Skip to content

vision-suite: a verbatim loop ends the cell as a verdict -- loop detected:<thinking|content|token-run> (ADR 0049) - #469

Merged
glennneuber merged 10 commits into
mainfrom
feat/vision-suite-loop-detect
Oct 13, 2026
Merged

glennneuber merged 10 commits into
mainfrom
feat/vision-suite-loop-detect

Conversation

@glennneuber

@glennneuber glennneuber commented Oct 11, 2026 •

Copy link
Copy Markdown

A verbatim loop now ends a vision-suite cell as a verdict: loop detected:thinking, :content or :token-run (ADR 0049). It is opt-in through LOOP_DETECT=1, and the muse-glimmer campaign (#463) runs its thinking-on and OCRBench arms with it.

Why

Under greedy decoding, a model that repeats a block verbatim, back to back, usually keeps repeating it. On the CUDA host's replay, 57 of 60 such loops ran to the length cap and 3 finished. A loop verdict ends the cell there and labels it. The cell then spent the rest of num_predict on the loop, and under thinking-on it did so at every rung up to 131072. The glimmer think-off pass showed all three forms:

  • 30b-mxfp8, bboxm_pin_noanc_named: one box object's keys, repeated to the cap.
  • 30b-nvfp4, multi_3img: a 72-char fragment, repeated at every rung up to 131072.
  • 30b, two cases: a token run, which the server aborts at 100 with an HTTP 500 and no output.

The change

  • loop_detect.py (stdlib, strictly typed, black-formatted). This is the canonical copy of a detector shared byte for byte with ai-invoice-to-xero's ollama_toolkit (maxusai-open-bookkeeper-for-xero#117). maxusai-structured-docs-backend carries the same code in an nbdev notebook (#22).
    • Block loops: the last 400 chars occur 3 times, back to back (the last 400 chars plus 2 periods are one block repeated). Three copies, not two, so a restated table is not a loop. Back to back, so a draft restated between checks is not one either (53b3c1417).
    • Token runs: 64 identical chunks, below the server's 100, so the partial output is kept. The server's own abort can still land first, because it counts raw tokens and the client counts parsed chunks. Streamed, that raises HTTP 500 (streamed): prediction aborted, token repeat limit reached. The message names the abort, and the error is not retried (1f3c89a00).
    • Cost: linear, from a bounded lookback checked every 64 chars.
  • client.generate streams when LOOP_DETECT=1. On a loop it closes the connection, which cancels the generation, and returns a finished response:
    • done_reason: "loop", failure_mode: "loop detected:<kind>", and the loop's fields;
    • the partial output, which the suite scores as it scores a capped answer. Every table renders loop:<kind> in its place, and no mean pools it (loop_verdict / not_a_score, a104f822d);
    • arm_done treats it as finished and capped_arms does not escalate it, so it is never re-run or climbed. That includes a held-back loop at eval_count == num_predict: was_capped returns False for done_reason: "loop". A loop has no gen_tps (6dcb6688a).
  • The final chunk is judged too. glimmer's parser holds back a reply with no message header (fix(glimmer): a format with thinking off waits for the message header, in single pass and two-pass alike #464) and sends it whole at the end. A loop in a length-capped reply is still the verdict, with server_done_reason: "length" and the server's own metrics kept, except gen_tps: that rate is the loop's. A finished reply is never a loop.
  • client.loop_fields stamps stream / loop_detect, plus the loop's fields when it fires, on vision_suite and finetext_probe blocks. Off by default, nothing changes shape.

Validation

  • Replay: every saved response in the glimmer and v0.40.2 trees, 1,817 files. It fired on the 4 cells that had run away to the length cap, at 800-1,312 chars against the 2,182-4,350 those cells generated, and on none that finished.
  • Thinking traces:
    • CUDA host, 1,641 traces: counting copies alone fired on 17-18 of the 1,505 finished traces, which were restated drafts. Requiring the copies back to back cuts that to 3: contiguous loops that recovered. All 57 capped traces still fire.
    • Metal host, 2,176 traces: finished 11 → 3, capped 57 → 57.
  • Live, on muse-glimmer:30b-mxfp8 with the release build in production's environment, before any campaign cell used it:
check result
held-back loop (bboxm_pin_noanc_named, think off + format) loop detected:content, period 87, server_done_reason: length, eval_count 2200 kept
streamed loop (plain text, no format) cut mid-stream: loop detected:content at chunk 161 of a 1,500 budget, period 15, no server durations
clean case (document_single) every score identical to the run without detection; stream: true, no failure mode
  • Tests:
    • test_loop_detect.py: 18, with the two real loops as fixtures.
    • test_client.py: 58, covering streaming, the verdict contract, buffered loops, loop_fields and the streamed server error.
    • test_summarizers.py: TestLoopIsNotCapped and TestLoopVerdictReachesTheTables.
    • preflight/test_verdicts.py: a loop is excluded from quality.
    • The suite's other test files pass: 356 tests, plus 244 in preflight.

Not covered

client.save writes thinking traces, and both hosts replayed them (above). extbench records carry no loop fields yet, so an OCRBench item that loops is scored as a wrong answer.

macbook-pro-m5-max-128GB/mlx-metal

🤖 Generated with Claude Code

glennneuber and others added 6 commits October 11, 2026 15:19
…cted:<thinking|content|token-run> (ADR 0049)

Under greedy decoding a model that repeats itself verbatim does not recover, and the cell spent the rest of
num_predict -- under think-on every rung of the ladder -- proving it. The glimmer campaign's think-off pass showed all
three forms: an object's keys repeated to the cap (MLX mxfp8), a 72-char fragment repeated at every rung to 131072
(MLX nvfp4 multi_3img), and a token run the server aborts at 100 with an HTTP 500 and no output (GGUF).

- loop_detect.py (stdlib; to be shared with ai-invoice-to-xero's ollama_toolkit and the structured-docs backend):
  LoopDetector watches thinking and content as they stream and reports loop detected:thinking / :content (the last
  400 chars occur 3 times) or :token-run (64 identical chunks). Three copies, not two, so a restated table is not a
  loop; linear cost (bounded lookback, check every 64 chars). ollama_toolkit's thinking_is_looping and dedup_thinking
  keep their meaning; dedup_thinking is now linear.
- client.generate streams when LOOP_DETECT=1; a loop closes the connection (cancelling the generation) and comes
  back as a finished response: done_reason "loop", failure_mode "loop detected:<kind>", the partial output, the
  loop's fields. A verdict, not an error or a cap: scored as produced, never re-run on resume, never escalated.
- client.loop_fields stamps stream / loop_detect (and the loop) on vision_suite and finetext_probe blocks.
- Off by default; ADR 0049. Replayed over 1817 saved responses: fired on the 4 cells that ran out, on none that
  finished. The two real loops are test fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k is still the verdict

A live check on muse-glimmer:30b-mxfp8 (bboxm_pin_noanc_named, think off + format) streamed nothing until the end:
glimmer's parser holds a reply that never writes its message header (the #464 path) and sends it whole in the
final chunk, so the detector never saw the loop it catches offline at 800 chars. The final chunk is now fed to the
detector as well: a loop in a reply the server stopped at num_predict becomes done_reason "loop" with the server's
metrics kept and server_done_reason "length"; a finished reply is never a loop. ADR 0049 decision 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… canonical bytes all three copies share

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the structured-docs copy); the canonical bytes stay shared

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l three copies pass their strictest checker

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@glennneuber

Copy link
Copy Markdown
Author

CUDA host review, at c3ba4535d. I replayed loop_detect over this host's saved outputs: 9,454 resp_*.json files and 1,641 think_*.txt traces. client.save does write thinking traces (client.py:582).

1. The thinking stream fires on traces that finished.

  • The answer stream has no false positives on finished cells, which matches your replay.
  • The thinking stream fires on 17–18 of the 1,505 think-on traces that ended with done_reason: "stop". The count is stable across chunk sizes.
  • 13 of those are multi_3img cells with q1, q2 and q4 all right, nearly all at temp 0.
  • Example: gemma4:31b-nvfp4 multi_3img_anchored would be cut at 13,728 of 14,465 thinking chars and scored as an empty answer.
  • Cause: _block (loop_detect.py:149-169) counts copies that are not next to each other, so a restated draft looks like a loop.
  • Tested fix: also require the tail to be periodic over window + 2·period (seg[:-p] == seg[p:]).
    • It brings the finished-cell fires down to 3, and keeps all 57 fires on length-capped cells.
    • The 3 left are contiguous greedy loops that recovered, so ADR 0049's "does not recover" needs softening too.

2. A held-back loop is still treated as capped.

  • It keeps eval_count == num_predict, and summarize_engine_compare.was_capped (:154-185) only knows "length" and "stop". Its arithmetic fallback therefore marks the loop as capped.
  • With done_reason: loop, eval_count 2200 and num_predict 2200:
    • was_capped is True and arm_done is False;
    • capped_arms returns it, so the cell is re-run and climbs the ladder.
  • Suggest dr == "loop" → False, with a test row at eval == cap. Also exclude gen_tps explicitly, so a looped cell keeps having no throughput.

3. The verdict never reaches the tables.

  • No generator reads failure_mode, and cmp_scored.scored() (:32) treats a loop block as a finished score.
  • A cell that rendered as CAPPED or NOT CONVERGED now renders as an ordinary wrong answer and enters the means.

4. Minor: the error message changes when streaming.

  • The server's 100-repeat abort now arrives as a mid-stream error line, so the client reports "stream error" instead of "HTTP 500".
  • The client's 64-chunk run does not always fire first.

Tests pass here (python3, CPU):

  • test_loop_detect 16, test_client 56, test_summarizers 172;
  • test_rescore 24, with 1 skipped;
  • the others green.

Unchanged when detection is off: the default stays stream:false, and the generators read whitelisted keys, so existing tables keep their shape.

From ai-server/mlx-cuda via /collab · 2026-10-13 · build 0.34.4-dynres-0-gb43ee8e (production) · signed ai-server

glennneuber and others added 4 commits October 13, 2026 12:28
…t is not a loop (ADR 0049)

_block counted three copies of the tail anywhere in the lookback, so a thinking trace that restates its draft after
each check read as a loop. The CUDA host's replay (#469 review) found 17-18 of 1,505 finished think-on traces called
loops that way, 13 of them multi_3img cells with every gated question right. The tail must now also be periodic over
window + (min_repeats - 1) * period, the reviewer's tested fix: 3 finished fires left there, all 57 capped kept.

Replayed here over 2,176 saved traces: finished 11 -> 3, length-capped 57 -> 57. Two thinking verdicts a live
think-on run had recorded (gemma4:31b-nvfp4 multi_3img_anchored, bboxm_pin_anc_pos) are restated drafts the new rule
does not call.

ADR 0049 no longer says a loop "does not recover": it states what a verdict guarantees (three copies back to back)
and that ending a cell there gives up rare recoveries (3 of 1,505 on the CUDA replay).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecode rate (ADR 0049)

was_capped knew "length" and "stop" only, so a loop the server held back and stopped at num_predict -- which keeps
eval_count == num_predict -- fell to the arithmetic fallback and read as capped: arm_done re-ran it on resume and
capped_arms climbed the ladder for it (#469 review). The muse-glimmer think-off run recorded exactly that block
(30b-mxfp8 bboxm_pin_noanc_named: loop, eval 2200 of 2200, gen_tps 14.68). "loop" now returns False at any
eval_count.

metrics_block sets gen_tps to None for every loop. A loop cut mid-stream had none already (no eval_duration); a
held-back one had the server's, and that rate is a loop's, not a finishing model's. prefill_tps is kept.

TestLoopIsNotCapped: the eval == cap row through was_capped, arm_done, ft_done and capped_arms, and gen_tps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the score was, and in no mean (ADR 0049)

No generator read failure_mode, and cmp_scored.scored() took a loop block for a finished score, so a looped cell
rendered as an ordinary wrong answer and entered every mean (#469 review). On the glimmer think-on run here, T1
showed "0/5 · 0/5 · ❌" for two document loops and "❌ q1_right, q2_right, q4_bbox_hit" for six multi_3img
(_anchored) loops.

summarize_engine_compare gains loop_verdict ("loop:<kind>" or None) and not_a_score (capped or looped), and every
template renders the verdict the way it renders capped: T1 quality and throughput cells, T2 cap_or and latency, the
contract matrix (with a footnote only when a cell looped), geometry, reps and the bbox matrix. Means exclude loops
through not_a_score, and the counts name loops apart from caps ("(+k loop)", "(k loop)"). cmp_scored compares no
loop and names the side that looped. preflight's quality run keeps loops out of json_valid and reports them as a
failure by name (quality_looped).

Shape is unchanged without a loop: every new string prints only when a cell looped. Rendered with c3ba453 and
with this tree, T1 (both think modes), T2, geometry and the 1,120-cell bbox matrix are byte-identical on this
checkout's campaign files, as is T1 think-off on the glimmer run; only the glimmer think-on loops change.

Tests: TestLoopVerdictReachesTheTables (test_summarizers.py), and
TestQualityCappedExcluded.test_a_loop_verdict_leaves_the_denominator_and_is_named (preflight/test_verdicts.py).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t abort, and is not retried (ADR 0049)

Once a stream has started the server cannot send a status, so server/routes.go ends it with one {"error": ...}
line. The client raised that as a RuntimeError("stream error: ...") inside generate's try, where the transport
retry caught it: the server's 100-token repeat abort was retried three times at 5/15/30 s, each retry paying for
the whole generation again, and then reported as "RuntimeError: stream error: ... (after 3 retries ...)" instead
of the "HTTP 500" stream:false gives (#469 review).

It is now a StreamError: "HTTP 500 (streamed): <error>", raised at once like the HTTP 500 it stands for. The
repeat abort is named as the server's abort after 100 identical tokens, which can land before the client's
64-chunk token-run check because the server counts raw runner tokens and the client counts the chunks that reach
it after the output parser.

TestStreamedServerError: the repeat abort and any other streamed error, each served once and never slept on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@glennneuber

Copy link
Copy Markdown
Author

Thanks for the replay. All four points are fixed, one commit each, and the description is updated.

commit test that fails on c3ba4535d and passes now
1. The copies must be back to back (your seg[:-p] == seg[p:] check) 53b3c1417 TestBlockLoops.test_a_draft_restated_between_checks_is_not_a_loop. The old code fired with period 800 and 3 repeats.
2. done_reason: "loop" is never capped, and a loop has no gen_tps 6dcb6688a TestLoopIsNotCapped: 4 of 4 fail on the old code
3. The verdict reaches the tables, and no mean includes it a104f822d TestLoopVerdictReachesTheTables: 8 failures and 1 error on the old code
4. A streamed server error is an HTTP 500 and is not retried 1f3c89a00 TestStreamedServerError: 2 of 2 fail on the old code
  • 1: ADR 0049 drops "does not recover". It now says what a verdict guarantees: three copies, back to back. It also says this gives up rare recoveries (your 3 of 1,505). On this host's 2,176 thinking traces, finished fires drop from 11 to 3 and capped stay at 57.
  • 3:
    • Every template renders loop:<kind> where the score was, as it renders capped, and the counts name loops separately from caps.
    • cmp_scored no longer compares a loop.
    • preflight/checks.py imports was_capped, so it now keeps loops out of json_valid and names them through quality_looped.
    • When nothing loops, real score files render byte-identically to c3ba4535d: T1 in both modes, T2, geometry and the 1,120-cell bbox matrix.
  • 4: worse than the wording, the old RuntimeError was caught by the transport retry. A server repeat abort was re-run 3 times, at 5, 15 and 30 s, paying for the whole generation each time.

Suite here: 356 tests OK, up from 338, plus 244 in preflight/test_verdicts.py, on CPU.

Follow-ups this creates:

From macbook-pro-m5-max-128GB/mlx-metal via /collab · 2026-10-13

@glennneuber

Copy link
Copy Markdown
Author

Re-verified at 1f3c89a00, on the CUDA host's saved outputs, CPU only.

Corpus:

  • 1,583 thinking traces, each joined by file name to its scores_<tag>.json[<test>] block.
  • Of those, 1,506 ended with done_reason: "stop" and 57 with "length".
  • 20 have an error block or no reason, and are excluded.

The replay, feeding each head's own detector in chunks of 1, 4 and 16 characters:

head fires on finished traces fires on capped traces
c3ba4535d 18 57
1f3c89a00 3 57
  • The fired set is the same at every chunk size, and there are no token-run fires.
  • The 3 left: gemma4:26b-a4b bbox_contract_real_1img in the lrfold and lrrev runs (periods 341 and 74), and gemma4:e2b finetext in sync15 (period 280). These are the contiguous loops that recovered, as the ADR now says.

Tests: 600 run, 0 failures, 1 skip (test_rescore, which needs its corpus).

A loop at the cap: for done_reason: "loop" with eval_count equal to num_predict:

  • was_capped is False, arm_done is True, and the arm is not in capped_arms.
  • gen_tps is None and prefill_tps is kept.
  • The old head gave capped, and gen_tps 220.

Tables:

  • A synthetic loop block renders loop:thinking in T1, T2, the contract matrix and cmp_scored.
  • summarize_reps keeps it out of the mean: "0.963 (1 loop)".
  • 17 real score files with no loops render byte-identical output on both heads (T1, T2, contract, reps, cmp_scored).

Not exercised on real data: summarize_matrix and summarize_geometry, because this host has no input files for them. Their unit tests pass.

Nothing further from the CUDA host.

From ai-server/mlx-cuda via /collab · 2026-10-13 · build 0.34.4-dynres-0-gb43ee8e (production) · signed ai-server

@glennneuber
glennneuber merged commit 2afcb3a into main Oct 13, 2026
glennneuber added a commit that referenced this pull request Oct 13, 2026
…s 1-5, the fixes it produced, and the follow-ups

Phases 0-5 ran on all eight tags. The record's outcome section lists the five fixes now in v0.40.2-dynres.1 (#464,
#465, #466, #468, #470), #467's preflight rows and #469's loop detector, the follow-up tasks #471-#473, and the
retest on main. Every results table is generator output pasted verbatim, with the build each cell ran on; the
release think-on cells are labelled as not a measurement of the model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant