Repository navigation
vision-suite: a verbatim loop ends the cell as a verdict -- loop detected:<thinking|content|token-run> (ADR 0049) - #469
Conversation
…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>
|
CUDA host review, at 1. The thinking stream fires on traces that finished.
2. A held-back loop is still treated as capped.
3. The verdict never reaches the tables.
4. Minor: the error message changes when streaming.
Tests pass here (python3, CPU):
Unchanged when detection is off: the default stays From |
…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>
|
Thanks for the replay. All four points are fixed, one commit each, and the description is updated.
Suite here: 356 tests OK, up from 338, plus 244 in Follow-ups this creates:
From |
|
Re-verified at Corpus:
The replay, feeding each head's own detector in chunks of 1, 4 and 16 characters:
Tests: 600 run, 0 failures, 1 skip ( A loop at the cap: for
Tables:
Not exercised on real data: Nothing further from the CUDA host. From |
…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>
A verbatim loop now ends a vision-suite cell as a verdict:
loop detected:thinking,:contentor:token-run(ADR 0049). It is opt-in throughLOOP_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_predicton 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'sollama_toolkit(maxusai-open-bookkeeper-for-xero#117). maxusai-structured-docs-backend carries the same code in an nbdev notebook (#22).53b3c1417).HTTP 500 (streamed): prediction aborted, token repeat limit reached. The message names the abort, and the error is not retried (1f3c89a00).client.generatestreams whenLOOP_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;loop:<kind>in its place, and no mean pools it (loop_verdict/not_a_score,a104f822d);arm_donetreats it as finished andcapped_armsdoes not escalate it, so it is never re-run or climbed. That includes a held-back loop ateval_count == num_predict:was_cappedreturns False fordone_reason: "loop". A loop has nogen_tps(6dcb6688a).server_done_reason: "length"and the server's own metrics kept, exceptgen_tps: that rate is the loop's. A finished reply is never a loop.client.loop_fieldsstampsstream/loop_detect, plus the loop's fields when it fires, on vision_suite and finetext_probe blocks. Off by default, nothing changes shape.Validation
muse-glimmer:30b-mxfp8with the release build in production's environment, before any campaign cell used it:bboxm_pin_noanc_named, think off + format)loop detected:content, period 87,server_done_reason: length,eval_count2200 keptloop detected:contentat chunk 161 of a 1,500 budget, period 15, no server durationsdocument_single)stream: true, no failure modetest_loop_detect.py: 18, with the two real loops as fixtures.test_client.py: 58, covering streaming, the verdict contract, buffered loops,loop_fieldsand the streamed server error.test_summarizers.py:TestLoopIsNotCappedandTestLoopVerdictReachesTheTables.preflight/test_verdicts.py: a loop is excluded from quality.Not covered
client.savewrites 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