diff --git a/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app/src/components/amicode-defaults-capsule.tsx index cf18d3f547..8941453091 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" } > @@ -370,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"} + + + 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 new file mode 100644 index 0000000000..c089a59e7a --- /dev/null +++ b/packages/ui/src/amicode/approval.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "bun:test" +import { + approvalState, + boundsText, + isActionable, + parseApprovalInput, + railWarrantChip, + 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() + +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_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") + }) +}) + +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 new file mode 100644 index 0000000000..7a0a32dab7 --- /dev/null +++ b/packages/ui/src/amicode/approval.ts @@ -0,0 +1,176 @@ +// 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`). + +/** 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_size_class?: "SMALL" | "MEDIUM"; + 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; + /** `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 = + /** 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_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"; +} + +/** 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/card.tsx b/packages/ui/src/amicode/card.tsx index 81a5190442..e8e7fd4ee1 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" @@ -64,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) => @@ -282,6 +291,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 +326,7 @@ export function AmicodeToolCard(props: { {(value) => } + {(req) => } {(ref) => } {(preview) => } {(e) => } diff --git a/packages/ui/src/amicode/connections.test.ts b/packages/ui/src/amicode/connections.test.ts index 4235cc4acd..b79709d685 100644 --- a/packages/ui/src/amicode/connections.test.ts +++ b/packages/ui/src/amicode/connections.test.ts @@ -478,7 +478,7 @@ describe("driftCopy (170 AC4)", () => { describe("connectionTitle", () => { test("known products get names; unknown ids render verbatim", () => { - expect(connectionTitle("company-compute")).toBe("Solver API key") + expect(connectionTitle("company-compute")).toBe("Harmoniqs Cloud") expect(connectionTitle("pasqal-cloud")).toBe("Pasqal Cloud") expect(connectionTitle("(unknown)")).toBe("(unknown)") }) @@ -592,17 +592,25 @@ describe("applyConnectionOverlay", () => { }) }) -// ── amicode#200 AC5: Company Compute relocated to the solver toggle ───────── +// ── Harmoniqs Cloud is connectable in the Connections tab (reverses #200 AC5) ─ import { statusTabConnections, COMPANY_COMPUTE_ID as COMPUTE_ID } from "./connections" -describe("status-tab connection list (#200 AC5)", () => { +describe("status-tab connection list", () => { const mk = (id: string) => ({ id, state: "connected" as const, rawState: "connected", validatedAt: "—", stale: false }) - test("excludes company-compute; everything else passes through in order", () => { + + // The regression this pins: #200 filtered company-compute out of this list, so + // Pasqal Cloud was connectable here and OUR cloud was not — users looked where + // Pasqal is, found nothing, and had nowhere to enter an API key. + test("includes company-compute, in wire order alongside the others", () => { const list = [mk(COMPUTE_ID), mk("pasqal-cloud"), mk("future-target")] - expect(statusTabConnections(list).map((c) => c.id)).toEqual(["pasqal-cloud", "future-target"]) + expect(statusTabConnections(list).map((c) => c.id)).toEqual([COMPUTE_ID, "pasqal-cloud", "future-target"]) + }) + + test("a compute-only list still renders a card (the empty state must not swallow it)", () => { + expect(statusTabConnections([mk(COMPUTE_ID)]).map((c) => c.id)).toEqual([COMPUTE_ID]) }) - test("empty and compute-only lists yield empty", () => { + + test("an empty list stays empty", () => { expect(statusTabConnections([])).toEqual([]) - expect(statusTabConnections([mk(COMPUTE_ID)])).toEqual([]) }) }) diff --git a/packages/ui/src/amicode/connections.ts b/packages/ui/src/amicode/connections.ts index da14cf35fd..1d0505db10 100644 --- a/packages/ui/src/amicode/connections.ts +++ b/packages/ui/src/amicode/connections.ts @@ -78,20 +78,33 @@ export type ConnectionActionView = { ok: boolean; connection?: ConnectionView; e export const COMPANY_COMPUTE_ID = "company-compute" -/** amicode#200: Company Compute lives in the solver toggle now — the status - * popover's Connections tab shows execution targets (Pasqal, future hardware) - * only. The wire still carries every connection; this is a render filter. */ +/** Every connection renders in the Connections tab, Harmoniqs Cloud included. + * + * This REVERSES amicode#200's render filter. That change moved Company Compute + * out of the tab, reasoning that it was one credential for one service rather + * than a separate product. The effect, though, was that Pasqal Cloud appeared + * as a connectable service and Harmoniqs Cloud — ours — did not: users went + * looking for it exactly where Pasqal is, found nothing, and had nowhere to + * enter an API key (2026-07-28). A cloud we sell has to be connectable in the + * place that lists clouds. + * + * The solver capsule keeps its own connect affordance; both routes write the + * same credential, so connecting in either place shows up in both. Kept as a + * function rather than dropping the call sites, so there is still one obvious + * place to filter if a genuinely internal connection ever appears. */ export function statusTabConnections(connections: ConnectionView[]): ConnectionView[] { - return connections.filter((c) => c.id !== COMPANY_COMPUTE_ID) + return connections } export const PASQAL_ID = "pasqal-cloud" /** Product names are not translated; ids without one render verbatim. */ export function connectionTitle(id: string): string { - // amicode#200 (Kate): one credential, one service — present it as what it - // is (the API key that unlocks the cloud solvers), not a separate product. + // Named as the product, alongside Pasqal Cloud. #200 called this "Solver API + // key" to avoid implying a second product, but in a list whose other entry is + // "Pasqal Cloud" that reads as a settings field rather than our service — and + // it is the name every downstream refusal uses (amico-run, the hpc gate). // The wire id stays "company-compute": server contract, not presentation. - if (id === COMPANY_COMPUTE_ID) return "Solver API key" + if (id === COMPANY_COMPUTE_ID) return "Harmoniqs Cloud" if (id === PASQAL_ID) return "Pasqal Cloud" return id } diff --git a/packages/ui/src/amicode/context-tree-data.test.ts b/packages/ui/src/amicode/context-tree-data.test.ts index 1c1c547438..5e34248868 100644 --- a/packages/ui/src/amicode/context-tree-data.test.ts +++ b/packages/ui/src/amicode/context-tree-data.test.ts @@ -76,6 +76,22 @@ describe("buildContextTree", () => { ]) expect(tree.children![0].children![0].vault).toBe(true) }) + test("vaultLocked marks non-browsable vault leaves locked; others untouched", () => { + const tree = buildContextTree( + [ + turn("m1", [ + { label: "STRATEGY.md", type: "note", path: "/u/.amico/vaults/armonissima/STRATEGY.md" }, + { label: "notes.md", type: "note", path: "/u/.amico/vaults/armonia-kate/notes.md" }, + { label: "solve.jl", type: "package", path: "/p/solve.jl" }, + ]), + ], + { vaultLocked: (mount) => mount === "armonissima" }, + ) + const [team, personal, project] = tree.children![0].children! + expect(team.locked).toBe(true) + expect(personal.locked).toBe(false) + expect(project.locked).toBeUndefined() // not a vault file — predicate never consulted + }) test("marathon sessions fold old turns into one earlier branch", () => { const turns = Array.from({ length: 30 }, (_, i) => turn(`m${i}`, [{ label: `f${i}.md`, type: "note", path: `/p/f${i}.md` }]), diff --git a/packages/ui/src/amicode/context-tree-data.ts b/packages/ui/src/amicode/context-tree-data.ts index 66a4b8c14c..25ee67394f 100644 --- a/packages/ui/src/amicode/context-tree-data.ts +++ b/packages/ui/src/amicode/context-tree-data.ts @@ -64,7 +64,14 @@ export function vaultRefFromPath(path: string): { mount: string; rel: string } | const dedupKey = (ref: ContextRef, kind: ContextTreeKind) => ref.path ? `p:${ref.path}` : `${kind}:${ref.label.toLowerCase()}` -export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: string } = {}): ContextTreeNodeInput { +export type ContextTreeOpts = { + rootLabel?: string + /** true when the mount refuses browsing (proprietary data/software) — its + * leaves render locked: dimmed, padlocked, not openable */ + vaultLocked?: (mount: string) => boolean +} + +export function buildContextTree(turns: ContextTurn[], opts: ContextTreeOpts = {}): ContextTreeNodeInput { const root: ContextTreeNodeInput = { id: "root", label: opts.rootLabel ?? "amico", @@ -95,7 +102,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin const key = dedupKey(ref, kind) if (seen.has(key)) continue seen.set(key, `ctx-${seen.size}`) - earlier.children!.push(leafOf(ref, kind, seen.get(key)!)) + earlier.children!.push(leafOf(ref, kind, seen.get(key)!, opts)) } } const seen = seenOf(root) @@ -123,7 +130,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin } const id = `ctx-${seen.size}` seen.set(key, id) - const leaf = leafOf(ref, kind, id) + const leaf = leafOf(ref, kind, id, opts) node.children!.push(leaf) lastLeaf = leaf } @@ -138,7 +145,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin return root } -function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string): ContextTreeNodeInput { +function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string, opts: ContextTreeOpts): ContextTreeNodeInput { const vault = ref.path ? vaultRefFromPath(ref.path) : undefined return { id, @@ -146,6 +153,7 @@ function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string): ContextTree kind, path: ref.path, vault: !!vault, + locked: vault && opts.vaultLocked ? opts.vaultLocked(vault.mount) : undefined, } } diff --git a/packages/ui/src/amicode/context-tree-engine.ts b/packages/ui/src/amicode/context-tree-engine.ts index c4dd217786..0717d3c5b0 100644 --- a/packages/ui/src/amicode/context-tree-engine.ts +++ b/packages/ui/src/amicode/context-tree-engine.ts @@ -44,6 +44,9 @@ export type ContextTreeNodeInput = { path?: string /** the file lives in a vault mount (open via the Vault panel, not a tab) */ vault?: boolean + /** the vault refuses browsing (proprietary data/software) — the node renders + * dimmed with a padlock and is NOT openable, path or not */ + locked?: boolean /** where the agent currently works — wears the thought-color cursor */ active?: boolean children?: ContextTreeNodeInput[] @@ -57,6 +60,7 @@ export type ContextTreeSelection = { kind: ContextTreeKind path?: string vault?: boolean + locked?: boolean } export interface ContextTreeEngineOptions { @@ -181,6 +185,7 @@ interface TNode { kind: ContextTreeKind path?: string vault?: boolean + locked?: boolean active: boolean depth: number // world targets (tidy layout) and animated positions @@ -333,6 +338,7 @@ export function createContextTreeEngine( n.kind = input.kind n.path = input.path n.vault = input.vault + n.locked = input.locked n.active = !!input.active n.depth = depth n.half = HALF[input.kind] ?? 4 @@ -456,7 +462,11 @@ export function createContextTreeEngine( kind: n.kind, path: n.path, vault: n.vault, + locked: n.locked, }) + // openable = the click actually goes somewhere; everything else (turns, + // skills, agents, actions, locked vault files) must not wear the pointer + const openable = (n: TNode) => !!n.path && !n.locked let dragging = false let dragMoved = false let lastPX = 0, @@ -499,7 +509,8 @@ export function createContextTreeEngine( const n = id ? (byId.get(id) ?? null) : null if (n !== hovered) { hovered = n - if (canvas.style) canvas.style.cursor = n && (n.path || n.kind === "turn") ? "pointer" : "grab" + // pointer only where a click opens something; a locked node says so + if (canvas.style) canvas.style.cursor = n && openable(n) ? "pointer" : n?.locked ? "not-allowed" : "grab" opts.onHover?.(n ? selection(n) : null) } } @@ -510,7 +521,9 @@ export function createContextTreeEngine( const p = local(e) const id = pick(p.x, p.y) const n = id ? byId.get(id) : undefined - if (n) { + // only openable nodes acknowledge the click — a ring on a dead-end node + // would promise an action that never comes + if (n && openable(n)) { n.ringT = beatNow opts.onSelect?.(selection(n)) } @@ -647,9 +660,11 @@ export function createContextTreeEngine( ctx.lineWidth = 1 ctx.stroke() } else { - ctx.fillStyle = rgba(color, hoveredNow ? 0.95 : 0.75) + // locked (non-browsable vault) leaves read clearly non-interactive: + // reduced emphasis, plus the padlock by the label (shape, not color) + ctx.fillStyle = rgba(color, n.locked ? 0.3 : hoveredNow ? 0.95 : 0.75) ctx.fill() - ctx.strokeStyle = rgba(color, 0.9) + ctx.strokeStyle = rgba(color, n.locked ? 0.45 : 0.9) ctx.lineWidth = 1 ctx.stroke() } @@ -690,13 +705,25 @@ export function createContextTreeEngine( isRoot || n.kind === "turn" ? 0.85 : hoveredNow || glancedNow || n.active ? 1 : nearHover ? 0.85 : 0.6 ctx.font = `${n.kind === "turn" || isRoot ? "600 " : ""}10px JuliaMono, ui-monospace, SFMono-Regular, Menlo, monospace` const text = n.label.length > 30 ? n.label.slice(0, 29) + "…" : n.label + const lockW = n.locked ? 10 : 0 const tw = ctx.measureText(text).width - const lx = x - tw / 2, + const lx = x - (tw + lockW) / 2, ly = y + half + 10 ctx.fillStyle = css.labelHalo - ctx.fillRect(lx - 2, ly - 7, tw + 4, 14) - ctx.fillStyle = rgba(css.fg, Math.min(la, 1) * n.alpha) - ctx.fillText(text, lx, ly) + ctx.fillRect(lx - 2, ly - 7, tw + lockW + 4, 14) + const inkA = Math.min(la, 1) * n.alpha + if (n.locked) { + // padlock: shackle arc over a body — the non-color "cannot open" signal + ctx.strokeStyle = rgba(css.fg, inkA) + ctx.lineWidth = 1 + ctx.beginPath() + ctx.arc(lx + 3, ly - 1.5, 2, Math.PI, 0) + ctx.stroke() + ctx.fillStyle = rgba(css.fg, inkA) + ctx.fillRect(lx, ly - 1.5, 6, 5) + } + ctx.fillStyle = rgba(css.fg, inkA) + ctx.fillText(text, lx + lockW, ly) ctx.globalAlpha = 1 } diff --git a/packages/ui/src/amicode/entity-rail.tsx b/packages/ui/src/amicode/entity-rail.tsx index c2b7352ca0..076ae474ee 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 { railWarrantChip, type ApprovalRequest, type Warrant } from "./approval" import { Icon } from "../components/icon" import { registerAmicodeUiBridge, type AmicodeWidgetHost } from "./ui-bridge" import { @@ -115,9 +117,17 @@ export function AmicodeEntityRail(props: { widgetHost?: AmicodeWidgetHost onOpenEntity: (kind: string, seq?: number) => void onAsk?: (text: string) => void - // Bridge-agnostic: fired when the user clicks "Inspect Run". The app wires it - // to the host (postAmicode → amicode.openInspector) and passes it only when - // framed in Amicode, so the button stays hidden everywhere else. + // 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 + // chip falls back to dialog/inert behavior everywhere else. onInspectRun?: () => void retryLabel: string unavailableLabel: string @@ -133,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. @@ -199,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 [] @@ -207,15 +229,10 @@ export function AmicodeEntityRail(props: { // Which chips hand off to the Run Inspector instead of the entity dialog. // Only the pulse chip, and only when the host actually wired an inspector — // standalone opencode has none, so there the chip keeps its dialog behavior. + // The pulse chip is the ONLY inspector entry on the rail — the separate + // "Inspect Run" button was redundant chrome next to it (Kate 2026-07-28). const opensInspector = (kind: string) => kind === "pulse" && props.onInspectRun !== undefined - // Whether there is a run to inspect — gates the "Inspect Run" button so it - // appears alongside the live run chip, not before any solve has started. - const hasRun = createMemo(() => { - const snapshot = state() - return snapshot.kind === "ready" && snapshot.view.runs.length > 0 - }) - return ( 0}>
@@ -296,33 +313,36 @@ 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). */} + + + )} - - + {/* 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()} + + )}
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..fd29b27b58 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 { @@ -513,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) { @@ -526,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. */ @@ -550,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()}}