diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index cf2066cb33..0d158546b2 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 7fa5993469..65f089da65 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 edc873f687..03aef0b100 100644 --- a/packages/ui/src/amicode/system-render.test.ts +++ b/packages/ui/src/amicode/system-render.test.ts @@ -1,6 +1,15 @@ import { describe, it, expect } from "bun:test" +import katex from "katex" import { systemProjection } from "./problem" -import { systemSchematicModel, systemTableModel, systemHamiltonianLatex, systemIdentityLine } from "./system-render" +import { + systemSchematicModel, + systemTableModel, + systemHamiltonianLatex, + systemHamiltonian, + systemIdentityLine, + systemCountLabel, + componentPhysicsRows, +} from "./system-render" const twoTransmon = { platform: "transmon", @@ -53,7 +62,7 @@ describe("systemSchematicModel", () => { describe("systemHamiltonianLatex", () => { it("composes drift + coupling + drive for a cavity+qubit dispersive system", () => { const cavQubit = { - platform: "cavity", + platform: "transmon", drive: { arch: "per-component" }, components: [ { id: "q1", role: "qubit", levels: 3, params: { omega: 4, delta: -0.2 } }, @@ -64,7 +73,11 @@ describe("systemHamiltonianLatex", () => { const h = systemHamiltonianLatex(systemProjection(cavQubit))! expect(h).toContain("\\hat H/\\hbar") expect(h).toContain("\\chi") // the dispersive interaction term - expect(h).toContain("\\varepsilon(t)") // drive term + // per-component drive → one independently indexed control PAIR per subsystem + // (two quadratures — Piccolo's n_drives = 2, matching the plugin's TRANSMON_LATEX) + expect(h).toContain("u_{1,1}(t)") + expect(h).toContain("i\\,u_{2,1}(t)") + expect(h).toContain("u_{1,2}(t)") }) it("returns undefined for a system with no components", () => { expect(systemHamiltonianLatex(systemProjection({ platform: "x", components: [], couplings: [] }))).toBeUndefined() @@ -77,25 +90,152 @@ describe("systemHamiltonianLatex", () => { couplings: [], } const h = systemHamiltonianLatex(systemProjection(rydberg))! - expect(h).toContain("-\\Delta\\,|r\\rangle\\langle r|") // detuning on the Rydberg level, not -Δ n̂ + // n̂ = |r⟩⟨r|, the same operator the vdW term uses — one notation per operator + expect(h).toContain("-\\Delta\\,\\hat n") expect(h).toContain("\\Omega(t)") // laser Rabi drive expect(h).toContain("|r\\rangle\\langle 1|") expect(h).not.toContain("\\varepsilon(t)") // no cavity-style drive on a bare atom expect(h).not.toContain("\\hat a") // no bosonic ladder operators at all + expect(h).not.toContain("\\sum") // N=1 carries no index clutter }) - it("two rydberg atoms + vdW → single deduped drift/drive pair + blockade term", () => { - const pair = { + it("N atoms sum over sites — the register size is IN the equation", () => { + const atoms = (n: number) => ({ platform: "rydberg", drive: { arch: "global" }, + components: Array.from({ length: n }, (_, i) => ({ id: `q${i + 1}`, role: "atom", levels: 3, params: {} })), + couplings: Array.from({ length: n - 1 }, (_, i) => ({ + between: [`q${i + 1}`, `q${i + 2}`], + kind: "vdW", + params: {}, + })), + }) + const two = systemHamiltonianLatex(systemProjection(atoms(2)))! + const three = systemHamiltonianLatex(systemProjection(atoms(3)))! + // the bug this replaces: 1, 2 and 20 atoms all rendered the same string + expect(two).not.toBe(systemHamiltonianLatex(systemProjection(atoms(1)))!) + expect(two).toContain("-\\Delta\\,\\sum_i \\hat n_{i}") + expect(two).toContain("\\tfrac{C_6}{r_{12}^6}\\,\\hat n_{1} \\hat n_{2}") // one edge → real site ids + expect(three).toContain("\\sum_{\\langle ij\\rangle} \\tfrac{C_6}{r_{ij}^6}") // two edges → sum over pairs + expect(two.split("\\Omega(t)")).toHaveLength(2) // one global control, applied to every site + }) + it("drive architecture reaches the equation: global shares one control, per-site indexes it", () => { + const pair = (arch: string) => ({ + platform: "rydberg", + drive: { arch }, components: [ { id: "q1", role: "atom", levels: 3, params: {} }, { id: "q2", role: "atom", levels: 3, params: {} }, ], couplings: [{ between: ["q1", "q2"], kind: "vdW", params: {} }], + }) + const global = systemHamiltonianLatex(systemProjection(pair("global")))! + const per = systemHamiltonianLatex(systemProjection(pair("per-component")))! + const zoned = systemHamiltonianLatex(systemProjection(pair("zoned")))! + expect(global).toContain("\\Omega(t)") // one knob for the whole register + expect(per).toContain("\\Omega_{i}(t)") // one knob per atom + expect(zoned).toContain("\\Omega_{z(i)}(t)") // one knob per zone + expect(new Set([global, per, zoned]).size).toBe(3) // the badge is not decoration + expect(global).toContain("-\\Delta\\,\\sum_i") // Δ is the laser's, so it follows the drive + expect(per).toContain("\\Delta_{i}") + }) + it("a qubit and a cavity never share an operator symbol", () => { + const h = systemHamiltonianLatex( + systemProjection({ + platform: "transmon", + drive: { arch: "per-component" }, + components: [ + { id: "q1", role: "qubit", levels: 3, params: {} }, + { id: "c1", role: "cavity", levels: 10, params: {} }, + ], + couplings: [{ between: ["q1", "c1"], kind: "dispersive-chi", params: {} }], + }), + )! + expect(h).toContain("\\hat a^\\dagger_{1} \\hat a_{1}") // qubit ladder + expect(h).toContain("\\hat b^\\dagger_{2} \\hat b_{2}") // cavity gets its OWN letter + expect(h).toContain("\\chi\\,\\hat b^\\dagger_{2} \\hat b_{2}\\,\\hat n_{1}") + }) + it("an off-template platform infers NOTHING rather than the transmon ladder", () => { + // The reported bug: "hrl style spin qubit" → role defaults to qubit, levels + // unstated, and the card asserted ω â†â + δ/2 ↲Ⲡ+ ε(t)(â + â†). There is + // no honest fallback here — `Ĥ_drift + Ĥ_c(t)` is true of every control + // problem ever posed — so the slot stays empty until someone records one. + const hrl = { + platform: "hrl-spin", + drive: { arch: "per-component" }, + components: [{ id: "q1", role: "other", params: {} }], + couplings: [], } - const h = systemHamiltonianLatex(systemProjection(pair))! - expect(h).toContain("C_6") // blockade interaction - expect(h.split("\\Omega(t)")).toHaveLength(2) // drive appears exactly once + expect(systemHamiltonianLatex(systemProjection(hrl))).toBeUndefined() + // …a role defaulted to "qubit" by an unrecognized platform is the same case… + expect( + systemHamiltonianLatex(systemProjection({ ...hrl, components: [{ id: "q1", role: "qubit", params: {} }] })), + ).toBeUndefined() + // …and so is one where the researcher HAS stated a level count. Three levels + // on an exchange-only qubit is three dots, not an anharmonic ladder. + expect( + systemHamiltonianLatex( + systemProjection({ ...hrl, components: [{ id: "q1", role: "qubit", levels: 3, params: { drive_max: 1 } }] }), + ), + ).toBeUndefined() + // the same entity on a platform we DO model keeps its ladder + const transmon = systemHamiltonianLatex( + systemProjection({ ...hrl, platform: "transmon", components: [{ id: "q1", role: "qubit", params: {} }] }), + )! + expect(transmon).toContain("\\tfrac{\\delta}{2}") + }) + + it("an unmodelled component in a MIXED system is a placeholder, not a hole", () => { + // Here there IS something to say, so the modelled parts render and the + // unknown one gets a named term rather than invented algebra. + const h = systemHamiltonianLatex( + systemProjection({ + platform: "hybrid", + drive: { arch: "per-component" }, + components: [ + { id: "q1", role: "atom", levels: 3, params: {} }, + { id: "s1", role: "spin-qudit", levels: 4, params: {} }, + ], + couplings: [], + }), + )! + expect(h).toContain("\\hat H_{\\mathrm{drift}}^{(2)}") + expect(h).toContain("\\hat H_{\\mathrm{c}}^{(2)}(t)") + expect(h).toContain("|r\\rangle\\langle 1|_{1}") // the atom still renders properly + expect(h).not.toContain("\\hat a") // no bosonic algebra conjured for the qudit + }) + it("a term carrying its own minus sign is joined with −, not '+ -'", () => { + const h = systemHamiltonianLatex( + systemProjection({ + platform: "hybrid", + drive: { arch: "per-component" }, + components: [ + { id: "c1", role: "cavity", levels: 10, params: {} }, + { id: "a1", role: "atom", levels: 3, params: {} }, + ], + couplings: [], + }), + )! + expect(h).not.toContain("+ -") + expect(h).toContain(" - \\Delta_{2}") + }) + it("levels, not param presence, picks the qubit model: 3-level ladder vs 2-level spin", () => { + const ladder = { + platform: "transmon", + drive: { arch: "per-component" }, + // params still empty — the model is a 3-level ladder regardless + components: [{ id: "q1", role: "qubit", levels: 3, params: {} }], + couplings: [], + } + const h = systemHamiltonianLatex(systemProjection(ladder))! + expect(h).toContain("\\tfrac{\\delta}{2}") + expect(h).not.toContain("\\hat\\sigma_z") + + const spin = systemHamiltonianLatex( + systemProjection({ ...ladder, components: [{ id: "q1", role: "qubit", levels: 2, params: {} }] }), + )! + expect(spin).toContain("\\hat\\sigma_z") + expect(spin).toContain("\\hat\\sigma_x") // driven in the Pauli basis… + expect(spin).not.toContain("\\hat a") // …not on a bosonic quadrature }) it("mixed atom + cavity → both drive flavors", () => { const mixed = { @@ -108,8 +248,190 @@ describe("systemHamiltonianLatex", () => { couplings: [], } const h = systemHamiltonianLatex(systemProjection(mixed))! - expect(h).toContain("\\Omega(t)") - expect(h).toContain("\\varepsilon(t)") + expect(h).toContain("\\Omega_{1}(t)") // laser Rabi on the atom + expect(h).toContain("u_{1,2}(t)") // quadrature drive on the cavity + }) +}) + +describe("systemHamiltonian — recorded beats inferred", () => { + // The architectural point: the agent knows what an exchange-only spin qubit + // is; the fallback table never will. When the model is RECORDED the card + // renders exactly that and stops guessing. + const hrl = { + platform: "hrl-spin", + drive: { arch: "per-component" }, + components: [ + { id: "q1", role: "other", params: {} }, + { id: "q2", role: "other", params: {} }, + { id: "q3", role: "other", params: {} }, + ], + couplings: [ + { between: ["q1", "q2"], kind: "exchange", params: {} }, + { between: ["q2", "q3"], kind: "exchange", params: {} }, + ], + hamiltonian: { + terms: [ + { kind: "coupling", latex: "J_{12}(t)\\,\\vec S_1 \\cdot \\vec S_2", label: "exchange 1–2" }, + { kind: "coupling", latex: "J_{23}(t)\\,\\vec S_2 \\cdot \\vec S_3", label: "exchange 2–3" }, + ], + notes: "encoded qubit in the S=1/2, S_z=-1/2 subspace; exchange-only, no on-site drive", + }, + } + + it("renders the recorded terms verbatim and marks them recorded", () => { + const h = systemHamiltonian(systemProjection(hrl))! + expect(h.source).toBe("recorded") + expect(h.latex).toBe("\\hat H/\\hbar = J_{12}(t)\\,\\vec S_1 \\cdot \\vec S_2 + J_{23}(t)\\,\\vec S_2 \\cdot \\vec S_3") + expect(h.notes).toContain("exchange-only") + expect(() => katex.renderToString(h.latex, { throwOnError: true })).not.toThrow() + }) + + it("orders drift → coupling → drive however they were recorded", () => { + const h = systemHamiltonian( + systemProjection({ + ...hrl, + hamiltonian: { + terms: [ + { kind: "drive", latex: "u(t)\\,\\hat X" }, + { kind: "drift", latex: "\\omega\\,\\hat Z" }, + { kind: "coupling", latex: "J\\,\\hat Z_1\\hat Z_2" }, + ], + }, + }), + )! + expect(h.latex).toBe("\\hat H/\\hbar = \\omega\\,\\hat Z + J\\,\\hat Z_1\\hat Z_2 + u(t)\\,\\hat X") + }) + + it("a recorded term carrying a minus is joined with −, like the inferred path", () => { + const h = systemHamiltonian( + systemProjection({ + ...hrl, + hamiltonian: { terms: [{ kind: "drift", latex: "\\omega\\,\\hat Z" }, { kind: "drift", latex: "-\\Delta\\,\\hat n" }] }, + }), + )! + expect(h.latex).not.toContain("+ -") + expect(h.latex).toContain(" - \\Delta") + }) + + it("falls back to the inferred form, labelled, when nothing is recorded", () => { + const h = systemHamiltonian(systemProjection(twoTransmon))! + expect(h.source).toBe("inferred") + expect(h.latex).toBe(systemHamiltonianLatex(systemProjection(twoTransmon))!) + }) + + it("nothing recorded and nothing modelled → undefined, so the card can say so", () => { + expect(systemHamiltonian(systemProjection({ ...hrl, hamiltonian: undefined }))).toBeUndefined() + }) + + it("junk terms are dropped rather than rendered as holes", () => { + const junk = (terms: unknown) => systemHamiltonian(systemProjection({ ...hrl, hamiltonian: { terms } } as any)) + expect(junk([{ kind: "drift" }, { kind: "drift", latex: " " }])).toBeUndefined() // → falls through + expect(junk([{ kind: "drift", latex: "\\omega\\,\\hat Z" }, { latex: 42 }])!.latex).toBe( + "\\hat H/\\hbar = \\omega\\,\\hat Z", + ) + expect(junk("not an array")).toBeUndefined() + }) +}) + +describe("systemHamiltonianLatex — exhaustive sweep", () => { + // Every role × every coupling kind × every drive arch × N ∈ {2,3}. The card + // renders this straight into KaTeX, so an unparseable string is a visible + // error box in the transcript; a composer that special-cases roles and edge + // shapes needs the whole product space swept, not a handful of examples. + const ROLES = ["qubit2", "qubit3", "atom", "cavity", "cavityK", "resonator", "mode", "unmodeled"] + const KINDS = ["exchange", "ZZ", "cross-resonance", "dispersive-chi", "vdW", "mode-mediated", "not-a-kind"] + const ARCHES = ["global", "per-component", "zoned", undefined] + // Platform is load-bearing: it is the only thing that licenses a ladder for a + // `qubit` role, so the sweep has to cross both sides of that line. + const PLATFORMS = ["transmon", "exchange-only-spin"] + const LADDER = new Set(["transmon", "bosonic"]) + /** No model → no terms. The only two ways to get there. */ + const unmodelled = (r: string, p: string) => r === "unmodeled" || (r === "qubit3" && !LADDER.has(p)) + const mk = (r: string, i: number) => + r === "qubit2" ? { id: `q${i}`, role: "qubit", levels: 2, params: {} } + : r === "qubit3" ? { id: `q${i}`, role: "qubit", levels: 3, params: {} } + : r === "atom" ? { id: `q${i}`, role: "atom", levels: 3, params: {} } + : r === "cavityK" ? { id: `q${i}`, role: "cavity", levels: 10, params: { K_c_Hz: 3 } } + : r === "unmodeled" ? { id: `q${i}`, role: "flux-tunable-thingy", levels: 4, params: { foo: 1 } } + : { id: `q${i}`, role: r, levels: 10, params: {} } + + it("every expressible system renders parseable KaTeX", () => { + const broken: string[] = [] + let checked = 0 + // Render each DISTINCT output once. The sweep enumerates ~8k systems but they + // collapse onto far fewer equations, and KaTeX is the expensive part — + // rendering the same string 200 times proves nothing and timed out CI. + const distinct = new Map() + for (const platform of PLATFORMS) + for (const a of ROLES) + for (const b of ROLES) + for (const kind of KINDS) + for (const arch of ARCHES) + for (const third of [false, true]) { + const components = [mk(a, 1), mk(b, 2), ...(third ? [mk(b, 3)] : [])] + const latex = systemHamiltonianLatex( + systemProjection({ + platform, + ...(arch ? { drive: { arch } } : {}), + components, + couplings: [ + { between: ["q1", "q2"], kind, params: {} }, + ...(third ? [{ between: ["q2", "q3"], kind, params: {} }] : []), + ], + }), + ) + // No output is the CORRECT answer when nothing in the system has + // a model — there is no honest canonical form to fall back to. + if (!latex) { + if (!unmodelled(a, platform) || !unmodelled(b, platform)) + broken.push(`no output: ${platform}/${a}/${b}/${kind}`) + continue + } + // …and conversely, an all-unmodelled system must NOT produce one. + if (unmodelled(a, platform) && unmodelled(b, platform)) + broken.push(`invented a model for ${platform}/${a}/${b}/${kind}: ${latex}`) + checked++ + if (!distinct.has(latex)) + distinct.set(latex, `${platform}/${a}/${b}/${kind}/${arch}/N${components.length}`) + } + for (const [latex, where] of distinct) { + try { + katex.renderToString(latex, { throwOnError: true }) + } catch (err) { + broken.push(`${where}: ${(err as Error).message}\n ${latex}`) + } + } + expect(checked).toBeGreaterThan(6000) + expect(distinct.size).toBeGreaterThan(100) // the sweep really does vary the output + expect(broken).toEqual([]) + }) + + it("survives malformed input without throwing", () => { + const cases = [ + // coupling naming a component that doesn't exist + { platform: "x", components: [{ id: "q1", role: "atom", levels: 3, params: {} }], + couplings: [{ between: ["q1", "GHOST"], kind: "vdW", params: {} }] }, + // a one-ended coupling + { platform: "x", components: [{ id: "q1", role: "qubit", levels: 3, params: {} }], + couplings: [{ between: ["q1"], kind: "ZZ", params: {} }] }, + // no levels recorded anywhere + { platform: "x", components: [{ id: "q1", role: "qubit", params: {} }], couplings: [] }, + // a mode-mediated hyperedge across three different roles + { platform: "x", + components: [ + { id: "q1", role: "qubit", levels: 3, params: {} }, + { id: "a1", role: "atom", levels: 3, params: {} }, + { id: "m1", role: "mode", levels: 8, params: {} }, + ], + couplings: [{ between: ["q1", "a1", "m1"], kind: "mode-mediated", params: {} }] }, + ] + for (const c of cases) { + const latex = systemHamiltonianLatex(systemProjection(c as any)) + // undefined is allowed (nothing modelled); anything else must be renderable + if (latex === undefined) continue + expect(latex).toContain("\\hat H/\\hbar") + expect(() => katex.renderToString(latex, { throwOnError: true })).not.toThrow() + } }) }) @@ -123,6 +445,119 @@ describe("systemTableModel", () => { }) }) +describe("componentPhysicsRows", () => { + const labels = (c: any, platform?: string) => componentPhysicsRows(c, platform).map((r) => r.label) + const row = (c: any, label: string, platform?: string) => + componentPhysicsRows(c, platform).find((r) => r.label === label) + + it("a rydberg atom is never asked for an anharmonicity — it gets detuning + Rabi", () => { + const atom = { id: "q1", role: "atom", levels: 3, params: {} } + expect(labels(atom)).not.toContain("anharmonicity") + expect(labels(atom)).not.toContain("frequency") + expect(labels(atom)).toEqual(["levels", "detuning", "rabi drive", "decay"]) + expect(row(atom, "detuning")!.state).toBe("missing") + }) + + it("a transmon keeps the frequency/anharmonicity/drive-bound spec", () => { + const q = { id: "q1", role: "qubit", levels: 3, params: { omega: 4.8, delta: -0.2 } } + expect(labels(q, "transmon")).toEqual(["levels", "frequency", "anharmonicity", "drive bound", "decay"]) + expect(row(q, "frequency", "transmon")).toMatchObject({ value: "4.8", state: "recorded" }) + expect(row(q, "drive bound", "transmon")).toMatchObject({ value: "not set", state: "missing" }) + }) + + it("a TWO-level qubit has no anharmonicity row at all", () => { + expect(labels({ id: "q1", role: "qubit", levels: 2, params: {} })).not.toContain("anharmonicity") + }) + + it("an off-template platform is NOT given the transmon model just because role defaults to qubit", () => { + // platformDefaultRole maps every unfamiliar platform to "qubit", so an + // exchange-only HRL-style spin qubit arrives here indistinguishable from a + // transmon by role alone. It used to be handed ω, δ, |u| and the transmon + // Hamiltonian; an exchange-only qubit has no anharmonicity to speak of. + const hrl = { id: "q1", role: "qubit", params: {} } + expect(labels(hrl)).toEqual(["levels"]) // no platform → nothing claimed + expect(componentPhysicsRows(hrl, "hrl-spin").map((r) => r.label)).toEqual(["levels"]) + expect(componentPhysicsRows(hrl, "hrl-spin").map((r) => r.label)).not.toContain("anharmonicity") + // …while a transmon, whose model we do have, still fills in before levels. + expect(componentPhysicsRows(hrl, "transmon").map((r) => r.label)).toEqual([ + "levels", + "frequency", + "anharmonicity", + "drive bound", + "decay", + ]) + }) + + it("two levels earns the generic two-level model on any platform", () => { + // Safe everywhere: every two-level system has a splitting and σx/σy control. + const spin = { id: "q1", role: "qubit", levels: 2, params: {} } + expect(componentPhysicsRows(spin, "hrl-spin").map((r) => r.label)).toEqual([ + "levels", + "frequency", + "drive bound", + "decay", + ]) + expect(componentPhysicsRows(spin, "hrl-spin").map((r) => r.label)).not.toContain("anharmonicity") + }) + + it("THREE levels is a dimension, not an oscillator — it earns no ladder off-template", () => { + // Reported against `exchange-only-spin` at levels=3: the card still showed + // ω â†â + δ/2 ↲Ⲡ+ u₁(â+â†) + i u₂(â−â†) and asked for an anharmonicity. + // An exchange-only qubit at levels=3 is three dots; a spin-1 defect is three + // Zeeman sublevels. Neither is an anharmonic ladder. + const three = { id: "q1", role: "qubit", levels: 3, params: {} } + expect(componentPhysicsRows(three, "exchange-only-spin").map((r) => r.label)).toEqual(["levels"]) + // …and the platform whose qubits ARE ladders still gets one. + expect(componentPhysicsRows(three, "transmon").map((r) => r.label)).toContain("anharmonicity") + }) + + it("an unrecognized role expects nothing — only what was recorded shows", () => { + const spin = { id: "s1", role: "spin", params: { J_MHz: 12 } } + expect(labels(spin)).toEqual(["levels", "J"]) + expect(row(spin, "levels")!.state).toBe("missing") + expect(row(spin, "J")).toMatchObject({ value: "12 MHz", state: "recorded" }) + }) + + it("unit-suffixed keys render their unit; bare keys never get an assumed one", () => { + const c = { id: "q1", role: "qubit", levels: 3, params: { omega_GHz: 4.8, drive_max: 0.2 } } + expect(row(c, "frequency", "transmon")!.value).toBe("4.8 GHz") + expect(row(c, "drive bound", "transmon")!.value).toBe("≤ 0.2") + }) + + it("an atom's Δ does not absorb a transmon's δ — a stray delta stays unclaimed", () => { + const atom = { id: "q1", role: "atom", levels: 3, params: { delta: 0.2 } } + expect(row(atom, "detuning")!.state).toBe("missing") + expect(row(atom, "delta")).toMatchObject({ sym: "δ", value: "0.2", state: "recorded" }) + }) + + it("levels reads 'not set' rather than being silently omitted", () => { + expect(row({ id: "q1", role: "qubit", params: {} }, "levels")).toMatchObject({ + value: "not set", + state: "missing", + }) + }) + + it("a cavity gets frequency/kerr/linewidth, not a drive bound", () => { + const cav = { id: "c1", role: "cavity", levels: 10, params: { K_c_Hz: 3.25 } } + expect(labels(cav)).toEqual(["levels", "frequency", "kerr", "linewidth", "decay"]) + expect(row(cav, "kerr")!.value).toBe("3.25 Hz") + }) + + it("zero still reads as unset, and recorded T₁/T₂ collapse into one decay row", () => { + const c = { id: "q1", role: "qubit", levels: 3, params: { omega: 0, T1: 30, T2: 20 } } + expect(row(c, "frequency", "transmon")!.state).toBe("missing") + expect(row(c, "decay", "transmon")).toMatchObject({ value: "T₁ 30 · T₂ 20", state: "recorded" }) + }) +}) + +describe("systemCountLabel", () => { + it("names N so an unanswered structure question can't read as 'one'", () => { + expect(systemCountLabel(systemProjection(twoTransmon))).toBe("2 qubits × 3 levels") + expect(systemCountLabel(systemProjection({ platform: "rydberg", params: {} }))).toBe("1 atom") + expect(systemCountLabel(systemProjection({ platform: "x", components: [], couplings: [] }))).toBeUndefined() + }) +}) + describe("systemIdentityLine", () => { it("summarizes platform · N role(s) × levels · arch", () => { expect(systemIdentityLine(systemProjection(twoTransmon))).toBe( diff --git a/packages/ui/src/amicode/system-render.ts b/packages/ui/src/amicode/system-render.ts index d9dfed949d..c97f311c65 100644 --- a/packages/ui/src/amicode/system-render.ts +++ b/packages/ui/src/amicode/system-render.ts @@ -2,24 +2,32 @@ // (spec-20260709 §6.1 / plan Task 4). Consumes the existing systemProjection; // no SolidJS. Never throws. import { systemProjection, type SystemProjection } from "./problem" +import { formatSci } from "./facets" + +/** The component-count claim the card is making — "2 atoms × 3 levels", or + * undefined when there are none. Rendered as its own badge so N is something + * the researcher can read and correct, not something they have to infer from + * the card's shape (an unanswered "how many atoms?" used to look like "one"). */ +export function systemCountLabel(proj: SystemProjection): string | undefined { + const comps = proj.components ?? [] + if (comps.length === 0) return undefined + const roles = new Set(comps.map((c) => c.role)) + const levels = new Set(comps.map((c) => c.levels).filter((l): l is number => typeof l === "number")) + // "other" is the honest role for an unclassified subsystem, but "3 others" + // reads as a bug — say what it is structurally instead. + const only = roles.size === 1 ? [...roles][0] : undefined + const role = only === undefined || only === "other" || only === "?" ? "component" : only + const seg = `${comps.length} ${comps.length === 1 ? role : `${role}s`}` + return levels.size === 1 ? `${seg} × ${[...levels][0]} levels` : seg +} /** One-line "what is this system" identity: platform · N role(s) × L levels · * drive. e.g. "rydberg · 2 atoms × 3 levels · global drive". Collapses * the schematic+table into a scannable header line. Never throws. */ export function systemIdentityLine(proj: SystemProjection): string { - const parts: string[] = [] - if (proj.platform) parts.push(proj.platform) - const comps = proj.components ?? [] - if (comps.length > 0) { - const roles = new Set(comps.map((c) => c.role)) - const levels = new Set(comps.map((c) => c.levels).filter((l): l is number => typeof l === "number")) - const role = roles.size === 1 ? [...roles][0] : "component" - let seg = `${comps.length} ${comps.length === 1 ? role : `${role}s`}` - if (levels.size === 1) seg += ` × ${[...levels][0]} levels` - parts.push(seg) - } - if (proj.driveArch) parts.push(`${proj.driveArch} drive`) - return parts.join(" · ") + return [proj.platform, systemCountLabel(proj), proj.driveArch ? `${proj.driveArch} drive` : undefined] + .filter((p): p is string => p !== undefined && p !== "") + .join(" · ") } export type SchematicNode = { id: string; label: string; levels?: number } @@ -63,70 +71,457 @@ export type ComponentRow = { id: string; role: string; levels?: number; params: export type CouplingRow = { between: string[]; kind: string; params: Record } export type TableModel = { components: ComponentRow[]; couplings: CouplingRow[] } -// Composite Hamiltonian LaTeX (spec §6.1 "show the system"): drift per distinct -// component role + interaction per distinct coupling kind + a drive term. -// Illustrative (authoring-aware bookkeeping spirit), not an exact derivation. -const COUPLING_TERM: Record = { - "dispersive-chi": "\\tfrac{\\chi}{2}\\,\\hat a^\\dagger \\hat a\\,\\hat\\sigma_z", - ZZ: "J\\,\\hat\\sigma_z^{(1)}\\hat\\sigma_z^{(2)}", - "cross-resonance": "\\Omega\\,\\hat\\sigma_x^{(1)}\\hat\\sigma_z^{(2)}", - exchange: "g\\,(\\hat a^\\dagger \\hat b + \\hat a \\hat b^\\dagger)", - vdW: "\\tfrac{C_6}{r^6}\\,\\hat n_1 \\hat n_2", - "mode-mediated": "g\\,(\\hat a^\\dagger \\hat b + \\mathrm{h.c.})", -} - -function driftTerm(role: string, params: Record): string { - const anharmonic = ["delta", "K_q", "anharmonicity", "K_c", "K_c_Hz", "kerr"].some((k) => k in params) - switch (role) { +// Composite Hamiltonian LaTeX (spec §6.1 "show the system"), composed over the +// ACTUAL component and edge sets. The previous version deduped term STRINGS over +// the set of distinct roles, which meant a 2-atom register and a 20-atom register +// rendered the identical single-site Hamiltonian, an N-edge chain rendered one +// edge with hardcoded indices (1),(2), a qubit and a cavity in the same system +// both used â, and the drive-arch badge had no counterpart in the equation. +// Still ILLUSTRATIVE — the canonical model per role, not a derivation from the +// recorded numbers — but it has to be the Hamiltonian of THIS system. + +type SiteKind = "spin" | "ladder" | "rydberg" | "opaque" +type Site = { idx: number; role: string; kind: SiteKind; key: string; letter: string } + +/** Distinct bosonic groups get distinct operator letters, so a qubit ladder and + * a cavity in one system are never both â. */ +const LADDER_LETTERS = ["a", "b", "c", "d", "e", "f", "g", "h"] +const KERR_KEYS = ["K", "K_c", "K_c_Hz", "kerr"] +const MODE_ROLES = new Set(["cavity", "resonator", "mode"]) + +/** The ONLY platforms whose `qubit` role is an anharmonic ladder. Nothing else + * may assume one — not the role (the plugin's platformDefaultRole maps every + * unfamiliar platform to "qubit"), and not the level count (three levels is a + * dimension, not an oscillator). Both of those leaks put the transmon + * Hamiltonian, and the transmon's anharmonicity row, on a spin qubit. + * A bosonic MODE is classified by its role instead, so it needs no entry here; + * "bosonic" covers a platform that calls its computational element a qubit. */ +const LADDER_PLATFORMS = new Set(["transmon", "bosonic"]) + +/** The term shape a component contributes; `key` groups sites that share one + * (two linear cavities are one group, a Kerr cavity is its own) and selects the + * physics rows, so the equation and the table can never disagree. */ +function classify(c: ComponentRow, platform?: string): { kind: SiteKind; key: string } { + switch (c.role) { + case "atom": + return { kind: "rydberg", key: "rydberg" } case "qubit": - return anharmonic - ? "\\omega\\,\\hat a^\\dagger \\hat a + \\tfrac{\\delta}{2}\\,\\hat a^\\dagger \\hat a^\\dagger \\hat a \\hat a" - : "\\tfrac{\\omega}{2}\\,\\hat\\sigma_z" + // Two levels is a generic two-level system on ANY platform — every one of + // them has an ω σ_z/2 splitting and σ_x/σ_y control, so that much is safe. + // + // MORE than two levels is NOT evidence of an anharmonic oscillator. It is + // evidence of a Hilbert-space dimension and nothing else: an exchange-only + // spin qubit at levels=3 is three dots, a spin-1 defect is three Zeeman + // sublevels, and neither is a ladder. Only a platform that comes with a + // ladder model may claim one. + if (c.levels === 2) return { kind: "spin", key: "spin" } + return LADDER_PLATFORMS.has((platform ?? "").toLowerCase()) + ? { kind: "ladder", key: "qubit" } + : { kind: "opaque", key: `opaque:${c.role}` } case "cavity": case "resonator": case "mode": - return anharmonic ? "\\omega_c\\,\\hat a^\\dagger \\hat a + \\tfrac{K}{2}\\,\\hat a^{\\dagger 2}\\hat a^2" : "\\omega_c\\,\\hat a^\\dagger \\hat a" - case "atom": - return "-\\Delta\\,|r\\rangle\\langle r|" + // A mode's Kerr is genuinely optional, so here the params ARE the evidence. + return KERR_KEYS.some((k) => k in c.params) ? { kind: "ladder", key: "mode-kerr" } : { kind: "ladder", key: "mode" } default: - return "\\hat H_{\\mathrm{drift}}" + return { kind: "opaque", key: `opaque:${c.role}` } } } -/** Drive term per component role. Atoms are laser-driven on the |1⟩↔|r⟩ - * transition (3-level Rydberg convention: |0⟩ dark); everything bosonic or - * bosonic-truncated keeps the quadrature drive. */ -function driveTerm(role: string): string { - switch (role) { - case "atom": - return "\\tfrac{\\Omega(t)}{2}\\,(|r\\rangle\\langle 1| + \\mathrm{h.c.})" +const ann = (letter: string, i: string) => (i ? `\\hat ${letter}_{${i}}` : `\\hat ${letter}`) +const cre = (letter: string, i: string) => (i ? `\\hat ${letter}^\\dagger_{${i}}` : `\\hat ${letter}^\\dagger`) +const num = (i: string) => (i ? `\\hat n_{${i}}` : "\\hat n") +const pauli = (axis: string, i: string) => (i ? `\\hat\\sigma_${axis}^{(${i})}` : `\\hat\\sigma_${axis}`) +const sub = (sym: string, i: string) => (i ? `${sym}_{${i}}` : sym) + +/** The index a CONTROL carries, bare: "" when one knob is shared by every site + * (a global drive, or a single-component system), the site index when each has + * its own, the zone when they are zoned. That distinction is the whole + * difference between a global-drive CZ and a locally-addressed one, and the + * card claims it in a badge. Bare so callers can compose it either as a + * subscript (`\Omega_{i}`) or into an existing one (`u_{1,i}`). */ +const controlIdx = (i: string, arch?: string) => (!i || arch === "global" ? "" : arch === "zoned" ? `z(${i})` : i) +const control = (i: string, arch?: string) => { + const c = controlIdx(i, arch) + return c ? `_{${c}}` : "" +} + +/** Sum prefix + index token for a group: no index at all in a single-component + * system, a literal site number for a lone member, `\sum_i` when the group is + * every site, else an explicit index set. */ +function indexing(group: Site[], total: number): { sum: string; i: string } { + if (total === 1) return { sum: "", i: "" } + if (group.length === total) return { sum: "\\sum_i ", i: "i" } + if (group.length === 1) return { sum: "", i: String(group[0].idx) } + return { sum: `\\sum_{i \\in \\{${group.map((s) => s.idx).join(",")}\\}} `, i: "i" } +} + +/** Parenthesize a summed body only when it has a top-level `+` — an h.c. inside + * its own parens must not trigger a redundant outer bracket. */ +function wrap(sum: string, body: string): string { + if (!sum) return body + let depth = 0 + for (const ch of body) { + if (ch === "(") depth++ + else if (ch === ")") depth-- + else if (ch === "+" && depth === 0) return `${sum}\\left(${body}\\right)` + } + return `${sum}${body}` +} + +function driftLatex(g: Site[], total: number, arch?: string): string { + const { sum, i } = indexing(g, total) + switch (g[0].kind) { + case "spin": + return wrap(sum, `\\tfrac{${sub("\\omega", i)}}{2}\\,${pauli("z", i)}`) + case "rydberg": { + // Δ is set by the laser, so it is per-site exactly when the drive is. + const c = control(i, arch) + return c ? `-${sum}\\Delta${c}\\,${num(i)}` : `-\\Delta\\,${sum}${num(i)}` + } + case "ladder": { + const L = g[0].letter + const isMode = g[0].key.startsWith("mode") + const w = isMode ? (i ? `\\omega_{c,${i}}` : "\\omega_c") : sub("\\omega", i) + const linear = `${w}\\,${cre(L, i)} ${ann(L, i)}` + if (g[0].key === "mode") return wrap(sum, linear) + const k = isMode ? sub("K", i) : sub("\\delta", i) + const sq = i ? `\\hat ${L}^{\\dagger 2}_{${i}}\\hat ${L}^{2}_{${i}}` : `\\hat ${L}^{\\dagger 2}\\hat ${L}^{2}` + return wrap(sum, `${linear} + \\tfrac{${k}}{2}\\,${sq}`) + } default: - return "\\varepsilon(t)\\,(\\hat a + \\hat a^\\dagger)" + // No model for this role — name a drift, don't invent its algebra. + return `${sum}${i ? `\\hat H_{\\mathrm{drift}}^{(${i})}` : "\\hat H_{\\mathrm{drift}}"}` } } -/** Compose an illustrative Hamiltonian for ANY composite system. undefined when - * there are no components. Distinct role drifts + distinct coupling terms + drive. */ -export function systemHamiltonianLatex(proj: SystemProjection): string | undefined { - const terms: string[] = [] - const seen = new Set() - for (const c of proj.components) { - const t = driftTerm(c.role, c.params) - if (!seen.has(t)) { seen.add(t); terms.push(t) } +/** Atoms are laser-driven on |1⟩↔|r⟩ (3-level Rydberg convention: |0⟩ dark); a + * strictly two-level component is driven in the Pauli basis its drift already + * uses; bosonic and bosonic-truncated components keep the quadrature drive; a + * role we have no model for gets a named control, not an invented operator. */ +function driveLatex(g: Site[], total: number, arch?: string): string { + const { sum, i } = indexing(g, total) + const c = control(i, arch) + switch (g[0].kind) { + case "rydberg": + return wrap(sum, `\\tfrac{\\Omega${c}(t)}{2}\\,(|r\\rangle\\langle 1|${i ? `_{${i}}` : ""} + \\mathrm{h.c.})`) + case "spin": + return wrap(sum, `u^x${c}(t)\\,${pauli("x", i)} + u^y${c}(t)\\,${pauli("y", i)}`) + case "ladder": { + // TWO quadratures. Piccolo drives a transmon with n_drives = 2, and the + // plugin's TRANSMON_LATEX (what the agent shows in chat) always said so — + // this table used to say `ε(t)(â+â†)`, one control, and nobody noticed the + // card and the chat disagreeing about the same device. + const q = controlIdx(i, arch) + const u = (n: number) => `u_{${n}${q ? `,${q}` : ""}}(t)` + const A = ann(g[0].letter, i) + const Ad = cre(g[0].letter, i) + return wrap(sum, `${u(1)}\\,(${A} + ${Ad}) + i\\,${u(2)}\\,(${A} - ${Ad})`) + } + default: + return `${sum}\\hat H_{\\mathrm{c}}${i ? `^{(${i})}` : ""}(t)` } +} + +type Edge = { a: Site; b: Site; rest: Site[] } + +/** Raising / lowering operator for a site in whatever algebra it actually has. + * A coupling term must never assume its endpoints are bosonic: the ladder + * letter is empty for a spin, an atom, or an unmodeled role, and `\hat ^\dagger` + * is not LaTeX — it renders as an error box in the transcript. */ +const raise = (s: Site, i: string) => + s.kind === "ladder" ? cre(s.letter, i) : s.kind === "rydberg" ? `|r\\rangle\\langle 1|_{${i}}` : `\\hat\\sigma_+^{(${i})}` +const lower = (s: Site, i: string) => + s.kind === "ladder" ? ann(s.letter, i) : s.kind === "rydberg" ? `|1\\rangle\\langle r|_{${i}}` : `\\hat\\sigma_-^{(${i})}` + +/** One term for a set of edges that share a kind AND an endpoint shape. A lone + * edge names its actual sites; several become a sum over pairs — the old code + * printed one hardcoded `(1),(2)` term no matter how many edges existed. */ +function couplingLatex(kind: string, edges: Edge[]): string { + const many = edges.length > 1 + const e = edges[0] + const x = many ? "i" : String(e.a.idx) + const y = many ? "j" : String(e.b.idx) + const pair = many ? "\\sum_{\\langle ij\\rangle} " : "" + // For a role we have no model for — or a coupling kind we don't know — name + // the interaction. Inventing its algebra would be a guess, and DROPPING it + // (what an unknown kind used to do) left the card listing a coupling that the + // equation silently didn't have. + const generic = `${pair}\\hat H_{\\mathrm{int},${x}${y}}` + if (e.a.kind === "opaque" || e.b.kind === "opaque") return generic + switch (kind) { + case "vdW": + return `${pair}\\tfrac{C_6}{r_{${x}${y}}^6}\\,${num(x)} ${num(y)}` + case "ZZ": { + const spins = e.a.kind === "spin" && e.b.kind === "spin" + const op = spins ? `${pauli("z", x)}${pauli("z", y)}` : `${num(x)} ${num(y)}` + return `${pair}${many ? "J_{ij}" : "J"}\\,${op}` + } + case "cross-resonance": { + // Drive on the control at the target's frequency. Pauli form only when + // both ends really are two-level — otherwise it would put σ algebra on + // components whose drift is an anharmonic ladder, in the same equation. + const amp = many ? "\\Omega_{\\mathrm{CR},ij}" : "\\Omega_{\\mathrm{CR}}" + return e.a.kind === "spin" && e.b.kind === "spin" + ? `${pair}${amp}\\,${pauli("x", x)}${pauli("z", y)}` + : `${pair}${amp}\\,(${lower(e.a, x)} + ${raise(e.a, x)})\\,${num(y)}` + } + case "exchange": + return `${pair}${many ? "g_{ij}" : "g"}\\,(${raise(e.a, x)} ${lower(e.b, y)} + \\mathrm{h.c.})` + case "dispersive-chi": { + // `b` is the mode (oriented by the caller). Several qubits on ONE cavity + // is the common readout layout, and there the cavity factors out. + if (e.b.kind !== "ladder") return generic // a dispersive shift needs a mode + const m = String(e.b.idx) + const cav = `${cre(e.b.letter, m)} ${ann(e.b.letter, m)}` + if (!many) + return e.a.kind === "spin" + ? `\\tfrac{\\chi}{2}\\,${cav}\\,${pauli("z", x)}` + : `\\chi\\,${cav}\\,${num(x)}` + if (edges.every((z) => z.b.idx === e.b.idx)) return `${cav}\\,\\sum_i \\chi_i\\,${num("i")}` + return `\\sum_{\\langle ij\\rangle} \\chi_{ij}\\,${cre(e.b.letter, "j")} ${ann(e.b.letter, "j")}\\,${num("i")}` + } + case "mode-mediated": { + // The shared mode is `b`; every other member couples into it. + if (e.b.kind !== "ladder") return generic // nothing to mediate through + const ids = [...new Set(edges.flatMap((z) => [z.a, ...z.rest]).map((s) => s.idx))].sort((p, q) => p - q) + const qi = ids.length > 1 ? "i" : String(ids[0]) + const sum = ids.length > 1 ? `\\sum_{i \\in \\{${ids.join(",")}\\}} ` : "" + return `${sum}g\\,(${raise(e.a, qi)} ${ann(e.b.letter, String(e.b.idx))} + \\mathrm{h.c.})` + } + } + return generic +} + +/** Terms carry their own sign, so a drift like `-Δ n̂` must not be pasted on + * with " + " (that printed a literal "+ -Δ"). */ +function joinTerms(terms: string[]): string { + return terms.reduce((acc, t) => (!acc ? t : t.startsWith("-") ? `${acc} - ${t.slice(1).trimStart()}` : `${acc} + ${t}`), "") +} + +export type SystemHamiltonian = { + latex: string + /** recorded = the researcher confirmed these exact terms · inferred = the + * canonical form for the platform, which the card must SAY it is guessing. */ + source: "recorded" | "inferred" + /** Conventions the recorded terms assume (frame, units, basis). */ + notes?: string +} + +/** What the card should show. Recorded terms win outright: they are the model + * the researcher confirmed, and the fallback below can only ever be right for + * platforms someone hardcoded. undefined = say nothing, which is the honest + * answer for an off-template platform nobody has described yet. */ +export function systemHamiltonian(proj: SystemProjection): SystemHamiltonian | undefined { + const recorded = proj.hamiltonian + if (recorded && recorded.terms.length > 0) { + // Ordered drift → coupling → drive regardless of the order they were + // recorded in, so the equation reads the way a physicist writes one. + const rank = { drift: 0, coupling: 1, drive: 2 } as Record + const terms = [...recorded.terms].sort((a, b) => (rank[a.kind] ?? 0) - (rank[b.kind] ?? 0)) + return { + latex: "\\hat H/\\hbar = " + joinTerms(terms.map((t) => t.latex.trim())), + source: "recorded", + ...(recorded.notes ? { notes: recorded.notes } : {}), + } + } + const latex = systemHamiltonianLatex(proj) + return latex ? { latex, source: "inferred" } : undefined +} + +/** The canonical form for a platform we model, composed from the structure: + * one drift and one drive per component GROUP (summed over the group's sites) + * plus one term per set of like edges. This is a FALLBACK — it is a guess about + * physics nobody stated, and every caller must present it as one. undefined + * when there is nothing to say. Never throws. */ +export function systemHamiltonianLatex(proj: SystemProjection): string | undefined { + const total = proj.components.length + if (total === 0) return undefined + + const sites: Site[] = proj.components.map((c, k) => ({ + idx: k + 1, + role: c.role, + letter: "", + ...classify(c, proj.platform), + })) + const byKey = new Map() + for (const s of sites) (byKey.get(s.key) ?? byKey.set(s.key, []).get(s.key)!).push(s) + const groups = [...byKey.values()] + let letters = 0 + for (const g of groups) + if (g[0].kind === "ladder") { + const L = LADDER_LETTERS[letters++ % LADDER_LETTERS.length] + for (const s of g) s.letter = L + } + + const byId = new Map(proj.components.map((c, k) => [c.id, sites[k]])) + const edges = new Map() for (const cp of proj.couplings) { - const t = COUPLING_TERM[cp.kind] - if (t && !seen.has(cp.kind)) { seen.add(cp.kind); terms.push(t) } + const members = cp.between.map((id) => byId.get(id)).filter((s): s is Site => s !== undefined) + if (members.length < 2) continue + // dispersive / mode-mediated are oriented so the shared mode is always `b`. + const mode = members.find((s) => MODE_ROLES.has(s.role)) + const others = mode ? members.filter((s) => s !== mode) : members + const edge: Edge = + mode && (cp.kind === "dispersive-chi" || cp.kind === "mode-mediated") + ? { a: others[0], b: mode, rest: others.slice(1) } + : { a: members[0], b: members[1], rest: members.slice(2) } + const key = `${cp.kind}|${edge.a.kind}|${edge.b.kind}` + ;(edges.get(key) ?? edges.set(key, []).get(key)!).push(edge) } - if (terms.length === 0) return undefined - for (const c of proj.components) { - const t = driveTerm(c.role) - if (!seen.has(t)) { - seen.add(t) - terms.push(t) + + // Nothing to say: every component is a model we don't have, so the only + // "Hamiltonian" we could compose is `Ĥ_drift + Ĥ_c(t)` — true of literally + // every control problem, and it would occupy the slot where the real model + // belongs. Silence here is what makes the agent record one. + if (groups.every((g) => g[0].kind === "opaque")) return undefined + + const terms = groups.map((g) => driftLatex(g, total, proj.driveArch)) + for (const [key, group] of edges) terms.push(couplingLatex(key.split("|")[0], group)) + terms.push(...groups.map((g) => driveLatex(g, total, proj.driveArch))) + return "\\hat H/\\hbar = " + joinTerms(terms) +} + +// --- physics rows ------------------------------------------------------------- +// The card must never invent a slot the model doesn't have. It used to emit a +// fixed transmon spec (frequency · anharmonicity · drive bound · decay) for +// EVERY component, so a Rydberg atom was asked for its anharmonicity while the +// Hamiltonian directly above it — which IS role-aware — showed a 3-level ladder +// with no such term. The row list is derived from the role here, next to the +// Hamiltonian tables, because split sources are why the two halves disagreed. + +/** Unitless params get a math symbol; unit-suffixed keys (chi_kHz, K_c_Hz, + * N_fock) keep their name so the unit isn't lost. */ +export const PARAM_SYMBOL: Record = { + omega: "ω", + delta: "δ", + chi: "χ", + strength: "J", + drive_max: "|u|", + du_bound: "|u̇|", + Delta: "Δ", + Omega: "Ω", + kappa: "κ", +} + +export type PhysicsRow = { + label: string + sym?: string + /** Formatted number (+ unit when the recorded key spells one), or "not set". */ + value: string + /** recorded = on file · missing = this role HAS this param, nobody has said + * what it is yet. Params the role doesn't have are absent, not "missing". */ + state: "recorded" | "missing" +} + +type ParamSpec = { + /** Accepted keys, canonical first. A `_GHz`-style suffix is matched + * automatically, so list only bare forms. Case-sensitive: an atom's `Delta` + * (detuning) must not silently absorb a transmon's `delta` (anharmonicity). */ + keys: string[] + label: string + sym: string + /** Rendered before the number ("≤ " for a bound). */ + prefix?: string +} + +/** Units are never assumed: transmon params are GHz, the Rydberg templates work + * in rad/μs, and a bare `omega` says which only by convention. So a unit is + * shown only when the recorded key spells it out. */ +const UNIT_SUFFIX = /_(Hz|kHz|MHz|GHz|THz|s|ms|us|µs|ns|rad|deg)$/ + +const MODE_PARAMS: ParamSpec[] = [ + { keys: ["omega_c", "omega", "frequency"], label: "frequency", sym: "ω" }, + { keys: ["K", "K_c", "kerr"], label: "kerr", sym: "K" }, + { keys: ["kappa"], label: "linewidth", sym: "κ" }, +] + +/** Params each MODEL has, in card order — keyed by the same `classify` result + * that picks the Hamiltonian terms, so the equation and the table are always + * describing the same physics. A model we don't have (`opaque:*`) expects + * NOTHING and shows only what was recorded: that is the honest floor for a + * platform outside the templated set. */ +const MODEL_PARAMS: Record = { + spin: [ + // A two-level system has a splitting and a drive bound — and no third level + // to be anharmonic against. + { keys: ["omega", "frequency", "f01"], label: "frequency", sym: "ω" }, + { keys: ["drive_max"], label: "drive bound", sym: "|u|", prefix: "≤ " }, + ], + qubit: [ + { keys: ["omega", "frequency", "f01"], label: "frequency", sym: "ω" }, + { keys: ["delta", "alpha", "anharmonicity"], label: "anharmonicity", sym: "δ" }, + { keys: ["drive_max"], label: "drive bound", sym: "|u|", prefix: "≤ " }, + ], + rydberg: [ + // Lowercase `delta_max`/`omega_max` are what the Rydberg template and the + // interview actually record (Δ_max, Ω_max). They are safe to claim here and + // ONLY here: the spec is keyed by model, so a transmon's δ can never reach + // this row. A bare `delta` on an atom stays deliberately unclaimed — it is + // far more likely a misfiled anharmonicity than a detuning. + { keys: ["Delta", "Delta_max", "delta_max", "detuning"], label: "detuning", sym: "Δ", prefix: "≤ " }, + { keys: ["Omega", "Omega_max", "omega_max", "rabi_max", "rabi", "drive_max"], label: "rabi drive", sym: "Ω", prefix: "≤ " }, + ], + mode: MODE_PARAMS, + "mode-kerr": MODE_PARAMS, +} + +/** First recorded key matching any alias. A zero keeps the old "0 means unset" + * reading — an all-zeros seed shouldn't look like a specified device. */ +function matchParam(params: Record, keys: string[]) { + for (const key of keys) + for (const [k, v] of Object.entries(params)) { + if (typeof v !== "number" || v === 0) continue + if (k === key || k.replace(UNIT_SUFFIX, "") === key) { + const unit = k.match(UNIT_SUFFIX)?.[1] + return { key: k, text: unit ? `${formatSci(v)} ${unit}` : formatSci(v) } + } } + return undefined +} + +/** Rows for ONE component: levels, the params its MODEL actually has (unanswered + * ones read "not set" — that list doubles as the interview's to-do), then + * anything else recorded, so nothing on file is dropped. `platform` is what + * separates a transmon from a qubit we have no model for. Never throws. */ +export function componentPhysicsRows(c: ComponentRow, platform?: string): PhysicsRow[] { + const spec = MODEL_PARAMS[classify(c, platform).key] ?? [] + const claimed = new Set() + const rows: PhysicsRow[] = + c.levels === undefined + ? [{ label: "levels", value: "not set", state: "missing" }] + : [{ label: "levels", value: String(c.levels), state: "recorded" }] + for (const s of spec) { + const hit = matchParam(c.params, s.keys) + if (hit) claimed.add(hit.key) + rows.push({ + label: s.label, + sym: s.sym, + value: hit ? `${s.prefix ?? ""}${hit.text}` : "not set", + state: hit ? "recorded" : "missing", + }) + } + // Decay belongs to the environment rather than to any one role's Hamiltonian, + // so it is asked for every role we model — and never invented for one we don't. + if (spec.length > 0) { + const t1 = matchParam(c.params, ["T1", "t1"]) + const t2 = matchParam(c.params, ["T2", "t2"]) + if (t1) claimed.add(t1.key) + if (t2) claimed.add(t2.key) + const decay = [t1 ? `T₁ ${t1.text}` : undefined, t2 ? `T₂ ${t2.text}` : undefined].filter(Boolean).join(" · ") + rows.push({ label: "decay", sym: "T₁/T₂", value: decay || "not set", state: decay ? "recorded" : "missing" }) + } + for (const [k, v] of Object.entries(c.params)) { + if (claimed.has(k) || typeof v !== "number" || v === 0) continue + const unit = k.match(UNIT_SUFFIX)?.[1] + rows.push({ + label: k.replace(UNIT_SUFFIX, ""), + ...(PARAM_SYMBOL[k] ? { sym: PARAM_SYMBOL[k] } : {}), + value: unit ? `${formatSci(v)} ${unit}` : formatSci(v), + state: "recorded", + }) } - return "\\hat H/\\hbar = " + terms.join(" + ") + return rows } export function systemTableModel(proj: SystemProjection): TableModel { diff --git a/packages/ui/src/amicode/system-view.tsx b/packages/ui/src/amicode/system-view.tsx index 36867abcf0..c82749edc5 100644 --- a/packages/ui/src/amicode/system-view.tsx +++ b/packages/ui/src/amicode/system-view.tsx @@ -2,26 +2,17 @@ import { For, Show, createMemo } from "solid-js" import katex from "katex" import { systemProjection } from "./problem" import { formatSci } from "./facets" -import { systemTableModel, systemHamiltonianLatex } from "./system-render" +import { systemTableModel, systemHamiltonian, systemCountLabel, componentPhysicsRows, PARAM_SYMBOL } from "./system-render" // AMICODE System hero (spec §6.1) — PHYSICS-FORWARD (Kate 2026-07-23): lead with -// the Hamiltonian, then a labeled physics spec (frequency, anharmonicity, drive -// bound, decay, + any recorded params). Missing canonical params read "not set" -// so an under-specified model is visible at a glance rather than silently thin. -// Multi-component systems show the component/coupling table (it scales; the -// node/edge schematic was removed — Kate 2026-07-24). Thin — logic in the pure -// systemProjection / system-render models. +// the Hamiltonian, then the physics spec for the component's ROLE. Params the +// role has but nobody has stated read "not set", so an under-specified model is +// visible at a glance; params the role does NOT have are absent entirely (the +// spec used to be a fixed transmon list, which asked Rydberg atoms for their +// anharmonicity). Multi-component systems show the component/coupling table (it +// scales; the node/edge schematic was removed — Kate 2026-07-24). Thin — logic +// in the pure systemProjection / system-render models. -// Unitless params get a math symbol; unit-suffixed keys (chi_kHz, K_c_Hz, N_fock) -// keep their name so the unit isn't lost. -const PARAM_SYMBOL: Record = { - omega: "ω", - delta: "δ", - chi: "χ", - strength: "J", - drive_max: "|u|", - du_bound: "|u̇|", -} // Drop unset (zero) params — "ω 0 · δ 0" is noise — and format the rest with // the π-aware formatter so a drive bound reads "|u| 40π", not "|u| 125.66…". const paramsText = (params: Record): string => @@ -30,64 +21,60 @@ const paramsText = (params: Record): string => .map(([k, v]) => `${PARAM_SYMBOL[k] ?? k} ${formatSci(v)}`) .join(" · ") -// Canonical single-qubit physics keys, consumed by the physics spec; anything -// left over is appended as its own row so nothing recorded is lost. -const CANON_KEYS = new Set(["omega", "frequency", "f01", "delta", "alpha", "anharmonicity", "drive_max", "T1", "t1", "T2", "t2"]) -type PhysRow = { label: string; sym?: string; value: string; set: boolean } - export function SystemComposite(props: { entity: Record }) { const proj = createMemo(() => systemProjection(props.entity)) const table = createMemo(() => systemTableModel(proj())) const hamiltonian = createMemo(() => { - const latex = systemHamiltonianLatex(proj()) - return latex ? katex.renderToString(latex, { throwOnError: false }) : undefined + const h = systemHamiltonian(proj()) + return h ? { ...h, html: katex.renderToString(h.latex, { throwOnError: false }) } : undefined }) // Single qubit/atom, no couplings → the physics spec. Else the structural view. const single = createMemo(() => proj().components.length === 1 && proj().couplings.length === 0) - const physics = createMemo(() => { - const c = proj().components[0] - if (!c) return [] - const par = c.params - const num = (keys: string[]): number | undefined => { - for (const k of keys) if (typeof par[k] === "number" && par[k] !== 0) return par[k] - return undefined - } - const rows: PhysRow[] = [] - if (c.levels !== undefined) rows.push({ label: "levels", value: String(c.levels), set: true }) - const f = num(["omega", "frequency", "f01"]) - rows.push({ label: "frequency", sym: "ω", value: f !== undefined ? formatSci(f) : "not set", set: f !== undefined }) - const a = num(["delta", "alpha", "anharmonicity"]) - rows.push({ label: "anharmonicity", sym: "δ", value: a !== undefined ? formatSci(a) : "not set", set: a !== undefined }) - const d = num(["drive_max"]) - rows.push({ label: "drive bound", sym: "|u|", value: d !== undefined ? `≤ ${formatSci(d)}` : "not set", set: d !== undefined }) - const t1 = num(["T1", "t1"]) - const t2 = num(["T2", "t2"]) - const decay = [t1 !== undefined ? `T₁ ${formatSci(t1)}` : null, t2 !== undefined ? `T₂ ${formatSci(t2)}` : null] - .filter(Boolean) - .join(" · ") - rows.push({ label: "decay", sym: "T₁/T₂", value: decay || "not set", set: t1 !== undefined || t2 !== undefined }) - for (const [k, v] of Object.entries(par)) - if (typeof v === "number" && v !== 0 && !CANON_KEYS.has(k)) - rows.push({ label: PARAM_SYMBOL[k] ?? k, sym: PARAM_SYMBOL[k], value: formatSci(v), set: true }) - return rows + const physics = createMemo(() => { + const c = table().components[0] + return c ? componentPhysicsRows(c, proj().platform) : [] }) return (
- +
{(p) => {p()}} + {/* N is a claim, not a layout detail — say it so an unanswered "how + many atoms?" can't read as a confident "one". */} + {(n) => {n()}} {(d) => {d()} drive}
- - {(html) => ( + {/* A recorded model is shown bare — it is what the researcher confirmed. + An INFERRED one is a guess about physics nobody stated, so it says so + and invites the correction; that correction is what gets recorded. */} + 0}> +
+ Hamiltonian not recorded +
+
+ No model for this platform yet — tell Amico the terms and it'll record them here. +
+
+ } + > + {(h) => ( <> -
Hamiltonian
-
-
+
+ Hamiltonian + + inferred · confirm or correct + +
+
+
+ {(n) =>
{n()}
}
)} @@ -129,7 +116,7 @@ export function SystemComposite(props: { entity: Record }) {
{r.label}
-
+
{r.value} {(s) => {s()}}