From 9a6434d9b7cd9adf4b4c18512f09d60d208ae216 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Sat, 27 Jun 2026 01:01:11 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(schema):=200.1d=20=E2=80=94=20Julia=20?= =?UTF-8?q?round-trip=20validates=20emitted=20artifacts=20against=20the=20?= =?UTF-8?q?shared=20schemas=20(closes=20#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the producer/consumer loop: a Julia validator that checks result.toml / manifest.toml against the SAME packages/schema/schemas/*.json the TS side uses — read DIRECTLY (no transcribed copy), so mutating a schema flips both languages. This is the cross-language anti-drift guarantee (#18 AC6). - packages/schema/julia/validate.jl: AmicoValidate module + CLI. Reads ../schemas via JSONSchema.jl (draft-07, the aju<->JSONSchema.jl common floor), parses TOML, validates, prints field-precise errors. A formatting shim turns JSONSchema.jl's coarse SingleIssue (path + keyword) into "/path/key: reason" comparable to the TS side — incl. reconstructing the missing key for `required` and the version-specific schema_version message. Exit 0 valid / 64 invalid. jsonify() coerces unquoted TOML datetimes (Dates → ISO string) so they validate like quoted (S2, Julia side). - packages/schema/julia/runtests.jl (19 tests): valid golden corpus conforms; invalid corpus field-precise (offending key/path); schema_version absent + unrecognized; unquoted-datetime; single-source asserts (SCHEMA_DIR == ../schemas, no .schema.json copy in the Julia tree) — the AC6 anti-drift proof. - ci.yml: `schema-roundtrip` job (setup-julia + instantiate + runtests.jl). FAST tier — JSONSchema only, NO Piccolo solve (per the design review split). The freshly-emitted round-trip over a real solve is the slow/nightly extension; solve_common.jl + solve_template.jl already stamp schema_version so emitted artifacts are round-trippable there. Committed Manifest.toml for reproducibility (precedent: extension/julia). All four slices green: TS schema 32 / amico-run 47 / extension 63 + Julia 19. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 23 ++++ packages/schema/julia/Manifest.toml | 164 ++++++++++++++++++++++++++++ packages/schema/julia/Project.toml | 10 ++ packages/schema/julia/runtests.jl | 58 ++++++++++ packages/schema/julia/validate.jl | 94 ++++++++++++++++ 5 files changed, 349 insertions(+) create mode 100644 packages/schema/julia/Manifest.toml create mode 100644 packages/schema/julia/Project.toml create mode 100644 packages/schema/julia/runtests.jl create mode 100644 packages/schema/julia/validate.jl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa4f3f95..daa8826c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,29 @@ jobs: # the non-filename schemas (AC5: all five) via --schema $V packages/schema/test/fixtures/valid/solvespec.toml --schema solvespec $V packages/schema/test/fixtures/valid/catalog-entry.toml --schema catalog-entry + schema-roundtrip: + # 0.1d: the Julia round-trip against the SAME packages/schema/schemas/*.json the + # TS side uses (cross-language anti-drift). Fast tier — JSONSchema only, NO Piccolo + # solve. Three lanes: (1) golden + negative fixtures, (2) a REAL producer lane + # (amico-run emits a run-dir → validate it — exercises the producer, not just + # fixtures), (3) AC6 drift check (perturb the shared schema → BOTH TS and Julia flip). + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: { node-version: 20, cache: pnpm } + - uses: julia-actions/setup-julia@v2 + with: { version: '1.12' } + - run: pnpm install --frozen-lockfile + - run: pnpm -r run build # amico-run + the validate launcher's bundle + - run: julia --project=packages/schema/julia -e 'using Pkg; Pkg.instantiate()' + - name: round-trip (golden + negative fixtures) + run: julia --project=packages/schema/julia packages/schema/julia/runtests.jl + - name: producer lane — amico-run emits a run-dir, Julia validates the emitted output + run: bash packages/schema/scripts/producer_roundtrip.sh + - name: AC6 anti-drift — perturbing the shared schema flips BOTH TS and Julia + run: bash packages/schema/scripts/ac6_drift_check.sh boot-smoke: strategy: matrix: diff --git a/packages/schema/julia/Manifest.toml b/packages/schema/julia/Manifest.toml new file mode 100644 index 00000000..49ac92a1 --- /dev/null +++ b/packages/schema/julia/Manifest.toml @@ -0,0 +1,164 @@ +# This file is machine-generated - editing it directly is not advised + +julia_version = "1.12.3" +manifest_format = "2.0" +project_hash = "21ac55b8e0f4abd48d2517aa1021fb48f818680a" + +[[deps.ArgTools]] +uuid = "0dad84c5-d112-42e6-8d28-ef12dabb789f" +version = "1.1.2" + +[[deps.Artifacts]] +uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" +version = "1.11.0" + +[[deps.Dates]] +deps = ["Printf"] +uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" +version = "1.11.0" + +[[deps.Downloads]] +deps = ["ArgTools", "FileWatching", "LibCURL", "NetworkOptions"] +uuid = "f43a241f-c20a-4ad4-852c-f6b1247861c6" +version = "1.7.0" + +[[deps.FileWatching]] +uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee" +version = "1.11.0" + +[[deps.JSON]] +deps = ["Dates", "Logging", "Parsers", "PrecompileTools", "StructUtils", "UUIDs", "Unicode"] +git-tree-sha1 = "c89d196f5ffb64bfbf80985b699ea913b0d2c211" +uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" +version = "1.6.1" + + [deps.JSON.extensions] + JSONArrowExt = ["ArrowTypes"] + + [deps.JSON.weakdeps] + ArrowTypes = "31f734f8-188a-4ce0-8406-c8a06bd891cd" + +[[deps.JSONSchema]] +deps = ["Downloads", "JSON", "URIs"] +git-tree-sha1 = "d13f79c4242969874da7d00bda17d59bc7699aa7" +uuid = "7d188eb4-7ad8-530c-ae41-71a32a6d4692" +version = "1.5.0" + + [deps.JSONSchema.extensions] + JSONSchemaJSON3Ext = "JSON3" + + [deps.JSONSchema.weakdeps] + JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" + +[[deps.LibCURL]] +deps = ["LibCURL_jll", "MozillaCACerts_jll"] +uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21" +version = "0.6.4" + +[[deps.LibCURL_jll]] +deps = ["Artifacts", "LibSSH2_jll", "Libdl", "OpenSSL_jll", "Zlib_jll", "nghttp2_jll"] +uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0" +version = "8.15.0+0" + +[[deps.LibSSH2_jll]] +deps = ["Artifacts", "Libdl", "OpenSSL_jll"] +uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8" +version = "1.11.3+1" + +[[deps.Libdl]] +uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" +version = "1.11.0" + +[[deps.Logging]] +uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" +version = "1.11.0" + +[[deps.MozillaCACerts_jll]] +uuid = "14a3606d-f60d-562e-9121-12d972cd8159" +version = "2025.5.20" + +[[deps.NetworkOptions]] +uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" +version = "1.3.0" + +[[deps.OpenSSL_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95" +version = "3.5.4+0" + +[[deps.Parsers]] +deps = ["Dates", "PrecompileTools", "UUIDs"] +git-tree-sha1 = "32a4e09c5f29402573d673901778a0e03b0807b9" +uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" +version = "2.8.6" + +[[deps.PrecompileTools]] +deps = ["Preferences"] +git-tree-sha1 = "edbeefc7a4889f528644251bdb5fc9ab5348bc2c" +uuid = "aea7be01-6a6a-4083-8856-8a6e6704d82a" +version = "1.3.4" + +[[deps.Preferences]] +deps = ["TOML"] +git-tree-sha1 = "8b770b60760d4451834fe79dd483e318eee709c4" +uuid = "21216c6a-2e73-6563-6e65-726566657250" +version = "1.5.2" + +[[deps.Printf]] +deps = ["Unicode"] +uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" +version = "1.11.0" + +[[deps.Random]] +deps = ["SHA"] +uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +version = "1.11.0" + +[[deps.SHA]] +uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" +version = "0.7.0" + +[[deps.StructUtils]] +deps = ["Dates", "UUIDs"] +git-tree-sha1 = "82bee338d650aa515f31866c460cb7e3bcef90b8" +uuid = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42" +version = "2.8.2" + + [deps.StructUtils.extensions] + StructUtilsMeasurementsExt = ["Measurements"] + StructUtilsStaticArraysCoreExt = ["StaticArraysCore"] + StructUtilsTablesExt = ["Tables"] + + [deps.StructUtils.weakdeps] + Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7" + StaticArraysCore = "1e83bf80-4336-4d27-bf5d-d5a4f845583c" + Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" + +[[deps.TOML]] +deps = ["Dates"] +uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" +version = "1.0.3" + +[[deps.URIs]] +git-tree-sha1 = "bef26fb046d031353ef97a82e3fdb6afe7f21b1a" +uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" +version = "1.6.1" + +[[deps.UUIDs]] +deps = ["Random", "SHA"] +uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" +version = "1.11.0" + +[[deps.Unicode]] +uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" +version = "1.11.0" + +[[deps.Zlib_jll]] +deps = ["Libdl"] +uuid = "83775a58-1f1d-513f-b197-d71354ab007a" +version = "1.3.1+2" + +[[deps.nghttp2_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" +version = "1.64.0+1" diff --git a/packages/schema/julia/Project.toml b/packages/schema/julia/Project.toml new file mode 100644 index 00000000..5edfbfbb --- /dev/null +++ b/packages/schema/julia/Project.toml @@ -0,0 +1,10 @@ +[deps] +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" +JSONSchema = "7d188eb4-7ad8-530c-ae41-71a32a6d4692" +TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[compat] +JSONSchema = "1" +julia = "1.10" diff --git a/packages/schema/julia/runtests.jl b/packages/schema/julia/runtests.jl new file mode 100644 index 00000000..fb57a744 --- /dev/null +++ b/packages/schema/julia/runtests.jl @@ -0,0 +1,58 @@ +#!/usr/bin/env julia +# 0.1d Julia round-trip test (fast tier — golden + negative fixtures, no solve). +# Proves the Julia validator honors the SAME shared schemas the TS side uses, and +# that mutating those single-source files would flip Julia too (anti-drift, #18 AC6). +# julia --project=packages/schema/julia packages/schema/julia/runtests.jl +# A top-level @testset throws on failure → nonzero exit → reds CI. +using Test +include(joinpath(@__DIR__, "validate.jl")) +using .AmicoValidate: validate_file, SCHEMA_DIR + +const FIX = normpath(joinpath(@__DIR__, "..", "test", "fixtures")) +const KINDS = ["manifest", "result", "lab", "solvespec", "catalog-entry", "finished"] + +@testset "0.1d Julia round-trip against the shared schemas" begin + @testset "valid golden corpus conforms" begin + for k in KINDS + @test validate_file(joinpath(FIX, "valid", "$k.toml"), k) === nothing + end + end + + @testset "invalid corpus → field-precise errors" begin + # each negative names the offending key/path (the lab-partner promise, S16) + @test occursin("run_id", validate_file(joinpath(FIX, "invalid", "manifest.toml"), "manifest")) + @test occursin("schema_version", validate_file(joinpath(FIX, "invalid", "result.toml"), "result")) + @test occursin("/transmon/levels", validate_file(joinpath(FIX, "invalid", "lab.toml"), "lab")) + @test occursin("pulse_path", validate_file(joinpath(FIX, "invalid", "catalog-entry.toml"), "catalog-entry")) + @test occursin("/status", validate_file(joinpath(FIX, "invalid", "finished.toml"), "finished")) + @test validate_file(joinpath(FIX, "invalid", "solvespec.toml"), "solvespec") !== nothing + end + + @testset "schema_version policy" begin + mktempdir() do d + absent = joinpath(d, "r.toml"); write(absent, "fidelity = 0.99\niterations = 60\n") + @test occursin("schema_version", validate_file(absent, "result")) + bad = joinpath(d, "b.toml"); write(bad, "schema_version = \"9\"\nfidelity = 0.99\niterations = 60\n") + @test occursin("unrecognized version", validate_file(bad, "result")) + end + end + + @testset "unquoted TOML datetime is tolerated (S2)" begin + mktempdir() do d + f = joinpath(d, "manifest.toml") + write(f, "schema_version = \"1\"\nrun_id=\"r\"\nscript_path=\"/s\"\nlab=\"d\"\nlab_id=\"d\"\n" * + "created_at=2026-06-15T00:00:00Z\norchestrator_version=\"0.1.0\"\n[julia]\nbinary=\"julia\"\n") + @test validate_file(f, "manifest") === nothing + end + end + + @testset "single source of truth (anti-drift, #18 AC6)" begin + # The Julia validator reads the SAME files the TS validator imports — the + # package's schemas/ dir — not a copy. Mutating one would flip both sides. + @test basename(SCHEMA_DIR) == "schemas" + @test isfile(joinpath(SCHEMA_DIR, "manifest.schema.json")) + @test normpath(SCHEMA_DIR) == normpath(joinpath(@__DIR__, "..", "schemas")) + # No transcribed schema copy lives in the Julia tree. + @test isempty(filter(f -> endswith(f, ".schema.json"), readdir(@__DIR__))) + end +end diff --git a/packages/schema/julia/validate.jl b/packages/schema/julia/validate.jl new file mode 100644 index 00000000..bdfd21b4 --- /dev/null +++ b/packages/schema/julia/validate.jl @@ -0,0 +1,94 @@ +#!/usr/bin/env julia +# amico-validate, Julia side (0.1d). Validates a TOML artifact against the SAME +# shared JSON Schema files the TS validator uses — read DIRECTLY from +# ../schemas/*.json, never a transcribed copy (that single source is what makes +# the cross-language anti-drift guarantee real: mutate a schema → both sides flip). +# +# Usage: julia --project=packages/schema/julia validate.jl [kind] +# kind inferred from basename for manifest.toml/result.toml/lab.toml/FINISHED. +# Exit: 0 valid · 64 invalid or usage error (mirrors amico-validate / amico-run). +module AmicoValidate + +import TOML +import JSON +import Dates +using JSONSchema + +const SCHEMA_DIR = normpath(joinpath(@__DIR__, "..", "schemas")) + +kind_for_filename(path) = begin + b = basename(path) + b == "manifest.toml" ? "manifest" : + b == "result.toml" ? "result" : + b == "lab.toml" ? "lab" : + b == "FINISHED" ? "finished" : nothing +end + +load_schema(kind) = Schema(read(joinpath(SCHEMA_DIR, "$(kind).schema.json"), String)) + +# TOML → JSON-compatible: an UNQUOTED TOML datetime parses to a Dates type, which +# would fail `type: string` (format: date-time). Coerce to ISO-8601 so quoted and +# unquoted datetimes validate identically (matches the TS validateFile S2 fix). +jsonify(x) = x +jsonify(d::AbstractDict) = Dict(string(k) => jsonify(v) for (k, v) in d) +jsonify(a::AbstractVector) = Any[jsonify(v) for v in a] +jsonify(t::Dates.TimeType) = string(t) + +# Shim JSONSchema.jl's SingleIssue into a field-precise message comparable to the +# TS side ("/path/to/key: reason"). Its `path` is like "[a][b]" or "" (root); +# `reason` is the failing keyword; `x` is the failing instance; `val` the schema +# fragment. For `required`/`enum` we reconstruct the offending key from x/val. +function format_issue(iss) + p = replace(iss.path, "[" => "/", "]" => "") + where = isempty(p) ? "(root)" : p + if iss.reason == "required" + missing = try + present = Set(string.(keys(iss.x))) + String[string(k) for k in iss.val if !(string(k) in present)] + catch; String[]; end + keys_txt = isempty(missing) ? "a required key" : join(["\"$k\"" for k in missing], ", ") + return "$where: missing required key $keys_txt" + elseif iss.reason == "additionalProperties" + return "$where: unknown key" + elseif iss.reason == "enum" + endswith(where, "schema_version") && return "/schema_version: unrecognized version" + return "$where: must be one of the allowed values" + else + return "$where: $(iss.reason)" # type, maximum, minimum, … + end +end + +"""Validate `path` (TOML) against the shared schema for `kind`. +Returns `nothing` if valid, else a field-precise error String.""" +function validate_file(path::AbstractString, kind::AbstractString) + data = jsonify(TOML.parsefile(path)) + iss = JSONSchema.validate(load_schema(kind), data) + iss === nothing ? nothing : format_issue(iss) +end + +function main(args) + if isempty(args) || args[1] in ("-h", "--help") + println("usage: julia validate.jl [kind]"); return isempty(args) ? 64 : 0 + end + file = args[1] + kind = length(args) >= 2 ? args[2] : kind_for_filename(file) + if kind === nothing + println(stderr, "amico-validate(jl): cannot infer schema for $file — pass a kind"); return 64 + end + err = try + validate_file(file, kind) + catch e + println(stderr, "amico-validate(jl): $file: $(sprint(showerror, e))"); return 64 + end + if err === nothing + println("OK $file ($kind)"); return 0 + else + println(stderr, "INVALID $file ($kind):\n $err"); return 64 + end +end + +end # module + +if abspath(PROGRAM_FILE) == @__FILE__ + exit(AmicoValidate.main(ARGS)) +end From 358a1d68ae4d597a6d3fad7c2e0e432ed7151976 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Sat, 27 Jun 2026 01:12:09 -0400 Subject: [PATCH 2/3] =?UTF-8?q?fix(schema):=200.1d=20code-review=20?= =?UTF-8?q?=E2=80=94=20Manifest+Test=20(CI=20redder),=20field-precise=20pa?= =?UTF-8?q?rity,=20demo-golden=20round-trip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the adversarial review of 0.1d: - MUST-FIX: re-resolved packages/schema/julia/Manifest.toml so `Test` (a direct dep added after the manifest was generated) is present — the committed manifest lacked it, so `Pkg.instantiate()` hard-failed the new schema-roundtrip CI job before any test ran. Verified a fresh instantiate from the committed manifest now exits 0. - Field-precise PARITY with the TS validator (the shim now reads JSONSchema.jl's iss.val): additionalProperties NAMES the offending key (`unknown key "rogue_key"`), enum lists allowed values (`must be one of (completed, failed, aborted)`), and type/maximum/minimum/exclusiveMinimum/minLength render as prose (`must be number`, `must be <= 1.0001`). where-path normalized to `/a/b` | `(root)`. - Round-trip now also validates the REAL bundled demo run dir (manifest/result/ FINISHED) — emitted golden, not just hand-authored fixtures (AC5). Julia round-trip 22 tests green; TS schema 32 / amico-run 47 / extension 63. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/schema/julia/Manifest.toml | 34 +++++++++++++++++- packages/schema/julia/runtests.jl | 7 ++++ packages/schema/julia/validate.jl | 56 ++++++++++++++++++++--------- 3 files changed, 79 insertions(+), 18 deletions(-) diff --git a/packages/schema/julia/Manifest.toml b/packages/schema/julia/Manifest.toml index 49ac92a1..36d162cb 100644 --- a/packages/schema/julia/Manifest.toml +++ b/packages/schema/julia/Manifest.toml @@ -2,7 +2,7 @@ julia_version = "1.12.3" manifest_format = "2.0" -project_hash = "21ac55b8e0f4abd48d2517aa1021fb48f818680a" +project_hash = "1edcb12d45221601b2491adf2fc752d3ca5544c3" [[deps.ArgTools]] uuid = "0dad84c5-d112-42e6-8d28-ef12dabb789f" @@ -12,6 +12,10 @@ version = "1.1.2" uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" version = "1.11.0" +[[deps.Base64]] +uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" +version = "1.11.0" + [[deps.Dates]] deps = ["Printf"] uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" @@ -26,6 +30,11 @@ version = "1.7.0" uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee" version = "1.11.0" +[[deps.InteractiveUtils]] +deps = ["Markdown"] +uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" +version = "1.11.0" + [[deps.JSON]] deps = ["Dates", "Logging", "Parsers", "PrecompileTools", "StructUtils", "UUIDs", "Unicode"] git-tree-sha1 = "c89d196f5ffb64bfbf80985b699ea913b0d2c211" @@ -50,6 +59,11 @@ version = "1.5.0" [deps.JSONSchema.weakdeps] JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" +[[deps.JuliaSyntaxHighlighting]] +deps = ["StyledStrings"] +uuid = "ac6e5ff7-fb65-4e79-a425-ec3bc9c03011" +version = "1.12.0" + [[deps.LibCURL]] deps = ["LibCURL_jll", "MozillaCACerts_jll"] uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21" @@ -73,6 +87,11 @@ version = "1.11.0" uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" version = "1.11.0" +[[deps.Markdown]] +deps = ["Base64", "JuliaSyntaxHighlighting", "StyledStrings"] +uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" +version = "1.11.0" + [[deps.MozillaCACerts_jll]] uuid = "14a3606d-f60d-562e-9121-12d972cd8159" version = "2025.5.20" @@ -118,6 +137,10 @@ version = "1.11.0" uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" version = "0.7.0" +[[deps.Serialization]] +uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" +version = "1.11.0" + [[deps.StructUtils]] deps = ["Dates", "UUIDs"] git-tree-sha1 = "82bee338d650aa515f31866c460cb7e3bcef90b8" @@ -134,11 +157,20 @@ version = "2.8.2" StaticArraysCore = "1e83bf80-4336-4d27-bf5d-d5a4f845583c" Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" +[[deps.StyledStrings]] +uuid = "f489334b-da3d-4c2e-b8f0-e476e12c162b" +version = "1.11.0" + [[deps.TOML]] deps = ["Dates"] uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" version = "1.0.3" +[[deps.Test]] +deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] +uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +version = "1.11.0" + [[deps.URIs]] git-tree-sha1 = "bef26fb046d031353ef97a82e3fdb6afe7f21b1a" uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" diff --git a/packages/schema/julia/runtests.jl b/packages/schema/julia/runtests.jl index fb57a744..ed19ba2d 100644 --- a/packages/schema/julia/runtests.jl +++ b/packages/schema/julia/runtests.jl @@ -46,6 +46,13 @@ const KINDS = ["manifest", "result", "lab", "solvespec", "catalog-entry", "finis end end + @testset "the real bundled demo run dir (emitted golden) validates" begin + demo = normpath(joinpath(@__DIR__, "..", "..", "extension", "demo", "run")) + @test validate_file(joinpath(demo, "manifest.toml"), "manifest") === nothing + @test validate_file(joinpath(demo, "result.toml"), "result") === nothing + @test validate_file(joinpath(demo, "FINISHED"), "finished") === nothing + end + @testset "single source of truth (anti-drift, #18 AC6)" begin # The Julia validator reads the SAME files the TS validator imports — the # package's schemas/ dir — not a copy. Mutating one would flip both sides. diff --git a/packages/schema/julia/validate.jl b/packages/schema/julia/validate.jl index bdfd21b4..6474bdbc 100644 --- a/packages/schema/julia/validate.jl +++ b/packages/schema/julia/validate.jl @@ -24,7 +24,19 @@ kind_for_filename(path) = begin b == "FINISHED" ? "finished" : nothing end -load_schema(kind) = Schema(read(joinpath(SCHEMA_DIR, "$(kind).schema.json"), String)) +schema_path(kind) = joinpath(SCHEMA_DIR, "$(kind).schema.json") +load_schema(kind) = Schema(read(schema_path(kind), String)) + +# Allowed property names at a JSON-pointer path within the schema — so the +# additionalProperties shim can NAME the offending extra key (JSONSchema.jl only +# reports `false`, not which key), matching the TS validator. +function allowed_props(schema, parts) + node = schema + for p in parts + node = get(get(node, "properties", Dict{String,Any}()), p, Dict{String,Any}()) + end + Set(string.(keys(get(node, "properties", Dict{String,Any}())))) +end # TOML → JSON-compatible: an UNQUOTED TOML datetime parses to a Dates type, which # would fail `type: string` (format: date-time). Coerce to ISO-8601 so quoted and @@ -38,23 +50,32 @@ jsonify(t::Dates.TimeType) = string(t) # TS side ("/path/to/key: reason"). Its `path` is like "[a][b]" or "" (root); # `reason` is the failing keyword; `x` is the failing instance; `val` the schema # fragment. For `required`/`enum` we reconstruct the offending key from x/val. -function format_issue(iss) - p = replace(iss.path, "[" => "/", "]" => "") - where = isempty(p) ? "(root)" : p - if iss.reason == "required" - missing = try - present = Set(string.(keys(iss.x))) - String[string(k) for k in iss.val if !(string(k) in present)] - catch; String[]; end - keys_txt = isempty(missing) ? "a required key" : join(["\"$k\"" for k in missing], ", ") - return "$where: missing required key $keys_txt" - elseif iss.reason == "additionalProperties" - return "$where: unknown key" - elseif iss.reason == "enum" +function format_issue(iss, schema) + parts = [String(p) for p in split(iss.path, ['[', ']']) if !isempty(p)] + where = isempty(parts) ? "(root)" : "/" * join(parts, "/") + r = iss.reason + if r == "required" + present = try Set(string.(keys(iss.x))) catch; Set{String}() end + missing = String[string(k) for k in iss.val if !(string(k) in present)] + return "$where: missing required key " * (isempty(missing) ? "(unknown)" : join(["\"$k\"" for k in missing], ", ")) + elseif r == "additionalProperties" + extra = try String[string(k) for k in keys(iss.x) if !(string(k) in allowed_props(schema, parts))] catch; String[]; end + return "$where: unknown key " * (isempty(extra) ? "(unexpected)" : join(["\"$k\"" for k in extra], ", ")) + elseif r == "enum" endswith(where, "schema_version") && return "/schema_version: unrecognized version" - return "$where: must be one of the allowed values" + return "$where: must be one of (" * join(string.(iss.val), ", ") * ")" + elseif r == "type" + return "$where: must be $(iss.val)" + elseif r == "maximum" + return "$where: must be <= $(iss.val)" + elseif r == "minimum" + return "$where: must be >= $(iss.val)" + elseif r == "exclusiveMinimum" + return "$where: must be > $(iss.val)" + elseif r == "minLength" + return "$where: must be a non-empty string" else - return "$where: $(iss.reason)" # type, maximum, minimum, … + return "$where: $r" end end @@ -63,7 +84,8 @@ Returns `nothing` if valid, else a field-precise error String.""" function validate_file(path::AbstractString, kind::AbstractString) data = jsonify(TOML.parsefile(path)) iss = JSONSchema.validate(load_schema(kind), data) - iss === nothing ? nothing : format_issue(iss) + iss === nothing && return nothing + format_issue(iss, JSON.parsefile(schema_path(kind))) end function main(args) From 2ef1d88ce4ada5b8d594349c5c0fd889ff58160b Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Sun, 28 Jun 2026 16:42:14 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(schema):=200.1d=20review=20=E2=80=94=20?= =?UTF-8?q?real=20producer=20round-trip=20+=20AC6=20exercised=20end-to-end?= =?UTF-8?q?=20(rebased=20on=20#28=20rename)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Jack's #31 review: - REAL producer lane (not just fixtures): scripts/producer_roundtrip.sh runs amico-run on a minimal non-Piccolo emitter (julia/emit_fixture.jl) so it produces a live run-dir (run.toml + FINISHED from the orchestrator, result.toml from the Julia emitter), then validates the FRESHLY-EMITTED artifacts with validate.jl. CI runs it (fast tier; the real Piccolo solve stays the slow/nightly extension). Scoped to what each side actually emits — the orchestrator writes run.toml/FINISHED, the Julia producer writes result.toml. - AC6 exercised, not asserted: scripts/ac6_drift_check.sh perturbs the SHARED schema, REBUILDS TS (it bakes schemas at build time — the gap Jack found), then asserts BOTH the TS bin AND validate.jl reject a previously-valid fixture, then reverts. Proven locally; wired into the schema-roundtrip CI job. runtests.jl's AC6 testset notes the build-vs- runtime asymmetry. - Rename lockstep: validate.jl + runtests.jl manifest.toml→run.toml, "manifest"→"run", run.schema.json; resolved the ci.yml conflict keeping #30's all-five gate + this job. schema 34 / amico-run 47 / extension 64 (+1 skip) / julia 22 green; producer + AC6 scripts pass locally. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/schema/julia/emit_fixture.jl | 14 +++++++++ packages/schema/julia/runtests.jl | 18 +++++++----- packages/schema/julia/validate.jl | 4 +-- packages/schema/scripts/ac6_drift_check.sh | 29 +++++++++++++++++++ packages/schema/scripts/producer_roundtrip.sh | 22 ++++++++++++++ 5 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 packages/schema/julia/emit_fixture.jl create mode 100755 packages/schema/scripts/ac6_drift_check.sh create mode 100755 packages/schema/scripts/producer_roundtrip.sh diff --git a/packages/schema/julia/emit_fixture.jl b/packages/schema/julia/emit_fixture.jl new file mode 100644 index 00000000..781ad617 --- /dev/null +++ b/packages/schema/julia/emit_fixture.jl @@ -0,0 +1,14 @@ +#!/usr/bin/env julia +# Minimal NON-Piccolo emitter for the producer round-trip lane (0.1d). amico-run +# runs this with cwd = the run dir and writes run.toml (first) + FINISHED (last); +# this writes a schema-conforming result.toml. The point is to exercise the real +# PRODUCER seam (amico-run + a Julia emitter → a live run-dir) in the fast tier, +# without a heavy Piccolo solve (the real solve is the slow/nightly extension). +# Uses only the TOML stdlib so it runs in any Julia env. +import TOML +println("AMICODE_ITER iter=1 f=1.000000e-03 inf_pr=1.0e-9 inf_du=1.0e-6"); flush(stdout) +open("result.toml.tmp", "w") do io + TOML.print(io, Dict("schema_version" => "1", "fidelity" => 0.9999, "iterations" => 1)) +end +mv("result.toml.tmp", "result.toml"; force = true) +println("DONE fidelity=0.9999"); flush(stdout) diff --git a/packages/schema/julia/runtests.jl b/packages/schema/julia/runtests.jl index ed19ba2d..16a7019e 100644 --- a/packages/schema/julia/runtests.jl +++ b/packages/schema/julia/runtests.jl @@ -9,7 +9,7 @@ include(joinpath(@__DIR__, "validate.jl")) using .AmicoValidate: validate_file, SCHEMA_DIR const FIX = normpath(joinpath(@__DIR__, "..", "test", "fixtures")) -const KINDS = ["manifest", "result", "lab", "solvespec", "catalog-entry", "finished"] +const KINDS = ["run", "result", "lab", "solvespec", "catalog-entry", "finished"] @testset "0.1d Julia round-trip against the shared schemas" begin @testset "valid golden corpus conforms" begin @@ -20,7 +20,7 @@ const KINDS = ["manifest", "result", "lab", "solvespec", "catalog-entry", "finis @testset "invalid corpus → field-precise errors" begin # each negative names the offending key/path (the lab-partner promise, S16) - @test occursin("run_id", validate_file(joinpath(FIX, "invalid", "manifest.toml"), "manifest")) + @test occursin("run_id", validate_file(joinpath(FIX, "invalid", "run.toml"), "run")) @test occursin("schema_version", validate_file(joinpath(FIX, "invalid", "result.toml"), "result")) @test occursin("/transmon/levels", validate_file(joinpath(FIX, "invalid", "lab.toml"), "lab")) @test occursin("pulse_path", validate_file(joinpath(FIX, "invalid", "catalog-entry.toml"), "catalog-entry")) @@ -39,25 +39,29 @@ const KINDS = ["manifest", "result", "lab", "solvespec", "catalog-entry", "finis @testset "unquoted TOML datetime is tolerated (S2)" begin mktempdir() do d - f = joinpath(d, "manifest.toml") + f = joinpath(d, "run.toml") write(f, "schema_version = \"1\"\nrun_id=\"r\"\nscript_path=\"/s\"\nlab=\"d\"\nlab_id=\"d\"\n" * "created_at=2026-06-15T00:00:00Z\norchestrator_version=\"0.1.0\"\n[julia]\nbinary=\"julia\"\n") - @test validate_file(f, "manifest") === nothing + @test validate_file(f, "run") === nothing end end @testset "the real bundled demo run dir (emitted golden) validates" begin demo = normpath(joinpath(@__DIR__, "..", "..", "extension", "demo", "run")) - @test validate_file(joinpath(demo, "manifest.toml"), "manifest") === nothing + @test validate_file(joinpath(demo, "run.toml"), "run") === nothing @test validate_file(joinpath(demo, "result.toml"), "result") === nothing @test validate_file(joinpath(demo, "FINISHED"), "finished") === nothing end @testset "single source of truth (anti-drift, #18 AC6)" begin # The Julia validator reads the SAME files the TS validator imports — the - # package's schemas/ dir — not a copy. Mutating one would flip both sides. + # package's schemas/ dir — not a copy. Julia reads them at RUNTIME; the TS + # side BAKES them into dist/ at BUILD time (import … with {type:"json"}), so + # the cross-language flip only holds after a TS rebuild — the ci.yml + # `ac6_drift_check.sh` step exercises that end-to-end (perturb → rebuild TS → + # assert BOTH flip). This testset proves the no-copy single source structurally. @test basename(SCHEMA_DIR) == "schemas" - @test isfile(joinpath(SCHEMA_DIR, "manifest.schema.json")) + @test isfile(joinpath(SCHEMA_DIR, "run.schema.json")) @test normpath(SCHEMA_DIR) == normpath(joinpath(@__DIR__, "..", "schemas")) # No transcribed schema copy lives in the Julia tree. @test isempty(filter(f -> endswith(f, ".schema.json"), readdir(@__DIR__))) diff --git a/packages/schema/julia/validate.jl b/packages/schema/julia/validate.jl index 6474bdbc..4708bcac 100644 --- a/packages/schema/julia/validate.jl +++ b/packages/schema/julia/validate.jl @@ -5,7 +5,7 @@ # the cross-language anti-drift guarantee real: mutate a schema → both sides flip). # # Usage: julia --project=packages/schema/julia validate.jl [kind] -# kind inferred from basename for manifest.toml/result.toml/lab.toml/FINISHED. +# kind inferred from basename for run.toml/result.toml/lab.toml/FINISHED. # Exit: 0 valid · 64 invalid or usage error (mirrors amico-validate / amico-run). module AmicoValidate @@ -18,7 +18,7 @@ const SCHEMA_DIR = normpath(joinpath(@__DIR__, "..", "schemas")) kind_for_filename(path) = begin b = basename(path) - b == "manifest.toml" ? "manifest" : + b == "run.toml" ? "run" : b == "result.toml" ? "result" : b == "lab.toml" ? "lab" : b == "FINISHED" ? "finished" : nothing diff --git a/packages/schema/scripts/ac6_drift_check.sh b/packages/schema/scripts/ac6_drift_check.sh new file mode 100755 index 00000000..7d21bf3d --- /dev/null +++ b/packages/schema/scripts/ac6_drift_check.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# 0.1d AC6 (anti-drift, exercised not asserted): mutating the SINGLE shared schema +# must flip BOTH validators. The TS side bakes schemas into dist/ at BUILD time +# (import … with {type:"json"}), so we perturb the shared file → REBUILD TS → +# assert BOTH the TS bin AND the Julia validator now reject a previously-valid +# fixture → revert. Proves the cross-language single source end-to-end (Jack's #31). +set -uo pipefail # NOT -e: validate returning 64 is expected and handled below +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +SCHEMA="$ROOT/packages/schema/schemas/run.schema.json" +FIX="$ROOT/packages/schema/test/fixtures/valid/run.toml" + +cleanup() { git -C "$ROOT" checkout -- "$SCHEMA" 2>/dev/null; pnpm --filter @amicode/schema build >/dev/null 2>&1 || true; } +trap cleanup EXIT + +# sanity: the fixture is valid BEFORE perturbing +"$ROOT/packages/schema/launcher/amico-validate" "$FIX" --schema run >/dev/null || { echo "precondition failed: fixture not valid"; exit 1; } + +# perturb: add a bogus required key to the shared schema, then re-bake TS +node -e "const fs=require('fs'),f='$SCHEMA',s=JSON.parse(fs.readFileSync(f));s.required.push('__drift_canary__');fs.writeFileSync(f,JSON.stringify(s,null,2))" +pnpm --filter @amicode/schema build >/dev/null + +# both must now REJECT (exit 64). If either still passes (exit 0), AC6 is violated. +if "$ROOT/packages/schema/launcher/amico-validate" "$FIX" --schema run >/dev/null 2>&1; then + echo "AC6 FAIL: TS validator did NOT flip after the shared schema was perturbed + rebuilt"; exit 1 +fi +if julia --project="$ROOT/packages/schema/julia" "$ROOT/packages/schema/julia/validate.jl" "$FIX" run >/dev/null 2>&1; then + echo "AC6 FAIL: Julia validator did NOT flip on the perturbed shared schema"; exit 1 +fi +echo "AC6 PASS — perturbing the shared schema flipped BOTH the TS bin and the Julia validator" diff --git a/packages/schema/scripts/producer_roundtrip.sh b/packages/schema/scripts/producer_roundtrip.sh new file mode 100755 index 00000000..c5df9f67 --- /dev/null +++ b/packages/schema/scripts/producer_roundtrip.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# 0.1d producer round-trip: run amico-run on a minimal emitter so it produces a +# REAL run-dir (run.toml + FINISHED from the orchestrator, result.toml from the +# Julia emitter), then validate the FRESHLY-EMITTED artifacts with the Julia +# validator against the shared schemas. Exercises the producer seam, not just +# committed fixtures (Jack's #31). NOTE: the orchestrator writes run.toml/FINISHED; +# the Julia producer writes result.toml — that's the one this round-trip pins. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +RUNS="$(mktemp -d)/runs" + +node "$ROOT/packages/amico-run/dist/amico-run.js" \ + "$ROOT/packages/schema/julia/emit_fixture.jl" --runs-root "$RUNS" --julia julia + +RUNDIR="$(dirname "$(find "$RUNS" -maxdepth 2 -name run.toml | head -1)")" +echo "emitted run-dir: $RUNDIR"; ls "$RUNDIR" + +VJL="julia --project=$ROOT/packages/schema/julia $ROOT/packages/schema/julia/validate.jl" +$VJL "$RUNDIR/run.toml" run +$VJL "$RUNDIR/result.toml" result +$VJL "$RUNDIR/FINISHED" finished +echo "PRODUCER ROUND-TRIP PASS"