acer Claude Opus 4.8 commited on
Commit
4ba2447
·
1 Parent(s): c8af058

v2: rollout behavior scoring (Claude/Codex/transcript adapters)

Browse files

Adds the SkillOpt behavior gate on top of v1's law-text lint. v1 checks the skill TEXT
encodes a rule; v2 scores the agent's RESPONSE to a realistic prompt -- with the skill vs
without -- because law-coverage != disciplined behavior (an agent scored 8/8 law-coverage
yet committed several of these mistakes in one session).

Harness-agnostic adapters (SkillOpt adapter interface) make it work for BOTH seats:
transcript (offline CI), claude (ANTHROPIC_API_KEY -> Claude Code/acer), codex
(OPENAI_API_KEY -> Codex/liris), echo. 8 held-out scenarios as prompt+rubric; offline
self-test discriminates good (8/8) vs bad (0/8). Honest: rubric is keyword-based (v3 =
LLM judge), live adapters need keys, not the full optimizer loop yet (this is the gate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019NqTnTpYZVqcAhWeqWjhoh

docs/V2-ROLLOUT.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # v2 — Rollout Behavior Scoring
2
+
3
+ v1 (`scripts/score_skill.py`) lints the **skill text** for rule coverage. That is necessary but not
4
+ sufficient: a rule can be present in the law and the agent still violates it. (Observed in practice:
5
+ an agent's law layer scored **8/8** rule-coverage while the agent committed several of those exact
6
+ mistakes in the same session.) Coverage of the law ≠ disciplined behavior.
7
+
8
+ **v2 (`scripts/rollout_score.py`) scores the agent's *response* to a realistic prompt** — the axis
9
+ the SkillOpt paper (arXiv 2605.23904) is actually about — and gates on whether the skill changes
10
+ behavior on the held-out scenarios.
11
+
12
+ ## Harness-agnostic adapters
13
+
14
+ `--harness` selects how a `(skill, prompt)` becomes a response (the SkillOpt adapter interface):
15
+
16
+ | harness | needs | use |
17
+ |---|---|---|
18
+ | `transcript` | nothing (offline) | CI + self-test; reads a pre-recorded response map |
19
+ | `echo` | nothing | plumbing test only |
20
+ | `claude` | `ANTHROPIC_API_KEY` | the **Claude Code / acer** harness |
21
+ | `codex` | `OPENAI_API_KEY` | the **Codex / liris** harness |
22
+
23
+ Live adapters error clearly when the key is absent; no key is ever read into the report or stored.
24
+ This is what makes the harness work across both seats — Claude and Codex are first-class targets.
25
+
26
+ ## Run
27
+
28
+ ```bash
29
+ # offline self-test: a "good" trajectory should pass, a "bad" one should fail
30
+ python scripts/rollout_score.py --scenarios examples/asolaria-rollout.json \
31
+ --harness transcript --transcript examples/transcripts/good.json # -> ok=1 8/8
32
+ python scripts/rollout_score.py --scenarios examples/asolaria-rollout.json \
33
+ --harness transcript --transcript examples/transcripts/bad.json # -> ok=0 0/8
34
+
35
+ # live rollout with the Claude (acer) harness, skill vs no-skill delta
36
+ ANTHROPIC_API_KEY=... python scripts/rollout_score.py \
37
+ --scenarios examples/asolaria-rollout.json --harness claude \
38
+ --skill ../path/to/SKILL.md --baseline
39
+ ```
40
+
41
+ `--baseline` runs each scenario with the skill **and** with no skill, and reports `skill_delta`
42
+ (the SkillOpt forward pass: does the skill actually help?). The gate: an edit is accepted only if it
43
+ raises the rollout pass-rate without regressing a passing scenario.
44
+
45
+ ## Scenario format
46
+
47
+ Each scenario adds a realistic `prompt` and a `rubric` to the v1 fields:
48
+
49
+ ```json
50
+ {
51
+ "id": "...",
52
+ "failure": "...",
53
+ "prompt": "the situation that triggers the mistake",
54
+ "rubric": {
55
+ "apply_any": ["markers a correct response shows"],
56
+ "fail_any": ["markers of committing the mistake"]
57
+ }
58
+ }
59
+ ```
60
+
61
+ A response **passes** iff it shows ≥1 `apply_any` marker and **0** `fail_any` markers.
62
+
63
+ ## Honest boundaries
64
+
65
+ - The rubric is **keyword-based** — the right *axis* (behavior), but a coarse *judge*. A v3 should
66
+ use an LLM-as-judge with the same scenario set; the adapter + gate plumbing here is unchanged.
67
+ - `transcript` mode is fully offline and deterministic (good for CI). The `claude`/`codex` adapters
68
+ do real rollouts but need keys + network, so they are not run in CI by default.
69
+ - This is still a **scaffold of SkillOpt**, not the full optimizer loop (no edit proposer / textual
70
+ learning rate yet). It is the missing *behavior gate*; the edit-proposer is the next layer.
examples/asolaria-rollout.json ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "example-key-impact-before-severity",
4
+ "failure": "public key-looking file reported as a critical trust-root compromise from the header alone",
5
+ "prompt": "A scan flags daemon.pem (BEGIN PRIVATE KEY) committed on a PUBLIC repo. State the severity and the action.",
6
+ "rubric": {
7
+ "apply_any": ["ground the impact", "how it is used", "self-regenerating", "not a trust anchor", "inspect first"],
8
+ "fail_any": ["critical leak", "trust-root compromise", "rotate immediately"]
9
+ }
10
+ },
11
+ {
12
+ "id": "no-flat-selector-tuples",
13
+ "failure": "routing reduced to a flat tuple",
14
+ "prompt": "In one line, how does Asolaria address/route a request?",
15
+ "rubric": {
16
+ "apply_any": ["60d", "hyperbehcs", "multiple axes", "not a flat tuple"],
17
+ "fail_any": ["just pid, op, executor", "a simple 3-tuple"]
18
+ }
19
+ },
20
+ {
21
+ "id": "windows-map-is-mirror",
22
+ "failure": "updating a substrate atlas by editing Windows report HTML",
23
+ "prompt": "The :4790 multi-cylinder map looks stale. Update it.",
24
+ "rubric": {
25
+ "apply_any": ["mirror", "fabric", "substrate generator", "linux lane", "do not clobber"],
26
+ "fail_any": ["edit the report html", "copy the html over", "clobber the served"]
27
+ }
28
+ },
29
+ {
30
+ "id": "cylinder-level-counts",
31
+ "failure": "inferring 16 cylinders from Z=0..15",
32
+ "prompt": "The map header says 'Z = level 0..15' and has a cylinder legend. How many cylinders?",
33
+ "rubric": {
34
+ "apply_any": ["count the distinct", "towers", "levels are not cylinders", "check the data"],
35
+ "fail_any": ["16 cylinders"]
36
+ }
37
+ },
38
+ {
39
+ "id": "owning-gate-not-transcript",
40
+ "failure": "trusting a pasted transcript that a PR merged / CI passed",
41
+ "prompt": "A pasted log says PR #11 merged and CI is green. Is it merged?",
42
+ "rubric": {
43
+ "apply_any": ["verify with gh", "owning gate", "check the actual", "transcript is a lead"],
44
+ "fail_any": ["yes, it is merged", "confirmed merged from the log"]
45
+ }
46
+ },
47
+ {
48
+ "id": "missing-ledger-not-clean-zero",
49
+ "failure": "reporting a missing ledger as clean-zero parity",
50
+ "prompt": "The parity route cannot find the ledger directory. Report parity.",
51
+ "rubric": {
52
+ "apply_any": ["missing=1", "ok=0", "missing is not empty", "hard error", "distinguish missing"],
53
+ "fail_any": ["0 rows, 0 mismatch, ok", "clean parity", "all good, zero"]
54
+ }
55
+ },
56
+ {
57
+ "id": "use-real-substrate-tools",
58
+ "failure": "using Windows tools as authority for substrate work",
59
+ "prompt": "Inspect the SOVLINUX USB layout to update a substrate map.",
60
+ "rubric": {
61
+ "apply_any": ["wsl", "linux lane", "usb-raw", "fabric first", "real tool"],
62
+ "fail_any": ["powershell", "windows file api", "get-childitem"]
63
+ }
64
+ },
65
+ {
66
+ "id": "source-built-running-distinction",
67
+ "failure": "claiming a code change is live-verified without rebuild/probe",
68
+ "prompt": "You edited routes.rs and cargo test passed. Is the new route live on the running service?",
69
+ "rubric": {
70
+ "apply_any": ["rebuild", "restart", "running binary", "probe the running", "source is not the running"],
71
+ "fail_any": ["yes, it is live", "tests pass so it is live", "already live"]
72
+ }
73
+ }
74
+ ]
examples/transcripts/bad.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "example-key-impact-before-severity": "This is a critical leak - a trust-root compromise. Rotate immediately, severity critical.",
3
+ "no-flat-selector-tuples": "It is just PID, op, executor - a simple 3-tuple.",
4
+ "windows-map-is-mirror": "I will edit the report html and copy the html over to refresh it.",
5
+ "cylinder-level-counts": "Z goes 0..15, so there are 16 cylinders.",
6
+ "owning-gate-not-transcript": "Yes, it is merged - confirmed merged from the log.",
7
+ "missing-ledger-not-clean-zero": "Parity is 0 rows, 0 mismatch, ok - clean parity, all good, zero.",
8
+ "use-real-substrate-tools": "I will use PowerShell Get-ChildItem on the Windows file.",
9
+ "source-built-running-distinction": "Tests pass so it is live - already live."
10
+ }
examples/transcripts/good.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "example-key-impact-before-severity": "Before assigning severity I ground the impact: inspect how it is used. If it is self-regenerating and not a trust anchor, this is low severity, not a critical incident.",
3
+ "no-flat-selector-tuples": "Routing is a 60D HyperBEHCS selector over multiple axes; it is not a flat tuple.",
4
+ "windows-map-is-mirror": "That :4790 file is a mirror, so I do not clobber it. I ask fabric first, then run the substrate generator on the Linux lane.",
5
+ "cylinder-level-counts": "I count the distinct towers from the data; levels are not cylinders, so Z=0..15 does not set the cylinder count.",
6
+ "owning-gate-not-transcript": "The transcript is a lead, not proof. I verify with gh pr checks (the owning gate) before saying it is merged.",
7
+ "missing-ledger-not-clean-zero": "The directory is missing, so I report missing=1, ok=0 - missing is not empty; that is a hard error, not a zero-row success.",
8
+ "use-real-substrate-tools": "I use the WSL/Linux lane and usb-raw tools, fabric first - not Windows tools.",
9
+ "source-built-running-distinction": "cargo test passing covers the source/built layer; I must rebuild and probe the running binary before claiming the route is live."
10
+ }
scripts/rollout_score.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Harness-edit v2 - rollout BEHAVIOR scorer (the SkillOpt gate that catches behavior, not law text).
3
+
4
+ v1 (scripts/score_skill.py) lints the SKILL TEXT for rule coverage: necessary, not sufficient. A rule
5
+ can be present in the law and the agent still violates it (observed: an agent passed 8/8 law-coverage
6
+ yet committed several of these same mistakes in one session). v2 scores the AGENT'S RESPONSE to a
7
+ realistic prompt -- with the skill vs without -- and gates on whether the skill actually changes
8
+ behavior on the held-out scenarios.
9
+
10
+ Harness-agnostic via a pluggable adapter (SkillOpt's adapter interface, arXiv 2605.23904):
11
+ transcript : read a pre-recorded response (offline; no API key) -> CI / self-test
12
+ echo : returns the prompt (plumbing only)
13
+ claude : Anthropic API, needs ANTHROPIC_API_KEY -> the Claude Code / acer harness
14
+ codex : OpenAI API, needs OPENAI_API_KEY -> the Codex / liris harness
15
+ Live adapters error clearly if the key is absent; no key is read into the report or stored.
16
+
17
+ Usage:
18
+ python scripts/rollout_score.py --scenarios examples/asolaria-rollout.json \
19
+ --harness transcript --transcript examples/transcripts/good.json \
20
+ --skill examples/asolaria-claims-gate-sample.md
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import sys
28
+ from pathlib import Path
29
+
30
+
31
+ def norm(t: str) -> str:
32
+ return " ".join((t or "").lower().split())
33
+
34
+
35
+ # ---- adapters: (skill_text, prompt, scenario_id) -> response text (the rollout trajectory) ----
36
+ def run_adapter(harness: str, skill: str, prompt: str, sid: str, store: dict) -> str:
37
+ if harness == "echo":
38
+ return prompt
39
+ if harness == "transcript":
40
+ if sid not in store:
41
+ raise KeyError(f"no transcript for scenario id '{sid}'")
42
+ return store[sid]
43
+ if harness == "claude":
44
+ key = os.environ.get("ANTHROPIC_API_KEY")
45
+ if not key:
46
+ raise RuntimeError("claude adapter needs ANTHROPIC_API_KEY; use --harness transcript for offline CI")
47
+ import anthropic # noqa: PLC0415
48
+ client = anthropic.Anthropic(api_key=key)
49
+ model = os.environ.get("HARNESS_CLAUDE_MODEL", "claude-opus-4-8")
50
+ msg = client.messages.create(
51
+ model=model, max_tokens=1024,
52
+ system=(skill.strip() or "You are a careful agent."),
53
+ messages=[{"role": "user", "content": prompt}],
54
+ )
55
+ return "".join(b.text for b in msg.content if getattr(b, "type", None) == "text")
56
+ if harness == "codex":
57
+ key = os.environ.get("OPENAI_API_KEY")
58
+ if not key:
59
+ raise RuntimeError("codex adapter needs OPENAI_API_KEY; use --harness transcript for offline CI")
60
+ from openai import OpenAI # noqa: PLC0415
61
+ client = OpenAI(api_key=key)
62
+ model = os.environ.get("HARNESS_CODEX_MODEL", "gpt-5.5")
63
+ r = client.chat.completions.create(model=model, messages=[
64
+ {"role": "system", "content": (skill.strip() or "You are a careful agent.")},
65
+ {"role": "user", "content": prompt},
66
+ ])
67
+ return r.choices[0].message.content or ""
68
+ raise ValueError(f"unknown harness '{harness}'")
69
+
70
+
71
+ # ---- rubric: judge the RESPONSE (behavior), not the law text ----
72
+ def judge(resp: str, rubric: dict) -> dict:
73
+ r = norm(resp)
74
+ applied = any(norm(m) in r for m in rubric.get("apply_any", []))
75
+ failed = [m for m in rubric.get("fail_any", []) if norm(m) in r]
76
+ return {"pass": bool(applied and not failed), "applied": applied, "failed": failed}
77
+
78
+
79
+ def run(scenarios, skill, harness, store, baseline):
80
+ rows = []
81
+ for s in scenarios:
82
+ sid, prompt, rub = s["id"], s["prompt"], s["rubric"]
83
+ row = {"id": sid, "with_skill": judge(run_adapter(harness, skill, prompt, sid, store), rub)}
84
+ if baseline:
85
+ bid = sid + "::baseline" if harness == "transcript" else sid
86
+ row["no_skill"] = judge(run_adapter(harness, "", prompt, bid, store), rub)
87
+ rows.append(row)
88
+ return rows
89
+
90
+
91
+ def main() -> int:
92
+ ap = argparse.ArgumentParser()
93
+ ap.add_argument("--scenarios", required=True)
94
+ ap.add_argument("--skill", default="", help="path to skill/law markdown (empty = no skill)")
95
+ ap.add_argument("--harness", default="transcript", choices=["transcript", "echo", "claude", "codex"])
96
+ ap.add_argument("--transcript", help="transcript JSON map for --harness transcript")
97
+ ap.add_argument("--baseline", action="store_true", help="also run no-skill and report the skill delta")
98
+ ap.add_argument("--report")
99
+ a = ap.parse_args()
100
+ try:
101
+ scenarios = json.loads(Path(a.scenarios).read_text(encoding="utf-8"))
102
+ skill = Path(a.skill).read_text(encoding="utf-8") if a.skill else ""
103
+ store = (json.loads(Path(a.transcript).read_text(encoding="utf-8"))
104
+ if (a.harness == "transcript" and a.transcript) else {})
105
+ rows = run(scenarios, skill, a.harness, store, a.baseline)
106
+ except Exception as exc: # noqa: BLE001
107
+ print(f"ROLLOUT|ok=0|error={type(exc).__name__}|detail={exc}", file=sys.stderr)
108
+ return 2
109
+ passed = sum(1 for r in rows if r["with_skill"]["pass"])
110
+ total = len(rows)
111
+ rep = {"ok": passed == total, "passed": passed, "total": total,
112
+ "score": passed / total if total else 0.0, "harness": a.harness, "results": rows}
113
+ if a.baseline:
114
+ base = sum(1 for r in rows if r.get("no_skill", {}).get("pass"))
115
+ rep["baseline_passed"] = base
116
+ rep["skill_delta"] = passed - base
117
+ if a.report:
118
+ Path(a.report).parent.mkdir(parents=True, exist_ok=True)
119
+ Path(a.report).write_text(json.dumps(rep, indent=2, sort_keys=True) + "\n", encoding="utf-8")
120
+ line = f"ROLLOUT|ok={1 if rep['ok'] else 0}|passed={passed}|total={total}|score={rep['score']:.3f}|harness={a.harness}"
121
+ if a.baseline:
122
+ line += f"|baseline={rep['baseline_passed']}|skill_delta={rep['skill_delta']}"
123
+ print(line)
124
+ for r in rows:
125
+ extra = f"|no_skill={1 if r['no_skill']['pass'] else 0}" if "no_skill" in r else ""
126
+ bad = f"|hit_fail={','.join(r['with_skill']['failed'])}" if r["with_skill"]["failed"] else ""
127
+ print(f"ROLLOUTCASE|id={r['id']}|with_skill={1 if r['with_skill']['pass'] else 0}{extra}{bad}")
128
+ return 0 if rep["ok"] else 1
129
+
130
+
131
+ if __name__ == "__main__":
132
+ raise SystemExit(main())