diff --git a/packages/amico-run/package.json b/packages/amico-run/package.json index b831f2d6..7c269437 100644 --- a/packages/amico-run/package.json +++ b/packages/amico-run/package.json @@ -13,6 +13,9 @@ "test": "vitest run --passWithNoTests --exclude '**/slow/**'", "test:slow": "vitest run test/slow" }, + "dependencies": { + "@amicode/schema": "workspace:*" + }, "devDependencies": { "@types/node": "^22.0.0", "esbuild": "^0.24.0", diff --git a/packages/amico-run/src/run_dir.ts b/packages/amico-run/src/run_dir.ts index 2c0f949c..e7899629 100644 --- a/packages/amico-run/src/run_dir.ts +++ b/packages/amico-run/src/run_dir.ts @@ -67,7 +67,7 @@ export function writeManifest(runDir: string, m: Manifest): void { ...(m.julia.project ? [`project = ${ts(m.julia.project)}`] : []), ...(m.julia.sysimage ? [`sysimage = ${ts(m.julia.sysimage)}`] : []), ] - atomicWriteFile(runDir, 'manifest.toml', lines.join('\n') + '\n') + atomicWriteFile(runDir, 'run.toml', lines.join('\n') + '\n') } export function writeFinished(runDir: string, status: RunStatus, exitCode: number): void { @@ -77,7 +77,7 @@ export function writeFinished(runDir: string, status: RunStatus, exitCode: numbe export function appendIndex(runsRoot: string, runId: string, createdAt: string, scriptPath: string): void { // The index is a tab-separated, one-line-per-run log; a tab/newline in the // (last-field) script path would corrupt it. Sanitize control chars to a - // space — manifest.toml holds the canonical, TOML-escaped script_path. + // space — run.toml holds the canonical, TOML-escaped script_path. const safePath = scriptPath.replace(/[\t\r\n]/g, ' ') appendFileSync(join(runsRoot, 'index'), `${runId}\t${createdAt}\t${safePath}\n`) } diff --git a/packages/amico-run/src/schemas.ts b/packages/amico-run/src/schemas.ts index 53cc0ab3..1f0958f4 100644 --- a/packages/amico-run/src/schemas.ts +++ b/packages/amico-run/src/schemas.ts @@ -1,42 +1,22 @@ -// PROVISIONAL shapes — superseded by Phase 0' SchemaPackage (plan task 0.1). -// Field names here ARE the contract and must survive that migration (spec §7). -export interface Validation { ok: boolean; errors: string[] } +// Run-dir validators now DELEGATE to the shared @amicode/schema package — the +// single source of truth for the run-dir contract (Phase 0' SchemaPackage). The +// export names and the {ok, errors} shape are preserved so existing consumers +// (the extension's run_dir_reader) are unchanged. +// +// No schema is DEFINED here anymore. Re-introducing a hand-rolled validator / +// schema in this file is a regression (guarded by schemas.test.ts). +import { validate, type Validation } from "@amicode/schema"; -type Obj = Record -const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v) - -function check(errors: string[], cond: boolean, path: string, want: string): void { - if (!cond) errors.push(`${path}: expected ${want}`) -} +export type { Validation }; export function validateManifest(v: unknown): Validation { - const errors: string[] = [] - if (!isObj(v)) return { ok: false, errors: ['manifest: expected table'] } - check(errors, v.schema_version === '1', 'schema_version', '"1"') - for (const k of ['run_id', 'script_path', 'lab', 'lab_id', 'created_at', 'orchestrator_version']) - check(errors, typeof v[k] === 'string' && (v[k] as string).length > 0, k, 'non-empty string') - check(errors, isObj(v.julia), 'julia', 'table') - if (isObj(v.julia)) { - check(errors, typeof v.julia.binary === 'string', 'julia.binary', 'string') - for (const k of ['project', 'sysimage'] as const) - check(errors, v.julia[k] === undefined || typeof v.julia[k] === 'string', `julia.${k}`, 'string if present') - } - return { ok: errors.length === 0, errors } + return validate(v, "run"); } export function validateFinished(v: unknown): Validation { - const errors: string[] = [] - if (!isObj(v)) return { ok: false, errors: ['FINISHED: expected table'] } - check(errors, v.status === 'completed' || v.status === 'failed' || v.status === 'aborted', - 'status', 'completed|failed|aborted') - check(errors, Number.isInteger(v.exit_code), 'exit_code', 'integer') - return { ok: errors.length === 0, errors } + return validate(v, "finished"); } export function validateResult(v: unknown): Validation { - const errors: string[] = [] - if (!isObj(v)) return { ok: false, errors: ['result: expected table'] } - check(errors, typeof v.fidelity === 'number', 'fidelity', 'number') - check(errors, Number.isInteger(v.iterations), 'iterations', 'integer') - return { ok: errors.length === 0, errors } + return validate(v, "result"); } diff --git a/packages/amico-run/test/failure_lanes.test.ts b/packages/amico-run/test/failure_lanes.test.ts index 03112dc6..c6622bd1 100644 --- a/packages/amico-run/test/failure_lanes.test.ts +++ b/packages/amico-run/test/failure_lanes.test.ts @@ -20,7 +20,7 @@ describe('§6 failure matrix', () => { const h = await sub(root, fakeJulia(root, 'j', 'throw new Error("boom")'), fakeJulia(root, 's.jl', '')) const f = await h.finished expect(f.status).toBe('failed') - expect(readToml(join(h.runDir, 'manifest.toml')).run_id).toBe(h.runId) + expect(readToml(join(h.runDir, 'run.toml')).run_id).toBe(h.runId) }) it('spawn failure after manifest (X_OK dir → spawn error) → FINISHED{failed, 127}', async () => { @@ -30,7 +30,7 @@ describe('§6 failure matrix', () => { mkdirSync(dirAsJulia, { mode: 0o755 }) const h = await sub(root, dirAsJulia, fakeJulia(root, 's.jl', '')) expect(await h.finished).toEqual({ status: 'failed', exitCode: 127 }) - expect(readToml(join(h.runDir, 'manifest.toml')).run_id).toBe(h.runId) // manifest survived + expect(readToml(join(h.runDir, 'run.toml')).run_id).toBe(h.runId) // manifest survived }) it('shell exec-failure rc passes through verbatim (wrapper execs missing target)', async () => { @@ -70,7 +70,7 @@ describe('§6 failure matrix', () => { it('manifest is on disk BEFORE julia spawns (script observes it in cwd at startup)', async () => { const root = tmpRoot() const julia = fakeJulia(root, 'j', - `process.exit(require('node:fs').existsSync('manifest.toml') ? 0 : 7)`) + `process.exit(require('node:fs').existsSync('run.toml') ? 0 : 7)`) const h = await sub(root, julia, fakeJulia(root, 's.jl', '')) expect(await h.finished).toEqual({ status: 'completed', exitCode: 0 }) }) diff --git a/packages/amico-run/test/local_executor.test.ts b/packages/amico-run/test/local_executor.test.ts index f850cec2..371e9027 100644 --- a/packages/amico-run/test/local_executor.test.ts +++ b/packages/amico-run/test/local_executor.test.ts @@ -28,7 +28,7 @@ describe('LocalExecutor happy path', () => { }) // manifest observable before events finish — submit() resolved, so it must exist NOW - const manifest = readToml(join(h.runDir, 'manifest.toml')) + const manifest = readToml(join(h.runDir, 'run.toml')) expect(validateManifest(manifest).ok).toBe(true) expect(manifest.lab_id).toBe('testlab') diff --git a/packages/amico-run/test/run_dir.test.ts b/packages/amico-run/test/run_dir.test.ts index 2e6a4a6d..adf7aba1 100644 --- a/packages/amico-run/test/run_dir.test.ts +++ b/packages/amico-run/test/run_dir.test.ts @@ -36,7 +36,7 @@ describe('writers', () => { created_at: '2026-06-10T10:12:45Z', orchestrator_version: '0.1.0', julia: { binary: 'julia', project: '/proj' }, }) - const m = readToml(join(root, 'manifest.toml')) + const m = readToml(join(root, 'run.toml')) expect(m.schema_version).toBe('1') expect(m.lab_id).toBe('x') expect((m.julia as Record).project).toBe('/proj') diff --git a/packages/amico-run/test/schemas.test.ts b/packages/amico-run/test/schemas.test.ts index 14ac2434..04296653 100644 --- a/packages/amico-run/test/schemas.test.ts +++ b/packages/amico-run/test/schemas.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { validateManifest, validateFinished, validateResult } from '../src/schemas.js' +// These wrappers delegate to the shared @amicode/schema (single source of truth); +// this suite is the delegation smoke + the field-precise contract they expose. const goodManifest = { schema_version: '1', run_id: 'r20260610-101245Z-ab12', script_path: '/s.jl', lab: 'default', lab_id: 'default', created_at: '2026-06-10T10:12:45Z', @@ -13,8 +17,8 @@ describe('validateManifest', () => { it('reports each missing/mistyped field by path', () => { const r = validateManifest({ ...goodManifest, run_id: 42, julia: {} }) expect(r.ok).toBe(false) - expect(r.errors.join(' ')).toContain('run_id') - expect(r.errors.join(' ')).toContain('julia.binary') + expect(r.errors.join(' ')).toContain('run_id') // wrong-typed top-level field + expect(r.errors.join(' ')).toContain('binary') // /julia missing required "binary" }) it('rejects unknown schema_version', () => expect(validateManifest({ ...goodManifest, schema_version: '2' }).ok).toBe(false)) @@ -30,8 +34,24 @@ describe('validateFinished', () => { }) describe('validateResult (reader-side)', () => { - it('requires fidelity number, iterations integer', () => { - expect(validateResult({ fidelity: 0.999, iterations: 200, wall_seconds: 12.5 }).ok).toBe(true) - expect(validateResult({ iterations: 200 }).ok).toBe(false) + it('requires schema_version, fidelity number, iterations integer', () => { + // The formalized contract carries schema_version on result.toml (0.1a adds the + // emit; the Julia round-trip enforces it). An artifact lacking it is rejected. + expect(validateResult({ schema_version: '1', fidelity: 0.999, iterations: 200, wall_seconds: 12.5 }).ok).toBe(true) + expect(validateResult({ fidelity: 0.999, iterations: 200 }).ok).toBe(false) // no schema_version + expect(validateResult({ schema_version: '1', iterations: 200 }).ok).toBe(false) // no fidelity + }) +}) + +// Anti-regression (N4): schemas.ts must remain a thin DELEGATION, never re-define +// a schema/validator. Guards the "one validator path" invariant (#15 AC7). +describe('schemas.ts is delegation-only (no re-introduced schema)', () => { + const src = readFileSync(join(__dirname, '..', 'src', 'schemas.ts'), 'utf8') + it('imports the shared @amicode/schema', () => + expect(src).toMatch(/from ["']@amicode\/schema["']/)) + it('does not hand-roll validation (no local check helper / additionalProperties / required arrays)', () => { + expect(src).not.toMatch(/additionalProperties/) + expect(src).not.toMatch(/function check\b/) + expect(src).not.toMatch(/errors\.push/) }) }) diff --git a/packages/amico-run/test/slow/integration.test.ts b/packages/amico-run/test/slow/integration.test.ts index 60051da5..8cbb5636 100644 --- a/packages/amico-run/test/slow/integration.test.ts +++ b/packages/amico-run/test/slow/integration.test.ts @@ -20,7 +20,7 @@ function solveAndValidate(script: string): void { expect(stdout).toMatch(/AMICODE_ITER iter=/) expect(stdout).toMatch(/AMICODE_FINISHED status=completed exitCode=0 runDir=(.+)/) const runDir = stdout.match(/runDir=(.+)/)![1].trim() - expect(validateManifest(readToml(join(runDir, 'manifest.toml'))).ok).toBe(true) + expect(validateManifest(readToml(join(runDir, 'run.toml'))).ok).toBe(true) expect(validateFinished(readToml(join(runDir, 'FINISHED'))).ok).toBe(true) const result = readToml(join(runDir, 'result.toml')) expect(validateResult(result).ok).toBe(true) diff --git a/packages/amico-run/test/slow/solve_common.jl b/packages/amico-run/test/slow/solve_common.jl index f4e865e3..3b82b30d 100644 --- a/packages/amico-run/test/slow/solve_common.jl +++ b/packages/amico-run/test/slow/solve_common.jl @@ -50,9 +50,10 @@ JLD2.save("pulse.jld2", "trajectory", dto_prob.trajectory) open("result.toml.tmp", "w") do io TOML.print(io, Dict( - "fidelity" => fid, - "iterations" => iters[], - "wall_seconds" => wall, + "schema_version" => "1", # run-dir contract version (@amicode/schema result schema) + "fidelity" => fid, + "iterations" => iters[], + "wall_seconds" => wall, )) end mv("result.toml.tmp", "result.toml"; force = true) diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index c49df549..75e41057 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -59,7 +59,7 @@ gate, tell them plainly it isn't supported yet and stop. ## The run-dir contract your script MUST emit -`amico-run` writes `manifest.toml` (first) and `FINISHED` (last) itself. Your +`amico-run` writes `run.toml` (first) and `FINISHED` (last) itself. Your script, running with cwd = the run dir, must emit: - `AMICODE_ITER iter= f= inf_pr=<…> inf_du=<…>` to stdout, flushed, diff --git a/packages/extension/CONTRACT.md b/packages/extension/CONTRACT.md index 8ad02d8c..54147ac4 100644 --- a/packages/extension/CONTRACT.md +++ b/packages/extension/CONTRACT.md @@ -10,12 +10,12 @@ Phase 0' (the SchemaPackage supersedes the provisional validators below). A run lives at `~/.amico/runs///`, where `runId` is `rZ-` (e.g. `r20260617-161814Z-e8cb`). `amico-run` writes -`manifest.toml` **first** and `FINISHED` **last**; the script (cwd = the run dir) +`run.toml` **first** and `FINISHED` **last**; the script (cwd = the run dir) emits the rest. | Artifact | Writer | Contents | |---|---|---| -| `manifest.toml` | amico-run (first) | `schema_version = "1"`, snake_case keys: `run_id`, `lab`, `lab_id`, `script_path`, `created_at`, `orchestrator_version`, and a `[julia]` table (`binary`, optional `project`/`sysimage`). | +| `run.toml` | amico-run (first) | `schema_version = "1"`, snake_case keys: `run_id`, `lab`, `lab_id`, `script_path`, `created_at`, `orchestrator_version`, and a `[julia]` table (`binary`, optional `project`/`sysimage`). | | `run.log` | amico-run (stdout tee) | One `AMICODE_ITER iter= f= inf_pr=<…> inf_du=<…>` line per Ipopt iteration (drives the live stats row), plus a final `DONE fidelity=<…>` line and any Julia traceback. | | `iter_.png` | script | Per-iteration pulse/fidelity plot. `N` is the iteration with **unbounded digits** (`iter_0`, `iter_10`, … `iter_0060`). The inspector globs `iter_*.png`. | | `result.toml` | script (atomic) | Written `result.toml.tmp` then renamed. At least `fidelity` (float) and `iterations` (int); `wall_seconds` optional. | diff --git a/packages/extension/demo/run/result.toml b/packages/extension/demo/run/result.toml index c27b9486..8fd04305 100644 --- a/packages/extension/demo/run/result.toml +++ b/packages/extension/demo/run/result.toml @@ -1,3 +1,4 @@ +schema_version = "1" iterations = 60 fidelity = 0.9999788203047787 wall_seconds = 101.5023238658905 diff --git a/packages/extension/demo/run/manifest.toml b/packages/extension/demo/run/run.toml similarity index 100% rename from packages/extension/demo/run/manifest.toml rename to packages/extension/demo/run/run.toml diff --git a/packages/extension/package.json b/packages/extension/package.json index 40568403..993a5d15 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -116,6 +116,7 @@ }, "devDependencies": { "@amicode/amico-run": "workspace:*", + "@amicode/schema": "workspace:*", "@types/node": "^22.0.0", "@types/vscode": "^1.95.0", "@vscode/vsce": "^3.2.0", diff --git a/packages/extension/src/demo_replay.ts b/packages/extension/src/demo_replay.ts index ba06f6db..8451f04b 100644 --- a/packages/extension/src/demo_replay.ts +++ b/packages/extension/src/demo_replay.ts @@ -4,7 +4,7 @@ import { generateRunId, appendIndex, updateLatest } from "@amicode/amico-run"; /** * Copy a bundled demo run-dir into the runs root under a fresh β.1 runId, - * rewrite manifest.toml's `run_id` to match the new directory, append the + * rewrite run.toml's `run_id` to match the new directory, append the * index, and swing `latest` to it. Reuses the β.1 run-dir primitives so the * staged run is byte-for-byte contract-identical and the existing * RunsRootWatcher renders it exactly like a live solve. @@ -18,7 +18,7 @@ export function stageDemoRun(demoDir: string, runsRoot: string): string { const runDir = join(runsRoot, runId); mkdirSync(runDir); for (const f of readdirSync(demoDir)) { - if (f === "manifest.toml") { + if (f === "run.toml") { const m = readFileSync(join(demoDir, f), "utf8").replace( /run_id\s*=\s*"[^"]*"/, `run_id = ${JSON.stringify(runId)}`, diff --git a/packages/extension/src/file_watcher.ts b/packages/extension/src/file_watcher.ts index ae44008c..8398663b 100644 --- a/packages/extension/src/file_watcher.ts +++ b/packages/extension/src/file_watcher.ts @@ -13,7 +13,7 @@ import { // ============================================================================ // RunsRootWatcher — watches the β.1 run-dir contract and drives the Inspector // + status bar. Follows the `latest` symlink; for the active run it reads: -// manifest.toml → run identity (run_id, lab_id), written FIRST +// run.toml → run identity (run_id, lab_id), written FIRST // iter_.png → live plot frames (unbounded digits) // run.log → AMICODE_ITER lines → live stats row // result.toml → fidelity (display + promote gate), atomic @@ -182,7 +182,7 @@ export class RunsRootWatcher implements vscode.Disposable { this.logTailer?.dispose(); this.activeRunDir = runDir; - const runId = String(readTomlSafe(path.join(runDir, "manifest.toml"))?.run_id ?? path.basename(runDir)); + const runId = String(readTomlSafe(path.join(runDir, "run.toml"))?.run_id ?? path.basename(runDir)); // If the run was ALREADY finished when we switched to it (e.g. launch follows // `latest` to a prior completed run, or the user switches back), don't pop the // promote prompt — only a FRESH live completion promotes. Pre-marking the run @@ -234,11 +234,18 @@ export class RunsRootWatcher implements vscode.Disposable { const finished = readTomlSafe(path.join(runDir, "FINISHED")); if (!finished || !validateFinished(finished).ok) return; const status = finished.status as RunStatus; - const runId = String(readTomlSafe(path.join(runDir, "manifest.toml"))?.run_id ?? path.basename(runDir)); + const runId = String(readTomlSafe(path.join(runDir, "run.toml"))?.run_id ?? path.basename(runDir)); let fidelity: number | undefined; if (status === "completed") { const result = readTomlSafe(path.join(runDir, "result.toml")); - if (result && validateResult(result).ok) fidelity = result.fidelity as number; + if (result) { + const v = validateResult(result); + if (v.ok) fidelity = result.fidelity as number; + // Don't silently drop fidelity + skip promote on a present-but-invalid + // result.toml — say why (S4). e.g. a pre-0.1a result.toml with no + // schema_version, or a fidelity gross-out-of-range. + else this.opts.channel.appendLine(`[runs] result.toml present but invalid: ${v.errors.join("; ")}`); + } } this.sink?.run({ runId, runDir, status, fidelity }); if (status === "completed" && fidelity !== undefined && fidelity >= (this.opts.promoteThreshold ?? 0.99)) { diff --git a/packages/extension/src/run_dir_reader.ts b/packages/extension/src/run_dir_reader.ts index 0b59a8f9..538af190 100644 --- a/packages/extension/src/run_dir_reader.ts +++ b/packages/extension/src/run_dir_reader.ts @@ -75,7 +75,7 @@ export function readTomlSafe(fp: string): Record | undefined { * consumed, so the live tailer can attach exactly there — no gap (lines * appended after the read are tailed) and no overlap (already-replayed lines). */ export function ingestRunDir(runDir: string, sink: RunSink, promoteThreshold = 0.99): number { - const manifest = readTomlSafe(path.join(runDir, "manifest.toml")); + const manifest = readTomlSafe(path.join(runDir, "run.toml")); if (!manifest || !validateManifest(manifest).ok) return 0; // no valid manifest → not a run dir yet const runId = String(manifest.run_id); @@ -107,7 +107,14 @@ export function ingestRunDir(runDir: string, sink: RunSink, promoteThreshold = 0 let fidelity: number | undefined; if (status === "completed") { const result = readTomlSafe(path.join(runDir, "result.toml")); - if (result && validateResult(result).ok) fidelity = result.fidelity as number; + if (result) { + const v = validateResult(result); + if (v.ok) fidelity = result.fidelity as number; + // Present-but-nonconforming result.toml: surface WHY rather than silently + // dropping fidelity + skipping promote (S4). console.warn keeps this reader + // vscode-free; the live watcher logs to its channel too. + else console.warn(`[amico] result.toml present but invalid (${runDir}): ${v.errors.join("; ")}`); + } } sink.run({ runId, runDir, status, fidelity }); if (status === "completed" && fidelity !== undefined && fidelity >= promoteThreshold) { diff --git a/packages/extension/templates/solve_template.jl b/packages/extension/templates/solve_template.jl index 9225410a..c5781c3b 100644 --- a/packages/extension/templates/solve_template.jl +++ b/packages/extension/templates/solve_template.jl @@ -88,6 +88,7 @@ open("result.toml.tmp", "w") do io # Record the regime each run actually solved (scalar FILL-IN params), so the # result is self-describing — not just fidelity/iterations. TOML.print(io, Dict( + "schema_version" => "1", # run-dir contract version (@amicode/schema result schema) "fidelity" => fid, "iterations" => iters[], "wall_seconds" => wall, "params" => Dict("delta" => δ, "levels" => levels, "T" => T, "N" => N, "drive_max" => drive_max, "max_iter" => max_iter), diff --git a/packages/extension/test/demo_replay.test.ts b/packages/extension/test/demo_replay.test.ts index 3ea1f90a..7d31f7c3 100644 --- a/packages/extension/test/demo_replay.test.ts +++ b/packages/extension/test/demo_replay.test.ts @@ -8,11 +8,11 @@ import { stageDemoRun } from '../src/demo_replay' function fakeDemo(): string { const d = mkdtempSync(join(tmpdir(), 'demo-')) - writeFileSync(join(d, 'manifest.toml'), + writeFileSync(join(d, 'run.toml'), `schema_version = "1"\nrun_id = "rDEMO"\nscript_path = "/demo.jl"\nlab = "default"\nlab_id = "default"\ncreated_at = "2026-06-17T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`) writeFileSync(join(d, 'run.log'), 'AMICODE_ITER iter=10 f=0.1 inf_pr=1e-8 inf_du=1e-6\n') writeFileSync(join(d, 'iter_0010.png'), 'PNG') - writeFileSync(join(d, 'result.toml'), 'fidelity = 0.9999\niterations = 10\n') + writeFileSync(join(d, 'result.toml'), 'schema_version = "1"\nfidelity = 0.9999\niterations = 10\n') writeFileSync(join(d, 'FINISHED'), 'status = "completed"\nexit_code = 0\n') return d } @@ -25,7 +25,7 @@ describe('stageDemoRun', () => { const runId = runDir.split('/').pop()! expect(runId).toMatch(/^r\d{8}-\d{6}Z-[0-9a-f]{4}$/) // β.1 runId format expect(existsSync(join(runDir, 'iter_0010.png'))).toBe(true) - const m = parse(readFileSync(join(runDir, 'manifest.toml'), 'utf8')) as Record + const m = parse(readFileSync(join(runDir, 'run.toml'), 'utf8')) as Record expect(validateManifest(m).ok).toBe(true) expect(m.run_id).toBe(runId) // rewritten to match the dir expect(validateFinished(parse(readFileSync(join(runDir, 'FINISHED'), 'utf8'))).ok).toBe(true) diff --git a/packages/extension/test/packaging.test.ts b/packages/extension/test/packaging.test.ts index 6bb38fd7..586bd389 100644 --- a/packages/extension/test/packaging.test.ts +++ b/packages/extension/test/packaging.test.ts @@ -11,7 +11,7 @@ const REQUIRED = [ 'extension/julia/Project.toml', 'extension/julia/Manifest.toml', 'extension/AGENTS.md', - 'extension/demo/run/manifest.toml', + 'extension/demo/run/run.toml', 'extension/demo/run/FINISHED', 'extension/demo/run/run.log', // inspector reads run.log for the demo's stats row; *.log-gitignored so easy to drop ] diff --git a/packages/extension/test/watcher_contract.test.ts b/packages/extension/test/watcher_contract.test.ts index 9428e4eb..d59d3314 100644 --- a/packages/extension/test/watcher_contract.test.ts +++ b/packages/extension/test/watcher_contract.test.ts @@ -8,11 +8,11 @@ function stageRun(opts: { status: string; exit: number; iters: number[]; fidelit const root = mkdtempSync(join(tmpdir(), 'runs-')) const runId = 'r20260615-000000Z-ab12' const dir = join(root, runId); mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, 'manifest.toml'), + writeFileSync(join(dir, 'run.toml'), `schema_version = "1"\nrun_id = "${runId}"\nscript_path = "/s.jl"\nlab = "default"\nlab_id = "default"\ncreated_at = "2026-06-15T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`) for (const k of opts.iters) writeFileSync(join(dir, `iter_${k}.png`), 'PNG') writeFileSync(join(dir, 'run.log'), opts.iters.map(k => `AMICODE_ITER iter=${k} f=0.1 inf_pr=1e-8 inf_du=1e-6`).join('\n') + '\n') - if (opts.fidelity !== undefined) writeFileSync(join(dir, 'result.toml'), `fidelity = ${opts.fidelity}\niterations = ${Math.max(...opts.iters, 0)}\n`) + if (opts.fidelity !== undefined) writeFileSync(join(dir, 'result.toml'), `schema_version = "1"\nfidelity = ${opts.fidelity}\niterations = ${Math.max(...opts.iters, 0)}\n`) writeFileSync(join(dir, 'FINISHED'), `status = "${opts.status}"\nexit_code = ${opts.exit}\n`) return dir } diff --git a/packages/extension/test/watcher_statemachine.test.ts b/packages/extension/test/watcher_statemachine.test.ts index add34ea7..50ab0319 100644 --- a/packages/extension/test/watcher_statemachine.test.ts +++ b/packages/extension/test/watcher_statemachine.test.ts @@ -28,7 +28,7 @@ import { RunsRootWatcher } from "../src/file_watcher"; const channel = { appendLine() {}, append() {} } as never; function writeManifest(dir: string, runId: string): void { - writeFileSync(join(dir, "manifest.toml"), + writeFileSync(join(dir, "run.toml"), `schema_version = "1"\nrun_id = "${runId}"\nscript_path = "/s.jl"\nlab = "default"\n` + `lab_id = "default"\ncreated_at = "2026-06-15T00:00:00Z"\norchestrator_version = "0.1.0"\n[julia]\nbinary = "julia"\n`); } @@ -83,7 +83,7 @@ describe("RunsRootWatcher state machine", () => { expect(inspector.setImageSource).toHaveBeenLastCalledWith(expect.stringContaining("iter_18.png"), 18); // FINISHED + result → terminal completion delivered once - writeFileSync(join(run, "result.toml"), "fidelity = 0.9999\niterations = 18\n"); + writeFileSync(join(run, "result.toml"), 'schema_version = "1"\nfidelity = 0.9999\niterations = 18\n'); writeFileSync(join(run, "FINISHED"), 'status = "completed"\nexit_code = 0\n'); tick(w); expect(inspector.postCompletion).toHaveBeenCalledWith("completed", 0.9999); diff --git a/packages/schema/esbuild.config.mjs b/packages/schema/esbuild.config.mjs new file mode 100644 index 00000000..53d3720a --- /dev/null +++ b/packages/schema/esbuild.config.mjs @@ -0,0 +1,16 @@ +import { build } from 'esbuild' + +// The library is consumed as TS source (main = src/index.ts; consumers bundle it +// via their own esbuild). We still bundle here as a build-time smoke check that +// the dep graph (ajv + ajv-formats + the JSON schemas) bundles cleanly into a +// single ESM module — the same way the extension/CLI will inline it. +await build({ + entryPoints: ['src/index.ts'], + bundle: true, + platform: 'node', + target: 'node20', + format: 'esm', + outfile: 'dist/index.js', + sourcemap: true, + logLevel: 'info', +}) diff --git a/packages/schema/package.json b/packages/schema/package.json new file mode 100644 index 00000000..1cab0211 --- /dev/null +++ b/packages/schema/package.json @@ -0,0 +1,25 @@ +{ + "name": "@amicode/schema", + "version": "0.1.0", + "license": "Apache-2.0", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "engines": { "node": ">=20" }, + "scripts": { + "build": "node esbuild.config.mjs", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests --exclude '**/slow/**'" + }, + "dependencies": { + "ajv": "^8.17.0", + "ajv-formats": "^3.0.1", + "smol-toml": "^1.3.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "esbuild": "^0.24.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } +} diff --git a/packages/schema/schemas/catalog-entry.schema.json b/packages/schema/schemas/catalog-entry.schema.json new file mode 100644 index 00000000..219b7ba8 --- /dev/null +++ b/packages/schema/schemas/catalog-entry.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://amico.harmoniqs.co/schema/catalog-entry/v1", + "title": "amico catalog-entry", + "description": "A promote-shaped record: what the CatalogStore (Phase 3) will persist when a converged solve is promoted. The STORE/consumer is Phase 3; only the SCHEMA is defined here, exercised via a committed fixture (#15 AC8). Fields anchored to the live promote path (PromoteInfo = {runId, runDir, fidelity}, promote-on-fidelity>=threshold).", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "run_id", "lab_id", "fidelity", "pulse_path"], + "properties": { + "schema_version": { "enum": ["1"] }, + "run_id": { "type": "string", "minLength": 1 }, + "lab_id": { "type": "string", "minLength": 1 }, + "gate": { "type": "string", "description": "target gate label, if recorded" }, + "fidelity": { "type": "number", "minimum": 0, "maximum": 1.0001 }, + "pulse_path": { "type": "string", "minLength": 1, "description": "path/ref to the promoted pulse artifact (e.g. pulse.jld2)" }, + "created_at": { "type": "string", "minLength": 1, "format": "date-time" }, + "params": { "type": "object", "additionalProperties": true, "description": "the regime solved (self-describing), copied from result.toml" } + } +} diff --git a/packages/schema/schemas/finished.schema.json b/packages/schema/schemas/finished.schema.json new file mode 100644 index 00000000..f24a85c3 --- /dev/null +++ b/packages/schema/schemas/finished.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://amico.harmoniqs.co/schema/finished", + "title": "amico run-dir FINISHED sentinel", + "description": "Terminal sentinel written LAST by amico-run. A SUB-SHAPE of the run-dir contract — it carries no schema_version of its own; the run's version lives in run.toml.", + "type": "object", + "additionalProperties": false, + "required": ["status", "exit_code"], + "properties": { + "status": { "enum": ["completed", "failed", "aborted"] }, + "exit_code": { "type": "integer" } + } +} diff --git a/packages/schema/schemas/lab.schema.json b/packages/schema/schemas/lab.schema.json new file mode 100644 index 00000000..d38536d0 --- /dev/null +++ b/packages/schema/schemas/lab.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://amico.harmoniqs.co/schema/lab/v1", + "title": "amico lab.toml", + "description": "A partner lab's hardware profile — the only place hardware params enter a solve. Formalizes the EXACT shape β.4 already ships in lab.toml.example ([lab].name + [transmon] omega/delta/levels/drive_max). Ranged hardware fields give the out-of-range failure class a home (S17).", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "lab", "transmon"], + "properties": { + "schema_version": { "enum": ["1"] }, + "lab": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string", "minLength": 1, "description": "lab/profile identifier" } + } + }, + "transmon": { + "type": "object", + "additionalProperties": false, + "required": ["omega_GHz", "delta_GHz", "levels", "drive_max_GHz"], + "properties": { + "omega_GHz": { "type": "number", "exclusiveMinimum": 0, "maximum": 100, "description": "qubit transition frequency (GHz)" }, + "delta_GHz": { "type": "number", "minimum": -2, "maximum": 2, "description": "anharmonicity (GHz; positive convention in the beta template). Bounded to catch garbage/sign-flipped values — physical |δ| is ~0.1–0.5 GHz; the sign convention itself isn't enforced." }, + "levels": { "type": "integer", "minimum": 2, "maximum": 10, "description": "transmon levels modeled (qubit + leakage)" }, + "drive_max_GHz": { "type": "number", "exclusiveMinimum": 0, "maximum": 10, "description": "per-quadrature drive bound (GHz)" } + } + } + } +} diff --git a/packages/schema/schemas/result.schema.json b/packages/schema/schemas/result.schema.json new file mode 100644 index 00000000..5b6d6e4f --- /dev/null +++ b/packages/schema/schemas/result.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://amico.harmoniqs.co/schema/result/v1", + "title": "amico run-dir result.toml", + "description": "Solve outcome written atomically by the bundled solve env. Carries schema_version (added when the contract was formalized; 0.1d enforces it on the Julia emit side).", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "fidelity", "iterations"], + "properties": { + "schema_version": { "enum": ["1"] }, + "fidelity": { "type": "number", "minimum": 0, "maximum": 1.0001, "description": "subspace gate fidelity (rollout-based; allow a few ulp over 1 for numerical noise, reject gross out-of-range)" }, + "iterations": { "type": "integer", "minimum": 0 }, + "wall_seconds": { "type": "number", "minimum": 0 }, + "params": { + "type": "object", + "description": "The regime the run actually solved (self-describing). Lenient by design — solve params vary by system/platform; the run-dir contract pins only fidelity + iterations.", + "additionalProperties": true + } + } +} diff --git a/packages/schema/schemas/run.schema.json b/packages/schema/schemas/run.schema.json new file mode 100644 index 00000000..cb515c3d --- /dev/null +++ b/packages/schema/schemas/run.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://amico.harmoniqs.co/schema/run/v1", + "title": "amico run-dir run.toml", + "description": "Per-run identity + provenance, written FIRST by amico-run (formerly manifest.toml — renamed to avoid colliding with Julia's Manifest.toml on case-insensitive filesystems). The per-run schema_version carrier for the run-dir contract.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "run_id", "script_path", "lab", "lab_id", "created_at", "orchestrator_version", "julia"], + "properties": { + "schema_version": { "enum": ["1"], "description": "run-dir contract version (enum = the supported-version SET; grows by one entry per bump)" }, + "run_id": { "type": "string", "minLength": 1 }, + "script_path": { "type": "string", "minLength": 1 }, + "lab": { "type": "string", "minLength": 1 }, + "lab_id": { "type": "string", "minLength": 1 }, + "created_at": { "type": "string", "minLength": 1, "format": "date-time" }, + "orchestrator_version": { "type": "string", "minLength": 1 }, + "julia": { + "type": "object", + "additionalProperties": false, + "required": ["binary"], + "properties": { + "binary": { "type": "string", "minLength": 1 }, + "project": { "type": "string" }, + "sysimage": { "type": "string" } + } + } + } +} diff --git a/packages/schema/schemas/solvespec.schema.json b/packages/schema/schemas/solvespec.schema.json new file mode 100644 index 00000000..7e10d41d --- /dev/null +++ b/packages/schema/schemas/solvespec.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://amico.harmoniqs.co/schema/solvespec/v1", + "title": "amico SolveSpec", + "description": "FORWARD-LOOKING. The resolved solve specification a future amico-run would assemble and validate before dispatch. amico-run is argv-only today and emits no SolveSpec, so this schema is authored from the PRD shape and exercised via a committed fixture + `--schema solvespec` only (no emitter to round-trip yet). Its assembler/validate() call is a later slice, NOT 0.1a.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "script_path", "lab_id"], + "properties": { + "schema_version": { "enum": ["1"] }, + "script_path": { "type": "string", "minLength": 1, "description": "the Julia script to run" }, + "lab_id": { "type": "string", "minLength": 1, "description": "lab pointer (id or path) — physics params live in the lab.toml/script, not here" }, + "gate": { "type": "string", "description": "target gate label (e.g. X, H), if known at assembly" }, + "params": { "type": "object", "additionalProperties": true, "description": "lenient solve-knob block (T, N, max_iter, …)" }, + "julia": { + "type": "object", + "additionalProperties": false, + "required": ["binary"], + "properties": { + "binary": { "type": "string", "minLength": 1 }, + "project": { "type": "string" }, + "sysimage": { "type": "string" } + } + } + } +} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts new file mode 100644 index 00000000..647197ce --- /dev/null +++ b/packages/schema/src/index.ts @@ -0,0 +1,109 @@ +// @amicode/schema — the single source of truth for amico's config + run-dir +// artifact shapes. JSON Schema files in ../schemas are the contract (shared +// verbatim with the Julia round-trip, 0.1d); this module compiles them with ajv +// and exposes ONE validate() consumed by the extension, the amico-run CLI, the +// amico-validate CLI, and CI. No consumer should define its own schema (regression). +import { readFileSync } from "node:fs"; +import { extname } from "node:path"; +import { parse as parseToml } from "smol-toml"; +import { Ajv, type ErrorObject, type ValidateFunction } from "ajv"; +import addFormatsDefault from "ajv-formats"; + +import runSchema from "../schemas/run.schema.json" with { type: "json" }; +import finishedSchema from "../schemas/finished.schema.json" with { type: "json" }; +import resultSchema from "../schemas/result.schema.json" with { type: "json" }; +import labSchema from "../schemas/lab.schema.json" with { type: "json" }; +import solvespecSchema from "../schemas/solvespec.schema.json" with { type: "json" }; +import catalogEntrySchema from "../schemas/catalog-entry.schema.json" with { type: "json" }; + +// ajv-formats ships a CJS default export; under NodeNext the default import can +// bind the module namespace rather than the callable, so normalize defensively. +const addFormats = (typeof addFormatsDefault === "function" + ? addFormatsDefault + : (addFormatsDefault as unknown as { default: unknown }).default) as unknown as (ajv: Ajv) => void; + +// The registry IS the schema set. Adding a schema = one import + one entry; the +// SchemaKind type and the CI conformance loop derive from it automatically. +const SCHEMAS = { + run: runSchema, + finished: finishedSchema, + result: resultSchema, + lab: labSchema, + solvespec: solvespecSchema, + "catalog-entry": catalogEntrySchema, +} as const; + +export type SchemaKind = keyof typeof SCHEMAS; +export const SCHEMA_KINDS = Object.keys(SCHEMAS) as SchemaKind[]; + +/** Versions the validators accept (Q87: tolerate known-prior within range, reject + * unknown/absent). Only v1 exists today; grows when the first bump lands. */ +export const SUPPORTED_SCHEMA_VERSIONS = ["1"] as const; + +export interface Validation { ok: boolean; errors: string[] } + +const ajv = new Ajv({ allErrors: true, strict: false }); +addFormats(ajv); +const compiled = new Map(); +for (const [kind, schema] of Object.entries(SCHEMAS)) { + compiled.set(kind as SchemaKind, ajv.compile(schema as object)); +} + +/** Validate an already-parsed artifact against its schema. Field-precise: every + * error names the offending key and its JSON-pointer path. */ +export function validate(artifact: unknown, kind: SchemaKind): Validation { + const v = compiled.get(kind); + if (!v) return { ok: false, errors: [`unknown schema kind: ${kind}`] }; + const ok = v(artifact) as boolean; + if (ok) return { ok: true, errors: [] }; + return { ok: false, errors: (v.errors ?? []).map(formatError) }; +} + +/** Validate a file on disk: read → parse (TOML, or JSON by extension) → validate. + * Parse/read failures are themselves field-precise-ish errors, never a throw. */ +export function validateFile(filePath: string, kind: SchemaKind): Validation { + let raw: string; + try { raw = readFileSync(filePath, "utf8"); } + catch (e) { return { ok: false, errors: [`cannot read ${filePath}: ${(e as Error).message}`] }; } + let parsed: unknown; + try { parsed = extname(filePath).toLowerCase() === ".json" ? JSON.parse(raw) : parseToml(raw); } + catch (e) { return { ok: false, errors: [`${filePath}: parse error — ${(e as Error).message}`] }; } + return validate(normalizeDates(parsed), kind); +} + +/** smol-toml parses an UNQUOTED TOML datetime (`created_at = 2026-…Z`) into a Date + * object, which fails our `type: string` (`format: date-time`) fields. Coerce any + * Date back to its ISO-8601 string so quoted and unquoted datetimes validate + * identically — important for the cross-language schemas (a Julia TOML.print of a + * DateTime emits unquoted). Shallow + one level of nesting covers our shapes. */ +function normalizeDates(v: unknown): unknown { + if (v instanceof Date) return v.toISOString(); + if (v && typeof v === "object" && !Array.isArray(v)) { + const out: Record = {}; + for (const [k, val] of Object.entries(v as Record)) out[k] = normalizeDates(val); + return out; + } + return v; +} + +function formatError(e: ErrorObject): string { + const where = e.instancePath === "" ? "(root)" : e.instancePath; + // The schema_version carrier gets a version-specific message (S14, #16 AC5 / + // #17 AC3). An ABSENT version fails as `required` on the parent (handled below); + // an UNRECOGNIZED version fails `enum` here. + if (e.instancePath === "/schema_version" && e.keyword === "enum") { + return `/schema_version: unrecognized version (supported: ${SUPPORTED_SCHEMA_VERSIONS.join(", ")})`; + } + switch (e.keyword) { + case "required": + return `${where}: missing required key "${(e.params as { missingProperty: string }).missingProperty}"`; + case "additionalProperties": + return `${where}: unknown key "${(e.params as { additionalProperty: string }).additionalProperty}"`; + case "enum": { + const allowed = (e.params as { allowedValues?: unknown[] }).allowedValues ?? []; + return `${where}: must be one of (${allowed.join(", ")})`; + } + default: + return `${where}: ${e.message ?? "invalid"}`; + } +} diff --git a/packages/schema/test/fixtures/valid/catalog-entry.toml b/packages/schema/test/fixtures/valid/catalog-entry.toml new file mode 100644 index 00000000..be11214a --- /dev/null +++ b/packages/schema/test/fixtures/valid/catalog-entry.toml @@ -0,0 +1,10 @@ +schema_version = "1" +run_id = "r20260615-000000Z-ab12" +lab_id = "default" +gate = "X" +fidelity = 0.99995 +pulse_path = "/Users/researcher/.amico/runs/default/r20260615-000000Z-ab12/pulse.jld2" +created_at = "2026-06-15T00:00:00Z" + +[params] +levels = 3 diff --git a/packages/schema/test/fixtures/valid/finished.toml b/packages/schema/test/fixtures/valid/finished.toml new file mode 100644 index 00000000..a09f7c1d --- /dev/null +++ b/packages/schema/test/fixtures/valid/finished.toml @@ -0,0 +1,2 @@ +status = "completed" +exit_code = 0 diff --git a/packages/schema/test/fixtures/valid/lab.toml b/packages/schema/test/fixtures/valid/lab.toml new file mode 100644 index 00000000..b264f214 --- /dev/null +++ b/packages/schema/test/fixtures/valid/lab.toml @@ -0,0 +1,10 @@ +schema_version = "1" + +[lab] +name = "demo-lab" + +[transmon] +omega_GHz = 5.0 +delta_GHz = 0.2 +levels = 3 +drive_max_GHz = 0.2 diff --git a/packages/schema/test/fixtures/valid/result.toml b/packages/schema/test/fixtures/valid/result.toml new file mode 100644 index 00000000..9a683645 --- /dev/null +++ b/packages/schema/test/fixtures/valid/result.toml @@ -0,0 +1,12 @@ +schema_version = "1" +fidelity = 0.99995 +iterations = 60 +wall_seconds = 88.5 + +[params] +delta = 0.2 +levels = 3 +T = 10.0 +N = 50 +drive_max = 0.2 +max_iter = 60 diff --git a/packages/schema/test/fixtures/valid/run.toml b/packages/schema/test/fixtures/valid/run.toml new file mode 100644 index 00000000..f5ea49cf --- /dev/null +++ b/packages/schema/test/fixtures/valid/run.toml @@ -0,0 +1,11 @@ +schema_version = "1" +run_id = "r20260615-000000Z-ab12" +script_path = "/tmp/amicode-work/solve.jl" +lab = "default" +lab_id = "default" +created_at = "2026-06-15T00:00:00Z" +orchestrator_version = "0.1.0" + +[julia] +binary = "julia" +project = "/Users/researcher/.amico/julia" diff --git a/packages/schema/test/fixtures/valid/solvespec.toml b/packages/schema/test/fixtures/valid/solvespec.toml new file mode 100644 index 00000000..d45bfa0e --- /dev/null +++ b/packages/schema/test/fixtures/valid/solvespec.toml @@ -0,0 +1,13 @@ +schema_version = "1" +script_path = "/tmp/amicode-work/solve.jl" +lab_id = "default" +gate = "X" + +[params] +T = 10.0 +N = 50 +max_iter = 60 + +[julia] +binary = "julia" +project = "/Users/researcher/.amico/julia" diff --git a/packages/schema/test/validate.test.ts b/packages/schema/test/validate.test.ts new file mode 100644 index 00000000..661787a0 --- /dev/null +++ b/packages/schema/test/validate.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync, writeFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { parse as parseToml } from "smol-toml"; +import { + validate, validateFile, SCHEMA_KINDS, SUPPORTED_SCHEMA_VERSIONS, type SchemaKind, +} from "../src/index.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const validDir = join(here, "fixtures", "valid"); +const fixtureFile = (kind: SchemaKind) => join(validDir, `${kind}.toml`); +const load = (kind: SchemaKind) => parseToml(readFileSync(fixtureFile(kind), "utf8")) as Record; +const hasErr = (errs: string[], needle: string) => errs.some((e) => e.includes(needle)); + +// ── the shared golden corpus (also consumed by 0.1c CLI + 0.1d Julia round-trip) ── +describe("valid golden fixtures validate clean", () => { + for (const kind of SCHEMA_KINDS) { + it(`${kind}: fixture conforms`, () => { + const r = validateFile(fixtureFile(kind), kind); + expect(r.errors).toEqual([]); + expect(r.ok).toBe(true); + }); + } +}); + +describe("schema set + exports", () => { + it("exposes all five versioned schemas + the FINISHED sub-shape", () => { + expect(new Set(SCHEMA_KINDS)).toEqual( + new Set(["run", "result", "lab", "solvespec", "catalog-entry", "finished"]), + ); + }); + it("SUPPORTED_SCHEMA_VERSIONS is the v1 instantiation of a version SET", () => { + expect([...SUPPORTED_SCHEMA_VERSIONS]).toEqual(["1"]); + }); + it("an unknown kind is a clean error, not a throw", () => { + const r = validate({}, "nope" as SchemaKind); + expect(r.ok).toBe(false); + expect(hasErr(r.errors, "unknown schema kind")).toBe(true); + }); +}); + +// ── schema_version policy (S5/S6, #15 AC3, #16 AC5, #17 AC3) ── +describe("schema_version policy", () => { + it("ABSENT version → field-precise missing-required (the five versioned schemas)", () => { + for (const kind of ["run", "result", "lab", "solvespec", "catalog-entry"] as SchemaKind[]) { + const obj = load(kind); delete obj.schema_version; + const r = validate(obj, kind); + expect(r.ok).toBe(false); + expect(hasErr(r.errors, "missing required key \"schema_version\"")).toBe(true); + } + }); + it("UNRECOGNIZED version → distinct version-specific error (all five versioned schemas)", () => { + for (const kind of ["run", "result", "lab", "solvespec", "catalog-entry"] as SchemaKind[]) { + const obj = load(kind); obj.schema_version = "99"; + const r = validate(obj, kind); + expect(r.ok).toBe(false); + expect(hasErr(r.errors, "/schema_version: unrecognized version")).toBe(true); + } + }); + it("every versioned schema's enum is in sync with SUPPORTED_SCHEMA_VERSIONS (no drift seam)", () => { + const schemasDir = join(here, "..", "schemas"); + for (const kind of ["run", "result", "lab", "solvespec", "catalog-entry"]) { + const schema = JSON.parse(readFileSync(join(schemasDir, `${kind}.schema.json`), "utf8")); + expect(schema.properties.schema_version.enum, `${kind} enum drift`).toEqual([...SUPPORTED_SCHEMA_VERSIONS]); + } + }); + it("FINISHED is a sub-shape — it carries NO schema_version and adding one is rejected", () => { + expect(validate({ status: "completed", exit_code: 0 }, "finished").ok).toBe(true); + const r = validate({ status: "completed", exit_code: 0, schema_version: "1" }, "finished"); + expect(r.ok).toBe(false); + expect(hasErr(r.errors, 'unknown key "schema_version"')).toBe(true); + }); +}); + +// ── field-precise negative matrix (#15 AC2, #16/#17 AC, #18 AC2/3) ── +describe("field-precise negative matrix", () => { + it("missing required key → names the absent key + path (top-level + nested)", () => { + const m = load("run"); delete m.run_id; + expect(hasErr(validate(m, "run").errors, 'missing required key "run_id"')).toBe(true); + const j = load("run"); delete (j.julia as Record).binary; + expect(hasErr(validate(j, "run").errors, '/julia: missing required key "binary"')).toBe(true); + }); + it("wrong-type and out-of-range are reported DISTINCTLY + field-precise (#18 AC3)", () => { + const wrong = load("result"); wrong.fidelity = "high"; + expect(hasErr(validate(wrong, "result").errors, "/fidelity: must be number")).toBe(true); // wrong type + const over = load("result"); over.fidelity = 1.5; + expect(hasErr(validate(over, "result").errors, "/fidelity: must be <= 1.0001")).toBe(true); // out of range — distinct + const lab = load("lab"); (lab.transmon as Record).levels = 99; + expect(hasErr(validate(lab, "lab").errors, "/transmon/levels: must be <= 10")).toBe(true); + }); + it("unknown key (top level) → names the offending key", () => { + const r = load("result"); r.bogus = 1; + expect(hasErr(validate(r, "result").errors, 'unknown key "bogus"')).toBe(true); + }); + it("a legitimately-converged fidelity slightly over 1.0 still validates (S1: no false-reject)", () => { + const r = load("result"); r.fidelity = 1.0000000002; + expect(validate(r, "result").ok).toBe(true); + }); + it("catalog-entry + solvespec negatives are field-precise (#15 AC8 / #17 AC5) [S5/S6]", () => { + const c = load("catalog-entry"); delete c.pulse_path; + expect(hasErr(validate(c, "catalog-entry").errors, 'missing required key "pulse_path"')).toBe(true); + const c2 = load("catalog-entry"); c2.fidelity = "x"; + expect(hasErr(validate(c2, "catalog-entry").errors, "/fidelity: must be number")).toBe(true); + const s = load("solvespec"); delete s.lab_id; + expect(hasErr(validate(s, "solvespec").errors, 'missing required key "lab_id"')).toBe(true); + const s2 = load("solvespec"); s2.unexpected = 1; + expect(hasErr(validate(s2, "solvespec").errors, 'unknown key "unexpected"')).toBe(true); + }); + it("lab hardware range bounds + name minLength are field-precise (#29)", () => { + const hi = load("lab"); (hi.transmon as Record).omega_GHz = 999; + expect(hasErr(validate(hi, "lab").errors, "/transmon/omega_GHz: must be <= 100")).toBe(true); + const dm = load("lab"); (dm.transmon as Record).drive_max_GHz = 50; + expect(hasErr(validate(dm, "lab").errors, "/transmon/drive_max_GHz: must be <= 10")).toBe(true); + const d = load("lab"); (d.transmon as Record).delta_GHz = 25; // garbage anharmonicity + expect(hasErr(validate(d, "lab").errors, "/transmon/delta_GHz: must be <= 2")).toBe(true); + const nm = load("lab"); (nm.lab as Record).name = ""; + expect(hasErr(validate(nm, "lab").errors, "/lab/name")).toBe(true); // minLength + }); + it("FINISHED bad status → field-precise enum error", () => { + const r = validate({ status: "halfway", exit_code: 0 }, "finished"); + expect(r.ok).toBe(false); + expect(hasErr(r.errors, "/status")).toBe(true); + }); + it("params sub-table is lenient (mixed int/float + extra keys allowed) [M2]", () => { + const r = load("result"); + (r.params as Record).future_knob = 7; // unknown param OK + (r.params as Record).levels = 4.0; // float where int-ish OK + expect(validate(r, "result").ok).toBe(true); + }); +}); + +// ── migration: the contract formalize-don't-fork guarantee (S2) ── +describe("formalize-don't-fork: real beta.1 artifacts validate under the closed schemas", () => { + it("a beta.1 manifest (writeManifest shape) + schema_version validates clean", () => { + // EXACT shape amico-run/src/run_dir.ts writeManifest emits. + const m = { + schema_version: "1", run_id: "r20260101-000000Z-aaaa", script_path: "/s.jl", + lab: "default", lab_id: "default", created_at: "2026-01-01T00:00:00.000Z", + orchestrator_version: "0.1.0", julia: { binary: "julia", project: "/p", sysimage: "/img.so" }, + }; + expect(validate(m, "run")).toEqual({ ok: true, errors: [] }); + }); + it("a beta.1 result.toml WITHOUT schema_version is now rejected (the documented migration: 0.1a adds the emit)", () => { + const old = { fidelity: 0.9999, iterations: 60, wall_seconds: 12.3, params: { levels: 3 } }; + const r = validate(old, "result"); + expect(r.ok).toBe(false); + expect(hasErr(r.errors, "schema_version")).toBe(true); + }); +}); + +// validateFile must accept an UNQUOTED TOML datetime (smol-toml parses it to a +// Date) the same as a quoted ISO string — important for cross-language emit (S2). +describe("validateFile tolerates unquoted TOML datetimes", () => { + it("an unquoted created_at validates identically to a quoted one", () => { + const dir = mkdtempSync(join(tmpdir(), "labfx-")); + const f = join(dir, "run.toml"); + writeFileSync(f, + 'schema_version = "1"\nrun_id = "r1"\nscript_path = "/s.jl"\nlab = "default"\n' + + 'lab_id = "default"\ncreated_at = 2026-06-15T00:00:00Z\norchestrator_version = "0.1.0"\n' + + '[julia]\nbinary = "julia"\n'); // NOTE: unquoted datetime + expect(validateFile(f, "run").errors).toEqual([]); + }); +}); + +// The REAL bundled demo run dir (β.6 replay fallback) must conform under the +// closed schemas — it's a shipped artifact the inspector reads (M4). +describe("bundled demo run dir conforms", () => { + const demoDir = join(here, "..", "..", "extension", "demo", "run"); + it("run.toml, FINISHED, result.toml all validate", () => { + expect(validateFile(join(demoDir, "run.toml"), "run").errors).toEqual([]); + expect(validateFile(join(demoDir, "FINISHED"), "finished").errors).toEqual([]); + expect(validateFile(join(demoDir, "result.toml"), "result").errors).toEqual([]); + }); +}); diff --git a/packages/schema/tsconfig.json b/packages/schema/tsconfig.json new file mode 100644 index 00000000..c924689e --- /dev/null +++ b/packages/schema/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1158f2f..22c76a57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,10 @@ importers: .: {} packages/amico-run: + dependencies: + '@amicode/schema': + specifier: workspace:* + version: link:../schema devDependencies: '@types/node': specifier: ^22.0.0 @@ -31,6 +35,9 @@ importers: '@amicode/amico-run': specifier: workspace:* version: link:../amico-run + '@amicode/schema': + specifier: workspace:* + version: link:../schema '@types/node': specifier: ^22.0.0 version: 22.19.19 @@ -53,6 +60,31 @@ importers: specifier: ^2.1.0 version: 2.1.9(@types/node@22.19.19) + packages/schema: + dependencies: + ajv: + specifier: ^8.17.0 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) + smol-toml: + specifier: ^1.3.0 + version: 1.6.1 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.19 + esbuild: + specifier: ^0.24.0 + version: 0.24.2 + typescript: + specifier: ^5.6.0 + version: 5.9.3 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@22.19.19) + packages: '@azu/format-text@1.0.2': @@ -710,6 +742,14 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -2324,6 +2364,10 @@ snapshots: agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3