2"""Batch activation capture for labeled probe sets (SAE training, diff, analysis)."""
4from __future__
import annotations
8from datetime
import datetime, timezone
9from pathlib
import Path
10from typing
import Any, Literal
14PROBE_TEXT_KEYS = (
"instruction",
"prompt",
"text",
"content",
"response")
15RESERVED_PROBE_KEYS = frozenset({
"id", *PROBE_TEXT_KEYS})
17Position = Literal[
"last",
"mean"]
18Granularity = Literal[
"prompt",
"token"]
19ModelMode = Literal[
"llm"]
21BALANCE_PRIORITY = (
"label",
"stressor",
"lang",
"group")
25 """Resolve prompts file from cwd or repo parent (e.g. when run from cli/)."""
31 Path.cwd().parent / raw,
32 Path.cwd().parent.parent / raw,
34 for candidate
in candidates:
35 if candidate.is_file():
36 return candidate.resolve()
41 path.parent.mkdir(parents=
True, exist_ok=
True)
42 with path.open(
"w", encoding=
"utf-8")
as fh:
44 row = {
"id": probe[
"id"],
"instruction": probe[
"text"], **(probe.get(
"metadata")
or {})}
45 fh.write(json.dumps(row, ensure_ascii=
False) +
"\n")
49def _balance_key(probe: dict[str, Any], group: str |
None =
None) -> str |
None:
50 meta = probe.get(
"metadata")
if isinstance(probe.get(
"metadata"), dict)
else {}
53 if val
is not None and str(val).strip():
54 return f
"{group}:{val}"
56 for key
in BALANCE_PRIORITY:
58 if val
is not None and str(val).strip():
64 probes: list[dict[str, Any]],
66 group: str |
None =
None,
67) -> tuple[list[dict[str, Any]], dict[str, Any]]:
68 groups: dict[str, list[dict[str, Any]]] = {}
74 "balance_reason":
"no_metadata_groups",
75 **({
"balance_group": group}
if group
else {}),
77 groups.setdefault(key, []).append(probe)
81 "balance_reason":
"single_group",
82 "balance_groups": len(groups),
83 **({
"balance_group": group}
if group
else {}),
86 target = min(len(items)
for items
in groups.values())
87 balanced: list[dict[str, Any]] = []
88 group_counts: dict[str, int] = {}
89 for key
in sorted(groups):
90 kept = groups[key][:target]
92 group_counts[key] = len(kept)
95 "balance_groups": len(groups),
96 "balance_per_group": target,
97 "balance_counts": group_counts,
98 **({
"balance_group": group}
if group
else {}),
106 topic: str =
"general knowledge, reasoning, and instructions",
107) -> list[dict[str, Any]]:
108 """Use the loaded LLM to generate diverse probe strings."""
113 model = get_loaded_model()
or load_model(model_id)
114 n = max(1, min(int(count), MAX_PROBE_COUNT))
115 topic =
" ".join(str(topic).split())
or "general knowledge"
118 f
"Generate exactly {n} diverse short English probe prompts for language model "
119 f
"interpretability testing. Mix factual completions, reasoning, and instruction-style "
120 f
"prompts. Theme: {topic}. Output one probe per line. No numbering, bullets, or explanations."
125 max_new_tokens=min(n * 28, 900),
130 lines: list[str] = []
131 for raw
in text.splitlines():
132 line = raw.strip().lstrip(
"-•*0123456789.) ").strip(
'"').strip(
"'").strip()
133 if len(line) < 8
or line.lower().startswith((
"here are",
"sure",
"generate",
"probe")):
135 lines.append(line[:512])
137 probes: list[dict[str, Any]] = [
141 "metadata": {
"source":
"generated",
"topic": topic,
"group":
"factual" if i % 2 == 0
else "instruction"},
143 for i, line
in enumerate(lines[:n])
147 for j, fallback
in enumerate(DEFAULT_PROMPTS):
153 "metadata": {
"source":
"default",
"topic": topic,
"group":
"factual" if j % 2 == 0
else "instruction"},
156 print(f
"[capture] generated {len(probes[:n])} LLM probes (topic={topic!r})", flush=
True)
164 model_mode: ModelMode,
165 prompts_path: str | Path |
None,
168 balance: bool =
False,
169 balance_group: str |
None =
None,
170 output_dir: Path |
None =
None,
171) -> tuple[list[dict[str, Any]], dict[str, Any]]:
172 """Load probes from file or generate when --prompts omitted."""
173 meta: dict[str, Any] = {
174 "probes_source":
"file",
175 "requested_count": count,
182 raise FileNotFoundError(
183 f
"Prompts file not found: {prompts_path} "
184 f
"(cwd={Path.cwd()}). Omit --prompts to auto-generate with --count and --topic."
186 meta[
"prompts_path"] = str(resolved)
188 if count
and len(probes) > count:
189 probes = probes[:count]
192 meta.update(bal_meta)
195 theme = topic
or "general knowledge, reasoning, and instructions"
196 meta[
"probes_source"] =
"generated"
199 if output_dir
is not None:
201 meta[
"generated_probes_path"] = str(out_path)
202 print(f
"[capture] wrote {out_path}", flush=
True)
206 meta.update(bal_meta)
211def load_probes(path: str | Path |
None, *, fallback: list[str] |
None =
None) -> list[dict[str, Any]]:
212 """Load probes with optional metadata from JSON/JSONL."""
214 texts = fallback
or []
215 return [{
"id": f
"p{i}",
"text": t,
"metadata": {}}
for i, t
in enumerate(texts)]
221 raise FileNotFoundError(f
"Prompts file not found: {p}")
223 text = p.read_text(encoding=
"utf-8").strip()
224 if p.suffix ==
".jsonl":
225 rows = [json.loads(line)
for line
in text.splitlines()
if line.strip()]
227 data = json.loads(text)
228 rows = data
if isinstance(data, list)
else [data]
230 out: list[dict[str, Any]] = []
231 for i, row
in enumerate(rows):
232 if isinstance(row, str):
233 out.append({
"id": f
"p{i}",
"text": row.strip(),
"metadata": {}})
235 if not isinstance(row, dict):
238 for key
in PROBE_TEXT_KEYS:
240 if isinstance(val, str)
and val.strip():
241 text_val = val.strip()
245 probe_id = str(row.get(
"id")
or f
"p{i}")
246 metadata = {k: v
for k, v
in row.items()
if k
not in RESERVED_PROBE_KEYS}
247 out.append({
"id": probe_id,
"text": text_val,
"metadata": metadata})
250 raise ValueError(f
"No probes found in {p}")
254def parse_layers(spec: str |
None, n_layers: int) -> list[int]:
255 if not spec
or spec.strip().lower() ==
"all":
256 return list(range(n_layers))
257 layers: list[int] = []
258 for part
in spec.split(
","):
263 if layer < 0
or layer >= n_layers:
264 raise ValueError(f
"Layer {layer} out of range [0, {n_layers - 1}]")
267 raise ValueError(
"No layers specified")
268 return sorted(set(layers))
274 return resolve_model_id(model_id),
"llm"
277def llm_layer_count(model_id: str, checkpoint_path: str | Path |
None =
None) -> int:
278 """Catalog layer count — never load weights just to read n_layers (OOM on T4)."""
282 return int(get_config(resolve_model_id(model_id))[
"n_layers"])
286def _pool_activation(tensor: torch.Tensor, position: Position) -> torch.Tensor:
287 """tensor: (seq, d_model) -> (d_model,)"""
288 if position ==
"last":
289 return tensor[-1].float().cpu()
290 return tensor.mean(dim=0).float().cpu()
299 max_chars: int = 512,
300) -> dict[int, torch.Tensor]:
301 tokens = model.to_tokens(text[:max_chars])
302 hooks = {f
"blocks.{layer}.hook_resid_post" for layer
in layers}
303 with torch.no_grad():
304 _, cache = model.run_with_cache(
306 names_filter=
lambda name: name
in hooks,
309 out: dict[int, torch.Tensor] = {}
311 key = f
"blocks.{layer}.hook_resid_post"
322 max_chars: int = 512,
323) -> tuple[dict[int, torch.Tensor], list[str]]:
324 tokens = model.to_tokens(text[:max_chars])
325 hooks = {f
"blocks.{layer}.hook_resid_post" for layer
in layers}
326 with torch.no_grad():
327 _, cache = model.run_with_cache(
329 names_filter=
lambda name: name
in hooks,
332 token_ids = tokens[0].tolist()
333 token_texts = [model.tokenizer.decode([tid], skip_special_tokens=
False)
for tid
in token_ids]
334 out: dict[int, torch.Tensor] = {}
336 key = f
"blocks.{layer}.hook_resid_post"
338 out[layer] = cache[key][0].float().cpu()
339 return out, token_texts
346 probes: list[dict[str, Any]],
347 layer_list: list[int],
348 per_layer: dict[int, list[torch.Tensor]],
349 summary_rows: list[dict[str, Any]],
351 model_mode: ModelMode,
353 checkpoint_path: str | Path |
None,
354 checkpoint_name: str |
None,
357 capture_name: str |
None,
358 sae_file: str |
None,
359 sae_features: dict[str, Any] |
None,
360 manifest_extras: dict[str, Any] |
None =
None,
361 granularity: Granularity =
"prompt",
362 token_spans: list[dict[str, Any]] |
None =
None,
364 (out_root /
"layers").mkdir(parents=
True, exist_ok=
True)
365 activation_files: dict[str, str] = {}
366 for layer, vecs
in per_layer.items():
369 rel = f
"layers/layer_{layer}.pt"
370 stacked = torch.cat(vecs, dim=0)
if granularity ==
"token" else torch.stack(vecs)
371 torch.save(stacked, out_root / rel)
372 activation_files[str(layer)] = rel
374 capture_id = capture_name
or uuid.uuid4().hex[:12]
375 manifest: dict[str, Any] = {
377 "capture_id": capture_id,
378 "created_at": datetime.now(timezone.utc).isoformat(),
379 "model_id": model_id,
380 "model_mode": model_mode,
381 "checkpoint": str(checkpoint_path)
if checkpoint_path
else None,
382 "checkpoint_name": checkpoint_name,
383 "position": position,
384 "granularity": granularity,
385 "encode_sae": encode_sae,
386 "n_probes": len(probes),
387 "layers": layer_list,
389 "output_dir": str(out_root.resolve()),
391 "summary":
"summary.jsonl",
392 "activations": activation_files,
397 manifest.update(manifest_extras)
399 manifest[
"files"][
"sae_features"] = sae_file
400 manifest[
"sae_features"] = sae_features
401 if granularity ==
"token":
402 manifest[
"files"][
"token_spans"] =
"token_spans.jsonl"
404 metadata_payload: dict[str, Any] = {
406 "kind":
"activation_capture_metadata",
407 "capture_id": capture_id,
408 "created_at": manifest[
"created_at"],
409 "model_id": model_id,
410 "model_mode": model_mode,
411 "checkpoint": str(checkpoint_path)
if checkpoint_path
else None,
412 "checkpoint_name": checkpoint_name,
413 "position": position,
414 "granularity": granularity,
415 "encode_sae": encode_sae,
416 "n_probes": len(probes),
417 "layers": layer_list,
419 "output_dir": str(out_root.resolve()),
420 "files": manifest[
"files"],
424 metadata_payload.update(manifest_extras)
426 metadata_payload[
"sae_features"] = sae_features
428 (out_root /
"manifest.json").write_text(json.dumps(manifest, indent=2), encoding=
"utf-8")
429 (out_root /
"metadata.json").write_text(json.dumps(metadata_payload, indent=2), encoding=
"utf-8")
430 with (out_root /
"summary.jsonl").open(
"w", encoding=
"utf-8")
as fh:
431 for row
in summary_rows:
432 fh.write(json.dumps(row) +
"\n")
433 if granularity ==
"token":
434 with (out_root /
"token_spans.jsonl").open(
"w", encoding=
"utf-8")
as fh:
435 for row
in (token_spans
or []):
436 fh.write(json.dumps(row, ensure_ascii=
False) +
"\n")
441 **({
"label": p[
"metadata"][
"label"]}
if p.get(
"metadata", {}).get(
"label")
else {}),
442 **({
"lang": p[
"metadata"][
"lang"]}
if p.get(
"metadata", {}).get(
"lang")
else {}),
448 "capture_id": capture_id,
449 "model_id": model_id,
450 "model_mode": model_mode,
451 "n_probes": len(probes),
452 "layers": layer_list,
453 "position": position,
454 "granularity": granularity,
455 "encode_sae": encode_sae,
457 "output_dir": str(out_root.resolve()),
458 "manifest_path": str((out_root /
"manifest.json").resolve()),
459 "metadata_path": str((out_root /
"metadata.json").resolve()),
460 "activation_files": activation_files,
461 "sae_features": sae_features,
462 "probe_samples": probe_samples,
463 "checkpoint": str(checkpoint_path)
if checkpoint_path
else None,
464 "checkpoint_name": checkpoint_name,
466 **(manifest_extras
or {}),
472 probes: list[dict[str, Any]],
473 output_dir: str | Path,
476 checkpoint_path: str | Path |
None,
477 checkpoint_name: str |
None,
480 sae_layer: int |
None,
481 capture_name: str |
None,
482 manifest_extras: dict[str, Any] |
None =
None,
483 granularity: Granularity =
"prompt",
491 if checkpoint_path
is not None:
494 unload_weights(clear_active=
False)
496 model = load_tl_from_checkpoint(model_id, checkpoint_path)
497 d_model = int(model.cfg.d_model)
498 out_root = Path(output_dir)
499 (out_root /
"layers").mkdir(parents=
True, exist_ok=
True)
501 per_layer: dict[int, list[torch.Tensor]] = {layer: []
for layer
in layers}
502 summary_rows: list[dict[str, Any]] = []
503 token_spans: list[dict[str, Any]] = []
505 if granularity ==
"token" and encode_sae:
506 raise ValueError(
"--encode-sae is not yet supported with --granularity token")
509 if granularity ==
"token":
514 row_base = {
"probe_id": probe[
"id"],
"metadata": probe.get(
"metadata")
or {}}
515 for layer, vec
in acts.items():
516 per_layer[layer].append(vec)
517 if granularity ==
"token":
518 start = sum(t.shape[0]
for t
in per_layer[layer][:-1])
519 end = start + int(vec.shape[0])
520 summary_rows.append({**row_base,
"layer": layer,
"n_tokens": int(vec.shape[0]),
"d_model": int(vec.shape[1])})
524 "token_start": start,
526 "n_tokens": int(vec.shape[0]),
527 "tokens": token_texts,
530 summary_rows.append({**row_base,
"layer": layer,
"d_model": int(vec.shape[0])})
535 enc_layer = sae_layer
if sae_layer
is not None else get_sae_layer(model_id)
536 if enc_layer
not in layers:
538 f
"--encode-sae requires layer {enc_layer} in captured layers ({layers})"
540 sae = load_sae(model_id, layer=enc_layer)
541 encoded_rows: list[torch.Tensor] = []
544 resid = acts[enc_layer].to(next(sae.parameters()).device)
545 encoded = sae.encode(normalize(resid.unsqueeze(0), model_id, enc_layer))[0]
546 encoded_rows.append(encoded.float().cpu())
547 sae_stack = torch.stack(encoded_rows)
548 sae_rel = f
"sae/sae_layer_{enc_layer}.pt"
549 (out_root /
"sae").mkdir(parents=
True, exist_ok=
True)
550 torch.save(sae_stack, out_root / sae_rel)
554 "n_features": int(sae_stack.shape[1]),
555 "n_probes": int(sae_stack.shape[0]),
565 summary_rows=summary_rows,
569 checkpoint_path=checkpoint_path,
570 checkpoint_name=checkpoint_name,
572 encode_sae=encode_sae,
573 capture_name=capture_name,
575 sae_features=sae_features,
576 manifest_extras=manifest_extras,
577 granularity=granularity,
578 token_spans=token_spans,
585 probes: list[dict[str, Any]],
586 output_dir: str | Path,
588 model_mode: ModelMode |
None =
None,
589 layers: list[int] |
None =
None,
590 checkpoint_path: str | Path |
None =
None,
591 checkpoint_name: str |
None =
None,
592 position: Position =
"last",
593 encode_sae: bool =
False,
594 sae_layer: int |
None =
None,
595 capture_name: str |
None =
None,
596 manifest_extras: dict[str, Any] |
None =
None,
597 granularity: Granularity =
"prompt",
604 n_layers = int(get_config(mid)[
"n_layers"])
605 layer_list = layers
if layers
is not None else list(range(n_layers))
612 checkpoint_path=checkpoint_path,
613 checkpoint_name=checkpoint_name,
615 encode_sae=encode_sae,
617 capture_name=capture_name,
618 manifest_extras=manifest_extras,
619 granularity=granularity,
str|None _balance_key(dict[str, Any] probe, str|None group=None)
tuple[str, ModelMode] resolve_capture_model_id(str model_id)
int llm_layer_count(str model_id, str|Path|None checkpoint_path=None)
dict[int, torch.Tensor] _forward_llm_layers(Any model, str text, list[int] layers, *, Position position, int max_chars=512)
dict[str, Any] _write_capture_artifacts(*, Path out_root, list[dict[str, Any]] probes, list[int] layer_list, dict[int, list[torch.Tensor]] per_layer, list[dict[str, Any]] summary_rows, str model_id, ModelMode model_mode, int d_model, str|Path|None checkpoint_path, str|None checkpoint_name, Position position, bool encode_sae, str|None capture_name, str|None sae_file, dict[str, Any]|None sae_features, dict[str, Any]|None manifest_extras=None, Granularity granularity="prompt", list[dict[str, Any]]|None token_spans=None)
dict[str, Any] run_capture_activations(str model_id, list[dict[str, Any]] probes, str|Path output_dir, *, ModelMode|None model_mode=None, list[int]|None layers=None, str|Path|None checkpoint_path=None, str|None checkpoint_name=None, Position position="last", bool encode_sae=False, int|None sae_layer=None, str|None capture_name=None, dict[str, Any]|None manifest_extras=None, Granularity granularity="prompt")
Path|None resolve_prompts_path(str|Path path)
tuple[dict[int, torch.Tensor], list[str]] _forward_llm_layers_token(Any model, str text, list[int] layers, *, int max_chars=512)
tuple[list[dict[str, Any]], dict[str, Any]] resolve_probes_for_capture(*, str model_id, ModelMode model_mode, str|Path|None prompts_path, int count, str|None topic, bool balance=False, str|None balance_group=None, Path|None output_dir=None)
torch.Tensor _pool_activation(torch.Tensor tensor, Position position)
list[dict[str, Any]] load_probes(str|Path|None path, *, list[str]|None fallback=None)
dict[str, Any] _run_capture_llm(str model_id, list[dict[str, Any]] probes, str|Path output_dir, *, list[int] layers, str|Path|None checkpoint_path, str|None checkpoint_name, Position position, bool encode_sae, int|None sae_layer, str|None capture_name, dict[str, Any]|None manifest_extras=None, Granularity granularity="prompt")
tuple[list[dict[str, Any]], dict[str, Any]] balance_probes(list[dict[str, Any]] probes, *, str|None group=None)
list[int] parse_layers(str|None spec, int n_layers)
list[dict[str, Any]] generate_llm_probes(str model_id, int count, *, str topic="general knowledge, reasoning, and instructions")
Path write_probes_jsonl(Path path, list[dict[str, Any]] probes)