From 59321d1616254bc0c6137b8221b26f5011242207 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Tue, 28 Jul 2026 18:25:49 -0400 Subject: [PATCH 01/13] style(rail): at-rest chevron on the pending-but-clickable pulse chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pulse chip opens the Run Inspector while still un-banked (766e201e7), but it kept the dotted border — the exact grammar every OTHER pending chip uses to mean inert — so its clickability was only discoverable on hover. Add an always-visible trailing chevron (neutral muted ink, bumps to base on hover/focus) so the shape signals the action at rest; the dotted border still tells the truth about not-yet-banked state. Also retire the stale comment claiming the recorded chips' soft-yellow fill (removed 2026-07-24). Co-Authored-By: Claude Fable 5 --- packages/ui/src/amicode/amicode.css | 30 +++++++++++++++++-------- packages/ui/src/amicode/entity-rail.tsx | 9 ++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index cf2066cb33..65bba90fa9 100644 --- a/packages/ui/src/amicode/amicode.css +++ b/packages/ui/src/amicode/amicode.css @@ -650,10 +650,11 @@ padding: 0; } -/* Rail entity chips (Kate 2026-07-24): STATIC label only — no value, no chevron - or + icon. A recorded (clickable) chip carries the soft-yellow fill as its - affordance and opens the current version; a not-yet-recorded chip is inert - with a dotted border. Focus ring comes from the shared rail button rule below. */ +/* Rail entity chips (Kate 2026-07-24): STATIC label only — no value, no + icon. + A recorded (clickable) chip is a solid-bordered pill on the layer-01 surface + (the yellow fill was retired 2026-07-24 — neutral surface, hover carries the + feedback) and opens the current version; a not-yet-recorded chip is inert with + a dotted border. Focus ring comes from the shared rail button rule below. */ [data-component="amicode-entity-rail"] .amc-rail-chip { display: inline-flex; align-items: center; @@ -707,17 +708,28 @@ } /* …except a not-recorded chip that IS a button (the pulse chip, which opens the Run Inspector before a pulse is banked): keep the dotted not-yet look, but it - must feel clickable. Hover feedback already comes from button.amc-rail-chip. - It also carries the recorded chips' ink — full-strength colour at weight 600 — - so its glyph and label read as bold as its neighbours instead of dimming out; - faint ink is how a chip says "inert", and this one isn't. The glyph inherits - currentColor, so the colour bump bolds the icon along with the label. The + must READ clickable at rest — hover feedback alone failed the glance test + (Kate 2026-07-28: "doesn't appear clickable"). Three signals: full-strength + ink at weight 600 (faint ink is how a chip says "inert", and this one isn't; + the glyph inherits currentColor so it bolds with the label), an always-visible + trailing chevron (the shape says "this goes somewhere" — same glyph family as + the receipt card's, but never hover-gated here), and the pointer cursor. The dotted border still carries the not-yet-banked state on its own. */ [data-component="amicode-entity-rail"] button.amc-rail-chip.is-empty { cursor: pointer; color: var(--v2-text-text-base); font-weight: 600; } +[data-component="amicode-entity-rail"] .amc-rail-chip .amc-chev { + color: var(--v2-text-text-muted); + font-size: var(--font-size-md); + line-height: 1; + transition: color 0.16s ease; +} +[data-component="amicode-entity-rail"] button.amc-rail-chip:hover .amc-chev, +[data-component="amicode-entity-rail"] button.amc-rail-chip:focus-visible .amc-chev { + color: var(--v2-text-text-base); +} [data-component="amicode-entity-rail"] button:focus-visible, [data-component="amicode-ask-card"] button:focus-visible, [data-component="amicode-run-window"] button:focus-visible { diff --git a/packages/ui/src/amicode/entity-rail.tsx b/packages/ui/src/amicode/entity-rail.tsx index c2b7352ca0..2e5b4bc88e 100644 --- a/packages/ui/src/amicode/entity-rail.tsx +++ b/packages/ui/src/amicode/entity-rail.tsx @@ -296,6 +296,15 @@ export function AmicodeEntityRail(props: { > {chip.label} + {/* At-rest chevron on the pending-but-clickable chip: the dotted + border alone reads "inert" (that's what it means on every + other pending chip), so the shape — not hover — carries the + "this goes somewhere" signal (Kate 2026-07-28). */} + + + )} From 43c1c512014ca9f7fb109a8487791dc044283fdb Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Tue, 28 Jul 2026 17:32:11 -0400 Subject: [PATCH 02/13] style(capsule): the HP row names Harmoniqs Cloud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The solver row said "API key connected" and "Runs in the cloud" — which cloud was never stated, on the one control where a user commits to a paid service. Every refusal they can hit downstream now says "Harmoniqs Cloud" (amico-run's local-launch refusal, the hpc gate), so the control that sends them there should use the same name. The connected tooltip also states the consequence — every solve on this solver runs there — since that is the thing users were guessing about. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/amicode-defaults-capsule.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app/src/components/amicode-defaults-capsule.tsx index cf18d3f547..b54dee7d48 100644 --- a/packages/app/src/components/amicode-defaults-capsule.tsx +++ b/packages/app/src/components/amicode-defaults-capsule.tsx @@ -335,18 +335,22 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl }} onClick={onHpClick} aria-label={ + // Name the cloud, not just "the cloud": users are picking a + // paid service here, and every refusal they can hit downstream + // (amico-run's local-launch refusal, the hpc gate) calls it + // Harmoniqs Cloud too — one name end to end. dot() === "connected" - ? "Piccolissimo + Altissimo solver — API key connected" + ? "Piccolissimo + Altissimo solver — connected to Harmoniqs Cloud" : dot() === "attention" - ? "Piccolissimo + Altissimo solver — API key needs attention" - : "Piccolissimo + Altissimo solver — add your API key to enable" + ? "Piccolissimo + Altissimo solver — Harmoniqs Cloud key needs attention" + : "Piccolissimo + Altissimo solver — add your Harmoniqs Cloud API key to enable" } title={ dot() === "connected" - ? "API key connected" + ? "Connected to Harmoniqs Cloud — every solve on this solver runs there" : dot() === "attention" - ? "API key needs attention — click to fix" - : "Runs in the cloud — click to add your API key" + ? "Harmoniqs Cloud key needs attention — click to fix" + : "Runs in Harmoniqs Cloud — click to add your API key" } > From 67bfdf7363835882094b96792f7a5ef5388ed3a9 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Tue, 28 Jul 2026 17:55:01 -0400 Subject: [PATCH 03/13] feat(connections): Harmoniqs Cloud is connectable where Pasqal is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: "where is our harmoniqs cloud, where can I put an API key in" — and separately, switching to Piccolissimo + Altissimo appeared to do nothing. Both trace to amicode#200. It filtered company-compute OUT of the Connections tab and renamed it "Solver API key", moving the whole connect flow inside the solver capsule's collapsed card. The reasoning was sound in isolation (one credential for one service, not a second product), but the result was that Pasqal Cloud rendered as a connectable service in the place that lists clouds and OURS did not. Users went looking for it exactly where Pasqal is. - statusTabConnections() no longer filters. Harmoniqs Cloud renders as a card beside Pasqal Cloud, with the base-url-token form (Service URL + API key) the tab already supported for non-Pasqal ids — so this is un-hiding a working flow, not building a new one. Kept as a function so there is still one obvious place to filter a genuinely internal connection later. - connectionTitle() returns "Harmoniqs Cloud". In a list whose other entry is "Pasqal Cloud", "Solver API key" read as a settings field rather than our service — and Harmoniqs Cloud is the name every downstream refusal already uses (amico-run's local-launch refusal, the hpc gate). - The solver row now states its connection state (CONNECTED / CHECK KEY / ADD KEY). With a key already on file, clicking HP correctly activates without re-asking — but the status dot lived inside the collapsed card, so the row had no way to say so and a successful switch was indistinguishable from a no-op. Text rather than a bare dot: Kate's idiom is that the dot accompanies words, and words survive colour-blindness and a screenshot. It also says what a click will do. The wire id stays company-compute — renaming it would break the credential route, the status cache, and CONNECTION_IDS for no user-visible gain. Both entry points write the same credential, so connecting in either shows up in both. ui: 57 pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/amicode-defaults-capsule.tsx | 27 ++++++++++++++++++- packages/ui/src/amicode/connections.test.ts | 22 ++++++++++----- packages/ui/src/amicode/connections.ts | 27 ++++++++++++++----- 3 files changed, 61 insertions(+), 15 deletions(-) diff --git a/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app/src/components/amicode-defaults-capsule.tsx index b54dee7d48..8941453091 100644 --- a/packages/app/src/components/amicode-defaults-capsule.tsx +++ b/packages/app/src/components/amicode-defaults-capsule.tsx @@ -374,12 +374,37 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl > PRO + {/* Connection state, ON THE ROW. Without this the row is silent: + with a key already on file a click just activates HP, which is + correct but indistinguishable from "nothing happened" (reported + 2026-07-28 as "it did not ask me for an API key" — it had no + reason to ask, and no way to say so). Text, not a bare dot: + Kate's idiom is that the dot accompanies words rather than the + solver name, and words survive colour-blindness and a + screenshot. It also states what a click will DO. */} + + {dot() === "connected" ? "CONNECTED" : dot() === "attention" ? "CHECK KEY" : "ADD KEY"} + - From bb2900599792494daa456e42dd251a27f47e331e Mon Sep 17 00:00:00 2001 From: kate bonner Date: Tue, 28 Jul 2026 19:10:45 -0400 Subject: [PATCH 06/13] fix(run): fidelity readout never rounds a near-unity F to a bare 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatFidelity's toFixed(4) collapsed any F ≥ 0.99995 to "1" in the run chip, ring-2 Run hero, and Run modal — hiding exactly the number that matters. Precision now extends a digit at a time (cap 10) until the gap from 1 (or 0) survives rounding; ordinary values keep the 4-decimal form and only a true 1 renders bare. Co-Authored-By: Claude Fable 5 --- packages/ui/src/amicode/problem.test.ts | 14 ++++++++++++++ packages/ui/src/amicode/problem.ts | 13 +++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/amicode/problem.test.ts b/packages/ui/src/amicode/problem.test.ts index a26833e1cc..ff669a05ec 100644 --- a/packages/ui/src/amicode/problem.test.ts +++ b/packages/ui/src/amicode/problem.test.ts @@ -118,6 +118,20 @@ describe("runChipText", () => { test("solving f outside [0,1] renders no F readout (objective ≠ fidelity)", () => { expect(runChipText([{ runId: "r", status: "solving", fidelity: 79.3, iteration: 0 }])).toBe("solving…") }) + test("near-unity fidelity never rounds to a bare 1 — precision extends until the gap shows", () => { + expect(runChipText([{ runId: "r", status: "finished", fidelity: 0.99997, iteration: 60 }])).toBe( + "F=0.99997 · 60 iter", + ) + expect(runChipText([{ runId: "r", status: "finished", fidelity: 0.99999997, iteration: 60 }])).toBe( + "F=0.99999997 · 60 iter", + ) + // solving mirror: a tiny live objective is a near-unity F + expect(runChipText([{ runId: "r", status: "solving", fidelity: 0.00003, iteration: 12 }])).toBe( + "solving… F=0.99997", + ) + // a true 1 (and only a true 1) still renders bare + expect(runChipText([{ runId: "r", status: "finished", fidelity: 1, iteration: 60 }])).toBe("F=1 · 60 iter") + }) }) describe("railState", () => { diff --git a/packages/ui/src/amicode/problem.ts b/packages/ui/src/amicode/problem.ts index 7fa5993469..1dc0e847ea 100644 --- a/packages/ui/src/amicode/problem.ts +++ b/packages/ui/src/amicode/problem.ts @@ -244,8 +244,17 @@ export function mergeChips(entities: Record>, sc // --- run chip ---------------------------------------------------------------- function formatFidelity(value: number): string { - // up to 4 significant decimals, trailing zeros trimmed - return String(Number(value.toFixed(4))) + // 4 decimals for ordinary values (trailing zeros trimmed) — but NEVER let + // rounding collapse a near-unity or near-zero fidelity into exactly "1"/"0": + // the gap from perfect IS the result (Kate 2026-07-28, a 0.99997 run showed + // "F = 1"). Precision extends one digit at a time until the gap survives, + // capped at 10; only a true 1 or 0 renders bare. + if (value === 1 || value === 0) return String(value) + for (let digits = 4; digits <= 10; digits++) { + const rounded = Number(value.toFixed(digits)) + if (rounded !== 1 && rounded !== 0) return String(rounded) + } + return String(value) } export function runChipText(statuses: RunStatusView[]): string | undefined { From d5f2d60b86011fe8b003e50d658dd573bc9069ad Mon Sep 17 00:00:00 2001 From: kate bonner Date: Tue, 28 Jul 2026 19:50:35 -0400 Subject: [PATCH 07/13] fix(session): header backdrop-blur off while the context-tree band is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the context-tree block open the sticky header grows to ~300px and the band sits inside the pl-4 gutter — backdrop-blur(10px) samples transcript content through that transparent 16px strip and smears it into a grey streak along the straight left edge (worst in light theme). The band is opaque below the 48px title strip, so the blur contributed nothing there. Slim headers keep the frosted fade. Co-Authored-By: Claude Fable 5 --- packages/app/src/pages/session/message-timeline.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 0d3bcc05ca..85052483f6 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1409,7 +1409,15 @@ export function MessageTimeline(props: { }} data-session-title classList={{ - "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)] backdrop-blur-[10px]": true, + "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, + // The frosted fade only while the header is slim. With the + // context-tree band open the header is ~300px tall and the band + // sits inside the pl-4 gutter — the blur samples transcript + // content through that transparent 16px strip and smears it into + // a grey streak along the straight left edge (worst on light). + // The band is opaque below the 48px title strip anyway, so the + // blur buys nothing there. + "backdrop-blur-[10px]": !contextTreeVisible(), "w-full": true, // the tall padding exists to seat the entity-chip rail; the // context-tree block closes the header itself when it renders, From 27b8ed1c28be2dd2a71ff31a6d46b11cb7319733 Mon Sep 17 00:00:00 2001 From: Jack Champagne <43344745+jack-champagne@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:37:51 -0400 Subject: [PATCH 08/13] test(http-recorder): match cassettes prompt-agnostically for transport tests (#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three `session.llm native recorded` scenarios are the only failures on `local/amicode`, so the fork's `test` job is red and every PR into it inherits a red check — including #93, whose own suite is 3366 pass / 3 fail with zero failures of its own. Cause is ours. AMICODE-PATCHES.md ADDENDUM 2 consolidated the prompt so `system.ts provider()` returns `[providerBase(model), PROMPT_COMMUNICATING]`, appending `communicating.txt` for every model family. The cassettes still record the pre-patch prompt, so all three break on a diff of prose. Those tests assert TRANSPORT mechanics — that a tool loop is driven to a final text answer. The prompt is incidental, so matching on it couples them to text they do not test. Patching the recorded prompt instead would work until the next prompt edit, and that patch already has three addenda. `RecorderOptions.match` is an existing seam (types.ts:85, threaded through `http()` to `recordingLayer`) that nothing used. Adds `promptAgnosticMatcher` alongside `defaultMatcher` and passes it from this one test. The prompt arrives in three shapes, all covered: instructions OpenAI Responses system[] Anthropic Messages input[].role=="system" the OpenCode proxy System-message content is replaced with a placeholder rather than deleted, so "a system message is present, with content" stays part of the match. Verified narrow, in test/matching.test.ts: it ignores prompt prose in all three shapes, and still rejects a different user message, model, tool set, URL, or a missing-vs-present system message. 49 pass in http-recorder; the three recorded scenarios go 3 fail -> 3 pass; typecheck green across 23 packages. --- packages/http-recorder/src/internal.ts | 1 + packages/http-recorder/src/matching.ts | 59 +++++++++++++++++++ .../http-recorder/test-matcher-tmp.test.ts | 59 +++++++++++++++++++ packages/http-recorder/test/matching.test.ts | 59 +++++++++++++++++++ .../test/session/llm-native-recorded.test.ts | 16 ++++- 5 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 packages/http-recorder/test-matcher-tmp.test.ts create mode 100644 packages/http-recorder/test/matching.test.ts diff --git a/packages/http-recorder/src/internal.ts b/packages/http-recorder/src/internal.ts index 7faecf0db0..ccdb507ed6 100644 --- a/packages/http-recorder/src/internal.ts +++ b/packages/http-recorder/src/internal.ts @@ -1,5 +1,6 @@ export { CassetteNotFoundError, hasCassetteSync, UnsafeCassetteError } from "./cassette.js" export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./internal-effect.js" +export { defaultMatcher, promptAgnosticMatcher } from "./matching.js" export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction.js" export { socketLayer } from "./socket.js" export { diff --git a/packages/http-recorder/src/matching.ts b/packages/http-recorder/src/matching.ts index 731aa8b57a..b805669f7c 100644 --- a/packages/http-recorder/src/matching.ts +++ b/packages/http-recorder/src/matching.ts @@ -36,6 +36,65 @@ export const canonicalSnapshot = (snapshot: RequestSnapshot): string => export const defaultMatcher: RequestMatcher = (incoming, recorded) => canonicalSnapshot(incoming) === canonicalSnapshot(recorded) +/** Placeholder standing in for elided system-prompt prose. Substituting rather than + * deleting keeps "a system message is present, with content" part of the match. */ +const SYSTEM_PROMPT_ELIDED = "" + +/** Top-level keys carrying the system prompt: OpenAI Responses uses `instructions`, + * Anthropic Messages uses `system`. */ +const SYSTEM_PROMPT_KEYS = ["instructions", "system"] as const + +/** Keys carrying a message list, where the prompt may instead arrive as a + * `role: "system"` entry (the shape the OpenCode proxy sends). */ +const MESSAGE_LIST_KEYS = ["input", "messages"] as const + +/** Neutralizes system-prompt prose wherever a provider puts it, leaving every other + * part of the request — model, tools, the conversation itself — matched exactly. */ +const withoutSystemPrompt = (snapshot: RequestSnapshot): RequestSnapshot => { + const body = jsonBody(snapshot.body) + if (!isRecord(body)) return snapshot + + const next: Record = { ...body } + let changed = false + + for (const key of SYSTEM_PROMPT_KEYS) { + if (!(key in next)) continue + delete next[key] + changed = true + } + + for (const key of MESSAGE_LIST_KEYS) { + const list = next[key] + if (!Array.isArray(list)) continue + let listChanged = false + const elided = list.map((entry) => { + if (!isRecord(entry) || entry["role"] !== "system" || !("content" in entry)) return entry + listChanged = true + return { ...entry, content: SYSTEM_PROMPT_ELIDED } + }) + if (!listChanged) continue + next[key] = elided + changed = true + } + + return changed ? { ...snapshot, body: JSON.stringify(next) } : snapshot +} + +/** + * Like {@link defaultMatcher}, but ignores the system prompt. + * + * A cassette records the exact request that produced its response, system prompt + * included — so any edit to a prompt invalidates every cassette that carries it, + * even when the behaviour under test has nothing to do with prompt text. Tests that + * assert on transport mechanics (tool loops, streaming, retries) should pin the + * mechanics and stay indifferent to wording. + * + * Use this when the prompt is incidental to what the test asserts. Do NOT use it for + * a test whose subject IS the prompt — there the exact text is the assertion. + */ +export const promptAgnosticMatcher: RequestMatcher = (incoming, recorded) => + canonicalSnapshot(withoutSystemPrompt(incoming)) === canonicalSnapshot(withoutSystemPrompt(recorded)) + export const safeText = (value: unknown) => { if (value === undefined) return "undefined" if (secretFindings(value).length > 0) return JSON.stringify(REDACTED) diff --git a/packages/http-recorder/test-matcher-tmp.test.ts b/packages/http-recorder/test-matcher-tmp.test.ts new file mode 100644 index 0000000000..7b46faa2ad --- /dev/null +++ b/packages/http-recorder/test-matcher-tmp.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import { promptAgnosticMatcher, defaultMatcher } from "@opencode-ai/http-recorder/internal" + +const snap = (body: unknown) => ({ + method: "POST", + url: "https://api.example.com/v1/x", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}) + +describe("promptAgnosticMatcher", () => { + test("ignores instructions (OpenAI shape)", () => { + const a = snap({ model: "m", instructions: "OLD PROMPT", input: [] }) + const b = snap({ model: "m", instructions: "TOTALLY NEW PROMPT", input: [] }) + expect(defaultMatcher(a, b)).toBe(false) + expect(promptAgnosticMatcher(a, b)).toBe(true) + }) + + test("ignores system[] (Anthropic shape)", () => { + const a = snap({ model: "m", system: [{ type: "text", text: "OLD" }] }) + const b = snap({ model: "m", system: [{ type: "text", text: "NEW" }] }) + expect(promptAgnosticMatcher(a, b)).toBe(true) + }) + + test("ignores a role:system message (proxy shape)", () => { + const a = snap({ model: "m", input: [{ role: "system", content: "OLD" }, { role: "user", content: "hi" }] }) + const b = snap({ model: "m", input: [{ role: "system", content: "NEW" }, { role: "user", content: "hi" }] }) + expect(promptAgnosticMatcher(a, b)).toBe(true) + }) + + // The guard rails: everything that is NOT prompt prose must still differentiate. + test("still rejects a different user message", () => { + const a = snap({ model: "m", input: [{ role: "system", content: "P" }, { role: "user", content: "Paris" }] }) + const b = snap({ model: "m", input: [{ role: "system", content: "P" }, { role: "user", content: "Berlin" }] }) + expect(promptAgnosticMatcher(a, b)).toBe(false) + }) + + test("still rejects a different model", () => { + expect(promptAgnosticMatcher(snap({ model: "a", instructions: "P" }), snap({ model: "b", instructions: "P" }))).toBe(false) + }) + + test("still rejects different tools", () => { + const a = snap({ model: "m", instructions: "P", tools: [{ name: "get_weather" }] }) + const b = snap({ model: "m", instructions: "P", tools: [{ name: "rm_rf" }] }) + expect(promptAgnosticMatcher(a, b)).toBe(false) + }) + + test("still rejects a missing vs present system message", () => { + const a = snap({ model: "m", input: [{ role: "system", content: "P" }, { role: "user", content: "hi" }] }) + const b = snap({ model: "m", input: [{ role: "user", content: "hi" }] }) + expect(promptAgnosticMatcher(a, b)).toBe(false) + }) + + test("still rejects a different url", () => { + const a = { ...snap({ model: "m", instructions: "P" }), url: "https://api.example.com/v1/x" } + const b = { ...snap({ model: "m", instructions: "P" }), url: "https://evil.example.com/v1/x" } + expect(promptAgnosticMatcher(a, b)).toBe(false) + }) +}) diff --git a/packages/http-recorder/test/matching.test.ts b/packages/http-recorder/test/matching.test.ts new file mode 100644 index 0000000000..7b46faa2ad --- /dev/null +++ b/packages/http-recorder/test/matching.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import { promptAgnosticMatcher, defaultMatcher } from "@opencode-ai/http-recorder/internal" + +const snap = (body: unknown) => ({ + method: "POST", + url: "https://api.example.com/v1/x", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}) + +describe("promptAgnosticMatcher", () => { + test("ignores instructions (OpenAI shape)", () => { + const a = snap({ model: "m", instructions: "OLD PROMPT", input: [] }) + const b = snap({ model: "m", instructions: "TOTALLY NEW PROMPT", input: [] }) + expect(defaultMatcher(a, b)).toBe(false) + expect(promptAgnosticMatcher(a, b)).toBe(true) + }) + + test("ignores system[] (Anthropic shape)", () => { + const a = snap({ model: "m", system: [{ type: "text", text: "OLD" }] }) + const b = snap({ model: "m", system: [{ type: "text", text: "NEW" }] }) + expect(promptAgnosticMatcher(a, b)).toBe(true) + }) + + test("ignores a role:system message (proxy shape)", () => { + const a = snap({ model: "m", input: [{ role: "system", content: "OLD" }, { role: "user", content: "hi" }] }) + const b = snap({ model: "m", input: [{ role: "system", content: "NEW" }, { role: "user", content: "hi" }] }) + expect(promptAgnosticMatcher(a, b)).toBe(true) + }) + + // The guard rails: everything that is NOT prompt prose must still differentiate. + test("still rejects a different user message", () => { + const a = snap({ model: "m", input: [{ role: "system", content: "P" }, { role: "user", content: "Paris" }] }) + const b = snap({ model: "m", input: [{ role: "system", content: "P" }, { role: "user", content: "Berlin" }] }) + expect(promptAgnosticMatcher(a, b)).toBe(false) + }) + + test("still rejects a different model", () => { + expect(promptAgnosticMatcher(snap({ model: "a", instructions: "P" }), snap({ model: "b", instructions: "P" }))).toBe(false) + }) + + test("still rejects different tools", () => { + const a = snap({ model: "m", instructions: "P", tools: [{ name: "get_weather" }] }) + const b = snap({ model: "m", instructions: "P", tools: [{ name: "rm_rf" }] }) + expect(promptAgnosticMatcher(a, b)).toBe(false) + }) + + test("still rejects a missing vs present system message", () => { + const a = snap({ model: "m", input: [{ role: "system", content: "P" }, { role: "user", content: "hi" }] }) + const b = snap({ model: "m", input: [{ role: "user", content: "hi" }] }) + expect(promptAgnosticMatcher(a, b)).toBe(false) + }) + + test("still rejects a different url", () => { + const a = { ...snap({ model: "m", instructions: "P" }), url: "https://api.example.com/v1/x" } + const b = { ...snap({ model: "m", instructions: "P" }), url: "https://evil.example.com/v1/x" } + expect(promptAgnosticMatcher(a, b)).toBe(false) + }) +}) diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index d17d7f8e5a..745c1fb137 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -289,7 +289,21 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) { metadata, redactor: HttpRecorderInternal.Redactor.make(redact), }) - : HttpRecorder.http(scenario.cassette, { directory: FIXTURES_DIR, metadata, redact }) + : // These scenarios assert TRANSPORT mechanics — that a tool loop is driven to a final + // text answer — not prompt wording. The default matcher compares the whole request, + // so appending to the system prompt invalidates every cassette here even though the + // behaviour under test is unchanged. That is exactly what happened when + // `provider()` began appending `communicating.txt` for every model family: all three + // cassettes broke at once, on a diff of prose. + // + // Matching prompt-agnostically pins what these tests are actually about and leaves + // prompt evolution to the tests that assert on prompts. + HttpRecorder.http(scenario.cassette, { + directory: FIXTURES_DIR, + metadata, + redact, + match: HttpRecorderInternal.promptAgnosticMatcher, + }) const recordedClient = LLMClient.layer.pipe( Layer.provide(Layer.mergeAll(RequestExecutor.layer.pipe(Layer.provide(recordedHttp)), WebSocketExecutor.layer)), ) From 9bd4649c867386e9f87a38c61bc6aa7c9f174943 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge <47730232+aarontrowbridge@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:38:36 -0400 Subject: [PATCH 09/13] feat(amicode): system card renders the recorded Hamiltonian, and stops inventing one (#93) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(amicode): per-component physics rows on the system card The card made its component-count claim implicitly, so an unanswered 'how many atoms?' read as 'one'. systemCountLabel now states it as its own badge ('2 atoms x 3 levels') that a researcher can read and correct. componentPhysicsRows renders what each role actually has, in card order: - unanswered params read 'not set', so the list doubles as the interview's to-do - anything else on file is still shown, so nothing recorded is dropped - an unrecognized role expects NOTHING -- the honest floor for a platform we have no model for - units are never assumed (transmon params are GHz, the Rydberg templates work in rad/us, a bare omega says which only by convention), so a unit appears only when the recorded key spells it out - a zero keeps the '0 means unset' reading, so an all-zeros seed does not look like a specified device driftTerm now keys the qubit ladder off LEVELS rather than off whether delta happens to be filled in: a 3-level transmon with empty params is still an anharmonic ladder, and rendering it as a bare spin misstates the model the solve will use. A mode's Kerr stays params-driven, since a linear cavity genuinely has none. * feat(amicode): extend the system card's physics rows Continues 0793fd62. Committed on the author's behalf as part of folding the worktree's in-progress work onto the branch; the content is theirs. +169 lines of tests alongside +278 of implementation; ui suite 442 -> 448 pass. * feat(amicode): render the recorded Hamiltonian; stop inferring one off-template The card re-derived the Hamiltonian in the renderer from (role, levels, platform) using hardcoded tables, so it could only ever be right for platforms someone had hardcoded. Two separate leaks put the transmon model on a spin qubit: - `platformDefaultRole` maps every unfamiliar platform to "qubit", so an exchange-only HRL spin qubit arrived indistinguishable from a transmon. - levels >= 3 was read as "anharmonic ladder". It is not. Three levels is a Hilbert-space DIMENSION: an exchange-only qubit at levels=3 is three dots, a spin-1 defect is three Zeeman sublevels. Neither is an oscillator. So the platform is now the only thing that licenses a ladder for a `qubit` role (transmon, bosonic). levels=2 still earns the generic two-level model, which is safe on any platform — every two-level system has an ω σ_z/2 splitting and σx/σy control. Everything else is opaque. Recorded beats inferred. `systemHamiltonian()` returns {latex, source}: a model recorded on the entity (amicode_set_model's `hamiltonian`, see harmoniqs/amicode#220) renders verbatim; otherwise the canonical form for the platform renders under an "inferred · confirm or correct" label. When nothing is recorded AND nothing is modelled the card says "not recorded" rather than showing `Ĥ_drift + Ĥ_c(t)`, which is true of every control problem ever posed and would occupy the slot the real model belongs in. Mixed systems still get a named placeholder per unmodelled component. One `classify()` now drives both the equation and the physics rows, so the two halves of the card cannot describe different physics again. Also fixes a three-way drift found while doing this: the ladder drive was a single `ε(t)(â+â†)` while the plugin's TRANSMON_LATEX (shown in chat) and Piccolo's n_drives = 2 both say two quadratures. Two is correct; the card now matches. Verified: 399 pass across 30 files, tsgo clean. The KaTeX sweep now crosses the platform axis too (8k systems) and asserts BOTH directions: no output when nothing is modelled, and never a model invented for an all-unmodelled system. Co-Authored-By: Claude Opus 5 * test(amicode): dedupe the KaTeX sweep — render each distinct equation once The sweep enumerates ~8k systems, but they collapse onto a few hundred distinct equations, and KaTeX is the expensive part. Rendering the same string 200 times proves nothing and took 22s on CI against a 5s default timeout (it passed locally on a fast machine, and I had only been running src/amicode — CI runs all of src). Coverage is unchanged: every distinct output is still parsed, and the mapping back to a producing system is kept for the failure message. Adds an assertion that the sweep really does vary its output, so a future change that collapses everything to one equation can't quietly pass. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- packages/ui/src/amicode/amicode.css | 25 + packages/ui/src/amicode/problem.ts | 30 + packages/ui/src/amicode/system-render.test.ts | 457 +++++++++++++++- packages/ui/src/amicode/system-render.ts | 515 ++++++++++++++++-- packages/ui/src/amicode/system-view.tsx | 101 ++-- 5 files changed, 1000 insertions(+), 128 deletions(-) diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index 4fb938f0a7..7524aeb3a8 100644 --- a/packages/ui/src/amicode/amicode.css +++ b/packages/ui/src/amicode/amicode.css @@ -769,6 +769,31 @@ [data-component="amicode-system-view"] .amc-c-lvl { color: var(--v2-text-text-muted); font-size: 0.8rem; } [data-component="amicode-system-view"] .amc-c-params { color: var(--v2-text-text-base); font-size: 0.8rem; } [data-component="amicode-system-view"] .amc-ev-formula { margin-top: 4px; } +/* Provenance on a section header. A RECORDED Hamiltonian is shown bare; an + inferred one — the canonical form for the platform, which nobody stated — + has to say so, or the card is asserting physics it guessed. Sits before the + ::after rule so the hairline still fills the remaining width. */ +[data-component="amicode-entity-view"] .amc-ev-sec-note { + font-size: 9px; + letter-spacing: 0.04em; + text-transform: none; + color: var(--v2-text-text-faint); + font-style: italic; +} +/* The "no model recorded" placeholder reads as a prompt, not as an equation. */ +[data-component="amicode-system-view"] .amc-ev-formula.is-empty { + color: var(--v2-text-text-faint); + font-size: 0.82rem; + font-style: italic; + background: transparent; + border-style: dashed; +} +/* Conventions the recorded terms assume (frame, units, basis ordering). */ +[data-component="amicode-system-view"] .amc-ev-formula-note { + margin: -2px 4px 4px; + font-size: 0.72rem; + color: var(--v2-text-text-faint); +} /* ---- Formulation hero (spec §6.2): mode badges + objective + constraints ---- */ [data-component="amicode-formulation-view"] { display: flex; flex-direction: column; gap: 8px; } diff --git a/packages/ui/src/amicode/problem.ts b/packages/ui/src/amicode/problem.ts index 1dc0e847ea..fd29b27b58 100644 --- a/packages/ui/src/amicode/problem.ts +++ b/packages/ui/src/amicode/problem.ts @@ -522,11 +522,22 @@ export interface SystemProjection { topology?: string components: { id: string; role: string; levels?: number; params: Record }[] couplings: { between: string[]; kind: string; params: Record }[] + /** The model the researcher CONFIRMED, term by term (amicode_set_model's + * `hamiltonian`). Present → the card renders exactly this; absent → it falls + * back to a canonical form for the platform and labels it as inferred. */ + hamiltonian?: { terms: HamiltonianTermProjection[]; notes?: string } notes?: string /** false = a legacy FLAT entity read-collapsed to N=1 (no couplings). */ isComposite: boolean } +export interface HamiltonianTermProjection { + kind: string + latex: string + acts_on?: string[] + label?: string +} + const asNumberRecord = (v: unknown): Record => { const out: Record = {} if (typeof v === "object" && v !== null) { @@ -535,6 +546,24 @@ const asNumberRecord = (v: unknown): Record => { return out } +/** Recorded Hamiltonian, tolerant of raw JSON: a term without usable `latex` is + * dropped rather than rendered as a hole, and an empty result reads as "nothing + * recorded" so the card falls back to inference instead of showing `Ĥ/ℏ = `. */ +const asHamiltonian = (v: unknown): SystemProjection["hamiltonian"] => { + if (typeof v !== "object" || v === null) return undefined + const raw = v as Record + if (!Array.isArray(raw.terms)) return undefined + const terms = (raw.terms as Record[]) + .filter((t) => t && typeof t.latex === "string" && t.latex.trim() !== "") + .map((t) => ({ + kind: typeof t.kind === "string" ? t.kind : "drift", + latex: t.latex as string, + ...(Array.isArray(t.acts_on) ? { acts_on: (t.acts_on as unknown[]).map(String) } : {}), + ...(typeof t.label === "string" ? { label: t.label } : {}), + })) + return terms.length === 0 ? undefined : { terms, ...(typeof raw.notes === "string" ? { notes: raw.notes } : {}) } +} + /** Structured projection for the entity view (spec §3 point 3). Composite → read * through; legacy flat → collapse to N=1 in place (role from platform: rydberg→atom * else qubit — mirrors normalizeSystem without importing it). Never throws. */ @@ -559,6 +588,7 @@ export function systemProjection(input: Record): SystemProjecti kind: str(cp.kind) ?? "?", params: asNumberRecord(cp.params), })), + ...(asHamiltonian(entity.hamiltonian) ? { hamiltonian: asHamiltonian(entity.hamiltonian) } : {}), ...(typeof entity.notes === "string" ? { notes: entity.notes } : {}), isComposite: true, } diff --git a/packages/ui/src/amicode/system-render.test.ts b/packages/ui/src/amicode/system-render.test.ts index edc873f687..03aef0b100 100644 --- a/packages/ui/src/amicode/system-render.test.ts +++ b/packages/ui/src/amicode/system-render.test.ts @@ -1,6 +1,15 @@ import { describe, it, expect } from "bun:test" +import katex from "katex" import { systemProjection } from "./problem" -import { systemSchematicModel, systemTableModel, systemHamiltonianLatex, systemIdentityLine } from "./system-render" +import { + systemSchematicModel, + systemTableModel, + systemHamiltonianLatex, + systemHamiltonian, + systemIdentityLine, + systemCountLabel, + componentPhysicsRows, +} from "./system-render" const twoTransmon = { platform: "transmon", @@ -53,7 +62,7 @@ describe("systemSchematicModel", () => { describe("systemHamiltonianLatex", () => { it("composes drift + coupling + drive for a cavity+qubit dispersive system", () => { const cavQubit = { - platform: "cavity", + platform: "transmon", drive: { arch: "per-component" }, components: [ { id: "q1", role: "qubit", levels: 3, params: { omega: 4, delta: -0.2 } }, @@ -64,7 +73,11 @@ describe("systemHamiltonianLatex", () => { const h = systemHamiltonianLatex(systemProjection(cavQubit))! expect(h).toContain("\\hat H/\\hbar") expect(h).toContain("\\chi") // the dispersive interaction term - expect(h).toContain("\\varepsilon(t)") // drive term + // per-component drive → one independently indexed control PAIR per subsystem + // (two quadratures — Piccolo's n_drives = 2, matching the plugin's TRANSMON_LATEX) + expect(h).toContain("u_{1,1}(t)") + expect(h).toContain("i\\,u_{2,1}(t)") + expect(h).toContain("u_{1,2}(t)") }) it("returns undefined for a system with no components", () => { expect(systemHamiltonianLatex(systemProjection({ platform: "x", components: [], couplings: [] }))).toBeUndefined() @@ -77,25 +90,152 @@ describe("systemHamiltonianLatex", () => { couplings: [], } const h = systemHamiltonianLatex(systemProjection(rydberg))! - expect(h).toContain("-\\Delta\\,|r\\rangle\\langle r|") // detuning on the Rydberg level, not -Δ n̂ + // n̂ = |r⟩⟨r|, the same operator the vdW term uses — one notation per operator + expect(h).toContain("-\\Delta\\,\\hat n") expect(h).toContain("\\Omega(t)") // laser Rabi drive expect(h).toContain("|r\\rangle\\langle 1|") expect(h).not.toContain("\\varepsilon(t)") // no cavity-style drive on a bare atom expect(h).not.toContain("\\hat a") // no bosonic ladder operators at all + expect(h).not.toContain("\\sum") // N=1 carries no index clutter }) - it("two rydberg atoms + vdW → single deduped drift/drive pair + blockade term", () => { - const pair = { + it("N atoms sum over sites — the register size is IN the equation", () => { + const atoms = (n: number) => ({ platform: "rydberg", drive: { arch: "global" }, + components: Array.from({ length: n }, (_, i) => ({ id: `q${i + 1}`, role: "atom", levels: 3, params: {} })), + couplings: Array.from({ length: n - 1 }, (_, i) => ({ + between: [`q${i + 1}`, `q${i + 2}`], + kind: "vdW", + params: {}, + })), + }) + const two = systemHamiltonianLatex(systemProjection(atoms(2)))! + const three = systemHamiltonianLatex(systemProjection(atoms(3)))! + // the bug this replaces: 1, 2 and 20 atoms all rendered the same string + expect(two).not.toBe(systemHamiltonianLatex(systemProjection(atoms(1)))!) + expect(two).toContain("-\\Delta\\,\\sum_i \\hat n_{i}") + expect(two).toContain("\\tfrac{C_6}{r_{12}^6}\\,\\hat n_{1} \\hat n_{2}") // one edge → real site ids + expect(three).toContain("\\sum_{\\langle ij\\rangle} \\tfrac{C_6}{r_{ij}^6}") // two edges → sum over pairs + expect(two.split("\\Omega(t)")).toHaveLength(2) // one global control, applied to every site + }) + it("drive architecture reaches the equation: global shares one control, per-site indexes it", () => { + const pair = (arch: string) => ({ + platform: "rydberg", + drive: { arch }, components: [ { id: "q1", role: "atom", levels: 3, params: {} }, { id: "q2", role: "atom", levels: 3, params: {} }, ], couplings: [{ between: ["q1", "q2"], kind: "vdW", params: {} }], + }) + const global = systemHamiltonianLatex(systemProjection(pair("global")))! + const per = systemHamiltonianLatex(systemProjection(pair("per-component")))! + const zoned = systemHamiltonianLatex(systemProjection(pair("zoned")))! + expect(global).toContain("\\Omega(t)") // one knob for the whole register + expect(per).toContain("\\Omega_{i}(t)") // one knob per atom + expect(zoned).toContain("\\Omega_{z(i)}(t)") // one knob per zone + expect(new Set([global, per, zoned]).size).toBe(3) // the badge is not decoration + expect(global).toContain("-\\Delta\\,\\sum_i") // Δ is the laser's, so it follows the drive + expect(per).toContain("\\Delta_{i}") + }) + it("a qubit and a cavity never share an operator symbol", () => { + const h = systemHamiltonianLatex( + systemProjection({ + platform: "transmon", + drive: { arch: "per-component" }, + components: [ + { id: "q1", role: "qubit", levels: 3, params: {} }, + { id: "c1", role: "cavity", levels: 10, params: {} }, + ], + couplings: [{ between: ["q1", "c1"], kind: "dispersive-chi", params: {} }], + }), + )! + expect(h).toContain("\\hat a^\\dagger_{1} \\hat a_{1}") // qubit ladder + expect(h).toContain("\\hat b^\\dagger_{2} \\hat b_{2}") // cavity gets its OWN letter + expect(h).toContain("\\chi\\,\\hat b^\\dagger_{2} \\hat b_{2}\\,\\hat n_{1}") + }) + it("an off-template platform infers NOTHING rather than the transmon ladder", () => { + // The reported bug: "hrl style spin qubit" → role defaults to qubit, levels + // unstated, and the card asserted ω â†â + δ/2 ↲Ⲡ+ ε(t)(â + â†). There is + // no honest fallback here — `Ĥ_drift + Ĥ_c(t)` is true of every control + // problem ever posed — so the slot stays empty until someone records one. + const hrl = { + platform: "hrl-spin", + drive: { arch: "per-component" }, + components: [{ id: "q1", role: "other", params: {} }], + couplings: [], } - const h = systemHamiltonianLatex(systemProjection(pair))! - expect(h).toContain("C_6") // blockade interaction - expect(h.split("\\Omega(t)")).toHaveLength(2) // drive appears exactly once + expect(systemHamiltonianLatex(systemProjection(hrl))).toBeUndefined() + // …a role defaulted to "qubit" by an unrecognized platform is the same case… + expect( + systemHamiltonianLatex(systemProjection({ ...hrl, components: [{ id: "q1", role: "qubit", params: {} }] })), + ).toBeUndefined() + // …and so is one where the researcher HAS stated a level count. Three levels + // on an exchange-only qubit is three dots, not an anharmonic ladder. + expect( + systemHamiltonianLatex( + systemProjection({ ...hrl, components: [{ id: "q1", role: "qubit", levels: 3, params: { drive_max: 1 } }] }), + ), + ).toBeUndefined() + // the same entity on a platform we DO model keeps its ladder + const transmon = systemHamiltonianLatex( + systemProjection({ ...hrl, platform: "transmon", components: [{ id: "q1", role: "qubit", params: {} }] }), + )! + expect(transmon).toContain("\\tfrac{\\delta}{2}") + }) + + it("an unmodelled component in a MIXED system is a placeholder, not a hole", () => { + // Here there IS something to say, so the modelled parts render and the + // unknown one gets a named term rather than invented algebra. + const h = systemHamiltonianLatex( + systemProjection({ + platform: "hybrid", + drive: { arch: "per-component" }, + components: [ + { id: "q1", role: "atom", levels: 3, params: {} }, + { id: "s1", role: "spin-qudit", levels: 4, params: {} }, + ], + couplings: [], + }), + )! + expect(h).toContain("\\hat H_{\\mathrm{drift}}^{(2)}") + expect(h).toContain("\\hat H_{\\mathrm{c}}^{(2)}(t)") + expect(h).toContain("|r\\rangle\\langle 1|_{1}") // the atom still renders properly + expect(h).not.toContain("\\hat a") // no bosonic algebra conjured for the qudit + }) + it("a term carrying its own minus sign is joined with −, not '+ -'", () => { + const h = systemHamiltonianLatex( + systemProjection({ + platform: "hybrid", + drive: { arch: "per-component" }, + components: [ + { id: "c1", role: "cavity", levels: 10, params: {} }, + { id: "a1", role: "atom", levels: 3, params: {} }, + ], + couplings: [], + }), + )! + expect(h).not.toContain("+ -") + expect(h).toContain(" - \\Delta_{2}") + }) + it("levels, not param presence, picks the qubit model: 3-level ladder vs 2-level spin", () => { + const ladder = { + platform: "transmon", + drive: { arch: "per-component" }, + // params still empty — the model is a 3-level ladder regardless + components: [{ id: "q1", role: "qubit", levels: 3, params: {} }], + couplings: [], + } + const h = systemHamiltonianLatex(systemProjection(ladder))! + expect(h).toContain("\\tfrac{\\delta}{2}") + expect(h).not.toContain("\\hat\\sigma_z") + + const spin = systemHamiltonianLatex( + systemProjection({ ...ladder, components: [{ id: "q1", role: "qubit", levels: 2, params: {} }] }), + )! + expect(spin).toContain("\\hat\\sigma_z") + expect(spin).toContain("\\hat\\sigma_x") // driven in the Pauli basis… + expect(spin).not.toContain("\\hat a") // …not on a bosonic quadrature }) it("mixed atom + cavity → both drive flavors", () => { const mixed = { @@ -108,8 +248,190 @@ describe("systemHamiltonianLatex", () => { couplings: [], } const h = systemHamiltonianLatex(systemProjection(mixed))! - expect(h).toContain("\\Omega(t)") - expect(h).toContain("\\varepsilon(t)") + expect(h).toContain("\\Omega_{1}(t)") // laser Rabi on the atom + expect(h).toContain("u_{1,2}(t)") // quadrature drive on the cavity + }) +}) + +describe("systemHamiltonian — recorded beats inferred", () => { + // The architectural point: the agent knows what an exchange-only spin qubit + // is; the fallback table never will. When the model is RECORDED the card + // renders exactly that and stops guessing. + const hrl = { + platform: "hrl-spin", + drive: { arch: "per-component" }, + components: [ + { id: "q1", role: "other", params: {} }, + { id: "q2", role: "other", params: {} }, + { id: "q3", role: "other", params: {} }, + ], + couplings: [ + { between: ["q1", "q2"], kind: "exchange", params: {} }, + { between: ["q2", "q3"], kind: "exchange", params: {} }, + ], + hamiltonian: { + terms: [ + { kind: "coupling", latex: "J_{12}(t)\\,\\vec S_1 \\cdot \\vec S_2", label: "exchange 1–2" }, + { kind: "coupling", latex: "J_{23}(t)\\,\\vec S_2 \\cdot \\vec S_3", label: "exchange 2–3" }, + ], + notes: "encoded qubit in the S=1/2, S_z=-1/2 subspace; exchange-only, no on-site drive", + }, + } + + it("renders the recorded terms verbatim and marks them recorded", () => { + const h = systemHamiltonian(systemProjection(hrl))! + expect(h.source).toBe("recorded") + expect(h.latex).toBe("\\hat H/\\hbar = J_{12}(t)\\,\\vec S_1 \\cdot \\vec S_2 + J_{23}(t)\\,\\vec S_2 \\cdot \\vec S_3") + expect(h.notes).toContain("exchange-only") + expect(() => katex.renderToString(h.latex, { throwOnError: true })).not.toThrow() + }) + + it("orders drift → coupling → drive however they were recorded", () => { + const h = systemHamiltonian( + systemProjection({ + ...hrl, + hamiltonian: { + terms: [ + { kind: "drive", latex: "u(t)\\,\\hat X" }, + { kind: "drift", latex: "\\omega\\,\\hat Z" }, + { kind: "coupling", latex: "J\\,\\hat Z_1\\hat Z_2" }, + ], + }, + }), + )! + expect(h.latex).toBe("\\hat H/\\hbar = \\omega\\,\\hat Z + J\\,\\hat Z_1\\hat Z_2 + u(t)\\,\\hat X") + }) + + it("a recorded term carrying a minus is joined with −, like the inferred path", () => { + const h = systemHamiltonian( + systemProjection({ + ...hrl, + hamiltonian: { terms: [{ kind: "drift", latex: "\\omega\\,\\hat Z" }, { kind: "drift", latex: "-\\Delta\\,\\hat n" }] }, + }), + )! + expect(h.latex).not.toContain("+ -") + expect(h.latex).toContain(" - \\Delta") + }) + + it("falls back to the inferred form, labelled, when nothing is recorded", () => { + const h = systemHamiltonian(systemProjection(twoTransmon))! + expect(h.source).toBe("inferred") + expect(h.latex).toBe(systemHamiltonianLatex(systemProjection(twoTransmon))!) + }) + + it("nothing recorded and nothing modelled → undefined, so the card can say so", () => { + expect(systemHamiltonian(systemProjection({ ...hrl, hamiltonian: undefined }))).toBeUndefined() + }) + + it("junk terms are dropped rather than rendered as holes", () => { + const junk = (terms: unknown) => systemHamiltonian(systemProjection({ ...hrl, hamiltonian: { terms } } as any)) + expect(junk([{ kind: "drift" }, { kind: "drift", latex: " " }])).toBeUndefined() // → falls through + expect(junk([{ kind: "drift", latex: "\\omega\\,\\hat Z" }, { latex: 42 }])!.latex).toBe( + "\\hat H/\\hbar = \\omega\\,\\hat Z", + ) + expect(junk("not an array")).toBeUndefined() + }) +}) + +describe("systemHamiltonianLatex — exhaustive sweep", () => { + // Every role × every coupling kind × every drive arch × N ∈ {2,3}. The card + // renders this straight into KaTeX, so an unparseable string is a visible + // error box in the transcript; a composer that special-cases roles and edge + // shapes needs the whole product space swept, not a handful of examples. + const ROLES = ["qubit2", "qubit3", "atom", "cavity", "cavityK", "resonator", "mode", "unmodeled"] + const KINDS = ["exchange", "ZZ", "cross-resonance", "dispersive-chi", "vdW", "mode-mediated", "not-a-kind"] + const ARCHES = ["global", "per-component", "zoned", undefined] + // Platform is load-bearing: it is the only thing that licenses a ladder for a + // `qubit` role, so the sweep has to cross both sides of that line. + const PLATFORMS = ["transmon", "exchange-only-spin"] + const LADDER = new Set(["transmon", "bosonic"]) + /** No model → no terms. The only two ways to get there. */ + const unmodelled = (r: string, p: string) => r === "unmodeled" || (r === "qubit3" && !LADDER.has(p)) + const mk = (r: string, i: number) => + r === "qubit2" ? { id: `q${i}`, role: "qubit", levels: 2, params: {} } + : r === "qubit3" ? { id: `q${i}`, role: "qubit", levels: 3, params: {} } + : r === "atom" ? { id: `q${i}`, role: "atom", levels: 3, params: {} } + : r === "cavityK" ? { id: `q${i}`, role: "cavity", levels: 10, params: { K_c_Hz: 3 } } + : r === "unmodeled" ? { id: `q${i}`, role: "flux-tunable-thingy", levels: 4, params: { foo: 1 } } + : { id: `q${i}`, role: r, levels: 10, params: {} } + + it("every expressible system renders parseable KaTeX", () => { + const broken: string[] = [] + let checked = 0 + // Render each DISTINCT output once. The sweep enumerates ~8k systems but they + // collapse onto far fewer equations, and KaTeX is the expensive part — + // rendering the same string 200 times proves nothing and timed out CI. + const distinct = new Map() + for (const platform of PLATFORMS) + for (const a of ROLES) + for (const b of ROLES) + for (const kind of KINDS) + for (const arch of ARCHES) + for (const third of [false, true]) { + const components = [mk(a, 1), mk(b, 2), ...(third ? [mk(b, 3)] : [])] + const latex = systemHamiltonianLatex( + systemProjection({ + platform, + ...(arch ? { drive: { arch } } : {}), + components, + couplings: [ + { between: ["q1", "q2"], kind, params: {} }, + ...(third ? [{ between: ["q2", "q3"], kind, params: {} }] : []), + ], + }), + ) + // No output is the CORRECT answer when nothing in the system has + // a model — there is no honest canonical form to fall back to. + if (!latex) { + if (!unmodelled(a, platform) || !unmodelled(b, platform)) + broken.push(`no output: ${platform}/${a}/${b}/${kind}`) + continue + } + // …and conversely, an all-unmodelled system must NOT produce one. + if (unmodelled(a, platform) && unmodelled(b, platform)) + broken.push(`invented a model for ${platform}/${a}/${b}/${kind}: ${latex}`) + checked++ + if (!distinct.has(latex)) + distinct.set(latex, `${platform}/${a}/${b}/${kind}/${arch}/N${components.length}`) + } + for (const [latex, where] of distinct) { + try { + katex.renderToString(latex, { throwOnError: true }) + } catch (err) { + broken.push(`${where}: ${(err as Error).message}\n ${latex}`) + } + } + expect(checked).toBeGreaterThan(6000) + expect(distinct.size).toBeGreaterThan(100) // the sweep really does vary the output + expect(broken).toEqual([]) + }) + + it("survives malformed input without throwing", () => { + const cases = [ + // coupling naming a component that doesn't exist + { platform: "x", components: [{ id: "q1", role: "atom", levels: 3, params: {} }], + couplings: [{ between: ["q1", "GHOST"], kind: "vdW", params: {} }] }, + // a one-ended coupling + { platform: "x", components: [{ id: "q1", role: "qubit", levels: 3, params: {} }], + couplings: [{ between: ["q1"], kind: "ZZ", params: {} }] }, + // no levels recorded anywhere + { platform: "x", components: [{ id: "q1", role: "qubit", params: {} }], couplings: [] }, + // a mode-mediated hyperedge across three different roles + { platform: "x", + components: [ + { id: "q1", role: "qubit", levels: 3, params: {} }, + { id: "a1", role: "atom", levels: 3, params: {} }, + { id: "m1", role: "mode", levels: 8, params: {} }, + ], + couplings: [{ between: ["q1", "a1", "m1"], kind: "mode-mediated", params: {} }] }, + ] + for (const c of cases) { + const latex = systemHamiltonianLatex(systemProjection(c as any)) + // undefined is allowed (nothing modelled); anything else must be renderable + if (latex === undefined) continue + expect(latex).toContain("\\hat H/\\hbar") + expect(() => katex.renderToString(latex, { throwOnError: true })).not.toThrow() + } }) }) @@ -123,6 +445,119 @@ describe("systemTableModel", () => { }) }) +describe("componentPhysicsRows", () => { + const labels = (c: any, platform?: string) => componentPhysicsRows(c, platform).map((r) => r.label) + const row = (c: any, label: string, platform?: string) => + componentPhysicsRows(c, platform).find((r) => r.label === label) + + it("a rydberg atom is never asked for an anharmonicity — it gets detuning + Rabi", () => { + const atom = { id: "q1", role: "atom", levels: 3, params: {} } + expect(labels(atom)).not.toContain("anharmonicity") + expect(labels(atom)).not.toContain("frequency") + expect(labels(atom)).toEqual(["levels", "detuning", "rabi drive", "decay"]) + expect(row(atom, "detuning")!.state).toBe("missing") + }) + + it("a transmon keeps the frequency/anharmonicity/drive-bound spec", () => { + const q = { id: "q1", role: "qubit", levels: 3, params: { omega: 4.8, delta: -0.2 } } + expect(labels(q, "transmon")).toEqual(["levels", "frequency", "anharmonicity", "drive bound", "decay"]) + expect(row(q, "frequency", "transmon")).toMatchObject({ value: "4.8", state: "recorded" }) + expect(row(q, "drive bound", "transmon")).toMatchObject({ value: "not set", state: "missing" }) + }) + + it("a TWO-level qubit has no anharmonicity row at all", () => { + expect(labels({ id: "q1", role: "qubit", levels: 2, params: {} })).not.toContain("anharmonicity") + }) + + it("an off-template platform is NOT given the transmon model just because role defaults to qubit", () => { + // platformDefaultRole maps every unfamiliar platform to "qubit", so an + // exchange-only HRL-style spin qubit arrives here indistinguishable from a + // transmon by role alone. It used to be handed ω, δ, |u| and the transmon + // Hamiltonian; an exchange-only qubit has no anharmonicity to speak of. + const hrl = { id: "q1", role: "qubit", params: {} } + expect(labels(hrl)).toEqual(["levels"]) // no platform → nothing claimed + expect(componentPhysicsRows(hrl, "hrl-spin").map((r) => r.label)).toEqual(["levels"]) + expect(componentPhysicsRows(hrl, "hrl-spin").map((r) => r.label)).not.toContain("anharmonicity") + // …while a transmon, whose model we do have, still fills in before levels. + expect(componentPhysicsRows(hrl, "transmon").map((r) => r.label)).toEqual([ + "levels", + "frequency", + "anharmonicity", + "drive bound", + "decay", + ]) + }) + + it("two levels earns the generic two-level model on any platform", () => { + // Safe everywhere: every two-level system has a splitting and σx/σy control. + const spin = { id: "q1", role: "qubit", levels: 2, params: {} } + expect(componentPhysicsRows(spin, "hrl-spin").map((r) => r.label)).toEqual([ + "levels", + "frequency", + "drive bound", + "decay", + ]) + expect(componentPhysicsRows(spin, "hrl-spin").map((r) => r.label)).not.toContain("anharmonicity") + }) + + it("THREE levels is a dimension, not an oscillator — it earns no ladder off-template", () => { + // Reported against `exchange-only-spin` at levels=3: the card still showed + // ω â†â + δ/2 ↲Ⲡ+ u₁(â+â†) + i u₂(â−â†) and asked for an anharmonicity. + // An exchange-only qubit at levels=3 is three dots; a spin-1 defect is three + // Zeeman sublevels. Neither is an anharmonic ladder. + const three = { id: "q1", role: "qubit", levels: 3, params: {} } + expect(componentPhysicsRows(three, "exchange-only-spin").map((r) => r.label)).toEqual(["levels"]) + // …and the platform whose qubits ARE ladders still gets one. + expect(componentPhysicsRows(three, "transmon").map((r) => r.label)).toContain("anharmonicity") + }) + + it("an unrecognized role expects nothing — only what was recorded shows", () => { + const spin = { id: "s1", role: "spin", params: { J_MHz: 12 } } + expect(labels(spin)).toEqual(["levels", "J"]) + expect(row(spin, "levels")!.state).toBe("missing") + expect(row(spin, "J")).toMatchObject({ value: "12 MHz", state: "recorded" }) + }) + + it("unit-suffixed keys render their unit; bare keys never get an assumed one", () => { + const c = { id: "q1", role: "qubit", levels: 3, params: { omega_GHz: 4.8, drive_max: 0.2 } } + expect(row(c, "frequency", "transmon")!.value).toBe("4.8 GHz") + expect(row(c, "drive bound", "transmon")!.value).toBe("≤ 0.2") + }) + + it("an atom's Δ does not absorb a transmon's δ — a stray delta stays unclaimed", () => { + const atom = { id: "q1", role: "atom", levels: 3, params: { delta: 0.2 } } + expect(row(atom, "detuning")!.state).toBe("missing") + expect(row(atom, "delta")).toMatchObject({ sym: "δ", value: "0.2", state: "recorded" }) + }) + + it("levels reads 'not set' rather than being silently omitted", () => { + expect(row({ id: "q1", role: "qubit", params: {} }, "levels")).toMatchObject({ + value: "not set", + state: "missing", + }) + }) + + it("a cavity gets frequency/kerr/linewidth, not a drive bound", () => { + const cav = { id: "c1", role: "cavity", levels: 10, params: { K_c_Hz: 3.25 } } + expect(labels(cav)).toEqual(["levels", "frequency", "kerr", "linewidth", "decay"]) + expect(row(cav, "kerr")!.value).toBe("3.25 Hz") + }) + + it("zero still reads as unset, and recorded T₁/T₂ collapse into one decay row", () => { + const c = { id: "q1", role: "qubit", levels: 3, params: { omega: 0, T1: 30, T2: 20 } } + expect(row(c, "frequency", "transmon")!.state).toBe("missing") + expect(row(c, "decay", "transmon")).toMatchObject({ value: "T₁ 30 · T₂ 20", state: "recorded" }) + }) +}) + +describe("systemCountLabel", () => { + it("names N so an unanswered structure question can't read as 'one'", () => { + expect(systemCountLabel(systemProjection(twoTransmon))).toBe("2 qubits × 3 levels") + expect(systemCountLabel(systemProjection({ platform: "rydberg", params: {} }))).toBe("1 atom") + expect(systemCountLabel(systemProjection({ platform: "x", components: [], couplings: [] }))).toBeUndefined() + }) +}) + describe("systemIdentityLine", () => { it("summarizes platform · N role(s) × levels · arch", () => { expect(systemIdentityLine(systemProjection(twoTransmon))).toBe( diff --git a/packages/ui/src/amicode/system-render.ts b/packages/ui/src/amicode/system-render.ts index d9dfed949d..c97f311c65 100644 --- a/packages/ui/src/amicode/system-render.ts +++ b/packages/ui/src/amicode/system-render.ts @@ -2,24 +2,32 @@ // (spec-20260709 §6.1 / plan Task 4). Consumes the existing systemProjection; // no SolidJS. Never throws. import { systemProjection, type SystemProjection } from "./problem" +import { formatSci } from "./facets" + +/** The component-count claim the card is making — "2 atoms × 3 levels", or + * undefined when there are none. Rendered as its own badge so N is something + * the researcher can read and correct, not something they have to infer from + * the card's shape (an unanswered "how many atoms?" used to look like "one"). */ +export function systemCountLabel(proj: SystemProjection): string | undefined { + const comps = proj.components ?? [] + if (comps.length === 0) return undefined + const roles = new Set(comps.map((c) => c.role)) + const levels = new Set(comps.map((c) => c.levels).filter((l): l is number => typeof l === "number")) + // "other" is the honest role for an unclassified subsystem, but "3 others" + // reads as a bug — say what it is structurally instead. + const only = roles.size === 1 ? [...roles][0] : undefined + const role = only === undefined || only === "other" || only === "?" ? "component" : only + const seg = `${comps.length} ${comps.length === 1 ? role : `${role}s`}` + return levels.size === 1 ? `${seg} × ${[...levels][0]} levels` : seg +} /** One-line "what is this system" identity: platform · N role(s) × L levels · * drive. e.g. "rydberg · 2 atoms × 3 levels · global drive". Collapses * the schematic+table into a scannable header line. Never throws. */ export function systemIdentityLine(proj: SystemProjection): string { - const parts: string[] = [] - if (proj.platform) parts.push(proj.platform) - const comps = proj.components ?? [] - if (comps.length > 0) { - const roles = new Set(comps.map((c) => c.role)) - const levels = new Set(comps.map((c) => c.levels).filter((l): l is number => typeof l === "number")) - const role = roles.size === 1 ? [...roles][0] : "component" - let seg = `${comps.length} ${comps.length === 1 ? role : `${role}s`}` - if (levels.size === 1) seg += ` × ${[...levels][0]} levels` - parts.push(seg) - } - if (proj.driveArch) parts.push(`${proj.driveArch} drive`) - return parts.join(" · ") + return [proj.platform, systemCountLabel(proj), proj.driveArch ? `${proj.driveArch} drive` : undefined] + .filter((p): p is string => p !== undefined && p !== "") + .join(" · ") } export type SchematicNode = { id: string; label: string; levels?: number } @@ -63,70 +71,457 @@ export type ComponentRow = { id: string; role: string; levels?: number; params: export type CouplingRow = { between: string[]; kind: string; params: Record } export type TableModel = { components: ComponentRow[]; couplings: CouplingRow[] } -// Composite Hamiltonian LaTeX (spec §6.1 "show the system"): drift per distinct -// component role + interaction per distinct coupling kind + a drive term. -// Illustrative (authoring-aware bookkeeping spirit), not an exact derivation. -const COUPLING_TERM: Record = { - "dispersive-chi": "\\tfrac{\\chi}{2}\\,\\hat a^\\dagger \\hat a\\,\\hat\\sigma_z", - ZZ: "J\\,\\hat\\sigma_z^{(1)}\\hat\\sigma_z^{(2)}", - "cross-resonance": "\\Omega\\,\\hat\\sigma_x^{(1)}\\hat\\sigma_z^{(2)}", - exchange: "g\\,(\\hat a^\\dagger \\hat b + \\hat a \\hat b^\\dagger)", - vdW: "\\tfrac{C_6}{r^6}\\,\\hat n_1 \\hat n_2", - "mode-mediated": "g\\,(\\hat a^\\dagger \\hat b + \\mathrm{h.c.})", -} - -function driftTerm(role: string, params: Record): string { - const anharmonic = ["delta", "K_q", "anharmonicity", "K_c", "K_c_Hz", "kerr"].some((k) => k in params) - switch (role) { +// Composite Hamiltonian LaTeX (spec §6.1 "show the system"), composed over the +// ACTUAL component and edge sets. The previous version deduped term STRINGS over +// the set of distinct roles, which meant a 2-atom register and a 20-atom register +// rendered the identical single-site Hamiltonian, an N-edge chain rendered one +// edge with hardcoded indices (1),(2), a qubit and a cavity in the same system +// both used â, and the drive-arch badge had no counterpart in the equation. +// Still ILLUSTRATIVE — the canonical model per role, not a derivation from the +// recorded numbers — but it has to be the Hamiltonian of THIS system. + +type SiteKind = "spin" | "ladder" | "rydberg" | "opaque" +type Site = { idx: number; role: string; kind: SiteKind; key: string; letter: string } + +/** Distinct bosonic groups get distinct operator letters, so a qubit ladder and + * a cavity in one system are never both â. */ +const LADDER_LETTERS = ["a", "b", "c", "d", "e", "f", "g", "h"] +const KERR_KEYS = ["K", "K_c", "K_c_Hz", "kerr"] +const MODE_ROLES = new Set(["cavity", "resonator", "mode"]) + +/** The ONLY platforms whose `qubit` role is an anharmonic ladder. Nothing else + * may assume one — not the role (the plugin's platformDefaultRole maps every + * unfamiliar platform to "qubit"), and not the level count (three levels is a + * dimension, not an oscillator). Both of those leaks put the transmon + * Hamiltonian, and the transmon's anharmonicity row, on a spin qubit. + * A bosonic MODE is classified by its role instead, so it needs no entry here; + * "bosonic" covers a platform that calls its computational element a qubit. */ +const LADDER_PLATFORMS = new Set(["transmon", "bosonic"]) + +/** The term shape a component contributes; `key` groups sites that share one + * (two linear cavities are one group, a Kerr cavity is its own) and selects the + * physics rows, so the equation and the table can never disagree. */ +function classify(c: ComponentRow, platform?: string): { kind: SiteKind; key: string } { + switch (c.role) { + case "atom": + return { kind: "rydberg", key: "rydberg" } case "qubit": - return anharmonic - ? "\\omega\\,\\hat a^\\dagger \\hat a + \\tfrac{\\delta}{2}\\,\\hat a^\\dagger \\hat a^\\dagger \\hat a \\hat a" - : "\\tfrac{\\omega}{2}\\,\\hat\\sigma_z" + // Two levels is a generic two-level system on ANY platform — every one of + // them has an ω σ_z/2 splitting and σ_x/σ_y control, so that much is safe. + // + // MORE than two levels is NOT evidence of an anharmonic oscillator. It is + // evidence of a Hilbert-space dimension and nothing else: an exchange-only + // spin qubit at levels=3 is three dots, a spin-1 defect is three Zeeman + // sublevels, and neither is a ladder. Only a platform that comes with a + // ladder model may claim one. + if (c.levels === 2) return { kind: "spin", key: "spin" } + return LADDER_PLATFORMS.has((platform ?? "").toLowerCase()) + ? { kind: "ladder", key: "qubit" } + : { kind: "opaque", key: `opaque:${c.role}` } case "cavity": case "resonator": case "mode": - return anharmonic ? "\\omega_c\\,\\hat a^\\dagger \\hat a + \\tfrac{K}{2}\\,\\hat a^{\\dagger 2}\\hat a^2" : "\\omega_c\\,\\hat a^\\dagger \\hat a" - case "atom": - return "-\\Delta\\,|r\\rangle\\langle r|" + // A mode's Kerr is genuinely optional, so here the params ARE the evidence. + return KERR_KEYS.some((k) => k in c.params) ? { kind: "ladder", key: "mode-kerr" } : { kind: "ladder", key: "mode" } default: - return "\\hat H_{\\mathrm{drift}}" + return { kind: "opaque", key: `opaque:${c.role}` } } } -/** Drive term per component role. Atoms are laser-driven on the |1⟩↔|r⟩ - * transition (3-level Rydberg convention: |0⟩ dark); everything bosonic or - * bosonic-truncated keeps the quadrature drive. */ -function driveTerm(role: string): string { - switch (role) { - case "atom": - return "\\tfrac{\\Omega(t)}{2}\\,(|r\\rangle\\langle 1| + \\mathrm{h.c.})" +const ann = (letter: string, i: string) => (i ? `\\hat ${letter}_{${i}}` : `\\hat ${letter}`) +const cre = (letter: string, i: string) => (i ? `\\hat ${letter}^\\dagger_{${i}}` : `\\hat ${letter}^\\dagger`) +const num = (i: string) => (i ? `\\hat n_{${i}}` : "\\hat n") +const pauli = (axis: string, i: string) => (i ? `\\hat\\sigma_${axis}^{(${i})}` : `\\hat\\sigma_${axis}`) +const sub = (sym: string, i: string) => (i ? `${sym}_{${i}}` : sym) + +/** The index a CONTROL carries, bare: "" when one knob is shared by every site + * (a global drive, or a single-component system), the site index when each has + * its own, the zone when they are zoned. That distinction is the whole + * difference between a global-drive CZ and a locally-addressed one, and the + * card claims it in a badge. Bare so callers can compose it either as a + * subscript (`\Omega_{i}`) or into an existing one (`u_{1,i}`). */ +const controlIdx = (i: string, arch?: string) => (!i || arch === "global" ? "" : arch === "zoned" ? `z(${i})` : i) +const control = (i: string, arch?: string) => { + const c = controlIdx(i, arch) + return c ? `_{${c}}` : "" +} + +/** Sum prefix + index token for a group: no index at all in a single-component + * system, a literal site number for a lone member, `\sum_i` when the group is + * every site, else an explicit index set. */ +function indexing(group: Site[], total: number): { sum: string; i: string } { + if (total === 1) return { sum: "", i: "" } + if (group.length === total) return { sum: "\\sum_i ", i: "i" } + if (group.length === 1) return { sum: "", i: String(group[0].idx) } + return { sum: `\\sum_{i \\in \\{${group.map((s) => s.idx).join(",")}\\}} `, i: "i" } +} + +/** Parenthesize a summed body only when it has a top-level `+` — an h.c. inside + * its own parens must not trigger a redundant outer bracket. */ +function wrap(sum: string, body: string): string { + if (!sum) return body + let depth = 0 + for (const ch of body) { + if (ch === "(") depth++ + else if (ch === ")") depth-- + else if (ch === "+" && depth === 0) return `${sum}\\left(${body}\\right)` + } + return `${sum}${body}` +} + +function driftLatex(g: Site[], total: number, arch?: string): string { + const { sum, i } = indexing(g, total) + switch (g[0].kind) { + case "spin": + return wrap(sum, `\\tfrac{${sub("\\omega", i)}}{2}\\,${pauli("z", i)}`) + case "rydberg": { + // Δ is set by the laser, so it is per-site exactly when the drive is. + const c = control(i, arch) + return c ? `-${sum}\\Delta${c}\\,${num(i)}` : `-\\Delta\\,${sum}${num(i)}` + } + case "ladder": { + const L = g[0].letter + const isMode = g[0].key.startsWith("mode") + const w = isMode ? (i ? `\\omega_{c,${i}}` : "\\omega_c") : sub("\\omega", i) + const linear = `${w}\\,${cre(L, i)} ${ann(L, i)}` + if (g[0].key === "mode") return wrap(sum, linear) + const k = isMode ? sub("K", i) : sub("\\delta", i) + const sq = i ? `\\hat ${L}^{\\dagger 2}_{${i}}\\hat ${L}^{2}_{${i}}` : `\\hat ${L}^{\\dagger 2}\\hat ${L}^{2}` + return wrap(sum, `${linear} + \\tfrac{${k}}{2}\\,${sq}`) + } default: - return "\\varepsilon(t)\\,(\\hat a + \\hat a^\\dagger)" + // No model for this role — name a drift, don't invent its algebra. + return `${sum}${i ? `\\hat H_{\\mathrm{drift}}^{(${i})}` : "\\hat H_{\\mathrm{drift}}"}` } } -/** Compose an illustrative Hamiltonian for ANY composite system. undefined when - * there are no components. Distinct role drifts + distinct coupling terms + drive. */ -export function systemHamiltonianLatex(proj: SystemProjection): string | undefined { - const terms: string[] = [] - const seen = new Set() - for (const c of proj.components) { - const t = driftTerm(c.role, c.params) - if (!seen.has(t)) { seen.add(t); terms.push(t) } +/** Atoms are laser-driven on |1⟩↔|r⟩ (3-level Rydberg convention: |0⟩ dark); a + * strictly two-level component is driven in the Pauli basis its drift already + * uses; bosonic and bosonic-truncated components keep the quadrature drive; a + * role we have no model for gets a named control, not an invented operator. */ +function driveLatex(g: Site[], total: number, arch?: string): string { + const { sum, i } = indexing(g, total) + const c = control(i, arch) + switch (g[0].kind) { + case "rydberg": + return wrap(sum, `\\tfrac{\\Omega${c}(t)}{2}\\,(|r\\rangle\\langle 1|${i ? `_{${i}}` : ""} + \\mathrm{h.c.})`) + case "spin": + return wrap(sum, `u^x${c}(t)\\,${pauli("x", i)} + u^y${c}(t)\\,${pauli("y", i)}`) + case "ladder": { + // TWO quadratures. Piccolo drives a transmon with n_drives = 2, and the + // plugin's TRANSMON_LATEX (what the agent shows in chat) always said so — + // this table used to say `ε(t)(â+â†)`, one control, and nobody noticed the + // card and the chat disagreeing about the same device. + const q = controlIdx(i, arch) + const u = (n: number) => `u_{${n}${q ? `,${q}` : ""}}(t)` + const A = ann(g[0].letter, i) + const Ad = cre(g[0].letter, i) + return wrap(sum, `${u(1)}\\,(${A} + ${Ad}) + i\\,${u(2)}\\,(${A} - ${Ad})`) + } + default: + return `${sum}\\hat H_{\\mathrm{c}}${i ? `^{(${i})}` : ""}(t)` } +} + +type Edge = { a: Site; b: Site; rest: Site[] } + +/** Raising / lowering operator for a site in whatever algebra it actually has. + * A coupling term must never assume its endpoints are bosonic: the ladder + * letter is empty for a spin, an atom, or an unmodeled role, and `\hat ^\dagger` + * is not LaTeX — it renders as an error box in the transcript. */ +const raise = (s: Site, i: string) => + s.kind === "ladder" ? cre(s.letter, i) : s.kind === "rydberg" ? `|r\\rangle\\langle 1|_{${i}}` : `\\hat\\sigma_+^{(${i})}` +const lower = (s: Site, i: string) => + s.kind === "ladder" ? ann(s.letter, i) : s.kind === "rydberg" ? `|1\\rangle\\langle r|_{${i}}` : `\\hat\\sigma_-^{(${i})}` + +/** One term for a set of edges that share a kind AND an endpoint shape. A lone + * edge names its actual sites; several become a sum over pairs — the old code + * printed one hardcoded `(1),(2)` term no matter how many edges existed. */ +function couplingLatex(kind: string, edges: Edge[]): string { + const many = edges.length > 1 + const e = edges[0] + const x = many ? "i" : String(e.a.idx) + const y = many ? "j" : String(e.b.idx) + const pair = many ? "\\sum_{\\langle ij\\rangle} " : "" + // For a role we have no model for — or a coupling kind we don't know — name + // the interaction. Inventing its algebra would be a guess, and DROPPING it + // (what an unknown kind used to do) left the card listing a coupling that the + // equation silently didn't have. + const generic = `${pair}\\hat H_{\\mathrm{int},${x}${y}}` + if (e.a.kind === "opaque" || e.b.kind === "opaque") return generic + switch (kind) { + case "vdW": + return `${pair}\\tfrac{C_6}{r_{${x}${y}}^6}\\,${num(x)} ${num(y)}` + case "ZZ": { + const spins = e.a.kind === "spin" && e.b.kind === "spin" + const op = spins ? `${pauli("z", x)}${pauli("z", y)}` : `${num(x)} ${num(y)}` + return `${pair}${many ? "J_{ij}" : "J"}\\,${op}` + } + case "cross-resonance": { + // Drive on the control at the target's frequency. Pauli form only when + // both ends really are two-level — otherwise it would put σ algebra on + // components whose drift is an anharmonic ladder, in the same equation. + const amp = many ? "\\Omega_{\\mathrm{CR},ij}" : "\\Omega_{\\mathrm{CR}}" + return e.a.kind === "spin" && e.b.kind === "spin" + ? `${pair}${amp}\\,${pauli("x", x)}${pauli("z", y)}` + : `${pair}${amp}\\,(${lower(e.a, x)} + ${raise(e.a, x)})\\,${num(y)}` + } + case "exchange": + return `${pair}${many ? "g_{ij}" : "g"}\\,(${raise(e.a, x)} ${lower(e.b, y)} + \\mathrm{h.c.})` + case "dispersive-chi": { + // `b` is the mode (oriented by the caller). Several qubits on ONE cavity + // is the common readout layout, and there the cavity factors out. + if (e.b.kind !== "ladder") return generic // a dispersive shift needs a mode + const m = String(e.b.idx) + const cav = `${cre(e.b.letter, m)} ${ann(e.b.letter, m)}` + if (!many) + return e.a.kind === "spin" + ? `\\tfrac{\\chi}{2}\\,${cav}\\,${pauli("z", x)}` + : `\\chi\\,${cav}\\,${num(x)}` + if (edges.every((z) => z.b.idx === e.b.idx)) return `${cav}\\,\\sum_i \\chi_i\\,${num("i")}` + return `\\sum_{\\langle ij\\rangle} \\chi_{ij}\\,${cre(e.b.letter, "j")} ${ann(e.b.letter, "j")}\\,${num("i")}` + } + case "mode-mediated": { + // The shared mode is `b`; every other member couples into it. + if (e.b.kind !== "ladder") return generic // nothing to mediate through + const ids = [...new Set(edges.flatMap((z) => [z.a, ...z.rest]).map((s) => s.idx))].sort((p, q) => p - q) + const qi = ids.length > 1 ? "i" : String(ids[0]) + const sum = ids.length > 1 ? `\\sum_{i \\in \\{${ids.join(",")}\\}} ` : "" + return `${sum}g\\,(${raise(e.a, qi)} ${ann(e.b.letter, String(e.b.idx))} + \\mathrm{h.c.})` + } + } + return generic +} + +/** Terms carry their own sign, so a drift like `-Δ n̂` must not be pasted on + * with " + " (that printed a literal "+ -Δ"). */ +function joinTerms(terms: string[]): string { + return terms.reduce((acc, t) => (!acc ? t : t.startsWith("-") ? `${acc} - ${t.slice(1).trimStart()}` : `${acc} + ${t}`), "") +} + +export type SystemHamiltonian = { + latex: string + /** recorded = the researcher confirmed these exact terms · inferred = the + * canonical form for the platform, which the card must SAY it is guessing. */ + source: "recorded" | "inferred" + /** Conventions the recorded terms assume (frame, units, basis). */ + notes?: string +} + +/** What the card should show. Recorded terms win outright: they are the model + * the researcher confirmed, and the fallback below can only ever be right for + * platforms someone hardcoded. undefined = say nothing, which is the honest + * answer for an off-template platform nobody has described yet. */ +export function systemHamiltonian(proj: SystemProjection): SystemHamiltonian | undefined { + const recorded = proj.hamiltonian + if (recorded && recorded.terms.length > 0) { + // Ordered drift → coupling → drive regardless of the order they were + // recorded in, so the equation reads the way a physicist writes one. + const rank = { drift: 0, coupling: 1, drive: 2 } as Record + const terms = [...recorded.terms].sort((a, b) => (rank[a.kind] ?? 0) - (rank[b.kind] ?? 0)) + return { + latex: "\\hat H/\\hbar = " + joinTerms(terms.map((t) => t.latex.trim())), + source: "recorded", + ...(recorded.notes ? { notes: recorded.notes } : {}), + } + } + const latex = systemHamiltonianLatex(proj) + return latex ? { latex, source: "inferred" } : undefined +} + +/** The canonical form for a platform we model, composed from the structure: + * one drift and one drive per component GROUP (summed over the group's sites) + * plus one term per set of like edges. This is a FALLBACK — it is a guess about + * physics nobody stated, and every caller must present it as one. undefined + * when there is nothing to say. Never throws. */ +export function systemHamiltonianLatex(proj: SystemProjection): string | undefined { + const total = proj.components.length + if (total === 0) return undefined + + const sites: Site[] = proj.components.map((c, k) => ({ + idx: k + 1, + role: c.role, + letter: "", + ...classify(c, proj.platform), + })) + const byKey = new Map() + for (const s of sites) (byKey.get(s.key) ?? byKey.set(s.key, []).get(s.key)!).push(s) + const groups = [...byKey.values()] + let letters = 0 + for (const g of groups) + if (g[0].kind === "ladder") { + const L = LADDER_LETTERS[letters++ % LADDER_LETTERS.length] + for (const s of g) s.letter = L + } + + const byId = new Map(proj.components.map((c, k) => [c.id, sites[k]])) + const edges = new Map() for (const cp of proj.couplings) { - const t = COUPLING_TERM[cp.kind] - if (t && !seen.has(cp.kind)) { seen.add(cp.kind); terms.push(t) } + const members = cp.between.map((id) => byId.get(id)).filter((s): s is Site => s !== undefined) + if (members.length < 2) continue + // dispersive / mode-mediated are oriented so the shared mode is always `b`. + const mode = members.find((s) => MODE_ROLES.has(s.role)) + const others = mode ? members.filter((s) => s !== mode) : members + const edge: Edge = + mode && (cp.kind === "dispersive-chi" || cp.kind === "mode-mediated") + ? { a: others[0], b: mode, rest: others.slice(1) } + : { a: members[0], b: members[1], rest: members.slice(2) } + const key = `${cp.kind}|${edge.a.kind}|${edge.b.kind}` + ;(edges.get(key) ?? edges.set(key, []).get(key)!).push(edge) } - if (terms.length === 0) return undefined - for (const c of proj.components) { - const t = driveTerm(c.role) - if (!seen.has(t)) { - seen.add(t) - terms.push(t) + + // Nothing to say: every component is a model we don't have, so the only + // "Hamiltonian" we could compose is `Ĥ_drift + Ĥ_c(t)` — true of literally + // every control problem, and it would occupy the slot where the real model + // belongs. Silence here is what makes the agent record one. + if (groups.every((g) => g[0].kind === "opaque")) return undefined + + const terms = groups.map((g) => driftLatex(g, total, proj.driveArch)) + for (const [key, group] of edges) terms.push(couplingLatex(key.split("|")[0], group)) + terms.push(...groups.map((g) => driveLatex(g, total, proj.driveArch))) + return "\\hat H/\\hbar = " + joinTerms(terms) +} + +// --- physics rows ------------------------------------------------------------- +// The card must never invent a slot the model doesn't have. It used to emit a +// fixed transmon spec (frequency · anharmonicity · drive bound · decay) for +// EVERY component, so a Rydberg atom was asked for its anharmonicity while the +// Hamiltonian directly above it — which IS role-aware — showed a 3-level ladder +// with no such term. The row list is derived from the role here, next to the +// Hamiltonian tables, because split sources are why the two halves disagreed. + +/** Unitless params get a math symbol; unit-suffixed keys (chi_kHz, K_c_Hz, + * N_fock) keep their name so the unit isn't lost. */ +export const PARAM_SYMBOL: Record = { + omega: "ω", + delta: "δ", + chi: "χ", + strength: "J", + drive_max: "|u|", + du_bound: "|u̇|", + Delta: "Δ", + Omega: "Ω", + kappa: "κ", +} + +export type PhysicsRow = { + label: string + sym?: string + /** Formatted number (+ unit when the recorded key spells one), or "not set". */ + value: string + /** recorded = on file · missing = this role HAS this param, nobody has said + * what it is yet. Params the role doesn't have are absent, not "missing". */ + state: "recorded" | "missing" +} + +type ParamSpec = { + /** Accepted keys, canonical first. A `_GHz`-style suffix is matched + * automatically, so list only bare forms. Case-sensitive: an atom's `Delta` + * (detuning) must not silently absorb a transmon's `delta` (anharmonicity). */ + keys: string[] + label: string + sym: string + /** Rendered before the number ("≤ " for a bound). */ + prefix?: string +} + +/** Units are never assumed: transmon params are GHz, the Rydberg templates work + * in rad/μs, and a bare `omega` says which only by convention. So a unit is + * shown only when the recorded key spells it out. */ +const UNIT_SUFFIX = /_(Hz|kHz|MHz|GHz|THz|s|ms|us|µs|ns|rad|deg)$/ + +const MODE_PARAMS: ParamSpec[] = [ + { keys: ["omega_c", "omega", "frequency"], label: "frequency", sym: "ω" }, + { keys: ["K", "K_c", "kerr"], label: "kerr", sym: "K" }, + { keys: ["kappa"], label: "linewidth", sym: "κ" }, +] + +/** Params each MODEL has, in card order — keyed by the same `classify` result + * that picks the Hamiltonian terms, so the equation and the table are always + * describing the same physics. A model we don't have (`opaque:*`) expects + * NOTHING and shows only what was recorded: that is the honest floor for a + * platform outside the templated set. */ +const MODEL_PARAMS: Record = { + spin: [ + // A two-level system has a splitting and a drive bound — and no third level + // to be anharmonic against. + { keys: ["omega", "frequency", "f01"], label: "frequency", sym: "ω" }, + { keys: ["drive_max"], label: "drive bound", sym: "|u|", prefix: "≤ " }, + ], + qubit: [ + { keys: ["omega", "frequency", "f01"], label: "frequency", sym: "ω" }, + { keys: ["delta", "alpha", "anharmonicity"], label: "anharmonicity", sym: "δ" }, + { keys: ["drive_max"], label: "drive bound", sym: "|u|", prefix: "≤ " }, + ], + rydberg: [ + // Lowercase `delta_max`/`omega_max` are what the Rydberg template and the + // interview actually record (Δ_max, Ω_max). They are safe to claim here and + // ONLY here: the spec is keyed by model, so a transmon's δ can never reach + // this row. A bare `delta` on an atom stays deliberately unclaimed — it is + // far more likely a misfiled anharmonicity than a detuning. + { keys: ["Delta", "Delta_max", "delta_max", "detuning"], label: "detuning", sym: "Δ", prefix: "≤ " }, + { keys: ["Omega", "Omega_max", "omega_max", "rabi_max", "rabi", "drive_max"], label: "rabi drive", sym: "Ω", prefix: "≤ " }, + ], + mode: MODE_PARAMS, + "mode-kerr": MODE_PARAMS, +} + +/** First recorded key matching any alias. A zero keeps the old "0 means unset" + * reading — an all-zeros seed shouldn't look like a specified device. */ +function matchParam(params: Record, keys: string[]) { + for (const key of keys) + for (const [k, v] of Object.entries(params)) { + if (typeof v !== "number" || v === 0) continue + if (k === key || k.replace(UNIT_SUFFIX, "") === key) { + const unit = k.match(UNIT_SUFFIX)?.[1] + return { key: k, text: unit ? `${formatSci(v)} ${unit}` : formatSci(v) } + } } + return undefined +} + +/** Rows for ONE component: levels, the params its MODEL actually has (unanswered + * ones read "not set" — that list doubles as the interview's to-do), then + * anything else recorded, so nothing on file is dropped. `platform` is what + * separates a transmon from a qubit we have no model for. Never throws. */ +export function componentPhysicsRows(c: ComponentRow, platform?: string): PhysicsRow[] { + const spec = MODEL_PARAMS[classify(c, platform).key] ?? [] + const claimed = new Set() + const rows: PhysicsRow[] = + c.levels === undefined + ? [{ label: "levels", value: "not set", state: "missing" }] + : [{ label: "levels", value: String(c.levels), state: "recorded" }] + for (const s of spec) { + const hit = matchParam(c.params, s.keys) + if (hit) claimed.add(hit.key) + rows.push({ + label: s.label, + sym: s.sym, + value: hit ? `${s.prefix ?? ""}${hit.text}` : "not set", + state: hit ? "recorded" : "missing", + }) + } + // Decay belongs to the environment rather than to any one role's Hamiltonian, + // so it is asked for every role we model — and never invented for one we don't. + if (spec.length > 0) { + const t1 = matchParam(c.params, ["T1", "t1"]) + const t2 = matchParam(c.params, ["T2", "t2"]) + if (t1) claimed.add(t1.key) + if (t2) claimed.add(t2.key) + const decay = [t1 ? `T₁ ${t1.text}` : undefined, t2 ? `T₂ ${t2.text}` : undefined].filter(Boolean).join(" · ") + rows.push({ label: "decay", sym: "T₁/T₂", value: decay || "not set", state: decay ? "recorded" : "missing" }) + } + for (const [k, v] of Object.entries(c.params)) { + if (claimed.has(k) || typeof v !== "number" || v === 0) continue + const unit = k.match(UNIT_SUFFIX)?.[1] + rows.push({ + label: k.replace(UNIT_SUFFIX, ""), + ...(PARAM_SYMBOL[k] ? { sym: PARAM_SYMBOL[k] } : {}), + value: unit ? `${formatSci(v)} ${unit}` : formatSci(v), + state: "recorded", + }) } - return "\\hat H/\\hbar = " + terms.join(" + ") + return rows } export function systemTableModel(proj: SystemProjection): TableModel { diff --git a/packages/ui/src/amicode/system-view.tsx b/packages/ui/src/amicode/system-view.tsx index 36867abcf0..c82749edc5 100644 --- a/packages/ui/src/amicode/system-view.tsx +++ b/packages/ui/src/amicode/system-view.tsx @@ -2,26 +2,17 @@ import { For, Show, createMemo } from "solid-js" import katex from "katex" import { systemProjection } from "./problem" import { formatSci } from "./facets" -import { systemTableModel, systemHamiltonianLatex } from "./system-render" +import { systemTableModel, systemHamiltonian, systemCountLabel, componentPhysicsRows, PARAM_SYMBOL } from "./system-render" // AMICODE System hero (spec §6.1) — PHYSICS-FORWARD (Kate 2026-07-23): lead with -// the Hamiltonian, then a labeled physics spec (frequency, anharmonicity, drive -// bound, decay, + any recorded params). Missing canonical params read "not set" -// so an under-specified model is visible at a glance rather than silently thin. -// Multi-component systems show the component/coupling table (it scales; the -// node/edge schematic was removed — Kate 2026-07-24). Thin — logic in the pure -// systemProjection / system-render models. +// the Hamiltonian, then the physics spec for the component's ROLE. Params the +// role has but nobody has stated read "not set", so an under-specified model is +// visible at a glance; params the role does NOT have are absent entirely (the +// spec used to be a fixed transmon list, which asked Rydberg atoms for their +// anharmonicity). Multi-component systems show the component/coupling table (it +// scales; the node/edge schematic was removed — Kate 2026-07-24). Thin — logic +// in the pure systemProjection / system-render models. -// Unitless params get a math symbol; unit-suffixed keys (chi_kHz, K_c_Hz, N_fock) -// keep their name so the unit isn't lost. -const PARAM_SYMBOL: Record = { - omega: "ω", - delta: "δ", - chi: "χ", - strength: "J", - drive_max: "|u|", - du_bound: "|u̇|", -} // Drop unset (zero) params — "ω 0 · δ 0" is noise — and format the rest with // the π-aware formatter so a drive bound reads "|u| 40π", not "|u| 125.66…". const paramsText = (params: Record): string => @@ -30,64 +21,60 @@ const paramsText = (params: Record): string => .map(([k, v]) => `${PARAM_SYMBOL[k] ?? k} ${formatSci(v)}`) .join(" · ") -// Canonical single-qubit physics keys, consumed by the physics spec; anything -// left over is appended as its own row so nothing recorded is lost. -const CANON_KEYS = new Set(["omega", "frequency", "f01", "delta", "alpha", "anharmonicity", "drive_max", "T1", "t1", "T2", "t2"]) -type PhysRow = { label: string; sym?: string; value: string; set: boolean } - export function SystemComposite(props: { entity: Record }) { const proj = createMemo(() => systemProjection(props.entity)) const table = createMemo(() => systemTableModel(proj())) const hamiltonian = createMemo(() => { - const latex = systemHamiltonianLatex(proj()) - return latex ? katex.renderToString(latex, { throwOnError: false }) : undefined + const h = systemHamiltonian(proj()) + return h ? { ...h, html: katex.renderToString(h.latex, { throwOnError: false }) } : undefined }) // Single qubit/atom, no couplings → the physics spec. Else the structural view. const single = createMemo(() => proj().components.length === 1 && proj().couplings.length === 0) - const physics = createMemo(() => { - const c = proj().components[0] - if (!c) return [] - const par = c.params - const num = (keys: string[]): number | undefined => { - for (const k of keys) if (typeof par[k] === "number" && par[k] !== 0) return par[k] - return undefined - } - const rows: PhysRow[] = [] - if (c.levels !== undefined) rows.push({ label: "levels", value: String(c.levels), set: true }) - const f = num(["omega", "frequency", "f01"]) - rows.push({ label: "frequency", sym: "ω", value: f !== undefined ? formatSci(f) : "not set", set: f !== undefined }) - const a = num(["delta", "alpha", "anharmonicity"]) - rows.push({ label: "anharmonicity", sym: "δ", value: a !== undefined ? formatSci(a) : "not set", set: a !== undefined }) - const d = num(["drive_max"]) - rows.push({ label: "drive bound", sym: "|u|", value: d !== undefined ? `≤ ${formatSci(d)}` : "not set", set: d !== undefined }) - const t1 = num(["T1", "t1"]) - const t2 = num(["T2", "t2"]) - const decay = [t1 !== undefined ? `T₁ ${formatSci(t1)}` : null, t2 !== undefined ? `T₂ ${formatSci(t2)}` : null] - .filter(Boolean) - .join(" · ") - rows.push({ label: "decay", sym: "T₁/T₂", value: decay || "not set", set: t1 !== undefined || t2 !== undefined }) - for (const [k, v] of Object.entries(par)) - if (typeof v === "number" && v !== 0 && !CANON_KEYS.has(k)) - rows.push({ label: PARAM_SYMBOL[k] ?? k, sym: PARAM_SYMBOL[k], value: formatSci(v), set: true }) - return rows + const physics = createMemo(() => { + const c = table().components[0] + return c ? componentPhysicsRows(c, proj().platform) : [] }) return (
- +
{(p) => {p()}} + {/* N is a claim, not a layout detail — say it so an unanswered "how + many atoms?" can't read as a confident "one". */} + {(n) => {n()}} {(d) => {d()} drive}
- - {(html) => ( + {/* A recorded model is shown bare — it is what the researcher confirmed. + An INFERRED one is a guess about physics nobody stated, so it says so + and invites the correction; that correction is what gets recorded. */} + 0}> +
+ Hamiltonian not recorded +
+
+ No model for this platform yet — tell Amico the terms and it'll record them here. +
+
+ } + > + {(h) => ( <> -
Hamiltonian
-
-
+
+ Hamiltonian + + inferred · confirm or correct + +
+
+
+ {(n) =>
{n()}
}
)} @@ -129,7 +116,7 @@ export function SystemComposite(props: { entity: Record }) {
{r.label}
-
+
{r.value} {(s) => {s()}}
From ae4a31c925ad8b5509f4d4665581f01d918b57b3 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Mon, 27 Jul 2026 17:54:31 -0400 Subject: [PATCH 10/13] feat(amicode): approval state machine + ledger transport (S3, partial) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec-20260727-164748 §9.5. Two of S3's three modules, mirroring the repo's existing ask.ts / ask-bridge.ts / ask-card.tsx split. The .tsx component is NOT here — see the gap note below. approval.ts (pure): the card's state machine over (request, warrants, now, hasBridge) → unavailable | pending | granted | expired. Two inherited properties and one deliberate divergence, all from §9.5: - Inherits the read-only interlock: no transport → `unavailable`, never actionable. Absence of a host is the safe state, so it is the default. - Inherits state-derived-from-the-durable-log: the state is a function of the approval rows that exist, never a stored UI flag, so it is replay-correct the way ask-card's hasUserReplyAfter is. - Does NOT judge whether granted bounds COVER the request. That verdict is the gate's (§5.1 rule 2); a card that second-guessed it would either contradict the gate or imply an authority it lacks. The card reports what was granted, the gate decides what that permits. Fail-closed twice over: an expiry exactly at `now` reads as expired, and an UNPARSEABLE expiry reads as expired rather than live — same direction the gate applies to an unresolved estimate (§4.4). boundsText renders only declared bounds, since an absent bound is not "unlimited". approval-bridge.ts: a SEPARATE bridge from ask-bridge, and that separation is the point. ask-bridge's `send` submits text as the user's next chat message; routing an approval through it would leave the ledger's only provenance reading "the agent says the user approved". `approve` reaches `amico ledger approve` (CLI-3) instead — the ledger's single writer. Disposer is registration-aware so a late unmount cannot wipe a newer bridge. packages/ui 352 pass (336 + 16 new), tsgo --noEmit clean. GAP FOUND (recorded as G-9 in the spec): nothing emits an approval REQUEST. There is no amicode_* tool for the agent to ask for one, so the card has no trigger. Writing the component before that surface is decided would be inventing a tool contract silently. Co-Authored-By: Claude Opus 5 --- .../ui/src/amicode/approval-bridge.test.ts | 57 +++++++++ packages/ui/src/amicode/approval-bridge.ts | 58 +++++++++ packages/ui/src/amicode/approval.test.ts | 88 ++++++++++++++ packages/ui/src/amicode/approval.ts | 112 ++++++++++++++++++ 4 files changed, 315 insertions(+) create mode 100644 packages/ui/src/amicode/approval-bridge.test.ts create mode 100644 packages/ui/src/amicode/approval-bridge.ts create mode 100644 packages/ui/src/amicode/approval.test.ts create mode 100644 packages/ui/src/amicode/approval.ts diff --git a/packages/ui/src/amicode/approval-bridge.test.ts b/packages/ui/src/amicode/approval-bridge.test.ts new file mode 100644 index 0000000000..0b983a6b36 --- /dev/null +++ b/packages/ui/src/amicode/approval-bridge.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { + amicodeWarrants, + hasApprovalBridge, + registerAmicodeApprovalBridge, + submitApproval, + type ApprovalBridge, +} from "./approval-bridge" +import type { ApprovalRequest, Warrant } from "./approval" + +const REQ: ApprovalRequest = { plan_hash: "9f2c", bounds: { max_solves: 8 } } +const WARRANT: Warrant = { + plan_hash: "9f2c", + bounds: { max_solves: 8 }, + expires_at: "2026-07-27T21:00:00Z", + issued_by: "user:ui", +} + +const stub = (): { bridge: ApprovalBridge; sent: ApprovalRequest[] } => { + const sent: ApprovalRequest[] = [] + return { bridge: { approve: (r) => sent.push(r), warrants: () => [WARRANT] }, sent } +} + +describe("approval bridge", () => { + test("no bridge → not actionable, no warrants, and submitting is a silent no-op", () => { + expect(hasApprovalBridge()).toBe(false) + expect(amicodeWarrants()).toEqual([]) + expect(() => submitApproval(REQ)).not.toThrow() // must not appear to have approved + }) + + test("registering exposes the host's warrants and routes approvals to it", () => { + const { bridge, sent } = stub() + const dispose = registerAmicodeApprovalBridge(bridge) + try { + expect(hasApprovalBridge()).toBe(true) + expect(amicodeWarrants()).toEqual([WARRANT]) + submitApproval(REQ) + expect(sent).toEqual([REQ]) + } finally { + dispose() + } + expect(hasApprovalBridge()).toBe(false) + }) + + test("a stale disposer cannot wipe a newer registration", () => { + const first = stub() + const second = stub() + const disposeFirst = registerAmicodeApprovalBridge(first.bridge) + const disposeSecond = registerAmicodeApprovalBridge(second.bridge) + disposeFirst() // late unmount of the older card + expect(hasApprovalBridge()).toBe(true) + submitApproval(REQ) + expect(second.sent).toEqual([REQ]) + expect(first.sent).toEqual([]) + disposeSecond() + }) +}) diff --git a/packages/ui/src/amicode/approval-bridge.ts b/packages/ui/src/amicode/approval-bridge.ts new file mode 100644 index 0000000000..11a0bc2735 --- /dev/null +++ b/packages/ui/src/amicode/approval-bridge.ts @@ -0,0 +1,58 @@ +import { createSignal } from "solid-js" +import type { ApprovalRequest, Warrant } from "./approval" + +// AMICODE: module-level bridge between the approval card (rendered deep inside the +// message-part dispatch, where no app context is reachable) and the host that can +// actually reach the ledger. Same shape and lifetime as ./ask-bridge.ts. +// +// WHY A SEPARATE BRIDGE FROM ask-bridge (spec-20260727-164748 §9.5). The ask bridge's +// `send` submits text as the user's next CHAT MESSAGE. Routing an approval through it +// would mean the agent reads the message and then writes the ledger row itself, so the +// only durable provenance for the approval would read "the agent says the user +// approved". `approve` instead reaches `amico ledger approve` on the host, which is the +// ledger's single writer (#212). The distinction is the point; do not "simplify" these +// two bridges into one. +// +// The read-only interlock is inherited from the same discipline: when no bridge is +// registered — share page, headless embed, any surface with no host — the card renders +// non-actionable rather than optimistically enabled. Absence of transport is the +// safe state, so it is the default. + +export interface ApprovalBridge { + /** Mint a warrant. Goes to the ledger via the host, NEVER through the chat. */ + approve: (request: ApprovalRequest) => void + /** Warrants the host currently knows about — the card's state is derived from + * these rather than stored, so it stays correct across replay and reload. */ + warrants: () => readonly Warrant[] +} + +const [current, setCurrent] = createSignal(undefined) + +export const amicodeApprovalBridge = current + +/** Register the host transport. Returns a disposer that only clears the signal if it + * is still the one it installed, so an unmount cannot wipe a newer registration. */ +export function registerAmicodeApprovalBridge(bridge: ApprovalBridge): () => void { + setCurrent(bridge) + return () => { + if (current() === bridge) setCurrent(undefined) + } +} + +/** Warrants known to the host, or an empty list when there is no transport. Empty + * means "no warrant", which the state machine reads as `pending` on a live surface + * and `unavailable` on a read-only one — never as "approved". */ +export function amicodeWarrants(): readonly Warrant[] { + return current()?.warrants() ?? [] +} + +/** True when a host transport is present. The card's actionability gate. */ +export function hasApprovalBridge(): boolean { + return current() !== undefined +} + +/** Send an approval. A no-op without a bridge — a read-only surface must never + * appear to have approved something it could not record. */ +export function submitApproval(request: ApprovalRequest): void { + current()?.approve(request) +} diff --git a/packages/ui/src/amicode/approval.test.ts b/packages/ui/src/amicode/approval.test.ts new file mode 100644 index 0000000000..b5d3d4db46 --- /dev/null +++ b/packages/ui/src/amicode/approval.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test" +import { approvalState, boundsText, isActionable, warrantFor, type Warrant } from "./approval" + +const NOW = Date.parse("2026-07-27T20:00:00Z") +const iso = (offsetMin: number) => new Date(NOW + offsetMin * 60_000).toISOString() + +const w = (over: Partial = {}): Warrant => ({ + plan_hash: "9f2c", + bounds: { max_solves: 8, tier: "free" }, + expires_at: iso(30), + issued_by: "user:ui", + ...over, +}) + +const req = { plan_hash: "9f2c", bounds: { max_solves: 8, tier: "free" } } + +describe("warrantFor", () => { + test("ignores warrants for other plans", () => { + expect(warrantFor("9f2c", [w({ plan_hash: "other" })], NOW)).toBeUndefined() + }) + test("prefers the live warrant over a lapsed one", () => { + const live = w({ expires_at: iso(10) }) + expect(warrantFor("9f2c", [w({ expires_at: iso(-60) }), live], NOW)).toBe(live) + }) + test("among live warrants, takes the one expiring latest", () => { + const later = w({ expires_at: iso(90) }) + expect(warrantFor("9f2c", [w({ expires_at: iso(10) }), later], NOW)).toBe(later) + }) + test("falls back to the most recent lapsed warrant when none is live", () => { + const recent = w({ expires_at: iso(-5) }) + expect(warrantFor("9f2c", [w({ expires_at: iso(-600) }), recent], NOW)).toBe(recent) + }) +}) + +describe("approvalState", () => { + // The read-only interlock, inherited from ask-bridge by construction. + test("no bridge → unavailable, even when a live warrant exists", () => { + expect(approvalState(req, [w()], NOW, false)).toEqual({ kind: "unavailable" }) + expect(isActionable(approvalState(req, [w()], NOW, false))).toBe(false) + }) + + test("no warrant → pending, and actionable", () => { + const s = approvalState(req, [], NOW, true) + expect(s).toEqual({ kind: "pending" }) + expect(isActionable(s)).toBe(true) + }) + + test("live warrant → granted, and NOT actionable", () => { + const s = approvalState(req, [w()], NOW, true) + expect(s.kind).toBe("granted") + expect(isActionable(s)).toBe(false) + }) + + test("lapsed warrant → expired, and actionable again", () => { + const s = approvalState(req, [w({ expires_at: iso(-1) })], NOW, true) + expect(s.kind).toBe("expired") + expect(isActionable(s)).toBe(true) + }) + + test("expiry exactly at now counts as expired — a warrant must not outlive its instant", () => { + expect(approvalState(req, [w({ expires_at: iso(0) })], NOW, true).kind).toBe("expired") + }) + + // Fail-closed, matching the gate's treatment of an unresolved estimate (§4.4). + test("an unparseable expiry reads as expired, never as live", () => { + expect(approvalState(req, [w({ expires_at: "not-a-date" })], NOW, true).kind).toBe("expired") + }) + + // The card must not pre-empt the gate's §5.1 rule 2 verdict. + test("a NARROWER granted warrant is still 'granted' — coverage is the gate's call", () => { + const narrow = w({ bounds: { max_solves: 2 } }) + const s = approvalState({ plan_hash: "9f2c", bounds: { max_solves: 8 } }, [narrow], NOW, true) + expect(s.kind).toBe("granted") + // and it reports what was ACTUALLY granted, not what was asked + expect(s.kind === "granted" && s.warrant.bounds).toEqual({ max_solves: 2 }) + }) +}) + +describe("boundsText", () => { + test("renders only declared bounds", () => { + expect(boundsText({ max_solves: 8, tier: "free" })).toBe("8 solves · tier free") + expect(boundsText({ max_solves: 1 })).toBe("1 solve") + expect(boundsText({ max_duration_s: 1800, device: "ro" })).toBe("30 min · device ro") + }) + test("empty bounds say so rather than implying unlimited", () => { + expect(boundsText({})).toBe("no bounds declared") + }) +}) diff --git a/packages/ui/src/amicode/approval.ts b/packages/ui/src/amicode/approval.ts new file mode 100644 index 0000000000..d18e01723f --- /dev/null +++ b/packages/ui/src/amicode/approval.ts @@ -0,0 +1,112 @@ +// AMICODE: approval-card logic (spec-20260727-164748 §9.5). Pure — no transport, +// no rendering — mirroring ./ask.ts beside ./ask-card.tsx and ./ask-bridge.ts. +// +// A capability warrant is what lets a gated launch through amico-run's --spec gate. +// The card is where a human mints one. Two properties are inherited from the ask +// card deliberately, and one is deliberately NOT: +// +// INHERITED — the read-only interlock. ask-bridge renders buttons disabled when +// no bridge is registered ("questions never submit from read-only surfaces"), so +// an approval is non-actionable on a share page or headless host by construction +// rather than by a check someone has to remember. +// +// INHERITED — state derived from the durable log, never stored in the UI. The ask +// card computes answered-ness from message order (hasUserReplyAfter) instead of +// holding a flag, which makes it replay-correct. Here the durable log is the +// ledger: the card's state is a function of the approval records that exist. +// +// NOT INHERITED — the transport. The ask card submits the chosen option as the +// user's next CHAT MESSAGE. An approval delivered that way would be interpreted +// by the agent, which would then write the ledger row, making the provenance read +// "the agent says the user approved". Approvals go straight to the ledger via +// ./approval-bridge.ts (→ `amico ledger approve`). + +/** What a warrant may authorise — the fleet spec §2.1 vocabulary for `device`. */ +export interface WarrantBounds { + max_solves?: number; + tier?: string; + max_duration_s?: number; + device?: "none" | "ro" | "rw"; +} + +/** The agent's ask: "approve this plan, with these bounds, for this reason." */ +export interface ApprovalRequest { + plan_hash: string; + bounds: WarrantBounds; + rationale?: string; +} + +/** An `approval` ledger row, as surfaced to the UI. */ +export interface Warrant { + plan_hash: string; + bounds: WarrantBounds; + expires_at: string; + issued_by: string; +} + +export type ApprovalState = + /** No transport registered — share page, headless embed. Never actionable. */ + | { kind: "unavailable" } + /** No live warrant for this plan. The one actionable state. */ + | { kind: "pending" } + /** A live warrant exists. Locked; `warrant` carries what was ACTUALLY granted, + * which may be narrower than what was requested. */ + | { kind: "granted"; warrant: Warrant } + /** A warrant existed and lapsed. Actionable again — re-approving is a new bet. */ + | { kind: "expired"; warrant: Warrant }; + +function expiryMs(w: Warrant): number { + const t = Date.parse(w.expires_at); + // An unparseable expiry is treated as ALREADY EXPIRED: a warrant whose lifetime + // cannot be established must not read as live. Same fail-closed direction the + // gate applies to an unresolved estimate (spec §4.4). + return Number.isNaN(t) ? -Infinity : t; +} + +/** The newest live warrant for `planHash`, else the newest lapsed one, else none. */ +export function warrantFor(planHash: string, warrants: readonly Warrant[], now: number): Warrant | undefined { + let live: Warrant | undefined; + let lapsed: Warrant | undefined; + for (const w of warrants) { + if (w.plan_hash !== planHash) continue; + const exp = expiryMs(w); + if (exp > now) { + if (!live || exp > expiryMs(live)) live = w; + } else if (!lapsed || exp > expiryMs(lapsed)) lapsed = w; + } + return live ?? lapsed; +} + +/** Derive the card's state. Deliberately does NOT check whether the warrant's + * bounds COVER the request — that verdict belongs to the gate (spec §5.1 rule 2), + * and a card that second-guessed it would either contradict the gate or imply an + * authority it does not have. The card reports what was granted; the gate decides + * what that permits. */ +export function approvalState( + request: ApprovalRequest, + warrants: readonly Warrant[], + now: number, + hasBridge: boolean, +): ApprovalState { + if (!hasBridge) return { kind: "unavailable" }; + const w = warrantFor(request.plan_hash, warrants, now); + if (!w) return { kind: "pending" }; + return expiryMs(w) > now ? { kind: "granted", warrant: w } : { kind: "expired", warrant: w }; +} + +/** True when the approve control may be pressed. */ +export function isActionable(state: ApprovalState): boolean { + return state.kind === "pending" || state.kind === "expired"; +} + +/** One-line summary of bounds for the card and the rail. Renders only DECLARED + * bounds — an absent bound is not "unlimited" (the gate refuses a launch needing a + * bound the warrant omits), so inventing a word for absence would mislead. */ +export function boundsText(bounds: WarrantBounds): string { + const parts: string[] = []; + if (bounds.max_solves !== undefined) parts.push(`${bounds.max_solves} solve${bounds.max_solves === 1 ? "" : "s"}`); + if (bounds.tier !== undefined) parts.push(`tier ${bounds.tier}`); + if (bounds.max_duration_s !== undefined) parts.push(`${Math.round(bounds.max_duration_s / 60)} min`); + if (bounds.device !== undefined) parts.push(`device ${bounds.device}`); + return parts.length ? parts.join(" · ") : "no bounds declared"; +} From 76157d1fa5e7be7fd6007a525172b947188329fb Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Mon, 27 Jul 2026 22:24:14 -0400 Subject: [PATCH 11/13] feat(amicode): the in-chat approval card, end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec-20260727-164748 §9.5. Completes the UI half: a gate refusal can now become a button instead of prose telling the researcher to go run a CLI verb. The path: amicode_request_approval (amicode-side plugin tool) renders AmicodeApprovalCard from the tool INPUT — the ask card's pattern, not a sentinel, because a request is an ASK rather than a record of something that happened. Pressing Approve goes through approval-bridge → POST /amicode/approve → shells `amico ledger approve`. The card then REFETCHES rather than optimistically flipping, so "granted" is always something the ledger said. Three deliberate differences from the ask card it otherwise mirrors: 1. Transport. ask-bridge submits as the user's next CHAT MESSAGE, which would mean the agent reads it and writes the ledger row — leaving the only provenance as "the agent says the user approved". Hence a sibling bridge, and `issued_by: user:ui` so an audit can tell a press from the CLI. 2. State source. Derived from the LEDGER (is there a live warrant for this plan_hash?) rather than from message order. Equally replay-correct, survives reload, and cannot disagree with what the gate will see. 3. No coverage verdict. The card never claims the granted bounds are enough — that is the gate's call (§5.1 rule 2). It shows requested, and once granted, what was ACTUALLY granted, which may be narrower. A card reading "approved" over a warrant the gate will still refuse is worse than no card. Server side keeps the single-writer rule (#212): reading the ledger here is a plain file read, but writing shells the CLI. An append from this process would break O_APPEND atomicity quietly and only under concurrency. Route and card are both tolerant per row — a malformed warrant is dropped, never defaulted into one. The bridge registers only when BOTH halves are present; a card that could approve but not read back its own warrant would show "pending" forever after a press. Omitting them leaves the card non-actionable, which is the correct behaviour for a share page or headless host. ui 378 pass · app 442 pass · opencode server 550 pass / 15 fail, against 538 / 16 on the clean base — the failures are the documented pre-existing widget-src env set, verified by stashing. tsgo clean in ui and app. Co-Authored-By: Claude Opus 5 --- .../src/pages/session/message-timeline.tsx | 39 ++++++ .../opencode/src/server/amicode/warrants.ts | 112 +++++++++++++++++ .../server/routes/instance/httpapi/server.ts | 22 ++++ .../test/server/amicode-warrants.test.ts | 116 ++++++++++++++++++ packages/ui/src/amicode/amicode.css | 71 +++++++++++ packages/ui/src/amicode/approval-card.tsx | 101 +++++++++++++++ packages/ui/src/amicode/approval.test.ts | 56 ++++++++- packages/ui/src/amicode/approval.ts | 31 ++++- packages/ui/src/amicode/card.tsx | 7 ++ packages/ui/src/amicode/entity-rail.tsx | 18 +++ 10 files changed, 569 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/src/server/amicode/warrants.ts create mode 100644 packages/opencode/test/server/amicode-warrants.test.ts create mode 100644 packages/ui/src/amicode/approval-card.tsx diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 85052483f6..ec7988d930 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -329,6 +329,29 @@ export function MessageTimeline(props: { const raw = problemRaw.latest return raw === undefined ? undefined : parseProblemResponse(raw) }) + // Capability warrants (spec-20260727-164748 §9.5). Fetched unconditionally rather + // than gated on a dialog: the approval card lives IN the transcript, so it needs + // its state on first paint. Cheap — one ledger read, and the card's state derives + // from these rows rather than from optimistic local state, so a press round-trips + // through the ledger before it reads as granted. + const [warrantsRaw, { refetch: refetchWarrants }] = createResource(() => + amicodeGet(server.current, "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/amicode/warrants").catch(() => undefined), + ) + const warrants = createMemo(() => { + const raw = warrantsRaw.latest as { ok?: boolean; warrants?: unknown } | undefined + if (!raw?.ok || !Array.isArray(raw.warrants)) return [] + // Tolerant per row: a malformed row is dropped, never defaulted into a warrant. + return raw.warrants.flatMap((r) => { + const row = r as Record + if (typeof row.plan_hash !== "string" || typeof row.expires_at !== "string") return [] + return [{ + plan_hash: row.plan_hash, + bounds: (typeof row.bounds === "object" && row.bounds !== null ? row.bounds : {}) as Record, + expires_at: row.expires_at, + issued_by: typeof row.issued_by === "string" ? row.issued_by : "unknown", + }] + }) + }) // Run verdict data for the entity view: /amicode/run-status is fetched // whenever a dialog opens (cheap, server-cached ~1s); only the Run dialog // reads it. Same endpoint the rail's run chip polls. @@ -1754,6 +1777,22 @@ export function MessageTimeline(props: { if (!id) return void sdk.client.session.promptAsync({ sessionID: id, parts: [{ type: "text", text }] }) }} + // Warrant transport (spec-20260727-164748 §9.5). NOT routed through + // onAsk on purpose: an approval delivered as a chat message would be + // read by the agent, which would then write the ledger row, leaving + // the only provenance as "the agent says the user approved". + warrants={() => warrants() ?? []} + onApprove={(request) => { + void amicodePost(server.current, "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/amicode/approve", { + plan_hash: request.plan_hash, + bounds: request.bounds, + }) + // Refetch so the card flips to "granted" from the LEDGER rather + // than from optimistic local state — same discipline as deriving + // state from the durable log in the first place. + .then(() => void refetchWarrants()) + .catch(() => void refetchWarrants()) + }} /> {/* amicode: the context tree, folded into the header (ADR 0003) — closes the sticky block beneath the title row + chip rail */} diff --git a/packages/opencode/src/server/amicode/warrants.ts b/packages/opencode/src/server/amicode/warrants.ts new file mode 100644 index 0000000000..8b3b1d6b48 --- /dev/null +++ b/packages/opencode/src/server/amicode/warrants.ts @@ -0,0 +1,112 @@ +// AMICODE: data source for the approval card's transport (spec-20260727-164748 §9.5). +// Two routes: read the warrants the ledger holds, and mint one. +// +// SINGLE-WRITER DISCIPLINE (#212). Reading the ledger here is a plain file read, but +// WRITING goes through `amico ledger approve` — never an append from this process. +// amico-run is the ledger's only writer, which is what makes O_APPEND atomicity hold +// across the extension, the CLI, and this server. A "quick" append here would break +// that quietly and only under concurrency. +// +// WHY A ROUTE AT ALL. The card renders inside the webview, where no host context is +// reachable, so the app registers a bridge against these two routes (same shape as the +// vault browser's data path). It keeps the whole approval flow inside the fork — no +// extension command, no new IPC. +import { spawnSync } from "node:child_process" +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import path from "node:path" + +export interface WarrantRow { + plan_hash: string + bounds: Record + expires_at: string + issued_by: string +} + +/** $AMICO_LEDGER override, else ~/.amico/ledger/runs.jsonl — must stay identical to + * amico-run's ledgerPath(), or the card would read a different file than the gate. */ +export function ledgerPath(env: Record = process.env): string { + return env.AMICO_LEDGER || path.join(homedir(), ".amico", "ledger", "runs.jsonl") +} + +/** Approval rows, newest last. A missing or partially-corrupt ledger yields whatever + * parsed — per-line tolerance, because one bad line must not blind the card to every + * other warrant. An empty result reads as "no warrant", which the card shows as + * pending; it never reads as approved. */ +export function readWarrants(file: string = ledgerPath()): WarrantRow[] { + if (!existsSync(file)) return [] + let raw: string + try { + raw = readFileSync(file, "utf8") + } catch { + return [] + } + const out: WarrantRow[] = [] + for (const line of raw.split("\n")) { + if (!line.trim()) continue + try { + const rec = JSON.parse(line) as Record + if (rec.type !== "approval") continue + if (typeof rec.plan_hash !== "string" || typeof rec.expires_at !== "string") continue + out.push({ + plan_hash: rec.plan_hash, + bounds: typeof rec.bounds === "object" && rec.bounds !== null ? (rec.bounds as Record) : {}, + expires_at: rec.expires_at, + issued_by: typeof rec.issued_by === "string" ? rec.issued_by : "unknown", + }) + } catch { + /* one unparseable line must not hide the rest */ + } + } + return out +} + +export function warrantsBody(file: string = ledgerPath()): string { + return JSON.stringify({ ok: true, warrants: readWarrants(file) }) +} + +export interface ApproveInput { + plan_hash?: unknown + bounds?: unknown + expires_in?: unknown +} + +/** Build the `amico ledger approve` argv. Exported for testing so the flag mapping is + * pinned without spawning anything. Only DECLARED bounds become flags — §5.1 rule 2 + * refuses a launch needing a bound the warrant omits, so filling in a default here + * would silently widen the warrant the researcher thought they were granting. */ +export function approveArgv(input: ApproveInput): string[] | { error: string } { + const plan = typeof input.plan_hash === "string" ? input.plan_hash.trim() : "" + if (!plan) return { error: "plan_hash is required" } + const argv = ["ledger", "approve", "--plan-hash", plan] + + const b = typeof input.bounds === "object" && input.bounds !== null ? (input.bounds as Record) : {} + if (typeof b.max_solves === "number" && Number.isInteger(b.max_solves) && b.max_solves >= 1) + argv.push("--max-solves", String(b.max_solves)) + if (typeof b.tier === "string" && b.tier.trim()) argv.push("--tier", b.tier.trim()) + if (b.max_size_class === "SMALL" || b.max_size_class === "MEDIUM") argv.push("--max-size-class", b.max_size_class) + if (b.device === "none" || b.device === "ro" || b.device === "rw") argv.push("--device", b.device) + if (typeof input.expires_in === "number" && Number.isInteger(input.expires_in) && input.expires_in >= 1) + argv.push("--expires-in", String(input.expires_in)) + + // Provenance: the ledger's only record of who approved. "user:ui" distinguishes a + // button press from "user:cli", so an audit can tell them apart. + argv.push("--issued-by", "user:ui") + return argv +} + +/** Mint a warrant by shelling the CLI. Never appends directly (see header). */ +export function approveBody(input: ApproveInput): string { + const argv = approveArgv(input) + if ("error" in argv) return JSON.stringify({ ok: false, error: argv.error }) + const run = spawnSync("amico", argv, { encoding: "utf8" }) + if (run.error || run.status !== 0) { + return JSON.stringify({ + ok: false, + // stderr, not stdout: the CLI puts its refusal reason there, and it never + // contains a credential (the approve verb takes none). + error: (run.stderr || run.error?.message || `amico exited ${run.status}`).trim(), + }) + } + return JSON.stringify({ ok: true, stdout: run.stdout.trim() }) +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index b8e0bc2ea9..eb34a70cd6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -59,6 +59,7 @@ import { Workspace } from "@/control-plane/workspace" import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" import { serveUIEffect } from "@/server/shared/ui" import * as AmicodeVaults from "@/server/amicode/vaults" +import * as AmicodeWarrants from "@/server/amicode/warrants" import * as AmicodeVaultBrowser from "@/server/amicode/vault-browser" import * as AmicodeProblems from "@/server/amicode/problems" import * as AmicodeWidgets from "@/server/amicode/widgets" @@ -207,6 +208,27 @@ const amicodeVaultsRoute = HttpRouter.use((router) => // Vault browser (the app's Vault panel): recursive read-only listing of one // mount, and single-file reads with a real-path traversal guard. Builders // never reject — failures come back as ok:false JSON bodies. + // Capability warrants (spec-20260727-164748 §9.5): the approval card's transport. + // READ is a plain ledger read; WRITE shells `amico ledger approve` because + // amico-run is the ledger's single writer (#212) and an append from here would + // break O_APPEND atomicity quietly, only under concurrency. + yield* router.add("GET", "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/amicode/warrants", () => + Effect.sync(() => HttpServerResponse.text(AmicodeWarrants.warrantsBody(), { contentType: "application/json" })), + ) + yield* router.add("POST", "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/amicode/approve", (request) => + Effect.gen(function* () { + const body = yield* Effect.orDie(request.text) + let parsed: AmicodeWarrants.ApproveInput = {} + try { + parsed = JSON.parse(body) as AmicodeWarrants.ApproveInput + } catch { + return HttpServerResponse.text(JSON.stringify({ ok: false, error: "body must be JSON" }), { + contentType: "application/json", + }) + } + return HttpServerResponse.text(AmicodeWarrants.approveBody(parsed), { contentType: "application/json" }) + }), + ) yield* router.add("GET", "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/amicode/vault-files", (request) => Effect.sync(() => { const mount = new URL(request.url, "http://localhost").searchParams.get("mount") ?? undefined diff --git a/packages/opencode/test/server/amicode-warrants.test.ts b/packages/opencode/test/server/amicode-warrants.test.ts new file mode 100644 index 0000000000..c239bb5528 --- /dev/null +++ b/packages/opencode/test/server/amicode-warrants.test.ts @@ -0,0 +1,116 @@ +// Warrant routes (spec-20260727-164748 §9.5). The two things worth pinning: the +// ledger read is tolerant per-line but never invents a warrant, and the approve +// argv maps ONLY declared bounds — because §5.1 rule 2 refuses a launch needing a +// bound the warrant omits, so a helpfully-defaulted flag here would silently widen +// what the researcher thought they granted. +import { describe, expect, test } from "bun:test" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { approveArgv, ledgerPath, readWarrants, warrantsBody } from "../../src/server/amicode/warrants" + +const approval = (over: Record = {}) => ({ + type: "approval", + ts: "2026-07-27T20:00:00Z", + plan_hash: "9f2c", + bounds: { max_solves: 8, max_size_class: "MEDIUM" }, + expires_at: "2026-07-27T21:00:00Z", + issued_by: "user:ui", + ...over, +}) + +function withLedger(lines: unknown[]): { file: string; cleanup: () => void } { + const dir = mkdtempSync(join(tmpdir(), "warrants-route-")) + const file = join(dir, "runs.jsonl") + writeFileSync(file, lines.map((l) => (typeof l === "string" ? l : JSON.stringify(l))).join("\n") + "\n") + return { file, cleanup: () => rmSync(dir, { recursive: true, force: true }) } +} + +describe("ledgerPath", () => { + test("honours AMICO_LEDGER so the card reads the SAME file the gate does", () => { + expect(ledgerPath({ AMICO_LEDGER: "/tmp/x.jsonl" })).toBe("/tmp/x.jsonl") + expect(ledgerPath({})).toContain(join(".amico", "ledger", "runs.jsonl")) + }) +}) + +describe("readWarrants", () => { + test("a missing ledger yields none — which reads as pending, never as approved", () => { + expect(readWarrants(join(tmpdir(), "definitely-absent-", String(Math.abs(1)), "x.jsonl"))).toEqual([]) + }) + + test("returns approval rows and ignores every other kind", () => { + const { file, cleanup } = withLedger([approval(), { type: "solve", ts: "t" }, { type: "dispatch", ts: "t" }]) + try { + const rows = readWarrants(file) + expect(rows).toHaveLength(1) + expect(rows[0].plan_hash).toBe("9f2c") + expect(rows[0].issued_by).toBe("user:ui") + } finally { + cleanup() + } + }) + + test("one corrupt line does not blind the card to the others", () => { + const { file, cleanup } = withLedger(["{not json", approval({ plan_hash: "keeper" })]) + try { + expect(readWarrants(file).map((r) => r.plan_hash)).toEqual(["keeper"]) + } finally { + cleanup() + } + }) + + test("rows missing plan_hash or expires_at are skipped, not defaulted", () => { + const { file, cleanup } = withLedger([ + { type: "approval", ts: "t", bounds: {}, expires_at: "t2", issued_by: "u" }, + { type: "approval", ts: "t", plan_hash: "p", bounds: {}, issued_by: "u" }, + ]) + try { + expect(readWarrants(file)).toEqual([]) + } finally { + cleanup() + } + }) + + test("warrantsBody is ok:true with the rows", () => { + const { file, cleanup } = withLedger([approval()]) + try { + expect(JSON.parse(warrantsBody(file))).toMatchObject({ ok: true, warrants: [{ plan_hash: "9f2c" }] }) + } finally { + cleanup() + } + }) +}) + +describe("approveArgv", () => { + test("plan_hash is required", () => { + expect(approveArgv({})).toEqual({ error: "plan_hash is required" }) + expect(approveArgv({ plan_hash: " " })).toEqual({ error: "plan_hash is required" }) + }) + + test("only DECLARED bounds become flags", () => { + expect(approveArgv({ plan_hash: "p" })).toEqual([ + "ledger", "approve", "--plan-hash", "p", "--issued-by", "user:ui", + ]) + }) + + test("maps each declared bound", () => { + expect( + approveArgv({ plan_hash: "p", bounds: { max_solves: 4, tier: "hpc", max_size_class: "MEDIUM", device: "ro" }, expires_in: 900 }), + ).toEqual([ + "ledger", "approve", "--plan-hash", "p", + "--max-solves", "4", "--tier", "hpc", "--max-size-class", "MEDIUM", "--device", "ro", + "--expires-in", "900", "--issued-by", "user:ui", + ]) + }) + + test("unusable bound values are dropped rather than coerced", () => { + expect( + approveArgv({ plan_hash: "p", bounds: { max_solves: 0, tier: " ", max_size_class: "LARGE", device: "yes" }, expires_in: -1 }), + ).toEqual(["ledger", "approve", "--plan-hash", "p", "--issued-by", "user:ui"]) + }) + + test("issued_by marks a button press, so an audit can tell it from the CLI", () => { + const argv = approveArgv({ plan_hash: "p" }) as string[] + expect(argv[argv.indexOf("--issued-by") + 1]).toBe("user:ui") + }) +}) diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index 7524aeb3a8..27eb2dd406 100644 --- a/packages/ui/src/amicode/amicode.css +++ b/packages/ui/src/amicode/amicode.css @@ -1391,4 +1391,75 @@ [data-color-scheme="dark"] [data-slot="amicode-beta-tag"] { color: var(--amber-dark-11); background: color-mix(in srgb, var(--amber-dark-9) 18%, transparent); +/* ───────────────────────────────────────────────────────────────────────────── + APPROVAL CARD — [data-component="amicode-approval-card"] (approval-card.tsx) + The capability-warrant card (spec-20260727-164748 §9.5). Sibling of the ask + card: same in-transcript weight, but the state lives in data-state so the + granted/expired/unavailable cases are styleable without extra classes. + ───────────────────────────────────────────────────────────────────────────── */ +[data-component="amicode-approval-card"] { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 14px; + border: 1px solid var(--amc-rule, rgba(0, 0, 0, 0.1)); + border-radius: 10px; + background: var(--amc-card-bg, transparent); +} +[data-component="amicode-approval-card"] .amc-ap-head { + display: flex; + align-items: center; + gap: 8px; +} +[data-component="amicode-approval-card"] .amc-ap-title { + font-weight: 600; +} +[data-component="amicode-approval-card"] .amc-ap-plan { + margin-left: auto; + font-family: var(--font-mono, monospace); + font-size: 0.85em; + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; + max-width: 40%; + white-space: nowrap; +} +[data-component="amicode-approval-card"] .amc-ap-why { + margin: 0; + opacity: 0.85; +} +[data-component="amicode-approval-card"] .amc-ap-bounds { + display: grid; + grid-template-columns: auto 1fr; + gap: 2px 10px; + margin: 0; + font-size: 0.9em; +} +[data-component="amicode-approval-card"] .amc-ap-bounds dt { + opacity: 0.55; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 0.85em; +} +[data-component="amicode-approval-card"] .amc-ap-bounds dd { + margin: 0; + font-family: var(--font-mono, monospace); +} +[data-component="amicode-approval-card"] .amc-ap-foot { + display: flex; + align-items: center; + gap: 10px; +} +[data-component="amicode-approval-card"] .amc-ap-note { + font-size: 0.85em; + opacity: 0.6; +} +/* A granted warrant is settled, not celebratory — the gate still decides whether + its bounds cover any particular launch, so this must not read as "cleared". */ +[data-component="amicode-approval-card"][data-state="granted"] .amc-ap-title { + opacity: 0.7; +} +[data-component="amicode-approval-card"] button[data-slot="amicode-approval-approve"]:disabled { + opacity: 0.45; + cursor: not-allowed; } diff --git a/packages/ui/src/amicode/approval-card.tsx b/packages/ui/src/amicode/approval-card.tsx new file mode 100644 index 0000000000..6d6a5735f3 --- /dev/null +++ b/packages/ui/src/amicode/approval-card.tsx @@ -0,0 +1,101 @@ +import { Show, createMemo } from "solid-js" +import { amicodeApprovalBridge } from "./approval-bridge" +import { approvalState, boundsText, isActionable, type ApprovalRequest } from "./approval" +import { AmicoMark } from "./spinner" + +// AMICODE: the capability-warrant card for amicode_request_approval tool parts +// (spec-20260727-164748 §9.5). Structurally the ask card's sibling — request text +// plus one action — with three deliberate differences: +// +// 1. TRANSPORT. The ask card submits the chosen option as the user's next CHAT +// MESSAGE. This one writes the ledger through ./approval-bridge.ts. If it went +// through the chat, the agent would read the message and write the row itself, +// leaving the ledger's only provenance reading "the agent says the user +// approved". That is the whole reason the two bridges are separate. +// +// 2. STATE SOURCE. The ask card derives answered-ness from message order. This one +// derives from the LEDGER — is there a live warrant for this plan_hash? — which +// is equally replay-correct and survives a reload, and means the card cannot +// disagree with what the gate will see. +// +// 3. NO COVERAGE VERDICT. The card never says whether the granted bounds are +// enough; that is the gate's call (§5.1 rule 2). It shows what was requested +// and, once granted, what was actually granted — which may be narrower. A card +// that rendered "approved ✓" over a warrant the gate will still refuse would be +// worse than no card. +// +// Display label is AMICO (persona); component/data-* identifiers stay amicode-*. + +export function AmicodeApprovalCard(props: { request: ApprovalRequest }) { + // No clock signal: the state recomputes on any bridge/warrant change, and an + // expiry crossing while the card sits idle resolves on the next interaction + // rather than needing a timer per card. + const state = createMemo(() => + approvalState(props.request, amicodeApprovalBridge()?.warrants() ?? [], Date.now(), amicodeApprovalBridge() !== undefined), + ) + const actionable = () => isActionable(state()) + + const approve = () => { + const bridge = amicodeApprovalBridge() + if (!bridge || !actionable()) return + bridge.approve(props.request) + } + + const granted = () => { + const s = state() + return s.kind === "granted" || s.kind === "expired" ? s.warrant : undefined + } + + return ( +
+
+ + + + Approved + + + + {props.request.plan_hash} + +
+ + + {(why) =>

{why()}

} +
+ +
+
Requested
+
{boundsText(props.request.bounds)}
+ {/* Shown only once a warrant exists, and shows the GRANTED bounds — which + can legitimately differ from the request. */} + + {(w) => ( + <> +
Granted
+
{boundsText(w().bounds)}
+ + )} +
+
+ +
+ + + read-only surface — approvals are disabled here + the gate decides whether these bounds cover a given launch + this warrant has lapsed + +
+
+ ) +} diff --git a/packages/ui/src/amicode/approval.test.ts b/packages/ui/src/amicode/approval.test.ts index b5d3d4db46..689d42a40c 100644 --- a/packages/ui/src/amicode/approval.test.ts +++ b/packages/ui/src/amicode/approval.test.ts @@ -1,5 +1,57 @@ import { describe, expect, test } from "bun:test" -import { approvalState, boundsText, isActionable, warrantFor, type Warrant } from "./approval" +import { + approvalState, + boundsText, + isActionable, + parseApprovalInput, + warrantFor, + type Warrant, +} from "./approval" + +describe("parseApprovalInput", () => { + test("reads plan_hash, bounds and rationale from the tool input", () => { + expect( + parseApprovalInput({ + plan_hash: " 9f2c ", + bounds: { max_solves: 8, tier: "hpc", max_size_class: "MEDIUM", device: "ro" }, + rationale: " CZ ladder sweep ", + }), + ).toEqual({ + plan_hash: "9f2c", + bounds: { max_solves: 8, tier: "hpc", max_size_class: "MEDIUM", device: "ro" }, + rationale: "CZ ladder sweep", + }) + }) + + test("no plan_hash → undefined (caller falls back to the chip)", () => { + for (const bad of [undefined, null, "plain", {}, { plan_hash: "" }, { plan_hash: " " }, { plan_hash: 4 }]) { + expect(parseApprovalInput(bad), JSON.stringify(bad)).toBeUndefined() + } + }) + + test("bounds are optional — a request with none still parses", () => { + expect(parseApprovalInput({ plan_hash: "h" })).toEqual({ plan_hash: "h", bounds: {} }) + }) + + // A bound the card shows but the gate does not enforce (or the reverse) is worse + // than an absent one, so unusable values are DROPPED rather than coerced. + test("unusable bound values are dropped, not guessed", () => { + expect( + parseApprovalInput({ + plan_hash: "h", + bounds: { max_solves: 0, tier: " ", max_size_class: "LARGE", device: "yes", nonesuch: 1 }, + }), + ).toEqual({ plan_hash: "h", bounds: {} }) + }) + + test("a fractional max_solves is dropped rather than floored", () => { + expect(parseApprovalInput({ plan_hash: "h", bounds: { max_solves: 1.5 } })?.bounds).toEqual({}) + }) + + test("non-object bounds are ignored without rejecting the request", () => { + expect(parseApprovalInput({ plan_hash: "h", bounds: "lots" })).toEqual({ plan_hash: "h", bounds: {} }) + }) +}) const NOW = Date.parse("2026-07-27T20:00:00Z") const iso = (offsetMin: number) => new Date(NOW + offsetMin * 60_000).toISOString() @@ -80,7 +132,7 @@ describe("boundsText", () => { test("renders only declared bounds", () => { expect(boundsText({ max_solves: 8, tier: "free" })).toBe("8 solves · tier free") expect(boundsText({ max_solves: 1 })).toBe("1 solve") - expect(boundsText({ max_duration_s: 1800, device: "ro" })).toBe("30 min · device ro") + expect(boundsText({ max_size_class: "MEDIUM", device: "ro" })).toBe("up to MEDIUM · device ro") }) test("empty bounds say so rather than implying unlimited", () => { expect(boundsText({})).toBe("no bounds declared") diff --git a/packages/ui/src/amicode/approval.ts b/packages/ui/src/amicode/approval.ts index d18e01723f..196e0101cb 100644 --- a/packages/ui/src/amicode/approval.ts +++ b/packages/ui/src/amicode/approval.ts @@ -21,11 +21,38 @@ // "the agent says the user approved". Approvals go straight to the ledger via // ./approval-bridge.ts (→ `amico ledger approve`). +/** Reads an approval request from the tool part's INPUT args, mirroring + * parseAskInput — the ask card's pattern, not a sentinel, because the request is + * the agent's ASK rather than a record of something that happened. + * + * Tolerant in the same way: anything unusable → undefined, and the caller falls + * back to the collapsed chip. But NOT tolerant about bounds: an unparseable bound + * is DROPPED rather than guessed, because a bound the card displays and the gate + * does not enforce (or vice versa) is worse than an absent one. */ +export function parseApprovalInput(input: unknown): ApprovalRequest | undefined { + if (typeof input !== "object" || input === null) return undefined + const raw = input as Record + const planHash = raw.plan_hash + if (typeof planHash !== "string" || planHash.trim().length === 0) return undefined + + const bounds: WarrantBounds = {} + const b = typeof raw.bounds === "object" && raw.bounds !== null ? (raw.bounds as Record) : {} + + if (typeof b.max_solves === "number" && Number.isInteger(b.max_solves) && b.max_solves >= 1) + bounds.max_solves = b.max_solves + if (typeof b.tier === "string" && b.tier.trim().length > 0) bounds.tier = b.tier.trim() + if (b.max_size_class === "SMALL" || b.max_size_class === "MEDIUM") bounds.max_size_class = b.max_size_class + if (b.device === "none" || b.device === "ro" || b.device === "rw") bounds.device = b.device + + const rationale = typeof raw.rationale === "string" && raw.rationale.trim().length > 0 ? raw.rationale.trim() : undefined + return { plan_hash: planHash.trim(), bounds, ...(rationale ? { rationale } : {}) } +} + /** What a warrant may authorise — the fleet spec §2.1 vocabulary for `device`. */ export interface WarrantBounds { max_solves?: number; tier?: string; - max_duration_s?: number; + max_size_class?: "SMALL" | "MEDIUM"; device?: "none" | "ro" | "rw"; } @@ -106,7 +133,7 @@ export function boundsText(bounds: WarrantBounds): string { const parts: string[] = []; if (bounds.max_solves !== undefined) parts.push(`${bounds.max_solves} solve${bounds.max_solves === 1 ? "" : "s"}`); if (bounds.tier !== undefined) parts.push(`tier ${bounds.tier}`); - if (bounds.max_duration_s !== undefined) parts.push(`${Math.round(bounds.max_duration_s / 60)} min`); + if (bounds.max_size_class !== undefined) parts.push(`up to ${bounds.max_size_class}`); if (bounds.device !== undefined) parts.push(`device ${bounds.device}`); return parts.length ? parts.join(" · ") : "no bounds declared"; } diff --git a/packages/ui/src/amicode/card.tsx b/packages/ui/src/amicode/card.tsx index 81a5190442..9a784a8845 100644 --- a/packages/ui/src/amicode/card.tsx +++ b/packages/ui/src/amicode/card.tsx @@ -2,6 +2,8 @@ import { For, Match, Show, Switch, createMemo } from "solid-js" import { amicodeStage } from "./stage" import { parseAskInput } from "./ask" import { AmicodeAskCard } from "./ask-card" +import { parseApprovalInput } from "./approval" +import { AmicodeApprovalCard } from "./approval-card" import { parseDiffSentinel, receiptParts } from "./receipt" import { receiptIsCurrent } from "./receipt-currency" import { systemReceiptPieces, formulationReceiptPieces } from "./facets" @@ -282,6 +284,10 @@ export function AmicodeToolCard(props: { sessionID?: string }) { const ask = createMemo(() => (props.tool === "amicode_ask" ? parseAskInput(props.input) : undefined)) + // §9.5: the warrant card, same tool-input pattern as the ask card. + const approval = createMemo(() => + props.tool === "amicode_request_approval" ? parseApprovalInput(props.input) : undefined, + ) const runRef = createMemo(() => (props.tool === "amicode_solve" ? runRefFromOutput(props.output) : undefined)) const authored = createMemo(() => props.tool === "amicode_author_widget" ? parseWidgetSentinel(props.output) : undefined, @@ -313,6 +319,7 @@ export function AmicodeToolCard(props: { {(value) => } + {(req) => } {(ref) => } {(preview) => } {(e) => } diff --git a/packages/ui/src/amicode/entity-rail.tsx b/packages/ui/src/amicode/entity-rail.tsx index 3d7129b8db..45e4e4d78a 100644 --- a/packages/ui/src/amicode/entity-rail.tsx +++ b/packages/ui/src/amicode/entity-rail.tsx @@ -1,6 +1,8 @@ import { For, Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js" import { hasUserReplyAfter } from "./ask" import { registerAmicodeAskBridge } from "./ask-bridge" +import { registerAmicodeApprovalBridge } from "./approval-bridge" +import type { ApprovalRequest, Warrant } from "./approval" import { Icon } from "../components/icon" import { registerAmicodeUiBridge, type AmicodeWidgetHost } from "./ui-bridge" import { @@ -115,6 +117,13 @@ export function AmicodeEntityRail(props: { widgetHost?: AmicodeWidgetHost onOpenEntity: (kind: string, seq?: number) => void onAsk?: (text: string) => void + // Warrant transport (spec-20260727-164748 §9.5). DELIBERATELY NOT onAsk: routing an + // approval through the chat would leave the ledger's only provenance reading "the + // agent says the user approved". The app wires these to GET /amicode/warrants and + // POST /amicode/approve; omitting them leaves the approval card non-actionable, + // which is the correct read-only-surface behaviour. + warrants?: () => readonly Warrant[] + onApprove?: (request: ApprovalRequest) => void // Bridge-agnostic: fired when the user clicks the pulse chip (the rail's one // inspector entry). The app wires it to the host (postAmicode → // amicode.openInspector) and passes it only when framed in Amicode, so the @@ -134,6 +143,15 @@ export function AmicodeEntityRail(props: { }) onCleanup(dispose) } + // Registered only when BOTH halves are present: a card that could approve but not + // read back its own warrant would show "pending" forever after a successful press. + if (props.onApprove && props.warrants) { + const disposeApproval = registerAmicodeApprovalBridge({ + approve: (request) => props.onApprove?.(request), + warrants: () => props.warrants?.() ?? [], + }) + onCleanup(disposeApproval) + } // The ui bridge (openEntity + the in-transcript entity-view transport) is // registered below, once the live problem view, run statuses, and refetch it // exposes are all in scope. From 9fbdf628e1d16eac33706245b8d0ea22c6b623e3 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Mon, 27 Jul 2026 22:39:05 -0400 Subject: [PATCH 12/13] fix(amicode): a receipt is informative or inert, never a dead link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the same clutter, seen in a live session: five identical "Recommend updated ✓" chips in a row. Cause was NOT the INLINE_KINDS change — `recommend` was never an inline kind, so it has always rendered as a chip. It read as identical five times because amicode_recommend emits no diff sentinel, so the chip had nothing to distinguish one call from the next, and the interview fires one call per knob. What changed is only that the System cards above them shrank, so a run that used to be buried became the tallest thing on screen. The plugin side (amicode) now emits a sentinel naming the param, so the run reads `Recommend · N 100` / `Recommend · Q 1e5` rather than five copies of one line. Five DISTINCT lines are a record; five identical ones are noise. That alone would have introduced a worse bug: Chip's clickable() was `!!parts()`, so any sentinel made the chip openable — and entity-view.tsx has cases for only the five real kinds, so clicking a `recommend` receipt would have opened an empty dialog. Clickability is now gated on the kind actually having an entity view. Diff detail and openability are separate properties and were conflated. Also records a gap in the spec's §9.3: it specifies at most one EXPANDED card and says nothing about runs of identical COLLAPSED receipts — the same defect one tier down. Coalescing a run (the repo already has AnimatedCountList for exactly this shape) is the follow-up, but naming them first may make hiding them unnecessary. ui 378 pass, tsgo clean. extension 777 pass. Co-Authored-By: Claude Opus 5 --- packages/ui/src/amicode/card.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/amicode/card.tsx b/packages/ui/src/amicode/card.tsx index 9a784a8845..e8e7fd4ee1 100644 --- a/packages/ui/src/amicode/card.tsx +++ b/packages/ui/src/amicode/card.tsx @@ -66,7 +66,14 @@ function Chip(props: { tool: string; status?: string; output?: string }) { const sentinel = parseDiffSentinel(props.output) return sentinel ? { sentinel, receipt: receiptParts(sentinel) } : undefined }) - const clickable = () => !!parts() + // Clickable ONLY when an entity view exists for the kind. A receipt carrying a + // sentinel for a kind entity-view.tsx has no case for (e.g. `recommend`, which + // emits a sentinel purely so its chip names the param) would otherwise open an + // empty dialog. Diff detail and openability are separate properties. + const clickable = () => { + const entity = parts()?.sentinel.entity + return entity !== undefined && INLINE_KINDS.has(entity) + } const diffPieces = createMemo( () => parts()?.receipt.changes.map((change) => From 72b08af018f038436b9816bafc9d8f9d6baa435e Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Mon, 27 Jul 2026 23:05:07 -0400 Subject: [PATCH 13/13] feat(amicode): warrant status on the rail (S4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §9.6 / G-6, which settled the rail over the context-tree panel on Kate's own ADR 0003 framing: the panel holds "a map of distinct context, not a log", and warrant bounds are authorization state rather than context. Smaller than the spec implied, because building the approval card already plumbed warrants into the rail as a prop — the "adds a second data source" objection was resolved as a side effect. What actually remained was the solve count and the chip. `/amicode/warrants` now counts `solve` rows per plan in the SAME ledger pass that collects the approvals, so the numerator can never be a stale second counter (§4.5: the ledger is the count-things store). `solves_used` is optional end to end — a surface that does not supply it gets a chip with no count rather than a wrong "0 of 8". railWarrantChip returns UNDEFINED when there is nothing true to say: no live warrant, or a live one declaring no bounds. A chip reading "warranted" with nothing behind it would imply an authorization the gate does not grant, since §5.1 rule 2 refuses a launch needing a bound the warrant omits. Silence is the honest state. The chip is inert on purpose, and reports CONSUMPTION rather than permission. "3 of 8 solves" is a ledger fact; whether the next launch passes is the gate's verdict. Same reason the card carries no coverage claim — two surfaces, one rule. ui 384 pass · app 442 pass · opencode warrants 12 pass · tsgo clean in ui and app. Co-Authored-By: Claude Opus 5 --- .../src/pages/session/message-timeline.tsx | 3 ++ .../opencode/src/server/amicode/warrants.ts | 14 ++++++- .../test/server/amicode-warrants.test.ts | 19 +++++++++ packages/ui/src/amicode/approval.test.ts | 39 +++++++++++++++++++ packages/ui/src/amicode/approval.ts | 37 ++++++++++++++++++ packages/ui/src/amicode/entity-rail.tsx | 23 ++++++++++- 6 files changed, 132 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index ec7988d930..60589f1d03 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -349,6 +349,9 @@ export function MessageTimeline(props: { bounds: (typeof row.bounds === "object" && row.bounds !== null ? row.bounds : {}) as Record, expires_at: row.expires_at, issued_by: typeof row.issued_by === "string" ? row.issued_by : "unknown", + // Omitted when the server did not send it, so the rail chip drops the count + // rather than rendering a wrong "0 of N". + ...(typeof row.solves_used === "number" ? { solves_used: row.solves_used } : {}), }] }) }) diff --git a/packages/opencode/src/server/amicode/warrants.ts b/packages/opencode/src/server/amicode/warrants.ts index 8b3b1d6b48..d47042218d 100644 --- a/packages/opencode/src/server/amicode/warrants.ts +++ b/packages/opencode/src/server/amicode/warrants.ts @@ -21,6 +21,10 @@ export interface WarrantRow { bounds: Record expires_at: string issued_by: string + /** `solve` rows already recorded under this plan — the numerator for a + * max_solves bound. Counted from the SAME ledger pass, so it can never be a + * stale second counter (spec §4.5: the ledger is the count-things store). */ + solves_used: number } /** $AMICO_LEDGER override, else ~/.amico/ledger/runs.jsonl — must stay identical to @@ -41,11 +45,17 @@ export function readWarrants(file: string = ledgerPath()): WarrantRow[] { } catch { return [] } - const out: WarrantRow[] = [] + const out: Omit[] = [] + const solves = new Map() for (const line of raw.split("\n")) { if (!line.trim()) continue try { const rec = JSON.parse(line) as Record + // Count solves in the same pass — a second read could disagree with the first. + if (rec.type === "solve") { + if (typeof rec.plan_hash === "string") solves.set(rec.plan_hash, (solves.get(rec.plan_hash) ?? 0) + 1) + continue + } if (rec.type !== "approval") continue if (typeof rec.plan_hash !== "string" || typeof rec.expires_at !== "string") continue out.push({ @@ -58,7 +68,7 @@ export function readWarrants(file: string = ledgerPath()): WarrantRow[] { /* one unparseable line must not hide the rest */ } } - return out + return out.map((w) => ({ ...w, solves_used: solves.get(w.plan_hash) ?? 0 })) } export function warrantsBody(file: string = ledgerPath()): string { diff --git a/packages/opencode/test/server/amicode-warrants.test.ts b/packages/opencode/test/server/amicode-warrants.test.ts index c239bb5528..6118dd04f3 100644 --- a/packages/opencode/test/server/amicode-warrants.test.ts +++ b/packages/opencode/test/server/amicode-warrants.test.ts @@ -71,6 +71,25 @@ describe("readWarrants", () => { } }) + test("counts solve rows under each plan, in the same pass", () => { + const { file, cleanup } = withLedger([ + approval({ plan_hash: "p1" }), + { type: "solve", ts: "t", plan_hash: "p1" }, + { type: "solve", ts: "t", plan_hash: "p1" }, + { type: "solve", ts: "t", plan_hash: "other" }, + { type: "solve", ts: "t" }, + approval({ plan_hash: "p2" }), + ]) + try { + const rows = readWarrants(file) + expect(rows.find((r) => r.plan_hash === "p1")?.solves_used).toBe(2) + // A plan with no solves is 0, not undefined — the chip renders "0 of N". + expect(rows.find((r) => r.plan_hash === "p2")?.solves_used).toBe(0) + } finally { + cleanup() + } + }) + test("warrantsBody is ok:true with the rows", () => { const { file, cleanup } = withLedger([approval()]) try { diff --git a/packages/ui/src/amicode/approval.test.ts b/packages/ui/src/amicode/approval.test.ts index 689d42a40c..c089a59e7a 100644 --- a/packages/ui/src/amicode/approval.test.ts +++ b/packages/ui/src/amicode/approval.test.ts @@ -4,6 +4,7 @@ import { boundsText, isActionable, parseApprovalInput, + railWarrantChip, warrantFor, type Warrant, } from "./approval" @@ -138,3 +139,41 @@ describe("boundsText", () => { expect(boundsText({})).toBe("no bounds declared") }) }) + +describe("railWarrantChip", () => { + const w2 = (over: Partial = {}): Warrant => ({ + plan_hash: "9f2c", + bounds: { max_solves: 8, tier: "free", max_size_class: "MEDIUM" }, + expires_at: iso(30), + issued_by: "user:ui", + ...over, + }) + + test("shows consumption against the declared bounds", () => { + expect(railWarrantChip([w2({ solves_used: 3 })], NOW)).toBe("3 of 8 solves · tier free · up to MEDIUM") + }) + + test("omits the count when the surface did not supply one, rather than guessing 0", () => { + expect(railWarrantChip([w2()], NOW)).toBe("8 solves · tier free · up to MEDIUM") + }) + + test("no live warrant → no chip", () => { + expect(railWarrantChip([], NOW)).toBeUndefined() + expect(railWarrantChip([w2({ expires_at: iso(-1) })], NOW)).toBeUndefined() + }) + + // A chip saying "warranted" with nothing behind it would imply an authorization + // the gate does not grant, since an omitted bound is a refusal not a default. + test("a live warrant declaring NO bounds produces no chip", () => { + expect(railWarrantChip([w2({ bounds: {} })], NOW)).toBeUndefined() + }) + + test("among live warrants, reports the one expiring latest (what the gate checks)", () => { + const later = w2({ expires_at: iso(90), bounds: { max_solves: 2 }, solves_used: 1 }) + expect(railWarrantChip([w2({ solves_used: 7 }), later], NOW)).toBe("1 of 2 solves") + }) + + test("an unparseable expiry is not live", () => { + expect(railWarrantChip([w2({ expires_at: "nope" })], NOW)).toBeUndefined() + }) +}) diff --git a/packages/ui/src/amicode/approval.ts b/packages/ui/src/amicode/approval.ts index 196e0101cb..7a0a32dab7 100644 --- a/packages/ui/src/amicode/approval.ts +++ b/packages/ui/src/amicode/approval.ts @@ -69,6 +69,10 @@ export interface Warrant { bounds: WarrantBounds; expires_at: string; issued_by: string; + /** `solve` rows recorded under this plan. OPTIONAL: absent on any surface that + * does not supply it, and the rail chip then omits the count rather than + * rendering a wrong "0 of 8". */ + solves_used?: number; } export type ApprovalState = @@ -137,3 +141,36 @@ export function boundsText(bounds: WarrantBounds): string { if (bounds.device !== undefined) parts.push(`device ${bounds.device}`); return parts.length ? parts.join(" · ") : "no bounds declared"; } + +/** Rail chip for the ACTIVE warrant (spec §9.6, G-6): remaining budget at a glance, + * so a researcher mid-campaign does not have to open anything to see how much + * authorization is left. + * + * Returns undefined when there is nothing true to say — no live warrant, or a live + * one that declares no bounds. A chip reading "warranted" with no bounds behind it + * would imply an authorization the gate does not actually grant (§5.1 rule 2 + * refuses a launch needing a bound the warrant omits), so silence is correct. + * + * Deliberately shows CONSUMPTION, not permission: "3 of 8 solves" is a fact from + * the ledger. It never says whether the next launch will pass — that is the gate's + * verdict, and the same reason the card carries no coverage claim. */ +export function railWarrantChip(warrants: readonly Warrant[], now: number): string | undefined { + // The live warrant expiring latest — the one a launch would actually be checked + // against, matching liveWarrant() in amico-run's warrant.ts. + let best: Warrant | undefined + for (const w of warrants) { + if (expiryMs(w) <= now) continue + if (!best || expiryMs(w) > expiryMs(best)) best = w + } + if (!best) return undefined + + const parts: string[] = [] + if (best.bounds.max_solves !== undefined) { + const used = best.solves_used + parts.push(used === undefined ? `${best.bounds.max_solves} solves` : `${used} of ${best.bounds.max_solves} solves`) + } + if (best.bounds.tier !== undefined) parts.push(`tier ${best.bounds.tier}`) + if (best.bounds.max_size_class !== undefined) parts.push(`up to ${best.bounds.max_size_class}`) + if (best.bounds.device !== undefined) parts.push(`device ${best.bounds.device}`) + return parts.length ? parts.join(" · ") : undefined +} diff --git a/packages/ui/src/amicode/entity-rail.tsx b/packages/ui/src/amicode/entity-rail.tsx index 45e4e4d78a..076ae474ee 100644 --- a/packages/ui/src/amicode/entity-rail.tsx +++ b/packages/ui/src/amicode/entity-rail.tsx @@ -2,7 +2,7 @@ import { For, Show, createEffect, createMemo, createResource, createSignal, onCl import { hasUserReplyAfter } from "./ask" import { registerAmicodeAskBridge } from "./ask-bridge" import { registerAmicodeApprovalBridge } from "./approval-bridge" -import type { ApprovalRequest, Warrant } from "./approval" +import { railWarrantChip, type ApprovalRequest, type Warrant } from "./approval" import { Icon } from "../components/icon" import { registerAmicodeUiBridge, type AmicodeWidgetHost } from "./ui-bridge" import { @@ -218,6 +218,9 @@ export function AmicodeEntityRail(props: { }) onCleanup(disposeUiBridge) + // Recomputed on any warrant change; no ticker, so an expiry crossing resolves on + // the next refetch rather than needing a timer per rail. + const warrantChip = createMemo(() => railWarrantChip(props.warrants?.() ?? [], Date.now())) const chips = createMemo(() => { const snapshot = state() if (snapshot.kind !== "ready") return [] @@ -323,6 +326,24 @@ export function AmicodeEntityRail(props: { )} + {/* Warrant status (spec §9.6 / G-6): the ACTIVE warrant's consumption, so a + researcher mid-campaign sees remaining authorization without opening + anything. Inert by design — it reports a ledger fact, and whether the + next launch passes is the gate's verdict, not this chip's. Absent when + there is no live warrant or it declares no bounds, so it never implies + an authorization the gate would refuse. */} + + {(text) => ( + + + {text()} + + )} +