Skip to content
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

A [QICK](https://github.com/openquantumhardware/qick) hardware backend for
[Intonato](https://github.com/harmoniqs/Intonato.jl)'s closed-loop quantum
optimal control (QILC). Bridges a Intonato `PulseTuningProblem` to a QICK RFSoC
board via [PythonCall](https://github.com/JuliaPy/PythonCall.jl).
optimal control. Bridges a Intonato `PulseTuningProblem` to a QICK RFSoC
board via [PythonCall](https://github.com/JuliaPy/PythonCall.jl). Part of the
Piccolo × QICK toolchain.

## What it provides

Expand All @@ -12,7 +13,8 @@ board via [PythonCall](https://github.com/JuliaPy/PythonCall.jl).
over an abstract `AbstractQickSoc`.
- **`MockQickSoc`** — a pure-Julia "board" that simulates execution by rolling
the played pulse through a known `QuantumSystem` and emitting synthetic IQ. The
whole QILC→QICK loop runs and is tested with **no Python and no hardware**.
whole closed-loop calibration → QICK loop runs and is tested with **no Python
and no hardware**.
- **`PyQickSoc`** — the real board, reached over PythonCall with a **lazy `qick`
import** (only on a board; never touched off-board or in CI). Some methods are
hardware stubs, finalized with the QICK collaboration.
Expand All @@ -22,6 +24,15 @@ board via [PythonCall](https://github.com/JuliaPy/PythonCall.jl).
- **`QickExperiment(backend; measurement_model)`** → a Intonato
`HardwareExperiment` you drop straight into `PulseTuningProblem`.

## Closed-loop calibration on hardware

`QickBackend` is the hardware **chassis** — it moves pulses to the board and IQ
back. The concrete **closed-loop calibration strategy** that drives it (the thing
that actually tunes your gates against the live device) is provided by
**Intonatissimo**, Harmoniqs's premium calibration package (partner-licensed). If
you're calibrating on real QICK hardware and want the closed-loop strategy rather
than rolling your own, [get in touch](https://harmoniqs.co).

## Usage (mock)

```julia
Expand All @@ -35,7 +46,7 @@ map = QickChannelMap([QickGenChannel(0, 5e9; i_drive = 1)]; n_drives = 1)
model = MeasurementModel(:ψ̃, [populations], [N])
qexp = QickExperiment(QickBackend(soc, map, [N]); measurement_model = model)

# Plug into Intonato's QILC chassis (a concrete tuning strategy is supplied separately):
# Plug into Intonato's closed-loop calibration chassis (a concrete tuning strategy is supplied separately):
ptp = PulseTuningProblem(qcp, qexp, model; R_tr = (u = 0.1,), Q_meas = 10.0)
solve!(ptp; max_iter = 10)
```
Expand All @@ -44,8 +55,9 @@ Swap `MockQickSoc` for `PyQickSoc(; dac_rate, adc_rate)` on a real QICK board.

## Data-provenance note

Intonato's `ExperimentRecord` logging is **not** triggered inside the QILC
chassis loop today (the chassis calls `run_experiment` with no logger, and the
Intonato's `ExperimentRecord` logging is **not** triggered inside the
closed-loop calibration chassis loop today (the chassis calls `run_experiment`
with no logger, and the
record's `raw` field is hardcoded `nothing`). So `QickBackend` stashes the most
recent raw IQ in `backend.last_raw`, and a *manual*
`run_experiment(qexp, pulse; logger=…)` logs measurement-level records. Full
Expand Down
5 changes: 4 additions & 1 deletion src/IntonatoQICK.jl
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ include("py_soc.jl")
# ──── Experiment factory ─────────────────────────────────────────────────────
include("experiment.jl")

# ──── Integration tests (mock QILC→QICK loop) ────────────────────────────────
# ──── Integration tests (mock closed-loop calibration → QICK loop) ───────────
include("integration_test.jl")

# ──── Public name-scrub guard (§2.3, §5.5) ───────────────────────────────────
include("name_scrub.jl")

# ──── Exports ────────────────────────────────────────────────────────────────
export AbstractQickSoc, MockQickSoc, PyQickSoc
export load_envelope!, play_program!, acquire, dac_rate, adc_rate
Expand Down
23 changes: 15 additions & 8 deletions src/backend.jl
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# QickBackend — the AbstractHardwareBackend over an AbstractQickSoc. Implements
# the documented hardware interface (upload_pulse! / trigger! / readout /
# sample_rate). The QILC chassis never calls these directly; the QickExperiment
# `run` closure (experiment.jl) does, once per experiment evaluation.
# sample_rate). The closed-loop calibration chassis never calls these directly;
# the QickExperiment `run` closure (experiment.jl) does, once per evaluation.

"""
QickBackend(soc, channel_map, indices; discriminator = b -> real.(b))
Expand All @@ -11,9 +11,9 @@ knot indices (into `1:N`) the readout produces; `discriminator` maps one IQ blob
to a data vector (default: real part, matching `MockQickSoc`'s populations
forward model). `last_raw` holds the most recent raw readout.

Note: under line search the QILC chassis evaluates the experiment several times
per outer iteration, so `last_raw` reflects the *last probe*, not necessarily the
accepted iterate.
Note: under line search the closed-loop calibration chassis evaluates the
experiment several times per outer iteration, so `last_raw` reflects the *last
probe*, not necessarily the accepted iterate.
"""
mutable struct QickBackend{S<:AbstractQickSoc} <: AbstractHardwareBackend
soc::S
Expand Down Expand Up @@ -43,8 +43,13 @@ function trigger!(b::QickBackend)
return nothing
end

function readout(b::QickBackend)
raw = acquire(b.soc, b.channel_map.readout_chs)
# Returns RAW multiplexed IQ (channel-major `raw[ch][k]`) and stashes it in
# `last_raw`; the reduction to measurements is done by the QickExperiment closure
# via `reduce_readout` (readout stays raw — the boundary invariant). `kind` is
# forwarded to the board so it performs the right measurement (`:iq` / `:wigner`
# / `:tomography_1q`); the mock ignores it.
function readout(b::QickBackend; kind::Symbol = :iq)
raw = acquire(b.soc, b.channel_map.readout_chs; kind = kind)
b.last_raw = raw
return raw
end
Expand All @@ -67,6 +72,8 @@ sample_rate(b::QickBackend) = dac_rate(b.soc)
IntonatoQICK.trigger!(b)
raw = IntonatoQICK.readout(b)
@test b.last_raw === raw
@test sum(real.(raw[1])) ≈ 1.0 atol=1e-6
# channel-major raw[ch][k]: one readout channel, one knot (final).
@test length(raw) == 1
@test sum(real.(raw[1][1])) ≈ 1.0 atol=1e-6
@test IntonatoQICK.sample_rate(b) == 20.0
end
29 changes: 21 additions & 8 deletions src/experiment.jl
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
# QickExperiment — wraps a QickBackend as an Intonato `HardwareExperiment`, the
# seam the QILC `PulseTuningProblem` chassis consumes. The `run` closure chains
# seam the closed-loop calibration `PulseTuningProblem` chassis consumes. The `run` closure chains
# translate→upload→trigger→readout→discriminate into a `Vector{Measurement}`.

"""
QickExperiment(backend; measurement_model) → HardwareExperiment
QickExperiment(backend; measurement_model, kind=:iq) → HardwareExperiment

Build a `HardwareExperiment` whose `run(pulse)` uploads/plays `pulse` on
`backend`'s SoC and discriminates the readout into measurements. The
`measurement_model` annotates logged `ExperimentRecord`s with true provenance
(rather than the identity placeholder); its indices must match `backend.indices`.
`backend`'s SoC and reduces the readout into measurements. The `measurement_model`
annotates logged `ExperimentRecord`s with true provenance (rather than the
identity placeholder); its indices must match `backend.indices`.

`kind` selects the readout behind the single `readout` verb (`:iq` | `:wigner` |
`:tomography_1q`, [`reduce_readout`](@ref)). It is forwarded to the board so it
performs the right measurement, and used to reduce the raw acquisition. `:iq`
reduces Julia-side via `backend.discriminator`; `:wigner` / `:tomography_1q` are
reduced board-side and passed through (boundary invariant, C7). An unrecognized
kind is a typed error, never a crash (§5.7).

Plug the result into `PulseTuningProblem(qcp, qick_exp, model; …)`.
"""
function QickExperiment(backend::QickBackend; measurement_model::MeasurementModel)
function QickExperiment(backend::QickBackend; measurement_model::MeasurementModel,
kind::Symbol = :iq)
if !(kind in READOUT_KINDS)
known = join(READOUT_KINDS, ", ")
error("QickExperiment: unknown readout kind :$kind (recognized: $known)")
end
run = pulse -> begin
upload_pulse!(backend, pulse)
trigger!(backend)
raw = readout(backend)
iq_to_measurements(raw, backend.discriminator, backend.indices)
raw = readout(backend; kind = kind) # raw IQ; the board measures per-kind
reduce_readout(kind, raw, backend.discriminator, backend.indices)
end
return HardwareExperiment(run, measurement_model)
end
Expand Down
11 changes: 6 additions & 5 deletions src/integration_test.jl
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Integration tests for the mock QILC→QICK loop. These validate (1) the
# pulse→envelope→rollout translation is faithful (converges as the DAC rate
# rises) and (2) the QickExperiment composes with the Intonato
# Integration tests for the mock closed-loop calibration → QICK loop. These
# validate (1) the pulse→envelope→rollout translation is faithful (converges as
# the DAC rate rises) and (2) the QickExperiment composes with the Intonato
# `PulseTuningProblem` chassis end-to-end.
#
# NOTE: algorithmic *convergence* of QILC through the QICK seam needs a concrete
# tuning strategy, which ships in a separate private package. To keep
# NOTE: algorithmic *convergence* of the closed-loop calibration through the QICK
# seam needs a concrete tuning strategy, which ships in a separate private
# package. To keep
# IntonatoQICK public (Intonato-only dep), the chassis test here uses the public
# no-op `IdentityStrategy` and asserts the loop *runs* through the seam.
# Convergence-through-QICK belongs in a private/demo integration test.
Expand Down
43 changes: 30 additions & 13 deletions src/mock_soc.jl
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
# played pulse through a known `QuantumSystem` (Intonato's own `rollout`, via a
# `SimulatedExperiment`) and emitting synthetic IQ. The forward model is explicit:
# state → IQ blob = measurement_fn(state) packed as a real-valued complex vector,
# which the trivial discriminator `real` inverts EXACTLY. So a QILC loop run
# through `QickBackend{MockQickSoc}` reproduces the same measurements a direct
# `SimulatedExperiment` would — the loop is validated without a board.
# which the trivial discriminator `real` inverts EXACTLY. So a closed-loop
# calibration run through `QickBackend{MockQickSoc}` reproduces the same
# measurements a direct `SimulatedExperiment` would — validated without a board.
#
# The user passes the "true" (optionally mismatched) system as the mock's system;
# the nominal QCP is solved against the nominal system separately.
Expand Down Expand Up @@ -44,7 +44,7 @@ load_envelope!(soc::MockQickSoc, gen_ch::Int, idata, qdata) =

play_program!(soc::MockQickSoc, program::QickProgram) = (soc._program = program; nothing)

function acquire(soc::MockQickSoc, _ro_chs)
function acquire(soc::MockQickSoc, ro_chs; kind::Symbol = :iq)
prog = soc._program
prog === nothing && error("MockQickSoc.acquire: no program played")
# Reconstruct the drive controls from the loaded per-channel envelopes.
Expand All @@ -60,31 +60,48 @@ function acquire(soc::MockQickSoc, _ro_chs)
model = MeasurementModel(:ψ̃, [soc.measurement_fn for _ in prog.indices], prog.indices)
exp = SimulatedExperiment(KetTrajectory(soc.system, recon, soc.ψ_init, soc.ψ_goal), model)
ms = run_experiment(exp, recon)
# Forward model state→IQ: pack each measurement's data as a complex blob.
return [ComplexF64.(m.data) for m in ms]
# Forward model state→IQ: pack each measurement's data as a complex blob,
# one blob per knot index.
per_knot = [ComplexF64.(m.data) for m in ms]
# Multiplexed, channel-major shape raw[ch][k]: one per-knot blob list per
# requested readout channel (`length(raw) == length(ro_chs)`). The mock's
# forward model is state-based, so every channel sees the same synthetic
# readout — enough to exercise the multiplexed IQ *shape* without a board.
# `kind` is accepted for interface parity (the board picks the measurement);
# the mock's synthetic IQ is kind-agnostic — real Wigner / tomography physics
# is board-side (expt_service), an accepted mock/real divergence.
return [copy(per_knot) for _ in ro_chs]
end

@testitem "MockQickSoc round-trips a pulse to valid populations IQ" begin
@testitem "MockQickSoc round-trips a pulse to valid multiplexed populations IQ" begin
using IntonatoQICK
using LinearAlgebra
σx = ComplexF64[0 1; 1 0]; σz = ComplexF64[1 0; 0 -1]
sys = QuantumSystem(1.0 * σz, [σx], [1.0])
N = 11; T = 5.0
times = collect(range(0.0, T, length=N))
pulse = LinearSplinePulse(0.1 .* randn(1, N), times)
map = QickChannelMap([QickGenChannel(0, 5e9; i_drive=1)]; n_drives=1)
# Two multiplexed readout channels.
map = QickChannelMap([QickGenChannel(0, 5e9; i_drive=1)]; readout_chs=[0, 1], n_drives=1)

soc = MockQickSoc(sys, ComplexF64[1, 0], ComplexF64[0, 1]; dac_rate=20.0)
prog = pulse_to_envelopes(pulse, map, dac_rate(soc), [N])
for (gen_ch, (idata, qdata)) in prog.envelopes
load_envelope!(soc, gen_ch, idata, qdata)
end
play_program!(soc, prog)
raw = acquire(soc, [0])
raw = acquire(soc, prog.readout_routing)

@test length(raw) == 1 # one measurement (final knot)
pops = real.(raw[1])
@test length(pops) == 2 # dim-2 populations
@test sum(pops) ≈ 1.0 atol=1e-6 # valid probability vector
# Channel-major raw[ch][k]: one entry per readout channel.
@test length(raw) == 2
@test all(length(raw[ch]) == 1 for ch in 1:2) # one knot (final) each
pops = real.(raw[1][1])
@test length(pops) == 2 # dim-2 populations
@test sum(pops) ≈ 1.0 atol=1e-6 # valid probability vector
@test all(pops .≥ -1e-9)
@test real.(raw[2][1]) ≈ pops # mock replicates across channels

# Single-channel round-trip still reduces to one blob list.
raw1 = acquire(soc, [0])
@test length(raw1) == 1 && length(raw1[1]) == 1
end
34 changes: 34 additions & 0 deletions src/name_scrub.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Name-scrub guard (Spec B §2.3, §5.5). IntonatoQICK is a PUBLIC package. What it
# reveals is split deliberately (Aaron 2026-07-07):
# - ALLOWED (funnel): the product NAME "Intonatissimo" + the capability phrase
# "closed-loop calibration" — an intentional wink/nod advertising the premium
# package at the point of need to drive licensing. NOT banned here.
# - BANNED (private METHOD): the strategy type and the bare method acronym.
# Gating controls *who can run* the premium node (package-entitlement); this guard
# controls *what public source reveals of the METHOD* — a code-hygiene concern.
#
# The banned terms are assembled from fragments at runtime so THIS guard file
# never itself contains a literal banned term (which would trip its own check).
# Extend this list, never shrink it — but the product NAME stays OUT of it (funnel).

@testitem "public source is name-scrubbed of the private METHOD (not the product name)" begin
using IntonatoQICK
# Assemble the scrub list from fragments (kept split in source on purpose).
# The product name "Intonatissimo" is INTENTIONALLY absent — it is the funnel.
banned = [
"ilc" * "strategy", # the private strategy type
"qi" * "lc", # the bare private method acronym ("NO … public")
]
srcdir = joinpath(pkgdir(IntonatoQICK), "src")
offenders = String[]
for (root, _, files) in walkdir(srcdir)
for f in files
endswith(f, ".jl") || continue
text = lowercase(read(joinpath(root, f), String))
for b in banned
occursin(b, text) && push!(offenders, "$(f): contains a banned term")
end
end
end
@test isempty(offenders)
end
Loading
Loading