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.
Files changed (90) hide show
  1. obey_cli/__init__.py +3 -0
  2. obey_cli/__main__.py +3 -0
  3. obey_cli/_data/demo/README.md +12 -0
  4. obey_cli/_data/demo/obey.yaml +287 -0
  5. obey_cli/_data/demo/repo/.gitignore +8 -0
  6. obey_cli/_data/demo/repo/AGENTS.md +38 -0
  7. obey_cli/_data/demo/repo/README.md +36 -0
  8. obey_cli/_data/demo/repo/eslint.config.js +24 -0
  9. obey_cli/_data/demo/repo/index.html +21 -0
  10. obey_cli/_data/demo/repo/openapi.yaml +86 -0
  11. obey_cli/_data/demo/repo/package.json +26 -0
  12. obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
  13. obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
  14. obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
  15. obey_cli/_data/demo/repo/src/api/client.ts +71 -0
  16. obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
  17. obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
  18. obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
  19. obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
  20. obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
  21. obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
  22. obey_cli/_data/demo/repo/src/logger.ts +36 -0
  23. obey_cli/_data/demo/repo/src/main.ts +30 -0
  24. obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
  25. obey_cli/_data/demo/repo/src/render-table.ts +48 -0
  26. obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
  27. obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
  28. obey_cli/_data/demo/repo/tsconfig.json +20 -0
  29. obey_cli/_data/demo/repo/vitest.config.ts +8 -0
  30. obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
  31. obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
  32. obey_cli/_data/demo/runs/meta.json +10 -0
  33. obey_cli/_data/schemas/obey-config-v1.json +116 -0
  34. obey_cli/_data/schemas/report-v1.json +55 -0
  35. obey_cli/_data/schemas/survey-record-v1.json +30 -0
  36. obey_cli/checkers/__init__.py +30 -0
  37. obey_cli/checkers/samples.py +95 -0
  38. obey_cli/cli.py +201 -0
  39. obey_cli/commands.py +676 -0
  40. obey_cli/config.py +90 -0
  41. obey_cli/discover.py +232 -0
  42. obey_cli/enforce/__init__.py +114 -0
  43. obey_cli/enforce/engine/__init__.py +0 -0
  44. obey_cli/enforce/engine/obey_engine.py +2005 -0
  45. obey_cli/enforce/merge.py +196 -0
  46. obey_cli/enforce/targets/__init__.py +354 -0
  47. obey_cli/enforce/templates/obey_hook.py +452 -0
  48. obey_cli/enforce/templates/pre_commit.py +116 -0
  49. obey_cli/extract.py +1342 -0
  50. obey_cli/lint.py +125 -0
  51. obey_cli/llm.py +129 -0
  52. obey_cli/parse_md.py +260 -0
  53. obey_cli/probes/__init__.py +0 -0
  54. obey_cli/probes/facts.py +162 -0
  55. obey_cli/probes/templates.py +326 -0
  56. obey_cli/py.typed +0 -0
  57. obey_cli/report/__init__.py +314 -0
  58. obey_cli/resources.py +35 -0
  59. obey_cli/rules.py +75 -0
  60. obey_cli/runner/__init__.py +0 -0
  61. obey_cli/runner/agents/__init__.py +22 -0
  62. obey_cli/runner/agents/base.py +96 -0
  63. obey_cli/runner/agents/claude_code.py +203 -0
  64. obey_cli/runner/agents/codex.py +152 -0
  65. obey_cli/runner/agents/copilot.py +142 -0
  66. obey_cli/runner/agents/gemini.py +125 -0
  67. obey_cli/runner/agents/opencode.py +156 -0
  68. obey_cli/runner/core.py +140 -0
  69. obey_cli/runner/egress_proxy.py +166 -0
  70. obey_cli/runner/gateway.py +529 -0
  71. obey_cli/runner/ledger.py +132 -0
  72. obey_cli/runner/local.py +176 -0
  73. obey_cli/runner/orchestrate.py +227 -0
  74. obey_cli/runner/sandbox/Dockerfile +41 -0
  75. obey_cli/runner/sandbox/entrypoint.sh +38 -0
  76. obey_cli/runner/sandbox.py +341 -0
  77. obey_cli/runner/sidecar.py +38 -0
  78. obey_cli/runner/workspace.py +187 -0
  79. obey_cli/score.py +150 -0
  80. obey_cli/study/__init__.py +0 -0
  81. obey_cli/study/analysis.py +277 -0
  82. obey_cli/study/design.py +98 -0
  83. obey_cli/study/experiment.py +198 -0
  84. obey_cli/study/report.py +519 -0
  85. obey_cli/study/survey.py +438 -0
  86. obey_cli-0.1.0.dist-info/METADATA +393 -0
  87. obey_cli-0.1.0.dist-info/RECORD +90 -0
  88. obey_cli-0.1.0.dist-info/WHEEL +4 -0
  89. obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
  90. obey_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
obey_cli/score.py ADDED
@@ -0,0 +1,150 @@
1
+ """Per-rule compliance from checker verdicts.
2
+
3
+ Compliance is obeyed / exercised, with a Wilson 95% interval. Runs where the rule was never exercised are
4
+ excluded from the ratio and reported as the exercised share next to it. In an enforced arm, a run that a hook
5
+ blocked at least once and that still ends with the rule violated counts as an evasion.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections import defaultdict
12
+ from collections.abc import Iterable, Mapping
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ FOLLOWED_THRESHOLD = 0.9
17
+ AGENT_NAMES = {
18
+ "claude-code": "Claude Code",
19
+ "codex": "Codex CLI",
20
+ "opencode": "OpenCode",
21
+ "gemini": "Gemini CLI",
22
+ "copilot": "Copilot CLI",
23
+ "cursor": "Cursor CLI",
24
+ }
25
+
26
+
27
+ def wilson(k: int, n: int, z: float = 1.959963984540054) -> tuple[float, float]:
28
+ """Wilson score interval for k successes out of n (0..1). (0, 1) when n is 0."""
29
+ if n <= 0:
30
+ return (0.0, 1.0)
31
+ p = k / n
32
+ denom = 1 + z * z / n
33
+ centre = (p + z * z / (2 * n)) / denom
34
+ half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
35
+ return (max(0.0, centre - half), min(1.0, centre + half))
36
+
37
+
38
+ @dataclass
39
+ class Outcome:
40
+ """One run scored against one rule."""
41
+
42
+ rule_id: str
43
+ agent: str
44
+ arm: str # rule | baseline | enforced
45
+ verdict: str # obeyed | violated | not_exercised
46
+ run_id: str
47
+ denied: int = 0
48
+ evidence: list[dict[str, Any]] = field(default_factory=list)
49
+ condition: str | None = None
50
+ status: str = "ok"
51
+
52
+
53
+ @dataclass
54
+ class RuleScore:
55
+ rule_id: str
56
+ agent: str
57
+ arm: str
58
+ runs: int
59
+ exercised: int
60
+ obeyed: int
61
+ violated: int
62
+ compliance: float | None
63
+ ci_low: float | None
64
+ ci_high: float | None
65
+ exercised_share: float
66
+ blocked_runs: int = 0
67
+ evasions: int = 0
68
+ evasion_rate: float | None = None
69
+ violation_example: dict[str, Any] | None = None
70
+ errors: int = 0
71
+
72
+ def as_dict(self) -> dict[str, Any]:
73
+ return {k: getattr(self, k) for k in self.__dataclass_fields__}
74
+
75
+
76
+ def score_outcomes(outcomes: Iterable[Outcome]) -> list[RuleScore]:
77
+ groups: dict[tuple[str, str, str], list[Outcome]] = defaultdict(list)
78
+ for o in outcomes:
79
+ groups[(o.rule_id, o.agent, o.arm)].append(o)
80
+ scores: list[RuleScore] = []
81
+ for (rule_id, agent, arm), items in sorted(groups.items()):
82
+ ok = [o for o in items if o.status == "ok"]
83
+ exercised = [o for o in ok if o.verdict != "not_exercised"]
84
+ obeyed = sum(o.verdict == "obeyed" for o in exercised)
85
+ violated = sum(o.verdict == "violated" for o in exercised)
86
+ compliance = obeyed / len(exercised) if exercised else None
87
+ lo, hi = wilson(obeyed, len(exercised)) if exercised else (None, None)
88
+ blocked = [o for o in ok if o.denied > 0]
89
+ evasions = sum(o.verdict == "violated" for o in blocked)
90
+ example = next((o for o in exercised if o.verdict == "violated"), None)
91
+ scores.append(
92
+ RuleScore(
93
+ rule_id=rule_id,
94
+ agent=agent,
95
+ arm=arm,
96
+ runs=len(ok),
97
+ exercised=len(exercised),
98
+ obeyed=obeyed,
99
+ violated=violated,
100
+ compliance=compliance,
101
+ ci_low=lo,
102
+ ci_high=hi,
103
+ exercised_share=len(exercised) / len(ok) if ok else 0.0,
104
+ blocked_runs=len(blocked),
105
+ evasions=evasions,
106
+ evasion_rate=evasions / len(blocked) if blocked else None,
107
+ violation_example=(
108
+ {"run_id": example.run_id, "evidence": example.evidence[:1]} if example is not None else None
109
+ ),
110
+ errors=len(items) - len(ok),
111
+ )
112
+ )
113
+ return scores
114
+
115
+
116
+ def summary_sentence(scores: Iterable[RuleScore], agent: str, arm: str = "rule") -> str:
117
+ """For example: "Claude Code followed 9 of 14 checkable rules in at least 90% of exercised runs"."""
118
+ mine = [s for s in scores if s.agent == agent and s.arm == arm]
119
+ measured = [s for s in mine if s.compliance is not None]
120
+ followed = sum(1 for s in measured if s.compliance is not None and s.compliance >= FOLLOWED_THRESHOLD)
121
+ name = AGENT_NAMES.get(agent, agent)
122
+ text = (
123
+ f"{name} followed {followed} of {len(measured)} checkable rules in at least "
124
+ f"{round(FOLLOWED_THRESHOLD * 100)}% of exercised runs"
125
+ )
126
+ unexercised = len(mine) - len(measured)
127
+ if unexercised:
128
+ text += f" ({unexercised} more never exercised)"
129
+ return text
130
+
131
+
132
+ def pooled(scores: Iterable[RuleScore], agent: str | None = None, arm: str = "rule") -> Mapping[str, Any]:
133
+ """Pooled compliance over all exercised runs of the selected rules, with a Wilson interval."""
134
+ chosen = [s for s in scores if s.arm == arm and (agent is None or s.agent == agent)]
135
+ k = sum(s.obeyed for s in chosen)
136
+ n = sum(s.exercised for s in chosen)
137
+ lo, hi = wilson(k, n)
138
+ blocked = sum(s.blocked_runs for s in chosen)
139
+ evasions = sum(s.evasions for s in chosen)
140
+ return {
141
+ "obeyed": k,
142
+ "exercised": n,
143
+ "runs": sum(s.runs for s in chosen),
144
+ "compliance": k / n if n else None,
145
+ "ci_low": lo if n else None,
146
+ "ci_high": hi if n else None,
147
+ "blocked_runs": blocked,
148
+ "evasions": evasions,
149
+ "evasion_rate": evasions / blocked if blocked else None,
150
+ }
File without changes
@@ -0,0 +1,277 @@
1
+ """Pre-registered analysis of the phrasing experiment (study/PREREGISTRATION.md).
2
+
3
+ Unit: one run. Outcome: 1 = obeyed, 0 = violated; runs where the rule was not exercised, and runs that did not
4
+ complete, are excluded and counted. Strata: rule slot × repository × harness.
5
+
6
+ - Effect of a contrast A vs B: the stratum-weighted difference in compliance, weighting each stratum that has
7
+ exercised runs in both conditions by its number of exercised runs in A and B.
8
+ - p-value: stratified permutation test (condition labels shuffled within strata), two-sided, 10,000 permutations.
9
+ - 95% interval: cluster bootstrap over rule slot × repository clusters (both harnesses resampled together),
10
+ 10,000 resamples, percentile interval.
11
+ - Phrasing family (three pairwise contrasts): Holm–Bonferroni at α = 0.05.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from collections import defaultdict
18
+ from collections.abc import Iterable, Mapping, Sequence
19
+ from dataclasses import dataclass
20
+ from statistics import NormalDist
21
+ from typing import Any
22
+
23
+ SEED = 20261024
24
+ PERMUTATIONS = 10_000
25
+ BOOTSTRAP = 10_000
26
+ CONTRASTS = (("prohibition", "imperative"), ("reason", "imperative"), ("reason", "prohibition"))
27
+
28
+
29
+ @dataclass
30
+ class Obs:
31
+ stratum: tuple[str, str, str] # slot, repo, harness
32
+ cluster: tuple[str, str] # slot, repo
33
+ condition: str
34
+ y: int
35
+
36
+
37
+ def observations(rows: Iterable[Mapping[str, Any]]) -> tuple[list[Obs], dict[str, int]]:
38
+ obs: list[Obs] = []
39
+ excluded = {"not_exercised": 0, "incomplete": 0}
40
+ for r in rows:
41
+ if r.get("status") != "ok":
42
+ excluded["incomplete"] += 1
43
+ continue
44
+ if r["verdict"] == "not_exercised":
45
+ excluded["not_exercised"] += 1
46
+ continue
47
+ obs.append(
48
+ Obs(
49
+ (r["slot"], r["repo"], r["harness"]),
50
+ (r["slot"], r["repo"]),
51
+ r["condition"],
52
+ 1 if r["verdict"] == "obeyed" else 0,
53
+ )
54
+ )
55
+ return obs, excluded
56
+
57
+
58
+ def _group(
59
+ obs: Sequence[Obs], a: Sequence[str], b: Sequence[str]
60
+ ) -> dict[tuple[str, str, str], tuple[list[int], list[int]]]:
61
+ groups: dict[tuple[str, str, str], tuple[list[int], list[int]]] = defaultdict(lambda: ([], []))
62
+ for o in obs:
63
+ if o.condition in a:
64
+ groups[o.stratum][0].append(o.y)
65
+ elif o.condition in b:
66
+ groups[o.stratum][1].append(o.y)
67
+ return {k: v for k, v in groups.items() if v[0] and v[1]}
68
+
69
+
70
+ def stratified_difference(groups: Mapping[Any, tuple[Sequence[int], Sequence[int]]]) -> float | None:
71
+ num = 0.0
72
+ den = 0.0
73
+ for ya, yb in groups.values():
74
+ w = len(ya) + len(yb)
75
+ num += w * (sum(ya) / len(ya) - sum(yb) / len(yb))
76
+ den += w
77
+ return num / den if den else None
78
+
79
+
80
+ def permutation_p(
81
+ groups: Mapping[Any, tuple[Sequence[int], Sequence[int]]], n: int = PERMUTATIONS, seed: int = SEED
82
+ ) -> float | None:
83
+ import numpy as np
84
+
85
+ observed = stratified_difference(groups)
86
+ if observed is None:
87
+ return None
88
+ rng = np.random.default_rng(seed)
89
+ pooled = [(np.array(list(ya) + list(yb)), len(ya)) for ya, yb in groups.values()]
90
+ weights = np.array([len(p) for p, _ in pooled], dtype=float)
91
+ hits = 0
92
+ for _ in range(n):
93
+ diffs = []
94
+ for values, na in pooled:
95
+ perm = rng.permutation(values)
96
+ diffs.append(perm[:na].mean() - perm[na:].mean())
97
+ stat = float(np.dot(weights, diffs) / weights.sum())
98
+ if abs(stat) >= abs(observed) - 1e-12:
99
+ hits += 1
100
+ return (hits + 1) / (n + 1)
101
+
102
+
103
+ def bootstrap_ci(
104
+ obs: Sequence[Obs], a: Sequence[str], b: Sequence[str], n: int = BOOTSTRAP, seed: int = SEED
105
+ ) -> tuple[float | None, float | None]:
106
+ import numpy as np
107
+
108
+ clusters: dict[tuple[str, str], list[Obs]] = defaultdict(list)
109
+ for o in obs:
110
+ clusters[o.cluster].append(o)
111
+ keys = sorted(clusters)
112
+ if not keys:
113
+ return (None, None)
114
+ rng = np.random.default_rng(seed)
115
+ stats = []
116
+ for _ in range(n):
117
+ sample: list[Obs] = []
118
+ for i, idx in enumerate(rng.integers(0, len(keys), len(keys))):
119
+ # Relabel resampled clusters so duplicates count as separate strata.
120
+ for o in clusters[keys[int(idx)]]:
121
+ sample.append(Obs((o.stratum[0], o.stratum[1] + f"#{i}", o.stratum[2]), o.cluster, o.condition, o.y))
122
+ diff = stratified_difference(_group(sample, a, b))
123
+ if diff is not None:
124
+ stats.append(diff)
125
+ if not stats:
126
+ return (None, None)
127
+ return (float(np.percentile(stats, 2.5)), float(np.percentile(stats, 97.5)))
128
+
129
+
130
+ def compliance(obs: Sequence[Obs], conditions: Sequence[str]) -> dict[str, Any]:
131
+ from obey_cli.score import wilson
132
+
133
+ ys = [o.y for o in obs if o.condition in conditions]
134
+ lo, hi = wilson(sum(ys), len(ys))
135
+ return {
136
+ "obeyed": sum(ys),
137
+ "n": len(ys),
138
+ "compliance": sum(ys) / len(ys) if ys else None,
139
+ "ci_low": lo if ys else None,
140
+ "ci_high": hi if ys else None,
141
+ }
142
+
143
+
144
+ def contrast(
145
+ obs: Sequence[Obs], a: Sequence[str], b: Sequence[str], permutations: int = PERMUTATIONS, bootstrap: int = BOOTSTRAP
146
+ ) -> dict[str, Any]:
147
+ groups = _group(obs, a, b)
148
+ lo, hi = bootstrap_ci(obs, a, b, bootstrap)
149
+ return {
150
+ "a": list(a),
151
+ "b": list(b),
152
+ "strata": len(groups),
153
+ "effect": stratified_difference(groups),
154
+ "ci_low": lo,
155
+ "ci_high": hi,
156
+ "p": permutation_p(groups, permutations),
157
+ "a_compliance": compliance(obs, a),
158
+ "b_compliance": compliance(obs, b),
159
+ }
160
+
161
+
162
+ def holm(pvalues: Sequence[float | None], alpha: float = 0.05) -> list[dict[str, Any]]:
163
+ order = sorted((p, i) for i, p in enumerate(pvalues) if p is not None)
164
+ m = len(order)
165
+ out: list[dict[str, Any]] = [{"p_adj": None, "reject": False} for _ in pvalues]
166
+ running = 0.0
167
+ still = True
168
+ for rank, (p, i) in enumerate(order):
169
+ adj = min(1.0, (m - rank) * p)
170
+ running = max(running, adj)
171
+ still = still and p <= alpha / (m - rank)
172
+ out[i] = {"p_adj": running, "reject": still}
173
+ return out
174
+
175
+
176
+ def analyse(
177
+ rows: Sequence[Mapping[str, Any]], permutations: int = PERMUTATIONS, bootstrap: int = BOOTSTRAP
178
+ ) -> dict[str, Any]:
179
+ obs, excluded = observations(rows)
180
+ rule_present = contrast(obs, ["imperative", "prohibition", "reason"], ["baseline"], permutations, bootstrap)
181
+ phrasing = [contrast(obs, [a], [b], permutations, bootstrap) for a, b in CONTRASTS]
182
+ for c, h in zip(phrasing, holm([c["p"] for c in phrasing]), strict=True):
183
+ c.update(h)
184
+ per_condition = {c: compliance(obs, [c]) for c in ("baseline", "imperative", "prohibition", "reason")}
185
+ exercised: dict[str, dict[str, int]] = defaultdict(lambda: {"exercised": 0, "runs": 0})
186
+ for r in rows:
187
+ if r.get("status") == "ok":
188
+ key = r["condition"]
189
+ exercised[key]["runs"] += 1
190
+ exercised[key]["exercised"] += r["verdict"] != "not_exercised"
191
+ return {
192
+ "n_runs": len(rows),
193
+ "n_exercised": len(obs),
194
+ "excluded": excluded,
195
+ "per_condition": per_condition,
196
+ "exercised_share": dict(exercised),
197
+ "rule_present_vs_baseline": rule_present,
198
+ "phrasing": phrasing,
199
+ }
200
+
201
+
202
+ def enforcement_contrast(
203
+ before: Sequence[Mapping[str, Any]], after: Sequence[Mapping[str, Any]], permutations: int = PERMUTATIONS
204
+ ) -> dict[str, Any]:
205
+ """Compliance with hooks minus compliance before, weighted within slot × repository × harness × condition cells,
206
+ with the stratified permutation test of the main analysis."""
207
+ groups: dict[tuple[str, ...], tuple[list[int], list[int]]] = defaultdict(lambda: ([], []))
208
+ for side, rows in ((0, after), (1, before)):
209
+ for r in rows:
210
+ if r.get("status") == "ok" and r["verdict"] != "not_exercised":
211
+ cell = (r["slot"], r["repo"], r["harness"], r["condition"])
212
+ groups[cell][side].append(1 if r["verdict"] == "obeyed" else 0)
213
+ usable = {k: v for k, v in groups.items() if v[0] and v[1]}
214
+ return {
215
+ "effect": stratified_difference(usable),
216
+ "p": permutation_p(usable, permutations),
217
+ "cells": len(usable),
218
+ }
219
+
220
+
221
+ def trials_needed(
222
+ pilot_rows: Sequence[Mapping[str, Any]],
223
+ *,
224
+ delta: float = 0.10,
225
+ alpha: float = 0.05,
226
+ family: int = 3,
227
+ power: float = 0.80,
228
+ cells: int = 40,
229
+ ) -> dict[str, Any]:
230
+ """The pre-registered sample-size formula, from the pilot's compliance, clustering and exercised share."""
231
+ obs, _ = observations(pilot_rows)
232
+ present = [o for o in obs if o.condition in ("imperative", "prohibition", "reason")]
233
+ p_bar = sum(o.y for o in present) / len(present) if present else 0.7
234
+ p1 = min(max(p_bar, 0.55), 0.95)
235
+ p2 = max(p1 - delta, 0.01)
236
+ z_a = NormalDist().inv_cdf(1 - alpha / family / 2)
237
+ z_b = NormalDist().inv_cdf(power)
238
+ n_arm = (z_a + z_b) ** 2 * (p1 * (1 - p1) + p2 * (1 - p2)) / delta**2
239
+ # Intra-cluster correlation of the outcome across slot × repository cells (ANOVA estimator), clamped.
240
+ by_cluster: dict[tuple[str, str], list[int]] = defaultdict(list)
241
+ for o in present:
242
+ by_cluster[o.cluster].append(o.y)
243
+ icc = _icc(list(by_cluster.values()))
244
+ ok_rows = [r for r in pilot_rows if r.get("status") == "ok" and r["condition"] != "baseline"]
245
+ exercised_share = (sum(r["verdict"] != "not_exercised" for r in ok_rows) / len(ok_rows)) if ok_rows else 1.0
246
+ e = max(exercised_share, 0.3)
247
+ m = max(n_arm / cells, 1.0)
248
+ deff = 1 + (m - 1) * icc
249
+ trials = math.ceil(n_arm * deff / (cells * e))
250
+ return {
251
+ "p_bar": p_bar,
252
+ "p1": p1,
253
+ "p2": p2,
254
+ "z_alpha": z_a,
255
+ "z_beta": z_b,
256
+ "n_arm": n_arm,
257
+ "icc": icc,
258
+ "design_effect": deff,
259
+ "exercised_share": exercised_share,
260
+ "trials_per_cell": trials,
261
+ }
262
+
263
+
264
+ def _icc(groups: Sequence[Sequence[int]]) -> float:
265
+ groups = [g for g in groups if len(g) >= 1]
266
+ k = len(groups)
267
+ n = sum(len(g) for g in groups)
268
+ if k < 2 or n <= k:
269
+ return 0.0
270
+ grand = sum(sum(g) for g in groups) / n
271
+ ssb = sum(len(g) * (sum(g) / len(g) - grand) ** 2 for g in groups)
272
+ ssw = sum(sum((y - sum(g) / len(g)) ** 2 for y in g) for g in groups)
273
+ msb = ssb / (k - 1)
274
+ msw = ssw / (n - k)
275
+ n0 = (n - sum(len(g) ** 2 for g in groups) / n) / (k - 1)
276
+ icc = (msb - msw) / (msb + (n0 - 1) * msw) if msb + (n0 - 1) * msw > 0 else 0.0
277
+ return min(max(icc, 0.0), 0.3)
@@ -0,0 +1,98 @@
1
+ """The phrasing experiment's design: rendering AGENTS.md per condition, and the rule each run is scored against."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ PHRASINGS = ("imperative", "prohibition", "reason")
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Cell:
16
+ repo: str
17
+ slot: str
18
+ condition: str # baseline | imperative | prohibition | reason
19
+ harness: str
20
+
21
+
22
+ def load(path: Path) -> dict[str, Any]:
23
+ data: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8"))
24
+ return data
25
+
26
+
27
+ def _one_line(text: str) -> str:
28
+ return " ".join(text.split())
29
+
30
+
31
+ def render(repo: dict[str, Any], slot: str | None, condition: str) -> tuple[str, int | None]:
32
+ """AGENTS.md for one run, and the 1-based line of the slot under test (None for the baseline).
33
+
34
+ The slot under test takes `condition`'s phrasing (or is left out for `baseline`); every other slot uses its
35
+ imperative phrasing; distractors never change.
36
+ """
37
+ lines = [*repo["header"].rstrip("\n").split("\n"), ""]
38
+ target_line = None
39
+ for key in repo["order"]:
40
+ if key in repo["distractors"]:
41
+ lines.append(f"- {_one_line(repo['distractors'][key])}")
42
+ continue
43
+ spec = repo["slots"][key]
44
+ if key == slot:
45
+ if condition == "baseline":
46
+ continue
47
+ lines.append(f"- {_one_line(spec[condition])}")
48
+ target_line = len(lines)
49
+ else:
50
+ lines.append(f"- {_one_line(spec['imperative'])}")
51
+ return "\n".join(lines) + "\n", target_line
52
+
53
+
54
+ def rule(design: dict[str, Any], repo_name: str, slot: str, condition: str) -> dict[str, Any]:
55
+ repo = design["repos"][repo_name]
56
+ spec = repo["slots"][slot]
57
+ _, line = render(repo, slot, condition if condition != "baseline" else "imperative")
58
+ text = _one_line(spec["imperative" if condition == "baseline" else condition])
59
+ return {
60
+ "id": f"{repo_name}--{slot}",
61
+ "text": text,
62
+ "source": {"file": "AGENTS.md", "lines": [line or 1, line or 1]},
63
+ "scope": {"paths": ["**"], "agents": None},
64
+ "checker": spec["checker"],
65
+ "status": "active",
66
+ "reason": None,
67
+ "origin": "user",
68
+ "probes": [
69
+ {
70
+ "id": f"{repo_name}--{slot}",
71
+ "template": "study",
72
+ "instruction": _one_line(spec["probe"]),
73
+ "setup_patch": None,
74
+ "domain": slot,
75
+ }
76
+ ],
77
+ }
78
+
79
+
80
+ def cells(
81
+ design: dict[str, Any],
82
+ *,
83
+ repos: list[str] | None = None,
84
+ slots: list[str] | None = None,
85
+ conditions: list[str] | None = None,
86
+ harnesses: list[str] | None = None,
87
+ ) -> list[Cell]:
88
+ out = []
89
+ for repo_name, repo in design["repos"].items():
90
+ if repos and repo_name not in repos:
91
+ continue
92
+ for slot in repo["slots"]:
93
+ if slots and slot not in slots:
94
+ continue
95
+ for condition in conditions or design["conditions"]:
96
+ for harness in harnesses or design["harnesses"]:
97
+ out.append(Cell(repo_name, slot, condition, harness))
98
+ return out