diff --git a/CHANGELOG.d/2.12.23-leftover-map-inner-product.md b/CHANGELOG.d/2.12.23-leftover-map-inner-product.md
new file mode 100644
index 000000000..456706fa2
--- /dev/null
+++ b/CHANGELOG.d/2.12.23-leftover-map-inner-product.md
@@ -0,0 +1,7 @@
+## 2.12.23 — Leftover-map inner product
+
+- Persist leftover-map inner product `ξ·ζ` on leftover post–criterion
+ pairs (ADR 0179). After `make seed`, closest and farthest leftover
+ pairs sit above the member list with `ξ·ζ` next to leftover-map
+ distance `d`; click opens that post. Omit the badge when the inner
+ product is missing. Never invent a leftover score.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f31b70545..6e8b45a1c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -244,6 +244,15 @@ All notable changes to this project are documented here. Format follows
lineage run and Start still recovers the designed A-100 fork.
Never invent a theta.
+## [2.12.23] - 2026-08-24
+
+### Added
+
+- Period leftover pair rows now name leftover-map inner product
+ `ξ·ζ` next to leftover-map distance `d`, then open that post
+ (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.6] - 2026-08-20
### Added
diff --git a/docs/adr/0179-leftover-map-inner-product.md b/docs/adr/0179-leftover-map-inner-product.md
new file mode 100644
index 000000000..331d81b5a
--- /dev/null
+++ b/docs/adr/0179-leftover-map-inner-product.md
@@ -0,0 +1,74 @@
+# ADR 0179 — Name leftover-map inner product 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 reconstructs the leftover cell as
+the inner product `ξ_p · ζ_i`. Hiding that inner product lets a buyer
+read a distant map pair as a large leftover cell, or a close pair as a
+reconstructed leftover, without the reconstructed value.
+
+This increment does not persist leftover-map coordinates, 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–0178. This protected-main increment uses **0179** so it does
+not collide with 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_inner_product` — the Gabriel inner
+product `ξ·ζ` of the leftover-map person and item coordinates that
+produced leftover-map distance `d`. Migration `0179` 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 an inner product. Fallback pairs that have no
+complete-case leftover map omit the value rather than inventing one.
+
+The pair button shows `ξ·ζ {signed}` next to leftover-map distance `d`
+when the value is finite. Next action: leftover-map inner product `ξ·ζ`
+reconstructs leftover residual after IRT main effects; open this post
+to read the named criterion. A missing or non-finite inner product
+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_inner_product`. After `make seed`, closest and farthest
+leftover pairs sit above the member list with named `ξ·ζ` 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, 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/App.tsx b/frontend/src/App.tsx
index 31b62ead7..8cd278ba7 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -4189,7 +4189,7 @@ function ReportsPanel({
)}
{report.leftover_pairs && report.leftover_pairs.length > 0 && (
-
+
{report.leftover_pairs.map((pair) => (
- {
"No customer entities match this scope.",
"Page",
"Answer",
+ "Leftover pairs",
+ "Closest leftover",
+ "Farthest leftover",
+ "Open this post to read the criterion it sat closest to after main effects.",
+ "Open this post to read the criterion it sat farthest from after main effects.",
+ "Leftover-map inner product ξ·ζ {value} reconstructs leftover residual after IRT main effects. Open this post to read {criterion}.",
+ "Open leftover {kind} pair: {title} · {criterion}",
"Showing the first {shown} of {total} posts known at this cutoff.",
"Persist the brand, system, and copyright metadata used by the workspace shell.",
"Review the source body or related posts for this dimension.",
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index df17e0c31..a320f8e9a 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -633,6 +633,17 @@ const TRANSLATIONS: Partial>> = {
"IRT 주효과 이후 잔여 맵에 잔여 구조가 없습니다. 이 글을 여세요.",
"Leftover map rank {rank} after IRT main effects. Open this post.":
"IRT 주효과 이후 잔여 맵 랭크 {rank}. 이 글을 여세요.",
+ "Leftover pairs": "잔여 쌍",
+ "Closest leftover": "가장 가까운 잔여",
+ "Farthest leftover": "가장 먼 잔여",
+ "Open this post to read the criterion it sat closest to after main effects.":
+ "주효과 이후 가장 가까웠던 기준을 읽으려면 이 글을 여세요.",
+ "Open this post to read the criterion it sat farthest from after main effects.":
+ "주효과 이후 가장 멀었던 기준을 읽으려면 이 글을 여세요.",
+ "Leftover-map inner product ξ·ζ {value} reconstructs leftover residual after IRT main effects. Open this post to read {criterion}.":
+ "잔여 맵 내적 ξ·ζ {value}가 IRT 주효과 이후 잔여를 재구성합니다. {criterion}을(를) 읽으려면 이 글을 여세요.",
+ "Open leftover {kind} pair: {title} · {criterion}":
+ "잔여 {kind} 쌍 열기: {title} · {criterion}",
},
zh: {
"Unknown": "未知",
@@ -1176,6 +1187,17 @@ const TRANSLATIONS: Partial>> = {
"IRT 主效应后残余图没有残余结构。打开这篇帖子。",
"Leftover map rank {rank} after IRT main effects. Open this post.":
"IRT 主效应后的残余图秩 {rank}。打开这篇帖子。",
+ "Leftover pairs": "残余对",
+ "Closest leftover": "最近的残余",
+ "Farthest leftover": "最远的残余",
+ "Open this post to read the criterion it sat closest to after main effects.":
+ "打开这篇帖子,阅读主效应后它最接近的标准。",
+ "Open this post to read the criterion it sat farthest from after main effects.":
+ "打开这篇帖子,阅读主效应后它最远离的标准。",
+ "Leftover-map inner product ξ·ζ {value} reconstructs leftover residual after IRT main effects. Open this post to read {criterion}.":
+ "残余图内积 ξ·ζ {value} 重建了 IRT 主效应后的残余。打开这篇帖子以阅读 {criterion}。",
+ "Open leftover {kind} pair: {title} · {criterion}":
+ "打开残余{kind}对:{title} · {criterion}",
},
ja: {
"Unknown": "不明",
@@ -1719,6 +1741,17 @@ const TRANSLATIONS: Partial>> = {
"IRT主効果後の残差マップに残差構造はありません。この投稿を開いてください。",
"Leftover map rank {rank} after IRT main effects. Open this post.":
"IRT主効果後の残差マップランク {rank}。この投稿を開いてください。",
+ "Leftover pairs": "残差ペア",
+ "Closest leftover": "最も近い残差",
+ "Farthest leftover": "最も遠い残差",
+ "Open this post to read the criterion it sat closest to after main effects.":
+ "主効果後に最も近かった基準を読むには、この投稿を開いてください。",
+ "Open this post to read the criterion it sat farthest from after main effects.":
+ "主効果後に最も遠かった基準を読むには、この投稿を開いてください。",
+ "Leftover-map inner product ξ·ζ {value} reconstructs leftover residual after IRT main effects. Open this post to read {criterion}.":
+ "残差マップ内積 ξ·ζ {value} が IRT 主効果後の残差を再構成します。{criterion} を読むにはこの投稿を開いてください。",
+ "Open leftover {kind} pair: {title} · {criterion}":
+ "残差{kind}ペアを開く: {title} · {criterion}",
},
vi: {
"Unknown": "Không rõ",
@@ -2262,6 +2295,17 @@ const TRANSLATIONS: Partial>> = {
"Bản đồ phần dư không có cấu trúc phần dư sau hiệu ứng chính IRT. Mở bài viết này.",
"Leftover map rank {rank} after IRT main effects. Open this post.":
"Hạng bản đồ phần dư {rank} sau hiệu ứng chính IRT. Mở bài viết này.",
+ "Leftover pairs": "Cặp phần dư",
+ "Closest leftover": "Phần dư gần nhất",
+ "Farthest leftover": "Phần dư xa nhất",
+ "Open this post to read the criterion it sat closest to after main effects.":
+ "Mở bài viết này để đọc tiêu chí mà nó gần nhất sau hiệu ứng chính.",
+ "Open this post to read the criterion it sat farthest from after main effects.":
+ "Mở bài viết này để đọc tiêu chí mà nó xa nhất sau hiệu ứng chính.",
+ "Leftover-map inner product ξ·ζ {value} reconstructs leftover residual after IRT main effects. Open this post to read {criterion}.":
+ "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}",
},
};
diff --git a/frontend/src/leftoverInnerProduct.test.ts b/frontend/src/leftoverInnerProduct.test.ts
new file mode 100644
index 000000000..2ab63b4af
--- /dev/null
+++ b/frontend/src/leftoverInnerProduct.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { formatLeftoverInnerProduct, formatSignedLeftoverValue } from "./leftoverInnerProduct";
+
+describe("formatLeftoverInnerProduct", () => {
+ it("names leftover-map inner product without inventing a leftover score", () => {
+ expect(formatLeftoverInnerProduct(0.4)).toBe("ξ·ζ +0.40");
+ expect(formatLeftoverInnerProduct(-1.1)).toBe("ξ·ζ \u22121.10");
+ expect(formatLeftoverInnerProduct(0)).toBe("ξ·ζ 0.00");
+ expect(formatSignedLeftoverValue(0.4)).toBe("+0.40");
+ expect(formatSignedLeftoverValue(-1.1)).toBe("\u22121.10");
+ });
+
+ it("omits the badge when the inner product is missing or non-finite", () => {
+ expect(formatLeftoverInnerProduct(null)).toBeNull();
+ expect(formatLeftoverInnerProduct(undefined)).toBeNull();
+ expect(formatLeftoverInnerProduct(Number.NaN)).toBeNull();
+ expect(formatLeftoverInnerProduct(Number.POSITIVE_INFINITY)).toBeNull();
+ });
+});
diff --git a/frontend/src/leftoverInnerProduct.ts b/frontend/src/leftoverInnerProduct.ts
new file mode 100644
index 000000000..192d4135e
--- /dev/null
+++ b/frontend/src/leftoverInnerProduct.ts
@@ -0,0 +1,26 @@
+/** Gabriel leftover-map inner product ``ξ·ζ`` after IRT main effects. */
+
+export const LEFTOVER_INNER_PRODUCT_ACTION =
+ "Leftover-map inner product ξ·ζ {value} reconstructs leftover residual 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 formatLeftoverInnerProduct(value: number | null | undefined): string | null {
+ if (value == null) {
+ return null;
+ }
+ const signed = formatSignedLeftoverValue(value);
+ return signed === null ? null : `ξ·ζ ${signed}`;
+}
diff --git a/frontend/src/leftoverPairGuidance.ts b/frontend/src/leftoverPairGuidance.ts
index 56b3b736a..74f9e137d 100644
--- a/frontend/src/leftoverPairGuidance.ts
+++ b/frontend/src/leftoverPairGuidance.ts
@@ -5,6 +5,8 @@
* criterion. Clicking a pair opens that post and lands on that criterion.
*/
+import { t, tf } from "./i18n";
+
export const LEFTOVER_CRITERION_SHORT_LABEL: Record = {
general_sentiment_positive: "constructive",
general_sentiment_negative: "negative",
@@ -35,7 +37,7 @@ export function leftoverPairOpenOptions(pair: LeftoverPairOpen): LeftoverPairOpe
}
export function leftoverPairKindLabel(pairKind: string): string {
- return pairKind === "farthest" ? "Farthest leftover" : "Closest leftover";
+ return pairKind === "farthest" ? t("Farthest leftover") : t("Closest leftover");
}
export function leftoverPairTitle(pair: LeftoverPairOpen): string {
@@ -43,7 +45,11 @@ export function leftoverPairTitle(pair: LeftoverPairOpen): string {
}
export function leftoverPairAriaLabel(pair: LeftoverPairOpen): string {
- return `Open leftover ${pair.pair_kind} pair: ${pair.post_title} · ${leftoverCriterionLabel(pair.criterion_code)}`;
+ return tf("Open leftover {kind} pair: {title} · {criterion}", {
+ kind: pair.pair_kind,
+ title: pair.post_title,
+ criterion: leftoverCriterionLabel(pair.criterion_code),
+ });
}
export function leftoverPairNextAction(pair: LeftoverPairOpen): string {
diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py
index 4a4fcd7f4..8eaea7c94 100644
--- a/lineageweave/leftover_pairs.py
+++ b/lineageweave/leftover_pairs.py
@@ -87,11 +87,12 @@ def leftover_pairs_from_residual(
for person, item in observed:
if person not in local_person or item not in local_item:
continue
- distance = float(
- np.linalg.norm(person_pos[local_person[person]] - item_pos[local_item[item]])
- )
+ person_coord = person_pos[local_person[person]]
+ item_coord = item_pos[local_item[item]]
+ distance = float(np.linalg.norm(person_coord - item_coord))
if not np.isfinite(distance):
continue
+ inner_product = float(np.dot(person_coord, item_coord))
candidates.append(
_candidate_row(post_ids, item_codes, matrix, expected, residual, person, item, distance)
)
diff --git a/migrations/0179_report_leftover_inner_product.sql b/migrations/0179_report_leftover_inner_product.sql
new file mode 100644
index 000000000..3217a30e4
--- /dev/null
+++ b/migrations/0179_report_leftover_inner_product.sql
@@ -0,0 +1,8 @@
+-- ADR 0179: persist leftover-map inner product ξ·ζ 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 an inner product. 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_inner_product numeric;
diff --git a/migrations/rollback/0179_report_leftover_inner_product.sql b/migrations/rollback/0179_report_leftover_inner_product.sql
new file mode 100644
index 000000000..3d7659a02
--- /dev/null
+++ b/migrations/rollback/0179_report_leftover_inner_product.sql
@@ -0,0 +1,4 @@
+-- Reverse 0179. Leftover distance and residual stay on the pair row.
+
+alter table report_leftover_pair
+ drop column if exists leftover_inner_product;