Core infrastructure for crosstalk-free GST experiment designs - #735
Core infrastructure for crosstalk-free GST experiment designs#735rileyjmurray wants to merge 87 commits into
Conversation
…e implementation for extract_labels()
…he switchboard with 'auto' to detect the underlying graph type and use the better algorithm designed for that graph type.
rileyjmurray
left a comment
There was a problem hiding this comment.
It's occurring to me that much of PR's complexity can be attributed to the concept of "lanes" within a circuit. I recommend we move all of that functionality to a second PR.
| layer_lbl = self.layertup[i] | ||
|
|
||
| if lines is None: # Add idles only when lines is None | ||
| if layer_lbl.sslbls is None: | ||
| if not layer_lbl.components: | ||
| components = [_Label('I', line) for line in self._line_labels] | ||
| else: | ||
| components = [layer_lbl] | ||
| else: | ||
| components = list(layer_lbl.components) | ||
| for line in self._line_labels: | ||
| if line not in layer_lbl.sslbls: | ||
| components.append(_Label('I', line)) | ||
| else: | ||
| components = list(layer_lbl.components) |
There was a problem hiding this comment.
A label whose string representation starts with a I is assumed to be an Instrument. I'm not sure if we have a truly standard way of referring to an idle gate. Gi is the most common, but that's not universal. This seems like a deeply messy issue.
There was a problem hiding this comment.
On a separate but related note, I don't like the depth of the nesting here. We've got a for, then if, then if, then for, then if. That's very hard to follow.
| # The 2**N nonzero elements of the resulting vector are at indices that are | ||
| # base-4 numbers whose digits are only 0 or 3. Rather than indexing these via | ||
| # the "natural" bit order of `finds` (which produces a bit-reversal permutation | ||
| # of indices), `_zvals_int64_indices_and_signs` indexes via `r = 0..2**N-1` | ||
| # directly, using order-preserving bit-spreading to get the corresponding | ||
| # index. This means `final_indices` comes out already sorted, so the scatter-write | ||
| # below is sequential rather than jumping around unpredictably (the naive | ||
| # bit-indexing scheme is a textbook-bad memory access pattern once `outvec` no | ||
| # longer fits in cache). |
There was a problem hiding this comment.
It's extremely misleading for this function to suggest that efficiency here matters. It does not. I take it that this comment block was written by a robot. That's fine, but I expect you to remove these kinds of things.
This PR should be merged before #735 as we are splitting that PR to make it simplier to review. ## Overview This PR adds a robust, high-performance, and mathematically rigorous **graph edge-coloring toolkit** inside `pygsti/tools/graphcoloring`. This toolkit is designed to support scheduling and routing operations on quantum processoring unit's coupling maps/interaction graphs by partitioning the edges into colored sets so that no two adjacent edges share a color class. All algorithms are wrapped behind a single, unified public API switchboard: `switchboard_find_edge_coloring`. If you would like to check whether a returned coloring is valid, you can call `check_valid_edge_coloring(color_patches, ret_false_on_error=False)`. Note that it verifies **properness** (no two edges sharing a vertex are assigned the same color, and no edge is duplicated across color classes) -- it does *not* independently check **completeness** (that every input edge received a color); all four algorithms are documented/tested to always return a complete coloring, so this is primarily a sanity check against accidental omissions or bugs. --- ## 🚀 Key Algorithms ### 1. `auto` (Recommended Default) - **Approach**: First structurally inspects the graph to see if it matches a canonical processor geometry ("line", "ring", "grid", or "torus"). If matched, it uses an optimal closed-form edge-coloring requiring the exact minimum chromatic index (at most 2 colors for lines, 2–3 for rings, and 4 for grids/even-tori) in $O(|V|)$ time. - **Bipartite Fallback**: If the graph is not a canonical topology but is bipartite (detected via `networkx.is_bipartite` in $O(|V| + |E|)$), it runs a randomized bipartite-optimal algorithm (`_BipartiteEdgeColoring`) which frequently colors bipartite graphs using exactly $\Delta$ colors. - **General Fallback**: For all other arbitrary non-bipartite graphs, it falls back to the deterministic `vizing_edge_coloring`. ### 2. `vizing` & `misra_gries` (Exact constructive Vizing-chain algorithms) - **Guarantees**: Proper, complete edge coloring using at most $\Delta + 1$ colors (Vizing's theorem) on all simple graphs. - **Worst-case Complexity**: $O(|V| \cdot |E|)$ time. - **Implementation**: Robust constructive implementation utilizing Vizing fans and alternating color-recoloring chains. - **References**: - V. G. Vizing, "On an estimate of the chromatic class of a p-graph," *Diskret. Analiz*, vol. 3, pp. 25-30, 1964 (in Russian; no stable DOI/URL -- the original chromatic-index theorem this module's "Vizing" and "Misra-Gries" algorithms both constructively prove). - J. Misra and D. Gries, "A constructive proof of Vizing's theorem," *Information Processing Letters*, vol. 41, no. 3, pp. 131-133, 1992. https://doi.org/10.1016/0020-0190(92)90041-S ### 3. `sinnamon` (Deterministic Greedy-Euler-Color) - **Guarantees**: Proper, complete coloring using at most $2\Delta - 1$ colors. - **Worst-case Complexity**: $O(|E| \log(\Delta))$ time. - **Reference**: C. Sinnamon, "Fast and Simple Edge-Coloring Algorithms," [arXiv:1907.03201, 2019](https://arxiv.org/abs/1907.03201). ### 4. `random_euler_color` (Randomized Random-Euler-Color) - **Guarantees**: Proper, complete coloring using at most $\Delta + 1$ colors. - **Worst-case Complexity**: Runs in expected $O(|E| \sqrt{|V|})$ time with high probability. - **Reference**: C. Sinnamon, "Fast and Simple Edge-Coloring Algorithms," [arXiv:1907.03201, 2019](https://arxiv.org/abs/1907.03201). - Branch [graphcoloring-algorithms-saved](https://github.com/sandialabs/pyGSTi/tree/graphcoloring-algorithms-saved) holds implementations of other graph algorithms if one wants to use those in the future. --- ## 🧪 Testing & Verification The tests are implemented in `test/unit/tools/test_graphcoloring.py`: - **Correctness & Completeness**: Every algorithm is tested on standard families (complete graphs, cycles, grids, paths, random regular, hubs) to ensure it produces a proper (no adjacent edges share a color) and complete (all edges colored) coloring. If there is a specific family of graphs you would like to ensure are tested you can add them here. - **Seed-Reproducibility**: Randomized algorithms (and the `"auto"` bipartite fallback) are verified to be fully deterministic when passing a fixed `seed`. - **Scaling Benchmarks**: Includes a robust scaling test class (`GraphColoringScalingTester`) guarded by hard wall-clock timeouts that prints comparative performance and color-quality tables when run locally (`pytest -m slow`). -- Added a workflow test to run the graphcoloring scaling tests if there is a change detected to the subpackage: tools/graphcoloring or its test, (test/unit/tools/test_graphcoloring.py). This will run for your PR as long as there is a difference in those files compared to the target branch. Note it should not run for the beta/master/bugfix branches as one should now the scaling of each of the potentially new algorithms before it makes it to that step in the workflow.
Split off from PR #735 This PR focuses on the ability to lane circuits. It is not dependent on #848. A circuit can be split into lanes, which are sets of non-interacting qubits. Once we have those lanes, we can potentially simulate the circuit more quickly depending on the error model. The current way to build a circuit with a known lane structure is to use the function `batch_tensor`, which requires two or more circuits (it will raise an error if given only one). Note that the lane structure will not be generated until you either build the circuit with `batch_tensor` or call `compute_subcircuits(circuit, cache_lanes_in_circuit=True)`. This cache is only used for static (read-only) circuits, so it cannot go stale from an in-place edit; converting a circuit back to editable and calling `done_editing()` clears the cache. A future PR may update the cache based upon the action done to the circuit, but that is not available at this time. When calling `compute_subcircuits`, the caller does not need to specify the lane partition (`qubit_to_lanes`/`lane_to_qubits`); if omitted, it will be automatically detected from the circuit. However, if the partition is already known, passing it in explicitly avoids the detection step and will be faster. Each circuit's lane cache is independent: copying a circuit deep-copies `saved_auxinfo`, so mutating one circuit's cache cannot corrupt another's. This PR is purely standalone infrastructure in `pygsti/circuits/` (`split_circuits_into_lanes.py` plus supporting cache hooks in `Circuit`). It is not yet wired into any forward simulator, protocol, or layout code — that integration is left for a future PR. Tests are in `test/unit/circuits/test_circuit_splitting.py` and cover: basic lane splitting and caching, cache invalidation/miss detection, automatic lane detection, `batch_tensor` with mismatched-depth circuits, custom line ordering (including multi-qubit gates and string labels), the single-circuit error case, and regression cases for non-contiguous/interleaved lanes.
…the process matrices.
…some helper functions.
…o ensure containment includes the all of the circuits from the previous germ powers.
This PR adds crosstalk-free GST (XFGST) experiment designs. Given a
QubitProcessorSpec, a 1Q GST design, and a 2Q GST design, it produces one circuit list that exercises many disjoint 1Q and 2Q GST experiments simultaneously across the device.The packing of disjoint experiments onto the n-qudit device comes from an edge coloring of the 2Q connectivity graph. Each color class — a patch — is a set of mutually disjoint edges, so all of its 2Q experiments can run together, and any qubit not covered by an edge in that patch runs 1Q GST instead. The coloring algorithms are #848 and the lane-tensoring machinery is #849; this PR is the layer on top of both. It also carries changes to core code outside the feature, including two observable changes to public methods, which have their own section and should be reviewed independently.
The API
New module
pygsti/protocols/xfgst_edesign.py, star-imported intopygsti.protocolswith__all__ = ['CrosstalkFreeExperimentDesign', 'make_xfgst_design'].CrosstalkFreeExperimentDesign(GateSetTomographyDesign)takes a processor spec, the two GST designs, and an edge coloring:edge_coloring{color: [edge, ...]}; one patch per colorcircuit_stitcherassign_the_designs_with_mappingnestedFalseensure_containment, and separately toGateSetTomographyDesign.__init__asnesteddebug_checkTrueseedNonenp.random.default_rnghanded to the stitcher**stitcher_kwargsmake_xfgst_design(nq_pspec, oneq_gstdesign, twoq_gstdesign, seed=0)is the simplified constructor: it computes the connectivity graph and its maximum degree, obtains a coloring fromswitchboard_find_edge_coloring("auto", ...)— an optimal closed-form coloring for canonical topologies (line, ring, grid, torus, as produced byProcessorSpec(geometry=...)), a generic(deg + 1)-color algorithm otherwise — and delegates.The default stitcher
assign_the_designs_with_mappingholds essentially all of the feature's logic. It returnscircuit_lists[L]for germ-power indexL, and there are four decisions in it worth review.Patch-major ordering is a contract. Within each germ power, circuits follow the input ordering of
color_patches, and downstream verification checks this rather than assuming it.The two designs must share a germ-power schedule, asserted as
len(oneq.circuit_lists) == len(twoq.circuit_lists)with the message"Not implemented.".List lengths are reconciled per lane slot.
random_index_schedule(n, max_len, randgen)always includes0..n-1, so every circuit in the shorter list appears at least once; the remaining slots are drawn uniformly with repetition and the whole schedule is shuffled. Every circuit in the longer list is used exactly once, either list may be the longer one, and a fresh schedule per lane slot means different edges in a patch get different assignments. This is list-length equalization, distinct from the depth paddingbatch_tensorapplies to shallower sub-circuits.Same-shape patches share a template. Patches bucket by
(num_edges, num_unused_qubits); one tensored circuit is built per group, and the rest obtain theirs viamap_state_space_labelsthroughmake_line_mapper, which zips lane layouts positionally and raises on arity mismatch, inconsistent duplicates, and non-injective results. This is the stitcher's main optimization and also its main failure mode — copying instead of relabeling silently puts several patches' circuits on the representative's qubits — soMultiplePatchesSameShapeTestertargets it directly.Two other things to know. Idles are always explicit:
build_layer_mappersmaps the empty layer labelLabel(())onto an explicitGi/Giiidle and rewrites 1Q labels inside the 2Q design into parallel layers, which matters because the tensored circuits are later split back into lanes. Andseedreproducibility is tied to the shape-group iteration order, so renaming or reordering patches changes the generated circuits. Passingnested=True(orensure_containment=Trueas a stitcher kwarg) makes germ powerL+1contain germ powerLexactly, patch-wise;EnsureContainmentTestercovers the single-patch, same-shape, and three-germ-power transitivity cases.Writing an alternative stitcher
CrosstalkFreeExperimentDesignsuppliesrandgenandensure_containmentand merges**stitcher_kwargsover them, so an alternative can take its own options without a signature change anywhere; the built-in accepts and ignores**kwargsto stay compatible, and setsaux_info = {}unconditionally since it produces none._layer_mappers_overrideis a private testing hook.Validation is external by design.
assert_circuit_lists_match_color_patches, run from__init__whendebug_check=True, re-derives the patch layout and checks even patch-major chunking, absence of implicit idles, and that each circuit's line labels are exactly its patch's qubits and every multi-qubit op sits on one of that patch's own edges. Comparing only the set of line labels does not catch a mis-relabeled template, since{0,1,2} == {0,1,2}holds even when the gates are on the wrong lines. Because the check runs against whatever was returned, it still applies to a swapped-in stitcher — which relying on the stitcher's owndebug_checkparameter did not. One known non-behavior: patches are not deduplicated, so[(0,1),(2,3)]and[(1,0),(3,2)]both generate.Behavior changes outside XFGST
Circuit.serializepreservesCircuitLabellayers. Withexpand_subcircuits=False, aCircuitLabellayer is appended as-is rather than expanded into its components. This matches the existing docstring ("IfFalse, the circuit may containCircuitLabellayers") but is an observable change to a core public method.EffectRepComputational.probabilityno longer densifies. It calls the newmatrixtools.zvals_int64_probabilityinstead of building a dense length-4**nfactorseffect vector and taking a full dot product, so the work isO(2**n)rather thanO(4**n), mirroring the compiled evotypes' C++ path. We make no representations about whether this change matters in the workflows where pyGSTi is used.compute_2Q_connectivityandcompute_clifford_2Q_connectivityno longer crash on{idle}.gate_num_qubits('{idle}')equals the device size, so on a two-qubit spec the global idle clears the== 2arity guard — but its availability is[None], so both methods reachqubit_labels.index(sslbls[0])and raiseTypeError: 'NoneType' object is not subscriptable. The Clifford method'sexcept (KeyError, TypeError, ValueError)does not save it:clifford_symplectic_rep_of(Label('{idle}', None))succeeds, so the crash lands after the guard. The idle is now special-cased and treated as available on every qubit.A regression test for per-site Clifford validation.
nonstd_gate_symplecticrepscan register a symplectic rep against a specificLabel— one embedding — rather than against a gate name, socompute_clifford_2Q_connectivityhas to validate each availability site individually. Trusting the name alone yields phantom edges that disagree withcompute_clifford_ops_on_qubitsand crashcreate_random_germ(pygsti/algorithms/randomcircuit.py:1736) withValueError: low >= highwhen one is sampled. The guard itself is unchanged relative todevelop— it was dropped and reinstated within this branch — so what lands here istest_compute_clifford_2Q_connectivity_only_counts_validated_sitesand an explanatory comment, not a fix. It is a randomized-benchmarking concern with no connection to XFGST.New
LocalNoiseModel.__getitem__, dispatching by substring:'rho'→prep_blks['layers'],'Mdefault'→povm_blks['layers'],':'→ split into a tuple, otherwiseoperation_blks['gates']falling back tooperation_blks['layers'].objectivefnsis now imported eagerly (pygsti/__init__.py,pygsti/objectivefns/__init__.py), sopygsti.objectivefns.objectivefns.Xresolves without a separate import — the intent stated in commitc4d3fb89e. Small import-time cost, no known circularity.test/unit/objects/test_processorspec.pymoved totest/unit/processors/, with about thirteen new tests for previously untestedQubitProcessorSpecmethods (resolved_availability,subset,map_qudit_labels, the connectivity methods, and others). Hardcoded paths to the old location will break.Reviewer attention
build_layer_mappersis both unexercised and over-specialized. No test runs it: every test passes_layer_mappers_override=, andtest/integration/test_xfgst.pyinjects its own near-duplicate_get_layer_mappershelper. It also assertstgt in [0, 1]and hardcodes'Gi'/'Gii', assuming the 2Q design's qubit labels are literally0and1and that both idle names exist in the model. Neither is checked against the processor spec, andmake_xfgst_designgives no way to override them.{idle}branch setsavail = [qubit_labels]and then indexessslbls[0]/sslbls[1], which is correct only for a two-qubit spec. It is safe today, because the idle's arity equals the device size and thegate_num_qubits(gn) == 2guard therefore confines the branch to two-qubit devices. The residual exposure is a user-registered two-qubit gate literally named{idle}on a larger spec, which would connect only the first two qubits. An assertion would make that reasoning local rather than implicit.LocalNoiseModel.__getitem__has no in-tree caller. Has a loose hueristic'rho' in keymatches any label containing "rho" anywhere, the annotation admitsLabelas well asstr(whereinmeans something else), and no key produces a cleanKeyError.Circuit.serializenow ends withtmp = Circuit._fastinit(...); return tmp— a leftover temporary.test/integration/test_xfgst.pytests GLNDHSCAmodels, but not CPTPLND models.Incidental changes
jupyter_notebooks/Examples/xfgst.ipynb(new): three code cells that build a 3-qubit XYIECR design and run the pipeline end to end. No markdown narrative yet.pygsti/tools/matrixtools.py: type annotations across roughly sixty signatures. Substantively,zvals_int64_probability(the sparse inner product used above), a rewrittenzvals_int64_to_dense, the bit-manipulation helpers_zvals_int64_indices_and_signs/_spread_bits/_reverse_bits_scalar, and a module-level cache. Separately,minweight_matchnow fills its weight matrix by broadcasting rather than looping, with guarded fast paths that fall back to the original loop whenever bit-identical results cannot be guaranteed.pygsti/models/explicitmodel.py:Union[X, Y]→X | Yon thedefault_gauge_groupsetter, an unused import dropped.pygsti/circuits/split_circuits_into_lanes.py,test/unit/circuits/test_circuit_splitting.py, andtest/unit/tools/test_graphcoloring.py.test/unit/objects/test_localnoisemodel.pyreformatted, bareassert→self.assertX..github/workflows/reuseable-main.yml: drops the stalepygsti/tools/graphcoloring.pypath from the graph-coloring change detector, since Feature graphcoloring #848 turned that module into a subpackage.Testing
test/unit/protocols/test_xfgst_edesign.py(new) covers the stitcher against stub designs:AssignDesignsLengthPairingTester(length reconciliation in both directions, plus idle padding of shallower sub-circuits),MultiplePatchesSameShapeTester, andEnsureContainmentTester(including three-germ-power transitivity).test/integration/test_xfgst.py(new) runs design construction, data simulation, and GST on a 3-qubit line under four Lindblad noise configurations — H, S, H+S, and H+S+C+A, the last underGLNDnotCPTPLND, asserting a2*deltaLogLbound for each. It is reduced-scale by construction and finishes in well under a minute.test_circuit.pygainsserialize/parallelizeround-tripping alongsideset_line_labels,set_occurrence, and QUIL string helper coverage;test_processorspec.pygains the connectivity tests described above;test_localnoisemodel.pygainstest_getitem.