diff --git a/CHANGELOG.d/2.12.25-leftover-map-length.md b/CHANGELOG.d/2.12.25-leftover-map-length.md new file mode 100644 index 000000000..62842aab4 --- /dev/null +++ b/CHANGELOG.d/2.12.25-leftover-map-length.md @@ -0,0 +1,8 @@ +## 2.12.25 — Leftover-map length + +- Persist leftover-map lengths `‖ξ‖` and `‖ζ‖` on leftover + post–criterion pairs (ADR 0181). After `make seed`, closest and + farthest leftover pairs sit above the member list with `‖ξ‖` and + `‖ζ‖` next to leftover-map distance `d`; click opens that post. + Omit the badges when lengths are missing. Never invent a leftover + score. diff --git a/CHANGELOG.md b/CHANGELOG.md index c20009878..76a792679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,17 @@ All notable changes to this project are documented here. Format follows Jeon et al., 2021, eq. 3; ADR 0180). A missing cosine omits the badge rather than inventing leftover-map alignment. +## [2.12.25] - 2026-08-24 + +### Added + +- Period leftover pairs now name leftover-map lengths `‖ξ‖` and `‖ζ‖` + next to leftover-map distance `d`. After `make seed`, closest and + farthest leftover pairs sit above the member list with named + leftover-map magnitude; click opens that post (Gabriel, 1971; + Jeon et al., 2021, eq. 3; ADR 0181). A missing length omits the + badge. Never invent a leftover score. + ## [2.12.6] - 2026-08-20 ### Added diff --git a/docs/adr/0049-leftover-pair-report-ui.md b/docs/adr/0049-leftover-pair-report-ui.md index f2b6f905b..642fea7aa 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -32,6 +32,9 @@ criterion. Missing leftover rows render nothing — never a placeholder pair. A hidden post never appears as a leftover pair. +A missing leftover-map length omits the `‖ξ‖` / `‖ζ‖` badges and +keeps the closest/farthest next action. See +[ADR 0181](0181-leftover-map-length.md). ## Consequences diff --git a/docs/adr/0181-leftover-map-length.md b/docs/adr/0181-leftover-map-length.md new file mode 100644 index 000000000..b81f2f6a2 --- /dev/null +++ b/docs/adr/0181-leftover-map-length.md @@ -0,0 +1,85 @@ +# ADR 0181 — Name leftover-map length 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) also names leftover-map vector lengths +`‖ξ‖` and `‖ζ‖`. Hiding those lengths lets a buyer read a close +leftover-map pair as leftover-map aligned magnitude, or a distant pair +as large leftover-map displacement, without the polar magnitude. + +A close leftover-map pair can sit at the origin (`‖ξ‖ = ‖ζ‖ = 0`) or +share a non-origin position (`d = 0` with positive length). Those are +different leftover-map facts. + +This increment does not persist leftover-map coordinates, does not name +observed `Y` / expected `E`, does not name leftover-map cosine, does +not name leftover-map inner product, 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. + +The unprotected-stack reconstructions for neighbouring leftover facts +use 0121–0180. This protected-main increment uses **0181** 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_person_length` and +`leftover_map_item_length` — the Euclidean lengths `‖ξ‖` and `‖ζ‖` of +the leftover-map person and item coordinates that produced leftover-map +distance `d`. Migration `0181` is the single source of the columns on +every install path, fresh or existing -- shipped migrations (`0001` / +`0012`) are never edited after the fact. The columns are nullable so +older leftover rows keep distance and residual without fabricating +lengths. Fallback pairs that have no complete-case leftover map omit +the values rather than inventing them. Origin coordinates persist +length `0` because that is the measured leftover-map magnitude. + +The pair button shows `‖ξ‖ {person}` and `‖ζ‖ {item}` next to +leftover-map distance `d` when both values are finite. Next action: +leftover-map length names leftover-map magnitude independently of +leftover-map distance; open this post to read the named criterion. A +missing or non-finite length omits the badges 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_person_length` and `leftover_map_item_length`. After +`make seed`, closest and farthest leftover pairs sit above the member +list with named `‖ξ‖` and `‖ζ‖` 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/leftoverMapLength.test.ts b/frontend/src/leftoverMapLength.test.ts new file mode 100644 index 000000000..0b7277629 --- /dev/null +++ b/frontend/src/leftoverMapLength.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { + formatLeftoverMapItemLength, + formatLeftoverMapLength, + formatLeftoverMapPersonLength, +} from "./leftoverMapLength"; + +describe("formatLeftoverMapLength", () => { + it("names leftover-map length without inventing a leftover score", () => { + expect(formatLeftoverMapPersonLength(0)).toBe("‖ξ‖ 0.00"); + expect(formatLeftoverMapItemLength(1.25)).toBe("‖ζ‖ 1.25"); + expect(formatLeftoverMapLength(0.4)).toBe("0.40"); + expect(formatLeftoverMapPersonLength(0.4)).toBe("‖ξ‖ 0.40"); + expect(formatLeftoverMapItemLength(0.9)).toBe("‖ζ‖ 0.90"); + }); + + it("omits the badge when length is missing, negative, or non-finite", () => { + expect(formatLeftoverMapPersonLength(null)).toBeNull(); + expect(formatLeftoverMapItemLength(undefined)).toBeNull(); + expect(formatLeftoverMapLength(Number.NaN)).toBeNull(); + expect(formatLeftoverMapLength(Number.POSITIVE_INFINITY)).toBeNull(); + expect(formatLeftoverMapLength(-0.01)).toBeNull(); + }); +}); diff --git a/frontend/src/leftoverMapLength.ts b/frontend/src/leftoverMapLength.ts new file mode 100644 index 000000000..bb1f5c426 --- /dev/null +++ b/frontend/src/leftoverMapLength.ts @@ -0,0 +1,21 @@ +/** Gabriel leftover-map lengths ``‖ξ‖`` and ``‖ζ‖`` after IRT main effects. */ + +export const LEFTOVER_MAP_LENGTH_ACTION = + "Leftover-map length ‖ξ‖ {person} and ‖ζ‖ {item} names leftover-map magnitude independently of leftover-map distance. Open this post to read {criterion}."; + +export function formatLeftoverMapLength(value: number | null | undefined): string | null { + if (value == null || !Number.isFinite(value) || value < 0) { + return null; + } + return value.toFixed(2); +} + +export function formatLeftoverMapPersonLength(value: number | null | undefined): string | null { + const formatted = formatLeftoverMapLength(value); + return formatted === null ? null : `‖ξ‖ ${formatted}`; +} + +export function formatLeftoverMapItemLength(value: number | null | undefined): string | null { + const formatted = formatLeftoverMapLength(value); + return formatted === null ? null : `‖ζ‖ ${formatted}`; +} diff --git a/migrations/0181_report_leftover_map_length.sql b/migrations/0181_report_leftover_map_length.sql new file mode 100644 index 000000000..973ee68ed --- /dev/null +++ b/migrations/0181_report_leftover_map_length.sql @@ -0,0 +1,25 @@ +-- ADR 0181: persist leftover-map lengths ‖ξ‖ and ‖ζ‖ on leftover +-- post–criterion pairs. Distance stays Euclidean leftover-map d. +-- Upgrade columns are nullable so older leftover rows keep distance +-- and residual without fabricating lengths. This migration is the +-- single source of the columns on fresh and existing installations. + +alter table report_leftover_pair + add column if not exists leftover_map_person_length numeric, + add column if not exists leftover_map_item_length numeric; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'report_leftover_pair_map_length_nonnegative' + ) then + alter table report_leftover_pair + add constraint report_leftover_pair_map_length_nonnegative + check ( + (leftover_map_person_length is null or leftover_map_person_length >= 0) + and (leftover_map_item_length is null or leftover_map_item_length >= 0) + ); + end if; +end $$; diff --git a/migrations/rollback/0181_report_leftover_map_length.sql b/migrations/rollback/0181_report_leftover_map_length.sql new file mode 100644 index 000000000..add379776 --- /dev/null +++ b/migrations/rollback/0181_report_leftover_map_length.sql @@ -0,0 +1,8 @@ +-- Reverse 0181. Leftover distance and residual stay on the pair row. + +alter table report_leftover_pair + drop constraint if exists report_leftover_pair_map_length_nonnegative; + +alter table report_leftover_pair + drop column if exists leftover_map_person_length, + drop column if exists leftover_map_item_length; diff --git a/tests/test_period_report.py b/tests/test_period_report.py index 0f7ad763a..4cdbd9071 100644 --- a/tests/test_period_report.py +++ b/tests/test_period_report.py @@ -225,6 +225,10 @@ def test_leftover_residual_biplot_separates_aligned_and_opposed_cells() -> None: } assert farthest.leftover_residual == pytest.approx(-2.0) assert farthest.leftover_distance == pytest.approx(2.0 * np.sqrt(2.0), rel=1e-6) + assert closest.leftover_map_person_length == pytest.approx(0.0, abs=1e-9) + assert closest.leftover_map_item_length == pytest.approx(0.0, abs=1e-9) + assert farthest.leftover_map_person_length == pytest.approx(np.sqrt(2.0), rel=1e-6) + assert farthest.leftover_map_item_length == pytest.approx(np.sqrt(2.0), rel=1e-6) def test_leftover_missing_cells_are_not_treated_as_zero() -> None: