diff --git a/CHANGELOG.d/2.12.21-leftover-map-rank.md b/CHANGELOG.d/2.12.21-leftover-map-rank.md new file mode 100644 index 000000000..439afd76a --- /dev/null +++ b/CHANGELOG.d/2.12.21-leftover-map-rank.md @@ -0,0 +1,8 @@ +## 2.12.21 — Leftover-map rank + +- Persist leftover-map rank on leftover post–criterion pairs (ADR 0172). + Rank is the number of Gabriel singular values above the floor. After + `make seed`, closest and farthest leftover pairs sit above the member + list with `rank {n}` next to leftover-map distance `d`; click opens + that post. Rank 0 names no leftover structure. Never invent a leftover + score or a theta. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dc8c0cf9..6dbd797ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -213,6 +213,15 @@ All notable changes to this project are documented here. Format follows shell. The production frontend build type-checks again. +## [2.12.21] - 2026-08-24 + +### Added + +- Period leftover pair rows now name leftover-map rank after IRT main + effects next to leftover-map distance `d`, then open that post + (Jeon et al., 2021, eq. 3; ADR 0172). Rank 0 names no leftover + structure rather than inventing a leftover score. + ## [2.12.20] - 2026-08-24 ### Fixed diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index 9fb75315e..a73c6736d 100644 --- a/backend/app/report_ingestion.py +++ b/backend/app/report_ingestion.py @@ -444,8 +444,8 @@ 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, - observed_response, expected_response - ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + observed_response, expected_response, leftover_map_rank + ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) """, grouping_kind, grouping_key, @@ -458,6 +458,7 @@ async def persist_period_report( pair.leftover_residual, pair.observed_response, pair.expected_response, + pair.leftover_map_rank, ) @@ -606,7 +607,7 @@ async def fetch_period_reports( f""" select lp.grouping_key, lp.pair_kind, lp.post_id, lp.criterion_code, lp.leftover_distance, lp.leftover_residual, - lp.observed_response, lp.expected_response, p.post_title, + lp.observed_response, lp.expected_response, lp.leftover_map_rank, p.post_title, p.visibility_code, p.corporate_entity_id, p.author_account_id, p.source_detail_state_code, ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context @@ -716,6 +717,11 @@ async def fetch_period_reports( if row["expected_response"] is None else float(row["expected_response"]) ), + "leftover_map_rank": ( + None + if row["leftover_map_rank"] is None + else int(row["leftover_map_rank"]) + ), "visibility_code": row["visibility_code"], "corporate_entity_id": str(row["corporate_entity_id"]), "author_account_id": str(row["author_account_id"]), diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3b4b6779c..7cfa2e074 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -198,6 +198,16 @@ / "migrations" / "0177_report_leftover_observed_expected.sql" ) +_LEFTOVER_MAP_RANK_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0172_report_leftover_map_rank.sql" +) +_ANALYSIS_RUN_STATUS_SAME_CLOCK_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0173_analysis_run_status_same_clock.sql" +) def _postgres_available() -> bool: @@ -327,6 +337,8 @@ def seeded_db(demo_analyst_token): cur.execute(_CATALOG_UNRESOLVED_REASON_MIGRATION.read_text()) cur.execute(_POST_ASK_HISTORY_MIGRATION.read_text()) cur.execute(_CUSTOMER_IDENTITY_MIGRATION.read_text()) + cur.execute(_LEFTOVER_MAP_RANK_MIGRATION.read_text()) + cur.execute(_ANALYSIS_RUN_STATUS_SAME_CLOCK_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 " diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index 27f39ef22..ac490b993 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_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*|0130_*|0133_*|0134_*|0136_*|0137_*|0138_*|0139_*|0173_*|0176_*|0177_*) ;; + 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*|0130_*|0133_*|0134_*|0136_*|0137_*|0138_*|0139_*|0172_*|0173_*|0176_*|0177_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0172-leftover-map-rank.md b/docs/adr/0172-leftover-map-rank.md new file mode 100644 index 000000000..f59a44e81 --- /dev/null +++ b/docs/adr/0172-leftover-map-rank.md @@ -0,0 +1,71 @@ +# ADR 0172 — Name leftover-map rank on leftover pairs + +**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). + +## Context + +ADR 0048 already persists leftover-map distance and leftover residual +`R = Y − E[Y|θ, item]` on `report_leftover_pair`. A rank-0 residual +still emits a stable closest/farthest pair so `make seed` is not empty; +the stored distance is then zero, not a fabricated interaction. ADR 0049 +renders those pairs above the member list. Without leftover-map rank, a +buyer cannot tell a Gabriel biplot with leftover structure (Jeon et al., +2021, eq. 3) from an origin collapse that still shows `d 0.00`. + +This increment does not persist leftover-map coordinates, does not name +observed `Y` / expected `E`, does not change leftover-map axis count, and +does not land Post quality on the leftover criterion. + +The unprotected-stack ADR for the same buyer fact was 0164. This +protected-main reconstruction uses **0172** so it does not collide with +two-axis leftover-map distance (0166), leftover coverage (0168), +leftover-map axis share (0148), leftover observed Y / expected E +(0177), or analysis-run status same clock (0171). + +## Decision + +Each leftover pair names `leftover_map_rank`: the number of Gabriel +singular values above the leftover singular floor on the complete-case +residual rectangle. Closest and farthest pairs on one period report share +that rank. A fallback pair that is not placed on a leftover map stores +rank `0`. Migration `0172` is the single source of the column on every +install path; shipped migrations (`0001` / `0012`) are never rewritten. +It adds a nullable column so older leftover rows keep distance and residual +without fabricating a rank. + +The pair button shows `rank {n}` when the value is a finite +non-negative integer. Rank `0` next action: leftover map has no leftover +structure after IRT main effects; open this post. Rank `≥ 1` next action: +read leftover map rank after IRT main effects, then open this post. Omit +the rank badge when the value is missing. Do not invent a leftover score. +Do not invent a theta. + +## Consequences + +`GET /api/reports/{grouping}/{period}` returns `leftover_map_rank`. After +`make seed`, closest and farthest leftover pairs sit above the member +list with leftover-map rank; click opens that post. Hidden posts stay +hidden. + +## Related + +Independent of leftover interaction-map persistence, leftover-criterion +evaluation landing, leftover residual UI extraction, leftover-map +complete-case coverage, leftover-map axis share, leftover pairs on the +grouping comparison strip, two-axis leftover-map distance, and leftover +observed `Y` / expected `E`. + +## 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 29022bdeb..cfd39b791 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.20", + "version": "2.12.21", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 288dcc667..a28848838 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1188,6 +1188,7 @@ describe("App, authenticated", () => { leftover_residual: 0.4, observed_response: 2.4, expected_response: 2.0, + leftover_map_rank: 1, }, { pair_kind: "farthest", @@ -1198,6 +1199,7 @@ describe("App, authenticated", () => { leftover_residual: -1.1, observed_response: 0.9, expected_response: 2.0, + leftover_map_rank: 1, }, ], members: [ @@ -5339,6 +5341,7 @@ describe("App, authenticated", () => { ); expect(closestPair).not.toHaveTextContent(/sat closest to after main effects/); expect(closestPair).toHaveTextContent("Y 2.40 · E 2.00"); + expect(closestPair).toHaveTextContent("rank 1"); expect(closestPair).toHaveTextContent("d 0.12"); expect(farthestPair).toHaveTextContent("Farthest leftover: Specification revision requested · negative"); expect(farthestPair).toHaveTextContent( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5d151b80c..31b62ead7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4200,6 +4200,7 @@ function ReportsPanel({ leftoverDistance={pair.leftover_distance} observedResponse={pair.observed_response} expectedResponse={pair.expected_response} + leftoverMapRank={pair.leftover_map_rank} onOpen={onSelectPost} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index dea6a17ad..53e840bc9 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -996,6 +996,7 @@ export interface LeftoverPair { leftover_residual: number; observed_response?: number | null; expected_response?: number | null; + leftover_map_rank?: number | null; } export interface PeriodGroupReport { diff --git a/frontend/src/components/LeftoverPairButton.tsx b/frontend/src/components/LeftoverPairButton.tsx index 02407113e..e444b7974 100644 --- a/frontend/src/components/LeftoverPairButton.tsx +++ b/frontend/src/components/LeftoverPairButton.tsx @@ -1,4 +1,5 @@ import { formatLeftoverObservedExpected } from "../leftoverObservedExpected"; +import { formatLeftoverMapRank } from "../leftoverMapRank"; import { leftoverPairAriaLabel, leftoverPairNextAction, @@ -13,15 +14,18 @@ export function LeftoverPairButton({ leftoverDistance, observedResponse, expectedResponse, + leftoverMapRank, onOpen, }: { pair: LeftoverPairOpen; leftoverDistance: number; observedResponse?: number | null; expectedResponse?: number | null; + leftoverMapRank?: number | null; onOpen: (postId: string, options: LeftoverPairOpenOptions) => void; }) { const observedExpected = formatLeftoverObservedExpected(observedResponse, expectedResponse); + const mapRank = formatLeftoverMapRank(leftoverMapRank); return ( ); diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 5ab0d630d..9834e766e 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -288,3 +288,17 @@ describe("locale-aware source labels", () => { expect(t("Public")).toBe(visibility); }); }); + +describe("leftover-map rank next action", () => { + it.each([ + ["ko", "IRT 주효과 이후 잔여 맵 랭크 1. 이 글을 여세요."], + ["zh", "IRT 主效应后的残余图秩 1。打开这篇帖子。"], + ["ja", "IRT主効果後の残差マップランク 1。この投稿を開いてください。"], + ["vi", "Hạng bản đồ phần dư 1 sau hiệu ứng chính IRT. Mở bài viết này."], + ] as const)("formats leftover-map rank next action in %s", (locale, expected) => { + setLocale(locale); + expect( + tf("Leftover map rank {rank} after IRT main effects. Open this post.", { rank: "1" }), + ).toBe(expected); + }); +}); diff --git a/frontend/src/leftoverMapRank.test.ts b/frontend/src/leftoverMapRank.test.ts new file mode 100644 index 000000000..774ad3524 --- /dev/null +++ b/frontend/src/leftoverMapRank.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { formatLeftoverMapRank } from "./leftoverMapRank"; + +describe("formatLeftoverMapRank", () => { + it("names leftover-map rank without inventing leftover structure", () => { + expect(formatLeftoverMapRank(0)).toBe("rank 0"); + expect(formatLeftoverMapRank(1)).toBe("rank 1"); + expect(formatLeftoverMapRank(2)).toBe("rank 2"); + }); + + it("omits the badge when rank is missing or not a non-negative integer", () => { + expect(formatLeftoverMapRank(null)).toBeNull(); + expect(formatLeftoverMapRank(undefined)).toBeNull(); + expect(formatLeftoverMapRank(-1)).toBeNull(); + expect(formatLeftoverMapRank(1.5)).toBeNull(); + expect(formatLeftoverMapRank(Number.NaN)).toBeNull(); + }); +}); diff --git a/frontend/src/leftoverMapRank.ts b/frontend/src/leftoverMapRank.ts new file mode 100644 index 000000000..75f2b489d --- /dev/null +++ b/frontend/src/leftoverMapRank.ts @@ -0,0 +1,13 @@ +/** Leftover-map rank after IRT main effects (Gabriel singular values). */ + +export const LEFTOVER_RANK_ZERO_ACTION = + "Leftover map has no leftover structure after IRT main effects. Open this post."; +export const LEFTOVER_RANK_STRUCTURE_ACTION = + "Leftover map rank {rank} after IRT main effects. Open this post."; + +export function formatLeftoverMapRank(rank: number | null | undefined): string | null { + if (rank == null || !Number.isInteger(rank) || rank < 0) { + return null; + } + return `rank ${rank}`; +} diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py index 4fa2f5ce6..4a4fcd7f4 100644 --- a/lineageweave/leftover_pairs.py +++ b/lineageweave/leftover_pairs.py @@ -32,6 +32,7 @@ class LeftoverPair: leftover_residual: float observed_response: float expected_response: float + leftover_map_rank: int def leftover_pairs_from_residual( @@ -74,7 +75,9 @@ def leftover_pairs_from_residual( 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) + person_pos, item_pos, leftover_map_rank = _complete_case_positions( + residual, center, keep_person, keep_item + ) 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) @@ -93,6 +96,7 @@ def leftover_pairs_from_residual( _candidate_row(post_ids, item_codes, matrix, expected, residual, person, item, distance) ) if not candidates: + leftover_map_rank = 0 for person, item in observed: distance = abs(float(residual[person, item]) - center) candidates.append( @@ -109,7 +113,10 @@ def leftover_pairs_from_residual( ) closest = min(candidates, key=lambda row: (row[0], row[1], row[2])) farthest = max(candidates, key=lambda row: (row[0], row[1], row[2])) - return (_pair_from_candidate(PAIR_KIND_CLOSEST, closest), _pair_from_candidate(PAIR_KIND_FARTHEST, farthest)) + return ( + _pair_from_candidate(PAIR_KIND_CLOSEST, closest, leftover_map_rank), + _pair_from_candidate(PAIR_KIND_FARTHEST, farthest, leftover_map_rank), + ) def _candidate_row( @@ -138,8 +145,14 @@ def _candidate_row( ) -def _pair_from_candidate(pair_kind: str, row: tuple[float, str, str, float, float, float]) -> LeftoverPair: +def _pair_from_candidate( + pair_kind: str, + row: tuple[float, str, str, float, float, float], + leftover_map_rank: int, +) -> LeftoverPair: """Build a leftover pair from a candidate row.""" + if leftover_map_rank < 0: + raise ValueError("leftover map rank must be a non-negative integer") return LeftoverPair( pair_kind=pair_kind, post_id=row[1], @@ -148,6 +161,7 @@ def _pair_from_candidate(pair_kind: str, row: tuple[float, str, str, float, floa leftover_residual=row[3], observed_response=row[4], expected_response=row[5], + leftover_map_rank=leftover_map_rank, ) @@ -167,32 +181,35 @@ def _complete_case_positions( center: float, keep_person: np.ndarray, keep_item: np.ndarray, -) -> tuple[np.ndarray | None, np.ndarray | None]: +) -> tuple[np.ndarray | None, np.ndarray | None, int]: """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 + return None, None, 0 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]: +def _leftover_map_positions(filled: np.ndarray) -> tuple[np.ndarray, np.ndarray, int]: """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), + 0, ) left, singular, right = np.linalg.svd(filled, full_matrices=False) keep = singular > _LEFTOVER_SINGULAR_FLOOR - if not np.any(keep): + leftover_map_rank = int(np.count_nonzero(keep)) + if leftover_map_rank == 0: return ( np.zeros((n_persons, 1), dtype=np.float64), np.zeros((n_items, 1), dtype=np.float64), + 0, ) scale = np.sqrt(singular[keep]) person_pos = left[:, keep] * scale item_pos = right[keep, :].T * scale - return person_pos, item_pos + return person_pos, item_pos, leftover_map_rank diff --git a/migrations/0172_report_leftover_map_rank.sql b/migrations/0172_report_leftover_map_rank.sql new file mode 100644 index 000000000..d09509e3d --- /dev/null +++ b/migrations/0172_report_leftover_map_rank.sql @@ -0,0 +1,25 @@ +-- ADR 0172: persist leftover-map rank on leftover post–criterion pairs. +-- Rank is the number of Gabriel singular values above the leftover +-- singular floor. Upgrade columns are nullable so older leftover rows +-- keep distance and residual without fabricating a rank. This sequential +-- migration is the single source of the column on every install path. +-- Shipped migrations (0001 / 0012) are never rewritten. + +alter table report_leftover_pair + add column if not exists leftover_map_rank integer; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'leftover_pair_map_rank_nonnegative_chk' + ) then + alter table report_leftover_pair + add constraint leftover_pair_map_rank_nonnegative_chk + check ( + leftover_map_rank is null + or leftover_map_rank >= 0 + ); + end if; +end $$; diff --git a/migrations/rollback/0172_report_leftover_map_rank.sql b/migrations/rollback/0172_report_leftover_map_rank.sql new file mode 100644 index 000000000..c70c36de3 --- /dev/null +++ b/migrations/rollback/0172_report_leftover_map_rank.sql @@ -0,0 +1,6 @@ +-- Reverse 0172. Leftover distance and residual stay on the pair row. + +alter table report_leftover_pair + drop constraint if exists leftover_pair_map_rank_nonnegative_chk; +alter table report_leftover_pair + drop column if exists leftover_map_rank; diff --git a/pyproject.toml b/pyproject.toml index 65ec1b930..57a6f2eb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.12.20" +version = "2.12.21" 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 1a8a46ce1..95eb43ee3 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -1213,8 +1213,8 @@ def _persist_seed_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, " - "observed_response, expected_response" - ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + "observed_response, expected_response, leftover_map_rank" + ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", ( grouping_kind, grouping_key, @@ -1227,6 +1227,7 @@ def _persist_seed_period_report( pair.leftover_residual, pair.observed_response, pair.expected_response, + pair.leftover_map_rank, ), ) diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py index f0ecd03e0..2bba5b8b3 100644 --- a/tests/test_leftover_pairs.py +++ b/tests/test_leftover_pairs.py @@ -80,6 +80,7 @@ def test_leftover_residual_biplot_separates_aligned_and_opposed_cells() -> None: assert farthest.leftover_distance == pytest.approx(2.0 * np.sqrt(2.0), rel=1e-6) for pair in pairs: _assert_residual_reconciles(pair) + assert pair.leftover_map_rank == 1 def test_zero_residual_still_emits_stable_leftover_pairs() -> None: @@ -95,6 +96,7 @@ def test_zero_residual_still_emits_stable_leftover_pairs() -> None: assert pairs[0].criterion_code == "item_one" assert pairs[0].observed_response == pytest.approx(1.0) assert pairs[0].expected_response == pytest.approx(1.0) + assert pairs[0].leftover_map_rank == 0 assert pairs[1].post_id == "beta-post" assert pairs[1].criterion_code == "item_two" for pair in pairs: diff --git a/tests/test_schema.py b/tests/test_schema.py index f45aedfc2..c7e1e7159 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -122,6 +122,11 @@ / "migrations" / "0177_report_leftover_observed_expected.sql" ) +_LEFTOVER_MAP_RANK_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0172_report_leftover_map_rank.sql" +) def _postgres_available() -> bool: @@ -184,6 +189,7 @@ def schema_db(): cur.execute(summary_input_migration) cur.execute(summary_input_migration) cur.execute(_POST_CONTENT_QUEUE_MIGRATION.read_text()) + cur.execute(_LEFTOVER_MAP_RANK_MIGRATION.read_text()) cur.execute(_LEFTOVER_OBSERVED_EXPECTED_MIGRATION.read_text()) conn.commit() yield conn @@ -494,6 +500,16 @@ def test_leftover_pair_names_nullable_observed_and_expected_columns(schema_db) - +def test_leftover_pair_names_leftover_map_rank_column(schema_db) -> None: + """Fresh leftover rows name map rank without backfilling legacy evidence.""" + with schema_db.cursor() as cur: + cur.execute( + "select column_name, is_nullable from information_schema.columns " + "where table_name = 'report_leftover_pair'" + ) + columns = dict(cur.fetchall()) + assert columns["leftover_map_rank"] == "YES" + def test_corporate_hierarchy_recursive_query_returns_correct_shape(schema_db) -> None: """The real product requirement: 'Acme Group -> Acme Electronics Korea -> Acme Electronics Gwangju Plant' must be walkable with one query, diff --git a/uv.lock b/uv.lock index 028442672..44b3c07b6 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.12.20" +version = "2.12.21" source = { editable = "." } dependencies = [ { name = "certifi" },