obey-cli 0.1.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.
- obey_cli/__init__.py +3 -0
- obey_cli/__main__.py +3 -0
- obey_cli/_data/demo/README.md +12 -0
- obey_cli/_data/demo/obey.yaml +287 -0
- obey_cli/_data/demo/repo/.gitignore +8 -0
- obey_cli/_data/demo/repo/AGENTS.md +38 -0
- obey_cli/_data/demo/repo/README.md +36 -0
- obey_cli/_data/demo/repo/eslint.config.js +24 -0
- obey_cli/_data/demo/repo/index.html +21 -0
- obey_cli/_data/demo/repo/openapi.yaml +86 -0
- obey_cli/_data/demo/repo/package.json +26 -0
- obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
- obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
- obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
- obey_cli/_data/demo/repo/src/api/client.ts +71 -0
- obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
- obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
- obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
- obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
- obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
- obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
- obey_cli/_data/demo/repo/src/logger.ts +36 -0
- obey_cli/_data/demo/repo/src/main.ts +30 -0
- obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
- obey_cli/_data/demo/repo/src/render-table.ts +48 -0
- obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
- obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
- obey_cli/_data/demo/repo/tsconfig.json +20 -0
- obey_cli/_data/demo/repo/vitest.config.ts +8 -0
- obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/meta.json +10 -0
- obey_cli/_data/schemas/obey-config-v1.json +116 -0
- obey_cli/_data/schemas/report-v1.json +55 -0
- obey_cli/_data/schemas/survey-record-v1.json +30 -0
- obey_cli/checkers/__init__.py +30 -0
- obey_cli/checkers/samples.py +95 -0
- obey_cli/cli.py +201 -0
- obey_cli/commands.py +676 -0
- obey_cli/config.py +90 -0
- obey_cli/discover.py +232 -0
- obey_cli/enforce/__init__.py +114 -0
- obey_cli/enforce/engine/__init__.py +0 -0
- obey_cli/enforce/engine/obey_engine.py +2005 -0
- obey_cli/enforce/merge.py +196 -0
- obey_cli/enforce/targets/__init__.py +354 -0
- obey_cli/enforce/templates/obey_hook.py +452 -0
- obey_cli/enforce/templates/pre_commit.py +116 -0
- obey_cli/extract.py +1342 -0
- obey_cli/lint.py +125 -0
- obey_cli/llm.py +129 -0
- obey_cli/parse_md.py +260 -0
- obey_cli/probes/__init__.py +0 -0
- obey_cli/probes/facts.py +162 -0
- obey_cli/probes/templates.py +326 -0
- obey_cli/py.typed +0 -0
- obey_cli/report/__init__.py +314 -0
- obey_cli/resources.py +35 -0
- obey_cli/rules.py +75 -0
- obey_cli/runner/__init__.py +0 -0
- obey_cli/runner/agents/__init__.py +22 -0
- obey_cli/runner/agents/base.py +96 -0
- obey_cli/runner/agents/claude_code.py +203 -0
- obey_cli/runner/agents/codex.py +152 -0
- obey_cli/runner/agents/copilot.py +142 -0
- obey_cli/runner/agents/gemini.py +125 -0
- obey_cli/runner/agents/opencode.py +156 -0
- obey_cli/runner/core.py +140 -0
- obey_cli/runner/egress_proxy.py +166 -0
- obey_cli/runner/gateway.py +529 -0
- obey_cli/runner/ledger.py +132 -0
- obey_cli/runner/local.py +176 -0
- obey_cli/runner/orchestrate.py +227 -0
- obey_cli/runner/sandbox/Dockerfile +41 -0
- obey_cli/runner/sandbox/entrypoint.sh +38 -0
- obey_cli/runner/sandbox.py +341 -0
- obey_cli/runner/sidecar.py +38 -0
- obey_cli/runner/workspace.py +187 -0
- obey_cli/score.py +150 -0
- obey_cli/study/__init__.py +0 -0
- obey_cli/study/analysis.py +277 -0
- obey_cli/study/design.py +98 -0
- obey_cli/study/experiment.py +198 -0
- obey_cli/study/report.py +519 -0
- obey_cli/study/survey.py +438 -0
- obey_cli-0.1.0.dist-info/METADATA +393 -0
- obey_cli-0.1.0.dist-info/RECORD +90 -0
- obey_cli-0.1.0.dist-info/WHEEL +4 -0
- obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
- obey_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
obey_cli/study/report.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"""Write study/REPORT.md and the charts from the committed per-run summaries (no model calls).
|
|
2
|
+
|
|
3
|
+
uv run obey study report
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from obey_cli.score import wilson
|
|
16
|
+
|
|
17
|
+
CONDITIONS = ("baseline", "imperative", "prohibition", "reason")
|
|
18
|
+
LABELS = {"baseline": "no rule", "imperative": "imperative", "prohibition": "prohibition", "reason": "with a reason"}
|
|
19
|
+
HARNESS = {"claude-code": "Claude Code", "opencode": "OpenCode"}
|
|
20
|
+
# For agents whose stream does not report a version: the version pinned in the sandbox image the study used.
|
|
21
|
+
IMAGE_VERSIONS = {"opencode": "1.19.4"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _rows(path: Path) -> list[dict[str, Any]]:
|
|
25
|
+
if not path.exists():
|
|
26
|
+
return []
|
|
27
|
+
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _pct(x: float | None) -> str:
|
|
31
|
+
return "–" if x is None else f"{x * 100:.0f}%"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _ci(k: int, n: int) -> str:
|
|
35
|
+
if not n:
|
|
36
|
+
return "–"
|
|
37
|
+
lo, hi = wilson(k, n)
|
|
38
|
+
return f"{k / n * 100:.0f}% [{lo * 100:.0f}–{hi * 100:.0f}]"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _rate(rows: list[dict[str, Any]]) -> tuple[int, int]:
|
|
42
|
+
ex = [r for r in rows if r["status"] == "ok" and r["verdict"] != "not_exercised"]
|
|
43
|
+
return sum(r["verdict"] == "obeyed" for r in ex), len(ex)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _by(rows: list[dict[str, Any]], *keys: str) -> dict[tuple[Any, ...], list[dict[str, Any]]]:
|
|
47
|
+
out: dict[tuple[Any, ...], list[dict[str, Any]]] = defaultdict(list)
|
|
48
|
+
for r in rows:
|
|
49
|
+
out[tuple(r[k] for k in keys)].append(r)
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
THEMES: dict[str, dict[str, Any]] = {
|
|
54
|
+
"light": {
|
|
55
|
+
"bg": "#ffffff",
|
|
56
|
+
"fg": "#1f2328",
|
|
57
|
+
"muted": "#59636e",
|
|
58
|
+
"grid": "#d1d9e0",
|
|
59
|
+
"series": ["#8c959f", "#0969da", "#bf3989", "#1a7f37"],
|
|
60
|
+
"after": "#1a7f37",
|
|
61
|
+
"before": "#8c959f",
|
|
62
|
+
"evasion": "#cf222e",
|
|
63
|
+
},
|
|
64
|
+
"dark": {
|
|
65
|
+
"bg": "#0d1117",
|
|
66
|
+
"fg": "#e6edf3",
|
|
67
|
+
"muted": "#9198a1",
|
|
68
|
+
"grid": "#3d444d",
|
|
69
|
+
"series": ["#6e7681", "#4493f8", "#db61a2", "#3fb950"],
|
|
70
|
+
"after": "#3fb950",
|
|
71
|
+
"before": "#6e7681",
|
|
72
|
+
"evasion": "#f85149",
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _chart_conditions(rows: list[dict[str, Any]], out: Path) -> list[str]:
|
|
78
|
+
import matplotlib
|
|
79
|
+
|
|
80
|
+
matplotlib.use("Agg")
|
|
81
|
+
import matplotlib.pyplot as plt
|
|
82
|
+
|
|
83
|
+
written = []
|
|
84
|
+
harnesses = sorted({r["harness"] for r in rows})
|
|
85
|
+
for theme, c in THEMES.items():
|
|
86
|
+
fig, ax = plt.subplots(figsize=(8, 4.2), dpi=100)
|
|
87
|
+
fig.patch.set_facecolor(c["bg"])
|
|
88
|
+
ax.set_facecolor(c["bg"])
|
|
89
|
+
width = 0.8 / len(CONDITIONS)
|
|
90
|
+
for i, cond in enumerate(CONDITIONS):
|
|
91
|
+
xs, ys, lo, hi = [], [], [], []
|
|
92
|
+
for j, h in enumerate(harnesses):
|
|
93
|
+
k, n = _rate([r for r in rows if r["harness"] == h and r["condition"] == cond])
|
|
94
|
+
if not n:
|
|
95
|
+
continue
|
|
96
|
+
low, high = wilson(k, n)
|
|
97
|
+
xs.append(j + (i - 1.5) * width)
|
|
98
|
+
ys.append(k / n * 100)
|
|
99
|
+
lo.append(max(0.0, k / n * 100 - low * 100)) # rounding can put a Wilson bound past the estimate
|
|
100
|
+
hi.append(max(0.0, high * 100 - k / n * 100))
|
|
101
|
+
ax.bar(xs, ys, width * 0.92, color=c["series"][i], label=LABELS[cond], zorder=2)
|
|
102
|
+
ax.errorbar(xs, ys, yerr=[lo, hi], fmt="none", ecolor=c["fg"], elinewidth=1, capsize=2, zorder=3)
|
|
103
|
+
ax.set_xticks(range(len(harnesses)), [HARNESS.get(h, h) for h in harnesses], color=c["fg"])
|
|
104
|
+
ax.set_ylim(0, 105)
|
|
105
|
+
ax.set_ylabel("probe compliance, % of exercised runs", color=c["muted"])
|
|
106
|
+
ax.tick_params(colors=c["muted"])
|
|
107
|
+
ax.grid(axis="y", color=c["grid"], linewidth=0.6, zorder=0)
|
|
108
|
+
for spine in ax.spines.values():
|
|
109
|
+
spine.set_visible(False)
|
|
110
|
+
ax.legend(frameon=False, labelcolor=c["fg"], ncol=4, loc="upper center", bbox_to_anchor=(0.5, 1.12))
|
|
111
|
+
fig.tight_layout()
|
|
112
|
+
path = out / f"compliance-by-phrasing-{theme}.svg"
|
|
113
|
+
fig.savefig(path, facecolor=c["bg"], metadata={"Date": None, "Creator": None})
|
|
114
|
+
plt.close(fig)
|
|
115
|
+
written.append(path.name)
|
|
116
|
+
return written
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _chart_enforcement(main: list[dict[str, Any]], sets: dict[str, list[dict[str, Any]]], out: Path) -> list[str]:
|
|
120
|
+
import matplotlib
|
|
121
|
+
|
|
122
|
+
matplotlib.use("Agg")
|
|
123
|
+
import matplotlib.pyplot as plt
|
|
124
|
+
|
|
125
|
+
rows_spec = [] # (label, before rows, after rows)
|
|
126
|
+
measured = {name: _measurable(main, sets.get(name) or [])[0] for name, _, _ in ENFORCE_SETS}
|
|
127
|
+
several = sum(bool(v) for v in measured.values()) > 1
|
|
128
|
+
for name, after in measured.items():
|
|
129
|
+
for slot in sorted({r["slot"] for r in after}):
|
|
130
|
+
mine = [r for r in after if r["slot"] == slot]
|
|
131
|
+
label = slot.replace("_", " ") + (f" ({name})" if several else "")
|
|
132
|
+
rows_spec.append((label, _before(main, mine), mine))
|
|
133
|
+
if not rows_spec:
|
|
134
|
+
return []
|
|
135
|
+
written = []
|
|
136
|
+
for theme, c in THEMES.items():
|
|
137
|
+
fig, ax = plt.subplots(figsize=(8, 0.42 * len(rows_spec) + 1.4), dpi=100)
|
|
138
|
+
fig.patch.set_facecolor(c["bg"])
|
|
139
|
+
ax.set_facecolor(c["bg"])
|
|
140
|
+
for i, (_, before, after) in enumerate(rows_spec):
|
|
141
|
+
bk, bn = _rate(before)
|
|
142
|
+
ak, an = _rate(after)
|
|
143
|
+
if bn and an:
|
|
144
|
+
ax.plot([bk / bn * 100, ak / an * 100], [i, i], color=c["grid"], linewidth=2, zorder=1)
|
|
145
|
+
ax.scatter([bk / bn * 100], [i], color=c["before"], s=40, zorder=2, label="before" if i == 0 else None)
|
|
146
|
+
ax.scatter(
|
|
147
|
+
[ak / an * 100], [i], color=c["after"], s=40, zorder=3, label="with hooks" if i == 0 else None
|
|
148
|
+
)
|
|
149
|
+
ax.set_yticks(range(len(rows_spec)), [label for label, _, _ in rows_spec], color=c["fg"])
|
|
150
|
+
ax.invert_yaxis()
|
|
151
|
+
ax.set_xlim(0, 105)
|
|
152
|
+
ax.set_xlabel("probe compliance, % of exercised runs", color=c["muted"])
|
|
153
|
+
ax.tick_params(colors=c["muted"])
|
|
154
|
+
ax.grid(axis="x", color=c["grid"], linewidth=0.6, zorder=0)
|
|
155
|
+
for spine in ax.spines.values():
|
|
156
|
+
spine.set_visible(False)
|
|
157
|
+
ax.legend(frameon=False, labelcolor=c["fg"], ncol=2, loc="lower center", bbox_to_anchor=(0.5, 1.0))
|
|
158
|
+
fig.tight_layout()
|
|
159
|
+
path = out / f"enforcement-before-after-{theme}.svg"
|
|
160
|
+
fig.savefig(path, facecolor=c["bg"], metadata={"Date": None, "Creator": None})
|
|
161
|
+
plt.close(fig)
|
|
162
|
+
written.append(path.name)
|
|
163
|
+
return written
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _phase_rows(results: Path, phase: str) -> list[dict[str, Any]]:
|
|
167
|
+
"""A phase's summaries, rescored with the current checkers when that file exists (study/DEVIATIONS.md)."""
|
|
168
|
+
rescored = results / f"{phase}.rescored.jsonl"
|
|
169
|
+
return _rows(rescored if rescored.exists() else results / f"{phase}.jsonl")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# Part 3 as run (study/DEVIATIONS.md, deviation 4): the registered phase, then the replacement sets.
|
|
173
|
+
ENFORCE_SETS = (
|
|
174
|
+
("3a", "rule present, with hooks", ("enforce-prohibition", "enforce-reason")),
|
|
175
|
+
("3b", "hooks without the rule in AGENTS.md", ("enforce-baseline",)),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def write_report(root: Path, charts: bool = True) -> str:
|
|
180
|
+
from obey_cli.study.analysis import analyse
|
|
181
|
+
|
|
182
|
+
results = root / "study" / "results"
|
|
183
|
+
pilot = _rows(results / "pilot.jsonl")
|
|
184
|
+
main = _rows(results / "main.jsonl")
|
|
185
|
+
corrected = _rows(results / "main.rescored.jsonl")
|
|
186
|
+
enf = _rows(results / "enforce.jsonl")
|
|
187
|
+
sets = {name: [r for phase in phases for r in _phase_rows(results, phase)] for name, _, phases in ENFORCE_SETS}
|
|
188
|
+
survey = (
|
|
189
|
+
json.loads((root / "study" / "survey" / "summary.json").read_text())
|
|
190
|
+
if (root / "study" / "survey" / "summary.json").exists()
|
|
191
|
+
else None
|
|
192
|
+
)
|
|
193
|
+
plan = json.loads((results / "plan.json").read_text()) if (results / "plan.json").exists() else None
|
|
194
|
+
analysis = analyse(main) if main else None
|
|
195
|
+
if analysis is not None:
|
|
196
|
+
(results / "analysis.json").write_text(json.dumps(analysis, indent=2) + "\n", encoding="utf-8")
|
|
197
|
+
analysis_c = analyse(corrected) if corrected else None
|
|
198
|
+
if analysis_c is not None:
|
|
199
|
+
(results / "analysis.rescored.json").write_text(json.dumps(analysis_c, indent=2) + "\n", encoding="utf-8")
|
|
200
|
+
chart_dir = root / "docs" / "images"
|
|
201
|
+
chart_dir.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
chart_files: list[str] = []
|
|
203
|
+
if charts and (corrected or main):
|
|
204
|
+
chart_files += _chart_conditions(corrected or main, chart_dir)
|
|
205
|
+
if charts and any(sets.values()):
|
|
206
|
+
chart_files += _chart_enforcement(corrected or main, sets, chart_dir)
|
|
207
|
+
lines = ["# Which AGENTS.md rules do coding agents follow, and does the phrasing matter?", ""]
|
|
208
|
+
lines += _survey_section(survey)
|
|
209
|
+
design_file = root / "study" / "design.yaml"
|
|
210
|
+
design = yaml.safe_load(design_file.read_text(encoding="utf-8")) if design_file.exists() else None
|
|
211
|
+
lines += _experiment_section(main, pilot, plan, analysis, corrected, analysis_c, design)
|
|
212
|
+
lines += _enforcement_section(corrected or main, enf, sets)
|
|
213
|
+
lines += _costs_section(
|
|
214
|
+
[("pilot", pilot), ("main grid", main), ("part 3 as registered", enf)]
|
|
215
|
+
+ [(f"part {name}", sets[name]) for name, _, _ in ENFORCE_SETS]
|
|
216
|
+
)
|
|
217
|
+
deviations = root / "study" / "DEVIATIONS.md"
|
|
218
|
+
lines += ["## Deviations from the pre-registration", ""]
|
|
219
|
+
lines += [deviations.read_text(encoding="utf-8").strip() if deviations.exists() else "None.", ""]
|
|
220
|
+
(root / "study" / "REPORT.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
221
|
+
n_sets = sum(len(v) for v in sets.values())
|
|
222
|
+
return (
|
|
223
|
+
f"wrote study/REPORT.md ({len(main)} main runs, {len(enf) + n_sets} enforcement runs; "
|
|
224
|
+
f"charts: {', '.join(chart_files)})"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _survey_section(s: dict[str, Any] | None) -> list[str]:
|
|
229
|
+
if not s:
|
|
230
|
+
return []
|
|
231
|
+
out = ["## Part 1: survey of public AGENTS.md files", ""]
|
|
232
|
+
out.append(
|
|
233
|
+
f"{s['repositories']} public repositories with a root AGENTS.md, sampled through GitHub code search "
|
|
234
|
+
f"(size-sliced queries, forks removed; median {s.get('stars_median', 0):.0f} stars). The survey "
|
|
235
|
+
"measures what the files say, not whether agents follow them. Only URLs, commit SHAs, licence ids and "
|
|
236
|
+
"derived counts are stored (`study/survey/`)."
|
|
237
|
+
)
|
|
238
|
+
out.append("")
|
|
239
|
+
rpf = s["rules_per_file"]
|
|
240
|
+
out += [
|
|
241
|
+
f"- Rules per file: median {rpf['median']:.0f}, mean {rpf['mean']:.1f}, 90th percentile {rpf['p90']}, "
|
|
242
|
+
f"max {rpf['max']}; {s['files_with_no_rules']} files had no directive at all.",
|
|
243
|
+
f"- Mechanically checkable (deterministic extractor): {s['checkable_share'] * 100:.1f}% of "
|
|
244
|
+
f"{s['rules_total']} rules; {s['repos_with_any_checkable']} of {s['fetched']} files have at least one.",
|
|
245
|
+
f"- Enforceable before the action by a pre-tool hook: {s['pre_tool_enforceable_share'] * 100:.1f}% of rules; "
|
|
246
|
+
f"another {s['stop_or_commit_only_share'] * 100:.1f}% only at stop or commit time.",
|
|
247
|
+
]
|
|
248
|
+
if s.get("llm"):
|
|
249
|
+
llm = s["llm"]
|
|
250
|
+
out.append(
|
|
251
|
+
f"- An LLM pass (unreviewed proposals, `anthropic/claude-haiku-5.5`) called "
|
|
252
|
+
f"{llm['proposed_share_of_unmapped'] * 100:.0f}% of the {llm['uncheckable_sent']} unmapped rules "
|
|
253
|
+
f"checkable; with those the checkable share would be {llm['checkable_share_with_llm'] * 100:.1f}%."
|
|
254
|
+
)
|
|
255
|
+
if s.get("enforcement"):
|
|
256
|
+
e = s["enforcement"]
|
|
257
|
+
out.append(
|
|
258
|
+
f"- Repositories that already enforce something mechanically: {e['any_repos']} "
|
|
259
|
+
f"({e['any_share'] * 100:.0f}%): git hooks (pre-commit, husky, lefthook) in {e['git_hook_repos']}, "
|
|
260
|
+
f"agent hooks (Claude Code, Codex, Copilot) in {e['agent_hooks_repos']}."
|
|
261
|
+
)
|
|
262
|
+
out += ["", "| Rule type | Rules | Repositories |", "|---|---|---|"]
|
|
263
|
+
out += [f"| {t['type']} | {t['rules']} | {t['repositories']} |" for t in s["types"]]
|
|
264
|
+
return [*out, ""]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _experiment_section(
|
|
268
|
+
main: list[dict[str, Any]],
|
|
269
|
+
pilot: list[dict[str, Any]],
|
|
270
|
+
plan: dict[str, Any] | None,
|
|
271
|
+
a: dict[str, Any] | None,
|
|
272
|
+
corrected: list[dict[str, Any]] | None = None,
|
|
273
|
+
ac: dict[str, Any] | None = None,
|
|
274
|
+
design: dict[str, Any] | None = None,
|
|
275
|
+
) -> list[str]:
|
|
276
|
+
out = ["## Part 2: controlled experiment on phrasing", ""]
|
|
277
|
+
if plan:
|
|
278
|
+
out.append(
|
|
279
|
+
f"Pilot: {plan['pilot_runs']} runs, compliance p̄ = {plan['p_bar']:.2f}, ICC = {plan['icc']:.2f}, "
|
|
280
|
+
f"exercised share {plan['exercised_share']:.2f}, ${plan['cost_per_run_usd']:.4f} per run. "
|
|
281
|
+
f"The pre-registered formula gave {plan['trials_per_cell']} trials per cell."
|
|
282
|
+
)
|
|
283
|
+
out.append("")
|
|
284
|
+
if not main or a is None:
|
|
285
|
+
return [*out, "The main grid has not run yet.", ""]
|
|
286
|
+
versions = sorted(
|
|
287
|
+
{
|
|
288
|
+
f"{HARNESS.get(r['harness'], r['harness'])} "
|
|
289
|
+
f"{r.get('agent_version') or IMAGE_VERSIONS.get(r['harness'], '')}".strip()
|
|
290
|
+
for r in main
|
|
291
|
+
}
|
|
292
|
+
)
|
|
293
|
+
dates = sorted(r["started_at"][:10] for r in main if r.get("started_at"))
|
|
294
|
+
incomplete = a["excluded"]["incomplete"]
|
|
295
|
+
out.append(
|
|
296
|
+
f"{a['n_runs']} runs ({incomplete} incomplete and re-run once; {a['n_exercised']} exercised); "
|
|
297
|
+
f"{', '.join(versions)}; model `{main[0].get('model')}`; {dates[0]} to {dates[-1]}; Mac Studio M4 Max, "
|
|
298
|
+
"128 GB. Compliance is probe compliance: compliance on tempting tasks, not in natural use."
|
|
299
|
+
)
|
|
300
|
+
if ac is not None:
|
|
301
|
+
out += [
|
|
302
|
+
"",
|
|
303
|
+
"Two sets of verdicts are reported (deviation 2): the **registered** verdicts recorded during the runs, "
|
|
304
|
+
"and the **corrected** verdicts, the same runs scored again after fixing a checker bug that marked 109 "
|
|
305
|
+
"ts-web `dependency_unchanged` runs as violated in every condition.",
|
|
306
|
+
]
|
|
307
|
+
out += ["", "| Condition | Registered | Corrected | Exercised runs |", "|---|---|---|---|"]
|
|
308
|
+
else:
|
|
309
|
+
out += ["", "| Condition | Compliance [95% CI] | Exercised runs |", "|---|---|---|"]
|
|
310
|
+
for cond in CONDITIONS:
|
|
311
|
+
pc = a["per_condition"][cond]
|
|
312
|
+
ex = a["exercised_share"].get(cond, {"exercised": 0, "runs": 0})
|
|
313
|
+
cells = [_ci(pc["obeyed"], pc["n"])]
|
|
314
|
+
if ac is not None:
|
|
315
|
+
pcc = ac["per_condition"][cond]
|
|
316
|
+
cells.append(_ci(pcc["obeyed"], pcc["n"]))
|
|
317
|
+
out.append(f"| {LABELS[cond]} | " + " | ".join(cells) + f" | {ex['exercised']} of {ex['runs']} |")
|
|
318
|
+
for title, an in (("registered", a), ("corrected", ac)):
|
|
319
|
+
if an is None:
|
|
320
|
+
continue
|
|
321
|
+
h1 = an["rule_present_vs_baseline"]
|
|
322
|
+
out += ["", f"Tests on the {title} verdicts:", "", f"- **H1 (rule present vs no rule):** {_pp(h1)}."]
|
|
323
|
+
out.append("- **H2 (phrasing), Holm-adjusted:**")
|
|
324
|
+
for c in an["phrasing"]:
|
|
325
|
+
out.append(
|
|
326
|
+
f" - {LABELS[c['a'][0]]} vs {LABELS[c['b'][0]]}: {_pp(c)}, adjusted p {_padj(c.get('p_adj'))}"
|
|
327
|
+
f"{' (significant)' if c.get('reject') else ''}."
|
|
328
|
+
)
|
|
329
|
+
detail = corrected if corrected else main
|
|
330
|
+
final = ac if ac is not None else a
|
|
331
|
+
quoted: set[tuple[str, str]] = set()
|
|
332
|
+
for c in final["phrasing"]:
|
|
333
|
+
if c.get("reject"):
|
|
334
|
+
text, rules = _drivers(detail, c["a"][0], c["b"][0])
|
|
335
|
+
out += [
|
|
336
|
+
"",
|
|
337
|
+
f"Cells where {LABELS[c['a'][0]]} and {LABELS[c['b'][0]]} differ"
|
|
338
|
+
+ (" (corrected verdicts)" if corrected else "")
|
|
339
|
+
+ f": {text}. A pooled difference that comes from few cells is why the cluster bootstrap interval "
|
|
340
|
+
"reaches zero while the permutation test rejects.",
|
|
341
|
+
]
|
|
342
|
+
quoted |= rules
|
|
343
|
+
for repo, slot in sorted(quoted):
|
|
344
|
+
spec = ((design or {}).get("repos", {}).get(repo, {}).get("slots", {}) or {}).get(slot)
|
|
345
|
+
if spec:
|
|
346
|
+
out += ["", f"The phrasings of that rule ({slot}, {repo}):", ""]
|
|
347
|
+
out += [f"- {LABELS[cond]}: {spec[cond]}" for cond in ("imperative", "prohibition", "reason")]
|
|
348
|
+
out += ["", f"Its probe: {spec['probe']}"]
|
|
349
|
+
out += [
|
|
350
|
+
"",
|
|
351
|
+
"<details><summary>Compliance by rule slot, repository and harness"
|
|
352
|
+
+ (" (corrected verdicts)" if corrected else "")
|
|
353
|
+
+ "</summary>",
|
|
354
|
+
"",
|
|
355
|
+
"| Slot | Repository | Harness | No rule | Imperative | Prohibition | With a reason |",
|
|
356
|
+
"|---|---|---|---|---|---|---|",
|
|
357
|
+
]
|
|
358
|
+
for (slot, repo, harness), rows in sorted(_by(detail, "slot", "repo", "harness").items()):
|
|
359
|
+
cells = [_ci(*_rate([r for r in rows if r["condition"] == c])) for c in CONDITIONS]
|
|
360
|
+
out.append(f"| {slot} | {repo} | {HARNESS.get(harness, harness)} | " + " | ".join(cells) + " |")
|
|
361
|
+
out += ["", "</details>", ""]
|
|
362
|
+
return out
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _p(p: float | None) -> str:
|
|
366
|
+
return "–" if p is None else ("< 0.001" if p < 0.001 else f"{p:.3f}")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _padj(p: float | None) -> str:
|
|
370
|
+
text = _p(p)
|
|
371
|
+
return text if text.startswith("<") else f"= {text}"
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _drivers(rows: list[dict[str, Any]], a: str, b: str) -> tuple[str, set[tuple[str, str]]]:
|
|
375
|
+
"""The cells in which conditions `a` and `b` differ, for reading a pooled phrasing effect, and their rules."""
|
|
376
|
+
parts = []
|
|
377
|
+
rules = set()
|
|
378
|
+
for (slot, repo, harness), cell in sorted(_by(rows, "slot", "repo", "harness").items()):
|
|
379
|
+
ka, na = _rate([r for r in cell if r["condition"] == a])
|
|
380
|
+
kb, nb = _rate([r for r in cell if r["condition"] == b])
|
|
381
|
+
if na and nb and ka * nb != kb * na:
|
|
382
|
+
parts.append(
|
|
383
|
+
f"{slot} ({repo}, {HARNESS.get(harness, harness)}): {LABELS[a]} {ka}/{na}, {LABELS[b]} {kb}/{nb}"
|
|
384
|
+
)
|
|
385
|
+
rules.add((repo, slot))
|
|
386
|
+
return ("; ".join(parts) if parts else "none"), rules
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _pp(c: dict[str, Any]) -> str:
|
|
390
|
+
eff = c.get("effect")
|
|
391
|
+
if eff is None:
|
|
392
|
+
return "not estimable"
|
|
393
|
+
lo, hi = c.get("ci_low"), c.get("ci_high")
|
|
394
|
+
interval = f" [95% CI {lo * 100:+.1f} to {hi * 100:+.1f}]" if lo is not None and hi is not None else ""
|
|
395
|
+
p = _p(c.get("p"))
|
|
396
|
+
return f"{eff * 100:+.1f} percentage points{interval}, p {p if p.startswith('<') else '= ' + p}"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _cells(rows: list[dict[str, Any]]) -> set[tuple[str, str, str, str]]:
|
|
400
|
+
return {(r["repo"], r["slot"], r["harness"], r["condition"]) for r in rows}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _measurable(
|
|
404
|
+
main: list[dict[str, Any]], rows: list[dict[str, Any]]
|
|
405
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
406
|
+
"""(runs in cells the main grid's verdicts show violated, the other runs): only those cells measure anything."""
|
|
407
|
+
broken = {c for c in _cells(rows) if any(r["verdict"] == "violated" for r in _before(main, [_cell_row(c)]))}
|
|
408
|
+
keep = [r for r in rows if (r["repo"], r["slot"], r["harness"], r["condition"]) in broken]
|
|
409
|
+
return keep, [r for r in rows if (r["repo"], r["slot"], r["harness"], r["condition"]) not in broken]
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _cell_row(cell: tuple[str, str, str, str]) -> dict[str, Any]:
|
|
413
|
+
return dict(zip(("repo", "slot", "harness", "condition"), cell, strict=True))
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _before(main: list[dict[str, Any]], after: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
417
|
+
cells = _cells(after)
|
|
418
|
+
return [r for r in main if (r["repo"], r["slot"], r["harness"], r["condition"]) in cells]
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _evasions(rows: list[dict[str, Any]]) -> tuple[int, int]:
|
|
422
|
+
blocked = [r for r in rows if r["status"] == "ok" and r["denied"] > 0]
|
|
423
|
+
return sum(r["verdict"] == "violated" for r in blocked), len(blocked)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _cell_table(main: list[dict[str, Any]], after: list[dict[str, Any]]) -> list[str]:
|
|
427
|
+
out = [
|
|
428
|
+
"| Slot (repository) | Harness | Condition | Before | With hooks | Blocked runs | Evasions |",
|
|
429
|
+
"|---|---|---|---|---|---|---|",
|
|
430
|
+
]
|
|
431
|
+
for (repo, slot, harness, cond), rows in sorted(_by(after, "repo", "slot", "harness", "condition").items()):
|
|
432
|
+
before = [
|
|
433
|
+
r for r in main if (r["repo"], r["slot"], r["harness"], r["condition"]) == (repo, slot, harness, cond)
|
|
434
|
+
]
|
|
435
|
+
ev, bl = _evasions(rows)
|
|
436
|
+
out.append(
|
|
437
|
+
f"| {slot} ({repo}) | {HARNESS.get(harness, harness)} | {LABELS[cond]} | {_ci(*_rate(before))} | "
|
|
438
|
+
f"{_ci(*_rate(rows))} | {bl} | {ev} |"
|
|
439
|
+
)
|
|
440
|
+
return out
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _enforcement_section(
|
|
444
|
+
main: list[dict[str, Any]], enf: list[dict[str, Any]], sets: dict[str, list[dict[str, Any]]] | None = None
|
|
445
|
+
) -> list[str]:
|
|
446
|
+
from obey_cli.study.analysis import enforcement_contrast
|
|
447
|
+
|
|
448
|
+
out = ["## Part 3: enforcement", ""]
|
|
449
|
+
sets = sets or {}
|
|
450
|
+
if not enf and not any(sets.values()):
|
|
451
|
+
return [*out, "Not run yet.", ""]
|
|
452
|
+
out.append(
|
|
453
|
+
"Each selected rule was compiled by `obey enforce` into hooks for the run's harness plus the git pre-commit "
|
|
454
|
+
"check, and the same probe was run again. *Before* is the same slot, repository, harness and condition in "
|
|
455
|
+
"the main grid (corrected verdicts). An evasion is a run in which a hook denied at least once and the rule "
|
|
456
|
+
"was still broken by the end."
|
|
457
|
+
)
|
|
458
|
+
out.append("")
|
|
459
|
+
for name, title, _ in ENFORCE_SETS:
|
|
460
|
+
rows = sets.get(name) or []
|
|
461
|
+
if not rows:
|
|
462
|
+
continue
|
|
463
|
+
after, excluded = _measurable(main, rows)
|
|
464
|
+
out += [f"### Part {name}: {title}", ""]
|
|
465
|
+
if not after:
|
|
466
|
+
ek, en = _rate(rows)
|
|
467
|
+
out += [
|
|
468
|
+
f"None of this set's cells was violated in the main grid once the checker corrections were applied "
|
|
469
|
+
f"(deviation 5), so it has nothing to measure. For completeness: {len(rows)} runs, {_ci(ek, en)} "
|
|
470
|
+
"scored compliant.",
|
|
471
|
+
"",
|
|
472
|
+
]
|
|
473
|
+
continue
|
|
474
|
+
before = _before(main, after)
|
|
475
|
+
bk, bn = _rate(before)
|
|
476
|
+
ak, an = _rate(after)
|
|
477
|
+
ev, bl = _evasions(after)
|
|
478
|
+
c = enforcement_contrast(before, after)
|
|
479
|
+
out += [
|
|
480
|
+
f"- Before: {_ci(bk, bn)} of exercised runs.",
|
|
481
|
+
f"- With hooks: {_ci(ak, an)} of exercised runs; difference {_pp(c)} "
|
|
482
|
+
f"(stratified permutation test over {c['cells']} cells).",
|
|
483
|
+
f"- Evasions: {ev} of {bl} runs in which a hook blocked at least once ({_ci(ev, bl) if bl else '–'}).",
|
|
484
|
+
"",
|
|
485
|
+
]
|
|
486
|
+
out += [*_cell_table(main, after), ""]
|
|
487
|
+
if excluded:
|
|
488
|
+
cells = sorted({(r["slot"], r["repo"], HARNESS.get(r["harness"], r["harness"])) for r in excluded})
|
|
489
|
+
out += [
|
|
490
|
+
f"Not counted: {len(excluded)} runs in "
|
|
491
|
+
+ ", ".join(f"{slot} ({repo}, {h})" for slot, repo, h in cells)
|
|
492
|
+
+ ", cells that the corrected verdicts show were never violated (deviation 5).",
|
|
493
|
+
"",
|
|
494
|
+
]
|
|
495
|
+
if enf:
|
|
496
|
+
ek, en = _rate(enf)
|
|
497
|
+
out += [
|
|
498
|
+
"### Part 3 as registered (not interpreted)",
|
|
499
|
+
"",
|
|
500
|
+
f"The registered selection (cells violated in the imperative condition) picked only the two cells of a "
|
|
501
|
+
f"checker bug, and the OpenCode runs were recorded with blocked commands counted as executed "
|
|
502
|
+
f"(deviation 3). For completeness: {len(enf)} runs, {_ci(ek, en)} scored compliant.",
|
|
503
|
+
"",
|
|
504
|
+
*_cell_table(main, enf),
|
|
505
|
+
"",
|
|
506
|
+
]
|
|
507
|
+
return out
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _costs_section(phases: list[tuple[str, list[dict[str, Any]]]]) -> list[str]:
|
|
511
|
+
out = ["## Cost", "", "| Phase | Runs | Cost (USD) | Per run |", "|---|---|---|---|"]
|
|
512
|
+
total = 0.0
|
|
513
|
+
for name, rows in phases:
|
|
514
|
+
cost = sum(r["cost_usd"] for r in rows)
|
|
515
|
+
total += cost
|
|
516
|
+
out.append(
|
|
517
|
+
f"| {name} | {len(rows)} | {cost:.2f} | {cost / len(rows):.4f} |" if rows else f"| {name} | 0 | – | – |"
|
|
518
|
+
)
|
|
519
|
+
return [*out, f"| total | {sum(len(rows) for _, rows in phases)} | {total:.2f} | |", ""]
|