Sitelet https://github.com/graphiq-dev/graphiq/pull/63
Skip to content

Release 0.1.2: bug fixes, compatibility and packaging - #63

Open
jie-qiqc wants to merge 227 commits into
mainfrom
bugfix
Open

jie-qiqc wants to merge 227 commits into
mainfrom
bugfix

Conversation

@jie-qiqc

@jie-qiqc jie-qiqc commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

This is a maintenance release of GraphiQ (version 0.1.2). It contains bug fixes, compatibility and packaging fixes, documentation and notebook fixes, and more tests. It adds no features. Some fixes change default or seeded results. Every one of them is listed in the next section, so results from 0.1.1 can be compared with results from this release.

Changes to default or seeded results

Solver searches and seeded runs

  • b825e7e, 32df543: replace_photon_one_qubit_op now labels the new gate "Fixed" only when the gate it replaces was "Fixed". Before, every new gate got the label. Seeded HybridEvolutionarySolver searches change, because a photon gate it replaces is no longer made permanently unremovable. An EvolutionarySolver search that starts from initialization with the default transformations is unchanged by this fix: every photon gate it replaces was labelled "Fixed" by initialization.
  • 6dd7f70: the evolutionary solvers' CNOT and measurement candidates no longer depend on PYTHONHASHSEED. Seeded searches that reach them can follow a different path than before, and that path is now the same in every process. That covers every search with more than one emitter, and every HybridEvolutionarySolver search, including single-emitter ones, since its default transformations add MeasurementCNOTandReset (so search_for_alternative_circuits in graphiq/benchmarks/alternate_circuits.py is among them). EvolutionarySolver searches with one emitter and the default transformations are unchanged.
  • 3bf3cac: iso_finder now applies sort_emit and label_map on every return path. For seeded AlternateTargetSolver runs with sort_emit=True (the default) and n_iso_graphs > 1, a run whose isomorph search returns early can order its candidates differently, keep a different circuit and relabel map for a repeated graph, and put a different candidate first. The set of candidate graphs is unchanged, and so are runs with n_iso_graphs=1.
  • 5e9e73f: _label_finder no longer returns repeated permutations for graphs with fewer than 8 nodes, or when the search is exhaustive. This affects seeded iso_finder and AlternateTargetSolver runs that drew a repeat: they can now return different graphs, and sometimes more of them. On a grid of 1480 seeded iso_finder calls, 236 changed. find_best, graph_analyzer and iso_scaling_test run with seed 1 by default, so their default outputs change. For example, find_best finds 12/60/360 relabellings for 4/5/6-node paths (11/51/311 before), which changes its CSV/JSON files and bests.txt.
  • cce2f07: with a noise_model_mapping and Monte Carlo off, AlternateTargetSolver.noise_score looks up the relabel map by node label. A target labelled 0..n-1 but inserted out of order is now scored against the right graph, so its candidates can rank differently. Before, a target with other labels raised KeyError (see below). Targets labelled 0..n-1 in insertion order are bit-identical.
  • 1cc80c7: assign_noise and MonteCarloNoise now pair a OneQubitGateWrapper's noise with its gates in constructor order. With Monte Carlo on, seeded scores change while their expectation does not (e.g. MonteCarloNoise on path_graph(5), 40 samples, seed 7, rate 0.3: 0.925 -> 0.875). Mappings that give different gate classes different models now reach the intended gates. Runs without noise, and "depolarizing" runs with Monte Carlo off, are unchanged.
  • 6d874cd, a3ada66: MixedStabilizer.reduce no longer skips entries (6d874cd), and now merges branches that represent the same stabilizer state, so the mixture stays bounded. Expected values are unchanged up to rounding. With "probabilistic" measurement determinism on a StabilizerCompiler with noise simulation on, fewer branches draw from the global numpy stream. A seeded compile can then give a different state, score and classical-register value, and later draws from that stream change too. With determinism 0 or 1, no outcome changes, and scores and summed probabilities change only by rounding in the last digits. From a3ada66 alone, search_for_alternative_circuits scores a ring4 TimeReversedSolver circuit under noise_model_loss_and_depolarizing(0.01, 0.01) as 0.104373358769414 instead of 0.10437335876941412, and reports its photon survival probability as 0.9801000000000005 instead of 0.9800999999999997. A seeded search can therefore rank two candidates whose exact scores tie differently.
  • b5c587e: a forced density-matrix measurement now treats a probability at or below a rounding floor (dim * eps * sum|p|) as 0. Outcomes change only where the forced probability is in (0, floor], or under determinism 0 where the other probability is >= 1. Seeded runs with determinism 0 or 1 change where a forced branch had a rounding-level probability. An invalid state whose probabilities are dominated by a negative value (e.g. diag([-1e-3, 1e-19]) set in place) now reaches NaN: the probabilistic mode raises numpy's ValueError, determinism 1 returns outcome 0 with a NaN state, and determinism 0 returns outcome 1 with a NaN state. Before this fix, all three returned outcome 1 without NaN.
  • 3926e4c: benchmarks/solvers.py runs now (it crashed before) and uses its stated settings. examples/example_solve.py also uses its stated settings, where before it ran on the defaults.

Noise, states and scores

  • d5dc56a: multi-qubit AmplitudeDampingNoise uses itertools.product. On 2 qubits it was not trace-preserving, and on 3 or more it applied no noise. Only noise maps that apply it to two or more qubits change; nothing in the package uses it.
  • 3dd4771: density-matrix partial_trace now contracts each traced qubit, so a Bell pair traces to I/2 (it gave |+><+|). Results change wherever a density matrix is traced over qubits still entangled with the rest, including the solvers' scoring after emitters are traced out.
  • ff194c4: stabilizer -> density-matrix conversion reads the phase vector, so |1> no longer converts to |0>. A stabilizer state with a negative generator scored against a density-matrix target (Infidelity, TraceDistance) gets a different score, and seeded searches that compile on StabilizerCompiler but score against a density-matrix target follow a different path.
  • 3e75bd4: stabilizer remove_qubit, and so stabilizer partial_trace, now carries the measurement sign to the surviving qubits. No score measured on the solvers' own circuits changed. A circuit that traces out a qubit left in 1 and correlated with a kept qubit now gets the correct state.
  • d2b9f6e: the density-matrix compiler now resets the emitter in MeasurementCNOTandReset, and the stabilizer compiler records its outcome in the classical register. This changes density-matrix circuits that reuse an emitter after outcome 1, and stabilizer circuits that read that register.
  • d1aca43: additive noise on ClassicalCNOT, ClassicalCZ and MeasurementCNOTandReset is now applied. The stabilizer compiler used to drop it, and the density-matrix compiler raised. Noisy runs with such noise change.
  • 3a951d1: SolverBase._identify_noise honours the per-leg <Op>_control/<Op>_target keys. Noisy runs that use them change, including noise_model_loss_and_depolarizing, examples/example_results.py and two noisy test fixtures.
  • d84039d, fcac0a3, 0281091: these three combine for AlternateTargetSolver with noise simulation on and Monte Carlo off. It scores candidates through assign_noise. That now applies per-leg CNOT noise (d84039d) and a section's OneQubitGateWrapper model as the solvers do (fcac0a3). A density-matrix measurement now keeps photon-loss trace, and Infidelity scores it (0281091). So scores under the loss maps in graphiq/benchmarks/alternate_circuits.py change on the stabilizer compiler and on the default density-matrix noise compiler. For TimeReversedSolver circuits under noise_model_loss_and_depolarizing, the density-matrix score goes from 0.0 to 0.0457 (linear4) and 0.1044 (ring4) at (0.01, 0.01), and to 0.3826 and 0.6718 at (0.1, 0.1); the stabilizer score goes from 0.0100/0.0199 and 0.1000/0.1900 to the same values. Under noise_model_pure_loss(0.1) the density-matrix score goes from 0.0 to 0.1000/0.1900, and the stabilizer score is unchanged.
  • 0281091: density-matrix runs with photon loss now score the loss instead of ignoring it or raising AssertionError. Against a stabilizer target their infidelity matches the stabilizer compiler's up to rounding (from 0281091 alone: linear4 under loss 0.1, 0.0 -> 0.1). The examples, benchmarks and notebooks that use the density-matrix compiler apply no photon loss, and the "depolarizing" mapping the data-collection helpers use is trace-preserving, so their results do not change from this.
  • 4340ebd: a forced outcome-0 density-matrix measurement no longer divides by p(0) = 0, which filled the state with NaN.
  • 42b9c87, bb00f28: inverse_circuit first tries its original reduction and keeps it only if the result is exactly the standard all-zero tableau. Otherwise it uses a rewritten reduction, or raises ValueError. TimeReversedSolver circuits on the 53 random graphs tested are identical to before. States the old reduction left short of |0...0> now reduce correctly. Stabilizer fidelity no longer returns 1 for orthogonal states in the case where the old reduction ended at |0...0> written with other Z generators.
  • Stabilizer tableau operations that returned wrong results without an error. None of these is reached by the compilers or solvers with their default settings; they change results for direct calls:
    • trace_out_qubits discarded the wrong qubits (9135b33).
    • reset_z overwrote the measured row's sign (42d5a3b).
    • swap_gate moved row signs (a18d020).
    • insert_qubit misplaced the new phase (b034771).
    • measure_x/measure_y did not rotate back (e4deaa9).
    • A Z measurement dropped the displaced row's sign (f0f7d5f).
  • 374887c: a controlled gate whose two legs disagree on "After gate" now gets both legs' noise. No built-in noise model sets it to False.

Metrics and data collection

  • b0b6b09: CircuitUnitaryCount counts SigmaY and SigmaZ, and counts SigmaX once. Before, SigmaY and SigmaZ were never counted and SigmaX counted three times. 43ee392 makes the same fix to result_maker's "n_unitary", which also skipped PhaseDagger. CircuitUnitaryCount is not used by any solver, benchmark or example, but result_maker records "n_unitary" by default, so that column changes in orbit_analyzer's result and in find_best's CSV/JSON (and so in what graph_analyzer, LC_scaling_test and iso_scaling_test write through it) for circuits with SigmaX, SigmaY, SigmaZ or PhaseDagger. From 43ee392 alone, find_best on a 5-cycle with 4 reorderings gives [17, 39, 56, 40] -> [17, 31, 44, 32]. find_best's cost does not use "n_unitary", so graph_analyzer's best and worst picks do not change.
  • 55ebf54: height_dict(graph=...), and with it height_max(graph=...), the minimum emitter count, order nodes by label. For a graph whose nodes were not inserted in label order, the emitter count changes (e.g. the path 0-1-2-3 inserted as [2, 0, 1, 3]: 2 -> 1), and so do GraphCorr/NodeCorr's "num_emit", "num_emit_per_photon" and "cnot_per_emitter" and graph_to_cnot. Graphs the package builds itself, and sort_emit, are unaffected.
  • 3ad68c3: GraphCorr/NodeCorr "cnot_per_photon" is now CNOTs / n (it was the raw count). Correlations change for samples that mix graph sizes. corr_n_dependence curves shift relative to each other, and _rep_counter lists reorder.
  • b1626bd: "max_emit_depth" in GraphCorr/NodeCorr drops by exactly 2. Correlations are unchanged, and met_distribution's mean drops by 2.
  • 0555984: graph_analyzer's worst-fidelity case (the bests.txt line and [4]) is now the lowest-fidelity case, and ties keep the first. The LC_scaling_test/iso_scaling_test bw lists and per-run bests.txt follow. The CSVs are unchanged.
  • 971b9ad: rgs_analysis's saved JSON loses its empty "n_cnot" column. The values are unchanged.
  • b55651e: photon_survival_rate reads the control leg's noise for a photon that is the control. Rates change only when the two legs carry different noise.

Saved files, exports and figures

  • 04b2f0f: benchmark_run's summary records the metric's class name (it recorded the compiler's).
  • 1fa6fdd: solver_info["seed"] reports the seed (it was always None), and benchmark_data records the seed actually used (i + seed_offset). Search results are unchanged.
  • 9296f9f: benchmark_run now writes pop/ and hof/ openQASM every iteration, as it meant to. That is about 2750 files per run for the pipeline example. The time column includes the writes, and the caller's setting is left at "both". Scores, circuits, logs and hall of fame are unchanged.
  • 44bac7a: hof log cost_mean/cost_variance/cost_max skip unfilled hall-of-fame slots (they were inf/nan while n_pop < n_hof). log_hof.csv and log.pdf change for such runs. Default settings are not affected.
  • f62ac12: exported openQASM for a OneQubitGateWrapper of two or more non-Identity gates that is not a palindrome lists its body in application order. Earlier exports described a different circuit to other tools. No score changes.
  • 8751b63: the exported rz definition uses U(0,0,phi). The old one was invalid OpenQASM 2.0.
  • f288ded: density_matrix_bars colours are centred on zero: zero is neutral, negatives run neutral to blue, positives neutral to red. Colour is relative to each panel's band. Every bar figure looks different, and no number changes.

Other functions that returned wrong results without an error

  • 3bb2bda: from_openqasm decodes multi-character gate names in wrappers (e.g. "sdg").
  • 9d0f3bb: _select_graphs no longer evicts the best candidate in get_lc_graph_by_max_edge/get_lc_graph_by_max_neighbor_edge.
  • 0b58dc6: the benchmarks/lc_equivalence.py demos run and report distinct graphs.
  • d27d95c, a74322c: is_lc_equivalent checks each connected component, so equivalent disconnected graphs are no longer reported non-equivalent. On connected graphs the boolean and the returned solution are as before. An unknown mode now always raises ValueError; before, it raised only when the solution space had dimension 5 or more, and otherwise returned a result.
  • a5a6c43: find_lc_operations builds its sequence from the first graph, the one the sequence starts from.
  • c15101a: bipartite_partial_transpose and negativity are correct for unequal dimensions. Equal dimensions, which covers every in-package call, are bitwise identical.
  • 3d13d6c: check_equivalent_unitaries takes the phase from the largest entry, so round-off no longer rejects unitaries that are equal up to phase. In-package answers are unchanged.
  • 71ec0f0: circuit_is_isomorphic tells control from target in classical pair ops, and remove_redundant_circuits keeps both such circuits.
  • e6fbe02: OpenQASMParser reports the classical-bit index of measure q[i] -> c[j]; as j (it reported i).
  • b3dae04: Painter draws an indexed reset/measure on its one qubit (it drew the whole register).
  • a662bfb: local_comp_graph ignores edge weights. Weight-1 and unweighted graphs are unchanged.
  • 23199ad: linear_partial_orbit walks the path in any node order. This matters only for AlternateTargetSolver(lc_method="linear"), which is not the default.
  • 68c5957: crazy(pos=True, alternate_order=True) places nodes by their new labels. The graphs are unchanged.

Behaviour and API changes

QuantumState and conversions (graphiq/state.py, graphiq/backends)

  • 9dd9583: QuantumState.n_qubits is a read-only property that follows the representation, and assigning to it raises AttributeError. After a resize, rep_data and compile(initial_state=) check the new width. DensityMatrix.n_qubits is new.
  • 9956e6a: a state built without rep_type records the type it detects, so show, conversions, metrics and solvers work on it.
  • d7ad363: dm -> s on a pure density matrix that is not a graph state raises instead of returning a wrong stabilizer.
  • 98c640a: a mixed density matrix of orthogonal graph states with distinct weights converts to stabilizers. dm -> s with mixed=False raises ValueError for a mixed matrix.
  • 1ea211c: dm -> s with mixed=False raises ValueError when the trace is not within about 1e-5 of 1 (0.1.1 raised TypeError for such a state with either setting). With mixed=True it converts to a MixedStabilizer that keeps the weight.
  • 301d4ca: stabilizer_to_graph(validate=True) accepts any generator set of a graph state (e.g. XZZ, ZXI, IXX).
  • 68de7ff: dm -> g works: a pure state gives Graph, and mixed=True gives MixedGraph. mixed=False and not pure raises ValueError.
  • These conversions now work where they raised before:
  • d5d380e: converting a tableau that carries an i phase to a density matrix raises ValueError.
  • 0281091: convert_representation("g") on a lossy density matrix raises AssertionError instead of TypeError.
  • a349830: validate_data rejects a list of (p, nx.Graph), and the constructor raises TypeError for it. It used to raise AttributeError.
  • 2baadf1: partial_trace on a graph state raises NotImplementedError even when rep_type was omitted.
  • show and drawing:
    • Graph.draw returns (fig, ax) (283e7af).
    • draw_graph returns the given axes and its root figure (4ea9577).
    • show on a mixed graph state raises NotImplementedError (8df0e0d).
  • 8c965e3: str() of a MixedGraph works. 809a3ca: state_to_graph returns its tuple in the documented order for matrix input.
  • e203ea8: dmf.fidelity compares two mixed states instead of raising, and sqrtm_psd is correct for complex input.

Stabilizer backend

  • c2cb1a0: new public is_stabilizer. StabilizerTableau.validate works.
  • These inputs now raise ValueError instead of being misread:
    • malformed stabilizer labels (4630a86)
    • a malformed phase vector, or a phase passed with a tableau to copy (0cebd8e)
    • a non-square X/Z pair (d1c3378)
    • an unknown Pauli type (e6ef168)
    • an unrecognised measurement_determinism in z_measurement_gate, raised before the tableau is touched (2341674, 806f801)
  • 2962021: rref's rank check rejects a single identity generator with AssertionError.
  • af80827, ee3e1f9: the stabilizer and DensityMatrix __eq__ no longer reorder operands, raise on foreign operands, or match by broadcasting.
  • These used to raise and now work: apply_x_measurement (167e7ab), tensor (3b4f9a9), binary_symplectic_product on Clifford tables (b7385d1), and _position_finder, which also no longer drops a Hadamard (3c41561).
  • ff2b80a, f539947: a measurement_determinism parameter briefly added to the partial_trace methods is removed again, so their signatures match 0.1.1.
  • 54b5a5a: MixedStabilizer.tableau raises its TypeError instead of returning it. f5d33ff: run_circuit(reverse=True) leaves the caller's list intact. 42b9c87: clifford_from_stabilizer no longer reduces the caller's tableau.

Noise (graphiq/noise)

  • 2e814f8: OneQubitGateReplacement and its subclasses raise NotImplementedError on the stabilizer backend. Before, they silently applied neither the noise nor the gate.
  • 3b40749: unsupported noise on a MixedStabilizer raises NotImplementedError (it raised TypeError). b7be7b0: replacement noise on MeasurementZ raises NotImplementedError on both compilers.
  • d1aca43: additive noise on a MeasurementZ raises ValueError on the stabilizer compiler. Before, it was dropped.
  • These used to raise and now work:
    • DepolarizingNoise on a pure Stabilizer (1f634a4)
    • GeneralKrausError on a density matrix (28ca290); non-2x2 operators raise ValueError
    • PauliError on a MixedStabilizer (baa7dfb)
  • 2e86792: default ResetErrors no longer share one parameter dict.
  • 7193fc5: section_finder raises ValueError for an unknown criteria string.
  • 0a9f663: assign_noise no longer changes the circuit it copies. MonteCarloNoise scores are unchanged, but AlternateTargetSolver's serial Monte Carlo circuits no longer carry the last trial's noise.
  • d84039d: a bare noise list of the wrong length on a controlled-pair op raises ValueError. A per-leg NoNoise() key takes precedence over a bare key.
  • fcac0a3: a non-addition model under a OneQubitGateWrapper key raises KeyError at compile.

Circuits and compilers (graphiq/circuit, graphiq/backends/*/compiler.py)

  • a84dd37: the default param_info of RX/RY/RZ is per-parameter (((-pi, pi),), ("theta",)), so initialize_parameters and GradientDescentSolver work with it.
  • 4023de3: circuit.parameters = ... works for ops missing from the id map (it raised KeyError).
  • 6db9d35, a39556a: compile(initial_state=) no longer changes the caller's initial state.
  • These used to raise and now work:
    • get_node_by_labels/get_node_exclude_labels on an unseen label (63e39c0)
    • ClassicalCNOT/ClassicalCZ on a MixedStabilizer (db658e1)
    • noisy MeasurementZ on DensityMatrixCompiler (09febbd)
    • to_json/from_json round trip (092892d)
  • ef79410: add_*_register return the new index. 5d37d89: the OneQubitGateWrapper nesting guard fires. bd0ac5b: name_to_class_map returns None for unknown names.
  • draw_dag: it uses a given ax (de734c9), and draws on a given fig and returns it (5e67661).

Metrics (graphiq/metrics.py)

  • b571da0: Metrics accepts CircuitEmitterCount, CircuitCnotCount, CircuitUnitaryCount, the three CircuitMaxEmit*Depth metrics and CircuitMeasureCount.
  • b0b6b09: a default-constructed CircuitCnotCount evaluates (it raised AttributeError). 76447e9: Infidelity on a mixed state works (it raised UnboundLocalError).
  • 0e53792: Metrics raises TypeError at construction for an unsupported metric_weight type.
  • 5ad94b1: the emitter-depth metrics return depth_penalty(0) on a circuit with no emitters (they raised ValueError).

Solvers (graphiq/solvers)

  • f8ba137: the AlternateTargetSolverSetting.lc_method default changes from "max edge" to None. The solver's own default raised before.
  • 31a6e89: allow_relabel and allow_lc are honoured. Both default to True.
  • f1b9df3: solve() with label_map=True works.
  • cce2f07: targets not labelled 0..n-1 (e.g. linear_cluster_state(n).data) are scored instead of raising KeyError.
  • 579a1f4: graph_to_circ raises ValueError for a graph with an isolated vertex (it raised IndexError).
  • 333591b: HybridEvolutionarySolver with a density-matrix target and compiler no longer converts the caller's target, and solve() completes.
  • 7457407: HybridEvolutionarySolver without a noise mapping leaves noise_simulation False, so compiled states are pure. Scores are unchanged.
  • f899dd6: each solver builds its own default setting.
  • d8aa954, 168375f, 29061b1: unfilled hall-of-fame slots no longer crash update_hof, update_logs or the "hof"/"both" openQASM save.
  • f48008e: use_adapt_probability adapts only the transformations in trans_probs. Before, it raised KeyError.
  • 1b54bfc: _change_pauli_type raises ValueError for an unknown basis.

Utilities, IO and benchmarks

  • 12eb241: iso_graph_finder/iso_equal_check accept labels other than 0..n-1. Before, they raised IndexError, and labels -n..-1 wrapped silently. Output for 0..n-1 labels is unchanged.
  • 29cc217: crazy(alternate_order=True) imports correctly.
  • c6e1090: _compare_graphs_visual's label check can pass.
  • 5c3e726: benchmark_data(save_directory=...) saves, and each per-circuit JSON now holds that circuit's data. The module's __main__ runs.
  • IO: load_np_array works on current numpy (5687ebe); new_directory dates its fallback folder (a20e5e5); SolverResult.load_json works and __setitem__ type-checks (8ce498c); load_txt closes its file (9da87be).
  • a662d17: density_matrix_heatmap(axs=...) returns the figure (it returned None).

Data collection (graphiq/data_collection)

  • 7392582: user_interface imports.
  • GraphCorr and NodeCorr:
    • GraphCorr works without an initial graph (304f0ae).
    • GraphCorr works with relabel_trials after graph_circ_dict (957999d).
    • NodeCorr's graph setter updates its adjacency, so its analyses use the new graph (7fe86f1).
    • The initial_graph setter rejects None (2fbbbc9).
  • 4c54d86: rnd_graph with an integer seed returns a connected graph. It looped forever when the first draw was disconnected; seeds whose first draw was connected give the same graph.
  • These now run: find_best without a dir_path (bc0ca07), plot_map_based, which gains a required result parameter (dc75888), and result_maker on zero-emitter circuits (a49cfa6).
  • 8578383, b621f5b: rgs_analysis and LC_scaling_known create their save directories.

Packaging and compatibility

  • 4eeb602: the wheel contains only graphiq. The tests, examples and visualization packages are no longer installed.
  • d086051: requests, which graphiq/utils/draw.py imports, is declared. The unused autograd, genbadge and defusedxml and the duplicate networkx/matplotlib lines are gone from requirements.txt.
  • 8e154ef: the invalid trailing space in the "Intended Audience :: Science/Research" classifier is removed.
  • 9614219: the jax array library imports on jax 0.4.25 and later. The numpy path is unchanged.
  • 0f8c4be: networkx 3.4+ uses random_labeled_tree where random_tree is gone. 4b2b427: math.factorial replaces np.math, which numpy 2.0 removed. 5687ebe: np.complex/np.float are replaced.
  • bdcfb76: two docstrings no longer contain invalid escape sequences, which are SyntaxWarnings on Python 3.12.
  • 168c44a: metrics.py is formatted with black 24, so CI's lint check passes on graphiq/.
  • 65d4bbb: the version is 0.1.2.
  • c64dfe5: the Python 3.8 CI job installs only ray from the all extra. PyPI no longer has a jaxlib for Python 3.8, so optax cannot be installed there and the job stopped at its install step. jax and optax are still tested on 3.9 and 3.10. The package does not change.

Documentation and notebooks

Tests

  • The suite gains coverage in these areas:
  • 8015cd4: seeded solver tests now require zero infidelity in both measurement branches. The linear4 and ghz3 searches are reseeded, and 32df543 reseeds them again.
  • 857bd03: twelve tests now assert their results.
  • df40b93: the forced-measurement rounding test builds its rounding-level state with a rotation by 2e-17, which leaves a probability of about 1e-34 whatever BLAS kernel numpy uses. Built as Hadamard twice, it failed on kernels without fused multiply-add, where H·H leaves no rounding error.
  • 46ed7f8: the suite uses the non-interactive Agg backend. c731cfa: the mixture-folding flag is restored after the tests that change it.
  • d162725: a benchmark test writes into tmp_path. 30078da: a test that ran nothing is removed.
  • aafd402: graphiq/data_collection is left out of coverage measurement. The package is unchanged.

NumPy 1.24 removed np.complex and np.float, which is already the pinned
version here, so IO.load_np_array raised AttributeError on any call.
Use np.complex128/np.float64 instead.
…ates

OneQubitGateWrapper concatenates each gate's openqasm name with no
separator (e.g. Hadamard+PhaseDagger+SigmaY -> "hsdgy"), but
from_openqasm decoded that one character at a time, silently
mis-parsing any multi-character name like PhaseDagger's "sdg".
Decode via greedy longest-match instead.
Tests exercising draw_circuit()/plt.show() popped up plot windows on
every full test-suite run since draw_circuit defaults to show=True.
Set the Agg backend in a top-level tests/conftest.py, loaded before
any test module imports matplotlib, so plotting still runs and is
still assertable but never opens a window.
`from correlation_module import *` only resolved when run from inside
graphiq/data_collection/, so importing this module normally raised
ModuleNotFoundError. Use the fully-qualified package path instead.
… unseen label

Both methods indexed node_dict[label] directly, raising KeyError
whenever no node in the circuit had ever carried that label (e.g.
querying for CNOT nodes on a circuit with no CNOT gates) - a real
crash hit by several metrics.py/correlation_module.py/
user_interface.py call sites. Use node_dict.get(label, []) so an
absent label just contributes nothing, matching "no nodes have this
label".
AmplitudeDampingNoise.apply built Kraus operators with
itertools.combinations instead of itertools.product. Since
amplitude_damping_operators always returns exactly 2 single-qubit
operators, combinations silently dropped terms: the channel was
non-trace-preserving for 2 qubits and applied zero Kraus operators
(no noise at all) for 3+ qubits. Use product, matching the pattern
already used by DepolarizingNoise in the same file.
CircuitCnotCount.__init__ set self.n_emitter_penalty instead of
self.n_cnot_penalty in the default-penalty branch, so evaluate()
crashed with AttributeError on any default-constructed instance.

CircuitUnitaryCount.evaluate's gate label list was
["SigmaX", "SigmaX", "SigmaX", ...] instead of
["SigmaX", "SigmaY", "SigmaZ", ...], so SigmaY gates were never
counted and SigmaX gates were triple-counted.
…er states

The MixedStabilizer branch checked isinstance(state.rep_data, ...)
instead of isinstance(rep_data, ...). rep_data holds the converted
stabilizer representation (set a few lines above); state.rep_data is
the original, pre-conversion representation. Whenever state was a
mixed QuantumState not already in stabilizer form, conversion produces
a MixedStabilizer, but the check inspected the untouched original,
so neither branch matched, fid was never assigned, and evaluate()
crashed with UnboundLocalError.
Metrics._all only listed Infidelity, TraceDistance, and CircuitDepth,
so Metrics rejected every other metric class defined in this file -
even when passed as an already-constructed instance. Register the
ones that actually fit Metrics' evaluate(state, circuit) calling
convention: CircuitEmitterCount, CircuitCnotCount,
CircuitUnitaryCount, CircuitMaxEmitDepth, CircuitMaxEmitResetDepth,
CircuitMaxEmitEffDepth, and CircuitMeasureCount.

GraphMetric is deliberately left out: its evaluate(graph_metric: str)
signature doesn't match that convention, so Metrics could never
actually call it - registering it would just turn a clear
"not a recognized metric" warning at construction time into a
confusing TypeError at evaluation time.

Move the Metrics class itself to after all the metric classes it
references, so _all can be one plain dict literal again instead of
needing a separate post-definition update().
…abilizer

classical_registers is a numpy array, but MixedStabilizer.apply_measurement
returns a list of per-mixture-branch outcomes, not a scalar. ClassicalCNOT
and ClassicalCZ both stored the raw list into classical_registers[op.c_register]
via an unconditional/trailing assignment, raising "setting an array element
with a sequence" whenever the stabilizer state was mixed. Store outcome[0]
in the MixedStabilizer branch and outcome in the plain-Stabilizer branch,
matching the already-correct MeasurementZ handling just below. Also drops
a leftover debug print() in the ClassicalCNOT branch.

Also documents, at each outcome[0]/outcomes[0] site, that this only
records one mixture branch's measurement result even though branches can
disagree - a pre-existing simplification (see the TODO on
MixedStabilizer.apply_measurement), not something this fix resolves.
CompilerBase.compile sends a gate with replacement noise to
compile_one_noisy_gate, which applies only the noise's own gate list.
OneQubitGateReplacement's Stabilizer branch built an empty gate_list,
in both its Stabilizer and MixedStabilizer paths (the latter dead code,
since MixedStabilizer isn't a Stabilizer subclass). So on the
stabilizer backend HadamardPerturbedError/PhasePerturbedError/
SigmaXPerturbedError (all subclasses of OneQubitGateReplacement) applied
neither the noise nor the gate they replace: the state's tableau was
unchanged before and after. Since a general one-qubit unitary isn't
generally Clifford and can't always be represented exactly in the
stabilizer formalism, raise NotImplementedError instead, matching the
pattern already used by LocalCliffordError and
TwoQubitControlledGateReplacement in this same file.
…rixCompiler

compile_one_noisy_gate referenced state.dm at four call sites, but
QuantumState has no .dm attribute - compile_one_gate in the same file
correctly unwraps via state = state.rep_data, but compile_one_noisy_gate
never did that. compile() sends a gate to compile_one_noisy_gate only
when it carries replacement noise, so attaching a replacement noise
model to a MeasurementZ and compiling with noise_simulation enabled
crashed with AttributeError. Use state.rep_data throughout, matching
compile_one_gate's convention. (Three of the four occurrences, in the
ClassicalControlledPairOperationBase/MeasurementCNOTandReset branches,
are currently dead code per compile()'s dispatch, but fixed alongside
the live one for consistency.)
…red candidates

self.hof is initialized with placeholder entries (np.inf, None). A
candidate whose score is np.isclose to an unfilled slot's placeholder
took the tie-breaking branch, which dereferenced .dag on the
placeholder's None circuit. Treat an unfilled slot as always losing
the tie-break (any real circuit fills it) instead of crashing.
… < n_hof

self.hof can contain unfilled (np.inf, None) placeholder entries -
update_hof fills at most one slot per population member, so whenever
n_pop < n_hof (an ordinary solver setting, not an exotic input) some
slots are still unfilled after the first iteration. update_logs computed
depth_hof over every hof entry unconditionally, calling .depth on the
None placeholder circuit and crashing with AttributeError on the very
first iteration. Filter out unfilled entries before computing
depth_hof. Same root cause as the update_hof fix in solver_base.py,
surfacing at a second call site.
replace_photon_one_qubit_op called gate.add_labels("Fixed") on the
gate it put in place of the old one, and remove_op never removes a
"Fixed" node. Remove that line.

On a HybridEvolutionarySolver circuit, whose photon gates carry no
"Fixed" label, a replaced photon gate used to become permanently
unremovable. It now stays removable, like the gates the other mutation
operators add.

In an EvolutionarySolver search that starts from initialization and
uses the default transformations, every photon gate this operator
replaces is one that initialization labelled "Fixed". There the line
was what kept the label, so once replaced such a gate now becomes
removable.

This changes seeded EvolutionarySolver and HybridEvolutionarySolver
searches.
For a pure input, density_to_stabilizer called the unvalidated
_density_to_graph_pure helper, so a pure state that isn't a graph
state was silently converted to the wrong stabilizer instead of
raising, even though the docstring limits the function to graph
states. Use density_to_graph (which validates by default) to match
the mixed-state branch, which already does.

As a result, QuantumState.convert_representation("s") on a pure
density matrix that is not a graph state now raises instead of
returning a wrong stabilizer. That includes the conversion
Infidelity.evaluate makes when the target is a stabilizer and the
state a density matrix.
- trace_distance (density_matrix/functions.py): the docstring was not
  raw, so \rho, \frac/\right and \text were silently turned into a
  carriage return, form feed and tab, and \s raised an
  invalid-escape warning. Add the r""" prefix the module's other LaTeX
  docstrings already use.
- binary_symplectic_product (stabilizer/functions/utils.py): the LaTeX
  row break was written as three backslashes plus a space, an invalid
  escape; use four. The runtime docstring text is unchanged.

Both are SyntaxWarnings on Python 3.12. Add a test that compiles every
.py file in the repo and fails on any invalid escape sequence.
…edError

MixedStabilizer is a sibling of Stabilizer, not a subclass, so the
"not implemented for stabilizer" arms of OneQubitGateReplacement,
TwoQubitControlledGateReplacement, LocalCliffordError,
AmplitudeDampingNoise, MixedUnitaryError, CoherentUnitaryError,
GeneralKrausError, MeasurementError and ResetError never matched a
mixture - which is what a noisy StabilizerCompiler run produces. A
mixture fell through to the generic "Backend type is not supported."
TypeError instead. Match both classes so the refusal is the same for
either representation.

Also correct the Graph-arm messages of TwoQubitControlledGateReplacement
and LocalCliffordError, which said "stabilizer" instead of "graph".
DepolarizingNoise.apply converts a pure Stabilizer into a one-branch
MixedStabilizer before branching, but it assigned the mixture to
state.rep_data while state.mixed was still False, so the setter raised
"Cannot initialize the stabilizer representation with datatype
MixedStabilizer". Past that, the local state_rep still pointed at the
discarded pure tableau while the rest of the method branches over the
mixture. Rebind the local and set the mixed flag before assigning, as
PhotonLoss already does.

This path is reached when Monte Carlo noise simulation (which compiles
to a pure Stabilizer) is combined with a noise map containing
DepolarizingNoise.
GeneralKrausError.apply's DensityMatrix arm - the only backend the
class claims to support - called self.get_backend_dependent_noise,
which is not defined anywhere, so every application raised
AttributeError. Embed the stored single-qubit Kraus operators on each
register in reg_list (as the other per-register channels such as
DepolarizingNoise do) and apply the resulting channel. Operators that
are not 2x2 are refused with a ValueError naming the single-qubit
restriction, instead of failing later with a dimension mismatch.
…rs dict

ResetError.__init__ defaulted noise_parameters to a literal {}, so
every default-constructed instance held the same dict object. Default
to None; NoiseBase.__init__ already turns None into a fresh empty
dict, matching the other noise base classes.
stabilizer_to_density's mixed-state branch (a list of
(probability, StabilizerTableau) pairs) summed the branches and then
fell off the end without returning, so it returned None - and
converting a mixed stabilizer QuantumState to "dm" failed with
"TypeError: Input must be a numpy.ndarray or an integer".

The accumulator was also the integer 0, so a real-valued first branch
followed by a Y-containing (complex) branch raised a numpy casting
error on the in-place add. Start from a complex zero matrix and return
the sum.
scipy's linalg.eigh returns eigenvectors as columns, but the mixed-state
branches of both functions read eigenvectors[i] (a row) and computed
eigenvectors[i] @ eigenvectors[i].conj().T, which collapses to a scalar
instead of the rank-1 projector |v_i><v_i|, so any mixed density
matrix crashed with "TypeError: Input density matrix must be a
numpy.ndarray". density_to_graph also stored the whole eigenvalues
array as each branch's weight instead of eigenvalues[i].
networkx 3.4 removed nx.random_tree (replaced by random_labeled_tree,
added in 3.2), and pyproject.toml allows any networkx 3.x, so a fresh
install hits AttributeError in data_collection/ui_functions.py's
"tree" graph type and in test_random_state_converter. Use random_tree
where it still exists, so older networkx versions keep generating the
same graphs, and random_labeled_tree otherwise.
Stabilizer.trace_out_qubits and MixedStabilizer.trace_out_qubits
passed the qubits to trace out as partial_trace's keep= argument, so
they kept exactly the qubits they were asked to discard. Pass the
complement instead.
MixedStabilizer.tableau returned a TypeError instance instead of
raising it, so reading the property on a mixture silently yielded an
exception object that failed later and far from the cause. Raise it.
…thods

- MixedStabilizer.__eq__ called self.sort() and other.sort(), so
  comparing two mixtures reordered both of them in place. Compare
  sorted copies instead.
- Stabilizer.__eq__ and MixedStabilizer.__eq__ reached straight for
  other.data / other.mixture, so comparing against anything else
  (an int, None, or a pure vs. mixed stabilizer) raised
  AttributeError. Return NotImplemented for a foreign operand so
  Python falls back to the reflected comparison / identity, which
  gives False.
…x.__eq__

DensityMatrix.__eq__ was a bare np.allclose(self._data, other.data):
comparing against a non-DensityMatrix (an int, None, ...) raised
AttributeError, comparing matrices of different sizes raised a
broadcasting ValueError, and a (1, 1) matrix could compare equal to a
larger one by broadcasting. Return NotImplemented for a foreign
operand and False when the shapes differ.
- one_pauli_type_finder and pauli_type_finder bounded their row loop
  by the number of columns (np.shape(x_matrix)[1]) instead of rows,
  so on a non-square pair they silently skipped the lower rows or
  indexed past the end. Every production caller passes a square
  matrix, so results there are unchanged.
- one_pauli_type_finder mapped any unrecognised pauli_type to the
  identity pair (0, 0), silently returning the identity rows instead
  of failing. Raise ValueError for anything but "x", "y" or "z".
StabilizerTableau's list branch checked that the X and Z matrices had
matching shapes but not that they were square, and took n_qubits from
the column axis, so e.g. a 4x2 pair built a self-contradictory
tableau. This is reachable from stabilizer_to_density's list[str]
input: stabilizer_to_density(["XZI", "ZXI"]) (two generators on three
qubits) returned an 8x8 "density matrix" of trace 2. Raise ValueError
for a non-square or non-2-D pair, and take n_qubits from the row axis
like the other constructor branches.
benchmark_data's docstring typed solver_class as SolverBase and said
nothing more. It builds each solver as solver_class(target=...,
metric=..., compiler=..., n_emitter=..., n_photon=...) and then reads
solver.hof[0], so TimeReversedSolver (which takes neither n_emitter nor
n_photon, and has no hof) and HybridEvolutionarySolver (which works out
n_emitter and n_photon itself) cannot be passed as they are.

The docstring now states that call and the hof requirement, that
EvolutionarySolver qualifies (through functools.partial for a
solver_setting), that HybridEvolutionarySolver can be passed through a
callable dropping the two keywords, for targets whose density matrix is
itself a graph state, and that TimeReversedSolver cannot be benchmarked.
It says the compiler must be a density-matrix one, since
circuit_measurement_independent compares density matrices and a
StabilizerCompiler raises TypeError there. The targets entry now
describes the (target_circuit, target) pairs it takes: the circuit is
not read, and target is a dict with "name", "n_emitters", "n_photons"
and the state under the key target_type.

Docstrings only; no behaviour changes.
CircuitMaxEmitDepth, CircuitMaxEmitResetDepth and CircuitMaxEmitEffDepth
build one value per emitter and take the max. On a circuit with no
emitters the dict is empty, so evaluate raised ValueError: max() arg is
an empty sequence, directly and through Metrics([...]). The other
circuit metrics return a value on the same circuit; CircuitEmitterCount
gives 0.

Pass default=0 to the three max calls. A circuit with no emitters now
scores depth_penalty(0) on each metric, which is 0 by default, and the
log records that value; a Metrics wrapper holding them returns 0.0. A
circuit with at least one emitter gets the same value as before, since
max with a default returns the same result on a non-empty dict. Nothing
in the package evaluates these metrics on a circuit without emitters, so
no solver, benchmark or example result changes.
bar_plot passed each entry to the diverging colour map as entry * 0.8.
The map covers [0, 1] and clips below 0, so every negative entry and
zero got the deep-blue end, told apart only by alpha, and a positive
entry of at most 1 stayed in the blue half: 0.5 was a pale blue. The
imaginary panel of any real density matrix was drawn all deep blue.

Map each entry through 0.5 + 0.5 * entry / max_height, the band
bar_plot already computes for the z-limits and the alpha. max_height
is always 0.25, 0.5 or 1.0, never 0, and no entry of a density matrix
exceeds it, so the position stays in [0, 1].

No numeric result changes; nothing reads the colours back. Every bar
figure changes colour: zero bars become the neutral grey-white
cmap_div(0.5) (so the imaginary panel of a real density matrix is all
neutral instead of all deep blue), negatives run from neutral to blue,
and positives from neutral to red. Any saved bar figure regenerated
after the fix looks different. Bar heights, z-limits, ticks and alpha
are unchanged.

Three consequences of normalising by the panel's own band:
- colour is relative to each panel's band (max_height 0.25, 0.5 or
  1.0), so the same value can get a different colour in two panels
  whose bands differ (for example 0.2 is at 0.9 in a 0.25 panel and at
  0.6 in a 1.0 panel), including the real and imaginary panels of one
  matrix;
- the bars still disagree with density_matrix_heatmap (fixed vmin=-1,
  vmax=1) whenever max_height < 1; their colour positions agree only
  in the 1.0 band (the bars also keep their own alpha);
- the end colours are reached only in the 1.0 band, by an entry of
  exactly +1 or -1; in the 0.25 and 0.5 bands every entry is strictly
  below max_height, so the extremes stay just short of the ends.
result_maker builds one value per emitter for max_emit_depth,
max_emit_reset_depth and max_emit_eff_depth, and reads one register
depth per emitter for depth, then takes the max of each. On a circuit
with no emitters all four sequences are empty, so result_maker raised
ValueError: max() arg is an empty sequence, including with its default
metric list. n_emitters, n_cnots, n_measurements and n_unitary give 0 on
the same circuit.

Pass default=0 to the four max calls, as the CircuitMaxEmitDepth,
CircuitMaxEmitResetDepth and CircuitMaxEmitEffDepth metrics already
do; each of those evaluates depth_penalty(0), which is 0 with the
default penalty. The depth column has no metric counterpart; 0 matches
what an emitter with no gates already gets there.

Nothing changes for any circuit with at least one emitter: every column
keeps its value. That covers every circuit the package's own
data-collection functions produce, so no orbit_analyzer, find_best,
graph_analyzer, benchmark, example or test result changes. A direct
result_maker call on a circuit with no emitters now returns 0 in
max_emit_depth, max_emit_reset_depth, max_emit_eff_depth and depth
instead of raising. (For comparison, an emitter with no gates gives 1
for the reset and eff depths, since Input to Output is one interval; a
circuit with no emitters has no interval, hence 0, as the library
metrics give with their default penalty.)
IO.default_path is data/ in the parent folder of the graphiq package.
The comment above it said it pointed to the repository's data/ wherever
the repository was, which holds only for a source checkout or an
editable install (pip install -e). For a regular install (pip install
graphiq, or pip install . from a checkout) io.py sits in site-packages,
so the default is <site-packages>/data. Nothing in the docstring said
where a default IO writes.

The class docstring now says so, and the comment matches it. The usage
example called IO.create_new_save_folder with include_uuid= and then
io.save_df, none of which exist; it now calls IO.new_directory with
include_id= and io.save_dataframe, the current names for the same
calls.

Docstring and comment only; where IO saves is unchanged.
The initial_graph setter's assert accepted None, but the setter then
passed the value to _graph_list_maker, which calls nx.to_numpy_array on
it, so assigning None always raised TypeError: 'NoneType' object is not
iterable. It raised after storing the value, leaving initial_graph None
while graph_list still held the old graph's relabelings. No version of
the setter ever handled None.

Accept only a networkx graph, as the assert's message already says.
Assigning None now raises AssertionError before anything changes, so
initial_graph and graph_list keep their previous values. Assigning a
graph behaves as before, and GraphCorr(initial_graph=None) still works:
the constructor handles None on its own path. Under python -O the
assert is stripped and assigning None fails as it did before. Nothing
in the package assigns initial_graph, so no result changes.
test_one_benchmarking_run built a solver, a run dict and an IO, and
then stopped: its benchmark(...) call has been commented out since
2023, and benchmark is no longer imported in the file, so the call
could not run as written. The test could fail only if a constructor
raised, which the other tests already cover.

Delete it and the __main__ block that called it. Every import stays in
use by the remaining tests. No source changes.
draw_dag documents fig as the figure on which to draw the DAG, but a
call with fig and no ax ignored it: the DAG was drawn on a new pyplot
figure, that new figure was returned, and the caller's figure was left
unchanged.

When fig is given without ax, draw on fig's current axes, fig.gca(),
which adds one subplot if fig has none, and return fig with that axes.
No new figure is opened. Calls with no arguments, with ax only, or with
both fig and ax behave as before. The docstring now says which axes a
fig-only call uses. No numbers are computed, so no result changes.
local_comp_graph built its adjacency matrix with
nx.to_numpy_array(input_graph), which fills each entry with the edge's
weight attribute. The matrix product then ran in float64 on those
weights with a single mod 2 at the end, so a weighted graph could give
a result that is not a local complementation of the graph: an edge
with weight 2.0, for example, was treated as absent.

Read the adjacency with weight=None, and say in the docstring that
edge weights are ignored.

local_comp_graph now ignores edge weights. Its result is that of the
same graph with its weights removed: every edge counts once, and
between two nodes of a MultiGraph the parallel edges count by the
parity of their number (an even number cancels). For a graph whose
edges all have weight 1 or no weight, which covers every graph the
package builds (including graphs from nx.from_numpy_array of a 0/1
matrix and graphs returned by local_comp_graph), the result is
unchanged. Before, any other weight could change the result, for
example an even, fractional, very large odd, nan or inf weight, and a
non-numeric string or a complex weight raised. No in-tree call passes
such a weight, so no result, test or benchmark in the package changes,
and no random numbers are drawn.
density_matrix_heatmap documents its return value as fig (figure
handle), axs, but a call with axs returned None in place of the figure.
The heatmaps were drawn on the given axes as documented.

When axs is given, return axs[0]'s root figure: the figure holding
axs[0], or the root figure when the axes sit in a SubFigure. If the two
axes come from different figures, it is the figure of the real-part
axes. The docstring now says that axs is two axes, real part first, and
which figure is returned. Calls without axs behave as before. No
numbers are computed, so no result changes.
_partial_orbit gives fixed local-complementation sequences for the path
0-1-...-(n-1): an index x means the x-th node along the path.
linear_partial_orbit passed each x straight to local_comp_graph, which
reads it as the x-th node in the graph's iteration order. The two agree
only when the path runs through the nodes in iteration order. For any
other path, the wrong nodes were complemented: the first graph could
differ from the input, and the list could repeat graphs.

Walk the path from the end that comes first in iteration order and map
each index to that node's position. Also require the graph to be
connected: the existing check (n - 1 edges, maximum degree 2) accepted
a shorter path plus disjoint cycles, which is not a linear cluster
state, and now raises the same "input graph is not a linear graph"
AssertionError. The docstring states both.

For a path already in iteration order, including nx.path_graph(n), the
output is unchanged, graphs and labels alike. For a path in another
order, the output is the in-order orbit carried along the path,
labelled 0..n-1 by position as before, with the input first and no
repeats. The only caller is AlternateTargetSolver with
lc_method="linear", which is not the default, was used nowhere in the
package, its benchmarks or examples, and had no test, so no default or
in-tree result changes. With that setting, candidate graphs change for
iso graphs or targets not in path order; with allow_relabel=True that
is typically every relabelling after the first.
CI's lint job runs black 24 --check on graphiq/. It rejects the
parenthesised n_cnot_penalty default in CircuitCnotCount.__init__: the
line fits on one line since the attribute was renamed from
n_emitter_penalty, so black joins it.

Join the line as black 24 does. Formatting only: the code, the comment
and every result are unchanged, and black 24 --check now passes on all
of graphiq/.
test_benchmark_run_graph_search_solver built its IO with no path, so
IO fell back to its default folder: data/ in the checkout for a source
or editable install, or <site-packages>/data for a regular install.
Every run left a dated benchmarks folder there, holding
solver_result.csv and a circuits folder, and nothing removed it. data/
is gitignored, so the folders piled up unseen.

Pass path=tmp_path, as the neighbouring benchmark_run tests do, and
assert that solver_result.csv is saved under tmp_path. That assertion
fails if the IO falls back to the default folder again. No source
changes.
QuantumState.n_qubits was stored once in __init__ and never updated.
After partial_trace took a 4-qubit state to 3 it still reported 4,
while rep_data reported 3. The same happened after resizing the live
representation directly, e.g. state.rep_data.remove_qubit(0) or
state.rep_data.add_node(...). The stale count broke two things: the
rep_data setter's width asserts compared incoming data against it,
so assigning a traced state's own data back raised AssertionError
while data of the old, pre-trace width was accepted; and
compile(circuit, initial_state=state) rejected a traced state whose
data was exactly the circuit's width.

n_qubits is now a read-only property returning rep_data.n_qubits, so
it cannot go stale. DensityMatrix gains the n_qubits property the
other representations already had. __init__ keeps the count from
validate_data in a local, and the representation helpers take the
expected width as an argument: __init__ passes that count, and the
rep_data setter passes the state's current width. The width asserts
themselves, their messages and their exception types are unchanged.

Behaviour changes:

- QuantumState.n_qubits reports the representation's current width
  after partial_trace or any resize made through rep_data.
- QuantumState.n_qubits is read-only: assigning to it raises
  AttributeError. Before, the assignment was accepted and
  desynchronised the count from the data. Nothing in graphiq assigned
  it outside __init__.
- After a resize, the rep_data setter accepts data of the new width
  and rejects data of the old width with AssertionError (the reverse
  of before). A state that was never resized is checked as before.
- An existing Stabilizer or MixedStabilizer object assigned through
  rep_data is still adopted without a width check; the state's
  n_qubits now follows its width.
- compile(circuit, initial_state=state) accepts a traced state for a
  circuit of its new width, and rejects it with "the number of qubits
  in initial state must match the circuit" for its old width. Before,
  the old width was accepted: an empty circuit returned a state
  narrower than the circuit, and a gate on a missing qubit failed
  later.
- The "Density matrix is not recommended" warnings, in the rep_data
  setter and in convert_representation, are judged on the current
  width rather than the construction-time count.
- Pickles and copies report the current width; n_qubits is no longer
  an instance attribute. A pickle made before this change still loads
  and reports the current width.
- DensityMatrix.n_qubits is new.

A 9x9 density matrix is still rejected with AssertionError, at
construction and through the setter, and malformed setter input
fails as before. Results of code paths that never resize a state are
unchanged.
…graph gets

With alternate_order=True, crazy relabels its graph with
relabel(adj, nodes), which sends old node i to new label nodes[i]. The
position dict was built the other way round, giving new label i the
position of old node nodes[i]. The two agree only when every swapped
pair of columns has the same size. Otherwise pos=True placed some nodes
in the wrong column: for n_list [2, 3], three of the six edges were
drawn within a single column.

Give node nodes[i] the position that node i has in the plain layout.
The graph itself is unchanged for every input, so its labelling and
emission order stay as they were. Only the positions returned with
pos=True and alternate_order=True change, and only when a swapped
column pair has unequal sizes. Nothing in the package calls crazy with
pos=True, so no in-tree result changes.
section_finder documents a ValueError for an invalid criteria, but it
raised one only when criteria was neither a string nor a list. A string
that named none of its options, such as the typo "reg_as_ctrl", matched
no branch and silently left the path unsliced, as a single section. A
NoisyEnsemble given that criteria in its noise parameters then built
its event trees from that one section, with no error, and could score
a different infidelity than the intended criteria would have.

Raise the same ValueError for an unknown string. The five valid strings
and a list of op classes behave as before, so no result from a valid
criteria changes.
Fifteen docstring lines in the backends described behaviour the code
does not have:

- bipartite_partial_transpose raises ValueError when subsys is neither
  0 nor 1; it never checks whether the matrix is bipartite.
- project_and_remove's rho was described as the matrix whose
  negativity is evaluated, copied from negativity.
- DensityMatrixCompiler.compile_one_noisy_gate takes the QuantumState,
  as compile_one_gate does, not a DensityMatrix.
- graph_to_density raises TypeError for a graphiq Graph; it accepts a
  networkx graph, an adjacency matrix or a list of (probability, graph)
  pairs.
- is_lc_equivalent and Graph.lc_equivalent return a (found, solution)
  pair, not an array, and Graph.lc_equivalent takes one other Graph. In
  'random' mode False only means no solution was found, which
  Graph.lc_equivalent's return line now says as is_lc_equivalent's
  does.
- _solution_basis_finder returns the solution-space basis vectors,
  _col_finder a list, and lc_graph_operations a list of node indices,
  not operation names.
- Graph.add_edge adds a missing node before adding the edge.
- create_n_ket1_state returns |1>^n, and add_columns adds columns.

Docstrings only; no behaviour or result changes.
Seventeen docstring lines in the circuit package and the openQASM,
drawing and comparison utilities described behaviour the code does not
have:

- CircuitDAG.from_json returns the circuit it builds, and
  calculate_reg_depth returns a list.
- OperationBase.openqasm_info and find_local_clifford_by_matrix never
  return None; both raise ValueError instead (find_local_clifford_by_matrix's
  :raises line already says so).
- The q_registers setter and _update_q_reg replace the whole
  quantum-register tuple and check its length against q_registers;
  operations have no emitter_registers or photon_registers. The
  c_registers setter checks c_reg against c_registers.
- MeasurementZ's noise type is graphiq.noise.noise_models.NoiseBase;
  there is no src package.
- OpenQASMParser.parse is a generator of per-node info dicts.
- Columns.set_all_col_element sets one row position in every column,
  and only where the entry is still 0.
- ged_adaptive returns whether the GED is zero, not the distance.

Docstrings only; no behaviour or result changes.
Fourteen docstring lines described behaviour the code does not have:

- TwoQubitControlledGateReplacement applies the target gate when the
  control is |1>, not |0>.
- CircuitUnitaryCount counts unitary gates; CircuitMaxEmitDepth,
  CircuitMaxEmitResetDepth and CircuitMaxEmitEffDepth score the
  maximum emitter depth, reset depth and effective depth, not the
  circuit depth.
- NoisyEnsemble.all_branches keeps each event tree built for a noisy
  register above the cut-off probability; the combined tree is their
  product and can fall below it.
- multi_reg_nodes selects nodes by their operation's base class, not by
  their number of edges.
- IO.load_np_array returns the loaded array.

Docstrings only; no behaviour or result changes.
Eleven docstring lines described behaviour the code does not have:

- SolverBase._identify_noise returns a list of two noise models for a
  two-legged operation, which every solver caller passes.
- GradientDescentSolver.solve runs n_step updates (there is no
  n_steps), and AlternateTargetSolver's constructor named a class that
  does not exist.
- lattice_cluster_state takes a tuple of sizes, one per dimension, and
  two_d_cluster and three_d_cluster build grid cluster states, not the
  crazy graph.
- search_for_alternative_circuits and the exemplary_* helpers need an
  EvolutionarySolverSetting; a RandomSearchSolverSetting raises
  AttributeError in solve.
- report_alternate_circuits takes the list of (score, probability,
  circuit) tuples the search returns.

Docstrings only; no behaviour or result changes.
…hat contradict the code

Nineteen docstring lines described behaviour the code does not have:

- GraphCorr._graph_list_maker returns the initial graph followed by up
  to count labelings, the first of which is the initial one again.
- GraphCorr.finder returns no standard deviations (they are only drawn
  as error bars), and its (x, y) tuple is (unique circuit metric
  values, average graph metric values).
- corr_n_dependence's constant_np is a number, the n*p kept constant,
  not a flag.
- met_met, NodeCorr.met_order_error, NodeCorr.next_node_corr and
  _rep_counter return the repetition counts as a numpy array.
- NodeCorr.order_corr averages the correlations, not the graph metric.
- correlation_checker prints and returns nothing; corr_with_mean also
  returns the unique values, means and standard deviations.
- _label_finder samples from all permutations when n_node is below 8
  as well as when exhaustive is set, and then ignores new_label_set.
- get_relabel_map adds -1: "self" when the two graphs are equal.
- density_matrix_heatmap and density_matrix_bars return two axes.

Docstrings only; no behaviour or result changes.
…it saves

6_solvers.ipynb listed self.save_openqasm among the solver's
attributes. It is a field of EvolutionarySolverSetting, a string:
"hof", "pop" or "both" write those circuits out as openQASM after each
generation, through the solver's io, and the default "none" saves
nothing. Without an io nothing is written.

One markdown line; no code or saved output changes.
8_variational_circuits.ipynb kept a TracerArrayConversionError
traceback, with local paths from an old run, as the output of its
optimisation-loop cell. Clear that cell's outputs and execution count.

Only the saved output changes; the cell's source and every other cell
are untouched.
…version

GradientDescentSolver and 8_variational_circuits.ipynb present the jax
array library as usable, and nothing said that it is not. In this
version:

- setting graphiq.DENSITY_MATRIX_ARRAY_LIBRARY after import graphiq
  does not switch the density-matrix backend to jax: importing graphiq
  already imports that backend, which reads the setting once and keeps
  numpy. It only lifts GradientDescentSolver's own check, which reads
  the setting each time a solver is made. The backend uses jax only if
  the constant is edited in graphiq/__init__.py;
- with jax selected, no density-matrix QuantumState can be built from a
  matrix: QuantumState accepts only numpy arrays, while DensityMatrix
  accepts only jax arrays;
- the density-matrix arm of Infidelity cannot be differentiated, since
  it takes the trace with numpy and branches on its value.

What still works: with jax selected by editing the constant,
GradientDescentSolver runs with states built from a number of qubits
and a jax-differentiable metric of the user's own.

GradientDescentSolver's class docstring now says this, and the
notebook's title cell says the notebook does not run as written and
that its saved outputs come from an older version.

Text only: no code, cell source other than the title cell, or saved
output changes.
…aphs

For graphs under 8 nodes, or whenever it is asked for an exhaustive
search, _label_finder drew its permutations with replacement
(rng.choice(perm[1:], n_label - 1)), so it could return the same
permutation more than once. automorph_check removes the duplicate
graphs, so callers saw missing relabellings rather than duplicate ones:
iso_finder could stop short of the graphs that exist and warn "Maximum
of N possible isomorphic graphs exist" for a count below the real one.
For example, iso_finder on a 4-node path, which has 12 distinct
labellings, returned 8 to 11 of them for 169 of seeds 0-199.

_label_finder now keeps the same draw, drops the repeats, and tops up
without replacement from the permutations that were not drawn. A draw
with no repeat gives exactly the rows it gave before, in the same order.
It also returns [[0]] for a 1-node graph, where it raised ValueError, so
iso_finder on a 1-node graph returns that graph.

This changes seeded results. A seeded iso_finder or AlternateTargetSolver
run changes only if one of its rounds drew a repeated permutation; it may
then return different, and sometimes more, relabelled graphs. On a grid
of 1480 seeded iso_finder calls on 4- to 7-node graphs, 350 drew a repeat
and 236 of those changed. Graphs of 8 or more nodes take the other branch
unless the search goes exhaustive.

The data_collection helpers find_best, graph_analyzer and
iso_scaling_test run with seed 1 by default, so their default outputs
change. find_best now finds every reordering: 12 instead of 11 for a
4-node path, 60 instead of 51 for a 5-node path, 360 instead of 311 for a
6-node path. The CSV and JSON files it saves, and graph_analyzer's
bests.txt ("number of iso found", "total number of cases" and the
best/worst picks when they depend on the new cases), change with it.
orbit_analyzer and lcs ask for one ordering and are unchanged.

new_label_set is still ignored in this branch, so each iso_finder round
still redraws its labellings instead of extending the previous round's.

test_label_map_has_one_map_per_returned_matrix_after_a_longer_search
moves from seed 5 to seed 0: seed 5 needed a longer search only because
its first draw repeated a permutation, while seed 0 needs one because of
the graph's own symmetry.
…ise_score

noise_score reads the target's adjacency matrix in the graph's node
order, but it built the relabelling permutation by looking the relabel
map up with the integers 0..n-1. The map is keyed by the target's own
node labels. So with noise simulation on (a noise_model_mapping given,
not Monte Carlo), a target whose nodes are not labelled 0..n-1 raised
KeyError. That includes linear_cluster_state(n).data, which is labelled
1..n, graphs with string labels, and QuantumState graph targets built on
such graphs. A target labelled 0..n-1 but with its nodes inserted out of
order raised nothing, but was scored against a relabelled target that
was not the graph its circuit makes: with depolarizing noise and seed 1,
the path 2-0-1 inserted as [2, 0, 1] scored 0.762727 for its first
candidate instead of 0.076624.

Look the map up by each node's label, in the order the adjacency
matrix is read, and drop the node count that only the old lookup used.

Every target labelled 0..n-1 in insertion order builds the same
permutation as before, so its scores are bit-identical. That covers
every target the tests passed to the solver before this change and every
graph t_graph builds itself; a graph drawn with t_graph's "draw" or
passed in with "nx" can be labelled either way. A target labelled
otherwise now gets a score instead of KeyError, and a 0..n-1 target
inserted out of order now gets the correct score (unless the old
permutation happened to be an automorphism of the target), so its
candidates can rank differently. Noise-free and Monte Carlo solves do
not call noise_score and are unchanged.
@jie-qiqc
jie-qiqc requested a review from Sobhan-Gh September 28, 2026 13:05
The pytest workflow installs the package with its all extra (jax,
optax, ray) on Python 3.8, 3.9 and 3.10. On 3.8 that install now fails
with a pip ResolutionImpossible between optax and jaxlib: PyPI no
longer has any jaxlib release with a Python 3.8 wheel, optax 0.1.8 and
earlier depend on jaxlib, and later optax releases require Python 3.9
or newer. So the 3.8 job stopped before running any test.

On 3.8 the job now installs ray alone, which test_solver_monte_carlo
needs for its parallel Monte Carlo run; 3.9 and 3.10 still install the
whole extra, so jax and optax are tested there. The package, its
dependencies and its results do not change.
…a tiny rotation

test_a_rounding_error_probability_is_not_forced built |0> as Hadamard
twice on one emitter and required p(1) to be nonzero and below 1e-20.
Whether H·H leaves any p(1) at all depends on the BLAS kernel numpy
uses: with numpy 1.24.4, kernels that fuse multiply-add leave 6e-34,
while the kernels without FMA that were tried (older x86 cores, and
Rosetta, which does not advertise AVX) leave exactly 0, so the
precondition failed there even though the measurement code is correct.

The circuit now applies one rotation by 2e-17, which leaves
p(1) = sin^2(1e-17), about 1e-34. That is a single product with nothing
to cancel, so it is the same nonzero value on every kernel, and far
below the rounding floor. The test still compiles the circuit, and
still fails on the code before the floor (outcome 1 instead of 0). The
library does not change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant