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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.d/2.12.8-leftover-criterion-evaluation-landing.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 10 additions & 7 deletions docs/adr/0049-leftover-pair-report-ui.md
Comment thread
seonghobae marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions docs/adr/0158-leftover-criterion-evaluation-landing.md
Original file line number Diff line number Diff line change
@@ -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`.
6 changes: 6 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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(
Expand Down
Loading
Loading