Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,19 @@ jobs:
cache: npm
cache-dependency-path: viewer/package-lock.json

- name: Stage verified viewer package data
- name: Stage and test the viewer
# The Python inliner deliberately consumes only the manifest-verified package-data copy.
# Rebuild it from the locked frontend inputs and prove that the committed static output is
# byte-identical before the Python matrix exercises the HTML command.
# test:ci:portable is a superset of build (validator drift check, typecheck, the vitest
# suite, then the same build), so the viewer's own tests run here and the committed
# static output is proven byte-identical before the Python matrix exercises the HTML
# command. fixture:check is excluded deliberately: the showcase fixture's PNG bytes and
# score cascade are specific to the platform that generated it (image encoders and
# floating-point libraries differ across OS), so regenerating it on this runner drifts
# by construction. The full gate, fixture:check included, runs locally via test:ci on
# the platform that produced the committed fixture.
run: |
npm --prefix viewer ci
npm --prefix viewer run build
npm --prefix viewer run test:ci:portable
git diff --exit-code -- viewer/dist-static

- name: Tests
Expand Down
3 changes: 2 additions & 1 deletion viewer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"build:vite": "vite build",
"package": "node scripts/package-artifacts.mjs",
"build": "npm run validators:check && npm run firefly:check && npm run preference-replay:check && npm run typecheck && npm run build:vite && npm run package",
"test:ci": "npm run fixture:check && npm run validators:check && npm run typecheck && npm run test && npm run build"
"test:ci": "npm run fixture:check && npm run validators:check && npm run typecheck && npm run test && npm run build",
"test:ci:portable": "npm run validators:check && npm run typecheck && npm run test && npm run build"
},
"dependencies": {
"ajv": "8.20.0",
Expand Down
39 changes: 28 additions & 11 deletions viewer/scripts/generate-validators.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import standaloneCode from "ajv/dist/standalone/index.js";
const viewerRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repositoryRoot = path.resolve(viewerRoot, "..");
const outputPath = path.join(viewerRoot, "src", "generated", "report-validators.mjs");
const declarationPath = path.join(viewerRoot, "src", "generated", "report-validators.d.mts");
const schemas = [
["report-v1.0", path.join(repositoryRoot, "moodboard", "schema", "report_v1_0.schema.json")],
["report-v1.1", path.join(repositoryRoot, "moodboard", "schema", "report_v1_1.schema.json")],
Expand All @@ -29,29 +30,45 @@ for (const [key, schemaPath] of schemas) {
digests.push(`${key}=${createHash("sha256").update(bytes).digest("hex")}`);
}

const exportNames = {
validateReportV10: "report-v1.0",
validateReportV11: "report-v1.1",
};

const generated = [
"// Generated by scripts/generate-validators.mjs; do not edit.",
`// Exact inputs: ${digests.join(", ")}`,
standaloneCode(ajv, {
validateReportV10: "report-v1.0",
validateReportV11: "report-v1.1",
}).trimEnd(),
standaloneCode(ajv, exportNames).trimEnd(),
"",
].join("\n");

const declaration = [
"// Generated by scripts/generate-validators.mjs; do not edit.",
'import type { ValidateFunction } from "ajv";',
"",
...Object.keys(exportNames).map((name) => `export const ${name}: ValidateFunction;`),
"",
].join("\n");

const mode = process.argv[2];
if (mode === "--write") {
await writeFile(outputPath, generated, "utf8");
} else if (mode === "--check") {
async function checkFile(filePath, expected, label) {
let existing;
try {
existing = await readFile(outputPath, "utf8");
existing = await readFile(filePath, "utf8");
} catch {
throw new Error("Generated report validators are absent; run `npm run validators:write`.");
throw new Error(`${label} are absent; run \`npm run validators:write\`.`);
}
if (existing !== generated) {
throw new Error("Generated report validators differ from the exact schemas; run `npm run validators:write`.");
if (existing !== expected) {
throw new Error(`${label} differ from the exact schemas; run \`npm run validators:write\`.`);
}
}

if (mode === "--write") {
await writeFile(outputPath, generated, "utf8");
await writeFile(declarationPath, declaration, "utf8");
} else if (mode === "--check") {
await checkFile(outputPath, generated, "Generated report validators");
await checkFile(declarationPath, declaration, "Generated report validator declarations");
} else {
throw new Error("Usage: node scripts/generate-validators.mjs --write|--check");
}
41 changes: 39 additions & 2 deletions viewer/scripts/generate_showcase_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,38 @@ def _normalized_report(path: Path) -> dict[str, Any]:
return report


def _drift_paths(generated: Any, committed: Any, prefix: str = "$", limit: int = 20) -> list[str]:
if len(paths := _collect_drift(generated, committed, prefix)) > limit:
return [*paths[:limit], f"... {len(paths) - limit} more"]
return paths


def _collect_drift(generated: Any, committed: Any, prefix: str) -> list[str]:
if type(generated) is not type(committed):
return [f"{prefix}: type {type(generated).__name__} != {type(committed).__name__}"]
if isinstance(generated, dict):
paths = []
for key in sorted(generated.keys() | committed.keys()):
if key not in generated:
paths.append(f"{prefix}.{key}: only in committed fixture")
elif key not in committed:
paths.append(f"{prefix}.{key}: only in regenerated report")
else:
paths.extend(_collect_drift(generated[key], committed[key], f"{prefix}.{key}"))
return paths
if isinstance(generated, list):
if len(generated) != len(committed):
return [f"{prefix}: length {len(generated)} != {len(committed)}"]
return [
path
for index, (left, right) in enumerate(zip(generated, committed, strict=True))
for path in _collect_drift(left, right, f"{prefix}[{index}]")
]
if generated != committed:
return [f"{prefix}: {generated!r} != {committed!r}"]
return []


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--seed", type=int, default=SEED)
Expand Down Expand Up @@ -386,8 +418,13 @@ def main(argv: list[str] | None = None) -> int:
expected_manifest = target / "manifest.json"
if not expected_report.is_file() or not expected_manifest.is_file():
raise RuntimeError(f"committed fixture is incomplete under {target}")
if _normalized_report(report) != _normalized_report(expected_report):
raise RuntimeError("engine-generated showcase report drifted outside timestamps")
generated_report = _normalized_report(report)
committed_report = _normalized_report(expected_report)
if generated_report != committed_report:
drift = "\n".join(_drift_paths(generated_report, committed_report))
raise RuntimeError(
f"engine-generated showcase report drifted outside timestamps:\n{drift}"
)
committed_manifest = json.loads(expected_manifest.read_text(encoding="utf-8"))
if committed_manifest.get("engine_source_dirty") is not False:
raise RuntimeError(
Expand Down
1 change: 1 addition & 0 deletions viewer/src/generated/report-validators.d.mts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Generated by scripts/generate-validators.mjs; do not edit.
import type { ValidateFunction } from "ajv";

export const validateReportV10: ValidateFunction;
Expand Down
2 changes: 1 addition & 1 deletion viewer/tests/fixtures/showcase/manifest.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"commands":{"build":["moodboard","build","viewer/test-artifacts/showcase-generation/references","--name","Governed editorial system","--output","viewer/test-artifacts/showcase-generation/showcase.brand.mb"],"rank":["moodboard","rank","viewer/test-artifacts/showcase-generation/candidates","--board","viewer/test-artifacts/showcase-generation/showcase.brand.mb","--references","viewer/test-artifacts/showcase-generation/references","--output","viewer/test-artifacts/showcase-generation/showcase-report.json","--alpha","0.10","--exemplars","3","--tie-pairs","all","--seed","20260808"]},"engine_revision":"source-set-sha256:5214ae57619b15219aaa50f00b611de49e3a8b8ec3b18b6b9faa1d1c8775e1c0","engine_source_digest":"5214ae57619b15219aaa50f00b611de49e3a8b8ec3b18b6b9faa1d1c8775e1c0","engine_source_dirty":false,"format_version":1,"generator_sha256":"ac2d3ee2aef3cc8405422df0d8cddcfc2ff1e78ba4c98260a16a5389119a45be","preconditions":{"abstained_assets":1,"asset_ids":["01_aligned.png","02_diverse_compatible.png","03_palette_drift.png","04_composition_drift.png","05_duplicate.png","06_far_outlier.png"],"candidate_previews_inline":true,"claim_scope":"functionality-and-interface-fixture","counterexamples":["palette-drift","composition-drift","exact-duplicate","diverse-but-system-compatible","far-outlier"],"scored_assets":5,"three_exemplars_per_asset":true,"tie_pairs":1},"report_schema_sha256":"5eb8a20e865612676e89f2cd58ab17108c715102e66d66680a5ceb1cd7626ed8","report_schema_version":"1.1","report_sha256":"bc2c245ac4c62a77323a34a6c053bfe63177a623728bbac821059aa2dcca0e29","scenario":"governed-editorial-system","seed":20260808,"source_images":{"candidates/01_aligned.png":"2a9e8619086866776d516fae6b9f3d4b245ecfff483937d4996b1706fec162ef","candidates/02_diverse_compatible.png":"0c02351addf18a42f0d4869fb4583b7656c25f5e1a7d9bf92e04b6ad3e798976","candidates/03_palette_drift.png":"15114049631cb851f0c6ba92247b3cc21d5afbf44e033394dd1b51ce9978cc21","candidates/04_composition_drift.png":"efbd3002d799f4c73dbd77a67717185cdd97f9c46858bcfdf26c23738591db01","candidates/05_duplicate.png":"62bb5dd28f52eb13eb2b51883f136fd60cbb9446dd7b90eb8b9ad92ec283eb6d","candidates/06_far_outlier.png":"770724cbde62885dd87908779d9e980e339a1dc8ae3652a5e95a640de5676c8d","references/reference_01.png":"62bb5dd28f52eb13eb2b51883f136fd60cbb9446dd7b90eb8b9ad92ec283eb6d","references/reference_02.png":"a1f3d5526341c49554b1377d705a4ef9663626e17f49cb6d89cacc377961b15c","references/reference_03.png":"ed9f89c487cd4cbefb67327e1c253c03617396a199d88fbe6f743ca6cec5b990","references/reference_04.png":"916431096934722cb5fdab9d03f62b00de94385c2f590725a5b61ff16bbe3707","references/reference_05.png":"8a9f23adf85a8d6e1d30b5250d73d3b36d83ba758d7de0e47ac2b28c7bb889eb","references/reference_06.png":"da1deef2e36e7ffbd22134f0b46569b9034e7c725dd49c4d96ae56f12fed6e9c","references/reference_07.png":"7d2678bfc69d30c66eb0405de2a5cbbc11cca122bec7eb0c10f2faa52e328937","references/reference_08.png":"0efe05d2803b2fa5fe0a3c9d45873cff83a36ae071c4a2f9cd07ab6285eeed29","references/reference_09.png":"2a9e8619086866776d516fae6b9f3d4b245ecfff483937d4996b1706fec162ef","references/reference_10.png":"65d29543dffe714b664332b9943b074a5fc3a380dd749834674600beb4f4be7f","references/reference_11.png":"c057e767b2d23ec56d4d2cd26aa7fef5e33b80be2c4707fb59948a0d2c3cd115","references/reference_12.png":"6966d49789ba42ab14bbc2694460eb3405b56ee75328e0393436884329e3c9c3"},"uv_lock_sha256":"27d3b57259d48048bca154221ed546a9922c9bbc7f5a0a811e1acccbbd391c40"}
{"commands":{"build":["moodboard","build","viewer/test-artifacts/showcase-generation/references","--name","Governed editorial system","--output","viewer/test-artifacts/showcase-generation/showcase.brand.mb"],"rank":["moodboard","rank","viewer/test-artifacts/showcase-generation/candidates","--board","viewer/test-artifacts/showcase-generation/showcase.brand.mb","--references","viewer/test-artifacts/showcase-generation/references","--output","viewer/test-artifacts/showcase-generation/showcase-report.json","--alpha","0.10","--exemplars","3","--tie-pairs","all","--seed","20260808"]},"engine_revision":"source-set-sha256:43631e431be3dbfe8fa5e8b0b1a3b11a41d35829ac85ce3f432b3255d9d0e862","engine_source_digest":"43631e431be3dbfe8fa5e8b0b1a3b11a41d35829ac85ce3f432b3255d9d0e862","engine_source_dirty":false,"format_version":1,"generator_sha256":"b002285bc238de5ae330af45e9eacd43b57e76c260b42721347e9ddd620aa006","preconditions":{"abstained_assets":1,"asset_ids":["01_aligned.png","02_diverse_compatible.png","03_palette_drift.png","04_composition_drift.png","05_duplicate.png","06_far_outlier.png"],"candidate_previews_inline":true,"claim_scope":"functionality-and-interface-fixture","counterexamples":["palette-drift","composition-drift","exact-duplicate","diverse-but-system-compatible","far-outlier"],"scored_assets":5,"three_exemplars_per_asset":true,"tie_pairs":1},"report_schema_sha256":"5eb8a20e865612676e89f2cd58ab17108c715102e66d66680a5ceb1cd7626ed8","report_schema_version":"1.1","report_sha256":"3c5f517483ce28797efb22339ede25c3fde84850aa50916f8b069db3a78cbe84","scenario":"governed-editorial-system","seed":20260808,"source_images":{"candidates/01_aligned.png":"2a9e8619086866776d516fae6b9f3d4b245ecfff483937d4996b1706fec162ef","candidates/02_diverse_compatible.png":"0c02351addf18a42f0d4869fb4583b7656c25f5e1a7d9bf92e04b6ad3e798976","candidates/03_palette_drift.png":"15114049631cb851f0c6ba92247b3cc21d5afbf44e033394dd1b51ce9978cc21","candidates/04_composition_drift.png":"efbd3002d799f4c73dbd77a67717185cdd97f9c46858bcfdf26c23738591db01","candidates/05_duplicate.png":"62bb5dd28f52eb13eb2b51883f136fd60cbb9446dd7b90eb8b9ad92ec283eb6d","candidates/06_far_outlier.png":"770724cbde62885dd87908779d9e980e339a1dc8ae3652a5e95a640de5676c8d","references/reference_01.png":"62bb5dd28f52eb13eb2b51883f136fd60cbb9446dd7b90eb8b9ad92ec283eb6d","references/reference_02.png":"a1f3d5526341c49554b1377d705a4ef9663626e17f49cb6d89cacc377961b15c","references/reference_03.png":"ed9f89c487cd4cbefb67327e1c253c03617396a199d88fbe6f743ca6cec5b990","references/reference_04.png":"916431096934722cb5fdab9d03f62b00de94385c2f590725a5b61ff16bbe3707","references/reference_05.png":"8a9f23adf85a8d6e1d30b5250d73d3b36d83ba758d7de0e47ac2b28c7bb889eb","references/reference_06.png":"da1deef2e36e7ffbd22134f0b46569b9034e7c725dd49c4d96ae56f12fed6e9c","references/reference_07.png":"7d2678bfc69d30c66eb0405de2a5cbbc11cca122bec7eb0c10f2faa52e328937","references/reference_08.png":"0efe05d2803b2fa5fe0a3c9d45873cff83a36ae071c4a2f9cd07ab6285eeed29","references/reference_09.png":"2a9e8619086866776d516fae6b9f3d4b245ecfff483937d4996b1706fec162ef","references/reference_10.png":"65d29543dffe714b664332b9943b074a5fc3a380dd749834674600beb4f4be7f","references/reference_11.png":"c057e767b2d23ec56d4d2cd26aa7fef5e33b80be2c4707fb59948a0d2c3cd115","references/reference_12.png":"6966d49789ba42ab14bbc2694460eb3405b56ee75328e0393436884329e3c9c3"},"uv_lock_sha256":"03cf54dade11ae0d46d32dcf2dd413bd6ecbc55c5c92aaac3cf20929c26d8ae7"}
6 changes: 3 additions & 3 deletions viewer/tests/fixtures/showcase/report.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"n_eff": 12.0,
"requested_alpha": 0.1,
"supported_alpha": 0.07692307692307693,
"built_at": "2026-08-14T18:47:35Z",
"built_at": "2026-08-17T19:09:54Z",
"representation": {
"style": {
"model": "classical-v1",
Expand Down Expand Up @@ -650,7 +650,7 @@
"name": "moodboard",
"version": "0.1.0",
"source_repository": "https://github.com/ohdearquant/moodboard.git",
"source_revision": "24babdf5215d1590a1fa3d238ceda1a000494edd",
"source_revision": "ba1ba208c6e58a42cf3a9ad0aad6db2605c5c60b",
"source_dirty": false
},
"model": {
Expand Down Expand Up @@ -679,7 +679,7 @@
"20260808"
],
"seed": 20260808,
"created_at": "2026-08-14T18:47:37Z",
"created_at": "2026-08-17T19:09:56Z",
"schema": {
"id": "https://github.com/ohdearquant/moodboard/schema/report_v1_1.schema.json",
"sha256": "5eb8a20e865612676e89f2cd58ab17108c715102e66d66680a5ceb1cd7626ed8"
Expand Down
Loading