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
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions ARCHITECTURE.md
Comment thread
seonghobae marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.d/2.12.16-leftover-residual-disclosure.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 9 additions & 7 deletions docs/adr/0049-leftover-pair-report-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
64 changes: 64 additions & 0 deletions docs/adr/0162-leftover-residual-disclosure.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
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.15",
"version": "2.12.16",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3373,13 +3373,15 @@ 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");
expect(farthestPair).toHaveTextContent("Farthest leftover: Specification revision requested · negative");
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");
Expand Down
82 changes: 6 additions & 76 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -3392,80 +3391,11 @@ function ReportsPanel({
</span>
)}
{report.leftover_pairs && report.leftover_pairs.length > 0 && (
<ul className="ticket-list" aria-label={t("Leftover pairs")}>
{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 (
<li
key={`${pair.pair_kind}:${pair.post_id}:${pair.criterion_code}`}
className="ticket-list-item"
>
<button
className="post-list-item"
aria-label={`Open leftover ${pair.pair_kind} pair: ${pair.post_title} · ${criterion}`}
onClick={() => onSelectPost(pair.post_id)}
>
<span className="ticket-title">
{kindLabel}: {pair.post_title} · {criterion}
</span>
<span className="post-badge">{nextAction}</span>
{observedExpected ? (
<span className="post-badge">{observedExpected}</span>
) : null}
{rankBadge ? <span className="post-badge">{rankBadge}</span> : null}
<span className="post-badge">d {pair.leftover_distance.toFixed(2)}</span>
</button>
</li>
);
})}
</ul>
<LeftoverPairList
pairs={report.leftover_pairs}
criterionLabel={criterionShortLabel}
onSelectPost={(postId) => onSelectPost(postId)}
/>
)}
{report.members.length > 0 && (
<ul className="ticket-list">
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/components/LeftoverPairList.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof LeftoverPairList>;

export default meta;

type Story = StoryObj<typeof meta>;

export const ClosestAndFarthest: Story = {};

export const Empty: Story = {
args: {
pairs: [],
},
};
Loading