From 8ba4b97c7e6fc36fa4a87b95d4352f110cf852ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:20:51 +0000 Subject: [PATCH 1/3] feat: persist leftover post-criterion pairs on period reports (v0.71.2) After seed, closest and farthest leftover pairs sit above the member list. Click a pair to open that post. --- AGENTS.md | 5 + ARCHITECTURE.md | 7 +- CHANGELOG.d/0.71.2-leftover-pairs.md | 10 ++ CHANGELOG.md | 9 ++ backend/app/report_ingestion.py | 49 +++++- docker/postgres-init/Dockerfile | 1 + .../0003-fast-mlsirm-report-integration.md | 4 + docs/adr/0017-persist-lsirm-leftover-pairs.md | 55 +++++++ docs/adr/0018-leftover-pair-report-ui.md | 39 +++++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 41 +++++ frontend/src/App.tsx | 23 +++ frontend/src/api.ts | 10 ++ lineageweave/__init__.py | 2 +- lineageweave/period_report.py | 146 ++++++++++++++++++ migrations/0001_initial_schema.sql | 23 +++ migrations/0012_report_leftover_pair.sql | 23 +++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 19 +++ tests/test_period_report.py | 71 +++++++++ tests/test_schema.py | 1 + uv.lock | 2 +- 22 files changed, 537 insertions(+), 7 deletions(-) create mode 100644 CHANGELOG.d/0.71.2-leftover-pairs.md create mode 100644 docs/adr/0017-persist-lsirm-leftover-pairs.md create mode 100644 docs/adr/0018-leftover-pair-report-ui.md create mode 100644 migrations/0012_report_leftover_pair.sql diff --git a/AGENTS.md b/AGENTS.md index 735988f0..10604b8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,11 @@ in the same spirit) -- never against real data, per the hard rule above. against a live local stack (`make up`) and self-skip without one -- see [README.md](README.md#local-product-stack-docker-compose). +Period leftover pairs (ADR 0017 / 0018) are computed from the residual +after a real GRM/GPCM score, never invented. Closest and farthest +post–criterion pairs persist to `report_leftover_pair` and sit above +the member list so a click opens that post. + `frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`, pnpm via Corepack -- do not add a second Node package manager or a floating Node version): diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 617b8b95..5166e1e7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -504,7 +504,9 @@ so PU/team/project thetas stay on one metric. Later periods EAP-score on those same fixed parameters (Kim, 2006 FIPC). After scoring, `information_polytomous` ranks the shared-bank items by Fisher information at the group's mean θ (Lord, 1980 max-info CAT). Rankings -persist to `report_item_information`. Results persist to +persist to `report_item_information`. After those IRT main effects, +residual SVD leftover pairs (Jeon et al., 2021; ADR 0017) persist to +`report_leftover_pair`. Results persist to `report_period_score` / `report_member_score`. `GET /api/reports/{grouping}` lists the trend; `GET /api/reports/{grouping}/{period}` is ABAC-filtered; @@ -515,7 +517,8 @@ post) that already have constructed IRT cells into the same shared bank as the dummy high/low band rows, so comparison-strip click through opens those DAG posts. Report members include the earliest open ticket title, status lookup label, and due date when one exists. The home page renders -the actual mean θ, the FIPC delta, the CAT-selected item, and the +the actual mean θ, the FIPC delta, the CAT-selected item, leftover +closest/farthest pairs above the member list, and the PU / corp / thread comparison -- never a placeholder. TEPP is unchanged. ## Phase 6b: Knowledge Graph as a real Ontology + Semantic Layer diff --git a/CHANGELOG.d/0.71.2-leftover-pairs.md b/CHANGELOG.d/0.71.2-leftover-pairs.md new file mode 100644 index 00000000..f8f2f268 --- /dev/null +++ b/CHANGELOG.d/0.71.2-leftover-pairs.md @@ -0,0 +1,10 @@ +# 0.71.2 — Leftover post–criterion pairs + +## Added + +- Persist closest and farthest leftover pairs from the residual + interaction map after GRM/GPCM scoring (ADR 0017). +- Period reports show those pairs above the member list; click opens + that post (ADR 0018). After `make seed`, closest and farthest + leftover pairs sit above the member list. Click a pair to open that + post. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0096828a..84ab0bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.71.2] - 2026-08-17 + +### Added + +- Period reports now persist closest and farthest leftover + post–criterion pairs after the IRT main effects (Jeon leftover + map). After `make seed`, closest and farthest leftover pairs sit + above the member list. Click a pair to open that post. + ## [0.71.0] - 2026-08-14 ### Added diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index 07c0bf56..be78949e 100644 --- a/backend/app/report_ingestion.py +++ b/backend/app/report_ingestion.py @@ -256,7 +256,7 @@ async def persist_period_report( period_code: str, report: PeriodReport, ) -> None: - """Replace the stored report, member scores, and item bank.""" + """Replace the stored report, member scores, leftover pairs, and item bank.""" await conn.execute( """ delete from report_period_score @@ -343,6 +343,24 @@ async def persist_period_report( item.rank, item.information, ) + for pair in report.leftover_pairs: + await conn.execute( + """ + insert into report_leftover_pair ( + grouping_kind, grouping_key, period_code, rubric_version, + pair_kind, post_id, criterion_code, leftover_distance, leftover_residual + ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9) + """, + grouping_kind, + grouping_key, + period_code, + RUBRIC_VERSION, + pair.pair_kind, + pair.post_id, + pair.criterion_code, + pair.leftover_distance, + pair.leftover_residual, + ) def _groups_from_rows( @@ -478,6 +496,21 @@ async def fetch_period_reports( period_code, RUBRIC_VERSION, ) + leftover = await conn.fetch( + """ + select lp.grouping_key, lp.pair_kind, lp.post_id, lp.criterion_code, + lp.leftover_distance, lp.leftover_residual, p.post_title + from report_leftover_pair 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, + case lp.pair_kind when 'closest' then 0 else 1 end, + p.post_title + """, + grouping_kind, + period_code, + RUBRIC_VERSION, + ) status_labels = await labels_for_codes( conn, [row["ticket_status_code"] for row in members if row["ticket_status_code"]], @@ -488,6 +521,9 @@ async def fetch_period_reports( selected_by_group: dict[str, list[asyncpg.Record]] = defaultdict(list) for row in selected: selected_by_group[row["grouping_key"]].append(row) + leftover_by_group: dict[str, list[asyncpg.Record]] = defaultdict(list) + for row in leftover: + leftover_by_group[row["grouping_key"]].append(row) payload: list[dict[str, Any]] = [] for header in headers: payload.append( @@ -545,6 +581,17 @@ async def fetch_period_reports( } for row in selected_by_group.get(header["grouping_key"], []) ], + "leftover_pairs": [ + { + "pair_kind": str(row["pair_kind"]), + "post_id": str(row["post_id"]), + "post_title": row["post_title"], + "criterion_code": str(row["criterion_code"]), + "leftover_distance": float(row["leftover_distance"]), + "leftover_residual": float(row["leftover_residual"]), + } + for row in leftover_by_group.get(header["grouping_key"], []) + ], } ) return payload diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index 0c6323a9..51ac998c 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -18,6 +18,7 @@ COPY migrations/0008_post_summary_result.sql /docker-entrypoint-initdb.d/09-post COPY migrations/0009_shared_metric_bank.sql /docker-entrypoint-initdb.d/10-shared-metric-bank.sql COPY migrations/0010_report_item_information.sql /docker-entrypoint-initdb.d/11-report-item-information.sql COPY migrations/0011_post_chat_result.sql /docker-entrypoint-initdb.d/12-post-chat-result.sql +COPY migrations/0012_report_leftover_pair.sql /docker-entrypoint-initdb.d/13-report-leftover-pair.sql # Official image already drops to this account at runtime; declare it so # the Dockerfile itself satisfies DS-0002 (explicit non-root USER). USER postgres diff --git a/docs/adr/0003-fast-mlsirm-report-integration.md b/docs/adr/0003-fast-mlsirm-report-integration.md index 031bb0ea..bdf234b6 100644 --- a/docs/adr/0003-fast-mlsirm-report-integration.md +++ b/docs/adr/0003-fast-mlsirm-report-integration.md @@ -100,6 +100,10 @@ than one large PR: `information_polytomous` (Lord, 1980 max-info). Persist the ranking (`report_item_information`) and show the rank-1 item on the Period reports panel. Do not reimplement an information function here. +7. **Leftover-pair slice** (shipped in 0.71.2; ADR 0017 / 0018): after + IRT main effects, persist closest and farthest post–criterion pairs + from the residual leftover map. Do not fork LSIRM; do not invent a + leftover-pair API inside `fast-mlsirm` in this slice. **TEPP boundary.** [ARCHITECTURE.md](../../ARCHITECTURE.md) already assigns calibrated temporal/event measurement to diff --git a/docs/adr/0017-persist-lsirm-leftover-pairs.md b/docs/adr/0017-persist-lsirm-leftover-pairs.md new file mode 100644 index 00000000..22f62abb --- /dev/null +++ b/docs/adr/0017-persist-lsirm-leftover-pairs.md @@ -0,0 +1,55 @@ +# ADR 0017 — Persist LSIRM leftover post–criterion pairs + +**Decision status:** Accepted +**Date:** 2026-08-17 + +## Context + +Period reports already persist IRT main effects: EAP θ per post, a +shared GRM/GPCM item bank, FIPC linking, and Lord (1980) max-info CAT +ranks. After those main effects, Jeon et al. (2021, eq. 3) leave a +leftover interaction `−γ‖ξ_p − ζ_i‖` on the person–item map. Closest +pairs are the smallest Euclidean leftover-map distances; farthest +pairs are the largest. + +`fast-mlsirm` implements the leftover term inside MLSRM fitting but +exposes no leftover-pair API. LineageWeave must not fork LSIRM or +invent a second IRT fit. Buyers still need a durable, clickable +answer to “which post–criterion pair is unexpectedly aligned, and +which pair is unexpectedly opposed?” + +## Decision + +After a real GRM/GPCM score, compute the residual matrix +`R = Y − E[Y|θ, item]` from the already-fitted category +probabilities. A Gabriel (1971) biplot of `R` supplies person +positions `ξ` and item positions `ζ`. Persist exactly one `closest` +and one `farthest` observed cell per period report in +`report_leftover_pair` (3NF, two-or-more-word `snake_case`). + +Cascade the rows with `report_period_score`. Do not store a second +theta. Do not invent leftover numbers when the IRT matrix is +unusable. A rank-0 residual still emits a stable pair so `make seed` +is not empty; the stored distance is then zero, not a fabricated +interaction. + +The UI contract is ADR 0018. + +## Consequences + +Rebuild and seed write leftover pairs in the same transaction as +member scores. `GET /api/reports/{grouping}/{period}` returns +`leftover_pairs` with the post title so the buyer can open that post. +Migration `0012_report_leftover_pair.sql` upgrades volumes that +already applied `0001`. + +## 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/0018-leftover-pair-report-ui.md b/docs/adr/0018-leftover-pair-report-ui.md new file mode 100644 index 00000000..ef3234f7 --- /dev/null +++ b/docs/adr/0018-leftover-pair-report-ui.md @@ -0,0 +1,39 @@ +# ADR 0018 — Leftover pairs sit above the report member list + +**Decision status:** Accepted +**Date:** 2026-08-17 + +## Context + +ADR 0017 persists closest and farthest leftover post–criterion pairs. +Those pairs only help if a buyer can see them on the Period reports +panel and open the named post without hunting through the member list. + +The member list is already the click-through to Event Lineage, Keyman, +and evaluation. Leftover pairs must not replace that list or invent a +second navigation surface. + +## Decision + +On each period-report group, render leftover pairs **above** the +member list. Each pair is a button: closest or farthest label, post +title, criterion short label, leftover-map distance. Clicking the +button opens that post with the same handler as a member row. + +After `make seed`, closest and farthest leftover pairs sit above the +member list. Click a pair to open that post. + +Missing leftover rows render nothing — never a placeholder pair. + +## Consequences + +The authorized report payload carries `leftover_pairs` next to +`members` and `selected_items`. Screen-reader names are +`Open leftover closest pair: {title}` and +`Open leftover farthest pair: {title}` so the control announces the +next action, not only the distance. + +## Related + +Depends on [ADR 0017](0017-persist-lsirm-leftover-pairs.md) and +[ADR 0003](0003-fast-mlsirm-report-integration.md). diff --git a/frontend/package.json b/frontend/package.json index 9c84795d..49535e11 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.71.0", + "version": "0.71.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 415e1419..a0f99714 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -292,6 +292,24 @@ describe("App, authenticated", () => { { item_code: "general_sentiment_positive", rank: 2, information: 0.4 }, { item_code: "general_sentiment_negative", rank: 3, information: 0.2 }, ], + leftover_pairs: [ + { + pair_kind: "closest", + post_id: "post-1", + post_title: "Public post", + criterion_code: "sales_lead_specificity", + leftover_distance: 0.12, + leftover_residual: 0.4, + }, + { + pair_kind: "farthest", + post_id: "post-2", + post_title: "Specification revision requested", + criterion_code: "general_sentiment_negative", + leftover_distance: 1.84, + leftover_residual: -1.1, + }, + ], members: [ { post_id: "post-1", @@ -1265,6 +1283,19 @@ describe("App, authenticated", () => { ); expect(screen.getByRole("button", { name: /open report post: public post/i })).toHaveTextContent("Open"); expect(screen.getByRole("button", { name: /open report post: public post/i })).toHaveTextContent("due 2026-01-12"); + expect(screen.getByLabelText("Leftover pairs")).toBeInTheDocument(); + const closestPair = screen.getByRole("button", { name: /open leftover closest pair: public post/i }); + const farthestPair = screen.getByRole("button", { + name: /open leftover farthest pair: specification revision requested/i, + }); + expect(closestPair).toHaveTextContent("Closest leftover: Public post"); + expect(closestPair).toHaveTextContent("sales-lead"); + expect(closestPair).toHaveTextContent("d 0.12"); + expect(farthestPair).toHaveTextContent("Farthest leftover: Specification revision requested"); + expect(farthestPair).toHaveTextContent("negative"); + expect(farthestPair).toHaveTextContent("d 1.84"); + const memberButton = screen.getByRole("button", { name: /open report post: public post/i }); + expect(closestPair.compareDocumentPosition(memberButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect( screen.getByRole("button", { name: /open report post: specification revision requested/i }), ).toHaveTextContent("Send Westfield Power the revised specification"); @@ -1308,6 +1339,16 @@ describe("App, authenticated", () => { expect(periodInput).toHaveValue("2026-W03"); }); + it("opens a leftover pair post from the report panel", async () => { + stubBackend(); + render(); + + await userEvent.click( + await screen.findByRole("button", { name: /open leftover closest pair: public post/i }), + ); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + }); + it("opens Event Lineage, Keyman, and evaluation from a report member click", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1e39a925..bfd1a9c2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1527,6 +1527,29 @@ function ReportsPanel({ {report.selected_items[0].information.toFixed(2)} )} + {report.leftover_pairs && report.leftover_pairs.length > 0 && ( +
    + {report.leftover_pairs.map((pair) => ( +
  • + +
  • + ))} +
+ )} {report.members.length > 0 && (
    {report.members.map((member) => ( diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2b669277..a28fad27 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -338,6 +338,15 @@ export interface SelectedReportItem { information: number; } +export interface LeftoverPair { + pair_kind: "closest" | "farthest" | string; + post_id: string; + post_title: string; + criterion_code: string; + leftover_distance: number; + leftover_residual: number; +} + export interface PeriodGroupReport { grouping_key: string; selected_model: string; @@ -351,6 +360,7 @@ export interface PeriodGroupReport { delta_mean_theta: number | null; members: ReportMember[]; selected_items: SelectedReportItem[]; + leftover_pairs: LeftoverPair[]; } export interface PeriodReports { diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 0ac8e50f..90076db2 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.71.0" +__version__ = "0.71.2" diff --git a/lineageweave/period_report.py b/lineageweave/period_report.py index 1a9a9bb3..88dc8649 100644 --- a/lineageweave/period_report.py +++ b/lineageweave/period_report.py @@ -16,6 +16,14 @@ ``fast_mlsirm.information_polytomous`` -- Samejima (1969) GRM / Muraki (1993) GPCM, computed in Rust. A missing bank is not invented. +Leftover post–criterion pairs (ADR 0017) come from the residual +interaction after those IRT main effects: ``R = Y − E[Y|θ, item]``. +A Gabriel biplot of ``R`` supplies person and item leftover-map +positions. Closest / farthest pairs are the min / max Euclidean +distances on that map (Jeon et al., 2021, eq. 3). ``fast-mlsirm`` +has no leftover-pair API; this module does not invent a second IRT +fit and does not fork LSIRM. + This module is pure compute. Persistence lives in ``backend/app/report_ingestion.py``. TEPP is not used here; temporal event measurement stays on ``tepp_client``. @@ -39,6 +47,9 @@ LINK_METHOD_FREE = "free" LINK_METHOD_FIPC = "fipc" +PAIR_KIND_CLOSEST = "closest" +PAIR_KIND_FARTHEST = "farthest" +_LEFTOVER_SINGULAR_FLOOR = 1e-12 @dataclass(frozen=True) @@ -59,6 +70,17 @@ class SelectedItem: rank: int +@dataclass(frozen=True) +class LeftoverPair: + """One post–criterion pair on the leftover interaction map.""" + + pair_kind: str + post_id: str + criterion_code: str + leftover_distance: float + leftover_residual: float + + @dataclass(frozen=True) class ItemBank: """Polytomous item parameters on one metric (the FIPC anchor).""" @@ -99,6 +121,7 @@ class PeriodReport: anchor_period_code: str | None = None delta_mean_theta: float | None = None selected_items: tuple[SelectedItem, ...] = () + leftover_pairs: tuple[LeftoverPair, ...] = () def _sigmoid(value: np.ndarray) -> np.ndarray: @@ -223,6 +246,125 @@ def observed_response_loglik(matrix: np.ndarray, probs: np.ndarray) -> float: return loglik +def expected_category_matrix(matrix: np.ndarray, probs: np.ndarray) -> np.ndarray: + """E[Y_pi] = sum_k k P(Y=k | θ_p, item_i); missing cells stay NaN.""" + n_categories = probs.shape[2] + categories = np.arange(n_categories, dtype=np.float64) + expected = np.tensordot(probs, categories, axes=([2], [0])) + return np.where(np.isnan(matrix), np.nan, expected) + + +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. A rank-0 + residual still emits a stable closest/farthest pair so seed is not + empty; it does not invent 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" + ) + 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: list[tuple[int, int]] = [ + (person, item) + for person in range(matrix.shape[0]) + for item in range(matrix.shape[1]) + if not np.isnan(matrix[person, item]) and np.isfinite(residual[person, item]) + ] + if not observed: + return () + + observed_values = np.asarray( + [residual[person, item] for person, item in observed], dtype=np.float64 + ) + center = float(np.mean(observed_values)) + filled = np.zeros(matrix.shape, dtype=np.float64) + for person, item in observed: + filled[person, item] = residual[person, item] - center + + person_pos, item_pos = _leftover_map_positions(filled) + candidates: list[tuple[float, str, str, float]] = [] + for person, item in observed: + distance = float(np.linalg.norm(person_pos[person] - item_pos[item])) + if not np.isfinite(distance): + continue + candidates.append( + ( + max(distance, 0.0), + post_ids[person], + item_codes[item], + float(residual[person, item]), + ) + ) + if not candidates: + 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])) + return ( + LeftoverPair( + pair_kind=PAIR_KIND_CLOSEST, + post_id=closest[1], + criterion_code=closest[2], + leftover_distance=closest[0], + leftover_residual=closest[3], + ), + LeftoverPair( + pair_kind=PAIR_KIND_FARTHEST, + post_id=farthest[1], + criterion_code=farthest[2], + leftover_distance=farthest[0], + leftover_residual=farthest[3], + ), + ) + + +def leftover_pairs_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.""" + probs = _category_probabilities(model, theta, fit) + expected = expected_category_matrix(matrix, probs) + return leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) + + +def _leftover_map_positions(filled: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Gabriel biplot coordinates; rank-0 residuals collapse to the origin.""" + n_persons, n_items = filled.shape + if n_persons == 0 or n_items == 0 or not np.any(np.abs(filled) > _LEFTOVER_SINGULAR_FLOOR): + return ( + np.zeros((n_persons, 1), dtype=np.float64), + np.zeros((n_items, 1), dtype=np.float64), + ) + 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), + ) + scale = np.sqrt(singular[keep]) + person_pos = left[:, keep] * scale + item_pos = right[keep, :].T * scale + return person_pos, item_pos + + def _member_scores(post_ids: list[str], scores: dict[str, np.ndarray]) -> tuple[MemberScore, ...]: theta = np.asarray(scores["theta_eap"], dtype=np.float64) theta_sd = np.asarray(scores["theta_sd"], dtype=np.float64) @@ -283,6 +425,7 @@ def calibrate_period_report( item_bank=item_bank, link_method=LINK_METHOD_FREE, selected_items=rank_items_by_information(item_bank, mean_theta), + leftover_pairs=leftover_pairs_for_fit(post_ids, item_codes, matrix, selected, theta, fit), ) @@ -330,6 +473,9 @@ def score_period_on_bank( else mean_theta - float(previous_mean_theta) ), selected_items=rank_items_by_information(item_bank, mean_theta), + leftover_pairs=leftover_pairs_for_fit( + post_ids, item_bank.item_codes, matrix, item_bank.model, theta, fit + ), ) diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index 6a2676a4..89e578e6 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -318,6 +318,29 @@ create table report_item_information ( check (item_rank >= 1) ); +-- Leftover interaction map pairs after IRT main effects (ADR 0017). +-- Closest / farthest post–criterion Euclidean distances on the residual +-- biplot (Jeon et al., 2021). Cascade with the period score. +create table report_leftover_pair ( + grouping_kind text not null, + grouping_key text not null, + period_code text not null, + rubric_version text not null, + pair_kind text not null, + post_id uuid not null references source_post (post_id), + criterion_code text not null references common_lookup_value (lookup_code), + leftover_distance numeric not null, + leftover_residual numeric not null, + primary key (grouping_kind, grouping_key, period_code, rubric_version, pair_kind), + 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, + check (pair_kind in ('closest', 'farthest')), + check (leftover_distance >= 0) +); + +create index report_leftover_pair_post_idx on report_leftover_pair (post_id); + -- --------------------------------------------------------------------- -- Keyman: real (or, in this repo's default synthetic configuration, -- fabricated -- see ADR 0001) people mentioned in posts. A person may diff --git a/migrations/0012_report_leftover_pair.sql b/migrations/0012_report_leftover_pair.sql new file mode 100644 index 00000000..f9c69e6f --- /dev/null +++ b/migrations/0012_report_leftover_pair.sql @@ -0,0 +1,23 @@ +-- ADR 0017: persist closest/farthest leftover post–criterion pairs after +-- IRT main effects. CREATE IF NOT EXISTS so a volume that already ran +-- 0001 still upgrades. + +create table if not exists report_leftover_pair ( + grouping_kind text not null, + grouping_key text not null, + period_code text not null, + rubric_version text not null, + pair_kind text not null, + post_id uuid not null references source_post (post_id), + criterion_code text not null references common_lookup_value (lookup_code), + leftover_distance numeric not null, + leftover_residual numeric not null, + primary key (grouping_kind, grouping_key, period_code, rubric_version, pair_kind), + 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, + check (pair_kind in ('closest', 'farthest')), + check (leftover_distance >= 0) +); + +create index if not exists report_leftover_pair_post_idx on report_leftover_pair (post_id); diff --git a/pyproject.toml b/pyproject.toml index 9a2272d3..63d23688 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.71.0" +version = "0.71.2" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 72318f33..d4c24d84 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -106,6 +106,7 @@ def seed( cur.execute((migrations / "0009_shared_metric_bank.sql").read_text()) cur.execute((migrations / "0010_report_item_information.sql").read_text()) cur.execute((migrations / "0011_post_chat_result.sql").read_text()) + cur.execute((migrations / "0012_report_leftover_pair.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values @@ -1026,6 +1027,24 @@ def _persist_seed_period_report( item.information, ), ) + for pair in report.leftover_pairs: + cur.execute( + "insert into report_leftover_pair (" + "grouping_kind, grouping_key, period_code, rubric_version, " + "pair_kind, post_id, criterion_code, leftover_distance, leftover_residual" + ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s)", + ( + grouping_kind, + grouping_key, + period_code, + RUBRIC_VERSION, + pair.pair_kind, + pair.post_id, + pair.criterion_code, + pair.leftover_distance, + pair.leftover_residual, + ), + ) def _seed_demo_period_report(cur, author_account_id, corporate_entity_id, process_unit_id) -> None: diff --git a/tests/test_period_report.py b/tests/test_period_report.py index 2ebdb4a9..04c9c392 100644 --- a/tests/test_period_report.py +++ b/tests/test_period_report.py @@ -13,9 +13,12 @@ from lineageweave.period_report import ( LINK_METHOD_FIPC, LINK_METHOD_FREE, + PAIR_KIND_CLOSEST, + PAIR_KIND_FARTHEST, ItemBank, assemble_response_matrix, calibrate_period_report, + leftover_pairs_from_residual, link_or_calibrate_period_report, rank_items_by_information, score_groups_on_shared_metric, @@ -198,6 +201,74 @@ def test_cat_selects_hard_item_at_high_theta() -> None: assert high[0].item_code != low[0].item_code +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, + ) + 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.leftover_distance == pytest.approx(2.0 * np.sqrt(2.0), rel=1e-6) + + +def test_zero_residual_still_emits_stable_leftover_pairs() -> 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 = 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[1].post_id == "beta-post" + assert pairs[1].criterion_code == "item_two" + + +def test_calibrated_report_attaches_leftover_pairs() -> None: + items = CRITERION_CODES + high_ids = [f"high-{idx}" for idx in range(4)] + low_ids = [f"low-{idx}" for idx in range(4)] + rows: list[tuple[str, str, int]] = [] + for post_id in high_ids: + for item in items: + rows.append((post_id, item, IRT_CATEGORY_COUNT - 1)) + for post_id in low_ids: + for item in items: + category = 0 if item != "sales_lead_specificity" else IRT_CATEGORY_COUNT - 1 + rows.append((post_id, item, category)) + report = calibrate_period_report(high_ids + low_ids, rows) + assert len(report.leftover_pairs) == 2 + kinds = {pair.pair_kind for pair in report.leftover_pairs} + assert kinds == {PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST} + by_kind = {pair.pair_kind: pair for pair in report.leftover_pairs} + assert by_kind[PAIR_KIND_CLOSEST].leftover_distance <= by_kind[PAIR_KIND_FARTHEST].leftover_distance + member_ids = {member.post_id for member in report.member_scores} + for pair in report.leftover_pairs: + assert pair.post_id in member_ids + assert pair.criterion_code in items + assert pair.leftover_distance >= 0.0 + assert np.isfinite(pair.leftover_residual) + + + def test_shared_metric_attaches_cat_ranking() -> None: """Scoring a group on the shared bank must persist a CAT ranking.""" items = CRITERION_CODES diff --git a/tests/test_schema.py b/tests/test_schema.py index 33e88f70..5fb787d8 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -96,6 +96,7 @@ def test_migration_applies_cleanly(schema_db) -> None: "report_member_score", "report_item_parameter", "report_item_information", + "report_leftover_pair", "post_summary_result", "post_summary_event", "post_summary_role", diff --git a/uv.lock b/uv.lock index 1964f34b..76a4a7d6 100644 --- a/uv.lock +++ b/uv.lock @@ -355,7 +355,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.71.0" +version = "0.71.2" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 7aa225cfec7f6f017f2e9b97d8f33ae21a6464df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:24:14 +0000 Subject: [PATCH 2/3] fix: hide leftover pairs the buyer cannot open Leftover pairs now join source_post visibility and use the same ABAC gate as report members. Hidden posts stay off the leftover list. The pair buttons name the leftover criterion as the next action. --- CHANGELOG.d/0.71.2-leftover-pairs.md | 2 +- CHANGELOG.md | 3 ++- backend/app/main.py | 9 ++++++++- backend/app/report_ingestion.py | 5 ++++- backend/tests/test_api.py | 4 ++++ docs/adr/0017-persist-lsirm-leftover-pairs.md | 5 +++-- docs/adr/0018-leftover-pair-report-ui.md | 7 +++++-- frontend/src/App.test.tsx | 12 +++++++---- frontend/src/App.tsx | 20 +++++++++++++------ 9 files changed, 49 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.d/0.71.2-leftover-pairs.md b/CHANGELOG.d/0.71.2-leftover-pairs.md index f8f2f268..7061090f 100644 --- a/CHANGELOG.d/0.71.2-leftover-pairs.md +++ b/CHANGELOG.d/0.71.2-leftover-pairs.md @@ -7,4 +7,4 @@ - Period reports show those pairs above the member list; click opens that post (ADR 0018). After `make seed`, closest and farthest leftover pairs sit above the member list. Click a pair to open that - post. + post. A leftover pair for a hidden post is omitted. diff --git a/CHANGELOG.md b/CHANGELOG.md index 84ab0bc3..c7d63529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ All notable changes to this project are documented here. Format follows - Period reports now persist closest and farthest leftover post–criterion pairs after the IRT main effects (Jeon leftover map). After `make seed`, closest and farthest leftover pairs sit - above the member list. Click a pair to open that post. + above the member list. Click a pair to open that post. A leftover + pair for a hidden post is omitted the same way a hidden member is. ## [0.71.0] - 2026-08-14 diff --git a/backend/app/main.py b/backend/app/main.py index c69609a9..91ad406b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -758,7 +758,14 @@ async def read_period_reports( members = [member for member in report["members"] if _can_see_post(account, member)] if not members: continue - visible.append({**report, "members": members, "post_count": len(members)}) + leftover_pairs = [ + pair + for pair in report.get("leftover_pairs", []) + if _can_see_post(account, pair) + ] + visible.append( + {**report, "members": members, "leftover_pairs": leftover_pairs, "post_count": len(members)} + ) return {"grouping_kind": grouping_kind, "period_code": period_code, "reports": visible} diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index be78949e..eff621d0 100644 --- a/backend/app/report_ingestion.py +++ b/backend/app/report_ingestion.py @@ -499,7 +499,8 @@ async def fetch_period_reports( leftover = await conn.fetch( """ select lp.grouping_key, lp.pair_kind, lp.post_id, lp.criterion_code, - lp.leftover_distance, lp.leftover_residual, p.post_title + lp.leftover_distance, lp.leftover_residual, p.post_title, + p.visibility_code, p.corporate_entity_id from report_leftover_pair 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 @@ -589,6 +590,8 @@ async def fetch_period_reports( "criterion_code": str(row["criterion_code"]), "leftover_distance": float(row["leftover_distance"]), "leftover_residual": float(row["leftover_residual"]), + "visibility_code": row["visibility_code"], + "corporate_entity_id": str(row["corporate_entity_id"]), } for row in leftover_by_group.get(header["grouping_key"], []) ], diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 74ab4470..1db483ee 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -2365,6 +2365,10 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token, assert high_report["link_method"] == "fipc" assert high_report["selected_model"] in {"grm", "gpcm"} assert high_report["delta_mean_theta"] is None + leftover_kinds = {pair["pair_kind"] for pair in high_report.get("leftover_pairs", [])} + 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", [])) week3 = client.get( "/api/reports/process_unit/2026-W03", diff --git a/docs/adr/0017-persist-lsirm-leftover-pairs.md b/docs/adr/0017-persist-lsirm-leftover-pairs.md index 22f62abb..c1c777f3 100644 --- a/docs/adr/0017-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0017-persist-lsirm-leftover-pairs.md @@ -40,8 +40,9 @@ The UI contract is ADR 0018. Rebuild and seed write leftover pairs in the same transaction as member scores. `GET /api/reports/{grouping}/{period}` returns `leftover_pairs` with the post title so the buyer can open that post. -Migration `0012_report_leftover_pair.sql` upgrades volumes that -already applied `0001`. +Hidden posts stay hidden: leftover pairs join `source_post` and use +the same ABAC gate as members. Migration `0012_report_leftover_pair.sql` +upgrades volumes that already applied `0001`. ## References diff --git a/docs/adr/0018-leftover-pair-report-ui.md b/docs/adr/0018-leftover-pair-report-ui.md index ef3234f7..2fcbd896 100644 --- a/docs/adr/0018-leftover-pair-report-ui.md +++ b/docs/adr/0018-leftover-pair-report-ui.md @@ -17,13 +17,16 @@ second navigation surface. On each period-report group, render leftover pairs **above** the member list. Each pair is a button: closest or farthest label, post -title, criterion short label, leftover-map distance. Clicking the -button opens that post with the same handler as a member row. +title, criterion short label, leftover-map distance, and the next +action (“Open this post to read the criterion it sat closest to / +farthest from after main effects.”). Clicking the button opens that +post with the same handler as a member row. After `make seed`, closest and farthest leftover pairs sit above the member list. Click a pair to open that post. Missing leftover rows render nothing — never a placeholder pair. +A hidden post never appears as a leftover pair. ## Consequences diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a0f99714..bc84dd3f 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1288,11 +1288,15 @@ describe("App, authenticated", () => { const farthestPair = screen.getByRole("button", { name: /open leftover farthest pair: specification revision requested/i, }); - expect(closestPair).toHaveTextContent("Closest leftover: Public post"); - expect(closestPair).toHaveTextContent("sales-lead"); + expect(closestPair).toHaveTextContent("Closest leftover: Public post · sales-lead"); + expect(closestPair).toHaveTextContent( + "Open this post to read the criterion it sat closest to after main effects.", + ); expect(closestPair).toHaveTextContent("d 0.12"); - expect(farthestPair).toHaveTextContent("Farthest leftover: Specification revision requested"); - expect(farthestPair).toHaveTextContent("negative"); + expect(farthestPair).toHaveTextContent("Farthest leftover: Specification revision requested · negative"); + expect(farthestPair).toHaveTextContent( + "Open this post to read the criterion it sat farthest from after main effects.", + ); expect(farthestPair).toHaveTextContent("d 1.84"); const memberButton = screen.getByRole("button", { name: /open report post: public post/i }); expect(closestPair.compareDocumentPosition(memberButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bfd1a9c2..ff11eb16 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1529,25 +1529,33 @@ function ReportsPanel({ )} {report.leftover_pairs && report.leftover_pairs.length > 0 && (
      - {report.leftover_pairs.map((pair) => ( + {report.leftover_pairs.map((pair) => { + const kindLabel = + pair.pair_kind === "farthest" ? "Farthest leftover" : "Closest leftover"; + const nextAction = + pair.pair_kind === "farthest" + ? "Open this post to read the criterion it sat farthest from after main effects." + : "Open this post to read the criterion it sat closest to after main effects."; + const criterion = criterionShortLabel(pair.criterion_code); + return (
    • - ))} + ); + })}
    )} {report.members.length > 0 && ( From 266a83e789edff55d5d942c8ebe429f6b531e7af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:40:30 +0000 Subject: [PATCH 3/3] fix: isolate leftover biplot and drop missing cells from SVD Leftover Gabriel factorization lives in leftover_pairs.py so leftover tests do not import period_report or fast_mlsirm. Missing response cells stay out of the complete-case rectangle. Leftover pairs now reference report_member_score and report_item_information. --- AGENTS.md | 10 +- CHANGELOG.d/0.71.2-leftover-pairs.md | 7 +- CHANGELOG.md | 6 +- docs/adr/0017-persist-lsirm-leftover-pairs.md | 21 ++- lineageweave/leftover_pairs.py | 168 ++++++++++++++++++ lineageweave/period_report.py | 114 +----------- migrations/0001_initial_schema.sql | 10 +- migrations/0012_report_leftover_pair.sql | 33 +++- tests/test_leftover_pairs.py | 118 ++++++++++++ tests/test_schema.py | 17 ++ 10 files changed, 373 insertions(+), 131 deletions(-) create mode 100644 lineageweave/leftover_pairs.py create mode 100644 tests/test_leftover_pairs.py diff --git a/AGENTS.md b/AGENTS.md index 10604b8a..e474b9de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,10 +73,12 @@ in the same spirit) -- never against real data, per the hard rule above. against a live local stack (`make up`) and self-skip without one -- see [README.md](README.md#local-product-stack-docker-compose). -Period leftover pairs (ADR 0017 / 0018) are computed from the residual -after a real GRM/GPCM score, never invented. Closest and farthest -post–criterion pairs persist to `report_leftover_pair` and sit above -the member list so a click opens that post. +Period leftover pairs (ADR 0017 / 0018) are computed in +`lineageweave/leftover_pairs.py` from the residual after a real +GRM/GPCM score, never invented. Missing cells stay out of the +Gabriel factorization. Closest and farthest post–criterion pairs +persist to `report_leftover_pair` and sit above the member list so +a click opens that post. `frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`, pnpm via Corepack -- do not add a second Node package manager or a diff --git a/CHANGELOG.d/0.71.2-leftover-pairs.md b/CHANGELOG.d/0.71.2-leftover-pairs.md index 7061090f..0c6b1e1f 100644 --- a/CHANGELOG.d/0.71.2-leftover-pairs.md +++ b/CHANGELOG.d/0.71.2-leftover-pairs.md @@ -4,7 +4,6 @@ - Persist closest and farthest leftover pairs from the residual interaction map after GRM/GPCM scoring (ADR 0017). -- Period reports show those pairs above the member list; click opens - that post (ADR 0018). After `make seed`, closest and farthest - leftover pairs sit above the member list. Click a pair to open that - post. A leftover pair for a hidden post is omitted. +- After `make seed`, period reports show the closest and farthest + leftover pairs above the member list; clicking a pair opens that post + (ADR 0018). A leftover pair for a hidden post is omitted. diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d63529..e7ffaa1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,9 @@ All notable changes to this project are documented here. Format follows - Period reports now persist closest and farthest leftover post–criterion pairs after the IRT main effects (Jeon leftover - map). After `make seed`, closest and farthest leftover pairs sit - above the member list. Click a pair to open that post. A leftover - pair for a hidden post is omitted the same way a hidden member is. + map). After `make seed`, leftover pairs sit above the member + list; clicking a pair opens that post. A leftover pair for a + hidden post is omitted the same way a hidden member is. ## [0.71.0] - 2026-08-14 diff --git a/docs/adr/0017-persist-lsirm-leftover-pairs.md b/docs/adr/0017-persist-lsirm-leftover-pairs.md index c1c777f3..3ceb7157 100644 --- a/docs/adr/0017-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0017-persist-lsirm-leftover-pairs.md @@ -22,16 +22,23 @@ which pair is unexpectedly opposed?” After a real GRM/GPCM score, compute the residual matrix `R = Y − E[Y|θ, item]` from the already-fitted category -probabilities. A Gabriel (1971) biplot of `R` supplies person -positions `ξ` and item positions `ζ`. Persist exactly one `closest` +probabilities. A Gabriel (1971) biplot of the **complete-case** +submatrix of `R` supplies person positions `ξ` and item positions +`ζ`. Missing response cells are excluded from the factorization; +they are never filled with zero. Persist exactly one `closest` and one `farthest` observed cell per period report in `report_leftover_pair` (3NF, two-or-more-word `snake_case`). -Cascade the rows with `report_period_score`. Do not store a second -theta. Do not invent leftover numbers when the IRT matrix is -unusable. A rank-0 residual still emits a stable pair so `make seed` -is not empty; the stored distance is then zero, not a fabricated -interaction. +The biplot lives in `lineageweave/leftover_pairs.py` so leftover +tests do not import `period_report` or `fast_mlsirm`. + +Cascade the rows with `report_period_score`. A leftover post must +also be a `report_member_score` row, and the leftover criterion +must be a `report_item_information` item on that same report. +Do not store a second theta. Do not invent leftover numbers when +the IRT matrix is unusable. A rank-0 residual still emits a +stable pair so `make seed` is not empty; the stored distance is +then zero, not a fabricated interaction. The UI contract is ADR 0018. diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py new file mode 100644 index 00000000..353b1a6e --- /dev/null +++ b/lineageweave/leftover_pairs.py @@ -0,0 +1,168 @@ +"""Jeon leftover post–criterion pairs after a main-effect IRT (ADR 0017). + +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. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +PAIR_KIND_CLOSEST = "closest" +PAIR_KIND_FARTHEST = "farthest" +_LEFTOVER_SINGULAR_FLOOR = 1e-12 + + +@dataclass(frozen=True) +class LeftoverPair: + """One post–criterion pair on the leftover interaction map.""" + + pair_kind: str + post_id: str + criterion_code: str + leftover_distance: float + leftover_residual: float + + +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. A rank-0 + residual still emits a stable closest/farthest pair so seed is not + empty; it does not invent 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" + ) + 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) + 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 = _complete_case_positions(residual, center, keep_person, keep_item) + candidates: list[tuple[float, str, str, float]] = [] + if person_pos is not None and item_pos is not None: + person_index = np.flatnonzero(keep_person) + item_index = np.flatnonzero(keep_item) + 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: + continue + distance = float( + np.linalg.norm(person_pos[local_person[person]] - item_pos[local_item[item]]) + ) + if not np.isfinite(distance): + continue + candidates.append( + ( + max(distance, 0.0), + post_ids[person], + item_codes[item], + float(residual[person, item]), + ) + ) + if not candidates: + for person, item in observed: + distance = abs(float(residual[person, item]) - center) + candidates.append( + ( + max(distance, 0.0), + post_ids[person], + item_codes[item], + float(residual[person, item]), + ) + ) + closest = min(candidates, key=lambda row: (row[0], row[1], row[2])) + farthest = max(candidates, key=lambda row: (row[0], row[1], row[2])) + return ( + LeftoverPair( + pair_kind=PAIR_KIND_CLOSEST, + post_id=closest[1], + criterion_code=closest[2], + leftover_distance=closest[0], + leftover_residual=closest[3], + ), + LeftoverPair( + pair_kind=PAIR_KIND_FARTHEST, + post_id=farthest[1], + criterion_code=farthest[2], + leftover_distance=farthest[0], + leftover_residual=farthest[3], + ), + ) + + +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) + 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]: + """Gabriel coordinates on the complete-case residual rectangle only.""" + person_index = np.flatnonzero(keep_person) + item_index = np.flatnonzero(keep_item) + if person_index.size == 0 or item_index.size == 0: + return None, None + 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]: + """Gabriel biplot coordinates; rank-0 residuals collapse to the origin.""" + n_persons, n_items = filled.shape + if n_persons == 0 or n_items == 0 or not np.any(np.abs(filled) > _LEFTOVER_SINGULAR_FLOOR): + return ( + np.zeros((n_persons, 1), dtype=np.float64), + np.zeros((n_items, 1), dtype=np.float64), + ) + 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), + ) + scale = np.sqrt(singular[keep]) + person_pos = left[:, keep] * scale + item_pos = right[keep, :].T * scale + return person_pos, item_pos diff --git a/lineageweave/period_report.py b/lineageweave/period_report.py index 88dc8649..ce817b89 100644 --- a/lineageweave/period_report.py +++ b/lineageweave/period_report.py @@ -43,13 +43,13 @@ validate_irt_response_matrix, ) +from .leftover_pairs import LeftoverPair, 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 LINK_METHOD_FREE = "free" LINK_METHOD_FIPC = "fipc" -PAIR_KIND_CLOSEST = "closest" -PAIR_KIND_FARTHEST = "farthest" -_LEFTOVER_SINGULAR_FLOOR = 1e-12 @dataclass(frozen=True) @@ -70,17 +70,6 @@ class SelectedItem: rank: int -@dataclass(frozen=True) -class LeftoverPair: - """One post–criterion pair on the leftover interaction map.""" - - pair_kind: str - post_id: str - criterion_code: str - leftover_distance: float - leftover_residual: float - - @dataclass(frozen=True) class ItemBank: """Polytomous item parameters on one metric (the FIPC anchor).""" @@ -254,82 +243,6 @@ def expected_category_matrix(matrix: np.ndarray, probs: np.ndarray) -> np.ndarra return np.where(np.isnan(matrix), np.nan, expected) -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. A rank-0 - residual still emits a stable closest/farthest pair so seed is not - empty; it does not invent 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" - ) - 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: list[tuple[int, int]] = [ - (person, item) - for person in range(matrix.shape[0]) - for item in range(matrix.shape[1]) - if not np.isnan(matrix[person, item]) and np.isfinite(residual[person, item]) - ] - if not observed: - return () - - observed_values = np.asarray( - [residual[person, item] for person, item in observed], dtype=np.float64 - ) - center = float(np.mean(observed_values)) - filled = np.zeros(matrix.shape, dtype=np.float64) - for person, item in observed: - filled[person, item] = residual[person, item] - center - - person_pos, item_pos = _leftover_map_positions(filled) - candidates: list[tuple[float, str, str, float]] = [] - for person, item in observed: - distance = float(np.linalg.norm(person_pos[person] - item_pos[item])) - if not np.isfinite(distance): - continue - candidates.append( - ( - max(distance, 0.0), - post_ids[person], - item_codes[item], - float(residual[person, item]), - ) - ) - if not candidates: - 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])) - return ( - LeftoverPair( - pair_kind=PAIR_KIND_CLOSEST, - post_id=closest[1], - criterion_code=closest[2], - leftover_distance=closest[0], - leftover_residual=closest[3], - ), - LeftoverPair( - pair_kind=PAIR_KIND_FARTHEST, - post_id=farthest[1], - criterion_code=farthest[2], - leftover_distance=farthest[0], - leftover_residual=farthest[3], - ), - ) - - def leftover_pairs_for_fit( post_ids: list[str], item_codes: tuple[str, ...], @@ -344,27 +257,6 @@ def leftover_pairs_for_fit( return leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) -def _leftover_map_positions(filled: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - """Gabriel biplot coordinates; rank-0 residuals collapse to the origin.""" - n_persons, n_items = filled.shape - if n_persons == 0 or n_items == 0 or not np.any(np.abs(filled) > _LEFTOVER_SINGULAR_FLOOR): - return ( - np.zeros((n_persons, 1), dtype=np.float64), - np.zeros((n_items, 1), dtype=np.float64), - ) - 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), - ) - scale = np.sqrt(singular[keep]) - person_pos = left[:, keep] * scale - item_pos = right[keep, :].T * scale - return person_pos, item_pos - - def _member_scores(post_ids: list[str], scores: dict[str, np.ndarray]) -> tuple[MemberScore, ...]: theta = np.asarray(scores["theta_eap"], dtype=np.float64) theta_sd = np.asarray(scores["theta_sd"], dtype=np.float64) diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index 89e578e6..94aecf01 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -320,7 +320,9 @@ create table report_item_information ( -- Leftover interaction map pairs after IRT main effects (ADR 0017). -- Closest / farthest post–criterion Euclidean distances on the residual --- biplot (Jeon et al., 2021). Cascade with the period score. +-- biplot (Jeon et al., 2021). Cascade with the period score. The pair +-- post must be a member of this report; the criterion must be a CAT +-- item on this report. create table report_leftover_pair ( grouping_kind text not null, grouping_key text not null, @@ -335,6 +337,12 @@ create table report_leftover_pair ( 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, + 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, + 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, check (pair_kind in ('closest', 'farthest')), check (leftover_distance >= 0) ); diff --git a/migrations/0012_report_leftover_pair.sql b/migrations/0012_report_leftover_pair.sql index f9c69e6f..d65612ea 100644 --- a/migrations/0012_report_leftover_pair.sql +++ b/migrations/0012_report_leftover_pair.sql @@ -1,6 +1,7 @@ -- ADR 0017: persist closest/farthest leftover post–criterion pairs after -- IRT main effects. CREATE IF NOT EXISTS so a volume that already ran --- 0001 still upgrades. +-- 0001 still upgrades. Composite FKs are added below so an existing +-- table from an earlier 0012 still gains member/item integrity. create table if not exists report_leftover_pair ( grouping_kind text not null, @@ -21,3 +22,33 @@ create table if not exists report_leftover_pair ( ); create index if not exists report_leftover_pair_post_idx on report_leftover_pair (post_id); + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'leftover_pair_member_score_fk' + ) then + alter table report_leftover_pair + add constraint leftover_pair_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_pair_item_information_fk' + ) then + alter table report_leftover_pair + add constraint leftover_pair_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/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py new file mode 100644 index 00000000..72dea89b --- /dev/null +++ b/tests/test_leftover_pairs.py @@ -0,0 +1,118 @@ +"""Leftover post–criterion pairs after the main-effect IRT (ADR 0017). + +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. +""" + +from __future__ import annotations + +import ast +import importlib.util +import sys +from pathlib import Path + +import numpy as np +import pytest + +_LEFTOVER_PATH = Path(__file__).resolve().parents[1] / "lineageweave" / "leftover_pairs.py" + + +def _load_leftover(): + 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 + + +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, + ) + 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.leftover_distance == pytest.approx(2.0 * np.sqrt(2.0), rel=1e-6) + + +def test_zero_residual_still_emits_stable_leftover_pairs() -> 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 = 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[1].post_id == "beta-post" + assert pairs[1].criterion_code == "item_two" + + +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, + ) + 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"), + } + + +def test_leftover_is_empty_without_observed_cells() -> None: + 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) == () diff --git a/tests/test_schema.py b/tests/test_schema.py index 5fb787d8..bd3258bd 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -106,6 +106,23 @@ def test_migration_applies_cleanly(schema_db) -> None: assert expected <= tables +def test_leftover_pair_references_member_and_item_rows(schema_db) -> None: + """A leftover pair cannot name a post or item from another report.""" + with schema_db.cursor() as cur: + cur.execute( + """ + select confrelid::regclass::text + from pg_constraint + where conrelid = 'report_leftover_pair'::regclass and contype = 'f' + """ + ) + targets = {row[0] for row in cur.fetchall()} + assert "report_member_score" in targets + assert "report_item_information" in targets + assert "report_period_score" in targets + + + def test_corporate_hierarchy_recursive_query_returns_correct_shape(schema_db) -> None: """The real product requirement: 'Acme Group -> Acme Electronics Korea -> Acme Electronics Gwangju Plant' must be walkable with one query,