From 70eea56d043e4a96e01546070a4dff44588a7a44 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 9 Jul 2026 11:32:00 -0400 Subject: [PATCH] =?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/); + }); +});