diff --git a/AGENTS.md b/AGENTS.md
index 735988f0..e474b9de 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -73,6 +73,13 @@ 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
+`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
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..0c6b1e1f
--- /dev/null
+++ b/CHANGELOG.d/0.71.2-leftover-pairs.md
@@ -0,0 +1,9 @@
+# 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).
+- 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 0096828a..e7ffaa1f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,16 @@ 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`, 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
### Added
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 07c0bf56..eff621d0 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,22 @@ 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,
+ 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
+ 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 +522,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 +582,19 @@ 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"]),
+ "visibility_code": row["visibility_code"],
+ "corporate_entity_id": str(row["corporate_entity_id"]),
+ }
+ for row in leftover_by_group.get(header["grouping_key"], [])
+ ],
}
)
return payload
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/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..3ceb7157
--- /dev/null
+++ b/docs/adr/0017-persist-lsirm-leftover-pairs.md
@@ -0,0 +1,63 @@
+# 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 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`).
+
+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.
+
+## 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.
+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
+
+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..2fcbd896
--- /dev/null
+++ b/docs/adr/0018-leftover-pair-report-ui.md
@@ -0,0 +1,42 @@
+# 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, 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
+
+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..bc84dd3f 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,23 @@ 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 · 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 · 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();
expect(
screen.getByRole("button", { name: /open report post: specification revision requested/i }),
).toHaveTextContent("Send Westfield Power the revised specification");
@@ -1308,6 +1343,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..ff11eb16 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1527,6 +1527,37 @@ function ReportsPanel({
{report.selected_items[0].information.toFixed(2)}
)}
+ {report.leftover_pairs && report.leftover_pairs.length > 0 && (
+
+ {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 && (
{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/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 1a9a9bb3..ce817b89 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``.
@@ -35,6 +43,9 @@
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"
@@ -99,6 +110,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 +235,28 @@ 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_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 _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 +317,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 +365,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..94aecf01 100644
--- a/migrations/0001_initial_schema.sql
+++ b/migrations/0001_initial_schema.sql
@@ -318,6 +318,37 @@ 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. 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,
+ 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,
+ 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)
+);
+
+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..d65612ea
--- /dev/null
+++ b/migrations/0012_report_leftover_pair.sql
@@ -0,0 +1,54 @@
+-- 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. 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,
+ 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);
+
+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/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_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_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..bd3258bd 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",
@@ -105,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,
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" },