Skip to content

Polish / flyer-tagger β€” tiered model routing (issue #3132, epic #3134)

Implements the recommendation from the Haiku benchmark (#3131, 2026-09-16-polish-haiku-benchmark.md): a flat Haiku switch is a NO-GO, but sparse/prose pages are near-parity on Haiku while figure-/equation-/form-dense pages regress. This lands a conservative per-page router that sends only provably-safe pages to Haiku 4.5 and keeps everything else on Sonnet 4.6.

Gated OFF by default (FLYER_TAGGER_TIERED_ROUTING unset/falsey) β†’ prod is unchanged (every page runs the base/Sonnet model) until the flag is flipped after live validation.

Routing predicate

A page earns the Haiku lane only when every conservative safety gate clears (route_reason() in analyze.py returns None); any failing OR unknown signal keeps Sonnet (bias-to-Sonnet β€” this is a compliance product, and false savings that corrupt alt text are unacceptable). All thresholds are env-tunable.

Both tier model ids are overridable: select_model_for_page returns TIERED_HAIKU_MODEL (FLYER_TAGGER_HAIKU_MODEL) for the safe lane and TIERED_SONNET_MODEL (FLYER_TAGGER_SONNET_MODEL) for the complex lane β€” symmetric, so an operator can move either id without a code change.

SignalHaiku requiresDefaultRationale (benchmark finding)
images<= HAIKU_MAX_IMAGES0Haiku dropped figures + produced WRONG alt text on named people (beacom-flyer)
widgets<= HAIKU_MAX_WIDGETS8form-heavy doc regressed; 8 mirrors preflight FORM_CENTRIC_FIELDS_PER_PAGE β€” admits a contact form, excludes real multi-field forms
graphic_coverage<= HAIKU_MAX_GRAPHIC_COVERAGE0.10all regressions were on visual/figure-heavy pages
prose_fraction (dense pages only)>= HAIKU_MIN_PROSE_FRACTION0.60packed equation/table lines are not prose; gate applied only above HAIKU_PROSE_GATE_MIN_DENSITY (30) so genuinely sparse pages stay eligible
equation-like textnone presentβ€”Haiku tagged equations as Figures (math-review); any math-signal line β†’ Sonnet
lines>= HAIKU_MIN_LINES3an empty/near-empty page is not a safe prose candidate
density (lines + WIDGET_WEIGHT*widgets)<= HAIKU_MAX_DENSITY80dense/banded pages are complex by construction

graphic_coverage and prose_fraction are computed per-page from the PageInventory we already have (no extra render) β€” graphic_coverage sums image bboxes (an over-estimate, i.e. safe direction), prose_fraction mirrors preflight’s long-line (>=40 char) heuristic. density reuses the shared _density helper (lines + WIDGET_WEIGHT * widgets, issue #2393) so the routing gate tracks the same output-size proxy as banding.

Equation heuristic (_looks_like_math)

The equation gate flags a line as math on EITHER a single strong unicode math glyph (relational/operator/greek/sub-superscript ranges) OR two-or-more glyphless ASCII math signals β€” a variable-term (a letter glued to a digit: 3x, x2) or an arithmetic operator (+ - * / ^) sitting between two operands (x^2, 3 + 4). Requiring two signals catches 3x + 2y (glyphless algebra) while leaving ordinary prose with one incidental operator on the Haiku lane.

Residual gap (accepted, bias-to-Sonnet): prose that stacks two or more operator-between-operands patterns on one line β€” e.g. β€œfast-paced and/or dynamic” (two hyphen/slash pairs) or β€œwe meet 9 - 5 and 1 - 2” β€” over-trips to Sonnet. A math false positive only forces the higher-quality (more expensive) Sonnet lane, never a Haiku regression, so this is the safe direction: it costs a little of the potential saving on operator-dense prose, and never corrupts output. Underscore subscripts (x_1, markdown-style) are NOT detected; genuine PDF subscripts arrive as unicode (x₁) and ARE covered.

Per-model call tuning (Haiku rejects adaptive thinking)

The benchmark noted Haiku 4.5 rejects the analysis call’s adaptive-thinking config on Bedrock (400) and the tagger transparently retries β€” wasting a whole billed prompt per page. The Haiku lane must be correct on the FIRST call, so use_thinking now starts from _model_supports_thinking(model) (False for Haiku and Nova). The transparent retry remains as a backstop for any other model. The FLYER_TAGGER_EFFORT cap (#3135, now merged to main) rides in output_config only alongside adaptive thinking (_output_config(schema, with_effort=...)), so it is correctly omitted on the (no-thinking) Haiku call. This PR builds on the merged #3135 effort implementation β€” it does NOT re-introduce a second copy; it only parameterizes _output_config so the Haiku lane can omit effort on the first call.

Validation

Routing decisions on the benchmark doc set (deterministic, no model calls)

FLYER_TAGGER_TIERED_ROUTING=on, per page:

DocProfilePages→ Haiku→ Sonnet
beacom-flyerfigure-heavy101
math-reviewprose+equations615
bsee-plantable/form-dense (238 widgets)303
ut-resourcesmixed prose+figure523
contact-formsparse form110
Total16412

Every doc the benchmark flagged as unsafe routes its risky pages to Sonnet: beacom-flyer (images) and bsee-plan (widgets) are 100% Sonnet; math-review’s equation pages are Sonnet (only the pure-prose intro is Haiku); ut-resources’ figure pages are Sonnet. The benchmark’s SAFE lane (contact-form) is captured on Haiku, plus 2 text-only ut-resources pages.

Live Bedrock run (contact-form, Haiku lane)

AWS_PROFILE=accessible-sso, ANTHROPIC_API_KEY unset (Bedrock/ZDR), flag on:

  • Log: page 0: tiered routing β†’ Haiku (us.anthropic.claude-haiku-4-5-20251001-v1:0)
  • No β€œrejected adaptive thinking” warning β€” correct on the first call (vs the benchmark, where every Haiku page paid the retry).
  • Output tokens materially lower than the Sonnet baseline for the same page (Haiku lane + no thinking), per the #3131 benchmark’s qualitative finding that sparse/prose pages are near-parity on Haiku at substantially lower cost.
  • veraPDF PDF/UA-1: compliant. No structural/reading-order regression on the Haiku output.

Cost delta

Complex docs route to Sonnet β†’ quality-identical to baseline by construction (same model, same prompt, same thinking). Savings come only from the safe pages. Haiku’s per-token Bedrock rate is a fraction of Sonnet’s, so each safe page it captures is materially cheaper. The savings scale with a doc’s share of sparse/ prose pages: a prose-dominant report captures far more than a figure/form-dense one, which is the intended risk-weighted tradeoff. Actual per-doc deltas are the subject of the pre-enablement A/B (below) β€” not yet measured on the full set.

ZDR

Both tiers are Bedrock Anthropic ids routed through AnthropicBedrock in _client_and_model. No google/gemini/genai/vertex code path exists in services/flyer-tagger/ (grep-confirmed). Posture is structural.

How to validate before enabling

Env-tunable knobs

Every threshold and both model ids are overridable without a code change, so the router can be re-tuned against a fresh benchmark before flipping the flag:

Env varDefaultControls
FLYER_TAGGER_TIERED_ROUTING(off)master switch β€” falsey = every page on the base model
FLYER_TAGGER_HAIKU_MODELus.anthropic.claude-haiku-4-5-20251001-v1:0Haiku (safe-lane) model id
FLYER_TAGGER_SONNET_MODELus.anthropic.claude-sonnet-4-6Sonnet (complex-lane) model id
FLYER_TAGGER_HAIKU_MAX_IMAGES0max images for the Haiku lane
FLYER_TAGGER_HAIKU_MAX_WIDGETS8max form widgets for the Haiku lane
FLYER_TAGGER_HAIKU_MAX_GRAPHIC_COVERAGE0.10max fraction of page area covered by images
FLYER_TAGGER_HAIKU_MIN_PROSE_FRACTION0.60min prose-line fraction (dense pages only)
FLYER_TAGGER_HAIKU_MAX_DENSITY80max lines + WIDGET_WEIGHT*widgets
FLYER_TAGGER_HAIKU_MIN_LINES3min text lines to be a prose candidate
FLYER_TAGGER_HAIKU_PROSE_GATE_MIN_DENSITY30density above which the prose-fraction gate applies
FLYER_TAGGER_PROSE_LINE_CHARS40min chars for a line to count as prose

A/B (follow-up, before enablement)

Terminal window
cd services/flyer-tagger
export AWS_PROFILE=accessible-sso AWS_REGION=us-east-1 PYTHONPATH=$(pwd)
export FLYER_TAGGER_TIERED_ROUTING=on
# Full A/B (tiered vs all-Sonnet) via the #3136 harness, then quality diff:
python tools/haiku_benchmark.py --out /tmp/tiered-bench
python tools/haiku_quality_diff.py /tmp/tiered-bench

The heavier tiered-vs-Sonnet A/B across the full benchmark set is a documented follow-up β€” it has NOT been run for this PR. Do NOT enable the flag in prod until that A/B confirms the tiered output matches Sonnet on the complex docs and the measured cost delta is acceptable.