Skip to content

Feature/calibration driver - #36

Open
ch-ahindura wants to merge 24 commits into
sopherapps:feature/calibration-driverfrom
ch-ahindura:feature/calibration-driver
Open

Feature/calibration driver#36
ch-ahindura wants to merge 24 commits into
sopherapps:feature/calibration-driverfrom
ch-ahindura:feature/calibration-driver

Conversation

@ch-ahindura

Copy link
Copy Markdown
Contributor

Why

To complete the CZ simulation started in the base repository's branch

RFC 0004 §10 required this be decided explicitly rather than inherited
from the events log's public read. The collections were already
authenticated-read, but the rule in migrate.go still carried the open
question ("Public read (maybe auth only?)") next to code that says
otherwise, and the RFC still posed it as pending.

A public events log leaks that a calibration happened; a public report
leaks the chip.
Two independent density matrices cannot be entangled — not "are not", but
cannot be, so a Bell state could not come out wrong because it could not
come out at all. `simulation/coupled.py` holds both transmons in one
9-dimensional register and couples them, and the CZ falls out of walking
the control through the |11>-|02> avoided crossing: conditional phase
179.3 degrees, Bell concurrence >0.9, leakage <0.5%, none of it written
down.

Two of these numbers are chosen rather than derived — the exchange
coupling and the flux-to-detuning curve — because they describe a coupler
this project has no device to measure. That makes two-qubit tier 3 weaker
than the one-qubit tier, and both the module and the RFC say so.

Driving the real routines through it found three faults:

- fit_chevron took the global argmax of the control population. Off
  resonance the control also reads ~1, so the maximum of that surface is
  as likely to sit on a row where nothing happened as on the gate. It now
  finds resonance by oscillation contrast, then the round trip along that
  row, and refuses a sweep whose largest swing is below 0.15 — the
  default amplitude grid steps over a 4.5 MHz crossing in ~75 MHz strides
  and comes back flat, which is the same failure spectroscopy already
  guards against.
- fit_conditional_phase read the zero crossing of the two fringes'
  difference, which sits at phi0 + phi_cz/2 — half the wanted angle,
  displaced by the control's dynamical phase over the flux pulse. That
  phase is tens of turns and is precisely what having two fringes is for.
  It now fits each fringe and subtracts: 179.2 against a true 179.3.
- The correction was `np.pi - crossing` with crossing in degrees. A gate
  needing 30 degrees back was told to apply -26.86.

The tier-1 test asserted `phase_correction == np.pi - conditional_phase`
— it encoded the bug as the expectation, which is why none of this
surfaced there.
The coordinator held one density matrix per qubit, so a flux pulse could
only raise: two independent states cannot be entangled. Qubits now start
independent and merge into a joint register the moment a flux pulse
couples them, so a one-qubit circuit stays a 3x3 matrix and a two-qubit
one is correct. `h; cz; h` comes out a Bell state, and half a CZ does
not — which is the control that makes the first mean anything.

Getting there needed two things the gate-level model does not show:

- A long flux pulse is not a pulse. The qblox backend emits a held DC
  offset — set to amp at t=0, cleared at 106 ns — plus a 4 ns square
  tail. Reading the pulses alone applied 4 ns of a gate that ran for 110,
  which is a quarter turn of an exchange instead of a full one.
- Correlation is not in either qubit. Both halves of a Bell pair have a
  marginal of exactly 0.5, so drawing each qubit's shots from its own
  marginal reproduces both marginals perfectly and destroys the only
  thing that made the state interesting. Shots are drawn from the pair's
  joint distribution, cached per register so the two halves agree on the
  shot they belong to, and invalidated by gates rather than by any
  evolution — a readout idles the register between the two halves being
  read.

The resulting Bell state is |01>+|10> rather than |00>+|11>: a CZ leaves
a large single-qubit phase behind, and correcting it is exactly what
conditional_phase writes cz.phase_correction for. The tests assert
|correlation| for that reason, via a Pearson coefficient — "how often do
the bits agree" calls a state with lopsided marginals strongly
correlated.

RFC 0004 §7 and §11 updated: the executor side now interprets the
compiled schedule, the routines still do not, and two-qubit tier 3 rests
on a coupler whose constants were chosen.
ShiftClockPhase never reached the simulated coordinator's pulse walk, so
every rz, z, s and t ran as an identity — silently, since a frame shift
plays no pulse and its absence looks like nothing happening. `h; h` and
`h; rz(pi); h` returned the same answer. The frame is now tracked per
clock and every drive is referenced to it.

This is not only about two-qubit gates: it is wrong for any circuit with
a virtual Z in it, which is most of them once a compiler has decomposed
anything. It is also the mechanism a CZ's per-qubit phase corrections use
(FluxTunableCoupler emits one ShiftClockPhase per qubit), so those could
not have worked either.

Also adds TransmonSimulator.at_f01, which solves EJ for a target
frequency by Newton on the real diagonalisation. A chip is not one qubit
repeated — the lab's five sit between 4.25 and 5.27 GHz — and a coupler
gate cannot be modelled honestly while every qubit is the same transmon.

The tests thread delays between phase updates because the compiler
refuses two closer than 4 ns: an NCO constraint of the real hardware,
surfaced only because these compile for it.
The simulated coordinator answered every protocol with an integrated IQ
point, so two of the three measurement levels were wrong in ways a job
could not see:

- meas_level=0 asks for a Trace, which is a time series. It got a single
  point, so a raw-waveform job produced a one-sample waveform and looked
  like it had worked. It is now the readout resonator's response over the
  integration window, sampled per nanosecond as the digitiser does, and it
  rings up at the resonator linewidth rather than appearing settled.
- meas_level=2 asks for ThresholdedAcquisition, which the *instrument*
  discriminates and returns as int 0/1. Handing back IQ still produced
  correct counts, because the executor falls through to discriminating in
  software — so the path every real job takes went untested while a
  different one passed. The simulator now applies the hardware's own rule:
  rotate by acq_rotation, compare the real part against acq_threshold.
  A device configured with the wrong rotation now produces wrong bits
  here, exactly as on the bench.

meas_level=1 was already right.

Tests run one circuit at all three and assert they agree — level 2 says
excited, so level 1's IQ must sit on the excited blob and level 0's
settled trace must point the same way. A level that disagrees is
reporting a different circuit than the one that ran.
The cypress e2e ran against the mock executor, and the quantify path it
could have used was pinned to `is_dummy=true` — a dummy cluster that
returns nan for every acquisition. Neither can show one measurement level
behaving differently from another, which is where the dashboard's job
results have actually gone wrong.

QPI_E2E_SIMULATED=1 now switches install_driver to pull the `sim`
dependency group and start_driver to pass `is_simulated=true`. The
dashboard spec sets it and runs a quantify driver.

The device config is generated rather than reused: the checked-in fixture
is deliberately 214 MHz off resonance so the calibration tests have a
qubit to find, and a job console driven by an uncalibrated chip returns
wrong counts for every circuit — which reads as a broken dashboard rather
than an uncalibrated one. simulated_device.py writes the same fixture
with the parameters a successful calibration would have produced.

Also adds `is_simulated` to the driver catalog, which only offered
`is_dummy` — so the registration form could not offer the simulator even
though both builders have read the option since 620023e. The catalog's
cross-check test listed the calibrate driver's options by hand and had
gone stale in the same way; it now agrees with calibrate.py again.
Three things, all found by driving the FluxTunableCoupler path rather
than the CompositeSquareEdge one the tests had been using.

The coupler's CZ never reached the simulator. Its pulse goes on the
coupler's own port — q1_q2:fl — and `compile_cz` lowers the gate into a
fresh subschedule, so the gate-level device_elements is gone by the time
the pulse arrives and the pair could not be identified. The port names
the edge, which is the reliable statement of which two qubits a coupler
sits between, so that is what it reads now. A qubit's own flux port still
takes its partner from the gate.

The coupler now carries its DC parking current, validated to the +/-3.1 mA
the couplers are characterised over. It is deliberately not scheduled: a
seconds-scale bias from an SPI rack's S4g is not a pulse, and
quantify-scheduler has no notion of one. But it is calibrated state — a
coupler parked at the wrong current has the wrong effective coupling and
every CZ across it is wrong — so it belongs in the device file beside the
pulse parameters. A `source` parameter records whether SPI or a QCM
delivers it.

The pulse is a SoftSquarePulse now, matching the tergite instruction this
element was ported from. A hard square's edges are broadband and carry
power at the 1-2 transition a CZ spends its whole duration avoiding.

And the dashboard's IQ plot mapped both axes onto a hardcoded -0.5..1.5
window. IQ comes back in whatever units the readout chain produces, so
that window is wrong for most devices — against the simulator both
clusters land outside it and the tab renders empty. It scales to the data
now, over one square window so the distance between the blobs, which is
the readout fidelity, is not stretched.
A `FluxTunableCoupler` does not push a qubit onto the |11>-|02> crossing
with DC flux. It parks the coupler with a bias current and then modulates
it at microwave frequency, and a sideband of that modulation bridges the
gap. Two things follow that the DC model could not express:

- it works with qubits nowhere near each other, which is why the lab's
  five sit 0.5-1 GHz apart and still have CZs between them;
- frequency is the *resonance condition*, not a carrier detail. A drive
  50 MHz off the transition compiles, plays, and performs no gate. There
  is now a test for exactly that, and it could not have failed under a
  model that ignored the clock.

The exchange rate is not invented. The lab's four calibrated edges imply
4.29, 1.91, 4.91 and 6.41 MHz per unit amplitude from their own
(amplitude, duration) pairs, reading each duration as one round trip;
the model uses their mean, so a device config taken off the bench lands
near a real CZ here instead of nowhere.

And the Bell state is |00> + |11> now, not merely *a* maximally entangled
pair. The coupler Stark-shifts both qubits while it drives them and the
phase that leaves is single-qubit, so no amount of tuning amplitude or
duration removes it — only the edge's two <qubit>_phase_correction
parameters do, which is what they are for and why this element carries
them although the tergite instruction it was ported from does not (qpi
goes through quantify's gate library, where there is nowhere else to put
them). Zeroing them still gives a perfectly entangled pair; it just gives
the wrong one, and that is the control test.

Getting there needed the virtual-Z fix to have landed first: the
corrections are ShiftClockPhase operations, and until d9543dc those did
nothing at all.
The job-console specs checked that a job submits and that the panel names
the right QPU. They passed whether or not the numbers in it meant
anything, which is the wrong kind of green for the one place these bugs
were actually seen.

Four specs now assert the numbers. Level 2 reads the histogram bins and
requires the weight to sit on |00> and |11> — the form's default circuit
is a Bell state, so that is an end-to-end check of the CZ through the
compiler, the simulator and the readout. Level 1 requires many IQ points
rather than one, and requires them inside the viewBox, which is what the
hardcoded axis range broke. Level 0 requires a curve over many samples.

On the weight rather than on which bars exist: a real Bell state still
puts a few shots in |01> and |10>, and a bar is drawn for each.

Two things had to be fixed to make them meaningful:

- The device the e2e generates calibrated the qubits and left the
  couplers at fixture defaults, so the CNOT was not a CZ and the
  histogram spread over all four outcomes. Both edge types are calibrated
  now, each by its own mechanism — the coupler edge parametrically, the
  qubit-port edge with DC flux — and both get their two phase
  corrections, without which the gate is conditional-phase correct and
  still builds the wrong Bell state.
- The Meas Level slider is a React controlled input, so `.invoke("val")`
  moved the thumb while leaving the component on the old value. Every
  test would have run at the default and measured the same thing. It goes
  through the native setter now, and asserts the value landed.

The level-0 spec uses a one-qubit circuit because a Qblox module can put
only one sequencer in scope mode: raw trace on two qubits does not
compile. Worth knowing — the form's default circuit measures both, so
selecting level 0 in the dashboard fails before the job runs.
A tunable coupler needs two things on two timescales, and only one is a
pulse: the CZ, a few hundred nanoseconds compiled into the schedule, and
the parking bias, a DC current that holds the coupler at its operating
point for as long as the fridge is cold.

quantify has no way to express the second. Its hardware config describes
clusters and nothing else — there is no SpiRack type in
backends.types.qblox at all — so the bias cannot be part of a schedule
even in principle, and if the driver does not set it directly then
nothing does and every CZ runs against a coupler parked wherever it was
left.

So `coupler_bias.py` sets it out of band at startup, through whichever
mechanism the edge names:

- `spi`, an S4g current source over qcodes, which is what the lab uses.
  Ramped rather than stepped: an S4g can slew fast enough to put a
  transient in the coupler's loop, and a parking current is meant to be
  the thing that is not moving while a gate runs.
- `qcm`, the same current held as an offset on a baseband cluster output,
  which takes an instrument out of the rack.

Switching is a config change, not a code change, which was the ask.

The refusals are the interesting part. A chip whose couplers disagree
about the mechanism is rejected — one rack cannot be two things, and
picking one silently would park half the chip through hardware that is
not wired to it. The QCM path refuses to guess a line resistance, since
guessing turns a current into the wrong voltage and every CZ across that
coupler is wrong in the way that looks like drift. And a chip with no
tunable couplers needs no rack and no address, because most here have
none.

Failing to park logs and continues rather than refusing to start: a
coupler that cannot be biased is a broken two-qubit gate, not a broken
node, and taking the whole QPU out for it would be the wrong trade.
A Qblox module can put exactly one sequencer into scope mode, so asking
two qubits for a raw trace in one schedule does not compile — "Only one
sequencer per device can trigger raw trace capture". That made
meas_level=0 unavailable for any circuit measuring more than one qubit,
including the dashboard's own default, which fails before the job runs.

The constraint is per module and per *run*, not per job, so the fix is to
take the traces in separate runs: the circuit is played once per measured
qubit with a single scope-mode acquisition each time, and the traces are
merged. Each pass carries one qubit's variable on channel-suffixed
dimensions, so there is nothing to collide when they come back together.

This is what the instrument allows and what a lab does by hand. The cost
is real and unavoidable — N runs for N qubits, because the shots are
repeated per qubit rather than shared — so it is logged rather than
hidden.

The dashboard e2e now exercises level 0 on the form's default two-qubit
circuit instead of being given a narrower one to keep it compiling.
The qblox tier-2 test asserted only that a routine *built* a schedule,
while the quantify one compiled it — so a routine whose schedule would
not compile under qblox passed. `HardwareAgent.compile` exists, so both
backends are held to the same standard now, and the first thing it caught
was real:

`drag` swept its parameter over -1.0 to 1.0. The DRAG parameter is in
seconds — it scales a time derivative of the pulse envelope, so its size
comes from the pulse, not from an amplitude. A 20 ns gate wants order
1e-11, which the lab's own device file agrees with: -5.4e-11 and
-1.5e-11. At the old scale the derivative term dwarfs the carrier, the
waveform exceeds full scale, and the schedule does not compile at all —
so the routine could never have run on hardware. Empirically it compiles
below about 2e-8. The default now sweeps ±2e-10, which brackets the
lab's values with room.

The same wrong scale was in the test's own sweep override, which is why
neither backend's tier-2 noticed the units even when quantify did compile.

Also ports the multi-qubit raw trace split to the qblox executor: the
scope-mode limit is a property of the module, not of the scheduler
driving it, so qblox hit it too. `measured_qubits` moves to the shared
qiskit utils rather than qblox importing from quantify.

Adds the sim environment to CI, which ran neither test-py-sim nor
test-py-loop — so the tier-3 physics and the whole calibrate/execute loop
had never run there.
`make test` was failing at test-docs-static with 17 problems claiming
--qpi-addr, --token and --ca-fingerprint are not flags the CLI accepts.
They are. The target passed alone and failed inside `make test`, which is
the shape of an environment left behind by an earlier target.

check_docs forces PYTHONPATH at the source tree so `qpi_driver.cli`
imports whether or not it is installed. Without typer that import gets a
stub, which prints a near-empty help and exits 0 — and `cli_flags` only
skips on a non-zero exit, so it believed the stub and reported the whole
documented CLI as removed.

Two changes, because the target should not have been able to drift and
the checker should not have believed the result:

- `uv run` now names `--extra cli` as well as the sync before it, so the
  environment is a property of this target rather than of whatever ran
  first. Starting from a quantify-only environment it now checks all 138
  claims instead of failing.
- A help with fewer than four flags across every subcommand is not a help
  worth checking against. It is treated as "the CLI could not be run
  here" — the warning-and-skip path that already existed for an absent
  toolchain — rather than as evidence that every flag was deleted.
qblox used to refuse the flag. The reasoning was that its HardwareAgent
compiles and runs in one call, so quantify's coordinator has nowhere to
plug in — true of `run`, and beside the point: the agent's `compile` is
offline and needs no instrument at all. So `SimulatedAgent` keeps a real
agent for everything structural and takes over only execution, handing
the compiled schedule to the same simulator quantify uses. The
compilation stays real, which means a schedule that would not assemble
for a cluster does not assemble here either.

quantify-scheduler is being deprecated, so this is the path that has to
keep working rather than the one that can stay a stub.

Making one simulator serve both meant naming four places where the two
schedulers disagree, all previously invisible because only one was ever
exercised:

- `pulse_info` and `acquisition_info` are a list of dicts under quantify
  and a single dict under qblox;
- a subschedule is a `Schedule` with `.operations` under quantify and a
  `TimeableSchedule` carrying `operation_dict` in `.data` under qblox,
  nested several deep where quantify has one level;
- a drive amplitude is `G_amp` under quantify and `amplitude` under qblox;
- `QuantumDevice.edges` is a *method* under quantify and a *dict* under
  qblox, and a bias submodule's parameters are callables under one and
  plain values under the other. Both of those were found by the qblox
  coupler parking failing with "'dict' object is not callable" — caught,
  logged, and given up on, which is exactly the silent failure the
  catch-and-continue was meant to allow for a broken rack rather than for
  a bug.

Also fixes a latent fragility in both device loaders: elements were added
in file order, so an edge listed before either of its qubits failed to
load. Anything that rewrites the YAML alphabetically — `yaml.safe_dump`'s
default — produced a config that could no longer be read. Elements are
added before edges now, whatever the order.

And `make test` was red on macOS for an unrelated reason: uv sync
reinstalls qblox_instruments, macOS strips the signature from its
bundled q1asm assembler, and only three of the eight targets that sync
put it back. The step is one definition now and every syncing target uses
it.
test_calibration_loop is parametrised over quantify and qblox rather than
duplicated, so every claim it makes — the calibration finds the chip, the
executor plays what was written, virtual Z rotates the frame, a CZ
entangles, all three measurement levels agree — is proved for both. 23
tests became 46. Parametrising rather than copying is the point: a claim
proved for one scheduler and quietly untested for the other is exactly how
qblox came to be a stub.

Turning it on found four things, all of which meant the qblox calibrator
had never completed a calibration:

- The write-back walked `device.elements()` and `device.edges()`, which
  are methods under quantify and dicts under qblox. So it raised, the
  report came back partial_failure, and no qblox calibration had ever
  reached the device file.
- What it wrote, qblox could not read back. Edges were written with
  positional constructor arguments, and qblox's edges are pydantic models
  — pydantic takes no positional arguments at all. They are keyword now,
  which both schedulers accept.
- It also wrote `edge_type`, `parent_element_name` and
  `child_element_name` as if they were calibration. They are structural
  and already in `element_type`, and re-emitting them makes the loader
  *assign* to a discriminated field, which pydantic refuses. Same for
  `element_type` and `name`, which qblox carries as ordinary fields and
  which overwrote persistence's own class-path mapping with a bare string.
- Both loaders added elements in file order, so an edge listed before
  either of its qubits failed. Anything that rewrites the YAML
  alphabetically produced an unloadable config.

And `conditional_phase` applied nothing at all. It wrote
`cz.phase_correction`, a name *neither* scheduler has — quantify names
them after the qubits, qblox after the roles — behind a `hasattr` guard
that was therefore never true. The names are resolved from the edge now.

Fixing that exposed what the routine was missing rather than merely
mis-spelling: the two corrections cancel each qubit's *single-qubit*
phase, which is not the conditional phase and is not derivable from it.
The routine measures four fringes instead of two — a Ramsey on each qubit
with the other down and up — so both phases are measured rather than one
measured and the other assumed.

`make test-py-loop` and the CI sim job now install both schedulers;
installing one silently skipped half the file.
A failed job rendered nothing at all. The status badge went red and the
three result tabs each reported "No counts data available" — true, and
useless — while the reason the driver had already sent sat unread in the
record. Hunting through three empty tabs for an explanation that was never
going to be in any of them is worse than a wall of text.

The reason is shown in place of the tabs now, which are hidden because
none of them has anything to display. Compiler errors are the common case
and they are long: both schedulers attach a paragraph of guidance to a
one-line problem, and that paragraph is usually the fix. So the first line
leads and the rest is kept verbatim rather than truncated.

A job that failed with no reason at all says so, and points at the driver
logs, rather than looking like a job with no results.

The spec drives a real refusal rather than a contrived one — two virtual Z
gates with no time between them, which the NCO cannot do ("the minimum
time between phase updates must be 4 ns"). That is a failure a user hits
by generating circuits programmatically, and it now explains itself.
`SIDEBAND_GAP_GHZ` exists because this project has no coupler to measure,
which makes it the weakest number in the two-qubit tier. An edge can now
say what its own drive is meant to bridge — `clock_freqs.sideband_gap`,
alongside the `cz` frequency it is played *at* — and the simulator uses
that in place of the constant wherever it is set.

Keeping the two separate is the point. The frequency a drive is played at
and the transition it should hit are different facts, and a config that
disagrees with itself is then a detectable mistake rather than a
definition: declaring a gap 50 MHz from the drive detunes the gate and the
Bell state stops being one, which is what the new test asserts.

Only an uncharacterised coupler falls back to the constant. Both
schedulers carry the parameter.

Also records the turn in the CHANGELOG and RFC 0004 §7, including that
this tier now runs under both schedulers rather than one — the arrangement
that would have caught the qblox write-back years earlier than it did.
The Unreleased section had grown to 235 lines — longer than all of 0.2.0 —
because each entry explained the mechanism of a fix rather than its
effect. One block ran to 881 words. That is a postmortem, and it was
already written in the commit messages and RFC 0004 §7, so the changelog
was the third copy and the least useful place to read it.

Condensed to the convention the rest of the file already follows: prose
where a migration needs guiding, one-line bullets under Added, Fixed and
Changed. 140 lines out, 75 in.

Nothing user-facing dropped — what went was the diagnostic narrative
(`device_elements`, `partial_failure`, how check_docs is wired). The
chevron and conditional-phase fits nearly went with it and are back as
bullets; they are real fixes, not commentary.
RFC 0004 §11 is the record of what is built and what is not, and it
predated the qblox work entirely. Four more faults belong in it, all found
by running the same tests under both schedulers rather than one:

- the qblox tuner had never completed a calibration — four separate faults
  in the write-back and the loaders, each alone fatal;
- `conditional_phase` applied nothing on either scheduler, writing a name
  neither has behind a guard that was therefore never true;
- `drag` swept a seconds-valued parameter over ±1.0, so its schedule could
  not compile at all;
- the qblox tier-2 test never compiled anything, which is why it did not
  catch the one above.

Their common cause is worth naming rather than leaving as four accidents:
every one sat in *shared* code that quantify happened to satisfy. Two were
also encoded as expectations — the tier-1 conditional-phase test asserted
the bug, and the tier-2 drag sweep carried the same wrong scale as the
routine. A test written from the code cannot contradict it.

Also corrects claims that had gone stale in the same move: the catalog and
the tuners README both said `is_simulated` was quantify-only. And §11's
"not implemented" list grows two honest entries — no coupler here has been
characterised, and the SPI and QCM bias paths have never touched hardware.
`CZChevron.apply` wrote `cz.amp` and `cz.duration`. Both schedulers spell
them `square_amp` and `square_duration`, so the write raised on every real
device — the routine measured the operating point correctly and then failed
at its last step, and `conditional_phase` never ran at all because it
depends on this one. Two-qubit calibration could not complete on hardware.

Neither existing tier could catch it. Tier 2 builds and compiles a schedule
without ever calling `apply`. Tier 3's fake device was given `cz.amp` and
`cz.duration` — the names the routine used — so it agreed with the routine
rather than with any device. That is the third time in this branch a fake
shaped to the code has hidden a fault in the code, after the tier-1
conditional-phase test asserting `np.pi - crossing` and the tier-2 drag
sweep carrying the routine's own wrong units.

So the fake now carries the names a real `CompositeSquareEdge` has, and
there is a test that calls `apply` against a real `QuantumDevice` under both
schedulers and reads the values back — which is the only arrangement that
could have caught any of the three.
Three faults in this branch were hidden by the same thing: a double that
agreed with the code. `cz_chevron` wrote `cz.amp`, a name no device has;
the tier-1 conditional-phase test asserted `np.pi - crossing`, the bug
itself; the tier-2 drag sweep carried the routine's own wrong units. Tier 2
compiles a schedule and never calls `apply`. Tier 3's fake device was given
the names the routine used.

So the loop test now calibrates *every* routine the simulated chip can
answer — through the shipped tuner, over a real `QuantumDevice` loaded from
YAML, with nothing replaced but the instrument — and requires the report to
come back `success` with no errors. Both schedulers, 23 routine-target
results each, up from three routines.

Getting there found three more faults, each of the same shape — a routine
that measured correctly and then produced something unusable:

- `fit_chevron` refines the CZ duration between its setpoints, so it
  reported sub-nanosecond precision. The instrument plays on a 1 ns grid, so
  writing it made every *later* schedule containing that CZ fail to compile,
  well away from the routine that caused it. Rounded on apply now.
- A flux pulse on a single qubit's port was treated as a two-qubit gate, so
  `flux_spectroscopy` — which holds a DC bias on one qubit to move its
  frequency — failed complaining about a partner it never mentioned. A flux
  pulse whose gate names no second qubit is a frequency shift.
- An edge routine needs *both* its qubits calibrated. With only q0 targeted,
  X on q1 did nothing, so the chevron prepared |10> rather than |11> and saw
  no exchange to find. The config now targets both; whether the DAG should
  refuse an edge whose qubits are not targeted is left open.

Three routines stay disabled, named in `UNSIMULATED` with reasons rather
than left out silently: the two resonator routines, because the coordinator's
readout is two blobs with no resonator behind them — and calibrating a
readout frequency the hardware config's fixed LO+IF cannot reach makes every
later schedule fail to compile; and `drag`, because the coordinator ignores
a pulse's DRAG parameter, so beta has no effect and the fit has nothing to
find. Each is a gap in the simulator, not in the routine.
The whole calibration DAG now runs against the simulator. Three routines
were excluded before, each because the simulator had no physics to answer
them with, and closing those two gaps found five faults in shipped code —
all of the shape this branch keeps producing: a routine that measures
correctly and then writes something unusable, or nothing at all.

**A readout resonator** (`simulation/resonator.py`). The readout was two
fixed points in the IQ plane: enough to tell |0> from |1>, and a flat line
for the two routines whose subject *is* the readout chain. It now has a
Lorentzian response with a linewidth to find and a power at which the
resonance walks from dressed to bare. `resonator_spectroscopy` recovers the
modelled kappa (1.96 MHz fitted against 2.00) and reading out away from
resonance costs contrast, which is what makes the frequency load-bearing
downstream rather than decorative.

**Pulse envelopes, and the derivative that rides on them.** A shaped pulse
is integrated in steps instead of averaged to a constant amplitude. That
averaging was precisely why the Motzoi parameter did nothing: the DRAG
correction *is* the derivative of the envelope, which averages to zero. The
leakage it cancels comes from the same three-level ladder that produces it,
so the optimum is found rather than assumed — 0.113 as a ratio, against the
-1/(2*alpha) theory gives for this transmon. Converged to five figures
against eight times the step resolution; the envelope is normalised to unit
mean so `amp180` still means what it meant, and a propagator cache makes the
loop suite faster than before rather than slower.

What that surfaced, all fixed:

- **The DRAG parameter is not the same quantity in the two schedulers.**
  quantify's `motzoi` is the dimensionless ratio of derivative to Gaussian;
  qblox's `beta` is that ratio times the pulse sigma, so in seconds — 2.5 ns
  apart for a 20 ns gate. One constant sweep served both, so it was nine
  orders out for whichever it was not written for, and out in the large
  direction the derivative exceeds full scale and nothing compiles.
  `SchedulerBackend.drag_span` gives each backend its own default; both now
  recover the same physical optimum to four figures. The previous fix to this
  routine, and the note in RFC 0004 saying the parameter "is in seconds",
  were right about qblox and wrong about quantify.
- **`drag` wrote to a parameter qblox does not have.** `rxy.motzoi`
  unconditionally, where qblox's transmon has `rxy.beta` — an optimum
  measured correctly with nowhere to put it. `drag_parameter_name` asks the
  element, as `phase_correction_names` already did for the edge.
- **`fit_chevron` calibrated a population swap and called it a CZ.** It
  walked to the first trough and on to the first sample that stopped rising.
  Right description of the experiment, wrong way to measure it: the trough is
  not smooth, because the exchange beats against the transitions the flux
  pulse sits near, so the walk stopped at the top of a wiggle a few per cent
  deep and still deep in |02> — 55 ns for a round trip of 110. A complete
  transfer is a perfectly good gate and the wrong one. The return is a level
  crossing now. Found only once the DAG ran far enough for
  `conditional_phase` to consume the answer, where it surfaced as a flat
  fringe two routines downstream.
- **The readout could be calibrated once and never again.** The hardware
  fixture pinned each readout port's IF and let the LO float, so three qubits
  sharing one QRM_RF agreed on an LO only because their configured
  frequencies happened to. Move one by a kilohertz and the module is asked
  for two LOs, and every *later* schedule fails to compile. The lab's own
  config pins the LO and lets each IF differ, which is what makes a readout
  frequency recalibratable at all.
- **`resonator_punchout` left the readout pointing where the resonator used
  to be.** A frequency and a power are not independent numbers: the resonance
  moves *because* the power changed, which is the entire content of punchout.
  So choosing a new power invalidated the frequency spectroscopy measured at
  the old one, and nothing revisited it — half a linewidth off, a fifth of
  the readout contrast, for every routine downstream. Punchout writes both
  now, and needed no extra measurement: it already fits a spectrum at every
  power in its range, so the row at the power it selects *is* the corrected
  frequency. It was being thrown away.

Two test faults worth naming, since both were passing:

- `qubit_spectroscopy` at the routine's 1% drive is a signal-to-noise of
  about five. The same sweep returned centres 14 MHz low, 12 MHz high and
  62 MHz low on nothing but noise, and the full-DAG test had been passing on
  that margin. Its sweep is also centred above the fixture's claim now: the
  qubit is 214 MHz *above* it, so a symmetric span left the line 6 MHz inside
  the boundary, where a Lorentzian's centre is essentially unconstrained.
- The full-DAG test asserted the CZ duration was a whole number of
  nanoseconds and never that it was the right number, which is how a 55 ns
  swap passed. It checks the round trip against the coupled model now.

The driver e2e's QPU-seconds check read its baseline nine API calls before
the approval it was measuring, so a job settling in between turned
"approving 300 seconds credits 300 seconds" into an assertion that nothing
else happened meanwhile. It reads the baseline where it means to.

`test_calibration_loop` runs all sixteen routines over two qubits and the
edge between them, under both schedulers, and requires the report to come
back `success` having skipped none — the equality matters more than the
success, since a routine quietly excluded is indistinguishable from one that
works. CI already runs it in the `sim` matrix entry.
Two gaps left by the previous commit, both in RFC 0004.

**§7 never mentioned the readout resonator or the pulse envelopes.** They were
described in §11, which is a narrative of what was built; §7 is where the
simulator's claims and the limits on them belong, and a caveat is no use to a
reader in the section that does not list caveats. Both now sit in tier 3
alongside the two-qubit paragraph they rhyme with, including the one that
matters: the readout is not a dispersive model. The two qubit states do not pull
the resonance to two frequencies, so the best point to read out at here is the
resonance itself, where a real chip has an optimum between the two pulled peaks
— a calibration this simulator cannot pose. §11 keeps two lines and points at §7.

**§9's automated-test list omitted `make test-py-loop`**, which is the target
covering most of what this RFC claims and the only evidence that a fitted number
survives the fit, the write-back, the YAML, the loader and the compiler with its
meaning intact. `test-py-cli` and the two e2e targets were missing too, and the
note that CI runs all of them — including why the matrix carries a `sim` entry
naming no executor, which is what stopped the loop tests from silently never
running there.

The tier-3 rows of the test-file table were also stale: the physics tests now
cover the resonator's own lineshape and punchout curve, and the loop test runs
the whole DAG rather than three routines.

No behaviour change. The status line still reads correctly — what §7 and §9
reserve is now the coupler's five chosen constants, the readout simplification
above, and the whole of the manual hardware verification, none of which has run
on a lab node.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant