diff --git a/packages/extension/demo/run/result.toml b/packages/extension/demo/run/result.toml index d7c71b6c..68a8fe60 100644 --- a/packages/extension/demo/run/result.toml +++ b/packages/extension/demo/run/result.toml @@ -4,6 +4,8 @@ schema_version = "1" wall_seconds = 109.01594400405884 [params] +system = "transmon" +gate = "X" levels = 3 drive_max = 0.2 T = 10.0 diff --git a/packages/extension/demo/run/run.log b/packages/extension/demo/run/run.log index 6606c166..b7c8128c 100644 --- a/packages/extension/demo/run/run.log +++ b/packages/extension/demo/run/run.log @@ -58,7 +58,7 @@ QuantumControlProblem Hint: show_problem(qcp; detail=:full) for pulse plot + sparsity -AMICODE_PULSE_META drives=2 knots=50 labels="a_1","a_2" bounds=-0.2:0.2,-0.2:0.2 +AMICODE_PULSE_META drives=2 knots=50 labels="u_1","u_2" bounds=-0.2:0.2,-0.2:0.2 AMICODE_ITER iter=0 f=7.930693e+01 inf_pr=3.306e+00 inf_du=4.344e-01 AMICODE_PULSE iter=0 dt=0.204082 a=0.00122269,0.079988,0.0285582,0.0540801,0.159005,-0.163859,0.10975,0.0792598,0.225433,-0.0367432,0.16558,0.14032,0.0462271,0.108334,0.179825,-0.0977399,0.165655,0.246999,-0.0193538,-0.211037,-0.174111,-0.125857,-0.221001,0.215075,-0.0648649,-0.127936,0.127302,0.00942714,-0.00511523,0.0107351,0.149222,0.00920709,-0.024628,-0.0666657,-0.0586522,-0.157342,-0.028419,0.0810429,-0.150415,-0.037007,-0.00844921,-0.0615253,0.176932,-0.0139109,0.138256,0.0532826,0.0636576,-0.0740906,-0.00579776,-0.0402367;-0.0235979,-0.00903938,0.0218624,-0.0386896,0.246838,0.241264,0.0996578,0.17691,0.252771,-0.145362,0.0510349,0.0378336,0.0339014,-0.0113967,0.109632,0.0389645,-0.0589498,-0.0433134,-0.170006,-0.0632173,0.116623,-0.0892016,0.11167,0.168127,0.138323,0.0396419,-0.069816,0.0302567,0.0163749,-0.102325,-0.0864511,0.0495011,-0.0660819,0.107856,0.00929036,0.049109,0.045827,0.178588,-0.196605,-0.0222713,0.0668082,-0.074412,-0.0316369,0.0245491,-0.112651,-0.0198992,0.0551696,0.0887624,-0.010815,-0.177116 diff --git a/packages/extension/esbuild.config.mjs b/packages/extension/esbuild.config.mjs index 6b04467e..d336deef 100644 --- a/packages/extension/esbuild.config.mjs +++ b/packages/extension/esbuild.config.mjs @@ -32,6 +32,18 @@ const targets = [ minify: false, logLevel: "info", }, + // catalog-card dev preview webview bundle (#47 scaffold) + { + entryPoints: ["src/catalog_card_webview.ts"], + bundle: true, + platform: "browser", + target: "es2022", + format: "iife", + outfile: "dist/catalog_card_webview.js", + sourcemap: true, + minify: false, + logLevel: "info", + }, // bottom-panel Run Inspector webview bundle { entryPoints: ["src/inspector_webview.ts"], diff --git a/packages/extension/media/ui/components/catalogcard.ts b/packages/extension/media/ui/components/catalogcard.ts new file mode 100644 index 00000000..6df3b640 --- /dev/null +++ b/packages/extension/media/ui/components/catalogcard.ts @@ -0,0 +1,270 @@ +// Catalog entry card (#47, UX2) — the atom of the catalog. Krishna's p5 +// sketch, top to bottom: chip header → pulse plot (with a quiet plot-attached +// action row — the resume hand-off, unlabeled by design) → metadata / +// pulse-data / high-level-metrics panels → model catalog (sibling chips). +// +// Data contract: `entry` is schema-true (catalog-entry.schema.json fields +// verbatim); `entry.proposed` is the clearly-separated extension block whose +// fields render with the "proposed" treatment (they are the feedback artifact +// for the Krishna field-selection questions — Jack's lane to resolve); +// `pulse` is the optional hydrated block sharing pulseplot's meta/values +// shapes (models a CatalogStore hydrating pulse_path on open). + +import { defineStyle } from "../style"; +import { text } from "../atoms/text"; +import { metric } from "./metric"; +import { chip, type ChipFields } from "./chip"; +import { pulseplot, type PulsePlotMeta, type PulsePlotRecord } from "./pulseplot"; + +defineStyle("catalogcard", ` + .catalogcard { display: flex; flex-direction: column; gap: var(--space-md); + padding: var(--space-lg); min-width: 0; + background: var(--bg-box); + border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius); } + .catalogcard .cc-head { display: flex; align-items: center; gap: var(--space-md); flex-wrap: wrap; } + .catalogcard .cc-plot { min-height: 200px; display: flex; } + .catalogcard .cc-panels { display: grid; gap: var(--space-sm); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } + .catalogcard .cc-panel { border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius); + padding: var(--space-sm) var(--space-md); + display: flex; flex-direction: column; gap: var(--space-xs); } + .catalogcard .cc-kv { display: flex; justify-content: space-between; gap: var(--space-md); + font-size: var(--text-small); min-width: 0; } + .catalogcard .cc-kv .v { font-family: var(--text-mono); overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } + .catalogcard .proposed { border-bottom: 1px dashed var(--color-dim); opacity: 0.75; } + .catalogcard .metric.proposed { border-style: dashed; border-bottom: var(--border-width) dashed var(--border-color-hero); opacity: 0.8; } + .catalogcard .cc-metrics { display: flex; flex-wrap: wrap; gap: var(--space-sm); } + .catalogcard .cc-metrics .metric { flex: 1 1 120px; min-width: 0; } + .catalogcard .cc-siblings { display: flex; gap: var(--space-sm); overflow-x: auto; + padding-bottom: var(--space-xs); } + .catalogcard .cc-actions { display: flex; gap: var(--space-sm); margin-left: auto; } + .catalogcard .cc-actions button { font-family: var(--text-font); font-size: var(--text-small); + padding: var(--space-xs) var(--space-md); cursor: pointer; + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 2px; + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); + background: var(--vscode-button-secondaryBackground, var(--bg-box)); } + .catalogcard .cc-actions button:hover { background: var(--vscode-button-secondaryHoverBackground, var(--bg-box)); } +`); + +/** Schema-true catalog-entry fields (catalog-entry.schema.json v1). */ +export interface CatalogEntry { + schema_version: string; + run_id: string; + lab_id: string; + fidelity: number; + pulse_path: string; + gate?: string; + created_at?: string; + params?: Record; + /** NOT in the schema — proposed extensions, rendered visibly marked. */ + proposed?: { tags?: string[]; index?: number; iterations?: number; wall_seconds?: number; + /** User-assigned system name — human identity over machine family. */ + system_name?: string }; +} + +export interface CardPulse { meta: PulsePlotMeta; record: PulsePlotRecord } + +export interface CatalogCard { + el: HTMLDivElement; +} + +/** The pulse actions — the save → tune → warm-start → promote ladder. + * Tune: refine THIS pulse (resume the chat interview with this run as + * context). Warm-Start: seed a NEW solve from this pulse's trajectory. + * Promote: send the pulse toward hardware (Intonato path; stub until then). + * Vocabulary note for the field-selection round: "promote" also means + * promote-to-catalog elsewhere (the prompt that opens this card). */ +export const PULSE_ACTIONS: ReadonlyArray<{ id: string; label: string }> = [ + { id: "tune", label: "Tune" }, + { id: "warmstart", label: "Warm-Start" }, + { id: "promote", label: "Promote" }, +]; + +export interface CatalogCardOpts { + pulse?: CardPulse; + siblings?: ChipFields[]; + /** Wired by the host; the row renders only when provided. */ + onAction?: (id: string) => void; +} + +export function catalogcard(entry: CatalogEntry, opts: CatalogCardOpts = {}): CatalogCard { + const el = document.createElement("div"); + el.className = "catalogcard"; + + // 1 — chip header + const head = document.createElement("div"); + head.className = "cc-head"; + // User-named system wins the identity slot (proposed-marked via `tag` + // styling in the chip); the derived family is the fallback. + const named = entry.proposed?.system_name; + head.append(chip({ gate: entry.gate, system: named ?? systemDescriptor(entry.params), ...entry.proposed }).el, + text("mono small dim", entry.run_id).el); + // Pulse actions live in the header, right-justified — with the identity, + // acting on the pulse it names. VS Code button styling, uniform secondary. + if (opts.onAction) { + const actions = document.createElement("div"); + actions.className = "cc-actions"; + for (const a of PULSE_ACTIONS) { + const b = document.createElement("button"); + b.textContent = a.label; + b.addEventListener("click", () => opts.onAction!(a.id)); + actions.append(b); + } + head.append(actions); + } + el.append(head); + + // 2 — pulse plot (hydrated; degrades to pulseplot's empty state) + const plotHost = document.createElement("div"); + plotHost.className = "cc-plot"; + const plot = pulseplot("Pulse not hydrated — entry carries pulse_path only."); + if (opts.pulse) { + plot.meta(opts.pulse.meta); + plot.update(opts.pulse.record); + } + plotHost.append(plot.el); + el.append(plotHost); + + // 3 — panels: metadata · pulse data · high-level metrics + const panels = document.createElement("div"); + panels.className = "cc-panels"; + panels.append( + panel("metadata", [ + kv("run", entry.run_id), + kv("system", systemDescriptor(entry.params) ?? "—"), + kv("gate", entry.gate ?? "—"), + // the user-assigned name (chip identity) vs the derived family above + ...(entry.proposed?.system_name ? [kv("name", entry.proposed.system_name, true)] : []), + ...(entry.proposed?.tags?.length ? [kv("tags", entry.proposed.tags.join(", "), true)] : []), + kv("created", entry.created_at ?? "—"), + // solver telemetry — provenance, not pulse quality (proposed fields) + ...(entry.proposed?.iterations !== undefined ? [kv("iterations", String(entry.proposed.iterations), true)] : []), + ...(entry.proposed?.wall_seconds !== undefined ? [kv("wall", `${entry.proposed.wall_seconds.toFixed(0)}s`, true)] : []), + ]), + panel("pulse data", [ + kv("pulse", entry.pulse_path.split("/").slice(-2).join("/")), + ...Object.entries(entry.params ?? {}).map(([k, v]) => kv(k, String(v))), + ]), + metricsPanel(entry, opts.pulse), + ); + el.append(panels); + + // 4 — model catalog: sibling chips + if (opts.siblings?.length) { + el.append(text("label-k", "model catalog").el); + const sibs = document.createElement("div"); + sibs.className = "cc-siblings"; + for (const s of opts.siblings) sibs.append(chip(s).el); + el.append(sibs); + } + + return { el }; +} + +/** Hamiltonian-based identity for the chip: the system FAMILY only + * (params.system, e.g. "transmon"). Level counts are modeling resolution, + * not identity — the same device simulated at 3 vs 4 levels is one system — + * so they stay in the pulse-data panel's params rows, not the chip. The + * real structured Hamiltonian-identity key (family + subsystem topology + + * parameters) is a Phase-3 CatalogStore schema decision. */ +function systemDescriptor(params?: Record): string | undefined { + const sys = params?.system; + return typeof sys === "string" ? sys : undefined; +} + +function panel(title: string, rows: HTMLElement[]): HTMLDivElement { + const p = document.createElement("div"); + p.className = "cc-panel"; + p.append(text("label-k", title).el, ...rows); + return p; +} + +function kv(k: string, v: string, proposed = false): HTMLDivElement { + const row = document.createElement("div"); + row.className = "cc-kv"; + row.append(text("dim", k).el, text(proposed ? "v proposed" : "v", v).el); + return row; +} + +function metricsPanel(entry: CatalogEntry, pulse?: CardPulse): HTMLDivElement { + const p = document.createElement("div"); + p.className = "cc-panel"; + p.append(text("label-k", "high-level metrics").el); + + // All four wear the same hero metric styling; provisional ones (definition + // or data source still a domain-owner decision) get a dashed border. + const heroCard = (label: string, value: string, proposed = false): HTMLDivElement => { + const m = metric(label, { hero: true }); + m.value(value); + if (proposed) m.el.classList.add("proposed"); + return m.el; + }; + + const row = document.createElement("div"); + row.className = "cc-metrics"; + p.append(row); + + row.append(heroCard("fidelity", entry.fidelity.toFixed(5))); + + // Gate time — real: params.T (template units are ns). + const T = entry.params?.T; + if (typeof T === "number") row.append(heroCard("gate time", `${T} ns`)); + + // Spectral bandwidth — computed from the hydrated knots: the frequency + // containing 95% of the pulse's AC power (DC/mean removed; ZOH samples at + // 1/dt). Definitional choices (threshold, DC handling, per-drive max) are + // domain-owner calls → proposed-marked, definition in the label. Units GHz + // for the template's ns time base. + const bw = spectralBandwidth(pulse); + if (bw !== undefined) row.append(heroCard("bandwidth (95% pwr)", `${bw.toPrecision(3)} GHz`, true)); + + // Robustness — fidelity sensitivity to parameter error. Needs perturbed + // rollouts recorded at solve time; NOT derivable from saved artifacts, so + // it renders as an empty proposed card (intent, not an invented number). + row.append(heroCard("robustness", "—", true)); + + return p; +} + +/** 95%-power occupied bandwidth, max across drives: smallest f such that the + * cumulative one-sided power spectrum (DC removed) reaches 95% of total. + * Plain O(N²) DFT — knots are ≤ a few hundred points. Undefined without + * pulse data, a usable dt, or any AC power. */ +function spectralBandwidth(pulse?: CardPulse): number | undefined { + if (!pulse || !(pulse.record.dt > 0)) return undefined; + const dt = pulse.record.dt; + let worst: number | undefined; + for (const drive of pulse.record.values) { + const n = drive.length; + if (n < 2 || drive.some((v) => !Number.isFinite(v))) continue; + const mean = drive.reduce((a, b) => a + b, 0) / n; + const x = drive.map((v) => v - mean); + const half = Math.floor(n / 2); + const power: number[] = []; + for (let k = 1; k <= half; k++) { // one-sided, DC excluded + let re = 0, im = 0; + for (let t = 0; t < n; t++) { + const ph = (-2 * Math.PI * k * t) / n; + re += x[t] * Math.cos(ph); + im += x[t] * Math.sin(ph); + } + power.push(re * re + im * im); + } + const total = power.reduce((a, b) => a + b, 0); + if (total <= 0) continue; + let cum = 0; + for (let k = 0; k < power.length; k++) { + cum += power[k]; + if (cum >= 0.95 * total) { + const f = (k + 1) / (n * dt); // bin k+1 → frequency + if (worst === undefined || f > worst) worst = f; + break; + } + } + } + return worst; +} diff --git a/packages/extension/media/ui/components/chip.ts b/packages/extension/media/ui/components/chip.ts new file mode 100644 index 00000000..24336e7b --- /dev/null +++ b/packages/extension/media/ui/components/chip.ts @@ -0,0 +1,49 @@ +// Chip component (#47) — the catalog entry's compact handle, per the p5 +// sketch: gate · system · tag · #index. Identity is HAMILTONIAN-based (a +// pulse's validity is a property of the system it was optimized against); +// the lab is provenance and lives in the card's metadata panel, not here. +// Fields the catalog-entry schema doesn't carry yet (tag, index) render with +// the "proposed" treatment — visually present, visibly not-yet-real (the +// card is the feedback artifact for exactly that field selection). + +import { defineStyle } from "../style"; +import { text } from "../atoms/text"; + +defineStyle("chip", ` + .chip { display: inline-flex; align-items: center; gap: var(--space-sm); + padding: var(--space-xs) var(--space-md); + border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius-round); + background: var(--bg-box); font-size: var(--text-small); + white-space: nowrap; } + .chip .chip-gate { font-weight: 600; } + .chip .chip-sep { color: var(--color-dim); opacity: 0.6; } + .chip .proposed { border-bottom: 1px dashed var(--color-dim); opacity: 0.75; } +`); + +export interface ChipFields { + gate?: string; + /** System descriptor (e.g. "transmon·3lvl") — Hamiltonian-based identity. */ + system?: string; + /** User-added tags — not in the catalog-entry schema, proposed (marked). + * The quick-digest handles for hyperparameter sweeps ("high-R", "fast"). */ + tags?: string[]; + /** Not in the catalog-entry schema — proposed (marked). */ + index?: number; +} + +export interface Chip { + el: HTMLSpanElement; +} + +export function chip(f: ChipFields): Chip { + const el = document.createElement("span"); + el.className = "chip"; + const sep = (): HTMLSpanElement => text("chip-sep", "·").el; + + el.append(text("chip-gate mono", f.gate ?? "?").el); + if (f.system) el.append(sep(), text("dim", f.system).el); + for (const t of f.tags ?? []) el.append(sep(), text("proposed", t).el); + if (f.index !== undefined) el.append(sep(), text("proposed mono", `#${String(f.index).padStart(4, "0")}`).el); + return { el }; +} diff --git a/packages/extension/package.json b/packages/extension/package.json index d1c304ae..70e3c4ff 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -83,6 +83,10 @@ { "command": "amicode.replayDemo", "title": "Amicode: Replay demo run" + }, + { + "command": "amicode.catalog.remove", + "title": "Remove from Catalog" } ], "configuration": { @@ -109,6 +113,21 @@ "description": "Path to the lab.toml hardware profile, validated on load. Empty = ~/.amico/lab.toml (where install.sh writes the starter)." } } + }, + "menus": { + "view/item/context": [ + { + "command": "amicode.catalog.remove", + "when": "view == amicode.catalog && viewItem == amicodeCatalogEntry", + "group": "7_modification" + } + ], + "commandPalette": [ + { + "command": "amicode.catalog.remove", + "when": "false" + } + ] } }, "scripts": { diff --git a/packages/extension/src/catalog_card_shell.ts b/packages/extension/src/catalog_card_shell.ts new file mode 100644 index 00000000..20c1f394 --- /dev/null +++ b/packages/extension/src/catalog_card_shell.ts @@ -0,0 +1,104 @@ +// Catalog-card shell (#47, v1) — hosts the catalogcard component in a webview. +// +// The card appears only through the save-to-catalog flow: a converged run's +// promote prompt (live solves via the watcher, demo replays via the replay +// command) → "Save to catalog" → `amicode.catalogCard.open` with the run dir. +// The entry is hydrated from the REAL run artifacts: run.toml (identity), +// result.toml (fidelity, params — params.gate/params.system lifted to the +// entry's top level; iterations/wall → the proposed block), and the run.log +// pulse lines (meta + newest record → the card's plot). Not palette- +// contributed — there is no card without a run to save. +// +// Persistence note: opening a card stores nothing durable; the session +// catalog (trees.ts) records POINTERS only. Where promoted artifacts persist +// is the open Phase-3 CatalogStore design (Q91/Q92). + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as vscode from "vscode"; +import { PulseStream, readTomlSafe, type PulseEvent } from "./run_dir_reader"; + +export function registerCatalogCard(ctx: vscode.ExtensionContext): void { + const open = new Map(); // run_id → live panel + ctx.subscriptions.push(vscode.commands.registerCommand("amicode.catalogCard.open", (runDir: string, systemName?: string, tags?: string[]) => { + const data = hydrateFromRunDir(runDir, systemName, tags); + if (!data) { + void vscode.window.showErrorMessage("Amicode: cannot build a catalog entry — run dir is missing run.toml/result.toml."); + return; + } + const key = String(data.entry.run_id); + const existing = open.get(key); + if (existing) { existing.reveal(vscode.ViewColumn.One); return; } // re-focus, don't re-create + const panel = vscode.window.createWebviewPanel( + "amicode.catalogCard", `Catalog: ${data.entry.run_id}`, vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(ctx.extensionUri, "dist"), + vscode.Uri.joinPath(ctx.extensionUri, "media"), + ], + }, + ); + open.set(key, panel); + panel.onDidDispose(() => open.delete(key), null, ctx.subscriptions); + panel.webview.onDidReceiveMessage((m) => { + if (m?.type === "whatnext") vscode.window.showInformationMessage(`what-next → ${m.id} (stub)`); + }); + const uri = (...p: string[]) => panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, ...p)); + const nonce = Math.random().toString(36).slice(2); + panel.webview.html = ` + + + + + + + +`; + })); +} + +/** Build the card's data from real run artifacts. Returns undefined when the + * dir lacks the promote-shaped basics. Shape mirrors the webview's CARD_DATA. + * Exported for tests. */ +export function hydrateFromRunDir(runDir: string, systemName?: string, tags?: string[]): { entry: Record; pulse?: { meta: unknown; record: unknown } } | undefined { + const manifest = readTomlSafe(path.join(runDir, "run.toml")); + const result = readTomlSafe(path.join(runDir, "result.toml")); + if (!manifest || !result) return undefined; + + const params = (result.params ?? {}) as Record; + const entry: Record = { + schema_version: "1", + run_id: String(manifest.run_id ?? path.basename(runDir)), + lab_id: String(manifest.lab_id ?? "default"), + gate: typeof params.gate === "string" ? params.gate : undefined, + fidelity: Number(result.fidelity ?? 0), + pulse_path: path.join(runDir, "pulse.jld2"), + created_at: manifest.created_at, + params, + // Not in catalog-entry.schema.json — rendered visibly marked. The + // user-assigned system name is the sharpest schema question here: human + // identity ("Emerald-Q3") vs machine params (family/levels/δ). + proposed: { + system_name: systemName, + tags, + iterations: result.iterations, + wall_seconds: result.wall_seconds, + }, + }; + + // Pulse plot from the run's own AMICODE_PULSE lines: meta + newest record. + let pulse: { meta: unknown; record: unknown } | undefined; + try { + const stream = new PulseStream(); + let meta: PulseEvent | undefined, newest: PulseEvent | undefined; + for (const line of fs.readFileSync(path.join(runDir, "run.log"), "utf8").split("\n")) { + const e = stream.onLine(line); + if (e?.type === "meta") { meta = e; newest = undefined; } + else if (e?.type === "record") newest = e; + } + if (meta?.type === "meta" && newest?.type === "record") pulse = { meta: meta.meta, record: newest.record }; + } catch { /* no run.log → card renders the not-hydrated state */ } + + return { entry, pulse }; +} diff --git a/packages/extension/src/catalog_card_webview.ts b/packages/extension/src/catalog_card_webview.ts new file mode 100644 index 00000000..fa4f26ed --- /dev/null +++ b/packages/extension/src/catalog_card_webview.ts @@ -0,0 +1,55 @@ +// Catalog-card webview entry (#47) — mounts the card from host-injected data +// (window.__CARD_DATA__, hydrated from the real run dir by the save-to-catalog +// flow); the baked fixture below is the fallback for hostless debugging. + +import { catalogcard, type CatalogEntry, type CardPulse } from "../media/ui/components/catalogcard"; + +declare function acquireVsCodeApi(): { postMessage(msg: unknown): void }; +declare global { interface Window { __CARD_DATA__?: { entry: CatalogEntry; pulse?: CardPulse } } } + +// Grounded in packages/schema/test/fixtures/valid/catalog-entry.toml; the +// `proposed` block is NOT schema — it renders visibly marked (field-selection +// feedback artifact for Krishna/Andrew). +const ENTRY: CatalogEntry = { + schema_version: "1", + run_id: "r20260615-000000Z-ab12", + lab_id: "default", + gate: "X", + fidelity: 0.99995, + pulse_path: "/Users/researcher/.amico/runs/default/r20260615-000000Z-ab12/pulse.jld2", + created_at: "2026-06-15T00:00:00Z", + params: { system: "transmon", levels: 3, T: 10.0, N: 50, drive_max: 0.2 }, + proposed: { tags: ["smooth"], index: 1, iterations: 60, wall_seconds: 41 }, +}; + +const PULSE: CardPulse = { + meta: { drives: 2, knots: 25, labels: ["u_1", "u_2"], bounds: [[-0.2, 0.2], [-0.2, 0.2]] }, + record: { + iter: 60, + dt: 0.4, + values: [ + [0.012, 0.048, 0.096, 0.141, 0.172, 0.184, 0.176, 0.149, 0.108, 0.058, + 0.006, -0.043, -0.084, -0.113, -0.128, -0.127, -0.111, -0.083, -0.047, -0.008, + 0.028, 0.055, 0.068, 0.062, 0.033], + [-0.021, -0.052, -0.079, -0.096, -0.100, -0.089, -0.065, -0.031, 0.009, 0.049, + 0.084, 0.109, 0.121, 0.118, 0.100, 0.070, 0.032, -0.009, -0.048, -0.079, + -0.098, -0.102, -0.089, -0.061, -0.024], + ], + }, +}; + +const SIBLINGS = [ + { gate: "X", system: "transmon", tags: ["fast"], index: 2 }, + { gate: "X", system: "transmon", tags: ["robust"], index: 3 }, + { gate: "H", system: "transmon", tags: ["smooth"], index: 7 }, +]; + +const vscodeApi = acquireVsCodeApi(); +const injected = window.__CARD_DATA__; +const card = catalogcard(injected?.entry ?? ENTRY, { + pulse: injected ? injected.pulse : PULSE, + siblings: injected ? [] : SIBLINGS, // sibling entries need a store — none yet on the real path + onAction: (id) => vscodeApi.postMessage({ type: "whatnext", id }), +}); +document.body.style.padding = "16px"; +document.body.append(card.el); diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index a7171ce5..4377a488 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -6,6 +6,7 @@ import { fetchProviderSignal } from "./llm_creds.mjs"; import { resolveOpencodeBinary, OpencodeMissingError } from "./opencode_binary"; import { ChatPanel } from "./chat_panel"; import { registerRunInspector } from "./run_inspector"; +import { registerCatalogCard } from "./catalog_card_shell"; import { registerTrees } from "./trees"; import { StatusBarManager } from "./status_bar"; import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent } from "./opencode_config"; @@ -14,6 +15,7 @@ import { resolveLabTomlPath, checkLabToml } from "./lab_config"; import { OpencodeEventClient } from "./sse_client"; import { RunsRootWatcher } from "./file_watcher"; import { stageDemoRun } from "./demo_replay"; +import { readTomlSafe } from "./run_dir_reader"; // ============================================================================ // Extension entry point. Boot order on activate: @@ -40,8 +42,51 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const runsRoot = resolveRunsRoot(vscode.workspace.getConfiguration("amicode").get("runsRoot", "")); // 1. UI surfaces - registerTrees(ctx); + const trees = registerTrees(ctx); registerRunInspector(ctx); + registerCatalogCard(ctx); // #47 dev scaffold — card opens via the save-to-catalog flow + ctx.subscriptions.push( + // #47 session catalog: record the save (workspaceState + tree), then open + // the card. Both prompts (demo replay, live promote) route through here. + vscode.commands.registerCommand("amicode.catalog.save", async (runDir: string) => { + const manifest = readTomlSafe(path.join(runDir, "run.toml")) ?? {}; + const result = readTomlSafe(path.join(runDir, "result.toml")) ?? {}; + const params = (result.params ?? {}) as Record; + const family = typeof params.system === "string" ? params.system : undefined; + // System identity is USER-NAMED (researchers think in named devices — + // "Emerald-Q3" — not families); the family prefills as the default and + // level counts stay in the card's params rows. Esc keeps the family. + const name = await vscode.window.showInputBox({ + prompt: "Name this system (shown on the catalog entry)", + value: family ?? "", + placeHolder: "e.g. Emerald-Q3", + }); + const system = name?.trim() ? name.trim() : family; + // Tags: the quick-digest handles for hyperparameter sweeps ("high-R", + // "T=8", "fast-ansatz") — optional, comma-separated. + const tagsRaw = await vscode.window.showInputBox({ + prompt: "Tags (comma-separated, optional)", + placeHolder: "e.g. high-R, T=8, fast", + }); + const tags = tagsRaw?.split(",").map((t) => t.trim()).filter(Boolean) ?? []; + await trees.catalog.save({ + run_id: String(manifest.run_id ?? path.basename(runDir)), + runDir, + lab_id: String(manifest.lab_id ?? "default"), + gate: typeof params.gate === "string" ? params.gate : undefined, + system, + tags, + fidelity: Number(result.fidelity ?? 0), + saved_at: new Date().toISOString(), + }); + await vscode.commands.executeCommand("amicode.catalogCard.open", runDir, system, tags); + }), + vscode.commands.registerCommand("amicode.catalog.refresh", () => trees.catalog.refresh()), + // Context-menu removal: unsave the pointer; run artifacts stay on disk. + vscode.commands.registerCommand("amicode.catalog.remove", async (entry?: { run_id?: string }) => { + if (entry?.run_id) await trees.catalog.remove(entry.run_id); + }), + ); statusBar = new StatusBarManager(); ctx.subscriptions.push({ dispose: () => statusBar?.dispose() }); @@ -208,6 +253,17 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const runDir = stageDemoRun(demoDir, runsRoot); runsChannel.appendLine(`[demo] replayed → ${runDir}`); await vscode.commands.executeCommand("amicode.runInspector.focus"); + // Save-to-catalog prompt (#47): the watcher suppresses the promote + // prompt for runs already finished at switch (anti-re-pop), so the + // explicit replay owns its own prompt → the catalog card. + const fid = Number((readTomlSafe(path.join(runDir, "result.toml")) ?? {}).fidelity ?? NaN); + if (fid >= 0.99) { + const choice = await vscode.window.showInformationMessage( + `Amicode: demo solve converged (F=${fid.toFixed(4)}). Save to catalog?`, + "Save to catalog", "Not now", + ); + if (choice === "Save to catalog") await vscode.commands.executeCommand("amicode.catalog.save", runDir); + } } catch (e) { void vscode.window.showErrorMessage(`Amicode: replay failed — ${(e as Error).message}`); } diff --git a/packages/extension/src/file_watcher.ts b/packages/extension/src/file_watcher.ts index 5a960491..bf25b419 100644 --- a/packages/extension/src/file_watcher.ts +++ b/packages/extension/src/file_watcher.ts @@ -94,8 +94,9 @@ class LiveRunSink implements RunSink { "Yes — promote", "No — keep local only", ); if (choice === "Yes — promote") { - vscode.window.showInformationMessage(`Amicode: promotion stub — would catalog ${info.runId}.`); - await vscode.commands.executeCommand("amicode.catalog.refresh").then(undefined, () => undefined); + // #47: record in the session catalog + open the card (store + // persistence is still Phase 3 — the session catalog is workspaceState). + await vscode.commands.executeCommand("amicode.catalog.save", info.runDir).then(undefined, () => undefined); } })(); } diff --git a/packages/extension/src/trees.ts b/packages/extension/src/trees.ts index 4e93dfbe..56b385d2 100644 --- a/packages/extension/src/trees.ts +++ b/packages/extension/src/trees.ts @@ -1,10 +1,12 @@ import * as vscode from "vscode"; // ============================================================================ -// Placeholder TreeViews for vault / catalog / armonia. -// v0 ships an empty-state message; real implementations connect to -// ArmoniaService when that lands. Registering them now reserves the -// activitybar real estate. +// TreeViews for vault / catalog / armonia. +// vault + armonia ship an empty-state message; real implementations connect +// to ArmoniaService when that lands. The catalog tree is the #47 SESSION +// catalog: entries collected by the save-to-catalog flow, persisted in +// workspaceState — explicitly NOT the Phase-3 CatalogStore (vault-backed, +// git-lfs pulse artifacts); it seeds the UX3 browser. // ============================================================================ class PlaceholderTree implements vscode.TreeDataProvider { @@ -22,13 +24,79 @@ class PlaceholderTree implements vscode.TreeDataProvider { refresh(): void { this._onDidChange.fire(); } } +/** A saved session-catalog entry (#47). Promote-shaped, mirrors the card's + * hydration source: enough to label the row and reopen the card. */ +export interface SessionCatalogEntry { + run_id: string; + runDir: string; + lab_id: string; + fidelity: number; + gate?: string; + /** User-named system (falls back to the derived family). */ + system?: string; + /** User-added tags — quick-digest handles for hyperparameter sweeps. */ + tags?: string[]; + saved_at: string; +} + +const CATALOG_KEY = "amicode.sessionCatalog"; + +export class SessionCatalogTree implements vscode.TreeDataProvider { + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChange.event; + + constructor(private readonly ctx: vscode.ExtensionContext) {} + + private entries(): SessionCatalogEntry[] { + return this.ctx.workspaceState.get(CATALOG_KEY, []); + } + + /** Record a save (newest first, deduped by run_id) and refresh the view. */ + async save(entry: SessionCatalogEntry): Promise { + const rest = this.entries().filter((e) => e.run_id !== entry.run_id); + await this.ctx.workspaceState.update(CATALOG_KEY, [entry, ...rest]); + this._onDidChange.fire(); + } + + /** Remove the POINTER record (unsave). Non-destructive by design: the run + * dir and pulse.jld2 stay on disk — deleting artifacts (and archive/ + * supersede semantics) belongs to the Phase-3 CatalogStore (Q94/Q95). */ + async remove(run_id: string): Promise { + await this.ctx.workspaceState.update(CATALOG_KEY, this.entries().filter((e) => e.run_id !== run_id)); + this._onDidChange.fire(); + } + + getTreeItem(el: SessionCatalogEntry | string): vscode.TreeItem { + if (typeof el === "string") return new vscode.TreeItem(el, vscode.TreeItemCollapsibleState.None); + // Chip-shaped label: gate · system · fidelity (Hamiltonian-based identity; + // the lab is provenance — it lives in the tooltip + the card's metadata). + const item = new vscode.TreeItem( + `${el.gate ?? "?"} · ${el.system ?? "?"} · F=${el.fidelity.toFixed(5)}`, + vscode.TreeItemCollapsibleState.None, + ); + item.description = el.run_id; + item.tooltip = `lab: ${el.lab_id}${el.tags?.length ? `\ntags: ${el.tags.join(", ")}` : ""}\nsaved ${el.saved_at}\n${el.runDir}`; + if (el.tags?.length) item.description = `${el.run_id} · ${el.tags.join(" · ")}`; + item.command = { command: "amicode.catalogCard.open", title: "Open catalog card", arguments: [el.runDir, el.system, el.tags] }; + item.contextValue = "amicodeCatalogEntry"; // enables the row's context menu (remove) + return item; + } + + getChildren(): (SessionCatalogEntry | string)[] { + const e = this.entries(); + return e.length ? e : ["(empty — save a converged run to the catalog)"]; + } + + refresh(): void { this._onDidChange.fire(); } +} + export function registerTrees(ctx: vscode.ExtensionContext): { vault: PlaceholderTree; - catalog: PlaceholderTree; + catalog: SessionCatalogTree; armonia: PlaceholderTree; } { const vault = new PlaceholderTree("(vault tree will appear when Armonia mounts are configured)"); - const catalog = new PlaceholderTree("(catalog tree will appear when a public vault is mounted)"); + const catalog = new SessionCatalogTree(ctx); const armonia = new PlaceholderTree("(no mounts yet — run Amicode: Open Chat to start)"); ctx.subscriptions.push( diff --git a/packages/extension/test/__mocks__/vscode.ts b/packages/extension/test/__mocks__/vscode.ts index f8817cc3..2500724f 100644 --- a/packages/extension/test/__mocks__/vscode.ts +++ b/packages/extension/test/__mocks__/vscode.ts @@ -5,10 +5,34 @@ export const window = { showInformationMessage: () => Promise.resolve(undefined), showErrorMessage: () => Promise.resolve(undefined), showWarningMessage: () => Promise.resolve(undefined), + showInputBox: () => Promise.resolve(undefined), createOutputChannel: () => ({ appendLine() {}, append() {}, dispose() {} }), registerWebviewViewProvider: () => ({ dispose() {} }), + createWebviewPanel: (_viewType: string, _title: string, _column?: unknown, _opts?: unknown) => { + const disposeCbs: Array<() => void> = []; + return { + webview: { + html: "", + cspSource: "test:", + asWebviewUri: (u: unknown) => u, + onDidReceiveMessage: () => ({ dispose() {} }), + }, + revealCount: 0, + reveal() { this.revealCount += 1; }, + onDidDispose(cb: () => void, _thisArg?: unknown, _subs?: unknown) { disposeCbs.push(cb); return { dispose() {} }; }, + dispose() { for (const cb of disposeCbs) cb(); }, + }; + }, +}; +const registeredCommands = new Map unknown>(); +export const commands = { + registerCommand: (id: string, fn: (...a: unknown[]) => unknown) => { + registeredCommands.set(id, fn); + return { dispose() { registeredCommands.delete(id); } }; + }, + executeCommand: (id: string, ...a: unknown[]) => Promise.resolve(registeredCommands.get(id)?.(...a)), }; -export const commands = { executeCommand: () => Promise.resolve(undefined) }; +export const ViewColumn = { One: 1, Two: 2 }; export const workspace = { workspaceFolders: [] as unknown[], getConfiguration: () => ({ get: (_k: string, d?: unknown) => d ?? "" }), @@ -29,3 +53,10 @@ export class EventEmitter { export class Disposable { dispose() {} } +export class TreeItem { + description?: string; + tooltip?: string; + command?: unknown; + constructor(public label: string, public collapsibleState?: number) {} +} +export const TreeItemCollapsibleState = { None: 0, Collapsed: 1, Expanded: 2 }; diff --git a/packages/extension/test/catalog_shell.test.ts b/packages/extension/test/catalog_shell.test.ts new file mode 100644 index 00000000..1e0155b6 --- /dev/null +++ b/packages/extension/test/catalog_shell.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi } from "vitest"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as vscode from "vscode"; +import { hydrateFromRunDir, registerCatalogCard } from "../src/catalog_card_shell"; +import { SessionCatalogTree, type SessionCatalogEntry } from "../src/trees"; + +// The save-to-catalog flow (#47): entry hydration from real run artifacts, +// and the session catalog (pointer records in workspaceState — NOT the +// Phase-3 CatalogStore; Q91/Q92 open). + +function stageRun(opts: { pulseLines?: string; gate?: string; system?: string }): string { + const dir = mkdtempSync(join(tmpdir(), "card-run-")); + writeFileSync(join(dir, "run.toml"), + 'schema_version = "1"\nrun_id = "r20260703-000000Z-cafe"\nlab_id = "default"\nscript_path = "/s.jl"\n' + + 'lab = "default"\ncreated_at = "2026-07-03T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n'); + const params = [ + opts.system ? `system = "${opts.system}"` : "", + opts.gate ? `gate = "${opts.gate}"` : "", + "levels = 3", + ].filter(Boolean).join("\n"); + writeFileSync(join(dir, "result.toml"), + `schema_version = "1"\nfidelity = 0.9998\niterations = 60\nwall_seconds = 41.5\n[params]\n${params}\n`); + if (opts.pulseLines !== undefined) writeFileSync(join(dir, "run.log"), opts.pulseLines); + return dir; +} + +describe("hydrateFromRunDir — entry from real run artifacts", () => { + it("maps identity, fidelity, params (gate lifted to top level), proposed block, and the newest pulse", () => { + const dir = stageRun({ + gate: "X", system: "transmon", + pulseLines: + 'AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.2:0.2\n' + + "AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2\n" + + "AMICODE_PULSE iter=2 dt=0.2 a=0.3,0.4\n", + }); + const data = hydrateFromRunDir(dir)!; + expect(data.entry).toMatchObject({ + run_id: "r20260703-000000Z-cafe", + lab_id: "default", + gate: "X", + fidelity: 0.9998, + proposed: { iterations: 60, wall_seconds: 41.5 }, + }); + expect((data.entry.params as Record).system).toBe("transmon"); + expect(data.pulse).toMatchObject({ record: { iter: 2 } }); // newest record, not the first + }); + + it("degrades: no run.log → no pulse; missing result.toml → undefined", () => { + const dir = stageRun({ gate: "X" }); + expect(hydrateFromRunDir(dir)!.pulse).toBeUndefined(); + const empty = mkdtempSync(join(tmpdir(), "card-empty-")); + expect(hydrateFromRunDir(empty)).toBeUndefined(); + }); +}); + +describe("registerCatalogCard — reveal-or-create panel dedupe", () => { + it("re-opening the same entry reveals the live panel; dispose allows re-create", async () => { + const dir = stageRun({ gate: "X", system: "transmon" }); + const ctx = { subscriptions: [], extensionUri: vscode.Uri.file("/ext") } as never; + registerCatalogCard(ctx); + const spy = vi.spyOn(vscode.window, "createWebviewPanel"); + + await vscode.commands.executeCommand("amicode.catalogCard.open", dir); + await vscode.commands.executeCommand("amicode.catalogCard.open", dir); + expect(spy).toHaveBeenCalledTimes(1); // one panel per run_id + const panel = spy.mock.results[0].value as { revealCount: number; dispose: () => void }; + expect(panel.revealCount).toBe(1); // second click re-focuses + + panel.dispose(); // user closes the tab + await vscode.commands.executeCommand("amicode.catalogCard.open", dir); + expect(spy).toHaveBeenCalledTimes(2); // closed → fresh panel + spy.mockRestore(); + }); +}); + +describe("SessionCatalogTree — pointer records, newest first", () => { + function makeCtx() { + const store = new Map(); + return { + workspaceState: { + get: (k: string, d: unknown) => (store.has(k) ? store.get(k) : d), + update: (k: string, v: unknown) => { store.set(k, v); return Promise.resolve(); }, + }, + } as never; + } + const entry = (run_id: string, over: Partial = {}): SessionCatalogEntry => ({ + run_id, runDir: `/runs/${run_id}`, lab_id: "default", fidelity: 0.999, + gate: "X", system: "transmon", saved_at: "2026-07-03T00:00:00Z", ...over, + }); + + it("saves newest-first, dedupes by run_id, and rows open the card for the run dir", async () => { + const tree = new SessionCatalogTree(makeCtx()); + await tree.save(entry("r1")); + await tree.save(entry("r2")); + await tree.save(entry("r1", { fidelity: 0.5 })); // re-save moves to front, replaces + const rows = tree.getChildren() as SessionCatalogEntry[]; + expect(rows.map((r) => r.run_id)).toEqual(["r1", "r2"]); + expect(rows[0].fidelity).toBe(0.5); + + const item = tree.getTreeItem(rows[1]) as { label: string; command?: { command: string; arguments: unknown[] } }; + expect(item.label).toContain("transmon"); + expect(item.command?.command).toBe("amicode.catalogCard.open"); + expect(item.command?.arguments).toEqual(["/runs/r2", "transmon", undefined]); // runDir + name + tags → card + }); + + it("remove() unsaves the pointer only — remaining entries and order survive", async () => { + const tree = new SessionCatalogTree(makeCtx()); + await tree.save(entry("r1")); + await tree.save(entry("r2")); + await tree.save(entry("r3")); + await tree.remove("r2"); + const rows = tree.getChildren() as SessionCatalogEntry[]; + expect(rows.map((r) => r.run_id)).toEqual(["r3", "r1"]); + await tree.remove("r2"); // idempotent — removing a gone entry is a no-op + expect((tree.getChildren() as SessionCatalogEntry[]).length).toBe(2); + }); + + it("rows carry the context value that enables the remove menu", async () => { + const tree = new SessionCatalogTree(makeCtx()); + await tree.save(entry("r1")); + const item = tree.getTreeItem((tree.getChildren() as SessionCatalogEntry[])[0]) as { contextValue?: string }; + expect(item.contextValue).toBe("amicodeCatalogEntry"); + }); + + it("empty state renders the hint row", () => { + const tree = new SessionCatalogTree(makeCtx()); + const rows = tree.getChildren(); + expect(rows).toHaveLength(1); + expect(String(rows[0])).toContain("empty"); + }); +}); diff --git a/packages/extension/test/watcher_contract.test.ts b/packages/extension/test/watcher_contract.test.ts index ff86d049..612b0b6f 100644 --- a/packages/extension/test/watcher_contract.test.ts +++ b/packages/extension/test/watcher_contract.test.ts @@ -53,7 +53,7 @@ describe('ingestRunDir — β.1 contract reading (replay)', () => { const sink = fakeSink() const dir = stageRun({ status: 'completed', exit: 0, iters: [1, 2, 3], fidelity: 0.999 }) writeFileSync(join(dir, 'run.log'), - 'AMICODE_PULSE_META drives=1 knots=2 labels="a_1" bounds=-0.2:0.2\n' + + 'AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.2:0.2\n' + 'AMICODE_PULSE iter=1 dt=0.2 a=0.1,0.2\n' + 'AMICODE_ITER iter=1 f=0.1 inf_pr=1e-8 inf_du=1e-6\n' + 'AMICODE_PULSE iter=2 dt=0.2 a=0.3,0.4\n' + @@ -90,11 +90,11 @@ describe('AMICODE_ITER parsing — Inf/NaN are kept, not dropped', () => { // is untouched. describe('AMICODE_PULSE_META parsing (#66 pinned grammar)', () => { it('parses drives/knots/labels/bounds from a well-formed meta line', () => { - const m = parsePulseMetaLine('AMICODE_PULSE_META drives=2 knots=50 labels="a_1","a_2" bounds=-0.2:0.2,-0.2:0.2') + const m = parsePulseMetaLine('AMICODE_PULSE_META drives=2 knots=50 labels="u_1","u_2" bounds=-0.2:0.2,-0.2:0.2') expect(m).toEqual({ drives: 2, knots: 50, - labels: ['a_1', 'a_2'], + labels: ['u_1', 'u_2'], bounds: [[-0.2, 0.2], [-0.2, 0.2]], }) }) @@ -122,7 +122,7 @@ describe('AMICODE_PULSE record parsing (#66 pinned grammar)', () => { // dropped; the last meta wins and resets state; count-mismatched records and // internally-inconsistent metas are ignored. describe('PulseStream — cross-line policy (#66)', () => { - const META = 'AMICODE_PULSE_META drives=2 knots=3 labels="a_1","a_2" bounds=-0.2:0.2,-0.2:0.2' + const META = 'AMICODE_PULSE_META drives=2 knots=3 labels="u_1","u_2" bounds=-0.2:0.2,-0.2:0.2' const REC = 'AMICODE_PULSE iter=6 dt=0.2 a=0.1,0.2,0.3;0.4,0.5,0.6' it('drops records that arrive before any meta', () => { @@ -142,8 +142,8 @@ describe('PulseStream — cross-line policy (#66)', () => { it('treats a meta whose label or bounds count disagrees with drives= as malformed (no state change)', () => { const ps = new PulseStream() - expect(ps.onLine('AMICODE_PULSE_META drives=2 knots=3 labels="a_1" bounds=-0.2:0.2,-0.2:0.2')).toBeUndefined() // 1 label ≠ 2 drives - expect(ps.onLine('AMICODE_PULSE_META drives=2 knots=3 labels="a_1","a_2" bounds=-0.2:0.2')).toBeUndefined() // 1 bound ≠ 2 drives + expect(ps.onLine('AMICODE_PULSE_META drives=2 knots=3 labels="u_1" bounds=-0.2:0.2,-0.2:0.2')).toBeUndefined() // 1 label ≠ 2 drives + expect(ps.onLine('AMICODE_PULSE_META drives=2 knots=3 labels="u_1","u_2" bounds=-0.2:0.2')).toBeUndefined() // 1 bound ≠ 2 drives expect(ps.onLine(REC)).toBeUndefined() // bad metas did NOT arm the stream }) @@ -155,7 +155,7 @@ describe('PulseStream — cross-line policy (#66)', () => { expect(ps.onLine(META)).toMatchObject({ type: 'meta' }) expect(ps.onLine(REC)).toMatchObject({ type: 'record' }) // a NEW meta with a different shape governs subsequent records - expect(ps.onLine('AMICODE_PULSE_META drives=1 knots=2 labels="a_1" bounds=-0.1:0.1')).toMatchObject({ type: 'meta' }) + expect(ps.onLine('AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.1:0.1')).toMatchObject({ type: 'meta' }) expect(ps.onLine(REC)).toBeUndefined() // old-shape record now ignored expect(ps.onLine('AMICODE_PULSE iter=9 dt=0.2 a=0.1,0.2')).toMatchObject({ type: 'record', record: { iter: 9 } }) }) diff --git a/packages/extension/test/watcher_statemachine.test.ts b/packages/extension/test/watcher_statemachine.test.ts index 75dd23bb..8826f7c2 100644 --- a/packages/extension/test/watcher_statemachine.test.ts +++ b/packages/extension/test/watcher_statemachine.test.ts @@ -38,7 +38,7 @@ function setLatest(root: string, target: string): void { symlinkSync(target, link); } const tick = (w: RunsRootWatcher): void => (w as unknown as { tick(): void }).tick(); -const META_LINE = 'AMICODE_PULSE_META drives=1 knots=2 labels="a_1" bounds=-0.2:0.2\n'; +const META_LINE = 'AMICODE_PULSE_META drives=1 knots=2 labels="u_1" bounds=-0.2:0.2\n'; describe("RunsRootWatcher state machine", () => { beforeEach(() => { for (const f of Object.values(inspector)) f.mockClear(); });