diff --git a/packages/extension/.gitignore b/packages/extension/.gitignore index 850676dd..13c4edba 100644 --- a/packages/extension/.gitignore +++ b/packages/extension/.gitignore @@ -1 +1,2 @@ dev/pulseplot_harness/main.js +media/vendor/ diff --git a/packages/extension/esbuild.config.mjs b/packages/extension/esbuild.config.mjs index d336deef..9cf16f0d 100644 --- a/packages/extension/esbuild.config.mjs +++ b/packages/extension/esbuild.config.mjs @@ -18,6 +18,13 @@ if (existsSync(`${arRoot}/dist/amico-run.js`)) { console.warn("[esbuild] amico-run/dist not built — run `pnpm --filter @amicode/amico-run build` before packaging"); } +// Stage KaTeX assets for the interview webview's Hamiltonian panel — the +// webview CSP forbids CDNs, so css + fonts ship locally from media/vendor/ +// (a build artifact, gitignored; the js is bundled into interview_webview.js). +mkdirSync("media/vendor/katex", { recursive: true }); +cpSync("node_modules/katex/dist/katex.min.css", "media/vendor/katex/katex.min.css", { dereference: true }); +cpSync("node_modules/katex/dist/fonts", "media/vendor/katex/fonts", { recursive: true, dereference: true }); + const targets = [ // extension host entry point { @@ -32,6 +39,18 @@ const targets = [ minify: false, logLevel: "info", }, + // UX1 live interview webview bundle (#46) + { + entryPoints: ["src/interview_webview.ts"], + bundle: true, + platform: "browser", + target: "es2022", + format: "iife", + outfile: "dist/interview_webview.js", + sourcemap: true, + minify: false, + logLevel: "info", + }, // catalog-card dev preview webview bundle (#47 scaffold) { entryPoints: ["src/catalog_card_webview.ts"], diff --git a/packages/extension/media/ui/atoms/loader.ts b/packages/extension/media/ui/atoms/loader.ts new file mode 100644 index 00000000..312f9733 --- /dev/null +++ b/packages/extension/media/ui/atoms/loader.ts @@ -0,0 +1,35 @@ +// Loader atom — an indeterminate spinner for in-flight agent work. Rides the +// VS Code progress token so it recolors with the theme; identity is carried +// by the aria-label, never visible text (the design call: loader in lieu of +// "Amico is thinking…" copy). + +import { defineStyle } from "../style"; + +defineStyle("loader", ` + .loader { display: inline-flex; align-items: center; gap: var(--space-sm); } + .loader .ld-spin { width: 14px; height: 14px; border-radius: 50%; flex: none; + border: 2px solid var(--border-color); + border-top-color: var(--vscode-progressBar-background, var(--color-run)); + animation: ld-rotate 0.9s linear infinite; } + @keyframes ld-rotate { to { transform: rotate(360deg); } } + @media (prefers-reduced-motion: reduce) { + .loader .ld-spin { animation-duration: 2.4s; } + } +`); + +export interface Loader { + el: HTMLSpanElement; + /** Show/hide without layout churn — callers keep one instance mounted. */ + set(visible: boolean): void; +} + +export function loader(label = "working"): Loader { + const el = document.createElement("span"); + el.className = "loader"; + const spin = document.createElement("span"); + spin.className = "ld-spin"; + spin.setAttribute("role", "progressbar"); + spin.setAttribute("aria-label", label); + el.append(spin); + return { el, set(visible) { el.style.display = visible ? "" : "none"; } }; +} diff --git a/packages/extension/media/ui/components/askframe.ts b/packages/extension/media/ui/components/askframe.ts new file mode 100644 index 00000000..388187e5 --- /dev/null +++ b/packages/extension/media/ui/components/askframe.ts @@ -0,0 +1,164 @@ +// Askframe component (#46, UX1) — renders one live interview question: +// header chip (stage id), persona lead-in, question text, option rows +// (default pilled "recommended"), and an Other slot. The question SHAPE +// stays AskUserQuestion-compatible (header/question/options/custom) so the +// interview protocol and any future renderer share one contract. +// +// LAYOUT CONTRACT (fork-transcript ready): these components ultimately render +// inside the opencode-fork transcript — an infinite VERTICAL scroll. Options +// therefore stack vertically as full-width rows (never a horizontal card +// grid), and nothing in here assumes a fixed viewport height or column count. + +import { defineStyle } from "../style"; +import { text } from "../atoms/text"; +import { pill } from "../atoms/pill"; + +defineStyle("askframe", ` + .askframe { display: flex; flex-direction: column; gap: var(--space-md); + background: var(--bg-box); + border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius); padding: var(--space-lg); + max-width: 720px; } + .askframe .af-header { display: flex; align-items: center; gap: var(--space-sm); } + .askframe .af-chip { font-size: var(--text-label); text-transform: uppercase; + letter-spacing: 0.6px; font-weight: 600; + padding: 2px var(--space-sm); border-radius: var(--border-radius); + background: var(--vscode-badge-background, var(--bg-plot)); + color: var(--vscode-badge-foreground, var(--vscode-foreground)); } + .askframe .af-persona { color: var(--color-dim); font-style: italic; } + .askframe .af-question { font-size: var(--text-value); font-weight: 600; } + .askframe .af-options { display: flex; flex-direction: column; gap: var(--space-sm); } + .askframe .af-option { display: flex; flex-direction: column; gap: var(--space-xs); + width: 100%; box-sizing: border-box; + padding: var(--space-sm) var(--space-md); cursor: pointer; + border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius); + background: var(--vscode-button-secondaryBackground, var(--bg-plot)); + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); } + .askframe .af-option:hover { background: color-mix(in srgb, var(--color-accent-fill, var(--color-accent)) 12%, + var(--vscode-button-secondaryBackground, var(--bg-plot))); } + .askframe .af-option:active { background: color-mix(in srgb, var(--color-accent-fill, var(--color-accent)) 24%, + var(--vscode-button-secondaryBackground, var(--bg-plot))); } + .askframe .af-option .af-labelrow { display: flex; align-items: center; + gap: var(--space-sm); } + .askframe .af-option .af-label { font-weight: 600; font-size: var(--text-small); } + .askframe .af-option .af-desc { font-size: var(--text-small); color: var(--color-dim); } + .askframe .af-option.other { border-style: dashed; opacity: 0.85; } + .askframe .af-options.af-multi .af-option:not(.other) .af-label::before { content: "☐ "; font-weight: 400; } + .askframe .af-options.af-multi .af-option.selected .af-label::before { content: "☑ "; color: var(--color-accent); } + .askframe .af-option.selected { border-color: var(--color-accent); } + .askframe .af-confirm { display: flex; } + .askframe .af-confirm 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-foreground, #fff); + background: var(--vscode-button-background, #0e639c); } + .askframe .af-confirm button:disabled { opacity: 0.5; cursor: not-allowed; } + .askframe.proposed { border-style: dashed; } + .askframe.proposed .af-chip::after { content: " · proposed"; opacity: 0.8; } + .askframe .af-annotation { border-top: var(--border-width) dashed var(--border-color); + padding-top: var(--space-sm); font-size: var(--text-small); + color: var(--color-dim); } + .askframe .af-annotation::before { content: "📝 "; } +`); + +export interface AskOption { + label: string; + description?: string; + default?: boolean; + /** Branch key reported to onChoose. Defaults to the label. */ + value?: string; +} + +export interface AskFrameSpec { + /** Header chip, e.g. "system setup · 1/4". */ + stage: string; + /** Amico's one-line persona lead-in (spec: lightweight anchor). */ + persona?: string; + question: string; + options: AskOption[]; + /** Storyboard annotation: provenance, divergences, feedback prompts. */ + annotation?: string; + /** Amendment frame — not in the draft tree; rendered dashed/marked. */ + proposed?: boolean; + /** Render the free-form Other slot (AskUserQuestion always offers it). */ + other?: boolean; + /** Multi-select: options toggle, a confirm button submits the set (the + * physics question — the live Hamiltonian assembles from the selection). */ + multiple?: boolean; +} + +export interface AskFrame { + el: HTMLDivElement; +} + +/** Single-select calls onChoose with the option value on click; multi-select + * calls it with the selected label SET on confirm. onToggle fires on every + * multi-select toggle so a live view (the Hamiltonian panel) can track it. */ +export function askframe(spec: AskFrameSpec, onChoose: (value: string | string[]) => void, onToggle?: (selected: string[]) => void): AskFrame { + const el = document.createElement("div"); + el.className = spec.proposed ? "askframe proposed" : "askframe"; + + const header = document.createElement("div"); + header.className = "af-header"; + const chipEl = document.createElement("span"); + chipEl.className = "af-chip"; + chipEl.textContent = spec.stage; + header.append(chipEl); + el.append(header); + + if (spec.persona) el.append(text("af-persona", spec.persona).el); + el.append(text("af-question", spec.question).el); + + const opts = document.createElement("div"); + opts.className = spec.multiple ? "af-options af-multi" : "af-options"; + const selected = new Set(); + for (const o of spec.options) { + const card = document.createElement("div"); + card.className = o.default ? "af-option default" : "af-option"; + const labelRow = document.createElement("div"); + labelRow.className = "af-labelrow"; + labelRow.append(text("af-label", o.label).el); + if (o.default) labelRow.append(pill("done", "recommended", { dot: false }).el); + card.append(labelRow); + if (o.description) card.append(text("af-desc", o.description).el); + const value = o.value ?? o.label; + if (spec.multiple) { + // Recommended default starts selected — toggling is refinement, not + // building the set from zero. + if (o.default) { selected.add(value); card.classList.add("selected"); } + card.addEventListener("click", () => { + card.classList.toggle("selected") ? selected.add(value) : selected.delete(value); + confirm.disabled = selected.size === 0; + confirm.textContent = `use these (${selected.size})`; + onToggle?.([...selected]); + }); + } else { + card.addEventListener("click", () => onChoose(value)); + } + opts.append(card); + } + if (spec.other ?? true) { + const card = document.createElement("div"); + card.className = "af-option other"; + card.append(text("af-label", "Other…").el, text("af-desc", "free-form answer").el); + card.addEventListener("click", () => onChoose("__other")); + opts.append(card); + } + el.append(opts); + + const confirm = document.createElement("button"); + if (spec.multiple) { + confirm.textContent = `use these (${selected.size})`; + confirm.disabled = selected.size === 0; + confirm.addEventListener("click", () => onChoose([...selected])); + const row = document.createElement("div"); + row.className = "af-confirm"; + row.append(confirm); + el.append(row); + } + + if (spec.annotation) el.append(text("af-annotation", spec.annotation).el); + return { el }; +} diff --git a/packages/extension/media/ui/components/hamiltonian.ts b/packages/extension/media/ui/components/hamiltonian.ts new file mode 100644 index 00000000..1c78cff0 --- /dev/null +++ b/packages/extension/media/ui/components/hamiltonian.ts @@ -0,0 +1,55 @@ +// Hamiltonian panel (#46, UX1) — the model Hamiltonian assembling itself in +// front of the physicist as they toggle terms on the multi-select physics +// question. Rendered as REAL math via KaTeX (bundled locally — the webview +// CSP forbids CDNs; css + fonts ship from media/vendor/katex). Term → math +// mapping lives in hamiltonian_terms.ts (pure). + +import katex from "katex"; +import { defineStyle } from "../style"; +import { text } from "../atoms/text"; +import { hamiltonianLines, LHS_LATEX } from "./hamiltonian_terms"; + +defineStyle("hamiltonian", ` + .hm-panel { display: flex; flex-direction: column; gap: var(--space-xs); + background: var(--bg-plot); + border: var(--border-width) dashed var(--border-color); + border-radius: var(--border-radius); + padding: var(--space-md) var(--space-lg); max-width: 720px; } + .hm-panel .hm-title { font-size: var(--text-label); text-transform: uppercase; + letter-spacing: 0.6px; color: var(--color-dim); } + .hm-panel .hm-line { padding-left: var(--space-lg); } + .hm-panel .hm-line.first { padding-left: 0; } + .hm-panel .hm-line.lindblad { opacity: 0.75; } + .hm-panel .hm-line .katex { font-size: 1.05em; } + .hm-panel .hm-note { font-size: var(--text-small); color: var(--color-dim); + margin-left: var(--space-lg); } +`); + +export interface HamiltonianPanel { + el: HTMLDivElement; + /** Re-render for the currently selected term labels. */ + set(selected: string[]): void; +} + +export function hamiltonianPanel(): HamiltonianPanel { + const el = document.createElement("div"); + el.className = "hm-panel"; + const body = document.createElement("div"); + el.append(text("hm-title", "model Hamiltonian").el, body); + + function set(selected: string[]): void { + body.replaceChildren(); + hamiltonianLines(selected).forEach((l, i) => { + // First line carries the LHS; its leading "+" folds into the "=". + const latex = i === 0 ? LHS_LATEX + l.latex.replace(/^\+\\,?/, "") : l.latex; + const line = document.createElement("div"); + line.className = "hm-line" + (i === 0 ? " first" : "") + (l.lindblad ? " lindblad" : ""); + line.innerHTML = katex.renderToString(latex, { throwOnError: false, output: "html" }); + body.append(line); + if (l.note) body.append(text("hm-note", l.note).el); + }); + } + + set([]); + return { el, set }; +} diff --git a/packages/extension/media/ui/components/hamiltonian_terms.ts b/packages/extension/media/ui/components/hamiltonian_terms.ts new file mode 100644 index 00000000..db05b706 --- /dev/null +++ b/packages/extension/media/ui/components/hamiltonian_terms.ts @@ -0,0 +1,104 @@ +// Hamiltonian term registry (#46, UX1) — pure mapping from the interview's +// physics option labels to rotating-frame Hamiltonian lines, so the view can +// assemble Ĥ(t) live as the user toggles terms. DOM-free by design: unit- +// testable in node, and shared with any future renderer venue. +// +// Conventions (single transmon, qubit-rotating frame, RWA): δ > 0 positive +// convention (the template's), two I/Q drive quadratures always present. + +export interface HamiltonianTerm { + /** Matches an option label / physics slot entry. */ + match: RegExp; + /** One displayed term (unicode math — the fallback + test surface). */ + math: string; + /** The same term as LaTeX (KaTeX-rendered in the panel). */ + latex: string; + /** Physicist aside, rendered dim beside the line. */ + note?: string; + /** Open-system effects enter the Lindbladian, never Ĥ. */ + lindblad?: boolean; +} + +export const HAMILTONIAN_TERMS: HamiltonianTerm[] = [ + { + match: /anharmonicity/i, + math: "− (δ⁄2)·â†â†ââ", + latex: "-\\tfrac{\\delta}{2}\\,\\hat a^{\\dagger}\\hat a^{\\dagger}\\hat a\\hat a", + note: "anharmonicity (δ > 0 convention)", + }, + { + match: /zz|crosstalk/i, + math: "+ ζ·(â†â ⊗ b̂†b̂)", + latex: "+\\,\\zeta\\,\\bigl(\\hat a^{\\dagger}\\hat a\\otimes\\hat b^{\\dagger}\\hat b\\bigr)", + note: "static ZZ with a spectator b̂", + }, + { + match: /coupler/i, + math: "+ g(t)·(â b̂† + ↠b̂)", + latex: "+\\,g(t)\\,\\bigl(\\hat a\\hat b^{\\dagger}+\\hat a^{\\dagger}\\hat b\\bigr)", + note: "tunable-coupler exchange", + }, + { + match: /t1|t2|decoher|dephas|dissipat|relax|noise/i, + math: "𝓛[ρ̂] ⊃ γ₁·𝒟[â] + γ_φ·𝒟[â†â]", + latex: "\\mathcal{L}[\\hat\\rho]\\supset\\gamma_1\\,\\mathcal{D}[\\hat a]+\\gamma_{\\phi}\\,\\mathcal{D}[\\hat a^{\\dagger}\\hat a]", + note: "open-system — enters the Lindbladian, not Ĥ", + lindblad: true, + }, +]; + +/** The controls — every gate problem drives the qubit, so this line is + * unconditional. */ +export const DRIVE_LINE: HamiltonianLine = { + math: "+ u₁(t)·(â + â†) + u₂(t)·i(↠− â)", + latex: "+\\,u_1(t)\\,(\\hat a+\\hat a^{\\dagger})+u_2(t)\\,i\\,(\\hat a^{\\dagger}-\\hat a)", + note: "I/Q drives (always present)", +}; + +/** LHS prefix for the first rendered line. */ +export const LHS_LATEX = "\\hat H(t)/\\hbar \\;=\\; "; + +export interface HamiltonianLine { + math: string; + latex: string; + note?: string; + lindblad?: boolean; +} + +/** Sanitize a free-text label for embedding in \text{} — strip TeX-active + * characters rather than escape-juggling them. */ +function texSafe(label: string): string { + return label.replace(/[\\{}$%&#^_~]/g, " ").trim(); +} + +/** True when a physics option label maps to a known term — the view mounts + * the live-Hamiltonian panel iff a question offers at least one. */ +export function isHamiltonianTerm(label: string): boolean { + return HAMILTONIAN_TERMS.some((t) => t.match.test(label)); +} + +/** Assemble the display lines for a set of selected term labels: drift terms + * first, the drive line always, then interactions, then Lindblad asides. + * Unrecognized labels still show up (captured, agent-interpreted) — the + * panel must never silently drop physics the user asked for. */ +export function hamiltonianLines(selected: string[]): HamiltonianLine[] { + const drift: HamiltonianLine[] = []; + const interactions: HamiltonianLine[] = []; + const lindblad: HamiltonianLine[] = []; + for (const s of selected) { + if (!s.trim() || /^(none|default|just the basics)/i.test(s)) continue; + const t = HAMILTONIAN_TERMS.find((t) => t.match.test(s)); + if (!t) { + interactions.push({ + math: `+ Ĥ⟨${s}⟩(t)`, + latex: `+\\,\\hat H_{\\langle\\text{${texSafe(s)}}\\rangle}(t)`, + note: "captured — Amico will interpret", + }); + } else if (t.lindblad) { + lindblad.push({ math: t.math, latex: t.latex, note: t.note, lindblad: true }); + } else { + (t.math.startsWith("−") ? drift : interactions).push({ math: t.math, latex: t.latex, note: t.note }); + } + } + return [...drift, DRIVE_LINE, ...interactions, ...lindblad]; +} diff --git a/packages/extension/media/ui/views/interview.ts b/packages/extension/media/ui/views/interview.ts new file mode 100644 index 00000000..9b94c6cf --- /dev/null +++ b/packages/extension/media/ui/views/interview.ts @@ -0,0 +1,312 @@ +// UX1 live interview view (#46) — Amico's questions rendered as stacked +// clickable components with a chat composer pinned at the bottom, ending in +// the résumé and a real solve. The agent guides (goal-directed, adaptive); +// this view renders whatever it asks. Terminal hand-off: the solve lights the +// Run Inspector; convergence triggers save-to-catalog; the pulse lands on the +// card. +// +// LAYOUT CONTRACT (fork-transcript ready): this flow ultimately renders inside +// the opencode-fork transcript — an infinite vertical scroll with a chat input +// at the bottom. So: no side rail (stage identity rides the askframe chip), +// content stacks top-to-bottom, and ALL free-text ("Other" answers, résumé +// revisions) flows through the one bottom composer, routed by context. + +import { defineStyle } from "../style"; +import { text } from "../atoms/text"; +import { loader } from "../atoms/loader"; +import { pill } from "../atoms/pill"; +import { askframe } from "../components/askframe"; +import { hamiltonianPanel } from "../components/hamiltonian"; +import { isHamiltonianTerm } from "../components/hamiltonian_terms"; + +defineStyle("interview", ` + body { margin: 0; font-family: var(--text-font); font-size: var(--text-body); + color: var(--vscode-foreground); } + .interview { display: flex; flex-direction: column; gap: var(--space-md); + padding: var(--space-lg); padding-bottom: 0; + max-width: 760px; margin: 0 auto; + min-height: 100vh; box-sizing: border-box; } + .iv-main { display: flex; flex-direction: column; gap: var(--space-md); + flex: 1; min-width: 0; justify-content: flex-end; } + .iv-statusrow { display: flex; align-items: center; gap: var(--space-sm); + min-height: 1.4em; } + .iv-status { color: var(--color-dim); font-style: italic; font-size: var(--text-small); } + .iv-activity { color: var(--color-dim); font-family: var(--text-mono); + font-size: var(--text-small); flex: 1; min-width: 0; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .iv-dock { position: sticky; bottom: 0; margin-top: auto; + display: flex; flex-direction: column; gap: var(--space-xs); + padding: var(--space-sm) 0 var(--space-md); + background: var(--vscode-editor-background, var(--bg-box)); } + .iv-composer { display: flex; flex-direction: column; gap: var(--space-sm); + border: var(--border-width) solid var(--border-color); + border-radius: 12px; padding: var(--space-md); + background: var(--vscode-input-background, var(--bg-plot)); } + .iv-composer:focus-within { border-color: var(--color-accent); } + .iv-composer input { font-family: var(--text-font); font-size: var(--text-body); + color: var(--vscode-input-foreground, var(--vscode-foreground)); + background: transparent; border: none; outline: none; padding: 0; } + .iv-composer input::placeholder { color: var(--vscode-input-placeholderForeground, var(--color-dim)); + opacity: 1; } + .iv-composer input:disabled { opacity: 0.5; } + .iv-composer .cp-controls { display: flex; justify-content: space-between; align-items: center; } + .iv-composer .cp-plus { font-size: var(--text-value); line-height: 1; + color: var(--color-dim); background: none; border: none; + padding: 0 var(--space-xs); cursor: default; } + .iv-composer .cp-send { width: 28px; height: 28px; border-radius: 6px; cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + font-size: var(--text-body); line-height: 1; + border: 1px solid var(--color-accent); + color: var(--color-on-accent, #000); + background: var(--color-accent-fill, var(--color-accent)); } + .iv-composer .cp-send:disabled { opacity: 0.4; cursor: not-allowed; } + .iv-resume button { + font-family: var(--text-font); font-size: var(--text-small); + padding: var(--space-xs) var(--space-md); cursor: pointer; + border: 1px solid var(--color-accent); border-radius: 2px; + color: var(--color-on-accent, #000); + background: var(--color-accent-fill, var(--color-accent)); } + .iv-resume { display: flex; flex-direction: column; gap: var(--space-md); + background: var(--bg-box); + border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius); padding: var(--space-lg); } + .iv-resume .rv-titlerow { display: flex; align-items: center; gap: var(--space-md); } + .iv-resume .rv-title { font-size: var(--text-hero); font-weight: 600; } + .iv-resume .rv-note { color: var(--color-dim); font-size: var(--text-small); } + .iv-resume .rv-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: var(--space-sm); font-size: var(--text-small); } + .iv-resume .rv-kv { display: flex; flex-direction: column; gap: 2px; } + .iv-resume .rv-kv .k { color: var(--color-dim); font-size: var(--text-label); + text-transform: uppercase; letter-spacing: 0.6px; } + .iv-resume .rv-kv .v { font-family: var(--text-mono); } + .iv-resume .rv-actions { display: flex; gap: var(--space-sm); } + .iv-resume .rv-actions button:disabled { opacity: 0.5; cursor: not-allowed; } + .iv-resume .rv-blocked { color: var(--color-dim); font-size: var(--text-small); } + .iv-resume .rv-hint { color: var(--color-dim); font-size: var(--text-small); } + .iv-resume .rv-hint::before { content: "note: "; text-transform: uppercase; + font-size: var(--text-label); letter-spacing: 0.5px; } +`); + +declare function acquireVsCodeApi(): { postMessage(msg: unknown): void }; + +export function createInterview(): { el: HTMLElement } { + const vscodeApi = acquireVsCodeApi(); + const el = document.createElement("div"); + el.className = "interview"; + + const status = text("iv-status", ""); + // Live activity: what the in-flight turn is actually doing (tool calls + + // referenced files, straight from the session — never invented). + const activity = text("iv-activity", ""); + const think = loader("Amico is thinking"); + const statusRow = document.createElement("div"); + statusRow.className = "iv-statusrow"; + statusRow.append(think.el, status.el, activity.el); + const host = document.createElement("div"); + const main = document.createElement("div"); + main.className = "iv-main"; + main.append(host); + + // Bottom dock: the loader/status row rides WITH the chat composer (like a + // typing indicator), and content bottom-anchors just above it — the chat + // reading order. The composer is the ONE free-text affordance, routed by + // context: during a question it carries the "Other" answer; on the résumé + // a revision request; disabled while Amico holds the ball. + const composer = document.createElement("div"); + composer.className = "iv-composer"; + const input = document.createElement("input"); + const controls = document.createElement("div"); + controls.className = "cp-controls"; + const plus = document.createElement("button"); + plus.className = "cp-plus"; + plus.textContent = "+"; + plus.disabled = true; + plus.title = "attachments land with the fork UI"; + const send = document.createElement("button"); + send.className = "cp-send"; + send.textContent = "↑"; + send.setAttribute("aria-label", "send"); + controls.append(plus, send); + composer.append(input, controls); + const dock = document.createElement("div"); + dock.className = "iv-dock"; + dock.append(statusRow, composer); + el.append(main, dock); + + let onFreeText: ((t: string) => void) | undefined; + function setComposer(fn: ((t: string) => void) | undefined, placeholder: string): void { + onFreeText = fn; + input.placeholder = placeholder; + input.disabled = send.disabled = !fn; + } + const submit = (): void => { + const t = input.value.trim(); + if (!t || !onFreeText) return; + input.value = ""; + onFreeText(t); + }; + send.addEventListener("click", submit); + input.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); }); + + function thinking(on: boolean, statusText = ""): void { + think.set(on); + status.set(statusText); + activity.set(""); // activity belongs to one turn — never carries over + if (on) { + host.replaceChildren(); + setComposer(undefined, "Amico is thinking…"); + } + } + + function renderQuestion(requestID: string, questions: Array<{ question: string; header: string; options: Array<{ label: string; description?: string }>; custom?: boolean; multiple?: boolean }>): void { + if (!questions.length) return; // malformed request — never submit an empty reply + thinking(false); + // Render sequentially: collect one answer per question, reply once. + const answers: string[][] = []; + const step = (i: number, answeredStage?: string): void => { + if (i >= questions.length) { + vscodeApi.postMessage({ type: "answer", requestID, answers }); + // The one signal we always have: which stage Amico is working + // through (tool telemetry lights the activity line only when a + // future agent actually uses tools). + thinking(true, answeredStage ? `thinking about ${answeredStage.replace(/-/g, " ")}…` : ""); + return; + } + const q = questions[i]; + host.replaceChildren(); + // Physics-flavored multi-select: the model Hamiltonian assembles live + // under the option cards as terms toggle. Declared before the frame so + // the toggle hook can reach it; assigned only when terms are on offer. + let hm: ReturnType | undefined; + const frame = askframe({ + stage: q.header, + question: q.question, + options: q.options.map((o, j) => ({ label: o.label, description: o.description, default: j === 0 })), + // No "Other…" card: the chat composer IS the free-form path — always + // present, always typable, no mode switch. + other: false, + multiple: q.multiple === true, + }, (value) => { + answers.push(Array.isArray(value) ? value : [value]); + step(i + 1, q.header); + }, (selected) => hm?.set(selected)); + host.append(frame.el); + if (q.multiple === true && q.options.some((o) => isHamiltonianTerm(o.label))) { + hm = hamiltonianPanel(); + hm.set(q.options.length ? [q.options[0].label] : []); // recommended default starts selected + host.append(hm.el); + } + // Composer answers THIS question free-form (same reply slot as a card). + // The placeholder IS the Other affordance — no card, no mode switch; + // multi-select questions invite a fuller custom response. + setComposer((t) => { answers.push([t]); step(i + 1, q.header); }, q.multiple === true ? "Custom response" : "Other"); + }; + step(0); + } + + interface FamilyInfo { label: string; status: string; demoRepo?: string; warmStartPolicy?: string; warmStarts?: Array<{ id: string; note: string }> } + + function renderResume(slots: Record, vars: number, estMinutes: string, estMemory: string, envelope?: { ok: boolean; reason?: string; template?: string }, hints: string[] = [], deltaDefault?: number, family?: FamilyInfo): void { + thinking(false); + host.replaceChildren(); // a stale question/résumé must never stack under a new one + const box = document.createElement("div"); + box.className = "iv-resume"; + const titleRow = document.createElement("div"); + titleRow.className = "rv-titlerow"; + titleRow.append(text("rv-title", envelope?.ok === false ? "Résumé — captured" : "Résumé — ready to solve").el); + // Status pill: WHICH vetted template runs, or where this family's physics + // lives today (demo repo) — a path, not a dead end. + if (envelope?.ok && envelope.template) titleRow.append(pill("done", `vetted · ${envelope.template}`).el); + else if (family?.status === "demo") titleRow.append(pill("idle", "template pending").el); + box.append(titleRow); + const grid = document.createElement("div"); + grid.className = "rv-grid"; + const kv = (k: string, v: string): void => { + const cell = document.createElement("div"); + cell.className = "rv-kv"; + cell.append(text("k", k).el, text("v", v).el); + grid.append(cell); + }; + kv("system", String(slots.system_name ?? slots.modality ?? "—")); + kv("modality", String(slots.modality ?? "—")); + if (Array.isArray(slots.physics) && slots.physics.length) kv("physics", slots.physics.join(", ")); + if (Array.isArray(slots.device_limits) && slots.device_limits.length) kv("device limits", slots.device_limits.join(", ")); + kv("gate", slots.gate === "custom" ? `custom — ${String(slots.gate_spec ?? "?")}` : String(slots.gate ?? "—")); + kv("levels", Array.isArray(slots.levels) ? slots.levels.join(" / ") : String(slots.levels ?? "—")); + // δ is what the solve RUNS with either way — showing the default keeps the + // résumé honest about whose device the pulse is actually solved for. + if (/transmon/i.test(String(slots.modality ?? ""))) { + kv("δ anharmonicity", slots.delta !== undefined ? `${slots.delta} GHz` : `${deltaDefault ?? "—"} GHz (default)`); + } + if (slots.frame) kv("frame", String(slots.frame)); + if (slots.modulation) kv("modulation", String(slots.modulation)); + if (slots.bounds) kv("bounds", String(slots.bounds)); + kv("T", `${slots.T} ns`); + kv("N", String(slots.N ?? "—")); + kv("drive max", `${slots.drive_max} GHz`); + kv("objective", String(slots.objective ?? "vetted default")); + if (Array.isArray(slots.followups) && slots.followups.length) kv("after baseline", slots.followups.join(" → ")); + kv("problem size", `~${vars.toLocaleString()} decision vars`); + kv("est. time", estMinutes); + kv("est. memory", estMemory); + // Which vetted template the deterministic solve leg will run — honest + // provenance, and the row a rydberg/coupler user will watch appear when + // their family's template lands in the registry. + if (envelope?.ok && envelope.template) kv("template", envelope.template); + // Warm-start paths: catalog seeds + the family's doctrine (per-platform — + // fluxonium says cold-only, rydberg baselines from J-P). + if (family?.warmStarts?.length) kv("warm starts", family.warmStarts.map((w) => `${w.id} (${w.note})`).join(" · ")); + box.append(grid); + if (family?.warmStartPolicy) box.append(text("rv-note", `warm-start doctrine: ${family.warmStartPolicy}`).el); + if (family?.status === "demo" && family.demoRepo) box.append(text("rv-note", `${family.label} solves live in ${family.demoRepo} — this configuration is captured and ready for its template`).el); + for (const h of hints) box.append(text("rv-hint", h).el); + + const actions = document.createElement("div"); + actions.className = "rv-actions"; + const solve = document.createElement("button"); + solve.textContent = "Solve"; + const blocked = envelope ? !envelope.ok : false; + solve.disabled = blocked; + if (blocked) solve.title = envelope?.reason ?? ""; + solve.addEventListener("click", () => { + solve.disabled = true; // synchronous — a double-click must not spawn two solves + vscodeApi.postMessage({ type: "solve", slots }); + }); + actions.append(solve); + box.append(actions); + if (blocked) box.append(text("rv-blocked", `Can't solve this configuration yet: ${envelope?.reason ?? ""} Adjust via the chat below, or it stays captured for a future template.`).el); + if (!blocked) box.append(text("iv-status", "Solving runs the vetted template with these parameters — watch the Run Inspector.").el); + host.append(box); + + // Revision through Amico itself, via the ONE chat composer — no per-field forms. + setComposer((t) => { + vscodeApi.postMessage({ type: "answer", requestID: "revise", answers: [[t]] }); + thinking(true, "updating the résumé…"); + }, 'change something? e.g. "make T 20 ns" or "gate H instead"'); + } + + window.addEventListener("message", (e) => { + const m = e.data ?? {}; + switch (m.type) { + case "thinking": thinking(true); break; + // Errors stop the spinner; stall warnings (spinning: true) keep it. + case "status": think.set(Boolean(m.spinning)); status.set(String(m.text ?? "")); break; + case "activity": { + const files = (m.files as string[] | undefined)?.slice(-3) ?? []; + activity.set(`${String(m.label ?? "")}${files.length ? ` — referenced: ${files.join(", ")}` : ""}`); + break; + } + case "question": renderQuestion(String(m.requestID), m.questions ?? []); break; + case "resume": renderResume(m.slots ?? {}, Number(m.vars ?? 0), String(m.estMinutes ?? ""), String(m.estMemory ?? "—"), m.envelope, m.hints ?? [], m.deltaDefault, m.family); break; + case "solving": + host.replaceChildren(); + think.set(false); + status.set("Solve launched — the Run Inspector is streaming it. On convergence you'll be prompted to save to the catalog."); + setComposer(undefined, "solve running — watch the Run Inspector"); + break; + } + }); + + thinking(true); + return { el }; +} diff --git a/packages/extension/package.json b/packages/extension/package.json index 70e3c4ff..0cd9312f 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,7 +1,7 @@ { "name": "amicode-v2", "displayName": "Amicode v2", - "description": "Amico research IDE \u2014 opencode-backed chat + native VS Code surfaces for vault, catalog, and live solve inspection.", + "description": "Amico research IDE — opencode-backed chat + native VS Code surfaces for vault, catalog, and live solve inspection.", "version": "0.0.1", "publisher": "harmoniqs", "license": "Apache-2.0", @@ -87,6 +87,10 @@ { "command": "amicode.catalog.remove", "title": "Remove from Catalog" + }, + { + "command": "amicode.startInterview", + "title": "Amicode: Start pulse-design interview" } ], "configuration": { @@ -145,6 +149,7 @@ "devDependencies": { "@amicode/amico-run": "workspace:*", "@amicode/schema": "workspace:*", + "@types/katex": "^0.16.8", "@types/node": "^22.0.0", "@types/vscode": "^1.95.0", "@vscode/vsce": "^3.2.0", @@ -152,5 +157,8 @@ "smol-toml": "^1.3.0", "typescript": "^5.6.0", "vitest": "^2.1.0" + }, + "dependencies": { + "katex": "^0.17.0" } } \ No newline at end of file diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index b3c3bac5..3daed8f7 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -7,6 +7,7 @@ import { resolveOpencodeBinary, OpencodeMissingError } from "./opencode_binary"; import { ChatPanel } from "./chat_panel"; import { registerRunInspector } from "./run_inspector"; import { registerCatalogCard } from "./catalog_card_shell"; +import { registerInterview } from "./interview_shell"; import { registerTrees } from "./trees"; import { StatusBarManager } from "./status_bar"; import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent } from "./opencode_config"; @@ -116,6 +117,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // 3. opencode project bootstrap const amicoRunBinDir = resolveAmicoRunBinDir(ctx.extensionPath); + registerInterview(ctx, { // #46 — the LIVE interview (Amico-guided, real solve) + serverUrl: () => opencodeReadyUrl, + runsRoot, + juliaProject: resolveJuliaProject(vscode.workspace.getConfiguration("amicode").get("juliaProject", "")), + amicoRunBinDir, + channel: runsChannel, + }); const opencodeProject = prepareOpencodeProject({ agentsSrc: path.resolve(ctx.extensionPath, "AGENTS.md"), templateSrc: path.resolve(ctx.extensionPath, "templates", "solve_template.jl"), diff --git a/packages/extension/src/interview_shell.ts b/packages/extension/src/interview_shell.ts new file mode 100644 index 00000000..f00d4db7 --- /dev/null +++ b/packages/extension/src/interview_shell.ts @@ -0,0 +1,561 @@ +// UX1 live interview (#46) — Amico guides a goal-directed interview; the +// extension renders each question as clickable components and closes the loop +// with a REAL solve. No hardcoded question tree: stages pin GOALS + SLOTS +// (the stage spec below is the agent's instructions); the agent phrases, +// orders, and skips adaptively via opencode's native question tool. +// +// loop: POST /session → one long-lived turn (POST /session/{id}/message) +// questions arrive as native question-tool calls INSIDE that turn — +// the driver POLLS `GET /question` (sessionID-filtered, deduped) and +// answers via POST /question/{requestID}/reply; the turn continues +// server-side across every question REGARDLESS of client connection +// (proven: a turn kept running after its originating POST died). +// The résumé lands as the turn's final text (strict JSON). If the +// model answers with a text-JSON question instead of the tool, the +// fallback parser renders it — both model behaviors are covered. +// solve: the résumé's Solve button fills the vetted template DETERMINISTICALLY +// and spawns amico-run — no LLM in the critical path; the running +// watcher lights the inspector, convergence triggers save-to-catalog, +// the pulse lands named+tagged on the card. Downstream all exists. +// +// Relitigation note: the interview-UX spec locked AskUserQuestion for v1 with +// Amicode as the richer renderer "later" — pulled forward with direct team +// sign-off (Kate, 2026-07-03); the conversation runs through opencode's native +// question protocol, so the question SHAPE is AskUserQuestion by construction. + +import * as cp from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as vscode from "vscode"; +import { templateEnvelope, fillTemplate, platformFamily, PLATFORM_FAMILIES, TEMPLATE_DELTA_DEFAULT, type InterviewSlots } from "./solve_templates"; + +// The solve leg's knowledge (what's vetted, how templates fill, physics hints) +// lives in solve_templates.ts as a REGISTRY — adding a modality is a content +// change there, zero interview code. Re-exported so existing callers/tests +// keep one import surface. +export { templateEnvelope, fillTemplate, physicsHints, canonicalGate, platformFamily, PLATFORM_FAMILIES, TEMPLATE_DELTA_DEFAULT, SOLVE_TEMPLATES, TRANSMON_1Q, type InterviewSlots, type TemplateSpec, type EnvelopeResult, type PlatformFamily } from "./solve_templates"; + +// --------------------------------------------------------------------------- +// Stage spec — the interview's contract (goals + slots), sent to the agent. +// --------------------------------------------------------------------------- + +export const INTERVIEW_STAGES = [ + { + id: "system-setup", + title: "System Setup", + goal: "know what physical system this is and what physics the model must include: modality, a user-assigned system name, where device parameters come from, and which Hamiltonian terms matter", + slots: ["modality (transmon | rydberg/neutral-atom | fluxonium | trapped-ion | bosonic/cavity | NV center | spin qubit | other — ALL are valid interview paths; the PLATFORM PATHS section says which solve in-extension today)", "system_name (user's name for the device, e.g. Emerald-Q3)", "device_source (existing profile | uploaded | manual defaults)", "physics (Hamiltonian terms/effects to include — ask as ONE MULTI-SELECT question (multiple: true) whose options are INDIVIDUAL modality-appropriate terms, e.g. transmon: anharmonicity, tunable coupler, ZZ crosstalk, decoherence; the UI assembles a live Hamiltonian from the selection, so never bundle two terms into one option label)", "device_limits (OPTIONAL — hardware constraints worth honoring: T1/T2 coherence, AWG bandwidth/sample rate, max output; use them to sanity-check T and drive_max)"], + }, + { + id: "define-model", + title: "Define Model", + goal: "know how faithful the simulation model must be, and in what frame", + slots: ["levels (per subsystem — scalar for a single qubit, array when the model has multiple subsystems; convention: transmon qubits ≈3 levels, tunable couplers ≈5)", "delta (OPTIONAL — transmon anharmonicity δ in GHz, POSITIVE convention; default 0.2 until device profiles land. A physicist with a real device knows their δ — recording it makes the solved pulse THEIR device's pulse, so ask for it whenever the device is real rather than hypothetical)", "drive_max (per-quadrature bound, GHz; real transmon hardware typically ≈0.05 GHz — the 0.2 GHz demo default is generous, so ask which regime they're in)", "n_drives (usually 2 quadratures)", "frame (OPTIONAL, transmon default: qubit-rotating frame with RWA; capture lab-frame or other choices verbatim)", "modulation (OPTIONAL, default: baseband I/Q on resonance; capture sideband/detuned schemes verbatim)", "bounds (OPTIONAL — anything beyond the symmetric per-quadrature cap: asymmetric bounds, slew-rate/derivative limits)"], + }, + { + id: "target", + title: "Target", + goal: "know exactly which unitary the pulse must implement", + slots: ["gate (X | Y | Z | H | S | T | sqrtX — the vetted set; for other unitaries set gate to \"custom\" with the user's description verbatim in gate_spec; for state preparation (|ψ₀⟩→|ψ_goal⟩, a different problem type than a gate) set gate to \"state-prep\" and describe both states in gate_spec. If the user picks Z, S, or T, note in the question description that hardware usually does these as virtual-Z frame updates for free — still solvable as a pulse if they really want one)"], + }, + { + id: "problem", + title: "Problem", + goal: "know how the search is formulated and its budget", + slots: ["objective (vetted-default = smooth-pulse baseline: fidelity objective + smoothness regularization | description of extras)", "T (gate time, ns — team-vault prior for transmon 1Q: 15–60 ns realistic, sweet spot ≈34–42 ns; T should GROW with levels, and fidelity ceilings under 80% usually mean T vs truncation, not hyperparameters)", "N (timesteps/knot points — the PRIMARY resolution knob; more often helps fidelity, but not universally)", "max_iter", "followups (OPTIONAL — advanced stages wanted AFTER the baseline: min-time | robustness | leakage suppression; these warm-start from the baseline pulse)"], + }, +] as const; + +/** Rough problem-size + wall-time estimate for the résumé. Pure. */ +export function estimateProblem(slots: InterviewSlots): { vars: number; estMinutes: string; estMemory: string } { + // Hilbert dim = product of per-subsystem levels (scalar = single subsystem). + const dim = (Array.isArray(slots.levels) ? slots.levels : [slots.levels]).reduce((a, b) => a * b, 1); + const iso = 2 * dim * dim; // iso-vectorized unitary per knot + const vars = slots.N * (iso + (slots.n_drives ?? 2) * 3 + 1); // states + controls/derivs + Δt + const estMinutes = vars < 3000 ? "2–3 min" : vars < 10000 ? "5–10 min" : "10+ min"; + // Coarse sparse-KKT bucket — informational (solves route to cloud anyway). + const estMemory = vars < 5000 ? "<1 GB" : vars < 20000 ? "1–4 GB" : "8+ GB"; + return { vars, estMinutes, estMemory }; +} + +/** Kickoff prompt: stage goals + the strict JSON turn protocol. Every agent + * reply is machine-parsed — one JSON object, nothing else. Pure; exported. */ +export function buildKickoffPrompt(): string { + const stages = INTERVIEW_STAGES.map((s, i) => + `${i + 1}. ${s.title} (header: "${s.id}") — goal: ${s.goal}. Slots: ${s.slots.join("; ")}.`).join("\n"); + const platforms = PLATFORM_FAMILIES.map((f) => + `- ${f.label}: ${f.status === "vetted" ? "VETTED template — solvable right here" : `working solves live in ${f.demoRepo} — capture faithfully; solvable here once its template lands`}${f.warmStartPolicy ? `. Warm-start doctrine: ${f.warmStartPolicy}` : ""}${f.warmStarts?.length ? `. Catalog seeds: ${f.warmStarts.map((w) => `${w.id} (${w.note})`).join(", ")}` : ""}`).join("\n"); + return `You are Amico, guiding a pulse-design interview. Work through these stages IN ORDER, but adapt freely within them: + +${stages} + +PLATFORM PATHS — offer ALL of these as modality options (every platform is a valid interview path). Be honest in option descriptions about which solve in-extension today vs which exist as demo-repo physics, and use each family's warm-start doctrine when discussing follow-ups (NEVER suggest warm starts for fluxonium): +${platforms} + +PROTOCOL: +- To ask the next question, use the QUESTION TOOL (one call per question): header = the current stage id EXACTLY as given above; a focused question; 2-5 options with the recommended default FIRST (label 1-4 words, description says why); allow a custom answer. Never ask questions as plain text. +- When every slot is known, finish the conversation with EXACTLY ONE JSON text object and nothing else (no prose, no code fences, no tool call): + {"type":"resume","slots":{"modality":"...","system_name":"...","device_source":"...","physics":["anharmonicity"],"levels":3,"drive_max":0.2,"n_drives":2,"gate":"X","objective":"vetted-default","T":10,"N":50,"max_iter":60}} + (numbers as numbers, no strings for numeric slots; "levels" is per subsystem — a scalar for a single qubit, an array like [3,5,3] when the model includes couplers/multiple subsystems) + +Rules: +- ONE question at a time. Single-select by default; the LIST-LIKE slots (physics, followups) are the exception — ask each as ONE multi-select question (set multiple: true) whose options are individual canonical terms (e.g. "anharmonicity", "ZZ crosstalk", "tunable coupler"). The UI renders a live Hamiltonian that updates as the user toggles terms, so option labels must be single terms, never bundles. A multi-select answer arrives as the list of selected labels; custom answers may still carry lists in prose. +- Free-text answers: interpret them into slots yourself (e.g. "two 3-level transmons with a 5-level tunable coupler" → modality transmon, levels [3,5,3], physics includes "tunable coupler"). +- Capture the user's system FAITHFULLY even if it exceeds today's vetted solve templates (other modalities, couplers, multi-qubit, custom gates, non-default frames) — never steer them to a simpler system; the extension decides what is solvable. +- OPTIONAL slots (physics extras, device_limits, frame, modulation, bounds, gate_spec): offer the default and move on — only dig in when the user signals they care. Include them in the resume slots only when the user chose something. Use device_limits to sanity-check T and drive_max (e.g. warn inside a question's description if T approaches T2). +- A gate outside X/Y/Z/H/S/T/sqrtX: set "gate":"custom" and record the user's exact description in "gate_spec". +- The workflow is FIDELITY-FIRST: robustness, min-time, and leakage suppression are later stages that warm-start from the baseline smooth pulse — never fold them into the first solve or promise them in it. If the user wants them, say so in the question's description (baseline first, then composed via warm-start) and capture the intent in "followups". (For the record: min-time composes from a free-time baseline with variable timesteps; robustness samples perturbed systems around the warm start.) +- The user may revise any earlier answer at ANY point, including after the resume ("change T to 20"). Update the slots and reply per the protocol — a follow-up question if the change requires one, otherwise the updated resume. +- Do NOT write Julia, do NOT run anything. The extension runs the solve after the user confirms the résumé. +Begin now with the first question.`; +} + +// --------------------------------------------------------------------------- + +export interface InterviewDeps { + serverUrl: () => URL | undefined; + runsRoot: string; + juliaProject: string; + amicoRunBinDir?: string; + channel: vscode.OutputChannel; +} + +/** Best-effort activity extraction from an in-flight turn's message parts — + * what Amico is doing RIGHT NOW (newest tool call) and which files the turn + * has referenced. Defensive by design: opencode part shapes vary by version, + * so unknown parts are ignored and nothing is ever invented — no tool parts + * means no activity label, and the dock just says "thinking". Pure; exported. */ +export function extractActivity(msgs: unknown): { label?: string; files: string[] } { + const files: string[] = []; + let label: string | undefined; + const list = Array.isArray(msgs) ? msgs : []; + const last = [...list].reverse().find((m) => (m as { info?: { role?: string } })?.info?.role === "assistant") as + | { parts?: Array> } | undefined; + const pathIn = (v: unknown): string | undefined => { + if (typeof v !== "string" || !/[\\/]|\.\w{1,5}$/.test(v) || /\s{2}|\n/.test(v)) return undefined; + return v.replace(/^.*[\\/]/, ""); + }; + for (const p of last?.parts ?? []) { + const type = String(p.type ?? ""); + if (!/tool/.test(type)) continue; + const name = String((p.tool as string) ?? (p.name as string) ?? "").trim(); + // The question tool IS the interview protocol, not activity — surfacing + // "question" as what Amico is doing is noise (evidence: it's the only + // tool today's interview agent ever calls). + if (/^question$/i.test(name)) continue; + // File-ish strings live in whichever bag this opencode version uses. + const bags = [p.input, p.args, (p.state as Record | undefined)?.input]; + let file: string | undefined; + for (const bag of bags) { + if (bag && typeof bag === "object") { + for (const v of Object.values(bag as Record)) { + file ??= pathIn(v); + } + } + } + if (file && !files.includes(file)) files.push(file); + if (name) label = file ? `${name} · ${file}` : name; + } + return { label, files }; +} + +/** Extract the protocol JSON from a reply — tolerates code fences and + * surrounding whitespace, nothing more. Exported for tests. */ +export function extractJson(reply: string): Record | undefined { + const trimmed = reply.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, ""); + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start < 0 || end <= start) return undefined; + try { return JSON.parse(trimmed.slice(start, end + 1)) as Record; } + catch { return undefined; } +} + +export function registerInterview(ctx: vscode.ExtensionContext, deps: InterviewDeps): void { + ctx.subscriptions.push(vscode.commands.registerCommand("amicode.startInterview", async () => { + const base = deps.serverUrl(); + if (!base) { + void vscode.window.showWarningMessage("Amicode: opencode server isn't ready yet."); + return; + } + + const panel = vscode.window.createWebviewPanel( + "amicode.interview", "Pulse-Design Interview", vscode.ViewColumn.One, + { + enableScripts: true, retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.joinPath(ctx.extensionUri, "dist"), vscode.Uri.joinPath(ctx.extensionUri, "media")], + }, + ); + const uri = (...p: string[]) => panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, ...p)); + const nonce = Math.random().toString(36).slice(2); + panel.webview.html = ` + + + + + + + +`; + + const driver = new InterviewDriver(base, panel, deps); + panel.onDidDispose(() => driver.dispose()); + await driver.start(); + })); +} + +/** One pending question request from `GET /question` (QuestionRequest). */ +interface QuestionRequest { + id: string; + sessionID: string; + questions: Array<{ question: string; header: string; options: Array<{ label: string; description?: string }>; custom?: boolean; multiple?: boolean }>; +} + +class InterviewDriver { + private sessionID?: string; + private turn = 0; + private turnInFlight = false; + private turnAbandoned = false; + private awaitingUser = false; + private ticking = false; + private retryCount = 0; + private turnStartedAt = 0; + private lastActivity = 0; + private stallWarned = false; + private activityTick = 0; + private lastActivityLabel = ""; + private lastPartsShape = ""; + private readonly seenQuestions = new Set(); + private poller?: NodeJS.Timeout; + private disposed = false; + + constructor( + private readonly base: URL, + private readonly panel: vscode.WebviewPanel, + private readonly deps: InterviewDeps, + ) { + this.panel.webview.onDidReceiveMessage((m) => void this.onWebviewMessage(m)); + } + + private api(p: string): string { + return new URL(p, this.base).toString(); + } + + private log(line: string): void { + this.deps.channel.appendLine(`[interview] ${line}`); + } + + async start(): Promise { + this.post({ type: "thinking" }); + this.log(`start — server ${this.base}`); + try { + const res = await fetch(this.api("/session"), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "pulse-design interview" }), + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) throw new Error(`session create HTTP ${res.status}`); + const session = (await res.json()) as { id?: string }; + if (typeof session.id !== "string" || !session.id) throw new Error("session create returned no id"); + this.sessionID = session.id; + this.log(`session created ${session.id}`); + this.beginTurn(buildKickoffPrompt()); + } catch (err) { + this.log(`start FAILED: ${(err as Error).message}`); + this.post({ type: "status", text: `couldn't start the interview: ${(err as Error).message}` }); + } + } + + /** Fire one long-lived turn: POST /session/{id}/message. The turn spans + * every question-tool call the agent makes (answered via the poller); its + * final text is the résumé. The POST carries NO timeout — a whole interview + * can live inside one turn; stall UX comes from the activity watchdog. + * (The queue-based /api/.../prompt path needs a connected web client and + * silently stalls headless; the question tool blocks the turn until the + * reply API answers it — both learned the hard way.) */ + private beginTurn(text: string): void { + if (!this.sessionID || this.turnInFlight) return; + this.post({ type: "thinking" }); + this.turnInFlight = true; + this.turnAbandoned = false; + this.turnStartedAt = Date.now(); + this.touch(); + this.startPoller(); + this.log(`turn → POST (${text.length} chars)`); + const t0 = Date.now(); + void fetch(this.api(`/session/${this.sessionID}/message`), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ parts: [{ type: "text", text }] }), + }).then(async (r) => { + this.turnInFlight = false; + if (!r.ok) { + this.log(`turn ← HTTP ${r.status} after ${Date.now() - t0}ms: ${(await r.text()).slice(0, 300)}`); + this.post({ type: "status", text: `Amico errored (HTTP ${r.status}). If no question is showing, restart the interview (run the command again).` }); + return; + } + const msg = (await r.json()) as { parts?: Array<{ type: string; text?: string }> }; + const reply = (msg.parts ?? []).filter((p) => p.type === "text").map((p) => p.text ?? "").join("\n"); + this.log(`turn ← 200 after ${Date.now() - t0}ms (${reply.length} chars)`); + this.onTurnComplete(reply); + }).catch((err: Error) => { + // The connection died — the turn may STILL be running server-side and + // the poller keeps answering its questions. The recovery poll picks the + // final text out of the session transcript when the turn completes. + this.turnInFlight = false; + this.turnAbandoned = true; + this.log(`turn transport error after ${Date.now() - t0}ms: ${err.name} ${err.message} — switching to transcript recovery`); + }); + } + + private touch(): void { + this.lastActivity = Date.now(); + this.stallWarned = false; + } + + /** Poll pending question requests (+ stall watchdog). `GET /question` is + * sessionID-filtered and deduped, and — unlike SSE — recovers questions + * asked while nobody was listening. */ + private startPoller(): void { + if (this.poller) return; + this.poller = setInterval(() => void this.pollTick(), 1500); + } + + private async pollTick(): Promise { + if (this.disposed || !this.sessionID || this.ticking) return; + // Idle: questions only arrive while a turn runs (or ran, abandoned). + if (!this.turnInFlight && !this.turnAbandoned) return; + this.ticking = true; + try { + const res = await fetch(this.api("/question"), { signal: AbortSignal.timeout(3000) }); + if (res.ok) { + const requests = (await res.json()) as QuestionRequest[]; + for (const req of requests) { + if (req.sessionID !== this.sessionID || this.seenQuestions.has(req.id)) continue; + this.seenQuestions.add(req.id); + this.touch(); + this.awaitingUser = true; // ball in the user's court — watchdog off + this.log(`question ${req.id} [${req.questions[0]?.header ?? "?"}]`); + this.post({ type: "question", requestID: req.id, questions: req.questions }); + } + } + } catch { /* transient poll failure — next tick retries */ } + try { + // Activity surfacing: every other tick, read the in-flight turn's parts + // and show what Amico is actually doing (newest tool call + files + // referenced). Real server activity also feeds the stall watchdog. + if ((this.turnInFlight || this.turnAbandoned) && !this.awaitingUser && ++this.activityTick % 2 === 0) { + try { + const res = await fetch(this.api(`/session/${this.sessionID}/message`), { signal: AbortSignal.timeout(3000) }); + if (res.ok) { + const msgs = (await res.json()) as Array<{ info?: { role?: string }; parts?: Array> }>; + // EVIDENCE (activity debug): what does the in-flight assistant + // message actually carry? Logged once per shape change so the + // channel shows the real part vocabulary of this opencode build. + const lastA = [...msgs].reverse().find((m) => m?.info?.role === "assistant"); + const shape = (lastA?.parts ?? []).map((p) => `${String(p.type)}${p.tool ? `:${String(p.tool)}` : ""}`).join(",") || "(no parts)"; + if (shape !== this.lastPartsShape) { + this.lastPartsShape = shape; + this.log(`activity parts: ${shape}`); + } + const { label, files } = extractActivity(msgs); + const key = `${label ?? ""}|${files.join(",")}`; + if (label && key !== this.lastActivityLabel) { + this.lastActivityLabel = key; + this.touch(); // tool calls progressing = not stalled + this.post({ type: "activity", label, files }); + } + } + } catch { /* transient — next tick retries */ } + } + // Recovery: the POST died but the turn kept running server-side — read + // its ending straight from the session transcript. + if (this.turnAbandoned && !this.turnInFlight) { + if (await this.tryRecoverFinalText()) this.turnAbandoned = false; + } + // Stall watchdog: warn once when an in-flight (or abandoned) turn has + // been silent for 3 minutes — never while the ball is in the user's court. + if ((this.turnInFlight || this.turnAbandoned) && !this.awaitingUser && !this.stallWarned && this.lastActivity && Date.now() - this.lastActivity > 180_000) { + this.stallWarned = true; + this.log(`stall: no activity for ${Math.round((Date.now() - this.lastActivity) / 1000)}s`); + this.post({ type: "status", text: "Amico is taking unusually long — the model server may be busy. Hang on, or restart the interview.", spinning: true }); + } + } finally { + this.ticking = false; + } + } + + /** Transcript recovery: after a client-side transport failure, the turn's + * final text is still in the session — take the newest COMPLETED assistant + * message's text. Returns false while the agent is still generating. */ + private async tryRecoverFinalText(): Promise { + if (!this.sessionID) return false; + try { + const res = await fetch(this.api(`/session/${this.sessionID}/message`), { signal: AbortSignal.timeout(3000) }); + if (!res.ok) return false; + const msgs = (await res.json()) as Array<{ info?: { role?: string; time?: { completed?: number } }; parts?: Array<{ type: string; text?: string }> }>; + const last = [...msgs].reverse().find((m) => m.info?.role === "assistant"); + if (!last?.info?.time?.completed) return false; + // Watermark: only accept an ending NEWER than the turn we abandoned — + // recovering an older message would silently drop the user's input + // (e.g. re-render a stale résumé after a failed revision POST). + if (last.info.time.completed < this.turnStartedAt - 2000) return false; + const text = (last.parts ?? []).filter((p) => p.type === "text").map((p) => p.text ?? "").join("\n"); + if (!text.trim()) return false; + this.log(`recovered final text from transcript (${text.length} chars)`); + this.onTurnComplete(text); + return true; + } catch { return false; } + } + + /** The turn's final text: the résumé (strict JSON) — or, fallback, a + * question the model asked as text instead of via the tool. */ + private onTurnComplete(reply: string): void { + this.touch(); + const json = extractJson(reply); + this.log(`reply parsed: ${json ? String(json.type) : "PROTOCOL BREAK"}`); + if (json?.type === "resume" || json?.type === "question") this.retryCount = 0; + if (json?.type === "resume" && json.slots && typeof json.slots === "object") { + const slots = json.slots as InterviewSlots; + const est = estimateProblem(slots); + const env = templateEnvelope(slots); + const fam = platformFamily(slots.modality); + this.awaitingUser = true; + this.post({ + type: "resume", slots, vars: est.vars, estMinutes: est.estMinutes, estMemory: est.estMemory, + // Serialize the envelope for the webview: ok/reason + WHICH vetted + // template will run (the spec object itself stays extension-side). + envelope: { ok: env.ok, reason: env.reason, template: env.template?.id }, + // Hints belong to the matched template (transmon hints on a rydberg + // config would be wrong physics) — a blocked résumé gets none. + hints: env.template?.hints?.(slots) ?? [], + deltaDefault: TEMPLATE_DELTA_DEFAULT, + // Platform-family info: warm-start doctrine + catalog seeds (regex + // stripped — the webview gets plain data). + family: fam && { label: fam.label, status: fam.status, demoRepo: fam.demoRepo, warmStartPolicy: fam.warmStartPolicy, warmStarts: fam.warmStarts }, + }); + return; + } + if (json?.type === "question" && typeof json.question === "string" && Array.isArray(json.options)) { + this.turn += 1; + this.awaitingUser = true; + this.post({ type: "question", requestID: `turn-${this.turn}`, questions: [{ + question: json.question, + header: String(json.header ?? ""), + options: (json.options as Array<{ label?: string; description?: string }>).map((o) => ({ + label: String(o.label ?? ""), description: o.description, + })), + custom: true, + multiple: json.multiple === true, // the physics question stays multi-select through the fallback too + }] }); + return; + } + if (this.retryCount < 2) { + this.retryCount += 1; + this.beginTurn("Continue per the protocol: ask the next question with the question tool, or reply with EXACTLY the resume JSON object and nothing else."); + return; + } + this.post({ type: "status", text: "Amico keeps breaking the reply protocol — restart the interview (run the command again)." }); + } + + private async onWebviewMessage(m: Record): Promise { + if (m.type === "answer") { + const requestID = String(m.requestID ?? ""); + const answers = (m.answers as string[][]) ?? []; + // Path record (proposed instrumentation, local-only). + this.log(`answer ${requestID} ${JSON.stringify(answers)}`); + this.post({ type: "thinking" }); + this.awaitingUser = false; + this.touch(); + if (requestID.startsWith("que")) { + // Native question-tool answer → the reply API resolves the tool call + // and the in-flight turn continues server-side. + try { + const r = await fetch(this.api(`/question/${requestID}/reply`), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ answers }), + signal: AbortSignal.timeout(10_000), + }); + if (!r.ok) { + this.log(`reply ← HTTP ${r.status}: ${(await r.text()).slice(0, 200)}`); + // Un-see it: if the request is still pending server-side the next + // poll tick re-renders it; if it's truly gone this is harmless. + this.seenQuestions.delete(requestID); + this.post({ type: "status", text: `answer didn't reach Amico (HTTP ${r.status}) — the question will re-appear shortly; if it doesn't, restart the interview.`, spinning: true }); + } + } catch (err) { + this.log(`reply FAILED: ${(err as Error).message}`); + this.seenQuestions.delete(requestID); + this.post({ type: "status", text: `answer didn't reach Amico (${(err as Error).message}) — the question will re-appear shortly; if it doesn't, restart the interview.`, spinning: true }); + } + } else { + // Text-JSON fallback question, or a résumé revision — a fresh turn. + this.beginTurn(answers.map((a) => a.join(", ")).join(" | ")); + } + } + if (m.type === "solve") { + this.launchSolve(m.slots as InterviewSlots); + } + } + + /** Deterministic solve leg: fill the vetted template, spawn amico-run. The + * running watcher takes it from there (inspector → save-to-catalog → card). */ + private launchSolve(slots: InterviewSlots): void { + const env = templateEnvelope(slots); + if (!env.ok || !env.template) { + this.post({ type: "status", text: `can't solve this configuration yet: ${env.reason}` }); + return; + } + try { + const templatePath = path.join(this.panelRoot(), "templates", env.template.templateFile); + const script = fillTemplate(fs.readFileSync(templatePath, "utf8"), slots, env.template); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "amicode-solve-")); + const scriptPath = path.join(dir, "solve.jl"); + fs.writeFileSync(scriptPath, script); + + const launcher = this.deps.amicoRunBinDir ? path.join(this.deps.amicoRunBinDir, "amico-run") : "amico-run"; + const args = [scriptPath, "--runs-root", this.deps.runsRoot, "--project", this.deps.juliaProject]; + this.deps.channel.appendLine(`[interview] solve → ${launcher} ${args.join(" ")}`); + const child = cp.spawn(launcher, args, { stdio: ["ignore", "pipe", "pipe"] }); + child.stdout.on("data", (b: Buffer) => this.deps.channel.append(`[solve] ${b.toString()}`)); + child.stderr.on("data", (b: Buffer) => this.deps.channel.append(`[solve!] ${b.toString()}`)); + child.on("error", (err) => { + this.post({ type: "status", text: `solve failed to launch: ${err.message}` }); + }); + this.post({ type: "solving" }); + void vscode.commands.executeCommand("amicode.runInspector.focus").then(undefined, () => undefined); + } catch (err) { + this.post({ type: "status", text: `solve failed: ${(err as Error).message}` }); + } + } + + private panelRoot(): string { + // extension root: dist/ and templates/ are siblings under the extension dir + return path.join(__dirname, ".."); + } + + private post(m: unknown): void { + if (this.disposed) return; + try { void this.panel.webview.postMessage(m); } catch { /* panel torn down */ } + } + + dispose(): void { + this.disposed = true; + if (this.poller) clearInterval(this.poller); + const sid = this.sessionID; + if (!sid) return; + // Abort releases the turn but does NOT clear pending question requests + // (verified live) — reject ours explicitly or they leak server-side. + void (async () => { + try { + const res = await fetch(this.api("/question"), { signal: AbortSignal.timeout(3000) }); + if (res.ok) { + const requests = (await res.json()) as QuestionRequest[]; + for (const req of requests.filter((r) => r.sessionID === sid)) { + await fetch(this.api(`/question/${req.id}/reject`), { method: "POST", signal: AbortSignal.timeout(3000) }).catch(() => undefined); + } + } + } catch { /* server gone — nothing to clean */ } + await fetch(this.api(`/session/${sid}/abort`), { method: "POST" }).catch(() => undefined); + })(); + } +} diff --git a/packages/extension/src/interview_webview.ts b/packages/extension/src/interview_webview.ts new file mode 100644 index 00000000..0ee60743 --- /dev/null +++ b/packages/extension/src/interview_webview.ts @@ -0,0 +1,6 @@ +// UX1 interview webview entry (#46) — mounts the live interview view. +import { applyBrandAccent } from "../media/ui/brand_accent"; +import { createInterview } from "../media/ui/views/interview"; + +applyBrandAccent(); // theme-calculated Harmoniqs yellow (brand-wide contract) +document.body.append(createInterview().el); diff --git a/packages/extension/src/solve_templates.ts b/packages/extension/src/solve_templates.ts new file mode 100644 index 00000000..ee9af346 --- /dev/null +++ b/packages/extension/src/solve_templates.ts @@ -0,0 +1,355 @@ +// Solve-template registry (#46) — the solve leg's knowledge, as data. +// +// The interview captures ANY modality faithfully; what's solvable is decided +// here, deterministically (no LLM in the critical path). Each vetted template +// is one registry entry declaring what it HONESTLY models: the modality, the +// gate set, the levels shape, the physics terms, and how its FILL-IN block is +// substituted. Adding a modality (rydberg CZ, qubit+coupler transmon, …) is a +// content change — a new .jl template + a new entry (+ its formulation.toml +// schema branch, per #81's landing pattern) — and touches ZERO interview code. +// +// "Vetted" is a physicist's signature, not a vibe: an entry must only declare +// physics its template actually implements (rydberg's dark |0⟩ means a rydberg +// entry must refuse 1Q gates; fluxonium's warm-start regression means its +// entry ships cold-start policy). The registry is the honesty gate — it never +// guesses, and an unmatched configuration returns a nameable reason instead. + +// --------------------------------------------------------------------------- +// Slots — the interview's résumé contract (owned here so the registry is +// self-contained; interview_shell re-exports for its callers/tests). +// --------------------------------------------------------------------------- + +export interface InterviewSlots { + modality: string; + system_name?: string; + device_source?: string; + /** Hamiltonian terms/effects the model includes (e.g. ["anharmonicity"]). */ + physics?: string[]; + /** Hardware constraints (T1/T2, AWG bandwidth…) — informational, never blocking. */ + device_limits?: string[]; + /** Per-subsystem: scalar for a single qubit, array for qubit+coupler models. */ + levels: number | number[]; + /** Transmon anharmonicity δ (GHz, positive convention); absent = template default. */ + delta?: number; + drive_max: number; + n_drives?: number; + /** Rotating-frame choice; absent = the template's default (qubit frame, RWA). */ + frame?: string; + /** Modulation scheme; absent = the template's default (baseband I/Q on resonance). */ + modulation?: string; + /** Bounds beyond the symmetric per-quadrature cap (asymmetric, slew-rate…). */ + bounds?: string; + gate: string; + /** Verbatim description when gate === "custom". */ + gate_spec?: string; + objective: string; + /** Stages composed AFTER the baseline via warm-start (min-time, robustness, + * leakage suppression) — captured intent, never part of the first solve. */ + followups?: string[]; + T: number; + N: number; + max_iter: number; +} + +/** Canonical Piccolo GATES key for a vetted gate answer — "h" → "H", + * "SQRTX" → "sqrtX". Julia symbol lookup is case-sensitive even though + * matching accepts any case. Pure; exported for tests. */ +export function canonicalGate(gate: string): string { + return /^sqrtx$/i.test(gate) ? "sqrtX" : gate.toUpperCase(); +} + +// --------------------------------------------------------------------------- +// TemplateSpec — one vetted template's declaration. +// --------------------------------------------------------------------------- + +export interface TemplateSpec { + /** Registry id, shown on the résumé (e.g. "transmon-1q"). */ + id: string; + /** Human label used in refusal messages (e.g. "single transmon"). */ + label: string; + /** Template filename under the extension's templates/ dir. */ + templateFile: string; + /** Which modality answers this template serves. */ + modality: RegExp; + /** The vetted gate set. */ + gates: RegExp; + /** Refusal text naming the gate set (kept human, not derived from the regex). */ + gatesLabel: string; + /** How many subsystems the model supports (levels array length). */ + maxSubsystems: number; + /** Physics terms the template models — anything else is an honest refusal. + * (default/none-style answers are always allowed through.) */ + physics: RegExp; + /** Accepted frame answers beyond "absent = default". */ + frame: RegExp; + frameLabel: string; + /** Accepted modulation answers beyond "absent = default". */ + modulation: RegExp; + modulationLabel: string; + /** Whether richer bounds (asymmetric, slew-rate) are modeled. */ + supportsBounds: boolean; + /** Per-template parameter validation (e.g. transmon δ > 0). */ + paramCheck?: (slots: InterviewSlots) => string | undefined; + /** FILL-IN substitutions: pattern → replacement (undefined = leave the + * template's own default line untouched). fillTemplate throws LOUDLY when + * a pattern is missing — template drift must never silently run defaults. */ + fill: Array<{ pattern: RegExp; value: (slots: InterviewSlots) => string | undefined }>; + /** Physicist sanity hints for the résumé — soft advice, never blockers. */ + hints?: (slots: InterviewSlots) => string[]; +} + +// --------------------------------------------------------------------------- +// transmon-1q — the first vetted template (P6 ✓, single transmon, qubit- +// rotating frame + RWA, baseband I/Q on resonance, anharmonicity physics). +// --------------------------------------------------------------------------- + +/** The vetted template's built-in anharmonicity — what actually runs when the + * user didn't supply their own δ. Kept in sync with solve_template.jl by the + * template-drift test. */ +export const TEMPLATE_DELTA_DEFAULT = 0.2; + +/** Physicist sanity hints (transmon). Pure; exported for tests. */ +export function physicsHints(slots: InterviewSlots): string[] { + const hints: string[] = []; + const δ = slots.delta ?? TEMPLATE_DELTA_DEFAULT; + const { T, N, drive_max } = slots; + if (![T, N, drive_max].every((v) => typeof v === "number" && Number.isFinite(v) && v > 0)) return hints; + // Drive area: a π rotation needs ∫Ω dt on the order of a half Rabi cycle. + // T·drive_max ≪ that and the optimizer has nothing to work with — the classic + // "why is it stagnating" trap for π-class gates (X/Y/H/sqrtX). + if (/^(X|Y|H|sqrtX)$/i.test(slots.gate) && T * drive_max < 0.5) { + hints.push(`under-driven: T·drive_max = ${(T * drive_max).toFixed(2)} GHz·ns — likely too little drive area for a π-class rotation; expect stagnation. Increase T or drive_max.`); + } + // Leakage regime: gate times inside ~1/δ can't spectrally avoid the |1⟩→|2⟩ + // transition — fidelity caps out unless levels/DRAG-like shaping absorb it. + if (T * δ < 1) { + hints.push(`fast-gate regime: T = ${T} ns is inside ~1/δ (${(1 / δ).toFixed(1)} ns at δ = ${δ} GHz) — leakage will limit fidelity; consider a longer T or more levels for realism.`); + } + // Control resolution: fewer than ~2 knots per 1/δ undersamples the very + // dynamics the anharmonicity introduces. + if (T / N > 0.5 / δ) { + hints.push(`coarse control grid: dt = ${(T / N).toFixed(2)} ns exceeds ~1/(2δ) — raise N (the primary resolution knob) to resolve the anharmonic dynamics.`); + } + return hints; +} + +export const TRANSMON_1Q: TemplateSpec = { + id: "transmon-1q", + label: "single transmon", + templateFile: "solve_template.jl", + modality: /transmon/i, + gates: /^(X|Y|Z|H|S|T|sqrtX)$/i, + gatesLabel: "X/Y/Z/H/S/T/sqrtX", + maxSubsystems: 1, + physics: /anharmonicity/i, + frame: /rotating|rwa|default/i, + frameLabel: "the qubit-rotating frame (RWA)", + modulation: /baseband|resonance|i\/?q|default/i, + modulationLabel: "baseband I/Q on resonance", + supportsBounds: false, + paramCheck: (slots) => { + // δ is optional, but a supplied one must be physical: the template documents + // the POSITIVE convention, so a lab-convention negative δ must bounce here + // rather than silently build an inverted Hamiltonian. + if (slots.delta !== undefined && (typeof slots.delta !== "number" || !Number.isFinite(slots.delta) || slots.delta <= 0)) { + return `"delta" must be a positive number in GHz (positive convention — e.g. 0.2 for -200 MHz lab anharmonicity)`; + } + return undefined; + }, + fill: [ + { pattern: /^system\s*=\s*"[^"]*"/m, value: (s) => `system = ${JSON.stringify(s.modality)}` }, + { pattern: /^gate_name\s*=\s*"[^"]*"/m, value: (s) => `gate_name = ${JSON.stringify(canonicalGate(s.gate))}` }, + // δ: the user's own anharmonicity when they gave one (a real device's pulse + // must be solved against THAT device's δ); template default otherwise. + { pattern: /^δ\s*=\s*[\d.]+/m, value: (s) => (s.delta !== undefined ? `δ = ${s.delta}` : undefined) }, + // Scalar by construction: matchTemplate gates the solve leg to + // single-subsystem configs before fillTemplate ever runs. + { pattern: /^levels\s*=\s*\d+/m, value: (s) => `levels = ${Math.trunc(Array.isArray(s.levels) ? s.levels[0] : s.levels)}` }, + { pattern: /^T\s*=\s*[\d.]+/m, value: (s) => `T = ${s.T}` }, + { pattern: /^N\s*=\s*\d+/m, value: (s) => `N = ${Math.trunc(s.N)}` }, + { pattern: /^drive_max\s*=\s*[\d.]+/m, value: (s) => `drive_max = ${s.drive_max}` }, + { pattern: /^max_iter\s*=\s*\d+/m, value: (s) => `max_iter = ${Math.trunc(s.max_iter)}` }, + ], + hints: physicsHints, +}; + +/** The vetted registry. Rydberg CZ (+ Pasqal profile) and multi-subsystem + * transmon land here as entries — each in the same PR as its template and + * formulation.toml schema branch (#81 pattern), physicist-vetted. */ +export const SOLVE_TEMPLATES: TemplateSpec[] = [TRANSMON_1Q]; + +// --------------------------------------------------------------------------- +// Platform families — every platform is a PATH in the interview, whether or +// not its amicode template has landed. Data from the harmoniqs demo-repo +// family (harmoniqs/-demo, per the demo skill's canonical layout), +// the team catalog (armonissima catalog/pulses/), and the vault's warm-start +// doctrine. The interview offers ALL of these as modality options; the +// envelope stays honest about which solve in-extension TODAY, and a refusal +// names where the physics already lives instead of a dead end. +// --------------------------------------------------------------------------- + +export interface PlatformFamily { + /** Modality answers this family covers. */ + match: RegExp; + label: string; + /** "vetted" = a SOLVE_TEMPLATES entry runs it here; "demo" = the solve + * exists in a harmoniqs demo repo but isn't wrapped for amicode yet. */ + status: "vetted" | "demo"; + /** Where the working solve scripts live (demo-repo name). */ + demoRepo?: string; + /** The vault's warm-start doctrine for this family — per-platform, and + * load-bearing (fluxonium warm starts REGRESS; rydberg baselines from J-P). */ + warmStartPolicy?: string; + /** Known catalog seeds (armonissima catalog/pulses/) usable as warm starts. */ + warmStarts?: Array<{ id: string; note: string }>; +} + +export const PLATFORM_FAMILIES: PlatformFamily[] = [ + { + match: /transmon/i, + label: "transmon", + status: "vetted", + warmStartPolicy: "DRAG analytic warm start exists; cold starts also converge for 1Q", + }, + { + match: /rydberg|neutral.?atom|atoms?/i, + label: "rydberg / neutral atom", + status: "demo", + demoRepo: "harmoniqs/atoms-demo", + warmStartPolicy: "baseline from the J-P pulse (T·Ω_max = 7.61); free-phase objective is critical", + warmStarts: [ + { id: "rydberg-CZ-quera-v1", note: "F = 0.99999, 273 ns, deep blockade" }, + { id: "rydberg-CZ-v1", note: "two-qubit CZ" }, + { id: "rydberg-X-v1", note: "single-qubit X" }, + ], + }, + { + match: /fluxonium/i, + label: "fluxonium", + status: "demo", + demoRepo: "harmoniqs/fluxonium-demo", + warmStartPolicy: "COLD START ONLY — warm starts regress on fluxonium (vault doctrine); multistart for Y", + }, + { + match: /ion|ytterbium|yb/i, + label: "trapped ion", + status: "demo", + demoRepo: "harmoniqs/ions", + warmStartPolicy: "Mølmer-Sørensen demos (QSCOUT ¹⁷¹Yb⁺) in the demo repo", + }, + { + match: /bosonic|cavity|gkp|cat/i, + label: "bosonic / cavity", + status: "demo", + demoRepo: "harmoniqs/nyu-bosonic-demo (+ gkp-stanford)", + warmStartPolicy: "structured ECD warm start; never perturb long-T warm starts (vault doctrine)", + }, + { + match: /nv.?center|nitrogen/i, + label: "NV center", + status: "demo", + demoRepo: "harmoniqs/nv-center-demo", + }, + { + match: /spin/i, + label: "spin qubit", + status: "demo", + demoRepo: "harmoniqs/spin-qubit-demo", + }, +]; + +/** The family a modality answer belongs to, if any. */ +export function platformFamily(modality: string): PlatformFamily | undefined { + return PLATFORM_FAMILIES.find((f) => f.match.test(modality)); +} + +// --------------------------------------------------------------------------- +// The gate — generic sanity + registry walk. +// --------------------------------------------------------------------------- + +export interface EnvelopeResult { + ok: boolean; + reason?: string; + /** The matched spec on ok — the solve leg reads templateFile/fill off it. */ + template?: TemplateSpec; +} + +/** What the vetted registry can honestly honor. The interview captures the + * user's true system; this guard keeps the Solve leg from running mislabeled + * physics — a "rydberg" answer must never run through TransmonSystem. Pure. */ +export function templateEnvelope(slots: InterviewSlots, registry: TemplateSpec[] = SOLVE_TEMPLATES): EnvelopeResult { + const levels = Array.isArray(slots.levels) ? slots.levels : [slots.levels]; + // Numeric sanity FIRST: a malformed résumé ("T": "10 ns", missing N) must + // never reach the Julia script with Solve enabled. Registry-independent. + const numeric: Array<[string, unknown]> = [["T", slots.T], ["N", slots.N], ["drive_max", slots.drive_max], ["max_iter", slots.max_iter]]; + for (const [name, v] of numeric) { + if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) { + return { ok: false, reason: `"${name}" isn't a valid number — use the update box to have Amico fix it` }; + } + } + if (levels.length === 0 || levels.some((l) => typeof l !== "number" || !Number.isFinite(l) || l < 2)) { + return { ok: false, reason: `"levels" isn't valid (each subsystem needs ≥2 levels) — use the update box to have Amico fix it` }; + } + + const candidates = registry.filter((t) => t.modality.test(slots.modality)); + if (!candidates.length) { + const vetted = registry.map((t) => t.label).join(", "); + // Not a dead end: name where this family's physics already lives. + const fam = platformFamily(slots.modality); + const path = fam?.demoRepo ? ` The ${fam.label} solve already exists in ${fam.demoRepo} — it needs the amicode run-dir wrap + a registry entry.` : ""; + const seeds = fam?.warmStarts?.length ? ` Catalog warm starts ready: ${fam.warmStarts.map((w) => w.id).join(", ")}.` : ""; + return { ok: false, reason: `no vetted template for ${slots.modality} yet — vetted so far: ${vetted}.${path}${seeds}` }; + } + // First candidate whose declaration covers the slots wins; otherwise report + // the FIRST candidate's first failure (good enough while families have one + // entry each — revisit ranking when e.g. transmon-1q and transmon-2q coexist). + let firstReason: string | undefined; + for (const t of candidates) { + const reason = checkAgainst(t, slots, levels); + if (!reason) return { ok: true, template: t }; + firstReason ??= reason; + } + return { ok: false, reason: firstReason }; + // device_limits deliberately never checked: hardware limits inform the + // agent's sanity checks on T/drive_max, they don't change what a template models. +} + +function checkAgainst(t: TemplateSpec, slots: InterviewSlots, levels: number[]): string | undefined { + const param = t.paramCheck?.(slots); + if (param) return param; + if (levels.length > t.maxSubsystems) { + return "multi-subsystem models (e.g. qubit + coupler levels) need a template we haven't vetted yet"; + } + const extras = (slots.physics ?? []).filter((p) => !t.physics.test(p) && !/default|none/i.test(p)); + if (extras.length) { + return `the vetted ${t.label} template doesn't model: ${extras.join(", ")}`; + } + if (!t.gates.test(slots.gate)) { + return `"${slots.gate}" is outside the vetted gate set (${t.gatesLabel}) — custom or state-prep targets need a template extension`; + } + if (slots.frame && !t.frame.test(slots.frame)) { + return `the vetted template works in ${t.frameLabel} — "${slots.frame}" isn't modeled yet`; + } + if (slots.modulation && !t.modulation.test(slots.modulation)) { + return `the vetted template assumes ${t.modulationLabel} — "${slots.modulation}" isn't modeled yet`; + } + if (slots.bounds && !t.supportsBounds) { + return "only the symmetric per-quadrature drive bound is modeled — richer bounds need a template extension"; + } + return undefined; +} + +/** Deterministic template fill — the solve leg's critical path. Substitutions + * come from the matched spec; throws when an expected FILL-IN pattern is + * missing (template drift must be LOUD: a silent skip would run template + * defaults instead of the user's values). */ +export function fillTemplate(template: string, slots: InterviewSlots, spec: TemplateSpec = TRANSMON_1Q): string { + let out = template; + for (const { pattern, value } of spec.fill) { + const sub = value(slots); + if (sub === undefined) continue; // deliberately untouched (e.g. default δ) + if (!pattern.test(out)) throw new Error(`solve template drifted — FILL-IN pattern not found: ${pattern}`); + out = out.replace(pattern, sub); + } + return out; +} diff --git a/packages/extension/test/interview.test.ts b/packages/extension/test/interview.test.ts new file mode 100644 index 00000000..51335d94 --- /dev/null +++ b/packages/extension/test/interview.test.ts @@ -0,0 +1,391 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { buildKickoffPrompt, canonicalGate, extractActivity, extractJson, fillTemplate, estimateProblem, physicsHints, platformFamily, PLATFORM_FAMILIES, templateEnvelope, INTERVIEW_STAGES, SOLVE_TEMPLATES, TEMPLATE_DELTA_DEFAULT, type InterviewSlots, type TemplateSpec } from "../src/interview_shell"; +import { HAMILTONIAN_TERMS, DRIVE_LINE } from "../media/ui/components/hamiltonian_terms"; +import { hamiltonianLines, isHamiltonianTerm } from "../media/ui/components/hamiltonian_terms"; + +// UX1 live interview (#46): the deterministic seams — the solve leg's +// template fill (no LLM in the critical path), the kickoff contract, and +// the résumé estimate. + +const SLOTS: InterviewSlots = { + modality: "transmon", system_name: "Emerald-Q3", device_source: "manual defaults", + levels: 4, drive_max: 0.15, n_drives: 2, gate: "H", + objective: "vetted-default", T: 12.5, N: 60, max_iter: 100, +}; + +describe("fillTemplate — deterministic solve leg", () => { + const template = readFileSync(join(__dirname, "..", "templates", "solve_template.jl"), "utf8"); + + it("substitutes every interview slot into the vetted template's FILL-IN block", () => { + const out = fillTemplate(template, SLOTS); + expect(out).toContain('system = "transmon"'); + expect(out).toContain('gate_name = "H"'); + expect(out).toContain("levels = 4"); + expect(out).toContain("T = 12.5"); + expect(out).toContain("N = 60"); + expect(out).toContain("drive_max = 0.15"); + expect(out).toContain("max_iter = 100"); + // the substitution must not clobber anything outside the FILL-IN block + expect(out).toContain("PulseEmitCallback"); + expect(out).toContain("AMICODE_PULSE_META"); + }); + + it("truncates non-integer counts (levels/N/max_iter are integers in Julia)", () => { + const out = fillTemplate(template, { ...SLOTS, N: 60.9, levels: 3.2, max_iter: 80.5 }); + expect(out).toContain("N = 60"); + expect(out).toContain("levels = 3"); + expect(out).toContain("max_iter = 80"); + }); + + it("normalizes a single-subsystem levels array (envelope gates multi-subsystem upstream)", () => { + expect(fillTemplate(template, { ...SLOTS, levels: [5] })).toContain("levels = 5"); + }); + + it("canonicalizes gate case for Julia's case-sensitive GATES lookup", () => { + expect(canonicalGate("h")).toBe("H"); + expect(canonicalGate("SQRTX")).toBe("sqrtX"); + expect(canonicalGate("sqrtX")).toBe("sqrtX"); + expect(fillTemplate(template, { ...SLOTS, gate: "h" })).toContain('gate_name = "H"'); + }); + + it("throws LOUDLY when the template drifts and a FILL-IN pattern is missing", () => { + const drifted = template.replace(/^gate_name\s*=/m, "gatename ="); + expect(() => fillTemplate(drifted, SLOTS)).toThrow(/template drifted/); + }); + + it("substitutes the user's own δ when given; leaves the template default otherwise", () => { + expect(fillTemplate(template, { ...SLOTS, delta: 0.34 })).toContain("δ = 0.34"); + // no delta slot → the template's default line survives verbatim + expect(fillTemplate(template, SLOTS)).toMatch(/^δ\s+=\s*0\.2\s/m); + }); + + it("TEMPLATE_DELTA_DEFAULT stays in sync with the template's δ line", () => { + expect(template).toMatch(new RegExp(`^δ\\s+=\\s*${TEMPLATE_DELTA_DEFAULT}\\b`, "m")); + }); +}); + +describe("templateEnvelope — the one vetted template's honest limits", () => { + it("accepts what the template models: single transmon, anharmonicity physics", () => { + expect(templateEnvelope(SLOTS).ok).toBe(true); + expect(templateEnvelope({ ...SLOTS, levels: [3], physics: ["anharmonicity"] }).ok).toBe(true); + }); + it("refuses other modalities — a rydberg answer must never run TransmonSystem", () => { + const env = templateEnvelope({ ...SLOTS, modality: "rydberg" }); + expect(env.ok).toBe(false); + expect(env.reason).toContain("rydberg"); + }); + it("refuses multi-subsystem levels (qubit 3 / coupler 5) and unmodeled physics", () => { + expect(templateEnvelope({ ...SLOTS, levels: [3, 5, 3] }).ok).toBe(false); + const env = templateEnvelope({ ...SLOTS, physics: ["anharmonicity", "ZZ crosstalk"] }); + expect(env.ok).toBe(false); + expect(env.reason).toContain("ZZ crosstalk"); + }); + it("refuses gates outside the vetted GATES table — CZ/custom/state-prep must not run the single-qubit template", () => { + expect(templateEnvelope({ ...SLOTS, gate: "CZ" }).ok).toBe(false); + const env = templateEnvelope({ ...SLOTS, gate: "custom", gate_spec: "RX(pi/7)" }); + expect(env.ok).toBe(false); + expect(env.reason).toContain("custom"); + expect(templateEnvelope({ ...SLOTS, gate: "state-prep", gate_spec: "|0> to |1>" }).ok).toBe(false); + expect(templateEnvelope({ ...SLOTS, gate: "sqrtX" }).ok).toBe(true); + }); + it("refuses malformed numeric slots — a bad résumé must never enable Solve", () => { + expect(templateEnvelope({ ...SLOTS, T: "10 ns" as never }).ok).toBe(false); + expect(templateEnvelope({ ...SLOTS, N: undefined as never }).ok).toBe(false); + expect(templateEnvelope({ ...SLOTS, max_iter: -5 }).ok).toBe(false); + expect(templateEnvelope({ ...SLOTS, levels: 1 }).ok).toBe(false); // a 1-level "qubit" is nonsense + expect(templateEnvelope({ ...SLOTS, levels: [] as never }).ok).toBe(false); + }); + it("refuses a non-physical δ — negative lab-convention values must not build an inverted Hamiltonian", () => { + expect(templateEnvelope({ ...SLOTS, delta: -0.2 }).ok).toBe(false); + expect(templateEnvelope({ ...SLOTS, delta: "0.2" as never }).ok).toBe(false); + expect(templateEnvelope({ ...SLOTS, delta: 0.34 }).ok).toBe(true); + expect(templateEnvelope(SLOTS).ok).toBe(true); // absent stays fine (template default) + }); + it("refuses non-default frame/modulation/bounds; device_limits stay informational", () => { + expect(templateEnvelope({ ...SLOTS, frame: "lab frame" }).ok).toBe(false); + expect(templateEnvelope({ ...SLOTS, frame: "qubit-rotating (RWA)" }).ok).toBe(true); + expect(templateEnvelope({ ...SLOTS, modulation: "sideband at 200 MHz" }).ok).toBe(false); + expect(templateEnvelope({ ...SLOTS, bounds: "slew rate < 0.1 GHz/ns" }).ok).toBe(false); + expect(templateEnvelope({ ...SLOTS, device_limits: ["T2 = 80 us", "AWG 2 GS/s"] }).ok).toBe(true); + }); +}); + +// The registry refactor's contract: adding a modality = a new TemplateSpec +// entry (+ its .jl template + schema branch), ZERO interview-code changes. +// Proven here with a synthetic rydberg spec injected as registry data. +describe("solve-template registry — adding a modality is content, not code", () => { + const RYDBERG_TEST: TemplateSpec = { + id: "rydberg-cz-test", + label: "rydberg pair (global drive)", + templateFile: "rydberg_template.jl", + modality: /rydberg/i, + // Dark |0⟩ in the 3-level model: entangling gates ONLY — a rydberg entry + // must never offer 1Q gates (the vault's loudest rydberg gotcha). + gates: /^CZ$/i, + gatesLabel: "CZ", + maxSubsystems: 2, + physics: /blockade|rydberg/i, + frame: /rotating|default/i, + frameLabel: "the rotating frame", + modulation: /global|default/i, + modulationLabel: "a global drive", + supportsBounds: false, + fill: [{ pattern: /^T\s*=\s*[\d.]+/m, value: (s) => `T = ${s.T}` }], + }; + const registry = [...SOLVE_TEMPLATES, RYDBERG_TEST]; + const rydberg: InterviewSlots = { ...SLOTS, modality: "rydberg", gate: "CZ", levels: [3, 3], physics: ["Rydberg blockade"] }; + + it("reports WHICH vetted template a solvable config will run", () => { + expect(templateEnvelope(SLOTS).template?.id).toBe("transmon-1q"); + }); + it("a new registry entry makes its modality solvable — no interview-code change", () => { + const env = templateEnvelope(rydberg, registry); + expect(env.ok).toBe(true); + expect(env.template?.id).toBe("rydberg-cz-test"); + // the shipped registry (no rydberg entry) still refuses the same config honestly + expect(templateEnvelope(rydberg).ok).toBe(false); + }); + it("enforces the family's own gate law (dark |0⟩ → entangling gates only)", () => { + const env = templateEnvelope({ ...rydberg, gate: "X" }, registry); + expect(env.ok).toBe(false); + expect(env.reason).toContain("CZ"); + }); + it("an unmatched modality names what IS vetted", () => { + const env = templateEnvelope({ ...SLOTS, modality: "bosonic" }, registry); + expect(env.ok).toBe(false); + expect(env.reason).toContain("single transmon"); + expect(env.reason).toContain("rydberg pair"); + }); + it("fillTemplate substitutes per the matched spec's own FILL-IN patterns", () => { + expect(fillTemplate("T = 10.0\n", rydberg, RYDBERG_TEST)).toContain("T = 12.5"); + expect(() => fillTemplate("no fill-in block here", rydberg, RYDBERG_TEST)).toThrow(/template drifted/); + }); +}); + +// Every platform is a PATH: the family registry carries where each family's +// physics lives (demo repos), its warm-start doctrine, and catalog seeds. +describe("platform families — all platforms are interview paths", () => { + it("covers the demo-repo family (transmon vetted; the rest name their repo)", () => { + const labels = PLATFORM_FAMILIES.map((f) => f.label); + for (const l of ["transmon", "rydberg / neutral atom", "fluxonium", "trapped ion", "bosonic / cavity", "NV center", "spin qubit"]) { + expect(labels).toContain(l); + } + expect(PLATFORM_FAMILIES.find((f) => f.label === "transmon")?.status).toBe("vetted"); + for (const f of PLATFORM_FAMILIES.filter((f) => f.status === "demo")) { + expect(f.demoRepo, `${f.label} needs a demoRepo`).toMatch(/^harmoniqs\//); + } + }); + it("routes modality synonyms to their family", () => { + expect(platformFamily("neutral atom")?.label).toBe("rydberg / neutral atom"); + expect(platformFamily("rydberg")?.warmStarts?.some((w) => w.id === "rydberg-CZ-quera-v1")).toBe(true); + expect(platformFamily("cavity QED")?.label).toBe("bosonic / cavity"); + }); + it("encodes the per-platform warm-start doctrine (fluxonium is COLD-ONLY)", () => { + expect(platformFamily("fluxonium")?.warmStartPolicy).toMatch(/COLD START ONLY/); + expect(platformFamily("rydberg")?.warmStartPolicy).toMatch(/J-P/); + }); + it("an unmatched-family refusal is a path, not a dead end", () => { + const env = templateEnvelope({ ...SLOTS, modality: "rydberg" }); + expect(env.ok).toBe(false); + expect(env.reason).toContain("harmoniqs/atoms-demo"); + expect(env.reason).toContain("rydberg-CZ-quera-v1"); + }); +}); + +describe("hamiltonian terms — LaTeX surface for the KaTeX panel", () => { + it("every registry term and the drive line carry LaTeX", () => { + for (const t of HAMILTONIAN_TERMS) expect(t.latex, t.math).toMatch(/\\/); + expect(DRIVE_LINE.latex).toContain("u_1(t)"); + }); + it("unknown labels are TeX-sanitized, never dropped", () => { + const l = hamiltonianLines(["charge dispersion {x} \\evil$"]).find((x) => x.latex.includes("\\text")); + expect(l).toBeDefined(); + expect(l?.latex).toContain("charge dispersion"); + expect(l?.latex).not.toContain("\\evil"); + expect(l?.latex).not.toContain("$"); + }); +}); + +describe("buildKickoffPrompt — the interview contract", () => { + const prompt = buildKickoffPrompt(); + + it("carries every stage with its id as the question header contract", () => { + for (const s of INTERVIEW_STAGES) { + expect(prompt).toContain(s.title); + expect(prompt).toContain(`header: "${s.id}"`); + } + }); + it("pins the load-bearing protocol rules: question TOOL for questions, strict JSON for the resume", () => { + expect(prompt).toContain("QUESTION TOOL"); + expect(prompt).toContain("Never ask questions as plain text"); + expect(prompt).toContain("ONE question at a time"); + expect(prompt).toContain("EXACTLY ONE JSON text object"); + expect(prompt).toContain('"type":"resume"'); + expect(prompt).toMatch(/do NOT run anything/i); + }); + it("carries the physics slot, per-subsystem levels, faithful capture, and revision rules", () => { + expect(prompt).toContain("physics"); + expect(prompt).toContain("per subsystem"); + expect(prompt).toMatch(/FAITHFULLY/); + expect(prompt).toMatch(/revise any earlier answer/i); + }); + it("pins fidelity-first staging: advanced goals warm-start from the baseline, never fold in", () => { + expect(prompt).toContain("followups"); + expect(prompt).toMatch(/FIDELITY-FIRST/); + expect(prompt).toMatch(/warm-start from the baseline/); + expect(prompt).toContain("PRIMARY resolution knob"); + expect(prompt).toContain("free-time baseline"); // min-time composition (Piccolo quickstart) + }); + it("recognizes state preparation as a distinct target type", () => { + expect(prompt).toContain('"state-prep"'); + expect(prompt).toContain("state preparation"); + }); + it("carries the researcher-feedback slots: custom gates, bounds, device limits, frame/modulation", () => { + expect(prompt).toContain('"gate":"custom"'); + expect(prompt).toContain("gate_spec"); + expect(prompt).toContain("device_limits"); + expect(prompt).toContain("frame"); + expect(prompt).toContain("modulation"); + expect(prompt).toContain("bounds"); + expect(prompt).toMatch(/offer the default and move on/i); + }); + it("carries the physicist device-identity slot (δ) and the virtual-Z aside", () => { + expect(prompt).toContain("anharmonicity δ"); + expect(prompt).toMatch(/POSITIVE convention/); + expect(prompt).toContain("virtual-Z"); + }); + it("carries the PLATFORM PATHS table: every family, its repo, its doctrine", () => { + expect(prompt).toContain("PLATFORM PATHS"); + expect(prompt).toContain("harmoniqs/atoms-demo"); + expect(prompt).toContain("harmoniqs/fluxonium-demo"); + expect(prompt).toMatch(/NEVER suggest warm starts for fluxonium/); + expect(prompt).toContain("rydberg-CZ-quera-v1"); + }); + it("pins physics as ONE multi-select question of individual terms (live-Hamiltonian contract)", () => { + expect(prompt).toContain("ONE question at a time"); + expect(prompt).toMatch(/multi-select question \(set multiple: true\)/); + expect(prompt).toMatch(/live Hamiltonian/); + expect(prompt).toMatch(/never bundle/i); // one term per option — the panel toggles term-by-term + }); +}); + +describe("hamiltonianLines — the live Ĥ(t) assembling from toggled physics terms", () => { + it("recognizes the transmon term vocabulary; arbitrary labels are not terms", () => { + for (const l of ["anharmonicity", "ZZ crosstalk", "tunable coupler", "T1/T2 decoherence"]) { + expect(isHamiltonianTerm(l), l).toBe(true); + } + expect(isHamiltonianTerm("Emerald-Q3")).toBe(false); + }); + it("always carries the I/Q drive line — a gate problem drives the qubit", () => { + expect(hamiltonianLines([]).some((l) => l.math.includes("u₁(t)"))).toBe(true); + }); + it("orders drift first, drives second, interactions after", () => { + const lines = hamiltonianLines(["ZZ crosstalk", "anharmonicity"]); + expect(lines[0].math).toContain("δ⁄2"); // drift leads even when selected later + expect(lines[1].math).toContain("u₁(t)"); + expect(lines[2].math).toContain("ζ"); + }); + it("routes decoherence to the Lindbladian, never into Ĥ", () => { + const l = hamiltonianLines(["T1/T2 decoherence"]).find((l) => l.lindblad); + expect(l?.math).toContain("𝓛"); + expect(l?.note).toContain("not Ĥ"); + }); + it("never drops unrecognized physics — captured as an agent-interpreted term", () => { + const l = hamiltonianLines(["charge dispersion"]).find((l) => l.math.includes("charge dispersion")); + expect(l?.note).toContain("Amico will interpret"); + }); + it("ignores none-of-these style answers", () => { + expect(hamiltonianLines(["none beyond the default"])).toHaveLength(1); // just the drive line + }); +}); + +describe("physicsHints — physicist sanity advice on the résumé (soft, never blocking)", () => { + it("stays silent for the well-posed default problem", () => { + expect(physicsHints({ ...SLOTS, gate: "X", T: 10, N: 50, drive_max: 0.2 })).toEqual([]); + }); + it("flags an under-driven π-class gate (T·drive_max too small to rotate)", () => { + const hints = physicsHints({ ...SLOTS, gate: "X", T: 2, N: 50, drive_max: 0.05 }); + expect(hints.some((h) => h.includes("under-driven"))).toBe(true); + // Z-class targets don't need a π of drive area — no under-driven nag + expect(physicsHints({ ...SLOTS, gate: "Z", T: 2, N: 50, drive_max: 0.05 }).some((h) => h.includes("under-driven"))).toBe(false); + }); + it("flags the fast-gate leakage regime (T inside ~1/δ), honoring the user's own δ", () => { + expect(physicsHints({ ...SLOTS, T: 4, N: 50, drive_max: 0.3 }).some((h) => h.includes("leakage"))).toBe(true); + // a stiffer transmon (bigger δ) makes the same T safe + expect(physicsHints({ ...SLOTS, T: 4, N: 50, drive_max: 0.3, delta: 0.5 }).some((h) => h.includes("leakage"))).toBe(false); + }); + it("flags a control grid too coarse to resolve the anharmonic dynamics", () => { + expect(physicsHints({ ...SLOTS, T: 50, N: 10, drive_max: 0.2 }).some((h) => h.includes("coarse control grid"))).toBe(true); + }); + it("never throws on malformed numerics (envelope handles blocking)", () => { + expect(physicsHints({ ...SLOTS, T: "10" as never })).toEqual([]); + }); +}); + +describe("extractActivity — honest turn-activity surfacing", () => { + const msg = (parts: Array>) => [ + { info: { role: "user" }, parts: [{ type: "text", text: "hi" }] }, + { info: { role: "assistant" }, parts }, + ]; + it("surfaces the newest tool call + referenced files (state.input shape)", () => { + const a = extractActivity(msg([ + { type: "tool", tool: "read", state: { input: { filePath: "/ext/AGENTS.md" } } }, + { type: "tool", tool: "read", state: { input: { filePath: "/ext/templates/solve_template.jl" } } }, + ])); + expect(a.label).toBe("read · solve_template.jl"); + expect(a.files).toEqual(["AGENTS.md", "solve_template.jl"]); + }); + it("tolerates the args/input bag variants", () => { + expect(extractActivity(msg([{ type: "tool-invocation", name: "grep", args: { path: "src/x.ts" } }])).label).toBe("grep · x.ts"); + expect(extractActivity(msg([{ type: "tool", tool: "bash", input: { command: "ls -la " } }])).label).toBe("bash"); + }); + it("the question tool is protocol, not activity — filtered out", () => { + // Evidence (live session log): "question" is the ONLY tool today's + // interview agent calls; surfacing it would just announce the question. + const a = extractActivity(msg([{ type: "tool", tool: "question", state: { input: { header: "target" } } }])); + expect(a.label).toBeUndefined(); + }); + it("no tool parts → no label, never invented", () => { + const a = extractActivity(msg([{ type: "text", text: "thinking about transmons" }])); + expect(a.label).toBeUndefined(); + expect(a.files).toEqual([]); + }); + it("survives garbage shapes", () => { + expect(() => extractActivity(null)).not.toThrow(); + expect(() => extractActivity([{ parts: "nope" }])).not.toThrow(); + expect(extractActivity(undefined).files).toEqual([]); + }); +}); + +describe("extractJson — the reply parser", () => { + it("parses a bare protocol object", () => { + expect(extractJson('{"type":"question","question":"?"}')).toEqual({ type: "question", question: "?" }); + }); + it("tolerates code fences and surrounding prose", () => { + expect(extractJson('Sure!\n```json\n{"type":"resume","slots":{"gate":"X"}}\n```')).toEqual({ type: "resume", slots: { gate: "X" } }); + }); + it("returns undefined on non-JSON replies (drives the re-ask)", () => { + expect(extractJson("What system are you working with?")).toBeUndefined(); + }); +}); + +describe("estimateProblem — résumé numbers", () => { + it("scales with N and levels and buckets the wall-time estimate", () => { + const small = estimateProblem({ ...SLOTS, levels: 3, N: 50 }); + const big = estimateProblem({ ...SLOTS, levels: 5, N: 200 }); + expect(big.vars).toBeGreaterThan(small.vars); + expect(small.estMinutes).toBe("2–3 min"); + expect(["5–10 min", "10+ min"]).toContain(big.estMinutes); + }); + it("treats array levels as a composite Hilbert space (dim = product)", () => { + const composite = estimateProblem({ ...SLOTS, levels: [3, 5], N: 50 }); + const dim15 = estimateProblem({ ...SLOTS, levels: 15, N: 50 }); + expect(composite.vars).toBe(dim15.vars); + }); + it("buckets a coarse memory estimate alongside time", () => { + expect(estimateProblem({ ...SLOTS, levels: 3, N: 50 }).estMemory).toBe("<1 GB"); + expect(estimateProblem({ ...SLOTS, levels: [3, 5], N: 200 }).estMemory).toBe("8+ GB"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22c76a57..9a8b18e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,10 @@ importers: version: 2.1.9(@types/node@22.19.19) packages/extension: + dependencies: + katex: + specifier: ^0.17.0 + version: 0.17.0 devDependencies: '@amicode/amico-run': specifier: workspace:* @@ -38,6 +42,9 @@ importers: '@amicode/schema': specifier: workspace:* version: link:../schema + '@types/katex': + specifier: ^0.16.8 + version: 0.16.8 '@types/node': specifier: ^22.0.0 version: 22.19.19 @@ -640,6 +647,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/node@22.19.19': resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} @@ -884,6 +894,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -1208,6 +1222,10 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + katex@0.17.0: + resolution: {integrity: sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==} + hasBin: true + keytar@7.9.0: resolution: {integrity: sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==} @@ -2229,6 +2247,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/katex@0.16.8': {} + '@types/node@22.19.19': dependencies: undici-types: 6.21.0 @@ -2511,6 +2531,8 @@ snapshots: commander@12.1.0: {} + commander@8.3.0: {} + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -2893,6 +2915,10 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + katex@0.17.0: + dependencies: + commander: 8.3.0 + keytar@7.9.0: dependencies: node-addon-api: 4.3.0