diff --git a/AGENTS.md b/AGENTS.md index 2bd32b4dc..92dbcc403 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -544,3 +544,5 @@ ADR 0295 keeps grouping-comparison reconstruction in the LineageWeave read-model ADR 0296 adds persisted grouping-comparison explained-leftover share `e` in the LineageWeave read-model/UI boundary: expose it in the pair button accessible name, keep the duplicate visible badge presentation-only, and never derive or clamp psychometric values. ADR 0367 exposes persisted grouping-comparison leftover-map axis share only for a fully caller-visible persisted grouping. Partial visibility returns no axis aggregate; never recompute it from visible members. Missing/non-finite share omits only the badge; persisted zero and finite negative values remain explicit. + +ADR 0369 adds persisted grouping-comparison `U²/R²` only as a presentation/read-model consumer on exact #828. Never derive or clamp it, never recompute it from a visible subset, and include every finite buyer-visible comparison metric in the actionable pair button accessible name. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2c555752b..1e9cbde0d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1097,3 +1097,5 @@ ADR 0295 adds persisted `R̂` to grouping-comparison pair buttons. The explicit ADR 0296 adds persisted explained-leftover share `e = R̂²/R²` to grouping-comparison pair actions; the button accessible name is authoritative for assistive technology and fast-mlsirm remains psychometric owner. ADR 0367 adds persisted `leftover_map_axes` to the grouping-comparison read model under the same whole-population authorization rule as other psychometric aggregates. The UI consumes persisted `leftover_share` through `leftoverMapCompareAxisShare`; no subset recomputation or singular-value derivation is permitted. + +ADR 0369 composes persisted finite grouping-comparison `leftover_map_unexplained_share` into pair actions. The parent button accessible name and visual badge share one formatter result; the visual duplicate is `aria-hidden`. Authorization and psychometric ownership remain upstream/current-parent contracts. diff --git a/CHANGELOG.d/2.55.0-leftover-map-compare-unexplained-share.md b/CHANGELOG.d/2.55.0-leftover-map-compare-unexplained-share.md new file mode 100644 index 000000000..57d19892c --- /dev/null +++ b/CHANGELOG.d/2.55.0-leftover-map-compare-unexplained-share.md @@ -0,0 +1,5 @@ +## 2.55.0 — Grouping comparison unexplained leftover share + +- Show persisted finite `U²/R²` on grouping-comparison leftover-pair actions through `formatLeftoverMapUnexplainedShare` (ADR 0369). Zero and values above 1 remain explicit; missing/non-finite values are omitted. +- Include the localized persisted metric in the actionable button accessible name; hide the duplicate visual badge from the accessibility tree. +- Never derive, clamp, or recompute psychometric truth from caller-visible subsets. Historical ADR0295/v2.52.0 is evidence only. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cffe6075..524e9a4c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to this project are documented here. Format follows ### Added +- Grouping comparison pair actions now surface persisted finite unexplained leftover share `U²/R²` under ADR 0369 / v2.55.0, including the metric in the button accessible name and never deriving it from other psychometric values. + - Grouping comparison pair buttons now expose persisted explained-leftover share `e = R̂²/R²` (ADR 0296 / v2.53.0). The visible duplicate badge is accessibility-hidden while the actionable button name includes the localized label and formatted persisted value. Missing/non-finite `e` omits only the suffix; finite zero and values above 1 remain explicit without clamping or client-side derivation. - Grouping comparison leftover-pair buttons now expose persisted leftover-map reconstruction `R̂` (ADR 0295 / v2.52.0). The visible badge is decorative to accessibility APIs; the button accessible name carries the reconstruction label and value, so the explicit button `aria-label` cannot suppress the metric. Missing/non-finite `R̂` omits only that value and no client-side proxy is invented. diff --git a/CLAUDE.md b/CLAUDE.md index a4d4ece4e..c86ebcb45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,3 +63,5 @@ ADR 0294 is the Proposed grouping-comparison incomplete-item presentation contra ADR 0295 is the Proposed grouping-comparison reconstruction contract: persisted `R̂` only, no client inference, accessible-name exposure required despite the button's explicit aria-label. ADR 0296 is the Proposed grouping-comparison explained-share contract: persisted `e` only, accessible-name exposure required, no client derivation or clamping. + +Grouping-comparison unexplained-share presentation is ADR 0369: consume persisted finite `leftover_map_unexplained_share`, never derive it, and keep its value in the actionable button accessible name. diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index 58c6c2dc4..b4bfb3187 100644 --- a/backend/app/report_ingestion.py +++ b/backend/app/report_ingestion.py @@ -1052,7 +1052,7 @@ async def fetch_period_comparison( f""" select lp.grouping_kind, lp.grouping_key, lp.pair_kind, lp.post_id, lp.criterion_code, lp.leftover_distance, lp.leftover_residual, - lp.leftover_map_reconstruction, + lp.leftover_map_reconstruction, lp.leftover_map_unexplained_share, p.post_title, p.visibility_code, p.corporate_entity_id, ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context from report_leftover_pair lp @@ -1138,6 +1138,11 @@ async def fetch_period_comparison( if pair["leftover_map_reconstruction"] is None else float(pair["leftover_map_reconstruction"]) ), + "leftover_map_unexplained_share": ( + None + if pair["leftover_map_unexplained_share"] is None + else float(pair["leftover_map_unexplained_share"]) + ), "visibility_code": pair["visibility_code"], "corporate_entity_id": str(pair["corporate_entity_id"]), "has_real_source_context": bool(pair["has_real_source_context"]), diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 95431c425..e3590a16c 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -5860,6 +5860,19 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token, or isinstance(pair["leftover_map_reconstruction"], (int, float)) for pair in leftover_thread.get("leftover_pairs", []) ) + assert all( + "leftover_map_unexplained_share" in pair + for pair in leftover_thread.get("leftover_pairs", []) + ) + assert any( + pair["leftover_map_unexplained_share"] is not None + for pair in leftover_thread.get("leftover_pairs", []) + ) + assert all( + pair["leftover_map_unexplained_share"] is None + or isinstance(pair["leftover_map_unexplained_share"], (int, float)) + for pair in leftover_thread.get("leftover_pairs", []) + ) def test_seed_period_report_includes_fixture_event_lineage_posts( diff --git a/backend/tests/test_report_ingestion_unexplained_share.py b/backend/tests/test_report_ingestion_unexplained_share.py new file mode 100644 index 000000000..0c6f6dff3 --- /dev/null +++ b/backend/tests/test_report_ingestion_unexplained_share.py @@ -0,0 +1,48 @@ +"""Regression for persisted grouping-comparison unexplained-share transport.""" + +from __future__ import annotations + +import asyncio +from decimal import Decimal + +from backend.app import report_ingestion + + +class _ComparisonConnection: + """Minimal asyncpg-compatible read boundary for one comparison row.""" + + async def fetch(self, query: str, *_args: object) -> list[dict[str, object]]: + if "from report_period_score" in query: + return [{"grouping_kind": "thread_group", "grouping_key": "synthetic-thread", "mean_theta": 0.1, "post_count": 2, "link_method": "synthetic"}] + if "from report_member_score" in query: + return [] + if "from report_leftover_pair" in query: + assert "lp.leftover_map_unexplained_share" in query, "leftover_map_unexplained_share is absent from comparison SELECT" + common = { + "grouping_kind": "thread_group", + "grouping_key": "synthetic-thread", + "post_id": "00000000-0000-0000-0000-000000000001", + "post_title": "Synthetic comparison post", + "criterion_code": "synthetic_criterion", + "leftover_distance": 0.5, + "leftover_residual": 0.4, + "leftover_map_reconstruction": Decimal("0.25"), + "visibility_code": "public", + "corporate_entity_id": "00000000-0000-0000-0000-000000000002", + "has_real_source_context": False, + } + return [ + {**common, "pair_kind": "closest", "leftover_map_unexplained_share": Decimal("0.02")}, + {**common, "pair_kind": "farthest", "leftover_map_unexplained_share": None}, + ] + if "from report_leftover_map_coverage" in query or "from report_leftover_map_axis" in query: + return [] + raise AssertionError(f"unexpected comparison query: {query}") + + +def test_fetch_period_comparison_transports_persisted_unexplained_share() -> None: + """Persisted finite values reach the read model while SQL NULL remains unknown.""" + payload = asyncio.run(report_ingestion.fetch_period_comparison(_ComparisonConnection(), "2026-W02")) # type: ignore[arg-type] + pairs = payload[0]["leftover_pairs"] + assert pairs[0]["leftover_map_unexplained_share"] == 0.02 + assert pairs[1]["leftover_map_unexplained_share"] is None diff --git a/docs/adr/0369-leftover-map-compare-unexplained-share.md b/docs/adr/0369-leftover-map-compare-unexplained-share.md new file mode 100644 index 000000000..ce851b826 --- /dev/null +++ b/docs/adr/0369-leftover-map-compare-unexplained-share.md @@ -0,0 +1,26 @@ +# ADR 0369 — Persisted unexplained leftover share in grouping comparison pair actions + +**Decision status:** Proposed + +## Problem +The period-report pair model persists `leftover_map_unexplained_share` (`s = U² / R²`) for authorized leftover pairs, but exact #828's grouping-comparison read path omits that field, so the frontend cannot receive it from `GET /api/reports/compare/{period_code}`. Historical #829 proved the buyer-visible delta but used stale ancestry/identity and put the metric only in a child badge while the parent button supplied an explicit accessible name. + +## Constraints +- Consume persisted psychometric truth; never derive `s` from `U`, `R`, reconstruction, distance, geometry, rank, coverage, or other shares. +- Missing/non-finite values are omitted. Zero and finite values above 1 remain explicit and unclamped. +- Whole-population authorization remains current-parent authority; no visible-subset recomputation. +- The actionable pair button and visible badge communicate the same finite persisted value. +- Inline KO/ZH/JA/VI copy is compatibility presentation only; #922/#929/#932 remain canonical eight-locale translation-ledger authority. + +## Alternatives and decision +Hiding `s` loses persisted buyer evidence. Recomputing it duplicates psychometric authority. A child-only badge fails the actionable accessible-name contract. Therefore consume persisted finite `s`, append its localized label/value to the button accessible name, and mark the duplicate visible badge `aria-hidden`. + +This is the serialized successor of exact #828 `7b6dbbb99a09d0523bce43c6872bbd9fa382a43a`, adopts the distinct historical #829 delta, and allocates v2.55.0. ADR0295/v2.52.0 is historical evidence only. + +## Verification +The accessibility test must RED while the parent name omits `U²/R² 0.02`, then GREEN after only the accessible-name/duplicate-badge fix. The transport regression must independently RED while the comparison SELECT omits the persisted field, then GREEN without deriving or clamping it. A real authenticated PostgreSQL/Keycloak/Valkey `GET /api/reports/compare/2026-W02` acceptance must prove the persisted key/value reaches authorized leftover pairs. Full backend, frontend lint/test/build/Storybook, version parity, clean-tree authority, and non-force promotion are required. Hosted exact-product-head checks, browser/keyboard/focus/a11y evidence, and qualifying independent approval remain separate merge gates. + +## Evidence +Gabriel, K. R. (1971). The biplot graphic display of matrices with application to principal component analysis. *Biometrika, 58*(3), 453–467. + +Jeon, M., Jin, I. H., Schweinberger, M., & Baugh, S. (2021). Mapping unobserved item–respondent interactions: A latent space item response model with interaction map. *Psychometrika, 86*(2), 378–403. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 6a0bc0295..7ff09f08e 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,8 @@ # Product & Technical Gap Baseline +> Current serialized repair (2026-09-07): #829 consumes exact promoted #828 `7b6dbbb99a09d0523bce43c6872bbd9fa382a43a` and preserves persisted `U²/R²` comparison presentation as ADR 0369 / v2.55.0. Finite values are present in the actionable button name and visible badge; missing/non-finite values are absent; values are never derived/clamped; current-parent whole-population authorization remains intact. Historical ADR0295/v2.52.0 is evidence only. Inline five-locale copy is compatibility presentation while #922/#929/#932 remain the eight-locale translation-ledger owner path. + + > Current serialized repair (2026-09-07): #828 is reconstructed from exact #827 `0f9e9c8db37948041f39b4e68e4bbd808fa83752` as ADR 0367 / v2.54.0. The valid delta carries persisted grouping-comparison `leftover_map_axes` but exposes them only when the caller can see the entire persisted grouping population; partial visibility returns no aggregate and never recomputes psychometrics from the visible subset. Presentation consumes persisted `leftover_share` only, with zero and finite negative values explicit and missing/non-finite values omitted. Historical v2.50.0/ADR0293 identity is evidence only. Existing five-locale compatibility copy is preserved while the database-backed eight-locale translation-ledger authority remains #922/#929/#932; this Draft does not create a competing translation source. diff --git a/frontend/package.json b/frontend/package.json index 52298d5f8..228735341 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.54.0", + "version": "2.55.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 6768f4a0e..3230225df 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -928,6 +928,7 @@ describe("App, authenticated", () => { leftover_residual: 0.4, leftover_map_reconstruction: 0.248, leftover_map_explained_share: 0.76, + leftover_map_unexplained_share: 0.02, }, ], leftover_map_coverage: { @@ -4383,6 +4384,10 @@ describe("App, authenticated", () => { name: /open leftover closest pair from comparison: public post.*leftover map comparison reconstruction R̂ \+0\.25.*leftover map comparison explained leftover share R̂²\/R² 0\.76/i, }); expect(explainedSharePair).toHaveTextContent("R̂²/R² 0.76"); + const unexplainedSharePair = screen.getByRole("button", { + name: /open leftover closest pair from comparison: public post.*leftover map comparison unexplained leftover share U²\/R² 0\.02/i, + }); + expect(unexplainedSharePair).toHaveTextContent("U²/R² 0.02"); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( expect.stringContaining("/api/reports/thread_group/2026-W02"), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d291e1f55..863999640 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -151,6 +151,10 @@ import { formatLeftoverMapExplainedShare, LEFTOVER_MAP_COMPARE_EXPLAINED_SHARE_LABEL, } from "./leftoverMapExplainedShare"; +import { + formatLeftoverMapUnexplainedShare, + LEFTOVER_MAP_COMPARE_UNEXPLAINED_SHARE_LABEL, +} from "./leftoverMapUnexplainedShare"; import { leftoverMapCompareAxisShare, LEFTOVER_MAP_COMPARE_AXIS_SHARE, @@ -4100,6 +4104,9 @@ function ReportsPanel({ const explainedShare = formatLeftoverMapExplainedShare( pair.leftover_map_explained_share, ); + const unexplainedShare = formatLeftoverMapUnexplainedShare( + pair.leftover_map_unexplained_share, + ); const pairAccessibleName = `Open leftover ${pair.pair_kind} pair from comparison: ${pair.post_title} · ${criterion}${ reconstruction ? ` · ${t(LEFTOVER_MAP_COMPARE_RECONSTRUCTION_LABEL)} ${reconstruction}` @@ -4108,6 +4115,10 @@ function ReportsPanel({ explainedShare ? ` · ${t(LEFTOVER_MAP_COMPARE_EXPLAINED_SHARE_LABEL)} ${explainedShare}` : "" + }${ + unexplainedShare + ? ` · ${t(LEFTOVER_MAP_COMPARE_UNEXPLAINED_SHARE_LABEL)} ${unexplainedShare}` + : "" }`; return (