v2: rollout behavior scoring (Claude/Codex/transcript adapters)
Browse filesAdds 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 +70 -0
- examples/asolaria-rollout.json +74 -0
- examples/transcripts/bad.json +10 -0
- examples/transcripts/good.json +10 -0
- scripts/rollout_score.py +132 -0
|
@@ -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.
|
|
@@ -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 |
+
]
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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())
|