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/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)
|
obey_cli/study/design.py
ADDED
|
@@ -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
|