loop-engineer 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. loop/__init__.py +17 -0
  2. loop/__main__.py +176 -0
  3. loop/_bundle/schemas/manifest.schema.json +41 -0
  4. loop/_bundle/schemas/receipt.schema.json +21 -0
  5. loop/_bundle/schemas/repair-record.schema.json +42 -0
  6. loop/_bundle/schemas/rollout-record.schema.json +27 -0
  7. loop/_bundle/schemas/state.schema.json +25 -0
  8. loop/_bundle/schemas/tasks.schema.json +30 -0
  9. loop/_bundle/schemas/terminal.schema.json +19 -0
  10. loop/_bundle/templates/AGENTS.md.tmpl +67 -0
  11. loop/_bundle/templates/EVALS-rubric.md.tmpl +114 -0
  12. loop/_bundle/templates/RUNLOG.md.tmpl +42 -0
  13. loop/_bundle/templates/SPEC.md.tmpl +61 -0
  14. loop/_bundle/templates/TASKS.json.tmpl +23 -0
  15. loop/_bundle/templates/WORKFLOW.md.tmpl +84 -0
  16. loop/_bundle/templates/extract-trace-metrics.sh +27 -0
  17. loop/_bundle/templates/judge-rubric.sh +28 -0
  18. loop/_bundle/templates/manifest.yaml.tmpl +47 -0
  19. loop/_bundle/templates/state.json.tmpl +38 -0
  20. loop/_bundle/templates/terminal_state.json.tmpl +13 -0
  21. loop/_bundle/templates/verify-fast.sh +27 -0
  22. loop/_bundle/templates/verify-full.sh +34 -0
  23. loop/_bundle/templates/verify-safety.sh +35 -0
  24. loop/_bundle/tools/anticheat_scan.py +490 -0
  25. loop/_bundle/tools/holdout_gate.py +121 -0
  26. loop/_bundle/tools/inspect_loop.py +669 -0
  27. loop/_bundle/tools/metrics.py +725 -0
  28. loop/_resources.py +43 -0
  29. loop/contract.py +577 -0
  30. loop/emit.py +258 -0
  31. loop/paths.py +77 -0
  32. loop/scaffold.py +131 -0
  33. loop_engineer-0.7.0.dist-info/METADATA +470 -0
  34. loop_engineer-0.7.0.dist-info/RECORD +37 -0
  35. loop_engineer-0.7.0.dist-info/WHEEL +4 -0
  36. loop_engineer-0.7.0.dist-info/entry_points.txt +3 -0
  37. loop_engineer-0.7.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,725 @@
1
+ """Derive false-completion-rate (FCR) and repair-productivity (RP) from a loop.
2
+
3
+ The runnable core of ST1: point it at a loop dir and it computes the two
4
+ first-class metrics (`reference/eval-suite.md` §2) from that loop's *real* on-disk
5
+ evidence — RUNLOG success claims, deterministic verify bundles, the held-out gate
6
+ verdict, the canonical repair records, and receipts — never from the agent's
7
+ narration. Every headline number ships with a `provenance` block so a skeptic can
8
+ re-derive it by hand.
9
+
10
+ Two honesty invariants distinguish this from a self-report:
11
+
12
+ * **`productive` is recomputed, never trusted.** RP is aggregated only over
13
+ repair records whose stored `productive` agrees with the value recomputed from
14
+ `verification_before`/`verification_after.score` (`recheck_productive`). A
15
+ record that disagrees, or cannot demonstrate a score delta, is *rejected* and
16
+ excluded — reported under `provenance.rejected_records`, not silently coerced.
17
+ * **FCR is derived two ways and disagreement is surfaced.** (a) the RUNLOG
18
+ success-claim × verify-bundle cross-join (the deterministic anchor, per §3),
19
+ and (b) the aggregated held-out-gate `false_completion` flag. An unmatched
20
+ success-claim counts as a false completion (fail-closed, §8). Claim
21
+ cleanness is outcome-class aware: a COMPLETION-class claim (`task_passed`,
22
+ `terminal`, `succeeded`, or the terminal state itself) is clean only if
23
+ EVERY verify bundle attached to its iteration is green — no exception of
24
+ any kind ("not backed by a green verify … is a false completion, full
25
+ stop"); a PROGRESS-class claim (`advanced`) may carry a red bundle only if
26
+ that bundle's own task reaches green in a STRICTLY LATER iteration (an
27
+ honestly-repaired intermediate). An unrelated green bundle never launders a
28
+ red gate, and a same-iteration green never excuses its own task's red —
29
+ within-iteration chronology is unknowable, so it fails closed.
30
+
31
+ The `### Outcome` token contract: a claim counts toward the FCR denominator only
32
+ when its outcome token is a recognized SUCCESS token — completion-class
33
+ (`task_passed`, `terminal`, `succeeded`) or progress-class (`advanced`);
34
+ `repair_triggered` / `task_failed` and peers are honest reds. Any other token of
35
+ two or more characters is surfaced under `provenance.unrecognized_outcomes` so a
36
+ synonym (`shipped`, `done`, `ok`, …) is visible rather than silently escaping the
37
+ denominator — it never widens the recognized-success set on its own.
38
+
39
+ A committed held-out verdict is validated structurally (it must carry the per-check
40
+ `visible`/`holdout` arrays and internally-consistent flags a real `holdout_gate`
41
+ run emits, not a hand-set 4-field stub) and its sha256 is recorded in provenance.
42
+ That committed verdict is *evidence, not proof*: a fully-fabricated,
43
+ internally-consistent artifact defeats offline shape-checking by construction, so
44
+ tamper detection of the artifact itself belongs to the anti-cheat layer
45
+ (`anticheat_scan.py`) — this command does not, and does not claim to, make the
46
+ verdict tamper-proof.
47
+
48
+ `--baseline` writes a checked-in scorecard, but only over a genuinely gate-backed
49
+ run: it refuses (non-zero, writes nothing) if no structurally-valid held-out
50
+ verdict artifact exists in the loop (plain-mode `evidence_backed` via a gate line
51
+ in a verify script is a weaker heuristic and NEVER qualifies a published
52
+ baseline), if any record was rejected, if the two FCR methods disagree, if no
53
+ iteration claims success (a vacuous 0/0 is not a publishable 0.0), or if any
54
+ counted repair record is unanchored. Baselining a self-asserted run would itself
55
+ be a false completion of ST1.
56
+
57
+ Pure stdlib, offline, deterministic: the same loop dir yields a byte-identical
58
+ scorecard.
59
+
60
+ Run::
61
+
62
+ python3 metrics.py <loop-dir>
63
+ python3 metrics.py --baseline <loop-dir>
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ import hashlib
69
+ import json
70
+ import subprocess
71
+ import sys
72
+ from pathlib import Path
73
+
74
+ _REPO_ROOT = Path(__file__).resolve().parent.parent
75
+ if str(_REPO_ROOT) not in sys.path:
76
+ sys.path.insert(0, str(_REPO_ROOT))
77
+
78
+ from loop.paths import LoopPaths, resolve_loop_paths # noqa: E402
79
+
80
+ import re # noqa: E402
81
+
82
+ METRICS_SCHEMA = "loop-engineer/metrics@1"
83
+ BASELINE_OUTPUT = "docs/metrics-baseline.json"
84
+
85
+ # A RUNLOG iteration whose outcome declares a task/loop reached "done". A
86
+ # repair_triggered / task_failed outcome is an honest red, NOT a success claim.
87
+ # Completion-class tokens assert "done" — every attached bundle must be green,
88
+ # no exceptions. Progress-class tokens assert forward motion — a red
89
+ # intermediate is excusable only by a strictly-later green of the same task.
90
+ _COMPLETION_OUTCOME_TOKENS = ("task_passed", "terminal", "succeeded")
91
+ _PROGRESS_OUTCOME_TOKENS = ("advanced",)
92
+ _SUCCESS_OUTCOME_TOKENS = _COMPLETION_OUTCOME_TOKENS + _PROGRESS_OUTCOME_TOKENS
93
+
94
+ # Recognized honest-red outcome tokens: not a success claim, but a known outcome
95
+ # (so they are not surfaced as an "unrecognized" synonym). Any outcome token in
96
+ # neither set is surfaced under provenance.unrecognized_outcomes.
97
+ _HONEST_RED_OUTCOME_TOKENS = (
98
+ "repair_triggered", "task_failed", "replan", "reverted", "revert",
99
+ "blocked", "terminated", "aborted", "failed",
100
+ )
101
+ _KNOWN_OUTCOME_TOKENS = frozenset(_SUCCESS_OUTCOME_TOKENS) | frozenset(_HONEST_RED_OUTCOME_TOKENS)
102
+
103
+ # Gate tokens (mirrors inspect_loop._GATE_TOKENS): a real held-out / anti-cheat
104
+ # gate invocation writes one of these into the execution trail.
105
+ _GATE_TOKENS = ("holdout_gate", "anticheat_scan", "anti_cheat")
106
+ # Gate script filenames (mirrors inspect_loop._GATE_SCRIPTS): the file a real
107
+ # invocation runs. evidence_backed via a verify script requires one to exist.
108
+ _GATE_SCRIPTS = ("holdout_gate.py", "anticheat_scan.py", "anti_cheat.py")
109
+
110
+ _ITER_HEADER_RE = re.compile(r"(?m)^##\s+Iteration\s+(\S+)")
111
+ # "outcome" declaration followed (within a little markup/whitespace) by its token.
112
+ _OUTCOME_RE = re.compile(r"outcome[^A-Za-z0-9]{0,40}?([A-Za-z][A-Za-z_]{1,})", re.IGNORECASE | re.DOTALL)
113
+ _VERIFY_REF_RE = re.compile(r"(verify-[\w.-]+?\.json)")
114
+
115
+
116
+ def _read_text(path: Path) -> str:
117
+ try:
118
+ return path.read_text(encoding="utf-8")
119
+ except OSError:
120
+ return ""
121
+
122
+
123
+ def _read_json_object(path: Path) -> dict:
124
+ try:
125
+ data = json.loads(path.read_text(encoding="utf-8"))
126
+ except (OSError, json.JSONDecodeError):
127
+ return {}
128
+ return data if isinstance(data, dict) else {}
129
+
130
+
131
+ def _num(value) -> float | None:
132
+ """A real number, or None. ``bool`` is not a number (True is not a score)."""
133
+ if isinstance(value, bool):
134
+ return None
135
+ if isinstance(value, (int, float)):
136
+ return float(value)
137
+ return None
138
+
139
+
140
+ def _norm_iter(value) -> str:
141
+ return str(value).strip()
142
+
143
+
144
+ # --- recheck_productive: the shared, never-trust-the-flag validator (§4.3) -----
145
+
146
+
147
+ def recheck_productive(record: dict) -> dict:
148
+ """Recompute a record's ``productive`` from its own evidence and compare.
149
+
150
+ Returns a verdict dict — ``kind`` (``repair``/``rollout``/``unknown``),
151
+ ``stored``, ``expected``, ``valid`` (stored agrees with a computable
152
+ expected), and ``reason``. Both the metrics command (RP) and
153
+ ``rollout_ledger.summarize`` consume it; neither ever sums a caller-supplied
154
+ boolean verbatim.
155
+ """
156
+
157
+ stored = record.get("productive")
158
+ stored_is_bool = isinstance(stored, bool)
159
+
160
+ has_before_after = isinstance(record.get("verification_before"), dict) and isinstance(
161
+ record.get("verification_after"), dict
162
+ )
163
+
164
+ if has_before_after:
165
+ kind = "repair"
166
+ before = _num(record["verification_before"].get("score"))
167
+ after = _num(record["verification_after"].get("score"))
168
+ if before is None or after is None:
169
+ return {
170
+ "kind": kind,
171
+ "stored": stored if stored_is_bool else None,
172
+ "expected": None,
173
+ "valid": False,
174
+ "reason": "missing numeric verification_before/after score",
175
+ }
176
+ expected = after > before
177
+ elif "score_delta" in record:
178
+ kind = "rollout"
179
+ delta = _num(record.get("score_delta"))
180
+ expected = delta is not None and delta > 0
181
+ else:
182
+ return {
183
+ "kind": "unknown",
184
+ "stored": stored if stored_is_bool else None,
185
+ "expected": None,
186
+ "valid": False,
187
+ "reason": "unrecognized record shape (no verification_before/after or score_delta)",
188
+ }
189
+
190
+ if not stored_is_bool:
191
+ return {
192
+ "kind": kind,
193
+ "stored": None,
194
+ "expected": expected,
195
+ "valid": False,
196
+ "reason": "productive is missing or not a boolean",
197
+ }
198
+ if stored != expected:
199
+ return {
200
+ "kind": kind,
201
+ "stored": stored,
202
+ "expected": expected,
203
+ "valid": False,
204
+ "reason": f"stored productive={stored} disagrees with recomputed {expected}",
205
+ }
206
+ return {"kind": kind, "stored": stored, "expected": expected, "valid": True, "reason": "ok"}
207
+
208
+
209
+ # --- RUNLOG / verify-bundle parsing (deterministic, evidence-only) -------------
210
+
211
+
212
+ def _runlog_blocks(runlog_text: str) -> list[tuple[str, str]]:
213
+ """Split RUNLOG.md into (iteration_id, block_text) pairs, in file order."""
214
+ matches = list(_ITER_HEADER_RE.finditer(runlog_text))
215
+ blocks: list[tuple[str, str]] = []
216
+ for i, m in enumerate(matches):
217
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(runlog_text)
218
+ blocks.append((_norm_iter(m.group(1)), runlog_text[m.start():end]))
219
+ return blocks
220
+
221
+
222
+ def _block_outcome_tokens(block_text: str) -> list[str]:
223
+ return [t.lower() for t in _OUTCOME_RE.findall(block_text)]
224
+
225
+
226
+ def _block_claims_success(block_text: str) -> bool:
227
+ return any(t in _SUCCESS_OUTCOME_TOKENS for t in _block_outcome_tokens(block_text))
228
+
229
+
230
+ def _load_verify_bundles(loop_dir: Path) -> list[dict]:
231
+ bundles: list[dict] = []
232
+ for path in sorted(loop_dir.rglob("verify-*.json")):
233
+ if "archive" in path.parts:
234
+ continue
235
+ data = _read_json_object(path)
236
+ if not data:
237
+ continue
238
+ outcome = str(data.get("outcome", "")).upper()
239
+ green = outcome == "PASS" or data.get("passed") is True
240
+ it = data.get("iteration_id", data.get("iteration"))
241
+ task = data.get("task")
242
+ bundles.append(
243
+ {
244
+ "path": path,
245
+ "name": path.name,
246
+ "green": green,
247
+ "iter": _norm_iter(it) if it is not None else None,
248
+ "task": str(task) if task is not None else None,
249
+ "score": _num(data.get("score")),
250
+ }
251
+ )
252
+ return bundles
253
+
254
+
255
+ def _assign_bundles_to_iters(bundles: list[dict], blocks: list[tuple[str, str]]) -> tuple[dict, list[str]]:
256
+ """Key each verify bundle to an iteration_id: its own iteration field first,
257
+ else the RUNLOG block that references its filename. Unassignable bundles are
258
+ returned separately (surfaced under provenance)."""
259
+ refs_by_iter = {iid: set(_VERIFY_REF_RE.findall(text)) for iid, text in blocks}
260
+ by_iter: dict[str, list[dict]] = {}
261
+ unmatched: list[str] = []
262
+ for b in bundles:
263
+ target = b["iter"]
264
+ if target is None:
265
+ target = next((iid for iid, refs in refs_by_iter.items() if b["name"] in refs), None)
266
+ b["assigned_iter"] = target
267
+ if target is None:
268
+ unmatched.append(b["name"])
269
+ else:
270
+ by_iter.setdefault(target, []).append(b)
271
+ return by_iter, sorted(unmatched)
272
+
273
+
274
+ def _valid_check_list(checks) -> bool:
275
+ """A non-empty list of ``{"id": ..., "passed": bool}`` per-check results."""
276
+ return (
277
+ isinstance(checks, list)
278
+ and bool(checks)
279
+ and all(isinstance(c, dict) and "id" in c and isinstance(c.get("passed"), bool) for c in checks)
280
+ )
281
+
282
+
283
+ def _is_valid_gate_verdict(data: dict) -> bool:
284
+ """Structurally validate a held-out verdict against ``holdout_gate.decide``'s
285
+ output shape. A real run carries the per-check ``visible``/``holdout`` arrays
286
+ plus flags RE-DERIVABLE from them; a hand-typed
287
+ ``{verdict, passed_visible, passed_holdout, false_completion}`` stub carries no
288
+ check evidence and is rejected — a self-asserted flag is not a gate run."""
289
+ if not isinstance(data.get("verdict"), str):
290
+ return False
291
+ for key in ("passed_visible", "passed_holdout", "false_completion"):
292
+ if not isinstance(data.get(key), bool):
293
+ return False
294
+ visible, holdout = data.get("visible"), data.get("holdout")
295
+ if not _valid_check_list(visible) or not _valid_check_list(holdout):
296
+ return False
297
+ passed_visible = all(c["passed"] for c in visible)
298
+ passed_holdout = all(c["passed"] for c in holdout)
299
+ if data["passed_visible"] != passed_visible or data["passed_holdout"] != passed_holdout:
300
+ return False
301
+ return data["false_completion"] == (passed_visible and not passed_holdout)
302
+
303
+
304
+ def _load_gate_verdicts(loop_dir: Path) -> list[dict]:
305
+ """Held-out / anti-cheat gate verdict artifacts (holdout_gate.decide output).
306
+
307
+ Only structurally-valid verdicts are returned; a fabricated 4-field stub is
308
+ not counted as gate evidence.
309
+ """
310
+ verdicts: list[dict] = []
311
+ for path in sorted(loop_dir.rglob("*.json")):
312
+ if "archive" in path.parts:
313
+ continue
314
+ data = _read_json_object(path)
315
+ if "false_completion" in data and _is_valid_gate_verdict(data):
316
+ verdicts.append({"path": path, "data": data})
317
+ return verdicts
318
+
319
+
320
+ def _sha256(path: Path) -> str:
321
+ try:
322
+ return hashlib.sha256(path.read_bytes()).hexdigest()
323
+ except OSError:
324
+ return ""
325
+
326
+
327
+ def _verify_scripts(workspace: Path) -> list[Path]:
328
+ scripts = workspace / "scripts"
329
+ if not scripts.is_dir():
330
+ return []
331
+ return sorted(p for p in scripts.glob("verify-*") if p.is_file())
332
+
333
+
334
+ def _gate_script_present(workspace: Path) -> bool:
335
+ """A gate script the loop's OWN verify surface can invoke exists. Only the
336
+ loop's workspace counts — checking this repo's toolkit would be vacuously
337
+ true for every foreign loop, since loop-engineer ships holdout_gate.py."""
338
+ base = workspace / "scripts"
339
+ return any((base / name).exists() for name in _GATE_SCRIPTS)
340
+
341
+
342
+ def _gate_invoked(paths: LoopPaths, gate_verdicts: list[dict]) -> bool:
343
+ """Is a real gate invocation detectable? Mirrors the inspector's
344
+ invocation-evidence rule (HI4), NOT looser prose matching:
345
+
346
+ (a) a recorded, structurally-valid gate VERDICT artifact, or
347
+ (b) a NON-COMMENT gate-token line in a verify-* script, where the loop's
348
+ own workspace also carries the gate script file.
349
+
350
+ Path (b) is a WEAKER heuristic than (a) — a script line proves intent, not
351
+ a run — which is why ``--baseline`` accepts only (a). A bare TASKS.json
352
+ verify *declaration* or a RUNLOG prose mention is NOT an invocation (a
353
+ ``# TODO: call holdout_gate.py`` comment earns nothing)."""
354
+ if gate_verdicts:
355
+ return True
356
+ if not _gate_script_present(paths.workspace):
357
+ return False
358
+ for script in _verify_scripts(paths.workspace):
359
+ for line in _read_text(script).splitlines():
360
+ stripped = line.strip()
361
+ if not stripped or stripped.startswith("#"):
362
+ continue
363
+ if any(tok in stripped for tok in _GATE_TOKENS):
364
+ return True
365
+ return False
366
+
367
+
368
+ def _load_receipt_costs(loop_dir: Path) -> tuple[float | None, int]:
369
+ """Sum ``cost_usd`` across receipts; None if no receipt carries a cost."""
370
+ total: float | None = None
371
+ count = 0
372
+ for path in sorted((loop_dir / "receipts").glob("*.jsonl")):
373
+ for line in _read_text(path).splitlines():
374
+ line = line.strip()
375
+ if not line:
376
+ continue
377
+ try:
378
+ rec = json.loads(line)
379
+ except json.JSONDecodeError:
380
+ continue
381
+ if not isinstance(rec, dict):
382
+ continue
383
+ count += 1
384
+ cost = _num(rec.get("cost_usd"))
385
+ if cost is not None:
386
+ total = cost if total is None else total + cost
387
+ return total, count
388
+
389
+
390
+ def _anchor_repair(record: dict, bundles: list[dict], iter_order: dict[str, int]) -> dict:
391
+ """Cross-check a repair record's self-reported before/after scores against the
392
+ deterministic verify bundles (§4.3, RP anchoring).
393
+
394
+ A record anchors only against a SAME-TASK red→green bundle pair: a non-green
395
+ bundle whose score equals ``verification_before.score`` and a green bundle of
396
+ the same task whose score equals ``verification_after.score``. When both
397
+ bundles' iterations are known, the red must precede the green — a pair whose
398
+ green came first is a regression, not a repair. Set-membership over all
399
+ bundle scores anchored nothing (a loop authors its own bundles, so matching
400
+ two free-floating numbers is free).
401
+
402
+ Returns ``status`` one of ``anchored``, ``rejected`` (scored bundles exist
403
+ but no qualifying pair corroborates the delta — a fabricated/borrowed
404
+ number), or ``unanchored`` (no scored bundle to anchor against at all).
405
+ ``recheck_productive`` runs first, so by here a valid record's before/after
406
+ scores are already numeric.
407
+ """
408
+ before = record.get("verification_before")
409
+ after = record.get("verification_after")
410
+ before = _num(before.get("score")) if isinstance(before, dict) else None
411
+ after = _num(after.get("score")) if isinstance(after, dict) else None
412
+ if before is None and after is None:
413
+ return {"status": "unanchored", "reason": "no before/after score to anchor"}
414
+ scored = [b for b in bundles if b["score"] is not None]
415
+ if not scored:
416
+ return {"status": "unanchored", "reason": "no verify bundle scores to anchor against"}
417
+ reds = [b for b in scored if not b["green"] and b["task"]]
418
+ greens = [b for b in scored if b["green"] and b["task"]]
419
+ for red in reds:
420
+ for green in greens:
421
+ if red["task"] != green["task"]:
422
+ continue
423
+ red_order = iter_order.get(red.get("assigned_iter"))
424
+ green_order = iter_order.get(green.get("assigned_iter"))
425
+ if red_order is not None and green_order is not None and not red_order < green_order:
426
+ continue
427
+ if red["score"] == before and green["score"] == after:
428
+ return {"status": "anchored", "reason": "ok"}
429
+ return {
430
+ "status": "rejected",
431
+ "reason": (
432
+ "self-reported verification_before/after scores are not corroborated by any "
433
+ "same-task red-to-green verify bundle pair"
434
+ ),
435
+ }
436
+
437
+
438
+ def _rel(path: Path, base: Path) -> str:
439
+ try:
440
+ return str(path.relative_to(base))
441
+ except ValueError:
442
+ return str(path)
443
+
444
+
445
+ # --- the scorecard -------------------------------------------------------------
446
+
447
+
448
+ def compute_metrics(loop_dir: str | Path, loop_label: str | None = None) -> dict:
449
+ paths = resolve_loop_paths(loop_dir)
450
+ workspace = paths.workspace
451
+ loop_dir_path = paths.loop_dir
452
+ label = loop_label if loop_label is not None else str(loop_dir)
453
+
454
+ runlog_text = _read_text(paths.runlog)
455
+ terminal = _read_json_object(paths.terminal)
456
+ blocks = _runlog_blocks(runlog_text)
457
+
458
+ bundles = _load_verify_bundles(loop_dir_path)
459
+ verify_by_iter, unmatched_verify = _assign_bundles_to_iters(bundles, blocks)
460
+
461
+ # Success claims: RUNLOG success-outcome iterations, plus the terminal claim.
462
+ # Completion-class claims (task_passed/terminal/succeeded, and the terminal
463
+ # state itself) assert "done"; progress-class claims (advanced) assert motion.
464
+ completion_iters: set[str] = set()
465
+ progress_iters: set[str] = set()
466
+ for iid, text in blocks:
467
+ tokens = _block_outcome_tokens(text)
468
+ if any(t in _COMPLETION_OUTCOME_TOKENS for t in tokens):
469
+ completion_iters.add(iid)
470
+ elif any(t in _PROGRESS_OUTCOME_TOKENS for t in tokens):
471
+ progress_iters.add(iid)
472
+ if terminal.get("state") == "Succeeded":
473
+ tid = _norm_iter(terminal.get("iteration_id"))
474
+ completion_iters.add(tid)
475
+ progress_iters.discard(tid)
476
+ # The terminal names the verify bundles that back its success claim.
477
+ evidence = terminal.get("evidence")
478
+ if isinstance(evidence, list):
479
+ wanted = {Path(str(e)).name for e in evidence}
480
+ for b in bundles:
481
+ if b["name"] in wanted:
482
+ verify_by_iter.setdefault(tid, [])
483
+ if b not in verify_by_iter[tid]:
484
+ verify_by_iter[tid].append(b)
485
+ claim_iters = completion_iters | progress_iters
486
+
487
+ # FCR-A cleanness is outcome-class aware. Completion-class: every attached
488
+ # bundle green, no exceptions ("not backed by a green verify … is a false
489
+ # completion, full stop"). Progress-class: a red bundle is excused only if
490
+ # its OWN task reaches green in a STRICTLY LATER iteration (the flagship's
491
+ # verify-T2-iter1 → verify-T2) — an unrelated green never launders a red
492
+ # gate, and a same-iteration green never excuses its own task's red
493
+ # (within-iteration chronology is unknowable). Unmatched claims and
494
+ # unordered iterations fail closed (§8).
495
+ iter_order = {iid: i for i, (iid, _text) in enumerate(blocks)}
496
+ green_task_orders: dict[str, set[int]] = {}
497
+ for iid, attached in verify_by_iter.items():
498
+ order = iter_order.get(iid)
499
+ if order is None:
500
+ continue
501
+ for b in attached:
502
+ if b["green"] and b["task"]:
503
+ green_task_orders.setdefault(b["task"], set()).add(order)
504
+
505
+ def _claim_is_clean(iid: str) -> bool:
506
+ attached = verify_by_iter.get(iid, [])
507
+ if not attached:
508
+ return False
509
+ if iid in completion_iters:
510
+ return all(b["green"] for b in attached)
511
+ if not any(b["green"] for b in attached):
512
+ return False
513
+ claim_order = iter_order.get(iid)
514
+ for b in attached:
515
+ if b["green"]:
516
+ continue
517
+ if claim_order is None or not b["task"]:
518
+ return False
519
+ if not any(o > claim_order for o in green_task_orders.get(b["task"], ())):
520
+ return False
521
+ return True
522
+
523
+ false_completions = sum(1 for iid in claim_iters if not _claim_is_clean(iid))
524
+ n_claims = len(claim_iters)
525
+ fcr_a = (false_completions / n_claims) if n_claims else 0.0
526
+
527
+ unrecognized_outcomes = sorted(
528
+ {t for _iid, text in blocks for t in _block_outcome_tokens(text) if t not in _KNOWN_OUTCOME_TOKENS}
529
+ )
530
+
531
+ # FCR-B: aggregated held-out-gate false_completion flag.
532
+ gate_verdicts = _load_gate_verdicts(loop_dir_path)
533
+ fc_flagged = sum(1 for v in gate_verdicts if v["data"].get("false_completion") is True)
534
+ fcr_b = (fc_flagged / len(gate_verdicts)) if gate_verdicts else None
535
+ fcr_methods_agree = None if fcr_b is None else (fcr_a == fcr_b)
536
+
537
+ evidence_backed = _gate_invoked(paths, gate_verdicts)
538
+
539
+ # RP: over recomputed-and-agreed repair records only, whose before/after
540
+ # scores are anchored to a same-task red→green verify-bundle pair (§4.3). A
541
+ # record whose stored productive lies (recheck) OR whose scores no pair
542
+ # corroborates (anchor) is rejected; a record with no scored bundle to
543
+ # anchor against is counted but flagged unanchored (a baseline refuses).
544
+ validated = 0
545
+ productive = 0
546
+ rejected: list[dict] = []
547
+ unanchored: list[str] = []
548
+ rp_source: list[str] = []
549
+ for path in sorted((loop_dir_path / "repair").glob("*.json")):
550
+ record = _read_json_object(path)
551
+ rel = _rel(path, workspace)
552
+ verdict = recheck_productive(record)
553
+ if not verdict["valid"]:
554
+ rejected.append({"record": rel, "reason": verdict["reason"]})
555
+ continue
556
+ anchor = _anchor_repair(record, bundles, iter_order)
557
+ if anchor["status"] == "rejected":
558
+ rejected.append({"record": rel, "reason": anchor["reason"]})
559
+ continue
560
+ if anchor["status"] == "unanchored":
561
+ unanchored.append(rel)
562
+ validated += 1
563
+ rp_source.append(rel)
564
+ if verdict["expected"]:
565
+ productive += 1
566
+ repair_productivity = (productive / validated) if validated else None
567
+
568
+ # Cost-per-success (layer 7): total receipt cost over true completions.
569
+ total_cost, _receipt_count = _load_receipt_costs(loop_dir_path)
570
+ successes = n_claims - false_completions
571
+ cost_per_success = (total_cost / successes) if (total_cost is not None and successes > 0) else None
572
+
573
+ fcr_source = sorted({_rel(paths.runlog, workspace)} | {_rel(b["path"], workspace) for b in bundles})
574
+ holdout_source = sorted(_rel(v["path"], workspace) for v in gate_verdicts)
575
+ holdout_verdicts = sorted(
576
+ ({"source": _rel(v["path"], workspace), "sha256": _sha256(v["path"])} for v in gate_verdicts),
577
+ key=lambda e: e["source"],
578
+ )
579
+ rejected.sort(key=lambda r: r["record"])
580
+
581
+ return {
582
+ "schema": METRICS_SCHEMA,
583
+ "loop": label,
584
+ "false_completion_rate": fcr_a,
585
+ "repair_productivity": repair_productivity,
586
+ "iterations_claiming_success": n_claims,
587
+ "false_completions": false_completions,
588
+ "repair_passes": validated,
589
+ "productive_repairs": productive,
590
+ "cost_per_success_usd": cost_per_success,
591
+ "evidence_backed": evidence_backed,
592
+ "provenance": {
593
+ "fcr_source": fcr_source,
594
+ "rp_source": sorted(rp_source),
595
+ "rejected_records": rejected,
596
+ "unanchored_records": sorted(unanchored),
597
+ "unrecognized_outcomes": unrecognized_outcomes,
598
+ "false_completion_rate_holdout": fcr_b,
599
+ "fcr_methods_agree": fcr_methods_agree,
600
+ "holdout_source": holdout_source,
601
+ "holdout_verdicts": holdout_verdicts,
602
+ "unmatched_verify": unmatched_verify,
603
+ },
604
+ }
605
+
606
+
607
+ def _input_files(loop_dir: str | Path) -> list[str]:
608
+ """The exact committed files the scorecard is derived from, repo-relative."""
609
+ paths = resolve_loop_paths(loop_dir)
610
+ loop_dir_path = paths.loop_dir
611
+ candidates: list[Path] = [paths.runlog, paths.terminal, paths.tasks]
612
+ candidates += sorted(loop_dir_path.rglob("verify-*.json"))
613
+ candidates += [v["path"] for v in _load_gate_verdicts(loop_dir_path)]
614
+ candidates += sorted((loop_dir_path / "repair").glob("*.json"))
615
+ candidates += sorted((loop_dir_path / "receipts").glob("*.jsonl"))
616
+ seen: list[str] = []
617
+ for p in candidates:
618
+ if "archive" in p.parts or not p.exists():
619
+ continue
620
+ rel = _rel(p, _REPO_ROOT)
621
+ if rel not in seen:
622
+ seen.append(rel)
623
+ return sorted(seen)
624
+
625
+
626
+ def _git_commit() -> str | None:
627
+ try:
628
+ out = subprocess.run(
629
+ ["git", "rev-parse", "HEAD"], cwd=_REPO_ROOT, capture_output=True, text=True
630
+ )
631
+ except OSError:
632
+ return None
633
+ return out.stdout.strip() or None if out.returncode == 0 else None
634
+
635
+
636
+ def build_baseline(loop_dir: str | Path, loop_label: str | None = None) -> tuple[bool, dict, list[str]]:
637
+ """Compute the scorecard and check the §4.5 baseline preconditions.
638
+
639
+ Returns ``(ok, scorecard, refusal_reasons)``. ``ok`` is False when the loop
640
+ carries no structurally-valid gate verdict artifact (the strict form of
641
+ evidence-backing — a verify-script gate line never qualifies a baseline),
642
+ contains a rejected record, has disagreeing FCR methods, claims no success
643
+ (vacuous 0/0), or counts an unanchored repair record. Each refusal names the
644
+ precondition that failed.
645
+ """
646
+ scorecard = compute_metrics(loop_dir, loop_label)
647
+ prov = scorecard["provenance"]
648
+ reasons: list[str] = []
649
+ if not prov["holdout_verdicts"]:
650
+ reasons.append(
651
+ "no structurally-valid held-out verdict artifact in the loop — a published "
652
+ "baseline requires a recorded gate VERDICT; a gate line in a verify-* script "
653
+ "(plain-mode evidence_backed) is a weaker heuristic and never qualifies"
654
+ )
655
+ rejected = prov["rejected_records"]
656
+ if rejected:
657
+ reasons.append(
658
+ f"{len(rejected)} rejected record(s) present (productive disagrees with its own "
659
+ "evidence, or before/after scores are not corroborated by any verify bundle)"
660
+ )
661
+ if prov["fcr_methods_agree"] is False:
662
+ reasons.append(
663
+ "fcr_methods_agree is False — the deterministic cross-join "
664
+ f"(FCR {scorecard['false_completion_rate']}) and the held-out-gate flag "
665
+ f"(FCR {prov['false_completion_rate_holdout']}) disagree; a baseline may not "
666
+ "launder an inconsistent run into a clean number"
667
+ )
668
+ if scorecard["iterations_claiming_success"] == 0:
669
+ reasons.append(
670
+ "iterations_claiming_success == 0 — a vacuous 0/0 run yields no publishable "
671
+ "false-completion-rate (an FCR 0.0 over no success claims is not a baseline)"
672
+ )
673
+ if prov["unanchored_records"]:
674
+ reasons.append(
675
+ f"{len(prov['unanchored_records'])} counted repair record(s) are unanchored — no "
676
+ "verify bundle score corroborates their before/after; a baseline must anchor RP "
677
+ "to deterministic evidence"
678
+ )
679
+ return (not reasons), scorecard, reasons
680
+
681
+
682
+ def write_baseline(loop_dir: str | Path, out_path: Path, loop_label: str | None = None) -> int:
683
+ ok, scorecard, reasons = build_baseline(loop_dir, loop_label)
684
+ if not ok:
685
+ print(
686
+ "metrics --baseline refused (writes nothing):\n - " + "\n - ".join(reasons),
687
+ file=sys.stderr,
688
+ )
689
+ return 1
690
+ baseline = dict(scorecard)
691
+ baseline["baseline"] = {
692
+ "source_example": scorecard["loop"],
693
+ "commit": _git_commit(),
694
+ "inputs": _input_files(loop_dir),
695
+ }
696
+ out_path.parent.mkdir(parents=True, exist_ok=True)
697
+ out_path.write_text(json.dumps(baseline, indent=2) + "\n", encoding="utf-8")
698
+ print(f"wrote baseline -> {_rel(out_path, _REPO_ROOT)}")
699
+ return 0
700
+
701
+
702
+ def run(argv: list[str]) -> int:
703
+ baseline_mode = False
704
+ positional: list[str] = []
705
+ for arg in argv:
706
+ if arg == "--baseline":
707
+ baseline_mode = True
708
+ else:
709
+ positional.append(arg)
710
+ if not positional:
711
+ print("usage: metrics.py [--baseline] <loop-dir>", file=sys.stderr)
712
+ return 2
713
+ loop_dir = positional[0]
714
+ if baseline_mode:
715
+ return write_baseline(loop_dir, _REPO_ROOT / BASELINE_OUTPUT, loop_label=loop_dir)
716
+ print(json.dumps(compute_metrics(loop_dir, loop_label=loop_dir), indent=2))
717
+ return 0
718
+
719
+
720
+ def main(argv: list[str] | None = None) -> int:
721
+ return run(list(sys.argv[1:] if argv is None else argv))
722
+
723
+
724
+ if __name__ == "__main__":
725
+ sys.exit(main())