diff --git a/AGENTS.md b/AGENTS.md index c4207f381..6544da08d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -258,8 +258,8 @@ Each accepted file runs with `psql -X -v ON_ERROR_STOP=1`, so a failure 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 / 0162 / 0163 / -0164 / 0182) are computed in `lineageweave/leftover_pairs.py` from the +Period leftover pairs (ADR 0017 / 0018 / 0048 / 0049 / 0119 / 0158 / 0162 / +0163 / 0164 / 0182) 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 @@ -268,7 +268,8 @@ persist to `report_leftover_pair` with signed residual `R`, observed plus leftover-map rank so rank 0 is not read as structure, and unexplained leftover `U = R − R̂` next to leftover-map distance `d` after two-axis Gabriel reconstruction. They sit above the member -list so a click opens that post. Two-axis reconstruction `R̂` is not +list so a click opens that post with the leftover criterion current +in Post quality (ADR 0158). 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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f25a29b24..46a8dd7e2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -596,7 +596,7 @@ on those same fixed parameters (Kim, 2006 FIPC). After scoring, information at the group's mean θ (Lord, 1980 max-info CAT). Rankings persist to `report_item_information`. After those IRT main effects, residual SVD leftover pairs on two Gabriel axes (Jeon et al., 2021; -ADR 0017 / 0048 / 0119 / 0162 / 0163 / 0164 / 0182) persist to +ADR 0017 / 0048 / 0049 / 0119 / 0158 / 0162 / 0163 / 0164 / 0182) persist to `report_leftover_pair` with signed residual `R`, observed `Y`, expected `E[Y|θ, item]`, full leftover-map rank, and unexplained leftover `U = R − R̂` named on the pair row. Leftover-map axis share (Gabriel diff --git a/CHANGELOG.d/2.12.8-leftover-criterion-evaluation-landing.md b/CHANGELOG.d/2.12.8-leftover-criterion-evaluation-landing.md new file mode 100644 index 000000000..853524e03 --- /dev/null +++ b/CHANGELOG.d/2.12.8-leftover-criterion-evaluation-landing.md @@ -0,0 +1,9 @@ +# 2.12.8 Leftover pair click lands Post quality on the named criterion + +## Changed + +- Clicking a leftover closest/farthest pair now opens that post with the + leftover criterion current in Post quality (IRT): the heading is + focused, the named criterion row is `aria-current`, and the next action + names the leftover score to read (ADR 0158). Home-list and report-member + opens do not carry leftover focus. diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e9eb1fc..94e653987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -186,6 +186,17 @@ All notable changes to this project are documented here. Format follows leftover pairs above the member list still open that post. Never invent a leftover score or a theta. +## [2.12.8] - 2026-08-23 + +### Changed + +- Clicking a leftover closest or farthest pair now opens that post with + the leftover criterion current in Post quality (IRT). The Post quality + heading is focused, the named criterion row is `aria-current`, and the + next action names the leftover score to read (Jeon leftover map, + ADR 0158). Home-list and report-member opens do not carry leftover + focus. + ## [2.12.6] - 2026-08-20 ### Added diff --git a/docs/adr/0049-leftover-pair-report-ui.md b/docs/adr/0049-leftover-pair-report-ui.md index 37beb78cb..3d939f8b2 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -5,7 +5,8 @@ **Amended by:** [ADR 0162](0162-leftover-residual-disclosure.md) (signed residual R); [ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E); [ADR 0164](0164-leftover-map-rank.md) (full map rank); -[ADR 0182](0182-leftover-map-unexplained.md) (unexplained leftover U) +[ADR 0182](0182-leftover-map-unexplained.md) (unexplained leftover U); +[ADR 0158](0158-leftover-criterion-evaluation-landing.md) (criterion evaluation landing) ## Context @@ -30,14 +31,16 @@ leftover structure, and unexplained leftover names "leftover map leaves unexplained `U` after IRT main effects; open this post to read the named criterion" when present. A missing unexplained leftover keeps the existing next action. -Clicking the button opens that post with the same handler as a member -row. Residual naming is [ADR 0162](0162-leftover-residual-disclosure.md), -observed/expected naming is [ADR 0163](0163-leftover-observed-expected.md), -rank naming is [ADR 0164](0164-leftover-map-rank.md), unexplained -leftover naming is [ADR 0182](0182-leftover-map-unexplained.md). +Clicking the button opens that post with leftover focus so Post +quality marks the named criterion current (ADR 0158). Residual naming +is [ADR 0162](0162-leftover-residual-disclosure.md), observed/expected +naming is [ADR 0163](0163-leftover-observed-expected.md), rank naming +is [ADR 0164](0164-leftover-map-rank.md), unexplained leftover naming +is [ADR 0182](0182-leftover-map-unexplained.md). After `make seed`, closest and farthest leftover pairs sit above the -member list. Click a pair to open that post. +member list. Click a pair to open that post with the leftover +criterion current in Post quality. Missing leftover rows render nothing — never a placeholder pair. A hidden post never appears as a leftover pair. diff --git a/docs/adr/0158-leftover-criterion-evaluation-landing.md b/docs/adr/0158-leftover-criterion-evaluation-landing.md new file mode 100644 index 000000000..ac80e6d38 --- /dev/null +++ b/docs/adr/0158-leftover-criterion-evaluation-landing.md @@ -0,0 +1,51 @@ +# ADR 0158 — Leftover pair click lands Post quality on the named criterion + +**Decision status:** Accepted +**Date:** 2026-08-23 + +## Context + +ADR 0049 puts closest and farthest leftover post–criterion pairs above +the period-report member list. Clicking a pair already opens that post. +The reader still has to hunt through Post quality (IRT) for the named +criterion. The leftover residual (Jeon et al., 2021, eq. 3; Gabriel +1971 biplot) is a post–criterion fact, not a post-only fact. + +Event Lineage landing (ADR 0078) already shows the pattern: the click +must name the next action and mark the current node. Leftover pairs +must not reuse the Event Lineage `fromReportMember` path. That path +reorders Keyman and evaluation under Event Lineage. A leftover click +is about the IRT leftover criterion, not about reconstructing the DAG. + +## Decision + +Pass leftover focus with the leftover-pair click: `pair_kind` and +`criterion_code`. Opening the post: + +1. Focuses the **Post quality (IRT)** heading. +2. Marks the leftover criterion row `aria-current="true"`. +3. Shows a leftover-criterion next action under that heading: + “{criterion} is the leftover criterion this post sat closest to / + farthest from after main effects. Read that Post quality score next.” + +The leftover-pair button next action is “Open this post so the leftover +criterion is current in Post quality.” Home-list and report-member opens +do not carry leftover focus, so they do not show leftover copy or mark +a criterion current. + +Do not invent leftover scores. Do not persist a second theta. Do not mix +this landing into the leftover interaction-map stack (ADR 0121). + +## Consequences + +`SelectPostOptions.fromLeftoverPair` is the only leftover-focus carrier. +Evaluation rows already returned by `GET /api/posts/{id}/evaluation` +are sufficient; no new API. Missing leftover rows still render nothing +(ADR 0049). A hidden post still never appears as a leftover pair. + +## Related + +Depends on [ADR 0048](0048-persist-lsirm-leftover-pairs.md), +[ADR 0049](0049-leftover-pair-report-ui.md), and +[ADR 0003](0003-fast-mlsirm-report-integration.md). +Independent of leftover-map persistence on `feat/persist-lsirm-interaction-map-v2127`. diff --git a/frontend/src/App.css b/frontend/src/App.css index 01e491796..fc6c39c04 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -279,6 +279,12 @@ box-shadow: inset 0 0 0 1px var(--color-accent-info); } +.evaluation-criterion[aria-current="true"] { + outline: 2px solid var(--color-accent-info); + outline-offset: 2px; + border-radius: 4px; +} + .post-badge { font-size: 0.75rem; opacity: 0.7; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 58499682e..a7c586f01 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -3732,6 +3732,7 @@ describe("App, authenticated", () => { expect(closestPair).toHaveTextContent("rank 1"); expect(closestPair).toHaveTextContent("U +0.05"); expect(closestPair).toHaveTextContent("d 0.12"); + expect(closestPair).toHaveAccessibleName("Open leftover closest pair: Public post · sales-lead"); expect(farthestPair).toHaveTextContent("Farthest leftover: Specification revision requested · negative"); expect(farthestPair).toHaveTextContent( "Leftover map leaves unexplained U −0.25 after IRT main effects. Open this post to read negative.", @@ -3817,6 +3818,37 @@ describe("App, authenticated", () => { await screen.findByRole("button", { name: /open leftover closest pair: public post/i }), ); await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(await screen.findByRole("heading", { name: "Post quality (IRT)" })).toHaveFocus(); + expect(await screen.findByRole("status", { name: "Leftover criterion next action" })).toHaveTextContent( + "sales-lead is the leftover criterion this post sat closest to after main effects. Read that Post quality score next.", + ); + expect((await screen.findByText("Sales-lead specificity: 3")).closest("li")).toHaveAttribute( + "aria-current", + "true", + ); + expect(screen.getByText("Constructive stance: 2").closest("li")).not.toHaveAttribute("aria-current"); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await userEvent.click( + await screen.findByRole("button", { + name: /open leftover farthest pair: specification revision requested/i, + }), + ); + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(await screen.findByRole("heading", { name: "Post quality (IRT)" })).toHaveFocus(); + expect(await screen.findByRole("status", { name: "Leftover criterion next action" })).toHaveTextContent( + "negative is the leftover criterion this post sat farthest from after main effects. Read that Post quality score next.", + ); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await userEvent.click(await screen.findByRole("button", { name: /open report post: public post/i })); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(screen.queryByRole("status", { name: "Leftover criterion next action" })).not.toBeInTheDocument(); + expect((await screen.findByText("Sales-lead specificity: 3")).closest("li")).not.toHaveAttribute( + "aria-current", + ); }); it("opens Event Lineage, Keyman, and evaluation from a report member click", async () => { @@ -3826,6 +3858,7 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: /open report post: public post/i })); await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); expect(screen.getByText("Constructive stance: 2")).toBeInTheDocument(); + expect(screen.queryByRole("status", { name: "Leftover criterion next action" })).not.toBeInTheDocument(); expect(screen.getAllByText(/Ada West/).length).toBeGreaterThan(0); expect(screen.getAllByLabelText("A-100 lineage")).toHaveLength(1); expect(screen.getByRole("status", { name: "Event Lineage next action" })).toHaveTextContent( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 90d0d4b5b..829cd509e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1329,18 +1329,36 @@ function KeymanPanel({ ); } +function leftoverCriterionNextAction( + criterion: string, + pairKind: "closest" | "farthest", +): string { + if (pairKind === "farthest") { + return tf( + "{criterion} is the leftover criterion this post sat farthest from after main effects. Read that Post quality score next.", + { criterion }, + ); + } + return tf( + "{criterion} is the leftover criterion this post sat closest to after main effects. Read that Post quality score next.", + { criterion }, + ); +} + function EvaluationPanel({ postId, accessToken, responses, canExtract, onEvaluated, + leftoverFocus, }: { postId: string; accessToken: string; responses: EvaluationResponse[] | null; canExtract: boolean; onEvaluated: (rows: EvaluationResponse[]) => void; + leftoverFocus?: { pairKind: "closest" | "farthest"; criterionCode: string } | null; }) { const [evaluating, setEvaluating] = useState(false); const [error, setError] = useState(null); @@ -1370,7 +1388,9 @@ function EvaluationPanel({ return (
-

{t("Post quality (IRT)")}

+

+ {t("Post quality (IRT)")} +

{canExtract && !orchestratorOff && (
{t("Evidence operations")} @@ -1380,6 +1400,14 @@ function EvaluationPanel({
)}
+ {leftoverFocus ? ( +

+ {leftoverCriterionNextAction( + criterionShortLabel(leftoverFocus.criterionCode), + leftoverFocus.pairKind, + )} +

+ ) : null} {error &&

{error}

} {responses === null ? (

{t("Loading evaluation...")}

@@ -1388,7 +1416,13 @@ function EvaluationPanel({ ) : (