diff --git a/CHANGELOG.d/2.12.24-leftover-map-cosine.md b/CHANGELOG.d/2.12.24-leftover-map-cosine.md new file mode 100644 index 000000000..3856da7a3 --- /dev/null +++ b/CHANGELOG.d/2.12.24-leftover-map-cosine.md @@ -0,0 +1,7 @@ +## 2.12.24 — Leftover-map cosine + +- Persist leftover-map cosine on leftover post–criterion pairs + (ADR 0180). After `make seed`, closest and farthest leftover pairs + sit above the member list with cosine next to leftover-map distance + `d`; click opens that post. Omit the badge when cosine is missing. + Never invent a leftover score. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e8b45a1c..c20009878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,6 +253,15 @@ All notable changes to this project are documented here. Format follows (Gabriel, 1971; Jeon et al., 2021, eq. 3; ADR 0179). A missing inner product omits the badge rather than inventing a leftover score. +## [2.12.24] - 2026-08-24 + +### Added + +- Period leftover pair rows now name leftover-map cosine next to + leftover-map distance `d`, then open that post (Gabriel, 1971; + Jeon et al., 2021, eq. 3; ADR 0180). A missing cosine omits the + badge rather than inventing leftover-map alignment. + ## [2.12.6] - 2026-08-20 ### Added diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md index b1db26002..2b1ab699b 100644 --- a/docs/adr/0048-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md @@ -43,7 +43,8 @@ the IRT matrix is unusable. A rank-0 residual still emits a stable pair so `make seed` is not empty; the stored distance is then zero, not a fabricated interaction. -The UI contract is ADR 0049. +The UI contract is ADR 0049. Leftover-map cosine on those pair rows +is [ADR 0180](0180-leftover-map-cosine.md). ## Consequences diff --git a/docs/adr/0180-leftover-map-cosine.md b/docs/adr/0180-leftover-map-cosine.md new file mode 100644 index 000000000..9c7cdbb8d --- /dev/null +++ b/docs/adr/0180-leftover-map-cosine.md @@ -0,0 +1,78 @@ +# ADR 0180 — Name leftover-map cosine 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 alignment as +the cosine of the angle between `ξ_p` and `ζ_i`. Hiding that cosine +lets a buyer read a large reconstructed leftover cell, or a close +map pair, as leftover-map alignment without a scale-free value. + +This increment does not persist leftover-map coordinates, does not +name leftover-map inner product, 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. + +The unprotected-stack reconstructions for neighbouring leftover facts +use 0162–0179. This protected-main increment uses **0180** so it does +not collide with 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_cosine` — the cosine of the +angle between leftover-map person and item coordinates that produced +leftover-map distance `d`. Migration `0180` 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 alignment. Fallback pairs that have no +complete-case leftover map, and pairs whose person or item vector +sits at the origin, omit the value rather than inventing one. + +The pair button shows `cos {signed}` next to leftover-map distance +`d` when the value is finite. Next action: leftover-map cosine names +leftover-map alignment independent of distance; open this post to +read the named criterion. A missing or non-finite cosine 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_cosine`. +After `make seed`, closest and farthest leftover pairs sit above the +member list with named cosine 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, and leftover-map inner +product. + +## 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/i18n.test.ts b/frontend/src/i18n.test.ts index a92b976f5..e29a86d67 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -270,6 +270,33 @@ describe("i18n", () => { expect(document.documentElement.lang).toBe(locale); }); + it.each([ + [ + "ko", + "잔여 지도 코사인 +0.95이(가) 거리와 무관한 잔여 지도 정렬을 이름합니다. sales-lead 기준을 읽으려면 이 글을 여세요.", + ], + [ + "zh", + "残差图余弦 +0.95 命名与距离无关的残差图对齐。打开这篇帖子阅读 sales-lead。", + ], + [ + "ja", + "残差マップ余弦 +0.95 は距離に依存しない残差マップの向きを示します。この投稿を開いて sales-lead を読んでください。", + ], + [ + "vi", + "Cosin bản đồ phần dư +0.95 đặt tên sự thẳng hàng độc lập với khoảng cách. Mở bài viết này để đọc sales-lead.", + ], + ] as const)("formats leftover-map cosine next action in %s", (locale, expected) => { + setLocale(locale); + expect( + tf( + "Leftover-map cosine {value} names leftover-map alignment independent of distance. Open this post to read {criterion}.", + { value: "+0.95", criterion: "sales-lead" }, + ), + ).toBe(expected); + }); + it.each([ ["ko", "DEMO은(는) 이벤트 계보의 현재 항목입니다. 다음으로 Keyman과 평가를 읽으세요."], ["zh", "DEMO 是事件谱系中的当前记录。接下来查看关键联系人和评估。"], diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index a320f8e9a..8767adac1 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -644,6 +644,8 @@ const TRANSLATIONS: Partial>> = { "잔여 맵 내적 ξ·ζ {value}가 IRT 주효과 이후 잔여를 재구성합니다. {criterion}을(를) 읽으려면 이 글을 여세요.", "Open leftover {kind} pair: {title} · {criterion}": "잔여 {kind} 쌍 열기: {title} · {criterion}", + "Leftover-map cosine {value} names leftover-map alignment independent of distance. Open this post to read {criterion}.": + "잔여 지도 코사인 {value}이(가) 거리와 무관한 잔여 지도 정렬을 이름합니다. {criterion} 기준을 읽으려면 이 글을 여세요.", }, zh: { "Unknown": "未知", @@ -1198,6 +1200,8 @@ const TRANSLATIONS: Partial>> = { "残余图内积 ξ·ζ {value} 重建了 IRT 主效应后的残余。打开这篇帖子以阅读 {criterion}。", "Open leftover {kind} pair: {title} · {criterion}": "打开残余{kind}对:{title} · {criterion}", + "Leftover-map cosine {value} names leftover-map alignment independent of distance. Open this post to read {criterion}.": + "残差图余弦 {value} 命名与距离无关的残差图对齐。打开这篇帖子阅读 {criterion}。", }, ja: { "Unknown": "不明", @@ -1752,6 +1756,8 @@ const TRANSLATIONS: Partial>> = { "残差マップ内積 ξ·ζ {value} が IRT 主効果後の残差を再構成します。{criterion} を読むにはこの投稿を開いてください。", "Open leftover {kind} pair: {title} · {criterion}": "残差{kind}ペアを開く: {title} · {criterion}", + "Leftover-map cosine {value} names leftover-map alignment independent of distance. Open this post to read {criterion}.": + "残差マップ余弦 {value} は距離に依存しない残差マップの向きを示します。この投稿を開いて {criterion} を読んでください。", }, vi: { "Unknown": "Không rõ", @@ -2306,6 +2312,8 @@ const TRANSLATIONS: Partial>> = { "Tích trong bản đồ phần dư ξ·ζ {value} tái tạo phần dư sau hiệu ứng chính IRT. Mở bài viết này để đọc {criterion}.", "Open leftover {kind} pair: {title} · {criterion}": "Mở cặp phần dư {kind}: {title} · {criterion}", + "Leftover-map cosine {value} names leftover-map alignment independent of distance. Open this post to read {criterion}.": + "Cosin bản đồ phần dư {value} đặt tên sự thẳng hàng độc lập với khoảng cách. Mở bài viết này để đọc {criterion}.", }, }; diff --git a/frontend/src/leftoverMapCosine.test.ts b/frontend/src/leftoverMapCosine.test.ts new file mode 100644 index 000000000..b38c88bfc --- /dev/null +++ b/frontend/src/leftoverMapCosine.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { formatLeftoverMapCosine, formatSignedLeftoverValue } from "./leftoverMapCosine"; + +describe("formatLeftoverMapCosine", () => { + it("names leftover-map cosine without inventing a leftover score", () => { + expect(formatLeftoverMapCosine(0.95)).toBe("cos +0.95"); + expect(formatLeftoverMapCosine(-1)).toBe("cos −1.00"); + expect(formatLeftoverMapCosine(0)).toBe("cos 0.00"); + expect(formatSignedLeftoverValue(0.95)).toBe("+0.95"); + expect(formatSignedLeftoverValue(-1)).toBe("−1.00"); + }); + + it("omits the badge when cosine is missing or non-finite", () => { + expect(formatLeftoverMapCosine(null)).toBeNull(); + expect(formatLeftoverMapCosine(undefined)).toBeNull(); + expect(formatLeftoverMapCosine(Number.NaN)).toBeNull(); + expect(formatLeftoverMapCosine(Number.POSITIVE_INFINITY)).toBeNull(); + }); +}); diff --git a/frontend/src/leftoverMapCosine.ts b/frontend/src/leftoverMapCosine.ts new file mode 100644 index 000000000..4683f8e92 --- /dev/null +++ b/frontend/src/leftoverMapCosine.ts @@ -0,0 +1,26 @@ +/** Gabriel leftover-map cosine after IRT main effects. */ + +export const LEFTOVER_MAP_COSINE_ACTION = + "Leftover-map cosine {value} names leftover-map alignment independent of distance. 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 `−${magnitude}`; + } + return magnitude; +} + +export function formatLeftoverMapCosine(value: number | null | undefined): string | null { + if (value == null) { + return null; + } + const signed = formatSignedLeftoverValue(value); + return signed === null ? null : `cos ${signed}`; +} diff --git a/lineageweave/period_report.py b/lineageweave/period_report.py index de79478b9..17b82e15e 100644 --- a/lineageweave/period_report.py +++ b/lineageweave/period_report.py @@ -252,7 +252,7 @@ def leftover_pairs_for_fit( theta: np.ndarray, fit: PolytomousFit, ) -> tuple[LeftoverPair, ...]: - """Leftover pairs from the already-fitted GRM/GPCM main effects.""" + """Leftover pairs from the already-fitted GRM/GPCM main effects, with cosine.""" probs = _category_probabilities(model, theta, fit) expected = expected_category_matrix(matrix, probs) return leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) diff --git a/migrations/0180_report_leftover_map_cosine.sql b/migrations/0180_report_leftover_map_cosine.sql new file mode 100644 index 000000000..74bad32ea --- /dev/null +++ b/migrations/0180_report_leftover_map_cosine.sql @@ -0,0 +1,8 @@ +-- ADR 0180: persist leftover-map cosine 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 alignment. 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_cosine numeric; diff --git a/migrations/rollback/0180_report_leftover_map_cosine.sql b/migrations/rollback/0180_report_leftover_map_cosine.sql new file mode 100644 index 000000000..fb0a86fb6 --- /dev/null +++ b/migrations/rollback/0180_report_leftover_map_cosine.sql @@ -0,0 +1,4 @@ +-- Reverse 0180. Leftover distance and residual stay on the pair row. + +alter table report_leftover_pair + drop column if exists leftover_map_cosine;