Spaces:
Running on Zero
Running on Zero
Commit ·
0536091
1
Parent(s): 80cf7fc
Release FeatureLens v0.12.0
Browse files- CHANGELOG.md +10 -0
- README.md +16 -3
- app.py +314 -8
- docs/METHODOLOGY.md +38 -0
- docs/VALIDATION.md +71 -75
- featurelens/runtime.py +216 -0
- pyproject.toml +1 -1
- research_config.json +16 -1
- scripts/release_check.py +23 -4
- tests/test_live_runtime_helpers.py +36 -0
- tests/test_ui_helpers.py +52 -0
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.
|
| 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.
|
| 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
|
| 854 |
return "", pd.DataFrame(columns=columns), pd.DataFrame()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
discovery = pd.DataFrame(discovery_table).copy()
|
| 856 |
controlled = pd.DataFrame(specificity_table).copy()
|
| 857 |
-
if
|
| 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.
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
| 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">
|
| 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">
|
| 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.
|
| 2 |
|
| 3 |
-
v0.
|
| 4 |
|
| 5 |
## Local release gate
|
| 6 |
|
| 7 |
-
|
| 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
|
|
|
|
|
|
|
| 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.
|
| 27 |
```
|
| 28 |
|
| 29 |
-
## HF acceptance
|
| 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 |
-
|
| 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 |
-
|
| 48 |
|
| 49 |
-
|
| 50 |
-
16369
|
| 51 |
-
25992
|
| 52 |
-
21670
|
| 53 |
-
```
|
| 54 |
|
| 55 |
-
|
| 56 |
|
| 57 |
-
|
| 58 |
-
- `25992`: raw target-effect leader;
|
| 59 |
-
- `21670`: second-largest raw target effect.
|
| 60 |
|
| 61 |
Set:
|
| 62 |
|
| 63 |
```text
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
```
|
| 66 |
|
| 67 |
-
Click
|
| 68 |
|
| 69 |
-
|
| 70 |
|
| 71 |
-
|
| 72 |
|
| 73 |
-
-
|
| 74 |
-
-
|
| 75 |
-
-
|
| 76 |
-
-
|
| 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 |
-
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
| 88 |
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
-
|
| 94 |
|
| 95 |
-
|
| 96 |
|
| 97 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
-
|
| 110 |
|
| 111 |
-
|
|
|
|
|
|
|
| 112 |
|
| 113 |
-
|
| 114 |
|
| 115 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 126 |
|
| 127 |
-
##
|
| 128 |
|
| 129 |
-
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
|
|
|
|
| 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.
|
| 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
|
| 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.
|
| 299 |
|
| 300 |
|
| 301 |
def check_pyproject() -> None:
|
| 302 |
text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
|
| 303 |
-
if 'version = "0.
|
| 304 |
-
raise SystemExit('pyproject.toml must declare version 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.
|
| 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
|