diff --git a/AGENTS.md b/AGENTS.md index 3af4169bc..266618122 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,7 +273,7 @@ stops startup instead of leaving a healthy-looking partial schema, and application code must not compensate for a missing table. Period leftover pairs (ADR 0017 / 0018 / 0048 / 0049 / 0119 / 0158 / 0162 / -0163 / 0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270 / 0271) are computed in `lineageweave/leftover_pairs.py` from the +0163 / 0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270 / 0271 / 0272 / 0273) 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 @@ -301,7 +301,15 @@ plot; rank-0 unused axes name only `0` and do not invent drawing-scale ticks. ADR 0271 captions leftover-map pair segments with persisted leftover-map distance `d` so the pair-row badge matches the graphic; a missing or non-finite `d` omits that segment caption and does not -invent `d` from plotted coordinates. When `R`, `R̂`, `U`, `x`, +invent `d` from plotted coordinates. ADR 0272 captions leftover-map +pair segments with persisted leftover-map reconstruction `R̂` so the +pair-row badge matches the graphic; a missing or non-finite `R̂` omits +that reconstruction caption and does not invent `R̂` from plotted +coordinates. ADR 0273 captions leftover-map pair segments with persisted +leftover-map explained leftover share `e` so the pair-row `R̂²/R²` +badge matches the graphic; a missing or non-finite `e` omits that +explained leftover share caption and does not invent `e` from `R̂` and +`R` or from plotted coordinates. When `R`, `R̂`, `U`, `x`, `s`, and `e` are finite, `e + s + x = 1`. When coordinates, reconstruction, and distance are finite, `R̂ = ξ · ζ` and `d = ‖ξ − ζ‖`. The pairs sit above the member diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 05f98e69e..a071f7a8b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -637,7 +637,7 @@ 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 0017 / 0048 / 0049 / 0119 / 0148 / 0158 / 0162 / 0163 / 0164 / 0168 / -0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270 / 0271) persist to `report_leftover_pair` with signed residual `R`, +0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270 / 0271 / 0272 / 0273) persist to `report_leftover_pair` with signed residual `R`, observed `Y`, expected `E[Y|θ, item]`, full leftover-map rank, unexplained leftover, ADR 0201 reconstruction evidence, ADR 0185 cross-share evidence, ADR 0233 unexplained leftover share `s`, ADR 0266 explained leftover @@ -647,7 +647,10 @@ display above the pair buttons. ADR 0269 captions leftover-map axes 1 and 2 with persisted leftover-map axis share. ADR 0270 ticks those leftover-map axes at persisted `ξ` / `ζ` coordinates so the pair-row badge matches the plot. ADR 0271 captions leftover-map pair segments -with persisted leftover-map distance `d`. Click a post marker or a pair button +with persisted leftover-map distance `d`. ADR 0272 captions leftover-map +pair segments with persisted leftover-map reconstruction `R̂`. ADR 0273 +captions leftover-map pair segments with persisted leftover-map +explained leftover share `e`. Click a post marker or a pair button opens that post. Those ADRs are the normative mathematical, storage, and display contracts. Leftover-map axis share (Gabriel inertia of residual SVD axes 1 and 2; ADR 0148) persists to diff --git a/CHANGELOG.d/2.29.0-leftover-map-segment-reconstruction.md b/CHANGELOG.d/2.29.0-leftover-map-segment-reconstruction.md new file mode 100644 index 000000000..c6d927390 --- /dev/null +++ b/CHANGELOG.d/2.29.0-leftover-map-segment-reconstruction.md @@ -0,0 +1,12 @@ +## 2.29.0 — Leftover-map reconstruction on graphic-display pair segments + +- Caption leftover-map graphic-display pair segments with persisted + leftover-map reconstruction `R̂` (ADR 0272). After `make seed`, + closest and farthest leftover pairs sit above the member list with + the Gabriel biplot of already-named coordinates, leftover-map axis + share when finite, axis ticks that match `ξ (x, y) ζ (x, y)` on the + pair row, pair segments that match `d` on the pair row, and pair + segments that match `R̂` on the pair row; click a post marker or a + pair button opens that post. A missing or non-finite `R̂` omits that + reconstruction caption. Do not invent `R̂` from plotted coordinates. + Never invent a leftover score. Never invent a theta. No new columns. diff --git a/CHANGELOG.d/2.30.0-leftover-map-segment-explained-share.md b/CHANGELOG.d/2.30.0-leftover-map-segment-explained-share.md new file mode 100644 index 000000000..e80d477a1 --- /dev/null +++ b/CHANGELOG.d/2.30.0-leftover-map-segment-explained-share.md @@ -0,0 +1,14 @@ +## 2.30.0 — Leftover-map explained leftover share on graphic-display pair segments + +- Caption leftover-map graphic-display pair segments with persisted + leftover-map explained leftover share `e = R̂² / R²` (ADR 0273). After + `make seed`, closest and farthest leftover pairs sit above the member + list with the Gabriel biplot of already-named coordinates, leftover-map + axis share when finite, axis ticks that match `ξ (x, y) ζ (x, y)` on + the pair row, pair segments that match `d` on the pair row, pair + segments that match `R̂` on the pair row, and pair segments that match + `R̂²/R²` on the pair row; click a post marker or a pair button opens + that post. A missing or non-finite `e` omits that explained leftover + share caption. A share greater than 1 is shown, never clamped. Do not + invent `e` from `R̂` and `R` or from plotted coordinates. Never invent + a leftover score. Never invent a theta. No new columns. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a2724eb8..271c89353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,32 @@ All notable changes to this project are documented here. Format follows ### Added +- Period leftover pairs now caption leftover-map graphic-display pair + segments with persisted leftover-map explained leftover share + `e = R̂² / R²` (ADR 0273 / v2.30.0). After `make seed`, closest and + farthest leftover pairs sit above the member list with the Gabriel + biplot of already-named coordinates, leftover-map axis share when + finite, axis ticks that match `ξ (x, y) ζ (x, y)` on the pair row, + pair segments that match `d` on the pair row, pair segments that + match `R̂` on the pair row, and pair segments that match `R̂²/R²` + on the pair row; click a post marker or a pair button opens that + post. A missing or non-finite `e` omits that explained leftover + share caption. A share greater than 1 is shown, never clamped. Do + not invent `e` from `R̂` and `R` or from plotted coordinates. Never + invent a leftover score or a theta. No new columns. + +- Period leftover pairs now caption leftover-map graphic-display pair + segments with persisted leftover-map reconstruction `R̂` (ADR 0272 / + v2.29.0). After `make seed`, closest and farthest leftover pairs sit + above the member list with the Gabriel biplot of already-named + coordinates, leftover-map axis share when finite, axis ticks that + match `ξ (x, y) ζ (x, y)` on the pair row, pair segments that match + `d` on the pair row, and pair segments that match `R̂` on the pair + row; click a post marker or a pair button opens that post. A missing + or non-finite `R̂` omits that reconstruction caption. Do not invent + `R̂` from plotted coordinates. Never invent a leftover score or a + theta. No new columns. + - Period leftover pairs now caption leftover-map graphic-display pair segments with persisted leftover-map distance `d` (ADR 0271 / v2.28.0). After `make seed`, closest and farthest leftover pairs sit diff --git a/CLAUDE.md b/CLAUDE.md index 1a36a87b3..c4dad11ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ cutoff. Global Ask optional `knowledge_cutoff` uses the same cover Create/start endpoint rules (ADR 0017 / 0021), tie-vs-miss similarity (ADR 0026), R&R catalog ids (ADR 0019 / 0027), leftover pairs -(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270 / 0271), occupational construct catalog search +(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270 / 0271 / 0272 / 0273), occupational construct catalog search (ADR 0257), the text-channel embedding swap and cosine clamp (ADR 0190), per-edge channel-score persistence (ADR 0195), token-backed status notices (ADR 0220), diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md index f3a8c636b..3c0adc8a5 100644 --- a/docs/adr/0048-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md @@ -14,7 +14,9 @@ [ADR 0268](0268-leftover-map-graphic-display.md) (leftover-map graphic display); [ADR 0269](0269-leftover-map-axis-share-plot.md) (leftover-map axis share on the graphic display); [ADR 0270](0270-leftover-map-coordinate-ticks.md) (leftover-map coordinate ticks); -[ADR 0271](0271-leftover-map-segment-distance.md) (leftover-map distance on pair segments) +[ADR 0271](0271-leftover-map-segment-distance.md) (leftover-map distance on pair segments); +[ADR 0272](0272-leftover-map-segment-reconstruction.md) (leftover-map reconstruction on pair segments); +[ADR 0273](0273-leftover-map-segment-explained-share.md) (leftover-map explained leftover share on pair segments) ## Context @@ -74,7 +76,10 @@ those leftover-map axes with persisted leftover-map axis share; it adds no columns. ADR 0270 ticks leftover-map axes at persisted `ξ` / `ζ` coordinates; it adds no columns. ADR 0271 captions leftover-map pair segments with persisted leftover-map distance `d`; it adds no -columns. +columns. ADR 0272 captions leftover-map pair segments with persisted +leftover-map reconstruction `R̂`; it adds no columns. ADR 0273 captions +leftover-map pair segments with persisted leftover-map explained leftover +share `e`; it adds no columns. 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 6ff778400..39cca48bd 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -15,7 +15,9 @@ [ADR 0268](0268-leftover-map-graphic-display.md) (leftover-map graphic display); [ADR 0269](0269-leftover-map-axis-share-plot.md) (leftover-map axis share on the graphic display); [ADR 0270](0270-leftover-map-coordinate-ticks.md) (leftover-map coordinate ticks); -[ADR 0271](0271-leftover-map-segment-distance.md) (leftover-map distance on pair segments) +[ADR 0271](0271-leftover-map-segment-distance.md) (leftover-map distance on pair segments); +[ADR 0272](0272-leftover-map-segment-reconstruction.md) (leftover-map reconstruction on pair segments); +[ADR 0273](0273-leftover-map-segment-explained-share.md) (leftover-map explained leftover share on pair segments) ## Context @@ -43,7 +45,9 @@ sits above the pair buttons (ADR 0268); click a post marker to open that post. Leftover-map axis share captions those leftover-map axes when finite (ADR 0269). Leftover-map axis ticks name persisted `ξ` / `ζ` coordinates (ADR 0270). Pair segments name persisted leftover-map -distance `d` (ADR 0271). The pair renders every available finite measurement. +distance `d` (ADR 0271). Pair segments name persisted leftover-map +reconstruction `R̂` (ADR 0272). Pair segments name persisted leftover-map +explained leftover share `e` (ADR 0273). The pair renders every available finite measurement. The next action uses the first available value in the priority below; no amendment hides another badge, rank 0 explicitly names no leftover structure, and unexplained leftover names @@ -76,6 +80,10 @@ Leftover-map coordinate ticks are [ADR 0270](0270-leftover-map-coordinate-ticks.md). Leftover-map distance on pair segments is [ADR 0271](0271-leftover-map-segment-distance.md). +Leftover-map reconstruction on pair segments is +[ADR 0272](0272-leftover-map-segment-reconstruction.md). +Leftover-map explained leftover share on pair segments is +[ADR 0273](0273-leftover-map-segment-explained-share.md). After `make seed`, closest and farthest leftover pairs sit above the member list. Click a pair to open that post with the leftover diff --git a/docs/adr/0201-leftover-map-reconstruction.md b/docs/adr/0201-leftover-map-reconstruction.md index 2071ad646..fa1677bb6 100644 --- a/docs/adr/0201-leftover-map-reconstruction.md +++ b/docs/adr/0201-leftover-map-reconstruction.md @@ -4,7 +4,11 @@ **Date:** 2026-08-25 **Amended by:** [ADR 0267](0267-leftover-map-coordinates.md) -(leftover-map coordinates ξ, ζ) +(leftover-map coordinates ξ, ζ); +[ADR 0272](0272-leftover-map-segment-reconstruction.md) +(leftover-map reconstruction on graphic-display pair segments); +[ADR 0273](0273-leftover-map-segment-explained-share.md) +(leftover-map explained leftover share on graphic-display pair segments) Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md), [ADR 0049](0049-leftover-pair-report-ui.md), and diff --git a/docs/adr/0266-leftover-map-explained-share.md b/docs/adr/0266-leftover-map-explained-share.md index 81a66105e..f05454772 100644 --- a/docs/adr/0266-leftover-map-explained-share.md +++ b/docs/adr/0266-leftover-map-explained-share.md @@ -4,7 +4,9 @@ **Date:** 2026-08-28 **Amended by:** [ADR 0267](0267-leftover-map-coordinates.md) -(leftover-map coordinates ξ, ζ) +(leftover-map coordinates ξ, ζ); +[ADR 0273](0273-leftover-map-segment-explained-share.md) +(leftover-map explained leftover share on graphic-display pair segments) Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and [ADR 0049](0049-leftover-pair-report-ui.md). Independent of leftover-map diff --git a/docs/adr/0268-leftover-map-graphic-display.md b/docs/adr/0268-leftover-map-graphic-display.md index 0b58bb964..9182aab35 100644 --- a/docs/adr/0268-leftover-map-graphic-display.md +++ b/docs/adr/0268-leftover-map-graphic-display.md @@ -8,7 +8,11 @@ [ADR 0270](0270-leftover-map-coordinate-ticks.md) (leftover-map coordinate ticks); [ADR 0271](0271-leftover-map-segment-distance.md) -(leftover-map distance on pair segments) +(leftover-map distance on pair segments); +[ADR 0272](0272-leftover-map-segment-reconstruction.md) +(leftover-map reconstruction on pair segments); +[ADR 0273](0273-leftover-map-segment-explained-share.md) +(leftover-map explained leftover share on pair segments) Amends [ADR 0049](0049-leftover-pair-report-ui.md) and [ADR 0267](0267-leftover-map-coordinates.md). Independent of leftover-map @@ -83,7 +87,9 @@ and `ζ`; click a post marker or a pair button opens that post. Hidden posts stay hidden. Leftover-map axis share captions those leftover-map axes when finite (ADR 0269). Leftover-map axis ticks name persisted `ξ` / `ζ` coordinates (ADR 0270). Pair segments name -persisted leftover-map distance `d` (ADR 0271). When coordinates, reconstruction, and +persisted leftover-map distance `d` (ADR 0271). Pair segments name +persisted leftover-map reconstruction `R̂` (ADR 0272). Pair segments name +persisted leftover-map explained leftover share `e` (ADR 0273). When coordinates, reconstruction, and distance are all finite, `R̂ = ξ_{1:2} · ζ_{1:2}` and `d = ‖ξ_{1:2} − ζ_{1:2}‖` remain the same identities already persisted by ADR 0267. diff --git a/docs/adr/0271-leftover-map-segment-distance.md b/docs/adr/0271-leftover-map-segment-distance.md index cf458aa5a..f4ed9a3d7 100644 --- a/docs/adr/0271-leftover-map-segment-distance.md +++ b/docs/adr/0271-leftover-map-segment-distance.md @@ -3,6 +3,11 @@ **Decision status:** Accepted **Date:** 2026-08-29 +**Amended by:** [ADR 0272](0272-leftover-map-segment-reconstruction.md) +(leftover-map reconstruction on pair segments); +[ADR 0273](0273-leftover-map-segment-explained-share.md) +(leftover-map explained leftover share on pair segments) + Amends [ADR 0268](0268-leftover-map-graphic-display.md) and [ADR 0049](0049-leftover-pair-report-ui.md). Independent of leftover-map coordinate ticks ([ADR 0270](0270-leftover-map-coordinate-ticks.md)), diff --git a/docs/adr/0272-leftover-map-segment-reconstruction.md b/docs/adr/0272-leftover-map-segment-reconstruction.md new file mode 100644 index 000000000..2dcede993 --- /dev/null +++ b/docs/adr/0272-leftover-map-segment-reconstruction.md @@ -0,0 +1,122 @@ +# ADR 0272 — Name leftover-map reconstruction on graphic-display pair segments + +**Decision status:** Accepted +**Date:** 2026-08-29 + +**Amended by:** [ADR 0273](0273-leftover-map-segment-explained-share.md) +(leftover-map explained leftover share on pair segments) + +Amends [ADR 0268](0268-leftover-map-graphic-display.md) and +[ADR 0049](0049-leftover-pair-report-ui.md). Independent of leftover-map +distance on pair segments ([ADR 0271](0271-leftover-map-segment-distance.md)), +leftover-map coordinate ticks ([ADR 0270](0270-leftover-map-coordinate-ticks.md)), +leftover-map axis share on the graphic display +([ADR 0269](0269-leftover-map-axis-share-plot.md)), leftover-map +explained leftover share ([ADR 0266](0266-leftover-map-explained-share.md)), +leftover-map unexplained leftover share +([ADR 0233](0233-leftover-map-unexplained-share.md)), leftover-map +reconstruction persistence ([ADR 0201](0201-leftover-map-reconstruction.md)), +and leftover-map axis share persistence ([ADR 0148](0148-leftover-map-axis-share.md)). + +## Context + +ADR 0201 already persists leftover-map reconstruction +`R̂ = ξ_{1:2} · ζ_{1:2}` on leftover pair rows. ADR 0267 already +persists the two-axis coordinates so that identity stays auditable +next to leftover-map distance `d`. ADR 0268 already draws the +connecting segment. ADR 0271 already names persisted `d` on that +segment. The segment still has no leftover-map reconstruction label, +so a buyer who reads `R̂ +0.35` on the pair row cannot match that +inner product to the graphic pair. Hiding `R̂` on the segment lets +leftover residual `R`, leftover-map distance `d`, or leftover-map +location be read as leftover-map reconstruction even after the +coordinates, ticks, and distance are named. + +This increment names leftover-map graphic-display pair segments with +persisted leftover-map reconstruction. It does not add columns. It +does not recompute `R̂` from plotted coordinates. It does not persist +leftover-map inner product, cosine, or length as separate columns +(`R̂` already is the two-axis inner product). It does not land Post +quality on the leftover criterion. Leftover-map distance stays +two-axis Euclidean. Do not invent a leftover score. Do not invent a +theta. + +The dashboard stack already used neighbouring leftover facts under +other numbers. This protected increment uses **0272** so it does not +collide with leftover-map distance on pair segments (0271), +leftover-map coordinate ticks (0270), leftover-map axis share on the +graphic display (0269), leftover-map graphic display (0268), +leftover-map coordinates (0267 / migration 0245), leftover-map +explained leftover share (0266 / migration 0244), leftover-map +unexplained leftover share (0233 / migration 0233), leftover-map +reconstruction persistence (0201 / migration 0206), leftover-map +cross share (0185), leftover residual disclosure, leftover observed +`Y` / expected `E`, leftover-map rank, two-axis leftover-map distance +persistence, leftover coverage, leftover-map axis share persistence +(0148), leftover interaction-map persistence, occupational construct +catalog search (0265), or the dashboard stacks. + +## Decision + +On the leftover-map graphic display, caption each closest or farthest +pair segment with the same persisted leftover-map reconstruction +formatter as the pair-row `R̂` badge. A missing or non-finite `R̂` +omits that reconstruction caption and keeps the connecting line and +any leftover-map distance caption. Rank-0 origin cells still name +`R̂ 0.00` when that persisted reconstruction is finite. Do not invent +`R̂` from plotted coordinates or from a pixel inner product. Click a +post marker to open that post. Criterion markers are not post +buttons. The grouping comparison strip (ADR 0149) stays on its +reduced leftover payload and does not gain these reconstruction +captions. + +Do not add SQL. Do not edit shipped migrations. Do not persist inner +product, cosine, or length as separate columns. Do not invent a +leftover score. Do not invent a theta. + +## Consequences + +After `make seed`, closest and farthest leftover pairs sit above the +member list with the leftover-map graphic display of persisted `ξ` +and `ζ`, leftover-map axes name persisted Gabriel inertia share when +finite, leftover-map axis ticks name the same coordinates shown on +the pair row, pair segments name persisted leftover-map distance `d` +and persisted leftover-map reconstruction `R̂`, and pair segments name +persisted leftover-map explained leftover share `e` (ADR 0273); click a post marker +or a pair button opens that post. Hidden posts stay hidden. Rank-0 +unused axes still plot at the origin, still name zero leftover-map +axis share with a `0` tick, still name `d 0.00` when that distance is +persisted, and still name `R̂ 0.00` when that reconstruction is +persisted. When coordinates, reconstruction, and distance are all +finite, `R̂ = ξ_{1:2} · ζ_{1:2}` and `d = ‖ξ_{1:2} − ζ_{1:2}‖` +remain the same identities already persisted by ADR 0267. + +## Related + +Independent of leftover interaction-map persistence, leftover-criterion +evaluation landing, leftover residual disclosure, leftover observed +`Y` / expected `E`, leftover-map complete-case coverage, leftover-map +axis share persistence, leftover pairs on the grouping comparison +strip, two-axis leftover-map distance persistence, leftover-map rank, +leftover-map inner product, leftover-map cosine, leftover-map length, +leftover-map reconstruction persistence, leftover-map unexplained +leftover, leftover-map cross share, leftover-map unexplained leftover +share, leftover-map explained leftover share, leftover-map coordinate +persistence, leftover-map graphic display, leftover-map axis share on +the graphic display, leftover-map coordinate ticks, and leftover-map +distance on pair segments. + +## 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 +(LSIRM interaction `−γ‖ξ_j − ζ_i‖` after main effects +`α_j − β_i`; typically `p = 2` for the interaction map. Gabriel +reconstruction of the leftover cell is the two-axis inner product +`R̂ = ξ_{1:2} · ζ_{1:2}`.) diff --git a/docs/adr/0273-leftover-map-segment-explained-share.md b/docs/adr/0273-leftover-map-segment-explained-share.md new file mode 100644 index 000000000..3330e3104 --- /dev/null +++ b/docs/adr/0273-leftover-map-segment-explained-share.md @@ -0,0 +1,126 @@ +# ADR 0273 — Name leftover-map explained leftover share on graphic-display pair segments + +**Decision status:** Accepted +**Date:** 2026-08-29 + +Amends [ADR 0268](0268-leftover-map-graphic-display.md) and +[ADR 0049](0049-leftover-pair-report-ui.md). Independent of leftover-map +reconstruction on pair segments +([ADR 0272](0272-leftover-map-segment-reconstruction.md)), leftover-map +distance on pair segments ([ADR 0271](0271-leftover-map-segment-distance.md)), +leftover-map coordinate ticks ([ADR 0270](0270-leftover-map-coordinate-ticks.md)), +leftover-map axis share on the graphic display +([ADR 0269](0269-leftover-map-axis-share-plot.md)), leftover-map +explained leftover share persistence +([ADR 0266](0266-leftover-map-explained-share.md)), leftover-map +unexplained leftover share +([ADR 0233](0233-leftover-map-unexplained-share.md)), leftover-map +reconstruction persistence ([ADR 0201](0201-leftover-map-reconstruction.md)), +and leftover-map axis share persistence ([ADR 0148](0148-leftover-map-axis-share.md)). + +## Context + +ADR 0266 already persists leftover-map explained leftover share +`e = R̂² / R²` of raw residual after two-axis Gabriel reconstruction. +ADR 0201 already persists leftover-map reconstruction `R̂`. ADR 0272 +already names persisted `R̂` on the leftover-map graphic-display pair +segment. The segment still has no explained leftover share label, so a +buyer who reads `R̂²/R² 0.76` on the pair row cannot match that square +share to the graphic pair. Hiding `e` on the segment lets leftover-map +reconstruction `R̂`, leftover residual `R`, or leftover-map axis share +`x` be read as the leftover the truncated map explains even after +`R̂` is named on the segment. When `R`, `R̂`, `U`, `x`, `s`, and `e` +are finite, `e + s + x = 1`; the graphic must name the same persisted +`e` the pair row already shows. + +This increment names leftover-map graphic-display pair segments with +persisted leftover-map explained leftover share. It does not add +columns. It does not recompute `e` from `R̂` and `R` or from plotted +coordinates. It does not persist leftover-map inner product, cosine, or +length as separate columns. It does not land Post quality on the leftover +criterion. Leftover-map distance stays two-axis Euclidean. Do not invent +a leftover score. Do not invent a theta. + +The dashboard stack already used neighbouring leftover facts under +other numbers. This protected increment uses **0273** so it does not +collide with leftover-map reconstruction on pair segments (0272), +leftover-map distance on pair segments (0271), leftover-map coordinate +ticks (0270), leftover-map axis share on the graphic display (0269), +leftover-map graphic display (0268), leftover-map coordinates (0267 / +migration 0245), leftover-map explained leftover share persistence +(0266 / migration 0244), leftover-map unexplained leftover share +(0233 / migration 0233), leftover-map reconstruction persistence +(0201 / migration 0206), leftover-map cross share (0185), leftover +residual disclosure, leftover observed `Y` / expected `E`, leftover-map +rank, two-axis leftover-map distance persistence, leftover coverage, +leftover-map axis share persistence (0148), leftover interaction-map +persistence, occupational construct catalog search (0265), or the +dashboard stacks. + +## Decision + +On the leftover-map graphic display, caption each closest or farthest +pair segment with the same persisted leftover-map explained leftover +share formatter as the pair-row `R̂²/R²` badge. A missing or +non-finite `e` omits that explained leftover share caption and keeps +the connecting line and any leftover-map distance or reconstruction +caption. Rank-0 origin cells still name `R̂²/R² 0.00` when that +persisted share is finite. A finite share greater than 1 is shown, +never clamped. Do not invent `e` from `R̂` and `R` or from plotted +coordinates. Click a post marker to open that post. Criterion markers +are not post buttons. The grouping comparison strip (ADR 0149) stays on +its reduced leftover payload and does not gain these explained leftover +share captions. + +Do not add SQL. Do not edit shipped migrations. Do not persist inner +product, cosine, or length as separate columns. Do not invent a leftover +score. Do not invent a theta. + +## Consequences + +After `make seed`, closest and farthest leftover pairs sit above the +member list with the leftover-map graphic display of persisted `ξ` +and `ζ`, leftover-map axes name persisted Gabriel inertia share when +finite, leftover-map axis ticks name the same coordinates shown on +the pair row, pair segments name persisted leftover-map distance `d`, +persisted leftover-map reconstruction `R̂`, and persisted leftover-map +explained leftover share `e`; click a post marker or a pair button +opens that post. Hidden posts stay hidden. Rank-0 unused axes still +plot at the origin, still name zero leftover-map axis share with a `0` +tick, still name `d 0.00` when that distance is persisted, still name +`R̂ 0.00` when that reconstruction is persisted, and still name +`R̂²/R² 0.00` when that explained leftover share is persisted. When +`R`, `R̂`, `U`, `x`, `s`, and `e` are all finite, `e + s + x = 1` +remains the same identity already persisted by ADR 0266. + +## Related + +Independent of leftover interaction-map persistence, leftover-criterion +evaluation landing, leftover residual disclosure, leftover observed +`Y` / expected `E`, leftover-map complete-case coverage, leftover-map +axis share persistence, leftover pairs on the grouping comparison +strip, two-axis leftover-map distance persistence, leftover-map rank, +leftover-map inner product, leftover-map cosine, leftover-map length, +leftover-map reconstruction persistence, leftover-map unexplained +leftover, leftover-map cross share, leftover-map unexplained leftover +share, leftover-map explained leftover share persistence, leftover-map +coordinate persistence, leftover-map graphic display, leftover-map axis +share on the graphic display, leftover-map coordinate ticks, +leftover-map distance on pair segments, and leftover-map reconstruction +on pair segments. + +## 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 +(LSIRM interaction `−γ‖ξ_j − ζ_i‖` after main effects +`α_j − β_i`; typically `p = 2` for the interaction map. Gabriel +reconstruction of the leftover cell is the two-axis inner product +`R̂ = ξ_{1:2} · ζ_{1:2}`. Explained leftover share is the square share +`e = R̂² / R²` of that leftover.) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b5d31877b..5db914a52 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,125 @@ # Product & Technical Gap Baseline +> Exact-head rendered-UX overlay: 2026-08-31 12:16 KST. PR #802 +> implementation revision `1e972d7f6` retains persisted-only `d`, `R̂`, and +> `e` projection while moving dense segment captions off their plot segments +> and painting them with the existing background token. The focused 32-test +> plot selection, frontend lint, production build, and Storybook build pass. +> Fresh synthetic `Reports/LeftoverMapPlot/ClosestAndFarthest` renders were +> inspected at 1440-by-1000 and 390-by-844: the three captions are separated +> from each segment and remain legible over axes; the fixed-width plot remains +> intentionally horizontally scrollable at mobile width. Screenshots remain +> temporary audit evidence and are not committed. This later documentation +> overlay does not inherit those results; exact-head GitHub Checks and an +> independent approval remain required before protected merge. +> +> Exact-head release-contract overlay: 2026-08-31 11:46 KST. +> Protected `main` remains `cb187cadee5fb6c46d8a944815ccc154a1e028d1`; +> sixty-nine open PRs and ten open issues were enumerated in a fresh snapshot. +> PR #780 remains on normal squash auto-merge with its exact-head product and +> frontend checks successful, resolved review threads, and no independent +> approval; its central required-workflow failures remain external protected +> delivery blockers, so no merge SHA or protected-main Voice acceptance is +> claimed. The next direct-to-`main` candidate, PR #802, exposed a release +> identity conflict: Python package metadata and the frontend named v2.30.0 +> while `lineageweave.__version__` still named v2.20.0. Because that runtime +> value is persisted as Event Lineage reconstruction provenance, the mismatch +> was not documentation-only. Implementation revision +> `88270da43cdb4a75349d10ab612b99690df59f45` synchronizes the runtime identity +> and adds a repository contract test that binds Python metadata, runtime +> provenance, and frontend release identity. Fourteen focused persistence, +> channel-evidence, and release-contract tests pass. This later evidence-only +> overlay does not transfer those results to its own head; current-head hosted +> Checks and independent approval remain pending, and normal squash auto-merge +> is retained. No mathematical value is recomputed in LineageWeave: the +> leftover-map UI continues to render only persisted owner-produced values and +> leaves missing or non-finite evidence unavailable. No self-approval, bypass, +> force push, arbitrary weighting, or hidden-evidence substitution is used. +> +> Exact-head loop overlay: 2026-08-31 06:31 KST. Protected `main` is +> `cb187cadee5fb6c46d8a944815ccc154a1e028d1` (leftover-map coordinates, +> graphic, axis share, ticks, and segment distance through v2.28.0, #782). +> The live inventory contains 55 open PRs and 10 open issues. Parent #782 is +> protected-merged; its first child #802 was therefore retargeted from the +> merged feature branch to `main`, and its squash-history conflict is repaired +> without force-push while preserving ADR 0272/0273 and v2.29.0/v2.30.0. +> #771, #772, #774, and #780 remain exact-head blocked: normal squash +> auto-merge is armed, no independent APPROVE exists, and failed hosted gates +> remain fail-closed rather than being treated as product failures. #780's +> focused Voice authority tests, frontend suite, lint, and build pass locally; +> its desktop and mobile evidence distinguish the carrying Post action from +> the separately authorized derivation-evidence action. This is candidate +> evidence only: authenticated PostgreSQL API and rendered runtime proof are +> still required before marking the Voice acceptance boundary complete. +> +> Highest buyer-visible active gap in this slice: finish the protected parent- +> first delivery of persisted leftover-map reconstruction `R̂` and explained +> share `e` on graphic pair segments (#802), without recomputing either value in +> LineageWeave. The implementation consumes the already persisted owner result, +> omits missing/non-finite values, and has focused regression coverage. Parallel +> stacked branches currently reuse release numbers (v2.46.0, v2.47.0, v2.50.0, +> and v2.61.0); those branches are not release-ready and must be serialized and +> renumbered after their common parent merges. No stacked child may be retargeted +> or inherit Checks before its own parent is protected-merged. + +> Exact-head loop overlay: 2026-08-29 20:00 KST. Protected `main` is +> `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map +> explained leftover share, #775). Open ready PRs still lack independent +> APPROVE. #782 leftover-map coordinates + graphic + axis share + ticks +> + distance (v2.24.0–v2.28.0 / ADR 0267–0271) is on +> `9bdd3e4d6e47a34ac22fca228b8d2a23d8cecca9` with auto-merge SQUASH +> armed. Independent APPROVE is still required for protected main. +> #801 leftover-map reconstruction on pair segments (v2.29.0 / ADR 0272) +> squash-merged onto `feat/leftover-map-segment-distance-v2280` (not +> onto #782); v2.29 is replayed onto the leftover-coordinates branch as +> cherry-pick `5f21538e`. Do not squash-merge stacked leftover PRs onto +> an unprotected leftover base. Auto-merge squash remains armed on +> #782/#780/#774/#772/#771/#770. Drafts remain dirty against `main`. +> #96 stays closed as a weaker duplicate of #91 (`state_reason` still +> 403). GitHub writes through `gh`/MCP succeed (comment/create-PR/ +> auto-merge). Copilot review is not independent APPROVE. Do not +> self-approve. +> +> Next buyer increment on this cycle: leftover-map explained leftover +> share `e` on graphic-display pair segments (ADR 0273 / v2.30.0) +> delivered locally on +> `feat/leftover-map-segment-explained-share-v2300`. Caption each +> closest/farthest segment with persisted leftover-map explained leftover +> share so the pair-row `R̂²/R²` badge matches the graphic. UI-only; no +> new columns. Missing/non-finite `e` omits that explained leftover +> share caption. A share greater than 1 is shown, never clamped. Do not +> invent `e` from `R̂` and `R` or from plotted coordinates. Do not +> invent leftover scores. Stack onto leftover branch +> `feat/leftover-map-coordinates-v2240`; leave the PR open for +> independent review. The following buyer increment is leftover-map +> unexplained leftover share `s` on pair segments so `e + s + x = 1` +> is graphic-auditable. + +> Exact-head loop overlay: 2026-08-29 16:20 KST. Protected `main` is +> `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map +> explained leftover share, #775). Open ready PRs still lack independent +> APPROVE. #782 leftover-map coordinates + graphic + axis share + ticks +> (v2.24.0–v2.27.0 / ADR 0267–0270) is on +> `2a203bf8b75b987ba899a0006a312d81259b9124`. #800 leftover-map distance +> on pair segments (v2.28.0 / ADR 0271) is stacked on that leftover +> branch at `1374a830582c22808c7ec02d4ae4cf2f5da55985`. Auto-merge squash +> remains armed on #782/#780/#774/#772/#771/#770. Independent APPROVE is +> still required for protected main. Drafts remain dirty against `main`. +> #96 stays closed as a weaker duplicate of #91. GitHub writes through +> `gh`/MCP succeed (comment/create-branch/auto-merge). Copilot review is +> not independent APPROVE. Do not self-approve. Do not `gh pr merge` +> stacked leftover PRs onto an unprotected leftover base. +> +> Next buyer increment on this cycle: leftover-map reconstruction `R̂` +> on graphic-display pair segments (ADR 0272 / v2.29.0). Caption each +> closest/farthest segment with persisted leftover-map reconstruction +> so the pair-row `R̂` badge matches the graphic. UI-only; no new +> columns. Missing/non-finite `R̂` omits that reconstruction caption. +> Do not invent `R̂` from plotted coordinates. Do not invent leftover +> scores. Stack onto leftover branch +> `feat/leftover-map-segment-distance-v2280`; leave the PR open for +> independent review. + > Exact-head loop overlay: 2026-08-29 13:20 KST. Protected `main` is > `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map > explained leftover share, #775). Open ready PRs still lack independent diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index f426285a6..e26bd5c3b 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -5,8 +5,8 @@ operator-facing control you can click before changing product CSS. | Story | Operator next action | Token / module | |---|---|---| -| `Reports/LeftoverMapPlot` | Read the leftover-map graphic display of persisted `ξ` (posts) and `ζ` (criteria), match axis ticks to those coordinates and pair-segment `d` to leftover-map distance, then click a post marker to open that post. Leftover-map axes name persisted leftover-map axis share when finite. `ClosestAndFarthest`, `RankZeroOrigin`, `MissingCoordinates`, and `MissingAxisShare` cover two-pair maps, rank-0 origin with 0% share, a `0` tick, and `d 0.00`, omitted plots, and missing share that keeps existing leftover-map axis text. The plot does not invent a leftover score. | `LeftoverMapPlot`, `leftoverMapPlotLayout`, `leftoverMapPlotAxisShare`, `--color-primary`, `--color-palette-blue-mid` | -| `Reports/LeftoverPairList` | Read closest/farthest leftover pairs with named `R`, `Y`/`E`, rank, `U`, `s`, `e`, `x`, `R̂`, `ξ`/`ζ`, and `d`, then open that post. The leftover-map graphic display sits above the pair buttons when coordinates are finite, leftover-map axes name persisted leftover-map axis share, leftover-map axis ticks name persisted coordinates, and pair segments name persisted leftover-map distance. | `LeftoverPairList`, `LeftoverMapPlot`, `ticket-list`, `post-badge` | +| `Reports/LeftoverMapPlot` | Read the leftover-map graphic display of persisted `ξ` (posts) and `ζ` (criteria), match axis ticks to those coordinates, pair-segment `d` to leftover-map distance, pair-segment `R̂` to leftover-map reconstruction, and pair-segment `R̂²/R²` to leftover-map explained leftover share, then click a post marker to open that post. Leftover-map axes name persisted leftover-map axis share when finite. `ClosestAndFarthest`, `RankZeroOrigin`, `MissingCoordinates`, and `MissingAxisShare` cover two-pair maps, rank-0 origin with 0% share, a `0` tick, `d 0.00`, `R̂ 0.00`, and `R̂²/R² 0.00`, omitted plots, and missing share that keeps existing leftover-map axis text. The plot does not invent a leftover score. | `LeftoverMapPlot`, `leftoverMapPlotLayout`, `leftoverMapPlotAxisShare`, `--color-primary`, `--color-palette-blue-mid` | +| `Reports/LeftoverPairList` | Read closest/farthest leftover pairs with named `R`, `Y`/`E`, rank, `U`, `s`, `e`, `x`, `R̂`, `ξ`/`ζ`, and `d`, then open that post. The leftover-map graphic display sits above the pair buttons when coordinates are finite, leftover-map axes name persisted leftover-map axis share, leftover-map axis ticks name persisted coordinates, pair segments name persisted leftover-map distance, pair segments name persisted leftover-map reconstruction, and pair segments name persisted leftover-map explained leftover share. | `LeftoverPairList`, `LeftoverMapPlot`, `ticket-list`, `post-badge` | | `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Post/Recorded perspectives` | Read the imported primary and every evidence-connected additional Voice with its recorded truth state instead of flattening them into one compound category. `CombinedEvidence`, `RejectedEvidence`, and `NarrowViewport` cover desktop, rejected-evidence, and narrow layouts. | `VoicePerspectiveList`, `ticket-list`, `post-meta` | diff --git a/frontend/package.json b/frontend/package.json index cb6d1347a..8a01b19f8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.28.0", + "version": "2.30.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/LeftoverMapPlot.css b/frontend/src/components/LeftoverMapPlot.css index c96a691ae..619fddaee 100644 --- a/frontend/src/components/LeftoverMapPlot.css +++ b/frontend/src/components/LeftoverMapPlot.css @@ -97,6 +97,10 @@ .leftover-map-plot-segment-label { fill: var(--color-text); font-size: 0.65rem; + paint-order: stroke; + stroke: var(--color-background); + stroke-width: 3px; + stroke-linejoin: round; } .leftover-map-plot-item { diff --git a/frontend/src/components/LeftoverMapPlot.stories.tsx b/frontend/src/components/LeftoverMapPlot.stories.tsx index 16c869e26..aad15812e 100644 --- a/frontend/src/components/LeftoverMapPlot.stories.tsx +++ b/frontend/src/components/LeftoverMapPlot.stories.tsx @@ -80,6 +80,8 @@ export const RankZeroOrigin: Story = { observed_response: 1, expected_response: 1, leftover_map_rank: 0, + leftover_map_reconstruction: 0, + leftover_map_explained_share: 0, leftover_map_person_axis_1: 0, leftover_map_person_axis_2: 0, leftover_map_item_axis_1: 0, diff --git a/frontend/src/components/LeftoverMapPlot.test.tsx b/frontend/src/components/LeftoverMapPlot.test.tsx index 9a7960300..4e830bc8e 100644 --- a/frontend/src/components/LeftoverMapPlot.test.tsx +++ b/frontend/src/components/LeftoverMapPlot.test.tsx @@ -12,6 +12,8 @@ const PAIRS: LeftoverPair[] = [ criterion_code: "sales_lead_quality", leftover_distance: 0.12, leftover_residual: 0.4, + leftover_map_reconstruction: 0.248, + leftover_map_explained_share: 0.76, leftover_map_person_axis_1: 0.5, leftover_map_person_axis_2: 0.1, leftover_map_item_axis_1: 0.5, @@ -24,6 +26,8 @@ const PAIRS: LeftoverPair[] = [ criterion_code: "negative_sentiment", leftover_distance: 1.84, leftover_residual: -1.1, + leftover_map_reconstruction: -0.95, + leftover_map_explained_share: 0.6, leftover_map_person_axis_1: 0.9, leftover_map_person_axis_2: 0.8, leftover_map_item_axis_1: -0.7, @@ -45,7 +49,7 @@ describe("LeftoverMapPlot", () => { expect(screen.getByLabelText("Leftover-map graphic display")).toBeInTheDocument(); expect( screen.getByText( - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d. Click a post marker to open that post. The plot does not invent a leftover score.", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d, leftover-map reconstruction R̂, and leftover-map explained leftover share e. Click a post marker to open that post. The plot does not invent a leftover score.", ), ).toBeInTheDocument(); expect(screen.getByText("Post ξ")).toBeInTheDocument(); @@ -57,6 +61,10 @@ describe("LeftoverMapPlot", () => { expect(screen.getByLabelText("leftover-map axis 1 tick 0.00")).toBeInTheDocument(); expect(screen.getByLabelText("leftover-map distance d 0.12")).toBeInTheDocument(); expect(screen.getByLabelText("leftover-map distance d 1.84")).toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map reconstruction R̂ +0.25")).toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map reconstruction R̂ −0.95")).toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map explained leftover share R̂²/R² 0.76")).toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map explained leftover share R̂²/R² 0.60")).toBeInTheDocument(); const postMarker = screen.getByRole("button", { name: "Open leftover-map post Public post at ξ (+0.50, +0.10)", @@ -103,6 +111,8 @@ describe("LeftoverMapPlot", () => { leftover_distance: 0, leftover_residual: 0, leftover_map_rank: 0, + leftover_map_reconstruction: 0, + leftover_map_explained_share: 0, leftover_map_person_axis_1: 0, leftover_map_person_axis_2: 0, leftover_map_item_axis_1: 0, @@ -125,6 +135,27 @@ describe("LeftoverMapPlot", () => { expect(screen.getAllByLabelText("leftover-map axis 1 tick 0.00").length).toBeGreaterThan(0); expect(screen.queryByLabelText("leftover-map axis 1 tick +1.00")).not.toBeInTheDocument(); expect(screen.getByLabelText("leftover-map distance d 0.00")).toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map reconstruction R̂ 0.00")).toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map explained leftover share R̂²/R² 0.00")).toBeInTheDocument(); + }); + + it("renders every distinct persisted tick when rounded labels match", () => { + render( + , + ); + + expect(screen.getByLabelText("leftover-map axis 1 tick 0.00")).toBeInTheDocument(); + expect(screen.getAllByLabelText("leftover-map axis 1 tick +0.00")).toHaveLength(2); }); it("captions leftover-map axes with persisted leftover-map axis share", () => { @@ -184,4 +215,58 @@ describe("LeftoverMapPlot", () => { expect(screen.queryByLabelText(/leftover-map distance/)).not.toBeInTheDocument(); expect(screen.getByLabelText("leftover-map axis 1 tick +0.50")).toBeInTheDocument(); }); + + it("omits leftover-map reconstruction on a pair segment when R̂ is missing", () => { + render( + , + ); + expect(screen.queryByLabelText(/leftover-map reconstruction/)).not.toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map distance d 0.12")).toBeInTheDocument(); + }); + + it("omits leftover-map explained leftover share on a pair segment when e is missing", () => { + render( + , + ); + expect(screen.queryByLabelText(/leftover-map explained leftover share/)).not.toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map reconstruction R̂ +0.25")).toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map distance d 0.12")).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/LeftoverMapPlot.tsx b/frontend/src/components/LeftoverMapPlot.tsx index 143781a26..97ca77115 100644 --- a/frontend/src/components/LeftoverMapPlot.tsx +++ b/frontend/src/components/LeftoverMapPlot.tsx @@ -12,6 +12,8 @@ import { LEFTOVER_MAP_PLOT_CAPTION, LEFTOVER_MAP_PLOT_POST_ACTION, LEFTOVER_MAP_PLOT_SEGMENT_DISTANCE, + LEFTOVER_MAP_PLOT_SEGMENT_EXPLAINED_SHARE, + LEFTOVER_MAP_PLOT_SEGMENT_RECONSTRUCTION, LEFTOVER_MAP_PLOT_TICK, } from "../leftoverMapPlotLayout"; import "./LeftoverMapPlot.css"; @@ -48,11 +50,15 @@ function leftoverMapPlotAxisText( * Gabriel inertia share when finite, including rank-0 zero-share axes. * Axis ticks name persisted leftover-map coordinates so ξ / ζ on the * pair row match the plot. Pair segments name persisted leftover-map - * distance ``d`` so the pair-row badge matches the graphic. Omit that - * segment caption when distance is missing or non-finite. Omit that axis - * badge when share is missing or non-finite and keep the existing - * leftover-map axis text. Omit the plot when no pair has four finite - * leftover-map coordinates. Never invent a leftover score. + * distance ``d``, leftover-map reconstruction ``R̂``, and leftover-map + * explained leftover share ``e`` so the pair-row badges match the graphic. + * Omit that distance caption when ``d`` is missing or non-finite. Omit + * that reconstruction caption when ``R̂`` is missing or non-finite. Omit + * that explained leftover share caption when ``e`` is missing or + * non-finite. Omit that axis badge when share is missing or non-finite + * and keep the existing leftover-map axis text. Omit the plot when no + * pair has four finite leftover-map coordinates. Never invent a leftover + * score. */ export function LeftoverMapPlot({ pairs, @@ -114,7 +120,7 @@ export function LeftoverMapPlot({ {layout.ticks.map((tick) => ( @@ -151,6 +157,32 @@ export function LeftoverMapPlot({ {segment.distanceLabel} ) : null} + {segment.reconstructionLabel !== null ? ( + + {segment.reconstructionLabel} + + ) : null} + {segment.explainedShareLabel !== null ? ( + + {segment.explainedShareLabel} + + ) : null} ))} {layout.items.map((marker) => ( diff --git a/frontend/src/components/LeftoverPairList.tsx b/frontend/src/components/LeftoverPairList.tsx index c6a0afb57..75e500821 100644 --- a/frontend/src/components/LeftoverPairList.tsx +++ b/frontend/src/components/LeftoverPairList.tsx @@ -61,7 +61,9 @@ export type LeftoverPairListProps = { * persisted leftover-map axis share when finite. ADR 0270 ticks those * leftover-map axes at persisted ``ξ`` / ``ζ`` so the pair-row badge matches * the plot. ADR 0271 names persisted leftover-map distance ``d`` on those - * pair segments. Every badge still + * pair segments. ADR 0272 names persisted leftover-map reconstruction + * ``R̂`` on those pair segments. ADR 0273 names persisted leftover-map + * explained leftover share ``e`` on those pair segments. Every badge still * renders together before opening the named post. */ export function LeftoverPairList({ diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index b4b8d2fb6..4ef3a02a2 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -67,7 +67,9 @@ describe("i18n", () => { "leftover-map axis {axis} ({share}%)", "leftover-map axis {axis} tick {value}", "leftover-map distance {label}", - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d. Click a post marker to open that post. The plot does not invent a leftover score.", + "leftover-map reconstruction {label}", + "leftover-map explained leftover share {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d, leftover-map reconstruction R̂, and leftover-map explained leftover share e. Click a post marker to open that post. The plot does not invent a leftover score.", "Open leftover-map post {title} at ξ {person}", "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.", @@ -331,22 +333,22 @@ describe("i18n", () => { it.each([ [ "ko", - "IRT 주효과 이후 잔여 지도입니다. 축 눈금은 저장된 잔여 지도 좌표입니다. 쌍 선분은 잔여 지도 거리 d입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", + "IRT 주효과 이후 잔여 지도입니다. 축 눈금은 저장된 잔여 지도 좌표입니다. 쌍 선분은 잔여 지도 거리 d, 잔여 지도 재구성 R̂, 잔여 지도 설명 잔여 점유율 e입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", ], - ["zh", "IRT 主效应后的残差图。轴刻度标出已保存的残差图坐标。配对线段标出残差图距离 d。点击帖子标记打开该帖子。此图不会虚构残差分数。"], + ["zh", "IRT 主效应后的残差图。轴刻度标出已保存的残差图坐标。配对线段标出残差图距离 d、残差图重建 R̂ 与残差图已解释残差份额 e。点击帖子标记打开该帖子。此图不会虚构残差分数。"], [ "ja", - "IRT主効果後の残差マップです。軸目盛は保存済みの残差マップ座標です。ペア線分は残差マップ距離 d です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", + "IRT主効果後の残差マップです。軸目盛は保存済みの残差マップ座標です。ペア線分は残差マップ距離 d、残差マップ再構成 R̂、残差マップ説明済み残差割合 e です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", ], [ "vi", - "Bản đồ phần dư sau hiệu ứng chính IRT. Vạch trục ghi tọa độ bản đồ phần dư đã lưu. Đoạn cặp ghi khoảng cách bản đồ phần dư d. Nhấn dấu bài viết để mở bài đó. Hình này không tạo ra điểm phần dư.", + "Bản đồ phần dư sau hiệu ứng chính IRT. Vạch trục ghi tọa độ bản đồ phần dư đã lưu. Đoạn cặp ghi khoảng cách bản đồ phần dư d, tái dựng bản đồ phần dư R̂ và tỷ phần phần dư được giải thích e. Nhấn dấu bài viết để mở bài đó. Hình này không tạo ra điểm phần dư.", ], ] as const)("formats leftover-map graphic display caption in %s", (locale, expected) => { setLocale(locale); expect( t( - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d. Click a post marker to open that post. The plot does not invent a leftover score.", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d, leftover-map reconstruction R̂, and leftover-map explained leftover share e. Click a post marker to open that post. The plot does not invent a leftover score.", ), ).toBe(expected); }); @@ -371,6 +373,28 @@ describe("i18n", () => { expect(tf("leftover-map distance {label}", { label: "d 0.12" })).toBe(expected); }); + it.each([ + ["ko", "잔여 지도 재구성 R̂ +0.35"], + ["zh", "残差图重建 R̂ +0.35"], + ["ja", "残差マップ再構成 R̂ +0.35"], + ["vi", "tái dựng bản đồ phần dư R̂ +0.35"], + ] as const)("formats leftover-map segment reconstruction in %s", (locale, expected) => { + setLocale(locale); + expect(tf("leftover-map reconstruction {label}", { label: "R̂ +0.35" })).toBe(expected); + }); + + it.each([ + ["ko", "잔여 지도 설명 잔여 점유율 R̂²/R² 0.76"], + ["zh", "残差图已解释残差份额 R̂²/R² 0.76"], + ["ja", "残差マップ説明済み残差割合 R̂²/R² 0.76"], + ["vi", "tỷ phần phần dư được giải thích trên bản đồ phần dư R̂²/R² 0.76"], + ] as const)("formats leftover-map segment explained leftover share in %s", (locale, expected) => { + setLocale(locale); + expect(tf("leftover-map explained leftover share {label}", { label: "R̂²/R² 0.76" })).toBe( + expected, + ); + }); + it.each([ ["ko", "잔여 지도 축 1 (82%)"], ["zh", "残差图轴 1 (82%)"], diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index bfe170ef8..b4816f24c 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -592,8 +592,10 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis {axis} ({share}%)": "잔여 지도 축 {axis} ({share}%)", "leftover-map axis {axis} tick {value}": "잔여 지도 축 {axis} 눈금 {value}", "leftover-map distance {label}": "잔여 지도 거리 {label}", - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d. Click a post marker to open that post. The plot does not invent a leftover score.": - "IRT 주효과 이후 잔여 지도입니다. 축 눈금은 저장된 잔여 지도 좌표입니다. 쌍 선분은 잔여 지도 거리 d입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", + "leftover-map reconstruction {label}": "잔여 지도 재구성 {label}", + "leftover-map explained leftover share {label}": "잔여 지도 설명 잔여 점유율 {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d, leftover-map reconstruction R̂, and leftover-map explained leftover share e. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT 주효과 이후 잔여 지도입니다. 축 눈금은 저장된 잔여 지도 좌표입니다. 쌍 선분은 잔여 지도 거리 d, 잔여 지도 재구성 R̂, 잔여 지도 설명 잔여 점유율 e입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", "Open leftover-map post {title} at ξ {person}": "잔여 지도 글 {title} 열기 (ξ {person})", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": @@ -1172,8 +1174,10 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis {axis} ({share}%)": "残差图轴 {axis} ({share}%)", "leftover-map axis {axis} tick {value}": "残差图轴 {axis} 刻度 {value}", "leftover-map distance {label}": "残差图距离 {label}", - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d. Click a post marker to open that post. The plot does not invent a leftover score.": - "IRT 主效应后的残差图。轴刻度标出已保存的残差图坐标。配对线段标出残差图距离 d。点击帖子标记打开该帖子。此图不会虚构残差分数。", + "leftover-map reconstruction {label}": "残差图重建 {label}", + "leftover-map explained leftover share {label}": "残差图已解释残差份额 {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d, leftover-map reconstruction R̂, and leftover-map explained leftover share e. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT 主效应后的残差图。轴刻度标出已保存的残差图坐标。配对线段标出残差图距离 d、残差图重建 R̂ 与残差图已解释残差份额 e。点击帖子标记打开该帖子。此图不会虚构残差分数。", "Open leftover-map post {title} at ξ {person}": "打开残差图帖子 {title}(ξ {person})", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": @@ -1756,8 +1760,10 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis {axis} ({share}%)": "残差マップ軸 {axis} ({share}%)", "leftover-map axis {axis} tick {value}": "残差マップ軸 {axis} 目盛 {value}", "leftover-map distance {label}": "残差マップ距離 {label}", - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d. Click a post marker to open that post. The plot does not invent a leftover score.": - "IRT主効果後の残差マップです。軸目盛は保存済みの残差マップ座標です。ペア線分は残差マップ距離 d です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", + "leftover-map reconstruction {label}": "残差マップ再構成 {label}", + "leftover-map explained leftover share {label}": "残差マップ説明済み残差割合 {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d, leftover-map reconstruction R̂, and leftover-map explained leftover share e. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT主効果後の残差マップです。軸目盛は保存済みの残差マップ座標です。ペア線分は残差マップ距離 d、残差マップ再構成 R̂、残差マップ説明済み残差割合 e です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", "Open leftover-map post {title} at ξ {person}": "残差マップの投稿 {title} を開く(ξ {person})", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": @@ -2340,8 +2346,10 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis {axis} ({share}%)": "trục bản đồ phần dư {axis} ({share}%)", "leftover-map axis {axis} tick {value}": "vạch trục bản đồ phần dư {axis} {value}", "leftover-map distance {label}": "khoảng cách bản đồ phần dư {label}", - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d. Click a post marker to open that post. The plot does not invent a leftover score.": - "Bản đồ phần dư sau hiệu ứng chính IRT. Vạch trục ghi tọa độ bản đồ phần dư đã lưu. Đoạn cặp ghi khoảng cách bản đồ phần dư d. Nhấn dấu bài viết để mở bài đó. Hình này không tạo ra điểm phần dư.", + "leftover-map reconstruction {label}": "tái dựng bản đồ phần dư {label}", + "leftover-map explained leftover share {label}": "tỷ phần phần dư được giải thích trên bản đồ phần dư {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d, leftover-map reconstruction R̂, and leftover-map explained leftover share e. Click a post marker to open that post. The plot does not invent a leftover score.": + "Bản đồ phần dư sau hiệu ứng chính IRT. Vạch trục ghi tọa độ bản đồ phần dư đã lưu. Đoạn cặp ghi khoảng cách bản đồ phần dư d, tái dựng bản đồ phần dư R̂ và tỷ phần phần dư được giải thích e. Nhấn dấu bài viết để mở bài đó. Hình này không tạo ra điểm phần dư.", "Open leftover-map post {title} at ξ {person}": "Mở bài viết bản đồ phần dư {title} tại ξ {person}", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": diff --git a/frontend/src/leftoverMapPlotLayout.test.ts b/frontend/src/leftoverMapPlotLayout.test.ts index aa8cebe29..51f266522 100644 --- a/frontend/src/leftoverMapPlotLayout.test.ts +++ b/frontend/src/leftoverMapPlotLayout.test.ts @@ -23,6 +23,8 @@ function pair( leftover_map_item_axis_1: 0.5, leftover_map_item_axis_2: -0.02, leftover_distance: 0.12, + leftover_map_reconstruction: 0.248, + leftover_map_explained_share: 0.76, ...overrides, }; } @@ -208,6 +210,22 @@ describe("layoutLeftoverMapPlot", () => { ); }); + it("keeps distinct persisted coordinates that share a rounded tick label", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ + leftover_map_person_axis_1: 0.001, + leftover_map_item_axis_1: 0.004, + }), + ], + criterionLabel, + ); + const axis1 = layout?.ticks.filter((tick) => tick.axis === 1); + expect(axis1?.map((tick) => tick.value)).toEqual([0, 0.001, 0.004]); + expect(axis1?.map((tick) => tick.label)).toEqual(["0.00", "+0.00", "+0.00"]); + expect(new Set(axis1?.map((tick) => tick.x))).toHaveProperty("size", 3); + }); + it("names persisted leftover-map distance on pair segments without inventing a leftover score", () => { const layout = layoutLeftoverMapPlot( [ @@ -226,10 +244,11 @@ describe("layoutLeftoverMapPlot", () => { criterionLabel, ); expect(layout?.segments.map((segment) => segment.distanceLabel)).toEqual(["d 0.12", "d 1.84"]); - expect(layout?.segments[0]?.labelX).toBeCloseTo( - ((layout?.segments[0]?.x1 ?? 0) + (layout?.segments[0]?.x2 ?? 0)) / 2, - 5, - ); + const segment = layout?.segments[0]; + const midpointX = ((segment?.x1 ?? 0) + (segment?.x2 ?? 0)) / 2; + const midpointY = ((segment?.y1 ?? 0) + (segment?.y2 ?? 0)) / 2; + expect(Math.hypot((segment?.labelX ?? 0) - midpointX, (segment?.labelY ?? 0) - midpointY)) + .toBeCloseTo(24, 5); }); it("omits a leftover-map distance caption when d is missing or non-finite", () => { @@ -265,6 +284,201 @@ describe("layoutLeftoverMapPlot", () => { expect(layout?.segments[0]?.distanceLabel).toBe("d 0.12"); expect(layout?.segments[0]?.distanceLabel).not.toBe("d 1.00"); }); + + it("names persisted leftover-map reconstruction on pair segments without inventing a leftover score", () => { + const layout = layoutLeftoverMapPlot( + [ + pair(), + pair({ + pair_kind: "farthest", + post_id: "post-demo-spec", + criterion_code: "negative_sentiment", + leftover_map_person_axis_1: 0.9, + leftover_map_person_axis_2: 0.8, + leftover_map_item_axis_1: -0.7, + leftover_map_item_axis_2: -0.4, + leftover_distance: 1.84, + leftover_map_reconstruction: -0.95, + }), + ], + criterionLabel, + ); + expect(layout?.segments.map((segment) => segment.reconstructionLabel)).toEqual([ + "R\u0302 +0.25", + "R\u0302 \u22120.95", + ]); + expect(layout?.segments[0]?.reconstructionX).toBeCloseTo(layout?.segments[0]?.labelX ?? 0, 5); + expect(layout?.segments[0]?.reconstructionY).toBeGreaterThan(layout?.segments[0]?.labelY ?? 0); + }); + + it("omits a leftover-map reconstruction caption when R̂ is missing or non-finite", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ leftover_map_reconstruction: null }), + pair({ + pair_kind: "farthest", + leftover_map_reconstruction: Number.NaN, + leftover_map_item_axis_1: -0.7, + leftover_map_item_axis_2: -0.4, + criterion_code: "negative_sentiment", + }), + ], + criterionLabel, + ); + expect(layout?.segments.map((segment) => segment.reconstructionLabel)).toEqual([null, null]); + expect(layout?.segments.map((segment) => segment.distanceLabel)).toEqual(["d 0.12", "d 0.12"]); + }); + + it("does not invent leftover-map reconstruction from plotted coordinates", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ + leftover_map_person_axis_1: 1, + leftover_map_person_axis_2: 0, + leftover_map_item_axis_1: 1, + leftover_map_item_axis_2: 0, + leftover_map_reconstruction: 0.35, + }), + ], + criterionLabel, + ); + expect(layout?.segments[0]?.reconstructionLabel).toBe("R\u0302 +0.35"); + expect(layout?.segments[0]?.reconstructionLabel).not.toBe("R\u0302 +1.00"); + }); + + it("names rank-0 origin reconstruction R̂ 0.00 when that persisted value is finite", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ + leftover_map_person_axis_1: 0, + leftover_map_person_axis_2: 0, + leftover_map_item_axis_1: 0, + leftover_map_item_axis_2: 0, + leftover_distance: 0, + leftover_map_reconstruction: 0, + }), + ], + criterionLabel, + ); + expect(layout?.segments[0]?.reconstructionLabel).toBe("R\u0302 0.00"); + expect(layout?.segments[0]?.distanceLabel).toBe("d 0.00"); + }); + + it("names persisted leftover-map explained leftover share on pair segments without inventing a leftover score", () => { + const layout = layoutLeftoverMapPlot( + [ + pair(), + pair({ + pair_kind: "farthest", + post_id: "post-demo-spec", + criterion_code: "negative_sentiment", + leftover_map_person_axis_1: 0.9, + leftover_map_person_axis_2: 0.8, + leftover_map_item_axis_1: -0.7, + leftover_map_item_axis_2: -0.4, + leftover_distance: 1.84, + leftover_map_reconstruction: -0.95, + leftover_map_explained_share: 0.6, + }), + ], + criterionLabel, + ); + expect(layout?.segments.map((segment) => segment.explainedShareLabel)).toEqual([ + "R\u0302\u00b2/R\u00b2 0.76", + "R\u0302\u00b2/R\u00b2 0.60", + ]); + expect(layout?.segments[0]?.explainedShareX).toBeCloseTo(layout?.segments[0]?.labelX ?? 0, 5); + expect(layout?.segments[0]?.explainedShareY).toBeGreaterThan( + layout?.segments[0]?.reconstructionY ?? 0, + ); + }); + + it("keeps complete caption stacks inside top and bottom plot edges", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ + leftover_map_person_axis_1: -1, + leftover_map_person_axis_2: -1, + leftover_map_item_axis_1: 1, + leftover_map_item_axis_2: -1, + }), + pair({ + pair_kind: "farthest", + post_id: "post-demo-spec", + criterion_code: "negative_sentiment", + leftover_map_person_axis_1: 1, + leftover_map_person_axis_2: 1, + leftover_map_item_axis_1: -1, + leftover_map_item_axis_2: 1, + }), + ], + criterionLabel, + ); + + for (const segment of layout?.segments ?? []) { + expect(segment.labelY).toBeGreaterThanOrEqual(12); + expect(segment.explainedShareY).toBeLessThanOrEqual((layout?.height ?? 0) - 4); + } + }); + + it("omits a leftover-map explained leftover share caption when e is missing or non-finite", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ leftover_map_explained_share: null }), + pair({ + pair_kind: "farthest", + leftover_map_explained_share: Number.NaN, + leftover_map_item_axis_1: -0.7, + leftover_map_item_axis_2: -0.4, + criterion_code: "negative_sentiment", + }), + ], + criterionLabel, + ); + expect(layout?.segments.map((segment) => segment.explainedShareLabel)).toEqual([null, null]); + expect(layout?.segments.map((segment) => segment.reconstructionLabel)).toEqual([ + "R\u0302 +0.25", + "R\u0302 +0.25", + ]); + }); + + it("does not invent leftover-map explained leftover share from reconstruction or residual", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ + leftover_map_person_axis_1: 1, + leftover_map_person_axis_2: 0, + leftover_map_item_axis_1: 1, + leftover_map_item_axis_2: 0, + leftover_map_reconstruction: 1, + leftover_map_explained_share: 0.76, + }), + ], + criterionLabel, + ); + expect(layout?.segments[0]?.explainedShareLabel).toBe("R\u0302\u00b2/R\u00b2 0.76"); + expect(layout?.segments[0]?.explainedShareLabel).not.toBe("R\u0302\u00b2/R\u00b2 1.00"); + }); + + it("names rank-0 origin explained leftover share e 0.00 when that persisted value is finite", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ + leftover_map_person_axis_1: 0, + leftover_map_person_axis_2: 0, + leftover_map_item_axis_1: 0, + leftover_map_item_axis_2: 0, + leftover_distance: 0, + leftover_map_reconstruction: 0, + leftover_map_explained_share: 0, + }), + ], + criterionLabel, + ); + expect(layout?.segments[0]?.explainedShareLabel).toBe("R\u0302\u00b2/R\u00b2 0.00"); + expect(layout?.segments[0]?.reconstructionLabel).toBe("R\u0302 0.00"); + expect(layout?.segments[0]?.distanceLabel).toBe("d 0.00"); + }); }); describe("formatLeftoverMapDistance", () => { diff --git a/frontend/src/leftoverMapPlotLayout.ts b/frontend/src/leftoverMapPlotLayout.ts index c0d09473a..638ad9513 100644 --- a/frontend/src/leftoverMapPlotLayout.ts +++ b/frontend/src/leftoverMapPlotLayout.ts @@ -1,15 +1,19 @@ /** Gabriel leftover-map graphic display of persisted ``ξ_{1:2}`` / ``ζ_{1:2}``. * Leftover-map axis share captions axes 1 and 2 when finite (ADR 0269). * Axis ticks name persisted leftover-map coordinates (ADR 0270). - * Pair segments name persisted leftover-map distance ``d`` (ADR 0271). + * Pair segments name persisted leftover-map distance ``d`` (ADR 0271), + * persisted leftover-map reconstruction ``R̂`` (ADR 0272), and persisted + * leftover-map explained leftover share ``e`` (ADR 0273). */ import { formatLeftoverMapCoordinatePair } from "./leftoverMapCoordinates"; +import { formatLeftoverMapExplainedShare } from "./leftoverMapExplainedShare"; +import { formatLeftoverMapReconstruction } from "./leftoverMapReconstruction"; import { formatSignedLeftoverValue } from "./leftoverMapUnexplained"; import type { LeftoverPair } from "./api"; export const LEFTOVER_MAP_PLOT_CAPTION = - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d. Click a post marker to open that post. The plot does not invent a leftover score."; + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d, leftover-map reconstruction R̂, and leftover-map explained leftover share e. Click a post marker to open that post. The plot does not invent a leftover score."; export const LEFTOVER_MAP_PLOT_POST_ACTION = "Open leftover-map post {title} at ξ {person}"; @@ -20,6 +24,12 @@ export const LEFTOVER_MAP_PLOT_TICK = export const LEFTOVER_MAP_PLOT_SEGMENT_DISTANCE = "leftover-map distance {label}"; +export const LEFTOVER_MAP_PLOT_SEGMENT_RECONSTRUCTION = + "leftover-map reconstruction {label}"; + +export const LEFTOVER_MAP_PLOT_SEGMENT_EXPLAINED_SHARE = + "leftover-map explained leftover share {label}"; + export const PLOT_WIDTH = 480; export const PLOT_HEIGHT = 320; export const PLOT_PADDING = 40; @@ -27,6 +37,10 @@ export const PLOT_TICK_LENGTH = 6; const UNIT_DISPLAY_SPAN = 2; const COLLAPSED_SPAN = 1e-12; const COINCIDENT_LABEL_OFFSET = 14; +const SEGMENT_LABEL_OFFSET = 24; +const RECONSTRUCTION_LABEL_OFFSET = 12; +const SEGMENT_LABEL_TOP_INSET = 12; +const SEGMENT_LABEL_BOTTOM_INSET = 4; export type LeftoverMapPlottablePair = { pair_kind: LeftoverPair["pair_kind"]; @@ -34,6 +48,8 @@ export type LeftoverMapPlottablePair = { post_title: string; criterion_code: string; leftover_distance?: number | null; + leftover_map_reconstruction?: number | null; + leftover_map_explained_share?: number | null; leftover_map_person_axis_1?: number | null; leftover_map_person_axis_2?: number | null; leftover_map_item_axis_1?: number | null; @@ -59,8 +75,14 @@ export type LeftoverMapPlotSegment = { x2: number; y2: number; distanceLabel: string | null; + reconstructionLabel: string | null; + explainedShareLabel: string | null; labelX: number; labelY: number; + reconstructionX: number; + reconstructionY: number; + explainedShareX: number; + explainedShareY: number; }; export type LeftoverMapPlotTick = { @@ -129,17 +151,17 @@ function toSvg( } function uniqueCoordinateTicks(values: number[]): { value: number; label: string }[] { - const byLabel = new Map(); + const seen = new Set(); + const ticks: { value: number; label: string }[] = []; for (const value of values) { const label = formatSignedLeftoverValue(value); - if (label === null) { + if (label === null || seen.has(value)) { continue; } - if (!byLabel.has(label)) { - byLabel.set(label, value); - } + seen.add(value); + ticks.push({ value, label }); } - return [...byLabel.entries()].map(([label, value]) => ({ value, label })); + return ticks; } function leftoverMapCoordinateTicks( @@ -186,12 +208,45 @@ function leftoverMapSegmentLabelPosition( y2: number, ): { labelX: number; labelY: number } { const coincident = Math.abs(x1 - x2) < 0.01 && Math.abs(y1 - y2) < 0.01; + const midpointX = (x1 + x2) / 2; + const midpointY = (y1 + y2) / 2; + if (coincident) { + return { + labelX: midpointX, + labelY: midpointY - COINCIDENT_LABEL_OFFSET, + }; + } + const deltaX = x2 - x1; + const deltaY = y2 - y1; + const length = Math.hypot(deltaX, deltaY); return { - labelX: (x1 + x2) / 2, - labelY: coincident ? (y1 + y2) / 2 - COINCIDENT_LABEL_OFFSET : (y1 + y2) / 2, + labelX: midpointX - (deltaY / length) * SEGMENT_LABEL_OFFSET, + labelY: midpointY + (deltaX / length) * SEGMENT_LABEL_OFFSET, }; } +function leftoverMapStackedCaptionY( + labelY: number, + stackedAbove: number, + captionSpacing = RECONSTRUCTION_LABEL_OFFSET, +): number { + return stackedAbove > 0 ? labelY + stackedAbove * captionSpacing : labelY; +} + +function leftoverMapCaptionSpacing(height: number, captionCount: number): number { + if (captionCount <= 1) { + return 0; + } + const availableStackHeight = Math.max( + 0, + height - SEGMENT_LABEL_TOP_INSET - SEGMENT_LABEL_BOTTOM_INSET, + ); + return Math.min( + RECONSTRUCTION_LABEL_OFFSET, + availableStackHeight / (captionCount - 1), + ); +} + export function layoutLeftoverMapPlot( pairs: LeftoverMapPlottablePair[], criterionLabel: (criterionCode: string) => string, @@ -274,6 +329,42 @@ export function layoutLeftoverMapPlot( pair.leftover_map_item_axis_2 as number, ); const distanceLabel = formatLeftoverMapDistance(pair.leftover_distance); + const reconstructionLabel = formatLeftoverMapReconstruction( + pair.leftover_map_reconstruction, + ); + const explainedShareLabel = formatLeftoverMapExplainedShare( + pair.leftover_map_explained_share, + ); + const labelPosition = leftoverMapSegmentLabelPosition( + personPos.x, + personPos.y, + itemPos.x, + itemPos.y, + ); + const captionCount = [distanceLabel, reconstructionLabel, explainedShareLabel].filter( + (label) => label !== null, + ).length; + const captionSpacing = leftoverMapCaptionSpacing(height, captionCount); + const maximumLabelY = Math.max( + SEGMENT_LABEL_TOP_INSET, + height - + SEGMENT_LABEL_BOTTOM_INSET - + Math.max(0, captionCount - 1) * captionSpacing, + ); + const labelY = Math.min( + Math.max(labelPosition.labelY, SEGMENT_LABEL_TOP_INSET), + maximumLabelY, + ); + const reconstructionY = leftoverMapStackedCaptionY( + labelY, + distanceLabel !== null && reconstructionLabel !== null ? 1 : 0, + captionSpacing, + ); + const explainedShareY = leftoverMapStackedCaptionY( + labelY, + (distanceLabel !== null ? 1 : 0) + (reconstructionLabel !== null ? 1 : 0), + captionSpacing, + ); segments.push({ pairKind: pair.pair_kind === "farthest" ? "farthest" : "closest", postId: pair.post_id, @@ -283,7 +374,14 @@ export function layoutLeftoverMapPlot( x2: itemPos.x, y2: itemPos.y, distanceLabel, - ...leftoverMapSegmentLabelPosition(personPos.x, personPos.y, itemPos.x, itemPos.y), + reconstructionLabel, + explainedShareLabel, + reconstructionX: labelPosition.labelX, + reconstructionY, + explainedShareX: labelPosition.labelX, + explainedShareY, + ...labelPosition, + labelY, }); } diff --git a/frontend/src/leftoverMapPlotSmallCanvas.test.ts b/frontend/src/leftoverMapPlotSmallCanvas.test.ts new file mode 100644 index 000000000..75d18ab13 --- /dev/null +++ b/frontend/src/leftoverMapPlotSmallCanvas.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { layoutLeftoverMapPlot, type LeftoverMapPlottablePair } from "./leftoverMapPlotLayout"; + +const completeCaptionPair: LeftoverMapPlottablePair = { + pair_kind: "closest", + post_id: "post-small-canvas", + post_title: "Small canvas post", + criterion_code: "sales_lead_quality", + leftover_distance: 0.12, + leftover_map_reconstruction: 0.25, + leftover_map_explained_share: 0.76, + leftover_map_person_axis_1: -1, + leftover_map_person_axis_2: -1, + leftover_map_item_axis_1: 1, + leftover_map_item_axis_2: -1, +}; + +describe("layoutLeftoverMapPlot small-canvas caption bounds", () => { + it("keeps a complete three-caption stack inside the requested 20px height", () => { + const layout = layoutLeftoverMapPlot( + [completeCaptionPair], + (criterionCode) => criterionCode, + { height: 20 }, + ); + + expect(layout).not.toBeNull(); + expect(layout?.height).toBe(20); + const segment = layout?.segments[0]; + expect(segment).toBeDefined(); + expect(segment?.labelY).toBeGreaterThanOrEqual(12); + expect(segment?.reconstructionY).toBeGreaterThanOrEqual(segment?.labelY ?? 0); + expect(segment?.explainedShareY).toBeGreaterThanOrEqual(segment?.reconstructionY ?? 0); + expect(segment?.explainedShareY).toBeLessThanOrEqual(16); + }); +}); diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 45c371fa7..ff0c6867e 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -130,4 +130,4 @@ "serialize_lineage_analysis_result", ] -__version__ = "2.20.0" +__version__ = "2.30.0" diff --git a/pyproject.toml b/pyproject.toml index 7744aef87..839a61c8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.28.0" +version = "2.30.0" 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/tests/test_package_version.py b/tests/test_package_version.py new file mode 100644 index 000000000..cb85f136e --- /dev/null +++ b/tests/test_package_version.py @@ -0,0 +1,20 @@ +"""Release identity stays consistent across Python and frontend artifacts.""" + +from __future__ import annotations + +import json +import tomllib +from pathlib import Path + +from lineageweave import __version__ + + +def test_release_versions_are_synchronized() -> None: + """Runtime provenance must use the same version as shipped package metadata.""" + repository_root = Path(__file__).resolve().parents[1] + project = tomllib.loads((repository_root / "pyproject.toml").read_text(encoding="utf-8")) + frontend = json.loads( + (repository_root / "frontend" / "package.json").read_text(encoding="utf-8") + ) + + assert __version__ == project["project"]["version"] == frontend["version"] diff --git a/uv.lock b/uv.lock index f94e79cf4..b21381b50 100644 --- a/uv.lock +++ b/uv.lock @@ -685,7 +685,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.28.0" +version = "2.30.0" source = { editable = "." } dependencies = [ { name = "certifi" },