From 8101e6dfe31b4d9192f75e110053dea3fbe28efa Mon Sep 17 00:00:00 2001 From: Martin Ahindura Date: Tue, 18 Aug 2026 13:56:36 +0200 Subject: [PATCH 01/38] docs(rfcs): stop scoping design facts to one lab's chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten passages named "the August 2026 chip" and two scoped a general design fact to "this chip". The configs those references came from — quantify.device.yml, quantify.hardware.yml, calibration.yml — are all gitignored, so a reader of the repo cannot see the device being appealed to and cannot check the claim. The rationale is worth keeping: these RFCs exist because a real bring-up found the gaps, and 0007 and 0008 say so by design. What changes is the referent. A run is nameable and its evidence is in this changelog; a device nobody outside the lab can identify is not. So "the August 2026 chip" becomes "the August 2026 bring-up", and the four places where that shifts the grammar move to the past tense they should always have had — it describes what one run did, not standing behaviour. Two scoping fixes are not renames. 0005 §13's "what it is worth on this chip" is about the simulator, three lines above "this simulator does not model that", and now says so. 0007's decision table meant "on a given chip", which is the point it was making about load-bearing config. Every number stays, f01: 4735509751.238763 included: nine significant figures for a line that was never there is the whole argument of that passage. The 0.4.2 changelog line is a released entry, edited only because "this chip's qubit" has no antecedent anywhere in it. --- CHANGELOG.md | 2 +- .../rfcs/0005-calibration-graph-completion.md | 2 +- docs/rfcs/0007-calibration-without-priors.md | 20 +++++++++---------- docs/rfcs/0008-parameter-provenance.md | 4 ++-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd80423b..8f1f14c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -359,7 +359,7 @@ and this project follows versions of format `{year}.{month}.{patch_number}`. with `AttributeError: 'SimulatedBackend' object has no attribute 'last_allowance_s'`. - `qpi-driver/py`: a quantify tuner or executor resets the cluster when it opens one. Sequencer offsets, NCO frequencies and `sync_en` survive a reconnect, so the driver - inherited whatever the last process left emitting — which held this chip's qubit in a + inherited whatever the last process left emitting — which held a qubit in a mixture that made X the identity, and deadlocked `wait_sync` before that. - `qpi-driver/py`: a quantify tuner or executor stops the cluster after every run, including a failed one. Only `stop` clears `sync_en` on the modules a schedule did diff --git a/docs/rfcs/0005-calibration-graph-completion.md b/docs/rfcs/0005-calibration-graph-completion.md index fe0adacb..7a65db97 100644 --- a/docs/rfcs/0005-calibration-graph-completion.md +++ b/docs/rfcs/0005-calibration-graph-completion.md @@ -518,7 +518,7 @@ came after the machinery. §14 records where the result diverged from §6 and § mechanism every calibration routine already uses to sweep a readout, and the same one the EF subspace's `ro2`/`ro_3st_opt` will need. - **What it is worth on this chip, honestly.** The frequency optimum sits about + **What it is worth on the simulated chip, honestly.** The frequency optimum sits about 200 kHz off the resonance — a tenth of a linewidth — so the frequency axis contributes almost nothing here, and the node earns its place through the amplitude, where signal grows linearly with drive while punch-through only bends diff --git a/docs/rfcs/0007-calibration-without-priors.md b/docs/rfcs/0007-calibration-without-priors.md index d9f8a8fb..2e4abc6b 100644 --- a/docs/rfcs/0007-calibration-without-priors.md +++ b/docs/rfcs/0007-calibration-without-priors.md @@ -66,7 +66,7 @@ a search but is never required to make one possible. | Decision | Resolution | |---|---| | New operation or event type? | **No.** `calibrate` carries this. Python driver only. | -| What a `RoutineConfig` means | **Changed, and this is the core of the RFC.** Today a sweep parameter is often load-bearing: omit it and the node cannot work on this chip. After this, every sweep has a derived default that works on any chip the hardware can address; config may only *narrow* a search to save time. A node that cannot run without an operator-supplied range is a bug. | +| What a `RoutineConfig` means | **Changed, and this is the core of the RFC.** Today a sweep parameter is often load-bearing: omit it and the node cannot work on a given chip. After this, every sweep has a derived default that works on any chip the hardware can address; config may only *narrow* a search to save time. A node that cannot run without an operator-supplied range is a bug. | | Where a bound comes from | Hardware config for instrument limits, upstream measurements for physical ones, escalation for the rest. New `tuners/base/limits.py`; the hardware config is already reachable from a routine via `device.hardware_config()`, as `has_flux_port` shows. | | Guards as signals | **Changed.** The six "your window is wrong" guards added in August 2026 raise prose. They gain a structured form the caller can act on, so the same detection drives a retry instead of a failure. §6. | | Routine interface | **Unchanged.** `measure` already absorbs a routine whose setpoints depend on an earlier acquisition — `qubit_spectroscopy` is the second implementor. No third interface. | @@ -118,7 +118,7 @@ codebase and was applied once. `f12_spectroscopy` centres on `f01 + anharmonicity_prior` and **ignores the config's `f12` entirely** — a physical relationship beats an unmeasured field, and its docstring says so. That is exactly the pattern this RFC generalises. It is also why that node was -the only one that ever found the August 2026 chip's qubit: it was the only node +the only one that ever found the qubit in the August 2026 bring-up: it was the only node searching from physics rather than from a prior. `conditional_phase` sweeps 0–360°. A phase has no range to guess at, so it never had @@ -268,7 +268,7 @@ Two changes, both cheap: and `fit_spectroscopy_power` already fits every row; today it picks the best row and discards the rest. Requiring the chosen centre to agree with a second row to within a linewidth costs nothing, since the data is already acquired, and noise does not - reproduce across powers. On the August 2026 chip this would have refused run one rather + reproduce across powers. In the August 2026 bring-up this would have refused run one rather than run six: its three rows fitted 782.7 kHz, 8.5 kHz and 28 kHz, which no real line does. @@ -412,8 +412,8 @@ after the two classes that need no loop at all. register budget, not for a better coefficient. `drag` is deliberately untouched: §5 proposed centring it on the measured - anharmonicity, and nothing measured says the symmetric sweep is wrong. Its failure on - the August 2026 chip was contrast, not placement. + anharmonicity, and nothing measured says the symmetric sweep is wrong. Its failure in + the August 2026 bring-up was contrast, not placement. 5. **Escalation — done.** `OutOfRange` carries the axis and the direction; `escalating` follows it, bounded at three attempts, and leaves an operator who named the axis alone. Two directions turned out to be needed rather than one: a flat *decay* wants a longer @@ -431,7 +431,7 @@ pulled into scope — the accept side of the guards is §6.2. **A prior is still indistinguishable from a measurement.** After this RFC the driver finds the qubit wherever it is, but nothing says whether `clock_freqs.f01` was measured by this -driver or typed in from a design document. The August 2026 chip carried +driver or typed in from a design document. The August 2026 bring-up carried `f01: 4735509751.238763` — nine significant figures, and the line was never there. Three things here want that distinction: §2's definition of a prior, §11's "no @@ -461,7 +461,7 @@ not an index over one. ## 11. Skipping what cannot succeed A node whose prerequisite was never produced cannot measure anything, and running it -anyway is how one failure became six. The August 2026 chip is the worked example: +anyway is how one failure became six. The August 2026 bring-up is the worked example: `qubit_spectroscopy` failed, and `rabi`, `resonator_spectroscopy_excited`, `readout_discrimination`, `allxy`, `drag` and `readout_fidelity` all then measured a qubit still in `|0⟩` and reported confident numbers from its noise. Six failures with @@ -480,7 +480,7 @@ plainly wrong. Twelve of the thirty-three nodes write nothing at all, so nothing depend on their output, and some are still depended on in the walk order. **Disabled is not failed.** `qubit_spectroscopy` depends on `resonator_punchout`, which -is switched off on the August 2026 chip because its amplitude grid never reaches +was switched off in the August 2026 bring-up because its amplitude grid never reaches punch-through, which phase 3 fixes (§12). `time_of_flight` is off too, and under naive propagation disabling either would skip the entire graph beneath it — which is to say, everything. That both are off *because* of range bugs this RFC fixes does not help: the @@ -542,8 +542,8 @@ and `drag` can legitimately run — as can `allxy`, `fine_amplitude`, `rb` and produced by design" from "producer switched off". Two read paths — `measure.integration_time` and `r12.ef_duration` — have no producer anywhere in the graph and are supplied by hand on every chip, so a sole-producer rule fires on them - every run; and the August 2026 chip disables `time_of_flight` while its - `measure.acq_delay` is a perfectly good hand-set 200 ns. Nothing is lost by waiting: + every run; and the August 2026 bring-up disabled `time_of_flight` while its + `measure.acq_delay` was a perfectly good hand-set 200 ns. Nothing is lost by waiting: the parameter view below already declines to block on either case. **Reinstated by RFC 0008 as a report, not an error.** Provenance splits the three cases the rule could not: a prior a routine in this run will measure, a prior whose producer is diff --git a/docs/rfcs/0008-parameter-provenance.md b/docs/rfcs/0008-parameter-provenance.md index 8664e9cd..80b02e32 100644 --- a/docs/rfcs/0008-parameter-provenance.md +++ b/docs/rfcs/0008-parameter-provenance.md @@ -16,7 +16,7 @@ driver measured, and a value somebody typed in. That is all "provenance" means here; §2 says it at more length. -The August 2026 chip carried `clock_freqs.f01: 4735509751.238763`. Nine significant +The August 2026 bring-up carried `clock_freqs.f01: 4735509751.238763`. Nine significant figures, so it reads as a measurement, and the qubit was 302 MHz away; the line had never been there. Six calibration runs were spent on the consequences. Precision is not provenance, and a file that cannot say which it is holding forces every reader, human or @@ -142,7 +142,7 @@ Not *how old* the measurement is — see §8. **Corrected while implementing phase 4: this must not relax the blocking rule.** The tempting reading is that a node blocked because its input failed *this* run should run anyway when an earlier run measured that input — the device does hold a real number. It is -wrong, and the August 2026 chip is the counterexample: a failure to *measure* f01 is +wrong, and the August 2026 bring-up is the counterexample: a failure to *measure* f01 is evidence against whatever f01 the file holds, because the usual reason spectroscopy finds no line is that the qubit is not where the file says. Running the six nodes behind it against last week's value fits the same noise, whatever the value's pedigree. So a failed From d7078666fc429ab2f2aa992afed98cd00e6461f9 Mon Sep 17 00:00:00 2001 From: Martin Ahindura Date: Tue, 18 Aug 2026 13:56:47 +0200 Subject: [PATCH 02/38] docs: add the three RFCs missing from the nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nav stopped at 0005, so 0006, 0007 and 0008 have been unreachable from the documentation site since each was written — present in docs/rfcs and in the RFC index, absent from the sidebar. mkdocs builds without complaint because an unlisted page is legal, which is why this went unnoticed. --- mkdocs.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index 3f2d02de..e02a2980 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -60,6 +60,9 @@ nav: - 0003 Driver Extensibility: rfcs/0003-driver-extensibility.md - 0004 Calibration Tuners: rfcs/0004-calibration-tuners.md - 0005 Calibration Graph Completion: rfcs/0005-calibration-graph-completion.md + - 0006 The Calibration Graph in the Dashboard: rfcs/0006-calibration-graph-in-the-dashboard.md + - 0007 Calibration Without Priors: rfcs/0007-calibration-without-priors.md + - 0008 Parameter Provenance: rfcs/0008-parameter-provenance.md - Changelog: changelog.md markdown_extensions: From 2107b0ed6ffbd9a20f3b0e4e90325186f418614a Mon Sep 17 00:00:00 2001 From: Martin Ahindura Date: Tue, 18 Aug 2026 13:57:20 +0200 Subject: [PATCH 03/38] =?UTF-8?q?docs(rfcs):=20add=20RFC=200009=20?= =?UTF-8?q?=E2=80=94=20parallel=20calibration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walk is `for routine, for target`, so a full calibration costs routines x targets and is linear in qubit count. Grouped it costs routines x groups, and the group count is a property of the connectivity graph rather than of its size: every topology in the RFC's table needs two groups for its single-qubit routines whether it carries five qubits or five hundred. That is the argument, and it is why the RFC quotes N/Gq rather than a speedup figure. Three decisions carry most of the design. Parallelism is fusion into one schedule, never concurrent submission. Investigating the alternative changed this argument rather than confirming it. Two independently compiled schedules do NOT collide on sequencer indices — those come from the hardware config's port-clock ordering and are stable per port-clock — so an earlier draft's reason was wrong, and §2 says so. What actually rules it out is that ClusterComponent.start() opens by disarming every sequencer in the cluster so its no-argument start_sequencer() fires only what it just armed; starting one group therefore disarms another mid-flight, and wait_done and retrieve_acquisition are cluster-wide the same way. Narrowing stop() to the modules in the program is possible and is noted as an independent latency aside, but it cannot help: the groups worth running at once share a feedline and so one module, and concurrent is not simultaneous, which is what a crosstalk-sensitive measurement needs. Demultiplexing happens in the DAG, so analyse never learns it ran in company. signal_of reads data_vars[0], so a fused dataset would feed one target's trace to every fit; slicing per channel in the walk leaves thirty-three analyse implementations and every fit untouched. The acquisition channel is the target's position in its group, passed explicitly — the pattern executors/quantify/conv.py already uses — because acq_channel is 0 on every element by default and fusion must not mutate the device to work around that. Grouping needs the coupling graph, not chip geometry, so no layout file is introduced here. Physical distance is the wrong metric: two qubits close together with no coupler between them are farther apart for this purpose than two adjacent ones. The layout work is left to its own RFC, and the one thing recorded is that nothing here depends on it. Phase 1 fixes a defect in 0006 found while writing this. The calibration graph has a `running` style and a legend entry that no walk has ever reached: progress events fire after a target finishes, so a single-target node's first event already has Done >= Total and settles straight to `done`. The repo's own reducer tests encode this without naming it — the test for `running` needs a three-target node to reach the state at all — and a bring-up calibrating one qubit at a time gives every node one target. A node whose every target is blocked never reports at all and stays `pending` for the rest of the run. Grouping is a colouring of a conflict graph whose radius is a parameter set by measurement, following Kelly on the DAG, Gambetta on simultaneous randomized benchmarking as the acceptance test, Murali on characterise-then- serialise, ISCA 2025 on parallel graph traversal, and Heinsoo on what makes a shared feedline workable. The simulator can validate the crosstalk detector for a pair, since MAX_ENTANGLED is 3, and can never license a radius, because the coefficient would be one this project chose. Quantities are stated per topology throughout. Nothing is derived from a particular device: the configs that would supply such numbers are gitignored, and the RFC names the field a value is computed from instead. --- CHANGELOG.md | 12 + docs/rfcs/0009-parallel-calibration.md | 698 +++++++++++++++++++++++++ docs/rfcs/README.md | 5 + mkdocs.yml | 1 + 4 files changed, 716 insertions(+) create mode 100644 docs/rfcs/0009-parallel-calibration.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f1f14c5..e233d06a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project follows versions of format `{year}.{month}.{patch_number}`. +## [Unreleased] + +### Added + +- `docs`: RFC 0009 — Parallel Calibration. Designs grouping a routine's targets into sets + that can be measured in one schedule, from a colouring of the coupling graph rather than + a hand-written list, and records why concurrent submission to one cluster cannot work — + the scheduler's `start` disarms every sequencer in the cluster before arming its own. + Names the defect it starts by fixing: the calibration graph has a `running` style and a + legend entry no walk has ever reached, so a single-target node draws as `pending` and + then `done`, never as running. + ## [0.4.2] - 2026-08-16 ### Added diff --git a/docs/rfcs/0009-parallel-calibration.md b/docs/rfcs/0009-parallel-calibration.md new file mode 100644 index 00000000..b757b623 --- /dev/null +++ b/docs/rfcs/0009-parallel-calibration.md @@ -0,0 +1,698 @@ +# RFC 0009 — Parallel Calibration + +- **Status:** Draft +- **Author:** Martin Ahindura +- **Created:** 2026-08-18 +- **Depends on:** RFC 0004 (the walk, the progress event, the report), RFC 0005 (the + thirty-three-node graph), RFC 0006 (the plan on the wire and the drawing), + RFC 0007 (escalation), RFC 0008 (provenance) +- **Touches:** `qpi-driver` (the DAG, the routine base, two new modules), `qpi-ui` (the + progress accumulator, the graph drawing), `calibration.yml`. No new event type, no new + collection, and no chip-geometry file — see D3. + +Quantities here are per *topology*, never per device. A statement like "two groups" +holds for a class of connectivity graph and for any size of it; where a number depends +on the chip, this RFC says which config field it is computed from rather than quoting a +value. Nothing in it is derived from a particular lab's `quantify.*.yml`, all of which +are untracked local state. + +## 1. The idea + +The walk is two nested loops: for each routine, for each target. Every acquisition waits +for the one before it, and nothing about the chip requires that. A Rabi on two qubits far +enough apart drives different ports, reads different resonator frequencies and shares no +state; run sequentially they cost twice one of them, and run together they cost one. + +The prize is not a constant factor. Sequentially, a full walk costs +`routines × targets`, so it is **linear in qubit count**; grouped, it costs +`routines × groups`, and the group count is a property of the connectivity graph and +**not of its size** (§5.5). A chain needs two groups for its single-qubit routines +whether it has five qubits or fifty. So a walk that takes hours on a small chip takes +days on a large one, and grouped it takes hours on both. + +This RFC decides which targets may run together, how one schedule carries several of +them, and how the dashboard shows a set of components in flight rather than a point in a +list. It also fixes a defect found while writing it: **the calibration graph has a +`running` style and a legend entry that no walk has ever reached** (§7.1). + +## 2. What exists today + +Read, not assumed. + +**The walk is strictly sequential.** `CalibrationDAG.run` iterates `order`, and inside it +`for target in targets`, calling `_run_one` per pair. There is one `SchedulerBackend` and +one `InstrumentCoordinator` behind it. + +**A cluster is one arm-and-start resource, and the constraint is on `start`.** +`QuantifyBackend.run` does `prepare` → `start` → `wait_done` → `retrieve_acquisition`, +with `stop()` in a `finally`. Reading the scheduler's own cluster component: `start()` +*opens* with a cluster-wide `instrument.stop_sequencer()` — its comment says this is to +disarm everything so the no-argument `start_sequencer()` that follows starts only what it +just armed — and `stop()` calls `disable_sync()` on every module before another +cluster-wide `stop_sequencer()`. `wait_done` and `retrieve_acquisition` are cluster-wide +in the same way. D1 is about what that rules out. + +**Sequencer indices are stable, not first-come.** A module's sequencer index comes from +the port-clock ordering in the hardware config (`_construct_all_sequencer_compilers` +walks `_extract_sequencer_compilation_configs()` and keeps the entries with data), so a +given port-clock compiles to the same sequencer in every schedule. Two independently +compiled schedules over disjoint targets would *not* collide on sequencer indices — a +claim an earlier draft of this RFC got wrong, and the reason D1 rests on `start` instead. + +**Every fit reads acquisition channel zero.** `signal_of` takes +`dataset[list(dataset.data_vars)[0]]`. A dataset carrying several channels is read as +whichever one came first. + +**Nothing in the tuner path sets `acq_channel`.** Routines call +`backend.Measure(target, acq_index=index, bin_mode=...)`, so the channel comes from the +element — and `acq_channel` on a transmon element is a parameter with +`initial_value=0`, so *every* element answers to channel 0 unless something says +otherwise. The executor path already solved this: `executors/quantify/conv.py:240` passes +`acq_channel=idx`, commented "Use unique acq_channel per qubit to avoid overlaps". The +fix is a pattern the repo already uses. + +**The scheduler's gates are already multi-qubit.** `Reset(*qubits)` and +`Measure(*qubits, acq_channel=..., acq_index=...)` both take a variadic target list, and +`schedule.add` takes `ref_op`/`ref_pt`/`ref_pt_new`. Simultaneity inside one schedule is +expressible in the API as it stands. + +**The wiring already says which targets contend.** The hardware config's +`connectivity.graph` maps each `:` to an instrument output. Two targets +whose ports resolve to *different* outputs contend for nothing; two that resolve to the +same output share its LO, its DAC and its sequencer pool. Readout is the usual case — +a shared feedline puts many resonators behind one output — and it is the only shared +resource this RFC has to reason about, because drive and flux ports are per target on +every wiring the repo supports. + +**The instruction budget is per sequencer, not per schedule.** `MAX_SWEEP_POINTS = 700` +exists because a QRM_RF rejected a 12700-instruction program against a 12288 ceiling. +That ceiling is a sequencer's, and a fused schedule gives each target its own sequencer +running its own copy of the sweep. **Fusion costs sequencers, not instructions**, so none +of the sweep-size guards move. + +**Routines carry per-target state.** `Rabi.build_schedule` writes `self._amplitudes` and +`self._amplitudes_ceiling`, the latter from `full_scale(device.get_element(target), ...)`. +`escalating` widens a `RoutineConfig` that belongs to the routine, not to a target. + +**Eleven routines override `measure`.** `rabi`, `ramsey`, `t1`, `t2_echo`, `drag`, +`fine_amplitude`, `fine_amplitude_90`, `resonator_spectroscopy`, `rb`, +`fine_amplitude_12`, `drag_12` — nine of them only to call `escalating`. This is where +most of a walk's time goes, and §6.5 is about it. + +**The device config has a coupling graph and no geometry.** A device config names +elements and edges (an edge carrying `parent_element_name`/`child_element_name`); the +hardware config names ports and outputs. Neither has coordinates, and quantify's +`QuantumDevice` has no field for them. §8. + +**The progress event fires after a target finishes.** `_report_progress` is called once +before the walk with the plan, then inside the target loop *after* `_run_one` returns. +Nothing announces a target starting. + +**The simulator supports several acquisition channels, caps entanglement at three, and +models no crosstalk.** `_to_dataset` groups acquisitions by channel and keys the data +variables by the integer channel. `MAX_ENTANGLED = 3`, because each qubit in a joint +register multiplies the Liouvillian's side by `levels²` and "four is not" survivable. +Crosstalk is listed under "What is not" modelled. D10 and §10.3. + +## 3. The gap, in three parts + +They are independent, and only the second is about physics. + +1. **Nothing knows which targets may run together.** The coupling graph exists but + nothing reads it as a conflict relation, and `calibration.yml` has no way to say + "these three, at once". +2. **One schedule cannot carry several targets.** Every routine builds for one target, + and every fit reads channel zero. +3. **The dashboard tracks a point, not a set.** `progress` names one routine and one + target. There is no shape for "these three qubits are being measured right now" — + and, prior to that, no shape for "this node is running" that ever fires. + +## 4. Decisions + +**D1 — Parallelism is fusion into one schedule, never concurrent submission.** + +The tempting design is a worker per group, each with its own `backend.run`, and the +natural follow-up is to scope the teardown so the groups stop treading on each other. +Scoping is *possible* — `ClusterModuleComponent` has its own `stop()` and +`disable_sync()`, so a cluster-wide teardown could be narrowed to the modules a schedule +actually used. It does not unlock concurrency, for three reasons, and they are worth +separating because only the first is about the API: + +1. **The blocker is on `start`, not `stop`.** `ClusterComponent.start()` begins by + disarming *every* sequencer in the cluster, deliberately, so that its no-argument + `start_sequencer()` fires only what it has just armed. Starting group B therefore + disarms group A mid-flight. A per-module `stop` leaves this untouched, and + `wait_done`/`retrieve_acquisition` are cluster-wide the same way, so B's retrieve + would read A's acquisition memory alongside its own. +2. **The contended resource is usually one module.** The groups worth running at once + are groups of qubits, and their readout typically shares a feedline and therefore one + output on one module. Per-module scoping is precisely useless for the case that + motivates it: the two groups would queue for the same module either way. +3. **Concurrent is not simultaneous, and calibration wants simultaneous.** Two + submissions have no shared time origin. What a grouped measurement must answer is + "how does this qubit behave *while* its neighbours are driven", and that requires the + drives to land at a known offset — which is what one schedule on one sync network + gives and two schedules never can, however carefully they are launched. + +So there is nothing to align between schedules. **The alignment is the schedule** — +`ref_op`/`ref_pt` inside one of them, compiled once, armed once, started once, retrieved +once. Fusion delivers everything per-module scoping was reaching for, and delivers the +simultaneity as well. + +*Two per-module observations are worth keeping even so.* `start()`'s opening disarm is +already a cluster-wide reset before every run, so the current code is consistent on that +point and needs no change. And narrowing `stop()` to the modules in the program would +still be a small, independent latency win on a large cluster — logged here as an aside, +out of scope, and not a prerequisite for anything below. + +**D2 — A group is a colouring of a conflict graph, computed from the coupling graph.** + +Not a hand-written list, because a hand-written list is wrong the first time a coupler is +added and nothing checks it. Not a solver, because greedy colouring reaches the +known-optimal answer on the topologies anyone builds: two groups for single-qubit +routines on any bipartite lattice, and Δ or Δ+1 for couplers by Vizing — which is what +Sycamore's four coupler patterns are. Explicit groups remain available as an override +(§5.3), for a chip whose measured crosstalk does not follow its topology. + +**D3 — Grouping needs the coupling graph, not chip geometry. No layout file here.** + +The conflict relation is *graph distance* over the coupling graph, which the device +config's edges already give. Coordinates would add nothing and would mislead: two qubits +physically close with no coupler between them are farther apart, for this purpose, than +two adjacent ones. The layout work is genuinely orthogonal (§8). + +**D4 — Demultiplex in the DAG. `analyse` never learns it ran in company.** + +One fused acquisition returns a Dataset with one data variable per channel. The DAG +slices it — `dataset[[channel]]` — and hands each routine a single-variable Dataset, +exactly the shape `signal_of` already reads. Thirty-three `analyse` implementations, +every fit in `tuners/fitting/`, and every existing test stay untouched. The alternative, +teaching each fit which channel is its own, is thirty-three chances to read another +qubit's data and fit a plausible curve to it. + +**D5 — The acquisition channel is the target's position in its group, passed explicitly.** + +Not the element's `measure.acq_channel`, which defaults to 0 on every element and which +fusion would otherwise have to mutate mid-walk. One `Measure` per target with an explicit +`acq_channel`, all aligned to the same start. Nothing on the device is written, so no run +can leave a chip's config carrying a channel assignment made for a group it was in an +hour ago. + +**D6 — Fusion is opt-in per routine, behind a behaviour-preserving default.** + +`build_group_schedule` joins `acquire`, `measure`, `applies_to` and `uncorrected` as a +base-class hook whose default reproduces today's behaviour: a group of one delegates to +`build_schedule`, a larger group is refused. A routine becomes fusable by implementing +it, one at a time, each with its own test. Nothing regresses on the day this lands, +because nothing has opted in. + +**D7 — A group shares one sweep grid, or it is not a group.** + +`Rabi`'s ceiling is `full_scale(element)` per target, so two targets can want different +amplitude grids, and fusing them would sweep one over the other's range. The group +builder compares the setpoints each target resolved and splits out any target that +disagrees, running it alone. Refusing the whole group instead would let one unusual +element disable parallelism for a chip. + +**D8 — Failure and escalation are per target, and re-fusion is of the refused subset.** + +A group of five with one refused fit is four results and one `RoutineError`, exactly as +five sequential runs would be. `escalating` then widens for the refused target only and +re-fuses *those*, because widening the whole group would re-sweep satisfied targets over +a range chosen for a different one — and `Rabi` documents why that is not free: sweeping +to full scale where half scale sufficed put `amp180` 6.9% out on the simulated chip. + +**D9 — Shared-output feasibility is computed and refused, not assumed.** + +Simultaneous tones behind one instrument output must fit one LO's addressable band and +one DAC's full scale, and must not need more sequencers than the module has. All three +are arithmetic on the two configs (§5.4). A group that does not fit is split, with the +numbers in the message — the failure mode otherwise is a clipped readout producing +confident wrong contrast on every qubit at once. + +**D10 — The simulator can validate the crosstalk *detector*; it cannot set the radius.** + +The simulator models no crosstalk today, and it can be made to for a **pair or a +triple**: `coupled.py` already holds two transmons in one register with an exchange term, +and a static ZZ coefficient is the same kind of addition. `MAX_ENTANGLED = 3` is the +ceiling, and it is a hard one — the Liouvillian's side grows as `levels^n`, and the +module says four qubits is not survivable — so a chip-scale group can never be simulated. + +That bounds what simulation is for, and the bound is more useful than it sounds. A pair +is enough to test that the acceptance measurement of §5.6 *works*: switch a ZZ term on +and `parallel_penalty` must become non-zero; switch it off and it must return to zero. +That validates the detector. It cannot validate a radius, because the coefficient would +be a number this project chose — `coupled.py` is already candid that several of its +constants were picked rather than measured — and a test asserting a spacing is safe would +be asserting our own constant. **The detector is testable in simulation; the radius is +settled on hardware.** §5.6. + +**D11 — The running node is fixed first, and separately.** + +A node in flight has been undrawable since RFC 0006 shipped (§7.1). It is a small driver +change plus a branch in the server's accumulator, it is worth having on its own, and a +grouped run is unwatchable without it. It goes first, in its own phase, and its tests do +not mention parallelism. + +## 5. Grouping + +### 5.1 What the literature settles + +"Which components may be calibrated at once" is "which gates may be applied at once", +which is well covered. + +**Grouping is a graph colouring.** Kelly et al. put calibration on a DAG and note +parallelism across qubits as its natural extension; the ISCA 2025 hardware-aware protocol +makes it explicit, using graph traversal to identify compatible calibration operations +and splitting oversized subgraphs, and reports 8–25× less calibration overhead than +sequential. Sycamore's two-qubit layers are four patterns of disjoint couplers tiling the +grid — an edge colouring of a degree-4 lattice at Vizing's Δ. + +**Adjacency is the conflict, and the guard band is empirical.** Simultaneous operation +degrades fidelity through residual ZZ coupling and drive leakage, and the degradation is +measured rather than derived: Gambetta et al.'s simultaneous randomized benchmarking +benchmarks each qubit alone and then together, and the difference in average gate +fidelity *is* the addressability. Murali et al. use SRB for pairwise crosstalk +characterisation, reduce it from all-pairs to a tractable set, then serialise the pairs +found to conflict — which is this RFC's structure exactly: characterise, group, and put +conflicting pairs in different groups. CAMEL partitions a frequency-tunable chip into +local windows for the same reason. + +**Simultaneous readout behind a shared feedline is standard and bounded.** Heinsoo et al. +read five qubits in one 1.2 GHz channel with individual Purcell filters and found +simultaneous readout errors within 1% of individual ones. That is the result which makes +a shared feedline workable rather than disqualifying, and its preconditions are what D9 +checks. + +So the rule implemented here is the one the literature converged on: **conflict is +proximity in the coupling graph; the radius is a parameter; the parameter is set by +measurement.** + +### 5.2 The rules + +The conflict graph over qubits has an edge between `u` and `v` when any of: + +- graph distance over the coupling graph is less than `qubit_spacing` — the *minimum* + distance two qubits in a group must be apart. The default 2 excludes adjacent pairs, + which leaves at least one idle qubit between every pair in a group; 1 imposes nothing + and measures the whole chip at once; 3 leaves two; +- the pair is listed in `parallel.exclude`; +- the wiring cannot carry both — their ports resolve to the same instrument output and + their clocks cannot coexist in it (§5.4). + +Over couplers, an edge when the graph distance between the two couplers' endpoint sets is +less than `edge_spacing` — default 1, which requires only that they share no qubit; 2 +additionally puts a qubit between them. + +Groups are the colour classes of a greedy colouring in a deterministic order (the +config's target order), so the same config always produces the same groups and a run is +reproducible. `max_group` caps a class, which is what keeps a wide chip inside its +sequencer count. + +### 5.3 The configuration + +```yaml +parallel: + enabled: true + qubit_spacing: 2 # minimum graph distance between two qubits in a group + edge_spacing: 1 # 1 = share no qubit; 2 = a qubit between two couplers + max_group: 8 + exclude: # never grouped, whatever the spacing allows + - [q1, q3] + groups: # explicit override; skips the colouring entirely + qubits: [[q0, q2, q4], [q1, q3]] +``` + +Absent, `parallel` means `enabled: false` and the walk is exactly today's. That is the +opposite of `routines`' default — where absent means enabled — and deliberately so: a +missing `routines` entry cannot make a run measure nothing, whereas a `parallel` block +defaulting to on would silently change how every existing chip calibrates. + +### 5.4 Shared-output feasibility + +For a group whose targets' ports resolve to the same instrument output, three checks, +each arithmetic on the hardware and device configs: + +- **One LO band.** Every clock in the group must lie within `backend.if_limit_hz` of the + group's band centre, since one output has one LO and each sequencer's NCO offsets from + it. Read the clocks from the device config, take the midpoint of their range, and + compare the worst offset against the limit. +- **One DAC.** The group's simultaneous pulse amplitudes sum below full scale, because + the tones are added before the converter. Read them from the device config. +- **Sequencer count.** One sequencer per port-clock pair, against the module's own count + from the hardware description. + +A group failing any of them is bisected and both halves retried. The message names the +measured figure and the ceiling, in the house style of `SchedulerBackend.allow` and for +the same reason: an operator can act on "worst offset X MHz against the Y MHz band" and +cannot act on "group too wide". + +### 5.5 What it costs and what it saves + +A sequential walk over `Rq` qubit routines and `Re` edge routines on `N` qubits and `E` +edges costs `Rq·N + Re·E` acquisitions. Grouped it costs `Rq·Gq + Re·Ge`, where `Gq` and +`Ge` are the colour-class counts. RFC 0005's graph gives `Rq = 27` and `Re = 6`. + +`Gq` and `Ge` depend on the connectivity graph and not on its size: + +| Topology | `Gq` at spacing 2 | `Gq` at spacing 3 | `Ge` at `edge_spacing: 1` | +| --- | --- | --- | --- | +| linear chain | 2 | 3 | 2 | +| square lattice (Δ=4) | 2 | 5 | 4 | +| heavy-hex (Δ≤3) | 2 | 4 | 3 | + +Every `Gq` at the default spacing is 2, because each of these graphs is bipartite; `Ge` +is Δ by Vizing. So the grouped cost is a constant — `27·2 + 6·Ge` — for a chain of five +qubits and a lattice of five hundred alike, while the sequential cost grows with both `N` +and `E`. **The speedup is therefore not a fixed number to quote but `N/Gq`,** which is +the whole reason to build this rather than buy a faster fridge. + +Setting `qubit_spacing: 1` collapses `Gq` to 1 and is what the SRB literature does +routinely; §5.6 is how a chip earns it. + +### 5.6 What licenses a tighter spacing + +`qubit_spacing: 2` ships as the default because no chip has evidence for anything +tighter until the measurement is made. That measurement is Gambetta et al.'s, and the +graph already contains both halves of it: `rb` measures a qubit's average gate fidelity +alone, and a fused `rb` over a group measures it in company. The difference is the +addressability, per qubit, in the units the drift check already thresholds on. + +So the acceptance test is a comparison of two runs the driver can already do, and it +belongs in the report rather than in a paper: `parallel_penalty` per target, beside the +fidelity it is derived from. An operator tightens `qubit_spacing` when that number is +small on their chip and loosens it when it is not. Nothing here decides for them, and +D10 says which half of this is testable without a fridge. + +## 6. Fusion + +### 6.1 The hook + +```python +def build_group_schedule( + self, targets: Sequence[str], device: Any, config: RoutineConfig, + backend: SchedulerBackend, +) -> Any: + """This experiment over every target at once, in one schedule. + + A fusable routine implements this and lets `build_schedule` delegate to it with a + single target; the default here goes the other way, so an unconverted routine keeps + working and declines a group. + """ + if len(targets) == 1: + return self.build_schedule(targets[0], device, config, backend) + raise RoutineError(f"{self.name} cannot run {len(targets)} targets in one schedule") +``` + +`fusable` mirrors `measures_itself`: whether the subclass overrode the hook. The DAG +always calls `build_group_schedule`, so there is one path, and a group of one exercises +it on every existing routine. + +A converted routine reads the way the sequential one did, with one addition — the +alignment: + +```python +def build_group_schedule(self, targets, device, config, backend): + self._amplitudes = ... + schedule = backend.new_schedule(self.name, repetitions=int(config.get("shots", 1024))) + for index, amplitude in enumerate(self._amplitudes): + schedule.add(backend.Reset(*targets)) + anchor = None + for target in targets: + pulse = backend.Rxy(theta=180, phi=0, qubit=target, amp180=amplitude) + anchor = anchor or schedule.add(pulse) + if schedule.schedulables[-1] is not anchor: + schedule.add(pulse, ref_op=anchor, ref_pt="start", ref_pt_new="start") + for channel, target in enumerate(targets): + schedule.add( + backend.Measure(target, acq_channel=channel, acq_index=index, ...), + ref_op=anchor, ref_pt="end", ref_pt_new="start", + ) + return schedule +``` + +`Reset(*targets)` is one operation over the whole group, so the resets coincide for free. +The drives and measurements need `ref_op`/`ref_pt` — without them `schedule.add` appends, +and a group's resets and pulses serialise into a schedule as long as the sequential run +it was meant to replace. **That is the whole of "aligning the schedules", and it lives +inside one.** + +### 6.2 Demultiplexing + +```python +def channel_of(dataset: Any, channel: int) -> Any: + """Acquisition channel *channel* alone, as a Dataset `signal_of` can read.""" +``` + +Data variables are keyed by the integer channel — the simulator's `_to_dataset` builds +`variables[channel]`, and a cluster agrees — so this is a selection, not a +reconstruction. A group's result is `[channel_of(dataset, i) for i in range(len(group))]` +and each element goes to `analyse` unchanged. + +A channel missing from the returned dataset is that target's failure and nobody else's: +recorded as a `RoutineError` against that target while the rest of the group succeeds +(D8). + +### 6.3 Where fusion lives + +``` +qpi-driver/py/qpi_driver/tuners/base/ + grouping.py # coupling graph -> conflict graph -> colour classes; feasibility + fusion.py # group schedule alignment; dataset demultiplex +``` + +Two modules because they answer unrelated questions and are tested against unrelated +things: `grouping.py` is graph theory over a config and needs no device, `fusion.py` is +scheduler mechanics and needs no topology. `dag.py` gains the group loop and nothing +else. + +### 6.4 Timeouts and the budget + +A fused acquisition's pulses are one target's, not the group's, because the sequencers +play concurrently — so `backend.allow` returns what it does today and the over-budget +refusal is unchanged. `start_accounting` moves from per-target to per-group, which is the +honest scope: the ceiling bounds one arm-and-wait cycle, and a group is one. + +### 6.5 The eleven that measure themselves + +`escalating` widens a `RoutineConfig` and retries, and it is where most of a walk's hours +go. Over a group it becomes: acquire once, analyse each target, keep the fits that +landed, and re-fuse the subset that raised `OutOfRange` under a config widened for them. +Bounded as it is now, by `MAX_ESCALATIONS` per subset — so the worst case is the current +worst case, and the common case where nothing escalates is one acquisition for the whole +group. + +Two routines stay sequential and should: `coupler_bias`, whose loop sets DC state between +acquisitions, and `qubit_spectroscopy`, whose next window depends on what the last one +found. Neither is a limitation to design around — a chip has one bias source, and a +search that branches per qubit is a different experiment per qubit. + +## 7. The dashboard + +### 7.1 The running node has never been drawn + +`CalibrationGraph` has a `running` style (`animate-pulse`, blue) and a legend entry for +it. Nothing reaches it. + +`advanceNodes` folds a progress event, and a progress event fires *after* a target +finishes. So for a node with one target the first event it produces already has +`Done >= Total`, and `settledState` sends it straight to `done` — `running` is +unreachable for that node, always. The repo's own tests encode this without naming it: +the reducer test asserts a single-target node ends `done` after one event, and the test +for `running` needs a three-target node to reach the state at all. **A bring-up +calibrating one qubit at a time gives every node one target, so on that run the state is +unreachable everywhere and the legend entry is dead.** Even with several targets it is +late: a node is `pending` — indistinguishable from "not its turn" — for the whole of its +first target, which for a spectroscopy or an RB is minutes. + +A second defect from the same cause: a target skipped for an unsatisfied prior (RFC 0007 +§11) `continue`s before `_report_progress`, so a node whose every target is blocked +produces no event at all and is drawn `pending` for the rest of the run. + +The logs are better than the graph but not complete. `run` logs +`[7/33] rabi running on ` before the target loop, so the journal does name the +current routine; per *target* there is only the `ok`/`FAILED` line afterwards. An +operator watching a long node can see the routine and cannot see which target. + +**The fix.** Emit a progress event *before* the work as well as after, carrying the +targets about to run: + +```python +{"step": position, "total": len(order), "routine": name, "running": [...targets]} +``` + +`advanceNodes` treats an event carrying `running` as a start — set `State = "running"`, +leave `Done` alone — and an event without it exactly as it does now. +`CalibrationNodeState` gains `running: string[]`, and a skipped target emits a completion +event so a blocked node settles instead of sitting at `pending`. A driver that predates +this sends no `running` key and behaves as it does today. + +### 7.2 The set in flight + +`running: string[]` on the node state is also the answer to tracking components, and it +needs no new event and no new collection. + +- The node in flight draws as `running`, the style that already exists. +- Its in-flight targets render as chips on the node and in `NodeCard` — target names + under a pulsing box is "these components are being calibrated right now", which is the + question asked. +- The group *is* the set, so nothing has to be joined client-side to recover it. + +`layoutGraph` and `statusOf` do not change shape: `statusOf` already returns +`reported?.state ?? "pending"`, and this makes `reported.state` arrive on time. + +The plan gains `groups` per routine — the colour classes the driver computed — so the +drawing can show a node's group count beside its target count, and an operator can see +where a run's parallelism went before it starts. + +## 8. Chip layout — out of scope, and its own RFC + +Quantify has no geometry: `QuantumDevice` has elements and edges, the hardware config has +ports and outputs, and there is no coordinate and no field to hold one. What exists is a +*coupling graph*, and per D3 that is all grouping needs. + +A layout is wanted for a different job — drawing the chip the way other vendors' consoles +do, in the QPU driver and registry — and it should be designed there, as **RFC 0010**, +not appended here. The only decision this RFC needs to record is that it is not a +dependency: nothing in §5–§7 waits for a layout, and grouping must never be rewritten to +want one, because physical distance is the wrong metric for the question grouping asks. + +## 9. Implementation plan + +Six phases. Each is separately valuable and separately revertable. + +**Phase 1 — the node in flight.** §7.1. A pre-work progress event in `dag.py`, a +completion event for a skipped target, the `running` branch in `advanceNodes`, the field +in `types.ts`. No grouping anywhere. Ships a graph that shows where a run has got to, +which every later phase is watched through. + +**Phase 2 — grouping, computed and published, nothing executed.** `grouping.py`, the +`parallel` block, the feasibility checks, `groups` on the plan. `enabled: false` by +default, so the walk is unchanged and the phase is provable by unit test and by `--plan` +output. Answers "which components can run together" before anything depends on it being +right. + +**Phase 3 — fusion on the plain path.** `build_group_schedule`, `channel_of`, the group +loop in `dag.py`, and conversion of the routines that do not override `measure`. The +value is not the speedup — the expensive nodes are all in phase 4 — it is that the +channels, the alignment and the demultiplexing are proven on cheap nodes first. + +**Phase 4 — the eleven that escalate.** §6.5. Group-aware `escalating` with per-subset +widening. This is where the bulk of the saving arrives. + +**Phase 5 — edges.** `edge_spacing`, and fusion for the six edge routines. Last because a +CZ occupies both its qubits and a flux line, so it has the most conflicts and the least +to gain, and because it is only meaningful once its qubits calibrate in parallel. + +**Phase 6 — the acceptance measurement.** §5.6. `parallel_penalty` in the report from +fused-versus-isolated `rb`, plus the pair-scale ZZ term in the simulator that D10 says +can test the detector. This is what turns `qubit_spacing` from a guess into a setting. + +## 10. Testing strategy + +### 10.1 The floor + +`grouping.py` and `fusion.py` are added to `PY_COV_INCLUDE` at `PY_COV_MIN = 96`. They +qualify where the routines do not: both are pure functions over configs and datasets with +no instrument behind them, so 96% is reachable honestly rather than by asserting that +mocks were called. + +Shared topology fixtures go in `tests/utils/chips.py` — chain, square lattice, heavy-hex +and star, each parameterised by size — beside the existing `circuits.py` and +`simulation.py`. + +### 10.2 What the tests must establish + +Not "the code runs". Each of these is a way the feature can be wrong: + +- **Grouping.** No two members of a group are within `qubit_spacing`; `exclude` beats the + spacing; groups partition the targets exactly once each; the colouring is deterministic + across runs; the class counts match §5.5's table for each topology at each spacing, and + do so at two different sizes of the same topology — which is what pins the claim that + the count does not depend on `N`. +- **Feasibility.** A group whose clocks straddle more than `if_limit_hz` is split and the + message carries both figures; one whose amplitudes sum past full scale is split; one + needing more sequencers than the module has is split. +- **Fusion.** A fused schedule's per-target operations share a start time — asserted on + the compiled schedule's timing, not on the calls made to build it. A group of one + compiles identically to today's schedule for that target. +- **Demultiplexing.** Each target's `analyse` receives its own channel. The test that + matters is the adversarial one: fuse two targets whose correct answers differ, and + assert each fit lands on its own. A demultiplexer wired to channel zero passes every + same-answer test ever written. +- **Per-target failure.** One refused fit in a group of five yields four results and one + error against the right target, and the walk continues. +- **Escalation.** A group where one target refuses re-fuses that target alone, and the + satisfied targets are not re-swept over the widened range. +- **The in-flight node.** A single-target node is reported `running` before it is + reported `done` — the regression test for §7.1, and it fails against the code as it + stands. A node whose every target is blocked settles rather than staying `pending`. +- **The crosstalk detector.** With the pair-scale ZZ term on, `parallel_penalty` is + non-zero; with it off, zero. Asserts the detector responds, and asserts nothing about + what spacing is safe (D10). +- **Backward compatibility.** No `parallel` block calibrates exactly as today, asserted + by comparing the walk's schedule sequence against the sequential one. + +### 10.3 What the simulator can and cannot show + +It can run a fused schedule end to end and produce per-qubit populations from per-qubit +amplitudes, so every claim above except the last is testable without hardware. With +D10's addition it can also show that the crosstalk detector detects crosstalk — for a +pair or a triple, since `MAX_ENTANGLED = 3`. It cannot show that a *group* is +crosstalk-free: a chip-scale joint register is beyond the ceiling, and the coefficient +would be one this project chose. §5.6 is the hardware counterpart, and this RFC does not +move to Implemented until it has been run. + +## 11. What this deliberately does not do + +- **No concurrent instrument access.** D1. One cluster, one arm-and-start cycle. The + per-module `stop()` narrowing D1 discusses is noted as an independent latency aside, + not adopted. +- **No parallelism across routines.** Two independent branches of the DAG could in + principle run at once, but they contend for the same cluster and would have to be fused + into one schedule anyway — at which point the grouping is over targets again, and the + ordering guarantees `diagnose` relies on get harder to keep. The win is over targets, + and that is where this stops. +- **No chip-scale crosstalk model.** D10 bounds simulation to validating the detector. + Learning a coupling matrix is a different RFC. +- **No frequency reallocation.** Klimov et al. optimise frequency trajectories to reduce + crosstalk; that changes what the chip *is*, and this RFC only decides what to measure + at the same time. +- **No chip geometry.** §8 — RFC 0010. +- **No change to the graph's shape, checks or provenance.** RFCs 0005, 0007 and 0008 hold + as written; a group is a different number of targets per acquisition and nothing else. + +## 12. References + +- J. Kelly, P. O'Malley, M. Neeley, H. Neven, J. M. Martinis, *Physical qubit calibration + on a directed acyclic graph*, [arXiv:1803.03226](https://arxiv.org/abs/1803.03226) + (2018). The graph and `diagnose` already follow it; §5.1 follows its remarks on + parallelism. +- J. M. Gambetta et al., *Characterization of addressability by simultaneous randomized + benchmarking*, Phys. Rev. Lett. **109**, 240504 (2012), + [arXiv:1204.6308](https://arxiv.org/abs/1204.6308). The acceptance measurement of §5.6. +- P. Murali, D. C. McKay, M. Martonosi, A. Javadi-Abhari, *Software Mitigation of + Crosstalk on Noisy Intermediate-Scale Quantum Computers*, ASPLOS 2020, + [arXiv:2001.02826](https://arxiv.org/abs/2001.02826). Tractable pairwise + characterisation, then serialisation of the conflicting pairs. +- Y. Zhu et al., *Hardware-aware Calibration Protocol for Quantum Computers*, ISCA 2025, + [doi:10.1145/3695053.3731036](https://doi.org/10.1145/3695053.3731036). Parallel + calibration by graph traversal over compatible operations; subgraph splitting; 8–25× + overhead reduction. +- J. Heinsoo et al., *Rapid high-fidelity multiplexed readout of superconducting qubits*, + Phys. Rev. Applied **10**, 034040 (2018), + [arXiv:1801.07904](https://arxiv.org/abs/1801.07904). Five qubits in one readout + channel, simultaneous within 1% of individual — the precondition set of §5.4. +- *Quantum Crosstalk Analysis for Simultaneous Gate Operations on Superconducting + Qubits*, PRX Quantum **3**, 020301 (2022), + [doi:10.1103/PRXQuantum.3.020301](https://doi.org/10.1103/PRXQuantum.3.020301). +- P. V. Klimov et al., *Optimizing quantum gates towards the scale of logical qubits*, + Nature Communications **15**, 2442 (2024), + [arXiv:2308.02321](https://arxiv.org/abs/2308.02321). Frequency allocation as crosstalk + mitigation — the alternative §11 declines. +- *CAMEL: Physically Inspired Crosstalk-Aware Mapping and gatE scheduLing for + Frequency-Tunable Quantum Chips*, + [arXiv:2311.18160](https://arxiv.org/abs/2311.18160). Local-window partitioning. +- F. Arute et al., *Quantum supremacy using a programmable superconducting processor*, + Nature **574**, 505 (2019). The four coupler patterns — an edge colouring of the grid + at Vizing's Δ. diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md index c1f6a776..90aec097 100644 --- a/docs/rfcs/README.md +++ b/docs/rfcs/README.md @@ -16,6 +16,7 @@ holds both the system design and its phased implementation plan, so a contributo | [0006](./0006-calibration-graph-in-the-dashboard.md) | The Calibration Graph in the Dashboard | Draft | | [0007](./0007-calibration-without-priors.md) | Calibration Without Priors | Implemented (§11.5 open) | | [0008](./0008-parameter-provenance.md) | Parameter Provenance | Implemented | +| [0009](./0009-parallel-calibration.md) | Parallel Calibration | Draft | RFCs 0004 and 0005 were written before the graph had run on a chip, and say so where it matters. RFCs 0007 and 0008 are the opposite case: they exist because of what running it @@ -23,6 +24,10 @@ on one found. 0008 was the piece 0007 deferred; both are now implemented, and ea where building it corrected what it had claimed. 0007 §11.1 was added after both were closed, because hardware found it — a gap the RFC's own mechanism was meant to cover. +0009 is the first to change *how* the graph is walked rather than what it contains, and it +opens by fixing a defect in 0006 that writing it surfaced: the dashboard has a `running` +style no walk has ever reached. + ## Conventions - Number sequentially: `000N-short-slug.md`. diff --git a/mkdocs.yml b/mkdocs.yml index e02a2980..a06bbf9b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -63,6 +63,7 @@ nav: - 0006 The Calibration Graph in the Dashboard: rfcs/0006-calibration-graph-in-the-dashboard.md - 0007 Calibration Without Priors: rfcs/0007-calibration-without-priors.md - 0008 Parameter Provenance: rfcs/0008-parameter-provenance.md + - 0009 Parallel Calibration: rfcs/0009-parallel-calibration.md - Changelog: changelog.md markdown_extensions: From 84877898dce9c2b0703402727d796cb64584c7d1 Mon Sep 17 00:00:00 2001 From: Martin Ahindura Date: Tue, 18 Aug 2026 16:05:19 +0200 Subject: [PATCH 04/38] fix(qpi-driver,qpi-ui): draw the node a calibration is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 0009 phase 1. The graph has had a `running` style and a legend entry since RFC 0006 that no walk ever reached. A progress event fired only after a target finished, so a node with one target had Done >= Total on its first event and settled straight to `done`. A bring-up calibrating one qubit at a time gives every node one target, so on that run the state was unreachable everywhere. With several targets it was merely late: the node stayed `pending`, indistinguishable from "not its turn", for the whole of its first target. The driver now reports before the work as well as after, carrying the targets it is about to measure, and the server treats an event with `running` as a start that advances no tally. Both events carry the same running totals, because the server reads whether a target failed from the difference against the last event's `failed` — a start reporting zeroes would make the next finish look like a failure. A target skipped for an unsatisfied prerequisite now reports too. It emitted nothing at all, so a node whose every target was blocked was drawn `pending` for the rest of the walk. Counting a skip as `done` would claim it measured something, so the event carries a cumulative `skipped` and the node settles to a new `blocked` state, or `partial` for a mixture. Failure outranks a skip: a node with one of each has something to investigate. The in-flight targets are drawn under the node and named in the progress line, which is what the dashboard needed to say *which* components are being calibrated rather than only that some are. A driver predating this sends no `running` key and reduces exactly as before. --- CHANGELOG.md | 8 ++ docs/rfcs/0009-parallel-calibration.md | 8 ++ qpi-driver/py/qpi_driver/tuners/base/dag.py | 47 +++++-- qpi-driver/py/tests/test_calibration_dag.py | 62 ++++++++- qpi-ui/internal/api/nng_driver.go | 123 ++++++++++++---- qpi-ui/internal/api/nng_driver_test.go | 131 ++++++++++++++++++ qpi-ui/internal/api/schema.go | 9 ++ .../elements/CalibrationGraph.tsx | 22 ++- .../CalibrationTab/elements/layout.test.ts | 31 ++++- .../tabs/CalibrationTab/elements/layout.ts | 3 + .../components/tabs/CalibrationTab/index.tsx | 13 +- qpi-ui/internal/dashboard/src/types.ts | 15 +- 12 files changed, 418 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e233d06a..f7709f97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project follows versions of format `{year}.{month}.{patch_number}`. ## [Unreleased] +### Fixed + +- `qpi-driver/py`, `qpi-ui`: the calibration graph draws the node a walk is on. A progress + event only ever fired after a target finished, so a single-target node went straight from + `pending` to `done` and the `running` style was unreachable. The driver now reports the + targets it is about to measure, and a target skipped for a missing prerequisite reports + too — a node whose every target was blocked stayed `pending` for the rest of the run. + ### Added - `docs`: RFC 0009 — Parallel Calibration. Designs grouping a routine's targets into sets diff --git a/docs/rfcs/0009-parallel-calibration.md b/docs/rfcs/0009-parallel-calibration.md index b757b623..6995dc1f 100644 --- a/docs/rfcs/0009-parallel-calibration.md +++ b/docs/rfcs/0009-parallel-calibration.md @@ -527,6 +527,14 @@ leave `Done` alone — and an event without it exactly as it does now. event so a blocked node settles instead of sitting at `pending`. A driver that predates this sends no `running` key and behaves as it does today. +**Refined while implementing phase 1: a skipped target needs a state of its own.** The +completion event settles the node, but counting a skip as `done` says a node measured +something when nothing ran. So the event also carries a cumulative `skipped`, and +`settledState` gains `blocked` — every target skipped — with `partial` for a mixture. +Failure outranks a skip, since a node with one of each has something to investigate. +`blocked` is a fifth walk-reported state and is distinct from the plan-derived `skipped`, +which still means "applies to nothing here". + ### 7.2 The set in flight `running: string[]` on the node state is also the answer to tracking components, and it diff --git a/qpi-driver/py/qpi_driver/tuners/base/dag.py b/qpi-driver/py/qpi_driver/tuners/base/dag.py index 4315fd4e..3c818415 100644 --- a/qpi-driver/py/qpi_driver/tuners/base/dag.py +++ b/qpi-driver/py/qpi_driver/tuners/base/dag.py @@ -24,9 +24,10 @@ log = logging.getLogger(__name__) -#: Called once per routine-and-target as the walk proceeds, with the keys the -#: ``CalibrationProgress`` event carries — and once before any of them with a -#: ``plan`` key instead, which is what tells the two apart (RFC 0006 §5.1). +#: Called as the walk proceeds, with the keys the ``CalibrationProgress`` event +#: carries. Three shapes, told apart by which key is present: ``plan`` once before +#: anything runs (RFC 0006 §5.1), ``running`` when targets are about to be measured, +#: and ``target`` when one has finished (RFC 0009 §7.1). #: Reporting is best-effort — see :meth:`CalibrationDAG.run` — so a sink may #: raise without ending a calibration. ProgressSink = Callable[[dict[str, Any]], None] @@ -449,6 +450,7 @@ def run( continue log.info("%s running on %s", label, ", ".join(targets)) + head = {"step": position, "total": len(order), "routine": routine_name} for target in targets: blocked = ledger.blockers(routine, target) if blocked: @@ -469,8 +471,20 @@ def run( report.notes.append(f"{routine_name}[{target}]: {left}") ledger.unsatisfied(routine, target, blame=ledger.blame(blocked)) skipped += 1 + # Without this the node reports nothing at all, and one whose every + # target is blocked stays `pending` for the rest of the run. + _report_progress( + on_progress, + {**head, **_tally(report, skipped, started), "target": target}, + ) continue + # Before the work, so the graph can colour the node it is on rather than + # the node it has just left. RFC 0009 §7.1. + _report_progress( + on_progress, + {**head, **_tally(report, skipped, started), "running": [target]}, + ) ran_any = True target_started = time.monotonic() # Before the run, not after: the ledger records what this routine @@ -502,15 +516,7 @@ def run( ) _report_progress( on_progress, - { - "step": position, - "total": len(order), - "routine": routine_name, - "target": target, - "succeeded": len(report.routine_results), - "failed": len(report.errors), - "elapsed_s": round(time.monotonic() - started, 1), - }, + {**head, **_tally(report, skipped, started), "target": target}, ) if not ran_any: @@ -696,6 +702,23 @@ def utc_timestamp() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" +def _tally( + report: CalibrationReport, skipped: int, started: float +) -> dict[str, Any]: + """The walk's running totals, which every progress event carries. + + On the start event too, not only the finish: the server reads whether a target + failed from the difference against the last event's ``failed``, so an event that + reported zero would make the next one look like a failure. + """ + return { + "succeeded": len(report.routine_results), + "failed": len(report.errors), + "skipped": skipped, + "elapsed_s": round(time.monotonic() - started, 1), + } + + def _report_progress(sink: ProgressSink | None, update: dict[str, Any]) -> None: """Hand *update* to *sink*, if there is one, without letting it stop the walk.""" if sink is None: diff --git a/qpi-driver/py/tests/test_calibration_dag.py b/qpi-driver/py/tests/test_calibration_dag.py index 072811c6..a48207d0 100644 --- a/qpi-driver/py/tests/test_calibration_dag.py +++ b/qpi-driver/py/tests/test_calibration_dag.py @@ -614,21 +614,54 @@ def test_every_target_reports_its_position_and_the_running_totals(self): # The plan is the first thing said, and the only one that is not a position. assert "plan" in updates.pop(0) - assert [(u["step"], u["routine"], u["target"]) for u in updates] == [ + finished = [u for u in updates if "target" in u] + assert [(u["step"], u["routine"], u["target"]) for u in finished] == [ (1, "a", "q0"), (1, "a", "q1"), (2, "b", "q0"), (2, "b", "q1"), ] - assert all(u["total"] == 2 for u in updates) + assert all(u["total"] == 2 for u in finished) # The counts are the report's own as it stands, so a watcher sees them climb. - assert [(u["succeeded"], u["failed"]) for u in updates] == [ + assert [(u["succeeded"], u["failed"]) for u in finished] == [ (1, 0), (2, 0), (2, 1), (2, 2), ] + def test_a_target_is_reported_before_it_runs_and_after(self): + """RFC 0009 §7.1 — a node reported only on finishing is never drawn running.""" + updates: list[dict] = [] + config = _config(target_qubits=["q0", "q1"]) + + CalibrationDAG([StubRoutine("a")], config).run( + device=None, + backend=FakeBackend(), + config=config, + on_progress=updates.append, + ) + + assert [ + (u.get("running"), u.get("target")) for u in updates if "plan" not in u + ] == [(["q0"], None), (None, "q0"), (["q1"], None), (None, "q1")] + + def test_a_start_carries_the_totals_the_finish_will_be_compared_against(self): + """A start reporting zeroes would make the next finish look like a failure.""" + updates: list[dict] = [] + routines = [FailingRoutine("a")] + config = _config(target_qubits=["q0", "q1"]) + + CalibrationDAG(routines, config).run( + device=None, + backend=FakeBackend(), + config=config, + on_progress=updates.append, + ) + + starts = [u for u in updates if "running" in u] + assert [u["failed"] for u in starts] == [0, 1] + def test_a_sink_that_raises_does_not_end_the_walk(self): """A calibration outlives whoever is watching it.""" @@ -972,6 +1005,29 @@ def test_a_reader_is_skipped_when_its_parameter_was_not_produced(self): for note in report.notes ), report.notes + def test_a_skipped_target_still_reports_so_its_node_settles(self): + """RFC 0009 §7.1 — a node reporting nothing at all is drawn `pending` forever.""" + updates: list[dict] = [] + config = _config() + routines = [ + FailingProducer("root", updates=("clock_freqs.f01",)), + Producer("reader", depends_on=("root",), reads=("clock_freqs.f01",)), + ] + + CalibrationDAG(routines, config).run( + device=None, + backend=FakeBackend(), + config=config, + on_progress=updates.append, + ) + + reader = [u for u in updates if u.get("routine") == "reader"] + # Reported, and counted as a skip rather than as a success or a failure. + assert [u.get("target") for u in reader if "target" in u] == ["q0"] + assert [u["skipped"] for u in reader if "target" in u] == [1] + # And never announced as running, because it never ran. + assert not any("running" in u for u in reader) + def test_a_failed_refiner_blocks_nothing(self): """Seven parameters have two writers. The second failing leaves the first's. diff --git a/qpi-ui/internal/api/nng_driver.go b/qpi-ui/internal/api/nng_driver.go index e02bd571..8543f145 100644 --- a/qpi-ui/internal/api/nng_driver.go +++ b/qpi-ui/internal/api/nng_driver.go @@ -698,15 +698,22 @@ func handleCalibrationProgress(ctx context.Context, app core.App, qpuID string, // CalibrationNodeState is one routine's state within a walk in flight, on the // request's `progress.nodes` map (RFC 0006 §5.3). // -// Done counts the targets that have finished, Failed how many of those failed, so -// the drawing reads `3/5` from Done and Total and colours from Failed. The states a -// walk produces are `running`, `done`, `partial` and `failed`; `pending`, `skipped` -// and `not_planned` are properties of the plan and are read from it directly. +// Done counts the targets that have finished, Failed how many of those failed and +// Skipped how many were never run for want of a prerequisite, so the drawing reads +// `3/5` from Done and Total and colours from the other two. Running names the targets +// being measured right now, which is what lets the drawing say *which* components are +// in flight rather than only that the node is (RFC 0009 §7.2). +// +// The states a walk produces are `running`, `done`, `partial`, `failed` and `blocked`; +// `pending`, `skipped` and `not_planned` are properties of the plan and are read from +// it directly. type CalibrationNodeState struct { - State string `json:"state"` - Done int `json:"done"` - Total int `json:"total"` - Failed int `json:"failed"` + State string `json:"state"` + Done int `json:"done"` + Total int `json:"total"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Running []string `json:"running,omitempty"` } // advanceNodes folds one progress event into the tallies a walk has accumulated. @@ -714,25 +721,38 @@ type CalibrationNodeState struct { // prior is the `progress` object already on the row — the previous event's payload, // node map included — and is nil before the first one. // -// Two things make this less obvious than a counter. A progress event fires *after* a -// target finishes, so the routine it names has just completed one and is still the -// running one until an event names a different routine; and the payload carries the -// walk's running totals rather than the outcome of the target it names, so whether -// that target failed is the difference from the last event's total. Exactly one of -// the two totals grows per target, which is what makes the difference readable. +// Two shapes arrive, told apart by whether Running is set. A start event names the +// targets about to be measured and advances no tally; a finish event names the one +// target that is done and advances exactly one of the totals. +// +// Two things make this less obvious than a counter. A finish event carries the walk's +// running totals rather than the outcome of the target it names, so whether that +// target failed is the difference from the last event's total — which is why a start +// event has to carry the same totals rather than zeroes. And a routine stays the +// running one until an event names a different routine, so the walk moving on is what +// settles whatever it was on before. func advanceNodes(prior map[string]any, event *CalibrationProgressPayload, plan *CalibrationPlan) map[string]CalibrationNodeState { nodes := priorNodes(prior) node := nodes[event.Routine] node.Total = plan.targetCount(event.Routine, node.Total) - node.Done++ - if float64(event.Failed) > numberOf(prior["failed"]) { - node.Failed++ - } - if node.Total > 0 && node.Done >= node.Total { - node.State = settledState(node) - } else { + if len(event.Running) > 0 { + node.Running = event.Running node.State = "running" + } else { + node.Done++ + if float64(event.Failed) > numberOf(prior["failed"]) { + node.Failed++ + } + if float64(event.Skipped) > numberOf(prior["skipped"]) { + node.Skipped++ + } + node.Running = without(node.Running, event.Target) + if node.Total > 0 && node.Done >= node.Total { + node.State = settledState(node) + } else { + node.State = "running" + } } nodes[event.Routine] = node @@ -742,6 +762,7 @@ func advanceNodes(prior map[string]any, event *CalibrationProgressPayload, plan if previous, ok := prior["routine"].(string); ok && previous != event.Routine { if done, seen := nodes[previous]; seen && done.State == "running" { done.State = settledState(done) + done.Running = nil nodes[previous] = done } } @@ -749,17 +770,40 @@ func advanceNodes(prior map[string]any, event *CalibrationProgressPayload, plan } // settledState is what a node that has finished its targets looks like. +// +// Failure outranks a skip: a node with one of each has something to investigate, and +// reporting it as merely blocked would bury that. `blocked` is every target skipped — +// nothing ran, so neither `done` nor `failed` is true of it (RFC 0007 §11). func settledState(node CalibrationNodeState) string { switch { - case node.Failed == 0: - return "done" - case node.Failed >= node.Done: + case node.Failed > 0 && node.Failed >= node.Done: return "failed" - default: + case node.Failed > 0: return "partial" + case node.Skipped > 0 && node.Skipped >= node.Done: + return "blocked" + case node.Skipped > 0: + return "partial" + default: + return "done" } } +// without is *targets* less *done*, for shrinking the in-flight set as a group's +// targets report back one at a time. +func without(targets []string, done string) []string { + kept := make([]string, 0, len(targets)) + for _, target := range targets { + if target != done { + kept = append(kept, target) + } + } + if len(kept) == 0 { + return nil + } + return kept +} + // priorNodes recovers the accumulated node map from the stored progress object, // which round-trips through JSON and so arrives as floats in maps. func priorNodes(prior map[string]any) map[string]CalibrationNodeState { @@ -775,15 +819,36 @@ func priorNodes(prior map[string]any) map[string]CalibrationNodeState { } state, _ := fields["state"].(string) nodes[name] = CalibrationNodeState{ - State: state, - Done: int(numberOf(fields["done"])), - Total: int(numberOf(fields["total"])), - Failed: int(numberOf(fields["failed"])), + State: state, + Done: int(numberOf(fields["done"])), + Total: int(numberOf(fields["total"])), + Failed: int(numberOf(fields["failed"])), + Skipped: int(numberOf(fields["skipped"])), + Running: stringsOf(fields["running"]), } } return nodes } +// stringsOf reads a JSON string array back out of an `any`. Nil for anything that is +// not one, absent included. +func stringsOf(value any) []string { + items, ok := value.([]any) + if !ok { + return nil + } + strings := make([]string, 0, len(items)) + for _, item := range items { + if text, ok := item.(string); ok { + strings = append(strings, text) + } + } + if len(strings) == 0 { + return nil + } + return strings +} + // numberOf reads a JSON number back out of an `any`, whatever numeric shape the // decoder chose. Zero for anything that is not one, absent included. func numberOf(value any) float64 { diff --git a/qpi-ui/internal/api/nng_driver_test.go b/qpi-ui/internal/api/nng_driver_test.go index 112cd2ff..f427a65f 100644 --- a/qpi-ui/internal/api/nng_driver_test.go +++ b/qpi-ui/internal/api/nng_driver_test.go @@ -1143,3 +1143,134 @@ func TestToStringSlice_NarrowsWhateverTheQueueStored(t *testing.T) { }) } } + +// TestAdvanceNodes_MarksASingleTargetNodeRunningBeforeItFinishes is the regression +// test for RFC 0009 §7.1. A finish event alone puts a one-target node straight to +// `done`, so before the driver reported a start there was no walk in which the +// `running` style the graph draws could ever be reached. +func TestAdvanceNodes_MarksASingleTargetNodeRunningBeforeItFinishes(t *testing.T) { + start := CalibrationProgressPayload{ + Step: 1, Total: 2, Routine: "resonator_spectroscopy", Running: []string{"q0"}, + } + nodes := advanceNodes(nil, &start, aPlan()) + + node := nodes["resonator_spectroscopy"] + if node.State != "running" || node.Done != 0 { + t.Fatalf("after start = %+v, want running with nothing done", node) + } + if len(node.Running) != 1 || node.Running[0] != "q0" { + t.Errorf("running = %v, want [q0]", node.Running) + } + + finish := CalibrationProgressPayload{ + Step: 1, Total: 2, Routine: "resonator_spectroscopy", Target: "q0", Succeeded: 1, + } + nodes = advanceNodes(storedAfter(t, &start, nodes), &finish, aPlan()) + + if node := nodes["resonator_spectroscopy"]; node.State != "done" || node.Done != 1 { + t.Errorf("after finish = %+v, want done 1/1", node) + } + if node := nodes["resonator_spectroscopy"]; len(node.Running) != 0 { + t.Errorf("running = %v, want empty once the target is done", node.Running) + } +} + +// TestAdvanceNodes_ShrinksTheInFlightSetAsAGroupReportsBack covers a fused group, +// whose start names every target at once and whose finishes arrive one at a time +// (RFC 0009 §7.2). +func TestAdvanceNodes_ShrinksTheInFlightSetAsAGroupReportsBack(t *testing.T) { + start := CalibrationProgressPayload{ + Step: 2, Total: 2, Routine: "rabi", Running: []string{"q0", "q1", "q2"}, + } + nodes := advanceNodes(nil, &start, aPlan()) + if got := nodes["rabi"]; len(got.Running) != 3 || got.Total != 3 { + t.Fatalf("after start = %+v, want 3 in flight out of 3", got) + } + + prior := storedAfter(t, &start, nodes) + finish := CalibrationProgressPayload{ + Step: 2, Total: 2, Routine: "rabi", Target: "q1", Succeeded: 1, + } + nodes = advanceNodes(prior, &finish, aPlan()) + + got := nodes["rabi"] + if got.State != "running" || got.Done != 1 { + t.Fatalf("after one finish = %+v, want running 1/3", got) + } + if len(got.Running) != 2 || got.Running[0] != "q0" || got.Running[1] != "q2" { + t.Errorf("running = %v, want the two targets still in flight", got.Running) + } +} + +// TestAdvanceNodes_SettlesANodeWhoseTargetsWereAllSkipped proves a node that never +// ran stops at `blocked` rather than sitting at `pending` for the rest of the walk. +// Skipped is neither done nor failed (RFC 0007 §11), and before RFC 0009 a blocked +// target reported nothing at all. +func TestAdvanceNodes_SettlesANodeWhoseTargetsWereAllSkipped(t *testing.T) { + nodes := map[string]CalibrationNodeState{} + prior := map[string]any{} + for i, target := range []string{"q0", "q1", "q2"} { + event := CalibrationProgressPayload{ + Step: 2, Total: 2, Routine: "rabi", Target: target, Skipped: i + 1, + } + nodes = advanceNodes(prior, &event, aPlan()) + prior = storedAfter(t, &event, nodes) + } + + if got := nodes["rabi"]; got.State != "blocked" || got.Skipped != 3 { + t.Errorf("rabi = %+v, want blocked with 3 skipped", got) + } +} + +// TestAdvanceNodes_PrefersFailureToASkip: a node with one of each has something to +// investigate, and reporting it as merely blocked would bury that. +func TestAdvanceNodes_PrefersFailureToASkip(t *testing.T) { + nodes := map[string]CalibrationNodeState{} + prior := map[string]any{} + walk := []CalibrationProgressPayload{ + {Step: 2, Total: 2, Routine: "rabi", Target: "q0", Skipped: 1}, + {Step: 2, Total: 2, Routine: "rabi", Target: "q1", Skipped: 1, Failed: 1}, + {Step: 2, Total: 2, Routine: "rabi", Target: "q2", Skipped: 1, Failed: 1, Succeeded: 1}, + } + for i := range walk { + nodes = advanceNodes(prior, &walk[i], aPlan()) + prior = storedAfter(t, &walk[i], nodes) + } + + if got := nodes["rabi"]; got.State != "partial" || got.Failed != 1 || got.Skipped != 1 { + t.Errorf("rabi = %+v, want partial with one failure and one skip", got) + } +} + +// TestAdvanceNodes_IgnoresAStartFromAnOlderDriver: no `running` key means the payload +// came from a driver predating RFC 0009, which must reduce exactly as it used to. +func TestAdvanceNodes_IgnoresAStartFromAnOlderDriver(t *testing.T) { + event := CalibrationProgressPayload{ + Step: 2, Total: 2, Routine: "rabi", Target: "q0", Succeeded: 1, + } + nodes := advanceNodes(nil, &event, aPlan()) + + if got := nodes["rabi"]; got.State != "running" || got.Done != 1 || got.Total != 3 { + t.Errorf("rabi = %+v, want running 1/3 as before", got) + } +} + +// storedAfter is the row's `progress` field as the handler writes it, round-tripped +// through JSON — the reducer reads its own previous output back out of a json field, +// not out of memory. +func storedAfter( + t *testing.T, event *CalibrationProgressPayload, nodes map[string]CalibrationNodeState, +) map[string]any { + t.Helper() + stored := event.ToMap() + stored["nodes"] = nodes + encoded, err := json.Marshal(stored) + if err != nil { + t.Fatalf("marshal progress: %v", err) + } + prior := map[string]any{} + if err := json.Unmarshal(encoded, &prior); err != nil { + t.Fatalf("unmarshal progress: %v", err) + } + return prior +} diff --git a/qpi-ui/internal/api/schema.go b/qpi-ui/internal/api/schema.go index b34503b2..d17ae819 100644 --- a/qpi-ui/internal/api/schema.go +++ b/qpi-ui/internal/api/schema.go @@ -277,7 +277,14 @@ type CalibrationProgressPayload struct { Target string `json:"target"` Succeeded int `json:"succeeded"` Failed int `json:"failed"` + Skipped int `json:"skipped"` ElapsedS float64 `json:"elapsed_s"` + + // Targets this routine is about to measure. Present on the event the driver + // sends before the work and absent on the one after it, which is what tells a + // start from a finish (RFC 0009 §7.1). Empty from a driver predating it, so + // such a driver still reduces exactly as it used to. + Running []string `json:"running"` } func (cpp *CalibrationProgressPayload) SetDefaults() { @@ -294,7 +301,9 @@ func (cpp *CalibrationProgressPayload) ToMap() map[string]any { "target": cpp.Target, "succeeded": cpp.Succeeded, "failed": cpp.Failed, + "skipped": cpp.Skipped, "elapsed_s": cpp.ElapsedS, + "running": cpp.Running, } } diff --git a/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/elements/CalibrationGraph.tsx b/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/elements/CalibrationGraph.tsx index eadee506..655ff53e 100644 --- a/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/elements/CalibrationGraph.tsx +++ b/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/elements/CalibrationGraph.tsx @@ -43,6 +43,11 @@ const NODE_STYLES: Record< box: "fill-red-500/15 stroke-red-500", label: "fill-red-700 dark:fill-red-400", }, + blocked: { + box: "fill-transparent stroke-amber-500/60", + label: "fill-amber-700/80 dark:fill-amber-300/80", + dashed: true, + }, pending: { box: "fill-transparent stroke-gray-300 dark:stroke-zinc-700", label: "fill-gray-500 dark:fill-zinc-400", @@ -64,6 +69,7 @@ const LEGEND: { status: CalibrationNodeStatus; label: string }[] = [ { status: "running", label: "running" }, { status: "partial", label: "some targets failed" }, { status: "failed", label: "failed" }, + { status: "blocked", label: "prerequisite never measured" }, { status: "pending", label: "not yet run" }, { status: "skipped", label: "applies to nothing here" }, { status: "not_planned", label: "not in this run" }, @@ -144,7 +150,11 @@ export const CalibrationGraph: React.FC = ({ onClick={() => onSelect?.(placed.node.name)} className={onSelect ? "cursor-pointer" : undefined} > - {`${placed.node.name} — ${placed.status}`} + + {placed.running.length + ? `${placed.node.name} — ${placed.status} on ${placed.running.join(", ")}` + : `${placed.node.name} — ${placed.status}`} + = ({ {line} ))} + {/* Which components are being measured, not merely that some are. */} + {placed.running.length > 0 && ( + + {placed.running.join(" ")} + + )} {/* Only when there is more than one, or `1/1` on every node is noise. */} {placed.total > 1 && ( { done: 3, total: 5, failed: 1, + skipped: 0, }; expect(statusOf(node("rabi"), reported)).toBe("partial"); }); it("prefers the plan's own verdict over a stale report", () => { - const reported = { state: "done" as const, done: 1, total: 1, failed: 0 }; + const reported = { + state: "done" as const, + done: 1, + total: 1, + failed: 0, + skipped: 0, + }; expect(statusOf(node("rb", [], { planned: false }), reported)).toBe( "not_planned", ); @@ -134,7 +141,7 @@ describe("layoutGraph", () => { it("carries the reported tally through", () => { const plan = { nodes: [node("rabi", [], { targets: ["q0", "q1", "q2"] })] }; const layout = layoutGraph(plan, { - rabi: { state: "running", done: 2, total: 3, failed: 0 }, + rabi: { state: "running", done: 2, total: 3, failed: 0, skipped: 0 }, }); expect(layout.nodes[0]).toMatchObject({ status: "running", @@ -142,4 +149,24 @@ describe("layoutGraph", () => { total: 3, }); }); + + it("carries the in-flight targets through, so the drawing can name them", () => { + const plan = { nodes: [node("rabi", [], { targets: ["q0", "q1", "q2"] })] }; + const layout = layoutGraph(plan, { + rabi: { + state: "running", + done: 1, + total: 3, + failed: 0, + skipped: 0, + running: ["q1", "q2"], + }, + }); + expect(layout.nodes[0].running).toEqual(["q1", "q2"]); + }); + + it("leaves a node no walk has reported on with nothing in flight", () => { + const plan = { nodes: [node("rabi", [], { targets: ["q0"] })] }; + expect(layoutGraph(plan).nodes[0].running).toEqual([]); + }); }); diff --git a/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/elements/layout.ts b/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/elements/layout.ts index b44e0411..c2a4207b 100644 --- a/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/elements/layout.ts +++ b/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/elements/layout.ts @@ -14,6 +14,8 @@ export interface PlacedNode { status: CalibrationNodeStatus; done: number; total: number; + /** Targets in flight, for a node the walk is on. Empty for every other node. */ + running: string[]; } export interface GraphLayout { @@ -91,6 +93,7 @@ export function layoutGraph( status: statusOf(node, state), done: state?.done ?? 0, total: state?.total || node.targets.length, + running: state?.running ?? [], }; }); diff --git a/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/index.tsx b/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/index.tsx index 1195fccf..5e75c7c4 100644 --- a/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/index.tsx +++ b/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/index.tsx @@ -186,8 +186,8 @@ export const CalibrationTab: React.FC = ({ : ""} . A full run can take hours. - {/* Only once a routine has finished: before that there is no - position to show, and "0 of 33" would read as stuck. */} + {/* From the first routine's start, not its finish — the driver + reports the targets it is about to measure (RFC 0009 §7.1). */} {request.progress && (
@@ -197,12 +197,17 @@ export const CalibrationTab: React.FC = ({ {request.progress.routine} {" "} - on {request.progress.target} + on{" "} + {request.progress.running?.length + ? request.progress.running.join(", ") + : request.progress.target} {request.progress.succeeded} ok {request.progress.failed > 0 && - `, ${request.progress.failed} failed`}{" "} + `, ${request.progress.failed} failed`} + {request.progress.skipped > 0 && + `, ${request.progress.skipped} skipped`}{" "} · {formatDuration(request.progress.elapsed_s)}
diff --git a/qpi-ui/internal/dashboard/src/types.ts b/qpi-ui/internal/dashboard/src/types.ts index 53dc4d69..694f0385 100644 --- a/qpi-ui/internal/dashboard/src/types.ts +++ b/qpi-ui/internal/dashboard/src/types.ts @@ -316,15 +316,20 @@ export interface CalibrationPlan { /** What one routine looks like right now, accumulated across progress events * (RFC 0006 §5.3). `done` counts the targets that have finished, `failed` how many - * of those failed. */ + * of those failed, and `skipped` how many never ran for want of a prerequisite. */ export interface CalibrationNodeState { - state: "running" | "done" | "partial" | "failed"; + state: "running" | "done" | "partial" | "failed" | "blocked"; done: number; total: number; failed: number; + skipped: number; + /** The targets being measured right now, so the drawing can name the components in + * flight rather than only the routine (RFC 0009 §7.2). Absent on a walk whose driver + * predates it, and empty once the last target of a group reports back. */ + running?: string[]; } -/** How a node is drawn. The four a walk reports, plus the three that are properties +/** How a node is drawn. The five a walk reports, plus the three that are properties * of the plan rather than of anything that happened: a planned node nothing has * reported on, one that applies to no target here, and one this run excludes. */ export type CalibrationNodeStatus = @@ -373,7 +378,11 @@ export interface CalibrationProgress { target: string; succeeded: number; failed: number; + skipped: number; elapsed_s: number; + /** Targets this routine is about to measure. Present on the event sent before the + * work and absent on the one after it, which is what tells a start from a finish. */ + running?: string[]; /** Per routine, accumulated rather than replaced — this is what colours the graph. * Absent on a run whose driver or server predates it. */ nodes?: Record; From 6267b58f73bb589662b640cdf8baa3d7c1aef8ab Mon Sep 17 00:00:00 2001 From: Martin Ahindura Date: Tue, 18 Aug 2026 16:50:04 +0200 Subject: [PATCH 05/38] feat(qpi-driver): group the targets that can be calibrated at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 0009 phase 2. Computed and published only — the walk still runs one target at a time, so this is provable by unit test before anything depends on it being right. Conflict is proximity in the coupling graph, which the device's edges already give; chip geometry does not come into it, since two qubits close together with no coupler between them are farther apart for this purpose than two adjacent ones. `parallel` in calibration.yml sets the radius — qubit_spacing, edge_spacing, max_group, exclude — or names groups outright for a chip whose measured crosstalk does not follow its topology. Off unless the file says so, which is the opposite of `routines` and deliberate: defaulting it to on would silently change how every existing chip calibrates. Unifying a qubit with an edge as its endpoint set is what lets one distance rule serve both, so a CZ conflicts with whatever either of its qubits conflicts with. The search is breadth-first bounded by the spacing, because the question is never how far apart two targets are, only whether they are far enough. Two things measured rather than assumed. The default spacing gives two groups on a chain, a square lattice and heavy-hex alike, at every size tested — which is the claim the RFC rests on, that the group count follows the topology and not the qubit count. And greedy colouring is *not* optimal at spacing 3: the five-colour Lee tiling is the optimum for an infinite lattice and greedy reaches six or seven. That costs runtime, never correctness, and the default is where greedy provably cannot do worse. RFC §5.5 and D2 are corrected to say so; they had quoted the optimum as though it were the result. Writing the config validation found a real hole: `groups: []` was coalesced to `{}` by a falsy default and silently accepted rather than rejected. Both list settings are now type-checked before any coalescing. grouping.py joins the gated coverage floor at 96% and sits at 100%. It qualifies where the routines do not — pure functions over a config, no instrument behind them. The one thing that reads a device is outputs_of, which reads the connectivity as an iterable of pairs and returns nothing when it cannot, the convention has_flux_port already uses. --- CHANGELOG.md | 5 + Makefile | 8 +- docs/rfcs/0009-parallel-calibration.md | 31 +- .../py/qpi_driver/tuners/base/__init__.py | 2 + .../py/qpi_driver/tuners/base/config.py | 89 ++++ qpi-driver/py/qpi_driver/tuners/base/dag.py | 41 +- .../py/qpi_driver/tuners/base/grouping.py | 218 ++++++++++ qpi-driver/py/tests/test_calibration_dag.py | 2 + .../py/tests/test_calibration_grouping.py | 392 ++++++++++++++++++ qpi-driver/py/tests/utils/chips.py | 41 ++ .../tabs/CalibrationTab/elements/NodeCard.tsx | 20 + .../components/tabs/CalibrationTab/index.tsx | 1 + qpi-ui/internal/dashboard/src/types.ts | 4 + 13 files changed, 840 insertions(+), 14 deletions(-) create mode 100644 qpi-driver/py/qpi_driver/tuners/base/grouping.py create mode 100644 qpi-driver/py/tests/test_calibration_grouping.py create mode 100644 qpi-driver/py/tests/utils/chips.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f7709f97..9cd58a09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ and this project follows versions of format `{year}.{month}.{patch_number}`. ### Added +- `qpi-driver/py`: `parallel` in `calibration.yml` groups a routine's targets into sets + that can be measured at once, coloured from the coupling graph — `qubit_spacing`, + `edge_spacing`, `max_group`, `exclude`, or explicit `groups`. Off unless the file says + otherwise, and for now only computed and published on the plan, so the dashboard can + show where a run's parallelism will go before it starts. - `docs`: RFC 0009 — Parallel Calibration. Designs grouping a routine's targets into sets that can be measured in one schedule, from a colouring of the coupling graph rather than a hand-written list, and records why concurrent submission to one cluster cannot work — diff --git a/Makefile b/Makefile index ed673f41..49365d1b 100644 --- a/Makefile +++ b/Makefile @@ -10,9 +10,11 @@ DOCS_SITE_VENV := bin/.docs-site-venv DOCS_SITE_OUT := bin/.docs-site # The framework modules the coverage floor applies to: the SDK, the CLI, the device -# registry and its options, and the executors that need no hardware. Everything else -# is reported but not gated — see cov-py. -PY_COV_INCLUDE := qpi_driver/cli.py,qpi_driver/sdk.py,qpi_driver/events.py,qpi_driver/paths.py,qpi_driver/options.py,qpi_driver/builtins/*.py,qpi_driver/executors/__init__.py,qpi_driver/executors/base/*.py,qpi_driver/executors/mock/*.py +# registry and its options, the executors that need no hardware, and the calibration +# grouping. The last qualifies where the routines do not: it is pure functions over a +# config with no instrument behind them. Everything else is reported but not gated — +# see cov-py. +PY_COV_INCLUDE := qpi_driver/cli.py,qpi_driver/sdk.py,qpi_driver/events.py,qpi_driver/paths.py,qpi_driver/options.py,qpi_driver/builtins/*.py,qpi_driver/executors/__init__.py,qpi_driver/executors/base/*.py,qpi_driver/executors/mock/*.py,qpi_driver/tuners/base/grouping.py PY_COV_MIN := 96 # `uv sync` reinstalls qblox_instruments, and macOS strips the code signature diff --git a/docs/rfcs/0009-parallel-calibration.md b/docs/rfcs/0009-parallel-calibration.md index 6995dc1f..e5675588 100644 --- a/docs/rfcs/0009-parallel-calibration.md +++ b/docs/rfcs/0009-parallel-calibration.md @@ -168,11 +168,12 @@ out of scope, and not a prerequisite for anything below. **D2 — A group is a colouring of a conflict graph, computed from the coupling graph.** Not a hand-written list, because a hand-written list is wrong the first time a coupler is -added and nothing checks it. Not a solver, because greedy colouring reaches the -known-optimal answer on the topologies anyone builds: two groups for single-qubit -routines on any bipartite lattice, and Δ or Δ+1 for couplers by Vizing — which is what -Sycamore's four coupler patterns are. Explicit groups remain available as an override -(§5.3), for a chip whose measured crosstalk does not follow its topology. +added and nothing checks it. Not a solver, because greedy colouring is optimal where it +matters: two groups for single-qubit routines on any bipartite lattice at the default +spacing, and Δ for couplers — Vizing's lower bound, and what Sycamore's four coupler +patterns are. It is *not* optimal everywhere, and §5.5 records where it is not rather +than claiming otherwise. Explicit groups remain available as an override (§5.3), for a +chip whose measured crosstalk does not follow its topology. **D3 — Grouping needs the coupling graph, not chip geometry. No layout file here.** @@ -358,14 +359,24 @@ edges costs `Rq·N + Re·E` acquisitions. Grouped it costs `Rq·Gq + Re·Ge`, wh | Topology | `Gq` at spacing 2 | `Gq` at spacing 3 | `Ge` at `edge_spacing: 1` | | --- | --- | --- | --- | | linear chain | 2 | 3 | 2 | -| square lattice (Δ=4) | 2 | 5 | 4 | +| square lattice (Δ=4) | 2 | 6–7 | 4 | | heavy-hex (Δ≤3) | 2 | 4 | 3 | +Measured against the implementation, not derived — see `tests/utils/chips.py`. + Every `Gq` at the default spacing is 2, because each of these graphs is bipartite; `Ge` -is Δ by Vizing. So the grouped cost is a constant — `27·2 + 6·Ge` — for a chain of five -qubits and a lattice of five hundred alike, while the sequential cost grows with both `N` -and `E`. **The speedup is therefore not a fixed number to quote but `N/Gq`,** which is -the whole reason to build this rather than buy a faster fridge. +is Δ, Vizing's lower bound, on all three. So the grouped cost is a constant — +`27·2 + 6·Ge` — for a chain of five qubits and a lattice of five hundred alike, while +the sequential cost grows with both `N` and `E`. **The speedup is therefore not a fixed +number to quote but `N/Gq`,** which is the whole reason to build this rather than buy a +faster fridge. + +**Greedy is exact at the default spacing and not at spacing 3.** The five-colour Lee +tiling is the optimum for an infinite lattice; greedy colouring reaches six on a small +one and seven at 5×5. That costs runtime and never correctness, and the default spacing +is the case where greedy provably cannot do worse — two classes on a bipartite graph. +Closing the gap is graph colouring, which is NP-hard, and not worth it for one class on +a non-default setting. Setting `qubit_spacing: 1` collapses `Gq` to 1 and is what the SRB literature does routinely; §5.6 is how a chip earns it. diff --git a/qpi-driver/py/qpi_driver/tuners/base/__init__.py b/qpi-driver/py/qpi_driver/tuners/base/__init__.py index b780bea0..b7d0b3b4 100644 --- a/qpi-driver/py/qpi_driver/tuners/base/__init__.py +++ b/qpi-driver/py/qpi_driver/tuners/base/__init__.py @@ -18,6 +18,7 @@ class does the rest. CalibrationConfig, ConfigError, MonitoringConfig, + ParallelConfig, RoutineConfig, ) from qpi_driver.tuners.base.dag import CalibrationDAG, ProgressSink, utc_timestamp @@ -69,6 +70,7 @@ class does the rest. "CalibrationConfig", "ConfigError", "MonitoringConfig", + "ParallelConfig", "RoutineConfig", "CalibrationDAG", "CalibrationReport", diff --git a/qpi-driver/py/qpi_driver/tuners/base/config.py b/qpi-driver/py/qpi_driver/tuners/base/config.py index 7bf2872f..cb1e4aeb 100644 --- a/qpi-driver/py/qpi_driver/tuners/base/config.py +++ b/qpi-driver/py/qpi_driver/tuners/base/config.py @@ -23,6 +23,19 @@ #: an instrument would otherwise hang the worker for the life of the driver. DEFAULT_ROUTINE_TIMEOUT_S = 900.0 +#: Hops that must separate two qubits measured at once. Two excludes adjacent pairs, +#: which leaves an idle qubit between every pair in a group — conservative against what +#: the simultaneous-benchmarking literature does routinely, and the right default until +#: a chip has measured its own penalty (RFC 0009 §5.6). +DEFAULT_QUBIT_SPACING = 2 + +#: The same for couplers, where one asks only that two of them share no qubit. +DEFAULT_EDGE_SPACING = 1 + +#: The most targets in one group. A ceiling on the sequencers one schedule can ask +#: for, before the per-output checks in `grouping.readout_misfit` narrow it further. +DEFAULT_MAX_GROUP = 8 + class ConfigError(ValueError): """``calibration.yml`` is not usable as written.""" @@ -71,6 +84,65 @@ class MonitoringConfig: allxy_as_smoke_test: bool = True +@dataclass +class ParallelConfig: + """Which targets a walk may measure at once (RFC 0009 §5.3). + + Off unless the file says otherwise, which is the opposite of `RoutineConfig`'s + default and deliberately so: a missing `routines` entry cannot make a run measure + nothing, whereas defaulting this to on would silently change how every existing + chip calibrates. + """ + + enabled: bool = False + qubit_spacing: int = DEFAULT_QUBIT_SPACING + edge_spacing: int = DEFAULT_EDGE_SPACING + max_group: int = DEFAULT_MAX_GROUP + #: Pairs never grouped, whatever the spacing allows. + exclude: list[list[str]] = field(default_factory=list) + #: Explicit classes per kind, which skip the colouring entirely. For a chip whose + #: measured crosstalk does not follow its topology. + groups: dict[str, list[list[str]]] = field(default_factory=dict) + + def spacing_for(self, kind: str) -> int: + """The radius that applies to *kind* — ``"qubits"`` or ``"edges"``.""" + return self.edge_spacing if kind == "edges" else self.qubit_spacing + + @classmethod + def from_dict(cls, data: Any) -> "ParallelConfig": + if not isinstance(data, dict): + raise ConfigError(f"'parallel' must be a mapping, got {type(data)}") + + # Checked before any falsy coalescing, or `groups: []` reads as "none given" + # rather than as the mistake it is. + exclude = data.get("exclude") + if exclude is not None and not isinstance(exclude, (list, tuple)): + raise ConfigError("parallel.exclude must be a list of target pairs") + for pair in exclude or []: + if not isinstance(pair, (list, tuple)) or len(pair) != 2: + raise ConfigError( + f"parallel.exclude takes pairs of target names, got {pair!r}" + ) + groups = data.get("groups") + if groups is not None and not isinstance(groups, dict): + raise ConfigError("parallel.groups must be a mapping of kind to groups") + groups = groups or {} + unknown = sorted(set(groups) - {"qubits", "edges"}) + if unknown: + raise ConfigError( + f"parallel.groups knows 'qubits' and 'edges', not {', '.join(unknown)}" + ) + + return cls( + enabled=bool(data.get("enabled", False)), + qubit_spacing=_positive(data, "qubit_spacing", DEFAULT_QUBIT_SPACING), + edge_spacing=_positive(data, "edge_spacing", DEFAULT_EDGE_SPACING), + max_group=_positive(data, "max_group", DEFAULT_MAX_GROUP), + exclude=[list(pair) for pair in exclude or []], + groups={kind: [list(g) for g in gs] for kind, gs in groups.items()}, + ) + + @dataclass class CalibrationConfig: """The whole of ``calibration.yml``.""" @@ -80,6 +152,7 @@ class CalibrationConfig: routines: dict[str, RoutineConfig] = field(default_factory=dict) monitoring: MonitoringConfig = field(default_factory=MonitoringConfig) routine_timeout_s: float = DEFAULT_ROUTINE_TIMEOUT_S + parallel: ParallelConfig = field(default_factory=ParallelConfig) def is_enabled(self, routine_name: str) -> bool: """Whether *routine_name* should run. Absent means yes — see the module docstring.""" @@ -200,6 +273,7 @@ def from_dict(cls, data: dict[str, Any]) -> "CalibrationConfig": routine_timeout_s=float( data.get("routine_timeout_s", DEFAULT_ROUTINE_TIMEOUT_S) ), + parallel=ParallelConfig.from_dict(data.get("parallel") or {}), ) @classmethod @@ -211,6 +285,21 @@ def from_yaml(cls, path: Path) -> "CalibrationConfig": return cls.from_dict(data) +def _positive(data: dict[str, Any], key: str, default: int) -> int: + """*key* as a whole number of at least one, so a typo is a startup error.""" + if key not in data: + return default + try: + value = int(data[key]) + except (TypeError, ValueError): + raise ConfigError( + f"parallel.{key} must be a whole number, got {data[key]!r}" + ) from None + if value < 1: + raise ConfigError(f"parallel.{key} must be at least 1, got {value}") + return value + + def _routine_timeout(name: str, routine_data: dict[str, Any]) -> float | None: """A routine's own ``timeout_s``, validated, or ``None`` to inherit the walk's.""" if "timeout_s" not in routine_data: diff --git a/qpi-driver/py/qpi_driver/tuners/base/dag.py b/qpi-driver/py/qpi_driver/tuners/base/dag.py index 3c818415..36d63cab 100644 --- a/qpi-driver/py/qpi_driver/tuners/base/dag.py +++ b/qpi-driver/py/qpi_driver/tuners/base/dag.py @@ -13,7 +13,8 @@ from qpi_driver.tuners.base.backend import SchedulerBackend from qpi_driver.tuners.base.config import CalibrationConfig -from qpi_driver.tuners.base.device import component_for, has_path +from qpi_driver.tuners.base.device import component_for, edge_names, has_path +from qpi_driver.tuners.base.grouping import couplings_of, groups_of from qpi_driver.tuners.base.provenance import ProvenanceStore from qpi_driver.tuners.base.report import CalibrationReport, RoutineResult from qpi_driver.tuners.base.routines import ( @@ -281,6 +282,7 @@ def plan( "is_benchmark": self.routines[name].is_benchmark, "has_check": self.routines[name].has_check, "updates": list(self.routines[name].updates), + "groups": self.groups_for(name, config, device), } for name in order + [name for name in self.routines if name not in planned] @@ -351,6 +353,43 @@ def _producers_of(self, paths: set[str]) -> str: } return ", ".join(sorted(producers)) + def groups_for( + self, name: str, config: CalibrationConfig, device: Any = None + ) -> list[list[str]]: + """Routine *name*'s targets, in the sets that may be measured at once (RFC 0009 §5). + + One target per group unless ``parallel.enabled``, which is what keeps this + additive: every caller sees the sequential shape until a config asks for + another. + + Explicit ``parallel.groups`` are filtered to the targets this run actually + walks rather than used as given — a config naming a qubit the run excludes + would otherwise put it back. + """ + targets = self._targets_for(name, config, device) + parallel = config.parallel + if not parallel.enabled or len(targets) < 2: + return [[target] for target in targets] + + kind = self.routines[name].targets + named = parallel.groups.get(kind) + if named is not None: + wanted = set(targets) + grouped = [[t for t in group if t in wanted] for group in named] + claimed = {t for group in grouped for t in group} + # Whatever the config forgot still has to be calibrated. + return [group for group in grouped if group] + [ + [t] for t in targets if t not in claimed + ] + + return groups_of( + targets, + adjacency=couplings_of(edge_names(device) or config.target_edges), + spacing=parallel.spacing_for(kind), + exclude=parallel.exclude, + max_group=parallel.max_group, + ) + def _targets_for( self, name: str, config: CalibrationConfig, device: Any = None ) -> list[str]: diff --git a/qpi-driver/py/qpi_driver/tuners/base/grouping.py b/qpi-driver/py/qpi_driver/tuners/base/grouping.py new file mode 100644 index 00000000..a8b17e8c --- /dev/null +++ b/qpi-driver/py/qpi_driver/tuners/base/grouping.py @@ -0,0 +1,218 @@ +"""Which targets may be calibrated at once (RFC 0009 §5). + +Conflict is proximity in the coupling graph, and the radius is a parameter — the +literature settles the shape and leaves the number to measurement (RFC 0009 §5.1). +Chip geometry does not come into it: two qubits close together with no coupler +between them are farther apart, for this purpose, than two adjacent ones. + +Everything here is a function of a config and a few numbers. The device is read in +one place, :func:`outputs_of`, and only to find out which targets share an +instrument output. +""" + +import logging +from collections import defaultdict +from collections.abc import Callable, Iterable, Mapping, Sequence +from typing import Any + +log = logging.getLogger(__name__) + +#: Qubit names in an edge name, as ``calibration.yml`` writes them. +_EDGE_PARTS = 2 + + +def endpoints_of(target: str) -> tuple[str, ...]: + """The qubits *target* occupies — itself for a qubit, both ends for an edge. + + Unifying the two is what lets one distance rule serve both: a CZ conflicts with + whatever either of its qubits conflicts with. + """ + parts = target.split("_") + if len(parts) == _EDGE_PARTS and all(parts): + return tuple(parts) + return (target,) + + +def couplings_of(edges: Iterable[str]) -> dict[str, set[str]]: + """Each qubit's neighbours, from ``_`` edge names.""" + adjacency: dict[str, set[str]] = defaultdict(set) + for edge in edges: + endpoints = endpoints_of(edge) + if len(endpoints) != _EDGE_PARTS: + continue + parent, child = endpoints + adjacency[parent].add(child) + adjacency[child].add(parent) + return dict(adjacency) + + +def is_too_close( + a: str, b: str, adjacency: Mapping[str, set[str]], spacing: int +) -> bool: + """Whether *a* and *b* are nearer than *spacing* hops apart. + + The distance between two edges is the shortest between their endpoint sets, and + zero when they share a qubit — so ``spacing=1`` asks only that two couplers be + disjoint, and ``spacing=2`` puts a qubit between them. + + Breadth-first and bounded by *spacing*, because the question is never how far + apart two targets are, only whether they are far enough. + """ + if spacing <= 0: + return False + ends_a = set(endpoints_of(a)) + ends_b = set(endpoints_of(b)) + frontier, seen = ends_a, set(ends_a) + for _ in range(spacing): + if frontier & ends_b: + return True + frontier = { + neighbour + for qubit in frontier + for neighbour in adjacency.get(qubit, ()) + if neighbour not in seen + } + seen |= frontier + return False + + +def groups_of( + targets: Sequence[str], + *, + adjacency: Mapping[str, set[str]], + spacing: int, + exclude: Iterable[Sequence[str]] = (), + max_group: int, +) -> list[list[str]]: + """*targets* split into sets that can be measured at once. + + Greedy colouring of the conflict graph, in the order *targets* are given, so the + same config always produces the same groups and a run is reproducible. Greedy + reaches the optimum on the topologies anyone builds: two classes on any bipartite + lattice at the default spacing, and Vizing's bound for couplers. + """ + banned = {frozenset(pair) for pair in exclude if len(pair) == _EDGE_PARTS} + classes: list[list[str]] = [] + for target in targets: + for members in classes: + if len(members) < max_group and not any( + _conflicts(target, member, adjacency, spacing, banned) + for member in members + ): + members.append(target) + break + else: + classes.append([target]) + return classes + + +def readout_misfit( + clocks: Sequence[float], + amplitudes: Sequence[float], + *, + band_hz: float, + sequencers: int = 0, +) -> str | None: + """Why these readouts cannot share one output, or ``None`` if they can (§5.4). + + Three ceilings, all arithmetic on the configs. The message names the figure and + the ceiling rather than saying the group is too wide, because only the first of + those is something an operator can act on. + """ + if len(clocks) > 1: + centre = (min(clocks) + max(clocks)) / 2 + worst = max(abs(clock - centre) for clock in clocks) + if worst > band_hz: + return ( + f"readout clocks span {(max(clocks) - min(clocks)) / 1e6:.1f} MHz, so the " + f"furthest sits {worst / 1e6:.1f} MHz from the band centre against an " + f"addressable {band_hz / 1e6:.0f} MHz" + ) + + total = sum(abs(amplitude) for amplitude in amplitudes) + if total > 1.0: + return ( + f"readout amplitudes sum to {total:.3f} of full scale, so the tones would " + f"clip when added" + ) + + if sequencers and len(clocks) > sequencers: + return ( + f"{len(clocks)} readout clocks on one output needs that many sequencers, " + f"and the module has {sequencers}" + ) + return None + + +def split_to_fit( + group: Sequence[str], misfit: Callable[[Sequence[str]], str | None] +) -> list[list[str]]: + """*group* bisected until every part fits, per *misfit*. + + Bisected rather than refused: a group an operator's spacing produced is a + statement about their chip, and the answer to one the instruments cannot play is + fewer targets per schedule. A single target that still does not fit is returned + alone, which is the sequential behaviour. + """ + reason = misfit(group) + if reason is None: + return [list(group)] + if len(group) < 2: + log.warning("%s alone does not fit: %s", group[0], reason) + return [list(group)] + log.info("splitting %s: %s", ", ".join(group), reason) + half = len(group) // 2 + return split_to_fit(group[:half], misfit) + split_to_fit(group[half:], misfit) + + +def outputs_of(device: Any) -> dict[str, str]: + """Which instrument output each port is wired to, from the hardware config. + + The one thing here that reads a device, and it reads the connectivity as an + iterable of ``(a, b)`` pairs — the shape both the config file and a graph object + present. Empty when the wiring cannot be read, which leaves every target looking + like it has an output to itself: the same convention `has_flux_port` uses, and it + leaves the group exactly as the colouring produced it. + """ + try: + graph = device.hardware_config().connectivity.graph + pairs = graph.edges if hasattr(graph, "edges") else graph + wiring: dict[str, str] = {} + for first, second in pairs: + # The port is the end naming a target, and either end may be it. + port, output = (second, first) if ":" in str(second) else (first, second) + wiring[str(port)] = str(output) + return wiring + except Exception: # noqa: BLE001 - unreadable wiring imposes no constraint + log.debug("could not read the wiring; no shared-output limits", exc_info=True) + return {} + + +def by_output( + targets: Sequence[str], wiring: Mapping[str, str], port: str +) -> dict[str, list[str]]: + """*targets* grouped by the output their *port* resolves to. + + Targets whose port the wiring does not name are grouped under the port itself, so + an unreadable or partial wiring keeps them together and the checks still apply. + """ + shared: dict[str, list[str]] = defaultdict(list) + for target in targets: + shared[wiring.get(f"{target}:{port}", port)].append(target) + return dict(shared) + + +def _conflicts( + a: str, + b: str, + adjacency: Mapping[str, set[str]], + spacing: int, + banned: set[frozenset[str]], +) -> bool: + if is_too_close(a, b, adjacency, spacing): + return True + return any( + frozenset((first, second)) in banned + for first in endpoints_of(a) + for second in endpoints_of(b) + ) diff --git a/qpi-driver/py/tests/test_calibration_dag.py b/qpi-driver/py/tests/test_calibration_dag.py index a48207d0..dc1a700f 100644 --- a/qpi-driver/py/tests/test_calibration_dag.py +++ b/qpi-driver/py/tests/test_calibration_dag.py @@ -701,6 +701,8 @@ def test_a_node_carries_what_the_dashboard_draws_it_from(self): "is_benchmark": False, "has_check": True, "updates": ["rxy.amp180"], + # One per target unless `parallel.enabled` — see test_calibration_grouping. + "groups": [["q0"], ["q1"]], } def test_an_excluded_routine_is_still_sent_marked_unplanned(self): diff --git a/qpi-driver/py/tests/test_calibration_grouping.py b/qpi-driver/py/tests/test_calibration_grouping.py new file mode 100644 index 00000000..c3fa51ff --- /dev/null +++ b/qpi-driver/py/tests/test_calibration_grouping.py @@ -0,0 +1,392 @@ +"""Which targets may be measured at once (RFC 0009 §5).""" + +import logging +from types import SimpleNamespace + +import pytest + +from qpi_driver.tuners.base.config import ( + DEFAULT_EDGE_SPACING, + DEFAULT_MAX_GROUP, + DEFAULT_QUBIT_SPACING, + CalibrationConfig, + ConfigError, + ParallelConfig, +) +from qpi_driver.tuners.base.dag import CalibrationDAG +from qpi_driver.tuners.base.grouping import ( + by_output, + couplings_of, + endpoints_of, + groups_of, + is_too_close, + outputs_of, + readout_misfit, + split_to_fit, +) +from tests.test_calibration_dag import StubRoutine +from tests.utils.chips import chain, heavy_hex, lattice, qubits_of + +#: Wide enough not to be the thing under test where the topology is. +UNBOUNDED = 999 + + +def _groups(edges, targets, spacing, **kwargs): + return groups_of( + targets, + adjacency=couplings_of(edges), + spacing=spacing, + max_group=kwargs.pop("max_group", UNBOUNDED), + **kwargs, + ) + + +class TestWhatATargetOccupies: + def test_a_qubit_is_its_own_endpoint(self): + assert endpoints_of("q3") == ("q3",) + + def test_an_edge_is_both_its_qubits(self): + assert endpoints_of("q0_q1") == ("q0", "q1") + + def test_a_name_that_is_not_a_pair_stays_whole(self): + """Otherwise a stray underscore would silently become a two-qubit target.""" + assert endpoints_of("q0_") == ("q0_",) + assert endpoints_of("q0_q1_q2") == ("q0_q1_q2",) + + +class TestTheCouplingGraph: + def test_an_edge_couples_both_ways(self): + assert couplings_of(["q0_q1"]) == {"q0": {"q1"}, "q1": {"q0"}} + + def test_a_chain_couples_only_its_neighbours(self): + adjacency = couplings_of(chain(5)) + assert adjacency["q0"] == {"q1"} + assert adjacency["q2"] == {"q1", "q3"} + + def test_a_name_that_is_not_an_edge_couples_nothing(self): + assert couplings_of(["q0"]) == {} + + +class TestHowFarApartIsFarEnough: + """`spacing` is the minimum distance, so conflict is distance < spacing.""" + + def setup_method(self): + self.chain = couplings_of(chain(5)) + + def test_a_spacing_of_one_constrains_nothing(self): + assert not is_too_close("q0", "q1", self.chain, 1) + + def test_the_default_spacing_excludes_adjacent_qubits(self): + assert is_too_close("q0", "q1", self.chain, DEFAULT_QUBIT_SPACING) + assert not is_too_close("q0", "q2", self.chain, DEFAULT_QUBIT_SPACING) + + def test_a_spacing_of_three_wants_two_qubits_between(self): + assert is_too_close("q0", "q2", self.chain, 3) + assert not is_too_close("q0", "q3", self.chain, 3) + + def test_couplers_sharing_a_qubit_are_never_far_enough(self): + assert is_too_close("q0_q1", "q1_q2", self.chain, DEFAULT_EDGE_SPACING) + + def test_disjoint_couplers_clear_the_default_edge_spacing(self): + """Adjacent but disjoint: `edge_spacing` 1 asks only that they share no qubit.""" + assert not is_too_close("q0_q1", "q2_q3", self.chain, DEFAULT_EDGE_SPACING) + + def test_a_higher_edge_spacing_puts_a_qubit_between_two_couplers(self): + assert is_too_close("q0_q1", "q2_q3", self.chain, 2) + assert not is_too_close("q0_q1", "q3_q4", self.chain, 2) + + def test_a_spacing_of_zero_is_no_question_at_all(self): + assert not is_too_close("q0", "q0", self.chain, 0) + + def test_an_unknown_qubit_conflicts_with_nothing_but_itself(self): + assert not is_too_close("q9", "q0", self.chain, 4) + + +class TestTheGroupsATopologyAllows: + """The measured figures behind RFC 0009 §5.5. + + Each topology is asserted at two sizes, which is what pins the claim that the + group count follows the connectivity graph and not the qubit count. + """ + + @pytest.mark.parametrize("qubits", [5, 10]) + def test_a_chain_needs_two_groups_at_the_default_spacing(self, qubits): + edges = chain(qubits) + groups = _groups(edges, qubits_of(edges), DEFAULT_QUBIT_SPACING) + assert len(groups) == 2 + + @pytest.mark.parametrize("side", [3, 5]) + def test_a_lattice_needs_two_groups_at_the_default_spacing(self, side): + """Two whatever the size, because a lattice is bipartite.""" + edges = lattice(side) + groups = _groups(edges, qubits_of(edges), DEFAULT_QUBIT_SPACING) + assert len(groups) == 2 + + def test_heavy_hex_needs_two_groups_at_the_default_spacing(self): + edges = heavy_hex() + assert len(_groups(edges, qubits_of(edges), DEFAULT_QUBIT_SPACING)) == 2 + + def test_a_chain_needs_three_groups_with_two_qubits_between(self): + edges = chain(10) + assert len(_groups(edges, qubits_of(edges), 3)) == 3 + + def test_greedy_exceeds_the_optimum_on_a_lattice_at_spacing_three(self): + """Measured, not derived. The five-colour Lee tiling is the *optimum* for an + infinite lattice; greedy colouring reaches six or seven, which costs runtime + and never correctness. The default spacing is where greedy is exact.""" + edges = lattice(5) + assert len(_groups(edges, qubits_of(edges), 3)) == 7 + + @pytest.mark.parametrize( + "edges,expected", [(chain(10), 2), (lattice(5), 4), (heavy_hex(), 3)] + ) + def test_couplers_group_into_one_class_per_degree(self, edges, expected): + """Vizing's bound, reached: a chain is degree 2, a lattice 4, heavy-hex 3.""" + assert len(_groups(edges, edges, DEFAULT_EDGE_SPACING)) == expected + + def test_every_target_lands_in_exactly_one_group(self): + edges = lattice(4) + targets = qubits_of(edges) + groups = _groups(edges, targets, DEFAULT_QUBIT_SPACING) + + placed = [target for group in groups for target in group] + assert sorted(placed) == sorted(targets) + assert len(placed) == len(set(placed)) + + def test_no_group_holds_two_targets_that_are_too_close(self): + edges = lattice(4) + adjacency = couplings_of(edges) + groups = _groups(edges, qubits_of(edges), DEFAULT_QUBIT_SPACING) + + for group in groups: + for i, first in enumerate(group): + for second in group[i + 1 :]: + assert not is_too_close( + first, second, adjacency, DEFAULT_QUBIT_SPACING + ) + + def test_the_same_config_always_produces_the_same_groups(self): + edges = lattice(4) + targets = qubits_of(edges) + once = _groups(edges, targets, DEFAULT_QUBIT_SPACING) + assert _groups(edges, targets, DEFAULT_QUBIT_SPACING) == once + + def test_an_excluded_pair_is_never_grouped(self): + edges = chain(5) + groups = _groups( + edges, qubits_of(edges), DEFAULT_QUBIT_SPACING, exclude=[["q0", "q2"]] + ) + + assert not any({"q0", "q2"} <= set(group) for group in groups) + + def test_a_coupler_is_excluded_through_either_of_its_qubits(self): + edges = chain(5) + groups = _groups(edges, edges, DEFAULT_EDGE_SPACING, exclude=[["q0", "q3"]]) + + assert not any({"q0_q1", "q2_q3"} <= set(group) for group in groups) + + def test_an_exclusion_that_is_not_a_pair_is_ignored(self): + edges = chain(5) + groups = _groups( + edges, qubits_of(edges), DEFAULT_QUBIT_SPACING, exclude=[["q0"]] + ) + assert len(groups) == 2 + + def test_max_group_caps_a_class(self): + edges = chain(10) + groups = _groups(edges, qubits_of(edges), DEFAULT_QUBIT_SPACING, max_group=2) + + assert all(len(group) <= 2 for group in groups) + assert sum(len(group) for group in groups) == 10 + + +class TestWhatOneOutputCanPlayAtOnce: + """§5.4 — three ceilings, all arithmetic on the configs.""" + + def test_readouts_inside_the_band_and_the_scale_fit(self): + assert readout_misfit([6.4e9, 6.8e9], [0.03, 0.04], band_hz=500e6) is None + + def test_a_lone_readout_is_never_out_of_band(self): + assert readout_misfit([6.4e9], [0.03], band_hz=1.0) is None + + def test_clocks_further_apart_than_the_band_do_not_fit(self): + reason = readout_misfit([6.0e9, 7.5e9], [0.03, 0.03], band_hz=500e6) + + assert reason is not None + # The figure and the ceiling, so an operator can act on it. + assert "750.0 MHz from the band centre" in reason + assert "500 MHz" in reason + + def test_amplitudes_that_would_clip_do_not_fit(self): + reason = readout_misfit([6.4e9] * 3, [0.4, 0.4, 0.4], band_hz=500e6) + + assert reason is not None and "1.200 of full scale" in reason + + def test_more_clocks_than_the_module_has_sequencers_do_not_fit(self): + reason = readout_misfit([6.4e9] * 7, [0.01] * 7, band_hz=500e6, sequencers=6) + + assert reason is not None and "the module has 6" in reason + + def test_a_group_that_fits_is_left_alone(self): + assert split_to_fit(["q0", "q1"], lambda _: None) == [["q0", "q1"]] + + def test_a_group_that_does_not_fit_is_bisected_until_it_does(self): + def misfit(group): + return "too many" if len(group) > 2 else None + + assert split_to_fit(["q0", "q1", "q2", "q3"], misfit) == [ + ["q0", "q1"], + ["q2", "q3"], + ] + + def test_a_single_target_that_cannot_fit_runs_alone_and_says_so(self, caplog): + with caplog.at_level(logging.WARNING): + assert split_to_fit(["q0"], lambda _: "nothing fits") == [["q0"]] + + assert "q0 alone does not fit" in caplog.text + + +class TestReadingTheWiring: + def _device(self, pairs, as_graph=False): + graph = SimpleNamespace(edges=pairs) if as_graph else pairs + return SimpleNamespace( + hardware_config=lambda: SimpleNamespace( + connectivity=SimpleNamespace(graph=graph) + ) + ) + + def test_it_maps_a_port_to_the_output_it_hangs_off(self): + wiring = outputs_of(self._device([["clusterA.module20.out0", "q0:res"]])) + + assert wiring == {"q0:res": "clusterA.module20.out0"} + + def test_either_end_may_be_the_port(self): + """A graph object may present an edge in either direction.""" + wiring = outputs_of(self._device([["q0:res", "clusterA.module20.out0"]])) + + assert wiring == {"q0:res": "clusterA.module20.out0"} + + def test_a_graph_object_is_read_through_its_edges(self): + wiring = outputs_of( + self._device([("clusterA.module20.out0", "q0:res")], as_graph=True) + ) + + assert wiring == {"q0:res": "clusterA.module20.out0"} + + def test_unreadable_wiring_imposes_no_constraint(self): + assert outputs_of(SimpleNamespace()) == {} + + def test_targets_are_grouped_by_the_output_they_share(self): + wiring = {"q0:res": "out0", "q1:res": "out0", "q2:res": "out1"} + + assert by_output(["q0", "q1", "q2"], wiring, "res") == { + "out0": ["q0", "q1"], + "out1": ["q2"], + } + + def test_a_target_the_wiring_does_not_name_stays_with_the_rest(self): + """An unreadable or partial wiring must not quietly drop the checks.""" + assert by_output(["q0", "q1"], {}, "res") == {"res": ["q0", "q1"]} + + +class TestTheParallelConfig: + def test_it_is_off_unless_the_file_says_otherwise(self): + assert not CalibrationConfig.from_dict({}).parallel.enabled + + def test_it_reads_the_spacings_and_the_ceiling(self): + config = ParallelConfig.from_dict( + {"enabled": True, "qubit_spacing": 3, "edge_spacing": 2, "max_group": 4} + ) + + assert config.enabled + assert config.spacing_for("qubits") == 3 + assert config.spacing_for("edges") == 2 + assert config.max_group == 4 + + def test_the_defaults_are_the_conservative_ones(self): + config = ParallelConfig.from_dict({"enabled": True}) + + assert config.spacing_for("qubits") == DEFAULT_QUBIT_SPACING + assert config.spacing_for("edges") == DEFAULT_EDGE_SPACING + assert config.max_group == DEFAULT_MAX_GROUP + + @pytest.mark.parametrize( + "data,message", + [ + ({"qubit_spacing": "wide"}, "must be a whole number"), + ({"max_group": 0}, "must be at least 1"), + ({"exclude": [["q0", "q1", "q2"]]}, "takes pairs"), + ({"groups": {"couplers": []}}, "knows 'qubits' and 'edges'"), + ({"groups": []}, "must be a mapping"), + ({"exclude": {"q0": "q1"}}, "must be a list of target pairs"), + ], + ) + def test_a_setting_it_cannot_use_is_a_startup_error(self, data, message): + with pytest.raises(ConfigError, match=message): + ParallelConfig.from_dict(data) + + def test_a_parallel_block_that_is_not_a_mapping_is_a_startup_error(self): + with pytest.raises(ConfigError, match="'parallel' must be a mapping"): + CalibrationConfig.from_dict({"parallel": ["enabled"]}) + + +class TestTheGroupsAWalkPublishes: + """`CalibrationDAG.groups_for`, and the `groups` key it puts on the plan.""" + + def _dag(self, **parallel): + config = CalibrationConfig( + target_qubits=["q0", "q1", "q2", "q3", "q4"], + target_edges=chain(5), + parallel=ParallelConfig(**parallel), + ) + return CalibrationDAG([StubRoutine("a")], config), config + + def test_a_walk_that_did_not_ask_runs_one_target_at_a_time(self): + """The default has to leave every existing chip calibrating as it did.""" + dag, config = self._dag() + + assert dag.groups_for("a", config) == [["q0"], ["q1"], ["q2"], ["q3"], ["q4"]] + + def test_enabling_it_groups_by_the_coupling_graph(self): + dag, config = self._dag(enabled=True) + + assert dag.groups_for("a", config) == [["q0", "q2", "q4"], ["q1", "q3"]] + + def test_explicit_groups_skip_the_colouring(self): + dag, config = self._dag(enabled=True, groups={"qubits": [["q0", "q1"], ["q2"]]}) + + groups = dag.groups_for("a", config) + + assert groups[:2] == [["q0", "q1"], ["q2"]] + + def test_a_target_the_explicit_groups_forgot_is_still_calibrated(self): + dag, config = self._dag(enabled=True, groups={"qubits": [["q0", "q2"]]}) + + assert dag.groups_for("a", config) == [["q0", "q2"], ["q1"], ["q3"], ["q4"]] + + def test_explicit_groups_cannot_add_a_target_the_run_excludes(self): + """A config naming a qubit this run does not walk would otherwise put it back.""" + dag, config = self._dag(enabled=True, groups={"qubits": [["q0", "q9"]]}) + + placed = [target for group in dag.groups_for("a", config) for target in group] + assert "q9" not in placed + + def test_the_plan_carries_the_groups_for_the_drawing(self): + dag, config = self._dag(enabled=True) + + node = dag.plan(["a"], config)["nodes"][0] + + assert node["groups"] == [["q0", "q2", "q4"], ["q1", "q3"]] + + def test_a_single_target_needs_no_colouring(self): + config = CalibrationConfig( + target_qubits=["q0"], parallel=ParallelConfig(enabled=True) + ) + dag = CalibrationDAG([StubRoutine("a")], config) + + assert dag.groups_for("a", config) == [["q0"]] + + def test_an_unknown_routine_has_no_groups(self): + dag, config = self._dag(enabled=True) + + assert dag.groups_for("nope", config) == [] diff --git a/qpi-driver/py/tests/utils/chips.py b/qpi-driver/py/tests/utils/chips.py new file mode 100644 index 00000000..8d8f6976 --- /dev/null +++ b/qpi-driver/py/tests/utils/chips.py @@ -0,0 +1,41 @@ +"""Coupling graphs to group against, by topology (RFC 0009 §10.2). + +Each returns edge names, which is what `couplings_of` reads and what a device config +holds. Parameterised by size so a test can assert that a group count depends on the +topology and not on how big it is — the claim the whole RFC rests on. +""" + + +def chain(qubits: int) -> list[str]: + """A line of *qubits*, as every small bring-up chip is.""" + return [f"q{i}_q{i + 1}" for i in range(qubits - 1)] + + +def lattice(side: int) -> list[str]: + """A square grid, ``side`` by ``side`` — degree 4 in the interior.""" + edges = [ + f"q{row * side + column}_q{row * side + column + 1}" + for row in range(side) + for column in range(side - 1) + ] + edges += [ + f"q{row * side + column}_q{(row + 1) * side + column}" + for row in range(side - 1) + for column in range(side) + ] + return edges + + +def heavy_hex() -> list[str]: + """Three fused hexagons — degree at most 3, as the larger vendors' chips are.""" + return [ + "q0_q1", "q1_q2", "q2_q3", "q3_q4", "q4_q5", "q5_q0", + "q2_q6", "q6_q7", "q7_q8", "q8_q9", "q9_q3", + "q5_q10", "q10_q11", "q11_q12", "q12_q13", "q13_q0", + ] # fmt: skip + + +def qubits_of(edges: list[str]) -> list[str]: + """Every qubit the *edges* name, in index order.""" + names = {qubit for edge in edges for qubit in edge.split("_")} + return sorted(names, key=lambda name: int(name[1:])) diff --git a/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/elements/NodeCard.tsx b/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/elements/NodeCard.tsx index 0b324a09..c3b6ef2c 100644 --- a/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/elements/NodeCard.tsx +++ b/qpi-ui/internal/dashboard/src/components/tabs/CalibrationTab/elements/NodeCard.tsx @@ -15,6 +15,8 @@ interface NodeCardProps { status: CalibrationNodeStatus; done: number; total: number; + /** Targets in flight, for the node the walk is on (RFC 0009 §7.2). */ + running?: string[]; plan: CalibrationPlan; /** The report this run produced, once it has landed. */ report?: CalibrationResult; @@ -25,6 +27,7 @@ const STATUS_LABELS: Record = { running: "running", done: "done", partial: "some targets failed", + blocked: "prerequisite never measured", failed: "failed", pending: "not yet run", skipped: "applies to nothing configured here", @@ -41,12 +44,15 @@ export const NodeCard: React.FC = ({ status, done, total, + running, plan, report, onSelect, }) => { const dependents = dependentsOf(node.name, plan); const outcomes = outcomesFor(node.name, node.targets, report); + const groups = node.groups ?? []; + const inFlight = running ?? []; return (