#!/usr/bin/env bash # Verify the sol-lane triage classifier end to end. # # Q119: this script used to regenerate the committed `actual.csv` in place and # diff it against `expected.csv`, so every run dirtied the tree it was grading # and the "known-good" file was produced by the same rules it was checking. # It now writes only into a throwaway directory and grades the JSON contract # plus the committed fallback cells. Exit codes follow the repo contract: # 0 clean, 1 a graded fault, 2 could not grade. set -u cd "$(dirname "$(readlink -f "$0")")" || exit 2 if ! python3 --version >/dev/null 2>&1; then echo "verify: python3 not available -- cannot grade" >&2 exit 2 fi SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/sol-classify.XXXXXX")" || { echo "verify: cannot create scratch dir -- cannot grade" >&2 exit 2 } cleanup() { rm -rf "$SCRATCH"; } trap cleanup EXIT BEFORE_GIT="$(git status --porcelain -uno 2>/dev/null)" IN_WORKTREE=0 git rev-parse --is-inside-work-tree >/dev/null 2>&1 && IN_WORKTREE=1 # Checksums of every artifact this directory ships, so the cleanliness leg is # not vacuous outside a git worktree (a copy in /tmp is still graded). snapshot() { md5sum classify.py demands.json expected.csv actual.csv actual.json \ Triage-2026-10-29.json seed-2026-10-29.json 2>/dev/null } BEFORE_MD5="$(snapshot)" # 1) the classifier runs on the committed seed and writes outside the tree python3 classify.py --seed seed-2026-10-29.json --output "$SCRATCH/out.json" rc=$? if [ "$rc" -ne 0 ]; then echo "FAIL: classification run exited $rc" exit 1 fi if [ ! -s "$SCRATCH/out.json" ]; then echo "FAIL: classifier wrote no artifact" exit 1 fi # 2) JSON contract: every item is labelled, the aggregate sums to the item count python3 - "$SCRATCH/out.json" <<'PY' import json import sys art = json.load(open(sys.argv[1], encoding="utf-8")) cats = art["categories"] items = art["items"] real = {"archive_fix", "app_pr", "general"} allowed = real | {"unclassified"} problems = [] if not items: problems.append("artifact has no items") if sum(cats.values()) != len(items): problems.append("categories sum %d != %d items" % (sum(cats.values()), len(items))) missing = allowed - set(cats) if missing: problems.append("categories missing %s" % sorted(missing)) for it in items: if it["category"] not in allowed: problems.append("item %r has category %r" % (it.get("id"), it["category"])) if it["decision"] not in {"approve", "table", "reject"}: problems.append("item %r has decision %r" % (it.get("id"), it["decision"])) ids = [it.get("id") for it in items] if len(set(ids)) != len(ids): problems.append("duplicate item ids") if problems: raise SystemExit("FAIL: " + "; ".join(problems)) print("contract ok: %d items, categories=%s" % (len(items), json.dumps(cats, sort_keys=True))) PY if [ $? -ne 0 ]; then echo "FAIL: JSON contract" exit 1 fi # 3) the committed cells (fallback contract, Q119) python3 test_classify_fallback.py || { echo "FAIL: fallback cells" exit 1 } # 4) the run must not have touched any artifact it grades AFTER_MD5="$(snapshot)" if [ "$BEFORE_MD5" != "$AFTER_MD5" ]; then echo "FAIL: verify.sh changed the tree it grades" >&2 printf 'before:\n%s\nafter:\n%s\n' "$BEFORE_MD5" "$AFTER_MD5" >&2 exit 1 fi if [ "$IN_WORKTREE" -eq 1 ]; then AFTER_GIT="$(git status --porcelain -uno 2>/dev/null)" if [ "$BEFORE_GIT" != "$AFTER_GIT" ]; then echo "FAIL: verify.sh changed the worktree it grades" >&2 printf '%s\n' "$AFTER_GIT" >&2 exit 1 fi fi echo "CHECK OK"