diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3a55015f5..07c5c5fd2 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -606,15 +606,20 @@ 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 0017 / 0048 / 0049 / 0119 / 0148 / 0158 / 0162 / 0163 / 0164 / 0168 /
-0182 / 0185 / 0201) persist to `report_leftover_pair` with signed residual `R`,
-observed `Y`, expected `E[Y|θ, item]`, full leftover-map rank, unexplained
-leftover, ADR 0201 reconstruction evidence, and ADR 0185 cross-share evidence.
-Those ADRs are the normative mathematical and storage contracts. Leftover-map axis share
-(Gabriel inertia of residual SVD axes 1 and 2; ADR 0148) persists to
+ADR 0017 / 0048 / 0049 / 0119 / 0121 / 0126 / 0148 / 0158 / 0162 / 0163 /
+0164 / 0168 / 0182 / 0185 / 0201) persist to `report_leftover_pair` with signed
+residual `R`, observed `Y`, expected `E[Y|θ, item]`, full leftover-map rank,
+unexplained leftover `U = R − R̂`, and leftover-map cross share
+`x = 2 R̂ U / R²`; signed reconstruction `R̂` also persists so the raw-residual
+identity remains auditable. Complete-case coordinates persist to
+`report_leftover_map_person` / `report_leftover_map_item` (ADR 0121).
+Leftover-map axis share (Gabriel inertia of residual SVD axes 1 and 2;
+ADR 0148) persists to
`report_leftover_map_axis`. Complete-case leftover-map coverage (ADR
0168) persists to `report_leftover_map_coverage` so readers see how
-many scored posts entered the factorization. Results persist to
+many scored posts entered the factorization -- without a complete-case
+rectangle neither a pair nor a coordinate is persisted either. Results
+persist to
`report_period_score` / `report_member_score`.
`GET /api/reports/{grouping}` lists the trend;
`GET /api/reports/{grouping}/{period}` is ABAC-filtered;
@@ -629,12 +634,10 @@ 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, leftover-map axis share for residual
-SVD axes 1 and 2, and complete-case coverage captions (map used N of M
-scored posts), plus the
-
-closest/farthest pairs above the member list, leftover pairs on the
-grouping comparison strip, and the
+effects) above the member list and on the grouping comparison strip,
+the leftover interaction map, leftover-map axis share for residual SVD
+axes 1 and 2, complete-case coverage captions (map used N of M scored
+posts), plus 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.19-leftover-interaction-map.md b/CHANGELOG.d/2.12.19-leftover-interaction-map.md
new file mode 100644
index 000000000..f24501cbf
--- /dev/null
+++ b/CHANGELOG.d/2.12.19-leftover-interaction-map.md
@@ -0,0 +1,11 @@
+## 2.12.19 — Persist leftover interaction-map coordinates
+
+- After IRT main effects, persist complete-case leftover-map person ξ
+ and item ζ coordinates (`report_leftover_map_person` /
+ `report_leftover_map_item`) and render the 2D Jeon / Gabriel map
+ above leftover pairs (ADR 0121 / 0126). Click a post node or a
+ leftover-pair criterion to open that post. Rank-0 and rank-1 maps
+ pad unused axes with zero. The Rust-backed `fast-mlsirm` contract owns
+ every numerical result; LineageWeave only maps identifiers, persists,
+ and presents it. Hidden posts stay hidden. Never invent a leftover score
+ or a theta.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8cf0eb0f8..a8bf5cc29 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -103,6 +103,10 @@ All notable changes to this project are documented here. Format follows
### Changed
+- Residual interaction-map arithmetic now comes from the Rust-backed
+ `fast-mlsirm` contract. LineageWeave maps returned indices to product
+ identifiers and persists the evidence; it no longer computes residuals,
+ factorization, coordinates, distances, reconstruction, or cross-share.
- ADRs 0011 and 0065 now include APA 7th References for the dated W3C
PROV-O and PROV-DM Recommendations (30 April 2013). Decisions are
unchanged.
@@ -222,6 +226,16 @@ All notable changes to this project are documented here. Format follows
omits the badge rather than inventing a leftover score. Two-axis
reconstruction `R̂` stays internal and is not persisted.
+## [2.12.20] - 2026-08-24
+
+### Added
+
+- Period reports now persist leftover interaction-map coordinates ξ / ζ
+ after IRT main effects and render a 2D Jeon / Gabriel map above leftover
+ pairs (ADR 0121 / 0126). Click a post node or a leftover-pair criterion
+ node to open that post. Rank-0 and rank-1 maps pad unused axes with
+ zero. Hidden posts stay hidden. Never invent a leftover score or a theta.
+
## [2.12.19] - 2026-08-24
### Added
diff --git a/backend/app/main.py b/backend/app/main.py
index b53907977..7d3daf7b1 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -2741,6 +2741,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
@@ -2769,12 +2775,29 @@ async def read_period_reports(
}
for pair in leftover_pairs
]
+ leftover_map_persons = [
+ {
+ key: value
+ for key, value in person.items()
+ if key
+ not in {
+ "has_real_source_context",
+ "visibility_code",
+ "corporate_entity_id",
+ "process_unit_id",
+ }
+ }
+ for person in leftover_map_persons
+ ]
+ leftover_map_items = list(report.get("leftover_map_items", []))
leftover_map_axes = list(report.get("leftover_map_axes", []))
visible.append(
{
**report,
"members": members,
"leftover_pairs": leftover_pairs,
+ "leftover_map_persons": leftover_map_persons,
+ "leftover_map_items": leftover_map_items,
"leftover_map_axes": leftover_map_axes,
"post_count": len(members),
}
diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py
index 4539710d6..190edab03 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, leftover-map axes, leftover coverage, and item bank."""
+ """Replace the stored report, member scores, leftover pairs, leftover map, leftover-map axes, leftover coverage, and item bank."""
await conn.execute(
"""
delete from report_period_score
@@ -465,6 +465,38 @@ async def persist_period_report(
pair.leftover_map_cross_share,
pair.leftover_map_reconstruction,
)
+ 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,
+ )
for axis in report.leftover_map_axes:
await conn.execute(
"""
@@ -664,6 +696,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, p.process_unit_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,
+ )
leftover_axes = await conn.fetch(
"""
select grouping_key, axis_index, leftover_singular_value, leftover_share
@@ -700,6 +758,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)
leftover_axes_by_group: dict[str, list[asyncpg.Record]] = defaultdict(list)
for row in leftover_axes:
leftover_axes_by_group[row["grouping_key"]].append(row)
@@ -816,6 +880,29 @@ 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"]),
+ "process_unit_id": (
+ None if row["process_unit_id"] is None else str(row["process_unit_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"], [])
+ ],
"leftover_map_axes": [
{
"axis_index": int(row["axis_index"]),
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index df1744dd2..0f309d464 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -196,6 +196,11 @@
/ "migrations"
/ "0169_report_leftover_map_axis.sql"
)
+_LEFTOVER_MAP_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0172_report_leftover_interaction_map.sql"
+)
_CHANNEL_EVIDENCE_MIGRATION = (
Path(__file__).resolve().parents[2]
/ "migrations"
@@ -367,6 +372,7 @@ def seeded_db(demo_analyst_token):
cur.execute(_GLOBAL_ASK_SCOPE_MIGRATION.read_text())
cur.execute(_EVENT_OCCURRED_AT_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text())
+ cur.execute(_LEFTOVER_MAP_MIGRATION.read_text())
cur.execute(_CHANNEL_EVIDENCE_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text())
@@ -5543,6 +5549,22 @@ 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 all(
+ {"visibility_code", "corporate_entity_id", "process_unit_id"}.isdisjoint(person)
+ for person in leftover_map_persons
+ )
+ 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))
assert all(
{"visibility_code", "corporate_entity_id", "process_unit_id"}.isdisjoint(pair)
for pair in high_report.get("leftover_pairs", [])
diff --git a/docs/adr/0003-fast-mlsirm-report-integration.md b/docs/adr/0003-fast-mlsirm-report-integration.md
index 11decffc0..f2032f23b 100644
--- a/docs/adr/0003-fast-mlsirm-report-integration.md
+++ b/docs/adr/0003-fast-mlsirm-report-integration.md
@@ -102,8 +102,9 @@ than one large PR:
reports panel. Do not reimplement an information function here.
7. **Leftover-pair slice** (shipped in 0.71.2; ADR 0017 / 0018 / 0048 /
0049): after IRT main effects, persist closest and farthest
- post–criterion pairs from the residual leftover map. Do not fork LSIRM or
- invent a leftover-pair API inside `fast-mlsirm` in this slice.
+ post–criterion pairs from the residual leftover map. Consume fast-mlsirm's
+ Rust-backed residual interaction-map contract and keep only identifier
+ mapping, authorization, persistence, and pair selection here (ADR 0211).
Category probabilities and expected responses must use `fast-mlsirm`'s
public Rust-backed prediction API (upstream PR #1279); LineageWeave must
not reproduce GRM/GPCM parameter conventions locally.
diff --git a/docs/adr/0049-leftover-pair-report-ui.md b/docs/adr/0049-leftover-pair-report-ui.md
index 4be472ef9..c0fa92da1 100644
--- a/docs/adr/0049-leftover-pair-report-ui.md
+++ b/docs/adr/0049-leftover-pair-report-ui.md
@@ -66,6 +66,5 @@ next action, not only the distance.
Depends on [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and
[ADR 0003](0003-fast-mlsirm-report-integration.md). Complete-case
coverage of the leftover map is [ADR 0168](0168-leftover-map-complete-case-coverage.md).
-
-[ADR 0003](0003-fast-mlsirm-report-integration.md). The grouping
+The grouping
comparison strip reuses this leftover store ([ADR 0149](0149-leftover-pairs-on-comparison-strip.md)).
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..5681d3789
--- /dev/null
+++ b/docs/adr/0121-persist-leftover-interaction-map.md
@@ -0,0 +1,78 @@
+# ADR 0121 — Persist leftover interaction-map coordinates
+
+**Decision status:** Accepted
+**Date:** 2026-08-24
+
+## 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 reader
+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 now exposes the Rust-backed residual interaction-map contract
+adopted by ADR 0211. LineageWeave must not fork that calculation, 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 reader-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 calculation lives in fast-mlsirm. `lineageweave/leftover_pairs.py`
+maps returned indices and values to authorized product identifiers and selects
+the closest/farthest cells; it contains no factorization or cross-term formula.
+
+## 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` already replays every
+four-digit `NNNN_*.sql` file (ADR 0166), so 0172 lands on existing
+volumes without a new allowlist entry.
+
+## 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..d7aef6bb7
--- /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-24
+
+## 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 reader 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 reader 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
+
+Readers 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/adr/0208-externalize-local-mathematical-compute.md b/docs/adr/0208-externalize-local-mathematical-compute.md
index 42a5a0591..a709246e4 100644
--- a/docs/adr/0208-externalize-local-mathematical-compute.md
+++ b/docs/adr/0208-externalize-local-mathematical-compute.md
@@ -28,10 +28,11 @@ The ecosystem product boundaries are already sufficient:
CPU/GPU implementation before LineageWeave treats a new result as governed
numerical evidence.
-LineageWeave has no standalone canonical PRD file on this exact head. Until
-one lands, `ARCHITECTURE.md` and the accepted ADR set are the product baseline;
-this absence remains a product-documentation gap, not permission to infer a
-different responsibility.
+LineageWeave's supporting PRD (`docs/product-requirements.md`) explicitly says
+that this product reconstructs and presents evidence but does not perform
+psychometric or statistical estimation. Accepted ADRs remain normative for
+architecture and policy, so the PRD and this decision express the same
+consumer-only responsibility.
## Decision
@@ -113,4 +114,3 @@ https://doi.org/10.1007/s11336-021-09762-5
Roberts, M. E., Stewart, B. M., & Tingley, D. (2019). stm: An R package for
structural topic models. *Journal of Statistical Software, 91*(2), 1–40.
https://doi.org/10.18637/jss.v091.i02
-
diff --git a/docs/adr/0211-fast-mlsirm-residual-interaction-map.md b/docs/adr/0211-fast-mlsirm-residual-interaction-map.md
new file mode 100644
index 000000000..7e69234c1
--- /dev/null
+++ b/docs/adr/0211-fast-mlsirm-residual-interaction-map.md
@@ -0,0 +1,47 @@
+# ADR 0211 — Consume fast-mlsirm residual interaction maps
+
+**Decision status:** Accepted
+**Date:** 2026-08-25
+
+## Context
+
+LineageWeave previously computed a Gabriel factorization of post-evaluation
+residuals locally. That violated the repository boundary: reusable
+psychometric arithmetic belongs to fast-mlsirm, while this product owns domain
+identifiers, authorization, persistence, and presentation.
+
+## Decision
+
+Pin and consume fast-mlsirm's versioned `residual_interaction_map` contract.
+fast-mlsirm's Rust core exclusively computes `R = Y - E`, complete-case
+admission, Gabriel coordinates, singular values, axis inertia, Euclidean map
+distance, truncated reconstruction `Rhat`, `U = R - Rhat`, and the exact
+algebraic cross term `2 Rhat U / R^2`.
+
+LineageWeave passes the ADR-defined two reader-visible axes, maps returned row
+and column indices to post and criterion identifiers, selects the deterministic
+minimum and maximum returned distances, applies ABAC at persistence/read time,
+and renders the supplied evidence. It does not reproduce any scientific
+formula. Missing or non-finite upstream values remain unavailable.
+
+The cross term is an auditable identity term, not a weight, threshold,
+psychometric score, or heuristic.
+
+## Consequences
+
+- The numerical method is reusable across products and tested once in Rust.
+- A missing or incompatible upstream contract fails closed; LineageWeave does
+ not fall back to NumPy SVD or local arithmetic.
+- Product tests mock sealed provider outputs to verify ID mapping and selection;
+ fast-mlsirm owns numerical recovery and edge-case tests.
+
+## 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/doctoring/python-mathematical-compute-boundary-audit.md b/docs/doctoring/python-mathematical-compute-boundary-audit.md
index f1dbeee83..284caea44 100644
--- a/docs/doctoring/python-mathematical-compute-boundary-audit.md
+++ b/docs/doctoring/python-mathematical-compute-boundary-audit.md
@@ -8,8 +8,9 @@ Python paths satisfy the Rust/GPU requirement.
## Product-boundary sources read
-- LineageWeave `ARCHITECTURE.md` and accepted ADRs 0003, 0132, 0145,
- 0200, 0201, and 0205. This exact head has no standalone canonical PRD.
+- LineageWeave `docs/product-requirements.md`, `ARCHITECTURE.md`, and accepted
+ ADRs 0003, 0132, 0145, 0200, 0201, and 0205. The supporting PRD makes the
+ consumer-only measurement boundary explicit while ADRs remain normative.
- TEPP `docs/product/prd-v0.4-approved.md`, whose approved TRSL-TM scope
owns temporal, relational, multilingual, topic, event, and trajectory
measurement.
diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md
index 3716656cd..5ba4a5ccf 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -14,6 +14,10 @@ operator-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 a reconstructed connection to read its inferred channel scores and Allen interval relation, or open the current branch node; compare empty, single-branch, grouped/forked, mobile-scroll, ungrouped, and long-title states before changing graph CSS. On narrow viewports, swipe the named viewport or focus it and use arrow keys to inspect the full lineage. | `--color-accent-background`, `--radius-control`, `--surface`, `--border`, `--color-focus-border`, `--size-control-min`, `LineageDag` |
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
+| `Navigation/WorkspaceNav` | Open 게시판, 고객 마스터, 달력, or Ask Agent. Admin is not a GNB tab. | `--gnb-height`, `--gnb-active-indicator-color`, `WorkspaceNav` |
+| `Evidence/OntologyExplorer` | Inspect typed people/orgs/posts, then open authorized evidence. Distinct from Event Lineage. | `--color-primary`, `--color-table-border`, `OntologyExplorer` |
+| `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` |
+| `Reports/LeftoverInteractionMap` | Read leftover-map post and criterion positions after IRT main effects, then open the named leftover-pair post. | leftover-map tokens, `LeftoverInteractionMap` |
| `Workspace/WorkspaceCalendar` | Read observed Naruon events, or open a commitment to land on that post. Fail-closed copy stays `이 범위의 일정을 아직 받을 수 없습니다`. | `--color-chip-border`, `WorkspaceCalendar`, `EvidenceStatusMark` |
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 eeaa61299..667282af6 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -674,6 +674,123 @@
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,
+.leftover-map-item[role="button"]:focus {
+ outline: none;
+}
+
+.leftover-map-person:focus-visible circle,
+.leftover-map-person:hover circle,
+.leftover-map-item[role="button"]:focus-visible rect,
+.leftover-map-item[role="button"]:hover rect {
+ stroke-width: 2.5;
+}
+
+.leftover-map-person:focus-visible,
+.leftover-map-item[role="button"]:focus-visible {
+ outline: 2px solid var(--color-primary, var(--color-accent-info));
+ outline-offset: 2px;
+}
+
+.leftover-map-key {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
+ gap: var(--space-control-gap);
+ margin: var(--space-control-gap) 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.leftover-map-key li {
+ min-width: 0;
+ font-size: var(--font-size-badge);
+ overflow-wrap: anywhere;
+}
+
+.leftover-map-key button {
+ min-height: 44px;
+ max-width: 100%;
+ padding: var(--space-chip-block) var(--space-chip-inline);
+ border: 1px solid var(--color-btn-secondary-border);
+ border-radius: var(--radius-control);
+ background: var(--color-btn-secondary-bg);
+ color: var(--color-btn-secondary-text);
+ text-align: start;
+ white-space: normal;
+}
+
+.leftover-map-key button:hover,
+.leftover-map-key button:focus-visible {
+ background: var(--color-btn-secondary-hover);
+ border-color: var(--color-focus-border);
+}
+
.lineage-dag-node:focus {
outline: none;
}
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 2dee4513d..30d391a1a 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -103,6 +103,7 @@ describe("App, authenticated", () => {
failedLineageRun?: boolean;
runningLineageRun?: boolean;
failedReportRun?: boolean;
+ emptyLeftoverMap?: boolean;
succeededReportRun?: boolean;
succeededTeppRun?: boolean;
pendingTeppRun?: boolean;
@@ -991,7 +992,7 @@ describe("App, authenticated", () => {
observed_response: 2.4,
expected_response: 2.0,
leftover_map_rank: 1,
- leftover_map_cross_share: 0.12,
+ leftover_map_cross_share: 0.21875,
leftover_map_reconstruction: 0.35,
},
{
@@ -1005,10 +1006,20 @@ describe("App, authenticated", () => {
observed_response: 0.9,
expected_response: 2.0,
leftover_map_rank: 1,
- leftover_map_cross_share: -0.24,
+ leftover_map_cross_share: 0.3512396694214876,
leftover_map_reconstruction: -0.85,
},
],
+ leftover_map_persons: options?.emptyLeftoverMap
+ ? []
+ : [
+ { post_id: "post-1", post_title: "Public post", axis_one: 0.2, axis_two: 0.4 },
+ ],
+ leftover_map_items: options?.emptyLeftoverMap
+ ? []
+ : [
+ { criterion_code: "sales_lead_specificity", axis_one: -0.1, axis_two: 0.3 },
+ ],
leftover_map_axes: [
{
axis_index: 1,
@@ -2261,7 +2272,10 @@ describe("App, authenticated", () => {
await userEvent.type(within(board).getByLabelText("Search semantic evidence"), "not found");
await userEvent.click(within(board).getByRole("button", { name: "Search" }));
- expect(within(board).getByRole("status")).toHaveTextContent("No posts match the current filters.");
+ expect(within(board).getByText("No posts match the current filters.")).toHaveAttribute(
+ "role",
+ "status",
+ );
await userEvent.click(within(board).getByRole("button", { name: "Reset filters" }));
expect(within(board).getByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
});
@@ -3481,9 +3495,10 @@ describe("App, authenticated", () => {
expect(
screen.getByRole("button", { name: "Compare Business unit (PU): Demo Report High, mean θ 0.81" }),
).not.toHaveAttribute("aria-current");
- expect(screen.getByRole("status")).toHaveTextContent(
+ const groupingStatus = screen.getByText(
"Demo Corp is the opened grouping. Read its mean θ and member posts below, then open a post.",
);
+ expect(groupingStatus).toHaveAttribute("role", "status");
expect(await screen.findByText(/Demo Corp: mean θ 0\.42/)).toBeInTheDocument();
expect(screen.queryByText(/corp-1: mean θ/)).not.toBeInTheDocument();
const openedReport = screen.getByRole("list", { name: "Opened grouping report" });
@@ -3491,7 +3506,7 @@ describe("App, authenticated", () => {
expect(
within(openedReport).getByRole("button", { name: /open report post: public post/i }),
).toBeInTheDocument();
- const status = screen.getByRole("status");
+ const status = groupingStatus;
const demoMean = screen.getByText(/Demo Corp: mean θ 0\.42/);
const weekChip = screen.getByRole("button", { name: /open report period 2026-W03/i });
expect(status.compareDocumentPosition(demoMean) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
@@ -3528,9 +3543,10 @@ describe("App, authenticated", () => {
expect(demoChip).toHaveAccessibleName(/mean θ 0\.42/);
expect(scrollIntoView).toHaveBeenCalled();
expect(periodInput).not.toHaveFocus();
- expect(screen.getByRole("status")).toHaveTextContent(
+ const groupingStatus = screen.getByText(
"Demo Corp is the opened grouping. Read its mean θ and member posts below, then open a post.",
);
+ expect(groupingStatus).toHaveAttribute("role", "status");
expect(await screen.findByText(/Demo Corp: mean θ 0\.42/)).toBeInTheDocument();
const openedReport = screen.getByRole("list", { name: "Opened grouping report" });
expect(within(openedReport).getByText(/Demo Corp: mean θ 0\.42/).closest("li")).toHaveAttribute(
@@ -3544,7 +3560,7 @@ describe("App, authenticated", () => {
});
expect(member).toHaveTextContent("θ 0.91");
expect(member).not.toHaveAttribute("aria-current");
- const status = screen.getByRole("status");
+ const status = groupingStatus;
const demoMean = screen.getByText(/Demo Corp: mean θ 0\.42/);
const weekChip = screen.getByRole("button", { name: /open report period 2026-W03/i });
expect(status.compareDocumentPosition(demoMean) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
@@ -3982,25 +3998,25 @@ describe("App, authenticated", () => {
// Leftover-map cross share is present, so it names the next action
// instead of the rank/observed-expected chain (ADR 0185).
expect(closestPair).toHaveTextContent(
- "Two leftover-map axes leave identity remainder 0.12 of raw residual after IRT main effects. Open this post to read sales-lead.",
+ "Two leftover-map axes leave identity remainder 0.22 of raw residual after IRT main effects. Open this post to read sales-lead.",
);
expect(closestPair).toHaveTextContent("R +0.40");
expect(closestPair).toHaveTextContent("Y 2.40 · E 2.00");
expect(closestPair).toHaveTextContent("rank 1");
expect(closestPair).toHaveTextContent("U +0.05");
- expect(closestPair).toHaveTextContent("2R̂U/R² 0.12");
+ expect(closestPair).toHaveTextContent("2R̂U/R² 0.22");
expect(closestPair).toHaveTextContent("R̂ +0.35");
expect(closestPair).toHaveTextContent("d 0.12");
expect(closestPair).toHaveAccessibleName("Open leftover closest pair: Public post · sales-lead");
expect(farthestPair).toHaveTextContent("Farthest leftover: Specification revision requested · negative");
expect(farthestPair).toHaveTextContent(
- "Two leftover-map axes leave identity remainder -0.24 of raw residual after IRT main effects. Open this post to read negative.",
+ "Two leftover-map axes leave identity remainder 0.35 of raw residual after IRT main effects. Open this post to read negative.",
);
expect(farthestPair).toHaveTextContent("R −1.10");
expect(farthestPair).toHaveTextContent("Y 0.90 · E 2.00");
expect(farthestPair).toHaveTextContent("rank 1");
expect(farthestPair).toHaveTextContent("U −0.25");
- expect(farthestPair).toHaveTextContent("2R̂U/R² -0.24");
+ expect(farthestPair).toHaveTextContent("2R̂U/R² 0.35");
expect(farthestPair).toHaveTextContent("R̂ −0.85");
expect(farthestPair).toHaveTextContent("d 1.84");
const memberButton = screen.getByRole("button", { name: /open report post: public post/i });
@@ -4023,6 +4039,16 @@ describe("App, authenticated", () => {
);
});
+ it("keeps report-level leftover metadata when no authorized map points remain", async () => {
+ stubBackend({ emptyLeftoverMap: true });
+ render();
+
+ await screen.findAllByText(/mean θ 0.42/);
+ expect(screen.getByLabelText("Leftover map coverage")).toBeInTheDocument();
+ expect(screen.getByLabelText("Leftover-map axis share")).toBeInTheDocument();
+ expect(screen.getByText(/leftover axis 1 82%/)).toBeInTheDocument();
+ });
+
it("shows the grouping comparison strip and switches grouping on click", async () => {
const fetchMock = stubBackend();
render();
@@ -4034,9 +4060,11 @@ describe("App, authenticated", () => {
await userEvent.click(
screen.getByRole("button", { name: "Compare Thread group: A-100, mean θ 0.81" }),
);
- expect(screen.getByRole("status")).toHaveTextContent(
- "A-100 is the opened grouping. Read its mean θ and member posts below, then open a post.",
- );
+ expect(
+ screen.getByText(
+ "A-100 is the opened grouping. Read its mean θ and member posts below, then open a post.",
+ ),
+ ).toHaveAttribute("role", "status");
expect(
screen.getByRole("button", {
name: /open leftover closest pair from comparison: public post/i,
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 76ff51dec..ce40c23e9 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,6 @@
import { AdminPanel } from "./components/AdminPanel";
import { LeftoverPairList } from "./components/LeftoverPairList";
+import { LeftoverInteractionMap } from "./LeftoverInteractionMap";
import { WorkspaceCalendar } from "./components/WorkspaceCalendar";
import { focusedGraphMustReset } from "./focusedGraphSelection";
@@ -3644,6 +3645,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_map_coverage && report.leftover_map_coverage.scored_post_count > 0 && (