Jac-Zac commited on
Commit
fee1567
·
1 Parent(s): e8b0701

Refactoring cleanups

Browse files
pyproject.toml CHANGED
@@ -15,6 +15,14 @@ dependencies = [
15
  "safetensors>=0.7.0",
16
  ]
17
 
 
 
 
 
 
 
 
 
18
  # Local development:
19
  # [tool.uv.sources]
20
  # persona-vectors = { path = "../persona-vectors", editable = true }
 
15
  "safetensors>=0.7.0",
16
  ]
17
 
18
+ [dependency-groups]
19
+ dev = [
20
+ "pytest>=9.0.3",
21
+ ]
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
25
+
26
  # Local development:
27
  # [tool.uv.sources]
28
  # persona-vectors = { path = "../persona-vectors", editable = true }
tabs/analysis/__init__.py ADDED
File without changes
tabs/analysis/_shared.py ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gc
2
+
3
+ import plotly.graph_objects as go
4
+ import streamlit as st
5
+ from persona_data.synth_persona import BASELINE_PERSONA_ID
6
+ from persona_vectors.extraction import MaskStrategy
7
+ from persona_vectors.plots import save_plot_html
8
+
9
+ from utils.analysis_sources import (
10
+ Store,
11
+ available_variants,
12
+ load_persona_vectors_cached,
13
+ load_variant_vectors_cached,
14
+ persona_names_cached,
15
+ personas_cached,
16
+ release_hf_store_cache,
17
+ store_cache_parts,
18
+ store_id,
19
+ store_layers_cached,
20
+ )
21
+ from utils.controls import render_mask_strategy_select
22
+ from utils.helpers import personas_fingerprint, prompt_variant_label, widget_key
23
+ from utils.theme import active_base, style_plotly_layer_controls
24
+
25
+ from tabs.analysis._state import (
26
+ _DEFAULT_LAYER_FRAMES,
27
+ _HIGHLIGHT_OTHER_COLOR,
28
+ _HIGHLIGHT_OTHER_LABEL,
29
+ _LAST_LAYER_FRAMES_KEY,
30
+ _LAST_MASK_STRATEGY_KEY,
31
+ PersonaOptions,
32
+ _is_assistant_persona,
33
+ _persona_names_state_key,
34
+ _personas_empty_message,
35
+ _remembered_selectbox,
36
+ _sequence_to_list,
37
+ )
38
+
39
+
40
+ def _gray_out_unselected_personas(fig: go.Figure) -> None:
41
+ def _gray_trace(trace: object) -> None:
42
+ marker = getattr(trace, "marker", None)
43
+ if marker is None:
44
+ return
45
+
46
+ colors = _sequence_to_list(getattr(marker, "color", None))
47
+ labels = _sequence_to_list(getattr(trace, "customdata", None))
48
+ if colors is not None and labels is not None and len(colors) == len(labels):
49
+ trace.marker.color = [
50
+ (
51
+ _HIGHLIGHT_OTHER_COLOR
52
+ if str(label) == _HIGHLIGHT_OTHER_LABEL
53
+ else color
54
+ )
55
+ for label, color in zip(labels, colors, strict=True)
56
+ ]
57
+ return
58
+
59
+ if getattr(trace, "name", None) == _HIGHLIGHT_OTHER_LABEL:
60
+ trace.marker.color = _HIGHLIGHT_OTHER_COLOR
61
+ trace.opacity = 0.28
62
+
63
+ for trace in fig.data:
64
+ _gray_trace(trace)
65
+ for frame in fig.frames:
66
+ for trace in frame.data:
67
+ _gray_trace(trace)
68
+
69
+
70
+ def _layers_for_variant(
71
+ store: Store,
72
+ variant: str,
73
+ persona_ids: list[str],
74
+ mask_strategy: MaskStrategy,
75
+ ) -> list[int]:
76
+ source, location, model_name = store_cache_parts(store)
77
+ return store_layers_cached(
78
+ source,
79
+ location,
80
+ model_name,
81
+ mask_strategy.value,
82
+ (variant,),
83
+ tuple(persona_ids),
84
+ )
85
+
86
+
87
+ def _load_persona_vectors(
88
+ store: Store,
89
+ variant: str,
90
+ mask_strategy: MaskStrategy,
91
+ persona_ids: list[str],
92
+ ):
93
+ source, location, model_name = store_cache_parts(store)
94
+ return load_persona_vectors_cached(
95
+ source,
96
+ location,
97
+ model_name,
98
+ mask_strategy.value,
99
+ variant,
100
+ tuple(persona_ids),
101
+ )
102
+
103
+
104
+ def _load_variant_vectors(
105
+ store: Store,
106
+ variants: list[str] | tuple[str, ...],
107
+ mask_strategy: MaskStrategy,
108
+ persona_ids: list[str],
109
+ ):
110
+ source, location, model_name = store_cache_parts(store)
111
+ return load_variant_vectors_cached(
112
+ source,
113
+ location,
114
+ model_name,
115
+ mask_strategy.value,
116
+ tuple(variants),
117
+ tuple(persona_ids),
118
+ )
119
+
120
+
121
+ def _release_vector_memory(store: Store, variants: list[str] | tuple[str, ...]) -> None:
122
+ release_hf_store_cache(store, variants)
123
+ gc.collect()
124
+
125
+
126
+ def _evenly_spaced_layers(layers: list[int], max_count: int) -> list[int]:
127
+ if max_count >= len(layers):
128
+ return layers
129
+ if max_count <= 1:
130
+ return [layers[0]]
131
+
132
+ last = len(layers) - 1
133
+ indices = [round(i * last / (max_count - 1)) for i in range(max_count)]
134
+ return [layers[index] for index in dict.fromkeys(indices)]
135
+
136
+
137
+ def _render_layer_frame_controls(
138
+ store: Store,
139
+ scope: str,
140
+ layers: list[int],
141
+ ) -> list[int]:
142
+ if len(layers) <= _DEFAULT_LAYER_FRAMES:
143
+ st.caption(f"Using all {len(layers)} available layer(s).")
144
+ return layers
145
+
146
+ frame_count = st.slider(
147
+ "Layer frames",
148
+ min_value=2,
149
+ max_value=len(layers),
150
+ value=min(
151
+ max(
152
+ int(
153
+ st.session_state.get(
154
+ _LAST_LAYER_FRAMES_KEY,
155
+ _DEFAULT_LAYER_FRAMES,
156
+ )
157
+ ),
158
+ 2,
159
+ ),
160
+ len(layers),
161
+ ),
162
+ key=widget_key("load", "layer_frames", scope, store_id(store)),
163
+ help="Limit animated Plotly frames to keep browser and RAM usage bounded.",
164
+ )
165
+ st.session_state[_LAST_LAYER_FRAMES_KEY] = frame_count
166
+ selected = _evenly_spaced_layers(layers, frame_count)
167
+ st.caption(f"Using {len(selected)} of {len(layers)} layers.")
168
+ return selected
169
+
170
+
171
+ def _load_persona_options(
172
+ store: Store,
173
+ variants: list[str],
174
+ mask_strategy: MaskStrategy,
175
+ *,
176
+ empty_message: str,
177
+ ) -> PersonaOptions | None:
178
+ source, location, model_name = store_cache_parts(store)
179
+ variant_key = tuple(variants)
180
+ persona_ids = personas_cached(
181
+ source,
182
+ location,
183
+ model_name,
184
+ mask_strategy.value,
185
+ variant_key,
186
+ include_baseline=True,
187
+ )
188
+ if not persona_ids:
189
+ st.info(empty_message)
190
+ return None
191
+
192
+ persona_names = persona_names_cached(
193
+ source,
194
+ location,
195
+ model_name,
196
+ mask_strategy.value,
197
+ variant_key,
198
+ tuple(persona_ids),
199
+ )
200
+ assistant_ids = [
201
+ persona_id
202
+ for persona_id in persona_ids
203
+ if _is_assistant_persona(persona_id, persona_names.get(persona_id))
204
+ ]
205
+ assistant_id = next(
206
+ (
207
+ persona_id
208
+ for persona_id in assistant_ids
209
+ if persona_id == BASELINE_PERSONA_ID
210
+ ),
211
+ assistant_ids[0] if assistant_ids else None,
212
+ )
213
+ regular_ids = [
214
+ persona_id for persona_id in persona_ids if persona_id not in assistant_ids
215
+ ]
216
+ if not regular_ids and assistant_id is None:
217
+ st.info("No personas found for this model and variant.")
218
+ return None
219
+ return PersonaOptions(
220
+ regular_ids=regular_ids,
221
+ assistant_id=assistant_id,
222
+ persona_names=persona_names,
223
+ )
224
+
225
+
226
+ def _seed_persona_memory(
227
+ remember_key: str,
228
+ options: PersonaOptions,
229
+ *,
230
+ default_all: bool,
231
+ default_count_limit: int | None = None,
232
+ ) -> tuple[int, bool]:
233
+ remembered_count_key = f"{remember_key}:count"
234
+ remembered_assistant_key = f"{remember_key}:include_assistant"
235
+ legacy_ids = st.session_state.get(remember_key, [])
236
+ if isinstance(legacy_ids, list) and legacy_ids:
237
+ st.session_state.setdefault(
238
+ remembered_count_key,
239
+ sum(persona_id in options.regular_ids for persona_id in legacy_ids),
240
+ )
241
+ st.session_state.setdefault(
242
+ remembered_assistant_key,
243
+ options.assistant_id in legacy_ids,
244
+ )
245
+
246
+ if default_count_limit is not None:
247
+ default_count = min(default_count_limit, len(options.regular_ids))
248
+ elif default_all:
249
+ default_count = len(options.regular_ids)
250
+ else:
251
+ default_count = min(1, len(options.regular_ids))
252
+ remembered_count = int(st.session_state.get(remembered_count_key, default_count))
253
+ persona_count = min(max(remembered_count, 0), len(options.regular_ids))
254
+ include_assistant = bool(st.session_state.get(remembered_assistant_key, False))
255
+ return persona_count, include_assistant
256
+
257
+
258
+ def _render_persona_count_controls(
259
+ store: Store,
260
+ variants: list[str],
261
+ mask_strategy: MaskStrategy,
262
+ widget_scope: str,
263
+ options: PersonaOptions,
264
+ *,
265
+ default_count: int,
266
+ include_assistant_default: bool,
267
+ ) -> tuple[int, bool]:
268
+ count_key = widget_key(
269
+ "load",
270
+ "persona_count",
271
+ widget_scope,
272
+ store.model_name,
273
+ mask_strategy.value,
274
+ *variants,
275
+ )
276
+ assistant_key = widget_key(
277
+ "load",
278
+ "include_assistant",
279
+ widget_scope,
280
+ store.model_name,
281
+ mask_strategy.value,
282
+ *variants,
283
+ )
284
+
285
+ if options.regular_ids:
286
+ persona_count = st.slider(
287
+ "Personas",
288
+ min_value=0 if options.assistant_id is not None else 1,
289
+ max_value=len(options.regular_ids),
290
+ value=default_count,
291
+ key=count_key,
292
+ help="Use the first N available non-assistant personas.",
293
+ )
294
+ else:
295
+ persona_count = 0
296
+ st.caption("No non-assistant personas are available for this selection.")
297
+ include_assistant = False
298
+ if options.assistant_id is not None:
299
+ include_assistant = st.checkbox(
300
+ "Include Assistant persona",
301
+ value=include_assistant_default,
302
+ key=assistant_key,
303
+ )
304
+ return persona_count, include_assistant
305
+
306
+
307
+ def _select_artifact_personas(
308
+ store: Store,
309
+ variants: list[str],
310
+ mask_strategy: MaskStrategy,
311
+ *,
312
+ widget_scope: str,
313
+ remember_key: str,
314
+ default_all: bool = False,
315
+ default_count_limit: int | None = None,
316
+ ) -> list[str]:
317
+ empty_message = _personas_empty_message(variants)
318
+ options = _load_persona_options(
319
+ store,
320
+ variants,
321
+ mask_strategy,
322
+ empty_message=empty_message,
323
+ )
324
+ if options is None:
325
+ st.session_state.pop(_persona_names_state_key(widget_scope), None)
326
+ return []
327
+
328
+ default_count, include_assistant_default = _seed_persona_memory(
329
+ remember_key,
330
+ options,
331
+ default_all=default_all,
332
+ default_count_limit=default_count_limit,
333
+ )
334
+ persona_count, include_assistant = _render_persona_count_controls(
335
+ store,
336
+ variants,
337
+ mask_strategy,
338
+ widget_scope,
339
+ options,
340
+ default_count=default_count,
341
+ include_assistant_default=include_assistant_default,
342
+ )
343
+
344
+ persona_ids = options.regular_ids[:persona_count]
345
+ if include_assistant and options.assistant_id is not None:
346
+ persona_ids.append(options.assistant_id)
347
+
348
+ remembered_count_key = f"{remember_key}:count"
349
+ remembered_assistant_key = f"{remember_key}:include_assistant"
350
+ st.session_state[remembered_count_key] = persona_count
351
+ st.session_state[remembered_assistant_key] = include_assistant
352
+ st.session_state[remember_key] = persona_ids
353
+ st.session_state[_persona_names_state_key(widget_scope)] = options.persona_names
354
+
355
+ if not persona_ids:
356
+ st.info("Select at least one persona or include the Assistant persona.")
357
+ return []
358
+
359
+ regular_label = f"{persona_count} persona{'s' if persona_count != 1 else ''}"
360
+ assistant_label = (
361
+ " plus Assistant" if include_assistant and options.assistant_id else ""
362
+ )
363
+ st.caption(f"Using {regular_label}{assistant_label}.")
364
+ return persona_ids
365
+
366
+
367
+ def _render_save_buttons(
368
+ figs: list[object],
369
+ filenames: list[str],
370
+ key_suffix: str,
371
+ ) -> None:
372
+ """Render the Save HTML button for one or more figures."""
373
+ if st.button("Save HTML", key=widget_key("load", "save_html", key_suffix)):
374
+ try:
375
+ _style_plotly_figures(figs)
376
+ paths = [
377
+ save_plot_html(fig, fn) for fig, fn in zip(figs, filenames, strict=True)
378
+ ]
379
+ st.success(f"Saved {len(paths)} HTML file(s) to `artifacts/plots`.")
380
+ except Exception as exc:
381
+ st.error(f"Could not save HTML: {exc}")
382
+
383
+
384
+ def _style_plotly_figures(figs: list[object]) -> None:
385
+ base = active_base()
386
+ for fig in figs:
387
+ if isinstance(fig, go.Figure):
388
+ style_plotly_layer_controls(fig, base)
389
+
390
+
391
+ def _plotly_chart(fig: object) -> None:
392
+ _style_plotly_figures([fig])
393
+ st.plotly_chart(
394
+ fig,
395
+ width="stretch",
396
+ config={"responsive": True, "displaylogo": False},
397
+ )
398
+
399
+
400
+ def _render_mask_strategy_select(scope: str) -> MaskStrategy:
401
+ return render_mask_strategy_select(
402
+ key=widget_key("load", "mask_strategy", scope),
403
+ last_key=_LAST_MASK_STRATEGY_KEY,
404
+ help_text="Which extracted activation set to load.",
405
+ )
406
+
407
+
408
+ def _select_single_variant_samples(
409
+ store: Store,
410
+ mask_strategy: MaskStrategy,
411
+ scope: str,
412
+ *,
413
+ remember_key: str,
414
+ variant_remember_key: str,
415
+ default_count_limit: int,
416
+ ) -> tuple[str, list[str], str, list[int]] | None:
417
+ variants = available_variants(store, mask_strategy)
418
+ if not variants:
419
+ st.info("No variants with saved vectors for this model.")
420
+ return None
421
+ variant_key = widget_key("load", "variant", scope, store_id(store))
422
+ default_variant = "biography" if "biography" in variants else variants[0]
423
+ variant = _remembered_selectbox(
424
+ "Variant",
425
+ key=variant_key,
426
+ remember_key=variant_remember_key,
427
+ options=variants,
428
+ default=default_variant,
429
+ format_func=prompt_variant_label,
430
+ )
431
+ persona_ids = _select_artifact_personas(
432
+ store,
433
+ [variant],
434
+ mask_strategy,
435
+ widget_scope=f"{scope}:{store_id(store)}",
436
+ remember_key=remember_key,
437
+ default_count_limit=default_count_limit,
438
+ )
439
+ if not persona_ids:
440
+ return None
441
+
442
+ persona_key = personas_fingerprint(persona_ids)
443
+ layer_options = _layers_for_variant(store, variant, persona_ids, mask_strategy)
444
+ if not layer_options:
445
+ st.info("No shared layers are available for the selected personas.")
446
+ return None
447
+
448
+ selected_layers = _render_layer_frame_controls(store, scope, layer_options)
449
+ return variant, persona_ids, persona_key, selected_layers
tabs/analysis/_state.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+
3
+ import streamlit as st
4
+ from persona_data.synth_persona import BASELINE_PERSONA_ID
5
+ from persona_vectors.attributes import DEFAULT_MAX_ATTRIBUTE_CATEGORIES
6
+
7
+ from utils.helpers import slugify, widget_key
8
+
9
+
10
+ def _filename(*parts: str) -> str:
11
+ return "__".join(slugify(part) for part in parts if part)
12
+
13
+
14
+ # Keep analysis-tab selection state separate so projection defaults do not
15
+ # overwrite cosine similarity defaults.
16
+ _LAST_COSINE_PERSONAS_KEY = "analysis:last_personas:cosine"
17
+ _LAST_PROJECTION_PERSONAS_KEY = "analysis:last_personas:projection"
18
+ _LAST_SIMILARITY_PERSONAS_KEY = "analysis:last_personas:similarity"
19
+ _LAST_MASK_STRATEGY_KEY = "analysis:last_mask_strategy"
20
+ _LAST_SOURCE_KEY = "analysis:last_source"
21
+ _LAST_PROJECTION_VARIANT_KEY = "analysis:last_projection_variant"
22
+ _LAST_SIMILARITY_VARIANT_KEY = "analysis:last_similarity_variant"
23
+ _LAST_PROJECTION_COLOR_MODE_KEY = "analysis:last_projection_color_mode"
24
+ _LAST_PROJECTION_ATTRIBUTE_KEY = "analysis:last_projection_attribute"
25
+ _LAST_PROJECTION_CLUSTER_K_KEY = "analysis:last_projection_cluster_k"
26
+ _LAST_PROJECTION_CLUSTER_MODE_KEY = "analysis:last_projection_cluster_mode"
27
+ _LAST_PROJECTION_HIGHLIGHTS_KEY = "analysis:last_projection_highlights"
28
+ _LAST_PROJECTION_DIMS_KEY = "analysis:last_projection_dims"
29
+ _LAST_LAYER_FRAMES_KEY = "analysis:last_layer_frames"
30
+
31
+ _DEFAULT_LAYER_FRAMES = 16
32
+ _DEFAULT_PERSONA_LIMITS = {
33
+ "similarity": 120,
34
+ "pca": 500,
35
+ "umap": 500,
36
+ "isomap": 500,
37
+ "dendro": 160,
38
+ }
39
+ _MAX_SIMILARITY_CELLS = 4_000_000
40
+ _MAX_PAIR_TRAJECTORY_TRACES = 500
41
+ _DEFAULT_GRAPH_NEIGHBORS = 5
42
+ _PROJECTION_KINDS = {"pca", "umap", "isomap"}
43
+ _CLUSTER_MODES = {
44
+ "Mean across layers": "mean_across_layers",
45
+ "First selected layer": "first_layer",
46
+ "Per layer": "per_layer",
47
+ }
48
+ _PROJECTION_COLOR_MODES = ["Persona", "K-means clusters", "Persona attribute"]
49
+ _MAX_ATTRIBUTE_CATEGORIES = DEFAULT_MAX_ATTRIBUTE_CATEGORIES
50
+
51
+
52
+ def _is_assistant_persona(persona_id: str, persona_name: str | None = None) -> bool:
53
+ persona_id_normalized = persona_id.strip().lower()
54
+ persona_name_normalized = (persona_name or "").strip().lower()
55
+ return (
56
+ persona_id_normalized in {"assistant", BASELINE_PERSONA_ID.lower()}
57
+ or persona_name_normalized == "assistant"
58
+ )
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class CosineSelection:
63
+ variants: list[str]
64
+ variant_a: str
65
+ variant_b: str
66
+ persona_ids: list[str]
67
+ persona_key: str
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class PersonaOptions:
72
+ regular_ids: list[str]
73
+ assistant_id: str | None
74
+ persona_names: dict[str, str]
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class ProjectionColorConfig:
79
+ color_mode: str = "Persona"
80
+ n_clusters: int | None = None
81
+ cluster_mode: str | None = None
82
+ attribute_name: str | None = None
83
+ highlight_persona_ids: tuple[str, ...] = ()
84
+ highlight_persona_key: str = ""
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class LayeredFigureStateKeys:
89
+ figure: str
90
+ projection: str | None = None
91
+
92
+
93
+ _HIGHLIGHT_OTHER_LABEL = "Other"
94
+ _HIGHLIGHT_OTHER_COLOR = "rgba(148, 163, 184, 0.35)"
95
+
96
+
97
+ def _persona_names_state_key(widget_scope: str) -> str:
98
+ return widget_key("load", "persona_names", widget_scope)
99
+
100
+
101
+ def _persona_display_label(persona_names: dict[str, str], persona_id: str) -> str:
102
+ name = persona_names.get(persona_id, persona_id)
103
+ return f"{name} ({persona_id})" if name != persona_id else persona_id
104
+
105
+
106
+ def _highlight_persona_groups(
107
+ persona_ids: list[str],
108
+ persona_names: dict[str, str],
109
+ highlight_persona_ids: tuple[str, ...],
110
+ ) -> list[str] | None:
111
+ if not highlight_persona_ids:
112
+ return None
113
+
114
+ highlighted = set(highlight_persona_ids)
115
+ return [
116
+ (
117
+ _persona_display_label(persona_names, persona_id)
118
+ if persona_id in highlighted
119
+ else _HIGHLIGHT_OTHER_LABEL
120
+ )
121
+ for persona_id in persona_ids
122
+ ]
123
+
124
+
125
+ def _sequence_to_list(value: object) -> list[object] | None:
126
+ if value is None or isinstance(value, (str, bytes)):
127
+ return None
128
+ if isinstance(value, list):
129
+ return value
130
+ if isinstance(value, tuple):
131
+ return list(value)
132
+ try:
133
+ return list(value)
134
+ except TypeError:
135
+ return None
136
+
137
+
138
+ _TRACKED_STATE_KEYS_KEY = "analysis:_tracked_state_keys"
139
+
140
+
141
+ def _clear_old_load_states(current_key: str, suffix: str) -> None:
142
+ # Only one heavy figure/projection state should live at a time. We track
143
+ # the keys we create per suffix so eviction is O(1) instead of scanning
144
+ # all of session_state on every rerun. Every such key is passed through
145
+ # this function before it is set, so the registry stays authoritative.
146
+ tracked: dict[str, set[str]] = st.session_state.setdefault(
147
+ _TRACKED_STATE_KEYS_KEY, {}
148
+ )
149
+ for key in tracked.get(suffix, ()):
150
+ if key != current_key:
151
+ st.session_state.pop(key, None)
152
+ tracked[suffix] = {current_key}
153
+
154
+
155
+ def _clear_old_figure_states(current_key: str) -> None:
156
+ _clear_old_load_states(current_key, "_fig_state")
157
+
158
+
159
+ def _clear_old_projection_states(current_key: str) -> None:
160
+ _clear_old_load_states(current_key, "_projection_state")
161
+
162
+
163
+ def _store_figure_state(key: str, value: object) -> None:
164
+ _clear_old_figure_states(key)
165
+ st.session_state[key] = value
166
+
167
+
168
+ def _seed_selectbox_key(
169
+ *,
170
+ key: str,
171
+ remember_key: str,
172
+ options: list[str],
173
+ default: str,
174
+ ) -> str:
175
+ value = st.session_state.get(key, st.session_state.get(remember_key, default))
176
+ if value not in options:
177
+ value = default
178
+ return value
179
+
180
+
181
+ def _remembered_selectbox(
182
+ label: str,
183
+ *,
184
+ key: str,
185
+ remember_key: str,
186
+ options: list[str],
187
+ default: str,
188
+ **selectbox_kwargs: object,
189
+ ) -> str:
190
+ selected = _seed_selectbox_key(
191
+ key=key,
192
+ remember_key=remember_key,
193
+ options=options,
194
+ default=default,
195
+ )
196
+ choice = st.selectbox(
197
+ label,
198
+ options=options,
199
+ index=options.index(selected),
200
+ key=key,
201
+ **selectbox_kwargs,
202
+ )
203
+ st.session_state[remember_key] = choice
204
+ return choice
205
+
206
+
207
+ def _personas_empty_message(variants: list[str]) -> str:
208
+ if len(variants) > 1:
209
+ return (
210
+ "No personas have vectors for all selected variants. "
211
+ "Pick a single variant or change the source."
212
+ )
213
+ return "No personas found for this model and variant."
214
+
215
+
216
+ def _remember_multiselect(
217
+ *,
218
+ key: str,
219
+ remember_key: str,
220
+ options: list[str],
221
+ ) -> list[str]:
222
+ remembered = st.session_state.get(key, st.session_state.get(remember_key, []))
223
+ if not isinstance(remembered, list):
224
+ remembered = []
225
+ return [value for value in remembered if value in options]
tabs/analysis/cosine.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from itertools import combinations
2
+
3
+ import streamlit as st
4
+ from persona_vectors.extraction import MaskStrategy
5
+ from persona_vectors.plots import plot_layer_similarity
6
+
7
+ from utils.analysis_sources import Store, available_variants, store_id
8
+ from utils.helpers import personas_fingerprint, prompt_variant_label, widget_key
9
+
10
+ from tabs.analysis._shared import (
11
+ _load_variant_vectors,
12
+ _plotly_chart,
13
+ _release_vector_memory,
14
+ _render_save_buttons,
15
+ _select_artifact_personas,
16
+ )
17
+ from tabs.analysis._state import (
18
+ _LAST_COSINE_PERSONAS_KEY,
19
+ CosineSelection,
20
+ _clear_old_figure_states,
21
+ _filename,
22
+ _store_figure_state,
23
+ )
24
+
25
+
26
+ def _render_cosine_selection(
27
+ store: Store,
28
+ mask_strategy: MaskStrategy,
29
+ ) -> CosineSelection | None:
30
+ variants = available_variants(store, mask_strategy)
31
+ if len(variants) < 2:
32
+ st.info("Need at least two variants with saved vectors for cosine comparison.")
33
+ return None
34
+
35
+ with st.expander("Vector selection", expanded=True):
36
+ col1, col2 = st.columns(2)
37
+ with col1:
38
+ variant_a = st.selectbox(
39
+ "Variant A",
40
+ options=variants,
41
+ index=0,
42
+ format_func=prompt_variant_label,
43
+ key=widget_key("load", "variant_a", store_id(store)),
44
+ )
45
+ with col2:
46
+ variant_b = st.selectbox(
47
+ "Variant B",
48
+ options=variants,
49
+ index=min(1, len(variants) - 1),
50
+ format_func=prompt_variant_label,
51
+ key=widget_key("load", "variant_b", store_id(store)),
52
+ )
53
+
54
+ if variant_a == variant_b:
55
+ st.warning("Choose two different variants to compare.")
56
+ return None
57
+
58
+ persona_ids = _select_artifact_personas(
59
+ store,
60
+ [variant_a, variant_b],
61
+ mask_strategy,
62
+ widget_scope=f"cosine:{store_id(store)}",
63
+ remember_key=_LAST_COSINE_PERSONAS_KEY,
64
+ )
65
+ if not persona_ids:
66
+ return None
67
+ return CosineSelection(
68
+ variants=variants,
69
+ variant_a=variant_a,
70
+ variant_b=variant_b,
71
+ persona_ids=persona_ids,
72
+ persona_key=personas_fingerprint(persona_ids),
73
+ )
74
+
75
+
76
+ def _build_cosine_figures(
77
+ store: Store,
78
+ mask_strategy: MaskStrategy,
79
+ selection: CosineSelection,
80
+ ) -> tuple[object, object | None, int, int] | None:
81
+ variant_sample_cache: dict[str, object] = {}
82
+
83
+ def _load_variant(variant: str):
84
+ if variant not in variant_sample_cache:
85
+ samples = _load_variant_vectors(
86
+ store,
87
+ [variant],
88
+ mask_strategy,
89
+ persona_ids=selection.persona_ids,
90
+ )
91
+ variant_sample_cache[variant] = samples[variant]
92
+ return variant_sample_cache[variant]
93
+
94
+ try:
95
+ samples_a = _load_variant(selection.variant_a)
96
+ samples_b = _load_variant(selection.variant_b)
97
+ except Exception as exc:
98
+ st.error(f"Could not load vectors: {exc}")
99
+ return None
100
+
101
+ labels = samples_a.labels
102
+ display_traces = [
103
+ (
104
+ label,
105
+ samples_a.vectors[index],
106
+ samples_b.vectors[index],
107
+ )
108
+ for index, label in enumerate(labels)
109
+ ]
110
+ fig = plot_layer_similarity(
111
+ display_traces,
112
+ title=(
113
+ f"{prompt_variant_label(selection.variant_a)} vs "
114
+ f"{prompt_variant_label(selection.variant_b)}"
115
+ ),
116
+ show=False,
117
+ )
118
+
119
+ pair_traces = []
120
+ pair_errors = []
121
+ for left, right in combinations(selection.variants, 2):
122
+ try:
123
+ left_samples = _load_variant(left)
124
+ right_samples = _load_variant(right)
125
+ pair_traces.append(
126
+ (
127
+ f"{prompt_variant_label(left)} vs {prompt_variant_label(right)}",
128
+ left_samples.vectors.mean(dim=0),
129
+ right_samples.vectors.mean(dim=0),
130
+ )
131
+ )
132
+ except Exception as exc:
133
+ pair_errors.append(f"{left} vs {right}: {exc}")
134
+ continue
135
+
136
+ for err in pair_errors:
137
+ st.warning(f"Skipped pair trace: `{err}`")
138
+ pair_fig = (
139
+ plot_layer_similarity(
140
+ pair_traces,
141
+ title="Variant-pair cosine similarity averaged over selected personas",
142
+ show=False,
143
+ )
144
+ if pair_traces
145
+ else None
146
+ )
147
+ return fig, pair_fig, len(display_traces), len(pair_traces)
148
+
149
+
150
+ def _render_cosine_similarity(
151
+ store: Store,
152
+ mask_strategy: MaskStrategy,
153
+ ) -> None:
154
+ selection = _render_cosine_selection(store, mask_strategy)
155
+ if selection is None:
156
+ return
157
+
158
+ cosine_fig_key = widget_key(
159
+ "load",
160
+ "cosine_fig_state",
161
+ store_id(store),
162
+ store.model_name,
163
+ mask_strategy.value,
164
+ selection.variant_a,
165
+ selection.variant_b,
166
+ selection.persona_key,
167
+ )
168
+ filename = _filename(
169
+ "analysis",
170
+ "cosine",
171
+ store.model_name,
172
+ mask_strategy.value,
173
+ selection.variant_a,
174
+ selection.variant_b,
175
+ )
176
+ pairs_filename = _filename(
177
+ "analysis",
178
+ "cosine_pairs",
179
+ store.model_name,
180
+ mask_strategy.value,
181
+ "_".join(selection.variants),
182
+ )
183
+ _clear_old_figure_states(cosine_fig_key)
184
+
185
+ if st.button(
186
+ "Compare vectors",
187
+ type="primary",
188
+ key=widget_key(
189
+ "load",
190
+ "analysis_vectors",
191
+ store_id(store),
192
+ store.model_name,
193
+ mask_strategy.value,
194
+ selection.variant_a,
195
+ selection.variant_b,
196
+ selection.persona_key,
197
+ ),
198
+ ):
199
+ progress = st.progress(0, text="Loading activation vectors…")
200
+ try:
201
+ progress.progress(15, text="Loading activation vectors…")
202
+ figures = _build_cosine_figures(store, mask_strategy, selection)
203
+ if figures is None:
204
+ st.session_state.pop(cosine_fig_key, None)
205
+ return
206
+ progress.progress(90, text="Storing figure state…")
207
+ _store_figure_state(cosine_fig_key, figures)
208
+ progress.progress(100, text="Done.")
209
+ finally:
210
+ _release_vector_memory(store, selection.variants)
211
+ progress.empty()
212
+
213
+ if cosine_fig_key in st.session_state:
214
+ fig, pair_fig, n_traces, n_pair_traces = st.session_state[cosine_fig_key]
215
+ _plotly_chart(fig)
216
+ figs = [fig]
217
+ filenames = [filename]
218
+ if pair_fig is not None:
219
+ st.subheader("Variant pairs")
220
+ _plotly_chart(pair_fig)
221
+ figs.append(pair_fig)
222
+ filenames.append(pairs_filename)
223
+ _render_save_buttons(figs, filenames, "cosine")
224
+ st.success(f"Loaded {n_traces} personas for cosine comparison.")
225
+ if pair_fig is not None:
226
+ st.caption(f"Generated {n_pair_traces} averaged variant-pair trace(s).")
tabs/analysis/dendrogram.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ from persona_vectors.extraction import MaskStrategy
3
+ from persona_vectors.plots import plot_persona_dendrogram
4
+
5
+ from utils.analysis_sources import (
6
+ Store,
7
+ available_variants,
8
+ store_cache_parts,
9
+ store_id,
10
+ store_layers_cached,
11
+ )
12
+ from utils.helpers import personas_fingerprint, prompt_variant_label, widget_key
13
+
14
+ from tabs.analysis._shared import (
15
+ _load_persona_options,
16
+ _load_persona_vectors,
17
+ _plotly_chart,
18
+ _release_vector_memory,
19
+ _render_layer_frame_controls,
20
+ _render_save_buttons,
21
+ _select_artifact_personas,
22
+ )
23
+ from tabs.analysis._state import (
24
+ _DEFAULT_PERSONA_LIMITS,
25
+ PersonaOptions,
26
+ _clear_old_figure_states,
27
+ _filename,
28
+ _persona_names_state_key,
29
+ _personas_empty_message,
30
+ _store_figure_state,
31
+ )
32
+
33
+ _LAST_DENDRO_PERSONAS_KEY = "analysis:last_personas:dendro"
34
+ _DENDRO_LINKAGE_OPTIONS = ["ward", "complete", "average", "single"]
35
+
36
+
37
+ def _render_persona_select_controls(
38
+ options: PersonaOptions,
39
+ widget_scope: str,
40
+ ) -> list[str]:
41
+ select_key = widget_key("load", "persona_select", widget_scope)
42
+ assistant_key = widget_key("load", "persona_select_assistant", widget_scope)
43
+
44
+ label_map = {
45
+ pid: f"{options.persona_names.get(pid, pid)} ({pid})"
46
+ for pid in options.regular_ids
47
+ }
48
+ sorted_labels = sorted(label_map.values())
49
+ selected_labels = st.multiselect(
50
+ "Select personas",
51
+ options=sorted_labels,
52
+ key=select_key,
53
+ placeholder="Search and select personas...",
54
+ )
55
+ label_to_id = {v: k for k, v in label_map.items()}
56
+ selected_ids = [label_to_id[lbl] for lbl in selected_labels]
57
+
58
+ if options.assistant_id is not None:
59
+ include_assistant = st.checkbox(
60
+ "Include Assistant persona",
61
+ key=assistant_key,
62
+ )
63
+ if include_assistant:
64
+ selected_ids.append(options.assistant_id)
65
+
66
+ st.session_state[_persona_names_state_key(widget_scope)] = dict(
67
+ options.persona_names
68
+ )
69
+
70
+ if not selected_ids:
71
+ st.info("Select at least one persona.")
72
+
73
+ return selected_ids
74
+
75
+
76
+ def _render_dendrogram_analysis(
77
+ store: Store,
78
+ mask_strategy: MaskStrategy,
79
+ ) -> None:
80
+ variants = available_variants(store, mask_strategy)
81
+ if not variants:
82
+ st.info("No variants with saved vectors for this model.")
83
+ return
84
+
85
+ with st.expander("Variant selection", expanded=True):
86
+ col1, col2 = st.columns(2)
87
+ default_a = "biography" if "biography" in variants else variants[0]
88
+ default_b_idx = (
89
+ variants.index("templated")
90
+ if "templated" in variants
91
+ else min(1, len(variants) - 1)
92
+ )
93
+ with col1:
94
+ variant_a = st.selectbox(
95
+ "Variant A",
96
+ options=variants,
97
+ index=variants.index(default_a),
98
+ format_func=prompt_variant_label,
99
+ key=widget_key("load", "dendro_variant_a", store_id(store)),
100
+ )
101
+ with col2:
102
+ variant_b = st.selectbox(
103
+ "Variant B",
104
+ options=variants,
105
+ index=default_b_idx,
106
+ format_func=prompt_variant_label,
107
+ key=widget_key("load", "dendro_variant_b", store_id(store)),
108
+ )
109
+
110
+ shared_variants = list(dict.fromkeys([variant_a, variant_b]))
111
+
112
+ select_specific = st.toggle(
113
+ "Select specific personas",
114
+ value=False,
115
+ key=widget_key("load", "dendro_select_mode", store_id(store)),
116
+ help="Search and select specific personas instead of using the first N.",
117
+ )
118
+
119
+ if select_specific:
120
+ empty_message = _personas_empty_message(shared_variants)
121
+ options = _load_persona_options(
122
+ store,
123
+ shared_variants,
124
+ mask_strategy,
125
+ empty_message=empty_message,
126
+ )
127
+ if options is None:
128
+ st.session_state.pop(
129
+ _persona_names_state_key(f"dendro:{store_id(store)}"), None
130
+ )
131
+ return
132
+ persona_ids = _render_persona_select_controls(
133
+ options,
134
+ widget_scope=f"dendro:{store_id(store)}",
135
+ )
136
+ if not persona_ids:
137
+ return
138
+ else:
139
+ persona_ids = _select_artifact_personas(
140
+ store,
141
+ shared_variants,
142
+ mask_strategy,
143
+ widget_scope=f"dendro:{store_id(store)}",
144
+ remember_key=_LAST_DENDRO_PERSONAS_KEY,
145
+ default_count_limit=_DEFAULT_PERSONA_LIMITS["dendro"],
146
+ )
147
+ if not persona_ids:
148
+ return
149
+
150
+ col_opts1, col_opts2 = st.columns(2)
151
+ with col_opts1:
152
+ layered_mode = st.toggle(
153
+ "Per-layer animated",
154
+ value=False,
155
+ key=widget_key("load", "dendro_layered", store_id(store)),
156
+ help="Animated dendrogram with one frame per layer instead of averaging all layers.",
157
+ )
158
+ with col_opts2:
159
+ linkage = st.selectbox(
160
+ "Linkage",
161
+ options=_DENDRO_LINKAGE_OPTIONS,
162
+ index=0,
163
+ key=widget_key("load", "dendro_linkage", store_id(store)),
164
+ )
165
+
166
+ selected_layers: list[int] | None = None
167
+ if layered_mode:
168
+ source, location, model_name = store_cache_parts(store)
169
+ layer_options = store_layers_cached(
170
+ source,
171
+ location,
172
+ model_name,
173
+ mask_strategy.value,
174
+ tuple(shared_variants),
175
+ tuple(persona_ids),
176
+ )
177
+ if not layer_options:
178
+ st.info("No shared layers are available for the selected personas.")
179
+ return
180
+ selected_layers = _render_layer_frame_controls(store, "dendro", layer_options)
181
+
182
+ persona_key = personas_fingerprint(persona_ids)
183
+ fig_key = widget_key(
184
+ "load",
185
+ "dendro_fig_state",
186
+ store_id(store),
187
+ store.model_name,
188
+ mask_strategy.value,
189
+ variant_a,
190
+ variant_b,
191
+ persona_key,
192
+ str(layered_mode),
193
+ linkage,
194
+ "_".join(map(str, selected_layers or [])),
195
+ )
196
+ _clear_old_figure_states(fig_key)
197
+
198
+ if st.button(
199
+ "Generate dendrograms",
200
+ type="primary",
201
+ key=widget_key(
202
+ "load", "dendro_btn", store_id(store), variant_a, variant_b, persona_key
203
+ ),
204
+ ):
205
+ progress = st.progress(0, text="Loading first variant vectors…")
206
+ try:
207
+ progress.progress(15, text="Loading first variant vectors…")
208
+ samples_a = _load_persona_vectors(
209
+ store,
210
+ variant_a,
211
+ mask_strategy,
212
+ persona_ids,
213
+ )
214
+ progress.progress(40, text="Building first dendrogram…")
215
+ fig_a = plot_persona_dendrogram(
216
+ samples_a,
217
+ layered=layered_mode,
218
+ layers=selected_layers,
219
+ linkage=linkage,
220
+ title=f"Dendrogram — {prompt_variant_label(variant_a)}",
221
+ )
222
+ fig_a.update_layout(height=750)
223
+ del samples_a
224
+ fig_b = None
225
+ if variant_a != variant_b:
226
+ progress.progress(60, text="Loading second variant vectors…")
227
+ samples_b = _load_persona_vectors(
228
+ store,
229
+ variant_b,
230
+ mask_strategy,
231
+ persona_ids,
232
+ )
233
+ progress.progress(75, text="Building second dendrogram…")
234
+ fig_b = plot_persona_dendrogram(
235
+ samples_b,
236
+ layered=layered_mode,
237
+ layers=selected_layers,
238
+ linkage=linkage,
239
+ title=f"Dendrogram — {prompt_variant_label(variant_b)}",
240
+ )
241
+ fig_b.update_layout(height=750)
242
+ del samples_b
243
+ progress.progress(90, text="Storing figure state…")
244
+ _store_figure_state(
245
+ fig_key,
246
+ (fig_a, fig_b, len(persona_ids), variant_a, variant_b),
247
+ )
248
+ progress.progress(100, text="Done.")
249
+ except Exception as exc:
250
+ st.error(f"Could not build dendrogram: {exc}")
251
+ st.session_state.pop(fig_key, None)
252
+ finally:
253
+ _release_vector_memory(store, shared_variants)
254
+ progress.empty()
255
+
256
+ if fig_key in st.session_state:
257
+ fig_a, fig_b, n_personas, va, vb = st.session_state[fig_key]
258
+ if fig_b is not None:
259
+ col_a, col_b = st.columns(2)
260
+ with col_a:
261
+ st.subheader(prompt_variant_label(va))
262
+ _plotly_chart(fig_a)
263
+ with col_b:
264
+ st.subheader(prompt_variant_label(vb))
265
+ _plotly_chart(fig_b)
266
+ else:
267
+ _plotly_chart(fig_a)
268
+
269
+ figs = [fig_a] + ([fig_b] if fig_b else [])
270
+ filenames = [
271
+ _filename("dendro", store.model_name, mask_strategy.value, va),
272
+ *(
273
+ [_filename("dendro", store.model_name, mask_strategy.value, vb)]
274
+ if fig_b
275
+ else []
276
+ ),
277
+ ]
278
+ _render_save_buttons(figs, filenames, "dendro")
279
+ st.success(f"Generated dendrogram(s) for {n_personas} persona(s).")
tabs/analysis/layered.py ADDED
@@ -0,0 +1,563 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections.abc import Callable
2
+
3
+ import plotly.graph_objects as go
4
+ import streamlit as st
5
+ from persona_vectors.attributes import (
6
+ attribute_color_kwargs,
7
+ attribute_display_label,
8
+ )
9
+ from persona_vectors.extraction import MaskStrategy
10
+ from persona_vectors.plots import (
11
+ build_layered_figure,
12
+ build_pair_similarity_figure,
13
+ build_similarity_figures,
14
+ prepare_layered_projection_data,
15
+ )
16
+
17
+ from utils.analysis_metadata import (
18
+ synth_persona_attribute_names,
19
+ synth_persona_dataset_cached,
20
+ )
21
+ from utils.analysis_sources import Store, store_id
22
+ from utils.helpers import personas_fingerprint, prompt_variant_label, widget_key
23
+
24
+ from tabs.analysis._shared import (
25
+ _gray_out_unselected_personas,
26
+ _load_persona_vectors,
27
+ _plotly_chart,
28
+ _release_vector_memory,
29
+ _render_save_buttons,
30
+ _select_single_variant_samples,
31
+ )
32
+ from tabs.analysis._state import (
33
+ _CLUSTER_MODES,
34
+ _DEFAULT_GRAPH_NEIGHBORS,
35
+ _LAST_PROJECTION_ATTRIBUTE_KEY,
36
+ _LAST_PROJECTION_CLUSTER_K_KEY,
37
+ _LAST_PROJECTION_CLUSTER_MODE_KEY,
38
+ _LAST_PROJECTION_COLOR_MODE_KEY,
39
+ _LAST_PROJECTION_HIGHLIGHTS_KEY,
40
+ _LAST_PROJECTION_PERSONAS_KEY,
41
+ _LAST_PROJECTION_VARIANT_KEY,
42
+ _LAST_SIMILARITY_VARIANT_KEY,
43
+ _MAX_ATTRIBUTE_CATEGORIES,
44
+ _MAX_PAIR_TRAJECTORY_TRACES,
45
+ _MAX_SIMILARITY_CELLS,
46
+ _PROJECTION_COLOR_MODES,
47
+ _PROJECTION_KINDS,
48
+ LayeredFigureStateKeys,
49
+ ProjectionColorConfig,
50
+ _clear_old_figure_states,
51
+ _clear_old_projection_states,
52
+ _highlight_persona_groups,
53
+ _persona_display_label,
54
+ _persona_names_state_key,
55
+ _remember_multiselect,
56
+ _remembered_selectbox,
57
+ _store_figure_state,
58
+ )
59
+
60
+
61
+ def _render_pair_trajectory_control(
62
+ *,
63
+ enabled: bool,
64
+ persona_count: int,
65
+ scope: str,
66
+ store: Store,
67
+ ) -> bool:
68
+ if not enabled:
69
+ return False
70
+ pair_count = persona_count * (persona_count - 1) // 2
71
+ if pair_count > _MAX_PAIR_TRAJECTORY_TRACES:
72
+ st.caption(
73
+ "Pair trajectories hidden because this selection would create "
74
+ f"{pair_count:,} Plotly traces."
75
+ )
76
+ return False
77
+ return st.checkbox(
78
+ "Pair trajectories",
79
+ value=False,
80
+ key=widget_key("load", "pair_trajectories", scope, store_id(store)),
81
+ help="Adds one line per persona pair. Keep this off for larger selections.",
82
+ )
83
+
84
+
85
+ def _validate_layered_figure_size(
86
+ figure_kind: str,
87
+ persona_count: int,
88
+ selected_layers: list[int],
89
+ ) -> bool:
90
+ if figure_kind != "similarity":
91
+ return True
92
+ similarity_cells = persona_count * persona_count * len(selected_layers)
93
+ if similarity_cells <= _MAX_SIMILARITY_CELLS:
94
+ return True
95
+ st.error(
96
+ "Reduce personas or layer frames before generating the similarity "
97
+ f"matrix ({similarity_cells:,} cells selected)."
98
+ )
99
+ return False
100
+
101
+
102
+ def _render_projection_color_config(
103
+ store: Store,
104
+ scope: str,
105
+ persona_ids: list[str],
106
+ ) -> ProjectionColorConfig | None:
107
+ widget_scope = f"{scope}:{store_id(store)}"
108
+ persona_key = personas_fingerprint(persona_ids)
109
+ persona_names = st.session_state.get(
110
+ _persona_names_state_key(widget_scope),
111
+ {},
112
+ )
113
+ color_mode_key = widget_key("load", "color_mode", scope, store_id(store))
114
+ color_mode = _remembered_selectbox(
115
+ "Color by",
116
+ key=color_mode_key,
117
+ remember_key=_LAST_PROJECTION_COLOR_MODE_KEY,
118
+ options=_PROJECTION_COLOR_MODES,
119
+ default="Persona",
120
+ )
121
+ if color_mode == "K-means clusters":
122
+ max_clusters = min(10, len(persona_ids))
123
+ if max_clusters < 2:
124
+ st.info("Select at least two personas to use K-means coloring.")
125
+ return None
126
+ cluster_key = widget_key("load", "cluster_k", scope, store_id(store))
127
+ default_clusters = min(3, len(persona_ids))
128
+ if cluster_key not in st.session_state:
129
+ st.session_state[cluster_key] = min(
130
+ max(
131
+ int(
132
+ st.session_state.get(
133
+ _LAST_PROJECTION_CLUSTER_K_KEY,
134
+ default_clusters,
135
+ )
136
+ ),
137
+ 2,
138
+ ),
139
+ max_clusters,
140
+ )
141
+ n_clusters = st.slider(
142
+ "K (clusters)",
143
+ min_value=2,
144
+ max_value=max_clusters,
145
+ key=cluster_key,
146
+ )
147
+ mode_key = widget_key("load", "cluster_mode", scope, store_id(store))
148
+ mode_options = list(_CLUSTER_MODES)
149
+ mode_label = _remembered_selectbox(
150
+ "Cluster fit",
151
+ key=mode_key,
152
+ remember_key=_LAST_PROJECTION_CLUSTER_MODE_KEY,
153
+ options=mode_options,
154
+ default=mode_options[0],
155
+ help=(
156
+ "Mean across layers is the previous behavior. First selected "
157
+ "layer keeps one fixed clustering from the first frame. Per layer "
158
+ "recomputes clustering for each animation frame."
159
+ ),
160
+ )
161
+ st.session_state[_LAST_PROJECTION_CLUSTER_K_KEY] = n_clusters
162
+ return ProjectionColorConfig(
163
+ color_mode=color_mode,
164
+ n_clusters=n_clusters,
165
+ cluster_mode=_CLUSTER_MODES[mode_label],
166
+ )
167
+
168
+ if color_mode == "Persona attribute":
169
+ persona_dataset = synth_persona_dataset_cached()
170
+ attribute_options = list(synth_persona_attribute_names())
171
+ if not attribute_options:
172
+ st.info("No persona attributes are available for this dataset.")
173
+ return None
174
+ default_attribute = (
175
+ attribute_options.index("sex") if "sex" in attribute_options else 0
176
+ )
177
+ attribute_key = widget_key("load", "attribute", scope, store_id(store))
178
+ attribute_name = _remembered_selectbox(
179
+ "Attribute",
180
+ key=attribute_key,
181
+ remember_key=_LAST_PROJECTION_ATTRIBUTE_KEY,
182
+ options=attribute_options,
183
+ default=attribute_options[default_attribute],
184
+ format_func=lambda name: attribute_display_label(persona_dataset, name),
185
+ )
186
+ info = persona_dataset.attribute_info(attribute_name)
187
+ if info.get("high_cardinality"):
188
+ st.caption(
189
+ "High-cardinality categorical attributes are grouped to the "
190
+ f"top {_MAX_ATTRIBUTE_CATEGORIES} values plus Other."
191
+ )
192
+ return ProjectionColorConfig(
193
+ color_mode=color_mode,
194
+ attribute_name=attribute_name,
195
+ )
196
+
197
+ highlight_persona_ids: tuple[str, ...] = ()
198
+ if persona_ids:
199
+ highlight_key = widget_key(
200
+ "load", "persona_highlight", scope, store_id(store), persona_key
201
+ )
202
+ highlighted = st.multiselect(
203
+ "Highlight personas",
204
+ options=persona_ids,
205
+ default=_remember_multiselect(
206
+ key=highlight_key,
207
+ remember_key=_LAST_PROJECTION_HIGHLIGHTS_KEY,
208
+ options=persona_ids,
209
+ ),
210
+ format_func=lambda persona_id: _persona_display_label(
211
+ persona_names, persona_id
212
+ ),
213
+ key=highlight_key,
214
+ help=(
215
+ "Select a few personas to keep their default colors while the rest "
216
+ "are grayed out."
217
+ ),
218
+ )
219
+ highlight_persona_ids = tuple(highlighted)
220
+ st.session_state[_LAST_PROJECTION_HIGHLIGHTS_KEY] = list(highlighted)
221
+
222
+ highlight_persona_key = (
223
+ personas_fingerprint(highlight_persona_ids) if highlight_persona_ids else ""
224
+ )
225
+
226
+ return ProjectionColorConfig(
227
+ color_mode=color_mode,
228
+ highlight_persona_ids=highlight_persona_ids,
229
+ highlight_persona_key=highlight_persona_key,
230
+ )
231
+
232
+
233
+ def _layered_figure_state_keys(
234
+ store: Store,
235
+ mask_strategy: MaskStrategy,
236
+ *,
237
+ scope: str,
238
+ figure_kind: str,
239
+ n_components: int,
240
+ color_config: ProjectionColorConfig,
241
+ variant: str,
242
+ persona_key: str,
243
+ selected_layers: list[int],
244
+ pair_trajectories: bool,
245
+ ) -> LayeredFigureStateKeys:
246
+ layer_key = "_".join(map(str, selected_layers))
247
+ figure_key = widget_key(
248
+ "load",
249
+ f"{scope}_fig_state",
250
+ store_id(store),
251
+ store.model_name,
252
+ mask_strategy.value,
253
+ figure_kind,
254
+ str(n_components),
255
+ color_config.color_mode,
256
+ str(color_config.attribute_name),
257
+ str(color_config.n_clusters),
258
+ str(color_config.cluster_mode),
259
+ str(color_config.highlight_persona_key),
260
+ variant,
261
+ "persona_vector",
262
+ persona_key,
263
+ layer_key,
264
+ str(pair_trajectories),
265
+ )
266
+ if figure_kind not in _PROJECTION_KINDS:
267
+ return LayeredFigureStateKeys(figure=figure_key)
268
+
269
+ graph_overlay = figure_kind == "isomap"
270
+ projection_key = widget_key(
271
+ "load",
272
+ f"{scope}_projection_state",
273
+ store_id(store),
274
+ store.model_name,
275
+ mask_strategy.value,
276
+ figure_kind,
277
+ str(n_components),
278
+ str(graph_overlay),
279
+ str(_DEFAULT_GRAPH_NEIGHBORS),
280
+ variant,
281
+ "persona_vector",
282
+ persona_key,
283
+ layer_key,
284
+ )
285
+ return LayeredFigureStateKeys(figure=figure_key, projection=projection_key)
286
+
287
+
288
+ def _projection_build_kwargs(
289
+ samples,
290
+ *,
291
+ figure_kind: str,
292
+ selected_layers: list[int],
293
+ n_components: int,
294
+ color_config: ProjectionColorConfig,
295
+ persona_ids: list[str],
296
+ persona_names: dict[str, str],
297
+ projection_key: str | None,
298
+ ) -> dict:
299
+ if figure_kind not in _PROJECTION_KINDS:
300
+ return {}
301
+
302
+ graph_overlay = figure_kind == "isomap"
303
+ build_kwargs = {
304
+ "n_components": n_components,
305
+ "graph_overlay": graph_overlay,
306
+ "graph_n_neighbors": _DEFAULT_GRAPH_NEIGHBORS,
307
+ }
308
+ if color_config.n_clusters is not None:
309
+ build_kwargs["n_clusters"] = color_config.n_clusters
310
+ build_kwargs["cluster_mode"] = color_config.cluster_mode
311
+ if projection_key is not None:
312
+ projection_data = st.session_state.get(projection_key)
313
+ if projection_data is None:
314
+ projection_data = prepare_layered_projection_data(
315
+ samples,
316
+ figure_kind,
317
+ layers=selected_layers,
318
+ n_components=n_components,
319
+ graph_overlay=graph_overlay,
320
+ graph_n_neighbors=_DEFAULT_GRAPH_NEIGHBORS,
321
+ )
322
+ st.session_state[projection_key] = projection_data
323
+ build_kwargs["projection_data"] = projection_data
324
+ if color_config.attribute_name is not None:
325
+ build_kwargs.update(
326
+ attribute_color_kwargs(
327
+ synth_persona_dataset_cached(),
328
+ color_config.attribute_name,
329
+ persona_ids,
330
+ max_categories=_MAX_ATTRIBUTE_CATEGORIES,
331
+ )
332
+ )
333
+ if color_config.color_mode == "Persona" and color_config.highlight_persona_ids:
334
+ groups = _highlight_persona_groups(
335
+ persona_ids,
336
+ persona_names,
337
+ color_config.highlight_persona_ids,
338
+ )
339
+ if groups is not None:
340
+ build_kwargs["groups"] = groups
341
+ return build_kwargs
342
+
343
+
344
+ def _build_layered_analysis_figures(
345
+ samples,
346
+ *,
347
+ figure_kind: str,
348
+ selected_layers: list[int],
349
+ variant: str,
350
+ title_fn: Callable[[str], str],
351
+ pair_trajectories: bool,
352
+ build_kwargs: dict,
353
+ ) -> tuple[go.Figure, go.Figure | None]:
354
+ if figure_kind == "similarity" and pair_trajectories:
355
+ return build_similarity_figures(
356
+ samples,
357
+ layers=selected_layers,
358
+ title=title_fn(variant),
359
+ pair_title=(
360
+ "Pair similarity trajectories - "
361
+ f"{prompt_variant_label(variant)} - persona vectors"
362
+ ),
363
+ )
364
+
365
+ main_fig = build_layered_figure(
366
+ samples,
367
+ figure_kind,
368
+ layers=selected_layers,
369
+ title=title_fn(variant),
370
+ **build_kwargs,
371
+ )
372
+ if figure_kind == "isomap":
373
+ _add_isomap_connection_toggle(main_fig)
374
+ if figure_kind in _PROJECTION_KINDS:
375
+ main_fig.update_layout(height=700)
376
+ extra_fig = (
377
+ build_pair_similarity_figure(
378
+ samples,
379
+ layers=selected_layers,
380
+ title=(
381
+ "Pair similarity trajectories - "
382
+ f"{prompt_variant_label(variant)} - persona vectors"
383
+ ),
384
+ )
385
+ if pair_trajectories
386
+ else None
387
+ )
388
+ return main_fig, extra_fig
389
+
390
+
391
+ def _add_isomap_connection_toggle(fig: go.Figure) -> None:
392
+ """Add an in-plot control for the Isomap kNN graph trace."""
393
+ if not fig.data or fig.data[0].name != "kNN graph":
394
+ return
395
+
396
+ existing_menus = tuple(fig.layout.updatemenus or ())
397
+ fig.update_layout(
398
+ updatemenus=existing_menus
399
+ + (
400
+ dict(
401
+ type="buttons",
402
+ direction="left",
403
+ active=0,
404
+ showactive=False,
405
+ x=0,
406
+ xanchor="left",
407
+ y=1.16,
408
+ yanchor="top",
409
+ pad=dict(t=0, r=10),
410
+ buttons=[
411
+ dict(
412
+ label="Show connections",
413
+ method="restyle",
414
+ args=[{"visible": True}, [0]],
415
+ ),
416
+ dict(
417
+ label="Hide connections",
418
+ method="restyle",
419
+ args=[{"visible": False}, [0]],
420
+ ),
421
+ ],
422
+ ),
423
+ ),
424
+ )
425
+
426
+
427
+ def _render_layered_figure_analysis(
428
+ store: Store,
429
+ mask_strategy: MaskStrategy,
430
+ *,
431
+ scope: str,
432
+ figure_kind: str,
433
+ button_label: str,
434
+ title_fn: Callable[[str], str],
435
+ include_pair_trajectories: bool = False,
436
+ n_components: int = 2,
437
+ remember_key: str = _LAST_PROJECTION_PERSONAS_KEY,
438
+ default_count_limit: int = 500,
439
+ ) -> None:
440
+ """Render a single-variant layered analysis: select → button → figure(s).
441
+
442
+ Used for similarity matrix, PCA, and UMAP. Set ``include_pair_trajectories``
443
+ to add the pair-similarity-trajectory figure (similarity matrix only).
444
+ """
445
+ selected = _select_single_variant_samples(
446
+ store,
447
+ mask_strategy,
448
+ scope,
449
+ remember_key=remember_key,
450
+ variant_remember_key=(
451
+ _LAST_PROJECTION_VARIANT_KEY
452
+ if figure_kind in _PROJECTION_KINDS
453
+ else _LAST_SIMILARITY_VARIANT_KEY
454
+ ),
455
+ default_count_limit=default_count_limit,
456
+ )
457
+ if selected is None:
458
+ return
459
+ variant, persona_ids, persona_key, selected_layers = selected
460
+
461
+ pair_trajectories = _render_pair_trajectory_control(
462
+ enabled=include_pair_trajectories,
463
+ persona_count=len(persona_ids),
464
+ scope=scope,
465
+ store=store,
466
+ )
467
+ if not _validate_layered_figure_size(
468
+ figure_kind, len(persona_ids), selected_layers
469
+ ):
470
+ return
471
+
472
+ color_config = ProjectionColorConfig()
473
+ if figure_kind in _PROJECTION_KINDS:
474
+ color_config = _render_projection_color_config(store, scope, persona_ids)
475
+ if color_config is None:
476
+ return
477
+
478
+ state_keys = _layered_figure_state_keys(
479
+ store,
480
+ mask_strategy,
481
+ scope=scope,
482
+ figure_kind=figure_kind,
483
+ n_components=n_components,
484
+ color_config=color_config,
485
+ variant=variant,
486
+ persona_key=persona_key,
487
+ selected_layers=selected_layers,
488
+ pair_trajectories=pair_trajectories,
489
+ )
490
+ if state_keys.projection is not None:
491
+ _clear_old_projection_states(state_keys.projection)
492
+ filename = scope
493
+ _clear_old_figure_states(state_keys.figure)
494
+ persona_names = st.session_state.get(
495
+ _persona_names_state_key(f"{scope}:{store_id(store)}"),
496
+ {},
497
+ )
498
+
499
+ if st.button(button_label, type="primary"):
500
+ build_label = {
501
+ "umap": "Computing UMAP projections…",
502
+ "pca": "Computing PCA projections…",
503
+ "isomap": "Computing Isomap projections…",
504
+ "similarity": "Computing similarity matrices…",
505
+ }.get(figure_kind, "Building figure…")
506
+ progress = st.progress(0, text="Loading activation vectors…")
507
+ try:
508
+ progress.progress(15, text="Loading activation vectors…")
509
+ samples = _load_persona_vectors(
510
+ store,
511
+ variant,
512
+ mask_strategy,
513
+ persona_ids,
514
+ )
515
+ progress.progress(55, text=build_label)
516
+ build_kwargs = _projection_build_kwargs(
517
+ samples,
518
+ figure_kind=figure_kind,
519
+ selected_layers=selected_layers,
520
+ n_components=n_components,
521
+ color_config=color_config,
522
+ persona_ids=persona_ids,
523
+ persona_names=persona_names,
524
+ projection_key=state_keys.projection,
525
+ )
526
+ main_fig, extra_fig = _build_layered_analysis_figures(
527
+ samples,
528
+ figure_kind=figure_kind,
529
+ selected_layers=selected_layers,
530
+ variant=variant,
531
+ title_fn=title_fn,
532
+ pair_trajectories=pair_trajectories,
533
+ build_kwargs=build_kwargs,
534
+ )
535
+ if (
536
+ color_config.color_mode == "Persona"
537
+ and color_config.highlight_persona_ids
538
+ ):
539
+ _gray_out_unselected_personas(main_fig)
540
+ progress.progress(90, text="Storing figure state…")
541
+ n_samples = samples.vectors.shape[0]
542
+ del samples
543
+ _store_figure_state(state_keys.figure, (main_fig, extra_fig, n_samples))
544
+ progress.progress(100, text="Done.")
545
+ except Exception as exc:
546
+ st.error(f"Could not build figure: {exc}")
547
+ st.session_state.pop(state_keys.figure, None)
548
+ finally:
549
+ _release_vector_memory(store, [variant])
550
+ progress.empty()
551
+
552
+ if state_keys.figure in st.session_state:
553
+ main_fig, extra_fig, n_samples = st.session_state[state_keys.figure]
554
+ _plotly_chart(main_fig)
555
+ figs = [main_fig]
556
+ filenames = [filename]
557
+ if extra_fig is not None:
558
+ st.subheader("Pair trajectories")
559
+ _plotly_chart(extra_fig)
560
+ figs.append(extra_fig)
561
+ filenames.append(f"{filename}__pair_trajectories")
562
+ _render_save_buttons(figs, filenames, scope)
563
+ st.success(f"Loaded {n_samples} samples.")
tabs/analysis_core.py CHANGED
@@ -1,28 +1,8 @@
1
- import gc
2
- from collections.abc import Callable
3
- from dataclasses import dataclass
4
- from itertools import combinations
5
  from pathlib import Path
6
 
7
- import plotly.graph_objects as go
8
  import streamlit as st
9
  from persona_data.environment import get_artifacts_dir
10
- from persona_data.synth_persona import BASELINE_PERSONA_ID
11
- from persona_vectors.attributes import (
12
- DEFAULT_MAX_ATTRIBUTE_CATEGORIES,
13
- attribute_color_kwargs,
14
- attribute_display_label,
15
- )
16
  from persona_vectors.extraction import MaskStrategy
17
- from persona_vectors.plots import (
18
- build_layered_figure,
19
- build_pair_similarity_figure,
20
- build_similarity_figures,
21
- plot_layer_similarity,
22
- plot_persona_dendrogram,
23
- prepare_layered_projection_data,
24
- save_plot_html,
25
- )
26
 
27
  from utils.analysis_sources import (
28
  DEFAULT_COMPARE_MODEL,
@@ -32,1611 +12,27 @@ from utils.analysis_sources import (
32
  SOURCES,
33
  Store,
34
  activation_store_cached,
35
- available_variants,
36
  hub_models_by_mask_strategy,
37
- load_persona_vectors_cached,
38
- load_variant_vectors_cached,
39
  local_model_matches,
40
  local_model_options_cached,
41
- persona_names_cached,
42
- personas_cached,
43
- release_hf_store_cache,
44
- store_cache_parts,
45
- store_id,
46
- store_layers_cached,
47
- )
48
- from utils.analysis_metadata import (
49
- synth_persona_attribute_names,
50
- synth_persona_dataset_cached,
51
  )
52
- from utils.controls import render_mask_strategy_select
53
  from utils.helpers import (
54
  ANALYSIS_HELP_TEXT,
55
  ANALYSIS_MODES,
56
- personas_fingerprint,
57
  prompt_variant_label,
58
- slugify,
59
  widget_key,
60
  )
61
- from utils.theme import active_base, style_plotly_layer_controls
62
-
63
-
64
- def _filename(*parts: str) -> str:
65
- return "__".join(slugify(part) for part in parts if part)
66
-
67
-
68
- # Keep analysis-tab selection state separate so projection defaults do not
69
- # overwrite cosine similarity defaults.
70
- _LAST_COSINE_PERSONAS_KEY = "analysis:last_personas:cosine"
71
- _LAST_PROJECTION_PERSONAS_KEY = "analysis:last_personas:projection"
72
- _LAST_SIMILARITY_PERSONAS_KEY = "analysis:last_personas:similarity"
73
- _LAST_MASK_STRATEGY_KEY = "analysis:last_mask_strategy"
74
- _LAST_SOURCE_KEY = "analysis:last_source"
75
- _LAST_PROJECTION_VARIANT_KEY = "analysis:last_projection_variant"
76
- _LAST_SIMILARITY_VARIANT_KEY = "analysis:last_similarity_variant"
77
- _LAST_PROJECTION_COLOR_MODE_KEY = "analysis:last_projection_color_mode"
78
- _LAST_PROJECTION_ATTRIBUTE_KEY = "analysis:last_projection_attribute"
79
- _LAST_PROJECTION_CLUSTER_K_KEY = "analysis:last_projection_cluster_k"
80
- _LAST_PROJECTION_CLUSTER_MODE_KEY = "analysis:last_projection_cluster_mode"
81
- _LAST_PROJECTION_HIGHLIGHTS_KEY = "analysis:last_projection_highlights"
82
- _LAST_PROJECTION_DIMS_KEY = "analysis:last_projection_dims"
83
- _LAST_LAYER_FRAMES_KEY = "analysis:last_layer_frames"
84
-
85
- _DEFAULT_LAYER_FRAMES = 16
86
- _DEFAULT_PERSONA_LIMITS = {
87
- "similarity": 120,
88
- "pca": 500,
89
- "umap": 500,
90
- "isomap": 500,
91
- "dendro": 160,
92
- }
93
- _MAX_SIMILARITY_CELLS = 4_000_000
94
- _MAX_PAIR_TRAJECTORY_TRACES = 500
95
- _DEFAULT_GRAPH_NEIGHBORS = 5
96
- _PROJECTION_KINDS = {"pca", "umap", "isomap"}
97
- _CLUSTER_MODES = {
98
- "Mean across layers": "mean_across_layers",
99
- "First selected layer": "first_layer",
100
- "Per layer": "per_layer",
101
- }
102
- _PROJECTION_COLOR_MODES = ["Persona", "K-means clusters", "Persona attribute"]
103
- _MAX_ATTRIBUTE_CATEGORIES = DEFAULT_MAX_ATTRIBUTE_CATEGORIES
104
-
105
-
106
- def _is_assistant_persona(persona_id: str, persona_name: str | None = None) -> bool:
107
- persona_id_normalized = persona_id.strip().lower()
108
- persona_name_normalized = (persona_name or "").strip().lower()
109
- return (
110
- persona_id_normalized in {"assistant", BASELINE_PERSONA_ID.lower()}
111
- or persona_name_normalized == "assistant"
112
- )
113
-
114
-
115
- @dataclass(frozen=True)
116
- class CosineSelection:
117
- variants: list[str]
118
- variant_a: str
119
- variant_b: str
120
- persona_ids: list[str]
121
- persona_key: str
122
-
123
-
124
- @dataclass(frozen=True)
125
- class PersonaOptions:
126
- regular_ids: list[str]
127
- assistant_id: str | None
128
- persona_names: dict[str, str]
129
-
130
-
131
- @dataclass(frozen=True)
132
- class ProjectionColorConfig:
133
- color_mode: str = "Persona"
134
- n_clusters: int | None = None
135
- cluster_mode: str | None = None
136
- attribute_name: str | None = None
137
- highlight_persona_ids: tuple[str, ...] = ()
138
- highlight_persona_key: str = ""
139
-
140
-
141
- @dataclass(frozen=True)
142
- class LayeredFigureStateKeys:
143
- figure: str
144
- projection: str | None = None
145
-
146
-
147
- _HIGHLIGHT_OTHER_LABEL = "Other"
148
- _HIGHLIGHT_OTHER_COLOR = "rgba(148, 163, 184, 0.35)"
149
-
150
-
151
- def _persona_names_state_key(widget_scope: str) -> str:
152
- return widget_key("load", "persona_names", widget_scope)
153
-
154
-
155
- def _persona_display_label(persona_names: dict[str, str], persona_id: str) -> str:
156
- name = persona_names.get(persona_id, persona_id)
157
- return f"{name} ({persona_id})" if name != persona_id else persona_id
158
-
159
-
160
- def _highlight_persona_groups(
161
- persona_ids: list[str],
162
- persona_names: dict[str, str],
163
- highlight_persona_ids: tuple[str, ...],
164
- ) -> list[str] | None:
165
- if not highlight_persona_ids:
166
- return None
167
-
168
- highlighted = set(highlight_persona_ids)
169
- return [
170
- (
171
- _persona_display_label(persona_names, persona_id)
172
- if persona_id in highlighted
173
- else _HIGHLIGHT_OTHER_LABEL
174
- )
175
- for persona_id in persona_ids
176
- ]
177
-
178
-
179
- def _sequence_to_list(value: object) -> list[object] | None:
180
- if value is None or isinstance(value, (str, bytes)):
181
- return None
182
- if isinstance(value, list):
183
- return value
184
- if isinstance(value, tuple):
185
- return list(value)
186
- try:
187
- return list(value)
188
- except TypeError:
189
- return None
190
-
191
-
192
- def _gray_out_unselected_personas(fig: go.Figure) -> None:
193
- def _gray_trace(trace: object) -> None:
194
- marker = getattr(trace, "marker", None)
195
- if marker is None:
196
- return
197
-
198
- colors = _sequence_to_list(getattr(marker, "color", None))
199
- labels = _sequence_to_list(getattr(trace, "customdata", None))
200
- if colors is not None and labels is not None and len(colors) == len(labels):
201
- trace.marker.color = [
202
- (
203
- _HIGHLIGHT_OTHER_COLOR
204
- if str(label) == _HIGHLIGHT_OTHER_LABEL
205
- else color
206
- )
207
- for label, color in zip(labels, colors, strict=True)
208
- ]
209
- return
210
-
211
- if getattr(trace, "name", None) == _HIGHLIGHT_OTHER_LABEL:
212
- trace.marker.color = _HIGHLIGHT_OTHER_COLOR
213
- trace.opacity = 0.28
214
-
215
- for trace in fig.data:
216
- _gray_trace(trace)
217
- for frame in fig.frames:
218
- for trace in frame.data:
219
- _gray_trace(trace)
220
-
221
-
222
- def _layers_for_variant(
223
- store: Store,
224
- variant: str,
225
- persona_ids: list[str],
226
- mask_strategy: MaskStrategy,
227
- ) -> list[int]:
228
- source, location, model_name = store_cache_parts(store)
229
- return store_layers_cached(
230
- source,
231
- location,
232
- model_name,
233
- mask_strategy.value,
234
- (variant,),
235
- tuple(persona_ids),
236
- )
237
-
238
-
239
- def _load_persona_vectors(
240
- store: Store,
241
- variant: str,
242
- mask_strategy: MaskStrategy,
243
- persona_ids: list[str],
244
- ):
245
- source, location, model_name = store_cache_parts(store)
246
- return load_persona_vectors_cached(
247
- source,
248
- location,
249
- model_name,
250
- mask_strategy.value,
251
- variant,
252
- tuple(persona_ids),
253
- )
254
-
255
-
256
- def _load_variant_vectors(
257
- store: Store,
258
- variants: list[str] | tuple[str, ...],
259
- mask_strategy: MaskStrategy,
260
- persona_ids: list[str],
261
- ):
262
- source, location, model_name = store_cache_parts(store)
263
- return load_variant_vectors_cached(
264
- source,
265
- location,
266
- model_name,
267
- mask_strategy.value,
268
- tuple(variants),
269
- tuple(persona_ids),
270
- )
271
-
272
-
273
- def _clear_old_load_states(current_key: str, suffix: str) -> None:
274
- for key in list(st.session_state):
275
- if key == current_key or not isinstance(key, str):
276
- continue
277
- parts = key.split("::", 2)
278
- if len(parts) >= 2 and parts[0] == "load" and parts[1].endswith(suffix):
279
- st.session_state.pop(key, None)
280
-
281
-
282
- def _clear_old_figure_states(current_key: str) -> None:
283
- _clear_old_load_states(current_key, "_fig_state")
284
-
285
-
286
- def _clear_old_projection_states(current_key: str) -> None:
287
- _clear_old_load_states(current_key, "_projection_state")
288
-
289
-
290
- def _store_figure_state(key: str, value: object) -> None:
291
- _clear_old_figure_states(key)
292
- st.session_state[key] = value
293
-
294
-
295
- def _seed_selectbox_key(
296
- *,
297
- key: str,
298
- remember_key: str,
299
- options: list[str],
300
- default: str,
301
- ) -> str:
302
- value = st.session_state.get(key, st.session_state.get(remember_key, default))
303
- if value not in options:
304
- value = default
305
- return value
306
-
307
-
308
- def _remember_multiselect(
309
- *,
310
- key: str,
311
- remember_key: str,
312
- options: list[str],
313
- ) -> list[str]:
314
- remembered = st.session_state.get(key, st.session_state.get(remember_key, []))
315
- if not isinstance(remembered, list):
316
- remembered = []
317
- return [value for value in remembered if value in options]
318
-
319
-
320
- def _release_vector_memory(store: Store, variants: list[str] | tuple[str, ...]) -> None:
321
- release_hf_store_cache(store, variants)
322
- gc.collect()
323
-
324
-
325
- def _evenly_spaced_layers(layers: list[int], max_count: int) -> list[int]:
326
- if max_count >= len(layers):
327
- return layers
328
- if max_count <= 1:
329
- return [layers[0]]
330
-
331
- last = len(layers) - 1
332
- indices = [round(i * last / (max_count - 1)) for i in range(max_count)]
333
- return [layers[index] for index in dict.fromkeys(indices)]
334
-
335
-
336
- def _render_layer_frame_controls(
337
- store: Store,
338
- scope: str,
339
- layers: list[int],
340
- ) -> list[int]:
341
- if len(layers) <= _DEFAULT_LAYER_FRAMES:
342
- st.caption(f"Using all {len(layers)} available layer(s).")
343
- return layers
344
-
345
- frame_count = st.slider(
346
- "Layer frames",
347
- min_value=2,
348
- max_value=len(layers),
349
- value=min(
350
- max(
351
- int(
352
- st.session_state.get(
353
- _LAST_LAYER_FRAMES_KEY,
354
- _DEFAULT_LAYER_FRAMES,
355
- )
356
- ),
357
- 2,
358
- ),
359
- len(layers),
360
- ),
361
- key=widget_key("load", "layer_frames", scope, store_id(store)),
362
- help="Limit animated Plotly frames to keep browser and RAM usage bounded.",
363
- )
364
- st.session_state[_LAST_LAYER_FRAMES_KEY] = frame_count
365
- selected = _evenly_spaced_layers(layers, frame_count)
366
- st.caption(f"Using {len(selected)} of {len(layers)} layers.")
367
- return selected
368
 
369
-
370
- def _load_persona_options(
371
- store: Store,
372
- variants: list[str],
373
- mask_strategy: MaskStrategy,
374
- *,
375
- empty_message: str,
376
- ) -> PersonaOptions | None:
377
- source, location, model_name = store_cache_parts(store)
378
- variant_key = tuple(variants)
379
- persona_ids = personas_cached(
380
- source,
381
- location,
382
- model_name,
383
- mask_strategy.value,
384
- variant_key,
385
- include_baseline=True,
386
- )
387
- if not persona_ids:
388
- st.info(empty_message)
389
- return None
390
-
391
- persona_names = persona_names_cached(
392
- source,
393
- location,
394
- model_name,
395
- mask_strategy.value,
396
- variant_key,
397
- tuple(persona_ids),
398
- )
399
- assistant_ids = [
400
- persona_id
401
- for persona_id in persona_ids
402
- if _is_assistant_persona(persona_id, persona_names.get(persona_id))
403
- ]
404
- assistant_id = next(
405
- (
406
- persona_id
407
- for persona_id in assistant_ids
408
- if persona_id == BASELINE_PERSONA_ID
409
- ),
410
- assistant_ids[0] if assistant_ids else None,
411
- )
412
- regular_ids = [
413
- persona_id for persona_id in persona_ids if persona_id not in assistant_ids
414
- ]
415
- if not regular_ids and assistant_id is None:
416
- st.info("No personas found for this model and variant.")
417
- return None
418
- return PersonaOptions(
419
- regular_ids=regular_ids,
420
- assistant_id=assistant_id,
421
- persona_names=persona_names,
422
- )
423
-
424
-
425
- def _seed_persona_memory(
426
- remember_key: str,
427
- options: PersonaOptions,
428
- *,
429
- default_all: bool,
430
- default_count_limit: int | None = None,
431
- ) -> tuple[int, bool]:
432
- remembered_count_key = f"{remember_key}:count"
433
- remembered_assistant_key = f"{remember_key}:include_assistant"
434
- legacy_ids = st.session_state.get(remember_key, [])
435
- if isinstance(legacy_ids, list) and legacy_ids:
436
- st.session_state.setdefault(
437
- remembered_count_key,
438
- sum(persona_id in options.regular_ids for persona_id in legacy_ids),
439
- )
440
- st.session_state.setdefault(
441
- remembered_assistant_key,
442
- options.assistant_id in legacy_ids,
443
- )
444
-
445
- if default_count_limit is not None:
446
- default_count = min(default_count_limit, len(options.regular_ids))
447
- elif default_all:
448
- default_count = len(options.regular_ids)
449
- else:
450
- default_count = min(1, len(options.regular_ids))
451
- remembered_count = int(st.session_state.get(remembered_count_key, default_count))
452
- persona_count = min(max(remembered_count, 0), len(options.regular_ids))
453
- include_assistant = bool(st.session_state.get(remembered_assistant_key, False))
454
- return persona_count, include_assistant
455
-
456
-
457
- def _render_persona_count_controls(
458
- store: Store,
459
- variants: list[str],
460
- mask_strategy: MaskStrategy,
461
- widget_scope: str,
462
- options: PersonaOptions,
463
- *,
464
- default_count: int,
465
- include_assistant_default: bool,
466
- ) -> tuple[int, bool]:
467
- count_key = widget_key(
468
- "load",
469
- "persona_count",
470
- widget_scope,
471
- store.model_name,
472
- mask_strategy.value,
473
- *variants,
474
- )
475
- assistant_key = widget_key(
476
- "load",
477
- "include_assistant",
478
- widget_scope,
479
- store.model_name,
480
- mask_strategy.value,
481
- *variants,
482
- )
483
-
484
- if options.regular_ids:
485
- persona_count = st.slider(
486
- "Personas",
487
- min_value=0 if options.assistant_id is not None else 1,
488
- max_value=len(options.regular_ids),
489
- value=default_count,
490
- key=count_key,
491
- help="Use the first N available non-assistant personas.",
492
- )
493
- else:
494
- persona_count = 0
495
- st.caption("No non-assistant personas are available for this selection.")
496
- include_assistant = False
497
- if options.assistant_id is not None:
498
- include_assistant = st.checkbox(
499
- "Include Assistant persona",
500
- value=include_assistant_default,
501
- key=assistant_key,
502
- )
503
- return persona_count, include_assistant
504
-
505
-
506
- def _select_artifact_personas(
507
- store: Store,
508
- variants: list[str],
509
- mask_strategy: MaskStrategy,
510
- *,
511
- widget_scope: str,
512
- remember_key: str,
513
- default_all: bool = False,
514
- default_count_limit: int | None = None,
515
- ) -> list[str]:
516
- empty_message = (
517
- "No personas have vectors for all selected variants. "
518
- "Pick a single variant or change the source."
519
- if len(variants) > 1
520
- else "No personas found for this model and variant."
521
- )
522
- options = _load_persona_options(
523
- store,
524
- variants,
525
- mask_strategy,
526
- empty_message=empty_message,
527
- )
528
- if options is None:
529
- st.session_state.pop(_persona_names_state_key(widget_scope), None)
530
- return []
531
-
532
- default_count, include_assistant_default = _seed_persona_memory(
533
- remember_key,
534
- options,
535
- default_all=default_all,
536
- default_count_limit=default_count_limit,
537
- )
538
- persona_count, include_assistant = _render_persona_count_controls(
539
- store,
540
- variants,
541
- mask_strategy,
542
- widget_scope,
543
- options,
544
- default_count=default_count,
545
- include_assistant_default=include_assistant_default,
546
- )
547
-
548
- persona_ids = options.regular_ids[:persona_count]
549
- if include_assistant and options.assistant_id is not None:
550
- persona_ids.append(options.assistant_id)
551
-
552
- remembered_count_key = f"{remember_key}:count"
553
- remembered_assistant_key = f"{remember_key}:include_assistant"
554
- st.session_state[remembered_count_key] = persona_count
555
- st.session_state[remembered_assistant_key] = include_assistant
556
- st.session_state[remember_key] = persona_ids
557
- st.session_state[_persona_names_state_key(widget_scope)] = options.persona_names
558
-
559
- if not persona_ids:
560
- st.info("Select at least one persona or include the Assistant persona.")
561
- return []
562
-
563
- regular_label = f"{persona_count} persona{'s' if persona_count != 1 else ''}"
564
- assistant_label = (
565
- " plus Assistant" if include_assistant and options.assistant_id else ""
566
- )
567
- st.caption(f"Using {regular_label}{assistant_label}.")
568
- return persona_ids
569
-
570
-
571
- def _render_save_buttons(
572
- figs: list[object],
573
- filenames: list[str],
574
- key_suffix: str,
575
- ) -> None:
576
- """Render the Save HTML button for one or more figures."""
577
- if st.button("Save HTML", key=widget_key("load", "save_html", key_suffix)):
578
- try:
579
- _style_plotly_figures(figs)
580
- paths = [
581
- save_plot_html(fig, fn) for fig, fn in zip(figs, filenames, strict=True)
582
- ]
583
- st.success(f"Saved {len(paths)} HTML file(s) to `artifacts/plots`.")
584
- except Exception as exc:
585
- st.error(f"Could not save HTML: {exc}")
586
-
587
-
588
- def _style_plotly_figures(figs: list[object]) -> None:
589
- base = active_base()
590
- for fig in figs:
591
- if isinstance(fig, go.Figure):
592
- style_plotly_layer_controls(fig, base)
593
-
594
-
595
- def _plotly_chart(fig: object) -> None:
596
- _style_plotly_figures([fig])
597
- st.plotly_chart(
598
- fig,
599
- width="stretch",
600
- config={"responsive": True, "displaylogo": False},
601
- )
602
-
603
-
604
- def _render_mask_strategy_select(scope: str) -> MaskStrategy:
605
- return render_mask_strategy_select(
606
- key=widget_key("load", "mask_strategy", scope),
607
- last_key=_LAST_MASK_STRATEGY_KEY,
608
- help_text="Which extracted activation set to load.",
609
- )
610
-
611
-
612
- def _render_cosine_selection(
613
- store: Store,
614
- mask_strategy: MaskStrategy,
615
- ) -> CosineSelection | None:
616
- variants = available_variants(store, mask_strategy)
617
- if len(variants) < 2:
618
- st.info("Need at least two variants with saved vectors for cosine comparison.")
619
- return None
620
-
621
- with st.expander("Vector selection", expanded=True):
622
- col1, col2 = st.columns(2)
623
- with col1:
624
- variant_a = st.selectbox(
625
- "Variant A",
626
- options=variants,
627
- index=0,
628
- format_func=prompt_variant_label,
629
- key=widget_key("load", "variant_a", store_id(store)),
630
- )
631
- with col2:
632
- variant_b = st.selectbox(
633
- "Variant B",
634
- options=variants,
635
- index=min(1, len(variants) - 1),
636
- format_func=prompt_variant_label,
637
- key=widget_key("load", "variant_b", store_id(store)),
638
- )
639
-
640
- if variant_a == variant_b:
641
- st.warning("Choose two different variants to compare.")
642
- return None
643
-
644
- persona_ids = _select_artifact_personas(
645
- store,
646
- [variant_a, variant_b],
647
- mask_strategy,
648
- widget_scope=f"cosine:{store_id(store)}",
649
- remember_key=_LAST_COSINE_PERSONAS_KEY,
650
- )
651
- if not persona_ids:
652
- return None
653
- return CosineSelection(
654
- variants=variants,
655
- variant_a=variant_a,
656
- variant_b=variant_b,
657
- persona_ids=persona_ids,
658
- persona_key=personas_fingerprint(persona_ids),
659
- )
660
-
661
-
662
- def _build_cosine_figures(
663
- store: Store,
664
- mask_strategy: MaskStrategy,
665
- selection: CosineSelection,
666
- ) -> tuple[object, object | None, int, int] | None:
667
- variant_sample_cache: dict[str, object] = {}
668
-
669
- def _load_variant(variant: str):
670
- if variant not in variant_sample_cache:
671
- samples = _load_variant_vectors(
672
- store,
673
- [variant],
674
- mask_strategy,
675
- persona_ids=selection.persona_ids,
676
- )
677
- variant_sample_cache[variant] = samples[variant]
678
- return variant_sample_cache[variant]
679
-
680
- try:
681
- samples_a = _load_variant(selection.variant_a)
682
- samples_b = _load_variant(selection.variant_b)
683
- except Exception as exc:
684
- st.error(f"Could not load vectors: {exc}")
685
- return None
686
-
687
- labels = samples_a.labels
688
- display_traces = [
689
- (
690
- label,
691
- samples_a.vectors[index],
692
- samples_b.vectors[index],
693
- )
694
- for index, label in enumerate(labels)
695
- ]
696
- fig = plot_layer_similarity(
697
- display_traces,
698
- title=(
699
- f"{prompt_variant_label(selection.variant_a)} vs "
700
- f"{prompt_variant_label(selection.variant_b)}"
701
- ),
702
- show=False,
703
- )
704
-
705
- pair_traces = []
706
- pair_errors = []
707
- for left, right in combinations(selection.variants, 2):
708
- try:
709
- left_samples = _load_variant(left)
710
- right_samples = _load_variant(right)
711
- pair_traces.append(
712
- (
713
- f"{prompt_variant_label(left)} vs {prompt_variant_label(right)}",
714
- left_samples.vectors.mean(dim=0),
715
- right_samples.vectors.mean(dim=0),
716
- )
717
- )
718
- except Exception as exc:
719
- pair_errors.append(f"{left} vs {right}: {exc}")
720
- continue
721
-
722
- for err in pair_errors:
723
- st.warning(f"Skipped pair trace: `{err}`")
724
- pair_fig = (
725
- plot_layer_similarity(
726
- pair_traces,
727
- title="Variant-pair cosine similarity averaged over selected personas",
728
- show=False,
729
- )
730
- if pair_traces
731
- else None
732
- )
733
- return fig, pair_fig, len(display_traces), len(pair_traces)
734
-
735
-
736
- def _render_cosine_similarity(
737
- store: Store,
738
- mask_strategy: MaskStrategy,
739
- ) -> None:
740
- selection = _render_cosine_selection(store, mask_strategy)
741
- if selection is None:
742
- return
743
-
744
- cosine_fig_key = widget_key(
745
- "load",
746
- "cosine_fig_state",
747
- store_id(store),
748
- store.model_name,
749
- mask_strategy.value,
750
- selection.variant_a,
751
- selection.variant_b,
752
- selection.persona_key,
753
- )
754
- filename = _filename(
755
- "analysis",
756
- "cosine",
757
- store.model_name,
758
- mask_strategy.value,
759
- selection.variant_a,
760
- selection.variant_b,
761
- )
762
- pairs_filename = _filename(
763
- "analysis",
764
- "cosine_pairs",
765
- store.model_name,
766
- mask_strategy.value,
767
- "_".join(selection.variants),
768
- )
769
- _clear_old_figure_states(cosine_fig_key)
770
-
771
- if st.button(
772
- "Compare vectors",
773
- type="primary",
774
- key=widget_key(
775
- "load",
776
- "analysis_vectors",
777
- store_id(store),
778
- store.model_name,
779
- mask_strategy.value,
780
- selection.variant_a,
781
- selection.variant_b,
782
- selection.persona_key,
783
- ),
784
- ):
785
- progress = st.progress(0, text="Loading activation vectors…")
786
- try:
787
- progress.progress(15, text="Loading activation vectors…")
788
- figures = _build_cosine_figures(store, mask_strategy, selection)
789
- if figures is None:
790
- st.session_state.pop(cosine_fig_key, None)
791
- return
792
- progress.progress(90, text="Storing figure state…")
793
- _store_figure_state(cosine_fig_key, figures)
794
- progress.progress(100, text="Done.")
795
- finally:
796
- _release_vector_memory(store, selection.variants)
797
- progress.empty()
798
-
799
- if cosine_fig_key in st.session_state:
800
- fig, pair_fig, n_traces, n_pair_traces = st.session_state[cosine_fig_key]
801
- _plotly_chart(fig)
802
- figs = [fig]
803
- filenames = [filename]
804
- if pair_fig is not None:
805
- st.subheader("Variant pairs")
806
- _plotly_chart(pair_fig)
807
- figs.append(pair_fig)
808
- filenames.append(pairs_filename)
809
- _render_save_buttons(figs, filenames, "cosine")
810
- st.success(f"Loaded {n_traces} personas for cosine comparison.")
811
- if pair_fig is not None:
812
- st.caption(f"Generated {n_pair_traces} averaged variant-pair trace(s).")
813
-
814
-
815
- def _select_single_variant_samples(
816
- store: Store,
817
- mask_strategy: MaskStrategy,
818
- scope: str,
819
- *,
820
- remember_key: str,
821
- variant_remember_key: str,
822
- default_count_limit: int,
823
- ) -> tuple[str, list[str], str, list[int]] | None:
824
- variants = available_variants(store, mask_strategy)
825
- if not variants:
826
- st.info("No variants with saved vectors for this model.")
827
- return None
828
- variant_key = widget_key("load", "variant", scope, store_id(store))
829
- default_variant = "biography" if "biography" in variants else variants[0]
830
- selected_variant = _seed_selectbox_key(
831
- key=variant_key,
832
- remember_key=variant_remember_key,
833
- options=variants,
834
- default=default_variant,
835
- )
836
- variant = st.selectbox(
837
- "Variant",
838
- options=variants,
839
- index=variants.index(selected_variant),
840
- format_func=prompt_variant_label,
841
- key=variant_key,
842
- )
843
- st.session_state[variant_remember_key] = variant
844
- persona_ids = _select_artifact_personas(
845
- store,
846
- [variant],
847
- mask_strategy,
848
- widget_scope=f"{scope}:{store_id(store)}",
849
- remember_key=remember_key,
850
- default_count_limit=default_count_limit,
851
- )
852
- if not persona_ids:
853
- return None
854
-
855
- persona_key = personas_fingerprint(persona_ids)
856
- layer_options = _layers_for_variant(store, variant, persona_ids, mask_strategy)
857
- if not layer_options:
858
- st.info("No shared layers are available for the selected personas.")
859
- return None
860
-
861
- selected_layers = _render_layer_frame_controls(store, scope, layer_options)
862
- return variant, persona_ids, persona_key, selected_layers
863
-
864
-
865
- def _render_pair_trajectory_control(
866
- *,
867
- enabled: bool,
868
- persona_count: int,
869
- scope: str,
870
- store: Store,
871
- ) -> bool:
872
- if not enabled:
873
- return False
874
- pair_count = persona_count * (persona_count - 1) // 2
875
- if pair_count > _MAX_PAIR_TRAJECTORY_TRACES:
876
- st.caption(
877
- "Pair trajectories hidden because this selection would create "
878
- f"{pair_count:,} Plotly traces."
879
- )
880
- return False
881
- return st.checkbox(
882
- "Pair trajectories",
883
- value=False,
884
- key=widget_key("load", "pair_trajectories", scope, store_id(store)),
885
- help="Adds one line per persona pair. Keep this off for larger selections.",
886
- )
887
-
888
-
889
- def _validate_layered_figure_size(
890
- figure_kind: str,
891
- persona_count: int,
892
- selected_layers: list[int],
893
- ) -> bool:
894
- if figure_kind != "similarity":
895
- return True
896
- similarity_cells = persona_count * persona_count * len(selected_layers)
897
- if similarity_cells <= _MAX_SIMILARITY_CELLS:
898
- return True
899
- st.error(
900
- "Reduce personas or layer frames before generating the similarity "
901
- f"matrix ({similarity_cells:,} cells selected)."
902
- )
903
- return False
904
-
905
-
906
- def _render_projection_color_config(
907
- store: Store,
908
- scope: str,
909
- persona_ids: list[str],
910
- ) -> ProjectionColorConfig | None:
911
- widget_scope = f"{scope}:{store_id(store)}"
912
- persona_key = personas_fingerprint(persona_ids)
913
- persona_names = st.session_state.get(
914
- _persona_names_state_key(widget_scope),
915
- {},
916
- )
917
- color_mode_key = widget_key("load", "color_mode", scope, store_id(store))
918
- selected_color_mode = _seed_selectbox_key(
919
- key=color_mode_key,
920
- remember_key=_LAST_PROJECTION_COLOR_MODE_KEY,
921
- options=_PROJECTION_COLOR_MODES,
922
- default="Persona",
923
- )
924
- color_mode = st.selectbox(
925
- "Color by",
926
- options=_PROJECTION_COLOR_MODES,
927
- index=_PROJECTION_COLOR_MODES.index(selected_color_mode),
928
- key=color_mode_key,
929
- )
930
- st.session_state[_LAST_PROJECTION_COLOR_MODE_KEY] = color_mode
931
- if color_mode == "K-means clusters":
932
- max_clusters = min(10, len(persona_ids))
933
- if max_clusters < 2:
934
- st.info("Select at least two personas to use K-means coloring.")
935
- return None
936
- cluster_key = widget_key("load", "cluster_k", scope, store_id(store))
937
- default_clusters = min(3, len(persona_ids))
938
- if cluster_key not in st.session_state:
939
- st.session_state[cluster_key] = min(
940
- max(
941
- int(
942
- st.session_state.get(
943
- _LAST_PROJECTION_CLUSTER_K_KEY,
944
- default_clusters,
945
- )
946
- ),
947
- 2,
948
- ),
949
- max_clusters,
950
- )
951
- n_clusters = st.slider(
952
- "K (clusters)",
953
- min_value=2,
954
- max_value=max_clusters,
955
- key=cluster_key,
956
- )
957
- mode_key = widget_key("load", "cluster_mode", scope, store_id(store))
958
- mode_options = list(_CLUSTER_MODES)
959
- selected_mode = _seed_selectbox_key(
960
- key=mode_key,
961
- remember_key=_LAST_PROJECTION_CLUSTER_MODE_KEY,
962
- options=mode_options,
963
- default=mode_options[0],
964
- )
965
- mode_label = st.selectbox(
966
- "Cluster fit",
967
- options=mode_options,
968
- index=mode_options.index(selected_mode),
969
- key=mode_key,
970
- help=(
971
- "Mean across layers is the previous behavior. First selected "
972
- "layer keeps one fixed clustering from the first frame. Per layer "
973
- "recomputes clustering for each animation frame."
974
- ),
975
- )
976
- st.session_state[_LAST_PROJECTION_CLUSTER_K_KEY] = n_clusters
977
- st.session_state[_LAST_PROJECTION_CLUSTER_MODE_KEY] = mode_label
978
- return ProjectionColorConfig(
979
- color_mode=color_mode,
980
- n_clusters=n_clusters,
981
- cluster_mode=_CLUSTER_MODES[mode_label],
982
- )
983
-
984
- if color_mode == "Persona attribute":
985
- persona_dataset = synth_persona_dataset_cached()
986
- attribute_options = list(synth_persona_attribute_names())
987
- if not attribute_options:
988
- st.info("No persona attributes are available for this dataset.")
989
- return None
990
- default_attribute = (
991
- attribute_options.index("sex") if "sex" in attribute_options else 0
992
- )
993
- attribute_key = widget_key("load", "attribute", scope, store_id(store))
994
- selected_attribute = _seed_selectbox_key(
995
- key=attribute_key,
996
- remember_key=_LAST_PROJECTION_ATTRIBUTE_KEY,
997
- options=attribute_options,
998
- default=attribute_options[default_attribute],
999
- )
1000
- attribute_name = st.selectbox(
1001
- "Attribute",
1002
- options=attribute_options,
1003
- index=attribute_options.index(selected_attribute),
1004
- format_func=lambda name: attribute_display_label(persona_dataset, name),
1005
- key=attribute_key,
1006
- )
1007
- st.session_state[_LAST_PROJECTION_ATTRIBUTE_KEY] = attribute_name
1008
- info = persona_dataset.attribute_info(attribute_name)
1009
- if info.get("high_cardinality"):
1010
- st.caption(
1011
- "High-cardinality categorical attributes are grouped to the "
1012
- f"top {_MAX_ATTRIBUTE_CATEGORIES} values plus Other."
1013
- )
1014
- return ProjectionColorConfig(
1015
- color_mode=color_mode,
1016
- attribute_name=attribute_name,
1017
- )
1018
-
1019
- highlight_persona_ids: tuple[str, ...] = ()
1020
- if persona_ids:
1021
- highlight_key = widget_key(
1022
- "load", "persona_highlight", scope, store_id(store), persona_key
1023
- )
1024
- highlighted = st.multiselect(
1025
- "Highlight personas",
1026
- options=persona_ids,
1027
- default=_remember_multiselect(
1028
- key=highlight_key,
1029
- remember_key=_LAST_PROJECTION_HIGHLIGHTS_KEY,
1030
- options=persona_ids,
1031
- ),
1032
- format_func=lambda persona_id: _persona_display_label(
1033
- persona_names, persona_id
1034
- ),
1035
- key=highlight_key,
1036
- help=(
1037
- "Select a few personas to keep their default colors while the rest "
1038
- "are grayed out."
1039
- ),
1040
- )
1041
- highlight_persona_ids = tuple(highlighted)
1042
- st.session_state[_LAST_PROJECTION_HIGHLIGHTS_KEY] = list(highlighted)
1043
-
1044
- highlight_persona_key = (
1045
- personas_fingerprint(highlight_persona_ids) if highlight_persona_ids else ""
1046
- )
1047
-
1048
- return ProjectionColorConfig(
1049
- color_mode=color_mode,
1050
- highlight_persona_ids=highlight_persona_ids,
1051
- highlight_persona_key=highlight_persona_key,
1052
- )
1053
-
1054
-
1055
- def _layered_figure_state_keys(
1056
- store: Store,
1057
- mask_strategy: MaskStrategy,
1058
- *,
1059
- scope: str,
1060
- figure_kind: str,
1061
- n_components: int,
1062
- color_config: ProjectionColorConfig,
1063
- variant: str,
1064
- persona_key: str,
1065
- selected_layers: list[int],
1066
- pair_trajectories: bool,
1067
- ) -> LayeredFigureStateKeys:
1068
- layer_key = "_".join(map(str, selected_layers))
1069
- figure_key = widget_key(
1070
- "load",
1071
- f"{scope}_fig_state",
1072
- store_id(store),
1073
- store.model_name,
1074
- mask_strategy.value,
1075
- figure_kind,
1076
- str(n_components),
1077
- color_config.color_mode,
1078
- str(color_config.attribute_name),
1079
- str(color_config.n_clusters),
1080
- str(color_config.cluster_mode),
1081
- str(color_config.highlight_persona_key),
1082
- variant,
1083
- "persona_vector",
1084
- persona_key,
1085
- layer_key,
1086
- str(pair_trajectories),
1087
- )
1088
- if figure_kind not in _PROJECTION_KINDS:
1089
- return LayeredFigureStateKeys(figure=figure_key)
1090
-
1091
- graph_overlay = figure_kind == "isomap"
1092
- projection_key = widget_key(
1093
- "load",
1094
- f"{scope}_projection_state",
1095
- store_id(store),
1096
- store.model_name,
1097
- mask_strategy.value,
1098
- figure_kind,
1099
- str(n_components),
1100
- str(graph_overlay),
1101
- str(_DEFAULT_GRAPH_NEIGHBORS),
1102
- variant,
1103
- "persona_vector",
1104
- persona_key,
1105
- layer_key,
1106
- )
1107
- return LayeredFigureStateKeys(figure=figure_key, projection=projection_key)
1108
-
1109
-
1110
- def _projection_build_kwargs(
1111
- samples,
1112
- *,
1113
- figure_kind: str,
1114
- selected_layers: list[int],
1115
- n_components: int,
1116
- color_config: ProjectionColorConfig,
1117
- persona_ids: list[str],
1118
- persona_names: dict[str, str],
1119
- projection_key: str | None,
1120
- ) -> dict:
1121
- if figure_kind not in _PROJECTION_KINDS:
1122
- return {}
1123
-
1124
- graph_overlay = figure_kind == "isomap"
1125
- build_kwargs = {
1126
- "n_components": n_components,
1127
- "graph_overlay": graph_overlay,
1128
- "graph_n_neighbors": _DEFAULT_GRAPH_NEIGHBORS,
1129
- }
1130
- if color_config.n_clusters is not None:
1131
- build_kwargs["n_clusters"] = color_config.n_clusters
1132
- build_kwargs["cluster_mode"] = color_config.cluster_mode
1133
- if projection_key is not None:
1134
- projection_data = st.session_state.get(projection_key)
1135
- if projection_data is None:
1136
- projection_data = prepare_layered_projection_data(
1137
- samples,
1138
- figure_kind,
1139
- layers=selected_layers,
1140
- n_components=n_components,
1141
- graph_overlay=graph_overlay,
1142
- graph_n_neighbors=_DEFAULT_GRAPH_NEIGHBORS,
1143
- )
1144
- st.session_state[projection_key] = projection_data
1145
- build_kwargs["projection_data"] = projection_data
1146
- if color_config.attribute_name is not None:
1147
- build_kwargs.update(
1148
- attribute_color_kwargs(
1149
- synth_persona_dataset_cached(),
1150
- color_config.attribute_name,
1151
- persona_ids,
1152
- max_categories=_MAX_ATTRIBUTE_CATEGORIES,
1153
- )
1154
- )
1155
- if color_config.color_mode == "Persona" and color_config.highlight_persona_ids:
1156
- groups = _highlight_persona_groups(
1157
- persona_ids,
1158
- persona_names,
1159
- color_config.highlight_persona_ids,
1160
- )
1161
- if groups is not None:
1162
- build_kwargs["groups"] = groups
1163
- return build_kwargs
1164
-
1165
-
1166
- def _build_layered_analysis_figures(
1167
- samples,
1168
- *,
1169
- figure_kind: str,
1170
- selected_layers: list[int],
1171
- variant: str,
1172
- title_fn: Callable[[str], str],
1173
- pair_trajectories: bool,
1174
- build_kwargs: dict,
1175
- ) -> tuple[go.Figure, go.Figure | None]:
1176
- if figure_kind == "similarity" and pair_trajectories:
1177
- return build_similarity_figures(
1178
- samples,
1179
- layers=selected_layers,
1180
- title=title_fn(variant),
1181
- pair_title=(
1182
- "Pair similarity trajectories - "
1183
- f"{prompt_variant_label(variant)} - persona vectors"
1184
- ),
1185
- )
1186
-
1187
- main_fig = build_layered_figure(
1188
- samples,
1189
- figure_kind,
1190
- layers=selected_layers,
1191
- title=title_fn(variant),
1192
- **build_kwargs,
1193
- )
1194
- if figure_kind == "isomap":
1195
- _add_isomap_connection_toggle(main_fig)
1196
- if figure_kind in _PROJECTION_KINDS:
1197
- main_fig.update_layout(height=700)
1198
- extra_fig = (
1199
- build_pair_similarity_figure(
1200
- samples,
1201
- layers=selected_layers,
1202
- title=(
1203
- "Pair similarity trajectories - "
1204
- f"{prompt_variant_label(variant)} - persona vectors"
1205
- ),
1206
- )
1207
- if pair_trajectories
1208
- else None
1209
- )
1210
- return main_fig, extra_fig
1211
-
1212
-
1213
- def _add_isomap_connection_toggle(fig: go.Figure) -> None:
1214
- """Add an in-plot control for the Isomap kNN graph trace."""
1215
- if not fig.data or fig.data[0].name != "kNN graph":
1216
- return
1217
-
1218
- existing_menus = tuple(fig.layout.updatemenus or ())
1219
- fig.update_layout(
1220
- updatemenus=existing_menus
1221
- + (
1222
- dict(
1223
- type="buttons",
1224
- direction="left",
1225
- active=0,
1226
- showactive=False,
1227
- x=0,
1228
- xanchor="left",
1229
- y=1.16,
1230
- yanchor="top",
1231
- pad=dict(t=0, r=10),
1232
- buttons=[
1233
- dict(
1234
- label="Show connections",
1235
- method="restyle",
1236
- args=[{"visible": True}, [0]],
1237
- ),
1238
- dict(
1239
- label="Hide connections",
1240
- method="restyle",
1241
- args=[{"visible": False}, [0]],
1242
- ),
1243
- ],
1244
- ),
1245
- ),
1246
- )
1247
-
1248
-
1249
- def _render_layered_figure_analysis(
1250
- store: Store,
1251
- mask_strategy: MaskStrategy,
1252
- *,
1253
- scope: str,
1254
- figure_kind: str,
1255
- button_label: str,
1256
- title_fn: Callable[[str], str],
1257
- include_pair_trajectories: bool = False,
1258
- n_components: int = 2,
1259
- remember_key: str = _LAST_PROJECTION_PERSONAS_KEY,
1260
- default_count_limit: int = 500,
1261
- ) -> None:
1262
- """Render a single-variant layered analysis: select → button → figure(s).
1263
-
1264
- Used for similarity matrix, PCA, and UMAP. Set ``include_pair_trajectories``
1265
- to add the pair-similarity-trajectory figure (similarity matrix only).
1266
- """
1267
- selected = _select_single_variant_samples(
1268
- store,
1269
- mask_strategy,
1270
- scope,
1271
- remember_key=remember_key,
1272
- variant_remember_key=(
1273
- _LAST_PROJECTION_VARIANT_KEY
1274
- if figure_kind in _PROJECTION_KINDS
1275
- else _LAST_SIMILARITY_VARIANT_KEY
1276
- ),
1277
- default_count_limit=default_count_limit,
1278
- )
1279
- if selected is None:
1280
- return
1281
- variant, persona_ids, persona_key, selected_layers = selected
1282
-
1283
- pair_trajectories = _render_pair_trajectory_control(
1284
- enabled=include_pair_trajectories,
1285
- persona_count=len(persona_ids),
1286
- scope=scope,
1287
- store=store,
1288
- )
1289
- if not _validate_layered_figure_size(
1290
- figure_kind, len(persona_ids), selected_layers
1291
- ):
1292
- return
1293
-
1294
- color_config = ProjectionColorConfig()
1295
- if figure_kind in _PROJECTION_KINDS:
1296
- color_config = _render_projection_color_config(store, scope, persona_ids)
1297
- if color_config is None:
1298
- return
1299
-
1300
- state_keys = _layered_figure_state_keys(
1301
- store,
1302
- mask_strategy,
1303
- scope=scope,
1304
- figure_kind=figure_kind,
1305
- n_components=n_components,
1306
- color_config=color_config,
1307
- variant=variant,
1308
- persona_key=persona_key,
1309
- selected_layers=selected_layers,
1310
- pair_trajectories=pair_trajectories,
1311
- )
1312
- if state_keys.projection is not None:
1313
- _clear_old_projection_states(state_keys.projection)
1314
- filename = scope
1315
- _clear_old_figure_states(state_keys.figure)
1316
- persona_names = st.session_state.get(
1317
- _persona_names_state_key(f"{scope}:{store_id(store)}"),
1318
- {},
1319
- )
1320
-
1321
- if st.button(button_label, type="primary"):
1322
- build_label = {
1323
- "umap": "Computing UMAP projections…",
1324
- "pca": "Computing PCA projections…",
1325
- "isomap": "Computing Isomap projections…",
1326
- "similarity": "Computing similarity matrices…",
1327
- }.get(figure_kind, "Building figure…")
1328
- progress = st.progress(0, text="Loading activation vectors…")
1329
- try:
1330
- progress.progress(15, text="Loading activation vectors…")
1331
- samples = _load_persona_vectors(
1332
- store,
1333
- variant,
1334
- mask_strategy,
1335
- persona_ids,
1336
- )
1337
- progress.progress(55, text=build_label)
1338
- build_kwargs = _projection_build_kwargs(
1339
- samples,
1340
- figure_kind=figure_kind,
1341
- selected_layers=selected_layers,
1342
- n_components=n_components,
1343
- color_config=color_config,
1344
- persona_ids=persona_ids,
1345
- persona_names=persona_names,
1346
- projection_key=state_keys.projection,
1347
- )
1348
- main_fig, extra_fig = _build_layered_analysis_figures(
1349
- samples,
1350
- figure_kind=figure_kind,
1351
- selected_layers=selected_layers,
1352
- variant=variant,
1353
- title_fn=title_fn,
1354
- pair_trajectories=pair_trajectories,
1355
- build_kwargs=build_kwargs,
1356
- )
1357
- if (
1358
- color_config.color_mode == "Persona"
1359
- and color_config.highlight_persona_ids
1360
- ):
1361
- _gray_out_unselected_personas(main_fig)
1362
- progress.progress(90, text="Storing figure state…")
1363
- n_samples = samples.vectors.shape[0]
1364
- del samples
1365
- _store_figure_state(state_keys.figure, (main_fig, extra_fig, n_samples))
1366
- progress.progress(100, text="Done.")
1367
- except Exception as exc:
1368
- st.error(f"Could not build figure: {exc}")
1369
- st.session_state.pop(state_keys.figure, None)
1370
- finally:
1371
- _release_vector_memory(store, [variant])
1372
- progress.empty()
1373
-
1374
- if state_keys.figure in st.session_state:
1375
- main_fig, extra_fig, n_samples = st.session_state[state_keys.figure]
1376
- _plotly_chart(main_fig)
1377
- figs = [main_fig]
1378
- filenames = [filename]
1379
- if extra_fig is not None:
1380
- st.subheader("Pair trajectories")
1381
- _plotly_chart(extra_fig)
1382
- figs.append(extra_fig)
1383
- filenames.append(f"{filename}__pair_trajectories")
1384
- _render_save_buttons(figs, filenames, scope)
1385
- st.success(f"Loaded {n_samples} samples.")
1386
-
1387
-
1388
- _LAST_DENDRO_PERSONAS_KEY = "analysis:last_personas:dendro"
1389
- _DENDRO_LINKAGE_OPTIONS = ["ward", "complete", "average", "single"]
1390
-
1391
-
1392
- def _render_persona_select_controls(
1393
- options: PersonaOptions,
1394
- widget_scope: str,
1395
- ) -> list[str]:
1396
- select_key = widget_key("load", "persona_select", widget_scope)
1397
- assistant_key = widget_key("load", "persona_select_assistant", widget_scope)
1398
-
1399
- label_map = {
1400
- pid: f"{options.persona_names.get(pid, pid)} ({pid})"
1401
- for pid in options.regular_ids
1402
- }
1403
- sorted_labels = sorted(label_map.values())
1404
- selected_labels = st.multiselect(
1405
- "Select personas",
1406
- options=sorted_labels,
1407
- key=select_key,
1408
- placeholder="Search and select personas...",
1409
- )
1410
- label_to_id = {v: k for k, v in label_map.items()}
1411
- selected_ids = [label_to_id[lbl] for lbl in selected_labels]
1412
-
1413
- if options.assistant_id is not None:
1414
- include_assistant = st.checkbox(
1415
- "Include Assistant persona",
1416
- key=assistant_key,
1417
- )
1418
- if include_assistant:
1419
- selected_ids.append(options.assistant_id)
1420
-
1421
- st.session_state[_persona_names_state_key(widget_scope)] = dict(
1422
- options.persona_names
1423
- )
1424
-
1425
- if not selected_ids:
1426
- st.info("Select at least one persona.")
1427
-
1428
- return selected_ids
1429
-
1430
-
1431
- def _render_dendrogram_analysis(
1432
- store: Store,
1433
- mask_strategy: MaskStrategy,
1434
- ) -> None:
1435
- variants = available_variants(store, mask_strategy)
1436
- if not variants:
1437
- st.info("No variants with saved vectors for this model.")
1438
- return
1439
-
1440
- with st.expander("Variant selection", expanded=True):
1441
- col1, col2 = st.columns(2)
1442
- default_a = "biography" if "biography" in variants else variants[0]
1443
- default_b_idx = (
1444
- variants.index("templated")
1445
- if "templated" in variants
1446
- else min(1, len(variants) - 1)
1447
- )
1448
- with col1:
1449
- variant_a = st.selectbox(
1450
- "Variant A",
1451
- options=variants,
1452
- index=variants.index(default_a),
1453
- format_func=prompt_variant_label,
1454
- key=widget_key("load", "dendro_variant_a", store_id(store)),
1455
- )
1456
- with col2:
1457
- variant_b = st.selectbox(
1458
- "Variant B",
1459
- options=variants,
1460
- index=default_b_idx,
1461
- format_func=prompt_variant_label,
1462
- key=widget_key("load", "dendro_variant_b", store_id(store)),
1463
- )
1464
-
1465
- shared_variants = list(dict.fromkeys([variant_a, variant_b]))
1466
-
1467
- select_specific = st.toggle(
1468
- "Select specific personas",
1469
- value=False,
1470
- key=widget_key("load", "dendro_select_mode", store_id(store)),
1471
- help="Search and select specific personas instead of using the first N.",
1472
- )
1473
-
1474
- if select_specific:
1475
- empty_message = (
1476
- "No personas have vectors for all selected variants. "
1477
- "Pick a single variant or change the source."
1478
- if len(shared_variants) > 1
1479
- else "No personas found for this model and variant."
1480
- )
1481
- options = _load_persona_options(
1482
- store,
1483
- shared_variants,
1484
- mask_strategy,
1485
- empty_message=empty_message,
1486
- )
1487
- if options is None:
1488
- st.session_state.pop(
1489
- _persona_names_state_key(f"dendro:{store_id(store)}"), None
1490
- )
1491
- return
1492
- persona_ids = _render_persona_select_controls(
1493
- options,
1494
- widget_scope=f"dendro:{store_id(store)}",
1495
- )
1496
- if not persona_ids:
1497
- return
1498
- else:
1499
- persona_ids = _select_artifact_personas(
1500
- store,
1501
- shared_variants,
1502
- mask_strategy,
1503
- widget_scope=f"dendro:{store_id(store)}",
1504
- remember_key=_LAST_DENDRO_PERSONAS_KEY,
1505
- default_count_limit=_DEFAULT_PERSONA_LIMITS["dendro"],
1506
- )
1507
- if not persona_ids:
1508
- return
1509
-
1510
- col_opts1, col_opts2 = st.columns(2)
1511
- with col_opts1:
1512
- layered_mode = st.toggle(
1513
- "Per-layer animated",
1514
- value=False,
1515
- key=widget_key("load", "dendro_layered", store_id(store)),
1516
- help="Animated dendrogram with one frame per layer instead of averaging all layers.",
1517
- )
1518
- with col_opts2:
1519
- linkage = st.selectbox(
1520
- "Linkage",
1521
- options=_DENDRO_LINKAGE_OPTIONS,
1522
- index=0,
1523
- key=widget_key("load", "dendro_linkage", store_id(store)),
1524
- )
1525
-
1526
- selected_layers: list[int] | None = None
1527
- if layered_mode:
1528
- source, location, model_name = store_cache_parts(store)
1529
- layer_options = store_layers_cached(
1530
- source,
1531
- location,
1532
- model_name,
1533
- mask_strategy.value,
1534
- tuple(shared_variants),
1535
- tuple(persona_ids),
1536
- )
1537
- if not layer_options:
1538
- st.info("No shared layers are available for the selected personas.")
1539
- return
1540
- selected_layers = _render_layer_frame_controls(store, "dendro", layer_options)
1541
-
1542
- persona_key = personas_fingerprint(persona_ids)
1543
- fig_key = widget_key(
1544
- "load",
1545
- "dendro_fig_state",
1546
- store_id(store),
1547
- store.model_name,
1548
- mask_strategy.value,
1549
- variant_a,
1550
- variant_b,
1551
- persona_key,
1552
- str(layered_mode),
1553
- linkage,
1554
- "_".join(map(str, selected_layers or [])),
1555
- )
1556
- _clear_old_figure_states(fig_key)
1557
-
1558
- if st.button(
1559
- "Generate dendrograms",
1560
- type="primary",
1561
- key=widget_key(
1562
- "load", "dendro_btn", store_id(store), variant_a, variant_b, persona_key
1563
- ),
1564
- ):
1565
- progress = st.progress(0, text="Loading first variant vectors…")
1566
- try:
1567
- progress.progress(15, text="Loading first variant vectors…")
1568
- samples_a = _load_persona_vectors(
1569
- store,
1570
- variant_a,
1571
- mask_strategy,
1572
- persona_ids,
1573
- )
1574
- progress.progress(40, text="Building first dendrogram…")
1575
- fig_a = plot_persona_dendrogram(
1576
- samples_a,
1577
- layered=layered_mode,
1578
- layers=selected_layers,
1579
- linkage=linkage,
1580
- title=f"Dendrogram — {prompt_variant_label(variant_a)}",
1581
- )
1582
- fig_a.update_layout(height=750)
1583
- del samples_a
1584
- fig_b = None
1585
- if variant_a != variant_b:
1586
- progress.progress(60, text="Loading second variant vectors…")
1587
- samples_b = _load_persona_vectors(
1588
- store,
1589
- variant_b,
1590
- mask_strategy,
1591
- persona_ids,
1592
- )
1593
- progress.progress(75, text="Building second dendrogram…")
1594
- fig_b = plot_persona_dendrogram(
1595
- samples_b,
1596
- layered=layered_mode,
1597
- layers=selected_layers,
1598
- linkage=linkage,
1599
- title=f"Dendrogram — {prompt_variant_label(variant_b)}",
1600
- )
1601
- fig_b.update_layout(height=750)
1602
- del samples_b
1603
- progress.progress(90, text="Storing figure state…")
1604
- _store_figure_state(
1605
- fig_key,
1606
- (fig_a, fig_b, len(persona_ids), variant_a, variant_b),
1607
- )
1608
- progress.progress(100, text="Done.")
1609
- except Exception as exc:
1610
- st.error(f"Could not build dendrogram: {exc}")
1611
- st.session_state.pop(fig_key, None)
1612
- finally:
1613
- _release_vector_memory(store, shared_variants)
1614
- progress.empty()
1615
-
1616
- if fig_key in st.session_state:
1617
- fig_a, fig_b, n_personas, va, vb = st.session_state[fig_key]
1618
- if fig_b is not None:
1619
- col_a, col_b = st.columns(2)
1620
- with col_a:
1621
- st.subheader(prompt_variant_label(va))
1622
- _plotly_chart(fig_a)
1623
- with col_b:
1624
- st.subheader(prompt_variant_label(vb))
1625
- _plotly_chart(fig_b)
1626
- else:
1627
- _plotly_chart(fig_a)
1628
-
1629
- figs = [fig_a] + ([fig_b] if fig_b else [])
1630
- filenames = [
1631
- _filename("dendro", store.model_name, mask_strategy.value, va),
1632
- *(
1633
- [_filename("dendro", store.model_name, mask_strategy.value, vb)]
1634
- if fig_b
1635
- else []
1636
- ),
1637
- ]
1638
- _render_save_buttons(figs, filenames, "dendro")
1639
- st.success(f"Generated dendrogram(s) for {n_personas} persona(s).")
1640
 
1641
 
1642
  def _render_source_select() -> str:
 
 
 
 
 
1
  from pathlib import Path
2
 
 
3
  import streamlit as st
4
  from persona_data.environment import get_artifacts_dir
 
 
 
 
 
 
5
  from persona_vectors.extraction import MaskStrategy
 
 
 
 
 
 
 
 
 
6
 
7
  from utils.analysis_sources import (
8
  DEFAULT_COMPARE_MODEL,
 
12
  SOURCES,
13
  Store,
14
  activation_store_cached,
 
15
  hub_models_by_mask_strategy,
 
 
16
  local_model_matches,
17
  local_model_options_cached,
 
 
 
 
 
 
 
 
 
 
18
  )
 
19
  from utils.helpers import (
20
  ANALYSIS_HELP_TEXT,
21
  ANALYSIS_MODES,
 
22
  prompt_variant_label,
 
23
  widget_key,
24
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
+ from tabs.analysis._shared import _render_mask_strategy_select
27
+ from tabs.analysis._state import (
28
+ _DEFAULT_PERSONA_LIMITS,
29
+ _LAST_PROJECTION_DIMS_KEY,
30
+ _LAST_SIMILARITY_PERSONAS_KEY,
31
+ _LAST_SOURCE_KEY,
32
+ )
33
+ from tabs.analysis.cosine import _render_cosine_similarity
34
+ from tabs.analysis.dendrogram import _render_dendrogram_analysis
35
+ from tabs.analysis.layered import _render_layered_figure_analysis
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
 
38
  def _render_source_select() -> str:
tabs/probe.py CHANGED
@@ -322,8 +322,8 @@ def _cached_sweep(
322
  if inputs.n_pca_components is not None:
323
  # Always overlay the compressed sweep against full activations.
324
  rows_by_label = {
325
- f"pca{inputs.n_pca_components}": _sweep(inputs.n_pca_components),
326
  "full": _sweep(None),
 
327
  }
328
  else:
329
  rows_by_label = {"full": _sweep(None)}
 
322
  if inputs.n_pca_components is not None:
323
  # Always overlay the compressed sweep against full activations.
324
  rows_by_label = {
 
325
  "full": _sweep(None),
326
+ f"pca{inputs.n_pca_components}": _sweep(inputs.n_pca_components),
327
  }
328
  else:
329
  rows_by_label = {"full": _sweep(None)}
tests/test_probes.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression tests for utils.probes.
2
+
3
+ Covers the probe-artifact filename parser (both naming conventions) and the
4
+ two correctness fixes:
5
+
6
+ * ``_normalize_batch`` applies PCA independently of the scaler (previously the
7
+ PCA branch was unreachable when no scaler was present).
8
+ * ``LoadedProbe.run`` predicts class 1 for a single-output probe whose sigmoid
9
+ score is >= 0.5 (previously it always predicted class 0).
10
+ """
11
+
12
+ import pytest
13
+ import torch
14
+
15
+ from utils.probes import (
16
+ LoadedProbe,
17
+ _LinearProbe,
18
+ _normalize_labels,
19
+ parse_probe_filename,
20
+ )
21
+
22
+
23
+ # --------------------------------------------------------------------------- #
24
+ # parse_probe_filename
25
+ # --------------------------------------------------------------------------- #
26
+
27
+
28
+ def test_parse_cognitive_map_filename():
29
+ meta = parse_probe_filename(
30
+ "cognitive_map_probe_layer12_lr_pre_reasoning_all_general.pt"
31
+ )
32
+ assert meta.layer == 12
33
+ assert meta.model_type == "lr"
34
+ assert meta.location == "pre_reasoning"
35
+ assert meta.scope == "general"
36
+
37
+
38
+ def test_parse_persona_probe_dir_without_pca():
39
+ meta = parse_probe_filename(
40
+ "google__gemma-3-27b-it/answer_mean/biography/sex/"
41
+ "logistic_regression_layer20/probe.json"
42
+ )
43
+ assert meta.layer == 20
44
+ assert meta.model_type == "logistic_regression"
45
+ assert meta.scope is None
46
+ assert meta.attribute_name == "sex"
47
+ assert meta.model_name == "google/gemma-3-27b-it"
48
+
49
+
50
+ def test_parse_persona_probe_dir_with_pca():
51
+ meta = parse_probe_filename(
52
+ "google__gemma-3-27b-it/answer_mean/biography/sex/"
53
+ "logistic_regression_pca10_layer46/weights.safetensors"
54
+ )
55
+ assert meta.layer == 46
56
+ assert meta.model_type == "logistic_regression"
57
+ assert meta.scope == "pca10"
58
+ assert meta.attribute_name == "sex"
59
+
60
+
61
+ def test_parse_unknown_filename_falls_back():
62
+ meta = parse_probe_filename("something_else.bin")
63
+ assert meta.layer is None
64
+ assert meta.model_type == "unknown"
65
+
66
+
67
+ # --------------------------------------------------------------------------- #
68
+ # _normalize_labels
69
+ # --------------------------------------------------------------------------- #
70
+
71
+
72
+ def test_normalize_labels_list_pads_and_truncates():
73
+ assert _normalize_labels(["a", "b"], 3) == ["a", "b", None]
74
+ assert _normalize_labels(["a", "b", "c"], 2) == ["a", "b"]
75
+
76
+
77
+ def test_normalize_labels_dict_indexes_by_key():
78
+ assert _normalize_labels({"1": "pos", "0": "neg"}, 2) == ["neg", "pos"]
79
+
80
+
81
+ def test_normalize_labels_none():
82
+ assert _normalize_labels(None, 2) == [None, None]
83
+
84
+
85
+ # --------------------------------------------------------------------------- #
86
+ # _normalize_batch — scaler and PCA are applied independently
87
+ # --------------------------------------------------------------------------- #
88
+
89
+
90
+ def _probe(model_input_dim: int, **kwargs) -> LoadedProbe:
91
+ return LoadedProbe(
92
+ model=_LinearProbe(input_dim=model_input_dim, num_classes=1),
93
+ input_dim=model_input_dim,
94
+ labels=[None],
95
+ model_type="linear",
96
+ layer=0,
97
+ location=None,
98
+ **kwargs,
99
+ )
100
+
101
+
102
+ def test_normalize_batch_noop_without_scaler_or_pca():
103
+ probe = _probe(3)
104
+ batch = torch.tensor([[1.0, 2.0, 3.0]])
105
+ assert torch.equal(probe._normalize_batch(batch), batch)
106
+
107
+
108
+ def test_normalize_batch_scaler_only():
109
+ probe = _probe(
110
+ 3,
111
+ scaler_mean=torch.ones(3),
112
+ scaler_std=torch.full((3,), 2.0),
113
+ )
114
+ batch = torch.tensor([[3.0, 5.0, 7.0]])
115
+ out = probe._normalize_batch(batch)
116
+ torch.testing.assert_close(out, torch.tensor([[1.0, 2.0, 3.0]]))
117
+
118
+
119
+ def test_normalize_batch_pca_only_applies_pca():
120
+ """Regression: PCA must apply even when no scaler is present."""
121
+ probe = _probe(
122
+ 2,
123
+ pca_mean=torch.ones(3),
124
+ pca_components=torch.tensor(
125
+ [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
126
+ ),
127
+ )
128
+ batch = torch.tensor([[2.0, 4.0, 9.0]])
129
+ out = probe._normalize_batch(batch)
130
+ # (batch - pca_mean) @ components.T -> rows [1, 3] selected by components
131
+ torch.testing.assert_close(out, torch.tensor([[1.0, 3.0]]))
132
+
133
+
134
+ def test_normalize_batch_scaler_then_pca():
135
+ probe = _probe(
136
+ 3,
137
+ scaler_mean=torch.zeros(3),
138
+ scaler_std=torch.ones(3),
139
+ pca_mean=torch.zeros(3),
140
+ pca_components=torch.eye(3),
141
+ )
142
+ batch = torch.tensor([[1.0, 2.0, 3.0]])
143
+ torch.testing.assert_close(probe._normalize_batch(batch), batch)
144
+
145
+
146
+ def test_normalize_batch_scaler_shape_mismatch_raises():
147
+ probe = _probe(
148
+ 3,
149
+ scaler_mean=torch.ones(5),
150
+ scaler_std=torch.ones(5),
151
+ )
152
+ with pytest.raises(ValueError, match="scaler shape"):
153
+ probe._normalize_batch(torch.zeros(1, 3))
154
+
155
+
156
+ def test_normalize_batch_pca_shape_mismatch_raises():
157
+ probe = _probe(
158
+ 2,
159
+ pca_mean=torch.ones(5),
160
+ pca_components=torch.zeros(2, 5),
161
+ )
162
+ with pytest.raises(ValueError, match="PCA mean shape"):
163
+ probe._normalize_batch(torch.zeros(1, 3))
164
+
165
+
166
+ # --------------------------------------------------------------------------- #
167
+ # LoadedProbe.run — single-output prediction
168
+ # --------------------------------------------------------------------------- #
169
+
170
+
171
+ def _single_output_probe(weight: list[float], bias: float) -> LoadedProbe:
172
+ model = _LinearProbe(input_dim=len(weight), num_classes=1)
173
+ with torch.no_grad():
174
+ model.linear.weight.copy_(torch.tensor([weight]))
175
+ model.linear.bias.copy_(torch.tensor([bias]))
176
+ return LoadedProbe(
177
+ model=model,
178
+ input_dim=len(weight),
179
+ labels=["neg", "pos"],
180
+ model_type="linear",
181
+ layer=0,
182
+ location=None,
183
+ )
184
+
185
+
186
+ def test_run_single_output_predicts_positive_when_score_high():
187
+ """Regression: single-output probe must predict class 1 when sigmoid >= 0.5."""
188
+ probe = _single_output_probe(weight=[1.0, 1.0], bias=5.0)
189
+ result = probe.run(torch.tensor([1.0, 1.0]))
190
+ assert result.predicted_index == 1
191
+ assert result.predicted_label == "pos"
192
+
193
+
194
+ def test_run_single_output_predicts_negative_when_score_low():
195
+ probe = _single_output_probe(weight=[1.0, 1.0], bias=-5.0)
196
+ result = probe.run(torch.tensor([1.0, 1.0]))
197
+ assert result.predicted_index == 0
198
+ assert result.predicted_label == "neg"
utils/probes.py CHANGED
@@ -154,7 +154,9 @@ class LoadedProbe:
154
  probs = probs.unsqueeze(0)
155
 
156
  predicted_index = (
157
- 0 if probs.numel() == 1 else int(torch.argmax(probs).item())
 
 
158
  )
159
  predicted_label = (
160
  self.labels[predicted_index]
@@ -208,28 +210,27 @@ class LoadedProbe:
208
  return logits, probs
209
 
210
  def _normalize_batch(self, batch: torch.Tensor) -> torch.Tensor:
211
- if self.scaler_mean is None or self.scaler_std is None:
212
- return batch
213
- mean = self.scaler_mean.to(dtype=torch.float32)
214
- std = self.scaler_std.to(dtype=torch.float32)
215
- if mean.ndim != 1 or std.ndim != 1 or mean.shape[0] != batch.shape[1]:
216
- raise ValueError(
217
- "Probe scaler shape does not match activation hidden size: "
218
- f"mean={tuple(mean.shape)} std={tuple(std.shape)} "
219
- f"batch={tuple(batch.shape)}"
220
- )
221
- safe_std = torch.where(std == 0, torch.ones_like(std), std)
222
- batch = (batch - mean) / safe_std
223
- if self.pca_mean is None or self.pca_components is None:
224
- return batch
225
- pca_mean = self.pca_mean.to(dtype=torch.float32)
226
- components = self.pca_components.to(dtype=torch.float32)
227
- if pca_mean.ndim != 1 or pca_mean.shape[0] != batch.shape[1]:
228
- raise ValueError(
229
- "Probe PCA mean shape does not match activation hidden size: "
230
- f"mean={tuple(pca_mean.shape)} batch={tuple(batch.shape)}"
231
- )
232
- return (batch - pca_mean) @ components.T
233
 
234
 
235
  def model_probe_dir_name(model_name: str) -> str:
 
154
  probs = probs.unsqueeze(0)
155
 
156
  predicted_index = (
157
+ int(probs.item() >= 0.5)
158
+ if probs.numel() == 1
159
+ else int(torch.argmax(probs).item())
160
  )
161
  predicted_label = (
162
  self.labels[predicted_index]
 
210
  return logits, probs
211
 
212
  def _normalize_batch(self, batch: torch.Tensor) -> torch.Tensor:
213
+ if self.scaler_mean is not None and self.scaler_std is not None:
214
+ mean = self.scaler_mean.to(dtype=torch.float32)
215
+ std = self.scaler_std.to(dtype=torch.float32)
216
+ if mean.ndim != 1 or std.ndim != 1 or mean.shape[0] != batch.shape[1]:
217
+ raise ValueError(
218
+ "Probe scaler shape does not match activation hidden size: "
219
+ f"mean={tuple(mean.shape)} std={tuple(std.shape)} "
220
+ f"batch={tuple(batch.shape)}"
221
+ )
222
+ safe_std = torch.where(std == 0, torch.ones_like(std), std)
223
+ batch = (batch - mean) / safe_std
224
+ if self.pca_mean is not None and self.pca_components is not None:
225
+ pca_mean = self.pca_mean.to(dtype=torch.float32)
226
+ components = self.pca_components.to(dtype=torch.float32)
227
+ if pca_mean.ndim != 1 or pca_mean.shape[0] != batch.shape[1]:
228
+ raise ValueError(
229
+ "Probe PCA mean shape does not match activation hidden size: "
230
+ f"mean={tuple(pca_mean.shape)} batch={tuple(batch.shape)}"
231
+ )
232
+ batch = (batch - pca_mean) @ components.T
233
+ return batch
 
234
 
235
 
236
  def model_probe_dir_name(model_name: str) -> str:
uv.lock CHANGED
@@ -343,14 +343,14 @@ wheels = [
343
 
344
  [[package]]
345
  name = "click"
346
- version = "8.3.3"
347
  source = { registry = "https://pypi.org/simple" }
348
  dependencies = [
349
  { name = "colorama", marker = "sys_platform == 'win32'" },
350
  ]
351
- sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
352
  wheels = [
353
- { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
354
  ]
355
 
356
  [[package]]
@@ -464,11 +464,11 @@ wheels = [
464
 
465
  [[package]]
466
  name = "decorator"
467
- version = "5.2.1"
468
  source = { registry = "https://pypi.org/simple" }
469
- sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" }
470
  wheels = [
471
- { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
472
  ]
473
 
474
  [[package]]
@@ -752,6 +752,15 @@ wheels = [
752
  { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
753
  ]
754
 
 
 
 
 
 
 
 
 
 
755
  [[package]]
756
  name = "ipython"
757
  version = "9.13.0"
@@ -1589,6 +1598,11 @@ dependencies = [
1589
  { name = "streamlit" },
1590
  ]
1591
 
 
 
 
 
 
1592
  [package.metadata]
1593
  requires-dist = [
1594
  { name = "catppuccin", specifier = ">=2.5.0" },
@@ -1601,6 +1615,9 @@ requires-dist = [
1601
  { name = "streamlit", specifier = ">=1.44.0" },
1602
  ]
1603
 
 
 
 
1604
  [[package]]
1605
  name = "persona-vectors"
1606
  version = "0.8.2"
@@ -1730,6 +1747,15 @@ wheels = [
1730
  { url = "https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0", size = 9898444, upload-time = "2026-04-09T20:36:39.812Z" },
1731
  ]
1732
 
 
 
 
 
 
 
 
 
 
1733
  [[package]]
1734
  name = "prompt-toolkit"
1735
  version = "3.0.52"
@@ -2068,6 +2094,22 @@ wheels = [
2068
  { url = "https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl", hash = "sha256:dc8c74844e4c7f5cbd1e0cd6909da86fdc789e6ff4997336e344779c3d5538ef", size = 73511, upload-time = "2026-01-08T21:29:57.306Z" },
2069
  ]
2070
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2071
  [[package]]
2072
  name = "python-dateutil"
2073
  version = "2.9.0.post0"
 
343
 
344
  [[package]]
345
  name = "click"
346
+ version = "8.4.0"
347
  source = { registry = "https://pypi.org/simple" }
348
  dependencies = [
349
  { name = "colorama", marker = "sys_platform == 'win32'" },
350
  ]
351
+ sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" }
352
  wheels = [
353
+ { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" },
354
  ]
355
 
356
  [[package]]
 
464
 
465
  [[package]]
466
  name = "decorator"
467
+ version = "5.3.0"
468
  source = { registry = "https://pypi.org/simple" }
469
+ sdist = { url = "https://files.pythonhosted.org/packages/5c/50/a39dd7ab407e93978dfa07d109b7d633e37958c89f30cbcec061b77b3ebc/decorator-5.3.0.tar.gz", hash = "sha256:95fda3122972c847cf0ff7e0ce2829bf25136f2526b627b3da85b60ca5f485c0", size = 58431, upload-time = "2026-05-17T06:59:57.258Z" }
470
  wheels = [
471
+ { url = "https://files.pythonhosted.org/packages/d5/6f/f8d0bba4dc2a69817d74f640d504650241ebf2f9f7263426f1b953b344d4/decorator-5.3.0-py3-none-any.whl", hash = "sha256:f8c2d71ede92f073144ddd7f3e9fbbc3bd0f2f29522c9d75ee648d66553834f4", size = 11104, upload-time = "2026-05-17T06:59:54.676Z" },
472
  ]
473
 
474
  [[package]]
 
752
  { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
753
  ]
754
 
755
+ [[package]]
756
+ name = "iniconfig"
757
+ version = "2.3.0"
758
+ source = { registry = "https://pypi.org/simple" }
759
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
760
+ wheels = [
761
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
762
+ ]
763
+
764
  [[package]]
765
  name = "ipython"
766
  version = "9.13.0"
 
1598
  { name = "streamlit" },
1599
  ]
1600
 
1601
+ [package.dev-dependencies]
1602
+ dev = [
1603
+ { name = "pytest" },
1604
+ ]
1605
+
1606
  [package.metadata]
1607
  requires-dist = [
1608
  { name = "catppuccin", specifier = ">=2.5.0" },
 
1615
  { name = "streamlit", specifier = ">=1.44.0" },
1616
  ]
1617
 
1618
+ [package.metadata.requires-dev]
1619
+ dev = [{ name = "pytest", specifier = ">=9.0.3" }]
1620
+
1621
  [[package]]
1622
  name = "persona-vectors"
1623
  version = "0.8.2"
 
1747
  { url = "https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0", size = 9898444, upload-time = "2026-04-09T20:36:39.812Z" },
1748
  ]
1749
 
1750
+ [[package]]
1751
+ name = "pluggy"
1752
+ version = "1.6.0"
1753
+ source = { registry = "https://pypi.org/simple" }
1754
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
1755
+ wheels = [
1756
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
1757
+ ]
1758
+
1759
  [[package]]
1760
  name = "prompt-toolkit"
1761
  version = "3.0.52"
 
2094
  { url = "https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl", hash = "sha256:dc8c74844e4c7f5cbd1e0cd6909da86fdc789e6ff4997336e344779c3d5538ef", size = 73511, upload-time = "2026-01-08T21:29:57.306Z" },
2095
  ]
2096
 
2097
+ [[package]]
2098
+ name = "pytest"
2099
+ version = "9.0.3"
2100
+ source = { registry = "https://pypi.org/simple" }
2101
+ dependencies = [
2102
+ { name = "colorama", marker = "sys_platform == 'win32'" },
2103
+ { name = "iniconfig" },
2104
+ { name = "packaging" },
2105
+ { name = "pluggy" },
2106
+ { name = "pygments" },
2107
+ ]
2108
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
2109
+ wheels = [
2110
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
2111
+ ]
2112
+
2113
  [[package]]
2114
  name = "python-dateutil"
2115
  version = "2.9.0.post0"