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.
| Signal | Haiku requires | Default | Rationale (benchmark finding) |
|---|---|---|---|
| images | <= HAIKU_MAX_IMAGES | 0 | Haiku dropped figures + produced WRONG alt text on named people (beacom-flyer) |
| widgets | <= HAIKU_MAX_WIDGETS | 8 | form-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_COVERAGE | 0.10 | all regressions were on visual/figure-heavy pages |
| prose_fraction (dense pages only) | >= HAIKU_MIN_PROSE_FRACTION | 0.60 | packed equation/table lines are not prose; gate applied only above HAIKU_PROSE_GATE_MIN_DENSITY (30) so genuinely sparse pages stay eligible |
| equation-like text | none present | β | Haiku tagged equations as Figures (math-review); any math-signal line β Sonnet |
| lines | >= HAIKU_MIN_LINES | 3 | an empty/near-empty page is not a safe prose candidate |
density (lines + WIDGET_WEIGHT*widgets) | <= HAIKU_MAX_DENSITY | 80 | dense/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:
| Doc | Profile | Pages | β Haiku | β Sonnet |
|---|---|---|---|---|
| beacom-flyer | figure-heavy | 1 | 0 | 1 |
| math-review | prose+equations | 6 | 1 | 5 |
| bsee-plan | table/form-dense (238 widgets) | 3 | 0 | 3 |
| ut-resources | mixed prose+figure | 5 | 2 | 3 |
| contact-form | sparse form | 1 | 1 | 0 |
| Total | 16 | 4 | 12 |
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 var | Default | Controls |
|---|---|---|
FLYER_TAGGER_TIERED_ROUTING | (off) | master switch β falsey = every page on the base model |
FLYER_TAGGER_HAIKU_MODEL | us.anthropic.claude-haiku-4-5-20251001-v1:0 | Haiku (safe-lane) model id |
FLYER_TAGGER_SONNET_MODEL | us.anthropic.claude-sonnet-4-6 | Sonnet (complex-lane) model id |
FLYER_TAGGER_HAIKU_MAX_IMAGES | 0 | max images for the Haiku lane |
FLYER_TAGGER_HAIKU_MAX_WIDGETS | 8 | max form widgets for the Haiku lane |
FLYER_TAGGER_HAIKU_MAX_GRAPHIC_COVERAGE | 0.10 | max fraction of page area covered by images |
FLYER_TAGGER_HAIKU_MIN_PROSE_FRACTION | 0.60 | min prose-line fraction (dense pages only) |
FLYER_TAGGER_HAIKU_MAX_DENSITY | 80 | max lines + WIDGET_WEIGHT*widgets |
FLYER_TAGGER_HAIKU_MIN_LINES | 3 | min text lines to be a prose candidate |
FLYER_TAGGER_HAIKU_PROSE_GATE_MIN_DENSITY | 30 | density above which the prose-fraction gate applies |
FLYER_TAGGER_PROSE_LINE_CHARS | 40 | min chars for a line to count as prose |
A/B (follow-up, before enablement)
cd services/flyer-taggerexport 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-benchpython tools/haiku_quality_diff.py /tmp/tiered-benchThe 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.