diff --git a/packages/extension/julia/Manifest.toml b/packages/extension/julia/Manifest.toml index d4a47f8c..fbe0c755 100644 --- a/packages/extension/julia/Manifest.toml +++ b/packages/extension/julia/Manifest.toml @@ -1,8 +1,8 @@ # This file is machine-generated - editing it directly is not advised -julia_version = "1.12.3" +julia_version = "1.12.5" manifest_format = "2.0" -project_hash = "7b8093d11837e508ee4e0b26e253dd86c7c18590" +project_hash = "df144d85154ffa415b78f9c7d42d7acfe2b32654" [[deps.ADTypes]] git-tree-sha1 = "bbc22a9a08a0ef6460041086d8a7b27940ed4ffd" @@ -1134,6 +1134,18 @@ version = "1.6.1" [deps.JSON.weakdeps] ArrowTypes = "31f734f8-188a-4ce0-8406-c8a06bd891cd" +[[deps.JSON3]] +deps = ["Dates", "Mmap", "Parsers", "PrecompileTools", "StructTypes", "UUIDs"] +git-tree-sha1 = "411eccfe8aba0814ffa0fdf4860913ed09c34975" +uuid = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" +version = "1.14.3" + + [deps.JSON3.extensions] + JSON3ArrowExt = ["ArrowTypes"] + + [deps.JSON3.weakdeps] + ArrowTypes = "31f734f8-188a-4ce0-8406-c8a06bd891cd" + [[deps.JpegTurbo]] deps = ["CEnum", "FileIO", "ImageCore", "JpegTurbo_jll", "TOML"] git-tree-sha1 = "9496de8fb52c224a2e3f9ff403947674517317d9" @@ -1392,7 +1404,7 @@ version = "0.3.4" [[deps.MozillaCACerts_jll]] uuid = "14a3606d-f60d-562e-9121-12d972cd8159" -version = "2025.5.20" +version = "2025.11.4" [[deps.MuladdMacro]] git-tree-sha1 = "cac9cc5499c25554cba55cd3c30543cff5ca4fab" @@ -1614,10 +1626,10 @@ uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" version = "2.8.6" [[deps.Piccolo]] -deps = ["DataInterpolations", "DirectTrajOpt", "Distributions", "ExponentialAction", "ForwardDiff", "JLD2", "LaTeXStrings", "Libdl", "LinearAlgebra", "Makie", "NamedTrajectories", "OrdinaryDiffEqLinear", "OrdinaryDiffEqTsit5", "Printf", "ProgressMeter", "Random", "Reexport", "SciMLBase", "SparseArrays", "SpecialFunctions", "SymbolicIndexingInterface", "TestItemRunner", "TestItems", "TrajectoryIndexingUtils", "UnicodePlots"] -git-tree-sha1 = "0a0571604a425b3fbe4dd941e5976588a47767a3" +deps = ["DataInterpolations", "DirectTrajOpt", "Distributions", "ExponentialAction", "ForwardDiff", "JLD2", "JSON3", "LaTeXStrings", "Libdl", "LinearAlgebra", "Makie", "NamedTrajectories", "OrdinaryDiffEqLinear", "OrdinaryDiffEqTsit5", "Pkg", "Printf", "ProgressMeter", "Random", "Reexport", "SHA", "SciMLBase", "SparseArrays", "SpecialFunctions", "SymbolicIndexingInterface", "TOML", "TestItemRunner", "TestItems", "TrajectoryIndexingUtils", "UnicodePlots"] +git-tree-sha1 = "0a03f80967db3884791daeef476cb85a24248be5" uuid = "c4671d76-df94-11ed-2057-43d4fd632fad" -version = "1.19.0" +version = "1.21.0" [deps.Piccolo.extensions] PiccoloMakieExt = ["Makie"] @@ -2119,6 +2131,12 @@ version = "0.7.3" SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" +[[deps.StructTypes]] +deps = ["Dates", "UUIDs"] +git-tree-sha1 = "159331b30e94d7b11379037feeb9b690950cace8" +uuid = "856f2bd8-1eba-4b0a-8007-ebc267875bd4" +version = "1.11.0" + [[deps.StructUtils]] deps = ["Dates", "UUIDs"] git-tree-sha1 = "82bee338d650aa515f31866c460cb7e3bcef90b8" diff --git a/packages/extension/julia/Project.toml b/packages/extension/julia/Project.toml index 6209c058..aa1ef12b 100644 --- a/packages/extension/julia/Project.toml +++ b/packages/extension/julia/Project.toml @@ -13,13 +13,13 @@ TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" # Measured against the registry today: with no compat, `Pkg.update("Piccolo")` on the # shipped pair takes 1.19.0 -> 1.20.0. With `~1.19` it holds at 1.19.0. # -# 1.20.0 is a deliberately breaking release (`integrator_type = :spline` now errors, -# `fidelity(qcp)` became free-phase-aware and returns different numbers for -# `free_phase = true` problems, and `sync_trajectory!` now warns on optimizer-vs-rollout -# divergence). Every shipped score and template was vetted against 1.19.0, so moving is -# a decision to make deliberately after re-vetting — not something a resolver should do -# on a user's machine mid-session. +# 1.20.0 and 1.21.0 are deliberately breaking releases (1.20: `integrator_type = +# :spline` now errors, `fidelity(qcp)` became free-phase-aware, and +# `sync_trajectory!` now warns on optimizer-vs-rollout divergence; 1.21: Specs +# Phase 1 wire format). Every shipped score and template was vetted against 1.19.0 +# — re-vetted here against 1.21.0 (solve_template.jl et al. do not use the +# breaking APIs, so no template changes were needed; see harmoniqs/Piccolo.jl#271). # -# `~1.19` admits 1.19.x patches (non-breaking by SemVer) and refuses 1.20. Raise this in -# the same PR that re-vets the templates against the new minor. -Piccolo = "~1.19" +# `~1.21` admits 1.21.x patches and refuses 1.22 (which will carry Phase 1b +# parametric typing). Raise this with re-vetting for each new minor. +Piccolo = "~1.21" diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts index 6a047992..ef3ba5bb 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -212,6 +212,341 @@ function recordEntity( return sentinelLine(slug, kind, action, seq, diff); } +// ── W2.1 deterministic projection: Formulation + CompositeSystem → ProblemSpec ─ +// No LLM; pure mapping per spec-20260717 master table. Mirrors +// packages/schema/src/project.ts — keep them in sync. Dependency-free (the +// plugin runs in Bun with no npm resolution), so TOML emission and light +// validation are hand-rolled, not ajv/smol-toml. The real ajv gate lives in +// @amicode/schema and in amico-run; this light validator catches the same +// structural faults before we write. + +function projectionSystemTemplate(sys: CompositeSystem): string { + const p = sys.platform.toLowerCase(); + const n = sys.components.length; + const hasCavity = sys.components.some((c) => c.role === "cavity" || c.role === "resonator" || c.role === "mode"); + const hasQubit = sys.components.some((c) => c.role === "qubit" || c.role === "atom"); + if (p === "transmon") return n === 1 ? "TransmonSystem" : "MultiTransmonSystem"; + if (p === "rydberg") return "RydbergChainSystem"; + if (p === "bosonic" || p === "cavity") { + if (hasQubit && hasCavity) return "TransmonCavitySystem"; + return "CatSystem"; + } + if (p === "ion" || p === "trapped-ion") return "IonChainSystem"; + if (hasCavity && !hasQubit) return "CatSystem"; + return n === 1 ? "TransmonSystem" : "MultiTransmonSystem"; +} + +function projectionPulseAndTemplate(p: Parameterization): { pulseKind: string; template: string } { + switch (p) { + case "smooth": + return { pulseKind: "zero_order", template: "SmoothPulseProblem" }; + case "bang_bang": + return { pulseKind: "zero_order", template: "BangBangPulseProblem" }; + case "linear_spline": + return { pulseKind: "linear_spline", template: "SplinePulseProblem" }; + case "cubic_spline": + return { pulseKind: "cubic_spline", template: "SplinePulseProblem" }; + } +} + +function projectionParseIntegrator(raw: string | undefined, freePhase: boolean): { kind: string; alg: string } { + const s = (raw ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); + let out: { kind: string; alg: string }; + if (s.includes("magnusgl4") || s === "gl4") out = { kind: "exponential", alg: "magnus_gl4" }; + else if (s.includes("magnusadapt4") || s.includes("adapt4")) out = { kind: "spline", alg: "magnus_adapt4" }; + else if (s.includes("tsit5")) out = { kind: "bilinear", alg: "tsit5" }; + else if (s.includes("bilinear")) out = { kind: "bilinear", alg: "tsit5" }; + else if (s.includes("spline")) out = { kind: "spline", alg: "magnus_adapt4" }; + else if (s.includes("exponential")) out = { kind: "exponential", alg: "magnus_gl4" }; + else out = { kind: "bilinear", alg: "tsit5" }; + if (freePhase && out.kind === "bilinear") out = { kind: "spline", alg: "magnus_adapt4" }; + return out; +} + +function projectionDtBoundsFromConstraints(constraints: FormulationEntity["constraints"]): [number, number] | undefined { + const c = constraints.find((x) => x.kind === "dt_bounds"); + if (!c) return undefined; + const p = c.params as Record; + const lo = (p.dt_min as number) ?? (p.lo as number) ?? (p.lower as number) ?? (p.min as number) ?? (p.dt_lower as number); + const hi = (p.dt_max as number) ?? (p.hi as number) ?? (p.upper as number) ?? (p.max as number) ?? (p.dt_upper as number); + if (typeof lo === "number" && typeof hi === "number" && Number.isFinite(lo) && Number.isFinite(hi)) return [lo, hi]; + return undefined; +} + +function projectionValidateLight(spec: Record): string[] { + const errs: string[] = []; + if (spec.schema_version !== 1) errs.push("/schema_version: must be 1"); + if (spec.kind !== "control") errs.push("/kind: must be control"); + const sys = spec.system as Record | undefined; + if (!sys || typeof sys.template !== "string") errs.push("/system/template: required"); + const pulse = spec.pulse as Record | undefined; + if (!pulse || typeof pulse.kind !== "string") errs.push("/pulse/kind: required"); + if (!pulse || typeof pulse.T !== "number") errs.push("/pulse/T: required number"); + const prob = spec.problem as Record | undefined; + if (!prob || typeof prob.template !== "string") errs.push("/problem/template: required"); + if (!prob || typeof prob.N !== "number") errs.push("/problem/N: required integer"); + // template↔pulseKind conditional (mirrors schema if/then) + if (prob && pulse) { + if (prob.template === "SplinePulseProblem" && pulse.kind === "zero_order") errs.push("/pulse/kind: SplinePulseProblem requires cubic_spline|linear_spline"); + if ((prob.template === "SmoothPulseProblem" || prob.template === "BangBangPulseProblem") && pulse.kind !== "zero_order") + errs.push(`/pulse/kind: ${prob.template} requires zero_order`); + } + // free_phase → integrator kind ∈ {exponential,spline} + if (prob && (prob as Record).free_phase === true) { + const integ = spec.integrator as Record | undefined; + if (integ && integ.kind === "bilinear") errs.push("/integrator/kind: free_phase requires exponential|spline"); + } + return errs; +} + +function problemSpecToml(spec: Record): string { + // Hand-rolled TOML emitter for the control ProblemSpec — dependency-free. + // Only emits the fields projection produces; unknown shapes are omitted. + const esc = (s: string): string => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; + const num = (n: number): string => String(n); + const lines: string[] = []; + lines.push(`schema_version = ${spec.schema_version as number}`, `kind = ${esc(spec.kind as string)}`, ""); + const sys = spec.system as Record; + lines.push("[system]", `kind = ${esc(sys.kind as string)}`); + if (typeof sys.template === "string") lines.push(`template = ${esc(sys.template)}`); + if (sys.params && typeof sys.params === "object") { + const entries = Object.entries(sys.params as Record); + if (entries.length) lines.push(`params = { ${entries.map(([k, v]) => `${k} = ${num(v)}`).join(", ")} }`); + } + if (sys.global_params && typeof sys.global_params === "object") { + const e = Object.entries(sys.global_params as Record); + if (e.length) lines.push(`global_params = { ${e.map(([k, v]) => `${k} = ${typeof v === "number" ? num(v) : esc(String(v))}`).join(", ")} }`); + } + lines.push(""); + const goal = spec.goal as Record | undefined; + if (goal) { + lines.push("[goal]", `kind = ${esc(goal.kind as string)}`); + if (typeof goal.gate === "string") lines.push(`gate = ${esc(goal.gate)}`); + if (typeof goal.target === "string") lines.push(`target = ${esc(goal.target)}`); + if (Array.isArray(goal.subsystem_levels)) lines.push(`subsystem_levels = [${(goal.subsystem_levels as number[]).join(", ")}]`); + lines.push(""); + } + const pulse = spec.pulse as Record; + lines.push("[pulse]", `kind = ${esc(pulse.kind as string)}`, `T = ${num(pulse.T as number)}`); + if (typeof pulse.init === "string") lines.push(`init = ${esc(pulse.init)}`); + if (typeof pulse.seed === "number") lines.push(`seed = ${pulse.seed as number}`); + lines.push(""); + const prob = spec.problem as Record; + lines.push("[problem]", `template = ${esc(prob.template as string)}`, `N = ${prob.N as number}`); + if (typeof prob.goal_treatment === "string") lines.push(`goal_treatment = ${esc(prob.goal_treatment)}`); + if (prob.free_dt !== undefined) { + if (Array.isArray(prob.free_dt)) lines.push(`free_dt = [${(prob.free_dt as number[]).join(", ")}]`); + else lines.push(`free_dt = ${prob.free_dt as boolean}`); + } + if (typeof prob.Q === "number") lines.push(`Q = ${num(prob.Q)}`); + if (typeof prob.R === "number") lines.push(`R = ${num(prob.R)}`); + if (typeof prob.R_u === "number") lines.push(`R_u = ${num(prob.R_u)}`); + if (typeof prob.R_du === "number") lines.push(`R_du = ${num(prob.R_du)}`); + if (typeof prob.R_ddu === "number") lines.push(`R_ddu = ${num(prob.R_ddu)}`); + if (typeof prob.final_fidelity === "number") lines.push(`final_fidelity = ${num(prob.final_fidelity)}`); + if (typeof prob.free_phase === "boolean") lines.push(`free_phase = ${prob.free_phase}`); + if (Array.isArray(prob.calibration_targets)) lines.push(`calibration_targets = [${(prob.calibration_targets as string[]).map(esc).join(", ")}]`); + if (prob.global_bounds && typeof prob.global_bounds === "object") { + const e = Object.entries(prob.global_bounds as Record); + if (e.length) lines.push(`global_bounds = { ${e.map(([k, v]) => `${k} = ${num(v)}`).join(", ")} }`); + } + if (typeof prob.du_bound === "number") lines.push(`du_bound = ${num(prob.du_bound)}`); + if (typeof prob.ddu_bound === "number") lines.push(`ddu_bound = ${num(prob.ddu_bound)}`); + if (prob.options && typeof prob.options === "object") { + const e = Object.entries(prob.options as Record); + if (e.length) lines.push(`options = { ${e.map(([k, v]) => `${k} = ${typeof v === "number" ? num(v) : typeof v === "boolean" ? String(v) : esc(String(v))}`).join(", ")} }`); + } + if (Array.isArray(prob.objectives)) { + for (const o of prob.objectives as Array>) { + lines.push("", "[[problem.objectives]]", `kind = ${esc(o.kind as string)}`); + if (typeof o.weight === "number") lines.push(`weight = ${num(o.weight)}`); + } + } + // back to top-level for wrappers etc: TOML forbids adding keys after array-of-tables, + // but our spec only has objectives as AoT inside [problem] — we already emitted them + // inline via [[problem.objectives]] which is valid. + const traj = spec.trajectory as Record | undefined; + if (traj && typeof traj.kind === "string") { + lines.push("", "[trajectory]", `kind = ${esc(traj.kind)}`); + } + const integ = spec.integrator as Record | undefined; + if (integ) { + lines.push("", "[integrator]", `kind = ${esc(integ.kind as string)}`); + if (typeof integ.alg === "string") lines.push(`alg = ${esc(integ.alg)}`); + } + const solver = spec.solver as Record | undefined; + if (solver) { + lines.push("", "[solver]", `backend = ${esc(solver.backend as string)}`); + if (typeof solver.device === "string") lines.push(`device = ${esc(solver.device)}`); + if (typeof solver.precision === "string") lines.push(`precision = ${esc(solver.precision)}`); + if (typeof solver.max_iter === "number") lines.push(`max_iter = ${solver.max_iter as number}`); + if (typeof solver.strategy === "string") lines.push(`strategy = ${esc(solver.strategy)}`); + if (typeof solver.tol === "number") lines.push(`tol = ${num(solver.tol)}`); + } + if (Array.isArray(spec.wrappers)) { + for (const w of spec.wrappers as Array>) { + lines.push("", "[[wrappers]]", `kind = ${esc(w.kind as string)}`); + if (Array.isArray(w.variants)) { + // variants are opaque objects — emit as inline tables if simple + for (const v of w.variants as Array>) { + const inner = Object.entries(v) + .map(([k, val]) => `${k} = ${typeof val === "number" ? num(val) : esc(String(val))}`) + .join(", "); + lines.push(`variants = [{ ${inner} }]`); + break; // one line carries all; schema allows array, we emit one table per wrapper for brevity + } + if ((w.variants as unknown[]).length === 0) lines.push(`variants = []`); + } + if (Array.isArray(w.weights)) lines.push(`weights = [${(w.weights as number[]).join(", ")}]`); + } + } + // warm_start etc. omitted (not projected) + lines.push(""); + return lines.join("\n"); +} + +type ProjectionResult = + | { ok: true; spec: Record; warnings: string[] } + | { ok: false; reason: string }; + +function projectToProblemSpecLocal( + formulation: FormulationEntity, + system: CompositeSystem, +): ProjectionResult { + const warnings: string[] = []; + const customObj = formulation.objectives.find((o) => o.kind === "custom"); + if (customObj) return { ok: false, reason: `custom objective not spec-expressible: ${customObj.label ?? customObj.kind} — falls back to script-tier authoring` }; + const customCon = formulation.constraints.find((c) => c.kind === "custom"); + if (customCon) return { ok: false, reason: `custom constraint not spec-expressible: ${customCon.label ?? customCon.kind} — falls back to script-tier authoring` }; + if (formulation.trajectory_type === "density" || formulation.trajectory_type === "multidensity" || formulation.trajectory_type === "multiket") + return { ok: false, reason: `trajectory_type "${formulation.trajectory_type}" not spec-expressible as a control ProblemSpec — falls back to script-tier` }; + + const template = projectionSystemTemplate(system); + const sys: Record = { kind: "template", template }; + const firstLevels = system.components[0]?.levels; + if (typeof firstLevels === "number") sys.params = { levels: firstLevels }; + else if (system.components.length === 1 && Object.keys(system.components[0].params ?? {}).length > 0) { + const p: Record = {}; + for (const [k, v] of Object.entries(system.components[0].params)) if (typeof v === "number") p[k] = v; + if (Object.keys(p).length > 0) sys.params = p; + } + + let trajectoryKind: string; + let goalKind: string; + if (formulation.trajectory_type === "gate") { + trajectoryKind = "unitary"; + goalKind = "unitary"; + } else { + trajectoryKind = "ket"; + goalKind = "ket"; + } + const subsystemLevels = system.components.map((c) => (typeof c.levels === "number" ? c.levels : 3)); + const goal: Record = { kind: goalKind, subsystem_levels: subsystemLevels }; + if (goalKind === "unitary") goal.gate = formulation.target || "X"; + else goal.target = formulation.target || "|0>"; + + const effParam = (formulation.solve?.parameterization as Parameterization | undefined) ?? formulation.parameterization; + const { pulseKind, template: probTemplate } = projectionPulseAndTemplate(effParam); + const T = formulation.solve?.T ?? 40; + const N = formulation.solve?.N ?? 40; + const pulse: Record = { kind: pulseKind, T, init: "default", seed: 0 }; + const problem: Record = { template: probTemplate, N }; + + if (formulation.time_mode === "min_time") { + problem.goal_treatment = "both"; + const bounds = projectionDtBoundsFromConstraints(formulation.constraints); + problem.free_dt = bounds ?? [0.01, 2.0]; + const ff = formulation.time_params?.final_fidelity; + problem.final_fidelity = typeof ff === "number" ? ff : 0.99; + const D = formulation.time_params?.D; + const w = typeof D === "number" ? D : 100; + const objs: Array> = [{ kind: "time", weight: w }]; + for (const o of formulation.objectives) { + if (o.kind === "reg_u" || o.kind === "reg_du" || o.kind === "reg_ddu" || o.kind === "sensitivity") { + const weight = (o.params as Record).weight as number | undefined; + const k = o.kind === "reg_u" ? "reg_u" : o.kind === "reg_du" ? "reg_du" : o.kind === "reg_ddu" ? "reg_ddu" : "sensitivity"; + objs.push({ kind: k, weight: typeof weight === "number" ? weight : 1 }); + } + } + problem.objectives = objs; + } else { + problem.goal_treatment = "objective"; + problem.free_dt = false; + const objs: Array> = []; + for (const o of formulation.objectives) { + if (o.kind === "reg_u") problem.R_u = ((o.params as Record).weight as number) ?? ((o.params as Record).value as number) ?? 1e-4; + else if (o.kind === "reg_du") problem.R_du = ((o.params as Record).weight as number) ?? 1e-5; + else if (o.kind === "reg_ddu") problem.R_ddu = ((o.params as Record).weight as number) ?? 1e-6; + else if (o.kind === "sensitivity") objs.push({ kind: "sensitivity", weight: (o.params as Record).weight as number ?? 1 }); + } + if (objs.length > 0) problem.objectives = objs; + } + + if (formulation.free_phase) problem.free_phase = true; + if (formulation.leakage) { + const opts: Record = { leakage_constraint: true }; + const lp = formulation.leakage_params ?? {}; + if (typeof lp.value === "number") opts.leakage_constraint_value = lp.value; + if (typeof lp.cost === "number") opts.leakage_cost = lp.cost; + problem.options = opts; + } + const cal = formulation.constraints.find((c) => c.kind === "calibration_pin"); + if (cal) { + const names = Object.keys(cal.params ?? {}); + if (names.length > 0) problem.calibration_targets = names; + else if (cal.label) problem.calibration_targets = [cal.label]; + } + for (const c of formulation.constraints) { + if (c.kind === "bounds" && c.params) problem.global_bounds = { ...c.params }; + else if (c.kind === "du_bound" && typeof (c.params as Record).value === "number") problem.du_bound = (c.params as Record).value as number; + else if (c.kind === "ddu_bound" && typeof (c.params as Record).value === "number") problem.ddu_bound = (c.params as Record).value as number; + else if (c.kind === "du_bound" && typeof (c.params as Record).du_bound === "number") problem.du_bound = (c.params as Record).du_bound as number; + } + + const integrator = projectionParseIntegrator(formulation.solve?.integrator, formulation.free_phase); + const maxIter = formulation.solve?.max_iter; + const solver: Record = { + backend: "ipopt", + device: "cpu", + precision: "f64", + max_iter: typeof maxIter === "number" ? maxIter : 500, + strategy: "direct", + }; + + let wrappers: Array> | undefined; + if (formulation.robustness.kind === "ensemble") { + const variants: unknown[] = []; + const maybeVariants = (formulation.robustness.params as Record).variants; + if (Array.isArray(maybeVariants)) variants.push(...(maybeVariants as unknown[])); + else variants.push({ ...formulation.robustness.params }); + const w: Record = { kind: "sampling", variants }; + const weights = (formulation.robustness.params as Record).weights; + if (Array.isArray(weights)) w.weights = weights; + wrappers = [w]; + } else if (formulation.robustness.kind === "sensitivity") { + wrappers = [{ kind: "robust", variants: [{ ...formulation.robustness.params }] }]; + } + + const spec: Record = { + schema_version: 1, + kind: "control", + system: sys, + goal, + pulse, + problem, + trajectory: { kind: trajectoryKind }, + integrator, + solver, + }; + if (wrappers) spec.wrappers = wrappers; + + if (probTemplate === "SplinePulseProblem" && pulseKind === "zero_order") warnings.push(`template ${probTemplate} requires cubic_spline|linear_spline pulse, got ${pulseKind}`); + if ((probTemplate === "SmoothPulseProblem" || probTemplate === "BangBangPulseProblem") && pulseKind !== "zero_order") + warnings.push(`template ${probTemplate} requires zero_order pulse, got ${pulseKind}`); + + return { ok: true, spec, warnings }; +} + // LaTeX shown at the PLATFORM stage — kept verbatim in sync with AGENTS.md's // "Pulse-designer interview" section (the agent renders these in chat). // @@ -819,7 +1154,44 @@ export const AmicodeTools = async (_input: unknown) => ({ merged.robustness.kind !== "none" ? merged.robustness.kind : undefined, merged.free_phase ? "free-phase" : undefined, ].filter(Boolean).join(" · "); - return `Formulation's locked for "${meta.slug}" — ${modes}, target ${merged.target}${warn}\n\n${sentinel}`; + + // ── W2.1 projection: Formulation + CompositeSystem → ProblemSpec ── + // Deterministic, no LLM; ajv-validated before write. Entity TOMLs still + // written (UI unchanged); problem.toml is the scriptless artifact. + let projectionNote = ""; + try { + const sysForProjRaw = readEntityJson>(meta.slug, "system"); + if (sysForProjRaw) { + const sysComposite = normalizeSystem(sysForProjRaw); + const proj = projectToProblemSpecLocal(merged as FormulationEntity, sysComposite); + if (proj.ok) { + const errs = projectionValidateLight(proj.spec); + if (errs.length === 0) { + const specToml = problemSpecToml(proj.spec); + const outPath = path.join(dir, "problem.toml"); + const outJson = path.join(dir, "problem.json"); + // atomic write TOML + JSON sidecar (JSON is the machine-read source for amicode_solve) + const tmpToml = outPath + ".tmp"; + fs.writeFileSync(tmpToml, specToml, "utf8"); + fs.renameSync(tmpToml, outPath); + fs.writeFileSync(outJson + ".tmp", JSON.stringify(proj.spec, null, 2) + "\n", "utf8"); + fs.renameSync(outJson + ".tmp", outJson); + const pw = proj.warnings.length ? ` (${proj.warnings.join("; ")})` : ""; + projectionNote = `\n\nProjected ProblemSpec → ${outPath} (ajv-valid)${pw} — ready for scriptless solvespec.`; + } else { + projectionNote = `\n\nProjection produced invalid ProblemSpec (not written): ${errs.join("; ")} — falls back to script-tier authoring.`; + } + } else { + projectionNote = `\n\nNot spec-expressible: ${proj.reason} — falls back to script-tier (composed/free) authoring (today's path, untouched).`; + } + } else { + projectionNote = `\n\nNo system recorded — cannot project ProblemSpec (record system first).`; + } + } catch (e) { + projectionNote = `\n\nProjection failed: ${(e as Error).message} — falls back to script-tier.`; + } + + return `Formulation's locked for "${meta.slug}" — ${modes}, target ${merged.target}${warn}${projectionNote}\n\n${sentinel}`; }, }, @@ -920,8 +1292,84 @@ export const AmicodeTools = async (_input: unknown) => ({ ]; const warn = missing.length ? ` Note: no recorded ${missing.join(" or ")}.` : ""; const runWarn = given(a.run_dir) ? "" : " No run_dir yet — launch via the workflow's amico-run bash command."; + + // ── W2.1: read problem.toml/problem.json, apply overrides, emit solvespec with problem_spec ── + // Still never launches — the AGENTS.md bash workflow owns launch for both tiers. + let solvespecNote = ""; + try { + const specJsonPath = path.join(dir, "problem.json"); + const specTomlPath = path.join(dir, "problem.toml"); + let spec: Record | undefined; + if (fs.existsSync(specJsonPath)) { + try { + spec = JSON.parse(fs.readFileSync(specJsonPath, "utf8")) as Record; + } catch { + spec = undefined; + } + } + if (!spec && fs.existsSync(specTomlPath)) { + // Fallback: re-project from entities (avoids TOML parsing in the plugin) + const fRaw2 = readEntityJson>(meta.slug, "formulation"); + const sRaw2 = readEntityJson>(meta.slug, "system"); + if (fRaw2 && sRaw2) { + const f2 = normalizeFormulation(fRaw2); + const s2 = normalizeSystem(sRaw2); + const proj2 = projectToProblemSpecLocal(f2 as FormulationEntity, s2); + if (proj2.ok) spec = proj2.spec; + } + } + if (spec) { + let mutated = false; + if (given(a.T)) { + (spec.pulse as Record).T = a.T; + mutated = true; + } + if (given(a.N)) { + (spec.problem as Record).N = a.N; + mutated = true; + } + if (given(a.max_iter)) { + (spec.solver as Record).max_iter = a.max_iter; + mutated = true; + } + if (given(a.integrator)) { + const integ = projectionParseIntegrator(a.integrator, !!((spec.problem as Record).free_phase)); + spec.integrator = integ as unknown as Record; + mutated = true; + } + if (mutated) { + const errs = projectionValidateLight(spec); + if (errs.length === 0) { + const specToml = problemSpecToml(spec); + const tmpToml = specTomlPath + ".tmp"; + fs.writeFileSync(tmpToml, specToml, "utf8"); + fs.renameSync(tmpToml, specTomlPath); + const tmpJson = specJsonPath + ".tmp"; + fs.writeFileSync(tmpJson, JSON.stringify(spec, null, 2) + "\n", "utf8"); + fs.renameSync(tmpJson, specJsonPath); + } else { + solvespecNote = ` (overrides produced invalid spec: ${errs.join("; ")})`; + } + } + const solvespecPath = path.join(dir, "solvespec.json"); + const solvespec: Record = { + schema_version: "4", + lab_id: "default", + problem_spec: specTomlPath, + }; + const tmpSpec = solvespecPath + ".tmp"; + fs.writeFileSync(tmpSpec, JSON.stringify(solvespec, null, 2) + "\n", "utf8"); + fs.renameSync(tmpSpec, solvespecPath); + solvespecNote += ` Solvespec → ${solvespecPath} (problem_spec path; still never launches — run via amico-run --spec).`; + } else { + solvespecNote = " No ProblemSpec to wrap — project first via amicode_formulate (or fallback to script-tier)."; + } + } catch (e) { + solvespecNote = ` Solvespec emit failed: ${(e as Error).message}`; + } + completeStage(dir, "solve"); - return `Solve knobs set for "${meta.slug}".${warn}${runWarn}\n\n${sentinel}`; + return `Solve knobs set for "${meta.slug}".${warn}${runWarn}${solvespecNote}\n\n${sentinel}`; }, }, diff --git a/packages/extension/opencode-plugin/problems.ts b/packages/extension/opencode-plugin/problems.ts index cc0c087a..e6877ab3 100644 --- a/packages/extension/opencode-plugin/problems.ts +++ b/packages/extension/opencode-plugin/problems.ts @@ -66,22 +66,39 @@ export function setActiveSlug(slug: string): void { } // --- problem.json (the machine-read source) ---------------------------------- +// W2.1: the workspace *card* is now `card.toml`/`card.json` (the `problem.toml` +// basename is the typed ProblemSpec, validated as `problemspec`). Legacy +// workspaces with `problem.toml`/`problem.json` still read correctly via fallback. function readProblemMeta(slug: string): ProblemMeta | undefined { - const file = path.join(problemDir(slug), "problem.json"); - if (!fs.existsSync(file)) return undefined; - try { - return JSON.parse(fs.readFileSync(file, "utf8")) as ProblemMeta; - } catch { - return undefined; + const candidates = [path.join(problemDir(slug), "card.json"), path.join(problemDir(slug), "problem.json")]; + for (const file of candidates) { + if (!fs.existsSync(file)) continue; + try { + return JSON.parse(fs.readFileSync(file, "utf8")) as ProblemMeta; + } catch { + continue; + } } + return undefined; } -/** Write both problem.toml and its .json sidecar, stamping `recorded` = now. */ +/** Write card.toml + card.json, stamping `recorded` = now. Legacy `problem.*` + * files are removed on next write to free the `problem.toml` basename for the + * typed ProblemSpec (W2.1). Reading still falls back to the legacy path. */ function writeProblemMeta(meta: ProblemMeta): void { const stamped: ProblemMeta = { ...meta, recorded: new Date().toISOString() }; - atomicWrite(path.join(problemDir(meta.slug), "problem.toml"), problemToml(stamped)); - atomicWrite(path.join(problemDir(meta.slug), "problem.json"), problemJson(stamped)); + const dir = problemDir(meta.slug); + atomicWrite(path.join(dir, "card.toml"), problemToml(stamped)); + atomicWrite(path.join(dir, "card.json"), problemJson(stamped)); + // Migrate legacy files away (best-effort, ignore ENOENT) + for (const legacy of [path.join(dir, "problem.toml"), path.join(dir, "problem.json")]) { + try { + if (fs.existsSync(legacy)) fs.unlinkSync(legacy); + } catch { + /* ignore */ + } + } } /** First non-colliding slug: `base`, then `base-2`, `base-3`, … */ @@ -289,8 +306,8 @@ export function migrateLegacyEntities( status: "archived", recorded: new Date().toISOString(), }; - fs.writeFileSync(path.join(ws, "problem.toml"), problemToml(meta)); - fs.writeFileSync(path.join(ws, "problem.json"), problemJson(meta)); + fs.writeFileSync(path.join(ws, "card.toml"), problemToml(meta)); + fs.writeFileSync(path.join(ws, "card.json"), problemJson(meta)); const others = fs .readdirSync(problemsRoot, { withFileTypes: true }) .filter((e) => e.isDirectory() && e.name !== slug); diff --git a/packages/extension/test/problems.test.ts b/packages/extension/test/problems.test.ts index 43e005ca..fff3c1c6 100644 --- a/packages/extension/test/problems.test.ts +++ b/packages/extension/test/problems.test.ts @@ -49,18 +49,31 @@ describe("problemsDir / problemDir", () => { }); describe("createProblem", () => { - it("writes problem.toml + .json + entities/ and sets active", () => { + it("writes card.toml + card.json + entities/ and sets active (W2.1: problem.toml is the ProblemSpec)", () => { const meta = createProblem("X gate on Q1"); expect(meta.slug).toBe("x-gate-on-q1"); expect(meta.status).toBe("designing"); const dir = problemDir("x-gate-on-q1"); - expect(fs.existsSync(path.join(dir, "problem.toml"))).toBe(true); - expect(fs.existsSync(path.join(dir, "problem.json"))).toBe(true); + expect(fs.existsSync(path.join(dir, "card.toml"))).toBe(true); + expect(fs.existsSync(path.join(dir, "card.json"))).toBe(true); expect(fs.existsSync(path.join(dir, "entities"))).toBe(true); + // legacy problem.* must NOT exist — that basename is now the ProblemSpec + expect(fs.existsSync(path.join(dir, "problem.toml"))).toBe(false); + expect(fs.existsSync(path.join(dir, "problem.json"))).toBe(false); expect(readActiveSlug()).toBe("x-gate-on-q1"); - const doc = parse(fs.readFileSync(path.join(dir, "problem.toml"), "utf8")) as any; + const doc = parse(fs.readFileSync(path.join(dir, "card.toml"), "utf8")) as any; expect(doc.problem.name).toBe("X gate on Q1"); }); + it("reads legacy problem.json when card.json absent (backward compat)", () => { + const dir = problemDir("legacy-read"); + fs.mkdirSync(dir, { recursive: true }); + // Simulate old workspace with problem.json only + fs.writeFileSync(path.join(dir, "problem.json"), JSON.stringify({ name: "old", slug: "legacy-read", created: new Date().toISOString(), status: "designing" })); + fs.writeFileSync(path.join(dir, "problem.toml"), `[problem]\nname = "old"\nslug = "legacy-read"\ncreated = "2026-01-01T00:00:00Z"\nstatus = "designing"\nrecorded = "2026-01-01T00:00:00Z"\n`); + setActiveSlug("legacy-read"); + const opened = openProblem("legacy-read"); + expect(opened?.slug).toBe("legacy-read"); + }); it("auto-suffixes a colliding slug", () => { createProblem("X gate"); const second = createProblem("X gate"); @@ -229,8 +242,8 @@ describe("migrateLegacyEntities (injectable roots — env-skip lives at the call expect(fs.existsSync(path.join(ws, "score_manifest.json"))).toBe(true); expect(fs.existsSync(path.join(ws, "interview_state.json"))).toBe(true); expect(fs.existsSync(path.join(ws, "usage.jsonl"))).toBe(true); - // synthesized archived meta + active set (no other problem) - const meta = JSON.parse(fs.readFileSync(path.join(ws, "problem.json"), "utf8")); + // synthesized archived meta + active set (no other problem) — W2.1: card.* is the card + const meta = JSON.parse(fs.readFileSync(path.join(ws, "card.json"), "utf8")); expect(meta.status).toBe("archived"); expect(fs.readFileSync(path.join(root, "active"), "utf8").trim()).toBe(dirs[0]); fs.rmSync(legacy, { recursive: true, force: true }); diff --git a/packages/schema/julia/validate.jl b/packages/schema/julia/validate.jl index 13fb9e07..e29bf97a 100644 --- a/packages/schema/julia/validate.jl +++ b/packages/schema/julia/validate.jl @@ -21,8 +21,18 @@ kind_for_filename(path) = begin b == "run.toml" ? "run" : b == "result.toml" ? "result" : b == "lab.toml" ? "lab" : - b == "problem.toml" ? "problemspec" : - b == "FINISHED" ? "finished" : nothing + b == "FINISHED" ? "finished" : + b == "card.toml" ? nothing : + b == "problem.toml" ? begin + # Table-sniff: legacy workspace cards share the basename but carry `[problem]`/`name =`. + # Return nothing (no schema) for a card so the CLI prompts for --schema instead of mis-validating. + try + raw = read(path, String)[1:min(4096, end)] + occursin(r"^\[problem\]"m, raw) && occursin(r"\bname\s*=\s*\"", raw) ? nothing : "problemspec" + catch + "problemspec" + end + end : nothing end schema_path(kind) = joinpath(SCHEMA_DIR, "$(kind).schema.json") diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index de3e4cb6..e64c6533 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -43,6 +43,8 @@ import planSchema from "../schemas/plan.schema.json" with { type: "json" }; // "exports" map, so a subpath import would work, but the root export is the // established, documented seam every other consumer uses — see `validate` below). export { structureHash, problemHash, canonicalJson, fullDict, structureFields, sha256hex, designHash, planHash } from "./hashing.js"; +export { projectToProblemSpec, isSpecExpressible } from "./project.js"; +export type { FormulationEntityLike, CompositeSystemLike, ProjectionResult } from "./project.js"; // ajv-formats ships a CJS default export; under NodeNext the default import can // bind the module namespace rather than the callable, so normalize defensively. @@ -101,14 +103,33 @@ export interface Validation { /** Resolve a schema kind from a file's basename, for the fixed-filename artifacts * (run.toml, result.toml, lab.toml, FINISHED). Returns undefined for files * with no canonical name (SolveSpec, catalog-entry) — those need an explicit - * --schema. The amico-validate CLI uses this for file-role resolution. */ + * --schema. The amico-validate CLI uses this for file-role resolution. + * + * `problem.toml` is the ProblemSpec control artifact (schema_version=1, kind=control). + * The workspace *card* (`~/.amico/problems//card.toml`, formerly `problem.toml`) + * shares the basename on older workspaces — to avoid misfiring, this function + * table-sniffs when the file exists: a file whose first 4 KiB contains a `[problem]` + * table with a `name =`/`slug =` key is the card, not a ProblemSpec, and returns + * undefined so the caller prompts for --schema instead of mis-validating. */ export function kindForFilename(filePath: string): SchemaKind | undefined { const base = filePath.replace(/^.*[\\/]/, ""); if (base === "run.toml") return "run"; if (base === "result.toml") return "result"; if (base === "lab.toml") return "lab"; if (base === "FINISHED") return "finished"; - if (base === "problem.toml") return "problemspec"; + if (base === "problem.toml") { + // Table-sniff to disambiguate legacy workspace cards (W2.1). + // A card's TOML starts with `[problem]` and carries `name`/`slug`; a ProblemSpec + // starts with `schema_version = 1` / `kind = "control"` and a `[system]` table. + try { + const raw = readFileSync(filePath, "utf8").slice(0, 4096); + if (/^\[problem\]/m.test(raw) && /\bname\s*=\s*"/m.test(raw)) return undefined; + } catch { + // file absent / unreadable — fall through to the basename verdict + } + return "problemspec"; + } + if (base === "card.toml") return undefined; // workspace card — no schema return undefined; } diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts new file mode 100644 index 00000000..8d87b224 --- /dev/null +++ b/packages/schema/src/project.ts @@ -0,0 +1,355 @@ +// packages/schema/src/project.ts — deterministic projection +// FormulationEntity + CompositeSystem → ProblemSpec (control branch) +// +// No LLM in the path. Pure function implementing the master spec's +// mapping table (spec-20260717-020639): +// parameterization → pulse.kind + problem.template +// min_time → goal_treatment="both" + free_dt + time objective +// ensemble → sampling wrapper +// leakage → problem.options +// custom → not spec-expressible fallback with reason +// T/N/max_iter/integrator etc. flow through from solve params. +// +// Used by: +// - amicode_formulate v2 (writes ~/.amico/problems//problem.toml) +// - tests / goldens +// The plugin (amicode_tools.ts) imports this via relative path +// ../../schema/src/project.js — Bun transpiles TS natively. +// +// The ProblemSpec shape is the vendored FULL variant's `control` branch: +// { schema_version:1, kind:"control", system, goal?, pulse, problem, +// trajectory?, integrator?, solver?, wrappers?, ... } +// Validation is external (ajv validate(..., "problemspec") before write). + +export type TrajectoryType = "ket" | "multiket" | "gate" | "density" | "multidensity"; +export type TimeMode = "fixed" | "min_time"; +export type Parameterization = "smooth" | "linear_spline" | "cubic_spline" | "bang_bang"; +export type RobustnessKind = "none" | "ensemble" | "sensitivity"; + +// Minimal structural mirrors of the entity types (do NOT import from +// extension/opencode-plugin/entities.ts to keep @amicode/schema self-contained). +export interface FormulationEntityLike { + trajectory_type: TrajectoryType; + time_mode: TimeMode; + time_params?: Record; + parameterization: Parameterization; + robustness: { kind: RobustnessKind; params: Record }; + free_phase: boolean; + leakage: boolean; + leakage_params?: Record; + target: string; + objectives: Array<{ kind: string; params: Record; label?: string }>; + constraints: Array<{ kind: string; params: Record; label?: string }>; + solve?: { T?: number; N?: number; max_iter?: number; integrator?: string; parameterization?: string }; + notes?: string; +} + +export interface CompositeSystemLike { + platform: string; + components: Array<{ id: string; role: string; levels?: number; params: Record }>; + couplings: Array<{ between: string[]; kind: string; params: Record }>; + topology?: string; + drive: { arch: string }; + hamiltonian?: unknown; + notes?: string; +} + +export type ProjectionOk = { ok: true; spec: Record; warnings: string[] }; +export type ProjectionErr = { ok: false; reason: string; spec?: undefined }; +export type ProjectionResult = ProjectionOk | ProjectionErr; + +// ── helpers ──────────────────────────────────────────────────────────────── + +function systemTemplate(sys: CompositeSystemLike): string { + const p = sys.platform.toLowerCase(); + const n = sys.components.length; + const hasCavity = sys.components.some((c) => c.role === "cavity" || c.role === "resonator" || c.role === "mode"); + const hasQubit = sys.components.some((c) => c.role === "qubit" || c.role === "atom"); + if (p === "transmon") return n === 1 ? "TransmonSystem" : "MultiTransmonSystem"; + if (p === "rydberg") return "RydbergChainSystem"; + if (p === "bosonic" || p === "cavity") { + if (hasQubit && hasCavity) return "TransmonCavitySystem"; + return "CatSystem"; + } + if (p === "ion" || p === "trapped-ion") return "IonChainSystem"; + // fallback: if cavity present → CatSystem, else single → TransmonSystem, multi → MultiTransmonSystem + if (hasCavity && !hasQubit) return "CatSystem"; + return n === 1 ? "TransmonSystem" : "MultiTransmonSystem"; +} + +function pulseAndTemplate( + p: Parameterization, +): { pulseKind: string; template: string } { + switch (p) { + case "smooth": + return { pulseKind: "zero_order", template: "SmoothPulseProblem" }; + case "bang_bang": + return { pulseKind: "zero_order", template: "BangBangPulseProblem" }; + case "linear_spline": + return { pulseKind: "linear_spline", template: "SplinePulseProblem" }; + case "cubic_spline": + return { pulseKind: "cubic_spline", template: "SplinePulseProblem" }; + } +} + +function parseIntegrator( + raw: string | undefined, + freePhase: boolean, +): { kind: string; alg: string } { + const s = (raw ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); + let out: { kind: string; alg: string }; + if (s.includes("magnusgl4") || s === "gl4") out = { kind: "exponential", alg: "magnus_gl4" }; + else if (s.includes("magnusadapt4") || s.includes("adapt4")) out = { kind: "spline", alg: "magnus_adapt4" }; + else if (s.includes("tsit5")) out = { kind: "bilinear", alg: "tsit5" }; + else if (s.includes("bilinear")) out = { kind: "bilinear", alg: "tsit5" }; + else if (s.includes("spline")) out = { kind: "spline", alg: "magnus_adapt4" }; + else if (s.includes("exponential")) out = { kind: "exponential", alg: "magnus_gl4" }; + else out = { kind: "bilinear", alg: "tsit5" }; + // free_phase requires exponential|spline (schema if/then) — upgrade bilinear silently + if (freePhase && out.kind === "bilinear") out = { kind: "spline", alg: "magnus_adapt4" }; + return out; +} + +function dtBoundsFromConstraints(constraints: FormulationEntityLike["constraints"]): [number, number] | undefined { + const c = constraints.find((x) => x.kind === "dt_bounds"); + if (!c) return undefined; + const p = c.params ?? {}; + // Accept several key spellings; params may carry dt_min/dt_max, lo/hi, lower/upper, min/max + const lo = + (p.dt_min as number) ?? + (p.lo as number) ?? + (p.lower as number) ?? + (p.min as number) ?? + (p.dt_lower as number); + const hi = + (p.dt_max as number) ?? + (p.hi as number) ?? + (p.upper as number) ?? + (p.max as number) ?? + (p.dt_upper as number); + if (typeof lo === "number" && typeof hi === "number" && Number.isFinite(lo) && Number.isFinite(hi)) { + return [lo, hi]; + } + // If the constraint has no numeric bounds, treat as unspecified (caller defaults) + return undefined; +} + +// ── main projection ────────────────────────────────────────────────────── + +export function projectToProblemSpec( + formulation: FormulationEntityLike, + system: CompositeSystemLike, +): ProjectionResult { + const warnings: string[] = []; + + // ── 1. non-spec-expressible fallback checks ────────────────────────── + // Custom objectives / constraints: no ProblemSpec representation. + const customObj = formulation.objectives.find((o) => o.kind === "custom"); + if (customObj) { + return { + ok: false, + reason: `custom objective not spec-expressible: ${customObj.label ?? customObj.kind} — falls back to script-tier authoring`, + }; + } + const customCon = formulation.constraints.find((c) => c.kind === "custom"); + if (customCon) { + return { + ok: false, + reason: `custom constraint not spec-expressible: ${customCon.label ?? customCon.kind} — falls back to script-tier authoring`, + }; + } + // Trajectory types beyond gate/ket are not expressible as control ProblemSpec + // (the control branch only knows ket|unitary). + if ( + formulation.trajectory_type === "density" || + formulation.trajectory_type === "multidensity" || + formulation.trajectory_type === "multiket" + ) { + return { + ok: false, + reason: `trajectory_type "${formulation.trajectory_type}" not spec-expressible as a control ProblemSpec — falls back to script-tier`, + }; + } + + // ── 2. system ──────────────────────────────────────────────────────── + const template = systemTemplate(system); + const sys: Record = { kind: "template", template }; + // Pass through levels + any numeric global params via system.params. + // For a single-component system, surface its levels; for multi, keep the map minimal. + const firstLevels = system.components[0]?.levels; + if (typeof firstLevels === "number") { + sys.params = { levels: firstLevels }; + } else if (system.components.length === 1 && Object.keys(system.components[0].params ?? {}).length > 0) { + // Preserve any numeric params if levels absent — still useful for hashing parity + const p: Record = {}; + for (const [k, v] of Object.entries(system.components[0].params)) if (typeof v === "number") p[k] = v; + if (Object.keys(p).length > 0) sys.params = p; + } + // If the system has >1 component, also surface all levels implicitly via goal.subsystem_levels + // (system.params itself stays single-level for template systems that expect a scalar). + + // ── 3. goal + trajectory ───────────────────────────────────────────── + let trajectoryKind: string; + let goalKind: string; + if (formulation.trajectory_type === "gate") { + trajectoryKind = "unitary"; + goalKind = "unitary"; + } else { + // ket (single-state prep) — the only other expressible trajectory + trajectoryKind = "ket"; + goalKind = "ket"; + } + + const subsystemLevels = system.components.map((c) => (typeof c.levels === "number" ? c.levels : 3)); + const goal: Record = { kind: goalKind, subsystem_levels: subsystemLevels }; + if (goalKind === "unitary") { + // Gate name — preserve the target string verbatim (e.g. "CZ", "X", "H") + goal.gate = formulation.target || "X"; + } else { + goal.target = formulation.target || "|0>"; + } + + // ── 4. pulse + problem template ────────────────────────────────────── + const effParam = (formulation.solve?.parameterization as Parameterization | undefined) ?? formulation.parameterization; + const { pulseKind, template: probTemplate } = pulseAndTemplate(effParam); + + const T = formulation.solve?.T ?? 40; + const N = formulation.solve?.N ?? 40; + + const pulse: Record = { kind: pulseKind, T, init: "default", seed: 0 }; + + const problem: Record = { template: probTemplate, N }; + + // min_time vs fixed + if (formulation.time_mode === "min_time") { + problem.goal_treatment = "both"; + const bounds = dtBoundsFromConstraints(formulation.constraints); + problem.free_dt = bounds ?? [0.01, 2.0]; + const ff = formulation.time_params?.final_fidelity; + problem.final_fidelity = typeof ff === "number" ? ff : 0.99; + // time objective weight D + const D = formulation.time_params?.D; + const w = typeof D === "number" ? D : 100; + // Merge with any regularizer objectives → problem.objectives includes time + regs + const objs: Array> = [{ kind: "time", weight: w }]; + for (const o of formulation.objectives) { + if (o.kind === "reg_u" || o.kind === "reg_du" || o.kind === "reg_ddu" || o.kind === "sensitivity") { + const weight = (o.params as Record).weight as number | undefined; + objs.push({ kind: o.kind === "reg_u" ? "reg_u" : o.kind === "reg_du" ? "reg_du" : o.kind === "reg_ddu" ? "reg_ddu" : "sensitivity", weight: typeof weight === "number" ? weight : 1 }); + } + } + problem.objectives = objs; + } else { + problem.goal_treatment = "objective"; + problem.free_dt = false; + // Regularizer weights for fixed-time: surface as direct problem fields + // where the mapping is unambiguous, plus objectives array carry-through. + const objs: Array> = []; + for (const o of formulation.objectives) { + if (o.kind === "reg_u") problem.R_u = (o.params as Record).weight as number ?? (o.params as Record).value as number ?? 1e-4; + else if (o.kind === "reg_du") problem.R_du = (o.params as Record).weight as number ?? 1e-5; + else if (o.kind === "reg_ddu") problem.R_ddu = (o.params as Record).weight as number ?? 1e-6; + else if (o.kind === "sensitivity") objs.push({ kind: "sensitivity", weight: (o.params as Record).weight as number ?? 1 }); + } + if (objs.length > 0) problem.objectives = objs; + // Also respect direct Q/R fields if objectives carry them via params.Q etc? Not needed. + } + + // free_phase + if (formulation.free_phase) problem.free_phase = true; + + // leakage → problem.options.leakage_constraint + if (formulation.leakage) { + const opts: Record = {}; + const lp = formulation.leakage_params ?? {}; + // Master spec: leakage → options.leakage_constraint (+ value/cost if given) + opts.leakage_constraint = true; + if (typeof lp.value === "number") opts.leakage_constraint_value = lp.value; + if (typeof lp.cost === "number") opts.leakage_cost = lp.cost; + // Also allow a boolean leakage_constraint_value already + problem.options = opts; + } + + // calibration_pin constraint → problem.calibration_targets + const cal = formulation.constraints.find((c) => c.kind === "calibration_pin"); + if (cal) { + const names = Object.keys(cal.params ?? {}); + if (names.length > 0) problem.calibration_targets = names; + else if (cal.label) problem.calibration_targets = [cal.label]; + } + + // Global bounds / du_bound etc. — if constraints include bounds/du_bound/ddu_bound, + // surface them as direct problem fields where the schema expects them. + for (const c of formulation.constraints) { + if (c.kind === "bounds" && c.params) { + // bounds constraint carries amplitude caps — store as global_bounds + problem.global_bounds = { ...c.params }; + } else if (c.kind === "du_bound" && typeof (c.params as Record).value === "number") { + problem.du_bound = (c.params as Record).value as number; + } else if (c.kind === "ddu_bound" && typeof (c.params as Record).value === "number") { + problem.ddu_bound = (c.params as Record).value as number; + } else if (c.kind === "du_bound" && typeof (c.params as Record).du_bound === "number") { + problem.du_bound = (c.params as Record).du_bound as number; + } + } + + // ── 5. integrator + solver ─────────────────────────────────────────── + const integrator = parseIntegrator(formulation.solve?.integrator, formulation.free_phase); + const maxIter = formulation.solve?.max_iter; + const solver: Record = { + backend: "ipopt", + device: "cpu", + precision: "f64", + max_iter: typeof maxIter === "number" ? maxIter : 500, + strategy: "direct", + }; + + // ── 6. wrappers (ensemble / sensitivity) ────────────────────────────── + let wrappers: Array> | undefined; + if (formulation.robustness.kind === "ensemble") { + const variants: unknown[] = []; + // If robustness.params carries an explicit variants array, use it; else one variant from params. + const maybeVariants = (formulation.robustness.params as Record).variants; + if (Array.isArray(maybeVariants)) variants.push(...(maybeVariants as unknown[])); + else variants.push({ ...formulation.robustness.params }); + const w: Record = { kind: "sampling", variants }; + const weights = (formulation.robustness.params as Record).weights; + if (Array.isArray(weights)) w.weights = weights; + wrappers = [w]; + } else if (formulation.robustness.kind === "sensitivity") { + wrappers = [{ kind: "robust", variants: [{ ...formulation.robustness.params }] }]; + } + + // ── 7. assemble ────────────────────────────────────────────────────── + const spec: Record = { + schema_version: 1, + kind: "control", + system: sys, + goal, + pulse, + problem, + trajectory: { kind: trajectoryKind }, + integrator, + solver, + }; + if (wrappers) spec.wrappers = wrappers; + + // Minimal sanity: pulse kind must match template per schema conditionals. + // This is already enforced by pulseAndTemplate, but warn if someone later edits the map. + if (probTemplate === "SplinePulseProblem" && pulseKind === "zero_order") { + warnings.push(`template ${probTemplate} requires cubic_spline|linear_spline pulse, got ${pulseKind}`); + } + if ((probTemplate === "SmoothPulseProblem" || probTemplate === "BangBangPulseProblem") && pulseKind !== "zero_order") { + warnings.push(`template ${probTemplate} requires zero_order pulse, got ${pulseKind}`); + } + + return { ok: true, spec, warnings }; +} + +/** Convenience: true when the facet set is spec-expressible (no fallback). */ +export function isSpecExpressible( + formulation: FormulationEntityLike, + _system: CompositeSystemLike, +): boolean { + return projectToProblemSpec(formulation, _system).ok; +} diff --git a/packages/schema/test/project.test.ts b/packages/schema/test/project.test.ts new file mode 100644 index 00000000..0b71e82f --- /dev/null +++ b/packages/schema/test/project.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from "vitest"; +import { validate } from "../src/index.js"; +import { projectToProblemSpec } from "../src/project.js"; +import type { FormulationEntityLike, CompositeSystemLike } from "../src/project.js"; + +function sys(over: Partial = {}): CompositeSystemLike { + return { + platform: "transmon", + components: [{ id: "q1", role: "qubit", levels: 3, params: {} }], + couplings: [], + drive: { arch: "per-component" }, + ...over, + }; +} +function form(over: Partial = {}): FormulationEntityLike { + return { + trajectory_type: "gate", + time_mode: "fixed", + parameterization: "smooth", + robustness: { kind: "none", params: {} }, + free_phase: false, + leakage: false, + target: "X", + objectives: [], + constraints: [], + ...over, + }; +} + +describe("projectToProblemSpec — deterministic projection goldens (W2.1)", () => { + it("base X gate, smooth → SmoothPulseProblem + zero_order, T/N/max_iter/integrator", () => { + const res = projectToProblemSpec( + form({ parameterization: "smooth", solve: { T: 40, N: 40, max_iter: 300, integrator: "tsit5" } }), + sys(), + ); + expect(res.ok).toBe(true); + if (!res.ok) return; + expect((res.spec.system as any).template).toBe("TransmonSystem"); + expect((res.spec.pulse as any).kind).toBe("zero_order"); + expect((res.spec.pulse as any).T).toBe(40); + expect((res.spec.problem as any).template).toBe("SmoothPulseProblem"); + expect((res.spec.problem as any).N).toBe(40); + expect((res.spec.solver as any).max_iter).toBe(300); + expect((res.spec.integrator as any).kind).toBe("bilinear"); + expect((res.spec.integrator as any).alg).toBe("tsit5"); + expect(validate(res.spec, "problemspec").ok).toBe(true); + }); + + it("cubic_spline → SplinePulseProblem + cubic_spline; linear similarly", () => { + const cubic = projectToProblemSpec(form({ parameterization: "cubic_spline" }), sys()); + expect(cubic.ok && (cubic.spec.pulse as any).kind).toBe("cubic_spline"); + expect(cubic.ok && (cubic.spec.problem as any).template).toBe("SplinePulseProblem"); + expect(cubic.ok && validate(cubic.spec, "problemspec").ok).toBe(true); + const linear = projectToProblemSpec(form({ parameterization: "linear_spline" }), sys()); + expect(linear.ok && (linear.spec.pulse as any).kind).toBe("linear_spline"); + expect(linear.ok && validate(linear.spec, "problemspec").ok).toBe(true); + }); + + it("bang_bang → BangBangPulseProblem + zero_order", () => { + const r = projectToProblemSpec(form({ parameterization: "bang_bang" }), sys()); + expect(r.ok && (r.spec.pulse as any).kind).toBe("zero_order"); + expect(r.ok && (r.spec.problem as any).template).toBe("BangBangPulseProblem"); + expect(r.ok && validate(r.spec, "problemspec").ok).toBe(true); + }); + + it("min_time → goal_treatment both + free_dt + time objective + final_fidelity", () => { + const r = projectToProblemSpec( + form({ + time_mode: "min_time", + time_params: { final_fidelity: 0.999, D: 50 }, + constraints: [{ kind: "dt_bounds", params: { dt_min: 0.05, dt_max: 1.5 } }], + }), + sys(), + ); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect((r.spec.problem as any).goal_treatment).toBe("both"); + expect((r.spec.problem as any).free_dt).toEqual([0.05, 1.5]); + expect((r.spec.problem as any).final_fidelity).toBeCloseTo(0.999); + const objs = (r.spec.problem as any).objectives as Array<{ kind: string; weight: number }>; + expect(objs.some((o) => o.kind === "time" && o.weight === 50)).toBe(true); + expect(validate(r.spec, "problemspec").ok).toBe(true); + }); + + it("min_time without dt_bounds defaults free_dt to [0.01,2.0] and still validates", () => { + const r = projectToProblemSpec(form({ time_mode: "min_time" }), sys()); + expect(r.ok && (r.spec.problem as any).free_dt).toEqual([0.01, 2.0]); + expect(r.ok && validate(r.spec, "problemspec").ok).toBe(true); + }); + + it("free_phase true → problem.free_phase and forces integrator to spline|exponential", () => { + const r = projectToProblemSpec(form({ free_phase: true, solve: { integrator: "bilinear" } }), sys()); + expect(r.ok && (r.spec.problem as any).free_phase).toBe(true); + expect(r.ok && ["spline", "exponential"].includes((r.spec.integrator as any).kind)).toBe(true); + expect(r.ok && validate(r.spec, "problemspec").ok).toBe(true); + }); + + it("ensemble robustness → sampling wrapper", () => { + const r = projectToProblemSpec(form({ robustness: { kind: "ensemble", params: { sigma: 0.01 } } }), sys()); + expect(r.ok && Array.isArray(r.spec.wrappers)).toBe(true); + expect(r.ok && (r.spec.wrappers as Array<{ kind: string }>)[0].kind).toBe("sampling"); + expect(r.ok && validate(r.spec, "problemspec").ok).toBe(true); + }); + + it("leakage true → problem.options.leakage_constraint", () => { + const r = projectToProblemSpec(form({ leakage: true, leakage_params: { value: 0.001, cost: 10 } }), sys()); + expect(r.ok && (r.spec.problem as any).options?.leakage_constraint).toBe(true); + expect(r.ok && (r.spec.problem as any).options?.leakage_constraint_value).toBeCloseTo(0.001); + expect(r.ok && validate(r.spec, "problemspec").ok).toBe(true); + }); + + it("custom objective → not spec-expressible fallback with reason", () => { + const r = projectToProblemSpec(form({ objectives: [{ kind: "custom", params: {}, label: "weird" }] }), sys()); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toMatch(/custom objective/); + }); + + it("custom constraint → not spec-expressible fallback", () => { + const r = projectToProblemSpec(form({ constraints: [{ kind: "custom", params: {}, label: "odd" }] }), sys()); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toMatch(/custom constraint/); + }); + + it("density trajectory → not spec-expressible", () => { + const r = projectToProblemSpec(form({ trajectory_type: "density" }), sys()); + expect(r.ok).toBe(false); + }); + + it("2-transmon CZ → MultiTransmonSystem, subsystem_levels [3,3], free_phase", () => { + const twoQ = sys({ + platform: "transmon", + components: [ + { id: "q1", role: "qubit", levels: 3, params: {} }, + { id: "q2", role: "qubit", levels: 3, params: {} }, + ], + couplings: [{ between: ["q1", "q2"], kind: "cross-resonance", params: { g: 0.005 } }], + drive: { arch: "per-component" }, + }); + const r = projectToProblemSpec(form({ target: "CZ", free_phase: true }), twoQ); + expect(r.ok && (r.spec.system as any).template).toBe("MultiTransmonSystem"); + expect(r.ok && (r.spec.goal as any).subsystem_levels).toEqual([3, 3]); + expect(r.ok && (r.spec.goal as any).gate).toBe("CZ"); + expect(r.ok && (r.spec.problem as any).free_phase).toBe(true); + expect(r.ok && validate(r.spec, "problemspec").ok).toBe(true); + }); + + it("integrator MagnusGL4 → exponential/magnus_gl4, MagnusAdapt4 → spline/magnus_adapt4", () => { + const gl4 = projectToProblemSpec(form({ solve: { integrator: "MagnusGL4" } }), sys()); + expect(gl4.ok && (gl4.spec.integrator as any).alg).toBe("magnus_gl4"); + const adapt = projectToProblemSpec(form({ solve: { integrator: "MagnusAdapt4" } }), sys()); + expect(adapt.ok && (adapt.spec.integrator as any).alg).toBe("magnus_adapt4"); + }); + + it("ket trajectory → ket goal and trajectory, validates", () => { + const r = projectToProblemSpec(form({ trajectory_type: "ket", target: "|1>" }), sys()); + expect(r.ok && (r.spec.goal as any).kind).toBe("ket"); + expect(r.ok && (r.spec.trajectory as any).kind).toBe("ket"); + expect(r.ok && validate(r.spec, "problemspec").ok).toBe(true); + }); +}); + +describe("kindForFilename — card.toml vs problem.toml disambiguation (W2.1)", () => { + it("problem.toml with [system] → problemspec; card.toml → undefined", async () => { + const { kindForFilename } = await import("../src/index.js"); + // non-existent files fall back to basename mapping + expect(kindForFilename("/tmp/any/problem.toml")).toBe("problemspec"); + expect(kindForFilename("/tmp/any/card.toml")).toBeUndefined(); + }); + it("an on-disk card-shaped problem.toml table-sniffs to undefined", async () => { + const { mkdtempSync, writeFileSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { kindForFilename } = await import("../src/index.js"); + const dir = mkdtempSync(join(tmpdir(), "kind-sniff-")); + const cardPath = join(dir, "problem.toml"); + writeFileSync(cardPath, `[problem]\nname = "old"\nslug = "old"\ncreated = "2026-01-01T00:00:00Z"\nstatus = "designing"\nrecorded = "2026-01-01T00:00:00Z"\n`); + expect(kindForFilename(cardPath)).toBeUndefined(); + // a real spec-shaped file still maps to problemspec + const specDir = join(dir, "specdir"); + const { mkdirSync } = await import("node:fs"); + mkdirSync(specDir, { recursive: true }); + const specFile = join(specDir, "problem.toml"); + writeFileSync(specFile, `schema_version = 1\nkind = "control"\n[system]\nkind = "template"\ntemplate = "TransmonSystem"\n[pulse]\nkind = "zero_order"\nT = 40\n[problem]\ntemplate = "SmoothPulseProblem"\nN = 40\n`); + expect(kindForFilename(specFile)).toBe("problemspec"); + rmSync(dir, { recursive: true, force: true }); + }); +});