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

Release FeatureLens v0.12.0

Browse files
CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
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.
 
1
  # Changelog
2
 
3
+ ## v0.12.0
4
+
5
+ - Added **split-half discovery stability** using the already-computed concept activation batch, so shortlist sensitivity is visible without another GPU forward.
6
+ - Added **Controlled evidence patterns**, a zero-GPU synthesis that distinguishes broad controlled influence, target-weighted effects, distribution-shift-dominant effects, and weak/mixed specificity while keeping eight-control tails explicitly coarse.
7
+ - Changed **Association vs controlled causality** so missing discovery state after a Space rebuild produces an explicit explanation instead of a blank panel.
8
+ - Added **Cross-target causal profile** for up to three candidates and five exact continuations, screening whether native ablation effects concentrate on one target or generalize across alternatives.
9
+ - Added automatic cross-target shortlist handoff from the target-specificity and JS-specificity leaders.
10
+ - Kept the validated in-place focus behavior unchanged.
11
+ - Kept HF validation quota-aware: only the touched discovery path and the new cross-target path need live GPU acceptance.
12
+
13
  ## v0.11.0
14
 
15
  - 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.
README.md CHANGED
@@ -13,7 +13,7 @@ license: mit
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
 
@@ -38,6 +38,9 @@ FeatureLens uses **Qwen3-1.7B-Base** with **Qwen-Scope residual-stream sparse au
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.
@@ -368,6 +371,16 @@ FeatureLens/
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,7 +391,7 @@ python scripts/ui_smoke.py
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,7 +407,7 @@ See [`docs/VALIDATION.md`](docs/VALIDATION.md). The v0.11 validation guide uses
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
 
 
13
 
14
  # FeatureLens — Causal Interpretability Workbench
15
 
16
+ > **v0.12:** a causal-evidence workflow that adds split-half discovery stability, controlled evidence-pattern synthesis, and cross-target profiling on top of random-normalized candidate specificity.
17
 
18
  **Research question:**
19
 
 
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. **Discovery stability** — does a live candidate shortlist survive a split-half check inside the same activation batch?
42
+ 16. **Cross-target profile** — does a candidate ablation concentrate on one exact continuation or move several alternatives?
43
+ 17. **Controlled evidence patterns** — is random-normalized influence target-weighted, distribution-shift weighted, broad, or weak/mixed?
44
  15. **Association–causality concordance** — do features ranked highly by concept evidence also rank highly by downstream target effect or distribution shift?
45
 
46
  A high AUROC, high paraphrase overlap, or a large activation is still correlational evidence. Causal evidence comes from downstream change under controlled intervention.
 
371
  - 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.
372
  - 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.
373
 
374
+ ## v0.12 evidence synthesis and cross-target profiling
375
+
376
+ v0.12 bundles several related improvements instead of adding one isolated widget:
377
+
378
+ - **Split-half discovery stability** reuses the concept-discovery activation batch and compares candidate shortlists from two prompt halves. This adds no GPU inference and is explicitly a small-sample sensitivity diagnostic rather than a reliability estimate.
379
+ - **Controlled evidence patterns** always summarize the random-normalized candidate table, separating broad controlled influence, target-weighted effects, distribution-shift-dominant effects, and weak/mixed specificity. These are effect-ratio descriptions, not significance labels.
380
+ - **Association vs controlled causality** no longer fails silently when discovery state is absent after a Space rebuild. The UI explains that controlled evidence remains valid while discovery-rank alignment requires the discovery table from the current browser session.
381
+ - **Cross-target causal profile** screens up to three candidate ablations across two to five exact continuations. It reports target-wise mean/sequence log-probability deltas, next-token JS, the strongest target per feature, and a target-profile ratio. This stage intentionally omits random controls; controlled candidate specificity remains the matched-random causal test.
382
+ - HF validation remains GPU-budget-aware: rerun only the touched discovery path and the new cross-target path. Unchanged paraphrase, trajectory, set-size, dose-response, cue, and focus paths stay covered by automated tests.
383
+
384
  ## Validation
385
 
386
  ```bash
 
391
  python scripts/release_check.py
392
  ```
393
 
394
+ See [`docs/VALIDATION.md`](docs/VALIDATION.md). The v0.12 guide uses the **exact labels shown in the UI** and asks for only two touched/new HF paths: causal-ready discovery (to validate split-half stability) and the cross-target causal profile. Unchanged paraphrase, trajectory, feature-set, dose-response, cue, controlled-specificity, and focus paths are not rerun.
395
 
396
  ## Limitations
397
 
 
407
  ## Resume-ready description
408
 
409
  > **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
410
+ > 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, split-half discovery stability, cross-target causal profiling, controlled evidence-pattern synthesis, and norm-matched random-control ensembles.
411
 
412
  ## Acknowledgements
413
 
app.py CHANGED
@@ -598,13 +598,20 @@ def _discovery_metrics_markdown(result) -> str:
598
  if result.current_context_available
599
  else "No Workbench prompt was available, so current-prompt compatibility was not evaluated."
600
  )
 
 
 
 
 
 
 
601
  return (
602
  f"Concept **{result.concept}** · layer **{result.layer}** · "
603
  f"{result.prompts_per_concept} prompts/concept. \n"
604
  f"Showing **{len(result.candidate_ids)}** candidates ranked by **{ranking}**. \n"
605
  f"Displayed candidates active at the selected Workbench token: "
606
  f"**{result.displayed_current_active_count}/{len(result.candidate_ids)}**. \n"
607
- f"{context} \n\n"
608
  "This is **candidate discovery**, not a semantic label. The offline held-out AUROC/F1 pipeline remains "
609
  "the standard for a concept-selectivity claim."
610
  )
@@ -828,6 +835,115 @@ def _candidate_specificity_metrics_markdown(result) -> str:
828
  )
829
 
830
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
831
  def _controlled_alignment_outputs(
832
  discovery_table: pd.DataFrame | None,
833
  specificity_table: pd.DataFrame | None,
@@ -850,12 +966,27 @@ def _controlled_alignment_outputs(
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")
@@ -1624,21 +1755,84 @@ def run_candidate_specificity_screen(
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,7 +1919,7 @@ def set_mode_help(mode: str):
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>'
@@ -2415,6 +2609,26 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
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 "
@@ -2457,7 +2671,74 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
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."
2463
  )
@@ -2516,7 +2797,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
2516
  height=320,
2517
  )
2518
 
2519
- gr.HTML('<div class="section-rule">D. Completion-cue sensitivity</div>')
2520
  gr.Markdown(
2521
  "Feature 22632 in the current math example fires only on the final `is` token. This scan tests whether a "
2522
  "selected feature responds to **completion cues themselves** rather than the underlying concept. Enter a stem "
@@ -2540,7 +2821,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
2540
  buttons=["fullscreen", "export"], elem_classes=["fl-plot"], height=320
2541
  )
2542
 
2543
- gr.HTML('<div class="section-rule">E. Cue × context specificity</div>')
2544
  gr.Markdown(
2545
  "The single-stem cue scan can tell you that a feature prefers `is`, but not whether that preference is lexical "
2546
  "or context-sensitive. This matrix crosses several prompt stems with the same completion cues in one batch."
@@ -2595,7 +2876,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
2595
  height=340,
2596
  )
2597
 
2598
- gr.HTML('<div class="section-rule">F. Controlled concept contrast for selected feature</div>')
2599
  gr.Markdown("### Prompt-wide controlled concept contrast")
2600
  contrast_btn = gr.Button("Run controlled concept contrast", variant="primary", elem_classes=["action-btn"])
2601
  contrast_metrics = gr.Markdown()
@@ -2920,10 +3201,32 @@ Association, robustness, geometry, and intervention evidence remain separate cla
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(
@@ -2988,7 +3291,10 @@ Association, robustness, geometry, and intervention evidence remain separate cla
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),
 
598
  if result.current_context_available
599
  else "No Workbench prompt was available, so current-prompt compatibility was not evaluated."
600
  )
601
+ stability = ""
602
+ if result.split_half_jaccard is not None and result.split_half_k is not None:
603
+ stability = (
604
+ f" \nSplit-half shortlist stability from the same activation batch: "
605
+ f"**{result.split_half_shared_count}** shared candidate(s), Jaccard **{result.split_half_jaccard:.3f}**. "
606
+ f"This is a small-sample sensitivity diagnostic, not a reliability estimate."
607
+ )
608
  return (
609
  f"Concept **{result.concept}** · layer **{result.layer}** · "
610
  f"{result.prompts_per_concept} prompts/concept. \n"
611
  f"Showing **{len(result.candidate_ids)}** candidates ranked by **{ranking}**. \n"
612
  f"Displayed candidates active at the selected Workbench token: "
613
  f"**{result.displayed_current_active_count}/{len(result.candidate_ids)}**. \n"
614
+ f"{context}{stability} \n\n"
615
  "This is **candidate discovery**, not a semantic label. The offline held-out AUROC/F1 pipeline remains "
616
  "the standard for a concept-selectivity claim."
617
  )
 
835
  )
836
 
837
 
838
+ def _controlled_evidence_patterns(
839
+ specificity_table: pd.DataFrame | None,
840
+ ) -> tuple[str, pd.DataFrame]:
841
+ """Summarize controlled target-vs-distribution specificity without requiring discovery state."""
842
+ columns = [
843
+ "Feature id",
844
+ "Target specificity ratio",
845
+ "JS specificity ratio",
846
+ "Target empirical tail p",
847
+ "JS empirical tail p",
848
+ "Evidence pattern",
849
+ "Interpretation",
850
+ ]
851
+ if specificity_table is None:
852
+ return "", pd.DataFrame(columns=columns)
853
+ table = pd.DataFrame(specificity_table).copy()
854
+ needed = {
855
+ "Feature id",
856
+ "Target specificity ratio",
857
+ "JS specificity ratio",
858
+ "Target empirical tail p",
859
+ "JS empirical tail p",
860
+ }
861
+ if table.empty or not needed.issubset(table.columns):
862
+ return "", pd.DataFrame(columns=columns)
863
+
864
+ rows: list[list[object]] = []
865
+ for _, row in table.iterrows():
866
+ feature_id = int(float(row["Feature id"]))
867
+ target_ratio = float(row["Target specificity ratio"])
868
+ js_ratio = float(row["JS specificity ratio"])
869
+ target_p = float(row["Target empirical tail p"])
870
+ js_p = float(row["JS empirical tail p"])
871
+ if target_ratio >= 1.5 and js_ratio >= 1.5:
872
+ pattern = "Broad controlled influence"
873
+ interpretation = "Both the specified target and the local next-token distribution exceed matched-random magnitude baselines."
874
+ elif target_ratio >= 1.5:
875
+ pattern = "Target-weighted"
876
+ interpretation = "The specified continuation is affected more strongly than the broader distributional diagnostic."
877
+ elif js_ratio >= 1.5 and target_ratio < 1.0:
878
+ pattern = "Distribution-shift dominant"
879
+ interpretation = "The feature changes the local distribution beyond matched random directions without selectively controlling this target."
880
+ elif js_ratio >= 1.5:
881
+ pattern = "Distribution-shift weighted"
882
+ interpretation = "Distributional influence is clearer than target-specific influence for the tested continuation."
883
+ else:
884
+ pattern = "Weak / mixed specificity"
885
+ interpretation = "Neither live specificity ratio clearly dominates its matched-random baseline."
886
+ rows.append([feature_id, target_ratio, js_ratio, target_p, js_p, pattern, interpretation])
887
+
888
+ out = pd.DataFrame(rows, columns=columns)
889
+ descriptions = "; ".join(
890
+ f"**{int(row['Feature id'])}**: {row['Evidence pattern']}" for _, row in out.iterrows()
891
+ )
892
+ summary = (
893
+ f"Controlled evidence patterns — {descriptions}. \n\n"
894
+ "These labels summarize **effect ratios**, not statistical significance. With eight random controls, empirical tail "
895
+ "values are coarse; the offline benchmark is the place for stronger uncertainty estimates."
896
+ )
897
+ return summary, out
898
+
899
+
900
+ def _cross_target_shortlist(specificity_table: pd.DataFrame | None, limit: int = 2) -> list[str]:
901
+ if specificity_table is None:
902
+ return []
903
+ table = pd.DataFrame(specificity_table).copy()
904
+ needed = {"Feature id", "Target specificity ratio", "JS specificity ratio"}
905
+ if table.empty or not needed.issubset(table.columns):
906
+ return []
907
+ table["Feature id"] = pd.to_numeric(table["Feature id"], errors="coerce")
908
+ table = table.dropna(subset=["Feature id"]).copy()
909
+ table["Feature id"] = table["Feature id"].astype(int)
910
+ selected: list[int] = []
911
+
912
+ def add(feature_id: int) -> None:
913
+ if feature_id not in selected and len(selected) < int(limit):
914
+ selected.append(feature_id)
915
+
916
+ target = table.sort_values(["Target specificity ratio", "Feature id"], ascending=[False, True])
917
+ js = table.sort_values(["JS specificity ratio", "Feature id"], ascending=[False, True])
918
+ if not target.empty:
919
+ add(int(target.iloc[0]["Feature id"]))
920
+ if not js.empty:
921
+ add(int(js.iloc[0]["Feature id"]))
922
+ for feature_id in target["Feature id"].tolist():
923
+ add(int(feature_id))
924
+ return [str(feature_id) for feature_id in selected]
925
+
926
+
927
+ def _cross_target_metrics_markdown(result) -> str:
928
+ feature_text = ", ".join(str(feature_id) for feature_id in result.feature_ids)
929
+ target_text = ", ".join(repr(target) for target in result.targets)
930
+ if result.summary_rows:
931
+ strongest = result.summary_rows[0]
932
+ lead = (
933
+ f"Largest screened cross-target effect: feature **{int(strongest[0])}** on **{strongest[1]!r}** "
934
+ f"with Δ mean log p/token **{float(strongest[2]):+.4f}**."
935
+ )
936
+ else:
937
+ lead = "No cross-target rows were produced."
938
+ return (
939
+ f"Profiled feature(s) **{feature_text}** across exact continuation(s) {target_text}; "
940
+ f"**{result.active_feature_count}/{len(result.feature_ids)}** selected features were active at the Workbench token. \n"
941
+ f"{lead} \n\n"
942
+ "This is a **target-profile screen** using native SAE ablations and a batched no-edit reference for each continuation. "
943
+ "It does not spend random controls, so use Controlled candidate specificity for matched-random causal claims."
944
+ )
945
+
946
+
947
  def _controlled_alignment_outputs(
948
  discovery_table: pd.DataFrame | None,
949
  specificity_table: pd.DataFrame | None,
 
966
  "JS empirical tail p",
967
  "Discovery→specificity rank shift",
968
  ]
969
+ if specificity_table is None:
970
  return "", pd.DataFrame(columns=columns), pd.DataFrame()
971
+ if discovery_table is None:
972
+ return (
973
+ "Controlled specificity results are available, but the concept-discovery table is not present in this browser "
974
+ "session, so discovery-rank alignment cannot be reconstructed. The controlled evidence-pattern summary above "
975
+ "remains valid; rerun discovery only if you specifically want association-rank alignment.",
976
+ pd.DataFrame(columns=columns),
977
+ pd.DataFrame(),
978
+ )
979
  discovery = pd.DataFrame(discovery_table).copy()
980
  controlled = pd.DataFrame(specificity_table).copy()
981
+ if controlled.empty or "Feature id" not in controlled:
982
  return "", pd.DataFrame(columns=columns), pd.DataFrame()
983
+ if discovery.empty or "Feature id" not in discovery:
984
+ return (
985
+ "Controlled specificity results are available, but no concept-discovery evidence is currently populated. "
986
+ "The controlled evidence-pattern summary above remains valid without that association table.",
987
+ pd.DataFrame(columns=columns),
988
+ pd.DataFrame(),
989
+ )
990
 
991
  discovery["Feature id"] = pd.to_numeric(discovery["Feature id"], errors="coerce")
992
  controlled["Feature id"] = pd.to_numeric(controlled["Feature id"], errors="coerce")
 
1755
  result.chart_rows,
1756
  columns=["Feature", "Specificity metric", "Ratio"],
1757
  )
1758
+ pattern_metrics, pattern_table = _controlled_evidence_patterns(table)
1759
  alignment_metrics, alignment_table, alignment_chart = _controlled_alignment_outputs(
1760
  discovery_table, table
1761
  )
1762
  choices = [str(feature_id) for feature_id in result.feature_ids]
1763
  candidate_update = gr.update(choices=choices, value=choices[0] if choices else None)
1764
+ cross_target_values = _cross_target_shortlist(table, limit=2)
1765
+ cross_target_update = gr.update(choices=choices, value=cross_target_values)
1766
  return (
1767
  _candidate_specificity_metrics_markdown(result),
1768
  table,
1769
  chart,
1770
  candidate_update,
1771
  _tsv(table),
1772
+ pattern_metrics,
1773
+ pattern_table,
1774
+ _tsv(pattern_table),
1775
  alignment_metrics,
1776
  alignment_table,
1777
  alignment_chart,
1778
  _tsv(alignment_table),
1779
+ cross_target_update,
1780
+ )
1781
+ except Exception as exc:
1782
+ _raise_ui_error(exc)
1783
+
1784
+
1785
+ @gpu(duration=40)
1786
+ def run_candidate_cross_target_profile(
1787
+ prompt: str,
1788
+ layer: int,
1789
+ token_index: int,
1790
+ feature_ids: list[str] | None,
1791
+ targets_text: str,
1792
+ ):
1793
+ try:
1794
+ selected = [int(float(value)) for value in (feature_ids or [])]
1795
+ targets = [line for line in str(targets_text).splitlines() if line.strip()]
1796
+ result = RUNTIME.candidate_cross_target_profile(
1797
+ text=prompt,
1798
+ layer=int(layer),
1799
+ token_index=int(token_index),
1800
+ feature_ids=selected,
1801
+ targets=targets,
1802
+ )
1803
+ columns = [
1804
+ "Feature id",
1805
+ "Target continuation",
1806
+ "Target token count",
1807
+ "Native activation",
1808
+ "Perturbation L2",
1809
+ "Δ mean log p/token",
1810
+ "Δ sequence log p",
1811
+ "Next-token JS",
1812
+ ]
1813
+ table = pd.DataFrame(result.rows, columns=columns)
1814
+ chart = pd.DataFrame(
1815
+ result.chart_rows,
1816
+ columns=["Target continuation", "Feature", "Δ mean log p/token"],
1817
+ )
1818
+ summary_columns = [
1819
+ "Feature id",
1820
+ "Strongest target",
1821
+ "Δ mean log p/token at strongest target",
1822
+ "Strongest |effect|",
1823
+ "Mean |effect| on other targets",
1824
+ "Target-profile ratio",
1825
+ "Effect sign pattern",
1826
+ "Maximum next-token JS",
1827
+ ]
1828
+ summary_table = pd.DataFrame(result.summary_rows, columns=summary_columns)
1829
+ return (
1830
+ _cross_target_metrics_markdown(result),
1831
+ table,
1832
+ chart,
1833
+ summary_table,
1834
+ _tsv(table),
1835
+ _tsv(summary_table),
1836
  )
1837
  except Exception as exc:
1838
  _raise_ui_error(exc)
 
1919
  with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_width=True) as demo:
1920
  gr.HTML(
1921
  '<header class="hero">'
1922
+ '<h1>FeatureLens <span style="font-size:.48em;opacity:.58;font-weight:400">v0.12</span></h1>'
1923
  '<div class="subtitle">Causal Interpretability Workbench</div>'
1924
  '<div class="question">Discover sparse features, test robustness, and separate correlation from causal influence.</div>'
1925
  '</header>'
 
2609
  height=330,
2610
  )
2611
 
2612
+ gr.Markdown("#### Controlled evidence patterns")
2613
+ gr.Markdown(
2614
+ "Zero-GPU synthesis of the controlled table above. It distinguishes target-weighted effects from broader "
2615
+ "distribution-shift effects without pretending that eight-control tail values are conventional significance tests.",
2616
+ elem_classes=["candidate-help"],
2617
+ )
2618
+ controlled_pattern_metrics = gr.Markdown()
2619
+ _table_heading('Controlled evidence pattern summary')
2620
+ controlled_pattern_table = gr.Dataframe(
2621
+ interactive=False,
2622
+ label="Controlled evidence pattern summary",
2623
+ show_label=False,
2624
+ buttons=["fullscreen"],
2625
+ elem_classes=["result-table"],
2626
+ wrap=True,
2627
+ max_height=300,
2628
+ )
2629
+ controlled_pattern_tsv = gr.Textbox(visible="hidden")
2630
+ controlled_pattern_copy = _copy_button()
2631
+
2632
  gr.Markdown("#### Association vs controlled causality")
2633
  gr.Markdown(
2634
  "Computed from concept discovery and the random-controlled comparison above. This is the stronger live version "
 
2671
  height=330,
2672
  )
2673
 
2674
+ gr.HTML('<div class="section-rule">C. Cross-target causal profile</div>')
2675
+ gr.Markdown(
2676
+ "Use the controlled shortlist to test whether a native SAE ablation is concentrated on one exact continuation "
2677
+ "or moves several alternatives. This is a cheap target-profile screen: no random controls are spent here."
2678
+ )
2679
+ with gr.Row(equal_height=True):
2680
+ cross_target_ids = gr.Dropdown(
2681
+ choices=[],
2682
+ value=[],
2683
+ multiselect=True,
2684
+ allow_custom_value=True,
2685
+ max_choices=3,
2686
+ label="Features for cross-target profile",
2687
+ info="Auto-filled from the target-specificity leader and JS-specificity leader when available.",
2688
+ scale=3,
2689
+ )
2690
+ cross_target_text = gr.Textbox(
2691
+ label="Exact target continuations (one per line)",
2692
+ value="2x\nx\n0\nx^2",
2693
+ lines=4,
2694
+ info="Two to five continuations. Each is teacher-forced separately.",
2695
+ scale=2,
2696
+ )
2697
+ cross_target_btn = gr.Button(
2698
+ "Run cross-target causal profile", variant="primary", elem_classes=["action-btn"]
2699
+ )
2700
+ cross_target_metrics = gr.Markdown()
2701
+ with gr.Row(equal_height=False):
2702
+ with gr.Column(scale=3):
2703
+ _table_heading('Cross-target causal profile')
2704
+ cross_target_table = gr.Dataframe(
2705
+ interactive=False,
2706
+ label="Cross-target causal profile",
2707
+ show_label=False,
2708
+ buttons=["fullscreen"],
2709
+ elem_classes=["result-table"],
2710
+ wrap=False,
2711
+ max_height=390,
2712
+ )
2713
+ cross_target_tsv = gr.Textbox(visible="hidden")
2714
+ cross_target_copy = _copy_button()
2715
+ with gr.Column(scale=2):
2716
+ cross_target_plot = gr.BarPlot(
2717
+ x="Target continuation",
2718
+ y="Δ mean log p/token",
2719
+ color="Feature",
2720
+ title="Candidate effect across exact continuations",
2721
+ elem_id="plot-cross-target-profile",
2722
+ x_title="Target continuation",
2723
+ y_title="Δ mean log p/token",
2724
+ buttons=["fullscreen", "export"],
2725
+ elem_classes=["fl-plot"],
2726
+ height=330,
2727
+ )
2728
+ _table_heading('Target-profile summary')
2729
+ cross_target_summary_table = gr.Dataframe(
2730
+ interactive=False,
2731
+ label="Target-profile summary",
2732
+ show_label=False,
2733
+ buttons=["fullscreen"],
2734
+ elem_classes=["result-table"],
2735
+ wrap=False,
2736
+ max_height=260,
2737
+ )
2738
+ cross_target_summary_tsv = gr.Textbox(visible="hidden")
2739
+ cross_target_summary_copy = _copy_button()
2740
+
2741
+ gr.HTML('<div class="section-rule">D. Inspect one feature</div>')
2742
  contrast_location = gr.Markdown(
2743
  "**Activation trace uses the current Workbench prompt.** The controlled concept scan below uses its own balanced prompt set."
2744
  )
 
2797
  height=320,
2798
  )
2799
 
2800
+ gr.HTML('<div class="section-rule">E. Completion-cue sensitivity</div>')
2801
  gr.Markdown(
2802
  "Feature 22632 in the current math example fires only on the final `is` token. This scan tests whether a "
2803
  "selected feature responds to **completion cues themselves** rather than the underlying concept. Enter a stem "
 
2821
  buttons=["fullscreen", "export"], elem_classes=["fl-plot"], height=320
2822
  )
2823
 
2824
+ gr.HTML('<div class="section-rule">F. Cue × context specificity</div>')
2825
  gr.Markdown(
2826
  "The single-stem cue scan can tell you that a feature prefers `is`, but not whether that preference is lexical "
2827
  "or context-sensitive. This matrix crosses several prompt stems with the same completion cues in one batch."
 
2876
  height=340,
2877
  )
2878
 
2879
+ gr.HTML('<div class="section-rule">G. Controlled concept contrast for selected feature</div>')
2880
  gr.Markdown("### Prompt-wide controlled concept contrast")
2881
  contrast_btn = gr.Button("Run controlled concept contrast", variant="primary", elem_classes=["action-btn"])
2882
  contrast_metrics = gr.Markdown()
 
3201
  candidate_specificity_plot,
3202
  discovery_candidate,
3203
  candidate_specificity_tsv,
3204
+ controlled_pattern_metrics,
3205
+ controlled_pattern_table,
3206
+ controlled_pattern_tsv,
3207
  controlled_alignment_metrics,
3208
  controlled_alignment_table,
3209
  controlled_alignment_plot,
3210
  controlled_alignment_tsv,
3211
+ cross_target_ids,
3212
+ ],
3213
+ )
3214
+ cross_target_btn.click(
3215
+ run_candidate_cross_target_profile,
3216
+ inputs=[
3217
+ prompt,
3218
+ discovery_layer,
3219
+ token_index,
3220
+ cross_target_ids,
3221
+ cross_target_text,
3222
+ ],
3223
+ outputs=[
3224
+ cross_target_metrics,
3225
+ cross_target_table,
3226
+ cross_target_plot,
3227
+ cross_target_summary_table,
3228
+ cross_target_tsv,
3229
+ cross_target_summary_tsv,
3230
  ],
3231
  )
3232
  candidate_specificity_table.select(
 
3291
  (candidate_screen_copy, candidate_screen_tsv),
3292
  (candidate_alignment_copy, candidate_alignment_tsv),
3293
  (candidate_specificity_copy, candidate_specificity_tsv),
3294
+ (controlled_pattern_copy, controlled_pattern_tsv),
3295
  (controlled_alignment_copy, controlled_alignment_tsv),
3296
+ (cross_target_copy, cross_target_tsv),
3297
+ (cross_target_summary_copy, cross_target_summary_tsv),
3298
  (cue_copy, cue_tsv),
3299
  (cue_context_copy, cue_context_tsv),
3300
  (para_copy, para_tsv),
docs/METHODOLOGY.md CHANGED
@@ -531,3 +531,41 @@ The zero-GPU synthesis layer joins discovery evidence to the controlled table an
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.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
534
+
535
+ ## v0.12 split-half discovery stability and cross-target profiling
536
+
537
+ ### Split-half discovery stability
538
+
539
+ The live concept-discovery batch already contains multiple controlled prompts per concept. v0.12 reuses those activations to form two prompt halves and independently reranks candidates under the same discovery mode. The UI reports the overlap/Jaccard of the two top-k sets. This costs no additional model inference.
540
+
541
+ Because the live setting uses only a few prompts per concept, this is a **sensitivity diagnostic**, not a confidence interval or reliability claim. Low overlap means the live shortlist is sample-sensitive and should not be treated as a stable semantic ranking.
542
+
543
+ ### Controlled evidence patterns
544
+
545
+ For every random-controlled candidate, FeatureLens keeps target specificity and whole-distribution JS specificity separate. v0.12 adds a descriptive pattern layer:
546
+
547
+ - **Broad controlled influence**: both target and JS specificity ratios are at least 1.5× their matched-random means.
548
+ - **Target-weighted**: target specificity is at least 1.5× while JS specificity is lower.
549
+ - **Distribution-shift dominant**: JS specificity is at least 1.5× while target specificity is below 1×.
550
+ - **Distribution-shift weighted**: JS specificity is at least 1.5× while target specificity is weaker but not below 1×.
551
+ - **Weak / mixed specificity**: neither ratio clears the descriptive 1.5× threshold.
552
+
553
+ These are effect-ratio summaries only. The live eight-control empirical tails remain coarse and are not converted into significance labels.
554
+
555
+ ### Cross-target causal profile
556
+
557
+ A feature can have a large effect on one specified continuation without being generally causal for the underlying concept. v0.12 therefore evaluates the same native SAE ablation against several exact continuations.
558
+
559
+ For each target continuation, FeatureLens computes a batched zero-edit baseline and reports:
560
+
561
+ - Δ mean log probability per target token,
562
+ - Δ full-sequence log probability,
563
+ - next-token JS divergence.
564
+
565
+ For each feature it also reports the strongest target by absolute mean-log-probability effect, the mean absolute effect on the remaining targets, and a **target-profile ratio**:
566
+
567
+ \[
568
+ R_{profile} = \frac{\max_t |\Delta \bar{\ell}_t|}{\operatorname{mean}_{u \ne t^*}|\Delta \bar{\ell}_u|}.
569
+ \]
570
+
571
+ This profile is a screening diagnostic and intentionally omits random controls. Random-normalized causal claims still require the Controlled candidate specificity experiment.
docs/VALIDATION.md CHANGED
@@ -1,10 +1,10 @@
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,7 +14,9 @@ python3 scripts/ui_smoke.py && \
14
  python3 scripts/release_check.py
15
  ```
16
 
17
- Expected release checker tail:
 
 
18
 
19
  ```text
20
  FeatureLens release check: PASS
@@ -23,20 +25,12 @@ FeatureLens release check: PASS
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
@@ -44,92 +38,94 @@ 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.
 
1
+ # FeatureLens v0.12 validation
2
 
3
+ v0.12 touches two GPU inference paths: **concept-guided discovery** (only to add split-half stability from the same activations) and the new **cross-target causal profile**. Do not rerun unchanged experiments merely as regressions.
4
 
5
  ## Local release gate
6
 
7
+ From the repository root:
8
 
9
  ```bash
10
  python3 -m pytest -q && \
 
14
  python3 scripts/release_check.py
15
  ```
16
 
17
+ Expected automated test count: **65 passed**.
18
+
19
+ Expected release footer:
20
 
21
  ```text
22
  FeatureLens release check: PASS
 
25
  layers: [4, 14, 26]
26
  feature-set sizes: [1, 3, 5]
27
  random controls: 8
28
+ release: v0.12.0
29
  ```
30
 
31
+ ## HF acceptance only two GPU calls
 
 
 
 
32
 
33
+ Use this Workbench context before both calls:
 
 
 
 
34
 
35
  ```text
36
  Prompt: The derivative of x squared is
 
38
  Prompt token index: -1
39
  ```
40
 
41
+ You do not need to rerun single-feature causality, dose response, paraphrase robustness, layer trajectory, feature-set sweeps, cue diagnostics, or focus/zoom.
42
 
43
+ ### GPU call 1 — touched discovery path
 
 
 
 
44
 
45
+ Exact UI path:
46
 
47
+ **Feature evidence A. Concept-guided candidate discovery**
 
 
48
 
49
  Set:
50
 
51
  ```text
52
+ Target concept: mathematics
53
+ Residual layer: 14
54
+ Prompts per concept: 4
55
+ Candidate features: 12
56
+ Candidate ranking: Causal-ready at current token
57
  ```
58
 
59
+ Click:
60
 
61
+ **Discover concept-associated candidates**
62
 
63
+ Pass conditions:
64
 
65
+ - The normal candidate table still appears.
66
+ - The summary includes a line beginning **Split-half shortlist stability from the same activation batch**.
67
+ - The reported Jaccard is in `[0, 1]`.
68
+ - The diagnostic is described as small-sample sensitivity, not semantic reliability.
 
 
 
 
69
 
70
+ Record only the split-half shared count/Jaccard unless the candidate ranking itself changes unexpectedly.
71
 
72
+ ### GPU call 2 — new cross-target causal profile
73
+
74
+ Exact UI path:
75
+
76
+ **Feature evidence → C. Cross-target causal profile**
77
 
78
+ Set:
79
+
80
+ ```text
81
+ Features for cross-target profile:
82
+ 25992
83
+ 16369
84
+ 21670
85
 
86
+ Exact target continuations (one per line):
87
+ 2x
88
+ x
89
+ 0
90
+ x^2
91
+ ```
92
 
93
+ Click:
94
 
95
+ **Run cross-target causal profile**
96
 
97
+ Pass conditions:
 
 
 
 
 
 
 
 
98
 
99
+ - The summary says 3 features were profiled across 4 exact continuations.
100
+ - **Cross-target causal profile** contains `3 × 4 = 12` rows.
101
+ - **Target-profile summary** contains one row per feature.
102
+ - The chart contains separate feature series across the four targets.
103
+ - No random-control/significance claim is made in this panel.
104
 
105
+ Please return:
106
 
107
+ 1. the **Target-profile summary** table;
108
+ 2. the 12-row **Cross-target causal profile** table (or at minimum the four rows for each feature);
109
+ 3. the chart screenshot if convenient.
110
 
111
+ ## Zero-GPU behavior covered by automated tests
112
 
113
+ Do not spend HF quota solely to test these:
 
 
 
 
 
 
 
 
114
 
115
+ - **Controlled evidence patterns** classifies the existing controlled specificity table without model inference.
116
+ - If **Association vs controlled causality** has no discovery table because the Space was rebuilt, it now displays an explicit state-explanation instead of staying silently blank.
117
+ - The cross-target selector prefers the target-specificity leader and JS-specificity leader when controlled results are present in the same session.
118
 
119
+ ## Do not rerun for v0.12
120
 
121
+ Unless something visibly breaks, skip:
122
 
123
+ - identity paraphrase;
124
+ - layer trajectory;
125
+ - 1/3/5 feature-set sweep;
126
+ - scale dose-response;
127
+ - cue × context;
128
+ - controlled candidate specificity;
129
+ - zoom/focus behavior.
130
 
131
+ Those implementations are unchanged in v0.12 and remain covered by the automated suite.
featurelens/runtime.py CHANGED
@@ -241,6 +241,9 @@ class ConceptFeatureDiscoveryResult:
241
  current_context_available: bool
242
  current_token_index: int | None
243
  displayed_current_active_count: int
 
 
 
244
 
245
 
246
  @dataclass
@@ -268,6 +271,16 @@ class CandidateSpecificityResult:
268
  execution_drift_js: float
269
 
270
 
 
 
 
 
 
 
 
 
 
 
271
  @dataclass
272
  class FeatureCueScanResult:
273
  feature_id: int
@@ -1937,6 +1950,65 @@ class FeatureLensRuntime:
1937
  include_self=True,
1938
  )
1939
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1940
  eligible = (target_mean > 0) & (mean_diff > 0)
1941
  if ranking_mode == 'causal_ready':
1942
  if not current_context_available or resolved_current_idx is None:
@@ -1966,6 +2038,9 @@ class FeatureLensRuntime:
1966
  current_context_available=current_context_available,
1967
  current_token_index=resolved_current_idx,
1968
  displayed_current_active_count=0,
 
 
 
1969
  )
1970
 
1971
  order = torch.argsort(ranking_values[candidate_idx], descending=True)
@@ -2014,6 +2089,9 @@ class FeatureLensRuntime:
2014
  current_context_available=current_context_available,
2015
  current_token_index=resolved_current_idx,
2016
  displayed_current_active_count=sum(bool(row[-1]) for row in table_rows),
 
 
 
2017
  )
2018
 
2019
  @torch.inference_mode()
@@ -2344,6 +2422,144 @@ class FeatureLensRuntime:
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()
 
241
  current_context_available: bool
242
  current_token_index: int | None
243
  displayed_current_active_count: int
244
+ split_half_k: int | None
245
+ split_half_shared_count: int
246
+ split_half_jaccard: float | None
247
 
248
 
249
  @dataclass
 
271
  execution_drift_js: float
272
 
273
 
274
+ @dataclass
275
+ class CandidateCrossTargetResult:
276
+ feature_ids: list[int]
277
+ targets: list[str]
278
+ rows: list[list[object]]
279
+ chart_rows: list[list[object]]
280
+ summary_rows: list[list[object]]
281
+ active_feature_count: int
282
+
283
+
284
  @dataclass
285
  class FeatureCueScanResult:
286
  feature_id: int
 
1950
  include_self=True,
1951
  )
1952
 
1953
+ # Split-half stability is computed from the same controlled activation batch, so it costs no
1954
+ # additional model inference. With very small live samples this is a diagnostic of shortlist
1955
+ # sensitivity, not a statistical reliability estimate.
1956
+ split_half_k: int | None = None
1957
+ split_half_shared_count = 0
1958
+ split_half_jaccard: float | None = None
1959
+
1960
+ def _rank_subset(subset_mask: torch.Tensor) -> list[int]:
1961
+ sub_target = controlled_dense[subset_mask & target_mask]
1962
+ sub_other = controlled_dense[subset_mask & other_mask]
1963
+ if sub_target.shape[0] == 0 or sub_other.shape[0] == 0:
1964
+ return []
1965
+ sub_target_mean = sub_target.mean(dim=0)
1966
+ sub_other_mean = sub_other.mean(dim=0)
1967
+ sub_target_rate = (sub_target > 0).float().mean(dim=0)
1968
+ sub_mean_diff = sub_target_mean - sub_other_mean
1969
+ sub_selectivity = sub_mean_diff / (sub_target_mean + sub_other_mean + 1e-8)
1970
+ sub_balanced = (
1971
+ sub_selectivity.clamp_min(0)
1972
+ * sub_target_rate
1973
+ * torch.log1p(sub_target_mean.clamp_min(0))
1974
+ )
1975
+ sub_eligible = (sub_target_mean > 0) & (sub_mean_diff > 0)
1976
+ if ranking_mode == 'causal_ready':
1977
+ sub_eligible = sub_eligible & (current_token_dense > 0)
1978
+ sub_ranking = sub_balanced * torch.log1p(current_token_dense.clamp_min(0))
1979
+ elif ranking_mode == 'balanced_selectivity':
1980
+ sub_ranking = sub_balanced
1981
+ else:
1982
+ sub_ranking = sub_mean_diff
1983
+ sub_idx = torch.nonzero(sub_eligible, as_tuple=False).flatten()
1984
+ if sub_idx.numel() == 0:
1985
+ return []
1986
+ sub_order = torch.argsort(sub_ranking[sub_idx], descending=True)
1987
+ return [int(value.item()) for value in sub_idx[sub_order[:top_n]]]
1988
+
1989
+ if n >= 2:
1990
+ half = max(1, n // 2)
1991
+ seen_by_concept: dict[str, int] = {}
1992
+ mask_a = torch.zeros(controlled_count, device=dense.device, dtype=torch.bool)
1993
+ mask_b = torch.zeros_like(mask_a)
1994
+ for row_idx, row in enumerate(rows):
1995
+ row_concept = str(row['concept'])
1996
+ local_idx = seen_by_concept.get(row_concept, 0)
1997
+ seen_by_concept[row_concept] = local_idx + 1
1998
+ if local_idx < half:
1999
+ mask_a[row_idx] = True
2000
+ else:
2001
+ mask_b[row_idx] = True
2002
+ ids_a = _rank_subset(mask_a)
2003
+ ids_b = _rank_subset(mask_b)
2004
+ if ids_a and ids_b:
2005
+ set_a, set_b = set(ids_a), set(ids_b)
2006
+ shared = set_a & set_b
2007
+ union = set_a | set_b
2008
+ split_half_k = min(len(ids_a), len(ids_b), top_n)
2009
+ split_half_shared_count = len(shared)
2010
+ split_half_jaccard = float(len(shared) / len(union)) if union else 1.0
2011
+
2012
  eligible = (target_mean > 0) & (mean_diff > 0)
2013
  if ranking_mode == 'causal_ready':
2014
  if not current_context_available or resolved_current_idx is None:
 
2038
  current_context_available=current_context_available,
2039
  current_token_index=resolved_current_idx,
2040
  displayed_current_active_count=0,
2041
+ split_half_k=split_half_k,
2042
+ split_half_shared_count=split_half_shared_count,
2043
+ split_half_jaccard=split_half_jaccard,
2044
  )
2045
 
2046
  order = torch.argsort(ranking_values[candidate_idx], descending=True)
 
2089
  current_context_available=current_context_available,
2090
  current_token_index=resolved_current_idx,
2091
  displayed_current_active_count=sum(bool(row[-1]) for row in table_rows),
2092
+ split_half_k=split_half_k,
2093
+ split_half_shared_count=split_half_shared_count,
2094
+ split_half_jaccard=split_half_jaccard,
2095
  )
2096
 
2097
  @torch.inference_mode()
 
2422
  execution_drift_js=float(execution_drift_js),
2423
  )
2424
 
2425
+ @torch.inference_mode()
2426
+ def candidate_cross_target_profile(
2427
+ self,
2428
+ text: str,
2429
+ layer: int,
2430
+ token_index: int,
2431
+ feature_ids: Sequence[int],
2432
+ targets: Sequence[str],
2433
+ ) -> CandidateCrossTargetResult:
2434
+ """Profile native candidate ablations across several exact target continuations.
2435
+
2436
+ This is a screening diagnostic, not a random-controlled causal claim. The residual
2437
+ representation and native feature deltas are captured once from the Workbench prompt.
2438
+ Each target is then evaluated with one small batched forward containing the zero-edit
2439
+ reference plus every selected candidate ablation.
2440
+ """
2441
+ if not text.strip():
2442
+ raise ValueError('Enter and inspect a Workbench prompt first.')
2443
+ ids = list(dict.fromkeys(int(x) for x in feature_ids))
2444
+ if not ids:
2445
+ raise ValueError('Select at least one candidate feature for cross-target profiling.')
2446
+ if len(ids) > 3:
2447
+ raise ValueError('Cross-target profiling supports at most three features per run.')
2448
+ if any(feature_id < 0 or feature_id >= self.settings.sae_width for feature_id in ids):
2449
+ raise ValueError(f'Feature ids must be in [0, {self.settings.sae_width - 1}].')
2450
+
2451
+ target_list: list[str] = []
2452
+ seen_targets: set[str] = set()
2453
+ for raw in targets:
2454
+ target = str(raw)
2455
+ if not target.strip() or target in seen_targets:
2456
+ continue
2457
+ seen_targets.add(target)
2458
+ target_list.append(target)
2459
+ if len(target_list) < 2:
2460
+ raise ValueError('Enter at least two distinct target continuations.')
2461
+ if len(target_list) > 5:
2462
+ raise ValueError('Cross-target profiling supports at most five target continuations.')
2463
+
2464
+ self.ensure_ready(preload_saes=False)
2465
+ assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
2466
+ prompt_inputs = self._inputs(text)
2467
+ prompt_len = int(prompt_inputs['input_ids'].shape[1])
2468
+ idx = self._resolve_index(int(token_index), prompt_len)
2469
+ sae = self.sae_store.get(int(layer))
2470
+
2471
+ capture: dict = {}
2472
+ with self._capture_hook(int(layer), capture):
2473
+ self.model(**prompt_inputs, use_cache=False)
2474
+ residual = capture['hidden'][0, idx]
2475
+ encoding = sae.encode(residual)
2476
+ activations = [float(encoding.activation_for(feature_id)) for feature_id in ids]
2477
+ deltas = [
2478
+ residual_delta(
2479
+ sae.decoder_direction(feature_id),
2480
+ activation,
2481
+ InterventionSpec('ablate', 0.0),
2482
+ )
2483
+ for feature_id, activation in zip(ids, activations, strict=True)
2484
+ ]
2485
+ delta_batch = torch.stack([torch.zeros_like(residual), *deltas], dim=0)
2486
+
2487
+ rows: list[list[object]] = []
2488
+ chart_rows: list[list[object]] = []
2489
+ by_feature: dict[int, list[tuple[str, float, float]]] = {feature_id: [] for feature_id in ids}
2490
+
2491
+ for target_text in target_list:
2492
+ target_ids = self._target_ids(target_text)
2493
+ full_inputs = self._append_target(prompt_inputs, target_ids)
2494
+ repeated = self._repeat_inputs(full_inputs, delta_batch.shape[0])
2495
+ with self._batch_delta_hook(int(layer), idx, delta_batch):
2496
+ outputs = self.model(**repeated, use_cache=False)
2497
+
2498
+ baseline_logits = outputs.logits[0]
2499
+ baseline_seq, baseline_mean, _ = sequence_logprob_summary(
2500
+ baseline_logits, prompt_length=prompt_len, target_ids=target_ids
2501
+ )
2502
+ baseline_next = baseline_logits[prompt_len - 1]
2503
+ token_count = len(target_ids)
2504
+ for output_idx, (feature_id, activation, delta) in enumerate(
2505
+ zip(ids, activations, deltas, strict=True), start=1
2506
+ ):
2507
+ logits = outputs.logits[output_idx]
2508
+ seq_logp, mean_logp, _ = sequence_logprob_summary(
2509
+ logits, prompt_length=prompt_len, target_ids=target_ids
2510
+ )
2511
+ mean_delta = float(mean_logp - baseline_mean)
2512
+ seq_delta = float(seq_logp - baseline_seq)
2513
+ js = float(js_divergence_from_logits(baseline_next, logits[prompt_len - 1]))
2514
+ rows.append(
2515
+ [
2516
+ int(feature_id),
2517
+ target_text,
2518
+ int(token_count),
2519
+ float(activation),
2520
+ float(torch.linalg.vector_norm(delta.float()).item()),
2521
+ mean_delta,
2522
+ seq_delta,
2523
+ js,
2524
+ ]
2525
+ )
2526
+ chart_rows.append([target_text, str(feature_id), mean_delta])
2527
+ by_feature[feature_id].append((target_text, mean_delta, js))
2528
+
2529
+ summary_rows: list[list[object]] = []
2530
+ for feature_id in ids:
2531
+ items = by_feature[feature_id]
2532
+ strongest = max(items, key=lambda item: abs(item[1]))
2533
+ strongest_abs = abs(float(strongest[1]))
2534
+ other_abs = [abs(float(item[1])) for item in items if item is not strongest]
2535
+ mean_other = float(sum(other_abs) / len(other_abs)) if other_abs else 0.0
2536
+ profile_ratio = float(strongest_abs / max(mean_other, 1e-12))
2537
+ signs = {1 if item[1] > 0 else -1 if item[1] < 0 else 0 for item in items}
2538
+ nonzero_signs = {sign for sign in signs if sign != 0}
2539
+ sign_consistency = 'same sign' if len(nonzero_signs) <= 1 else 'mixed signs'
2540
+ summary_rows.append(
2541
+ [
2542
+ int(feature_id),
2543
+ str(strongest[0]),
2544
+ float(strongest[1]),
2545
+ strongest_abs,
2546
+ mean_other,
2547
+ profile_ratio,
2548
+ sign_consistency,
2549
+ max(float(item[2]) for item in items),
2550
+ ]
2551
+ )
2552
+ summary_rows.sort(key=lambda row: float(row[3]), reverse=True)
2553
+
2554
+ return CandidateCrossTargetResult(
2555
+ feature_ids=ids,
2556
+ targets=target_list,
2557
+ rows=rows,
2558
+ chart_rows=chart_rows,
2559
+ summary_rows=summary_rows,
2560
+ active_feature_count=sum(activation > 0 for activation in activations),
2561
+ )
2562
+
2563
  @staticmethod
2564
  def _cue_prompt(stem: str, cue: str) -> str:
2565
  stem = stem.rstrip()
pyproject.toml CHANGED
@@ -1,6 +1,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
 
 
1
  [project]
2
  name = "featurelens"
3
+ version = "0.12.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 \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",
@@ -154,5 +154,20 @@
154
  "association_vs_controlled_causality_alignment",
155
  "target_specificity_vs_js_specificity_separation",
156
  "single_new_gpu_call_hf_acceptance"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  ]
158
  }
 
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",
 
154
  "association_vs_controlled_causality_alignment",
155
  "target_specificity_vs_js_specificity_separation",
156
  "single_new_gpu_call_hf_acceptance"
157
+ ],
158
+ "cross_target_feature_limit": 3,
159
+ "cross_target_target_limit": 5,
160
+ "cross_target_default_targets": [
161
+ "2x",
162
+ "x",
163
+ "0",
164
+ "x^2"
165
+ ],
166
+ "live_features_v0_12": [
167
+ "controlled_evidence_pattern_synthesis",
168
+ "split_half_discovery_stability",
169
+ "cross_target_candidate_profile",
170
+ "missing_discovery_alignment_fallback",
171
+ "gpu_budget_aware_touched_path_validation"
172
  ]
173
  }
scripts/release_check.py CHANGED
@@ -206,6 +206,21 @@ def check_config(config: dict) -> None:
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
 
@@ -292,16 +307,20 @@ def check_readme() -> None:
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,7 +338,7 @@ 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__':
 
206
  'research_config.json live_features_v0_11 mismatch: ' f'{sorted(actual_live_v11)}'
207
  )
208
 
209
+ required_live_v12 = {
210
+ 'controlled_evidence_pattern_synthesis',
211
+ 'split_half_discovery_stability',
212
+ 'cross_target_candidate_profile',
213
+ 'missing_discovery_alignment_fallback',
214
+ 'gpu_budget_aware_touched_path_validation',
215
+ }
216
+ actual_live_v12 = set(config.get('live_features_v0_12', []))
217
+ if actual_live_v12 != required_live_v12:
218
+ raise SystemExit(
219
+ 'research_config.json live_features_v0_12 mismatch: ' f'{sorted(actual_live_v12)}'
220
+ )
221
+ if config.get('cross_target_feature_limit') != 3 or config.get('cross_target_target_limit') != 5:
222
+ raise SystemExit('Cross-target live limits must be 3 features and 5 targets.')
223
+
224
  if 'german_language' not in config.get('concepts', []) or 'french_language' in config.get('concepts', []):
225
  raise SystemExit('research_config.json must use german_language and must not contain french_language.')
226
 
 
307
  'association vs controlled causality',
308
  'target-specificity ratio',
309
  'js-specificity ratio',
310
+ 'controlled evidence patterns',
311
+ 'split-half',
312
+ 'cross-target',
313
+ 'target-profile',
314
  ]
315
  missing = [value for value in required_strings if value.lower() not in readme.lower()]
316
  if missing:
317
+ raise SystemExit(f'README.md is missing required v0.12 content: {missing}')
318
 
319
 
320
  def check_pyproject() -> None:
321
  text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
322
+ if 'version = "0.12.0"' not in text:
323
+ raise SystemExit('pyproject.toml must declare version 0.12.0.')
324
 
325
 
326
  def main() -> None:
 
338
  print(f' layers: {config["layers"]}')
339
  print(f' feature-set sizes: {config["feature_set_sizes"]}')
340
  print(f' random controls: {config["live_random_controls"]}')
341
+ print(' release: v0.12.0')
342
 
343
 
344
  if __name__ == '__main__':
tests/test_live_runtime_helpers.py CHANGED
@@ -363,3 +363,39 @@ def test_candidate_specificity_screen_batches_random_controlled_candidates() ->
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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
366
+
367
+
368
+ def test_concept_feature_discovery_reports_split_half_stability_without_extra_forward() -> None:
369
+ runtime = make_runtime()
370
+ result = runtime.concept_feature_discovery(
371
+ concept='mathematics',
372
+ layer=0,
373
+ prompts_per_concept=2,
374
+ top_n=3,
375
+ ranking_mode='balanced_selectivity',
376
+ current_text='abc',
377
+ current_token_index=-1,
378
+ )
379
+ if result.split_half_jaccard is not None:
380
+ assert 0.0 <= result.split_half_jaccard <= 1.0
381
+ assert result.split_half_k is not None
382
+ assert 0 <= result.split_half_shared_count <= max(len(result.candidate_ids), result.split_half_k)
383
+
384
+
385
+ def test_candidate_cross_target_profile_runs_multiple_features_and_targets() -> None:
386
+ runtime = make_runtime()
387
+ result = runtime.candidate_cross_target_profile(
388
+ text='abc',
389
+ layer=0,
390
+ token_index=-1,
391
+ feature_ids=[0, 1],
392
+ targets=['d', 'e', 'f'],
393
+ )
394
+ assert result.feature_ids == [0, 1]
395
+ assert result.targets == ['d', 'e', 'f']
396
+ assert len(result.rows) == 6
397
+ assert len(result.chart_rows) == 6
398
+ assert len(result.summary_rows) == 2
399
+ assert all(len(row) == 8 for row in result.rows)
400
+ assert all(len(row) == 8 for row in result.summary_rows)
401
+ assert all(math.isfinite(float(row[5])) for row in result.rows)
tests/test_ui_helpers.py CHANGED
@@ -256,3 +256,55 @@ def test_controlled_candidate_ui_limits_live_comparison_to_three_features() -> N
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
259
+
260
+
261
+ def _v11_controlled_table() -> pd.DataFrame:
262
+ return pd.DataFrame(
263
+ [
264
+ [1, 25992, 21.328, True, 21.328, -0.11188, -0.01628, 0.05637, 0.06847, 1.9849, 0.3333, -0.22376, 0.001133, 0.000498, 0.000219, 2.2769, 0.1111],
265
+ [2, 21670, 6.359, True, 6.359, 0.02105, -0.01246, 0.01732, 0.01587, 1.2157, 0.4444, 0.04211, 0.000104, 0.000043, 0.000027, 2.4423, 0.2222],
266
+ [3, 16369, 29.234, True, 29.235, -0.00621, -0.00330, 0.04204, 0.04735, 0.1476, 0.8889, -0.01241, 0.001832, 0.000689, 0.000197, 2.6610, 0.1111],
267
+ ],
268
+ columns=[
269
+ 'Rank', 'Feature id', 'Native activation', 'Active at current token', 'Perturbation L2',
270
+ 'SAE Δ mean log p/token', 'Random signed mean Δ', 'Random mean |Δ|', 'Random |Δ| std',
271
+ 'Target specificity ratio', 'Target empirical tail p', 'SAE Δ sequence log p',
272
+ 'SAE next-token JS', 'Random mean JS', 'Random JS std', 'JS specificity ratio',
273
+ 'JS empirical tail p',
274
+ ],
275
+ )
276
+
277
+
278
+ def test_controlled_evidence_patterns_separate_target_and_distributional_influence() -> None:
279
+ app = _import_app()
280
+ summary, table = app._controlled_evidence_patterns(_v11_controlled_table())
281
+ patterns = dict(zip(table['Feature id'].astype(int), table['Evidence pattern'], strict=True))
282
+ assert patterns[25992] == 'Broad controlled influence'
283
+ assert patterns[21670] == 'Distribution-shift weighted'
284
+ assert patterns[16369] == 'Distribution-shift dominant'
285
+ assert 'effect ratios' in summary
286
+ assert 'statistical significance' in summary
287
+
288
+
289
+ def test_controlled_alignment_explains_missing_discovery_state_instead_of_blank() -> None:
290
+ app = _import_app()
291
+ summary, table, chart = app._controlled_alignment_outputs(None, _v11_controlled_table())
292
+ assert 'discovery' in summary.lower()
293
+ assert 'browser session' in summary.lower()
294
+ assert table.empty
295
+ assert chart.empty
296
+
297
+
298
+ def test_cross_target_shortlist_preserves_target_and_js_leaders() -> None:
299
+ app = _import_app()
300
+ selected = app._cross_target_shortlist(_v11_controlled_table(), limit=2)
301
+ assert selected == ['25992', '16369']
302
+
303
+
304
+ def test_cross_target_ui_has_independent_targets_and_small_feature_limit() -> None:
305
+ app = _import_app()
306
+ assert app.cross_target_ids.multiselect is True
307
+ assert app.cross_target_ids.max_choices == 3
308
+ assert '2x' in app.cross_target_text.value
309
+ assert app.cross_target_table.show_label is False
310
+ assert app.cross_target_summary_table.show_label is False