Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions packages/extension/skills/intonatissimo/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
---
name: intonatissimo
description: Intonatissimo.jl usage — the entitled calibration tier. Measurement-matching objectives, NLP subproblem construction/updates, and the public-API ILC loop over Intonato's PulseTuningProblem chassis. Use when authoring a closed-loop pulse-tuning solve.jl in an Intonatissimo-enabled environment.
surface: entitled
entitlement: issimo
---

# Intonatissimo — usage guide

Intonatissimo is the entitled calibration tier: the measurement-matching
objective family, the control/calibration subproblem constructors, and the ILC
tuning strategy, all riding Intonato's public `PulseTuningProblem` loop. One
import reaches everything — `using Intonatissimo` re-exports the Intonato
chassis (which re-exports Piccolo), so `PulseTuningProblem`, `solve!`,
`Measurement`, `Learn` and friends are all in scope.

> **Delivery mode (read first).** Intonatissimo requires the **issimo
> entitlement**. It is delivered either as the **HP-mode prebuilt sysimage** or
> via a **private-registry sandbox environment** — it is **NOT in the default
> provisioned solve env**. Recipes in this skill run only in those environments;
> smoke-test signatures in the actual environment before launching.

## Measurement-matching objective

`MeasurementMatchingObjective(model, y_target, traj; Q = 1.0, weights = nothing)`
builds a `KnotPointObjective` whose loss at each measurement knot is
$\sum_i \big(w_i (g_i(x) - y_i)\big)^2$ — the objective that matches simulated
measurements to a target vector inside an NLP.

```julia
model = MeasurementModel(state_name(qtraj), [pop(), pop(), pop()], [1, 25, 50])
kpo, params = MeasurementMatchingObjective(model, y_target, traj; weights = w_vec)
```

- Returns **two** values: the objective (compose it like any other
`AbstractObjective`) and a `Vector{MeasurementParam}` — **keep the params**.
- `MeasurementParam` is a mutable shared-reference container for one measurement
point's target and weights. Mutate through the references between outer
iterations; rebuilding the objective allocates and loses warm-start structure.
- `update_targets!(params, y_target)` — retarget in place. `y_target` is a
`Vector{Measurement}` (a `Measurement` wraps one measurement vector plus its
knot index).
- `update_weights!(params, w_flat)` — write the flattened per-element effective
weights in model element order (measurement-major), in place.
- `weights` are per-measurement task-importance vectors. The legacy `Q` /
`Q_meas` kwargs are deprecated aliases that fold into task weights — pass
`weights`.

## Subproblem family

The subproblem constructors build the per-iteration control (and calibration)
NLPs that reuse the original problem's dynamics and constraints:

| Call | Role |
|---|---|
| `build_subproblem(qcp, model, y_target; R_tr, R_θ, Q_meas)` | Control NLP: measurement matching + per-component trust-region weights (`R_tr`, e.g. `(u = 1e-2, du = 1e-1)`) + global-variable regularizers (`R_θ`, e.g. `(ω = 1.0,)`) → `SubproblemHandle` |
| `build_subproblem(qcp, model, y_target; include_original_objective = true)` | Same, with the QCP's original objective (including infidelity) composed in — use when measurement-matching alone doesn't drive fidelity |
| `build_calibration_subproblem(qcp, model, y_exp; R_θ, Q_cal)` | Calibration NLP: matches **raw** experiment data `y_exp` (not ILC-shifted targets), states free, everything else pinned |
| `update_subproblem!(handle, y_target, z_ref[, model])` | In-place iteration step: retarget, recenter trust-region baselines on `z_ref`, warm-start the trajectory; the 4-arg form also refreshes precision weights at the current prediction |
| `update_pinned_values!(handle, z_ref)` | Refresh a calibration subproblem's pin values to the new reference trajectory |
| `make_global_regularizer(name, traj, R)` | Quadratic penalty $(R/2)\|\theta - \text{baseline}\|^2$ on one global variable — compose your own `R_θ` set if the constructors' NamedTuple form doesn't express it |

The discipline that makes this fast: **build once, update in place**. The
`SubproblemHandle` holds shared references to the objective's mutable parameters, so
`update_subproblem!` touches only targets, baselines, and the warm start —
no reconstruction between outer iterations.

```julia
handle = build_subproblem(qcp, model, y_target; R_tr = (u = 1e-2, du = 1e-1))
solve!(handle.prob; max_iter = 200)
update_subproblem!(handle, y_target_next, z_ref) # next outer iteration, same problem
```

## Driving the public ILC loop

The loop chassis is Intonato's `PulseTuningProblem`: it wraps a solved
`QuantumControlProblem`, an experiment, and a measurement model, and runs the
outer propose → run experiment → retarget loop. The strategy kwarg selects the
inner step — pass an `ILCStrategy` to get Intonatissimo's ILC.

```julia
using Intonatissimo

mm = MeasurementModel(state_name(qtraj), [pop(), pop(), pop()], [1, 25, 50])
expmt = SimulatedExperiment(qtraj_true, mm; rng = MersenneTwister(0)) # or a HardwareExperiment

ptp = PulseTuningProblem(qcp, expmt, mm; strategy = ILCStrategy())
solve!(ptp; max_iter = 5, tol = 1e-3, γ = 0.8, min_nominal_fidelity = 0.8)
# ptp.qcp is updated in place with the tuned result
```

- **Plain ILC** is the bare `ILCStrategy()` — no parameter calibration.
- `solve!` kwargs: `max_iter` (outer iterations, default 5), `tol` (measurement
error convergence, default 1e-3), `γ` (trust-region schedule factor, default
0.8), `line_search` (Armijo backtracking, default `true`), `ipopt_options`
(forwarded to the inner NLP solve), `max_rejections` (early stop after n
consecutive line-search rejections), `polyak_avg` (average the last n
iterates before syncing), `min_nominal_fidelity` (refuses to run on a
poorly-converged pulse; set 0 to skip).
- The goal measurements are resolved **once** at solve start and fixed for the
whole solve — pass `y_goal = [...]` to `PulseTuningProblem` to supply them
explicitly.
- The device model the loop plans against defaults to the nominal model of the
QCP's system; pass `device_model = ...` to plan against something predictive
(e.g. a parametric-Hamiltonian sysid model) instead.

### Calibration: learnable device parameters

`ILCStrategy(mode; kwargs...)` adds parameter calibration on top of plain ILC.
`mode` is `Alternating()` (a separate calibration NLP before each control NLP —
the robust default when controls outnumber measurements) or `Joint()`
(parameters and controls co-optimized in one NLP — use under strong measurement
coverage). Learnable parameters are declared once as `Learn` values keyed by
system-global name; each `Learn`'s `weight` becomes that parameter's
regularization strength, and the constructor validates the QCP against the
declarations before any device time is spent.

```julia
strat = ILCStrategy(Alternating();
learn = (ω = Learn(ω_nom; bounds = (0.5ω_nom, 2.0ω_nom), weight = 5.0),),
Q_cal = 1.0, # calibration-NLP measurement weight
pin_Δt = true, # pin per-knot Δt across outer iterations
cal_eval_hessian = false, # calibration NLP on Ipopt L-BFGS (the fast default)
)
ptp = PulseTuningProblem(qcp, expmt, mm; strategy = strat)
solve!(ptp; max_iter = 5)
```

- `Learn(init; bounds = (lo, hi), weight = 1.0)` — `bounds` is required (an
unbounded learnable global is the failure mode this declaration exists to
prevent); `Learn(; bounds = ...)` starts from the nominal value.
- A mode object with an empty `learn` set errors — bare `ILCStrategy()` is the
plain-ILC form.
- `include_fidelity_in_u_nlp = true` also keeps the QCP's original objective in
the control NLP, driving fidelity directly when measurement-matching has
nontrivial kernel directions.
- The feedforward step itself is one call if you drive targets by hand:
`y_target = compute_ilc_targets(y_goal, y_model, y_exp)` — element-wise
$y_\text{goal} + y_\text{model} - y_\text{exp}$, pre-compensating the
estimated model error $\hat\varepsilon = y_\text{exp} - y_\text{model}$.

## Error channels (call level)

For updates confined to a labelled set of dissipative error channels, the
public surface is four calls:

```julia
chans = [ErrorChannel(:T1_12, L_t1, 1e-3), ErrorChannel(:dephase, L_phi, 2e-4)]
sub = error_channel_subspace(integrator, traj, chans) # labelled gradient directions + orthonormal span
δx_p = project_step(δx, sub) # project a proposed step onto the span
c = capture(sub, direction) # fraction of the direction's energy the span explains
```

- `ErrorChannel(label::Symbol, L, γ)` — jump operator `L`, rate `γ > 0`, and a
label (e.g. `:T1_12`, `:leakage_2`, `:dephase`) that rides through for
attribution.
- `error_channel_subspace(integ, traj, channels; P_enc = nothing)` — one
normalized gradient direction per channel plus the orthonormal span. The
result carries an honest rank audit: structurally dependent channel sets
(e.g. two diagonal channels differing by a multiple of identity) are dropped
from the basis with a warning — read `size(sub.basis, 2)` for the working
dimension, never assume your channel count.
- `capture` is the diagnostic for "are these K channels enough to explain this
residual direction?" — a low value says the residual has components your
channel set doesn't span.

> **Environment pairing requirement.** The encoded-decay kernels this family
> consumes (the conjugation-frames / encoded-decay-rate kernels, imported from
> Piccolissimo as `conjugation_frames` and companions) are present only when
> your Piccolissimo environment is paired with the conjugation-kernels branch
> (pre-paired by your delivery contact) rather than the
> mainline checkout. If the pairing is wrong, `using Intonatissimo` fails at
> load with a missing-name error — re-pair the environment; the calls above do
> not change.
163 changes: 163 additions & 0 deletions packages/extension/skills/piccolissimo/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
---
name: piccolissimo
description: Piccolissimo.jl usage — the entitled fast-path tier for smooth-pulse problems. Spline-faithful integrators for Piccolo problems, adjoint robustness objectives, Gauss–Newton solver configuration, Magnus algorithm selection, and warm-start idioms. Use when authoring a solve.jl in a Piccolissimo-enabled environment.
surface: entitled
entitlement: issimo
---

# Piccolissimo — usage guide

Piccolissimo is the entitled fast-path tier for smooth-pulse problems: you author
Piccolo problems exactly as usual (`SplinePulseProblem`, `SmoothPulseProblem`,
`UnitaryTrajectory`, …) and plug in Piccolissimo's faster integrators, robustness
objectives, and solver configuration on top. `using Piccolo, Piccolissimo` reaches
both surfaces.

> **Delivery mode (read first).** Piccolissimo requires the **issimo entitlement**.
> It is delivered either as the **HP-mode prebuilt sysimage** or via a
> **private-registry sandbox environment** — it is **NOT in the default provisioned
> solve env**. Recipes in this skill run only in those environments; smoke-test
> signatures in the actual environment before launching.

## Selecting the spline integrator

Loading Piccolissimo registers a `SplineIntegrator` into Piccolo's integrator
registry. In direct problem construction you pass the integrator explicitly:

```julia
using Piccolo, Piccolissimo

sys = QuantumSystem(H_drift, H_drives, drive_bounds) # your model, as usual
pulse = CubicSplinePulse(u_init, du_init, times) # spline pulse
qtraj = UnitaryTrajectory(sys, pulse, U_goal)

N = 50
integrator = SplineIntegrator(qtraj, N) # spline-faithful dynamics
qcp = SplinePulseProblem(qtraj, N; integrator = integrator, Q = 100.0, du_bound = 10.0)

solve!(qcp; max_iter = 200, tol = 1e-8)
```

Three facts worth internalizing:

- **Piccolo deliberately has no `:spline` backend of its own.** Passing
`integrator_type = :spline` to `SplinePulseProblem` errors with instructions;
the only shipped Piccolo backend is `:pwc` (`BilinearIntegrator`), which models
the drive as piecewise-constant and ignores `:du` — a spline pulse optimized
against it is not the pulse its name promises.
- **`global_names` requires this integrator.** Optimizing global variables
(frequencies, couplings) with `SplinePulseProblem(qtraj, N; global_names = [...])`
errors unless you pass a globals-aware integrator — the spline integrator above,
constructed with `global_names = [:ω, ...]`.
- **Algorithm choice is a constructor kwarg.** `SplineIntegrator(qtraj, N;
alg = Tsit5Alg())` is the default; see the Magnus section below for the other
algorithms and when to reach for them.

On ket and multiket trajectories the constructor additionally accepts
`exact_hessian` (request the exact second-order sensitivity path where implemented)
and `use_ket_sensitivity` (ket-level sensitivity propagation). Some combinations
error at construction rather than silently falling back — if a requested algorithm
does not support the sensitivity mode you asked for, you will know immediately.

## Robustness objectives

Two public objectives make a pulse robust to parameter error, and one wrapper
composes them into a solvable problem:

- `AdjointRobustnessObjective` — operator-space adjoint sensitivity objective.
- `KetAdjointRobustnessObjective(integrator, error_operators, goal, traj; Q = 1.0)`
— ket-space variant, constructed from an exponential integrator over a
`KetTrajectory` (or its multiket sibling over `MultiKetTrajectory` with a vector
of goals).
- `RobustControlProblem(qcp; kwargs...)` — wraps an existing solved problem to
minimize error susceptibility subject to a fidelity floor. Deep-copies the
trajectory and constraints, composes the robustness term with the original
regularizers, and adds a `FinalUnitaryFidelityConstraint`-style floor — the same
composition pattern as `MinimumTimeProblem`.

```julia
# qcp: a solved unitary problem (spline- or bilinear-integrated)
rcp = RobustControlProblem(qcp;
error_operators = [E_detune, E_amp], # Hermitian error matrices
sys = sys, # REQUIRED when qcp integrates with SplineIntegrator
final_fidelity = 0.9999, # fidelity floor constraint
Q_robustness = 1.0, # weight on the robustness term
)
solve!(rcp; max_iter = 200)
```

`keep_infidelity_objective = true` keeps the original infidelity term in the
objective alongside robustness (default `false` — the floor constraint carries
fidelity). `error_operators` are the Hermitian matrices whose susceptibility you
want minimized (e.g. a detuning shift, an amplitude-scale error).

## Gauss–Newton solver configuration

The spline integrator's default second-order mode is Gauss–Newton. Configuration
guidance, all at the `solve!`/constructor level:

- **Iteration caps and tolerances** pass straight through `solve!` as Ipopt
options: `solve!(qcp; max_iter = 200, tol = 1e-8, constr_viol_tol = 1e-6,
acceptable_tol = 1e-6)`. `max_iter` 100–300 is the working band for spline
problems; tighten `tol` when you need the last digit of fidelity.
- **Linear-drive models** (affine drive coefficients — the standard transmon
bilinear form) are the GN path's home regime: keep the defaults.
- **Nonlinear-drive models** (models with drive coefficients like $|\alpha|^2$
— dispersive transmon-cavity) converge poorly on the GN spline path. Two
configuration escapes:
- `solve!(qcp; eval_hessian = false)` — switches Ipopt to L-BFGS, which
routinely fixes the stall on these models;
- or solve the same problem on the exponential integrator, whose second-order
path is exact for its piecewise-constant controls.
- **Ket/multiket problems** can pass `exact_hessian = true` to the
`SplineIntegrator` constructor to request the exact second-order sensitivity
path. The unitary path does not offer this flag.

## Magnus algorithms and substep sizing

The spline integrator's forward-propagation algorithm is a kwarg — these are the
choices and their accuracy knobs:

| Algorithm | Constructor | Accuracy knob |
|---|---|---|
| `Tsit5Alg()` (default) | `Tsit5Alg(; adaptive = true, tol = 1e-6, ode_h = 0.1)` | `tol` (adaptive) / `ode_h` (fixed) |
| `MagnusGL4Alg` | `MagnusGL4Alg(; n_steps = 10, tol = 1e-6)` | `n_steps` — steps per knot interval, the **sole** accuracy knob (`tol` is inert) |
| `MagnusAdapt4Alg` | `MagnusAdapt4Alg(; tol = 1e-6)` | `tol` — adaptive on unitary problems; on ket problems it routes to the fixed-step cell and `tol` is inert (a warning fires — control accuracy via `MagnusGL4Alg(n_steps = …)`) |
| `ChebyshevAlg` | `ChebyshevAlg(; n_sub = :auto, bracket = nothing, …)` | `n_sub = :auto` self-sizes each interval at construction and freezes after; ket-only, matrix-free |

Rule of thumb: keep the default `Tsit5Alg` for ordinary problems; reach for the
Magnus algorithms when stiffness, long gates, or large-$\|H\|$ regimes actually
demand them (e.g. `MagnusGL4Alg(n_steps ≈ 50)` for a deep Rydberg blockade, where
the default under-resolves and optimizer fidelity diverges from a fine re-rollout).
For simple bilinear single-qubit gates the plain path usually converges faster
than the Magnus variants.

For manual substep sizing of the fixed-step cells, two exported diagnostics do
the arithmetic for you: `suggest_n_sub(H_drift, H_drives, bracket, coeff!, Ψ0, Δt;
phase_budget = 2.0, dyn_tol = 1e-8, grad_tol = 1e-6)` returns a substep count for
one interval, and `expl_discretization_error(...; same kwargs, n_sub)` (positional) reports the
estimated discretization error at a given count. Sizing is frozen after
construction — a mid-solve re-size is never silently attempted.

## Warm-start idioms

The pulse round-trips through disk, and a solved problem rehydrates directly:

```julia
# after a solved run — extract and save the optimized pulse
pulse_v2 = CubicSplinePulse(get_trajectory(qcp)) # rehydrate from the solved trajectory
JLD2.jldsave("pulse.jld2"; pulse = pulse_v2, fidelity = 0.9998)

# next script — reload and warm-start
pulse = load_pulse("pulse.jld2") # returns the pulse (only the "pulse" key)
qtraj = UnitaryTrajectory(sys, pulse, U_goal)
qcp = SplinePulseProblem(qtraj) # native knot times — best for warm-starting
solve!(qcp; max_iter = 60)
```

- `SplinePulseProblem(qtraj)` with no knot count uses the pulse's **native knot
times** — the intended warm-start path.
- `CubicSplinePulse(controls, derivatives, times)` builds a spline pulse from raw
knot data when you have it.
- `load_pulse` returns only the pulse object; bundle metadata (fidelity, gate
name) with `JLD2.jldsave(...; pulse = ..., fidelity = ...)` at save time.
Loading