[Backend] Summary quality varies 8x run-to-run on identical input #423

Closed
opened 2026-08-26 18:28:22 +00:00 by claude-bot · 5 comments
Contributor

Found by the #349 eval harness on its first real measurement, immediately after fixing the output cap in 3bf85b3.

The measurement

Three consecutive runs. Same transcript (a real 77-minute session, 1,529 lines), same model (qwen3.5-9B via llama.cpp at 131k context), same prompt, same code. Recorded in webapp/backend/evals/results/history.jsonl.

run coverage attribution chronology (tau) beat validation
1 0.091 0.000 not measurable 0.240
2 0.727 0.375 0.857 0.824
3 0.545 0.667 1.000 0.765

Coverage — the fraction of eleven hand-verified events that survive into the summary — ranges from 1 of 11 to 8 of 11. Beat validation ranges from 24% to 82%.

Run 1 is not merely a worse summary. It covered so few events that chronology could not be scored at all: there were fewer than two events to put in an order.

Why this matters more than the average

The milestone's stated goal is that "the accuracy floor must not depend on model size — self-hosted small models are a first-class target". These numbers say the floor currently depends on luck, which is worse, because it is invisible. A GM whose session lands on a run-1 draw gets a summary missing ten of eleven events, with nothing anywhere reporting that anything went wrong — the pipeline completed successfully.

It also undermines the thing #349 was built for. "We changed the prompt and it got better" cannot be claimed from a single run when single runs vary this much. Every future comparison needs n runs and a spread, not a number.

What is not the cause

  • Not the output cap. All three runs are after 3bf85b3.
  • Not reasoning tokens. json_mode already sends enable_thinking: False.
  • Not windowing. At 131k context the whole session is one window in all three runs.
  • Not the validator being wrong. Run 3 scored 1.000 chronology, so the same validator accepts good output when it arrives.

Worth investigating

  • Sampling temperature. If beat extraction is running at the server's default temperature, that alone could explain it. Structured extraction against a fixed schema has nothing to gain from sampling diversity — this is the first thing to check and possibly the whole answer.
  • Best-of-n extraction. Beats are already validated in code, so running extraction twice and keeping the union of validated beats is cheap and directly targets coverage. The validator makes this safe in a way it would not be for prose.
  • A coverage floor. If a run yields fewer than some fraction of the beats a previous run on the same session produced, that is detectable and worth surfacing rather than shipping silently.
  • Whether it reproduces on a larger model. If variance is a small-model property, that is a different (and more acceptable) finding than if it is universal — but it needs measuring, not assuming.

Acceptance criteria

  • The dominant cause is identified and named, with measurements rather than reasoning
  • Variance across repeated runs on one fixture is reduced to a stated, defensible band
  • python -m evals grows a repeat-count option so variance is a first-class output, not something you discover by running it three times by hand
  • A run that covers drastically fewer events than expected is visible to the GM rather than silent

Reproducing

python -m evals --fixtures evals/fixtures/private --provider live --record

Needs QB_EVAL_ENDPOINT (base URL — the client appends /v1/chat/completions), QB_EVAL_MODEL, QB_EVAL_CONTEXT_TOKENS, and ALLOW_PRIVATE_SERVICE_URLS=true for a private endpoint. The private fixture is not in git; see webapp/backend/evals/README.md.

Found by the #349 eval harness on its first real measurement, immediately after fixing the output cap in `3bf85b3`. ## The measurement Three consecutive runs. Same transcript (a real 77-minute session, 1,529 lines), same model (qwen3.5-9B via llama.cpp at 131k context), same prompt, same code. Recorded in `webapp/backend/evals/results/history.jsonl`. | run | coverage | attribution | chronology (tau) | beat validation | |---|---|---|---|---| | 1 | 0.091 | 0.000 | not measurable | 0.240 | | 2 | 0.727 | 0.375 | 0.857 | 0.824 | | 3 | 0.545 | 0.667 | 1.000 | 0.765 | Coverage — the fraction of eleven hand-verified events that survive into the summary — ranges from **1 of 11 to 8 of 11**. Beat validation ranges from 24% to 82%. Run 1 is not merely a worse summary. It covered so few events that chronology could not be scored at all: there were fewer than two events to put in an order. ## Why this matters more than the average The milestone's stated goal is that "the accuracy floor must not depend on model size — self-hosted small models are a first-class target". These numbers say the floor currently depends on *luck*, which is worse, because it is invisible. A GM whose session lands on a run-1 draw gets a summary missing ten of eleven events, with nothing anywhere reporting that anything went wrong — the pipeline completed successfully. It also undermines the thing #349 was built for. "We changed the prompt and it got better" cannot be claimed from a single run when single runs vary this much. Every future comparison needs n runs and a spread, not a number. ## What is not the cause - **Not the output cap.** All three runs are after `3bf85b3`. - **Not reasoning tokens.** `json_mode` already sends `enable_thinking: False`. - **Not windowing.** At 131k context the whole session is one window in all three runs. - **Not the validator being wrong.** Run 3 scored 1.000 chronology, so the same validator accepts good output when it arrives. ## Worth investigating - **Sampling temperature.** If beat extraction is running at the server's default temperature, that alone could explain it. Structured extraction against a fixed schema has nothing to gain from sampling diversity — this is the first thing to check and possibly the whole answer. - **Best-of-n extraction.** Beats are already validated in code, so running extraction twice and keeping the union of *validated* beats is cheap and directly targets coverage. The validator makes this safe in a way it would not be for prose. - **A coverage floor.** If a run yields fewer than some fraction of the beats a previous run on the same session produced, that is detectable and worth surfacing rather than shipping silently. - **Whether it reproduces on a larger model.** If variance is a small-model property, that is a different (and more acceptable) finding than if it is universal — but it needs measuring, not assuming. ## Acceptance criteria - [ ] The dominant cause is identified and named, with measurements rather than reasoning - [ ] Variance across repeated runs on one fixture is reduced to a stated, defensible band - [ ] `python -m evals` grows a repeat-count option so variance is a first-class output, not something you discover by running it three times by hand - [ ] A run that covers drastically fewer events than expected is visible to the GM rather than silent ## Reproducing ``` python -m evals --fixtures evals/fixtures/private --provider live --record ``` Needs `QB_EVAL_ENDPOINT` (base URL — the client appends `/v1/chat/completions`), `QB_EVAL_MODEL`, `QB_EVAL_CONTEXT_TOKENS`, and `ALLOW_PRIVATE_SERVICE_URLS=true` for a private endpoint. The private fixture is not in git; see `webapp/backend/evals/README.md`.
Author
Contributor

Dominant cause found and fixed in 0002fa4; --repeat added in 37f8c72. Leaving this open — two criteria are met, two are not.

The cause: we were sampling at random

llm_service sent no sampling parameters at all — no temperature, top_p, top_k or seed — so every structured extraction ran at the server's default, which is tuned for creative writing. Extraction against a fixed schema has one right answer in the transcript; variety can only move away from it. That is the same argument the module already makes for enable_thinking, which it disables under json_mode and leaves alone for prose. Sampling now follows the same boundary.

Verified against the real endpoint before writing any code: three identical beat-extraction requests returned three different bodies. top_k: 1 was tried alongside and is redundant — temperature 0 is already greedy here and produced a byte-identical response, so it is not shipped.

What it bought, over six runs

metric before (n=3) after (n=6)
coverage 0.091 – 0.727 0.545 – 0.727
attribution 0.000 – 0.667 0.333 – 0.571
chronology (tau) n/a – 1.000 0.810 – 1.000
beat validation 0.240 – 0.824 0.750 – 0.846

Coverage spread falls from 0.636 to 0.182, and its floor rises from 1 of 11 events to 6 of 11. Beat validation spread falls from 0.584 to 0.096. Chronology is now always measurable, where one run previously covered too few events to have an order at all.

The catastrophic draw is gone. The wobble is not.

A correction I need to make

0002fa4's message says "beat validation is now identical across all three runs". True of that sample, and I let it imply determinism. It is not.

--repeat 3 then gave beat validation 0.750–0.846, so I isolated extraction from compose: three identical extraction calls on the real transcript at temperature 0 returned 26, 26 and 25 beats — two byte-identical, one not.

The earlier evidence looked stronger than it was because the determinism probe used a five-line toy prompt, where five identical requests did return five identical bodies. At real scale it does not hold. That is the same shape of mistake this milestone keeps turning up — a check that agrees with the belief that produced it — so it is worth naming rather than quietly restating the numbers.

What is left

Residual nondeterminism has two sources, and neither is sampling.

  1. Prose compose still runs at the operator's sampling (json_mode=False), deliberately, per #232. Coverage and attribution are scored on the composed prose, which is exactly where the residual spread sits — the fix behaving as designed rather than half-working. But it raises a real question: the beats are already validated when compose runs, so a validated beat that does not reach the prose is information loss, not voice. Whether "did this event survive into the summary" is a stylistic matter at all is worth deciding.
  2. Batched inference. Likely --parallel 2 with continuous batching: batch composition changes the order of floating-point reductions, so identical greedy requests can diverge. This is a hypothesis, not a measurement — it is not a sampling parameter and no code change here would address it. Testable by running the dev server with --parallel 1 and repeating the isolation above.

Best-of-n is the promising direction. Beats are validated in code, so running extraction twice and keeping the union of validated beats is cheap, targets coverage directly, and is safe in a way it would not be for prose. Worth trying before chasing bit-determinism, which may not be achievable on a batching server at all.

Criteria

  • The dominant cause is identified and named, with measurements rather than reasoning
  • Variance reduced to a stated, defensible band — the band is stated (coverage 0.545–0.727) but a GM getting between 6 and 8 of 11 events is not yet defensible
  • python -m evals grows a repeat-count option — --repeat N, with min/mean/max/spread per metric; every run recorded, not just the last
  • A run that covers drastically fewer events than expected is visible to the GM rather than silent

Also

_structured_openai and _structured_anthropic are deliberately left at their default temperature, with comments saying so. The o-series rejects any temperature but the default, and Anthropic's extended thinking requires 1 — sending 0 would trade an intermittent quality problem for a hard 400 on exactly the models a GM is most likely to pick, and there is no key here to verify against. #339 was already that mistake once. Belongs with v4.2.0's capability flags; a test pins that both stay bare.

Ollama got the change on the same reasoning but was not verified — no instance was reachable. Its options dict is the proven envelope this function already uses for num_ctx, so it is not a guess at the shape, and a test covers that setting one does not clobber the other. Worth probing on a real Ollama alongside the open format: <schema> question.

Dominant cause found and fixed in `0002fa4`; `--repeat` added in `37f8c72`. **Leaving this open** — two criteria are met, two are not. ## The cause: we were sampling at random `llm_service` sent no sampling parameters at all — no `temperature`, `top_p`, `top_k` or `seed` — so every structured extraction ran at the server's default, which is tuned for creative writing. Extraction against a fixed schema has one right answer in the transcript; variety can only move away from it. That is the same argument the module already makes for `enable_thinking`, which it disables under `json_mode` and leaves alone for prose. Sampling now follows the same boundary. Verified against the real endpoint before writing any code: three identical beat-extraction requests returned three different bodies. `top_k: 1` was tried alongside and is redundant — temperature 0 is already greedy here and produced a byte-identical response, so it is not shipped. ## What it bought, over six runs | metric | before (n=3) | after (n=6) | |---|---|---| | coverage | 0.091 – 0.727 | 0.545 – 0.727 | | attribution | 0.000 – 0.667 | 0.333 – 0.571 | | chronology (tau) | n/a – 1.000 | 0.810 – 1.000 | | beat validation | 0.240 – 0.824 | 0.750 – 0.846 | Coverage spread falls from 0.636 to 0.182, and its floor rises from 1 of 11 events to 6 of 11. Beat validation spread falls from 0.584 to 0.096. Chronology is now always measurable, where one run previously covered too few events to have an order at all. **The catastrophic draw is gone. The wobble is not.** ## A correction I need to make `0002fa4`'s message says "beat validation is now identical across all three runs". True of that sample, and I let it imply determinism. It is not. `--repeat 3` then gave beat validation 0.750–0.846, so I isolated extraction from compose: three identical extraction calls on the real transcript at temperature 0 returned **26, 26 and 25 beats** — two byte-identical, one not. The earlier evidence looked stronger than it was because the determinism probe used a five-line toy prompt, where five identical requests did return five identical bodies. At real scale it does not hold. That is the same shape of mistake this milestone keeps turning up — a check that agrees with the belief that produced it — so it is worth naming rather than quietly restating the numbers. ## What is left **Residual nondeterminism has two sources, and neither is sampling.** 1. **Prose compose still runs at the operator's sampling** (`json_mode=False`), deliberately, per #232. Coverage and attribution are scored on the composed prose, which is exactly where the residual spread sits — the fix behaving as designed rather than half-working. But it raises a real question: the beats are already *validated* when compose runs, so a validated beat that does not reach the prose is information loss, not voice. Whether "did this event survive into the summary" is a stylistic matter at all is worth deciding. 2. **Batched inference.** Likely `--parallel 2` with continuous batching: batch composition changes the order of floating-point reductions, so identical greedy requests can diverge. **This is a hypothesis, not a measurement** — it is not a sampling parameter and no code change here would address it. Testable by running the dev server with `--parallel 1` and repeating the isolation above. **Best-of-n is the promising direction.** Beats are validated in code, so running extraction twice and keeping the union of *validated* beats is cheap, targets coverage directly, and is safe in a way it would not be for prose. Worth trying before chasing bit-determinism, which may not be achievable on a batching server at all. ## Criteria - [x] The dominant cause is identified and named, with measurements rather than reasoning - [ ] Variance reduced to a stated, defensible band — the band is stated (coverage 0.545–0.727) but a GM getting between 6 and 8 of 11 events is not yet defensible - [x] `python -m evals` grows a repeat-count option — `--repeat N`, with min/mean/max/spread per metric; every run recorded, not just the last - [ ] A run that covers drastically fewer events than expected is visible to the GM rather than silent ## Also `_structured_openai` and `_structured_anthropic` are deliberately left at their default temperature, with comments saying so. The o-series rejects any temperature but the default, and Anthropic's extended thinking requires 1 — sending 0 would trade an intermittent quality problem for a hard 400 on exactly the models a GM is most likely to pick, and there is no key here to verify against. #339 was already that mistake once. Belongs with v4.2.0's capability flags; a test pins that both stay bare. Ollama got the change on the same reasoning but was **not verified** — no instance was reachable. Its `options` dict is the proven envelope this function already uses for `num_ctx`, so it is not a guess at the shape, and a test covers that setting one does not clobber the other. Worth probing on a real Ollama alongside the open `format: <schema>` question.
Author
Contributor

Compose work in 899ac1e. The headline result is a negative one, so putting it first: the detector I built to catch dropped events does not fire on real sessions, and the beats-to-prose gap is unchanged at ~0.15.

What is demonstrated

The prompt was asking for the drop. COMPOSE_SYSTEM_PROMPT said "cover the key events" — inviting a selection pass over a list that has already been filtered twice, extracted as a beat and then checked against the transcript. There is no third sieve to apply. It now requires every listed event to be represented, while allowing related events to share a sentence and minor ones a clause.

The harness was measuring a copy of the pipeline, not the pipeline. This is the find worth keeping. My first version of the compose fix measured as doing nothing at all — and it wasn't nothing: runner.py re-implements the compose sequence, so it never executed the new code. A harness built to measure changes to the pipeline could not see a change to the pipeline.

recover_dropped_events now takes its compose call as an argument and the harness calls that same function rather than mirroring it. Anything the pipeline does to a summary after composing has to be reachable from there, or the eval quietly stops measuring the product. Worth auditing the rest of runner.py against _summarise_from_beats for the same class of drift.

What is not

unrepresented_beats flagged nothing across six live runs, while the harness measured 1–2 verified events per run missing from the prose. A ~100% false-negative rate.

Two detectors tried, both wrong in opposite directions:

  • Any two content words — too weak. A session's beats share heavily specific vocabulary (mimic, amphora, amulet), so a dropped beat's words turn up anyway because the beat next to it got written about.
  • Words unique within the session — too strict. Most beats have no word of their own, so they are skipped unchecked.

The machinery around it is right and unit-tested — given a summary that drops an event it re-requests it by name and keeps the better draft; given one that recovers nothing it keeps the first. It ships because it is non-fatal, costs nothing when it does not fire, and is correct in the clear-cut cases. But it is not the fix.

Why it is hard, stated plainly: the eval harness only detects these drops because its fixtures carry hand-authored per-event mention groups with curated alternatives. Production has only the beat's own summary text, which is a much weaker signal. Closing that gap needs something other than token overlap — a structured "which of these events did you cover?" check is the obvious candidate, at the cost of one extra call and of being a model judging its own work, which is what this milestone has been trying to get away from.

Numbers, six live runs post-fix

metric min mean max spread
coverage (prose) 0.455 0.545 0.636 0.182
coverage in beats 0.636 0.697 0.727 0.091
attribution 0.429 0.510 0.600 0.171
chronology (tau) 0.714 0.794 0.867 0.152
beat validation 0.625 0.740 0.846 0.221

Gap between beats and prose: 0.152, against 0.182 before. Within noise at n=3.

One run did reach coverage 0.909 with cov.in beats 1.000 — so when extraction happens to find everything, compose keeps most of it. That is a hint the extraction ceiling matters as much as the compose leak, which points back at best-of-n extraction as the next thing to try.

Also

A test double was returning "The final summary." for a beat about disarming a rune trap — a compose step that ignored its entire input, i.e. exactly the condition now being checked for. Fixed the double rather than loosening the assertion, per #343: a test double that models an impossible response will eventually assert that the impossible is fine.

1,207 passing before, 1,215 after.

Criteria

  • The dominant cause is identified and named, with measurements rather than reasoning
  • Variance reduced to a stated, defensible band — stated, not yet defensible
  • python -m evals grows a repeat-count option
  • A run that covers drastically fewer events than expected is visible to the GM — the logging exists; the detector feeding it does not work yet
Compose work in `899ac1e`. **The headline result is a negative one**, so putting it first: the detector I built to catch dropped events does not fire on real sessions, and the beats-to-prose gap is unchanged at ~0.15. ## What is demonstrated **The prompt was asking for the drop.** `COMPOSE_SYSTEM_PROMPT` said *"cover the key events"* — inviting a selection pass over a list that has already been filtered twice, extracted as a beat and then checked against the transcript. There is no third sieve to apply. It now requires every listed event to be represented, while allowing related events to share a sentence and minor ones a clause. **The harness was measuring a copy of the pipeline, not the pipeline.** This is the find worth keeping. My first version of the compose fix measured as doing *nothing at all* — and it wasn't nothing: `runner.py` re-implements the compose *sequence*, so it never executed the new code. A harness built to measure changes to the pipeline could not see a change to the pipeline. `recover_dropped_events` now takes its compose call as an argument and the harness calls that same function rather than mirroring it. Anything the pipeline does to a summary after composing has to be reachable from there, or the eval quietly stops measuring the product. Worth auditing the rest of `runner.py` against `_summarise_from_beats` for the same class of drift. ## What is not `unrepresented_beats` flagged **nothing across six live runs**, while the harness measured 1–2 verified events per run missing from the prose. A ~100% false-negative rate. Two detectors tried, both wrong in opposite directions: - **Any two content words** — too weak. A session's beats share heavily specific vocabulary (mimic, amphora, amulet), so a dropped beat's words turn up anyway because the beat next to it got written about. - **Words unique within the session** — too strict. Most beats have no word of their own, so they are skipped unchecked. The machinery around it is right and unit-tested — given a summary that drops an event it re-requests it by name and keeps the better draft; given one that recovers nothing it keeps the first. It ships because it is non-fatal, costs nothing when it does not fire, and is correct in the clear-cut cases. But it is not the fix. **Why it is hard, stated plainly:** the eval harness only detects these drops because its fixtures carry *hand-authored per-event mention groups* with curated alternatives. Production has only the beat's own summary text, which is a much weaker signal. Closing that gap needs something other than token overlap — a structured "which of these events did you cover?" check is the obvious candidate, at the cost of one extra call and of being a model judging its own work, which is what this milestone has been trying to get away from. ## Numbers, six live runs post-fix | metric | min | mean | max | spread | |---|---|---|---|---| | coverage (prose) | 0.455 | 0.545 | 0.636 | 0.182 | | coverage in beats | 0.636 | 0.697 | 0.727 | 0.091 | | attribution | 0.429 | 0.510 | 0.600 | 0.171 | | chronology (tau) | 0.714 | 0.794 | 0.867 | 0.152 | | beat validation | 0.625 | 0.740 | 0.846 | 0.221 | Gap between beats and prose: **0.152**, against 0.182 before. Within noise at n=3. One run did reach coverage 0.909 with `cov.in beats` 1.000 — so when extraction happens to find everything, compose keeps most of it. That is a hint the extraction ceiling matters as much as the compose leak, which points back at best-of-n extraction as the next thing to try. ## Also A test double was returning `"The final summary."` for a beat about disarming a rune trap — a compose step that ignored its entire input, i.e. exactly the condition now being checked for. Fixed the double rather than loosening the assertion, per #343: a test double that models an impossible response will eventually assert that the impossible is fine. 1,207 passing before, **1,215** after. ## Criteria - [x] The dominant cause is identified and named, with measurements rather than reasoning - [ ] Variance reduced to a stated, defensible band — stated, not yet defensible - [x] `python -m evals` grows a repeat-count option - [ ] A run that covers drastically fewer events than expected is visible to the GM — the logging exists; the detector feeding it does not work yet
Author
Contributor

Best-of-2 extraction in 5357022. This is the first change that has moved coverage at all.

Measured, three runs each side

metric single pass best of two
coverage (prose) 0.545 (0.455–0.636) 0.667 (0.545–0.818)
coverage in beats 0.697 (0.636–0.727) 0.818 (0.636–1.000)
attribution 0.510 (0.429–0.600) 0.542 (0.500–0.571)
chronology (tau) 0.794 (0.714–0.867) 0.868 (0.833–0.905)
beat validation 0.740 (0.625–0.846) 0.812 (0.714–0.923)

Every metric improved. Coverage is up 22% relative, and one run reached 1.000 in beats — all eleven hand-verified events found — where single-pass never exceeded 0.727. The ceiling moved, which neither of the previous two attempts managed.

Two caveats I want on the record. Ranges overlap at n=3, so this is a real signal but not a tight one. And it did not reduce variance the way I predicted when I proposed it: spread went up, not down (coverage 0.182 → 0.273). Averaging more draws improved the mean and evidently not the spread, at least at this sample size. I said best-of-n would steady it as a side effect; that part was wrong.

The beats-to-prose gap is unchanged at ~0.15. Best-of-n raises what compose is handed; compose still discards its usual share. The two problems are independent, exactly as the stage measurement indicated.

Why the union is safe here

Every beat is validated against the transcript by pure code before anything uses it, so taking the union across passes cannot smuggle in a hallucination. The property that makes best-of-n dangerous for prose is the one validate_beats already removes.

dedupe_beats decides two beats are one event from their cited evidence, not from how alike the summaries read — two passes word the same event differently, so text similarity would be guessing, whereas citing the same transcript line is a fact. Actors must overlap too, since one line can carry two people's actions and shared evidence alone would merge events that merely coincided.

Biased towards keeping: a duplicate costs a sentence written twice; an over-merge silently loses an event. A test pins that dedupe is a no-op on a single pass, because an over-eager merge there would drop events for every install that never runs a second one.

The drift, fixed at the cause

The window/extraction loop existed twice — once in the pipeline, once in the harness. That is why the compose fix measured as doing nothing earlier today. gather_beats_over_windows is now the single implementation with extract injected, and the harness calls it. Second time this pattern has bitten, so this fixes the cause rather than the instance. Worth a sweep of anything else runner.py still mirrors.

Cost, stated rather than buried

This doubles the most expensive call in the pipeline — time on a self-hosted box, money on a metered provider. BEAT_EXTRACTION_PASSES = 2 is a module constant. Whether hosted campaigns should be able to turn it down is a product decision and shouldn't live in a default.

1,215 passing before, 1,222 after.

Criteria

  • The dominant cause is identified and named, with measurements rather than reasoning
  • Variance reduced to a stated, defensible band — coverage is better but the spread is wider; this criterion is further away than when it was written, and honestly may be the wrong target. A high floor probably matters more to a GM than a narrow band, and best-of-2 raised the mean without raising the floor (min 0.545 either way).
  • python -m evals grows a repeat-count option
  • A run that covers drastically fewer events than expected is visible to the GM

Suggest the remaining work is: reframe criterion 2 around the floor rather than the spread, and get a detector good enough to satisfy criterion 4 — those are the same problem, since a usable floor check is exactly what would make a bad run visible.

Best-of-2 extraction in `5357022`. **This is the first change that has moved coverage at all.** ## Measured, three runs each side | metric | single pass | best of two | |---|---|---| | coverage (prose) | 0.545 (0.455–0.636) | **0.667** (0.545–0.818) | | coverage in beats | 0.697 (0.636–0.727) | **0.818** (0.636–1.000) | | attribution | 0.510 (0.429–0.600) | 0.542 (0.500–0.571) | | chronology (tau) | 0.794 (0.714–0.867) | 0.868 (0.833–0.905) | | beat validation | 0.740 (0.625–0.846) | 0.812 (0.714–0.923) | Every metric improved. Coverage is up **22% relative**, and one run reached **1.000 in beats** — all eleven hand-verified events found — where single-pass never exceeded 0.727. The ceiling moved, which neither of the previous two attempts managed. **Two caveats I want on the record.** Ranges overlap at n=3, so this is a real signal but not a tight one. And it did **not** reduce variance the way I predicted when I proposed it: spread went *up*, not down (coverage 0.182 → 0.273). Averaging more draws improved the mean and evidently not the spread, at least at this sample size. I said best-of-n would steady it as a side effect; that part was wrong. The beats-to-prose gap is unchanged at ~0.15. Best-of-n raises what compose is handed; compose still discards its usual share. The two problems are independent, exactly as the stage measurement indicated. ## Why the union is safe here Every beat is validated against the transcript by pure code before anything uses it, so taking the union across passes cannot smuggle in a hallucination. The property that makes best-of-n dangerous for prose is the one `validate_beats` already removes. `dedupe_beats` decides two beats are one event from their **cited evidence**, not from how alike the summaries read — two passes word the same event differently, so text similarity would be guessing, whereas citing the same transcript line is a fact. Actors must overlap too, since one line can carry two people's actions and shared evidence alone would merge events that merely coincided. Biased towards keeping: a duplicate costs a sentence written twice; an over-merge silently loses an event. A test pins that dedupe is a **no-op on a single pass**, because an over-eager merge there would drop events for every install that never runs a second one. ## The drift, fixed at the cause The window/extraction loop existed twice — once in the pipeline, once in the harness. That is why the compose fix measured as doing nothing earlier today. `gather_beats_over_windows` is now the single implementation with `extract` injected, and the harness calls it. Second time this pattern has bitten, so this fixes the cause rather than the instance. Worth a sweep of anything else `runner.py` still mirrors. ## Cost, stated rather than buried This doubles the most expensive call in the pipeline — time on a self-hosted box, money on a metered provider. `BEAT_EXTRACTION_PASSES = 2` is a module constant. Whether hosted campaigns should be able to turn it down is a product decision and shouldn't live in a default. 1,215 passing before, **1,222** after. ## Criteria - [x] The dominant cause is identified and named, with measurements rather than reasoning - [ ] Variance reduced to a stated, defensible band — **coverage is better but the spread is wider**; this criterion is further away than when it was written, and honestly may be the wrong target. A high floor probably matters more to a GM than a narrow band, and best-of-2 raised the mean without raising the floor (min 0.545 either way). - [x] `python -m evals` grows a repeat-count option - [ ] A run that covers drastically fewer events than expected is visible to the GM Suggest the remaining work is: reframe criterion 2 around the **floor** rather than the spread, and get a detector good enough to satisfy criterion 4 — those are the same problem, since a usable floor check is exactly what would make a bad run visible.
Author
Contributor

Window sweep done in 8bc9018. The floor is raised: 0.091 when this was filed, 0.727 now.

The sweep

Real session, four runs per point, qwen3.5-9B:

windows coverage floor coverage mean attribution beat validation
1 0.455 0.568 0.533 0.747
2 0.545 0.614 0.810 0.729
3 0.455 0.523 0.425 0.736
4 0.727 0.818 0.804 0.795
7 0.727 0.773 0.615 0.723

Four windows — a ~7,000-token budget, cap 11264 — is best or tied-best on everything, and is now the default. Worst-case events reaching the summary: 8 of 11, against 5 of 11 with the whole session in one window.

The tail is not what I predicted

I said before running this that going finer would lose events to window boundaries. It doesn't — seven windows holds the same coverage floor. What it loses is attribution (0.804 → 0.615) and validation rate (0.795 → 0.723).

The events are still found; they are attributed worse. Each window carries less surrounding dialogue, so the model has less to ground "who did this" in and cites less reliably. Window size trades finding events against attributing them — and since those are the two failure modes this milestone exists for, the balance point matters more than either alone. That reframes the knob from "how much can the model attend to" to "how much context does an attribution need", which is a different and more useful question.

The dip at 3 windows is unexplained: worse than both neighbours on coverage and attribution, which no smooth story predicts. Most likely where the boundaries happen to fall in this one transcript. Four runs cannot separate that from noise, so it is recorded rather than smoothed away. It does not move the optimum.

The two-way isolation you authorised

It was answerable without GPU time, and the answer was that I had been chasing the wrong variable:

  • extract_beats' declared context — dead. context_tokens only ever reaches Ollama's num_ctx; the llama.cpp path never receives it.
  • Compose — dead. The harness compose has no fits_in_context check, and _dispatch_prose → llama.cpp ignores context.
  • Window count — the only live variable, and it differed 4 vs 5 between the two runs I had been comparing, because extraction_windows applies prompt_budget_tokens' 0.7 fraction and the old harness maths did not.

So my "clean" run was not a cleaner version of the confounded one; it was a different configuration. I changed the thing under test while removing what I believed was a confound, then read the difference as evidence about compose. Third time this milestone has produced a measurement that agreed with the belief that made it, and the fix was arithmetic rather than another run.

Criteria

  • The dominant cause is identified and named, with measurements rather than reasoning
  • Variance reduced to a stated, defensible bandfloor raised, per the reframing: coverage floor 0.091 → 0.455 → 0.727, i.e. worst-case 1 of 11 events to 8 of 11
  • python -m evals grows a repeat-count option
  • A run that covers drastically fewer events than expected is visible to the GM

What remains

Only the GM-visibility criterion, and it still needs a detector I have failed at twice — token overlap against a beat's own summary is too weak a signal, and the harness only manages it because its fixtures carry hand-authored mention groups. Now that the floor is 0.727 rather than 0.091, the case for that detector is weaker than it was; it may be better handled as "the GM can see the verified beat list alongside the summary" than as an automated check.

Suggest closing this and opening a separate issue for that, since it is now a UI question rather than a pipeline one.

Arc of the whole issue: the first three attempts all asked how many times to query. The answer was how much to ask about at once.

Window sweep done in `8bc9018`. **The floor is raised: 0.091 when this was filed, 0.727 now.** ## The sweep Real session, four runs per point, qwen3.5-9B: | windows | coverage floor | coverage mean | attribution | beat validation | |---|---|---|---|---| | 1 | 0.455 | 0.568 | 0.533 | 0.747 | | 2 | 0.545 | 0.614 | 0.810 | 0.729 | | 3 | 0.455 | 0.523 | 0.425 | 0.736 | | **4** | **0.727** | **0.818** | 0.804 | **0.795** | | 7 | 0.727 | 0.773 | 0.615 | 0.723 | Four windows — a ~7,000-token budget, cap `11264` — is best or tied-best on everything, and is now the default. Worst-case events reaching the summary: **8 of 11**, against 5 of 11 with the whole session in one window. ## The tail is not what I predicted I said before running this that going finer would lose events to window boundaries. It doesn't — seven windows holds the same coverage floor. What it loses is **attribution** (0.804 → 0.615) and validation rate (0.795 → 0.723). The events are still *found*; they are attributed *worse*. Each window carries less surrounding dialogue, so the model has less to ground "who did this" in and cites less reliably. Window size trades finding events against attributing them — and since those are the two failure modes this milestone exists for, the balance point matters more than either alone. That reframes the knob from "how much can the model attend to" to "how much context does an attribution need", which is a different and more useful question. The **dip at 3 windows** is unexplained: worse than both neighbours on coverage and attribution, which no smooth story predicts. Most likely where the boundaries happen to fall in this one transcript. Four runs cannot separate that from noise, so it is recorded rather than smoothed away. It does not move the optimum. ## The two-way isolation you authorised It was answerable without GPU time, and the answer was that I had been chasing the wrong variable: - **`extract_beats`' declared context** — dead. `context_tokens` only ever reaches Ollama's `num_ctx`; the llama.cpp path never receives it. - **Compose** — dead. The harness compose has no `fits_in_context` check, and `_dispatch_prose` → llama.cpp ignores context. - **Window count** — the only live variable, and it differed **4 vs 5** between the two runs I had been comparing, because `extraction_windows` applies `prompt_budget_tokens`' 0.7 fraction and the old harness maths did not. So my "clean" run was not a cleaner version of the confounded one; it was a different configuration. I changed the thing under test while removing what I believed was a confound, then read the difference as evidence about compose. Third time this milestone has produced a measurement that agreed with the belief that made it, and the fix was arithmetic rather than another run. ## Criteria - [x] The dominant cause is identified and named, with measurements rather than reasoning - [x] ~~Variance reduced to a stated, defensible band~~ → **floor raised**, per the reframing: coverage floor 0.091 → 0.455 → **0.727**, i.e. worst-case 1 of 11 events to 8 of 11 - [x] `python -m evals` grows a repeat-count option - [ ] A run that covers drastically fewer events than expected is visible to the GM ## What remains Only the GM-visibility criterion, and it still needs a detector I have failed at twice — token overlap against a beat's own summary is too weak a signal, and the harness only manages it because its fixtures carry hand-authored mention groups. Now that the floor is 0.727 rather than 0.091, the case for that detector is weaker than it was; it may be better handled as "the GM can see the verified beat list alongside the summary" than as an automated check. Suggest closing this and opening a separate issue for that, since it is now a UI question rather than a pipeline one. **Arc of the whole issue:** the first three attempts all asked *how many times to query*. The answer was *how much to ask about at once*.
Author
Contributor

Closing. The fourth criterion is handed to #424 in v4.4.0, reframed as showing the GM the verified events rather than detecting a lossy run in code.

What this issue achieved

Coverage floor: 0.091 → 0.727. Worst-case events reaching a GM's summary went from 1 of 11 to 8 of 11.

coverage floor coverage mean
as filed 0.091 0.545
greedy decoding (0002fa4) 0.455 0.568
best-of-2 extraction (5357022) 0.545 0.667
windowed extraction (8bc9018) 0.727 0.818

What shipped

  • 3bf85b3 — beat extraction was silently capped at 2048 output tokens, and failed hardest on the largest context windows. Found by #349's harness on its first real run.
  • 0002fa4 — no sampling parameters were ever sent, so every structured extraction ran at the server's creative-writing default. Greedy decoding under json_mode removed the catastrophic draw.
  • 5357022 — extraction runs twice and keeps the union of validated beats, merged on cited evidence rather than text similarity.
  • e997926 — passes continue while they are still finding events. Did not raise the floor; kept with its negative result recorded.
  • 48036ab / 8bc9018 — extraction windows sized for attention rather than context, swept to an optimum of ~7,000 tokens per window.

What I got wrong, since it is the useful part

Three of the four attempts asked how many times to query. The answer was how much to ask about at once.

  • Predicted best-of-n would reduce variance as a side effect. It did not — spread went up.
  • Predicted the adaptive loop would raise the floor. The floor came back identical, because agreement between passes is not evidence of completeness: a bad run is one where passes agree on a poor view, so the extra passes never fire where they are needed.
  • Predicted finer windows would lose events to boundaries. They do not — 7 windows holds the coverage floor. What degrades is attribution, because each window carries less dialogue to ground "who did this" in.
  • Reported a "compose effect" that did not exist. My clean run used 5 windows where the run it was meant to reproduce used 4, because extraction_windows applies prompt_budget_tokens' 0.7 fraction and the old harness maths did not. I changed the thing under test while removing what I believed was a confound.

That last one is the pattern worth carrying forward: three separate times this milestone produced a measurement that agreed with the belief that made it. The one time I computed what each configuration actually did before spending GPU on it, arithmetic answered the question outright and saved the run.

Left behind for whoever picks this up

  • QB_EXTRACTION_CONTEXT_CAP — the window size is a measured value on one model and one session, not a derived one, and will move with the model. Tune it with python -m evals, not by argument.
  • The cap fixes window size, so a three-hour session gets more windows of the same size rather than four huge ones. That assumes the effect is size-driven rather than count-driven — consistent with the sweep, but never directly tested, because the corpus has exactly one real session. A second real session of a materially different length would settle it, and is the single most valuable thing to add to the fixture corpus.
  • The dip at 3 windows is unexplained and recorded rather than smoothed away.
  • Six live runs' worth of numbers are in evals/results/history.jsonl, tagged by configuration.
Closing. The fourth criterion is handed to #424 in v4.4.0, reframed as showing the GM the verified events rather than detecting a lossy run in code. ## What this issue achieved **Coverage floor: 0.091 → 0.727.** Worst-case events reaching a GM's summary went from 1 of 11 to 8 of 11. | | coverage floor | coverage mean | |---|---|---| | as filed | 0.091 | 0.545 | | greedy decoding (`0002fa4`) | 0.455 | 0.568 | | best-of-2 extraction (`5357022`) | 0.545 | 0.667 | | **windowed extraction (`8bc9018`)** | **0.727** | **0.818** | ## What shipped - **`3bf85b3`** — beat extraction was silently capped at 2048 output tokens, and failed *hardest* on the largest context windows. Found by #349's harness on its first real run. - **`0002fa4`** — no sampling parameters were ever sent, so every structured extraction ran at the server's creative-writing default. Greedy decoding under `json_mode` removed the catastrophic draw. - **`5357022`** — extraction runs twice and keeps the union of *validated* beats, merged on cited evidence rather than text similarity. - **`e997926`** — passes continue while they are still finding events. Did not raise the floor; kept with its negative result recorded. - **`48036ab` / `8bc9018`** — extraction windows sized for attention rather than context, swept to an optimum of ~7,000 tokens per window. ## What I got wrong, since it is the useful part Three of the four attempts asked **how many times to query**. The answer was **how much to ask about at once**. - Predicted best-of-n would reduce variance as a side effect. It did not — spread went *up*. - Predicted the adaptive loop would raise the floor. The floor came back *identical*, because agreement between passes is not evidence of completeness: a bad run is one where passes agree on a poor view, so the extra passes never fire where they are needed. - Predicted finer windows would lose events to boundaries. They do not — 7 windows holds the coverage floor. What degrades is *attribution*, because each window carries less dialogue to ground "who did this" in. - Reported a "compose effect" that did not exist. My clean run used 5 windows where the run it was meant to reproduce used 4, because `extraction_windows` applies `prompt_budget_tokens`' 0.7 fraction and the old harness maths did not. I changed the thing under test while removing what I believed was a confound. That last one is the pattern worth carrying forward: **three separate times this milestone produced a measurement that agreed with the belief that made it.** The one time I computed what each configuration actually did before spending GPU on it, arithmetic answered the question outright and saved the run. ## Left behind for whoever picks this up - `QB_EXTRACTION_CONTEXT_CAP` — the window size is a *measured* value on one model and one session, not a derived one, and will move with the model. Tune it with `python -m evals`, not by argument. - The cap fixes window **size**, so a three-hour session gets more windows of the same size rather than four huge ones. That assumes the effect is size-driven rather than count-driven — consistent with the sweep, but never directly tested, because the corpus has exactly one real session. **A second real session of a materially different length would settle it**, and is the single most valuable thing to add to the fixture corpus. - The dip at 3 windows is unexplained and recorded rather than smoothed away. - Six live runs' worth of numbers are in `evals/results/history.jsonl`, tagged by configuration.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Quest-Board#423
No description provided.