Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.d/2.12.23-leftover-map-inner-product.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions docs/adr/0179-leftover-map-inner-product.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4189,7 +4189,7 @@ function ReportsPanel({
</span>
)}
{report.leftover_pairs && report.leftover_pairs.length > 0 && (
<ul className="ticket-list" aria-label="Leftover pairs">
<ul className="ticket-list" aria-label={t("Leftover pairs")}>
{report.leftover_pairs.map((pair) => (
<li
key={`${pair.pair_kind}:${pair.post_id}:${pair.criterion_code}`}
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ describe("i18n", () => {
"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.",
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,17 @@ const TRANSLATIONS: Partial<Record<Locale, Record<string, string>>> = {
"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": "未知",
Expand Down Expand Up @@ -1176,6 +1187,17 @@ const TRANSLATIONS: Partial<Record<Locale, Record<string, string>>> = {
"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": "不明",
Expand Down Expand Up @@ -1719,6 +1741,17 @@ const TRANSLATIONS: Partial<Record<Locale, Record<string, string>>> = {
"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õ",
Expand Down Expand Up @@ -2262,6 +2295,17 @@ const TRANSLATIONS: Partial<Record<Locale, Record<string, string>>> = {
"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}",
},
};

Expand Down
19 changes: 19 additions & 0 deletions frontend/src/leftoverInnerProduct.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
26 changes: 26 additions & 0 deletions frontend/src/leftoverInnerProduct.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
10 changes: 8 additions & 2 deletions frontend/src/leftoverPairGuidance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
general_sentiment_positive: "constructive",
general_sentiment_negative: "negative",
Expand Down Expand Up @@ -35,15 +37,19 @@ 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 {
return `${leftoverPairKindLabel(pair.pair_kind)}: ${pair.post_title} · ${leftoverCriterionLabel(pair.criterion_code)}`;
}

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 {
Expand Down
7 changes: 4 additions & 3 deletions lineageweave/leftover_pairs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Inner product computed then discarded

The inner product is computed (np.dot at leftover_pairs.py) but never added to the candidate tuple or LeftoverPair, so report_ingestion persists nothing and the new leftover_inner_product column stays null. The ξ·ζ badge never gets a value, making the feature a no-op end to end.

Prompt for agents
The inner product computed at lineageweave/leftover_pairs.py:95 is discarded. To actually deliver the feature, thread the value through: add a leftover_inner_product field to the LeftoverPair dataclass (lineageweave/leftover_pairs.py:24-35), make _candidate_row accept/return the inner product (defaulting to None for the fallback branch that has no complete-case map at lines 99-114), have _pair_from_candidate populate the new field, persist it in backend/app/report_ingestion.py into the report_leftover_pair.leftover_inner_product column, expose it via the API (frontend/src/api.ts), and render it in LeftoverPairButton.tsx using the existing formatLeftoverInnerProduct helper. Fallback pairs with no complete-case leftover map must omit the value (None) rather than inventing one, per ADR 0179.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

candidates.append(
_candidate_row(post_ids, item_codes, matrix, expected, residual, person, item, distance)
)
Comment on lines +90 to 98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Inner product reconstructs centered residual, not raw residual

leftover_inner_product reconstructs the centered cell residual - center (leftover_pairs.py, with centering at leftover_pairs.py), while leftover_residual stores the raw residual. When center != 0 these differ, yet the next-action text says ξ·ζ 'reconstructs leftover residual'. Tests only cover center == 0. ADR 0179 documents the centered intent and the raw residual is not shown in the UI, so there is no visible contradiction.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Expand Down
8 changes: 8 additions & 0 deletions migrations/0179_report_leftover_inner_product.sql
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions migrations/rollback/0179_report_leftover_inner_product.sql
Original file line number Diff line number Diff line change
@@ -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;