-
Notifications
You must be signed in to change notification settings - Fork 1
feat: persist leftover-map axis share on period reports (v2.12.16) #519
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| # 2.12.19 — Leftover-map axis share | ||
|
|
||
| ## Added | ||
|
|
||
| - Persist leftover-map axis share (Gabriel inertia of residual SVD | ||
| axes 1 and 2, `σ²/Σσ²`) on each period report (ADR 0148). Rank-0 | ||
| residuals emit two zero-share axes; missing cells stay out of the | ||
| factorization. After `make seed`, leftover-axis badges sit with the | ||
| leftover pairs; the caption tells the buyer to open a leftover pair. | ||
| Axis share is report-level 3NF and is not hidden when leftover pairs | ||
| are ABAC-filtered. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -351,7 +351,7 @@ async def persist_period_report( | |
| period_code: str, | ||
| report: PeriodReport, | ||
| ) -> None: | ||
| """Replace the stored report, member scores, leftover pairs, and item bank.""" | ||
| """Replace the stored report, member scores, leftover pairs, leftover-map axes, and item bank.""" | ||
| await conn.execute( | ||
| """ | ||
| delete from report_period_score | ||
|
|
@@ -460,6 +460,22 @@ async def persist_period_report( | |
| pair.expected_response, | ||
| pair.leftover_map_rank, | ||
| ) | ||
| for axis in report.leftover_map_axes: | ||
| await conn.execute( | ||
| """ | ||
| insert into report_leftover_map_axis ( | ||
| grouping_kind, grouping_key, period_code, rubric_version, | ||
| axis_index, leftover_singular_value, leftover_share | ||
| ) values ($1,$2,$3,$4,$5,$6,$7) | ||
| """, | ||
| grouping_kind, | ||
| grouping_key, | ||
| period_code, | ||
| RUBRIC_VERSION, | ||
| axis.axis_index, | ||
| axis.leftover_singular_value, | ||
| axis.leftover_share, | ||
| ) | ||
|
Comment on lines
+463
to
+478
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Axis-share replace relies on cascade delete Neither Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
|
|
||
| def _groups_from_rows( | ||
|
|
@@ -620,6 +636,17 @@ async def fetch_period_reports( | |
| period_code, | ||
| RUBRIC_VERSION, | ||
| ) | ||
| leftover_axes = await conn.fetch( | ||
| """ | ||
| select grouping_key, axis_index, leftover_singular_value, leftover_share | ||
| from report_leftover_map_axis | ||
| where grouping_kind = $1 and period_code = $2 and rubric_version = $3 | ||
| order by grouping_key, axis_index | ||
| """, | ||
| 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"]], | ||
|
|
@@ -633,6 +660,9 @@ async def fetch_period_reports( | |
| leftover_by_group: dict[str, list[asyncpg.Record]] = defaultdict(list) | ||
| for row in leftover: | ||
| leftover_by_group[row["grouping_key"]].append(row) | ||
| 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) | ||
| payload: list[dict[str, Any]] = [] | ||
| for header in headers: | ||
| grouping_key = header["grouping_key"] | ||
|
|
@@ -724,6 +754,14 @@ async def fetch_period_reports( | |
| } | ||
| for row in leftover_by_group.get(header["grouping_key"], []) | ||
| ], | ||
| "leftover_map_axes": [ | ||
| { | ||
| "axis_index": int(row["axis_index"]), | ||
| "leftover_singular_value": float(row["leftover_singular_value"]), | ||
| "leftover_share": float(row["leftover_share"]), | ||
| } | ||
| for row in leftover_axes_by_group.get(header["grouping_key"], []) | ||
| ], | ||
| } | ||
| ) | ||
| return payload | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| # ADR 0148 — Persist leftover-map axis share | ||
|
|
||
| **Decision status:** Accepted | ||
| **Date:** 2026-08-24 | ||
|
|
||
| ## Context | ||
|
|
||
| ADR 0048 persists closest and farthest leftover post–criterion pairs | ||
| from a Gabriel (1971) biplot of the residual `R = Y − E[Y|θ, item]` | ||
| after a real GRM/GPCM score (Jeon et al., 2021, eq. 3). Buyers can | ||
| open those pairs (ADR 0049) but cannot yet read how much leftover-map | ||
| structure sits on axis 1 versus axis 2. | ||
|
|
||
| Gabriel inertia of leftover-map axis `k` is `σ_k² / Σ_j σ_j²`. That | ||
| share is a report-level property of the residual SVD, not a | ||
| post-identifying leftover score and not a second theta. Denormalizing | ||
| it onto each leftover pair would violate 3NF. | ||
|
|
||
| `fast-mlsirm` still exposes no leftover-pair or leftover-map API. | ||
| LineageWeave must not fork LSIRM or invent leftover numbers when the | ||
| residual is rank-0. | ||
|
|
||
| ## Decision | ||
|
|
||
| After the same residual SVD that produces leftover pairs, persist | ||
| exactly two leftover-map axes (axis 1 and axis 2) per period report in | ||
| `report_leftover_map_axis` (3NF, two-or-more-word `snake_case`). | ||
|
|
||
| Share is `σ_k² / Σ_j σ_j²` from the leftover singular values that | ||
| survive the leftover singular floor. Rank-0 residuals emit two | ||
| zero-share axes so `make seed` can name leftover-map structure without | ||
| inventing a leftover score. Missing response cells stay out of the | ||
| factorization. | ||
|
|
||
| Cascade the rows with `report_period_score`. Axes are aggregate and | ||
| non-identifying: ABAC that hides leftover pairs does not hide axis | ||
| share. Do not store a second theta. Do not invent leftover numbers. | ||
|
|
||
| The biplot lives in `lineageweave/leftover_pairs.py` so leftover tests | ||
| do not import `period_report` or `fast_mlsirm`. | ||
|
|
||
| ## Consequences | ||
|
|
||
| Rebuild and seed write leftover-map axes in the same transaction as | ||
| leftover pairs. `GET /api/reports/{grouping}/{period}` returns | ||
| `leftover_map_axes` next to `leftover_pairs`. The Period reports panel | ||
| shows leftover-axis share badges and a caption that tells the buyer to | ||
| open a leftover pair. Migration `0169_report_leftover_map_axis.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 |
Uh oh!
There was an error while loading. Please reload this page.