diff --git a/CHANGELOG.d/2.12.22-leftover-residual-disclosure.md b/CHANGELOG.d/2.12.22-leftover-residual-disclosure.md new file mode 100644 index 000000000..cd9b4d2d2 --- /dev/null +++ b/CHANGELOG.d/2.12.22-leftover-residual-disclosure.md @@ -0,0 +1,7 @@ +## 2.12.22 — Leftover residual disclosure + +- Name signed leftover residual `R = Y − E[Y|θ, item]` on leftover + post–criterion pair rows (ADR 0178). 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. A non-finite + residual is an em dash, never a fabricated leftover score. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dbd797ef..f31b70545 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -213,6 +213,15 @@ All notable changes to this project are documented here. Format follows shell. The production frontend build type-checks again. +## [2.12.22] - 2026-08-24 + +### Added + +- Period leftover pair rows now name signed leftover residual + `R = Y − E[Y|θ, item]` next to leftover-map distance `d`, then open + that post (Jeon et al., 2021, eq. 3; ADR 0178). A non-finite residual + is an em dash rather than a fabricated leftover score. + ## [2.12.21] - 2026-08-24 ### Added diff --git a/docs/adr/0178-leftover-residual-disclosure.md b/docs/adr/0178-leftover-residual-disclosure.md new file mode 100644 index 000000000..0b1fe52a6 --- /dev/null +++ b/docs/adr/0178-leftover-residual-disclosure.md @@ -0,0 +1,67 @@ +# ADR 0178 — Disclose leftover residual on period-report pair rows + +**Decision status:** Accepted +**Date:** 2026-08-24 + +Amends [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. 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, does not name +observed `Y` / expected `E`, does not name leftover-map rank, and does +not land Post quality on the leftover criterion. No schema change: +`leftover_residual` already exists from ADR 0048 / migration `0012`. + +The unprotected-stack ADR for the same buyer fact was 0162. This +protected-main reconstruction uses **0178** so it does not collide with +two-axis leftover-map distance (0166), leftover coverage (0168), +leftover-map axis share (0148), leftover-map rank (0172), leftover +observed Y / expected E (0177), or analysis-run status same clock +(0171). + +## Decision + +Each leftover pair button shows signed leftover residual `R` with two +decimal places next to leftover-map distance `d`. Next action: leftover +residual `R` after IRT main effects; open this post to read the named +criterion. 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` next to `d`; click opens that post. + +## Related + +Independent of leftover interaction-map persistence, leftover-criterion +evaluation landing, leftover-map complete-case coverage, leftover-map +axis share, leftover pairs on the grouping comparison strip, two-axis +leftover-map distance, leftover observed `Y` / expected `E`, and +leftover-map rank. + +## 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/leftoverResidual.test.ts b/frontend/src/leftoverResidual.test.ts new file mode 100644 index 000000000..bcdb831c3 --- /dev/null +++ b/frontend/src/leftoverResidual.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { formatLeftoverResidual } from "./leftoverResidual"; + +describe("formatLeftoverResidual", () => { + it("keeps a signed residual without inventing a leftover score", () => { + expect(formatLeftoverResidual(0.4)).toBe("+0.40"); + expect(formatLeftoverResidual(-1.1)).toBe("\u22121.10"); + expect(formatLeftoverResidual(0)).toBe("0.00"); + expect(formatLeftoverResidual(Number.NaN)).toBe("—"); + }); +}); diff --git a/frontend/src/leftoverResidual.ts b/frontend/src/leftoverResidual.ts new file mode 100644 index 000000000..7795ee43d --- /dev/null +++ b/frontend/src/leftoverResidual.ts @@ -0,0 +1,18 @@ +/** Signed leftover residual ``R = Y − E[Y|θ, item]`` after IRT main effects. */ + +export const LEFTOVER_RESIDUAL_ACTION = + "Leftover residual R {residual} after IRT main effects. Open this post to read {criterion}."; + +export function formatLeftoverResidual(value: number): string { + if (!Number.isFinite(value)) { + return "—"; + } + const magnitude = Math.abs(value).toFixed(2); + if (value > 0) { + return `+${magnitude}`; + } + if (value < 0) { + return `\u2212${magnitude}`; + } + return magnitude; +} diff --git a/tests/test_schema.py b/tests/test_schema.py index c7e1e7159..9971cf7d4 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -510,6 +510,7 @@ def test_leftover_pair_names_leftover_map_rank_column(schema_db) -> None: columns = dict(cur.fetchall()) assert columns["leftover_map_rank"] == "YES" + def test_corporate_hierarchy_recursive_query_returns_correct_shape(schema_db) -> None: """The real product requirement: 'Acme Group -> Acme Electronics Korea -> Acme Electronics Gwangju Plant' must be walkable with one query,