Coverage for transformer_lens/tools/analysis/jacobian_lens_causal_swap_benchmark.py: 97%

237 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-10-10 05:10 +0000

1"""Causal coordinate-swap benchmark for ``JacobianLens.coordinate_patch_hooks``. 

2 

3Measures whether an anchored J-space coordinate edit installed live inside a forward pass 

4via ``coordinate_patch_hooks`` causes a directional change in model output, under three 

5controls: baseline-capability filtering (only intervene on prompts the model already 

6answers correctly), displacement-matched random-atom controls (isolate "this concept 

7mattered" from "any edit of similar magnitude would have mattered"), and exact 

8Clopper-Pearson uncertainty on every reported rate. Each trial draws its control arm under 

9several seeds and records every draw, so the control arm's own spread is visible rather 

10than folded into a single number. 

11 

12This module is layered bottom-up: the model-free prompt corpus and rank/margin metric, 

13baseline-capability filtering, displacement-matched control-token selection, a per-trial 

14runner that wires the first three together with real ``coordinate_patch_hooks`` calls 

15against a live model, and exact Clopper-Pearson confidence intervals plus a versioned JSON 

16artifact schema that fingerprints both the protocol and the result blocks and embeds the 

17corpus definition. Generating the frozen artifact consumed by 

18``demos/Jacobian_Lens_Coordinate_Patch_Benchmark_Demo.ipynb`` is a separate entry point: 

19``transformer_lens.tools.analysis.jacobian_lens_causal_swap_benchmark_cli``. 

20""" 

21 

22from __future__ import annotations 

23 

24import hashlib 

25import itertools 

26import json 

27from collections import Counter 

28from dataclasses import asdict, dataclass 

29from pathlib import Path 

30from typing import ( 

31 Any, 

32 Container, 

33 Dict, 

34 Iterator, 

35 List, 

36 Literal, 

37 Mapping, 

38 MutableMapping, 

39 Optional, 

40 Sequence, 

41 Tuple, 

42) 

43 

44import torch 

45 

46from transformer_lens.tools.analysis.jacobian_lens import DEFAULT_K, JacobianLens 

47from transformer_lens.tools.analysis.jacobian_lens_decomposition import ( 

48 JSpaceDecomposition, 

49) 

50 

51 

52@dataclass(frozen=True) 

53class FunctionSpec: 

54 """A templated prompt function evaluated over a shared set of concepts. 

55 

56 ``template`` takes a single ``{arg}`` placeholder, e.g. ``"The capital of {arg} is"``. 

57 ``answers`` maps each concept to its answer word under this function, e.g. 

58 ``{"France": "Paris"}``. 

59 """ 

60 

61 name: str 

62 template: str 

63 answers: Dict[str, str] 

64 

65 

66@dataclass(frozen=True) 

67class BenchmarkCorpus: 

68 """A named set of concepts and the prompt functions evaluated over them.""" 

69 

70 name: str 

71 concepts: Sequence[str] 

72 functions: Sequence[FunctionSpec] 

73 

74 

75@dataclass(frozen=True) 

76class PromptTrialSpec: 

77 """One (function, ordered source/target concept pair) prompt instance.""" 

78 

79 function: str 

80 source: str 

81 target: str 

82 prompt: str 

83 source_answer: str 

84 target_answer: str 

85 

86 

87def iter_prompt_trials(corpus: BenchmarkCorpus) -> Iterator[PromptTrialSpec]: 

88 """Yields one spec per (function, ordered source/target concept pair). 

89 

90 Ordered pairs are every element of ``itertools.permutations(corpus.concepts, 2)``, the 

91 same cross product Jacobian_Lens_Demo.ipynb's country benchmark already uses, now as 

92 tested library code instead of a notebook cell. 

93 """ 

94 for function in corpus.functions: 

95 for source, target in itertools.permutations(corpus.concepts, 2): 

96 yield PromptTrialSpec( 

97 function=function.name, 

98 source=source, 

99 target=target, 

100 prompt=function.template.format(arg=source), 

101 source_answer=function.answers[source], 

102 target_answer=function.answers[target], 

103 ) 

104 

105 

106def corpus_definition(corpus: BenchmarkCorpus) -> Dict[str, Any]: 

107 """Serializes a corpus into the JSON shape embedded in the protocol manifest. 

108 

109 The prompts and answers decide which prompts are baseline-capable and how every 

110 condition is scored, so they belong inside the fingerprinted manifest rather than 

111 travelling as a bare name. Key order is normalized so the same corpus always 

112 serializes identically. 

113 """ 

114 return { 

115 "name": corpus.name, 

116 "concepts": list(corpus.concepts), 

117 "functions": [ 

118 { 

119 "name": function.name, 

120 "template": function.template, 

121 "answers": dict(function.answers), 

122 } 

123 for function in corpus.functions 

124 ], 

125 } 

126 

127 

128@dataclass(frozen=True) 

129class AnswerMetrics: 

130 """Rank/margin/tie metrics for one target token against one next-token logit vector.""" 

131 

132 top1_token_id: int 

133 target_rank: int 

134 target_is_top1: bool 

135 target_tied_for_top: bool 

136 target_logit_margin: float 

137 

138 

139def compute_answer_metrics(logits: torch.Tensor, target_token_id: int) -> AnswerMetrics: 

140 """Computes rank/margin/tie metrics for ``target_token_id`` against ``logits``. 

141 

142 Ports Jacobian_Lens_Demo.ipynb's ``_target_metrics`` cell verbatim (arithmetic 

143 unchanged, only renamed and restructured into a dataclass), so results stay directly 

144 comparable with that notebook's already-reviewed success/rank definitions. 

145 """ 

146 if logits.ndim != 1 or logits.numel() < 2: 

147 raise ValueError("expected one-dimensional next-token logits") 

148 if not 0 <= target_token_id < logits.shape[0]: 

149 raise ValueError("target token id is outside the vocabulary") 

150 if not torch.isfinite(logits).all(): 

151 raise ValueError("logits must be finite") 

152 

153 target_logit = logits[target_token_id] 

154 top_logit = logits.max() 

155 top1_token_id = int(logits.argmax().item()) 

156 top_logit_tie_count = int((logits == top_logit).sum().item()) 

157 competitors = torch.cat((logits[:target_token_id], logits[target_token_id + 1 :])) 

158 return AnswerMetrics( 

159 top1_token_id=top1_token_id, 

160 target_rank=int((logits > target_logit).sum().item()) + 1, 

161 target_is_top1=top1_token_id == target_token_id, 

162 target_tied_for_top=bool(target_logit == top_logit) and top_logit_tie_count > 1, 

163 target_logit_margin=float((target_logit - competitors.max()).item()), 

164 ) 

165 

166 

167@dataclass(frozen=True) 

168class BaselineRecord: 

169 """A source prompt's own-answer metrics under the unperturbed baseline forward pass.""" 

170 

171 function: str 

172 source: str 

173 prompt: str 

174 metrics: AnswerMetrics 

175 

176 

177def filter_baseline_capable( 

178 baselines: Sequence[BaselineRecord], 

179) -> Tuple[List[BaselineRecord], List[BaselineRecord]]: 

180 """Splits baseline records into (capable, excluded) prompts. 

181 

182 A prompt is baseline-capable when the model's own deterministic argmax already matches 

183 the source's answer (``metrics.target_is_top1``) and that answer is not tied for the 

184 maximum (``metrics.target_tied_for_top``); only such prompts are eligible for later 

185 intervention trials, so an edit's effect is never measured against a prompt the 

186 unperturbed model already gets wrong. A tied-for-top baseline is treated as not capable: 

187 ``argmax`` returns the lowest index on an exact tie, so admitting ties would let token-id 

188 order decide whether a prompt enters the trial set. Order-preserving in both outputs; 

189 never mutates ``baselines``. 

190 """ 

191 capable = [ 

192 record 

193 for record in baselines 

194 if record.metrics.target_is_top1 and not record.metrics.target_tied_for_top 

195 ] 

196 excluded = [ 

197 record 

198 for record in baselines 

199 if not record.metrics.target_is_top1 or record.metrics.target_tied_for_top 

200 ] 

201 return capable, excluded 

202 

203 

204def _control_generator(seed: int) -> torch.Generator: 

205 """Builds the CPU generator used to pick a control token. 

206 

207 The generator is pinned to CPU rather than the dictionary's device. ``torch.randint`` 

208 infers its output device from the generator, so a device-local generator would make the 

209 same seed select a different control token per device and the frozen artifact would stop 

210 being reproducible. A CPU generator also avoids the device mismatch that raises on 

211 accelerators, where ``torch.randint`` allocates on CPU while a device-local generator 

212 expects its own device. 

213 """ 

214 return torch.Generator(device="cpu").manual_seed(seed) 

215 

216 

217def select_displacement_matched_control_token( 

218 dictionary: torch.Tensor, 

219 source_token_id: int, 

220 target_token_id: int, 

221 excluded_ids: Container[int], 

222 active_support: Container[int], 

223 *, 

224 tolerance: float = 0.1, 

225 seed: int = 0, 

226) -> Optional[int]: 

227 """Deterministically selects a displacement-matched control token id. 

228 

229 The real condition perturbs the activation by ``c_src * (a_target - a_source)``, so the 

230 edit's size is set by ``||a_target - a_source||``. Matching atom norms leaves that size 

231 free: two atoms of equal norm can sit at very different distances from ``a_source``, and 

232 the control edit then runs at a different magnitude from the real one. A candidate token 

233 id ``t`` therefore qualifies when 

234 ``abs(||a_t - a_source|| - ||a_target - a_source||) <= tolerance * ||a_target - a_source||`` 

235 and ``t`` is not ``source_token_id``, not ``target_token_id``, not a member of 

236 ``excluded_ids``, and not a member of ``active_support`` -- an atom already carrying the 

237 source coordinate is not a clean control. 

238 

239 One qualifying candidate is picked with a seeded CPU ``torch.Generator`` so the same 

240 ``seed`` always yields the same control token on every device. Returns ``None`` when no 

241 candidate qualifies; the tolerance is never silently widened and selection never falls 

242 back to the globally nearest atom. Returning ``None`` rather than raising lets the caller 

243 record an empty pool as its own skip status instead of aborting the sweep. 

244 """ 

245 if dictionary.ndim != 2: 

246 raise ValueError( 

247 f"dictionary must be 2-D [num_atoms, d_model], got shape {tuple(dictionary.shape)}" 

248 ) 

249 atoms = dictionary.float() 

250 source_atom = atoms[source_token_id] 

251 target_displacement = float((atoms[target_token_id] - source_atom).norm()) 

252 displacements = (atoms - source_atom).norm(dim=1) 

253 within_tolerance = (displacements - target_displacement).abs() <= ( 

254 tolerance * target_displacement 

255 ) 

256 candidates = [ 

257 token_id 

258 for token_id in range(dictionary.shape[0]) 

259 if token_id != source_token_id 

260 and token_id != target_token_id 

261 and token_id not in excluded_ids 

262 and token_id not in active_support 

263 and bool(within_tolerance[token_id]) 

264 ] 

265 if not candidates: 

266 return None 

267 generator = _control_generator(seed) 

268 pick = int(torch.randint(len(candidates), (1,), generator=generator).item()) 

269 return candidates[pick] 

270 

271 

272def _resolve_answer_token_id(model: Any, word: str) -> int: 

273 """Resolves a concept or answer word to the token id it maps to as a continuation. 

274 

275 Prepends a leading space so the id matches how the word tokenizes when it follows 

276 other prompt text (e.g. " Paris", not "Paris"), the same convention the corpus 

277 templates themselves rely on. 

278 """ 

279 return int(model.to_single_token(f" {word}")) 

280 

281 

282@dataclass(frozen=True) 

283class ControlDraw: 

284 """One seeded control-arm draw: the token it selected and how that condition scored.""" 

285 

286 seed: int 

287 token_id: int 

288 metrics: AnswerMetrics 

289 

290 

291@dataclass(frozen=True) 

292class TrialResult: 

293 """One ``(function, source, target, layer)`` causal-swap trial's full record. 

294 

295 ``control_token_id`` and ``control_target_metrics`` mirror the first entry of 

296 ``control_draws`` so a single-draw reader sees the same fields it always did. The full 

297 list carries the control arm's own spread across seeds, which one draw cannot measure. 

298 """ 

299 

300 function: str 

301 source: str 

302 target: str 

303 layer: int 

304 status: Literal["ok", "skipped_source_inactive", "skipped_no_control_token"] 

305 baseline: AnswerMetrics 

306 real_target_metrics: Optional[AnswerMetrics] 

307 control_token_id: Optional[int] 

308 control_target_metrics: Optional[AnswerMetrics] 

309 control_draws: List[ControlDraw] 

310 error: Optional[str] 

311 

312 

313def run_causal_swap_trial( 

314 lens: JacobianLens, 

315 model: Any, 

316 trial_spec: PromptTrialSpec, 

317 layer: int, 

318 *, 

319 decomposition_cache: Optional[MutableMapping[Tuple[int, int, int], JSpaceDecomposition]] = None, 

320 control_tolerance: float = 0.1, 

321 control_seeds: Sequence[int] = (0,), 

322 alpha: float = 1.0, 

323 k: int = DEFAULT_K, 

324) -> TrialResult: 

325 """Runs one causal-swap trial: baseline, then real and control coordinate-patch conditions. 

326 

327 A baseline forward pass scores the prompt's own (unperturbed) source answer. A 

328 displacement-matched control token is then selected from ``layer``'s lens-vector 

329 dictionary, excluding the source token, the real target token, both prompts' answer 

330 tokens, and every atom in the layer's active support. The real and control conditions 

331 each install ``coordinate_patch_hooks`` at ``layer`` and position ``-1``, sharing one 

332 ``decomposition_cache`` so only the first of the two performs the vocabulary-scale 

333 decomposition; both are scored against the target's own answer token, so a real-vs-control 

334 gap isolates "swapping toward this concept mattered" from "any edit of this magnitude 

335 would have mattered." 

336 

337 The control arm is drawn once per entry in ``control_seeds``. A single draw leaves the 

338 control arm's own variance unmeasured, so a reader cannot tell a real-vs-control gap from 

339 draw variation; the per-seed results are carried on ``control_draws``. Every draw reuses 

340 the same ``decomposition_cache``, so only the first pays the vocabulary-scale solve. 

341 

342 Two conditions produce a skip rather than a result. When the source is not in the layer's 

343 active support, the trial is recorded with ``status="skipped_source_inactive"``. When no 

344 displacement-matched control token survives the exclusions, the trial is recorded with 

345 ``status="skipped_no_control_token"``. Both carry a diagnostic message in ``error``. 

346 

347 The active-support precondition is checked up front, against the same decomposition the 

348 hooks consume, rather than inferred from a caught exception. ``coordinate_patch_hooks`` 

349 raises plain ``ValueError`` for several unrelated protocol faults (a same-id source and 

350 target, an out-of-vocabulary answer, non-finite logits) and defines no narrower subclass 

351 to discriminate on, so catching ``ValueError`` would file those as skips and drop them 

352 from both denominators. Every other error therefore propagates unchanged. 

353 ``coordinate_patch_hooks``'s own ``UserWarning``s (both the per-call install notice and 

354 any solver-side conditioning warning) are not suppressed here and propagate to the caller 

355 unchanged. 

356 

357 Args: 

358 lens: The fitted lens. 

359 model: The model to run trials against. 

360 trial_spec: The prompt, source/target concepts, and their answer words. 

361 layer: The single layer to patch at. 

362 decomposition_cache: Shared cache passed to both the real and control 

363 ``coordinate_patch_hooks`` calls. A fresh cache is used if omitted. 

364 control_tolerance: Relative tolerance for the displacement-matched control token. 

365 control_seeds: Seeds to draw the control arm under. Each seed selects its own 

366 control token and contributes one :class:`ControlDraw`; the first seed's draw 

367 is mirrored onto ``control_token_id`` and ``control_target_metrics``. 

368 alpha: Interpolation strength forwarded to ``coordinate_patch_hooks``. 

369 k: Sparse-solver upper bound forwarded to ``coordinate_patch_hooks``. 

370 

371 Returns: 

372 The trial's :class:`TrialResult`. 

373 

374 Raises: 

375 ValueError: If the source and target concepts resolve to the same token id, which 

376 would make the coordinate patch a silent no-op, or if ``control_seeds`` is 

377 empty. 

378 """ 

379 if decomposition_cache is None: 

380 decomposition_cache = {} 

381 if not control_seeds: 

382 raise ValueError("control_seeds must contain at least one seed") 

383 tokens = model.to_tokens(trial_spec.prompt) 

384 source_id = _resolve_answer_token_id(model, trial_spec.source) 

385 target_id = _resolve_answer_token_id(model, trial_spec.target) 

386 source_answer_id = _resolve_answer_token_id(model, trial_spec.source_answer) 

387 target_answer_id = _resolve_answer_token_id(model, trial_spec.target_answer) 

388 if source_id == target_id: 

389 raise ValueError( 

390 f"source and target resolve to the same token id {source_id}; a coordinate " 

391 "patch would be a silent no-op" 

392 ) 

393 

394 with torch.no_grad(): 

395 baseline_logits = model(tokens)[0, -1].float() 

396 baseline_metrics = compute_answer_metrics(baseline_logits, source_answer_id) 

397 

398 dictionary = lens.lens_vector_dictionary(model, layer) 

399 decomposition = lens.decompose(model, trial_spec.prompt, layer=layer, position=-1, k=k) 

400 # Seed the shared cache under the hook's own key so the first firing is a cache hit and 

401 # the up-front solve is not repeated. The hook normalizes -1 against the activation's 

402 # sequence length, which equals the tokenized prompt length for a single-example pass. 

403 decomposition_cache[(layer, 0, tokens.shape[1] - 1)] = decomposition 

404 if source_id not in decomposition.support.tolist(): 

405 return TrialResult( 

406 function=trial_spec.function, 

407 source=trial_spec.source, 

408 target=trial_spec.target, 

409 layer=layer, 

410 status="skipped_source_inactive", 

411 baseline=baseline_metrics, 

412 real_target_metrics=None, 

413 control_token_id=None, 

414 control_target_metrics=None, 

415 control_draws=[], 

416 error=( 

417 f"source token id {source_id} is not in layer {layer}'s active support " 

418 f"for this prompt" 

419 ), 

420 ) 

421 control_token_ids: List[int] = [] 

422 for seed in control_seeds: 422 ↛ 451line 422 didn't jump to line 451 because the loop on line 422 didn't complete

423 token_id = select_displacement_matched_control_token( 

424 dictionary, 

425 source_id, 

426 target_id, 

427 excluded_ids={source_answer_id, target_answer_id}, 

428 active_support=decomposition.support.tolist(), 

429 tolerance=control_tolerance, 

430 seed=seed, 

431 ) 

432 if token_id is None: 

433 return TrialResult( 

434 function=trial_spec.function, 

435 source=trial_spec.source, 

436 target=trial_spec.target, 

437 layer=layer, 

438 status="skipped_no_control_token", 

439 baseline=baseline_metrics, 

440 real_target_metrics=None, 

441 control_token_id=None, 

442 control_target_metrics=None, 

443 control_draws=[], 

444 error=( 

445 "no displacement-matched control token within relative tolerance " 

446 f"{control_tolerance} of ||a_target - a_source|| for seed {seed}" 

447 ), 

448 ) 

449 control_token_ids.append(token_id) 

450 

451 def _condition_metrics(condition_target_id: int) -> AnswerMetrics: 

452 hooks = lens.coordinate_patch_hooks( 

453 model, 

454 source_id, 

455 condition_target_id, 

456 layers=[layer], 

457 positions=[-1], 

458 decomposition_cache=decomposition_cache, 

459 k=k, 

460 alpha=alpha, 

461 ) 

462 with model.hooks(fwd_hooks=hooks), torch.no_grad(): 

463 condition_logits = model(tokens)[0, -1].float() 

464 return compute_answer_metrics(condition_logits, target_answer_id) 

465 

466 real_metrics = _condition_metrics(target_id) 

467 control_draws = [ 

468 ControlDraw( 

469 seed=seed, 

470 token_id=token_id, 

471 metrics=_condition_metrics(token_id), 

472 ) 

473 for seed, token_id in zip(control_seeds, control_token_ids) 

474 ] 

475 

476 return TrialResult( 

477 function=trial_spec.function, 

478 source=trial_spec.source, 

479 target=trial_spec.target, 

480 layer=layer, 

481 status="ok", 

482 baseline=baseline_metrics, 

483 real_target_metrics=real_metrics, 

484 control_token_id=control_draws[0].token_id, 

485 control_target_metrics=control_draws[0].metrics, 

486 control_draws=control_draws, 

487 error=None, 

488 ) 

489 

490 

491def run_causal_swap_benchmark( 

492 lens: JacobianLens, 

493 model: Any, 

494 corpus: BenchmarkCorpus, 

495 layers: Sequence[int], 

496 **trial_kwargs: Any, 

497) -> Tuple[List[TrialResult], List[BaselineRecord]]: 

498 """Runs the full causal-swap sweep: baseline filtering, then every surviving trial. 

499 

500 Each ``(function, source)`` prompt's baseline is computed once -- it does not depend on 

501 ``layer`` -- and only prompts that survive :func:`filter_baseline_capable` proceed to 

502 :func:`run_causal_swap_trial`, once per remaining ``layer``. Each trial gets its own fresh 

503 ``decomposition_cache``: the cache key is ``(layer, batch_idx, position)``, which collides 

504 across different prompts run as independent single-example forward passes, so a cache may 

505 only be reused within one trial's real/control pair, never across trials. 

506 

507 Args: 

508 lens: The fitted lens. 

509 model: The model to run trials against. 

510 corpus: The prompt corpus to sweep. 

511 layers: Layers to sweep as an independent trial dimension. 

512 **trial_kwargs: Forwarded to :func:`run_causal_swap_trial` (``control_tolerance``, 

513 ``control_seeds``, ``alpha``, ``k``; ``decomposition_cache`` is not accepted here 

514 since each trial always uses its own). 

515 

516 Returns: 

517 ``(trials, excluded_baselines)``. 

518 """ 

519 all_specs = list(iter_prompt_trials(corpus)) 

520 specs_by_prompt: Dict[Tuple[str, str], List[PromptTrialSpec]] = {} 

521 baselines: List[BaselineRecord] = [] 

522 for spec in all_specs: 

523 prompt_key = (spec.function, spec.source) 

524 if prompt_key not in specs_by_prompt: 524 ↛ 538line 524 didn't jump to line 538 because the condition on line 524 was always true

525 specs_by_prompt[prompt_key] = [] 

526 tokens = model.to_tokens(spec.prompt) 

527 with torch.no_grad(): 

528 baseline_logits = model(tokens)[0, -1].float() 

529 source_answer_id = _resolve_answer_token_id(model, spec.source_answer) 

530 baselines.append( 

531 BaselineRecord( 

532 function=spec.function, 

533 source=spec.source, 

534 prompt=spec.prompt, 

535 metrics=compute_answer_metrics(baseline_logits, source_answer_id), 

536 ) 

537 ) 

538 specs_by_prompt[prompt_key].append(spec) 

539 

540 capable, excluded = filter_baseline_capable(baselines) 

541 capable_prompt_keys = {(record.function, record.source) for record in capable} 

542 

543 trials: List[TrialResult] = [] 

544 for layer in layers: 

545 for prompt_key, specs in specs_by_prompt.items(): 

546 if prompt_key not in capable_prompt_keys: 

547 continue 

548 for spec in specs: 

549 trials.append( 

550 run_causal_swap_trial( 

551 lens, 

552 model, 

553 spec, 

554 layer, 

555 **trial_kwargs, 

556 ) 

557 ) 

558 return trials, excluded 

559 

560 

561@dataclass(frozen=True) 

562class SuccessRateInterval: 

563 """An exact Clopper-Pearson confidence interval around a success rate. 

564 

565 ``n_trials`` and ``n_successes`` are the counts the interval was computed from, so a 

566 reader can see the denominator behind the bounds rather than inferring it. 

567 """ 

568 

569 point_estimate: float 

570 ci_low: float 

571 ci_high: float 

572 n_trials: int 

573 n_successes: int 

574 confidence: float 

575 

576 

577# Bisection depth for the Clopper-Pearson bounds. Each step halves the bracket, so 200 steps 

578# resolve the bound far below the precision any reported rate needs. 

579_CLOPPER_PEARSON_STEPS = 200 

580 

581 

582def _binomial_cdf(k: int, n: int, p: float) -> float: 

583 """Exact ``P(X <= k)`` for ``X ~ Binomial(n, p)``. 

584 

585 Accumulates the pmf by recurrence (``term_{i} = term_{i-1} * (n - i + 1) / i * p / (1 - p)``) 

586 rather than forming ``comb(n, i)`` and ``p ** i`` separately, which overflow for the 

587 trial counts this benchmark reports. 

588 """ 

589 if k < 0: 589 ↛ 590line 589 didn't jump to line 590 because the condition on line 589 was never true

590 return 0.0 

591 if k >= n: 591 ↛ 592line 591 didn't jump to line 592 because the condition on line 591 was never true

592 return 1.0 

593 if p <= 0.0: 593 ↛ 594line 593 didn't jump to line 594 because the condition on line 593 was never true

594 return 1.0 

595 if p >= 1.0: 595 ↛ 596line 595 didn't jump to line 596 because the condition on line 595 was never true

596 return 0.0 

597 term = (1.0 - p) ** n 

598 total = term 

599 odds = p / (1.0 - p) 

600 for i in range(1, k + 1): 

601 term *= (n - i + 1) / i * odds 

602 total += term 

603 return total 

604 

605 

606def _clopper_pearson_bound(n_successes: int, n_trials: int, tail: float, *, upper: bool) -> float: 

607 """Solves one Clopper-Pearson bound by bisecting the exact binomial CDF. 

608 

609 The lower bound solves ``P(X >= k) = tail`` and the upper bound solves ``P(X <= k) = 

610 tail``. ``P(X >= k)`` rises with ``p`` while ``P(X <= k)`` falls, so the two bounds 

611 bracket from opposite sides; reversing that collapses every interval to ``[0, 1]``. 

612 """ 

613 if upper and n_successes == n_trials: 

614 return 1.0 

615 if not upper and n_successes == 0: 

616 return 0.0 

617 low, high = 0.0, 1.0 

618 for _ in range(_CLOPPER_PEARSON_STEPS): 

619 midpoint = (low + high) / 2.0 

620 if upper: 

621 # P(X <= k) decreases in p, so a probability above the tail puts the bound below 

622 # the midpoint. 

623 if _binomial_cdf(n_successes, n_trials, midpoint) > tail: 

624 low = midpoint 

625 else: 

626 high = midpoint 

627 else: 

628 # P(X >= k) increases in p, so a probability above the tail puts the bound above 

629 # the midpoint. 

630 if 1.0 - _binomial_cdf(n_successes - 1, n_trials, midpoint) > tail: 

631 high = midpoint 

632 else: 

633 low = midpoint 

634 return (low + high) / 2.0 

635 

636 

637def success_rate_ci( 

638 successes: Sequence[bool], 

639 *, 

640 confidence: float = 0.95, 

641) -> SuccessRateInterval: 

642 """Computes an exact Clopper-Pearson confidence interval for a success rate. 

643 

644 A percentile bootstrap cannot express uncertainty about an all-failure or all-success 

645 sample: every resample of an all-zero vector is all zeros, so the interval collapses to 

646 ``[0, 0]`` and reports the same certainty for one trial as for a thousand. The exact 

647 interval instead widens as the trial count shrinks, giving ``[0.0, 0.459]`` for zero 

648 successes out of six. 

649 

650 Deterministic by construction: there is no resampling and no seed. 

651 

652 Raises: 

653 ValueError: If ``successes`` is empty. 

654 """ 

655 if len(successes) == 0: 

656 raise ValueError("successes must be a non-empty sequence") 

657 n_trials = len(successes) 

658 n_successes = sum(1 for success in successes if success) 

659 tail = (1.0 - confidence) / 2.0 

660 return SuccessRateInterval( 

661 point_estimate=n_successes / n_trials, 

662 ci_low=_clopper_pearson_bound(n_successes, n_trials, tail, upper=False), 

663 ci_high=_clopper_pearson_bound(n_successes, n_trials, tail, upper=True), 

664 n_trials=n_trials, 

665 n_successes=n_successes, 

666 confidence=confidence, 

667 ) 

668 

669 

670_REQUIRED_MANIFEST_FIELDS = ( 

671 "model_id", 

672 "model_revision", 

673 "lens_repo", 

674 "lens_file", 

675 "lens_revision", 

676 "corpus_name", 

677 "corpus", 

678 "corpus_repo", 

679 "corpus_path", 

680 "corpus_revision", 

681 "layers_swept", 

682 "alpha", 

683 "k", 

684 "control_tolerance", 

685 "control_seeds", 

686 "success_definition", 

687 "baseline_definition", 

688 "rank_definition", 

689) 

690 

691 

692def build_protocol_manifest(**fields: Any) -> Dict[str, Any]: 

693 """Assembles a protocol manifest, requiring the benchmark's fixed field set. 

694 

695 Requires at least :data:`_REQUIRED_MANIFEST_FIELDS`, the same key set (and, where they 

696 overlap, the same string values) as ``Jacobian_Lens_Demo.ipynb``'s existing 

697 ``protocol_manifest`` cell. Beyond that cell's fields the manifest carries the full 

698 corpus definition (``corpus``) and the repo/path/revision triple it was taken from 

699 (``corpus_repo``, ``corpus_path``, ``corpus_revision``), so the prompts and answers 

700 that decide capability and scoring enter the fingerprint instead of a bare name. 

701 

702 Raises: 

703 ValueError: If any required field is missing, naming the missing field(s). 

704 """ 

705 missing = [name for name in _REQUIRED_MANIFEST_FIELDS if name not in fields] 

706 if missing: 

707 raise ValueError(f"protocol manifest is missing required field(s): {', '.join(missing)}") 

708 return dict(fields) 

709 

710 

711def fingerprint_manifest(manifest: Dict[str, Any]) -> str: 

712 """Fingerprints a protocol manifest with the recipe ``Jacobian_Lens_Demo.ipynb`` uses.""" 

713 return hashlib.sha256( 

714 json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode() 

715 ).hexdigest() 

716 

717 

718def results_fingerprint(artifact: Mapping[str, Any]) -> str: 

719 """Fingerprints an artifact's result blocks with the manifest recipe. 

720 

721 ``protocol_fingerprint`` covers only the manifest, so an artifact whose trials, 

722 excluded baselines, or confidence-interval blocks were rewritten would still load 

723 clean and print the same provenance hash. Hashing those blocks separately catches 

724 that tampering while leaving the manifest recipe and its stored value untouched. 

725 """ 

726 return hashlib.sha256( 

727 json.dumps( 

728 { 

729 "trials": artifact["trials"], 

730 "excluded_baselines": artifact["excluded_baselines"], 

731 "real_success_ci": artifact["real_success_ci"], 

732 "control_success_ci": artifact["control_success_ci"], 

733 }, 

734 sort_keys=True, 

735 separators=(",", ":"), 

736 ).encode() 

737 ).hexdigest() 

738 

739 

740SCHEMA_VERSION = 2 

741 

742 

743def serialize_artifact( 

744 manifest: Dict[str, Any], 

745 trials: Sequence[TrialResult], 

746 excluded_baselines: Sequence[BaselineRecord], 

747 real_ci: SuccessRateInterval, 

748 control_ci: SuccessRateInterval, 

749) -> Dict[str, Any]: 

750 """Assembles a JSON-serializable artifact dict from a benchmark run's results. 

751 

752 ``n_independent_prompts`` records how many distinct ``(function, source)`` prompts the 

753 pooled rate rests on. Pooling trials as independent draws overstates the evidence when 

754 several trials share one prompt, so the count travels with the rate. 

755 

756 ``layers_executed`` lists the sorted distinct layers that produced an ``ok`` trial, and 

757 ``trial_status_counts`` counts trials per status, so a reader can see how many of the 

758 swept layers contributed nothing and how many trials were skipped rather than executed. 

759 """ 

760 artifact: Dict[str, Any] = { 

761 "schema_version": SCHEMA_VERSION, 

762 "protocol_manifest": manifest, 

763 "protocol_fingerprint": fingerprint_manifest(manifest), 

764 "trials": [asdict(trial) for trial in trials], 

765 "excluded_baselines": [asdict(record) for record in excluded_baselines], 

766 "real_success_ci": asdict(real_ci), 

767 "control_success_ci": asdict(control_ci), 

768 "n_independent_prompts": len( 

769 {(trial.function, trial.source) for trial in trials if trial.status == "ok"} 

770 ), 

771 "layers_executed": sorted({trial.layer for trial in trials if trial.status == "ok"}), 

772 "trial_status_counts": dict(Counter(trial.status for trial in trials)), 

773 } 

774 artifact["results_fingerprint"] = results_fingerprint(artifact) 

775 return artifact 

776 

777 

778_REQUIRED_ARTIFACT_FIELDS = ( 

779 "schema_version", 

780 "protocol_manifest", 

781 "protocol_fingerprint", 

782 "trials", 

783 "excluded_baselines", 

784 "real_success_ci", 

785 "control_success_ci", 

786 "n_independent_prompts", 

787 "layers_executed", 

788 "trial_status_counts", 

789 "results_fingerprint", 

790) 

791 

792 

793def load_artifact(path: Path) -> Dict[str, Any]: 

794 """Reads and validates a frozen artifact produced by :func:`serialize_artifact`. 

795 

796 Validates that every required top-level key is present, that ``schema_version`` 

797 matches :data:`SCHEMA_VERSION`, that ``protocol_fingerprint`` matches a fresh 

798 :func:`fingerprint_manifest` of the loaded ``protocol_manifest``, and that 

799 ``results_fingerprint`` matches a fresh :func:`results_fingerprint` of the loaded 

800 result blocks -- catching a hand-edited or corrupted artifact rather than trusting 

801 the stored fingerprints blindly. 

802 

803 Raises: 

804 ValueError: Naming the missing or mismatched field. 

805 """ 

806 artifact = json.loads(Path(path).read_text()) 

807 missing = [name for name in _REQUIRED_ARTIFACT_FIELDS if name not in artifact] 

808 if missing: 

809 raise ValueError(f"artifact is missing required field(s): {', '.join(missing)}") 

810 if artifact["schema_version"] != SCHEMA_VERSION: 

811 raise ValueError( 

812 f"artifact schema_version {artifact['schema_version']!r} does not match the " 

813 f"expected {SCHEMA_VERSION!r}" 

814 ) 

815 expected_fingerprint = fingerprint_manifest(artifact["protocol_manifest"]) 

816 if artifact["protocol_fingerprint"] != expected_fingerprint: 

817 raise ValueError( 

818 "artifact protocol_fingerprint does not match a freshly computed fingerprint of " 

819 "its protocol_manifest (the manifest may have been hand-edited or corrupted)" 

820 ) 

821 expected_results_fingerprint = results_fingerprint(artifact) 

822 if artifact["results_fingerprint"] != expected_results_fingerprint: 

823 raise ValueError( 

824 "artifact results_fingerprint does not match a freshly computed fingerprint of " 

825 "its trials, excluded_baselines, and confidence-interval blocks (they may have " 

826 "been hand-edited or corrupted)" 

827 ) 

828 return artifact