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: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,14 +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) are computed in
Period leftover pairs (ADR 0048 / 0049 / 0119 / 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`, and sit above the
member list so a click opens that post.
`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.

`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
8 changes: 4 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -591,8 +591,8 @@ 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) persist to `report_leftover_pair` with observed
`Y` and expected `E[Y|θ, item]`. Results persist to
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
`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 +604,8 @@ 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` and expected `E` after IRT main
effects plus leftover-map distance `d`) above the member list, and the
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
PU / corp / thread comparison -- never a placeholder. TEPP is unchanged.

## Phase 6b: Knowledge Graph as a real Ontology + Semantic Layer
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.d/2.12.15-leftover-map-rank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
## 2.12.15 — Leftover-map rank

- Persist leftover-map rank on leftover post–criterion pairs (ADR 0164).
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.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ 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.15] - 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 0164). Rank 0 names no leftover
structure rather than inventing a leftover score. When observed `Y`
and expected `E` are also present, one localized next action names all
three measurements instead of hiding either amendment.

## [2.12.14] - 2026-08-24

### Added
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ chip name contains `Corporate entity: Demo Corp` and the persisted
mean θ. The period-report panel says Demo Corp is the opened grouping
and to read its mean θ and member posts, then open a post. Those
members land immediately under that next action, ahead of Other Corp
and the week strip. Opening Public post names the next action: read
and the week strip. After `make seed`, leftover closest/farthest pairs
sit above the member list with leftover-map rank; rank 0 names no
leftover structure. Opening Public post names the next action: read
Event Lineage, Keyman, and evaluation on that post. The popup Event
Lineage DAG marks that post current. After that current node, the
popup names Keyman and evaluation as the next read. After landed
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 @@ -605,7 +606,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,
({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context
from report_leftover_pair lp
Expand Down Expand Up @@ -712,6 +713,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"]),
"has_real_source_context": bool(row["has_real_source_context"]),
Expand Down
7 changes: 7 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@
/ "migrations"
/ "0163_report_leftover_observed_expected.sql"
)
_LEFTOVER_MAP_RANK_MIGRATION = (
Path(__file__).resolve().parents[2]
/ "migrations"
/ "0164_report_leftover_map_rank.sql"
)


def _postgres_available() -> bool:
Expand Down Expand Up @@ -233,6 +238,7 @@ def seeded_db(demo_analyst_token):
cur.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text())
cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text())
cur.execute(_LEFTOVER_OBSERVED_EXPECTED_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_RANK_MIGRATION.read_text())
cur.execute(
"insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values "
"('corporate_entity_level', 'group', 'Group'), "
Expand Down Expand Up @@ -4672,6 +4678,7 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token,
assert all(pair["post_title"] for pair in high_report.get("leftover_pairs", []))
assert all(pair["leftover_distance"] >= 0 for pair in high_report.get("leftover_pairs", []))
for pair in high_report.get("leftover_pairs", []):
assert pair["leftover_map_rank"] >= 0
observed = pair.get("observed_response")
expected = pair.get("expected_response")
if observed is None or expected is None:
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 @@ -19,7 +19,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do
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_*) ;;
0163_*) ;;
0163_*|0164_*) ;;
*) continue ;;
esac
printf 'Applying %s\n' "$migration_name"
Expand Down
6 changes: 4 additions & 2 deletions docs/adr/0048-persist-lsirm-leftover-pairs.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
**Decision status:** Accepted
**Date:** 2026-08-17
**Amended by:** [ADR 0119](0119-leftover-map-two-dimensional-distance.md) (two leftover-map axes);
[ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E)
[ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E);
[ADR 0164](0164-leftover-map-rank.md) (full map rank)

## Context

Expand Down Expand Up @@ -35,7 +36,8 @@ The biplot lives in `lineageweave/leftover_pairs.py` so leftover
tests do not import `period_report` or `fast_mlsirm`. Distances are
Euclidean on the two leftover-map axes (ADR 0119). Each leftover row
also names observed `Y` and expected `E[Y|θ, item]` so residual
reconciles to `Y − E` (ADR 0163).
reconciles to `Y − E` (ADR 0163), and names the full singular-value
rank while distance remains on the first two axes (ADR 0164).

Cascade the rows with `report_period_score`. A leftover post must
also be a `report_member_score` row, and the leftover criterion
Expand Down
14 changes: 9 additions & 5 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 0163](0163-leftover-observed-expected.md) (observed Y and expected E);
[ADR 0164](0164-leftover-map-rank.md) (full map rank)

## Context

Expand All @@ -18,11 +19,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, leftover-map distance, observed `Y` and
expected `E` when both are finite, and the next action (“Read observed
Y and expected E after IRT main effects, then open this 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.
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. 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
member list. Click a pair to open that post.
Expand Down
63 changes: 63 additions & 0 deletions docs/adr/0164-leftover-map-rank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# ADR 0164 — 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.

## 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 `0164` 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, 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.14",
"version": "2.12.15",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,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 @@ -952,6 +953,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 @@ -3369,15 +3371,17 @@ describe("App, authenticated", () => {
});
expect(closestPair).toHaveTextContent("Closest leftover: Public post · sales-lead");
expect(closestPair).toHaveTextContent(
"Read observed Y 2.40 and expected E 2.00 after IRT main effects, then open this post.",
"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("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 observed Y 0.90 and expected E 2.00 after IRT main effects, then open this post.",
"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("Y 0.90 · E 2.00");
expect(farthestPair).toHaveTextContent("rank 1");
expect(farthestPair).toHaveTextContent("d 1.84");
const memberButton = screen.getByRole("button", { name: /open report post: public post/i });
expect(closestPair.compareDocumentPosition(memberButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
Expand Down
53 changes: 42 additions & 11 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ import {
} 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 @@ -3400,18 +3401,47 @@ function ReportsPanel({
pair.observed_response,
pair.expected_response,
);
const nextAction =
observedExpected === null
? pair.pair_kind === "farthest"
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.")
: 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),
},
);
: 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}`}
Expand All @@ -3429,6 +3459,7 @@ function ReportsPanel({
{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>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,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
Loading