fix(backend): pick summary events across the whole session, and label chatter (#603) #605

Merged
claude-bot merged 2 commits from fix/compose-fill-and-chatter into main 2026-09-11 15:30:44 +00:00
Contributor

Closes #603.

The bug

select_beats_for_compose took one beat per time bucket, then filled the spare slots story kinds first, then earliest first, and within a bucket it also picked earliest first. So:

  • a late event the model labelled other came last whatever the cap was;
  • a session's ending lost every tie, which is why the "What's next?" hook kept coming out weak;
  • raising the cap (#600) only bought the next-earliest leftovers, including chatter.

The fix

  1. Spare slots follow density. They're shared out by largest remainder, in proportion to each bucket's unpicked beats. Ties go to the denser bucket, then spread and centred, never to the first bucket by default. Every non-empty bucket keeps at least one slot, and none gets more than it holds.
  2. Picks within a bucket spread across its time. A bucket with k > 1 picks takes evenly spaced beats, including both ends (half-up integer rounding, so indices never collide).
  3. Both ends of the session are anchored. A single-pick first bucket takes its earliest beat (#589's opening guarantee), and a single-pick last bucket takes its latest. When over the cap, the first and last eligible story events are always picked. Kind now only decides single-pick buckets in the middle of the session.
  4. Chatter is labelled, not omitted. Off-topic talk (other games, food, real life) is recorded with type table_admin, the kind that now means "not part of the story", instead of being expected to disappear. This is #596's lesson again: the model didn't omit the Phasmophobia talk, it recorded it as other.

The result is still deterministic, re-sorted chronologically, and the identity function at or under the cap.

Measured without the GPU

A replay of the old and new selectors on the real stored events of three dev runs at cap 36 (the script and data stay out of the repo):

Run Picker Cant City First event Last event
03b3acfa (43 story events) old → new 0/1 → 1/1 1/2 → 2/2 yes → yes yes → yes
ace1414f (60) old → new 0/1 → 0/1 1/1 → 1/1 no → yes yes → yes
b0613da6 (54) old → new 0/1 → 1/1 0/1 → 1/1 yes → yes no → yes

In ace1414f the Cant is the 4th of five reveals in a bucket that gets two picks. The spread takes the 1st and 5th, so it's a better sample of the session, not a guarantee for any one event, and the docstring says so.

Still to measure: the chatter label changes model output, so it gets one live summary-only rerun on dev after merge.

Tests

  • Full backend suite: 3316 passed, 13 skipped.
  • The selection and table-admin files: 148 passed. New tests cover:
    • density allocation, including an exhaustive check of the arithmetic
    • no bias towards early buckets on ties
    • the spread, including an exhaustive _spread check
    • both anchors, even against a reveal, and the single-bucket case
    • a parametrised check that the first and last eligible beats are always picked
    • a request-level test that the chatter rule reaches the wire
  • Tests whose expected picks changed, each because of the ending anchor or the spread:
    • test_one_event_comes_out_of_each_slice_of_the_clock
    • test_table_admin_does_not_take_a_slice_of_the_clock
    • test_within_one_slice_the_kind_that_carries_it_wins, now using a middle slice
    • the kinds-over-filler test
  • Lint: ruff 0.4.4 check and format --check are clean.

Deferred: renaming the event log's "table-admin notes" line to "table notes" is a frontend change, waiting on #602.

🤖 Generated with Claude Code

Closes #603. ## The bug `select_beats_for_compose` took one beat per time bucket, then filled the spare slots **story kinds first, then earliest first**, and within a bucket it also picked earliest first. So: - a late event the model labelled `other` came last whatever the cap was; - a session's ending lost every tie, which is why the "What's next?" hook kept coming out weak; - raising the cap (#600) only bought the next-earliest leftovers, including chatter. ## The fix 1. **Spare slots follow density.** They're shared out by largest remainder, in proportion to each bucket's unpicked beats. Ties go to the denser bucket, then spread and centred, never to the first bucket by default. Every non-empty bucket keeps at least one slot, and none gets more than it holds. 2. **Picks within a bucket spread across its time.** A bucket with k > 1 picks takes evenly spaced beats, including both ends (half-up integer rounding, so indices never collide). 3. **Both ends of the session are anchored.** A single-pick first bucket takes its earliest beat (#589's opening guarantee), and a single-pick last bucket takes its latest. **When over the cap, the first and last eligible story events are always picked.** Kind now only decides single-pick buckets in the middle of the session. 4. **Chatter is labelled, not omitted.** Off-topic talk (other games, food, real life) is recorded with type `table_admin`, the kind that now means "not part of the story", instead of being expected to disappear. This is #596's lesson again: the model didn't omit the Phasmophobia talk, it recorded it as `other`. The result is still deterministic, re-sorted chronologically, and the identity function at or under the cap. ## Measured without the GPU A replay of the old and new selectors on the real stored events of three dev runs at cap 36 (the script and data stay out of the repo): | Run | Picker | Cant | City | First event | Last event | |---|---|---|---|---|---| | 03b3acfa (43 story events) | old → new | 0/1 → **1/1** | 1/2 → **2/2** | yes → yes | yes → yes | | ace1414f (60) | old → new | 0/1 → 0/1 | 1/1 → 1/1 | **no → yes** | yes → yes | | b0613da6 (54) | old → new | 0/1 → **1/1** | 0/1 → **1/1** | yes → yes | **no → yes** | In ace1414f the Cant is the 4th of five reveals in a bucket that gets two picks. The spread takes the 1st and 5th, so it's a better sample of the session, not a guarantee for any one event, and the docstring says so. **Still to measure:** the chatter label changes model output, so it gets one live summary-only rerun on dev after merge. ## Tests - **Full backend suite:** 3316 passed, 13 skipped. - **The selection and table-admin files:** 148 passed. New tests cover: - density allocation, including an exhaustive check of the arithmetic - no bias towards early buckets on ties - the spread, including an exhaustive `_spread` check - both anchors, even against a reveal, and the single-bucket case - a parametrised check that the first and last *eligible* beats are always picked - a request-level test that the chatter rule reaches the wire - **Tests whose expected picks changed,** each because of the ending anchor or the spread: - `test_one_event_comes_out_of_each_slice_of_the_clock` - `test_table_admin_does_not_take_a_slice_of_the_clock` - `test_within_one_slice_the_kind_that_carries_it_wins`, now using a middle slice - the kinds-over-filler test - **Lint:** ruff 0.4.4 `check` and `format --check` are clean. **Deferred:** renaming the event log's "table-admin notes" line to "table notes" is a frontend change, waiting on #602. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Fill across time. After #589's one beat per non-empty time bucket, the
slots left by empty buckets went to one queue of every unpicked beat,
story kinds first and then earliest first -- kind deciding which part of
the session got the slots, plus an early bias. They are now shared out
by largest remainder, in proportion to the unpicked beats each bucket
still holds (_share_leftover_slots). Ties on the fraction go to the
denser bucket, then are spaced evenly and centred across the tied
buckets in time order, never first-come. Inside a bucket the existing
rank (kind tier, then time) still picks. Identity under the cap, exactly
`limit` over it, table_admin removed before bucketing, unverified
absent above the cap, pure/deterministic, chronological output: all
unchanged.

test_the_kinds_that_carry_a_session_are_kept_over_the_ones_that_fill_it_in
pinned the old session-wide kind-first fill (four story beats in the
first 40 s pulled in over filler elsewhere); rewritten to assert kind
winning inside a crowded stretch, which is what #589 intended.

On the three stored real runs at cap 36 the second half gains picks
where it was busy (54-event run: 16 -> 19 of 36), but the Thieves' Cant
and mid-sized-city events are still cut there by the within-bucket
earliest-first tiebreak; the docstring says so rather than claiming a fix.

Label chatter. The extraction rule "leave out chatter that is not about
the game" did not hold; the model recorded it as other/social and the
prose narrated it. The table_admin rule now covers anything not part of
the story -- running the game, or talk that is not about the game --
with its own examples. "Treat suggestions as NOT performed" is kept.
Request-level test asserts the rule reaches the wire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(backend): spread a busy bucket's picks and anchor the session's ending (#603)
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 52s
CI / Docker image build (pull_request) Successful in 26s
CI / Bot/backend version sync (pull_request) Successful in 39s
CI / Backend lint (ruff) (pull_request) Successful in 42s
CI / Bot tests and audit (pull_request) Successful in 1m52s
CI / Frontend tests, audit, and build (pull_request) Successful in 2m15s
CI / Backend migration, tests, and audit (pull_request) Successful in 7m34s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 18m8s
5f2aec29b2
The proportional fill in ce7a6e2 sent spare slots to the late stretch but
did not recover the two #603 events: inside each ~2-minute bucket the
earliest beat of the same kind still won (Cant third of three with two
slots; city second of two in the last bucket with one).

Within a bucket with k picks, after the unchanged largest-remainder share:
- k > 1: spread across the bucket's beats in time order, both ends
  included, round-half-up(i * (n - 1) / (k - 1)) in integers (_spread).
  Strictly increasing for k <= n, so exactly k, no dedupe needed; Python's
  half-to-even round() would collide (1.5 and 2.5 -> 2). Kind does not
  enter a spread.
- k == 1: first bucket takes its earliest beat (#589 opening), last
  bucket its latest (ending, for the closing hook), others by rank (kind
  tier, then time); a lone bucket keeps the earliest.
So above the cap, with limit >= 2, the first and last verified story beats
are always picked; a parametrised test pins that property.

Tests whose expectations moved, each because of the ending anchor or the
spread: test_one_event_comes_out_of_each_slice_of_the_clock and
test_table_admin_does_not_take_a_slice_of_the_clock ([0,20,40] ->
[0,20,50]); test_within_one_slice_the_kind_that_carries_it_wins (now a
middle slice, since the first is the opening anchor); the kinds-over-filler
test (rewritten to single-slot slices); and four #603 tests from ce7a6e2.

On the stored real runs at cap 36, the 54-event run now picks both the
Cant and the city event; the other run still loses the Cant (fourth of
five reveals in a two-slot bucket).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot scheduled this pull request to auto merge when all checks succeed 2026-09-11 15:20:54 +00:00
claude-bot deleted branch fix/compose-fill-and-chatter 2026-09-11 15:30:45 +00:00
Sign in to join this conversation.
No description provided.