Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **Development Tooling**: Use uv as the default dependency manager for local development, CI, documentation builds, and publishing workflows.
- **Graph State API**: Replaced legacy `physical_*` graph methods/properties with standard graph-style `nodes`, `edges`, `add_node()`, `add_edge()`, `remove_node()`, `remove_edge()`, and count/query helpers.
- **Pattern Simulator**: Materialize pending output Pauli-frame corrections when returning output statevectors or explicit output measurement results.

### Removed

- **Pattern Commands**: Removed explicit `graphqomb.command.X` and `graphqomb.command.Z` correction commands. Output corrections are now represented by `PauliFrame` only, and `.ptn` files containing `X`/`Z` command lines are rejected.

## [0.3.1] - 2026-05-17

Expand Down
4 changes: 3 additions & 1 deletion docs/source/architecture.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ The lowered pattern combines:
- a :class:`graphqomb.pauli_frame.PauliFrame` used for dependency tracking,
- derived metrics such as depth, space usage, and active volume.

Most scheduled work is serialized as prepare, entangle, and measure commands separated by ``TICK`` slice boundaries. In the current implementation, terminal ``X``/``Z`` correction commands may also remain for unmeasured outputs, so the executable command stream is close to, but not strictly limited to, ``N``, ``E``, ``M``, and ``TICK``.
Most scheduled work is serialized as prepare, entangle, and measure commands separated by ``TICK`` slice boundaries.
Pauli corrections are retained in the :class:`graphqomb.pauli_frame.PauliFrame` rather than emitted as ``X``/``Z`` commands, so the executable command stream is limited to ``N``, ``E``, ``M``, and ``TICK``.
The pattern simulator materializes pending output-frame corrections when returning an output statevector or an explicit output measurement result.

Feedforward and causality
-------------------------
Expand Down
6 changes: 0 additions & 6 deletions docs/source/command.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,6 @@ Command Classes
.. autoclass:: graphqomb.command.M
:members:

.. autoclass:: graphqomb.command.X
:members:

.. autoclass:: graphqomb.command.Z
:members:

.. autoclass:: graphqomb.command.TICK
:members:

Expand Down
37 changes: 1 addition & 36 deletions graphqomb/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
- `N`: Preparation command.
- `E`: Entanglement command.
- `M`: Measurement command.
- `X`: X correction command.
- `Z`: Z correction command.
- `TICK`: Time slice separator command.
- `Command`: Type alias of all commands.
"""
Expand Down Expand Up @@ -76,39 +74,6 @@ def __str__(self) -> str:
return f"M: node={self.node}, plane={self.meas_basis.plane}, angle={self.meas_basis.angle}"


@dataclasses.dataclass
class _Correction:
node: int


@dataclasses.dataclass
class X(_Correction):
"""X correction command.

Attributes
----------
node : `int`
The node index to apply the correction.
"""

def __str__(self) -> str:
return f"X: node={self.node}"


@dataclasses.dataclass
class Z(_Correction):
"""Z correction command.

Attributes
----------
node : `int`
The node index to apply the correction.
"""

def __str__(self) -> str:
return f"Z: node={self.node}"


@dataclasses.dataclass
class TICK:
"""Time slice separator command.
Expand All @@ -121,4 +86,4 @@ def __str__(self) -> str:
return "TICK"


Command = N | E | M | X | Z | TICK
Command = N | E | M | TICK
19 changes: 8 additions & 11 deletions graphqomb/pattern.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from collections.abc import Sequence
from typing import TYPE_CHECKING

from graphqomb.command import TICK, Command, E, M, N, X, Z
from graphqomb.command import TICK, Command, E, M, N

if TYPE_CHECKING:
from collections.abc import Callable, Iterator
Expand Down Expand Up @@ -162,7 +162,7 @@ def idle_times(self) -> dict[int, int]:
idle_times[cmd.node] = current_time - prepared_time[cmd.node]

for output_node in self.output_node_indices:
if output_node in prepared_time:
if output_node in prepared_time and output_node not in idle_times:
idle_times[output_node] = current_time - prepared_time[output_node]

return idle_times
Expand Down Expand Up @@ -253,7 +253,7 @@ def _ensure_no_operations_on_measured_qubits(pattern: Pattern) -> None:
if not set(cmd.nodes).isdisjoint(measured):
msg = f"Entanglement operation targets a measured qubit: {cmd}"
raise ValueError(msg)
elif isinstance(cmd, (N, X, Z)):
elif isinstance(cmd, N):
if cmd.node in measured:
msg = f"Operation on a measured qubit: {cmd}"
raise ValueError(msg)
Expand Down Expand Up @@ -286,7 +286,7 @@ def _ensure_no_unprepared_qubit_operations(pattern: Pattern) -> None:
if cmd.nodes[0] not in prepared or cmd.nodes[1] not in prepared:
msg = f"Entanglement operation targets a qubit that hasn't been prepared yet: {cmd}"
raise ValueError(msg)
elif isinstance(cmd, (M, X, Z)) and cmd.node not in prepared:
elif isinstance(cmd, M) and cmd.node not in prepared:
msg = f"Operation on a qubit that hasn't been prepared yet: {cmd}"
raise ValueError(msg)
elif isinstance(cmd, TICK):
Expand All @@ -295,7 +295,7 @@ def _ensure_no_unprepared_qubit_operations(pattern: Pattern) -> None:


def _ensure_measurement_consistency(pattern: Pattern) -> None:
"""Ensure that measurements are applied exactly to all non-output qubits and that no output qubit is ever measured.
"""Ensure that measurements are applied exactly to all non-output qubits.

Parameters
----------
Expand All @@ -305,7 +305,7 @@ def _ensure_measurement_consistency(pattern: Pattern) -> None:
Raises
------
ValueError
If a measurement targets an output qubit, or if some non-output qubits are never measured.
If some non-output qubits are never measured.
"""
output_nodes = set(pattern.output_node_indices)
non_output_nodes = (
Expand All @@ -314,11 +314,8 @@ def _ensure_measurement_consistency(pattern: Pattern) -> None:
measured: set[int] = set()
for cmd in pattern:
if isinstance(cmd, M):
if cmd.node in output_nodes:
msg = f"The command measures an output qubit: {cmd}"
raise ValueError(msg)
measured.add(cmd.node)
if measured != non_output_nodes:
if not non_output_nodes.issubset(measured):
missing = non_output_nodes - measured
msg = f"Missing measurements on qubit(s): {sorted(missing)}"
raise ValueError(msg)
Expand Down Expand Up @@ -346,7 +343,7 @@ def print_pattern(
"""

def identity_filter(cmd: Command) -> Command | None:
return cmd if isinstance(cmd, (N, E, M, X, Z, TICK)) else None
return cmd if isinstance(cmd, (N, E, M, TICK)) else None

if cmd_filter is None:
cmd_filter = identity_filter
Expand Down
18 changes: 2 additions & 16 deletions graphqomb/ptn_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from types import MappingProxyType
from typing import TYPE_CHECKING

from graphqomb.command import TICK, Command, E, M, N, X, Z
from graphqomb.command import TICK, Command, E, M, N
from graphqomb.common import (
Axis,
AxisMeasBasis,
Expand Down Expand Up @@ -201,10 +201,6 @@ def _write_command(out: StringIO, cmd: Command) -> None:
out.write(f"E {cmd.nodes[0]} {cmd.nodes[1]}\n")
elif isinstance(cmd, M):
_write_measurement(out, cmd)
elif isinstance(cmd, X):
out.write(f"X {cmd.node}\n")
elif isinstance(cmd, Z):
out.write(f"Z {cmd.node}\n")


def _is_positive_pauli_measurement(meas_basis: MeasBasis, pauli_axis: Axis) -> bool:
Expand Down Expand Up @@ -612,7 +608,7 @@ def _command_nodes(cmd: Command) -> set[int]:
`set`\[`int`\]
Node ids referenced by the command.
"""
if isinstance(cmd, (N, M, X, Z)):
if isinstance(cmd, (N, M)):
return {cmd.node}
if isinstance(cmd, E):
return set(cmd.nodes)
Expand Down Expand Up @@ -848,16 +844,6 @@ def _parse_command(self, line: str) -> None:
self._parse_e_command(parts)
elif cmd_type == "M":
self._parse_m_command(parts)
elif cmd_type == "X":
if len(parts) != 2: # noqa: PLR2004
msg = "X command requires exactly one node"
raise ValueError(msg)
self.result.commands.append(X(node=_parse_int(parts[1], "node")))
elif cmd_type == "Z":
if len(parts) != 2: # noqa: PLR2004
msg = "Z command requires exactly one node"
raise ValueError(msg)
self.result.commands.append(Z(node=_parse_int(parts[1], "node")))
else:
msg = f"Unknown command: {cmd_type}"
raise ValueError(msg)
Expand Down
8 changes: 2 additions & 6 deletions graphqomb/qompiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from graphlib import TopologicalSorter
from typing import TYPE_CHECKING

from graphqomb.command import TICK, Command, E, M, N, X, Z
from graphqomb.command import TICK, Command, E, M, N
from graphqomb.feedforward import check_flow, dag_from_flow
from graphqomb.graphstate import odd_neighbors
from graphqomb.pattern import Pattern
Expand Down Expand Up @@ -138,11 +138,7 @@ def _qompile(
# Insert TICK between time slices
commands.append(TICK())

for node in graph.output_node_indices:
if meas_basis := graph.meas_bases.get(node):
commands.append(M(node, meas_basis))
else:
commands.extend((X(node=node), Z(node=node)))
commands.extend(M(node, meas_bases[node]) for node in graph.output_node_indices if node in meas_bases)

# Collect input node coordinates
input_coords = {node: graph_coords[node] for node in graph.input_node_indices if node in graph_coords}
Expand Down
63 changes: 48 additions & 15 deletions graphqomb/simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import numpy as np

from graphqomb.command import TICK, E, M, N, X, Z
from graphqomb.command import TICK, E, M, N
from graphqomb.common import MeasBasis, Plane
from graphqomb.gates import MultiGate, SingleGate, TwoQubitGate
from graphqomb.pattern import is_runnable
Expand All @@ -29,6 +29,9 @@
from graphqomb.pattern import Pattern
from graphqomb.simulator_backend import BaseFullStateSimulator

_X_MATRIX = np.asarray([[0, 1], [1, 0]], dtype=np.complex128)
_Z_MATRIX = np.asarray([[1, 0], [0, -1]], dtype=np.complex128)


class SimulatorBackend(Enum):
"""Enum class for circuit simulator backend.
Expand Down Expand Up @@ -112,13 +115,16 @@ class PatternSimulator:
The list of node indices in the pattern.
results : `dict`\[`int`, `bool`\]
The measurement results for each node.
output_results : `dict`\[`int`, `bool`\]
Measurement results for output nodes, keyed by logical output index.
calc_prob : `bool`
Whether to calculate probabilities.
"""

state: BaseFullStateSimulator
node_indices: list[int]
results: dict[int, bool]
output_results: dict[int, bool]
calc_prob: bool
__pattern: Pattern

Expand All @@ -131,6 +137,7 @@ def __init__(
) -> None:
self.node_indices = list(pattern.input_node_indices.keys())
self.results = {}
self.output_results = {}

self.calc_prob = calc_prob
self.__pattern = pattern
Expand Down Expand Up @@ -194,32 +201,52 @@ def _updated_measurement_basis(self, cmd: M) -> MeasBasis:

return basis

def _sample_measurement_result(
self,
node_id: int,
meas_basis: MeasBasis,
rng: np.random.Generator,
) -> bool:
state = self.state.state()
norm_sq = float(np.real(np.vdot(state, state)))
basis_vector = meas_basis.vector()
projected = np.tensordot(basis_vector.conjugate(), state, axes=(0, node_id))
prob_false = float(np.real(np.vdot(projected, projected)) / norm_sq)
prob_false = min(1.0, max(0.0, prob_false))
return bool(rng.uniform() >= prob_false)

def _apply_output_pauli_frame(self, node: int) -> None:
node_id = self.node_indices.index(node)
if self.__pattern.pauli_frame.x_pauli[node]:
self.state.evolve(_X_MATRIX, node_id)
if self.__pattern.pauli_frame.z_pauli[node]:
self.state.evolve(_Z_MATRIX, node_id)

@apply_cmd.register
def _(self, cmd: M, *, rng: np.random.Generator) -> None:
if self.calc_prob:
raise NotImplementedError
result = rng.uniform() < 1 / 2

node_id = self.node_indices.index(cmd.node)
self.state.measure(node_id, self._updated_measurement_basis(cmd), result)
if cmd.node in self.__pattern.output_node_indices:
meas_basis = self._updated_measurement_basis(cmd)
result = self._sample_measurement_result(node_id, meas_basis, rng)
else:
meas_basis = self._updated_measurement_basis(cmd)
result = rng.uniform() < 1 / 2

self.state.measure(node_id, meas_basis, result)
self.results[cmd.node] = result
self.node_indices.remove(cmd.node)

if cmd.node in self.__pattern.output_node_indices:
qindex = self.__pattern.output_node_indices[cmd.node]
self.output_results[qindex] = result
return

if result:
self.__pattern.pauli_frame.meas_flip(cmd.node)

@apply_cmd.register
def _(self, cmd: X, *, rng: np.random.Generator) -> None: # noqa: ARG002
node_id = self.node_indices.index(cmd.node)
if self.__pattern.pauli_frame.x_pauli[cmd.node]:
self.state.evolve(np.asarray([[0, 1], [1, 0]]), node_id)

@apply_cmd.register
def _(self, cmd: Z, *, rng: np.random.Generator) -> None: # noqa: ARG002
node_id = self.node_indices.index(cmd.node)
if self.__pattern.pauli_frame.z_pauli[cmd.node]:
self.state.evolve(np.asarray([[1, 0], [0, -1]]), node_id)

@apply_cmd.register
def _(self, cmd: TICK, *, rng: np.random.Generator) -> None:
# TICK is a time separator that doesn't affect quantum state
Expand All @@ -240,6 +267,12 @@ def simulate(self, rng: np.random.Generator | None = None) -> None:
for cmd in self.__pattern.commands:
self.apply_cmd(cmd, rng=rng)

# Only remaining output nodes still have state-vector axes; measured outputs
# remain in output_node_indices but have been removed from node_indices.
for node in self.node_indices:
if node in self.__pattern.output_node_indices:
self._apply_output_pauli_frame(node)

# Create a mapping from current node indices to output node indices
permutation = [self.__pattern.output_node_indices[node] for node in self.node_indices]

Expand Down
9 changes: 1 addition & 8 deletions graphqomb/stim_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from io import StringIO
from typing import TYPE_CHECKING

from graphqomb.command import TICK, E, M, N, X, Z
from graphqomb.command import TICK, E, M, N
from graphqomb.common import Axis, MeasBasis, determine_pauli_axis
from graphqomb.noise_model import (
Coordinate,
Expand Down Expand Up @@ -91,12 +91,6 @@ def _process_commands(self) -> None:
self._handle_measure(cmd.node, cmd.meas_basis)
elif isinstance(cmd, TICK):
self._handle_tick()
elif isinstance(cmd, (X, Z)):
cmd_name = type(cmd).__name__
msg = (
f"Unsupported command for stim compilation: {cmd_name}. X/Z correction commands are not supported."
)
raise NotImplementedError(msg)
else:
msg = f"Unsupported command for stim compilation: {type(cmd).__name__}"
raise TypeError(msg)
Expand Down Expand Up @@ -275,7 +269,6 @@ def stim_compile(
Stim only supports Clifford gates, therefore this compiler only supports
Pauli measurements (X, Y, Z basis) which correspond to Clifford operations.
Non-Pauli measurements will raise a ValueError.
Patterns containing X or Z correction commands will raise a NotImplementedError.

Examples
--------
Expand Down
Loading
Loading