From 60ec4e1e0731f624c5c1a75710fbdf82776c3f94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 20:25:44 +0000 Subject: [PATCH 1/2] feat: persist leftover observed Y and expected E (v2.12.20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name leftover_observed_score and leftover_expected_score on leftover pairs so leftover residual R = Y − E is checkable (ADR 0168). Missing cells stay out of the factorization. Do not invent a leftover score or a second theta. --- AGENTS.md | 8 +- ARCHITECTURE.md | 8 +- .../2.12.20-leftover-observed-expected.md | 13 ++++ CHANGELOG.md | 12 +++ backend/app/report_ingestion.py | 12 ++- backend/tests/test_api.py | 8 ++ docker/postgres-init/migrate.sh | 2 +- docs/adr/0168-leftover-observed-expected.md | 66 ++++++++++++++++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 14 +++- frontend/src/App.tsx | 13 +++- frontend/src/api.ts | 2 + lineageweave/leftover_pairs.py | 76 +++++++++++-------- migrations/0001_initial_schema.sql | 11 ++- ...0108_report_leftover_observed_expected.sql | 32 ++++++++ ...0108_report_leftover_observed_expected.sql | 13 ++++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 8 +- tests/test_leftover_pairs.py | 31 +++++++- tests/test_migration_replay.py | 20 +++++ tests/test_period_report.py | 5 ++ uv.lock | 2 +- 22 files changed, 304 insertions(+), 56 deletions(-) create mode 100644 CHANGELOG.d/2.12.20-leftover-observed-expected.md create mode 100644 docs/adr/0168-leftover-observed-expected.md create mode 100644 migrations/0108_report_leftover_observed_expected.sql create mode 100644 migrations/rollback/0108_report_leftover_observed_expected.sql diff --git a/AGENTS.md b/AGENTS.md index 1728f9e61..1fa4b1274 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,12 +186,14 @@ in the same spirit) -- never against real data, per the hard rule above. against a live local stack (`make up`) and self-skip without one -- see [README.md](README.md#local-product-stack-docker-compose). -Period leftover pairs (ADR 0017 / 0018) are computed in +Period leftover pairs (ADR 0048 / 0049 / 0168) 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. +persist `leftover_observed_score`, `leftover_expected_score`, and +`leftover_residual` (`R = Y − E`) to `report_leftover_pair` and sit +above the member list so a click opens that post. Do not invent a +leftover score or a second theta. `frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`, pnpm via Corepack -- do not add a second Node package manager or a diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d0280ff97..ddf463753 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -588,8 +588,9 @@ 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`. After those IRT main effects, -residual SVD leftover pairs (Jeon et al., 2021; ADR 0017) persist to -`report_leftover_pair`. Results persist to +residual SVD leftover pairs (Jeon et al., 2021; ADR 0048 / 0168) persist to +`report_leftover_pair` with observed Y, expected E[Y|θ, item], and +leftover residual R = Y − E. Results persist to `report_period_score` / `report_member_score`. `GET /api/reports/{grouping}` lists the trend; `GET /api/reports/{grouping}/{period}` is ABAC-filtered; @@ -601,7 +602,8 @@ 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, leftover -closest/farthest pairs above the member list, and the +closest/farthest pairs (observed, expected, leftover residual) above +the member list, and the PU / corp / thread comparison -- never a placeholder. TEPP is unchanged. ## Phase 6b: Knowledge Graph as a real Ontology + Semantic Layer diff --git a/CHANGELOG.d/2.12.20-leftover-observed-expected.md b/CHANGELOG.d/2.12.20-leftover-observed-expected.md new file mode 100644 index 000000000..d2c8e266d --- /dev/null +++ b/CHANGELOG.d/2.12.20-leftover-observed-expected.md @@ -0,0 +1,13 @@ +# 2.12.20 — Leftover observed Y and expected E + +## Added + +- Persist leftover observed score `Y` and expected score `E[Y|θ, item]` + on each closest/farthest leftover pair so leftover residual + `R = Y − E` is checkable (ADR 0168). Missing cells stay out of the + factorization. Existing leftover rows are derived; migration `0108` + deletes incomplete rows rather than inventing `Y` or `E`. +- After `make seed`, leftover pair badges name observed, expected, and + leftover residual above the member list. Click still opens that post. + Copy tells the buyer to check leftover residual equals observed minus + expected. Do not invent a leftover score or a second theta. diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ed1a099..d26808de0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,18 @@ All notable changes to this project are documented here. Format follows environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. +## [2.12.20] - 2026-08-24 + +### Added + +- Leftover closest/farthest pairs now persist observed score `Y` and + expected score `E[Y|θ, item]` beside leftover residual `R = Y − E` + (ADR 0168). After `make seed`, leftover pair badges name observed, + expected, and leftover residual above the member list; click still + opens that post. Check leftover residual equals observed minus + expected. Missing cells stay out of the factorization. Do not invent + a leftover score or a second theta. + ## [2.12.6] - 2026-08-20 ### Added diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index 50614b0ad..b2f0e89f6 100644 --- a/backend/app/report_ingestion.py +++ b/backend/app/report_ingestion.py @@ -443,8 +443,9 @@ async def persist_period_report( """ 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) + pair_kind, post_id, criterion_code, leftover_distance, leftover_residual, + leftover_observed_score, leftover_expected_score + ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) """, grouping_kind, grouping_key, @@ -455,6 +456,8 @@ async def persist_period_report( pair.criterion_code, pair.leftover_distance, pair.leftover_residual, + pair.leftover_observed_score, + pair.leftover_expected_score, ) @@ -601,7 +604,8 @@ async def fetch_period_reports( leftover = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli f""" 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, + lp.leftover_observed_score, lp.leftover_expected_score, p.post_title, p.visibility_code, p.corporate_entity_id, ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context from report_leftover_pair lp @@ -698,6 +702,8 @@ async def fetch_period_reports( "criterion_code": str(row["criterion_code"]), "leftover_distance": float(row["leftover_distance"]), "leftover_residual": float(row["leftover_residual"]), + "leftover_observed_score": float(row["leftover_observed_score"]), + "leftover_expected_score": float(row["leftover_expected_score"]), "visibility_code": row["visibility_code"], "corporate_entity_id": str(row["corporate_entity_id"]), "has_real_source_context": bool(row["has_real_source_context"]), diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 438b4786a..d9038897d 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -4518,6 +4518,14 @@ 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", [])) + assert all( + abs( + pair["leftover_residual"] + - (pair["leftover_observed_score"] - pair["leftover_expected_score"]) + ) + < 1e-6 + for pair in high_report.get("leftover_pairs", []) + ) week3 = client.get( "/api/reports/process_unit/2026-W03", diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index f329117d6..01beb7b78 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0060_*|0100_*|0101_*|0102_*) ;; + 0060_*|0100_*|0101_*|0102_*|0108_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0168-leftover-observed-expected.md b/docs/adr/0168-leftover-observed-expected.md new file mode 100644 index 000000000..4ef1d844d --- /dev/null +++ b/docs/adr/0168-leftover-observed-expected.md @@ -0,0 +1,66 @@ +# ADR 0168 — Persist leftover observed Y and expected E[Y|θ, item] + +**Decision status:** Accepted +**Date:** 2026-08-24 + +Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and +[ADR 0049](0049-leftover-pair-report-ui.md). Independent of leftover-map +coordinates, leftover criterion landing, leftover complete-case +coverage, leftover-map axis share, leftover comparison-strip reuse, +and two-axis leftover-map distance. + +## Context + +ADR 0048 already computes leftover residual `R = Y − E[Y|θ, item]` +after IRT main effects (Jeon et al., 2021, eq. 3; Gabriel, 1971) and +persists closest/farthest pairs with `leftover_distance` and +`leftover_residual`. The buyer could see distance `d` but could not +check that residual identity: observed `Y` and expected `E` were +computed, then dropped. + +Backfilling `Y = R` and `E = 0` would invent an expected score. +Persisting only `R` leaves the identity uncheckable. + +## Decision + +Each leftover pair persists `leftover_observed_score` (`Y`) and +`leftover_expected_score` (`E[Y|θ, item]`) next to +`leftover_residual`. The check +`abs(R − (Y − E)) < 1e-9` is named +`leftover_pair_residual_identity`. Missing or non-finite cells never +become pairs. Existing leftover rows are derived: migration `0108` +deletes incomplete rows so rebuild/seed rewrites honest `Y` and `E` +instead of fabricating them. + +Do not invent a leftover score. Do not invent a second theta. + +After `make seed`, leftover pair badges name observed, expected, and +leftover residual above the member list. Click still opens that post. +Copy tells the buyer to check leftover residual equals observed minus +expected. + +## Consequences + +`GET /api/reports/{grouping}/{period}` returns `leftover_observed_score` +and `leftover_expected_score` on each pair. Hidden posts stay hidden +through the same ABAC join as members. Migration `0108` upgrades +volumes that already applied `0012`. Fresh `0001` schema includes the +same columns and identity check. + +## Related + +Does not mix into leftover persist-map, leftover criterion landing, +leftover complete-case coverage, leftover-map axis share, leftover +comparison-strip reuse, or two-axis leftover-map distance. Issues #79 +and #87 stay open. + +## 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/frontend/package.json b/frontend/package.json index e2e996bbe..a82321845 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.6", + "version": "2.12.20", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7462abd2c..53f83ae90 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -932,6 +932,8 @@ describe("App, authenticated", () => { criterion_code: "sales_lead_specificity", leftover_distance: 0.12, leftover_residual: 0.4, + leftover_observed_score: 3.4, + leftover_expected_score: 3.0, }, { pair_kind: "farthest", @@ -940,6 +942,8 @@ describe("App, authenticated", () => { criterion_code: "general_sentiment_negative", leftover_distance: 1.84, leftover_residual: -1.1, + leftover_observed_score: 1.0, + leftover_expected_score: 2.1, }, ], members: [ @@ -3357,13 +3361,19 @@ describe("App, authenticated", () => { }); 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.", + "Open this post to read the closest leftover criterion. Check leftover residual equals observed minus expected.", ); + expect(closestPair).toHaveTextContent("observed 3.40"); + expect(closestPair).toHaveTextContent("expected 3.00"); + expect(closestPair).toHaveTextContent("leftover 0.40"); expect(closestPair).toHaveTextContent("d 0.12"); 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.", + "Open this post to read the farthest leftover criterion. Check leftover residual equals observed minus expected.", ); + expect(farthestPair).toHaveTextContent("observed 1.00"); + expect(farthestPair).toHaveTextContent("expected 2.10"); + expect(farthestPair).toHaveTextContent("leftover -1.10"); 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 6fba0dd41..069c4d186 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3396,8 +3396,8 @@ function ReportsPanel({ 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."; + ? "Open this post to read the farthest leftover criterion. Check leftover residual equals observed minus expected." + : "Open this post to read the closest leftover criterion. Check leftover residual equals observed minus expected."; const criterion = criterionShortLabel(pair.criterion_code); return (
  • {nextAction} + + observed {pair.leftover_observed_score.toFixed(2)} + + + expected {pair.leftover_expected_score.toFixed(2)} + + + leftover {pair.leftover_residual.toFixed(2)} + d {pair.leftover_distance.toFixed(2)}
  • diff --git a/frontend/src/api.ts b/frontend/src/api.ts index cd0141a32..eba4edfad 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -759,6 +759,8 @@ export interface LeftoverPair { criterion_code: string; leftover_distance: number; leftover_residual: number; + leftover_observed_score: number; + leftover_expected_score: number; } export interface PeriodGroupReport { diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py index 353b1a6ef..fc03cbef4 100644 --- a/lineageweave/leftover_pairs.py +++ b/lineageweave/leftover_pairs.py @@ -1,9 +1,12 @@ -"""Jeon leftover post–criterion pairs after a main-effect IRT (ADR 0017). +"""Jeon leftover post–criterion pairs after a main-effect IRT (ADR 0048 / 0168). 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. +they are never treated as zero residuals. Closest and farthest pairs +keep the observed score ``Y`` and expected score ``E`` so a buyer can +check the leftover residual identity. This module does not invent a +leftover score or a second theta. """ from __future__ import annotations @@ -26,6 +29,8 @@ class LeftoverPair: criterion_code: str leftover_distance: float leftover_residual: float + leftover_observed_score: float + leftover_expected_score: float def leftover_pairs_from_residual( @@ -68,7 +73,7 @@ def leftover_pairs_from_residual( 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]] = [] + candidates: list[tuple[float, str, str, float, float, float]] = [] if person_pos is not None and item_pos is not None: person_index = np.flatnonzero(keep_person) item_index = np.flatnonzero(keep_item) @@ -82,42 +87,47 @@ def leftover_pairs_from_residual( ) if not np.isfinite(distance): continue - candidates.append( - ( - max(distance, 0.0), - post_ids[person], - item_codes[item], - float(residual[person, item]), - ) - ) + candidates.append(_candidate(distance, post_ids, item_codes, matrix, expected, 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]), - ) - ) + candidates.append(_candidate(distance, post_ids, item_codes, matrix, expected, 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 (_pair(PAIR_KIND_CLOSEST, closest), _pair(PAIR_KIND_FARTHEST, farthest)) + + +def _candidate( + distance: float, + post_ids: list[str], + item_codes: tuple[str, ...], + matrix: np.ndarray, + expected: np.ndarray, + residual: np.ndarray, + person: int, + item: int, +) -> tuple[float, str, str, float, float, float]: + """One observed leftover cell: distance, ids, R, Y, and E.""" 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], - ), + max(distance, 0.0), + post_ids[person], + item_codes[item], + float(residual[person, item]), + float(matrix[person, item]), + float(expected[person, item]), + ) + + +def _pair(kind: str, row: tuple[float, str, str, float, float, float]) -> LeftoverPair: + """Map a leftover candidate tuple onto the persisted pair contract.""" + return LeftoverPair( + pair_kind=kind, + post_id=row[1], + criterion_code=row[2], + leftover_distance=row[0], + leftover_residual=row[3], + leftover_observed_score=row[4], + leftover_expected_score=row[5], ) diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index 483893a1d..b87904e99 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -325,11 +325,12 @@ create table report_item_information ( check (item_rank >= 1) ); --- Leftover interaction map pairs after IRT main effects (ADR 0017). +-- Leftover interaction map pairs after IRT main effects (ADR 0048 / 0168). -- Closest / farthest post–criterion Euclidean distances on the residual -- biplot (Jeon et al., 2021). Cascade with the period score. The pair -- post must be a member of this report; the criterion must be a CAT --- item on this report. +-- item on this report. Observed Y and expected E[Y|θ, item] persist so +-- leftover_residual is checkable as Y − E. Do not invent a leftover score. create table report_leftover_pair ( grouping_kind text not null, grouping_key text not null, @@ -340,6 +341,8 @@ create table report_leftover_pair ( criterion_code text not null references common_lookup_value (lookup_code), leftover_distance numeric not null, leftover_residual numeric not null, + leftover_observed_score numeric not null, + leftover_expected_score 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) @@ -351,7 +354,9 @@ create table report_leftover_pair ( 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) + check (leftover_distance >= 0), + constraint leftover_pair_residual_identity + check (abs(leftover_residual - (leftover_observed_score - leftover_expected_score)) < 1e-9) ); create index report_leftover_pair_post_idx on report_leftover_pair (post_id); diff --git a/migrations/0108_report_leftover_observed_expected.sql b/migrations/0108_report_leftover_observed_expected.sql new file mode 100644 index 000000000..463aa1dae --- /dev/null +++ b/migrations/0108_report_leftover_observed_expected.sql @@ -0,0 +1,32 @@ +-- ADR 0168: persist observed Y and expected E[Y|θ, item] on leftover pairs +-- so leftover_residual is checkable as Y − E. Existing leftover rows are +-- derived; delete incomplete rows so rebuild/seed rewrites honest scores. +-- Never invent a leftover score or a second theta. + +alter table report_leftover_pair + add column if not exists leftover_observed_score numeric, + add column if not exists leftover_expected_score numeric; + +delete from report_leftover_pair +where leftover_observed_score is null + or leftover_expected_score is null; + +alter table report_leftover_pair + alter column leftover_observed_score set not null, + alter column leftover_expected_score set not null; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'leftover_pair_residual_identity' + ) then + alter table report_leftover_pair + add constraint leftover_pair_residual_identity + check ( + abs(leftover_residual - (leftover_observed_score - leftover_expected_score)) + < 1e-9 + ); + end if; +end $$; diff --git a/migrations/rollback/0108_report_leftover_observed_expected.sql b/migrations/rollback/0108_report_leftover_observed_expected.sql new file mode 100644 index 000000000..e7159bdc3 --- /dev/null +++ b/migrations/rollback/0108_report_leftover_observed_expected.sql @@ -0,0 +1,13 @@ +-- Reverse migration 0108. Leftover pairs remain; observed/expected +-- columns and the residual-identity check are removed. + +begin; + +alter table report_leftover_pair + drop constraint if exists leftover_pair_residual_identity; + +alter table report_leftover_pair + drop column if exists leftover_observed_score, + drop column if exists leftover_expected_score; + +commit; diff --git a/pyproject.toml b/pyproject.toml index cb4be2916..65ec1b930 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.12.6" +version = "2.12.20" 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 8ec15a065..a242cfec4 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -117,6 +117,7 @@ def seed( 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((migrations / "0108_report_leftover_observed_expected.sql").read_text()) cur.execute((migrations / "0060_role_responsibility_agent_type.sql").read_text()) cur.execute((migrations / "0013_person_job_title.sql").read_text()) cur.execute((migrations / "0014_role_responsibility_team_actor_type.sql").read_text()) @@ -1195,8 +1196,9 @@ def _persist_seed_period_report( 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)", + "pair_kind, post_id, criterion_code, leftover_distance, leftover_residual, " + "leftover_observed_score, leftover_expected_score" + ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", ( grouping_kind, grouping_key, @@ -1207,6 +1209,8 @@ def _persist_seed_period_report( pair.criterion_code, pair.leftover_distance, pair.leftover_residual, + pair.leftover_observed_score, + pair.leftover_expected_score, ), ) diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py index 72dea89b6..16ec1c27e 100644 --- a/tests/test_leftover_pairs.py +++ b/tests/test_leftover_pairs.py @@ -1,4 +1,4 @@ -"""Leftover post–criterion pairs after the main-effect IRT (ADR 0017). +"""Leftover post–criterion pairs after the main-effect IRT (ADR 0048 / 0168). Uses a constructed residual matrix so the closest and farthest pair are known without calling ``fit_polytomous``. Loads @@ -116,3 +116,32 @@ def test_leftover_is_empty_without_observed_cells() -> None: matrix = np.array([[np.nan]], dtype=np.float64) expected = np.array([[0.0]], dtype=np.float64) assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == () + + +def test_leftover_pair_names_observed_minus_expected() -> None: + """Leftover residual is Y − E, not a second invented leftover score.""" + post_ids = ["post-high", "post-low"] + item_codes = ("item_one", "item_two") + matrix = np.array( + [ + [4.0, 1.0], + [1.0, 4.0], + ], + dtype=np.float64, + ) + expected = np.array( + [ + [3.0, 2.0], + [2.0, 3.0], + ], + 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] + for pair in pairs: + assert pair.leftover_residual == pytest.approx( + pair.leftover_observed_score - pair.leftover_expected_score + ) + assert pair.leftover_observed_score in {1.0, 4.0} + assert pair.leftover_expected_score in {2.0, 3.0} + assert pair.leftover_observed_score != pair.leftover_residual diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 29fe1c176..ca7c318f2 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -33,3 +33,23 @@ def test_migrate_sh_replays_leftover_pair_migration_on_existing_volumes() -> Non ).read_text(encoding="utf-8") assert "0012_*" in script + + +def test_migrate_sh_replays_leftover_observed_expected_migration() -> None: + """Existing volumes must gain leftover Y and E columns on compose up.""" + script = ( + Path(__file__).resolve().parents[1] + / "docker" + / "postgres-init" + / "migrate.sh" + ).read_text(encoding="utf-8") + + assert "0108_*" in script + sql = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0108_report_leftover_observed_expected.sql" + ).read_text(encoding="utf-8") + assert "leftover_observed_score" in sql + assert "leftover_expected_score" in sql + assert "leftover_pair_residual_identity" in sql diff --git a/tests/test_period_report.py b/tests/test_period_report.py index 04c9c3921..618f257d0 100644 --- a/tests/test_period_report.py +++ b/tests/test_period_report.py @@ -266,6 +266,11 @@ def test_calibrated_report_attaches_leftover_pairs() -> None: assert pair.criterion_code in items assert pair.leftover_distance >= 0.0 assert np.isfinite(pair.leftover_residual) + assert np.isfinite(pair.leftover_observed_score) + assert np.isfinite(pair.leftover_expected_score) + assert pair.leftover_residual == pytest.approx( + pair.leftover_observed_score - pair.leftover_expected_score + ) diff --git a/uv.lock b/uv.lock index 10bcf9ff1..028442672 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.12.6" +version = "2.12.20" source = { editable = "." } dependencies = [ { name = "certifi" }, From e20250b0861b7ed40c91feeb82ede096146393c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 05:38:16 +0900 Subject: [PATCH 2/2] fix: stop duplicating leftover observed/expected schema into 0001, repair login build migrations/0001_initial_schema.sql is shipped, baked into the Postgres image for fresh installs only. This PR had duplicated the new leftover_observed_score/leftover_expected_score columns (plus their residual-identity check constraint) directly into 0001 as well as into the correctly idempotent 0108_report_leftover_observed_expected.sql migration. Reverts 0001 to its shipped form (byte-identical to origin/main) so 0108 is the single source on every install path, and wires 0108 into the two hand-enumerated migration fixtures (tests/test_schema.py::schema_db, backend/tests/test_api.py::seeded_db) so removing it from 0001 doesn't silently stop creating the columns. Also repairs this branch's build: the login button still built an unsanitized returnUrl inline instead of using returnUrlFromLocation()/rememberOidcReturnUrl(), and an unreachable login-screen AdminPanel render (accessToken is string | undefined pre-login) failed the TypeScript build. Same root cause and fix as every other branch that inherited this from before the canonical fix landed on main. --- backend/tests/test_api.py | 6 ++++++ frontend/src/App.test.tsx | 3 +++ frontend/src/App.tsx | 4 ++-- migrations/0001_initial_schema.sql | 11 +++-------- tests/test_schema.py | 6 ++++++ 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index d9038897d..3038eba5a 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -113,6 +113,11 @@ / "migrations" / "0102_project_bound_summary_event.sql" ) +_LEFTOVER_OBSERVED_EXPECTED_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0108_report_leftover_observed_expected.sql" +) def _postgres_available() -> bool: @@ -226,6 +231,7 @@ def seeded_db(demo_analyst_token): cur.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text()) + cur.execute(_LEFTOVER_OBSERVED_EXPECTED_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 53f83ae90..9447b546b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -41,6 +41,9 @@ describe("App, unauthenticated", () => { state: expect.objectContaining({ returnUrl: expect.stringMatching(/^\//) }), }), ); + // Persisted as a fallback in case the OIDC state round-trip is dropped + // (see oidcReturnUrl.ts's restoreOidcReturnUrl, consumed in main.tsx). + expect(window.sessionStorage.getItem("lineageweave.oidc.returnUrl")).toMatch(/^\//); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 069c4d186..c84486fcd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4619,7 +4619,8 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
    - {destination === "admin" ? : null}