feat(backend): label the GM's table admin instead of narrating it (#596) #599

Merged
claude-bot merged 3 commits from feat/596-table-admin-beats into main 2026-09-11 06:26:58 +00:00
Contributor

Closes #596.

In the Gemma 4 replay, verified "events" included the GM fixing token positions, explaining a term, and working out which check reading a journal needs. They verify because the GM really said those lines. They clutter the event log, compete for the 30 events the summary is written from, and reach the prose: run 4 has "the group's positions were noted" and "When the GM asked if anyone other than Viquilonto (Viq) would examine the journal". #584 showed that telling this model to omit something doesn't hold, so this has it label instead.

What changes

  • A table_admin beat kind covers the table running the game: rules calls, which check to roll, token moves, sheet admin, and prompting the players.
    • It's in BEAT_TYPES. The prompt's type line and the json_schema enum llama.cpp enforces are both built from that one list, and table_admin is last in it.
    • _coerce_beat accepts "table-admin" and "Table Admin".
    • No migration: session_beats.kind is unconstrained text.
  • Extraction prompt: "record it, with type table_admin", with examples written for the prompt, so a mislabel costs a folded row, never a lost event. Two existing rules are reworded to match:
    • The "table talk" rule is narrowed to chatter that isn't about the game at all.
    • The #584 rule now says to label a GM answer table_admin unless the fiction changes, where it used to say not to record it. That was the omission instruction that didn't hold.
  • Compose: select_beats_for_compose removes table_admin beats before the #589 time slicing, so they take no slice. render_beats_for_compose and unrepresented_beats skip them too, so none can be narrated or retried as "dropped".
  • Dedupe tie-break (_story_over_table_admin, in both of dedupe_beats' rules): when two copies of one event differ only in that one is table_admin, the story copy wins. Verification still comes first, so a verified copy always beats an unverified one (#333).
  • answer_shaped_beats (#584) skips table_admin beats, so its count keeps measuring answers that still pose as events.
  • A check that only reports: one INFO line per run counts verified GM-only other beats that read like table admin ("The GM asks/explains/moves…", present or past tense). It shows whether the model uses the label, and changes nothing.
  • Event log (SessionDetail.jsx): table_admin rows fold behind one quiet "N table-admin notes" line and expand in place in time order. The header counts story events only.
  • Eval harness: a text-only fixture (table_admin_labelled.json), and fixtures can now list phrases the summary must not contain (expected.absent).

Measurement plan

#598 (#595 and #588's exact duplicates) was merged and measured on its own first: that's run 4, 6d3e6f54. This one is measured on the next dev rerun against run 4. If the result is borderline, it gets a second rerun, since Gemma's event counts vary between runs.

Checked

  • Full backend suite: 3245 passed, 13 skipped, after rebasing onto 0935b7a.
  • Frontend: vitest 853 passed, and eslint is clean.
  • Lint: ruff 0.4.4 check and format --check are clean.
  • New tests in test_table_admin_beats.py cover:
    • end-to-end acceptance of the kind
    • selection excluding it while still stratifying the rest
    • the report-only matcher, including a player-actor case that must not match
    • request-level assertions on the system prompt and enum actually sent
    • the kind being last in the list
    • the dedupe tie-break under both rules and in both arrival orders
    • two frontend fold tests

🤖 Generated with Claude Code

Closes #596. In the Gemma 4 replay, verified "events" included the GM fixing token positions, explaining a term, and working out which check reading a journal needs. They verify because the GM really said those lines. They clutter the event log, compete for the 30 events the summary is written from, and reach the prose: run 4 has *"the group's positions were noted"* and *"When the GM asked if anyone other than Viquilonto (Viq) would examine the journal"*. #584 showed that telling this model to **omit** something doesn't hold, so this has it **label** instead. ## What changes - **A `table_admin` beat kind** covers the table running the game: rules calls, which check to roll, token moves, sheet admin, and prompting the players. - It's in `BEAT_TYPES`. The prompt's type line and the json_schema enum llama.cpp enforces are both built from that one list, and `table_admin` is last in it. - `_coerce_beat` accepts "table-admin" and "Table Admin". - No migration: `session_beats.kind` is unconstrained text. - **Extraction prompt:** "record it, with type table_admin", with examples written for the prompt, so a mislabel costs a folded row, never a lost event. **Two existing rules are reworded to match:** - The "table talk" rule is narrowed to chatter that isn't about the game at all. - The #584 rule now says to label a GM answer `table_admin` unless the fiction changes, where it used to say not to record it. That was the omission instruction that didn't hold. - **Compose:** `select_beats_for_compose` removes `table_admin` beats **before** the #589 time slicing, so they take no slice. `render_beats_for_compose` and `unrepresented_beats` skip them too, so none can be narrated or retried as "dropped". - **Dedupe tie-break** (`_story_over_table_admin`, in both of `dedupe_beats`' rules): when two copies of one event differ only in that one is `table_admin`, the **story copy wins**. Verification still comes first, so a verified copy always beats an unverified one (#333). - **`answer_shaped_beats`** (#584) skips `table_admin` beats, so its count keeps measuring answers that still pose as events. - **A check that only reports:** one INFO line per run counts verified GM-only `other` beats that read like table admin ("The GM asks/explains/moves…", present or past tense). It shows whether the model uses the label, and changes nothing. - **Event log** (`SessionDetail.jsx`): `table_admin` rows fold behind one quiet "N table-admin notes" line and expand in place in time order. The header counts story events only. - **Eval harness:** a text-only fixture (`table_admin_labelled.json`), and fixtures can now list phrases the summary must not contain (`expected.absent`). ## Measurement plan #598 (#595 and #588's exact duplicates) was merged and measured on its own first: that's run 4, `6d3e6f54`. This one is measured on the next dev rerun against run 4. If the result is borderline, it gets a second rerun, since Gemma's event counts vary between runs. ## Checked - **Full backend suite:** 3245 passed, 13 skipped, after rebasing onto `0935b7a`. - **Frontend:** vitest 853 passed, and eslint is clean. - **Lint:** ruff 0.4.4 `check` and `format --check` are clean. - **New tests** in `test_table_admin_beats.py` cover: - end-to-end acceptance of the kind - selection excluding it while still stratifying the rest - the report-only matcher, including a player-actor case that must not match - request-level assertions on the system prompt and enum actually sent - the kind being last in the list - the dedupe tie-break under both rules and in both arrival orders - two frontend fold tests 🤖 Generated with [Claude Code](https://claude.com/claude-code)
A real replay verified a string of "events" that were the GM running the
game - fixing token positions, explaining a term, which skill a check needs,
character-sheet admin. Each was honestly cited, so validation passed it; each
competed for the summary's thirty slots, and one reached the prose. A prompt
rule telling the model to omit such lines did not hold on that model, so the
model now labels them and code decides what the label means.

- beat_service: new TABLE_ADMIN kind in BEAT_TYPES; the extraction prompt's
  shape line and EXTRACT_SCHEMA's enum are both built from BEAT_TYPES, so the
  enforced llama.cpp enum includes it. New rule asks for table_admin with
  invented examples (labelling, not omission); the #584 answer rule now labels
  rather than omits. "table-admin"/"Table Admin" normalise to the kind.
- select_beats_for_compose drops table_admin before stratifying (#589), so it
  neither counts towards the cap nor takes a slice of the clock;
  render_beats_for_compose and unrepresented_beats skip it too, so no "every
  event must be represented" check can count one as omitted.
  answer_shaped_beats no longer counts a labelled answer.
- Report-only backstop: looks_like_table_admin / unlabelled_table_admin, and
  one INFO line per run: "N beats look like table admin but were labelled
  other (#596)". Changes nothing.
- session_beats.kind is unconstrained Text: no migration.
- Session page: table_admin rows fold behind one quiet "N table-admin notes"
  line inside the event log, which opens them in their chronological place,
  marked "table admin". The header counts story events. Unknown kinds render
  as ordinary rows.
- Evals: synthetic table_admin_labelled.json fixture (text only), plus an
  optional expected.absent[] precision check in test_evals_scoring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prompt's one-of type list is built from BEAT_TYPES, and local models lean
towards the first option in a list like that. Leading with the kind that keeps
a beat out of the summary would push real events into it and make the label
measure worse than it is. TABLE_ADMIN now goes after "other", so the prompt
reads "combat|decision|reveal|travel|social|other|table_admin"; the
json_schema enum follows the same tuple. Pinned by a test; the schema
docstring lists the kinds in the new order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(backend): dedupe keeps the story copy over a table_admin one (#596, #588)
All checks were successful
CI / Bot/backend version sync (pull_request) Successful in 44s
CI / Backend lint (ruff) (pull_request) Successful in 52s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m29s
CI / Docker image build (pull_request) Successful in 1m1s
CI / Bot tests and audit (pull_request) Successful in 2m38s
CI / Frontend tests, audit, and build (pull_request) Successful in 2m58s
CI / Backend migration, tests, and audit (pull_request) Successful in 9m52s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 15m23s
3b0262c513
Two extraction passes can label one event differently, one as story and one
as table_admin, and the copy dedupe keeps decides whether the event is
narrated. Citation count (shared-evidence rule) or first occurrence
(exact-duplicate rule) could keep the table_admin copy, silently taking a real
event out of the summary - breaking the promise the label rests on, that a
mislabel costs one folded row and never a lost event.

New helper _story_over_table_admin, called by both rules: verification still
decides first (#333); between equally verified copies where exactly one is
table_admin, the story kind wins whatever the citations say; otherwise the
existing ranking stands.

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 06:11:03 +00:00
claude-bot deleted branch feat/596-table-admin-beats 2026-09-11 06:26:59 +00:00
Sign in to join this conversation.
No description provided.