ArchitSharma commited on
Commit
0481a55
·
1 Parent(s): 0536091

Release FeatureLens v0.13.0

Browse files
CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
 
 
 
 
 
 
 
1
  # Changelog
2
 
3
  ## v0.12.0
 
1
+ ## v0.13.0
2
+
3
+ - Added deterministic **32-resample candidate-support diagnostics** from the same concept-discovery activation batch; each displayed feature now reports shortlist support and median resample rank without another model forward.
4
+ - Extended cross-target profiling with **normalized effect entropy, effect concentration, signed bias, and a descriptive profile pattern** so concentrated target dependence is separated from broad same-sign behavior.
5
+ - Added **pairwise target-preference shifts** derived from the same cross-target scores: Δ(A−B) = Δmean(A) − Δmean(B), with a table and plot and no additional inference.
6
+ - Kept HF acceptance quota-aware: only concept discovery and cross-target profiling are touched GPU paths.
7
+
8
  # Changelog
9
 
10
  ## v0.12.0
README.md CHANGED
@@ -13,7 +13,7 @@ license: mit
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
 
@@ -412,3 +412,21 @@ See [`docs/VALIDATION.md`](docs/VALIDATION.md). The v0.12 guide uses the **exact
412
  ## Acknowledgements
413
 
414
  FeatureLens builds on the open Qwen3 model and Qwen-Scope residual-stream SAE checkpoints from the Qwen team.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  # FeatureLens — Causal Interpretability Workbench
15
 
16
+ > **v0.13:** a stability-and-target-selectivity release that adds deterministic resample support to live discovery and extracts effect concentration, signed bias, and pairwise target-preference shifts from the existing cross-target GPU call.
17
 
18
  **Research question:**
19
 
 
412
  ## Acknowledgements
413
 
414
  FeatureLens builds on the open Qwen3 model and Qwen-Scope residual-stream SAE checkpoints from the Qwen team.
415
+
416
+ ## v0.13 stability and target-selectivity synthesis
417
+
418
+ v0.13 deliberately adds **no new GPU callback**. It strengthens the two live paths already used in v0.12.
419
+
420
+ ### Resample shortlist support
421
+
422
+ Concept-guided discovery still reports split-half Jaccard, but now also reranks the same already-computed controlled activations under **32 deterministic balanced bootstrap resamples**. Each displayed feature receives a **Resample shortlist support** fraction and **Median resample rank**. The summary reports the mean displayed support and how many candidates survive at least 75% of resampled shortlists. These are small-sample sensitivity diagnostics, not confidence intervals or held-out reliability claims.
423
+
424
+ ### Cross-target effect concentration
425
+
426
+ The cross-target profile now separates a feature whose causal effect is concentrated on one exact continuation from one that broadly moves several alternatives. For each feature it reports **Normalized effect entropy**, **Effect concentration = 1 − entropy**, and **Signed bias = ΣΔ / Σ|Δ|**. A descriptive profile pattern labels cases such as target-concentrated/mixed-sign or broad same-sign suppression/enhancement; this label is a heuristic summary, not a semantic feature label.
427
+
428
+ ### Pairwise target preference
429
+
430
+ Every cross-target run also derives **pairwise target preference shifts** without another forward pass. For targets A and B, FeatureLens reports `Δ(A−B) = Δ mean log p/token(A) − Δ mean log p/token(B)`. Positive values mean the SAE ablation shifts token-normalized preference toward A relative to B. This turns the same cross-target scores into a more direct comparison of alternatives while keeping the random-controlled specificity experiment separate.
431
+
432
+ See [`docs/VALIDATION.md`](docs/VALIDATION.md). v0.13 requires only the two touched GPU paths: causal-ready discovery and cross-target profiling.
app.py CHANGED
@@ -605,6 +605,13 @@ def _discovery_metrics_markdown(result) -> str:
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"
@@ -933,14 +940,25 @@ def _cross_target_metrics_markdown(result) -> str:
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
 
@@ -1626,6 +1644,8 @@ def run_concept_feature_discovery(
1626
  "Current prompt max",
1627
  "Current token activation",
1628
  "Active at current token",
 
 
1629
  ]
1630
  table = pd.DataFrame(result.rows, columns=columns)
1631
  chart = pd.DataFrame(result.chart_rows, columns=["Feature", "Candidate score"])
@@ -1823,16 +1843,36 @@ def run_candidate_cross_target_profile(
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,7 +1959,7 @@ def set_mode_help(mode: str):
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>'
@@ -2738,6 +2778,41 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
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."
@@ -3225,8 +3300,11 @@ Association, robustness, geometry, and intervention evidence remain separate cla
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(
@@ -3295,6 +3373,7 @@ Association, robustness, geometry, and intervention evidence remain separate cla
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),
 
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
+ if result.resample_replicates and result.resample_mean_support is not None:
609
+ stability += (
610
+ f" \nDeterministic balanced-resample support ({result.resample_replicates} resamples, same activations): "
611
+ f"mean support across displayed candidates **{result.resample_mean_support:.1%}**; "
612
+ f"**{result.resample_high_support_count}/{len(result.candidate_ids)}** displayed candidates appeared in at least 75% "
613
+ "of resampled shortlists. This is still a live small-sample stability diagnostic, not a confidence interval."
614
+ )
615
  return (
616
  f"Concept **{result.concept}** · layer **{result.layer}** · "
617
  f"{result.prompts_per_concept} prompts/concept. \n"
 
940
  f"Largest screened cross-target effect: feature **{int(strongest[0])}** on **{strongest[1]!r}** "
941
  f"with Δ mean log p/token **{float(strongest[2]):+.4f}**."
942
  )
943
+ patterns = "; ".join(f"{int(row[0])}: {row[10]}" for row in result.summary_rows)
944
+ profile_text = f" \nHeuristic target-profile patterns — {patterns}."
945
  else:
946
  lead = "No cross-target rows were produced."
947
+ profile_text = ""
948
+ pairwise_text = ""
949
+ if result.pairwise_rows:
950
+ top_pair = result.pairwise_rows[0]
951
+ pairwise_text = (
952
+ f" \nLargest token-normalized pairwise preference shift: feature **{int(top_pair[0])}**, "
953
+ f"**{top_pair[1]!r} vs {top_pair[2]!r}**, Δ(A−B) **{float(top_pair[3]):+.4f}**."
954
+ )
955
  return (
956
  f"Profiled feature(s) **{feature_text}** across exact continuation(s) {target_text}; "
957
  f"**{result.active_feature_count}/{len(result.feature_ids)}** selected features were active at the Workbench token. \n"
958
+ f"{lead}{profile_text}{pairwise_text} \n\n"
959
  "This is a **target-profile screen** using native SAE ablations and a batched no-edit reference for each continuation. "
960
+ "The profile labels are descriptive heuristics, and pairwise shifts compare token-normalized target effects; neither spends "
961
+ "random controls. Use Controlled candidate specificity for matched-random causal claims."
962
  )
963
 
964
 
 
1644
  "Current prompt max",
1645
  "Current token activation",
1646
  "Active at current token",
1647
+ "Resample shortlist support",
1648
+ "Median resample rank",
1649
  ]
1650
  table = pd.DataFrame(result.rows, columns=columns)
1651
  chart = pd.DataFrame(result.chart_rows, columns=["Feature", "Candidate score"])
 
1843
  "Mean |effect| on other targets",
1844
  "Target-profile ratio",
1845
  "Effect sign pattern",
1846
+ "Normalized effect entropy",
1847
+ "Effect concentration",
1848
+ "Signed bias",
1849
+ "Profile pattern",
1850
  "Maximum next-token JS",
1851
  ]
1852
  summary_table = pd.DataFrame(result.summary_rows, columns=summary_columns)
1853
+ pairwise_columns = [
1854
+ "Feature id",
1855
+ "Target A",
1856
+ "Target B",
1857
+ "Δ normalized preference A−B",
1858
+ "|Preference shift|",
1859
+ "Direction",
1860
+ ]
1861
+ pairwise_table = pd.DataFrame(result.pairwise_rows, columns=pairwise_columns)
1862
+ pairwise_chart = pairwise_table.copy()
1863
+ if not pairwise_chart.empty:
1864
+ pairwise_chart["Target pair"] = pairwise_chart["Target A"].astype(str) + " vs " + pairwise_chart["Target B"].astype(str)
1865
+ pairwise_chart["Feature"] = pairwise_chart["Feature id"].astype(str)
1866
  return (
1867
  _cross_target_metrics_markdown(result),
1868
  table,
1869
  chart,
1870
  summary_table,
1871
+ pairwise_table,
1872
+ pairwise_chart,
1873
  _tsv(table),
1874
  _tsv(summary_table),
1875
+ _tsv(pairwise_table),
1876
  )
1877
  except Exception as exc:
1878
  _raise_ui_error(exc)
 
1959
  with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_width=True) as demo:
1960
  gr.HTML(
1961
  '<header class="hero">'
1962
+ '<h1>FeatureLens <span style="font-size:.48em;opacity:.58;font-weight:400">v0.13</span></h1>'
1963
  '<div class="subtitle">Causal Interpretability Workbench</div>'
1964
  '<div class="question">Discover sparse features, test robustness, and separate correlation from causal influence.</div>'
1965
  '</header>'
 
2778
  cross_target_summary_tsv = gr.Textbox(visible="hidden")
2779
  cross_target_summary_copy = _copy_button()
2780
 
2781
+ gr.Markdown("#### Pairwise target preference shifts")
2782
+ gr.Markdown(
2783
+ "Derived from the same cross-target scores with **no additional model call**. For each feature, "
2784
+ "Δ(A−B) = Δ mean log p/token(A) − Δ mean log p/token(B), so positive values mean the ablation shifts "
2785
+ "token-normalized preference toward A relative to B."
2786
+ )
2787
+ with gr.Row(equal_height=False):
2788
+ with gr.Column(scale=3):
2789
+ _table_heading('Pairwise target preference shifts')
2790
+ cross_target_pairwise_table = gr.Dataframe(
2791
+ interactive=False,
2792
+ label="Pairwise target preference shifts",
2793
+ show_label=False,
2794
+ buttons=["fullscreen"],
2795
+ elem_classes=["result-table"],
2796
+ wrap=False,
2797
+ max_height=340,
2798
+ )
2799
+ cross_target_pairwise_tsv = gr.Textbox(visible="hidden")
2800
+ cross_target_pairwise_copy = _copy_button()
2801
+ with gr.Column(scale=2):
2802
+ cross_target_pairwise_plot = gr.BarPlot(
2803
+ x="Target pair",
2804
+ y="Δ normalized preference A−B",
2805
+ color="Feature",
2806
+ title="Pairwise target preference shifts",
2807
+ elem_id="plot-cross-target-pairwise",
2808
+ x_title="Target pair",
2809
+ y_title="Δ normalized preference A−B",
2810
+ x_label_angle=-30,
2811
+ buttons=["fullscreen", "export"],
2812
+ elem_classes=["fl-plot"],
2813
+ height=330,
2814
+ )
2815
+
2816
  gr.HTML('<div class="section-rule">D. Inspect one feature</div>')
2817
  contrast_location = gr.Markdown(
2818
  "**Activation trace uses the current Workbench prompt.** The controlled concept scan below uses its own balanced prompt set."
 
3300
  cross_target_table,
3301
  cross_target_plot,
3302
  cross_target_summary_table,
3303
+ cross_target_pairwise_table,
3304
+ cross_target_pairwise_plot,
3305
  cross_target_tsv,
3306
  cross_target_summary_tsv,
3307
+ cross_target_pairwise_tsv,
3308
  ],
3309
  )
3310
  candidate_specificity_table.select(
 
3373
  (controlled_alignment_copy, controlled_alignment_tsv),
3374
  (cross_target_copy, cross_target_tsv),
3375
  (cross_target_summary_copy, cross_target_summary_tsv),
3376
+ (cross_target_pairwise_copy, cross_target_pairwise_tsv),
3377
  (cue_copy, cue_tsv),
3378
  (cue_context_copy, cue_context_tsv),
3379
  (para_copy, para_tsv),
docs/METHODOLOGY.md CHANGED
@@ -569,3 +569,17 @@ R_{profile} = \frac{\max_t |\Delta \bar{\ell}_t|}{\operatorname{mean}_{u \ne t^*
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.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
572
+
573
+ ## v0.13 resample stability and pairwise target preference
574
+
575
+ ### Deterministic balanced resampling
576
+
577
+ The live discovery forward already yields a prompt-wide SAE activation vector for every controlled prompt. v0.13 reuses that tensor for 32 deterministic balanced bootstrap resamples. Each concept is resampled independently with replacement so the live concept balance is preserved. Candidate ranking is recomputed under the currently selected ranking mode, including the fixed current-token compatibility term for `causal_ready`. For every candidate in the full-data shortlist, FeatureLens records the fraction of resamples in which it reappears and its median rank when present. No model or SAE forward is repeated. Because the live prompt count is deliberately tiny, these are sensitivity descriptors rather than statistical confidence estimates.
578
+
579
+ ### Cross-target concentration
580
+
581
+ For one feature with target effects `Δ_t`, v0.13 normalizes `|Δ_t|` into a probability vector and reports normalized entropy `H / log(T)`, effect concentration `1 − H/log(T)`, and signed bias `ΣΔ_t / Σ|Δ_t|`. These quantities distinguish target-concentrated effects from broad effects and indicate whether broad influence is primarily suppressive, enhancing, or mixed-sign. The profile text is a descriptive heuristic only.
582
+
583
+ ### Pairwise preference shifts
584
+
585
+ For every unordered target pair `(A, B)`, FeatureLens derives `Δ_pref(A,B) = Δmean(A) − Δmean(B)` from the already-computed teacher-forced mean-log-probability effects. This is the intervention-induced change in token-normalized preference for A relative to B. It does not add a model call and does not replace matched-random specificity controls.
docs/VALIDATION.md CHANGED
@@ -1,11 +1,9 @@
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 && \
11
  python3 -m compileall -q app.py featurelens experiments scripts && \
@@ -14,9 +12,7 @@ python3 scripts/ui_smoke.py && \
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,107 +21,69 @@ 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
37
- Residual layer: 14
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.
 
1
+ # FeatureLens v0.13 validation
2
 
3
+ v0.13 changes only **concept-guided discovery post-processing** and **cross-target profile post-processing**. Both changes reuse activations/scores already produced by those GPU calls. Do not rerun unrelated live experiments.
4
 
5
  ## Local release gate
6
 
 
 
7
  ```bash
8
  python3 -m pytest -q && \
9
  python3 -m compileall -q app.py featurelens experiments scripts && \
 
12
  python3 scripts/release_check.py
13
  ```
14
 
15
+ Expected release checker tail:
 
 
16
 
17
  ```text
18
  FeatureLens release check: PASS
 
21
  layers: [4, 14, 26]
22
  feature-set sizes: [1, 3, 5]
23
  random controls: 8
24
+ release: v0.13.0
25
  ```
26
 
27
+ ## HF GPU call 1 discovery stability
 
 
 
 
 
 
 
 
28
 
29
+ Exact path: **Feature evidence A. Concept-guided candidate discovery**.
30
 
31
+ Use:
32
 
33
+ - **Target concept:** `mathematics`
34
+ - **Residual layer:** `14`
35
+ - **Prompts per concept:** `4`
36
+ - **Candidate features:** `12`
37
+ - **Candidate ranking:** `Causal-ready at current token`
38
+ - Current Workbench prompt: `The derivative of x squared is`
39
+ - Current Workbench token index: `-1`
 
 
 
 
 
 
40
 
41
+ Click **Discover concept-associated candidates**.
42
 
43
+ Record only the new stability evidence:
44
 
45
+ - split-half shared count and Jaccard;
46
+ - summary mean resample support;
47
+ - number of displayed candidates with ≥75% resample support;
48
+ - for the top five rows: **Feature id**, **Resample shortlist support**, **Median resample rank**.
49
 
50
+ No other discovery table columns need to be recopied unless the candidate ranking unexpectedly changes.
 
 
 
51
 
52
+ ## HF GPU call 2 richer cross-target profile
53
 
54
+ Exact path: **Feature evidence C. Cross-target causal profile**.
55
 
56
+ Use:
57
 
58
+ - **Features for cross-target profile:** `25992`, `16369`, `21670`
59
+ - **Exact target continuations:**
 
60
 
61
  ```text
 
 
 
 
 
 
62
  2x
63
  x
64
  0
65
  x^2
66
  ```
67
 
68
+ Click **Run cross-target causal profile**.
 
 
 
 
 
 
 
 
 
 
69
 
70
+ Send:
71
 
72
+ 1. **Target-profile summary** with the new columns **Normalized effect entropy**, **Effect concentration**, **Signed bias**, and **Profile pattern**.
73
+ 2. The top six rows of **Pairwise target preference shifts** by `|Preference shift|`.
74
+ 3. A screenshot of the pairwise plot if convenient.
75
 
76
+ ## Do not rerun for v0.13
77
 
78
+ Do **not** spend ZeroGPU quota on:
79
 
80
+ - controlled candidate specificity;
81
+ - candidate triage;
82
+ - dose response;
83
+ - paraphrase identity;
 
 
 
 
 
84
  - layer trajectory;
85
  - 1/3/5 feature-set sweep;
86
+ - cue or cue × context tests;
87
+ - focus/zoom behavior.
 
 
88
 
89
+ Those inference paths are unchanged in v0.13.
featurelens/runtime.py CHANGED
@@ -5,6 +5,7 @@ import html
5
  import json
6
  import math
7
  import os
 
8
  from collections.abc import Iterator, Sequence
9
  from contextlib import contextmanager
10
  from dataclasses import dataclass
@@ -244,6 +245,9 @@ class ConceptFeatureDiscoveryResult:
244
  split_half_k: int | None
245
  split_half_shared_count: int
246
  split_half_jaccard: float | None
 
 
 
247
 
248
 
249
  @dataclass
@@ -278,6 +282,7 @@ class CandidateCrossTargetResult:
278
  rows: list[list[object]]
279
  chart_rows: list[list[object]]
280
  summary_rows: list[list[object]]
 
281
  active_feature_count: int
282
 
283
 
@@ -1950,16 +1955,30 @@ class FeatureLensRuntime:
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)
@@ -1989,18 +2008,17 @@ class FeatureLensRuntime:
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
@@ -2009,6 +2027,22 @@ class FeatureLensRuntime:
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:
@@ -2041,11 +2075,39 @@ class FeatureLensRuntime:
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)
2047
  candidate_idx = candidate_idx[order[:top_n]]
2048
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2049
  table_rows: list[list[object]] = []
2050
  chart_rows: list[list[object]] = []
2051
  default_candidate_id: int | None = None
@@ -2069,6 +2131,8 @@ class FeatureLensRuntime:
2069
  current_max,
2070
  current_token,
2071
  bool(current_token > 0),
 
 
2072
  ]
2073
  table_rows.append(row)
2074
  chart_rows.append([str(fid), score])
@@ -2088,10 +2152,13 @@ class FeatureLensRuntime:
2088
  default_candidate_id=default_candidate_id,
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()
@@ -2527,6 +2594,7 @@ class FeatureLensRuntime:
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]))
@@ -2534,9 +2602,33 @@ class FeatureLensRuntime:
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),
@@ -2546,10 +2638,35 @@ class FeatureLensRuntime:
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,
@@ -2557,6 +2674,7 @@ class FeatureLensRuntime:
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
 
 
5
  import json
6
  import math
7
  import os
8
+ import random
9
  from collections.abc import Iterator, Sequence
10
  from contextlib import contextmanager
11
  from dataclasses import dataclass
 
245
  split_half_k: int | None
246
  split_half_shared_count: int
247
  split_half_jaccard: float | None
248
+ resample_replicates: int
249
+ resample_mean_support: float | None
250
+ resample_high_support_count: int
251
 
252
 
253
  @dataclass
 
282
  rows: list[list[object]]
283
  chart_rows: list[list[object]]
284
  summary_rows: list[list[object]]
285
+ pairwise_rows: list[list[object]]
286
  active_feature_count: int
287
 
288
 
 
1955
  include_self=True,
1956
  )
1957
 
1958
+ # Stability diagnostics reuse the same controlled activation batch, so they add no model inference.
1959
+ # Split-half overlap is intentionally simple; deterministic balanced bootstrap support gives a second
1960
+ # view of how often each displayed feature survives small changes to the live prompt sample.
1961
  split_half_k: int | None = None
1962
  split_half_shared_count = 0
1963
  split_half_jaccard: float | None = None
1964
+ resample_replicates = 0
1965
+ resample_rank_lists: list[list[int]] = []
1966
+ row_concepts = [str(row['concept']) for row in rows]
1967
 
1968
+ def _rank_indices(row_indices: list[int]) -> list[int]:
1969
+ if not row_indices:
1970
+ return []
1971
+ index_tensor = torch.tensor(row_indices, device=dense.device, dtype=torch.long)
1972
+ sub_dense = controlled_dense.index_select(0, index_tensor)
1973
+ sub_labels = [row_concepts[index] for index in row_indices]
1974
+ sub_target_mask = torch.tensor(
1975
+ [label == concept for label in sub_labels],
1976
+ device=dense.device,
1977
+ dtype=torch.bool,
1978
+ )
1979
+ sub_other_mask = ~sub_target_mask
1980
+ sub_target = sub_dense[sub_target_mask]
1981
+ sub_other = sub_dense[sub_other_mask]
1982
  if sub_target.shape[0] == 0 or sub_other.shape[0] == 0:
1983
  return []
1984
  sub_target_mean = sub_target.mean(dim=0)
 
2008
  if n >= 2:
2009
  half = max(1, n // 2)
2010
  seen_by_concept: dict[str, int] = {}
2011
+ indices_a: list[int] = []
2012
+ indices_b: list[int] = []
2013
+ for row_idx, row_concept in enumerate(row_concepts):
 
2014
  local_idx = seen_by_concept.get(row_concept, 0)
2015
  seen_by_concept[row_concept] = local_idx + 1
2016
  if local_idx < half:
2017
+ indices_a.append(row_idx)
2018
  else:
2019
+ indices_b.append(row_idx)
2020
+ ids_a = _rank_indices(indices_a)
2021
+ ids_b = _rank_indices(indices_b)
2022
  if ids_a and ids_b:
2023
  set_a, set_b = set(ids_a), set(ids_b)
2024
  shared = set_a & set_b
 
2027
  split_half_shared_count = len(shared)
2028
  split_half_jaccard = float(len(shared) / len(union)) if union else 1.0
2029
 
2030
+ pools: dict[str, list[int]] = {}
2031
+ for row_idx, row_concept in enumerate(row_concepts):
2032
+ pools.setdefault(row_concept, []).append(row_idx)
2033
+ seed = 13013 + int(layer) * 97 + n * 17 + sum(ord(ch) for ch in concept)
2034
+ rng = random.Random(seed)
2035
+ resample_replicates = 32
2036
+ for _ in range(resample_replicates):
2037
+ sampled_indices: list[int] = []
2038
+ for row_concept in sorted(pools):
2039
+ pool = pools[row_concept]
2040
+ sampled_indices.extend(rng.choice(pool) for _ in range(len(pool)))
2041
+ ranked = _rank_indices(sampled_indices)
2042
+ if ranked:
2043
+ resample_rank_lists.append(ranked)
2044
+ resample_replicates = len(resample_rank_lists)
2045
+
2046
  eligible = (target_mean > 0) & (mean_diff > 0)
2047
  if ranking_mode == 'causal_ready':
2048
  if not current_context_available or resolved_current_idx is None:
 
2075
  split_half_k=split_half_k,
2076
  split_half_shared_count=split_half_shared_count,
2077
  split_half_jaccard=split_half_jaccard,
2078
+ resample_replicates=resample_replicates,
2079
+ resample_mean_support=None,
2080
+ resample_high_support_count=0,
2081
  )
2082
 
2083
  order = torch.argsort(ranking_values[candidate_idx], descending=True)
2084
  candidate_idx = candidate_idx[order[:top_n]]
2085
 
2086
+ resample_support: dict[int, float] = {}
2087
+ resample_median_rank: dict[int, float | None] = {}
2088
+ displayed_supports: list[float] = []
2089
+ if resample_replicates:
2090
+ for feature_tensor in candidate_idx:
2091
+ fid = int(feature_tensor.item())
2092
+ ranks = [ranked.index(fid) + 1 for ranked in resample_rank_lists if fid in ranked]
2093
+ support = len(ranks) / resample_replicates
2094
+ if ranks:
2095
+ ordered_ranks = sorted(ranks)
2096
+ midpoint = len(ordered_ranks) // 2
2097
+ if len(ordered_ranks) % 2:
2098
+ median_rank = float(ordered_ranks[midpoint])
2099
+ else:
2100
+ median_rank = float((ordered_ranks[midpoint - 1] + ordered_ranks[midpoint]) / 2)
2101
+ else:
2102
+ median_rank = None
2103
+ resample_support[fid] = float(support)
2104
+ resample_median_rank[fid] = median_rank
2105
+ displayed_supports.append(float(support))
2106
+ resample_mean_support = (
2107
+ float(sum(displayed_supports) / len(displayed_supports)) if displayed_supports else None
2108
+ )
2109
+ resample_high_support_count = sum(support >= 0.75 for support in displayed_supports)
2110
+
2111
  table_rows: list[list[object]] = []
2112
  chart_rows: list[list[object]] = []
2113
  default_candidate_id: int | None = None
 
2131
  current_max,
2132
  current_token,
2133
  bool(current_token > 0),
2134
+ resample_support.get(fid) if resample_replicates else None,
2135
+ resample_median_rank.get(fid) if resample_replicates else None,
2136
  ]
2137
  table_rows.append(row)
2138
  chart_rows.append([str(fid), score])
 
2152
  default_candidate_id=default_candidate_id,
2153
  current_context_available=current_context_available,
2154
  current_token_index=resolved_current_idx,
2155
+ displayed_current_active_count=sum(bool(row[11]) for row in table_rows),
2156
  split_half_k=split_half_k,
2157
  split_half_shared_count=split_half_shared_count,
2158
  split_half_jaccard=split_half_jaccard,
2159
+ resample_replicates=resample_replicates,
2160
+ resample_mean_support=resample_mean_support,
2161
+ resample_high_support_count=resample_high_support_count,
2162
  )
2163
 
2164
  @torch.inference_mode()
 
2594
  by_feature[feature_id].append((target_text, mean_delta, js))
2595
 
2596
  summary_rows: list[list[object]] = []
2597
+ pairwise_rows: list[list[object]] = []
2598
  for feature_id in ids:
2599
  items = by_feature[feature_id]
2600
  strongest = max(items, key=lambda item: abs(item[1]))
 
2602
  other_abs = [abs(float(item[1])) for item in items if item is not strongest]
2603
  mean_other = float(sum(other_abs) / len(other_abs)) if other_abs else 0.0
2604
  profile_ratio = float(strongest_abs / max(mean_other, 1e-12))
2605
+ deltas = [float(item[1]) for item in items]
2606
+ abs_values = [abs(delta) for delta in deltas]
2607
+ total_abs = float(sum(abs_values))
2608
+ if total_abs > 0 and len(abs_values) > 1:
2609
+ proportions = [value / total_abs for value in abs_values if value > 0]
2610
+ normalized_entropy = float(
2611
+ -sum(value * math.log(value) for value in proportions) / math.log(len(abs_values))
2612
+ )
2613
+ else:
2614
+ normalized_entropy = 0.0
2615
+ effect_concentration = float(1.0 - normalized_entropy)
2616
+ signed_bias = float(sum(deltas) / total_abs) if total_abs > 0 else 0.0
2617
+ signs = {1 if delta > 0 else -1 if delta < 0 else 0 for delta in deltas}
2618
  nonzero_signs = {sign for sign in signs if sign != 0}
2619
  sign_consistency = 'same sign' if len(nonzero_signs) <= 1 else 'mixed signs'
2620
+ if effect_concentration >= 0.25:
2621
+ profile_pattern = (
2622
+ 'target-concentrated / mixed-sign'
2623
+ if sign_consistency == 'mixed signs'
2624
+ else 'target-concentrated / same-sign'
2625
+ )
2626
+ elif signed_bias <= -0.8:
2627
+ profile_pattern = 'broad same-sign suppression'
2628
+ elif signed_bias >= 0.8:
2629
+ profile_pattern = 'broad same-sign enhancement'
2630
+ else:
2631
+ profile_pattern = 'broad mixed-sign'
2632
  summary_rows.append(
2633
  [
2634
  int(feature_id),
 
2638
  mean_other,
2639
  profile_ratio,
2640
  sign_consistency,
2641
+ normalized_entropy,
2642
+ effect_concentration,
2643
+ signed_bias,
2644
+ profile_pattern,
2645
  max(float(item[2]) for item in items),
2646
  ]
2647
  )
2648
+ for left_idx in range(len(items)):
2649
+ for right_idx in range(left_idx + 1, len(items)):
2650
+ target_a, delta_a, _ = items[left_idx]
2651
+ target_b, delta_b, _ = items[right_idx]
2652
+ preference_shift = float(delta_a - delta_b)
2653
+ direction = (
2654
+ f'toward {target_a}'
2655
+ if preference_shift > 0
2656
+ else f'toward {target_b}' if preference_shift < 0 else 'no shift'
2657
+ )
2658
+ pairwise_rows.append(
2659
+ [
2660
+ int(feature_id),
2661
+ str(target_a),
2662
+ str(target_b),
2663
+ preference_shift,
2664
+ abs(preference_shift),
2665
+ direction,
2666
+ ]
2667
+ )
2668
  summary_rows.sort(key=lambda row: float(row[3]), reverse=True)
2669
+ pairwise_rows.sort(key=lambda row: float(row[4]), reverse=True)
2670
 
2671
  return CandidateCrossTargetResult(
2672
  feature_ids=ids,
 
2674
  rows=rows,
2675
  chart_rows=chart_rows,
2676
  summary_rows=summary_rows,
2677
+ pairwise_rows=pairwise_rows,
2678
  active_feature_count=sum(activation > 0 for activation in activations),
2679
  )
2680
 
pyproject.toml CHANGED
@@ -1,6 +1,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
 
 
1
  [project]
2
  name = "featurelens"
3
+ version = "0.13.0"
4
  description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
5
  requires-python = ">=3.10"
6
 
research_config.json CHANGED
@@ -169,5 +169,13 @@
169
  "cross_target_candidate_profile",
170
  "missing_discovery_alignment_fallback",
171
  "gpu_budget_aware_touched_path_validation"
 
 
 
 
 
 
 
 
172
  ]
173
  }
 
169
  "cross_target_candidate_profile",
170
  "missing_discovery_alignment_fallback",
171
  "gpu_budget_aware_touched_path_validation"
172
+ ],
173
+ "discovery_resample_replicates": 32,
174
+ "live_features_v0_13": [
175
+ "balanced_bootstrap_candidate_support",
176
+ "cross_target_effect_concentration",
177
+ "pairwise_target_preference_shifts",
178
+ "zero_extra_gpu_evidence_synthesis",
179
+ "touched_path_only_hf_validation"
180
  ]
181
  }
scripts/release_check.py CHANGED
@@ -218,6 +218,21 @@ def check_config(config: dict) -> None:
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
 
@@ -311,16 +326,20 @@ def check_readme() -> None:
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,7 +357,7 @@ 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__':
 
218
  raise SystemExit(
219
  'research_config.json live_features_v0_12 mismatch: ' f'{sorted(actual_live_v12)}'
220
  )
221
+
222
+ required_live_v13 = {
223
+ 'balanced_bootstrap_candidate_support',
224
+ 'cross_target_effect_concentration',
225
+ 'pairwise_target_preference_shifts',
226
+ 'zero_extra_gpu_evidence_synthesis',
227
+ 'touched_path_only_hf_validation',
228
+ }
229
+ actual_live_v13 = set(config.get('live_features_v0_13', []))
230
+ if actual_live_v13 != required_live_v13:
231
+ raise SystemExit(
232
+ 'research_config.json live_features_v0_13 mismatch: ' f'{sorted(actual_live_v13)}'
233
+ )
234
+ if config.get('discovery_resample_replicates') != 32:
235
+ raise SystemExit('Discovery live resample count must be 32.')
236
  if config.get('cross_target_feature_limit') != 3 or config.get('cross_target_target_limit') != 5:
237
  raise SystemExit('Cross-target live limits must be 3 features and 5 targets.')
238
 
 
326
  'split-half',
327
  'cross-target',
328
  'target-profile',
329
+ 'resample shortlist support',
330
+ 'pairwise target preference',
331
+ 'effect concentration',
332
+ 'signed bias',
333
  ]
334
  missing = [value for value in required_strings if value.lower() not in readme.lower()]
335
  if missing:
336
+ raise SystemExit(f'README.md is missing required v0.13 content: {missing}')
337
 
338
 
339
  def check_pyproject() -> None:
340
  text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
341
+ if 'version = "0.13.0"' not in text:
342
+ raise SystemExit('pyproject.toml must declare version 0.13.0.')
343
 
344
 
345
  def main() -> None:
 
357
  print(f' layers: {config["layers"]}')
358
  print(f' feature-set sizes: {config["feature_set_sizes"]}')
359
  print(f' random controls: {config["live_random_controls"]}')
360
+ print(' release: v0.13.0')
361
 
362
 
363
  if __name__ == '__main__':
tests/test_live_runtime_helpers.py CHANGED
@@ -233,7 +233,7 @@ def test_concept_feature_discovery_runs_on_toy_runtime() -> None:
233
  assert result.current_token_index == 2
234
  assert len(result.rows) <= 3
235
  assert result.candidate_ids == [int(row[1]) for row in result.rows]
236
- assert all(len(row) == 12 for row in result.rows)
237
  if result.rows:
238
  assert result.default_candidate_id in result.candidate_ids
239
  assert all(math.isfinite(float(row[2])) for row in result.rows)
@@ -253,7 +253,7 @@ def test_concept_feature_discovery_supports_raw_mean_difference() -> None:
253
  assert result.ranking_mode == 'raw_mean_difference'
254
  assert result.current_context_available is False
255
  assert result.current_token_index is None
256
- assert all(len(row) == 12 for row in result.rows)
257
 
258
 
259
 
@@ -272,7 +272,7 @@ def test_concept_feature_discovery_supports_causal_ready_mode() -> None:
272
  assert result.ranking_mode == 'causal_ready'
273
  assert result.current_context_available is True
274
  assert result.displayed_current_active_count == len(result.rows)
275
- assert all(bool(row[-1]) for row in result.rows)
276
 
277
 
278
  def test_concept_feature_discovery_causal_ready_requires_workbench_context() -> None:
@@ -382,6 +382,29 @@ def test_concept_feature_discovery_reports_split_half_stability_without_extra_fo
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(
@@ -397,5 +420,11 @@ def test_candidate_cross_target_profile_runs_multiple_features_and_targets() ->
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)
 
233
  assert result.current_token_index == 2
234
  assert len(result.rows) <= 3
235
  assert result.candidate_ids == [int(row[1]) for row in result.rows]
236
+ assert all(len(row) == 14 for row in result.rows)
237
  if result.rows:
238
  assert result.default_candidate_id in result.candidate_ids
239
  assert all(math.isfinite(float(row[2])) for row in result.rows)
 
253
  assert result.ranking_mode == 'raw_mean_difference'
254
  assert result.current_context_available is False
255
  assert result.current_token_index is None
256
+ assert all(len(row) == 14 for row in result.rows)
257
 
258
 
259
 
 
272
  assert result.ranking_mode == 'causal_ready'
273
  assert result.current_context_available is True
274
  assert result.displayed_current_active_count == len(result.rows)
275
+ assert all(bool(row[11]) for row in result.rows)
276
 
277
 
278
  def test_concept_feature_discovery_causal_ready_requires_workbench_context() -> None:
 
382
  assert 0 <= result.split_half_shared_count <= max(len(result.candidate_ids), result.split_half_k)
383
 
384
 
385
+ def test_concept_feature_discovery_reports_resample_support_from_same_batch() -> None:
386
+ runtime = make_runtime()
387
+ result = runtime.concept_feature_discovery(
388
+ concept='mathematics',
389
+ layer=0,
390
+ prompts_per_concept=2,
391
+ top_n=3,
392
+ ranking_mode='balanced_selectivity',
393
+ current_text='abc',
394
+ current_token_index=-1,
395
+ )
396
+ assert 0 <= result.resample_replicates <= 32
397
+ if result.resample_replicates and result.rows:
398
+ assert result.resample_mean_support is not None
399
+ assert 0.0 <= result.resample_mean_support <= 1.0
400
+ assert 0 <= result.resample_high_support_count <= len(result.rows)
401
+ for row in result.rows:
402
+ assert row[12] is not None
403
+ assert 0.0 <= float(row[12]) <= 1.0
404
+ if row[13] is not None:
405
+ assert 1.0 <= float(row[13]) <= result.top_n
406
+
407
+
408
  def test_candidate_cross_target_profile_runs_multiple_features_and_targets() -> None:
409
  runtime = make_runtime()
410
  result = runtime.candidate_cross_target_profile(
 
420
  assert len(result.chart_rows) == 6
421
  assert len(result.summary_rows) == 2
422
  assert all(len(row) == 8 for row in result.rows)
423
+ assert all(len(row) == 12 for row in result.summary_rows)
424
+ assert len(result.pairwise_rows) == 2 * 3 # 2 features × C(3 targets, 2)
425
+ assert all(len(row) == 6 for row in result.pairwise_rows)
426
+ assert all(0.0 <= float(row[7]) <= 1.0 for row in result.summary_rows) # normalized entropy
427
+ assert all(0.0 <= float(row[8]) <= 1.0 for row in result.summary_rows) # concentration
428
+ assert all(-1.0 <= float(row[9]) <= 1.0 for row in result.summary_rows) # signed bias
429
+ assert all(isinstance(row[10], str) and row[10] for row in result.summary_rows)
430
  assert all(math.isfinite(float(row[5])) for row in result.rows)
tests/test_ui_helpers.py CHANGED
@@ -308,3 +308,58 @@ def test_cross_target_ui_has_independent_targets_and_small_feature_limit() -> No
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
311
+
312
+
313
+ def test_v13_discovery_markdown_reports_resample_support() -> None:
314
+ app = _import_app()
315
+ result = SimpleNamespace(
316
+ candidate_ids=[16369, 5712, 26112],
317
+ concept='mathematics',
318
+ layer=14,
319
+ prompts_per_concept=4,
320
+ ranking_mode='causal_ready',
321
+ current_context_available=True,
322
+ current_token_index=5,
323
+ displayed_current_active_count=3,
324
+ split_half_k=3,
325
+ split_half_shared_count=2,
326
+ split_half_jaccard=0.5,
327
+ resample_replicates=32,
328
+ resample_mean_support=0.71875,
329
+ resample_high_support_count=2,
330
+ )
331
+ text = app._discovery_metrics_markdown(result)
332
+ assert '32 resamples' in text
333
+ assert '71.9%' in text
334
+ assert '2/3' in text
335
+ assert 'confidence interval' in text
336
+
337
+
338
+ def test_v13_cross_target_markdown_reports_profile_and_pairwise_summary() -> None:
339
+ app = _import_app()
340
+ result = SimpleNamespace(
341
+ feature_ids=[25992, 16369],
342
+ targets=['2x', 'x', '0', 'x^2'],
343
+ active_feature_count=2,
344
+ summary_rows=[
345
+ [16369, 'x', 0.2883, 0.2883, 0.0513, 5.62, 'mixed signs', 0.58, 0.42, 0.92,
346
+ 'target-concentrated / mixed-sign', 0.0017],
347
+ [25992, '0', -0.1657, 0.1657, 0.0936, 1.77, 'same sign', 0.95, 0.05, -1.0,
348
+ 'broad same-sign suppression', 0.0011],
349
+ ],
350
+ pairwise_rows=[
351
+ [16369, 'x', '0', 0.2969, 0.2969, 'toward x'],
352
+ ],
353
+ )
354
+ text = app._cross_target_metrics_markdown(result)
355
+ assert 'target-concentrated / mixed-sign' in text
356
+ assert 'broad same-sign suppression' in text
357
+ assert 'pairwise preference shift' in text
358
+ assert "'x' vs '0'" in text
359
+
360
+
361
+ def test_v13_pairwise_cross_target_ui_is_zero_extra_gpu_output() -> None:
362
+ app = _import_app()
363
+ assert app.cross_target_pairwise_table.show_label is False
364
+ assert app.cross_target_pairwise_plot.title == 'Pairwise target preference shifts'
365
+ assert app.cross_target_pairwise_plot.visible is True