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.
- loop/__init__.py +17 -0
- loop/__main__.py +176 -0
- loop/_bundle/schemas/manifest.schema.json +41 -0
- loop/_bundle/schemas/receipt.schema.json +21 -0
- loop/_bundle/schemas/repair-record.schema.json +42 -0
- loop/_bundle/schemas/rollout-record.schema.json +27 -0
- loop/_bundle/schemas/state.schema.json +25 -0
- loop/_bundle/schemas/tasks.schema.json +30 -0
- loop/_bundle/schemas/terminal.schema.json +19 -0
- loop/_bundle/templates/AGENTS.md.tmpl +67 -0
- loop/_bundle/templates/EVALS-rubric.md.tmpl +114 -0
- loop/_bundle/templates/RUNLOG.md.tmpl +42 -0
- loop/_bundle/templates/SPEC.md.tmpl +61 -0
- loop/_bundle/templates/TASKS.json.tmpl +23 -0
- loop/_bundle/templates/WORKFLOW.md.tmpl +84 -0
- loop/_bundle/templates/extract-trace-metrics.sh +27 -0
- loop/_bundle/templates/judge-rubric.sh +28 -0
- loop/_bundle/templates/manifest.yaml.tmpl +47 -0
- loop/_bundle/templates/state.json.tmpl +38 -0
- loop/_bundle/templates/terminal_state.json.tmpl +13 -0
- loop/_bundle/templates/verify-fast.sh +27 -0
- loop/_bundle/templates/verify-full.sh +34 -0
- loop/_bundle/templates/verify-safety.sh +35 -0
- loop/_bundle/tools/anticheat_scan.py +490 -0
- loop/_bundle/tools/holdout_gate.py +121 -0
- loop/_bundle/tools/inspect_loop.py +669 -0
- loop/_bundle/tools/metrics.py +725 -0
- loop/_resources.py +43 -0
- loop/contract.py +577 -0
- loop/emit.py +258 -0
- loop/paths.py +77 -0
- loop/scaffold.py +131 -0
- loop_engineer-0.7.0.dist-info/METADATA +470 -0
- loop_engineer-0.7.0.dist-info/RECORD +37 -0
- loop_engineer-0.7.0.dist-info/WHEEL +4 -0
- loop_engineer-0.7.0.dist-info/entry_points.txt +3 -0
- 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())
|