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