diff --git a/AGENTS.md b/AGENTS.md
index 6cd2d9c73..5ed21a371 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -186,15 +186,15 @@ in the same spirit) -- never against real data, per the hard rule above.
against a live local stack (`make up`) and self-skip without one -- see
[README.md](README.md#local-product-stack-docker-compose).
-Period leftover pairs (ADR 0048 / 0049 / 0119 / 0163 / 0164) are computed in
+Period leftover pairs (ADR 0048 / 0049 / 0119 / 0162 / 0163 / 0164) are computed in
`lineageweave/leftover_pairs.py` from the residual after a real
GRM/GPCM score, never invented. Distances are Euclidean on the
two-dimensional Gabriel leftover map; missing cells stay out of
the factorization. Closest and farthest post–criterion pairs
-persist to `report_leftover_pair` with observed `Y` and expected
-`E[Y|θ, item]` so residual reconciles to `Y − E`, plus leftover-map
-rank so rank 0 is not read as structure, and sit above the member list
-so a click opens that post.
+persist to `report_leftover_pair` with signed residual `R`, observed
+`Y`, and expected `E[Y|θ, item]` so `R = Y − E` remains auditable,
+plus leftover-map rank so rank 0 is not read as structure. They sit
+above the member list so a click opens that post.
`frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`,
pnpm via Corepack -- do not add a second Node package manager or a
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 82f8d9081..44664df5d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -591,8 +591,9 @@ on those same fixed parameters (Kim, 2006 FIPC). After scoring,
information at the group's mean θ (Lord, 1980 max-info CAT). Rankings
persist to `report_item_information`. After those IRT main effects,
residual SVD leftover pairs on two Gabriel axes (Jeon et al., 2021;
-ADR 0048 / 0119 / 0163 / 0164) persist to `report_leftover_pair` with observed
-`Y`, expected `E[Y|θ, item]`, and full leftover-map rank. Results persist to
+ADR 0048 / 0119 / 0162 / 0163 / 0164) persist to `report_leftover_pair` with
+signed residual `R`, observed `Y`, expected `E[Y|θ, item]`, and full
+leftover-map rank. Results persist to
`report_period_score` / `report_member_score`.
`GET /api/reports/{grouping}` lists the trend;
`GET /api/reports/{grouping}/{period}` is ABAC-filtered;
@@ -604,8 +605,9 @@ bank as the dummy high/low band rows, so comparison-strip click
through opens those DAG posts. Report members include the earliest
open ticket title, status lookup label, and due date when one exists. The home page renders
the actual mean θ, the FIPC delta, the CAT-selected item, leftover
-closest/farthest pairs (observed `Y`, expected `E`, full rank, and
-two-axis leftover-map distance `d` after IRT main effects) above the member list, and the
+closest/farthest pairs (signed residual `R`, observed `Y`, expected
+`E`, full rank, and two-axis leftover-map distance `d` after IRT main
+effects) above the member list, and the
PU / corp / thread comparison -- never a placeholder. TEPP is unchanged.
## Phase 6b: Knowledge Graph as a real Ontology + Semantic Layer
diff --git a/CHANGELOG.d/2.12.16-leftover-residual-disclosure.md b/CHANGELOG.d/2.12.16-leftover-residual-disclosure.md
new file mode 100644
index 000000000..b18326ce2
--- /dev/null
+++ b/CHANGELOG.d/2.12.16-leftover-residual-disclosure.md
@@ -0,0 +1,6 @@
+# 2.12.16 leftover residual disclosure
+
+After `make seed`, closest and farthest leftover pairs sit above the
+period-report member list with signed residual `R`, observed `Y`,
+expected `E`, full leftover-map rank, and two-axis distance `d`.
+Read the named evidence, then click the pair to open that post.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b6a41a02b..bc85bd407 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,15 @@ All notable changes to this project are documented here. Format follows
environment, so local OIDC and synthetic-data workflows resolve the same
pinned dependencies as CI.
+## [2.12.16] - 2026-08-24
+
+### Added
+
+- Period leftover pair rows now name signed residual `R` beside observed
+ `Y`, expected `E`, leftover-map rank, and distance `d`, then open that
+ post from the reusable `LeftoverPairList` Storybook component
+ (Jeon et al., 2021, eq. 3; ADR 0162).
+
## [2.12.15] - 2026-08-24
### Added
diff --git a/docs/adr/0049-leftover-pair-report-ui.md b/docs/adr/0049-leftover-pair-report-ui.md
index 04023f207..3b6e4ce43 100644
--- a/docs/adr/0049-leftover-pair-report-ui.md
+++ b/docs/adr/0049-leftover-pair-report-ui.md
@@ -2,7 +2,8 @@
**Decision status:** Accepted
**Date:** 2026-08-17
-**Amended by:** [ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E);
+**Amended by:** [ADR 0162](0162-leftover-residual-disclosure.md) (signed residual R);
+[ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E);
[ADR 0164](0164-leftover-map-rank.md) (full map rank)
## Context
@@ -19,13 +20,14 @@ second navigation surface.
On each period-report group, render leftover pairs **above** the
member list. Each pair is a button: closest or farthest label, post
-title, criterion short label, two-axis leftover-map distance, full map
-rank, observed `Y`, and expected `E` when finite. The next action names
-every available measurement before opening the post; when rank and
-`Y` / `E` coexist, neither amendment hides the other. Rank 0 explicitly
-names no leftover structure.
+title, criterion short label, signed residual `R`, two-axis leftover-map
+distance, full map rank, observed `Y`, and expected `E` when finite.
+The next action names every available measurement before opening the
+post; no amendment hides another, and rank 0 explicitly names no
+leftover structure.
Clicking the button opens that post with the same handler as a member
-row. Observed/expected naming is [ADR 0163](0163-leftover-observed-expected.md);
+row. Residual naming is [ADR 0162](0162-leftover-residual-disclosure.md),
+observed/expected naming is [ADR 0163](0163-leftover-observed-expected.md),
rank naming is [ADR 0164](0164-leftover-map-rank.md).
After `make seed`, closest and farthest leftover pairs sit above the
diff --git a/docs/adr/0162-leftover-residual-disclosure.md b/docs/adr/0162-leftover-residual-disclosure.md
new file mode 100644
index 000000000..fc790b7ca
--- /dev/null
+++ b/docs/adr/0162-leftover-residual-disclosure.md
@@ -0,0 +1,64 @@
+# ADR 0162 — Disclose leftover residual on period-report pair rows
+
+**Decision status:** Accepted
+**Date:** 2026-08-23
+
+## Context
+
+ADR 0048 already persists `leftover_distance` (Euclidean leftover-map
+gap from the Gabriel biplot of `R`) 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 (ADR 0121 /
+PR #481) and does not land Post quality on the leftover criterion
+(ADR 0125 / PR #485).
+
+## Decision
+
+Each leftover pair button shows:
+
+1. closest or farthest label, post title, and criterion short label;
+2. leftover residual `R` with an explicit sign, two decimal places;
+3. observed `Y`, expected `E`, full leftover-map rank, and two-axis
+ distance `d` when those later ADR 0163/0164 fields are available;
+4. one next action that retains every available measurement before
+ opening the post to read the named criterion.
+
+Missing leftover rows still render nothing. 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`, `Y`, `E`, rank, and `d`; click opens that post.
+
+## Related
+
+Amends [ADR 0049](0049-leftover-pair-report-ui.md). Depends on
+[ADR 0048](0048-persist-lsirm-leftover-pairs.md) and
+[ADR 0003](0003-fast-mlsirm-report-integration.md). Independent of
+leftover interaction-map persistence and leftover-criterion evaluation
+landing.
+
+## 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/docs/storybook-inventory.md b/docs/storybook-inventory.md
index 5b0fe143b..9a38d2837 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -11,6 +11,7 @@ buyer-facing control you can click before changing product CSS.
| `Admin/AdminPanel` | Change the tenant brand name, then verify the saved or failed state before leaving settings. | `--surface`, `--border`, `--space-panel-block`, `AdminPanel` |
| `Lineage/LineageDag` | Open the current branch node; compare empty, grouped/forked, ungrouped, and long-title states before changing graph CSS. | `--surface`, `--border`, `LineageDag` |
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
+| `Reports/LeftoverPairList` | Read residual R, observed Y, expected E, map rank, and distance after IRT main effects, then open the named post. | `--color-chip-border`, `LeftoverPairList` |
Repeated web objects must use `frontend/src/styles/tokens.css` and a module
under `frontend/src/components/`. Do not add a second Node package manager;
diff --git a/frontend/package.json b/frontend/package.json
index cc9f4ce0e..02f50d38d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "2.12.15",
+ "version": "2.12.16",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 0b8a5ab51..9ff79a27e 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -3373,6 +3373,7 @@ describe("App, authenticated", () => {
expect(closestPair).toHaveTextContent(
"Read leftover map rank 1, observed Y 2.40, and expected E 2.00 after IRT main effects, then open this post.",
);
+ expect(closestPair).toHaveTextContent("R +0.40");
expect(closestPair).toHaveTextContent("Y 2.40 · E 2.00");
expect(closestPair).toHaveTextContent("rank 1");
expect(closestPair).toHaveTextContent("d 0.12");
@@ -3380,6 +3381,7 @@ describe("App, authenticated", () => {
expect(farthestPair).toHaveTextContent(
"Read leftover map rank 1, observed Y 0.90, and expected E 2.00 after IRT main effects, then open this post.",
);
+ expect(farthestPair).toHaveTextContent("R −1.10");
expect(farthestPair).toHaveTextContent("Y 0.90 · E 2.00");
expect(farthestPair).toHaveTextContent("rank 1");
expect(farthestPair).toHaveTextContent("d 1.84");
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index fd8d97c35..39446abf8 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,4 +1,5 @@
import { AdminPanel } from "./components/AdminPanel";
+import { LeftoverPairList } from "./components/LeftoverPairList";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useAuth } from "react-oidc-context";
@@ -102,8 +103,6 @@ import {
useLocale,
} from "./i18n";
import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl";
-import { formatLeftoverObservedExpected } from "./leftoverObservedExpected";
-import { formatLeftoverMapRank, LEFTOVER_RANK_STRUCTURE_ACTION, LEFTOVER_RANK_ZERO_ACTION } from "./leftoverMapRank";
import "./App.css";
function orchestratorUnavailableMessage(err: unknown, action: string): string {
@@ -3392,80 +3391,11 @@ function ReportsPanel({
)}
{report.leftover_pairs && report.leftover_pairs.length > 0 && (
-
- {report.leftover_pairs.map((pair) => {
- const kindLabel =
- pair.pair_kind === "farthest" ? t("Farthest leftover") : t("Closest leftover");
- const criterion = criterionShortLabel(pair.criterion_code);
- const observedExpected = formatLeftoverObservedExpected(
- pair.observed_response,
- pair.expected_response,
- );
- const rankBadge = formatLeftoverMapRank(pair.leftover_map_rank);
- let nextAction: string;
- if (rankBadge !== null && observedExpected !== null) {
- nextAction =
- pair.leftover_map_rank === 0
- ? tf(
- "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.",
- {
- observed: Number(pair.observed_response).toFixed(2),
- expected: Number(pair.expected_response).toFixed(2),
- },
- )
- : tf(
- "Read leftover map rank {rank}, observed Y {observed}, and expected E {expected} after IRT main effects, then open this post.",
- {
- rank: String(pair.leftover_map_rank),
- observed: Number(pair.observed_response).toFixed(2),
- expected: Number(pair.expected_response).toFixed(2),
- },
- );
- } else if (rankBadge !== null) {
- nextAction =
- pair.leftover_map_rank === 0
- ? t(LEFTOVER_RANK_ZERO_ACTION)
- : tf(LEFTOVER_RANK_STRUCTURE_ACTION, {
- rank: String(pair.leftover_map_rank),
- });
- } else if (observedExpected !== null) {
- nextAction = tf(
- "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.",
- {
- observed: Number(pair.observed_response).toFixed(2),
- expected: Number(pair.expected_response).toFixed(2),
- },
- );
- } else {
- nextAction =
- pair.pair_kind === "farthest"
- ? t("Open this post to read the criterion it sat farthest from after main effects.")
- : t("Open this post to read the criterion it sat closest to after main effects.");
- }
- return (
-
diff --git a/frontend/src/components/LeftoverPairList.stories.tsx b/frontend/src/components/LeftoverPairList.stories.tsx
new file mode 100644
index 000000000..680640026
--- /dev/null
+++ b/frontend/src/components/LeftoverPairList.stories.tsx
@@ -0,0 +1,48 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { LeftoverPairList } from "./LeftoverPairList";
+
+const meta = {
+ title: "Reports/LeftoverPairList",
+ component: LeftoverPairList,
+ args: {
+ criterionLabel: (code: string) =>
+ code === "sales_lead_quality" ? "sales-lead" : "negative",
+ onSelectPost: () => undefined,
+ pairs: [
+ {
+ pair_kind: "closest",
+ post_id: "post-demo-public",
+ post_title: "Public post",
+ criterion_code: "sales_lead_quality",
+ leftover_distance: 0.12,
+ leftover_residual: 0.4,
+ observed_response: 2.4,
+ expected_response: 2.0,
+ leftover_map_rank: 1,
+ },
+ {
+ pair_kind: "farthest",
+ post_id: "post-demo-spec",
+ post_title: "Specification revision requested",
+ criterion_code: "negative_sentiment",
+ leftover_distance: 1.84,
+ leftover_residual: -1.1,
+ observed_response: 0.9,
+ expected_response: 2.0,
+ leftover_map_rank: 1,
+ },
+ ],
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const ClosestAndFarthest: Story = {};
+
+export const Empty: Story = {
+ args: {
+ pairs: [],
+ },
+};
diff --git a/frontend/src/components/LeftoverPairList.test.tsx b/frontend/src/components/LeftoverPairList.test.tsx
new file mode 100644
index 000000000..19e306040
--- /dev/null
+++ b/frontend/src/components/LeftoverPairList.test.tsx
@@ -0,0 +1,128 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import type { LeftoverPair } from "../api";
+import { LeftoverPairList } from "./LeftoverPairList";
+
+const PAIRS: LeftoverPair[] = [
+ {
+ pair_kind: "closest",
+ post_id: "post-demo-public",
+ post_title: "Public post",
+ criterion_code: "sales_lead_quality",
+ leftover_distance: 0.12,
+ leftover_residual: 0.4,
+ observed_response: 2.4,
+ expected_response: 2.0,
+ leftover_map_rank: 1,
+ },
+ {
+ pair_kind: "farthest",
+ post_id: "post-demo-spec",
+ post_title: "Specification revision requested",
+ criterion_code: "negative_sentiment",
+ leftover_distance: 1.84,
+ leftover_residual: -1.1,
+ observed_response: 0.9,
+ expected_response: 2.0,
+ leftover_map_rank: 1,
+ },
+];
+
+function criterionLabel(code: string): string {
+ return code === "sales_lead_quality" ? "sales-lead" : "negative";
+}
+
+describe("LeftoverPairList", () => {
+ it("names leftover residual so the next click opens that post", async () => {
+ const onSelectPost = vi.fn();
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText("Leftover pairs")).toBeInTheDocument();
+ const closest = screen.getByRole("button", {
+ name: "Open leftover closest pair: Public post · sales-lead",
+ });
+ expect(closest).toHaveTextContent("Closest leftover: Public post · sales-lead");
+ expect(closest).toHaveTextContent(
+ "Read leftover map rank 1, observed Y 2.40, and expected E 2.00 after IRT main effects, then open this post.",
+ );
+ expect(closest).toHaveTextContent("R +0.40");
+ expect(closest).toHaveTextContent("Y 2.40 · E 2.00");
+ expect(closest).toHaveTextContent("rank 1");
+ expect(closest).toHaveTextContent("d 0.12");
+
+ const farthest = screen.getByRole("button", {
+ name: "Open leftover farthest pair: Specification revision requested · negative",
+ });
+ expect(farthest).toHaveTextContent("R −1.10");
+ expect(farthest).toHaveTextContent("Y 0.90 · E 2.00");
+ expect(farthest).toHaveTextContent("rank 1");
+ expect(farthest).toHaveTextContent("d 1.84");
+ expect(farthest).toHaveTextContent(
+ "Read leftover map rank 1, observed Y 0.90, and expected E 2.00 after IRT main effects, then open this post.",
+ );
+
+ await userEvent.click(closest);
+ expect(onSelectPost).toHaveBeenCalledWith("post-demo-public");
+ });
+
+ it("keeps residual guidance for an older payload without rank or Y/E", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("button")).toHaveTextContent(
+ "Leftover residual R +0.40 after IRT main effects. Open this post to read sales-lead.",
+ );
+ });
+
+ it.each([
+ [
+ "rank-zero observed evidence",
+ { observed_response: 1, expected_response: 1, leftover_map_rank: 0 },
+ "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y 1.00 and expected E 1.00, then open this post.",
+ ],
+ [
+ "rank-only evidence",
+ { observed_response: null, expected_response: null, leftover_map_rank: 2 },
+ "Leftover map rank 2 after IRT main effects. Open this post.",
+ ],
+ [
+ "rank-zero-only evidence",
+ { observed_response: null, expected_response: null, leftover_map_rank: 0 },
+ "Leftover map has no leftover structure after IRT main effects. Open this post.",
+ ],
+ [
+ "observed and expected evidence",
+ { observed_response: 2.4, expected_response: 2, leftover_map_rank: null },
+ "Read observed Y 2.40 and expected E 2.00 after IRT main effects, then open this post.",
+ ],
+ ] as const)("selects the next action for %s", (_label, evidence, expectedAction) => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("button")).toHaveTextContent(expectedAction);
+ });
+
+ it("renders nothing when leftover pairs are missing", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/frontend/src/components/LeftoverPairList.tsx b/frontend/src/components/LeftoverPairList.tsx
new file mode 100644
index 000000000..f718b6369
--- /dev/null
+++ b/frontend/src/components/LeftoverPairList.tsx
@@ -0,0 +1,114 @@
+import type { LeftoverPair } from "../api";
+import { t, tf } from "../i18n";
+import {
+ formatLeftoverMapRank,
+ LEFTOVER_RANK_STRUCTURE_ACTION,
+ LEFTOVER_RANK_ZERO_ACTION,
+} from "../leftoverMapRank";
+import { formatLeftoverObservedExpected } from "../leftoverObservedExpected";
+import { formatLeftoverResidual } from "../leftoverResidual";
+
+export type LeftoverPairListProps = {
+ pairs: LeftoverPair[];
+ criterionLabel: (criterionCode: string) => string;
+ onSelectPost: (postId: string) => void;
+};
+
+/**
+ * Closest and farthest leftover post–criterion pairs after IRT main effects.
+ *
+ * Distance is the two-axis leftover-map Euclidean gap. Residual is
+ * ``R = Y − E[Y|θ, item]`` (Jeon et al., 2021, eq. 3 input).
+ * The action preserves residual, observed/expected, and full-rank
+ * amendments together before opening the named post.
+ */
+export function LeftoverPairList({
+ pairs,
+ criterionLabel,
+ onSelectPost,
+}: LeftoverPairListProps) {
+ if (pairs.length === 0) {
+ return null;
+ }
+ return (
+
+ {pairs.map((pair) => {
+ const kindLabel =
+ pair.pair_kind === "farthest" ? t("Farthest leftover") : t("Closest leftover");
+ const criterion = criterionLabel(pair.criterion_code);
+ const residual = formatLeftoverResidual(pair.leftover_residual);
+ const observedExpected = formatLeftoverObservedExpected(
+ pair.observed_response,
+ pair.expected_response,
+ );
+ const rankBadge = formatLeftoverMapRank(pair.leftover_map_rank);
+ let nextAction: string;
+ if (rankBadge !== null && observedExpected !== null) {
+ nextAction =
+ pair.leftover_map_rank === 0
+ ? tf(
+ "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.",
+ {
+ observed: Number(pair.observed_response).toFixed(2),
+ expected: Number(pair.expected_response).toFixed(2),
+ },
+ )
+ : tf(
+ "Read leftover map rank {rank}, observed Y {observed}, and expected E {expected} after IRT main effects, then open this post.",
+ {
+ rank: String(pair.leftover_map_rank),
+ observed: Number(pair.observed_response).toFixed(2),
+ expected: Number(pair.expected_response).toFixed(2),
+ },
+ );
+ } else if (rankBadge !== null) {
+ nextAction =
+ pair.leftover_map_rank === 0
+ ? t(LEFTOVER_RANK_ZERO_ACTION)
+ : tf(LEFTOVER_RANK_STRUCTURE_ACTION, {
+ rank: String(pair.leftover_map_rank),
+ });
+ } else if (observedExpected !== null) {
+ nextAction = tf(
+ "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.",
+ {
+ observed: Number(pair.observed_response).toFixed(2),
+ expected: Number(pair.expected_response).toFixed(2),
+ },
+ );
+ } else {
+ nextAction = tf(
+ "Leftover residual R {residual} after IRT main effects. Open this post to read {criterion}.",
+ { residual, criterion },
+ );
+ }
+ return (
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts
index 2fa306247..553f3f148 100644
--- a/frontend/src/i18n.test.ts
+++ b/frontend/src/i18n.test.ts
@@ -38,6 +38,8 @@ describe("i18n", () => {
"Leftover pairs",
"Closest leftover",
"Farthest leftover",
+ "Leftover residual R {residual} after IRT main effects. Open this post to read {criterion}.",
+ "Open leftover {kind} pair: {title} · {criterion}",
"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.",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.",
@@ -85,6 +87,21 @@ describe("i18n", () => {
expect(tf("{post} is current in Event Lineage. Read Keyman and evaluation next.", { post: "DEMO" })).toBe(expected);
});
+ it.each([
+ ["ko", "IRT 주효과 이후 잔여 R +0.40. sales-lead 기준을 읽으려면 이 글을 여세요."],
+ ["zh", "IRT 主效应后的残余 R +0.40。打开这篇帖子阅读 sales-lead。"],
+ ["ja", "IRT主効果後の残差 R +0.40。この投稿を開いて sales-lead を読んでください。"],
+ ["vi", "Phần dư R +0.40 sau hiệu ứng chính IRT. Mở bài viết này để đọc sales-lead."],
+ ] as const)("formats leftover residual next action in %s", (locale, expected) => {
+ setLocale(locale);
+ expect(
+ tf(
+ "Leftover residual R {residual} after IRT main effects. Open this post to read {criterion}.",
+ { residual: "+0.40", criterion: "sales-lead" },
+ ),
+ ).toBe(expected);
+ });
+
it.each([
["ko", "IRT 주효과 이후 관측 Y 2.40와 기대 E 2.00를 읽은 다음, 이 글을 여세요."],
["zh", "阅读 IRT 主效应后的观测 Y 2.40 与期望 E 2.00,然后打开这篇帖子。"],
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index d3405a81e..f448aee62 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -359,6 +359,10 @@ const TRANSLATIONS: Partial>> = {
"Leftover pairs": "잔여 쌍",
"Closest leftover": "가장 가까운 잔여",
"Farthest leftover": "가장 먼 잔여",
+ "Leftover residual R {residual} after IRT main effects. Open this post to read {criterion}.":
+ "IRT 주효과 이후 잔여 R {residual}. {criterion} 기준을 읽으려면 이 글을 여세요.",
+ "Open leftover {kind} pair: {title} · {criterion}":
+ "잔여 {kind} 쌍 열기: {title} · {criterion}",
"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.":
@@ -714,6 +718,10 @@ const TRANSLATIONS: Partial>> = {
"Leftover pairs": "残余配对",
"Closest leftover": "最近残余",
"Farthest leftover": "最远残余",
+ "Leftover residual R {residual} after IRT main effects. Open this post to read {criterion}.":
+ "IRT 主效应后的残余 R {residual}。打开这篇帖子阅读 {criterion}。",
+ "Open leftover {kind} pair: {title} · {criterion}":
+ "打开残余 {kind} 配对:{title} · {criterion}",
"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.":
@@ -1069,6 +1077,10 @@ const TRANSLATIONS: Partial>> = {
"Leftover pairs": "残差ペア",
"Closest leftover": "最も近い残差",
"Farthest leftover": "最も遠い残差",
+ "Leftover residual R {residual} after IRT main effects. Open this post to read {criterion}.":
+ "IRT主効果後の残差 R {residual}。この投稿を開いて {criterion} を読んでください。",
+ "Open leftover {kind} pair: {title} · {criterion}":
+ "残差 {kind} ペアを開く: {title} · {criterion}",
"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.":
@@ -1424,6 +1436,10 @@ const TRANSLATIONS: Partial>> = {
"Leftover pairs": "Cặp phần dư",
"Closest leftover": "Phần dư gần nhất",
"Farthest leftover": "Phần dư xa nhất",
+ "Leftover residual R {residual} after IRT main effects. Open this post to read {criterion}.":
+ "Phần dư R {residual} 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}",
"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í nằm gần nhất sau hiệu ứng chính.",
"Open this post to read the criterion it sat farthest from after main effects.":
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..e21fe98d7
--- /dev/null
+++ b/frontend/src/leftoverResidual.ts
@@ -0,0 +1,14 @@
+/** Signed leftover residual ``R = Y − E[Y|θ, item]`` after IRT main effects. */
+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/pyproject.toml b/pyproject.toml
index c97a8c871..0f3f0dc79 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "lineageweave"
-version = "2.12.15"
+version = "2.12.16"
description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication."
readme = "README.md"
license = { text = "MIT" }
diff --git a/uv.lock b/uv.lock
index 822441ca0..cf5db11aa 100644
--- a/uv.lock
+++ b/uv.lock
@@ -454,7 +454,7 @@ wheels = [
[[package]]
name = "lineageweave"
-version = "2.12.15"
+version = "2.12.16"
source = { editable = "." }
dependencies = [
{ name = "certifi" },