ArchitSharma commited on
Commit
4a79e5b
·
1 Parent(s): 2082e2f

Release FeatureLens v0.5.0

Browse files
.github/workflows/ci.yml CHANGED
@@ -15,12 +15,16 @@ jobs:
15
  - name: Install lightweight test dependencies
16
  run: |
17
  python -m pip install --upgrade pip
18
- pip install "torch>=2.8,<2.12" "huggingface_hub>=0.34,<2" "numpy>=2,<3" "pytest>=8.3,<10" "ruff>=0.9,<1"
 
 
19
  - name: Ruff
20
  run: python -m ruff check app.py featurelens experiments tests scripts
21
  - name: Unit tests
22
  run: python -m pytest -q
23
  - name: Compile
24
  run: python -m compileall -q app.py featurelens experiments scripts
 
 
25
  - name: Release check
26
  run: python scripts/release_check.py
 
15
  - name: Install lightweight test dependencies
16
  run: |
17
  python -m pip install --upgrade pip
18
+ pip install "torch>=2.8,<2.12" "huggingface_hub>=0.34,<2" "numpy>=2,<3" \
19
+ "pandas>=2.2,<3" "gradio==6.24.0" "transformers>=4.51,<6" \
20
+ "pytest>=8.3,<10" "ruff>=0.9,<1"
21
  - name: Ruff
22
  run: python -m ruff check app.py featurelens experiments tests scripts
23
  - name: Unit tests
24
  run: python -m pytest -q
25
  - name: Compile
26
  run: python -m compileall -q app.py featurelens experiments scripts
27
+ - name: UI launch smoke
28
+ run: python scripts/ui_smoke.py
29
  - name: Release check
30
  run: python scripts/release_check.py
CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
  # Changelog
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  ## v0.4.0
4
 
5
  ### Causal correctness
 
1
  # Changelog
2
 
3
+ ## v0.5.0
4
+
5
+ ### Causal specificity
6
+ - Added a **contrastive continuation preference test** that scores two exact continuations under the same single-feature intervention and 8-direction norm-matched control ensemble.
7
+ - Reports baseline/edited sequence log-odds A−B, causal log-odds shift, token-normalized preference shift, random-control magnitude statistics, and an exploratory empirical tail probability.
8
+ - Keeps this distinct from absolute target probability so broad distributional disruption is not mistaken for selective behavioral control.
9
+
10
+ ### Feature evidence and geometry
11
+ - Added a **feature-token activation trace** over every token in the current Workbench prompt.
12
+ - Fixed the controlled concept scan to use **prompt-wide max activation over non-padding tokens** instead of only the final token.
13
+ - All-zero concept batches now report `inactive in every sampled prompt` and do not invent a leading concept.
14
+ - Added **feature-set decoder geometry** for 2–8 selected features: pairwise decoder cosine, mean/max absolute cosine, activation-weighted joint-ablation norm, independent-direction reference norm, and alignment/cancellation ratio.
15
+
16
+ ### Interface
17
+ - Widened and explicitly centered the application canvas (up to 1600 px) and enabled `fill_width=True` to use desktop space more effectively.
18
+ - Normalized serif typography, labels, controls, table font sizes, and action-button styling.
19
+ - Added bounded Dataframe heights to reduce excessive dynamic page growth.
20
+ - Added copy-button visual acknowledgement (`✓ Copied with headers`).
21
+ - Added a browser-side resize/mutation observer to request layout reflow when dynamic output height changes inside an embedded Space.
22
+ - Retained the safe Gradio theme configuration without string font tuples; serif typography is applied in CSS.
23
+
24
+ ### Validation
25
+ - Expanded automated coverage from 29 to **38 tests**, including contrastive log-odds, decoder geometry, copy/export helpers, and toy-runtime end-to-end checks.
26
+ - Added `scripts/ui_smoke.py` so the actual Gradio `launch()` path is part of the release procedure instead of only constructing the component tree.
27
+ - Reworked v0.5 acceptance tests around the new concept-scan semantics, feature-token trace, contrastive preference, geometry, copy feedback, and embedded-page reflow.
28
+
29
  ## v0.4.0
30
 
31
  ### Causal correctness
README.md CHANGED
@@ -13,7 +13,7 @@ license: mit
13
 
14
  # FeatureLens — Causal Interpretability Workbench
15
 
16
- > **v0.4:** batch-context causal baselines, 8-direction norm-matched random ensembles, individual-vs-joint interaction decomposition, prompt-wide paraphrase robustness, a controlled live concept contrast, copy-ready tables with headers, and a more restrained research-instrument UI.
17
 
18
  **Research question:**
19
 
@@ -34,14 +34,16 @@ FeatureLens is independent of thesis code and thesis datasets.
34
  7. **Feature-set causality** — do small sparse subspaces matter more than one feature?
35
  8. **Non-additivity** — does a joint edit differ from the sum of individual effects?
36
  9. **Specificity** — are targeted effects larger than norm-matched random residual perturbations?
 
 
37
 
38
  A high AUROC, high paraphrase overlap, or a large activation is still correlational evidence. Causal evidence comes from downstream change under controlled intervention.
39
 
40
- ## Why v0.4 changed the causal baseline
41
 
42
  v0.3 batched multiple residual edits for efficiency, but compared them with a **separately executed baseline**. On real Qwen3 inference this produced a small numerical discrepancy in the nominal `1×` no-edit dose-response row.
43
 
44
- v0.4 therefore includes an explicit **batched zero-edit reference** in causal batches. Every effect inside that batch is measured against the zero-edit row from the **same execution context**.
45
 
46
  For the scale dose-response, the `1×` row itself is the no-edit reference:
47
 
@@ -58,7 +60,7 @@ The UI also reports single-forward → batched-null execution drift as a diagnos
58
 
59
  ## Random-control ensemble
60
 
61
- A single random residual direction can be unusually weak or unusually disruptive by chance. v0.4 replaces the live single control with **8 deterministic norm-matched random directions**.
62
 
63
  For a targeted residual delta `Δh`, each control satisfies:
64
 
@@ -123,7 +125,10 @@ The difference is reported as an **interaction excess**. A non-zero excess means
123
 
124
  ### Feature evidence
125
 
126
- The **Controlled concept contrast** takes a selected feature and evaluates it on a small balanced batch drawn from the repository's seven controlled concept groups:
 
 
 
127
 
128
  - code;
129
  - mathematics;
@@ -133,13 +138,27 @@ The **Controlled concept contrast** takes a selected feature and evaluates it on
133
  - factual entities;
134
  - uncertainty.
135
 
136
- One wording per paraphrase pair is used to reduce near-duplicate inflation. The live scan reports mean activation, median activation, activation rate, and maximum activation by concept.
137
 
138
  This is an exploratory live feature-evidence tool, **not an automatic semantic label**. The held-out offline AUROC/F1 benchmark remains the stronger claim.
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  ### Paraphrase robustness
141
 
142
- v0.4 reports two complementary views:
143
 
144
  1. **Selected-token robustness** — TopK Jaccard and sparse cosine at the two manually chosen token positions.
145
  2. **Prompt-wide robustness** — for each SAE feature, take its maximum activation across all prompt tokens, then compare the two sparse prompt profiles.
@@ -150,11 +169,17 @@ The prompt-wide view reduces sensitivity to accidentally comparing semantically
150
 
151
  The trajectory view compares SAE reconstruction and sparsity/concentration diagnostics at the same prompt token across layers 4, 14 and 26. It does **not** equate feature IDs across layer-specific SAE dictionaries.
152
 
 
 
 
 
 
 
153
  ## Copy tables with headers
154
 
155
  Every result table that is intended for analysis has an explicit **Copy table with headers** button. The app serializes the displayed data as TSV before copying, so column names are preserved.
156
 
157
- The native Dataframe fullscreen control is retained, while the custom copy action avoids relying on browser text selection.
158
 
159
  ## Reconstruction-preserving edits
160
 
@@ -266,7 +291,7 @@ FEATURELENS_CONTRAST_PROMPTS_PER_CONCEPT=4
266
 
267
  ## UI design
268
 
269
- v0.4 intentionally moves away from a rounded, saturated dashboard aesthetic:
270
 
271
  - Times / Liberation Serif-style formal typography;
272
  - restrained teal, umber, stone, plum, and muted red chart accents;
@@ -275,7 +300,7 @@ v0.4 intentionally moves away from a rounded, saturated dashboard aesthetic:
275
  - explicit section rules instead of pill badges;
276
  - extra bottom padding and a visible end-of-workbench footer to avoid an abrupt embedded-page cutoff.
277
 
278
- The extra bottom spacing cannot control Hugging Face's outer iframe resizing, but it prevents the app itself from ending flush against its final component.
279
 
280
  ## Repository layout
281
 
@@ -301,7 +326,9 @@ FeatureLens/
301
  │ └── run_all.py
302
  ├── data/
303
  ├── tests/
304
- ├── scripts/release_check.py
 
 
305
  ├── docs/
306
  └── research_config.json
307
  ```
@@ -312,10 +339,11 @@ FeatureLens/
312
  python -m ruff check app.py featurelens experiments tests scripts
313
  python -m pytest -q
314
  python -m compileall -q app.py featurelens experiments scripts
 
315
  python scripts/release_check.py
316
  ```
317
 
318
- See [`docs/VALIDATION.md`](docs/VALIDATION.md). The validation guide uses the **exact labels shown in the v0.4 UI** and defines expected outcomes for normal, edge, and adversarial cases.
319
 
320
  ## Limitations
321
 
@@ -330,7 +358,7 @@ See [`docs/VALIDATION.md`](docs/VALIDATION.md). The validation guide uses the **
330
  ## Resume-ready description
331
 
332
  > **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
333
- > Built an SAE-based interpretability system for Qwen3-1.7B with held-out concept discovery, local/prompt-wide paraphrase robustness, reconstruction-preserving single and multi-feature interventions, full-continuation scoring, dose-response analysis, non-additivity tests, and norm-matched random-control ensembles.
334
 
335
  ## Acknowledgements
336
 
 
13
 
14
  # FeatureLens — Causal Interpretability Workbench
15
 
16
+ > **v0.5:** contrastive continuation preference tests, feature-token activation traces, SAE decoder-geometry diagnostics, prompt-wide concept contrast, a wider centered research UI, copy confirmation, and dynamic-height reflow safeguards—while retaining v0.4's batched null references and 8-direction random ensembles.
17
 
18
  **Research question:**
19
 
 
34
  7. **Feature-set causality** — do small sparse subspaces matter more than one feature?
35
  8. **Non-additivity** — does a joint edit differ from the sum of individual effects?
36
  9. **Specificity** — are targeted effects larger than norm-matched random residual perturbations?
37
+ 10. **Contrastive specificity** — does an edit change preference between a desired and comparison continuation?
38
+ 11. **Geometry** — are selected SAE decoder directions aligned, orthogonal, or cancelling before downstream model non-linearity?
39
 
40
  A high AUROC, high paraphrase overlap, or a large activation is still correlational evidence. Causal evidence comes from downstream change under controlled intervention.
41
 
42
+ ## Causal baseline discipline
43
 
44
  v0.3 batched multiple residual edits for efficiency, but compared them with a **separately executed baseline**. On real Qwen3 inference this produced a small numerical discrepancy in the nominal `1×` no-edit dose-response row.
45
 
46
+ FeatureLens therefore includes an explicit **batched zero-edit reference** in causal batches. Every effect inside that batch is measured against the zero-edit row from the **same execution context**.
47
 
48
  For the scale dose-response, the `1×` row itself is the no-edit reference:
49
 
 
60
 
61
  ## Random-control ensemble
62
 
63
+ A single random residual direction can be unusually weak or unusually disruptive by chance. FeatureLens uses with **8 deterministic norm-matched random directions**.
64
 
65
  For a targeted residual delta `Δh`, each control satisfies:
66
 
 
125
 
126
  ### Feature evidence
127
 
128
+ The **Feature evidence** tab now provides two complementary live views:
129
+
130
+ 1. **Feature-token activation trace** — shows where the selected feature enters the SAE TopK support across every token of the current Workbench prompt.
131
+ 2. **Prompt-wide controlled concept contrast** — evaluates the selected feature on a small balanced batch drawn from the repository's seven controlled concept groups:
132
 
133
  - code;
134
  - mathematics;
 
138
  - factual entities;
139
  - uncertainty.
140
 
141
+ One wording per paraphrase pair is used to reduce near-duplicate inflation. Each prompt is summarized by the feature's **maximum activation across non-padding prompt tokens**. The live scan reports mean/median prompt-wide maxima, prompt activation rate, mean activation when active, and maximum activation by concept. If the feature is inactive in every sampled prompt, the UI reports that explicitly and does not fabricate a leading concept.
142
 
143
  This is an exploratory live feature-evidence tool, **not an automatic semantic label**. The held-out offline AUROC/F1 benchmark remains the stronger claim.
144
 
145
+ ### Contrastive continuation preference
146
+
147
+ A probability increase for one target can reflect a broad distribution shift rather than selective behavioral control. v0.5 adds a stricter live test with two exact continuations, A and B. FeatureLens scores both teacher-forced under the same SAE intervention and random-control ensemble, then reports the causal change in:
148
+
149
+ ```text
150
+ log P(A) - log P(B)
151
+ ```
152
+
153
+ The app also reports a token-normalized preference shift, targeted/random magnitude ratio, and exploratory empirical tail probability. This lets the workbench distinguish **general distributional influence** from a more selective change in relative continuation preference.
154
+
155
+ ### Feature-set decoder geometry
156
+
157
+ The Feature Sets tab can inspect 2–8 selected decoder directions without an additional generation experiment. It reports pairwise decoder cosine similarities and compares the L2 norm of the activation-weighted joint ablation with the root-sum-square norm expected for independent directions. A ratio below 1 indicates net geometric cancellation; above 1 indicates net alignment. This is a geometric diagnostic, not by itself evidence of downstream causal interaction.
158
+
159
  ### Paraphrase robustness
160
 
161
+ FeatureLens reports two complementary views:
162
 
163
  1. **Selected-token robustness** — TopK Jaccard and sparse cosine at the two manually chosen token positions.
164
  2. **Prompt-wide robustness** — for each SAE feature, take its maximum activation across all prompt tokens, then compare the two sparse prompt profiles.
 
169
 
170
  The trajectory view compares SAE reconstruction and sparsity/concentration diagnostics at the same prompt token across layers 4, 14 and 26. It does **not** equate feature IDs across layer-specific SAE dictionaries.
171
 
172
+ ## v0.5 interface and embedded-Space behavior
173
+
174
+ The app uses `gr.Blocks(fill_width=True)` together with a centered 1600 px research canvas so wide result tables make better use of desktop screens. Result tables use bounded heights to reduce runaway page growth in the Hugging Face iframe. A lightweight browser-side resize/mutation observer requests a layout reflow when dynamic outputs change height, addressing the intermittent case where the embedded Space stopped scrolling until the user switched tabs.
175
+
176
+ All explicit action/copy buttons share the same muted teal treatment. Copy actions now briefly change their own label to **✓ Copied with headers** before returning to the normal label. Typography and control sizes were normalized around a serif research-document style.
177
+
178
  ## Copy tables with headers
179
 
180
  Every result table that is intended for analysis has an explicit **Copy table with headers** button. The app serializes the displayed data as TSV before copying, so column names are preserved.
181
 
182
+ The native Dataframe fullscreen control is retained, while the custom copy action avoids relying on browser text selection and gives immediate visual confirmation when clipboard writing succeeds.
183
 
184
  ## Reconstruction-preserving edits
185
 
 
291
 
292
  ## UI design
293
 
294
+ v0.5 keeps the interface deliberately closer to a conventional research instrument than a rounded dashboard:
295
 
296
  - Times / Liberation Serif-style formal typography;
297
  - restrained teal, umber, stone, plum, and muted red chart accents;
 
300
  - explicit section rules instead of pill badges;
301
  - extra bottom padding and a visible end-of-workbench footer to avoid an abrupt embedded-page cutoff.
302
 
303
+ The app also installs a lightweight browser-side reflow observer for dynamic output-height changes. Hugging Face still owns the outer embedding frame, so the direct Space URL remains a useful diagnostic if embedded-page scrolling ever behaves differently.
304
 
305
  ## Repository layout
306
 
 
326
  │ └── run_all.py
327
  ├── data/
328
  ├── tests/
329
+ ├── scripts/
330
+ │ ├── release_check.py
331
+ │ └── ui_smoke.py
332
  ├── docs/
333
  └── research_config.json
334
  ```
 
339
  python -m ruff check app.py featurelens experiments tests scripts
340
  python -m pytest -q
341
  python -m compileall -q app.py featurelens experiments scripts
342
+ python scripts/ui_smoke.py
343
  python scripts/release_check.py
344
  ```
345
 
346
+ See [`docs/VALIDATION.md`](docs/VALIDATION.md). The v0.5 validation guide uses the **exact labels shown in the UI**, focuses on new-version acceptance/regression tests, and intentionally defers the comprehensive adversarial suite to the final release.
347
 
348
  ## Limitations
349
 
 
358
  ## Resume-ready description
359
 
360
  > **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
361
+ > Built an SAE-based interpretability system for Qwen3-1.7B with held-out concept discovery, token/prompt-wide feature evidence, reconstruction-preserving single and multi-feature interventions, full-continuation and contrastive preference scoring, dose-response analysis, decoder-geometry/non-additivity diagnostics, and norm-matched random-control ensembles.
362
 
363
  ## Acknowledgements
364
 
app.py CHANGED
@@ -17,116 +17,107 @@ INK_BLUEGREY = "#71808A"
17
 
18
  CSS = r"""
19
  .gradio-container {
20
- max-width: 1280px !important;
21
- padding-bottom: 96px !important;
 
 
 
22
  font-family: "Times New Roman", Times, "Liberation Serif", Georgia, serif !important;
23
- line-height: 1.45;
 
24
  }
25
  .gradio-container input,
26
  .gradio-container textarea,
27
  .gradio-container button,
28
  .gradio-container select,
29
  .gradio-container label,
30
- .gradio-container table {
 
31
  font-family: "Times New Roman", Times, "Liberation Serif", Georgia, serif !important;
32
  }
 
 
 
 
 
 
 
 
 
33
  .hero {
34
- padding: 12px 2px 4px;
35
  border-bottom: 1px solid var(--border-color-primary);
36
- margin-bottom: 14px;
37
  }
38
  .hero h1 {
39
  margin: 0;
40
- font-size: 2.15rem;
41
  font-weight: 600;
42
  letter-spacing: 0;
43
  }
44
- .hero .subtitle {
45
- margin-top: 3px;
46
- font-size: 1.02rem;
47
- opacity: .76;
48
- }
49
- .hero .metadata {
50
- margin-top: 8px;
51
- font-size: .82rem;
52
- opacity: .62;
53
- letter-spacing: .015em;
54
- }
55
  .research-q {
56
- margin: 12px 0 16px;
57
- padding: 10px 14px;
58
  border-left: 3px solid #708B86;
59
  border-top: 1px solid var(--border-color-primary);
60
  border-bottom: 1px solid var(--border-color-primary);
61
  background: var(--background-fill-secondary);
62
  }
63
  .section-rule {
64
- margin: 18px 0 10px;
65
- padding-top: 8px;
66
  border-top: 1px solid var(--border-color-primary);
67
  font-variant: small-caps;
68
- letter-spacing: .055em;
69
- font-size: .88rem;
70
- opacity: .72;
71
- }
72
- .token-wrap {
73
- display: flex;
74
- flex-wrap: wrap;
75
- gap: 5px;
76
- padding: 7px 2px 13px;
77
- line-height: 1.9;
78
  }
 
79
  .token {
80
  background: var(--background-fill-secondary);
81
  border: 1px solid var(--border-color-primary);
82
- border-radius: 3px;
83
- padding: 2px 7px;
84
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important;
85
- font-size: 12px;
86
  }
87
- .token.selected {
88
- border: 2px solid #708B86;
89
- font-weight: 700;
90
- }
91
- .token sup { opacity: .55; margin-right: 4px; }
92
- .small-note { opacity: .70; font-size: 12px; }
93
  .instrument-note {
94
  border: 1px solid var(--border-color-primary);
95
- border-radius: 4px;
96
- padding: 9px 11px;
97
  background: var(--background-fill-secondary);
98
- margin: 5px 0 10px;
99
- }
100
- .copy-row { margin-top: -5px; }
101
- .copy-row button { min-height: 31px !important; }
102
- .gradio-container button {
103
- border-radius: 4px !important;
104
- font-weight: 600 !important;
105
- }
106
- .gradio-container .form,
107
- .gradio-container .block {
108
- border-radius: 4px !important;
109
- }
110
- .gradio-container textarea,
111
- .gradio-container input {
112
- border-radius: 3px !important;
113
  }
114
- .form-note {
115
- margin-top: -3px;
116
- margin-bottom: 8px;
117
- font-size: .84rem;
118
- opacity: .70;
 
 
119
  }
 
 
 
 
 
120
  .classic-footer {
121
- margin-top: 36px;
122
- padding: 18px 2px 42px;
123
  border-top: 1px solid var(--border-color-primary);
124
- opacity: .62;
125
- font-size: .82rem;
126
- min-height: 92px;
 
 
 
 
127
  }
128
- /* Add breathing room below tabs in embedded Hugging Face iframes. */
129
- .tabs, .tabitem { padding-bottom: 18px !important; }
130
  """
131
 
132
  THEME = gr.themes.Base(
@@ -139,6 +130,18 @@ THEME = gr.themes.Base(
139
  COPY_JS = r"""
140
  (text) => {
141
  const value = text || "";
 
 
 
 
 
 
 
 
 
 
 
 
142
  const fallback = () => {
143
  const node = document.createElement("textarea");
144
  node.value = value;
@@ -149,12 +152,38 @@ COPY_JS = r"""
149
  node.select();
150
  document.execCommand("copy");
151
  document.body.removeChild(node);
 
152
  };
153
  if (navigator.clipboard && navigator.clipboard.writeText) {
154
- navigator.clipboard.writeText(value).catch(fallback);
155
  } else {
156
  fallback();
157
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  return [];
159
  }
160
  """
@@ -171,11 +200,15 @@ def _tsv(frame: pd.DataFrame) -> str:
171
 
172
 
173
  def _copy_button(label: str = "Copy table with headers") -> gr.Button:
174
- return gr.Button(label, size="sm", variant="secondary")
 
 
 
 
175
 
176
 
177
  def _bind_copy(button: gr.Button, source: gr.Textbox) -> None:
178
- button.click(fn=None, inputs=[source], outputs=None, js=COPY_JS, queue=False)
179
 
180
 
181
  def _analysis_metrics_markdown(result) -> str:
@@ -305,14 +338,74 @@ def _paraphrase_metrics_markdown(result) -> str:
305
 
306
 
307
  def _concept_metrics_markdown(result) -> str:
308
- ratio_text = "undefined" if result.leading_ratio == 0 else f"{result.leading_ratio:.2f}×"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  return (
310
  f"Feature **{result.feature_id}**, layer **{result.layer}** · "
311
- f"{result.prompts_per_concept} controlled prompts/concept. \n"
312
- f"Highest mean activation in this small live contrast: **{result.leading_concept}** "
313
- f"({ratio_text} the runner-up mean). \n\n"
314
- "This is an **exploratory contrast scan**, not an automatic semantic label. The offline held-out AUROC/F1 "
315
- "benchmark remains the stronger feature/concept evidence."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  )
317
 
318
 
@@ -657,19 +750,109 @@ def run_concept_contrast(feature_id: str, layer: int, prompts_per_concept: int):
657
  columns = [
658
  "Concept",
659
  "Prompts",
660
- "Mean activation",
661
- "Median activation",
662
- "Activation rate",
 
663
  "Max activation",
664
  ]
665
  table = pd.DataFrame(result.rows, columns=columns)
666
- chart = pd.DataFrame(result.chart_rows, columns=["Concept", "Mean activation"])
667
- chart["Series"] = "Mean activation"
668
  return _concept_metrics_markdown(result), table, chart, _tsv(table)
669
  except Exception as exc:
670
  _raise_ui_error(exc)
671
 
672
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
673
  def mode_help(mode: str):
674
  if mode == "ablate":
675
  return gr.update(value=0.0, interactive=False, label="Coefficient (unused for ablation)")
@@ -684,10 +867,10 @@ def set_mode_help(mode: str):
684
  return gr.update(value=2.0, interactive=True, label="Shared feature multiplier")
685
 
686
 
687
- with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as demo:
688
  gr.HTML(
689
  '<header class="hero">'
690
- '<h1>FeatureLens <span style="font-size:.48em;opacity:.58;font-weight:400">v0.4</span></h1>'
691
  '<div class="subtitle">Causal Interpretability Workbench</div>'
692
  '<div class="metadata">Qwen3-1.7B-Base · Qwen-Scope residual SAEs · layers 4 / 14 / 26 · '
693
  '32,768 features · TopK=50 · ZeroGPU</div>'
@@ -729,7 +912,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
729
  info="-1 = final prompt token.",
730
  )
731
  top_n = gr.Slider(5, 20, value=12, step=1, label="Displayed active features")
732
- analyze_btn = gr.Button("Inspect sparse features", variant="primary")
733
 
734
  gr.Markdown("#### Prompt tokens\nThe selected prompt token is outlined more strongly.")
735
  token_view = gr.HTML(
@@ -743,7 +926,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
743
  datatype=["number", "number", "number", "str"],
744
  interactive=False,
745
  label="Strongest active SAE features",
746
- wrap=True,
 
747
  buttons=["fullscreen"],
748
  )
749
  feature_tsv = gr.Textbox(visible="hidden")
@@ -795,7 +979,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
795
  step=1,
796
  label="Greedy generation length",
797
  )
798
- intervene_btn = gr.Button("Run single-feature causal test", variant="primary")
799
  intervention_metrics = gr.Markdown()
800
  with gr.Column(scale=3):
801
  with gr.Row():
@@ -805,7 +989,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
805
  interactive=False,
806
  label="Next-token distribution shift",
807
  buttons=["fullscreen"],
808
- wrap=True,
 
809
  )
810
  token_prob_tsv = gr.Textbox(visible="hidden")
811
  token_prob_copy = _copy_button()
@@ -813,7 +998,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
813
  interactive=False,
814
  label="Target continuation token-by-token score",
815
  buttons=["fullscreen"],
816
- wrap=True,
 
817
  )
818
  target_token_tsv = gr.Textbox(visible="hidden")
819
  target_token_copy = _copy_button()
@@ -825,7 +1011,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
825
  "the native coefficient. The six conditions are evaluated together, and all deltas are measured "
826
  "against the 1× row from that same batch."
827
  )
828
- dose_btn = gr.Button("Run scale dose-response")
829
  dose_metrics = gr.Markdown()
830
  with gr.Row(equal_height=False):
831
  with gr.Column(scale=3):
@@ -833,7 +1019,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
833
  interactive=False,
834
  label="Scale dose-response measurements",
835
  buttons=["fullscreen"],
836
- wrap=True,
 
837
  )
838
  dose_tsv = gr.Textbox(visible="hidden")
839
  dose_copy = _copy_button()
@@ -849,6 +1036,56 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
849
  height=330,
850
  )
851
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
852
  with gr.Tab("Feature sets"):
853
  gr.Markdown(
854
  "### Distributed sparse representations\n"
@@ -890,7 +1127,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
890
  lines=1,
891
  scale=2,
892
  )
893
- set_btn = gr.Button("Run joint feature-set causal test", variant="primary")
894
  set_metrics = gr.Markdown()
895
  with gr.Row(equal_height=False):
896
  with gr.Column(scale=2):
@@ -898,7 +1135,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
898
  interactive=False,
899
  label="Joint intervention features",
900
  buttons=["fullscreen"],
901
- wrap=True,
 
902
  )
903
  set_feature_tsv = gr.Textbox(visible="hidden")
904
  set_feature_copy = _copy_button()
@@ -907,7 +1145,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
907
  interactive=False,
908
  label="Target continuation token-by-token score",
909
  buttons=["fullscreen"],
910
- wrap=True,
 
911
  )
912
  set_target_tsv = gr.Textbox(visible="hidden")
913
  set_target_copy = _copy_button()
@@ -918,7 +1157,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
918
  "norm-matched random directions, all sharing one batched no-edit baseline."
919
  )
920
  set_sweep_target = gr.Textbox(label="Target continuation for set-size sweep", value="2x")
921
- set_sweep_btn = gr.Button("Run 1/3/5-feature ablation sweep")
922
  set_sweep_note = gr.Markdown()
923
  with gr.Row(equal_height=False):
924
  with gr.Column(scale=3):
@@ -926,7 +1165,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
926
  interactive=False,
927
  label="Feature-set size measurements",
928
  buttons=["fullscreen"],
929
- wrap=True,
 
930
  )
931
  set_sweep_tsv = gr.Textbox(visible="hidden")
932
  set_sweep_copy = _copy_button()
@@ -952,7 +1192,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
952
  "non-additivity without claiming that the features form a direct circuit."
953
  )
954
  interaction_target = gr.Textbox(label="Target continuation for interaction test", value="2x")
955
- interaction_btn = gr.Button("Run individual-vs-joint decomposition")
956
  interaction_metrics = gr.Markdown()
957
  with gr.Row(equal_height=False):
958
  with gr.Column(scale=3):
@@ -960,7 +1200,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
960
  interactive=False,
961
  label="Individual and joint ablation measurements",
962
  buttons=["fullscreen"],
963
- wrap=True,
 
964
  )
965
  interaction_tsv = gr.Textbox(visible="hidden")
966
  interaction_copy = _copy_button()
@@ -977,12 +1218,48 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
977
  height=330,
978
  )
979
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
980
  with gr.Tab("Feature evidence"):
981
  gr.Markdown(
982
- "### Controlled concept contrast scan\n"
983
- "This lightweight live test asks whether the selected SAE feature activates preferentially on one of "
984
- "FeatureLens's seven controlled concept groups. It uses one wording per paraphrase pair and evaluates "
985
- "the **final prompt token** for every contrast prompt in one batch."
986
  )
987
  contrast_location = gr.Markdown("No Workbench inspection has been run yet.")
988
  with gr.Row(equal_height=True):
@@ -1006,7 +1283,40 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
1006
  label="Prompts per concept",
1007
  scale=2,
1008
  )
1009
- contrast_btn = gr.Button("Run controlled concept contrast", variant="primary")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1010
  contrast_metrics = gr.Markdown()
1011
  with gr.Row(equal_height=False):
1012
  with gr.Column(scale=3):
@@ -1014,19 +1324,20 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
1014
  interactive=False,
1015
  label="Feature activation by controlled concept",
1016
  buttons=["fullscreen"],
1017
- wrap=True,
 
1018
  )
1019
  contrast_tsv = gr.Textbox(visible="hidden")
1020
  contrast_copy = _copy_button()
1021
  with gr.Column(scale=2):
1022
  contrast_plot = gr.BarPlot(
1023
  x="Concept",
1024
- y="Mean activation",
1025
  color="Series",
1026
- color_map={"Mean activation": INK_BLUEGREY},
1027
- title="Controlled concept contrast",
1028
  x_title="Concept",
1029
- y_title="Mean feature activation",
1030
  x_label_angle=-25,
1031
  height=330,
1032
  )
@@ -1035,7 +1346,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
1035
  gr.Markdown(
1036
  "### Local and prompt-wide robustness\n"
1037
  "The selected-token comparison is strict but can be misleading if the two chosen tokens play different "
1038
- "semantic roles. v0.4 therefore also reports a **prompt-wide max-pooled feature profile**: for every SAE "
1039
  "feature, take its maximum activation across all prompt tokens, then compare the two sparse profiles."
1040
  )
1041
  with gr.Row():
@@ -1058,7 +1369,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
1058
  para_idx_a = gr.Number(value=-1, precision=0, label="Original prompt token index")
1059
  para_idx_b = gr.Number(value=-1, precision=0, label="Paraphrase token index")
1060
  para_top_n = gr.Slider(5, 20, value=12, step=1, label="Displayed active features")
1061
- para_btn = gr.Button("Compare paraphrase representations", variant="primary")
1062
  with gr.Row():
1063
  with gr.Column():
1064
  gr.Markdown("#### Original prompt tokens")
@@ -1073,7 +1384,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
1073
  interactive=False,
1074
  label="Top-feature overlap at selected tokens",
1075
  buttons=["fullscreen"],
1076
- wrap=True,
 
1077
  )
1078
  para_tsv = gr.Textbox(visible="hidden")
1079
  para_copy = _copy_button()
@@ -1109,7 +1421,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
1109
  info="-1 = final token",
1110
  scale=1,
1111
  )
1112
- trajectory_btn = gr.Button("Compare layers", variant="primary")
1113
  gr.Markdown("#### Prompt tokens")
1114
  trajectory_tokens = gr.HTML()
1115
  with gr.Row(equal_height=False):
@@ -1118,7 +1430,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
1118
  interactive=False,
1119
  label="Layer diagnostics",
1120
  buttons=["fullscreen"],
1121
- wrap=True,
 
1122
  )
1123
  trajectory_tsv = gr.Textbox(visible="hidden")
1124
  trajectory_copy = _copy_button()
@@ -1143,7 +1456,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
1143
  gr.Markdown(
1144
  "The offline pipeline remains the source of held-out feature/concept AUROC + F1, reconstruction "
1145
  "quality, paraphrase stability, dense residual linear probes, and causal intervention conclusions. "
1146
- "v0.4 strengthens the live workbench with batch-context null references and random-control ensembles; "
1147
  "the saved report should be generated only from real experiment artifacts."
1148
  )
1149
 
@@ -1164,7 +1477,7 @@ $$h' = h + \sum_{i \in S}\Delta z_i d_i.$$
1164
 
1165
  FeatureLens patches the delta into the **original residual**; it never replaces the residual with the complete SAE reconstruction.
1166
 
1167
- ### v0.4 control discipline
1168
 
1169
  Batched experiments include an explicit **zero-edit row**. Causal effects are measured against that row rather than a separately executed baseline, which removes batch-vs-single floating-point drift from the measured effect. Random specificity uses an ensemble of norm-matched residual directions rather than one arbitrary seed.
1170
 
@@ -1172,14 +1485,15 @@ Batched experiments include an explicit **zero-edit row**. Causal effects are me
1172
 
1173
  1. SAE reconstruction quality.
1174
  2. Held-out feature/concept prediction.
1175
- 3. Local and prompt-wide paraphrase robustness.
1176
- 4. Controlled live concept contrast.
1177
  5. Single-feature causal intervention and dose-response.
1178
- 6. Joint feature-set intervention and set-size sensitivity.
1179
- 7. Individual-vs-joint non-additivity.
1180
- 8. Specificity relative to norm-matched random controls.
 
1181
 
1182
- Association, robustness, and intervention evidence remain separate claims.
1183
  """
1184
  )
1185
 
@@ -1189,6 +1503,8 @@ Association, robustness, and intervention evidence remain separate claims.
1189
  'End of workbench.</footer>'
1190
  )
1191
 
 
 
1192
  # Event wiring.
1193
  analyze_btn.click(
1194
  analyze_prompt,
@@ -1226,6 +1542,12 @@ Association, robustness, and intervention evidence remain separate claims.
1226
  inputs=[prompt, layer, token_index, feature_id, target_text],
1227
  outputs=[dose_table, dose_plot, dose_metrics, dose_tsv],
1228
  )
 
 
 
 
 
 
1229
  set_mode.change(set_mode_help, inputs=[set_mode], outputs=[set_coefficient])
1230
  set_btn.click(
1231
  run_feature_set,
@@ -1242,6 +1564,16 @@ Association, robustness, and intervention evidence remain separate claims.
1242
  inputs=[prompt, layer, token_index, feature_set_ids, interaction_target],
1243
  outputs=[interaction_table, interaction_metrics, interaction_plot, interaction_tsv],
1244
  )
 
 
 
 
 
 
 
 
 
 
1245
  contrast_btn.click(
1246
  run_concept_contrast,
1247
  inputs=[contrast_feature_id, contrast_layer, contrast_n],
@@ -1263,10 +1595,13 @@ Association, robustness, and intervention evidence remain separate claims.
1263
  (token_prob_copy, token_prob_tsv),
1264
  (target_token_copy, target_token_tsv),
1265
  (dose_copy, dose_tsv),
 
1266
  (set_feature_copy, set_feature_tsv),
1267
  (set_target_copy, set_target_tsv),
1268
  (set_sweep_copy, set_sweep_tsv),
1269
  (interaction_copy, interaction_tsv),
 
 
1270
  (contrast_copy, contrast_tsv),
1271
  (para_copy, para_tsv),
1272
  (trajectory_copy, trajectory_tsv),
 
17
 
18
  CSS = r"""
19
  .gradio-container {
20
+ width: min(96vw, 1600px) !important;
21
+ max-width: 1600px !important;
22
+ margin-left: auto !important;
23
+ margin-right: auto !important;
24
+ padding: 0 22px 128px !important;
25
  font-family: "Times New Roman", Times, "Liberation Serif", Georgia, serif !important;
26
+ font-size: 16px !important;
27
+ line-height: 1.52;
28
  }
29
  .gradio-container input,
30
  .gradio-container textarea,
31
  .gradio-container button,
32
  .gradio-container select,
33
  .gradio-container label,
34
+ .gradio-container table,
35
+ .gradio-container .prose {
36
  font-family: "Times New Roman", Times, "Liberation Serif", Georgia, serif !important;
37
  }
38
+ .gradio-container input,
39
+ .gradio-container textarea,
40
+ .gradio-container select { font-size: 15.5px !important; }
41
+ .gradio-container table { font-size: 14.5px !important; }
42
+ .gradio-container label, .gradio-container .label-wrap { font-size: 15px !important; }
43
+ .gradio-container p, .gradio-container li { font-size: 16px; }
44
+ .gradio-container h2 { font-size: 1.55rem; }
45
+ .gradio-container h3 { font-size: 1.30rem; }
46
+ .gradio-container h4 { font-size: 1.08rem; }
47
  .hero {
48
+ padding: 15px 2px 7px;
49
  border-bottom: 1px solid var(--border-color-primary);
50
+ margin-bottom: 16px;
51
  }
52
  .hero h1 {
53
  margin: 0;
54
+ font-size: 2.25rem;
55
  font-weight: 600;
56
  letter-spacing: 0;
57
  }
58
+ .hero .subtitle { margin-top: 4px; font-size: 1.08rem; opacity: .78; }
59
+ .hero .metadata { margin-top: 8px; font-size: .92rem; opacity: .68; letter-spacing: .01em; }
 
 
 
 
 
 
 
 
 
60
  .research-q {
61
+ margin: 14px 0 18px;
62
+ padding: 11px 15px;
63
  border-left: 3px solid #708B86;
64
  border-top: 1px solid var(--border-color-primary);
65
  border-bottom: 1px solid var(--border-color-primary);
66
  background: var(--background-fill-secondary);
67
  }
68
  .section-rule {
69
+ margin: 20px 0 11px;
70
+ padding-top: 9px;
71
  border-top: 1px solid var(--border-color-primary);
72
  font-variant: small-caps;
73
+ letter-spacing: .045em;
74
+ font-size: .98rem;
75
+ opacity: .78;
 
 
 
 
 
 
 
76
  }
77
+ .token-wrap { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 2px 14px; line-height: 1.9; }
78
  .token {
79
  background: var(--background-fill-secondary);
80
  border: 1px solid var(--border-color-primary);
81
+ border-radius: 2px;
82
+ padding: 3px 7px;
83
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important;
84
+ font-size: 12.5px;
85
  }
86
+ .token.selected { border: 2px solid #708B86; font-weight: 700; }
87
+ .token sup { opacity: .58; margin-right: 4px; }
88
+ .small-note { opacity: .74; font-size: 14px; }
 
 
 
89
  .instrument-note {
90
  border: 1px solid var(--border-color-primary);
91
+ border-radius: 2px;
92
+ padding: 10px 12px;
93
  background: var(--background-fill-secondary);
94
+ margin: 6px 0 11px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  }
96
+ .copy-row { margin-top: -4px; }
97
+ .copy-row button { min-height: 34px !important; }
98
+ .gradio-container button { border-radius: 3px !important; font-weight: 600 !important; font-size: 15px !important; }
99
+ .action-btn button, .copy-btn button {
100
+ background: #5f817b !important;
101
+ color: #fff !important;
102
+ border: 1px solid #5f817b !important;
103
  }
104
+ .action-btn button:hover, .copy-btn button:hover { background: #536f6a !important; border-color: #536f6a !important; }
105
+ .gradio-container .form, .gradio-container .block { border-radius: 3px !important; }
106
+ .gradio-container textarea, .gradio-container input { border-radius: 2px !important; }
107
+ .form-note { margin-top: -3px; margin-bottom: 9px; font-size: .92rem; opacity: .72; }
108
+ .wide-table { width: 100% !important; }
109
  .classic-footer {
110
+ margin-top: 44px;
111
+ padding: 20px 2px 58px;
112
  border-top: 1px solid var(--border-color-primary);
113
+ opacity: .70;
114
+ font-size: .92rem;
115
+ min-height: 110px;
116
+ }
117
+ .tabs, .tabitem { padding-bottom: 26px !important; }
118
+ @media (max-width: 900px) {
119
+ .gradio-container { width: 100% !important; padding-left: 12px !important; padding-right: 12px !important; }
120
  }
 
 
121
  """
122
 
123
  THEME = gr.themes.Base(
 
130
  COPY_JS = r"""
131
  (text) => {
132
  const value = text || "";
133
+ const button = document.activeElement && document.activeElement.tagName === "BUTTON"
134
+ ? document.activeElement : null;
135
+ const oldLabel = button ? button.innerText : null;
136
+ const signal = () => {
137
+ if (!button) return;
138
+ button.innerText = "✓ Copied with headers";
139
+ button.disabled = true;
140
+ window.setTimeout(() => {
141
+ button.innerText = oldLabel || "Copy table with headers";
142
+ button.disabled = false;
143
+ }, 1200);
144
+ };
145
  const fallback = () => {
146
  const node = document.createElement("textarea");
147
  node.value = value;
 
152
  node.select();
153
  document.execCommand("copy");
154
  document.body.removeChild(node);
155
+ signal();
156
  };
157
  if (navigator.clipboard && navigator.clipboard.writeText) {
158
+ navigator.clipboard.writeText(value).then(signal).catch(fallback);
159
  } else {
160
  fallback();
161
  }
162
+ return [value];
163
+ }
164
+ """
165
+
166
+ INSTALL_REFLOW_JS = r"""
167
+ () => {
168
+ if (window.__featurelens_reflow_installed) return [];
169
+ window.__featurelens_reflow_installed = true;
170
+ let timer = null;
171
+ const kick = () => {
172
+ window.clearTimeout(timer);
173
+ timer = window.setTimeout(() => {
174
+ window.dispatchEvent(new Event("resize"));
175
+ }, 80);
176
+ };
177
+ const root = document.querySelector(".gradio-container") || document.body;
178
+ if (window.ResizeObserver) {
179
+ const observer = new ResizeObserver(kick);
180
+ observer.observe(root);
181
+ window.__featurelens_reflow_observer = observer;
182
+ }
183
+ const mutation = new MutationObserver(kick);
184
+ mutation.observe(root, {subtree: true, childList: true});
185
+ window.__featurelens_mutation_observer = mutation;
186
+ kick();
187
  return [];
188
  }
189
  """
 
200
 
201
 
202
  def _copy_button(label: str = "Copy table with headers") -> gr.Button:
203
+ return gr.Button(label, size="sm", variant="primary", elem_classes=["copy-btn"])
204
+
205
+
206
+ def _copy_ack(_text: str) -> None:
207
+ gr.Info("Copied table with headers.", duration=1.2)
208
 
209
 
210
  def _bind_copy(button: gr.Button, source: gr.Textbox) -> None:
211
+ button.click(fn=_copy_ack, inputs=[source], outputs=None, js=COPY_JS, queue=False)
212
 
213
 
214
  def _analysis_metrics_markdown(result) -> str:
 
338
 
339
 
340
  def _concept_metrics_markdown(result) -> str:
341
+ coverage = f"{result.active_prompt_count}/{result.total_prompt_count}"
342
+ if result.leading_concept is None:
343
+ leader = (
344
+ "The feature was **inactive in every sampled prompt**. No concept leader is reported; "
345
+ "increase the sample or inspect a different feature."
346
+ )
347
+ elif result.leading_ratio is None:
348
+ leader = (
349
+ f"Highest prompt-wide mean activation: **{result.leading_concept}**; the runner-up mean was zero."
350
+ )
351
+ else:
352
+ leader = (
353
+ f"Highest prompt-wide mean activation: **{result.leading_concept}** "
354
+ f"(**{result.leading_ratio:.2f}×** the runner-up mean)."
355
+ )
356
  return (
357
  f"Feature **{result.feature_id}**, layer **{result.layer}** · "
358
+ f"{result.prompts_per_concept} controlled prompts/concept · active in **{coverage}** sampled prompts. \n"
359
+ f"{leader} \n\n"
360
+ "Each prompt is summarized by the feature's **maximum activation across its non-padding tokens**. "
361
+ "This is an exploratory contrast scan, not an automatic semantic label; held-out AUROC/F1 remains the stronger evidence."
362
+ )
363
+
364
+
365
+ def _trace_metrics_markdown(result) -> str:
366
+ if result.max_token_index is None:
367
+ peak = "Feature is inactive at every prompt token."
368
+ else:
369
+ peak = (
370
+ f"Peak activation **{result.max_activation:.4f}** at token **{result.max_token_index}** "
371
+ f"({result.tokens[result.max_token_index]!r})."
372
+ )
373
+ return (
374
+ f"Feature **{result.feature_id}**, layer **{result.layer}** · active at "
375
+ f"**{result.active_token_count}/{result.token_count}** prompt tokens. \n{peak}"
376
+ )
377
+
378
+
379
+ def _geometry_metrics_markdown(result) -> str:
380
+ if result.alignment_ratio > 1.05:
381
+ geometry = "The activation-weighted ablation directions are net **aligned**."
382
+ elif result.alignment_ratio < 0.95:
383
+ geometry = "The activation-weighted ablation directions show net **cancellation**."
384
+ else:
385
+ geometry = "The joint norm is close to the orthogonal/independent reference."
386
+ return (
387
+ f"Selected features: **{', '.join(str(x) for x in result.feature_ids)}** · layer **{result.layer}** \n"
388
+ f"Mean |decoder cosine|: **{result.mean_abs_decoder_cosine:.3f}** · "
389
+ f"max |decoder cosine|: **{result.max_abs_decoder_cosine:.3f}** \n"
390
+ f"Joint ablation L2: **{result.joint_ablation_norm:.4f}** · independent-direction reference: "
391
+ f"**{result.independent_norm:.4f}** · geometry ratio: **{result.alignment_ratio:.3f}×** \n"
392
+ f"{geometry} This diagnoses SAE decoder geometry; it does not by itself establish downstream causal interaction."
393
+ )
394
+
395
+
396
+ def _contrastive_metrics_markdown(result) -> str:
397
+ direction = "toward A" if result.delta_log_odds > 0 else ("toward B" if result.delta_log_odds < 0 else "neither way")
398
+ return (
399
+ f"Feature **{result.feature_id}** · activation **{result.feature_activation:.4f}** · perturbation L2 "
400
+ f"**{result.perturbation_norm:.4f}** \n"
401
+ f"Exact-sequence log-odds A−B — baseline: **{result.baseline_log_odds:+.4f}** · "
402
+ f"SAE edit: **{result.modified_log_odds:+.4f}** · causal shift: **{result.delta_log_odds:+.4f}** ({direction}). \n"
403
+ f"Token-normalized preference shift: **{result.delta_normalized_preference:+.4f}**. \n"
404
+ f"Random ensemble ({result.random_control_count}) |Δ log-odds| mean: **{result.random_abs_mean_delta:.4f}** "
405
+ f"± **{result.random_delta_std:.4f}** · SAE/random ratio: **{result.specificity_ratio:.2f}×** · "
406
+ f"empirical tail p: **{result.empirical_p:.3f}**. \n\n"
407
+ "This asks whether the intervention changes the model's **relative preference between two specified continuations**, "
408
+ "which is stricter than showing that one continuation's probability moved in isolation."
409
  )
410
 
411
 
 
750
  columns = [
751
  "Concept",
752
  "Prompts",
753
+ "Mean prompt-wide max",
754
+ "Median prompt-wide max",
755
+ "Prompt activation rate",
756
+ "Mean when active",
757
  "Max activation",
758
  ]
759
  table = pd.DataFrame(result.rows, columns=columns)
760
+ chart = pd.DataFrame(result.chart_rows, columns=["Concept", "Mean prompt-wide max"])
761
+ chart["Series"] = "Prompt-wide max"
762
  return _concept_metrics_markdown(result), table, chart, _tsv(table)
763
  except Exception as exc:
764
  _raise_ui_error(exc)
765
 
766
 
767
+ @gpu(duration=30)
768
+ def run_feature_trace(prompt: str, layer: int, feature_id: str):
769
+ try:
770
+ if not prompt.strip():
771
+ raise ValueError("Enter a prompt first.")
772
+ if feature_id is None or str(feature_id).strip() == "":
773
+ raise ValueError("Choose a feature id first.")
774
+ result = RUNTIME.feature_token_trace(
775
+ text=prompt,
776
+ layer=int(layer),
777
+ feature_id=int(float(feature_id)),
778
+ )
779
+ columns = ["Token position", "Token", "Activation", "Active in TopK"]
780
+ table = pd.DataFrame(result.rows, columns=columns)
781
+ chart = pd.DataFrame(result.chart_rows, columns=["Token", "Activation"])
782
+ chart["Series"] = "Feature activation"
783
+ return _trace_metrics_markdown(result), table, chart, _tsv(table)
784
+ except Exception as exc:
785
+ _raise_ui_error(exc)
786
+
787
+
788
+ @gpu(duration=30)
789
+ def run_feature_geometry(prompt: str, layer: int, token_index: int, feature_ids: list[str] | None):
790
+ try:
791
+ selected = [int(float(value)) for value in (feature_ids or [])]
792
+ result = RUNTIME.feature_geometry(
793
+ text=prompt,
794
+ layer=int(layer),
795
+ token_index=int(token_index),
796
+ feature_ids=selected,
797
+ )
798
+ columns = ["Feature A", "Feature B", "Activation A", "Activation B", "Decoder cosine"]
799
+ table = pd.DataFrame(result.rows, columns=columns)
800
+ chart = pd.DataFrame(result.chart_rows, columns=["Feature pair", "Decoder cosine"])
801
+ chart["Series"] = "Decoder cosine"
802
+ return _geometry_metrics_markdown(result), table, chart, _tsv(table)
803
+ except Exception as exc:
804
+ _raise_ui_error(exc)
805
+
806
+
807
+ @gpu(duration=40)
808
+ def run_contrastive_causal(
809
+ prompt: str,
810
+ layer: int,
811
+ token_index: int,
812
+ feature_id: str,
813
+ mode: str,
814
+ coefficient: float,
815
+ target_a: str,
816
+ target_b: str,
817
+ ):
818
+ try:
819
+ if feature_id is None or str(feature_id).strip() == "":
820
+ raise ValueError("Choose a feature id first.")
821
+ result = RUNTIME.contrastive_intervention(
822
+ text=prompt,
823
+ layer=int(layer),
824
+ token_index=int(token_index),
825
+ feature_id=int(float(feature_id)),
826
+ mode=mode,
827
+ coefficient=float(coefficient),
828
+ target_a=target_a,
829
+ target_b=target_b,
830
+ )
831
+ columns = [
832
+ "Continuation",
833
+ "Text",
834
+ "Tokens",
835
+ "Baseline sequence log p",
836
+ "SAE-edit sequence log p",
837
+ "Δ sequence log p",
838
+ "Baseline mean log p/token",
839
+ "SAE-edit mean log p/token",
840
+ "Δ mean log p/token",
841
+ ]
842
+ table = pd.DataFrame(result.rows, columns=columns)
843
+ chart = pd.DataFrame(
844
+ [
845
+ ["Baseline", result.baseline_log_odds],
846
+ ["SAE edit", result.modified_log_odds],
847
+ ],
848
+ columns=["Condition", "A−B sequence log-odds"],
849
+ )
850
+ chart["Series"] = "Contrastive preference"
851
+ return _contrastive_metrics_markdown(result), table, chart, _tsv(table)
852
+ except Exception as exc:
853
+ _raise_ui_error(exc)
854
+
855
+
856
  def mode_help(mode: str):
857
  if mode == "ablate":
858
  return gr.update(value=0.0, interactive=False, label="Coefficient (unused for ablation)")
 
867
  return gr.update(value=2.0, interactive=True, label="Shared feature multiplier")
868
 
869
 
870
+ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_width=True) as demo:
871
  gr.HTML(
872
  '<header class="hero">'
873
+ '<h1>FeatureLens <span style="font-size:.48em;opacity:.58;font-weight:400">v0.5</span></h1>'
874
  '<div class="subtitle">Causal Interpretability Workbench</div>'
875
  '<div class="metadata">Qwen3-1.7B-Base · Qwen-Scope residual SAEs · layers 4 / 14 / 26 · '
876
  '32,768 features · TopK=50 · ZeroGPU</div>'
 
912
  info="-1 = final prompt token.",
913
  )
914
  top_n = gr.Slider(5, 20, value=12, step=1, label="Displayed active features")
915
+ analyze_btn = gr.Button("Inspect sparse features", variant="primary", elem_classes=["action-btn"])
916
 
917
  gr.Markdown("#### Prompt tokens\nThe selected prompt token is outlined more strongly.")
918
  token_view = gr.HTML(
 
926
  datatype=["number", "number", "number", "str"],
927
  interactive=False,
928
  label="Strongest active SAE features",
929
+ wrap=False,
930
+ max_height=380,
931
  buttons=["fullscreen"],
932
  )
933
  feature_tsv = gr.Textbox(visible="hidden")
 
979
  step=1,
980
  label="Greedy generation length",
981
  )
982
+ intervene_btn = gr.Button("Run single-feature causal test", variant="primary", elem_classes=["action-btn"])
983
  intervention_metrics = gr.Markdown()
984
  with gr.Column(scale=3):
985
  with gr.Row():
 
989
  interactive=False,
990
  label="Next-token distribution shift",
991
  buttons=["fullscreen"],
992
+ wrap=False,
993
+ max_height=380,
994
  )
995
  token_prob_tsv = gr.Textbox(visible="hidden")
996
  token_prob_copy = _copy_button()
 
998
  interactive=False,
999
  label="Target continuation token-by-token score",
1000
  buttons=["fullscreen"],
1001
+ wrap=False,
1002
+ max_height=380,
1003
  )
1004
  target_token_tsv = gr.Textbox(visible="hidden")
1005
  target_token_copy = _copy_button()
 
1011
  "the native coefficient. The six conditions are evaluated together, and all deltas are measured "
1012
  "against the 1× row from that same batch."
1013
  )
1014
+ dose_btn = gr.Button("Run scale dose-response", variant="primary", elem_classes=["action-btn"])
1015
  dose_metrics = gr.Markdown()
1016
  with gr.Row(equal_height=False):
1017
  with gr.Column(scale=3):
 
1019
  interactive=False,
1020
  label="Scale dose-response measurements",
1021
  buttons=["fullscreen"],
1022
+ wrap=False,
1023
+ max_height=380,
1024
  )
1025
  dose_tsv = gr.Textbox(visible="hidden")
1026
  dose_copy = _copy_button()
 
1036
  height=330,
1037
  )
1038
 
1039
+ gr.HTML('<div class="section-rule">IV. Contrastive continuation preference</div>')
1040
+ with gr.Accordion("Contrastive causal preference test", open=False):
1041
+ gr.Markdown(
1042
+ "Absolute target probability can move because an intervention broadly perturbs the distribution. "
1043
+ "This test asks the stricter question: **does the edit shift preference between two exact continuations?** "
1044
+ "Both continuations are teacher-forced and compared with the same 8-direction norm-matched random ensemble."
1045
+ )
1046
+ with gr.Row(equal_height=True):
1047
+ contrastive_a = gr.Textbox(label="Continuation A (preferred)", value="2x", scale=2)
1048
+ contrastive_b = gr.Textbox(label="Continuation B (comparison)", value="x", scale=2)
1049
+ with gr.Row(equal_height=True):
1050
+ contrastive_mode = gr.Dropdown(
1051
+ choices=["ablate", "scale", "inject"],
1052
+ value="ablate",
1053
+ label="Contrastive intervention",
1054
+ scale=1,
1055
+ )
1056
+ contrastive_coefficient = gr.Number(
1057
+ value=0.0,
1058
+ interactive=False,
1059
+ label="Coefficient (unused for ablation)",
1060
+ scale=1,
1061
+ )
1062
+ contrastive_btn = gr.Button(
1063
+ "Run contrastive preference test", variant="primary", elem_classes=["action-btn"]
1064
+ )
1065
+ contrastive_metrics = gr.Markdown()
1066
+ with gr.Row(equal_height=False):
1067
+ with gr.Column(scale=3):
1068
+ contrastive_table = gr.Dataframe(
1069
+ interactive=False,
1070
+ label="Contrastive continuation scores",
1071
+ buttons=["fullscreen"],
1072
+ wrap=False,
1073
+ max_height=320,
1074
+ )
1075
+ contrastive_tsv = gr.Textbox(visible="hidden")
1076
+ contrastive_copy = _copy_button()
1077
+ with gr.Column(scale=2):
1078
+ contrastive_plot = gr.BarPlot(
1079
+ x="Condition",
1080
+ y="A−B sequence log-odds",
1081
+ color="Series",
1082
+ color_map={"Contrastive preference": INK_TEAL},
1083
+ title="Preference between exact continuations",
1084
+ x_title="Execution condition",
1085
+ y_title="Sequence log-odds A−B",
1086
+ height=320,
1087
+ )
1088
+
1089
  with gr.Tab("Feature sets"):
1090
  gr.Markdown(
1091
  "### Distributed sparse representations\n"
 
1127
  lines=1,
1128
  scale=2,
1129
  )
1130
+ set_btn = gr.Button("Run joint feature-set causal test", variant="primary", elem_classes=["action-btn"])
1131
  set_metrics = gr.Markdown()
1132
  with gr.Row(equal_height=False):
1133
  with gr.Column(scale=2):
 
1135
  interactive=False,
1136
  label="Joint intervention features",
1137
  buttons=["fullscreen"],
1138
+ wrap=False,
1139
+ max_height=380,
1140
  )
1141
  set_feature_tsv = gr.Textbox(visible="hidden")
1142
  set_feature_copy = _copy_button()
 
1145
  interactive=False,
1146
  label="Target continuation token-by-token score",
1147
  buttons=["fullscreen"],
1148
+ wrap=False,
1149
+ max_height=380,
1150
  )
1151
  set_target_tsv = gr.Textbox(visible="hidden")
1152
  set_target_copy = _copy_button()
 
1157
  "norm-matched random directions, all sharing one batched no-edit baseline."
1158
  )
1159
  set_sweep_target = gr.Textbox(label="Target continuation for set-size sweep", value="2x")
1160
+ set_sweep_btn = gr.Button("Run 1/3/5-feature ablation sweep", variant="primary", elem_classes=["action-btn"])
1161
  set_sweep_note = gr.Markdown()
1162
  with gr.Row(equal_height=False):
1163
  with gr.Column(scale=3):
 
1165
  interactive=False,
1166
  label="Feature-set size measurements",
1167
  buttons=["fullscreen"],
1168
+ wrap=False,
1169
+ max_height=380,
1170
  )
1171
  set_sweep_tsv = gr.Textbox(visible="hidden")
1172
  set_sweep_copy = _copy_button()
 
1192
  "non-additivity without claiming that the features form a direct circuit."
1193
  )
1194
  interaction_target = gr.Textbox(label="Target continuation for interaction test", value="2x")
1195
+ interaction_btn = gr.Button("Run individual-vs-joint decomposition", variant="primary", elem_classes=["action-btn"])
1196
  interaction_metrics = gr.Markdown()
1197
  with gr.Row(equal_height=False):
1198
  with gr.Column(scale=3):
 
1200
  interactive=False,
1201
  label="Individual and joint ablation measurements",
1202
  buttons=["fullscreen"],
1203
+ wrap=False,
1204
+ max_height=380,
1205
  )
1206
  interaction_tsv = gr.Textbox(visible="hidden")
1207
  interaction_copy = _copy_button()
 
1218
  height=330,
1219
  )
1220
 
1221
+
1222
+ gr.HTML('<div class="section-rule">Decoder geometry</div>')
1223
+ with gr.Accordion("Feature-set decoder geometry", open=False):
1224
+ gr.Markdown(
1225
+ "Joint causal effects can reflect both **decoder-direction geometry** and downstream non-linearity. "
1226
+ "This zero-extra-generation diagnostic measures pairwise decoder cosines and compares the actual "
1227
+ "activation-weighted joint-ablation norm with the norm expected for independent directions."
1228
+ )
1229
+ geometry_btn = gr.Button(
1230
+ "Inspect selected-feature geometry", variant="primary", elem_classes=["action-btn"]
1231
+ )
1232
+ geometry_metrics = gr.Markdown()
1233
+ with gr.Row(equal_height=False):
1234
+ with gr.Column(scale=3):
1235
+ geometry_table = gr.Dataframe(
1236
+ interactive=False,
1237
+ label="Pairwise decoder geometry",
1238
+ buttons=["fullscreen"],
1239
+ wrap=False,
1240
+ max_height=340,
1241
+ )
1242
+ geometry_tsv = gr.Textbox(visible="hidden")
1243
+ geometry_copy = _copy_button()
1244
+ with gr.Column(scale=2):
1245
+ geometry_plot = gr.BarPlot(
1246
+ x="Feature pair",
1247
+ y="Decoder cosine",
1248
+ color="Series",
1249
+ color_map={"Decoder cosine": INK_PLUM},
1250
+ title="Pairwise SAE decoder cosine",
1251
+ x_title="Feature pair",
1252
+ y_title="Cosine similarity",
1253
+ x_label_angle=-30,
1254
+ height=320,
1255
+ )
1256
+
1257
  with gr.Tab("Feature evidence"):
1258
  gr.Markdown(
1259
+ "### Feature activation evidence\n"
1260
+ "Inspect **where a feature fires inside the current prompt**, then test whether it activates preferentially "
1261
+ "across FeatureLens's seven controlled concept groups. The concept scan uses the feature's **maximum "
1262
+ "activation across non-padding tokens in each prompt**, avoiding arbitrary final-token comparisons."
1263
  )
1264
  contrast_location = gr.Markdown("No Workbench inspection has been run yet.")
1265
  with gr.Row(equal_height=True):
 
1283
  label="Prompts per concept",
1284
  scale=2,
1285
  )
1286
+
1287
+ gr.Markdown("#### Activation trace across the current Workbench prompt")
1288
+ gr.Markdown(
1289
+ "Uses the prompt currently entered in **Workbench** and shows exactly which prompt tokens include the "
1290
+ "selected feature in the SAE TopK support."
1291
+ )
1292
+ trace_btn = gr.Button("Trace feature across prompt tokens", variant="primary", elem_classes=["action-btn"])
1293
+ trace_metrics = gr.Markdown()
1294
+ with gr.Row(equal_height=False):
1295
+ with gr.Column(scale=3):
1296
+ trace_table = gr.Dataframe(
1297
+ interactive=False,
1298
+ label="Feature activation by prompt token",
1299
+ buttons=["fullscreen"],
1300
+ wrap=False,
1301
+ max_height=340,
1302
+ )
1303
+ trace_tsv = gr.Textbox(visible="hidden")
1304
+ trace_copy = _copy_button()
1305
+ with gr.Column(scale=2):
1306
+ trace_plot = gr.BarPlot(
1307
+ x="Token",
1308
+ y="Activation",
1309
+ color="Series",
1310
+ color_map={"Feature activation": INK_TEAL},
1311
+ title="Feature activation across prompt tokens",
1312
+ x_title="Prompt token",
1313
+ y_title="Activation",
1314
+ x_label_angle=-35,
1315
+ height=320,
1316
+ )
1317
+
1318
+ gr.Markdown("#### Prompt-wide controlled concept contrast")
1319
+ contrast_btn = gr.Button("Run controlled concept contrast", variant="primary", elem_classes=["action-btn"])
1320
  contrast_metrics = gr.Markdown()
1321
  with gr.Row(equal_height=False):
1322
  with gr.Column(scale=3):
 
1324
  interactive=False,
1325
  label="Feature activation by controlled concept",
1326
  buttons=["fullscreen"],
1327
+ wrap=False,
1328
+ max_height=380,
1329
  )
1330
  contrast_tsv = gr.Textbox(visible="hidden")
1331
  contrast_copy = _copy_button()
1332
  with gr.Column(scale=2):
1333
  contrast_plot = gr.BarPlot(
1334
  x="Concept",
1335
+ y="Mean prompt-wide max",
1336
  color="Series",
1337
+ color_map={"Prompt-wide max": INK_BLUEGREY},
1338
+ title="Prompt-wide controlled concept contrast",
1339
  x_title="Concept",
1340
+ y_title="Mean max activation",
1341
  x_label_angle=-25,
1342
  height=330,
1343
  )
 
1346
  gr.Markdown(
1347
  "### Local and prompt-wide robustness\n"
1348
  "The selected-token comparison is strict but can be misleading if the two chosen tokens play different "
1349
+ "semantic roles. v0.5 reports a **prompt-wide max-pooled feature profile**: for every SAE "
1350
  "feature, take its maximum activation across all prompt tokens, then compare the two sparse profiles."
1351
  )
1352
  with gr.Row():
 
1369
  para_idx_a = gr.Number(value=-1, precision=0, label="Original prompt token index")
1370
  para_idx_b = gr.Number(value=-1, precision=0, label="Paraphrase token index")
1371
  para_top_n = gr.Slider(5, 20, value=12, step=1, label="Displayed active features")
1372
+ para_btn = gr.Button("Compare paraphrase representations", variant="primary", elem_classes=["action-btn"])
1373
  with gr.Row():
1374
  with gr.Column():
1375
  gr.Markdown("#### Original prompt tokens")
 
1384
  interactive=False,
1385
  label="Top-feature overlap at selected tokens",
1386
  buttons=["fullscreen"],
1387
+ wrap=False,
1388
+ max_height=380,
1389
  )
1390
  para_tsv = gr.Textbox(visible="hidden")
1391
  para_copy = _copy_button()
 
1421
  info="-1 = final token",
1422
  scale=1,
1423
  )
1424
+ trajectory_btn = gr.Button("Compare layers", variant="primary", elem_classes=["action-btn"])
1425
  gr.Markdown("#### Prompt tokens")
1426
  trajectory_tokens = gr.HTML()
1427
  with gr.Row(equal_height=False):
 
1430
  interactive=False,
1431
  label="Layer diagnostics",
1432
  buttons=["fullscreen"],
1433
+ wrap=False,
1434
+ max_height=380,
1435
  )
1436
  trajectory_tsv = gr.Textbox(visible="hidden")
1437
  trajectory_copy = _copy_button()
 
1456
  gr.Markdown(
1457
  "The offline pipeline remains the source of held-out feature/concept AUROC + F1, reconstruction "
1458
  "quality, paraphrase stability, dense residual linear probes, and causal intervention conclusions. "
1459
+ "v0.5 strengthens the live workbench with batch-context null references and random-control ensembles; "
1460
  "the saved report should be generated only from real experiment artifacts."
1461
  )
1462
 
 
1477
 
1478
  FeatureLens patches the delta into the **original residual**; it never replaces the residual with the complete SAE reconstruction.
1479
 
1480
+ ### v0.5 control discipline
1481
 
1482
  Batched experiments include an explicit **zero-edit row**. Causal effects are measured against that row rather than a separately executed baseline, which removes batch-vs-single floating-point drift from the measured effect. Random specificity uses an ensemble of norm-matched residual directions rather than one arbitrary seed.
1483
 
 
1485
 
1486
  1. SAE reconstruction quality.
1487
  2. Held-out feature/concept prediction.
1488
+ 3. Token-local activation traces and prompt-wide feature evidence.
1489
+ 4. Local and prompt-wide paraphrase robustness.
1490
  5. Single-feature causal intervention and dose-response.
1491
+ 6. Contrastive continuation preference under intervention.
1492
+ 7. Joint feature-set intervention and set-size sensitivity.
1493
+ 8. Decoder-direction geometry and individual-vs-joint non-additivity.
1494
+ 9. Specificity relative to norm-matched random controls.
1495
 
1496
+ Association, robustness, geometry, and intervention evidence remain separate claims.
1497
  """
1498
  )
1499
 
 
1503
  'End of workbench.</footer>'
1504
  )
1505
 
1506
+ demo.load(fn=None, js=INSTALL_REFLOW_JS, queue=False)
1507
+
1508
  # Event wiring.
1509
  analyze_btn.click(
1510
  analyze_prompt,
 
1542
  inputs=[prompt, layer, token_index, feature_id, target_text],
1543
  outputs=[dose_table, dose_plot, dose_metrics, dose_tsv],
1544
  )
1545
+ contrastive_mode.change(mode_help, inputs=[contrastive_mode], outputs=[contrastive_coefficient])
1546
+ contrastive_btn.click(
1547
+ run_contrastive_causal,
1548
+ inputs=[prompt, layer, token_index, feature_id, contrastive_mode, contrastive_coefficient, contrastive_a, contrastive_b],
1549
+ outputs=[contrastive_metrics, contrastive_table, contrastive_plot, contrastive_tsv],
1550
+ )
1551
  set_mode.change(set_mode_help, inputs=[set_mode], outputs=[set_coefficient])
1552
  set_btn.click(
1553
  run_feature_set,
 
1564
  inputs=[prompt, layer, token_index, feature_set_ids, interaction_target],
1565
  outputs=[interaction_table, interaction_metrics, interaction_plot, interaction_tsv],
1566
  )
1567
+ geometry_btn.click(
1568
+ run_feature_geometry,
1569
+ inputs=[prompt, layer, token_index, feature_set_ids],
1570
+ outputs=[geometry_metrics, geometry_table, geometry_plot, geometry_tsv],
1571
+ )
1572
+ trace_btn.click(
1573
+ run_feature_trace,
1574
+ inputs=[prompt, contrast_layer, contrast_feature_id],
1575
+ outputs=[trace_metrics, trace_table, trace_plot, trace_tsv],
1576
+ )
1577
  contrast_btn.click(
1578
  run_concept_contrast,
1579
  inputs=[contrast_feature_id, contrast_layer, contrast_n],
 
1595
  (token_prob_copy, token_prob_tsv),
1596
  (target_token_copy, target_token_tsv),
1597
  (dose_copy, dose_tsv),
1598
+ (contrastive_copy, contrastive_tsv),
1599
  (set_feature_copy, set_feature_tsv),
1600
  (set_target_copy, set_target_tsv),
1601
  (set_sweep_copy, set_sweep_tsv),
1602
  (interaction_copy, interaction_tsv),
1603
+ (geometry_copy, geometry_tsv),
1604
+ (trace_copy, trace_tsv),
1605
  (contrast_copy, contrast_tsv),
1606
  (para_copy, para_tsv),
1607
  (trajectory_copy, trajectory_tsv),
docs/HF_DEPLOY.md CHANGED
@@ -15,14 +15,17 @@ The live app does not need every SAE layer from the full repository.
15
 
16
  ## GPU-decorated actions
17
 
18
- Current v0.4 live actions include:
19
 
20
  - **Inspect sparse features**;
21
  - **Run single-feature causal test**;
22
  - **Run scale dose-response**;
 
23
  - **Run joint feature-set causal test**;
24
  - **Run 1/3/5-feature ablation sweep**;
25
  - **Run individual-vs-joint decomposition**;
 
 
26
  - **Run controlled concept contrast**;
27
  - **Compare paraphrase representations**;
28
  - **Compare layers**.
@@ -31,7 +34,7 @@ Allocation durations in `app.py` are ceilings requested from ZeroGPU, not expect
31
 
32
  ## Batch-first causal execution
33
 
34
- v0.4 deliberately batches related conditions so stronger diagnostics do not require a separate GPU callback for every condition.
35
 
36
  Examples:
37
 
@@ -39,7 +42,8 @@ Examples:
39
  - single-feature causal tests stack zero edit, one targeted SAE edit, and eight norm-matched random controls;
40
  - 1/3/5 feature-set sensitivity batches targeted edits and control ensembles;
41
  - individual-vs-joint decomposition batches all individual ablations plus the joint ablation;
42
- - controlled concept contrast evaluates its balanced prompt batch together.
 
43
 
44
  The primary causal reference inside each experiment is a **batched zero-edit row**. This prevents batch-vs-single floating-point drift from being mistaken for an intervention effect.
45
 
@@ -55,18 +59,21 @@ The heavier feature-set, set-size, interaction, and contrast panels avoid unnece
55
 
56
  Major output tables include a dedicated **Copy table with headers** action. The app serializes the result as tab-separated text before invoking the browser clipboard API. This makes pasted output self-describing and spreadsheet-friendly.
57
 
58
- If browser clipboard permission is unavailable, the frontend uses a temporary-textarea fallback.
59
 
60
  ## Embedded-Space layout
61
 
62
  The app uses:
63
 
64
- - restrained serif typography;
65
- - small corner radii rather than pill-heavy controls;
66
- - explicit bottom padding on the Gradio container and tab contents;
67
- - a visible footer with `End of workbench.`.
 
 
 
68
 
69
- These changes prevent app-owned content from ending abruptly. The outer Hugging Face embedding frame may still resize asynchronously; that behavior is outside the Python layout itself.
70
 
71
  ## Offline benchmark
72
 
@@ -86,3 +93,13 @@ python3 -m experiments.run_feature_sets --random-controls 16
86
  ```
87
 
88
  Then commit only the small report/catalog/CSV/figure artifacts intended for presentation. Large activation arrays remain gitignored.
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  ## GPU-decorated actions
17
 
18
+ Current v0.5 live actions include:
19
 
20
  - **Inspect sparse features**;
21
  - **Run single-feature causal test**;
22
  - **Run scale dose-response**;
23
+ - **Run contrastive preference test**;
24
  - **Run joint feature-set causal test**;
25
  - **Run 1/3/5-feature ablation sweep**;
26
  - **Run individual-vs-joint decomposition**;
27
+ - **Inspect selected-feature geometry**;
28
+ - **Trace feature across prompt tokens**;
29
  - **Run controlled concept contrast**;
30
  - **Compare paraphrase representations**;
31
  - **Compare layers**.
 
34
 
35
  ## Batch-first causal execution
36
 
37
+ FeatureLens deliberately batches related conditions so stronger diagnostics do not require a separate GPU callback for every condition.
38
 
39
  Examples:
40
 
 
42
  - single-feature causal tests stack zero edit, one targeted SAE edit, and eight norm-matched random controls;
43
  - 1/3/5 feature-set sensitivity batches targeted edits and control ensembles;
44
  - individual-vs-joint decomposition batches all individual ablations plus the joint ablation;
45
+ - controlled concept contrast evaluates its balanced prompt batch together;
46
+ - contrastive preference reuses one targeted delta/control ensemble while scoring the two exact continuations in two compact batched forwards.
47
 
48
  The primary causal reference inside each experiment is a **batched zero-edit row**. This prevents batch-vs-single floating-point drift from being mistaken for an intervention effect.
49
 
 
59
 
60
  Major output tables include a dedicated **Copy table with headers** action. The app serializes the result as tab-separated text before invoking the browser clipboard API. This makes pasted output self-describing and spreadsheet-friendly.
61
 
62
+ On successful clipboard write, the clicked button briefly changes to **✓ Copied with headers**. If browser clipboard permission is unavailable, the frontend uses a temporary-textarea fallback.
63
 
64
  ## Embedded-Space layout
65
 
66
  The app uses:
67
 
68
+ - `gr.Blocks(fill_width=True)`;
69
+ - an explicitly centered desktop canvas up to 1600 px wide;
70
+ - restrained serif typography with normalized control/table sizes;
71
+ - consistent muted-teal action and copy buttons;
72
+ - bounded result-table heights;
73
+ - explicit bottom padding and a visible `End of workbench.` footer;
74
+ - a browser-side ResizeObserver/MutationObserver that requests a resize reflow after dynamic result-height changes.
75
 
76
+ These changes reduce wasted horizontal space and mitigate the embedded-Space case where the outer page stopped extending after a large dynamic result. Hugging Face still owns the outer embedding frame, so compare with the direct `*.hf.space` URL if the parent page ever behaves differently.
77
 
78
  ## Offline benchmark
79
 
 
93
  ```
94
 
95
  Then commit only the small report/catalog/CSV/figure artifacts intended for presentation. Large activation arrays remain gitignored.
96
+
97
+ ## Local UI launch smoke
98
+
99
+ Before pushing a release, run:
100
+
101
+ ```bash
102
+ python3 scripts/ui_smoke.py
103
+ ```
104
+
105
+ This opens the real Gradio `launch()` path on a temporary localhost port and immediately closes it. It exists specifically so theme/launch integration errors are caught before Hugging Face rebuilds the Space.
docs/METHODOLOGY.md CHANGED
@@ -116,7 +116,7 @@ Next-token Jensen-Shannon divergence and greedy generation remain complementary
116
 
117
  v0.3 exposed an important numerical issue: batched and separately executed model forwards can differ slightly even when the intended residual edit is zero. A nominal `1×` dose-response condition therefore showed a small non-zero effect when compared with a separately executed baseline.
118
 
119
- v0.4 treats this as instrumentation drift, not causal evidence.
120
 
121
  Every batched causal experiment now includes an explicit zero-edit condition:
122
 
@@ -250,7 +250,7 @@ The strict selected-token comparison measures:
250
 
251
  This can be misleading when the two manually selected tokens play different roles. For example, comparing the final token `is` in one prompt with a final punctuation token in its paraphrase is not a clean semantic-anchor comparison.
252
 
253
- v0.4 therefore adds a prompt-wide profile. For each SAE feature, FeatureLens takes its maximum activation across all prompt tokens:
254
 
255
  ```text
256
  profile_i(prompt) = max_t z_{t,i}
@@ -262,23 +262,75 @@ The selected-token and prompt-wide metrics answer different questions and are in
262
 
263
  ---
264
 
265
- ## Controlled concept contrast scan
266
 
267
- The live **Feature evidence** tab provides an exploratory contrast for one selected feature.
268
 
269
- It samples a small balanced batch from the same seven controlled concept groups, using only one wording from each paraphrase pair, and measures feature activation at the final prompt token.
 
 
 
 
 
 
 
 
 
 
 
 
270
 
271
  For each concept it reports:
272
 
273
- - mean activation;
274
- - median activation;
275
- - activation rate;
 
276
  - maximum activation.
277
 
 
 
278
  This is a **diagnostic**, not the offline feature-labeling procedure. The live scan never modifies `Offline concept hint` and does not claim semantic identity from a handful of prompts.
279
 
280
  ---
281
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  ## Statistical interpretation in the offline report
283
 
284
  The generated offline report uses paired targeted-vs-control comparisons.
 
116
 
117
  v0.3 exposed an important numerical issue: batched and separately executed model forwards can differ slightly even when the intended residual edit is zero. A nominal `1×` dose-response condition therefore showed a small non-zero effect when compared with a separately executed baseline.
118
 
119
+ FeatureLens treats this as instrumentation drift, not causal evidence.
120
 
121
  Every batched causal experiment now includes an explicit zero-edit condition:
122
 
 
250
 
251
  This can be misleading when the two manually selected tokens play different roles. For example, comparing the final token `is` in one prompt with a final punctuation token in its paraphrase is not a clean semantic-anchor comparison.
252
 
253
+ FeatureLens therefore also reports a prompt-wide profile. For each SAE feature, FeatureLens takes its maximum activation across all prompt tokens:
254
 
255
  ```text
256
  profile_i(prompt) = max_t z_{t,i}
 
262
 
263
  ---
264
 
265
+ ## Feature-token activation trace
266
 
267
+ For a selected SAE feature and layer, v0.5 can encode every prompt-token residual and report the feature's TopK activation token by token. This answers a basic localization question that a single selected-token view cannot: is the feature concentrated at one syntactic/semantic position, or does it recur across the prompt?
268
 
269
+ The trace reports activation at each token, active-token count, and the peak token. A zero entry means that feature is not present in that token's TopK SAE support.
270
+
271
+ ---
272
+
273
+ ## Prompt-wide controlled concept contrast scan
274
+
275
+ The live **Feature evidence** tab also provides an exploratory concept contrast for one selected feature.
276
+
277
+ It samples a small balanced batch from the same seven controlled concept groups, using only one wording from each paraphrase pair. Earlier versions sampled only each prompt's final token, which can miss a feature that is active elsewhere in the prompt. v0.5 instead defines each prompt-level feature score as:
278
+
279
+ ```text
280
+ score(feature, prompt) = max over non-padding prompt tokens of z_feature
281
+ ```
282
 
283
  For each concept it reports:
284
 
285
+ - mean prompt-wide maximum activation;
286
+ - median prompt-wide maximum activation;
287
+ - prompt activation rate;
288
+ - mean activation when active;
289
  - maximum activation.
290
 
291
+ If the feature is inactive in every sampled prompt, FeatureLens reports that state explicitly and does not assign a leading concept.
292
+
293
  This is a **diagnostic**, not the offline feature-labeling procedure. The live scan never modifies `Offline concept hint` and does not claim semantic identity from a handful of prompts.
294
 
295
  ---
296
 
297
+ ## Contrastive continuation preference
298
+
299
+ Absolute probability change for one target can be difficult to interpret when an SAE edit broadly perturbs the output distribution. v0.5 therefore adds a contrastive causal test for two exact continuations, A and B.
300
+
301
+ For each continuation, FeatureLens performs full teacher-forced sequence scoring under the same zero-edit reference, targeted SAE edit, and norm-matched random-control ensemble. It then computes:
302
+
303
+ ```text
304
+ preference = log P(A) - log P(B)
305
+ causal preference shift = preference_edited - preference_reference
306
+ ```
307
+
308
+ The exact-sequence quantity is a true log-odds comparison between those two specified continuations. The UI additionally reports a token-normalized preference difference for interpretability when the continuations have different lengths.
309
+
310
+ A large next-token JS effect but a weak contrastive preference shift is evidence for **general distributional influence** more than selective control of the A-vs-B behavioral choice.
311
+
312
+ ---
313
+
314
+ ## Feature-set decoder geometry
315
+
316
+ Joint interventions can be affected by the geometry of SAE decoder directions before any downstream non-linearity is considered. For 2–8 selected same-layer features, v0.5 reports pairwise decoder cosine similarities.
317
+
318
+ It also forms each feature's activation-weighted ablation delta and compares:
319
+
320
+ ```text
321
+ ||sum_i delta_i||_2
322
+ ```
323
+
324
+ with the independent-direction reference:
325
+
326
+ ```text
327
+ sqrt(sum_i ||delta_i||_2^2)
328
+ ```
329
+
330
+ Their ratio is 1 for an orthogonal/independent norm geometry, below 1 under net cancellation, and above 1 under net alignment. This diagnostic helps interpret joint-ablation results but does not establish downstream causal interaction by itself.
331
+
332
+ ---
333
+
334
  ## Statistical interpretation in the offline report
335
 
336
  The generated offline report uses paired targeted-vs-control comparisons.
docs/VALIDATION.md CHANGED
@@ -1,655 +1,308 @@
1
- # FeatureLens v0.4 validation guide
2
 
3
- This guide intentionally uses the **exact labels visible in the v0.4 Gradio UI**. Follow it in order after every Hugging Face deployment that changes runtime or UI code.
4
-
5
- The tests are divided into four categories:
6
-
7
- 1. local software correctness;
8
- 2. Hugging Face / ZeroGPU deployment correctness;
9
- 3. causal-instrumentation sanity checks;
10
- 4. edge/adversarial behavior.
11
-
12
- A successful live prompt is a software sanity check, **not** a scientific conclusion. Scientific claims should come from the saved offline benchmark artifacts and their paired controls.
13
-
14
- ---
15
 
16
  ## A. Local release gate
17
 
18
- From the repository root run:
19
 
20
  ```bash
21
  python3 -m pytest -q
22
  python3 -m compileall -q app.py featurelens experiments scripts
 
23
  python3 scripts/release_check.py
24
  ```
25
 
26
  Expected:
27
 
28
  ```text
29
- 29 passed
 
30
  FeatureLens release check: PASS
31
  discovery prompts: 224
32
  causal tasks: 28
33
  layers: [4, 14, 26]
34
  feature-set sizes: [1, 3, 5]
35
  random controls: 8
36
- release: v0.4.0
37
  ```
38
 
39
- `compileall` should exit silently with status 0.
40
-
41
- Optional lint gate:
42
-
43
- ```bash
44
- python3 -m ruff check app.py featurelens experiments tests scripts
45
- ```
46
 
47
  ---
48
 
49
  ## B. Hugging Face startup and layout
50
 
51
- ### Test B1 container startup
52
 
53
- After a fresh Space rebuild, open the container logs.
54
-
55
- Expected launch line:
56
 
57
  ```text
58
  * Running on local URL: http://0.0.0.0:7860
59
  ```
60
 
61
- The earlier deployment warning should not return:
62
 
63
- ```text
64
- coroutine 'App.create_app.<locals>.get_current_user' was never awaited
65
- ```
66
 
67
- The launch output should not contain `with SSR ⚡` because FeatureLens launches with `ssr_mode=False`.
68
 
69
- **Pass:** the Space reaches `Running`, the UI loads, and no repeated startup exception appears.
 
 
 
 
70
 
71
- ### Test B2 page bottom / embedded Space behavior
72
-
73
- Open **Workbench**, scroll to the bottom of the full application, and then switch through **Feature sets**, **Feature evidence**, **Paraphrase robustness**, and **Layer trajectory**.
74
-
75
- Verify:
76
-
77
- - descenders such as `g`, `p`, `q`, and `y` are not clipped inside app content;
78
- - the last interactive element is followed by visible breathing room;
79
- - the footer containing `End of workbench.` can be reached;
80
- - labels do not overlap controls after a tab switch.
81
-
82
- Also resize the browser to roughly half desktop width.
83
-
84
- **Pass:** form rows wrap rather than overlap, controls remain readable, and the footer is reachable. If the outer Hugging Face iframe itself temporarily refuses to resize and then corrects itself after loading, record that separately from an app-level clipping failure.
85
 
86
  ---
87
 
88
- ## C. Workbench inspection
89
-
90
- ### Test C1 — inspect a mathematics prompt
91
 
92
- Open **Workbench** and set:
93
 
94
- - **Prompt**: `The derivative of x squared is`
95
- - **Residual layer**: `14`
96
- - **Prompt token index**: `-1`
97
- - **Displayed active features**: `12`
 
 
98
 
99
  Click **Inspect sparse features**.
100
 
101
- Verify:
102
-
103
- 1. **Prompt tokens** appears and one token has the stronger selected outline.
104
- 2. **Analysis metrics** reports:
105
- - `Layer 14`;
106
- - the resolved prompt token index;
107
- - active SAE features;
108
- - reconstruction cosine;
109
- - NMSE;
110
- - Top-5 activation mass.
111
- 3. **Strongest active SAE features** contains 12 rows unless fewer are available.
112
- 4. **Activation profile** contains the same displayed feature IDs.
113
- 5. **Single feature id** is populated.
114
- 6. Open **Feature sets** and verify **Feature set** is populated with active feature IDs.
115
- 7. Open **Feature evidence** and verify **Feature id** is populated.
116
-
117
- Basic bounds:
118
-
119
- - reconstruction cosine is finite and lies in `[-1, 1]`;
120
- - NMSE is finite and non-negative;
121
- - feature IDs lie in `[0, 32767]`;
122
- - displayed TopK activations are non-negative.
123
-
124
- ### Test C2 — copy feature table with headers
125
-
126
- Under **Strongest active SAE features**, click **Copy table with headers** and paste into a plain-text editor.
127
-
128
- The **first pasted line must contain the column names**, tab-separated, for example:
129
 
130
  ```text
131
- Rank Feature ID Activation Offline concept hint
 
 
 
 
 
132
  ```
133
 
134
- The following lines must contain the table rows.
135
-
136
- Repeat by pasting into a spreadsheet. Columns should split naturally on tabs.
137
-
138
- ---
139
-
140
- ## D. Single-feature causal test
141
-
142
- Continue from Test C1 and choose the strongest value in **Single feature id**.
143
-
144
- ### Test D1 — active-feature ablation with a multi-token target
145
-
146
- Set:
147
-
148
- - **Single-feature intervention**: `ablate`
149
- - **Target continuation (optional)**: `2x`
150
- - **Greedy generation length**: `8`
151
 
152
- Click **Run single-feature causal test**.
153
-
154
- Verify:
155
-
156
- 1. **Baseline greedy generation** and **SAE-edited greedy generation** are populated.
157
- 2. **Next-token distribution shift** is populated.
158
- 3. **Target continuation token-by-token score** has one row for every token used to encode `2x`.
159
- 4. The metrics report:
160
- - original feature activation;
161
- - Δ coefficient;
162
- - perturbation L2;
163
- - next-token JS;
164
- - random-ensemble mean JS and standard deviation;
165
- - number of random controls (`8`);
166
- - JS magnitude ratio and empirical tail probability;
167
- - execution-context null drift;
168
- - full target token count;
169
- - sequence and mean-per-token target effects;
170
- - random-ensemble target effect statistics.
171
- 5. For an active feature under ablation, `Δ coefficient ≈ -original feature activation`.
172
 
173
- The two greedy generations are **allowed to be identical**. Probability/log-probability metrics are more sensitive than deterministic argmax text.
174
 
175
- ### Test D2 — full-continuation table copy
176
 
177
- Click **Copy table with headers** under **Target continuation token-by-token score** and paste into a text editor.
 
 
 
 
 
 
 
 
178
 
179
- The first line must contain all target-score headers, followed by one line per target token.
180
 
181
- ### Test D3 scale
182
 
183
- Set:
184
 
185
- - **Single-feature intervention**: `scale`
186
- - **Feature multiplier**: `2`
187
- - **Target continuation (optional)**: `2x`
 
 
188
 
189
  Click **Run single-feature causal test**.
190
 
191
- For an active feature, expect coefficient ≈ +original feature activation`.
192
 
193
- ### Test D4 inject
194
 
195
- Set:
 
 
196
 
197
- - **Single-feature intervention**: `inject`
198
- - **Additive feature coefficient**: `5`
199
- - **Target continuation (optional)**: `2x`
200
 
201
- Click **Run single-feature causal test**.
202
-
203
- Expected:
204
-
205
- - `Δ coefficient = +5` regardless of native activation;
206
- - perturbation L2 is finite and non-zero;
207
- - the random ensemble remains norm-matched to the resulting residual perturbation.
208
 
209
  ---
210
 
211
- ## E. Numerical-null / scale dose-response test
212
-
213
- This is the **critical v0.4 regression test** for the small non-zero `1×` drift observed in v0.3.
214
 
215
- Keep the Workbench prompt/location and selected active feature from Test C1. Keep **Target continuation (optional)** set to `2x`.
216
 
217
- Open **Scale dose-response** and click **Run scale dose-response**.
218
-
219
- Expected rows in **Scale dose-response measurements**:
220
 
221
  ```text
222
- 0
223
- 0.5
224
- 1
225
- 1.5
226
- 2
227
- 3
228
  ```
229
 
230
- The experiment is always a scale sweep:
231
 
232
- - `0×` = ablation;
233
- - `0.5×` = half native activation;
234
- - `1×` = batched no-edit reference;
235
- - `1.5×` = 1.5 times native activation;
236
- - `2×` = double;
237
- - `3×` = triple.
238
 
239
- ### Required `1×` row
240
 
241
- The `1×` row must report, apart from harmless display formatting such as `-0.0`:
242
 
243
  ```text
244
- Δ feature coefficient = 0
245
- Perturbation L2 = 0
246
- Δ mean log p/token = 0
247
- Δ sequence log p = 0
248
- Next-token JS = 0
249
  ```
250
 
251
- Do **not** accept the old v0.3 behavior where the row had a small non-zero causal delta. Any separately executed single-vs-batch numerical discrepancy should now appear only as an **execution-context null drift** diagnostic and must not be counted as the intervention effect.
252
-
253
- The curve itself does not need to be monotonic.
254
-
255
- ---
256
-
257
- ## F. Feature-set causal tests
258
-
259
- Run Test C1 first so **Feature set** is populated.
260
-
261
- ### Test F1 — top-three joint ablation
262
-
263
- Open **Feature sets**. Select exactly the three strongest active values in **Feature set**.
264
-
265
- Set:
266
-
267
- - **Intervention**: `ablate`
268
- - **Target continuation**: `2x`
269
-
270
- Click **Run joint feature-set causal test**.
271
 
272
- Verify:
273
 
274
- - **Joint intervention features** has exactly three rows;
275
- - every active feature has `Δ coefficient = -original activation`;
276
- - the total joint perturbation L2 is finite;
277
- - the target continuation contains all target tokens;
278
- - random-control count is `8`;
279
- - random signed mean, random mean absolute effect, random standard deviation, magnitude ratio, and empirical tail probability are shown;
280
- - execution-context null drift is shown separately from causal effect.
281
 
282
- Do not require the targeted effect to exceed the random ensemble on every prompt. This is an empirical question.
 
 
 
 
 
 
283
 
284
- ### Test F2 joint scale
285
 
286
- Set:
287
-
288
- - **Intervention**: `scale`
289
- - **Shared feature multiplier**: `2`
290
-
291
- Run **Run joint feature-set causal test** again.
292
-
293
- Expected: each selected active feature receives its own positive native-activation delta before decoder-direction deltas are summed.
294
-
295
- ### Test F3 — copy joint tables
296
-
297
- Use **Copy table with headers** under both:
298
-
299
- - **Joint intervention features**;
300
- - **Target continuation token-by-token score**.
301
-
302
- For both, the first pasted line must contain the table headers.
303
 
304
  ---
305
 
306
- ## G. 1/3/5 feature-set sensitivity
307
-
308
- In **Feature sets**, set:
309
-
310
- - **Target continuation for set-size sweep**: `2x`
311
 
312
- Click **Run 1/3/5-feature ablation sweep**.
313
-
314
- Expected **Feature-set size measurements** rows:
315
 
316
  ```text
317
- 1
318
- 3
319
- 5
320
  ```
321
 
322
- For each row verify:
323
-
324
- - exactly `k` feature IDs are listed;
325
- - perturbation L2 is finite;
326
- - targeted Δ mean log p/token is present;
327
- - random-ensemble signed mean, mean absolute effect, and standard deviation are present;
328
- - targeted/random magnitude ratio is finite or safely handled when the random effect is numerically zero;
329
- - empirical tail probability lies in `[0, 1]`;
330
- - SAE and random next-token JS statistics are present.
331
-
332
- **Do not require monotonicity with k.** A k=3 edit may be less effective than k=1, and k=5 may reverse the effect. That can be a real non-linear response rather than a bug.
333
-
334
- ---
335
-
336
- ## H. Individual-vs-joint non-additivity
337
-
338
- In **Feature sets**, select exactly the three strongest active values in **Feature set**.
339
-
340
- Set:
341
-
342
- - **Target continuation for interaction test**: `2x`
343
-
344
- Click **Run individual-vs-joint decomposition**.
345
-
346
- Verify:
347
-
348
- 1. **Individual and joint ablation measurements** contains one row per selected feature plus one `Joint ablation` row.
349
- 2. The summary reports:
350
- - additive expectation = sum of individual Δ mean log p/token values;
351
- - observed joint effect;
352
- - interaction excess = joint − additive;
353
- - normalized interaction;
354
- - execution-context null drift.
355
- 3. The joint row perturbation norm is finite.
356
-
357
- No particular sign is required. A non-zero interaction excess indicates non-additivity under this intervention, **not** proof that the selected SAE features form a direct circuit.
358
-
359
- ---
360
-
361
- ## I. Controlled concept contrast
362
-
363
- First run Test C1. Open **Feature evidence**.
364
-
365
- Set:
366
-
367
- - **Feature id**: the strongest feature from Test C1;
368
- - **Residual layer**: `14`;
369
- - **Prompts per concept**: `4`.
370
-
371
- Click **Run controlled concept contrast**.
372
-
373
- Expected **Feature activation by controlled concept**:
374
-
375
- - exactly seven concept rows;
376
- - each row reports prompt count, mean activation, median activation, activation rate, and max activation;
377
- - the chart contains the same seven concepts;
378
- - the summary may identify the group with the highest mean activation but must explicitly describe the scan as exploratory.
379
-
380
- This live scan is **not allowed to rename the feature or populate `Offline concept hint`**. A semantic hint should only come from the offline train/held-out discovery pipeline.
381
 
382
- Use **Copy table with headers** and verify the first copied line contains all columns.
383
-
384
- ---
385
-
386
- ## J. Paraphrase robustness
387
-
388
- Open **Paraphrase robustness** and set:
389
-
390
- - **Original prompt**: `The derivative of x squared is`
391
- - **Paraphrase**: `Differentiate x squared with respect to x:`
392
- - **Residual layer**: `14`
393
- - **Original prompt token index**: `-1`
394
- - **Paraphrase token index**: `-1`
395
- - **Displayed active features**: `12`
396
-
397
- Click **Compare paraphrase representations**.
398
-
399
- Verify:
400
-
401
- 1. **Original prompt tokens** and **Paraphrase tokens** render.
402
- 2. **Robustness metrics** reports two distinct comparisons:
403
- - selected-token TopK Jaccard and sparse activation cosine;
404
- - **prompt-wide** max-pooled Jaccard and cosine.
405
- 3. **Top-feature overlap at selected tokens** marks features `shared`, `original only`, or `paraphrase only`.
406
- 4. The prompt-wide metrics remain available even when the two selected final tokens play different syntactic/semantic roles.
407
-
408
- Bounds:
409
-
410
- - Jaccards lie in `[0, 1]`;
411
- - cosines should lie approximately in `[0, 1]` for non-negative SAE activations.
412
-
413
- Do not impose an arbitrary minimum robustness threshold.
414
-
415
- ### Test J2 — identity sanity check
416
-
417
- Use the **same exact prompt** in **Original prompt** and **Paraphrase**, with the same token indices and layer.
418
-
419
- Expected, within numerical precision:
420
 
421
  ```text
422
- selected-token Jaccard1
423
- selected-token cosine ≈ 1
424
- prompt-wide Jaccard ≈ 1
425
- prompt-wide cosine ≈ 1
426
  ```
427
 
428
- This is a strong sanity check for the comparison implementation.
429
-
430
- ---
431
-
432
- ## K. Layer trajectory
433
-
434
- Open **Layer trajectory** and set:
435
 
436
- - **Prompt**: `The derivative of x squared is`
437
- - **Prompt token index**: `-1`
 
 
438
 
439
- Click **Compare layers**.
440
-
441
- Expected:
442
-
443
- - **Prompt tokens** renders;
444
- - **Layer diagnostics** has exactly three rows: `4`, `14`, `26`;
445
- - **Representation trajectory** contains reconstruction cosine, Top-5 mass, and activation entropy.
446
-
447
- Do not compare numerical feature IDs across layers. Each residual layer has its own independently learned SAE dictionary.
448
 
449
  ---
450
 
451
- # L. Edge and adversarial tests
452
-
453
- These tests are designed to expose software or interpretation failures. They are not meant to produce attractive scientific results.
454
 
455
- ## Test L1 — empty prompt
456
-
457
- In **Workbench**, clear **Prompt** completely and click **Inspect sparse features**.
458
-
459
- **Pass:** the UI displays a clear error and the Space remains alive. The container must not restart.
460
-
461
- ## Test L2 — out-of-range prompt token index
462
-
463
- Use the normal mathematics prompt but set **Prompt token index** to `999` and click **Inspect sparse features**.
464
-
465
- **Pass:** a clear index/range error is shown; the application remains usable afterward.
466
-
467
- ## Test L3 — feature-ID boundaries
468
-
469
- After a successful Workbench inspection, manually enter the following in **Single feature id**:
470
-
471
- 1. `0` — valid boundary;
472
- 2. `32767` — valid boundary;
473
- 3. `32768` — invalid.
474
-
475
- Run **Run single-feature causal test** with a short target.
476
-
477
- **Pass:** IDs 0 and 32767 are accepted; 32768 produces a clear validation error without crashing the Space.
478
-
479
- ## Test L4 — inactive-feature semantics
480
-
481
- Find a feature that has activation 0 at the selected prompt location. A practical way is to enter a valid feature ID that is not among the active TopK IDs.
482
-
483
- Set **Single-feature intervention** to `ablate` and run the causal test.
484
-
485
- Expected:
486
 
487
  ```text
488
- Original feature activation ≈ 0
489
- Δ coefficient ≈ 0
490
- Perturbation L2 0
491
  ```
492
 
493
- The UI should state that ablation/scaling of an inactive feature has zero coefficient delta.
494
-
495
- Then switch to `inject`, set **Additive feature coefficient** to `5`, and run again.
496
-
497
- Expected: a non-zero residual perturbation is possible because injection intentionally tests the decoder direction even when the native activation is zero.
498
-
499
- ## Test L5 — zero-perturbation random-control handling
500
-
501
- Continue Test L4 with the inactive feature and `ablate`.
502
-
503
- **Pass:** the zero targeted perturbation does not create `NaN`, `inf`, or a crash in random-control statistics. JS/effect values should be zero or numerical zero and ratios/p-values must be safely represented.
504
-
505
- ## Test L6 — exact target whitespace
506
-
507
- For the same prompt and feature, run the target twice:
508
 
509
- ```text
510
- 2x
511
- ```
512
 
513
- and
514
 
515
  ```text
516
- 2x
 
 
 
 
 
 
517
  ```
518
 
519
- (the second begins with a space).
520
-
521
- **Pass:** FeatureLens reports the actual tokenization of each exact string and scores all returned target tokens. Different tokenizations/results are allowed.
522
-
523
- ## Test L7 — required-target validation
524
-
525
- Clear the target and try:
526
-
527
- - **Run scale dose-response**;
528
- - **Run joint feature-set causal test**;
529
- - **Run 1/3/5-feature ablation sweep**;
530
- - **Run individual-vs-joint decomposition**.
531
-
532
- **Pass:** every experiment that mathematically requires a target gives a clear UI error rather than silently inventing one or failing deep in the model call.
533
-
534
- The ordinary **Run single-feature causal test** may be used without a target because next-token JS and greedy generation are still defined.
535
-
536
- ## Test L8 — identical paraphrases
537
-
538
- Repeat Test J2 with the exact same text, same layer, and same token index.
539
-
540
- **Pass:** all robustness metrics are approximately 1. This is the adversarial regression check for the prompt-wide pooling code.
541
 
542
- ## Test L9 unrelated “paraphrase”
 
543
 
544
- Compare:
 
545
 
546
- ```text
547
- The derivative of x squared is
548
- ```
549
-
550
- with:
551
-
552
- ```text
553
- Je voudrais réserver une table pour demain soir.
554
- ```
555
-
556
- **Pass:** the app returns finite robustness metrics but does **not** label the prompts as semantically equivalent merely because some features overlap. No hard numerical threshold is required.
557
 
558
- ## Test L10 lexical trap / negation
559
 
560
- Inspect:
561
 
562
  ```text
563
- This is not a positive review.
 
 
564
  ```
565
 
566
- Then optionally run **Run controlled concept contrast** on one strongly active feature.
567
 
568
- **Pass:** the system reports activations without converting the lexical word `positive` into a hard semantic claim. `Offline concept hint` must remain whatever the offline catalog says.
569
 
570
- ## Test L11 mixed-language prompt
 
 
 
 
 
 
571
 
572
- Inspect:
573
 
574
  ```text
575
- The answer est probablement correct, but I am not certain.
 
 
576
  ```
577
 
578
- **Pass:** tokenization, SAE inspection, and causal controls remain finite. Do not expect a single concept feature to dominate.
579
-
580
- ## Test L12 — long prompt truncation
581
-
582
- Paste a prompt substantially longer than the configured prompt-token limit (repeat several paragraphs if needed) and click **Inspect sparse features**.
583
-
584
- **Pass:** the request truncates safely to the configured maximum instead of causing an OOM or index corruption. **Prompt tokens** should reflect the actually processed token sequence.
585
-
586
- ## Test L13 — table clipboard behavior
587
-
588
- For at least three different output tables, click **Copy table with headers** and paste into:
589
-
590
- 1. a plain-text editor;
591
- 2. a spreadsheet.
592
-
593
- **Pass:** the first row always contains headers, data are tab-separated, and embedded commas in cells do not shift spreadsheet columns.
594
-
595
- ## Test L14 — rapid double click / queue safety
596
-
597
- Click one GPU-heavy action twice quickly, for example **Run single-feature causal test**.
598
-
599
- **Pass:** requests queue under the app's concurrency limit rather than corrupting shared activation hooks or crashing the runtime. Do not repeatedly hammer the button beyond this single queue test because ZeroGPU quota is limited.
600
-
601
- ## Test L15 — responsive Feature Sets form
602
-
603
- At narrow browser width, open **Feature sets**.
604
-
605
- Verify the row containing:
606
-
607
- - **Intervention**;
608
- - **Multiplier (unused for ablation)** or **Shared feature multiplier**;
609
- - **Target continuation**
610
-
611
- wraps cleanly. Switch **Intervention** between `ablate` and `scale`.
612
-
613
- **Pass:** labels remain attached to the correct fields, no control text runs together, and changing the mode updates the multiplier label without shifting the target field into an ambiguous position.
614
 
615
  ---
616
 
617
- # M. Offline scientific benchmark acceptance
618
 
619
- After the public Space passes the live tests, run on separate CUDA compute:
620
-
621
- ```bash
622
- python3 experiments/run_all.py
623
- ```
624
 
625
- v0.4 offline causal runners use:
 
 
 
 
 
 
 
 
 
626
 
627
- - a batched zero-edit reference;
628
- - targeted SAE interventions;
629
- - configurable norm-matched random-control ensembles (default `8`);
630
- - full-continuation mean log-probability effects.
631
-
632
- Expected major artifacts include:
633
-
634
- ```text
635
- artifacts/feature_catalog.csv
636
- artifacts/layer_metrics.csv
637
- artifacts/stability.csv
638
- artifacts/causal_results.csv
639
- artifacts/feature_set_results.csv
640
- artifacts/report.md
641
- artifacts/figures/
642
- ```
643
 
644
- Before putting any number on a resume or portfolio page, manually verify:
645
-
646
- - train-only feature selection;
647
- - group-wise paraphrase split integrity;
648
- - held-out AUROC/F1;
649
- - reconstruction metrics by layer;
650
- - selected-token and paraphrase stability;
651
- - complete random-control ensembles for each causal condition;
652
- - execution-context null drift;
653
- - targeted-vs-random paired effect uncertainty;
654
- - whether single-feature and multi-feature conclusions agree or conflict;
655
- - whether any narrative claim is stronger than the saved raw rows support.
 
 
 
 
1
+ # FeatureLens v0.5.0 Validation
2
 
3
+ This validation pass is intentionally focused on the **new v0.5 behavior and the core v0.4 regressions it could affect**. The full adversarial/edge-case battery is deferred to the final release, as requested.
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  ## A. Local release gate
6
 
7
+ From the repository root:
8
 
9
  ```bash
10
  python3 -m pytest -q
11
  python3 -m compileall -q app.py featurelens experiments scripts
12
+ python3 scripts/ui_smoke.py
13
  python3 scripts/release_check.py
14
  ```
15
 
16
  Expected:
17
 
18
  ```text
19
+ 38 passed
20
+ FeatureLens UI launch smoke: PASS (...)
21
  FeatureLens release check: PASS
22
  discovery prompts: 224
23
  causal tasks: 28
24
  layers: [4, 14, 26]
25
  feature-set sizes: [1, 3, 5]
26
  random controls: 8
27
+ release: v0.5.0
28
  ```
29
 
30
+ The UI smoke is important: it executes the real Gradio `launch()` path, which is where the v0.4 theme-font error occurred.
 
 
 
 
 
 
31
 
32
  ---
33
 
34
  ## B. Hugging Face startup and layout
35
 
36
+ After pushing, inspect the container log.
37
 
38
+ Expected startup:
 
 
39
 
40
  ```text
41
  * Running on local URL: http://0.0.0.0:7860
42
  ```
43
 
44
+ There should be no SSR line, un-awaited coroutine warning, or theme/font traceback.
45
 
46
+ ### Layout acceptance
 
 
47
 
48
+ On a desktop-width browser:
49
 
50
+ - The application should be **centered**, not pinned to the left edge.
51
+ - The usable canvas should expand substantially farther across a wide monitor than v0.4.
52
+ - Explicit action buttons and **Copy table with headers** buttons should use the same muted teal treatment.
53
+ - Body copy, labels, controls, and headings should use a consistent serif-oriented typography system.
54
+ - Tables should use the available width before horizontal scrolling is needed.
55
 
56
+ The widest research tables may still need horizontal scrolling; that is preferable to wrapping numerical headings into unreadable multi-line columns.
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
  ---
59
 
60
+ ## C. Core Workbench inspection
 
 
61
 
62
+ In **Workbench** use:
63
 
64
+ ```text
65
+ Prompt: The derivative of x squared is
66
+ Residual layer: 14
67
+ Prompt token index: -1
68
+ Displayed active features: 12
69
+ ```
70
 
71
  Click **Inspect sparse features**.
72
 
73
+ Expected regression values should remain approximately:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  ```text
76
+ prompt token: 5
77
+ Reconstruction cosine: 0.9015
78
+ NMSE: 0.1874
79
+ Top-5 activation mass: 26.7%
80
+ Top feature: 22632
81
+ Top activation: 32.90625
82
  ```
83
 
84
+ The **Strongest active SAE features** table and **Activation profile** should populate.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
+ ### Copy feedback
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
+ Click **Copy table with headers** below the feature table.
89
 
90
+ Pass condition:
91
 
92
+ 1. Clipboard content starts with the header row, e.g.
93
+ ```text
94
+ Rank<TAB>Feature id<TAB>Activation<TAB>Offline concept hint
95
+ ```
96
+ 2. The clicked button briefly changes to:
97
+ ```text
98
+ ✓ Copied with headers
99
+ ```
100
+ and then returns to **Copy table with headers**.
101
 
102
+ ---
103
 
104
+ ## D. Dynamic-height / embedded scrolling regression
105
 
106
+ Still in **Workbench**, select feature `22632` and run:
107
 
108
+ ```text
109
+ Single-feature intervention: ablate
110
+ Target continuation (optional): 2x
111
+ Greedy generation length: 8
112
+ ```
113
 
114
  Click **Run single-feature causal test**.
115
 
116
+ After the result expands, **do not switch tabs**.
117
 
118
+ Immediately try to scroll to:
119
 
120
+ - **III. Single-feature scale dose-response**
121
+ - **IV. Contrastive continuation preference**
122
+ - and eventually the bottom of the Workbench tab.
123
 
124
+ Pass condition: the page continues scrolling after the dynamic result appears. A tab switch should not be required to recover the missing lower portion of the page.
 
 
125
 
126
+ v0.5 installs a browser-side resize/mutation observer specifically to request reflow after dynamic output-height changes. If the page still gets stuck, record whether the failure occurs on the Hugging Face Space page only or also when opening the direct `*.hf.space` app URL.
 
 
 
 
 
 
127
 
128
  ---
129
 
130
+ ## E. Scale dose-response numerical-null regression
 
 
131
 
132
+ Open **Scale dose-response** and click **Run scale dose-response** using target continuation `2x`.
133
 
134
+ The `1.0` row must remain the exact batched numerical reference:
 
 
135
 
136
  ```text
137
+ Multiplier: 1.0
138
+ Δ feature coefficient: 0.0
139
+ Perturbation L2: 0.0
140
+ Δ mean log p/token: 0.0
141
+ Δ sequence log p: 0.0
142
+ Next-token JS: 0.0
143
  ```
144
 
145
+ This is a hard regression requirement.
146
 
147
+ ---
 
 
 
 
 
148
 
149
+ ## F. Contrastive continuation preference — new v0.5 test
150
 
151
+ In **Workbench IV. Contrastive continuation preference**, use:
152
 
153
  ```text
154
+ Continuation A (preferred): 2x
155
+ Continuation B (comparison): x
156
+ Contrastive intervention: ablate
 
 
157
  ```
158
 
159
+ Use feature `22632`, layer `14`, prompt token `5` inherited from the Workbench.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
 
161
+ Click **Run contrastive preference test**.
162
 
163
+ Expected outputs:
 
 
 
 
 
 
164
 
165
+ - **Contrastive continuation scores** with two rows: `A (preferred)` and `B (comparison)`.
166
+ - Exact sequence log-probability and mean log-probability/token for both continuations.
167
+ - Baseline A−B sequence log-odds.
168
+ - SAE-edited A−B sequence log-odds.
169
+ - Causal A−B log-odds shift.
170
+ - 8-control random magnitude mean/std, specificity ratio, and empirical tail p.
171
+ - **Preference between exact continuations** chart.
172
 
173
+ There is no predetermined scientific sign. The test passes if the quantities are finite and internally coherent.
174
 
175
+ Interpretation rule: a strong JS shift with a weak contrastive preference shift supports **general distributional influence** more than selective preference control.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
  ---
178
 
179
+ ## G. Feature-token activation trace — new v0.5 test
 
 
 
 
180
 
181
+ Open **Feature evidence**. The Workbench inspection should have populated:
 
 
182
 
183
  ```text
184
+ Feature id: 22632
185
+ Residual layer: 14
 
186
  ```
187
 
188
+ Click **Trace feature across prompt tokens**.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
+ For the canonical prompt, the output should contain one row per prompt token. The final selected token (`is`, token index `5`) should reproduce the known feature activation approximately:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
 
192
  ```text
193
+ Feature 22632 at token 532.90625
 
 
 
194
  ```
195
 
196
+ Expected outputs:
 
 
 
 
 
 
197
 
198
+ - active token count;
199
+ - peak token position and activation;
200
+ - **Feature activation by prompt token** table;
201
+ - **Feature activation across prompt tokens** chart.
202
 
203
+ This test directly reveals whether a feature is localized to one token or distributed across the prompt.
 
 
 
 
 
 
 
 
204
 
205
  ---
206
 
207
+ ## H. Prompt-wide controlled concept contrast — corrected v0.5 test
 
 
208
 
209
+ In **Feature evidence** use:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
  ```text
212
+ Feature id: 22632
213
+ Residual layer: 14
214
+ Prompts per concept: 4
215
  ```
216
 
217
+ Click **Run controlled concept contrast**.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
+ v0.5 no longer evaluates only the final token. For each sampled prompt it takes the feature's **maximum activation across all non-padding prompt tokens**.
 
 
220
 
221
+ The table columns should be:
222
 
223
  ```text
224
+ Concept
225
+ Prompts
226
+ Mean prompt-wide max
227
+ Median prompt-wide max
228
+ Prompt activation rate
229
+ Mean when active
230
+ Max activation
231
  ```
232
 
233
+ Two outcomes are valid:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
 
235
+ ### If at least one sampled prompt activates the feature
236
+ The UI may report a highest-mean concept and a runner-up ratio.
237
 
238
+ ### If all sampled activations remain zero
239
+ The UI must say that the feature was **inactive in every sampled prompt**. It must **not** report `code` (or any other concept) as a winner and must not print an undefined winner ratio.
240
 
241
+ ---
 
 
 
 
 
 
 
 
 
 
242
 
243
+ ## I. Feature-set decoder geometry new v0.5 test
244
 
245
+ Open **Feature sets** after the canonical Workbench inspection. Select the top three features:
246
 
247
  ```text
248
+ 22632
249
+ 25695
250
+ 16369
251
  ```
252
 
253
+ Open **Feature-set decoder geometry** and click **Inspect selected-feature geometry**.
254
 
255
+ Expected:
256
 
257
+ - exactly three pair rows (`3 choose 2`);
258
+ - decoder cosine for each pair;
259
+ - mean absolute decoder cosine;
260
+ - maximum absolute decoder cosine;
261
+ - activation-weighted joint-ablation L2;
262
+ - independent-direction root-sum-square L2 reference;
263
+ - geometry ratio.
264
 
265
+ Interpretation:
266
 
267
  ```text
268
+ ratio < 1 -> net geometric cancellation
269
+ ratio ≈ 1 -> approximately independent/orthogonal net norm
270
+ ratio > 1 -> net geometric alignment
271
  ```
272
 
273
+ This does not replace the individual-vs-joint causal test; it helps separate decoder geometry from downstream non-linearity.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
 
275
  ---
276
 
277
+ ## J. Existing feature-set and paraphrase regressions
278
 
279
+ A full rerun is not necessary for every v0.4 panel. Do these quick checks:
 
 
 
 
280
 
281
+ 1. **Run 1/3/5-feature ablation sweep** still produces rows for `k = 1, 3, 5` and 8-control statistics.
282
+ 2. **Run individual-vs-joint decomposition** still produces three individual rows plus one joint row for the selected top three features.
283
+ 3. In **Paraphrase robustness**, identical prompt vs identical prompt should still return:
284
+ ```text
285
+ selected-token Jaccard = 1.000
286
+ selected-token cosine = 1.000
287
+ prompt-wide Jaccard = 1.000
288
+ prompt-wide cosine = 1.000
289
+ ```
290
+ 4. **Layer trajectory → Compare layers** still returns layers `4`, `14`, and `26`.
291
 
292
+ ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
 
294
+ ## Deferred final-release tests
295
+
296
+ The comprehensive adversarial suite remains intentionally deferred until the project is feature-complete. The final release should include, at minimum:
297
+
298
+ - empty prompts/targets;
299
+ - invalid token indices;
300
+ - feature IDs `0`, `32767`, and invalid `32768`;
301
+ - inactive feature ablation/scale/injection semantics;
302
+ - whitespace-sensitive continuations (`2x` vs ` 2x`);
303
+ - unrelated and negated paraphrases;
304
+ - mixed-language prompts;
305
+ - long-prompt truncation;
306
+ - rapid repeated clicks / ZeroGPU queue behavior;
307
+ - narrow/mobile layout;
308
+ - direct Space URL vs Hugging Face embedded-page scrolling.
featurelens/metrics.py CHANGED
@@ -117,3 +117,37 @@ def sparse_topk_cosine(
117
  if norm_a == 0.0 or norm_b == 0.0:
118
  return 0.0
119
  return float(dot / (norm_a * norm_b))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  if norm_a == 0.0 or norm_b == 0.0:
118
  return 0.0
119
  return float(dot / (norm_a * norm_b))
120
+
121
+
122
+ def contrastive_log_odds(
123
+ baseline_a: float,
124
+ modified_a: float,
125
+ baseline_b: float,
126
+ modified_b: float,
127
+ ) -> tuple[float, float, float]:
128
+ """Return baseline A-vs-B log-odds, modified log-odds, and causal shift."""
129
+ baseline = float(baseline_a) - float(baseline_b)
130
+ modified = float(modified_a) - float(modified_b)
131
+ return baseline, modified, modified - baseline
132
+
133
+
134
+ def decoder_cosine_matrix(directions: torch.Tensor) -> torch.Tensor:
135
+ """Pairwise cosine matrix for decoder directions shaped ``[features, d_model]``."""
136
+ if directions.ndim != 2 or directions.shape[0] < 1:
137
+ raise ValueError('directions must have shape [features, d_model].')
138
+ values = directions.float()
139
+ norms = torch.linalg.vector_norm(values, dim=1, keepdim=True).clamp_min(1e-12)
140
+ normalized = values / norms
141
+ return normalized @ normalized.T
142
+
143
+
144
+ def joint_direction_norm_ratio(deltas: torch.Tensor) -> tuple[float, float, float]:
145
+ """Compare the norm of a summed edit with the root-sum-square independent reference."""
146
+ if deltas.ndim != 2 or deltas.shape[0] < 1:
147
+ raise ValueError('deltas must have shape [features, d_model].')
148
+ values = deltas.float()
149
+ individual_norms = torch.linalg.vector_norm(values, dim=1)
150
+ joint_norm = float(torch.linalg.vector_norm(values.sum(dim=0)).item())
151
+ independent_norm = float(torch.sqrt(torch.sum(individual_norms ** 2)).item())
152
+ ratio = joint_norm / max(independent_norm, 1e-12)
153
+ return joint_norm, independent_norm, float(ratio)
featurelens/runtime.py CHANGED
@@ -22,6 +22,9 @@ from .interventions import (
22
  residual_delta,
23
  )
24
  from .metrics import (
 
 
 
25
  js_divergence_from_logits,
26
  reconstruction_metrics,
27
  sequence_logprob_summary,
@@ -169,8 +172,59 @@ class ConceptContrastResult:
169
  prompts_per_concept: int
170
  rows: list[list[object]]
171
  chart_rows: list[list[object]]
172
- leading_concept: str
173
- leading_ratio: float
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
 
176
  @dataclass
@@ -1369,6 +1423,247 @@ class FeatureLensRuntime:
1369
  selected.extend(by_concept[concept][: int(prompts_per_concept)])
1370
  return selected
1371
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1372
  @torch.inference_mode()
1373
  def concept_contrast_scan(
1374
  self,
@@ -1376,9 +1671,11 @@ class FeatureLensRuntime:
1376
  layer: int,
1377
  prompts_per_concept: int | None = None,
1378
  ) -> ConceptContrastResult:
1379
- """Measure one SAE feature across a small balanced concept contrast batch."""
1380
  self.ensure_ready(preload_saes=False)
1381
  assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
 
 
1382
  n = int(prompts_per_concept or self.settings.contrast_prompts_per_concept)
1383
  if n < 1 or n > 8:
1384
  raise ValueError('Contrast prompts per concept must be between 1 and 8.')
@@ -1397,13 +1694,17 @@ class FeatureLensRuntime:
1397
  capture: dict = {}
1398
  with self._capture_hook(int(layer), capture):
1399
  self.model(**batch, use_cache=False)
1400
- # Left padding means the last position is the final real token for every prompt.
1401
- residuals = capture['hidden'][:, -1, :]
1402
  sae = self.sae_store.get(int(layer))
1403
  encoding = sae.encode(residuals)
1404
- mask = encoding.indices == int(feature_id)
1405
- activations = torch.where(mask, encoding.values, torch.zeros_like(encoding.values)).sum(dim=-1)
1406
- values = activations.detach().float().cpu().tolist()
 
 
 
 
 
1407
 
1408
  grouped: dict[str, list[float]] = {}
1409
  for row, value in zip(rows, values, strict=True):
@@ -1413,22 +1714,28 @@ class FeatureLensRuntime:
1413
  vals = grouped[concept]
1414
  tensor = torch.tensor(vals, dtype=torch.float64)
1415
  active = [value for value in vals if value > 0]
1416
- median = float(torch.median(tensor).item())
1417
  table_rows.append(
1418
  [
1419
  concept,
1420
  len(vals),
1421
  float(tensor.mean().item()),
1422
- median,
1423
  float(len(active) / len(vals)),
 
1424
  float(max(vals) if vals else 0.0),
1425
  ]
1426
  )
1427
- table_rows.sort(key=lambda row: (float(row[2]), float(row[4])), reverse=True)
1428
- leader = str(table_rows[0][0])
1429
- first = float(table_rows[0][2])
1430
- second = float(table_rows[1][2]) if len(table_rows) > 1 else 0.0
1431
- ratio = first / max(second, 1e-12) if first > 0 else 0.0
 
 
 
 
 
1432
  chart_rows = [[str(row[0]), float(row[2])] for row in table_rows]
1433
  return ConceptContrastResult(
1434
  feature_id=int(feature_id),
@@ -1437,7 +1744,9 @@ class FeatureLensRuntime:
1437
  rows=table_rows,
1438
  chart_rows=chart_rows,
1439
  leading_concept=leader,
1440
- leading_ratio=float(ratio),
 
 
1441
  )
1442
 
1443
 
 
22
  residual_delta,
23
  )
24
  from .metrics import (
25
+ contrastive_log_odds,
26
+ decoder_cosine_matrix,
27
+ joint_direction_norm_ratio,
28
  js_divergence_from_logits,
29
  reconstruction_metrics,
30
  sequence_logprob_summary,
 
172
  prompts_per_concept: int
173
  rows: list[list[object]]
174
  chart_rows: list[list[object]]
175
+ leading_concept: str | None
176
+ leading_ratio: float | None
177
+ active_prompt_count: int
178
+ total_prompt_count: int
179
+
180
+
181
+ @dataclass
182
+ class FeatureTraceResult:
183
+ feature_id: int
184
+ layer: int
185
+ tokens: list[str]
186
+ rows: list[list[object]]
187
+ chart_rows: list[list[object]]
188
+ active_token_count: int
189
+ token_count: int
190
+ max_activation: float
191
+ max_token_index: int | None
192
+
193
+
194
+ @dataclass
195
+ class FeatureGeometryResult:
196
+ feature_ids: list[int]
197
+ layer: int
198
+ rows: list[list[object]]
199
+ chart_rows: list[list[object]]
200
+ mean_abs_decoder_cosine: float
201
+ max_abs_decoder_cosine: float
202
+ joint_ablation_norm: float
203
+ independent_norm: float
204
+ alignment_ratio: float
205
+
206
+
207
+ @dataclass
208
+ class ContrastiveCausalResult:
209
+ feature_id: int
210
+ layer: int
211
+ feature_activation: float
212
+ perturbation_norm: float
213
+ target_a_tokens: list[str]
214
+ target_b_tokens: list[str]
215
+ rows: list[list[object]]
216
+ baseline_log_odds: float
217
+ modified_log_odds: float
218
+ delta_log_odds: float
219
+ baseline_normalized_preference: float
220
+ modified_normalized_preference: float
221
+ delta_normalized_preference: float
222
+ random_signed_mean_delta: float
223
+ random_abs_mean_delta: float
224
+ random_delta_std: float
225
+ specificity_ratio: float
226
+ empirical_p: float
227
+ random_control_count: int
228
 
229
 
230
  @dataclass
 
1423
  selected.extend(by_concept[concept][: int(prompts_per_concept)])
1424
  return selected
1425
 
1426
+ @torch.inference_mode()
1427
+ def feature_token_trace(
1428
+ self,
1429
+ text: str,
1430
+ layer: int,
1431
+ feature_id: int,
1432
+ ) -> FeatureTraceResult:
1433
+ """Trace one SAE feature across every non-padding token in a prompt."""
1434
+ if not text.strip():
1435
+ raise ValueError('Enter a prompt first.')
1436
+ self.ensure_ready(preload_saes=False)
1437
+ assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
1438
+ if int(layer) not in self.settings.layers:
1439
+ raise ValueError(f'Layer must be one of {self.settings.layers}.')
1440
+ if int(feature_id) < 0 or int(feature_id) >= self.settings.sae_width:
1441
+ raise ValueError(f'Feature id must be in [0, {self.settings.sae_width - 1}].')
1442
+
1443
+ inputs = self._inputs(text)
1444
+ capture: dict = {}
1445
+ with self._capture_hook(int(layer), capture):
1446
+ self.model(**inputs, use_cache=False)
1447
+ hidden = capture['hidden'][0]
1448
+ sae = self.sae_store.get(int(layer))
1449
+ encoding = sae.encode(hidden)
1450
+ mask = encoding.indices == int(feature_id)
1451
+ activations = torch.where(mask, encoding.values, torch.zeros_like(encoding.values)).sum(dim=-1)
1452
+ values = activations.detach().float().cpu().tolist()
1453
+ ids = inputs['input_ids'][0].tolist()
1454
+ tokens = [self.tokenizer.decode([int(token_id)]) for token_id in ids]
1455
+
1456
+ rows: list[list[object]] = []
1457
+ chart_rows: list[list[object]] = []
1458
+ for idx, (token, value) in enumerate(zip(tokens, values, strict=True)):
1459
+ value = float(value)
1460
+ rows.append([idx, repr(token), value, bool(value > 0)])
1461
+ chart_rows.append([f'{idx}: {token if token.strip() else repr(token)}', value])
1462
+
1463
+ active = [idx for idx, value in enumerate(values) if float(value) > 0]
1464
+ if active:
1465
+ max_idx = max(active, key=lambda idx: float(values[idx]))
1466
+ max_value = float(values[max_idx])
1467
+ else:
1468
+ max_idx = None
1469
+ max_value = 0.0
1470
+ return FeatureTraceResult(
1471
+ feature_id=int(feature_id),
1472
+ layer=int(layer),
1473
+ tokens=tokens,
1474
+ rows=rows,
1475
+ chart_rows=chart_rows,
1476
+ active_token_count=len(active),
1477
+ token_count=len(tokens),
1478
+ max_activation=max_value,
1479
+ max_token_index=max_idx,
1480
+ )
1481
+
1482
+ @torch.inference_mode()
1483
+ def feature_geometry(
1484
+ self,
1485
+ text: str,
1486
+ layer: int,
1487
+ token_index: int,
1488
+ feature_ids: Sequence[int],
1489
+ ) -> FeatureGeometryResult:
1490
+ """Inspect pairwise SAE decoder geometry and activation-weighted ablation geometry."""
1491
+ if not text.strip():
1492
+ raise ValueError('Enter a prompt first.')
1493
+ ids = list(dict.fromkeys(int(x) for x in feature_ids))
1494
+ if len(ids) < 2:
1495
+ raise ValueError('Select at least two distinct features for geometry analysis.')
1496
+ if len(ids) > 8:
1497
+ raise ValueError('Geometry analysis supports at most eight features in the live app.')
1498
+ if any(feature_id < 0 or feature_id >= self.settings.sae_width for feature_id in ids):
1499
+ raise ValueError(f'Feature ids must be in [0, {self.settings.sae_width - 1}].')
1500
+
1501
+ self.ensure_ready(preload_saes=False)
1502
+ assert self.model is not None and self.sae_store is not None
1503
+ inputs = self._inputs(text)
1504
+ capture: dict = {}
1505
+ with self._capture_hook(int(layer), capture):
1506
+ self.model(**inputs, use_cache=False)
1507
+ hidden = capture['hidden'][0]
1508
+ idx = self._resolve_index(int(token_index), hidden.shape[0])
1509
+ sae = self.sae_store.get(int(layer))
1510
+ encoding = sae.encode(hidden[idx])
1511
+ activations = [float(encoding.activation_for(feature_id)) for feature_id in ids]
1512
+ directions = torch.stack([sae.decoder_direction(feature_id).float() for feature_id in ids])
1513
+ gram = decoder_cosine_matrix(directions)
1514
+
1515
+ rows: list[list[object]] = []
1516
+ chart_rows: list[list[object]] = []
1517
+ offdiag: list[float] = []
1518
+ for i in range(len(ids)):
1519
+ for j in range(i + 1, len(ids)):
1520
+ cosine = float(gram[i, j].item())
1521
+ offdiag.append(abs(cosine))
1522
+ rows.append([ids[i], ids[j], activations[i], activations[j], cosine])
1523
+ chart_rows.append([f'{ids[i]} ↔ {ids[j]}', cosine])
1524
+
1525
+ individual_deltas = torch.stack(
1526
+ [-float(activation) * direction for activation, direction in zip(activations, directions, strict=True)]
1527
+ )
1528
+ joint_norm, independent_norm, alignment_ratio = joint_direction_norm_ratio(individual_deltas)
1529
+ return FeatureGeometryResult(
1530
+ feature_ids=ids,
1531
+ layer=int(layer),
1532
+ rows=rows,
1533
+ chart_rows=chart_rows,
1534
+ mean_abs_decoder_cosine=float(sum(offdiag) / len(offdiag)) if offdiag else 0.0,
1535
+ max_abs_decoder_cosine=float(max(offdiag)) if offdiag else 0.0,
1536
+ joint_ablation_norm=joint_norm,
1537
+ independent_norm=independent_norm,
1538
+ alignment_ratio=float(alignment_ratio),
1539
+ )
1540
+
1541
+ @torch.inference_mode()
1542
+ def contrastive_intervention(
1543
+ self,
1544
+ text: str,
1545
+ layer: int,
1546
+ token_index: int,
1547
+ feature_id: int,
1548
+ mode: str,
1549
+ coefficient: float,
1550
+ target_a: str,
1551
+ target_b: str,
1552
+ ) -> ContrastiveCausalResult:
1553
+ """Measure whether an SAE edit shifts preference between two exact continuations."""
1554
+ if not text.strip():
1555
+ raise ValueError('Enter a prompt first.')
1556
+ if not target_a:
1557
+ raise ValueError('Enter preferred continuation A.')
1558
+ if not target_b:
1559
+ raise ValueError('Enter comparison continuation B.')
1560
+ if target_a == target_b:
1561
+ raise ValueError('Continuations A and B must be different.')
1562
+
1563
+ self.ensure_ready(preload_saes=False)
1564
+ assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
1565
+ prompt_inputs = self._inputs(text)
1566
+ prompt_len = int(prompt_inputs['input_ids'].shape[1])
1567
+ idx = self._resolve_index(int(token_index), prompt_len)
1568
+ capture: dict = {}
1569
+ with self._capture_hook(int(layer), capture):
1570
+ self.model(**prompt_inputs, use_cache=False)
1571
+ residual = capture['hidden'][0, idx]
1572
+ sae = self.sae_store.get(int(layer))
1573
+ encoding = sae.encode(residual)
1574
+ activation = float(encoding.activation_for(int(feature_id)))
1575
+ delta = residual_delta(
1576
+ sae.decoder_direction(int(feature_id)),
1577
+ activation,
1578
+ InterventionSpec(mode=mode, coefficient=float(coefficient)),
1579
+ )
1580
+ seed = self._control_seed(text, int(layer), str(int(feature_id)), mode, float(coefficient))
1581
+ controls = self._random_controls(delta, seed=seed, count=self.settings.live_random_controls)
1582
+ deltas = torch.stack([torch.zeros_like(delta), delta, *controls], dim=0)
1583
+
1584
+ def score(target_text: str):
1585
+ target_ids = self._target_ids(target_text)
1586
+ scoring_inputs = self._append_target(prompt_inputs, target_ids)
1587
+ repeated = self._repeat_inputs(scoring_inputs, deltas.shape[0])
1588
+ with self._batch_delta_hook(int(layer), idx, deltas):
1589
+ output = self.model(**repeated, use_cache=False)
1590
+ summaries = [
1591
+ sequence_logprob_summary(output.logits[row], prompt_length=prompt_len, target_ids=target_ids)
1592
+ for row in range(output.logits.shape[0])
1593
+ ]
1594
+ return target_ids, summaries
1595
+
1596
+ ids_a, scores_a = score(target_a)
1597
+ ids_b, scores_b = score(target_b)
1598
+ base_a_seq, base_a_mean, _ = scores_a[0]
1599
+ edit_a_seq, edit_a_mean, _ = scores_a[1]
1600
+ base_b_seq, base_b_mean, _ = scores_b[0]
1601
+ edit_b_seq, edit_b_mean, _ = scores_b[1]
1602
+
1603
+ baseline_log_odds, modified_log_odds, delta_log_odds = contrastive_log_odds(
1604
+ base_a_seq, edit_a_seq, base_b_seq, edit_b_seq
1605
+ )
1606
+ baseline_norm_pref, modified_norm_pref, delta_norm_pref = contrastive_log_odds(
1607
+ base_a_mean, edit_a_mean, base_b_mean, edit_b_mean
1608
+ )
1609
+
1610
+ random_delta_log_odds: list[float] = []
1611
+ for row in range(2, len(scores_a)):
1612
+ random_a_seq = float(scores_a[row][0])
1613
+ random_b_seq = float(scores_b[row][0])
1614
+ random_delta_log_odds.append(
1615
+ float((random_a_seq - random_b_seq) - baseline_log_odds)
1616
+ )
1617
+ random_signed, random_abs, random_std, empirical_p = self._random_effect_summary(
1618
+ random_delta_log_odds, delta_log_odds
1619
+ )
1620
+ ratio = abs(delta_log_odds) / max(random_abs, 1e-12)
1621
+ rows = [
1622
+ [
1623
+ 'A (preferred)',
1624
+ target_a,
1625
+ len(ids_a),
1626
+ float(base_a_seq),
1627
+ float(edit_a_seq),
1628
+ float(edit_a_seq - base_a_seq),
1629
+ float(base_a_mean),
1630
+ float(edit_a_mean),
1631
+ float(edit_a_mean - base_a_mean),
1632
+ ],
1633
+ [
1634
+ 'B (comparison)',
1635
+ target_b,
1636
+ len(ids_b),
1637
+ float(base_b_seq),
1638
+ float(edit_b_seq),
1639
+ float(edit_b_seq - base_b_seq),
1640
+ float(base_b_mean),
1641
+ float(edit_b_mean),
1642
+ float(edit_b_mean - base_b_mean),
1643
+ ],
1644
+ ]
1645
+ return ContrastiveCausalResult(
1646
+ feature_id=int(feature_id),
1647
+ layer=int(layer),
1648
+ feature_activation=activation,
1649
+ perturbation_norm=float(torch.linalg.vector_norm(delta.float()).item()),
1650
+ target_a_tokens=[self.tokenizer.decode([int(x)]) for x in ids_a],
1651
+ target_b_tokens=[self.tokenizer.decode([int(x)]) for x in ids_b],
1652
+ rows=rows,
1653
+ baseline_log_odds=baseline_log_odds,
1654
+ modified_log_odds=modified_log_odds,
1655
+ delta_log_odds=delta_log_odds,
1656
+ baseline_normalized_preference=baseline_norm_pref,
1657
+ modified_normalized_preference=modified_norm_pref,
1658
+ delta_normalized_preference=delta_norm_pref,
1659
+ random_signed_mean_delta=random_signed,
1660
+ random_abs_mean_delta=random_abs,
1661
+ random_delta_std=random_std,
1662
+ specificity_ratio=float(ratio),
1663
+ empirical_p=float(empirical_p),
1664
+ random_control_count=len(controls),
1665
+ )
1666
+
1667
  @torch.inference_mode()
1668
  def concept_contrast_scan(
1669
  self,
 
1671
  layer: int,
1672
  prompts_per_concept: int | None = None,
1673
  ) -> ConceptContrastResult:
1674
+ """Measure one SAE feature using prompt-wide max activation on a balanced concept batch."""
1675
  self.ensure_ready(preload_saes=False)
1676
  assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
1677
+ if int(feature_id) < 0 or int(feature_id) >= self.settings.sae_width:
1678
+ raise ValueError(f'Feature id must be in [0, {self.settings.sae_width - 1}].')
1679
  n = int(prompts_per_concept or self.settings.contrast_prompts_per_concept)
1680
  if n < 1 or n > 8:
1681
  raise ValueError('Contrast prompts per concept must be between 1 and 8.')
 
1694
  capture: dict = {}
1695
  with self._capture_hook(int(layer), capture):
1696
  self.model(**batch, use_cache=False)
1697
+ residuals = capture['hidden']
 
1698
  sae = self.sae_store.get(int(layer))
1699
  encoding = sae.encode(residuals)
1700
+ feature_mask = encoding.indices == int(feature_id)
1701
+ token_activations = torch.where(
1702
+ feature_mask, encoding.values, torch.zeros_like(encoding.values)
1703
+ ).sum(dim=-1)
1704
+ attention = batch.get('attention_mask', torch.ones_like(batch['input_ids'])).bool()
1705
+ token_activations = torch.where(attention, token_activations, torch.zeros_like(token_activations))
1706
+ prompt_activations = token_activations.max(dim=1).values
1707
+ values = prompt_activations.detach().float().cpu().tolist()
1708
 
1709
  grouped: dict[str, list[float]] = {}
1710
  for row, value in zip(rows, values, strict=True):
 
1714
  vals = grouped[concept]
1715
  tensor = torch.tensor(vals, dtype=torch.float64)
1716
  active = [value for value in vals if value > 0]
1717
+ positive_mean = float(sum(active) / len(active)) if active else 0.0
1718
  table_rows.append(
1719
  [
1720
  concept,
1721
  len(vals),
1722
  float(tensor.mean().item()),
1723
+ float(torch.median(tensor).item()),
1724
  float(len(active) / len(vals)),
1725
+ positive_mean,
1726
  float(max(vals) if vals else 0.0),
1727
  ]
1728
  )
1729
+ table_rows.sort(key=lambda row: (float(row[2]), float(row[4]), float(row[6])), reverse=True)
1730
+ active_prompt_count = sum(1 for value in values if float(value) > 0)
1731
+ if table_rows and float(table_rows[0][2]) > 0:
1732
+ leader: str | None = str(table_rows[0][0])
1733
+ first = float(table_rows[0][2])
1734
+ second = float(table_rows[1][2]) if len(table_rows) > 1 else 0.0
1735
+ ratio: float | None = first / second if second > 0 else None
1736
+ else:
1737
+ leader = None
1738
+ ratio = None
1739
  chart_rows = [[str(row[0]), float(row[2])] for row in table_rows]
1740
  return ConceptContrastResult(
1741
  feature_id=int(feature_id),
 
1744
  rows=table_rows,
1745
  chart_rows=chart_rows,
1746
  leading_concept=leader,
1747
+ leading_ratio=ratio,
1748
+ active_prompt_count=int(active_prompt_count),
1749
+ total_prompt_count=len(values),
1750
  )
1751
 
1752
 
pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
  [project]
2
  name = "featurelens"
3
- version = "0.4.0"
4
  description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
5
  requires-python = ">=3.10"
6
 
 
1
  [project]
2
  name = "featurelens"
3
+ version = "0.5.0"
4
  description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
5
  requires-python = ">=3.10"
6
 
research_config.json CHANGED
@@ -31,9 +31,9 @@
31
  "ablate",
32
  "scale_2x"
33
  ],
34
- "negative_control": "norm-matched random residual direction",
35
  "primary_causal_metric": "full target continuation mean log-probability delta per token (teacher-forced)",
36
- "live_causal_controls": "norm-matched random residual direction",
37
  "dose_response_multipliers": [
38
  0.0,
39
  0.5,
@@ -61,7 +61,7 @@
61
  "ablate",
62
  "scale"
63
  ],
64
- "feature_set_negative_control": "norm-matched random residual direction",
65
  "dose_response_execution": "all scale conditions share the 1x batched no-edit reference",
66
  "feature_set_sweep_execution": "zero-edit reference + SAE edits + random-control ensembles evaluated in one batched forward",
67
  "live_random_controls": 8,
@@ -77,5 +77,17 @@
77
  "copy_tables_with_headers"
78
  ],
79
  "concept_contrast_prompts_per_concept": 4,
80
- "interaction_feature_limit": 5
 
 
 
 
 
 
 
 
 
 
 
 
81
  }
 
31
  "ablate",
32
  "scale_2x"
33
  ],
34
+ "negative_control": "norm-matched random residual direction ensemble",
35
  "primary_causal_metric": "full target continuation mean log-probability delta per token (teacher-forced)",
36
+ "live_causal_controls": "8-direction norm-matched random residual ensemble",
37
  "dose_response_multipliers": [
38
  0.0,
39
  0.5,
 
61
  "ablate",
62
  "scale"
63
  ],
64
+ "feature_set_negative_control": "norm-matched random residual direction ensemble",
65
  "dose_response_execution": "all scale conditions share the 1x batched no-edit reference",
66
  "feature_set_sweep_execution": "zero-edit reference + SAE edits + random-control ensembles evaluated in one batched forward",
67
  "live_random_controls": 8,
 
77
  "copy_tables_with_headers"
78
  ],
79
  "concept_contrast_prompts_per_concept": 4,
80
+ "interaction_feature_limit": 5,
81
+ "concept_contrast_pooling": "max activation across non-padding prompt tokens",
82
+ "live_geometry_feature_limit": 8,
83
+ "contrastive_preference_metric": "change in exact-sequence log-odds between two user-specified continuations",
84
+ "live_features_v0_5": [
85
+ "wide_centered_responsive_layout",
86
+ "copy_feedback",
87
+ "dynamic_height_reflow_observer",
88
+ "promptwide_concept_contrast_scan",
89
+ "feature_token_activation_trace",
90
+ "contrastive_continuation_preference_test",
91
+ "feature_decoder_geometry"
92
+ ]
93
  }
scripts/release_check.py CHANGED
@@ -25,6 +25,7 @@ REQUIRED = [
25
  'data/prompts.jsonl',
26
  'data/causal_tasks.jsonl',
27
  'docs/VALIDATION.md',
 
28
  ]
29
 
30
 
@@ -81,6 +82,8 @@ def check_config(config: dict) -> None:
81
  'offline_random_controls_default': 8,
82
  'concept_contrast_prompts_per_concept': 4,
83
  'interaction_feature_limit': 5,
 
 
84
  }
85
  for key, value in expected.items():
86
  if config.get(key) != value:
@@ -101,6 +104,22 @@ def check_config(config: dict) -> None:
101
  f'{sorted(actual_live_v04)}'
102
  )
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  def check_datasets(config: dict) -> tuple[list[dict], list[dict]]:
106
  prompts = load_jsonl(ROOT / 'data' / 'prompts.jsonl')
@@ -161,16 +180,19 @@ def check_readme() -> None:
161
  'batched zero-edit',
162
  'concept contrast',
163
  'non-additivity',
 
 
 
164
  ]
165
  missing = [value for value in required_strings if value.lower() not in readme.lower()]
166
  if missing:
167
- raise SystemExit(f'README.md is missing required v0.4 content: {missing}')
168
 
169
 
170
  def check_pyproject() -> None:
171
  text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
172
- if 'version = "0.4.0"' not in text:
173
- raise SystemExit('pyproject.toml must declare version 0.4.0.')
174
 
175
 
176
  def main() -> None:
@@ -188,7 +210,7 @@ def main() -> None:
188
  print(f' layers: {config["layers"]}')
189
  print(f' feature-set sizes: {config["feature_set_sizes"]}')
190
  print(f' random controls: {config["live_random_controls"]}')
191
- print(' release: v0.4.0')
192
 
193
 
194
  if __name__ == '__main__':
 
25
  'data/prompts.jsonl',
26
  'data/causal_tasks.jsonl',
27
  'docs/VALIDATION.md',
28
+ 'scripts/ui_smoke.py',
29
  ]
30
 
31
 
 
82
  'offline_random_controls_default': 8,
83
  'concept_contrast_prompts_per_concept': 4,
84
  'interaction_feature_limit': 5,
85
+ 'live_geometry_feature_limit': 8,
86
+ 'concept_contrast_pooling': 'max activation across non-padding prompt tokens',
87
  }
88
  for key, value in expected.items():
89
  if config.get(key) != value:
 
104
  f'{sorted(actual_live_v04)}'
105
  )
106
 
107
+ required_live_v05 = {
108
+ 'wide_centered_responsive_layout',
109
+ 'copy_feedback',
110
+ 'dynamic_height_reflow_observer',
111
+ 'promptwide_concept_contrast_scan',
112
+ 'feature_token_activation_trace',
113
+ 'contrastive_continuation_preference_test',
114
+ 'feature_decoder_geometry',
115
+ }
116
+ actual_live_v05 = set(config.get('live_features_v0_5', []))
117
+ if actual_live_v05 != required_live_v05:
118
+ raise SystemExit(
119
+ 'research_config.json live_features_v0_5 mismatch: '
120
+ f'{sorted(actual_live_v05)}'
121
+ )
122
+
123
 
124
  def check_datasets(config: dict) -> tuple[list[dict], list[dict]]:
125
  prompts = load_jsonl(ROOT / 'data' / 'prompts.jsonl')
 
180
  'batched zero-edit',
181
  'concept contrast',
182
  'non-additivity',
183
+ 'contrastive',
184
+ 'decoder geometry',
185
+ 'token activation',
186
  ]
187
  missing = [value for value in required_strings if value.lower() not in readme.lower()]
188
  if missing:
189
+ raise SystemExit(f'README.md is missing required v0.5 content: {missing}')
190
 
191
 
192
  def check_pyproject() -> None:
193
  text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
194
+ if 'version = "0.5.0"' not in text:
195
+ raise SystemExit('pyproject.toml must declare version 0.5.0.')
196
 
197
 
198
  def main() -> None:
 
210
  print(f' layers: {config["layers"]}')
211
  print(f' feature-set sizes: {config["feature_set_sizes"]}')
212
  print(f' random controls: {config["live_random_controls"]}')
213
+ print(' release: v0.5.0')
214
 
215
 
216
  if __name__ == '__main__':
scripts/ui_smoke.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import socket
5
+ import sys
6
+ import types
7
+ from pathlib import Path
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ if str(ROOT) not in sys.path:
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ os.environ.setdefault('FEATURELENS_EAGER_LOAD', '0')
14
+
15
+ # The UI smoke validates Gradio construction + launch and intentionally does not
16
+ # load Qwen weights. Allow it to run in a lightweight developer environment
17
+ # where Transformers itself is absent; normal application execution still
18
+ # requires requirements.txt.
19
+ try:
20
+ import transformers # noqa: F401
21
+ except ModuleNotFoundError:
22
+ stub = types.ModuleType('transformers')
23
+
24
+ class _UnavailableAutoClass:
25
+ @classmethod
26
+ def from_pretrained(cls, *_args, **_kwargs): # pragma: no cover - safety guard
27
+ raise RuntimeError(
28
+ 'Transformers is not installed. Install requirements.txt before model inference.'
29
+ )
30
+
31
+ stub.AutoModelForCausalLM = _UnavailableAutoClass
32
+ stub.AutoTokenizer = _UnavailableAutoClass
33
+ sys.modules['transformers'] = stub
34
+
35
+ from app import CSS, THEME, demo # noqa: E402
36
+
37
+
38
+ def _free_port() -> int:
39
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
40
+ sock.bind(('127.0.0.1', 0))
41
+ return int(sock.getsockname()[1])
42
+
43
+
44
+ def main() -> None:
45
+ port = _free_port()
46
+ demo.launch(
47
+ css=CSS,
48
+ theme=THEME,
49
+ ssr_mode=False,
50
+ prevent_thread_lock=True,
51
+ server_name='127.0.0.1',
52
+ server_port=port,
53
+ show_error=True,
54
+ )
55
+ print(f'FeatureLens UI launch smoke: PASS (port {port})')
56
+ demo.close()
57
+
58
+
59
+ if __name__ == '__main__':
60
+ main()
tests/test_live_runtime_helpers.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import sys
5
+ import types
6
+ from types import SimpleNamespace
7
+
8
+ import torch
9
+
10
+ try:
11
+ import transformers # noqa: F401
12
+ except ModuleNotFoundError:
13
+ stub = types.ModuleType('transformers')
14
+ stub.AutoModelForCausalLM = type('AutoModelForCausalLM', (), {})
15
+ stub.AutoTokenizer = type('AutoTokenizer', (), {})
16
+ sys.modules['transformers'] = stub
17
+
18
+ from featurelens.config import Settings
19
+ from featurelens.runtime import FeatureLensRuntime
20
+ from featurelens.sae import SAEWeights
21
+
22
+
23
+ class FakeTokenizer:
24
+ pad_token_id = 0
25
+ eos_token_id = 0
26
+ eos_token = '<pad>'
27
+ pad_token = '<pad>'
28
+ padding_side = 'left'
29
+
30
+ @staticmethod
31
+ def _ids(text: str) -> list[int]:
32
+ # Keep 0 reserved for padding; make deterministic small-vocabulary ids.
33
+ return [1 + (ord(char) % 7) for char in text] or [1]
34
+
35
+ def __call__(
36
+ self,
37
+ text,
38
+ *,
39
+ return_tensors=None,
40
+ padding=False,
41
+ truncation=False,
42
+ max_length=None,
43
+ add_special_tokens=True,
44
+ ):
45
+ if isinstance(text, str):
46
+ ids = self._ids(text)
47
+ if max_length is not None:
48
+ ids = ids[-int(max_length) :]
49
+ if return_tensors == 'pt':
50
+ tensor = torch.tensor([ids], dtype=torch.long)
51
+ return {'input_ids': tensor, 'attention_mask': torch.ones_like(tensor)}
52
+ return {'input_ids': ids}
53
+
54
+ sequences = [self._ids(item) for item in text]
55
+ if max_length is not None:
56
+ sequences = [ids[-int(max_length) :] for ids in sequences]
57
+ width = max(len(ids) for ids in sequences)
58
+ padded = []
59
+ masks = []
60
+ for ids in sequences:
61
+ pad = width - len(ids)
62
+ padded.append([0] * pad + ids)
63
+ masks.append([0] * pad + [1] * len(ids))
64
+ return {
65
+ 'input_ids': torch.tensor(padded, dtype=torch.long),
66
+ 'attention_mask': torch.tensor(masks, dtype=torch.long),
67
+ }
68
+
69
+ def decode(self, ids) -> str:
70
+ token_id = int(ids[0])
71
+ return '<pad>' if token_id == 0 else f't{token_id}'
72
+
73
+
74
+ class FakeBackbone(torch.nn.Module):
75
+ def __init__(self, d_model: int) -> None:
76
+ super().__init__()
77
+ self.layers = torch.nn.ModuleList([torch.nn.Identity()])
78
+ self.embedding = torch.nn.Embedding(8, d_model)
79
+ with torch.no_grad():
80
+ self.embedding.weight.copy_(
81
+ torch.tensor(
82
+ [
83
+ [0.0, 0.0, 0.0],
84
+ [1.0, 0.2, 0.1],
85
+ [0.8, 0.3, 0.2],
86
+ [0.6, 0.4, 0.3],
87
+ [0.4, 0.5, 0.4],
88
+ [0.3, 0.6, 0.5],
89
+ [0.2, 0.7, 0.6],
90
+ [0.1, 0.8, 0.7],
91
+ ]
92
+ )
93
+ )
94
+
95
+
96
+ class FakeLM(torch.nn.Module):
97
+ def __init__(self) -> None:
98
+ super().__init__()
99
+ self.model = FakeBackbone(d_model=3)
100
+ self.proj = torch.nn.Linear(3, 8, bias=False)
101
+ with torch.no_grad():
102
+ self.proj.weight.copy_(
103
+ torch.tensor(
104
+ [
105
+ [0.0, 0.0, 0.0],
106
+ [1.0, 0.0, 0.0],
107
+ [0.0, 1.0, 0.0],
108
+ [0.0, 0.0, 1.0],
109
+ [0.5, 0.5, 0.0],
110
+ [0.5, 0.0, 0.5],
111
+ [0.0, 0.5, 0.5],
112
+ [-0.4, 0.3, 0.2],
113
+ ]
114
+ )
115
+ )
116
+
117
+ def forward(self, input_ids, attention_mask=None, use_cache=False):
118
+ hidden = self.model.embedding(input_ids)
119
+ for layer in self.model.layers:
120
+ hidden = layer(hidden)
121
+ return SimpleNamespace(logits=self.proj(hidden))
122
+
123
+
124
+ class FakeSAEStore:
125
+ def __init__(self) -> None:
126
+ self.sae = SAEWeights(
127
+ layer=0,
128
+ w_enc_t=torch.tensor(
129
+ [
130
+ [1.0, 0.0, 0.3, -0.2],
131
+ [0.0, 1.0, 0.2, 0.1],
132
+ [0.0, 0.0, 1.0, 0.4],
133
+ ]
134
+ ),
135
+ w_dec=torch.tensor(
136
+ [
137
+ [1.0, 0.0, 0.5, -0.2],
138
+ [0.0, 1.0, 0.2, 0.3],
139
+ [0.0, 0.0, 1.0, 0.4],
140
+ ]
141
+ ),
142
+ b_enc=torch.zeros(4),
143
+ b_dec=torch.zeros(3),
144
+ top_k=2,
145
+ )
146
+
147
+ def get(self, layer: int):
148
+ assert layer == 0
149
+ return self.sae
150
+
151
+ def preload(self) -> None:
152
+ return None
153
+
154
+
155
+ def make_runtime() -> FeatureLensRuntime:
156
+ settings = Settings(
157
+ layers=(0,),
158
+ sae_top_k=2,
159
+ sae_width=4,
160
+ d_model=3,
161
+ max_prompt_tokens=32,
162
+ live_random_controls=3,
163
+ contrast_prompts_per_concept=2,
164
+ eager_load=False,
165
+ sae_dtype='float32',
166
+ )
167
+ runtime = FeatureLensRuntime(settings)
168
+ runtime.device = torch.device('cpu')
169
+ runtime.model = FakeLM().eval()
170
+ runtime.tokenizer = FakeTokenizer()
171
+ runtime.sae_store = FakeSAEStore()
172
+ return runtime
173
+
174
+
175
+ def test_feature_token_trace_runs_end_to_end_on_toy_runtime() -> None:
176
+ runtime = make_runtime()
177
+ result = runtime.feature_token_trace('abc', layer=0, feature_id=0)
178
+ assert result.token_count == 3
179
+ assert len(result.rows) == 3
180
+ assert result.active_token_count >= 1
181
+ assert result.max_activation > 0
182
+
183
+
184
+ def test_feature_geometry_runs_end_to_end_on_toy_runtime() -> None:
185
+ runtime = make_runtime()
186
+ result = runtime.feature_geometry('abc', layer=0, token_index=-1, feature_ids=[0, 1, 2])
187
+ assert len(result.rows) == 3 # 3 choose 2
188
+ assert math.isfinite(result.alignment_ratio)
189
+ assert result.independent_norm >= 0
190
+
191
+
192
+ def test_contrastive_intervention_runs_end_to_end_on_toy_runtime() -> None:
193
+ runtime = make_runtime()
194
+ result = runtime.contrastive_intervention(
195
+ text='abc',
196
+ layer=0,
197
+ token_index=-1,
198
+ feature_id=0,
199
+ mode='ablate',
200
+ coefficient=0.0,
201
+ target_a='a',
202
+ target_b='b',
203
+ )
204
+ assert len(result.rows) == 2
205
+ assert result.random_control_count == 3
206
+ assert math.isfinite(result.delta_log_odds)
207
+ assert math.isfinite(result.specificity_ratio)
208
+
209
+
210
+ def test_concept_contrast_promptwide_scan_runs_on_toy_runtime() -> None:
211
+ runtime = make_runtime()
212
+ result = runtime.concept_contrast_scan(feature_id=0, layer=0, prompts_per_concept=1)
213
+ assert result.total_prompt_count == 7
214
+ assert len(result.rows) == 7
215
+ assert all(len(row) == 7 for row in result.rows)
216
+ assert 0 <= result.active_prompt_count <= result.total_prompt_count
tests/test_metrics.py CHANGED
@@ -75,3 +75,40 @@ def test_sparse_topk_cosine_is_one_for_identical_sparse_vectors() -> None:
75
  def test_sparse_topk_cosine_is_zero_for_disjoint_support() -> None:
76
  cosine = sparse_topk_cosine([1, 3], [2.0, 1.0], [2, 4], [5.0, 7.0])
77
  assert cosine == 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  def test_sparse_topk_cosine_is_zero_for_disjoint_support() -> None:
76
  cosine = sparse_topk_cosine([1, 3], [2.0, 1.0], [2, 4], [5.0, 7.0])
77
  assert cosine == 0.0
78
+
79
+
80
+ def test_contrastive_log_odds_reports_preference_shift() -> None:
81
+ from featurelens.metrics import contrastive_log_odds
82
+
83
+ baseline, modified, delta = contrastive_log_odds(
84
+ baseline_a=-4.0,
85
+ modified_a=-3.5,
86
+ baseline_b=-2.0,
87
+ modified_b=-2.2,
88
+ )
89
+ assert math.isclose(baseline, -2.0)
90
+ assert math.isclose(modified, -1.3)
91
+ assert math.isclose(delta, 0.7)
92
+
93
+
94
+ def test_decoder_cosine_matrix_and_joint_norm_ratio() -> None:
95
+ from featurelens.metrics import decoder_cosine_matrix, joint_direction_norm_ratio
96
+
97
+ orthogonal = torch.tensor([[1.0, 0.0], [0.0, 2.0]])
98
+ matrix = decoder_cosine_matrix(orthogonal)
99
+ assert torch.allclose(matrix, torch.eye(2), atol=1e-6)
100
+
101
+ joint_norm, independent_norm, ratio = joint_direction_norm_ratio(orthogonal)
102
+ assert math.isclose(joint_norm, math.sqrt(5.0), rel_tol=1e-6)
103
+ assert math.isclose(independent_norm, math.sqrt(5.0), rel_tol=1e-6)
104
+ assert math.isclose(ratio, 1.0, rel_tol=1e-6)
105
+
106
+
107
+ def test_joint_norm_ratio_detects_cancellation() -> None:
108
+ from featurelens.metrics import joint_direction_norm_ratio
109
+
110
+ opposing = torch.tensor([[1.0, 0.0], [-1.0, 0.0]])
111
+ joint_norm, independent_norm, ratio = joint_direction_norm_ratio(opposing)
112
+ assert math.isclose(joint_norm, 0.0, abs_tol=1e-8)
113
+ assert independent_norm > 0
114
+ assert math.isclose(ratio, 0.0, abs_tol=1e-8)
tests/test_ui_helpers.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import types
5
+ from types import SimpleNamespace
6
+
7
+ import pandas as pd
8
+
9
+
10
+ def _import_app():
11
+ try:
12
+ import transformers # noqa: F401
13
+ except ModuleNotFoundError:
14
+ stub = types.ModuleType('transformers')
15
+ stub.AutoModelForCausalLM = type('AutoModelForCausalLM', (), {})
16
+ stub.AutoTokenizer = type('AutoTokenizer', (), {})
17
+ sys.modules['transformers'] = stub
18
+ import app
19
+
20
+ return app
21
+
22
+
23
+ def test_concept_metrics_do_not_invent_a_leader_for_all_zero_scan() -> None:
24
+ app = _import_app()
25
+ result = SimpleNamespace(
26
+ feature_id=22632,
27
+ layer=14,
28
+ prompts_per_concept=4,
29
+ active_prompt_count=0,
30
+ total_prompt_count=28,
31
+ leading_concept=None,
32
+ leading_ratio=None,
33
+ )
34
+ text = app._concept_metrics_markdown(result)
35
+ assert 'inactive in every sampled prompt' in text
36
+ assert 'Highest prompt-wide mean activation' not in text
37
+
38
+
39
+ def test_tsv_copy_payload_keeps_headers() -> None:
40
+ app = _import_app()
41
+ frame = pd.DataFrame([[1, 2.0]], columns=['Feature id', 'Activation'])
42
+ payload = app._tsv(frame)
43
+ assert payload.startswith('Feature id\tActivation\n')
44
+ assert payload.endswith('1\t2.0\n')