Skip to content

Add OvisOCR2 support, with a gate fix for long-period loops - #43

Open
charitarthchugh wants to merge 1 commit into
mainfrom
worktree-feat-ovisocr2
Open

Add OvisOCR2 support, with a gate fix for long-period loops#43
charitarthchugh wants to merge 1 commit into
mainfrom
worktree-feat-ovisocr2

Conversation

@charitarthchugh

@charitarthchugh charitarthchugh commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Adds an OCR adapter for OvisOCR2 (ATH-MaaS/OvisOCR2, 0.8B, Qwen3_5ForConditionalGeneration), selected with --ocr-model ovisocr2. It is a compact end-to-end page parser post-trained from Qwen3.5-0.8B — 96.58 on OmniDocBench v1.6 (first end-to-end model to top that leaderboard) and 75.06 Avg3 on PureDocBench.

Along the way this uncovered a structural blind spot in the quality gate, fixed here, plus a new flag to switch individual checks off.

The adapter

Everything came from primary sources — the model card's own inference script, config.json, chat_template.jinja, and vLLM's engine args — then was verified against a live server.

Three details a plausible-looking guess would have gotten wrong:

  • Content-part order is load-bearing. chat_template.jinja applies render_content(...)|trim to the user turn. The vendor prompt begins with a literal \n; with the image part first, that newline sits mid-string and survives as the image/instruction separator. Text-first and |trim eats it — a different token sequence than the model was trained on.
  • No chat_template_kwargs needed. The template's generation prompt is {%- if enable_thinking is defined and enable_thinking is true %}, whose else-branch emits a pre-closed <think>\n\n</think>. A plain OpenAI request already means non-thinking, so the vendor's enable_thinking=False is the default.
  • The vendor's gdn_prefill_backend="triton" should not be copied. 18 of the model's 24 layers are Gated-DeltaNet linear_attention, whose prefill needs a chunked parallel scan; vLLM ships three implementations. The --gdn-prefill-backend flag that selects one is real but absent from vllm serve --help (registered in arg_utils.py). Its default is auto, and _resolve_gdn_prefill_backend already returns Triton on anything below Hopper, or the faster FlashInfer kernel on Hopper (SM90) and Blackwell (SM10.x, linear_key_head_dim == 128, CUDA >= 13). So pinning triton is a no-op on consumer cards and a downgrade on Hopper. The serve command below omits it; docs say to add it only to force the fallback if FlashInfer won't build.

Other adapter behaviour: max_tokens=16384 per the vendor (the pipeline's 8000 default truncates dense HTML tables); temperature stays pipeline-owned; <img src="images/bbox_…"> placeholders are stripped, since paperscale never writes the crops they reference, and those pages are reported as diagrams instead; is_table is recomputed from the emitted HTML.

The gate fix (affects every --ocr-model)

The vendor ships a _clean_truncated_repeats helper that trims a repeating tail out of long responses. This adapter deliberately does not port it: a looping page is an incomplete page, and rewriting it in parse hands the pipeline something that reads as clean and complete.

Putting that detection in the gate instead exposed a real gap. _has_repeated_ngram_loop scores count × ngram_size / tokens with ngram_size capped at 5, so it tops out at 5 / period:

loop period 50% of page 70% 90%
2 tokens REJECTED REJECTED REJECTED
5 tokens REJECTED REJECTED REJECTED
8 tokens accepted REJECTED REJECTED
13 tokens (a sentence) accepted accepted accepted
22 tokens accepted accepted accepted

A page that is 86% one repeated sentence was being accepted. _has_repeated_tail_loop tests whether a span near the end repeats at a fixed period, so period length stops mattering. It reuses OvisOCR2's vendor constants as a detector rather than a repair — the page is retried at a higher temperature instead of silently rewritten — and requires the same 0.35 share the n-gram check uses, which is what keeps genuinely repetitive form rows (measured at 0.23) from reading as a loop.

Two details a first cut got wrong, both caught in review:

  • The loop rarely reaches the final character. OvisOCR2 emits HTML tables, so a model looping table rows then closing the tag leaves </table> behind — and an end-anchored check accepted every such page. It now probes a ladder of end positions. Stepping back one character at a time does not work: on prose a character coincidentally lines up at the period within a few steps, which halts the search inside the trailer.
  • It runs on every page, so it is a slice comparison rather than a per-period character walk. The walk cost ~300 ms on a page whose tail repeats just under the share threshold; it is ~1.5 ms worst case now and 0.04 ms on ordinary pages. Cross-checked against the original walk over 5000 randomised inputs: strictly more sensitive, never less.

New flag

--disable-quality-check CHECK, repeatable, validated against the check vocabulary, with all to disable the gate entirely. Disabling one check leaves the others armed, which is the point: a corpus that systematically trips one gate can be processed without it, instead of loosening --max_page_error_rate for every failure mode at once. It logs a warning when active. Pairs with PAPERSCALE_DEBUG_REJECTS to see what a gate was dropping before switching it off.

Verification

Live smoke test against vLLM 0.27.1 serving the model on an RTX 3090: 2 pages, 0 failures, 0 retries, 0 quality-gate rejects, finish_reason: stop.

Sending the adapter's exact payload and inspecting the unprocessed response confirmed the parts that unit tests cannot: content parts arrived as ['image_url', 'text'] with the prompt's leading newline intact, and the raw output contained <img src="images/bbox_94_615_891_757.jpg" />, which parse() reduced to zero img tags with is_table=True, is_diagram=True. Transcription matched the pdftotext ground truth — all six table columns and three data rows exact, including (3,664.60) and the em-dash in Escrow — Class B, with the display formula as well-formed LaTeX.

poetry run pytest -q378 passed, 1 skipped, 34 subtests, 0 failed (needs --extras tui; without it 16 test_tui.py tests fail on a missing rich, unrelated to this change). ruff check and ruff format --check clean; no line exceeds the declared 160.

The change also went through a two-axis review (standards + spec). Acted on: the end-anchoring bug above; deduplicating the <think>-strip and HTML-table flag out of qianfan.py into markdown.py; removing a silent getattr fallback so the verifier on args is always the one a page is judged by; moving the "all" expansion beside QUALITY_CHECK_CODES; making the vocabulary-drift test actually detect drift in both directions; and adding end-to-end coverage for the CLI flag from argv through to the verifier.

Serving

vllm serve ATH-MaaS/OvisOCR2 --port 8000 \
  --limit-mm-per-prompt '{"image": 1}' \
  --mm-processor-kwargs '{"images_kwargs": {"min_pixels": 200704, "max_pixels": 8294400}}'

Reviewer notes

  • The gate change is the part worth scrutiny — it changes page acceptance for every model, not just this adapter. The adapter alone is additive.
  • Smoke testing used a synthetic one-page document (the legal corpus was not mounted). It validates plumbing and format handling end-to-end, but says nothing about scanned-page accuracy.
  • Verified against vLLM 0.27.1; the vendor pins 0.22.1.
  • Pre-existing and untouched: math delimiters differ across adapters. OvisOCR2 emits $$…$$ (like qianfan-ocr), whereas surya2 converts to \[…\] / \(…\). Worth normalising separately if that matters.

OvisOCR2 (ATH-MaaS/OvisOCR2) is a 0.8B end-to-end page parser post-trained
from Qwen3.5-0.8B. Selected with `--ocr-model ovisocr2`.

Adapter details taken from primary sources (the model card's own inference
script, config.json, chat_template.jinja, vLLM engine args), and verified
against a live vLLM 0.27.1 server:

* Vendor prompt verbatim, including its leading newline and the literal
  {left}/{top} braces.
* Image content part before the text part. The chat template trims the ends
  of the rendered user turn, so a text-first layout would eat that leading
  newline and send a different token sequence than the model was trained on.
* max_tokens=16384 per the vendor; the pipeline's 8000 default truncates
  pages of dense HTML tables. Temperature stays pipeline-owned.
* Strips the `<img src="images/bbox_...">` placeholders (paperscale never
  writes the crops they reference) and reports those pages as diagrams;
  recomputes is_table from the emitted HTML. Confirmed against a real
  response containing `<img src="images/bbox_94_615_891_757.jpg" />`.

The `<think>`-block strip and the HTML-table flag were duplicated verbatim
from qianfan.py, so both move to markdown.py as strip_think_blocks() and
has_html_table(), and qianfan.py now uses them too.

The adapter deliberately does NOT port the vendor's _clean_truncated_repeats
tail trimmer. A looping page is an incomplete page, and rewriting it in parse
would hand the pipeline something that reads as clean and complete.

That detection belongs in the quality gate, which turned out to have a
structural blind spot: _has_repeated_ngram_loop scores
count * ngram_size / tokens with ngram_size capped at 5, so it tops out at
5 / period. A looped sentence (13+ tokens) stays under the 0.35 threshold
however much of the page it consumes -- measured at 0.35 even when the loop
was 90% of the output, i.e. accepted. _has_repeated_tail_loop tests instead
whether a span near the end of the page repeats at a fixed period, so period
length stops mattering. It reuses OvisOCR2's vendor constants as a detector
rather than a repair, and requires the same 0.35 share, which is what keeps
genuinely repetitive form rows (measured at 0.23) from being read as a loop.
This changes acceptance for every --ocr-model, not just ovisocr2.

Two details that a first cut got wrong, both caught by review:

* The loop rarely runs to the final character -- OvisOCR2 emits HTML tables,
  so a model looping table rows then closing the tag left "</table>" behind
  and an end-anchored check accepted the page. The check now probes a ladder
  of end positions. Stepping back one character at a time does not work: on
  prose a character coincidentally lines up at the period within a few steps,
  which stops the search inside the trailer.
* The check runs on every page, so it is a slice comparison rather than a
  per-period character walk. The walk cost ~300ms on a page whose tail
  repeats just under the share threshold; it is ~1.5ms now, worst case, and
  0.04ms on ordinary pages. Cross-checked against the original walk over
  5000 randomised inputs: strictly more sensitive, never less.

Also adds `--disable-quality-check CHECK` (repeatable, `all` disables the gate
entirely) so a corpus that trips one gate systematically can be processed
without it, instead of loosening --max_page_error_rate for every failure mode
at once. The "all" expansion lives beside QUALITY_CHECK_CODES in verifier.py,
and main() sets the run's verifier on args, with the argparse default keeping
the fully armed gate for callers that bypass main().

Co-Authored-By: Claude Opus 5 (1M context) <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