Routing and auto mode
parsecraft.routing turns per-page analysis into a routing plan: which backend
runs on which page, and which fallbacks follow. Planning is a pure, deterministic
function; an optional judge may re-rank eligible candidates but never change the
rules. The decision is recorded in ADR-0004.
Pipeline¶
flowchart LR
A[Source document] --> B["analyze() → AnalysisResult"]
B --> C["Intent per page<br/>(routing/rules.py)"]
C --> D["Eligible candidates<br/>(RoutingConstraints)"]
D --> E{judge provided?}
E -- no --> F[DeterministicJudge]
E -- yes --> G[RoutingJudge.rank]
F --> H[RoutingPlan]
G --> H
Analysis runs first and produces per-page signals. Those signals classify each
page into an intent, constraints filter the backend catalog to eligible
candidates, and the judge orders them. The result is a RoutingPlan.
Execution flow¶
parsecraft.pipeline executes a plan. execute() plans and dispatches in one
call: it builds the plan itself, groups contiguous pages, converts each group
with its chosen backend, retries fallback candidates on typed failure, and
aggregates a DocumentResult.
sequenceDiagram
autonumber
actor Caller
participant P as pipeline.execute
participant R as routing.plan_route
participant J as RoutingJudge
participant Reg as BackendRegistry
participant B as DocumentBackend
Caller->>P: execute(analysis, registry, constraints, source, judge)
P->>R: plan_route(analysis, registry.list_backends(), constraints, judge)
R->>J: rank(intent, eligible candidates)
J-->>R: ordered backend names
R-->>P: RoutingPlan
loop each PageGroup — contiguous pages, one chosen backend
P->>Reg: create(chosen, config)
Reg-->>P: backend
P->>B: convert(ConversionRequest(page_range=PageRange))
B-->>P: BackendResult — pages + failures
opt attempt failed — typed PassFailure or page mismatch
P->>Reg: create(next candidate)
end
end
P-->>Caller: PipelineResult(document, plan, groups)
Execution API¶
Exported from parsecraft.pipeline: PageGroup, PassAttempt,
PipelineResult, execute.
def execute(
analysis: AnalysisResult,
registry: BackendRegistry,
constraints: RoutingConstraints,
source: SourceDocument,
judge: RoutingJudge | None = None,
*,
produced_at: datetime | None = None,
) -> PipelineResult
execute takes no RoutingPlan and no ConversionRequest: it calls
plan_route itself and builds one ConversionRequest per group.
| Type | Field | Type | Meaning |
|---|---|---|---|
PipelineResult |
document |
DocumentResult |
Aggregated IR document |
PipelineResult |
plan |
RoutingPlan |
The plan that was executed |
PipelineResult |
groups |
list[PageGroup] |
Dispatch groups with per-attempt records |
PageGroup |
page_numbers |
list[int] |
Contiguous pages in the group |
PageGroup |
intent |
Intent |
Shared intent |
PageGroup |
candidates |
list[str] |
Shared candidate order |
PageGroup |
winner |
str or None |
Backend that succeeded, None if every pass failed |
PageGroup |
attempts |
list[PassAttempt] |
Every attempt, in candidate order |
PassAttempt |
backend |
str |
Backend tried |
PassAttempt |
status |
PassStatus |
ok or failed |
PassAttempt |
failure |
PassFailure or None |
Typed failure when the attempt failed |
Aggregation builds DocumentMetadata (source_uri, source_hash from the
analysis, format from the source media type, page_count, produced_at) and
one TraceEntry per attempt, with page_range set to the group's range and
pass_kind native or visual by intent. An attempt that reports multiple
PassFailures emits one trace entry per failure, while PassAttempt.failure
keeps the first.
Signal to intent¶
Intent is a StrEnum with four members:
| Member | Value | Meaning |
|---|---|---|
NATIVE |
native |
Native text is present and usable |
OCR_GENERAL |
ocr |
OCR is needed; no table/figure hint |
OCR_TABLES |
ocr-tables |
Multi-page document with a tables hint |
OCR_VISION |
ocr-vision |
Equations or figure-heavy pages |
Per page, in order (constants live in routing/rules.py):
| Constant | Value | Use |
|---|---|---|
NATIVE_MIN_TEXT_CHARS |
40 |
Below this, the page is treated as needing OCR |
MAX_REPLACEMENT_RATIO |
0.05 |
Above this replacement-character ratio, OCR |
HEAVY_IMAGE_COUNT |
5 |
Document image total that implies figure-heavy |
A page needs OCR when it is blank, has no native text, has fewer than
NATIVE_MIN_TEXT_CHARS characters, or has a replacement-character ratio above
MAX_REPLACEMENT_RATIO. The OCR flavor comes from document-level diagnostics
(feature:tables, feature:equations, feature:figures) or from
sum(image_count) >= HEAVY_IMAGE_COUNT:
- Tables hint and
page_count > 1→OCR_TABLES. - Equations or figures hint →
OCR_VISION. - Otherwise →
OCR_GENERAL.
Pages that do not need OCR route as NATIVE.
Eligibility¶
A candidate is excluded from every candidates list unless it satisfies all
hard constraints. These are code-owned; a judge never sees an ineligible
backend.
| Rule | Excludes |
|---|---|
| Installed extras | optional_dependency_group not in constraints.installed_extras |
| VRAM budget | requires_gpu with estimated_vram_gb unset or greater than vram_budget_gb |
| Format coverage | constraints.formats not fully covered by supported_formats |
| OCR switch | allow_ocr=False and the backend is an OCR backend |
| Offline | offline=True and the backend carries a ModelAssetDescriptor |
| Language | A candidate that declares a non-empty languages set, when the requested language is not in it |
Language narrows declarations, not the field: an empty languages tuple means
the backend makes no language claim, so a request can never disqualify it (see
Language).
The intent family is also fixed:
| Intent | Eligible family |
|---|---|
NATIVE |
Eligible native backends lead, OCR backends are fallbacks. At least one eligible non-OCR backend is required |
OCR_GENERAL, OCR_TABLES, OCR_VISION |
OCR backends only |
A NATIVE page with native text but no eligible native backend for its format
(say a text/csv source with no CSV native backend) raises
NoEligibleBackendError with intent=Intent.NATIVE rather than routing a
native-intent page to OCR.
Eligibility funnel¶
flowchart TD
A["Catalog — registry.list_backends()"] --> B["installed extras"]
B --> C["VRAM budget"]
C --> D["format coverage"]
D --> E["allow_ocr switch"]
E --> F["offline / model assets"]
F --> G["intent family"]
G --> H["eligible candidates"]
The filters run in this order. A backend that fails any stage never reaches the judge; the funnel is the only path from catalog to candidates.
Constraints¶
RoutingConstraints (parsecraft.routing) holds the hard limits. All fields are
code-owned at plan time and may be set per call.
| Field | Type | Default | Meaning |
|---|---|---|---|
formats |
set[str] |
empty | Source formats the plan must serve; a candidate must cover all of them. Empty means no restriction |
installed_extras |
set[str] |
empty | Extra names present in the environment |
vram_budget_gb |
float |
0.0 |
VRAM ceiling (ge=0) |
max_passes |
int |
1 |
Caps len(PageRoute.candidates) (ge=1) |
allow_ocr |
bool |
True |
When false, OCR backends are ineligible |
offline |
bool |
True |
When true, model-asset backends are ineligible |
language |
str or None |
None |
Requested BCP-47 document language; None places no language restriction |
Language (optional)¶
Language is declared by backends and requested by the caller; the deterministic core only reads the plain data and never detects anything itself.
| Layer | Field | Meaning |
|---|---|---|
| Declared | BackendCapabilities.languages |
BCP-47 tags the backend claims; empty tuple = no claim (language-agnostic) |
| Requested | RoutingConstraints.language |
BCP-47 tag for the document; None = unrestricted |
Eligibility only narrows a declaration: a candidate with a non-empty languages
set is excluded when the requested language is not in it, while an empty tuple is
never excluded — agnostic is the fail-safe direction, because set membership
cannot express broad coverage. Declared values (verified against model card
metadata, 2026-09-27): ocr-tele declares zh and en; ocr-ovis,
ocr-unlimited, ocr-qianfan, the native family, and liteparse are
language-agnostic (their cards say nothing, "multilingual", or claim broad
coverage).
Detection is an optional seam, like the judge. parsecraft.routing.language
defines LanguageDetector (detect_language(text) -> str | None); the core
never imports an implementation. The ollama/laya-backed detector is
parsecraft.providers.ollaya.OllayaLanguageDetector, loaded with
load_language_detector(...): it asks a typed choice question over BCP-47
candidates, returns None below a confidence floor (calibrated doubt means "not
identified"), raises OllayaJudgeError when the daemon is unreachable or answers
off-schema, and reads OLLAYA_BASE_URL (default http://localhost:11435).
Importing parsecraft.routing never pulls providers.ollaya — pinned by
tests/test_routing_language.py.
Plan¶
RoutingPlan and PageRoute are pydantic models.
| Type | Field | Type | Meaning |
|---|---|---|---|
RoutingPlan |
primary |
str |
Winning backend across pages (mode; ties go to the lexicographically smallest name) |
RoutingPlan |
pages |
list[PageRoute] |
Ascending by page_number, at least one |
PageRoute |
page_number |
int |
Page (ge=1) |
PageRoute |
intent |
Intent |
Classified intent for the page |
PageRoute |
candidates |
list[str] |
Judge-ordered, at least one, truncated to max_passes; candidates[0] is the pass-1 route, the rest are fallbacks |
PageRoute |
chosen |
str |
Equal to candidates[0] (validated) |
PageRoute |
reason |
str |
Deterministic, human-readable justification |
A plan carries fallbacks as extra candidates; it does not carry execution
failures. Those are PassFailure records on the backend result.
Per-page dispatch and fallbacks¶
Each page carries ordered candidates. candidates[0] is the pass-1 route and
candidates[1..] are fallback passes, tried only after a typed failure. Pages
with the same (intent, chosen, candidates) group into one range when the chosen
backend supports page ranges and multi-page conversion.
flowchart LR
P["page — candidates[0], candidates[1..]"] --> W{"candidates[0] succeeds?"}
W -- yes --> A["winner = candidates[0]"]
W -- "no — typed PassFailure" --> N{"candidates[1] succeeds?"}
N -- yes --> B["winner = candidates[1]"]
N -- no --> F["PageGroup.winner = None<br/>placeholder PageResult + WARNING"]
A --> G["group contiguous pages<br/>same (intent, chosen, candidates)"]
B --> G
G --> R["one ConversionRequest per PageRange"]
A group merges contiguous pages only when the chosen backend declares both
supports_page_ranges and supports_multi_page; otherwise each page converts
alone. When every candidate fails, each page gets a placeholder PageResult
with a pipeline-all-passes-failed warning diagnostic (constant
ALL_PASSES_FAILED_CODE) — no silent drops.
Judge seam¶
RoutingJudge is a runtime_checkable Protocol:
def rank(self, intent: Intent, candidates: Sequence[BackendDescriptor]) -> Sequence[str]
It receives only eligible candidates and returns backend names, best first. It
may re-rank or drop, but not add. DeterministicJudge is the default and orders
by:
- Preferred model for the intent (
OCR_TABLES→ocr-unlimited,OCR_VISION→ocr-ovis). - Native before OCR on the
NATIVEintent. - Lowest
estimated_vram_gb(Nonelast). - Name, ascending.
When judge=None, plan_route uses DeterministicJudge, so routing never
requires a judge.
Public API¶
def plan_route(
analysis: AnalysisResult,
backends: Sequence[BackendDescriptor],
constraints: RoutingConstraints,
judge: RoutingJudge | None = None,
) -> RoutingPlan
Exported from parsecraft.routing: DeterministicJudge, Intent,
JudgeViolationError, NoEligibleBackendError, PageRoute,
RoutingConstraints, RoutingError, RoutingJudge, RoutingPlan,
plan_route.
| Error | Raised when |
|---|---|
RoutingError |
Analysis has no signals |
NoEligibleBackendError |
No eligible candidate for an intent, or none at all; carries the intent, including NATIVE with only OCR candidates |
JudgeViolationError |
A judge returns an ineligible name, a duplicate, or an empty order |
What routing does not see¶
Routing reads only what backends declare and what the caller passes in; it
never probes the machine. Host reality is detected once, in
parsecraft.environment, and distilled into RoutingConstraints.
| Fact | Declared — descriptor capabilities |
Detected — parsecraft.environment |
|---|---|---|
| Backends present | — | EnvironmentInfo.backends |
| Installed extras | optional_dependency_group |
EnvironmentInfo.installed_extras |
| GPU need and VRAM | requires_gpu, estimated_vram_gb |
EnvironmentInfo.vram_budget_gb — measured |
| Formats | supported_formats |
— |
| Model assets | model_asset |
— |
| Offline | — | EnvironmentInfo.offline — operator-declared |
probe_environment() -> EnvironmentInfo measures installed extras (resolve
only, never import), total GPU VRAM via nvidia-smi, and the operator-declared
PARSECRAFT_OFFLINE flag. constraints_from_environment(environment, *,
formats=(), allow_ocr=None, max_passes=1) -> RoutingConstraints fills all seven
constraint fields; with allow_ocr=None the switch is derived from whether an
ocr- extra is installed. Routing consumes the built constraints plus
descriptors and never probes hardware; the probe never judges eligibility.
Planning does not depend on probing: pass RoutingConstraints directly and
routing works.
Language is declared, not detected. Routing reads
BackendCapabilities.languages and RoutingConstraints.language as plain data
and never calls a detector; filling the request is the caller's job. See
Language.
Status¶
The routing engine (parsecraft.routing) and the executor
(parsecraft.pipeline) are implemented and exported. Execution is library-only
for now: the convert --auto CLI wiring is planned (bead pc-4u7.14). Also
planned: a Jev / System One-backed RoutingJudge (the seam is the hook; it
would be an optional extra) and cancellation/timeout passthrough into
ConversionRequest. Tracking bead: pc-5ub.