diff --git a/CHANGELOG.d/2.12.25-leftover-map-reconstruction.md b/CHANGELOG.d/2.12.25-leftover-map-reconstruction.md new file mode 100644 index 000000000..44bbb3b43 --- /dev/null +++ b/CHANGELOG.d/2.12.25-leftover-map-reconstruction.md @@ -0,0 +1,8 @@ +## 2.12.25 — Leftover-map reconstruction + +- Persist two-axis leftover-map reconstruction `R̂ = ξ_{1:2} · ζ_{1:2}` + on leftover post–criterion pairs (ADR 0181). After `make seed`, + closest and farthest leftover pairs sit above the member list with + `R̂` next to leftover-map distance `d`; click opens that post. Omit + the badge when reconstruction is missing. Never invent a leftover + score. diff --git a/CHANGELOG.md b/CHANGELOG.md index 76a792679..3614b0320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -273,6 +273,15 @@ All notable changes to this project are documented here. Format follows Jeon et al., 2021, eq. 3; ADR 0181). A missing length omits the badge. Never invent a leftover score. +## [2.12.25] - 2026-08-24 + +### Added + +- Period leftover pair rows now name two-axis leftover-map reconstruction + `R̂ = ξ_{1:2} · ζ_{1:2}` next to leftover-map distance `d`, then open + that post (Gabriel, 1971; Jeon et al., 2021, eq. 3; ADR 0181). A missing + reconstruction omits the badge rather than inventing a leftover score. + ## [2.12.6] - 2026-08-20 ### Added diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index ac490b993..534daa71a 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_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*|0130_*|0133_*|0134_*|0136_*|0137_*|0138_*|0139_*|0172_*|0173_*|0176_*|0177_*) ;; + 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*|0130_*|0133_*|0134_*|0136_*|0137_*|0138_*|0139_*|0172_*|0173_*|0176_*|0177_*|0179_*|0180_*|0181_*|0182_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0182-leftover-map-reconstruction.md b/docs/adr/0182-leftover-map-reconstruction.md new file mode 100644 index 000000000..2469d54c6 --- /dev/null +++ b/docs/adr/0182-leftover-map-reconstruction.md @@ -0,0 +1,85 @@ +# ADR 0182 — Name two-axis leftover-map reconstruction on period-report pair rows + +**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 `d = ‖ξ_p − ζ_i‖` 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. Distance is the Jeon et al. (2021, +eq. 3) map gap. Gabriel (1971) reconstructs a matrix from the biplot +as the inner product of person and item coordinates. The leftover map +readers read is two-axis: unused axes pad with zero, and hidden SVD +axes after the second are dropped. Full-rank inner product `ξ·ζ` +therefore reconstructs the centered residual, not the cell the +two-axis map shows. Hiding two-axis reconstruction `R̂ = ξ_{1:2} · ζ_{1:2}` +lets a reader read leftover residual `R` or leftover-map distance `d` +as the reconstructed leftover cell. + +This increment does not persist leftover-map coordinates, does not +name leftover-map inner product as a separate full-rank column, does +not name leftover-map cosine, does not name observed `Y` / expected +`E`, does not name leftover-map rank, does not split leftover-map +distance onto two axes, and does not land Post quality on the leftover +criterion. Leftover-map distance stays full-rank Euclidean. + +The unprotected-stack reconstructions for neighbouring leftover facts +use 0162–0180. This protected-main increment uses **0182** so it does +not collide with leftover-map cosine (0180), leftover-map inner product +(0179), leftover residual disclosure (0178), leftover observed `Y` / +expected `E` (0177), leftover-map rank (0172), two-axis leftover-map +distance (0166), leftover coverage (0168), leftover-map axis share +(0148), or leftover interaction-map persistence (0121). + +## Decision + +Each leftover pair names `leftover_map_reconstruction` — the two-axis +Gabriel reconstruction `R̂ = ξ_{1:2} · ζ_{1:2}` of the leftover-map +person and item coordinates. Migration `0182` is the single source of +the column on every install path, fresh or existing -- shipped +migrations (`0001` / `0012`) are never edited after the fact. The +column is nullable so older leftover rows keep distance and residual +without fabricating a reconstruction. Fallback pairs that have no +complete-case leftover map omit the value rather than inventing one. +A rank-0 origin map stores `0.0` (zeros · zeros), not a missing value. +A non-finite reconstruction stores null rather than inventing a +leftover score. + +The pair button shows `R̂ {signed}` next to leftover-map distance `d` +when the value is finite. Next action: leftover map reconstructs `R̂` +after IRT main effects; open this post to read the named criterion. +A missing or non-finite reconstruction omits the badge and keeps the +existing closest/farthest next action. Do not invent a leftover score. +Do not invent a theta. + +## Consequences + +`GET /api/reports/{grouping}/{period}` returns +`leftover_map_reconstruction`. After `make seed`, closest and farthest +leftover pairs sit above the member list with named `R̂` next to `d`; +click opens that post. Hidden posts stay hidden. + +## Related + +Independent of leftover interaction-map persistence, leftover-criterion +evaluation landing, leftover residual disclosure, leftover observed +`Y` / expected `E`, leftover-map complete-case coverage, leftover-map +axis share, leftover pairs on the grouping comparison strip, two-axis +leftover-map distance, leftover-map rank, leftover-map inner product, +and leftover-map cosine. + +## 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/src/App.tsx b/frontend/src/App.tsx index 8cd278ba7..24bf8dabd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -149,6 +149,11 @@ import { useLocale, } from "./i18n"; import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl"; +import { + formatLeftoverMapReconstruction, + formatSignedLeftoverValue, + LEFTOVER_MAP_RECONSTRUCTION_ACTION, +} from "./leftoverMapReconstruction"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { diff --git a/frontend/src/leftoverMapReconstruction.test.ts b/frontend/src/leftoverMapReconstruction.test.ts new file mode 100644 index 000000000..76357fa3d --- /dev/null +++ b/frontend/src/leftoverMapReconstruction.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { + formatLeftoverMapReconstruction, + formatSignedLeftoverValue, +} from "./leftoverMapReconstruction"; + +describe("formatLeftoverMapReconstruction", () => { + it("names two-axis leftover-map reconstruction without inventing a leftover score", () => { + expect(formatLeftoverMapReconstruction(0.4)).toBe("R\u0302 +0.40"); + expect(formatLeftoverMapReconstruction(-1.1)).toBe("R\u0302 \u22121.10"); + expect(formatLeftoverMapReconstruction(0)).toBe("R\u0302 0.00"); + expect(formatSignedLeftoverValue(0.4)).toBe("+0.40"); + expect(formatSignedLeftoverValue(-1.1)).toBe("\u22121.10"); + }); + + it("omits the badge when reconstruction is missing or non-finite", () => { + expect(formatLeftoverMapReconstruction(null)).toBeNull(); + expect(formatLeftoverMapReconstruction(undefined)).toBeNull(); + expect(formatLeftoverMapReconstruction(Number.NaN)).toBeNull(); + expect(formatLeftoverMapReconstruction(Number.POSITIVE_INFINITY)).toBeNull(); + }); +}); diff --git a/frontend/src/leftoverMapReconstruction.ts b/frontend/src/leftoverMapReconstruction.ts new file mode 100644 index 000000000..6fc57e9b0 --- /dev/null +++ b/frontend/src/leftoverMapReconstruction.ts @@ -0,0 +1,26 @@ +/** Two-axis leftover-map reconstruction ``R̂ = ξ_{1:2} · ζ_{1:2}``. */ + +export const LEFTOVER_MAP_RECONSTRUCTION_ACTION = + "Leftover map reconstructs R̂ {value} after IRT main effects. Open this post to read {criterion}."; + +export function formatSignedLeftoverValue(value: number): string | null { + if (!Number.isFinite(value)) { + return null; + } + const magnitude = Math.abs(value).toFixed(2); + if (value > 0) { + return `+${magnitude}`; + } + if (value < 0) { + return `\u2212${magnitude}`; + } + return magnitude; +} + +export function formatLeftoverMapReconstruction(value: number | null | undefined): string | null { + if (value == null) { + return null; + } + const signed = formatSignedLeftoverValue(value); + return signed === null ? null : `R\u0302 ${signed}`; +} diff --git a/migrations/0182_report_leftover_map_reconstruction.sql b/migrations/0182_report_leftover_map_reconstruction.sql new file mode 100644 index 000000000..d2b1b0e14 --- /dev/null +++ b/migrations/0182_report_leftover_map_reconstruction.sql @@ -0,0 +1,8 @@ +-- ADR 0182: persist two-axis leftover-map reconstruction R̂ = ξ_{1:2} · ζ_{1:2} +-- on leftover post–criterion pairs. Distance stays Euclidean leftover-map d. +-- Upgrade column is nullable so older leftover rows keep distance and +-- residual without fabricating a reconstruction. This migration is the +-- single source of the column on fresh and existing installations. + +alter table report_leftover_pair + add column if not exists leftover_map_reconstruction numeric; diff --git a/migrations/rollback/0182_report_leftover_map_reconstruction.sql b/migrations/rollback/0182_report_leftover_map_reconstruction.sql new file mode 100644 index 000000000..75ed83fb3 --- /dev/null +++ b/migrations/rollback/0182_report_leftover_map_reconstruction.sql @@ -0,0 +1,4 @@ +-- Reverse 0182. Leftover distance and residual stay on the pair row. + +alter table report_leftover_pair + drop column if exists leftover_map_reconstruction;