diff --git a/AGENTS.md b/AGENTS.md
index 552ade17d..6cd2d9c73 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -186,14 +186,15 @@ 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 0048 / 0049 / 0119 / 0163) are computed in
+Period leftover pairs (ADR 0048 / 0049 / 0119 / 0163 / 0164) are computed in
`lineageweave/leftover_pairs.py` from the residual after a real
GRM/GPCM score, never invented. Distances are Euclidean on the
two-dimensional Gabriel leftover map; missing cells stay out of
the factorization. Closest and farthest post–criterion pairs
persist to `report_leftover_pair` with observed `Y` and expected
-`E[Y|θ, item]` so residual reconciles to `Y − E`, and sit above the
-member list so a click opens that post.
+`E[Y|θ, item]` so residual reconciles to `Y − E`, plus leftover-map
+rank so rank 0 is not read as structure, and sit above the member list
+so a click opens that post.
`frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`,
pnpm via Corepack -- do not add a second Node package manager or a
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 9da3bde58..82f8d9081 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -591,8 +591,8 @@ on those same fixed parameters (Kim, 2006 FIPC). After scoring,
information at the group's mean θ (Lord, 1980 max-info CAT). Rankings
persist to `report_item_information`. After those IRT main effects,
residual SVD leftover pairs on two Gabriel axes (Jeon et al., 2021;
-ADR 0048 / 0119 / 0163) persist to `report_leftover_pair` with observed
-`Y` and expected `E[Y|θ, item]`. Results persist to
+ADR 0048 / 0119 / 0163 / 0164) persist to `report_leftover_pair` with observed
+`Y`, expected `E[Y|θ, item]`, and full leftover-map rank. Results persist to
`report_period_score` / `report_member_score`.
`GET /api/reports/{grouping}` lists the trend;
`GET /api/reports/{grouping}/{period}` is ABAC-filtered;
@@ -604,8 +604,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 (observed `Y` and expected `E` after IRT main
-effects plus leftover-map distance `d`) above the member list, and the
+closest/farthest pairs (observed `Y`, expected `E`, full rank, and
+two-axis leftover-map distance `d` after IRT main effects) 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.15-leftover-map-rank.md b/CHANGELOG.d/2.12.15-leftover-map-rank.md
new file mode 100644
index 000000000..9f2e670c2
--- /dev/null
+++ b/CHANGELOG.d/2.12.15-leftover-map-rank.md
@@ -0,0 +1,8 @@
+## 2.12.15 — Leftover-map rank
+
+- Persist leftover-map rank on leftover post–criterion pairs (ADR 0164).
+ 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 e4f8c7310..b6a41a02b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,17 @@ 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.15] - 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 0164). Rank 0 names no leftover
+ structure rather than inventing a leftover score. When observed `Y`
+ and expected `E` are also present, one localized next action names all
+ three measurements instead of hiding either amendment.
+
## [2.12.14] - 2026-08-24
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index 1bcf50763..42b091e42 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -54,7 +54,9 @@ chip name contains `Corporate entity: Demo Corp` and the persisted
mean θ. The period-report panel says Demo Corp is the opened grouping
and to read its mean θ and member posts, then open a post. Those
members land immediately under that next action, ahead of Other Corp
-and the week strip. Opening Public post names the next action: read
+and the week strip. After `make seed`, leftover closest/farthest pairs
+sit above the member list with leftover-map rank; rank 0 names no
+leftover structure. Opening Public post names the next action: read
Event Lineage, Keyman, and evaluation on that post. The popup Event
Lineage DAG marks that post current. After that current node, the
popup names Keyman and evaluation as the next read. After landed
diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py
index dac5695a4..b714f3667 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,
)
@@ -605,7 +606,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,
({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context
from report_leftover_pair lp
@@ -712,6 +713,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"]),
"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 c608d7b4a..15b5331cb 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -119,6 +119,11 @@
/ "migrations"
/ "0163_report_leftover_observed_expected.sql"
)
+_LEFTOVER_MAP_RANK_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0164_report_leftover_map_rank.sql"
+)
def _postgres_available() -> bool:
@@ -233,6 +238,7 @@ def seeded_db(demo_analyst_token):
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(_LEFTOVER_MAP_RANK_MIGRATION.read_text())
cur.execute(
"insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values "
"('corporate_entity_level', 'group', 'Group'), "
@@ -4672,6 +4678,7 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token,
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", []))
for pair in high_report.get("leftover_pairs", []):
+ assert pair["leftover_map_rank"] >= 0
observed = pair.get("observed_response")
expected = pair.get("expected_response")
if observed is None or expected is None:
diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh
index 07cc98417..31ae66347 100644
--- a/docker/postgres-init/migrate.sh
+++ b/docker/postgres-init/migrate.sh
@@ -19,7 +19,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do
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_*) ;;
- 0163_*) ;;
+ 0163_*|0164_*) ;;
*) continue ;;
esac
printf 'Applying %s\n' "$migration_name"
diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md
index b8593ad48..bc79cb17e 100644
--- a/docs/adr/0048-persist-lsirm-leftover-pairs.md
+++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md
@@ -3,7 +3,8 @@
**Decision status:** Accepted
**Date:** 2026-08-17
**Amended by:** [ADR 0119](0119-leftover-map-two-dimensional-distance.md) (two leftover-map axes);
-[ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E)
+[ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E);
+[ADR 0164](0164-leftover-map-rank.md) (full map rank)
## Context
@@ -35,7 +36,8 @@ The biplot lives in `lineageweave/leftover_pairs.py` so leftover
tests do not import `period_report` or `fast_mlsirm`. Distances are
Euclidean on the two leftover-map axes (ADR 0119). Each leftover row
also names observed `Y` and expected `E[Y|θ, item]` so residual
-reconciles to `Y − E` (ADR 0163).
+reconciles to `Y − E` (ADR 0163), and names the full singular-value
+rank while distance remains on the first two axes (ADR 0164).
Cascade the rows with `report_period_score`. A leftover post must
also be a `report_member_score` row, and the leftover criterion
diff --git a/docs/adr/0049-leftover-pair-report-ui.md b/docs/adr/0049-leftover-pair-report-ui.md
index f0e5b090e..04023f207 100644
--- a/docs/adr/0049-leftover-pair-report-ui.md
+++ b/docs/adr/0049-leftover-pair-report-ui.md
@@ -2,7 +2,8 @@
**Decision status:** Accepted
**Date:** 2026-08-17
-**Amended by:** [ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E)
+**Amended by:** [ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E);
+[ADR 0164](0164-leftover-map-rank.md) (full map rank)
## Context
@@ -18,11 +19,14 @@ second navigation surface.
On each period-report group, render leftover pairs **above** the
member list. Each pair is a button: closest or farthest label, post
-title, criterion short label, leftover-map distance, observed `Y` and
-expected `E` when both are finite, and the next action (“Read observed
-Y and expected E after IRT main effects, then open this post.”).
+title, criterion short label, two-axis leftover-map distance, full map
+rank, observed `Y`, and expected `E` when finite. The next action names
+every available measurement before opening the post; when rank and
+`Y` / `E` coexist, neither amendment hides the other. Rank 0 explicitly
+names no leftover structure.
Clicking the button opens that post with the same handler as a member
-row. Observed/expected naming is [ADR 0163](0163-leftover-observed-expected.md).
+row. Observed/expected naming is [ADR 0163](0163-leftover-observed-expected.md);
+rank naming is [ADR 0164](0164-leftover-map-rank.md).
After `make seed`, closest and farthest leftover pairs sit above the
member list. Click a pair to open that post.
diff --git a/docs/adr/0164-leftover-map-rank.md b/docs/adr/0164-leftover-map-rank.md
new file mode 100644
index 000000000..139ed215a
--- /dev/null
+++ b/docs/adr/0164-leftover-map-rank.md
@@ -0,0 +1,63 @@
+# ADR 0164 — 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.
+
+## 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 `0164` 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, 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 e4795bad5..cc9f4ce0e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "2.12.14",
+ "version": "2.12.15",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 68adee5ff..0b8a5ab51 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -942,6 +942,7 @@ describe("App, authenticated", () => {
leftover_residual: 0.4,
observed_response: 2.4,
expected_response: 2.0,
+ leftover_map_rank: 1,
},
{
pair_kind: "farthest",
@@ -952,6 +953,7 @@ describe("App, authenticated", () => {
leftover_residual: -1.1,
observed_response: 0.9,
expected_response: 2.0,
+ leftover_map_rank: 1,
},
],
members: [
@@ -3369,15 +3371,17 @@ describe("App, authenticated", () => {
});
expect(closestPair).toHaveTextContent("Closest leftover: Public post · sales-lead");
expect(closestPair).toHaveTextContent(
- "Read observed Y 2.40 and expected E 2.00 after IRT main effects, then open this post.",
+ "Read leftover map rank 1, observed Y 2.40, and expected E 2.00 after IRT main effects, then open this post.",
);
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(
- "Read observed Y 0.90 and expected E 2.00 after IRT main effects, then open this post.",
+ "Read leftover map rank 1, observed Y 0.90, and expected E 2.00 after IRT main effects, then open this post.",
);
expect(farthestPair).toHaveTextContent("Y 0.90 · E 2.00");
+ expect(farthestPair).toHaveTextContent("rank 1");
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 a6b7c10e1..fd8d97c35 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -103,6 +103,7 @@ import {
} from "./i18n";
import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl";
import { formatLeftoverObservedExpected } from "./leftoverObservedExpected";
+import { formatLeftoverMapRank, LEFTOVER_RANK_STRUCTURE_ACTION, LEFTOVER_RANK_ZERO_ACTION } from "./leftoverMapRank";
import "./App.css";
function orchestratorUnavailableMessage(err: unknown, action: string): string {
@@ -3400,18 +3401,47 @@ function ReportsPanel({
pair.observed_response,
pair.expected_response,
);
- const nextAction =
- observedExpected === null
- ? pair.pair_kind === "farthest"
+ const rankBadge = formatLeftoverMapRank(pair.leftover_map_rank);
+ let nextAction: string;
+ if (rankBadge !== null && observedExpected !== null) {
+ nextAction =
+ pair.leftover_map_rank === 0
+ ? tf(
+ "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.",
+ {
+ observed: Number(pair.observed_response).toFixed(2),
+ expected: Number(pair.expected_response).toFixed(2),
+ },
+ )
+ : tf(
+ "Read leftover map rank {rank}, observed Y {observed}, and expected E {expected} after IRT main effects, then open this post.",
+ {
+ rank: String(pair.leftover_map_rank),
+ observed: Number(pair.observed_response).toFixed(2),
+ expected: Number(pair.expected_response).toFixed(2),
+ },
+ );
+ } else if (rankBadge !== null) {
+ nextAction =
+ pair.leftover_map_rank === 0
+ ? t(LEFTOVER_RANK_ZERO_ACTION)
+ : tf(LEFTOVER_RANK_STRUCTURE_ACTION, {
+ rank: String(pair.leftover_map_rank),
+ });
+ } else if (observedExpected !== null) {
+ nextAction = tf(
+ "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.",
+ {
+ observed: Number(pair.observed_response).toFixed(2),
+ expected: Number(pair.expected_response).toFixed(2),
+ },
+ );
+ } else {
+ nextAction =
+ pair.pair_kind === "farthest"
? t("Open this post to read the criterion it sat farthest from after main effects.")
- : t("Open this post to read the criterion it sat closest to after main effects.")
- : tf(
- "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.",
- {
- observed: Number(pair.observed_response).toFixed(2),
- expected: Number(pair.expected_response).toFixed(2),
- },
- );
+ : t("Open this post to read the criterion it sat closest to after main effects.");
+ }
return (
{observedExpected}
) : null}
+ {rankBadge ? {rankBadge} : null}
d {pair.leftover_distance.toFixed(2)}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 6ecd93aee..485f3ac04 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -761,6 +761,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/i18n.test.ts b/frontend/src/i18n.test.ts
index 4bb2bfe7f..2fa306247 100644
--- a/frontend/src/i18n.test.ts
+++ b/frontend/src/i18n.test.ts
@@ -41,6 +41,10 @@ describe("i18n", () => {
"Open this post to read the criterion it sat closest to after main effects.",
"Open this post to read the criterion it sat farthest from after main effects.",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.",
+ "Leftover map has no leftover structure after IRT main effects. Open this post.",
+ "Leftover map rank {rank} after IRT main effects. Open this post.",
+ "Read leftover map rank {rank}, observed Y {observed}, and expected E {expected} after IRT main effects, then open this post.",
+ "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.",
"Showing the first {shown} of {total} posts known at this cutoff.",
] as const;
@@ -95,6 +99,33 @@ describe("i18n", () => {
),
).toBe(expected);
});
+
+ 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);
+ });
+
+ it.each([
+ ["ko", "IRT 주효과 이후 잔여 맵 랭크 1, 관측 Y 2.40, 기대 E 2.00를 읽은 다음, 이 글을 여세요."],
+ ["zh", "阅读 IRT 主效应后的残余图秩 1、观测 Y 2.40 与期望 E 2.00,然后打开这篇帖子。"],
+ ["ja", "IRT主効果後の残差マップランク 1、観測 Y 2.40、期待 E 2.00 を読んでから、この投稿を開いてください。"],
+ ["vi", "Đọc hạng bản đồ phần dư 1, Y quan sát 2.40 và E kỳ vọng 2.00 sau hiệu ứng chính IRT, rồi mở bài viết này."],
+ ] as const)("formats combined leftover evidence next action in %s", (locale, expected) => {
+ setLocale(locale);
+ expect(
+ tf(
+ "Read leftover map rank {rank}, observed Y {observed}, and expected E {expected} after IRT main effects, then open this post.",
+ { rank: "1", observed: "2.40", expected: "2.00" },
+ ),
+ ).toBe(expected);
+ });
});
describe("locale-aware source labels", () => {
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 726f44c2b..d3405a81e 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -365,6 +365,14 @@ const TRANSLATIONS: Partial>> = {
"주효과 이후 가장 멀리 앉은 기준을 읽으려면 이 글을 여세요.",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.":
"IRT 주효과 이후 관측 Y {observed}와 기대 E {expected}를 읽은 다음, 이 글을 여세요.",
+ "Leftover map has no leftover structure after IRT main effects. Open this post.":
+ "IRT 주효과 이후 잔여 맵에 잔여 구조가 없습니다. 이 글을 여세요.",
+ "Leftover map rank {rank} after IRT main effects. Open this post.":
+ "IRT 주효과 이후 잔여 맵 랭크 {rank}. 이 글을 여세요.",
+ "Read leftover map rank {rank}, observed Y {observed}, and expected E {expected} after IRT main effects, then open this post.":
+ "IRT 주효과 이후 잔여 맵 랭크 {rank}, 관측 Y {observed}, 기대 E {expected}를 읽은 다음, 이 글을 여세요.",
+ "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.":
+ "IRT 주효과 이후 잔여 맵 랭크 0은 잔여 구조가 없음을 뜻합니다. 관측 Y {observed}와 기대 E {expected}를 읽은 다음, 이 글을 여세요.",
},
zh: {
"Unknown": "未知",
@@ -712,6 +720,14 @@ const TRANSLATIONS: Partial>> = {
"打开这篇帖子,阅读主效应后距离最远的准则。",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.":
"阅读 IRT 主效应后的观测 Y {observed} 与期望 E {expected},然后打开这篇帖子。",
+ "Leftover map has no leftover structure after IRT main effects. Open this post.":
+ "IRT 主效应后残余图没有残余结构。打开这篇帖子。",
+ "Leftover map rank {rank} after IRT main effects. Open this post.":
+ "IRT 主效应后的残余图秩 {rank}。打开这篇帖子。",
+ "Read leftover map rank {rank}, observed Y {observed}, and expected E {expected} after IRT main effects, then open this post.":
+ "阅读 IRT 主效应后的残余图秩 {rank}、观测 Y {observed} 与期望 E {expected},然后打开这篇帖子。",
+ "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.":
+ "残余图秩 0 表示 IRT 主效应后没有残余结构。阅读观测 Y {observed} 与期望 E {expected},然后打开这篇帖子。",
},
ja: {
"Unknown": "不明",
@@ -1059,6 +1075,14 @@ const TRANSLATIONS: Partial>> = {
"主効果後に最も遠くなった基準を読むには、この投稿を開いてください。",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.":
"IRT主効果後の観測 Y {observed} と期待 E {expected} を読んでから、この投稿を開いてください。",
+ "Leftover map has no leftover structure after IRT main effects. Open this post.":
+ "IRT主効果後の残差マップに残差構造はありません。この投稿を開いてください。",
+ "Leftover map rank {rank} after IRT main effects. Open this post.":
+ "IRT主効果後の残差マップランク {rank}。この投稿を開いてください。",
+ "Read leftover map rank {rank}, observed Y {observed}, and expected E {expected} after IRT main effects, then open this post.":
+ "IRT主効果後の残差マップランク {rank}、観測 Y {observed}、期待 E {expected} を読んでから、この投稿を開いてください。",
+ "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.":
+ "残差マップランク 0 は IRT 主効果後に残差構造がないことを示します。観測 Y {observed} と期待 E {expected} を読んでから、この投稿を開いてください。",
},
vi: {
"Unknown": "Không rõ",
@@ -1406,6 +1430,14 @@ const TRANSLATIONS: Partial>> = {
"Mở bài viết này để đọc tiêu chí nằm xa nhất sau hiệu ứng chính.",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.":
"Đọc Y quan sát {observed} và E kỳ vọng {expected} sau hiệu ứng chính IRT, rồi mở bài viết này.",
+ "Leftover map has no leftover structure after IRT main effects. Open this post.":
+ "Bản đồ phần dư không có cấu trúc phần dư sau hiệu ứng chính IRT. Mở bài viết này.",
+ "Leftover map rank {rank} after IRT main effects. Open this post.":
+ "Hạng bản đồ phần dư {rank} sau hiệu ứng chính IRT. Mở bài viết này.",
+ "Read leftover map rank {rank}, observed Y {observed}, and expected E {expected} after IRT main effects, then open this post.":
+ "Đọc hạng bản đồ phần dư {rank}, Y quan sát {observed} và E kỳ vọng {expected} sau hiệu ứng chính IRT, rồi mở bài viết này.",
+ "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.":
+ "Hạng bản đồ phần dư 0 nghĩa là không có cấu trúc phần dư sau hiệu ứng chính IRT. Đọc Y quan sát {observed} và E kỳ vọng {expected}, rồi mở bài viết này.",
},
};
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 31545ea65..297d47012 100644
--- a/lineageweave/leftover_pairs.py
+++ b/lineageweave/leftover_pairs.py
@@ -1,6 +1,6 @@
"""Jeon leftover post–criterion pairs after a main-effect IRT.
-Implements ADR 0048 as amended by ADR 0119 and ADR 0163.
+Implements ADR 0048 as amended by ADR 0119, ADR 0163, and ADR 0164.
Does not import ``fast_mlsirm`` or ``period_report``. A Gabriel biplot
of the residual ``R = Y − E[Y|θ, item]`` supplies person and item
@@ -10,7 +10,8 @@
Pair distances are Euclidean
on the two leftover-map axes (Jeon et al., 2021); unused axes pad with
zero rather than inventing a second component, and hidden SVD axes
-after the second are dropped.
+after the second are dropped. Each pair also names the full leftover-map
+rank so a rank-0 collapse is not read as leftover structure.
"""
from __future__ import annotations
@@ -37,6 +38,7 @@ class LeftoverPair:
leftover_residual: float
observed_response: float
expected_response: float
+ leftover_map_rank: int
def leftover_pairs_from_residual(
@@ -53,7 +55,8 @@ def leftover_pairs_from_residual(
use the two leftover-map axes; a rank-0 residual still emits a
stable closest/farthest pair so seed is not empty and does not
invent a leftover score. Stored residual equals observed ``Y`` minus
- expected ``E[Y|θ, item]``.
+ expected ``E[Y|θ, item]``. Stored leftover-map rank is the number
+ of Gabriel singular values above the floor.
"""
if matrix.shape != (len(post_ids), len(item_codes)):
raise ValueError(
@@ -80,7 +83,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)
@@ -101,6 +106,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(
@@ -117,7 +123,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(
@@ -146,8 +155,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],
@@ -156,6 +171,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,
)
@@ -175,17 +191,17 @@ 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 coordinates ordered by descending singular value.
NumPy's SVD contract returns singular values largest-first, so filtering
@@ -197,13 +213,15 @@ def _leftover_map_positions(filled: np.ndarray) -> tuple[np.ndarray, np.ndarray]
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
+ leftover_map_rank = int(np.count_nonzero(keep))
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
def _pad_map_axes(positions: np.ndarray) -> np.ndarray:
diff --git a/migrations/0164_report_leftover_map_rank.sql b/migrations/0164_report_leftover_map_rank.sql
new file mode 100644
index 000000000..47a12843d
--- /dev/null
+++ b/migrations/0164_report_leftover_map_rank.sql
@@ -0,0 +1,24 @@
+-- ADR 0164: 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.
+
+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/0164_report_leftover_map_rank.sql b/migrations/rollback/0164_report_leftover_map_rank.sql
new file mode 100644
index 000000000..ff11ab005
--- /dev/null
+++ b/migrations/rollback/0164_report_leftover_map_rank.sql
@@ -0,0 +1,6 @@
+-- Reverse 0164. 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 75322f606..c97a8c871 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "lineageweave"
-version = "2.12.14"
+version = "2.12.15"
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 a5a0204c8..04a1f9c00 100644
--- a/scripts/seed_demo_data.py
+++ b/scripts/seed_demo_data.py
@@ -118,6 +118,7 @@ def seed(
cur.execute((migrations / "0011_post_chat_result.sql").read_text())
cur.execute((migrations / "0012_report_leftover_pair.sql").read_text())
cur.execute((migrations / "0163_report_leftover_observed_expected.sql").read_text())
+ cur.execute((migrations / "0164_report_leftover_map_rank.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())
@@ -1197,8 +1198,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,
@@ -1211,6 +1212,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 18c6fbebd..db7a226f9 100644
--- a/tests/test_leftover_pairs.py
+++ b/tests/test_leftover_pairs.py
@@ -1,6 +1,6 @@
"""Leftover post–criterion pairs after the main-effect IRT.
-Covers ADR 0048 as amended by ADR 0119 and ADR 0163.
+Covers ADR 0048 as amended by ADR 0119, ADR 0163, and ADR 0164.
Uses a constructed residual matrix so the closest and farthest pair
are known without calling ``fit_polytomous``. Loads
@@ -99,6 +99,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:
@@ -119,6 +120,7 @@ def test_zero_residual_still_emits_stable_leftover_pairs() -> None:
assert pairs[1].criterion_code == "item_two"
for pair in pairs:
_assert_residual_reconciles(pair)
+ assert pair.leftover_map_rank == 0
def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None:
@@ -147,6 +149,7 @@ def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None:
}
for pair in pairs:
_assert_residual_reconciles(pair)
+ assert pair.leftover_map_rank == 1
def test_leftover_is_empty_without_observed_cells() -> None:
@@ -206,7 +209,7 @@ def test_leftover_residual_rejects_database_tolerance_boundary() -> None:
)
-def test_rank_three_pair_distances_match_two_dimensional_gabriel_coords() -> None:
+def test_rank_four_pair_distances_match_two_dimensional_gabriel_coords() -> None:
"""Jeon leftover_distance is Euclidean on the 2D map, not the full SVD rank."""
post_ids = ["post-a", "post-b", "post-c", "post-d"]
item_codes = ("item-a", "item-b", "item-c", "item-d")
@@ -222,7 +225,7 @@ def test_rank_three_pair_distances_match_two_dimensional_gabriel_coords() -> Non
expected = np.zeros_like(matrix)
filled = matrix - float(np.mean(matrix))
person_full, item_full = _gabriel_positions(filled)
- assert person_full.shape[1] >= 3
+ assert person_full.shape[1] == 4
person_map = _pad_map_axes(person_full)
item_map = _pad_map_axes(item_full)
full_distances = np.linalg.norm(person_full[:, None, :] - item_full[None, :, :], axis=2)
@@ -234,6 +237,7 @@ def test_rank_three_pair_distances_match_two_dimensional_gabriel_coords() -> Non
post_index = {post_id: index for index, post_id in enumerate(post_ids)}
item_index = {code: index for index, code in enumerate(item_codes)}
for pair in pairs:
+ assert pair.leftover_map_rank == 4
person = post_index[pair.post_id]
item = item_index[pair.criterion_code]
assert pair.leftover_distance == pytest.approx(float(map_distances[person, item]))
@@ -278,6 +282,7 @@ def test_sparse_residual_uses_only_observed_cells_for_fallback_distance() -> Non
("post-b", "item-b"),
]
assert [pair.leftover_distance for pair in pairs] == pytest.approx([1.0, 1.0])
+ assert [pair.leftover_map_rank for pair in pairs] == [0, 0]
def test_nonfinite_map_distance_falls_back_to_centered_residual(
@@ -290,6 +295,7 @@ def test_nonfinite_map_distance_falls_back_to_centered_residual(
lambda *_args: (
np.array([[np.inf]], dtype=np.float64),
np.array([[-np.inf]], dtype=np.float64),
+ 1,
),
)
pairs = leftover_pairs_from_residual(
@@ -307,7 +313,7 @@ def test_empty_observation_mask_has_no_complete_case_axes() -> None:
keep_person, keep_item = leftover._complete_case_masks(observed)
assert not keep_person.any()
assert not keep_item.any()
- person_pos, item_pos = leftover._complete_case_positions(
+ person_pos, item_pos, rank = leftover._complete_case_positions(
np.zeros((1, 1), dtype=np.float64),
0.0,
keep_person,
@@ -315,3 +321,14 @@ def test_empty_observation_mask_has_no_complete_case_axes() -> None:
)
assert person_pos is None
assert item_pos is None
+ assert rank == 0
+
+
+def test_leftover_map_rank_rejects_negative_rank() -> None:
+ """Python must reject a leftover-map rank excluded by the DB check."""
+ with pytest.raises(ValueError, match="non-negative integer"):
+ leftover._pair_from_candidate(
+ PAIR_KIND_CLOSEST,
+ (0.0, "public-post", "sales_lead_specificity", 0.0, 1.0, 1.0),
+ -1,
+ )
diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py
index c780ed90f..da8c7d3d3 100644
--- a/tests/test_migration_replay.py
+++ b/tests/test_migration_replay.py
@@ -34,3 +34,4 @@ def test_migrate_sh_replays_leftover_pair_migration_on_existing_volumes() -> Non
assert "0012_*" in script
assert "0163_*" in script
+ assert "0164_*" in script
diff --git a/tests/test_period_report.py b/tests/test_period_report.py
index 50d48e843..318ad23ae 100644
--- a/tests/test_period_report.py
+++ b/tests/test_period_report.py
@@ -269,6 +269,7 @@ def test_calibrated_report_attaches_leftover_pairs() -> None:
assert pair.leftover_residual == pytest.approx(
pair.observed_response - pair.expected_response, abs=1e-6
)
+ assert pair.leftover_map_rank >= 0
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 880b179bb..81cc3fcfd 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -48,6 +48,11 @@
/ "migrations"
/ "0163_report_leftover_observed_expected.sql"
)
+_LEFTOVER_MAP_RANK_MIGRATION = (
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0164_report_leftover_map_rank.sql"
+)
def _postgres_available() -> bool:
@@ -85,6 +90,7 @@ def schema_db():
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(_LEFTOVER_MAP_RANK_MIGRATION.read_text())
conn.commit()
yield conn
finally:
@@ -203,6 +209,17 @@ def test_leftover_pair_names_nullable_observed_and_expected_columns(schema_db) -
assert reconcile_constraint_count == 1
+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:
diff --git a/uv.lock b/uv.lock
index 6ea3da5fb..822441ca0 100644
--- a/uv.lock
+++ b/uv.lock
@@ -454,7 +454,7 @@ wheels = [
[[package]]
name = "lineageweave"
-version = "2.12.14"
+version = "2.12.15"
source = { editable = "." }
dependencies = [
{ name = "certifi" },