Skip to content

Commit d283a41

Browse files
committed
Merge feat/review-panels into main (PR thebeastagi#41: structured L3 review panels)
2 parents 520c5b6 + 6e9c70f commit d283a41

2 files changed

Lines changed: 523 additions & 0 deletions

File tree

src/sapiens/review.py

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
"""Structured L3 review panels (Phase 3 v1).
2+
3+
Replaces the single-shot L3 "review" self-check with a structured, multi-reviewer panel
4+
that scrutinises a candidate's accumulated evidence before any L3 promotion. Reviewers
5+
are role-specialised and produce typed objections; the panel applies a disagreement gate
6+
(unanimous endorsement required) over one or more rounds, with objections carried forward
7+
so reviewers can reconsider. The panel produces ledger-valid review ``Evidence``, so L3
8+
promotion still flows through the existing kernel/ledger transition guard — traceability
9+
is preserved.
10+
11+
Phase 0: reviewers are deterministic and operate on synthetic candidates and their
12+
real-evidence histories. A domain-theorist reviewer is deliberately out of scope here
13+
(it needs real domain knowledge, Phase 4). Nothing is claimed about nature.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import hashlib
19+
from dataclasses import dataclass, field
20+
from typing import Protocol, runtime_checkable
21+
22+
from .budget import ExecutionContext
23+
from .models import Candidate, Evidence
24+
25+
26+
def _identifier(*parts: object) -> str:
27+
return hashlib.sha256("|".join(map(str, parts)).encode()).hexdigest()[:20]
28+
29+
30+
@dataclass(frozen=True)
31+
class Objection:
32+
category: str # e.g. "not-significant", "effect-too-small", "leakage-risk", "overfitting-risk"
33+
severity: str # "minor" | "major" | "critical"
34+
rationale: str
35+
36+
37+
@dataclass(frozen=True)
38+
class ReviewOpinion:
39+
reviewer: str
40+
verdict: str # "endorse" | "object" | "abstain"
41+
objections: tuple[Objection, ...]
42+
confidence: float # in [0, 1]
43+
44+
45+
@dataclass(frozen=True)
46+
class ReviewReport:
47+
candidate_id: str
48+
rounds: int
49+
opinions_by_round: tuple[tuple[ReviewOpinion, ...], ...]
50+
verdict: str # "endorse" | "object"
51+
objections: tuple[Objection, ...] = field(default_factory=tuple)
52+
53+
@property
54+
def passed(self) -> bool:
55+
return self.verdict == "endorse"
56+
57+
58+
def _best_statistics(evidence: tuple[Evidence, ...]) -> tuple[float, float, int] | None:
59+
"""Most significant (smallest p-value) statistical evidence available, or None."""
60+
best: tuple[float, float, int] | None = None
61+
for item in evidence:
62+
if "r" in item.details and "pvalue" in item.details:
63+
r = float(item.details["r"])
64+
pvalue = float(item.details["pvalue"])
65+
n = int(item.details.get("n", 0))
66+
if best is None or pvalue < best[1]:
67+
best = (r, pvalue, n)
68+
return best
69+
70+
71+
@runtime_checkable
72+
class Reviewer(Protocol):
73+
"""A role-specialised reviewer that scrutinises a candidate's evidence history."""
74+
75+
@property
76+
def role(self) -> str: ...
77+
78+
def review(
79+
self,
80+
candidate: Candidate,
81+
evidence: tuple[Evidence, ...],
82+
*,
83+
seed: int,
84+
context: ExecutionContext,
85+
prior_objections: tuple[Objection, ...] = (),
86+
) -> ReviewOpinion: ...
87+
88+
89+
class StatisticianReviewer:
90+
"""Re-examines effect size, significance, and sample size from the validation stats."""
91+
92+
role = "statistician"
93+
94+
def __init__(
95+
self, *, alpha: float = 0.05, min_effect: float = 0.5, min_sample: int = 30
96+
) -> None:
97+
self.alpha = alpha
98+
self.min_effect = min_effect
99+
self.min_sample = min_sample
100+
101+
def review(
102+
self,
103+
candidate: Candidate,
104+
evidence: tuple[Evidence, ...],
105+
*,
106+
seed: int,
107+
context: ExecutionContext,
108+
prior_objections: tuple[Objection, ...] = (),
109+
) -> ReviewOpinion:
110+
context.checkpoint()
111+
stats = _best_statistics(evidence)
112+
objections: list[Objection] = []
113+
if stats is None:
114+
objections.append(
115+
Objection("no-statistical-evidence", "major", "no quantitative evidence to assess")
116+
)
117+
return ReviewOpinion(self.role, "object", tuple(objections), 0.0)
118+
r, pvalue, n = stats
119+
if pvalue > self.alpha:
120+
objections.append(
121+
Objection(
122+
"not-significant",
123+
"major",
124+
f"pvalue {pvalue:.2e} exceeds alpha {self.alpha}",
125+
)
126+
)
127+
if r < self.min_effect:
128+
objections.append(
129+
Objection("effect-too-small", "major", f"effect {r:.3f} below {self.min_effect}")
130+
)
131+
if n < self.min_sample:
132+
objections.append(
133+
Objection("insufficient-sample", "minor", f"n={n} below {self.min_sample}")
134+
)
135+
verdict = "endorse" if not objections else "object"
136+
return ReviewOpinion(self.role, verdict, tuple(objections), max(0.0, 1.0 - pvalue))
137+
138+
139+
class MethodologistReviewer:
140+
"""Scrutinises methodology: review-stage evidence must be held-out, not training data."""
141+
142+
role = "methodologist"
143+
144+
def review(
145+
self,
146+
candidate: Candidate,
147+
evidence: tuple[Evidence, ...],
148+
*,
149+
seed: int,
150+
context: ExecutionContext,
151+
prior_objections: tuple[Objection, ...] = (),
152+
) -> ReviewOpinion:
153+
context.checkpoint()
154+
objections: list[Objection] = []
155+
if not evidence:
156+
objections.append(Objection("no-evidence", "major", "no evidence history to review"))
157+
for item in evidence:
158+
if item.kind == "review" and item.dataset.endswith("train"):
159+
objections.append(
160+
Objection(
161+
"leakage-risk",
162+
"major",
163+
f"review evidence ({item.evidence_id}) computed on training data",
164+
)
165+
)
166+
verdict = "endorse" if not objections else "object"
167+
return ReviewOpinion(self.role, verdict, tuple(objections), 0.8)
168+
169+
170+
class DevilsAdvocateReviewer:
171+
"""Adversarial: demands overwhelming evidence and probes for alternative explanations."""
172+
173+
role = "devils-advocate"
174+
175+
def __init__(
176+
self, *, overwhelming_effect: float = 0.9, overwhelming_pvalue: float = 1e-6
177+
) -> None:
178+
self.overwhelming_effect = overwhelming_effect
179+
self.overwhelming_pvalue = overwhelming_pvalue
180+
181+
def review(
182+
self,
183+
candidate: Candidate,
184+
evidence: tuple[Evidence, ...],
185+
*,
186+
seed: int,
187+
context: ExecutionContext,
188+
prior_objections: tuple[Objection, ...] = (),
189+
) -> ReviewOpinion:
190+
context.checkpoint()
191+
stats = _best_statistics(evidence)
192+
objections: list[Objection] = []
193+
if stats is None:
194+
objections.append(
195+
Objection("no-statistical-evidence", "major", "nothing to cross-examine")
196+
)
197+
else:
198+
r, pvalue, _ = stats
199+
if r < self.overwhelming_effect or pvalue > self.overwhelming_pvalue:
200+
objections.append(
201+
Objection(
202+
"overfitting-risk",
203+
"major",
204+
"evidence not overwhelming; plausible alternative explanations remain",
205+
)
206+
)
207+
verdict = "endorse" if not objections else "object"
208+
return ReviewOpinion(self.role, verdict, tuple(objections), 0.5)
209+
210+
211+
class ReviewPanel:
212+
"""Runs reviewers over one or more rounds with a unanimous-endorsement disagreement gate."""
213+
214+
def __init__(self, reviewers: list[Reviewer], *, rounds: int = 1) -> None:
215+
if not reviewers:
216+
raise ValueError("at least one reviewer is required")
217+
if rounds <= 0:
218+
raise ValueError("rounds must be positive")
219+
self._reviewers = list(reviewers)
220+
self._rounds = rounds
221+
222+
def evaluate(
223+
self,
224+
candidate: Candidate,
225+
evidence: tuple[Evidence, ...],
226+
*,
227+
seed: int,
228+
context: ExecutionContext,
229+
) -> ReviewReport:
230+
opinions_by_round: list[tuple[ReviewOpinion, ...]] = []
231+
prior: tuple[Objection, ...] = ()
232+
verdict = "object"
233+
for round_index in range(self._rounds):
234+
round_opinions: list[ReviewOpinion] = []
235+
for index, reviewer in enumerate(self._reviewers):
236+
opinion = reviewer.review(
237+
candidate,
238+
evidence,
239+
seed=seed + (round_index + 1) * 1000 + index,
240+
context=context,
241+
prior_objections=prior,
242+
)
243+
round_opinions.append(opinion)
244+
opinions_by_round.append(tuple(round_opinions))
245+
if all(opinion.verdict == "endorse" for opinion in round_opinions):
246+
verdict = "endorse"
247+
break
248+
prior = tuple(o for opinion in round_opinions for o in opinion.objections)
249+
return ReviewReport(
250+
candidate_id=candidate.candidate_id,
251+
rounds=len(opinions_by_round),
252+
opinions_by_round=tuple(opinions_by_round),
253+
verdict=verdict,
254+
objections=prior,
255+
)
256+
257+
258+
def review_evidence(report: ReviewReport, candidate: Candidate, *, seed: int) -> Evidence:
259+
"""Build ledger-valid review Evidence reflecting the panel's verdict."""
260+
reviewers = sorted(
261+
{opinion.reviewer for round_ in report.opinions_by_round for opinion in round_}
262+
)
263+
categories = sorted({o.category for o in report.objections})
264+
return Evidence(
265+
_identifier(candidate.candidate_id, "panel-review", seed),
266+
candidate.candidate_id,
267+
"review",
268+
report.passed,
269+
"panel-review-v1",
270+
"synthetic-panel",
271+
seed,
272+
1.0 if report.passed else 0.0,
273+
{
274+
"verdict": report.verdict,
275+
"rounds": report.rounds,
276+
"reviewers": tuple(reviewers),
277+
"objection_categories": tuple(categories),
278+
"objection_count": len(report.objections),
279+
},
280+
)
281+
282+
283+
@dataclass(frozen=True)
284+
class CatchReport:
285+
good_total: int
286+
good_endorsed: int
287+
bad_total: int
288+
bad_rejected: int
289+
290+
@property
291+
def true_endorse_rate(self) -> float:
292+
return self.good_endorsed / self.good_total if self.good_total else 0.0
293+
294+
@property
295+
def catch_rate(self) -> float:
296+
return self.bad_rejected / self.bad_total if self.bad_total else 0.0
297+
298+
299+
def catch_rate(
300+
panel: ReviewPanel,
301+
good: list[Candidate],
302+
bad: list[Candidate],
303+
evidence_for: object,
304+
*,
305+
seed: int,
306+
context: ExecutionContext,
307+
) -> CatchReport:
308+
"""Score the panel itself: endorse known-good, reject known-bad candidates.
309+
310+
``evidence_for`` maps a candidate to its evidence history (``Callable[[Candidate],
311+
tuple[Evidence, ...]]``).
312+
"""
313+
good_endorsed = sum(
314+
1
315+
for candidate in good
316+
if panel.evaluate(candidate, evidence_for(candidate), seed=seed, context=context).passed
317+
)
318+
bad_rejected = sum(
319+
1
320+
for candidate in bad
321+
if not panel.evaluate(
322+
candidate, evidence_for(candidate), seed=seed + 1, context=context
323+
).passed
324+
)
325+
return CatchReport(len(good), good_endorsed, len(bad), bad_rejected)

0 commit comments

Comments
 (0)