#!/usr/bin/env python3 """PGAR runtime gate (oracle-free). Standalone: standard library only, so that it runs with any Python ≥ 3.9. Usage: python pgar_gate.py [--python ] [--no-repro] [--json] Reads /gate_spec.json and checks the OUTPUT CONTRACT and REPRODUCIBILITY of the deliverables under /out. It contains no reference answers and never judges scientific correctness. Writes a receipt to /out/state/gate_receipts/.json and exits 0 if passed, 1 otherwise. """ import csv, datetime, json, math, os, re, shutil, subprocess, sys, tempfile, hashlib GATE_VERSION = "pgar_gate/1.0" NUMERIC_RE = r"^#+\s*" def _utc(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") def _load_json(path): try: with open(path) as fh: return json.load(fh), None except FileNotFoundError: return None, "missing" except Exception as e: # noqa: BLE001 return None, f"invalid JSON: {e}" def _is_num(v): return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v) def _type_ok(v, t, nullable): if v is None: return bool(nullable) return {"number": _is_num(v), "integer": isinstance(v, int) and not isinstance(v, bool), "string": isinstance(v, str), "boolean": isinstance(v, bool), "array": isinstance(v, list), "object": isinstance(v, dict)}.get(t, True) def _parse_date(s): try: return datetime.date.fromisoformat(str(s)[:10]) except Exception: # noqa: BLE001 return None def _compare_results(a, b, rel): """Return list of keys whose values differ beyond rel tolerance (numbers) or are unequal (others).""" diffs = [] keys = set(a) | set(b) for k in keys: va, vb = a.get(k), b.get(k) if _is_num(va) and _is_num(vb): if abs(va - vb) > rel * max(1.0, abs(va), abs(vb)): diffs.append(k) elif isinstance(va, (list, dict)) or isinstance(vb, (list, dict)): if json.dumps(va, sort_keys=True) != json.dumps(vb, sort_keys=True): # allow numeric tolerance inside nested structures try: fa = [float(x) for x in re.findall(r"-?\d+\.\d+(?:e-?\d+)?", json.dumps(va))] fb = [float(x) for x in re.findall(r"-?\d+\.\d+(?:e-?\d+)?", json.dumps(vb))] if len(fa) != len(fb) or any(abs(x - y) > rel * max(1.0, abs(x), abs(y)) for x, y in zip(fa, fb)): diffs.append(k) elif re.sub(r"-?\d+\.\d+(?:e-?\d+)?", "#", json.dumps(va, sort_keys=True)) != re.sub(r"-?\d+\.\d+(?:e-?\d+)?", "#", json.dumps(vb, sort_keys=True)): diffs.append(k) except Exception: # noqa: BLE001 diffs.append(k) elif va != vb: diffs.append(k) return sorted(diffs) def run_gate(ws, python=None, do_repro=True): ws = os.path.abspath(ws) checks = [] def add(cid, ok, detail=""): checks.append({"id": cid, "passed": bool(ok), "detail": detail}) spec, err = _load_json(os.path.join(ws, "gate_spec.json")) if spec is None: add("gate_spec", False, f"gate_spec.json {err}") return _finish(ws, checks) # 1. required files for rel in spec.get("required_files", []): p = os.path.join(ws, rel) ok = os.path.isfile(p) and os.path.getsize(p) > 0 add(f"file:{rel}", ok, "present" if ok else "missing or empty") # 2. results.json keys/types res, err = _load_json(os.path.join(ws, "out", "results.json")) if not isinstance(res, dict): add("results_json", False, f"out/results.json {err or 'is not an object'}") res = {} else: add("results_json", True, "parsed") for k, rule in spec.get("results_keys", {}).items(): if k not in res: add(f"key:{k}", False, "missing") else: ok = _type_ok(res[k], rule.get("type"), rule.get("nullable", False)) add(f"key:{k}", ok, "ok" if ok else f"type/nullability mismatch (expected {rule.get('type')}, nullable={rule.get('nullable', False)}, got {type(res[k]).__name__}={res[k]!r:.60})") if spec.get("task_id") and res.get("task_id") not in (None, spec["task_id"]): add("task_id_matches", False, f"results.task_id={res.get('task_id')!r} != {spec['task_id']!r}") # 3. report sections rep = "" try: rep = open(os.path.join(ws, "out", "report.md"), encoding="utf-8", errors="replace").read() except Exception: # noqa: BLE001 pass for s in spec.get("report_sections", []): ok = re.search(NUMERIC_RE + re.escape(s) + r"\s*$", rep, re.M | re.I) is not None add(f"section:{s}", ok, "present" if ok else "heading not found") fam = set(spec.get("family_checks", [])) # 4. claims claims, cerr = _load_json(os.path.join(ws, "out", "claims.json")) if "claims_reference_existing_keys" in fam or os.path.exists(os.path.join(ws, "out", "claims.json")): if not isinstance(claims, list) or not claims: add("claims_json", False, f"out/claims.json {cerr or 'must be a non-empty list'}") else: bad = [] for i, c in enumerate(claims): if not isinstance(c, dict) or not all(k in c for k in ("id", "text", "results_keys", "status")): bad.append(f"#{i}: missing fields"); continue if c["status"] not in ("supported", "unsupported", "not_evaluated"): bad.append(f"{c.get('id')}: bad status {c['status']!r}") missing = [k for k in (c.get("results_keys") or []) if k not in res] if missing: bad.append(f"{c.get('id')}: results_keys not in results.json: {missing}") add("claims_json", not bad, "; ".join(bad) if bad else f"{len(claims)} claims, all keys resolve") # 5. phase ledger pl_path = os.path.join(ws, "out", "state", "phases.json") if "phase_ledger_consistent" in fam and os.path.exists(pl_path): pl, perr = _load_json(pl_path) if not isinstance(pl, dict): add("phase_ledger", False, f"phases.json {perr or 'not an object'}") else: bad = [] for pid, st in pl.items(): if not isinstance(st, dict) or st.get("status") not in ("pending", "running", "passed", "failed", "blocked", "stale", "skipped_optional"): bad.append(f"{pid}: invalid status"); continue if st.get("status") == "passed" and not st.get("checks"): bad.append(f"{pid}: passed without recorded checks") not_done = [pid for pid, st in pl.items() if isinstance(st, dict) and st.get("status") not in ("passed", "skipped_optional", "blocked")] if not_done: bad.append(f"phases not terminal: {not_done}") add("phase_ledger", not bad, "; ".join(bad) if bad else "consistent") # 6. exclusion ledger ex_path = os.path.join(ws, "out", "exclusions.csv") if "exclusion_ledger_sums" in fam and os.path.exists(ex_path): try: rows = list(csv.DictReader(open(ex_path, newline=""))) bad = [] prev_after = None for i, r in enumerate(rows): nb, na, nr = int(float(r["n_before"])), int(float(r["n_after"])), int(float(r["n_removed"])) if nb - nr != na: bad.append(f"row {i}: n_before - n_removed != n_after") if prev_after is not None and nb != prev_after: bad.append(f"row {i}: n_before != previous n_after") prev_after = na add("exclusion_ledger", not bad, "; ".join(bad) if bad else f"{len(rows)} steps consistent") except Exception as e: # noqa: BLE001 add("exclusion_ledger", False, f"unreadable: {e}") # 7. numeric sanity checks if "ci_contains_estimate" in fam: for est, lo, hi in spec.get("ci_pairs", []): e, l, h = res.get(est), res.get(lo), res.get(hi) if all(_is_num(v) for v in (e, l, h)): add(f"ci:{est}", l <= e <= h, f"{l} <= {e} <= {h}") elif e is None and l is None and h is None: add(f"ci:{est}", True, "all null (allowed if reported infeasible)") else: add(f"ci:{est}", False, "non-numeric CI triple") if "positive_se" in fam: for k in spec.get("positive_keys", []): v = res.get(k) add(f"positive:{k}", (v is None) or (_is_num(v) and v > 0), f"{v}") if "chronology" in fam: for a, b in spec.get("chronology_pairs", []): da, db = _parse_date(res.get(a)), _parse_date(res.get(b)) add(f"chronology:{a}<={b}", da is not None and db is not None and da <= db, f"{res.get(a)} vs {res.get(b)}") if "forecasts_csv_chronology" in fam: fp = os.path.join(ws, "out", "forecasts.csv") try: rows = list(csv.DictReader(open(fp, newline=""))) bad = sum(1 for r in rows if not (_parse_date(r.get("origin_date")) and _parse_date(r.get("target_date")) and _parse_date(r["origin_date"]) < _parse_date(r["target_date"]))) add("forecasts_chronology", rows and bad == 0, f"{bad} rows with target_date <= origin_date out of {len(rows)}") except Exception as e: # noqa: BLE001 add("forecasts_chronology", False, f"forecasts.csv unreadable: {e}") # 8. reproducibility if do_repro and os.path.isfile(os.path.join(ws, "out", "analysis.py")) and isinstance(res, dict) and res: py = python or sys.executable tmp = tempfile.mkdtemp(prefix="pgar_repro_") try: shutil.copytree(os.path.join(ws, "data"), os.path.join(tmp, "data")) shutil.copytree(os.path.join(ws, "out"), os.path.join(tmp, "out"), ignore=shutil.ignore_patterns("results.json", "state", "review")) for extra in ("TASK.md", "gate_spec.json"): if os.path.exists(os.path.join(ws, extra)): shutil.copy(os.path.join(ws, extra), tmp) t0 = datetime.datetime.now() proc = subprocess.run([py, "out/analysis.py"], cwd=tmp, capture_output=True, text=True, timeout=int(spec.get("analysis_timeout_s", 300))) dur = (datetime.datetime.now() - t0).total_seconds() new, nerr = _load_json(os.path.join(tmp, "out", "results.json")) if proc.returncode != 0: add("reproducibility", False, f"analysis.py exit {proc.returncode} after {dur:.1f}s; stderr tail: {proc.stderr[-600:]}") elif not isinstance(new, dict): add("reproducibility", False, f"analysis.py did not regenerate out/results.json ({nerr})") else: diffs = _compare_results(res, new, float(spec.get("reproducibility_tolerance_rel", 1e-6))) add("reproducibility", not diffs, f"regenerated in {dur:.1f}s; " + ("identical within tolerance" if not diffs else f"differences in keys: {diffs}")) except subprocess.TimeoutExpired: add("reproducibility", False, "analysis.py timed out") except Exception as e: # noqa: BLE001 add("reproducibility", False, f"repro error: {e}") finally: shutil.rmtree(tmp, ignore_errors=True) elif do_repro: add("reproducibility", False, "out/analysis.py or out/results.json missing") return _finish(ws, checks) def _finish(ws, checks): passed = all(c["passed"] for c in checks) and bool(checks) receipt = {"gate_version": GATE_VERSION, "workspace": ws, "utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), "passed": passed, "n_checks": len(checks), "n_failed": sum(1 for c in checks if not c["passed"]), "checks": checks} try: d = os.path.join(ws, "out", "state", "gate_receipts"); os.makedirs(d, exist_ok=True) path = os.path.join(d, _utc() + ".json") with open(path, "w") as fh: json.dump(receipt, fh, indent=1) receipt["receipt_path"] = path receipt["receipt_sha256"] = hashlib.sha256(open(path, "rb").read()).hexdigest() except Exception as e: # noqa: BLE001 receipt["receipt_path"] = None; receipt["receipt_error"] = str(e) return receipt def main(argv): if len(argv) < 2: print(__doc__); return 2 ws = argv[1]; py = None; repro = True; as_json = False i = 2 while i < len(argv): if argv[i] == "--python": py = argv[i + 1]; i += 2 elif argv[i] == "--no-repro": repro = False; i += 1 elif argv[i] == "--json": as_json = True; i += 1 else: i += 1 r = run_gate(ws, py, repro) if as_json: print(json.dumps(r, indent=1)) else: print(f"PGAR GATE {r['gate_version']} — {'PASSED' if r['passed'] else 'FAILED'} ({r['n_failed']} of {r['n_checks']} checks failed)") for c in r["checks"]: print(f" [{'ok' if c['passed'] else 'FAIL'}] {c['id']}: {c['detail']}") print(f"receipt: {r.get('receipt_path')}") return 0 if r["passed"] else 1 if __name__ == "__main__": sys.exit(main(sys.argv))