baseline: byte-for-byte backtest reference at da61cff + compare tooling

Pin reference outputs of backtest/gpu_backtest.py (XAU/USD, window
2025-08-04..2026-08-04, 4 primary TFs x spread 0.30/0.65 = 8 runs) under
baseline/da61cff/: trade books, stdout summaries, derived equity curves
and metrics, plus a manifest with exact argv, input-parquet and output
hashes, backend, and tool versions.

scripts/capture_baseline.py captures (cache-warm only, aborts rather
than fetch live); scripts/compare_baseline.py re-runs the identical
commands and byte-diffs every output, PASS/FAIL per file. Verified
32/32 files PASS on full re-run at this commit; m5_s065 reproduces the
pinned HEAD reference exactly (555 trades, +98.64R, PF 1.483). No
signal, execution, or scanner logic touched; 359 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J3mZCQRHnJKt7qXQFG3RhB
This commit is contained in:
Harwinder Sekhon
2026-08-07 16:34:14 -04:00
parent da61cff509
commit 1c264d5315
37 changed files with 10181 additions and 0 deletions
+307
View File
@@ -0,0 +1,307 @@
"""Shared machinery for the backtest baseline capture/compare pair.
`capture_baseline.py` and `compare_baseline.py` both import everything
here so the two sides produce their outputs from literally the same code:
the run-spec table, the subprocess runner, the equity/metrics derivations,
and the cache/stderr sanity checks.
Reproducibility invariants encoded here (see baseline/<commit>/README.md):
* --start/--end always pinned — `--days` bakes wall-clock time-of-day
into the first replay bar (gpu_backtest.py resolves `end` with
datetime.now when --end is absent).
* --warmup-bars pinned at 250 — it feeds pad_days and therefore the
parquet cache FILENAME.
* Subprocess env drops CUDA_PATH (pins the NumPy CPU backend; setting
it would silently flip _simulate_trailing onto the CuPy path) and
sets PYTHONHASHSEED=0 (removes str-hash set-ordering as a variable).
* Data must come from backtest/cache/ — a cache miss would hit the
Oanda API (and the stale .env token 401s), and freshly fetched bars
are not the baseline's bars. Any "Fetching" line in stderr fails the
run.
* stdout (summary.txt) is byte-comparable; stderr (run.log) carries
%(asctime)s timestamps and elapsed times and is provenance-only.
"""
from __future__ import annotations
import hashlib
import json
import math
import os
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Dict, List, Tuple
import pandas as pd
ROOT = Path(__file__).resolve().parents[1]
VENV_PYTHON = ROOT / "venv" / "bin" / "python"
BACKTESTER_REL = "backtest/gpu_backtest.py"
CACHE_DIR = ROOT / "backtest" / "cache"
BASELINE_ROOT = ROOT / "baseline"
WINDOW_START = "2025-08-04"
WINDOW_END = "2026-08-04"
#: Files that must match byte-for-byte on compare. run.log is deliberately
#: absent (timestamped stderr, never comparable).
COMPARED_FILES = ("trades.csv", "summary.txt", "equity.csv", "metrics.json")
#: Canonical factory confirm map (pair_signals/xauusd.py) — pinned on the
#: command line so each invocation is self-contained.
CONFIRMS = {
"5min": "15min,1h",
"15min": "5min,1h",
"1h": "15min,4h",
"4h": "1h,1d",
}
#: Mirrors BaseDataProvider.BARS_PER_DAY for the TFs the baseline touches.
#: Kept local so the scripts never import repo modules (importing app_config
#: pulls the .env into os.environ). Drift is self-detecting: if the pad
#: math ever changes upstream, the expected cache file goes missing and the
#: pre-flight aborts before any network fetch.
_BARS_PER_DAY = {"5min": 288, "15min": 96, "1h": 24, "4h": 6, "1d": 1}
#: fetch_history logs an ERROR for parquets older than the raw-cache format
#: change (2026-07-08) — those contain CLEANED bars and are invalid input.
RAW_CACHE_EPOCH = 1783468800.0
# ─────────────────────────────────────────────────────────────────────
# Run specs
# ─────────────────────────────────────────────────────────────────────
def run_specs() -> Dict[str, dict]:
"""The 8-run baseline matrix: 4 primary TFs × spreads {0.30, 0.65}."""
specs: Dict[str, dict] = {}
for key, tf in (("m5", "5min"), ("m15", "15min"), ("h1", "1h"), ("h4", "4h")):
for spread in ("0.30", "0.65"):
run_key = f"{key}_s{spread.replace('.', '')}" # 0.30 → s030
specs[run_key] = {
"primary_tf": tf,
"confirm_tfs": CONFIRMS[tf],
"spread": spread,
"argv": build_argv(tf, spread),
}
return specs
def build_argv(primary_tf: str, spread: str) -> List[str]:
"""Backtester argv with every reproducibility-relevant knob pinned.
--export-csv is appended at run time (path depends on the output dir).
"""
return [
BACKTESTER_REL,
"--pair", "XAUUSD",
"--primary-tf", primary_tf,
"--confirm-tfs", CONFIRMS[primary_tf],
"--start", WINDOW_START,
"--end", WINDOW_END,
"--spread", spread,
"--quality", "A+,A,B,C",
"--warmup-bars", "250",
"--cooldown-bars", "5",
"--max-hold-bars", "300",
"--workers", "4",
"--reasoning-samples", "3",
"--detail",
]
def expected_cache_files(primary_tf: str) -> List[str]:
"""Parquet filenames a run will read (mirrors gpu_backtest pad math).
pad_days = max(60, warmup_bars // 4) = 62 at the pinned 250;
per-TF floor min_pad_days = ceil(210 / bars_per_day * 7/5) + 5.
"""
start = datetime.strptime(WINDOW_START, "%Y-%m-%d")
end = datetime.strptime(WINDOW_END, "%Y-%m-%d")
pad_days = max(60, 250 // 4)
names = []
for tf in [primary_tf] + CONFIRMS[primary_tf].split(","):
min_pad = math.ceil(210 / _BARS_PER_DAY[tf] * 7 / 5) + 5
tf_start = start - timedelta(days=max(pad_days, min_pad))
names.append(
f"oanda_XAU_USD_{tf}_{tf_start:%Y%m%d}_{end:%Y%m%d}.parquet")
return names
def all_expected_cache_files(specs: Dict[str, dict]) -> List[str]:
seen: List[str] = []
for spec in specs.values():
for name in expected_cache_files(spec["primary_tf"]):
if name not in seen:
seen.append(name)
return seen
# ─────────────────────────────────────────────────────────────────────
# Subprocess runner + stderr sanity
# ─────────────────────────────────────────────────────────────────────
def run_backtest(argv: List[str], run_dir: Path) -> None:
"""Run one backtest: stdout → summary.txt, stderr → run.log.
Raises RuntimeError (with the log path) on a nonzero exit.
"""
run_dir.mkdir(parents=True, exist_ok=True)
env = {k: v for k, v in os.environ.items() if k != "CUDA_PATH"}
env["PYTHONHASHSEED"] = "0"
cmd = ([str(VENV_PYTHON)] + argv
+ ["--export-csv", str(run_dir / "trades.csv")])
with open(run_dir / "summary.txt", "wb") as out, \
open(run_dir / "run.log", "wb") as err:
rc = subprocess.run(cmd, cwd=ROOT, env=env,
stdout=out, stderr=err).returncode
if rc != 0:
raise RuntimeError(
f"backtest exited {rc} — see {run_dir / 'run.log'}")
def check_run_log(run_dir: Path, primary_tf: str) -> Tuple[List[str], str]:
"""Sanity-check a run's stderr. Returns (problems, backend_string).
Hard requirements: every TF loaded from the parquet cache (zero
"Fetching" lines — a live fetch means the input data is not the
baseline's data) and a parseable Backend line.
"""
text = (run_dir / "run.log").read_text(errors="replace")
problems: List[str] = []
if "Fetching " in text:
problems.append("live fetch attempted (cache miss) — data is not "
"the baseline's data")
for tf in [primary_tf] + CONFIRMS[primary_tf].split(","):
if f"Loading cached XAU/USD {tf}:" not in text:
problems.append(f"no cache-hit line for TF {tf}")
if "predates the raw-cache format" in text:
problems.append("pre-2026-07-08 cache file in use (cleaned bars)")
backend = ""
for line in text.splitlines():
if "Backend: " in line:
backend = line.split("Backend: ", 1)[1].split(" | ", 1)[0].strip()
break
if not backend:
problems.append("no Backend line found in run.log")
return problems, backend
# ─────────────────────────────────────────────────────────────────────
# Derived outputs (the backtester exports no equity curve — derive it
# from the trade book instead of modifying gpu_backtest.py)
# ─────────────────────────────────────────────────────────────────────
def _resolved_trades(trades_csv: Path) -> pd.DataFrame:
"""Resolved trades in book order (matches compute_metrics' filter:
unresolved '' / NO_DATA rows are exported to CSV but never scored)."""
df = pd.read_csv(trades_csv, dtype=str, keep_default_na=False)
df["r_multiple"] = pd.to_numeric(df["r_multiple"], errors="coerce")
mask = (df["exit_reason"] != "") & (df["exit_reason"] != "NO_DATA") \
& df["r_multiple"].notna()
return df[mask].reset_index(drop=True)
def derive_equity(trades_csv: Path, out_path: Path) -> None:
"""Cumulative-R equity curve over resolved trades in book order."""
df = _resolved_trades(trades_csv)
eq = pd.DataFrame({
"trade_n": range(1, len(df) + 1),
"entry_time": df["entry_time"],
"exit_time": df["exit_time"],
"quality": df["quality"],
"strategy": df["strategy"],
"r_multiple": df["r_multiple"],
})
eq["cum_r"] = eq["r_multiple"].cumsum()
eq["high_water"] = eq["cum_r"].cummax().clip(lower=0.0)
eq["drawdown_r"] = eq["cum_r"] - eq["high_water"]
eq.to_csv(out_path, index=False, float_format="%.6f")
def derive_metrics(trades_csv: Path, out_path: Path) -> dict:
"""Headline + grouped stats as sorted, fixed-precision JSON."""
df = _resolved_trades(trades_csv)
metrics = {"overall": _stats(df["r_multiple"]),
"by_quality": {}, "by_strategy": {}}
for col, bucket in (("quality", "by_quality"), ("strategy", "by_strategy")):
for value in sorted(df[col].unique()):
metrics[bucket][value] = _stats(df.loc[df[col] == value,
"r_multiple"])
out_path.write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n")
return metrics
def _stats(r: pd.Series) -> dict:
n = len(r)
wins = int((r > 0).sum())
gross_win = float(r[r > 0].sum())
gross_loss = float(-r[r < 0].sum())
cum = r.cumsum()
dd = cum - cum.cummax().clip(lower=0.0)
return {
"trades": n,
"total_r": round(float(r.sum()), 6),
"win_rate_pct": round(wins / n * 100, 6) if n else 0.0,
"profit_factor": (round(gross_win / gross_loss, 6)
if gross_loss > 0 else None),
"max_dd_r": round(float(dd.min()), 6) if n else 0.0,
}
# ─────────────────────────────────────────────────────────────────────
# Hashing / manifest helpers
# ─────────────────────────────────────────────────────────────────────
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def config_inputs() -> dict:
"""Non-secret config.json fields that alter backtest results (the pair
factory overlays `pairs`, and `data_providers.active` keys the cache)."""
cfg_path = ROOT / "config.json"
if not cfg_path.exists():
return {"data_providers.active": None, "pairs": None}
cfg = json.loads(cfg_path.read_text())
return {
"data_providers.active": cfg.get("data_providers", {}).get("active"),
"pairs": cfg.get("pairs"),
}
def git_info() -> dict:
def _git(*args: str) -> str:
return subprocess.run(["git", *args], cwd=ROOT, capture_output=True,
text=True).stdout.strip()
return {
"commit": _git("rev-parse", "HEAD"),
"commit_short": _git("rev-parse", "--short", "HEAD"),
# -uno: untracked files (e.g. these scripts pre-commit) can't
# change the backtester; only tracked modifications count.
"dirty": bool(_git("status", "--porcelain", "-uno")),
}
def tool_versions() -> dict:
import numpy
versions = {
"python": ".".join(map(str, sys.version_info[:3])),
"numpy": numpy.__version__,
"pandas": pd.__version__,
}
try:
from importlib.metadata import version
versions["cupy"] = version("cupy")
except Exception:
versions["cupy"] = None
return versions
def utc_now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python
"""Capture the byte-for-byte backtest baseline at the current commit.
Runs the 8-run matrix (M5/M15/H1/H4 × spread 0.30/0.65, window
2025-08-04 → 2026-08-04) through backtest/gpu_backtest.py and pins every
output under baseline/<commit_short>/:
baseline/<commit>/
manifest.json commands, input hashes, output hashes, env
README.md layout + how to verify
<run_key>/
trades.csv full trade book (--export-csv)
summary.txt backtester stdout (byte-comparable)
equity.csv derived cumulative-R curve
metrics.json derived headline + grouped stats
run.log backtester stderr (provenance only)
Aborts before running anything if the tracked tree is dirty or any needed
cache parquet is missing (a cache miss would fetch live — wrong data, and
the local .env Oanda token is stale). Verify afterwards with
scripts/compare_baseline.py — it must print all-PASS at the same commit.
Usage:
venv/bin/python scripts/capture_baseline.py [--runs m5_s030,...] [--force]
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import baseline_common as bc
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--runs", default="",
help="comma-separated run keys to capture (default: all)")
ap.add_argument("--force", action="store_true",
help="overwrite existing run dirs / manifest")
args = ap.parse_args()
git = bc.git_info()
if git["dirty"]:
print("ABORT: tracked working tree is dirty — a baseline must be "
"attributable to exactly one commit. Commit or stash first.")
return 2
specs = bc.run_specs()
if args.runs:
wanted = [k.strip() for k in args.runs.split(",") if k.strip()]
unknown = [k for k in wanted if k not in specs]
if unknown:
print(f"ABORT: unknown run key(s) {unknown}; "
f"valid: {', '.join(specs)}")
return 2
specs = {k: specs[k] for k in wanted}
base_dir = bc.BASELINE_ROOT / git["commit_short"]
if not args.force:
clashes = [k for k in specs if (base_dir / k).exists()]
if clashes:
print(f"ABORT: {base_dir} already has run(s) {clashes} "
"(use --force to overwrite)")
return 2
# Pre-flight: every parquet the runs will read must already exist and
# postdate the raw-cache format change. Never trigger a network fetch.
problems = []
cache_hashes = {}
for name in bc.all_expected_cache_files(specs):
path = bc.CACHE_DIR / name
if not path.exists():
problems.append(f"missing cache parquet: {name}")
elif path.stat().st_mtime < bc.RAW_CACHE_EPOCH:
problems.append(f"pre-raw-format cache parquet (cleaned bars): "
f"{name}")
else:
cache_hashes[name] = bc.sha256_file(path)
if problems:
print("ABORT: cache pre-flight failed — refusing to run "
"(a miss would fetch live data):")
for p in problems:
print(f" - {p}")
return 2
print(f"Pre-flight OK: {len(cache_hashes)} cache parquets present "
f"and hashed. Capturing {len(specs)} run(s) at {git['commit_short']}.")
manifest_runs = {}
backend = None
for i, (key, spec) in enumerate(specs.items(), 1):
run_dir = base_dir / key
print(f"[{i}/{len(specs)}] {key}: {spec['primary_tf']} "
f"spread {spec['spread']} ...", flush=True)
bc.run_backtest(spec["argv"], run_dir)
log_problems, run_backend = bc.check_run_log(run_dir,
spec["primary_tf"])
if log_problems:
print(f"ABORT: {key} failed stderr sanity checks:")
for p in log_problems:
print(f" - {p}")
print(f" log: {run_dir / 'run.log'}")
return 2
if backend is None:
backend = run_backend
elif run_backend != backend:
print(f"ABORT: backend flipped mid-capture "
f"({backend!r} → {run_backend!r})")
return 2
bc.derive_equity(run_dir / "trades.csv", run_dir / "equity.csv")
metrics = bc.derive_metrics(run_dir / "trades.csv",
run_dir / "metrics.json")
manifest_runs[key] = {
"argv": spec["argv"],
"primary_tf": spec["primary_tf"],
"cache_files": bc.expected_cache_files(spec["primary_tf"]),
"files": {f: bc.sha256_file(run_dir / f)
for f in bc.COMPARED_FILES},
}
o = metrics["overall"]
print(f" {o['trades']} trades, {o['total_r']:+.2f}R, "
f"PF {o['profit_factor']}, maxDD {o['max_dd_r']:.2f}R")
manifest = {
"created_utc": bc.utc_now_iso(),
"commit": git["commit"],
"commit_short": git["commit_short"],
"dirty": git["dirty"],
"window": {"start": bc.WINDOW_START, "end": bc.WINDOW_END},
"backend": backend,
"env_overrides": {"CUDA_PATH": "removed", "PYTHONHASHSEED": "0"},
"versions": bc.tool_versions(),
"config_inputs": bc.config_inputs(),
"cache_files": cache_hashes,
"compared_files": list(bc.COMPARED_FILES),
"runs": manifest_runs,
}
manifest_path = base_dir / "manifest.json"
if manifest_path.exists():
# Partial re-capture with --runs: merge into the existing manifest
# (only the just-captured keys are replaced; same commit is
# guaranteed by the dir name).
old = json.loads(manifest_path.read_text())
old["runs"].update(manifest_runs)
old["cache_files"].update(cache_hashes)
old["created_utc"] = manifest["created_utc"]
manifest = old
manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True)
+ "\n")
_write_readme(base_dir)
print(f"\nBaseline captured: {base_dir}")
print("Verify now: venv/bin/python scripts/compare_baseline.py "
"(must be all-PASS at this commit)")
return 0
def _write_readme(base_dir: Path) -> None:
(base_dir / "README.md").write_text(f"""\
# Backtest baseline — commit {base_dir.name}
Reference outputs of `backtest/gpu_backtest.py` for XAU/USD, window
{bc.WINDOW_START} → {bc.WINDOW_END}, 4 primary TFs × spreads 0.30/0.65.
Captured by `scripts/capture_baseline.py`; verify with
`venv/bin/python scripts/compare_baseline.py` (re-runs every command and
byte-diffs the outputs, PASS/FAIL per file).
Per run dir: `trades.csv` (full book), `summary.txt` (backtester stdout),
`equity.csv` + `metrics.json` (derived from the book). Those four are
byte-compared. `run.log` is the backtester's stderr — timestamped, kept
for provenance only, never compared.
`manifest.json` records the exact argv per run, sha256 of every input
parquet and output file, backend, tool versions, and the non-secret
config inputs that alter results. Input data must come from
`backtest/cache/` — if those parquets change, comparisons are no longer
attributable to code changes and the compare script aborts.
""")
if __name__ == "__main__":
sys.exit(main())
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python
"""Re-run the baseline backtests and byte-diff every output against baseline/.
For each captured run, replays the exact argv recorded in the baseline's
manifest.json (same env pinning: no CUDA_PATH, PYTHONHASHSEED=0), rebuilds
the derived files, and compares trades.csv / summary.txt / equity.csv /
metrics.json byte-for-byte (sha256), printing PASS/FAIL per file.
run.log (stderr) is provenance-only and never compared.
All-PASS at the capture commit proves determinism; a FAIL after a code
change means the change altered backtest behavior — which is exactly what
this script exists to detect. Exit code 0 only if every file PASSes.
Pre-flight aborts (before any run) if an input cache parquet is missing or
its hash changed: with different input data, diffs are not attributable to
code. Live fetches are treated as failures for the same reason.
Usage:
venv/bin/python scripts/compare_baseline.py
[--baseline baseline/<commit>] [--runs m5_s030,...] [--keep]
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import baseline_common as bc
def newest_baseline() -> Path | None:
if not bc.BASELINE_ROOT.is_dir():
return None
dirs = [d for d in bc.BASELINE_ROOT.iterdir()
if (d / "manifest.json").exists()]
return max(dirs, key=lambda d: (d / "manifest.json").stat().st_mtime,
default=None)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--baseline", default=None,
help="baseline dir (default: newest under baseline/)")
ap.add_argument("--runs", default="",
help="comma-separated run keys to compare (default: all)")
ap.add_argument("--keep", action="store_true",
help="keep re-run outputs even on all-PASS")
args = ap.parse_args()
base_dir = Path(args.baseline) if args.baseline else newest_baseline()
if not base_dir or not (base_dir / "manifest.json").exists():
print("ABORT: no baseline found — run scripts/capture_baseline.py "
"first (or pass --baseline).")
return 2
manifest = json.loads((base_dir / "manifest.json").read_text())
runs = manifest["runs"]
if args.runs:
wanted = [k.strip() for k in args.runs.split(",") if k.strip()]
unknown = [k for k in wanted if k not in runs]
if unknown:
print(f"ABORT: unknown run key(s) {unknown}; "
f"baseline has: {', '.join(runs)}")
return 2
runs = {k: runs[k] for k in wanted}
git = bc.git_info()
print(f"Baseline: {base_dir} (captured at {manifest['commit_short']}"
f"{', DIRTY' if manifest.get('dirty') else ''})")
print(f"Current HEAD: {git['commit_short']}"
f"{' (dirty)' if git['dirty'] else ''}"
+ (" — same commit, expecting all-PASS"
if git["commit"] == manifest["commit"]
else " — differs from baseline; FAILs would mean changed "
"backtest behavior"))
# ── Pre-flight: identical input data, or nothing is attributable ──
needed = sorted({f for r in runs.values() for f in r["cache_files"]})
data_problems = []
for name in needed:
path = bc.CACHE_DIR / name
if not path.exists():
data_problems.append(f"missing cache parquet: {name}")
elif bc.sha256_file(path) != manifest["cache_files"].get(name):
data_problems.append(f"cache parquet CHANGED since capture: "
f"{name}")
if data_problems:
print("ABORT: input data drift — diffs would not be attributable "
"to code:")
for p in data_problems:
print(f" - {p}")
return 2
cfg_now = bc.config_inputs()
if cfg_now != manifest.get("config_inputs"):
print("WARNING: non-secret config.json inputs differ from capture "
"time (data_providers.active / pairs overlay) — these alter "
"results:")
print(f" baseline: {manifest.get('config_inputs')}")
print(f" now: {cfg_now}")
work_dir = Path(tempfile.mkdtemp(prefix="baseline_compare_"))
print(f"Re-running {len(runs)} run(s) into {work_dir}\n")
n_pass = n_fail = 0
failed_runs = []
for i, (key, spec) in enumerate(runs.items(), 1):
run_dir = work_dir / key
print(f"[{i}/{len(runs)}] {key}")
try:
bc.run_backtest(spec["argv"], run_dir)
problems, backend = bc.check_run_log(run_dir, spec["primary_tf"])
if backend and backend != manifest.get("backend"):
problems.append(f"backend {backend!r} != baseline "
f"{manifest.get('backend')!r}")
if problems:
raise RuntimeError("; ".join(problems))
bc.derive_equity(run_dir / "trades.csv", run_dir / "equity.csv")
bc.derive_metrics(run_dir / "trades.csv",
run_dir / "metrics.json")
except Exception as exc:
print(f" RUN FAILED: {exc}")
for fname in bc.COMPARED_FILES:
print(f" {fname:<13} FAIL (run failed)")
n_fail += 1
failed_runs.append(key)
continue
run_ok = True
for fname in bc.COMPARED_FILES:
new = run_dir / fname
ref = base_dir / key / fname
same = (new.exists() and ref.exists()
and bc.sha256_file(new) == spec["files"][fname])
print(f" {fname:<13} {'PASS' if same else 'FAIL'}")
if same:
n_pass += 1
else:
n_fail += 1
run_ok = False
if new.exists() and ref.exists():
print(f" baseline: {ref} ({ref.stat().st_size} B)")
print(f" re-run: {new} ({new.stat().st_size} B)")
if not run_ok:
failed_runs.append(key)
total = n_pass + n_fail
print(f"\nRESULT: {n_pass}/{total} files PASS across {len(runs)} run(s) "
f"vs baseline {manifest['commit_short']}")
if n_fail:
print(f"FAILED runs: {', '.join(dict.fromkeys(failed_runs))}")
print(f"Re-run outputs kept for inspection: {work_dir}")
return 1
if args.keep:
print(f"Re-run outputs kept (--keep): {work_dir}")
else:
shutil.rmtree(work_dir, ignore_errors=True)
return 0
if __name__ == "__main__":
sys.exit(main())