diff --git a/.github/workflows/build-canvas.yml b/.github/workflows/build-canvas.yml index 46febf60..b6f68392 100644 --- a/.github/workflows/build-canvas.yml +++ b/.github/workflows/build-canvas.yml @@ -6,13 +6,6 @@ on: branches: - main pull_request: - paths: - - ".github/workflows/**" - - ".nvmrc" - - "crates/**" - - "package.json" - - "pnpm-lock.yaml" - - "pnpm-workspace.yaml" concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/consolidation-gates.yml b/.github/workflows/consolidation-gates.yml new file mode 100644 index 00000000..f7c2033e --- /dev/null +++ b/.github/workflows/consolidation-gates.yml @@ -0,0 +1,336 @@ +name: consolidation gates + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + scope: + name: resolve gate scope + runs-on: ubuntu-24.04 + outputs: + base: ${{ steps.refs.outputs.base }} + head: ${{ steps.refs.outputs.head }} + engine: ${{ steps.refs.outputs.engine }} + steps: + - name: Checkout head revision + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Resolve revisions and engine scope + id: refs + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BASE_SHA: ${{ github.event.before }} + run: | + head=$(git rev-parse HEAD) + case "$EVENT_NAME" in + pull_request) base="$PR_BASE_SHA" ;; + push) base="$PUSH_BASE_SHA" ;; + *) base=$(git rev-parse HEAD^) ;; + esac + + if [[ -z "$base" || "$base" =~ ^0+$ ]]; then + base=$(git rev-parse HEAD^) + fi + + engine=false + if ! git diff --quiet "$base" "$head" -- \ + .github/workflows \ + bin/check-cargo-lock-additions-only \ + bin/check-legacy-pixel-sweep \ + Cargo.lock \ + Cargo.toml \ + rust-toolchain.toml \ + crates \ + fixtures \ + third_party; then + engine=true + fi + + echo "base=$base" >> "$GITHUB_OUTPUT" + echo "head=$head" >> "$GITHUB_OUTPUT" + echo "engine=$engine" >> "$GITHUB_OUTPUT" + + lock_additions_only: + name: Cargo.lock additions only + needs: scope + runs-on: ubuntu-24.04 + steps: + - name: Checkout head revision + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ needs.scope.outputs.head }} + + - name: Gate workspace crate cuts + run: >- + python3 bin/check-cargo-lock-additions-only + "${{ needs.scope.outputs.base }}" + "${{ needs.scope.outputs.head }}" + + seam_architecture: + name: seam architecture + needs: scope + if: needs.scope.outputs.engine == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout head revision + uses: actions/checkout@v4 + with: + ref: ${{ needs.scope.outputs.head }} + + - name: Free up disk space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/local/lib/android + sudo rm -rf /opt/ghc + sudo rm -rf /opt/hostedtoolcache/CodeQL + sudo docker image prune --all --force + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libexpat1-dev \ + libfontconfig1-dev \ + libfreetype6-dev \ + libgl1-mesa-dev \ + libgles2-mesa-dev \ + libwayland-dev \ + libx11-dev \ + libx11-xcb-dev \ + libxcb-render0-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + libxcursor-dev \ + libxi-dev \ + libxinerama-dev \ + libxrandr-dev \ + libxxf86vm-dev \ + mesa-common-dev + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.92.0 + + - name: Cache cargo build outputs + uses: Swatinem/rust-cache@v2 + with: + shared-key: consolidation-seams + cache-all-crates: true + cache-workspace-crates: true + cache-on-failure: true + + - name: Run the five legacy seam locks + env: + FORCE_SKIA_BINARIES_DOWNLOAD: "1" + run: | + cargo test --locked -p grida \ + --test cg_architecture \ + --test svg_import_architecture \ + --test html_import_architecture \ + --test htmlcss_svg_architecture \ + --test painter_architecture + + legacy_pixel_sweep: + name: legacy pixel sweep + needs: scope + if: needs.scope.outputs.engine == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 40 + steps: + - name: Checkout head revision + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ needs.scope.outputs.head }} + + - name: Free up disk space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/local/lib/android + sudo rm -rf /opt/ghc + sudo rm -rf /opt/hostedtoolcache/CodeQL + sudo docker image prune --all --force + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libexpat1-dev \ + libfontconfig1-dev \ + libfreetype6-dev \ + libgl1-mesa-dev \ + libgles2-mesa-dev \ + libwayland-dev \ + libx11-dev \ + libx11-xcb-dev \ + libxcb-render0-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + libxcursor-dev \ + libxi-dev \ + libxinerama-dev \ + libxrandr-dev \ + libxxf86vm-dev \ + mesa-common-dev + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.92.0 + + - name: Cache cargo build outputs + uses: Swatinem/rust-cache@v2 + with: + shared-key: consolidation-pixels + cache-all-crates: true + cache-workspace-crates: true + cache-on-failure: true + + - name: A/B the declared 65-fixture subset + env: + FORCE_SKIA_BINARIES_DOWNLOAD: "1" + run: >- + python3 bin/check-legacy-pixel-sweep + "${{ needs.scope.outputs.base }}" + + n0_gate: + name: n0 host gate + needs: scope + if: needs.scope.outputs.engine == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + FORCE_SKIA_BINARIES_DOWNLOAD: "1" + N0_GATE_HOST_ID: github-actions-ubuntu-24.04-x86_64 + N0_GATE_REQUIRE_HOST_BASELINES: "1" + N0_GATE_SOURCE_SHA: ${{ needs.scope.outputs.head }} + N0_GATE_CI_RUN_ID: ${{ github.run_id }} + N0_GATE_RUST_TOOLCHAIN: "1.92.0" + N0_GATE_SKIA_SAFE: "0.93.1" + steps: + - name: Checkout head revision + uses: actions/checkout@v4 + with: + ref: ${{ needs.scope.outputs.head }} + + - name: Free up disk space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/local/lib/android + sudo rm -rf /opt/ghc + sudo rm -rf /opt/hostedtoolcache/CodeQL + sudo docker image prune --all --force + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libexpat1-dev \ + libfontconfig1-dev \ + libfreetype6-dev \ + libgl1-mesa-dev \ + libgles2-mesa-dev \ + libwayland-dev \ + libx11-dev \ + libx11-xcb-dev \ + libxcb-render0-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + libxcursor-dev \ + libxi-dev \ + libxinerama-dev \ + libxrandr-dev \ + libxxf86vm-dev \ + mesa-common-dev + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.92.0 + + - name: Cache cargo build outputs + uses: Swatinem/rust-cache@v2 + with: + shared-key: consolidation-n0-release + cache-all-crates: true + cache-workspace-crates: true + cache-on-failure: true + + - name: Build the shot host and gate together + run: >- + cargo build --locked --release + -p n0_dev -p n0 + --bin n0_dev --bin gate + + - name: Run the n0 gate + id: gate + continue-on-error: true + run: target/release/gate + + - name: Produce a review-only baseline candidate + if: steps.gate.outcome == 'failure' + id: candidate + continue-on-error: true + run: target/release/gate --bless-shots --bless-bench + + - name: Upload the review-only baseline candidate + if: >- + steps.gate.outcome == 'failure' && + steps.candidate.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: n0-gate-baseline-candidate-${{ github.run_id }} + path: | + crates/n0/rig/baselines.json + crates/n0_dev/shots/*.png + if-no-files-found: error + retention-days: 7 + + - name: Require the unblessed gate + if: steps.gate.outcome == 'failure' + run: exit 1 + + armed: + name: armed + needs: + - scope + - lock_additions_only + - seam_architecture + - legacy_pixel_sweep + - n0_gate + if: always() + runs-on: ubuntu-24.04 + steps: + - name: Require every applicable consolidation gate + env: + ENGINE_SCOPE: ${{ needs.scope.outputs.engine }} + LOCK_RESULT: ${{ needs.lock_additions_only.result }} + SEAM_RESULT: ${{ needs.seam_architecture.result }} + PIXEL_RESULT: ${{ needs.legacy_pixel_sweep.result }} + N0_RESULT: ${{ needs.n0_gate.result }} + run: | + if [[ "$LOCK_RESULT" != success ]]; then + exit 1 + fi + if [[ "$ENGINE_SCOPE" == true ]] && \ + [[ "$SEAM_RESULT" != success || \ + "$PIXEL_RESULT" != success || \ + "$N0_RESULT" != success ]]; then + exit 1 + fi + echo "All applicable consolidation gates passed." diff --git a/.github/workflows/test-crates.yml b/.github/workflows/test-crates.yml index a8c83133..8b31bbae 100644 --- a/.github/workflows/test-crates.yml +++ b/.github/workflows/test-crates.yml @@ -10,6 +10,8 @@ on: - "Cargo.lock" - "Cargo.toml" - "crates/**" + - "fixtures/**" + - "bin/**" - "third_party/**" - "rust-toolchain.toml" diff --git a/bin/check-cargo-lock-additions-only b/bin/check-cargo-lock-additions-only new file mode 100755 index 00000000..9d1d72d2 --- /dev/null +++ b/bin/check-cargo-lock-additions-only @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 + +"""Gate workspace-crate cuts on an additions-only Cargo.lock diff. + +The consolidation extraction rule makes a new workspace member the durable +signal that a crate cut is happening. Only those changes activate this gate; +ordinary dependency-update PRs are outside its contract. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import tomllib + + +def git_text(ref: str, path: str) -> str: + result = subprocess.run( + ["git", "show", f"{ref}:{path}"], + check=True, + stdout=subprocess.PIPE, + text=True, + ) + return result.stdout + + +def workspace_members(ref: str) -> set[str]: + cargo_toml = tomllib.loads(git_text(ref, "Cargo.toml")) + members = cargo_toml.get("workspace", {}).get("members") + if not isinstance(members, list) or not all( + isinstance(item, str) for item in members + ): + raise ValueError(f"{ref}: Cargo.toml has no string workspace.members list") + return set(members) + + +def lock_diff(base: str, head: str) -> list[str]: + result = subprocess.run( + [ + "git", + "diff", + "--no-ext-diff", + "--unified=0", + base, + head, + "--", + "Cargo.lock", + ], + check=True, + stdout=subprocess.PIPE, + text=True, + ) + return result.stdout.splitlines() + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Require Cargo.lock additions-only when a workspace crate is added." + ) + parser.add_argument("base", help="base git revision") + parser.add_argument("head", nargs="?", default="HEAD", help="head git revision") + args = parser.parse_args() + + try: + added_members = sorted(workspace_members(args.head) - workspace_members(args.base)) + diff = lock_diff(args.base, args.head) + except (subprocess.CalledProcessError, tomllib.TOMLDecodeError, ValueError) as error: + print( + f"Cargo.lock additions-only gate could not inspect the revisions: {error}", + file=sys.stderr, + ) + return 2 + + if not added_members: + print("Cargo.lock additions-only: NOT APPLICABLE (no workspace crate added)") + return 0 + + removed = [line for line in diff if line.startswith("-") and not line.startswith("---")] + if removed: + print( + "Cargo.lock additions-only: FAIL\n" + f"new workspace member(s): {', '.join(added_members)}\n" + "removed lockfile lines:", + file=sys.stderr, + ) + for line in removed: + print(f" {line}", file=sys.stderr) + return 1 + + added = sum(line.startswith("+") and not line.startswith("+++") for line in diff) + print( + "Cargo.lock additions-only: PASS " + f"({', '.join(added_members)}; {added} added line(s), 0 removed)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bin/check-legacy-pixel-sweep b/bin/check-legacy-pixel-sweep new file mode 100755 index 00000000..a95cf5b2 --- /dev/null +++ b/bin/check-legacy-pixel-sweep @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 + +"""Compare a declared legacy-renderer subset byte-for-byte across revisions. + +The base and head corpus identities must first match exactly; both binaries +then render that enumerated L0.exact input set in the same host environment. +This is a zero-behavior A/B sweep, not a reftest: no external oracle or +similarity threshold participates in the verdict. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile + + +SUITE = Path("fixtures/test-html/suites/L0.exact.json") +TRANSITIVE_REFERENCE = re.compile( + rb"(?:\b(?:src|srcset|href)\s*=|\bsrc\s*:|url\s*\(|@import\b)", + re.IGNORECASE, +) + + +def run(command: list[str], *, cwd: Path, env: dict[str, str]) -> None: + subprocess.run(command, cwd=cwd, env=env, check=True) + + +def pngs(directory: Path) -> dict[Path, bytes]: + return { + path.relative_to(directory): path.read_bytes() + for path in sorted(directory.rglob("*.png")) + } + + +def digest(data: bytes) -> str: + return hashlib.sha256(data).hexdigest()[:16] + + +def checked_file(root: Path, suite_dir: Path, relative: str) -> tuple[str, str]: + path = (suite_dir / relative).resolve() + try: + logical = path.relative_to(root.resolve()).as_posix() + except ValueError as error: + raise ValueError(f"declared corpus path escapes the repository: {relative}") from error + data = path.read_bytes() + if TRANSITIVE_REFERENCE.search(data): + raise ValueError( + f"{logical} contains an unenumerated transitive reference; " + "teach the corpus manifest to close it before admitting the fixture" + ) + return logical, hashlib.sha256(data).hexdigest() + + +def corpus_identity(root: Path) -> tuple[dict[str, object], dict[str, object]]: + suite = root / SUITE + suite_bytes = suite.read_bytes() + parsed = json.loads(suite_bytes) + fixtures = parsed.get("fixtures") + defaults = parsed.get("defaults", {}) + if not isinstance(fixtures, list) or not fixtures: + raise ValueError(f"{suite}: fixtures must be a non-empty list") + default_css = defaults.get("extra_css", []) + if not isinstance(default_css, list): + raise ValueError(f"{suite}: defaults.extra_css must be a list") + + files: dict[str, str] = {} + for entry in fixtures: + if not isinstance(entry, dict) or not isinstance(entry.get("path"), str): + raise ValueError(f"{suite}: every fixture must declare a string path") + logical, file_digest = checked_file(root, suite.parent, entry["path"]) + files[logical] = file_digest + css = entry.get("extra_css", default_css) + if not isinstance(css, list) or not all(isinstance(item, str) for item in css): + raise ValueError(f"{suite}: fixture extra_css must be a string list") + for relative in css: + logical, file_digest = checked_file(root, suite.parent, relative) + files[logical] = file_digest + + identity: dict[str, object] = { + "suite_sha256": hashlib.sha256(suite_bytes).hexdigest(), + "files": dict(sorted(files.items())), + } + return identity, parsed + + +def identity_digest(identity: dict[str, object]) -> str: + encoded = json.dumps(identity, sort_keys=True, separators=(",", ":")).encode() + return digest(encoded) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="A/B the legacy renderer over the checked-in L0.exact subset." + ) + parser.add_argument("base", help="base git revision") + args = parser.parse_args() + + repo = Path( + subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + check=True, + stdout=subprocess.PIPE, + text=True, + ).stdout.strip() + ) + suite = repo / SUITE + target = repo / "target" / "consolidation-pixel-sweep" + env = os.environ.copy() + env["CARGO_TARGET_DIR"] = str(target) + + with tempfile.TemporaryDirectory(prefix="grida-consolidation-pixels-") as temp: + temp_root = Path(temp) + base_tree = temp_root / "base" + base_output = temp_root / "base-output" + head_output = temp_root / "head-output" + + subprocess.run( + ["git", "worktree", "add", "--detach", str(base_tree), args.base], + cwd=repo, + check=True, + ) + try: + base_identity, _ = corpus_identity(base_tree) + head_identity, head_suite = corpus_identity(repo) + if base_identity != head_identity: + print( + "Legacy pixel sweep: FAIL (declared corpus identity changed)", + file=sys.stderr, + ) + print( + f" base {identity_digest(base_identity)}; " + f"head {identity_digest(head_identity)}", + file=sys.stderr, + ) + return 1 + declared = len(head_suite["fixtures"]) + corpus_digest = identity_digest(head_identity) + + base_output.mkdir() + head_output.mkdir() + common = [ + "cargo", + "run", + "--locked", + "-p", + "grida_wpt", + "--", + "render", + "--suite", + str(suite), + ] + run(common + ["--out-dir", str(base_output)], cwd=base_tree, env=env) + run(common + ["--out-dir", str(head_output)], cwd=repo, env=env) + + before = pngs(base_output) + after = pngs(head_output) + if len(before) != declared or len(after) != declared: + print( + "Legacy pixel sweep: FAIL " + f"(declared {declared}, base rendered {len(before)}, head rendered {len(after)})", + file=sys.stderr, + ) + return 1 + if before.keys() != after.keys(): + print("Legacy pixel sweep: FAIL (output file sets differ)", file=sys.stderr) + print(f" base only: {sorted(before.keys() - after.keys())}", file=sys.stderr) + print(f" head only: {sorted(after.keys() - before.keys())}", file=sys.stderr) + return 1 + + changed = [path for path in before if before[path] != after[path]] + if changed: + print("Legacy pixel sweep: FAIL (byte differences)", file=sys.stderr) + for path in changed: + print( + f" {path}: base {digest(before[path])}, head {digest(after[path])}", + file=sys.stderr, + ) + return 1 + + print( + "Legacy pixel sweep: PASS " + f"({declared}/{declared} L0.exact PNGs byte-identical; " + f"corpus {corpus_digest})" + ) + print( + "Declared CI subset only; the full iosvg resvg sweep remains local-only per PR." + ) + return 0 + finally: + subprocess.run( + ["git", "worktree", "remove", str(base_tree)], + cwd=repo, + check=False, + ) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, json.JSONDecodeError, subprocess.CalledProcessError) as error: + print(f"Legacy pixel sweep command failed: {error}", file=sys.stderr) + code = error.returncode if isinstance(error, subprocess.CalledProcessError) else 2 + raise SystemExit(code) from error diff --git a/crates/n0/README.md b/crates/n0/README.md index 7c662080..3eb33bbc 100644 --- a/crates/n0/README.md +++ b/crates/n0/README.md @@ -25,9 +25,10 @@ cd crates/n0 && cargo test # the trace arm must keep compiling cargo check --features trace -# the re-host gate: replay determinism, differential/cache checks, -# benchmark budgets, and the deterministic screenshot oracle -# (needs the spike built first — it owns the golden pixels) +# the re-host gate: replay determinism, differential/cache checks, and the +# host-owned screenshot/timing baselines (the spike owns the golden pixels) +# CI owns the committed baselines; other hosts run the portable checks and +# same-host shot determinism without comparing incomparable environments. cargo build --release -p n0_dev cargo run --release --bin gate @@ -556,10 +557,15 @@ files; the panel shows the per-frame damage count. The screenshot oracle was explicitly rebaselined after accepting three historical semantic corrections: frames no longer receive invented ink, authored parent strokes paint after children, and text uses shaped metrics and -positioned-glyph replay. The shot paint context now loads the repository's -bundled Inter face, so its four goldens are deterministic across host font -configurations. Replay, differential/cache, screenshot, and benchmark gates -are green together. +positioned-glyph replay. The shot paint context loads the repository's bundled +Inter face, so host font configuration cannot perturb its four outputs. +Raster-stack and timing variance still make those outputs and budgets +host-owned: the committed owner is named in `rig/baselines.json`, and CI +requires that identity before it may compare or bless them. A non-owner local +run renders every shot twice to prove same-host determinism and skips timing +comparison; replay and the byte-level differential run everywhere. A baseline +replacement is an explicit CI-produced change with etiology, never an automatic +local update. ## Scope fence (named, not silent) diff --git a/crates/n0/rig/baselines.json b/crates/n0/rig/baselines.json index f5e0bfaa..27915000 100644 --- a/crates/n0/rig/baselines.json +++ b/crates/n0/rig/baselines.json @@ -1,14 +1,18 @@ { + "ci_run_id": "29700301719", "entries": { "flat10k": { - "build_us": 82.5, - "resolve_us": 346.709 + "build_us": 269.591, + "resolve_us": 1076.711 }, "starter": { - "build_us": 0.458, - "resolve_us": 7.25 + "build_us": 1.654, + "resolve_us": 15.639000000000001 } }, - "machine": "aarch64-macos", - "note": "min-of-11 microseconds; regenerate with `gate --bless-bench`" + "host_id": "github-actions-ubuntu-24.04-x86_64", + "note": "CI-owned min-of-11 microseconds; regenerate shots and bench together after etiology", + "rust_toolchain": "1.92.0", + "skia_safe": "0.93.1", + "source_sha": "ed0f3109431587c99d25244542c89631060d8bdf" } diff --git a/crates/n0/src/bin/gate.rs b/crates/n0/src/bin/gate.rs index 273f0866..0234b7a9 100644 --- a/crates/n0/src/bin/gate.rs +++ b/crates/n0/src/bin/gate.rs @@ -1,8 +1,9 @@ //! ENG-0.2 / S-5 · the rig. `cargo run --release --bin gate` runs the checks //! that keep every optimization honest, before the engine grows: //! -//! 1. **shots** — the re-hosted spike's `--shot` output is byte-identical to -//! the committed goldens (the pixel gate; the spike owns golden pixels). +//! 1. **shots** — on the baseline-owning CI host, the re-hosted spike's +//! `--shot` output is byte-identical to the committed goldens; other hosts +//! prove two-run determinism (the spike owns golden pixels). //! 2. **replays** — each corpus `.replay` plays twice to a bit-identical //! document and result sequence (determinism, ENG-5.2). //! 3. **diff** — the oracle law (ENG-0.2): every render optimization proves @@ -11,8 +12,9 @@ //! 4. **bench** — resolve + drawlist timings stay within the checked-in //! budgets (`rig/baselines.json`), fail past `max(1.5x, +50us)`. //! -//! `--bless-shots` / `--bless-bench` re-record the baselines. Paint TIMING is -//! deliberately not gated (GPU-noisy); paint CORRECTNESS is the shot gate. +//! `--bless-shots` + `--bless-bench` re-record the host-owned baseline set. +//! Paint TIMING is deliberately not gated (GPU-noisy); paint CORRECTNESS is +//! the shot gate. use std::path::{Path, PathBuf}; use std::process::Command; @@ -31,17 +33,102 @@ fn manifest() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) } +fn baselines_path() -> PathBuf { + manifest().join("rig/baselines.json") +} + +fn read_baselines() -> Option { + std::fs::read_to_string(baselines_path()) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) +} + +fn baseline_host_id(baselines: &serde_json::Value) -> Option<&str> { + baselines + .get("host_id") + .or_else(|| baselines.get("machine")) + .and_then(|host| host.as_str()) +} + +fn current_host_id() -> String { + std::env::var("N0_GATE_HOST_ID") + .unwrap_or_else(|_| format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS)) +} + fn main() { let args: Vec = std::env::args().collect(); let bless_shots = args.iter().any(|a| a == "--bless-shots"); let bless_bench = args.iter().any(|a| a == "--bless-bench"); + if bless_shots != bless_bench { + eprintln!( + "shot and timing baselines share one host owner; pass \ + --bless-shots and --bless-bench together" + ); + std::process::exit(2); + } + let baselines = read_baselines(); + let host_id = current_host_id(); + let require_host_baselines = std::env::var_os("N0_GATE_REQUIRE_HOST_BASELINES").is_some(); + if bless_shots { + let provenance = [ + "N0_GATE_HOST_ID", + "N0_GATE_SOURCE_SHA", + "N0_GATE_CI_RUN_ID", + "N0_GATE_RUST_TOOLCHAIN", + "N0_GATE_SKIA_SAFE", + ]; + let missing: Vec<&str> = provenance + .into_iter() + .filter(|name| std::env::var(name).map_or(true, |value| value.is_empty())) + .collect(); + if !require_host_baselines || !missing.is_empty() { + let missing = if missing.is_empty() { + "none".to_string() + } else { + missing.join(", ") + }; + eprintln!( + "baseline candidates require N0_GATE_REQUIRE_HOST_BASELINES and complete CI \ + provenance; missing: {}", + missing + ); + std::process::exit(2); + } + } println!("== n0 gate =="); + println!("host: {host_id}"); let mut ok = true; - ok &= gate_shots(bless_shots); + let (shots_ok, shot_candidate) = gate_shots( + bless_shots, + baselines.as_ref().and_then(baseline_host_id), + &host_id, + require_host_baselines, + ); + ok &= shots_ok; ok &= gate_replays(); ok &= gate_diff(); - ok &= gate_bench(bless_bench); + let (bench_ok, bench_candidate) = gate_bench( + bless_bench, + baselines.as_ref(), + &host_id, + require_host_baselines, + ); + ok &= bench_ok; + + if ok && bless_shots { + let Some(bench_candidate) = bench_candidate.as_deref() else { + eprintln!("baseline candidate is incomplete"); + std::process::exit(1); + }; + match install_baseline_set(&shot_candidate, bench_candidate) { + Ok(()) => println!("\n[baseline] complete host-owned set installed"), + Err(error) => { + eprintln!("failed to install baseline candidate: {error}"); + std::process::exit(1); + } + } + } if ok { println!("\nGATE: PASS"); @@ -53,7 +140,12 @@ fn main() { // ── 1. shots ──────────────────────────────────────────────────────────── -fn gate_shots(bless: bool) -> bool { +fn gate_shots( + bless: bool, + baseline_host: Option<&str>, + host_id: &str, + require_host_baselines: bool, +) -> (bool, Vec<(PathBuf, Vec)>) { println!("\n[shots] spike --shot vs goldens"); let spike = manifest().join("../../target/release/n0_dev"); let goldens = manifest().join("../n0_dev/shots"); @@ -62,11 +154,19 @@ fn gate_shots(bless: bool) -> bool { " MISSING spike binary: {}\n build it first: cargo build --release -p n0_dev", spike.display() ); - return false; + return (false, Vec::new()); + } + let owns_baselines = baseline_host == Some(host_id); + if !bless && !owns_baselines { + println!( + " host baseline owned by {}; checking same-host determinism only", + baseline_host.unwrap_or("MISSING") + ); } let mut all = true; + let mut candidate = Vec::new(); for state in STATES { - let tmp = std::env::temp_dir().join(format!("anchor-gate-{state}.png")); + let tmp = std::env::temp_dir().join(format!("n0-gate-{state}.png")); let status = Command::new(&spike) .arg("--shot") .arg(&tmp) @@ -74,17 +174,83 @@ fn gate_shots(bless: bool) -> bool { .status(); let golden = goldens.join(format!("{state}.png")); if bless { - if let (Ok(_), Ok(bytes)) = (status, std::fs::read(&tmp)) { - std::fs::write(&golden, bytes).expect("bless golden"); - println!(" {state:10} blessed"); + match (status, std::fs::read(&tmp)) { + (Ok(status), Ok(bytes)) if status.success() => { + candidate.push((golden, bytes)); + println!(" {state:10} captured"); + } + _ => { + eprintln!(" {state:10} FAILED to produce a baseline"); + all = false; + } } continue; } - let same = matches!(status, Ok(s) if s.success()) && files_equal(&tmp, &golden); - println!(" {state:10} {}", if same { "IDENTICAL" } else { "DIFF" }); + let same = if owns_baselines { + matches!(status, Ok(s) if s.success()) && files_equal(&tmp, &golden) + } else { + let repeat = std::env::temp_dir().join(format!("n0-gate-{state}-repeat.png")); + let repeat_status = Command::new(&spike) + .arg("--shot") + .arg(&repeat) + .arg(state) + .status(); + matches!(status, Ok(s) if s.success()) + && matches!(repeat_status, Ok(s) if s.success()) + && files_equal(&tmp, &repeat) + }; + let verdict = if owns_baselines { + if same { + "IDENTICAL" + } else { + "DIFF" + } + } else if same { + "DETERMINISTIC" + } else { + "NONDETERMINISTIC" + }; + println!(" {state:10} {verdict}"); all &= same; } - all + if !bless && baseline_host.is_none() { + eprintln!(" committed shot baseline has no host owner"); + all = false; + } else if !bless && !owns_baselines && require_host_baselines { + eprintln!(" REQUIRED host-owned shot baselines are absent"); + all = false; + } + (all, candidate) +} + +fn install_baseline_set(shots: &[(PathBuf, Vec)], bench: &str) -> std::io::Result<()> { + if shots.len() != STATES.len() { + return Err(std::io::Error::other("shot candidate is incomplete")); + } + + let stage = manifest().join(format!( + "../../target/n0-gate-baseline-candidate-{}", + std::process::id() + )); + std::fs::create_dir_all(&stage)?; + let mut staged_shots = Vec::with_capacity(shots.len()); + for (destination, bytes) in shots { + let filename = destination + .file_name() + .ok_or_else(|| std::io::Error::other("shot baseline has no filename"))?; + let staged = stage.join(filename); + std::fs::write(&staged, bytes)?; + staged_shots.push((staged, destination)); + } + let staged_bench = stage.join("baselines.json"); + std::fs::write(&staged_bench, bench)?; + + for (staged, destination) in staged_shots { + std::fs::copy(staged, destination)?; + } + std::fs::copy(staged_bench, baselines_path())?; + let _ = std::fs::remove_dir_all(stage); + Ok(()) } fn files_equal(a: &Path, b: &Path) -> bool { @@ -224,10 +390,13 @@ fn gate_diff() -> bool { // ── 4. bench ──────────────────────────────────────────────────────────── -fn gate_bench(bless: bool) -> bool { +fn gate_bench( + bless: bool, + prior: Option<&serde_json::Value>, + host_id: &str, + require_host_baselines: bool, +) -> (bool, Option) { println!("\n[bench] resolve + drawlist (min of 11, microseconds)"); - let baselines_path = manifest().join("rig/baselines.json"); - let machine = format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS); // "starter" = the corpus's normalized starter doc (no dependency on the // spike's scene builder); "flat10k" = a synthetic stress canvas. @@ -243,40 +412,43 @@ fn gate_bench(bless: bool) -> bool { ("flat10k", bench_doc(&flat, &starter_opts)), ]; - let prior = std::fs::read_to_string(&baselines_path) - .ok() - .and_then(|t| serde_json::from_str::(&t).ok()); - - let same_machine = prior - .as_ref() - .and_then(|v| v.get("machine")) - .and_then(|m| m.as_str()) - .map(|m| m == machine) - .unwrap_or(false); + let prior_host = prior.and_then(baseline_host_id); + let same_host = prior_host == Some(host_id); let mut all = true; for (name, (r_us, b_us)) in measured { print!(" {name:10} resolve {r_us:8.1} build {b_us:8.1}"); - if bless || prior.is_none() { + if bless { println!(" (recording)"); continue; } - if !same_machine { - println!(" (other machine — comparison skipped)"); + let Some(prior) = prior else { + println!(" MISSING baseline"); + all = false; + continue; + }; + if prior_host.is_none() { + println!(" MISSING baseline owner"); + all = false; + continue; + } + if !same_host { + println!( + " (baseline owned by {} — comparison skipped)", + prior_host.unwrap_or("MISSING") + ); + all &= !require_host_baselines; continue; } - let base = prior - .as_ref() - .and_then(|v| v.get("entries")) - .and_then(|e| e.get(name)); + let base = prior.get("entries").and_then(|e| e.get(name)); let br = base .and_then(|b| b.get("resolve_us")) .and_then(|v| v.as_f64()); let bb = base .and_then(|b| b.get("build_us")) .and_then(|v| v.as_f64()); - let r_ok = br.map(|base| within(r_us, base)).unwrap_or(true); - let b_ok = bb.map(|base| within(b_us, base)).unwrap_or(true); + let r_ok = br.map(|base| within(r_us, base)).unwrap_or(false); + let b_ok = bb.map(|base| within(b_us, base)).unwrap_or(false); println!( " resolve {} build {}", verdict(r_ok, r_us, br), @@ -285,23 +457,24 @@ fn gate_bench(bless: bool) -> bool { all &= r_ok && b_ok; } - if bless || prior.is_none() { + let candidate = if bless { let json = serde_json::json!({ - "machine": machine, - "note": "min-of-11 microseconds; regenerate with `gate --bless-bench`", + "host_id": host_id, + "source_sha": std::env::var("N0_GATE_SOURCE_SHA").unwrap_or_else(|_| "unrecorded".into()), + "ci_run_id": std::env::var("N0_GATE_CI_RUN_ID").unwrap_or_else(|_| "unrecorded".into()), + "rust_toolchain": std::env::var("N0_GATE_RUST_TOOLCHAIN").unwrap_or_else(|_| "unrecorded".into()), + "skia_safe": std::env::var("N0_GATE_SKIA_SAFE").unwrap_or_else(|_| "unrecorded".into()), + "note": "CI-owned min-of-11 microseconds; regenerate shots and bench together after etiology", "entries": { "starter": { "resolve_us": measured[0].1.0, "build_us": measured[0].1.1 }, "flat10k": { "resolve_us": measured[1].1.0, "build_us": measured[1].1.1 }, } }); - std::fs::write( - &baselines_path, - serde_json::to_string_pretty(&json).unwrap() + "\n", - ) - .expect("write baselines"); - println!(" baselines written -> {}", baselines_path.display()); - } - all + Some(serde_json::to_string_pretty(&json).unwrap() + "\n") + } else { + None + }; + (all, candidate) } /// Budget rule: pass under `max(1.5x baseline, baseline + 50us)` — the floor @@ -313,7 +486,7 @@ fn within(measured: f64, baseline: f64) -> bool { fn verdict(ok: bool, measured: f64, baseline: Option) -> String { match baseline { Some(b) => format!("{}({measured:.1}/{b:.1})", if ok { "OK " } else { "OVER " }), - None => "OK(new)".to_string(), + None => "MISSING".to_string(), } } diff --git a/crates/n0_dev/shots/crosszero.png b/crates/n0_dev/shots/crosszero.png index 4cc98ad7..0205cf1f 100644 Binary files a/crates/n0_dev/shots/crosszero.png and b/crates/n0_dev/shots/crosszero.png differ diff --git a/crates/n0_dev/shots/default.png b/crates/n0_dev/shots/default.png index 4c532b16..725c9a5d 100644 Binary files a/crates/n0_dev/shots/default.png and b/crates/n0_dev/shots/default.png differ diff --git a/crates/n0_dev/shots/rot45.png b/crates/n0_dev/shots/rot45.png index 87e00ce1..0d0954a1 100644 Binary files a/crates/n0_dev/shots/rot45.png and b/crates/n0_dev/shots/rot45.png differ diff --git a/crates/n0_dev/shots/ungroup.png b/crates/n0_dev/shots/ungroup.png index a77d7fa4..1b5ed40a 100644 Binary files a/crates/n0_dev/shots/ungroup.png and b/crates/n0_dev/shots/ungroup.png differ