ArchitSharma commited on
Commit
463bcbe
·
1 Parent(s): 393bb89

Release FeatureLens v0.8.0

Browse files
CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
  # Changelog
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  ## v0.7.0
4
 
5
  ### Research-instrument UI cleanup
 
1
  # Changelog
2
 
3
+ ## v0.8.0
4
+
5
+ ### UI readability and focus
6
+ - Replaced plot-native fullscreen behavior with a **bounded FeatureLens focus overlay**. The plot is copied into a centered reading surface (max ~1120 px) instead of stretching across an ultrawide display; closing the overlay restores the original page position.
7
+ - Kept descriptive plot export filenames and the Gradio Dataframe fullscreen control.
8
+ - Replaced fragile native Dataframe labels with explicit **result-table headings** above every major table so table titles follow the same typography hierarchy as the rest of the application.
9
+ - Added a muted cue palette to the cue × context plot instead of relying on Gradio/Vega default saturated series colors.
10
+
11
+ ### Independent experiment inputs
12
+ - The scale dose-response panel now owns its **Dose-response feature id** and **Dose-response target continuation**. It no longer depends on running the single-feature causal test or filling that section's optional target field first.
13
+ - Clarified in-panel provenance: dose response reads the current prompt/layer/token fields from Workbench Section I but is otherwise a standalone experiment.
14
+
15
+ ### Candidate discovery
16
+ - Added **Causal-ready at current token** ranking. It requires positive concept contrast *and* activation at the selected Workbench token, then ranks those compatible candidates using balanced selectivity plus a log-scaled current-token activation term.
17
+ - Discovery summaries now report how many displayed candidates are actually active at the selected Workbench token. This makes the distinction between a prompt-wide concept candidate and an immediately ablatable feature explicit.
18
+ - Balanced selectivity and raw mean-difference modes remain available for methodological comparison.
19
+
20
+ ### Cue specificity
21
+ - Cue × context summaries now derive the dominant cue, its context coverage, and off-dominant activity. A feature that fires for one cue in every tested context while all other cues stay inactive is reported as a **cue-dominant tested pattern**, not merely with generic interpretation text.
22
+
23
+ ### Validation
24
+ - Expanded the automated suite to cover causal-ready candidate discovery, independent dose-response target state, cue-dominance diagnostics, plot-focus JavaScript markers, and explicit result-heading behavior.
25
+ - Retained compile, Ruff, actual Gradio `launch()`, release-check, and deferred final adversarial-suite gates.
26
+
27
  ## v0.7.0
28
 
29
  ### Research-instrument UI cleanup
README.md CHANGED
@@ -13,7 +13,7 @@ license: mit
13
 
14
  # FeatureLens — Causal Interpretability Workbench
15
 
16
- > **v0.7:** a cleaner research-instrument UI, bounded focus mode for tables/plots, descriptive chart exports, German-language controls, stronger concept-candidate ranking with current-prompt compatibility, click-to-select candidate handoff, and a cue × context specificity diagnostic—while retaining the causal, robustness, geometry, and prompt-wide evidence tools.
17
 
18
  **Research question:**
19
 
@@ -97,7 +97,8 @@ The main workflow supports:
97
  - exact **full-continuation teacher-forced scoring**;
98
  - per-target-token log-probability decomposition;
99
  - 8-direction norm-matched random controls;
100
- - batched scale dose-response.
 
101
 
102
  ### Feature sets
103
 
@@ -345,6 +346,13 @@ FeatureLens/
345
  └── research_config.json
346
  ```
347
 
 
 
 
 
 
 
 
348
  ## Validation
349
 
350
  ```bash
@@ -355,7 +363,7 @@ python scripts/ui_smoke.py
355
  python scripts/release_check.py
356
  ```
357
 
358
- See [`docs/VALIDATION.md`](docs/VALIDATION.md). The v0.7 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.
359
 
360
  ## Limitations
361
 
@@ -370,7 +378,7 @@ See [`docs/VALIDATION.md`](docs/VALIDATION.md). The v0.7 validation guide uses t
370
 
371
  ## Resume-ready description
372
 
373
- > **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
374
  > Built an SAE-based interpretability system for Qwen3-1.7B with held-out concept discovery, concept-guided candidate discovery, token/prompt-wide, completion-cue, and cue × context 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.
375
 
376
  ## Acknowledgements
 
13
 
14
  # FeatureLens — Causal Interpretability Workbench
15
 
16
+ > **v0.8:** a cleaner evidence workflow with bounded plot focus, explicit table headings, standalone dose-response inputs, a causal-ready current-token candidate ranking, and data-driven cue-dominant specificity summaries—while retaining balanced/raw discovery, causal controls, robustness, geometry, and prompt-wide evidence.
17
 
18
  **Research question:**
19
 
 
97
  - exact **full-continuation teacher-forced scoring**;
98
  - per-target-token log-probability decomposition;
99
  - 8-direction norm-matched random controls;
100
+ - **standalone** batched scale dose-response with its own feature id and target continuation;
101
+ - bounded plot focus views that do not stretch across ultrawide displays.
102
 
103
  ### Feature sets
104
 
 
346
  └── research_config.json
347
  ```
348
 
349
+ ## v0.8 live workflow improvements
350
+
351
+ - **Causal-ready candidate ranking** requires concept contrast and current-token activity before suggesting a feature for immediate intervention.
352
+ - **Standalone dose-response** has independent feature and target inputs; Section II does not need to run first.
353
+ - **Cue × context interpretation** reports cue dominance from the measured activation matrix rather than generic boilerplate.
354
+ - **Plot focus** uses a bounded overlay and preserves page position on close; table titles are explicit headings instead of relying on Gradio component labels.
355
+
356
  ## Validation
357
 
358
  ```bash
 
363
  python scripts/release_check.py
364
  ```
365
 
366
+ See [`docs/VALIDATION.md`](docs/VALIDATION.md). The v0.8 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.
367
 
368
  ## Limitations
369
 
 
378
 
379
  ## Resume-ready description
380
 
381
+ > **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
382
  > Built an SAE-based interpretability system for Qwen3-1.7B with held-out concept discovery, concept-guided candidate discovery, token/prompt-wide, completion-cue, and cue × context 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.
383
 
384
  ## Acknowledgements
app.py CHANGED
@@ -121,6 +121,13 @@ CSS = r"""
121
  }
122
  .start-card h3 { margin-top: 0; }
123
  .graph-note { font-size: .95rem; opacity: .72; margin-top: 2px; }
 
 
 
 
 
 
 
124
  .result-table .label-wrap,
125
  .result-table .label-wrap span,
126
  .result-table label,
@@ -141,19 +148,60 @@ CSS = r"""
141
  font-weight: 700 !important;
142
  line-height: 1.25 !important;
143
  }
144
- /* Native fullscreen is converted into a bounded focus surface. Tables may be wider; plots stay
145
- narrower so chart labels can be read without scanning across an entire monitor. */
146
- .fl-plot.fullscreen {
147
- top: 6vh !important;
148
- left: 50% !important;
149
- transform: translateX(-50%) !important;
150
- width: min(90vw, 1120px) !important;
151
- height: 88vh !important;
152
- padding: 18px 22px 22px !important;
153
- border-radius: 4px !important;
154
- overflow: auto !important;
155
- box-shadow: 0 0 0 100vmax rgba(0, 0, 0, .66), 0 14px 40px rgba(0, 0, 0, .35) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  .result-table.fullscreen {
158
  top: 5vh !important;
159
  left: 50% !important;
@@ -165,7 +213,6 @@ CSS = r"""
165
  overflow: auto !important;
166
  box-shadow: 0 0 0 100vmax rgba(0, 0, 0, .66), 0 14px 40px rgba(0, 0, 0, .35) !important;
167
  }
168
- .fl-plot.fullscreen svg { max-width: 100% !important; }
169
  .candidate-help { opacity: .78; font-size: .97rem; margin-top: -2px; }
170
  @media (max-width: 900px) {
171
  .gradio-container { width: 100% !important; padding-left: 12px !important; padding-right: 12px !important; }
@@ -222,9 +269,7 @@ INSTALL_REFLOW_JS = r"""
222
  let timer = null;
223
  const kick = () => {
224
  window.clearTimeout(timer);
225
- timer = window.setTimeout(() => {
226
- window.dispatchEvent(new Event("resize"));
227
- }, 80);
228
  };
229
  const root = document.querySelector(".gradio-container") || document.body;
230
  if (window.ResizeObserver) {
@@ -236,33 +281,94 @@ INSTALL_REFLOW_JS = r"""
236
  mutation.observe(root, {subtree: true, childList: true});
237
  window.__featurelens_mutation_observer = mutation;
238
 
239
- // Keep the page anchored when entering/exiting native fullscreen. Gradio toggles fullscreen on the
240
- // same component node, so FeatureLens can focus the component without navigating away from its source location.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  document.addEventListener("click", (event) => {
242
  const button = event.target && event.target.closest ? event.target.closest("button") : null;
243
  if (!button) return;
244
  const label = `${button.getAttribute("aria-label") || ""} ${button.getAttribute("title") || ""} ${button.textContent || ""}`.toLowerCase();
245
  if (!label.includes("fullscreen")) return;
246
- const block = button.closest(".fl-plot, .result-table");
247
- if (!block) return;
248
- const entering = !block.classList.contains("fullscreen");
249
- if (entering) {
250
- window.__featurelens_focus_scroll_y = window.scrollY;
251
- window.__featurelens_focus_block = block;
252
- window.setTimeout(() => {
253
- block.scrollTop = 0;
254
- if (window.__featurelens_focus_scroll_y != null) window.scrollTo(0, window.__featurelens_focus_scroll_y);
255
- }, 80);
256
- } else {
257
- const y = window.__featurelens_focus_scroll_y;
258
- window.setTimeout(() => {
259
- if (y != null) window.scrollTo(0, y);
260
- window.__featurelens_focus_scroll_y = null;
261
- window.__featurelens_focus_block = null;
262
- }, 80);
263
- }
264
  }, true);
265
 
 
 
 
 
266
  // Gradio's native plot exporter currently emits chart.png. Remember which FeatureLens plot initiated
267
  // the export, then rename the transient download anchor before the browser handles it.
268
  document.addEventListener("click", (event) => {
@@ -305,6 +411,10 @@ def _copy_button(label: str = "Copy table with headers") -> gr.Button:
305
  return gr.Button(label, size="sm", variant="primary", elem_classes=["copy-btn"])
306
 
307
 
 
 
 
 
308
  def _copy_ack(_text: str) -> None:
309
  gr.Info("Copied table with headers.", duration=1.2)
310
 
@@ -515,15 +625,21 @@ def _contrastive_metrics_markdown(result) -> str:
515
 
516
  def _discovery_metrics_markdown(result) -> str:
517
  if not result.candidate_ids:
 
 
 
 
 
 
518
  return (
519
  f"No positively selective candidate features were found for **{result.concept}** at layer "
520
  f"**{result.layer}** in this small live batch. Increase the sample or try another layer."
521
  )
522
- ranking = (
523
- "balanced selectivity × coverage × log-magnitude"
524
- if result.ranking_mode == "balanced_selectivity"
525
- else "raw target-minus-other mean activation"
526
- )
527
  context = (
528
  f"Current Workbench token **{result.current_token_index}** was checked in the same model batch. "
529
  "The default candidate prefers the highest-ranked feature active at that token when one exists."
@@ -534,6 +650,8 @@ def _discovery_metrics_markdown(result) -> str:
534
  f"Concept **{result.concept}** · layer **{result.layer}** · "
535
  f"{result.prompts_per_concept} prompts/concept. \n"
536
  f"Showing **{len(result.candidate_ids)}** candidates ranked by **{ranking}**. \n"
 
 
537
  f"{context} \n\n"
538
  "This is **candidate discovery**, not a semantic label. The offline held-out AUROC/F1 pipeline remains "
539
  "the standard for a concept-selectivity claim."
@@ -545,13 +663,27 @@ def _cue_context_metrics_markdown(result) -> str:
545
  f"{cue}: {count}/{len(result.stems)} contexts"
546
  for cue, count in result.cue_active_context_counts.items()
547
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
548
  return (
549
  f"Feature **{result.feature_id}**, layer **{result.layer}** · active in "
550
  f"**{result.active_condition_count}/{result.condition_count}** stem × cue conditions. \n"
551
  f"Cue coverage — {active}. \n\n"
552
- "A cue that activates across unrelated stems looks more lexical/cue-specific; activation only in a subset "
553
- "of semantically related stems is more consistent with context-sensitive completion structure. This is still "
554
- "a diagnostic, not a semantic label."
555
  )
556
 
557
 
@@ -1033,11 +1165,11 @@ def run_concept_feature_discovery(
1033
  workbench_token_index: int,
1034
  ):
1035
  try:
1036
- ranking_mode = (
1037
- "balanced_selectivity"
1038
- if ranking_label == "Balanced selectivity"
1039
- else "raw_mean_difference"
1040
- )
1041
  result = RUNTIME.concept_feature_discovery(
1042
  concept=concept,
1043
  layer=int(layer),
@@ -1154,7 +1286,7 @@ def set_mode_help(mode: str):
1154
  with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_width=True) as demo:
1155
  gr.HTML(
1156
  '<header class="hero">'
1157
- '<h1>FeatureLens <span style="font-size:.48em;opacity:.58;font-weight:400">v0.7</span></h1>'
1158
  '<div class="subtitle">Causal Interpretability Workbench</div>'
1159
  '<div class="question">Discover sparse features, test robustness, and separate correlation from causal influence.</div>'
1160
  '</header>'
@@ -1241,11 +1373,12 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1241
  analysis_metrics = gr.Markdown()
1242
  with gr.Row(equal_height=False):
1243
  with gr.Column(scale=3):
 
1244
  feature_table = gr.Dataframe(
1245
  headers=["Rank", "Feature id", "Activation", "Offline concept hint"],
1246
  datatype=["number", "number", "number", "str"],
1247
  interactive=False,
1248
- label="Strongest active SAE features",
1249
  wrap=False,
1250
  max_height=380,
1251
  buttons=["fullscreen"], elem_classes=["result-table"],
@@ -1307,18 +1440,20 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1307
  with gr.Row():
1308
  baseline_out = gr.Textbox(label="Baseline greedy generation", lines=6, interactive=False)
1309
  modified_out = gr.Textbox(label="SAE-edited greedy generation", lines=6, interactive=False)
 
1310
  token_prob_table = gr.Dataframe(
1311
  interactive=False,
1312
- label="Next-token distribution shift",
1313
  buttons=["fullscreen"], elem_classes=["result-table"],
1314
  wrap=False,
1315
  max_height=380,
1316
  )
1317
  token_prob_tsv = gr.Textbox(visible="hidden")
1318
  token_prob_copy = _copy_button()
 
1319
  target_token_table = gr.Dataframe(
1320
  interactive=False,
1321
- label="Target continuation token-by-token score",
1322
  buttons=["fullscreen"], elem_classes=["result-table"],
1323
  wrap=False,
1324
  max_height=380,
@@ -1329,24 +1464,34 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1329
  gr.HTML('<div class="section-rule">III. Single-feature scale dose-response</div>')
1330
  with gr.Group():
1331
  gr.Markdown("**Uses current Workbench prompt / layer / token.** Choose the feature for this experiment below.")
1332
- dose_feature_id = gr.Dropdown(
1333
- choices=[],
1334
- allow_custom_value=True,
1335
- label="Dose-response feature id",
1336
- info="Populated from the latest Workbench inspection; editable here.",
1337
- )
 
 
 
 
 
 
 
 
1338
  gr.Markdown(
 
 
1339
  "Always a **scale** experiment: 0× = ablation, 1× = numerical no-edit reference, 2× = double "
1340
- "the native coefficient. The six conditions are evaluated together, and all deltas are measured "
1341
- "against the 1× row from that same batch."
1342
  )
1343
  dose_btn = gr.Button("Run scale dose-response", variant="primary", elem_classes=["action-btn"])
1344
  dose_metrics = gr.Markdown()
1345
  with gr.Row(equal_height=False):
1346
  with gr.Column(scale=3):
 
1347
  dose_table = gr.Dataframe(
1348
  interactive=False,
1349
- label="Scale dose-response measurements",
1350
  buttons=["fullscreen"], elem_classes=["result-table"],
1351
  wrap=False,
1352
  max_height=380,
@@ -1402,9 +1547,10 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1402
  contrastive_metrics = gr.Markdown()
1403
  with gr.Row(equal_height=False):
1404
  with gr.Column(scale=3):
 
1405
  contrastive_table = gr.Dataframe(
1406
  interactive=False,
1407
- label="Contrastive continuation scores",
1408
  buttons=["fullscreen"], elem_classes=["result-table"],
1409
  wrap=False,
1410
  max_height=320,
@@ -1469,9 +1615,10 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1469
  set_metrics = gr.Markdown()
1470
  with gr.Row(equal_height=False):
1471
  with gr.Column(scale=2):
 
1472
  set_feature_table = gr.Dataframe(
1473
  interactive=False,
1474
- label="Joint intervention features",
1475
  buttons=["fullscreen"], elem_classes=["result-table"],
1476
  wrap=False,
1477
  max_height=380,
@@ -1479,9 +1626,10 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1479
  set_feature_tsv = gr.Textbox(visible="hidden")
1480
  set_feature_copy = _copy_button()
1481
  with gr.Column(scale=3):
 
1482
  set_target_table = gr.Dataframe(
1483
  interactive=False,
1484
- label="Target continuation token-by-token score",
1485
  buttons=["fullscreen"], elem_classes=["result-table"],
1486
  wrap=False,
1487
  max_height=380,
@@ -1499,9 +1647,10 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1499
  set_sweep_note = gr.Markdown()
1500
  with gr.Row(equal_height=False):
1501
  with gr.Column(scale=3):
 
1502
  set_sweep_table = gr.Dataframe(
1503
  interactive=False,
1504
- label="Feature-set size measurements",
1505
  buttons=["fullscreen"], elem_classes=["result-table"],
1506
  wrap=False,
1507
  max_height=380,
@@ -1535,9 +1684,10 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1535
  interaction_metrics = gr.Markdown()
1536
  with gr.Row(equal_height=False):
1537
  with gr.Column(scale=3):
 
1538
  interaction_table = gr.Dataframe(
1539
  interactive=False,
1540
- label="Individual and joint ablation measurements",
1541
  buttons=["fullscreen"], elem_classes=["result-table"],
1542
  wrap=False,
1543
  max_height=380,
@@ -1572,9 +1722,10 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1572
  geometry_metrics = gr.Markdown()
1573
  with gr.Row(equal_height=False):
1574
  with gr.Column(scale=3):
 
1575
  geometry_table = gr.Dataframe(
1576
  interactive=False,
1577
- label="Pairwise decoder geometry",
1578
  buttons=["fullscreen"], elem_classes=["result-table"],
1579
  wrap=False,
1580
  max_height=340,
@@ -1623,10 +1774,10 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1623
  discovery_top_n = gr.Slider(5, 20, value=12, step=1, label="Candidate features")
1624
  with gr.Row(equal_height=True):
1625
  discovery_ranking = gr.Dropdown(
1626
- choices=["Balanced selectivity", "Raw mean difference"],
1627
  value="Balanced selectivity",
1628
  label="Candidate ranking",
1629
- info="Balanced selectivity is the recommended exploratory ranking; raw mean difference reproduces the earlier scale-dominated ordering.",
1630
  scale=2,
1631
  )
1632
  gr.Markdown(
@@ -1637,8 +1788,9 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1637
  discovery_metrics = gr.Markdown()
1638
  with gr.Row(equal_height=False):
1639
  with gr.Column(scale=3):
 
1640
  discovery_table = gr.Dataframe(
1641
- interactive=False, label="Candidate feature evidence", buttons=["fullscreen"], elem_classes=["result-table"],
1642
  wrap=False, max_height=420
1643
  )
1644
  discovery_tsv = gr.Textbox(visible="hidden")
@@ -1697,9 +1849,10 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1697
  trace_metrics = gr.Markdown()
1698
  with gr.Row(equal_height=False):
1699
  with gr.Column(scale=3):
 
1700
  trace_table = gr.Dataframe(
1701
  interactive=False,
1702
- label="Feature activation by prompt token",
1703
  buttons=["fullscreen"], elem_classes=["result-table"],
1704
  wrap=False,
1705
  max_height=340,
@@ -1733,7 +1886,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1733
  cue_metrics = gr.Markdown()
1734
  with gr.Row(equal_height=False):
1735
  with gr.Column(scale=3):
1736
- cue_table = gr.Dataframe(interactive=False, label="Feature response by completion cue", buttons=["fullscreen"], elem_classes=["result-table"], wrap=False, max_height=340)
 
1737
  cue_tsv = gr.Textbox(visible="hidden")
1738
  cue_copy = _copy_button()
1739
  with gr.Column(scale=2):
@@ -1765,9 +1919,10 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1765
  cue_context_metrics = gr.Markdown()
1766
  with gr.Row(equal_height=False):
1767
  with gr.Column(scale=3):
 
1768
  cue_context_table = gr.Dataframe(
1769
  interactive=False,
1770
- label="Cue × context feature response",
1771
  buttons=["fullscreen"],
1772
  elem_classes=["result-table"],
1773
  wrap=False,
@@ -1780,6 +1935,13 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1780
  x="Prompt stem",
1781
  y="Activation",
1782
  color="Cue",
 
 
 
 
 
 
 
1783
  title="Cue response across contexts",
1784
  elem_id="plot-cue-context-matrix",
1785
  x_title="Prompt stem",
@@ -1796,9 +1958,10 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1796
  contrast_metrics = gr.Markdown()
1797
  with gr.Row(equal_height=False):
1798
  with gr.Column(scale=3):
 
1799
  contrast_table = gr.Dataframe(
1800
  interactive=False,
1801
- label="Feature activation by controlled concept",
1802
  buttons=["fullscreen"], elem_classes=["result-table"],
1803
  wrap=False,
1804
  max_height=380,
@@ -1857,9 +2020,10 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1857
  para_metrics = gr.Markdown()
1858
  with gr.Row(equal_height=False):
1859
  with gr.Column(scale=3):
 
1860
  para_table = gr.Dataframe(
1861
  interactive=False,
1862
- label="Top-feature overlap at selected tokens",
1863
  buttons=["fullscreen"], elem_classes=["result-table"],
1864
  wrap=False,
1865
  max_height=380,
@@ -1904,9 +2068,10 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
1904
  trajectory_tokens = gr.HTML()
1905
  with gr.Row(equal_height=False):
1906
  with gr.Column(scale=3):
 
1907
  trajectory_table = gr.Dataframe(
1908
  interactive=False,
1909
- label="Layer diagnostics",
1910
  buttons=["fullscreen"], elem_classes=["result-table"],
1911
  wrap=False,
1912
  max_height=380,
@@ -2018,7 +2183,7 @@ Association, robustness, geometry, and intervention evidence remain separate cla
2018
  )
2019
  dose_btn.click(
2020
  run_dose_response,
2021
- inputs=[prompt, layer, token_index, dose_feature_id, target_text],
2022
  outputs=[dose_table, dose_plot, dose_metrics, dose_tsv],
2023
  )
2024
  contrastive_mode.change(mode_help, inputs=[contrastive_mode], outputs=[contrastive_coefficient])
@@ -2127,4 +2292,4 @@ if __name__ == "__main__":
2127
  theme=THEME,
2128
  ssr_mode=False,
2129
  show_error=True,
2130
- )
 
121
  }
122
  .start-card h3 { margin-top: 0; }
123
  .graph-note { font-size: .95rem; opacity: .72; margin-top: 2px; }
124
+ .table-heading { margin: 4px 0 7px !important; }
125
+ .table-heading h4, .table-heading p {
126
+ font-size: 1.24rem !important;
127
+ font-weight: 700 !important;
128
+ line-height: 1.28 !important;
129
+ margin: 0 !important;
130
+ }
131
  .result-table .label-wrap,
132
  .result-table .label-wrap span,
133
  .result-table label,
 
148
  font-weight: 700 !important;
149
  line-height: 1.25 !important;
150
  }
151
+ /* Plot focus is handled by a FeatureLens overlay instead of Gradio native fullscreen.
152
+ The original page never moves, and the focused chart is bounded for readability. */
153
+ #featurelens-plot-focus-overlay {
154
+ position: fixed;
155
+ inset: 0;
156
+ z-index: 100000;
157
+ background: rgba(8, 8, 8, .82);
158
+ display: flex;
159
+ align-items: flex-start;
160
+ justify-content: center;
161
+ padding: 5vh 3vw;
162
+ box-sizing: border-box;
163
+ }
164
+ .featurelens-focus-panel {
165
+ width: min(92vw, 1120px);
166
+ max-height: 90vh;
167
+ overflow: auto;
168
+ background: var(--background-fill-primary);
169
+ border: 1px solid var(--border-color-primary);
170
+ border-radius: 4px;
171
+ box-shadow: 0 14px 46px rgba(0, 0, 0, .45);
172
+ padding: 14px 18px 18px;
173
+ }
174
+ .featurelens-focus-toolbar {
175
+ display: flex;
176
+ align-items: center;
177
+ justify-content: space-between;
178
+ margin-bottom: 8px;
179
+ }
180
+ .featurelens-focus-title { font-size: 1.18rem; font-weight: 700; }
181
+ .featurelens-focus-close {
182
+ min-width: 36px !important;
183
+ min-height: 32px !important;
184
+ background: var(--background-fill-secondary) !important;
185
+ color: var(--body-text-color) !important;
186
+ border: 1px solid var(--border-color-primary) !important;
187
  }
188
+ .featurelens-focus-chart {
189
+ width: 100%;
190
+ display: flex;
191
+ align-items: flex-start;
192
+ justify-content: center;
193
+ overflow: hidden;
194
+ }
195
+ .featurelens-focus-chart svg,
196
+ .featurelens-focus-chart canvas {
197
+ display: block;
198
+ width: 100% !important;
199
+ max-width: 1080px !important;
200
+ height: auto !important;
201
+ max-height: 78vh !important;
202
+ object-fit: contain;
203
+ }
204
+ /* Keep Gradio Dataframe fullscreen available; tables genuinely benefit from width. */
205
  .result-table.fullscreen {
206
  top: 5vh !important;
207
  left: 50% !important;
 
213
  overflow: auto !important;
214
  box-shadow: 0 0 0 100vmax rgba(0, 0, 0, .66), 0 14px 40px rgba(0, 0, 0, .35) !important;
215
  }
 
216
  .candidate-help { opacity: .78; font-size: .97rem; margin-top: -2px; }
217
  @media (max-width: 900px) {
218
  .gradio-container { width: 100% !important; padding-left: 12px !important; padding-right: 12px !important; }
 
269
  let timer = null;
270
  const kick = () => {
271
  window.clearTimeout(timer);
272
+ timer = window.setTimeout(() => window.dispatchEvent(new Event("resize")), 80);
 
 
273
  };
274
  const root = document.querySelector(".gradio-container") || document.body;
275
  if (window.ResizeObserver) {
 
281
  mutation.observe(root, {subtree: true, childList: true});
282
  window.__featurelens_mutation_observer = mutation;
283
 
284
+ const closePlotFocus = () => {
285
+ const overlay = document.getElementById("featurelens-plot-focus-overlay");
286
+ if (!overlay) return;
287
+ overlay.remove();
288
+ document.body.style.overflow = window.__featurelens_body_overflow || "";
289
+ const y = window.__featurelens_plot_scroll_y;
290
+ window.__featurelens_plot_scroll_y = null;
291
+ window.__featurelens_body_overflow = null;
292
+ if (y != null) window.scrollTo(0, y);
293
+ };
294
+
295
+ const readableTitle = (block) => {
296
+ const id = (block && block.id) || "plot-featurelens-chart";
297
+ return id.replace(/^plot-/, "").replace(/[-_]+/g, " ")
298
+ .replace(/\b\w/g, (letter) => letter.toUpperCase());
299
+ };
300
+
301
+ const openPlotFocus = (block) => {
302
+ closePlotFocus();
303
+ window.__featurelens_plot_scroll_y = window.scrollY;
304
+ window.__featurelens_body_overflow = document.body.style.overflow || "";
305
+ document.body.style.overflow = "hidden";
306
+
307
+ const overlay = document.createElement("div");
308
+ overlay.id = "featurelens-plot-focus-overlay";
309
+ const panel = document.createElement("div");
310
+ panel.className = "featurelens-focus-panel";
311
+ const toolbar = document.createElement("div");
312
+ toolbar.className = "featurelens-focus-toolbar";
313
+ const title = document.createElement("div");
314
+ title.className = "featurelens-focus-title";
315
+ title.textContent = readableTitle(block);
316
+ const close = document.createElement("button");
317
+ close.className = "featurelens-focus-close";
318
+ close.type = "button";
319
+ close.setAttribute("aria-label", "Close chart focus view");
320
+ close.textContent = "Close";
321
+ toolbar.append(title, close);
322
+
323
+ const chart = document.createElement("div");
324
+ chart.className = "featurelens-focus-chart";
325
+ const sourceSvg = block.querySelector("svg");
326
+ const sourceCanvas = block.querySelector("canvas");
327
+ if (sourceSvg) {
328
+ const clone = sourceSvg.cloneNode(true);
329
+ clone.removeAttribute("width");
330
+ clone.removeAttribute("height");
331
+ chart.appendChild(clone);
332
+ } else if (sourceCanvas) {
333
+ const canvas = document.createElement("canvas");
334
+ canvas.width = sourceCanvas.width;
335
+ canvas.height = sourceCanvas.height;
336
+ const context = canvas.getContext("2d");
337
+ if (context) context.drawImage(sourceCanvas, 0, 0);
338
+ chart.appendChild(canvas);
339
+ } else {
340
+ const fallback = block.cloneNode(true);
341
+ fallback.querySelectorAll("button").forEach((node) => node.remove());
342
+ fallback.removeAttribute("id");
343
+ chart.appendChild(fallback);
344
+ }
345
+ panel.append(toolbar, chart);
346
+ overlay.appendChild(panel);
347
+ document.body.appendChild(overlay);
348
+ close.addEventListener("click", closePlotFocus);
349
+ overlay.addEventListener("click", (event) => {
350
+ if (event.target === overlay) closePlotFocus();
351
+ });
352
+ };
353
+
354
+ // Intercept Gradio's plot fullscreen button before the native handler. Native fullscreen stretches
355
+ // Vega plots across the monitor; FeatureLens uses a bounded, static focus copy instead.
356
  document.addEventListener("click", (event) => {
357
  const button = event.target && event.target.closest ? event.target.closest("button") : null;
358
  if (!button) return;
359
  const label = `${button.getAttribute("aria-label") || ""} ${button.getAttribute("title") || ""} ${button.textContent || ""}`.toLowerCase();
360
  if (!label.includes("fullscreen")) return;
361
+ const plot = button.closest(".fl-plot");
362
+ if (!plot) return;
363
+ event.preventDefault();
364
+ event.stopImmediatePropagation();
365
+ openPlotFocus(plot);
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  }, true);
367
 
368
+ document.addEventListener("keydown", (event) => {
369
+ if (event.key === "Escape") closePlotFocus();
370
+ });
371
+
372
  // Gradio's native plot exporter currently emits chart.png. Remember which FeatureLens plot initiated
373
  // the export, then rename the transient download anchor before the browser handles it.
374
  document.addEventListener("click", (event) => {
 
411
  return gr.Button(label, size="sm", variant="primary", elem_classes=["copy-btn"])
412
 
413
 
414
+ def _table_heading(text: str) -> gr.Markdown:
415
+ return gr.Markdown(f"#### {text}", elem_classes=["table-heading"])
416
+
417
+
418
  def _copy_ack(_text: str) -> None:
419
  gr.Info("Copied table with headers.", duration=1.2)
420
 
 
625
 
626
  def _discovery_metrics_markdown(result) -> str:
627
  if not result.candidate_ids:
628
+ if result.ranking_mode == "causal_ready":
629
+ return (
630
+ f"No positively selective **current-token-active** candidates were found for **{result.concept}** "
631
+ f"at layer **{result.layer}** in this live batch. Try another token/layer or use Balanced selectivity "
632
+ "to discover concept-associated features elsewhere in the prompt."
633
+ )
634
  return (
635
  f"No positively selective candidate features were found for **{result.concept}** at layer "
636
  f"**{result.layer}** in this small live batch. Increase the sample or try another layer."
637
  )
638
+ ranking = {
639
+ "balanced_selectivity": "balanced selectivity × coverage × log-magnitude",
640
+ "raw_mean_difference": "raw target-minus-other mean activation",
641
+ "causal_ready": "causal-ready evidence at the current Workbench token",
642
+ }[result.ranking_mode]
643
  context = (
644
  f"Current Workbench token **{result.current_token_index}** was checked in the same model batch. "
645
  "The default candidate prefers the highest-ranked feature active at that token when one exists."
 
650
  f"Concept **{result.concept}** · layer **{result.layer}** · "
651
  f"{result.prompts_per_concept} prompts/concept. \n"
652
  f"Showing **{len(result.candidate_ids)}** candidates ranked by **{ranking}**. \n"
653
+ f"Displayed candidates active at the selected Workbench token: "
654
+ f"**{result.displayed_current_active_count}/{len(result.candidate_ids)}**. \n"
655
  f"{context} \n\n"
656
  "This is **candidate discovery**, not a semantic label. The offline held-out AUROC/F1 pipeline remains "
657
  "the standard for a concept-selectivity claim."
 
663
  f"{cue}: {count}/{len(result.stems)} contexts"
664
  for cue, count in result.cue_active_context_counts.items()
665
  )
666
+ interpretation = (
667
+ "No tested cue activated the feature."
668
+ if result.dominant_cue is None or result.active_condition_count == 0
669
+ else (
670
+ f"**Cue-dominant pattern:** `{result.dominant_cue}` activates in every tested context while all "
671
+ "other tested cues are inactive. Under this matrix, the evidence is much more consistent with a "
672
+ "lexical/cue-specific response than with a mathematics-specific response."
673
+ if result.dominant_cue_context_count == len(result.stems)
674
+ and result.off_dominant_active_count == 0
675
+ else (
676
+ f"The strongest cue is `{result.dominant_cue}` ({result.dominant_cue_context_count}/{len(result.stems)} "
677
+ "contexts), but other cues or context dependence remain. Treat the pattern as mixed/context-sensitive."
678
+ )
679
+ )
680
+ )
681
  return (
682
  f"Feature **{result.feature_id}**, layer **{result.layer}** · active in "
683
  f"**{result.active_condition_count}/{result.condition_count}** stem × cue conditions. \n"
684
  f"Cue coverage — {active}. \n\n"
685
+ f"{interpretation} \n\n"
686
+ "This is a controlled diagnostic over the tested stems/cues, not a universal semantic label."
 
687
  )
688
 
689
 
 
1165
  workbench_token_index: int,
1166
  ):
1167
  try:
1168
+ ranking_mode = {
1169
+ "Balanced selectivity": "balanced_selectivity",
1170
+ "Raw mean difference": "raw_mean_difference",
1171
+ "Causal-ready at current token": "causal_ready",
1172
+ }[ranking_label]
1173
  result = RUNTIME.concept_feature_discovery(
1174
  concept=concept,
1175
  layer=int(layer),
 
1286
  with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_width=True) as demo:
1287
  gr.HTML(
1288
  '<header class="hero">'
1289
+ '<h1>FeatureLens <span style="font-size:.48em;opacity:.58;font-weight:400">v0.8</span></h1>'
1290
  '<div class="subtitle">Causal Interpretability Workbench</div>'
1291
  '<div class="question">Discover sparse features, test robustness, and separate correlation from causal influence.</div>'
1292
  '</header>'
 
1373
  analysis_metrics = gr.Markdown()
1374
  with gr.Row(equal_height=False):
1375
  with gr.Column(scale=3):
1376
+ _table_heading('Strongest active SAE features')
1377
  feature_table = gr.Dataframe(
1378
  headers=["Rank", "Feature id", "Activation", "Offline concept hint"],
1379
  datatype=["number", "number", "number", "str"],
1380
  interactive=False,
1381
+ label="Strongest active SAE features", show_label=False,
1382
  wrap=False,
1383
  max_height=380,
1384
  buttons=["fullscreen"], elem_classes=["result-table"],
 
1440
  with gr.Row():
1441
  baseline_out = gr.Textbox(label="Baseline greedy generation", lines=6, interactive=False)
1442
  modified_out = gr.Textbox(label="SAE-edited greedy generation", lines=6, interactive=False)
1443
+ _table_heading('Next-token distribution shift')
1444
  token_prob_table = gr.Dataframe(
1445
  interactive=False,
1446
+ label="Next-token distribution shift", show_label=False,
1447
  buttons=["fullscreen"], elem_classes=["result-table"],
1448
  wrap=False,
1449
  max_height=380,
1450
  )
1451
  token_prob_tsv = gr.Textbox(visible="hidden")
1452
  token_prob_copy = _copy_button()
1453
+ _table_heading('Target continuation token-by-token score')
1454
  target_token_table = gr.Dataframe(
1455
  interactive=False,
1456
+ label="Target continuation token-by-token score", show_label=False,
1457
  buttons=["fullscreen"], elem_classes=["result-table"],
1458
  wrap=False,
1459
  max_height=380,
 
1464
  gr.HTML('<div class="section-rule">III. Single-feature scale dose-response</div>')
1465
  with gr.Group():
1466
  gr.Markdown("**Uses current Workbench prompt / layer / token.** Choose the feature for this experiment below.")
1467
+ with gr.Row(equal_height=True):
1468
+ dose_feature_id = gr.Dropdown(
1469
+ choices=[],
1470
+ allow_custom_value=True,
1471
+ label="Dose-response feature id",
1472
+ info="Populated from Workbench inspection, or enter any valid feature id directly.",
1473
+ scale=2,
1474
+ )
1475
+ dose_target_text = gr.Textbox(
1476
+ label="Dose-response target continuation",
1477
+ value="2x",
1478
+ info="Independent of the Single-feature causal test target field.",
1479
+ scale=2,
1480
+ )
1481
  gr.Markdown(
1482
+ "**Standalone experiment:** you do not need to run Section II first. This panel reads the prompt, "
1483
+ "layer, and token fields from Section I and uses its own feature + target fields above. "
1484
  "Always a **scale** experiment: 0× = ablation, 1× = numerical no-edit reference, 2× = double "
1485
+ "the native coefficient. The six conditions are evaluated together against the row."
 
1486
  )
1487
  dose_btn = gr.Button("Run scale dose-response", variant="primary", elem_classes=["action-btn"])
1488
  dose_metrics = gr.Markdown()
1489
  with gr.Row(equal_height=False):
1490
  with gr.Column(scale=3):
1491
+ _table_heading('Scale dose-response measurements')
1492
  dose_table = gr.Dataframe(
1493
  interactive=False,
1494
+ label="Scale dose-response measurements", show_label=False,
1495
  buttons=["fullscreen"], elem_classes=["result-table"],
1496
  wrap=False,
1497
  max_height=380,
 
1547
  contrastive_metrics = gr.Markdown()
1548
  with gr.Row(equal_height=False):
1549
  with gr.Column(scale=3):
1550
+ _table_heading('Contrastive continuation scores')
1551
  contrastive_table = gr.Dataframe(
1552
  interactive=False,
1553
+ label="Contrastive continuation scores", show_label=False,
1554
  buttons=["fullscreen"], elem_classes=["result-table"],
1555
  wrap=False,
1556
  max_height=320,
 
1615
  set_metrics = gr.Markdown()
1616
  with gr.Row(equal_height=False):
1617
  with gr.Column(scale=2):
1618
+ _table_heading('Joint intervention features')
1619
  set_feature_table = gr.Dataframe(
1620
  interactive=False,
1621
+ label="Joint intervention features", show_label=False,
1622
  buttons=["fullscreen"], elem_classes=["result-table"],
1623
  wrap=False,
1624
  max_height=380,
 
1626
  set_feature_tsv = gr.Textbox(visible="hidden")
1627
  set_feature_copy = _copy_button()
1628
  with gr.Column(scale=3):
1629
+ _table_heading('Target continuation token-by-token score')
1630
  set_target_table = gr.Dataframe(
1631
  interactive=False,
1632
+ label="Target continuation token-by-token score", show_label=False,
1633
  buttons=["fullscreen"], elem_classes=["result-table"],
1634
  wrap=False,
1635
  max_height=380,
 
1647
  set_sweep_note = gr.Markdown()
1648
  with gr.Row(equal_height=False):
1649
  with gr.Column(scale=3):
1650
+ _table_heading('Feature-set size measurements')
1651
  set_sweep_table = gr.Dataframe(
1652
  interactive=False,
1653
+ label="Feature-set size measurements", show_label=False,
1654
  buttons=["fullscreen"], elem_classes=["result-table"],
1655
  wrap=False,
1656
  max_height=380,
 
1684
  interaction_metrics = gr.Markdown()
1685
  with gr.Row(equal_height=False):
1686
  with gr.Column(scale=3):
1687
+ _table_heading('Individual and joint ablation measurements')
1688
  interaction_table = gr.Dataframe(
1689
  interactive=False,
1690
+ label="Individual and joint ablation measurements", show_label=False,
1691
  buttons=["fullscreen"], elem_classes=["result-table"],
1692
  wrap=False,
1693
  max_height=380,
 
1722
  geometry_metrics = gr.Markdown()
1723
  with gr.Row(equal_height=False):
1724
  with gr.Column(scale=3):
1725
+ _table_heading('Pairwise decoder geometry')
1726
  geometry_table = gr.Dataframe(
1727
  interactive=False,
1728
+ label="Pairwise decoder geometry", show_label=False,
1729
  buttons=["fullscreen"], elem_classes=["result-table"],
1730
  wrap=False,
1731
  max_height=340,
 
1774
  discovery_top_n = gr.Slider(5, 20, value=12, step=1, label="Candidate features")
1775
  with gr.Row(equal_height=True):
1776
  discovery_ranking = gr.Dropdown(
1777
+ choices=["Balanced selectivity", "Causal-ready at current token", "Raw mean difference"],
1778
  value="Balanced selectivity",
1779
  label="Candidate ranking",
1780
+ info="Balanced selectivity finds concept-associated candidates; Causal-ready restricts to features active at the selected Workbench token; raw mean difference exposes scale-dominated ranking.",
1781
  scale=2,
1782
  )
1783
  gr.Markdown(
 
1788
  discovery_metrics = gr.Markdown()
1789
  with gr.Row(equal_height=False):
1790
  with gr.Column(scale=3):
1791
+ _table_heading('Candidate feature evidence')
1792
  discovery_table = gr.Dataframe(
1793
+ interactive=False, label="Candidate feature evidence", show_label=False, buttons=["fullscreen"], elem_classes=["result-table"],
1794
  wrap=False, max_height=420
1795
  )
1796
  discovery_tsv = gr.Textbox(visible="hidden")
 
1849
  trace_metrics = gr.Markdown()
1850
  with gr.Row(equal_height=False):
1851
  with gr.Column(scale=3):
1852
+ _table_heading('Feature activation by prompt token')
1853
  trace_table = gr.Dataframe(
1854
  interactive=False,
1855
+ label="Feature activation by prompt token", show_label=False,
1856
  buttons=["fullscreen"], elem_classes=["result-table"],
1857
  wrap=False,
1858
  max_height=340,
 
1886
  cue_metrics = gr.Markdown()
1887
  with gr.Row(equal_height=False):
1888
  with gr.Column(scale=3):
1889
+ _table_heading('Feature response by completion cue')
1890
+ cue_table = gr.Dataframe(interactive=False, label="Feature response by completion cue", show_label=False, buttons=["fullscreen"], elem_classes=["result-table"], wrap=False, max_height=340)
1891
  cue_tsv = gr.Textbox(visible="hidden")
1892
  cue_copy = _copy_button()
1893
  with gr.Column(scale=2):
 
1919
  cue_context_metrics = gr.Markdown()
1920
  with gr.Row(equal_height=False):
1921
  with gr.Column(scale=3):
1922
+ _table_heading('Cue × context feature response')
1923
  cue_context_table = gr.Dataframe(
1924
  interactive=False,
1925
+ label="Cue × context feature response", show_label=False,
1926
  buttons=["fullscreen"],
1927
  elem_classes=["result-table"],
1928
  wrap=False,
 
1935
  x="Prompt stem",
1936
  y="Activation",
1937
  color="Cue",
1938
+ color_map={
1939
+ "is": INK_TEAL,
1940
+ "=": INK_UMBER,
1941
+ ":": INK_RED,
1942
+ "equals": INK_PLUM,
1943
+ "therefore": INK_STONE,
1944
+ },
1945
  title="Cue response across contexts",
1946
  elem_id="plot-cue-context-matrix",
1947
  x_title="Prompt stem",
 
1958
  contrast_metrics = gr.Markdown()
1959
  with gr.Row(equal_height=False):
1960
  with gr.Column(scale=3):
1961
+ _table_heading('Feature activation by controlled concept')
1962
  contrast_table = gr.Dataframe(
1963
  interactive=False,
1964
+ label="Feature activation by controlled concept", show_label=False,
1965
  buttons=["fullscreen"], elem_classes=["result-table"],
1966
  wrap=False,
1967
  max_height=380,
 
2020
  para_metrics = gr.Markdown()
2021
  with gr.Row(equal_height=False):
2022
  with gr.Column(scale=3):
2023
+ _table_heading('Top-feature overlap at selected tokens')
2024
  para_table = gr.Dataframe(
2025
  interactive=False,
2026
+ label="Top-feature overlap at selected tokens", show_label=False,
2027
  buttons=["fullscreen"], elem_classes=["result-table"],
2028
  wrap=False,
2029
  max_height=380,
 
2068
  trajectory_tokens = gr.HTML()
2069
  with gr.Row(equal_height=False):
2070
  with gr.Column(scale=3):
2071
+ _table_heading('Layer diagnostics')
2072
  trajectory_table = gr.Dataframe(
2073
  interactive=False,
2074
+ label="Layer diagnostics", show_label=False,
2075
  buttons=["fullscreen"], elem_classes=["result-table"],
2076
  wrap=False,
2077
  max_height=380,
 
2183
  )
2184
  dose_btn.click(
2185
  run_dose_response,
2186
+ inputs=[prompt, layer, token_index, dose_feature_id, dose_target_text],
2187
  outputs=[dose_table, dose_plot, dose_metrics, dose_tsv],
2188
  )
2189
  contrastive_mode.change(mode_help, inputs=[contrastive_mode], outputs=[contrastive_coefficient])
 
2292
  theme=THEME,
2293
  ssr_mode=False,
2294
  show_error=True,
2295
+ )
docs/METHODOLOGY.md CHANGED
@@ -391,3 +391,29 @@ S_f = \max(0,\mathrm{selectivity}_f) \cdot \mathrm{target\ rate}_f \cdot \log(1
391
  $$
392
 
393
  This ranking is still exploratory. It is designed to triage candidates, not replace held-out AUROC/F1. The same forward pass optionally includes the current Workbench prompt, allowing the candidate table to report current-prompt maximum activation and selected-token activation. A candidate can therefore be concept-associated in the live batch but visibly inactive at the current causal location.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  $$
392
 
393
  This ranking is still exploratory. It is designed to triage candidates, not replace held-out AUROC/F1. The same forward pass optionally includes the current Workbench prompt, allowing the candidate table to report current-prompt maximum activation and selected-token activation. A candidate can therefore be concept-associated in the live batch but visibly inactive at the current causal location.
394
+
395
+ ## v0.8 causal-ready candidate mode
396
+
397
+ Prompt-wide concept association and immediate causal usability are different constraints. A feature may rank highly for a concept across the controlled prompt set while having zero activation at the selected Workbench token, in which case ablation at that location is a no-op.
398
+
399
+ The **Causal-ready at current token** mode therefore requires:
400
+
401
+ ```text
402
+ target_mean > 0
403
+ mean_difference > 0
404
+ current_token_activation > 0
405
+ ```
406
+
407
+ and ranks eligible features using the balanced score multiplied by a log-scaled current-token activation term:
408
+
409
+ $$
410
+ S_f^{\mathrm{ready}} = S_f \cdot \log(1 + z_f^{\mathrm{current\ token}}).
411
+ $$
412
+
413
+ The log factor makes current-token presence matter without allowing a single very large coefficient to dominate as strongly as a raw activation product would. This remains an exploratory triage score, not held-out concept evidence.
414
+
415
+ ## v0.8 cue-dominance summary
416
+
417
+ The cue × context matrix now summarizes the measured activation pattern rather than always returning generic interpretation text. For each cue, FeatureLens counts the number of tested contexts in which the feature is active and computes mean activation across those contexts.
418
+
419
+ A particularly strong tested cue-specific pattern occurs when one cue activates in every tested context and all other tested cues remain inactive. FeatureLens describes that pattern as **cue-dominant under the tested matrix**. The wording is intentionally local to the controlled stems and cues; it does not assert a universal semantic label for the SAE feature.
docs/VALIDATION.md CHANGED
@@ -1,59 +1,34 @@
1
- # FeatureLens v0.7 validation
2
 
3
- This guide tests the **v0.7 changes** plus a small number of high-value regressions. Use the exact UI labels below. The comprehensive edge/adversarial suite remains deferred until final hardening.
4
 
5
- ## A. Local release gates
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
- 45 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.7.0
28
- ```
29
-
30
- ## B. UI cleanup, table typography, focus mode, and export names
31
-
32
- 1. Open **Workbench**.
33
- 2. Confirm these are normal always-visible sections, **not collapsible dropdowns/accordions**:
34
- - **III. Single-feature scale dose-response**
35
- - **IV. Contrastive continuation preference**
36
- 3. Confirm there is only one experiment heading before each section. A plot title such as `Scale dose-response` is fine; there should not be a second accordion heading with the same text.
37
- 4. After any result table appears, confirm its table title (for example **Strongest active SAE features**) is visually larger/bolder than ordinary body text, and column headings are clearly readable.
38
- 5. Produce a plot and click its top-right **fullscreen/focus** icon.
39
- - The focused plot should open near the top-center of the viewport.
40
- - It should be bounded rather than stretching across the entire monitor.
41
- - Axis labels should remain readable without needing to pan horizontally across a huge plot.
42
- 6. Exit focus mode. You should return to approximately the same page location you were at before opening it.
43
- 7. Repeat the focus test on one wide result table.
44
- 8. Click the plot **export** icon. Verify the downloaded filename is descriptive, for example:
45
-
46
- ```text
47
- featurelens_activation-profile.png
48
- featurelens_scale-dose-response.png
49
  ```
50
 
51
- It should not be the generic `chart.png`.
52
- 9. Click **Copy table with headers** and confirm the temporary `✓ Copied with headers` feedback still appears.
53
-
54
- ## C. Establish the shared Workbench context
55
 
56
- In **Workbench → I. Inspect a prompt location** set:
57
 
58
  ```text
59
  Prompt: The derivative of x squared is
@@ -64,40 +39,57 @@ Displayed active features: 12
64
 
65
  Click **Inspect sparse features**.
66
 
67
- Expected regression values are approximately:
 
 
 
68
 
69
- ```text
70
- selected token: 5 (' is')
71
- feature 22632 activation: 32.90625
72
- reconstruction cosine: 0.9015
73
- NMSE: 0.1874
74
- Top-5 activation mass: 26.7%
75
- ```
76
 
77
- The persistent **Current Workbench context** banner must state the inspected prompt, layer 14, and token 5. It should remain visible when switching tabs.
 
 
 
 
 
78
 
79
- ## D. German-language control regression
80
 
81
- Open **Feature evidence → A. Concept-guided candidate discovery** and inspect **Target concept**.
 
82
 
83
- Pass conditions:
84
 
85
- - `german_language` is available;
86
- - `french_language` is not available.
87
 
88
- The controlled examples in **Workbench** should also include a German example such as:
 
 
89
 
90
  ```text
91
- Ich möchte einen Tisch für zwei reservieren.
 
92
  ```
93
 
94
- No GPU run is required for this check.
 
 
 
 
 
 
 
 
 
 
95
 
96
- ## E. Balanced concept-guided candidate discovery
97
 
98
- Open **Feature evidence A. Concept-guided candidate discovery**.
99
 
100
- Set:
101
 
102
  ```text
103
  Target concept: mathematics
@@ -107,73 +99,76 @@ Candidate features: 12
107
  Candidate ranking: Balanced selectivity
108
  ```
109
 
110
- Keep the Workbench context from Test C and click **Discover concept-associated candidates**.
111
 
112
- Verify that **Candidate feature evidence** includes these fields:
 
 
 
 
 
 
 
 
 
113
 
114
  ```text
115
- Rank
116
- Feature id
117
- Candidate score
118
- Target mean max
119
- Other mean max
120
- Mean difference
121
- Selectivity
122
- Target activation rate
123
- Other activation rate
124
- Current prompt max
125
- Current token activation
126
- Active at current token
127
  ```
128
 
129
- Interpretation check:
130
 
131
- - `Candidate score` is an exploratory balanced score, not a semantic label.
132
- - Globally huge but weakly selective features should no longer automatically dominate merely because their raw coefficients are enormous.
133
- - `Current prompt max` and `Current token activation` show whether a candidate can immediately be tested in the current Workbench context.
134
- - **Selected candidate feature id** should default to the highest-ranked displayed candidate active at the current Workbench token when such a candidate exists; otherwise it may default to the top-ranked candidate.
135
 
136
- Record the top 12 rows. These are the most scientifically useful new v0.7 results.
137
 
138
- ### E2. Raw-ranking comparison
139
 
140
- Without changing the other fields, set:
141
 
142
  ```text
143
  Candidate ranking: Raw mean difference
144
  ```
145
 
146
- and rerun **Discover concept-associated candidates**.
147
 
148
- This should reproduce the scale-sensitive style of ranking used previously. The purpose of this test is to verify that v0.7 exposes the methodological choice explicitly instead of silently changing it.
 
 
149
 
150
- You do **not** need to investigate all 12 raw-ranking features causally.
151
 
152
- ## F. Candidate row selection and explicit reuse
153
 
154
- After Test E:
155
 
156
- 1. Click a row in **Candidate feature evidence**. The row's feature id should appear in **Selected candidate feature id**.
157
- 2. Click **Use selected candidate across feature tests**.
158
- 3. A visible confirmation should say that the feature was loaded.
159
- 4. Verify the same id appears in:
160
- - **Workbench → Single feature id**;
161
- - **Workbench → Dose-response feature id**;
162
- - **Workbench → Contrastive feature id**;
163
- - **Feature evidence → Feature id**.
164
 
165
- This is a state-routing operation and should not consume another GPU call.
 
 
 
 
 
 
 
166
 
167
- ## G. Completion-cue regression for feature 22632
168
 
169
- Open **Feature evidence → C. Completion-cue sensitivity**.
170
-
171
- Set:
172
 
173
  ```text
174
  Feature id: 22632
175
  Residual layer: 14
176
- Prompt stem: The derivative of x squared
 
 
 
 
 
177
  Completion cues:
178
  is
179
  =
@@ -182,101 +177,75 @@ equals
182
  therefore
183
  ```
184
 
185
- Click **Run completion-cue scan**.
186
-
187
- The `is` condition reconstructs the original prompt and should remain approximately:
188
 
189
- ```text
190
- activation: 32.90625
191
- Active in TopK: True
192
- ```
 
193
 
194
- The previous reference result had zero activation for `=`, `:`, `equals`, and `therefore`. Treat that as a regression reference, not as a hard scientific requirement if tiny numerical differences appear.
195
 
196
- ## H. Cue × context specificity
197
 
198
- Open **Feature evidence → D. Cue × context specificity**.
199
 
200
- Use:
201
 
202
  ```text
203
- Feature id: 22632
204
- Residual layer: 14
205
 
206
- Prompt stems:
207
- The derivative of x squared
208
- The capital of Germany
209
- The weather today
210
- My name
211
 
212
- Completion cues:
213
- is
214
- =
215
- :
216
- equals
217
- therefore
218
- ```
219
 
220
- Click **Run cue × context scan**.
221
 
222
- Expected output:
223
 
224
- - 20 rows = 4 stems × 5 cues;
225
- - columns **Prompt stem**, **Cue**, **Full prompt**, **Final token**, **Activation**, **Active in TopK**;
226
- - a **Cue response across contexts** plot.
 
227
 
228
- Scientific question: does feature `22632` fire specifically on the literal `is`, on `is` across many contexts, on several completion-boundary cues, or only in the original mathematics context?
 
 
 
229
 
230
- Send the active rows and the per-cue context summary. This is the other key new v0.7 result.
231
 
232
- ## I. Prompt-wide selected-feature contrast with German control
233
 
234
- Still in **Feature evidence**, set:
235
 
236
  ```text
237
- Feature id: 22632
238
- Residual layer: 14
239
- Prompts per concept: 4
240
  ```
241
 
242
- Click **Run controlled concept contrast**.
243
-
244
- The result should contain `german_language` rather than `french_language` while preserving seven balanced concepts and 28 sampled prompts total.
245
-
246
- Do not expect the exact old French-based concept means to remain identical because one control group has intentionally changed.
247
-
248
- ## J. High-value existing regressions
249
-
250
- Only confirm these run; do not resend full tables unless something changes unexpectedly.
251
-
252
- 1. **Workbench → III. Single-feature scale dose-response** with feature `22632`, target `2x`:
253
- - multiplier `1.0` must remain the exact same-batch numerical null with all causal deltas equal to zero.
254
- 2. **Paraphrase robustness** default pair:
255
- - selected-token cosine should remain around `0.301`;
256
- - prompt-wide cosine should remain around `0.981`.
257
- 3. **Paraphrase robustness** identical-prompt control:
258
- - all four robustness metrics should remain `1.000`.
259
- 4. **Layer trajectory**:
260
- - layer 14 should remain the weakest reconstruction of layers 4/14/26 on the default prompt.
261
- 5. **Feature sets Set-size sensitivity Run 1/3/5-feature ablation sweep**:
262
- - this is the top-k causal sweep;
263
- - it is **not** the Workbench `Strongest active SAE features` table;
264
- - confirm k = 1, 3, 5 rows appear.
265
- 6. After dynamic results expand, scroll to the actual bottom without switching tabs. The v0.5 reflow fix should remain intact.
266
-
267
- ## K. Deferred final hardening
268
-
269
- Do not spend ZeroGPU quota on the complete adversarial suite yet. Preserve these for the final hardening release:
270
-
271
- - empty prompts;
272
- - out-of-range token indices;
273
- - feature ids `0`, `32767`, invalid `32768`;
274
- - inactive feature ablation vs injection;
275
- - whitespace-sensitive targets such as `2x` vs ` 2x`;
276
- - missing required target continuations;
277
- - unrelated paraphrases;
278
- - negation and mixed-language prompts;
279
- - long-prompt truncation;
280
- - rapid repeated clicks / queue behavior;
281
- - narrow/mobile-width layout;
282
- - final copy/focus/export sweep.
 
1
+ # FeatureLens v0.8 validation
2
 
3
+ This guide tests the v0.8 changes plus a small set of high-value regressions. Use the **exact UI labels** below. The full adversarial/edge suite remains deferred until final hardening.
4
 
5
+ ## 0. 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 -m ruff check app.py featurelens experiments tests scripts && \
13
+ python3 scripts/ui_smoke.py && \
14
  python3 scripts/release_check.py
15
  ```
16
 
17
+ Expected release footer:
18
 
19
  ```text
 
 
20
  FeatureLens release check: PASS
21
  discovery prompts: 224
22
  causal tasks: 28
23
  layers: [4, 14, 26]
24
  feature-set sizes: [1, 3, 5]
25
  random controls: 8
26
+ release: v0.8.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  ```
28
 
29
+ ## 1. Establish the canonical Workbench context
 
 
 
30
 
31
+ Open **Workbench → I. Inspect a prompt location** and set:
32
 
33
  ```text
34
  Prompt: The derivative of x squared is
 
39
 
40
  Click **Inspect sparse features**.
41
 
42
+ Pass conditions:
43
+ - **Current Workbench context** reports layer 14 and token 5 (`' is'`).
44
+ - **Strongest active SAE features** begins with feature `22632` at about `32.90625`.
45
+ - Result-table titles such as **Strongest active SAE features** are visually larger than table body text and are not rendered as tiny native component labels.
46
 
47
+ ## 2. Plot focus-view regression
48
+
49
+ On **Activation profile**, click the top-right fullscreen/focus icon.
 
 
 
 
50
 
51
+ Pass conditions:
52
+ - FeatureLens opens a darkened overlay with a centered chart rather than Gradio's stretched native fullscreen view.
53
+ - The chart reading surface is bounded to roughly desktop-document width; it should not require multiple horizontal scrolls.
54
+ - The focused chart starts at its own top.
55
+ - Press **Close**, click the backdrop, or press **Esc**.
56
+ - The page returns to exactly the same Workbench scroll position.
57
 
58
+ Then click the export icon.
59
 
60
+ Pass condition:
61
+ - the filename is descriptive (for example `featurelens_activation-profile.png`), not `chart.png`.
62
 
63
+ Repeat the focus-view check on one long-label plot such as **Cue response across contexts**.
64
 
65
+ ## 3. Standalone scale dose-response
 
66
 
67
+ Do **not** run **II. Single-feature causal test** first.
68
+
69
+ Under **III. Single-feature scale dose-response**, set:
70
 
71
  ```text
72
+ Dose-response feature id: 22632
73
+ Dose-response target continuation: 2x
74
  ```
75
 
76
+ Click **Run scale dose-response**.
77
+
78
+ Pass conditions:
79
+ - The experiment runs without using the optional target field from Section II.
80
+ - No `Enter a target continuation...` error occurs.
81
+ - The 1× row is the exact numerical null:
82
+ - Δ feature coefficient = 0
83
+ - Perturbation L2 = 0
84
+ - Δ mean log p/token = 0
85
+ - Δ sequence log p = 0
86
+ - Next-token JS = 0
87
 
88
+ The expected qualitative curve from prior Qwen runs is positive under ablation and negative at stronger amplification; exact floating-point values may vary slightly.
89
 
90
+ ## 4. Candidate discovery Balanced selectivity
91
 
92
+ Open **Feature evidence → A. Concept-guided candidate discovery** and set:
93
 
94
  ```text
95
  Target concept: mathematics
 
99
  Candidate ranking: Balanced selectivity
100
  ```
101
 
102
+ Click **Discover concept-associated candidates**.
103
 
104
+ Pass conditions:
105
+ - **Candidate feature evidence** has an explicit readable heading.
106
+ - The result summary states how many displayed candidates are active at the selected Workbench token.
107
+ - The ranking is selectivity-oriented rather than dominated by giant globally active features.
108
+
109
+ Record the top 12 rows.
110
+
111
+ ## 5. Candidate discovery — Causal-ready at current token
112
+
113
+ Change only:
114
 
115
  ```text
116
+ Candidate ranking: Causal-ready at current token
 
 
 
 
 
 
 
 
 
 
 
117
  ```
118
 
119
+ Click **Discover concept-associated candidates**.
120
 
121
+ Pass conditions:
122
+ - Every returned row has **Active at current token = True**.
123
+ - The result summary reports `displayed/current-active = N/N`.
124
+ - If no positively selective current-token-active feature exists, the UI reports that clearly instead of silently falling back to a prompt-wide candidate.
125
 
126
+ This mode is intentionally stricter than Balanced selectivity: it asks for a candidate that is both concept-associated in the live batch **and immediately ablatable at the selected Workbench token**.
127
 
128
+ ## 6. Candidate discovery — Raw mean difference
129
 
130
+ Change only:
131
 
132
  ```text
133
  Candidate ranking: Raw mean difference
134
  ```
135
 
136
+ Click **Discover concept-associated candidates**.
137
 
138
+ Pass conditions:
139
+ - large-scale features such as the previously observed `31205`, `28345`, or other globally high-activation directions may return near the top;
140
+ - this visibly demonstrates why raw activation difference is a scale-sensitive baseline rather than the recommended discovery ranking.
141
 
142
+ ## 7. Candidate handoff
143
 
144
+ Return to **Balanced selectivity** or **Causal-ready at current token**.
145
 
146
+ Click a row in **Candidate feature evidence**, then click:
147
 
148
+ **Use selected candidate across feature tests**
 
 
 
 
 
 
 
149
 
150
+ Pass conditions:
151
+ - the status message names the selected feature;
152
+ - the feature propagates to:
153
+ - **Single feature id**
154
+ - **Dose-response feature id**
155
+ - **Contrastive feature id**
156
+ - **Feature evidence → Feature id**
157
+ - no GPU job is launched by the handoff action itself.
158
 
159
+ ## 8. Cue × context specificity for feature 22632
160
 
161
+ Under **Feature evidence → D. Cue × context specificity**, use:
 
 
162
 
163
  ```text
164
  Feature id: 22632
165
  Residual layer: 14
166
+ Prompt stems:
167
+ The derivative of x squared
168
+ The capital of Germany
169
+ The weather today
170
+ My name
171
+
172
  Completion cues:
173
  is
174
  =
 
177
  therefore
178
  ```
179
 
180
+ Click **Run cue × context scan**.
 
 
181
 
182
+ Prior live evidence found `is` active in all 4 contexts and every other cue inactive. If that reproduces, pass conditions are:
183
+ - summary explicitly calls the tested matrix a **cue-dominant pattern**;
184
+ - `is: 4/4 contexts`;
185
+ - all off-cue conditions remain inactive;
186
+ - the plot uses the restrained FeatureLens cue palette rather than saturated default blue/orange/red/green colors.
187
 
188
+ Scientific interpretation: this is evidence that `22632` is much more consistent with an `is`-specific lexical/structural response under the tested matrix than with a mathematics-specific feature. It remains a controlled diagnostic, not a universal feature label.
189
 
190
+ ## 9. High-value regressions
191
 
192
+ ### 9a. Paraphrase identity control
193
 
194
+ Open **Paraphrase robustness** and set both text fields to:
195
 
196
  ```text
197
+ The derivative of x squared is
198
+ ```
199
 
200
+ Use layer 14 and final token for both.
 
 
 
 
201
 
202
+ Expected:
203
+ - selected-token Jaccard = 1.000
204
+ - selected-token cosine = 1.000
205
+ - prompt-wide Jaccard = 1.000
206
+ - prompt-wide cosine = 1.000
 
 
207
 
208
+ ### 9b. Layer trajectory
209
 
210
+ Open **Layer trajectory** with:
211
 
212
+ ```text
213
+ Prompt: The derivative of x squared is
214
+ Prompt token index: -1
215
+ ```
216
 
217
+ Expected approximate regression values:
218
+ - layer 4 cosine ≈ 0.962
219
+ - layer 14 cosine ≈ 0.902
220
+ - layer 26 cosine ≈ 0.961
221
 
222
+ ### 9c. Feature-set sensitivity
223
 
224
+ Open **Feature sets → Set-size sensitivity**.
225
 
226
+ Set:
227
 
228
  ```text
229
+ Target continuation for set-size sweep: 2x
 
 
230
  ```
231
 
232
+ Click **Run 1/3/5-feature ablation sweep**.
233
+
234
+ Pass conditions:
235
+ - rows for k = 1, 3, 5;
236
+ - 8 random controls per k;
237
+ - all conditions share the batched null reference;
238
+ - no regression to a single arbitrary random control.
239
+
240
+ ## 10. What to send back
241
+
242
+ For v0.8 acceptance, send:
243
+ 1. whether the bounded plot focus view finally solves the ultrawide/stretch problem;
244
+ 2. whether closing focus returns to the same page position;
245
+ 3. whether table headings are finally readable;
246
+ 4. the **Causal-ready at current token** candidate table (or the explicit no-candidate message);
247
+ 5. the cue × context summary for `22632`;
248
+ 6. whether standalone dose response works without Section II;
249
+ 7. any unexpected regression from 9a–9c.
250
+
251
+ The comprehensive malformed-input, invalid-feature-id, long-prompt, queue, narrow-screen, and repeated-click adversarial suite is still reserved for final release hardening.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
featurelens/runtime.py CHANGED
@@ -240,6 +240,7 @@ class ConceptFeatureDiscoveryResult:
240
  default_candidate_id: int | None
241
  current_context_available: bool
242
  current_token_index: int | None
 
243
 
244
 
245
  @dataclass
@@ -264,6 +265,10 @@ class FeatureCueContextResult:
264
  active_condition_count: int
265
  condition_count: int
266
  cue_active_context_counts: dict[str, int]
 
 
 
 
267
 
268
 
269
  @dataclass
@@ -1803,9 +1808,10 @@ class FeatureLensRuntime:
1803
 
1804
  ``balanced_selectivity`` downweights globally high-activation features by combining target selectivity,
1805
  target coverage, and target activation magnitude. ``raw_mean_difference`` preserves the simpler raw
1806
- mean-difference ranking for comparison. If a Workbench prompt is supplied it is appended to the same
1807
- model batch, so
1808
- current-prompt compatibility does not require another forward pass.
 
1809
  """
1810
  self.ensure_ready(preload_saes=False)
1811
  assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
@@ -1818,8 +1824,10 @@ class FeatureLensRuntime:
1818
  top_n = int(top_n)
1819
  if top_n < 1 or top_n > 25:
1820
  raise ValueError('Number of candidate features must be between 1 and 25.')
1821
- if ranking_mode not in {'balanced_selectivity', 'raw_mean_difference'}:
1822
- raise ValueError("ranking_mode must be 'balanced_selectivity' or 'raw_mean_difference'.")
 
 
1823
 
1824
  rows = self._contrast_prompt_rows(n)
1825
  available = sorted({str(row['concept']) for row in rows})
@@ -1905,6 +1913,19 @@ class FeatureLensRuntime:
1905
  )
1906
 
1907
  eligible = (target_mean > 0) & (mean_diff > 0)
 
 
 
 
 
 
 
 
 
 
 
 
 
1908
  candidate_idx = torch.nonzero(eligible, as_tuple=False).flatten()
1909
  if candidate_idx.numel() == 0:
1910
  return ConceptFeatureDiscoveryResult(
@@ -1919,9 +1940,9 @@ class FeatureLensRuntime:
1919
  default_candidate_id=None,
1920
  current_context_available=current_context_available,
1921
  current_token_index=resolved_current_idx,
 
1922
  )
1923
 
1924
- ranking_values = balanced_score if ranking_mode == 'balanced_selectivity' else mean_diff
1925
  order = torch.argsort(ranking_values[candidate_idx], descending=True)
1926
  candidate_idx = candidate_idx[order[:top_n]]
1927
 
@@ -1967,6 +1988,7 @@ class FeatureLensRuntime:
1967
  default_candidate_id=default_candidate_id,
1968
  current_context_available=current_context_available,
1969
  current_token_index=resolved_current_idx,
 
1970
  )
1971
 
1972
  @staticmethod
@@ -2118,6 +2140,7 @@ class FeatureLensRuntime:
2118
  chart_rows: list[list[object]] = []
2119
  active_count = 0
2120
  cue_active_context_counts = {cue: 0 for cue in cue_list}
 
2121
  for row_idx, (stem, cue, full_prompt) in enumerate(conditions):
2122
  valid_positions = torch.nonzero(attention[row_idx], as_tuple=False).flatten()
2123
  final_pos = int(valid_positions[-1].item())
@@ -2128,12 +2151,25 @@ class FeatureLensRuntime:
2128
  active = activation > 0
2129
  active_count += int(active)
2130
  cue_active_context_counts[cue] += int(active)
 
2131
  token_id = int(batch['input_ids'][row_idx, final_pos].item())
2132
  final_token = self.tokenizer.decode([token_id])
2133
  short_stem = stem if len(stem) <= 42 else stem[:39] + '…'
2134
  rows.append([stem, cue, full_prompt, repr(final_token), activation, active])
2135
  chart_rows.append([short_stem, cue, activation])
2136
 
 
 
 
 
 
 
 
 
 
 
 
 
2137
  return FeatureCueContextResult(
2138
  feature_id=int(feature_id),
2139
  layer=int(layer),
@@ -2144,6 +2180,10 @@ class FeatureLensRuntime:
2144
  active_condition_count=active_count,
2145
  condition_count=len(conditions),
2146
  cue_active_context_counts=cue_active_context_counts,
 
 
 
 
2147
  )
2148
 
2149
 
 
240
  default_candidate_id: int | None
241
  current_context_available: bool
242
  current_token_index: int | None
243
+ displayed_current_active_count: int
244
 
245
 
246
  @dataclass
 
265
  active_condition_count: int
266
  condition_count: int
267
  cue_active_context_counts: dict[str, int]
268
+ cue_mean_activations: dict[str, float]
269
+ dominant_cue: str | None
270
+ dominant_cue_context_count: int
271
+ off_dominant_active_count: int
272
 
273
 
274
  @dataclass
 
1808
 
1809
  ``balanced_selectivity`` downweights globally high-activation features by combining target selectivity,
1810
  target coverage, and target activation magnitude. ``raw_mean_difference`` preserves the simpler raw
1811
+ mean-difference ranking for comparison. ``causal_ready`` further requires activation at the currently
1812
+ selected Workbench token and ranks those compatible candidates by balanced evidence plus a log-scaled
1813
+ current-token activation term. If a Workbench prompt is supplied it is appended to the same model batch,
1814
+ so current-prompt compatibility does not require another forward pass.
1815
  """
1816
  self.ensure_ready(preload_saes=False)
1817
  assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
 
1824
  top_n = int(top_n)
1825
  if top_n < 1 or top_n > 25:
1826
  raise ValueError('Number of candidate features must be between 1 and 25.')
1827
+ if ranking_mode not in {'balanced_selectivity', 'raw_mean_difference', 'causal_ready'}:
1828
+ raise ValueError(
1829
+ "ranking_mode must be 'balanced_selectivity', 'raw_mean_difference', or 'causal_ready'."
1830
+ )
1831
 
1832
  rows = self._contrast_prompt_rows(n)
1833
  available = sorted({str(row['concept']) for row in rows})
 
1913
  )
1914
 
1915
  eligible = (target_mean > 0) & (mean_diff > 0)
1916
+ if ranking_mode == 'causal_ready':
1917
+ if not current_context_available or resolved_current_idx is None:
1918
+ raise ValueError(
1919
+ "Causal-ready ranking requires a current Workbench prompt/token. "
1920
+ "Set the Workbench prompt/layer/token first."
1921
+ )
1922
+ eligible = eligible & (current_token_dense > 0)
1923
+ ranking_values = balanced_score * torch.log1p(current_token_dense.clamp_min(0))
1924
+ elif ranking_mode == 'balanced_selectivity':
1925
+ ranking_values = balanced_score
1926
+ else:
1927
+ ranking_values = mean_diff
1928
+
1929
  candidate_idx = torch.nonzero(eligible, as_tuple=False).flatten()
1930
  if candidate_idx.numel() == 0:
1931
  return ConceptFeatureDiscoveryResult(
 
1940
  default_candidate_id=None,
1941
  current_context_available=current_context_available,
1942
  current_token_index=resolved_current_idx,
1943
+ displayed_current_active_count=0,
1944
  )
1945
 
 
1946
  order = torch.argsort(ranking_values[candidate_idx], descending=True)
1947
  candidate_idx = candidate_idx[order[:top_n]]
1948
 
 
1988
  default_candidate_id=default_candidate_id,
1989
  current_context_available=current_context_available,
1990
  current_token_index=resolved_current_idx,
1991
+ displayed_current_active_count=sum(bool(row[-1]) for row in table_rows),
1992
  )
1993
 
1994
  @staticmethod
 
2140
  chart_rows: list[list[object]] = []
2141
  active_count = 0
2142
  cue_active_context_counts = {cue: 0 for cue in cue_list}
2143
+ cue_activation_values = {cue: [] for cue in cue_list}
2144
  for row_idx, (stem, cue, full_prompt) in enumerate(conditions):
2145
  valid_positions = torch.nonzero(attention[row_idx], as_tuple=False).flatten()
2146
  final_pos = int(valid_positions[-1].item())
 
2151
  active = activation > 0
2152
  active_count += int(active)
2153
  cue_active_context_counts[cue] += int(active)
2154
+ cue_activation_values[cue].append(activation)
2155
  token_id = int(batch['input_ids'][row_idx, final_pos].item())
2156
  final_token = self.tokenizer.decode([token_id])
2157
  short_stem = stem if len(stem) <= 42 else stem[:39] + '…'
2158
  rows.append([stem, cue, full_prompt, repr(final_token), activation, active])
2159
  chart_rows.append([short_stem, cue, activation])
2160
 
2161
+ cue_mean_activations = {
2162
+ cue: float(sum(vals) / len(vals)) if vals else 0.0
2163
+ for cue, vals in cue_activation_values.items()
2164
+ }
2165
+ dominant_cue = max(
2166
+ cue_list,
2167
+ key=lambda cue: (cue_active_context_counts[cue], cue_mean_activations[cue]),
2168
+ ) if cue_list else None
2169
+ dominant_count = cue_active_context_counts.get(dominant_cue, 0) if dominant_cue else 0
2170
+ off_dominant_active_count = sum(
2171
+ count for cue, count in cue_active_context_counts.items() if cue != dominant_cue
2172
+ )
2173
  return FeatureCueContextResult(
2174
  feature_id=int(feature_id),
2175
  layer=int(layer),
 
2180
  active_condition_count=active_count,
2181
  condition_count=len(conditions),
2182
  cue_active_context_counts=cue_active_context_counts,
2183
+ cue_mean_activations=cue_mean_activations,
2184
+ dominant_cue=dominant_cue,
2185
+ dominant_cue_context_count=dominant_count,
2186
+ off_dominant_active_count=off_dominant_active_count,
2187
  )
2188
 
2189
 
pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
  [project]
2
  name = "featurelens"
3
- version = "0.7.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.8.0"
4
  description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
5
  requires-python = ">=3.10"
6
 
research_config.json CHANGED
@@ -90,7 +90,7 @@
90
  "contrastive_continuation_preference_test",
91
  "feature_decoder_geometry"
92
  ],
93
- "concept_candidate_discovery_metric": "balanced exploratory score = selectivity × target activation rate × log1p(target mean), with raw-contrast alternative and current-Workbench compatibility",
94
  "completion_cue_scan": "final-token feature activation after controlled suffix/cue substitution",
95
  "live_features_v0_6": [
96
  "start_here_plain_language_onboarding",
@@ -109,5 +109,13 @@
109
  "balanced_candidate_ranking_and_current_prompt_compatibility",
110
  "click_to_select_candidate_rows",
111
  "completion_cue_context_matrix"
 
 
 
 
 
 
 
 
112
  ]
113
  }
 
90
  "contrastive_continuation_preference_test",
91
  "feature_decoder_geometry"
92
  ],
93
+ "concept_candidate_discovery_metric": "balanced exploratory score = selectivity \u00d7 target activation rate \u00d7 log1p(target mean); causal-ready mode additionally requires current-token activity and log-scales that activation; raw mean-difference remains available as a scale-sensitive comparison",
94
  "completion_cue_scan": "final-token feature activation after controlled suffix/cue substitution",
95
  "live_features_v0_6": [
96
  "start_here_plain_language_onboarding",
 
109
  "balanced_candidate_ranking_and_current_prompt_compatibility",
110
  "click_to_select_candidate_rows",
111
  "completion_cue_context_matrix"
112
+ ],
113
+ "live_features_v0_8": [
114
+ "bounded_plot_focus_overlay_with_scroll_restore",
115
+ "explicit_result_table_headings",
116
+ "standalone_dose_response_target_and_feature_inputs",
117
+ "causal_ready_current_token_candidate_ranking",
118
+ "cue_dominance_specificity_interpretation",
119
+ "muted_cue_context_plot_palette"
120
  ]
121
  }
scripts/release_check.py CHANGED
@@ -150,6 +150,21 @@ def check_config(config: dict) -> None:
150
  'research_config.json live_features_v0_7 mismatch: '
151
  f'{sorted(actual_live_v07)}'
152
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  if 'german_language' not in config.get('concepts', []) or 'french_language' in config.get('concepts', []):
154
  raise SystemExit('research_config.json must use german_language and must not contain french_language.')
155
 
@@ -222,16 +237,20 @@ def check_readme() -> None:
222
  'balanced selectivity',
223
  'german',
224
  'start here',
 
 
 
 
225
  ]
226
  missing = [value for value in required_strings if value.lower() not in readme.lower()]
227
  if missing:
228
- raise SystemExit(f'README.md is missing required v0.7 content: {missing}')
229
 
230
 
231
  def check_pyproject() -> None:
232
  text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
233
- if 'version = "0.7.0"' not in text:
234
- raise SystemExit('pyproject.toml must declare version 0.7.0.')
235
 
236
 
237
  def main() -> None:
@@ -249,7 +268,7 @@ def main() -> None:
249
  print(f' layers: {config["layers"]}')
250
  print(f' feature-set sizes: {config["feature_set_sizes"]}')
251
  print(f' random controls: {config["live_random_controls"]}')
252
- print(' release: v0.7.0')
253
 
254
 
255
  if __name__ == '__main__':
 
150
  'research_config.json live_features_v0_7 mismatch: '
151
  f'{sorted(actual_live_v07)}'
152
  )
153
+
154
+ required_live_v08 = {
155
+ 'bounded_plot_focus_overlay_with_scroll_restore',
156
+ 'explicit_result_table_headings',
157
+ 'standalone_dose_response_target_and_feature_inputs',
158
+ 'causal_ready_current_token_candidate_ranking',
159
+ 'cue_dominance_specificity_interpretation',
160
+ 'muted_cue_context_plot_palette',
161
+ }
162
+ actual_live_v08 = set(config.get('live_features_v0_8', []))
163
+ if actual_live_v08 != required_live_v08:
164
+ raise SystemExit(
165
+ 'research_config.json live_features_v0_8 mismatch: ' f'{sorted(actual_live_v08)}'
166
+ )
167
+
168
  if 'german_language' not in config.get('concepts', []) or 'french_language' in config.get('concepts', []):
169
  raise SystemExit('research_config.json must use german_language and must not contain french_language.')
170
 
 
237
  'balanced selectivity',
238
  'german',
239
  'start here',
240
+ 'causal-ready',
241
+ 'standalone',
242
+ 'plot focus',
243
+ 'cue-dominant',
244
  ]
245
  missing = [value for value in required_strings if value.lower() not in readme.lower()]
246
  if missing:
247
+ raise SystemExit(f'README.md is missing required v0.8 content: {missing}')
248
 
249
 
250
  def check_pyproject() -> None:
251
  text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
252
+ if 'version = "0.8.0"' not in text:
253
+ raise SystemExit('pyproject.toml must declare version 0.8.0.')
254
 
255
 
256
  def main() -> None:
 
268
  print(f' layers: {config["layers"]}')
269
  print(f' feature-set sizes: {config["feature_set_sizes"]}')
270
  print(f' random controls: {config["live_random_controls"]}')
271
+ print(' release: v0.8.0')
272
 
273
 
274
  if __name__ == '__main__':
tests/test_live_runtime_helpers.py CHANGED
@@ -256,6 +256,41 @@ def test_concept_feature_discovery_supports_raw_mean_difference() -> None:
256
  assert all(len(row) == 12 for row in result.rows)
257
 
258
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  def test_feature_cue_scan_runs_on_toy_runtime() -> None:
260
  runtime = make_runtime()
261
  result = runtime.feature_cue_scan(
@@ -283,4 +318,8 @@ def test_feature_cue_context_scan_runs_on_toy_runtime() -> None:
283
  assert all(len(row) == 6 for row in result.rows)
284
  assert 0 <= result.active_condition_count <= result.condition_count
285
  assert set(result.cue_active_context_counts) == {'is', '=', ':'}
 
 
 
 
286
  assert len(result.chart_rows) == 6
 
256
  assert all(len(row) == 12 for row in result.rows)
257
 
258
 
259
+
260
+
261
+ def test_concept_feature_discovery_supports_causal_ready_mode() -> None:
262
+ runtime = make_runtime()
263
+ result = runtime.concept_feature_discovery(
264
+ concept='mathematics',
265
+ layer=0,
266
+ prompts_per_concept=1,
267
+ top_n=5,
268
+ ranking_mode='causal_ready',
269
+ current_text='abc',
270
+ current_token_index=-1,
271
+ )
272
+ assert result.ranking_mode == 'causal_ready'
273
+ assert result.current_context_available is True
274
+ assert result.displayed_current_active_count == len(result.rows)
275
+ assert all(bool(row[-1]) for row in result.rows)
276
+
277
+
278
+ def test_concept_feature_discovery_causal_ready_requires_workbench_context() -> None:
279
+ runtime = make_runtime()
280
+ try:
281
+ runtime.concept_feature_discovery(
282
+ concept='mathematics',
283
+ layer=0,
284
+ prompts_per_concept=1,
285
+ top_n=3,
286
+ ranking_mode='causal_ready',
287
+ )
288
+ except ValueError as exc:
289
+ assert 'Workbench' in str(exc)
290
+ else:
291
+ raise AssertionError('causal_ready should require Workbench context')
292
+
293
+
294
  def test_feature_cue_scan_runs_on_toy_runtime() -> None:
295
  runtime = make_runtime()
296
  result = runtime.feature_cue_scan(
 
318
  assert all(len(row) == 6 for row in result.rows)
319
  assert 0 <= result.active_condition_count <= result.condition_count
320
  assert set(result.cue_active_context_counts) == {'is', '=', ':'}
321
+ assert set(result.cue_mean_activations) == {'is', '=', ':'}
322
+ assert result.dominant_cue in {'is', '=', ':'}
323
+ assert 0 <= result.dominant_cue_context_count <= 2
324
+ assert 0 <= result.off_dominant_active_count <= result.active_condition_count
325
  assert len(result.chart_rows) == 6
tests/test_ui_helpers.py CHANGED
@@ -68,5 +68,40 @@ def test_frontend_helpers_name_exports_and_preserve_focus_position() -> None:
68
  app = _import_app()
69
  assert 'featurelens_${stem' in app.INSTALL_REFLOW_JS
70
  assert 'chart.png' in app.INSTALL_REFLOW_JS
71
- assert '__featurelens_focus_scroll_y' in app.INSTALL_REFLOW_JS
 
 
72
  assert 'window.scrollTo' in app.INSTALL_REFLOW_JS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  app = _import_app()
69
  assert 'featurelens_${stem' in app.INSTALL_REFLOW_JS
70
  assert 'chart.png' in app.INSTALL_REFLOW_JS
71
+ assert 'featurelens-plot-focus-overlay' in app.INSTALL_REFLOW_JS
72
+ assert '__featurelens_plot_scroll_y' in app.INSTALL_REFLOW_JS
73
+ assert 'stopImmediatePropagation' in app.INSTALL_REFLOW_JS
74
  assert 'window.scrollTo' in app.INSTALL_REFLOW_JS
75
+
76
+
77
+ def test_dose_response_has_independent_target_control() -> None:
78
+ app = _import_app()
79
+ assert app.dose_target_text.value == '2x'
80
+ # The single-feature target remains optional and independent.
81
+ assert app.target_text.value in {'', None}
82
+
83
+
84
+ def test_cue_context_markdown_reports_strong_cue_dominance() -> None:
85
+ app = _import_app()
86
+ result = SimpleNamespace(
87
+ feature_id=22632,
88
+ layer=14,
89
+ stems=['math', 'capital', 'weather', 'name'],
90
+ active_condition_count=4,
91
+ condition_count=20,
92
+ cue_active_context_counts={'is': 4, '=': 0, ':': 0, 'equals': 0, 'therefore': 0},
93
+ dominant_cue='is',
94
+ dominant_cue_context_count=4,
95
+ off_dominant_active_count=0,
96
+ )
97
+ text = app._cue_context_metrics_markdown(result)
98
+ assert 'Cue-dominant pattern' in text
99
+ assert '`is` activates in every tested context' in text
100
+ assert 'lexical/cue-specific' in text
101
+
102
+
103
+ def test_result_tables_hide_native_labels_in_favor_of_explicit_headings() -> None:
104
+ app = _import_app()
105
+ assert app.discovery_table.show_label is False
106
+ assert app.dose_table.show_label is False
107
+ assert '.table-heading' in app.CSS