From 1bd45fcdf8039af86d3b62092489fb9c5347f51a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 20:31:32 +0000 Subject: [PATCH 1/8] feat: persist leftover observed Y and expected E (v2.12.20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After seed, leftover closest/farthest pairs sit above the member list with observed Y and expected E[Y|θ, item] next to leftover-map distance d. Click opens that post. Residual stays R = Y − E (Jeon et al., 2021 eq. 3; Gabriel 1971; ADR 0170). Never invent a leftover score or theta. Independent of leftover persist-map (#481), criterion landing (#485), complete-case coverage (#518), leftover-map axis share (#519), comparison-strip leftover pairs (#521), and two-axis distance (#522). Issues #79 and #87 stay open. --- AGENTS.md | 11 +-- ARCHITECTURE.md | 8 +- .../2.12.20-leftover-observed-expected.md | 8 ++ CHANGELOG.md | 10 +++ CLAUDE.md | 4 +- backend/app/report_ingestion.py | 20 ++++- backend/tests/test_api.py | 12 +++ docker/postgres-init/migrate.sh | 2 +- .../0003-fast-mlsirm-report-integration.md | 7 +- docs/adr/0048-persist-lsirm-leftover-pairs.md | 5 +- docs/adr/0049-leftover-pair-report-ui.md | 10 ++- docs/adr/0170-leftover-observed-expected.md | 77 ++++++++++++++++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 10 ++- frontend/src/App.tsx | 28 ++++-- frontend/src/api.ts | 2 + frontend/src/i18n.test.ts | 21 +++++ frontend/src/i18n.ts | 36 ++++++++ frontend/src/leftoverObservedExpected.test.ts | 17 ++++ frontend/src/leftoverObservedExpected.ts | 16 ++++ lineageweave/leftover_pairs.py | 88 +++++++++++++------ lineageweave/period_report.py | 15 ++-- ...0170_report_leftover_observed_expected.sql | 30 +++++++ ...0170_report_leftover_observed_expected.sql | 8 ++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 8 +- tests/test_leftover_pairs.py | 69 ++++++++++++++- tests/test_migration_replay.py | 1 + tests/test_period_report.py | 3 + tests/test_schema.py | 31 +++++++ uv.lock | 2 +- 31 files changed, 492 insertions(+), 71 deletions(-) create mode 100644 CHANGELOG.d/2.12.20-leftover-observed-expected.md create mode 100644 docs/adr/0170-leftover-observed-expected.md create mode 100644 frontend/src/leftoverObservedExpected.test.ts create mode 100644 frontend/src/leftoverObservedExpected.ts create mode 100644 migrations/0170_report_leftover_observed_expected.sql create mode 100644 migrations/rollback/0170_report_leftover_observed_expected.sql diff --git a/AGENTS.md b/AGENTS.md index 1728f9e61..a4f160b36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,12 +186,13 @@ in the same spirit) -- never against real data, per the hard rule above. against a live local stack (`make up`) and self-skip without one -- see [README.md](README.md#local-product-stack-docker-compose). -Period leftover pairs (ADR 0017 / 0018) are computed in -`lineageweave/leftover_pairs.py` from the residual after a real -GRM/GPCM score, never invented. Missing cells stay out of the +Period leftover pairs (ADR 0017 / 0018 / 0048 / 0049 / 0170) are +computed in `lineageweave/leftover_pairs.py` from the residual after a +real GRM/GPCM score, never invented. Missing cells stay out of the Gabriel factorization. Closest and farthest post–criterion pairs -persist to `report_leftover_pair` and sit above the member list so -a click opens that post. +persist to `report_leftover_pair` with observed `Y` and expected +`E[Y|θ, item]` so residual reconciles to `Y − E`, and sit above the +member list so a click opens that post. `frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`, pnpm via Corepack -- do not add a second Node package manager or a diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d0280ff97..77a8d68cc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -588,8 +588,9 @@ on those same fixed parameters (Kim, 2006 FIPC). After scoring, `information_polytomous` ranks the shared-bank items by Fisher information at the group's mean θ (Lord, 1980 max-info CAT). Rankings persist to `report_item_information`. After those IRT main effects, -residual SVD leftover pairs (Jeon et al., 2021; ADR 0017) persist to -`report_leftover_pair`. Results persist to +residual SVD leftover pairs (Jeon et al., 2021; ADR 0017 / 0048 / 0170) +persist to `report_leftover_pair` with observed `Y` and expected +`E[Y|θ, item]`. Results persist to `report_period_score` / `report_member_score`. `GET /api/reports/{grouping}` lists the trend; `GET /api/reports/{grouping}/{period}` is ABAC-filtered; @@ -601,7 +602,8 @@ bank as the dummy high/low band rows, so comparison-strip click through opens those DAG posts. Report members include the earliest open ticket title, status lookup label, and due date when one exists. The home page renders the actual mean θ, the FIPC delta, the CAT-selected item, leftover -closest/farthest pairs above the member list, and the +closest/farthest pairs (observed `Y` and expected `E` after IRT main +effects plus leftover-map distance `d`) above the member list, and the PU / corp / thread comparison -- never a placeholder. TEPP is unchanged. ## Phase 6b: Knowledge Graph as a real Ontology + Semantic Layer diff --git a/CHANGELOG.d/2.12.20-leftover-observed-expected.md b/CHANGELOG.d/2.12.20-leftover-observed-expected.md new file mode 100644 index 000000000..09c5ba1ba --- /dev/null +++ b/CHANGELOG.d/2.12.20-leftover-observed-expected.md @@ -0,0 +1,8 @@ +## 2.12.20 — Leftover observed Y and expected E + +- Persist observed `Y` and expected `E[Y|θ, item]` on leftover + post–criterion pairs (ADR 0170). Residual stays `R = Y − E`. After + `make seed`, closest and farthest leftover pairs sit above the member + list with `Y` and `E` next to leftover-map distance `d`; click opens + that post. Omit the badge when either value is missing. Never invent + a leftover score or a theta. diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ed1a099..62452db99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ All notable changes to this project are documented here. Format follows environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. +## [2.12.20] - 2026-08-24 + +### Added + +- Period leftover pair rows now name observed `Y` and expected + `E[Y|θ, item]` after IRT main effects next to leftover-map distance + `d`, then open that post (Jeon et al., 2021, eq. 3; ADR 0170). Residual + stays `R = Y − E`. Missing or non-finite `Y` / `E` omit the badge + rather than inventing a leftover score. + ## [2.12.6] - 2026-08-20 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 1bcf50763..1e79625b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,9 @@ chip name contains `Corporate entity: Demo Corp` and the persisted mean θ. The period-report panel says Demo Corp is the opened grouping and to read its mean θ and member posts, then open a post. Those members land immediately under that next action, ahead of Other Corp -and the week strip. Opening Public post names the next action: read +and the week strip. Leftover closest/farthest pairs name observed `Y` +and expected `E` after IRT main effects next to leftover-map distance +`d`; click a pair to open that post. Opening Public post names the next action: read Event Lineage, Keyman, and evaluation on that post. The popup Event Lineage DAG marks that post current. After that current node, the popup names Keyman and evaluation as the next read. After landed diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index 50614b0ad..dac5695a4 100644 --- a/backend/app/report_ingestion.py +++ b/backend/app/report_ingestion.py @@ -443,8 +443,9 @@ async def persist_period_report( """ insert into report_leftover_pair ( grouping_kind, grouping_key, period_code, rubric_version, - pair_kind, post_id, criterion_code, leftover_distance, leftover_residual - ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9) + pair_kind, post_id, criterion_code, leftover_distance, leftover_residual, + observed_response, expected_response + ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) """, grouping_kind, grouping_key, @@ -455,6 +456,8 @@ async def persist_period_report( pair.criterion_code, pair.leftover_distance, pair.leftover_residual, + pair.observed_response, + pair.expected_response, ) @@ -601,7 +604,8 @@ async def fetch_period_reports( leftover = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli f""" select lp.grouping_key, lp.pair_kind, lp.post_id, lp.criterion_code, - lp.leftover_distance, lp.leftover_residual, p.post_title, + lp.leftover_distance, lp.leftover_residual, + lp.observed_response, lp.expected_response, p.post_title, p.visibility_code, p.corporate_entity_id, ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context from report_leftover_pair lp @@ -698,6 +702,16 @@ async def fetch_period_reports( "criterion_code": str(row["criterion_code"]), "leftover_distance": float(row["leftover_distance"]), "leftover_residual": float(row["leftover_residual"]), + "observed_response": ( + None + if row["observed_response"] is None + else float(row["observed_response"]) + ), + "expected_response": ( + None + if row["expected_response"] is None + else float(row["expected_response"]) + ), "visibility_code": row["visibility_code"], "corporate_entity_id": str(row["corporate_entity_id"]), "has_real_source_context": bool(row["has_real_source_context"]), diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 438b4786a..0bb8c5deb 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -113,6 +113,11 @@ / "migrations" / "0102_project_bound_summary_event.sql" ) +_LEFTOVER_OBSERVED_EXPECTED_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0170_report_leftover_observed_expected.sql" +) def _postgres_available() -> bool: @@ -226,6 +231,7 @@ def seeded_db(demo_analyst_token): cur.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text()) + cur.execute(_LEFTOVER_OBSERVED_EXPECTED_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -4518,6 +4524,12 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token, assert leftover_kinds <= {"closest", "farthest"} assert all(pair["post_title"] for pair in high_report.get("leftover_pairs", [])) assert all(pair["leftover_distance"] >= 0 for pair in high_report.get("leftover_pairs", [])) + for pair in high_report.get("leftover_pairs", []): + observed = pair.get("observed_response") + expected = pair.get("expected_response") + if observed is None or expected is None: + continue + assert abs(pair["leftover_residual"] - (observed - expected)) < 1e-6 week3 = client.get( "/api/reports/process_unit/2026-W03", diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index f329117d6..86629137b 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0060_*|0100_*|0101_*|0102_*) ;; + 0060_*|0100_*|0101_*|0102_*|0170_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0003-fast-mlsirm-report-integration.md b/docs/adr/0003-fast-mlsirm-report-integration.md index bdf234b65..2ab6d5111 100644 --- a/docs/adr/0003-fast-mlsirm-report-integration.md +++ b/docs/adr/0003-fast-mlsirm-report-integration.md @@ -100,10 +100,11 @@ than one large PR: `information_polytomous` (Lord, 1980 max-info). Persist the ranking (`report_item_information`) and show the rank-1 item on the Period reports panel. Do not reimplement an information function here. -7. **Leftover-pair slice** (shipped in 0.71.2; ADR 0017 / 0018): after +7. **Leftover-pair slice** (shipped in 0.71.2; ADR 0017 / 0018 / 0048 / 0049 / 0170): after IRT main effects, persist closest and farthest post–criterion pairs - from the residual leftover map. Do not fork LSIRM; do not invent a - leftover-pair API inside `fast-mlsirm` in this slice. + from the residual leftover map, naming observed `Y` and expected + `E[Y|θ, item]` so residual reconciles to `Y − E`. Do not fork LSIRM; + do not invent a leftover-pair API inside `fast-mlsirm` in this slice. **TEPP boundary.** [ARCHITECTURE.md](../../ARCHITECTURE.md) already assigns calibrated temporal/event measurement to diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md index 8e87383f0..b54ce7ef7 100644 --- a/docs/adr/0048-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md @@ -2,6 +2,7 @@ **Decision status:** Accepted **Date:** 2026-08-17 +**Amended by:** [ADR 0170](0170-leftover-observed-expected.md) (observed Y and expected E) ## Context @@ -30,7 +31,9 @@ and one `farthest` observed cell per period report in `report_leftover_pair` (3NF, two-or-more-word `snake_case`). The biplot lives in `lineageweave/leftover_pairs.py` so leftover -tests do not import `period_report` or `fast_mlsirm`. +tests do not import `period_report` or `fast_mlsirm`. Each leftover +row also names observed `Y` and expected `E[Y|θ, item]` so residual +reconciles to `Y − E` (ADR 0170). Cascade the rows with `report_period_score`. A leftover post must also be a `report_member_score` row, and the leftover criterion diff --git a/docs/adr/0049-leftover-pair-report-ui.md b/docs/adr/0049-leftover-pair-report-ui.md index a93985b40..eb80ff7a2 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -2,6 +2,7 @@ **Decision status:** Accepted **Date:** 2026-08-17 +**Amended by:** [ADR 0170](0170-leftover-observed-expected.md) (observed Y and expected E) ## Context @@ -17,10 +18,11 @@ second navigation surface. On each period-report group, render leftover pairs **above** the member list. Each pair is a button: closest or farthest label, post -title, criterion short label, leftover-map distance, and the next -action (“Open this post to read the criterion it sat closest to / -farthest from after main effects.”). Clicking the button opens that -post with the same handler as a member row. +title, criterion short label, leftover-map distance, observed `Y` and +expected `E` when both are finite, and the next action (“Read observed +Y and expected E after IRT main effects, then open this post.”). +Clicking the button opens that post with the same handler as a member +row. Observed/expected naming is [ADR 0170](0170-leftover-observed-expected.md). After `make seed`, closest and farthest leftover pairs sit above the member list. Click a pair to open that post. diff --git a/docs/adr/0170-leftover-observed-expected.md b/docs/adr/0170-leftover-observed-expected.md new file mode 100644 index 000000000..ca1265d66 --- /dev/null +++ b/docs/adr/0170-leftover-observed-expected.md @@ -0,0 +1,77 @@ +# ADR 0170 — Persist observed Y and expected E on leftover pairs + +**Decision status:** Accepted +**Date:** 2026-08-24 + +Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and +[ADR 0049](0049-leftover-pair-report-ui.md). + +## Context + +ADR 0048 already persists leftover-map distance and leftover residual +`R = Y − E[Y|θ, item]` on `report_leftover_pair`. ADR 0049 already +renders closest and farthest pairs above the member list and opens the +named post. Residual disclosure without naming `Y` and `E` leaves a +buyer unable to tell whether a leftover cell is a high observed +response or a low expected category after IRT main effects. + +Jeon et al. (2021, eq. 3) leftover interaction is `−γ‖ξ_p − ζ_i‖`. +Gabriel (1971) supplies the leftover-map coordinates from a residual +biplot of `R`. `R` is not an invented leftover score: it is the +observed category minus the already-fitted expected category. Those +two inputs must travel with the pair row. + +This increment does not persist leftover-map coordinates, does not +change leftover-map axis count, does not name complete-case coverage, +and does not land Post quality on the leftover criterion. + +The unprotected-stack ADR for the same buyer fact was 0163. This +protected-main reconstruction uses **0170** so it does not collide +with leftover coverage (0168), leftover-map axis share (0169), or +two-axis leftover-map distance (0166). + +## Decision + +Each leftover pair names: + +1. `observed_response` — the observed category `Y` for that + post–criterion cell; +2. `expected_response` — `E[Y|θ, item]` from the already-fitted + GRM/GPCM main effects; +3. `leftover_residual`, which must equal `Y − E` within `1e-6`. + +Migration `0170` is the single source of both columns on every +install path, fresh or existing -- shipped migrations (`0001`/`0012`) +are never edited after the fact. It adds them as nullable so older +leftover rows keep distance and residual without fabricating `Y` or +`E`. The pair button shows +`Y {observed} · E {expected}` next to leftover-map distance `d` when +both values are finite. The next action is: read observed `Y` and +expected `E` after IRT main effects, then open this post. Omit the +`Y` / `E` badge when either value is missing or non-finite. Do not +invent a leftover score. Do not invent a theta. + +## Consequences + +`GET /api/reports/{grouping}/{period}` returns `observed_response` +and `expected_response`. After `make seed`, closest and farthest +leftover pairs sit above the member list with named `Y` and `E`; +click opens that post. Hidden posts stay hidden. + +## Related + +Independent of leftover interaction-map persistence, leftover-criterion +evaluation landing, leftover residual UI extraction, leftover-map +complete-case coverage, leftover-map axis share, leftover pairs on the +grouping comparison strip, and two-axis leftover-map distance. + +## References + +Gabriel, K. R. (1971). The biplot graphic display of matrices with +application to principal component analysis. *Biometrika, 58*(3), +453–467. https://doi.org/10.1093/biomet/58.3.453 + +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. +https://doi.org/10.1007/s11336-021-09762-5 diff --git a/frontend/package.json b/frontend/package.json index e2e996bbe..a82321845 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.6", + "version": "2.12.20", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7462abd2c..90c4d6719 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -932,6 +932,8 @@ describe("App, authenticated", () => { criterion_code: "sales_lead_specificity", leftover_distance: 0.12, leftover_residual: 0.4, + observed_response: 2.4, + expected_response: 2.0, }, { pair_kind: "farthest", @@ -940,6 +942,8 @@ describe("App, authenticated", () => { criterion_code: "general_sentiment_negative", leftover_distance: 1.84, leftover_residual: -1.1, + observed_response: 0.9, + expected_response: 2.0, }, ], members: [ @@ -3357,13 +3361,15 @@ describe("App, authenticated", () => { }); expect(closestPair).toHaveTextContent("Closest leftover: Public post · sales-lead"); expect(closestPair).toHaveTextContent( - "Open this post to read the criterion it sat closest to after main effects.", + "Read observed Y 2.40 and expected E 2.00 after IRT main effects, then open this post.", ); + expect(closestPair).toHaveTextContent("Y 2.40 · E 2.00"); expect(closestPair).toHaveTextContent("d 0.12"); expect(farthestPair).toHaveTextContent("Farthest leftover: Specification revision requested · negative"); expect(farthestPair).toHaveTextContent( - "Open this post to read the criterion it sat farthest from after main effects.", + "Read observed Y 0.90 and expected E 2.00 after IRT main effects, then open this post.", ); + expect(farthestPair).toHaveTextContent("Y 0.90 · E 2.00"); expect(farthestPair).toHaveTextContent("d 1.84"); const memberButton = screen.getByRole("button", { name: /open report post: public post/i }); expect(closestPair.compareDocumentPosition(memberButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fba0dd41..13f05d50c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -102,6 +102,7 @@ import { useLocale, } from "./i18n"; import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl"; +import { formatLeftoverObservedExpected } from "./leftoverObservedExpected"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -3390,15 +3391,27 @@ function ReportsPanel({ )} {report.leftover_pairs && report.leftover_pairs.length > 0 && ( -