diff --git a/AGENTS.md b/AGENTS.md
index b34194c8d..1b5b7cc58 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -192,15 +192,17 @@ 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 / 0162 / 0163 / 0164) are computed in
-`lineageweave/leftover_pairs.py` from the residual after a real
+Period leftover pairs (ADR 0048 / 0049 / 0119 / 0126 / 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 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.
+above the member list so a click opens that post. Pair-member
+leftover-map criterion nodes (ADR 0126) also open that leftover-pair
+post; non-pair criteria stay non-interactive.
`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 fe6a94941..da1c79500 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -596,7 +596,9 @@ 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 / 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
+leftover-map rank. Complete-case leftover-map coordinates ξ / ζ
+(ADR 0121) persist to `report_leftover_map_person` and
+`report_leftover_map_item`. Results persist to
`report_period_score` / `report_member_score`.
`GET /api/reports/{grouping}` lists the trend;
`GET /api/reports/{grouping}/{period}` is ABAC-filtered;
@@ -610,7 +612,9 @@ open ticket title, status lookup label, and due date when one exists. The home p
the actual mean θ, the FIPC delta, the CAT-selected item, leftover
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
+effects) and the leftover interaction map above the member list
+(click a leftover-map person or pair-member criterion node to open
+that post), 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.10-leftover-map-criterion-node.md b/CHANGELOG.d/2.12.10-leftover-map-criterion-node.md
new file mode 100644
index 000000000..4c217cabb
--- /dev/null
+++ b/CHANGELOG.d/2.12.10-leftover-map-criterion-node.md
@@ -0,0 +1,8 @@
+## 2.12.10 — Leftover-map criterion nodes
+
+- Pair-member leftover-map criterion nodes open the leftover-pair post
+ (closest preferred, then farthest). Non-pair criteria stay
+ non-interactive diamonds so the map does not invent a next action
+ (ADR 0126).
+- Keyboard Enter/Space matches leftover-map person nodes. Click does
+ not land leftover focus on Post quality.
diff --git a/CHANGELOG.d/2.12.7-leftover-interaction-map.md b/CHANGELOG.d/2.12.7-leftover-interaction-map.md
new file mode 100644
index 000000000..9085a288f
--- /dev/null
+++ b/CHANGELOG.d/2.12.7-leftover-interaction-map.md
@@ -0,0 +1,12 @@
+## 2.12.7 — Leftover interaction map
+
+- Persist complete-case leftover-map coordinates ξ / ζ after IRT main
+ effects (Jeon et al., 2021; Gabriel, 1971) in
+ `report_leftover_map_person` and `report_leftover_map_item` (ADR 0121).
+- Render a 2D leftover interaction map above the leftover pair list.
+ Click a post node to open that post. Closest and farthest pairs stay
+ highlighted. Hidden posts stay hidden.
+- Rank-0 and rank-1 maps pad the unused axis with zero. Missing residual
+ cells never enter the factorization as zero.
+- Replay `0103_tenant_settings.sql` and
+ `0172_report_leftover_interaction_map.sql` on existing volumes.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9d0005ecd..5880d5fb7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -98,6 +98,25 @@ All notable changes to this project are documented here. Format follows
leftover pairs above the member list still open that post. Never
invent a leftover score or a theta.
+## [2.12.10] - 2026-08-23
+
+### Added
+
+- Pair-member leftover-map criterion nodes now open the leftover-pair
+ post (closest preferred, then farthest). Non-pair criteria stay
+ non-interactive. Click does not set leftover focus on Post quality
+ (ADR 0126).
+
+## [2.12.7] - 2026-08-23
+
+### Added
+
+- Period reports now persist leftover interaction-map coordinates
+ (Jeon et al. 2021 ξ / ζ after IRT main effects) and render a 2D
+ biplot above the leftover pair list. Click a post node to open that
+ post. Rank-0 and rank-1 maps pad the unused axis with zero; missing
+ cells stay out of the factorization (ADR 0121).
+
## [2.12.6] - 2026-08-20
### Added
diff --git a/backend/app/main.py b/backend/app/main.py
index 0e6fb68f2..a3f90e0a7 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -2371,6 +2371,12 @@ async def read_period_reports(
if _can_see_post(account, pair)
and not _is_synthetic_demo_member(pair, demo_entity_ids)
]
+ leftover_map_persons = [
+ person
+ for person in report.get("leftover_map_persons", [])
+ if _can_see_post(account, person)
+ and not _is_synthetic_demo_member(person, demo_entity_ids)
+ ]
members = [
{key: value for key, value in member.items() if key != "has_real_source_context"}
for member in members
@@ -2379,8 +2385,20 @@ async def read_period_reports(
{key: value for key, value in pair.items() if key != "has_real_source_context"}
for pair in leftover_pairs
]
+ leftover_map_persons = [
+ {key: value for key, value in person.items() if key != "has_real_source_context"}
+ for person in leftover_map_persons
+ ]
+ leftover_map_items = list(report.get("leftover_map_items", []))
visible.append(
- {**report, "members": members, "leftover_pairs": leftover_pairs, "post_count": len(members)}
+ {
+ **report,
+ "members": members,
+ "leftover_pairs": leftover_pairs,
+ "leftover_map_persons": leftover_map_persons,
+ "leftover_map_items": leftover_map_items,
+ "post_count": len(members),
+ }
)
return {"grouping_kind": grouping_kind, "period_code": period_code, "reports": visible}
diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py
index b714f3667..7ea9bcf7e 100644
--- a/backend/app/report_ingestion.py
+++ b/backend/app/report_ingestion.py
@@ -351,7 +351,7 @@ async def persist_period_report(
period_code: str,
report: PeriodReport,
) -> None:
- """Replace the stored report, member scores, leftover pairs, and item bank."""
+ """Replace the stored report, member scores, leftover pairs, leftover map, and item bank."""
await conn.execute(
"""
delete from report_period_score
@@ -460,6 +460,38 @@ async def persist_period_report(
pair.expected_response,
pair.leftover_map_rank,
)
+ for person in report.leftover_map_persons:
+ await conn.execute(
+ """
+ insert into report_leftover_map_person (
+ grouping_kind, grouping_key, period_code, rubric_version,
+ post_id, axis_one, axis_two
+ ) values ($1,$2,$3,$4,$5,$6,$7)
+ """,
+ grouping_kind,
+ grouping_key,
+ period_code,
+ RUBRIC_VERSION,
+ person.post_id,
+ person.axis_one,
+ person.axis_two,
+ )
+ for item in report.leftover_map_items:
+ await conn.execute(
+ """
+ insert into report_leftover_map_item (
+ grouping_kind, grouping_key, period_code, rubric_version,
+ criterion_code, axis_one, axis_two
+ ) values ($1,$2,$3,$4,$5,$6,$7)
+ """,
+ grouping_kind,
+ grouping_key,
+ period_code,
+ RUBRIC_VERSION,
+ item.criterion_code,
+ item.axis_one,
+ item.axis_two,
+ )
def _groups_from_rows(
@@ -620,6 +652,32 @@ async def fetch_period_reports(
period_code,
RUBRIC_VERSION,
)
+ # Safe SQL: the source-context expression is an immutable schema fragment; report keys are bound.
+ leftover_map_persons = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ f"""
+ select lp.grouping_key, lp.post_id, lp.axis_one, lp.axis_two, p.post_title,
+ p.visibility_code, p.corporate_entity_id,
+ ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context
+ from report_leftover_map_person lp
+ join source_post p on p.post_id = lp.post_id
+ where lp.grouping_kind = $1 and lp.period_code = $2 and lp.rubric_version = $3
+ order by lp.grouping_key, p.post_title
+ """,
+ grouping_kind,
+ period_code,
+ RUBRIC_VERSION,
+ )
+ leftover_map_items = await conn.fetch(
+ """
+ select grouping_key, criterion_code, axis_one, axis_two
+ from report_leftover_map_item
+ where grouping_kind = $1 and period_code = $2 and rubric_version = $3
+ order by grouping_key, criterion_code
+ """,
+ grouping_kind,
+ period_code,
+ RUBRIC_VERSION,
+ )
status_labels = await labels_for_codes(
conn,
[row["ticket_status_code"] for row in members if row["ticket_status_code"]],
@@ -633,6 +691,12 @@ async def fetch_period_reports(
leftover_by_group: dict[str, list[asyncpg.Record]] = defaultdict(list)
for row in leftover:
leftover_by_group[row["grouping_key"]].append(row)
+ leftover_persons_by_group: dict[str, list[asyncpg.Record]] = defaultdict(list)
+ for row in leftover_map_persons:
+ leftover_persons_by_group[row["grouping_key"]].append(row)
+ leftover_items_by_group: dict[str, list[asyncpg.Record]] = defaultdict(list)
+ for row in leftover_map_items:
+ leftover_items_by_group[row["grouping_key"]].append(row)
payload: list[dict[str, Any]] = []
for header in headers:
grouping_key = header["grouping_key"]
@@ -724,6 +788,26 @@ async def fetch_period_reports(
}
for row in leftover_by_group.get(header["grouping_key"], [])
],
+ "leftover_map_persons": [
+ {
+ "post_id": str(row["post_id"]),
+ "post_title": row["post_title"],
+ "axis_one": float(row["axis_one"]),
+ "axis_two": float(row["axis_two"]),
+ "visibility_code": row["visibility_code"],
+ "corporate_entity_id": str(row["corporate_entity_id"]),
+ "has_real_source_context": bool(row["has_real_source_context"]),
+ }
+ for row in leftover_persons_by_group.get(header["grouping_key"], [])
+ ],
+ "leftover_map_items": [
+ {
+ "criterion_code": str(row["criterion_code"]),
+ "axis_one": float(row["axis_one"]),
+ "axis_two": float(row["axis_two"]),
+ }
+ for row in leftover_items_by_group.get(header["grouping_key"], [])
+ ],
}
)
return payload
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index cef8eafb8..2e1fae81f 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -132,6 +132,11 @@
/ "migrations"
/ "0164_report_leftover_map_rank.sql"
)
+_LEFTOVER_MAP_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0172_report_leftover_interaction_map.sql"
+)
def _postgres_available() -> bool:
@@ -249,6 +254,7 @@ def seeded_db(demo_analyst_token):
cur.execute(_CHANNEL_WEIGHT_MIGRATION.read_text())
cur.execute(_LEFTOVER_OBSERVED_EXPECTED_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_RANK_MIGRATION.read_text())
+ cur.execute(_LEFTOVER_MAP_MIGRATION.read_text())
cur.execute(
"insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values "
"('corporate_entity_level', 'group', 'Group'), "
@@ -4732,6 +4738,18 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token,
assert leftover_kinds <= {"closest", "farthest"}
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", []))
+ leftover_map_persons = high_report.get("leftover_map_persons", [])
+ leftover_map_items = high_report.get("leftover_map_items", [])
+ member_ids = {member["post_id"] for member in high_report["members"]}
+ item_codes = {item["item_code"] for item in high_report.get("selected_items", [])}
+ assert leftover_map_persons
+ assert leftover_map_items
+ assert all(person["post_title"] for person in leftover_map_persons)
+ assert {person["post_id"] for person in leftover_map_persons} <= member_ids
+ assert {item["criterion_code"] for item in leftover_map_items} <= item_codes
+ for point in leftover_map_persons + leftover_map_items:
+ assert isinstance(point["axis_one"], (int, float))
+ assert isinstance(point["axis_two"], (int, float))
for pair in high_report.get("leftover_pairs", []):
assert pair["leftover_map_rank"] >= 0
observed = pair.get("observed_response")
diff --git a/docs/adr/0003-fast-mlsirm-report-integration.md b/docs/adr/0003-fast-mlsirm-report-integration.md
index bdf234b65..5977cd62f 100644
--- a/docs/adr/0003-fast-mlsirm-report-integration.md
+++ b/docs/adr/0003-fast-mlsirm-report-integration.md
@@ -100,7 +100,7 @@ than one large PR:
`information_polytomous` (Lord, 1980 max-info). Persist the ranking
(`report_item_information`) and show the rank-1 item on the Period
reports panel. Do not reimplement an information function here.
-7. **Leftover-pair slice** (shipped in 0.71.2; ADR 0017 / 0018): after
+7. **Leftover-pair slice** (shipped in 0.71.2; ADR 0048 / 0049): after
IRT main effects, persist closest and farthest post–criterion pairs
from the residual leftover map. Do not fork LSIRM; do not invent a
leftover-pair API inside `fast-mlsirm` in this slice.
diff --git a/docs/adr/0121-persist-leftover-interaction-map.md b/docs/adr/0121-persist-leftover-interaction-map.md
new file mode 100644
index 000000000..245cccfa0
--- /dev/null
+++ b/docs/adr/0121-persist-leftover-interaction-map.md
@@ -0,0 +1,76 @@
+# ADR 0121 — Persist leftover interaction-map coordinates
+
+**Decision status:** Accepted
+**Date:** 2026-08-23
+
+## Context
+
+ADR 0048 persists the closest and farthest leftover post–criterion
+pairs after IRT main effects. Those pairs are two cells on the Jeon
+et al. (2021, eq. 3) leftover interaction map `−γ‖ξ_p − ζ_i‖`. The
+Gabriel (1971) biplot that produces the pairs already computes person
+positions `ξ` and item positions `ζ`, then discards them. A buyer
+who sees only two named pairs cannot see *why* those cells sat
+closest or farthest, or where the other complete-case posts and
+criteria sit on the same leftover map.
+
+`fast-mlsirm` still exposes no leftover-map API. LineageWeave must
+not fork LSIRM, invent a second IRT fit, or treat a missing residual
+cell as a zero residual.
+
+## Decision
+
+After a real GRM/GPCM score, keep the complete-case Gabriel
+coordinates that leftover pairs already use. Persist every complete-
+case post as `report_leftover_map_person` (`axis_one`, `axis_two`)
+and every complete-case criterion as `report_leftover_map_item`.
+Pad unused axes with zero when residual rank is below two. Do not
+invent a second component. Closest/farthest selection and persisted
+distance use those same two buyer-visible axes; unpersisted higher
+components never silently change a highlighted map pair. Incomplete
+rows and columns stay out of the factorization.
+
+Cascade the rows with `report_period_score`. A leftover-map post
+must also be a `report_member_score` row. A leftover-map criterion
+must be a `report_item_information` item on that same report. Do not
+store a second theta. A rank-0 residual still emits origin
+coordinates so `make seed` is not empty; those zeros are not a
+fabricated interaction.
+
+Closest and farthest pairs remain ADR 0048 / ADR 0049. The map sits
+**above** that pair list on the period-report group. Clicking a
+person node opens that post with the same handler as a leftover
+pair. Pair-member criterion nodes open that leftover-pair post
+([ADR 0126](0126-leftover-map-criterion-node.md)). Hidden posts stay hidden: leftover-map persons join
+`source_post` and use the same ABAC gate as members and leftover
+pairs. Missing map rows render nothing.
+
+The biplot remains in `lineageweave/leftover_pairs.py` so leftover
+tests do not import `period_report` or `fast_mlsirm`.
+
+## Consequences
+
+Rebuild and seed write leftover-map coordinates in the same
+transaction as leftover pairs. `GET /api/reports/{grouping}/{period}`
+returns `leftover_map_persons` (with post title) and
+`leftover_map_items`. Migration
+`0172_report_leftover_interaction_map.sql` upgrades volumes that
+already applied `0001` / `0012`. `migrate.sh` also replays `0103`
+(`tenant_settings`) so existing volumes pick up that table.
+
+## Related
+
+Depends on [ADR 0048](0048-persist-lsirm-leftover-pairs.md),
+[ADR 0049](0049-leftover-pair-report-ui.md), and
+[ADR 0003](0003-fast-mlsirm-report-integration.md).
+
+## 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/adr/0126-leftover-map-criterion-node.md b/docs/adr/0126-leftover-map-criterion-node.md
new file mode 100644
index 000000000..8e119fe9a
--- /dev/null
+++ b/docs/adr/0126-leftover-map-criterion-node.md
@@ -0,0 +1,60 @@
+# ADR 0126 — Leftover-map criterion nodes open the leftover-pair post
+
+**Decision status:** Accepted
+**Date:** 2026-08-23
+
+## Context
+
+ADR 0121 persists leftover interaction-map coordinates and renders a
+2D Gabriel biplot above leftover pairs. Person (post) nodes are
+buttons that open that post. Criterion (item) nodes are diamonds
+without a next action: a buyer who sees a highlighted closest or
+farthest criterion cannot act on it.
+
+ADR 0125 lands leftover-pair *list* clicks on Post quality with a
+leftover-focus flag. This increment is independent of that landing.
+The map criterion node opens the leftover-pair post only. It does
+not set leftover focus or `aria-current` on Post quality.
+
+A criterion that is not a leftover-pair member has no buyer next
+action. Inventing a click that opens an arbitrary post would
+fabricate a pair.
+
+## Decision
+
+Export `leftoverPairForCriterion(pairs, criterionCode)`. Prefer the
+closest leftover pair for that criterion, then farthest. If none,
+the criterion stays a non-interactive diamond.
+
+When a pair exists, the criterion node is `role="button"`, keyboard
+activable (Enter / Space), and named `Open leftover map criterion:
+{label}`. Activation calls `onSelectPost(pair.post_id)` — the same
+handler as a leftover-map person node and leftover-pair list button
+on this stack.
+
+Do not pass leftover-focus flags. Hidden posts stay hidden because
+the pair's `post_id` is already ABAC-filtered with leftover pairs.
+
+## Consequences
+
+Buyers can click a highlighted leftover-map criterion and read the
+post that sat closest (or farthest) from it after IRT main effects.
+Non-pair criteria remain visual context on the Gabriel biplot.
+
+## Related
+
+Depends on [ADR 0121](0121-persist-leftover-interaction-map.md),
+[ADR 0048](0048-persist-lsirm-leftover-pairs.md), and
+[ADR 0049](0049-leftover-pair-report-ui.md). Independent of 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 0d729eec7..b1b577290 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -12,6 +12,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/LeftoverInteractionMap` | Click a leftover-pair criterion diamond to open that pair's post. | leftover-map item/person tokens, `LeftoverInteractionMap` |
| `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
diff --git a/frontend/src/App.css b/frontend/src/App.css
index b88ee953a..e88d505e1 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -548,6 +548,90 @@
fill: var(--text-h);
}
+.leftover-interaction-map {
+ margin: 0.5rem 0 0.75rem;
+}
+
+.leftover-interaction-map figcaption {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin-bottom: 0.4rem;
+}
+
+.leftover-interaction-map svg {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--surface);
+}
+
+.leftover-map-pair {
+ stroke-width: 1.5;
+}
+
+.leftover-map-pair.leftover-map-closest {
+ stroke: var(--badge-status-success-text);
+}
+
+.leftover-map-pair.leftover-map-farthest {
+ stroke: var(--badge-status-danger-text);
+}
+
+.leftover-map-person {
+ cursor: pointer;
+}
+
+.leftover-map-person circle {
+ fill: var(--badge-actor-person-bg);
+ stroke: var(--badge-actor-person-text);
+ stroke-width: 1.5;
+}
+
+.leftover-map-item[role="button"] {
+ cursor: pointer;
+}
+
+.leftover-map-item rect {
+ fill: var(--badge-actor-organization-bg);
+ stroke: var(--badge-actor-organization-text);
+ stroke-width: 1.5;
+}
+
+.leftover-map-person.leftover-map-closest circle,
+.leftover-map-item.leftover-map-closest rect {
+ stroke: var(--badge-status-success-text);
+ stroke-width: 2;
+}
+
+.leftover-map-person.leftover-map-farthest circle,
+.leftover-map-item.leftover-map-farthest rect {
+ stroke: var(--badge-status-danger-text);
+ stroke-width: 2;
+}
+
+.leftover-map-person text,
+.leftover-map-item text {
+ font-size: 0.7rem;
+ fill: var(--color-text-heading);
+}
+
+.leftover-map-person:focus {
+ outline: none;
+}
+
+.leftover-map-person:focus circle,
+.leftover-map-person:hover circle {
+ stroke-width: 2.5;
+}
+
+.leftover-map-item[role="button"]:focus {
+ outline: none;
+}
+
+.leftover-map-item[role="button"]:focus rect,
+.leftover-map-item[role="button"]:hover rect {
+ stroke-width: 2.5;
+}
+
.lineage-dag-node:focus {
outline: none;
}
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 065c8ef40..5fe704436 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -966,6 +966,19 @@ describe("App, authenticated", () => {
leftover_map_rank: 1,
},
],
+ leftover_map_persons: [
+ { post_id: "post-1", post_title: "Public post", axis_one: -0.5, axis_two: 0.1 },
+ {
+ post_id: "post-2",
+ post_title: "Specification revision requested",
+ axis_one: 0.8,
+ axis_two: -0.4,
+ },
+ ],
+ leftover_map_items: [
+ { criterion_code: "sales_lead_specificity", axis_one: -0.4, axis_two: 0.05 },
+ { criterion_code: "general_sentiment_negative", axis_one: 1.2, axis_two: -0.9 },
+ ],
members: [
{
post_id: "post-1",
@@ -3542,6 +3555,8 @@ describe("App, authenticated", () => {
expect(screen.getByRole("button", { name: /open report post: public post/i })).toHaveTextContent("Open");
expect(screen.getByRole("button", { name: /open report post: public post/i })).toHaveTextContent("due 2026-01-12");
expect(screen.getByLabelText("Leftover pairs")).toBeInTheDocument();
+ expect(screen.getByLabelText("Leftover interaction map")).toBeInTheDocument();
+ const mapPost = screen.getByRole("button", { name: "Open leftover map post: Public post" });
const closestPair = screen.getByRole("button", { name: /open leftover closest pair: public post/i });
const farthestPair = screen.getByRole("button", {
name: /open leftover farthest pair: specification revision requested/i,
@@ -3563,6 +3578,7 @@ describe("App, authenticated", () => {
expect(farthestPair).toHaveTextContent("rank 1");
expect(farthestPair).toHaveTextContent("d 1.84");
const memberButton = screen.getByRole("button", { name: /open report post: public post/i });
+ expect(mapPost.compareDocumentPosition(closestPair) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(closestPair.compareDocumentPosition(memberButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(
screen.getByRole("button", { name: /open report post: specification revision requested/i }),
@@ -3622,6 +3638,26 @@ describe("App, authenticated", () => {
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
});
+ it("opens a leftover-map post from the report panel", async () => {
+ stubBackend();
+ render();
+
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Open leftover map post: Public post" }),
+ );
+ await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
+ });
+
+ it("opens a leftover-map criterion post from the report panel", async () => {
+ stubBackend();
+ render();
+
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Open leftover map criterion: sales-lead" }),
+ );
+ await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
+ });
+
it("opens Event Lineage, Keyman, and evaluation from a report member click", async () => {
stubBackend();
render();
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 6aa467231..35426eb41 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -91,6 +91,7 @@ import { PopupCloseButton } from "./components/PopupCloseButton";
import { chatEvidenceKindLabel } from "./evidenceKindLabels";
import { BuyerNav, type BuyerDestination } from "./components/BuyerNav";
import { LineageDag } from "./LineageDag";
+import { LeftoverInteractionMap } from "./LeftoverInteractionMap";
import { PostBody } from "./PostBody";
import { decodeHtmlEntities } from "./postBodyDisplay";
import { FiveW1H } from "./components/FiveW1H";
@@ -3409,6 +3410,16 @@ function ReportsPanel({
{report.selected_items[0].information.toFixed(2)}
)}
+ {((report.leftover_map_persons && report.leftover_map_persons.length > 0) ||
+ (report.leftover_map_items && report.leftover_map_items.length > 0)) && (
+
+ )}
{report.leftover_pairs && report.leftover_pairs.length > 0 && (
+ code === "sales_lead_specificity" ? "sales-lead" : code.replaceAll("_", " ");
+
+const meta = {
+ title: "Reports/LeftoverInteractionMap",
+ component: LeftoverInteractionMap,
+ args: {
+ persons: [
+ { post_id: "post-1", post_title: "Public post", axis_one: -0.5, axis_two: 0.1 },
+ {
+ post_id: "post-2",
+ post_title: "Specification revision requested",
+ axis_one: 0.8,
+ axis_two: -0.4,
+ },
+ ],
+ items: [
+ { criterion_code: "sales_lead_specificity", axis_one: -0.4, axis_two: 0.05 },
+ { criterion_code: "general_sentiment_negative", axis_one: 1.2, axis_two: -0.9 },
+ { criterion_code: "general_sentiment_positive", axis_one: 0.1, axis_two: 0.7 },
+ ],
+ pairs: [
+ {
+ pair_kind: "closest",
+ post_id: "post-1",
+ post_title: "Public post",
+ criterion_code: "sales_lead_specificity",
+ leftover_distance: 0.12,
+ leftover_residual: 0.4,
+ },
+ {
+ pair_kind: "farthest",
+ post_id: "post-2",
+ post_title: "Specification revision requested",
+ criterion_code: "general_sentiment_negative",
+ leftover_distance: 1.84,
+ leftover_residual: -1.1,
+ },
+ ],
+ itemLabel,
+ onSelectPost: () => undefined,
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const ClosestFarthest: Story = {};
+
+export const CriterionClick: Story = {
+ args: {
+ items: [{ criterion_code: "sales_lead_specificity", axis_one: -0.4, axis_two: 0.05 }],
+ pairs: [
+ {
+ pair_kind: "closest",
+ post_id: "post-1",
+ post_title: "Public post",
+ criterion_code: "sales_lead_specificity",
+ leftover_distance: 0.12,
+ leftover_residual: 0.4,
+ },
+ ],
+ },
+};
+
+export const OriginPad: Story = {
+ args: {
+ persons: [{ post_id: "post-1", post_title: "Public post", axis_one: 0, axis_two: 0 }],
+ items: [{ criterion_code: "sales_lead_specificity", axis_one: 0, axis_two: 0 }],
+ pairs: [],
+ },
+};
diff --git a/frontend/src/LeftoverInteractionMap.test.tsx b/frontend/src/LeftoverInteractionMap.test.tsx
new file mode 100644
index 000000000..d6a60c8b2
--- /dev/null
+++ b/frontend/src/LeftoverInteractionMap.test.tsx
@@ -0,0 +1,186 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import {
+ LeftoverInteractionMap,
+ leftoverPairForCriterion,
+ projectLeftoverMap,
+} from "./LeftoverInteractionMap";
+import type { LeftoverPair } from "./api";
+
+const itemLabel = (code: string) => (code === "sales_lead_specificity" ? "sales-lead" : code);
+
+const closestPair: LeftoverPair = {
+ pair_kind: "closest",
+ post_id: "post-1",
+ post_title: "Public post",
+ criterion_code: "sales_lead_specificity",
+ leftover_distance: 0.12,
+ leftover_residual: 0.4,
+};
+
+const farthestPair: LeftoverPair = {
+ pair_kind: "farthest",
+ post_id: "post-2",
+ post_title: "Specification revision requested",
+ criterion_code: "general_sentiment_negative",
+ leftover_distance: 1.84,
+ leftover_residual: -1.1,
+};
+
+describe("leftoverPairForCriterion", () => {
+ it("prefers the closest leftover pair for a criterion", () => {
+ const both: LeftoverPair[] = [
+ farthestPair,
+ closestPair,
+ {
+ ...farthestPair,
+ pair_kind: "farthest",
+ post_id: "post-9",
+ criterion_code: "sales_lead_specificity",
+ },
+ ];
+ expect(leftoverPairForCriterion(both, "sales_lead_specificity")).toMatchObject({
+ pair_kind: "closest",
+ post_id: "post-1",
+ });
+ });
+
+ it("uses the farthest leftover pair when the criterion is not closest", () => {
+ expect(leftoverPairForCriterion([closestPair, farthestPair], "general_sentiment_negative")).toMatchObject({
+ pair_kind: "farthest",
+ post_id: "post-2",
+ });
+ });
+
+ it("returns null when the criterion is not a leftover-pair member", () => {
+ expect(leftoverPairForCriterion([closestPair, farthestPair], "general_sentiment_positive")).toBeNull();
+ });
+});
+
+describe("LeftoverInteractionMap", () => {
+ it("projects coincident origin points to the map center", () => {
+ const projected = projectLeftoverMap(
+ [{ post_id: "post-1", post_title: "Public post", axis_one: 0, axis_two: 0 }],
+ [{ criterion_code: "sales_lead_specificity", axis_one: 0, axis_two: 0 }],
+ itemLabel,
+ [],
+ 360,
+ 240,
+ 36,
+ );
+ expect(projected.persons[0]).toMatchObject({ x: 180, y: 120 });
+ expect(projected.items[0]).toMatchObject({ x: 180, y: 120 });
+ });
+
+ it("renders closest and farthest leftover-map nodes and opens a post", async () => {
+ const onSelectPost = vi.fn();
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("group", { name: "Leftover interaction map" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Open leftover map post: Public post" })).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Open leftover map post: Public post" }));
+ expect(onSelectPost).toHaveBeenCalledWith("post-1");
+ });
+
+ it("opens the leftover-pair post from a pair-member criterion node", async () => {
+ const onSelectPost = vi.fn();
+ render(
+ ,
+ );
+
+ const criterion = screen.getByRole("button", { name: "Open leftover map criterion: sales-lead" });
+ expect(criterion).toBeInTheDocument();
+ await userEvent.click(criterion);
+ expect(onSelectPost).toHaveBeenCalledWith("post-1");
+ expect(
+ screen.queryByRole("button", { name: "Open leftover map criterion: general_sentiment_positive" }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("opens a leftover-pair post from a criterion node with the keyboard", async () => {
+ const onSelectPost = vi.fn();
+ render(
+ ,
+ );
+
+ const criterion = screen.getByRole("button", { name: "Open leftover map criterion: sales-lead" });
+ criterion.focus();
+ await userEvent.keyboard("{Enter}");
+ expect(onSelectPost).toHaveBeenCalledWith("post-1");
+ });
+
+ it("preserves closest and farthest emphasis on the same node", () => {
+ const projected = projectLeftoverMap(
+ [{ post_id: "post-1", post_title: "Public post", axis_one: 0, axis_two: 0 }],
+ [
+ { criterion_code: "item-a", axis_one: -1, axis_two: 0 },
+ { criterion_code: "item-b", axis_one: 1, axis_two: 0 },
+ ],
+ itemLabel,
+ [
+ {
+ pair_kind: "closest",
+ post_id: "post-1",
+ post_title: "Public post",
+ criterion_code: "item-a",
+ leftover_distance: 1,
+ leftover_residual: 0,
+ },
+ {
+ pair_kind: "farthest",
+ post_id: "post-1",
+ post_title: "Public post",
+ criterion_code: "item-b",
+ leftover_distance: 1,
+ leftover_residual: 0,
+ },
+ ],
+ );
+
+ expect(projected.persons[0]).toMatchObject({ pairKinds: ["closest", "farthest"] });
+ });
+});
diff --git a/frontend/src/LeftoverInteractionMap.tsx b/frontend/src/LeftoverInteractionMap.tsx
new file mode 100644
index 000000000..77faeccc4
--- /dev/null
+++ b/frontend/src/LeftoverInteractionMap.tsx
@@ -0,0 +1,242 @@
+import type { LeftoverMapItem, LeftoverMapPerson, LeftoverPair } from "./api";
+import { t, tf } from "./i18n";
+
+const MAP_WIDTH = 360;
+const MAP_HEIGHT = 240;
+const MAP_PAD = 36;
+
+export type ProjectedLeftoverPoint = {
+ id: string;
+ label: string;
+ x: number;
+ y: number;
+ kind: "person" | "item";
+ pairKinds: ("closest" | "farthest")[];
+};
+
+function pairKindsFor(
+ id: string,
+ kind: "person" | "item",
+ pairs: LeftoverPair[],
+): ("closest" | "farthest")[] {
+ const kinds = new Set<"closest" | "farthest">();
+ for (const pair of pairs) {
+ const match = kind === "person" ? pair.post_id === id : pair.criterion_code === id;
+ if (!match) continue;
+ if (pair.pair_kind === "closest" || pair.pair_kind === "farthest") {
+ kinds.add(pair.pair_kind);
+ }
+ }
+ return [...kinds];
+}
+
+export function leftoverPairForCriterion(
+ pairs: LeftoverPair[],
+ criterionCode: string,
+): LeftoverPair | null {
+ let closest: LeftoverPair | null = null;
+ let farthest: LeftoverPair | null = null;
+ for (const pair of pairs) {
+ if (pair.criterion_code !== criterionCode) continue;
+ if (pair.pair_kind === "closest" && closest === null) closest = pair;
+ if (pair.pair_kind === "farthest" && farthest === null) farthest = pair;
+ }
+ return closest ?? farthest;
+}
+
+export function projectLeftoverMap(
+ persons: LeftoverMapPerson[],
+ items: LeftoverMapItem[],
+ itemLabel: (criterionCode: string) => string,
+ pairs: LeftoverPair[] = [],
+ width = MAP_WIDTH,
+ height = MAP_HEIGHT,
+ pad = MAP_PAD,
+): { persons: ProjectedLeftoverPoint[]; items: ProjectedLeftoverPoint[] } {
+ const raw = [
+ ...persons.map((person) => ({
+ id: person.post_id,
+ label: person.post_title,
+ axisOne: person.axis_one,
+ axisTwo: person.axis_two,
+ kind: "person" as const,
+ })),
+ ...items.map((item) => ({
+ id: item.criterion_code,
+ label: itemLabel(item.criterion_code),
+ axisOne: item.axis_one,
+ axisTwo: item.axis_two,
+ kind: "item" as const,
+ })),
+ ];
+ const xs = raw.map((point) => point.axisOne);
+ const ys = raw.map((point) => point.axisTwo);
+ const minX = Math.min(...xs);
+ const maxX = Math.max(...xs);
+ const minY = Math.min(...ys);
+ const maxY = Math.max(...ys);
+ const spanX = maxX - minX;
+ const spanY = maxY - minY;
+ const toSvg = (axisOne: number, axisTwo: number) => ({
+ x: spanX === 0 ? width / 2 : pad + ((axisOne - minX) / spanX) * (width - 2 * pad),
+ y: spanY === 0 ? height / 2 : pad + ((maxY - axisTwo) / spanY) * (height - 2 * pad),
+ });
+ return {
+ persons: raw
+ .filter((point) => point.kind === "person")
+ .map((point) => {
+ const { x, y } = toSvg(point.axisOne, point.axisTwo);
+ return {
+ id: point.id,
+ label: point.label,
+ x,
+ y,
+ kind: "person" as const,
+ pairKinds: pairKindsFor(point.id, "person", pairs),
+ };
+ }),
+ items: raw
+ .filter((point) => point.kind === "item")
+ .map((point) => {
+ const { x, y } = toSvg(point.axisOne, point.axisTwo);
+ return {
+ id: point.id,
+ label: point.label,
+ x,
+ y,
+ kind: "item" as const,
+ pairKinds: pairKindsFor(point.id, "item", pairs),
+ };
+ }),
+ };
+}
+
+function truncateLabel(label: string): string {
+ return label.length > 22 ? `${label.slice(0, 21)}…` : label;
+}
+
+function activateLeftoverMapNode(
+ event: { key: string; preventDefault: () => void },
+ postId: string,
+ onSelectPost: (postId: string) => void,
+) {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ onSelectPost(postId);
+ }
+}
+
+export function LeftoverInteractionMap({
+ persons,
+ items,
+ pairs,
+ itemLabel,
+ onSelectPost,
+}: {
+ persons: LeftoverMapPerson[];
+ items: LeftoverMapItem[];
+ pairs: LeftoverPair[];
+ itemLabel: (criterionCode: string) => string;
+ onSelectPost: (postId: string) => void;
+}) {
+ if (persons.length === 0 && items.length === 0) {
+ return null;
+ }
+ const projected = projectLeftoverMap(persons, items, itemLabel, pairs);
+ const personById = Object.fromEntries(projected.persons.map((point) => [point.id, point]));
+ const itemById = Object.fromEntries(projected.items.map((point) => [point.id, point]));
+ return (
+
+ {t("Leftover interaction map after main effects")}
+
+
+ );
+}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 677ce0799..00453ef68 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -776,6 +776,19 @@ export interface LeftoverPair {
leftover_map_rank?: number | null;
}
+export interface LeftoverMapPerson {
+ post_id: string;
+ post_title: string;
+ axis_one: number;
+ axis_two: number;
+}
+
+export interface LeftoverMapItem {
+ criterion_code: string;
+ axis_one: number;
+ axis_two: number;
+}
+
export interface PeriodGroupReport {
grouping_key: string;
grouping_label?: string;
@@ -791,6 +804,8 @@ export interface PeriodGroupReport {
members: ReportMember[];
selected_items: SelectedReportItem[];
leftover_pairs: LeftoverPair[];
+ leftover_map_persons?: LeftoverMapPerson[];
+ leftover_map_items?: LeftoverMapItem[];
}
export interface PeriodReports {
diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts
index 608f95858..45b2de701 100644
--- a/frontend/src/i18n.test.ts
+++ b/frontend/src/i18n.test.ts
@@ -90,6 +90,19 @@ describe("i18n", () => {
expect(tf("{post} is current in Event Lineage. Read Keyman and evaluation next.", { post: "DEMO" })).toBe(expected);
});
+ it.each([
+ ["ko", "잔여 맵 평가 항목 열기: sales-lead", "가장 가까운 잔여", "가장 먼 잔여"],
+ ["zh", "打开残差图评估项:sales-lead", "最近残差", "最远残差"],
+ ["ja", "残差マップの評価項目を開く: sales-lead", "最も近い残差", "最も遠い残差"],
+ ["vi", "Mở tiêu chí bản đồ phần dư: sales-lead", "Phần dư gần nhất", "Phần dư xa nhất"],
+ ] as const)("translates leftover-map criterion next action in %s", (locale, expected, closest, farthest) => {
+ setLocale(locale);
+ expect(tf("Open leftover map criterion: {label}", { label: "sales-lead" })).toBe(expected);
+ expect(t("Leftover interaction map")).not.toBe("Leftover interaction map");
+ expect(t("Closest leftover")).toBe(closest);
+ expect(t("Farthest leftover")).toBe(farthest);
+ });
+
it.each([
["ko", "IRT 주효과 이후 잔여 R +0.40. sales-lead 기준을 읽으려면 이 글을 여세요."],
["zh", "IRT 主效应后的残余 R +0.40。打开这篇帖子阅读 sales-lead。"],
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 06545e35a..fedebddc1 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -331,6 +331,16 @@ const TRANSLATIONS: Partial>> = {
"Related next action": "관련 대상 다음 작업",
"Ask next action": "Ask 다음 작업",
"Keyman next action": "Keyman 다음 작업",
+ "Leftover interaction map": "잔여 상호작용 맵",
+ "Leftover interaction map after main effects": "주효과 이후 잔여 상호작용 맵",
+ "Closest leftover": "가장 가까운 잔여",
+ "Farthest leftover": "가장 먼 잔여",
+ "Open leftover map post: {label}": "잔여 맵 글 열기: {label}",
+ "Open leftover map criterion: {label}": "잔여 맵 평가 항목 열기: {label}",
+ "Open this leftover map criterion to read the leftover pair post: {label}":
+ "이 잔여 맵 평가 항목을 열어 잔여 쌍 글을 읽으세요: {label}",
+ "Open this post on the leftover map: {label}": "잔여 맵에서 이 글을 여세요: {label}",
+ "Criterion: {label}": "평가 항목: {label}",
"Ask seeded question: {question}": "시드 질문: {question}",
"{post} is current in Event Lineage. Read Keyman and evaluation next.":
"{post}은(는) 이벤트 계보의 현재 항목입니다. 다음으로 Keyman과 평가를 읽으세요.",
@@ -375,8 +385,6 @@ const TRANSLATIONS: Partial>> = {
"Interactive questions are unavailable right now; saved evidence remains available.":
"대화형 질문을 지금 사용할 수 없습니다. 저장된 근거는 계속 확인할 수 있습니다.",
"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}":
@@ -708,6 +716,16 @@ const TRANSLATIONS: Partial>> = {
"Related next action": "相关节点操作",
"Ask next action": "Ask 操作",
"Keyman next action": "关键联系人操作",
+ "Leftover interaction map": "残差交互图",
+ "Leftover interaction map after main effects": "主效应后的残差交互图",
+ "Closest leftover": "最近残差",
+ "Farthest leftover": "最远残差",
+ "Open leftover map post: {label}": "打开残差图帖子:{label}",
+ "Open leftover map criterion: {label}": "打开残差图评估项:{label}",
+ "Open this leftover map criterion to read the leftover pair post: {label}":
+ "打开此残差图评估项以阅读残差配对帖子:{label}",
+ "Open this post on the leftover map: {label}": "在残差图上打开这篇帖子:{label}",
+ "Criterion: {label}": "评估项:{label}",
"Ask seeded question: {question}": "种子问题:{question}",
"{post} is current in Event Lineage. Read Keyman and evaluation next.":
"{post} 是事件谱系中的当前记录。接下来查看关键联系人和评估。",
@@ -752,8 +770,6 @@ const TRANSLATIONS: Partial>> = {
"Interactive questions are unavailable right now; saved evidence remains available.":
"交互式提问暂不可用;已保存的证据仍可查看。",
"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}":
@@ -1085,6 +1101,16 @@ const TRANSLATIONS: Partial>> = {
"Related next action": "関連ノードの操作",
"Ask next action": "Askの操作",
"Keyman next action": "キーパーソンの操作",
+ "Leftover interaction map": "残差インタラクションマップ",
+ "Leftover interaction map after main effects": "主効果後の残差インタラクションマップ",
+ "Closest leftover": "最も近い残差",
+ "Farthest leftover": "最も遠い残差",
+ "Open leftover map post: {label}": "残差マップの投稿を開く: {label}",
+ "Open leftover map criterion: {label}": "残差マップの評価項目を開く: {label}",
+ "Open this leftover map criterion to read the leftover pair post: {label}":
+ "この残差マップ評価項目を開いて残差ペアの投稿を読む: {label}",
+ "Open this post on the leftover map: {label}": "残差マップでこの投稿を開く: {label}",
+ "Criterion: {label}": "評価項目: {label}",
"Ask seeded question: {question}": "シードされた質問: {question}",
"{post} is current in Event Lineage. Read Keyman and evaluation next.":
"{post}はイベント系譜の現在の記録です。次にキーパーソンと評価を確認してください。",
@@ -1129,8 +1155,6 @@ const TRANSLATIONS: Partial>> = {
"Interactive questions are unavailable right now; saved evidence remains available.":
"対話形式の質問は現在利用できません。保存された証拠は確認できます。",
"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}":
@@ -1462,6 +1486,16 @@ const TRANSLATIONS: Partial>> = {
"Related next action": "Thao tác nút liên quan",
"Ask next action": "Thao tác Ask",
"Keyman next action": "Thao tác người liên hệ chính",
+ "Leftover interaction map": "Bản đồ tương tác phần dư",
+ "Leftover interaction map after main effects": "Bản đồ tương tác phần dư sau hiệu ứng chính",
+ "Closest leftover": "Phần dư gần nhất",
+ "Farthest leftover": "Phần dư xa nhất",
+ "Open leftover map post: {label}": "Mở bài viết bản đồ phần dư: {label}",
+ "Open leftover map criterion: {label}": "Mở tiêu chí bản đồ phần dư: {label}",
+ "Open this leftover map criterion to read the leftover pair post: {label}":
+ "Mở tiêu chí bản đồ phần dư này để đọc bài viết cặp phần dư: {label}",
+ "Open this post on the leftover map: {label}": "Mở bài viết này trên bản đồ phần dư: {label}",
+ "Criterion: {label}": "Tiêu chí: {label}",
"Ask seeded question: {question}": "Câu hỏi mồi: {question}",
"{post} is current in Event Lineage. Read Keyman and evaluation next.":
"{post} là bản ghi hiện tại trong Dòng sự kiện. Hãy xem người liên hệ chính và đánh giá tiếp theo.",
@@ -1506,8 +1540,6 @@ const TRANSLATIONS: Partial>> = {
"Interactive questions are unavailable right now; saved evidence remains available.":
"Câu hỏi tương tác hiện không khả dụng; bằng chứng đã lưu vẫn có thể xem.",
"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}":
diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py
index 297d47012..c4bb7e221 100644
--- a/lineageweave/leftover_pairs.py
+++ b/lineageweave/leftover_pairs.py
@@ -1,6 +1,6 @@
"""Jeon leftover post–criterion pairs after a main-effect IRT.
-Implements ADR 0048 as amended by ADR 0119, ADR 0163, and ADR 0164.
+Implements ADR 0048 as amended by ADR 0119, ADR 0121, ADR 0163, and ADR 0164.
Does not import ``fast_mlsirm`` or ``period_report``. A Gabriel biplot
of the residual ``R = Y − E[Y|θ, item]`` supplies person and item
@@ -11,7 +11,10 @@
on the two leftover-map axes (Jeon et al., 2021); unused axes pad with
zero rather than inventing a second component, and hidden SVD axes
after the second are dropped. Each pair also names the full leftover-map
-rank so a rank-0 collapse is not read as leftover structure.
+rank so a rank-0 collapse is not read as leftover structure. Person and
+item leftover-map coordinates ξ / ζ are exposed on
+``LeftoverInteractionMap`` for callers that persist the full biplot
+(ADR 0121).
"""
from __future__ import annotations
@@ -41,22 +44,60 @@ class LeftoverPair:
leftover_map_rank: int
+@dataclass(frozen=True)
+class LeftoverMapPerson:
+ """One post's leftover-map coordinates ξ after IRT main effects."""
+
+ post_id: str
+ axis_one: float
+ axis_two: float
+
+
+@dataclass(frozen=True)
+class LeftoverMapItem:
+ """One criterion's leftover-map coordinates ζ after IRT main effects."""
+
+ criterion_code: str
+ axis_one: float
+ axis_two: float
+
+
+@dataclass(frozen=True)
+class LeftoverInteractionMap:
+ """Gabriel leftover-map points plus the closest and farthest observed pairs."""
+
+ pairs: tuple[LeftoverPair, ...]
+ persons: tuple[LeftoverMapPerson, ...]
+ items: tuple[LeftoverMapItem, ...]
+
+
def leftover_pairs_from_residual(
post_ids: list[str],
item_codes: tuple[str, ...],
matrix: np.ndarray,
expected: np.ndarray,
) -> tuple[LeftoverPair, ...]:
- """Closest and farthest leftover-map pairs from residual SVD biplot.
+ """Closest and farthest leftover-map pairs from residual SVD biplot."""
+ return leftover_map_from_residual(post_ids, item_codes, matrix, expected).pairs
+
+
+def leftover_map_from_residual(
+ post_ids: list[str],
+ item_codes: tuple[str, ...],
+ matrix: np.ndarray,
+ expected: np.ndarray,
+) -> LeftoverInteractionMap:
+ """Person and item leftover-map coordinates plus closest/farthest pairs.
Jeon et al. (2021) leftover interaction is ``−γ‖ξ_p − ζ_i‖``. This
estimator places persons and items from the residual after IRT main
effects (Gabriel, 1971). Only observed cells become pairs. Distances
use the two leftover-map axes; a rank-0 residual still emits a
stable closest/farthest pair so seed is not empty and does not
- invent a leftover score. Stored residual equals observed ``Y`` minus
- expected ``E[Y|θ, item]``. Stored leftover-map rank is the number
- of Gabriel singular values above the floor.
+ invent a leftover score. Unused map axes stay zero when the
+ residual rank is below two. Stored residual equals observed ``Y``
+ minus expected ``E[Y|θ, item]``. Stored leftover-map rank is the
+ number of Gabriel singular values above the floor.
"""
if matrix.shape != (len(post_ids), len(item_codes)):
raise ValueError(
@@ -74,7 +115,7 @@ def leftover_pairs_from_residual(
if observed_mask[person, item]
]
if not observed:
- return ()
+ return LeftoverInteractionMap(pairs=(), persons=(), items=())
keep_person, keep_item = _complete_case_masks(observed_mask)
person_index = np.flatnonzero(keep_person)
@@ -86,12 +127,30 @@ def leftover_pairs_from_residual(
person_pos, item_pos, leftover_map_rank = _complete_case_positions(
residual, center, keep_person, keep_item
)
+ persons: tuple[LeftoverMapPerson, ...] = ()
+ items: tuple[LeftoverMapItem, ...] = ()
candidates: list[tuple[float, str, str, float, float, float]] = []
if person_pos is not None and item_pos is not None:
person_index = np.flatnonzero(keep_person)
item_index = np.flatnonzero(keep_item)
person_xy = _pad_map_axes(person_pos)
item_xy = _pad_map_axes(item_pos)
+ persons = tuple(
+ LeftoverMapPerson(
+ post_id=post_ids[int(person)],
+ axis_one=float(person_xy[local, 0]),
+ axis_two=float(person_xy[local, 1]),
+ )
+ for local, person in enumerate(person_index)
+ )
+ items = tuple(
+ LeftoverMapItem(
+ criterion_code=item_codes[int(item)],
+ axis_one=float(item_xy[local, 0]),
+ axis_two=float(item_xy[local, 1]),
+ )
+ for local, item in enumerate(item_index)
+ )
local_person = {int(person): local for local, person in enumerate(person_index)}
local_item = {int(item): local for local, item in enumerate(item_index)}
for person, item in observed:
@@ -123,9 +182,13 @@ def leftover_pairs_from_residual(
)
closest = min(candidates, key=lambda row: (row[0], row[1], row[2]))
farthest = max(candidates, key=lambda row: (row[0], row[1], row[2]))
- return (
- _pair_from_candidate(PAIR_KIND_CLOSEST, closest, leftover_map_rank),
- _pair_from_candidate(PAIR_KIND_FARTHEST, farthest, leftover_map_rank),
+ return LeftoverInteractionMap(
+ pairs=(
+ _pair_from_candidate(PAIR_KIND_CLOSEST, closest, leftover_map_rank),
+ _pair_from_candidate(PAIR_KIND_FARTHEST, farthest, leftover_map_rank),
+ ),
+ persons=persons,
+ items=items,
)
@@ -179,8 +242,7 @@ def _complete_case_masks(observed: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Drop incomplete rows, then incomplete columns among remaining rows."""
keep_person = observed.any(axis=1)
keep_item = observed.any(axis=0)
- if np.any(keep_item):
- keep_person = keep_person & observed[:, keep_item].all(axis=1)
+ keep_person = keep_person & observed[:, keep_item].all(axis=1)
if np.any(keep_person):
keep_item = keep_item & observed[keep_person, :].all(axis=0)
return keep_person, keep_item
@@ -209,7 +271,7 @@ def _leftover_map_positions(filled: np.ndarray) -> tuple[np.ndarray, np.ndarray,
the two leading leftover-map axes. Rank-0 residuals collapse to the origin.
"""
n_persons, n_items = filled.shape
- if n_persons == 0 or n_items == 0 or not np.any(np.abs(filled) > _LEFTOVER_SINGULAR_FLOOR):
+ if not np.any(np.abs(filled) > _LEFTOVER_SINGULAR_FLOOR):
return (
np.zeros((n_persons, 1), dtype=np.float64),
np.zeros((n_items, 1), dtype=np.float64),
diff --git a/lineageweave/period_report.py b/lineageweave/period_report.py
index 0b7fabc79..d874408eb 100644
--- a/lineageweave/period_report.py
+++ b/lineageweave/period_report.py
@@ -16,8 +16,9 @@
``fast_mlsirm.information_polytomous`` -- Samejima (1969) GRM /
Muraki (1993) GPCM, computed in Rust. A missing bank is not invented.
-Leftover post–criterion pairs (ADR 0017) come from the residual
-interaction after those IRT main effects: ``R = Y − E[Y|θ, item]``.
+Leftover post–criterion pairs (ADR 0048) and leftover-map coordinates
+(ADR 0121) come from the residual interaction after those IRT main
+effects: ``R = Y − E[Y|θ, item]``.
A Gabriel biplot of ``R`` supplies person and item leftover-map
positions. Closest / farthest pairs are the min / max Euclidean
distances on that map (Jeon et al., 2021, eq. 3). ``fast-mlsirm``
@@ -43,7 +44,8 @@
validate_irt_response_matrix,
)
-from .leftover_pairs import LeftoverPair, leftover_pairs_from_residual
+from .leftover_pairs import LeftoverInteractionMap, LeftoverMapItem, LeftoverMapPerson, LeftoverPair
+from .leftover_pairs import leftover_map_from_residual
from .leftover_pairs import PAIR_KIND_CLOSEST as PAIR_KIND_CLOSEST
from .leftover_pairs import PAIR_KIND_FARTHEST as PAIR_KIND_FARTHEST
from .post_evaluation import CRITERION_CODES, IRT_CATEGORY_COUNT
@@ -111,6 +113,8 @@ class PeriodReport:
delta_mean_theta: float | None = None
selected_items: tuple[SelectedItem, ...] = ()
leftover_pairs: tuple[LeftoverPair, ...] = ()
+ leftover_map_persons: tuple[LeftoverMapPerson, ...] = ()
+ leftover_map_items: tuple[LeftoverMapItem, ...] = ()
def _sigmoid(value: np.ndarray) -> np.ndarray:
@@ -252,9 +256,21 @@ def leftover_pairs_for_fit(
fit: PolytomousFit,
) -> tuple[LeftoverPair, ...]:
"""Leftover pairs from the already-fitted GRM/GPCM main effects."""
+ return leftover_map_for_fit(post_ids, item_codes, matrix, model, theta, fit).pairs
+
+
+def leftover_map_for_fit(
+ post_ids: list[str],
+ item_codes: tuple[str, ...],
+ matrix: np.ndarray,
+ model: str,
+ theta: np.ndarray,
+ fit: PolytomousFit,
+) -> LeftoverInteractionMap:
+ """Leftover-map coordinates and pairs from already-fitted GRM/GPCM main effects."""
probs = _category_probabilities(model, theta, fit)
expected = expected_category_matrix(matrix, probs)
- return leftover_pairs_from_residual(post_ids, item_codes, matrix, expected)
+ return leftover_map_from_residual(post_ids, item_codes, matrix, expected)
def _member_scores(post_ids: list[str], scores: dict[str, np.ndarray]) -> tuple[MemberScore, ...]:
@@ -306,6 +322,7 @@ def calibrate_period_report(
theta = np.asarray(scores["theta_eap"], dtype=np.float64)
mean_theta = float(theta.mean())
item_bank = item_bank_from_fit(fit, item_codes, source_period_code)
+ leftover_map = leftover_map_for_fit(post_ids, item_codes, matrix, selected, theta, fit)
return PeriodReport(
selected_model=selected,
mean_theta=mean_theta,
@@ -319,7 +336,9 @@ def calibrate_period_report(
item_bank=item_bank,
link_method=LINK_METHOD_FREE,
selected_items=rank_items_by_information(item_bank, mean_theta),
- leftover_pairs=leftover_pairs_for_fit(post_ids, item_codes, matrix, selected, theta, fit),
+ leftover_pairs=leftover_map.pairs,
+ leftover_map_persons=leftover_map.persons,
+ leftover_map_items=leftover_map.items,
)
@@ -348,6 +367,9 @@ def score_period_on_bank(
item_type="polytomous",
response_process="cumulative",
)
+ leftover_map = leftover_map_for_fit(
+ post_ids, item_bank.item_codes, matrix, item_bank.model, theta, fit
+ )
return PeriodReport(
selected_model=item_bank.model,
mean_theta=mean_theta,
@@ -367,9 +389,9 @@ def score_period_on_bank(
else mean_theta - float(previous_mean_theta)
),
selected_items=rank_items_by_information(item_bank, mean_theta),
- leftover_pairs=leftover_pairs_for_fit(
- post_ids, item_bank.item_codes, matrix, item_bank.model, theta, fit
- ),
+ leftover_pairs=leftover_map.pairs,
+ leftover_map_persons=leftover_map.persons,
+ leftover_map_items=leftover_map.items,
)
diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql
index 483893a1d..48e26a701 100644
--- a/migrations/0001_initial_schema.sql
+++ b/migrations/0001_initial_schema.sql
@@ -325,7 +325,7 @@ create table report_item_information (
check (item_rank >= 1)
);
--- Leftover interaction map pairs after IRT main effects (ADR 0017).
+-- Leftover interaction map pairs after IRT main effects (ADR 0048).
-- Closest / farthest post–criterion Euclidean distances on the residual
-- biplot (Jeon et al., 2021). Cascade with the period score. The pair
-- post must be a member of this report; the criterion must be a CAT
diff --git a/migrations/0012_report_leftover_pair.sql b/migrations/0012_report_leftover_pair.sql
index d65612ea1..16161ae0e 100644
--- a/migrations/0012_report_leftover_pair.sql
+++ b/migrations/0012_report_leftover_pair.sql
@@ -1,4 +1,4 @@
--- ADR 0017: persist closest/farthest leftover post–criterion pairs after
+-- ADR 0048: persist closest/farthest leftover post–criterion pairs after
-- IRT main effects. CREATE IF NOT EXISTS so a volume that already ran
-- 0001 still upgrades. Composite FKs are added below so an existing
-- table from an earlier 0012 still gains member/item integrity.
diff --git a/migrations/0172_report_leftover_interaction_map.sql b/migrations/0172_report_leftover_interaction_map.sql
new file mode 100644
index 000000000..1c53f7004
--- /dev/null
+++ b/migrations/0172_report_leftover_interaction_map.sql
@@ -0,0 +1,65 @@
+-- ADR 0121: persist leftover interaction-map coordinates after IRT main
+-- effects. CREATE IF NOT EXISTS so a volume that already ran 0001 still
+-- upgrades. Composite FKs are added below so an existing table from an
+-- an earlier migration still gains member/item integrity.
+
+create table if not exists report_leftover_map_person (
+ grouping_kind text not null,
+ grouping_key text not null,
+ period_code text not null,
+ rubric_version text not null,
+ post_id uuid not null references source_post (post_id),
+ axis_one numeric not null,
+ axis_two numeric not null,
+ primary key (grouping_kind, grouping_key, period_code, rubric_version, post_id),
+ foreign key (grouping_kind, grouping_key, period_code, rubric_version)
+ references report_period_score (grouping_kind, grouping_key, period_code, rubric_version)
+ on delete cascade
+);
+
+create index if not exists report_leftover_map_person_post_idx
+ on report_leftover_map_person (post_id);
+
+create table if not exists report_leftover_map_item (
+ grouping_kind text not null,
+ grouping_key text not null,
+ period_code text not null,
+ rubric_version text not null,
+ criterion_code text not null references common_lookup_value (lookup_code),
+ axis_one numeric not null,
+ axis_two numeric not null,
+ primary key (grouping_kind, grouping_key, period_code, rubric_version, criterion_code),
+ foreign key (grouping_kind, grouping_key, period_code, rubric_version)
+ references report_period_score (grouping_kind, grouping_key, period_code, rubric_version)
+ on delete cascade
+);
+
+do $$
+begin
+ if not exists (
+ select 1
+ from pg_constraint
+ where conname = 'leftover_map_person_member_score_fk'
+ ) then
+ alter table report_leftover_map_person
+ add constraint leftover_map_person_member_score_fk
+ foreign key (grouping_kind, grouping_key, period_code, rubric_version, post_id)
+ references report_member_score (
+ grouping_kind, grouping_key, period_code, rubric_version, post_id
+ )
+ on delete cascade;
+ end if;
+ if not exists (
+ select 1
+ from pg_constraint
+ where conname = 'leftover_map_item_information_fk'
+ ) then
+ alter table report_leftover_map_item
+ add constraint leftover_map_item_information_fk
+ foreign key (grouping_kind, grouping_key, period_code, rubric_version, criterion_code)
+ references report_item_information (
+ grouping_kind, grouping_key, period_code, rubric_version, item_code
+ )
+ on delete cascade;
+ end if;
+end $$;
diff --git a/migrations/rollback/0172_report_leftover_interaction_map.sql b/migrations/rollback/0172_report_leftover_interaction_map.sql
new file mode 100644
index 000000000..c6f41988b
--- /dev/null
+++ b/migrations/rollback/0172_report_leftover_interaction_map.sql
@@ -0,0 +1,2 @@
+drop table if exists report_leftover_map_item;
+drop table if exists report_leftover_map_person;
diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py
index 04a1f9c00..307a2abd1 100644
--- a/scripts/seed_demo_data.py
+++ b/scripts/seed_demo_data.py
@@ -119,6 +119,7 @@ def seed(
cur.execute((migrations / "0012_report_leftover_pair.sql").read_text())
cur.execute((migrations / "0163_report_leftover_observed_expected.sql").read_text())
cur.execute((migrations / "0164_report_leftover_map_rank.sql").read_text())
+ cur.execute((migrations / "0172_report_leftover_interaction_map.sql").read_text())
cur.execute((migrations / "0060_role_responsibility_agent_type.sql").read_text())
cur.execute((migrations / "0013_person_job_title.sql").read_text())
cur.execute((migrations / "0014_role_responsibility_team_actor_type.sql").read_text())
@@ -1215,6 +1216,38 @@ def _persist_seed_period_report(
pair.leftover_map_rank,
),
)
+ for person in report.leftover_map_persons:
+ cur.execute(
+ "insert into report_leftover_map_person ("
+ "grouping_kind, grouping_key, period_code, rubric_version, "
+ "post_id, axis_one, axis_two"
+ ") values (%s,%s,%s,%s,%s,%s,%s)",
+ (
+ grouping_kind,
+ grouping_key,
+ period_code,
+ RUBRIC_VERSION,
+ person.post_id,
+ person.axis_one,
+ person.axis_two,
+ ),
+ )
+ for item in report.leftover_map_items:
+ cur.execute(
+ "insert into report_leftover_map_item ("
+ "grouping_kind, grouping_key, period_code, rubric_version, "
+ "criterion_code, axis_one, axis_two"
+ ") values (%s,%s,%s,%s,%s,%s,%s)",
+ (
+ grouping_kind,
+ grouping_key,
+ period_code,
+ RUBRIC_VERSION,
+ item.criterion_code,
+ item.axis_one,
+ item.axis_two,
+ ),
+ )
def _seed_demo_period_report(cur, author_account_id, corporate_entity_id, process_unit_id) -> None:
diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py
index 72a2de975..f87158b68 100644
--- a/tests/test_leftover_pairs.py
+++ b/tests/test_leftover_pairs.py
@@ -1,6 +1,6 @@
"""Leftover post–criterion pairs after the main-effect IRT.
-Covers ADR 0048 as amended by ADR 0119, ADR 0163, and ADR 0164.
+Covers ADR 0048 as amended by ADR 0119, ADR 0121, ADR 0163, and ADR 0164.
Uses a constructed residual matrix so the closest and farthest pair
are known without calling ``fit_polytomous``. Loads
@@ -46,6 +46,7 @@ def _load_leftover():
PAIR_KIND_CLOSEST = leftover.PAIR_KIND_CLOSEST
PAIR_KIND_FARTHEST = leftover.PAIR_KIND_FARTHEST
leftover_pairs_from_residual = leftover.leftover_pairs_from_residual
+leftover_map_from_residual = leftover.leftover_map_from_residual
def _assert_residual_reconciles(pair) -> None:
@@ -159,6 +160,142 @@ def test_leftover_is_empty_without_observed_cells() -> None:
matrix = np.array([[np.nan]], dtype=np.float64)
expected = np.array([[0.0]], dtype=np.float64)
assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == ()
+ empty_map = leftover_map_from_residual(post_ids, item_codes, matrix, expected)
+ assert empty_map.pairs == ()
+ assert empty_map.persons == ()
+ assert empty_map.items == ()
+
+
+def test_leftover_map_rejects_mismatched_shapes() -> None:
+ """Post/item and expected shapes must agree before factorization."""
+ with pytest.raises(ValueError, match="matrix shape"):
+ leftover_map_from_residual(["post-a"], ("item-a",), np.zeros((2, 1)), np.zeros((2, 1)))
+ with pytest.raises(ValueError, match="expected shape"):
+ leftover_map_from_residual(["post-a"], ("item-a",), np.zeros((1, 1)), np.zeros((1, 2)))
+
+
+def test_disconnected_observations_emit_pairs_without_invented_coordinates() -> None:
+ """A sparse pattern with no complete rectangle retains only observed-cell evidence."""
+ matrix = np.array([[1.0, np.nan], [np.nan, 3.0]], dtype=np.float64)
+ leftover_map = leftover_map_from_residual(
+ ["post-a", "post-b"],
+ ("item-a", "item-b"),
+ matrix,
+ np.zeros_like(matrix),
+ )
+
+ assert leftover_map.persons == ()
+ assert leftover_map.items == ()
+ assert [(pair.post_id, pair.criterion_code) for pair in leftover_map.pairs] == [
+ ("post-a", "item-a"),
+ ("post-b", "item-b"),
+ ]
+ assert [pair.leftover_distance for pair in leftover_map.pairs] == pytest.approx([1.0, 1.0])
+
+
+def test_overflowed_projected_distance_is_not_persisted() -> None:
+ """Finite extreme residuals may overflow a norm; those distances stay out."""
+ extreme = 5e307
+ matrix = np.array([[extreme, -extreme], [-extreme, extreme]], dtype=np.float64)
+ with np.errstate(over="ignore"):
+ leftover_map = leftover_map_from_residual(
+ ["post-a", "post-b"],
+ ("item-a", "item-b"),
+ matrix,
+ np.zeros_like(matrix),
+ )
+
+ assert all(np.isfinite(pair.leftover_distance) for pair in leftover_map.pairs)
+
+
+def test_leftover_map_pads_rank_one_axis_two_to_zero() -> None:
+ """A rank-1 residual still emits two map axes; the unused axis is zero."""
+ post_ids = ["post-a", "post-b", "post-c"]
+ item_codes = ("item_near", "item_mid", "item_far")
+ matrix = np.array(
+ [
+ [2.0, 0.0, -2.0],
+ [0.0, 0.0, 0.0],
+ [-2.0, 0.0, 2.0],
+ ],
+ dtype=np.float64,
+ )
+ leftover_map = leftover_map_from_residual(post_ids, item_codes, matrix, np.zeros_like(matrix))
+ assert leftover_map.pairs == leftover_pairs_from_residual(
+ post_ids, item_codes, matrix, np.zeros_like(matrix)
+ )
+ assert {person.post_id for person in leftover_map.persons} == set(post_ids)
+ assert {item.criterion_code for item in leftover_map.items} == set(item_codes)
+ for person in leftover_map.persons:
+ assert person.axis_two == pytest.approx(0.0)
+ assert np.isfinite(person.axis_one)
+ for item in leftover_map.items:
+ assert item.axis_two == pytest.approx(0.0)
+ assert np.isfinite(item.axis_one)
+ by_post = {person.post_id: person for person in leftover_map.persons}
+ by_item = {item.criterion_code: item for item in leftover_map.items}
+ closest = leftover_map.pairs[0]
+ person = by_post[closest.post_id]
+ item = by_item[closest.criterion_code]
+ distance = np.hypot(person.axis_one - item.axis_one, person.axis_two - item.axis_two)
+ assert distance == pytest.approx(closest.leftover_distance, abs=1e-9)
+
+
+def test_rank_three_pair_distances_match_the_persisted_two_axis_map() -> None:
+ """Hidden higher components must not change buyer-visible pair distances."""
+ post_ids = ["post-a", "post-b", "post-c", "post-d"]
+ item_codes = ("item-a", "item-b", "item-c", "item-d")
+ matrix = np.array(
+ [
+ [4.0, 1.0, 0.0, -1.0],
+ [0.0, 3.0, 1.0, -2.0],
+ [-2.0, 0.0, 2.0, 1.0],
+ [1.0, -1.0, 0.0, 4.0],
+ ],
+ dtype=np.float64,
+ )
+ leftover_map = leftover_map_from_residual(post_ids, item_codes, matrix, np.zeros_like(matrix))
+ by_post = {person.post_id: person for person in leftover_map.persons}
+ by_item = {item.criterion_code: item for item in leftover_map.items}
+
+ for pair in leftover_map.pairs:
+ person = by_post[pair.post_id]
+ item = by_item[pair.criterion_code]
+ distance = np.hypot(person.axis_one - item.axis_one, person.axis_two - item.axis_two)
+ assert pair.leftover_distance == pytest.approx(distance)
+
+
+def test_zero_residual_map_places_every_complete_case_at_the_origin() -> None:
+ post_ids = ["alpha-post", "beta-post"]
+ item_codes = ("item_one", "item_two")
+ matrix = np.ones((2, 2), dtype=np.float64)
+ leftover_map = leftover_map_from_residual(post_ids, item_codes, matrix, matrix)
+ assert leftover_map.pairs[0].leftover_distance == pytest.approx(0.0)
+ assert {person.post_id for person in leftover_map.persons} == set(post_ids)
+ assert {item.criterion_code for item in leftover_map.items} == set(item_codes)
+ for person in leftover_map.persons:
+ assert person.axis_one == pytest.approx(0.0)
+ assert person.axis_two == pytest.approx(0.0)
+ for item in leftover_map.items:
+ assert item.axis_one == pytest.approx(0.0)
+ assert item.axis_two == pytest.approx(0.0)
+
+
+def test_sparse_person_does_not_receive_invented_map_coordinates() -> None:
+ """Incomplete rows are dropped from the Gabriel rectangle, not filled with zero."""
+ post_ids = ["aligned-post", "opposed-post", "sparse-post"]
+ item_codes = ("item_near", "item_far")
+ matrix = np.array(
+ [
+ [2.0, -2.0],
+ [-2.0, 2.0],
+ [2.0, np.nan],
+ ],
+ dtype=np.float64,
+ )
+ leftover_map = leftover_map_from_residual(post_ids, item_codes, matrix, np.zeros_like(matrix))
+ assert {person.post_id for person in leftover_map.persons} == {"aligned-post", "opposed-post"}
+ assert "sparse-post" not in {person.post_id for person in leftover_map.persons}
def test_leftover_residual_equals_observed_minus_expected() -> None:
diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py
index 8af142a75..4757f5e7f 100644
--- a/tests/test_migration_replay.py
+++ b/tests/test_migration_replay.py
@@ -49,6 +49,17 @@ def test_migrate_sh_replays_leftover_pair_migration_on_existing_volumes() -> Non
subprocess.run(["sh", "-n", str(migration_script)], check=True)
+def test_migrate_sh_generic_pattern_covers_leftover_interaction_map_migration() -> None:
+ """ADR 0166's four-digit pattern must replay 0172 without a new allowlist entry."""
+ import fnmatch
+
+ name = "0172_report_leftover_interaction_map.sql"
+ assert (Path(__file__).resolve().parents[1] / "migrations" / name).exists()
+ assert fnmatch.fnmatchcase(name, "[0-9][0-9][0-9][0-9]_*")
+ assert not fnmatch.fnmatchcase(name, "000[0-9]_*")
+ assert not fnmatch.fnmatchcase(name, "001[01]_*")
+
+
def test_tenant_settings_migration_is_safe_to_replay() -> None:
"""The newest migration must survive migrate.sh's every-start replay."""
sql = (
diff --git a/tests/test_period_report.py b/tests/test_period_report.py
index 318ad23ae..d103f531c 100644
--- a/tests/test_period_report.py
+++ b/tests/test_period_report.py
@@ -10,6 +10,7 @@
import numpy as np
import pytest
+from lineageweave.leftover_pairs import leftover_pairs_from_residual
from lineageweave.period_report import (
LINK_METHOD_FIPC,
LINK_METHOD_FREE,
@@ -18,7 +19,6 @@
ItemBank,
assemble_response_matrix,
calibrate_period_report,
- leftover_pairs_from_residual,
link_or_calibrate_period_report,
rank_items_by_information,
score_groups_on_shared_metric,
@@ -270,6 +270,14 @@ def test_calibrated_report_attaches_leftover_pairs() -> None:
pair.observed_response - pair.expected_response, abs=1e-6
)
assert pair.leftover_map_rank >= 0
+ assert {person.post_id for person in report.leftover_map_persons} <= member_ids
+ assert {item.criterion_code for item in report.leftover_map_items} <= set(items)
+ for person in report.leftover_map_persons:
+ assert np.isfinite(person.axis_one)
+ assert np.isfinite(person.axis_two)
+ for item in report.leftover_map_items:
+ assert np.isfinite(item.axis_one)
+ assert np.isfinite(item.axis_two)
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 81cc3fcfd..42371acad 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -53,6 +53,11 @@
/ "migrations"
/ "0164_report_leftover_map_rank.sql"
)
+_LEFTOVER_INTERACTION_MAP_MIGRATION = (
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0172_report_leftover_interaction_map.sql"
+)
def _postgres_available() -> bool:
@@ -91,6 +96,7 @@ def schema_db():
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(_LEFTOVER_INTERACTION_MAP_MIGRATION.read_text())
conn.commit()
yield conn
finally:
@@ -134,6 +140,8 @@ def test_migration_applies_cleanly(schema_db) -> None:
"report_item_parameter",
"report_item_information",
"report_leftover_pair",
+ "report_leftover_map_person",
+ "report_leftover_map_item",
"post_summary_result",
"post_summary_event",
"post_summary_role",
@@ -184,6 +192,28 @@ def test_leftover_pair_references_member_and_item_rows(schema_db) -> None:
assert "report_period_score" in targets
+def test_leftover_map_references_member_and_item_rows(schema_db) -> None:
+ """A leftover-map point cannot name a post or item from another report."""
+ with schema_db.cursor() as cur:
+ cur.execute(
+ """
+ select conrelid::regclass::text, confrelid::regclass::text
+ from pg_constraint
+ where conrelid in (
+ 'report_leftover_map_person'::regclass,
+ 'report_leftover_map_item'::regclass
+ ) and contype = 'f'
+ """
+ )
+ by_table: dict[str, set[str]] = {}
+ for table_name, target in cur.fetchall():
+ by_table.setdefault(table_name, set()).add(target)
+ assert "report_member_score" in by_table["report_leftover_map_person"]
+ assert "report_period_score" in by_table["report_leftover_map_person"]
+ assert "report_item_information" in by_table["report_leftover_map_item"]
+ assert "report_period_score" in by_table["report_leftover_map_item"]
+
+
def test_leftover_pair_names_nullable_observed_and_expected_columns(schema_db) -> None:
"""Every install path preserves legacy pairs while naming new Y and E."""
with schema_db.cursor() as cur:
@@ -220,8 +250,6 @@ def test_leftover_pair_names_leftover_map_rank_column(schema_db) -> None:
assert columns["leftover_map_rank"] == "YES"
-
-
def test_corporate_hierarchy_recursive_query_returns_correct_shape(schema_db) -> None:
"""The real product requirement: 'Acme Group -> Acme Electronics Korea
-> Acme Electronics Gwangju Plant' must be walkable with one query,
diff --git a/tests/test_static_sql_review_contracts.py b/tests/test_static_sql_review_contracts.py
index 7269e6acf..31c7896bc 100644
--- a/tests/test_static_sql_review_contracts.py
+++ b/tests/test_static_sql_review_contracts.py
@@ -28,7 +28,7 @@
)
ASYNC_STATEMENT_METHODS = {"execute", "fetch", "fetchrow", "fetchval"}
SQL_REVIEW_RULE = "python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli"
-EXPECTED_SQL_SUPPRESSION_COUNT = 35
+EXPECTED_SQL_SUPPRESSION_COUNT = 36
@pytest.mark.parametrize("relative_path", SQL_REVIEW_PATHS)