diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3a55015f5..07c5c5fd2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -606,15 +606,20 @@ on those same fixed parameters (Kim, 2006 FIPC). After scoring, information at the group's mean θ (Lord, 1980 max-info CAT). Rankings persist to `report_item_information`. After those IRT main effects, residual SVD leftover pairs on two Gabriel axes (Jeon et al., 2021; -ADR 0017 / 0048 / 0049 / 0119 / 0148 / 0158 / 0162 / 0163 / 0164 / 0168 / -0182 / 0185 / 0201) persist to `report_leftover_pair` with signed residual `R`, -observed `Y`, expected `E[Y|θ, item]`, full leftover-map rank, unexplained -leftover, ADR 0201 reconstruction evidence, and ADR 0185 cross-share evidence. -Those ADRs are the normative mathematical and storage contracts. Leftover-map axis share -(Gabriel inertia of residual SVD axes 1 and 2; ADR 0148) persists to +ADR 0017 / 0048 / 0049 / 0119 / 0121 / 0126 / 0148 / 0158 / 0162 / 0163 / +0164 / 0168 / 0182 / 0185 / 0201) persist to `report_leftover_pair` with signed +residual `R`, observed `Y`, expected `E[Y|θ, item]`, full leftover-map rank, +unexplained leftover `U = R − R̂`, and leftover-map cross share +`x = 2 R̂ U / R²`; signed reconstruction `R̂` also persists so the raw-residual +identity remains auditable. Complete-case coordinates persist to +`report_leftover_map_person` / `report_leftover_map_item` (ADR 0121). +Leftover-map axis share (Gabriel inertia of residual SVD axes 1 and 2; +ADR 0148) persists to `report_leftover_map_axis`. Complete-case leftover-map coverage (ADR 0168) persists to `report_leftover_map_coverage` so readers see how -many scored posts entered the factorization. Results persist to +many scored posts entered the factorization -- without a complete-case +rectangle neither a pair nor a coordinate is persisted either. Results +persist to `report_period_score` / `report_member_score`. `GET /api/reports/{grouping}` lists the trend; `GET /api/reports/{grouping}/{period}` is ABAC-filtered; @@ -629,12 +634,10 @@ open ticket title, status lookup label, and due date when one exists. The home p the actual mean θ, the FIPC delta, the CAT-selected item, leftover closest/farthest pairs (signed residual `R`, observed `Y`, expected `E`, full rank, and two-axis leftover-map distance `d` after IRT main -effects) above the member list, leftover-map axis share for residual -SVD axes 1 and 2, and complete-case coverage captions (map used N of M -scored posts), plus the - -closest/farthest pairs above the member list, leftover pairs on the -grouping comparison strip, and the +effects) above the member list and on the grouping comparison strip, +the leftover interaction map, leftover-map axis share for residual SVD +axes 1 and 2, complete-case coverage captions (map used N of M scored +posts), plus the PU / corp / thread comparison -- never a placeholder. TEPP is unchanged. ## Phase 6b: Knowledge Graph as a real Ontology + Semantic Layer diff --git a/CHANGELOG.d/2.12.19-leftover-interaction-map.md b/CHANGELOG.d/2.12.19-leftover-interaction-map.md new file mode 100644 index 000000000..f24501cbf --- /dev/null +++ b/CHANGELOG.d/2.12.19-leftover-interaction-map.md @@ -0,0 +1,11 @@ +## 2.12.19 — Persist leftover interaction-map coordinates + +- After IRT main effects, persist complete-case leftover-map person ξ + and item ζ coordinates (`report_leftover_map_person` / + `report_leftover_map_item`) and render the 2D Jeon / Gabriel map + above leftover pairs (ADR 0121 / 0126). Click a post node or a + leftover-pair criterion to open that post. Rank-0 and rank-1 maps + pad unused axes with zero. The Rust-backed `fast-mlsirm` contract owns + every numerical result; LineageWeave only maps identifiers, persists, + and presents it. Hidden posts stay hidden. Never invent a leftover score + or a theta. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf0eb0f8..a8bf5cc29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,10 @@ All notable changes to this project are documented here. Format follows ### Changed +- Residual interaction-map arithmetic now comes from the Rust-backed + `fast-mlsirm` contract. LineageWeave maps returned indices to product + identifiers and persists the evidence; it no longer computes residuals, + factorization, coordinates, distances, reconstruction, or cross-share. - ADRs 0011 and 0065 now include APA 7th References for the dated W3C PROV-O and PROV-DM Recommendations (30 April 2013). Decisions are unchanged. @@ -222,6 +226,16 @@ All notable changes to this project are documented here. Format follows omits the badge rather than inventing a leftover score. Two-axis reconstruction `R̂` stays internal and is not persisted. +## [2.12.20] - 2026-08-24 + +### Added + +- Period reports now persist leftover interaction-map coordinates ξ / ζ + after IRT main effects and render a 2D Jeon / Gabriel map above leftover + pairs (ADR 0121 / 0126). Click a post node or a leftover-pair criterion + node to open that post. Rank-0 and rank-1 maps pad unused axes with + zero. Hidden posts stay hidden. Never invent a leftover score or a theta. + ## [2.12.19] - 2026-08-24 ### Added diff --git a/backend/app/main.py b/backend/app/main.py index b53907977..7d3daf7b1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2741,6 +2741,12 @@ async def read_period_reports( if _can_see_post(account, pair) and not _is_synthetic_demo_member(pair, demo_entity_ids) ] + leftover_map_persons = [ + person + for person in report.get("leftover_map_persons", []) + if _can_see_post(account, person) + and not _is_synthetic_demo_member(person, demo_entity_ids) + ] members = [ { key: value @@ -2769,12 +2775,29 @@ async def read_period_reports( } for pair in leftover_pairs ] + leftover_map_persons = [ + { + key: value + for key, value in person.items() + if key + not in { + "has_real_source_context", + "visibility_code", + "corporate_entity_id", + "process_unit_id", + } + } + for person in leftover_map_persons + ] + leftover_map_items = list(report.get("leftover_map_items", [])) leftover_map_axes = list(report.get("leftover_map_axes", [])) visible.append( { **report, "members": members, "leftover_pairs": leftover_pairs, + "leftover_map_persons": leftover_map_persons, + "leftover_map_items": leftover_map_items, "leftover_map_axes": leftover_map_axes, "post_count": len(members), } diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index 4539710d6..190edab03 100644 --- a/backend/app/report_ingestion.py +++ b/backend/app/report_ingestion.py @@ -351,7 +351,7 @@ async def persist_period_report( period_code: str, report: PeriodReport, ) -> None: - """Replace the stored report, member scores, leftover pairs, leftover-map axes, leftover coverage, and item bank.""" + """Replace the stored report, member scores, leftover pairs, leftover map, leftover-map axes, leftover coverage, and item bank.""" await conn.execute( """ delete from report_period_score @@ -465,6 +465,38 @@ async def persist_period_report( pair.leftover_map_cross_share, pair.leftover_map_reconstruction, ) + for person in report.leftover_map_persons: + await conn.execute( + """ + insert into report_leftover_map_person ( + grouping_kind, grouping_key, period_code, rubric_version, + post_id, axis_one, axis_two + ) values ($1,$2,$3,$4,$5,$6,$7) + """, + grouping_kind, + grouping_key, + period_code, + RUBRIC_VERSION, + person.post_id, + person.axis_one, + person.axis_two, + ) + for item in report.leftover_map_items: + await conn.execute( + """ + insert into report_leftover_map_item ( + grouping_kind, grouping_key, period_code, rubric_version, + criterion_code, axis_one, axis_two + ) values ($1,$2,$3,$4,$5,$6,$7) + """, + grouping_kind, + grouping_key, + period_code, + RUBRIC_VERSION, + item.criterion_code, + item.axis_one, + item.axis_two, + ) for axis in report.leftover_map_axes: await conn.execute( """ @@ -664,6 +696,32 @@ async def fetch_period_reports( period_code, RUBRIC_VERSION, ) + # Safe SQL: the source-context expression is an immutable schema fragment; report keys are bound. + leftover_map_persons = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + select lp.grouping_key, lp.post_id, lp.axis_one, lp.axis_two, p.post_title, + p.visibility_code, p.corporate_entity_id, p.process_unit_id, + ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context + from report_leftover_map_person lp + join source_post p on p.post_id = lp.post_id + where lp.grouping_kind = $1 and lp.period_code = $2 and lp.rubric_version = $3 + order by lp.grouping_key, p.post_title + """, + grouping_kind, + period_code, + RUBRIC_VERSION, + ) + leftover_map_items = await conn.fetch( + """ + select grouping_key, criterion_code, axis_one, axis_two + from report_leftover_map_item + where grouping_kind = $1 and period_code = $2 and rubric_version = $3 + order by grouping_key, criterion_code + """, + grouping_kind, + period_code, + RUBRIC_VERSION, + ) leftover_axes = await conn.fetch( """ select grouping_key, axis_index, leftover_singular_value, leftover_share @@ -700,6 +758,12 @@ async def fetch_period_reports( leftover_by_group: dict[str, list[asyncpg.Record]] = defaultdict(list) for row in leftover: leftover_by_group[row["grouping_key"]].append(row) + leftover_persons_by_group: dict[str, list[asyncpg.Record]] = defaultdict(list) + for row in leftover_map_persons: + leftover_persons_by_group[row["grouping_key"]].append(row) + leftover_items_by_group: dict[str, list[asyncpg.Record]] = defaultdict(list) + for row in leftover_map_items: + leftover_items_by_group[row["grouping_key"]].append(row) leftover_axes_by_group: dict[str, list[asyncpg.Record]] = defaultdict(list) for row in leftover_axes: leftover_axes_by_group[row["grouping_key"]].append(row) @@ -816,6 +880,29 @@ async def fetch_period_reports( } for row in leftover_by_group.get(header["grouping_key"], []) ], + "leftover_map_persons": [ + { + "post_id": str(row["post_id"]), + "post_title": row["post_title"], + "axis_one": float(row["axis_one"]), + "axis_two": float(row["axis_two"]), + "visibility_code": row["visibility_code"], + "corporate_entity_id": str(row["corporate_entity_id"]), + "process_unit_id": ( + None if row["process_unit_id"] is None else str(row["process_unit_id"]) + ), + "has_real_source_context": bool(row["has_real_source_context"]), + } + for row in leftover_persons_by_group.get(header["grouping_key"], []) + ], + "leftover_map_items": [ + { + "criterion_code": str(row["criterion_code"]), + "axis_one": float(row["axis_one"]), + "axis_two": float(row["axis_two"]), + } + for row in leftover_items_by_group.get(header["grouping_key"], []) + ], "leftover_map_axes": [ { "axis_index": int(row["axis_index"]), diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index df1744dd2..0f309d464 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -196,6 +196,11 @@ / "migrations" / "0169_report_leftover_map_axis.sql" ) +_LEFTOVER_MAP_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0172_report_leftover_interaction_map.sql" +) _CHANNEL_EVIDENCE_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -367,6 +372,7 @@ def seeded_db(demo_analyst_token): cur.execute(_GLOBAL_ASK_SCOPE_MIGRATION.read_text()) cur.execute(_EVENT_OCCURRED_AT_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text()) + cur.execute(_LEFTOVER_MAP_MIGRATION.read_text()) cur.execute(_CHANNEL_EVIDENCE_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text()) @@ -5543,6 +5549,22 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token, assert leftover_kinds <= {"closest", "farthest"} assert all(pair["post_title"] for pair in high_report.get("leftover_pairs", [])) assert all(pair["leftover_distance"] >= 0 for pair in high_report.get("leftover_pairs", [])) + leftover_map_persons = high_report.get("leftover_map_persons", []) + leftover_map_items = high_report.get("leftover_map_items", []) + member_ids = {member["post_id"] for member in high_report["members"]} + item_codes = {item["item_code"] for item in high_report.get("selected_items", [])} + assert leftover_map_persons + assert leftover_map_items + assert all(person["post_title"] for person in leftover_map_persons) + assert {person["post_id"] for person in leftover_map_persons} <= member_ids + assert all( + {"visibility_code", "corporate_entity_id", "process_unit_id"}.isdisjoint(person) + for person in leftover_map_persons + ) + assert {item["criterion_code"] for item in leftover_map_items} <= item_codes + for point in leftover_map_persons + leftover_map_items: + assert isinstance(point["axis_one"], (int, float)) + assert isinstance(point["axis_two"], (int, float)) assert all( {"visibility_code", "corporate_entity_id", "process_unit_id"}.isdisjoint(pair) for pair in high_report.get("leftover_pairs", []) diff --git a/docs/adr/0003-fast-mlsirm-report-integration.md b/docs/adr/0003-fast-mlsirm-report-integration.md index 11decffc0..f2032f23b 100644 --- a/docs/adr/0003-fast-mlsirm-report-integration.md +++ b/docs/adr/0003-fast-mlsirm-report-integration.md @@ -102,8 +102,9 @@ than one large PR: reports panel. Do not reimplement an information function here. 7. **Leftover-pair slice** (shipped in 0.71.2; ADR 0017 / 0018 / 0048 / 0049): after IRT main effects, persist closest and farthest - post–criterion pairs from the residual leftover map. Do not fork LSIRM or - invent a leftover-pair API inside `fast-mlsirm` in this slice. + post–criterion pairs from the residual leftover map. Consume fast-mlsirm's + Rust-backed residual interaction-map contract and keep only identifier + mapping, authorization, persistence, and pair selection here (ADR 0211). Category probabilities and expected responses must use `fast-mlsirm`'s public Rust-backed prediction API (upstream PR #1279); LineageWeave must not reproduce GRM/GPCM parameter conventions locally. diff --git a/docs/adr/0049-leftover-pair-report-ui.md b/docs/adr/0049-leftover-pair-report-ui.md index 4be472ef9..c0fa92da1 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -66,6 +66,5 @@ next action, not only the distance. Depends on [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and [ADR 0003](0003-fast-mlsirm-report-integration.md). Complete-case coverage of the leftover map is [ADR 0168](0168-leftover-map-complete-case-coverage.md). - -[ADR 0003](0003-fast-mlsirm-report-integration.md). The grouping +The grouping comparison strip reuses this leftover store ([ADR 0149](0149-leftover-pairs-on-comparison-strip.md)). diff --git a/docs/adr/0121-persist-leftover-interaction-map.md b/docs/adr/0121-persist-leftover-interaction-map.md new file mode 100644 index 000000000..5681d3789 --- /dev/null +++ b/docs/adr/0121-persist-leftover-interaction-map.md @@ -0,0 +1,78 @@ +# ADR 0121 — Persist leftover interaction-map coordinates + +**Decision status:** Accepted +**Date:** 2026-08-24 + +## Context + +ADR 0048 persists the closest and farthest leftover post–criterion +pairs after IRT main effects. Those pairs are two cells on the Jeon +et al. (2021, eq. 3) leftover interaction map `−γ‖ξ_p − ζ_i‖`. The +Gabriel (1971) biplot that produces the pairs already computes person +positions `ξ` and item positions `ζ`, then discards them. A reader +who sees only two named pairs cannot see *why* those cells sat +closest or farthest, or where the other complete-case posts and +criteria sit on the same leftover map. + +fast-mlsirm now exposes the Rust-backed residual interaction-map contract +adopted by ADR 0211. LineageWeave must not fork that calculation, invent a +second IRT fit, or treat a missing residual cell as a zero residual. + +## Decision + +After a real GRM/GPCM score, keep the complete-case Gabriel +coordinates that leftover pairs already use. Persist every complete- +case post as `report_leftover_map_person` (`axis_one`, `axis_two`) +and every complete-case criterion as `report_leftover_map_item`. +Pad unused axes with zero when residual rank is below two. Do not +invent a second component. Closest/farthest selection and persisted +distance use those same two reader-visible axes; unpersisted higher +components never silently change a highlighted map pair. Incomplete +rows and columns stay out of the factorization. + +Cascade the rows with `report_period_score`. A leftover-map post +must also be a `report_member_score` row. A leftover-map criterion +must be a `report_item_information` item on that same report. Do not +store a second theta. A rank-0 residual still emits origin +coordinates so `make seed` is not empty; those zeros are not a +fabricated interaction. + +Closest and farthest pairs remain ADR 0048 / ADR 0049. The map sits +**above** that pair list on the period-report group. Clicking a +person node opens that post with the same handler as a leftover +pair. Pair-member criterion nodes open that leftover-pair post +([ADR 0126](0126-leftover-map-criterion-node.md)). Hidden posts stay hidden: leftover-map persons join +`source_post` and use the same ABAC gate as members and leftover +pairs. Missing map rows render nothing. + +The biplot calculation lives in fast-mlsirm. `lineageweave/leftover_pairs.py` +maps returned indices and values to authorized product identifiers and selects +the closest/farthest cells; it contains no factorization or cross-term formula. + +## Consequences + +Rebuild and seed write leftover-map coordinates in the same +transaction as leftover pairs. `GET /api/reports/{grouping}/{period}` +returns `leftover_map_persons` (with post title) and +`leftover_map_items`. Migration +`0172_report_leftover_interaction_map.sql` upgrades volumes that +already applied `0001` / `0012`. `migrate.sh` already replays every +four-digit `NNNN_*.sql` file (ADR 0166), so 0172 lands on existing +volumes without a new allowlist entry. + +## Related + +Depends on [ADR 0048](0048-persist-lsirm-leftover-pairs.md), +[ADR 0049](0049-leftover-pair-report-ui.md), and +[ADR 0003](0003-fast-mlsirm-report-integration.md). + +## References + +Gabriel, K. R. (1971). The biplot graphic display of matrices with +application to principal component analysis. *Biometrika, 58*(3), +453–467. https://doi.org/10.1093/biomet/58.3.453 + +Jeon, M., Jin, I. H., Schweinberger, M., & Baugh, S. (2021). Mapping +unobserved item–respondent interactions: A latent space item response +model with interaction map. *Psychometrika, 86*(2), 378–403. +https://doi.org/10.1007/s11336-021-09762-5 diff --git a/docs/adr/0126-leftover-map-criterion-node.md b/docs/adr/0126-leftover-map-criterion-node.md new file mode 100644 index 000000000..d7aef6bb7 --- /dev/null +++ b/docs/adr/0126-leftover-map-criterion-node.md @@ -0,0 +1,60 @@ +# ADR 0126 — Leftover-map criterion nodes open the leftover-pair post + +**Decision status:** Accepted +**Date:** 2026-08-24 + +## Context + +ADR 0121 persists leftover interaction-map coordinates and renders a +2D Gabriel biplot above leftover pairs. Person (post) nodes are +buttons that open that post. Criterion (item) nodes are diamonds +without a next action: a reader who sees a highlighted closest or +farthest criterion cannot act on it. + +ADR 0125 lands leftover-pair *list* clicks on Post quality with a +leftover-focus flag. This increment is independent of that landing. +The map criterion node opens the leftover-pair post only. It does +not set leftover focus or `aria-current` on Post quality. + +A criterion that is not a leftover-pair member has no reader next +action. Inventing a click that opens an arbitrary post would +fabricate a pair. + +## Decision + +Export `leftoverPairForCriterion(pairs, criterionCode)`. Prefer the +closest leftover pair for that criterion, then farthest. If none, +the criterion stays a non-interactive diamond. + +When a pair exists, the criterion node is `role="button"`, keyboard +activable (Enter / Space), and named `Open leftover map criterion: +{label}`. Activation calls `onSelectPost(pair.post_id)` — the same +handler as a leftover-map person node and leftover-pair list button +on this stack. + +Do not pass leftover-focus flags. Hidden posts stay hidden because +the pair's `post_id` is already ABAC-filtered with leftover pairs. + +## Consequences + +Readers can click a highlighted leftover-map criterion and read the +post that sat closest (or farthest) from it after IRT main effects. +Non-pair criteria remain visual context on the Gabriel biplot. + +## Related + +Depends on [ADR 0121](0121-persist-leftover-interaction-map.md), +[ADR 0048](0048-persist-lsirm-leftover-pairs.md), and +[ADR 0049](0049-leftover-pair-report-ui.md). Independent of leftover +criterion evaluation landing. + +## References + +Gabriel, K. R. (1971). The biplot graphic display of matrices with +application to principal component analysis. *Biometrika, 58*(3), +453–467. https://doi.org/10.1093/biomet/58.3.453 + +Jeon, M., Jin, I. H., Schweinberger, M., & Baugh, S. (2021). Mapping +unobserved item–respondent interactions: A latent space item response +model with interaction map. *Psychometrika, 86*(2), 378–403. +https://doi.org/10.1007/s11336-021-09762-5 diff --git a/docs/adr/0208-externalize-local-mathematical-compute.md b/docs/adr/0208-externalize-local-mathematical-compute.md index 42a5a0591..a709246e4 100644 --- a/docs/adr/0208-externalize-local-mathematical-compute.md +++ b/docs/adr/0208-externalize-local-mathematical-compute.md @@ -28,10 +28,11 @@ The ecosystem product boundaries are already sufficient: CPU/GPU implementation before LineageWeave treats a new result as governed numerical evidence. -LineageWeave has no standalone canonical PRD file on this exact head. Until -one lands, `ARCHITECTURE.md` and the accepted ADR set are the product baseline; -this absence remains a product-documentation gap, not permission to infer a -different responsibility. +LineageWeave's supporting PRD (`docs/product-requirements.md`) explicitly says +that this product reconstructs and presents evidence but does not perform +psychometric or statistical estimation. Accepted ADRs remain normative for +architecture and policy, so the PRD and this decision express the same +consumer-only responsibility. ## Decision @@ -113,4 +114,3 @@ https://doi.org/10.1007/s11336-021-09762-5 Roberts, M. E., Stewart, B. M., & Tingley, D. (2019). stm: An R package for structural topic models. *Journal of Statistical Software, 91*(2), 1–40. https://doi.org/10.18637/jss.v091.i02 - diff --git a/docs/adr/0211-fast-mlsirm-residual-interaction-map.md b/docs/adr/0211-fast-mlsirm-residual-interaction-map.md new file mode 100644 index 000000000..7e69234c1 --- /dev/null +++ b/docs/adr/0211-fast-mlsirm-residual-interaction-map.md @@ -0,0 +1,47 @@ +# ADR 0211 — Consume fast-mlsirm residual interaction maps + +**Decision status:** Accepted +**Date:** 2026-08-25 + +## Context + +LineageWeave previously computed a Gabriel factorization of post-evaluation +residuals locally. That violated the repository boundary: reusable +psychometric arithmetic belongs to fast-mlsirm, while this product owns domain +identifiers, authorization, persistence, and presentation. + +## Decision + +Pin and consume fast-mlsirm's versioned `residual_interaction_map` contract. +fast-mlsirm's Rust core exclusively computes `R = Y - E`, complete-case +admission, Gabriel coordinates, singular values, axis inertia, Euclidean map +distance, truncated reconstruction `Rhat`, `U = R - Rhat`, and the exact +algebraic cross term `2 Rhat U / R^2`. + +LineageWeave passes the ADR-defined two reader-visible axes, maps returned row +and column indices to post and criterion identifiers, selects the deterministic +minimum and maximum returned distances, applies ABAC at persistence/read time, +and renders the supplied evidence. It does not reproduce any scientific +formula. Missing or non-finite upstream values remain unavailable. + +The cross term is an auditable identity term, not a weight, threshold, +psychometric score, or heuristic. + +## Consequences + +- The numerical method is reusable across products and tested once in Rust. +- A missing or incompatible upstream contract fails closed; LineageWeave does + not fall back to NumPy SVD or local arithmetic. +- Product tests mock sealed provider outputs to verify ID mapping and selection; + fast-mlsirm owns numerical recovery and edge-case tests. + +## References + +Gabriel, K. R. (1971). The biplot graphic display of matrices with application +to principal component analysis. *Biometrika, 58*(3), 453–467. +https://doi.org/10.1093/biomet/58.3.453 + +Jeon, M., Jin, I. H., Schweinberger, M., & Baugh, S. (2021). Mapping +unobserved item-respondent interactions: A latent space item response model +with interaction map. *Psychometrika, 86*(2), 378–403. +https://doi.org/10.1007/s11336-021-09762-5 diff --git a/docs/doctoring/python-mathematical-compute-boundary-audit.md b/docs/doctoring/python-mathematical-compute-boundary-audit.md index f1dbeee83..284caea44 100644 --- a/docs/doctoring/python-mathematical-compute-boundary-audit.md +++ b/docs/doctoring/python-mathematical-compute-boundary-audit.md @@ -8,8 +8,9 @@ Python paths satisfy the Rust/GPU requirement. ## Product-boundary sources read -- LineageWeave `ARCHITECTURE.md` and accepted ADRs 0003, 0132, 0145, - 0200, 0201, and 0205. This exact head has no standalone canonical PRD. +- LineageWeave `docs/product-requirements.md`, `ARCHITECTURE.md`, and accepted + ADRs 0003, 0132, 0145, 0200, 0201, and 0205. The supporting PRD makes the + consumer-only measurement boundary explicit while ADRs remain normative. - TEPP `docs/product/prd-v0.4-approved.md`, whose approved TRSL-TM scope owns temporal, relational, multilingual, topic, event, and trajectory measurement. diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 3716656cd..5ba4a5ccf 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -14,6 +14,10 @@ operator-facing control you can click before changing product CSS. | `Admin/AdminPanel` | Change the tenant brand name, then verify the saved or failed state before leaving settings. | `--surface`, `--border`, `--space-panel-block`, `AdminPanel` | | `Lineage/LineageDag` | Open a reconstructed connection to read its inferred channel scores and Allen interval relation, or open the current branch node; compare empty, single-branch, grouped/forked, mobile-scroll, ungrouped, and long-title states before changing graph CSS. On narrow viewports, swipe the named viewport or focus it and use arrow keys to inspect the full lineage. | `--color-accent-background`, `--radius-control`, `--surface`, `--border`, `--color-focus-border`, `--size-control-min`, `LineageDag` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | +| `Navigation/WorkspaceNav` | Open 게시판, 고객 마스터, 달력, or Ask Agent. Admin is not a GNB tab. | `--gnb-height`, `--gnb-active-indicator-color`, `WorkspaceNav` | +| `Evidence/OntologyExplorer` | Inspect typed people/orgs/posts, then open authorized evidence. Distinct from Event Lineage. | `--color-primary`, `--color-table-border`, `OntologyExplorer` | +| `Reports/LeftoverPairList` | Read residual R, observed Y, expected E, map rank, and distance after IRT main effects, then open the named post. | `--color-chip-border`, `LeftoverPairList` | +| `Reports/LeftoverInteractionMap` | Read leftover-map post and criterion positions after IRT main effects, then open the named leftover-pair post. | leftover-map tokens, `LeftoverInteractionMap` | | `Workspace/WorkspaceCalendar` | Read observed Naruon events, or open a commitment to land on that post. Fail-closed copy stays `이 범위의 일정을 아직 받을 수 없습니다`. | `--color-chip-border`, `WorkspaceCalendar`, `EvidenceStatusMark` | Repeated web objects must use `frontend/src/styles/tokens.css` and a module diff --git a/frontend/src/App.css b/frontend/src/App.css index eeaa61299..667282af6 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -674,6 +674,123 @@ fill: var(--text-h); } +.leftover-interaction-map { + margin: 0.5rem 0 0.75rem; +} + +.leftover-interaction-map figcaption { + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: 0.4rem; +} + +.leftover-interaction-map svg { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} + +.leftover-map-pair { + stroke-width: 1.5; +} + +.leftover-map-pair.leftover-map-closest { + stroke: var(--badge-status-success-text); +} + +.leftover-map-pair.leftover-map-farthest { + stroke: var(--badge-status-danger-text); +} + +.leftover-map-person { + cursor: pointer; +} + +.leftover-map-person circle { + fill: var(--badge-actor-person-bg); + stroke: var(--badge-actor-person-text); + stroke-width: 1.5; +} + +.leftover-map-item[role="button"] { + cursor: pointer; +} + +.leftover-map-item rect { + fill: var(--badge-actor-organization-bg); + stroke: var(--badge-actor-organization-text); + stroke-width: 1.5; +} + +.leftover-map-person.leftover-map-closest circle, +.leftover-map-item.leftover-map-closest rect { + stroke: var(--badge-status-success-text); + stroke-width: 2; +} + +.leftover-map-person.leftover-map-farthest circle, +.leftover-map-item.leftover-map-farthest rect { + stroke: var(--badge-status-danger-text); + stroke-width: 2; +} + +.leftover-map-person text, +.leftover-map-item text { + font-size: 0.7rem; + fill: var(--color-text-heading); +} + +.leftover-map-person:focus, +.leftover-map-item[role="button"]:focus { + outline: none; +} + +.leftover-map-person:focus-visible circle, +.leftover-map-person:hover circle, +.leftover-map-item[role="button"]:focus-visible rect, +.leftover-map-item[role="button"]:hover rect { + stroke-width: 2.5; +} + +.leftover-map-person:focus-visible, +.leftover-map-item[role="button"]:focus-visible { + outline: 2px solid var(--color-primary, var(--color-accent-info)); + outline-offset: 2px; +} + +.leftover-map-key { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: var(--space-control-gap); + margin: var(--space-control-gap) 0 0; + padding: 0; + list-style: none; +} + +.leftover-map-key li { + min-width: 0; + font-size: var(--font-size-badge); + overflow-wrap: anywhere; +} + +.leftover-map-key button { + min-height: 44px; + max-width: 100%; + padding: var(--space-chip-block) var(--space-chip-inline); + border: 1px solid var(--color-btn-secondary-border); + border-radius: var(--radius-control); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); + text-align: start; + white-space: normal; +} + +.leftover-map-key button:hover, +.leftover-map-key button:focus-visible { + background: var(--color-btn-secondary-hover); + border-color: var(--color-focus-border); +} + .lineage-dag-node:focus { outline: none; } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 2dee4513d..30d391a1a 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -103,6 +103,7 @@ describe("App, authenticated", () => { failedLineageRun?: boolean; runningLineageRun?: boolean; failedReportRun?: boolean; + emptyLeftoverMap?: boolean; succeededReportRun?: boolean; succeededTeppRun?: boolean; pendingTeppRun?: boolean; @@ -991,7 +992,7 @@ describe("App, authenticated", () => { observed_response: 2.4, expected_response: 2.0, leftover_map_rank: 1, - leftover_map_cross_share: 0.12, + leftover_map_cross_share: 0.21875, leftover_map_reconstruction: 0.35, }, { @@ -1005,10 +1006,20 @@ describe("App, authenticated", () => { observed_response: 0.9, expected_response: 2.0, leftover_map_rank: 1, - leftover_map_cross_share: -0.24, + leftover_map_cross_share: 0.3512396694214876, leftover_map_reconstruction: -0.85, }, ], + leftover_map_persons: options?.emptyLeftoverMap + ? [] + : [ + { post_id: "post-1", post_title: "Public post", axis_one: 0.2, axis_two: 0.4 }, + ], + leftover_map_items: options?.emptyLeftoverMap + ? [] + : [ + { criterion_code: "sales_lead_specificity", axis_one: -0.1, axis_two: 0.3 }, + ], leftover_map_axes: [ { axis_index: 1, @@ -2261,7 +2272,10 @@ describe("App, authenticated", () => { await userEvent.type(within(board).getByLabelText("Search semantic evidence"), "not found"); await userEvent.click(within(board).getByRole("button", { name: "Search" })); - expect(within(board).getByRole("status")).toHaveTextContent("No posts match the current filters."); + expect(within(board).getByText("No posts match the current filters.")).toHaveAttribute( + "role", + "status", + ); await userEvent.click(within(board).getByRole("button", { name: "Reset filters" })); expect(within(board).getByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); }); @@ -3481,9 +3495,10 @@ describe("App, authenticated", () => { expect( screen.getByRole("button", { name: "Compare Business unit (PU): Demo Report High, mean θ 0.81" }), ).not.toHaveAttribute("aria-current"); - expect(screen.getByRole("status")).toHaveTextContent( + const groupingStatus = screen.getByText( "Demo Corp is the opened grouping. Read its mean θ and member posts below, then open a post.", ); + expect(groupingStatus).toHaveAttribute("role", "status"); expect(await screen.findByText(/Demo Corp: mean θ 0\.42/)).toBeInTheDocument(); expect(screen.queryByText(/corp-1: mean θ/)).not.toBeInTheDocument(); const openedReport = screen.getByRole("list", { name: "Opened grouping report" }); @@ -3491,7 +3506,7 @@ describe("App, authenticated", () => { expect( within(openedReport).getByRole("button", { name: /open report post: public post/i }), ).toBeInTheDocument(); - const status = screen.getByRole("status"); + const status = groupingStatus; const demoMean = screen.getByText(/Demo Corp: mean θ 0\.42/); const weekChip = screen.getByRole("button", { name: /open report period 2026-W03/i }); expect(status.compareDocumentPosition(demoMean) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); @@ -3528,9 +3543,10 @@ describe("App, authenticated", () => { expect(demoChip).toHaveAccessibleName(/mean θ 0\.42/); expect(scrollIntoView).toHaveBeenCalled(); expect(periodInput).not.toHaveFocus(); - expect(screen.getByRole("status")).toHaveTextContent( + const groupingStatus = screen.getByText( "Demo Corp is the opened grouping. Read its mean θ and member posts below, then open a post.", ); + expect(groupingStatus).toHaveAttribute("role", "status"); expect(await screen.findByText(/Demo Corp: mean θ 0\.42/)).toBeInTheDocument(); const openedReport = screen.getByRole("list", { name: "Opened grouping report" }); expect(within(openedReport).getByText(/Demo Corp: mean θ 0\.42/).closest("li")).toHaveAttribute( @@ -3544,7 +3560,7 @@ describe("App, authenticated", () => { }); expect(member).toHaveTextContent("θ 0.91"); expect(member).not.toHaveAttribute("aria-current"); - const status = screen.getByRole("status"); + const status = groupingStatus; const demoMean = screen.getByText(/Demo Corp: mean θ 0\.42/); const weekChip = screen.getByRole("button", { name: /open report period 2026-W03/i }); expect(status.compareDocumentPosition(demoMean) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); @@ -3982,25 +3998,25 @@ describe("App, authenticated", () => { // Leftover-map cross share is present, so it names the next action // instead of the rank/observed-expected chain (ADR 0185). expect(closestPair).toHaveTextContent( - "Two leftover-map axes leave identity remainder 0.12 of raw residual after IRT main effects. Open this post to read sales-lead.", + "Two leftover-map axes leave identity remainder 0.22 of raw residual after IRT main effects. Open this post to read sales-lead.", ); expect(closestPair).toHaveTextContent("R +0.40"); expect(closestPair).toHaveTextContent("Y 2.40 · E 2.00"); expect(closestPair).toHaveTextContent("rank 1"); expect(closestPair).toHaveTextContent("U +0.05"); - expect(closestPair).toHaveTextContent("2R̂U/R² 0.12"); + expect(closestPair).toHaveTextContent("2R̂U/R² 0.22"); expect(closestPair).toHaveTextContent("R̂ +0.35"); expect(closestPair).toHaveTextContent("d 0.12"); expect(closestPair).toHaveAccessibleName("Open leftover closest pair: Public post · sales-lead"); expect(farthestPair).toHaveTextContent("Farthest leftover: Specification revision requested · negative"); expect(farthestPair).toHaveTextContent( - "Two leftover-map axes leave identity remainder -0.24 of raw residual after IRT main effects. Open this post to read negative.", + "Two leftover-map axes leave identity remainder 0.35 of raw residual after IRT main effects. Open this post to read negative.", ); expect(farthestPair).toHaveTextContent("R −1.10"); expect(farthestPair).toHaveTextContent("Y 0.90 · E 2.00"); expect(farthestPair).toHaveTextContent("rank 1"); expect(farthestPair).toHaveTextContent("U −0.25"); - expect(farthestPair).toHaveTextContent("2R̂U/R² -0.24"); + expect(farthestPair).toHaveTextContent("2R̂U/R² 0.35"); expect(farthestPair).toHaveTextContent("R̂ −0.85"); expect(farthestPair).toHaveTextContent("d 1.84"); const memberButton = screen.getByRole("button", { name: /open report post: public post/i }); @@ -4023,6 +4039,16 @@ describe("App, authenticated", () => { ); }); + it("keeps report-level leftover metadata when no authorized map points remain", async () => { + stubBackend({ emptyLeftoverMap: true }); + render(); + + await screen.findAllByText(/mean θ 0.42/); + expect(screen.getByLabelText("Leftover map coverage")).toBeInTheDocument(); + expect(screen.getByLabelText("Leftover-map axis share")).toBeInTheDocument(); + expect(screen.getByText(/leftover axis 1 82%/)).toBeInTheDocument(); + }); + it("shows the grouping comparison strip and switches grouping on click", async () => { const fetchMock = stubBackend(); render(); @@ -4034,9 +4060,11 @@ describe("App, authenticated", () => { await userEvent.click( screen.getByRole("button", { name: "Compare Thread group: A-100, mean θ 0.81" }), ); - expect(screen.getByRole("status")).toHaveTextContent( - "A-100 is the opened grouping. Read its mean θ and member posts below, then open a post.", - ); + expect( + screen.getByText( + "A-100 is the opened grouping. Read its mean θ and member posts below, then open a post.", + ), + ).toHaveAttribute("role", "status"); expect( screen.getByRole("button", { name: /open leftover closest pair from comparison: public post/i, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 76ff51dec..ce40c23e9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { AdminPanel } from "./components/AdminPanel"; import { LeftoverPairList } from "./components/LeftoverPairList"; +import { LeftoverInteractionMap } from "./LeftoverInteractionMap"; import { WorkspaceCalendar } from "./components/WorkspaceCalendar"; import { focusedGraphMustReset } from "./focusedGraphSelection"; @@ -3644,6 +3645,16 @@ function ReportsPanel({ {report.selected_items[0].information.toFixed(2)} )} + {report.leftover_map_persons && report.leftover_map_persons.length > 0 && + report.leftover_map_items && report.leftover_map_items.length > 0 && ( + + )} {report.leftover_map_coverage && report.leftover_map_coverage.scored_post_count > 0 && (

{tf("Leftover map used {used} of {scored} scored posts (complete-case)", { diff --git a/frontend/src/LeftoverInteractionMap.stories.tsx b/frontend/src/LeftoverInteractionMap.stories.tsx new file mode 100644 index 000000000..2754a19c3 --- /dev/null +++ b/frontend/src/LeftoverInteractionMap.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LeftoverInteractionMap } from "./LeftoverInteractionMap"; + +const itemLabel = (code: string) => + 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..b75b30787 --- /dev/null +++ b/frontend/src/LeftoverInteractionMap.test.tsx @@ -0,0 +1,219 @@ +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("uses one scale for both Gabriel axes", () => { + const projected = projectLeftoverMap( + [{ post_id: "post-1", post_title: "Public post", axis_one: 0, axis_two: 0 }], + [ + { criterion_code: "item-x", axis_one: 2, axis_two: 0 }, + { criterion_code: "item-y", axis_one: 0, axis_two: 1 }, + ], + itemLabel, + [], + 360, + 240, + 36, + ); + const origin = projected.persons[0]; + const deltaX = Math.abs(projected.items[0].x - origin.x); + const deltaY = Math.abs(projected.items[1].y - origin.y); + expect(deltaX).toBeCloseTo(deltaY * 2); + }); + + 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 post: Specification revision requested" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Open this leftover map criterion to read the leftover pair post: general_sentiment_negative", + }), + ).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..befafe734 --- /dev/null +++ b/frontend/src/LeftoverInteractionMap.tsx @@ -0,0 +1,262 @@ +/* oxlint-disable react/only-export-components -- deterministic stateless projection helpers are tested directly */ +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 scale = Math.min( + spanX === 0 ? Number.POSITIVE_INFINITY : (width - 2 * pad) / spanX, + spanY === 0 ? Number.POSITIVE_INFINITY : (height - 2 * pad) / spanY, + ); + const boundedScale = Number.isFinite(scale) ? scale : 0; + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const toSvg = (axisOne: number, axisTwo: number) => ({ + x: width / 2 + (axisOne - centerX) * boundedScale, + y: height / 2 - (axisTwo - centerY) * boundedScale, + }); + 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 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)} + > + + + {tf("Open this leftover map criterion to read the leftover pair post: {label}", { + label: point.label, + })} + + + ); + } + return ( + + + {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)} + > + + {tf("Open this post on the leftover map: {label}", { label: point.label })} + + ))} + +
    + {projected.persons.map((point) => ( +
  • + +
  • + ))} + {projected.items.map((point) => { + const pair = leftoverPairForCriterion(pairs, point.id); + return ( +
  • + {pair ? ( + + ) : ( + tf("Criterion: {label}", { label: point.label }) + )} +
  • + ); + })} +
+
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2c812ccaa..14e657682 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1004,6 +1004,19 @@ export interface LeftoverPair { leftover_map_reconstruction?: 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 LeftoverMapAxis { axis_index: number; leftover_singular_value: number; @@ -1034,6 +1047,8 @@ export interface PeriodGroupReport { members: ReportMember[]; selected_items: SelectedReportItem[]; leftover_pairs: LeftoverPair[]; + leftover_map_persons?: LeftoverMapPerson[]; + leftover_map_items?: LeftoverMapItem[]; leftover_map_axes?: LeftoverMapAxis[]; leftover_map_coverage?: LeftoverMapCoverage | null; } diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 18485aba9..4fe682f57 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -146,6 +146,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", "부모", "자식", "부모의 자식에 대한 관계: 포함; 부모 열기"], ["zh", "父", "子", "父 与 子 的关系:包含;打开 父"], diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 2d19b1cb8..9fa86f7fa 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -496,6 +496,14 @@ const TRANSLATIONS: Partial>> = { "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Open a leftover pair to read the post–criterion cell. The shares do not invent a leftover score.": "잔차 지도 축 비율은 잔차 SVD 축 1과 2의 Gabriel 관성입니다. 글–기준 셀을 읽으려면 잔차 쌍을 여세요. 이 비율은 잔차 점수를 만들어내지 않습니다.", "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}.": @@ -993,6 +1001,14 @@ const TRANSLATIONS: Partial>> = { "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Open a leftover pair to read the post–criterion cell. The shares do not invent a leftover score.": "残差图轴占比是残差 SVD 第 1、2 轴的 Gabriel 惯量。打开一个残差配对可查看文章–准则单元格。这些占比不会虚构残差分数。", "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}.": @@ -1493,6 +1509,14 @@ const TRANSLATIONS: Partial>> = { "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Open a leftover pair to read the post–criterion cell. The shares do not invent a leftover score.": "残差マップ軸の比率は、残差 SVD の第1軸と第2軸の Gabriel 慣性です。投稿–基準セルを読むには残差ペアを開いてください。この比率から残差スコアを作りません。", "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}.": @@ -1993,6 +2017,14 @@ const TRANSLATIONS: Partial>> = { "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Open a leftover pair to read the post–criterion cell. The shares do not invent a leftover score.": "Tỷ trọng trục bản đồ phần dư là quán tính Gabriel của các trục SVD phần dư 1 và 2. Mở một cặp phần dư để đọc ô bài viết–tiêu chí. Các tỷ trọng này không tạo ra điểm phần dư.", "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 070416fe2..a45dc8465 100644 --- a/lineageweave/leftover_pairs.py +++ b/lineageweave/leftover_pairs.py @@ -1,46 +1,21 @@ -"""Jeon leftover post–criterion pairs after a main-effect IRT. - -Implements ADR 0048 as amended by ADR 0119, ADR 0163, ADR 0164, ADR 0182, -and ADR 0185. - -Does not import ``fast_mlsirm`` or ``period_report``. A Gabriel biplot -of the residual ``R = Y − E[Y|θ, item]`` supplies person and item -positions. Missing response cells are excluded from the factorization; -they are never treated as zero residuals. Each pair names observed -``Y`` and expected ``E`` so residual always reconciles to ``Y − E``. -Pair distances are Euclidean 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. Axis share is the Gabriel inertia of the first -two leftover-map axes (ADR 0148). Complete-case coverage (ADR 0168) -names how many scored posts entered that rectangle; without a -complete-case rectangle there is no leftover pair to name, and the -report carries coverage counts instead of a center-distance stand-in -pair. Each pair also names unexplained leftover ``U = R − R̂`` after -two-axis Gabriel reconstruction ``R̂ = ξ_{1:2} · ζ_{1:2}`` so the -leftover cell the map does not reconstruct is not confused with -leftover residual ``R`` or leftover-map distance ``d``. Each pair -further names leftover-map cross share ``x = 2 R̂ U / R²`` of the raw -residual after that same truncated two-axis reconstruction, -so the identity remainder left by the truncation is not confused with -leftover residual ``R``, leftover-map distance ``d``, or unexplained -leftover ``U``. Explained leftover share ``e = R̂² / R²`` and -unexplained leftover share ``s = U² / R²`` are not persisted. Signed -reconstruction ``R̂`` is persisted so ``U + R̂ = R`` stays auditable. ``x`` -may be negative when reconstruction and unexplained leftover have opposite signs. +"""Map fast-mlsirm residual interaction results to LineageWeave identifiers. + +fast-mlsirm owns residual, complete-case admission, Gabriel factorization, +coordinates, axis inertia, distances, reconstruction, unexplained residual, +and cross-share arithmetic. This module owns only product identifiers and the +closest/farthest selection persisted by ADR 0048. """ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace +from typing import Any import numpy as np +from fast_mlsirm import residual_interaction_map PAIR_KIND_CLOSEST = "closest" PAIR_KIND_FARTHEST = "farthest" -_LEFTOVER_SINGULAR_FLOOR = 1e-12 -_RESIDUAL_RECONCILE_TOLERANCE = 1e-6 _LEFTOVER_MAP_AXES = 2 @@ -61,6 +36,24 @@ class LeftoverPair: leftover_map_reconstruction: float | None = None +@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 LeftoverMapAxis: """Gabriel inertia for one leftover-map axis on a period report.""" @@ -70,9 +63,19 @@ class LeftoverMapAxis: leftover_share: float +@dataclass(frozen=True) +class LeftoverInteractionMap: + """Mapped points, axis evidence, and closest/farthest observed pairs.""" + + pairs: tuple[LeftoverPair, ...] + persons: tuple[LeftoverMapPerson, ...] + items: tuple[LeftoverMapItem, ...] + axes: tuple[LeftoverMapAxis, ...] + + @dataclass(frozen=True) class LeftoverMapCoverage: - """Complete-case counts for the leftover interaction map.""" + """Complete-case counts returned by fast-mlsirm.""" map_post_count: int scored_post_count: int @@ -82,33 +85,92 @@ class LeftoverMapCoverage: incomplete_item_count: int +def _validate_identifiers( + post_ids: list[str], item_codes: tuple[str, ...], matrix: np.ndarray +) -> None: + """Require identifier cardinality to match the supplied response matrix.""" + expected_shape = (len(post_ids), len(item_codes)) + if matrix.shape != expected_shape: + raise ValueError( + f"matrix shape {matrix.shape} does not match " + f"{len(post_ids)} posts × {len(item_codes)} items" + ) + + +def _optional_finite(value: float) -> float | None: + """Translate the upstream nullable-array NaN representation to Optional.""" + return float(value) if np.isfinite(value) else None + + +def _upstream_map_coordinates_are_finite(result: Any) -> bool: + """Return whether every required upstream map coordinate is persistable.""" + return all( + np.isfinite(values).all() + for values in ( + result.person_coordinates, + result.item_coordinates, + result.singular_values, + result.axis_shares, + ) + ) + + +def _upstream_map_has_expected_shape(result: Any) -> bool: + """Return whether the provider envelope matches the pinned two-axis contract.""" + person_count = result.person_indices.size + item_count = result.item_indices.size + return ( + result.person_indices.ndim == 1 + and result.item_indices.ndim == 1 + and result.person_coordinates.shape == (person_count, _LEFTOVER_MAP_AXES) + and result.item_coordinates.shape == (item_count, _LEFTOVER_MAP_AXES) + and result.axis_shares.shape == (_LEFTOVER_MAP_AXES,) + and result.singular_values.ndim == 1 + and all( + values.shape == (person_count, item_count) + for values in ( + result.residual, + result.distance, + result.reconstruction, + result.unexplained, + result.cross_share, + ) + ) + ) + + +def _upstream_map_indices_are_valid( + result: Any, post_count: int, item_count: int +) -> bool: + """Require unique integer indices inside the supplied identifier bounds.""" + return all( + np.issubdtype(indices.dtype, np.integer) + and np.unique(indices).size == indices.size + and (indices.size == 0 or (indices.min() >= 0 and indices.max() < bound)) + for indices, bound in ( + (result.person_indices, post_count), + (result.item_indices, item_count), + ) + ) + + +def _upstream_map_is_valid(result: Any, post_count: int, item_count: int) -> bool: + """Apply one fail-closed envelope contract to map rows and coverage.""" + return ( + _upstream_map_has_expected_shape(result) + and _upstream_map_indices_are_valid(result, post_count, item_count) + and _upstream_map_coordinates_are_finite(result) + ) + + 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. - - 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. When Gabriel coordinates - exist, unexplained leftover ``U = R − R̂`` names the leftover cell - the two-axis map does not reconstruct, and leftover-map cross share - ``x = 2 R̂ U / R²`` names the identity remainder of raw residual - ``R`` after two-axis reconstruction ``R̂ = ξ_{1:2} · ζ_{1:2}`` and - unexplained leftover ``U = R − R̂``. Signed ``R̂`` is persisted with - ``U`` so their raw-residual identity stays auditable. Without a complete-case map there is no pair - to name (ADR 0168); the caller reads coverage counts instead of a - center-distance stand-in pair. - """ - pairs, _axes = leftover_map_from_residual(post_ids, item_codes, matrix, expected) - return pairs + """Return the product-selected closest and farthest upstream map cells.""" + return leftover_map_from_residual(post_ids, item_codes, matrix, expected).pairs def leftover_map_from_residual( @@ -116,219 +178,100 @@ def leftover_map_from_residual( item_codes: tuple[str, ...], matrix: np.ndarray, expected: np.ndarray, -) -> tuple[tuple[LeftoverPair, ...], tuple[LeftoverMapAxis, ...]]: - """Leftover pairs plus the first two Gabriel leftover-map axis shares. - - Axis share is ``σ_k² / Σ_j σ_j²`` for leftover-map axes 1 and 2. - Rank-0 residuals emit two zero-share axes so seed can name leftover-map - structure without inventing a leftover score. - """ - if matrix.shape != (len(post_ids), len(item_codes)): - raise ValueError( - f"matrix shape {matrix.shape} does not match {len(post_ids)} posts × {len(item_codes)} items" +) -> LeftoverInteractionMap: + """Map fast-mlsirm's two-axis residual interaction result to product IDs.""" + _validate_identifiers(post_ids, item_codes, matrix) + result = residual_interaction_map(matrix, expected, axis_count=_LEFTOVER_MAP_AXES) + if result.person_indices.size == 0 or result.item_indices.size == 0: + return LeftoverInteractionMap(pairs=(), persons=(), items=(), axes=()) + if not _upstream_map_is_valid(result, len(post_ids), len(item_codes)): + return LeftoverInteractionMap(pairs=(), persons=(), items=(), axes=()) + + persons = tuple( + LeftoverMapPerson( + post_id=post_ids[int(person)], + axis_one=float(result.person_coordinates[local, 0]), + axis_two=float(result.person_coordinates[local, 1]), + ) + for local, person in enumerate(result.person_indices) + ) + items = tuple( + LeftoverMapItem( + criterion_code=item_codes[int(item)], + axis_one=float(result.item_coordinates[local, 0]), + axis_two=float(result.item_coordinates[local, 1]), ) - if expected.shape != matrix.shape: - raise ValueError(f"expected shape {expected.shape} does not match matrix {matrix.shape}") - - residual = matrix.astype(np.float64) - expected.astype(np.float64) - observed_mask = (~np.isnan(matrix)) & np.isfinite(residual) & np.isfinite(expected) - observed: list[tuple[int, int]] = [ - (person, item) - for person in range(matrix.shape[0]) - for item in range(matrix.shape[1]) - if observed_mask[person, item] - ] - if not observed: - return (), () - - keep_person, keep_item = _complete_case_masks(observed_mask) - person_index = np.flatnonzero(keep_person) - item_index = np.flatnonzero(keep_item) - if person_index.size > 0 and item_index.size > 0: - center = float(np.mean(residual[np.ix_(person_index, item_index)])) - else: - center = float(np.mean([residual[person, item] for person, item in observed])) - person_pos, item_pos, singular = _complete_case_positions( - residual, center, keep_person, keep_item + for local, item in enumerate(result.item_indices) ) - axes = leftover_map_axes_from_singular(singular) - leftover_map_rank = int(singular.size) - candidates: list[ - tuple[ - float, str, str, float, float, float, - float | None, float | None, float | None, - ] - ] = [] - 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) - 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: - if person not in local_person or item not in local_item: + axes = tuple( + LeftoverMapAxis( + axis_index=axis + 1, + leftover_singular_value=float( + result.singular_values[axis] + if axis < result.singular_values.size + else 0.0 + ), + leftover_share=float(result.axis_shares[axis]), + ) + for axis in range(_LEFTOVER_MAP_AXES) + ) + rank = int(result.singular_values.size) + candidates: list[tuple[float, str, str, LeftoverPair]] = [] + for local_person, person in enumerate(result.person_indices): + person_index = int(person) + for local_item, item in enumerate(result.item_indices): + item_index = int(item) + distance = float(result.distance[local_person, local_item]) + residual = float(result.residual[local_person, local_item]) + observed = float(matrix[person_index, item_index]) + expected_value = float(expected[person_index, item_index]) + if not all( + np.isfinite(value) + for value in (distance, residual, observed, expected_value) + ): continue - distance = float( - np.linalg.norm(person_xy[local_person[person]] - item_xy[local_item[item]]) - ) - if not np.isfinite(distance): + if residual != observed - expected_value: continue - reconstruction = float( - np.dot(person_xy[local_person[person]], item_xy[local_item[item]]) - ) - residual_cell = float(residual[person, item]) - unexplained = _unexplained_leftover(residual_cell, reconstruction) - share = _leftover_map_cross_share(residual_cell, reconstruction) candidates.append( - _candidate_row( - post_ids, - item_codes, - matrix, - expected, - residual, - person, - item, + ( distance, - unexplained, - share, - reconstruction if np.isfinite(reconstruction) else None, + post_ids[person_index], + item_codes[item_index], + LeftoverPair( + pair_kind="", + post_id=post_ids[person_index], + criterion_code=item_codes[item_index], + leftover_distance=distance, + leftover_residual=residual, + observed_response=observed, + expected_response=expected_value, + leftover_map_rank=rank, + leftover_map_unexplained=_optional_finite( + result.unexplained[local_person, local_item] + ), + leftover_map_cross_share=_optional_finite( + result.cross_share[local_person, local_item] + ), + leftover_map_reconstruction=_optional_finite( + result.reconstruction[local_person, local_item] + ), + ), ) ) - if not candidates: - # ADR 0168: 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 (), () - closest = min(candidates, key=lambda row: (row[0], row[1], row[2])) - farthest = max(candidates, key=lambda row: (row[0], row[1], row[2])) - pairs = ( - _pair_from_candidate(PAIR_KIND_CLOSEST, closest, leftover_map_rank), - _pair_from_candidate(PAIR_KIND_FARTHEST, farthest, leftover_map_rank), - ) - return pairs, axes - - -def _unexplained_leftover(residual: float, reconstruction: float) -> float | None: - """Return ``U = R − R̂`` when both terms are finite; otherwise omit.""" - if not np.isfinite(reconstruction): - return None - unexplained = residual - reconstruction - if not np.isfinite(unexplained): - return None - return float(unexplained) - - -def _leftover_map_cross_share(residual: float, reconstruction: float) -> float | None: - """Return ``x = 2 R̂ U / R²`` when both terms are finite; otherwise omit. - - Unexplained leftover ``U = R − R̂`` is computed internally. - Truncated two-axis reconstruction of a higher-rank cell keeps a - cross term ``2 R̂ U``, so per-cell ``e + s ≠ 1``. The identity - remainder ``x`` names that cross term as a share of raw residual. - ``x`` may be negative when reconstruction and unexplained - leftover have opposite signs; a negative finite share is stored, - not omitted. - """ - if not np.isfinite(residual) or not np.isfinite(reconstruction): - return None - unexplained = float(residual - reconstruction) - # Threshold on absolute magnitudes, not squares: squaring first makes the - # effective floor sqrt(1e-12) = 1e-6 and collapses small-but-finite cells - # (e.g. R = 1e-7 with a valid cross term) to an omitted badge. - if abs(residual) > _LEFTOVER_SINGULAR_FLOOR: - share = float(2.0 * reconstruction * unexplained / (residual * residual)) - return share if np.isfinite(share) else None - if abs(reconstruction) <= _LEFTOVER_SINGULAR_FLOOR and abs(unexplained) <= _LEFTOVER_SINGULAR_FLOOR: - return 0.0 - return None - - -def _candidate_row( - post_ids: list[str], - item_codes: tuple[str, ...], - matrix: np.ndarray, - expected: np.ndarray, - residual: np.ndarray, - person: int, - item: int, - distance: float, - leftover_map_unexplained: float | None, - leftover_map_cross_share: float | None, - leftover_map_reconstruction: float | None, -) -> tuple[ - float, str, str, float, float, float, - float | None, float | None, float | None, -]: - """One observed cell: distance, ids, residual, Y, E, U, cross share, R̂.""" - leftover_residual = float(residual[person, item]) - observed_response = float(matrix[person, item]) - expected_response = float(expected[person, item]) - if abs(leftover_residual - (observed_response - expected_response)) >= _RESIDUAL_RECONCILE_TOLERANCE: - raise ValueError("leftover residual must equal observed Y minus expected E") - return ( - max(distance, 0.0), - post_ids[person], - item_codes[item], - leftover_residual, - observed_response, - expected_response, - leftover_map_unexplained, - leftover_map_cross_share, - leftover_map_reconstruction, - ) - - -def _pair_from_candidate( - pair_kind: str, - row: tuple[ - float, str, str, float, float, float, - float | None, float | None, float | None, - ], - leftover_map_rank: int, -) -> LeftoverPair: - """Build a leftover pair from a candidate row.""" - if leftover_map_rank < 0: - raise ValueError("leftover map rank must be a non-negative integer") - return LeftoverPair( - pair_kind=pair_kind, - post_id=row[1], - criterion_code=row[2], - leftover_distance=row[0], - leftover_residual=row[3], - observed_response=row[4], - expected_response=row[5], - leftover_map_rank=leftover_map_rank, - leftover_map_unexplained=row[6], - leftover_map_cross_share=row[7], - leftover_map_reconstruction=row[8], - ) - - -def leftover_map_axes_from_singular(singular: np.ndarray) -> tuple[LeftoverMapAxis, ...]: - """Gabriel axis inertia for leftover-map axes 1 and 2. - - Share is ``σ_k² / Σ_j σ_j²``. Values at or below the leftover - singular floor do not enter the denominator. Rank-0 residuals emit - two zero-share axes. - """ - values = np.asarray(singular, dtype=np.float64).reshape(-1) - kept = values[np.isfinite(values) & (values > _LEFTOVER_SINGULAR_FLOOR)] - total = float(np.sum(kept * kept)) if kept.size else 0.0 - axes: list[LeftoverMapAxis] = [] - for index in (1, 2): - value = float(kept[index - 1]) if kept.size >= index else 0.0 - if not np.isfinite(value) or value < 0.0: - value = 0.0 - share = (value * value / total) if total > 0.0 else 0.0 - axes.append( - LeftoverMapAxis( - axis_index=index, - leftover_singular_value=value, - leftover_share=max(share, 0.0), - ) + pairs: tuple[LeftoverPair, ...] = () + if candidates: + closest = min(candidates, key=lambda row: row[:3])[3] + farthest = max(candidates, key=lambda row: row[:3])[3] + pairs = ( + replace(closest, pair_kind=PAIR_KIND_CLOSEST), + replace(farthest, pair_kind=PAIR_KIND_FARTHEST), ) - return tuple(axes) + return LeftoverInteractionMap( + pairs=pairs, + persons=persons, + items=items, + axes=axes, + ) def leftover_map_coverage_from_residual( @@ -337,111 +280,17 @@ def leftover_map_coverage_from_residual( matrix: np.ndarray, expected: np.ndarray, ) -> LeftoverMapCoverage: - """Name how many scored posts entered the complete-case leftover map. - - Gabriel (1971) factorizes the complete-case residual rectangle. - Missing cells stay out of that rectangle; they are never filled - with zero. ``map_post_count`` is the number of posts that entered - the factorization. ``scored_post_count`` is posts with at least - one observed cell. Incomplete rows are excluded, never zeroed. - """ - if matrix.shape != (len(post_ids), len(item_codes)): - raise ValueError( - f"matrix shape {matrix.shape} does not match {len(post_ids)} posts × {len(item_codes)} items" - ) - if expected.shape != matrix.shape: - raise ValueError(f"expected shape {expected.shape} does not match matrix {matrix.shape}") - - residual = matrix.astype(np.float64) - expected.astype(np.float64) - # Identical mask to leftover_map_from_residual's: a cell the pair map - # scores must be exactly a cell coverage counts, so map_post_count and - # the caption can never drift apart if expected ever goes non-finite. - observed_mask = (~np.isnan(matrix)) & np.isfinite(residual) & np.isfinite(expected) - scored_post_count = int(observed_mask.any(axis=1).sum()) - scored_item_count = int(observed_mask.any(axis=0).sum()) - keep_person, keep_item = _complete_case_masks(observed_mask) - map_post_count = int(keep_person.sum()) - map_item_count = int(keep_item.sum()) + """Map fast-mlsirm's scored and complete-case coverage counts.""" + _validate_identifiers(post_ids, item_codes, matrix) + result = residual_interaction_map(matrix, expected, axis_count=_LEFTOVER_MAP_AXES) + map_is_valid = _upstream_map_is_valid(result, len(post_ids), len(item_codes)) + map_post_count = int(result.person_indices.size) if map_is_valid else 0 + map_item_count = int(result.item_indices.size) if map_is_valid else 0 return LeftoverMapCoverage( map_post_count=map_post_count, - scored_post_count=scored_post_count, + scored_post_count=result.scored_person_count, map_item_count=map_item_count, - scored_item_count=scored_item_count, - incomplete_post_count=scored_post_count - map_post_count, - incomplete_item_count=scored_item_count - map_item_count, + scored_item_count=result.scored_item_count, + incomplete_post_count=result.scored_person_count - map_post_count, + incomplete_item_count=result.scored_item_count - map_item_count, ) - - -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) - if np.any(keep_person): - keep_item = keep_item & observed[keep_person, :].all(axis=0) - else: - keep_item = np.zeros_like(keep_item) - return keep_person, keep_item - - -def _complete_case_positions( - residual: np.ndarray, - center: float, - keep_person: np.ndarray, - keep_item: np.ndarray, -) -> tuple[np.ndarray | None, np.ndarray | None, np.ndarray]: - """Gabriel coordinates on the complete-case residual rectangle only.""" - person_index = np.flatnonzero(keep_person) - item_index = np.flatnonzero(keep_item) - empty_singular = np.zeros(0, dtype=np.float64) - if person_index.size == 0 or item_index.size == 0: - return None, None, empty_singular - filled = residual[np.ix_(person_index, item_index)] - center - return _leftover_map_positions(filled) - - -def _leftover_map_positions( - filled: np.ndarray, -) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """Gabriel coordinates ordered by descending singular value. - - NumPy's SVD contract returns singular values largest-first, so filtering - by the numerical floor preserves a prefix and the first two columns remain - the two leading leftover-map axes. Rank-0 residuals collapse to the origin. - """ - n_persons, n_items = filled.shape - empty_singular = np.zeros(0, dtype=np.float64) - if n_persons == 0 or n_items == 0 or 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), - empty_singular, - ) - left, singular, right = np.linalg.svd(filled, full_matrices=False) - keep = singular > _LEFTOVER_SINGULAR_FLOOR - if not np.any(keep): - return ( - np.zeros((n_persons, 1), dtype=np.float64), - np.zeros((n_items, 1), dtype=np.float64), - empty_singular, - ) - scale = np.sqrt(singular[keep]) - person_pos = left[:, keep] * scale - item_pos = right[keep, :].T * scale - return person_pos, item_pos, singular[keep] - - -def _pad_map_axes(positions: np.ndarray) -> np.ndarray: - """Pad or truncate Gabriel coordinates to two leftover-map axes. - - Unused axes pad with zero rather than inventing a second component. - Hidden SVD axes after the second are dropped so reconstruction is - ``ξ_{1:2} · ζ_{1:2}``, not the full-rank inner product. That - reconstruction is persisted with unexplained leftover and cross share so - the raw-residual identity remains auditable. - """ - padded = np.zeros((positions.shape[0], _LEFTOVER_MAP_AXES), dtype=np.float64) - width = min(_LEFTOVER_MAP_AXES, positions.shape[1]) - padded[:, :width] = positions[:, :width] - return padded diff --git a/lineageweave/period_report.py b/lineageweave/period_report.py index 5e3244c1c..a301ca208 100644 --- a/lineageweave/period_report.py +++ b/lineageweave/period_report.py @@ -16,16 +16,17 @@ ``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 / 0048) come from the residual -interaction after those IRT main effects: ``R = Y − E[Y|θ, item]``. +Leftover post–criterion pairs (ADR 0017 / 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). Leftover-map axis -share is Gabriel inertia ``σ_k² / Σ_j σ_j²`` of residual SVD axes 1 -and 2 (ADR 0148). Complete-case coverage (ADR 0168) 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. +distances on that map (Jeon et al., 2021, eq. 3). fast-mlsirm's +``residual_interaction_map`` owns residual, complete-case admission, Gabriel +coordinates, distance, axis inertia, reconstruction, unexplained residual, +and cross share. This module maps those results to product identifiers and +selects the persisted closest/farthest cells (ADR 0211); it does not fork the +calculation or invent a second IRT fit. This module is pure compute. Persistence lives in ``backend/app/report_ingestion.py``. TEPP is not used here; temporal @@ -47,18 +48,23 @@ validate_irt_response_matrix, ) +from . import leftover_pairs as _leftover_pairs from .leftover_pairs import ( + LeftoverInteractionMap, LeftoverMapAxis, LeftoverMapCoverage, + LeftoverMapItem, + LeftoverMapPerson, LeftoverPair, leftover_map_coverage_from_residual, leftover_map_from_residual, ) -from .leftover_pairs import leftover_pairs_from_residual as leftover_pairs_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 +PAIR_KIND_CLOSEST = _leftover_pairs.PAIR_KIND_CLOSEST +PAIR_KIND_FARTHEST = _leftover_pairs.PAIR_KIND_FARTHEST +leftover_pairs_from_residual = _leftover_pairs.leftover_pairs_from_residual + LINK_METHOD_FREE = "free" LINK_METHOD_FIPC = "fipc" @@ -122,6 +128,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, ...] = () leftover_map_axes: tuple[LeftoverMapAxis, ...] = () leftover_map_coverage: LeftoverMapCoverage | None = None @@ -207,31 +215,30 @@ def expected_category_matrix(matrix: np.ndarray, probs: np.ndarray) -> np.ndarra return np.where(np.isnan(matrix), np.nan, expected) -def leftover_map_for_fit( +def leftover_pairs_for_fit( post_ids: list[str], item_codes: tuple[str, ...], matrix: np.ndarray, model: str, theta: np.ndarray, fit: PolytomousFit, -) -> tuple[tuple[LeftoverPair, ...], tuple[LeftoverMapAxis, ...]]: - """Leftover pairs and leftover-map axis share from fitted GRM/GPCM.""" - probs = _category_probabilities(model, theta, fit) - expected = expected_category_matrix(matrix, probs) - return leftover_map_from_residual(post_ids, item_codes, matrix, expected) +) -> 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_pairs_for_fit( +def leftover_map_for_fit( post_ids: list[str], item_codes: tuple[str, ...], matrix: np.ndarray, model: str, theta: np.ndarray, fit: PolytomousFit, -) -> tuple[LeftoverPair, ...]: - """Leftover pairs from the already-fitted GRM/GPCM main effects.""" - pairs, _axes = leftover_map_for_fit(post_ids, item_codes, matrix, model, theta, fit) - return pairs +) -> 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_map_from_residual(post_ids, item_codes, matrix, expected) def leftover_map_coverage_for_fit( @@ -306,9 +313,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_pairs, leftover_map_axes = leftover_map_for_fit( - post_ids, item_codes, matrix, selected, theta, fit - ) + leftover_map = leftover_map_for_fit(post_ids, item_codes, matrix, selected, theta, fit) leftover_map_coverage = leftover_map_coverage_for_fit( post_ids, item_codes, matrix, selected, theta, fit ) @@ -325,8 +330,10 @@ 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, - leftover_map_axes=leftover_map_axes, + leftover_pairs=leftover_map.pairs, + leftover_map_persons=leftover_map.persons, + leftover_map_items=leftover_map.items, + leftover_map_axes=leftover_map.axes, leftover_map_coverage=leftover_map_coverage, ) @@ -356,7 +363,7 @@ def score_period_on_bank( item_type="polytomous", response_process="cumulative", ) - leftover_pairs, leftover_map_axes = leftover_map_for_fit( + leftover_map = leftover_map_for_fit( post_ids, item_bank.item_codes, matrix, item_bank.model, theta, fit ) leftover_map_coverage = leftover_map_coverage_for_fit( @@ -381,8 +388,10 @@ 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, - leftover_map_axes=leftover_map_axes, + leftover_pairs=leftover_map.pairs, + leftover_map_persons=leftover_map.persons, + leftover_map_items=leftover_map.items, + leftover_map_axes=leftover_map.axes, leftover_map_coverage=leftover_map_coverage, ) 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 e3b7a5b18..4741b6c21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ backend = [ # yet; pinned to a specific commit, same pattern as rankweave. Ships a # PyO3/maturin Rust core with no fallback wheel, so building this from # source needs a Rust toolchain -- see backend/Dockerfile. - "fast-mlsirm @ git+https://github.com/ContextualWisdomLab/fast-mlsirm.git@d025b7d237d8db7ca97a5611606c6285d5870895", + "fast-mlsirm @ git+https://github.com/ContextualWisdomLab/fast-mlsirm.git@369158bbba4b765a5ce1b1b499a930a2ca5a3d93", ] [tool.setuptools.packages.find] diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 092d28fa5..184892afc 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -124,6 +124,7 @@ def seed( cur.execute((migrations / "0164_report_leftover_map_rank.sql").read_text()) cur.execute((migrations / "0168_report_leftover_map_coverage.sql").read_text()) cur.execute((migrations / "0169_report_leftover_map_axis.sql").read_text()) + cur.execute((migrations / "0172_report_leftover_interaction_map.sql").read_text()) cur.execute((migrations / "0182_report_leftover_map_unexplained.sql").read_text()) cur.execute((migrations / "0185_report_leftover_map_cross_share.sql").read_text()) cur.execute((migrations / "0206_report_leftover_map_reconstruction.sql").read_text()) @@ -1418,6 +1419,38 @@ def _persist_seed_period_report( pair.leftover_map_reconstruction, ), ) + 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, + ), + ) for axis in report.leftover_map_axes: cur.execute( "insert into report_leftover_map_axis (" diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py index a1080e172..a85b0ea6a 100644 --- a/tests/test_leftover_pairs.py +++ b/tests/test_leftover_pairs.py @@ -1,615 +1,297 @@ -"""Leftover post–criterion pairs after the main-effect IRT. - -Covers ADR 0048 as amended by ADR 0119, ADR 0148, ADR 0163, ADR 0164, -ADR 0182, and ADR 0185. - -Uses a constructed residual matrix so the closest and farthest pair -are known without calling ``fit_polytomous``. Loads -``leftover_pairs.py`` by path so package ``__init__`` / ``period_report`` -/ ``fast_mlsirm`` stay out of this module. -""" +"""LineageWeave's identifier adapter for fast-mlsirm interaction maps.""" from __future__ import annotations -import ast -import importlib.util -import sys -from pathlib import Path +from types import SimpleNamespace import numpy as np import pytest -_LEFTOVER_PATH = Path(__file__).resolve().parents[1] / "lineageweave" / "leftover_pairs.py" -_LEFTOVER_SINGULAR_FLOOR = 1e-12 -_LEFTOVER_MAP_AXES = 2 - - -def _load_leftover(): - """Load only the dependency-light leftover module under test.""" - source = _LEFTOVER_PATH.read_text(encoding="utf-8") - imported = [] - for node in ast.parse(source).body: - if isinstance(node, ast.Import): - imported.extend(alias.name.split(".", 1)[0] for alias in node.names) - elif isinstance(node, ast.ImportFrom): - imported.append((node.module or "").split(".", 1)[0]) - assert "fast_mlsirm" not in imported - assert "period_report" not in imported - spec = importlib.util.spec_from_file_location("lineageweave_leftover_pairs", _LEFTOVER_PATH) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -leftover = _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 -leftover_map_axes_from_singular = leftover.leftover_map_axes_from_singular -leftover_map_coverage_from_residual = leftover.leftover_map_coverage_from_residual - - -def _assert_residual_reconciles(pair) -> None: - """Assert the persisted residual is exactly grounded in named Y and E.""" - assert pair.leftover_residual == pytest.approx( - pair.observed_response - pair.expected_response, abs=1e-6 +from lineageweave import leftover_pairs as leftover + + +def _upstream_result() -> SimpleNamespace: + """Return fixed provider evidence; this repository does not recompute it.""" + return SimpleNamespace( + person_indices=np.array([0, 1]), + item_indices=np.array([0, 1]), + scored_person_count=3, + scored_item_count=2, + person_coordinates=np.array([[0.0, 0.0], [2.0, 0.0]]), + item_coordinates=np.array([[0.1, 0.0], [-1.0, 0.0]]), + singular_values=np.array([3.0]), + axis_shares=np.array([1.0, 0.0]), + residual=np.array([[0.5, 0.25], [-0.5, -1.0]]), + distance=np.array([[0.1, 1.0], [1.9, 3.0]]), + reconstruction=np.array([[0.375, 0.125], [-0.25, -0.75]]), + unexplained=np.array([[0.125, 0.125], [-0.25, -0.25]]), + cross_share=np.array([[0.375, 0.5], [0.5, 0.375]]), ) -def _assert_never_persists_hidden_shares(pair) -> None: - """The cross-share/reconstruction path never persists unsupported shares.""" - assert not hasattr(pair, "leftover_map_explained_share") - assert not hasattr(pair, "leftover_map_unexplained_share") +def _matching_inputs() -> tuple[np.ndarray, np.ndarray]: + """Return observed and expected arrays consistent with provider residuals.""" + expected = np.full((2, 2), 2.0) + observed = expected + _upstream_result().residual + return observed, expected -def _gabriel_positions(filled: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - """Independent Gabriel coordinates used to prove leftover_distance axes.""" - left, singular, right = np.linalg.svd(filled, full_matrices=False) - keep = singular > _LEFTOVER_SINGULAR_FLOOR - scale = np.sqrt(singular[keep]) - return left[:, keep] * scale, right[keep, :].T * scale +def test_maps_upstream_evidence_and_selects_closest_and_farthest( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Only product IDs and closest/farthest selection are added locally.""" + calls: list[tuple[np.ndarray, np.ndarray, int]] = [] + def provider(matrix: np.ndarray, expected: np.ndarray, *, axis_count: int): + calls.append((matrix, expected, axis_count)) + return _upstream_result() -def _pad_map_axes(positions: np.ndarray) -> np.ndarray: - """Independently pad or truncate coordinates to two map axes.""" - padded = np.zeros((positions.shape[0], _LEFTOVER_MAP_AXES), dtype=np.float64) - width = min(_LEFTOVER_MAP_AXES, positions.shape[1]) - padded[:, :width] = positions[:, :width] - return padded + monkeypatch.setattr(leftover, "residual_interaction_map", provider) + matrix = np.array([[2.5, 2.25], [1.5, 1.0], [1.0, np.nan]]) + expected = np.array([[2.0, 2.0], [2.0, 2.0], [1.0, 1.0]]) + interaction_map = leftover.leftover_map_from_residual( + ["public-post", "spec-post", "sparse-post"], + ("sales_lead_specificity", "general_sentiment_negative"), + matrix, + expected, + ) -def test_leftover_residual_biplot_separates_aligned_and_opposed_cells() -> None: - """A rank-1 leftover spike puts the aligned cell closest and the opposed cell farthest.""" - 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, + assert len(calls) == 1 + assert calls[0][0] is matrix + assert calls[0][1] is expected + assert calls[0][2] == 2 + assert [person.post_id for person in interaction_map.persons] == [ + "public-post", + "spec-post", + ] + assert [item.criterion_code for item in interaction_map.items] == [ + "sales_lead_specificity", + "general_sentiment_negative", + ] + assert [axis.leftover_share for axis in interaction_map.axes] == pytest.approx( + [1.0, 0.0] + ) + closest, farthest = interaction_map.pairs + assert closest.pair_kind == leftover.PAIR_KIND_CLOSEST + assert (closest.post_id, closest.criterion_code) == ( + "public-post", + "sales_lead_specificity", + ) + assert closest.leftover_distance == pytest.approx(0.1) + assert closest.leftover_residual == pytest.approx(0.5) + assert closest.leftover_map_reconstruction == pytest.approx(0.375) + assert closest.leftover_map_unexplained == pytest.approx(0.125) + assert closest.leftover_map_cross_share == pytest.approx(0.375) + assert farthest.pair_kind == leftover.PAIR_KIND_FARTHEST + assert (farthest.post_id, farthest.criterion_code) == ( + "spec-post", + "general_sentiment_negative", ) - expected = np.zeros_like(matrix) - pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - closest, farthest = pairs - assert closest.leftover_distance < farthest.leftover_distance - assert closest.leftover_distance == pytest.approx(0.0, abs=1e-9) - assert (farthest.post_id, farthest.criterion_code) in { - ("post-a", "item_far"), - ("post-c", "item_near"), - } - assert farthest.leftover_residual == pytest.approx(-2.0) - assert farthest.observed_response == pytest.approx(-2.0) - assert farthest.expected_response == pytest.approx(0.0) - assert farthest.leftover_distance == pytest.approx(2.0 * np.sqrt(2.0), rel=1e-6) - assert closest.leftover_map_unexplained == pytest.approx(0.0, abs=1e-6) - assert farthest.leftover_map_unexplained == pytest.approx(0.0, abs=1e-6) - # Closest is the origin cell (R = 0, R̂ = 0, U = 0): 0/0 stores 0. - assert closest.leftover_map_cross_share == pytest.approx(0.0, abs=1e-6) - # Rank-1 reconstructed opposed cell: U = 0 so x = 0. - assert farthest.leftover_map_cross_share == pytest.approx(0.0, abs=1e-6) - assert closest.leftover_map_reconstruction == pytest.approx(0.0, abs=1e-6) - assert farthest.leftover_map_reconstruction == pytest.approx(-2.0, abs=1e-6) - for pair in pairs: - _assert_residual_reconciles(pair) - _assert_never_persists_hidden_shares(pair) - assert pair.leftover_map_rank == 1 - coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) - assert coverage.map_post_count == 3 - assert coverage.scored_post_count == 3 - assert coverage.incomplete_post_count == 0 - assert coverage.map_item_count == 3 - assert coverage.scored_item_count == 3 - - -def test_zero_residual_still_emits_stable_leftover_pairs() -> None: - """A rank-zero map retains deterministic closest and farthest rows.""" - post_ids = ["alpha-post", "beta-post"] - item_codes = ("item_one", "item_two") - matrix = np.ones((2, 2), dtype=np.float64) - expected = np.ones((2, 2), dtype=np.float64) - pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - assert pairs[0].leftover_distance == pytest.approx(0.0) - assert pairs[1].leftover_distance == pytest.approx(0.0) - assert pairs[0].post_id == "alpha-post" - assert pairs[0].criterion_code == "item_one" - assert pairs[0].observed_response == pytest.approx(1.0) - assert pairs[0].expected_response == pytest.approx(1.0) - assert pairs[1].post_id == "beta-post" - assert pairs[1].criterion_code == "item_two" - assert pairs[0].leftover_map_unexplained == pytest.approx(0.0) - assert pairs[1].leftover_map_unexplained == pytest.approx(0.0) - assert pairs[0].leftover_map_cross_share == pytest.approx(0.0) - assert pairs[1].leftover_map_cross_share == pytest.approx(0.0) - assert pairs[0].leftover_map_reconstruction == pytest.approx(0.0) - assert pairs[1].leftover_map_reconstruction == pytest.approx(0.0) - for pair in pairs: - _assert_residual_reconciles(pair) - assert pair.leftover_map_rank == 0 - coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) - assert coverage.map_post_count == 2 - assert coverage.scored_post_count == 2 - assert coverage.incomplete_post_count == 0 - - -def test_rank_zero_nonzero_constant_residual_keeps_raw_identity() -> None: - """Centering a constant nonzero residual gives R̂=0 while U remains R.""" - matrix = np.ones((2, 2), dtype=np.float64) - pairs = leftover_pairs_from_residual( + assert farthest.leftover_distance == pytest.approx(3.0) + assert farthest.leftover_map_rank == 1 + assert farthest.observed_response == pytest.approx(1.0) + assert farthest.expected_response == pytest.approx(2.0) + assert farthest.leftover_map_cross_share == pytest.approx(0.375) + + +def test_pair_projection_reuses_the_mapped_result( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The pairs-only compatibility API adds no second provider contract.""" + monkeypatch.setattr( + leftover, + "residual_interaction_map", + lambda *_args, **_kwargs: _upstream_result(), + ) + observed, expected = _matching_inputs() + pairs = leftover.leftover_pairs_from_residual( ["post-a", "post-b"], ("item-a", "item-b"), - matrix, - np.zeros_like(matrix), + observed, + expected, ) + assert [pair.pair_kind for pair in pairs] == [ + leftover.PAIR_KIND_CLOSEST, + leftover.PAIR_KIND_FARTHEST, + ] - assert pairs - for pair in pairs: - assert pair.leftover_map_rank == 0 - assert pair.leftover_residual == pytest.approx(1.0) - assert pair.leftover_map_reconstruction == pytest.approx(0.0) - assert pair.leftover_map_unexplained == pytest.approx(1.0) - assert pair.leftover_map_unexplained + pair.leftover_map_reconstruction == pytest.approx( - pair.leftover_residual - ) + +def test_maps_upstream_coverage_without_rederiving_admission( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Scored and complete-case counts remain fast-mlsirm evidence.""" + monkeypatch.setattr( + leftover, + "residual_interaction_map", + lambda *_args, **_kwargs: _upstream_result(), + ) + coverage = leftover.leftover_map_coverage_from_residual( + ["post-a", "post-b", "post-c"], + ("item-a", "item-b"), + np.ones((3, 2)), + np.zeros((3, 2)), + ) + assert coverage == leftover.LeftoverMapCoverage( + map_post_count=2, + scored_post_count=3, + map_item_count=2, + scored_item_count=2, + incomplete_post_count=1, + incomplete_item_count=0, + ) -def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None: - """A missing cell must not enter the Gabriel factorization as 0.""" - 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, +def test_empty_upstream_map_does_not_invent_product_evidence( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No admitted rectangle means no points, axes, or selected pairs.""" + result = _upstream_result() + result.person_indices = np.array([], dtype=np.int64) + result.item_indices = np.array([], dtype=np.int64) + monkeypatch.setattr( + leftover, "residual_interaction_map", lambda *_args, **_kwargs: result ) - expected = np.zeros_like(matrix) - pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - closest, farthest = pairs - assert {pair.post_id for pair in pairs} <= {"aligned-post", "opposed-post"} - assert closest.leftover_distance == pytest.approx(0.0, abs=1e-9) - assert farthest.leftover_distance == pytest.approx(2.0 * np.sqrt(2.0), rel=1e-6) - assert farthest.leftover_residual == pytest.approx(-2.0) - assert (farthest.post_id, farthest.criterion_code) in { - ("aligned-post", "item_far"), - ("opposed-post", "item_near"), - } - for pair in pairs: - _assert_residual_reconciles(pair) - assert pair.leftover_map_rank == 1 - assert pair.leftover_map_unexplained == pytest.approx(0.0, abs=1e-6) - assert pair.leftover_map_cross_share == pytest.approx(0.0, abs=1e-6) - coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) - assert coverage.map_post_count == 2 - assert coverage.scored_post_count == 3 - assert coverage.incomplete_post_count == 1 - assert coverage.map_item_count == 2 - assert coverage.scored_item_count == 2 - assert coverage.incomplete_item_count == 0 - - -def test_leftover_is_empty_without_observed_cells() -> None: - """An entirely missing response matrix yields no invented pair.""" - post_ids = ["post-empty"] - item_codes = ("item_one",) - 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) == () - coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) - assert coverage.map_post_count == 0 - assert coverage.scored_post_count == 0 - assert coverage.incomplete_post_count == 0 - assert coverage.map_item_count == 0 - assert coverage.scored_item_count == 0 - assert coverage.incomplete_item_count == 0 - - -def test_leftover_residual_equals_observed_minus_expected() -> None: - """Named Y and E on leftover pairs must reconcile to R = Y − E.""" - post_ids = ["public-post", "spec-post"] - item_codes = ("sales_lead_specificity", "general_sentiment_negative") - matrix = np.array( - [ - [2.4, 0.0], - [0.0, 0.9], - ], - dtype=np.float64, + interaction_map = leftover.leftover_map_from_residual( + ["post-a"], ("item-a",), np.ones((1, 1)), np.zeros((1, 1)) ) - expected = np.array( - [ - [2.0, 0.0], - [0.0, 2.0], - ], - dtype=np.float64, + assert interaction_map == leftover.LeftoverInteractionMap( + pairs=(), persons=(), items=(), axes=() ) - pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - by_cell = {(pair.post_id, pair.criterion_code): pair for pair in pairs} - closest_cell = by_cell[("public-post", "sales_lead_specificity")] - farthest_cell = by_cell[("spec-post", "general_sentiment_negative")] - assert closest_cell.observed_response == pytest.approx(2.4) - assert closest_cell.expected_response == pytest.approx(2.0) - assert closest_cell.leftover_residual == pytest.approx(0.4) - assert farthest_cell.observed_response == pytest.approx(0.9) - assert farthest_cell.expected_response == pytest.approx(2.0) - assert farthest_cell.leftover_residual == pytest.approx(-1.1) - for pair in pairs: - _assert_residual_reconciles(pair) - - -def test_leftover_residual_rejects_database_tolerance_boundary() -> None: - """Python must reject the exact boundary excluded by the DB check.""" - with pytest.raises(ValueError, match="observed Y minus expected E"): - leftover._candidate_row( - ["public-post"], - ("sales_lead_specificity",), - np.array([[0.0]]), - np.array([[0.0]]), - np.array([[1e-6]]), - 0, - 0, - 0.0, - None, - None, - None, - ) -def test_leftover_pairs_empty_without_complete_case_map() -> None: - """No complete-case rectangle (ADR 0168): no stand-in pair, coverage instead.""" - post_ids = ["sparse-a", "sparse-b"] - item_codes = ("item_near", "item_far") - matrix = np.array( - [ - [2.0, np.nan], - [np.nan, -2.0], - ], - dtype=np.float64, +def test_nullable_cross_share_remains_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An upstream NaN is translated to None rather than a fabricated score.""" + result = _upstream_result() + result.cross_share[0, 0] = np.nan + monkeypatch.setattr( + leftover, "residual_interaction_map", lambda *_args, **_kwargs: result ) - expected = np.zeros_like(matrix) - assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == () - coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) - assert coverage.map_post_count == 0 - assert coverage.scored_post_count == 2 - assert coverage.incomplete_post_count == 2 - - -def test_rank_one_nonzero_center_is_disclosed_by_raw_residual_cross_share() -> None: - """Raw-residual cross share retains the mean omitted by centered SVD.""" - post_ids = ["post-a", "post-b", "post-c"] - item_codes = ("item_near", "item_mid", "item_far") - matrix = np.array( - [ - [5.0, 3.0, 1.0], - [3.0, 3.0, 3.0], - [1.0, 3.0, 5.0], - ], - dtype=np.float64, + observed, expected = _matching_inputs() + interaction_map = leftover.leftover_map_from_residual( + ["post-a", "post-b"], + ("item-a", "item-b"), + observed, + expected, ) - expected = np.zeros_like(matrix) - assert float(np.mean(matrix)) == pytest.approx(3.0) - pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - closest, farthest = pairs - person_full, item_full, _singular = leftover._leftover_map_positions(matrix - np.mean(matrix)) - reconstruction = leftover._pad_map_axes(person_full) @ leftover._pad_map_axes(item_full).T - post_index = {post_id: index for index, post_id in enumerate(post_ids)} - item_index = {code: index for index, code in enumerate(item_codes)} - for pair in pairs: - residual = pair.leftover_residual - recon = float(reconstruction[post_index[pair.post_id], item_index[pair.criterion_code]]) - expected_share = 0.0 if residual == 0.0 and recon == 0.0 else 2.0 * recon * (residual - recon) / residual**2 - assert pair.leftover_map_cross_share == pytest.approx(expected_share, abs=1e-6) - assert farthest.leftover_residual != pytest.approx(0.0) - assert farthest.leftover_map_cross_share != pytest.approx(farthest.leftover_residual) - for pair in pairs: - _assert_residual_reconciles(pair) - _assert_never_persists_hidden_shares(pair) - - -def test_rank_one_leftover_map_puts_all_inertia_on_axis_one() -> None: - """A rank-1 residual must report leftover-map share 1 on axis 1, 0 on axis 2.""" - 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, + assert interaction_map.pairs[0].leftover_map_cross_share is None + + +def test_non_finite_upstream_values_never_enter_selection_or_map_points( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Non-finite provider evidence remains unavailable instead of persisting.""" + result = _upstream_result() + result.distance[0, 0] = np.nan + result.residual[1, 1] = np.inf + result.unexplained[0, 1] = np.nan + result.reconstruction[0, 1] = np.inf + monkeypatch.setattr( + leftover, "residual_interaction_map", lambda *_args, **_kwargs: result ) - expected = np.zeros_like(matrix) - pairs, axes = leftover_map_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - assert [axis.axis_index for axis in axes] == [1, 2] - assert axes[0].leftover_share == pytest.approx(1.0) - assert axes[1].leftover_share == pytest.approx(0.0) - assert axes[0].leftover_singular_value > 0.0 - assert axes[1].leftover_singular_value == pytest.approx(0.0) - assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == pairs - - -def test_zero_residual_emits_two_zero_share_leftover_map_axes() -> None: - post_ids = ["alpha-post", "beta-post"] - item_codes = ("item_one", "item_two") - matrix = np.ones((2, 2), dtype=np.float64) - expected = np.ones((2, 2), dtype=np.float64) - pairs, axes = leftover_map_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - assert [axis.axis_index for axis in axes] == [1, 2] - assert axes[0].leftover_share == pytest.approx(0.0) - assert axes[1].leftover_share == pytest.approx(0.0) - assert axes[0].leftover_singular_value == pytest.approx(0.0) - assert axes[1].leftover_singular_value == pytest.approx(0.0) - - -def test_leftover_map_axes_from_singular_use_gabriel_inertia() -> None: - """Share is σ_k² / Σ_j σ_j² from the actual singular values, never a leftover score.""" - singular = np.array([3.0, 1.0, 0.5], dtype=np.float64) - total = float(np.sum(singular * singular)) - axes = leftover_map_axes_from_singular(singular) - assert [axis.axis_index for axis in axes] == [1, 2] - assert axes[0].leftover_singular_value == pytest.approx(3.0) - assert axes[1].leftover_singular_value == pytest.approx(1.0) - assert axes[0].leftover_share == pytest.approx(9.0 / total) - assert axes[1].leftover_share == pytest.approx(1.0 / total) - assert leftover_map_axes_from_singular(np.zeros(0))[0].leftover_share == pytest.approx(0.0) - assert leftover_map_from_residual( - ["post-empty"], - ("item_one",), - np.array([[np.nan]], dtype=np.float64), - np.array([[0.0]], dtype=np.float64), - ) == ((), ()) - - -def test_rank_four_pair_distances_match_two_dimensional_gabriel_coords() -> None: - """Jeon leftover_distance is Euclidean on the 2D map, not the full SVD rank.""" - 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, + observed, expected = _matching_inputs() + interaction_map = leftover.leftover_map_from_residual( + ["post-a", "post-b"], + ("item-a", "item-b"), + observed, + expected, ) - expected = np.zeros_like(matrix) - filled = matrix - float(np.mean(matrix)) - person_full, item_full = _gabriel_positions(filled) - assert person_full.shape[1] == 4 - person_map = _pad_map_axes(person_full) - item_map = _pad_map_axes(item_full) - full_distances = np.linalg.norm(person_full[:, None, :] - item_full[None, :, :], axis=2) - map_distances = np.linalg.norm(person_map[:, None, :] - item_map[None, :, :], axis=2) - assert float(np.max(np.abs(full_distances - map_distances))) > 1e-6 - - pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - post_index = {post_id: index for index, post_id in enumerate(post_ids)} - item_index = {code: index for index, code in enumerate(item_codes)} - for pair in pairs: - assert pair.leftover_map_rank == 4 - person = post_index[pair.post_id] - item = item_index[pair.criterion_code] - assert pair.leftover_distance == pytest.approx(float(map_distances[person, item])) - assert pair.leftover_distance != pytest.approx( - float(full_distances[person, item]), abs=1e-9 - ) + assert [pair.leftover_distance for pair in interaction_map.pairs] == [1.0, 1.9] + assert interaction_map.pairs[0].leftover_map_unexplained is None + assert interaction_map.pairs[0].leftover_map_reconstruction is None - farthest_map = np.unravel_index(int(np.argmax(map_distances)), map_distances.shape) - farthest = pairs[1] - assert (post_index[farthest.post_id], item_index[farthest.criterion_code]) == farthest_map - - -def test_unexplained_and_cross_share_are_identity_remainder_terms() -> None: - """Unexplained U is R − R̂; cross share is 2 R̂ U / R². Neither is R or d. - - Uses the same rank-4 matrix as the two-axis distance proof above: R̂ is - the two-axis Gabriel reconstruction ``person_map @ item_map.T``, built - from the same padded coordinates ``leftover_distance`` already uses. - """ - 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, + result.person_coordinates[0, 0] = np.nan + assert leftover.leftover_map_from_residual( + ["post-a", "post-b"], + ("item-a", "item-b"), + observed, + expected, + ) == leftover.LeftoverInteractionMap(pairs=(), persons=(), items=(), axes=()) + + +def test_malformed_rank_one_provider_shape_fails_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A non-padded rank-one envelope cannot reach two-axis indexing.""" + result = _upstream_result() + result.person_coordinates = result.person_coordinates[:, :1] + result.item_coordinates = result.item_coordinates[:, :1] + result.axis_shares = result.axis_shares[:1] + monkeypatch.setattr( + leftover, "residual_interaction_map", lambda *_args, **_kwargs: result ) - expected = np.zeros_like(matrix) - center = float(np.mean(matrix)) - filled = matrix - center - person_full, item_full, singular = leftover._leftover_map_positions(filled) - rank = int(singular.size) - assert person_full.shape[1] >= 3 - person_map = leftover._pad_map_axes(person_full) - item_map = leftover._pad_map_axes(item_full) - reconstruction = person_map @ item_map.T - full_inner = person_full @ item_full.T - map_distances = np.linalg.norm(person_map[:, None, :] - item_map[None, :, :], axis=2) - assert float(np.max(np.abs(reconstruction - filled))) > 1e-6 - assert float(np.max(np.abs(reconstruction - full_inner))) > 1e-6 - assert abs(center) > 1e-6 - - pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - post_index = {post_id: index for index, post_id in enumerate(post_ids)} - item_index = {code: index for index, code in enumerate(item_codes)} - saw_nonzero_cross = False - for pair in pairs: - person = post_index[pair.post_id] - item = item_index[pair.criterion_code] - recon = float(reconstruction[person, item]) - # Raw unexplained leftover U = R − R̂ (ADR 0182). - expected_unexplained = float(pair.leftover_residual) - recon - assert pair.leftover_map_unexplained == pytest.approx(expected_unexplained) - assert pair.leftover_map_unexplained != pytest.approx(pair.leftover_residual) - assert pair.leftover_map_unexplained != pytest.approx(pair.leftover_distance) - assert pair.leftover_map_reconstruction == pytest.approx(recon) - assert pair.leftover_map_unexplained + pair.leftover_map_reconstruction == pytest.approx( - pair.leftover_residual - ) - # Raw-residual cross share x = 2 R̂ U / R² (ADR 0185). - residual = float(pair.leftover_residual) - expected_share = (2.0 * recon * expected_unexplained) / (residual * residual) - explained_share = (recon * recon) / (residual * residual) - unexplained_share = (expected_unexplained * expected_unexplained) / (residual * residual) - assert pair.leftover_map_cross_share == pytest.approx(expected_share) - assert explained_share + unexplained_share + expected_share == pytest.approx(1.0) - if abs(expected_share) > 1e-6: - saw_nonzero_cross = True - assert pair.leftover_map_cross_share != pytest.approx(pair.leftover_residual) - assert pair.leftover_map_cross_share != pytest.approx(pair.leftover_distance) - # Distance is Euclidean on the two leftover-map axes (ADR 0119), the - # same basis the reconstruction above uses -- not the full-rank - # Gabriel inner product. - assert pair.leftover_distance == pytest.approx(float(map_distances[person, item])) - assert pair.leftover_map_rank == rank - _assert_never_persists_hidden_shares(pair) - assert saw_nonzero_cross - - -def test_cross_share_stores_negative_finite_identity_remainder() -> None: - """A negative identity remainder is stored, never omitted or clamped.""" - assert leftover._leftover_map_cross_share(1.0, 2.0) == pytest.approx(-4.0) - assert leftover._leftover_map_cross_share(2.0, 2.0) == pytest.approx(0.0) - assert leftover._leftover_map_cross_share(0.0, 0.0) == pytest.approx(0.0) - assert leftover._leftover_map_cross_share(float("nan"), 1.0) is None - assert leftover._leftover_map_cross_share(1.0, float("inf")) is None - - -def test_pad_map_axes_truncates_hidden_svd_components() -> None: - """Axes after the second leftover-map axis do not enter reconstruction.""" - padded = leftover._pad_map_axes(np.array([[1.0, 2.0, 9.0]], dtype=np.float64)) - assert padded.shape == (1, 2) - assert padded[0].tolist() == pytest.approx([1.0, 2.0]) - - -def test_rejects_response_and_expectation_shape_mismatches() -> None: - """Scientific inputs must match their declared post and criterion axes.""" - with pytest.raises(ValueError, match="matrix shape"): - leftover_pairs_from_residual( - ["post-a"], - ("item-a",), - np.zeros((2, 1), dtype=np.float64), - np.zeros((2, 1), dtype=np.float64), - ) - with pytest.raises(ValueError, match="expected shape"): - leftover_pairs_from_residual( - ["post-a"], - ("item-a",), - np.zeros((1, 1), dtype=np.float64), - np.zeros((1, 2), dtype=np.float64), - ) + observed, expected = _matching_inputs() + assert leftover.leftover_map_from_residual( + ["post-a", "post-b"], ("item-a", "item-b"), observed, expected + ) == leftover.LeftoverInteractionMap(pairs=(), persons=(), items=(), axes=()) -def test_nonfinite_map_distance_emits_no_pair( +def test_out_of_range_provider_indices_fail_closed( monkeypatch: pytest.MonkeyPatch, ) -> None: - """An unusable factorization coordinate cannot become persisted distance.""" + """A malformed owner envelope cannot index beyond product identifiers.""" + result = _upstream_result() + result.person_indices = np.array([0, 2], dtype=np.int64) monkeypatch.setattr( - leftover, - "_complete_case_positions", - lambda *_args: ( - np.array([[np.inf]], dtype=np.float64), - np.array([[-np.inf]], dtype=np.float64), - np.array([1.0], dtype=np.float64), - ), + leftover, "residual_interaction_map", lambda *_args, **_kwargs: result ) - pairs = leftover_pairs_from_residual( - ["post-a"], - ("item-a",), - np.array([[1.0]], dtype=np.float64), - np.array([[0.0]], dtype=np.float64), + observed, expected = _matching_inputs() + + assert leftover.leftover_map_from_residual( + ["post-a", "post-b"], ("item-a", "item-b"), observed, expected + ) == leftover.LeftoverInteractionMap(pairs=(), persons=(), items=(), axes=()) + + coverage = leftover.leftover_map_coverage_from_residual( + ["post-a", "post-b"], ("item-a", "item-b"), observed, expected ) - assert pairs == () - - -def test_empty_observation_mask_has_no_complete_case_axes() -> None: - """The complete-case helpers preserve an empty scientific boundary.""" - observed = np.zeros((1, 1), dtype=bool) - keep_person, keep_item = leftover._complete_case_masks(observed) - assert not keep_person.any() - assert not keep_item.any() - person_pos, item_pos, singular = leftover._complete_case_positions( - np.zeros((1, 1), dtype=np.float64), - 0.0, - keep_person, - keep_item, + assert coverage.map_post_count == 0 + assert coverage.map_item_count == 0 + assert coverage.incomplete_post_count == coverage.scored_post_count + assert coverage.incomplete_item_count == coverage.scored_item_count + + +def test_inconsistent_provider_residual_is_excluded_before_selection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Only cells satisfying the auditable R equals Y minus E identity persist.""" + result = _upstream_result() + result.residual[0, 0] = 0.75 + monkeypatch.setattr( + leftover, "residual_interaction_map", lambda *_args, **_kwargs: result ) - assert person_pos is None - assert item_pos is None - assert singular.size == 0 - - -def test_leftover_map_rank_rejects_negative_rank() -> None: - """Python must reject a leftover-map rank excluded by the DB check.""" - with pytest.raises(ValueError, match="non-negative integer"): - leftover._pair_from_candidate( - PAIR_KIND_CLOSEST, - (0.0, "public-post", "sales_lead_specificity", 0.0, 1.0, 1.0, None), - -1, - ) + observed, expected = _matching_inputs() + interaction_map = leftover.leftover_map_from_residual( + ["post-a", "post-b"], ("item-a", "item-b"), observed, expected + ) + assert [ + (pair.post_id, pair.criterion_code) for pair in interaction_map.pairs + ] == [("post-a", "item-b"), ("post-b", "item-b")] -def test_small_finite_residual_keeps_cross_share() -> None: - """A tiny-but-finite residual keeps its cross share. +def test_rejects_identifier_shape_mismatch_before_provider_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Product identifiers must address every supplied response cell.""" + called = False - Squaring before the floor made the effective threshold 1e-6, so - R = 1e-7 with reconstruction 5e-8 collapsed to an omitted badge - even though x = 0.5 is well-defined (coderabbit review thread). - """ - share = leftover._leftover_map_cross_share(1e-7, 5e-8) - assert share == pytest.approx(0.5) + def provider(*_args, **_kwargs): + nonlocal called + called = True + return _upstream_result() + monkeypatch.setattr(leftover, "residual_interaction_map", provider) + with pytest.raises(ValueError, match="matrix shape"): + leftover.leftover_map_from_residual( + ["post-a"], ("item-a",), np.ones((2, 1)), np.zeros((2, 1)) + ) + assert not called -def test_leftover_is_unavailable_without_a_complete_case_rectangle() -> None: - """Observed cells alone cannot invent Gabriel positions or map coverage.""" - post_ids = ["post-a", "post-b"] - item_codes = ("item-one", "item-two") - matrix = np.array([[1.0, np.nan], [np.nan, 1.0]], dtype=np.float64) - expected = np.zeros_like(matrix) - assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == () - coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) - assert coverage.map_post_count == 0 - assert coverage.scored_post_count == 2 - assert coverage.map_item_count == 0 - assert coverage.scored_item_count == 2 - assert coverage.incomplete_post_count == 2 - assert coverage.incomplete_item_count == 2 +def test_upstream_rejects_expectation_shape_mismatch() -> None: + """The supplier owns scientific array-shape validation.""" + with pytest.raises(ValueError, match="same two-dimensional shape"): + leftover.leftover_map_from_residual( + ["post-a"], ("item-a",), np.ones((1, 1)), np.zeros((1, 2)) + ) diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index c170f73e8..4fc649aa7 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -50,6 +50,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_interval_relation_backfill_uses_utc_created_day() -> None: sql = ( Path(__file__).resolve().parents[1] diff --git a/tests/test_period_report.py b/tests/test_period_report.py index 16a42abab..df5c88933 100644 --- a/tests/test_period_report.py +++ b/tests/test_period_report.py @@ -278,6 +278,14 @@ def test_calibrated_report_attaches_leftover_pairs() -> None: assert np.isfinite(pair.leftover_map_reconstruction) assert not hasattr(pair, "leftover_map_explained_share") assert not hasattr(pair, "leftover_map_unexplained_share") + 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) assert [axis.axis_index for axis in report.leftover_map_axes] == [1, 2] for axis in report.leftover_map_axes: assert axis.leftover_singular_value >= 0.0 diff --git a/tests/test_schema.py b/tests/test_schema.py index d88d07bd2..04b402726 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -73,6 +73,11 @@ / "migrations" / "0169_report_leftover_map_axis.sql" ) +_LEFTOVER_INTERACTION_MAP_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0172_report_leftover_interaction_map.sql" +) _CHANNEL_EVIDENCE_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" / "0174_post_lineage_edge_signal.sql" ) @@ -127,6 +132,7 @@ def schema_db(): cur.execute(_LEFTOVER_MAP_RANK_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_COVERAGE_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text()) + cur.execute(_LEFTOVER_INTERACTION_MAP_MIGRATION.read_text()) cur.execute(_CHANNEL_EVIDENCE_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text()) @@ -177,6 +183,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", "report_leftover_map_axis", "report_leftover_map_coverage", "post_summary_result", @@ -250,6 +258,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_lineage_channel_evidence_is_cascaded_and_lookup_controlled(schema_db) -> None: """Signal rows cannot outlive their edge or name an unknown channel.""" 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 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) diff --git a/uv.lock b/uv.lock index 6190681ba..6aa373c40 100644 --- a/uv.lock +++ b/uv.lock @@ -470,8 +470,8 @@ wheels = [ [[package]] name = "fast-mlsirm" -version = "0.8.0" -source = { git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=d025b7d237d8db7ca97a5611606c6285d5870895#d025b7d237d8db7ca97a5611606c6285d5870895" } +version = "0.9.0" +source = { git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=369158bbba4b765a5ce1b1b499a930a2ca5a3d93#369158bbba4b765a5ce1b1b499a930a2ca5a3d93" } dependencies = [ { name = "numpy" }, ] @@ -644,7 +644,7 @@ requires-dist = [ { name = "certifi", specifier = ">=2024.0.0" }, { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" }, { name = "cryptography", specifier = ">=42.0" }, - { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=d025b7d237d8db7ca97a5611606c6285d5870895" }, + { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=369158bbba4b765a5ce1b1b499a930a2ca5a3d93" }, { name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.115.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, { name = "opentelemetry-api", specifier = ">=1.30.0" },