From 740221185cea9619ff8208b8184e1d3092370486 Mon Sep 17 00:00:00 2001 From: Masato Fukushima Date: Wed, 8 Jul 2026 00:03:08 -0400 Subject: [PATCH 1/3] Add Stim circuit importer --- graphqomb/qec/qeccode.py | 55 +++- graphqomb/stim_importer.py | 497 ++++++++++++++++++++++++++++++++++++ tests/test_stim_importer.py | 69 +++++ 3 files changed, 610 insertions(+), 11 deletions(-) create mode 100644 graphqomb/stim_importer.py create mode 100644 tests/test_stim_importer.py diff --git a/graphqomb/qec/qeccode.py b/graphqomb/qec/qeccode.py index 73c9f2a9..03ed4757 100644 --- a/graphqomb/qec/qeccode.py +++ b/graphqomb/qec/qeccode.py @@ -64,7 +64,21 @@ class StabilizerGraphStateBuildResult(NamedTuple): ancilla_nodes: dict[int, int] -def build_graph_state(code: StabilizerCode, z_base: int = 0) -> StabilizerGraphStateBuildResult: +class _DataLayerConfig(NamedTuple): + lower_z: int + upper_z: int + meas_basis: AxisMeasBasis + data_as_io: bool + qubit_indices: Mapping[int, int] | None + + +def build_graph_state( + code: StabilizerCode, + z_base: int = 0, + *, + data_as_io: bool = False, + qubit_indices: Mapping[int, int] | None = None, +) -> StabilizerGraphStateBuildResult: """Build a two-layer graph-state unit from a stabilizer code. Parameters @@ -75,6 +89,12 @@ def build_graph_state(code: StabilizerCode, z_base: int = 0) -> StabilizerGraphS z_base : `int`, optional Lower data-layer index. The builder creates layers `z_base` and `z_base + 1`, by default 0. + data_as_io : `bool`, optional + Whether to register lower data nodes as inputs and upper data nodes as + outputs, by default False. + qubit_indices : `collections.abc.Mapping`[`int`, `int`] | `None`, optional + Mapping from stabilizer-code qubit columns to graph qindices when + ``data_as_io`` is enabled. If omitted, code qubit columns are used. Returns ------- @@ -95,7 +115,17 @@ def build_graph_state(code: StabilizerCode, z_base: int = 0) -> StabilizerGraphS lower_z = z_base upper_z = z_base + 1 - data_nodes = _add_layered_data_nodes(graph, code, lower_z, upper_z, x_meas_basis) + data_nodes = _add_layered_data_nodes( + graph, + code, + _DataLayerConfig( + lower_z=lower_z, + upper_z=upper_z, + meas_basis=x_meas_basis, + data_as_io=data_as_io, + qubit_indices=qubit_indices, + ), + ) ancilla_nodes = _add_ancilla_nodes(graph, code, data_nodes, (lower_z, upper_z), x_meas_basis) return StabilizerGraphStateBuildResult(graph, data_nodes, ancilla_nodes) @@ -104,9 +134,7 @@ def build_graph_state(code: StabilizerCode, z_base: int = 0) -> StabilizerGraphS def _add_layered_data_nodes( graph: GraphState, code: StabilizerCode, - lower_z: int, - upper_z: int, - meas_basis: AxisMeasBasis, + config: _DataLayerConfig, ) -> dict[tuple[int, int], int]: """Add layered data nodes. @@ -117,13 +145,18 @@ def _add_layered_data_nodes( """ data_nodes: dict[tuple[int, int], int] = {} for qubit in range(code.num_qubits): - lower_node = graph.add_node(coordinate=_data_coordinate(code, qubit, lower_z)) - upper_node = graph.add_node(coordinate=_data_coordinate(code, qubit, upper_z)) + lower_node = graph.add_node(coordinate=_data_coordinate(code, qubit, config.lower_z)) + upper_node = graph.add_node(coordinate=_data_coordinate(code, qubit, config.upper_z)) graph.add_edge(lower_node, upper_node) - graph.assign_meas_basis(lower_node, meas_basis) - graph.assign_meas_basis(upper_node, meas_basis) - data_nodes[qubit, lower_z] = lower_node - data_nodes[qubit, upper_z] = upper_node + graph.assign_meas_basis(lower_node, config.meas_basis) + if config.data_as_io: + q_index = config.qubit_indices[qubit] if config.qubit_indices is not None else qubit + graph.register_input(lower_node, q_index) + graph.register_output(upper_node, q_index) + else: + graph.assign_meas_basis(upper_node, config.meas_basis) + data_nodes[qubit, config.lower_z] = lower_node + data_nodes[qubit, config.upper_z] = upper_node return data_nodes diff --git a/graphqomb/stim_importer.py b/graphqomb/stim_importer.py new file mode 100644 index 00000000..e6f80fa4 --- /dev/null +++ b/graphqomb/stim_importer.py @@ -0,0 +1,497 @@ +"""Import supported Stim circuits into GraphQOMB patterns.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, Protocol, TypeGuard + +from graphqomb.circuit import Circuit, CircuitScheduleStrategy, circuit2graph +from graphqomb.gates import CNOT, CZ, SWAP, Gate, H, Rz, S, X, Y, Z +from graphqomb.graphstate import GraphState, compose, odd_neighbors +from graphqomb.qec.qeccode import StabilizerGraphStateBuildResult, build_graph_state +from graphqomb.qec.stim_mpp import StimMppExtraction, stabilizer_code_from_stim_text +from graphqomb.qec.stim_mpp import _load_stim as _load_stim_module +from graphqomb.qompiler import qompile + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator, Mapping, Sequence + + from graphqomb.pattern import Pattern + + +_UNITARY_GATES = frozenset({"H", "S", "SQRT_Z", "S_DAG", "SQRT_Z_DAG", "X", "Y", "Z", "CX", "CNOT", "CZ", "SWAP"}) +_ANNOTATION_GATES = frozenset({"DETECTOR", "OBSERVABLE_INCLUDE"}) +_SINGLE_QUBIT_GATE_FACTORIES: dict[str, Callable[[int], Gate]] = { + "H": H, + "S": S, + "SQRT_Z": S, + "S_DAG": lambda qubit: Rz(qubit, -math.pi / 2), + "SQRT_Z_DAG": lambda qubit: Rz(qubit, -math.pi / 2), + "X": X, + "Y": Y, + "Z": Z, +} +_TWO_QUBIT_GATE_FACTORIES: dict[str, Callable[[tuple[int, int]], Gate]] = { + "CX": CNOT, + "CNOT": CNOT, + "CZ": CZ, + "SWAP": SWAP, +} + + +class _StimGateTarget(Protocol): + value: int + qubit_value: int | None + is_qubit_target: bool + + +class _StimCircuitInstruction(Protocol): + name: str + + def targets_copy(self) -> list[_StimGateTarget]: + """Return instruction targets.""" + ... + + def target_groups(self) -> list[list[_StimGateTarget]]: + """Return grouped instruction targets.""" + ... + + def gate_args_copy(self) -> list[float]: + """Return instruction arguments.""" + ... + + +class _StimRepeatBlock(Protocol): + """Marker protocol for Stim repeat blocks.""" + + +class _StimCircuit(Protocol): + def __iter__(self) -> Iterator[_StimCircuitInstruction | _StimRepeatBlock]: + """Iterate over circuit instructions and repeat blocks.""" + ... + + def flattened(self) -> _StimCircuit: + """Return a flattened circuit.""" + ... + + +class _StimModule(Protocol): + Circuit: type[Any] + CircuitInstruction: type[Any] + CircuitRepeatBlock: type[Any] + + +@dataclass(frozen=True) +class StimImportResult: + """Result of importing a supported Stim circuit.""" + + pattern: Pattern + stim_to_qubit: dict[int, int] + qubit_to_stim: dict[int, int] + mpp_extractions: tuple[StimMppExtraction, ...] + + +@dataclass(frozen=True) +class _Fragment: + graph: GraphState + xflow: dict[int, set[int]] + zflow: dict[int, set[int]] + parity_check_groups: list[set[int]] + logical_observables: dict[int, set[int]] + mpp_extractions: tuple[StimMppExtraction, ...] = () + + +@dataclass(frozen=True) +class _ImportContext: + stim_to_qubit: Mapping[int, int] + coordinate_by_stim_id: Mapping[int, tuple[float, ...]] + coord_dims: int + schedule_strategy: CircuitScheduleStrategy + + +def stim_file_to_pattern( + path: str | Path, + *, + coord_dims: int = 2, + schedule_strategy: CircuitScheduleStrategy = CircuitScheduleStrategy.PARALLEL, +) -> StimImportResult: + """Import a supported Stim file into a GraphQOMB pattern. + + Returns + ------- + `StimImportResult` + Imported pattern, qubit mapping, and MPP extraction metadata. + """ + return stim_text_to_pattern( + Path(path).read_text(encoding="utf-8"), + coord_dims=coord_dims, + schedule_strategy=schedule_strategy, + ) + + +def stim_text_to_pattern( + text: str, + *, + coord_dims: int = 2, + schedule_strategy: CircuitScheduleStrategy = CircuitScheduleStrategy.PARALLEL, +) -> StimImportResult: + """Import supported Stim text into a GraphQOMB pattern. + + Returns + ------- + `StimImportResult` + Imported pattern, qubit mapping, and MPP extraction metadata. + """ + stim_module = _load_stim() + return stim_circuit_to_pattern( + stim_module.Circuit(text), + coord_dims=coord_dims, + schedule_strategy=schedule_strategy, + ) + + +def stim_circuit_to_pattern( + circuit: _StimCircuit, + *, + coord_dims: int = 2, + schedule_strategy: CircuitScheduleStrategy = CircuitScheduleStrategy.PARALLEL, +) -> StimImportResult: + """Import a supported Stim circuit into a GraphQOMB pattern. + + The v1 importer supports noiseless Clifford unitary blocks and MPP blocks. + Blocks with MPP measurements must be separated from unitary blocks by TICK. + + Returns + ------- + `StimImportResult` + Imported pattern, qubit mapping, and MPP extraction metadata. + + Raises + ------ + ValueError + If the circuit uses unsupported instructions or invalid coordinates. + """ + if coord_dims not in {2, 3}: + msg = "coord_dims must be 2 or 3." + raise ValueError(msg) + + stim_module = _load_stim() + flat_circuit = circuit.flattened() + coordinate_by_stim_id = _extract_qubit_coordinates(flat_circuit, coord_dims=coord_dims, stim_module=stim_module) + stim_to_qubit = _stim_to_qubit_map(flat_circuit, stim_module=stim_module) + qubit_to_stim = {qubit: stim_id for stim_id, qubit in stim_to_qubit.items()} + context = _ImportContext( + stim_to_qubit=stim_to_qubit, + coordinate_by_stim_id=coordinate_by_stim_id, + coord_dims=coord_dims, + schedule_strategy=schedule_strategy, + ) + + fragments = [ + _fragment_from_block( + block, + block_index=block_index, + context=context, + ) + for block_index, block in enumerate(_tick_blocks(flat_circuit, stim_module=stim_module)) + if block + ] + if not fragments: + fragments = [ + _unitary_fragment( + (), + context=context, + ) + ] + + fragment = _compose_fragments(fragments) + pattern = qompile( + fragment.graph, + fragment.xflow, + fragment.zflow, + parity_check_group=fragment.parity_check_groups, + logical_observables=fragment.logical_observables, + ) + return StimImportResult( + pattern=pattern, + stim_to_qubit=stim_to_qubit, + qubit_to_stim=qubit_to_stim, + mpp_extractions=fragment.mpp_extractions, + ) + + +def _load_stim() -> _StimModule: + return _load_stim_module() + + +def _is_circuit_instruction( + instruction: _StimCircuitInstruction | _StimRepeatBlock, + stim_module: _StimModule, +) -> TypeGuard[_StimCircuitInstruction]: + return isinstance(instruction, stim_module.CircuitInstruction) + + +def _tick_blocks(circuit: _StimCircuit, *, stim_module: _StimModule) -> list[tuple[_StimCircuitInstruction, ...]]: + blocks: list[tuple[_StimCircuitInstruction, ...]] = [] + current: list[_StimCircuitInstruction] = [] + for instruction in circuit: + if not _is_circuit_instruction(instruction, stim_module): + msg = "Flattened Stim circuit unexpectedly contains a repeat block." + raise TypeError(msg) + if instruction.name == "TICK": + blocks.append(tuple(current)) + current = [] + continue + if instruction.name == "QUBIT_COORDS": + continue + if instruction.name == "SHIFT_COORDS": + msg = "SHIFT_COORDS is not supported by stim_circuit_to_pattern." + raise ValueError(msg) + current.append(instruction) + blocks.append(tuple(current)) + return blocks + + +def _fragment_from_block( + block: Sequence[_StimCircuitInstruction], + *, + block_index: int, + context: _ImportContext, +) -> _Fragment: + has_mpp = any(instruction.name == "MPP" for instruction in block) + has_unitary = any(instruction.name in _UNITARY_GATES for instruction in block) + unsupported = [ + instruction.name + for instruction in block + if ( + instruction.name not in _UNITARY_GATES + and instruction.name not in _ANNOTATION_GATES + and instruction.name != "MPP" + ) + ] + if unsupported: + msg = f"Unsupported Stim instruction(s): {', '.join(sorted(set(unsupported)))}." + raise ValueError(msg) + if has_mpp and has_unitary: + msg = "MPP instructions must be separated from unitary gate instructions by TICK." + raise ValueError(msg) + if has_mpp: + return _mpp_fragment( + block, + block_index=block_index, + context=context, + ) + if any(instruction.name in _ANNOTATION_GATES for instruction in block): + msg = "DETECTOR and OBSERVABLE_INCLUDE are only supported in MPP blocks." + raise ValueError(msg) + return _unitary_fragment( + block, + context=context, + ) + + +def _unitary_fragment( + block: Sequence[_StimCircuitInstruction], + *, + context: _ImportContext, +) -> _Fragment: + circuit = Circuit(len(context.stim_to_qubit)) + for instruction in block: + _append_unitary_instruction(circuit, instruction, context.stim_to_qubit) + + graph, xflow, _scheduler = circuit2graph(circuit, schedule_strategy=context.schedule_strategy) + _apply_stim_coordinates( + graph, + stim_to_qubit=context.stim_to_qubit, + coordinate_by_stim_id=context.coordinate_by_stim_id, + ) + xflow_sets = {node: set(targets) for node, targets in xflow.items()} + zflow = {node: odd_neighbors(targets, graph) for node, targets in xflow_sets.items()} + return _Fragment(graph=graph, xflow=xflow_sets, zflow=zflow, parity_check_groups=[], logical_observables={}) + + +def _append_unitary_instruction( + circuit: Circuit, + instruction: _StimCircuitInstruction, + stim_to_qubit: Mapping[int, int], +) -> None: + for group in instruction.target_groups(): + qubits = [_plain_qubit_target(target, instruction.name) for target in group] + mapped = [stim_to_qubit[qubit] for qubit in qubits] + single_factory = _SINGLE_QUBIT_GATE_FACTORIES.get(instruction.name) + two_factory = _TWO_QUBIT_GATE_FACTORIES.get(instruction.name) + if single_factory is not None: + circuit.apply_macro_gate(single_factory(mapped[0])) + elif two_factory is not None: + circuit.apply_macro_gate(two_factory((mapped[0], mapped[1]))) + else: + msg = f"Unsupported unitary Stim instruction: {instruction.name}." + raise ValueError(msg) + + +def _mpp_fragment( + block: Sequence[_StimCircuitInstruction], + *, + block_index: int, + context: _ImportContext, +) -> _Fragment: + text = _stim_text_with_coords(block, coordinate_by_stim_id=context.coordinate_by_stim_id) + extraction = stabilizer_code_from_stim_text(text, coord_dims=context.coord_dims) + qubit_indices = { + column: context.stim_to_qubit[stim_id] + for column, stim_id in extraction.column_to_stim.items() + } + z_base = 2 * block_index + result = build_graph_state(extraction.code, z_base=z_base, data_as_io=True, qubit_indices=qubit_indices) + xflow, zflow = _mpp_flow(result, z_base=z_base) + return _Fragment( + graph=result.graph, + xflow=xflow, + zflow=zflow, + parity_check_groups=extraction.detector_groups(result.ancilla_nodes), + logical_observables=extraction.logical_observables(result.ancilla_nodes), + mpp_extractions=(extraction,), + ) + + +def _mpp_flow( + result: StabilizerGraphStateBuildResult, + *, + z_base: int, +) -> tuple[dict[int, set[int]], dict[int, set[int]]]: + xflow: dict[int, set[int]] = {} + zflow: dict[int, set[int]] = {} + for qubit in {key[0] for key in result.data_nodes}: + lower_node = result.data_nodes[qubit, z_base] + upper_node = result.data_nodes[qubit, z_base + 1] + xflow[lower_node] = {upper_node} + zflow[lower_node] = set() + for ancilla_node in result.ancilla_nodes.values(): + xflow[ancilla_node] = {ancilla_node} + zflow[ancilla_node] = set() + return xflow, zflow + + +def _compose_fragments(fragments: Sequence[_Fragment]) -> _Fragment: + current = fragments[0] + for fragment in fragments[1:]: + graph, node_map1, node_map2 = compose(current.graph, fragment.graph) + current = _Fragment( + graph=graph, + xflow=_remap_flow(current.xflow, node_map1) | _remap_flow(fragment.xflow, node_map2), + zflow=_remap_flow(current.zflow, node_map1) | _remap_flow(fragment.zflow, node_map2), + parity_check_groups=[ + *[_remap_node_set(group, node_map1) for group in current.parity_check_groups], + *[_remap_node_set(group, node_map2) for group in fragment.parity_check_groups], + ], + logical_observables=_merge_logical_observables( + _remap_logical_observables(current.logical_observables, node_map1), + _remap_logical_observables(fragment.logical_observables, node_map2), + ), + mpp_extractions=(*current.mpp_extractions, *fragment.mpp_extractions), + ) + return current + + +def _remap_flow(flow: Mapping[int, set[int]], node_map: Mapping[int, int]) -> dict[int, set[int]]: + return {node_map[node]: _remap_node_set(targets, node_map) for node, targets in flow.items()} + + +def _remap_node_set(nodes: set[int], node_map: Mapping[int, int]) -> set[int]: + return {node_map[node] for node in nodes} + + +def _remap_logical_observables( + logical_observables: Mapping[int, set[int]], + node_map: Mapping[int, int], +) -> dict[int, set[int]]: + return {logical_idx: _remap_node_set(nodes, node_map) for logical_idx, nodes in logical_observables.items()} + + +def _merge_logical_observables(*items: Mapping[int, set[int]]) -> dict[int, set[int]]: + merged: dict[int, set[int]] = {} + for item in items: + for logical_idx, nodes in item.items(): + merged.setdefault(logical_idx, set()).symmetric_difference_update(nodes) + return merged + + +def _apply_stim_coordinates( + graph: GraphState, + *, + stim_to_qubit: Mapping[int, int], + coordinate_by_stim_id: Mapping[int, tuple[float, ...]], +) -> None: + qubit_to_stim = {qubit: stim_id for stim_id, qubit in stim_to_qubit.items()} + for node, q_index in graph.input_node_indices.items() | graph.output_node_indices.items(): + stim_id = qubit_to_stim[q_index] + coord = coordinate_by_stim_id.get(stim_id) + if coord is not None: + graph.set_coordinate(node, coord) + + +def _extract_qubit_coordinates( + circuit: _StimCircuit, + *, + coord_dims: int, + stim_module: _StimModule, +) -> dict[int, tuple[float, ...]]: + coordinates: dict[int, tuple[float, ...]] = {} + for instruction in circuit: + if not _is_circuit_instruction(instruction, stim_module): + msg = "Flattened Stim circuit unexpectedly contains a repeat block." + raise TypeError(msg) + if instruction.name != "QUBIT_COORDS": + continue + args = instruction.gate_args_copy() + if len(args) < coord_dims: + msg = f"QUBIT_COORDS has {len(args)} coordinate(s), fewer than requested coord_dims={coord_dims}." + raise ValueError(msg) + coord = tuple(float(value) for value in args[:coord_dims]) + for target in instruction.targets_copy(): + coordinates[_plain_qubit_target(target, instruction.name)] = coord + return coordinates + + +def _stim_to_qubit_map(circuit: _StimCircuit, *, stim_module: _StimModule) -> dict[int, int]: + stim_ids: set[int] = set() + for instruction in circuit: + if not _is_circuit_instruction(instruction, stim_module): + msg = "Flattened Stim circuit unexpectedly contains a repeat block." + raise TypeError(msg) + if instruction.name in {"TICK", "DETECTOR", "OBSERVABLE_INCLUDE", "SHIFT_COORDS"}: + continue + for target in instruction.targets_copy(): + qubit_value = target.qubit_value + if qubit_value is not None: + stim_ids.add(int(qubit_value)) + return {stim_id: qubit for qubit, stim_id in enumerate(sorted(stim_ids))} + + +def _plain_qubit_target(target: _StimGateTarget, instruction_name: str) -> int: + qubit_value = target.qubit_value + if qubit_value is None or not target.is_qubit_target: + msg = f"{instruction_name} contains unsupported target {target!r}; only plain qubit targets are supported." + raise ValueError(msg) + return int(qubit_value) + + +def _stim_text_with_coords( + instructions: Sequence[_StimCircuitInstruction], + *, + coordinate_by_stim_id: Mapping[int, tuple[float, ...]], +) -> str: + stim_ids = sorted( + int(target.qubit_value) + for instruction in instructions + for target in instruction.targets_copy() + if target.qubit_value is not None + ) + coord_lines = [ + f"QUBIT_COORDS({', '.join(str(value) for value in coordinate_by_stim_id[stim_id])}) {stim_id}" + for stim_id in dict.fromkeys(stim_ids) + if stim_id in coordinate_by_stim_id + ] + return "\n".join([*coord_lines, *(str(instruction) for instruction in instructions)]) diff --git a/tests/test_stim_importer.py b/tests/test_stim_importer.py new file mode 100644 index 00000000..01d1640f --- /dev/null +++ b/tests/test_stim_importer.py @@ -0,0 +1,69 @@ +"""Tests for importing supported Stim circuits into GraphQOMB patterns.""" + +from __future__ import annotations + +import pytest + +from graphqomb.stim_importer import stim_text_to_pattern + +pytest.importorskip("stim") + + +def test_stim_text_to_pattern_imports_unitary_clifford_block() -> None: + result = stim_text_to_pattern( + """ + H 10 + CX 10 12 + S_DAG 12 + """ + ) + + assert result.stim_to_qubit == {10: 0, 12: 1} + assert result.qubit_to_stim == {0: 10, 1: 12} + assert result.mpp_extractions == () + assert set(result.pattern.input_node_indices.values()) == {0, 1} + assert set(result.pattern.output_node_indices.values()) == {0, 1} + + +def test_stim_text_to_pattern_preserves_sparse_qubit_coordinates() -> None: + result = stim_text_to_pattern( + """ + QUBIT_COORDS(1, 2) 10 + QUBIT_COORDS(3, 4) 99 + H 99 + """ + ) + + assert result.stim_to_qubit == {10: 0, 99: 1} + assert result.pattern.input_coordinates + assert set(result.pattern.input_coordinates.values()) == {(1.0, 2.0), (3.0, 4.0)} + + +def test_stim_text_to_pattern_imports_tick_separated_mpp_block() -> None: + result = stim_text_to_pattern( + """ + H 10 + TICK + MPP X10*Z12 + DETECTOR rec[-1] + OBSERVABLE_INCLUDE(3) rec[-1] + TICK + CZ 10 12 + """ + ) + + assert result.stim_to_qubit == {10: 0, 12: 1} + assert len(result.mpp_extractions) == 1 + assert len(result.pattern.pauli_frame.parity_check_group) == 1 + assert set(result.pattern.pauli_frame.logical_observables) == {3} + assert set(result.pattern.output_node_indices.values()) == {0, 1} + + +def test_stim_text_to_pattern_rejects_mixed_mpp_and_unitary_block() -> None: + with pytest.raises(ValueError, match="separated from unitary gate instructions by TICK"): + stim_text_to_pattern("H 0\nMPP X0\n") + + +def test_stim_text_to_pattern_rejects_measurement_instruction() -> None: + with pytest.raises(ValueError, match="Unsupported Stim instruction"): + stim_text_to_pattern("M 0\n") From 62e9419aadb45f08bc0fca40d1db11ea1a509dc8 Mon Sep 17 00:00:00 2001 From: Masato Fukushima Date: Wed, 8 Jul 2026 00:17:09 -0400 Subject: [PATCH 2/3] Add Pauli input initialization --- CHANGELOG.md | 3 + graphqomb/graphstate.py | 110 ++++++++++++++++++++++++++--- graphqomb/pattern.py | 28 +++++++- graphqomb/ptn_format.py | 105 +++++++++++++++++++++++++-- graphqomb/qompiler.py | 1 + graphqomb/simulator.py | 16 ++++- graphqomb/statevec.py | 28 ++++++++ graphqomb/stim_compiler.py | 15 +++- graphqomb/stim_importer.py | 5 +- pyproject.toml | 1 + tests/test_graph_compose.py | 32 ++++++++- tests/test_graphstate.py | 38 +++++++++- tests/test_graphstate_bulk_init.py | 31 +++++++- tests/test_ptn_format.py | 92 +++++++++++++++++++++++- tests/test_simulator.py | 40 ++++++++++- tests/test_stim_compiler.py | 46 ++++++++++++ uv.lock | 2 + 17 files changed, 561 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c411fb12..ba38f8f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **QEC Stim MPP Import**: Added utilities for building `StabilizerCode` inputs from Stim `MPP` layers, including sparse Stim qubit id mapping, coordinate import, multi-layer selection, detector/logical-observable import, and the optional `graphqomb[stim]` extra. +- **Input Initialization Bases**: Added per-input positive Pauli eigenstate initialization via `GraphState.register_input(..., init_axis=...)` and `assign_input_initialization_axis()`, with `X+`, `Y+`, and `Z+` propagated through `qompile()`, `Pattern`, `PatternSimulator`, and Stim export. ### Changed - **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. +- **PTN Format**: Bumped exported `.ptn` files to format version 2 to record non-default input initialization bases with `.input_basis`; version 1 files remain readable and default inputs to `X+`. +- **Stim Compiler**: Input reset instructions now follow the stored initialization basis (`RX`, `RY`, or `R`) while non-input preparations remain `RX`. ### Removed diff --git a/graphqomb/graphstate.py b/graphqomb/graphstate.py index 6345866e..c7dd2463 100644 --- a/graphqomb/graphstate.py +++ b/graphqomb/graphstate.py @@ -26,7 +26,7 @@ import typing_extensions -from graphqomb.common import MeasBasis, Plane, PlannerMeasBasis +from graphqomb.common import Axis, MeasBasis, Plane, PlannerMeasBasis from graphqomb.euler import update_lc_basis, update_lc_lc if TYPE_CHECKING: @@ -49,6 +49,17 @@ def input_node_indices(self) -> dict[int, int]: qubit indices map of input nodes. """ + @property + def input_initialization_axes(self) -> dict[int, Axis]: + r"""Return input initialization Pauli axes. + + Returns + ------- + `dict`\[`int`, `Axis`\] + map of input nodes to Pauli initialization axes. + """ + return dict.fromkeys(self.input_node_indices, Axis.X) + @property @abc.abstractmethod def output_node_indices(self) -> dict[int, int]: @@ -174,7 +185,7 @@ def number_of_edges(self) -> int: return len(self.edges) @abc.abstractmethod - def register_input(self, node: int, q_index: int) -> None: + def register_input(self, node: int, q_index: int, *, init_axis: Axis = Axis.X) -> None: """Mark the node as an input node. Parameters @@ -183,6 +194,8 @@ def register_input(self, node: int, q_index: int) -> None: node index q_index : `int` logical qubit index + init_axis : `Axis`, optional + Pauli axis for positive-eigenstate initialization, by default Axis.X """ @abc.abstractmethod @@ -244,6 +257,7 @@ class GraphState(BaseGraphState): """Minimal implementation of GraphState.""" __input_node_indices: dict[int, int] + __input_initialization_axes: dict[int, Axis] __output_node_indices: dict[int, int] __nodes: set[int] __neighbors: dict[int, set[int]] @@ -257,6 +271,7 @@ class GraphState(BaseGraphState): def __init__(self) -> None: self.__input_node_indices = {} + self.__input_initialization_axes = {} self.__output_node_indices = {} self.__nodes = set() self.__neighbors = {} @@ -278,6 +293,18 @@ def input_node_indices(self) -> dict[int, int]: """ return self.__input_node_indices.copy() + @property + @typing_extensions.override + def input_initialization_axes(self) -> dict[int, Axis]: + r"""Return input initialization Pauli axes. + + Returns + ------- + `dict`\[`int`, `Axis`\] + map of input nodes to Pauli initialization axes. + """ + return {node: self.__input_initialization_axes.get(node, Axis.X) for node in self.__input_node_indices} + @property @typing_extensions.override def output_node_indices(self) -> dict[int, int]: @@ -483,6 +510,7 @@ def remove_node(self, node: int) -> None: if node in self.output_node_indices: del self.__output_node_indices[node] + self.__input_initialization_axes.pop(node, None) self.__meas_bases.pop(node, None) self.__local_cliffords.pop(node, None) self.__coordinates.pop(node, None) @@ -514,7 +542,7 @@ def remove_edge(self, node1: int, node2: int) -> None: self.__neighbors[node2] -= {node1} @typing_extensions.override - def register_input(self, node: int, q_index: int) -> None: + def register_input(self, node: int, q_index: int, *, init_axis: Axis = Axis.X) -> None: """Mark the node as an input node. Parameters @@ -523,6 +551,8 @@ def register_input(self, node: int, q_index: int) -> None: node index q_index : `int` logical qubit index + init_axis : `Axis`, optional + Pauli axis for positive-eigenstate initialization, by default Axis.X Raises ------ @@ -536,7 +566,44 @@ def register_input(self, node: int, q_index: int) -> None: if q_index in self.input_node_indices.values(): msg = "The q_index already exists in input qubit indices" raise ValueError(msg) + self._check_input_initialization_axis(init_axis) self.__input_node_indices[node] = q_index + self.__input_initialization_axes[node] = init_axis + + def assign_input_initialization_axis(self, node: int, axis: Axis) -> None: + """Assign a Pauli initialization axis to an input node. + + Parameters + ---------- + node : `int` + input node index + axis : `Axis` + Pauli axis for positive-eigenstate initialization + + Raises + ------ + ValueError + If the node does not exist or is not registered as an input. + """ + self._ensure_node_exists(node) + if node not in self.__input_node_indices: + msg = f"Node is not registered as an input node: {node}" + raise ValueError(msg) + self._check_input_initialization_axis(axis) + self.__input_initialization_axes[node] = axis + + @staticmethod + def _check_input_initialization_axis(axis: Axis) -> None: + """Validate an input initialization axis. + + Raises + ------ + TypeError + If the axis is not an Axis value. + """ + if not isinstance(axis, Axis): + msg = "Input initialization axis must be one of Axis.X, Axis.Y, Axis.Z" + raise TypeError(msg) @typing_extensions.override def register_output(self, node: int, q_index: int) -> None: @@ -677,14 +744,18 @@ def _expand_input_local_cliffords(self) -> dict[int, LocalCliffordExpansion]: """ node_index_addition_map: dict[int, LocalCliffordExpansion] = {} new_input_indices: dict[int, int] = {} + new_input_initialization_axes: dict[int, Axis] = {} for input_node, q_index in self.input_node_indices.items(): + init_axis = self.input_initialization_axes[input_node] lc = self._pop_local_clifford(input_node) if lc is None: new_input_indices[input_node] = q_index + new_input_initialization_axes[input_node] = init_axis continue new_node_index0 = self.add_node() new_input_indices[new_node_index0] = q_index + new_input_initialization_axes[new_node_index0] = init_axis new_node_index1 = self.add_node() new_node_index2 = self.add_node() @@ -701,8 +772,13 @@ def _expand_input_local_cliffords(self) -> dict[int, LocalCliffordExpansion]: ) self.__input_node_indices = {} + self.__input_initialization_axes = {} for new_input_index, q_index in new_input_indices.items(): - self.register_input(new_input_index, q_index) + self.register_input( + new_input_index, + q_index, + init_axis=new_input_initialization_axes[new_input_index], + ) return node_index_addition_map @@ -754,6 +830,7 @@ def from_graph( # noqa: C901, PLR0912, PLR0913, PLR0917 outputs: Sequence[NodeT] | None = None, meas_bases: Mapping[NodeT, MeasBasis] | None = None, coordinates: Mapping[NodeT, tuple[float, ...]] | None = None, + input_initialization_axes: Mapping[NodeT, Axis] | None = None, ) -> tuple[GraphState, dict[NodeT, int]]: r"""Create a graph state from nodes and edges with arbitrary node types. @@ -778,6 +855,8 @@ def from_graph( # noqa: C901, PLR0912, PLR0913, PLR0917 Default is None (no bases assigned initially). coordinates : `collections.abc.Mapping`\[NodeT, `tuple`\[`float`, ...\]\] | `None`, optional Coordinates for nodes (2D or 3D). Default is None (no coordinates). + input_initialization_axes : `collections.abc.Mapping`\[NodeT, `Axis`\] | `None`, optional + Pauli initialization axes for input nodes. Default is None (all inputs use Axis.X). Returns ------- @@ -844,7 +923,10 @@ def from_graph( # noqa: C901, PLR0912, PLR0913, PLR0917 # Register inputs with sequential qubit indices if inputs is not None: for q_index, input_node in enumerate(inputs): - graph_state.register_input(node_map[input_node], q_index) + init_axis = ( + Axis.X if input_initialization_axes is None else input_initialization_axes.get(input_node, Axis.X) + ) + graph_state.register_input(node_map[input_node], q_index, init_axis=init_axis) # Register outputs with sequential qubit indices if outputs is not None: @@ -908,7 +990,11 @@ def from_base_graph_state( # Register inputs with same qubit indices for input_node, q_index in base.input_node_indices.items(): - graph_state.register_input(node_map[input_node], q_index) + graph_state.register_input( + node_map[input_node], + q_index, + init_axis=base.input_initialization_axes.get(input_node, Axis.X), + ) # Register outputs with same qubit indices for output_node, q_index in base.output_node_indices.items(): @@ -1018,11 +1104,19 @@ def compose( # noqa: C901, PLR0912 # Register input nodes with preserved qindices for input_node, q_index in graph1.input_node_indices.items(): - composed_graph.register_input(node_map1[input_node], q_index) + composed_graph.register_input( + node_map1[input_node], + q_index, + init_axis=graph1.input_initialization_axes.get(input_node, Axis.X), + ) for input_node, q_index in graph2.input_node_indices.items(): if q_index not in target_q_indices: - composed_graph.register_input(node_map2[input_node], q_index) + composed_graph.register_input( + node_map2[input_node], + q_index, + init_axis=graph2.input_initialization_axes.get(input_node, Axis.X), + ) # Register output nodes with preserved qindices for output_node, q_index in graph1.output_node_indices.items(): diff --git a/graphqomb/pattern.py b/graphqomb/pattern.py index 259b157f..a8421080 100644 --- a/graphqomb/pattern.py +++ b/graphqomb/pattern.py @@ -20,9 +20,32 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterator + from graphqomb.common import Axis from graphqomb.pauli_frame import PauliFrame +def _empty_input_coordinates() -> dict[int, tuple[float, ...]]: + r"""Return an empty input-coordinate map. + + Returns + ------- + `dict`\[`int`, `tuple`\[`float`, ...\]\] + Empty input-coordinate map. + """ + return {} + + +def _empty_input_initialization_axes() -> dict[int, Axis]: + r"""Return an empty input-initialization axis map. + + Returns + ------- + `dict`\[`int`, `Axis`\] + Empty input-initialization axis map. + """ + return {} + + @dataclasses.dataclass(frozen=True) class Pattern(Sequence[Command]): r"""Pattern class. @@ -39,13 +62,16 @@ class Pattern(Sequence[Command]): Pauli frame of the pattern to track the Pauli state of each node input_coordinates : `dict`\[`int`, `tuple`\[`float`, ...\]\] Coordinates for input nodes (2D or 3D) + input_initialization_axes : `dict`\[`int`, `Axis`\] + Pauli initialization axes for input nodes. Missing inputs default to Axis.X. """ input_node_indices: dict[int, int] output_node_indices: dict[int, int] commands: tuple[Command, ...] pauli_frame: PauliFrame - input_coordinates: dict[int, tuple[float, ...]] = dataclasses.field(default_factory=dict) + input_coordinates: dict[int, tuple[float, ...]] = dataclasses.field(default_factory=_empty_input_coordinates) + input_initialization_axes: dict[int, Axis] = dataclasses.field(default_factory=_empty_input_initialization_axes) def __len__(self) -> int: return len(self.commands) diff --git a/graphqomb/ptn_format.py b/graphqomb/ptn_format.py index 8765e667..76b38ed7 100644 --- a/graphqomb/ptn_format.py +++ b/graphqomb/ptn_format.py @@ -38,7 +38,8 @@ if TYPE_CHECKING: from collections.abc import Sequence -PTN_VERSION = 1 +PTN_VERSION = 2 +SUPPORTED_PTN_VERSIONS = frozenset({1, PTN_VERSION}) # Angle formatting/parsing lookup tables _ANGLE_TO_STR: dict[float, str] = { @@ -178,6 +179,13 @@ def _write_header(out: StringIO, pattern: Pattern) -> None: f"{node}:{qidx}" for node, qidx in sorted(pattern.input_node_indices.items(), key=operator.itemgetter(1)) ] out.write(f".input {' '.join(input_parts)}\n") + input_basis_parts = [ + f"{node}:{axis.name}" + for node, qidx in sorted(pattern.input_node_indices.items(), key=operator.itemgetter(1)) + if (axis := pattern.input_initialization_axes.get(node, Axis.X)) is not Axis.X + ] + if input_basis_parts: + out.write(f".input_basis {' '.join(input_basis_parts)}\n") if pattern.output_node_indices: output_parts = [ @@ -381,6 +389,38 @@ def _parse_node_qubit_pairs(parts: Sequence[str]) -> dict[int, int]: return result +def _parse_node_axis_pairs(parts: Sequence[str]) -> dict[int, Axis]: + r"""Parse node:axis pairs from string parts. + + Returns + ------- + `dict`\[`int`, `Axis`\] + Mapping from node to Pauli axis. + + Raises + ------ + ValueError + If any pair is malformed, duplicated, or uses an invalid axis. + """ + result: dict[int, Axis] = {} + for part in parts: + pair = part.split(":") + if len(pair) != 2: # noqa: PLR2004 + msg = f"Invalid node:axis pair: {part!r}" + raise ValueError(msg) + node_str, axis_str = pair + node = _parse_int(node_str, "node") + if node in result: + msg = f"Duplicate input basis node: {node}" + raise ValueError(msg) + try: + result[node] = Axis[axis_str] + except KeyError as exc: + msg = f"Invalid input basis axis: {axis_str!r}" + raise ValueError(msg) from exc + return result + + def _parse_node_set(parts: Sequence[str], label: str) -> set[int]: r"""Parse a non-empty set of node ids. @@ -454,6 +494,17 @@ def _empty_coordinates() -> dict[int, tuple[float, ...]]: return {} +def _empty_input_initialization_axes() -> dict[int, Axis]: + r"""Return an empty input-initialization axis map. + + Returns + ------- + `dict`\[`int`, `Axis`\] + Empty input-initialization axis map. + """ + return {} + + def _empty_commands() -> list[Command]: r"""Return an empty command list. @@ -499,6 +550,8 @@ class _PatternData: Mapping from node to qubit index for output nodes. input_coordinates : `dict`[`int`, `tuple`[`float`, ...]] Coordinates for input nodes. + input_initialization_axes : `dict`[`int`, `Axis`] + Pauli initialization axes for input nodes. commands : `list`[`Command`] List of quantum commands. xflow : `dict`[`int`, `set`[`int`]] @@ -512,6 +565,7 @@ class _PatternData: input_node_indices: dict[int, int] = field(default_factory=_empty_node_index_map) output_node_indices: dict[int, int] = field(default_factory=_empty_node_index_map) input_coordinates: dict[int, tuple[float, ...]] = field(default_factory=_empty_coordinates) + input_initialization_axes: dict[int, Axis] = field(default_factory=_empty_input_initialization_axes) commands: list[Command] = field(default_factory=_empty_commands) xflow: dict[int, set[int]] = field(default_factory=_empty_node_set_map) zflow: dict[int, set[int]] = field(default_factory=_empty_node_set_map) @@ -529,6 +583,7 @@ class _LoadedGraphState(BaseGraphState): _edges: set[tuple[int, int]] _meas_bases: dict[int, MeasBasis] _coordinates: dict[int, tuple[float, ...]] + _input_initialization_axes: dict[int, Axis] _neighbors: dict[int, set[int]] = field(init=False, repr=False) def __post_init__(self) -> None: @@ -538,6 +593,7 @@ def __post_init__(self) -> None: self._edges = {(node1, node2) if node1 < node2 else (node2, node1) for node1, node2 in self._edges} self._meas_bases = dict(self._meas_bases) self._coordinates = dict(self._coordinates) + self._input_initialization_axes = dict(self._input_initialization_axes) self._neighbors: dict[int, set[int]] = {node: set() for node in self._nodes} for node1, node2 in self._edges: self._neighbors.setdefault(node1, set()).add(node2) @@ -547,6 +603,10 @@ def __post_init__(self) -> None: def input_node_indices(self) -> dict[int, int]: return self._input_node_indices.copy() + @property + def input_initialization_axes(self) -> dict[int, Axis]: + return {node: self._input_initialization_axes.get(node, Axis.X) for node in self._input_node_indices} + @property def output_node_indices(self) -> dict[int, int]: return self._output_node_indices.copy() @@ -575,7 +635,7 @@ def add_edge(self, node1: int, node2: int) -> None: msg = "Loaded .ptn graph states are read-only" raise NotImplementedError(msg) - def register_input(self, node: int, q_index: int) -> None: + def register_input(self, node: int, q_index: int, *, init_axis: Axis = Axis.X) -> None: msg = "Loaded .ptn graph states are read-only" raise NotImplementedError(msg) @@ -615,6 +675,27 @@ def _command_nodes(cmd: Command) -> set[int]: return set() +def _input_initialization_axes_from_data(data: _PatternData) -> dict[int, Axis]: + r"""Validate and normalize parsed input initialization axes. + + Returns + ------- + `dict`\[`int`, `Axis`\] + Normalized input initialization axes. + + Raises + ------ + ValueError + If an input basis is specified for a non-input node. + """ + non_input_basis_nodes = set(data.input_initialization_axes) - set(data.input_node_indices) + if non_input_basis_nodes: + msg = f"Input basis specified for non-input node(s): {sorted(non_input_basis_nodes)}" + raise ValueError(msg) + + return {node: data.input_initialization_axes.get(node, Axis.X) for node in data.input_node_indices} + + def _build_pattern(data: _PatternData) -> Pattern: """Build a Pattern from parsed .ptn data. @@ -628,6 +709,8 @@ def _build_pattern(data: _PatternData) -> Pattern: ValueError If parsed commands contain invalid graph structure. """ + input_initialization_axes = _input_initialization_axes_from_data(data) + nodes: set[int] = set(data.input_node_indices) | set(data.output_node_indices) | set(data.input_coordinates) edges: set[tuple[int, int]] = set() meas_bases: dict[int, MeasBasis] = {} @@ -665,6 +748,7 @@ def _build_pattern(data: _PatternData) -> Pattern: _edges=edges, _meas_bases=meas_bases, _coordinates=coordinates, + _input_initialization_axes=input_initialization_axes, ) pauli_frame = PauliFrame( graphstate, @@ -679,6 +763,7 @@ def _build_pattern(data: _PatternData) -> Pattern: commands=tuple(data.commands), pauli_frame=pauli_frame, input_coordinates=dict(data.input_coordinates), + input_initialization_axes=input_initialization_axes, ) @@ -688,7 +773,7 @@ class _Parser: def __init__(self) -> None: self.result = _PatternData() self.current_timeslice = -1 - self.version_found = False + self.version: int | None = None def parse(self, s: str) -> Pattern: r"""Parse the input string and return Pattern. @@ -711,9 +796,12 @@ def parse(self, s: str) -> Pattern: for line_num, raw_line in enumerate(s.splitlines(), 1): self._parse_line(line_num, raw_line) - if not self.version_found: + if self.version is None: msg = "Missing .version directive" raise ValueError(msg) + if self.version == 1 and self.result.input_initialization_axes: + msg = ".input_basis requires .ptn version 2 or later" + raise ValueError(msg) return _build_pattern(self.result) @@ -759,6 +847,8 @@ def _parse_directive(self, line: str) -> None: self._handle_version(content) elif directive == ".input": self.result.input_node_indices = _parse_node_qubit_pairs(content.split()) + elif directive == ".input_basis": + self.result.input_initialization_axes = _parse_node_axis_pairs(content.split()) elif directive == ".output": self.result.output_node_indices = _parse_node_qubit_pairs(content.split()) elif directive == ".coord": @@ -787,10 +877,11 @@ def _handle_version(self, content: str) -> None: If the version is unsupported. """ version = _parse_int(content, "version") - if version != PTN_VERSION: - msg = f"Unsupported .ptn version: {version} (expected {PTN_VERSION})" + if version not in SUPPORTED_PTN_VERSIONS: + supported = ", ".join(str(supported_version) for supported_version in sorted(SUPPORTED_PTN_VERSIONS)) + msg = f"Unsupported .ptn version: {version} (supported: {supported})" raise ValueError(msg) - self.version_found = True + self.version = version def _handle_coord(self, content: str) -> None: """Handle .coord directive. diff --git a/graphqomb/qompiler.py b/graphqomb/qompiler.py index d49dec39..14858a92 100644 --- a/graphqomb/qompiler.py +++ b/graphqomb/qompiler.py @@ -149,4 +149,5 @@ def _qompile( commands=tuple(commands), pauli_frame=pauli_frame, input_coordinates=input_coords, + input_initialization_axes=graph.input_initialization_axes, ) diff --git a/graphqomb/simulator.py b/graphqomb/simulator.py index 559bc2ec..7ef0da20 100644 --- a/graphqomb/simulator.py +++ b/graphqomb/simulator.py @@ -16,13 +16,15 @@ import numpy as np from graphqomb.command import TICK, E, M, N -from graphqomb.common import MeasBasis, Plane +from graphqomb.common import Axis, MeasBasis, Plane from graphqomb.gates import MultiGate, SingleGate, TwoQubitGate from graphqomb.pattern import is_runnable from graphqomb.rng import ensure_rng from graphqomb.statevec import StateVector if TYPE_CHECKING: + from numpy.typing import NDArray + from graphqomb.circuit import BaseCircuit from graphqomb.command import Command from graphqomb.gates import Gate @@ -31,6 +33,11 @@ _X_MATRIX = np.asarray([[0, 1], [1, 0]], dtype=np.complex128) _Z_MATRIX = np.asarray([[1, 0], [0, -1]], dtype=np.complex128) +_INPUT_STATE_VECTORS: dict[Axis, NDArray[np.complex128]] = { + Axis.X: np.asarray([1.0, 1.0], dtype=np.complex128) / np.sqrt(2), + Axis.Y: np.asarray([1.0, 1.0j], dtype=np.complex128) / np.sqrt(2), + Axis.Z: np.asarray([1.0, 0.0], dtype=np.complex128), +} class SimulatorBackend(Enum): @@ -146,8 +153,11 @@ def __init__( is_runnable(self.__pattern) if backend == SimulatorBackend.StateVector: - # Note: deterministic check skipped for now - self.state = StateVector.from_num_qubits(len(self.__pattern.input_node_indices)) + input_states = [ + _INPUT_STATE_VECTORS[self.__pattern.input_initialization_axes.get(node, Axis.X)] + for node in self.node_indices + ] + self.state = StateVector.from_product_states(input_states) elif backend == SimulatorBackend.DensityMatrix: raise NotImplementedError else: diff --git a/graphqomb/statevec.py b/graphqomb/statevec.py index 59c795ba..bd94e4d7 100644 --- a/graphqomb/statevec.py +++ b/graphqomb/statevec.py @@ -121,6 +121,34 @@ def from_num_qubits(num_qubits: int) -> StateVector: raise ValueError(msg) return StateVector(np.full((2,) * num_qubits, 1 / math.sqrt(2**num_qubits), dtype=np.complex128)) + @staticmethod + def from_product_states(states: Sequence[ArrayLike]) -> StateVector: + r"""Create a state vector from one-qubit product states. + + Parameters + ---------- + states : sequence of array-like + One-qubit state vectors in external qubit order. + + Returns + ------- + `StateVector` + The resulting product state vector. + + Raises + ------ + ValueError + If any one-qubit state does not have exactly two amplitudes. + """ + result = np.asarray(1.0, dtype=np.complex128) + for state in states: + one_qubit_state = np.asarray(state, dtype=np.complex128) + if one_qubit_state.size != 2: # noqa: PLR2004 + msg = "Each product-state factor must have exactly two amplitudes." + raise ValueError(msg) + result = np.asarray(np.kron(result, one_qubit_state.reshape(2)), dtype=np.complex128) + return StateVector(result) + @staticmethod def tensor_product(a: StateVector, b: StateVector) -> StateVector: """Tensor product with other state vector, self ⊗ other. diff --git a/graphqomb/stim_compiler.py b/graphqomb/stim_compiler.py index db62cd84..4cc8e16f 100644 --- a/graphqomb/stim_compiler.py +++ b/graphqomb/stim_compiler.py @@ -79,7 +79,8 @@ def _emit_input_nodes(self) -> None: coordinates = self._pattern.input_coordinates if self._emit_qubit_coords else None for node in self._pattern.input_node_indices: coord = coordinates.get(node) if coordinates else None - self._process_prepare(node, coord, is_input=True) + axis = self._pattern.input_initialization_axes.get(node, Axis.X) + self._process_prepare(node, coord, is_input=True, init_axis=axis) def _process_commands(self) -> None: for cmd in self._pattern: @@ -95,7 +96,14 @@ def _process_commands(self) -> None: msg = f"Unsupported command for stim compilation: {type(cmd).__name__}" raise TypeError(msg) - def _process_prepare(self, node: int, coordinate: tuple[float, ...] | None, *, is_input: bool) -> None: + def _process_prepare( + self, + node: int, + coordinate: tuple[float, ...] | None, + *, + is_input: bool, + init_axis: Axis = Axis.X, + ) -> None: event = PrepareEvent(time=self._tick, node=self._node_info(node), is_input=is_input) ops = self._validate_ops_for_event(event, self._collect_noise_ops_from_models(lambda m: m.on_prepare(event))) default_placement = default_noise_placement(event) @@ -104,7 +112,8 @@ def _process_prepare(self, node: int, coordinate: tuple[float, ...] | None, *, i coord = coordinate if self._emit_qubit_coords else None if coord is not None: self._stim_io.write(f"QUBIT_COORDS({', '.join(str(c) for c in coord)}) {node}\n") - self._stim_io.write(f"RX {node}\n") + reset_instr = {Axis.X: "RX", Axis.Y: "RY", Axis.Z: "R"}[init_axis] + self._stim_io.write(f"{reset_instr} {node}\n") self._rec_index += self._emit_noise_ops(ops, NoisePlacement.AFTER, default_placement) self._alive_nodes.add(node) diff --git a/graphqomb/stim_importer.py b/graphqomb/stim_importer.py index e6f80fa4..dd3c45ce 100644 --- a/graphqomb/stim_importer.py +++ b/graphqomb/stim_importer.py @@ -339,10 +339,7 @@ def _mpp_fragment( ) -> _Fragment: text = _stim_text_with_coords(block, coordinate_by_stim_id=context.coordinate_by_stim_id) extraction = stabilizer_code_from_stim_text(text, coord_dims=context.coord_dims) - qubit_indices = { - column: context.stim_to_qubit[stim_id] - for column, stim_id in extraction.column_to_stim.items() - } + qubit_indices = {column: context.stim_to_qubit[stim_id] for column, stim_id in extraction.column_to_stim.items()} z_base = 2 * block_index result = build_graph_state(extraction.code, z_base=z_base, data_as_io=True, qubit_indices=qubit_indices) xflow, zflow = _mpp_flow(result, z_base=z_base) diff --git a/pyproject.toml b/pyproject.toml index 67155410..e79544a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ dev = [ "pytest==9.0.3", "pytest-cov==7.1.0", "ruff==0.15.17", + "stim>=1.15", "types-networkx==3.6.1.20260518", ] doc = [ diff --git a/tests/test_graph_compose.py b/tests/test_graph_compose.py index 510a8e00..d0a7e5ac 100644 --- a/tests/test_graph_compose.py +++ b/tests/test_graph_compose.py @@ -4,7 +4,7 @@ import pytest -from graphqomb.common import Plane, PlannerMeasBasis +from graphqomb.common import Axis, Plane, PlannerMeasBasis from graphqomb.graphstate import BaseGraphState, GraphState, compose @@ -170,6 +170,36 @@ def test_compose_preserves_measurement_bases() -> None: assert composed.meas_bases[mapped_node2] == meas_basis +def test_compose_preserves_surviving_input_initialization_axes() -> None: + """Composition preserves initialization axes for inputs that remain inputs.""" + graph1 = GraphState() + g1_in = graph1.add_node() + g1_out = graph1.add_node() + graph1.add_edge(g1_in, g1_out) + graph1.register_input(g1_in, 0, init_axis=Axis.Y) + graph1.register_output(g1_out, 1) + graph1.assign_meas_basis(g1_in, PlannerMeasBasis(Plane.XY, 0.0)) + + graph2 = GraphState() + g2_in_connected = graph2.add_node() + g2_in_survives = graph2.add_node() + g2_out = graph2.add_node() + graph2.add_edge(g2_in_connected, g2_out) + graph2.add_edge(g2_in_survives, g2_out) + graph2.register_input(g2_in_connected, 1, init_axis=Axis.Z) + graph2.register_input(g2_in_survives, 2, init_axis=Axis.Z) + graph2.register_output(g2_out, 3) + graph2.assign_meas_basis(g2_in_connected, PlannerMeasBasis(Plane.XY, 0.0)) + graph2.assign_meas_basis(g2_in_survives, PlannerMeasBasis(Plane.XY, 0.0)) + + composed, node_map1, node_map2 = compose(graph1, graph2) + + assert composed.input_initialization_axes == { + node_map1[g1_in]: Axis.Y, + node_map2[g2_in_survives]: Axis.Z, + } + + def test_compose_full_connection() -> None: """Test compose where all outputs of graph1 connect to inputs of graph2.""" # Create graph1: input [0] -> output [1, 2] diff --git a/tests/test_graphstate.py b/tests/test_graphstate.py index acd80f72..f707e59f 100644 --- a/tests/test_graphstate.py +++ b/tests/test_graphstate.py @@ -7,7 +7,7 @@ import numpy as np import pytest -from graphqomb.common import Plane, PlannerMeasBasis +from graphqomb.common import Axis, Plane, PlannerMeasBasis from graphqomb.euler import LocalClifford from graphqomb.graphstate import GraphState, bipartite_edges, compose, odd_neighbors @@ -102,6 +102,42 @@ def test_add_node_input_output(graph: GraphState) -> None: assert graph.output_node_indices[node_index] == q_index +def test_register_input_defaults_to_x_initialization_axis(graph: GraphState) -> None: + """Input nodes default to positive X-basis initialization.""" + node_index = graph.add_node() + + graph.register_input(node_index, 0) + + assert graph.input_initialization_axes == {node_index: Axis.X} + + +def test_register_input_accepts_pauli_initialization_axis(graph: GraphState) -> None: + """Input nodes can be initialized in a positive Pauli eigenstate.""" + node_index = graph.add_node() + + graph.register_input(node_index, 0, init_axis=Axis.Y) + + assert graph.input_initialization_axes == {node_index: Axis.Y} + + +def test_assign_input_initialization_axis_updates_input(graph: GraphState) -> None: + """Input initialization axes can be assigned after input registration.""" + node_index = graph.add_node() + graph.register_input(node_index, 0) + + graph.assign_input_initialization_axis(node_index, Axis.Z) + + assert graph.input_initialization_axes == {node_index: Axis.Z} + + +def test_assign_input_initialization_axis_rejects_non_input(graph: GraphState) -> None: + """Only registered input nodes can receive an input initialization axis.""" + node_index = graph.add_node() + + with pytest.raises(ValueError, match="Node is not registered as an input node"): + graph.assign_input_initialization_axis(node_index, Axis.Z) + + def test_ensure_node_exists_raises(graph: GraphState) -> None: """Test ensuring a node exists in the graph.""" with pytest.raises(ValueError, match="Node does not exist node=1"): diff --git a/tests/test_graphstate_bulk_init.py b/tests/test_graphstate_bulk_init.py index 17631110..1902dcc7 100644 --- a/tests/test_graphstate_bulk_init.py +++ b/tests/test_graphstate_bulk_init.py @@ -6,7 +6,7 @@ import pytest -from graphqomb.common import Plane, PlannerMeasBasis +from graphqomb.common import Axis, Plane, PlannerMeasBasis from graphqomb.graphstate import GraphState @@ -76,6 +76,22 @@ def test_from_graph_with_multiple_inputs_outputs() -> None: assert gs.output_node_indices == {node_map[outputs[0]]: 0, node_map[outputs[1]]: 1} +def test_from_graph_with_input_initialization_axes() -> None: + """Test from_graph() preserves specified input initialization axes.""" + nodes = ["a", "b", "c"] + edges = [("a", "c"), ("b", "c")] + inputs = ["a", "b"] + + gs, node_map = GraphState.from_graph( + nodes=nodes, + edges=edges, + inputs=inputs, + input_initialization_axes={"a": Axis.Y, "b": Axis.Z}, + ) + + assert gs.input_initialization_axes == {node_map["a"]: Axis.Y, node_map["b"]: Axis.Z} + + def test_from_graph_with_meas_bases() -> None: """Test from_graph() with measurement bases.""" nodes = ["a", "b", "c"] @@ -213,6 +229,19 @@ def test_from_base_graph_state_preserves_indices() -> None: assert dst.output_node_indices[node_map[n1]] == 10 +def test_from_base_graph_state_preserves_input_initialization_axes() -> None: + """Test from_base_graph_state() preserves input initialization axes.""" + src = GraphState() + n0 = src.add_node() + n1 = src.add_node() + src.register_input(n0, 0, init_axis=Axis.Y) + src.register_input(n1, 1, init_axis=Axis.Z) + + dst, node_map = GraphState.from_base_graph_state(src) + + assert dst.input_initialization_axes == {node_map[n0]: Axis.Y, node_map[n1]: Axis.Z} + + def test_from_base_graph_state_independence() -> None: """Test that modifications to copied graph don't affect original.""" # Create source graph diff --git a/tests/test_ptn_format.py b/tests/test_ptn_format.py index ad14ec6d..60521566 100644 --- a/tests/test_ptn_format.py +++ b/tests/test_ptn_format.py @@ -104,6 +104,7 @@ def assert_pattern_equivalent(actual: Pattern, expected: Pattern) -> None: assert actual.input_node_indices == expected.input_node_indices assert actual.output_node_indices == expected.output_node_indices assert actual.input_coordinates == expected.input_coordinates + assert actual.input_initialization_axes == expected.input_initialization_axes assert actual.pauli_frame.xflow == expected.pauli_frame.xflow assert actual.pauli_frame.zflow == expected.pauli_frame.zflow assert actual.pauli_frame.parity_check_group == expected.pauli_frame.parity_check_group @@ -116,13 +117,102 @@ def test_dumps_basic() -> None: pattern = create_simple_pattern() ptn_str = dumps(pattern) - assert ".version 1" in ptn_str + assert ".version 2" in ptn_str assert ".input" in ptn_str assert ".output" in ptn_str assert "#======== QUANTUM ========" in ptn_str assert "#======== CLASSICAL ========" in ptn_str +def test_dumps_omits_default_input_basis() -> None: + """Default X-basis input initialization is implicit in .ptn output.""" + pattern = create_simple_pattern() + + assert ".input_basis" not in dumps(pattern) + + +def test_ptn_roundtrip_preserves_input_initialization_axes() -> None: + """Non-default input initialization axes survive .ptn roundtrip.""" + graph = GraphState() + in0 = graph.add_node() + in1 = graph.add_node() + out0 = graph.add_node() + out1 = graph.add_node() + + graph.register_input(in0, 0, init_axis=Axis.Y) + graph.register_input(in1, 1, init_axis=Axis.Z) + graph.register_output(out0, 0) + graph.register_output(out1, 1) + graph.add_edge(in0, out0) + graph.add_edge(in1, out1) + graph.assign_meas_basis(in0, AxisMeasBasis(Axis.X, Sign.PLUS)) + graph.assign_meas_basis(in1, AxisMeasBasis(Axis.X, Sign.PLUS)) + + pattern = qompile(graph, {in0: {out0}, in1: {out1}}) + ptn_str = dumps(pattern) + loaded = loads(ptn_str) + + assert ".input_basis" in ptn_str + assert loaded.input_initialization_axes == {in0: Axis.Y, in1: Axis.Z} + + +def test_ptn_loads_legacy_input_without_input_basis_as_x() -> None: + """Existing .ptn files without .input_basis default inputs to X initialization.""" + ptn_str = """# GraphQOMB Pattern Format v1 +.version 1 +.input 0:0 +.output 1:0 + +[0] +E 0 1 +M 0 X + + +.xflow 0 -> 1 +""" + + loaded = loads(ptn_str) + + assert loaded.input_initialization_axes == {0: Axis.X} + + +def test_ptn_load_rejects_input_basis_for_non_input_node() -> None: + """Input basis directives can only reference input nodes.""" + ptn_str = """# GraphQOMB Pattern Format v2 +.version 2 +.input 0:0 +.input_basis 1:Z +.output 1:0 + +[0] +E 0 1 +M 0 X + + +.xflow 0 -> 1 +""" + + with pytest.raises(ValueError, match="Input basis specified for non-input node"): + loads(ptn_str) + + +def test_ptn_load_rejects_input_basis_in_legacy_version() -> None: + """Version 1 .ptn files cannot use the version 2 input-basis directive.""" + ptn_str = """# GraphQOMB Pattern Format v1 +.version 1 +.input 0:0 +.input_basis 0:Z +.output 1:0 + +[0] +E 0 1 +M 0 X + + +.xflow 0 -> 1 +""" + + with pytest.raises(ValueError, match=r"\.input_basis requires \.ptn version 2"): + loads(ptn_str) + + def test_dumps_contains_commands() -> None: """Test that dumps includes all command types.""" pattern = create_simple_pattern() diff --git a/tests/test_simulator.py b/tests/test_simulator.py index ec12f2fc..3ae6d7f5 100644 --- a/tests/test_simulator.py +++ b/tests/test_simulator.py @@ -13,10 +13,15 @@ from graphqomb.statevec import StateVector -def _single_output_pattern(*, measured: bool, axis: Axis = Axis.Z) -> tuple[Pattern, int]: +def _single_output_pattern( + *, + measured: bool, + axis: Axis = Axis.Z, + init_axis: Axis = Axis.X, +) -> tuple[Pattern, int]: graph = GraphState() node = graph.add_node() - graph.register_input(node, 0) + graph.register_input(node, 0, init_axis=init_axis) graph.register_output(node, 0) pauli_frame = PauliFrame(graph, xflow={}, zflow={}) @@ -26,10 +31,41 @@ def _single_output_pattern(*, measured: bool, axis: Axis = Axis.Z) -> tuple[Patt output_node_indices=graph.output_node_indices, commands=commands, pauli_frame=pauli_frame, + input_initialization_axes=graph.input_initialization_axes, ) return pattern, node +def test_pattern_simulator_initializes_input_in_x_basis() -> None: + """PatternSimulator initializes X-axis inputs as |+>.""" + pattern, _ = _single_output_pattern(measured=False, init_axis=Axis.X) + simulator = PatternSimulator(pattern, SimulatorBackend.StateVector) + + simulator.simulate() + + np.testing.assert_allclose(simulator.state.state(), np.asarray([1.0, 1.0]) / np.sqrt(2)) + + +def test_pattern_simulator_initializes_input_in_y_basis() -> None: + """PatternSimulator initializes Y-axis inputs as |Y+>.""" + pattern, _ = _single_output_pattern(measured=False, init_axis=Axis.Y) + simulator = PatternSimulator(pattern, SimulatorBackend.StateVector) + + simulator.simulate() + + np.testing.assert_allclose(simulator.state.state(), np.asarray([1.0, 1.0j]) / np.sqrt(2)) + + +def test_pattern_simulator_initializes_input_in_z_basis() -> None: + """PatternSimulator initializes Z-axis inputs as |0>.""" + pattern, _ = _single_output_pattern(measured=False, init_axis=Axis.Z) + simulator = PatternSimulator(pattern, SimulatorBackend.StateVector) + + simulator.simulate() + + np.testing.assert_allclose(simulator.state.state(), np.asarray([1.0, 0.0])) + + def test_pattern_simulator_applies_output_x_frame_to_statevector() -> None: """An unmeasured output statevector should include pending X frame corrections.""" pattern, node = _single_output_pattern(measured=False) diff --git a/tests/test_stim_compiler.py b/tests/test_stim_compiler.py index 116c76e3..40ddfc33 100644 --- a/tests/test_stim_compiler.py +++ b/tests/test_stim_compiler.py @@ -136,6 +136,52 @@ def test_stim_compile_basic_pattern() -> None: assert stim_str.count("\n") > 0 +@pytest.mark.parametrize( + ("init_axis", "expected_reset"), + [ + (Axis.X, "RX"), + (Axis.Y, "RY"), + (Axis.Z, "R"), + ], +) +def test_stim_compile_uses_input_initialization_axis(init_axis: Axis, expected_reset: str) -> None: + """Input initialization axes choose the corresponding Stim reset instruction.""" + graph = GraphState() + in_node = graph.add_node() + out_node = graph.add_node() + + graph.register_input(in_node, 0, init_axis=init_axis) + graph.register_output(out_node, 0) + graph.add_edge(in_node, out_node) + graph.assign_meas_basis(in_node, AxisMeasBasis(Axis.X, Sign.PLUS)) + + pattern = qompile(graph, {in_node: {out_node}}) + stim_lines = stim_compile(pattern).splitlines() + + assert f"{expected_reset} {in_node}" in stim_lines + + +def test_stim_compile_keeps_non_input_preparations_in_x_basis() -> None: + """Only input reset instructions use the input initialization axis.""" + graph = GraphState() + in_node = graph.add_node() + mid_node = graph.add_node() + out_node = graph.add_node() + + graph.register_input(in_node, 0, init_axis=Axis.Z) + graph.register_output(out_node, 0) + graph.add_edge(in_node, mid_node) + graph.add_edge(mid_node, out_node) + graph.assign_meas_basis(in_node, AxisMeasBasis(Axis.X, Sign.PLUS)) + graph.assign_meas_basis(mid_node, AxisMeasBasis(Axis.X, Sign.PLUS)) + + pattern = qompile(graph, {in_node: {mid_node}, mid_node: {out_node}}) + stim_lines = stim_compile(pattern).splitlines() + + assert f"R {in_node}" in stim_lines + assert f"RX {mid_node}" in stim_lines + + def test_stim_compile_x_measurement() -> None: """Test X measurement compilation.""" pattern, meas_node, in_node = create_simple_pattern_x_measurement() diff --git a/uv.lock b/uv.lock index 167cdf13..687bef62 100644 --- a/uv.lock +++ b/uv.lock @@ -725,6 +725,7 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "stim" }, { name = "types-networkx" }, ] doc = [ @@ -766,6 +767,7 @@ requires-dist = [ { name = "scipy", specifier = ">=1.15.3" }, { name = "sphinx", marker = "extra == 'doc'", specifier = ">=8.1.3" }, { name = "sphinx-gallery", marker = "extra == 'doc'", specifier = ">=0.17.0" }, + { name = "stim", marker = "extra == 'dev'", specifier = ">=1.15" }, { name = "stim", marker = "extra == 'stim'", specifier = ">=1.15" }, { name = "types-networkx", marker = "extra == 'dev'", specifier = "==3.6.1.20260518" }, { name = "typing-extensions" }, From ce9282dfe3d3555f0168bd019361b08205121063 Mon Sep 17 00:00:00 2001 From: Masato Fukushima Date: Wed, 8 Jul 2026 00:21:17 -0400 Subject: [PATCH 3/3] Revert "Add Pauli input initialization" This reverts commit 62e9419aadb45f08bc0fca40d1db11ea1a509dc8. --- CHANGELOG.md | 3 - graphqomb/graphstate.py | 110 +++-------------------------- graphqomb/pattern.py | 28 +------- graphqomb/ptn_format.py | 105 ++------------------------- graphqomb/qompiler.py | 1 - graphqomb/simulator.py | 16 +---- graphqomb/statevec.py | 28 -------- graphqomb/stim_compiler.py | 15 +--- graphqomb/stim_importer.py | 5 +- pyproject.toml | 1 - tests/test_graph_compose.py | 32 +-------- tests/test_graphstate.py | 38 +--------- tests/test_graphstate_bulk_init.py | 31 +------- tests/test_ptn_format.py | 92 +----------------------- tests/test_simulator.py | 40 +---------- tests/test_stim_compiler.py | 46 ------------ uv.lock | 2 - 17 files changed, 32 insertions(+), 561 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba38f8f4..c411fb12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,15 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **QEC Stim MPP Import**: Added utilities for building `StabilizerCode` inputs from Stim `MPP` layers, including sparse Stim qubit id mapping, coordinate import, multi-layer selection, detector/logical-observable import, and the optional `graphqomb[stim]` extra. -- **Input Initialization Bases**: Added per-input positive Pauli eigenstate initialization via `GraphState.register_input(..., init_axis=...)` and `assign_input_initialization_axis()`, with `X+`, `Y+`, and `Z+` propagated through `qompile()`, `Pattern`, `PatternSimulator`, and Stim export. ### Changed - **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. -- **PTN Format**: Bumped exported `.ptn` files to format version 2 to record non-default input initialization bases with `.input_basis`; version 1 files remain readable and default inputs to `X+`. -- **Stim Compiler**: Input reset instructions now follow the stored initialization basis (`RX`, `RY`, or `R`) while non-input preparations remain `RX`. ### Removed diff --git a/graphqomb/graphstate.py b/graphqomb/graphstate.py index c7dd2463..6345866e 100644 --- a/graphqomb/graphstate.py +++ b/graphqomb/graphstate.py @@ -26,7 +26,7 @@ import typing_extensions -from graphqomb.common import Axis, MeasBasis, Plane, PlannerMeasBasis +from graphqomb.common import MeasBasis, Plane, PlannerMeasBasis from graphqomb.euler import update_lc_basis, update_lc_lc if TYPE_CHECKING: @@ -49,17 +49,6 @@ def input_node_indices(self) -> dict[int, int]: qubit indices map of input nodes. """ - @property - def input_initialization_axes(self) -> dict[int, Axis]: - r"""Return input initialization Pauli axes. - - Returns - ------- - `dict`\[`int`, `Axis`\] - map of input nodes to Pauli initialization axes. - """ - return dict.fromkeys(self.input_node_indices, Axis.X) - @property @abc.abstractmethod def output_node_indices(self) -> dict[int, int]: @@ -185,7 +174,7 @@ def number_of_edges(self) -> int: return len(self.edges) @abc.abstractmethod - def register_input(self, node: int, q_index: int, *, init_axis: Axis = Axis.X) -> None: + def register_input(self, node: int, q_index: int) -> None: """Mark the node as an input node. Parameters @@ -194,8 +183,6 @@ def register_input(self, node: int, q_index: int, *, init_axis: Axis = Axis.X) - node index q_index : `int` logical qubit index - init_axis : `Axis`, optional - Pauli axis for positive-eigenstate initialization, by default Axis.X """ @abc.abstractmethod @@ -257,7 +244,6 @@ class GraphState(BaseGraphState): """Minimal implementation of GraphState.""" __input_node_indices: dict[int, int] - __input_initialization_axes: dict[int, Axis] __output_node_indices: dict[int, int] __nodes: set[int] __neighbors: dict[int, set[int]] @@ -271,7 +257,6 @@ class GraphState(BaseGraphState): def __init__(self) -> None: self.__input_node_indices = {} - self.__input_initialization_axes = {} self.__output_node_indices = {} self.__nodes = set() self.__neighbors = {} @@ -293,18 +278,6 @@ def input_node_indices(self) -> dict[int, int]: """ return self.__input_node_indices.copy() - @property - @typing_extensions.override - def input_initialization_axes(self) -> dict[int, Axis]: - r"""Return input initialization Pauli axes. - - Returns - ------- - `dict`\[`int`, `Axis`\] - map of input nodes to Pauli initialization axes. - """ - return {node: self.__input_initialization_axes.get(node, Axis.X) for node in self.__input_node_indices} - @property @typing_extensions.override def output_node_indices(self) -> dict[int, int]: @@ -510,7 +483,6 @@ def remove_node(self, node: int) -> None: if node in self.output_node_indices: del self.__output_node_indices[node] - self.__input_initialization_axes.pop(node, None) self.__meas_bases.pop(node, None) self.__local_cliffords.pop(node, None) self.__coordinates.pop(node, None) @@ -542,7 +514,7 @@ def remove_edge(self, node1: int, node2: int) -> None: self.__neighbors[node2] -= {node1} @typing_extensions.override - def register_input(self, node: int, q_index: int, *, init_axis: Axis = Axis.X) -> None: + def register_input(self, node: int, q_index: int) -> None: """Mark the node as an input node. Parameters @@ -551,8 +523,6 @@ def register_input(self, node: int, q_index: int, *, init_axis: Axis = Axis.X) - node index q_index : `int` logical qubit index - init_axis : `Axis`, optional - Pauli axis for positive-eigenstate initialization, by default Axis.X Raises ------ @@ -566,44 +536,7 @@ def register_input(self, node: int, q_index: int, *, init_axis: Axis = Axis.X) - if q_index in self.input_node_indices.values(): msg = "The q_index already exists in input qubit indices" raise ValueError(msg) - self._check_input_initialization_axis(init_axis) self.__input_node_indices[node] = q_index - self.__input_initialization_axes[node] = init_axis - - def assign_input_initialization_axis(self, node: int, axis: Axis) -> None: - """Assign a Pauli initialization axis to an input node. - - Parameters - ---------- - node : `int` - input node index - axis : `Axis` - Pauli axis for positive-eigenstate initialization - - Raises - ------ - ValueError - If the node does not exist or is not registered as an input. - """ - self._ensure_node_exists(node) - if node not in self.__input_node_indices: - msg = f"Node is not registered as an input node: {node}" - raise ValueError(msg) - self._check_input_initialization_axis(axis) - self.__input_initialization_axes[node] = axis - - @staticmethod - def _check_input_initialization_axis(axis: Axis) -> None: - """Validate an input initialization axis. - - Raises - ------ - TypeError - If the axis is not an Axis value. - """ - if not isinstance(axis, Axis): - msg = "Input initialization axis must be one of Axis.X, Axis.Y, Axis.Z" - raise TypeError(msg) @typing_extensions.override def register_output(self, node: int, q_index: int) -> None: @@ -744,18 +677,14 @@ def _expand_input_local_cliffords(self) -> dict[int, LocalCliffordExpansion]: """ node_index_addition_map: dict[int, LocalCliffordExpansion] = {} new_input_indices: dict[int, int] = {} - new_input_initialization_axes: dict[int, Axis] = {} for input_node, q_index in self.input_node_indices.items(): - init_axis = self.input_initialization_axes[input_node] lc = self._pop_local_clifford(input_node) if lc is None: new_input_indices[input_node] = q_index - new_input_initialization_axes[input_node] = init_axis continue new_node_index0 = self.add_node() new_input_indices[new_node_index0] = q_index - new_input_initialization_axes[new_node_index0] = init_axis new_node_index1 = self.add_node() new_node_index2 = self.add_node() @@ -772,13 +701,8 @@ def _expand_input_local_cliffords(self) -> dict[int, LocalCliffordExpansion]: ) self.__input_node_indices = {} - self.__input_initialization_axes = {} for new_input_index, q_index in new_input_indices.items(): - self.register_input( - new_input_index, - q_index, - init_axis=new_input_initialization_axes[new_input_index], - ) + self.register_input(new_input_index, q_index) return node_index_addition_map @@ -830,7 +754,6 @@ def from_graph( # noqa: C901, PLR0912, PLR0913, PLR0917 outputs: Sequence[NodeT] | None = None, meas_bases: Mapping[NodeT, MeasBasis] | None = None, coordinates: Mapping[NodeT, tuple[float, ...]] | None = None, - input_initialization_axes: Mapping[NodeT, Axis] | None = None, ) -> tuple[GraphState, dict[NodeT, int]]: r"""Create a graph state from nodes and edges with arbitrary node types. @@ -855,8 +778,6 @@ def from_graph( # noqa: C901, PLR0912, PLR0913, PLR0917 Default is None (no bases assigned initially). coordinates : `collections.abc.Mapping`\[NodeT, `tuple`\[`float`, ...\]\] | `None`, optional Coordinates for nodes (2D or 3D). Default is None (no coordinates). - input_initialization_axes : `collections.abc.Mapping`\[NodeT, `Axis`\] | `None`, optional - Pauli initialization axes for input nodes. Default is None (all inputs use Axis.X). Returns ------- @@ -923,10 +844,7 @@ def from_graph( # noqa: C901, PLR0912, PLR0913, PLR0917 # Register inputs with sequential qubit indices if inputs is not None: for q_index, input_node in enumerate(inputs): - init_axis = ( - Axis.X if input_initialization_axes is None else input_initialization_axes.get(input_node, Axis.X) - ) - graph_state.register_input(node_map[input_node], q_index, init_axis=init_axis) + graph_state.register_input(node_map[input_node], q_index) # Register outputs with sequential qubit indices if outputs is not None: @@ -990,11 +908,7 @@ def from_base_graph_state( # Register inputs with same qubit indices for input_node, q_index in base.input_node_indices.items(): - graph_state.register_input( - node_map[input_node], - q_index, - init_axis=base.input_initialization_axes.get(input_node, Axis.X), - ) + graph_state.register_input(node_map[input_node], q_index) # Register outputs with same qubit indices for output_node, q_index in base.output_node_indices.items(): @@ -1104,19 +1018,11 @@ def compose( # noqa: C901, PLR0912 # Register input nodes with preserved qindices for input_node, q_index in graph1.input_node_indices.items(): - composed_graph.register_input( - node_map1[input_node], - q_index, - init_axis=graph1.input_initialization_axes.get(input_node, Axis.X), - ) + composed_graph.register_input(node_map1[input_node], q_index) for input_node, q_index in graph2.input_node_indices.items(): if q_index not in target_q_indices: - composed_graph.register_input( - node_map2[input_node], - q_index, - init_axis=graph2.input_initialization_axes.get(input_node, Axis.X), - ) + composed_graph.register_input(node_map2[input_node], q_index) # Register output nodes with preserved qindices for output_node, q_index in graph1.output_node_indices.items(): diff --git a/graphqomb/pattern.py b/graphqomb/pattern.py index a8421080..259b157f 100644 --- a/graphqomb/pattern.py +++ b/graphqomb/pattern.py @@ -20,32 +20,9 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterator - from graphqomb.common import Axis from graphqomb.pauli_frame import PauliFrame -def _empty_input_coordinates() -> dict[int, tuple[float, ...]]: - r"""Return an empty input-coordinate map. - - Returns - ------- - `dict`\[`int`, `tuple`\[`float`, ...\]\] - Empty input-coordinate map. - """ - return {} - - -def _empty_input_initialization_axes() -> dict[int, Axis]: - r"""Return an empty input-initialization axis map. - - Returns - ------- - `dict`\[`int`, `Axis`\] - Empty input-initialization axis map. - """ - return {} - - @dataclasses.dataclass(frozen=True) class Pattern(Sequence[Command]): r"""Pattern class. @@ -62,16 +39,13 @@ class Pattern(Sequence[Command]): Pauli frame of the pattern to track the Pauli state of each node input_coordinates : `dict`\[`int`, `tuple`\[`float`, ...\]\] Coordinates for input nodes (2D or 3D) - input_initialization_axes : `dict`\[`int`, `Axis`\] - Pauli initialization axes for input nodes. Missing inputs default to Axis.X. """ input_node_indices: dict[int, int] output_node_indices: dict[int, int] commands: tuple[Command, ...] pauli_frame: PauliFrame - input_coordinates: dict[int, tuple[float, ...]] = dataclasses.field(default_factory=_empty_input_coordinates) - input_initialization_axes: dict[int, Axis] = dataclasses.field(default_factory=_empty_input_initialization_axes) + input_coordinates: dict[int, tuple[float, ...]] = dataclasses.field(default_factory=dict) def __len__(self) -> int: return len(self.commands) diff --git a/graphqomb/ptn_format.py b/graphqomb/ptn_format.py index 76b38ed7..8765e667 100644 --- a/graphqomb/ptn_format.py +++ b/graphqomb/ptn_format.py @@ -38,8 +38,7 @@ if TYPE_CHECKING: from collections.abc import Sequence -PTN_VERSION = 2 -SUPPORTED_PTN_VERSIONS = frozenset({1, PTN_VERSION}) +PTN_VERSION = 1 # Angle formatting/parsing lookup tables _ANGLE_TO_STR: dict[float, str] = { @@ -179,13 +178,6 @@ def _write_header(out: StringIO, pattern: Pattern) -> None: f"{node}:{qidx}" for node, qidx in sorted(pattern.input_node_indices.items(), key=operator.itemgetter(1)) ] out.write(f".input {' '.join(input_parts)}\n") - input_basis_parts = [ - f"{node}:{axis.name}" - for node, qidx in sorted(pattern.input_node_indices.items(), key=operator.itemgetter(1)) - if (axis := pattern.input_initialization_axes.get(node, Axis.X)) is not Axis.X - ] - if input_basis_parts: - out.write(f".input_basis {' '.join(input_basis_parts)}\n") if pattern.output_node_indices: output_parts = [ @@ -389,38 +381,6 @@ def _parse_node_qubit_pairs(parts: Sequence[str]) -> dict[int, int]: return result -def _parse_node_axis_pairs(parts: Sequence[str]) -> dict[int, Axis]: - r"""Parse node:axis pairs from string parts. - - Returns - ------- - `dict`\[`int`, `Axis`\] - Mapping from node to Pauli axis. - - Raises - ------ - ValueError - If any pair is malformed, duplicated, or uses an invalid axis. - """ - result: dict[int, Axis] = {} - for part in parts: - pair = part.split(":") - if len(pair) != 2: # noqa: PLR2004 - msg = f"Invalid node:axis pair: {part!r}" - raise ValueError(msg) - node_str, axis_str = pair - node = _parse_int(node_str, "node") - if node in result: - msg = f"Duplicate input basis node: {node}" - raise ValueError(msg) - try: - result[node] = Axis[axis_str] - except KeyError as exc: - msg = f"Invalid input basis axis: {axis_str!r}" - raise ValueError(msg) from exc - return result - - def _parse_node_set(parts: Sequence[str], label: str) -> set[int]: r"""Parse a non-empty set of node ids. @@ -494,17 +454,6 @@ def _empty_coordinates() -> dict[int, tuple[float, ...]]: return {} -def _empty_input_initialization_axes() -> dict[int, Axis]: - r"""Return an empty input-initialization axis map. - - Returns - ------- - `dict`\[`int`, `Axis`\] - Empty input-initialization axis map. - """ - return {} - - def _empty_commands() -> list[Command]: r"""Return an empty command list. @@ -550,8 +499,6 @@ class _PatternData: Mapping from node to qubit index for output nodes. input_coordinates : `dict`[`int`, `tuple`[`float`, ...]] Coordinates for input nodes. - input_initialization_axes : `dict`[`int`, `Axis`] - Pauli initialization axes for input nodes. commands : `list`[`Command`] List of quantum commands. xflow : `dict`[`int`, `set`[`int`]] @@ -565,7 +512,6 @@ class _PatternData: input_node_indices: dict[int, int] = field(default_factory=_empty_node_index_map) output_node_indices: dict[int, int] = field(default_factory=_empty_node_index_map) input_coordinates: dict[int, tuple[float, ...]] = field(default_factory=_empty_coordinates) - input_initialization_axes: dict[int, Axis] = field(default_factory=_empty_input_initialization_axes) commands: list[Command] = field(default_factory=_empty_commands) xflow: dict[int, set[int]] = field(default_factory=_empty_node_set_map) zflow: dict[int, set[int]] = field(default_factory=_empty_node_set_map) @@ -583,7 +529,6 @@ class _LoadedGraphState(BaseGraphState): _edges: set[tuple[int, int]] _meas_bases: dict[int, MeasBasis] _coordinates: dict[int, tuple[float, ...]] - _input_initialization_axes: dict[int, Axis] _neighbors: dict[int, set[int]] = field(init=False, repr=False) def __post_init__(self) -> None: @@ -593,7 +538,6 @@ def __post_init__(self) -> None: self._edges = {(node1, node2) if node1 < node2 else (node2, node1) for node1, node2 in self._edges} self._meas_bases = dict(self._meas_bases) self._coordinates = dict(self._coordinates) - self._input_initialization_axes = dict(self._input_initialization_axes) self._neighbors: dict[int, set[int]] = {node: set() for node in self._nodes} for node1, node2 in self._edges: self._neighbors.setdefault(node1, set()).add(node2) @@ -603,10 +547,6 @@ def __post_init__(self) -> None: def input_node_indices(self) -> dict[int, int]: return self._input_node_indices.copy() - @property - def input_initialization_axes(self) -> dict[int, Axis]: - return {node: self._input_initialization_axes.get(node, Axis.X) for node in self._input_node_indices} - @property def output_node_indices(self) -> dict[int, int]: return self._output_node_indices.copy() @@ -635,7 +575,7 @@ def add_edge(self, node1: int, node2: int) -> None: msg = "Loaded .ptn graph states are read-only" raise NotImplementedError(msg) - def register_input(self, node: int, q_index: int, *, init_axis: Axis = Axis.X) -> None: + def register_input(self, node: int, q_index: int) -> None: msg = "Loaded .ptn graph states are read-only" raise NotImplementedError(msg) @@ -675,27 +615,6 @@ def _command_nodes(cmd: Command) -> set[int]: return set() -def _input_initialization_axes_from_data(data: _PatternData) -> dict[int, Axis]: - r"""Validate and normalize parsed input initialization axes. - - Returns - ------- - `dict`\[`int`, `Axis`\] - Normalized input initialization axes. - - Raises - ------ - ValueError - If an input basis is specified for a non-input node. - """ - non_input_basis_nodes = set(data.input_initialization_axes) - set(data.input_node_indices) - if non_input_basis_nodes: - msg = f"Input basis specified for non-input node(s): {sorted(non_input_basis_nodes)}" - raise ValueError(msg) - - return {node: data.input_initialization_axes.get(node, Axis.X) for node in data.input_node_indices} - - def _build_pattern(data: _PatternData) -> Pattern: """Build a Pattern from parsed .ptn data. @@ -709,8 +628,6 @@ def _build_pattern(data: _PatternData) -> Pattern: ValueError If parsed commands contain invalid graph structure. """ - input_initialization_axes = _input_initialization_axes_from_data(data) - nodes: set[int] = set(data.input_node_indices) | set(data.output_node_indices) | set(data.input_coordinates) edges: set[tuple[int, int]] = set() meas_bases: dict[int, MeasBasis] = {} @@ -748,7 +665,6 @@ def _build_pattern(data: _PatternData) -> Pattern: _edges=edges, _meas_bases=meas_bases, _coordinates=coordinates, - _input_initialization_axes=input_initialization_axes, ) pauli_frame = PauliFrame( graphstate, @@ -763,7 +679,6 @@ def _build_pattern(data: _PatternData) -> Pattern: commands=tuple(data.commands), pauli_frame=pauli_frame, input_coordinates=dict(data.input_coordinates), - input_initialization_axes=input_initialization_axes, ) @@ -773,7 +688,7 @@ class _Parser: def __init__(self) -> None: self.result = _PatternData() self.current_timeslice = -1 - self.version: int | None = None + self.version_found = False def parse(self, s: str) -> Pattern: r"""Parse the input string and return Pattern. @@ -796,12 +711,9 @@ def parse(self, s: str) -> Pattern: for line_num, raw_line in enumerate(s.splitlines(), 1): self._parse_line(line_num, raw_line) - if self.version is None: + if not self.version_found: msg = "Missing .version directive" raise ValueError(msg) - if self.version == 1 and self.result.input_initialization_axes: - msg = ".input_basis requires .ptn version 2 or later" - raise ValueError(msg) return _build_pattern(self.result) @@ -847,8 +759,6 @@ def _parse_directive(self, line: str) -> None: self._handle_version(content) elif directive == ".input": self.result.input_node_indices = _parse_node_qubit_pairs(content.split()) - elif directive == ".input_basis": - self.result.input_initialization_axes = _parse_node_axis_pairs(content.split()) elif directive == ".output": self.result.output_node_indices = _parse_node_qubit_pairs(content.split()) elif directive == ".coord": @@ -877,11 +787,10 @@ def _handle_version(self, content: str) -> None: If the version is unsupported. """ version = _parse_int(content, "version") - if version not in SUPPORTED_PTN_VERSIONS: - supported = ", ".join(str(supported_version) for supported_version in sorted(SUPPORTED_PTN_VERSIONS)) - msg = f"Unsupported .ptn version: {version} (supported: {supported})" + if version != PTN_VERSION: + msg = f"Unsupported .ptn version: {version} (expected {PTN_VERSION})" raise ValueError(msg) - self.version = version + self.version_found = True def _handle_coord(self, content: str) -> None: """Handle .coord directive. diff --git a/graphqomb/qompiler.py b/graphqomb/qompiler.py index 14858a92..d49dec39 100644 --- a/graphqomb/qompiler.py +++ b/graphqomb/qompiler.py @@ -149,5 +149,4 @@ def _qompile( commands=tuple(commands), pauli_frame=pauli_frame, input_coordinates=input_coords, - input_initialization_axes=graph.input_initialization_axes, ) diff --git a/graphqomb/simulator.py b/graphqomb/simulator.py index 7ef0da20..559bc2ec 100644 --- a/graphqomb/simulator.py +++ b/graphqomb/simulator.py @@ -16,15 +16,13 @@ import numpy as np from graphqomb.command import TICK, E, M, N -from graphqomb.common import Axis, MeasBasis, Plane +from graphqomb.common import MeasBasis, Plane from graphqomb.gates import MultiGate, SingleGate, TwoQubitGate from graphqomb.pattern import is_runnable from graphqomb.rng import ensure_rng from graphqomb.statevec import StateVector if TYPE_CHECKING: - from numpy.typing import NDArray - from graphqomb.circuit import BaseCircuit from graphqomb.command import Command from graphqomb.gates import Gate @@ -33,11 +31,6 @@ _X_MATRIX = np.asarray([[0, 1], [1, 0]], dtype=np.complex128) _Z_MATRIX = np.asarray([[1, 0], [0, -1]], dtype=np.complex128) -_INPUT_STATE_VECTORS: dict[Axis, NDArray[np.complex128]] = { - Axis.X: np.asarray([1.0, 1.0], dtype=np.complex128) / np.sqrt(2), - Axis.Y: np.asarray([1.0, 1.0j], dtype=np.complex128) / np.sqrt(2), - Axis.Z: np.asarray([1.0, 0.0], dtype=np.complex128), -} class SimulatorBackend(Enum): @@ -153,11 +146,8 @@ def __init__( is_runnable(self.__pattern) if backend == SimulatorBackend.StateVector: - input_states = [ - _INPUT_STATE_VECTORS[self.__pattern.input_initialization_axes.get(node, Axis.X)] - for node in self.node_indices - ] - self.state = StateVector.from_product_states(input_states) + # Note: deterministic check skipped for now + self.state = StateVector.from_num_qubits(len(self.__pattern.input_node_indices)) elif backend == SimulatorBackend.DensityMatrix: raise NotImplementedError else: diff --git a/graphqomb/statevec.py b/graphqomb/statevec.py index bd94e4d7..59c795ba 100644 --- a/graphqomb/statevec.py +++ b/graphqomb/statevec.py @@ -121,34 +121,6 @@ def from_num_qubits(num_qubits: int) -> StateVector: raise ValueError(msg) return StateVector(np.full((2,) * num_qubits, 1 / math.sqrt(2**num_qubits), dtype=np.complex128)) - @staticmethod - def from_product_states(states: Sequence[ArrayLike]) -> StateVector: - r"""Create a state vector from one-qubit product states. - - Parameters - ---------- - states : sequence of array-like - One-qubit state vectors in external qubit order. - - Returns - ------- - `StateVector` - The resulting product state vector. - - Raises - ------ - ValueError - If any one-qubit state does not have exactly two amplitudes. - """ - result = np.asarray(1.0, dtype=np.complex128) - for state in states: - one_qubit_state = np.asarray(state, dtype=np.complex128) - if one_qubit_state.size != 2: # noqa: PLR2004 - msg = "Each product-state factor must have exactly two amplitudes." - raise ValueError(msg) - result = np.asarray(np.kron(result, one_qubit_state.reshape(2)), dtype=np.complex128) - return StateVector(result) - @staticmethod def tensor_product(a: StateVector, b: StateVector) -> StateVector: """Tensor product with other state vector, self ⊗ other. diff --git a/graphqomb/stim_compiler.py b/graphqomb/stim_compiler.py index 4cc8e16f..db62cd84 100644 --- a/graphqomb/stim_compiler.py +++ b/graphqomb/stim_compiler.py @@ -79,8 +79,7 @@ def _emit_input_nodes(self) -> None: coordinates = self._pattern.input_coordinates if self._emit_qubit_coords else None for node in self._pattern.input_node_indices: coord = coordinates.get(node) if coordinates else None - axis = self._pattern.input_initialization_axes.get(node, Axis.X) - self._process_prepare(node, coord, is_input=True, init_axis=axis) + self._process_prepare(node, coord, is_input=True) def _process_commands(self) -> None: for cmd in self._pattern: @@ -96,14 +95,7 @@ def _process_commands(self) -> None: msg = f"Unsupported command for stim compilation: {type(cmd).__name__}" raise TypeError(msg) - def _process_prepare( - self, - node: int, - coordinate: tuple[float, ...] | None, - *, - is_input: bool, - init_axis: Axis = Axis.X, - ) -> None: + def _process_prepare(self, node: int, coordinate: tuple[float, ...] | None, *, is_input: bool) -> None: event = PrepareEvent(time=self._tick, node=self._node_info(node), is_input=is_input) ops = self._validate_ops_for_event(event, self._collect_noise_ops_from_models(lambda m: m.on_prepare(event))) default_placement = default_noise_placement(event) @@ -112,8 +104,7 @@ def _process_prepare( coord = coordinate if self._emit_qubit_coords else None if coord is not None: self._stim_io.write(f"QUBIT_COORDS({', '.join(str(c) for c in coord)}) {node}\n") - reset_instr = {Axis.X: "RX", Axis.Y: "RY", Axis.Z: "R"}[init_axis] - self._stim_io.write(f"{reset_instr} {node}\n") + self._stim_io.write(f"RX {node}\n") self._rec_index += self._emit_noise_ops(ops, NoisePlacement.AFTER, default_placement) self._alive_nodes.add(node) diff --git a/graphqomb/stim_importer.py b/graphqomb/stim_importer.py index dd3c45ce..e6f80fa4 100644 --- a/graphqomb/stim_importer.py +++ b/graphqomb/stim_importer.py @@ -339,7 +339,10 @@ def _mpp_fragment( ) -> _Fragment: text = _stim_text_with_coords(block, coordinate_by_stim_id=context.coordinate_by_stim_id) extraction = stabilizer_code_from_stim_text(text, coord_dims=context.coord_dims) - qubit_indices = {column: context.stim_to_qubit[stim_id] for column, stim_id in extraction.column_to_stim.items()} + qubit_indices = { + column: context.stim_to_qubit[stim_id] + for column, stim_id in extraction.column_to_stim.items() + } z_base = 2 * block_index result = build_graph_state(extraction.code, z_base=z_base, data_as_io=True, qubit_indices=qubit_indices) xflow, zflow = _mpp_flow(result, z_base=z_base) diff --git a/pyproject.toml b/pyproject.toml index e79544a5..67155410 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,6 @@ dev = [ "pytest==9.0.3", "pytest-cov==7.1.0", "ruff==0.15.17", - "stim>=1.15", "types-networkx==3.6.1.20260518", ] doc = [ diff --git a/tests/test_graph_compose.py b/tests/test_graph_compose.py index d0a7e5ac..510a8e00 100644 --- a/tests/test_graph_compose.py +++ b/tests/test_graph_compose.py @@ -4,7 +4,7 @@ import pytest -from graphqomb.common import Axis, Plane, PlannerMeasBasis +from graphqomb.common import Plane, PlannerMeasBasis from graphqomb.graphstate import BaseGraphState, GraphState, compose @@ -170,36 +170,6 @@ def test_compose_preserves_measurement_bases() -> None: assert composed.meas_bases[mapped_node2] == meas_basis -def test_compose_preserves_surviving_input_initialization_axes() -> None: - """Composition preserves initialization axes for inputs that remain inputs.""" - graph1 = GraphState() - g1_in = graph1.add_node() - g1_out = graph1.add_node() - graph1.add_edge(g1_in, g1_out) - graph1.register_input(g1_in, 0, init_axis=Axis.Y) - graph1.register_output(g1_out, 1) - graph1.assign_meas_basis(g1_in, PlannerMeasBasis(Plane.XY, 0.0)) - - graph2 = GraphState() - g2_in_connected = graph2.add_node() - g2_in_survives = graph2.add_node() - g2_out = graph2.add_node() - graph2.add_edge(g2_in_connected, g2_out) - graph2.add_edge(g2_in_survives, g2_out) - graph2.register_input(g2_in_connected, 1, init_axis=Axis.Z) - graph2.register_input(g2_in_survives, 2, init_axis=Axis.Z) - graph2.register_output(g2_out, 3) - graph2.assign_meas_basis(g2_in_connected, PlannerMeasBasis(Plane.XY, 0.0)) - graph2.assign_meas_basis(g2_in_survives, PlannerMeasBasis(Plane.XY, 0.0)) - - composed, node_map1, node_map2 = compose(graph1, graph2) - - assert composed.input_initialization_axes == { - node_map1[g1_in]: Axis.Y, - node_map2[g2_in_survives]: Axis.Z, - } - - def test_compose_full_connection() -> None: """Test compose where all outputs of graph1 connect to inputs of graph2.""" # Create graph1: input [0] -> output [1, 2] diff --git a/tests/test_graphstate.py b/tests/test_graphstate.py index f707e59f..acd80f72 100644 --- a/tests/test_graphstate.py +++ b/tests/test_graphstate.py @@ -7,7 +7,7 @@ import numpy as np import pytest -from graphqomb.common import Axis, Plane, PlannerMeasBasis +from graphqomb.common import Plane, PlannerMeasBasis from graphqomb.euler import LocalClifford from graphqomb.graphstate import GraphState, bipartite_edges, compose, odd_neighbors @@ -102,42 +102,6 @@ def test_add_node_input_output(graph: GraphState) -> None: assert graph.output_node_indices[node_index] == q_index -def test_register_input_defaults_to_x_initialization_axis(graph: GraphState) -> None: - """Input nodes default to positive X-basis initialization.""" - node_index = graph.add_node() - - graph.register_input(node_index, 0) - - assert graph.input_initialization_axes == {node_index: Axis.X} - - -def test_register_input_accepts_pauli_initialization_axis(graph: GraphState) -> None: - """Input nodes can be initialized in a positive Pauli eigenstate.""" - node_index = graph.add_node() - - graph.register_input(node_index, 0, init_axis=Axis.Y) - - assert graph.input_initialization_axes == {node_index: Axis.Y} - - -def test_assign_input_initialization_axis_updates_input(graph: GraphState) -> None: - """Input initialization axes can be assigned after input registration.""" - node_index = graph.add_node() - graph.register_input(node_index, 0) - - graph.assign_input_initialization_axis(node_index, Axis.Z) - - assert graph.input_initialization_axes == {node_index: Axis.Z} - - -def test_assign_input_initialization_axis_rejects_non_input(graph: GraphState) -> None: - """Only registered input nodes can receive an input initialization axis.""" - node_index = graph.add_node() - - with pytest.raises(ValueError, match="Node is not registered as an input node"): - graph.assign_input_initialization_axis(node_index, Axis.Z) - - def test_ensure_node_exists_raises(graph: GraphState) -> None: """Test ensuring a node exists in the graph.""" with pytest.raises(ValueError, match="Node does not exist node=1"): diff --git a/tests/test_graphstate_bulk_init.py b/tests/test_graphstate_bulk_init.py index 1902dcc7..17631110 100644 --- a/tests/test_graphstate_bulk_init.py +++ b/tests/test_graphstate_bulk_init.py @@ -6,7 +6,7 @@ import pytest -from graphqomb.common import Axis, Plane, PlannerMeasBasis +from graphqomb.common import Plane, PlannerMeasBasis from graphqomb.graphstate import GraphState @@ -76,22 +76,6 @@ def test_from_graph_with_multiple_inputs_outputs() -> None: assert gs.output_node_indices == {node_map[outputs[0]]: 0, node_map[outputs[1]]: 1} -def test_from_graph_with_input_initialization_axes() -> None: - """Test from_graph() preserves specified input initialization axes.""" - nodes = ["a", "b", "c"] - edges = [("a", "c"), ("b", "c")] - inputs = ["a", "b"] - - gs, node_map = GraphState.from_graph( - nodes=nodes, - edges=edges, - inputs=inputs, - input_initialization_axes={"a": Axis.Y, "b": Axis.Z}, - ) - - assert gs.input_initialization_axes == {node_map["a"]: Axis.Y, node_map["b"]: Axis.Z} - - def test_from_graph_with_meas_bases() -> None: """Test from_graph() with measurement bases.""" nodes = ["a", "b", "c"] @@ -229,19 +213,6 @@ def test_from_base_graph_state_preserves_indices() -> None: assert dst.output_node_indices[node_map[n1]] == 10 -def test_from_base_graph_state_preserves_input_initialization_axes() -> None: - """Test from_base_graph_state() preserves input initialization axes.""" - src = GraphState() - n0 = src.add_node() - n1 = src.add_node() - src.register_input(n0, 0, init_axis=Axis.Y) - src.register_input(n1, 1, init_axis=Axis.Z) - - dst, node_map = GraphState.from_base_graph_state(src) - - assert dst.input_initialization_axes == {node_map[n0]: Axis.Y, node_map[n1]: Axis.Z} - - def test_from_base_graph_state_independence() -> None: """Test that modifications to copied graph don't affect original.""" # Create source graph diff --git a/tests/test_ptn_format.py b/tests/test_ptn_format.py index 60521566..ad14ec6d 100644 --- a/tests/test_ptn_format.py +++ b/tests/test_ptn_format.py @@ -104,7 +104,6 @@ def assert_pattern_equivalent(actual: Pattern, expected: Pattern) -> None: assert actual.input_node_indices == expected.input_node_indices assert actual.output_node_indices == expected.output_node_indices assert actual.input_coordinates == expected.input_coordinates - assert actual.input_initialization_axes == expected.input_initialization_axes assert actual.pauli_frame.xflow == expected.pauli_frame.xflow assert actual.pauli_frame.zflow == expected.pauli_frame.zflow assert actual.pauli_frame.parity_check_group == expected.pauli_frame.parity_check_group @@ -117,102 +116,13 @@ def test_dumps_basic() -> None: pattern = create_simple_pattern() ptn_str = dumps(pattern) - assert ".version 2" in ptn_str + assert ".version 1" in ptn_str assert ".input" in ptn_str assert ".output" in ptn_str assert "#======== QUANTUM ========" in ptn_str assert "#======== CLASSICAL ========" in ptn_str -def test_dumps_omits_default_input_basis() -> None: - """Default X-basis input initialization is implicit in .ptn output.""" - pattern = create_simple_pattern() - - assert ".input_basis" not in dumps(pattern) - - -def test_ptn_roundtrip_preserves_input_initialization_axes() -> None: - """Non-default input initialization axes survive .ptn roundtrip.""" - graph = GraphState() - in0 = graph.add_node() - in1 = graph.add_node() - out0 = graph.add_node() - out1 = graph.add_node() - - graph.register_input(in0, 0, init_axis=Axis.Y) - graph.register_input(in1, 1, init_axis=Axis.Z) - graph.register_output(out0, 0) - graph.register_output(out1, 1) - graph.add_edge(in0, out0) - graph.add_edge(in1, out1) - graph.assign_meas_basis(in0, AxisMeasBasis(Axis.X, Sign.PLUS)) - graph.assign_meas_basis(in1, AxisMeasBasis(Axis.X, Sign.PLUS)) - - pattern = qompile(graph, {in0: {out0}, in1: {out1}}) - ptn_str = dumps(pattern) - loaded = loads(ptn_str) - - assert ".input_basis" in ptn_str - assert loaded.input_initialization_axes == {in0: Axis.Y, in1: Axis.Z} - - -def test_ptn_loads_legacy_input_without_input_basis_as_x() -> None: - """Existing .ptn files without .input_basis default inputs to X initialization.""" - ptn_str = """# GraphQOMB Pattern Format v1 -.version 1 -.input 0:0 -.output 1:0 - -[0] -E 0 1 -M 0 X + - -.xflow 0 -> 1 -""" - - loaded = loads(ptn_str) - - assert loaded.input_initialization_axes == {0: Axis.X} - - -def test_ptn_load_rejects_input_basis_for_non_input_node() -> None: - """Input basis directives can only reference input nodes.""" - ptn_str = """# GraphQOMB Pattern Format v2 -.version 2 -.input 0:0 -.input_basis 1:Z -.output 1:0 - -[0] -E 0 1 -M 0 X + - -.xflow 0 -> 1 -""" - - with pytest.raises(ValueError, match="Input basis specified for non-input node"): - loads(ptn_str) - - -def test_ptn_load_rejects_input_basis_in_legacy_version() -> None: - """Version 1 .ptn files cannot use the version 2 input-basis directive.""" - ptn_str = """# GraphQOMB Pattern Format v1 -.version 1 -.input 0:0 -.input_basis 0:Z -.output 1:0 - -[0] -E 0 1 -M 0 X + - -.xflow 0 -> 1 -""" - - with pytest.raises(ValueError, match=r"\.input_basis requires \.ptn version 2"): - loads(ptn_str) - - def test_dumps_contains_commands() -> None: """Test that dumps includes all command types.""" pattern = create_simple_pattern() diff --git a/tests/test_simulator.py b/tests/test_simulator.py index 3ae6d7f5..ec12f2fc 100644 --- a/tests/test_simulator.py +++ b/tests/test_simulator.py @@ -13,15 +13,10 @@ from graphqomb.statevec import StateVector -def _single_output_pattern( - *, - measured: bool, - axis: Axis = Axis.Z, - init_axis: Axis = Axis.X, -) -> tuple[Pattern, int]: +def _single_output_pattern(*, measured: bool, axis: Axis = Axis.Z) -> tuple[Pattern, int]: graph = GraphState() node = graph.add_node() - graph.register_input(node, 0, init_axis=init_axis) + graph.register_input(node, 0) graph.register_output(node, 0) pauli_frame = PauliFrame(graph, xflow={}, zflow={}) @@ -31,41 +26,10 @@ def _single_output_pattern( output_node_indices=graph.output_node_indices, commands=commands, pauli_frame=pauli_frame, - input_initialization_axes=graph.input_initialization_axes, ) return pattern, node -def test_pattern_simulator_initializes_input_in_x_basis() -> None: - """PatternSimulator initializes X-axis inputs as |+>.""" - pattern, _ = _single_output_pattern(measured=False, init_axis=Axis.X) - simulator = PatternSimulator(pattern, SimulatorBackend.StateVector) - - simulator.simulate() - - np.testing.assert_allclose(simulator.state.state(), np.asarray([1.0, 1.0]) / np.sqrt(2)) - - -def test_pattern_simulator_initializes_input_in_y_basis() -> None: - """PatternSimulator initializes Y-axis inputs as |Y+>.""" - pattern, _ = _single_output_pattern(measured=False, init_axis=Axis.Y) - simulator = PatternSimulator(pattern, SimulatorBackend.StateVector) - - simulator.simulate() - - np.testing.assert_allclose(simulator.state.state(), np.asarray([1.0, 1.0j]) / np.sqrt(2)) - - -def test_pattern_simulator_initializes_input_in_z_basis() -> None: - """PatternSimulator initializes Z-axis inputs as |0>.""" - pattern, _ = _single_output_pattern(measured=False, init_axis=Axis.Z) - simulator = PatternSimulator(pattern, SimulatorBackend.StateVector) - - simulator.simulate() - - np.testing.assert_allclose(simulator.state.state(), np.asarray([1.0, 0.0])) - - def test_pattern_simulator_applies_output_x_frame_to_statevector() -> None: """An unmeasured output statevector should include pending X frame corrections.""" pattern, node = _single_output_pattern(measured=False) diff --git a/tests/test_stim_compiler.py b/tests/test_stim_compiler.py index 40ddfc33..116c76e3 100644 --- a/tests/test_stim_compiler.py +++ b/tests/test_stim_compiler.py @@ -136,52 +136,6 @@ def test_stim_compile_basic_pattern() -> None: assert stim_str.count("\n") > 0 -@pytest.mark.parametrize( - ("init_axis", "expected_reset"), - [ - (Axis.X, "RX"), - (Axis.Y, "RY"), - (Axis.Z, "R"), - ], -) -def test_stim_compile_uses_input_initialization_axis(init_axis: Axis, expected_reset: str) -> None: - """Input initialization axes choose the corresponding Stim reset instruction.""" - graph = GraphState() - in_node = graph.add_node() - out_node = graph.add_node() - - graph.register_input(in_node, 0, init_axis=init_axis) - graph.register_output(out_node, 0) - graph.add_edge(in_node, out_node) - graph.assign_meas_basis(in_node, AxisMeasBasis(Axis.X, Sign.PLUS)) - - pattern = qompile(graph, {in_node: {out_node}}) - stim_lines = stim_compile(pattern).splitlines() - - assert f"{expected_reset} {in_node}" in stim_lines - - -def test_stim_compile_keeps_non_input_preparations_in_x_basis() -> None: - """Only input reset instructions use the input initialization axis.""" - graph = GraphState() - in_node = graph.add_node() - mid_node = graph.add_node() - out_node = graph.add_node() - - graph.register_input(in_node, 0, init_axis=Axis.Z) - graph.register_output(out_node, 0) - graph.add_edge(in_node, mid_node) - graph.add_edge(mid_node, out_node) - graph.assign_meas_basis(in_node, AxisMeasBasis(Axis.X, Sign.PLUS)) - graph.assign_meas_basis(mid_node, AxisMeasBasis(Axis.X, Sign.PLUS)) - - pattern = qompile(graph, {in_node: {mid_node}, mid_node: {out_node}}) - stim_lines = stim_compile(pattern).splitlines() - - assert f"R {in_node}" in stim_lines - assert f"RX {mid_node}" in stim_lines - - def test_stim_compile_x_measurement() -> None: """Test X measurement compilation.""" pattern, meas_node, in_node = create_simple_pattern_x_measurement() diff --git a/uv.lock b/uv.lock index 687bef62..167cdf13 100644 --- a/uv.lock +++ b/uv.lock @@ -725,7 +725,6 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, - { name = "stim" }, { name = "types-networkx" }, ] doc = [ @@ -767,7 +766,6 @@ requires-dist = [ { name = "scipy", specifier = ">=1.15.3" }, { name = "sphinx", marker = "extra == 'doc'", specifier = ">=8.1.3" }, { name = "sphinx-gallery", marker = "extra == 'doc'", specifier = ">=0.17.0" }, - { name = "stim", marker = "extra == 'dev'", specifier = ">=1.15" }, { name = "stim", marker = "extra == 'stim'", specifier = ">=1.15" }, { name = "types-networkx", marker = "extra == 'dev'", specifier = "==3.6.1.20260518" }, { name = "typing-extensions" },