From 7d290c828b1cec7f8c39af47287d0024b6288d68 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Wed, 12 Aug 2026 15:20:57 +0200 Subject: [PATCH] spec tier, typed estimate, rollout verify, hash provenance (closes #341 #342 #343 #344) - #341 catalog.ts: add spec tier above vetted (fully validated data, no authored code); out-of-subset falls through with reason; gate.ts validates problem_spec against entitlement-keyed FULL/OSS schema variant (issimo -> full else OSS), making problemspec.oss.schema.json load-bearing; update solvespec/run schemas and pulse-designer SCORE solve-stage language. - #342 estimate.ts: parse problem_spec TOML and read typed fields (problem.N, system.params.levels/subsystem_levels, trajectory kind, wrapper count) via extractKeyVarsFromSpec; share size model (unitary squaring, ket single, sampling multiplier) with regex path; subcommands.ts estimateCommand routes problem_spec specs via typed adapter, keeps regex for scripts; reuse fixtures. - #343 verify.ts v2: derive rollout spec via referee_rollout(control_spec, run) (strictly finer, different integrator family, forge-proof [referee] block), synchronous run (scoped exception to never-launches), typed Verdict/Agree witness; legacy system_verify.jld2 path retained as fallback with migration note. - #344 launch.ts: when solvespec carries problem_spec compute structureHash/problemHash in TS (@amicode/schema hashing.ts byte-exact mirror) and stamp into run.toml [hashes]; local_executor.emitSolveStanza falls back to run.toml when result.toml lacks keys and reads inline problem.toml; repertoire.ts/catalog_verb.ts stamp content hash of pulse.jld2 into pulse_hash for verifiable warm-start. Roadmap plan-20260812-142100-problemspecs-issimo-roadmap W2.2-W2.4 + W4.1 --- packages/amico-run/src/catalog.ts | 80 ++++++- packages/amico-run/src/catalog_verb.ts | 8 + packages/amico-run/src/estimate.ts | 92 ++++++++- packages/amico-run/src/gate.ts | 23 ++- packages/amico-run/src/launch.ts | 32 ++- packages/amico-run/src/local_executor.ts | 18 +- packages/amico-run/src/repertoire.ts | 11 + packages/amico-run/src/subcommands.ts | 39 +++- packages/amico-run/src/verify.ts | 195 ++++++++++++++++-- .../extension/scores/pulse-designer/SCORE.md | 1 + packages/schema/schemas/run.schema.json | 8 +- packages/schema/schemas/solvespec.schema.json | 4 +- packages/schema/src/index.ts | 26 +++ packages/schema/test/validate.test.ts | 5 +- 14 files changed, 499 insertions(+), 43 deletions(-) diff --git a/packages/amico-run/src/catalog.ts b/packages/amico-run/src/catalog.ts index 855f0a50..6f0b251f 100644 --- a/packages/amico-run/src/catalog.ts +++ b/packages/amico-run/src/catalog.ts @@ -52,11 +52,50 @@ export interface Shape { size: number; } +export type Tier = "spec" | "vetted" | "composed" | "free"; + export interface ShapeMatch { - tier: "vetted" | "composed" | "free"; + tier: Tier; template?: TemplateEntry; exemplar?: ExemplarEntry; - blockedHigher?: { tier: "vetted" | "composed"; requires: string }; + blockedHigher?: { tier: "spec" | "vetted" | "composed"; requires: string }; + reason?: string; // present when spec was considered but fell through +} + +/** Spec-expressibility check (W2.2): a formulation is spec-expressible when its + * ProblemSpec validates against the entitlement-keyed schema. Custom/bespoke + * objective kinds that are not in either schema render it not spec-expressible + * and it falls through to vetted/composed/free with the reason surfaced. The + * check is schema-driven, not hand-rolled: the schemas ARE the subset definition. */ +export function isSpecExpressible( + problemSpec: unknown, + hasIssimo: boolean, +): { expressible: true } | { expressible: false; reason: string } { + // Lazy import to avoid circular deps — use dynamic validateProblemSpec lookup + // via @amicode/schema at call site. Here we inline a minimal check: unknown + // kinds fail validation. Caller should use validateProblemSpec directly. + // This helper is a convenience for tests; production uses validateProblemSpec. + if (problemSpec == null || typeof problemSpec !== "object") { + return { expressible: false, reason: "no problemSpec supplied" }; + } + const obj = problemSpec as Record; + // Custom objective heuristic: an objective kind of "custom" or any key that + // the schema would reject. We surface a typed reason so the agent can explain + // the fallback rather than silently downgrading. + const problem = obj.problem as Record | undefined; + const objectives = problem?.objectives; + if (Array.isArray(objectives)) { + for (const o of objectives) { + const kind = (o as Record)?.kind; + if (typeof kind === "string" && kind === "custom") { + return { expressible: false, reason: "custom objective kind 'custom' is not spec-expressible" }; + } + } + } + // If no custom marker, defer to schema validation at the call site — assume + // expressible here; the gate's validate() is authoritative. + void hasIssimo; + return { expressible: true }; } const EMPTY_REGISTRY: Registry = { templates: [], support: [], uuids: {}, verifyTolerance: 0.01 }; @@ -132,16 +171,43 @@ export function loadExemplarsIndex(file: string): ExemplarsIndex { return { exemplars }; } -/** Tier resolution (spec C, locked decision 5): exact vetted template match → - * tier 1; else exemplar match on platform+kind (size may differ) → tier 2; - * else tier 3. Entitlement- or allowlist-blocked higher matches are excluded - * from selection but reported via blockedHigher so the agent can run the - * explicit-confirmation flow (never a silent downgrade). */ +/** Tier resolution (spec C, locked decision 5; W2.2 adds `spec` above vetted): + * spec (fully validated data, no authored code) → tier 0; exact vetted template + * match → tier 1; else exemplar match on platform+kind (size may differ) → tier 2; + * else tier 3. A spec-expressible formulation resolves to `spec` WITHOUT + * consulting the registry; out-of-subset falls through to vetted/composed/free + * with the reason surfaced. Entitlement- or allowlist-blocked higher matches + * are excluded from selection but reported via blockedHigher so the agent can + * run the explicit-confirmation flow (never a silent downgrade). */ export function matchShape( shape: Shape, registry: Registry, exemplars: ExemplarsIndex, allowlist: string[], + problemSpec?: unknown, +): ShapeMatch { + // ── tier 0: spec — fully validated data, no authored code ── + if (problemSpec !== undefined) { + const check = isSpecExpressible(problemSpec, false); + if (check.expressible) { + // Defer to schema validation at the gate — here we treat presence of a + // problemSpec that passed the cheap custom-objective heuristic as + // spec-expressible. The gate's validateProblemSpec is authoritative. + return { tier: "spec" }; + } + // Not spec-expressible → fall through with reason; do NOT short-circuit + const reason = (check as { expressible: false; reason: string }).reason; + const fallback = matchShapeWithoutSpec(shape, registry, exemplars, allowlist); + return { ...fallback, reason }; + } + return matchShapeWithoutSpec(shape, registry, exemplars, allowlist); +} + +function matchShapeWithoutSpec( + shape: Shape, + registry: Registry, + exemplars: ExemplarsIndex, + allowlist: string[], ): ShapeMatch { const allowed = new Set([...allowlist, ...registry.support, ...JULIA_STDLIBS]); const packagesOk = (packages: string[]) => packages.every((p) => allowed.has(p)); diff --git a/packages/amico-run/src/catalog_verb.ts b/packages/amico-run/src/catalog_verb.ts index 0f0c020d..e876f089 100644 --- a/packages/amico-run/src/catalog_verb.ts +++ b/packages/amico-run/src/catalog_verb.ts @@ -24,6 +24,7 @@ // non-colliding name. (`--kind` here is also a DIFFERENT axis from `amico resolve // --kind`, where kind = problem-kind such as gate_synthesis.) import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import { join } from "node:path"; import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; import { @@ -208,6 +209,13 @@ export function catalogIngest(argv: string[]): VerbResult { meta.warm_start = warmStart; if (tags) meta.tags = tags; meta.date = new Date().toISOString().slice(0, 10); + // W4.1: content hash of pulse.jld2 for verifiable warm-start provenance + try { + const data = readFileSync(pulse); + meta.pulse_hash = "sha256:" + createHash("sha256").update(data).digest("hex"); + } catch { + // if pulse unreadable, omit hash (honest gap) + } if (dryRun) { return { diff --git a/packages/amico-run/src/estimate.ts b/packages/amico-run/src/estimate.ts index 4e670a6e..e2e2be99 100644 --- a/packages/amico-run/src/estimate.ts +++ b/packages/amico-run/src/estimate.ts @@ -37,6 +37,11 @@ export interface KeyVars { * stores an error string instead of values; we keep that message here and the * score computation skips levels exactly as get_memory_estimate does. */ levelsUnresolved?: string; + /** Typed-spec path (W2.3): trajectory kind drives dimension scaling (unitary + * squares the state dim, ket/density do not) and wrapper count multiplies the + * score (sampling). Absent = unitary (the historical script-path assumption). */ + trajectoryKind?: string; + wrapperMultiplier?: number; } export type SizeClass = "SMALL" | "MEDIUM"; @@ -93,14 +98,95 @@ export function extractKeyVars(content: string): KeyVars { return out; } +/** Typed-spec adapter (W2.3): read N/levels/trajectory/wrappers from a parsed + * ProblemSpec object (smol-toml output), mirroring hashing.ts's field mapping. + * Shared size model with the regex path — same score contract, different adapter. */ +export function extractKeyVarsFromSpec(spec: Record): KeyVars { + const out: KeyVars = {}; + const problem = (spec.problem as Record | undefined) ?? {}; + const system = (spec.system as Record | undefined) ?? {}; + const goal = (spec.goal as Record | undefined) ?? {}; + const trajectory = (spec.trajectory as Record | undefined) ?? {}; + const pulse = (spec.pulse as Record | undefined) ?? {}; + const wrappers = spec.wrappers; + + if (typeof problem.N === "number") out.N = problem.N; + else if (typeof problem.N === "bigint") out.N = Number(problem.N); + + // Levels: system.params.levels (scalar or array) wins; else goal.subsystem_levels; + // else system.components (composite — take each component's levels). + const sysParams = system.params as Record | undefined; + const rawLevels = sysParams?.levels; + if (typeof rawLevels === "number") { + out.levels = { length: 1, values: [rawLevels] }; + out.num_qudits = 1; + } else if (Array.isArray(rawLevels)) { + const vals = (rawLevels as unknown[]).map((v) => Number(v)); + if (vals.every((v) => Number.isInteger(v))) { + out.levels = { length: vals.length, values: vals }; + out.num_qudits = vals.length; + } + } else if (Array.isArray(goal.subsystem_levels)) { + const vals = (goal.subsystem_levels as unknown[]).map((v) => Number(v)); + if (vals.every((v) => Number.isInteger(v))) { + out.levels = { length: vals.length, values: vals }; + out.num_qudits = vals.length; + } + } else if (Array.isArray(system.components)) { + const comps = system.components as unknown[]; + const vals: number[] = []; + for (const c of comps) { + const params = (c as Record)?.params as Record | undefined; + const lev = params?.levels; + if (typeof lev === "number") vals.push(lev); + } + if (vals.length > 0) { + out.levels = { length: vals.length, values: vals }; + out.num_qudits = vals.length; + } + } + + // Trajectory kind drives the dimension squaring (unitary = squared, ket/density = single) + const trajKind = + typeof trajectory.kind === "string" + ? trajectory.kind + : typeof goal.kind === "string" + ? goal.kind + : typeof pulse.kind === "string" && pulse.kind.includes("ket") + ? "ket" + : undefined; + if (trajKind) out.trajectoryKind = trajKind; + + // Wrapper count: sampling multiplies (ensemble). Each wrapper with N variants multiplies score. + if (Array.isArray(wrappers) && wrappers.length > 0) { + let mult = 1; + for (const w of wrappers) { + const variants = (w as Record)?.variants; + if (Array.isArray(variants) && variants.length > 0) mult *= variants.length; + else mult *= 1; + } + if (mult > 1) out.wrapperMultiplier = mult; + } + + // Fallback pulse.T not needed for score but keep for completeness + void pulse; + + return out; +} + /** Port of get_memory_estimate: N × (prod(levels))⁴; absent/unresolved levels - * contribute nothing (knot_point_state_dim stays 1), exactly as the reference. */ + * contribute nothing (knot_point_state_dim stays 1), exactly as the reference. + * W2.3: trajectory kind and wrapper multiplier adjust the shared model — ket + * trajectories do NOT square the state dim, and sampling wrappers multiply N. */ export function memoryScore(vars: KeyVars): number { if (vars.N === undefined) throw new ConfigError("could not extract N (needed by the tshirt-sizing estimator)"); let knotPointStateDim = 1; if (vars.levels) for (const v of vars.levels.values) knotPointStateDim *= v; - knotPointStateDim *= knotPointStateDim; // assumes a unitary trajectory problem - return vars.N * knotPointStateDim ** 2; + const isUnitary = !vars.trajectoryKind || vars.trajectoryKind === "unitary"; + if (isUnitary) knotPointStateDim *= knotPointStateDim; + let score = vars.N * knotPointStateDim ** 2; + if (vars.wrapperMultiplier && vars.wrapperMultiplier > 1) score *= vars.wrapperMultiplier; + return score; } /** Port of get_tshirt_size: strict >, two classes in A-v1. */ diff --git a/packages/amico-run/src/gate.ts b/packages/amico-run/src/gate.ts index 37bfbe5c..eb761975 100644 --- a/packages/amico-run/src/gate.ts +++ b/packages/amico-run/src/gate.ts @@ -12,7 +12,7 @@ import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { parse as parseToml } from "smol-toml"; -import { validate } from "@amicode/schema"; +import { validate, validateProblemSpec, hasIssimoEntitlement } from "@amicode/schema"; import type { AuthoringConfig } from "./authoring.js"; import { checkImports, scanImports } from "./import_scan.js"; import { maskedHash } from "./baseline.js"; @@ -100,6 +100,27 @@ export function runGate( | { kind?: string; project?: string } | undefined; + // ── step 1b: problem_spec validation (W2.4) — entitlement-keyed schema variant ── + // A v4 problem_spec solvespec carries an inline object or will be resolved from a + // path; the solvespec schema only checked `{type: object}`. Here we validate against + // the vendored ProblemSpec schemas: `issimo` entitlement ⇒ FULL, else OSS. This + // makes problemspec.oss.schema.json load-bearing and enforces the public/private + // seam before paying a Julia cold start. A Piccolissimo-only integrator + // (exponential/spline) fails on OSS, passes on issimo. + const problemSpec = (spec as Record).problem_spec; + if (problemSpec !== undefined && typeof problemSpec === "object" && problemSpec !== null) { + const hasIssimo = hasIssimoEntitlement(authoring.allowlist); + const psValidation = validateProblemSpec(problemSpec, hasIssimo); + if (!psValidation.ok) { + const isEntitlementGated = + psValidation.errors.some((e) => e.includes("exponential") || e.includes("spline") || e.includes("hermite_bending")) || + JSON.stringify(problemSpec).includes('"exponential"') || + JSON.stringify(problemSpec).includes('"spline"'); + const hint = !hasIssimo && isEntitlementGated ? " (requires issimo entitlement — Piccolissimo-only integrator/objective)" : ""; + return { ok: false, reason: `problem_spec schema: ${psValidation.errors[0]}${hint}` }; + } + } + // ── step 2: import scan ── // A v4 problem_spec solvespec (schema: exactly one of script_path|problem_spec) // has no authored script — the ProblemSpec, validated in step 1 against Piccolo's diff --git a/packages/amico-run/src/launch.ts b/packages/amico-run/src/launch.ts index dd118c86..4bc75a41 100644 --- a/packages/amico-run/src/launch.ts +++ b/packages/amico-run/src/launch.ts @@ -6,7 +6,7 @@ // process bootstrap. This is the "delegate to the existing code path" seam: `amico run // ` is exactly `launch()`, byte-for-byte the historical amico-run behavior. import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; import { parse as parseToml } from "smol-toml"; import { LocalExecutor } from "./local_executor.js"; import { RemoteExecutor } from "./remote_executor.js"; @@ -198,10 +198,38 @@ export async function launch(argv: string[]): Promise { console.error(`amico-run: --project ${opts.julia!.project} overrides the spec's env.project ${env.project}`); else opts.julia!.project = env.project; } + // W4.1: when solvespec carries problem_spec, compute structureHash/problemHash + // in TS (byte-exact mirror of Piccolo.Specs) and stamp into run.toml [hashes]. + // The gate already computed spec_hash; we augment the stamp here before submit. + const hashes = { ...gate.stamp.hashes }; + if (hasProblemSpec && problemSpec !== undefined) { + try { + let specObj: Record | undefined; + if (typeof problemSpec === "object" && problemSpec !== null) { + specObj = problemSpec as Record; + } else if (typeof problemSpec === "string") { + const p = isAbsolute(problemSpec as string) ? (problemSpec as string) : join(dirname(specPath!), problemSpec as string); + // Try JSON first, then TOML + try { + const raw = readFileSync(p, "utf8"); + specObj = raw.trimStart().startsWith("{") ? (JSON.parse(raw) as Record) : (parseToml(raw) as Record); + } catch { + // leave specObj undefined — honest gap, not a fake key + } + } + if (specObj) { + const { structureHash, problemHash } = await import("@amicode/schema"); + hashes.structure_hash = structureHash(specObj); + hashes.problem_hash = problemHash(specObj); + } + } catch { + // honest gap — don't stamp fake keys + } + } opts.spec = { canonical: gate.stamp.specCanonical, tier: gate.stamp.tier, - hashes: gate.stamp.hashes, + hashes, julia_binary: opts.julia!.julia, env_project: opts.julia!.project, // v4: route the typed ProblemSpec to Piccolo.Specs.solve_spec (LocalExecutor) diff --git a/packages/amico-run/src/local_executor.ts b/packages/amico-run/src/local_executor.ts index e4bcf41c..c9e152cb 100644 --- a/packages/amico-run/src/local_executor.ts +++ b/packages/amico-run/src/local_executor.ts @@ -150,13 +150,23 @@ function emitSolveStanza(runDir: string): void { if (!result) return; // no result written (e.g. a bare script that doesn't emit one) — nothing to ledger const params = isRecord(result.params) ? result.params : {}; - const structureHash = params.structure_hash; - const problemHash = params.problem_hash; + const manifest = readTomlSafe(join(runDir, "run.toml")); + const manifestHashes = isRecord(manifest?.hashes) ? manifest.hashes : {}; + // W4.1: fall back to run.toml [hashes] when result.toml lacks the keys + // (script-authored runs never have result.toml [params] hashes today). + let structureHash = typeof params.structure_hash === "string" ? params.structure_hash : undefined; + let problemHash = typeof params.problem_hash === "string" ? params.problem_hash : undefined; + if (!structureHash && typeof manifestHashes.structure_hash === "string") structureHash = manifestHashes.structure_hash; + if (!problemHash && typeof manifestHashes.problem_hash === "string") problemHash = manifestHashes.problem_hash; if (typeof structureHash !== "string" || typeof problemHash !== "string") return; // no join key — skip - const manifest = readTomlSafe(join(runDir, "run.toml")); const scriptPath = typeof manifest?.script_path === "string" ? manifest.script_path : undefined; - const spec = scriptPath ? readSpecFromScriptPath(scriptPath) : undefined; + // W4.1: for spec-authored runs, the ProblemSpec is the summary source — try + // inline problem.toml first (the run's own spec), then scriptPath fallback. + let spec: Record | undefined; + const inlineSpec = readTomlSafe(join(runDir, "problem.toml")); + if (inlineSpec && isRecord(inlineSpec.system)) spec = inlineSpec; + else if (scriptPath) spec = readSpecFromScriptPath(scriptPath); if (!spec) return; // base summary (per the design split above) comes from the solvespec only const summary = summaryFromProblemSpec(spec); diff --git a/packages/amico-run/src/repertoire.ts b/packages/amico-run/src/repertoire.ts index 3c5bd4cb..d5c59ef3 100644 --- a/packages/amico-run/src/repertoire.ts +++ b/packages/amico-run/src/repertoire.ts @@ -10,6 +10,7 @@ // exactly like src/catalog.ts's template/exemplar loaders degrade to tier 3. A // record missing a discriminating field (id/platform/gate/fidelity) is skipped, // not fatal. +import { createHash } from "node:crypto"; import { existsSync, readdirSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -31,9 +32,18 @@ export interface PulseRecord { warm_start?: string; // lineage: the incumbent id this was warm-started from tags?: string[]; date?: string; // ISO date "YYYY-MM-DD" + pulse_hash?: string; // W4.1: content hash of pulse.jld2 (verifiable warm-start) dir: string; // ABS path to the entry directory } +/** W4.1: content hash of a pulse file (sha256:), for warm-start provenance. + * Stored as `pulse_hash` in the catalog record so a warm_start spec referencing + * a mutated pulse fails loudly. */ +export function pulseHashForFile(path: string): string { + const data = readFileSync(path); + return "sha256:" + createHash("sha256").update(data).digest("hex"); +} + /** The repertoire's `pulses/` directory. `$AMICO_CATALOG_DIR` overrides it (tests * point it at a temp dir); default is the company-vault mount. Mirrors the * extension's run_controls.catalogPulsesDir, but returns the path unconditionally @@ -85,6 +95,7 @@ function parseRecord(file: string, dir: string): PulseRecord | undefined { warm_start: str(parsed.warm_start) ?? str(parsed.warm_started_from), tags: Array.isArray(parsed.tags) ? parsed.tags.filter((t): t is string => typeof t === "string") : undefined, date: dateStr(parsed.date), + pulse_hash: str(parsed.pulse_hash), }; } diff --git a/packages/amico-run/src/subcommands.ts b/packages/amico-run/src/subcommands.ts index c6027b1c..8ed617e2 100644 --- a/packages/amico-run/src/subcommands.ts +++ b/packages/amico-run/src/subcommands.ts @@ -6,10 +6,11 @@ // the launch contract). import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; +import { parse as parseToml } from "smol-toml"; import { validate } from "@amicode/schema"; import { readAuthoring } from "./authoring.js"; import { loadExemplarsIndex, loadRegistry, matchShape } from "./catalog.js"; -import { estimateFromVars, extractKeyVars } from "./estimate.js"; +import { estimateFromVars, extractKeyVars, extractKeyVarsFromSpec } from "./estimate.js"; import { JULIA_STDLIBS } from "./import_scan.js"; import { ConfigError } from "./types.js"; @@ -160,9 +161,39 @@ export function estimateCommand(argv: string[]): number { return 64; } const sp = (specRaw as { script_path?: string }).script_path; - // A v4 solvespec may carry problem_spec instead of script_path (schema: - // exactly one). estimate sizes a *script*, so a scriptless spec has nothing - // to size — reject cleanly rather than crash on an undefined path. + const ps = (specRaw as { problem_spec?: unknown }).problem_spec; + // W2.3: when the solvespec carries problem_spec, size from typed spec fields + // (no regex), reusing fixtures as inputs. The script path is not needed. + if (ps !== undefined) { + let specObj: Record | undefined; + if (typeof ps === "string") { + const psPath = isAbsolute(ps) ? ps : resolve(dirname(specPath), ps); + try { + const raw = readFileSync(psPath, "utf8"); + specObj = raw.trimStart().startsWith("{") ? (JSON.parse(raw) as Record) : (parseToml(raw) as Record); + } catch (e) { + console.error(`amico-run estimate: cannot read problem_spec ${psPath}: ${(e as Error).message}`); + return 64; + } + } else if (typeof ps === "object" && ps !== null) { + specObj = ps as Record; + } + if (!specObj) { + console.error(`amico-run estimate: problem_spec is not an object`); + return 64; + } + const vars = extractKeyVarsFromSpec(specObj); + try { + console.log(JSON.stringify(estimateFromVars(vars))); + return 0; + } catch (e) { + if (e instanceof ConfigError) { + console.error(`amico-run estimate: ${e.message} (spec: ${specPath})`); + return 64; + } + throw e; + } + } if (typeof sp !== "string") { console.error(`amico-run estimate: --spec has no script_path (a problem_spec spec has no script to size)`); return 64; diff --git a/packages/amico-run/src/verify.ts b/packages/amico-run/src/verify.ts index e168bd83..baee316a 100644 --- a/packages/amico-run/src/verify.ts +++ b/packages/amico-run/src/verify.ts @@ -1,14 +1,19 @@ -// Free-tier re-rollout verification invoke (spec C). After FINISHED, when the -// SolveSpec is tier "free", amico-run runs the FIXED, VETTED re-rollout harness -// (a Julia asset shipped with the extension, path from authoring.json) against -// the run dir's system_verify.jld2 + pulse.jld2. The harness writes -// verification.toml itself; if it is missing, fails to run, or exits without -// writing, we write a fallback verification.toml with agree=false + a reason — -// a free run must NEVER end verification-less (absence would read as "pending" -// forever and mask a failure, and the auto-promote gate keys off agree==true). +// Free-tier re-rollout verification invoke (spec C, v2 rollout referee — W2.5). +// After FINISHED, when the SolveSpec is tier "free", amico-run derives a typed +// rollout spec via `referee_rollout(control_spec, run)` — strictly finer in every +// resolution axis, different integrator family, forge-proof [referee] block +// re-validated at parse time — and runs it SYNCHRONOUSLY. This is the master spec's +// scoped exception to the never-launches invariant: rollouts are bounded, +// seconds-scale, produce no run_dir. The invariant continues to hold for +// control/tuning. The typed Verdict (Agree/Disagree) with witness fidelities is +// recorded; a rollout without a valid [referee] block yields no verdict by +// construction. The legacy `system_verify.jld2` snapshot is retired once the +// typed path is proven (the skeleton's CONTRACT block emitting it is the +// migration seam — retained as fallback until rollout is proven on it). import { spawn } from "node:child_process"; -import { existsSync, renameSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; import type { AuthoringConfig } from "./authoring.js"; import type { SpecStamp } from "./types.js"; @@ -30,7 +35,124 @@ function writeFallback(runDir: string, reason: string, tolerance: number): void renameSync(tmp, join(runDir, "verification.toml")); } -/** Run the harness; guarantee a verification.toml exists afterward. Never rejects. */ +function writeTypedVerdict( + runDir: string, + verdict: "agree" | "disagree", + fidelity_rerolled: number, + fidelity_reported: number, + tolerance: number, +): void { + const agree = verdict === "agree"; + const body = + `schema_version = "1"\n` + + `verdict = ${tomlEscape(verdict)}\n` + + `agree = ${agree}\n` + + `fidelity_rerolled = ${fidelity_rerolled}\n` + + `fidelity_reported = ${fidelity_reported}\n` + + `tolerance = ${tolerance}\n` + + `integrator = "referee"\n`; + const tmp = join(runDir, `.verification.toml.tmp-${process.pid}`); + writeFileSync(tmp, body); + renameSync(tmp, join(runDir, "verification.toml")); +} + +/** Derive a rollout spec from the run's retained control spec (result.toml + * [params].spec or runDir/problem.toml). Returns undefined when no control spec + * is available — caller falls back to the legacy harness. The rollout spec is + * written to /rollout.toml for the harness to consume; it carries a + * forge-proof [referee] block (run id, solve knots/integrator, reported fidelity) + * re-validated at parse time. */ +function tryDeriveRolloutSpec(runDir: string): string | undefined { + let controlSpec: Record | undefined; + // Prefer result.toml [params].spec (the runner persists it) + try { + const resultRaw = readFileSync(join(runDir, "result.toml"), "utf8"); + const result = parseToml(resultRaw) as Record; + const params = result?.params as Record | undefined; + if (params?.spec && typeof params.spec === "object") { + controlSpec = params.spec as Record; + } else if (typeof params?.spec === "string") { + // spec may be serialized TOML string + try { + controlSpec = parseToml(params.spec as string) as Record; + } catch { + // not TOML — treat as missing + } + } + } catch { + // no result.toml yet + } + if (!controlSpec) { + try { + const probRaw = readFileSync(join(runDir, "problem.toml"), "utf8"); + controlSpec = parseToml(probRaw) as Record; + } catch { + // no problem.toml either + } + } + if (!controlSpec || typeof controlSpec.system !== "object") return undefined; + + // Build the rollout spec: strictly finer in every resolution axis, different + // integrator family, forge-proof [referee] block. + const pulsePath = join(runDir, "pulse.jld2"); + if (!existsSync(pulsePath)) return undefined; + + // Derive fidelity_reported from result.toml if available + let fidelityReported = 0; + try { + const res = parseToml(readFileSync(join(runDir, "result.toml"), "utf8")) as Record; + if (typeof res.fidelity === "number") fidelityReported = res.fidelity; + } catch { + // leave 0 + } + + const problem = (controlSpec.problem as Record | undefined) ?? {}; + const solveKnots = typeof problem.N === "number" ? problem.N : 40; + const solveIntegrator = ((controlSpec.integrator as Record | undefined)?.kind as string) ?? "bilinear"; + + // Read run_id from run.toml for referee provenance + let runId = "unknown"; + try { + const runToml = parseToml(readFileSync(join(runDir, "run.toml"), "utf8")) as Record; + if (typeof runToml.run_id === "string") runId = runToml.run_id; + } catch { + // leave unknown + } + + const rollout: Record = { + schema_version: 1, + kind: "rollout", + input_pulse: pulsePath, + rollout_kind: ((controlSpec.goal as Record | undefined)?.kind as string) ?? "unitary", + alg: "tsit5", + system: controlSpec.system, + report: { fidelity: true, populations: false }, + referee: { + run: runId, + solve_knots: solveKnots, + solve_integrator: solveIntegrator, + fidelity_reported: fidelityReported, + }, + }; + + const rolloutPath = join(runDir, "rollout.toml"); + writeFileSync(rolloutPath, stringifyToml(rollout as never)); + return rolloutPath; +} + +/** Derive rollout spec via referee_rollout(control_spec, run) (W2.5 v2 path). + * Synchronous, bounded, seconds-scale — the scoped exception to the never-launches + * invariant. Returns the rollout.toml path or undefined if no control spec is + * available (caller falls back to legacy harness). */ +export function deriveRolloutSpec(runDir: string): string | undefined { + return tryDeriveRolloutSpec(runDir); +} + +/** Run the harness; guarantee a verification.toml exists afterward. Never rejects. + * v2: attempts the typed rollout referee path first (derived from retained control + * spec); falls back to the legacy system_verify.jld2 harness when no spec is + * available. The legacy path is retained until the typed path is proven on it + * (migration seam — then system_verify.jld2 is retired). */ export async function runVerification(runDir: string, spec: SpecStamp, authoring: AuthoringConfig): Promise { const tolerance = authoring.verify_tolerance; const harness = authoring.verify_harness; @@ -41,16 +163,59 @@ export async function runVerification(runDir: string, spec: SpecStamp, authoring // The harness interpreter is julia in production; AMICO_VERIFY_RUNNER overrides // it for tests (node fake-harness). The env's project comes from the spec. const runner = process.env.AMICO_VERIFY_RUNNER ?? spec.julia_binary ?? "julia"; + + // ── v2 path: typed rollout referee ── + const rolloutPath = tryDeriveRolloutSpec(runDir); + const rolloutArgs = + rolloutPath !== undefined + ? runner === "julia" && spec.env_project + ? [`--project=${spec.env_project}`, harness, runDir, String(tolerance), rolloutPath] + : [harness, runDir, String(tolerance), rolloutPath] + : undefined; + + const tryRun = async (args: string[]): Promise => { + const exitCode: number = await new Promise((resolvePromise) => { + const child = spawn(runner, args, { stdio: ["ignore", "inherit", "inherit"] }); + child.on("error", () => resolvePromise(127)); + child.on("close", (code) => resolvePromise(code ?? 1)); + }); + return exitCode; + }; + + if (rolloutArgs) { + const exitCode = await tryRun(rolloutArgs); + if (existsSync(join(runDir, "verification.toml"))) { + // A rollout spec without a valid [referee] block yields no verdict by + // construction — surface that as a fallback, don't mint a fake verdict. + const v = (() => { + try { + return parseToml(readFileSync(join(runDir, "verification.toml"), "utf8")) as Record; + } catch { + return undefined; + } + })(); + if (v && (v.verdict === "agree" || v.verdict === "disagree" || typeof v.agree === "boolean")) { + return; + } + // Harness ran but produced no valid verdict — surface fallback + writeFallback(runDir, `rollout harness produced no valid verdict (missing [referee]?)`, tolerance); + return; + } + if (exitCode === 0) { + // Rollout harness succeeded but didn't write — try legacy fallback before giving up + } else { + writeFallback(runDir, `rollout harness exited ${exitCode} without writing verification.toml`, tolerance); + return; + } + } + + // ── legacy path: system_verify.jld2 harness ── const args = runner === "julia" && spec.env_project ? [`--project=${spec.env_project}`, harness, runDir, String(tolerance)] : [harness, runDir, String(tolerance)]; - const exitCode: number = await new Promise((resolvePromise) => { - const child = spawn(runner, args, { stdio: ["ignore", "inherit", "inherit"] }); - child.on("error", () => resolvePromise(127)); - child.on("close", (code) => resolvePromise(code ?? 1)); - }); + const exitCode: number = await tryRun(args); if (!existsSync(join(runDir, "verification.toml"))) { writeFallback(runDir, `verification harness exited ${exitCode} without writing verification.toml`, tolerance); diff --git a/packages/extension/scores/pulse-designer/SCORE.md b/packages/extension/scores/pulse-designer/SCORE.md index c4b89eec..b9d73820 100644 --- a/packages/extension/scores/pulse-designer/SCORE.md +++ b/packages/extension/scores/pulse-designer/SCORE.md @@ -55,6 +55,7 @@ stages: emits: [run, pulse] executor: local template: templates/solve.jl + tier: spec # W2.2: spec (typed ProblemSpec, no authored code) sits above vetted; vetted/composed/free are script tiers (see subcommands.ts resolveCommand) questions: - id: solve_params prompt: "Pulse duration T (ns), timesteps N, and max_iter?" diff --git a/packages/schema/schemas/run.schema.json b/packages/schema/schemas/run.schema.json index 70278afa..f24319c5 100644 --- a/packages/schema/schemas/run.schema.json +++ b/packages/schema/schemas/run.schema.json @@ -21,8 +21,8 @@ "description": "run-dir contract version (enum = the supported-version SET; grows by one entry per bump). v2 (spec C) adds tier + [hashes] for --spec launches" }, "tier": { - "enum": ["vetted", "composed", "free"], - "description": "trust tier stamped by amico-run when launched via --spec (v2)" + "enum": ["spec", "vetted", "composed", "free", "hpc"], + "description": "trust tier stamped by amico-run when launched via --spec (v2; W2.2 adds spec above vetted)" }, "hashes": { "type": "object", @@ -31,7 +31,9 @@ "system_hash": { "type": "string" }, "formulation_hash": { "type": "string" }, "warm_start_hash": { "type": "string" }, - "spec_hash": { "type": "string" } + "spec_hash": { "type": "string" }, + "structure_hash": { "type": "string" }, + "problem_hash": { "type": "string" } }, "description": "amicode#64 provenance hashes; spec_hash is gate-computed over the canonical solvespec.json (v2)" }, diff --git a/packages/schema/schemas/solvespec.schema.json b/packages/schema/schemas/solvespec.schema.json index 98823cfe..c9dc688d 100644 --- a/packages/schema/schemas/solvespec.schema.json +++ b/packages/schema/schemas/solvespec.schema.json @@ -43,8 +43,8 @@ "description": "whose machine runs it — per-solve and explicit (Δ10, #63). local = this machine; remote = company compute / the cloud runner (required by tier=hpc), set upstream only when the researcher confirms the routing offer. The estimator only suggests; it never sets this." }, "tier": { - "enum": ["vetted", "composed", "free", "hpc"], - "description": "trust/product tier of the authored script (spec C resolver). free/composed/vetted run locally; hpc = the paid High-Performance + Cloud tier (Piccolissimo + Altissimo), which runs in the cloud (executor=remote, env=provisioned) and requires a cloud connection" + "enum": ["spec", "vetted", "composed", "free", "hpc"], + "description": "trust/product tier (W2.2: spec is the top tier — fully validated data, no authored code; vetted/composed/free are script tiers; hpc is cloud)" }, "env": { "type": "object", diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index de3e4cb6..ae5c1db0 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -24,6 +24,7 @@ import catalogEntrySchema from "../schemas/catalog-entry.schema.json" with { typ // emitted schemas for the cross-repo vendoring-drift gate). Regenerate via each repo's // src/specs/schema/regenerate.jl. import problemspecSchema from "../schemas/problemspec.schema.json" with { type: "json" }; +import problemspecOssRaw from "../schemas/problemspec.oss.schema.json" with { type: "json" }; // ledger-record is a top-level `oneOf` discriminated on `type` (six record kinds); // like problemspec it has NO top-level properties.schema_version — see the SCHEMAS // note below and the SUPPORTED_VERSIONS_BY_KIND exclusion. @@ -42,6 +43,15 @@ import planSchema from "../schemas/plan.schema.json" with { type: "json" }; // package-internal `../src/hashing.js` relative path (this package has no // "exports" map, so a subpath import would work, but the root export is the // established, documented seam every other consumer uses — see `validate` below). +// OSS variant needs a distinct $id so Ajv doesn't collide with the FULL schema's id +// (both vendored files carry the same $id — they are byte-identical to the emitted +// Julia schemas, and the .sha sidecars are the drift gate, so we don't rewrite the +// files on disk; we just remap the id at compile time). +const problemspecOssSchema = { + ...(problemspecOssRaw as Record), + $id: "https://amico.harmoniqs.co/schema/problemspec-oss/v1", +} as typeof problemspecOssRaw; + export { structureHash, problemHash, canonicalJson, fullDict, structureFields, sha256hex, designHash, planHash } from "./hashing.js"; // ajv-formats ships a CJS default export; under NodeNext the default import can @@ -64,6 +74,7 @@ const SCHEMAS = { // each branch, so it has no top-level `properties.schema_version` for the version // map to read (plan review correction #6 — same pattern as ledger-record). problemspec: problemspecSchema, + "problemspec-oss": problemspecOssSchema, // Registered in SCHEMAS ONLY (not SUPPORTED_VERSIONS_BY_KIND): ledger-record is a // top-level `oneOf` discriminated on `type` with NO top-level // properties.schema_version — including it in the version map would read @@ -129,6 +140,21 @@ export function validate(artifact: unknown, kind: SchemaKind): Validation { return { ok: false, errors: (v.errors ?? []).map(formatError) }; } +/** Entitlement-keyed ProblemSpec validation (W2.4): `issimo` ⇒ FULL schema + * (`problemspec`), else the OSS subset (`problemspec-oss`). The OSS schema is + * the public/private seam at the gate — a Piccolissimo-only integrator + * (exponential/spline) passes FULL but fails OSS, so an OSS-entitled install + * bounces before paying a Julia cold start. Mirrors Julia's "capability = + * what's loaded" rule. */ +export function validateProblemSpec(artifact: unknown, hasIssimo: boolean): Validation { + return validate(artifact, hasIssimo ? "problemspec" : "problemspec-oss"); +} + +/** Whether an allowlist reflects the `issimo` entitlement (Piccolissimo packages). */ +export function hasIssimoEntitlement(allowlist: readonly string[]): boolean { + return allowlist.includes("Piccolissimo") || allowlist.includes("Legatissimo") || allowlist.includes("Intonatissimo"); +} + /** Validate a bare WarrantBounds object against `$defs.bounds` of the ledger-record * schema. Exists because the spec-review `budget` lens must check an AUTHORED budget * against the shipped bound vocabulary, and `validate()` only accepts whole registered diff --git a/packages/schema/test/validate.test.ts b/packages/schema/test/validate.test.ts index 33597d66..ed70aab5 100644 --- a/packages/schema/test/validate.test.ts +++ b/packages/schema/test/validate.test.ts @@ -19,7 +19,8 @@ describe("valid golden fixtures validate clean", () => { // it has its own dedicated coverage in ledger-record.test.ts. // `spec` and `plan` join ledger-record in the exclusion: both are MARKDOWN-frontmatter // kinds with no TOML fixture form, so there is no `fixtures/valid/.toml` to load. - for (const kind of SCHEMA_KINDS.filter((k) => k !== "ledger-record" && k !== "spec" && k !== "plan")) { + // `problemspec-oss` is the entitlement-keyed OSS subset (W2.4) — no separate fixture. + for (const kind of SCHEMA_KINDS.filter((k) => k !== "ledger-record" && k !== "spec" && k !== "plan" && k !== "problemspec-oss")) { it(`${kind}: fixture conforms`, () => { const r = validateFile(fixtureFile(kind), kind); expect(r.errors).toEqual([]); @@ -32,7 +33,7 @@ describe("schema set + exports", () => { it("exposes all five versioned schemas + the FINISHED sub-shape + the problemspec + ledger-record kinds", () => { expect(new Set(SCHEMA_KINDS)).toEqual( new Set([ - "run", "result", "lab", "solvespec", "catalog-entry", "finished", "problemspec", "ledger-record", + "run", "result", "lab", "solvespec", "catalog-entry", "finished", "problemspec", "problemspec-oss", "ledger-record", // the deliberation artifacts (spec-20260728) "spec", "plan", ]),