From 86401996356bb1ab5589e5e135e5e64df8267a00 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 09:36:22 +0000 Subject: [PATCH 01/20] feat: persist leftover interaction-map coordinates (v2.12.19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After IRT main effects, persist complete-case ξ/ζ leftover-map coordinates and render the 2D Jeon/Gabriel map above leftover pairs (ADR 0121 / 0126). Click a post node or leftover-pair criterion to open that post. Rank-0/1 maps pad unused axes with zero. Hidden posts stay hidden. Never invent a leftover score or a theta. --- AGENTS.md | 7 +- ARCHITECTURE.md | 7 +- .../2.12.19-leftover-interaction-map.md | 9 + CHANGELOG.md | 10 + CLAUDE.md | 3 +- backend/app/main.py | 20 +- backend/app/report_ingestion.py | 86 ++++++- backend/tests/test_api.py | 18 ++ .../0121-persist-leftover-interaction-map.md | 77 ++++++ docs/adr/0126-leftover-map-criterion-node.md | 60 +++++ docs/storybook-inventory.md | 1 + frontend/package.json | 2 +- frontend/src/App.css | 84 ++++++ frontend/src/App.test.tsx | 36 +++ frontend/src/App.tsx | 11 + .../src/LeftoverInteractionMap.stories.tsx | 85 ++++++ frontend/src/LeftoverInteractionMap.test.tsx | 192 ++++++++++++++ frontend/src/LeftoverInteractionMap.tsx | 242 ++++++++++++++++++ frontend/src/api.ts | 15 ++ frontend/src/i18n.test.ts | 13 + frontend/src/i18n.ts | 32 +++ lineageweave/leftover_pairs.py | 83 +++++- lineageweave/period_report.py | 38 ++- .../0172_report_leftover_interaction_map.sql | 65 +++++ .../0172_report_leftover_interaction_map.sql | 2 + pyproject.toml | 2 +- scripts/seed_demo_data.py | 33 +++ tests/test_leftover_pairs.py | 124 ++++++++- tests/test_migration_replay.py | 11 + tests/test_period_report.py | 8 + tests/test_schema.py | 30 +++ tests/test_static_sql_review_contracts.py | 2 +- uv.lock | 2 +- 33 files changed, 1379 insertions(+), 31 deletions(-) create mode 100644 CHANGELOG.d/2.12.19-leftover-interaction-map.md create mode 100644 docs/adr/0121-persist-leftover-interaction-map.md create mode 100644 docs/adr/0126-leftover-map-criterion-node.md create mode 100644 frontend/src/LeftoverInteractionMap.stories.tsx create mode 100644 frontend/src/LeftoverInteractionMap.test.tsx create mode 100644 frontend/src/LeftoverInteractionMap.tsx create mode 100644 migrations/0172_report_leftover_interaction_map.sql create mode 100644 migrations/rollback/0172_report_leftover_interaction_map.sql diff --git a/AGENTS.md b/AGENTS.md index b34194c8d..49f8698a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,11 +192,14 @@ 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 +Period leftover pairs (ADR 0048 / 0049 / 0119 / 0121 / 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 +the factorization. Complete-case person ξ and item ζ coordinates +persist to `report_leftover_map_person` / `report_leftover_map_item` +and render above leftover pairs; click a post node or leftover-pair +criterion to open that post. 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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fe6a94941..f404067d6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -594,9 +594,10 @@ on those same fixed parameters (Kim, 2006 FIPC). After scoring, information at the group's mean θ (Lord, 1980 max-info CAT). Rankings persist to `report_item_information`. After those IRT main effects, residual SVD leftover pairs on two Gabriel axes (Jeon et al., 2021; -ADR 0048 / 0119 / 0162 / 0163 / 0164) persist to `report_leftover_pair` with +ADR 0048 / 0119 / 0121 / 0126 / 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 persist to +`report_leftover_map_person` / `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 +611,7 @@ 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, 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.19-leftover-interaction-map.md b/CHANGELOG.d/2.12.19-leftover-interaction-map.md new file mode 100644 index 000000000..3ec017112 --- /dev/null +++ b/CHANGELOG.d/2.12.19-leftover-interaction-map.md @@ -0,0 +1,9 @@ +## 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. Hidden posts stay hidden. Never invent a + leftover score or a theta. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d0005ecd..20059cac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,16 @@ All notable changes to this project are documented here. Format follows environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. +## [2.12.19] - 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.18] - 2026-08-24 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 42b091e42..dc83ef118 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,8 @@ and to read its mean θ and member posts, then open a post. Those members land immediately under that next action, ahead of Other Corp and the week strip. After `make seed`, leftover closest/farthest pairs sit above the member list with leftover-map rank; rank 0 names no -leftover structure. Opening Public post names the next action: read +leftover structure. The leftover interaction map sits above those +pairs; click a post node or leftover-pair criterion to open that post. Opening Public post names the next action: read Event Lineage, Keyman, and evaluation on that post. The popup Event Lineage DAG marks that post current. After that current node, the popup names Keyman and evaluation as the next read. After landed 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/0121-persist-leftover-interaction-map.md b/docs/adr/0121-persist-leftover-interaction-map.md new file mode 100644 index 000000000..96b586481 --- /dev/null +++ b/docs/adr/0121-persist-leftover-interaction-map.md @@ -0,0 +1,77 @@ +# 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 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` 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..139801706 --- /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 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..2d3449178 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -13,6 +13,7 @@ buyer-facing control you can click before changing product CSS. | `Lineage/LineageDag` | Open the current branch node; compare empty, grouped/forked, ungrouped, and long-title states before changing graph CSS. | `--surface`, `--border`, `LineageDag` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | | `Reports/LeftoverPairList` | Read residual R, observed Y, expected E, map rank, and distance after IRT main effects, then open the named post. | `--color-chip-border`, `LeftoverPairList` | +| `Reports/LeftoverInteractionMap` | Read leftover-map post and criterion positions after IRT main effects, then open the named leftover-pair post. | leftover-map tokens, `LeftoverInteractionMap` | Repeated web objects must use `frontend/src/styles/tokens.css` and a module under `frontend/src/components/`. Do not add a second Node package manager; diff --git a/frontend/package.json b/frontend/package.json index 594ba6bee..844794550 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.18", + "version": "2.12.19", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index b88ee953a..ecef261c3 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, +.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; +} + .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..dfbde79d4 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 { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useAuth } from "react-oidc-context"; @@ -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, + observed_response: 2.4, + expected_response: 2.0, + leftover_map_rank: 1, + }, + { + 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, + observed_response: 0.9, + expected_response: 2.0, + leftover_map_rank: 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, + observed_response: 2.4, + expected_response: 2.0, + leftover_map_rank: 1, + }, + ], + }, +}; + +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..b4c0cb578 --- /dev/null +++ b/frontend/src/LeftoverInteractionMap.test.tsx @@ -0,0 +1,192 @@ +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, + observed_response: 2.4, + expected_response: 2.0, + leftover_map_rank: 1, +}; + +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, + observed_response: 0.9, + expected_response: 2.0, + leftover_map_rank: 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")}
+ + {pairs.map((pair) => { + const person = personById[pair.post_id]; + const item = itemById[pair.criterion_code]; + if (!person || !item) return null; + const kindClass = + pair.pair_kind === "farthest" ? "leftover-map-farthest" : "leftover-map-closest"; + return ( + + + {t(pair.pair_kind === "farthest" ? "Farthest leftover" : "Closest leftover")}:{" "} + {person.label} · {item.label} + + + ); + })} + {projected.items.map((point) => { + const pair = leftoverPairForCriterion(pairs, point.id); + const pairClass = point.pairKinds.map((kind) => ` leftover-map-${kind}`).join(""); + if (pair) { + return ( + onSelectPost(pair.post_id)} + onKeyDown={(event) => activateLeftoverMapNode(event, pair.post_id, onSelectPost)} + > + + + {truncateLabel(point.label)} + + + {tf("Open this leftover map criterion to read the leftover pair post: {label}", { + label: point.label, + })} + + + ); + } + return ( + + + + {truncateLabel(point.label)} + + {tf("Criterion: {label}", { label: point.label })} + + ); + })} + {projected.persons.map((point) => ( + ` leftover-map-${kind}`).join("")}`} + transform={`translate(${point.x}, ${point.y})`} + role="button" + tabIndex={0} + aria-label={tf("Open leftover map post: {label}", { label: point.label })} + onClick={() => onSelectPost(point.id)} + onKeyDown={(event) => activateLeftoverMapNode(event, point.id, onSelectPost)} + > + + + {truncateLabel(point.label)} + + {tf("Open this post on the leftover map: {label}", { label: point.label })} + + ))} + +
+ ); +} 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..1be12fd37 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..c2a7fdad3 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -375,6 +375,14 @@ const TRANSLATIONS: Partial>> = { "Interactive questions are unavailable right now; saved evidence remains available.": "대화형 질문을 지금 사용할 수 없습니다. 저장된 근거는 계속 확인할 수 있습니다.", "Leftover pairs": "잔여 쌍", + "Leftover interaction map": "잔여 상호작용 맵", + "Leftover interaction map after main effects": "주효과 이후 잔여 상호작용 맵", + "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}", "Closest leftover": "가장 가까운 잔여", "Farthest leftover": "가장 먼 잔여", "Leftover residual R {residual} after IRT main effects. Open this post to read {criterion}.": @@ -752,6 +760,14 @@ const TRANSLATIONS: Partial>> = { "Interactive questions are unavailable right now; saved evidence remains available.": "交互式提问暂不可用;已保存的证据仍可查看。", "Leftover pairs": "残余配对", + "Leftover interaction map": "残差交互图", + "Leftover interaction map after main effects": "主效应后的残差交互图", + "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}", "Closest leftover": "最近残余", "Farthest leftover": "最远残余", "Leftover residual R {residual} after IRT main effects. Open this post to read {criterion}.": @@ -1129,6 +1145,14 @@ const TRANSLATIONS: Partial>> = { "Interactive questions are unavailable right now; saved evidence remains available.": "対話形式の質問は現在利用できません。保存された証拠は確認できます。", "Leftover pairs": "残差ペア", + "Leftover interaction map": "残差インタラクションマップ", + "Leftover interaction map after main effects": "主効果後の残差インタラクションマップ", + "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}", "Closest leftover": "最も近い残差", "Farthest leftover": "最も遠い残差", "Leftover residual R {residual} after IRT main effects. Open this post to read {criterion}.": @@ -1506,6 +1530,14 @@ 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ư", + "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", + "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}", "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}.": diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py index 297d47012..3ab6feb5b 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, ) 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/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/pyproject.toml b/pyproject.toml index 9a3975230..8b01c9850 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.12.18" +version = "2.12.19" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/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..53e0c4f1c 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,127 @@ 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_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..7e2484f4c 100644 --- a/tests/test_period_report.py +++ b/tests/test_period_report.py @@ -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..58baf70f7 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: 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) diff --git a/uv.lock b/uv.lock index 112d46840..095fa6696 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.12.18" +version = "2.12.19" source = { editable = "." } dependencies = [ { name = "certifi" }, From 769095b2353f427fc2bd65837c1eca46056abdad Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 09:39:56 +0900 Subject: [PATCH 02/20] fix(adr): allocate coverage decision 0202 --- CHANGELOG.md | 2 +- docs/adr/0048-persist-lsirm-leftover-pairs.md | 2 +- docs/adr/0049-leftover-pair-report-ui.md | 2 +- ...overage.md => 0202-leftover-map-complete-case-coverage.md} | 2 +- lineageweave/leftover_pairs.py | 4 ++-- lineageweave/period_report.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) rename docs/adr/{0183-leftover-map-complete-case-coverage.md => 0202-leftover-map-complete-case-coverage.md} (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fcb71874..edd017257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ All notable changes to this project are documented here. Format follows - The PROV-O support profile now mints its product class mappings only in the canonical lowercase namespace while importing the legacy compatibility map. - Period leftover maps now persist how many scored posts entered the - complete-case Gabriel factorization (ADR 0183). The leftover pair + complete-case Gabriel factorization (ADR 0202). The leftover pair list is captioned “Leftover map used N of M scored posts (complete-case)”; incomplete rows stay excluded, never filled with zero. diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md index 8bfd67c67..6084c29df 100644 --- a/docs/adr/0048-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md @@ -52,7 +52,7 @@ the IRT matrix is unusable. A rank-0 residual still emits a stable pair so `make seed` is not empty; the stored distance is then zero, not a fabricated interaction. -The UI contract is ADR 0049. Complete-case coverage is ADR 0183. +The UI contract is ADR 0049. Complete-case coverage is ADR 0202. ## Consequences diff --git a/docs/adr/0049-leftover-pair-report-ui.md b/docs/adr/0049-leftover-pair-report-ui.md index 0047a261a..c29ec4225 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -54,4 +54,4 @@ 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 0183](0183-leftover-map-complete-case-coverage.md). +coverage of the leftover map is [ADR 0202](0202-leftover-map-complete-case-coverage.md). diff --git a/docs/adr/0183-leftover-map-complete-case-coverage.md b/docs/adr/0202-leftover-map-complete-case-coverage.md similarity index 98% rename from docs/adr/0183-leftover-map-complete-case-coverage.md rename to docs/adr/0202-leftover-map-complete-case-coverage.md index e9e34a195..c2fe29f38 100644 --- a/docs/adr/0183-leftover-map-complete-case-coverage.md +++ b/docs/adr/0202-leftover-map-complete-case-coverage.md @@ -1,4 +1,4 @@ -# ADR 0183 — Name leftover complete-case coverage +# ADR 0202 — Name leftover complete-case coverage **Decision status:** Accepted **Date:** 2026-08-24 diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py index f7f893dbb..2ca5f9d35 100644 --- a/lineageweave/leftover_pairs.py +++ b/lineageweave/leftover_pairs.py @@ -16,7 +16,7 @@ item leftover-map coordinates ξ / ζ are exposed on ``LeftoverInteractionMap`` for callers that persist the full biplot (ADR 0121). Axis share is the Gabriel inertia of the first two -leftover-map axes (ADR 0148). Complete-case coverage (ADR 0183) names +leftover-map axes (ADR 0148). Complete-case coverage (ADR 0202) names how many scored posts entered that rectangle. Each pair also names unexplained leftover ``U = R − R̂`` after two-axis Gabriel reconstruction ``R̂ = ξ_{1:2} · ζ_{1:2}`` (ADR 0182) so the leftover @@ -232,7 +232,7 @@ def leftover_map_from_residual( ) ) if not candidates: - # ADR 0183: without a complete-case Gabriel map there is no + # ADR 0202: without a complete-case Gabriel map there is no # leftover pair to name. The report carries coverage counts # instead of a center-distance stand-in pair. return LeftoverInteractionMap(pairs=(), persons=persons, items=items, axes=axes) diff --git a/lineageweave/period_report.py b/lineageweave/period_report.py index 819de1c79..c139fbce7 100644 --- a/lineageweave/period_report.py +++ b/lineageweave/period_report.py @@ -23,7 +23,7 @@ positions. Closest / farthest pairs are the min / max Euclidean distances on that map (Jeon et al., 2021, eq. 3). Leftover-map axis share is Gabriel inertia ``σ_k² / Σ_j σ_j²`` of residual SVD axes 1 -and 2 (ADR 0148). Complete-case coverage (ADR 0183) names how many +and 2 (ADR 0148). Complete-case coverage (ADR 0202) names how many scored posts entered the factorization; incomplete rows are excluded, never filled with zero. ``fast-mlsirm`` has no leftover-pair API; this module does not invent a second IRT fit and does not fork LSIRM. From 1aa69d2903b1a31d2af86d991ef5377d172640c5 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 10:03:13 +0900 Subject: [PATCH 03/20] docs: remove leaked merge-conflict tail --- CLAUDE.md | 57 ------------------------------------------------------- 1 file changed, 57 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9b5f4d049..f32325abc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,60 +54,3 @@ migration replay (ADR 0166), docstring coverage, and the measurement boundary are all stated in [AGENTS.md](AGENTS.md) -- read it before changing code, tests, or runtime policy rather than restating anything here. -======= -period-report run on the same snapshot (ADR 0013 / ADR 0024). The TEPP path goes through `tepp_client`. A missing -transport or an unused accepted envelope is Failed -(`tepp_not_available` / `tepp_result_not_persisted`). Do not invent a -theta or a local psychometric substitute. The home list caption stays -`kind · status · entity`; the machine failure code is detail-only -(ADR 0014). Open a Failed TEPP row, then connect a live TEPP -transport. A failed lineage row retries reconstruction -- it does not -mention TEPP. A failed period-report row rebuilds the report. A -pending TEPP row does not claim a calibrated measurement. A pending -lineage row says reconstruction has not started yet. -Digest prefixes stay audible; hover a prefix to read the full digest. -Opening a cutoff title shows the live post. Titles marked updated -after cutoff were rewritten after the run; the opened body names -both clocks and shows **Body this run knew** beside the live -rewrite. Compare those two texts before treating the live body as -reconstructed evidence (ADR 0016 / 0025). -`POST /api/analysis-runs` records Pending lineage only on an -authorized cutoff capture (ADR 0017). TEPP and period-report kinds -are 422. The Request button waits until affiliated corps load; choose -a corp if the token walks more than one. `POST /api/analysis-runs/{id}/start` -commits Running plus a durable outbox row, then reconstructs that -frozen cutoff bag (ADR 0021 / ADR 0023) or submits TEPP through -`tepp_client` (ADR 0022). A missing transport or unused accepted -envelope is Failed. Failed TEPP is terminal — connect a TEPP -transport from that Failed row. Create does not invent a Pending -TEPP row. Do not invent a theta. Hover the Result prefix to read -the parent-choice digest. -After `make seed`, open **Period report · Succeeded · Demo Corp**, -then **Open period report 2026-W02**. The home week is already -2026-W02, so the grouping comparison strip lands on Demo Corp. Report -grouping is Corporate entity and Demo Corp is current. The focused -chip name contains `Corporate entity: Demo Corp` and the persisted -mean θ. The period-report panel says Demo Corp is the opened grouping -and to read its mean θ and member posts, then open a post. Those -members land immediately under that next action, ahead of Other Corp -and the week strip. After `make seed`, leftover closest/farthest pairs -sit above the member list with leftover-map rank (rank 0 names no -leftover structure) and unexplained leftover `U` next to leftover-map -distance `d`. Leftover-map axis share badges name Gabriel inertia -of axes 1 and 2; open a leftover pair to read the post–criterion cell. -The shares do not invent a leftover score. Opening Public post names the next action: read -Event Lineage, Keyman, and evaluation on that post. The popup Event -Lineage DAG marks that post current. After that current node, the -popup names Keyman and evaluation as the next read. After landed -evaluation, the popup names the first Keyman as the next read. After -landed Ada West related, the popup names the first related node as -the next read. After that next action, the popup lands Priya Nair -related nodes. After those related nodes land, the popup names Ask -about this lineage as the next read. After that next action, the -popup lands Ask about this lineage. After landed chat, the popup names -the first Ask. After that next action, the popup lands the first Ask -answer. After landed first Ask answer, the popup names the first -cited source. After that next action, the popup lands the first cited -evidence. Changing the week first still -focuses the report period field. Mean θ stays on the period-report -panel. From 380944d684430fe778d7715922bf8a214e240107 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 11:00:15 +0900 Subject: [PATCH 04/20] test: count interaction-map SQL review site --- tests/test_static_sql_review_contracts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_static_sql_review_contracts.py b/tests/test_static_sql_review_contracts.py index 31c7896bc..ae06e781d 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 = 36 +EXPECTED_SQL_SUPPRESSION_COUNT = 37 @pytest.mark.parametrize("relative_path", SQL_REVIEW_PATHS) From 69c050781384e2702f9865f00314c2e7829bfb28 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 11:48:23 +0900 Subject: [PATCH 05/20] docs(adr): fix stale leftover-map coverage citation and dedupe list ARCHITECTURE.md's leftover-pair ADR enumeration carried a stray "0183" (Four-destination Korean GNB chrome -- unrelated) and a duplicate "0148" citation from an earlier auto-merge. The "Complete-case leftover-map coverage" sentence cited ADR 0183 too, which is dangling: docs/adr/0168-leftover-map-complete-case-coverage.md was renamed to 0202 earlier in this branch's history, and AGENTS.md's matching sentence still pointed at the deleted 0168 path. Point both at ADR 0202 (the branch's actual, already-referenced coverage ADR) and drop the two ADR 0183 mentions in ARCHITECTURE.md's Phase 6e enumeration, which are about the four-destination GNB, not leftover pairs. --- AGENTS.md | 2 +- ARCHITECTURE.md | 17 ++++++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6544da08d..253739bce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,7 +273,7 @@ in Post quality (ADR 0158). Two-axis reconstruction `R̂` is not persisted. Leftover-map axis share (ADR 0148) is Gabriel inertia of residual SVD axes 1 and 2 and persists to `report_leftover_map_axis`. Rank-0 residuals emit two zero-share axes; the shares are report-level -and are not a leftover score. Complete-case coverage (ADR 0168) persists to +and are not a leftover score. Complete-case coverage (ADR 0202) persists to `report_leftover_map_coverage` and captions the pair list with how many scored posts entered the map. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2e3742284..c45941c25 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -596,16 +596,15 @@ 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 / 0121 / 0126 / 0148 / 0158 / 0162 / 0163 / -0164 / 0182 / -0183) persist to `report_leftover_pair` with signed residual `R`, -observed `Y`, expected `E[Y|θ, item]`, full leftover-map rank, and -unexplained leftover `U = R − R̂` named on the pair row. Complete-case -leftover-map coordinates persist to `report_leftover_map_person` / -`report_leftover_map_item`. Leftover-map axis share (Gabriel inertia of -residual SVD axes 1 and 2; ADR 0148) persists to +ADR 0017 / 0048 / 0049 / 0119 / 0126 / 0158 / 0162 / 0163 / 0164 / 0182) +persist to `report_leftover_pair` with signed residual `R`, observed `Y`, +expected `E[Y|θ, item]`, full leftover-map rank, and unexplained leftover +`U = R − R̂` named on the pair row. Complete-case leftover-map +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 -0183) persists to `report_leftover_map_coverage` so readers see how +0202) persists to `report_leftover_map_coverage` so readers see how many scored posts entered the factorization -- without a complete-case rectangle neither a pair nor a coordinate is persisted either. Results persist to From 085549518c4aafff59b32b7d3193ea62a4b28c3e Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 14:19:50 +0900 Subject: [PATCH 06/20] docs: align leftover coverage ADR references --- CHANGELOG.d/2.12.17-leftover-map-coverage.md | 2 +- migrations/0168_report_leftover_map_coverage.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.d/2.12.17-leftover-map-coverage.md b/CHANGELOG.d/2.12.17-leftover-map-coverage.md index efa2db5e2..28c7e91e1 100644 --- a/CHANGELOG.d/2.12.17-leftover-map-coverage.md +++ b/CHANGELOG.d/2.12.17-leftover-map-coverage.md @@ -3,7 +3,7 @@ ## Added - Period leftover maps now persist how many scored posts entered the - complete-case Gabriel factorization (ADR 0183). Missing cells stay + complete-case Gabriel factorization (ADR 0202). Missing cells stay out of the map; they are never treated as zero. - After seed, the leftover pair list is captioned “Leftover map used N of M scored posts (complete-case)” so a sparse diff --git a/migrations/0168_report_leftover_map_coverage.sql b/migrations/0168_report_leftover_map_coverage.sql index ada23f9a6..6e85f5d09 100644 --- a/migrations/0168_report_leftover_map_coverage.sql +++ b/migrations/0168_report_leftover_map_coverage.sql @@ -1,4 +1,4 @@ --- ADR 0183: persist leftover complete-case coverage (map used N of M +-- ADR 0202: persist leftover complete-case coverage (map used N of M -- scored posts). CREATE IF NOT EXISTS so a volume that already ran -- 0001 still upgrades. Incomplete rows stay excluded; missing cells -- are never stored as zero. From 96b8896ad68c8cfe314f39dc7956854020ef6974 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 15:58:17 +0900 Subject: [PATCH 07/20] fix: hide incomplete leftover maps --- docs/adr/0182-leftover-map-unexplained.md | 2 +- frontend/src/App.test.tsx | 11 ++++++++++- frontend/src/App.tsx | 4 ++-- lineageweave/leftover_pairs.py | 2 +- tests/test_leftover_pairs.py | 1 + 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/adr/0182-leftover-map-unexplained.md b/docs/adr/0182-leftover-map-unexplained.md index 1993ad7df..e7cc46fca 100644 --- a/docs/adr/0182-leftover-map-unexplained.md +++ b/docs/adr/0182-leftover-map-unexplained.md @@ -38,7 +38,7 @@ length (0181 on the length stack), leftover-map cosine (0180), leftover-map inner product (0179), leftover residual disclosure (0178), leftover observed `Y` / expected `E` (0177), leftover-map rank (0172), two-axis leftover-map distance (0166), leftover coverage -(0183), leftover-map axis share (0148), or leftover interaction-map +(0168), leftover-map axis share (0148), or leftover interaction-map persistence (0121). ## Decision diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 13e94107c..566cffbb6 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -118,6 +118,7 @@ describe("App, authenticated", () => { contentAfterSummary?: boolean; askLineageGraph?: boolean; askImageCitation?: boolean; + criteriaOnlyLeftoverMap?: boolean; }): ReturnType & { releaseMe: () => void; releasePostOne: () => void } { const statusLabel: Record = { open: "Open", @@ -997,7 +998,7 @@ describe("App, authenticated", () => { leftover_map_cross_share: -0.24, }, ], - leftover_map_persons: [ + leftover_map_persons: options?.criteriaOnlyLeftoverMap ? [] : [ { post_id: "post-1", post_title: "Public post", axis_one: -0.5, axis_two: 0.1 }, { post_id: "post-2", @@ -3959,6 +3960,14 @@ describe("App, authenticated", () => { await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); }); + it("does not render a leftover map without authorized post coordinates", async () => { + stubBackend({ criteriaOnlyLeftoverMap: true }); + render(); + + expect(await screen.findByLabelText("Leftover map coverage")).toBeInTheDocument(); + expect(screen.queryByLabelText("Leftover interaction map")).not.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 8c8b78170..790bbb584 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3544,8 +3544,8 @@ 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_persons && report.leftover_map_persons.length > 0 && + report.leftover_map_items && report.leftover_map_items.length > 0 && ( None: assert leftover_map.persons == () assert leftover_map.items == () assert leftover_map.pairs == () + assert leftover_map.axes == () def test_leftover_map_pads_rank_one_axis_two_to_zero() -> None: From acd44dfe0db3efea9574a43bf3749d8f02c2ca4a Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 16:47:04 +0900 Subject: [PATCH 08/20] fix: preserve validated OIDC return location --- frontend/src/App.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d9b34cef..3c184a9d7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4979,7 +4979,8 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean