#!/usr/bin/env python3 """CASAN No-progress / Oscillation Detector (Plan-17 Track 2, harness-owned). Watches a run's per-step observations and decides whether the loop is CONVERGING, STALLED (no forward progress), or OSCILLATING (repeating/thrashing actions). A stalled or oscillating loop is halted or escalated (on_stall policy) instead of being allowed to burn budget going nowhere. Detection rules (thresholds from loop-policy.yaml, else strictest built-in): * oscillation = the last N observations share one action_hash (stuck repeating), * thrash = the last `thrash_window` steps alternate between exactly 2 action_hashes (A,B,A,B...), * no-progress = the last W observations made zero forward progress (progress never exceeded the running best). Deny-by-default & fail-closed: absent/unknown policy => strictest thresholds (detect sooner); a corrupt policy => ESCALATE/HALT, never silent CONVERGING. Insufficient data (< window) => CONVERGING (the Budget Governor is the hard stop). Usage: loop-convergence.py observe --run-id R --step N --action-hash H --progress P loop-convergence.py verdict --run-id R [--profile prod|dev] Exit codes: 0 CONVERGING (or observe recorded ok) 3 HALT (STALLED/OSCILLATING with on_stall=halt, OR fail-closed error) 4 ESCALATE (STALLED/OSCILLATING with on_stall=escalate) 2 usage error """ import argparse import json import os import sys import loop_common as lc def _emit(payload, stream=sys.stdout): json.dump(payload, stream, ensure_ascii=False, indent=2, sort_keys=True) stream.write("\n") def _obs_path(run_id): return os.path.join(lc.run_dir(run_id), "convergence.jsonl") def _load_obs(run_id): path = _obs_path(run_id) rows = [] if os.path.isfile(path): with open(path, encoding="utf-8") as fh: for line in fh: line = line.strip() if not line: continue try: rows.append(json.loads(line)) except ValueError: # A corrupt observation stream cannot be trusted. raise lc.PolicyError("observation_stream_corrupt") rows.sort(key=lambda r: r.get("step", 0)) return rows def cmd_observe(args): path = _obs_path(args.run_id) os.makedirs(os.path.dirname(path), exist_ok=True) rec = { "step": args.step, "action_hash": args.action_hash, "progress": args.progress, "ts": lc.now_iso(), } with open(path, "a", encoding="utf-8") as fh: fh.write(json.dumps(rec, ensure_ascii=False, sort_keys=True) + "\n") _emit({"decision": "OBSERVED", "run_id": args.run_id, "step": args.step}) return 0 def _detect_oscillation(hashes, cfg): n = cfg["oscillation_repeat"] if len(hashes) >= n and len(set(hashes[-n:])) == 1: return {"pattern": "repeat", "action_hash": hashes[-1], "count": n} w = cfg["thrash_window"] if len(hashes) >= w: window = hashes[-w:] distinct = set(window) if len(distinct) == 2: # A,B,A,B... : every element differs from its immediate neighbour. if all(window[i] != window[i + 1] for i in range(len(window) - 1)): return {"pattern": "thrash", "actions": sorted(distinct), "window": w} return None def _detect_no_progress(progresses, cfg): w = cfg["no_progress_window"] if len(progresses) < w: return None # Mark each step that improved on the running best; STALLED if the last W # steps contain no improvement at all. best = None improved = [] for p in progresses: if p is None: improved.append(False) continue if best is None or p > best: improved.append(True) best = p else: improved.append(False) if not any(improved[-w:]): return {"window": w, "last_progress": progresses[-1]} return None def cmd_verdict(args): prof = args.profile or lc.profile() try: policy = lc.load_policy() cfg, source = lc.resolve_convergence(policy, prof) rows = _load_obs(args.run_id) except lc.PolicyError as exc: payload = { "decision": "HALT", "reason": "fail_closed", "detail": str(exc), "run_id": args.run_id, "provenance": lc.provenance("loop-convergence", None, verified=False), } lc.append_audit({"kind": "convergence_halt", **payload}) _emit(payload) return 3 hashes = [r.get("action_hash") for r in rows] progresses = [r.get("progress") for r in rows] osc = _detect_oscillation(hashes, cfg) stall = None if osc else _detect_no_progress(progresses, cfg) if osc or stall: verdict = "OSCILLATING" if osc else "STALLED" on_stall = cfg.get("on_stall", "escalate") decision = "HALT" if on_stall == "halt" else "ESCALATE" payload = { "decision": decision, "verdict": verdict, "run_id": args.run_id, "profile": prof, "policy_source": source, "thresholds": cfg, "observations": len(rows), "evidence": osc or stall, "on_stall": on_stall, "provenance": lc.provenance("loop-convergence", None, verified=policy is not None), } lc.append_audit({"kind": "convergence_" + verdict.lower(), **payload}) _emit(payload) return 3 if decision == "HALT" else 4 payload = { "decision": "CONVERGING", "verdict": "CONVERGING", "run_id": args.run_id, "profile": prof, "policy_source": source, "thresholds": cfg, "observations": len(rows), "provenance": lc.provenance("loop-convergence", None, verified=policy is not None), } _emit(payload) return 0 def build_parser(): p = argparse.ArgumentParser(description="CASAN Convergence Detector (Plan-17 T2)") sub = p.add_subparsers(dest="cmd", required=True) o = sub.add_parser("observe", help="Record a per-step observation") o.add_argument("--run-id", required=True) o.add_argument("--step", type=int, required=True) o.add_argument("--action-hash", required=True) o.add_argument("--progress", type=float, required=True) o.set_defaults(func=cmd_observe) v = sub.add_parser("verdict", help="Classify the run: CONVERGING|STALLED|OSCILLATING") v.add_argument("--run-id", required=True) v.add_argument("--profile", default=None) v.set_defaults(func=cmd_verdict) return p def main(argv=None): args = build_parser().parse_args(argv) try: return args.func(args) except lc.PolicyError as exc: _emit({"decision": "HALT", "reason": "fail_closed", "detail": str(exc)}) return 3 except Exception as exc: # never fail open _emit({"decision": "HALT", "reason": "internal_error", "detail": str(exc)}, sys.stderr) return 3 if __name__ == "__main__": raise SystemExit(main())