• Stefy Lanza (nextime / spora )'s avatar
    stop a looping page as it happens, not at its token cap (v0.4.39) · bca55f84
    Stefy Lanza (nextime / spora ) authored
    A VLM that loses its place on a page repeats the last thing it saw until
    it runs out of tokens, and the result arrives looking like a successful
    transcription. Every engine here already runs `detect_repeat_token` over
    the FINISHED reply and treats a looping one as retryable -- the waste is
    that the reply has to finish first, so a page that starts looping on its
    tenth token still emits its whole budget before anyone looks at it.
    
    Measured on 100 pages of a real judicial corpus (surya, A40, cap 4096 /
    1 continuation): 4 pages looped and accounted for 18.9% of the run's
    entire generation time. The worst spent 450 s emitting 17,586 characters
    of one repeated clause. Replayed through the shipped guard: 19.3% of
    generation time not spent, 0.560 -> 0.690 pages/s (+23%), worst page
    450 s -> 67 s. Over all four measured rungs, 12 of 12 looping pages
    caught and ZERO false positives across 366 clean pages.
    
    WHY THIS IS SAFE TO HAVE ON BY DEFAULT: it is the same function with the
    same thresholds that already judges the finished reply. A page abandoned
    here is a page the post-hoc check would have rejected anyway, reached
    sooner. Nothing new decides anything. Two measured margins against
    cutting a page that merely LOOKS repetitive for a while: nothing is
    judged before 2000 characters (the largest genuine page in the corpus is
    5,054, median ~1,900), and the detector must fire on 2 consecutive
    checks. Swept over 393 page reads -- at 1200/1 there was one false
    positive, a page with a repetitive middle and genuine text after it; at
    2000/2 none, with all 12 real loops still caught.
    
    Closing the stream is the point: the server cancels the sequence when the
    client goes away, so the tokens not waited for were never generated.
    
    chandra and olmocr own their HTTP call, so they stream it directly. surya
    does not -- it builds and sends its own request -- so the CLIENT it is
    handed is wrapped instead, in the worker hook we already own, using
    SURYA'S OWN detector so the abort and surya's retry decision can never
    disagree. The worker keeps its no-codai-imports rule.
    
    Four defects found while writing it, each worse than the feature:
    
      * one check per CALL made a persistence above 1 unreachable for a
        caller that fed a whole reply at once, and made every verdict a
        function of the server's chunk framing. Now one check per step
        boundary crossed.
      * falling back to a blocking call after a failed stream re-ran the
        page: two generations billed, and the second reply mistaken for the
        continuation of the first. Only a failure that consumed NOTHING is
        retryable now (StreamUnsupported); a server that ignores `stream`
        has its complete body read rather than discarded, so the common case
        costs no extra call at all.
      * a local `from codai.ocr.base import OcrError` shadowed the
        module-level one for the whole function, so `except OcrError` raised
        UnboundLocalError and swallowed a mid-stream transport failure as
        retryable -- the one case that must never be retried.
      * the surya hook installs on `_trace or _repair`, so the abort
        depended silently on `repair_accents`. Now named in that condition.
    
    A server proved unable to stream is not asked again (one wasted request
    per server per process, not one per page). 4xx to a streaming request
    means "cannot stream" and falls back; 5xx is a real error and is not
    retried against a broken server.
    
    29 tests. Full suite 3849 passed, 15 pre-existing failures unchanged.
    Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01KnfkxBpcZ1v6ypRFFyhrVM
    bca55f84