AQIT 0.1.0
Loading...
Searching...
No Matches
find_feature.py
Go to the documentation of this file.
1# Copyright (c) 2025-present Aquin Labs Private Limited. All Rights Reserved.
2"""Rank SAE features that separate honest vs deceptive probe sets (deception scorer)."""
3
4from __future__ import annotations
5
6import json
7from datetime import datetime, timezone
8from pathlib import Path
9from typing import Any
10
11import torch
12
13from aquin.compute.activation_capture import resolve_prompts_path
14
15HONEST_LABELS = frozenset({"honest", "truthful", "true", "control", "baseline"})
16DECEPTIVE_LABELS = frozenset({"deceptive", "deception", "lie", "lying", "dishonest", "false"})
17TEXT_KEYS = ("instruction", "prompt", "text", "content", "honest", "deceptive")
18VALID_DIRECTIONS = frozenset({"both", "deceptive", "honest"})
21def normalize_direction(direction: str | None) -> str:
22 d = (direction or "both").strip().lower()
23 if d not in VALID_DIRECTIONS:
24 raise ValueError(f"Unknown direction {direction!r}; supported: both, deceptive, honest")
25 return d
26
27
28def _resolve_deception_fixture() -> Path:
29 """Bundled probes were removed. Callers must pass prompts=."""
30 raise FileNotFoundError(
31 "No default deception probes. Pass prompts= with an honest/deceptive JSON/JSONL path."
32 )
33
34
35def _row_text(row: dict[str, Any], *keys: str) -> str:
36 for key in keys:
37 val = row.get(key)
38 if isinstance(val, str) and val.strip():
39 return val.strip()
40 return ""
41
42
43def load_deception_probes(path: str | Path | None) -> tuple[list[str], list[str], dict[str, Any]]:
44 """
45 Load honest and deceptive text lists from JSON/JSONL.
46
47 Supports:
48 - paired rows: honest + deceptive fields on one object
49 - labeled rows: instruction/prompt/text + label honest|deceptive
50 """
51 p = Path(path) if path else _resolve_deception_fixture()
52 if not p.is_file():
53 resolved = resolve_prompts_path(p)
54 if resolved is None:
55 raise FileNotFoundError(f"Prompts file not found: {p}")
56 p = resolved
57
58 text = p.read_text(encoding="utf-8").strip()
59 if p.suffix == ".jsonl":
60 rows = [json.loads(line) for line in text.splitlines() if line.strip()]
61 else:
62 data = json.loads(text)
63 rows = data if isinstance(data, list) else [data]
64
65 honest: list[str] = []
66 deceptive: list[str] = []
67 n_pairs = 0
68 n_labeled = 0
69
70 for row in rows:
71 if not isinstance(row, dict):
72 continue
73 h = _row_text(row, "honest", "instruction_honest", "true", "control")
74 d = _row_text(row, "deceptive", "instruction_deceptive", "false", "lie")
75 if h and d:
76 honest.append(h)
77 deceptive.append(d)
78 n_pairs += 1
79 continue
80
81 label = str(row.get("label") or row.get("class") or row.get("condition") or "").strip().lower()
82 body = _row_text(row, *TEXT_KEYS)
83 if not body or not label:
84 continue
85 if label in HONEST_LABELS:
86 honest.append(body)
87 n_labeled += 1
88 elif label in DECEPTIVE_LABELS:
89 deceptive.append(body)
90 n_labeled += 1
91
92 if not honest or not deceptive:
93 raise ValueError(
94 f"No honest/deceptive probe pairs in {p}. "
95 "Use paired rows (honest+deceptive) or labeled rows (label: honest|deceptive)."
96 )
97
98 meta = {
99 "prompts_path": str(p),
100 "n_honest": len(honest),
101 "n_deceptive": len(deceptive),
102 "n_pairs": n_pairs,
103 "n_labeled": n_labeled,
104 }
105 return honest, deceptive, meta
106
107
109 honest_acts: torch.Tensor,
110 deceptive_acts: torch.Tensor,
111 *,
112 top_k: int,
113 direction: str = "both",
114) -> list[dict[str, Any]]:
115 """honest_acts, deceptive_acts: (n_features,) mean activations."""
116 direction = normalize_direction(direction)
117 delta = deceptive_acts - honest_acts
118 abs_delta = delta.abs()
119
120 if direction == "deceptive":
121 mask = delta > 0
122 rank_scores = delta
123 elif direction == "honest":
124 mask = delta < 0
125 rank_scores = -delta
126 else:
127 mask = torch.ones_like(delta, dtype=torch.bool)
128 rank_scores = abs_delta
129
130 if not bool(mask.any().item()):
131 return []
132
133 valid_idx = mask.nonzero(as_tuple=False).squeeze(-1)
134 valid_scores = rank_scores[mask]
135 k = min(top_k, int(valid_scores.shape[0]))
136 local_top = valid_scores.topk(k).indices
137 top_idx = valid_idx[local_top].tolist()
138
139 rows = [
140 {
141 "feature_idx": int(i),
142 "honest_mean": round(float(honest_acts[i].item()), 6),
143 "deceptive_mean": round(float(deceptive_acts[i].item()), 6),
144 "delta": round(float(delta[i].item()), 6),
145 "abs_delta": round(float(abs_delta[i].item()), 6),
146 }
147 for i in top_idx
148 ]
149 if direction == "deceptive":
150 rows.sort(key=lambda r: r["delta"], reverse=True)
151 elif direction == "honest":
152 rows.sort(key=lambda r: r["delta"])
153 else:
154 rows.sort(key=lambda r: r["abs_delta"], reverse=True)
155 return rows
156
157
159 direction: str,
160 chosen: dict[str, Any] | None,
161 *,
162 persisted: bool,
163 conditioning: str = "behavior",
164 behavior_meta: dict[str, Any] | None = None,
165) -> str | None:
166 direction = normalize_direction(direction)
167 warnings: list[str] = []
168
169 if conditioning == "behavior" and behavior_meta:
170 n_truthful = int(behavior_meta.get("n_truthful") or 0)
171 n_deceptive = int(behavior_meta.get("n_deceptive") or 0)
172 n_ambiguous = int(behavior_meta.get("n_ambiguous") or 0)
173 n_generated = int(behavior_meta.get("n_generated") or 0)
174 if n_truthful < 2 or n_deceptive < 2:
175 warnings.append(
176 f"Small behavior buckets (truthful={n_truthful}, deceptive={n_deceptive}). "
177 "Add probes or tune references."
178 )
179 if n_generated and n_ambiguous > n_generated // 2:
180 warnings.append(
181 f"{n_ambiguous}/{n_generated} completions were ambiguous; "
182 "classification may be weak."
183 )
184 elif conditioning == "prompt":
185 warnings.append(
186 "Using prompt conditioning (static probe text). "
187 "Default behavior mode generates completions and buckets by observed output."
188 )
189
190 if chosen is None:
191 if direction == "deceptive":
192 warnings.append(
193 "No features with positive delta (deceptive > honest). "
194 "Try --direction both, another layer, or custom probes."
195 )
196 elif direction == "honest":
197 warnings.append(
198 "No features with negative delta (honest > deceptive). "
199 "Try --direction both, another layer, or custom probes."
200 )
201 else:
202 warnings.append("No separating features found.")
203 return " ".join(warnings) if warnings else None
204
205 delta = chosen.get("delta")
206 if delta is not None and direction == "both" and float(delta) < 0:
207 msg = (
208 "Top feature has negative delta (honest activates more than deceptive). "
209 "Use --direction deceptive for deception-biased ranking."
210 )
211 if persisted:
212 msg = (
213 "Persisted feature has negative delta (honest > deceptive). "
214 "Use --direction deceptive to pick deception-biased features."
215 )
216 warnings.append(msg)
217
218 return " ".join(warnings) if warnings else None
219
220
222 model_id: str,
223 key: str,
224 record: dict[str, Any],
225 *,
226 session_id: str | None = None,
227) -> str:
228 """Write canonical feature record to ~/.aquin/experiments/<model>.json and optional session memory."""
229 exp_dir = Path.home() / ".aquin" / "experiments"
230 exp_dir.mkdir(parents=True, exist_ok=True)
231 slug = model_id.replace("/", "--")
232 path = exp_dir / f"{slug}.json"
233 existing: dict[str, Any] = {}
234 if path.is_file():
235 try:
236 existing = json.loads(path.read_text(encoding="utf-8"))
237 except Exception:
238 existing = {}
239 if not isinstance(existing, dict):
240 existing = {}
241 existing[key] = record
242 existing["updated_at"] = datetime.now(timezone.utc).isoformat()
243 path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
244
245 if session_id:
246 from aquin.engine.session_memory_store import patch_local_memory
247
248 patch_local_memory(session_id, key, record.get("feature_idx"))
249 patch_local_memory(session_id, f"{key}_record", record)
250
251 return str(path)
252
253
255 model_id: str,
256 *,
257 scorer: str = "deception",
258 prompts_path: str | Path | None = None,
259 layer: int | None = None,
260 checkpoint_path: str | Path | None = None,
261 top_k: int = 20,
262 direction: str = "both",
263 conditioning: str = "behavior",
264 max_new_tokens: int = 64,
265 temperature: float = 0.0,
266 benchmark_top: int = 0,
267 persist_key: str | None = None,
268 session_id: str | None = None,
269 openai_client: Any | None = None,
270) -> dict[str, Any]:
271 direction = normalize_direction(direction)
272 from aquin.compute.deception_behavior import normalize_conditioning
273
274 conditioning = normalize_conditioning(conditioning)
275 if scorer != "deception":
276 raise ValueError(f"Unknown scorer {scorer!r}; supported: deception")
277
278 from aquin.compute.deception_behavior import collect_behavior_encode_texts
279 from aquin.compute.feature_analysis import load_sae
280 from aquin.compute.model_loader import get_sae_layer, resolve_model_id
281 from aquin.compute.sae_diff import load_tl_from_checkpoint, mean_sae_activations
282
283 mid = resolve_model_id(model_id)
284 sae_layer = int(layer if layer is not None else get_sae_layer(mid))
285 behavior_meta: dict[str, Any] | None = None
286
287 model = load_tl_from_checkpoint(mid, checkpoint_path)
288 sae = load_sae(mid, sae_layer)
289
290 if conditioning == "behavior":
291 honest, deceptive, behavior_meta = collect_behavior_encode_texts(
292 prompts_path,
293 mid,
294 model,
295 checkpoint_path=checkpoint_path,
296 max_new_tokens=max_new_tokens,
297 temperature=temperature,
298 )
299 if not honest or not deceptive:
300 del model
301 n_gen = int((behavior_meta or {}).get("n_generated") or 0)
302 n_amb = int((behavior_meta or {}).get("n_ambiguous") or 0)
303 preview = ""
304 samples = (behavior_meta or {}).get("samples") or []
305 if samples:
306 first = samples[0]
307 preview = (
308 f" Example: prompt={first.get('probe_id')!r} "
309 f"behavior={first.get('behavior')!r} "
310 f"response={first.get('response_preview')!r}."
311 )
312 raise ValueError(
313 "Behavior conditioning needs at least one truthful and one deceptive completion. "
314 f"Got truthful={len(honest)}, deceptive={len(deceptive)} "
315 f"(generated={n_gen}, ambiguous={n_amb}).{preview} "
316 "Use probes with honest_reference/deceptive_reference, "
317 "or pass --conditioning prompt to use static honest/deceptive text."
318 )
319 probe_meta = {
320 "prompts_path": behavior_meta.get("prompts_path"),
321 "n_honest": len(honest),
322 "n_deceptive": len(deceptive),
323 "n_pairs": 0,
324 "n_labeled": 0,
325 }
326 else:
327 honest, deceptive, probe_meta = load_deception_probes(prompts_path)
328 behavior_meta = None
329
330 honest_mean = mean_sae_activations(model, sae, sae_layer, honest, mid)
331 deceptive_mean = mean_sae_activations(model, sae, sae_layer, deceptive, mid)
332 rankings = _rank_features(honest_mean, deceptive_mean, top_k=top_k, direction=direction)
333
334 chosen = rankings[0] if rankings else None
335 chosen_idx = int(chosen["feature_idx"]) if chosen else None
336
337 if benchmark_top > 0 and rankings and openai_client is not None:
338 from aquin.compute.interp_score import run_interp_score
339
340 probe = deceptive[0] if deceptive else honest[0]
341 for row in rankings[: max(1, min(benchmark_top, len(rankings)))]:
342 fidx = int(row["feature_idx"])
343 try:
344 interp = run_interp_score(
345 fidx, probe, model, sae, openai_client,
346 model_id=mid, layer=sae_layer,
347 )
348 row["interp_score"] = interp.get("score")
349 row["purity_score"] = interp.get("purity_score")
350 except Exception as exc:
351 row["interp_error"] = str(exc)
352
353 def _combined(row: dict[str, Any]) -> float:
354 if direction == "deceptive":
355 base = float(row.get("delta") or 0)
356 elif direction == "honest":
357 base = float(-(row.get("delta") or 0))
358 else:
359 base = float(row.get("abs_delta") or 0)
360 interp = row.get("interp_score")
361 if interp is None:
362 return base
363 return base + 0.05 * float(interp)
364
365 rankings.sort(key=_combined, reverse=True)
366 chosen = rankings[0]
367 chosen_idx = int(chosen["feature_idx"])
368
369 del model
370
371 warning = _find_feature_warning(
372 direction,
373 chosen,
374 persisted=bool(persist_key),
375 conditioning=conditioning,
376 behavior_meta=behavior_meta,
377 )
378
379 payload: dict[str, Any] = {
380 "type": "findFeature",
381 "status": "done",
382 "scorer": scorer,
383 "direction": direction,
384 "conditioning": conditioning,
385 "model_id": mid,
386 "layer": sae_layer,
387 "checkpoint": str(checkpoint_path) if checkpoint_path else None,
388 "n_honest": probe_meta["n_honest"],
389 "n_deceptive": probe_meta["n_deceptive"],
390 "prompts_path": probe_meta.get("prompts_path"),
391 "chosen_feature_idx": chosen_idx,
392 "chosen_delta": chosen.get("delta") if chosen else None,
393 "warning": warning,
394 "rankings": rankings,
395 }
396 if behavior_meta:
397 payload["behavior"] = {
398 "n_generated": behavior_meta.get("n_generated"),
399 "n_truthful": behavior_meta.get("n_truthful"),
400 "n_deceptive": behavior_meta.get("n_deceptive"),
401 "n_ambiguous": behavior_meta.get("n_ambiguous"),
402 "intent_behavior_match": behavior_meta.get("intent_behavior_match"),
403 "samples": behavior_meta.get("samples"),
404 }
405
406 if persist_key and chosen_idx is not None:
407 record = {
408 "feature_idx": chosen_idx,
409 "layer": sae_layer,
410 "model_id": mid,
411 "scorer": scorer,
412 "direction": direction,
413 "conditioning": conditioning,
414 "delta": chosen.get("delta"),
415 "abs_delta": chosen.get("abs_delta"),
416 "prompts_path": probe_meta.get("prompts_path"),
417 "chosen_at": datetime.now(timezone.utc).isoformat(),
418 }
419 exp_path = persist_experiment_record(mid, persist_key, record, session_id=session_id)
420 payload["persisted_key"] = persist_key
421 payload["experiment_path"] = exp_path
422
423 return payload
424
str _row_text(dict[str, Any] row, *str keys)
str|None _find_feature_warning(str direction, dict[str, Any]|None chosen, *, bool persisted, str conditioning="behavior", dict[str, Any]|None behavior_meta=None)
dict[str, Any] run_find_feature(str model_id, *, str scorer="deception", str|Path|None prompts_path=None, int|None layer=None, str|Path|None checkpoint_path=None, int top_k=20, str direction="both", str conditioning="behavior", int max_new_tokens=64, float temperature=0.0, int benchmark_top=0, str|None persist_key=None, str|None session_id=None, Any|None openai_client=None)
list[dict[str, Any]] _rank_features(torch.Tensor honest_acts, torch.Tensor deceptive_acts, *, int top_k, str direction="both")
str persist_experiment_record(str model_id, str key, dict[str, Any] record, *, str|None session_id=None)
str normalize_direction(str|None direction)
tuple[list[str], list[str], dict[str, Any]] load_deception_probes(str|Path|None path)