From 99dede67735c96d525a1ba7d02fc0c79c9545f3e Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Sat, 27 Jun 2026 00:41:44 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(extension):=200.1b=20=E2=80=94=20valid?= =?UTF-8?q?ate=20lab.toml=20on=20load=20with=20field-precise=20errors=20(c?= =?UTF-8?q?loses=20#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A partner lab's lab.toml is the only place hardware params enter a solve; β had no validation, so a malformed/mistyped config silently solved against the wrong hardware or failed opaquely mid-solve. This validates it on extension load against the shared @amicode/schema lab schema (defined in 0.1a) and surfaces a field-precise error (offending key + dotted path), non-fatally. - src/lab_config.ts: resolveLabTomlPath (default ~/.amico/lab.toml, ~ expansion) + checkLabToml → absent | valid | invalid{errors}, via the SINGLE @amicode/schema validator (no second validation path). - extension.ts activate(): validates on load; invalid → showErrorMessage with the first field-precise error + full list in the "Amicode — runs" output channel. - amicode.labToml config setting (path; empty → ~/.amico/lab.toml). - lab.toml.example: stamped schema_version = "1" so the shipped starter validates. - Tests (lab_config.test.ts, 13): field-precise negative matrix (missing / wrong-type / out-of-range / unknown-key / absent+unrecognized version), parity with @amicode/schema.validateFile (single-path proof), shipped-example conforms. Extension 63 tests green; typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/package.json | 5 ++ packages/extension/scripts/lab.toml.example | 6 +- packages/extension/src/extension.ts | 18 ++++++ packages/extension/src/lab_config.ts | 38 ++++++++++++ packages/extension/test/lab_config.test.ts | 65 +++++++++++++++++++++ 5 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 packages/extension/src/lab_config.ts create mode 100644 packages/extension/test/lab_config.test.ts diff --git a/packages/extension/package.json b/packages/extension/package.json index 993a5d15..400d28b9 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -99,6 +99,11 @@ "type": "string", "default": "", "description": "Runs root the inspector watches. Empty = ~/.amico/runs/default (must match where amico-run writes)." + }, + "amicode.labToml": { + "type": "string", + "default": "", + "description": "Path to the lab.toml hardware profile, validated on load. Empty = ~/.amico/lab.toml (where install.sh writes the starter)." } } } diff --git a/packages/extension/scripts/lab.toml.example b/packages/extension/scripts/lab.toml.example index df482a22..da264532 100644 --- a/packages/extension/scripts/lab.toml.example +++ b/packages/extension/scripts/lab.toml.example @@ -1,5 +1,9 @@ # Amicode starter lab config (one lab). Representative single-transmon params; -# the agent / template may read these. (lab.toml schema lands in Phase 2.) +# the agent / template reads these. Validated on extension load against the shared +# @amicode/schema lab schema — a malformed value surfaces a field-precise error +# rather than silently solving against the wrong hardware. +schema_version = "1" + [lab] name = "demo-lab" diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 6cf97b10..a10f0c71 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -9,6 +9,7 @@ import { registerTrees } from "./trees"; import { StatusBarManager } from "./status_bar"; import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent } from "./opencode_config"; import { resolveAmicoRunBinDir, resolveRunsRoot } from "./opencode_paths"; +import { resolveLabTomlPath, checkLabToml } from "./lab_config"; import { OpencodeEventClient } from "./sse_client"; import { RunsRootWatcher } from "./file_watcher"; import { stageDemoRun } from "./demo_replay"; @@ -50,6 +51,23 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { watcher.start(); ctx.subscriptions.push(watcher); + // Validate lab.toml on load (0.1b / S17). A malformed hardware profile would + // otherwise silently solve against the wrong hardware or fail opaquely mid-solve. + // Field-precise: the error names the offending key + path. Non-fatal — the rest + // of the extension still activates; a partner can fix the config and reload. + const labPath = resolveLabTomlPath(vscode.workspace.getConfiguration("amicode").get("labToml", "")); + const lab = checkLabToml(labPath); + if (lab.state === "invalid") { + runsChannel.appendLine(`[lab] ${lab.path} is INVALID:`); + for (const e of lab.errors) runsChannel.appendLine(` ${e}`); + void vscode.window.showErrorMessage( + `Amicode: lab.toml is invalid — ${lab.errors[0]}` + + (lab.errors.length > 1 ? ` (+${lab.errors.length - 1} more; see "Amicode — runs" output)` : ""), + ); + } else if (lab.state === "valid") { + runsChannel.appendLine(`[lab] validated ${lab.path}`); + } + // 3. opencode project bootstrap const amicoRunBinDir = resolveAmicoRunBinDir(ctx.extensionPath); const opencodeProject = prepareOpencodeProject({ diff --git a/packages/extension/src/lab_config.ts b/packages/extension/src/lab_config.ts new file mode 100644 index 00000000..efb3efbf --- /dev/null +++ b/packages/extension/src/lab_config.ts @@ -0,0 +1,38 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { validateFile, type Validation } from "@amicode/schema"; + +// ============================================================================ +// lab.toml load-time validation (0.1b / S17). A partner lab's lab.toml is the +// only place hardware params enter a solve; validating it on extension load +// turns a malformed/mistyped config into a field-precise error (offending key + +// path) instead of a silent solve against the wrong hardware or an opaque +// mid-solve failure. The schema itself lives in the shared @amicode/schema +// package (0.1a) — this is only the resolve-path + load + surface seam. +// ============================================================================ + +/** Resolve the lab.toml the extension validates on load. Empty config → + * ~/.amico/lab.toml (where install.sh writes the starter). A leading ~ is + * expanded, mirroring resolveRunsRoot / resolveJuliaProject. */ +export function resolveLabTomlPath(configValue: string): string { + const v = (configValue ?? "").trim(); + if (v === "") return path.join(os.homedir(), ".amico", "lab.toml"); + if (v === "~") return os.homedir(); + if (v.startsWith("~/")) return path.join(os.homedir(), v.slice(2)); + return v; +} + +export type LabCheck = + | { state: "absent"; path: string } + | { state: "valid"; path: string } + | { state: "invalid"; path: string; errors: string[] }; + +/** Validate the lab.toml at `labPath` against the shared lab schema. A missing + * file is `absent` (not an error — a lab may be provisioned later); a present + * file is validated field-precise via the single @amicode/schema validator. */ +export function checkLabToml(labPath: string): LabCheck { + if (!fs.existsSync(labPath)) return { state: "absent", path: labPath }; + const v: Validation = validateFile(labPath, "lab"); + return v.ok ? { state: "valid", path: labPath } : { state: "invalid", path: labPath, errors: v.errors }; +} diff --git a/packages/extension/test/lab_config.test.ts b/packages/extension/test/lab_config.test.ts new file mode 100644 index 00000000..d536b5e9 --- /dev/null +++ b/packages/extension/test/lab_config.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir, homedir } from "node:os"; +import { join } from "node:path"; +import { validateFile } from "@amicode/schema"; +import { resolveLabTomlPath, checkLabToml } from "../src/lab_config"; + +const VALID = + 'schema_version = "1"\n[lab]\nname = "demo-lab"\n' + + "[transmon]\nomega_GHz = 5.0\ndelta_GHz = 0.2\nlevels = 3\ndrive_max_GHz = 0.2\n"; + +function writeLab(content: string): string { + const p = join(mkdtempSync(join(tmpdir(), "lab-")), "lab.toml"); + writeFileSync(p, content); + return p; +} +function errs(content: string): string[] { + const c = checkLabToml(writeLab(content)); + return c.state === "invalid" ? c.errors : []; +} +const has = (es: string[], needle: string) => es.some((e) => e.includes(needle)); + +describe("resolveLabTomlPath", () => { + it("defaults to ~/.amico/lab.toml", () => + expect(resolveLabTomlPath("")).toBe(join(homedir(), ".amico", "lab.toml"))); + it("expands a leading ~", () => { + expect(resolveLabTomlPath("~")).toBe(homedir()); + expect(resolveLabTomlPath("~/x/lab.toml")).toBe(join(homedir(), "x", "lab.toml")); + }); + it("uses an explicit path, trimmed", () => + expect(resolveLabTomlPath(" /a/lab.toml ")).toBe("/a/lab.toml")); +}); + +describe("checkLabToml", () => { + it("a missing file is `absent`, not an error (a lab may be provisioned later)", () => + expect(checkLabToml(join(tmpdir(), "definitely-absent-lab-dir", "lab.toml")).state).toBe("absent")); + it("a conforming lab.toml is `valid`", () => + expect(checkLabToml(writeLab(VALID)).state).toBe("valid")); + + // field-precise negative matrix (#16 ACs / S17) + it("missing required key → names the absent key + path", () => + expect(has(errs(VALID.replace("drive_max_GHz = 0.2\n", "")), 'missing required key "drive_max_GHz"')).toBe(true)); + it("wrong type → names the offending key", () => + expect(has(errs(VALID.replace("levels = 3", 'levels = "three"')), "/transmon/levels: must be integer")).toBe(true)); + it("out-of-range → names the offending key (distinct from wrong-type)", () => + expect(has(errs(VALID.replace("levels = 3", "levels = 99")), "/transmon/levels: must be <= 10")).toBe(true)); + it("unknown / misspelled key → names that key", () => + expect(has(errs(VALID + "rogue = 1\n"), 'unknown key "rogue"')).toBe(true)); + it("absent schema_version → field-precise required error", () => + expect(has(errs(VALID.replace('schema_version = "1"\n', "")), 'missing required key "schema_version"')).toBe(true)); + it("unrecognized schema_version → version-specific error", () => + expect(has(errs(VALID.replace('schema_version = "1"', 'schema_version = "9"')), "/schema_version: unrecognized version")).toBe(true)); + + it("parity: checkLabToml uses the SAME validator as @amicode/schema directly (no second path)", () => { + const p = writeLab(VALID.replace("levels = 3", "levels = 99")); + const c = checkLabToml(p); + expect(c.state).toBe("invalid"); + expect((c as { errors: string[] }).errors).toEqual(validateFile(p, "lab").errors); + }); +}); + +describe("the shipped starter lab.toml.example conforms", () => { + it("scripts/lab.toml.example validates clean", () => + expect(validateFile(join(__dirname, "..", "scripts", "lab.toml.example"), "lab").errors).toEqual([])); +}); From ade5a6d378d98b27d2001515e0f28cad466c5f57 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Sun, 28 Jun 2026 16:29:25 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(extension):=200.1b=20review=20=E2=80=94?= =?UTF-8?q?=20corpus-wide=20parity=20+=20lab=20range/minLength/Schuster=20?= =?UTF-8?q?(rebased=20on=20#28=20rename)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Jack's #29 nits: - Loop the parity assertion over a CORPUS (valid + missing/wrong-type/out-of-range/ unknown-key/absent+unrecognized-version) asserting checkLabToml === @amicode/schema validateFile on each — was a single input (#16 "over the corpus"). - Add the range-bound negatives the matrix skipped: omega_GHz≤100, drive_max_GHz≤10, delta_GHz (now bounded in #28), and lab.name minLength — each field-precise. - Add a Schuster-profile valid fixture (negative-δ convention, 4 levels) alongside demo-lab — the PRD demo forcing-function + second real-shaped profile. Rebased onto #28's manifest.toml→run.toml rename (lab files don't reference the run-dir header, so clean). extension 64 (+1 packaging skip) green. Non-blocking (Jack): the toast/channel seam has no VS Code host test (no host in CI) and lab.toml has no `provides` — both are Phase-1 prerequisites (provides ties to the 0.1a-follow env-contract), noted for that work, not this slice. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/extension/test/lab_config.test.ts | 40 ++++++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/packages/extension/test/lab_config.test.ts b/packages/extension/test/lab_config.test.ts index d536b5e9..176d4f47 100644 --- a/packages/extension/test/lab_config.test.ts +++ b/packages/extension/test/lab_config.test.ts @@ -51,15 +51,41 @@ describe("checkLabToml", () => { it("unrecognized schema_version → version-specific error", () => expect(has(errs(VALID.replace('schema_version = "1"', 'schema_version = "9"')), "/schema_version: unrecognized version")).toBe(true)); - it("parity: checkLabToml uses the SAME validator as @amicode/schema directly (no second path)", () => { - const p = writeLab(VALID.replace("levels = 3", "levels = 99")); - const c = checkLabToml(p); - expect(c.state).toBe("invalid"); - expect((c as { errors: string[] }).errors).toEqual(validateFile(p, "lab").errors); + it("hardware range bounds are field-precise (#29: omega/drive_max/delta) + name minLength", () => { + expect(has(errs(VALID.replace("omega_GHz = 5.0", "omega_GHz = 999")), "/transmon/omega_GHz: must be <= 100")).toBe(true); + expect(has(errs(VALID.replace("drive_max_GHz = 0.2", "drive_max_GHz = 50")), "/transmon/drive_max_GHz: must be <= 10")).toBe(true); + expect(has(errs(VALID.replace("delta_GHz = 0.2", "delta_GHz = 25")), "/transmon/delta_GHz: must be <= 2")).toBe(true); // garbage anharmonicity + expect(has(errs(VALID.replace('name = "demo-lab"', 'name = ""')), "/lab/name")).toBe(true); // minLength + }); + + it("parity over a corpus: checkLabToml === @amicode/schema.validateFile on every input (no second path) [#16]", () => { + const corpus = [ + VALID, // valid + VALID.replace("drive_max_GHz = 0.2\n", ""), // missing required + VALID.replace("levels = 3", 'levels = "three"'), // wrong type + VALID.replace("levels = 3", "levels = 99"), // out of range + VALID.replace("delta_GHz = 0.2", "delta_GHz = 25"), // out of range (delta) + VALID + "rogue = 1\n", // unknown key + VALID.replace('schema_version = "1"\n', ""), // absent version + VALID.replace('schema_version = "1"', 'schema_version = "9"'), // unrecognized version + ]; + for (const content of corpus) { + const p = writeLab(content); + const c = checkLabToml(p); + const direct = validateFile(p, "lab"); + expect(c.state === "valid" ? [] : (c as { errors: string[] }).errors).toEqual(direct.errors); + } }); }); -describe("the shipped starter lab.toml.example conforms", () => { - it("scripts/lab.toml.example validates clean", () => +describe("valid lab profiles conform (demo + Schuster)", () => { + it("scripts/lab.toml.example (the shipped starter) validates clean", () => expect(validateFile(join(__dirname, "..", "scripts", "lab.toml.example"), "lab").errors).toEqual([])); + it("a Schuster-profile lab (negative-convention δ, 4 levels) validates clean", () => { + // Distinct from demo-lab: negative anharmonicity convention + a 4-level model, + // exercising the schema's range tolerance on a second real-shaped profile. + const schuster = 'schema_version = "1"\n[lab]\nname = "schuster-transmon"\n' + + "[transmon]\nomega_GHz = 4.8\ndelta_GHz = -0.33\nlevels = 4\ndrive_max_GHz = 0.1\n"; + expect(checkLabToml(writeLab(schuster)).state).toBe("valid"); + }); });