Skip to content

extract_llm_sweep: the prefix ask reads a transcript that excludes every candidate it judges #122

Description

@amiddavid

Found by live verification of #118 against the real gateway, with a verbatim wire recorder between
context-guru and the provider.

The defect

The prefix ask reads the previous turn's sent body — everything up to MaxCachedIdx. The
candidate set is the uncached tail — only messages past MaxCachedIdx.

Those two sets are disjoint by construction. So the model is asked to judge whether outputs are
spent, over a transcript that does not contain them.

Why: sweeping() implies !ColdCache, so TailOnlyCold(i, true) degrades to the ordinary tail
gate, and only post-boundary messages become candidates. The ask, meanwhile, is the pre-boundary
body. Nothing in between connects them.

What the model actually did

Live, with the provider's own usage off the wire:

  • A/turn3 — two older outputs refused as cached_prefix=2, the two new ones offered. The model
    kept both and cited "Reply with the word ACK only." as the obligation for each. That is a real
    transcript string, so sweep_quote_fabricated correctly stayed silent — but it is not a
    judgement about the outputs. It is the only obligation the model could find, because the outputs
    were not there to reason about.
  • D/turn2 — dropped its output on {"i":0,"needed_by":"none","quote":"","verdict":"drop"},
    having seen only begins: # ledger_b and a token count.

So a drop is being decided from a 90-character head and a size. The comparative judgement this
design rests on did not happen on either prefix-ask turn.

The second half: depth and the cache read are mutually exclusive

Both derive from the same tracker record. A tracker hit gives you a stash to ask over and a
tail-only boundary. A tracker miss gives you depth and sweep_no_prefix. Every
deep-candidate firing observed (runs C and E, MaxCachedIdx=-1) was on the fallback path — the
content-carrying prompt — not on the prefix ask.

So the mechanism that reaches deep history is the one the prefix ask was built to replace, and the
prefix ask only ever sees the tail it cannot judge.

Consequence: the shipped default is the refuted shape

sweep_inventory_of_one fires on the ordinary agent turn, not an edge case. The candidate set
is the tail; the tail of a normal agent turn is one tool result. Run D — the plain case — produced
sweep_inventory_of_one=1 sweep_dropped=1. Run A needed two tool calls crammed into a single turn
to reach an inventory of two.

That is the per-output shape 4ca1f13 records as "refuted at 6% live-kept", reached by default,
and the component drops on it.

Related bias in the fallback: conversationGoal opens with the first user message, so on B/turn3
the goal read [r1] Read data/orders_2021.csv … tell me the order count in each and the model kept
all three outputs as needed for "(a) the step you are on right now".

Why no test caught it

All 20 sweep unit tests run with MaxCachedIdx: -1, which disables the tail gate entirely — so
the disjointness cannot appear. And extract_sweep_test.go:104 claims the depth restriction is
lifted "so the candidates reach the inventory: the tail gate is exercised separately below". There
is no such test
, in that file or the repo. That comment asserts coverage that does not exist.

This is the fifth vacuous-coverage instance in this component family (after the three trim tests,
the tool_result-counting precondition in #110, and the economic_gate: false blind spot in this
same component).

What needs deciding

This is design-level, not a patch. The options are not equivalent and each gives something up:

  1. Ask over the whole transcript, not the previous sent body — put the candidates inside the
    region the model reads. Loses the cache read for anything past the boundary, which is the
    mechanism's whole economy.
  2. Make the candidates the cached region — judge deep history, which is what the proposal says
    the component is for, and accept that acting on it invalidates the prefix. That is what the
    original cold-turn gating was for, and the pre-expiry window was meant to make it cheap.
  3. Keep the tail candidates and stop pretending the model reads them — ship the outputs in the
    ask, i.e. the fallback path, and drop the prefix-ask premise for this component.

(2) looks closest to the stated intent: the proposal's problem statement is deep history, and the
pre-expiry window exists precisely so that invalidating the prefix costs little. Under (2) the
prefix ask and the candidate set coincide instead of excluding each other.

Also stale, and load-bearing for this decision

docs/proposals/sweep-adjudicator.md frames the problem entirely as deep history and never states
that the tail gate confines the component to the uncached tail — it says only that "the newest tool
output is invisible" to the ask, which understates this by the whole transcript.

docs/hosted.md:645-653 is wrong on five counts for this branch: "Both call a cheap model
(claude-haiku-4-5…)", "when the economic gate says", "runs only on a turn whose prompt cache has
expired
", "adjudicates at depth", and "a batch of outputs at a time".

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions