diff --git a/AGENTS.md b/AGENTS.md index 6cd2d9c73..5ed21a371 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,15 +186,15 @@ 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 0048 / 0049 / 0119 / 0163 / 0164) are computed in +Period leftover pairs (ADR 0048 / 0049 / 0119 / 0162 / 0163 / 0164) are computed in `lineageweave/leftover_pairs.py` from the residual after a real GRM/GPCM score, never invented. Distances are Euclidean on the two-dimensional Gabriel leftover map; missing cells stay out of the factorization. Closest and farthest post–criterion pairs -persist to `report_leftover_pair` with observed `Y` and expected -`E[Y|θ, item]` so residual reconciles to `Y − E`, plus leftover-map -rank so rank 0 is not read as structure, and sit above the member list -so a click opens that post. +persist to `report_leftover_pair` with signed residual `R`, observed +`Y`, and expected `E[Y|θ, item]` so `R = Y − E` remains auditable, +plus leftover-map rank so rank 0 is not read as structure. They 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 82f8d9081..44664df5d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -591,8 +591,9 @@ on those same fixed parameters (Kim, 2006 FIPC). After scoring, 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 on two Gabriel axes (Jeon et al., 2021; -ADR 0048 / 0119 / 0163 / 0164) persist to `report_leftover_pair` with observed -`Y`, expected `E[Y|θ, item]`, and full leftover-map rank. Results persist to +ADR 0048 / 0119 / 0162 / 0163 / 0164) persist to `report_leftover_pair` with +signed residual `R`, observed `Y`, expected `E[Y|θ, item]`, and full +leftover-map rank. Results persist to `report_period_score` / `report_member_score`. `GET /api/reports/{grouping}` lists the trend; `GET /api/reports/{grouping}/{period}` is ABAC-filtered; @@ -604,8 +605,9 @@ 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 (observed `Y`, expected `E`, full rank, and -two-axis leftover-map distance `d` after IRT main effects) above the member list, and the +closest/farthest pairs (signed residual `R`, observed `Y`, expected +`E`, full rank, and two-axis leftover-map distance `d` after IRT main +effects) 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.16-leftover-residual-disclosure.md b/CHANGELOG.d/2.12.16-leftover-residual-disclosure.md new file mode 100644 index 000000000..b18326ce2 --- /dev/null +++ b/CHANGELOG.d/2.12.16-leftover-residual-disclosure.md @@ -0,0 +1,6 @@ +# 2.12.16 leftover residual disclosure + +After `make seed`, closest and farthest leftover pairs sit above the +period-report member list with signed residual `R`, observed `Y`, +expected `E`, full leftover-map rank, and two-axis distance `d`. +Read the named evidence, then click the pair to open that post. diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a41a02b..bc85bd407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,15 @@ 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.16] - 2026-08-24 + +### Added + +- Period leftover pair rows now name signed residual `R` beside observed + `Y`, expected `E`, leftover-map rank, and distance `d`, then open that + post from the reusable `LeftoverPairList` Storybook component + (Jeon et al., 2021, eq. 3; ADR 0162). + ## [2.12.15] - 2026-08-24 ### Added diff --git a/docs/adr/0049-leftover-pair-report-ui.md b/docs/adr/0049-leftover-pair-report-ui.md index 04023f207..3b6e4ce43 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -2,7 +2,8 @@ **Decision status:** Accepted **Date:** 2026-08-17 -**Amended by:** [ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E); +**Amended by:** [ADR 0162](0162-leftover-residual-disclosure.md) (signed residual R); +[ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E); [ADR 0164](0164-leftover-map-rank.md) (full map rank) ## Context @@ -19,13 +20,14 @@ 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, two-axis leftover-map distance, full map -rank, observed `Y`, and expected `E` when finite. The next action names -every available measurement before opening the post; when rank and -`Y` / `E` coexist, neither amendment hides the other. Rank 0 explicitly -names no leftover structure. +title, criterion short label, signed residual `R`, two-axis leftover-map +distance, full map rank, observed `Y`, and expected `E` when finite. +The next action names every available measurement before opening the +post; no amendment hides another, and rank 0 explicitly names no +leftover structure. Clicking the button opens that post with the same handler as a member -row. Observed/expected naming is [ADR 0163](0163-leftover-observed-expected.md); +row. Residual naming is [ADR 0162](0162-leftover-residual-disclosure.md), +observed/expected naming is [ADR 0163](0163-leftover-observed-expected.md), rank naming is [ADR 0164](0164-leftover-map-rank.md). After `make seed`, closest and farthest leftover pairs sit above the diff --git a/docs/adr/0162-leftover-residual-disclosure.md b/docs/adr/0162-leftover-residual-disclosure.md new file mode 100644 index 000000000..fc790b7ca --- /dev/null +++ b/docs/adr/0162-leftover-residual-disclosure.md @@ -0,0 +1,64 @@ +# ADR 0162 — Disclose leftover residual on period-report pair rows + +**Decision status:** Accepted +**Date:** 2026-08-23 + +## Context + +ADR 0048 already persists `leftover_distance` (Euclidean leftover-map +gap from the Gabriel biplot of `R`) 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. The pair button showed only `d`, so a buyer could not tell +a large leftover response from a merely distant map pair. + +Jeon et al. (2021, eq. 3) leftover interaction is +`−γ‖ξ_p − ζ_i‖`. Distance is that map gap. Residual is the observed +leftover *after IRT main effects* that entered the biplot. They are +different quantities. Hiding residual would keep the persisted column +as an unpublished measurement. + +This increment does not persist leftover-map coordinates (ADR 0121 / +PR #481) and does not land Post quality on the leftover criterion +(ADR 0125 / PR #485). + +## Decision + +Each leftover pair button shows: + +1. closest or farthest label, post title, and criterion short label; +2. leftover residual `R` with an explicit sign, two decimal places; +3. observed `Y`, expected `E`, full leftover-map rank, and two-axis + distance `d` when those later ADR 0163/0164 fields are available; +4. one next action that retains every available measurement before + opening the post to read the named criterion. + +Missing leftover rows still render nothing. A non-finite residual +renders an em dash rather than a fabricated leftover score. Click +still uses the same post-open handler as ADR 0049. + +## Consequences + +`GET /api/reports/{grouping}/{period}` already returns +`leftover_residual`. The frontend now names that value. After +`make seed`, closest and farthest leftover pairs sit above the member +list with `R`, `Y`, `E`, rank, and `d`; click opens that post. + +## Related + +Amends [ADR 0049](0049-leftover-pair-report-ui.md). Depends on +[ADR 0048](0048-persist-lsirm-leftover-pairs.md) and +[ADR 0003](0003-fast-mlsirm-report-integration.md). Independent of +leftover interaction-map persistence and leftover-criterion evaluation +landing. + +## 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/docs/storybook-inventory.md b/docs/storybook-inventory.md index 5b0fe143b..9a38d2837 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -11,6 +11,7 @@ buyer-facing control you can click before changing product CSS. | `Admin/AdminPanel` | Change the tenant brand name, then verify the saved or failed state before leaving settings. | `--surface`, `--border`, `--space-panel-block`, `AdminPanel` | | `Lineage/LineageDag` | Open the current branch node; compare empty, grouped/forked, ungrouped, and long-title states before changing graph CSS. | `--surface`, `--border`, `LineageDag` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | +| `Reports/LeftoverPairList` | Read residual R, observed Y, expected E, map rank, and distance after IRT main effects, then open the named post. | `--color-chip-border`, `LeftoverPairList` | Repeated web objects must use `frontend/src/styles/tokens.css` and a module under `frontend/src/components/`. Do not add a second Node package manager; diff --git a/frontend/package.json b/frontend/package.json index cc9f4ce0e..02f50d38d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.15", + "version": "2.12.16", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 0b8a5ab51..9ff79a27e 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -3373,6 +3373,7 @@ describe("App, authenticated", () => { expect(closestPair).toHaveTextContent( "Read leftover map rank 1, observed Y 2.40, and expected E 2.00 after IRT main effects, then open this post.", ); + expect(closestPair).toHaveTextContent("R +0.40"); expect(closestPair).toHaveTextContent("Y 2.40 · E 2.00"); expect(closestPair).toHaveTextContent("rank 1"); expect(closestPair).toHaveTextContent("d 0.12"); @@ -3380,6 +3381,7 @@ describe("App, authenticated", () => { expect(farthestPair).toHaveTextContent( "Read leftover map rank 1, observed Y 0.90, and expected E 2.00 after IRT main effects, then open this post.", ); + expect(farthestPair).toHaveTextContent("R −1.10"); expect(farthestPair).toHaveTextContent("Y 0.90 · E 2.00"); expect(farthestPair).toHaveTextContent("rank 1"); expect(farthestPair).toHaveTextContent("d 1.84"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fd8d97c35..39446abf8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { AdminPanel } from "./components/AdminPanel"; +import { LeftoverPairList } from "./components/LeftoverPairList"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useAuth } from "react-oidc-context"; @@ -102,8 +103,6 @@ import { useLocale, } from "./i18n"; import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl"; -import { formatLeftoverObservedExpected } from "./leftoverObservedExpected"; -import { formatLeftoverMapRank, LEFTOVER_RANK_STRUCTURE_ACTION, LEFTOVER_RANK_ZERO_ACTION } from "./leftoverMapRank"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -3392,80 +3391,11 @@ function ReportsPanel({ )} {report.leftover_pairs && report.leftover_pairs.length > 0 && ( - + onSelectPost(postId)} + /> )} {report.members.length > 0 && (