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
8 changes: 8 additions & 0 deletions CHANGELOG.d/2.12.21-leftover-map-rank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
## 2.12.21 — Leftover-map rank

- Persist leftover-map rank on leftover post–criterion pairs (ADR 0172).
Rank is the number of Gabriel singular values above the floor. After
`make seed`, closest and farthest leftover pairs sit above the member
list with `rank {n}` next to leftover-map distance `d`; click opens
that post. Rank 0 names no leftover structure. Never invent a leftover
score or a theta.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.21] - 2026-08-24

### Added

- Period leftover pair rows now name leftover-map rank after IRT main
effects next to leftover-map distance `d`, then open that post
(Jeon et al., 2021, eq. 3; ADR 0172). Rank 0 names no leftover
structure rather than inventing a leftover score.

Comment on lines +216 to +224

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: 2.12.21 changelog entry placed out of version order

The new ## [2.12.21] section sits between ## [2.12.19] and ## [2.12.20] (CHANGELOG.md:216). Newest-first order would put 2.12.21 above both. The surrounding entries are already non-monotonic (2.12.19 before 2.12.20), so this may reflect merge-order layout rather than a defect.

Open in Devin Review

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

## [2.12.20] - 2026-08-24

### Fixed
Expand Down
12 changes: 9 additions & 3 deletions backend/app/report_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,8 +444,8 @@ async def persist_period_report(
insert into report_leftover_pair (
grouping_kind, grouping_key, period_code, rubric_version,
pair_kind, post_id, criterion_code, leftover_distance, leftover_residual,
observed_response, expected_response
) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
observed_response, expected_response, leftover_map_rank
) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
""",
grouping_kind,
grouping_key,
Expand All @@ -458,6 +458,7 @@ async def persist_period_report(
pair.leftover_residual,
pair.observed_response,
pair.expected_response,
pair.leftover_map_rank,
)


Expand Down Expand Up @@ -606,7 +607,7 @@ async def fetch_period_reports(
f"""
select lp.grouping_key, lp.pair_kind, lp.post_id, lp.criterion_code,
lp.leftover_distance, lp.leftover_residual,
lp.observed_response, lp.expected_response, p.post_title,
lp.observed_response, lp.expected_response, lp.leftover_map_rank, p.post_title,
p.visibility_code, p.corporate_entity_id,
p.author_account_id, p.source_detail_state_code,
({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context
Expand Down Expand Up @@ -716,6 +717,11 @@ async def fetch_period_reports(
if row["expected_response"] is None
else float(row["expected_response"])
),
"leftover_map_rank": (
None
if row["leftover_map_rank"] is None
else int(row["leftover_map_rank"])
),
"visibility_code": row["visibility_code"],
"corporate_entity_id": str(row["corporate_entity_id"]),
"author_account_id": str(row["author_account_id"]),
Expand Down
12 changes: 12 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,16 @@
/ "migrations"
/ "0177_report_leftover_observed_expected.sql"
)
_LEFTOVER_MAP_RANK_MIGRATION = (
Path(__file__).resolve().parents[2]
/ "migrations"
/ "0172_report_leftover_map_rank.sql"
)
_ANALYSIS_RUN_STATUS_SAME_CLOCK_MIGRATION = (
Path(__file__).resolve().parents[2]
/ "migrations"
/ "0173_analysis_run_status_same_clock.sql"
)


def _postgres_available() -> bool:
Expand Down Expand Up @@ -327,6 +337,8 @@ def seeded_db(demo_analyst_token):
cur.execute(_CATALOG_UNRESOLVED_REASON_MIGRATION.read_text())
cur.execute(_POST_ASK_HISTORY_MIGRATION.read_text())
cur.execute(_CUSTOMER_IDENTITY_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_RANK_MIGRATION.read_text())
cur.execute(_ANALYSIS_RUN_STATUS_SAME_CLOCK_MIGRATION.read_text())
cur.execute(_LEFTOVER_OBSERVED_EXPECTED_MIGRATION.read_text())
cur.execute(
"insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values "
Expand Down
2 changes: 1 addition & 1 deletion docker/postgres-init/migrate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do
migration_name=${migration##*/}
case "$migration_name" in
0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;;
0060_*|0100_*|0101_*|0102_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*|0130_*|0133_*|0134_*|0136_*|0137_*|0138_*|0139_*|0173_*|0176_*|0177_*) ;;
0060_*|0100_*|0101_*|0102_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*|0130_*|0133_*|0134_*|0136_*|0137_*|0138_*|0139_*|0172_*|0173_*|0176_*|0177_*) ;;
*) continue ;;
esac
printf 'Applying %s\n' "$migration_name"
Expand Down
71 changes: 71 additions & 0 deletions docs/adr/0172-leftover-map-rank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# ADR 0172 — Name leftover-map rank on leftover pairs

**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 and leftover residual
`R = Y − E[Y|θ, item]` on `report_leftover_pair`. A rank-0 residual
still emits a stable closest/farthest pair so `make seed` is not empty;
the stored distance is then zero, not a fabricated interaction. ADR 0049
renders those pairs above the member list. Without leftover-map rank, a
buyer cannot tell a Gabriel biplot with leftover structure (Jeon et al.,
2021, eq. 3) from an origin collapse that still shows `d 0.00`.

This increment does not persist leftover-map coordinates, does not name
observed `Y` / expected `E`, does not change leftover-map axis count, and
does not land Post quality on the leftover criterion.

The unprotected-stack ADR for the same buyer fact was 0164. This
protected-main reconstruction uses **0172** so it does not collide with
two-axis leftover-map distance (0166), leftover coverage (0168),
leftover-map axis share (0148), leftover observed Y / expected E
(0177), or analysis-run status same clock (0171).

## Decision

Each leftover pair names `leftover_map_rank`: the number of Gabriel
singular values above the leftover singular floor on the complete-case
residual rectangle. Closest and farthest pairs on one period report share
that rank. A fallback pair that is not placed on a leftover map stores
rank `0`. Migration `0172` is the single source of the column on every
install path; shipped migrations (`0001` / `0012`) are never rewritten.
It adds a nullable column so older leftover rows keep distance and residual
without fabricating a rank.

The pair button shows `rank {n}` when the value is a finite
non-negative integer. Rank `0` next action: leftover map has no leftover
structure after IRT main effects; open this post. Rank `≥ 1` next action:
read leftover map rank after IRT main effects, then open this post. Omit
the rank badge when the value is missing. Do not invent a leftover score.
Do not invent a theta.

## Consequences

`GET /api/reports/{grouping}/{period}` returns `leftover_map_rank`. After
`make seed`, closest and farthest leftover pairs sit above the member
list with leftover-map rank; click opens that post. Hidden posts stay
hidden.

## Related

Independent of leftover interaction-map persistence, leftover-criterion
evaluation landing, leftover residual UI extraction, leftover-map
complete-case coverage, leftover-map axis share, leftover pairs on the
grouping comparison strip, two-axis leftover-map distance, and leftover
observed `Y` / expected `E`.

## 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/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "2.12.20",
"version": "2.12.21",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1188,6 +1188,7 @@ describe("App, authenticated", () => {
leftover_residual: 0.4,
observed_response: 2.4,
expected_response: 2.0,
leftover_map_rank: 1,
},
{
pair_kind: "farthest",
Expand All @@ -1198,6 +1199,7 @@ describe("App, authenticated", () => {
leftover_residual: -1.1,
observed_response: 0.9,
expected_response: 2.0,
leftover_map_rank: 1,
},
],
members: [
Expand Down Expand Up @@ -5339,6 +5341,7 @@ describe("App, authenticated", () => {
);
expect(closestPair).not.toHaveTextContent(/sat closest to after main effects/);
expect(closestPair).toHaveTextContent("Y 2.40 · E 2.00");
expect(closestPair).toHaveTextContent("rank 1");
expect(closestPair).toHaveTextContent("d 0.12");
expect(farthestPair).toHaveTextContent("Farthest leftover: Specification revision requested · negative");
expect(farthestPair).toHaveTextContent(
Expand Down
1 change: 1 addition & 0 deletions frontend/src/App.tsx
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -4200,6 +4200,7 @@ function ReportsPanel({
leftoverDistance={pair.leftover_distance}
observedResponse={pair.observed_response}
expectedResponse={pair.expected_response}
leftoverMapRank={pair.leftover_map_rank}
onOpen={onSelectPost}
/>
</li>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,7 @@ export interface LeftoverPair {
leftover_residual: number;
observed_response?: number | null;
expected_response?: number | null;
leftover_map_rank?: number | null;
}

export interface PeriodGroupReport {
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/LeftoverPairButton.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { formatLeftoverObservedExpected } from "../leftoverObservedExpected";
import { formatLeftoverMapRank } from "../leftoverMapRank";
import {
leftoverPairAriaLabel,
leftoverPairNextAction,
Expand All @@ -13,15 +14,18 @@ export function LeftoverPairButton({
leftoverDistance,
observedResponse,
expectedResponse,
leftoverMapRank,
onOpen,
}: {
pair: LeftoverPairOpen;
leftoverDistance: number;
observedResponse?: number | null;
expectedResponse?: number | null;
leftoverMapRank?: number | null;
onOpen: (postId: string, options: LeftoverPairOpenOptions) => void;
}) {
const observedExpected = formatLeftoverObservedExpected(observedResponse, expectedResponse);
const mapRank = formatLeftoverMapRank(leftoverMapRank);
return (
<button
type="button"
Expand All @@ -32,6 +36,7 @@ export function LeftoverPairButton({
<span className="ticket-title">{leftoverPairTitle(pair)}</span>
<span className="post-badge">{leftoverPairNextAction(pair)}</span>
{observedExpected ? <span className="post-badge">{observedExpected}</span> : null}
{mapRank ? <span className="post-badge">{mapRank}</span> : null}
<span className="post-badge">d {leftoverDistance.toFixed(2)}</span>
</button>
);
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,17 @@ describe("locale-aware source labels", () => {
expect(t("Public")).toBe(visibility);
});
});

describe("leftover-map rank next action", () => {
it.each([
["ko", "IRT 주효과 이후 잔여 맵 랭크 1. 이 글을 여세요."],
["zh", "IRT 主效应后的残余图秩 1。打开这篇帖子。"],
["ja", "IRT主効果後の残差マップランク 1。この投稿を開いてください。"],
["vi", "Hạng bản đồ phần dư 1 sau hiệu ứng chính IRT. Mở bài viết này."],
] as const)("formats leftover-map rank next action in %s", (locale, expected) => {
setLocale(locale);
expect(
tf("Leftover map rank {rank} after IRT main effects. Open this post.", { rank: "1" }),
).toBe(expected);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
});
});
18 changes: 18 additions & 0 deletions frontend/src/leftoverMapRank.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { formatLeftoverMapRank } from "./leftoverMapRank";

describe("formatLeftoverMapRank", () => {
it("names leftover-map rank without inventing leftover structure", () => {
expect(formatLeftoverMapRank(0)).toBe("rank 0");
expect(formatLeftoverMapRank(1)).toBe("rank 1");
expect(formatLeftoverMapRank(2)).toBe("rank 2");
});

it("omits the badge when rank is missing or not a non-negative integer", () => {
expect(formatLeftoverMapRank(null)).toBeNull();
expect(formatLeftoverMapRank(undefined)).toBeNull();
expect(formatLeftoverMapRank(-1)).toBeNull();
expect(formatLeftoverMapRank(1.5)).toBeNull();
expect(formatLeftoverMapRank(Number.NaN)).toBeNull();
});
});
13 changes: 13 additions & 0 deletions frontend/src/leftoverMapRank.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** Leftover-map rank after IRT main effects (Gabriel singular values). */

export const LEFTOVER_RANK_ZERO_ACTION =
"Leftover map has no leftover structure after IRT main effects. Open this post.";
export const LEFTOVER_RANK_STRUCTURE_ACTION =
"Leftover map rank {rank} after IRT main effects. Open this post.";
Comment on lines +3 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Rank next-action copy defined but never rendered

leftoverMapRank.ts exports LEFTOVER_RANK_ZERO_ACTION and LEFTOVER_RANK_STRUCTURE_ACTION, and ADR 0172 specifies distinct rank-0 vs rank≥1 next-action copy. LeftoverPairButton only renders the rank {n} badge and keeps the existing leftoverPairNextAction copy, so the rank-specific next action never appears. The two constants are unused.

Open in Devin Review

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


export function formatLeftoverMapRank(rank: number | null | undefined): string | null {
if (rank == null || !Number.isInteger(rank) || rank < 0) {
return null;
}
return `rank ${rank}`;
}
Loading