From 79b5ee42564a1140fbd1b6d894e5baa990430af1 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 28 Jul 2026 16:10:38 -0400 Subject: [PATCH 1/4] feat(amicode): per-component physics rows on the system card The card made its component-count claim implicitly, so an unanswered 'how many atoms?' read as 'one'. systemCountLabel now states it as its own badge ('2 atoms x 3 levels') that a researcher can read and correct. componentPhysicsRows renders what each role actually has, in card order: - unanswered params read 'not set', so the list doubles as the interview's to-do - anything else on file is still shown, so nothing recorded is dropped - an unrecognized role expects NOTHING -- the honest floor for a platform we have no model for - units are never assumed (transmon params are GHz, the Rydberg templates work in rad/us, a bare omega says which only by convention), so a unit appears only when the recorded key spells it out - a zero keeps the '0 means unset' reading, so an all-zeros seed does not look like a specified device driftTerm now keys the qubit ladder off LEVELS rather than off whether delta happens to be filled in: a 3-level transmon with empty params is still an anharmonic ladder, and rendering it as a bare spin misstates the model the solve will use. A mode's Kerr stays params-driven, since a linear cavity genuinely has none. --- packages/ui/src/amicode/system-render.test.ts | 98 +++++++- packages/ui/src/amicode/system-render.ts | 210 +++++++++++++++--- packages/ui/src/amicode/system-view.tsx | 66 ++---- 3 files changed, 292 insertions(+), 82 deletions(-) diff --git a/packages/ui/src/amicode/system-render.test.ts b/packages/ui/src/amicode/system-render.test.ts index edc873f68..5caabbe5e 100644 --- a/packages/ui/src/amicode/system-render.test.ts +++ b/packages/ui/src/amicode/system-render.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect } from "bun:test" import { systemProjection } from "./problem" -import { systemSchematicModel, systemTableModel, systemHamiltonianLatex, systemIdentityLine } from "./system-render" +import { + systemSchematicModel, + systemTableModel, + systemHamiltonianLatex, + systemIdentityLine, + systemCountLabel, + componentPhysicsRows, +} from "./system-render" const twoTransmon = { platform: "transmon", @@ -97,6 +104,25 @@ describe("systemHamiltonianLatex", () => { expect(h).toContain("C_6") // blockade interaction expect(h.split("\\Omega(t)")).toHaveLength(2) // drive appears exactly once }) + 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 = { platform: "hybrid", @@ -123,6 +149,76 @@ describe("systemTableModel", () => { }) }) +describe("componentPhysicsRows", () => { + const labels = (c: any) => componentPhysicsRows(c).map((r) => r.label) + const row = (c: any, label: string) => componentPhysicsRows(c).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)).toEqual(["levels", "frequency", "anharmonicity", "drive bound", "decay"]) + expect(row(q, "frequency")).toMatchObject({ value: "4.8", state: "recorded" }) + expect(row(q, "drive bound")).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 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")!.value).toBe("4.8 GHz") + expect(row(c, "drive bound")!.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")!.state).toBe("missing") + expect(row(c, "decay")).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 d9dfed949..0afb98ea4 100644 --- a/packages/ui/src/amicode/system-render.ts +++ b/packages/ui/src/amicode/system-render.ts @@ -2,24 +2,29 @@ // (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")) + const role = roles.size === 1 ? [...roles][0] : "component" + 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 } @@ -75,17 +80,23 @@ const COUPLING_TERM: Record = { "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) { +function driftTerm(c: { role: string; levels?: number; params: Record }): string { + switch (c.role) { 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" + // Keyed off LEVELS, not off whether δ happens to be filled in yet: a + // 3-level transmon whose params are still empty is an anharmonic ladder, + // and rendering it as a bare spin misstates the model the solve will use. + return c.levels === 2 + ? "\\tfrac{\\omega}{2}\\,\\hat\\sigma_z" + : "\\omega\\,\\hat a^\\dagger \\hat a + \\tfrac{\\delta}{2}\\,\\hat a^\\dagger \\hat a^\\dagger \\hat a \\hat a" 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" + // A mode's Kerr is genuinely optional (a linear cavity has none), so here + // the recorded params ARE the evidence. + return ["K", "K_c", "K_c_Hz", "kerr"].some((k) => k in c.params) + ? "\\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|" default: @@ -93,16 +104,14 @@ function driftTerm(role: string, params: Record): string { } } -/** Drive term per component role. Atoms are laser-driven on the |1⟩↔|r⟩ - * transition (3-level Rydberg convention: |0⟩ dark); everything bosonic or +/** Drive term per component. Atoms are laser-driven on the |1⟩↔|r⟩ transition + * (3-level Rydberg convention: |0⟩ dark); a strictly two-level component is + * driven in the Pauli basis its drift already uses; 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.})" - default: - return "\\varepsilon(t)\\,(\\hat a + \\hat a^\\dagger)" - } +function driveTerm(c: { role: string; levels?: number }): string { + if (c.role === "atom") return "\\tfrac{\\Omega(t)}{2}\\,(|r\\rangle\\langle 1| + \\mathrm{h.c.})" + if (c.role === "qubit" && c.levels === 2) return "u_1(t)\\,\\hat\\sigma_x + u_2(t)\\,\\hat\\sigma_y" + return "\\varepsilon(t)\\,(\\hat a + \\hat a^\\dagger)" } /** Compose an illustrative Hamiltonian for ANY composite system. undefined when @@ -111,7 +120,7 @@ export function systemHamiltonianLatex(proj: SystemProjection): string | undefin const terms: string[] = [] const seen = new Set() for (const c of proj.components) { - const t = driftTerm(c.role, c.params) + const t = driftTerm(c) if (!seen.has(t)) { seen.add(t); terms.push(t) } } for (const cp of proj.couplings) { @@ -120,7 +129,7 @@ export function systemHamiltonianLatex(proj: SystemProjection): string | undefin } if (terms.length === 0) return undefined for (const c of proj.components) { - const t = driveTerm(c.role) + const t = driveTerm(c) if (!seen.has(t)) { seen.add(t) terms.push(t) @@ -129,6 +138,145 @@ export function systemHamiltonianLatex(proj: SystemProjection): string | undefin return "\\hat H/\\hbar = " + terms.join(" + ") } +// --- 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 + /** False → the param does not exist in THIS component; the row is dropped + * entirely rather than shown as an unanswered question. */ + applies?: (c: ComponentRow) => boolean +} + +/** 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 ROLE actually has, in card order. An unrecognized role expects + * NOTHING — it shows only what was recorded, which is the honest floor for a + * platform we have no model for. */ +const ROLE_PARAMS: Record = { + qubit: [ + { keys: ["omega", "frequency", "f01"], label: "frequency", sym: "ω" }, + { + keys: ["delta", "alpha", "anharmonicity"], + label: "anharmonicity", + sym: "δ", + // A two-level qubit has no third level to be anharmonic against. + applies: (c) => c.levels === undefined || c.levels > 2, + }, + { keys: ["drive_max"], label: "drive bound", sym: "|u|", prefix: "≤ " }, + ], + atom: [ + { keys: ["Delta", "detuning", "Delta_max"], label: "detuning", sym: "Δ" }, + { keys: ["Omega", "Omega_max", "rabi", "drive_max"], label: "rabi drive", sym: "Ω", prefix: "≤ " }, + ], + cavity: MODE_PARAMS, + resonator: MODE_PARAMS, + mode: 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 role 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. Never throws. */ +export function componentPhysicsRows(c: ComponentRow): PhysicsRow[] { + const spec = ROLE_PARAMS[c.role] ?? [] + 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) { + if (s.applies && !s.applies(c)) continue + 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 rows +} + export function systemTableModel(proj: SystemProjection): TableModel { return { components: proj.components.map((c) => ({ diff --git a/packages/ui/src/amicode/system-view.tsx b/packages/ui/src/amicode/system-view.tsx index 36867abcf..f2244cdd5 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, systemHamiltonianLatex, 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,11 +21,6 @@ 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())) @@ -44,39 +30,19 @@ export function SystemComposite(props: { entity: Record }) { }) // 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) : [] }) 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}
@@ -129,7 +95,7 @@ export function SystemComposite(props: { entity: Record }) {
{r.label}
-
+
{r.value} {(s) => {s()}}
From 4e6489488dbbbfcfb841bfb8b00ed88e44503dac Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 28 Jul 2026 17:05:51 -0400 Subject: [PATCH 2/4] feat(amicode): extend the system card's physics rows Continues 0793fd62. Committed on the author's behalf as part of folding the worktree's in-progress work onto the branch; the content is theirs. +169 lines of tests alongside +278 of implementation; ui suite 442 -> 448 pass. --- packages/ui/src/amicode/system-render.test.ts | 169 ++++++++++- packages/ui/src/amicode/system-render.ts | 278 ++++++++++++++---- 2 files changed, 383 insertions(+), 64 deletions(-) diff --git a/packages/ui/src/amicode/system-render.test.ts b/packages/ui/src/amicode/system-render.test.ts index 5caabbe5e..c20bb3d96 100644 --- a/packages/ui/src/amicode/system-render.test.ts +++ b/packages/ui/src/amicode/system-render.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "bun:test" +import katex from "katex" import { systemProjection } from "./problem" import { systemSchematicModel, @@ -71,7 +72,9 @@ 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 per subsystem + expect(h).toContain("\\varepsilon_{1}(t)") + expect(h).toContain("\\varepsilon_{2}(t)") }) it("returns undefined for a system with no components", () => { expect(systemHamiltonianLatex(systemProjection({ platform: "x", components: [], couplings: [] }))).toBeUndefined() @@ -84,25 +87,96 @@ 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 h = systemHamiltonianLatex(systemProjection(pair))! - expect(h).toContain("C_6") // blockade interaction - expect(h.split("\\Omega(t)")).toHaveLength(2) // drive appears exactly once + }) + 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("a role we have no model for gets a named drift and control, not invented algebra", () => { + const h = systemHamiltonianLatex( + systemProjection({ + platform: "spin", + drive: { arch: "per-component" }, + components: [{ id: "s1", role: "spin-qudit", levels: 4, params: {} }], + couplings: [], + }), + )! + expect(h).toBe("\\hat H/\\hbar = \\hat H_{\\mathrm{drift}} + \\hat H_{\\mathrm{c}}(t)") + expect(h).not.toContain("\\hat a") // no bosonic quadrature drive conjured for it + }) + 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 = { @@ -134,8 +208,83 @@ 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("\\varepsilon_{2}(t)") // quadrature drive on the cavity + }) +}) + +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] + 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 + 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: "x", + ...(arch ? { drive: { arch } } : {}), + components, + couplings: [ + { between: ["q1", "q2"], kind, params: {} }, + ...(third ? [{ between: ["q2", "q3"], kind, params: {} }] : []), + ], + }), + ) + if (!latex) { broken.push(`no output: ${a}/${b}/${kind}`); continue } + checked++ + try { + katex.renderToString(latex, { throwOnError: true }) + } catch (err) { + broken.push(`${a}/${b}/${kind}/${arch}/N${components.length}: ${(err as Error).message}\n ${latex}`) + } + } + expect(checked).toBeGreaterThan(3000) + 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))! + expect(latex).toContain("\\hat H/\\hbar") + expect(() => katex.renderToString(latex, { throwOnError: true })).not.toThrow() + } }) }) diff --git a/packages/ui/src/amicode/system-render.ts b/packages/ui/src/amicode/system-render.ts index 0afb98ea4..9714a33ac 100644 --- a/packages/ui/src/amicode/system-render.ts +++ b/packages/ui/src/amicode/system-render.ts @@ -68,74 +68,239 @@ 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.})", -} +// 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"]) -function driftTerm(c: { role: string; levels?: number; params: Record }): string { +/** The term shape a component contributes; `key` groups sites that share one + * (two linear cavities are one group, a Kerr cavity is its own). */ +function classify(c: ComponentRow): { kind: SiteKind; key: string } { switch (c.role) { + case "atom": + return { kind: "rydberg", key: "rydberg" } case "qubit": - // Keyed off LEVELS, not off whether δ happens to be filled in yet: a - // 3-level transmon whose params are still empty is an anharmonic ladder, - // and rendering it as a bare spin misstates the model the solve will use. - return c.levels === 2 - ? "\\tfrac{\\omega}{2}\\,\\hat\\sigma_z" - : "\\omega\\,\\hat a^\\dagger \\hat a + \\tfrac{\\delta}{2}\\,\\hat a^\\dagger \\hat a^\\dagger \\hat a \\hat a" + // Levels, not param presence: a 3-level transmon with empty params is + // still an anharmonic ladder, and a spin is never one. + return c.levels === 2 ? { kind: "spin", key: "spin" } : { kind: "ladder", key: "qubit" } case "cavity": case "resonator": case "mode": - // A mode's Kerr is genuinely optional (a linear cavity has none), so here - // the recorded params ARE the evidence. - return ["K", "K_c", "K_c_Hz", "kerr"].some((k) => k in c.params) - ? "\\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. Atoms are laser-driven on the |1⟩↔|r⟩ transition - * (3-level Rydberg convention: |0⟩ dark); a strictly two-level component is - * driven in the Pauli basis its drift already uses; everything bosonic or - * bosonic-truncated keeps the quadrature drive. */ -function driveTerm(c: { role: string; levels?: number }): string { - if (c.role === "atom") return "\\tfrac{\\Omega(t)}{2}\\,(|r\\rangle\\langle 1| + \\mathrm{h.c.})" - if (c.role === "qubit" && c.levels === 2) return "u_1(t)\\,\\hat\\sigma_x + u_2(t)\\,\\hat\\sigma_y" - return "\\varepsilon(t)\\,(\\hat a + \\hat a^\\dagger)" +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) + +/** Whether a control carries a site index: a GLOBAL drive is one knob shared by + * every site, per-component is an independent knob each, zoned is one per zone. + * That distinction is the whole difference between a global-drive CZ and a + * locally-addressed one, and the card claims it in a badge. */ +const control = (i: string, arch?: string) => + !i || arch === "global" ? "" : arch === "zoned" ? `_{z(${i})}` : `_{${i}}` + +/** 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" } } -/** 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) - if (!seen.has(t)) { seen.add(t); terms.push(t) } +/** 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)` } - 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) } + 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: + // 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}}"}` + } +} + +/** 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": + return wrap(sum, `\\varepsilon${c}(t)\\,(${ann(g[0].letter, i)} + ${cre(g[0].letter, i)})`) + default: + return `${sum}\\hat H_{\\mathrm{c}}${i ? `^{(${i})}` : ""}(t)` } - if (terms.length === 0) return undefined - for (const c of proj.components) { - const t = driveTerm(c) - if (!seen.has(t)) { - seen.add(t) - terms.push(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}`), "") +} + +/** Compose an illustrative Hamiltonian for ANY composite system: one drift and + * one drive per component GROUP (summed over the group's sites) plus one term + * per set of like edges. undefined when there are no components. 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) })) + 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 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) } - return "\\hat H/\\hbar = " + terms.join(" + ") + + 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 ------------------------------------------------------------- @@ -211,8 +376,13 @@ const ROLE_PARAMS: Record = { { keys: ["drive_max"], label: "drive bound", sym: "|u|", prefix: "≤ " }, ], atom: [ - { keys: ["Delta", "detuning", "Delta_max"], label: "detuning", sym: "Δ" }, - { keys: ["Omega", "Omega_max", "rabi", "drive_max"], label: "rabi drive", sym: "Ω", prefix: "≤ " }, + // 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 role, 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: "≤ " }, ], cavity: MODE_PARAMS, resonator: MODE_PARAMS, From e4e013333f662f5f7c77eadd606947f251843e86 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 28 Jul 2026 17:45:07 -0400 Subject: [PATCH 3/4] feat(amicode): render the recorded Hamiltonian; stop inferring one off-template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card re-derived the Hamiltonian in the renderer from (role, levels, platform) using hardcoded tables, so it could only ever be right for platforms someone had hardcoded. Two separate leaks put the transmon model on a spin qubit: - `platformDefaultRole` maps every unfamiliar platform to "qubit", so an exchange-only HRL spin qubit arrived indistinguishable from a transmon. - levels >= 3 was read as "anharmonic ladder". It is not. Three levels is a Hilbert-space DIMENSION: an exchange-only qubit at levels=3 is three dots, a spin-1 defect is three Zeeman sublevels. Neither is an oscillator. So the platform is now the only thing that licenses a ladder for a `qubit` role (transmon, bosonic). levels=2 still earns the generic two-level model, which is safe on any platform — every two-level system has an ω σ_z/2 splitting and σx/σy control. Everything else is opaque. Recorded beats inferred. `systemHamiltonian()` returns {latex, source}: a model recorded on the entity (amicode_set_model's `hamiltonian`, see harmoniqs/amicode#220) renders verbatim; otherwise the canonical form for the platform renders under an "inferred · confirm or correct" label. When nothing is recorded AND nothing is modelled the card says "not recorded" rather than showing `Ĥ_drift + Ĥ_c(t)`, which is true of every control problem ever posed and would occupy the slot the real model belongs in. Mixed systems still get a named placeholder per unmodelled component. One `classify()` now drives both the equation and the physics rows, so the two halves of the card cannot describe different physics again. Also fixes a three-way drift found while doing this: the ladder drive was a single `ε(t)(â+â†)` while the plugin's TRANSMON_LATEX (shown in chat) and Piccolo's n_drives = 2 both say two quadratures. Two is correct; the card now matches. Verified: 399 pass across 30 files, tsgo clean. The KaTeX sweep now crosses the platform axis too (8k systems) and asserts BOTH directions: no output when nothing is modelled, and never a model invented for an all-unmodelled system. Co-Authored-By: Claude Opus 5 --- packages/ui/src/amicode/amicode.css | 25 ++ packages/ui/src/amicode/problem.ts | 30 ++ packages/ui/src/amicode/system-render.test.ts | 273 +++++++++++++++--- packages/ui/src/amicode/system-render.ts | 159 +++++++--- packages/ui/src/amicode/system-view.tsx | 39 ++- 5 files changed, 431 insertions(+), 95 deletions(-) diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index cf2066cb3..0d158546b 100644 --- a/packages/ui/src/amicode/amicode.css +++ b/packages/ui/src/amicode/amicode.css @@ -760,6 +760,31 @@ [data-component="amicode-system-view"] .amc-c-lvl { color: var(--v2-text-text-muted); font-size: 0.8rem; } [data-component="amicode-system-view"] .amc-c-params { color: var(--v2-text-text-base); font-size: 0.8rem; } [data-component="amicode-system-view"] .amc-ev-formula { margin-top: 4px; } +/* Provenance on a section header. A RECORDED Hamiltonian is shown bare; an + inferred one — the canonical form for the platform, which nobody stated — + has to say so, or the card is asserting physics it guessed. Sits before the + ::after rule so the hairline still fills the remaining width. */ +[data-component="amicode-entity-view"] .amc-ev-sec-note { + font-size: 9px; + letter-spacing: 0.04em; + text-transform: none; + color: var(--v2-text-text-faint); + font-style: italic; +} +/* The "no model recorded" placeholder reads as a prompt, not as an equation. */ +[data-component="amicode-system-view"] .amc-ev-formula.is-empty { + color: var(--v2-text-text-faint); + font-size: 0.82rem; + font-style: italic; + background: transparent; + border-style: dashed; +} +/* Conventions the recorded terms assume (frame, units, basis ordering). */ +[data-component="amicode-system-view"] .amc-ev-formula-note { + margin: -2px 4px 4px; + font-size: 0.72rem; + color: var(--v2-text-text-faint); +} /* ---- Formulation hero (spec §6.2): mode badges + objective + constraints ---- */ [data-component="amicode-formulation-view"] { display: flex; flex-direction: column; gap: 8px; } diff --git a/packages/ui/src/amicode/problem.ts b/packages/ui/src/amicode/problem.ts index 7fa599346..65f089da6 100644 --- a/packages/ui/src/amicode/problem.ts +++ b/packages/ui/src/amicode/problem.ts @@ -513,11 +513,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 +537,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 +579,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 c20bb3d96..73c28d79c 100644 --- a/packages/ui/src/amicode/system-render.test.ts +++ b/packages/ui/src/amicode/system-render.test.ts @@ -5,6 +5,7 @@ import { systemSchematicModel, systemTableModel, systemHamiltonianLatex, + systemHamiltonian, systemIdentityLine, systemCountLabel, componentPhysicsRows, @@ -61,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 } }, @@ -72,9 +73,11 @@ describe("systemHamiltonianLatex", () => { const h = systemHamiltonianLatex(systemProjection(cavQubit))! expect(h).toContain("\\hat H/\\hbar") expect(h).toContain("\\chi") // the dispersive interaction term - // per-component drive → one independently indexed control per subsystem - expect(h).toContain("\\varepsilon_{1}(t)") - expect(h).toContain("\\varepsilon_{2}(t)") + // 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() @@ -151,17 +154,54 @@ describe("systemHamiltonianLatex", () => { 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("a role we have no model for gets a named drift and control, not invented algebra", () => { + 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: [], + } + 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: "spin", + platform: "hybrid", drive: { arch: "per-component" }, - components: [{ id: "s1", role: "spin-qudit", levels: 4, params: {} }], + components: [ + { id: "q1", role: "atom", levels: 3, params: {} }, + { id: "s1", role: "spin-qudit", levels: 4, params: {} }, + ], couplings: [], }), )! - expect(h).toBe("\\hat H/\\hbar = \\hat H_{\\mathrm{drift}} + \\hat H_{\\mathrm{c}}(t)") - expect(h).not.toContain("\\hat a") // no bosonic quadrature drive conjured for it + 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( @@ -209,7 +249,87 @@ describe("systemHamiltonianLatex", () => { } const h = systemHamiltonianLatex(systemProjection(mixed))! expect(h).toContain("\\Omega_{1}(t)") // laser Rabi on the atom - expect(h).toContain("\\varepsilon_{2}(t)") // quadrature drive on the cavity + 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() }) }) @@ -221,6 +341,12 @@ describe("systemHamiltonianLatex — exhaustive sweep", () => { 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: {} } @@ -232,32 +358,44 @@ describe("systemHamiltonianLatex — exhaustive sweep", () => { it("every expressible system renders parseable KaTeX", () => { const broken: string[] = [] let checked = 0 - 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: "x", - ...(arch ? { drive: { arch } } : {}), - components, - couplings: [ - { between: ["q1", "q2"], kind, params: {} }, - ...(third ? [{ between: ["q2", "q3"], kind, params: {} }] : []), - ], - }), - ) - if (!latex) { broken.push(`no output: ${a}/${b}/${kind}`); continue } - checked++ - try { - katex.renderToString(latex, { throwOnError: true }) - } catch (err) { - broken.push(`${a}/${b}/${kind}/${arch}/N${components.length}: ${(err as Error).message}\n ${latex}`) + 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++ + try { + katex.renderToString(latex, { throwOnError: true }) + } catch (err) { + broken.push( + `${platform}/${a}/${b}/${kind}/${arch}/N${components.length}: ${(err as Error).message}\n ${latex}`, + ) + } } - } - expect(checked).toBeGreaterThan(3000) + expect(checked).toBeGreaterThan(6000) expect(broken).toEqual([]) }) @@ -281,7 +419,9 @@ describe("systemHamiltonianLatex — exhaustive sweep", () => { couplings: [{ between: ["q1", "a1", "m1"], kind: "mode-mediated", params: {} }] }, ] for (const c of cases) { - const latex = systemHamiltonianLatex(systemProjection(c as any))! + 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() } @@ -299,8 +439,9 @@ describe("systemTableModel", () => { }) describe("componentPhysicsRows", () => { - const labels = (c: any) => componentPhysicsRows(c).map((r) => r.label) - const row = (c: any, label: string) => componentPhysicsRows(c).find((r) => r.label === label) + 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: {} } @@ -312,15 +453,57 @@ describe("componentPhysicsRows", () => { 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)).toEqual(["levels", "frequency", "anharmonicity", "drive bound", "decay"]) - expect(row(q, "frequency")).toMatchObject({ value: "4.8", state: "recorded" }) - expect(row(q, "drive bound")).toMatchObject({ value: "not set", state: "missing" }) + 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"]) @@ -330,8 +513,8 @@ describe("componentPhysicsRows", () => { 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")!.value).toBe("4.8 GHz") - expect(row(c, "drive bound")!.value).toBe("≤ 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", () => { @@ -355,8 +538,8 @@ describe("componentPhysicsRows", () => { 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")!.state).toBe("missing") - expect(row(c, "decay")).toMatchObject({ value: "T₁ 30 · T₂ 20", state: "recorded" }) + expect(row(c, "frequency", "transmon")!.state).toBe("missing") + expect(row(c, "decay", "transmon")).toMatchObject({ value: "T₁ 30 · T₂ 20", state: "recorded" }) }) }) diff --git a/packages/ui/src/amicode/system-render.ts b/packages/ui/src/amicode/system-render.ts index 9714a33ac..c97f311c6 100644 --- a/packages/ui/src/amicode/system-render.ts +++ b/packages/ui/src/amicode/system-render.ts @@ -13,7 +13,10 @@ export function systemCountLabel(proj: SystemProjection): string | undefined { 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")) - const role = roles.size === 1 ? [...roles][0] : "component" + // "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 } @@ -86,16 +89,35 @@ 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). */ -function classify(c: ComponentRow): { kind: SiteKind; key: string } { + * (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": - // Levels, not param presence: a 3-level transmon with empty params is - // still an anharmonic ladder, and a spin is never one. - return c.levels === 2 ? { kind: "spin", key: "spin" } : { kind: "ladder", key: "qubit" } + // 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": @@ -112,12 +134,17 @@ 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) -/** Whether a control carries a site index: a GLOBAL drive is one knob shared by - * every site, per-component is an independent knob each, zoned is one per zone. - * That distinction is the whole difference between a global-drive CZ and a - * locally-addressed one, and the card claims it in a badge. */ -const control = (i: string, arch?: string) => - !i || arch === "global" ? "" : arch === "zoned" ? `_{z(${i})}` : `_{${i}}` +/** 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 @@ -180,8 +207,17 @@ function driveLatex(g: Site[], total: number, arch?: string): string { 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": - return wrap(sum, `\\varepsilon${c}(t)\\,(${ann(g[0].letter, i)} + ${cre(g[0].letter, 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)` } @@ -263,14 +299,51 @@ function joinTerms(terms: string[]): string { return terms.reduce((acc, t) => (!acc ? t : t.startsWith("-") ? `${acc} - ${t.slice(1).trimStart()}` : `${acc} + ${t}`), "") } -/** Compose an illustrative Hamiltonian for ANY composite system: one drift and - * one drive per component GROUP (summed over the group's sites) plus one term - * per set of like edges. undefined when there are no components. Never throws. */ +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) })) + 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()] @@ -297,6 +370,12 @@ export function systemHamiltonianLatex(proj: SystemProjection): string | undefin ;(edges.get(key) ?? edges.set(key, []).get(key)!).push(edge) } + // 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))) @@ -344,9 +423,6 @@ type ParamSpec = { sym: string /** Rendered before the number ("≤ " for a bound). */ prefix?: string - /** False → the param does not exist in THIS component; the row is dropped - * entirely rather than shown as an unanswered question. */ - applies?: (c: ComponentRow) => boolean } /** Units are never assumed: transmon params are GHz, the Rydberg templates work @@ -360,33 +436,34 @@ const MODE_PARAMS: ParamSpec[] = [ { keys: ["kappa"], label: "linewidth", sym: "κ" }, ] -/** Params each ROLE actually has, in card order. An unrecognized role expects - * NOTHING — it shows only what was recorded, which is the honest floor for a - * platform we have no model for. */ -const ROLE_PARAMS: Record = { +/** 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: "δ", - // A two-level qubit has no third level to be anharmonic against. - applies: (c) => c.levels === undefined || c.levels > 2, - }, + { keys: ["delta", "alpha", "anharmonicity"], label: "anharmonicity", sym: "δ" }, { keys: ["drive_max"], label: "drive bound", sym: "|u|", prefix: "≤ " }, ], - atom: [ + 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 role, so a transmon's δ can never reach + // 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: "≤ " }, ], - cavity: MODE_PARAMS, - resonator: MODE_PARAMS, mode: MODE_PARAMS, + "mode-kerr": MODE_PARAMS, } /** First recorded key matching any alias. A zero keeps the old "0 means unset" @@ -403,18 +480,18 @@ function matchParam(params: Record, keys: string[]) { return undefined } -/** Rows for ONE component: levels, the params its role actually has (unanswered +/** 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. Never throws. */ -export function componentPhysicsRows(c: ComponentRow): PhysicsRow[] { - const spec = ROLE_PARAMS[c.role] ?? [] + * 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) { - if (s.applies && !s.applies(c)) continue const hit = matchParam(c.params, s.keys) if (hit) claimed.add(hit.key) rows.push({ diff --git a/packages/ui/src/amicode/system-view.tsx b/packages/ui/src/amicode/system-view.tsx index f2244cdd5..c82749edc 100644 --- a/packages/ui/src/amicode/system-view.tsx +++ b/packages/ui/src/amicode/system-view.tsx @@ -2,7 +2,7 @@ import { For, Show, createMemo } from "solid-js" import katex from "katex" import { systemProjection } from "./problem" import { formatSci } from "./facets" -import { systemTableModel, systemHamiltonianLatex, systemCountLabel, componentPhysicsRows, PARAM_SYMBOL } 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 the physics spec for the component's ROLE. Params the @@ -25,14 +25,14 @@ 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 = table().components[0] - return c ? componentPhysicsRows(c) : [] + return c ? componentPhysicsRows(c, proj().platform) : [] }) return ( @@ -47,13 +47,34 @@ export function SystemComposite(props: { entity: Record }) {
- - {(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()}
}
)} From 2b809817b3e5d273d33e70ce178512a701ffab0c Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 28 Jul 2026 21:10:34 -0400 Subject: [PATCH 4/4] =?UTF-8?q?test(amicode):=20dedupe=20the=20KaTeX=20swe?= =?UTF-8?q?ep=20=E2=80=94=20render=20each=20distinct=20equation=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep enumerates ~8k systems, but they collapse onto a few hundred distinct equations, and KaTeX is the expensive part. Rendering the same string 200 times proves nothing and took 22s on CI against a 5s default timeout (it passed locally on a fast machine, and I had only been running src/amicode — CI runs all of src). Coverage is unchanged: every distinct output is still parsed, and the mapping back to a producing system is kept for the failure message. Adds an assertion that the sweep really does vary its output, so a future change that collapses everything to one equation can't quietly pass. Co-Authored-By: Claude Opus 5 --- packages/ui/src/amicode/system-render.test.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/amicode/system-render.test.ts b/packages/ui/src/amicode/system-render.test.ts index 73c28d79c..03aef0b10 100644 --- a/packages/ui/src/amicode/system-render.test.ts +++ b/packages/ui/src/amicode/system-render.test.ts @@ -358,6 +358,10 @@ describe("systemHamiltonianLatex — exhaustive sweep", () => { 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) @@ -387,15 +391,18 @@ describe("systemHamiltonianLatex — exhaustive sweep", () => { if (unmodelled(a, platform) && unmodelled(b, platform)) broken.push(`invented a model for ${platform}/${a}/${b}/${kind}: ${latex}`) checked++ - try { - katex.renderToString(latex, { throwOnError: true }) - } catch (err) { - broken.push( - `${platform}/${a}/${b}/${kind}/${arch}/N${components.length}: ${(err as Error).message}\n ${latex}`, - ) - } + 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([]) })