ArchitSharma commited on
Commit
80cf7fc
·
1 Parent(s): ff63ba1

Release FeatureLens v0.11.0

Browse files
CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
  # Changelog
2
 
 
 
 
 
 
 
 
 
 
 
3
  ## v0.10.0
4
 
5
  - Added a zero-extra-GPU **Discovery–causality alignment** panel after candidate triage.
 
1
  # Changelog
2
 
3
+ ## v0.11.0
4
+
5
+ - Added **Controlled candidate specificity**, a one-batch follow-up that compares up to three candidate SAE ablations against each candidate's own 8-direction norm-matched random ensemble.
6
+ - Added a strategic controlled shortlist that preserves the discovery leader, target-effect leader, and distribution-shift leader when they differ, then fills remaining slots by triage target rank.
7
+ - Added target-specificity and JS-specificity ratios plus coarse empirical random-control tails for every controlled candidate.
8
+ - Added **Association vs controlled causality**, joining concept-discovery evidence to random-normalized causal specificity instead of relying only on raw triage magnitude.
9
+ - Kept target-specific and whole-distribution causal influence separate rather than collapsing them into one score.
10
+ - Kept the validated in-place focus/zoom implementation unchanged.
11
+ - Reduced HF acceptance to one new GPU call; unchanged discovery/triage and other regression paths remain covered by automated tests.
12
+
13
  ## v0.10.0
14
 
15
  - Added a zero-extra-GPU **Discovery–causality alignment** panel after candidate triage.
README.md CHANGED
@@ -13,7 +13,7 @@ license: mit
13
 
14
  # FeatureLens — Causal Interpretability Workbench
15
 
16
- > **v0.10:** an association-to-causality synthesis workflow that joins concept discovery and batched causal triage, exposes rank disagreement, and quantifies descriptive concordance without spending another GPU call.
17
 
18
  **Research question:**
19
 
@@ -30,14 +30,15 @@ FeatureLens uses **Qwen3-1.7B-Base** with **Qwen-Scope residual-stream sparse au
30
  4. **Candidate discovery** — which features are more active for a chosen controlled concept than for the other groups?
31
  5. **Controlled contrast** — does a selected feature prefer one concept group in a balanced live mini-batch?
32
  6. **Cue sensitivity** — is a feature responding to a lexical/completion cue rather than the underlying concept?
33
- 7. **Single-feature causality** — does ablation/scaling change downstream probability?
34
- 8. **Dose-response** — does effect size vary coherently with feature coefficient?
35
- 9. **Feature-set causality** — do small sparse subspaces matter more than one feature?
36
- 10. **Non-additivity** — does a joint edit differ from the sum of individual effects?
37
- 11. **Specificity** — are targeted effects larger than norm-matched random residual perturbations?
38
- 12. **Contrastive specificity** — does an edit change preference between a desired and comparison continuation?
39
- 13. **Geometry** — are selected SAE decoder directions aligned, orthogonal, or cancelling before downstream model non-linearity?
40
- 14. **Association–causality concordance** — do features ranked highly by concept evidence also rank highly by downstream target effect or distribution shift?
 
41
 
42
  A high AUROC, high paraphrase overlap, or a large activation is still correlational evidence. Causal evidence comes from downstream change under controlled intervention.
43
 
@@ -358,6 +359,15 @@ FeatureLens/
358
  - **Association-vs-target-effect scatter** is generated from the same two existing GPU calls; the synthesis itself costs no additional GPU time.
359
  - Existing v0.9 causal-ready discovery, candidate triage, in-place focus, and GPU-budget-aware validation remain unchanged.
360
 
 
 
 
 
 
 
 
 
 
361
  ## Validation
362
 
363
  ```bash
@@ -368,7 +378,7 @@ python scripts/ui_smoke.py
368
  python scripts/release_check.py
369
  ```
370
 
371
- See [`docs/VALIDATION.md`](docs/VALIDATION.md). The v0.10 validation guide uses the **exact labels shown in the UI**. It reuses the existing discovery + triage workflow and does not ask for paraphrase, trajectory, feature-set, dose-response, or focus regressions because those implementations were not changed.
372
 
373
  ## Limitations
374
 
@@ -384,7 +394,7 @@ See [`docs/VALIDATION.md`](docs/VALIDATION.md). The v0.10 validation guide uses
384
  ## Resume-ready description
385
 
386
  > **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
387
- > 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, discovery-to-causality rank analysis, and norm-matched random-control ensembles.
388
 
389
  ## Acknowledgements
390
 
 
13
 
14
  # FeatureLens — Causal Interpretability Workbench
15
 
16
+ > **v0.11:** a controlled association-to-causality workflow that follows cheap candidate triage with per-feature norm-matched random ensembles, separating raw target effect from random-normalized causal specificity.
17
 
18
  **Research question:**
19
 
 
30
  4. **Candidate discovery** — which features are more active for a chosen controlled concept than for the other groups?
31
  5. **Controlled contrast** — does a selected feature prefer one concept group in a balanced live mini-batch?
32
  6. **Cue sensitivity** — is a feature responding to a lexical/completion cue rather than the underlying concept?
33
+ 7. **Candidate causal specificity** — do shortlisted candidate ablations exceed their own norm-matched random perturbation ensembles?
34
+ 8. **Single-feature causality** — does ablation/scaling change downstream probability?
35
+ 9. **Dose-response** — does effect size vary coherently with feature coefficient?
36
+ 10. **Feature-set causality** — do small sparse subspaces matter more than one feature?
37
+ 11. **Non-additivity** — does a joint edit differ from the sum of individual effects?
38
+ 12. **Specificity** — are targeted effects larger than norm-matched random residual perturbations?
39
+ 13. **Contrastive specificity** — does an edit change preference between a desired and comparison continuation?
40
+ 14. **Geometry** — are selected SAE decoder directions aligned, orthogonal, or cancelling before downstream model non-linearity?
41
+ 15. **Association–causality concordance** — do features ranked highly by concept evidence also rank highly by downstream target effect or distribution shift?
42
 
43
  A high AUROC, high paraphrase overlap, or a large activation is still correlational evidence. Causal evidence comes from downstream change under controlled intervention.
44
 
 
359
  - **Association-vs-target-effect scatter** is generated from the same two existing GPU calls; the synthesis itself costs no additional GPU time.
360
  - Existing v0.9 causal-ready discovery, candidate triage, in-place focus, and GPU-budget-aware validation remain unchanged.
361
 
362
+ ## v0.11 controlled candidate specificity
363
+
364
+ - **Controlled candidate specificity** compares up to three shortlisted SAE ablations against each candidate's own eight-direction norm-matched random ensemble in one shared batched execution context.
365
+ - **Strategic shortlist selection** preserves the discovery leader, target-effect leader, and distribution-shift leader when they differ, then fills remaining slots by triage target rank. This avoids confirming only whichever metric already won.
366
+ - **Two controlled causal notions remain separate:** target-specificity ratio normalizes the specified continuation effect; JS-specificity ratio normalizes whole next-token distribution shift.
367
+ - **Association vs controlled causality** joins discovery score/rank to random-normalized target and JS specificity, with descriptive Spearman concordance and rank shifts.
368
+ - The controlled live screen remains deliberately small: three candidates × eight controls each plus one zero-edit reference. Empirical tails are therefore coarse diagnostics rather than conventional significance tests.
369
+ - The validated in-place focus behavior and unchanged paraphrase/trajectory/feature-set paths are not touched. HF acceptance for v0.11 requires only the new controlled-candidate GPU path.
370
+
371
  ## Validation
372
 
373
  ```bash
 
378
  python scripts/release_check.py
379
  ```
380
 
381
+ See [`docs/VALIDATION.md`](docs/VALIDATION.md). The v0.11 validation guide uses the **exact labels shown in the UI** and asks for only the new controlled-candidate comparison on HF. Discovery, triage, paraphrase, trajectory, feature-set, dose-response, cue, and focus paths are not rerun because their implementations were not changed.
382
 
383
  ## Limitations
384
 
 
394
  ## Resume-ready description
395
 
396
  > **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
397
+ > 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, discovery-to-causality rank analysis, multi-candidate random-controlled specificity screening, and norm-matched random-control ensembles.
398
 
399
  ## Acknowledgements
400
 
app.py CHANGED
@@ -749,6 +749,218 @@ def _candidate_alignment_outputs(
749
  return summary, table, chart
750
 
751
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
752
  def _cue_context_metrics_markdown(result) -> str:
753
  active = ", ".join(
754
  f"{cue}: {count}/{len(result.stems)} contexts"
@@ -1349,6 +1561,11 @@ def run_candidate_causal_screen(
1349
  alignment_metrics, alignment_table, alignment_chart = _candidate_alignment_outputs(
1350
  discovery_table, table
1351
  )
 
 
 
 
 
1352
  return (
1353
  _candidate_screen_metrics_markdown(result),
1354
  table,
@@ -1359,6 +1576,69 @@ def run_candidate_causal_screen(
1359
  alignment_table,
1360
  alignment_chart,
1361
  _tsv(alignment_table),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1362
  )
1363
  except Exception as exc:
1364
  _raise_ui_error(exc)
@@ -1445,7 +1725,7 @@ def set_mode_help(mode: str):
1445
  with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_width=True) as demo:
1446
  gr.HTML(
1447
  '<header class="hero">'
1448
- '<h1>FeatureLens <span style="font-size:.48em;opacity:.58;font-weight:400">v0.10</span></h1>'
1449
  '<div class="subtitle">Causal Interpretability Workbench</div>'
1450
  '<div class="question">Discover sparse features, test robustness, and separate correlation from causal influence.</div>'
1451
  '</header>'
@@ -2075,6 +2355,108 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
2075
  height=330,
2076
  )
2077
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2078
  gr.HTML('<div class="section-rule">C. Inspect one feature</div>')
2079
  contrast_location = gr.Markdown(
2080
  "**Activation trace uses the current Workbench prompt.** The controlled concept scan below uses its own balanced prompt set."
@@ -2390,7 +2772,7 @@ Batched experiments include an explicit **zero-edit row**. Causal effects are me
2390
 
2391
  1. SAE reconstruction quality.
2392
  2. Held-out feature/concept prediction.
2393
- 3. Concept-guided candidate discovery and batched causal candidate triage.
2394
  4. Selected-feature concept contrast and token-local activation traces, completion-cue sensitivity, and cue × context specificity.
2395
  5. Local and prompt-wide paraphrase robustness.
2396
  6. Single-feature causal intervention and dose-response.
@@ -2519,8 +2901,37 @@ Association, robustness, geometry, and intervention evidence remain separate cla
2519
  candidate_alignment_table,
2520
  candidate_alignment_plot,
2521
  candidate_alignment_tsv,
 
2522
  ],
2523
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2524
  candidate_screen_table.select(
2525
  select_candidate_row,
2526
  inputs=[candidate_screen_table],
@@ -2576,6 +2987,8 @@ Association, robustness, geometry, and intervention evidence remain separate cla
2576
  (discovery_copy, discovery_tsv),
2577
  (candidate_screen_copy, candidate_screen_tsv),
2578
  (candidate_alignment_copy, candidate_alignment_tsv),
 
 
2579
  (cue_copy, cue_tsv),
2580
  (cue_context_copy, cue_context_tsv),
2581
  (para_copy, para_tsv),
 
749
  return summary, table, chart
750
 
751
 
752
+
753
+ def _controlled_candidate_shortlist(
754
+ discovery_table: pd.DataFrame | None,
755
+ screen_table: pd.DataFrame | None,
756
+ limit: int = 3,
757
+ ) -> list[str]:
758
+ """Pick a small controlled follow-up set without another model call.
759
+
760
+ Prefer the strongest discovery candidate, strongest target-effect candidate,
761
+ and strongest distribution-shift candidate. Fill any duplicate slots using
762
+ target-effect rank. This preserves the association-vs-causality contrast
763
+ instead of blindly testing only the triage top-k.
764
+ """
765
+ if screen_table is None:
766
+ return []
767
+ screen = pd.DataFrame(screen_table).copy()
768
+ if screen.empty or "Feature id" not in screen:
769
+ return []
770
+ screen["Feature id"] = pd.to_numeric(screen["Feature id"], errors="coerce")
771
+ screen = screen.dropna(subset=["Feature id"]).copy()
772
+ screen["Feature id"] = screen["Feature id"].astype(int)
773
+ if screen.empty:
774
+ return []
775
+
776
+ selected: list[int] = []
777
+
778
+ def add(feature_id: int) -> None:
779
+ if feature_id not in selected and len(selected) < int(limit):
780
+ selected.append(feature_id)
781
+
782
+ if discovery_table is not None:
783
+ discovery = pd.DataFrame(discovery_table).copy()
784
+ if not discovery.empty and {"Feature id", "Rank"}.issubset(discovery.columns):
785
+ discovery["Feature id"] = pd.to_numeric(discovery["Feature id"], errors="coerce")
786
+ discovery = discovery.dropna(subset=["Feature id"]).copy()
787
+ discovery["Feature id"] = discovery["Feature id"].astype(int)
788
+ screened_ids = set(screen["Feature id"].astype(int).tolist())
789
+ discovery = discovery[discovery["Feature id"].isin(screened_ids)]
790
+ if not discovery.empty:
791
+ top_discovery = discovery.sort_values(["Rank", "Feature id"]).iloc[0]
792
+ add(int(top_discovery["Feature id"]))
793
+
794
+ target_sorted = screen.sort_values(["Rank", "Feature id"])
795
+ add(int(target_sorted.iloc[0]["Feature id"]))
796
+
797
+ if "Next-token JS" in screen.columns:
798
+ js_sorted = screen.sort_values(["Next-token JS", "Feature id"], ascending=[False, True])
799
+ add(int(js_sorted.iloc[0]["Feature id"]))
800
+
801
+ for feature_id in target_sorted["Feature id"].astype(int).tolist():
802
+ add(feature_id)
803
+ if len(selected) >= int(limit):
804
+ break
805
+ return [str(feature_id) for feature_id in selected]
806
+
807
+
808
+ def _candidate_specificity_metrics_markdown(result) -> str:
809
+ tokens = " ".join(repr(token) for token in result.target_tokens)
810
+ if result.rows:
811
+ top = result.rows[0]
812
+ strongest = (
813
+ f"Highest target-specificity ratio: feature **{int(top[1])}** at **{float(top[9]):.2f}×** "
814
+ f"the random mean |effect| (empirical tail **{float(top[10]):.3f}**)."
815
+ )
816
+ else:
817
+ strongest = "No controlled candidate rows were produced."
818
+ return (
819
+ f"Compared **{result.candidate_count}** candidate feature(s); **{result.active_feature_count}** were active "
820
+ f"at the selected Workbench token. Each candidate uses **{result.random_control_count}** norm-matched random controls. \n"
821
+ f"Target continuation: {len(result.target_tokens)} token(s): {tokens}. \n"
822
+ f"{strongest} \n"
823
+ f"Execution-context null drift: mean log p/token **{result.execution_drift_mean_logprob:+.2e}**, "
824
+ f"JS **{result.execution_drift_js:.2e}**. \n\n"
825
+ "This is the controlled follow-up to triage: target-specificity compares |Δ mean log p/token| against the "
826
+ "candidate's own norm-matched random ensemble; JS-specificity does the same for the local next-token distribution. "
827
+ "With eight controls, empirical tail values remain intentionally coarse."
828
+ )
829
+
830
+
831
+ def _controlled_alignment_outputs(
832
+ discovery_table: pd.DataFrame | None,
833
+ specificity_table: pd.DataFrame | None,
834
+ ) -> tuple[str, pd.DataFrame, pd.DataFrame]:
835
+ """Join concept evidence to random-controlled causal specificity."""
836
+ columns = [
837
+ "Feature id",
838
+ "Discovery rank",
839
+ "Specificity rank",
840
+ "Target-effect rank",
841
+ "JS-specificity rank",
842
+ "Candidate score",
843
+ "|SAE Δ mean log p/token|",
844
+ "Random mean |Δ|",
845
+ "Target specificity ratio",
846
+ "Target empirical tail p",
847
+ "SAE next-token JS",
848
+ "Random mean JS",
849
+ "JS specificity ratio",
850
+ "JS empirical tail p",
851
+ "Discovery→specificity rank shift",
852
+ ]
853
+ if discovery_table is None or specificity_table is None:
854
+ return "", pd.DataFrame(columns=columns), pd.DataFrame()
855
+ discovery = pd.DataFrame(discovery_table).copy()
856
+ controlled = pd.DataFrame(specificity_table).copy()
857
+ if discovery.empty or controlled.empty or "Feature id" not in discovery or "Feature id" not in controlled:
858
+ return "", pd.DataFrame(columns=columns), pd.DataFrame()
859
+
860
+ discovery["Feature id"] = pd.to_numeric(discovery["Feature id"], errors="coerce")
861
+ controlled["Feature id"] = pd.to_numeric(controlled["Feature id"], errors="coerce")
862
+ discovery = discovery.dropna(subset=["Feature id"]).copy()
863
+ controlled = controlled.dropna(subset=["Feature id"]).copy()
864
+ discovery["Feature id"] = discovery["Feature id"].astype(int)
865
+ controlled["Feature id"] = controlled["Feature id"].astype(int)
866
+
867
+ needed_discovery = {"Rank", "Candidate score"}
868
+ needed_controlled = {
869
+ "Rank",
870
+ "SAE Δ mean log p/token",
871
+ "Random mean |Δ|",
872
+ "Target specificity ratio",
873
+ "Target empirical tail p",
874
+ "SAE next-token JS",
875
+ "Random mean JS",
876
+ "JS specificity ratio",
877
+ "JS empirical tail p",
878
+ }
879
+ if not needed_discovery.issubset(discovery.columns) or not needed_controlled.issubset(controlled.columns):
880
+ return "", pd.DataFrame(columns=columns), pd.DataFrame()
881
+
882
+ discovery_lookup = discovery.set_index("Feature id", drop=False)
883
+ target_ranked = controlled.assign(
884
+ _abs_target=controlled["SAE Δ mean log p/token"].astype(float).abs()
885
+ ).sort_values(["_abs_target", "Feature id"], ascending=[False, True])
886
+ target_ranks = {
887
+ int(row["Feature id"]): rank for rank, (_, row) in enumerate(target_ranked.iterrows(), start=1)
888
+ }
889
+ js_ranked = controlled.sort_values(
890
+ ["JS specificity ratio", "Feature id"], ascending=[False, True]
891
+ )
892
+ js_ranks = {
893
+ int(row["Feature id"]): rank for rank, (_, row) in enumerate(js_ranked.iterrows(), start=1)
894
+ }
895
+
896
+ rows: list[list[object]] = []
897
+ for _, row in controlled.iterrows():
898
+ feature_id = int(row["Feature id"])
899
+ if feature_id not in discovery_lookup.index:
900
+ continue
901
+ discovery_row = discovery_lookup.loc[feature_id]
902
+ if isinstance(discovery_row, pd.DataFrame):
903
+ discovery_row = discovery_row.iloc[0]
904
+ discovery_rank = int(float(discovery_row["Rank"]))
905
+ specificity_rank = int(float(row["Rank"]))
906
+ rows.append(
907
+ [
908
+ feature_id,
909
+ discovery_rank,
910
+ specificity_rank,
911
+ int(target_ranks[feature_id]),
912
+ int(js_ranks[feature_id]),
913
+ float(discovery_row["Candidate score"]),
914
+ abs(float(row["SAE Δ mean log p/token"])),
915
+ float(row["Random mean |Δ|"]),
916
+ float(row["Target specificity ratio"]),
917
+ float(row["Target empirical tail p"]),
918
+ float(row["SAE next-token JS"]),
919
+ float(row["Random mean JS"]),
920
+ float(row["JS specificity ratio"]),
921
+ float(row["JS empirical tail p"]),
922
+ discovery_rank - specificity_rank,
923
+ ]
924
+ )
925
+
926
+ table = pd.DataFrame(rows, columns=columns)
927
+ if table.empty:
928
+ return "", table, pd.DataFrame()
929
+
930
+ rho_target = _spearman_rank_corr(
931
+ table["Candidate score"].astype(float).tolist(),
932
+ table["Target specificity ratio"].astype(float).tolist(),
933
+ )
934
+ rho_js = _spearman_rank_corr(
935
+ table["Candidate score"].astype(float).tolist(),
936
+ table["JS specificity ratio"].astype(float).tolist(),
937
+ )
938
+ top_discovery = table.sort_values(["Discovery rank", "Feature id"]).iloc[0]
939
+ top_specificity = table.sort_values(["Specificity rank", "Feature id"]).iloc[0]
940
+ top_js = table.sort_values(["JS-specificity rank", "Feature id"]).iloc[0]
941
+
942
+ def fmt_rho(value: float | None) -> str:
943
+ return "undefined" if value is None else f"{value:+.3f}"
944
+
945
+ summary = (
946
+ f"Controlled comparison covers **{len(table)}** candidates from the discovery/triage workflow. \n"
947
+ f"Top discovery candidate: **{int(top_discovery['Feature id'])}** · strongest random-normalized target effect: "
948
+ f"**{int(top_specificity['Feature id'])}** · strongest random-normalized JS shift: **{int(top_js['Feature id'])}**. \n"
949
+ f"Spearman ρ(candidate score, target-specificity ratio): **{fmt_rho(rho_target)}** · "
950
+ f"ρ(candidate score, JS-specificity ratio): **{fmt_rho(rho_js)}**. \n\n"
951
+ "This is stronger than the cheap triage comparison because each candidate is normalized against its own "
952
+ "norm-matched random ensemble. The candidate count and eight-control ensemble are still small, so the correlations "
953
+ "and empirical tails are descriptive live diagnostics rather than significance claims."
954
+ )
955
+
956
+ chart = table[
957
+ ["Feature id", "Candidate score", "Target specificity ratio", "Discovery rank", "Specificity rank", "Target empirical tail p"]
958
+ ].copy()
959
+ chart["Feature id"] = chart["Feature id"].astype(str)
960
+ chart["Series"] = "Controlled candidate"
961
+ return summary, table, chart
962
+
963
+
964
  def _cue_context_metrics_markdown(result) -> str:
965
  active = ", ".join(
966
  f"{cue}: {count}/{len(result.stems)} contexts"
 
1561
  alignment_metrics, alignment_table, alignment_chart = _candidate_alignment_outputs(
1562
  discovery_table, table
1563
  )
1564
+ specificity_shortlist = _controlled_candidate_shortlist(discovery_table, table, limit=3)
1565
+ specificity_update = gr.update(
1566
+ choices=choices,
1567
+ value=specificity_shortlist,
1568
+ )
1569
  return (
1570
  _candidate_screen_metrics_markdown(result),
1571
  table,
 
1576
  alignment_table,
1577
  alignment_chart,
1578
  _tsv(alignment_table),
1579
+ specificity_update,
1580
+ )
1581
+ except Exception as exc:
1582
+ _raise_ui_error(exc)
1583
+
1584
+
1585
+ @gpu(duration=40)
1586
+ def run_candidate_specificity_screen(
1587
+ prompt: str,
1588
+ layer: int,
1589
+ token_index: int,
1590
+ feature_ids: list[str] | None,
1591
+ target_text: str,
1592
+ discovery_table: pd.DataFrame | None,
1593
+ ):
1594
+ try:
1595
+ selected = [int(float(value)) for value in (feature_ids or [])]
1596
+ result = RUNTIME.candidate_specificity_screen(
1597
+ text=prompt,
1598
+ layer=int(layer),
1599
+ token_index=int(token_index),
1600
+ feature_ids=selected,
1601
+ target_text=target_text,
1602
+ )
1603
+ columns = [
1604
+ "Rank",
1605
+ "Feature id",
1606
+ "Native activation",
1607
+ "Active at current token",
1608
+ "Perturbation L2",
1609
+ "SAE Δ mean log p/token",
1610
+ "Random signed mean Δ",
1611
+ "Random mean |Δ|",
1612
+ "Random |Δ| std",
1613
+ "Target specificity ratio",
1614
+ "Target empirical tail p",
1615
+ "SAE Δ sequence log p",
1616
+ "SAE next-token JS",
1617
+ "Random mean JS",
1618
+ "Random JS std",
1619
+ "JS specificity ratio",
1620
+ "JS empirical tail p",
1621
+ ]
1622
+ table = pd.DataFrame(result.rows, columns=columns)
1623
+ chart = pd.DataFrame(
1624
+ result.chart_rows,
1625
+ columns=["Feature", "Specificity metric", "Ratio"],
1626
+ )
1627
+ alignment_metrics, alignment_table, alignment_chart = _controlled_alignment_outputs(
1628
+ discovery_table, table
1629
+ )
1630
+ choices = [str(feature_id) for feature_id in result.feature_ids]
1631
+ candidate_update = gr.update(choices=choices, value=choices[0] if choices else None)
1632
+ return (
1633
+ _candidate_specificity_metrics_markdown(result),
1634
+ table,
1635
+ chart,
1636
+ candidate_update,
1637
+ _tsv(table),
1638
+ alignment_metrics,
1639
+ alignment_table,
1640
+ alignment_chart,
1641
+ _tsv(alignment_table),
1642
  )
1643
  except Exception as exc:
1644
  _raise_ui_error(exc)
 
1725
  with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_width=True) as demo:
1726
  gr.HTML(
1727
  '<header class="hero">'
1728
+ '<h1>FeatureLens <span style="font-size:.48em;opacity:.58;font-weight:400">v0.11</span></h1>'
1729
  '<div class="subtitle">Causal Interpretability Workbench</div>'
1730
  '<div class="question">Discover sparse features, test robustness, and separate correlation from causal influence.</div>'
1731
  '</header>'
 
2355
  height=330,
2356
  )
2357
 
2358
+ gr.Markdown("#### Controlled candidate specificity")
2359
+ gr.Markdown(
2360
+ "Follow the cheap triage with a small random-controlled comparison. FeatureLens automatically proposes a "
2361
+ "three-feature shortlist that preserves the disagreement between discovery, target-effect, and distribution-shift "
2362
+ "rankings. Each candidate receives its own 8-direction norm-matched random ensemble in one batched forward."
2363
+ )
2364
+ with gr.Row(equal_height=True):
2365
+ candidate_specificity_ids = gr.Dropdown(
2366
+ choices=[],
2367
+ value=[],
2368
+ multiselect=True,
2369
+ allow_custom_value=True,
2370
+ max_choices=3,
2371
+ label="Candidates for controlled comparison",
2372
+ info="Auto-filled after triage; choose up to three features.",
2373
+ scale=3,
2374
+ )
2375
+ candidate_specificity_target = gr.Textbox(
2376
+ label="Controlled target continuation",
2377
+ value="2x",
2378
+ info="Exact continuation scored for every candidate and its random controls.",
2379
+ scale=2,
2380
+ )
2381
+ candidate_specificity_btn = gr.Button(
2382
+ "Run controlled candidate comparison", variant="primary", elem_classes=["action-btn"]
2383
+ )
2384
+ candidate_specificity_metrics = gr.Markdown()
2385
+ with gr.Row(equal_height=False):
2386
+ with gr.Column(scale=3):
2387
+ _table_heading('Controlled candidate specificity')
2388
+ candidate_specificity_table = gr.Dataframe(
2389
+ interactive=False,
2390
+ label="Controlled candidate specificity",
2391
+ show_label=False,
2392
+ buttons=["fullscreen"],
2393
+ elem_classes=["result-table"],
2394
+ wrap=False,
2395
+ max_height=390,
2396
+ )
2397
+ candidate_specificity_tsv = gr.Textbox(visible="hidden")
2398
+ candidate_specificity_copy = _copy_button()
2399
+ with gr.Column(scale=2):
2400
+ candidate_specificity_plot = gr.BarPlot(
2401
+ x="Feature",
2402
+ y="Ratio",
2403
+ color="Specificity metric",
2404
+ color_map={
2405
+ "Target specificity": INK_TEAL,
2406
+ "JS specificity": INK_UMBER,
2407
+ },
2408
+ title="Random-normalized causal specificity",
2409
+ elem_id="plot-candidate-specificity",
2410
+ x_title="Feature id",
2411
+ y_title="SAE effect / random mean effect",
2412
+ x_label_angle=-35,
2413
+ buttons=["fullscreen", "export"],
2414
+ elem_classes=["fl-plot"],
2415
+ height=330,
2416
+ )
2417
+
2418
+ gr.Markdown("#### Association vs controlled causality")
2419
+ gr.Markdown(
2420
+ "Computed from concept discovery and the random-controlled comparison above. This is the stronger live version "
2421
+ "of the association-vs-causality diagnostic because each candidate is normalized against its own matched-control ensemble.",
2422
+ elem_classes=["candidate-help"],
2423
+ )
2424
+ controlled_alignment_metrics = gr.Markdown()
2425
+ with gr.Row(equal_height=False):
2426
+ with gr.Column(scale=3):
2427
+ _table_heading('Discovery–controlled-causality alignment')
2428
+ controlled_alignment_table = gr.Dataframe(
2429
+ interactive=False,
2430
+ label="Discovery–controlled-causality alignment",
2431
+ show_label=False,
2432
+ buttons=["fullscreen"],
2433
+ elem_classes=["result-table"],
2434
+ wrap=False,
2435
+ max_height=360,
2436
+ )
2437
+ controlled_alignment_tsv = gr.Textbox(visible="hidden")
2438
+ controlled_alignment_copy = _copy_button()
2439
+ with gr.Column(scale=2):
2440
+ controlled_alignment_plot = gr.ScatterPlot(
2441
+ x="Candidate score",
2442
+ y="Target specificity ratio",
2443
+ color="Series",
2444
+ color_map={"Controlled candidate": INK_TEAL},
2445
+ title="Association evidence vs controlled target specificity",
2446
+ elem_id="plot-controlled-association-causality",
2447
+ x_title="Discovery candidate score",
2448
+ y_title="Target specificity ratio",
2449
+ tooltip=[
2450
+ "Feature id",
2451
+ "Discovery rank",
2452
+ "Specificity rank",
2453
+ "Target empirical tail p",
2454
+ ],
2455
+ buttons=["fullscreen", "export"],
2456
+ elem_classes=["fl-plot"],
2457
+ height=330,
2458
+ )
2459
+
2460
  gr.HTML('<div class="section-rule">C. Inspect one feature</div>')
2461
  contrast_location = gr.Markdown(
2462
  "**Activation trace uses the current Workbench prompt.** The controlled concept scan below uses its own balanced prompt set."
 
2772
 
2773
  1. SAE reconstruction quality.
2774
  2. Held-out feature/concept prediction.
2775
+ 3. Concept-guided candidate discovery, batched causal triage, and random-controlled candidate comparison.
2776
  4. Selected-feature concept contrast and token-local activation traces, completion-cue sensitivity, and cue × context specificity.
2777
  5. Local and prompt-wide paraphrase robustness.
2778
  6. Single-feature causal intervention and dose-response.
 
2901
  candidate_alignment_table,
2902
  candidate_alignment_plot,
2903
  candidate_alignment_tsv,
2904
+ candidate_specificity_ids,
2905
  ],
2906
  )
2907
+ candidate_specificity_btn.click(
2908
+ run_candidate_specificity_screen,
2909
+ inputs=[
2910
+ prompt,
2911
+ discovery_layer,
2912
+ token_index,
2913
+ candidate_specificity_ids,
2914
+ candidate_specificity_target,
2915
+ discovery_table,
2916
+ ],
2917
+ outputs=[
2918
+ candidate_specificity_metrics,
2919
+ candidate_specificity_table,
2920
+ candidate_specificity_plot,
2921
+ discovery_candidate,
2922
+ candidate_specificity_tsv,
2923
+ controlled_alignment_metrics,
2924
+ controlled_alignment_table,
2925
+ controlled_alignment_plot,
2926
+ controlled_alignment_tsv,
2927
+ ],
2928
+ )
2929
+ candidate_specificity_table.select(
2930
+ select_candidate_row,
2931
+ inputs=[candidate_specificity_table],
2932
+ outputs=[discovery_candidate],
2933
+ queue=False,
2934
+ )
2935
  candidate_screen_table.select(
2936
  select_candidate_row,
2937
  inputs=[candidate_screen_table],
 
2987
  (discovery_copy, discovery_tsv),
2988
  (candidate_screen_copy, candidate_screen_tsv),
2989
  (candidate_alignment_copy, candidate_alignment_tsv),
2990
+ (candidate_specificity_copy, candidate_specificity_tsv),
2991
+ (controlled_alignment_copy, controlled_alignment_tsv),
2992
  (cue_copy, cue_tsv),
2993
  (cue_context_copy, cue_context_tsv),
2994
  (para_copy, para_tsv),
docs/METHODOLOGY.md CHANGED
@@ -462,3 +462,72 @@ so positive values indicate a feature that rises in the target-effect ordering r
462
  Across the small screened set, FeatureLens computes tie-aware Spearman correlations between candidate score and (a) absolute target effect and (b) next-token JS. These are deliberately labeled **descriptive** because the live shortlist is small and the triage stage does not spend random-control ensembles. A promoted feature still requires the full single-feature causal test before specificity is interpreted.
463
 
464
  This synthesis costs no additional model forward: it is computed from the discovery and triage tables already produced by the two-stage workflow.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
  Across the small screened set, FeatureLens computes tie-aware Spearman correlations between candidate score and (a) absolute target effect and (b) next-token JS. These are deliberately labeled **descriptive** because the live shortlist is small and the triage stage does not spend random-control ensembles. A promoted feature still requires the full single-feature causal test before specificity is interpreted.
463
 
464
  This synthesis costs no additional model forward: it is computed from the discovery and triage tables already produced by the two-stage workflow.
465
+
466
+ ## v0.11 controlled candidate specificity
467
+
468
+ The cheap candidate-ablation triage intentionally omits random controls so multiple candidate features can be screened in one small batch. Raw intervention magnitude is useful for triage, but it does not establish that an SAE direction is more behaviorally specific than an arbitrary residual perturbation of the same norm.
469
+
470
+ v0.11 therefore adds a second-stage controlled screen for at most three candidates. For candidate feature \(i\) with native activation \(z_i\) and decoder direction \(d_i\), the targeted ablation is
471
+
472
+ \[
473
+ \Delta h_i = -z_i d_i.
474
+ \]
475
+
476
+ For each candidate, FeatureLens generates `live_random_controls` deterministic random residual directions \(r_{ij}\) such that
477
+
478
+ \[
479
+ \|r_{ij}\|_2 = \|\Delta h_i\|_2.
480
+ \]
481
+
482
+ A single batched execution contains one zero-edit reference, every targeted SAE ablation, and every candidate-specific random control. All target and JS effects are therefore measured relative to the same batched null.
483
+
484
+ Two random-normalized causal quantities are kept separate:
485
+
486
+ 1. **Target specificity ratio**
487
+
488
+ \[
489
+ \frac{|\Delta \bar{\ell}_{\text{SAE}}|}
490
+ {\operatorname{mean}_j |\Delta \bar{\ell}_{r_j}|},
491
+ \]
492
+
493
+ where \(\bar{\ell}\) is the teacher-forced mean log probability per target token.
494
+
495
+ 2. **JS specificity ratio**
496
+
497
+ \[
498
+ \frac{\operatorname{JS}(p_0, p_{\text{SAE}})}
499
+ {\operatorname{mean}_j \operatorname{JS}(p_0, p_{r_j})}.
500
+ \]
501
+
502
+ The first asks whether the SAE edit is unusually influential for the specified continuation. The second asks whether it is unusually disruptive to the local next-token distribution as a whole. They are intentionally not collapsed into a single score.
503
+
504
+ The live empirical random-control tail uses the finite-ensemble correction
505
+
506
+ \[
507
+ p = \frac{1 + \#\{|e_{r_j}| \ge |e_{\text{SAE}}|\}}
508
+ {1 + N_{\text{controls}}}.
509
+ \]
510
+
511
+ With eight controls, the smallest possible live value is \(1/9\approx0.111\). These values are therefore coarse specificity diagnostics, not conventional significance tests.
512
+
513
+ ### Strategic controlled shortlist
514
+
515
+ After cheap triage, the default controlled shortlist is chosen to preserve disagreement rather than merely retest the top target-effect rows:
516
+
517
+ 1. strongest discovery candidate among the screened features;
518
+ 2. strongest raw target-effect candidate;
519
+ 3. strongest next-token-JS candidate if distinct;
520
+ 4. fill any remaining slot by target-effect rank.
521
+
522
+ This makes the controlled follow-up directly test the project's central question: whether strong concept-association evidence predicts random-normalized causal specificity.
523
+
524
+ ### Association vs controlled causality
525
+
526
+ The zero-GPU synthesis layer joins discovery evidence to the controlled table and keeps four ranks separate:
527
+
528
+ - discovery rank;
529
+ - raw target-effect rank;
530
+ - random-normalized target-specificity rank;
531
+ - random-normalized JS-specificity rank.
532
+
533
+ Descriptive Spearman correlations between candidate score and the two specificity ratios are shown only as small-sample diagnostics. The full offline benchmark remains the place for larger candidate sets, more random controls, uncertainty intervals, and formal held-out conclusions.
docs/VALIDATION.md CHANGED
@@ -1,12 +1,10 @@
1
- # FeatureLens v0.10 validation
2
 
3
- v0.10 does **not** change paraphrase robustness, layer trajectory, feature-set interventions, dose-response inference, cue × context, or the in-place focus implementation. Do not spend ZeroGPU quota rerunning those paths.
4
 
5
- The only new live behavior is the **association-to-causality synthesis** computed after the existing discovery + candidate-triage workflow.
6
 
7
- ## 1. Local release gate — no HF GPU
8
-
9
- From the repository root:
10
 
11
  ```bash
12
  python3 -m pytest -q && \
@@ -16,139 +14,122 @@ python3 scripts/ui_smoke.py && \
16
  python3 scripts/release_check.py
17
  ```
18
 
19
- Expected high-level result:
20
-
21
- - all tests pass;
22
- - Ruff reports `All checks passed!`;
23
- - `FeatureLens UI launch smoke: PASS`;
24
- - release check ends with `release: v0.10.0`.
25
-
26
- Stop if any local gate fails.
27
-
28
- ---
29
-
30
- # HF acceptance — two GPU calls total
31
-
32
- Use the existing Workbench context:
33
 
34
- - **Workbench → Prompt:** `The derivative of x squared is`
35
- - **Workbench → Residual layer:** `14`
36
- - **Workbench → Prompt token index:** `-1`
37
-
38
- You do **not** need to click **Inspect sparse features** first.
39
-
40
- ## 2. GPU call 1 — causal-ready mathematics discovery
41
-
42
- Exact path:
43
-
44
- **Feature evidence → A. Concept-guided candidate discovery**
45
 
46
- Set exactly:
47
 
48
- - **Target concept:** `mathematics`
49
- - **Residual layer:** `14`
50
- - **Prompts per concept:** `4`
51
- - **Candidate features:** `12`
52
- - **Candidate ranking:** `Causal-ready at current token`
53
 
54
- Click:
55
 
56
- **Discover concept-associated candidates**
57
 
58
- Expected regression behavior from v0.9:
59
 
60
- - the summary should say `12/12` displayed candidates are active at the selected Workbench token;
61
- - **Candidate features to screen** should auto-populate with the first five returned feature IDs;
62
- - no additional UI/focus regression testing is required because that code was not changed in v0.10.
63
 
64
- For the current canonical prompt, the v0.9 result began with candidates `16369`, `5712`, `26112`, `25992`, `21670`. Exact floating-point values can vary slightly, but a major ordering change should be reported.
 
 
 
 
65
 
66
- ## 3. GPU call 2 triage + association-to-causality synthesis
67
 
68
- Exact path:
 
 
 
 
69
 
70
- **Feature evidence B. Batched causal candidate triage**
71
 
72
- Leave the five auto-populated candidates selected.
 
 
73
 
74
  Set:
75
 
76
- - **Screen target continuation:** `2x`
77
-
78
- Click:
79
-
80
- **Screen candidate ablations**
81
-
82
- The normal **Candidate ablation screen** should appear first.
83
-
84
- Immediately below it, without another GPU action, v0.10 should populate:
85
-
86
- ### `Association vs causal influence`
87
-
88
- with:
89
 
90
- 1. a descriptive summary;
91
- 2. **Discovery–causality alignment** table;
92
- 3. **Association evidence vs target effect** scatter plot.
93
 
94
- ### Required alignment-table columns
95
 
96
- - `Feature id`
97
- - `Discovery rank`
98
- - `Target-effect rank`
99
- - `Distribution-shift rank`
100
- - `Candidate score`
101
- - `Selectivity`
102
- - `Current token activation`
103
- - `|Δ mean log p/token|`
104
- - `Next-token JS`
105
- - `Discovery→target rank shift`
106
 
107
- ### Required summary behavior
 
 
 
 
 
 
 
108
 
109
- The summary must identify separately:
110
 
111
- - top discovery candidate;
112
- - strongest target-effect candidate;
113
- - strongest next-token distribution-shift candidate;
114
- - Spearman `ρ(candidate score, |target effect|)`;
115
- - Spearman `ρ(candidate score, next-token JS)`;
116
- - an explicit warning that the live correlations are descriptive because the screened set is small and triage has no random-control ensemble.
117
 
118
- For the exact v0.9 values you reported, the expected qualitative pattern is:
119
 
120
- - discovery rank #1: feature `16369`;
121
- - strongest target effect: feature `25992`;
122
- - strongest next-token JS shift: feature `16369`;
123
- - discovery score versus target-effect magnitude: strongly negative descriptive rank correlation;
124
- - discovery score versus JS: positive but weaker descriptive rank correlation.
125
 
126
- Do not require exact decimals as a pass condition.
127
 
128
- ### Rank-shift sanity check
129
 
130
- For the v0.9 ordering:
 
 
 
 
 
 
 
 
131
 
132
- - feature `25992`: discovery rank `4`, target-effect rank `1` `Discovery→target rank shift = +3`;
133
- - feature `16369`: discovery rank `1`, target-effect rank `5` → rank shift `-4`.
134
 
135
- This is the most important v0.10 regression check because it demonstrates that concept-evidence rank and target-causal rank are not interchangeable.
136
 
137
- ---
138
 
139
- ## 4. What to send back
140
 
141
- Only send:
 
 
 
 
 
 
 
 
142
 
143
- 1. the v0.10 **Candidate ablation screen** if it changed materially from v0.9;
144
- 2. the new **Discovery–causality alignment** table;
145
- 3. the new association/causality summary with the two Spearman values;
146
- 4. optionally a screenshot of **Association evidence vs target effect** if the plot looks wrong.
147
 
148
- Do **not** rerun identity paraphrase, layer trajectory, 1/3/5 sweep, dose response, cue × context, or focus behavior for v0.10. Those implementations were not changed.
149
 
150
- ---
151
 
152
- # Final hardening later
 
 
153
 
154
- The broad adversarial/release suite remains deferred until the live feature set is frozen. After v0.10 acceptance, the next high-value step should be the real offline held-out benchmark rather than another round of unrelated live widgets.
 
1
+ # FeatureLens v0.11 validation
2
 
3
+ v0.11 adds one new GPU inference path: **Controlled candidate specificity**. It does **not** change concept discovery, cheap candidate triage, paraphrase robustness, layer trajectory, feature-set interventions, dose response, cue diagnostics, or the validated in-place focus behavior. Do not spend ZeroGPU quota rerunning those unchanged paths.
4
 
5
+ ## Local release gate
6
 
7
+ Run from the repository root:
 
 
8
 
9
  ```bash
10
  python3 -m pytest -q && \
 
14
  python3 scripts/release_check.py
15
  ```
16
 
17
+ Expected release checker tail:
 
 
 
 
 
 
 
 
 
 
 
 
 
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.11.0
27
+ ```
 
 
28
 
29
+ ## HF acceptance: one GPU call only
30
 
31
+ The v0.10 discovery and triage paths were already validated. For v0.11, test only the new controlled follow-up.
 
 
 
 
32
 
33
+ ### Exact path
34
 
35
+ Open **Feature evidence** and scroll to:
36
 
37
+ **B. Batched causal candidate triage → Controlled candidate specificity**
38
 
39
+ Use the current Workbench context:
 
 
40
 
41
+ ```text
42
+ Prompt: The derivative of x squared is
43
+ Residual layer: 14
44
+ Prompt token index: -1
45
+ ```
46
 
47
+ In **Candidates for controlled comparison**, manually enter/select exactly:
48
 
49
+ ```text
50
+ 16369
51
+ 25992
52
+ 21670
53
+ ```
54
 
55
+ These are the three strategic candidates established by the validated v0.10 workflow:
56
 
57
+ - `16369`: discovery leader and next-token-JS leader;
58
+ - `25992`: raw target-effect leader;
59
+ - `21670`: second-largest raw target effect.
60
 
61
  Set:
62
 
63
+ ```text
64
+ Controlled target continuation: 2x
65
+ ```
 
 
 
 
 
 
 
 
 
 
66
 
67
+ Click **Run controlled candidate comparison**.
 
 
68
 
69
+ ### Pass conditions
70
 
71
+ The run should produce **Controlled candidate specificity** with exactly three feature rows. For every row verify:
 
 
 
 
 
 
 
 
 
72
 
73
+ - `Active at current token` is `True`;
74
+ - `Target specificity ratio` is finite and non-negative;
75
+ - `Target empirical tail p` lies in `(0, 1]`;
76
+ - `JS specificity ratio` is finite and non-negative;
77
+ - `JS empirical tail p` lies in `(0, 1]`;
78
+ - random-control means/stds are populated;
79
+ - the summary says each candidate used 8 norm-matched random controls;
80
+ - execution-context null drift is reported separately from the causal effect.
81
 
82
+ The plot **Random-normalized causal specificity** should show two series per feature:
83
 
84
+ ```text
85
+ Target specificity
86
+ JS specificity
87
+ ```
 
 
88
 
89
+ Do not assume which feature must win. That is the empirical result this version is intended to measure.
90
 
91
+ ## Optional integration check no additional GPU call
 
 
 
 
92
 
93
+ If the Space still has the v0.10 discovery table populated in the same browser session, the **Association vs controlled causality** section should also populate automatically.
94
 
95
+ Verify that **Discovery–controlled-causality alignment** contains the same controlled features and includes:
96
 
97
+ - discovery rank;
98
+ - specificity rank;
99
+ - target-effect rank;
100
+ - JS-specificity rank;
101
+ - target specificity ratio;
102
+ - target empirical tail p;
103
+ - JS specificity ratio;
104
+ - JS empirical tail p;
105
+ - discovery→specificity rank shift.
106
 
107
+ The summary may include descriptive Spearman correlations. With only three controlled candidates, these are diagnostic summaries, **not significance claims**.
 
108
 
109
+ If the discovery table is not populated because the Space was freshly rebuilt, do **not** spend two extra GPU calls merely to repopulate it for v0.11 acceptance. The zero-GPU join logic is covered by automated tests.
110
 
111
+ ## Do not rerun for v0.11
112
 
113
+ Do not rerun these paths unless you independently notice a regression:
114
 
115
+ - Concept-guided candidate discovery;
116
+ - Batched causal candidate triage;
117
+ - Scale dose-response;
118
+ - Single-feature causal test;
119
+ - Paraphrase identity control;
120
+ - Layer trajectory;
121
+ - 1/3/5 feature-set sweep;
122
+ - cue sensitivity or cue × context;
123
+ - plot/table focus behavior.
124
 
125
+ Those implementations were not changed in v0.11.
 
 
 
126
 
127
+ ## What to send back
128
 
129
+ For the new controlled comparison, send:
130
 
131
+ 1. the three feature rows from **Controlled candidate specificity**;
132
+ 2. the summary above the table;
133
+ 3. optionally the **Association vs controlled causality** summary/table if it populated without extra GPU work.
134
 
135
+ That is sufficient to decide whether the live feature set should now be frozen and the project moved to the full offline benchmark.
featurelens/runtime.py CHANGED
@@ -255,6 +255,19 @@ class CandidateCausalScreenResult:
255
  execution_drift_js: float
256
 
257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  @dataclass
259
  class FeatureCueScanResult:
260
  feature_id: int
@@ -2131,6 +2144,206 @@ class FeatureLensRuntime:
2131
  execution_drift_js=float(execution_drift_js),
2132
  )
2133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2134
  @staticmethod
2135
  def _cue_prompt(stem: str, cue: str) -> str:
2136
  stem = stem.rstrip()
 
255
  execution_drift_js: float
256
 
257
 
258
+ @dataclass
259
+ class CandidateSpecificityResult:
260
+ feature_ids: list[int]
261
+ target_tokens: list[str]
262
+ rows: list[list[object]]
263
+ chart_rows: list[list[object]]
264
+ active_feature_count: int
265
+ candidate_count: int
266
+ random_control_count: int
267
+ execution_drift_mean_logprob: float
268
+ execution_drift_js: float
269
+
270
+
271
  @dataclass
272
  class FeatureCueScanResult:
273
  feature_id: int
 
2144
  execution_drift_js=float(execution_drift_js),
2145
  )
2146
 
2147
+ @torch.inference_mode()
2148
+ def candidate_specificity_screen(
2149
+ self,
2150
+ text: str,
2151
+ layer: int,
2152
+ token_index: int,
2153
+ feature_ids: Sequence[int],
2154
+ target_text: str,
2155
+ ) -> CandidateSpecificityResult:
2156
+ """Compare a small candidate set against per-feature norm-matched random ensembles.
2157
+
2158
+ This is the controlled follow-up to :meth:`candidate_causal_screen`. Each SAE
2159
+ ablation gets its own deterministic random-control ensemble with the same L2 norm.
2160
+ All targeted and random conditions share one batched zero-edit reference so the
2161
+ comparison is both GPU-efficient and execution-context consistent.
2162
+ """
2163
+ if not text.strip():
2164
+ raise ValueError('Enter and inspect a Workbench prompt first.')
2165
+ if not target_text.strip():
2166
+ raise ValueError('Enter a target continuation for controlled candidate comparison.')
2167
+ ids = list(dict.fromkeys(int(x) for x in feature_ids))
2168
+ if not ids:
2169
+ raise ValueError('Select at least one candidate feature for controlled comparison.')
2170
+ if len(ids) > 3:
2171
+ raise ValueError('Controlled candidate comparison supports at most three features per run.')
2172
+ if any(feature_id < 0 or feature_id >= self.settings.sae_width for feature_id in ids):
2173
+ raise ValueError(f'Feature ids must be in [0, {self.settings.sae_width - 1}].')
2174
+
2175
+ self.ensure_ready(preload_saes=False)
2176
+ assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
2177
+ prompt_inputs = self._inputs(text)
2178
+ prompt_len = int(prompt_inputs['input_ids'].shape[1])
2179
+ idx = self._resolve_index(int(token_index), prompt_len)
2180
+ target_ids = self._target_ids(target_text)
2181
+ full_inputs = self._append_target(prompt_inputs, target_ids)
2182
+ sae = self.sae_store.get(int(layer))
2183
+
2184
+ capture: dict = {}
2185
+ with self._capture_hook(int(layer), capture):
2186
+ single_baseline_out = self.model(**full_inputs, use_cache=False)
2187
+ single_logits = single_baseline_out.logits[0]
2188
+ _, single_mean, _ = sequence_logprob_summary(
2189
+ single_logits, prompt_length=prompt_len, target_ids=target_ids
2190
+ )
2191
+
2192
+ residual = capture['hidden'][0, idx]
2193
+ encoding = sae.encode(residual)
2194
+ activations = [float(encoding.activation_for(feature_id)) for feature_id in ids]
2195
+
2196
+ zero = torch.zeros_like(residual)
2197
+ all_deltas: list[torch.Tensor] = [zero]
2198
+ metadata: list[tuple[int, str, float, float]] = []
2199
+ for feature_id, activation in zip(ids, activations, strict=True):
2200
+ delta = residual_delta(
2201
+ sae.decoder_direction(feature_id),
2202
+ activation,
2203
+ InterventionSpec('ablate', 0.0),
2204
+ )
2205
+ norm = float(torch.linalg.vector_norm(delta.float()).item())
2206
+ all_deltas.append(delta)
2207
+ metadata.append((feature_id, 'sae', activation, norm))
2208
+ seed = self._control_seed(text, int(layer), str(feature_id), 'candidate_specificity', 0.0)
2209
+ controls = self._random_controls(
2210
+ delta, seed=seed, count=self.settings.live_random_controls
2211
+ )
2212
+ for control_idx, control in enumerate(controls):
2213
+ all_deltas.append(control)
2214
+ metadata.append((feature_id, f'random_{control_idx}', activation, norm))
2215
+
2216
+ delta_batch = torch.stack(all_deltas, dim=0)
2217
+ repeated = self._repeat_inputs(full_inputs, delta_batch.shape[0])
2218
+ with self._batch_delta_hook(int(layer), idx, delta_batch):
2219
+ outputs = self.model(**repeated, use_cache=False)
2220
+
2221
+ baseline_logits = outputs.logits[0]
2222
+ baseline_seq, baseline_mean, _ = sequence_logprob_summary(
2223
+ baseline_logits, prompt_length=prompt_len, target_ids=target_ids
2224
+ )
2225
+ baseline_next = baseline_logits[prompt_len - 1]
2226
+ execution_drift_mean = float(baseline_mean - single_mean)
2227
+ execution_drift_js = js_divergence_from_logits(
2228
+ single_logits[prompt_len - 1], baseline_next
2229
+ )
2230
+
2231
+ grouped: dict[int, dict[str, object]] = {
2232
+ feature_id: {
2233
+ 'activation': activation,
2234
+ 'norm': 0.0,
2235
+ 'sae': None,
2236
+ 'random': [],
2237
+ }
2238
+ for feature_id, activation in zip(ids, activations, strict=True)
2239
+ }
2240
+
2241
+ for output_idx, meta in enumerate(metadata, start=1):
2242
+ feature_id, kind, activation, norm = meta
2243
+ logits = outputs.logits[output_idx]
2244
+ seq_logp, mean_logp, _ = sequence_logprob_summary(
2245
+ logits, prompt_length=prompt_len, target_ids=target_ids
2246
+ )
2247
+ item = {
2248
+ 'seq_delta': float(seq_logp - baseline_seq),
2249
+ 'mean_delta': float(mean_logp - baseline_mean),
2250
+ 'js': float(js_divergence_from_logits(baseline_next, logits[prompt_len - 1])),
2251
+ }
2252
+ grouped[feature_id]['activation'] = float(activation)
2253
+ grouped[feature_id]['norm'] = float(norm)
2254
+ if kind == 'sae':
2255
+ grouped[feature_id]['sae'] = item
2256
+ else:
2257
+ random_items = grouped[feature_id]['random']
2258
+ assert isinstance(random_items, list)
2259
+ random_items.append(item)
2260
+
2261
+ scored: list[dict[str, object]] = []
2262
+ for feature_id in ids:
2263
+ group = grouped[feature_id]
2264
+ sae_item = group['sae']
2265
+ random_items = group['random']
2266
+ assert isinstance(sae_item, dict)
2267
+ assert isinstance(random_items, list) and random_items
2268
+ mean_delta = float(sae_item['mean_delta'])
2269
+ js = float(sae_item['js'])
2270
+ random_mean_deltas = [float(item['mean_delta']) for item in random_items]
2271
+ random_js_values = [float(item['js']) for item in random_items]
2272
+ random_signed, random_abs, random_std, target_p = self._random_effect_summary(
2273
+ random_mean_deltas, mean_delta
2274
+ )
2275
+ _, random_js_abs, random_js_std, js_p = self._random_effect_summary(
2276
+ random_js_values, js
2277
+ )
2278
+ scored.append(
2279
+ {
2280
+ 'feature_id': int(feature_id),
2281
+ 'activation': float(group['activation']),
2282
+ 'active': bool(float(group['activation']) > 0),
2283
+ 'norm': float(group['norm']),
2284
+ 'mean_delta': mean_delta,
2285
+ 'seq_delta': float(sae_item['seq_delta']),
2286
+ 'random_signed': float(random_signed),
2287
+ 'random_abs': float(random_abs),
2288
+ 'random_std': float(random_std),
2289
+ 'target_ratio': float(abs(mean_delta) / max(random_abs, 1e-12)),
2290
+ 'target_p': float(target_p),
2291
+ 'js': js,
2292
+ 'random_js': float(random_js_abs),
2293
+ 'random_js_std': float(random_js_std),
2294
+ 'js_ratio': float(js / max(random_js_abs, 1e-12)),
2295
+ 'js_p': float(js_p),
2296
+ }
2297
+ )
2298
+
2299
+ scored.sort(
2300
+ key=lambda item: (float(item['target_ratio']), abs(float(item['mean_delta']))),
2301
+ reverse=True,
2302
+ )
2303
+ rows: list[list[object]] = []
2304
+ chart_rows: list[list[object]] = []
2305
+ for rank, item in enumerate(scored, start=1):
2306
+ feature_id = int(item['feature_id'])
2307
+ rows.append(
2308
+ [
2309
+ rank,
2310
+ feature_id,
2311
+ float(item['activation']),
2312
+ bool(item['active']),
2313
+ float(item['norm']),
2314
+ float(item['mean_delta']),
2315
+ float(item['random_signed']),
2316
+ float(item['random_abs']),
2317
+ float(item['random_std']),
2318
+ float(item['target_ratio']),
2319
+ float(item['target_p']),
2320
+ float(item['seq_delta']),
2321
+ float(item['js']),
2322
+ float(item['random_js']),
2323
+ float(item['random_js_std']),
2324
+ float(item['js_ratio']),
2325
+ float(item['js_p']),
2326
+ ]
2327
+ )
2328
+ chart_rows.extend(
2329
+ [
2330
+ [str(feature_id), 'Target specificity', float(item['target_ratio'])],
2331
+ [str(feature_id), 'JS specificity', float(item['js_ratio'])],
2332
+ ]
2333
+ )
2334
+
2335
+ return CandidateSpecificityResult(
2336
+ feature_ids=[int(item['feature_id']) for item in scored],
2337
+ target_tokens=[self.tokenizer.decode([int(token_id)]) for token_id in target_ids],
2338
+ rows=rows,
2339
+ chart_rows=chart_rows,
2340
+ active_feature_count=sum(bool(item['active']) for item in scored),
2341
+ candidate_count=len(scored),
2342
+ random_control_count=self.settings.live_random_controls,
2343
+ execution_drift_mean_logprob=execution_drift_mean,
2344
+ execution_drift_js=float(execution_drift_js),
2345
+ )
2346
+
2347
  @staticmethod
2348
  def _cue_prompt(stem: str, cue: str) -> str:
2349
  stem = stem.rstrip()
pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
  [project]
2
  name = "featurelens"
3
- version = "0.10.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.11.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); 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",
@@ -139,5 +139,20 @@
139
  "descriptive_spearman_concordance_summary",
140
  "target_effect_vs_distribution_shift_rank_separation",
141
  "no_extra_gpu_candidate_synthesis"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  ]
143
  }
 
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",
 
139
  "descriptive_spearman_concordance_summary",
140
  "target_effect_vs_distribution_shift_rank_separation",
141
  "no_extra_gpu_candidate_synthesis"
142
+ ],
143
+ "candidate_specificity_limit": 3,
144
+ "candidate_specificity_control": "each candidate gets live_random_controls deterministic norm-matched residual directions in one shared batched zero-edit execution context",
145
+ "candidate_specificity_metrics": [
146
+ "target effect versus candidate-specific random mean absolute effect",
147
+ "next-token JS versus candidate-specific random mean JS",
148
+ "coarse empirical random-control tail probabilities",
149
+ "discovery rank versus random-normalized target-specificity rank"
150
+ ],
151
+ "live_features_v0_11": [
152
+ "controlled_multi_candidate_random_specificity_screen",
153
+ "strategic_discovery_target_js_shortlist",
154
+ "association_vs_controlled_causality_alignment",
155
+ "target_specificity_vs_js_specificity_separation",
156
+ "single_new_gpu_call_hf_acceptance"
157
  ]
158
  }
scripts/release_check.py CHANGED
@@ -84,6 +84,7 @@ def check_config(config: dict) -> None:
84
  'live_geometry_feature_limit': 8,
85
  'concept_contrast_pooling': 'max activation across non-padding prompt tokens',
86
  'candidate_causal_screen_limit': 8,
 
87
  }
88
  for key, value in expected.items():
89
  if config.get(key) != value:
@@ -179,6 +180,32 @@ def check_config(config: dict) -> None:
179
  'research_config.json live_features_v0_9 mismatch: ' f'{sorted(actual_live_v09)}'
180
  )
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  if 'german_language' not in config.get('concepts', []) or 'french_language' in config.get('concepts', []):
183
  raise SystemExit('research_config.json must use german_language and must not contain french_language.')
184
 
@@ -260,16 +287,21 @@ def check_readme() -> None:
260
  'spearman',
261
  'rank-shift',
262
  'no additional gpu',
 
 
 
 
 
263
  ]
264
  missing = [value for value in required_strings if value.lower() not in readme.lower()]
265
  if missing:
266
- raise SystemExit(f'README.md is missing required v0.10 content: {missing}')
267
 
268
 
269
  def check_pyproject() -> None:
270
  text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
271
- if 'version = "0.10.0"' not in text:
272
- raise SystemExit('pyproject.toml must declare version 0.10.0.')
273
 
274
 
275
  def main() -> None:
@@ -287,7 +319,7 @@ def main() -> None:
287
  print(f' layers: {config["layers"]}')
288
  print(f' feature-set sizes: {config["feature_set_sizes"]}')
289
  print(f' random controls: {config["live_random_controls"]}')
290
- print(' release: v0.10.0')
291
 
292
 
293
  if __name__ == '__main__':
 
84
  'live_geometry_feature_limit': 8,
85
  'concept_contrast_pooling': 'max activation across non-padding prompt tokens',
86
  'candidate_causal_screen_limit': 8,
87
+ 'candidate_specificity_limit': 3,
88
  }
89
  for key, value in expected.items():
90
  if config.get(key) != value:
 
180
  'research_config.json live_features_v0_9 mismatch: ' f'{sorted(actual_live_v09)}'
181
  )
182
 
183
+ required_live_v10 = {
184
+ 'discovery_to_causality_alignment_table',
185
+ 'association_evidence_vs_target_effect_scatter',
186
+ 'descriptive_spearman_concordance_summary',
187
+ 'target_effect_vs_distribution_shift_rank_separation',
188
+ 'no_extra_gpu_candidate_synthesis',
189
+ }
190
+ actual_live_v10 = set(config.get('live_features_v0_10', []))
191
+ if actual_live_v10 != required_live_v10:
192
+ raise SystemExit(
193
+ 'research_config.json live_features_v0_10 mismatch: ' f'{sorted(actual_live_v10)}'
194
+ )
195
+
196
+ required_live_v11 = {
197
+ 'controlled_multi_candidate_random_specificity_screen',
198
+ 'strategic_discovery_target_js_shortlist',
199
+ 'association_vs_controlled_causality_alignment',
200
+ 'target_specificity_vs_js_specificity_separation',
201
+ 'single_new_gpu_call_hf_acceptance',
202
+ }
203
+ actual_live_v11 = set(config.get('live_features_v0_11', []))
204
+ if actual_live_v11 != required_live_v11:
205
+ raise SystemExit(
206
+ 'research_config.json live_features_v0_11 mismatch: ' f'{sorted(actual_live_v11)}'
207
+ )
208
+
209
  if 'german_language' not in config.get('concepts', []) or 'french_language' in config.get('concepts', []):
210
  raise SystemExit('research_config.json must use german_language and must not contain french_language.')
211
 
 
287
  'spearman',
288
  'rank-shift',
289
  'no additional gpu',
290
+ 'controlled candidate specificity',
291
+ 'norm-matched random ensemble',
292
+ 'association vs controlled causality',
293
+ 'target-specificity ratio',
294
+ 'js-specificity ratio',
295
  ]
296
  missing = [value for value in required_strings if value.lower() not in readme.lower()]
297
  if missing:
298
+ raise SystemExit(f'README.md is missing required v0.11 content: {missing}')
299
 
300
 
301
  def check_pyproject() -> None:
302
  text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
303
+ if 'version = "0.11.0"' not in text:
304
+ raise SystemExit('pyproject.toml must declare version 0.11.0.')
305
 
306
 
307
  def main() -> None:
 
319
  print(f' layers: {config["layers"]}')
320
  print(f' feature-set sizes: {config["feature_set_sizes"]}')
321
  print(f' random controls: {config["live_random_controls"]}')
322
+ print(' release: v0.11.0')
323
 
324
 
325
  if __name__ == '__main__':
tests/test_live_runtime_helpers.py CHANGED
@@ -342,3 +342,24 @@ def test_candidate_causal_screen_batches_multiple_ablation_candidates() -> None:
342
  assert 0 <= result.active_feature_count <= result.candidate_count
343
  assert all(math.isfinite(float(row[5])) for row in result.rows)
344
  assert all(float(row[7]) >= 0 for row in result.rows)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  assert 0 <= result.active_feature_count <= result.candidate_count
343
  assert all(math.isfinite(float(row[5])) for row in result.rows)
344
  assert all(float(row[7]) >= 0 for row in result.rows)
345
+
346
+
347
+ def test_candidate_specificity_screen_batches_random_controlled_candidates() -> None:
348
+ runtime = make_runtime()
349
+ result = runtime.candidate_specificity_screen(
350
+ text='abc',
351
+ layer=0,
352
+ token_index=-1,
353
+ feature_ids=[0, 1],
354
+ target_text='d',
355
+ )
356
+ assert result.candidate_count == 2
357
+ assert result.random_control_count == 3
358
+ assert len(result.rows) == 2
359
+ assert len(result.chart_rows) == 4
360
+ assert all(len(row) == 17 for row in result.rows)
361
+ assert all(row[0] == rank for rank, row in enumerate(result.rows, start=1))
362
+ assert all(float(row[9]) >= 0 for row in result.rows) # target specificity
363
+ assert all(0 < float(row[10]) <= 1 for row in result.rows) # empirical tail
364
+ assert all(float(row[15]) >= 0 for row in result.rows) # JS specificity
365
+ assert all(0 < float(row[16]) <= 1 for row in result.rows)
tests/test_ui_helpers.py CHANGED
@@ -181,3 +181,78 @@ def test_candidate_alignment_ui_uses_same_screen_call_and_no_new_gpu_button() ->
181
  assert app.candidate_alignment_table.show_label is False
182
  assert app.candidate_alignment_plot.visible is True
183
  assert 'Association evidence vs target effect' == app.candidate_alignment_plot.title
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  assert app.candidate_alignment_table.show_label is False
182
  assert app.candidate_alignment_plot.visible is True
183
  assert 'Association evidence vs target effect' == app.candidate_alignment_plot.title
184
+
185
+
186
+ def _v10_discovery_table() -> pd.DataFrame:
187
+ return pd.DataFrame(
188
+ [
189
+ [1, 16369, 4.7348, 15.0, 0.0, 15.0, 1.0, 0.5, 0.0, 50.0, 29.25, True],
190
+ [2, 5712, 4.1969, 9.1, 1.5, 7.6, 0.72, 1.0, 0.17, 11.4, 11.38, True],
191
+ [3, 26112, 3.9419, 10.8, 0.0, 10.8, 1.0, 0.5, 0.0, 23.4, 23.42, True],
192
+ [4, 25992, 3.7342, 10.1, 0.0, 10.1, 1.0, 0.5, 0.0, 21.4, 21.36, True],
193
+ [5, 21670, 3.6200, 18.9, 2.0, 16.9, 0.81, 0.75, 0.125, 25.3, 6.36, True],
194
+ ],
195
+ columns=[
196
+ 'Rank', 'Feature id', 'Candidate score', 'Target mean max', 'Other mean max',
197
+ 'Mean difference', 'Selectivity', 'Target activation rate', 'Other activation rate',
198
+ 'Current prompt max', 'Current token activation', 'Active at current token',
199
+ ],
200
+ )
201
+
202
+
203
+ def _v10_screen_table() -> pd.DataFrame:
204
+ return pd.DataFrame(
205
+ [
206
+ [1, 25992, 21.3, True, 21.3, -0.1119, -0.2238, 0.001066],
207
+ [2, 21670, 6.36, True, 6.36, 0.0313, 0.0626, 0.000106],
208
+ [3, 26112, 23.4, True, 23.4, -0.0211, -0.0423, 0.000417],
209
+ [4, 5712, 11.38, True, 11.38, 0.0110, 0.0221, 0.000155],
210
+ [5, 16369, 29.23, True, 29.23, -0.0101, -0.0201, 0.001714],
211
+ ],
212
+ columns=[
213
+ 'Rank', 'Feature id', 'Native activation', 'Active at current token', 'Perturbation L2',
214
+ 'Δ mean log p/token', 'Δ sequence log p', 'Next-token JS',
215
+ ],
216
+ )
217
+
218
+
219
+ def test_controlled_candidate_shortlist_preserves_discovery_and_causal_leaders() -> None:
220
+ app = _import_app()
221
+ selected = app._controlled_candidate_shortlist(_v10_discovery_table(), _v10_screen_table(), limit=3)
222
+ assert selected == ['16369', '25992', '21670']
223
+
224
+
225
+ def test_controlled_alignment_uses_random_normalized_specificity() -> None:
226
+ app = _import_app()
227
+ controlled = pd.DataFrame(
228
+ [
229
+ [1, 25992, 21.3, True, 21.3, -0.1119, -0.01, 0.04, 0.02, 2.80, 0.111, -0.2238, 0.001066, 0.00040, 0.0001, 2.665, 0.111],
230
+ [2, 16369, 29.2, True, 29.2, -0.0101, 0.00, 0.05, 0.03, 0.20, 0.778, -0.0201, 0.001714, 0.00050, 0.0002, 3.428, 0.111],
231
+ [3, 21670, 6.36, True, 6.36, 0.0313, 0.00, 0.03, 0.01, 1.04, 0.444, 0.0626, 0.000106, 0.00020, 0.0001, 0.53, 0.778],
232
+ ],
233
+ columns=[
234
+ 'Rank', 'Feature id', 'Native activation', 'Active at current token', 'Perturbation L2',
235
+ 'SAE Δ mean log p/token', 'Random signed mean Δ', 'Random mean |Δ|', 'Random |Δ| std',
236
+ 'Target specificity ratio', 'Target empirical tail p', 'SAE Δ sequence log p',
237
+ 'SAE next-token JS', 'Random mean JS', 'Random JS std', 'JS specificity ratio',
238
+ 'JS empirical tail p',
239
+ ],
240
+ )
241
+ summary, table, chart = app._controlled_alignment_outputs(_v10_discovery_table(), controlled)
242
+ assert len(table) == 3
243
+ assert int(table.loc[table['Specificity rank'].idxmin(), 'Feature id']) == 25992
244
+ row_25992 = table.loc[table['Feature id'] == 25992].iloc[0]
245
+ row_16369 = table.loc[table['Feature id'] == 16369].iloc[0]
246
+ assert int(row_25992['Discovery→specificity rank shift']) == 3
247
+ assert int(row_16369['Discovery→specificity rank shift']) == -1
248
+ assert 'norm-matched random ensemble' in summary
249
+ assert set(chart['Feature id']) == {'25992', '16369', '21670'}
250
+
251
+
252
+ def test_controlled_candidate_ui_limits_live_comparison_to_three_features() -> None:
253
+ app = _import_app()
254
+ assert app.candidate_specificity_ids.multiselect is True
255
+ assert app.candidate_specificity_ids.max_choices == 3
256
+ assert app.candidate_specificity_target.value == '2x'
257
+ assert app.candidate_specificity_table.show_label is False
258
+ assert app.controlled_alignment_table.show_label is False