From ceb49d7079bace98dd0065f98840fd8a103a892d Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 03:32:05 -0400 Subject: [PATCH 01/49] =?UTF-8?q?amico-run(#108):=20B1=20=E2=80=94=20gener?= =?UTF-8?q?alize=20amico-run=20into=20the=20`amico`=20verb=20router?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the `amico` verb-router surface over the existing amico-run launch path, non-destructively. No package rename, no removed behavior. - Extract the launch body of cli.ts verbatim into src/launch.ts (`main` → `launch`); cli.ts becomes a thin `amico-run` bin wrapper. This lets BOTH the `amico-run` bin and the new `amico run` verb call the SAME launch path with no behavior fork. - Add src/amico.ts — the verb router. `run`/`resolve`/`sandbox` delegate verbatim to launch (`amico ` ≡ `amico-run `). `--help` lists the full verb surface; unknown verb → exit 64. - Add src/verbs.ts — spine bookkeeping verbs (catalog/vault/device/note) as B1 STUBS: print intent + the module/slice each will generalize, exit 0. Real bodies are B2/B3/B5. - Add src/mcp_serve.ts — the optional MCP facade over the same verbs. STUB seam only: `--list` renders the verb↔tool mapping; the transport carries ZERO MCP SDK dependency so it stays within the S31 no-MCP guard (landing the real transport needs an explicit S31 amendment, as spec C did). - esbuild builds both dist/amico-run.js and dist/amico.js; package.json adds the `amico` bin; launcher/amico mirrors launcher/amico-run. - test/amico.test.ts covers router dispatch: --help surface, unknown-verb →64, verbatim run/resolve/sandbox delegation, stub verbs, mcp-serve facade. All existing amico-run tests stay green (106 → 120 with the 14 new router tests). Spec-20260708-112732 §7.3; plan-20260708-214610 slice B1. Closes #108 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/amico-run/esbuild.config.mjs | 22 ++- packages/amico-run/launcher/amico | 16 +++ packages/amico-run/package.json | 3 +- packages/amico-run/src/amico.ts | 77 ++++++++++ packages/amico-run/src/cli.ts | 198 +------------------------- packages/amico-run/src/launch.ts | 198 ++++++++++++++++++++++++++ packages/amico-run/src/mcp_serve.ts | 64 +++++++++ packages/amico-run/src/verbs.ts | 78 ++++++++++ packages/amico-run/test/amico.test.ts | 166 +++++++++++++++++++++ 9 files changed, 622 insertions(+), 200 deletions(-) create mode 100755 packages/amico-run/launcher/amico create mode 100644 packages/amico-run/src/amico.ts create mode 100644 packages/amico-run/src/launch.ts create mode 100644 packages/amico-run/src/mcp_serve.ts create mode 100644 packages/amico-run/src/verbs.ts create mode 100644 packages/amico-run/test/amico.test.ts diff --git a/packages/amico-run/esbuild.config.mjs b/packages/amico-run/esbuild.config.mjs index 69a8d9b7..f7921c55 100644 --- a/packages/amico-run/esbuild.config.mjs +++ b/packages/amico-run/esbuild.config.mjs @@ -1,17 +1,25 @@ import { build } from "esbuild"; import { chmodSync } from "node:fs"; -await build({ - entryPoints: ["src/cli.ts"], +// Two bins from one package: the historical `amico-run` (entry cli.ts) and the new `amico` +// verb router (entry amico.ts, issue #108). Both share the launch path (src/launch.ts); +// amico.ts additionally bundles the spine verbs + the mcp-serve facade. +const common = { bundle: true, platform: "node", target: "node20", - // ESM, not CJS: the package is "type": "module", so node executes dist/amico-run.js - // as ESM — a CJS bundle would die on `require is not defined in ES module scope`. + // ESM, not CJS: the package is "type": "module", so node executes the bundle as ESM — + // a CJS bundle would die on `require is not defined in ES module scope`. format: "esm", - outfile: "dist/amico-run.js", banner: { js: "#!/usr/bin/env node" }, sourcemap: true, logLevel: "info", -}); -chmodSync("dist/amico-run.js", 0o755); +}; + +for (const [entry, outfile] of [ + ["src/cli.ts", "dist/amico-run.js"], + ["src/amico.ts", "dist/amico.js"], +]) { + await build({ ...common, entryPoints: [entry], outfile }); + chmodSync(outfile, 0o755); +} diff --git a/packages/amico-run/launcher/amico b/packages/amico-run/launcher/amico new file mode 100755 index 00000000..28492b14 --- /dev/null +++ b/packages/amico-run/launcher/amico @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Thin launcher for the `amico` verb router: resolve node, exec the bundled router. +# No logic lives here (mirror of launcher/amico-run). See src/amico.ts. +set -euo pipefail +SOURCE="${BASH_SOURCE[0]}" +while [ -h "$SOURCE" ]; do # resolve symlink chains (node_modules/.bin) + DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" + SOURCE="$(readlink "$SOURCE")" + [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" +done +DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" +if ! command -v node >/dev/null 2>&1; then + echo "amico: node >= 20 not found on PATH (install node or fix PATH; see provisioning runbook)" >&2 + exit 64 +fi +exec node "$DIR/../dist/amico.js" "$@" diff --git a/packages/amico-run/package.json b/packages/amico-run/package.json index e2a197bc..b1b18821 100644 --- a/packages/amico-run/package.json +++ b/packages/amico-run/package.json @@ -6,7 +6,8 @@ "main": "./src/index.ts", "types": "./src/index.ts", "bin": { - "amico-run": "./launcher/amico-run" + "amico-run": "./launcher/amico-run", + "amico": "./launcher/amico" }, "engines": { "node": ">=20" diff --git a/packages/amico-run/src/amico.ts b/packages/amico-run/src/amico.ts new file mode 100644 index 00000000..e62b159f --- /dev/null +++ b/packages/amico-run/src/amico.ts @@ -0,0 +1,77 @@ +// `amico` — the shared CLI verb router (issue #108, spec-20260708-112732 §7.3). Generalizes +// the `amico-run` bin: the same launch path (`amico run --spec …`) plus the +// spine bookkeeping verbs and the `mcp-serve` facade. ONE binary, invoked via bash by both +// runtimes (Claude Code + opencode) AND directly by the deterministic harness / cron / CI / +// Julia. +// +// B1 SCOPE: `run` / `resolve` / `sandbox` delegate VERBATIM to the existing amico-run launch +// path (src/launch.ts): `amico ` is exactly `amico-run `, so +// there is no behavior fork and the amico-run test suite still covers the real bodies. The +// spine verbs (catalog/vault/device/note) and `mcp-serve` are STUB seams (see verbs.ts, +// mcp_serve.ts) — routing works today; real bodies land in later spine slices. +import { launch } from "./launch.js"; +import { SPINE_VERBS } from "./verbs.js"; +import { serve } from "./mcp_serve.js"; + +function usage(): string { + const rows: [string, string][] = [ + ["run [--spec ] […]", "launch a solve — the amico-run launch path (delegates verbatim)"], + ["resolve --platform

--kind --size ", "tier resolution → JSON (amico-run subcommand)"], + ["sandbox --packages A,B,…", "generate a per-problem Julia env (amico-run subcommand)"], + ...SPINE_VERBS.map((v) => [`${v.name} …`, `${v.summary} [stub → ${v.slice}]`] as [string, string]), + ["mcp-serve [--list]", "expose the spine verbs as MCP tools (optional facade) [stub]"], + ["--help, -h", "show this verb surface"], + ]; + const width = Math.max(...rows.map(([u]) => u.length)); + const lines = rows.map(([u, d]) => ` amico ${u.padEnd(width)} ${d}`); + return `usage:\n${lines.join("\n")}`; +} + +export async function main(argv: string[]): Promise { + const head = argv[0]; + const rest = argv.slice(1); + + if (!head || head === "--help" || head === "-h") { + console.log(usage()); + return head ? 0 : 64; // explicit --help is success; a bare `amico` is a usage error + } + + // spine bookkeeping verbs (catalog/vault/device/note) — B1 stubs, print intent + exit 0. + const verb = SPINE_VERBS.find((v) => v.name === head); + if (verb) { + const { json, code } = await verb.run(rest); + console.log(JSON.stringify(json)); + return code; + } + + switch (head) { + // ── delegate verbatim to the existing amico-run launch path ── + // `amico run ` ≡ `amico-run ` (launch / --spec gate) + // `amico resolve ` ≡ `amico-run resolve ` (tier resolution subcommand) + // `amico sandbox ` ≡ `amico-run sandbox ` (env generation subcommand) + case "run": + return launch(rest); + case "resolve": + return launch(["resolve", ...rest]); + case "sandbox": + return launch(["sandbox", ...rest]); + + case "mcp-serve": + return serve(rest); + + default: + console.error(`amico: unknown verb "${head}"\n${usage()}`); + return 64; + } +} + +main(process.argv.slice(2)).then( + (c) => { + process.exitCode = c; + }, + (e) => { + // Any unexpected throw is an orchestrator fault, not a solve failure → 64. + console.error(`amico: unexpected error: ${e instanceof Error ? (e.stack ?? e.message) : e}`); + process.exitCode = 64; + }, +); diff --git a/packages/amico-run/src/cli.ts b/packages/amico-run/src/cli.ts index 2ef2ddc0..27db9659 100644 --- a/packages/amico-run/src/cli.ts +++ b/packages/amico-run/src/cli.ts @@ -1,196 +1,10 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { parse as parseToml } from "smol-toml"; -import { LocalExecutor } from "./local_executor.js"; -import { ConfigError, type Finished, type SubmitOpts } from "./types.js"; -import { readAuthoring } from "./authoring.js"; -import { runGate } from "./gate.js"; -import { runVerification } from "./verify.js"; -import { trySubcommand } from "./subcommands.js"; +// The `amico-run` bin entry point. The launch logic itself lives in launch.ts so the new +// `amico` verb router (amico.ts, issue #108) can call the SAME launch path without +// re-running this bootstrap. This file is intentionally thin: resolve argv → launch() → +// set the process exit code, with the historical amico-run error/exit semantics unchanged. +import { launch } from "./launch.js"; -function readTomlSafe(fp: string): Record | undefined { - try { - return parseToml(readFileSync(fp, "utf8")) as Record; - } catch { - return undefined; - } -} - -const USAGE = `usage: amico-run [--executor local] [--lab ] - [--runs-root ] [--julia ] [--project ] [--sysimage ] - [--spec ] (spec C: validate + gate before launch) - amico-run resolve --platform

--kind --size (tier resolution → JSON) - amico-run sandbox --packages A,B,… (generate env/Project.toml) - (a bare script literally named "resolve"/"sandbox" still launches — dispatch checks the file exists)`; - -export async function main(argv: string[]): Promise { - // spec C subcommands — dispatched before the launch flag loop - const sub = trySubcommand(argv); - if (sub !== undefined) return sub; - - let script: string | undefined; - let executor = "local"; - let specPath: string | undefined; - const opts: SubmitOpts = { julia: {} }; - let projectExplicit = false; - - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - const next = (): string => { - const v = argv[++i]; - if (v === undefined) throw new ConfigError(`flag ${a} requires a value`); - return v; - }; - try { - switch (a) { - case "--help": - case "-h": - console.log(USAGE); - return 0; - case "--executor": - executor = next(); - break; - case "--lab": - opts.lab = next(); - break; - case "--runs-root": - opts.runsRoot = next(); - break; - case "--julia": - opts.julia!.julia = next(); - break; - case "--project": - opts.julia!.project = next(); - projectExplicit = true; - break; - case "--sysimage": - opts.julia!.sysimage = next(); - break; - case "--spec": - specPath = next(); - break; - default: - if (a.startsWith("-")) { - console.error(`amico-run: unknown flag ${a}\n${USAGE}`); - return 64; - } - if (script) { - console.error(`amico-run: multiple scripts given`); - return 64; - } - script = a; - } - } catch (e) { - if (e instanceof ConfigError) { - console.error(`amico-run: ${e.message}`); - return 64; - } - throw e; - } - } - if (!script) { - console.error(`amico-run: no script given\n${USAGE}`); - return 64; - } - if (executor !== "local") { - console.error(`amico-run: only --executor local is supported in β`); - return 64; - } - - // ── spec C: the launch gate. Failures leave NO run dir and exit 64. ── - if (specPath) { - let specRaw: unknown; - try { - specRaw = JSON.parse(readFileSync(specPath, "utf8")); - } catch (e) { - console.error(`amico-run: cannot read --spec ${specPath}: ${(e as Error).message}`); - return 64; - } - let scriptText: string; - try { - scriptText = readFileSync(script, "utf8"); - } catch (e) { - console.error(`amico-run: cannot read script ${script}: ${(e as Error).message}`); - return 64; - } - const { config: authoring, warning } = readAuthoring(); - if (warning) console.error(`amico-run: ${warning}`); - const gate = runGate(specRaw, scriptText, authoring); - if (!gate.ok) { - console.error(`amico-run: gate: ${gate.reason}`); - return 64; - } - // env resolution: spec env.project feeds --project unless the flag was explicit - const env = (specRaw as { env?: { kind?: string; project?: string } }).env; - if (env?.project && (env.kind === "project" || env.kind === "sandbox")) { - if (projectExplicit && opts.julia!.project !== env.project) - console.error(`amico-run: --project ${opts.julia!.project} overrides the spec's env.project ${env.project}`); - else opts.julia!.project = env.project; - } - opts.spec = { - canonical: gate.stamp.specCanonical, - tier: gate.stamp.tier, - hashes: gate.stamp.hashes, - julia_binary: opts.julia!.julia, - env_project: opts.julia!.project, - }; - } - - // NOTE: `--sysimage ` is honored (passed through to the Julia process and - // recorded in the manifest) but amicode does NOT build one — the local - // PackageCompiler build (~25-50 min, CairoMakie-dominated) wasn't worth it. The - // intended fast-path is a prebuilt sysimage distributed like Piccolissimo's - // (CI build on self-hosted runners → R2 → manifest → download), pointed at via - // this flag. Until that exists, solves pay the cold start (inspector warms up). - - let handle; - try { - handle = await new LocalExecutor().submit(script, opts); - } catch (e) { - if (e instanceof ConfigError) { - console.error(`amico-run: ${e.message}`); - return 64; - } - throw e; - } - - const onSignal = (): void => { - void handle.abort(); - }; - process.on("SIGINT", onSignal); - process.on("SIGTERM", onSignal); - - let fin: Finished | undefined; - for await (const ev of handle.events) { - if (ev.kind === "iter" || ev.kind === "done") console.log(ev.raw); - else if (ev.kind === "log") console.log(ev.line); - else fin = { status: ev.status, exitCode: ev.exitCode }; - } - const f = fin ?? (await handle.finished); - - // FINISHED-write failure lane (spec §6 last row): verdict file must exist on disk - if (!existsSync(join(handle.runDir, "FINISHED"))) { - console.error(`amico-run: FINISHED missing in ${handle.runDir} (write fault)`); - return 64; - } - // spec C: free-tier re-rollout verification runs AFTER FINISHED, BEFORE the - // AMICODE_FINISHED line — so consumers see a settled verification state. The - // harness (or the fallback) always writes verification.toml; the promote gate - // keys off agree==true. - if (opts.spec?.tier === "free") { - const { config: authoring } = readAuthoring(); - await runVerification(handle.runDir, opts.spec, authoring); - const verified = readTomlSafe(join(handle.runDir, "verification.toml")); - console.log(`AMICODE_VERIFIED agree=${verified?.agree === true}`); - } - // stdout protocol line — camelCase by design (spec §4) - console.log(`AMICODE_FINISHED status=${f.status} exitCode=${f.exitCode} runDir=${handle.runDir}`); - if (f.status === "aborted") return 130; - if (f.status === "completed") return 0; - return f.exitCode === 0 ? 1 : f.exitCode; -} - -main(process.argv.slice(2)).then( +launch(process.argv.slice(2)).then( (c) => { process.exitCode = c; }, diff --git a/packages/amico-run/src/launch.ts b/packages/amico-run/src/launch.ts new file mode 100644 index 00000000..8758eb7d --- /dev/null +++ b/packages/amico-run/src/launch.ts @@ -0,0 +1,198 @@ +// The amico-run LAUNCH path, extracted verbatim from cli.ts (B1: generalize amico-run +// into the `amico` verb router, issue #108). Nothing here changed except the name +// `main` → `launch` and the removal of the top-level self-invocation, which now lives in +// cli.ts (the `amico-run` bin) — so both the `amico-run` bin AND the new `amico run` +// router verb can call this ONE launch function without either re-running the other's +// process bootstrap. This is the "delegate to the existing code path" seam: `amico run +// ` is exactly `launch()`, byte-for-byte the historical amico-run behavior. +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse as parseToml } from "smol-toml"; +import { LocalExecutor } from "./local_executor.js"; +import { ConfigError, type Finished, type SubmitOpts } from "./types.js"; +import { readAuthoring } from "./authoring.js"; +import { runGate } from "./gate.js"; +import { runVerification } from "./verify.js"; +import { trySubcommand } from "./subcommands.js"; + +function readTomlSafe(fp: string): Record | undefined { + try { + return parseToml(readFileSync(fp, "utf8")) as Record; + } catch { + return undefined; + } +} + +const USAGE = `usage: amico-run [--executor local] [--lab ] + [--runs-root ] [--julia ] [--project ] [--sysimage ] + [--spec ] (spec C: validate + gate before launch) + amico-run resolve --platform

--kind --size (tier resolution → JSON) + amico-run sandbox --packages A,B,… (generate env/Project.toml) + (a bare script literally named "resolve"/"sandbox" still launches — dispatch checks the file exists)`; + +export async function launch(argv: string[]): Promise { + // spec C subcommands — dispatched before the launch flag loop + const sub = trySubcommand(argv); + if (sub !== undefined) return sub; + + let script: string | undefined; + let executor = "local"; + let specPath: string | undefined; + const opts: SubmitOpts = { julia: {} }; + let projectExplicit = false; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + const next = (): string => { + const v = argv[++i]; + if (v === undefined) throw new ConfigError(`flag ${a} requires a value`); + return v; + }; + try { + switch (a) { + case "--help": + case "-h": + console.log(USAGE); + return 0; + case "--executor": + executor = next(); + break; + case "--lab": + opts.lab = next(); + break; + case "--runs-root": + opts.runsRoot = next(); + break; + case "--julia": + opts.julia!.julia = next(); + break; + case "--project": + opts.julia!.project = next(); + projectExplicit = true; + break; + case "--sysimage": + opts.julia!.sysimage = next(); + break; + case "--spec": + specPath = next(); + break; + default: + if (a.startsWith("-")) { + console.error(`amico-run: unknown flag ${a}\n${USAGE}`); + return 64; + } + if (script) { + console.error(`amico-run: multiple scripts given`); + return 64; + } + script = a; + } + } catch (e) { + if (e instanceof ConfigError) { + console.error(`amico-run: ${e.message}`); + return 64; + } + throw e; + } + } + if (!script) { + console.error(`amico-run: no script given\n${USAGE}`); + return 64; + } + if (executor !== "local") { + console.error(`amico-run: only --executor local is supported in β`); + return 64; + } + + // ── spec C: the launch gate. Failures leave NO run dir and exit 64. ── + if (specPath) { + let specRaw: unknown; + try { + specRaw = JSON.parse(readFileSync(specPath, "utf8")); + } catch (e) { + console.error(`amico-run: cannot read --spec ${specPath}: ${(e as Error).message}`); + return 64; + } + let scriptText: string; + try { + scriptText = readFileSync(script, "utf8"); + } catch (e) { + console.error(`amico-run: cannot read script ${script}: ${(e as Error).message}`); + return 64; + } + const { config: authoring, warning } = readAuthoring(); + if (warning) console.error(`amico-run: ${warning}`); + const gate = runGate(specRaw, scriptText, authoring); + if (!gate.ok) { + console.error(`amico-run: gate: ${gate.reason}`); + return 64; + } + // env resolution: spec env.project feeds --project unless the flag was explicit + const env = (specRaw as { env?: { kind?: string; project?: string } }).env; + if (env?.project && (env.kind === "project" || env.kind === "sandbox")) { + if (projectExplicit && opts.julia!.project !== env.project) + console.error(`amico-run: --project ${opts.julia!.project} overrides the spec's env.project ${env.project}`); + else opts.julia!.project = env.project; + } + opts.spec = { + canonical: gate.stamp.specCanonical, + tier: gate.stamp.tier, + hashes: gate.stamp.hashes, + julia_binary: opts.julia!.julia, + env_project: opts.julia!.project, + }; + } + + // NOTE: `--sysimage ` is honored (passed through to the Julia process and + // recorded in the manifest) but amicode does NOT build one — the local + // PackageCompiler build (~25-50 min, CairoMakie-dominated) wasn't worth it. The + // intended fast-path is a prebuilt sysimage distributed like Piccolissimo's + // (CI build on self-hosted runners → R2 → manifest → download), pointed at via + // this flag. Until that exists, solves pay the cold start (inspector warms up). + + let handle; + try { + handle = await new LocalExecutor().submit(script, opts); + } catch (e) { + if (e instanceof ConfigError) { + console.error(`amico-run: ${e.message}`); + return 64; + } + throw e; + } + + const onSignal = (): void => { + void handle.abort(); + }; + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + + let fin: Finished | undefined; + for await (const ev of handle.events) { + if (ev.kind === "iter" || ev.kind === "done") console.log(ev.raw); + else if (ev.kind === "log") console.log(ev.line); + else fin = { status: ev.status, exitCode: ev.exitCode }; + } + const f = fin ?? (await handle.finished); + + // FINISHED-write failure lane (spec §6 last row): verdict file must exist on disk + if (!existsSync(join(handle.runDir, "FINISHED"))) { + console.error(`amico-run: FINISHED missing in ${handle.runDir} (write fault)`); + return 64; + } + // spec C: free-tier re-rollout verification runs AFTER FINISHED, BEFORE the + // AMICODE_FINISHED line — so consumers see a settled verification state. The + // harness (or the fallback) always writes verification.toml; the promote gate + // keys off agree==true. + if (opts.spec?.tier === "free") { + const { config: authoring } = readAuthoring(); + await runVerification(handle.runDir, opts.spec, authoring); + const verified = readTomlSafe(join(handle.runDir, "verification.toml")); + console.log(`AMICODE_VERIFIED agree=${verified?.agree === true}`); + } + // stdout protocol line — camelCase by design (spec §4) + console.log(`AMICODE_FINISHED status=${f.status} exitCode=${f.exitCode} runDir=${handle.runDir}`); + if (f.status === "aborted") return 130; + if (f.status === "completed") return 0; + return f.exitCode === 0 ? 1 : f.exitCode; +} diff --git a/packages/amico-run/src/mcp_serve.ts b/packages/amico-run/src/mcp_serve.ts new file mode 100644 index 00000000..f7a5bf02 --- /dev/null +++ b/packages/amico-run/src/mcp_serve.ts @@ -0,0 +1,64 @@ +// `amico mcp-serve` — the OPTIONAL MCP facade over the same verbs (spec-20260708-112732 +// §7.3). Both runtimes (opencode + Claude Code) have full MCP support, so exposing the +// spine verbs as MCP tools makes them callable-by-name with typed discovery in BOTH, +// without a second implementation. +// +// The mapping is the whole point: each Verb becomes one MCP tool; a tools/call would +// dispatch to the SAME Verb.run the CLI uses. One impl, two transports. +// +// B1 SCOPE (issue #108): this is a STUB seam. `--list` renders the verb↔tool mapping; the +// real transport (an MCP stdio server) is NOT wired here. Note the deliberate constraint: +// test/s31.test.ts (S31 / spec §4) forbids the MCP server SDK inside the orchestrator src, +// so this slice carries ZERO MCP dependency. Landing the real transport body requires an +// explicit, reviewed S31 amendment in a later slice — exactly as spec C amended the S31 +// SolveSpec ban to name amico-run the launch gate. Either path here exits cleanly (code 0). + +import { SPINE_VERBS, type Verb } from "./verbs.js"; + +interface McpToolDescriptor { + name: string; + description: string; + inputSchema: { type: "object"; properties: Record }; +} + +/** Verb → MCP tool descriptor. Args pass through as a string[] under `argv`; a real build + * would derive a proper JSON-Schema per verb from its flag set. */ +export function verbToMcpTool(v: Verb): McpToolDescriptor { + return { + name: `amico_${v.name}`, + description: v.summary, + inputSchema: { type: "object", properties: { argv: { type: "array", items: { type: "string" } } } }, + }; +} + +export function listMcpTools(): McpToolDescriptor[] { + return SPINE_VERBS.map(verbToMcpTool); +} + +/** tools/call handler — dispatches to the same Verb.run the CLI uses. */ +export async function callMcpTool(name: string, argv: string[]): Promise { + const verb = SPINE_VERBS.find((v) => `amico_${v.name}` === name); + if (!verb) throw new Error(`unknown tool ${name}`); + const result = await verb.run(argv); + return result.json; +} + +export async function serve(argv: string[]): Promise { + if (argv.includes("--list")) { + // Demonstrable path: show the verb↔tool mapping without standing up a transport. + console.log(JSON.stringify({ tools: listMcpTools() }, null, 2)); + return 0; + } + // ── the transport seam (the only net-new code MCP adds over the CLI) — lands in a later + // slice. It stands up an MCP stdio server whose list-tools returns listMcpTools() and + // whose call-tool dispatches to callMcpTool(name, argv). It is intentionally NOT + // imported here so this slice stays free of the MCP SDK (S31; see the file header). ── + console.log( + JSON.stringify({ + stub: true, + note: "B1 seam only — the MCP stdio transport is not wired here (S31 keeps the orchestrator SDK-free); use --list for the verb↔tool mapping", + tools: listMcpTools().map((t) => t.name), + }), + ); + return 0; +} diff --git a/packages/amico-run/src/verbs.ts b/packages/amico-run/src/verbs.ts new file mode 100644 index 00000000..c2fa46a5 --- /dev/null +++ b/packages/amico-run/src/verbs.ts @@ -0,0 +1,78 @@ +// The shared-spine bookkeeping verbs — the `amicode_*` opencode-plugin tools, migrated +// to `amico` CLI verbs (spec-20260708-112732 §7.3 triage). Each is deterministic +// filesystem/vault work: callable by agents via bash, by the deterministic harness +// directly, and by cron/CI/Julia. +// +// B1 SCOPE (issue #108): these are STUBS. Each verb is present as a routing seam, prints +// its intent (including the real module it will generalize and the slice that lands the +// body), and exits cleanly with code 0. NO bookkeeping logic is migrated here — that is +// B2/B3/B5. Do not add real reads/writes in this file without the corresponding slice. +// +// Each verb is a plain (args) => {json, code} function so the SAME function backs both the +// CLI dispatch (amico.ts) and the MCP facade (mcp_serve.ts). One impl, two transports. + +export interface VerbResult { + json: unknown; // structured result (stdout as JSON for the CLI; tool content for MCP) + code: number; // process exit code (0 ok, 64 usage/gate, else failure) +} + +export interface Verb { + name: string; + summary: string; // one-line help + MCP tool description + generalizes: string; // the real module/plugin tool whose body lands in a later slice + slice: string; // which spine slice implements the real body + run: (args: string[]) => VerbResult | Promise; +} + +/** A uniform B1 stub body: echo the intent, name the target module + slice, exit 0. */ +function stub(verb: Omit): Verb { + return { + ...verb, + run: (args) => ({ + json: { + verb: verb.name, + stub: true, + args, + intent: verb.summary, + generalizes: verb.generalizes, + implemented_by: verb.slice, + note: "B1 seam only — routing works; the real body lands in a later spine slice", + }, + code: 0, + }), + }; +} + +// catalog — warm-start lookup + pulse ingest against the repertoire (metadata.toml). +const catalog = stub({ + name: "catalog", + summary: "warm-start lookup / pulse ingest against the repertoire (metadata.toml)", + generalizes: "amico-run/src/catalog.ts + the amicode_* catalog plugin tool", + slice: "spine bookkeeping (B2)", +}); + +// vault — retrieval over the knowledge graph (query tools, not front-loading context). +const vault = stub({ + name: "vault", + summary: "query the knowledge graph (insights/experiments/strategy) — retrieval, not front-load", + generalizes: "the amicode_* vault plugin tools", + slice: "spine bookkeeping (B3)", +}); + +// device — the dispatcher successor (device status / next-actions; benchmark-exclusivity lock). +const device = stub({ + name: "device", + summary: "device status / next-actions (dispatcher successor; benchmark-exclusivity lock)", + generalizes: "the amicode_* device/dispatcher plugin tools", + slice: "spine bookkeeping (B5)", +}); + +// note — write an experiment note / bump best_gates (librarian bookkeeping half). +const note = stub({ + name: "note", + summary: "write experiment note / update best_gates (librarian bookkeeping → deterministic)", + generalizes: "the amicode_* librarian/note plugin tools", + slice: "spine bookkeeping (B3)", +}); + +export const SPINE_VERBS: Verb[] = [catalog, vault, device, note]; diff --git a/packages/amico-run/test/amico.test.ts b/packages/amico-run/test/amico.test.ts new file mode 100644 index 00000000..e9f8325a --- /dev/null +++ b/packages/amico-run/test/amico.test.ts @@ -0,0 +1,166 @@ +// Router-dispatch tests for the `amico` verb router (issue #108). The launch/resolve/ +// sandbox BODIES are already covered by cli.test.ts + subcommands.test.ts against the +// amico-run bundle; these tests assert the ROUTER seam: verb routing, --help surface, +// unknown-verb → 64, verbatim delegation of run/resolve/sandbox, and the stub verbs + +// mcp-serve facade. Run: `pnpm --filter @amicode/amico-run test`. +import { describe, it, expect, beforeAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { tmpRoot, fakeJulia, readToml } from "./helpers.js"; + +const BUNDLE = join(__dirname, "..", "dist", "amico.js"); +beforeAll(() => { + execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); +}); + +function run(args: string[], env: Record = {}): { code: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); + return { code: 0, stdout, stderr: "" }; + } catch (e) { + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; + } +} + +// Authoring fixture — mirrors subcommands.test.ts so the delegated resolve/sandbox verbs +// exercise the SAME code path with the SAME registry. +const REGISTRY = ` +verify_tolerance = 0.01 +[[template]] +id = "transmon-gate-1q" +platform = "transmon" +kind = "gate_synthesis" +size = 1 +path = "solve_template.jl" +status = "vetted" +packages = ["Piccolo", "CairoMakie", "JLD2", "TOML", "Printf"] +[support] +packages = ["JLD2", "CairoMakie", "TOML", "Printf"] +[uuids] +Piccolo = "c4671d76-df94-11ed-2057-43d4fd632fad" +JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" +`; + +function authoringDir(): string { + const dir = mkdtempSync(join(tmpdir(), "amico-router-")); + writeFileSync(join(dir, "registry.toml"), REGISTRY); + writeFileSync(join(dir, "index.json"), JSON.stringify({ schema_version: 1, exemplars: [] })); + writeFileSync( + join(dir, "authoring.json"), + JSON.stringify({ + schema_version: 1, + allowlist: ["Piccolo", "Legato", "Intonato", "NamedTrajectories", "DirectTrajOpt"], + support_set: ["JLD2", "CairoMakie", "TOML", "Printf"], + registry: join(dir, "registry.toml"), + exemplars: join(dir, "index.json"), + verify_tolerance: 0.01, + }), + ); + return dir; +} + +describe("amico router — help + unknown verb", () => { + it("--help lists the full verb surface, exit 0", () => { + const r = run(["--help"]); + expect(r.code).toBe(0); + for (const v of ["run", "resolve", "sandbox", "catalog", "vault", "device", "note", "mcp-serve"]) { + expect(r.stdout).toContain(`amico ${v}`); + } + }); + it("bare `amico` (no verb) → usage, exit 64", () => { + const r = run([]); + expect(r.code).toBe(64); + expect(r.stdout).toContain("usage:"); + }); + it("unknown verb → exit 64, names the verb on stderr", () => { + const r = run(["frobnicate"]); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/unknown verb "frobnicate"/); + }); +}); + +describe("amico router — run delegates verbatim to the launch path", () => { + it("clean solve: relays iter lines, prints AMICODE_FINISHED, exits 0", () => { + const root = tmpRoot(); + const julia = fakeJulia(root, "j", `console.log('AMICODE_ITER iter=1 f=0.5'); console.log('DONE f=0.99')`); + const script = fakeJulia(root, "s.jl", ""); + const r = run(["run", script, "--runs-root", join(root, "runs"), "--julia", julia]); + expect(r.code).toBe(0); + expect(r.stdout).toContain("AMICODE_ITER iter=1 f=0.5"); + expect(r.stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0 runDir=.+/); + }); + it("run with no script → 64 with the amico-run launch-path error (delegation is verbatim)", () => { + const r = run(["run"]); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/amico-run: no script given/); + }); + it("run with an unknown flag → 64 (launch-path flag handling, not swallowed)", () => { + const root = tmpRoot(); + const r = run(["run", fakeJulia(root, "s.jl", ""), "--gates", "X"]); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/unknown flag/); + }); +}); + +describe("amico router — resolve/sandbox delegate verbatim to the subcommands", () => { + it("resolve: exact vetted shape → tier vetted JSON with template_path + packages", () => { + const dir = authoringDir(); + const r = run(["resolve", "--platform", "transmon", "--kind", "gate_synthesis", "--size", "1"], { + AMICO_AUTHORING_FILE: join(dir, "authoring.json"), + }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.tier).toBe("vetted"); + expect(out.template_path).toMatch(/solve_template\.jl$/); + expect(out.packages).toContain("Piccolo"); + rmSync(dir, { recursive: true, force: true }); + }); + it("sandbox: writes env/Project.toml with [deps] uuids", () => { + const dir = authoringDir(); + const target = mkdtempSync(join(tmpdir(), "amico-router-ws-")); + const r = run(["sandbox", target, "--packages", "Piccolo,JLD2"], { + AMICO_AUTHORING_FILE: join(dir, "authoring.json"), + }); + expect(r.code).toBe(0); + expect(existsSync(join(target, "env", "Project.toml"))).toBe(true); + const deps = readToml(join(target, "env", "Project.toml")).deps as Record; + expect(deps.Piccolo).toBe("c4671d76-df94-11ed-2057-43d4fd632fad"); + rmSync(dir, { recursive: true, force: true }); + rmSync(target, { recursive: true, force: true }); + }); +}); + +describe("amico router — spine verbs are B1 stubs (print intent, exit 0)", () => { + for (const name of ["catalog", "vault", "device", "note"]) { + it(`${name} routes, prints stub intent JSON, exits 0`, () => { + const r = run([name, "some", "args"]); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out).toMatchObject({ verb: name, stub: true }); + expect(out.args).toEqual(["some", "args"]); + expect(typeof out.intent).toBe("string"); + }); + } +}); + +describe("amico router — mcp-serve facade", () => { + it("--list renders each spine verb as an MCP tool, exit 0", () => { + const r = run(["mcp-serve", "--list"]); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + const names = out.tools.map((t: { name: string }) => t.name); + expect(names).toEqual( + expect.arrayContaining(["amico_catalog", "amico_vault", "amico_device", "amico_note"]), + ); + }); + it("no flag → stub note + tool list, exit 0 (cleanly)", () => { + const r = run(["mcp-serve"]); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.stub).toBe(true); + expect(out.tools).toEqual(expect.arrayContaining(["amico_catalog"])); + }); +}); From 5c490e519ab32dd57190d7fd9e8610dd094c3e04 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 03:33:48 -0400 Subject: [PATCH 02/49] feat(entities): composite System schema + enums + validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Component/Coupling/CompositeSystem types + closed enums (Role/CouplingKind/ Topology/DriveArch); validateCompositeSystem (open platform, per-component levels>=2, unique ids, coupling id-refs, mode-mediated exactly-one-mode) + soft compositeSystemWarnings. Alongside the flat SystemEntity (untouched) so each commit compiles. +10 vitest (47/47). Spec §2.1/§2.3/§7.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../extension/opencode-plugin/entities.ts | 145 ++++++++++++++++++ packages/extension/test/amicode_tools.test.ts | 115 ++++++++++++++ 2 files changed, 260 insertions(+) diff --git a/packages/extension/opencode-plugin/entities.ts b/packages/extension/opencode-plugin/entities.ts index 29afb950..1646dc1c 100644 --- a/packages/extension/opencode-plugin/entities.ts +++ b/packages/extension/opencode-plugin/entities.ts @@ -202,6 +202,151 @@ export function updateSystem(existing: SystemEntity, patch: SystemPatch): System return merged; } +// --- composite system (spec-20260709-023819) --------------------------------- +// The System entity becomes a COMPOSITE: components[] + couplings[] + topology + +// drive-arch, with single-qubit the degenerate N=1 case. Introduced alongside the +// flat SystemEntity above; normalizeSystem (below) migrates a flat on-disk entity +// to a composite on read. `Role`/`CouplingKind`/`Topology`/`DriveArch` are CLOSED +// validated sets; `platform` stays OPEN (any non-empty string), as the flat entity +// always was (spec §2.1). + +export const ROLES = ["qubit", "cavity", "resonator", "mode", "atom"] as const; +export type Role = (typeof ROLES)[number]; + +export const COUPLING_KINDS = ["exchange", "ZZ", "cross-resonance", "dispersive-chi", "vdW", "mode-mediated"] as const; +export type CouplingKind = (typeof COUPLING_KINDS)[number]; + +/** v1 presets only; ring/grid/star/all-to-all are deferred (spec §9). */ +export const TOPOLOGIES = ["single-pair", "linear-chain", "custom"] as const; +export type Topology = (typeof TOPOLOGIES)[number]; + +export const DRIVE_ARCHS = ["global", "per-component", "zoned"] as const; +export type DriveArch = (typeof DRIVE_ARCHS)[number]; + +export interface Component { + id: string; + role: Role; + /** Optional, per-component; when given an integer >= MIN_LEVELS. Absent = "levels TBD". */ + levels?: number; + params: Record; +} + +export interface Coupling { + /** >=2 component ids. Pairwise = 2 ids; a mode-mediated hyperedge lists the + * coupled components PLUS the shared mode's OWN component id. */ + between: string[]; + kind: CouplingKind; + params: Record; +} + +export interface CompositeSystem { + /** Open platform string (spec A), same rule as the flat entity. */ + platform: string; + components: Component[]; + couplings: Coupling[]; + /** Provenance: which preset generated `couplings` (undefined for hand-authored). */ + topology?: Topology; + drive: { arch: DriveArch }; + /** Free text; excluded from the canonical hash (like the flat entity). */ + notes?: string; +} + +/** Roles that carry a bosonic mode (the shared member of a mode-mediated edge). */ +const MODE_ROLES = new Set(["mode", "resonator"]); + +function isFiniteNumber(v: unknown): v is number { + return typeof v === "number" && Number.isFinite(v); +} + +/** Problems with a CompositeSystem; [] means valid. Closed sets for + * role/kind/topology/drive.arch; platform open; levels optional but >= 2. */ +export function validateCompositeSystem(e: CompositeSystem): string[] { + const problems: string[] = []; + if (typeof e.platform !== "string" || e.platform.trim() === "") { + problems.push("platform must be a non-empty string"); + } + if (!Array.isArray(e.components) || e.components.length < 1) { + problems.push("components must be a non-empty array"); + return problems; // nothing below is checkable without components + } + const ids = new Set(); + for (const c of e.components) { + if (typeof c.id !== "string" || c.id.trim() === "") problems.push(`component id must be a non-empty string`); + else if (ids.has(c.id)) problems.push(`duplicate component id "${c.id}"`); + else ids.add(c.id); + if (!(ROLES as readonly string[]).includes(c.role)) { + problems.push(`component "${c.id}" role must be one of ${ROLES.join("|")}, got ${JSON.stringify(c.role)}`); + } + if (c.levels !== undefined && (!Number.isInteger(c.levels) || c.levels < MIN_LEVELS)) { + problems.push(`component "${c.id}" levels, when given, must be an integer >= ${MIN_LEVELS}, got ${c.levels}`); + } + for (const [k, v] of Object.entries(c.params ?? {})) { + if (!isFiniteNumber(v)) problems.push(`component "${c.id}" param "${k}" must be a finite number, got ${v}`); + } + } + if (!Array.isArray(e.couplings)) { + problems.push("couplings must be an array"); + } else { + for (const cp of e.couplings) { + if (!Array.isArray(cp.between) || cp.between.length < 2) { + problems.push(`coupling.between must list >= 2 component ids`); + continue; + } + for (const id of cp.between) { + if (!ids.has(id)) problems.push(`coupling references unknown component id "${id}"`); + } + if (!(COUPLING_KINDS as readonly string[]).includes(cp.kind)) { + problems.push(`coupling.kind must be one of ${COUPLING_KINDS.join("|")}, got ${JSON.stringify(cp.kind)}`); + } + if (cp.kind === "mode-mediated") { + const modeMembers = cp.between.filter((id) => { + const comp = e.components.find((c) => c.id === id); + return comp !== undefined && MODE_ROLES.has(comp.role); + }); + if (modeMembers.length !== 1) { + problems.push( + `mode-mediated coupling must include exactly one component of role mode|resonator, found ${modeMembers.length}`, + ); + } + } + for (const [k, v] of Object.entries(cp.params ?? {})) { + if (!isFiniteNumber(v)) problems.push(`coupling param "${k}" must be a finite number, got ${v}`); + } + } + } + if (e.topology !== undefined && !(TOPOLOGIES as readonly string[]).includes(e.topology)) { + problems.push(`topology must be one of ${TOPOLOGIES.join("|")}, got ${JSON.stringify(e.topology)}`); + } + if (!e.drive || !(DRIVE_ARCHS as readonly string[]).includes(e.drive.arch)) { + problems.push(`drive.arch must be one of ${DRIVE_ARCHS.join("|")}, got ${JSON.stringify(e.drive?.arch)}`); + } + return problems; +} + +/** Soft per-role level guidance (NOT validation errors) — mirrors the flat + * entity's MAX_LEVELS soft-cap posture (spec §2.2). */ +const ROLE_LEVEL_HINTS: Partial> = { + qubit: { max: 5, note: "many levels for a qubit — worsens conditioning/leakage/cost" }, + atom: { max: 5, note: "many levels for an atom — worsens conditioning/leakage/cost" }, + cavity: { min: 4, note: "low Fock truncation for a cavity — may under-resolve the mode" }, + resonator: { min: 4, note: "low Fock truncation for a resonator — may under-resolve the mode" }, + mode: { min: 4, note: "low Fock truncation for a mode — may under-resolve the mode" }, +}; + +/** Soft warnings for a (valid) composite — never a rejection. */ +export function compositeSystemWarnings(e: CompositeSystem): string[] { + const warnings: string[] = []; + for (const c of e.components ?? []) { + if (c.levels === undefined) continue; + const hint = ROLE_LEVEL_HINTS[c.role]; + if (!hint) continue; + if ((hint.max !== undefined && c.levels > hint.max) || (hint.min !== undefined && c.levels < hint.min)) { + warnings.push(`component "${c.id}" (${c.role}, ${c.levels} levels): ${hint.note}`); + } + } + return warnings; +} + // --- TOML emission ------------------------------------------------------------- /** Escape a string for a TOML basic (double-quoted) string. */ diff --git a/packages/extension/test/amicode_tools.test.ts b/packages/extension/test/amicode_tools.test.ts index c0c16f50..7164f2f6 100644 --- a/packages/extension/test/amicode_tools.test.ts +++ b/packages/extension/test/amicode_tools.test.ts @@ -23,6 +23,9 @@ import { validateSystem, validateFormulation, updateSystem, + validateCompositeSystem, + compositeSystemWarnings, + type CompositeSystem, canonicalJson, deriveSlug, entityDiff, @@ -321,3 +324,115 @@ describe("problem + run-ref serializers", () => { expect((parse(t) as any).runs[0].tier).toBe("vetted"); }); }); + +describe("composite system schema + validation (spec-20260709)", () => { + const COMP: CompositeSystem = { + platform: "transmon", + components: [ + { id: "q1", role: "qubit", levels: 3, params: { omega: 4.8, delta: -0.2 } }, + { id: "q2", role: "qubit", levels: 3, params: { omega: 4.9, delta: -0.2 } }, + ], + couplings: [{ between: ["q1", "q2"], kind: "cross-resonance", params: { g: 0.005 } }], + topology: "single-pair", + drive: { arch: "per-component" }, + }; + + it("accepts a valid composite", () => { + expect(validateCompositeSystem(COMP)).toEqual([]); + }); + + it("N=1 (degenerate single-qubit) is valid with empty couplings", () => { + expect( + validateCompositeSystem({ + platform: "transmon", + components: [{ id: "q1", role: "qubit", levels: 3, params: {} }], + couplings: [], + drive: { arch: "per-component" }, + }), + ).toEqual([]); + }); + + it("accepts an arbitrary (open) platform string; rejects empty", () => { + expect(validateCompositeSystem({ ...COMP, platform: "fluxonium-xyz" })).toEqual([]); + expect(validateCompositeSystem({ ...COMP, platform: "" }).join(" ")).toMatch(/platform/); + }); + + it("rejects unknown role / kind / topology / drive.arch (closed sets)", () => { + expect( + validateCompositeSystem({ ...COMP, components: [{ id: "q1", role: "spin" as any, params: {} }] }).join(" "), + ).toMatch(/role/); + expect( + validateCompositeSystem({ + ...COMP, + couplings: [{ between: ["q1", "q2"], kind: "banana" as any, params: {} }], + }).join(" "), + ).toMatch(/kind/); + expect(validateCompositeSystem({ ...COMP, topology: "grid" as any }).join(" ")).toMatch(/topology/); + expect(validateCompositeSystem({ ...COMP, drive: { arch: "telepathy" as any } }).join(" ")).toMatch(/drive/); + }); + + it("rejects a coupling referencing an unknown component id", () => { + expect( + validateCompositeSystem({ + ...COMP, + couplings: [{ between: ["q1", "q9"], kind: "cross-resonance", params: {} }], + }).join(" "), + ).toMatch(/unknown component/); + }); + + it("rejects duplicate component ids", () => { + expect( + validateCompositeSystem({ ...COMP, components: [COMP.components[0], COMP.components[0]] }).join(" "), + ).toMatch(/duplicate/); + }); + + it("mode-mediated requires exactly one mode/resonator member (ion + motional mode)", () => { + const ok: CompositeSystem = { + platform: "ion", + components: [ + { id: "i1", role: "atom", params: {} }, + { id: "i2", role: "atom", params: {} }, + { id: "m1", role: "mode", levels: 8, params: {} }, + ], + couplings: [{ between: ["i1", "i2", "m1"], kind: "mode-mediated", params: { eta: 0.1 } }], + drive: { arch: "global" }, + }; + expect(validateCompositeSystem(ok)).toEqual([]); + const bad = { ...ok, couplings: [{ between: ["i1", "i2"], kind: "mode-mediated" as const, params: {} }] }; + expect(validateCompositeSystem(bad).join(" ")).toMatch(/mode-mediated/); + }); + + it("rejects non-integer / <2 component levels", () => { + expect( + validateCompositeSystem({ ...COMP, components: [{ id: "q1", role: "qubit", levels: 1, params: {} }] }).join(" "), + ).toMatch(/levels/); + expect( + validateCompositeSystem({ ...COMP, components: [{ id: "q1", role: "qubit", levels: 3.5, params: {} }] }).join(" "), + ).toMatch(/levels/); + }); + + it("heterogeneous cavity+qubit (bosonic) is native", () => { + const bosonic: CompositeSystem = { + platform: "bosonic", + components: [ + { id: "q1", role: "qubit", levels: 2, params: {} }, + { id: "cav", role: "cavity", levels: 12, params: { kerr: -0.001 } }, + ], + couplings: [{ between: ["q1", "cav"], kind: "dispersive-chi", params: { chi: 0.002 } }], + drive: { arch: "per-component" }, + }; + expect(validateCompositeSystem(bosonic)).toEqual([]); + }); + + it("compositeSystemWarnings are soft (never a rejection)", () => { + const lowCav: CompositeSystem = { + platform: "bosonic", + components: [{ id: "cav", role: "cavity", levels: 2, params: {} }], + couplings: [], + drive: { arch: "per-component" }, + }; + expect(validateCompositeSystem(lowCav)).toEqual([]); // valid... + expect(compositeSystemWarnings(lowCav).join(" ")).toMatch(/Fock/); // ...but warned + expect(compositeSystemWarnings(COMP)).toEqual([]); // clean 3-level qubits, no warning + }); +}); From 0e90f36467ac5d58a88a6e4b1a4acf4df323866d Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 03:40:11 -0400 Subject: [PATCH 03/49] feat(entities): normalizeSystem (flat->N=1) + composite merge/toml/hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit platformDefaultRole/Arch (spec §2.1 table); normalizeSystem read-shim (idempotent on composite, notes carried); updateCompositeSystem normalizes a flat existing first (F1 — flat on-disk + composite patch merges cleanly); compositeSystemToml (AoT + inline params, smol-toml round-trip verified); canonicalJson unchanged (drops notes). +6 vitest (53/53). Spec §6/§2.3. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../extension/opencode-plugin/entities.ts | 116 ++++++++++++++++++ packages/extension/test/amicode_tools.test.ts | 74 +++++++++++ 2 files changed, 190 insertions(+) diff --git a/packages/extension/opencode-plugin/entities.ts b/packages/extension/opencode-plugin/entities.ts index 1646dc1c..047b0d2b 100644 --- a/packages/extension/opencode-plugin/entities.ts +++ b/packages/extension/opencode-plugin/entities.ts @@ -347,6 +347,122 @@ export function compositeSystemWarnings(e: CompositeSystem): string[] { return warnings; } +// --- migration + merge (spec §6, §2.3) --------------------------------------- + +/** Platform → default component role (spec §2.1 table). Unknown → qubit. */ +export function platformDefaultRole(platform: string): Role { + return platform.toLowerCase() === "rydberg" ? "atom" : "qubit"; +} + +/** Platform → default drive arch (spec §2.1 table). rydberg/ion → global; else per-component. */ +export function platformDefaultArch(platform: string): DriveArch { + const p = platform.toLowerCase(); + return p === "rydberg" || p === "ion" ? "global" : "per-component"; +} + +/** Read-shim (spec §6): a flat on-disk `{platform, levels, params, notes}` becomes an N=1 + * composite; an already-composite value passes through (idempotent), filling a default + * `drive` if absent. Pure; tolerant of raw JSON (never throws). This is the sole flat→composite + * path — the plugin invokes it at every read site (amicode_tools.ts). */ +export function normalizeSystem(raw: unknown): CompositeSystem { + const r = (raw ?? {}) as Record; + const platform = typeof r.platform === "string" ? r.platform : ""; + if (Array.isArray(r.components)) { + const rawDrive = r.drive as { arch?: unknown } | undefined; + const drive = + rawDrive && typeof rawDrive.arch === "string" + ? { arch: rawDrive.arch as DriveArch } + : { arch: platformDefaultArch(platform) }; + const out: CompositeSystem = { + platform, + components: r.components as Component[], + couplings: Array.isArray(r.couplings) ? (r.couplings as Coupling[]) : [], + drive, + }; + if (typeof r.topology === "string") out.topology = r.topology as Topology; + if (typeof r.notes === "string") out.notes = r.notes; + return out; + } + // flat → N=1 composite + const comp: Component = { + id: "q1", + role: platformDefaultRole(platform), + params: (r.params as Record) ?? {}, + }; + if (typeof r.levels === "number") comp.levels = r.levels; + const out: CompositeSystem = { platform, components: [comp], couplings: [], drive: { arch: platformDefaultArch(platform) } }; + if (typeof r.notes === "string") out.notes = r.notes; + return out; +} + +export interface CompositeSystemPatch { + /** Upserted by `id` (existing component of that id is field-merged; else appended). */ + components?: Component[]; + /** Replaces the coupling set wholesale (edges are a set, not field-merged). */ + couplings?: Coupling[]; + topology?: Topology; + drive?: { arch: DriveArch }; + notes?: string; +} + +/** Merge a composite patch into an existing System (pure; input never mutated). F1: `existing` + * is `normalizeSystem`d first, so a legacy FLAT on-disk entity merges cleanly with a composite + * patch. Throws if the RESULT is invalid (a bad patch can't corrupt a valid recorded entity). */ +export function updateCompositeSystem(existing: unknown, patch: CompositeSystemPatch): CompositeSystem { + const base = normalizeSystem(existing); + const components = base.components.map((c) => ({ ...c })); + for (const pc of patch.components ?? []) { + const i = components.findIndex((c) => c.id === pc.id); + if (i >= 0) components[i] = { ...components[i], ...pc, params: { ...components[i].params, ...(pc.params ?? {}) } }; + else components.push(pc); + } + const merged: CompositeSystem = { + platform: base.platform, + components, + couplings: patch.couplings ?? base.couplings, + drive: patch.drive ?? base.drive, + }; + const topology = patch.topology ?? base.topology; + if (topology !== undefined) merged.topology = topology; + const notes = patch.notes ?? base.notes; + if (notes !== undefined) merged.notes = notes; + const problems = validateCompositeSystem(merged); + if (problems.length) throw new Error(`invalid composite system after merge: ${problems.join("; ")}`); + return merged; +} + +/** Serialize a CompositeSystem: [system] platform/topology/notes/recorded + [system.drive] + + * [[system.components]] (params as an inline table) + [[system.couplings]]. Throws on invalid. + * (tomlEscape/tomlKey/tomlNumber/isoNow are hoisted function declarations below.) */ +export function compositeSystemToml(e: CompositeSystem, now?: Date): string { + const problems = validateCompositeSystem(e); + if (problems.length) throw new Error(`invalid composite system: ${problems.join("; ")}`); + const inlineParams = (p: Record): string => { + const entries = Object.entries(p); + return entries.length === 0 ? "{}" : `{ ${entries.map(([k, v]) => `${tomlKey(k)} = ${tomlNumber(v)}`).join(", ")} }`; + }; + const lines: string[] = ["[system]", `platform = ${tomlEscape(e.platform)}`]; + if (e.topology !== undefined) lines.push(`topology = ${tomlEscape(e.topology)}`); + if (e.notes !== undefined) lines.push(`notes = ${tomlEscape(e.notes)}`); + lines.push(`recorded = ${tomlEscape(isoNow(now))}`); + lines.push("", "[system.drive]", `arch = ${tomlEscape(e.drive.arch)}`); + for (const c of e.components) { + lines.push("", "[[system.components]]", `id = ${tomlEscape(c.id)}`, `role = ${tomlEscape(c.role)}`); + if (c.levels !== undefined) lines.push(`levels = ${c.levels}`); + lines.push(`params = ${inlineParams(c.params)}`); + } + for (const cp of e.couplings) { + lines.push( + "", + "[[system.couplings]]", + `between = [${cp.between.map(tomlEscape).join(", ")}]`, + `kind = ${tomlEscape(cp.kind)}`, + `params = ${inlineParams(cp.params)}`, + ); + } + return lines.join("\n") + "\n"; +} + // --- TOML emission ------------------------------------------------------------- /** Escape a string for a TOML basic (double-quoted) string. */ diff --git a/packages/extension/test/amicode_tools.test.ts b/packages/extension/test/amicode_tools.test.ts index 7164f2f6..b838bf79 100644 --- a/packages/extension/test/amicode_tools.test.ts +++ b/packages/extension/test/amicode_tools.test.ts @@ -25,6 +25,9 @@ import { updateSystem, validateCompositeSystem, compositeSystemWarnings, + normalizeSystem, + updateCompositeSystem, + compositeSystemToml, type CompositeSystem, canonicalJson, deriveSlug, @@ -436,3 +439,74 @@ describe("composite system schema + validation (spec-20260709)", () => { expect(compositeSystemWarnings(COMP)).toEqual([]); // clean 3-level qubits, no warning }); }); + +describe("normalizeSystem + composite merge/toml/hash (spec-20260709)", () => { + const COMPOSITE: CompositeSystem = { + platform: "transmon", + components: [ + { id: "q1", role: "qubit", levels: 3, params: { omega: 4.8, delta: -0.2 } }, + { id: "q2", role: "qubit", levels: 3, params: { omega: 4.9, delta: -0.2 } }, + ], + couplings: [{ between: ["q1", "q2"], kind: "cross-resonance", params: { g: 0.005 } }], + topology: "single-pair", + drive: { arch: "per-component" }, + }; + + it("flat → N=1 composite (levels→components[0], notes carried, role/arch from platform)", () => { + const c = normalizeSystem({ platform: "transmon", levels: 3, params: { omega: 4.8 }, notes: "prose" }); + expect(c.components).toHaveLength(1); + expect(c.components[0]).toMatchObject({ id: "q1", role: "qubit", levels: 3, params: { omega: 4.8 } }); + expect(c.couplings).toEqual([]); + expect(c.drive.arch).toBe("per-component"); + expect(c.notes).toBe("prose"); + expect(validateCompositeSystem(c)).toEqual([]); + }); + + it("rydberg→atom/global; unknown→qubit/per-component; absent flat levels stays absent", () => { + const r = normalizeSystem({ platform: "rydberg", levels: 3, params: {} }); + expect(r.components[0].role).toBe("atom"); + expect(r.drive.arch).toBe("global"); + const u = normalizeSystem({ platform: "fluxonium", params: {} }); + expect(u.components[0].role).toBe("qubit"); + expect(u.components[0].levels).toBeUndefined(); + expect(u.drive.arch).toBe("per-component"); + }); + + it("is idempotent on an already-composite entity", () => { + expect(normalizeSystem(COMPOSITE)).toEqual(COMPOSITE); + }); + + it("updateCompositeSystem tolerates a FLAT existing (F1) + merges a composite patch", () => { + const merged = updateCompositeSystem( + { platform: "transmon", levels: 3, params: { omega: 4.8 } }, + { + components: [{ id: "q2", role: "qubit", levels: 3, params: { omega: 4.9 } }], + couplings: [{ between: ["q1", "q2"], kind: "cross-resonance", params: { g: 0.005 } }], + topology: "single-pair", + drive: { arch: "per-component" }, + }, + ); + expect(merged.components.map((c) => c.id).sort()).toEqual(["q1", "q2"]); + expect(merged.couplings).toHaveLength(1); + expect(merged.topology).toBe("single-pair"); + expect(validateCompositeSystem(merged)).toEqual([]); + }); + + it("compositeSystemToml round-trips through smol-toml (AoT + inline params)", () => { + const doc = parse(compositeSystemToml(COMPOSITE)) as any; + expect(doc.system.platform).toBe("transmon"); + expect(doc.system.topology).toBe("single-pair"); + expect(doc.system.drive.arch).toBe("per-component"); + expect(doc.system.components).toHaveLength(2); + expect(doc.system.components[0].id).toBe("q1"); + expect(doc.system.components[0].params.omega).toBe(4.8); + expect(doc.system.couplings[0].kind).toBe("cross-resonance"); + expect(doc.system.couplings[0].between).toEqual(["q1", "q2"]); + }); + + it("canonicalJson drops notes → composites differing only in notes hash-equal", () => { + const a = normalizeSystem({ platform: "transmon", levels: 3, params: { omega: 4.8 }, notes: "x" }); + const b = normalizeSystem({ platform: "transmon", levels: 3, params: { omega: 4.8 }, notes: "DIFFERENT" }); + expect(canonicalJson(a)).toBe(canonicalJson(b)); + }); +}); From 07a6c08e1d7689dac92bb22950a1cabe2c31f4f5 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 03:41:32 -0400 Subject: [PATCH 04/49] feat(entities): topology expansion + homogeneous replicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expandTopology (single-pair->1 edge, linear-chain->N-1, custom->[], deferred presets throw §9); replicateHomogeneous (q1..qN, deep-copied params). Pure. +7 vitest (60/60). Spec §2.3/§4.2/§7.3/§7.4. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../extension/opencode-plugin/entities.ts | 50 +++++++++++++++++++ packages/extension/test/amicode_tools.test.ts | 38 ++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/packages/extension/opencode-plugin/entities.ts b/packages/extension/opencode-plugin/entities.ts index 047b0d2b..e7f41f3e 100644 --- a/packages/extension/opencode-plugin/entities.ts +++ b/packages/extension/opencode-plugin/entities.ts @@ -463,6 +463,56 @@ export function compositeSystemToml(e: CompositeSystem, now?: Date): string { return lines.join("\n") + "\n"; } +// --- topology + replicate (spec §2.3, §4.2) ---------------------------------- + +/** Expand a v1 topology preset into explicit edges over `componentIds` (canonical order), + * each stamped with `kind` + shared `params`. `custom` returns [] (edges authored directly). + * ring/grid/star/all-to-all are deferred (spec §9) and throw. Bad arity throws. */ +export function expandTopology( + topology: Topology, + componentIds: string[], + kind: CouplingKind, + params: Record = {}, +): Coupling[] { + switch (topology) { + case "custom": + return []; + case "single-pair": + if (componentIds.length !== 2) { + throw new Error(`single-pair topology needs exactly 2 components, got ${componentIds.length}`); + } + return [{ between: [componentIds[0], componentIds[1]], kind, params: { ...params } }]; + case "linear-chain": { + if (componentIds.length < 2) { + throw new Error(`linear-chain topology needs >= 2 components, got ${componentIds.length}`); + } + const edges: Coupling[] = []; + for (let i = 0; i + 1 < componentIds.length; i++) { + edges.push({ between: [componentIds[i], componentIds[i + 1]], kind, params: { ...params } }); + } + return edges; + } + default: + throw new Error(`topology "${topology}" is deferred (spec §9); v1 supports single-pair|linear-chain|custom`); + } +} + +/** Replicate a homogeneous component template into N components identical except `id` + * (`${prefix}1..${prefix}N`) — so couplings/topology can reference them (spec §4.2). */ +export function replicateHomogeneous( + template: Omit, + n: number, + prefix = "q", +): Component[] { + if (!Number.isInteger(n) || n < 1) throw new Error(`replicateHomogeneous needs n >= 1, got ${n}`); + return Array.from({ length: n }, (_, i) => ({ + id: `${prefix}${i + 1}`, + role: template.role, + ...(template.levels !== undefined ? { levels: template.levels } : {}), + params: { ...template.params }, + })); +} + // --- TOML emission ------------------------------------------------------------- /** Escape a string for a TOML basic (double-quoted) string. */ diff --git a/packages/extension/test/amicode_tools.test.ts b/packages/extension/test/amicode_tools.test.ts index b838bf79..87e8b8aa 100644 --- a/packages/extension/test/amicode_tools.test.ts +++ b/packages/extension/test/amicode_tools.test.ts @@ -28,6 +28,8 @@ import { normalizeSystem, updateCompositeSystem, compositeSystemToml, + expandTopology, + replicateHomogeneous, type CompositeSystem, canonicalJson, deriveSlug, @@ -510,3 +512,39 @@ describe("normalizeSystem + composite merge/toml/hash (spec-20260709)", () => { expect(canonicalJson(a)).toBe(canonicalJson(b)); }); }); + +describe("topology expansion + homogeneous replicate (spec-20260709)", () => { + it("single-pair → 1 edge over [q1,q2]", () => { + const edges = expandTopology("single-pair", ["q1", "q2"], "cross-resonance", { g: 0.005 }); + expect(edges).toEqual([{ between: ["q1", "q2"], kind: "cross-resonance", params: { g: 0.005 } }]); + }); + it("linear-chain(N) → N-1 edges in canonical order", () => { + const edges = expandTopology("linear-chain", ["q1", "q2", "q3", "q4"], "exchange"); + expect(edges.map((e) => e.between)).toEqual([ + ["q1", "q2"], + ["q2", "q3"], + ["q3", "q4"], + ]); + expect(edges.every((e) => e.kind === "exchange")).toBe(true); + }); + it("custom → [] (edges authored directly)", () => { + expect(expandTopology("custom", ["q1", "q2"], "ZZ")).toEqual([]); + }); + it("single-pair with wrong arity throws", () => { + expect(() => expandTopology("single-pair", ["q1", "q2", "q3"], "ZZ")).toThrow(/single-pair/); + }); + it("deferred presets (e.g. ring) throw §9", () => { + expect(() => expandTopology("ring" as any, ["q1", "q2"], "ZZ")).toThrow(/deferred/); + }); + it("replicateHomogeneous → N components identical except id (q1..qN)", () => { + const comps = replicateHomogeneous({ role: "qubit", levels: 3, params: { omega: 4.8 } }, 3); + expect(comps.map((c) => c.id)).toEqual(["q1", "q2", "q3"]); + expect(comps.every((c) => c.role === "qubit" && c.levels === 3 && c.params.omega === 4.8)).toBe(true); + // mutating one component's params must not alias the others + comps[0].params.omega = 9; + expect(comps[1].params.omega).toBe(4.8); + }); + it("replicateHomogeneous rejects n < 1", () => { + expect(() => replicateHomogeneous({ role: "qubit", params: {} }, 0)).toThrow(/n >= 1/); + }); +}); From e13e9d64b8e0d056c5f069c42ccb6699bd53e4cc Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 10:43:01 -0400 Subject: [PATCH 05/49] feat(tools): composite-aware pick_system/set_model (F1 + F2) pick_system seeds an N=1 composite (platform-default role/arch, known-platform levels=3 on components[0]); set_model accepts components/couplings/topology/ drive_arch (+ levels/drive_max/params back-compat onto the first component). F1: normalizes a flat on-disk existing before merge, and recordEntity normalizes the system before-snapshot -> no spurious flat->composite diff. F2: a non-custom topology preset expands to edges via expandTopology (coupling_kind-stamped). Array args use nested-optional-safe schemas (item required omits levels; never type:[..,null] inside items). Verified: 60/60 entities vitest + bun parse/resolve (plugin not tsc-covered by design). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode-plugin/amicode_tools.ts | 176 +++++++++++++++--- 1 file changed, 147 insertions(+), 29 deletions(-) diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts index dc97916c..a8a870c2 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -41,7 +41,22 @@ import { truncateDiffForSentinel, KNOWN_PLATFORMS, MAX_LEVELS, + compositeSystemToml, + validateCompositeSystem, + compositeSystemWarnings, + normalizeSystem, + updateCompositeSystem, + expandTopology, + platformDefaultRole, + platformDefaultArch, type SystemEntity, + type CompositeSystem, + type Component, + type Coupling, + type CompositeSystemPatch, + type CouplingKind, + type Topology, + type DriveArch, type FormulationEntity, type RunStub, type DeviceSessionStub, @@ -136,7 +151,13 @@ function recordEntity( toml: string, source: { tool: string; stage?: string }, ): string { - const before = readEntityJson>(slug, kind); + const before0 = readEntityJson>(slug, kind); + // F1 (spec §6): normalize a system `before` snapshot to composite so the diff is + // composite-vs-composite, not a spurious flat→composite restructure on first touch. + const before = + kind === "system" && before0 !== undefined + ? (normalizeSystem(before0) as unknown as Record) + : before0; const action: "created" | "updated" = before ? "updated" : "created"; writeEntityFiles(slug, kind, toml, JSON.stringify(entity, null, 2) + "\n"); const diff = entityDiff(before, entity); @@ -319,20 +340,26 @@ export const AmicodeTools = async (_input: unknown) => ({ const params: Record = {}; if (given(a.omega)) params.omega = a.omega; if (given(a.delta)) params.delta = a.delta; - // Known platforms default to a sensible model size; unknown ones get no - // levels default (recorded honestly — spec A). + // Seed an N=1 COMPOSITE (spec §2/§3): one component with the platform's default role. + // Known platforms default to 3 levels; unknown ones stay "levels TBD" (recorded honestly). const known = (KNOWN_PLATFORMS as readonly string[]).includes(a.platform); - const entity: SystemEntity = { platform: a.platform, params }; - if (known) entity.levels = 3; + const seed: Component = { id: "q1", role: platformDefaultRole(a.platform), params }; + if (known) seed.levels = 3; + const entity: CompositeSystem = { + platform: a.platform, + components: [seed], + couplings: [], + drive: { arch: platformDefaultArch(a.platform) }, + }; if (given(a.notes)) entity.notes = a.notes; - const problems = validateSystem(entity); + const problems = validateCompositeSystem(entity); if (problems.length) return `Cannot record system: ${problems.join("; ")}`; - const sentinel = recordEntity(meta.slug, "system", entity as any, systemToml(entity), { + const sentinel = recordEntity(meta.slug, "system", entity as any, compositeSystemToml(entity), { tool: "amicode_pick_system", stage: "platform", }); completeStage(dir, "platform"); - const levelsDesc = entity.levels !== undefined ? `${entity.levels} levels` : "levels TBD"; + const levelsDesc = seed.levels !== undefined ? `${seed.levels} levels` : "levels TBD"; if (a.platform === "transmon") { return ( `Transmon it is — ${levelsDesc}, ${paramsSummary(params)}. Filed under "${meta.slug}".\n\n` + @@ -365,48 +392,139 @@ export const AmicodeTools = async (_input: unknown) => ({ amicode_set_model: { description: - "Merge model details (interview stage 2: MODEL) into the recorded System entity: " + - "levels, drive_max, and any extra named numeric parameters. Requires " + - "amicode_pick_system to have run first. Bookkeeping only.", + "Merge model details into the recorded COMPOSITE System entity (interview stages MODEL / " + + "STRUCTURE / COMPONENT-PARAMS / COUPLINGS — all sub-steps of the `model` gate). Requires " + + "amicode_pick_system first. Two ways to use it, mixable in one call: (a) single-qubit " + + "back-compat — `levels`/`drive_max`/`params` fold onto the first component; (b) composite — " + + "`components` (upserted by id), `couplings` (replaces the set), `topology` (a preset expands " + + "to edges — pass `coupling_kind`), `drive_arch`. Bookkeeping only.", args: { levels: { type: ["integer", "null"], - description: "Number of levels to model (>=2, default 3); null to leave unchanged.", + description: "Back-compat: levels for the FIRST component (>=2); null to leave unchanged.", }, drive_max: { type: ["number", "null"], - description: "Drive amplitude bound (GHz); null to leave unchanged.", + description: "Back-compat: drive amplitude bound (GHz) onto the first component; null to skip.", }, params: { type: ["object", "null"], additionalProperties: { type: "number" }, - description: 'Extra named numeric model parameters to merge (e.g. {"T1": 80}); null for none.', + description: "Back-compat: extra numeric params merged onto the FIRST component; null for none.", + }, + components: { + // Array-of-objects arg. legacyJsonSchema only strips "null" at THIS top level (not inside + // `items`), so per-object optionals (levels) are expressed by OMITTING from `required`, + // NEVER a nested type:["integer","null"] (that re-trips the Gemini rejection). + type: ["array", "null"], + items: { + type: "object", + properties: { + id: { type: "string" }, + role: { type: "string" }, + levels: { type: "integer" }, + params: { type: "object", additionalProperties: { type: "number" } }, + }, + required: ["id", "role", "params"], + }, + description: + "Components to upsert by id. role ∈ qubit|cavity|resonator|mode|atom. levels optional. " + + "For N identical components, list them all (ids q1..qN).", + }, + couplings: { + type: ["array", "null"], + items: { + type: "object", + properties: { + between: { type: "array", items: { type: "string" } }, + kind: { type: "string" }, + params: { type: "object", additionalProperties: { type: "number" } }, + }, + required: ["between", "kind", "params"], + }, + description: + "Explicit coupling edges (replaces the set). between = >=2 component ids (a mode-mediated " + + "hyperedge includes the shared mode's id). kind ∈ exchange|ZZ|cross-resonance|dispersive-chi|vdW|mode-mediated.", + }, + topology: { + type: ["string", "null"], + description: "Preset provenance: single-pair | linear-chain | custom. A non-custom preset expands to edges (pass coupling_kind).", + }, + coupling_kind: { + type: ["string", "null"], + description: "Edge kind stamped when a topology preset expands into couplings (e.g. cross-resonance, vdW).", + }, + coupling_params: { + type: ["object", "null"], + additionalProperties: { type: "number" }, + description: "Shared numeric params for the edges an expanding topology preset generates.", + }, + drive_arch: { + type: ["string", "null"], + description: "Drive architecture: global | per-component | zoned.", }, }, - async execute(a: { levels?: number | null; drive_max?: number | null; params?: Record | null }) { + async execute(a: { + levels?: number | null; + drive_max?: number | null; + params?: Record | null; + components?: Component[] | null; + couplings?: Coupling[] | null; + topology?: Topology | null; + coupling_kind?: CouplingKind | null; + coupling_params?: Record | null; + drive_arch?: DriveArch | null; + }) { const meta = ensureActiveProblem(); const dir = problemDir(meta.slug); const blocked = guardAndRecordStage(problemsDir(), dir, "model"); if (blocked) return blocked; - const existing = readEntityJson(meta.slug, "system"); - if (!existing) return "No system recorded yet — call amicode_pick_system first (interview stage 1)."; - const patchParams: Record = { ...(given(a.params) ? a.params : {}) }; - if (given(a.drive_max)) patchParams.drive_max = a.drive_max; + const existingRaw = readEntityJson>(meta.slug, "system"); + if (!existingRaw) return "No system recorded yet — call amicode_pick_system first (interview stage 1)."; + const existing = normalizeSystem(existingRaw); // F1: tolerate a legacy flat on-disk entity + + const patch: CompositeSystemPatch = {}; + if (given(a.components)) patch.components = a.components; + if (given(a.couplings)) patch.couplings = a.couplings; + if (given(a.topology)) patch.topology = a.topology; + if (given(a.drive_arch)) patch.drive = { arch: a.drive_arch }; + // Back-compat single-field path: fold levels/drive_max/params onto the FIRST component + // (only when no explicit `components` array was given). + if (!given(a.components) && (given(a.levels) || given(a.drive_max) || given(a.params))) { + const first = existing.components[0]; + const c: Component = { id: first.id, role: first.role, params: { ...(given(a.params) ? a.params : {}) } }; + if (given(a.drive_max)) c.params.drive_max = a.drive_max; + if (given(a.levels)) c.levels = a.levels; + patch.components = [c]; + } + try { - const merged = updateSystem(existing, { - levels: given(a.levels) ? a.levels : undefined, - params: patchParams, - }); - const sentinel = recordEntity(meta.slug, "system", merged as any, systemToml(merged), { + let merged = updateCompositeSystem(existing, patch); + // F2 (spec §2.3): a non-custom topology preset expands to explicit couplings when none + // were supplied. Needs coupling_kind; without it we record the topology and note it. + let couplingNote = ""; + if (given(a.topology) && a.topology !== "custom" && !given(a.couplings)) { + if (given(a.coupling_kind)) { + const ids = merged.components.map((c) => c.id); + const edges = expandTopology(a.topology, ids, a.coupling_kind, given(a.coupling_params) ? a.coupling_params : {}); + merged = updateCompositeSystem(merged, { couplings: edges }); + } else { + couplingNote = ` (topology recorded — pass coupling_kind to expand it into edges)`; + } + } + const sentinel = recordEntity(meta.slug, "system", merged as any, compositeSystemToml(merged), { tool: "amicode_set_model", stage: "model", }); completeStage(dir, "model"); - const warn = - merged.levels !== undefined && merged.levels > MAX_LEVELS - ? ` ⚠️ ${merged.levels} levels worsens conditioning/leakage and solve cost — convergence may degrade.` - : ""; - return `Tweaked — ${merged.platform}, ${merged.levels ?? "levels TBD"}, ${paramsSummary(merged.params)}.${warn}\n\n${sentinel}`; + const warnings = compositeSystemWarnings(merged); + const warn = warnings.length ? ` ⚠️ ${warnings.join("; ")}` : ""; + const nC = merged.components.length; + const nK = merged.couplings.length; + return ( + `Tweaked — ${merged.platform}: ${nC} component${nC === 1 ? "" : "s"}, ${nK} coupling${nK === 1 ? "" : "s"}, ` + + `drive ${merged.drive.arch}.${couplingNote}${warn}\n\n${sentinel}` + ); } catch (err) { return `Cannot update model: ${err instanceof Error ? err.message : String(err)}`; } From 99e8335b74fd3dcf5efce4fc7646c3ca53272a52 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 10:52:23 -0400 Subject: [PATCH 06/49] feat(amicode-prompt): structure-first + frontier-batched multipartite interview AGENTS.md + SCORE.md MODEL stage rewritten for the composite System: ask STRUCTURE first (count / homogeneous? / topology / drive-arch, singly), then batch the mechanical per-component params in one question form (homogeneous -> ask once, replicate to N); record via ONE composite amicode_set_model call (components upserted q1..qN; topology preset + coupling_kind expands to edges; drive_arch). Single-qubit stays the N=1 flow. Adds the frontier-batching policy (batch mechanical, keep semantic singular; value/relevance guardrails) and the stage-gate note (STRUCTURE/COMPONENT-PARAMS/COUPLINGS are model sub-steps, gate sequence unchanged). SCORE version NOT bumped (guidance prose, not a stage-contract change). Prose; verified by Task 9 fixtures + Task 10 live. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/AGENTS.md | 28 +++++++++++++++++-- .../extension/scores/pulse-designer/SCORE.md | 11 ++++++-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index c0b66fad..60b98746 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -201,9 +201,31 @@ Stages, in order: scan, slow at 2 qubits (splice params into the exemplar; the gate's masked-baseline check keeps its physics intact). Don't tell the user Rydberg is unsupported. -2. **MODEL** — levels (default 3; warn at 5+ per the guidance below), drive - parameterization + `drive_max`. Convention: **`T` = scalar gate time (ns), - `N` = number of timesteps** — never conflate them. Record via `amicode_set_model`. +2. **MODEL (structure-first, then batch)** — the System is a **composite**: + components + couplings + drive-architecture, with a single qubit as the + degenerate **N=1** case. Ask the STRUCTURE first (it gates everything, so keep + these conversational/singular): **how many components** (single / a pair / a + chain of N / custom) · **are they homogeneous** (all identical)? · **topology** + if N>1 (`single-pair` | `linear-chain` | `custom`) · **drive-arch** + (`global` | `per-component` | `zoned`, platform-defaulted). THEN batch the + mechanical params in one `question` form: per-component `levels` (default 3; + warn 5+), `drive_max`, ω/δ — **if homogeneous, ask once and replicate to N** + (a 10-qubit chain is one form, not ten). Record it all in ONE + `amicode_set_model` call: `components` (upserted by id, ids `q1..qN`), + `couplings` (or a `topology` preset + `coupling_kind` — the preset expands to + edges), `drive_arch`. Single-qubit stays the old one-component flow + (`levels`/`drive_max` fold onto the first component). Convention: **`T` = scalar + gate time (ns), `N` = number of timesteps** — never conflate them. + - **Frontier-batching:** batch questions whose prerequisites are already + answered into ONE `question` call, but keep the *semantic/branching* picks + singular — platform, target-gate, objective. Never batch a question whose + OPTIONS depend on an unanswered prior (e.g. the gate list needs the platform), + nor one whose RELEVANCE depends on a pending answer (e.g. topology only if + N>1). Batch the mechanical (numbers), converse on the judgment. + - **Stage-gate note:** STRUCTURE / COMPONENT-PARAMS / COUPLINGS are all + sub-steps of this one `MODEL` gate (recorded via `amicode_set_model`) — the + interview's `platform → model → formulate → solve → hardware` gate sequence + is unchanged. 3. **MODE** — simulate first, or straight to solve? Warm start available? (If yes: the warm-start idiom below, `load_traj`.) 4. **PROBLEM** — gate synthesis vs state prep; the target (X, Y, Z, H, S, T, diff --git a/packages/extension/scores/pulse-designer/SCORE.md b/packages/extension/scores/pulse-designer/SCORE.md index b3c67291..fba0949e 100644 --- a/packages/extension/scores/pulse-designer/SCORE.md +++ b/packages/extension/scores/pulse-designer/SCORE.md @@ -186,8 +186,15 @@ Per-stage notes: **invoke it by name** for the physics before authoring — do not hand-roll the Hamiltonian from memory when a skill carries it. -2. **model** — convention: **`T` = scalar gate time (ns), `N` = number of - timesteps** — never conflate them. Record via `amicode_set_model`. +2. **model** — the System is a **composite** (components + couplings + drive-arch; + single qubit = N=1). Go **structure-first** (how many components · homogeneous? · + topology if N>1 · drive-arch — asked singly), THEN **batch** the mechanical + per-component params in one `question` form (homogeneous → ask once, replicate to + N). Record it all in ONE `amicode_set_model` call (`components` upserted by id + `q1..qN`; `couplings` or a `topology` preset + `coupling_kind` that expands to + edges; `drive_arch`). STRUCTURE/COMPONENT-PARAMS/COUPLINGS are sub-steps of THIS + `model` gate — not new gates. Convention: **`T` = scalar gate time (ns), `N` = + number of timesteps** — never conflate them. Levels are **platform-dependent** — do not default to 3 blindly. A **transmon** qubit keeps 3 (default) or 4 for leakage realism; avoid 5+ (worse conditioning/leakage, higher solve cost). A **cavity / bosonic From c60420fb8fdb8c099ac5782c78704c91977ba90e Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 10:58:55 -0400 Subject: [PATCH 07/49] feat(amicode): composite->solve.jl authoring map + golden skeleton fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md gains the Composite authoring map (components/couplings/drive -> Piccolo constructor: TransmonSystem / MultiTransmonSystem / {Global,LocalDetune, Zoned}RydbergSystem / bosonic / mode-mediated; free_phase=N; EmbeddedOperator on the subspace) with the free-tier honesty caveat (authoring-aware, NOT tier- wired). Three golden reference skeletons (2-transmon CZ, Rydberg CZ global, cavity+qubit) + a snapshot presence-check vitest (3/3) — structural only, NOT a mapping function (F3; §9 non-goal). Spec §5/§7.7. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/AGENTS.md | 28 +++++++++++++++ .../test/composite_skeletons.test.ts | 36 +++++++++++++++++++ .../composite-skeletons/cavity-qubit.jl | 19 ++++++++++ .../composite-skeletons/cz-2transmon.jl | 20 +++++++++++ .../composite-skeletons/cz-rydberg-global.jl | 18 ++++++++++ 5 files changed, 121 insertions(+) create mode 100644 packages/extension/test/composite_skeletons.test.ts create mode 100644 packages/extension/test/fixtures/composite-skeletons/cavity-qubit.jl create mode 100644 packages/extension/test/fixtures/composite-skeletons/cz-2transmon.jl create mode 100644 packages/extension/test/fixtures/composite-skeletons/cz-rydberg-global.jl diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index 60b98746..f92b54ee 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -254,6 +254,34 @@ Stages, in order: `amicode_calibrate` (bookkeeping stubs — they perform NO device I/O), set no expectations of device I/O in this build. +## Composite authoring map (System → solve.jl) + +The recorded composite System tells you how to author the multi-component `solve.jl`. +This is **authoring-aware bookkeeping — NOT wired into tier resolution**: a multipartite +gate still resolves to the **free tier** and is honestly **unvetted / re-rollout-checked**, +exactly as a multi-qubit transmon gate is today. Read the composite like so: + +- `components[].role` + `levels` → `subsystem_levels` + which Piccolo system. +- `couplings` (kind + params) → the interaction terms / coupling constructor. +- `drive.arch` → control-channel count / addressability. +- Formulation target → `EmbeddedOperator` on the computational subspace, and + `free_phase = N` (one virtual-Z per component) for entangling gates. + +Constructor map (guidance, not a lookup you follow blindly): + +| composite shape | Piccolo constructor | +| --- | --- | +| single transmon (N=1, qubit) | `TransmonSystem` (the vetted single-qubit template) | +| N transmons + `cross-resonance` / `ZZ` | `MultiTransmonSystem` / a from-scratch coupled model | +| Rydberg atoms + `vdW`, drive `global` | `GlobalRydbergSystem` (3-level variant for leakage) | +| Rydberg + `vdW`, drive `per-component` | `LocalDetuneRydbergSystem` | +| Rydberg + `vdW`, drive `zoned` | `ZonedDetuneRydbergSystem` | +| cavity + qubit + `dispersive-chi` | the bosonic cavity+qubit system (invoke the `bosonic` skill) | +| ion / bus `mode-mediated` | a shared-mode model (the mode is its own component) | + +Golden reference skeletons for the canonical cases (2-transmon CZ, Rydberg CZ, cavity+qubit) +live in `test/fixtures/composite-skeletons/` — the intended authoring output, snapshot-checked. + ## Scope & parameter guidance **Transmon: single qubit only via the vetted template.** The bundled vetted diff --git a/packages/extension/test/composite_skeletons.test.ts b/packages/extension/test/composite_skeletons.test.ts new file mode 100644 index 00000000..a09bc9a0 --- /dev/null +++ b/packages/extension/test/composite_skeletons.test.ts @@ -0,0 +1,36 @@ +// Golden-skeleton SNAPSHOT check (spec-20260709 §5 / §7.7, plan F3). +// +// This is a STRUCTURAL PRESENCE check of the documented example solve.jl skeletons — +// it verifies the intended authoring output (right constructor + per-component +// subsystem_levels + free_phase = N for entanglers), NOT a composite→constructor +// mapping function (deliberately NOT built — that's the §9 load-bearing non-goal). +// Real mapping verification is the Task 10 live run. +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; + +const read = (name: string) => + readFileSync(new URL(`./fixtures/composite-skeletons/${name}`, import.meta.url), "utf8"); + +describe("composite → solve.jl golden skeletons (snapshot presence)", () => { + it("2-transmon CZ → MultiTransmonSystem, subsystem_levels [3, 3], free_phase", () => { + const s = read("cz-2transmon.jl"); + expect(s).toContain("MultiTransmonSystem"); + expect(s).toContain("subsystem_levels = [3, 3]"); + expect(s).toContain("EmbeddedOperator"); + expect(s).toContain("free_phase = true"); + }); + + it("Rydberg CZ (global) → GlobalRydbergSystem, 3-level per atom, free_phase", () => { + const s = read("cz-rydberg-global.jl"); + expect(s).toContain("GlobalRydbergSystem"); + expect(s).toContain("subsystem_levels = [3, 3]"); + expect(s).toContain("free_phase = true"); + }); + + it("heterogeneous cavity+qubit → cavity system with a Fock-truncated cavity level", () => { + const s = read("cavity-qubit.jl"); + expect(s).toContain("subsystem_levels = [2, 12]"); // qubit 2, cavity Fock cutoff 12 + expect(s).toContain("dispersive-chi"); + expect(s).toContain("free_phase = true"); + }); +}); diff --git a/packages/extension/test/fixtures/composite-skeletons/cavity-qubit.jl b/packages/extension/test/fixtures/composite-skeletons/cavity-qubit.jl new file mode 100644 index 00000000..bea110ef --- /dev/null +++ b/packages/extension/test/fixtures/composite-skeletons/cavity-qubit.jl @@ -0,0 +1,19 @@ +# Golden skeleton (spec-20260709 §5): heterogeneous cavity+qubit → bosonic system. +# components: [{q1: qubit, 2}, {cav: cavity, 12}] # Fock truncation on the cavity +# couplings: [{between: [q1,cav], kind: dispersive-chi}] +# drive: { arch: per-component } +# Snapshot-checked, NOT executed. Free tier / unvetted; invoke the `bosonic` skill for the model. +using Piccolo + +# Heterogeneous composite: qubit + cavity in the displaced/dispersive frame. +sys = CavityQubitSystem( + subsystem_levels = [2, 12], # qubit 2, cavity Fock cutoff 12 (per-component levels) + # chi from the dispersive-chi coupling params +) + +# Target on the computational subspace (state prep or a cavity operation) +U_goal = EmbeddedOperator(:target, sys) + +# free_phase = N = 2 across the two subsystems +prob = UnitarySmoothPulseProblem(sys, U_goal, N, Δt; free_phase = true) +solve!(prob) diff --git a/packages/extension/test/fixtures/composite-skeletons/cz-2transmon.jl b/packages/extension/test/fixtures/composite-skeletons/cz-2transmon.jl new file mode 100644 index 00000000..18e4bda9 --- /dev/null +++ b/packages/extension/test/fixtures/composite-skeletons/cz-2transmon.jl @@ -0,0 +1,20 @@ +# Golden skeleton (spec-20260709 §5): composite 2-transmon CZ → MultiTransmonSystem. +# Reference of the intended authoring output for a composite System of +# components: [{q1: qubit, 3}, {q2: qubit, 3}] +# couplings: [{between: [q1,q2], kind: cross-resonance}] +# drive: { arch: per-component } +# Snapshot-checked (composite_skeletons.test.ts), NOT executed. Free tier / unvetted. +using Piccolo + +sys = MultiTransmonSystem( + n_qubits = 2, + subsystem_levels = [3, 3], # per-component levels from components[].levels + # ω/δ per component from components[].params; g from the cross-resonance coupling params +) + +# CZ on the {|0⟩,|1⟩}^2 computational subspace of the 3^2 Hilbert space +U_goal = EmbeddedOperator(:CZ, sys) + +# free_phase = N = 2 (one virtual-Z per component) — entangling gate +prob = UnitarySmoothPulseProblem(sys, U_goal, N, Δt; free_phase = true) +solve!(prob) diff --git a/packages/extension/test/fixtures/composite-skeletons/cz-rydberg-global.jl b/packages/extension/test/fixtures/composite-skeletons/cz-rydberg-global.jl new file mode 100644 index 00000000..8ac8666b --- /dev/null +++ b/packages/extension/test/fixtures/composite-skeletons/cz-rydberg-global.jl @@ -0,0 +1,18 @@ +# Golden skeleton (spec-20260709 §5): composite Rydberg CZ (global drive) → GlobalRydbergSystem. +# components: [{r1: atom, 3}, {r2: atom, 3}] +# couplings: [{between: [r1,r2], kind: vdW}] +# drive: { arch: global } +# Snapshot-checked, NOT executed. Free tier / unvetted (Piccolissimo path when entitled). +using Piccolo + +sys = GlobalRydbergSystem( + n_atoms = 2, + subsystem_levels = [3, 3], # 3-level ladder per atom (|0>,|1>,|r>) — leakage-aware + # C6/r^6 from the vdW coupling params; global Ω,Δ drive (no per-atom addressing) +) + +U_goal = EmbeddedOperator(:CZ, sys) # CZ on {|0>,|1>}^2 + +# free_phase = N = 2 — CZ up to virtual-Z rotations (the honest primary metric for entanglers) +prob = UnitarySmoothPulseProblem(sys, U_goal, N, Δt; free_phase = true) +solve!(prob) From d56beb0c591931070cb5171057ec2dc0756799f3 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 11:02:48 -0400 Subject: [PATCH 08/49] fix(amicode-prompt): keep agents_md test invariants after interview rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the exact **MODEL** stage marker (was **MODEL (structure-first…)** → broke the stage-chain assertion) and un-wrap "scalar gate time" onto one line (the T-vs-N guardrail regex needs it contiguous). Behavior/content unchanged; agents_md.test.ts 19/19 green again. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/AGENTS.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index f92b54ee..aeab0eda 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -201,7 +201,7 @@ Stages, in order: scan, slow at 2 qubits (splice params into the exemplar; the gate's masked-baseline check keeps its physics intact). Don't tell the user Rydberg is unsupported. -2. **MODEL (structure-first, then batch)** — the System is a **composite**: +2. **MODEL** — structure-first, then batch. The System is a **composite**: components + couplings + drive-architecture, with a single qubit as the degenerate **N=1** case. Ask the STRUCTURE first (it gates everything, so keep these conversational/singular): **how many components** (single / a pair / a @@ -214,10 +214,10 @@ Stages, in order: `amicode_set_model` call: `components` (upserted by id, ids `q1..qN`), `couplings` (or a `topology` preset + `coupling_kind` — the preset expands to edges), `drive_arch`. Single-qubit stays the old one-component flow - (`levels`/`drive_max` fold onto the first component). Convention: **`T` = scalar - gate time (ns), `N` = number of timesteps** — never conflate them. + (`levels`/`drive_max` fold onto the first component). Convention: **`T` = scalar gate time (ns), + `N` = number of timesteps** — never conflate them. - **Frontier-batching:** batch questions whose prerequisites are already - answered into ONE `question` call, but keep the *semantic/branching* picks + answered into ONE `question` call, but keep the _semantic/branching_ picks singular — platform, target-gate, objective. Never batch a question whose OPTIONS depend on an unanswered prior (e.g. the gate list needs the platform), nor one whose RELEVANCE depends on a pending answer (e.g. topology only if From 0fd846dcd270496ba899608d4104c42358734fca Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 11:07:42 -0400 Subject: [PATCH 09/49] =?UTF-8?q?amico=20catalog(#111):=20B2=20=E2=80=94?= =?UTF-8?q?=20real=20repertoire=20query=20+=20verified=20ingest=20verb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the B1 `catalog` stub with a working spine bookkeeping verb backed by the pulse repertoire (~/.amico/vaults/armonissima/catalog/pulses//metadata.toml, the amico-catalog skill's Phase-0 schema). Two subcommands, both deterministic filesystem work callable via bash by either runtime, the harness, or cron/CI: amico catalog query --platform

--kind → the incumbent pulse metadata for (platform, gate) + the ranked candidate list (fidelity desc; ties broken by shorter duration). This is the warm-start lookup the interview does before authoring a solve. amico catalog ingest --platform

--kind [--from-run

] [--artifact ] [--fidelity ] [--agree …] … → the PROMOTION path, GATED on verification.agree (matching the existing semantics — a run is promotable only when the independent re-rollout agreed: verification.toml `agree = true`; --from-run reads it, or pass --agree explicitly). When gated open it promotes iff the candidate beats the incumbent (amico-catalog Version rule), writing a new {platform}-{kind}-v{N+1} entry (metadata.toml + copied pulse.jld2) with `warm_start` lineage back to the incumbent. agree≠true → blocked (64); no beat → no-op (0); promoted → 0. Design notes: - Pure core in repertoire.ts (loaders never throw; a corrupt/missing catalog degrades to empty, like src/catalog.ts's template/exemplar loaders); the verb body (arg parsing + writes) in catalog_verb.ts. verbs.ts wires catalog.run to it and marks it non-stub; vault/device/note stay B1 stubs. One impl backs both the CLI (amico.ts) and the mcp-serve facade (unchanged). - $AMICO_CATALOG_DIR overrides the pulses dir (tests point it at a temp catalog). - FLAG NAMES honor the S31 guard (test/s31.test.ts bans the physics-knob double-dash flags gate/pulse/system in src/): the gate discriminator is `--kind` (issue #111's surface, maps to the `gate` field) and the pulse-artifact PATH is `--artifact` (a file path, not a physics knob — same category as spec/from-run). Tests: repertoire pure logic + query/ingest through the dist/amico.js bundle (mirrors amico.test.ts / subcommands.test.ts); amico.test.ts's stub loop drops catalog (now real) and asserts it routes to query/ingest. amico-run 141 green, extension fast suite 435 green. Stacks on #118 (B1). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/amico-run/src/amico.ts | 14 +- packages/amico-run/src/catalog_verb.ts | 277 +++++++++++++++++++ packages/amico-run/src/repertoire.ts | 152 ++++++++++ packages/amico-run/src/verbs.ts | 29 +- packages/amico-run/test/amico.test.ts | 37 ++- packages/amico-run/test/catalog_verb.test.ts | 267 ++++++++++++++++++ 6 files changed, 759 insertions(+), 17 deletions(-) create mode 100644 packages/amico-run/src/catalog_verb.ts create mode 100644 packages/amico-run/src/repertoire.ts create mode 100644 packages/amico-run/test/catalog_verb.test.ts diff --git a/packages/amico-run/src/amico.ts b/packages/amico-run/src/amico.ts index e62b159f..1c6e5d95 100644 --- a/packages/amico-run/src/amico.ts +++ b/packages/amico-run/src/amico.ts @@ -6,9 +6,10 @@ // // B1 SCOPE: `run` / `resolve` / `sandbox` delegate VERBATIM to the existing amico-run launch // path (src/launch.ts): `amico ` is exactly `amico-run `, so -// there is no behavior fork and the amico-run test suite still covers the real bodies. The -// spine verbs (catalog/vault/device/note) and `mcp-serve` are STUB seams (see verbs.ts, -// mcp_serve.ts) — routing works today; real bodies land in later spine slices. +// there is no behavior fork and the amico-run test suite still covers the real bodies. Of +// the spine verbs, `catalog` is REAL (B2 — repertoire query/ingest; see catalog_verb.ts); +// `vault`/`device`/`note` and `mcp-serve` remain STUB seams (see verbs.ts, mcp_serve.ts) — +// routing works today; their real bodies land in later spine slices. import { launch } from "./launch.js"; import { SPINE_VERBS } from "./verbs.js"; import { serve } from "./mcp_serve.js"; @@ -18,7 +19,9 @@ function usage(): string { ["run [--spec ] […]", "launch a solve — the amico-run launch path (delegates verbatim)"], ["resolve --platform

--kind --size ", "tier resolution → JSON (amico-run subcommand)"], ["sandbox --packages A,B,…", "generate a per-problem Julia env (amico-run subcommand)"], - ...SPINE_VERBS.map((v) => [`${v.name} …`, `${v.summary} [stub → ${v.slice}]`] as [string, string]), + ...SPINE_VERBS.map( + (v) => [`${v.name} …`, v.stub ? `${v.summary} [stub → ${v.slice}]` : v.summary] as [string, string], + ), ["mcp-serve [--list]", "expose the spine verbs as MCP tools (optional facade) [stub]"], ["--help, -h", "show this verb surface"], ]; @@ -36,7 +39,8 @@ export async function main(argv: string[]): Promise { return head ? 0 : 64; // explicit --help is success; a bare `amico` is a usage error } - // spine bookkeeping verbs (catalog/vault/device/note) — B1 stubs, print intent + exit 0. + // spine bookkeeping verbs (catalog real; vault/device/note stubs) — dispatch to Verb.run, + // print its JSON result, and relay its exit code. const verb = SPINE_VERBS.find((v) => v.name === head); if (verb) { const { json, code } = await verb.run(rest); diff --git a/packages/amico-run/src/catalog_verb.ts b/packages/amico-run/src/catalog_verb.ts new file mode 100644 index 00000000..0f0c020d --- /dev/null +++ b/packages/amico-run/src/catalog_verb.ts @@ -0,0 +1,277 @@ +// `amico catalog` — the real spine bookkeeping verb (issue #111, slice B2). Two +// subcommands, both deterministic filesystem work callable via bash by either +// runtime, by the harness, or by cron/CI: +// +// amico catalog query --platform

--kind +// → the incumbent pulse metadata for (platform, gate), plus the ranked +// candidate list, read from the repertoire (metadata.toml records). This +// is the warm-start lookup the interview does before authoring a solve. +// +// amico catalog ingest --platform

--kind [--from-run

] +// [--artifact ] [--fidelity ] [--agree true|false] … +// → the PROMOTION path. Gated on verification.agree (matching the existing +// semantics: a free-tier run is promotable only when the independent +// re-rollout agreed — verification.toml `agree = true`). When gated open, +// it promotes iff the candidate BEATS the incumbent (amico-catalog Version +// rule), writing a new `{platform}-{kind}-v{N+1}` entry (metadata.toml + +// copied pulse.jld2) with `warm_start` lineage back to the incumbent. +// +// FLAG NAMES (S31 guard): the gate discriminator is `--kind` (issue #111's +// acceptance surface) and maps onto the repertoire's `gate` field (e.g. X | CZ). +// The pulse-artifact path is `--artifact`. test/s31.test.ts bans the physics-knob +// double-dash flags (gate/pulse/system) anywhere in src/; a pulse-file PATH is not +// a physics knob — same category as the spec / from-run file paths — so it takes a +// non-colliding name. (`--kind` here is also a DIFFERENT axis from `amico resolve +// --kind`, where kind = problem-kind such as gate_synthesis.) +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; +import { + beats, + catalogPulsesDir, + loadRepertoire, + nextVersionId, + queryIncumbent, + type PulseRecord, +} from "./repertoire.js"; +import type { VerbResult } from "./verbs.js"; + +function flagValue(argv: string[], name: string): string | undefined { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; +} + +function readTomlSafe(file: string): Record | undefined { + if (!existsSync(file)) return undefined; + try { + return parseToml(readFileSync(file, "utf8")) as Record; + } catch { + return undefined; + } +} + +/** Present a record for JSON output: the persisted fields + the resolved absolute + * pulse path (dropping the internal `dir`). */ +function present(rec: PulseRecord): Record { + const { dir, ...rest } = rec; + const pulse = join(dir, "pulse.jld2"); + return { ...rest, dir, pulse_path: existsSync(pulse) ? pulse : null }; +} + +// ── query ────────────────────────────────────────────────────────────────── +export function catalogQuery(argv: string[]): VerbResult { + const platform = flagValue(argv, "--platform"); + const gate = flagValue(argv, "--kind"); + if (!platform || !gate) { + return { + json: { verb: "catalog", subcommand: "query", error: "--platform and --kind are required" }, + code: 64, + }; + } + const pulsesDir = catalogPulsesDir(); + const { incumbent, candidates } = queryIncumbent(loadRepertoire(pulsesDir), platform, gate); + return { + json: { + verb: "catalog", + subcommand: "query", + catalog: pulsesDir, + platform, + gate, + count: candidates.length, + incumbent: incumbent ? present(incumbent) : null, + candidates: candidates.map(present), + }, + code: 0, + }; +} + +// ── ingest ───────────────────────────────────────────────────────────────── +/** Parse the verification gate: explicit `--agree` wins; else the run dir's + * verification.toml `agree`; else undefined (no evidence → not verified). */ +function resolveAgree(argv: string[], runDir: string | undefined): { agree?: boolean; error?: string } { + const explicit = flagValue(argv, "--agree"); + if (explicit !== undefined) { + if (explicit === "true") return { agree: true }; + if (explicit === "false") return { agree: false }; + return { error: `--agree must be true or false (got "${explicit}")` }; + } + if (runDir) { + const v = readTomlSafe(join(runDir, "verification.toml")); + if (v && typeof v.agree === "boolean") return { agree: v.agree }; + return {}; // run dir given but no readable verification → undefined (blocked) + } + return {}; +} + +export function catalogIngest(argv: string[]): VerbResult { + const fail = (error: string): VerbResult => ({ json: { verb: "catalog", subcommand: "ingest", error }, code: 64 }); + + const platform = flagValue(argv, "--platform"); + const gate = flagValue(argv, "--kind"); + if (!platform || !gate) return fail("--platform and --kind are required"); + + const runDir = flagValue(argv, "--from-run"); + const result = runDir ? readTomlSafe(join(runDir, "result.toml")) : undefined; + + // Pulse source: explicit --artifact, else /pulse.jld2. + const pulse = flagValue(argv, "--artifact") ?? (runDir ? join(runDir, "pulse.jld2") : undefined); + if (!pulse) return fail("a pulse source is required: --artifact or --from-run "); + if (!existsSync(pulse)) return fail(`pulse artifact not found: ${pulse}`); + + // Fidelity: explicit --fidelity, else result.toml `fidelity`. + const fidRaw = flagValue(argv, "--fidelity"); + const fidelity = fidRaw !== undefined ? Number(fidRaw) : num(result?.fidelity); + if (fidelity === undefined || !Number.isFinite(fidelity)) { + return fail("a fidelity is required: --fidelity or --from-run with a result.toml"); + } + + // ── the promotion GATE: verification.agree must be true ── + const { agree, error } = resolveAgree(argv, runDir); + if (error) return fail(error); + if (agree !== true) { + return { + json: { + verb: "catalog", + subcommand: "ingest", + promoted: false, + blocked: true, + agree: agree ?? null, + reason: + agree === false + ? "verification disagreed (agree = false) — the run is UNTRUSTED and cannot be promoted" + : "no verification evidence (agree unknown) — pass --agree true or --from-run with a verification.toml", + }, + code: 64, + }; + } + + const pulsesDir = catalogPulsesDir(); + const records = loadRepertoire(pulsesDir); + const { incumbent } = queryIncumbent(records, platform, gate); + const durRaw = flagValue(argv, "--duration-us"); + const duration_us = durRaw !== undefined ? Number(durRaw) : num(result?.duration_us); + + const candidate: PulseRecord = { + id: "(candidate)", + platform, + gate, + fidelity, + duration_us: duration_us !== undefined && Number.isFinite(duration_us) ? duration_us : undefined, + dir: "", + }; + + // amico-catalog Version rule: promote only when the candidate beats the incumbent. + if (!beats(candidate, incumbent)) { + return { + json: { + verb: "catalog", + subcommand: "ingest", + promoted: false, + agree: true, + incumbent: incumbent ? present(incumbent) : null, + reason: `does not beat the incumbent ${incumbent?.id} (fidelity ${incumbent?.fidelity})`, + }, + code: 0, + }; + } + + const id = flagValue(argv, "--id") ?? nextVersionId(records, platform, gate); + const warmStart = flagValue(argv, "--warm-start") ?? incumbent?.id ?? ""; + const tagsRaw = flagValue(argv, "--tags"); + const tags = tagsRaw + ? tagsRaw + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + : undefined; + + const dryRun = argv.includes("--dry-run"); + const entryDir = join(pulsesDir, id); + const relPath = `pulses/${id}/pulse.jld2`; + + // Build the flat metadata record in a stable, human-readable key order. + const meta: Record = { + schema_version: 1, + id, + platform, + gate, + fidelity, + }; + if (candidate.duration_us !== undefined) meta.duration_us = candidate.duration_us; + const pulseType = flagValue(argv, "--type"); + if (pulseType) meta.pulse_type = pulseType; + const nKnotsRaw = flagValue(argv, "--n-knots"); + if (nKnotsRaw !== undefined && Number.isFinite(Number(nKnotsRaw))) meta.N_knots = Number(nKnotsRaw); + if (argv.includes("--free-phase")) meta.free_phase = true; + meta.path = relPath; + meta.branch = flagValue(argv, "--branch") ?? "main"; + meta.warm_start = warmStart; + if (tags) meta.tags = tags; + meta.date = new Date().toISOString().slice(0, 10); + + if (dryRun) { + return { + json: { + verb: "catalog", + subcommand: "ingest", + promoted: false, + dry_run: true, + agree: true, + would_write: { id, dir: entryDir, metadata: meta }, + incumbent: incumbent ? present(incumbent) : null, + }, + code: 0, + }; + } + + if (existsSync(entryDir)) { + return fail(`catalog entry already exists: ${entryDir} (pass --id to override the version)`); + } + + try { + mkdirSync(entryDir, { recursive: true }); + copyFileSync(pulse, join(entryDir, "pulse.jld2")); + writeFileSync(join(entryDir, "metadata.toml"), stringifyToml(meta) + "\n"); + } catch (e) { + return fail(`failed to write catalog entry: ${e instanceof Error ? e.message : String(e)}`); + } + + return { + json: { + verb: "catalog", + subcommand: "ingest", + promoted: true, + agree: true, + id, + dir: entryDir, + path: relPath, + pulse_path: join(entryDir, "pulse.jld2"), + fidelity, + warm_start: warmStart, + previous_incumbent: incumbent ? { id: incumbent.id, fidelity: incumbent.fidelity } : null, + }, + code: 0, + }; +} + +function num(v: unknown): number | undefined { + return typeof v === "number" ? v : undefined; +} + +// ── dispatch ───────────────────────────────────────────────────────────────── +/** The `catalog` verb body: dispatch on the subcommand. Backs BOTH the CLI + * (amico.ts) and the MCP facade (mcp_serve.ts) — one impl, two transports. */ +export function catalogVerb(argv: string[]): VerbResult { + const sub = argv[0]; + const rest = argv.slice(1); + if (sub === "query") return catalogQuery(rest); + if (sub === "ingest") return catalogIngest(rest); + return { + json: { + verb: "catalog", + error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, + usage: "amico catalog query --platform

--kind | amico catalog ingest --platform

--kind …", + }, + code: 64, + }; +} diff --git a/packages/amico-run/src/repertoire.ts b/packages/amico-run/src/repertoire.ts new file mode 100644 index 00000000..3c5bd4cb --- /dev/null +++ b/packages/amico-run/src/repertoire.ts @@ -0,0 +1,152 @@ +// The pulse REPERTOIRE (a.k.a. the catalog) — the durable store of promoted +// pulses under the company vault: ~/.amico/vaults/armonissima/catalog/pulses// +// carrying a flat `metadata.toml` + a git-lfs `pulse.jld2` (the amico-catalog +// skill's Phase-0 schema). This is the pure core behind the `amico catalog` verb +// (issue #111, slice B2): warm-start QUERY (rank incumbents by fidelity) and the +// half of INGEST that decides the version bump. It generalizes the amico-catalog +// skill's retrieval + ingestion protocol into deterministic, bash-callable logic. +// +// Loaders never throw: a missing/corrupt catalog degrades to an EMPTY repertoire, +// exactly like src/catalog.ts's template/exemplar loaders degrade to tier 3. A +// record missing a discriminating field (id/platform/gate/fidelity) is skipped, +// not fatal. +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { parse as parseToml } from "smol-toml"; + +/** A flat `metadata.toml` pulse record (amico-catalog Phase-0 schema). Known keys + * are typed; `dir` is the resolved on-disk entry directory (never persisted). */ +export interface PulseRecord { + id: string; + platform: string; + gate: string; + fidelity: number; + duration_us?: number; + pulse_type?: string; + N_knots?: number; + free_phase?: boolean; + path?: string; // pulse.jld2 path, relative to the catalog ROOT (parent of pulses/) + branch?: string; + warm_start?: string; // lineage: the incumbent id this was warm-started from + tags?: string[]; + date?: string; // ISO date "YYYY-MM-DD" + dir: string; // ABS path to the entry directory +} + +/** The repertoire's `pulses/` directory. `$AMICO_CATALOG_DIR` overrides it (tests + * point it at a temp dir); default is the company-vault mount. Mirrors the + * extension's run_controls.catalogPulsesDir, but returns the path unconditionally + * — loadRepertoire handles a missing mount by returning []. */ +export function catalogPulsesDir(): string { + const env = process.env.AMICO_CATALOG_DIR; + if (env && env.trim() !== "") return env; + return join(homedir(), ".amico", "vaults", "armonissima", "catalog", "pulses"); +} + +function num(v: unknown): number | undefined { + return typeof v === "number" ? v : undefined; +} +function str(v: unknown): string | undefined { + return typeof v === "string" ? v : undefined; +} +function dateStr(v: unknown): string | undefined { + if (typeof v === "string") return v; + if (v instanceof Date) return v.toISOString().slice(0, 10); // smol-toml bare-date → TomlDate + return undefined; +} + +function parseRecord(file: string, dir: string): PulseRecord | undefined { + let parsed: Record; + try { + parsed = parseToml(readFileSync(file, "utf8")) as Record; + } catch { + return undefined; + } + const id = str(parsed.id); + const platform = str(parsed.platform); + const gate = str(parsed.gate); + const fidelity = num(parsed.fidelity); + // Can't query or rank a record missing a discriminating field — skip, don't crash. + if (!id || !platform || !gate || fidelity === undefined) return undefined; + return { + id, + platform, + gate, + fidelity, + dir, + duration_us: num(parsed.duration_us), + pulse_type: str(parsed.pulse_type), + N_knots: num(parsed.N_knots), + free_phase: typeof parsed.free_phase === "boolean" ? parsed.free_phase : undefined, + path: str(parsed.path), + branch: str(parsed.branch), + // real entries use `warm_start`; the skill doc example says `warm_started_from` — accept both. + warm_start: str(parsed.warm_start) ?? str(parsed.warm_started_from), + tags: Array.isArray(parsed.tags) ? parsed.tags.filter((t): t is string => typeof t === "string") : undefined, + date: dateStr(parsed.date), + }; +} + +/** Scan each entry's `/metadata.toml` under the pulses dir into records. + * Never throws. */ +export function loadRepertoire(pulsesDir: string): PulseRecord[] { + if (!existsSync(pulsesDir)) return []; + let names: string[]; + try { + names = readdirSync(pulsesDir); + } catch { + return []; + } + const records: PulseRecord[] = []; + for (const name of names) { + const dir = join(pulsesDir, name); + const file = join(dir, "metadata.toml"); + if (!existsSync(file)) continue; + const rec = parseRecord(file, dir); + if (rec) records.push(rec); + } + return records; +} + +/** amico-catalog "Version" rule: better = higher fidelity; if fidelity ties, + * shorter duration wins; if both tie, indistinguishable. Returns >0 if `a` beats + * `b`, <0 if `b` beats `a`, 0 if neither. */ +export function comparePulses(a: PulseRecord, b: PulseRecord): number { + if (a.fidelity !== b.fidelity) return a.fidelity - b.fidelity; + const da = a.duration_us ?? Infinity; + const db = b.duration_us ?? Infinity; + return db - da; // shorter duration → larger score (better) +} + +export interface QueryResult { + incumbent?: PulseRecord; // the best-ranked match, if any + candidates: PulseRecord[]; // all matches, ranked best → worst +} + +/** Warm-start lookup: matches on platform + gate, ranked by comparePulses. */ +export function queryIncumbent(records: PulseRecord[], platform: string, gate: string): QueryResult { + const candidates = records + .filter((r) => r.platform === platform && r.gate === gate) + .sort((a, b) => comparePulses(b, a)); // best first + return { incumbent: candidates[0], candidates }; +} + +/** Does `candidate` beat the incumbent? No incumbent → always (first of its kind). */ +export function beats(candidate: PulseRecord, incumbent: PulseRecord | undefined): boolean { + if (!incumbent) return true; + return comparePulses(candidate, incumbent) > 0; +} + +/** `{platform}-{gate}-v{N+1}`, where N is the highest existing version for this + * (platform, gate). No prior entry → v1. */ +export function nextVersionId(records: PulseRecord[], platform: string, gate: string): string { + const prefix = `${platform}-${gate}-v`; + let max = 0; + for (const r of records) { + if (!r.id.startsWith(prefix)) continue; + const n = Number(r.id.slice(prefix.length)); + if (Number.isInteger(n) && n > max) max = n; + } + return `${prefix}${max + 1}`; +} diff --git a/packages/amico-run/src/verbs.ts b/packages/amico-run/src/verbs.ts index c2fa46a5..edd26bad 100644 --- a/packages/amico-run/src/verbs.ts +++ b/packages/amico-run/src/verbs.ts @@ -3,14 +3,18 @@ // filesystem/vault work: callable by agents via bash, by the deterministic harness // directly, and by cron/CI/Julia. // -// B1 SCOPE (issue #108): these are STUBS. Each verb is present as a routing seam, prints -// its intent (including the real module it will generalize and the slice that lands the -// body), and exits cleanly with code 0. NO bookkeeping logic is migrated here — that is -// B2/B3/B5. Do not add real reads/writes in this file without the corresponding slice. +// SLICE STATUS: `catalog` is REAL (issue #111, slice B2 — its body lives in +// catalog_verb.ts / repertoire.ts). `vault` / `device` / `note` are still STUBS: +// each is a routing seam that prints its intent (the module it will generalize + +// the slice that lands the body) and exits 0. Do not add real reads/writes for a +// stubbed verb in this file without its corresponding slice — put the body in a +// dedicated module and wire it here, as catalog does. // // Each verb is a plain (args) => {json, code} function so the SAME function backs both the // CLI dispatch (amico.ts) and the MCP facade (mcp_serve.ts). One impl, two transports. +import { catalogVerb } from "./catalog_verb.js"; + export interface VerbResult { json: unknown; // structured result (stdout as JSON for the CLI; tool content for MCP) code: number; // process exit code (0 ok, 64 usage/gate, else failure) @@ -21,13 +25,15 @@ export interface Verb { summary: string; // one-line help + MCP tool description generalizes: string; // the real module/plugin tool whose body lands in a later slice slice: string; // which spine slice implements the real body + stub?: boolean; // true while the body is still a B1 seam (help renders "[stub → slice]") run: (args: string[]) => VerbResult | Promise; } /** A uniform B1 stub body: echo the intent, name the target module + slice, exit 0. */ -function stub(verb: Omit): Verb { +function stub(verb: Omit): Verb { return { ...verb, + stub: true, run: (args) => ({ json: { verb: verb.name, @@ -43,13 +49,16 @@ function stub(verb: Omit): Verb { }; } -// catalog — warm-start lookup + pulse ingest against the repertoire (metadata.toml). -const catalog = stub({ +// catalog — warm-start query + verified pulse ingest against the repertoire +// (metadata.toml). REAL as of B2: `query` ranks incumbents by fidelity; `ingest` +// promotes a run to a new versioned entry, gated on verification.agree. +const catalog: Verb = { name: "catalog", - summary: "warm-start lookup / pulse ingest against the repertoire (metadata.toml)", - generalizes: "amico-run/src/catalog.ts + the amicode_* catalog plugin tool", + summary: "warm-start query / verified pulse ingest against the repertoire (metadata.toml)", + generalizes: "the amico-catalog skill (repertoire retrieval + ingestion protocol)", slice: "spine bookkeeping (B2)", -}); + run: catalogVerb, +}; // vault — retrieval over the knowledge graph (query tools, not front-loading context). const vault = stub({ diff --git a/packages/amico-run/test/amico.test.ts b/packages/amico-run/test/amico.test.ts index e9f8325a..e206587b 100644 --- a/packages/amico-run/test/amico.test.ts +++ b/packages/amico-run/test/amico.test.ts @@ -133,8 +133,8 @@ describe("amico router — resolve/sandbox delegate verbatim to the subcommands" }); }); -describe("amico router — spine verbs are B1 stubs (print intent, exit 0)", () => { - for (const name of ["catalog", "vault", "device", "note"]) { +describe("amico router — spine verbs vault/device/note are still B1 stubs (print intent, exit 0)", () => { + for (const name of ["vault", "device", "note"]) { it(`${name} routes, prints stub intent JSON, exits 0`, () => { const r = run([name, "some", "args"]); expect(r.code).toBe(0); @@ -146,6 +146,39 @@ describe("amico router — spine verbs are B1 stubs (print intent, exit 0)", () } }); +describe("amico router — catalog is REAL (B2), no longer a stub", () => { + it("catalog with no subcommand → usage error, exit 64", () => { + const r = run(["catalog"]); + expect(r.code).toBe(64); + const out = JSON.parse(r.stdout); + expect(out.verb).toBe("catalog"); + expect(out.stub).toBeUndefined(); + expect(out.error).toMatch(/unknown subcommand/); + }); + it("catalog query routes to the repertoire body (empty catalog → count 0, exit 0)", () => { + const empty = mkdtempSync(join(tmpdir(), "amico-cat-empty-")); + const r = run(["catalog", "query", "--platform", "transmon", "--kind", "X"], { AMICO_CATALOG_DIR: empty }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out).toMatchObject({ verb: "catalog", subcommand: "query", platform: "transmon", gate: "X", count: 0 }); + expect(out.incumbent).toBeNull(); + rmSync(empty, { recursive: true, force: true }); + }); + it("catalog ingest blocks when verification did not agree, exit 64", () => { + const empty = mkdtempSync(join(tmpdir(), "amico-cat-ingest-")); + const pulse = join(empty, "pulse.jld2"); + writeFileSync(pulse, "binary"); + const r = run( + ["catalog", "ingest", "--platform", "transmon", "--kind", "X", "--artifact", pulse, "--fidelity", "0.99", "--agree", "false"], + { AMICO_CATALOG_DIR: join(empty, "pulses") }, + ); + expect(r.code).toBe(64); + const out = JSON.parse(r.stdout); + expect(out).toMatchObject({ verb: "catalog", subcommand: "ingest", promoted: false, blocked: true }); + rmSync(empty, { recursive: true, force: true }); + }); +}); + describe("amico router — mcp-serve facade", () => { it("--list renders each spine verb as an MCP tool, exit 0", () => { const r = run(["mcp-serve", "--list"]); diff --git a/packages/amico-run/test/catalog_verb.test.ts b/packages/amico-run/test/catalog_verb.test.ts new file mode 100644 index 00000000..81ea4b0c --- /dev/null +++ b/packages/amico-run/test/catalog_verb.test.ts @@ -0,0 +1,267 @@ +// `amico catalog` (issue #111, slice B2) — the repertoire query + verified-ingest +// verb. Pure logic (repertoire.ts) is unit-tested against src; the query/ingest +// bodies are exercised end-to-end through the `dist/amico.js` bundle with +// $AMICO_CATALOG_DIR pointed at a seeded temp repertoire (mirrors amico.test.ts / +// subcommands.test.ts). Run: `pnpm --filter @amicode/amico-run test`. +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readToml } from "./helpers.js"; +import { + loadRepertoire, + queryIncumbent, + beats, + nextVersionId, + comparePulses, + type PulseRecord, +} from "../src/repertoire.js"; + +// ── seed helpers ────────────────────────────────────────────────────────────── +/** Write a `//metadata.toml` (+ optional pulse.jld2) entry. */ +function seedEntry( + pulsesDir: string, + id: string, + fields: Record, + withPulse = true, +): void { + const dir = join(pulsesDir, id); + mkdirSync(dir, { recursive: true }); + const lines = Object.entries({ id, ...fields }).map(([k, v]) => `${k} = ${JSON.stringify(v)}`); + writeFileSync(join(dir, "metadata.toml"), lines.join("\n") + "\n"); + if (withPulse) writeFileSync(join(dir, "pulse.jld2"), "fake-pulse-binary"); +} + +let pulses: string; +beforeEach(() => { + pulses = join(mkdtempSync(join(tmpdir(), "amico-repertoire-")), "pulses"); + mkdirSync(pulses, { recursive: true }); +}); +afterEach(() => rmSync(join(pulses, ".."), { recursive: true, force: true })); + +// ── pure logic (repertoire.ts) ───────────────────────────────────────────────── +describe("repertoire loader", () => { + it("scans metadata.toml entries; skips dirs without one; never throws on missing root", () => { + seedEntry(pulses, "transmon-X-v1", { platform: "transmon", gate: "X", fidelity: 0.999 }); + mkdirSync(join(pulses, "not-a-pulse"), { recursive: true }); // no metadata.toml + expect(loadRepertoire(pulses)).toHaveLength(1); + expect(loadRepertoire(join(pulses, "does-not-exist"))).toEqual([]); + }); + it("skips records missing a discriminating field (no fidelity)", () => { + seedEntry(pulses, "broken-v1", { platform: "transmon", gate: "X" }); // no fidelity + expect(loadRepertoire(pulses)).toEqual([]); + }); +}); + +describe("queryIncumbent ranking (amico-catalog Version rule)", () => { + it("ranks by fidelity desc; incumbent = best match on platform+gate", () => { + seedEntry(pulses, "transmon-X-v1", { platform: "transmon", gate: "X", fidelity: 0.99 }); + seedEntry(pulses, "transmon-X-v2", { platform: "transmon", gate: "X", fidelity: 0.9999 }); + seedEntry(pulses, "rydberg-CZ-v1", { platform: "rydberg", gate: "CZ", fidelity: 0.9999999 }); + const { incumbent, candidates } = queryIncumbent(loadRepertoire(pulses), "transmon", "X"); + expect(candidates.map((c) => c.id)).toEqual(["transmon-X-v2", "transmon-X-v1"]); + expect(incumbent?.id).toBe("transmon-X-v2"); + }); + it("fidelity tie → shorter duration wins", () => { + seedEntry(pulses, "transmon-X-v1", { platform: "transmon", gate: "X", fidelity: 0.9999, duration_us: 0.05 }); + seedEntry(pulses, "transmon-X-v2", { platform: "transmon", gate: "X", fidelity: 0.9999, duration_us: 0.03 }); + expect(queryIncumbent(loadRepertoire(pulses), "transmon", "X").incumbent?.id).toBe("transmon-X-v2"); + }); + it("no match → undefined incumbent, empty candidates", () => { + seedEntry(pulses, "transmon-X-v1", { platform: "transmon", gate: "X", fidelity: 0.99 }); + const q = queryIncumbent(loadRepertoire(pulses), "fluxonium", "Y"); + expect(q.incumbent).toBeUndefined(); + expect(q.candidates).toEqual([]); + }); +}); + +describe("beats + nextVersionId", () => { + const rec = (id: string, f: number, d?: number): PulseRecord => ({ + id, + platform: "transmon", + gate: "X", + fidelity: f, + duration_us: d, + dir: "", + }); + it("beats: no incumbent → true; higher fidelity → true; equal → false", () => { + expect(beats(rec("c", 0.99), undefined)).toBe(true); + expect(beats(rec("c", 0.999), rec("i", 0.99))).toBe(true); + expect(beats(rec("c", 0.99), rec("i", 0.999))).toBe(false); + expect(beats(rec("c", 0.99), rec("i", 0.99))).toBe(false); + expect(comparePulses(rec("c", 0.99, 0.03), rec("i", 0.99, 0.05))).toBeGreaterThan(0); + }); + it("nextVersionId bumps the max existing version; no prior → v1", () => { + seedEntry(pulses, "transmon-X-v1", { platform: "transmon", gate: "X", fidelity: 0.9 }); + seedEntry(pulses, "transmon-X-v3", { platform: "transmon", gate: "X", fidelity: 0.99 }); + const records = loadRepertoire(pulses); + expect(nextVersionId(records, "transmon", "X")).toBe("transmon-X-v4"); + expect(nextVersionId(records, "rydberg", "CZ")).toBe("rydberg-CZ-v1"); + }); +}); + +// ── verb bodies through the bundle ────────────────────────────────────────────── +const BUNDLE = join(__dirname, "..", "dist", "amico.js"); +beforeAll(() => { + execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); +}); +function run(args: string[], env: Record = {}): { code: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); + return { code: 0, stdout, stderr: "" }; + } catch (e) { + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; + } +} + +describe("amico catalog query (bundle)", () => { + it("returns the incumbent + ranked candidates for platform+gate", () => { + seedEntry(pulses, "transmon-X-v1", { platform: "transmon", gate: "X", fidelity: 0.99 }); + seedEntry(pulses, "transmon-X-v2", { platform: "transmon", gate: "X", fidelity: 0.9999 }); + const r = run(["catalog", "query", "--platform", "transmon", "--kind", "X"], { AMICO_CATALOG_DIR: pulses }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.count).toBe(2); + expect(out.incumbent.id).toBe("transmon-X-v2"); + expect(out.incumbent.pulse_path).toMatch(/transmon-X-v2\/pulse\.jld2$/); + expect(out.candidates.map((c: PulseRecord) => c.id)).toEqual(["transmon-X-v2", "transmon-X-v1"]); + }); + it("--kind maps onto the repertoire `gate` field (e.g. CZ)", () => { + seedEntry(pulses, "rydberg-CZ-v1", { platform: "rydberg", gate: "CZ", fidelity: 0.9999999 }); + const r = run(["catalog", "query", "--platform", "rydberg", "--kind", "CZ"], { AMICO_CATALOG_DIR: pulses }); + expect(r.code).toBe(0); + expect(JSON.parse(r.stdout).incumbent.id).toBe("rydberg-CZ-v1"); + }); + it("empty catalog → count 0, null incumbent", () => { + const r = run(["catalog", "query", "--platform", "transmon", "--kind", "X"], { AMICO_CATALOG_DIR: pulses }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.count).toBe(0); + expect(out.incumbent).toBeNull(); + }); + it("missing --platform/--kind → 64", () => { + expect(run(["catalog", "query", "--platform", "transmon"], { AMICO_CATALOG_DIR: pulses }).code).toBe(64); + }); +}); + +describe("amico catalog ingest (bundle) — the promotion gate", () => { + it("agree=true + beats incumbent → promotes a new versioned entry with lineage", () => { + seedEntry(pulses, "transmon-X-v1", { platform: "transmon", gate: "X", fidelity: 0.99 }); + const newPulse = join(pulses, "..", "new-pulse.jld2"); + writeFileSync(newPulse, "better-pulse"); + const r = run( + [ + "catalog", "ingest", + "--platform", "transmon", "--kind", "X", + "--artifact", newPulse, "--fidelity", "0.99999", + "--duration-us", "0.03", "--agree", "true", "--tags", "seed,transmon", + ], + { AMICO_CATALOG_DIR: pulses }, + ); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out).toMatchObject({ promoted: true, id: "transmon-X-v2", warm_start: "transmon-X-v1" }); + // metadata written + pulse copied + const meta = readToml(join(pulses, "transmon-X-v2", "metadata.toml")); + expect(meta.id).toBe("transmon-X-v2"); + expect(meta.fidelity).toBe(0.99999); + expect(meta.warm_start).toBe("transmon-X-v1"); + expect(meta.path).toBe("pulses/transmon-X-v2/pulse.jld2"); + expect(meta.tags).toEqual(["seed", "transmon"]); + expect(existsSync(join(pulses, "transmon-X-v2", "pulse.jld2"))).toBe(true); + }); + + it("agree=false → BLOCKED, no write, exit 64", () => { + const newPulse = join(pulses, "..", "p.jld2"); + writeFileSync(newPulse, "x"); + const r = run( + ["catalog", "ingest", "--platform", "transmon", "--kind", "X", "--artifact", newPulse, "--fidelity", "0.9999", "--agree", "false"], + { AMICO_CATALOG_DIR: pulses }, + ); + expect(r.code).toBe(64); + const out = JSON.parse(r.stdout); + expect(out).toMatchObject({ promoted: false, blocked: true, agree: false }); + expect(existsSync(join(pulses, "transmon-X-v1"))).toBe(false); + }); + + it("no verification evidence → BLOCKED, exit 64 (agree unknown ≠ true)", () => { + const newPulse = join(pulses, "..", "p.jld2"); + writeFileSync(newPulse, "x"); + const r = run( + ["catalog", "ingest", "--platform", "transmon", "--kind", "X", "--artifact", newPulse, "--fidelity", "0.9999"], + { AMICO_CATALOG_DIR: pulses }, + ); + expect(r.code).toBe(64); + expect(JSON.parse(r.stdout).blocked).toBe(true); + }); + + it("agree=true but does NOT beat the incumbent → promoted:false, exit 0 (no-op)", () => { + seedEntry(pulses, "transmon-X-v1", { platform: "transmon", gate: "X", fidelity: 0.99999 }); + const newPulse = join(pulses, "..", "p.jld2"); + writeFileSync(newPulse, "x"); + const r = run( + ["catalog", "ingest", "--platform", "transmon", "--kind", "X", "--artifact", newPulse, "--fidelity", "0.99", "--agree", "true"], + { AMICO_CATALOG_DIR: pulses }, + ); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.promoted).toBe(false); + expect(out.reason).toMatch(/does not beat/); + }); + + it("--from-run reads verification.toml (agree) + result.toml (fidelity), defaults the artifact", () => { + const runDir = mkdtempSync(join(tmpdir(), "amico-run-fromrun-")); + writeFileSync(join(runDir, "pulse.jld2"), "run-pulse"); + writeFileSync(join(runDir, "result.toml"), `fidelity = 0.999995\nduration_us = 0.04\n`); + writeFileSync(join(runDir, "verification.toml"), `agree = true\nfidelity_rerolled = 0.999995\n`); + const r = run( + ["catalog", "ingest", "--platform", "transmon", "--kind", "X", "--from-run", runDir], + { AMICO_CATALOG_DIR: pulses }, + ); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out).toMatchObject({ promoted: true, id: "transmon-X-v1", fidelity: 0.999995 }); + expect(existsSync(join(pulses, "transmon-X-v1", "pulse.jld2"))).toBe(true); + rmSync(runDir, { recursive: true, force: true }); + }); + + it("--from-run with verification agree=false → BLOCKED even though fidelity is high", () => { + const runDir = mkdtempSync(join(tmpdir(), "amico-run-fromrun-block-")); + writeFileSync(join(runDir, "pulse.jld2"), "run-pulse"); + writeFileSync(join(runDir, "result.toml"), `fidelity = 0.999999\n`); + writeFileSync(join(runDir, "verification.toml"), `agree = false\n`); + const r = run( + ["catalog", "ingest", "--platform", "transmon", "--kind", "X", "--from-run", runDir], + { AMICO_CATALOG_DIR: pulses }, + ); + expect(r.code).toBe(64); + expect(JSON.parse(r.stdout).blocked).toBe(true); + expect(existsSync(join(pulses, "transmon-X-v1"))).toBe(false); + rmSync(runDir, { recursive: true, force: true }); + }); + + it("--dry-run computes the decision without writing", () => { + const newPulse = join(pulses, "..", "p.jld2"); + writeFileSync(newPulse, "x"); + const r = run( + ["catalog", "ingest", "--platform", "transmon", "--kind", "X", "--artifact", newPulse, "--fidelity", "0.9999", "--agree", "true", "--dry-run"], + { AMICO_CATALOG_DIR: pulses }, + ); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out).toMatchObject({ promoted: false, dry_run: true }); + expect(out.would_write.id).toBe("transmon-X-v1"); + expect(existsSync(join(pulses, "transmon-X-v1"))).toBe(false); + }); + + it("missing pulse artifact → 64", () => { + const r = run( + ["catalog", "ingest", "--platform", "transmon", "--kind", "X", "--artifact", join(pulses, "nope.jld2"), "--fidelity", "0.99", "--agree", "true"], + { AMICO_CATALOG_DIR: pulses }, + ); + expect(r.code).toBe(64); + expect(JSON.parse(r.stdout).error).toMatch(/not found/); + }); +}); From 70eea56d043e4a96e01546070a4dff44588a7a44 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 11:32:00 -0400 Subject: [PATCH 10/49] =?UTF-8?q?amico=20vault/device/note(#113):=20B3=20?= =?UTF-8?q?=E2=80=94=20migrate=20the=20remaining=20bookkeeping=20verbs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the three remaining B1 stub verbs with real bodies, following the B2 pattern (pure core + `*_verb.ts` body + tests, wired into verbs.ts/amico.ts): - `amico vault query` — read-only knowledge-graph retrieval. Ranks insights/experiments by relevance (title > tags > body) to a `--q` query, with `--type/--platform/--kind/--limit` filters. Pure core: vault_query.ts. - `amico device status|next|lock` — the dispatcher successor (§3.1 / W-2). Pure evaluate()/nextActions() over an acyclic calibration graph (ported from the device-management branch, stripped of vscode/server coupling): * status — DeviceStatus projection; honesty rule (uncharacterized/stale by TTL, suspect on a moved dependency) — never a fabricated number. * next — ranked actions, roots-first; qilc nodes surface the Intonatissimo premium funnel (product + capability, never the method acronym) + standard fallback. * lock — benchmark-exclusivity: a device under a benchmark allocation accepts no concurrent submission and suspends leaf fan-out; a concurrent acquire by another owner is refused, same-owner re-acquire is idempotent. `--now ` pins the evaluation clock for deterministic replay. Pure core: device_graph.ts. - `amico note write|bump-best` — the librarian's deterministic bookkeeping half (§3.1 / W-3): write a fully-templated experiment note; bump a system-context note's best_gates only on strictly-higher fidelity (surgical frontmatter edit, rest of the note untouched). Pure core: note.ts. The dispatcher agent is retired: its lookup becomes the pure `device` verb. All four spine verbs now have real bodies; only mcp-serve remains a seam. S31: flag names sidestep the banned physics-knob double-dash flags (gate/pulse/system) — the gate discriminator is `--kind` as in B2; test/s31.test.ts stays green. Env overrides ($AMICO_VAULT_DIR, $AMICO_DEVICE_DIR) mirror B2's $AMICO_CATALOG_DIR for testability. Tests: +50 (vault_verb/device_verb/note_verb), amico.test.ts stub-block replaced with real-routing assertions. Full amico-run suite 191 passed (was 141). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/amico-run/src/amico.ts | 9 +- packages/amico-run/src/device_graph.ts | 623 ++++++++++++++++++++ packages/amico-run/src/device_verb.ts | 261 ++++++++ packages/amico-run/src/note.ts | 261 ++++++++ packages/amico-run/src/note_verb.ts | 221 +++++++ packages/amico-run/src/vault_query.ts | 227 +++++++ packages/amico-run/src/vault_verb.ts | 70 +++ packages/amico-run/src/verbs.ts | 52 +- packages/amico-run/test/amico.test.ts | 31 +- packages/amico-run/test/device_verb.test.ts | 261 ++++++++ packages/amico-run/test/note_verb.test.ts | 189 ++++++ packages/amico-run/test/vault_verb.test.ts | 123 ++++ 12 files changed, 2296 insertions(+), 32 deletions(-) create mode 100644 packages/amico-run/src/device_graph.ts create mode 100644 packages/amico-run/src/device_verb.ts create mode 100644 packages/amico-run/src/note.ts create mode 100644 packages/amico-run/src/note_verb.ts create mode 100644 packages/amico-run/src/vault_query.ts create mode 100644 packages/amico-run/src/vault_verb.ts create mode 100644 packages/amico-run/test/device_verb.test.ts create mode 100644 packages/amico-run/test/note_verb.test.ts create mode 100644 packages/amico-run/test/vault_verb.test.ts diff --git a/packages/amico-run/src/amico.ts b/packages/amico-run/src/amico.ts index 1c6e5d95..07d03a1e 100644 --- a/packages/amico-run/src/amico.ts +++ b/packages/amico-run/src/amico.ts @@ -6,10 +6,11 @@ // // B1 SCOPE: `run` / `resolve` / `sandbox` delegate VERBATIM to the existing amico-run launch // path (src/launch.ts): `amico ` is exactly `amico-run `, so -// there is no behavior fork and the amico-run test suite still covers the real bodies. Of -// the spine verbs, `catalog` is REAL (B2 — repertoire query/ingest; see catalog_verb.ts); -// `vault`/`device`/`note` and `mcp-serve` remain STUB seams (see verbs.ts, mcp_serve.ts) — -// routing works today; their real bodies land in later spine slices. +// there is no behavior fork and the amico-run test suite still covers the real bodies. ALL +// FOUR spine verbs now have real bodies: `catalog` (B2 — catalog_verb.ts) and, as of B3, +// `vault` (vault_verb.ts), `device` (device_verb.ts), `note` (note_verb.ts). Only `mcp-serve` +// remains a STUB seam (see mcp_serve.ts) — its verb↔tool mapping works (`--list`); the real +// MCP stdio transport lands in a later slice (kept SDK-free by S31). import { launch } from "./launch.js"; import { SPINE_VERBS } from "./verbs.js"; import { serve } from "./mcp_serve.js"; diff --git a/packages/amico-run/src/device_graph.ts b/packages/amico-run/src/device_graph.ts new file mode 100644 index 00000000..3375f3a9 --- /dev/null +++ b/packages/amico-run/src/device_graph.ts @@ -0,0 +1,623 @@ +// The device CALIBRATION GRAPH — the pure core behind the `amico device` verb +// (issue #113, slice B3), the dispatcher successor (spec-20260708-112732 §3.1 / +// W-2; spec-20260706-221348 §4). A directed ACYCLIC graph after Kelly et al. +// "Physical qubit calibration on a directed acyclic graph" (arXiv:1803.03226, +// "Optimus"): nodes = calibrations, directed edges = dependencies. +// +// This is the SAME pure machinery the extension's calibration_graph.ts / +// device_status.ts / device_registry.ts carry, ported here into the shared CLI +// spine (the doctrine's "repeated + deterministically formulable = code"). It is +// PURE + TOTAL and free of any server/queue transport — the LLM traversal AGENT +// is retired; the ranked-action machinery is deterministic and lives here. +// Loaders never throw: a missing/corrupt graph or state degrades to an empty view, +// exactly like repertoire.ts's loaders degrade to an empty repertoire. +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { parse as parseToml } from "smol-toml"; + +/** The single status enum — node state, evaluate() verdict, and the per-qubit + * rollup all use it (no divergent vocabularies). */ +export type NodeStatus = "calibrated" | "stale" | "suspect" | "failed" | "uncharacterized"; + +/** `check` = the cheap check_data action; `calibrate` = a full recalibration. + * `redesign` is emitted by the premium/entitlement path (never by evaluate()). */ +export type RecommendedAction = "none" | "check" | "calibrate" | "redesign"; + +/** Per-adapter OPAQUE experiment blob — the queue verbs stay uniform; only the + * payload shape is adapter-specific. Carried verbatim; never interpreted here. */ +export interface ExperimentBlob { + adapter: string; + payload: unknown; +} + +/** DISPLAY severity precedence: failed > suspect > stale > uncharacterized > + * calibrated. Used for "worst status wins" combination + the qubit rollup. */ +const SEVERITY: Record = { + calibrated: 0, + uncharacterized: 1, + stale: 2, + suspect: 3, + failed: 4, +}; + +export function worseStatus(a: NodeStatus, b: NodeStatus): NodeStatus { + return SEVERITY[a] >= SEVERITY[b] ? a : b; +} + +export interface Threshold { + metric: string; + max?: number; + min?: number; +} + +export interface GraphNode { + name: string; + depends_on: string[]; + experiment?: ExperimentBlob; + produces: string[]; + ttl_seconds?: number; + impl: "standard" | "qilc"; + /** A qilc node names a standard fallback whose `produces` overlaps. */ + fallback?: string; + /** Optional qubit association → the per-qubit rollup. */ + qubit?: string; + thresholds?: { check?: Threshold; calibrate?: Threshold }; +} + +export interface CalibrationGraph { + nodes: Map; + /** Topological order: every dependency precedes its dependents. */ + topoOrder: string[]; + /** Longest path from any root (roots = depth 0) — the ranking key. */ + depth(name: string): number; + /** Direct dependents of `name`. */ + children(name: string): string[]; +} + +/** Rolling ops state per node (the `state.json` map). */ +export interface NodeState { + value?: Record; + ts?: string; // ISO8601 + status?: NodeStatus; // last recorded own status (e.g. an experiment reported "failed") + job_id?: string; + config_version?: string; +} + +export interface NodeVerdict { + node: string; + status: NodeStatus; + recommended_action: RecommendedAction; + reason: string; + /** Seconds since last result; +Infinity if uncharacterized (sorts first). */ + ageSeconds: number; + depth: number; + impl: "standard" | "qilc"; + fallback?: string; + qubit?: string; +} + +// ── loadGraph — parse TOML, build nodes, validate acyclicity ────────────────── + +type LoadResult = { ok: true; graph: CalibrationGraph } | { ok: false; error: string }; + +function asStringArray(v: unknown): string[] { + return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; +} + +function parseThreshold(v: unknown): Threshold | undefined { + if (!v || typeof v !== "object") return undefined; + const o = v as Record; + if (typeof o.metric !== "string") return undefined; + const t: Threshold = { metric: o.metric }; + if (typeof o.max === "number") t.max = o.max; + if (typeof o.min === "number") t.min = o.min; + return t; +} + +function parseExperiment(v: unknown): ExperimentBlob | undefined { + if (!v || typeof v !== "object") return undefined; + const o = v as Record; + if (typeof o.adapter !== "string") return undefined; + return { adapter: o.adapter, payload: o.payload ?? {} }; +} + +/** Kahn topological sort. Returns undefined if a cycle remains (a back-edge). + * Deterministic: ready nodes are processed in sorted name order. */ +function topoSort(nodes: Map): string[] | undefined { + const indeg = new Map(); + const adj = new Map(); // dep → dependents + for (const name of nodes.keys()) { + indeg.set(name, 0); + adj.set(name, []); + } + for (const node of nodes.values()) { + for (const dep of node.depends_on) { + if (!nodes.has(dep)) continue; // unknown dep — dropped from the edge set (defensive) + adj.get(dep)!.push(node.name); + indeg.set(node.name, (indeg.get(node.name) ?? 0) + 1); + } + } + const ready = [...indeg.entries()].filter(([, d]) => d === 0).map(([n]) => n).sort(); + const order: string[] = []; + while (ready.length > 0) { + const n = ready.shift()!; + order.push(n); + for (const child of adj.get(n)!.slice().sort()) { + const d = (indeg.get(child) ?? 0) - 1; + indeg.set(child, d); + if (d === 0) { + const idx = ready.findIndex((x) => x > child); + if (idx === -1) ready.push(child); + else ready.splice(idx, 0, child); + } + } + } + return order.length === nodes.size ? order : undefined; +} + +export function loadGraph(tomlText: string): LoadResult { + let parsed: Record; + try { + parsed = parseToml(tomlText) as Record; + } catch (e) { + return { ok: false, error: `parse_error: ${(e as Error).message}` }; + } + const nodeTable = parsed.node; + if (!nodeTable || typeof nodeTable !== "object") { + return { ok: false, error: "no_nodes: graph has no [node.*] tables" }; + } + const nodes = new Map(); + for (const [name, raw] of Object.entries(nodeTable as Record)) { + if (!raw || typeof raw !== "object") continue; + const o = raw as Record; + const thr = o.thresholds as Record | undefined; + nodes.set(name, { + name, + depends_on: asStringArray(o.depends_on), + experiment: parseExperiment(o.experiment), + produces: asStringArray(o.produces), + ttl_seconds: typeof o.ttl_seconds === "number" ? o.ttl_seconds : undefined, + impl: o.impl === "qilc" ? "qilc" : "standard", + fallback: typeof o.fallback === "string" ? o.fallback : undefined, + qubit: typeof o.qubit === "string" ? o.qubit : undefined, + thresholds: thr ? { check: parseThreshold(thr.check), calibrate: parseThreshold(thr.calibrate) } : undefined, + }); + } + if (nodes.size === 0) return { ok: false, error: "no_nodes: graph has no [node.*] tables" }; + + const order = topoSort(nodes); + if (order === undefined) return { ok: false, error: "cycle: the calibration graph has a dependency cycle" }; + + // depth = longest path from a root; computed over the topo order. + const depthMap = new Map(); + for (const name of order) { + const node = nodes.get(name)!; + const deps = node.depends_on.filter((d) => nodes.has(d)); + depthMap.set(name, deps.length === 0 ? 0 : 1 + Math.max(...deps.map((d) => depthMap.get(d) ?? 0))); + } + const childMap = new Map(); + for (const name of nodes.keys()) childMap.set(name, []); + for (const node of nodes.values()) + for (const dep of node.depends_on) if (nodes.has(dep)) childMap.get(dep)!.push(node.name); + + return { + ok: true, + graph: { + nodes, + topoOrder: order, + depth: (name) => depthMap.get(name) ?? 0, + children: (name) => (childMap.get(name) ?? []).slice(), + }, + }; +} + +// ── evaluate — pure + total (precondition: the graph loaded acyclically) ────── + +const ACTION_FOR: Record = { + calibrated: "none", + stale: "check", + suspect: "check", + uncharacterized: "calibrate", + failed: "calibrate", +}; + +/** Own status from a node's own state alone (no propagation). */ +function ownStatus( + node: GraphNode, + st: NodeState | undefined, + nowMs: number, +): { status: NodeStatus; ageSeconds: number } { + if (!st || !st.ts) return { status: "uncharacterized", ageSeconds: Infinity }; + const tsMs = Date.parse(st.ts); + if (Number.isNaN(tsMs)) return { status: "uncharacterized", ageSeconds: Infinity }; + const ageSeconds = (nowMs - tsMs) / 1000; + // an experiment that reported failure is authoritative + if (st.status === "failed") return { status: "failed", ageSeconds }; + // threshold breach (only when the metric is actually present in the value) + const check = node.thresholds?.check; + if (check && st.value && typeof st.value[check.metric] === "number") { + const m = st.value[check.metric] as number; + if ((check.max !== undefined && m > check.max) || (check.min !== undefined && m < check.min)) + return { status: "failed", ageSeconds }; + } + // stale by ttl + if (node.ttl_seconds !== undefined && ageSeconds > node.ttl_seconds) return { status: "stale", ageSeconds }; + return { status: "calibrated", ageSeconds }; +} + +export function evaluate(graph: CalibrationGraph, state: Record, nowMs: number): NodeVerdict[] { + // Pass 1 — own status. + const own = new Map(); + for (const [name, node] of graph.nodes) own.set(name, ownStatus(node, state[name], nowMs)); + + // Pass 2 — suspect propagation over the topo order. A node whose OWN status is + // calibrated but which has any non-calibrated dependency becomes suspect (a + // parent moved out from under it). suspect only ever replaces calibrated — a + // node with its own problem keeps its own (more actionable) status/action. + const finalStatus = new Map(); + for (const name of graph.topoOrder) { + const node = graph.nodes.get(name)!; + const os = own.get(name)!.status; + if (os !== "calibrated") { + finalStatus.set(name, os); + continue; + } + const anyDepDirty = node.depends_on + .filter((d) => graph.nodes.has(d)) + .some((d) => finalStatus.get(d) !== "calibrated"); + finalStatus.set(name, anyDepDirty ? "suspect" : "calibrated"); + } + + // Pass 3 — verdicts + rank. + const verdicts: NodeVerdict[] = []; + for (const [name, node] of graph.nodes) { + const status = finalStatus.get(name)!; + const { ageSeconds } = own.get(name)!; + verdicts.push({ + node: name, + status, + recommended_action: ACTION_FOR[status], + reason: reasonFor(status, node), + ageSeconds, + depth: graph.depth(name), + impl: node.impl, + fallback: node.fallback, + qubit: node.qubit, + }); + } + // rank: topological depth asc (roots first), then age desc (+∞ first), then name. + verdicts.sort( + (a, b) => + a.depth - b.depth || + cmpAgeDesc(a.ageSeconds, b.ageSeconds) || + (a.node < b.node ? -1 : a.node > b.node ? 1 : 0), + ); + return verdicts; +} + +function cmpAgeDesc(a: number, b: number): number { + if (a === b) return 0; + if (a === Infinity) return -1; + if (b === Infinity) return 1; + return b - a; +} + +function reasonFor(status: NodeStatus, node: GraphNode): string { + switch (status) { + case "calibrated": + return "fresh; all dependencies calibrated"; + case "stale": + return `last result older than ttl (${node.ttl_seconds ?? "∞"}s)`; + case "suspect": + return "a dependency moved since this node last ran"; + case "failed": + return "last check breached its threshold or the experiment failed"; + case "uncharacterized": + return "no recorded result"; + } +} + +// ── device-status projection (the honesty rule: uncharacterized/stale) ──────── + +export interface QubitRollup { + qubit: string; + /** Worst-status-wins over that qubit's nodes. uncharacterized if none (honest gap). */ + status: NodeStatus; + nodeCount: number; +} + +export interface MetricReading { + value: number; + ts?: string; + ageSeconds: number; + status: NodeStatus; + node: string; +} + +/** The projection the `device status` verb renders. Metrics are present ONLY for + * MEASURED nodes — never a fabricated number (the honesty rule). */ +export interface DeviceStatus { + qubits: QubitRollup[]; + metrics: Record; + /** Latest produced params (T1/T2/fidelity/…), measured-only. */ + calibrationParams: Record; + /** The full ranked verdict set. */ + nodes: NodeVerdict[]; +} + +export function buildDeviceStatus( + graph: CalibrationGraph, + state: Record, + now: number, + qubitsArg?: string[], +): DeviceStatus { + const verdicts = evaluate(graph, state, now); + const byNode = new Map(verdicts.map((v) => [v.node, v])); + + const qubitSet = + qubitsArg ?? + [...new Set([...graph.nodes.values()].map((n) => n.qubit).filter((q): q is string => !!q))].sort(); + const qubits: QubitRollup[] = qubitSet.map((qubit) => { + const nodeVerdicts = verdicts.filter((v) => v.qubit === qubit); + let status: NodeStatus = "uncharacterized"; // no nodes → honest gap + if (nodeVerdicts.length > 0) + status = nodeVerdicts.reduce((acc, v) => worseStatus(acc, v.status), "calibrated"); + return { qubit, status, nodeCount: nodeVerdicts.length }; + }); + + const metrics: Record = {}; + const calibrationParams: Record = {}; + for (const [name, node] of graph.nodes) { + const st = state[name]; + if (!st || !st.value) continue; + const v = byNode.get(name)!; + for (const key of node.produces) { + const val = st.value[key]; + if (val === undefined) continue; + calibrationParams[key] = val; + if (typeof val === "number" && Number.isFinite(val)) + metrics[key] = { value: val, ts: st.ts, ageSeconds: v.ageSeconds, status: v.status, node: name }; + } + } + return { qubits, metrics, calibrationParams, nodes: verdicts }; +} + +// ── next-actions + the premium (Intonatissimo) funnel ───────────────────────── + +export interface NextAction { + node: string; + /** The node to actually run — the fallback when a qilc node is locked. */ + recommendedNode: string; + status: NodeStatus; + action: RecommendedAction; + impl: "standard" | "qilc"; + /** Premium + unentitled → locked, and carries the funnel (below). Never + * auto-runs the premium action; shows the upsell rather than a dead grey-out. */ + locked: boolean; + /** The funnel on a locked premium node: name the product + capability + invite. + * NEVER the private method acronym. Absent on unlocked nodes. */ + premium?: { package: string; capability: string; invite: string }; + reason: string; +} + +export interface NextActionsResult { + idle: boolean; + ranked_actions: NextAction[]; +} + +export function nextActions( + graph: CalibrationGraph, + state: Record, + now: number, + opts: { entitled: boolean; idle: boolean }, +): NextActionsResult { + const verdicts = evaluate(graph, state, now); + const ranked: NextAction[] = []; + for (const v of verdicts) { + if (v.recommended_action === "none") continue; // calibrated nodes need no action + const base: NextAction = { + node: v.node, + recommendedNode: v.node, + status: v.status, + action: v.recommended_action, + impl: v.impl, + locked: false, + reason: v.reason, + }; + if (v.impl === "qilc" && !opts.entitled) { + base.locked = true; // access control — but a FUNNEL, not a dead grey-out + base.premium = { + package: "Intonatissimo", + capability: "closed-loop calibration", + invite: + "Closed-loop calibration here is handled by Intonatissimo — contact Harmoniqs to enable it on this device.", + }; + if (v.fallback) { + base.recommendedNode = v.fallback; // deterministic path falls back to the standard node... + base.action = "calibrate"; + base.reason = `Closed-loop calibration via Intonatissimo (premium) — falling back to '${v.fallback}' until enabled`; + } else { + base.action = "redesign"; + base.reason = + "Closed-loop calibration via Intonatissimo (premium) not enabled, no fallback → redesign the pulse"; + } + } + ranked.push(base); + } + return { idle: opts.idle, ranked_actions: ranked }; +} + +// ── state.json loader ───────────────────────────────────────────────────────── + +const NODE_STATUSES: NodeStatus[] = ["calibrated", "stale", "suspect", "failed", "uncharacterized"]; + +function isRecord(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v); +} + +function toNodeState(o: Record): NodeState { + const st: NodeState = {}; + if (isRecord(o.value)) st.value = o.value; + if (typeof o.ts === "string") st.ts = o.ts; + if (typeof o.status === "string" && (NODE_STATUSES as string[]).includes(o.status)) st.status = o.status as NodeStatus; + if (typeof o.job_id === "string") st.job_id = o.job_id; + if (typeof o.config_version === "string") st.config_version = o.config_version; + return st; +} + +/** Parse a `state.json` body → { node → NodeState }. Never throws: junk, a + * non-object, or a missing file all degrade to {}. */ +export function parseStateJson(text: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return {}; + } + if (!isRecord(parsed)) return {}; + const out: Record = {}; + for (const [node, raw] of Object.entries(parsed)) if (isRecord(raw)) out[node] = toNodeState(raw); + return out; +} + +// ── the benchmark-exclusivity lock (W-2) ────────────────────────────────────── +// A device under a `benchmark` allocation accepts NO concurrent submission, and +// the harness suspends leaf fan-out for its duration (the no-parallel-benchmark +// rule). The lock is durable ops state (a `lock.json` alongside state.json); the +// DECISION logic is pure and lives here, the file I/O in device_verb.ts. + +/** The exclusive allocation modes — a held lock in one of these blocks submission. */ +export const EXCLUSIVE_MODES = ["benchmark"] as const; + +export interface DeviceLock { + mode: string; + owner: string; + acquired_at: string; // ISO8601 +} + +export function parseLock(text: string): DeviceLock | undefined { + let o: unknown; + try { + o = JSON.parse(text); + } catch { + return undefined; + } + if (!isRecord(o)) return undefined; + if (typeof o.mode !== "string" || typeof o.owner !== "string" || typeof o.acquired_at !== "string") return undefined; + return { mode: o.mode, owner: o.owner, acquired_at: o.acquired_at }; +} + +export function isExclusive(mode: string): boolean { + return (EXCLUSIVE_MODES as readonly string[]).includes(mode); +} + +/** A device accepts a concurrent submission iff it is NOT under an exclusive + * (benchmark) allocation. */ +export function acceptsSubmission(lock: DeviceLock | undefined): boolean { + return !(lock && isExclusive(lock.mode)); +} + +export type AcquireDecision = + | { ok: true; lock: DeviceLock; reentrant: boolean } + | { ok: false; reason: string; held: DeviceLock }; + +/** Acquire an exclusive allocation. Free → granted. Held by the SAME owner → + * re-entrant (idempotent). Held by ANOTHER owner → refused (the exclusivity). */ +export function acquireDecision( + current: DeviceLock | undefined, + mode: string, + owner: string, + now: string, +): AcquireDecision { + if (current && current.owner !== owner) { + return { + ok: false, + reason: `device is held by "${current.owner}" (mode ${current.mode}) — benchmark exclusivity refuses a concurrent allocation`, + held: current, + }; + } + if (current && current.owner === owner && current.mode === mode) { + return { ok: true, lock: current, reentrant: true }; // idempotent re-acquire keeps acquired_at + } + return { ok: true, lock: { mode, owner, acquired_at: now }, reentrant: false }; +} + +export type ReleaseDecision = + | { ok: true; released: boolean; had?: DeviceLock } + | { ok: false; reason: string; held: DeviceLock }; + +/** Release an allocation. No lock → no-op. Held by the owner (or --force) → + * released. Held by another owner without --force → refused. */ +export function releaseDecision( + current: DeviceLock | undefined, + owner: string | undefined, + force: boolean, +): ReleaseDecision { + if (!current) return { ok: true, released: false }; + if (force || (owner !== undefined && current.owner === owner)) return { ok: true, released: true, had: current }; + return { + ok: false, + reason: `device is held by "${current.owner}" — pass --owner ${current.owner} or --force to release it`, + held: current, + }; +} + +// ── on-disk device layout ───────────────────────────────────────────────────── +// Ops state, NOT vault (churning): //{graph.toml,state.json,lock.json}. +// $AMICO_DEVICE_DIR overrides the root (tests point it at a temp dir); default is +// the local ops mount. + +export function deviceRoot(): string { + const env = process.env.AMICO_DEVICE_DIR; + if (env && env.trim() !== "") return env; + return join(homedir(), ".amico", "devices"); +} + +export interface DeviceLoad { + dir: string; + graph?: CalibrationGraph; + graphError?: string; + state: Record; + lock?: DeviceLock; +} + +/** Read a device's graph.toml + state.json + lock.json. Never throws: a missing + * graph leaves `graph` undefined with a `graphError`; a missing state/lock + * degrades to {}/undefined. */ +export function loadDevice(root: string, device: string): DeviceLoad { + const dir = join(root, device); + const load: DeviceLoad = { dir, state: {} }; + + const graphFile = join(dir, "graph.toml"); + if (existsSync(graphFile)) { + try { + const res = loadGraph(readFileSync(graphFile, "utf8")); + if (res.ok) load.graph = res.graph; + else load.graphError = res.error; + } catch (e) { + load.graphError = `read_error: ${e instanceof Error ? e.message : String(e)}`; + } + } else { + load.graphError = `no_graph: ${graphFile} not found`; + } + + const stateFile = join(dir, "state.json"); + if (existsSync(stateFile)) { + try { + load.state = parseStateJson(readFileSync(stateFile, "utf8")); + } catch { + load.state = {}; + } + } + + const lockFile = join(dir, "lock.json"); + if (existsSync(lockFile)) { + try { + load.lock = parseLock(readFileSync(lockFile, "utf8")); + } catch { + load.lock = undefined; + } + } + return load; +} diff --git a/packages/amico-run/src/device_verb.ts b/packages/amico-run/src/device_verb.ts new file mode 100644 index 00000000..95ec5274 --- /dev/null +++ b/packages/amico-run/src/device_verb.ts @@ -0,0 +1,261 @@ +// `amico device` — the dispatcher successor (issue #113, slice B3; +// spec-20260708-112732 §3.1 / W-2). The 87-line dispatcher AGENT is retired: its +// job (read device state → recommend the next calibration) is pure lookup with no +// LLM judgment, so it becomes a deterministic CLI verb. Three subcommands, all +// reading the device ops layout under $AMICO_DEVICE_DIR: +// +// amico device status --device [--now ] +// → the DeviceStatus projection (per-qubit rollup, measured metrics, ranked +// node verdicts) + the current allocation lock. Honesty rule: a node with +// no result is `uncharacterized`, a node past its TTL is `stale` — never a +// fabricated number. +// +// amico device next --device [--now ] [--entitled] +// → the ranked next-actions via the pure evaluate()/nextActions(). qilc +// (premium) nodes surface the Intonatissimo funnel (never the method +// acronym) + a standard fallback. A benchmark-locked device reports +// accepts_submission=false and idle=false (W-2: no concurrent submission). +// +// amico device lock --device [--mode benchmark] [--owner ] +// [--acquire | --release | --status] [--force] [--now ] +// → the benchmark-exclusivity lock (W-2): a device under a benchmark +// allocation accepts no concurrent submission and the harness suspends +// leaf fan-out for its duration. Acquire is refused when another owner +// holds it; re-acquire by the same owner is idempotent. +// +// The evaluate()/nextActions()/lock DECISION logic is pure (device_graph.ts); this +// file is the flag surface + file I/O. FLAG NAMES (S31 guard): the physics-knob +// double-dash flags (gate/pulse/system) are banned in src/; device flags +// (--device/--mode/--owner/--now/--entitled) sidestep them cleanly. +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + acceptsSubmission, + acquireDecision, + buildDeviceStatus, + deviceRoot, + isExclusive, + loadDevice, + nextActions, + releaseDecision, + type DeviceLock, +} from "./device_graph.js"; +import type { VerbResult } from "./verbs.js"; + +function flagValue(argv: string[], name: string): string | undefined { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; +} + +/** Resolve the evaluation clock: --now (deterministic, for tests/replay) + * else the wall clock. Returns {ms, iso}. */ +function resolveNow(argv: string[]): { ms: number; iso: string; error?: string } { + const raw = flagValue(argv, "--now"); + if (raw === undefined) { + const d = new Date(); + return { ms: d.getTime(), iso: d.toISOString() }; + } + const ms = Date.parse(raw); + if (Number.isNaN(ms)) return { ms: 0, iso: "", error: `--now must be an ISO8601 timestamp (got "${raw}")` }; + return { ms, iso: new Date(ms).toISOString() }; +} + +function requireDevice(argv: string[], sub: string): { device: string } | VerbResult { + const device = flagValue(argv, "--device"); + if (!device) return { json: { verb: "device", subcommand: sub, error: "--device is required" }, code: 64 }; + return { device }; +} + +function lockView(lock: DeviceLock | undefined) { + return lock + ? { held: true, mode: lock.mode, owner: lock.owner, acquired_at: lock.acquired_at, exclusive: isExclusive(lock.mode) } + : { held: false }; +} + +// ── status ───────────────────────────────────────────────────────────────── +export function deviceStatus(argv: string[]): VerbResult { + const req = requireDevice(argv, "status"); + if ("json" in req) return req; + const now = resolveNow(argv); + if (now.error) return { json: { verb: "device", subcommand: "status", error: now.error }, code: 64 }; + + const load = loadDevice(deviceRoot(), req.device); + if (!load.graph) { + return { + json: { + verb: "device", + subcommand: "status", + device: req.device, + dir: load.dir, + // honesty: no graph → the whole device is uncharacterized, not fabricated. + overall: "uncharacterized", + error: load.graphError, + lock: lockView(load.lock), + }, + code: 64, + }; + } + const status = buildDeviceStatus(load.graph, load.state, now.ms); + const overall = status.nodes.reduce( + (worst, v) => (severity(v.status) > severity(worst) ? v.status : worst), + "calibrated", + ); + return { + json: { + verb: "device", + subcommand: "status", + device: req.device, + now: now.iso, + overall, + qubits: status.qubits, + metrics: status.metrics, + calibration_params: status.calibrationParams, + nodes: status.nodes, + lock: lockView(load.lock), + accepts_submission: acceptsSubmission(load.lock), + }, + code: 0, + }; +} + +const SEVERITY_ORDER: Record = { + calibrated: 0, + uncharacterized: 1, + stale: 2, + suspect: 3, + failed: 4, +}; +function severity(s: string): number { + return SEVERITY_ORDER[s] ?? 0; +} + +// ── next ─────────────────────────────────────────────────────────────────── +export function deviceNext(argv: string[]): VerbResult { + const req = requireDevice(argv, "next"); + if ("json" in req) return req; + const now = resolveNow(argv); + if (now.error) return { json: { verb: "device", subcommand: "next", error: now.error }, code: 64 }; + + const load = loadDevice(deviceRoot(), req.device); + if (!load.graph) { + return { + json: { verb: "device", subcommand: "next", device: req.device, dir: load.dir, error: load.graphError }, + code: 64, + }; + } + const entitled = argv.includes("--entitled"); + // A benchmark-locked device is NOT idle (it holds hardware exclusivity) and + // accepts no concurrent submission — the harness must not fan out onto it. + const idle = acceptsSubmission(load.lock); + const result = nextActions(load.graph, load.state, now.ms, { entitled, idle }); + return { + json: { + verb: "device", + subcommand: "next", + device: req.device, + now: now.iso, + entitled, + idle: result.idle, + accepts_submission: acceptsSubmission(load.lock), + lock: lockView(load.lock), + count: result.ranked_actions.length, + ranked_actions: result.ranked_actions, + }, + code: 0, + }; +} + +// ── lock ─────────────────────────────────────────────────────────────────── +export function deviceLock(argv: string[]): VerbResult { + const req = requireDevice(argv, "lock"); + if ("json" in req) return req; + const now = resolveNow(argv); + if (now.error) return { json: { verb: "device", subcommand: "lock", error: now.error }, code: 64 }; + + const root = deviceRoot(); + const load = loadDevice(root, req.device); + const lockFile = join(load.dir, "lock.json"); + + const doAcquire = argv.includes("--acquire"); + const doRelease = argv.includes("--release"); + if (doAcquire && doRelease) { + return { json: { verb: "device", subcommand: "lock", error: "pass at most one of --acquire / --release" }, code: 64 }; + } + + // default (no --acquire/--release, or --status) → report the current allocation. + if (!doAcquire && !doRelease) { + return { + json: { verb: "device", subcommand: "lock", op: "status", device: req.device, lock: lockView(load.lock), accepts_submission: acceptsSubmission(load.lock) }, + code: 0, + }; + } + + if (doRelease) { + const owner = flagValue(argv, "--owner"); + const force = argv.includes("--force"); + const decision = releaseDecision(load.lock, owner, force); + if (!decision.ok) { + return { json: { verb: "device", subcommand: "lock", op: "release", released: false, device: req.device, reason: decision.reason, lock: lockView(decision.held) }, code: 64 }; + } + if (decision.released && existsSync(lockFile)) { + try { + rmSync(lockFile, { force: true }); + } catch (e) { + return { json: { verb: "device", subcommand: "lock", op: "release", error: `failed to remove lock: ${e instanceof Error ? e.message : String(e)}` }, code: 1 }; + } + } + return { json: { verb: "device", subcommand: "lock", op: "release", released: decision.released, device: req.device, accepts_submission: true, lock: { held: false } }, code: 0 }; + } + + // --acquire + const owner = flagValue(argv, "--owner"); + if (!owner) return { json: { verb: "device", subcommand: "lock", op: "acquire", error: "--owner is required to acquire an exclusive allocation" }, code: 64 }; + const mode = flagValue(argv, "--mode") ?? "benchmark"; + const decision = acquireDecision(load.lock, mode, owner, now.iso); + if (!decision.ok) { + return { json: { verb: "device", subcommand: "lock", op: "acquire", acquired: false, device: req.device, mode, owner, reason: decision.reason, lock: lockView(decision.held) }, code: 64 }; + } + try { + mkdirSync(load.dir, { recursive: true }); + writeFileSync(lockFile, JSON.stringify(decision.lock, null, 2) + "\n"); + } catch (e) { + return { json: { verb: "device", subcommand: "lock", op: "acquire", error: `failed to write lock: ${e instanceof Error ? e.message : String(e)}` }, code: 1 }; + } + return { + json: { + verb: "device", + subcommand: "lock", + op: "acquire", + acquired: true, + reentrant: decision.reentrant, + device: req.device, + mode, + owner, + // W-2: an exclusive allocation refuses concurrent submission + suspends fan-out. + accepts_submission: acceptsSubmission(decision.lock), + suspends_fanout: isExclusive(mode), + lock: lockView(decision.lock), + }, + code: 0, + }; +} + +// ── dispatch ───────────────────────────────────────────────────────────────── +/** The `device` verb body: dispatch on the subcommand. Backs BOTH the CLI + * (amico.ts) and the MCP facade (mcp_serve.ts) — one impl, two transports. */ +export function deviceVerb(argv: string[]): VerbResult { + const sub = argv[0]; + const rest = argv.slice(1); + if (sub === "status") return deviceStatus(rest); + if (sub === "next") return deviceNext(rest); + if (sub === "lock") return deviceLock(rest); + return { + json: { + verb: "device", + error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, + usage: + "amico device status --device | amico device next --device [--entitled] | amico device lock --device --owner [--acquire|--release]", + }, + code: 64, + }; +} diff --git a/packages/amico-run/src/note.ts b/packages/amico-run/src/note.ts new file mode 100644 index 00000000..51ffa71d --- /dev/null +++ b/packages/amico-run/src/note.ts @@ -0,0 +1,261 @@ +// Librarian bookkeeping — the pure core behind the `amico note` verb (issue #113, +// slice B3; spec-20260708-112732 §3.1 / W-3). The librarian AGENT splits by ring: +// its INTERNAL half is DETERMINISTIC bookkeeping (write an experiment note, bump +// the system-context `best_gates`) and migrates to this CLI verb; the judgment +// half (insight extraction) stays a headless leaf. This module is that +// deterministic half — no LLM, no clock (dates/ids are passed in), fully +// unit-testable, mirroring repertoire.ts. +// +// Two operations: (1) render an experiment note with full frontmatter from a +// finished-run row; (2) bump the `best_gates` list in a system-context note, +// replacing the incumbent gate entry iff the candidate has higher fidelity. + +// ── experiment note rendering ───────────────────────────────────────────────── + +export interface ExperimentFields { + platform: string; + gate: string; + fidelity: number; + date: string; // ISO date "YYYY-MM-DD" + duration_us?: number; + status?: string; // completed | improved | failed | stalled (default completed) + task_type?: string; // experiment | validation | regression | … (default experiment) + session_id?: string; + warm_start?: string; // catalog-id, or absent + failure_mode?: string; // stagnation | divergence | … , or absent + device?: string; // wikilink target (default "[[local-workstation]]") + branch?: string; // default main + desc?: string; // one-line summary for the H1 + title +} + +/** `experiment---` — deterministic id/basename. + * A `session_id` (when present) disambiguates same-day same-gate notes. */ +export function experimentId(f: ExperimentFields): string { + const day = f.date.replace(/-/g, ""); + const suffix = f.session_id ? `-${f.session_id.slice(0, 8)}` : ""; + return `experiment-${day}-${f.platform}-${f.gate}${suffix}`; +} + +function fmScalar(key: string, value: string | number | null): string { + return `${key}: ${value === null ? "null" : value}`; +} + +/** Render the full experiment note (frontmatter + body skeleton). Deterministic: + * no fields are invented — every value comes from `f` or a documented default. */ +export function renderExperimentNote(f: ExperimentFields): string { + const status = f.status ?? "completed"; + const taskType = f.task_type ?? "experiment"; + const device = f.device ?? "[[local-workstation]]"; + const branch = f.branch ?? "main"; + const title = f.desc ? f.desc : `${f.platform} ${f.gate} — ${status}`; + const tags = ["experiment", f.platform, `gate/${f.gate}`, `status/${status}`, `task/${taskType}`]; + + const fm = [ + "---", + fmScalar("type", "experiment"), + fmScalar("task_type", taskType), + fmScalar("date", f.date), + fmScalar("session_id", f.session_id ? `"${f.session_id}"` : "null"), + fmScalar("platform", f.platform), + fmScalar("gate", f.gate), + fmScalar("fidelity", f.fidelity), + fmScalar("duration_us", f.duration_us ?? "null"), + fmScalar("status", status), + fmScalar("failure_mode", f.failure_mode ?? "null"), + fmScalar("warm_start", f.warm_start ? `"${f.warm_start}"` : "null"), + fmScalar("device", `"${device}"`), + fmScalar("branch", branch), + `tags: [${tags.join(", ")}]`, + "---", + ].join("\n"); + + const infidelity = 1 - f.fidelity; + const body = [ + "", + `# Exp: ${title}`, + "", + "## Setup", + `- Platform: ${f.platform}`, + `- Gate: ${f.gate}`, + `- Warm-start: ${f.warm_start ?? "null (cold start)"}`, + `- Device: ${device}`, + "", + "## Result", + `- $\\mathcal{F} = ${f.fidelity}$ (infidelity $1 - \\mathcal{F} = ${infidelity.toExponential(3)}$)`, + f.duration_us !== undefined ? `- Duration: ${f.duration_us} µs` : "- Duration: (not recorded)", + `- Status: ${status}`, + f.failure_mode ? `- Failure mode: ${f.failure_mode}` : "", + "", + "## Analysis", + "- (bookkeeping stub written by `amico note write`; extend with interpretation.)", + "", + ] + .filter((l) => l !== "") + .join("\n"); + + return fm + "\n" + body + "\n"; +} + +// ── best_gates bump ─────────────────────────────────────────────────────────── + +export interface BestGate { + gate: string; + fidelity: number; + duration_ns?: number; + source?: string; // wikilink to the experiment/catalog entry +} + +export interface MergeResult { + gates: BestGate[]; + bumped: boolean; // did the list change? + previous?: BestGate; // the incumbent entry for this gate, if any + reason: string; +} + +/** Replace the incumbent entry for `entry.gate` iff the candidate has strictly + * higher fidelity; add it if absent; otherwise no-op. Pure — returns a new list + * (input untouched), sorted by gate name for a stable, diff-friendly file. */ +export function mergeBestGates(existing: BestGate[], entry: BestGate): MergeResult { + const previous = existing.find((g) => g.gate === entry.gate); + if (!previous) { + const gates = [...existing, entry].sort((a, b) => (a.gate < b.gate ? -1 : a.gate > b.gate ? 1 : 0)); + return { gates, bumped: true, reason: `added ${entry.gate} (no prior best_gate)` }; + } + if (entry.fidelity > previous.fidelity) { + const gates = existing.map((g) => (g.gate === entry.gate ? entry : g)); + return { gates, bumped: true, previous, reason: `bumped ${entry.gate}: ${previous.fidelity} → ${entry.fidelity}` }; + } + return { + gates: existing, + bumped: false, + previous, + reason: `did not bump ${entry.gate}: candidate ${entry.fidelity} ≤ incumbent ${previous.fidelity}`, + }; +} + +/** Parse one inline-table best_gate entry: `{gate: X, fidelity: 0.99, + * duration_ns: 37, source: "[[..]]"}`. Returns undefined if it lacks the + * discriminating fields (gate + fidelity). */ +export function parseBestGate(inline: string): BestGate | undefined { + const body = inline.trim().replace(/^\{/, "").replace(/\}$/, ""); + const fields: Record = {}; + for (const pair of splitTopLevel(body)) { + const idx = pair.indexOf(":"); + if (idx === -1) continue; + const key = pair.slice(0, idx).trim(); + const val = pair.slice(idx + 1).trim(); + if (key) fields[key] = val; + } + const gate = fields.gate?.replace(/^["']|["']$/g, ""); + const fidelity = fields.fidelity !== undefined ? Number(fields.fidelity) : NaN; + if (!gate || !Number.isFinite(fidelity)) return undefined; + const g: BestGate = { gate, fidelity }; + if (fields.duration_ns !== undefined && Number.isFinite(Number(fields.duration_ns))) + g.duration_ns = Number(fields.duration_ns); + if (fields.source !== undefined) g.source = fields.source.replace(/^["']|["']$/g, ""); + return g; +} + +/** Split an inline-table body on commas that are NOT inside quotes/brackets. */ +function splitTopLevel(s: string): string[] { + const out: string[] = []; + let depth = 0; + let quote = ""; + let cur = ""; + for (const ch of s) { + if (quote) { + cur += ch; + if (ch === quote) quote = ""; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + cur += ch; + continue; + } + if (ch === "[" || ch === "{") depth++; + if (ch === "]" || ch === "}") depth--; + if (ch === "," && depth === 0) { + out.push(cur); + cur = ""; + continue; + } + cur += ch; + } + if (cur.trim()) out.push(cur); + return out; +} + +export function serializeBestGate(g: BestGate): string { + const parts = [`gate: ${g.gate}`, `fidelity: ${g.fidelity}`]; + if (g.duration_ns !== undefined) parts.push(`duration_ns: ${g.duration_ns}`); + if (g.source !== undefined) parts.push(`source: "${g.source}"`); + return ` - {${parts.join(", ")}}`; +} + +export interface BumpTextResult { + ok: boolean; + text?: string; // the rewritten note (only when ok) + bumped?: boolean; + previous?: BestGate; + reason: string; +} + +/** Bump the `best_gates` block of a system-context note (given its full text) with + * `entry`. Pure text surgery: parses the block (both `best_gates: []` and the + * multi-line list form), merges, re-serializes ONLY that block, leaving the rest + * of the note byte-identical. Errors (no frontmatter / no `best_gates:` key) are + * returned, never thrown. */ +export function bumpBestGatesInText(text: string, entry: BestGate): BumpTextResult { + if (!text.startsWith("---")) return { ok: false, reason: "not a note: no leading frontmatter" }; + const fmEnd = text.indexOf("\n---", 3); + if (fmEnd === -1) return { ok: false, reason: "malformed frontmatter (no closing ---)" }; + + const lines = text.split("\n"); + // Frontmatter spans lines[1 .. closeIdx-1]; find the closing `---`. + let closeIdx = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i] === "---") { + closeIdx = i; + break; + } + } + if (closeIdx === -1) return { ok: false, reason: "malformed frontmatter (no closing ---)" }; + + // Locate `best_gates:` within the frontmatter. + let keyIdx = -1; + for (let i = 1; i < closeIdx; i++) { + if (/^best_gates:/.test(lines[i])) { + keyIdx = i; + break; + } + } + if (keyIdx === -1) return { ok: false, reason: "no `best_gates:` key in the note frontmatter" }; + + // The block: the key line plus following list items (indented `-`), until the + // next top-level frontmatter key or the closing ---. + const keyLine = lines[keyIdx]; + const existing: BestGate[] = []; + let blockEnd = keyIdx + 1; // first line NOT part of the block + const inlineEmpty = /^best_gates:\s*\[\s*\]\s*$/.test(keyLine); + if (!inlineEmpty) { + for (let i = keyIdx + 1; i < closeIdx; i++) { + const l = lines[i]; + if (/^[A-Za-z_][A-Za-z0-9_]*:/.test(l)) break; // next top-level key + blockEnd = i + 1; + const m = l.match(/^\s*-\s*(\{.*\})\s*$/); + if (m) { + const g = parseBestGate(m[1]); + if (g) existing.push(g); + } + } + } + + const merge = mergeBestGates(existing, entry); + if (!merge.bumped) return { ok: true, text, bumped: false, previous: merge.previous, reason: merge.reason }; + + const newBlock = + merge.gates.length === 0 ? ["best_gates: []"] : ["best_gates:", ...merge.gates.map(serializeBestGate)]; + const rebuilt = [...lines.slice(0, keyIdx), ...newBlock, ...lines.slice(blockEnd)].join("\n"); + return { ok: true, text: rebuilt, bumped: true, previous: merge.previous, reason: merge.reason }; +} diff --git a/packages/amico-run/src/note_verb.ts b/packages/amico-run/src/note_verb.ts new file mode 100644 index 00000000..2e32c6fd --- /dev/null +++ b/packages/amico-run/src/note_verb.ts @@ -0,0 +1,221 @@ +// `amico note` — the librarian's deterministic bookkeeping half (issue #113, +// slice B3; spec-20260708-112732 §3.1 / W-3). Two subcommands, both pure-logic +// (note.ts) wrapped in filesystem I/O against the mounted vault +// ($AMICO_VAULT_DIR): +// +// amico note write --platform

--kind --fidelity [--duration-us ] +// [--status ] [--session ] [--warm-start ] +// [--from-run

] [--date ] [--desc ] +// [--dry-run] +// → write an experiment note (full frontmatter + body skeleton) into +// experiments/. Deterministic: --date pins the id/date; --from-run reads +// result.toml for fidelity/duration. +// +// amico note bump-best --platform

--kind --fidelity +// [--duration-ns | --duration-us ] [--source ] +// [--context ] [--dry-run] +// → bump the `best_gates` list in the platform's system-context note, +// replacing the incumbent gate entry iff the candidate has higher +// fidelity. Surgical text edit — the rest of the note is untouched. +// +// FLAG NAMES (S31 guard): the physics-knob double-dash flags (gate/pulse/system) +// are banned in src/; the gate discriminator is `--kind` (mapping onto the note +// `gate` field, as `amico catalog` does). +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse as parseToml } from "smol-toml"; +import { + bumpBestGatesInText, + experimentId, + renderExperimentNote, + type BestGate, + type ExperimentFields, +} from "./note.js"; +import { vaultDir } from "./vault_query.js"; +import type { VerbResult } from "./verbs.js"; + +function flagValue(argv: string[], name: string): string | undefined { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; +} + +function readTomlSafe(file: string): Record | undefined { + if (!existsSync(file)) return undefined; + try { + return parseToml(readFileSync(file, "utf8")) as Record; + } catch { + return undefined; + } +} + +function num(v: unknown): number | undefined { + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +// ── write ───────────────────────────────────────────────────────────────────── +export function noteWrite(argv: string[]): VerbResult { + const fail = (error: string): VerbResult => ({ json: { verb: "note", subcommand: "write", error }, code: 64 }); + + const platform = flagValue(argv, "--platform"); + const gate = flagValue(argv, "--kind"); + if (!platform || !gate) return fail("--platform and --kind are required"); + + const runDir = flagValue(argv, "--from-run"); + const result = runDir ? readTomlSafe(join(runDir, "result.toml")) : undefined; + + const fidRaw = flagValue(argv, "--fidelity"); + const fidelity = fidRaw !== undefined ? Number(fidRaw) : num(result?.fidelity); + if (fidelity === undefined || !Number.isFinite(fidelity)) { + return fail("a fidelity is required: --fidelity or --from-run

with a result.toml"); + } + + const durRaw = flagValue(argv, "--duration-us"); + const duration_us = durRaw !== undefined ? Number(durRaw) : num(result?.duration_us); + + const fields: ExperimentFields = { + platform, + gate, + fidelity, + date: flagValue(argv, "--date") ?? today(), + duration_us: duration_us !== undefined && Number.isFinite(duration_us) ? duration_us : undefined, + status: flagValue(argv, "--status"), + task_type: flagValue(argv, "--task-type"), + session_id: flagValue(argv, "--session"), + warm_start: flagValue(argv, "--warm-start"), + failure_mode: flagValue(argv, "--failure-mode"), + device: flagValue(argv, "--device-note"), + branch: flagValue(argv, "--branch"), + desc: flagValue(argv, "--desc"), + }; + + const id = flagValue(argv, "--id") ?? experimentId(fields); + const dir = vaultDir(); + const expDir = join(dir, "experiments"); + const file = join(expDir, `${id}.md`); + const content = renderExperimentNote(fields); + + if (argv.includes("--dry-run")) { + return { json: { verb: "note", subcommand: "write", written: false, dry_run: true, id, path: file, content }, code: 0 }; + } + if (existsSync(file)) return fail(`experiment note already exists: ${file} (pass --id to override)`); + + try { + mkdirSync(expDir, { recursive: true }); + writeFileSync(file, content); + } catch (e) { + return fail(`failed to write note: ${e instanceof Error ? e.message : String(e)}`); + } + return { + json: { verb: "note", subcommand: "write", written: true, id, path: file, platform, gate, fidelity, duration_us: fields.duration_us ?? null }, + code: 0, + }; +} + +// ── bump-best ───────────────────────────────────────────────────────────────── +/** Frontmatter `platform:` scalar of a note file (cheap regex; undefined on any + * read/parse trouble). */ +function notePlatform(file: string): string | undefined { + let text: string; + try { + text = readFileSync(file, "utf8"); + } catch { + return undefined; + } + const m = text.match(/^platform:\s*(.+)$/m); + return m ? m[1].trim().replace(/^["']|["']$/g, "") : undefined; +} + +/** Resolve the system-context note: explicit --context path, else the first + * qubit-hardware-context/*.md whose `platform:` matches (sorted for + * determinism). */ +function resolveContextNote(argv: string[], dir: string, platform: string): { path: string } | { error: string } { + const explicit = flagValue(argv, "--context"); + if (explicit) { + if (!existsSync(explicit)) return { error: `--context note not found: ${explicit}` }; + return { path: explicit }; + } + const ctxDir = join(dir, "qubit-hardware-context"); + if (!existsSync(ctxDir)) return { error: `no qubit-hardware-context/ under the vault (${dir}) — pass --context ` }; + let names: string[]; + try { + names = readdirSync(ctxDir).filter((n) => n.endsWith(".md")).sort(); + } catch { + return { error: `cannot read qubit-hardware-context/ under ${dir}` }; + } + const match = names.find((n) => notePlatform(join(ctxDir, n)) === platform); + if (!match) return { error: `no system-context note with platform "${platform}" — pass --context ` }; + return { path: join(ctxDir, match) }; +} + +export function noteBumpBest(argv: string[]): VerbResult { + const fail = (error: string): VerbResult => ({ json: { verb: "note", subcommand: "bump-best", error }, code: 64 }); + + const platform = flagValue(argv, "--platform"); + const gate = flagValue(argv, "--kind"); + if (!platform || !gate) return fail("--platform and --kind are required"); + + const fidRaw = flagValue(argv, "--fidelity"); + const fidelity = fidRaw !== undefined ? Number(fidRaw) : undefined; + if (fidelity === undefined || !Number.isFinite(fidelity)) return fail("--fidelity is required"); + + const durNsRaw = flagValue(argv, "--duration-ns"); + const durUsRaw = flagValue(argv, "--duration-us"); + let duration_ns: number | undefined; + if (durNsRaw !== undefined && Number.isFinite(Number(durNsRaw))) duration_ns = Number(durNsRaw); + else if (durUsRaw !== undefined && Number.isFinite(Number(durUsRaw))) duration_ns = Number(durUsRaw) * 1000; + + const entry: BestGate = { gate, fidelity, duration_ns, source: flagValue(argv, "--source") }; + + const dir = vaultDir(); + const resolved = resolveContextNote(argv, dir, platform); + if ("error" in resolved) return fail(resolved.error); + + const text = readFileSync(resolved.path, "utf8"); + const res = bumpBestGatesInText(text, entry); + if (!res.ok) return fail(`${res.reason} (${resolved.path})`); + + const common = { + verb: "note", + subcommand: "bump-best", + context: resolved.path, + platform, + gate, + fidelity, + bumped: res.bumped, + previous: res.previous ?? null, + reason: res.reason, + }; + + if (!res.bumped) return { json: { ...common, written: false }, code: 0 }; + if (argv.includes("--dry-run")) return { json: { ...common, written: false, dry_run: true }, code: 0 }; + + try { + writeFileSync(resolved.path, res.text!); + } catch (e) { + return fail(`failed to write context note: ${e instanceof Error ? e.message : String(e)}`); + } + return { json: { ...common, written: true }, code: 0 }; +} + +// ── dispatch ───────────────────────────────────────────────────────────────── +/** The `note` verb body: dispatch on the subcommand. Backs BOTH the CLI + * (amico.ts) and the MCP facade (mcp_serve.ts). */ +export function noteVerb(argv: string[]): VerbResult { + const sub = argv[0]; + const rest = argv.slice(1); + if (sub === "write") return noteWrite(rest); + if (sub === "bump-best") return noteBumpBest(rest); + return { + json: { + verb: "note", + error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, + usage: + "amico note write --platform

--kind --fidelity | amico note bump-best --platform

--kind --fidelity [--source ]", + }, + code: 64, + }; +} diff --git a/packages/amico-run/src/vault_query.ts b/packages/amico-run/src/vault_query.ts new file mode 100644 index 00000000..b101be0b --- /dev/null +++ b/packages/amico-run/src/vault_query.ts @@ -0,0 +1,227 @@ +// The knowledge-graph RETRIEVAL core — the pure logic behind the `amico vault` +// verb (issue #113, slice B3; spec-20260708-112732 §3.1, §7.3). The vault is the +// Amico Obsidian knowledge graph (insights/, experiments/, …) under the mounted +// vaults. The design intent (§7.3) is RETRIEVAL, not front-loading context: a +// query tool an agent calls on demand, ranking notes by relevance to a query +// rather than dumping the whole graph into the prompt. +// +// This mirrors repertoire.ts: a never-throwing loader (a missing/corrupt vault +// degrades to no notes; an unreadable file is skipped, not fatal) + a pure +// ranking function. Frontmatter parsing is intentionally MINIMAL — the handful +// of scalar fields the ranker/filters need (type/platform/gate/tags), extracted +// by regex, not a full YAML engine. +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** A vault note projected for retrieval. `body` is the markdown after the + * frontmatter; `title` is the first `# ` heading (else the filename). */ +export interface NoteRecord { + path: string; // ABS path + file: string; // basename + folder: string; // "insights" | "experiments" | … + type?: string; // frontmatter `type` + title: string; + platform?: string; + gate?: string; + tags: string[]; + body: string; +} + +/** The vault root. `$AMICO_VAULT_DIR` overrides it (tests point it at a temp + * dir); default is the company (team) vault mount. Returns the path + * unconditionally — loadNotes handles a missing mount by returning []. */ +export function vaultDir(): string { + const env = process.env.AMICO_VAULT_DIR; + if (env && env.trim() !== "") return env; + return join(homedir(), ".amico", "vaults", "armonissima"); +} + +/** The note folders the retrieval searches — the knowledge-graph nucleus + * (spec §3.1: insights/experiments). */ +export const NOTE_FOLDERS = ["insights", "experiments"] as const; + +// ── frontmatter (minimal, regex-based — NOT a general YAML parser) ──────────── + +interface Frontmatter { + type?: string; + platform?: string; + gate?: string; + tags: string[]; +} + +function splitFrontmatter(text: string): { fm: string; body: string } { + // A note begins with `---\n … \n---\n`. Anything else → no frontmatter. + if (!text.startsWith("---")) return { fm: "", body: text }; + const end = text.indexOf("\n---", 3); + if (end === -1) return { fm: "", body: text }; + const fm = text.slice(text.indexOf("\n") + 1, end); + const rest = text.slice(end + 4); // past "\n---" + const body = rest.startsWith("\n") ? rest.slice(1) : rest; + return { fm, body }; +} + +function scalar(fm: string, key: string): string | undefined { + const m = fm.match(new RegExp(`^${key}:\\s*(.+)$`, "m")); + if (!m) return undefined; + const raw = m[1].trim(); + if (raw === "" || raw === "null" || raw === "~") return undefined; + return raw.replace(/^["']|["']$/g, ""); // strip surrounding quotes +} + +function parseTags(fm: string): string[] { + // `tags: [a, b, gate/X]` — the inline-list form the vault uses. + const m = fm.match(/^tags:\s*\[(.*)\]/m); + if (!m) return []; + return m[1] + .split(",") + .map((t) => t.trim().replace(/^["']|["']$/g, "")) + .filter(Boolean); +} + +function parseFrontmatter(fm: string): Frontmatter { + return { + type: scalar(fm, "type"), + platform: scalar(fm, "platform"), + gate: scalar(fm, "gate"), + tags: parseTags(fm), + }; +} + +function titleOf(body: string, file: string): string { + const m = body.match(/^#\s+(.+)$/m); + return m ? m[1].trim() : file.replace(/\.md$/, ""); +} + +function parseNote(path: string, file: string, folder: string): NoteRecord | undefined { + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch { + return undefined; + } + const { fm, body } = splitFrontmatter(text); + const meta = parseFrontmatter(fm); + return { + path, + file, + folder, + type: meta.type, + title: titleOf(body, file), + platform: meta.platform, + gate: meta.gate, + tags: meta.tags, + body, + }; +} + +/** Scan the note folders under `dir` into records. Never throws. */ +export function loadNotes(dir: string, folders: readonly string[] = NOTE_FOLDERS): NoteRecord[] { + const records: NoteRecord[] = []; + for (const folder of folders) { + const folderDir = join(dir, folder); + if (!existsSync(folderDir)) continue; + let names: string[]; + try { + names = readdirSync(folderDir); + } catch { + continue; + } + for (const name of names) { + if (!name.endsWith(".md")) continue; + const rec = parseNote(join(folderDir, name), name, folder); + if (rec) records.push(rec); + } + } + return records; +} + +// ── relevance ranking ───────────────────────────────────────────────────────── + +export interface RankedNote { + path: string; + file: string; + folder: string; + type?: string; + title: string; + tags: string[]; + score: number; + snippet: string; +} + +export interface QueryOpts { + type?: string; // filter: only notes with this frontmatter `type` + platform?: string; // filter: only notes with this platform + gate?: string; // filter: only notes with this gate + limit?: number; // top-N (default 10) +} + +function tokenize(s: string): string[] { + return s + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((t) => t.length > 1); +} + +/** Weighted term-frequency score: a title hit is worth the most, a tag hit next, + * a body hit least. Deterministic; ties break by path. */ +function scoreNote(note: NoteRecord, terms: string[]): number { + if (terms.length === 0) return 0; + const title = note.title.toLowerCase(); + const tagBlob = note.tags.join(" ").toLowerCase(); + const body = note.body.toLowerCase(); + let score = 0; + for (const term of terms) { + if (title.includes(term)) score += 5; + if (tagBlob.includes(term)) score += 3; + const bodyHits = countOccurrences(body, term); + score += Math.min(bodyHits, 5); // cap body weight so a term-spamming note can't dominate + } + return score; +} + +function countOccurrences(haystack: string, needle: string): number { + let count = 0; + let i = haystack.indexOf(needle); + while (i !== -1) { + count++; + i = haystack.indexOf(needle, i + needle.length); + } + return count; +} + +/** A short context snippet: the first body line containing any query term, else + * the note's first non-empty prose line. */ +function snippetFor(note: NoteRecord, terms: string[]): string { + const lines = note.body.split("\n").map((l) => l.trim()); + const prose = lines.filter((l) => l && !l.startsWith("#") && !l.startsWith("---")); + const hit = prose.find((l) => terms.some((t) => l.toLowerCase().includes(t))); + const line = hit ?? prose[0] ?? ""; + return line.length > 200 ? line.slice(0, 197) + "…" : line; +} + +export function rankNotes(notes: NoteRecord[], query: string, opts: QueryOpts = {}): RankedNote[] { + const terms = tokenize(query); + const filtered = notes.filter( + (n) => + (opts.type === undefined || n.type === opts.type) && + (opts.platform === undefined || n.platform === opts.platform) && + (opts.gate === undefined || n.gate === opts.gate), + ); + const scored = filtered.map((n) => ({ note: n, score: scoreNote(n, terms) })); + scored.sort((a, b) => b.score - a.score || (a.note.path < b.note.path ? -1 : a.note.path > b.note.path ? 1 : 0)); + const limit = opts.limit !== undefined && opts.limit > 0 ? opts.limit : 10; + return scored + .filter((s) => s.score > 0) + .slice(0, limit) + .map(({ note, score }) => ({ + path: note.path, + file: note.file, + folder: note.folder, + type: note.type, + title: note.title, + tags: note.tags, + score, + snippet: snippetFor(note, terms), + })); +} diff --git a/packages/amico-run/src/vault_verb.ts b/packages/amico-run/src/vault_verb.ts new file mode 100644 index 00000000..1d63806e --- /dev/null +++ b/packages/amico-run/src/vault_verb.ts @@ -0,0 +1,70 @@ +// `amico vault` — knowledge-graph retrieval (issue #113, slice B3; +// spec-20260708-112732 §3.1, §7.3). One subcommand today, read-only: +// +// amico vault query --q "" [--type insight|experiment] +// [--platform

] [--kind ] [--limit ] +// → the notes (insights/experiments) most RELEVANT to the query, ranked +// (title > tags > body weighting), read from the mounted vault. This is +// the retrieval seam an agent hits on demand — retrieval, not +// front-loading the whole graph into context. +// +// Pure ranking logic lives in vault_query.ts; this is the flag surface + I/O. +// FLAG NAMES (S31 guard): the physics-knob double-dash flags (gate/pulse/system) +// are banned in src/; the gate discriminator is `--kind` (mapping onto the note +// `gate` field, exactly as `amico catalog` does), and the free-text query is +// `--q`. +import { loadNotes, rankNotes, vaultDir, type QueryOpts } from "./vault_query.js"; +import type { VerbResult } from "./verbs.js"; + +function flagValue(argv: string[], name: string): string | undefined { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; +} + +export function vaultQuery(argv: string[]): VerbResult { + const q = flagValue(argv, "--q"); + if (q === undefined || q.trim() === "") { + return { json: { verb: "vault", subcommand: "query", error: "--q is required" }, code: 64 }; + } + const opts: QueryOpts = { + type: flagValue(argv, "--type"), + platform: flagValue(argv, "--platform"), + gate: flagValue(argv, "--kind"), + }; + const limitRaw = flagValue(argv, "--limit"); + if (limitRaw !== undefined) { + const n = Number(limitRaw); + if (!Number.isFinite(n) || n <= 0) return { json: { verb: "vault", subcommand: "query", error: `--limit must be a positive number (got "${limitRaw}")` }, code: 64 }; + opts.limit = Math.floor(n); + } + const dir = vaultDir(); + const hits = rankNotes(loadNotes(dir), q, opts); + return { + json: { + verb: "vault", + subcommand: "query", + vault: dir, + query: q, + filters: { type: opts.type ?? null, platform: opts.platform ?? null, gate: opts.gate ?? null }, + count: hits.length, + hits, + }, + code: 0, + }; +} + +/** The `vault` verb body: dispatch on the subcommand. Backs BOTH the CLI + * (amico.ts) and the MCP facade (mcp_serve.ts). */ +export function vaultVerb(argv: string[]): VerbResult { + const sub = argv[0]; + const rest = argv.slice(1); + if (sub === "query") return vaultQuery(rest); + return { + json: { + verb: "vault", + error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, + usage: 'amico vault query --q "" [--type insight|experiment] [--platform

] [--kind ] [--limit ]', + }, + code: 64, + }; +} diff --git a/packages/amico-run/src/verbs.ts b/packages/amico-run/src/verbs.ts index edd26bad..3e950317 100644 --- a/packages/amico-run/src/verbs.ts +++ b/packages/amico-run/src/verbs.ts @@ -3,17 +3,20 @@ // filesystem/vault work: callable by agents via bash, by the deterministic harness // directly, and by cron/CI/Julia. // -// SLICE STATUS: `catalog` is REAL (issue #111, slice B2 — its body lives in -// catalog_verb.ts / repertoire.ts). `vault` / `device` / `note` are still STUBS: -// each is a routing seam that prints its intent (the module it will generalize + -// the slice that lands the body) and exits 0. Do not add real reads/writes for a -// stubbed verb in this file without its corresponding slice — put the body in a -// dedicated module and wire it here, as catalog does. +// SLICE STATUS: ALL FOUR spine verbs are now REAL. `catalog` landed in B2 (issue +// #111 — body in catalog_verb.ts / repertoire.ts); `vault` / `device` / `note` +// land in B3 (issue #113 — bodies in vault_verb.ts / device_verb.ts / note_verb.ts, +// pure cores in vault_query.ts / device_graph.ts / note.ts). The B1 `stub()` helper +// remains for any FUTURE seam, but no spine verb uses it today. Keep each verb's +// real body in its dedicated module and wire it here, never inline in this file. // // Each verb is a plain (args) => {json, code} function so the SAME function backs both the // CLI dispatch (amico.ts) and the MCP facade (mcp_serve.ts). One impl, two transports. import { catalogVerb } from "./catalog_verb.js"; +import { vaultVerb } from "./vault_verb.js"; +import { deviceVerb } from "./device_verb.js"; +import { noteVerb } from "./note_verb.js"; export interface VerbResult { json: unknown; // structured result (stdout as JSON for the CLI; tool content for MCP) @@ -61,27 +64,34 @@ const catalog: Verb = { }; // vault — retrieval over the knowledge graph (query tools, not front-loading context). -const vault = stub({ +// REAL as of B3: `query` ranks insights/experiments by relevance to a free-text query. +const vault: Verb = { name: "vault", - summary: "query the knowledge graph (insights/experiments/strategy) — retrieval, not front-load", - generalizes: "the amicode_* vault plugin tools", + summary: "query the knowledge graph (insights/experiments) by relevance — retrieval, not front-load", + generalizes: "the amicode_* vault plugin tools (retrieval half)", slice: "spine bookkeeping (B3)", -}); + run: vaultVerb, +}; -// device — the dispatcher successor (device status / next-actions; benchmark-exclusivity lock). -const device = stub({ +// device — the dispatcher successor. REAL as of B3: `status` (honesty rule: +// uncharacterized/stale), `next` (ranked actions via pure evaluate()), `lock` +// (benchmark-exclusivity: a locked device accepts no concurrent submission). +const device: Verb = { name: "device", - summary: "device status / next-actions (dispatcher successor; benchmark-exclusivity lock)", - generalizes: "the amicode_* device/dispatcher plugin tools", - slice: "spine bookkeeping (B5)", -}); + summary: "device status / next-actions / benchmark-exclusivity lock (dispatcher successor)", + generalizes: "the amicode_* device/dispatcher plugin tools + the dispatcher agent", + slice: "spine bookkeeping (B3)", + run: deviceVerb, +}; -// note — write an experiment note / bump best_gates (librarian bookkeeping half). -const note = stub({ +// note — librarian bookkeeping. REAL as of B3: `write` (experiment note) + +// `bump-best` (best_gates), both deterministic. +const note: Verb = { name: "note", - summary: "write experiment note / update best_gates (librarian bookkeeping → deterministic)", - generalizes: "the amicode_* librarian/note plugin tools", + summary: "write experiment note / bump best_gates (librarian bookkeeping → deterministic)", + generalizes: "the amicode_* librarian/note plugin tools (bookkeeping half)", slice: "spine bookkeeping (B3)", -}); + run: noteVerb, +}; export const SPINE_VERBS: Verb[] = [catalog, vault, device, note]; diff --git a/packages/amico-run/test/amico.test.ts b/packages/amico-run/test/amico.test.ts index e206587b..c4452ef6 100644 --- a/packages/amico-run/test/amico.test.ts +++ b/packages/amico-run/test/amico.test.ts @@ -133,17 +133,34 @@ describe("amico router — resolve/sandbox delegate verbatim to the subcommands" }); }); -describe("amico router — spine verbs vault/device/note are still B1 stubs (print intent, exit 0)", () => { +describe("amico router — spine verbs vault/device/note are REAL (B3), no longer stubs", () => { + // The ROUTER seam only: an unknown subcommand routes into the real body (→ usage + // error, exit 64, no `stub` marker). The per-verb bodies are covered end-to-end in + // vault_verb / device_verb / note_verb test files. for (const name of ["vault", "device", "note"]) { - it(`${name} routes, prints stub intent JSON, exits 0`, () => { - const r = run([name, "some", "args"]); - expect(r.code).toBe(0); + it(`${name} routes to its real body: unknown subcommand → usage error, exit 64, no stub`, () => { + const r = run([name, "frobnicate"]); + expect(r.code).toBe(64); const out = JSON.parse(r.stdout); - expect(out).toMatchObject({ verb: name, stub: true }); - expect(out.args).toEqual(["some", "args"]); - expect(typeof out.intent).toBe("string"); + expect(out.verb).toBe(name); + expect(out.stub).toBeUndefined(); + expect(out.error).toMatch(/unknown subcommand/); }); } + it("device status routes to the real body (no graph → uncharacterized + 64)", () => { + const root = mkdtempSync(join(tmpdir(), "amico-router-dev-")); + const r = run(["device", "status", "--device", "ghost"], { AMICO_DEVICE_DIR: root }); + expect(r.code).toBe(64); + expect(JSON.parse(r.stdout)).toMatchObject({ verb: "device", subcommand: "status", overall: "uncharacterized" }); + rmSync(root, { recursive: true, force: true }); + }); + it("vault query routes to the real body (empty vault → count 0, exit 0)", () => { + const dir = mkdtempSync(join(tmpdir(), "amico-router-vault-")); + const r = run(["vault", "query", "--q", "anything"], { AMICO_VAULT_DIR: dir }); + expect(r.code).toBe(0); + expect(JSON.parse(r.stdout)).toMatchObject({ verb: "vault", subcommand: "query", count: 0 }); + rmSync(dir, { recursive: true, force: true }); + }); }); describe("amico router — catalog is REAL (B2), no longer a stub", () => { diff --git a/packages/amico-run/test/device_verb.test.ts b/packages/amico-run/test/device_verb.test.ts new file mode 100644 index 00000000..42a9ac07 --- /dev/null +++ b/packages/amico-run/test/device_verb.test.ts @@ -0,0 +1,261 @@ +// `amico device` (issue #113, slice B3) — the dispatcher successor. Pure graph +// logic (device_graph.ts: loadGraph / evaluate / nextActions / the lock decision) +// is unit-tested against src; the status/next/lock bodies are exercised through +// `dist/amico.js` with $AMICO_DEVICE_DIR pointed at a seeded temp device dir. The +// `--now` flag pins the evaluation clock so age/staleness are deterministic. +// Run: `pnpm --filter @amicode/amico-run test`. +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + loadGraph, + evaluate, + nextActions, + acquireDecision, + releaseDecision, + acceptsSubmission, + parseStateJson, + type CalibrationGraph, + type NodeState, +} from "../src/device_graph.js"; + +const GRAPH_TOML = ` +[node.resonator_spec] +depends_on = [] +qubit = "Q1" +produces = ["resonator_freq"] +ttl_seconds = 86400 +impl = "standard" +[node.qubit_spec] +depends_on = ["resonator_spec"] +qubit = "Q1" +produces = ["qubit_freq"] +ttl_seconds = 43200 +impl = "standard" +[node.cz_gate] +depends_on = ["qubit_spec"] +qubit = "Q1" +produces = ["cz_fidelity"] +impl = "qilc" +fallback = "cz_gate_standard" +[node.cz_gate_standard] +depends_on = ["qubit_spec"] +qubit = "Q1" +produces = ["cz_fidelity"] +impl = "standard" +`; + +function graph(): CalibrationGraph { + const res = loadGraph(GRAPH_TOML); + if (!res.ok) throw new Error(res.error); + return res.graph; +} + +const NOW = Date.parse("2026-07-09T02:00:00Z"); +const FRESH = { ts: "2026-07-09T00:00:00Z", value: { resonator_freq: 6.1 }, status: "calibrated", job_id: "j1" } as NodeState; + +// ── pure logic (device_graph.ts) ──────────────────────────────────────────────── +describe("loadGraph", () => { + it("builds nodes, topo order, depth, and rejects cycles", () => { + const g = graph(); + expect(g.topoOrder[0]).toBe("resonator_spec"); + expect(g.depth("resonator_spec")).toBe(0); + expect(g.depth("qubit_spec")).toBe(1); + expect(g.depth("cz_gate")).toBe(2); + const cyclic = loadGraph(`[node.a]\ndepends_on = ["b"]\nimpl="standard"\nproduces=[]\n[node.b]\ndepends_on = ["a"]\nimpl="standard"\nproduces=[]`); + expect(cyclic.ok).toBe(false); + }); +}); + +describe("evaluate — honesty rule (uncharacterized / stale / suspect)", () => { + it("no state → all uncharacterized; roots rank first", () => { + const verdicts = evaluate(graph(), {}, NOW); + expect(verdicts.every((v) => v.status === "uncharacterized")).toBe(true); + expect(verdicts[0].node).toBe("resonator_spec"); // depth 0 ranks first + expect(verdicts[0].ageSeconds).toBe(Infinity); + }); + it("fresh root, unmeasured children → children uncharacterized (not fabricated)", () => { + const verdicts = evaluate(graph(), { resonator_spec: FRESH }, NOW); + const byNode = Object.fromEntries(verdicts.map((v) => [v.node, v])); + expect(byNode.resonator_spec.status).toBe("calibrated"); + expect(byNode.qubit_spec.status).toBe("uncharacterized"); + }); + it("a result older than ttl → stale", () => { + const old = { ts: "2026-07-01T00:00:00Z", value: { resonator_freq: 6.1 }, status: "calibrated" } as NodeState; + const v = evaluate(graph(), { resonator_spec: old }, NOW).find((x) => x.node === "resonator_spec")!; + expect(v.status).toBe("stale"); + }); + it("a fresh child of a stale parent → suspect", () => { + const state = { + resonator_spec: { ts: "2026-07-01T00:00:00Z", value: {}, status: "calibrated" } as NodeState, // stale + qubit_spec: { ts: "2026-07-09T00:00:00Z", value: {}, status: "calibrated" } as NodeState, // fresh + }; + const v = evaluate(graph(), state, NOW).find((x) => x.node === "qubit_spec")!; + expect(v.status).toBe("suspect"); + }); +}); + +describe("nextActions — premium (Intonatissimo) funnel", () => { + it("unentitled qilc node is locked, carries the funnel + standard fallback, never the acronym", () => { + const res = nextActions(graph(), { resonator_spec: FRESH }, NOW, { entitled: false, idle: true }); + const cz = res.ranked_actions.find((a) => a.node === "cz_gate")!; + expect(cz.locked).toBe(true); + expect(cz.recommendedNode).toBe("cz_gate_standard"); + expect(cz.premium?.package).toBe("Intonatissimo"); + expect(cz.premium?.capability).toBe("closed-loop calibration"); + // the funnel names product + capability but NEVER the method acronym in the + // USER-FACING copy (the `impl: "qilc"` enum is internal graph plumbing). + expect(cz.premium?.invite).not.toMatch(/QILC/i); + expect(cz.reason).not.toMatch(/QILC/i); + }); + it("entitled → qilc node runs itself, not the fallback", () => { + const res = nextActions(graph(), { resonator_spec: FRESH }, NOW, { entitled: true, idle: true }); + const cz = res.ranked_actions.find((a) => a.node === "cz_gate")!; + expect(cz.locked).toBe(false); + expect(cz.recommendedNode).toBe("cz_gate"); + }); +}); + +describe("benchmark-exclusivity lock decisions", () => { + it("free → granted; same owner re-acquire → reentrant; other owner → refused", () => { + const first = acquireDecision(undefined, "benchmark", "jj", "2026-07-09T02:00:00Z"); + expect(first.ok).toBe(true); + if (!first.ok) throw new Error(); + expect(acquireDecision(first.lock, "benchmark", "jj", "2026-07-09T09:00:00Z")).toMatchObject({ ok: true, reentrant: true }); + expect(acquireDecision(first.lock, "benchmark", "raghav", "2026-07-09T09:00:00Z").ok).toBe(false); + }); + it("a benchmark lock blocks concurrent submission; release frees it", () => { + const lock = { mode: "benchmark", owner: "jj", acquired_at: "2026-07-09T02:00:00Z" }; + expect(acceptsSubmission(lock)).toBe(false); + expect(acceptsSubmission(undefined)).toBe(true); + expect(releaseDecision(lock, "jj", false)).toMatchObject({ ok: true, released: true }); + expect(releaseDecision(lock, "someone-else", false).ok).toBe(false); + expect(releaseDecision(lock, undefined, true)).toMatchObject({ ok: true, released: true }); + }); +}); + +describe("parseStateJson never throws", () => { + it("junk / non-object / good all degrade sanely", () => { + expect(parseStateJson("not json")).toEqual({}); + expect(parseStateJson("[1,2,3]")).toEqual({}); + expect(parseStateJson(JSON.stringify({ a: { ts: "2026-01-01T00:00:00Z" } })).a?.ts).toBe("2026-01-01T00:00:00Z"); + }); +}); + +// ── verb bodies through the bundle ────────────────────────────────────────────── +const BUNDLE = join(__dirname, "..", "dist", "amico.js"); +beforeAll(() => { + execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); +}); +function run(args: string[], env: Record = {}): { code: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); + return { code: 0, stdout, stderr: "" }; + } catch (e) { + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; + } +} + +let root: string; // $AMICO_DEVICE_DIR +function seedDevice(device: string, opts: { state?: string } = {}): void { + const dir = join(root, device); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "graph.toml"), GRAPH_TOML); + if (opts.state) writeFileSync(join(dir, "state.json"), opts.state); +} +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "amico-device-")); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +describe("amico device status (bundle)", () => { + it("projects qubit rollup + measured metrics + node verdicts; honest overall", () => { + seedDevice("snowbird", { state: JSON.stringify({ resonator_spec: FRESH }) }); + const r = run(["device", "status", "--device", "snowbird", "--now", "2026-07-09T02:00:00Z"], { AMICO_DEVICE_DIR: root }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.overall).toBe("uncharacterized"); // unmeasured children keep the device honest + expect(out.qubits).toEqual([{ qubit: "Q1", status: "uncharacterized", nodeCount: 4 }]); + expect(out.metrics.resonator_freq.value).toBe(6.1); + expect(out.accepts_submission).toBe(true); + }); + it("no graph on disk → uncharacterized + error, exit 64 (never fabricated)", () => { + const r = run(["device", "status", "--device", "ghost"], { AMICO_DEVICE_DIR: root }); + expect(r.code).toBe(64); + const out = JSON.parse(r.stdout); + expect(out.overall).toBe("uncharacterized"); + expect(out.error).toMatch(/no_graph/); + }); + it("missing --device → 64", () => { + expect(run(["device", "status"], { AMICO_DEVICE_DIR: root }).code).toBe(64); + }); +}); + +describe("amico device next (bundle)", () => { + it("ranks actions; qilc node surfaces the premium funnel", () => { + seedDevice("snowbird", { state: JSON.stringify({ resonator_spec: FRESH }) }); + const r = run(["device", "next", "--device", "snowbird", "--now", "2026-07-09T02:00:00Z"], { AMICO_DEVICE_DIR: root }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.idle).toBe(true); + const cz = out.ranked_actions.find((a: { node: string }) => a.node === "cz_gate"); + expect(cz.locked).toBe(true); + expect(cz.premium.package).toBe("Intonatissimo"); + }); +}); + +describe("amico device lock (bundle) — benchmark exclusivity (W-2)", () => { + it("acquire → suspends fan-out + refuses concurrent submission; state persists", () => { + seedDevice("snowbird"); + const r = run(["device", "lock", "--device", "snowbird", "--acquire", "--owner", "jj", "--now", "2026-07-09T02:00:00Z"], { AMICO_DEVICE_DIR: root }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out).toMatchObject({ acquired: true, suspends_fanout: true, accepts_submission: false }); + expect(existsSync(join(root, "snowbird", "lock.json"))).toBe(true); + }); + it("a locked device reports idle=false + accepts_submission=false on next", () => { + seedDevice("snowbird", { state: JSON.stringify({ resonator_spec: FRESH }) }); + run(["device", "lock", "--device", "snowbird", "--acquire", "--owner", "jj"], { AMICO_DEVICE_DIR: root }); + const r = run(["device", "next", "--device", "snowbird", "--now", "2026-07-09T02:00:00Z"], { AMICO_DEVICE_DIR: root }); + const out = JSON.parse(r.stdout); + expect(out.idle).toBe(false); + expect(out.accepts_submission).toBe(false); + expect(out.lock.held).toBe(true); + }); + it("a concurrent acquire by another owner is refused, exit 64", () => { + seedDevice("snowbird"); + run(["device", "lock", "--device", "snowbird", "--acquire", "--owner", "jj"], { AMICO_DEVICE_DIR: root }); + const r = run(["device", "lock", "--device", "snowbird", "--acquire", "--owner", "raghav"], { AMICO_DEVICE_DIR: root }); + expect(r.code).toBe(64); + expect(JSON.parse(r.stdout).acquired).toBe(false); + }); + it("acquire is idempotent for the same owner (re-entrant)", () => { + seedDevice("snowbird"); + run(["device", "lock", "--device", "snowbird", "--acquire", "--owner", "jj", "--now", "2026-07-09T02:00:00Z"], { AMICO_DEVICE_DIR: root }); + const r = run(["device", "lock", "--device", "snowbird", "--acquire", "--owner", "jj", "--now", "2026-07-09T09:00:00Z"], { AMICO_DEVICE_DIR: root }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.reentrant).toBe(true); + expect(out.lock.acquired_at).toBe("2026-07-09T02:00:00.000Z"); // keeps the original time + }); + it("release frees the device; status reports held:false", () => { + seedDevice("snowbird"); + run(["device", "lock", "--device", "snowbird", "--acquire", "--owner", "jj"], { AMICO_DEVICE_DIR: root }); + const rel = run(["device", "lock", "--device", "snowbird", "--release", "--owner", "jj"], { AMICO_DEVICE_DIR: root }); + expect(rel.code).toBe(0); + expect(JSON.parse(rel.stdout).released).toBe(true); + expect(existsSync(join(root, "snowbird", "lock.json"))).toBe(false); + const st = run(["device", "lock", "--device", "snowbird"], { AMICO_DEVICE_DIR: root }); + expect(JSON.parse(st.stdout).lock.held).toBe(false); + }); + it("acquire without --owner → 64", () => { + seedDevice("snowbird"); + expect(run(["device", "lock", "--device", "snowbird", "--acquire"], { AMICO_DEVICE_DIR: root }).code).toBe(64); + }); + it("unknown subcommand → 64", () => { + expect(run(["device", "frobnicate"], { AMICO_DEVICE_DIR: root }).code).toBe(64); + }); +}); diff --git a/packages/amico-run/test/note_verb.test.ts b/packages/amico-run/test/note_verb.test.ts new file mode 100644 index 00000000..af13fb1e --- /dev/null +++ b/packages/amico-run/test/note_verb.test.ts @@ -0,0 +1,189 @@ +// `amico note` (issue #113, slice B3) — the librarian's deterministic bookkeeping +// half. Pure logic (note.ts: renderExperimentNote / mergeBestGates / +// bumpBestGatesInText) is unit-tested against src; the write / bump-best bodies +// run through `dist/amico.js` with $AMICO_VAULT_DIR pointed at a seeded temp vault. +// Run: `pnpm --filter @amicode/amico-run test`. +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + renderExperimentNote, + experimentId, + mergeBestGates, + bumpBestGatesInText, + parseBestGate, + type BestGate, +} from "../src/note.js"; + +// ── pure logic (note.ts) ──────────────────────────────────────────────────────── +describe("renderExperimentNote + experimentId", () => { + it("renders full frontmatter with the passed fields (nothing invented)", () => { + const note = renderExperimentNote({ platform: "fluxonium", gate: "X", fidelity: 0.99986, date: "2026-07-09", duration_us: 0.01, status: "improved" }); + expect(note).toMatch(/^---\ntype: experiment/); + expect(note).toMatch(/platform: fluxonium/); + expect(note).toMatch(/gate: X/); + expect(note).toMatch(/fidelity: 0.99986/); + expect(note).toMatch(/status: improved/); + expect(note).toMatch(/tags: \[experiment, fluxonium, gate\/X, status\/improved, task\/experiment\]/); + }); + it("deterministic id from date+platform+gate (+session prefix)", () => { + expect(experimentId({ platform: "transmon", gate: "H", fidelity: 0.9, date: "2026-07-09" })).toBe("experiment-20260709-transmon-H"); + expect(experimentId({ platform: "transmon", gate: "H", fidelity: 0.9, date: "2026-07-09", session_id: "abcd1234-xyz" })).toBe("experiment-20260709-transmon-H-abcd1234"); + }); +}); + +describe("mergeBestGates", () => { + const g = (gate: string, f: number): BestGate => ({ gate, fidelity: f }); + it("adds an absent gate (sorted); bumps only on strictly higher fidelity; no-op otherwise", () => { + expect(mergeBestGates([g("Y", 0.99)], g("X", 0.9)).gates.map((x) => x.gate)).toEqual(["X", "Y"]); + const bump = mergeBestGates([g("X", 0.99)], g("X", 0.999)); + expect(bump.bumped).toBe(true); + expect(bump.previous?.fidelity).toBe(0.99); + const noop = mergeBestGates([g("X", 0.999)], g("X", 0.9)); + expect(noop.bumped).toBe(false); + }); +}); + +describe("parseBestGate", () => { + it("parses an inline table with a quoted wikilink source", () => { + const g = parseBestGate('{gate: X, fidelity: 0.9995, duration_ns: 10, source: "[[fluxonium-X-v1]]"}'); + expect(g).toMatchObject({ gate: "X", fidelity: 0.9995, duration_ns: 10, source: "[[fluxonium-X-v1]]" }); + }); + it("rejects an entry missing gate or fidelity", () => { + expect(parseBestGate("{fidelity: 0.9}")).toBeUndefined(); + expect(parseBestGate("{gate: X}")).toBeUndefined(); + }); +}); + +describe("bumpBestGatesInText — surgical frontmatter edit", () => { + const note = [ + "---", + "type: system-context", + "platform: fluxonium", + "best_gates:", + ' - {gate: X, fidelity: 0.9995, duration_ns: 10, source: "[[fluxonium-X-v1]]"}', + ' - {gate: Y, fidelity: 0.999, duration_ns: 30, source: "[[fluxonium-Y-v1]]"}', + "open_questions:", + ' - "min gate time?"', + "tags: [system-context, fluxonium]", + "---", + "", + "# Fluxonium", + "Body.", + "", + ].join("\n"); + + it("bumps a gate to higher fidelity, leaving the rest of the note intact", () => { + const res = bumpBestGatesInText(note, { gate: "X", fidelity: 0.99999, duration_ns: 10, source: "[[exp-new]]" }); + expect(res.ok).toBe(true); + expect(res.bumped).toBe(true); + expect(res.text).toMatch(/gate: X, fidelity: 0.99999/); + expect(res.text).toMatch(/open_questions:/); // untouched + expect(res.text).toMatch(/# Fluxonium\nBody\./); // body untouched + expect(res.text).toMatch(/gate: Y, fidelity: 0.999/); // sibling untouched + }); + it("no-op when the candidate does not beat the incumbent", () => { + const res = bumpBestGatesInText(note, { gate: "X", fidelity: 0.9 }); + expect(res.bumped).toBe(false); + expect(res.text).toBe(note); + }); + it("adds a new gate into an empty `best_gates: []`", () => { + const empty = ["---", "type: system-context", "platform: rydberg", "best_gates: []", "tags: [x]", "---", "# R", "b", ""].join("\n"); + const res = bumpBestGatesInText(empty, { gate: "CZ", fidelity: 0.999, source: "[[exp-cz]]" }); + expect(res.bumped).toBe(true); + expect(res.text).toMatch(/best_gates:\n {2}- \{gate: CZ, fidelity: 0.999/); + expect(res.text).not.toMatch(/best_gates: \[\]/); + }); + it("errors (never throws) when there is no best_gates key", () => { + const res = bumpBestGatesInText("---\ntype: system-context\n---\n# x\n", { gate: "X", fidelity: 0.9 }); + expect(res.ok).toBe(false); + expect(res.reason).toMatch(/best_gates/); + }); +}); + +// ── verb bodies through the bundle ────────────────────────────────────────────── +const BUNDLE = join(__dirname, "..", "dist", "amico.js"); +beforeAll(() => { + execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); +}); +function run(args: string[], env: Record = {}): { code: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); + return { code: 0, stdout, stderr: "" }; + } catch (e) { + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; + } +} + +let vault: string; // $AMICO_VAULT_DIR +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "amico-note-")); +}); +afterEach(() => rmSync(vault, { recursive: true, force: true })); + +describe("amico note write (bundle)", () => { + it("writes an experiment note into experiments/ with full frontmatter", () => { + const r = run(["note", "write", "--platform", "fluxonium", "--kind", "X", "--fidelity", "0.99986", "--duration-us", "0.01", "--status", "improved", "--date", "2026-07-09"], { AMICO_VAULT_DIR: vault }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out).toMatchObject({ written: true, id: "experiment-20260709-fluxonium-X" }); + const text = readFileSync(join(vault, "experiments", "experiment-20260709-fluxonium-X.md"), "utf8"); + expect(text).toMatch(/fidelity: 0.99986/); + expect(text).toMatch(/status: improved/); + }); + it("--from-run reads result.toml for fidelity/duration", () => { + const runDir = mkdtempSync(join(tmpdir(), "amico-note-run-")); + writeFileSync(join(runDir, "result.toml"), "fidelity = 0.999995\nduration_us = 0.04\n"); + const r = run(["note", "write", "--platform", "transmon", "--kind", "H", "--from-run", runDir, "--date", "2026-07-09"], { AMICO_VAULT_DIR: vault }); + expect(r.code).toBe(0); + const text = readFileSync(join(vault, "experiments", "experiment-20260709-transmon-H.md"), "utf8"); + expect(text).toMatch(/fidelity: 0.999995/); + expect(text).toMatch(/duration_us: 0.04/); + rmSync(runDir, { recursive: true, force: true }); + }); + it("--dry-run computes without writing", () => { + const r = run(["note", "write", "--platform", "fluxonium", "--kind", "X", "--fidelity", "0.9", "--date", "2026-07-09", "--dry-run"], { AMICO_VAULT_DIR: vault }); + expect(r.code).toBe(0); + expect(JSON.parse(r.stdout).dry_run).toBe(true); + expect(existsSync(join(vault, "experiments"))).toBe(false); + }); + it("missing fidelity + no run → 64", () => { + expect(run(["note", "write", "--platform", "fluxonium", "--kind", "X"], { AMICO_VAULT_DIR: vault }).code).toBe(64); + }); +}); + +describe("amico note bump-best (bundle)", () => { + function seedContext(): void { + const dir = join(vault, "qubit-hardware-context"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "fluxonium-half-flux.md"), + ["---", "type: system-context", "platform: fluxonium", "best_gates:", ' - {gate: X, fidelity: 0.9995, duration_ns: 10, source: "[[fluxonium-X-v1]]"}', "open_questions:", ' - "min gate time?"', "tags: [system-context, fluxonium]", "---", "# Fluxonium", "Body.", ""].join("\n"), + ); + } + it("auto-finds the system-context note by platform and bumps best_gates", () => { + seedContext(); + const r = run(["note", "bump-best", "--platform", "fluxonium", "--kind", "X", "--fidelity", "0.99999", "--duration-ns", "10", "--source", "[[exp-new-X]]"], { AMICO_VAULT_DIR: vault }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out).toMatchObject({ bumped: true, written: true }); + const text = readFileSync(join(vault, "qubit-hardware-context", "fluxonium-half-flux.md"), "utf8"); + expect(text).toMatch(/gate: X, fidelity: 0.99999/); + expect(text).toMatch(/open_questions:/); // preserved + }); + it("no-op when the candidate does not beat the incumbent (exit 0, written:false)", () => { + seedContext(); + const r = run(["note", "bump-best", "--platform", "fluxonium", "--kind", "X", "--fidelity", "0.9"], { AMICO_VAULT_DIR: vault }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.bumped).toBe(false); + expect(out.written).toBe(false); + }); + it("no matching context note → 64", () => { + const r = run(["note", "bump-best", "--platform", "nonesuch", "--kind", "X", "--fidelity", "0.99"], { AMICO_VAULT_DIR: vault }); + expect(r.code).toBe(64); + }); +}); diff --git a/packages/amico-run/test/vault_verb.test.ts b/packages/amico-run/test/vault_verb.test.ts new file mode 100644 index 00000000..677259b3 --- /dev/null +++ b/packages/amico-run/test/vault_verb.test.ts @@ -0,0 +1,123 @@ +// `amico vault` (issue #113, slice B3) — knowledge-graph retrieval. Pure ranking +// (vault_query.ts) is unit-tested against src; the query body is exercised +// end-to-end through `dist/amico.js` with $AMICO_VAULT_DIR pointed at a seeded +// temp vault (mirrors catalog_verb.test.ts). Run: `pnpm --filter @amicode/amico-run test`. +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadNotes, rankNotes } from "../src/vault_query.js"; + +// ── seed helpers ────────────────────────────────────────────────────────────── +function seedNote(vault: string, folder: string, file: string, frontmatter: string, body: string): void { + const dir = join(vault, folder); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, file), `---\n${frontmatter}\n---\n\n${body}\n`); +} + +let vault: string; +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "amico-vault-")); +}); +afterEach(() => rmSync(vault, { recursive: true, force: true })); + +// ── pure logic (vault_query.ts) ───────────────────────────────────────────────── +describe("loadNotes", () => { + it("scans insights/ + experiments/; parses type/platform/gate/tags/title; skips non-md", () => { + seedNote(vault, "insights", "insight-1.md", "type: insight\nplatform: fluxonium\ngate: X\ntags: [insight, fluxonium, gate/X]", "# Warm-start wins\nWarm-starting helps."); + seedNote(vault, "experiments", "exp-1.md", "type: experiment\nplatform: transmon\ngate: H", "# A transmon run\nbody"); + writeFileSync(join(vault, "insights", "not-md.txt"), "ignored"); + const notes = loadNotes(vault); + expect(notes).toHaveLength(2); + const insight = notes.find((n) => n.file === "insight-1.md")!; + expect(insight.type).toBe("insight"); + expect(insight.platform).toBe("fluxonium"); + expect(insight.gate).toBe("X"); + expect(insight.tags).toEqual(["insight", "fluxonium", "gate/X"]); + expect(insight.title).toBe("Warm-start wins"); + }); + it("never throws on a missing vault → empty", () => { + expect(loadNotes(join(vault, "does-not-exist"))).toEqual([]); + }); + it("falls back to the filename as title when no H1", () => { + seedNote(vault, "insights", "no-heading.md", "type: insight", "just prose, no heading"); + expect(loadNotes(vault)[0].title).toBe("no-heading"); + }); +}); + +describe("rankNotes relevance", () => { + it("ranks title hits above tag hits above body hits", () => { + seedNote(vault, "insights", "a.md", "type: insight\ntags: [insight]", "# fluxonium optimization\nbody about gates"); // title hit = 5 + seedNote(vault, "insights", "b.md", "type: insight\ntags: [insight, fluxonium]", "# unrelated\nbody"); // tag hit = 3 + seedNote(vault, "insights", "c.md", "type: insight\ntags: [insight]", "# unrelated\nfluxonium fluxonium in body"); // 2 body hits = 2 + const hits = rankNotes(loadNotes(vault), "fluxonium"); + expect(hits.map((h) => h.file)).toEqual(["a.md", "b.md", "c.md"]); + expect(hits[0].score).toBeGreaterThan(hits[1].score); // title-weighted > tag-weighted + expect(hits[1].score).toBeGreaterThan(hits[2].score); // tag-weighted > body-weighted + }); + it("respects the --type / --platform / --gate filters", () => { + seedNote(vault, "insights", "i.md", "type: insight\nplatform: fluxonium\ngate: X", "# fluxonium\nx"); + seedNote(vault, "experiments", "e.md", "type: experiment\nplatform: fluxonium\ngate: X", "# fluxonium\nx"); + expect(rankNotes(loadNotes(vault), "fluxonium", { type: "insight" }).map((h) => h.file)).toEqual(["i.md"]); + expect(rankNotes(loadNotes(vault), "fluxonium", { gate: "Y" })).toEqual([]); + }); + it("honors limit and drops zero-score notes", () => { + seedNote(vault, "insights", "hit.md", "type: insight", "# rydberg blockade\ntext"); + seedNote(vault, "insights", "miss.md", "type: insight", "# nothing\ntext"); + const hits = rankNotes(loadNotes(vault), "rydberg", { limit: 5 }); + expect(hits).toHaveLength(1); + expect(hits[0].file).toBe("hit.md"); + }); +}); + +// ── verb body through the bundle ────────────────────────────────────────────── +const BUNDLE = join(__dirname, "..", "dist", "amico.js"); +beforeAll(() => { + execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); +}); +function run(args: string[], env: Record = {}): { code: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); + return { code: 0, stdout, stderr: "" }; + } catch (e) { + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; + } +} + +describe("amico vault query (bundle)", () => { + it("ranks notes by relevance, returns count + hits with snippet", () => { + seedNote(vault, "insights", "warm.md", "type: insight\ntags: [insight, method/warm-start]", "# warm-start beats cold-start\nwarm-starting from the incumbent helps a lot"); + seedNote(vault, "experiments", "cold.md", "type: experiment\ntags: [experiment]", "# a cold run\nunrelated body"); + const r = run(["vault", "query", "--q", "warm-start incumbent"], { AMICO_VAULT_DIR: vault }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out).toMatchObject({ verb: "vault", subcommand: "query", query: "warm-start incumbent" }); + expect(out.count).toBeGreaterThanOrEqual(1); + expect(out.hits[0].file).toBe("warm.md"); + expect(out.hits[0].snippet).toMatch(/warm-start/i); + }); + it("--type filter narrows to that note type", () => { + seedNote(vault, "insights", "i.md", "type: insight", "# rydberg\nrydberg text"); + seedNote(vault, "experiments", "e.md", "type: experiment", "# rydberg\nrydberg text"); + const r = run(["vault", "query", "--q", "rydberg", "--type", "experiment"], { AMICO_VAULT_DIR: vault }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.count).toBe(1); + expect(out.hits[0].type).toBe("experiment"); + }); + it("empty vault → count 0, exit 0", () => { + const r = run(["vault", "query", "--q", "anything"], { AMICO_VAULT_DIR: vault }); + expect(r.code).toBe(0); + expect(JSON.parse(r.stdout).count).toBe(0); + }); + it("missing --q → 64", () => { + expect(run(["vault", "query"], { AMICO_VAULT_DIR: vault }).code).toBe(64); + }); + it("unknown subcommand → 64 with usage", () => { + const r = run(["vault", "frobnicate"], { AMICO_VAULT_DIR: vault }); + expect(r.code).toBe(64); + expect(JSON.parse(r.stdout).error).toMatch(/unknown subcommand/); + }); +}); From 13758297ab7b021633c340b5e054f7af7e2c5570 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 17:06:18 -0400 Subject: [PATCH 11/49] Stage library skills by surface:product tag, not a hardcoded list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hardcoded platform-skill name list with `surface: product` frontmatter discovery (spec-20260708-112732 §4.5/§7.1). `resolveLibrarySkills` now scans each library root and stages ONLY skills tagged `surface: product`; `internal` and untagged skills — the ~44 process skills in the same library — are dropped. The tag is now the least-privilege leak guard the hardcoded list used to be. Staging still copies only the selected set to the per-session stage dir; `skills.paths` never points at the library root. - readFrontmatter parses optional `surface` string - resolveLibrarySkills(roots, isEntitled?) — surface-tag discovery, first-root- wins, malformed/missing skipped, entitlement seam wired - GATED_PRODUCT_SKILLS + isProductSkillEntitled: §7.1 entitlement hook, empty today (all 10 product skills public) so a no-op, gated by a one-row edit later - call site wires isProductSkillEntitled; drops platformSkills option + the amicode.platformSkills setting; DEFAULT_PLATFORM_SKILLS kept as the golden reference set (now the 10 product skills) - tests: 10 product skills discovered from the real amico-plugin root; internal (pr/debugging/dream) explicitly excluded; untagged excluded; entitlement seam Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/package.json | 10 +- packages/extension/src/extension.ts | 1 - packages/extension/src/opencode_config.ts | 41 +++++-- .../extension/src/scores/package_skills.ts | 91 +++++++++++---- .../test/scores/package_skills.test.ts | 106 +++++++++++++++--- .../test/scores/prep_integration.test.ts | 23 ++-- 6 files changed, 210 insertions(+), 62 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index 9a0349a3..c6a298cd 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -151,21 +151,13 @@ "default": [], "description": "Roots to search for co-located package skills (.jl/skills//SKILL.md). Empty = ~/harmoniqs/packages. First root containing a package's skills wins." }, - "amicode.platformSkills": { - "type": "array", - "items": { - "type": "string" - }, - "default": [], - "description": "Platform-skill names indexed from the central library (public). Empty = atoms, transmon, fluxonium, ions, bosonic. Only listed names are indexed — the library also holds process skills that must not leak." - }, "amicode.skillLibraryRoots": { "type": "array", "items": { "type": "string" }, "default": [], - "description": "Roots for the central platform-skill library. Empty = ~/harmoniqs/amico-plugin/skills." + "description": "Roots for the central skill library, scanned for skills tagged `surface: product`. Empty = ~/harmoniqs/amico-plugin/skills. Only product-tagged skills stage into Amicode; internal/untagged process skills never leak." }, "amicode.vaultDir": { "type": "string", diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 41fbf2be..de166d7d 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -151,7 +151,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { templateSrc: path.resolve(ctx.extensionPath, "templates", "solve_template.jl"), juliaProject: resolveJuliaProject(vscode.workspace.getConfiguration("amicode").get("juliaProject", "")), skillRoots: cfgArr("skillRoots"), - platformSkills: cfgArr("platformSkills"), skillLibraryRoots: cfgArr("skillLibraryRoots"), // User-memory substrate (spec-20260705-002847): "" in the setting keeps the // auto-resolve (kind=personal marker scan); a path pins the vault explicitly. diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index a1806a68..64ea9959 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -9,6 +9,7 @@ import { compileScore, spliceIntoAgentsMd, compileChainedScore, chainManifest } import { resolveLibrarySkills, resolvePackageSkills, + isProductSkillEntitled, buildSkillIndexSection, stageOpencodeSkills, type SkillIndexEntry, @@ -132,11 +133,28 @@ const DEFAULT_PLUGIN_PATH = path.resolve(__dirname, "..", "opencode-plugin", "am export const DEFAULT_SCORES_ROOT = path.resolve(__dirname, "..", "scores"); /** Skill-index roots (spec-20260704-113005 §3). Package skills are co-located - * in the workspace package repos; platform skills are the configured names in - * the central amico-plugin library. Overridable via settings (Task 6). */ + * in the workspace package repos; library (product) skills are discovered by + * `surface: product` tag from the central amico-plugin library. Overridable + * via settings (Task 6). */ export const DEFAULT_SKILL_ROOTS = [path.join(os.homedir(), "harmoniqs", "packages")]; -export const DEFAULT_PLATFORM_SKILLS = ["atoms", "transmon", "fluxonium", "ions", "bosonic"]; export const DEFAULT_LIBRARY_ROOTS = [path.join(os.homedir(), "harmoniqs", "amico-plugin", "skills")]; +/** The canonical `surface: product` skill set (spec-20260708-112732 §4.5) — a + * documentation/reference anchor for what tag-based discovery is expected to + * surface, NOT a selection input (selection is now purely by frontmatter tag, + * see resolveLibrarySkills). Kept as the golden expectation the discovery test + * asserts against the real library root. */ +export const DEFAULT_PLATFORM_SKILLS = [ + "atoms", + "bosonic", + "fluxonium", + "ions", + "transmon", + "setup", + "solve", + "plot", + "objectives", + "demo", +]; /** Bundled spec-C authoring assets (absolute), resolved relative to this module. * At runtime __dirname is the extension's dist/src dir; the assets ship one @@ -358,9 +376,8 @@ export interface OpencodeConfigOptions { entitlementsDir?: string; /** Roots to search for co-located package skills (spec §3). Default: DEFAULT_SKILL_ROOTS. */ skillRoots?: string[]; - /** Configured platform-skill names to index from the library (spec §3). Default: DEFAULT_PLATFORM_SKILLS. */ - platformSkills?: string[]; - /** Roots for the central platform-skill library (spec §3). Default: DEFAULT_LIBRARY_ROOTS. */ + /** Roots for the central library, scanned for `surface: product` skills + * (spec-20260708-112732 §4.5). Default: DEFAULT_LIBRARY_ROOTS. */ skillLibraryRoots?: string[]; /** Personal vault dir for the user-memory substrate (spec-20260705-002847). * undefined → auto-resolve (kind=personal marker scan under ~/.amico/vaults); @@ -467,11 +484,15 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro try { const entsDir = opts.entitlementsDir ?? path.join(os.homedir(), ".amico", "amicode"); const scoresRoot = opts.scoresRoot ?? DEFAULT_SCORES_ROOT; - const allow = packageAllowlist(entitlementsTablePath(scoresRoot), readLocalEntitlements(entsDir).entitlements); + const ents = readLocalEntitlements(entsDir).entitlements; + const allow = packageAllowlist(entitlementsTablePath(scoresRoot), ents); skillEntries = [ - ...resolveLibrarySkills( - opts.platformSkills ?? DEFAULT_PLATFORM_SKILLS, - opts.skillLibraryRoots ?? DEFAULT_LIBRARY_ROOTS, + // Library (product) skills by `surface: product` tag (spec-20260708-112732 + // §4.5). The entitlement seam (§7.1) is wired but a no-op today — every + // product skill is public, so all stage; a future GATED_PRODUCT_SKILLS row + // gates without touching this call. + ...resolveLibrarySkills(opts.skillLibraryRoots ?? DEFAULT_LIBRARY_ROOTS, (name) => + isProductSkillEntitled(name, ents), ), ...resolvePackageSkills(allow, opts.skillRoots ?? DEFAULT_SKILL_ROOTS), ]; diff --git a/packages/extension/src/scores/package_skills.ts b/packages/extension/src/scores/package_skills.ts index db5c88d6..20de8d7d 100644 --- a/packages/extension/src/scores/package_skills.ts +++ b/packages/extension/src/scores/package_skills.ts @@ -5,9 +5,12 @@ import { parse as parseYaml } from "yaml"; // same parser as scores/loader.ts // Dual-source skill index (spec-20260704-113005 §1/§3). Two skill TYPES: // - PACKAGE skills: co-located at packages/

.jl/skills//SKILL.md, // discovered ONLY for entitlement-allowlisted packages (gated). -// - PLATFORM skills: cross-package physics refs in the central amico-plugin -// library, discovered by an explicit CONFIGURED NAME LIST (public), never a -// whole-dir scan — the library holds ~50 process skills that must not leak. +// - LIBRARY (product) skills: cross-package refs in the central amico-plugin +// library, discovered by SURFACE TAG (spec-20260708-112732 §4.5/§7.1): the +// library dir is scanned, but ONLY skills whose frontmatter carries +// `surface: product` are staged. `internal` and untagged skills — the ~44 +// process skills in the same library — MUST NOT leak into Amicode; the tag +// IS the least-privilege guard (superseding the old hardcoded name list). // Content is read on demand by the agent — never baked into the prompt or the // .vsix. Errors mirror the entitlements philosophy: skip + warn, never throw. export interface SkillIndexEntry { @@ -24,15 +27,22 @@ function expandHome(p: string): string { return p; } -/** Parse a SKILL.md's frontmatter; throw on anything malformed (caller skips). */ -function readFrontmatter(skillPath: string): { name: string; description: string } { +/** Parse a SKILL.md's frontmatter; throw on anything malformed (caller skips). + * `surface` (spec-20260708-112732 §4.5) is optional — a string tag + * (`product` | `internal`) or undefined when the skill is untagged. It drives + * library-skill staging (see resolveLibrarySkills). */ +function readFrontmatter(skillPath: string): { name: string; description: string; surface?: string } { const raw = fs.readFileSync(skillPath, "utf8"); const m = raw.match(/^---\n([\s\S]*?)\n---/); if (!m) throw new Error("missing frontmatter"); - const fm = parseYaml(m[1]) as { name?: string; description?: string }; + const fm = parseYaml(m[1]) as { name?: string; description?: string; surface?: string }; if (typeof fm.name !== "string" || typeof fm.description !== "string") throw new Error("frontmatter needs name + description"); - return { name: fm.name, description: fm.description }; + return { + name: fm.name, + description: fm.description, + surface: typeof fm.surface === "string" ? fm.surface : undefined, + }; } /** Package skills for allowlisted packages (gated). First root containing @@ -70,21 +80,64 @@ export function resolvePackageSkills(allowlist: string[], roots: string[]): Skil return out; } -/** Platform skills from the central library (spec §3, Rev 2). PUBLIC by - * construction — NO entitlement input. Only the CONFIGURED names are looked - * up: the library dir also holds ~50 process skills that must never leak into - * the Amicode prompt (explicit list is the guard; marker-based discovery is a - * recorded follow-up). First root containing `/SKILL.md` wins. */ -export function resolveLibrarySkills(names: string[], roots: string[]): SkillIndexEntry[] { +/** Library (product) skills gated behind an entitlement — the future-gated- + * product seam (spec-20260708-112732 §7.1). EMPTY today: every `surface: + * product` skill is public, so the entitlement filter is a no-op and all + * product skills stage. Maps skill name → the entitlement code required to + * stage it; adding one row here gates that skill WITHOUT touching discovery. */ +export const GATED_PRODUCT_SKILLS: Readonly> = {}; + +/** Entitlement predicate for library (product) skill staging (§7.1 seam). A + * product skill absent from GATED_PRODUCT_SKILLS is public (always staged); a + * gated one stages only when its required entitlement is held. Wired at the + * call site even while the map is empty, so gating later is a one-row edit. */ +export function isProductSkillEntitled(name: string, entitlements: readonly string[] = []): boolean { + const required = GATED_PRODUCT_SKILLS[name]; + return required === undefined || entitlements.includes(required); +} + +/** Library skills from the central amico-plugin library, discovered by SURFACE + * TAG (spec-20260708-112732 §4.5/§7.1). The library root is SCANNED, but ONLY + * skills whose frontmatter carries `surface: product` are returned — `internal` + * and untagged skills (the ~44 process skills) are the leak hazard and are + * DROPPED. The tag is the least-privilege guard that the old hardcoded name + * list used to be; staging (stageOpencodeSkills) still copies only THIS + * selected set to the per-session stage dir — `skills.paths` never points at + * the library root itself. First root holding a given `/SKILL.md` wins. + * + * `isEntitled` is the entitlement seam: a product skill is included only when + * the predicate admits its name. The default admits every product skill (the + * public-today behaviour); production wires isProductSkillEntitled so a future + * GATED_PRODUCT_SKILLS row gates without a code change. */ +export function resolveLibrarySkills( + roots: string[], + isEntitled: (name: string) => boolean = () => true, +): SkillIndexEntry[] { const out: SkillIndexEntry[] = []; - for (const name of names) { - const skillPath = roots.map((r) => path.join(expandHome(r), name, "SKILL.md")).find((p) => fs.existsSync(p)); - if (!skillPath) continue; // configured-but-absent — silently skipped + const seen = new Set(); // first-root-wins, keyed by dir name + for (const r of roots) { + const root = expandHome(r); + let names: string[] = []; try { - const fm = readFrontmatter(skillPath); + names = fs.readdirSync(root); + } catch { + continue; // missing library root — silently skipped (session proceeds) + } + for (const name of names.sort()) { + if (seen.has(name)) continue; + const skillPath = path.join(root, name, "SKILL.md"); + if (!fs.existsSync(skillPath)) continue; + let fm: { name: string; description: string; surface?: string }; + try { + fm = readFrontmatter(skillPath); + } catch (e) { + console.warn(`amicode: skipping malformed library skill ${skillPath}: ${e}`); + continue; + } + if (fm.surface !== "product") continue; // THE GUARD: internal/untagged never stage + seen.add(name); // this dir is the authoritative product skill (earlier root wins) + if (!isEntitled(fm.name)) continue; // §7.1 entitlement seam (no-op today) out.push({ source: "library", name: fm.name, description: fm.description, path: skillPath }); - } catch (e) { - console.warn(`amicode: skipping malformed library skill ${skillPath}: ${e}`); } } return out; diff --git a/packages/extension/test/scores/package_skills.test.ts b/packages/extension/test/scores/package_skills.test.ts index 4e680bd8..17f795bc 100644 --- a/packages/extension/test/scores/package_skills.test.ts +++ b/packages/extension/test/scores/package_skills.test.ts @@ -5,9 +5,11 @@ import * as path from "node:path"; import { resolvePackageSkills, resolveLibrarySkills, + isProductSkillEntitled, buildSkillIndexSection, stageOpencodeSkills, } from "../../src/scores/package_skills"; +import { DEFAULT_LIBRARY_ROOTS, DEFAULT_PLATFORM_SKILLS } from "../../src/opencode_config"; function mkRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "amicode-skillroot-")); @@ -19,11 +21,14 @@ function writeSkill(root: string, pkg: string, name: string, description = "desc fs.writeFileSync(p, `---\nname: ${name}\ndescription: ${description}\nagents: [experimenter]\n---\n\n# body\n`); return p; } -function writeLibSkill(root: string, name: string): string { +/** Write a library skill. `surface` is one of "product" | "internal" | null + * (null = untagged frontmatter, the pre-tagging state). */ +function writeLibSkill(root: string, name: string, surface: "product" | "internal" | null = "product"): string { const dir = path.join(root, name); fs.mkdirSync(dir, { recursive: true }); const p = path.join(dir, "SKILL.md"); - fs.writeFileSync(p, `---\nname: ${name}\ndescription: ${name} physics\nagents: [experimenter]\n---\n\n# body\n`); + const surfaceLine = surface === null ? "" : `surface: ${surface}\n`; + fs.writeFileSync(p, `---\nname: ${name}\ndescription: ${name} physics\nagents: [experimenter]\n${surfaceLine}---\n\n# body\n`); return p; } @@ -66,22 +71,95 @@ describe("resolvePackageSkills (spec-20260704-113005 §3)", () => { }); }); -describe("resolveLibrarySkills (spec-20260704-113005 §3, Rev 2 — platform source, PUBLIC)", () => { - it("indexes ONLY the configured names — a process skill in the same root must not leak", () => { +describe("resolveLibrarySkills (spec-20260708-112732 §4.5/§7.1 — surface-tag discovery)", () => { + it("stages ONLY surface:product skills — internal + untagged in the same root must NOT leak", () => { const root = mkRoot(); - writeLibSkill(root, "atoms"); - writeLibSkill(root, "brainstorming"); // process skill — the leak hazard - const idx = resolveLibrarySkills(["atoms", "transmon"], [root]); // transmon configured but absent - expect(idx).toHaveLength(1); - expect(idx[0]).toMatchObject({ source: "library", name: "atoms" }); - expect(idx[0].package).toBeUndefined(); + writeLibSkill(root, "atoms", "product"); + writeLibSkill(root, "transmon", "product"); + writeLibSkill(root, "pr", "internal"); // process skill tagged internal — the leak hazard + writeLibSkill(root, "debugging", "internal"); + writeLibSkill(root, "legacy", null); // untagged (pre-tagging) — also excluded + const idx = resolveLibrarySkills([root]); + expect(idx.map((e) => e.name).sort()).toEqual(["atoms", "transmon"]); + for (const e of idx) { + expect(e.source).toBe("library"); + expect(e.package).toBeUndefined(); + } + }); + it("EXCLUDES a known internal skill explicitly (the least-privilege leak guard)", () => { + const root = mkRoot(); + writeLibSkill(root, "atoms", "product"); + writeLibSkill(root, "pr", "internal"); + writeLibSkill(root, "dream", "internal"); + const names = resolveLibrarySkills([root]).map((e) => e.name); + expect(names).toContain("atoms"); + expect(names).not.toContain("pr"); + expect(names).not.toContain("dream"); + }); + it("EXCLUDES an untagged skill (no surface frontmatter)", () => { + const root = mkRoot(); + writeLibSkill(root, "atoms", "product"); + writeLibSkill(root, "mystery", null); + expect(resolveLibrarySkills([root]).map((e) => e.name)).toEqual(["atoms"]); }); it("missing library root → empty, no throw (session proceeds)", () => { - expect(resolveLibrarySkills(["atoms"], ["/nonexistent-lib"])).toEqual([]); + expect(resolveLibrarySkills(["/nonexistent-lib"])).toEqual([]); + }); + it("malformed frontmatter skips that skill, keeps the product ones", () => { + const root = mkRoot(); + const bad = path.join(root, "broken"); + fs.mkdirSync(bad, { recursive: true }); + fs.writeFileSync(path.join(bad, "SKILL.md"), "no frontmatter here"); + writeLibSkill(root, "atoms", "product"); + expect(resolveLibrarySkills([root]).map((e) => e.name)).toEqual(["atoms"]); + }); + it("first root containing a product skill wins", () => { + const r1 = mkRoot(), + r2 = mkRoot(); + writeLibSkill(r1, "atoms", "product"); + const p2 = path.join(r2, "atoms"); + fs.mkdirSync(p2, { recursive: true }); + fs.writeFileSync( + path.join(p2, "SKILL.md"), + `---\nname: atoms\ndescription: from r2\nsurface: product\n---\n# body\n`, + ); + const idx = resolveLibrarySkills([r1, r2]); + expect(idx).toHaveLength(1); + expect(idx[0].description).toBe("atoms physics"); // r1's copy + }); + describe("entitlement seam (§7.1)", () => { + it("default predicate admits every product skill (public today)", () => { + const root = mkRoot(); + writeLibSkill(root, "atoms", "product"); + writeLibSkill(root, "transmon", "product"); + expect(resolveLibrarySkills([root]).map((e) => e.name).sort()).toEqual(["atoms", "transmon"]); + }); + it("a predicate can gate a product skill without touching discovery", () => { + const root = mkRoot(); + writeLibSkill(root, "atoms", "product"); + writeLibSkill(root, "premium", "product"); + const idx = resolveLibrarySkills([root], (name) => name !== "premium"); + expect(idx.map((e) => e.name)).toEqual(["atoms"]); // product-tagged but not entitled → dropped + }); + it("isProductSkillEntitled: public product skills (empty GATED_PRODUCT_SKILLS) always admitted", () => { + expect(isProductSkillEntitled("atoms", [])).toBe(true); + expect(isProductSkillEntitled("solve", ["some-code"])).toBe(true); + }); }); - it("takes no entitlement input at all — public by construction", () => { - // signature-level guarantee: (names, roots) only. - expect(resolveLibrarySkills.length).toBe(2); + it("discovers exactly the product-skill set from the real amico-plugin library root", () => { + const root = DEFAULT_LIBRARY_ROOTS[0]; + if (!fs.existsSync(root)) { + // machine without the amico-plugin checkout (e.g. CI) — the hermetic tests + // above cover the discovery logic; skip the real-root assertion. + return; + } + const names = resolveLibrarySkills(DEFAULT_LIBRARY_ROOTS).map((e) => e.name).sort(); + expect(names).toEqual([...DEFAULT_PLATFORM_SKILLS].sort()); + expect(names).toHaveLength(10); + // explicit leak-guard on real data: known internal skills must be absent + for (const internal of ["pr", "debugging", "dream", "meeting", "tdd"]) { + expect(names).not.toContain(internal); + } }); }); diff --git a/packages/extension/test/scores/prep_integration.test.ts b/packages/extension/test/scores/prep_integration.test.ts index 31554351..6814f432 100644 --- a/packages/extension/test/scores/prep_integration.test.ts +++ b/packages/extension/test/scores/prep_integration.test.ts @@ -36,7 +36,6 @@ function prep(overrides: Partial[0]> = // hermetic by default: no skill index unless a test opts in (otherwise these // default to the machine's ~/harmoniqs/{packages,amico-plugin/skills}). skillRoots: [], - platformSkills: [], skillLibraryRoots: [], // hermetic: personalization off (else auto-resolve hits the machine's real // personal vault and, absent a profile there, routes to the overture score — @@ -60,11 +59,19 @@ function mkPkgSkillRoot(): string { } function mkLibRoot(): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), "libskill-")); - const d = path.join(root, "atoms"); - fs.mkdirSync(d, { recursive: true }); + // atoms is surface:product → staged; pr is surface:internal → the leak hazard + // that surface-tag discovery must drop (spec-20260708-112732 §4.5). + const atoms = path.join(root, "atoms"); + fs.mkdirSync(atoms, { recursive: true }); fs.writeFileSync( - path.join(d, "SKILL.md"), - "---\nname: atoms\ndescription: rydberg physics\nagents: [experimenter]\n---\n# body\n", + path.join(atoms, "SKILL.md"), + "---\nname: atoms\ndescription: rydberg physics\nagents: [experimenter]\nsurface: product\n---\n# body\n", + ); + const pr = path.join(root, "pr"); + fs.mkdirSync(pr, { recursive: true }); + fs.writeFileSync( + path.join(pr, "SKILL.md"), + "---\nname: pr\ndescription: open a PR\nagents: [engineer]\nsurface: internal\n---\n# body\n", ); return root; } @@ -182,7 +189,6 @@ describe("prepareOpencodeProject × skill index (spec §3, Rev 2 — dual-source const proj = prep({ entitlementsDir: entitledDir(), skillRoots: [mkPkgSkillRoot()], - platformSkills: ["atoms"], skillLibraryRoots: [mkLibRoot()], }); const agents = fs.readFileSync(proj.agentsPath, "utf8"); @@ -191,6 +197,7 @@ describe("prepareOpencodeProject × skill index (spec §3, Rev 2 — dual-source expect(proj.skillPaths.some((p) => p.endsWith("/atoms/SKILL.md"))).toBe(true); const skills = readSkills(); expect(libNames(skills)).toContain("atoms"); + expect(libNames(skills)).not.toContain("pr"); // surface:internal never stages (leak guard, §4.5) expect(pkgNames(skills)).toContain("Piccolissimo"); expect(skills[0].source).toBe("library"); // platform entries first (spec §3) }); @@ -206,7 +213,6 @@ describe("prepareOpencodeProject × skill index (spec §3, Rev 2 — dual-source scoresRoot: badRoot, entitlementsDir: entitledDir(), skillRoots: [mkPkgSkillRoot()], - platformSkills: ["atoms"], skillLibraryRoots: [mkLibRoot()], }); const agents = fs.readFileSync(proj.agentsPath, "utf8"); @@ -221,7 +227,6 @@ describe("prepareOpencodeProject × skill index (spec §3, Rev 2 — dual-source const proj = prep({ entitlementsDir: fs.mkdtempSync(path.join(os.tmpdir(), "no-ents-")), // public skillRoots: [mkPkgSkillRoot()], - platformSkills: ["atoms"], skillLibraryRoots: [mkLibRoot()], }); expect(fs.readFileSync(proj.agentsPath, "utf8")).toContain("## Skill index"); @@ -230,6 +235,7 @@ describe("prepareOpencodeProject × skill index (spec §3, Rev 2 — dual-source expect(skills.length).toBeGreaterThan(0); expect(skills.every((e) => e.source === "library")).toBe(true); // platform only, no package expect(libNames(skills)).toContain("atoms"); + expect(libNames(skills)).not.toContain("pr"); // surface:internal never stages (leak guard, §4.5) expect(authoring.allowlist).not.toContain("Piccolissimo"); }); @@ -237,7 +243,6 @@ describe("prepareOpencodeProject × skill index (spec §3, Rev 2 — dual-source const proj = prep({ entitlementsDir: entitledDir(), skillRoots: [mkPkgSkillRoot()], - platformSkills: ["atoms"], skillLibraryRoots: ["/nonexistent-lib"], }); expect(fs.readFileSync(proj.agentsPath, "utf8")).toContain("## Skill index"); From 7d2ffb2a4a2df77ab1be5212569addd074e97d07 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 18:44:36 -0400 Subject: [PATCH 12/49] =?UTF-8?q?feat(amicode):=20Formulation=20typed-face?= =?UTF-8?q?t=20schema=20+=20enums=20(spec=20=C2=A73=20/=20plan=20Task=207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy FormulationEntity → LegacyFormulationEntity; new structured FormulationEntity (trajectory_type/time_mode/parameterization/robustness/ free_phase/leakage facets + typed objectives[]/constraints[]) + closed-enum consts. Types-only scaffolding; validate/toml/tool bodies rewritten in Tasks 9/10/11 (tree intentionally inconsistent until then). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../extension/opencode-plugin/entities.ts | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/extension/opencode-plugin/entities.ts b/packages/extension/opencode-plugin/entities.ts index e7f41f3e..a82a2a96 100644 --- a/packages/extension/opencode-plugin/entities.ts +++ b/packages/extension/opencode-plugin/entities.ts @@ -51,13 +51,83 @@ export interface SolveParams { pinned_globals?: string[]; } -export interface FormulationEntity { +/** Legacy free-form Formulation (on-disk pre-spec-20260709). Migrated by + * normalizeFormulation; no longer written. */ +export interface LegacyFormulationEntity { problem: string; target: string; objective: string; constraints: string[]; + solve?: SolveParams; +} + +// ---- Formulation typed facets (spec-20260709 §3) --------------------------- +export type TrajectoryType = "ket" | "multiket" | "gate" | "density" | "multidensity"; +export type TimeMode = "fixed" | "min_time"; +export type Parameterization = "smooth" | "linear_spline" | "cubic_spline" | "bang_bang"; +export type RobustnessKind = "none" | "ensemble" | "sensitivity"; +export type ConstraintKind = + | "bounds" + | "du_bound" + | "ddu_bound" + | "dt_bounds" + | "final_fidelity" + | "calibration_pin" + | "custom"; +export type ObjectiveKind = "reg_u" | "reg_du" | "reg_ddu" | "sensitivity" | "custom"; + +export const TRAJECTORY_TYPES: TrajectoryType[] = ["ket", "multiket", "gate", "density", "multidensity"]; +export const TIME_MODES: TimeMode[] = ["fixed", "min_time"]; +export const PARAMETERIZATIONS: Parameterization[] = ["smooth", "linear_spline", "cubic_spline", "bang_bang"]; +export const ROBUSTNESS_KINDS: RobustnessKind[] = ["none", "ensemble", "sensitivity"]; +export const CONSTRAINT_KINDS: ConstraintKind[] = [ + "bounds", + "du_bound", + "ddu_bound", + "dt_bounds", + "final_fidelity", + "calibration_pin", + "custom", +]; +export const OBJECTIVE_KINDS: ObjectiveKind[] = ["reg_u", "reg_du", "reg_ddu", "sensitivity", "custom"]; + +export interface Robustness { + kind: RobustnessKind; + params: Record; +} +export interface ObjectiveTerm { + kind: ObjectiveKind; + params: Record; + label?: string; +} +export interface Constraint { + kind: ConstraintKind; + params: Record; + label?: string; +} + +/** Structured Formulation (spec-20260709 §3). Legacy free-form entities migrate + * via normalizeFormulation. The PRIMARY objective is DERIVED (trajectory_type + + * free_phase + time_mode), never stored; `objectives[]` holds ADDED terms only. + * Leakage's sole home is the flag + leakage_params. */ +export interface FormulationEntity { + trajectory_type: TrajectoryType; + time_mode: TimeMode; + /** {final_fidelity?, D?} — used/editable when time_mode === "min_time". */ + time_params?: Record; + parameterization: Parameterization; + robustness: Robustness; + free_phase: boolean; + leakage: boolean; + /** {value?, cost?} when leakage=true — encodes both the constraint and objective. */ + leakage_params?: Record; + target: string; + /** ADDED terms only (regularizers/sensitivity/custom); primary is derived. */ + objectives: ObjectiveTerm[]; + constraints: Constraint[]; /** Solve params (spec A) — present once amicode_solve has recorded them. */ solve?: SolveParams; + notes?: string; } export interface RunStub { From b2f9fcdc40fa07e30677b53f4b004e036beecafa Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 18:51:17 -0400 Subject: [PATCH 13/49] feat(amicode): normalizeFormulation + updateFormulation + shared migration corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec §3.1.3,§8,§10 / plan Task 8. Legacy free-form → structured facets (idempotent, never throws); pure updateFormulation (normalize-then-upsert). Canonical corpus at test/fixtures/formulation-migration.json (fork mirrors it in Task 12). 14 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../extension/opencode-plugin/entities.ts | 138 ++++++++++++++++++ .../test/fixtures/formulation-migration.json | 105 +++++++++++++ packages/extension/test/formulation.test.ts | 45 ++++++ 3 files changed, 288 insertions(+) create mode 100644 packages/extension/test/fixtures/formulation-migration.json create mode 100644 packages/extension/test/formulation.test.ts diff --git a/packages/extension/opencode-plugin/entities.ts b/packages/extension/opencode-plugin/entities.ts index a82a2a96..3449745f 100644 --- a/packages/extension/opencode-plugin/entities.ts +++ b/packages/extension/opencode-plugin/entities.ts @@ -130,6 +130,144 @@ export interface FormulationEntity { notes?: string; } +// ---- Formulation migration + merge (spec §3.1.3, §8, §10) ------------------ + +function normRobustness(r: unknown): Robustness { + if (r && typeof r === "object" && typeof (r as any).kind === "string") { + const o = r as any; + return { kind: o.kind, params: o.params && typeof o.params === "object" ? o.params : {} }; + } + return { kind: "none", params: {} }; +} +function normTerm; label?: string }>(o: any): T { + const out: any = { kind: o.kind, params: o.params && typeof o.params === "object" ? o.params : {} }; + if (typeof o.label === "string") out.label = o.label; + return out as T; +} +function inferTypeFromTarget(target: string): TrajectoryType { + return /^\s*\||prep|state/i.test(target) ? "ket" : "gate"; +} +function constraintKindFor(lc: string): ConstraintKind { + if (/slew|\bdu\b/.test(lc)) return "du_bound"; + if (/ddu|accel/.test(lc)) return "ddu_bound"; + if (/Δt|timestep|\bdt\b/.test(lc)) return "dt_bounds"; + if (/calibration|\bpin\b/.test(lc)) return "calibration_pin"; + if (/amplitude|bound/.test(lc)) return "bounds"; + return "custom"; +} + +/** Legacy free-form → structured; structured passes through (defaults filled). + * §10 mapping table. Idempotent. Never throws. */ +export function normalizeFormulation(raw: unknown): FormulationEntity { + const r = (raw ?? {}) as Record; + + // Already structured → normalize sub-shapes, fill defaults, pass through. + if (typeof r.trajectory_type === "string") { + const out: FormulationEntity = { + trajectory_type: r.trajectory_type, + time_mode: r.time_mode === "min_time" ? "min_time" : "fixed", + parameterization: typeof r.parameterization === "string" ? r.parameterization : "smooth", + robustness: normRobustness(r.robustness), + free_phase: r.free_phase === true, + leakage: r.leakage === true, + target: typeof r.target === "string" ? r.target : "", + objectives: Array.isArray(r.objectives) ? r.objectives.map((o: any) => normTerm(o)) : [], + constraints: Array.isArray(r.constraints) ? r.constraints.map((c: any) => normTerm(c)) : [], + }; + if (r.time_params && typeof r.time_params === "object") out.time_params = r.time_params; + if (r.leakage_params && typeof r.leakage_params === "object") out.leakage_params = r.leakage_params; + if (r.solve && typeof r.solve === "object") out.solve = r.solve; + if (typeof r.notes === "string") out.notes = r.notes; + return out; + } + + // Legacy free-form. + const problem = typeof r.problem === "string" ? r.problem : ""; + const target = typeof r.target === "string" ? r.target : ""; + let trajectory_type: TrajectoryType = "gate"; + let time_mode: TimeMode = "fixed"; + if (problem === "state_prep") trajectory_type = "ket"; + else if (problem === "min_time") { + time_mode = "min_time"; + trajectory_type = inferTypeFromTarget(target); + } + + const objectives: ObjectiveTerm[] = []; + const objStr = typeof r.objective === "string" ? r.objective.trim() : ""; + if (objStr && !/infidelity/i.test(objStr)) objectives.push({ kind: "custom", params: {}, label: objStr }); + + const constraints: Constraint[] = []; + let leakage = false; + let time_params: Record | undefined; + for (const c of Array.isArray(r.constraints) ? r.constraints : []) { + if (typeof c !== "string") continue; + const lc = c.toLowerCase(); + if (/leakage/.test(lc)) { + leakage = true; + continue; + } + if (/final.?fidelity/.test(lc)) { + const m = c.match(/[\d.]+/); + time_params = { ...(time_params ?? {}), final_fidelity: m ? Number(m[0]) : 0.99 }; + continue; + } + constraints.push({ kind: constraintKindFor(lc), params: {}, label: c }); + } + + const out: FormulationEntity = { + trajectory_type, + time_mode, + parameterization: "smooth", + robustness: { kind: "none", params: {} }, + free_phase: false, + leakage, + target, + objectives, + constraints, + }; + if (time_params) out.time_params = time_params; + if (r.solve && typeof r.solve === "object") out.solve = r.solve; + if (typeof r.notes === "string") out.notes = r.notes; + return out; +} + +export interface FormulationPatch { + trajectory_type?: TrajectoryType; + time_mode?: TimeMode; + time_params?: Record; + parameterization?: Parameterization; + robustness?: Robustness; + free_phase?: boolean; + leakage?: boolean; + leakage_params?: Record; + target?: string; + objectives?: ObjectiveTerm[]; + constraints?: Constraint[]; + solve?: SolveParams; + notes?: string; +} + +/** Normalize the (possibly legacy) existing, then upsert: scalar modes replace, + * sets replace-whole when provided, param bags shallow-merge. */ +export function updateFormulation(existing: unknown, patch: FormulationPatch): FormulationEntity { + const base = normalizeFormulation(existing); + const merged: FormulationEntity = { ...base }; + if (patch.trajectory_type !== undefined) merged.trajectory_type = patch.trajectory_type; + if (patch.time_mode !== undefined) merged.time_mode = patch.time_mode; + if (patch.parameterization !== undefined) merged.parameterization = patch.parameterization; + if (patch.robustness !== undefined) merged.robustness = patch.robustness; + if (patch.free_phase !== undefined) merged.free_phase = patch.free_phase; + if (patch.leakage !== undefined) merged.leakage = patch.leakage; + if (patch.target !== undefined) merged.target = patch.target; + if (patch.objectives !== undefined) merged.objectives = patch.objectives; + if (patch.constraints !== undefined) merged.constraints = patch.constraints; + if (patch.time_params !== undefined) merged.time_params = { ...(base.time_params ?? {}), ...patch.time_params }; + if (patch.leakage_params !== undefined) merged.leakage_params = { ...(base.leakage_params ?? {}), ...patch.leakage_params }; + if (patch.solve !== undefined) merged.solve = { ...(base.solve ?? {}), ...patch.solve }; + if (patch.notes !== undefined) merged.notes = patch.notes; + return merged; +} + export interface RunStub { formulation_ref?: string; system_ref?: string; diff --git a/packages/extension/test/fixtures/formulation-migration.json b/packages/extension/test/fixtures/formulation-migration.json new file mode 100644 index 00000000..6dd6384c --- /dev/null +++ b/packages/extension/test/fixtures/formulation-migration.json @@ -0,0 +1,105 @@ +{ + "_source": "CANONICAL. amicode packages/extension/test/fixtures/formulation-migration.json is the source of truth for the legacy->structured Formulation mapping (spec-20260709 §10). The opencode fork copy at packages/ui/src/amicode/fixtures/ is a VERBATIM mirror — edit here, then re-copy. Each `structured` holds ONLY mapped facet fields (no derived primaryKey / synthesized final_fidelity).", + "pairs": [ + { + "name": "gate_synthesis basic", + "legacy": { "problem": "gate_synthesis", "target": "CZ", "objective": "unitary infidelity", "constraints": ["amplitude bound (drive_max)"] }, + "structured": { + "trajectory_type": "gate", + "time_mode": "fixed", + "parameterization": "smooth", + "robustness": { "kind": "none", "params": {} }, + "free_phase": false, + "leakage": false, + "target": "CZ", + "objectives": [], + "constraints": [{ "kind": "bounds", "params": {}, "label": "amplitude bound (drive_max)" }] + } + }, + { + "name": "state_prep -> ket", + "legacy": { "problem": "state_prep", "target": "|1>", "objective": "ket infidelity", "constraints": [] }, + "structured": { + "trajectory_type": "ket", + "time_mode": "fixed", + "parameterization": "smooth", + "robustness": { "kind": "none", "params": {} }, + "free_phase": false, + "leakage": false, + "target": "|1>", + "objectives": [], + "constraints": [] + } + }, + { + "name": "min_time -> time_mode", + "legacy": { "problem": "min_time", "target": "CZ", "objective": "unitary infidelity", "constraints": ["amplitude bound"] }, + "structured": { + "trajectory_type": "gate", + "time_mode": "min_time", + "parameterization": "smooth", + "robustness": { "kind": "none", "params": {} }, + "free_phase": false, + "leakage": false, + "target": "CZ", + "objectives": [], + "constraints": [{ "kind": "bounds", "params": {}, "label": "amplitude bound" }] + } + }, + { + "name": "custom objective + unknown constraint", + "legacy": { "problem": "gate_synthesis", "target": "X", "objective": "custom cost foo", "constraints": ["keep it smooth"] }, + "structured": { + "trajectory_type": "gate", + "time_mode": "fixed", + "parameterization": "smooth", + "robustness": { "kind": "none", "params": {} }, + "free_phase": false, + "leakage": false, + "target": "X", + "objectives": [{ "kind": "custom", "params": {}, "label": "custom cost foo" }], + "constraints": [{ "kind": "custom", "params": {}, "label": "keep it smooth" }] + } + }, + { + "name": "leakage constraint -> flag", + "legacy": { "problem": "gate_synthesis", "target": "CZ", "objective": "unitary infidelity", "constraints": ["leakage suppression"] }, + "structured": { + "trajectory_type": "gate", + "time_mode": "fixed", + "parameterization": "smooth", + "robustness": { "kind": "none", "params": {} }, + "free_phase": false, + "leakage": true, + "target": "CZ", + "objectives": [], + "constraints": [] + } + }, + { + "name": "already structured (idempotent)", + "legacy": { + "trajectory_type": "gate", + "time_mode": "fixed", + "parameterization": "cubic_spline", + "robustness": { "kind": "ensemble", "params": { "n_systems": 3 } }, + "free_phase": true, + "leakage": false, + "target": "CZ", + "objectives": [{ "kind": "reg_du", "params": { "R": 0.00001 } }], + "constraints": [{ "kind": "bounds", "params": {} }] + }, + "structured": { + "trajectory_type": "gate", + "time_mode": "fixed", + "parameterization": "cubic_spline", + "robustness": { "kind": "ensemble", "params": { "n_systems": 3 } }, + "free_phase": true, + "leakage": false, + "target": "CZ", + "objectives": [{ "kind": "reg_du", "params": { "R": 0.00001 } }], + "constraints": [{ "kind": "bounds", "params": {} }] + } + } + ] +} diff --git a/packages/extension/test/formulation.test.ts b/packages/extension/test/formulation.test.ts new file mode 100644 index 00000000..07c47e48 --- /dev/null +++ b/packages/extension/test/formulation.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { normalizeFormulation, updateFormulation } from "../opencode-plugin/entities"; + +const corpus = JSON.parse( + readFileSync(new URL("./fixtures/formulation-migration.json", import.meta.url), "utf8"), +) as { pairs: { name: string; legacy: unknown; structured: unknown }[] }; + +describe("normalizeFormulation (shared migration corpus §10)", () => { + for (const pair of corpus.pairs) { + it(`migrates: ${pair.name}`, () => { + expect(normalizeFormulation(pair.legacy)).toEqual(pair.structured); + }); + it(`idempotent: ${pair.name}`, () => { + const once = normalizeFormulation(pair.legacy); + expect(normalizeFormulation(once)).toEqual(once); + }); + } +}); + +describe("updateFormulation", () => { + it("normalizes a legacy existing, then upserts patch facets", () => { + const existing = { problem: "gate_synthesis", target: "CZ", objective: "unitary infidelity", constraints: [] }; + const merged = updateFormulation(existing, { time_mode: "min_time", time_params: { final_fidelity: 0.999 } }); + expect(merged.trajectory_type).toBe("gate"); // preserved from normalize + expect(merged.time_mode).toBe("min_time"); // patched + expect(merged.time_params).toEqual({ final_fidelity: 0.999 }); + expect(merged.target).toBe("CZ"); // untouched + }); + it("replaces whole sets and shallow-merges param bags", () => { + const base = { + trajectory_type: "gate", + objectives: [{ kind: "reg_u", params: { R: 1e-4 } }], + leakage_params: { value: 1e-3 }, + }; + const merged = updateFormulation(base, { + objectives: [{ kind: "reg_du", params: { R: 1e-5 } }], + leakage: true, + leakage_params: { cost: 1e-2 }, + }); + expect(merged.objectives).toEqual([{ kind: "reg_du", params: { R: 1e-5 } }]); + expect(merged.leakage).toBe(true); + expect(merged.leakage_params).toEqual({ value: 1e-3, cost: 1e-2 }); // shallow-merged + }); +}); From 0c78beb9a218b92d09f4ce6613651d8f9aaa2cbd Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 18:52:39 -0400 Subject: [PATCH 14/49] =?UTF-8?q?feat(amicode):=20validateFormulation=20(s?= =?UTF-8?q?tructured)=20+=20formulationWarnings=20(spec=20=C2=A73.2=20/=20?= =?UTF-8?q?Task=209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../extension/opencode-plugin/entities.ts | 35 +++++++++++++++---- packages/extension/test/formulation.test.ts | 32 ++++++++++++++++- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/extension/opencode-plugin/entities.ts b/packages/extension/opencode-plugin/entities.ts index 3449745f..d5cdafd0 100644 --- a/packages/extension/opencode-plugin/entities.ts +++ b/packages/extension/opencode-plugin/entities.ts @@ -379,15 +379,38 @@ export function validateSystem(e: SystemEntity): string[] { /** Problems with a FormulationEntity; [] means valid. */ export function validateFormulation(e: FormulationEntity): string[] { const problems: string[] = []; - if (typeof e.problem !== "string" || e.problem.trim() === "") problems.push("problem must be non-empty"); - if (typeof e.target !== "string" || e.target.trim() === "") problems.push("target must be non-empty"); - if (typeof e.objective !== "string" || e.objective.trim() === "") problems.push("objective must be non-empty"); - if (!Array.isArray(e.constraints) || e.constraints.some((c) => typeof c !== "string")) { - problems.push("constraints must be an array of strings"); - } + if (!TRAJECTORY_TYPES.includes(e.trajectory_type)) problems.push(`trajectory_type must be one of ${TRAJECTORY_TYPES.join(", ")}`); + if (!TIME_MODES.includes(e.time_mode)) problems.push(`time_mode must be one of ${TIME_MODES.join(", ")}`); + if (!PARAMETERIZATIONS.includes(e.parameterization)) problems.push(`parameterization must be one of ${PARAMETERIZATIONS.join(", ")}`); + if (!e.robustness || !ROBUSTNESS_KINDS.includes(e.robustness.kind)) problems.push(`robustness.kind must be one of ${ROBUSTNESS_KINDS.join(", ")}`); + if (typeof e.free_phase !== "boolean") problems.push("free_phase must be a boolean"); + if (typeof e.leakage !== "boolean") problems.push("leakage must be a boolean"); + if (typeof e.target !== "string") problems.push("target must be a string"); + if (!Array.isArray(e.objectives)) problems.push("objectives must be an array"); + else e.objectives.forEach((o, i) => { if (!OBJECTIVE_KINDS.includes(o.kind)) problems.push(`objectives[${i}].kind invalid: ${o.kind}`); }); + if (!Array.isArray(e.constraints)) problems.push("constraints must be an array"); + else e.constraints.forEach((c, i) => { if (!CONSTRAINT_KINDS.includes(c.kind)) problems.push(`constraints[${i}].kind invalid: ${c.kind}`); }); return problems; } +/** Soft, non-blocking warnings (spec §3.2). `componentCount` (N, from the + * sibling System entity) is optional; the free_phase-on-N=1 warning fires only + * when it is exactly 1, and is skipped when componentCount is undefined. */ +export function formulationWarnings(e: FormulationEntity, componentCount?: number): string[] { + const warnings: string[] = []; + if (e.trajectory_type === "density" || e.trajectory_type === "multidensity") + warnings.push("density trajectory — the System should be an open quantum system (dissipators)"); + if (e.time_mode === "min_time") { + if (!e.time_params || typeof e.time_params.final_fidelity !== "number") + warnings.push("min_time without a time_params.final_fidelity floor"); + if (!Array.isArray(e.constraints) || !e.constraints.some((c) => c.kind === "dt_bounds")) + warnings.push("min_time needs free Δt — add a dt_bounds constraint"); + } + if (e.free_phase && componentCount === 1) + warnings.push("free_phase on a single-component system has no virtual-Z freedom"); + return warnings; +} + // --- merge (amicode_set_model) ------------------------------------------------ export interface SystemPatch { diff --git a/packages/extension/test/formulation.test.ts b/packages/extension/test/formulation.test.ts index 07c47e48..92a4252a 100644 --- a/packages/extension/test/formulation.test.ts +++ b/packages/extension/test/formulation.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; -import { normalizeFormulation, updateFormulation } from "../opencode-plugin/entities"; +import { normalizeFormulation, updateFormulation, validateFormulation, formulationWarnings } from "../opencode-plugin/entities"; const corpus = JSON.parse( readFileSync(new URL("./fixtures/formulation-migration.json", import.meta.url), "utf8"), @@ -43,3 +43,33 @@ describe("updateFormulation", () => { expect(merged.leakage_params).toEqual({ value: 1e-3, cost: 1e-2 }); // shallow-merged }); }); + +describe("validateFormulation", () => { + const good = normalizeFormulation({ problem: "gate_synthesis", target: "CZ", objective: "unitary infidelity", constraints: [] }); + it("passes a good entity", () => { + expect(validateFormulation(good)).toEqual([]); + }); + it("flags an unknown enum value", () => { + const bad = { ...good, trajectory_type: "bogus" as any }; + expect(validateFormulation(bad).length).toBeGreaterThan(0); + }); + it("flags an unknown objective kind", () => { + const bad = { ...good, objectives: [{ kind: "nope" as any, params: {} }] }; + expect(validateFormulation(bad).some((p) => p.includes("objectives[0]"))).toBe(true); + }); +}); + +describe("formulationWarnings", () => { + it("min_time without final_fidelity and without dt_bounds → two warnings", () => { + const e = normalizeFormulation({ trajectory_type: "gate", time_mode: "min_time" }); + const w = formulationWarnings(e); + expect(w.some((x) => /final_fidelity/.test(x))).toBe(true); + expect(w.some((x) => /dt_bounds/.test(x))).toBe(true); + }); + it("free_phase warning fires only when componentCount === 1", () => { + const e = normalizeFormulation({ trajectory_type: "gate", free_phase: true }); + expect(formulationWarnings(e, 1).some((x) => /free_phase/.test(x))).toBe(true); + expect(formulationWarnings(e, 2).some((x) => /free_phase/.test(x))).toBe(false); + expect(formulationWarnings(e).some((x) => /free_phase/.test(x))).toBe(false); // undefined N → skipped + }); +}); From 918f4bd3282d429068303e8d8c2c53694b8063f9 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 18:56:08 -0400 Subject: [PATCH 15/49] feat(amicode): structured formulationToml round-trip + migrate legacy tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec §8 / plan Task 10. formulationToml emits the typed facets (inline robustness/time_params/leakage_params tables + [[objectives]]/[[constraints]] array-of-tables). Migrated the legacy FORM fixture + its TOML/validate/solve assertions in amicode_tools.test.ts to the structured shape. 79 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../extension/opencode-plugin/entities.ts | 38 +++++++++++++--- packages/extension/test/amicode_tools.test.ts | 43 +++++++++++-------- packages/extension/test/formulation.test.ts | 32 +++++++++++++- 3 files changed, 88 insertions(+), 25 deletions(-) diff --git a/packages/extension/opencode-plugin/entities.ts b/packages/extension/opencode-plugin/entities.ts index d5cdafd0..bcbca196 100644 --- a/packages/extension/opencode-plugin/entities.ts +++ b/packages/extension/opencode-plugin/entities.ts @@ -797,14 +797,42 @@ export function systemToml(e: SystemEntity, now?: Date): string { export function formulationToml(e: FormulationEntity, now?: Date): string { const problems = validateFormulation(e); if (problems.length) throw new Error(`invalid formulation: ${problems.join("; ")}`); - const lines = [ + const inlineNum = (p: Record): string => { + const entries = Object.entries(p); + return entries.length === 0 ? "{}" : `{ ${entries.map(([k, v]) => `${tomlKey(k)} = ${tomlNumber(v)}`).join(", ")} }`; + }; + const inlineMixed = (p: Record): string => { + const entries = Object.entries(p); + return entries.length === 0 + ? "{}" + : `{ ${entries.map(([k, v]) => `${tomlKey(k)} = ${typeof v === "number" ? tomlNumber(v) : tomlEscape(v)}`).join(", ")} }`; + }; + const lines: string[] = [ "[formulation]", - `problem = ${tomlEscape(e.problem)}`, + `trajectory_type = ${tomlEscape(e.trajectory_type)}`, + `time_mode = ${tomlEscape(e.time_mode)}`, + `parameterization = ${tomlEscape(e.parameterization)}`, + `free_phase = ${e.free_phase}`, + `leakage = ${e.leakage}`, `target = ${tomlEscape(e.target)}`, - `objective = ${tomlEscape(e.objective)}`, - `constraints = [${e.constraints.map(tomlEscape).join(", ")}]`, - `recorded = ${tomlEscape(isoNow(now))}`, + `robustness = { kind = ${tomlEscape(e.robustness.kind)}, params = ${inlineMixed(e.robustness.params)} }`, ]; + if (e.time_params !== undefined) lines.push(`time_params = ${inlineNum(e.time_params)}`); + if (e.leakage_params !== undefined) lines.push(`leakage_params = ${inlineNum(e.leakage_params)}`); + if (e.notes !== undefined) lines.push(`notes = ${tomlEscape(e.notes)}`); + lines.push(`recorded = ${tomlEscape(isoNow(now))}`); + // Array-of-tables + [formulation.solve] MUST follow all [formulation] scalar + // keys (TOML: no scalar key may be added after a sub-table opens). + for (const o of e.objectives) { + lines.push("", "[[formulation.objectives]]", `kind = ${tomlEscape(o.kind)}`); + if (o.label !== undefined) lines.push(`label = ${tomlEscape(o.label)}`); + lines.push(`params = ${inlineNum(o.params)}`); + } + for (const c of e.constraints) { + lines.push("", "[[formulation.constraints]]", `kind = ${tomlEscape(c.kind)}`); + if (c.label !== undefined) lines.push(`label = ${tomlEscape(c.label)}`); + lines.push(`params = ${inlineNum(c.params)}`); + } // [formulation.solve] sub-table (spec A) — MUST follow all [formulation] // scalar keys (TOML: no keys added to a table after a sub-table opens). if (e.solve) { diff --git a/packages/extension/test/amicode_tools.test.ts b/packages/extension/test/amicode_tools.test.ts index 87e8b8aa..4fb14957 100644 --- a/packages/extension/test/amicode_tools.test.ts +++ b/packages/extension/test/amicode_tools.test.ts @@ -49,10 +49,15 @@ const SYS: SystemEntity = { }; const FORM: FormulationEntity = { - problem: "gate_synthesis", + trajectory_type: "gate", + time_mode: "fixed", + parameterization: "smooth", + robustness: { kind: "none", params: {} }, + free_phase: false, + leakage: false, target: "X", - objective: "unitary infidelity", - constraints: ["amplitude bound (drive_max)", "smoothness"], + objectives: [], + constraints: [{ kind: "bounds", params: {}, label: "amplitude bound (drive_max)" }], }; describe("systemToml", () => { @@ -93,26 +98,21 @@ describe("systemToml", () => { }); describe("formulationToml", () => { - it("round-trips problem/target/objective/constraints under [formulation]", () => { + it("round-trips the structured facets under [formulation]", () => { const doc = parse(formulationToml(FORM)) as any; - expect(doc.formulation.problem).toBe("gate_synthesis"); + expect(doc.formulation.trajectory_type).toBe("gate"); expect(doc.formulation.target).toBe("X"); - expect(doc.formulation.objective).toBe("unitary infidelity"); - expect(doc.formulation.constraints).toEqual(FORM.constraints); + expect(doc.formulation.robustness).toEqual({ kind: "none", params: {} }); + expect(doc.formulation.constraints[0].kind).toBe("bounds"); expect(Number.isNaN(Date.parse(doc.formulation.recorded))).toBe(false); }); it("escapes quotes, backslashes, and newlines in string values (round-trip exact)", () => { const nasty = 'say "hi" \\ then\nnewline\ttab'; - const doc = parse(formulationToml({ ...FORM, target: nasty, constraints: [nasty] })) as any; + const doc = parse(formulationToml({ ...FORM, target: nasty })) as any; expect(doc.formulation.target).toBe(nasty); - expect(doc.formulation.constraints).toEqual([nasty]); }); - it("rejects an empty or whitespace-only target", () => { - expect(() => formulationToml({ ...FORM, target: "" })).toThrow(/target/); - expect(() => formulationToml({ ...FORM, target: " " })).toThrow(/target/); - }); - it("rejects an empty problem", () => { - expect(() => formulationToml({ ...FORM, problem: "" })).toThrow(/problem/); + it("rejects an unknown enum value", () => { + expect(() => formulationToml({ ...FORM, trajectory_type: "bogus" as any })).toThrow(/trajectory_type/); }); }); @@ -124,7 +124,7 @@ describe("validateSystem / validateFormulation", () => { it("name the offending field in each problem message", () => { expect(validateSystem({ ...SYS, platform: "" as any }).join(" ")).toMatch(/platform/); expect(validateSystem({ ...SYS, levels: 1 }).join(" ")).toMatch(/levels/); - expect(validateFormulation({ ...FORM, target: "" }).join(" ")).toMatch(/target/); + expect(validateFormulation({ ...FORM, time_mode: "nope" as any }).join(" ")).toMatch(/time_mode/); }); }); @@ -262,10 +262,15 @@ describe("opened entity model (spec A)", () => { }); it("round-trips formulation.solve through TOML", () => { const f: FormulationEntity = { - problem: "min_time", + trajectory_type: "gate", + time_mode: "min_time", + parameterization: "smooth", + robustness: { kind: "none", params: {} }, + free_phase: false, + leakage: false, target: "CZ", - objective: "unitary infidelity", - constraints: ["amplitude bound"], + objectives: [], + constraints: [{ kind: "dt_bounds", params: {} }], solve: { T: 10, N: 50, max_iter: 60, integrator: "MagnusGL4" }, }; const parsed = parse(formulationToml(f)) as any; diff --git a/packages/extension/test/formulation.test.ts b/packages/extension/test/formulation.test.ts index 92a4252a..a1f9fe24 100644 --- a/packages/extension/test/formulation.test.ts +++ b/packages/extension/test/formulation.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; -import { normalizeFormulation, updateFormulation, validateFormulation, formulationWarnings } from "../opencode-plugin/entities"; +import { parse } from "smol-toml"; +import { normalizeFormulation, updateFormulation, validateFormulation, formulationWarnings, formulationToml } from "../opencode-plugin/entities"; const corpus = JSON.parse( readFileSync(new URL("./fixtures/formulation-migration.json", import.meta.url), "utf8"), @@ -73,3 +74,32 @@ describe("formulationWarnings", () => { expect(formulationWarnings(e).some((x) => /free_phase/.test(x))).toBe(false); // undefined N → skipped }); }); + +describe("formulationToml round-trip (§8)", () => { + it("round-trips modes, inline param bags, and array-of-table sets", () => { + const e = normalizeFormulation({ + trajectory_type: "gate", + time_mode: "min_time", + time_params: { final_fidelity: 0.999, D: 100 }, + parameterization: "cubic_spline", + robustness: { kind: "ensemble", params: { n_systems: 3 } }, + free_phase: true, + leakage: true, + leakage_params: { value: 0.001, cost: 0.01 }, + target: "CZ", + objectives: [{ kind: "reg_du", params: { R: 0.00001 }, label: "smooth" }], + constraints: [{ kind: "bounds", params: {} }, { kind: "dt_bounds", params: {} }], + solve: { T: 0.5, N: 51, max_iter: 500, integrator: "MagnusGL4" }, + }); + const doc = parse(formulationToml(e)) as any; + expect(doc.formulation.trajectory_type).toBe("gate"); + expect(doc.formulation.time_mode).toBe("min_time"); + expect(doc.formulation.time_params).toEqual({ final_fidelity: 0.999, D: 100 }); + expect(doc.formulation.robustness).toEqual({ kind: "ensemble", params: { n_systems: 3 } }); + expect(doc.formulation.free_phase).toBe(true); + expect(doc.formulation.leakage_params).toEqual({ value: 0.001, cost: 0.01 }); + expect(doc.formulation.objectives).toEqual([{ kind: "reg_du", params: { R: 0.00001 }, label: "smooth" }]); + expect(doc.formulation.constraints.map((c: any) => c.kind)).toEqual(["bounds", "dt_bounds"]); + expect(doc.formulation.solve.integrator).toBe("MagnusGL4"); + }); +}); From fa044bd934dd6f064b852aa5b8e56659dd1d9c37 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 18:59:14 -0400 Subject: [PATCH 16/49] feat(amicode): amicode_formulate typed facets + normalize both read sites + warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec §3.3 / plan Task 11. amicode_formulate takes typed facets (no legacy string args; legacyJsonSchema-safe item schemas), routes the merge through the pure updateFormulation, and surfaces formulationWarnings(merged, N) on the response (N from the sibling System). recordEntity now normalizes a formulation `before` snapshot too; amicode_solve normalizes its formulation read before re-serializing (spec §3.1.3 all-read-sites-normalize). Tool execute() is not vitest-importable (module side-effects) — behavior verified at Task 17 live; pure merge/warnings/normalize already covered. Parse-clean; suite 483 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode-plugin/amicode_tools.ts | 178 ++++++++++++++---- 1 file changed, 139 insertions(+), 39 deletions(-) diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts index a8a870c2..f5f80a40 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -58,6 +58,14 @@ import { type Topology, type DriveArch, type FormulationEntity, + normalizeFormulation, + updateFormulation, + formulationWarnings, + type FormulationPatch, + type TrajectoryType, + type TimeMode, + type Parameterization, + type Robustness, type RunStub, type DeviceSessionStub, type CalibrationStub, @@ -152,12 +160,17 @@ function recordEntity( source: { tool: string; stage?: string }, ): string { const before0 = readEntityJson>(slug, kind); - // F1 (spec §6): normalize a system `before` snapshot to composite so the diff is - // composite-vs-composite, not a spurious flat→composite restructure on first touch. + // F1 (spec §6 / §3.3): normalize a system OR formulation `before` snapshot so the + // diff is structured-vs-structured, not a spurious legacy→structured restructure + // on first touch. const before = - kind === "system" && before0 !== undefined - ? (normalizeSystem(before0) as unknown as Record) - : before0; + before0 === undefined + ? before0 + : kind === "system" + ? (normalizeSystem(before0) as unknown as Record) + : kind === "formulation" + ? (normalizeFormulation(before0) as unknown as Record) + : before0; const action: "created" | "updated" = before ? "updated" : "created"; writeEntityFiles(slug, kind, toml, JSON.stringify(entity, null, 2) + "\n"); const diff = entityDiff(before, entity); @@ -533,56 +546,139 @@ export const AmicodeTools = async (_input: unknown) => ({ amicode_formulate: { description: - "Record the Formulation entity (interview stages 4–5: PROBLEM + FORMULATION): " + - "problem kind, target, objective, constraints. Bookkeeping only.", + "Record the Formulation entity (interview stages 4–5: PROBLEM + FORMULATION) as TYPED " + + "facets. The primary infidelity objective is DERIVED from trajectory_type + free_phase + " + + "time_mode — do NOT pass it; `objectives` holds only ADDED terms (regularizers, etc.). " + + "Upsert: any omitted facet keeps its existing/default value. Bookkeeping only.", args: { - problem: { - type: "string", - description: 'Problem kind, e.g. "gate_synthesis", "state_prep", "min_time".', + trajectory_type: { + type: ["string", "null"], + description: "ket | multiket | gate | density | multidensity. null keeps existing (default gate).", }, - target: { - type: "string", - description: 'The target, e.g. "X", "H", "sqrt(X)", or a description of the unitary/state.', + time_mode: { + type: ["string", "null"], + description: "fixed | min_time (orthogonal to type). null keeps existing (default fixed).", + }, + time_params: { + type: ["object", "null"], + additionalProperties: { type: "number" }, + description: "{final_fidelity, D} — the min-time fidelity floor + duration weight. null to skip.", + }, + parameterization: { + type: ["string", "null"], + description: "smooth | linear_spline | cubic_spline | bang_bang. null keeps existing (default smooth).", + }, + robustness: { + // Loose object (kind + params) — kept schema-shallow to avoid the nested + // legacyJsonSchema union gotcha; normalized in execute. + type: ["object", "null"], + description: "{ kind: none|ensemble|sensitivity, params: {...} }. null keeps existing (default none).", + }, + free_phase: { + type: ["boolean", "null"], + description: "Virtual-Z free phase (objective-only, never in the ODE). null keeps existing (default false).", }, - objective: { + leakage: { + type: ["boolean", "null"], + description: "Leakage suppression (its sole home — not an objective/constraint kind). null keeps existing.", + }, + leakage_params: { + type: ["object", "null"], + additionalProperties: { type: "number" }, + description: "{value, cost} for the leakage flag. null to skip.", + }, + target: { type: ["string", "null"], - description: 'Objective; null for the default "unitary infidelity".', + description: 'Target gate/state, e.g. "CZ", "H", "|1>". null keeps existing.', + }, + objectives: { + // Array-of-objects. legacyJsonSchema strips "null" only at THIS top level, not + // inside `items` — per-object optionals (label) are expressed by OMITTING from + // `required`, NEVER a nested type:[...,"null"]. Replaces the ADDED-terms set. + type: ["array", "null"], + items: { + type: "object", + properties: { + kind: { type: "string" }, + params: { type: "object", additionalProperties: { type: "number" } }, + label: { type: "string" }, + }, + required: ["kind", "params"], + }, + description: "ADDED objective terms: kind ∈ reg_u|reg_du|reg_ddu|sensitivity|custom. Replaces the set.", }, constraints: { - // Optional nullable array — see the details field above. legacyJsonSchema - // strips "null" → optional singular-typed array (provider-agnostic). type: ["array", "null"], - items: { type: "string" }, - description: 'Constraint list; omit for the default ["amplitude bound (drive_max)"].', + items: { + type: "object", + properties: { + kind: { type: "string" }, + params: { type: "object", additionalProperties: { type: "number" } }, + label: { type: "string" }, + }, + required: ["kind", "params"], + }, + description: + "Typed constraints: kind ∈ bounds|du_bound|ddu_bound|dt_bounds|final_fidelity|calibration_pin|custom. " + + "Replaces the set. final_fidelity is normally derived from time_params, not authored here.", }, }, - async execute(a: { problem: string; target: string; objective?: string | null; constraints?: string[] | null }) { + async execute(a: { + trajectory_type?: string | null; + time_mode?: string | null; + time_params?: Record | null; + parameterization?: string | null; + robustness?: { kind?: string; params?: Record } | null; + free_phase?: boolean | null; + leakage?: boolean | null; + leakage_params?: Record | null; + target?: string | null; + objectives?: FormulationPatch["objectives"] | null; + constraints?: FormulationPatch["constraints"] | null; + }) { const meta = ensureActiveProblem(); const dir = problemDir(meta.slug); const blocked = guardAndRecordStage(problemsDir(), dir, "formulate"); if (blocked) return blocked; - // Preserve any solve sub-object already recorded (stage 6 writes it via - // amicode_solve; re-running formulate must not wipe it). - const existing = readEntityJson(meta.slug, "formulation"); - const entity: FormulationEntity = { - problem: a.problem, - target: a.target, - objective: given(a.objective) ? a.objective : "unitary infidelity", - constraints: - Array.isArray(a.constraints) && a.constraints.length > 0 ? a.constraints : ["amplitude bound (drive_max)"], - }; - if (existing?.solve) entity.solve = existing.solve; - const problems = validateFormulation(entity); + // Upsert onto the existing (legacy-tolerant) entity via the pure merge. + const existing = readEntityJson>(meta.slug, "formulation"); + const patch: FormulationPatch = {}; + if (given(a.trajectory_type)) patch.trajectory_type = a.trajectory_type as TrajectoryType; + if (given(a.time_mode)) patch.time_mode = a.time_mode as TimeMode; + if (given(a.time_params)) patch.time_params = a.time_params; + if (given(a.parameterization)) patch.parameterization = a.parameterization as Parameterization; + if (given(a.robustness)) + patch.robustness = { + kind: (a.robustness.kind ?? "none") as Robustness["kind"], + params: a.robustness.params ?? {}, + }; + if (given(a.free_phase)) patch.free_phase = a.free_phase; + if (given(a.leakage)) patch.leakage = a.leakage; + if (given(a.leakage_params)) patch.leakage_params = a.leakage_params; + if (given(a.target)) patch.target = a.target; + if (given(a.objectives)) patch.objectives = a.objectives; + if (given(a.constraints)) patch.constraints = a.constraints; + + const merged = updateFormulation(existing, patch); + const problems = validateFormulation(merged); if (problems.length) return `Cannot record formulation: ${problems.join("; ")}`; - const sentinel = recordEntity(meta.slug, "formulation", entity as any, formulationToml(entity), { + const sentinel = recordEntity(meta.slug, "formulation", merged as any, formulationToml(merged), { tool: "amicode_formulate", stage: "formulate", }); completeStage(dir, "formulate"); - return ( - `Formulation's locked for "${meta.slug}" — ${entity.problem}, targeting ${entity.target}; ` + - `objective: ${entity.objective}; constraints: ${entity.constraints.join(" · ")}\n\n${sentinel}` - ); + // Surface soft warnings (spec §3.2) — N comes from the sibling System. + const sysRaw = readEntityJson>(meta.slug, "system"); + const componentCount = sysRaw ? normalizeSystem(sysRaw).components.length : undefined; + const warnings = formulationWarnings(merged, componentCount); + const warn = warnings.length ? ` ⚠️ ${warnings.join("; ")}` : ""; + const modes = [ + merged.trajectory_type, + merged.time_mode === "min_time" ? "min-time" : undefined, + merged.robustness.kind !== "none" ? merged.robustness.kind : undefined, + merged.free_phase ? "free-phase" : undefined, + ].filter(Boolean).join(" · "); + return `Formulation's locked for "${meta.slug}" — ${modes}, target ${merged.target}${warn}\n\n${sentinel}`; }, }, @@ -631,8 +727,12 @@ export const AmicodeTools = async (_input: unknown) => ({ // half of #64's formulation_hash). One event, no sentinel (the Run // sentinel below is this call's receipt). if (given(a.T) || given(a.N) || given(a.max_iter) || given(a.integrator)) { - const form = readEntityJson(meta.slug, "formulation"); - if (form) { + const formRaw = readEntityJson>(meta.slug, "formulation"); + if (formRaw) { + // Normalize a possibly-legacy on-disk formulation before re-serializing + // (spec §3.1.3 "all read sites normalize") — else the structured + // formulationToml would reject/misserialize the legacy shape. + const form = normalizeFormulation(formRaw); const solve = { ...(form.solve ?? {}) }; if (given(a.T)) solve.T = a.T; if (given(a.N)) solve.N = a.N; From fe5a714c3f7217dddc3b63371355d3d6ddfe6a31 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 19:11:00 -0400 Subject: [PATCH 17/49] =?UTF-8?q?docs(amicode):=20formulate=20stage=20rewr?= =?UTF-8?q?ite=20+=20Formulation=E2=86=92Piccolo=20authoring=20map=20(spec?= =?UTF-8?q?=20=C2=A77=20/=20Task=2016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md + SCORE.md formulate stages rewritten around the typed facets (trajectory/time/parameterization/robustness + free-phase/leakage flags; derived primary objective; typed constraints). New "Formulation authoring map" section (facet → Piccolo template/kwargs). SCORE version NOT bumped (guidance prose). agents_md.test.ts +1 marker; stage-chain + T-vs-N guardrails preserved. Full amicode suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/AGENTS.md | 44 ++++++++++++++++--- .../extension/scores/pulse-designer/SCORE.md | 23 +++++----- packages/extension/test/agents_md.test.ts | 8 ++++ 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index aeab0eda..bb051215 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -232,13 +232,21 @@ Stages, in order: √X, or an arbitrary single-qubit unitary via the vetted template). Multi-qubit and other-platform gates are **not out of bounds** — they route through the free-tier offer (author from scratch, unvetted, verified), per the scope section. -5. **FORMULATION** — objective and constraints. The vetted template optimizes - unitary infidelity under the amplitude bound `drive_max`; record any further - objectives/constraints the user wants in the Formulation entity as follow-ups - — do not improvise unvetted physics into the script. **Never silently - co-optimize global model parameters** (frequencies, anharmonicities) — if - the user wants that, it's a recorded follow-up, not a tonight-edit. Record via - `amicode_formulate`. +5. **FORMULATION** — the optimization problem as **typed facets**, not prose. + Settle: the **trajectory type** (ket | multiket | gate | density), the **time + mode** (fixed | min-time — orthogonal to type), the **parameterization** + (smooth | linear/cubic spline | bang-bang), and the flags **free-phase** + (virtual-Z; the honest primary metric for entangling gates) and **leakage** + (suppression). The **primary infidelity objective is DERIVED** from the type + + free-phase — do NOT pass it; `objectives` carries only ADDED terms + (regularizers `R_u`/`R_du`, sensitivity). Constraints are **typed** (amplitude + / du / ddu bounds, `dt_bounds`, `final_fidelity`, calibration-pin). Going + **min-time** demotes the infidelity to a hard `final_fidelity` constraint and + needs free Δt (a `dt_bounds` constraint). Still respect the tier: don't + improvise unvetted physics into a vetted script. **Never silently co-optimize + global model parameters** (frequencies, anharmonicities) — that's a recorded + follow-up, not a tonight-edit. Record all of it via `amicode_formulate` (typed + args — it upserts, derives the primary objective, and surfaces soft warnings). 6. **SOLVE PARAMS** — `T`, `N`, `max_iter` (defaults per the regime guidance below); pass them to `amicode_solve` (it records them on the Formulation and writes the Run entity, stamped with the resolved `tier`), then author @@ -282,6 +290,28 @@ Constructor map (guidance, not a lookup you follow blindly): Golden reference skeletons for the canonical cases (2-transmon CZ, Rydberg CZ, cavity+qubit) live in `test/fixtures/composite-skeletons/` — the intended authoring output, snapshot-checked. +## Formulation authoring map (facets → Piccolo template) + +The recorded Formulation facets tell you which Piccolo template + kwargs to author. +Same honesty caveat as the composite map: **authoring-aware bookkeeping, NOT wired into +tier resolution** — a non-stock problem still resolves to the **free tier** and is +**unvetted / re-rollout-checked**. Map each facet: + +| facet | Piccolo authoring | +| --- | --- | +| `trajectory_type` | `KetTrajectory` / `MultiKetTrajectory` / `UnitaryTrajectory` (+`EmbeddedOperator`) / `DensityTrajectory` (+`OpenQuantumSystem`) | +| `parameterization` | `SmoothPulseProblem` / `SplinePulseProblem` (linear\|cubic) / `BangBangPulseProblem` | +| `time_mode: min_time` | wrap the solved problem in `MinimumTimeProblem(qcp; final_fidelity, D, Δt_bounds)` | +| `robustness: ensemble` | `SamplingProblem(qcp, systems; weights)` | +| `robustness: sensitivity` | `UnitarySensitivityObjective` / `AdjointRobustnessObjective` (Piccolissimo) | +| `free_phase` | `…Problem(...; free_phase = true)` — one virtual-Z per component; objective-only | +| `leakage` (flag) | `PiccoloOptions(leakage_constraint = true, leakage_constraint_value, leakage_cost)` | +| constraint `calibration_pin` | `calibration_targets = […]` (pins globals via `fix_global_variable!`) | + +The **primary infidelity objective is derived** from `trajectory_type` + `free_phase` +(min-time makes the min-time term primary and demotes fidelity to a `final_fidelity` +constraint) — author it from the type, never from a stored objective string. + ## Scope & parameter guidance **Transmon: single qubit only via the vetted template.** The bundled vetted diff --git a/packages/extension/scores/pulse-designer/SCORE.md b/packages/extension/scores/pulse-designer/SCORE.md index fba0949e..a38e34f3 100644 --- a/packages/extension/scores/pulse-designer/SCORE.md +++ b/packages/extension/scores/pulse-designer/SCORE.md @@ -47,9 +47,9 @@ stages: - id: formulate emits: [formulation] questions: - - id: objective - prompt: "Objective and constraints? (gate → unitary infidelity; state preparation → ket infidelity to the target state; both under the amplitude bound)" - default: "the standard objective for this problem type" + - id: formulation + prompt: "The problem shape — trajectory type (gate / state-prep / open-system), fixed-time vs min-time, and any robustness or free-phase? (the infidelity objective is DERIVED from the type; constraints default to the amplitude bound)" + default: "a fixed-time gate, free-phase on for entangling gates" memory_hooks: [free-phase-objective-only, pin-globals-first-solve] - id: solve emits: [run, pulse] @@ -227,13 +227,16 @@ Per-stage notes: (invoke `piccolissimo-authoring`) with the platform physics skill (`bosonic` for a cavity). Name the problem for the target (e.g. `cat-state-transmon-cavity`) — the strip slug follows the name, so a wrong name reads as a wrong problem. -5. **formulate** — the objective matches the problem TYPE: **gate synthesis** → - unitary infidelity under the amplitude bound `drive_max` (the vetted template); - **state preparation** → **ket infidelity** to the target state (a `KetTrajectory` - solve). Record any further objectives/constraints as follow-ups in the - Formulation entity — do not improvise unvetted physics into the script. **Never silently co-optimize global model parameters** - (frequencies, anharmonicities) — if the user wants that, it's a recorded - follow-up, not a live edit. Record via `amicode_formulate`. +5. **formulate** — record the problem as **typed facets**: **trajectory type** + (gate → unitary infidelity; state-prep → ket infidelity; open-system → density), + **time mode** (fixed vs min-time), **parameterization**, and the **free-phase** / + **leakage** flags. The infidelity objective is **DERIVED from the type** (+ free-phase) + — don't state it; `objectives` carries only added terms (regularizers). Constraints + are typed (default: the amplitude bound `drive_max`); min-time adds a `final_fidelity` + constraint + needs a `dt_bounds` (free Δt). Do not improvise unvetted physics into a + vetted script. **Never silently co-optimize global model parameters** (frequencies, + anharmonicities) — that's a recorded follow-up, not a live edit. Record via + `amicode_formulate`. 6. **solve** — defaults converge to F > 0.999 in the default regime. `N`: keep ~5–10 steps/ns (`N = 50` suits `T ≈ 10 ns`; `T = 30 ns` → `N ≈ 200`, else the pulse is under-resolved and fidelity diff --git a/packages/extension/test/agents_md.test.ts b/packages/extension/test/agents_md.test.ts index 5adfc850..9462b27d 100644 --- a/packages/extension/test/agents_md.test.ts +++ b/packages/extension/test/agents_md.test.ts @@ -61,6 +61,14 @@ describe("AGENTS.md teaches the D9/D10 script-authoring workflow", () => { expect(AGENTS).not.toMatch(/--system\b/); expect(AGENTS).not.toMatch(/load_pulse/); }); + it("teaches the Formulation → Piccolo authoring map (typed facets)", () => { + expect(AGENTS).toMatch(/Formulation authoring map/); + expect(AGENTS).toMatch(/MinimumTimeProblem/); + expect(AGENTS).toMatch(/SamplingProblem/); + expect(AGENTS).toMatch(/trajectory_type/); + expect(AGENTS).toMatch(/free_phase = true/); + expect(AGENTS).toMatch(/primary infidelity objective is derived/i); + }); }); describe("AGENTS.md pulse-designer interview (Layer 0)", () => { From 486df6384ee26a15c4079e6666aacfef131c4f54 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 7 Jul 2026 00:03:53 -0400 Subject: [PATCH 18/49] =?UTF-8?q?feat(device-mgmt):=20QICK=20job-server=20?= =?UTF-8?q?queue=20contract=20+=20MockJobServer=20(Spec=20A=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- packages/extension/src/qick_job_server.ts | 342 ++++++++++++++++++ .../extension/test/qick_job_server.test.ts | 55 +++ 2 files changed, 397 insertions(+) create mode 100644 packages/extension/src/qick_job_server.ts create mode 100644 packages/extension/test/qick_job_server.test.ts diff --git a/packages/extension/src/qick_job_server.ts b/packages/extension/src/qick_job_server.ts new file mode 100644 index 00000000..edba811e --- /dev/null +++ b/packages/extension/src/qick_job_server.ts @@ -0,0 +1,342 @@ +// ============================================================================ +// QICK job-server (QUEUE) contract — Spec A §2. +// +// A minimal queue-layer contract any QICK job server can satisfy (Schuster's +// multimode `job_server` is impl #1). This is the QUEUE contract (submit / +// queue / history / status / cancel / config-versioning / health) consumed by +// the device view + calibration graph. It is DELIBERATELY distinct from: +// - the 3-verb MEASUREMENT contract (upload_pulse!/trigger!/readout → +// expt_service) the QILC inner loop speaks (Spec B), and +// - Raghav's internal Amicode Scheduler/RunsManager (which queues pulse-design +// *solves* off the runs/index). +// Do not conflate them (§2 reviewer flag). +// +// vscode-free + no Date.now/Math.random anywhere — so vitest runs the whole +// contract headless and deterministically (the run_registry.ts precedent). +// ============================================================================ + +export type JobStatus = "pending" | "running" | "completed" | "failed" | "cancelled"; + +/** Per-adapter OPAQUE experiment blob (§4.1). The `schuster` adapter reads the + * payload as a `job_server` job spec (class/module/config); the `mcp` adapter + * reads it as a named Snowbird MCP tool call. The queue verbs stay uniform; + * only the payload shape is adapter-specific. */ +export interface ExperimentBlob { + adapter: string; + payload: unknown; +} + +export interface SubmitRequest { + user: string; + experiment: ExperimentBlob; + priority?: number; + station_config?: unknown; + config_version_ids?: Record; +} + +/** Job shape — adopted from Schuster verbatim (§2.2), not imposed. */ +export interface Job { + job_id: string; + user: string; + experiment: ExperimentBlob; + status: JobStatus; + priority: number; + created_at?: string; + started_at?: string; + completed_at?: string; + data_file_path?: string; + error_message?: string; + /** Canned/observed result payload (the node's produced params live here). */ + result?: Record; + config_version_ids?: Record; +} + +/** Live queue view — drives idle-detection (§5.1). */ +export interface QueueView { + running?: Job; + pending: Job[]; +} + +/** An immutable calibration snapshot from the config-versioning ledger (§2.1). */ +export interface ConfigVersion { + version_id: string; + type: string; + payload?: unknown; + created_at?: string; + is_main?: boolean; +} + +export interface HealthStats { + pending: number; + running: number; +} + +export interface Health { + ok: boolean; + stats: HealthStats; + /** Advisory entitlement hint (§5.2) — the RUN-TIME truth is package resolution. */ + capabilities?: string[]; + /** Drive-line channels the server reports online (§3.2 drive-lines-online). */ + channels?: string[]; +} + +export interface HistoryFilters { + user?: string; + status?: JobStatus; + limit?: number; +} + +/** Never-reject result envelope (§2.3): every adapter call returns this — a + * dead tunnel or a 500 degrades the view, never crashes the session. The + * in-memory MockJobServer returns DIRECT values (it cannot fail); the HTTP + * adapters (qick_client.ts) return `Result`. */ +export type Result = { ok: true; value: T } | { ok: false; error: string }; + +/** The adapter interface the HTTP clients implement (SchusterJobServer, + * SnowbirdMcpJobServer — qick_client.ts). Every verb is never-reject. */ +export interface AbstractJobServer { + submit(req: SubmitRequest): Promise>; + queue(): Promise>; + history(filters: HistoryFilters): Promise>; + status(jobId: string): Promise>; + cancel(jobId: string): Promise>; + configVersions(type: string): Promise>; + mainConfig(type: string): Promise>; + pushConfig(type: string, payload: unknown): Promise>; + setMain(type: string, versionId: string): Promise>; + health(): Promise>; +} + +// -------------------------------------------------------------------------- +// Never-throw parsers — the HTTP adapters route raw JSON through these so a +// malformed/partial payload degrades to an empty view instead of throwing. +// -------------------------------------------------------------------------- + +function asString(v: unknown): string | undefined { + return typeof v === "string" ? v : undefined; +} +function asNumber(v: unknown, dflt: number): number { + return typeof v === "number" && Number.isFinite(v) ? v : dflt; +} +function asRecord(v: unknown): Record | undefined { + return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : undefined; +} + +const JOB_STATUSES: JobStatus[] = ["pending", "running", "completed", "failed", "cancelled"]; + +/** Parse one loosely-typed job object → Job, or undefined if it lacks a job_id. + * Never throws. */ +export function parseJob(v: unknown): Job | undefined { + const o = asRecord(v); + if (!o) return undefined; + const job_id = asString(o.job_id); + if (!job_id) return undefined; + const expRec = asRecord(o.experiment); + const experiment: ExperimentBlob = { + adapter: asString(expRec?.adapter) ?? asString(o.adapter) ?? "unknown", + payload: expRec?.payload ?? o.expt_config ?? {}, + }; + const status = (JOB_STATUSES as string[]).includes(String(o.status)) ? (o.status as JobStatus) : "pending"; + return { + job_id, + user: asString(o.user) ?? "unknown", + experiment, + status, + priority: asNumber(o.priority, 0), + created_at: asString(o.created_at), + started_at: asString(o.started_at), + completed_at: asString(o.completed_at), + data_file_path: asString(o.data_file_path), + error_message: asString(o.error_message), + result: asRecord(o.result), + config_version_ids: asRecord(o.config_version_ids) as Record | undefined, + }; +} + +/** Parse a `GET /jobs/queue` body → {running, pending[]}. Never throws. */ +export function parseQueue(v: unknown): QueueView { + const o = asRecord(v); + if (!o) return { running: undefined, pending: [] }; + const running = parseJob(o.running); + const pendingRaw = Array.isArray(o.pending) ? o.pending : []; + const pending = pendingRaw.map(parseJob).filter((j): j is Job => j !== undefined); + return { running, pending }; +} + +/** Parse a `GET /jobs/history` body (array) → Job[]. Never throws. */ +export function parseHistory(v: unknown): Job[] { + const arr = Array.isArray(v) ? v : asRecord(v)?.jobs; + if (!Array.isArray(arr)) return []; + return arr.map(parseJob).filter((j): j is Job => j !== undefined); +} + +/** Parse a config-versions body (array) → ConfigVersion[]. Never throws. */ +export function parseConfigVersions(v: unknown): ConfigVersion[] { + const arr = Array.isArray(v) ? v : asRecord(v)?.versions; + if (!Array.isArray(arr)) return []; + const out: ConfigVersion[] = []; + for (const item of arr) { + const o = asRecord(item); + const version_id = asString(o?.version_id); + if (!o || !version_id) continue; + out.push({ + version_id, + type: asString(o.type) ?? "", + payload: o.payload, + created_at: asString(o.created_at), + is_main: o.is_main === true, + }); + } + return out; +} + +/** Parse a single config-version object → ConfigVersion, or undefined. */ +export function parseConfigVersion(v: unknown): ConfigVersion | undefined { + const o = asRecord(v); + const version_id = asString(o?.version_id); + if (!o || !version_id) return undefined; + return { + version_id, + type: asString(o.type) ?? "", + payload: o.payload, + created_at: asString(o.created_at), + is_main: o.is_main === true, + }; +} + +// -------------------------------------------------------------------------- +// MockJobServer — in-memory queue + canned results + a settable capability set. +// All §6 acceptance tests run against it, no hardware. Deterministic: ids come +// from monotonic counters (NO Date.now / Math.random). Methods return DIRECT +// values (it cannot fail) — the never-reject Result envelope is the HTTP +// adapters' concern (qick_client.ts). +// -------------------------------------------------------------------------- + +export interface MockJobServerOptions { + capabilities?: string[]; + channels?: string[]; +} + +interface PendingEntry { + job: Job; + seq: number; +} + +export class MockJobServer { + private jobCounter = 0; + private cfgCounter = 0; + private seqCounter = 0; + private pendingEntries: PendingEntry[] = []; + private runningJob?: Job; + private readonly done: Job[] = []; + private readonly configs = new Map(); + private readonly mains = new Map(); + private readonly capabilities?: string[]; + private readonly channels?: string[]; + + constructor(opts: MockJobServerOptions = {}) { + this.capabilities = opts.capabilities; + this.channels = opts.channels; + } + + /** Pending sorted the way it would run: priority desc, then FIFO (seq asc). */ + private sortedPending(): PendingEntry[] { + return [...this.pendingEntries].sort((a, b) => b.job.priority - a.job.priority || a.seq - b.seq); + } + + async submit(req: SubmitRequest): Promise { + const job: Job = { + job_id: `JOB-${++this.jobCounter}`, + user: req.user, + experiment: req.experiment, + status: "pending", + priority: req.priority ?? 0, + config_version_ids: req.config_version_ids, + }; + this.pendingEntries.push({ job, seq: ++this.seqCounter }); + return { ...job }; + } + + async queue(): Promise { + return { + running: this.runningJob ? { ...this.runningJob } : undefined, + pending: this.sortedPending().map((e) => ({ ...e.job })), + }; + } + + async history(filters: HistoryFilters): Promise { + let jobs = this.done; + if (filters.user !== undefined) jobs = jobs.filter((j) => j.user === filters.user); + if (filters.status !== undefined) jobs = jobs.filter((j) => j.status === filters.status); + const out = jobs.map((j) => ({ ...j })); + return filters.limit !== undefined ? out.slice(-filters.limit) : out; + } + + async status(jobId: string): Promise { + if (this.runningJob?.job_id === jobId) return { ...this.runningJob }; + const pend = this.pendingEntries.find((e) => e.job.job_id === jobId); + if (pend) return { ...pend.job }; + const fin = this.done.find((j) => j.job_id === jobId); + return fin ? { ...fin } : undefined; + } + + async cancel(jobId: string): Promise { + const idx = this.pendingEntries.findIndex((e) => e.job.job_id === jobId); + if (idx === -1) return false; + const [entry] = this.pendingEntries.splice(idx, 1); + this.done.push({ ...entry.job, status: "cancelled" }); + return true; + } + + /** TEST HELPER (not a contract verb): promote the highest-priority pending job + * to running, then finish it with the given outcome (default: completed with + * the supplied result). Returns the finished job, or undefined if idle. */ + async runNext(outcome: { result?: Record; status?: JobStatus; error?: string } = {}): Promise { + const ordered = this.sortedPending(); + if (ordered.length === 0) return undefined; + const head = ordered[0]; + this.pendingEntries = this.pendingEntries.filter((e) => e !== head); + const status: JobStatus = outcome.status ?? (outcome.error ? "failed" : "completed"); + const finished: Job = { + ...head.job, + status, + result: outcome.result, + error_message: outcome.error, + }; + this.runningJob = undefined; + this.done.push(finished); + return { ...finished }; + } + + async configVersions(type: string): Promise { + return (this.configs.get(type) ?? []).map((v) => ({ ...v, is_main: this.mains.get(type) === v.version_id })); + } + + async mainConfig(type: string): Promise { + const mainId = this.mains.get(type); + if (!mainId) return undefined; + const found = (this.configs.get(type) ?? []).find((v) => v.version_id === mainId); + return found ? { ...found, is_main: true } : undefined; + } + + async pushConfig(type: string, payload: unknown): Promise { + const ver: ConfigVersion = { version_id: `CFG-${type}-${++this.cfgCounter}`, type, payload }; + const list = this.configs.get(type) ?? []; + list.push(ver); + this.configs.set(type, list); + return { ...ver }; + } + + async setMain(type: string, versionId: string): Promise { + this.mains.set(type, versionId); + } + + async health(): Promise { + return { + ok: true, + stats: { pending: this.pendingEntries.length, running: this.runningJob ? 1 : 0 }, + capabilities: this.capabilities, + channels: this.channels, + }; + } +} diff --git a/packages/extension/test/qick_job_server.test.ts b/packages/extension/test/qick_job_server.test.ts new file mode 100644 index 00000000..a4569c0b --- /dev/null +++ b/packages/extension/test/qick_job_server.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { MockJobServer, parseQueue, parseHistory, parseConfigVersions } from "../src/qick_job_server"; + +// Spec A §2 — the QICK job-server (QUEUE) contract. Distinct from the 3-verb +// measurement contract (expt_service, Spec B) and from Raghav's internal +// Scheduler/RunsManager. MockJobServer is in-memory + deterministic (counter +// ids, NO Date.now/Math.random) so every §6 acceptance test runs headless. + +describe("QICK job-server (queue) contract", () => { + it("MockJobServer: submit → queue → run → history", async () => { + const js = new MockJobServer(); + const { job_id } = await js.submit({ user: "amico", experiment: { adapter: "mock", payload: {} }, priority: 0 }); + expect((await js.queue()).pending.map((j) => j.job_id)).toContain(job_id); + await js.runNext({ result: { pi_amp: 0.031 } }); + expect((await js.queue()).running).toBeUndefined(); + expect((await js.history({})).some((j) => j.job_id === job_id && j.status === "completed")).toBe(true); + }); + it("idle ⟺ no running AND no pending", async () => { + const js = new MockJobServer(); + expect((await js.queue()).running).toBeUndefined(); + await js.submit({ user: "a", experiment: { adapter: "mock", payload: {} }, priority: 0 }); + const q = await js.queue(); + expect(q.running !== undefined || q.pending.length > 0).toBe(true); + }); + it("capabilities settable (advisory entitlement hint)", async () => { + expect((await new MockJobServer({ capabilities: ["qilc"] }).health()).capabilities).toEqual(["qilc"]); + }); + it("parsers never throw on junk", () => { + expect(parseQueue(undefined)).toEqual({ running: undefined, pending: [] }); + expect(parseHistory("garbage")).toEqual([]); + expect(parseConfigVersions(null)).toEqual([]); + }); + it("priority-then-FIFO ordering is deterministic", async () => { + const js = new MockJobServer(); + const a = await js.submit({ user: "u", experiment: { adapter: "mock", payload: {} }, priority: 0 }); + const b = await js.submit({ user: "u", experiment: { adapter: "mock", payload: {} }, priority: 5 }); + const c = await js.submit({ user: "u", experiment: { adapter: "mock", payload: {} }, priority: 0 }); + // higher priority first (b), then FIFO among equal priority (a before c) + expect((await js.queue()).pending.map((j) => j.job_id)).toEqual([b.job_id, a.job_id, c.job_id]); + // deterministic ids from a counter (no Date.now / Math.random) + expect([a.job_id, b.job_id, c.job_id]).toEqual(["JOB-1", "JOB-2", "JOB-3"]); + const run = await js.runNext(); + expect(run?.job_id).toBe(b.job_id); // the highest priority runs first + }); + it("cancel removes a pending job; config-versioning round-trips", async () => { + const js = new MockJobServer(); + const { job_id } = await js.submit({ user: "u", experiment: { adapter: "mock", payload: {} } }); + expect(await js.cancel(job_id)).toBe(true); + expect((await js.queue()).pending).toHaveLength(0); + const ver = await js.pushConfig("hw", { pi_amp: 0.03 }); + await js.setMain("hw", ver.version_id); + expect((await js.mainConfig("hw"))?.version_id).toBe(ver.version_id); + expect((await js.configVersions("hw")).map((v) => v.version_id)).toContain(ver.version_id); + }); +}); From 6270bf874585bcca3d7b5d634a73140ad3a665f5 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 7 Jul 2026 00:06:53 -0400 Subject: [PATCH 19/49] =?UTF-8?q?feat(device-mgmt):=20calibration=20graph?= =?UTF-8?q?=20loader=20(acyclic)=20+=20total=20evaluate()=20(Spec=20A=20?= =?UTF-8?q?=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- packages/extension/src/calibration_graph.ts | 314 ++++++++++++++++++ .../extension/test/calibration_graph.test.ts | 125 +++++++ .../extension/test/corpus/snowbird-graph.toml | 108 ++++++ 3 files changed, 547 insertions(+) create mode 100644 packages/extension/src/calibration_graph.ts create mode 100644 packages/extension/test/calibration_graph.test.ts create mode 100644 packages/extension/test/corpus/snowbird-graph.toml diff --git a/packages/extension/src/calibration_graph.ts b/packages/extension/src/calibration_graph.ts new file mode 100644 index 00000000..8d8b3323 --- /dev/null +++ b/packages/extension/src/calibration_graph.ts @@ -0,0 +1,314 @@ +import { parse as parseToml } from "smol-toml"; +import type { ExperimentBlob } from "./qick_job_server"; + +// ============================================================================ +// The calibration graph — Spec A §4. A directed ACYCLIC graph after Kelly et +// al. "Physical qubit calibration on a directed acyclic graph" (arXiv:1803.03226, +// "Optimus"): nodes = calibrations, directed edges = dependencies. This is the +// doctrine's "repeated + deterministically formulable = code": the traversal +// AGENT (§7) is deferred, but the ranked-action machinery is deterministic and +// lives here. +// +// Pure + vscode-free (the run_registry.ts precedent) so vitest runs it headless. +// evaluate() is PURE + TOTAL: acyclicity is validated at load, so a topological +// order always exists and the suspect sweep is well-defined. +// ============================================================================ + +/** The single status enum — node state, evaluate() verdict, and the §3.2 qubit + * rollup all use it (no divergent vocabularies). */ +export type NodeStatus = "calibrated" | "stale" | "suspect" | "failed" | "uncharacterized"; + +/** `check` = the cheap check_data action (§4.1); `check_state` is internal to + * propagation and never surfaces as a recommendation. `redesign` is emitted by + * the entitlement path (device_status.ts §5.2), never by evaluate(). */ +export type RecommendedAction = "none" | "check" | "calibrate" | "redesign"; + +/** DISPLAY severity precedence (§3.2): failed > suspect > stale > uncharacterized + * > calibrated. Used for the "worst status wins" combination + the qubit rollup. */ +const SEVERITY: Record = { + calibrated: 0, + uncharacterized: 1, + stale: 2, + suspect: 3, + failed: 4, +}; + +export function worseStatus(a: NodeStatus, b: NodeStatus): NodeStatus { + return SEVERITY[a] >= SEVERITY[b] ? a : b; +} + +export interface Threshold { + metric: string; + max?: number; + min?: number; +} + +export interface GraphNode { + name: string; + depends_on: string[]; + experiment?: ExperimentBlob; + produces: string[]; + ttl_seconds?: number; + impl: "standard" | "qilc"; + /** A qilc node names a standard fallback whose `produces` overlaps (§5.2). */ + fallback?: string; + /** Optional qubit association → the §3.2 per-qubit rollup. */ + qubit?: string; + thresholds?: { check?: Threshold; calibrate?: Threshold }; +} + +export interface CalibrationGraph { + nodes: Map; + /** Topological order: every dependency precedes its dependents. */ + topoOrder: string[]; + /** Longest path from any root (roots = depth 0) — the ranking key. */ + depth(name: string): number; + /** Direct dependents of `name`. */ + children(name: string): string[]; +} + +/** Rolling ops state per node (§4.2, `state.json`). */ +export interface NodeState { + value?: Record; + ts?: string; // ISO8601 + status?: NodeStatus; // last recorded own status (e.g. an experiment reported "failed") + job_id?: string; + config_version?: string; +} + +export interface NodeVerdict { + node: string; + status: NodeStatus; + recommended_action: RecommendedAction; + reason: string; + /** Seconds since last result; +Infinity if uncharacterized (sorts first). */ + ageSeconds: number; + depth: number; + impl: "standard" | "qilc"; + fallback?: string; + qubit?: string; +} + +// -------------------------------------------------------------------------- +// loadGraph — parse TOML (smol-toml), build nodes, validate acyclicity. +// -------------------------------------------------------------------------- + +type LoadResult = { ok: true; graph: CalibrationGraph } | { ok: false; error: string }; + +function asStringArray(v: unknown): string[] { + return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; +} + +function parseThreshold(v: unknown): Threshold | undefined { + if (!v || typeof v !== "object") return undefined; + const o = v as Record; + if (typeof o.metric !== "string") return undefined; + const t: Threshold = { metric: o.metric }; + if (typeof o.max === "number") t.max = o.max; + if (typeof o.min === "number") t.min = o.min; + return t; +} + +function parseExperiment(v: unknown): ExperimentBlob | undefined { + if (!v || typeof v !== "object") return undefined; + const o = v as Record; + if (typeof o.adapter !== "string") return undefined; + return { adapter: o.adapter, payload: o.payload ?? {} }; +} + +/** Kahn topological sort. Returns undefined if a cycle remains (a back-edge). */ +function topoSort(nodes: Map): string[] | undefined { + const indeg = new Map(); + const adj = new Map(); // dep → dependents + for (const name of nodes.keys()) { + indeg.set(name, 0); + adj.set(name, []); + } + for (const node of nodes.values()) { + for (const dep of node.depends_on) { + if (!nodes.has(dep)) continue; // unknown dep — dropped from the edge set (defensive) + adj.get(dep)!.push(node.name); + indeg.set(node.name, (indeg.get(node.name) ?? 0) + 1); + } + } + // Deterministic: process ready nodes in sorted name order. + const ready = [...indeg.entries()].filter(([, d]) => d === 0).map(([n]) => n).sort(); + const order: string[] = []; + while (ready.length > 0) { + const n = ready.shift()!; + order.push(n); + for (const child of adj.get(n)!.slice().sort()) { + const d = (indeg.get(child) ?? 0) - 1; + indeg.set(child, d); + if (d === 0) { + // keep `ready` sorted for determinism + const idx = ready.findIndex((x) => x > child); + if (idx === -1) ready.push(child); + else ready.splice(idx, 0, child); + } + } + } + return order.length === nodes.size ? order : undefined; +} + +export function loadGraph(tomlText: string): LoadResult { + let parsed: Record; + try { + parsed = parseToml(tomlText) as Record; + } catch (e) { + return { ok: false, error: `parse_error: ${(e as Error).message}` }; + } + const nodeTable = parsed.node; + if (!nodeTable || typeof nodeTable !== "object") { + return { ok: false, error: "no_nodes: graph has no [node.*] tables" }; + } + const nodes = new Map(); + for (const [name, raw] of Object.entries(nodeTable as Record)) { + if (!raw || typeof raw !== "object") continue; + const o = raw as Record; + const thr = o.thresholds as Record | undefined; + nodes.set(name, { + name, + depends_on: asStringArray(o.depends_on), + experiment: parseExperiment(o.experiment), + produces: asStringArray(o.produces), + ttl_seconds: typeof o.ttl_seconds === "number" ? o.ttl_seconds : undefined, + impl: o.impl === "qilc" ? "qilc" : "standard", + fallback: typeof o.fallback === "string" ? o.fallback : undefined, + qubit: typeof o.qubit === "string" ? o.qubit : undefined, + thresholds: thr + ? { check: parseThreshold(thr.check), calibrate: parseThreshold(thr.calibrate) } + : undefined, + }); + } + if (nodes.size === 0) return { ok: false, error: "no_nodes: graph has no [node.*] tables" }; + + const order = topoSort(nodes); + if (order === undefined) { + return { ok: false, error: "cycle: the calibration graph has a dependency cycle" }; + } + + // depth = longest path from a root; computed over the topo order. + const depthMap = new Map(); + for (const name of order) { + const node = nodes.get(name)!; + const deps = node.depends_on.filter((d) => nodes.has(d)); + depthMap.set(name, deps.length === 0 ? 0 : 1 + Math.max(...deps.map((d) => depthMap.get(d) ?? 0))); + } + const childMap = new Map(); + for (const name of nodes.keys()) childMap.set(name, []); + for (const node of nodes.values()) + for (const dep of node.depends_on) if (nodes.has(dep)) childMap.get(dep)!.push(node.name); + + const graph: CalibrationGraph = { + nodes, + topoOrder: order, + depth: (name) => depthMap.get(name) ?? 0, + children: (name) => (childMap.get(name) ?? []).slice(), + }; + return { ok: true, graph }; +} + +// -------------------------------------------------------------------------- +// evaluate — pure + total. Precondition: the graph loaded acyclically. +// -------------------------------------------------------------------------- + +const ACTION_FOR: Record = { + calibrated: "none", + stale: "check", + suspect: "check", + uncharacterized: "calibrate", + failed: "calibrate", +}; + +/** Own status from a node's own state alone (no propagation). */ +function ownStatus(node: GraphNode, st: NodeState | undefined, nowMs: number): { status: NodeStatus; ageSeconds: number } { + if (!st || !st.ts) return { status: "uncharacterized", ageSeconds: Infinity }; + const tsMs = Date.parse(st.ts); + const ageSeconds = Number.isNaN(tsMs) ? Infinity : (nowMs - tsMs) / 1000; + if (Number.isNaN(tsMs)) return { status: "uncharacterized", ageSeconds: Infinity }; + // an experiment that reported failure is authoritative + if (st.status === "failed") return { status: "failed", ageSeconds }; + // threshold breach (only when the metric is actually present in the value) + const check = node.thresholds?.check; + if (check && st.value && typeof st.value[check.metric] === "number") { + const m = st.value[check.metric] as number; + if ((check.max !== undefined && m > check.max) || (check.min !== undefined && m < check.min)) + return { status: "failed", ageSeconds }; + } + // stale by ttl + if (node.ttl_seconds !== undefined && ageSeconds > node.ttl_seconds) return { status: "stale", ageSeconds }; + return { status: "calibrated", ageSeconds }; +} + +export function evaluate(graph: CalibrationGraph, state: Record, nowMs: number): NodeVerdict[] { + // Pass 1 — own status. + const own = new Map(); + for (const [name, node] of graph.nodes) own.set(name, ownStatus(node, state[name], nowMs)); + + // Pass 2 — suspect propagation over the topo order. A node whose OWN status is + // calibrated but which has any non-calibrated dependency becomes suspect (a + // parent moved out from under it). suspect only ever replaces calibrated — + // a node with its own problem keeps its own (more actionable) status/action. + const finalStatus = new Map(); + for (const name of graph.topoOrder) { + const node = graph.nodes.get(name)!; + const os = own.get(name)!.status; + if (os !== "calibrated") { + finalStatus.set(name, os); + continue; + } + const anyDepDirty = node.depends_on + .filter((d) => graph.nodes.has(d)) + .some((d) => finalStatus.get(d) !== "calibrated"); + finalStatus.set(name, anyDepDirty ? "suspect" : "calibrated"); + } + + // Pass 3 — verdicts + rank. + const verdicts: NodeVerdict[] = []; + for (const [name, node] of graph.nodes) { + const status = finalStatus.get(name)!; + const { ageSeconds } = own.get(name)!; + verdicts.push({ + node: name, + status, + recommended_action: ACTION_FOR[status], + reason: reasonFor(status, node), + ageSeconds, + depth: graph.depth(name), + impl: node.impl, + fallback: node.fallback, + qubit: node.qubit, + }); + } + // rank: topological depth asc (roots first), then age desc (+∞ first), then name. + verdicts.sort( + (a, b) => + a.depth - b.depth || + cmpAgeDesc(a.ageSeconds, b.ageSeconds) || + (a.node < b.node ? -1 : a.node > b.node ? 1 : 0), + ); + return verdicts; +} + +function cmpAgeDesc(a: number, b: number): number { + if (a === b) return 0; + if (a === Infinity) return -1; + if (b === Infinity) return 1; + return b - a; +} + +function reasonFor(status: NodeStatus, node: GraphNode): string { + switch (status) { + case "calibrated": + return "fresh; all dependencies calibrated"; + case "stale": + return `last result older than ttl (${node.ttl_seconds ?? "∞"}s)`; + case "suspect": + return "a dependency moved since this node last ran"; + case "failed": + return "last check breached its threshold or the experiment failed"; + case "uncharacterized": + return "no recorded result"; + } +} diff --git a/packages/extension/test/calibration_graph.test.ts b/packages/extension/test/calibration_graph.test.ts new file mode 100644 index 00000000..dd37eba3 --- /dev/null +++ b/packages/extension/test/calibration_graph.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadGraph, evaluate, type CalibrationGraph, type NodeState } from "../src/calibration_graph"; + +// Spec A §4 — the deterministic calibration graph (Kelly et al. "Optimus" DAG). +// Cycle rejection at load (§4.1); pure + total evaluate() (§4.3): own-status, +// suspect-propagation over the topo order, verdict + ranked action list. All +// exercised on the Snowbird fixture DAG (§4.4). Fixed NOW → no wall-clock. + +const NOW = Date.parse("2026-07-07T00:00:00Z"); // fixed epoch ms — deterministic +const FIXTURE = readFileSync(join(__dirname, "corpus", "snowbird-graph.toml"), "utf8"); + +function loadOk(): CalibrationGraph { + const r = loadGraph(FIXTURE); + if (!r.ok) throw new Error(`fixture failed to load: ${r.error}`); + return r.graph; +} + +/** Seed every node with a fresh (recent) result → all calibrated. */ +function freshState(g: CalibrationGraph): Record { + const fresh = new Date(NOW - 60_000).toISOString(); // 1 min ago + const state: Record = {}; + for (const name of g.nodes.keys()) state[name] = { value: {}, ts: fresh, status: "calibrated", job_id: `JOB-${name}` }; + return state; +} + +describe("calibration graph — loadGraph (acyclic)", () => { + it("loads the Snowbird fixture and orders dependencies before dependents", () => { + const g = loadOk(); + expect(g.nodes.has("cz_gate")).toBe(true); + expect(g.nodes.get("cz_gate")?.impl).toBe("qilc"); + expect(g.nodes.get("cz_gate")?.fallback).toBe("cz_gate_standard"); + // topo order: every dependency precedes the node that depends on it. + const pos = new Map(g.topoOrder.map((n, i) => [n, i])); + for (const node of g.nodes.values()) + for (const dep of node.depends_on) expect(pos.get(dep)!).toBeLessThan(pos.get(node.name)!); + // roots have depth 0; a leaf is deeper than its parents. + expect(g.depth("resonator_spec")).toBe(0); + expect(g.depth("qubit_spec")).toBe(1); + expect(g.depth("chevron")).toBeGreaterThan(g.depth("readout")); + }); + + it("rejects a graph with a dependency cycle with a typed error (§6 crit 7)", () => { + const cyclic = ` +[node.a] +depends_on = ["c"] +produces = ["x"] +impl = "standard" +[node.b] +depends_on = ["a"] +produces = ["y"] +impl = "standard" +[node.c] +depends_on = ["b"] +produces = ["z"] +impl = "standard" +`; + const r = loadGraph(cyclic); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.toLowerCase()).toContain("cycle"); + }); +}); + +describe("calibration graph — evaluate() (pure + total)", () => { + it("all-fresh state → every node calibrated / action none", () => { + const g = loadOk(); + const verdicts = evaluate(g, freshState(g), NOW); + expect(verdicts.every((v) => v.status === "calibrated")).toBe(true); + expect(verdicts.every((v) => v.recommended_action === "none")).toBe(true); + expect(verdicts.find((v) => v.node === "pi_amp")?.status).toBe("calibrated"); + }); + + it("a stale parent marks its full descendant closure suspect; the parent ranks first (§6 crit 2)", () => { + const g = loadOk(); + const state = freshState(g); + // qubit_spec measured well before its 12h ttl → stale. + state.qubit_spec = { value: {}, ts: new Date(NOW - 100 * 3600_000).toISOString(), status: "calibrated", job_id: "JOB-old" }; + const verdicts = evaluate(g, state, NOW); + const byNode = new Map(verdicts.map((v) => [v.node, v])); + + expect(byNode.get("qubit_spec")?.status).toBe("stale"); + expect(byNode.get("qubit_spec")?.recommended_action).toBe("check"); + // full descendant closure is suspect (§4.3 step 4) + for (const desc of ["pi_amp", "pi_len", "T1", "T2_ramsey", "T2_echo", "readout", "chevron", "cz_gate", "cz_gate_standard"]) + expect(byNode.get(desc)?.status, `${desc} should be suspect`).toBe("suspect"); + // the ancestor above the stale node is untouched + expect(byNode.get("resonator_spec")?.status).toBe("calibrated"); + + // ranked action list: qubit_spec (the moved parent) ranks before its children + const actionable = verdicts.filter((v) => v.recommended_action !== "none").map((v) => v.node); + expect(actionable.indexOf("qubit_spec")).toBeLessThan(actionable.indexOf("pi_amp")); + expect(actionable.indexOf("qubit_spec")).toBeLessThan(actionable.indexOf("chevron")); + }); + + it("empty state → uncharacterized / +∞ age / calibrate; ranked roots-first, name tie-broken (§6 crit 2)", () => { + const g = loadOk(); + const verdicts = evaluate(g, {}, NOW); + expect(verdicts.every((v) => v.status === "uncharacterized")).toBe(true); + expect(verdicts.every((v) => v.recommended_action === "calibrate")).toBe(true); + expect(verdicts.every((v) => v.ageSeconds === Infinity)).toBe(true); + + // roots-first: resonator_spec (depth 0) is the single most-urgent root. + expect(verdicts[0].node).toBe("resonator_spec"); + // deterministic name tie-break among equal (depth, +∞ age): pi_amp before pi_len. + const order = verdicts.map((v) => v.node); + expect(order.indexOf("pi_amp")).toBeLessThan(order.indexOf("pi_len")); + // a leaf never precedes its parent. + expect(order.indexOf("readout")).toBeLessThan(order.indexOf("chevron")); + }); + + it("an explicit failed status → failed / calibrate, and evaluate stays total on unknown nodes in state", () => { + const g = loadOk(); + const state = freshState(g); + state.readout = { value: { readout_fidelity: 0.5 }, ts: new Date(NOW - 60_000).toISOString(), status: "failed", job_id: "JOB-x" }; + state["ghost_node_not_in_graph"] = { value: {}, ts: new Date(NOW).toISOString(), status: "calibrated" }; + const verdicts = evaluate(g, state, NOW); + const byNode = new Map(verdicts.map((v) => [v.node, v])); + expect(byNode.get("readout")?.status).toBe("failed"); + expect(byNode.get("readout")?.recommended_action).toBe("calibrate"); + // chevron descends from readout → suspect; a stray state key is ignored (no throw, no verdict) + expect(byNode.get("chevron")?.status).toBe("suspect"); + expect(byNode.has("ghost_node_not_in_graph")).toBe(false); + }); +}); diff --git a/packages/extension/test/corpus/snowbird-graph.toml b/packages/extension/test/corpus/snowbird-graph.toml new file mode 100644 index 00000000..4fdc5cfa --- /dev/null +++ b/packages/extension/test/corpus/snowbird-graph.toml @@ -0,0 +1,108 @@ +# Snowbird calibration graph (Spec A §4.4) — the worked example DAG. +# resonator_spec → qubit_spec → {pi_amp, pi_len} +# → {T1, T2_ramsey, T2_echo, readout} → chevron +# cz_gate (impl=qilc, fallback=cz_gate_standard) depends on {T2_ramsey, readout} +# +# Snowbird is driven by its QICK MCP server, so every node's `experiment` is an +# `adapter = "mcp"` blob naming a measurement tool (§4.1 per-adapter opaque blob). +# This fixture backs the §6 evaluation acceptance tests — MOCK-only, no hardware. + +[node.resonator_spec] +depends_on = [] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "resonator_spectroscopy" } } +produces = ["resonator_freq"] +ttl_seconds = 86400 +impl = "standard" +[node.resonator_spec.thresholds] +check = { metric = "resonator_freq_drift", max = 0.5 } +calibrate = { metric = "resonator_snr", min = 5.0 } + +[node.qubit_spec] +depends_on = ["resonator_spec"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "qubit_spectroscopy" } } +produces = ["qubit_freq"] +ttl_seconds = 43200 +impl = "standard" +[node.qubit_spec.thresholds] +check = { metric = "qubit_freq_drift", max = 0.2 } +calibrate = { metric = "qubit_snr", min = 4.0 } + +[node.pi_amp] +depends_on = ["qubit_spec"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "amplitude_rabi" } } +produces = ["pi_amp"] +ttl_seconds = 43200 +impl = "standard" +[node.pi_amp.thresholds] +check = { metric = "pi_amp_drift", max = 0.02 } +calibrate = { metric = "rabi_contrast", min = 0.8 } + +[node.pi_len] +depends_on = ["qubit_spec"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "length_rabi" } } +produces = ["pi_len"] +ttl_seconds = 43200 +impl = "standard" + +[node.T1] +depends_on = ["pi_amp", "pi_len"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "t1" } } +produces = ["T1"] +ttl_seconds = 21600 +impl = "standard" + +[node.T2_ramsey] +depends_on = ["pi_amp", "pi_len"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "t2_ramsey" } } +produces = ["T2_ramsey"] +ttl_seconds = 21600 +impl = "standard" + +[node.T2_echo] +depends_on = ["pi_amp", "pi_len"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "t2_echo" } } +produces = ["T2_echo"] +ttl_seconds = 21600 +impl = "standard" + +[node.readout] +depends_on = ["pi_amp", "pi_len"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "readout_optimization" } } +produces = ["readout_fidelity"] +ttl_seconds = 21600 +impl = "standard" + +[node.chevron] +depends_on = ["readout"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "chevron" } } +produces = ["chevron_map"] +ttl_seconds = 21600 +impl = "standard" + +# A qilc (PREMIUM, §5.2) node names a standard fallback so an UNentitled user +# still gets a computable recommendation. +[node.cz_gate] +depends_on = ["T2_ramsey", "readout"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "cz_qilc" } } +produces = ["cz_fidelity"] +ttl_seconds = 21600 +impl = "qilc" +fallback = "cz_gate_standard" + +[node.cz_gate_standard] +depends_on = ["T2_ramsey", "readout"] +qubit = "Q1" +experiment = { adapter = "mcp", payload = { tool = "cz_standard" } } +produces = ["cz_fidelity"] +ttl_seconds = 21600 +impl = "standard" From dc70f8133b139369e1ee19b9b384193e6136e59a Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 7 Jul 2026 00:10:05 -0400 Subject: [PATCH 20/49] =?UTF-8?q?feat(device-mgmt):=20pure=20device=20cali?= =?UTF-8?q?bration=20registry=20=E2=80=94=20state.json/history.jsonl=20(Sp?= =?UTF-8?q?ec=20A=20=C2=A74.2;=20C6/C7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- packages/extension/src/device_registry.ts | 150 ++++++++++++++++++ .../extension/test/device_registry.test.ts | 94 +++++++++++ 2 files changed, 244 insertions(+) create mode 100644 packages/extension/src/device_registry.ts create mode 100644 packages/extension/test/device_registry.test.ts diff --git a/packages/extension/src/device_registry.ts b/packages/extension/src/device_registry.ts new file mode 100644 index 00000000..1be0027e --- /dev/null +++ b/packages/extension/src/device_registry.ts @@ -0,0 +1,150 @@ +import type { NodeState, NodeStatus } from "./calibration_graph"; + +// ============================================================================ +// Device calibration registry — Spec A §4.2 + corrections C6/C7. +// +// On disk (rolling OPS state, NOT vault — §4.2): `state.json` (a JSON map, the +// LATEST value per node) + `history.jsonl` (the append log of every calibration +// result). Node DEFINITIONS + thresholds are durable knowledge and live in the +// vault `graph.toml` (calibration_graph.ts); latest values + their history are +// churning ops state and live here. +// +// This module is PURE + in-memory (the run_registry.ts precedent): parse/ +// serialize helpers + an idempotent registry keyed by node. The FILE I/O lives +// in the poll loop / a manager (the real run_registry ↔ runs_manager split); +// per §2.4 the Amicode TS queue client — never the QILC loop — writes state.json. +// ============================================================================ + +/** A finished calibration job's result event → one `history.jsonl` line and the + * new latest `state.json` entry for its node. */ +export interface CalibrationEvent { + node: string; + value?: Record; + ts: string; // ISO8601 + status: NodeStatus; + job_id: string; + config_version?: string; +} + +const NODE_STATUSES: NodeStatus[] = ["calibrated", "stale", "suspect", "failed", "uncharacterized"]; + +function isRecord(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v); +} + +function toNodeState(o: Record): NodeState { + const st: NodeState = {}; + if (isRecord(o.value)) st.value = o.value; + if (typeof o.ts === "string") st.ts = o.ts; + if (typeof o.status === "string" && (NODE_STATUSES as string[]).includes(o.status)) st.status = o.status as NodeStatus; + if (typeof o.job_id === "string") st.job_id = o.job_id; + if (typeof o.config_version === "string") st.config_version = o.config_version; + return st; +} + +/** Parse a `state.json` body → { node → NodeState }. Never throws: junk, a + * non-object, or a missing file all degrade to {}. */ +export function parseStateJson(text: string | unknown): Record { + let parsed: unknown; + if (typeof text === "string") { + try { + parsed = JSON.parse(text); + } catch { + return {}; + } + } else { + parsed = text; + } + if (!isRecord(parsed)) return {}; + const out: Record = {}; + for (const [node, raw] of Object.entries(parsed)) if (isRecord(raw)) out[node] = toNodeState(raw); + return out; +} + +/** Serialize a node-state map → deterministic `state.json` text (sorted keys so + * a replay produces a byte-identical file — §6 crit 6). */ +export function serializeStateJson(state: Record): string { + const sorted: Record = {}; + for (const k of Object.keys(state).sort()) sorted[k] = state[k]; + return JSON.stringify(sorted, null, 2); +} + +/** Serialize one calibration event → a single `history.jsonl` line. */ +export function historyLine(ev: CalibrationEvent): string { + return JSON.stringify(ev); +} + +/** Parse one `history.jsonl` line → CalibrationEvent, or undefined. Never throws + * (a blank/torn final line heals on the next drain — the run_registry.ts rule). */ +export function parseHistoryLine(line: string): CalibrationEvent | undefined { + if (!line || !line.trim()) return undefined; + let o: unknown; + try { + o = JSON.parse(line); + } catch { + return undefined; + } + if (!isRecord(o)) return undefined; + if (typeof o.node !== "string" || typeof o.job_id !== "string" || typeof o.ts !== "string") return undefined; + const status = typeof o.status === "string" && (NODE_STATUSES as string[]).includes(o.status) ? (o.status as NodeStatus) : "calibrated"; + return { + node: o.node, + job_id: o.job_id, + ts: o.ts, + status, + value: isRecord(o.value) ? o.value : undefined, + config_version: typeof o.config_version === "string" ? o.config_version : undefined, + }; +} + +/** In-memory calibration state, keyed by node. Idempotent by job_id (§6 crit 6): + * replaying a finished-job event is a no-op. */ +export class DeviceRegistry { + private readonly map = new Map(); + /** Applied job_ids — the idempotency key (§6 crit 6, "keyed on job_id"). */ + private readonly seen = new Set(); + + /** Hydrate from a parsed `state.json` map (poll loop reads the file, hands it + * here). The carried job_id primes the dedup set so a replay after reload is + * still a no-op. */ + constructor(initial?: Record) { + if (!initial) return; + for (const [node, st] of Object.entries(initial)) { + this.map.set(node, { ...st }); + if (st.job_id) this.seen.add(st.job_id); + } + } + + /** Apply a finished-job event. Returns true if state changed, false if this + * job_id was already applied (idempotent replay). */ + record(ev: CalibrationEvent): boolean { + if (ev.job_id && this.seen.has(ev.job_id)) return false; + if (ev.job_id) this.seen.add(ev.job_id); + this.map.set(ev.node, { + value: ev.value, + ts: ev.ts, + status: ev.status, + job_id: ev.job_id, + config_version: ev.config_version, + }); + return true; + } + + latest(node: string): NodeState | undefined { + const st = this.map.get(node); + return st ? { ...st } : undefined; + } + + /** The `Record` evaluate() consumes — a deep-ish copy so + * callers can't mutate registry state (the run_registry.ts all()-copies rule). */ + toStateMap(): Record { + const out: Record = {}; + for (const [node, st] of this.map) out[node] = { ...st }; + return out; + } + + /** Serialized `state.json` — byte-stable across replays (§6 crit 6). */ + snapshot(): string { + return serializeStateJson(this.toStateMap()); + } +} diff --git a/packages/extension/test/device_registry.test.ts b/packages/extension/test/device_registry.test.ts new file mode 100644 index 00000000..a58cd4ac --- /dev/null +++ b/packages/extension/test/device_registry.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { + DeviceRegistry, + parseStateJson, + serializeStateJson, + parseHistoryLine, + historyLine, + type CalibrationEvent, +} from "../src/device_registry"; + +// Spec A §4.2 + corrections C6/C7: node state on disk is `state.json` (JSON map, +// latest value per node) + `history.jsonl` (append log). This registry is PURE +// in-memory (the run_registry.ts precedent) — file I/O lives in the poll loop / +// manager (the real run_registry ↔ runs_manager split). §6 crit 6: replaying a +// finished-job event keyed on job_id → zero change to state.json. + +const ev = (node: string, job_id: string, ts: string, extra: Partial = {}): CalibrationEvent => ({ + node, + job_id, + ts, + status: "calibrated", + value: {}, + ...extra, +}); + +describe("state.json / history.jsonl grammar (never-throw)", () => { + it("parses a state.json map into NodeState entries", () => { + const map = parseStateJson( + JSON.stringify({ + pi_amp: { value: { pi_amp: 0.031 }, ts: "2026-07-06T21:00:00Z", status: "calibrated", job_id: "JOB-42", config_version: "CFG-HW-3" }, + }), + ); + expect(map.pi_amp).toMatchObject({ ts: "2026-07-06T21:00:00Z", status: "calibrated", job_id: "JOB-42", config_version: "CFG-HW-3" }); + expect((map.pi_amp.value as { pi_amp: number }).pi_amp).toBe(0.031); + }); + it("degrades to {} on junk / missing (never throws)", () => { + expect(parseStateJson("not json")).toEqual({}); + expect(parseStateJson(undefined)).toEqual({}); + expect(parseStateJson("[1,2,3]")).toEqual({}); // not an object map + }); + it("parses a history.jsonl line; rejects blank/torn lines (heals on next drain)", () => { + const line = historyLine(ev("pi_amp", "JOB-1", "2026-07-06T21:00:00Z", { value: { pi_amp: 0.031 } })); + const back = parseHistoryLine(line); + expect(back).toMatchObject({ node: "pi_amp", job_id: "JOB-1", status: "calibrated" }); + expect(parseHistoryLine("")).toBeUndefined(); + expect(parseHistoryLine(" ")).toBeUndefined(); + expect(parseHistoryLine('{"node":"pi_amp"')).toBeUndefined(); // torn final line + expect(parseHistoryLine('{"ts":"x"}')).toBeUndefined(); // no node/job_id + }); +}); + +describe("DeviceRegistry", () => { + it("record is idempotent by job_id — replaying a finished-job event is a no-op (§6 crit 6)", () => { + const reg = new DeviceRegistry(); + const e = ev("pi_amp", "JOB-1", "2026-07-06T21:00:00Z", { value: { pi_amp: 0.031 } }); + expect(reg.record(e)).toBe(true); + const before = reg.snapshot(); + expect(reg.record(e)).toBe(false); // replay = no change + expect(reg.snapshot()).toBe(before); // state.json unchanged, byte-for-byte + expect(reg.latest("pi_amp")?.job_id).toBe("JOB-1"); + }); + + it("latest returns the newest recorded state; a new job for a node supersedes", () => { + const reg = new DeviceRegistry(); + reg.record(ev("pi_amp", "JOB-1", "2026-07-06T21:00:00Z", { value: { pi_amp: 0.031 } })); + expect(reg.record(ev("pi_amp", "JOB-2", "2026-07-06T22:00:00Z", { value: { pi_amp: 0.029 } }))).toBe(true); + expect(reg.latest("pi_amp")?.job_id).toBe("JOB-2"); + expect((reg.latest("pi_amp")?.value as { pi_amp: number }).pi_amp).toBe(0.029); + }); + + it("hydrates from a parsed state.json and round-trips through serialize/parse", () => { + const reg = new DeviceRegistry(); + reg.record(ev("pi_amp", "JOB-1", "2026-07-06T21:00:00Z")); + reg.record(ev("T1", "JOB-2", "2026-07-06T21:05:00Z", { value: { T1: 55.0 } })); + const text = reg.snapshot(); + const reg2 = new DeviceRegistry(parseStateJson(text)); + expect(reg2.latest("T1")?.job_id).toBe("JOB-2"); + expect(reg2.toStateMap()).toEqual(reg.toStateMap()); + // a re-hydrated registry still dedups a replayed event (job_id carried in state) + expect(reg2.record(ev("pi_amp", "JOB-1", "2026-07-06T21:00:00Z"))).toBe(false); + expect(serializeStateJson(reg2.toStateMap())).toBe(text); + }); + + it("toStateMap yields exactly the Record evaluate() consumes", () => { + const reg = new DeviceRegistry(); + reg.record(ev("pi_amp", "JOB-1", "2026-07-06T21:00:00Z", { status: "calibrated" })); + const map = reg.toStateMap(); + expect(Object.keys(map)).toEqual(["pi_amp"]); + expect(map.pi_amp.status).toBe("calibrated"); + // returns a COPY — callers can't mutate registry state + (map.pi_amp as { status: string }).status = "failed"; + expect(reg.latest("pi_amp")?.status).toBe("calibrated"); + }); +}); From 0279c83db8f9000be33f05d83e49cf3bddba6781 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 7 Jul 2026 00:15:16 -0400 Subject: [PATCH 21/49] =?UTF-8?q?feat(device-mgmt):=20device-status=20proj?= =?UTF-8?q?ection=20+=20queue-aware=20nextActions=20+=20entitlement=20seam?= =?UTF-8?q?=20(Spec=20A=20=C2=A73,=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- packages/extension/src/device_status.ts | 183 ++++++++++++++++++ packages/extension/test/device_status.test.ts | 146 ++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 packages/extension/src/device_status.ts create mode 100644 packages/extension/test/device_status.test.ts diff --git a/packages/extension/src/device_status.ts b/packages/extension/src/device_status.ts new file mode 100644 index 00000000..85563a37 --- /dev/null +++ b/packages/extension/src/device_status.ts @@ -0,0 +1,183 @@ +import { + evaluate, + worseStatus, + type CalibrationGraph, + type NodeState, + type NodeStatus, + type NodeVerdict, + type RecommendedAction, +} from "./calibration_graph"; +import type { ConfigVersion, QueueView } from "./qick_job_server"; + +// ============================================================================ +// Device-status projection (Spec A §3.2) + queue-aware next-actions + the +// entitlement seam (§5). All PURE — no vscode, no fetch. The device view renders +// these objects; the §6 tests assert the objects directly (the Kobalte-SSR +// untestability lesson). Entitlement here is a RESOLVED boolean passed in — +// the authoritative package-resolution predicate lives in qick_client.ts +// (isQilcEntitled), reconciled per correction C10 so there is no duplicate. +// ============================================================================ + +export interface DriveLine { + id: string; + target?: string; + kind?: string; +} + +export interface DriveLineStatus extends DriveLine { + online: boolean; +} + +export interface QubitRollup { + qubit: string; + /** Worst-status-wins over that qubit's nodes (§3.2). uncharacterized if none. */ + status: NodeStatus; + nodeCount: number; +} + +export interface MetricReading { + value: number; + ts?: string; + ageSeconds: number; + status: NodeStatus; + node: string; +} + +/** The object the device view renders (§3.2) — derived on demand, never git-churned. */ +export interface DeviceStatus { + driveLines: DriveLineStatus[]; + qubits: QubitRollup[]; + /** Latest numeric produced params (T1/T2/fidelity/…) with age + status. Only + * present for MEASURED nodes — never a fabricated number (§3.2 honesty rule). */ + metrics: Record; + /** main_config values ∪ per-node produced params. */ + calibrationParams: Record; + /** The full ranked verdict set (§4.3) — feeds the view's node list. */ + nodes: NodeVerdict[]; +} + +export interface BuildDeviceStatusArgs { + graph: CalibrationGraph; + state: Record; + now: number; + driveLines: DriveLine[]; + /** Device qubits (from the card) — a qubit with no graph node rolls up + * uncharacterized (honesty). Defaults to the qubits named on graph nodes. */ + qubits?: string[]; + /** Channels the server's health() reports online. Absent/empty → all offline + * (a dead server degrades honestly, §6 crit 5 projection side). */ + onlineChannels?: string[]; + mainConfig?: ConfigVersion; +} + +export function buildDeviceStatus(args: BuildDeviceStatusArgs): DeviceStatus { + const { graph, state, now, driveLines, onlineChannels, mainConfig } = args; + const verdicts = evaluate(graph, state, now); + const byNode = new Map(verdicts.map((v) => [v.node, v])); + + // drive lines online + const online = new Set(onlineChannels ?? []); + const driveLineStatus: DriveLineStatus[] = driveLines.map((d) => ({ ...d, online: online.has(d.id) })); + + // per-qubit rollup (worst status wins) + const qubitSet = args.qubits ?? [...new Set([...graph.nodes.values()].map((n) => n.qubit).filter((q): q is string => !!q))]; + const qubits: QubitRollup[] = qubitSet.map((qubit) => { + const nodeVerdicts = verdicts.filter((v) => v.qubit === qubit); + let status: NodeStatus = "uncharacterized"; // no nodes → honest gap + if (nodeVerdicts.length > 0) { + status = nodeVerdicts.reduce((acc, v) => worseStatus(acc, v.status), "calibrated"); + } + return { qubit, status, nodeCount: nodeVerdicts.length }; + }); + + // latest metrics + produced params (only for measured nodes) + const metrics: Record = {}; + const producedParams: Record = {}; + for (const [name, node] of graph.nodes) { + const st = state[name]; + if (!st || !st.value) continue; + const v = byNode.get(name)!; + for (const key of node.produces) { + const val = st.value[key]; + if (val === undefined) continue; + producedParams[key] = val; + if (typeof val === "number" && Number.isFinite(val)) { + metrics[key] = { value: val, ts: st.ts, ageSeconds: v.ageSeconds, status: v.status, node: name }; + } + } + } + + const mainPayload = mainConfig && mainConfig.payload && typeof mainConfig.payload === "object" ? (mainConfig.payload as Record) : {}; + const calibrationParams: Record = { ...mainPayload, ...producedParams }; + + return { driveLines: driveLineStatus, qubits, metrics, calibrationParams, nodes: verdicts }; +} + +// -------------------------------------------------------------------------- +// Queue-awareness + entitlement (§5). +// -------------------------------------------------------------------------- + +export interface NextAction { + /** The graph node this action pertains to. */ + node: string; + /** The node to actually run — the fallback when a qilc node is locked (§5.2). */ + recommendedNode: string; + status: NodeStatus; + action: RecommendedAction; + impl: "standard" | "qilc"; + /** qilc + unentitled → greyed/locked in the view; never recommends the qilc action. */ + locked: boolean; + reason: string; +} + +export interface NextActionsResult { + /** Idle ⟺ no running job AND no pending job for this device (§5.1). */ + idle: boolean; + ranked_actions: NextAction[]; +} + +export function nextActions( + graph: CalibrationGraph, + state: Record, + queue: QueueView, + now: number, + opts: { entitled: boolean }, +): NextActionsResult { + const verdicts = evaluate(graph, state, now); + const idle = queue.running === undefined && queue.pending.length === 0; + + const ranked: NextAction[] = []; + for (const v of verdicts) { + if (v.recommended_action === "none") continue; // calibrated nodes need no action + const base: NextAction = { + node: v.node, + recommendedNode: v.node, + status: v.status, + action: v.recommended_action, + impl: v.impl, + locked: false, + reason: v.reason, + }; + if (v.impl === "qilc" && !opts.entitled) { + base.locked = true; // rendered greyed; §5.2 access control + if (v.fallback) { + // recommend the standard fallback node instead of the qilc action + base.recommendedNode = v.fallback; + base.action = "calibrate"; + base.reason = `qilc calibration locked (unentitled) → fall back to '${v.fallback}'`; + } else { + base.action = "redesign"; + base.reason = "qilc calibration locked (unentitled), no fallback → redesign the pulse"; + } + } + ranked.push(base); + } + return { idle, ranked_actions: ranked }; +} + +/** Advisory-only capability hint from the job server's health() flags (§5.2). + * This is NOT the entitlement authority — the run-time truth is whether the + * private package resolves (qick_client.isQilcEntitled). */ +export function capabilityHint(feature: string, capabilities: string[] | undefined): boolean { + return capabilities?.includes(feature) ?? false; +} diff --git a/packages/extension/test/device_status.test.ts b/packages/extension/test/device_status.test.ts new file mode 100644 index 00000000..667d5b81 --- /dev/null +++ b/packages/extension/test/device_status.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadGraph, type CalibrationGraph, type NodeState } from "../src/calibration_graph"; +import { buildDeviceStatus, nextActions, capabilityHint } from "../src/device_status"; +import { MockJobServer, type QueueView } from "../src/qick_job_server"; + +// Spec A §3 (device projection) + §5 (queue-awareness + entitlement). All pure — +// the tests assert the PROJECTION OBJECT, never a rendered view (the Kobalte-SSR +// untestability lesson, §6 crit 1). Covers §6 crit 1, 3, 4. + +const NOW = Date.parse("2026-07-07T00:00:00Z"); +const FIXTURE = readFileSync(join(__dirname, "corpus", "snowbird-graph.toml"), "utf8"); +function loadOk(): CalibrationGraph { + const r = loadGraph(FIXTURE); + if (!r.ok) throw new Error(r.error); + return r.graph; +} +const fresh = new Date(NOW - 60_000).toISOString(); +const seed = (value: Record, job: string): NodeState => ({ value, ts: fresh, status: "calibrated", job_id: job }); + +const DRIVE_LINES = [ + { id: "ch0", target: "Q1", kind: "drive" }, + { id: "ch1", target: "Q1", kind: "flux" }, + { id: "ch2", target: "Q2", kind: "drive" }, +]; + +describe("buildDeviceStatus — live projection (§6 crit 1)", () => { + it("carries drive-lines-online, per-qubit rollup, latest metrics with ages, and honest gaps", () => { + const g = loadOk(); + // Seed the chain measured+fresh, but pi_amp is STALE (past its ttl) so its + // measured descendants roll up SUSPECT; leave the cz nodes UNMEASURED so + // their metric stays an honest gap. + const stale = new Date(NOW - 100 * 3600_000).toISOString(); + const state: Record = { + resonator_spec: seed({ resonator_freq: 7.1e9 }, "J1"), + qubit_spec: seed({ qubit_freq: 5.1e9 }, "J2"), + pi_amp: { value: { pi_amp: 0.031 }, ts: stale, status: "calibrated", job_id: "J3" }, + pi_len: seed({ pi_len: 40 }, "J4"), + T1: seed({ T1: 55.2 }, "J5"), + T2_ramsey: seed({ T2_ramsey: 31.0 }, "J6"), + T2_echo: seed({ T2_echo: 48.0 }, "J7"), + readout: seed({ readout_fidelity: 0.97 }, "J8"), + chevron: seed({ chevron_map: 1 }, "J9"), + }; + const status = buildDeviceStatus({ + graph: g, + state, + now: NOW, + driveLines: DRIVE_LINES, + qubits: ["Q1", "Q2"], + onlineChannels: ["ch0", "ch1"], // ch2 offline (not reported) + mainConfig: { version_id: "CFG-1", type: "hw", payload: { qubit_freq: 5.1e9, readout_freq: 7.05e9 } }, + }); + + // drive lines online (from health channels) + expect(status.driveLines.find((d) => d.id === "ch0")?.online).toBe(true); + expect(status.driveLines.find((d) => d.id === "ch2")?.online).toBe(false); + + // per-qubit rollup (worst-status wins): pi_amp stale → its measured + // descendants are SUSPECT, so Q1 rolls up suspect; Q2 has NO nodes → + // uncharacterized (honest gap, not a guess). + const q1 = status.qubits.find((q) => q.qubit === "Q1")!; + const q2 = status.qubits.find((q) => q.qubit === "Q2")!; + expect(q1.status).toBe("suspect"); + expect(q2.status).toBe("uncharacterized"); + + // latest metrics with ages — only for MEASURED nodes; NEVER fabricated. + expect(status.metrics.T1).toMatchObject({ value: 55.2, status: "suspect" }); + expect(status.metrics.T1.ageSeconds).toBeCloseTo(60, 0); + expect(status.metrics.T2_ramsey.value).toBe(31.0); + expect(status.metrics.readout_fidelity.value).toBe(0.97); + expect(status.metrics.cz_fidelity).toBeUndefined(); // cz unmeasured → absent, not a guess + + // calibration params = main_config payload ∪ produced params + expect(status.calibrationParams.readout_freq).toBe(7.05e9); // from main_config + expect(status.calibrationParams.pi_amp).toBe(0.031); // from a produced node value + }); + + it("a dead server (no online channels) → every drive line offline (§6 crit 5 projection side)", () => { + const g = loadOk(); + const status = buildDeviceStatus({ graph: g, state: {}, now: NOW, driveLines: DRIVE_LINES, qubits: ["Q1"] }); + expect(status.driveLines.every((d) => !d.online)).toBe(true); + expect(status.qubits[0].status).toBe("uncharacterized"); // empty state → honest + }); +}); + +describe("nextActions — queue-awareness (§6 crit 3)", () => { + it("empty queue → idle=true + a non-empty ranked action list", async () => { + const g = loadOk(); + const js = new MockJobServer(); + const q = await js.queue(); + const r = nextActions(g, {}, q, NOW, { entitled: true }); + expect(r.idle).toBe(true); + expect(r.ranked_actions.length).toBeGreaterThan(0); + }); + it("a running/pending job for the device → idle=false", async () => { + const g = loadOk(); + const js = new MockJobServer(); + await js.submit({ user: "u", experiment: { adapter: "mock", payload: {} } }); + const q: QueueView = await js.queue(); + expect(nextActions(g, {}, q, NOW, { entitled: true }).idle).toBe(false); + }); +}); + +describe("nextActions — entitlement seam (§6 crit 4)", () => { + const g = loadOk(); + const emptyQueue: QueueView = { running: undefined, pending: [] }; + + it("unentitled → qilc node LOCKED, action redirected to its fallback node", () => { + const r = nextActions(g, {}, emptyQueue, NOW, { entitled: false }); + const cz = r.ranked_actions.find((a) => a.node === "cz_gate")!; + expect(cz.locked).toBe(true); + expect(cz.recommendedNode).toBe("cz_gate_standard"); // fallback, not the qilc node + // it NEVER recommends the qilc action itself + expect(cz.action).not.toBe("redesign"); // has a fallback → calibrate the fallback + }); + + it("unentitled qilc node WITHOUT a fallback → redesign kick", () => { + const cyclicFree = ` +[node.solo] +depends_on = [] +produces = ["f"] +impl = "qilc" +`; + const g2 = loadGraph(cyclicFree); + if (!g2.ok) throw new Error(g2.error); + const r = nextActions(g2.graph, {}, emptyQueue, NOW, { entitled: false }); + const solo = r.ranked_actions.find((a) => a.node === "solo")!; + expect(solo.locked).toBe(true); + expect(solo.action).toBe("redesign"); + }); + + it("entitled → qilc node ranks normally, unlocked, recommends itself", () => { + const r = nextActions(g, {}, emptyQueue, NOW, { entitled: true }); + const cz = r.ranked_actions.find((a) => a.node === "cz_gate")!; + expect(cz.locked).toBe(false); + expect(cz.recommendedNode).toBe("cz_gate"); + }); + + it("capabilityHint is advisory only (health flag), not the authority", () => { + expect(capabilityHint("qilc", ["qilc"])).toBe(true); + expect(capabilityHint("qilc", [])).toBe(false); + expect(capabilityHint("qilc", undefined)).toBe(false); + }); +}); From 3c5f45bd73e0d1ccdb8b069c4eb33a7e473836a4 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 7 Jul 2026 00:17:38 -0400 Subject: [PATCH 22/49] =?UTF-8?q?feat(device-mgmt):=20QICK=20HTTP=20client?= =?UTF-8?q?=20(Schuster/MCP)=20+=20package-entitlement=20predicate=20(Spec?= =?UTF-8?q?=20A=20=C2=A72.3,=C2=A75.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- packages/extension/src/qick_client.ts | 266 ++++++++++++++++++++ packages/extension/test/qick_client.test.ts | 92 +++++++ 2 files changed, 358 insertions(+) create mode 100644 packages/extension/src/qick_client.ts create mode 100644 packages/extension/test/qick_client.test.ts diff --git a/packages/extension/src/qick_client.ts b/packages/extension/src/qick_client.ts new file mode 100644 index 00000000..dc20983f --- /dev/null +++ b/packages/extension/src/qick_client.ts @@ -0,0 +1,266 @@ +import { + parseQueue, + parseHistory, + parseConfigVersions, + parseConfigVersion, + parseJob, + type AbstractJobServer, + type ConfigVersion, + type Health, + type HistoryFilters, + type Job, + type QueueView, + type Result, + type SubmitRequest, +} from "./qick_job_server"; + +// ============================================================================ +// QICK job-server HTTP/MCP clients — Spec A §2.3 (adapters) + §5.2 (entitlement). +// +// SchusterJobServer — impl #1, Node `fetch` → the multimode `job_server` +// FastAPI (submit/queue/history/status/cancel + +// config-versioning + health). Endpoint from the +// environment card's keyed endpoints[role=job_server] +// pointer (never credentials — §3.1 / L0 §2.5). +// SnowbirdMcpJobServer — impl #2, the same verbs onto Snowbird's QICK MCP +// tool calls (experiment.payload.tool). +// +// Both are NEVER-REJECT (§2.3): every call returns Result — a dead tunnel / +// 500 / timeout / malformed body degrades the view, never crashes the session. +// vscode-free (only Node fetch / an injected runner) so it is unit-testable. +// ============================================================================ + +/** Minimal `fetch` surface we use — injectable so tests drive a stub. */ +export type FetchLike = ( + url: string, + init?: { method?: string; headers?: Record; body?: string }, +) => Promise<{ ok: boolean; status: number; json(): Promise; text(): Promise }>; + +export interface SchusterOptions { + /** Base URL resolved from the environment card's endpoints[role=job_server].ptr. */ + baseUrl: string; + /** Injectable for tests; defaults to the global fetch. */ + fetchImpl?: FetchLike; +} + +function ok(value: T): Result { + return { ok: true, value }; +} +function err(error: string): Result { + return { ok: false, error }; +} + +/** Never-throw health parser (kept local — the health shape is client-specific). */ +function parseHealth(v: unknown): Health { + const o = v && typeof v === "object" ? (v as Record) : {}; + const stats = o.stats && typeof o.stats === "object" ? (o.stats as Record) : {}; + return { + ok: o.ok !== false, + stats: { + pending: typeof stats.pending === "number" ? stats.pending : 0, + running: typeof stats.running === "number" ? stats.running : 0, + }, + capabilities: Array.isArray(o.capabilities) ? o.capabilities.filter((c): c is string => typeof c === "string") : undefined, + channels: Array.isArray(o.channels) ? o.channels.filter((c): c is string => typeof c === "string") : undefined, + }; +} + +export class SchusterJobServer implements AbstractJobServer { + constructor(private readonly opts: SchusterOptions) {} + + private get fetchImpl(): FetchLike { + return this.opts.fetchImpl ?? (globalThis.fetch as unknown as FetchLike); + } + + /** One never-reject round trip: fetch → status check → JSON → parse. Any + * failure (network throw, non-2xx, malformed body) → {ok:false, error}. */ + private async req( + method: string, + path: string, + parse: (json: unknown) => T, + body?: unknown, + ): Promise> { + try { + const res = await this.fetchImpl(this.opts.baseUrl + path, { + method, + headers: body !== undefined ? { "content-type": "application/json" } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!res.ok) return err(`http_${res.status}: ${method} ${path}`); + let json: unknown; + try { + json = await res.json(); + } catch (e) { + return err(`parse_error: ${method} ${path}: ${(e as Error).message}`); + } + return ok(parse(json)); + } catch (e) { + return err(`network_error: ${method} ${path}: ${(e as Error).message}`); + } + } + + async submit(reqBody: SubmitRequest): Promise> { + return this.req("POST", "/jobs/submit", (j) => { + const id = j && typeof j === "object" ? (j as Record).job_id : undefined; + return { job_id: typeof id === "string" ? id : "" }; + }, reqBody); + } + + async queue(): Promise> { + return this.req("GET", "/jobs/queue", parseQueue); + } + + async history(filters: HistoryFilters): Promise> { + const qs = new URLSearchParams(); + if (filters.user) qs.set("user", filters.user); + if (filters.status) qs.set("status", filters.status); + if (filters.limit !== undefined) qs.set("limit", String(filters.limit)); + const suffix = qs.toString() ? `?${qs.toString()}` : ""; + return this.req("GET", `/jobs/history${suffix}`, parseHistory); + } + + async status(jobId: string): Promise> { + const r = await this.req("GET", `/jobs/${encodeURIComponent(jobId)}`, (j) => parseJob(j)); + if (!r.ok) return r; + if (!r.value) return err(`parse_error: malformed job ${jobId}`); + return ok(r.value); + } + + async cancel(jobId: string): Promise> { + return this.req("DELETE", `/jobs/${encodeURIComponent(jobId)}`, () => undefined); + } + + async configVersions(type: string): Promise> { + return this.req("GET", `/config/versions?type=${encodeURIComponent(type)}`, parseConfigVersions); + } + + async mainConfig(type: string): Promise> { + return this.req("GET", `/config/main?type=${encodeURIComponent(type)}`, parseConfigVersion); + } + + async pushConfig(type: string, payload: unknown): Promise> { + const r = await this.req("POST", "/config/push", (j) => parseConfigVersion(j), { type, payload }); + if (!r.ok) return r; + if (!r.value) return err("parse_error: push_config returned no version"); + return ok(r.value); + } + + async setMain(type: string, versionId: string): Promise> { + return this.req("POST", "/config/main", () => undefined, { type, version_id: versionId }); + } + + async health(): Promise> { + return this.req("GET", "/health", parseHealth); + } +} + +// -------------------------------------------------------------------------- +// SnowbirdMcpJobServer — the contract verbs onto Snowbird's QICK MCP tool calls. +// Snowbird's MCP surface is a set of SYNCHRONOUS named measurement tools, so +// there is no persistent job queue: submit dispatches the tool named in the +// experiment payload; queue/history are empty (idle-safe); the job store verbs +// are unsupported but degrade gracefully (never-reject). +// -------------------------------------------------------------------------- + +export type McpToolCaller = (tool: string, args: unknown) => Promise; + +export interface SnowbirdMcpOptions { + callTool: McpToolCaller; + capabilities?: string[]; + channels?: string[]; +} + +export class SnowbirdMcpJobServer implements AbstractJobServer { + private jobCounter = 0; + constructor(private readonly opts: SnowbirdMcpOptions) {} + + async submit(reqBody: SubmitRequest): Promise> { + const payload = reqBody.experiment.payload as Record | undefined; + const tool = payload && typeof payload.tool === "string" ? payload.tool : undefined; + if (!tool) return err("bad_request: mcp experiment payload has no `tool`"); + try { + await this.opts.callTool(tool, payload); + return ok({ job_id: `MCP-${++this.jobCounter}` }); + } catch (e) { + return err(`mcp_error: ${tool}: ${(e as Error).message}`); + } + } + + async queue(): Promise> { + // MCP tools are synchronous — no persistent queue → always idle-safe. + return ok({ running: undefined, pending: [] }); + } + + async history(): Promise> { + return ok([]); + } + + async status(jobId: string): Promise> { + return err(`unsupported: mcp adapter has no job store (${jobId})`); + } + + async cancel(): Promise> { + return err("unsupported: mcp tool calls are synchronous, nothing to cancel"); + } + + async configVersions(): Promise> { + return ok([]); + } + + async mainConfig(): Promise> { + return ok(undefined); + } + + async pushConfig(): Promise> { + return err("unsupported: mcp config write-back is a Spec B deliverable"); + } + + async setMain(): Promise> { + return err("unsupported: mcp config write-back is a Spec B deliverable"); + } + + async health(): Promise> { + return ok({ + ok: true, + stats: { pending: 0, running: 0 }, + capabilities: this.opts.capabilities, + channels: this.opts.channels, + }); + } +} + +// -------------------------------------------------------------------------- +// Entitlement predicate (§5.2) — the AUTHORITATIVE gate is package resolution: +// the private Intonatissimo package resolving in the target Julia environment +// (i.e. the qilc strategy can actually run). The job server's health() +// capabilities flag is only an advisory hint (device_status.capabilityHint). +// Reconciled with the scores-tier entitlement axis per correction C10: this is +// a DISTINCT, named predicate (package resolution ≠ scores tier), not a +// duplicate `isEntitled`. +// -------------------------------------------------------------------------- + +/** Injectable command runner — defaults to child_process.execFile. Returns the + * process exit code; never throws in the default impl (spawn errors → code 1). */ +export type CommandRunner = (cmd: string, args: string[]) => Promise<{ code: number }>; + +const defaultRunner: CommandRunner = (cmd, args) => + new Promise((resolve) => { + // Lazy require so the browser/webview bundles never pull node:child_process. + import("node:child_process") + .then(({ execFile }) => { + execFile(cmd, args, (error) => resolve({ code: error ? (typeof error.code === "number" ? error.code : 1) : 0 })); + }) + .catch(() => resolve({ code: 1 })); + }); + +/** True ⟺ the private Intonatissimo package resolves in `juliaProject` (the qilc + * strategy can run). Never throws — any failure (no julia, spawn error) → false. */ +export async function isQilcEntitled(juliaProject: string, run: CommandRunner = defaultRunner): Promise { + const script = `using Pkg; exit(haskey(Pkg.project().dependencies, "Intonatissimo") ? 0 : 1)`; + try { + const { code } = await run("julia", [`--project=${juliaProject}`, "-e", script]); + return code === 0; + } catch { + return false; + } +} diff --git a/packages/extension/test/qick_client.test.ts b/packages/extension/test/qick_client.test.ts new file mode 100644 index 00000000..f0b51821 --- /dev/null +++ b/packages/extension/test/qick_client.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { SchusterJobServer, SnowbirdMcpJobServer, isQilcEntitled, type FetchLike } from "../src/qick_client"; + +// Spec A §2.3 (adapters) + §5.2 (entitlement predicate). Both HTTP adapters are +// NEVER-REJECT: a dead tunnel / 500 / timeout → {ok:false,...}, never a throw +// (§6 crit 5). The entitlement authority is package resolution (Intonatissimo), +// driven here through an injectable command runner — no Julia in the test. + +const jsonRes = (body: unknown, status = 200): ReturnType => + Promise.resolve({ ok: status >= 200 && status < 300, status, json: async () => body, text: async () => JSON.stringify(body) }); + +describe("SchusterJobServer — never-reject HTTP adapter (§6 crit 5)", () => { + it("maps verbs onto the FastAPI and parses results", async () => { + const calls: string[] = []; + const fetchImpl: FetchLike = async (url, init) => { + calls.push(`${init?.method ?? "GET"} ${url}`); + if (url.endsWith("/jobs/submit")) return jsonRes({ job_id: "JOB-9" }); + if (url.endsWith("/jobs/queue")) return jsonRes({ running: null, pending: [{ job_id: "JOB-9", status: "pending" }] }); + if (url.includes("/health")) return jsonRes({ ok: true, stats: { pending: 1, running: 0 }, capabilities: ["qilc"], channels: ["ch0"] }); + return jsonRes({}); + }; + const js = new SchusterJobServer({ baseUrl: "http://localhost:8000", fetchImpl }); + const sub = await js.submit({ user: "amico", experiment: { adapter: "schuster", payload: {} } }); + expect(sub.ok && sub.value.job_id).toBe("JOB-9"); + const q = await js.queue(); + expect(q.ok && q.value.pending.map((j) => j.job_id)).toEqual(["JOB-9"]); + const h = await js.health(); + expect(h.ok && h.value.capabilities).toEqual(["qilc"]); + expect(h.ok && h.value.channels).toEqual(["ch0"]); + expect(calls).toContain("POST http://localhost:8000/jobs/submit"); + }); + + it("a 500 degrades to {ok:false}, never throws", async () => { + const js = new SchusterJobServer({ baseUrl: "http://x", fetchImpl: async () => jsonRes({}, 500) }); + const q = await js.queue(); + expect(q.ok).toBe(false); + if (!q.ok) expect(q.error).toContain("500"); + }); + + it("a network throw / timeout degrades to {ok:false}, never throws", async () => { + const js = new SchusterJobServer({ + baseUrl: "http://x", + fetchImpl: async () => { + throw new Error("ECONNREFUSED"); + }, + }); + const h = await js.health(); + expect(h.ok).toBe(false); + if (!h.ok) expect(h.error.toLowerCase()).toContain("network"); + }); + + it("malformed JSON on a 200 still never throws (degrades to error)", async () => { + const js = new SchusterJobServer({ + baseUrl: "http://x", + fetchImpl: async () => Promise.resolve({ ok: true, status: 200, json: async () => { throw new Error("bad json"); }, text: async () => "oops" }), + }); + const q = await js.queue(); + expect(q.ok).toBe(false); + }); +}); + +describe("SnowbirdMcpJobServer — verbs onto MCP tool calls", () => { + it("submit dispatches the tool named in the experiment payload", async () => { + const seen: Array<{ tool: string; args: unknown }> = []; + const js = new SnowbirdMcpJobServer({ callTool: async (tool, args) => { seen.push({ tool, args }); return { data_file_path: "/tmp/x.h5" }; } }); + const r = await js.submit({ user: "amico", experiment: { adapter: "mcp", payload: { tool: "t2_ramsey", config: { reps: 1000 } } } }); + expect(r.ok).toBe(true); + expect(seen[0].tool).toBe("t2_ramsey"); + }); + it("a throwing tool call degrades to {ok:false}, never throws", async () => { + const js = new SnowbirdMcpJobServer({ callTool: async () => { throw new Error("mcp down"); } }); + const r = await js.submit({ user: "a", experiment: { adapter: "mcp", payload: { tool: "t1" } } }); + expect(r.ok).toBe(false); + }); + it("queue is empty (MCP is synchronous — no persistent queue) → idle-safe", async () => { + const js = new SnowbirdMcpJobServer({ callTool: async () => ({}) }); + const q = await js.queue(); + expect(q.ok && q.value).toEqual({ running: undefined, pending: [] }); + }); +}); + +describe("isQilcEntitled — package-resolution authority (§5.2)", () => { + it("resolvable Intonatissimo → true", async () => { + expect(await isQilcEntitled("/env", async () => ({ code: 0 }))).toBe(true); + }); + it("absent package → false", async () => { + expect(await isQilcEntitled("/env", async () => ({ code: 1 }))).toBe(false); + }); + it("a runner that throws (no julia) → false, never throws", async () => { + expect(await isQilcEntitled("/env", async () => { throw new Error("julia not found"); })).toBe(false); + }); +}); From aac54f4abe4581bd69330adf23c2e141ee2fdaa1 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 7 Jul 2026 00:22:50 -0400 Subject: [PATCH 23/49] =?UTF-8?q?feat(device-mgmt):=20device=20inspector?= =?UTF-8?q?=20webview=20(sibling=20to=20run=20inspector;=20Spec=20A=20?= =?UTF-8?q?=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host WebviewViewProvider + browser view + esbuild target, mirroring run_inspector.ts: device-keyed postMessage protocol, replay-on-reopen buffer, CSP+nonce shell, theme via VS Code CSS vars. Unit-tested under happy-dom (C4/C5). Co-Authored-By: Claude Opus 4.8 --- packages/extension/esbuild.config.mjs | 12 + .../media/ui/views/device_inspector.ts | 276 ++++++++++++++++++ packages/extension/src/device_inspector.ts | 147 ++++++++++ .../extension/src/device_inspector_webview.ts | 20 ++ .../test/device_inspector_view.test.ts | 177 +++++++++++ 5 files changed, 632 insertions(+) create mode 100644 packages/extension/media/ui/views/device_inspector.ts create mode 100644 packages/extension/src/device_inspector.ts create mode 100644 packages/extension/src/device_inspector_webview.ts create mode 100644 packages/extension/test/device_inspector_view.test.ts diff --git a/packages/extension/esbuild.config.mjs b/packages/extension/esbuild.config.mjs index 435af26a..c84df009 100644 --- a/packages/extension/esbuild.config.mjs +++ b/packages/extension/esbuild.config.mjs @@ -58,6 +58,18 @@ const targets = [ logLevel: "info", loader: { ".svg": "text" }, }, + // panel Device Inspector webview bundle (Spec A §3 — sibling to the Run Inspector) + { + entryPoints: ["src/device_inspector_webview.ts"], + bundle: true, + platform: "browser", + target: "es2022", + format: "iife", + outfile: "dist/device_inspector_webview.js", + sourcemap: true, + minify: false, + logLevel: "info", + }, ]; if (watch) { diff --git a/packages/extension/media/ui/views/device_inspector.ts b/packages/extension/media/ui/views/device_inspector.ts new file mode 100644 index 00000000..574cc46b --- /dev/null +++ b/packages/extension/media/ui/views/device_inspector.ts @@ -0,0 +1,276 @@ +// Device Inspector view — a device-focused dashboard (Spec A §3): drive lines +// online, per-qubit rollup, latest T1/T2/fidelities with staleness, calibration +// params, and the ranked action list (qilc items locked/greyed when unentitled). +// +// Sibling to the Run Inspector view (media/ui/views/inspector.ts): same idioms — +// a device-KEYED message protocol shared with device_inspector.ts, ONE pane per +// device, the ACTIVE one shown (host sends `activate`), and each atom/component +// owns its style via constructable stylesheets. Theme-aware via VS Code CSS vars +// (brand.css) — NO external CDN. + +import { defineStyle } from "../style"; +import { mark } from "../atoms/icon"; +import { text } from "../atoms/text"; +import { button } from "../atoms/button"; +import { metric } from "../components/metric"; +import type { DeviceStatus, DriveLineStatus, MetricReading, NextAction, QubitRollup } from "../../../src/device_status"; +import type { NodeStatus } from "../../../src/calibration_graph"; + +defineStyle("device-view", ` + body { margin: 0; height: 100vh; font-family: var(--text-font); + font-size: var(--text-body); color: var(--vscode-foreground); } + .brand { font-weight: 600; } + .device-pane:not(.active) { display: none; } + .device-pane.active { display: flex; } + .device-name { font-weight: 600; } + .section-label { font-size: var(--text-label); text-transform: uppercase; + letter-spacing: 0.6px; font-weight: 600; color: var(--color-dim); + margin-bottom: var(--space-xs); } + /* status badge — the ONE enum, theme-aware colors from VS Code tokens. */ + .sbadge { font-size: var(--text-small); font-weight: 600; letter-spacing: 0.5px; + text-transform: uppercase; padding: var(--space-xs) var(--space-md); + border-radius: var(--border-radius-round); + border: var(--border-width) solid currentColor; + display: inline-flex; align-items: center; gap: var(--space-sm); } + .sbadge::before { content: ""; width: var(--square-dot); height: var(--square-dot); + border-radius: 50%; background: currentColor; } + .sbadge.calibrated { color: var(--color-ok); } + .sbadge.stale { color: var(--color-dim); } + .sbadge.suspect { color: var(--color-run); } + .sbadge.failed { color: var(--color-fail); } + .sbadge.uncharacterized { color: var(--color-dim); opacity: 0.8; } + /* drive-line chips */ + .drive-line { display: inline-flex; align-items: center; gap: var(--space-sm); + padding: var(--space-xs) var(--space-md); + border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius-round); + background: var(--bg-box); font-size: var(--text-small); white-space: nowrap; } + .drive-line::before { content: ""; width: var(--square-dot); height: var(--square-dot); border-radius: 50%; } + .drive-line.online::before { background: var(--color-ok); } + .drive-line.offline { opacity: 0.6; } + .drive-line.offline::before { background: var(--color-dim); } + .drive-line .dl-kind { color: var(--color-dim); } + /* action rows */ + .action { display: flex; align-items: center; gap: var(--space-md); + padding: var(--space-sm) var(--space-md); + border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius); background: var(--bg-box); } + .action .act-node { font-family: var(--text-mono); font-weight: 600; } + .action .act-verb { color: var(--color-dim); font-size: var(--text-small); + text-transform: uppercase; letter-spacing: 0.5px; } + .action.locked { opacity: 0.55; } + .action.locked .act-node::before { content: "🔒 "; } + .action .act-fallback { color: var(--color-dim); font-size: var(--text-small); font-style: italic; } + .metric-age { color: var(--color-dim); font-size: var(--text-small); } + .params { font-family: var(--text-mono); font-size: var(--text-small); color: var(--color-dim); + display: flex; flex-wrap: wrap; gap: var(--space-md); } +`); + +const IDLE_HINT = "No device selected — open a device from the Amicode chat or the device picker."; + +export interface DeviceInspectorView { + el: HTMLElement; + onMessage(msg: unknown): void; +} + +const STATUS_LABEL: Record = { + calibrated: "calibrated", + stale: "stale", + suspect: "suspect", + failed: "failed", + uncharacterized: "uncharacterized", +}; + +const SEVERITY: Record = { calibrated: 0, uncharacterized: 1, stale: 2, suspect: 3, failed: 4 }; + +function statusBadge(status: NodeStatus): HTMLElement { + const el = document.createElement("span"); + el.className = `sbadge ${status}`; + el.textContent = STATUS_LABEL[status]; + return el; +} + +/** Human staleness label, e.g. "3h ago" / "just now" / "never". */ +function ageLabel(ageSeconds: number): string { + if (!Number.isFinite(ageSeconds)) return "never"; + if (ageSeconds < 60) return "just now"; + if (ageSeconds < 3600) return `${Math.round(ageSeconds / 60)}m ago`; + if (ageSeconds < 86400) return `${Math.round(ageSeconds / 3600)}h ago`; + return `${Math.round(ageSeconds / 86400)}d ago`; +} + +interface Pane { + el: HTMLElement; + applyStatus(status: DeviceStatus): void; + applyActions(actions: NextAction[]): void; + setActive(active: boolean): void; +} + +function section(labelText: string): { el: HTMLElement; body: HTMLElement } { + const el = document.createElement("div"); + el.className = "stack gap-sm"; + const label = document.createElement("div"); + label.className = "section-label"; + label.textContent = labelText; + const body = document.createElement("div"); + body.className = "row wrap"; + el.append(label, body); + return { el, body }; +} + +function createPane(device: string, post: (msg: unknown) => void): Pane { + const nameEl = text("device-name", device); + const rollup = document.createElement("span"); + + const brand = document.createElement("div"); + brand.className = "row gap-sm brand"; + brand.append(mark(), text("", "Device Inspector").el); + + const refreshBtn = button("↻ Refresh", () => post({ type: "control", action: "refresh", device })); + refreshBtn.el.classList.add("push-end"); + + const topbar = document.createElement("div"); + topbar.className = "row wrap"; + topbar.append(brand, nameEl.el, rollup, refreshBtn.el); + + const drive = section("Drive lines"); + const qubits = section("Qubits"); + const metrics = section("Latest metrics"); + const params = section("Calibration params"); + params.body.classList.add("params"); + const actions = section("Recommended actions"); + actions.body.className = "stack gap-sm"; // action rows stack vertically + + const el = document.createElement("div"); + el.className = "device-pane stack pad-lg scroll-y"; + el.style.height = "100vh"; + el.append(topbar, drive.el, qubits.el, metrics.el, params.el, actions.el); + + const clear = (n: HTMLElement) => { while (n.firstChild) n.removeChild(n.firstChild); }; + + const overallStatus = (qs: QubitRollup[]): NodeStatus => + qs.reduce((acc, q) => (SEVERITY[q.status] > SEVERITY[acc] ? q.status : acc), "calibrated"); + + return { + el, + setActive(active: boolean): void { + el.classList.toggle("active", active); + }, + applyStatus(status: DeviceStatus): void { + // overall rollup badge + clear(rollup); + rollup.className = "push-end"; + rollup.append(statusBadge(status.qubits.length ? overallStatus(status.qubits) : "uncharacterized")); + + // drive lines + clear(drive.body); + for (const d of status.driveLines) drive.body.append(driveLineChip(d)); + + // qubits + clear(qubits.body); + for (const q of status.qubits) { + const wrap = document.createElement("span"); + wrap.className = "row gap-sm"; + wrap.append(text("mono", q.qubit).el, statusBadge(q.status)); + qubits.body.append(wrap); + } + + // latest metrics (only the measured ones — honesty rule) + clear(metrics.body); + metrics.body.className = "metric-row"; + const keys = Object.keys(status.metrics).sort(); + if (keys.length === 0) metrics.body.append(text("dim", "no measured values yet").el); + for (const key of keys) metrics.body.append(metricCard(key, status.metrics[key])); + + // calibration params + clear(params.body); + const pkeys = Object.keys(status.calibrationParams).sort(); + if (pkeys.length === 0) params.body.append(text("dim", "—").el); + for (const k of pkeys) params.body.append(text("", `${k}=${formatVal(status.calibrationParams[k])}`).el); + }, + applyActions(list: NextAction[]): void { + clear(actions.body); + if (list.length === 0) { actions.body.append(text("dim", "all calibrated — nothing to do").el); return; } + for (const a of list) actions.body.append(actionRow(a)); + }, + }; +} + +function driveLineChip(d: DriveLineStatus): HTMLElement { + const el = document.createElement("span"); + el.className = `drive-line ${d.online ? "online" : "offline"}`; + el.append(text("mono", d.id).el); + if (d.target) el.append(text("dl-kind", `· ${d.target}`).el); + if (d.kind) el.append(text("dl-kind", `· ${d.kind}`).el); + return el; +} + +function metricCard(key: string, m: MetricReading): HTMLElement { + const card = metric(key, { variant: "small" }); + card.value(String(m.value)); + const age = document.createElement("div"); + age.className = "metric-age"; + age.textContent = ageLabel(m.ageSeconds); + card.el.append(age); + return card.el; +} + +function actionRow(a: NextAction): HTMLElement { + const el = document.createElement("div"); + el.className = `action ${a.locked ? "locked" : ""}`.trim(); + el.append(statusBadge(a.status)); + el.append(text("act-node", a.node).el); + el.append(text("act-verb", a.action).el); + if (a.locked && a.recommendedNode !== a.node) el.append(text("act-fallback", `→ ${a.recommendedNode}`).el); + return el; +} + +function formatVal(v: unknown): string { + if (typeof v === "number") return String(v); + if (typeof v === "string") return v; + return JSON.stringify(v); +} + +export function createDeviceInspectorView(post: (msg: unknown) => void): DeviceInspectorView { + const panes = new Map(); + let active: string | undefined; + + const empty = text("dim", IDLE_HINT); + empty.el.className = "pad-lg dim"; + + const el = document.createElement("div"); + el.style.height = "100vh"; + el.append(empty.el); + + const paneFor = (device: string): Pane => { + let p = panes.get(device); + if (!p) { + p = createPane(device, post); + panes.set(device, p); + el.append(p.el); + } + return p; + }; + + const activate = (device: string): void => { + active = device; + empty.el.style.display = "none"; + for (const [id, p] of panes) p.setActive(id === device); + if (!panes.has(device)) paneFor(device).setActive(true); + }; + + return { + el, + onMessage(msg: unknown): void { + if (!msg || typeof msg !== "object") return; + const m = msg as Record; + if (m.type === "ping") { post({ type: "pong", seq: m.seq, t0: m.t0 }); return; } + if (m.type === "activate") { if (typeof m.device === "string") activate(m.device); return; } + const device = typeof m.device === "string" ? m.device : active; + if (!device) return; + const pane = paneFor(device); + if (m.type === "device-status" && m.status) pane.applyStatus(m.status as DeviceStatus); + else if (m.type === "actions" && Array.isArray(m.actions)) pane.applyActions(m.actions as NextAction[]); + }, + }; +} diff --git a/packages/extension/src/device_inspector.ts b/packages/extension/src/device_inspector.ts new file mode 100644 index 00000000..1d0307f2 --- /dev/null +++ b/packages/extension/src/device_inspector.ts @@ -0,0 +1,147 @@ +import * as vscode from "vscode"; +import { inspectorResourceRootDirs } from "./opencode_paths"; +import type { DeviceStatus, NextAction } from "./device_status"; + +// ============================================================================ +// Device Inspector — a panel webview showing a device-focused dashboard (Spec A +// §3): drive lines online, per-qubit rollup, latest T1/T2/fidelities with +// staleness, calibration params, and the ranked action list (qilc items locked +// when unentitled). +// +// SIBLING to Raghav's Run Inspector (run_inspector.ts) — NOT a fork SolidJS +// surface, NOT an edit to run_inspector.ts. Same idioms: a WebviewViewProvider + +// registerDeviceInspector(ctx), a typed DEVICE-keyed postMessage protocol, and a +// per-device replay-on-reopen buffer so a reopened panel rebuilds every pane. +// +// The pure projection/action logic lives in device_status.ts (Task 4) over the +// DeviceRegistry state (Task 3) + the qick_client queue (Task 5); this class is +// only the vscode plumbing + the webview shell (CSP + nonce; theme via VS Code +// CSS vars). The message payloads are DeviceStatus / NextAction[] verbatim. +// ============================================================================ + +let DEVICE_INSPECTOR: DeviceInspectorView | undefined; + +/** Everything replayable about one device's pane — kept current whether or not + * the webview exists, so resolveWebviewView can rebuild the pane on reopen. */ +interface DeviceBuffer { + device: string; + status?: DeviceStatus; + actions?: NextAction[]; +} + +class DeviceInspectorView implements vscode.WebviewViewProvider { + private view?: vscode.WebviewView; + private readonly panes = new Map(); + private activeDevice?: string; + + constructor(private readonly ctx: vscode.ExtensionContext) {} + + private paneFor(device: string): DeviceBuffer { + let p = this.panes.get(device); + if (!p) { + p = { device }; + this.panes.set(device, p); + } + return p; + } + + resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + view.webview.options = { + enableScripts: true, + // Extension assets only — the view renders from message data. + localResourceRoots: inspectorResourceRootDirs(this.ctx.extensionUri.fsPath).map((d) => vscode.Uri.file(d)), + }; + view.webview.html = this.renderHtml(view.webview); + + const msgSub = view.webview.onDidReceiveMessage((msg: { type?: string; action?: string }) => { + if (msg?.type !== "control") return; + if (msg.action === "refresh") void vscode.commands.executeCommand("amicode.device.refresh"); + }); + view.onDidDispose(() => { + this.view = undefined; + msgSub.dispose(); + }); + + // Replay EVERY device pane from its buffer (status then actions), then pick + // the visible pane last (activate is idempotent + last, so it wins). + for (const p of this.panes.values()) this.replayPane(view, p); + if (this.activeDevice) view.webview.postMessage({ type: "activate", device: this.activeDevice }); + } + + private replayPane(view: vscode.WebviewView, p: DeviceBuffer): void { + if (p.status) view.webview.postMessage({ type: "device-status", device: p.device, status: p.status }); + if (p.actions) view.webview.postMessage({ type: "actions", device: p.device, actions: p.actions }); + } + + // -------- public surface used by the poll loop (all device-keyed) -------- + + postDeviceStatus(device: string, status: DeviceStatus): void { + this.paneFor(device).status = status; + if (this.view) this.view.webview.postMessage({ type: "device-status", device, status }); + } + + postActions(device: string, actions: NextAction[]): void { + this.paneFor(device).actions = actions; + if (this.view) this.view.webview.postMessage({ type: "actions", device, actions }); + } + + /** Make `device` the visible pane. Buffered until the webview materializes. */ + activate(device: string): void { + this.paneFor(device); + this.activeDevice = device; + if (this.view) this.view.webview.postMessage({ type: "activate", device }); + } + + reveal(): void { + vscode.commands.executeCommand("amicode.deviceInspector.focus").then(undefined, () => undefined); + } + + private renderHtml(webview: vscode.Webview): string { + const uri = (...parts: string[]) => webview.asWebviewUri(vscode.Uri.joinPath(this.ctx.extensionUri, ...parts)); + const nonce = newNonce(); + // Same security shell as the Run Inspector: CSP + nonce, brand/layout + // stylesheets, and the TS-composed view bundle. Theme rides VS Code CSS vars + // under webview.cspSource (brand.css) — NOT the chat iframe's ?colorScheme=. + // style-src keeps 'unsafe-inline' for runtime element .style / static attrs. + return /* html */ ` + + + + + + + + + + +`; + } +} + +function newNonce(): string { + let s = ""; + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for (let i = 0; i < 32; i++) s += chars[Math.floor(Math.random() * chars.length)]; + return s; +} + +export function registerDeviceInspector(ctx: vscode.ExtensionContext): DeviceInspectorView { + DEVICE_INSPECTOR = new DeviceInspectorView(ctx); + ctx.subscriptions.push( + vscode.window.registerWebviewViewProvider("amicode.deviceInspector", DEVICE_INSPECTOR, { + webviewOptions: { retainContextWhenHidden: true }, + }), + ); + return DEVICE_INSPECTOR; +} + +export function getDeviceInspector(): DeviceInspectorView | undefined { + return DEVICE_INSPECTOR; +} + +export type { DeviceInspectorView }; diff --git a/packages/extension/src/device_inspector_webview.ts b/packages/extension/src/device_inspector_webview.ts new file mode 100644 index 00000000..57155e0a --- /dev/null +++ b/packages/extension/src/device_inspector_webview.ts @@ -0,0 +1,20 @@ +// Device Inspector webview entry — mounts the TS-composed view (media/ui/views/ +// device_inspector.ts). No static markup: the view builds its own DOM from +// atoms/components; brand.css + layout.css are linked by the shell +// (device_inspector.ts). Mirrors inspector_webview.ts. + +import { applyBrandAccent } from "../media/ui/brand_accent"; +import { createDeviceInspectorView } from "../media/ui/views/device_inspector"; + +applyBrandAccent(); // theme-calculated Harmoniqs yellow (brand-wide contract) + +declare function acquireVsCodeApi(): { + postMessage(msg: unknown): void; +}; + +const vscodeApi = acquireVsCodeApi(); +const view = createDeviceInspectorView((msg) => vscodeApi.postMessage(msg)); +document.body.append(view.el); +window.addEventListener("message", (e) => view.onMessage(e.data)); + +vscodeApi.postMessage({ type: "log", text: "device_inspector_webview booted" }); diff --git a/packages/extension/test/device_inspector_view.test.ts b/packages/extension/test/device_inspector_view.test.ts new file mode 100644 index 00000000..b6bdbe1e --- /dev/null +++ b/packages/extension/test/device_inspector_view.test.ts @@ -0,0 +1,177 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createDeviceInspectorView } from "../media/ui/views/device_inspector"; +import { registerDeviceInspector } from "../src/device_inspector"; +import type { DeviceStatus, NextAction } from "../src/device_status"; + +// C4/C5 — the device view IS unit-tested here (happy-dom, mirroring +// inspector_webview_view.test.ts + inspector_view_contract.test.ts): the +// device-keyed pane router, locked-qilc rendering, the shell⇄view CSP seam, and +// host buffering / replay-on-reopen. + +const status = (rollup: DeviceStatus["qubits"]): DeviceStatus => ({ + driveLines: [ + { id: "ch0", target: "Q1", kind: "drive", online: true }, + { id: "ch2", target: "Q2", kind: "drive", online: false }, + ], + qubits: rollup, + metrics: { T1: { value: 55.2, ts: "2026-07-06T21:00:00Z", ageSeconds: 3600, status: "calibrated", node: "T1" } }, + calibrationParams: { pi_amp: 0.031 }, + nodes: [], +}); + +const lockedAction: NextAction = { + node: "cz_gate", + recommendedNode: "cz_gate_standard", + status: "uncharacterized", + action: "calibrate", + impl: "qilc", + locked: true, + reason: "qilc calibration locked (unentitled) → fall back to 'cz_gate_standard'", +}; +const openAction: NextAction = { + node: "pi_amp", + recommendedNode: "pi_amp", + status: "stale", + action: "check", + impl: "standard", + locked: false, + reason: "stale", +}; + +const panes = (v: { el: HTMLElement }) => [...v.el.querySelectorAll(".device-pane")]; +const activePane = (v: { el: HTMLElement }) => v.el.querySelector(".device-pane.active"); +const nameOf = (pane: Element | null | undefined) => pane?.querySelector(".device-name")?.textContent; + +describe("Device Inspector view router (device-keyed panes)", () => { + it("activate shows exactly one pane and hides the empty-state hint", () => { + const v = createDeviceInspectorView(() => {}); + const emptyHint = v.el.firstElementChild as HTMLElement; + expect(emptyHint.style.display).not.toBe("none"); + + v.onMessage({ type: "device-status", device: "snowbird", status: status([{ qubit: "Q1", status: "suspect", nodeCount: 3 }]) }); + v.onMessage({ type: "device-status", device: "multimode", status: status([{ qubit: "Q1", status: "calibrated", nodeCount: 2 }]) }); + expect(panes(v)).toHaveLength(2); + expect(v.el.querySelectorAll(".device-pane.active")).toHaveLength(0); + + v.onMessage({ type: "activate", device: "snowbird" }); + expect(v.el.querySelectorAll(".device-pane.active")).toHaveLength(1); + expect(nameOf(activePane(v))).toContain("snowbird"); + expect(emptyHint.style.display).toBe("none"); + }); + + it("a background device's status never mutates the active pane (no cross-talk)", () => { + const v = createDeviceInspectorView(() => {}); + v.onMessage({ type: "activate", device: "snowbird" }); + v.onMessage({ type: "device-status", device: "snowbird", status: status([{ qubit: "Q1", status: "suspect", nodeCount: 3 }]) }); + const active = activePane(v)!; + expect(nameOf(active)).toContain("snowbird"); + // multimode arrives in the background — its own pane, not snowbird's. + v.onMessage({ type: "device-status", device: "multimode", status: status([{ qubit: "Q1", status: "calibrated", nodeCount: 2 }]) }); + expect(nameOf(activePane(v))).toContain("snowbird"); + expect(panes(v)).toHaveLength(2); + }); + + it("renders drive-line online/offline chips and a locked qilc action greyed", () => { + const v = createDeviceInspectorView(() => {}); + v.onMessage({ type: "activate", device: "snowbird" }); + v.onMessage({ type: "device-status", device: "snowbird", status: status([{ qubit: "Q1", status: "suspect", nodeCount: 3 }]) }); + v.onMessage({ type: "actions", device: "snowbird", actions: [openAction, lockedAction] }); + const pane = activePane(v)!; + // drive-line online state is visible + expect(pane.querySelectorAll(".drive-line.online").length).toBe(1); + expect(pane.querySelectorAll(".drive-line.offline").length).toBe(1); + // the qilc action is rendered locked/greyed; the standard one is not + expect(pane.querySelectorAll(".action.locked").length).toBe(1); + expect(pane.querySelector(".action.locked")?.textContent).toContain("cz_gate"); + // honesty: T1 metric present with its value + expect(pane.textContent).toContain("55.2"); + }); +}); + +// --- Host shell contract + buffering (mirrors inspector_view_contract.test.ts) --- + +const PKG_ROOT = join(__dirname, ".."); + +function makeView() { + const posted: Array> = []; + let disposeCb: () => void = () => undefined; + let capturedHtml = ""; + const view = { + webview: { + options: {}, + cspSource: "vscode-webview://unit", + asWebviewUri: (u: { fsPath?: string }) => ({ toString: () => "vscode-webview://unit/" + (u?.fsPath ?? String(u)) }), + postMessage: (m: Record) => { posted.push(m); }, + onDidReceiveMessage: () => ({ dispose() {} }), + set html(v: string) { capturedHtml = v; }, + get html() { return capturedHtml; }, + }, + onDidDispose: (cb: () => void) => { disposeCb = cb; return { dispose() {} }; }, + }; + return { view, posted, dispose: () => disposeCb(), html: () => capturedHtml }; +} + +function harness() { + const ctx = { extensionUri: { fsPath: PKG_ROOT }, subscriptions: [] as unknown[] }; + return registerDeviceInspector(ctx as never); +} + +describe("Device Inspector shell contract (plumbing ⇄ TS-composed view)", () => { + it("links brand.css + layout.css + the device view bundle under a nonce'd CSP", () => { + const inspector = harness(); + const v = makeView(); + inspector.resolveWebviewView(v.view as never); + const html = v.html(); + expect(html).toMatch(/]+href="vscode-webview:\/\/unit\/[^"]*brand\.css"/); + expect(html).toMatch(/]+href="vscode-webview:\/\/unit\/[^"]*layout\.css"/); + expect(html).toMatch(/