diff --git a/docs/decision-log.md b/docs/decision-log.md index f7458167d..8a50b1d86 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -28,6 +28,10 @@ When in doubt: capture, don't invent. Record the decision; link to its source ar Decisions we keep relitigating. Each entry: short statement, rationale, closing artifact, date pinned. +- **2026-06-03 — CS-8 refines LOCKED #2: the factor-evaluation surface adds per-factor ΔR² ("association strength"), still no Cp/BIC.** `code`: `feat/cs-8-association-strength` (PR pending); `spec`: [`2026-05-31-factors-evaluation-design.md`](superpowers/specs/2026-05-31-factors-evaluation-design.md) §2; sub-plan [`2026-06-02-cs-8-association-strength.md`](superpowers/plans/2026-06-02-cs-8-association-strength.md). + + LOCKED #2 ("surface metrics = adjusted R² + per-factor p ONLY") is **refined, not reopened**: the `ModelBuilderBand` now also surfaces each factor's **semipartial R²** (`ΔR² = R²(kept) − R²(kept∖f)` for a kept factor; gain-on-add for a candidate) as the **effect-size magnitude paired with the existing partial p** — the same numerator the nested-F partial p is built on (`perFactorDeltaR2`, an O(1) read off the enumerated subset index, no new regression). It is raw R² (so ≥0, on the share-of-the-total-spread scale) and **explicitly non-summing** (honors ADR-073 — contribution, never a forced variance decomposition; correlated factors' shared variance is attributed to no single factor). Framed in-UI as an association clue, **never a cause verdict** (a `model-not-a-verdict` line + caption). Cp/BIC remain OFF the surface — ΔR² is an effect size, not a model-selection criterion, so the Cp/BIC bar is untouched. Rejected: ΔR²adj (can go negative), partial R² (off the total-variance scale), relabel-only (loses the magnitude). + - **2026-06-02 — Documentation alignment completes before further product refactoring.** `new spec`: [`docs/superpowers/specs/2026-06-02-documentation-alignment-design.md`](superpowers/specs/2026-06-02-documentation-alignment-design.md) (draft). A grounded multi-agent audit (against commit `fff607f2`) found the shipped V1 surface is **~1-in-6 documented** — 57 capabilities: 10 current / 7 stale / 12 partial / 28 missing. Root cause is the **systematically-skipped SDD Apply phase**: specs ship as `delivered` (code lands) while their `implements:` target docs are never written (`2026-05-29-investigation-surface`, `2026-05-31-factors-evaluation`, `2026-05-26-canvas-connection-journey`, `2026-05-28-state-edit-mode` all show this). diff --git a/docs/superpowers/specs/2026-05-31-factors-evaluation-design.md b/docs/superpowers/specs/2026-05-31-factors-evaluation-design.md index d470de78d..fd1a870d1 100644 --- a/docs/superpowers/specs/2026-05-31-factors-evaluation-design.md +++ b/docs/superpowers/specs/2026-05-31-factors-evaluation-design.md @@ -42,7 +42,7 @@ The Wall ships with a live-derived status, comments, ActionItem tasks, the Measu ### Locked design calls (product owner, 2026-05-31) 1. **"Simplest adequate" default** = the fewest factors **within 1 point of the max adjusted R²** where each kept factor's **p < .15**; the analyst adjusts from there. (ADR-088 amendment + a tunable.) -2. **Surface metrics = adjusted R² + per-factor p ONLY.** No Mallows Cp / BIC on the surface (Cp may be an _internal_ picker metric only). Keep it simple and meaningful. +2. **Surface metrics = adjusted R² + per-factor p + per-factor ΔR² ("association strength") ONLY.** No Mallows Cp / BIC on the surface (Cp may be an _internal_ picker metric only). Keep it simple and meaningful. _(Refined CS-8, 2026-06-03: ΔR² = each factor's **semipartial R²** — `R²(kept) − R²(kept∖f)` for a kept factor, gain-on-add for a candidate — the effect-size magnitude paired with the existing partial p, on the same numerator. Raw R² so ≥0, on the share-of-the-spread scale, and **non-summing per ADR-073**. It is an effect size, not a model-selection criterion, so it does not reopen the Cp/BIC question.)_ 3. **Evaluate is one-tap, never auto-run** (avoids implying a post-selection p is a clean pre-planned test). 4. **A manual model override is VIEW-STATE while exploring; the _concluded_ model is saved via capture-as-Finding** (the Finding snapshots the factors — `FindingProjectionModelContext` already carries `rSquaredAdj`/`scopeLabel`/`linkedFactor`). No stored selection field; no Finding-per-toggle. 5. **Terminology:** top status label is **"Supported"** (shipped #259); "Counts against" stays loud. diff --git a/packages/core/src/i18n/messages/ar.ts b/packages/core/src/i18n/messages/ar.ts index 67c245801..3b03fde1e 100644 --- a/packages/core/src/i18n/messages/ar.ts +++ b/packages/core/src/i18n/messages/ar.ts @@ -949,6 +949,12 @@ export const ar: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/bg.ts b/packages/core/src/i18n/messages/bg.ts index 44f3240f8..9f095a22b 100644 --- a/packages/core/src/i18n/messages/bg.ts +++ b/packages/core/src/i18n/messages/bg.ts @@ -958,6 +958,12 @@ export const bg: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/cs.ts b/packages/core/src/i18n/messages/cs.ts index ceb280bb2..a3b990673 100644 --- a/packages/core/src/i18n/messages/cs.ts +++ b/packages/core/src/i18n/messages/cs.ts @@ -871,6 +871,12 @@ export const cs: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/da.ts b/packages/core/src/i18n/messages/da.ts index ea673a22f..e00079722 100644 --- a/packages/core/src/i18n/messages/da.ts +++ b/packages/core/src/i18n/messages/da.ts @@ -922,6 +922,12 @@ export const da: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/de.ts b/packages/core/src/i18n/messages/de.ts index 7b75646c6..ed813d8d0 100644 --- a/packages/core/src/i18n/messages/de.ts +++ b/packages/core/src/i18n/messages/de.ts @@ -961,6 +961,12 @@ export const de: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/el.ts b/packages/core/src/i18n/messages/el.ts index 89043c94f..bbe91bc9f 100644 --- a/packages/core/src/i18n/messages/el.ts +++ b/packages/core/src/i18n/messages/el.ts @@ -961,6 +961,12 @@ export const el: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/en.ts b/packages/core/src/i18n/messages/en.ts index 04564b2e1..6b9ae92a4 100644 --- a/packages/core/src/i18n/messages/en.ts +++ b/packages/core/src/i18n/messages/en.ts @@ -970,6 +970,12 @@ export const en: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/es.ts b/packages/core/src/i18n/messages/es.ts index a7467bb18..ebf811185 100644 --- a/packages/core/src/i18n/messages/es.ts +++ b/packages/core/src/i18n/messages/es.ts @@ -963,6 +963,12 @@ export const es: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/fi.ts b/packages/core/src/i18n/messages/fi.ts index 4090a2e3c..9d5a1f9f0 100644 --- a/packages/core/src/i18n/messages/fi.ts +++ b/packages/core/src/i18n/messages/fi.ts @@ -959,6 +959,12 @@ export const fi: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/fr.ts b/packages/core/src/i18n/messages/fr.ts index 447dcc7cb..27e15b830 100644 --- a/packages/core/src/i18n/messages/fr.ts +++ b/packages/core/src/i18n/messages/fr.ts @@ -967,6 +967,12 @@ export const fr: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/he.ts b/packages/core/src/i18n/messages/he.ts index b5ba0006f..735ed80dc 100644 --- a/packages/core/src/i18n/messages/he.ts +++ b/packages/core/src/i18n/messages/he.ts @@ -947,6 +947,12 @@ export const he: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/hi.ts b/packages/core/src/i18n/messages/hi.ts index 4609e7300..475a56924 100644 --- a/packages/core/src/i18n/messages/hi.ts +++ b/packages/core/src/i18n/messages/hi.ts @@ -959,6 +959,12 @@ export const hi: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/hr.ts b/packages/core/src/i18n/messages/hr.ts index 352f301de..4a6c8c038 100644 --- a/packages/core/src/i18n/messages/hr.ts +++ b/packages/core/src/i18n/messages/hr.ts @@ -954,6 +954,12 @@ export const hr: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/hu.ts b/packages/core/src/i18n/messages/hu.ts index 9f6a48064..22f7454bb 100644 --- a/packages/core/src/i18n/messages/hu.ts +++ b/packages/core/src/i18n/messages/hu.ts @@ -875,6 +875,12 @@ export const hu: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/id.ts b/packages/core/src/i18n/messages/id.ts index cc4b71f56..5394701df 100644 --- a/packages/core/src/i18n/messages/id.ts +++ b/packages/core/src/i18n/messages/id.ts @@ -910,6 +910,12 @@ export const id: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/it.ts b/packages/core/src/i18n/messages/it.ts index f20cb3c3a..93387440d 100644 --- a/packages/core/src/i18n/messages/it.ts +++ b/packages/core/src/i18n/messages/it.ts @@ -931,6 +931,12 @@ export const it: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/ja.ts b/packages/core/src/i18n/messages/ja.ts index 99e7fba3a..919967a7f 100644 --- a/packages/core/src/i18n/messages/ja.ts +++ b/packages/core/src/i18n/messages/ja.ts @@ -920,6 +920,12 @@ export const ja: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/ko.ts b/packages/core/src/i18n/messages/ko.ts index 6c40c5fab..7632ca43d 100644 --- a/packages/core/src/i18n/messages/ko.ts +++ b/packages/core/src/i18n/messages/ko.ts @@ -920,6 +920,12 @@ export const ko: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/ms.ts b/packages/core/src/i18n/messages/ms.ts index b0fced8f1..2abfa7966 100644 --- a/packages/core/src/i18n/messages/ms.ts +++ b/packages/core/src/i18n/messages/ms.ts @@ -959,6 +959,12 @@ export const ms: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/nb.ts b/packages/core/src/i18n/messages/nb.ts index f1c438603..f92560e21 100644 --- a/packages/core/src/i18n/messages/nb.ts +++ b/packages/core/src/i18n/messages/nb.ts @@ -872,6 +872,12 @@ export const nb: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/nl.ts b/packages/core/src/i18n/messages/nl.ts index a76d59cdc..8df74081e 100644 --- a/packages/core/src/i18n/messages/nl.ts +++ b/packages/core/src/i18n/messages/nl.ts @@ -930,6 +930,12 @@ export const nl: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/pl.ts b/packages/core/src/i18n/messages/pl.ts index 19d88b9ba..09c918062 100644 --- a/packages/core/src/i18n/messages/pl.ts +++ b/packages/core/src/i18n/messages/pl.ts @@ -926,6 +926,12 @@ export const pl: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/pt.ts b/packages/core/src/i18n/messages/pt.ts index b1b133754..02234ec8a 100644 --- a/packages/core/src/i18n/messages/pt.ts +++ b/packages/core/src/i18n/messages/pt.ts @@ -963,6 +963,12 @@ export const pt: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/ro.ts b/packages/core/src/i18n/messages/ro.ts index 6fba2a4e2..bc74fbe71 100644 --- a/packages/core/src/i18n/messages/ro.ts +++ b/packages/core/src/i18n/messages/ro.ts @@ -911,6 +911,12 @@ export const ro: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/sk.ts b/packages/core/src/i18n/messages/sk.ts index 0acb69b2f..a1c564f40 100644 --- a/packages/core/src/i18n/messages/sk.ts +++ b/packages/core/src/i18n/messages/sk.ts @@ -958,6 +958,12 @@ export const sk: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/sv.ts b/packages/core/src/i18n/messages/sv.ts index 7e95a585c..c5375f9fb 100644 --- a/packages/core/src/i18n/messages/sv.ts +++ b/packages/core/src/i18n/messages/sv.ts @@ -920,6 +920,12 @@ export const sv: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/th.ts b/packages/core/src/i18n/messages/th.ts index 849a11fab..d3dba8178 100644 --- a/packages/core/src/i18n/messages/th.ts +++ b/packages/core/src/i18n/messages/th.ts @@ -901,6 +901,12 @@ export const th: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/tr.ts b/packages/core/src/i18n/messages/tr.ts index 65be699ba..44cc4669f 100644 --- a/packages/core/src/i18n/messages/tr.ts +++ b/packages/core/src/i18n/messages/tr.ts @@ -928,6 +928,12 @@ export const tr: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/uk.ts b/packages/core/src/i18n/messages/uk.ts index 0ebfcb6a2..1f69bc1b6 100644 --- a/packages/core/src/i18n/messages/uk.ts +++ b/packages/core/src/i18n/messages/uk.ts @@ -911,6 +911,12 @@ export const uk: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/vi.ts b/packages/core/src/i18n/messages/vi.ts index 9e4c93938..abfd1fbca 100644 --- a/packages/core/src/i18n/messages/vi.ts +++ b/packages/core/src/i18n/messages/vi.ts @@ -909,6 +909,12 @@ export const vi: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/zhHans.ts b/packages/core/src/i18n/messages/zhHans.ts index 33cf1219d..ac25ede47 100644 --- a/packages/core/src/i18n/messages/zhHans.ts +++ b/packages/core/src/i18n/messages/zhHans.ts @@ -911,6 +911,12 @@ export const zhHans: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/messages/zhHant.ts b/packages/core/src/i18n/messages/zhHant.ts index a3ebec315..ab505b85c 100644 --- a/packages/core/src/i18n/messages/zhHant.ts +++ b/packages/core/src/i18n/messages/zhHant.ts @@ -911,6 +911,12 @@ export const zhHant: MessageCatalog = { 'wall.model.vitalFewLine': 'vital-few line', 'wall.model.rSquaredAdj': 'R²adj {value}', 'wall.model.factorP': 'p {value}', + 'wall.model.associationStrength': 'Association strength', + 'wall.model.deltaR2': 'ΔR² {value}', + 'wall.model.notAVerdict': + 'Associated with the spread in this scope — a clue to investigate, not a verdict.', + 'wall.model.deltaR2Caption': + 'Each bar is a factor’s unique share of the spread; correlated factors overlap, so they need not sum to the model fit.', 'wall.model.useSuggested': '↩ Use suggested model', 'wall.model.addToModel': 'Add {factor} to the model', 'wall.model.removeFromModel': 'Remove {factor} from the model', diff --git a/packages/core/src/i18n/types.ts b/packages/core/src/i18n/types.ts index 336b615b1..4eee20309 100644 --- a/packages/core/src/i18n/types.ts +++ b/packages/core/src/i18n/types.ts @@ -1045,6 +1045,10 @@ export interface MessageCatalog { 'wall.model.vitalFewLine': string; 'wall.model.rSquaredAdj': string; 'wall.model.factorP': string; + 'wall.model.associationStrength': string; + 'wall.model.deltaR2': string; + 'wall.model.notAVerdict': string; + 'wall.model.deltaR2Caption': string; 'wall.model.useSuggested': string; 'wall.model.addToModel': string; 'wall.model.removeFromModel': string; diff --git a/packages/core/src/stats/__tests__/modelBuilder.test.ts b/packages/core/src/stats/__tests__/modelBuilder.test.ts index e2a09739b..b3d19c6db 100644 --- a/packages/core/src/stats/__tests__/modelBuilder.test.ts +++ b/packages/core/src/stats/__tests__/modelBuilder.test.ts @@ -15,6 +15,7 @@ import { buildSubsetIndex, lookupSubset, perFactorPValues, + perFactorDeltaR2, selectVitalFew, isFitOnlyEstimate, redundancyHint, @@ -433,3 +434,72 @@ describe('redundancyHint', () => { expect(hint!.vif).toBeCloseTo(25, 10); }); }); + +// ============================================================================ +// perFactorDeltaR2 — per-factor semipartial R² (association strength) +// ============================================================================ + +/** Deterministic: Shift dominates Y, Machine adds a little, Noise is junk. */ +function buildRerankData(): DataRow[] { + const shiftEffect: Record = { A: 0, B: 10, C: 20 }; + const machineEffect: Record = { X: 0, Y: 2 }; + const rows: DataRow[] = []; + let i = 0; + for (const s of ['A', 'B', 'C']) { + for (const m of ['X', 'Y']) { + for (const nz of ['p', 'q']) { + for (let r = 0; r < 3; r++) { + const wobble = ((i * 7) % 5) - 2; // deterministic -2..2 + rows.push({ + Shift: s, + Machine: m, + Noise: nz, + Y: shiftEffect[s] + machineEffect[m] + wobble, + }); + i++; + } + } + } + } + return rows; +} + +describe('perFactorDeltaR2', () => { + const data = buildRerankData(); + const result = computeBestSubsets(data, 'Y', ['Shift', 'Machine', 'Noise'])!; + const index = buildSubsetIndex(result); + + it('ranks the dominant factor highest and junk near zero, all >= 0', () => { + const kept = ['Shift', 'Machine', 'Noise']; + const d = perFactorDeltaR2(kept, ['Shift', 'Machine', 'Noise'], index); + expect(d.get('Shift')!).toBeGreaterThan(d.get('Machine')!); + expect(d.get('Machine')!).toBeGreaterThan(d.get('Noise')!); + for (const v of d.values()) expect(v).toBeGreaterThanOrEqual(0); + }); + + it('for a KEPT factor returns the drop-on-remove (semipartial R²)', () => { + // Use the public lookupSubset accessor (not raw byKey.get) so the test + // stays decoupled from the internal subset-key encoding. + const full = lookupSubset(index, ['Shift', 'Machine'])!; + const reduced = lookupSubset(index, ['Machine'])!; + const d = perFactorDeltaR2(['Shift', 'Machine'], ['Shift'], index); + expect(d.get('Shift')!).toBeCloseTo(full.rSquared - reduced.rSquared, 10); + }); + + it('for a NON-KEPT candidate returns the gain-on-add', () => { + const kept = ['Machine']; + const augmented = lookupSubset(index, ['Machine', 'Shift'])!; + const base = lookupSubset(index, ['Machine'])!; + const d = perFactorDeltaR2(kept, ['Shift'], index); + expect(d.get('Shift')!).toBeCloseTo(augmented.rSquared - base.rSquared, 10); + }); + + it('with no kept factors a candidate gets its single-factor marginal R² (empty baseline)', () => { + // The Analyze Wall opens a fresh scope with zero factors selected; the + // baseline R² is then 0, so a candidate's ΔR² is its standalone + // single-factor R². Guards the lookupSubset([]) → null empty-set path. + const single = lookupSubset(index, ['Shift'])!; + const d = perFactorDeltaR2([], ['Shift'], index); + expect(d.get('Shift')!).toBeCloseTo(single.rSquared, 10); + }); +}); diff --git a/packages/core/src/stats/index.ts b/packages/core/src/stats/index.ts index c83900733..e31ff4111 100644 --- a/packages/core/src/stats/index.ts +++ b/packages/core/src/stats/index.ts @@ -131,6 +131,7 @@ export { buildSubsetIndex, lookupSubset, perFactorPValues, + perFactorDeltaR2, selectVitalFew, isFitOnlyEstimate, redundancyHint, diff --git a/packages/core/src/stats/modelBuilder.ts b/packages/core/src/stats/modelBuilder.ts index acd4d3bb1..7d3f673c4 100644 --- a/packages/core/src/stats/modelBuilder.ts +++ b/packages/core/src/stats/modelBuilder.ts @@ -13,8 +13,10 @@ * exact (order-independent) factor set, so toggling a candidate across the * "vital-few line" never recomputes the regression. * 3. `perFactorPValues`— each kept factor's p for the surface header. We surface - * adjusted R² + per-factor p ONLY (LOCKED #2: no Mallows Cp / BIC on the - * surface). p source: the OLS per-predictor p (group min per factorName) + * adjusted R² + per-factor p + per-factor ΔR² (semipartial R², the effect size + * behind the partial p — see `perFactorDeltaR2`) ONLY (LOCKED #2, refined CS-8: + * still no Mallows Cp / BIC on the surface — ΔR² is an effect size, never a + * model-selection criterion). p source: the OLS per-predictor p (group min per factorName) * when present, else the factor's own single-factor subset overall-F p * (always enumerated). Both are deterministic + engine-derived. * 4. `redundancyHint` — multicollinearity honesty (spec §3): toggling a @@ -431,3 +433,49 @@ export function redundancyHint( return { removedFactor, vif, rSquaredAdjDelta: delta }; } + +// ============================================================================ +// Per-factor association strength (semipartial R²) +// ============================================================================ + +/** + * Per-factor **semipartial R²** ("association strength, ΔR²") for the current + * model — the *unique* share of the spread each factor accounts for. + * + * - KEPT factor `f`: ΔR²_f = R²(kept) − R²(kept \ {f}) (drop-on-remove) + * - NON-KEPT factor `c`: ΔR²_c = R²(kept ∪ {c}) − R²(kept) (gain-on-add) + * + * Uses RAW R² (not adjusted), so values are always ≥ 0 and read on the + * "share of the total spread" scale. The KEPT delta is the SAME numerator the + * nested-F partial p in `perFactorPValues` is built on (p = significance, + * ΔR² = magnitude). By design these do NOT sum to the model R²: under + * collinearity the shared variance is attributed to no single factor (ADR-073 — + * contribution, never a forced decomposition). Every value is an O(1) lookup in + * the already-enumerated subset index; no regression is re-run. Returns 0 for a + * factor whose required subset was not enumerated (defensive). + */ +export function perFactorDeltaR2( + keptFactors: readonly string[], + factors: readonly string[], + index: SubsetIndex +): Map { + const out = new Map(); + const kept = [...keptFactors]; + const keptSubset = lookupSubset(index, kept); + const r2Kept = keptSubset?.rSquared ?? 0; + + for (const f of factors) { + let delta: number; + if (kept.includes(f)) { + const reducedFactors = kept.filter(k => k !== f); + const reduced = reducedFactors.length > 0 ? lookupSubset(index, reducedFactors) : null; + const r2Reduced = reducedFactors.length === 0 ? 0 : (reduced?.rSquared ?? 0); + delta = r2Kept - r2Reduced; + } else { + const augmented = lookupSubset(index, [...kept, f]); + delta = (augmented?.rSquared ?? r2Kept) - r2Kept; + } + out.set(f, Math.max(0, delta)); + } + return out; +} diff --git a/packages/ui/src/components/AnalyzeWall/ModelBuilderBand.tsx b/packages/ui/src/components/AnalyzeWall/ModelBuilderBand.tsx index 4f0b72d36..d91391358 100644 --- a/packages/ui/src/components/AnalyzeWall/ModelBuilderBand.tsx +++ b/packages/ui/src/components/AnalyzeWall/ModelBuilderBand.tsx @@ -13,7 +13,9 @@ * useState (LOCKED #4 — nothing persists until capture-as-Finding). Toggling is * an O(1) `lookupSubset` into the already-enumerated subsets (no recompute). * - * Surface metrics = adjusted R² + per-factor p ONLY (LOCKED #2 — no Cp/BIC). + * Surface metrics = adjusted R² + per-factor p + per-factor ΔR² (semipartial R², + * "association strength") ONLY (LOCKED #2, refined CS-8 — no Cp/BIC; ΔR² is the + * effect size behind the partial p, never a model-selection criterion). * Copy uses factor-side verbs only ("accounts for the spread", "vital few"). */ @@ -29,6 +31,7 @@ import { redundancyHint, computeSubsetVIF, factorSetKey, + perFactorDeltaR2, } from '@variscout/core/stats'; import { formatMessage } from '@variscout/core/i18n'; import { useWallLocale } from './hooks/useWallLocale'; @@ -85,6 +88,21 @@ function fmtP(value: number): string { if (value < 0.001) return '<.001'; return value.toFixed(3); } +/** Sort factors by descending association strength (ΔR²); shared by capture + both lists. */ +const byDeltaR2Desc = + (m: Map) => + (a: string, b: string): number => + (m.get(b) ?? 0) - (m.get(a) ?? 0); + +/** A small inline association-strength bar; width = ΔR² (0..1), honest scale. */ +const DeltaBar = ({ value }: { value: number }) => { + const pct = Math.max(0, Math.min(1, value)) * 100; + return ( + + + + ); +}; export const ModelBuilderBand: React.FC = ({ rows, @@ -168,6 +186,13 @@ export const ModelBuilderBand: React.FC = ({ return computeSubsetVIF([...rows], outcome, kept); }, [keptSubset, rows, outcome, kept]); + // Per-factor association strength (semipartial R²) for kept + candidate + // factors. O(1) reads off the enumerated index (see perFactorDeltaR2). + const deltaR2 = useMemo>(() => { + if (!engine) return new Map(); + return perFactorDeltaR2(kept, eligibleFactors, engine.index); + }, [engine, kept, eligibleFactors]); + // Has the analyst deviated from the engine suggestion? Drives the snap-back. const deviated = useMemo(() => { if (!engine) return false; @@ -219,11 +244,8 @@ export const ModelBuilderBand: React.FC = ({ if (!onCaptureModel || !keptSubset || kept.length === 0) return; const perFactorP: Record = {}; for (const f of kept) perFactorP[f] = keptP.get(f) ?? 1; - // top factor = the kept factor with the lowest p (most explanatory). - const topFactor = - kept.length > 0 - ? [...kept].sort((a, b) => (keptP.get(a) ?? 1) - (keptP.get(b) ?? 1))[0] - : null; + // top factor = the kept factor with the highest association strength (ΔR²). + const topFactor = kept.length > 0 ? [...kept].sort(byDeltaR2Desc(deltaR2))[0] : null; onCaptureModel({ factors: [...kept], rSquaredAdj: keptSubset.rSquaredAdj, @@ -231,10 +253,13 @@ export const ModelBuilderBand: React.FC = ({ scopeLabel, topFactor, }); - }, [onCaptureModel, keptSubset, kept, keptP, scopeLabel]); + }, [onCaptureModel, keptSubset, kept, keptP, scopeLabel, deltaR2]); // ── Render ──────────────────────────────────────────────────────────────── - const candidatesBelowLine = eligibleFactors.filter(f => !kept.includes(f)); + const keptSorted = [...kept].sort(byDeltaR2Desc(deltaR2)); + const candidatesBelowLine = eligibleFactors + .filter(f => !kept.includes(f)) + .sort(byDeltaR2Desc(deltaR2)); return ( = ({ + {/* Association framing — this is a magnitude, not a cause verdict. */} +
+ {formatMessage(locale, 'wall.model.notAVerdict')} +
+ {/* KEPT — the vital few, above the line. */}
{formatMessage(locale, 'wall.model.keptHeading')}
    - {kept.map(factor => ( + {keptSorted.map(factor => (
  • - - {formatMessage(locale, 'wall.model.factorP', { - value: fmtP(keptP.get(factor) ?? 1), - })} + + + + {formatMessage(locale, 'wall.model.deltaR2', { + value: fmtR2(deltaR2.get(factor) ?? 0), + })} + + + {formatMessage(locale, 'wall.model.factorP', { + value: fmtP(keptP.get(factor) ?? 1), + })} +
  • ))} @@ -337,17 +382,25 @@ export const ModelBuilderBand: React.FC = ({
      {candidatesBelowLine.map(factor => ( -
    • +
    • + + {formatMessage(locale, 'wall.model.deltaR2', { + value: fmtR2(deltaR2.get(factor) ?? 0), + })} +
    • ))} {constantFactors.map(factor => ( diff --git a/packages/ui/src/components/AnalyzeWall/__tests__/ModelBuilderBand.test.tsx b/packages/ui/src/components/AnalyzeWall/__tests__/ModelBuilderBand.test.tsx index e9985bbab..9d25d56f3 100644 --- a/packages/ui/src/components/AnalyzeWall/__tests__/ModelBuilderBand.test.tsx +++ b/packages/ui/src/components/AnalyzeWall/__tests__/ModelBuilderBand.test.tsx @@ -208,4 +208,125 @@ describe('ModelBuilderBand', () => { // The constant factor is NOT a toggleable candidate. expect(screen.queryByTestId('model-candidate-factor-Machine')).toBeNull(); }); + + it('shows a ΔR² (association strength) value for each kept factor', () => { + renderInSvg( + + ); + expect(screen.getByTestId('model-deltaR2-Shift')).toBeInTheDocument(); + const text = screen.getByTestId('model-deltaR2-Shift').textContent ?? ''; + expect(text).toMatch(/ΔR²/); + // Numeric-signal guard: ΔR² must be > 0 for the dominant Shift factor — catches + // dead-wiring where the engine is disconnected and all values fall back to 0. + expect(parseFloat(text.replace(/[^\d.]/g, ''))).toBeGreaterThan(0); + }); + + it('shows the "association, not a verdict" framing', () => { + renderInSvg( + + ); + expect(screen.getByTestId('model-not-a-verdict')).toHaveTextContent(/not a verdict/i); + }); + + /** + * Conditional structure: globally Region drives Y (0 vs 500 — massive gap); + * WITHIN Region A, Machine drives the residual (X≈0 vs Y≈8), while Noise is a + * balanced junk factor with no association anywhere. Region B rows are clustered + * so tightly that Machine adds no global marginal R² worth keeping. + * → global vital few = [Region] only; drill to Region=A (Region constant) → + * [Machine] (and Noise stays below the line even though it is also eligible). + * + * The Region effect (500-unit gap) is ~62× larger than Machine (8 units), so the + * semipartial R² for Machine after partialling out Region is negligible globally. + * The wobble cycles -1/0/1 and is balanced within each 6-row cell, so Noise (which + * only ever differs by wobble) carries exactly zero signal. + */ + function buildConditionalData(): DataRow[] { + const rows: DataRow[] = []; + let i = 0; + const push = (Region: string, Machine: string, Noise: string, base: number, mEff: number) => { + for (let r = 0; r < 6; r++) { + const wobble = (i % 3) - 1; // deterministic -1, 0, 1 rotation (sums to 0 per 6-row cell) + rows.push({ Region, Machine, Noise, Y: base + mEff + wobble }); + i++; + } + }; + // Region A (base 0): Machine drives the residual (X=0, Y=8); Noise is junk. + push('A', 'X', 'p', 0, 0); + push('A', 'X', 'q', 0, 0); + push('A', 'Y', 'p', 0, 8); + push('A', 'Y', 'q', 0, 8); + // Region B (base 500): Machine flat, Noise junk. + push('B', 'X', 'p', 500, 0); + push('B', 'X', 'q', 500, 0); + push('B', 'Y', 'p', 500, 0); + push('B', 'Y', 'q', 500, 0); + return rows; + } + + it('re-ranks the vital few when the analyst drills into a scope', () => { + const all = buildConditionalData(); + + // Global view: Region is the vital few; Machine and Noise are below the line. + const { unmount } = render( + + + + ); + expect(screen.getByTestId('model-kept-factor-Region')).toBeInTheDocument(); + expect(screen.queryByTestId('model-kept-factor-Machine')).not.toBeInTheDocument(); + expect(screen.queryByTestId('model-kept-factor-Noise')).not.toBeInTheDocument(); + unmount(); + + // Drilled to Region=A: Region is constant in scope, so Machine AND Noise are + // both eligible candidates. Keeping Machine but NOT Noise is load-bearing on + // the band actually re-running the engine over the Region-A rows — it can't + // pass by merely showing the sole remaining factor. + const regionA = all.filter((r: DataRow) => r['Region'] === 'A'); + render( + + + + ); + expect(screen.getByTestId('model-kept-factor-Machine')).toBeInTheDocument(); + expect(screen.queryByTestId('model-kept-factor-Noise')).not.toBeInTheDocument(); + expect(screen.getByTestId('model-constant-factor-Region')).toBeInTheDocument(); + }); });