Skip to content
Draft
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
4 changes: 4 additions & 0 deletions packages/amico-run/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export function validateFinished(v: unknown): Validation {
return validate(v, "finished");
}

export function validateFormulation(v: unknown): Validation {
return validate(v, "formulation");
}

export function validateResult(v: unknown): Validation {
return validate(v, "result");
}
17 changes: 16 additions & 1 deletion packages/amico-run/test/schemas.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { validateManifest, validateFinished, validateResult } from '../src/schemas.js'
import { validateManifest, validateFinished, validateResult, validateFormulation } from '../src/schemas.js'

// These wrappers delegate to the shared @amicode/schema (single source of truth);
// this suite is the delegation smoke + the field-precise contract they expose.
Expand Down Expand Up @@ -43,6 +43,21 @@ describe('validateResult (reader-side)', () => {
})
})

describe('validateFormulation (reader-side)', () => {
it('requires schema_version + system.family + formulation.gate; leaves lenient per family', () => {
// The pre-solve problem-definition file (#64 counterpart). Structure fixed,
// leaf params lenient per family (a rydberg [system] carries no delta).
expect(validateFormulation({
schema_version: '1',
system: { family: 'transmon', delta: 0.2, levels: 3, drive_max: 0.2 },
formulation: { gate: 'X', T: 10.0, N: 50, Q: 100.0, R: 0.01 },
}).ok).toBe(true)
expect(validateFormulation({ system: { family: 'transmon' }, formulation: { gate: 'X' } }).ok).toBe(false) // no schema_version
expect(validateFormulation({ schema_version: '1', system: {}, formulation: { gate: 'X' } }).ok).toBe(false) // no family
expect(validateFormulation({ schema_version: '1', system: { family: 'transmon' }, formulation: {} }).ok).toBe(false) // no gate
})
})

// Anti-regression (N4): schemas.ts must remain a thin DELEGATION, never re-define
// a schema/validator. Guards the "one validator path" invariant (#15 AC7).
describe('schemas.ts is delegation-only (no re-introduced schema)', () => {
Expand Down
7 changes: 5 additions & 2 deletions packages/extension/src/file_watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getInspector } from "./run_inspector";
import type { StatusBarManager } from "./status_bar";
import type { RunStatus } from "./types";
import {
AMICODE_ITER_RE, ingestRunDir, readTomlSafe, parseAmicoNum, PulseStream, SinkDedup,
AMICODE_ITER_RE, ingestRunDir, readTomlSafe, readFormulation, parseAmicoNum, PulseStream, SinkDedup,
type IterRecord, type PulseEvent, type RunCompletion, type PromoteInfo, type RunSink,
} from "./run_dir_reader";

Expand Down Expand Up @@ -247,7 +247,10 @@ export class RunsRootWatcher implements vscode.Disposable {
else this.opts.channel.appendLine(`[runs] result.toml present but invalid: ${v.errors.join("; ")}`);
}
}
this.sink?.run({ runId, runDir, status, fidelity });
// Same read as the replay path (shared helper — the two completion routes
// can't diverge on whether a live-finished run carries its formulation).
const formulation = readFormulation(runDir);
this.sink?.run({ runId, runDir, status, fidelity, formulation });
if (status === "completed" && fidelity !== undefined && fidelity >= (this.opts.promoteThreshold ?? 0.99)) {
this.sink?.promote({ runId, runDir, fidelity });
}
Expand Down
29 changes: 26 additions & 3 deletions packages/extension/src/run_dir_reader.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { parse } from "smol-toml";
import { validateManifest, validateFinished, validateResult } from "@amicode/amico-run";
import { validateManifest, validateFinished, validateResult, validateFormulation } from "@amicode/amico-run";
import type { RunStatus } from "./types";

// ============================================================================
Expand Down Expand Up @@ -105,7 +105,13 @@ export class PulseStream {
}

export interface IterRecord { iter: number; f_val: number; inf_pr: number; inf_du: number }
export interface RunCompletion { runId: string; runDir: string; status: RunStatus; fidelity?: number }

/** The pre-solve problem definition, surfaced from formulation.toml when present
* and valid (additive to the run-dir contract). `[system]`/`[formulation]` are
* lenient leaf-field bags per family (see formulation.schema.json), so this is
* intentionally loose; the only guaranteed keys are system.family + formulation.gate. */
export interface Formulation { system: Record<string, unknown>; formulation: Record<string, unknown> }
export interface RunCompletion { runId: string; runDir: string; status: RunStatus; fidelity?: number; formulation?: Formulation }
export interface PromoteInfo { runId: string; runDir: string; fidelity: number }

/** Where ingestRunDir routes its findings. The live impl carries the
Expand Down Expand Up @@ -136,6 +142,18 @@ export function readTomlSafe(fp: string): Record<string, unknown> | undefined {
catch { return undefined; }
}

/** Read + validate formulation.toml (#64 counterpart) — additive, absent → undefined.
* Shared by BOTH completion routes (ingestRunDir replay + the live-finish path) so
* they can't diverge on whether a run carries its problem identity. Say-why-on-
* invalid; never throws. */
export function readFormulation(runDir: string): Formulation | undefined {
const raw = readTomlSafe(path.join(runDir, "formulation.toml"));
if (!raw) return undefined;
const v = validateFormulation(raw);
if (!v.ok) { console.warn(`[amico] formulation.toml present but invalid (${runDir}): ${v.errors.join("; ")}`); return undefined; }
return { system: raw.system as Record<string, unknown>, formulation: raw.formulation as Record<string, unknown> };
}

/** Pure, stateless replay of a run dir against the β.1 contract. Calls each
* sink method at most once per relevant artifact. Safe to re-invoke (the live
* sink's guards make it idempotent). Returns the number of run.log bytes
Expand Down Expand Up @@ -169,6 +187,11 @@ export function ingestRunDir(runDir: string, sink: RunSink, promoteThreshold = 0
if (newestPulse) sink.pulse(newestPulse);
}

// formulation.toml (#64 counterpart) — the pre-solve problem definition, read via
// the shared readFormulation helper so this replay path and the live-finish path
// stay in lockstep (additive; absent → undefined, older runs unchanged).
const formulation = readFormulation(runDir);

// FINISHED is the authoritative terminal signal
const finished = readTomlSafe(path.join(runDir, "FINISHED"));
if (!finished || !validateFinished(finished).ok) return logBytes;
Expand All @@ -186,7 +209,7 @@ export function ingestRunDir(runDir: string, sink: RunSink, promoteThreshold = 0
else console.warn(`[amico] result.toml present but invalid (${runDir}): ${v.errors.join("; ")}`);
}
}
sink.run({ runId, runDir, status, fidelity });
sink.run({ runId, runDir, status, fidelity, formulation });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Does onFinished read formulation.toml? Curious if live-finished runs emit formulation: undefined and only the replay path got the new field. Should we add the same read there?

@jack-champagne jack-champagne Jul 4, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch, onFinished (the live-finish path in file_watcher.ts) built the completion as {runId, runDir, status, fidelity}, no formulation. This means live-finished run carried formulation: undefined which is no-bueno and replayed-already-finished runs do get it.

I have pushed a commit with a shared readFormulation(runDir) for both ingestRunDir and onFinished. readTerminal/completeRun is a third completion path that'll need the same helper when it lands which I noted it on that review too.)

if (status === "completed" && fidelity !== undefined && fidelity >= promoteThreshold) {
sink.promote({ runId, runDir, fidelity });
}
Expand Down
91 changes: 83 additions & 8 deletions packages/extension/templates/solve_template.jl
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,20 @@ using TOML
using Printf

# ── FILL IN ──────────────────────────────────────────────────────────────
δ = 0.2 # anharmonicity (GHz, positive convention)
levels = 3 # transmon levels modeled (3 = qubit + 1 leakage; bump to 4–5 for more leakage realism)
gate = GATES[:X]
T = 10.0 # gate time (ns)
N = 50 # timesteps
drive_max = 0.2 # per-quadrature drive bound (GHz)
max_iter = 60
δ = 0.2 # anharmonicity (GHz, positive convention)
levels = 3 # transmon levels modeled (3 = qubit + 1 leakage; bump to 4–5 for more leakage realism)
# Standard gate — the symbol is the single source: it drives both the matrix AND the
# label, so they can't drift (GATES[:X] is a matrix; the ":X" name is otherwise lost).
gate_sym = :X
gate = GATES[gate_sym]
gate_name = string(gate_sym)
# — Bespoke gate NOT in the (deliberately sparse) GATES set (cat / Fock-mix / CXX / ZZ…)?
# Declare both directly — there's no symbol to derive from: gate = my_unitary; gate_name = "cat-CZ"
system_name = nothing # optional user-assigned device name (e.g. "qram-cleland"); nothing if unnamed
T = 10.0 # gate time (ns)
N = 50 # timesteps
drive_max = 0.2 # per-quadrature drive bound (GHz)
max_iter = 60
# ─────────────────────────────────────────────────────────────────────────

sys = TransmonSystem(; δ = δ, levels = levels, drive_bounds = fill(drive_max, 2))
Expand All @@ -26,11 +33,63 @@ op = size(gate, 1) == sys.levels ? gate : EmbeddedOperator(gate, sys)
times = collect(range(0.0, T, length = N))
initial = 0.1 * randn(sys.n_drives, N)
qtraj = UnitaryTrajectory(sys, ZeroOrderPulse(initial, times), op)
Q = 100.0 # infidelity objective weight — defines the optimum (recorded in formulation.toml)
R = 1e-2 # control-effort objective weight — defines the optimum (recorded in formulation.toml)
qcp = SmoothPulseProblem(qtraj, N;
piccolo_options = PiccoloOptions(timesteps_all_equal = true),
Q = 100.0, R = 1e-2)
Q = Q, R = R)
prob = hasproperty(qcp, :prob) ? qcp.prob : qcp

# Pre-solve run-dir emit helper: writes formulation.toml (the DECLARED problem —
# physics + objective), the authoritative identity source #64 keys hashes off.
# Defined INLINE — NOT include(joinpath(@__DIR__, "...")): AGENTS.md deploys this
# template by copying THIS FILE ALONE into a scratch dir and running the copy, so
# @__DIR__ is that scratch dir and a sibling include resolves to a file that was
# never copied (LoadError before solve!). When a second template needs this, lift
# it verbatim into a Julia package (AmicoRunDir.jl) resolved via `using` — the only
# share mechanism that survives the single-file copy.
#
# Contract (validated by @amicode/schema formulation.schema.json):
# [system] family (required) + optional name + family-dependent params
# [formulation] gate + T + N (required) + optional Q/R + family-dependent extras
# system_params / formulation_extra are merged per family (the schema constrains
# known families but tolerates unknown leaves), so a rydberg caller can pass
# Omega_max/C6/... with no edit here.
function emit_formulation(;
system_family::AbstractString,
gate_name::AbstractString,
system_params::AbstractDict = Dict{String,Any}(),
system_name::Union{AbstractString,Nothing} = nothing,
formulation_extra::AbstractDict = Dict{String,Any}(),
path::AbstractString = "formulation.toml",
)
system = Dict{String,Any}("family" => String(system_family))
if system_name !== nothing
system["name"] = String(system_name)
end
for (k, v) in system_params
system[String(k)] = v
end

formulation = Dict{String,Any}("gate" => String(gate_name))
for (k, v) in formulation_extra
formulation[String(k)] = v
end

doc = Dict{String,Any}(
"schema_version" => "1", # run-dir contract version (@amicode/schema formulation schema)
"system" => system,
"formulation" => formulation,
)

tmp = path * ".tmp"
open(tmp, "w") do io
TOML.print(io, doc)
end
mv(tmp, path; force = true) # atomic swap — a partial read never sees a half-written file
return path
end

# Per-iter live plot flows through Piccolo's `LivePulsePlotCallback`, an
# `AbstractIntermediateCallback` (the blessed, solver-agnostic per-iter plot
# idiom — see AGENTS.md). It reconstructs the pulse from the optimizer's primal
Expand Down Expand Up @@ -86,6 +145,18 @@ let ls = join(("\"a_$i\"" for i in 1:sys.n_drives), ","),
flush(stdout)
end

# Pre-solve: drop formulation.toml — the DECLARED problem (physics + objective),
# written before solve! while every FILL-IN var is in scope. Authoritative
# identity source (#64 keys hashes off this); [system] = the device, [formulation]
# = the optimal-control problem posed against it.
emit_formulation(;
system_family = "transmon",
gate_name = gate_name,
system_name = system_name,
system_params = Dict("delta" => δ, "levels" => levels, "drive_max" => drive_max),
formulation_extra = Dict("T" => T, "N" => N, "Q" => Q, "R" => R),
)

# AMICODE_ITER text telemetry stays on the RAW Ipopt callback — it needs the rich
# IPM state (obj_value/inf_pr/inf_du) that the agnostic `(primal, iter)` contract
# doesn't carry. Both callbacks fire once per iteration (DTO composes the raw
Expand Down Expand Up @@ -132,6 +203,10 @@ JLD2.save("pulse.jld2", "traj", prob.trajectory) # key "traj" so `load_traj` c
open("result.toml.tmp", "w") do io
# Record the regime each run actually solved (scalar FILL-IN params), so the
# result is self-describing — not just fidelity/iterations.
# NOTE: [params] is now REDUNDANT with the pre-solve formulation.toml (the
# authoritative problem-definition file). Kept for now so in-flight consumers
# (#73 card reads params.T/params.system) don't break; killing it is a
# follow-up once those readers migrate to formulation.toml (tracked in #64).
TOML.print(io, Dict(
"schema_version" => "1", # run-dir contract version (@amicode/schema result schema)
"fidelity" => fid, "iterations" => iters[], "wall_seconds" => wall,
Expand Down
57 changes: 57 additions & 0 deletions packages/extension/test/template_deploy_contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import { mkdtempSync, copyFileSync, existsSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'

// Regression guard (#81). DEPLOYMENT REALITY: AGENTS.md tells the agent to COPY the
// solve template into a scratch dir and run THAT copy — `julia <scratch>/solve.jl`
// (see AGENTS.md "Workflow" step 2/3). So inside the running script @__DIR__ is the
// scratch dir, NOT the bundled templates dir — and any sibling `include(@__DIR__/…)`
// opens a file only present if the deploy step also copied it. #81's first draft added
// `include(joinpath(@__DIR__, "emit_formulation.jl"))` while AGENTS.md copies only the
// template → LoadError before solve!, on every agent-driven run. Nothing in the vitest
// suite executed the template, so it shipped green. This test closes that gap WITHOUT
// needing Julia: it ties the two seams together — reproduce exactly the files AGENTS.md
// copies into scratch, then assert every include() in the deployed script resolves there.
// Stays green under either fix: inline the helper (no include), or teach AGENTS.md to
// copy it too. The deeper guard is #78's smoke corpus actually running a solve.
const EXT = join(__dirname, '..')
const AGENTS = readFileSync(join(EXT, 'AGENTS.md'), 'utf8')
const TEMPLATE = join(EXT, 'templates', 'solve_template.jl')

describe('solve template deploys runnably under AGENTS.md single-file copy [#81]', () => {
it('every include() in the deployed script resolves in the scratch dir', () => {
const scratch = mkdtempSync(join(tmpdir(), 'amicode-work-'))

// Reproduce AGENTS.md's scratch-dir copies: `cp <src> /tmp/amicode-work/<dst>`.
// {{TEMPLATE_PATH}} is the substitution for the bundled solve_template.jl.
const copied = new Map<string, string>() // destName -> srcAbs
for (const m of AGENTS.matchAll(/\bcp\s+(\S+)\s+(\S+)/g)) {
const [, srcTok, dst] = m
if (!dst.includes('amicode-work')) continue
const srcAbs = srcTok.replace('{{TEMPLATE_PATH}}', TEMPLATE)
copied.set(dst.endsWith('/') ? basename(srcAbs) : basename(dst), srcAbs)
}
expect(copied.size, 'AGENTS.md should document copying the template into the scratch dir').toBeGreaterThan(0)
for (const [name, srcAbs] of copied) copyFileSync(srcAbs, join(scratch, name))

// amico-run runs `julia <scratch>/solve.jl`, so @__DIR__ === scratch for the run file.
const runFile = copied.has('solve.jl') ? 'solve.jl' : [...copied.keys()][0]
const src = readFileSync(join(scratch, runFile), 'utf8')

// Julia resolves a relative include (bare or joinpath(@__DIR__, …)) against @__DIR__.
const unresolved: string[] = []
for (const m of src.matchAll(/^[ \t]*include\((.+?)\)[ \t]*(?:#.*)?$/gm)) {
const arg = m[1].trim()
const sibling =
arg.match(/^joinpath\(\s*@__DIR__\s*,\s*"([^"]+)"\s*\)$/)?.[1] ??
arg.match(/^"(?!\/)([^"]+)"$/)?.[1]
if (sibling === undefined) { unresolved.push(`unclassifiable include(${arg})`); continue }
if (!existsSync(join(scratch, sibling))) unresolved.push(sibling)
}
expect(
unresolved,
`deployed template can't resolve include(s): ${unresolved.join(', ')} — inline them, or make AGENTS.md copy them into the scratch dir`,
).toEqual([])
})
})
Loading
Loading