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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,14 @@ and are not a leftover score. Complete-case coverage (ADR 0168) persists to
`report_leftover_map_coverage` and captions the pair list with how
many scored posts entered the map.

Period leftover pairs (ADR 0017 / 0018 / 0048 / 0049 / 0149) are computed in
`lineageweave/leftover_pairs.py` from the residual after a real
GRM/GPCM score, never invented. Missing cells stay out of the
Gabriel factorization. Closest and farthest post–criterion pairs
persist to `report_leftover_pair` and sit above the member list so
a click opens that post. The grouping comparison strip reuses that
authorized leftover store; a leftover pair for a hidden post is omitted.

Comment on lines +279 to +286

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Duplicate leftover-pairs paragraph in AGENTS.md

The change adds a second 'Period leftover pairs' paragraph beside the existing one, re-listing a shorter ADR set and dropping the R/Y/E/rank/U/d detail the original documents. Two overlapping statements of the same policy invite drift.

Open in Devin Review

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

`frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`,
pnpm via Corepack -- do not add a second Node package manager or a
floating Node version):
Expand Down
6 changes: 5 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,8 @@ many scored posts entered the factorization. Results persist to
`report_period_score` / `report_member_score`.
`GET /api/reports/{grouping}` lists the trend;
`GET /api/reports/{grouping}/{period}` is ABAC-filtered;
`GET /api/reports/compare/{period}` is the home-page grouping strip;
`GET /api/reports/compare/{period}` is the home-page grouping strip
and carries the same ABAC-filtered leftover pairs (ADR 0149);
`POST .../rebuild` scores every grouping kind (post_admin). `make seed`
folds A-100/B-200 Event Lineage fixtures (and the Riverbend calendar
post) that already have constructed IRT cells into the same shared
Expand All @@ -620,6 +621,9 @@ closest/farthest pairs (signed residual `R`, observed `Y`, expected
effects) above the member list, leftover-map axis share for residual
SVD axes 1 and 2, and complete-case coverage captions (map used N of M
scored posts), plus the

closest/farthest pairs above the member list, leftover pairs on the
grouping comparison strip, and the
Comment on lines +624 to +626

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Garbled duplicated clause in ARCHITECTURE.md

The edit inserts a blank line and a duplicated clause into a sentence that already lists the closest/farthest pairs above the member list, producing a broken, doubly-listed sentence. Prose only, but likely an editing slip.

Open in Devin Review

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

PU / corp / thread comparison -- never a placeholder. TEPP is unchanged.

## Phase 6b: Knowledge Graph as a real Ontology + Semantic Layer
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.d/2.12.17-leftover-pairs-comparison-strip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# 2.12.17 — Leftover pairs on the grouping comparison strip

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Changelog fragment reuses a released version number

The new changelog fragment and PR title use 2.12.17, but CHANGELOG.md already ships released [2.12.17] and [2.12.18] sections. The feature itself lands under [Unreleased], so behavior is unaffected, but the reused version label can confuse changelog assembly.

Open in Devin Review

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


## Added

- `GET /api/reports/compare/{period}` carries ABAC-filtered leftover
pairs (ADR 0149). After `make seed`, the A-100 comparison row names
leftover pairs; click opens that post. Hidden leftover posts stay
hidden. Do not invent leftover numbers.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ All notable changes to this project are documented here. Format follows
(complete-case)”; incomplete rows stay excluded, never filled with
zero.

- The grouping comparison strip now names leftover post–criterion
pairs on each visible row (ADR 0149). After `make seed`, open a
leftover pair on A-100 from the strip to read that post. A leftover
pair for a hidden post is omitted. Distances come from the same
authorized leftover store as the period-report list; they do not
invent a leftover score.

### Fixed

- Event Lineage's DAG no longer leaves a linear (no-branch) reconstruct
Expand Down
57 changes: 57 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,60 @@ migration replay (ADR 0166), docstring coverage, and the measurement
boundary are all stated in [AGENTS.md](AGENTS.md) -- read it before
changing code, tests, or runtime policy rather than restating anything
here.
=======

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Merge conflict marker committed into CLAUDE.md

A literal ======= git merge conflict marker sits at CLAUDE.md:57, trailed by a duplicated block of analysis-run, cutoff, and endpoint rules through the end of the file. The artifact was never resolved, and the duplicated text forks rules the file's own opening forbids restating.

Prompt for agents
CLAUDE.md contains an unresolved git merge conflict. Line 57 is a bare `=======` conflict marker, and lines 58 through the end of the file (113) are a duplicated, forked copy of rules that are already stated in the section above (Analysis-run states, Cutoff knowledge, endpoint rules) and in AGENTS.md. Remove the `=======` marker and the entire duplicated block so the file ends cleanly at the '## Where the rest lives' section, keeping the intended single-source pointer form that CLAUDE.md's opening ('Do not fork those rules here') requires.
Open in Devin Review

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

period-report run on the same snapshot (ADR 0013 / ADR 0024). The TEPP path goes through `tepp_client`. A missing
transport or an unused accepted envelope is Failed
(`tepp_not_available` / `tepp_result_not_persisted`). Do not invent a
theta or a local psychometric substitute. The home list caption stays
`kind · status · entity`; the machine failure code is detail-only
(ADR 0014). Open a Failed TEPP row, then connect a live TEPP
transport. A failed lineage row retries reconstruction -- it does not
mention TEPP. A failed period-report row rebuilds the report. A
pending TEPP row does not claim a calibrated measurement. A pending
lineage row says reconstruction has not started yet.
Digest prefixes stay audible; hover a prefix to read the full digest.
Opening a cutoff title shows the live post. Titles marked updated
after cutoff were rewritten after the run; the opened body names
both clocks and shows **Body this run knew** beside the live
rewrite. Compare those two texts before treating the live body as
reconstructed evidence (ADR 0016 / 0025).
`POST /api/analysis-runs` records Pending lineage only on an
authorized cutoff capture (ADR 0017). TEPP and period-report kinds
are 422. The Request button waits until affiliated corps load; choose
a corp if the token walks more than one. `POST /api/analysis-runs/{id}/start`
commits Running plus a durable outbox row, then reconstructs that
frozen cutoff bag (ADR 0021 / ADR 0023) or submits TEPP through
`tepp_client` (ADR 0022). A missing transport or unused accepted
envelope is Failed. Failed TEPP is terminal — connect a TEPP
transport from that Failed row. Create does not invent a Pending
TEPP row. Do not invent a theta. Hover the Result prefix to read
the parent-choice digest.
After `make seed`, open **Period report · Succeeded · Demo Corp**,
then **Open period report 2026-W02**. The home week is already
2026-W02, so the grouping comparison strip lands on Demo Corp. Report
grouping is Corporate entity and Demo Corp is current. The focused
chip name contains `Corporate entity: Demo Corp` and the persisted
mean θ. The period-report panel says Demo Corp is the opened grouping
and to read its mean θ and member posts, then open a post. Those
members land immediately under that next action, ahead of Other Corp
and the week strip. After `make seed`, leftover closest/farthest pairs
sit above the member list with leftover-map rank (rank 0 names no
leftover structure) and unexplained leftover `U` next to leftover-map
distance `d`. Leftover-map axis share badges name Gabriel inertia
of axes 1 and 2; open a leftover pair to read the post–criterion cell.
The shares do not invent a leftover score. Opening Public post names the next action: read
Event Lineage, Keyman, and evaluation on that post. The popup Event
Lineage DAG marks that post current. After that current node, the
popup names Keyman and evaluation as the next read. After landed
evaluation, the popup names the first Keyman as the next read. After
landed Ada West related, the popup names the first related node as
the next read. After that next action, the popup lands Priya Nair
related nodes. After those related nodes land, the popup names Ask
about this lineage as the next read. After that next action, the
popup lands Ask about this lineage. After landed chat, the popup names
the first Ask. After that next action, the popup lands the first Ask
answer. After landed first Ask answer, the popup names the first
cited source. After that next action, the popup lands the first cited
evidence. Changing the week first still
focuses the report period field. Mean θ stays on the period-report
panel.
19 changes: 18 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2430,7 +2430,24 @@ async def compare_period_groupings(
]
if not members:
continue
visible.append({**row, "members": [], "post_count": len(members)})
leftover_pairs = [
pair
for pair in row.get("leftover_pairs", [])
if _can_see_post(account, pair)
and not _is_synthetic_demo_member(pair, demo_entity_ids)
]
leftover_pairs = [
{key: value for key, value in pair.items() if key != "has_real_source_context"}
for pair in leftover_pairs
]
Comment thread
seonghobae marked this conversation as resolved.
visible.append(
{
**row,
"members": [],
"leftover_pairs": leftover_pairs,
"post_count": len(members),
}
)
Comment thread
seonghobae marked this conversation as resolved.
Comment on lines +2433 to +2450

@devin-ai-integration devin-ai-integration Bot Aug 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Comparison strip changes from counts-only to per-post identifiers

The compare endpoint sets members to [] and previously exposed only post_count. The new leftover_pairs now return post_id, post_title, visibility_code, and corporate_entity_id (report_ingestion.py). Each pair is ABAC- and demo-filtered like the members, so only visible posts appear, and the shape matches the existing report endpoint. The comparison strip's exposure surface widens from aggregate counts to per-post identifiers.

Open in Devin Review

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

return {"period_code": period_code, "groupings": visible}


Expand Down
36 changes: 36 additions & 0 deletions backend/app/report_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,28 @@ async def fetch_period_comparison(
members_by_key: dict[tuple[str, str], list[asyncpg.Record]] = defaultdict(list)
for row in members:
members_by_key[(row["grouping_kind"], row["grouping_key"])].append(row)
# Safe SQL: the source-context expression is an immutable schema fragment; grouping filters are bound.
leftover = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
f"""
select lp.grouping_kind, lp.grouping_key, lp.pair_kind, lp.post_id,
lp.criterion_code, lp.leftover_distance, lp.leftover_residual,
p.post_title, p.visibility_code, p.corporate_entity_id,
({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context
from report_leftover_pair lp
join source_post p on p.post_id = lp.post_id
where lp.period_code = $1 and lp.rubric_version = $2
and lp.grouping_kind = any($3::text[])
order by lp.grouping_kind, lp.grouping_key,
case lp.pair_kind when 'closest' then 0 else 1 end,
p.post_title
""",
period_code,
RUBRIC_VERSION,
list(GROUPING_KINDS),
)
leftover_by_key: dict[tuple[str, str], list[asyncpg.Record]] = defaultdict(list)
for row in leftover:
leftover_by_key[(row["grouping_kind"], row["grouping_key"])].append(row)
payload: list[dict[str, Any]] = []
for row in rows:
label = await resolve_grouping_label(conn, row["grouping_kind"], row["grouping_key"])
Expand All @@ -997,6 +1019,20 @@ async def fetch_period_comparison(
}
for member in members_by_key.get((row["grouping_kind"], row["grouping_key"]), [])
],
"leftover_pairs": [
{
"pair_kind": str(pair["pair_kind"]),
"post_id": str(pair["post_id"]),
"post_title": pair["post_title"],
"criterion_code": str(pair["criterion_code"]),
"leftover_distance": float(pair["leftover_distance"]),
"leftover_residual": float(pair["leftover_residual"]),
"visibility_code": pair["visibility_code"],
"corporate_entity_id": str(pair["corporate_entity_id"]),
"has_real_source_context": bool(pair["has_real_source_context"]),
}
for pair in leftover_by_key.get((row["grouping_kind"], row["grouping_key"]), [])
],
Comment on lines 1019 to +1035

@devin-ai-integration devin-ai-integration Bot Aug 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Comparison leftover query omits residual-detail columns

fetch_period_comparison returns only the fields the strip renders (kind, post, criterion, distance, residual), unlike the report path which also returns observed/expected/rank/unexplained. The frontend types those extras as optional and the strip does not use them, so the narrower payload is safe.

Open in Devin Review

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

}
)
return payload
Expand Down
9 changes: 9 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5064,6 +5064,15 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token,
if row["grouping_kind"] == "thread_group"
}
assert threads["A-100"] > threads["B-200"]
leftover_thread = next(
row
for row in compare.json()["groupings"]
if row["grouping_kind"] == "thread_group" and row["grouping_label"] == "A-100"
)
leftover_kinds = {pair["pair_kind"] for pair in leftover_thread.get("leftover_pairs", [])}
assert leftover_kinds <= {"closest", "farthest"}
assert all(pair["post_title"] for pair in leftover_thread.get("leftover_pairs", []))
assert all(pair["leftover_distance"] >= 0 for pair in leftover_thread.get("leftover_pairs", []))


def test_seed_period_report_includes_fixture_event_lineage_posts(
Expand Down
3 changes: 3 additions & 0 deletions docs/adr/0049-leftover-pair-report-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,6 @@ next action, not only the distance.
Depends on [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and
[ADR 0003](0003-fast-mlsirm-report-integration.md). Complete-case
coverage of the leftover map is [ADR 0168](0168-leftover-map-complete-case-coverage.md).

[ADR 0003](0003-fast-mlsirm-report-integration.md). The grouping
comparison strip reuses this leftover store ([ADR 0149](0149-leftover-pairs-on-comparison-strip.md)).
Comment on lines +59 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Orphaned ADR link in Related section

The appended Related paragraph opens with a stray ADR 0003. link that belongs to no sentence; ADR 0003 is already cited two lines above. The intended reference is ADR 0149 in the sentence that follows.

Suggested change
[ADR 0003](0003-fast-mlsirm-report-integration.md). The grouping
comparison strip reuses this leftover store ([ADR 0149](0149-leftover-pairs-on-comparison-strip.md)).
The grouping
comparison strip reuses this leftover store ([ADR 0149](0149-leftover-pairs-on-comparison-strip.md)).
Open in Devin Review

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

42 changes: 42 additions & 0 deletions docs/adr/0149-leftover-pairs-on-comparison-strip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# ADR 0149 — Leftover pairs on the grouping comparison strip

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

## Context

ADR 0048 persists closest and farthest leftover post–criterion pairs.
ADR 0049 shows those pairs above the period-report member list. The
home-page grouping comparison strip (`GET /api/reports/compare/{period}`)
already names mean θ per PU / corp / thread so a buyer can switch
grouping without opening the period-report list first. That strip
does not yet name leftover pairs, so the buyer still has to switch
grouping before they can open a leftover post.

Leftover pairs are already authorized rows. Denormalizing them onto
the comparison strip would invent a second leftover store.

## Decision

The comparison payload carries the same ABAC-filtered `leftover_pairs`
as the period-report payload. Each visible comparison row may name
closest and farthest leftover pairs. Clicking a leftover pair on the
strip opens that post with the same handler as a leftover pair on the
period-report list. A leftover pair for a hidden post is omitted the
same way a hidden member is.

Do not invent leftover numbers or a second theta. Missing leftover
rows render nothing.

## Consequences

After `make seed`, the A-100 comparison row names leftover pairs.
Open a leftover pair from the strip to read the post–criterion cell.
The Period reports member list still shows leftover pairs above
members (ADR 0049). This slice only adds the same authorized leftover
store to the comparison strip.

## Related

Depends on [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and
[ADR 0049](0049-leftover-pair-report-ui.md).
27 changes: 27 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,16 @@ describe("App, authenticated", () => {
mean_theta: 0.81,
post_count: 4,
link_method: "fipc",
leftover_pairs: [
{
pair_kind: "closest",
post_id: "post-1",
post_title: "Public post",
criterion_code: "sales_lead_specificity",
leftover_distance: 0.12,
leftover_residual: 0.4,
},
],
},
],
}),
Expand Down Expand Up @@ -3765,6 +3775,11 @@ describe("App, authenticated", () => {
expect(screen.getByRole("status")).toHaveTextContent(
"A-100 is the opened grouping. Read its mean θ and member posts below, then open a post.",
);
expect(
screen.getByRole("button", {
name: /open leftover closest pair from comparison: public post/i,
}),
).toHaveTextContent("Closest leftover: Public post · sales-lead");
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/api/reports/thread_group/2026-W02"),
Expand All @@ -3782,6 +3797,18 @@ describe("App, authenticated", () => {
expect(periodInput).toHaveValue("2026-W03");
});

it("opens a leftover pair post from the comparison strip", async () => {
stubBackend();
render(<App showLabPanels />);

await userEvent.click(
await screen.findByRole("button", {
name: /open leftover closest pair from comparison: public post/i,
}),
);
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
});

it("opens a leftover pair post from the report panel", async () => {
stubBackend();
render(<App showLabPanels />);
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3588,6 +3588,37 @@ function ReportsPanel({
<span className="post-badge">mean θ {row.mean_theta.toFixed(2)}</span>
<span className="post-badge">{row.post_count} posts</span>
</button>
{row.leftover_pairs && row.leftover_pairs.length > 0 && (
<ul className="ticket-list" aria-label={`Leftover pairs for ${row.grouping_label}`}>
{row.leftover_pairs.map((pair) => {
const kindLabel =
pair.pair_kind === "farthest" ? "Farthest leftover" : "Closest leftover";
const nextAction =
pair.pair_kind === "farthest"
? "Open this post to read the criterion it sat farthest from after main effects."
: "Open this post to read the criterion it sat closest to after main effects.";
const criterion = criterionShortLabel(pair.criterion_code);
return (
<li
key={`${row.grouping_kind}:${row.grouping_key}:${pair.pair_kind}:${pair.post_id}:${pair.criterion_code}`}
className="ticket-list-item"
>
<button
className="post-list-item"
aria-label={`Open leftover ${pair.pair_kind} pair from comparison: ${pair.post_title} · ${criterion}`}
onClick={() => onSelectPost(pair.post_id)}
>
<span className="ticket-title">
{kindLabel}: {pair.post_title} · {criterion}
</span>
<span className="post-badge">{nextAction}</span>
<span className="post-badge">d {pair.leftover_distance.toFixed(2)}</span>
</button>
</li>
);
})}
</ul>
)}
Comment thread
seonghobae marked this conversation as resolved.
Comment on lines +3591 to +3621

@devin-ai-integration devin-ai-integration Bot Aug 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Comparison strip duplicates leftover-pair markup instead of reusing the shared component

The report panel renders leftover pairs through the shared LeftoverPairList component, while the comparison strip inlines its own markup at App.tsx. The two surfaces can drift over time; the inline version deliberately shows only distance d.

Open in Devin Review

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

</li>
))}
</ul>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,7 @@ export interface GroupingComparisonRow {
mean_theta: number;
post_count: number;
link_method: string;
leftover_pairs?: LeftoverPair[];
}

export interface PeriodComparison {
Expand Down
2 changes: 1 addition & 1 deletion tests/test_static_sql_review_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
)
ASYNC_STATEMENT_METHODS = {"execute", "fetch", "fetchrow", "fetchval"}
SQL_REVIEW_RULE = "python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli"
EXPECTED_SQL_SUPPRESSION_COUNT = 35
EXPECTED_SQL_SUPPRESSION_COUNT = 36


@pytest.mark.parametrize("relative_path", SQL_REVIEW_PATHS)
Expand Down
Loading