Skip to content

Core infrastructure for crosstalk-free GST experiment designs - #735

Open
rileyjmurray wants to merge 87 commits into
developfrom
upstream-xfgst-core
Open

Core infrastructure for crosstalk-free GST experiment designs#735
rileyjmurray wants to merge 87 commits into
developfrom
upstream-xfgst-core

Conversation

@rileyjmurray

@rileyjmurray rileyjmurray commented Apr 19, 2026

Copy link
Copy Markdown
Contributor

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 into pygsti.protocols with __all__ = ['CrosstalkFreeExperimentDesign', 'make_xfgst_design'].

CrosstalkFreeExperimentDesign(GateSetTomographyDesign) takes a processor spec, the two GST designs, and an edge coloring:

Parameter Default Meaning
edge_coloring (required) {color: [edge, ...]}; one patch per color
circuit_stitcher assign_the_designs_with_mapping Pluggable; see the contract below
nested False Forwarded to the stitcher as ensure_containment, and separately to GateSetTomographyDesign.__init__ as nested
debug_check True Stitcher-agnostic verification of the result
seed None Seeds the np.random.default_rng handed to the stitcher
**stitcher_kwargs Forwarded verbatim; takes precedence over the defaults above

make_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 from switchboard_find_edge_coloring("auto", ...) — an optimal closed-form coloring for canonical topologies (line, ring, grid, torus, as produced by ProcessorSpec(geometry=...)), a generic (deg + 1)-color algorithm otherwise — and delegates.

The default stitcher

assign_the_designs_with_mapping holds essentially all of the feature's logic. It returns circuit_lists[L] for germ-power index L, 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 includes 0..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 padding batch_tensor applies 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 via map_state_space_labels through make_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 — so MultiplePatchesSameShapeTester targets it directly.

Two other things to know. Idles are always explicit: build_layer_mappers maps the empty layer label Label(()) onto an explicit Gi/Gii idle and rewrites 1Q labels inside the 2Q design into parallel layers, which matters because the tensored circuits are later split back into lanes. And seed reproducibility is tied to the shape-group iteration order, so renaming or reordering patches changes the generated circuits. Passing nested=True (or ensure_containment=True as a stitcher kwarg) makes germ power L+1 contain germ power L exactly, patch-wise; EnsureContainmentTester covers the single-patch, same-shape, and three-germ-power transitivity cases.

Writing an alternative stitcher

circuit_stitcher(oneq_gstdesign, twoq_gstdesign, vertices, color_patches,
                 randgen=..., ensure_containment=..., **stitcher_kwargs)
    -> List[List[Circuit]]

CrosstalkFreeExperimentDesign supplies randgen and ensure_containment and merges **stitcher_kwargs over them, so an alternative can take its own options without a signature change anywhere; the built-in accepts and ignores **kwargs to stay compatible, and sets aux_info = {} unconditionally since it produces none. _layer_mappers_override is a private testing hook.

Validation is external by design. assert_circuit_lists_match_color_patches, run from __init__ when debug_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 own debug_check parameter 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.serialize preserves CircuitLabel layers. With expand_subcircuits=False, a CircuitLabel layer is appended as-is rather than expanded into its components. This matches the existing docstring ("If False, the circuit may contain CircuitLabel layers") but is an observable change to a core public method.

EffectRepComputational.probability no longer densifies. It calls the new matrixtools.zvals_int64_probability instead of building a dense length-4**nfactors effect vector and taking a full dot product, so the work is O(2**n) rather than O(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_connectivity and compute_clifford_2Q_connectivity no longer crash on {idle}. gate_num_qubits('{idle}') equals the device size, so on a two-qubit spec the global idle clears the == 2 arity guard — but its availability is [None], so both methods reach qubit_labels.index(sslbls[0]) and raise TypeError: 'NoneType' object is not subscriptable. The Clifford method's except (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_symplecticreps can register a symplectic rep against a specific Label — one embedding — rather than against a gate name, so compute_clifford_2Q_connectivity has to validate each availability site individually. Trusting the name alone yields phantom edges that disagree with compute_clifford_ops_on_qubits and crash create_random_germ (pygsti/algorithms/randomcircuit.py:1736) with ValueError: low >= high when one is sampled. The guard itself is unchanged relative to develop — it was dropped and reinstated within this branch — so what lands here is test_compute_clifford_2Q_connectivity_only_counts_validated_sites and 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, otherwise operation_blks['gates'] falling back to operation_blks['layers'].

objectivefns is now imported eagerly (pygsti/__init__.py, pygsti/objectivefns/__init__.py), so pygsti.objectivefns.objectivefns.X resolves without a separate import — the intent stated in commit c4d3fb89e. Small import-time cost, no known circularity.

test/unit/objects/test_processorspec.py moved to test/unit/processors/, with about thirteen new tests for previously untested QubitProcessorSpec methods (resolved_availability, subset, map_qudit_labels, the connectivity methods, and others). Hardcoded paths to the old location will break.

Reviewer attention

  1. build_layer_mappers is both unexercised and over-specialized. No test runs it: every test passes _layer_mappers_override=, and test/integration/test_xfgst.py injects its own near-duplicate _get_layer_mappers helper. It also asserts tgt in [0, 1] and hardcodes 'Gi' / 'Gii', assuming the 2Q design's qubit labels are literally 0 and 1 and that both idle names exist in the model. Neither is checked against the processor spec, and make_xfgst_design gives no way to override them.
  2. The {idle} branch sets avail = [qubit_labels] and then indexes sslbls[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 the gate_num_qubits(gn) == 2 guard 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.
  3. LocalNoiseModel.__getitem__ has no in-tree caller. Has a loose hueristic'rho' in key matches any label containing "rho" anywhere, the annotation admits Label as well as str (where in means something else), and no key produces a clean KeyError.
  4. Minor: Circuit.serialize now ends with tmp = Circuit._fastinit(...); return tmp — a leftover temporary.
  5. test/integration/test_xfgst.py tests GLND HSCA models, 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 rewritten zvals_int64_to_dense, the bit-manipulation helpers _zvals_int64_indices_and_signs / _spread_bits / _reverse_bits_scalar, and a module-level cache. Separately, minweight_match now 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 | Y on the default_gauge_group setter, an unused import dropped.
  • Sandia license headers added to pygsti/circuits/split_circuits_into_lanes.py, test/unit/circuits/test_circuit_splitting.py, and test/unit/tools/test_graphcoloring.py.
  • test/unit/objects/test_localnoisemodel.py reformatted, bare assertself.assertX.
  • .github/workflows/reuseable-main.yml: drops the stale pygsti/tools/graphcoloring.py path 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, and EnsureContainmentTester (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 under GLND not CPTPLND, asserting a 2*deltaLogL bound for each. It is reduced-scale by construction and finishes in well under a minute.

test_circuit.py gains serialize/parallelize round-tripping alongside set_line_labels, set_occurrence, and QUIL string helper coverage; test_processorspec.py gains the connectivity tests described above; test_localnoisemodel.py gains test_getitem.

@rileyjmurray
rileyjmurray requested review from a team as code owners April 19, 2026 19:14
@rileyjmurray
rileyjmurray requested a review from sserita April 19, 2026 19:14
Comment thread pygsti/circuits/split_circuits_into_lanes.py Outdated
Comment thread pygsti/circuits/split_circuits_into_lanes.py Outdated
Comment thread pygsti/circuits/split_circuits_into_lanes.py
Comment thread pygsti/circuits/split_circuits_into_lanes.py Outdated
Comment thread pygsti/tools/graphcoloring.py Outdated
Comment thread pygsti/processors/processorspec.py
Comment thread pygsti/models/localnoisemodel.py Outdated

@rileyjmurray rileyjmurray left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pygsti/circuits/circuit.py Outdated
Comment on lines +1348 to +1362
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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pygsti/circuits/split_circuits_into_lanes.py
Comment thread pygsti/tools/matrixtools.py Outdated
Comment thread pygsti/tools/matrixtools.py Outdated
Comment on lines +2646 to +2654
# 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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pygsti/evotypes/densitymx_slow/effectreps.py
sserita pushed a commit that referenced this pull request Jul 23, 2026
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.
sserita pushed a commit that referenced this pull request Jul 24, 2026
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.
@rileyjmurray rileyjmurray changed the title WIP: core infrastructure for crosstalk-free GST Core infrastructure for crosstalk-free GST experiment designs Jul 29, 2026
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.

2 participants