Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,9 @@ list so a click opens that post. Two-axis reconstruction `R̂` is not
persisted. Leftover-map axis share (ADR 0148) is Gabriel inertia of
residual SVD axes 1 and 2 and persists to `report_leftover_map_axis`.
Rank-0 residuals emit two zero-share axes; the shares are report-level
and are not a leftover score.
and are not a leftover score. Complete-case coverage (ADR 0168) persists to
`report_leftover_map_coverage` and captions the pair list with how
many scored posts entered the map.

`frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`,
pnpm via Corepack -- do not add a second Node package manager or a
Expand Down
7 changes: 5 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,9 @@ ADR 0017 / 0048 / 0119 / 0162 / 0163 / 0164 / 0182) persist to
`E[Y|θ, item]`, full leftover-map rank, and unexplained leftover
`U = R − R̂` named on the pair row. Leftover-map axis share (Gabriel
inertia of residual SVD axes 1 and 2; ADR 0148) persists to
`report_leftover_map_axis`. Results persist to
`report_leftover_map_axis`. Complete-case leftover-map coverage (ADR
0168) persists to `report_leftover_map_coverage` so readers see how
many scored posts entered the factorization. Results persist to
`report_period_score` / `report_member_score`.
`GET /api/reports/{grouping}` lists the trend;
`GET /api/reports/{grouping}/{period}` is ABAC-filtered;
Expand All @@ -616,7 +618,8 @@ the actual mean θ, the FIPC delta, the CAT-selected item, leftover
closest/farthest pairs (signed residual `R`, observed `Y`, expected
`E`, full rank, and two-axis leftover-map distance `d` after IRT main
effects) above the member list, leftover-map axis share for residual
SVD axes 1 and 2, and the
SVD axes 1 and 2, and complete-case coverage captions (map used N of M
scored posts), plus the
PU / corp / thread comparison -- never a placeholder. TEPP is unchanged.

## Phase 6b: Knowledge Graph as a real Ontology + Semantic Layer
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.d/2.12.17-leftover-map-coverage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# 2.12.17 — Leftover map complete-case coverage

## Added

- Period leftover maps now persist how many scored posts entered the
complete-case Gabriel factorization (ADR 0168). Missing cells stay
out of the map; they are never treated as zero.
- After seed, the leftover pair list is captioned
“Leftover map used N of M scored posts (complete-case)” so a sparse
post that was scored but excluded is visible as coverage, not as a
fabricated pair.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ All notable changes to this project are documented here. Format follows
compatibility vocabulary after validating every mapping's term kind.
- The PROV-O support profile now mints its product class mappings only in the
canonical lowercase namespace while importing the legacy compatibility map.
- Period leftover maps now persist how many scored posts entered the
complete-case Gabriel factorization (ADR 0168). The leftover pair
list is captioned “Leftover map used N of M scored posts
(complete-case)”; incomplete rows stay excluded, never filled with
zero.

### Fixed

Expand Down
53 changes: 52 additions & 1 deletion backend/app/report_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ async def persist_period_report(
period_code: str,
report: PeriodReport,
) -> None:
"""Replace the stored report, member scores, leftover pairs, leftover-map axes, and item bank."""
"""Replace the stored report, member scores, leftover pairs, leftover-map axes, leftover coverage, and item bank."""
await conn.execute(
"""
delete from report_period_score
Expand Down Expand Up @@ -478,6 +478,27 @@ async def persist_period_report(
axis.leftover_singular_value,
axis.leftover_share,
)
if report.leftover_map_coverage is not None:
coverage = report.leftover_map_coverage
await conn.execute(
"""
insert into report_leftover_map_coverage (
grouping_kind, grouping_key, period_code, rubric_version,
map_post_count, scored_post_count, map_item_count, scored_item_count,
incomplete_post_count, incomplete_item_count
) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
""",
grouping_kind,
grouping_key,
period_code,
RUBRIC_VERSION,
coverage.map_post_count,
coverage.scored_post_count,
coverage.map_item_count,
coverage.scored_item_count,
coverage.incomplete_post_count,
coverage.incomplete_item_count,
)


def _groups_from_rows(
Expand Down Expand Up @@ -650,6 +671,18 @@ async def fetch_period_reports(
period_code,
RUBRIC_VERSION,
)
leftover_coverage = await conn.fetch(
"""
select grouping_key, map_post_count, scored_post_count,
map_item_count, scored_item_count,
incomplete_post_count, incomplete_item_count
from report_leftover_map_coverage
where grouping_kind = $1 and period_code = $2 and rubric_version = $3
""",
grouping_kind,
period_code,
RUBRIC_VERSION,
)
Comment thread
seonghobae marked this conversation as resolved.
status_labels = await labels_for_codes(
conn,
[row["ticket_status_code"] for row in members if row["ticket_status_code"]],
Expand All @@ -666,6 +699,7 @@ async def fetch_period_reports(
leftover_axes_by_group: dict[str, list[asyncpg.Record]] = defaultdict(list)
for row in leftover_axes:
leftover_axes_by_group[row["grouping_key"]].append(row)
leftover_coverage_by_group = {row["grouping_key"]: row for row in leftover_coverage}
payload: list[dict[str, Any]] = []
for header in headers:
grouping_key = header["grouping_key"]
Expand Down Expand Up @@ -770,11 +804,28 @@ async def fetch_period_reports(
}
for row in leftover_axes_by_group.get(header["grouping_key"], [])
],
"leftover_map_coverage": _leftover_map_coverage_payload(
leftover_coverage_by_group.get(header["grouping_key"])
),
}
)
return payload


def _leftover_map_coverage_payload(row: asyncpg.Record | None) -> dict[str, int] | None:
"""One complete-case leftover-map coverage row, or None when unpersisted."""
if row is None:
return None
return {
"map_post_count": int(row["map_post_count"]),
"scored_post_count": int(row["scored_post_count"]),
"map_item_count": int(row["map_item_count"]),
"scored_item_count": int(row["scored_item_count"]),
"incomplete_post_count": int(row["incomplete_post_count"]),
"incomplete_item_count": int(row["incomplete_item_count"]),
}


async def list_period_report_summaries(
conn: asyncpg.Connection,
grouping_kind: str,
Expand Down
13 changes: 13 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@
/ "migrations"
/ "0169_report_leftover_map_axis.sql"
)
_LEFTOVER_MAP_COVERAGE_MIGRATION = (
Path(__file__).resolve().parents[2]
/ "migrations"
/ "0168_report_leftover_map_coverage.sql"
)
_LEFTOVER_MAP_UNEXPLAINED_MIGRATION = (
Path(__file__).resolve().parents[2]
/ "migrations"
Expand Down Expand Up @@ -264,6 +269,7 @@ def seeded_db(demo_analyst_token):
cur.execute(_CHANNEL_WEIGHT_MIGRATION.read_text())
cur.execute(_LEFTOVER_OBSERVED_EXPECTED_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_RANK_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_COVERAGE_MIGRATION.read_text())
cur.execute(_GLOBAL_ASK_JOB_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text())
Expand Down Expand Up @@ -5018,6 +5024,13 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token,
assert [axis["axis_index"] for axis in leftover_axes] == [1, 2]
assert all(axis["leftover_singular_value"] >= 0 for axis in leftover_axes)
assert all(0.0 <= axis["leftover_share"] <= 1.0 for axis in leftover_axes)
leftover_coverage = high_report.get("leftover_map_coverage")
assert leftover_coverage is not None
assert leftover_coverage["map_post_count"] <= leftover_coverage["scored_post_count"]
assert leftover_coverage["incomplete_post_count"] == (
leftover_coverage["scored_post_count"] - leftover_coverage["map_post_count"]
)
assert leftover_coverage["scored_post_count"] >= 2

week3 = client.get(
"/api/reports/process_unit/2026-W03",
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0048-persist-lsirm-leftover-pairs.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ 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 0049.
The UI contract is ADR 0049. Complete-case coverage is ADR 0168.

## Consequences

Expand Down
3 changes: 2 additions & 1 deletion docs/adr/0049-leftover-pair-report-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,5 @@ next action, not only the distance.
## Related

Depends on [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and
[ADR 0003](0003-fast-mlsirm-report-integration.md).
[ADR 0003](0003-fast-mlsirm-report-integration.md). Complete-case
coverage of the leftover map is [ADR 0168](0168-leftover-map-complete-case-coverage.md).
74 changes: 74 additions & 0 deletions docs/adr/0168-leftover-map-complete-case-coverage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# ADR 0168 — Name leftover complete-case coverage

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Duplicate ADR number 0168

The new 0168-leftover-map-complete-case-coverage.md reuses a number already held by docs/adr/0168-ontology-provenance-explorer.md. Existing citations (AGENTS.md, CHANGELOG.md, ARCHITECTURE.md) mean the ontology explorer, while the new ones (AGENTS.md, 0048-persist-lsirm-leftover-pairs.md, 0049-leftover-pair-report-ui.md) mean leftover coverage. Every reference to ADR 0168 is now ambiguous.

Prompt for agents
The new ADR file docs/adr/0168-leftover-map-complete-case-coverage.md reuses ADR number 0168, which is already assigned to docs/adr/0168-ontology-provenance-explorer.md. Renumber the new leftover-map complete-case coverage ADR to an unused number, rename the file accordingly, and update every reference to it: the title line inside the file, AGENTS.md (the Tests section coverage paragraph), ARCHITECTURE.md (Phase 6e coverage paragraph), CHANGELOG.md and CHANGELOG.d entry, docs/adr/0048-persist-lsirm-leftover-pairs.md, docs/adr/0049-leftover-pair-report-ui.md, and any code comments/docstrings citing ADR 0168 for coverage (e.g. lineageweave/leftover_pairs.py, lineageweave/period_report.py). Leave the existing ADR 0168 references that mean the ontology explorer (AGENTS.md:15, CHANGELOG.md:23, ARCHITECTURE.md:82) untouched.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


**Decision status:** Accepted
**Date:** 2026-08-24

## Context

ADR 0048 already factorizes the leftover residual
`R = Y − E[Y|θ, item]` on the **complete-case** rectangle (Gabriel,
1971). Incomplete rows are dropped; missing cells are never filled
with zero. ADR 0049 then shows closest and farthest pairs above the
member list.

The period report still does not tell the buyer how many scored
posts entered that factorization. A sparse post with one missing
criterion is excluded from the map, so closest/farthest pairs can
name two posts while three posts were scored. Without a coverage
caption, the buyer cannot tell whether the map used every scored
post or dropped incomplete rows.

This slice does not persist leftover-map coordinates (that is a
separate increment), does not disclose residual `R` on pair rows,
and does not change pair click-through.

## Decision

After a real GRM/GPCM score, persist one
`report_leftover_map_coverage` row per period report (3NF,
two-or-more-word `snake_case`):

- `map_post_count` — posts that entered the complete-case leftover
map
- `scored_post_count` — posts with at least one observed cell
- `map_item_count` / `scored_item_count` — the same counts for
criteria
- `incomplete_post_count` / `incomplete_item_count` — scored minus
map, stored so the buyer fact is durable and check-constrained

Do not store a second theta. Do not fill missing cells with zero.
Do not invent coverage when the IRT matrix is unusable.

`GET /api/reports/{grouping}/{period}` returns
`leftover_map_coverage` next to `leftover_pairs`. The Period
reports panel renders a caption **above** the leftover pair list:

> Leftover map used N of M scored posts (complete-case)

Missing coverage renders nothing. A hidden post never appears as a
leftover pair (ADR 0049); coverage counts remain the fitted map,
not a visibility-filtered recount.

## Consequences

Rebuild and seed write coverage in the same transaction as leftover
pairs. Migration `0168_report_leftover_map_coverage.sql` upgrades
volumes that already applied `0001`. `migrate.sh` replays `0168_*`
on existing volumes.

## 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

## Related

Depends on [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and
[ADR 0049](0049-leftover-pair-report-ui.md). Complements, and does
not replace, leftover-map coordinates or residual-row disclosure.
13 changes: 13 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,14 @@ describe("App, authenticated", () => {
leftover_share: 0.18,
},
],
leftover_map_coverage: {
map_post_count: 2,
scored_post_count: 3,
map_item_count: 2,
scored_item_count: 2,
incomplete_post_count: 1,
incomplete_item_count: 0,
},
members: [
{
post_id: "post-1",
Expand Down Expand Up @@ -3697,6 +3705,10 @@ 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();
expect(screen.getByLabelText("Leftover map coverage")).toHaveTextContent(
"Leftover map used 2 of 3 scored posts (complete-case)",
);
const coverageCaption = screen.getByLabelText("Leftover map coverage");
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,
Expand All @@ -3720,6 +3732,7 @@ describe("App, authenticated", () => {
expect(farthestPair).toHaveTextContent("U −0.25");
expect(farthestPair).toHaveTextContent("d 1.84");
const memberButton = screen.getByRole("button", { name: /open report post: public post/i });
expect(coverageCaption.compareDocumentPosition(closestPair) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(closestPair.compareDocumentPosition(memberButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(
screen.getByRole("button", { name: /open report post: specification revision requested/i }),
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3457,6 +3457,14 @@ function ReportsPanel({
{report.selected_items[0].information.toFixed(2)}
</span>
)}
{report.leftover_map_coverage && report.leftover_map_coverage.scored_post_count > 0 && (
<p className="post-meta" role="note" aria-label={t("Leftover map coverage")}>
{tf("Leftover map used {used} of {scored} scored posts (complete-case)", {
used: report.leftover_map_coverage.map_post_count,
scored: report.leftover_map_coverage.scored_post_count,
})}
</p>
)}
{report.leftover_map_axes?.map((axis) => (
<span key={axis.axis_index} className="post-badge">
{tf("leftover axis {axis} {share}%", {
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,15 @@ export interface LeftoverMapAxis {
leftover_share: number;
}

export interface LeftoverMapCoverage {
map_post_count: number;
scored_post_count: number;
map_item_count: number;
scored_item_count: number;
incomplete_post_count: number;
incomplete_item_count: number;
}

export interface PeriodGroupReport {
grouping_key: string;
grouping_label?: string;
Expand All @@ -902,6 +911,7 @@ export interface PeriodGroupReport {
selected_items: SelectedReportItem[];
leftover_pairs: LeftoverPair[];
leftover_map_axes?: LeftoverMapAxis[];
leftover_map_coverage?: LeftoverMapCoverage | null;
}

export interface PeriodReports {
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ const TRANSLATIONS: Partial<Record<Locale, Record<string, string>>> = {
"Choose a post": "글을 선택하세요",
"Analysis runs": "분석 실행",
"Period reports": "기간 리포트",
"Leftover map coverage": "잔여 지도 포함 범위",
"Leftover map used {used} of {scored} scored posts (complete-case)":
"잔여 지도는 채점된 글 {scored}개 중 {used}개를 사용했습니다(완전사례)",
"Event Lineage": "이벤트 계보",
"Related posts": "관련 글",
"Related to": "관련 대상:",
Expand Down Expand Up @@ -643,6 +646,9 @@ const TRANSLATIONS: Partial<Record<Locale, Record<string, string>>> = {
"Choose a post": "选择文章",
"Analysis runs": "分析运行",
"Period reports": "周期报告",
"Leftover map coverage": "残差地图覆盖范围",
"Leftover map used {used} of {scored} scored posts (complete-case)":
"残差地图使用了 {scored} 篇已评分帖文中的 {used} 篇(完全案例)",
"Event Lineage": "事件谱系",
"Related posts": "相关文章",
"Related to": "相关对象:",
Expand Down Expand Up @@ -1111,6 +1117,9 @@ const TRANSLATIONS: Partial<Record<Locale, Record<string, string>>> = {
"Choose a post": "投稿を選択",
"Analysis runs": "分析実行",
"Period reports": "期間レポート",
"Leftover map coverage": "残差マップの対象範囲",
"Leftover map used {used} of {scored} scored posts (complete-case)":
"残差マップは採点済み投稿 {scored} 件のうち {used} 件を使いました(完全ケース)",
"Event Lineage": "イベント系譜",
"Related posts": "関連する投稿",
"Related to": "関連対象:",
Expand Down Expand Up @@ -1556,6 +1565,9 @@ const TRANSLATIONS: Partial<Record<Locale, Record<string, string>>> = {
"Choose a post": "Chọn bài viết",
"Analysis runs": "Lần chạy phân tích",
"Period reports": "Báo cáo theo kỳ",
"Leftover map coverage": "Phạm vi bản đồ phần dư",
"Leftover map used {used} of {scored} scored posts (complete-case)":
"Bản đồ phần dư dùng {used} trên {scored} bài đã chấm (trường hợp đầy đủ)",
"Event Lineage": "Dòng sự kiện",
"Related posts": "Bài viết liên quan",
"Related to": "Liên quan đến:",
Expand Down
Loading
Loading