open-interpretability 0.0.20__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.
- open_interpretability-0.0.20.dist-info/METADATA +157 -0
- open_interpretability-0.0.20.dist-info/RECORD +256 -0
- open_interpretability-0.0.20.dist-info/WHEEL +4 -0
- open_interpretability-0.0.20.dist-info/entry_points.txt +2 -0
- open_interpretability-0.0.20.dist-info/licenses/LICENSE +21 -0
- openinterp/__init__.py +6 -0
- openinterp/agents/__init__.py +25 -0
- openinterp/agents/base.py +229 -0
- openinterp/agents/judge.py +149 -0
- openinterp/agents/proposer.py +471 -0
- openinterp/agents/proposer_profiles.py +79 -0
- openinterp/agents/review_measurements.py +173 -0
- openinterp/agents/reviewer.py +498 -0
- openinterp/agreement.py +63 -0
- openinterp/audit_options.py +79 -0
- openinterp/baseline.py +254 -0
- openinterp/benchmark.py +450 -0
- openinterp/blends.py +239 -0
- openinterp/builder/__init__.py +6 -0
- openinterp/builder/agents.py +260 -0
- openinterp/builder/aligned.py +41 -0
- openinterp/builder/charts.py +411 -0
- openinterp/builder/circuit_pages.py +37 -0
- openinterp/builder/circuits.py +264 -0
- openinterp/builder/compiler.py +495 -0
- openinterp/builder/corpora.py +98 -0
- openinterp/builder/coverage.py +46 -0
- openinterp/builder/dashboard.py +255 -0
- openinterp/builder/disk_memo.py +65 -0
- openinterp/builder/experiment.py +105 -0
- openinterp/builder/heat.py +121 -0
- openinterp/builder/hrefs.py +71 -0
- openinterp/builder/learned_baseline.py +204 -0
- openinterp/builder/model_facts.py +138 -0
- openinterp/builder/neuronpedia_export.py +107 -0
- openinterp/builder/parallel.py +100 -0
- openinterp/builder/parquet_export.py +458 -0
- openinterp/builder/pieces.py +127 -0
- openinterp/builder/pii.py +211 -0
- openinterp/builder/registry_stats.py +144 -0
- openinterp/builder/research.py +109 -0
- openinterp/builder/sections.py +28 -0
- openinterp/builder/significance.py +103 -0
- openinterp/builder/sister_graph.py +180 -0
- openinterp/builder/standing.py +185 -0
- openinterp/builder/statuses.py +34 -0
- openinterp/builder/stopped.py +91 -0
- openinterp/builder/stops.py +30 -0
- openinterp/builder/superpositions.py +102 -0
- openinterp/builder/templates/_agent_role_cells.html +9 -0
- openinterp/builder/templates/_causal_chart.html +67 -0
- openinterp/builder/templates/_circuit.html +8 -0
- openinterp/builder/templates/_dashboard.html +133 -0
- openinterp/builder/templates/_experiment.html +500 -0
- openinterp/builder/templates/_macros.html +82 -0
- openinterp/builder/templates/_patchscope.html +33 -0
- openinterp/builder/templates/_prior_art.html +24 -0
- openinterp/builder/templates/_specificity.html +21 -0
- openinterp/builder/templates/_steering.html +34 -0
- openinterp/builder/templates/_superposition.html +62 -0
- openinterp/builder/templates/_vocab_chart.html +88 -0
- openinterp/builder/templates/_wiring.html +44 -0
- openinterp/builder/templates/about.html +95 -0
- openinterp/builder/templates/agent.html +69 -0
- openinterp/builder/templates/agents.html +80 -0
- openinterp/builder/templates/base.html +103 -0
- openinterp/builder/templates/circuit.html +64 -0
- openinterp/builder/templates/circuits.html +46 -0
- openinterp/builder/templates/experiment.html +21 -0
- openinterp/builder/templates/hub.html +16 -0
- openinterp/builder/templates/index.html +387 -0
- openinterp/builder/templates/layer.html +69 -0
- openinterp/builder/templates/learned-baseline.html +44 -0
- openinterp/builder/templates/model.html +151 -0
- openinterp/builder/templates/models.html +47 -0
- openinterp/builder/templates/neuron.html +234 -0
- openinterp/builder/templates/paper.html +39 -0
- openinterp/builder/templates/redirect.html +1 -0
- openinterp/builder/templates/research.html +31 -0
- openinterp/builder/templates/user.html +54 -0
- openinterp/builder/templates/users.html +39 -0
- openinterp/builder/templates/wiki/experiment.md +271 -0
- openinterp/builder/templates/wiki/index.md +32 -0
- openinterp/builder/templates/wiki/neuron.md +30 -0
- openinterp/builder/templates/wiki/stop.md +36 -0
- openinterp/builder/tree.py +470 -0
- openinterp/builder/users.py +142 -0
- openinterp/builder/wiki.py +427 -0
- openinterp/checks.py +429 -0
- openinterp/cli.py +494 -0
- openinterp/cli_baseline.py +332 -0
- openinterp/cli_benchmark.py +300 -0
- openinterp/cli_build.py +128 -0
- openinterp/cli_check.py +51 -0
- openinterp/cli_concepts.py +76 -0
- openinterp/cli_corpus.py +252 -0
- openinterp/cli_costs.py +257 -0
- openinterp/cli_coverage.py +76 -0
- openinterp/cli_crossref.py +86 -0
- openinterp/cli_errata.py +142 -0
- openinterp/cli_evidence.py +495 -0
- openinterp/cli_evidence_store.py +93 -0
- openinterp/cli_explain.py +101 -0
- openinterp/cli_loop.py +125 -0
- openinterp/cli_memory.py +28 -0
- openinterp/cli_neuronpedia.py +175 -0
- openinterp/cli_pause.py +45 -0
- openinterp/cli_pole_effects.py +133 -0
- openinterp/cli_probing.py +119 -0
- openinterp/cli_registry.py +348 -0
- openinterp/cli_reproduce.py +446 -0
- openinterp/cli_review.py +302 -0
- openinterp/cli_scan.py +223 -0
- openinterp/cli_score.py +446 -0
- openinterp/cli_sisters.py +86 -0
- openinterp/cli_submission.py +217 -0
- openinterp/cli_superpositions.py +118 -0
- openinterp/cli_wiring.py +54 -0
- openinterp/codeid.py +73 -0
- openinterp/concepts.py +400 -0
- openinterp/core/__init__.py +59 -0
- openinterp/core/agent_names.py +76 -0
- openinterp/core/attribution.py +101 -0
- openinterp/core/cache_dirs.py +15 -0
- openinterp/core/causal.py +82 -0
- openinterp/core/confounds.py +103 -0
- openinterp/core/contributors.py +26 -0
- openinterp/core/corpora.py +126 -0
- openinterp/core/costs.py +430 -0
- openinterp/core/draws.py +109 -0
- openinterp/core/errata.py +244 -0
- openinterp/core/evidence.py +160 -0
- openinterp/core/examples.py +93 -0
- openinterp/core/exceptions.py +29 -0
- openinterp/core/explanation.py +130 -0
- openinterp/core/fence.py +42 -0
- openinterp/core/heavy_lock.py +114 -0
- openinterp/core/identifiers.py +198 -0
- openinterp/core/isolation.py +149 -0
- openinterp/core/ledger.py +130 -0
- openinterp/core/licenses.py +28 -0
- openinterp/core/manifold.py +117 -0
- openinterp/core/partners.py +32 -0
- openinterp/core/patchscope.py +59 -0
- openinterp/core/paths.py +40 -0
- openinterp/core/pause.py +96 -0
- openinterp/core/pricing.py +178 -0
- openinterp/core/prior_art.py +44 -0
- openinterp/core/probing.py +146 -0
- openinterp/core/profiles.py +221 -0
- openinterp/core/project.py +28 -0
- openinterp/core/run_environment.py +38 -0
- openinterp/core/schemas.py +484 -0
- openinterp/core/specificity.py +49 -0
- openinterp/core/stats.py +172 -0
- openinterp/core/steering.py +49 -0
- openinterp/core/strict.py +19 -0
- openinterp/core/takedowns.py +136 -0
- openinterp/core/transcripts.py +130 -0
- openinterp/core/wiring.py +80 -0
- openinterp/corpus.py +476 -0
- openinterp/corpus_cache.py +145 -0
- openinterp/corpus_layouts.py +108 -0
- openinterp/corpus_presets.py +168 -0
- openinterp/corpus_rebuild.py +93 -0
- openinterp/coverage.py +155 -0
- openinterp/crossref.py +442 -0
- openinterp/data/__init__.py +9 -0
- openinterp/data/offensive_words.txt +58 -0
- openinterp/data/public_suffix_list.dat +15771 -0
- openinterp/data/sample_corpus.txt +40 -0
- openinterp/draw_steps.py +359 -0
- openinterp/errata_checks.py +129 -0
- openinterp/evidence.py +462 -0
- openinterp/evidence_branch.py +119 -0
- openinterp/evidence_lenses.py +75 -0
- openinterp/experiment_loop.py +357 -0
- openinterp/explain.py +252 -0
- openinterp/half_precision.py +79 -0
- openinterp/harness/__init__.py +37 -0
- openinterp/harness/ane/__init__.py +9 -0
- openinterp/harness/ane/backend.py +324 -0
- openinterp/harness/ane/cache.py +276 -0
- openinterp/harness/ane/chunks.py +163 -0
- openinterp/harness/ane/convert.py +116 -0
- openinterp/harness/ane/forward.py +258 -0
- openinterp/harness/ane/qualified.py +40 -0
- openinterp/harness/annotations.py +329 -0
- openinterp/harness/attribution.py +96 -0
- openinterp/harness/causal.py +264 -0
- openinterp/harness/confounds.py +184 -0
- openinterp/harness/covariance.py +301 -0
- openinterp/harness/crossref.py +195 -0
- openinterp/harness/cuda_devices.py +88 -0
- openinterp/harness/dead.py +124 -0
- openinterp/harness/devices.py +401 -0
- openinterp/harness/dictionaries.py +496 -0
- openinterp/harness/dreaming.py +116 -0
- openinterp/harness/environment.py +64 -0
- openinterp/harness/explanation_scores.py +371 -0
- openinterp/harness/features.py +276 -0
- openinterp/harness/first_position.py +137 -0
- openinterp/harness/heads.py +226 -0
- openinterp/harness/hooks.py +499 -0
- openinterp/harness/input_attribution.py +367 -0
- openinterp/harness/jlens.py +183 -0
- openinterp/harness/layer_cache.py +301 -0
- openinterp/harness/layer_major.py +274 -0
- openinterp/harness/lookahead.py +93 -0
- openinterp/harness/manifold.py +200 -0
- openinterp/harness/memory.py +471 -0
- openinterp/harness/moe.py +134 -0
- openinterp/harness/patcher.py +322 -0
- openinterp/harness/patchscope.py +177 -0
- openinterp/harness/probing.py +383 -0
- openinterp/harness/scan.py +462 -0
- openinterp/harness/scan_backend.py +201 -0
- openinterp/harness/sister_passes.py +463 -0
- openinterp/harness/sisters.py +363 -0
- openinterp/harness/sites.py +101 -0
- openinterp/harness/specificity.py +107 -0
- openinterp/harness/steering.py +159 -0
- openinterp/harness/superpositions.py +116 -0
- openinterp/harness/units.py +203 -0
- openinterp/harness/wiring.py +432 -0
- openinterp/harness/wiring_activations.py +285 -0
- openinterp/langid.py +73 -0
- openinterp/names.py +129 -0
- openinterp/neuronpedia_baseline.py +88 -0
- openinterp/neuronpedia_review.py +148 -0
- openinterp/planner.py +466 -0
- openinterp/planner_effects.py +89 -0
- openinterp/planner_heads.py +92 -0
- openinterp/planner_models.py +174 -0
- openinterp/planner_sisters.py +122 -0
- openinterp/planner_states.py +57 -0
- openinterp/planner_superpositions.py +114 -0
- openinterp/pole_effects.py +102 -0
- openinterp/prior_art.py +143 -0
- openinterp/reproduce.py +450 -0
- openinterp/revision.py +45 -0
- openinterp/runner_cache.py +35 -0
- openinterp/score_controls.py +169 -0
- openinterp/sensitive.py +243 -0
- openinterp/show.py +236 -0
- openinterp/signing.py +209 -0
- openinterp/sisters.py +476 -0
- openinterp/superposition_finders.py +225 -0
- openinterp/sync/__init__.py +5 -0
- openinterp/sync/r2.py +280 -0
- openinterp/syntax.py +399 -0
- openinterp/takedown_checks.py +76 -0
- openinterp/toxicity.py +158 -0
- openinterp/verdict_regression.py +79 -0
- openinterp/wiring_backfill.py +170 -0
- openinterp/wordnet.py +362 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""What the reviewer measures on the model.
|
|
2
|
+
|
|
3
|
+
It measures probe and counterfactual activity, the lookahead check, and the causal and
|
|
4
|
+
wiring readouts. :mod:`openinterp.agents.reviewer` turns these into metrics and a verdict.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from functools import partial
|
|
11
|
+
|
|
12
|
+
from openinterp.core.exceptions import ArchitectureNotSupportedError
|
|
13
|
+
from openinterp.core.schemas import AdversarialTestResult, ReviewPolicy
|
|
14
|
+
from openinterp.harness.causal import causal_metrics
|
|
15
|
+
from openinterp.harness.covariance import joint_ablation
|
|
16
|
+
from openinterp.harness.layer_major import map_layer_major, prompt_tokens
|
|
17
|
+
from openinterp.harness.lookahead import ablation_profile
|
|
18
|
+
from openinterp.harness.scan import has_first_token_floor
|
|
19
|
+
from openinterp.harness.units import peak_strength, submission_unit
|
|
20
|
+
from openinterp.harness.wiring_activations import activation_wiring
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def settle_first_token_floor(submission, runner, policy: ReviewPolicy):
|
|
24
|
+
"""Return (submission, unit, scale).
|
|
25
|
+
|
|
26
|
+
If the scan predates protocol 0.8, ``first_token_floor`` is judged here. From 0.8, the
|
|
27
|
+
scan judges it.
|
|
28
|
+
"""
|
|
29
|
+
unit = submission_unit(submission)
|
|
30
|
+
scale = submission.max_activation or max(
|
|
31
|
+
peak_strength(runner, unit, p.base_prompt) or 0.0 for p in submission.counterfactuals
|
|
32
|
+
)
|
|
33
|
+
if submission.first_token_floor is None:
|
|
34
|
+
read = partial(unit.strengths, runner)
|
|
35
|
+
floor = has_first_token_floor(runner, read, scale, policy)
|
|
36
|
+
submission = submission.model_copy(update={"first_token_floor": floor})
|
|
37
|
+
unit = submission_unit(submission)
|
|
38
|
+
return submission, unit, scale
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def measure_probes(runner, unit, probes, scale: float, threshold: float):
|
|
42
|
+
"""Each probe's result, plus the measured probes and whether each was active.
|
|
43
|
+
|
|
44
|
+
A one-token prompt to a unit with a first-token floor has nothing measured: it is
|
|
45
|
+
recorded as unmeasured and counts in no metric.
|
|
46
|
+
"""
|
|
47
|
+
results: list[AdversarialTestResult] = []
|
|
48
|
+
measured_probes, actual = [], []
|
|
49
|
+
texts = [probe.text for probe in probes]
|
|
50
|
+
strengths = map_layer_major(
|
|
51
|
+
runner, partial(peak_strength, runner, unit), texts,
|
|
52
|
+
tokens=prompt_tokens(runner, texts), logits=False,
|
|
53
|
+
) # fmt: skip
|
|
54
|
+
for probe, strength in zip(probes, strengths, strict=True):
|
|
55
|
+
active = strength is not None and scale > 0 and strength >= threshold
|
|
56
|
+
if strength is not None:
|
|
57
|
+
measured_probes.append(probe)
|
|
58
|
+
actual.append(active)
|
|
59
|
+
results.append(
|
|
60
|
+
AdversarialTestResult(
|
|
61
|
+
kind="positive_holdout" if probe.expected_active else "hard_negative",
|
|
62
|
+
prompt=probe.text,
|
|
63
|
+
passed=strength is not None and active == probe.expected_active,
|
|
64
|
+
expected_active=probe.expected_active,
|
|
65
|
+
activation=None if strength is None else round(strength, 6),
|
|
66
|
+
detail=probe.rationale,
|
|
67
|
+
measured=strength is not None,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return results, measured_probes, actual
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def measure_counterfactuals(runner, unit, submission, scale: float, policy: ReviewPolicy):
|
|
74
|
+
"""Measure each counterfactual pair's Δ, and whether it matches the expected Δ."""
|
|
75
|
+
results = []
|
|
76
|
+
pairs = submission.counterfactuals
|
|
77
|
+
texts = [t for pair in pairs for t in (pair.base_prompt, pair.counterfactual_prompt)]
|
|
78
|
+
strengths = map_layer_major(
|
|
79
|
+
runner, partial(peak_strength, runner, unit), texts,
|
|
80
|
+
tokens=prompt_tokens(runner, texts), logits=False,
|
|
81
|
+
) # fmt: skip
|
|
82
|
+
for pair, base, cf in zip(pairs, strengths[::2], strengths[1::2], strict=True):
|
|
83
|
+
both = base is not None and cf is not None
|
|
84
|
+
observed = (base - cf) / scale if both and scale > 0 else 0.0
|
|
85
|
+
results.append(
|
|
86
|
+
AdversarialTestResult(
|
|
87
|
+
kind="counterfactual",
|
|
88
|
+
prompt=f"{pair.base_prompt} -> {pair.counterfactual_prompt}",
|
|
89
|
+
passed=both and scale > 0 and _delta_agrees(pair.expected_delta, observed, policy),
|
|
90
|
+
expected_delta=pair.expected_delta,
|
|
91
|
+
observed_delta=round(observed, 6) if both else None,
|
|
92
|
+
measured=both,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
return results
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def lookahead_result(runner, unit, submission) -> AdversarialTestResult:
|
|
99
|
+
"""Whether ablating the unit on the base prompts agrees with the claimed mechanism."""
|
|
100
|
+
prompts = [p.base_prompt for p in submission.counterfactuals]
|
|
101
|
+
measured = ablation_profile(runner, prompts, unit.ablation(runner))
|
|
102
|
+
claimed = submission.lookahead.is_lookahead
|
|
103
|
+
return AdversarialTestResult(
|
|
104
|
+
kind="lookahead_consistency",
|
|
105
|
+
prompt="; ".join(p.base_prompt for p in submission.counterfactuals),
|
|
106
|
+
passed=measured.is_lookahead == claimed,
|
|
107
|
+
detail=(
|
|
108
|
+
f"claimed {'lookahead' if claimed else 'retrospective'}; measured "
|
|
109
|
+
f"{'lookahead' if measured.is_lookahead else 'retrospective'} "
|
|
110
|
+
f"(KL={measured.kl_divergence:.4g}, tokens={measured.target_upcoming_tokens[:5]})"
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def causal_readouts(submission, runner, probes, policy: ReviewPolicy, scale: float):
|
|
116
|
+
"""(causal metrics, joint ablation with the unit's correlated partners)."""
|
|
117
|
+
causal = causal_metrics(
|
|
118
|
+
submission,
|
|
119
|
+
runner,
|
|
120
|
+
freeze_downstream=policy.freeze_downstream,
|
|
121
|
+
null_neurons=policy.causal_null_neurons,
|
|
122
|
+
min_change=policy.delta_abs_tolerance * scale,
|
|
123
|
+
)
|
|
124
|
+
return causal, _joint_ablation(submission, runner, probes, policy)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _joint_ablation(submission, runner, probes, policy):
|
|
128
|
+
# Ranked by co-activation lift (the scan's order); Spearman is the fallback signal.
|
|
129
|
+
partners: list[int] = []
|
|
130
|
+
for c in submission.correlated_neurons:
|
|
131
|
+
lifted = c.coactivation_lift_lower >= policy.min_partner_lift and (
|
|
132
|
+
c.coactive_tokens >= policy.min_partner_coactive
|
|
133
|
+
)
|
|
134
|
+
if (lifted or abs(c.spearman) >= policy.min_partner_correlation) and (
|
|
135
|
+
c.neuron_idx not in partners
|
|
136
|
+
):
|
|
137
|
+
partners.append(c.neuron_idx)
|
|
138
|
+
partners = partners[: policy.joint_ablation_partners]
|
|
139
|
+
if not partners or submission.identifier.kind != "mlp_neuron_pole":
|
|
140
|
+
return None
|
|
141
|
+
ident = submission.identifier
|
|
142
|
+
prompts = [p.base_prompt for p in submission.counterfactuals]
|
|
143
|
+
prompts += [p.text for p in probes if p.expected_active]
|
|
144
|
+
return joint_ablation(runner, prompts, ident.layer, ident.neuron_idx, ident.pole, partners)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _activation_wiring(submission, runner, probes, threshold):
|
|
148
|
+
"""Return wiring by attribution at the unit's peak, and how many prompts it fired on.
|
|
149
|
+
|
|
150
|
+
The prompts are those that it should fire on: the counterfactuals' base prompts and
|
|
151
|
+
the holdouts.
|
|
152
|
+
"""
|
|
153
|
+
if threshold <= 0:
|
|
154
|
+
return None
|
|
155
|
+
prompts = [p.base_prompt for p in submission.counterfactuals]
|
|
156
|
+
prompts += [p.text for p in probes if p.expected_active]
|
|
157
|
+
try:
|
|
158
|
+
return activation_wiring(runner, submission_unit(submission), prompts, threshold)
|
|
159
|
+
except ArchitectureNotSupportedError:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _delta_agrees(expected: float, observed: float, policy: ReviewPolicy) -> bool:
|
|
164
|
+
"""Observed Δ must land within max(abs_tol, rel_tol·|expected|) of the prediction.
|
|
165
|
+
|
|
166
|
+
Non-control pairs (|expected| > abs_tol) must also have the predicted sign.
|
|
167
|
+
"""
|
|
168
|
+
tolerance = max(policy.delta_abs_tolerance, policy.delta_rel_tolerance * abs(expected))
|
|
169
|
+
if abs(observed - expected) > tolerance:
|
|
170
|
+
return False
|
|
171
|
+
if abs(expected) <= policy.delta_abs_tolerance:
|
|
172
|
+
return True
|
|
173
|
+
return math.copysign(1, observed) == math.copysign(1, expected)
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""Reviewer agent: stress-tests a submitted hypothesis against the model itself.
|
|
2
|
+
|
|
3
|
+
The LLM's only job is to write adversarial probes. :func:`evaluate_submission` computes
|
|
4
|
+
the verdict deterministically from measurements. It uses a :class:`ReviewPolicy`, which
|
|
5
|
+
is stored in the verdict. So any decision can be reproduced from the record and the model
|
|
6
|
+
weights (see docs/PROTOCOL.md).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from openinterp import prior_art, wordnet
|
|
17
|
+
from openinterp.agents.base import LLMAgent
|
|
18
|
+
from openinterp.agents.proposer import (
|
|
19
|
+
FIRST_TOKEN_UNMEASURED,
|
|
20
|
+
format_input_attribution,
|
|
21
|
+
format_review_examples,
|
|
22
|
+
)
|
|
23
|
+
from openinterp.agents.review_measurements import ( # noqa: F401 (_delta_agrees re-exported)
|
|
24
|
+
_activation_wiring,
|
|
25
|
+
_delta_agrees,
|
|
26
|
+
causal_readouts,
|
|
27
|
+
lookahead_result,
|
|
28
|
+
measure_counterfactuals,
|
|
29
|
+
measure_probes,
|
|
30
|
+
settle_first_token_floor,
|
|
31
|
+
)
|
|
32
|
+
from openinterp.core import costs
|
|
33
|
+
from openinterp.core.agent_names import same_agent
|
|
34
|
+
from openinterp.core.confounds import LANGUAGE, SYNTAX_NODE
|
|
35
|
+
from openinterp.core.fence import FENCE_RULE, fence
|
|
36
|
+
from openinterp.core.schemas import (
|
|
37
|
+
MAX_PROBES,
|
|
38
|
+
AdversarialTestResult,
|
|
39
|
+
NeuronAuditSubmission,
|
|
40
|
+
ReviewMetrics,
|
|
41
|
+
ReviewPolicy,
|
|
42
|
+
VerdictStatus,
|
|
43
|
+
VerificationVerdict,
|
|
44
|
+
scan_active_share,
|
|
45
|
+
)
|
|
46
|
+
from openinterp.core.stats import balanced_accuracy, effective_counts
|
|
47
|
+
from openinterp.harness.confounds import (
|
|
48
|
+
LENGTH_RATIO_WARN, # noqa: F401 - re-exported
|
|
49
|
+
baseline_predictions,
|
|
50
|
+
length_predictions,
|
|
51
|
+
length_ratio, # noqa: F401 - re-exported
|
|
52
|
+
length_warning,
|
|
53
|
+
)
|
|
54
|
+
from openinterp.harness.environment import run_environment
|
|
55
|
+
from openinterp.harness.hooks import NeuronHookRunner
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AdversarialProbe(BaseModel): # noqa: D101 (a docstring would join the JSON schema that the agent reads)
|
|
59
|
+
text: str
|
|
60
|
+
expected_active: bool = Field(
|
|
61
|
+
description="What the *hypothesis* predicts. Hard negatives are near-misses predicted "
|
|
62
|
+
"inactive; positive holdouts are novel cases predicted active."
|
|
63
|
+
)
|
|
64
|
+
rationale: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def split(n: int) -> tuple[int, int]:
|
|
68
|
+
"""The hard negatives and positive holdouts that a set of ``n`` probes asks for."""
|
|
69
|
+
holdouts = max(4, round(n / 3)) # the system prompt asks for at least four
|
|
70
|
+
return n - holdouts, holdouts
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ProbeSet(BaseModel): # noqa: D101 (a docstring would join the JSON schema that the agent reads)
|
|
74
|
+
probes: list[AdversarialProbe] = Field(min_length=10, max_length=MAX_PROBES)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
DEFAULT_POLICY = ReviewPolicy()
|
|
78
|
+
|
|
79
|
+
# Probes in two groups of very different lengths can be told apart by length alone. Since
|
|
80
|
+
# protocol 0.9 the confound baseline includes that rule (issue #3525), so the reviewer is
|
|
81
|
+
# asked to match lengths (and warned: harness.confounds.length_warning).
|
|
82
|
+
LENGTH_MATCHED = (
|
|
83
|
+
" Give the hard negatives and the positive holdouts similar lengths in tokens. The "
|
|
84
|
+
"review compares the hypothesis with a rule that reads only a probe's length. If your "
|
|
85
|
+
"holdouts are much longer than your negatives, that rule can explain them as well as "
|
|
86
|
+
"the hypothesis does, and the hypothesis fails."
|
|
87
|
+
)
|
|
88
|
+
HEAD_ACTIVITY = (
|
|
89
|
+
"A head counts as active at a token when its write to the residual stream there is far "
|
|
90
|
+
"from its average write: what it attends to and copies at that token is unusual. A "
|
|
91
|
+
"probe is active if any of its tokens after the first is. Probes are measured on their "
|
|
92
|
+
"own, so any earlier context a probe relies on (e.g. a repeated phrase) must be inside "
|
|
93
|
+
"it.\n"
|
|
94
|
+
"The head reads tokens, not words. When a hypothesis talks about repeats or matches, "
|
|
95
|
+
"they are of tokens, and a token usually includes the space before it, so ' My' "
|
|
96
|
+
"(after a space) and 'My' (at the start of the text or after a newline) are different "
|
|
97
|
+
"tokens, while '. My' later in the text repeats '. My' earlier, however short or "
|
|
98
|
+
"common. Label each probe by the repeats its tokens make, not by the words you read.\n"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
LANGUAGE_PROBES = (
|
|
102
|
+
"The scan corpus is labelled by language, and each example above gives its language. "
|
|
103
|
+
"If the hypothesis claims the feature whatever the language, include positive holdouts "
|
|
104
|
+
"that express it in other languages, translated. If it names or implies one language, "
|
|
105
|
+
"include hard negatives in another language written in the same script (e.g. Spanish "
|
|
106
|
+
"against Portuguese, Russian against Ukrainian) that keep the examples' words and "
|
|
107
|
+
"topic as far as that language allows.\n"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
SYNTAX_PROBES = (
|
|
112
|
+
"The scan corpus is labelled by syntax, and each code example above gives its peak "
|
|
113
|
+
"token's syntax node, bracket depths, indent depth and syntax construct. Include hard "
|
|
114
|
+
"negatives that keep the token and change its syntactic role: `return` as a keyword, "
|
|
115
|
+
"inside a string and inside a comment. If the hypothesis claims a feature whatever the "
|
|
116
|
+
"programming language, include positive holdouts in another programming language.\n"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def has_syntax_labels(submission: NeuronAuditSubmission) -> bool:
|
|
121
|
+
"""Does a top example say what syntax its peak token has?"""
|
|
122
|
+
return any(SYNTAX_NODE in (e.peak_labels or {}) for e in submission.top_examples)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def has_language_labels(submission: NeuronAuditSubmission) -> bool:
|
|
126
|
+
"""Do the submission's top examples say which language each is in?"""
|
|
127
|
+
return any(LANGUAGE in (e.peak_labels or {}) for e in submission.top_examples)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ReviewerAgent(LLMAgent):
|
|
131
|
+
"""The reviewer: writes probes designed to break the hypothesis."""
|
|
132
|
+
|
|
133
|
+
role = "reviewer"
|
|
134
|
+
system_prompt = (
|
|
135
|
+
"You are an adversarial peer reviewer for mechanistic interpretability claims. Given "
|
|
136
|
+
"a hypothesis about what makes a neuron pole (or an attention head, or a sparse "
|
|
137
|
+
"dictionary latent) fire, write probes "
|
|
138
|
+
"designed to break it. "
|
|
139
|
+
"Two thirds of the probes must be hard negatives: near-miss sentences that share "
|
|
140
|
+
"surface features with the activating examples (same words in other senses, same "
|
|
141
|
+
"topic without the claimed feature, the feature in an unusual position) but which "
|
|
142
|
+
"the hypothesis predicts should NOT activate the pole. The rest (at least four) are "
|
|
143
|
+
"positive holdouts: novel contexts unlike the provided examples that the hypothesis "
|
|
144
|
+
"predicts SHOULD activate it, including paraphrases of the claimed feature. Label each "
|
|
145
|
+
"probe with what the hypothesis predicts, not with what you believe the neuron will do. "
|
|
146
|
+
+ FENCE_RULE
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def generate_probes(
|
|
150
|
+
self,
|
|
151
|
+
submission: NeuronAuditSubmission,
|
|
152
|
+
n: int = MAX_PROBES,
|
|
153
|
+
full_snippets: bool = False,
|
|
154
|
+
related: str = "",
|
|
155
|
+
) -> ProbeSet:
|
|
156
|
+
"""Ask the reviewer for ``n`` probes."""
|
|
157
|
+
prompt = self.probe_prompt(submission, n, full_snippets, related)
|
|
158
|
+
return self.structured(ProbeSet, prompt)
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def probe_prompt(
|
|
162
|
+
submission: NeuronAuditSubmission,
|
|
163
|
+
n: int = MAX_PROBES,
|
|
164
|
+
full_snippets: bool = False,
|
|
165
|
+
related: str = "",
|
|
166
|
+
) -> str:
|
|
167
|
+
"""Return the task text that the reviewer answers.
|
|
168
|
+
|
|
169
|
+
It shows no activations of candidate probes. ``related`` is what other analyses
|
|
170
|
+
link to the unit, so that the reviewer can probe where they disagree. That is
|
|
171
|
+
published latents aligned with the unit
|
|
172
|
+
(:func:`openinterp.crossref.format_for_reviewer`), and its studied sister neurons
|
|
173
|
+
(:func:`openinterp.sisters.format_sisters`). The submission's prior art is always
|
|
174
|
+
shown (:func:`openinterp.prior_art.format_for_reviewer`).
|
|
175
|
+
"""
|
|
176
|
+
mechanism = "lookahead" if submission.lookahead.is_lookahead else "retrospective"
|
|
177
|
+
claim = (
|
|
178
|
+
f"Hypothesis: {submission.natural_language_explanation}\n"
|
|
179
|
+
f"Claimed activating tokens: {submission.activating_tokens}\n"
|
|
180
|
+
f"Claimed mechanism: {mechanism}"
|
|
181
|
+
f" {submission.lookahead.target_upcoming_tokens or ''}"
|
|
182
|
+
)
|
|
183
|
+
is_head = submission.identifier.kind == "attn_head"
|
|
184
|
+
context = ""
|
|
185
|
+
if submission.input_attribution is not None:
|
|
186
|
+
context = format_input_attribution(submission.input_attribution)
|
|
187
|
+
if context:
|
|
188
|
+
context += (
|
|
189
|
+
"Probes are measured on their own, so a probe contains only the context "
|
|
190
|
+
"you write into it: vary the tokens above, not just the peak token.\n"
|
|
191
|
+
)
|
|
192
|
+
return (
|
|
193
|
+
f"{submission.identifier.noun.capitalize()}: {submission.identifier}\n"
|
|
194
|
+
+ (HEAD_ACTIVITY if is_head else "")
|
|
195
|
+
+ (FIRST_TOKEN_UNMEASURED if submission.first_token_floor else "")
|
|
196
|
+
+ f"The submitted hypothesis, which your probes test:\n{fence(claim)}\n"
|
|
197
|
+
+ format_review_examples(submission.top_examples, full_snippets)
|
|
198
|
+
+ (LANGUAGE_PROBES if has_language_labels(submission) else "")
|
|
199
|
+
+ (SYNTAX_PROBES if has_syntax_labels(submission) else "")
|
|
200
|
+
+ wordnet.format_for_reviewer(submission.top_examples, wordnet.lexicon())
|
|
201
|
+
+ (f"{context}\n" if context else "")
|
|
202
|
+
+ (f"{related}\n" if related else "")
|
|
203
|
+
+ (f"{art}\n" if (art := prior_art.format_for_reviewer(submission.prior_art)) else "")
|
|
204
|
+
+ "Write {} probes: {} hard negatives and {} positive holdouts.".format(n, *split(n))
|
|
205
|
+
+ LENGTH_MATCHED
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def token_check_prompt(
|
|
210
|
+
submission: NeuronAuditSubmission, probes: Sequence[AdversarialProbe], tokenizer
|
|
211
|
+
) -> str:
|
|
212
|
+
"""Return a second-pass prompt for the reviewer: its probes as the model's tokens.
|
|
213
|
+
|
|
214
|
+
Then the reviewer can fix labels that it gave by reading words. It shows no
|
|
215
|
+
activations: only the tokenizer runs. For a head, each probe also lists the token
|
|
216
|
+
runs that repeat an earlier one.
|
|
217
|
+
"""
|
|
218
|
+
is_head = submission.identifier.kind == "attn_head"
|
|
219
|
+
blocks = []
|
|
220
|
+
for n, probe in enumerate(probes):
|
|
221
|
+
ids = tokenizer.encode(probe.text)
|
|
222
|
+
tokens = [tokenizer.decode([i]) for i in ids]
|
|
223
|
+
label = "active" if probe.expected_active else "inactive"
|
|
224
|
+
lines = [
|
|
225
|
+
f"{n}. expected {label}: {probe.text!r}",
|
|
226
|
+
f" tokens: {' | '.join(repr(t) for t in tokens)}",
|
|
227
|
+
]
|
|
228
|
+
if is_head:
|
|
229
|
+
runs = [f"{''.join(tokens[a:b])!r} ({b - a})" for a, b in repeated_runs(ids)]
|
|
230
|
+
lines.append(f" repeated token runs: {', '.join(runs) or 'none'}")
|
|
231
|
+
blocks.append("\n".join(lines))
|
|
232
|
+
repeats = (
|
|
233
|
+
"Each 'repeated token runs' entry is a longest run of tokens that also occurs "
|
|
234
|
+
"earlier in the probe, with its length in tokens.\n"
|
|
235
|
+
if is_head
|
|
236
|
+
else ""
|
|
237
|
+
)
|
|
238
|
+
unequal = length_warning(probes, tokenizer, bool(submission.first_token_floor))
|
|
239
|
+
rebalance = f"{unequal} Rewrite some probes to match the lengths. " if unequal else ""
|
|
240
|
+
return (
|
|
241
|
+
ReviewerAgent.probe_prompt(submission, len(probes))
|
|
242
|
+
+ "\n\nYou wrote the probes below. Here they are as the model's tokens. "
|
|
243
|
+
+ repeats
|
|
244
|
+
+ rebalance
|
|
245
|
+
+ "Check each label against what the hypothesis predicts for these tokens, not "
|
|
246
|
+
"for the words. Where a label is wrong, rewrite the probe so it tests what you "
|
|
247
|
+
"meant (preferred, to keep the share of hard negatives), or relabel it. Leave "
|
|
248
|
+
"the other probes unchanged and write the whole probe set again.\n\n"
|
|
249
|
+
+ fence("\n\n".join(blocks))
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def review(
|
|
253
|
+
self,
|
|
254
|
+
submission: NeuronAuditSubmission,
|
|
255
|
+
runner: NeuronHookRunner,
|
|
256
|
+
*,
|
|
257
|
+
probes: Sequence[AdversarialProbe] | None = None,
|
|
258
|
+
policy: ReviewPolicy = DEFAULT_POLICY,
|
|
259
|
+
) -> VerificationVerdict:
|
|
260
|
+
"""Get probes (unless given), measure them and return the verdict."""
|
|
261
|
+
if probes is None:
|
|
262
|
+
probes = self.generate_probes(submission).probes
|
|
263
|
+
return evaluate_submission(submission, runner, probes, self.model, policy)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def repeated_runs(ids: Sequence[int]) -> list[tuple[int, int]]:
|
|
267
|
+
"""Maximal runs ``ids[a:b]`` that also occur ending earlier in ``ids``, as ``(a, b)``.
|
|
268
|
+
|
|
269
|
+
``match[j]`` is the longest run ending at ``j`` that ends at some earlier position
|
|
270
|
+
too; a run is reported where it stops growing.
|
|
271
|
+
"""
|
|
272
|
+
match = [0] * len(ids)
|
|
273
|
+
for j in range(len(ids)):
|
|
274
|
+
for i in range(j):
|
|
275
|
+
k = 0
|
|
276
|
+
while k <= i and ids[i - k] == ids[j - k]:
|
|
277
|
+
k += 1
|
|
278
|
+
match[j] = max(match[j], k)
|
|
279
|
+
return [
|
|
280
|
+
(j - match[j] + 1, j + 1)
|
|
281
|
+
for j in range(len(ids))
|
|
282
|
+
if match[j] and (j + 1 == len(ids) or match[j + 1] != match[j] + 1)
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def evaluate_submission(
|
|
287
|
+
submission: NeuronAuditSubmission,
|
|
288
|
+
runner: NeuronHookRunner,
|
|
289
|
+
probes: Sequence[AdversarialProbe],
|
|
290
|
+
reviewer_model: str,
|
|
291
|
+
policy: ReviewPolicy = DEFAULT_POLICY,
|
|
292
|
+
*,
|
|
293
|
+
wire: bool = True,
|
|
294
|
+
) -> VerificationVerdict:
|
|
295
|
+
"""Measure ``probes`` and the submission's own claims, and decide.
|
|
296
|
+
|
|
297
|
+
``wire=False`` skips the activation wiring (reported, not gated), for example when
|
|
298
|
+
reproducing a record.
|
|
299
|
+
"""
|
|
300
|
+
lap = costs.laps()
|
|
301
|
+
submission, unit, scale = settle_first_token_floor(submission, runner, policy)
|
|
302
|
+
threshold = policy.activation_threshold * scale
|
|
303
|
+
results, measured_probes, actual = measure_probes(runner, unit, probes, scale, threshold)
|
|
304
|
+
results += measure_counterfactuals(runner, unit, submission, scale, policy)
|
|
305
|
+
results.append(lookahead_result(runner, unit, submission))
|
|
306
|
+
lap("probe_measurement")
|
|
307
|
+
metrics = review_metrics(
|
|
308
|
+
results,
|
|
309
|
+
policy,
|
|
310
|
+
independent=not same_agent(reviewer_model, submission.submitting_agent_model),
|
|
311
|
+
baseline=confound_baseline(submission, runner, measured_probes, actual, threshold, policy),
|
|
312
|
+
active_share=scan_active_share(submission, policy),
|
|
313
|
+
first_token_floor=submission.first_token_floor,
|
|
314
|
+
)
|
|
315
|
+
lap("gates")
|
|
316
|
+
causal, joint = causal_readouts(submission, runner, probes, policy, scale)
|
|
317
|
+
lap("causal_metrics")
|
|
318
|
+
wiring = _activation_wiring(submission, runner, probes, threshold) if wire else None
|
|
319
|
+
if wire:
|
|
320
|
+
lap("wiring")
|
|
321
|
+
return VerificationVerdict(
|
|
322
|
+
status=decide(metrics, policy),
|
|
323
|
+
confidence_score=metrics.balanced_accuracy,
|
|
324
|
+
adversarial_test_results=results,
|
|
325
|
+
reviewer_agent_model=reviewer_model,
|
|
326
|
+
timestamp=datetime.now(UTC),
|
|
327
|
+
causal_metrics=causal,
|
|
328
|
+
metrics=metrics,
|
|
329
|
+
policy=policy,
|
|
330
|
+
joint_ablation=joint,
|
|
331
|
+
environment=run_environment(runner),
|
|
332
|
+
wiring=wiring,
|
|
333
|
+
notes=_summary(results, metrics),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _probe_score(
|
|
338
|
+
probes: Sequence[AdversarialProbe],
|
|
339
|
+
predicted: Sequence[bool],
|
|
340
|
+
actual: Sequence[bool],
|
|
341
|
+
policy: ReviewPolicy,
|
|
342
|
+
) -> float:
|
|
343
|
+
"""Return the balanced accuracy of ``predicted`` vs ``actual`` activity.
|
|
344
|
+
|
|
345
|
+
It is split by the hypothesis's labels (hard negatives / holdouts), with near-duplicate
|
|
346
|
+
probes clustered.
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
def subset(expected: bool):
|
|
350
|
+
rows = [
|
|
351
|
+
(p.text, pr == a)
|
|
352
|
+
for p, pr, a in zip(probes, predicted, actual, strict=True)
|
|
353
|
+
if p.expected_active == expected
|
|
354
|
+
]
|
|
355
|
+
return effective_counts(
|
|
356
|
+
[t for t, _ in rows], [ok for _, ok in rows], policy.probe_cluster_threshold
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return balanced_accuracy(subset(False), subset(True))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def confound_baseline(
|
|
363
|
+
submission: NeuronAuditSubmission,
|
|
364
|
+
runner: NeuronHookRunner,
|
|
365
|
+
probes: Sequence[AdversarialProbe],
|
|
366
|
+
actual: Sequence[bool],
|
|
367
|
+
threshold: float,
|
|
368
|
+
policy: ReviewPolicy,
|
|
369
|
+
) -> tuple[str, float] | None:
|
|
370
|
+
"""Return the best trivial explanation of the probes' activity: (name, accuracy).
|
|
371
|
+
|
|
372
|
+
The candidates are the token-identity and position rules, and "never active". Since
|
|
373
|
+
protocol 0.9 they also include each "at least n tokens" rule of probe length (#3525).
|
|
374
|
+
``None`` when the submission has no confound profile (scan predates protocol 0.3).
|
|
375
|
+
"""
|
|
376
|
+
if submission.confounds is None or runner.tokenizer is None or not probes:
|
|
377
|
+
return None
|
|
378
|
+
texts = [p.text for p in probes]
|
|
379
|
+
skip_first = bool(submission.first_token_floor)
|
|
380
|
+
candidates = baseline_predictions(
|
|
381
|
+
submission.confounds, runner.tokenizer, texts, threshold, skip_first
|
|
382
|
+
)
|
|
383
|
+
candidates |= length_predictions(runner.tokenizer, texts, skip_first) # protocol 0.9
|
|
384
|
+
candidates["never active"] = [False] * len(probes)
|
|
385
|
+
return best_baseline(candidates, probes, actual, policy)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def best_baseline(
|
|
389
|
+
candidates: dict[str, Sequence[bool]],
|
|
390
|
+
probes: Sequence[AdversarialProbe],
|
|
391
|
+
actual: Sequence[bool],
|
|
392
|
+
policy: ReviewPolicy,
|
|
393
|
+
) -> tuple[str, float]:
|
|
394
|
+
"""The candidate rule that explains the probes' activity best: (name, balanced accuracy).
|
|
395
|
+
|
|
396
|
+
Ties go to the first name in sorted order.
|
|
397
|
+
"""
|
|
398
|
+
scored = {name: _probe_score(probes, pred, actual, policy) for name, pred in candidates.items()}
|
|
399
|
+
best = max(sorted(scored), key=lambda name: scored[name])
|
|
400
|
+
return best, round(scored[best], 6)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _pass_rate(results: Sequence[AdversarialTestResult], kinds: set[str]) -> float | None:
|
|
404
|
+
subset = [r for r in results if r.kind in kinds and r.measured]
|
|
405
|
+
return sum(r.passed for r in subset) / len(subset) if subset else None
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def review_metrics(
|
|
409
|
+
results: Sequence[AdversarialTestResult],
|
|
410
|
+
policy: ReviewPolicy,
|
|
411
|
+
*,
|
|
412
|
+
independent: bool,
|
|
413
|
+
baseline: tuple[str, float] | None = None,
|
|
414
|
+
active_share: float | None = None,
|
|
415
|
+
first_token_floor: bool | None = None,
|
|
416
|
+
) -> ReviewMetrics:
|
|
417
|
+
"""The rates and gates of the decision rule, from the measured results (docs/PROTOCOL.md §4)."""
|
|
418
|
+
|
|
419
|
+
def counts(kind: str):
|
|
420
|
+
rows = [r for r in results if r.kind == kind and r.measured]
|
|
421
|
+
return effective_counts(
|
|
422
|
+
[r.prompt for r in rows], [r.passed for r in rows], policy.probe_cluster_threshold
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
negatives, holdouts = counts("hard_negative"), counts("positive_holdout")
|
|
426
|
+
reproduction = _pass_rate(results, {"counterfactual"})
|
|
427
|
+
lookahead = all(r.passed for r in results if r.kind == "lookahead_consistency")
|
|
428
|
+
balanced = balanced_accuracy(negatives, holdouts)
|
|
429
|
+
# The baseline's accuracy is stored rounded, so compare at the same precision. A tie
|
|
430
|
+
# passes (protocol 0.8). Before 0.8, 10/11 lost to a rounded-up 10/11.
|
|
431
|
+
beats = not policy.require_confound_control or (
|
|
432
|
+
baseline is not None and round(balanced, 6) >= baseline[1]
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
gates = _gates(
|
|
436
|
+
policy, negatives, holdouts, reproduction, lookahead, independent, beats, active_share
|
|
437
|
+
)
|
|
438
|
+
return ReviewMetrics(
|
|
439
|
+
specificity=None if negatives.rate is None else round(negatives.rate, 6),
|
|
440
|
+
sensitivity=None if holdouts.rate is None else round(holdouts.rate, 6),
|
|
441
|
+
balanced_accuracy=round(balanced, 6),
|
|
442
|
+
counterfactual_reproduction=reproduction,
|
|
443
|
+
lookahead_consistent=lookahead,
|
|
444
|
+
independent_review=independent,
|
|
445
|
+
gates=gates,
|
|
446
|
+
effective_hard_negatives=negatives.clusters,
|
|
447
|
+
effective_holdouts=holdouts.clusters,
|
|
448
|
+
confound_baseline=baseline[0] if baseline else None,
|
|
449
|
+
confound_baseline_accuracy=baseline[1] if baseline else None,
|
|
450
|
+
active_share=None if active_share is None else round(active_share, 6),
|
|
451
|
+
first_token_floor=first_token_floor,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _gates(
|
|
456
|
+
policy, negatives, holdouts, reproduction, lookahead, independent, beats, active_share
|
|
457
|
+
) -> dict[str, bool]:
|
|
458
|
+
return {
|
|
459
|
+
"enough_hard_negatives": negatives.clusters >= policy.min_hard_negatives,
|
|
460
|
+
"enough_holdouts": holdouts.clusters >= policy.min_holdouts,
|
|
461
|
+
"specificity": (negatives.rate or 0.0) >= policy.min_specificity,
|
|
462
|
+
"sensitivity": (holdouts.rate or 0.0) >= policy.min_sensitivity,
|
|
463
|
+
"counterfactual_reproduction": (reproduction or 0.0)
|
|
464
|
+
>= policy.min_counterfactual_reproduction,
|
|
465
|
+
"lookahead_consistent": lookahead,
|
|
466
|
+
"independent_review": independent or not policy.require_independent_review,
|
|
467
|
+
"beats_confound_baseline": beats,
|
|
468
|
+
"not_default_on": active_share is None or active_share < policy.max_active_share,
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def decide(metrics: ReviewMetrics, policy: ReviewPolicy) -> VerdictStatus:
|
|
473
|
+
"""VERIFIED if every gate passes; REJECTED if adequately probed yet near chance."""
|
|
474
|
+
if all(metrics.gates.values()):
|
|
475
|
+
return "VERIFIED"
|
|
476
|
+
adequately_probed = metrics.gates["enough_hard_negatives"] and metrics.gates["enough_holdouts"]
|
|
477
|
+
if adequately_probed and metrics.balanced_accuracy < policy.reject_below_balanced_accuracy:
|
|
478
|
+
return "REJECTED"
|
|
479
|
+
return "NEEDS_REVISION"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _summary(results: Sequence[AdversarialTestResult], metrics: ReviewMetrics) -> str:
|
|
483
|
+
counts: dict[str, list[int]] = {}
|
|
484
|
+
for r in results:
|
|
485
|
+
passed, total = counts.setdefault(r.kind, [0, 0])
|
|
486
|
+
counts[r.kind] = [passed + r.passed, total + 1]
|
|
487
|
+
tally = "; ".join(f"{kind}: {p}/{t} passed" for kind, (p, t) in counts.items())
|
|
488
|
+
failed = [gate for gate, ok in metrics.gates.items() if not ok]
|
|
489
|
+
return tally + (f". Failed gates: {', '.join(failed)}." if failed else ". All gates passed.")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def probes_from_verdict(verdict: VerificationVerdict) -> list[AdversarialProbe]:
|
|
493
|
+
"""Recover the probe set stored in a verdict, to re-evaluate it under another policy."""
|
|
494
|
+
return [
|
|
495
|
+
AdversarialProbe(text=r.prompt, expected_active=bool(r.expected_active), rationale=r.detail)
|
|
496
|
+
for r in verdict.adversarial_test_results
|
|
497
|
+
if r.kind in {"hard_negative", "positive_holdout"}
|
|
498
|
+
]
|