Q2NSViz 0.1.0
Q2NS trace visualizer
Loading...
Searching...
No Matches
logic.py
Go to the documentation of this file.
1# -----------------------------------------------------------------------------
2# Q2NSViz - Quantum Network Trace Visualizer
3# Copyright (c) 2026 QuantumInternet.it
4#
5# This program is released under the MIT License - see LICENSE for details.
6# -----------------------------------------------------------------------------
7
8import bisect
9import json
10import logging
11from collections import defaultdict
12from collections.abc import Iterable
13from dataclasses import dataclass
14from pathlib import Path
15from typing import Any
16
17logger = logging.getLogger(__name__)
18
19
20# ---------------------------------------------------------------------------
21# Data structures
22# ---------------------------------------------------------------------------
23
24
25@dataclass
26class Node:
27 """A network node with a canvas-relative position.
28
29 @param label Unique identifier used in events and displayed on the canvas.
30 @param x_pct Horizontal position as a percentage [0, 100] of the layout area.
31 @param y_pct Vertical position as a percentage [0, 100] of the layout area.
32 @param has_explicit_position: True when (x, y) were set by a createNode event;
33 False triggers the automatic circular layout.
34 @ingroup q2nsviz_engine
35 """
36
37 label: str
38 x_pct: float
39 y_pct: float
40 has_explicit_position: bool = False
41
42
43@dataclass
44class Channel:
45 """A directed communication link between two network nodes.
46
47 @param from_node Label of the source node.
48 @param to_node Label of the destination node.
49 @param kind Channel type: ``"quantum"`` or ``"classical"``.
50 Unknown kinds are stored but not rendered by the canvas.
51 @ingroup q2nsviz_engine
52 """
53
54 from_node: str
55 to_node: str
56 kind: str # "quantum" or "classical"
57
58
59@dataclass
60class Qubit:
61 """A qubit residing at a network node, tracked by label.
62
63 @param label Unique identifier matching the Q2NS ``createQubit`` label field.
64 @param node Current host node label. Updated by ``sendQubit`` events.
65 ``None`` while the qubit is in transit.
66 @ingroup q2nsviz_engine
67 """
68
69 label: str
70 node: str | None = None
71
72
73@dataclass
75 """A classical bit residing at a network node, tracked by label.
76
77 @param label Unique identifier matching the Q2NS ``createCbit`` label field.
78 @param node Current host node label. Updated by ``sendCbit`` events.
79 ``None`` while the bit is in transit.
80 @ingroup q2nsviz_engine
81 """
82
83 label: str
84 node: str | None = None
85
86
87# ---------------------------------------------------------------------------
88# Replay checkpoint (used by the incremental replay engine)
89# ---------------------------------------------------------------------------
90
91
92@dataclass
93class _StateSnapshot:
94 """Full replay state captured at one checkpoint of the action stream.
95
96 Produced by ``_build_checkpoints()`` and consumed by ``snapshot_at()``,
97 which restores the nearest checkpoint at or before the requested time
98 and replays the remaining actions. Containers are stored as copies so
99 a restore cannot corrupt the stored snapshot. Operation-window sets
100 (inflight, gate, measuring) are stored as reference counters so that
101 overlapping windows on the same label survive a restore-and-continue.
102 @ingroup q2nsviz_engine
103 """
104
105 qubits: dict[str, Qubit]
106 cbits: dict[str, ClassicalBit]
107 ent_graph: dict[str, frozenset[str]]
108 measured_qubits: frozenset[str]
109 removed_qubits: frozenset[str]
110 discarded_qubits: frozenset[str]
111 removed_cbits: frozenset[str]
112 inflight_qubits: dict[str, int]
113 inflight_cbits: dict[str, int]
114 gate_qubits: dict[str, int]
115 measuring_qubits: dict[str, int]
116 graph_measuring_qubits: dict[str, int]
117
118
119# ---------------------------------------------------------------------------
120# Union-Find (for entanglement groups)
121# ---------------------------------------------------------------------------
122
123
125 """Disjoint-set data structure with path compression and union by rank.
126
127 Used by ``SimulationStateManager`` to compute the connected components of
128 the entanglement graph.
129 @ingroup q2nsviz_engine
130 """
131
132 def __init__(self):
133 self.parent: dict[str, str] = {}
134 self.rank: dict[str, int] = {}
135
136 def find(self, x: str) -> str:
137 """Find the root representative of the set containing *x*.
138
139 Inserts *x* into the structure on first access. Uses iterative
140 two-pass path compression so the call stack never grows with set size.
141
142 @param x Element label to look up.
143 @returns Root representative of the component containing *x*.
144 """
145 if x not in self.parent:
146 self.parent[x] = x
147 self.rank[x] = 0
148 root = x
149 while self.parent[root] != root:
150 if self.parent[root] not in self.parent:
151 self.parent[self.parent[root]] = self.parent[root]
152 self.rank[self.parent[root]] = 0
153 root = self.parent[root]
154 node = x
155 while node != root:
156 nxt = self.parent[node]
157 self.parent[node] = root
158 node = nxt
159 return root
160
161 def union(self, a: str, b: str):
162 """Merge the sets containing *a* and *b* using union by rank.
163
164 @param a First element label.
165 @param b Second element label.
166 """
167 ra, rb = self.find(a), self.find(b)
168 if ra == rb:
169 return
170 if self.rank[ra] < self.rank[rb]:
171 self.parent[ra] = rb
172 elif self.rank[ra] > self.rank[rb]:
173 self.parent[rb] = ra
174 else:
175 self.parent[rb] = ra
176 self.rank[ra] += 1
177
178 def groups(self) -> dict[str, list[str]]:
179 """Return all components as ``{root: [members]}``.
180
181 @returns Dictionary mapping each component root to its member list.
182 Every element appears in exactly one list.
183 """
184 result: dict[str, list[str]] = defaultdict(list)
185 for x in list(self.parent.keys()):
186 result[self.find(x)].append(x)
187 return dict(result)
188
189
190# ---------------------------------------------------------------------------
191# Public snapshot (returned by SimulationStateManager.snapshot_at)
192# ---------------------------------------------------------------------------
193
194
195@dataclass(frozen=True)
197 """Immutable view of the reconstructed network state at one instant.
198
199 Returned by ``SimulationStateManager.snapshot_at()``. This is the unit
200 of state that the ``QuantumVisualizerWindow`` controller dispatches to
201 the views, and the object a script inspects after reconstructing a
202 chosen simulation time. Dynamic containers are copies or immutable
203 views; advancing the manager afterwards never mutates an
204 already-returned ``Snapshot``.
205
206 @param t_ns Simulation timestamp this snapshot reflects.
207 @param nodes Node topology (label -> ``Node``), built once at load time.
208 @param channels Channel list, built once at load time.
209 @param qubits Every qubit created up to ``t_ns`` (label -> ``Qubit``, with its current host node).
210 @param cbits Every classical bit created up to ``t_ns`` (label -> ``ClassicalBit``).
211 @param ent_graph Entanglement-graph adjacency (label -> neighbor labels);
212 vertices with no edges are omitted.
213 @param entangled_states Entangled components of two or more qubits, keyed by the
214 lexicographically smallest member (root -> sorted member tuple).
215 @param measured_qubits Qubits measured at or before ``t_ns``.
216 @param removed_qubits Qubits explicitly removed from the simulation.
217 @param discarded_qubits Qubits removed with ``reason == "discarded"``.
218 @param lost_qubits ``removed_qubits - measured_qubits - discarded_qubits``.
219 @param live_qubit_labels Qubits neither measured nor removed.
220 @param inflight_qubits Qubits in transit inside a ``sendQubit`` window.
221 @param inflight_cbits Classical bits in transit inside a ``sendCbit`` window.
222 @param gate_qubits Qubits inside an ``entangle`` processing window
223 (``[t_ns - duration_ns, t_ns)``; the edges commit at ``t_ns``).
224 @param measuring_qubits Qubits inside a ``measure`` ``duration_ns`` window.
225 @param graph_measuring_qubits Qubits inside a ``graphMeasure`` ``duration_ns`` window.
226 @param removed_cbits Classical bits removed by ``removeCbit``.
227 @ingroup q2nsviz_engine
228 """
229
230 t_ns: int
231 nodes: dict[str, Node]
232 channels: list[Channel]
233 qubits: dict[str, Qubit]
234 cbits: dict[str, ClassicalBit]
235 ent_graph: dict[str, frozenset[str]]
236 entangled_states: dict[str, tuple[str, ...]]
237 measured_qubits: frozenset[str]
238 removed_qubits: frozenset[str]
239 discarded_qubits: frozenset[str]
240 lost_qubits: frozenset[str]
241 live_qubit_labels: frozenset[str]
242 inflight_qubits: frozenset[str]
243 inflight_cbits: frozenset[str]
244 gate_qubits: frozenset[str]
245 measuring_qubits: frozenset[str]
246 graph_measuring_qubits: frozenset[str]
247 removed_cbits: frozenset[str]
248
249
250# ---------------------------------------------------------------------------
251# Simulation State Manager
252# ---------------------------------------------------------------------------
253
254
256 """Replays a Q2NS simulation event stream and exposes the quantum state.
257
258 Usage pattern::
259
260 sm = SimulationStateManager()
261 sm.load_events(parsed_events)
262 snap = sm.snapshot_at(t_ns)
263 groups = snap.entangled_states
264
265 Key invariants
266 ---------------
267 - ``snapshot_at(t_ns)`` returns an immutable ``Snapshot`` carrying all
268 per-instant state; prefer its fields over the query helpers. The
269 helpers reflect the state positioned by the most recent
270 ``snapshot_at()`` call; calling them on a fresh instance returns
271 empty results silently.
272 - ``ent_graph`` is restored or advanced to the requested time on every
273 ``snapshot_at()`` call. Do not cache pointers to its contents.
274 - The class is **not** thread-safe; all calls must originate from the
275 Qt main thread.
276
277 JSON event types handled
278 -------------------------
279 ``createNode``, ``createChannel``,
280 ``createQubit``, ``sendQubit``, ``removeQubit``,
281 ``createCbit``, ``sendCbit``, ``removeCbit``,
282 ``entangle``, ``measure``, ``graphMeasure``,
283 ``sendPacket``, ``traceText``.
284 @ingroup q2nsviz_engine
285 """
286
287 # Actions replayed between stored checkpoints, bounds both the memory used
288 # by the checkpoint list and the replay cost of a backward jump.
289 _CHECKPOINT_INTERVAL = 256
290
291 def __init__(self):
292 """Initialize all tracking collections to empty state.
293
294 Call ``reset()`` to return to this state after loading events,
295 or ``load_events()`` to populate from a parsed trace file.
296 """
297 self.nodes: dict[str, Node] = {}
298 self.channels: list[Channel] = []
299 self.qubits: dict[str, Qubit] = {}
300 self.cbits: dict[str, ClassicalBit] = {}
301 self.ent_graph: dict[str, set[str]] = defaultdict(set)
302 self.measured_qubits: set[str] = set()
303 self.removed_qubits: set[str] = set()
304 self.discarded_qubits: set[str] = set()
305 self.removed_cbits: set[str] = set()
306 self.events: list[dict[str, Any]] = []
307 self.events_by_type: dict[str, list[dict[str, Any]]] = {}
308 self.log_events: list[dict[str, Any]] = []
309 self._log_times: list[int] = []
310 self.t_max: int = 0
311 self.time_array: list[int] = []
312 # Replay engine: (t_ns, event_idx, phase, kind, event) actions,
313 # sparse checkpoints, and the (t_ns, next_action_idx) live cursor.
314 self._actions: list[tuple[int, int, int, str, dict[str, Any]]] = []
315 self._checkpoints: list[tuple[int, _StateSnapshot]] = []
316 self._checkpoint_times: list[int] = []
317 self._cursor: tuple[int, int] | None = None
318 self._win_inflight_qubits: dict[str, int] = {}
319 self._win_inflight_cbits: dict[str, int] = {}
320 self._win_gate: dict[str, int] = {}
321 self._win_measuring: dict[str, int] = {}
322 self._win_graph_measuring: dict[str, int] = {}
323
324 def reset(self):
325 """Clear all loaded state."""
326 self.nodes.clear()
327 self.channels.clear()
328 self.events.clear()
329 self.events_by_type.clear()
330 self.log_events = []
331 self._log_times = []
332 self.t_max = 0
333 self.time_array.clear()
334 self._actions.clear()
335 self._checkpoints.clear()
336 self._checkpoint_times.clear()
337 self._cursor = None
339
340 # -- Event loading ------------------------------------------------------
341
342 def load_events(self, source: str | Path | list[dict[str, Any]]):
343 """Ingest a trace and build the simulation timeline.
344
345 Accepts either an already-parsed event list or a path to a trace
346 file; a path is parsed with ``EventFileParser`` first, with any
347 parse errors logged as warnings. Processes ``createNode`` and
348 ``createChannel`` events immediately; all other event types are
349 deferred to ``snapshot_at()``. Runs ``_validate_events()`` and logs
350 any detected issues as warnings.
351
352 @param source List of raw event dictionaries from
353 ``EventFileParser``, or the path of a JSON/NDJSON
354 trace file to load.
355
356 Calls ``reset()`` before loading, so any previously loaded state is
357 discarded. Each call to ``load_events()`` always starts from a clean
358 slate.
359 """
360 if isinstance(source, str | Path):
361 events, errors = EventFileParser.load_from_file(str(source))
362 for error in errors:
363 logger.warning("Trace parse: %s", error)
364 else:
365 events = source
366 self.reset()
367 self.events = events
368
369 issues = self._validate_events(events)
370 for issue in issues:
371 logger.warning("Trace validation: %s", issue)
372
373 seen_channels: set[tuple] = set()
374 for event in events:
375 event_type = event.get("type")
376
377 if event_type == "createNode":
378 label = event.get("label")
379 if not label:
380 logger.warning("createNode event missing 'label': %s", event)
381 continue
382 node = Node(
383 label=label,
384 x_pct=event.get("x", 0.0),
385 y_pct=event.get("y", 0.0),
386 has_explicit_position=("x" in event and "y" in event),
387 )
388 self.nodes[label] = node
389
390 elif event_type == "createChannel":
391 from_node = event.get("from")
392 to_node = event.get("to")
393 if not from_node or not to_node:
394 logger.warning("createChannel missing 'from'/'to': %s", event)
395 continue
396 kind = event.get("kind", "quantum")
397 # One undirected line is drawn per {pair, kind}; a link declared in
398 # both directions collapses to a single channel.
399 key = (*sorted((from_node, to_node)), kind)
400 if key in seen_channels:
401 continue
402 seen_channels.add(key)
403 self.channels.append(Channel(from_node=from_node, to_node=to_node, kind=kind))
404
405 self._build_index()
406 self._build_timeline()
407 self._build_actions()
408 self._build_checkpoints()
409
410 def _build_index(self):
411 """Group the events by type, and collect the ``traceText`` events in time order.
412
413 Both indexes are built once per load so that the per-frame consumers --
414 the canvas animation passes and ``get_log_events()`` -- never rescan the
415 full event list.
416 """
417 by_type: dict[str, list[dict[str, Any]]] = defaultdict(list)
418 for event in self.events:
419 by_type[event.get("type", "")].append(event)
420 self.events_by_type = dict(by_type)
421 self.log_events = sorted(self.events_by_type.get("traceText", []), key=lambda e: e.get("t_ns", 0))
422 self._log_times = [e.get("t_ns", 0) for e in self.log_events]
423
424 # -- Timeline -----------------------------------------------------------
425
426 def _build_timeline(self):
427 """Build a sorted array of keyframe times from the event list."""
428 self.t_max = 0
429 for event in self.events:
430 if "t_ns" in event and event["t_ns"] is not None:
431 self.t_max = max(self.t_max, event["t_ns"])
432 if "t1_ns" in event and event["t1_ns"] is not None:
433 self.t_max = max(self.t_max, event["t1_ns"])
434
435 time_set: set[int] = set()
436 for event in self.events:
437 if "t_ns" in event and event["t_ns"] is not None:
438 time_set.add(event["t_ns"])
439 if "t0_ns" in event and "t1_ns" in event and event["t0_ns"] is not None and event["t1_ns"] is not None:
440 t0, t1 = event["t0_ns"], event["t1_ns"]
441 time_set.add(t0)
442 time_set.add(t1)
443 # Interpolation frames for smooth animation
444 k_frames = 3
445 for k in range(1, k_frames + 1):
446 time_set.add(int(t0 + (k / (k_frames + 1)) * (t1 - t0)))
447 if "duration_ns" in event and event.get("t_ns") is not None:
448 duration = event["duration_ns"]
449 if duration and duration > 0:
450 t_end = event["t_ns"]
451 t_start = max(0, t_end - duration)
452 time_set.add(t_start)
453 # Midpoint keyframe, so stepping cannot skip over the operation ring
454 time_set.add((t_start + t_end) // 2)
455
456 self.time_array = sorted(time_set)
457
458 # -- Validation ---------------------------------------------------------
459
460 def _validate_events(self, events: list[dict[str, Any]]) -> list[str]:
461 """Validate the event stream and return a list of issue descriptions.
462
463 Checks performed:
464 - Timestamps are non-negative and non-decreasing.
465 - t0_ns <= t1_ns for movement events (sendQubit, sendCbit, sendPacket).
466 - Qubit and node labels referenced before createQubit / createNode.
467 - duration_ns windows (``[t_ns - duration_ns, t_ns)``) that reach back
468 before t=0 or before the referenced qubit existed.
469 """
470 issues: list[str] = []
471 known_bits: dict[str, int] = {}
472 known_nodes: set[str] = set()
473 last_t: int = -1
474
475 for idx, ev in enumerate(events):
476 ev_type = ev.get("type", "")
477 t = ev.get("t_ns")
478
479 if t is not None:
480 if t < 0:
481 issues.append(f"Event #{idx} ({ev_type}): negative t_ns={t}")
482 if t < last_t:
483 issues.append(
484 f"Event #{idx} ({ev_type}): t_ns={t} is before previous "
485 f"t_ns={last_t} \u2014 events are out of chronological order"
486 )
487 last_t = max(last_t, t)
488
489 if ev_type in {"sendQubit", "sendCbit", "sendPacket"}:
490 t0, t1 = ev.get("t0_ns"), ev.get("t1_ns")
491 if t0 is not None and t1 is not None and t0 > t1:
492 issues.append(f"Event #{idx} ({ev_type}): t0_ns={t0} > t1_ns={t1}")
493
494 if ev_type == "createNode":
495 label = ev.get("label")
496 if label:
497 known_nodes.add(label)
498
499 elif ev_type == "createChannel":
500 for field in ("from", "to"):
501 ref = ev.get(field)
502 if ref and ref not in known_nodes:
503 issues.append(f"Event #{idx} (createChannel): '{field}' references unknown node '{ref}'")
504
505 elif ev_type == "createQubit":
506 label = ev.get("label")
507 if not label:
508 issues.append(f"Event #{idx} (createQubit): missing 'label' field")
509 else:
510 known_bits[label] = t if t is not None else 0
511
512 elif ev_type == "createCbit":
513 label = ev.get("label")
514 if not label:
515 issues.append(f"Event #{idx} (createCbit): missing 'label' field")
516 else:
517 known_bits[label] = t if t is not None else 0
518
519 elif ev_type in {"measure", "graphMeasure", "removeQubit"}:
520 qubit_label = ev.get("bit")
521 if qubit_label and qubit_label not in known_bits:
522 issues.append(f"Event #{idx} ({ev_type}): qubit '{qubit_label}' referenced before createQubit")
523 if ev_type != "removeQubit" and qubit_label:
524 issues.extend(
525 self._window_issues(idx, ev_type, t, ev.get("duration_ns"), [qubit_label], known_bits)
526 )
527
528 elif ev_type in {"removeCbit"}:
529 cbit_label = ev.get("bit")
530 if cbit_label and cbit_label not in known_bits:
531 issues.append(f"Event #{idx} ({ev_type}): cbit '{cbit_label}' referenced before createCbit")
532
533 elif ev_type == "sendQubit":
534 qubit_label = ev.get("bit")
535 if qubit_label and qubit_label not in known_bits:
536 issues.append(f"Event #{idx} (sendQubit): qubit '{qubit_label}' referenced before createQubit")
537 for field in ("from", "to"):
538 ref = ev.get(field)
539 if ref and ref not in known_nodes:
540 issues.append(f"Event #{idx} (sendQubit): '{field}' references unknown node '{ref}'")
541
542 elif ev_type == "sendCbit":
543 cbit_label = ev.get("bit")
544 if cbit_label and cbit_label not in known_bits:
545 issues.append(f"Event #{idx} (sendCbit): cbit '{cbit_label}' referenced before createCbit")
546 for field in ("from", "to"):
547 ref = ev.get(field)
548 if ref and ref not in known_nodes:
549 issues.append(f"Event #{idx} (sendCbit): '{field}' references unknown node '{ref}'")
550
551 elif ev_type == "sendPacket":
552 for field in ("from", "to"):
553 ref = ev.get(field)
554 if ref and ref not in known_nodes:
555 issues.append(f"Event #{idx} (sendPacket): '{field}' references unknown node '{ref}'")
556
557 elif ev_type == "entangle":
558 for qubit_label in ev.get("bits", []):
559 if qubit_label not in known_bits:
560 issues.append(
561 f"Event #{idx} (entangle): qubit '{qubit_label}' referenced before createQubit"
562 )
563 issues.extend(
564 self._window_issues(idx, ev_type, t, ev.get("duration_ns"), ev.get("bits", []), known_bits)
565 )
566
567 return issues
568
569 @staticmethod
570 def _window_issues(
571 idx: int, ev_type: str, t: int | None, duration: int | None, bits: list[str], known_bits: dict[str, int]
572 ) -> list[str]:
573 """Check one durationed event's back-window ``[t - duration, t)`` for acausality."""
574 issues: list[str] = []
575 if t is None or not duration or duration <= 0:
576 return issues
577 t_start = t - duration
578 if t_start < 0:
579 issues.append(
580 f"Event #{idx} ({ev_type}): duration_ns={duration} exceeds t_ns={t} "
581 f"— processing window clipped at t=0"
582 )
583 for bit in bits:
584 created = known_bits.get(bit)
585 if created is not None and t_start < created:
586 issues.append(
587 f"Event #{idx} ({ev_type}): window [{t_start}, {t}) starts before "
588 f"createQubit of '{bit}' at t_ns={created} — acausal duration_ns"
589 )
590 return issues
591
592 # -- Replay engine ------------------------------------------------------
593
594 def _build_actions(self) -> None:
595 """Compile ``self.events`` into a time-sorted list of atomic state actions.
596
597 Each action is ``(t_ns, event_idx, phase, kind, event)``. Sorting by
598 ``(t_ns, event_idx, phase)`` preserves the file order of same-time
599 events, so order-sensitive semantics (``entangle`` toggling, measured
600 guards) replay exactly as in ``_snapshot_at_uncached()`` for
601 chronologically consistent traces. Events with a ``duration_ns``
602 window expand into start/end actions that maintain the operation-ring
603 sets; transfers expand into departure/arrival actions. Malformed
604 events are warned about once here and skipped.
605 @ingroup q2nsviz_engine
606 """
607 actions: list[tuple[int, int, int, str, dict[str, Any]]] = []
608 for idx, event in enumerate(self.events):
609 event_type = event.get("type")
610 t = event.get("t_ns", 0)
611
612 if event_type == "createQubit":
613 if not event.get("label"):
614 logger.warning("createQubit event missing 'label': %s", event)
615 continue
616 actions.append((t, idx, 0, "createQubit", event))
617
618 elif event_type == "createCbit":
619 if not event.get("label"):
620 logger.warning("createCbit event missing 'label': %s", event)
621 continue
622 actions.append((t, idx, 0, "createCbit", event))
623
624 elif event_type == "sendQubit":
625 if not event.get("bit"):
626 logger.warning("sendQubit event missing 'bit': %s", event)
627 continue
628 actions.append((event.get("t0_ns", 0), idx, 0, "transitStartQubit", event))
629 t1 = event.get("t1_ns")
630 if t1 is not None:
631 actions.append((t1, idx, 1, "arriveEndQubit", event))
632 else:
633 # Missing t1_ns: the reference replay sets the node from
634 # t=0 on and never closes the transit window.
635 actions.append((0, idx, 1, "arriveQubit", event))
636
637 elif event_type == "sendCbit":
638 if not event.get("bit"):
639 logger.warning("sendCbit event missing 'bit': %s", event)
640 continue
641 actions.append((event.get("t0_ns", 0), idx, 0, "transitStartCbit", event))
642 t1 = event.get("t1_ns")
643 if t1 is not None:
644 actions.append((t1, idx, 1, "arriveEndCbit", event))
645 else:
646 actions.append((0, idx, 1, "arriveCbit", event))
647
648 elif event_type == "entangle":
649 duration = event.get("duration_ns", 0) or 0
650 if duration > 0:
651 actions.append((max(0, t - duration), idx, 0, "gateStart", event))
652 actions.append((t, idx, 1, "gateEnd", event))
653 actions.append((t, idx, 2, "entangleCommit", event))
654
655 elif event_type == "measure":
656 duration = event.get("duration_ns", 0) or 0
657 if duration > 0:
658 actions.append((max(0, t - duration), idx, 0, "measureStart", event))
659 actions.append((t, idx, 1, "measureEnd", event))
660 actions.append((t, idx, 2, "measureCommit", event))
661
662 elif event_type == "graphMeasure":
663 duration = event.get("duration_ns", 0) or 0
664 if duration > 0:
665 actions.append((max(0, t - duration), idx, 0, "graphMeasureStart", event))
666 actions.append((t, idx, 1, "graphMeasureEnd", event))
667 actions.append((t, idx, 2, "graphMeasureCommit", event))
668
669 elif event_type == "removeQubit":
670 if not event.get("bit"):
671 logger.warning("removeQubit event missing 'bit': %s", event)
672 continue
673 actions.append((t, idx, 0, "removeQubit", event))
674
675 elif event_type == "removeCbit":
676 if not event.get("bit"):
677 logger.warning("removeCbit event missing 'bit': %s", event)
678 continue
679 actions.append((t, idx, 0, "removeCbit", event))
680
681 actions.sort(key=lambda a: (a[0], a[1], a[2]))
682 self._actions = actions
683
684 @staticmethod
685 def _counter_inc(counter: dict[str, int], key: str) -> None:
686 counter[key] = counter.get(key, 0) + 1
687
688 @staticmethod
689 def _counter_dec(counter: dict[str, int], key: str) -> None:
690 n = counter.get(key, 0)
691 if n <= 1:
692 counter.pop(key, None)
693 else:
694 counter[key] = n - 1
695
696 def _apply_action(self, kind: str, event: dict[str, Any]) -> None:
697 """Apply one atomic action to the live replay state."""
698 if kind == "createQubit":
699 label = event["label"]
700 self.qubits[label] = Qubit(label=label, node=event.get("node"))
701 elif kind == "createCbit":
702 label = event["label"]
703 self.cbits[label] = ClassicalBit(label=label, node=event.get("node"))
704 elif kind == "transitStartQubit":
705 self._counter_inc(self._win_inflight_qubits, event["bit"])
706 elif kind == "arriveEndQubit":
707 bit = event["bit"]
708 if bit in self.qubits:
709 self.qubits[bit].node = event.get("to")
710 self._counter_dec(self._win_inflight_qubits, bit)
711 elif kind == "arriveQubit":
712 bit = event["bit"]
713 if bit in self.qubits:
714 self.qubits[bit].node = event.get("to")
715 elif kind == "transitStartCbit":
716 self._counter_inc(self._win_inflight_cbits, event["bit"])
717 elif kind == "arriveEndCbit":
718 bit = event["bit"]
719 if bit in self.cbits:
720 self.cbits[bit].node = event.get("to")
721 self._counter_dec(self._win_inflight_cbits, bit)
722 elif kind == "arriveCbit":
723 bit = event["bit"]
724 if bit in self.cbits:
725 self.cbits[bit].node = event.get("to")
726 elif kind == "gateStart":
727 for label in event.get("bits", []):
728 self._counter_inc(self._win_gate, label)
729 elif kind == "gateEnd":
730 for label in event.get("bits", []):
731 self._counter_dec(self._win_gate, label)
732 elif kind == "entangleCommit":
733 labels = event.get("bits", [])
734 for i in range(len(labels)):
735 for j in range(i + 1, len(labels)):
736 self._ent_graph_add_edge(labels[i], labels[j])
737 elif kind == "measureStart":
738 bit = event.get("bit")
739 if bit:
740 self._counter_inc(self._win_measuring, bit)
741 elif kind == "measureEnd":
742 bit = event.get("bit")
743 if bit:
744 self._counter_dec(self._win_measuring, bit)
745 elif kind == "measureCommit":
746 bit = event.get("bit")
747 if bit:
748 self.measured_qubits.add(bit)
750 elif kind == "graphMeasureStart":
751 bit = event.get("bit")
752 if bit:
753 self._counter_inc(self._win_graph_measuring, bit)
754 elif kind == "graphMeasureEnd":
755 bit = event.get("bit")
756 if bit:
757 self._counter_dec(self._win_graph_measuring, bit)
758 elif kind == "graphMeasureCommit":
759 target = event.get("bit")
760 if target:
761 self.measured_qubits.add(target)
762 self._perform_graph_measurement(target, event.get("base", "Z"), event.get("supportNode"))
763 elif kind == "removeQubit":
764 bit = event["bit"]
765 self.removed_qubits.add(bit)
766 if event.get("reason") == "discarded":
767 self.discarded_qubits.add(bit)
769 elif kind == "removeCbit":
770 self.removed_cbits.add(event["bit"])
771
772 def _clear_replay_state(self) -> None:
773 """Reset the live replay state to the pre-simulation (t < 0) state."""
774 self.qubits.clear()
775 self.cbits.clear()
776 self.ent_graph.clear()
777 self.measured_qubits.clear()
778 self.removed_qubits.clear()
779 self.discarded_qubits.clear()
780 self.removed_cbits.clear()
781 self._win_inflight_qubits.clear()
782 self._win_inflight_cbits.clear()
783 self._win_gate.clear()
784 self._win_measuring.clear()
785 self._win_graph_measuring.clear()
786
787 def _capture_snapshot(self) -> _StateSnapshot:
788 """Copy the live replay state into an immutable checkpoint."""
789 return _StateSnapshot(
790 qubits={k: Qubit(label=v.label, node=v.node) for k, v in self.qubits.items()},
791 cbits={k: ClassicalBit(label=v.label, node=v.node) for k, v in self.cbits.items()},
792 ent_graph={k: frozenset(v) for k, v in self.ent_graph.items()},
793 measured_qubits=frozenset(self.measured_qubits),
794 removed_qubits=frozenset(self.removed_qubits),
795 discarded_qubits=frozenset(self.discarded_qubits),
796 removed_cbits=frozenset(self.removed_cbits),
797 inflight_qubits=dict(self._win_inflight_qubits),
798 inflight_cbits=dict(self._win_inflight_cbits),
799 gate_qubits=dict(self._win_gate),
800 measuring_qubits=dict(self._win_measuring),
801 graph_measuring_qubits=dict(self._win_graph_measuring),
802 )
803
804 def _restore_snapshot(self, snap: _StateSnapshot) -> None:
805 """Replace the live replay state with fresh mutable copies of *snap*."""
806 self.qubits = {k: Qubit(label=v.label, node=v.node) for k, v in snap.qubits.items()}
807 self.cbits = {k: ClassicalBit(label=v.label, node=v.node) for k, v in snap.cbits.items()}
808 self.ent_graph = defaultdict(set, {k: set(v) for k, v in snap.ent_graph.items()})
809 self.measured_qubits = set(snap.measured_qubits)
810 self.removed_qubits = set(snap.removed_qubits)
811 self.discarded_qubits = set(snap.discarded_qubits)
812 self.removed_cbits = set(snap.removed_cbits)
813 self._win_inflight_qubits = dict(snap.inflight_qubits)
814 self._win_inflight_cbits = dict(snap.inflight_cbits)
815 self._win_gate = dict(snap.gate_qubits)
816 self._win_measuring = dict(snap.measuring_qubits)
817 self._win_graph_measuring = dict(snap.graph_measuring_qubits)
818
819 def _build_checkpoints(self) -> None:
820 """Replay the action stream once, storing sparse checkpoints.
821
822 A checkpoint is stored after roughly every ``_CHECKPOINT_INTERVAL``
823 applied actions, always on a boundary between distinct timestamps so
824 that a restored checkpoint reflects every action at its time. Memory
825 is O(actions / interval) snapshots; traces shorter than the interval
826 store none and replay from the empty state on demand.
827
828 Called automatically at the end of ``load_events()``. Leaves the
829 live state cleared; ``snapshot_at()`` positions it on first use.
830 @ingroup q2nsviz_engine
831 """
832 self._checkpoints.clear()
833 self._checkpoint_times.clear()
835 actions = self._actions
836 n = len(actions)
837 i = 0
838 since_checkpoint = 0
839 while i < n:
840 t_block = actions[i][0]
841 while i < n and actions[i][0] == t_block:
842 self._apply_action(actions[i][3], actions[i][4])
843 i += 1
844 since_checkpoint += 1
845 if since_checkpoint >= self._CHECKPOINT_INTERVAL and i < n:
846 self._checkpoint_times.append(t_block)
847 self._checkpoints.append((i, self._capture_snapshot()))
848 since_checkpoint = 0
850 self._cursor = None
851
852 # -- State reduction ----------------------------------------------------
853
854 def _make_snapshot(
855 self,
856 t_ns: int,
857 *,
858 inflight_qubits: Iterable[str],
859 gate_qubits: Iterable[str],
860 measuring_qubits: Iterable[str],
861 graph_measuring_qubits: Iterable[str],
862 inflight_cbits: Iterable[str],
863 ) -> Snapshot:
864 """Package the current live replay state as an immutable ``Snapshot``.
865
866 A pure function of the live state: both the incremental engine
867 (``snapshot_at()``) and the from-scratch reference
868 (``_snapshot_at_uncached()``) position the state independently and
869 then delegate here, so shared packaging cannot mask a divergence
870 between the two replay paths. The operation-window sets are computed
871 by each caller and passed in. Empty adjacency entries are dropped
872 from ``ent_graph`` and entangled components are keyed by their
873 smallest member, so snapshots from the two paths compare equal
874 whenever the underlying state matches.
875 @ingroup q2nsviz_engine
876 """
877 measured = frozenset(self.measured_qubits)
878 removed = frozenset(self.removed_qubits)
879 discarded = frozenset(self.discarded_qubits)
880 groups = self.get_entangled_states()
881 return Snapshot(
882 t_ns=t_ns,
883 nodes=self.nodes,
884 channels=self.channels,
885 qubits={k: Qubit(label=v.label, node=v.node) for k, v in self.qubits.items()},
886 cbits={k: ClassicalBit(label=v.label, node=v.node) for k, v in self.cbits.items()},
887 ent_graph={q: frozenset(nbrs) for q, nbrs in self.ent_graph.items() if nbrs},
888 entangled_states={min(members): tuple(sorted(members)) for members in groups.values()},
889 measured_qubits=measured,
890 removed_qubits=removed,
891 discarded_qubits=discarded,
892 lost_qubits=removed - measured - discarded,
893 live_qubit_labels=frozenset(q for q in self.qubits if q not in removed and q not in measured),
894 inflight_qubits=frozenset(inflight_qubits),
895 inflight_cbits=frozenset(inflight_cbits),
896 gate_qubits=frozenset(gate_qubits),
897 measuring_qubits=frozenset(measuring_qubits),
898 graph_measuring_qubits=frozenset(graph_measuring_qubits),
899 removed_cbits=frozenset(self.removed_cbits),
900 )
901
902 def seek(self, t_ns: int) -> None:
903 """Position the replay state at *t_ns* without packaging a ``Snapshot``.
904
905 Moving forward in time continues incrementally from the last queried
906 position; moving backward restores the nearest checkpoint at or before
907 *t_ns* and replays the gap (at most ``_CHECKPOINT_INTERVAL`` actions
908 after a restore). Valid for arbitrary timestamps, not only keyframes
909 in ``time_array``.
910
911 This is the O(advanced-actions) positioning primitive shared by
912 ``snapshot_at()``. Use it directly for bulk timeline scans where the
913 O(#qubits) packaging cost of a full ``Snapshot`` per step would
914 dominate, then read the query helpers (``get_entangled_states()``
915 and friends) on the positioned state.
916
917 @param t_ns Simulation timestamp in nanoseconds.
918 @ingroup q2nsviz_engine
919 """
920 actions = self._actions
921 if self._cursor is not None and t_ns >= self._cursor[0]:
922 i = self._cursor[1]
923 else:
924 pos = bisect.bisect_right(self._checkpoint_times, t_ns) - 1
925 if pos >= 0:
926 i, snap = self._checkpoints[pos]
927 self._restore_snapshot(snap)
928 else:
930 i = 0
931 n = len(actions)
932 while i < n and actions[i][0] <= t_ns:
933 self._apply_action(actions[i][3], actions[i][4])
934 i += 1
935 self._cursor = (t_ns, i)
936
937 def snapshot_at(self, t_ns: int) -> Snapshot:
938 """Advance or restore the replay state to *t_ns* and return a ``Snapshot``.
939
940 Positions the state via ``seek()`` and packages it as an immutable
941 ``Snapshot``. This is the per-instant entry point used by the
942 ``QuantumVisualizerWindow`` controller and by scripts.
943
944 ``_snapshot_at_uncached()`` is retained as the from-scratch reference
945 implementation and produces identical results for chronologically
946 consistent traces.
947
948 @param t_ns Simulation timestamp in nanoseconds.
949 @returns ``Snapshot`` of the reconstructed state at *t_ns*.
950 @ingroup q2nsviz_engine
951 """
952 self.seek(t_ns)
953 return self._make_snapshot(
954 t_ns,
955 inflight_qubits=self._win_inflight_qubits,
956 gate_qubits=self._win_gate,
957 measuring_qubits=self._win_measuring,
958 graph_measuring_qubits=self._win_graph_measuring,
959 inflight_cbits=self._win_inflight_cbits,
960 )
961
962 def _snapshot_at_uncached(self, t_ns: int) -> Snapshot:
963 """Replay all events up to *t_ns* from scratch and return a ``Snapshot``.
964
965 The from-scratch reference implementation used to verify the incremental
966 replay engine; normal playback uses ``snapshot_at()``. Durationed events
967 (``entangle``, ``measure``, ``graphMeasure``) are stamped at operation
968 completion: the state transition commits at ``t_ns``, with the operation
969 ring shown during ``[t_ns - duration_ns, t_ns)``. O(N) in the number of
970 events.
971
972 @param t_ns Simulation timestamp in nanoseconds.
973 @returns ``Snapshot`` of the reconstructed state at *t_ns*.
974 """
975 # From-scratch replay leaves the incremental cursor stale; invalidate it
976 # so a later snapshot_at() restores from a checkpoint instead.
977 self._cursor = None
978 self.qubits.clear()
979 self.cbits.clear()
980 self.ent_graph.clear()
981 self.measured_qubits.clear()
982 self.removed_qubits.clear()
983 self.discarded_qubits.clear()
984 self.removed_cbits.clear()
985 inflight_qubits: set[str] = set()
986 inflight_cbits: set[str] = set()
987 gate_qubits: set[str] = set()
988 measuring_qubits: set[str] = set()
989 graph_measuring_qubits: set[str] = set()
990
991 for event in self.events:
992 event_type = event.get("type")
993 event_t = event.get("t_ns", 0)
994
995 if event_type == "createQubit" and event_t <= t_ns:
996 label = event.get("label")
997 if not label:
998 logger.warning("createQubit event missing 'label': %s", event)
999 continue
1000 qubit = Qubit(label=label, node=event.get("node"))
1001 self.qubits[label] = qubit
1002
1003 elif event_type == "sendQubit":
1004 qubit_label = event.get("bit")
1005 if not qubit_label:
1006 logger.warning("sendQubit event missing 'bit': %s", event)
1007 continue
1008 if event.get("t1_ns", 0) <= t_ns and qubit_label in self.qubits:
1009 self.qubits[qubit_label].node = event.get("to")
1010 if event.get("t0_ns", 0) <= t_ns < event.get("t1_ns", float("inf")):
1011 inflight_qubits.add(qubit_label)
1012
1013 elif event_type == "createCbit" and event_t <= t_ns:
1014 label = event.get("label")
1015 if not label:
1016 logger.warning("createCbit event missing 'label': %s", event)
1017 continue
1018 cbit = ClassicalBit(label=label, node=event.get("node"))
1019 self.cbits[label] = cbit
1020
1021 elif event_type == "sendCbit":
1022 cbit_label = event.get("bit")
1023 if not cbit_label:
1024 logger.warning("sendCbit event missing 'bit': %s", event)
1025 continue
1026 if event.get("t1_ns", 0) <= t_ns and cbit_label in self.cbits:
1027 self.cbits[cbit_label].node = event.get("to")
1028 if event.get("t0_ns", 0) <= t_ns < event.get("t1_ns", float("inf")):
1029 inflight_cbits.add(cbit_label)
1030
1031 elif event_type == "entangle":
1032 duration_ns = event.get("duration_ns", 0) or 0
1033 t_start = max(0, event_t - duration_ns)
1034 # The duration window marks the gate's processing time (the halo);
1035 # the entanglement edges themselves commit at event_t below.
1036 if t_start <= t_ns < event_t:
1037 for qubit_label in event.get("bits", []):
1038 gate_qubits.add(qubit_label)
1039 if event_t <= t_ns:
1040 qubit_labels = event.get("bits", [])
1041 for i in range(len(qubit_labels)):
1042 for j in range(i + 1, len(qubit_labels)):
1043 self._ent_graph_add_edge(qubit_labels[i], qubit_labels[j])
1044
1045 elif event_type == "measure":
1046 duration_ns = event.get("duration_ns", 0) or 0
1047 t_start = max(0, event_t - duration_ns)
1048 if t_start <= t_ns < event_t:
1049 qubit_label = event.get("bit")
1050 if qubit_label:
1051 measuring_qubits.add(qubit_label)
1052 if event_t <= t_ns:
1053 qubit_label = event.get("bit")
1054 if qubit_label:
1055 self.measured_qubits.add(qubit_label)
1056 self._ent_graph_remove_vertex(qubit_label)
1057
1058 elif event_type == "graphMeasure":
1059 duration_ns = event.get("duration_ns", 0) or 0
1060 t_start = max(0, event_t - duration_ns)
1061 if t_start <= t_ns < event_t:
1062 target = event.get("bit")
1063 if target:
1064 graph_measuring_qubits.add(target)
1065 if event_t <= t_ns:
1066 target = event.get("bit")
1067 if target:
1068 self.measured_qubits.add(target)
1069 self._perform_graph_measurement(target, event.get("base", "Z"), event.get("supportNode"))
1070
1071 elif event_type == "removeQubit" and event_t <= t_ns:
1072 qubit_label = event.get("bit")
1073 if not qubit_label:
1074 logger.warning("removeQubit event missing 'bit': %s", event)
1075 continue
1076 self.removed_qubits.add(qubit_label)
1077 if event.get("reason") == "discarded":
1078 self.discarded_qubits.add(qubit_label)
1079 self._ent_graph_remove_vertex(qubit_label)
1080
1081 elif event_type == "removeCbit" and event_t <= t_ns:
1082 cbit_label = event.get("bit")
1083 if not cbit_label:
1084 logger.warning("removeCbit event missing 'bit': %s", event)
1085 continue
1086 self.removed_cbits.add(cbit_label)
1087
1088 return self._make_snapshot(
1089 t_ns,
1090 inflight_qubits=inflight_qubits,
1091 gate_qubits=gate_qubits,
1092 measuring_qubits=measuring_qubits,
1093 graph_measuring_qubits=graph_measuring_qubits,
1094 inflight_cbits=inflight_cbits,
1095 )
1096
1097 # --- Entanglement graph helpers -----------------------------------------
1098
1099 def _ent_graph_add_edge(self, qubit1: str, qubit2: str):
1100 """Toggle the entanglement edge between *qubit1* and *qubit2*.
1101
1102 Models a generic entangling operation: applying it twice is the
1103 identity, so a second ``entangle`` event on the same pair
1104 dis-entangles them.
1105 """
1106 if qubit1 in self.measured_qubits or qubit2 in self.measured_qubits:
1107 logger.warning("Ignoring entangle event: %s or %s is already measured", qubit1, qubit2)
1108 return
1109 if qubit1 in self.removed_qubits or qubit2 in self.removed_qubits:
1110 logger.warning("Ignoring entangle event: %s or %s is already removed/lost", qubit1, qubit2)
1111 return
1112 if qubit2 in self.ent_graph.get(qubit1, set()):
1113 self.ent_graph[qubit1].discard(qubit2)
1114 self.ent_graph[qubit2].discard(qubit1)
1115 else:
1116 self.ent_graph[qubit1].add(qubit2)
1117 self.ent_graph[qubit2].add(qubit1)
1118
1119 def _ent_graph_remove_vertex(self, qubit: str):
1120 """Remove *qubit* from the entanglement graph and clean up its edges."""
1121 if qubit in self.ent_graph:
1122 for connected in self.ent_graph[qubit]:
1123 if connected in self.ent_graph:
1124 self.ent_graph[connected].discard(qubit)
1125 del self.ent_graph[qubit]
1126
1127 def _ent_graph_local_complement(self, target: str):
1128 """Toggle all edges between neighbors of *target* (graph-state LC op)."""
1129 if target not in self.ent_graph:
1130 logger.warning("Qubit %s not found in entanglement graph", target)
1131 return
1132
1133 neighbors = list(self.ent_graph[target])
1134 logger.debug("Local complementation on %s, neighbors: %s", target, neighbors)
1135
1136 for i in range(len(neighbors)):
1137 for j in range(i + 1, len(neighbors)):
1138 n1, n2 = neighbors[i], neighbors[j]
1139 if n2 in self.ent_graph.get(n1, set()):
1140 self.ent_graph[n1].discard(n2)
1141 self.ent_graph[n2].discard(n1)
1142 logger.debug(" Removed edge: [%s, %s]", n1, n2)
1143 else:
1144 self.ent_graph[n1].add(n2)
1145 self.ent_graph[n2].add(n1)
1146 logger.debug(" Added edge: [%s, %s]", n1, n2)
1147
1148 def _perform_graph_measurement(self, target: str, basis: str, support_node: str | None = None):
1149 """Execute a graph-state measurement in the given Pauli basis."""
1150 basis = basis.upper()
1151
1152 if basis == "Z":
1153 logger.info("Graph-state Z measurement on %s", target)
1154 self._ent_graph_remove_vertex(target)
1155
1156 elif basis == "Y":
1157 logger.info("Graph-state Y measurement on %s", target)
1158 self._ent_graph_local_complement(target)
1159 self._ent_graph_remove_vertex(target)
1160
1161 elif basis == "X":
1162 logger.info("Graph-state X measurement on %s", target)
1163 if target not in self.ent_graph:
1164 logger.warning("Qubit %s not found in entanglement graph", target)
1165 return
1166 neighbors = sorted(self.ent_graph[target])
1167 if neighbors:
1168 if support_node is None or support_node not in neighbors:
1169 logger.warning(
1170 "graphMeasure on '%s' (X-basis): no valid supportNode in trace "
1171 "(got %r, neighbors: %s). Falling back to alphabetical ('%s'). "
1172 "Displayed topology may differ from the simulator's "
1173 "internal representation.",
1174 target,
1175 support_node,
1176 neighbors,
1177 neighbors[0],
1178 )
1179 support_node = neighbors[0]
1180 logger.debug("Adjacent: %s, support node: %s", neighbors, support_node)
1181 self._ent_graph_local_complement(support_node)
1182 self._ent_graph_local_complement(target)
1183 self._ent_graph_remove_vertex(target)
1184 self._ent_graph_local_complement(support_node)
1185 else:
1186 self._ent_graph_remove_vertex(target)
1187 logger.debug("Qubit %s had no neighbors; removed", target)
1188
1189 else:
1190 logger.warning("Unknown basis '%s', defaulting to Z measurement", basis)
1191 self._ent_graph_remove_vertex(target)
1192
1193 # --- Query helpers ------------------------------------------------------
1194
1195 def get_entanglement_groups(self) -> dict[str, list[str]]:
1196 """Return all entanglement components as ``{root: [members]}``.
1197
1198 Must be called *after* ``snapshot_at(t_ns)`` so that ``ent_graph``
1199 and ``qubits`` reflect the desired simulation time.
1200
1201 Computes connected components of ``ent_graph`` via
1202 ``UnionFind``, excluding measured and removed qubits. The result
1203 therefore correctly reflects graph-state measurements (X/Y/Z) and
1204 any bridge qubits whose removal splits the graph.
1205
1206 @returns Dictionary mapping each component root label to the
1207 sorted list of qubit labels in that component.
1208 """
1209 gone = self.removed_qubits | self.measured_qubits
1210 uf = UnionFind()
1211 for q in self.qubits:
1212 if q not in gone:
1213 uf.find(q)
1214 for q, neighbors in self.ent_graph.items():
1215 if q in gone:
1216 continue
1217 for nbr in neighbors:
1218 if nbr not in gone:
1219 uf.union(q, nbr)
1220 return dict(uf.groups())
1221
1222 def get_entangled_states(self) -> dict[str, list[str]]:
1223 """Return entanglement components with two or more qubits.
1224
1225 Traverses only the entanglement graph, so the cost scales with the
1226 number of live entanglement edges rather than with every qubit
1227 created so far (``get_entanglement_groups()`` also enrolls singleton
1228 components and therefore scans the full qubit registry).
1229
1230 @returns ``{root: members}`` for every component with two or more
1231 qubits; equal, as member sets, to filtering
1232 ``get_entanglement_groups()`` by ``len(members) >= 2``.
1233 """
1234 # Excluding "measured or lost" qubits reduces to "measured or removed";
1235 # plain membership tests avoid an O(all qubits) set subtraction per call.
1236 uf = UnionFind()
1237 for q, neighbors in self.ent_graph.items():
1238 if q in self.measured_qubits or q in self.removed_qubits:
1239 continue
1240 for nbr in neighbors:
1241 if nbr not in self.measured_qubits and nbr not in self.removed_qubits:
1242 uf.union(q, nbr)
1243 return {root: members for root, members in uf.groups().items() if len(members) > 1}
1244
1245 def log_count_at(self, t_ns: int) -> int:
1246 """Return how many ``traceText`` events have a timestamp <= *t_ns*.
1247
1248 The log grows monotonically with time, so the GUI uses this count to
1249 append only the new lines instead of rebuilding the whole log view.
1250
1251 @param t_ns Cut-off time in nanoseconds.
1252 @returns Index into ``log_events`` one past the last visible entry.
1253 """
1254 return bisect.bisect_right(self._log_times, t_ns)
1255
1256 def get_log_events(self, t_ns: int) -> list[dict[str, Any]]:
1257 """Return all ``traceText`` events with timestamp <= *t_ns*, in time order.
1258
1259 @param t_ns Cut-off time in nanoseconds.
1260 @returns List of event dicts with ``type == "traceText"``.
1261 """
1262 return self.log_events[: self.log_count_at(t_ns)]
1263
1264
1265# ---------------------------------------------------------------------------
1266# Event file parser
1267# ---------------------------------------------------------------------------
1268
1269
1271 """Reads Q2NS simulation trace files in NDJSON or JSON-array format.
1272
1273 Both formats are auto-detected by inspecting the first non-whitespace
1274 character of the content string:
1275
1276 - JSON array: content begins with ``[``; the entire file is parsed as
1277 a single ``json.loads`` call.
1278 - NDJSON: each non-empty line is an independent JSON object.
1279 @ingroup q2nsviz_engine
1280 """
1281
1282 @staticmethod
1283 def parse(content: str) -> tuple[list[dict[str, Any]], list[str]]:
1284 """Parse a string containing a Q2NS trace in NDJSON or JSON-array format.
1285
1286 @param content Raw file content as a Unicode string.
1287 @returns ``(events, errors)`` where *events* is the list of
1288 successfully parsed event dicts and *errors* lists any
1289 human-readable parse failures (empty on success).
1290 """
1291 content = content.strip()
1292 if not content:
1293 return [], []
1294
1295 if content.startswith("["):
1296 try:
1297 data = json.loads(content)
1298 if isinstance(data, list):
1299 return data, []
1300 except json.JSONDecodeError as exc:
1301 logger.error("Failed to parse JSON array: %s", exc)
1302 return [], [f"JSON array parse error: {exc}"]
1303
1304 events: list[dict[str, Any]] = []
1305 errors: list[str] = []
1306 for line_no, line in enumerate(content.split("\n"), start=1):
1307 line = line.strip()
1308 if not line:
1309 continue
1310 try:
1311 events.append(json.loads(line))
1312 except json.JSONDecodeError as exc:
1313 msg = f"Line {line_no}: {exc}"
1314 logger.error("NDJSON parse error: %s", msg)
1315 errors.append(msg)
1316 return events, errors
1317
1318 @staticmethod
1319 def load_from_file(filepath: str) -> tuple[list[dict[str, Any]], list[str]]:
1320 """Read a simulation trace file from disk and delegate to ``parse()``.
1321
1322 @param filepath Absolute or relative path to the trace file.
1323 Must be UTF-8 encoded.
1324 @returns Same ``(events, errors)`` tuple as ``parse()``.
1325 @warning Raises ``OSError`` if the file cannot be opened.
1326 """
1327 with open(filepath, encoding="utf-8") as fh:
1328 return EventFileParser.parse(fh.read())
A directed communication link between two network nodes.
Definition logic.py:44
A classical bit residing at a network node, tracked by label.
Definition logic.py:74
Reads Q2NS simulation trace files in NDJSON or JSON-array format.
Definition logic.py:1270
tuple[list[dict[str, Any]], list[str]] load_from_file(str filepath)
Read a simulation trace file from disk and delegate to parse().
Definition logic.py:1319
tuple[list[dict[str, Any]], list[str]] parse(str content)
Parse a string containing a Q2NS trace in NDJSON or JSON-array format.
Definition logic.py:1283
A network node with a canvas-relative position.
Definition logic.py:26
A qubit residing at a network node, tracked by label.
Definition logic.py:60
Replays a Q2NS simulation event stream and exposes the quantum state.
Definition logic.py:255
list[dict[str, Any]] get_log_events(self, int t_ns)
Return all traceText events with timestamp <= t_ns, in time order.
Definition logic.py:1256
load_events(self, str|Path|list[dict[str, Any]] source)
Ingest a trace and build the simulation timeline.
Definition logic.py:342
int log_count_at(self, int t_ns)
Return how many traceText events have a timestamp <= t_ns.
Definition logic.py:1245
dict[str, list[str]] get_entanglement_groups(self)
Return all entanglement components as {root: [members]}.
Definition logic.py:1195
dict[str, list[str]] get_entangled_states(self)
Return entanglement components with two or more qubits.
Definition logic.py:1222
reset(self)
Clear all loaded state.
Definition logic.py:324
Immutable view of the reconstructed network state at one instant.
Definition logic.py:196
Disjoint-set data structure with path compression and union by rank.
Definition logic.py:124
str find(self, str x)
Find the root representative of the set containing x.
Definition logic.py:136
union(self, str a, str b)
Merge the sets containing a and b using union by rank.
Definition logic.py:161
dict[str, list[str]] groups(self)
Return all components as {root: [members]}.
Definition logic.py:178
Snapshot snapshot_at(self, int t_ns)
Advance or restore the replay state to t_ns and return a Snapshot.
Definition logic.py:937
None seek(self, int t_ns)
Position the replay state at t_ns without packaging a Snapshot.
Definition logic.py:902