Skip to content

Provider ecosystem: adapters, device profiles, conformance, and the bake-off harness (proposed)

Status: proposed — design for the Phase-3 workstream (many real providers per stage, config-driven, CPU/GPU, with an enterprise-readiness gate). Builds on ADR-0004 (Capability protocols + Provider plugins), ADR-0007 (CPU-first, GPU as upgrade), ADR-0012 (permissive defaults, restricted opt-in), ADR-0016 (thin core + per-Provider packages), ADR-0033 (streaming Runner).

Goal

Turn "model-agnostic" from a seam into a proven, testable ecosystem: many real providers per Stage (PaddleOCR-VL, LightOnOCR, docling, kreuzberg, lightparse for Parse; the Rust chunking engine for Chunk; GLiNER variants for NER/RE; multiple embedders; multiple PII models), each a config-selectable plugin, with a harness that (A) validates a default stack end-to-end, (B) runs a per-Stage bake-off across many providers, and (C) enforces enterprise readiness across the board.

Decisions

1. Provider = Adapter over a native library (no new seam)

The Capability protocol (ADR-0004) IS the stable input/output interface. A provider is an adapter: it maps a native library's I/O to the Capability's typed contract (bytes/ParserInput → DocumentRecord+PageMap for Parse; text → EntityMention for NER; etc.). Each adapter is its own isolated package with its heavy dep (ADR-0016). We add a thin optional AdapterBase in latence-core carrying the cross-cutting concerns every adapter repeats — device selection, batching, mapping native exceptions to the typed error taxonomy (ADR-0034), and offset/Provenance preservation helpers — so a new adapter is small and consistent. No change to the Capability protocols themselves.

2. ProviderProfile — a declared descriptor (new, small, additive)

Every provider declares a ProviderProfile: compute (cpu | gpu | either), approx memory_mb, model_id + license (SPDX + weights/code verified per ADR-0012), deterministic (bool), batch support, and cost_per_1k (for endpoint/API providers). The registry surfaces it. This drives: skipping GPU providers on a CPU-only host (flag, don't fail — the G1 posture), device routing, license/cost columns in the bake-off, and honest "needs a GPU/perf rig" gaps. Device handling stays the adapter's job (it moves its own model to config.device = auto|cpu|cuda); the framework does not orchestrate serving (ADR-0003) — it only passes config + reads the profile.

3. Stacks — the default stack + named alternatives, config-driven

A stacks/ directory holds full Pipeline configs: default.yaml (the blessed CPU-first stack, ADR-0007), gpu-sota.yaml (the GPU/high-accuracy stack), and room for client stacks. (A) A stack-validation harness runs a named stack end-to-end on a corpus and asserts contracts/KG/RAG/report + the G1-style reliability checks — so "test the default stack" is one command.

4. Bake-off / Provider Matrix harness (the core new tool)

Config-driven matrix/<stage>.yaml lists candidate providers for one Stage; the harness holds every other Stage at the default and runs the SAME corpus + gold-set through each candidate, producing a comparison report — quality (the S10 gold-set P/R/F1 or a stage-specific metric), latency/throughput, peak memory, cost, license, determinism, error rate. Generalizes the S10 gold-set harness + the G1 perf lane into a per-Stage bake-off. Output: a signed MATRIX-RESULTS.md per stage (like G1-RESULTS). GPU candidates are gated on device availability (run where present, else skip+flag — no fabricated numbers).

5. Provider Conformance suite (enterprise readiness, C)

A reusable, parametrized-over-every-registered-provider pytest suite that is the "across the board" gate. For each provider it asserts: emits VALID typed records (Provenance/Classification/offsets present, contract-complete); graceful failure (malformed/oversized/adversarial input → typed error or PARSE_ERROR, never a crash — reusing the Screening/fuzz discipline); resource bounds respected (declared memory/device honored); license recorded + permissive-or-opt-in (ADR-0012); determinism or documented non-determinism; no secret/PII leak. "Passes conformance" = enterprise-ready. Runs in CI for CPU providers; GPU providers run the conformance suite where a GPU exists (flagged otherwise).

6. License + cost are first-class

Per ADR-0012 and the research-diligence directive, every adapter's weights+code license is verified and recorded in its ProviderProfile and surfaced in the bake-off — an enterprise decision input, not an afterthought. Restricted-license models (EmbeddingGemma) remain opt-in with flow-down.

Honest boundary

The harness runs fully for CPU providers here; GPU-model adapters (PaddleOCR-VL, LightOnOCR, GLiNER-large, ColBERT) and endpoint providers can be conformance/seam-verified with mocks, but their real quality/throughput numbers need a GPU/perf environment (#64) — the bake-off produces those where the environment exists and flags them otherwise, never fabricated.

Rejected

  • A new "adapter" protocol distinct from the Capability protocol (the Capability already IS the I/O contract; a second layer is ceremony).
  • The Runner orchestrating GPU placement/serving (ADR-0003: providers/endpoints own serving; the Runner is substrate-agnostic).
  • A monolithic "all providers installed" env (defeats ADR-0016 thin-core; the matrix installs only the candidates under test).

Status note — §2 realized (P3-F1, #86)

§2 (ProviderProfile + device handling) is BUILT (Phase-3 Wave-0, slice P3-F1). Delivered in latence-core:

  • latence_core.providers.profile.ProviderProfile — a frozen, strict Pydantic v2 descriptor (compute cpu|gpu|either, memory_mb, model_id, SPDX license + license_verified, deterministic, batch, cost_per_1k), with the UNVERIFIED invariant enforced (license == "UNVERIFIED"license_verified must be False, per ADR-0012). A provider declares it via a profile classmethod (preferred) or a PROFILE ClassVar; the HasProfile runtime_checkable Protocol + profile_of() read either without importing heavy deps.
  • latence_core.providers.device — the dep-light, torch-free device seam (ADR-0016): cuda_available() is override-driven under LATENCE_CUDA=0|1 (deterministic for CI) and defaults False when unknown; select_device(requested, profile) returns a typed DeviceDecision and skips-with-flag (never raises) a GPU-only provider on a CPU-only host — the G1 posture. Device placement stays the adapter's job.
  • ProviderRegistry.profile(name) / .profiles() surface declared profiles (missing = None, never fabricated; an import-erroring provider is skipped-and-flagged, not fatal).
  • The LocalRunner routes each Stage's device config value against its provider's profile before running; a skipped GPU stage produces an empty output + records skipped=True + the profile columns in the Quality Report's StageMetrics (QUALITY_SCHEMA_VERSION 16). The in-core reference providers (gazetteer, pattern, markdown chunker, intake screener) and entity.gliner declare honest profiles; license_verified=True appears only where genuinely verified.

§1 (AdapterBase), §3 (stacks), §4 (bake-off), §5 (conformance) remain proposed — the later Wave-0/Wave-1 slices (P3-F2…F5).

Status note — §1 realized (P3-F2, #87)

§1 (AdapterBase) is BUILT (Phase-3 Wave-0, slice P3-F2). Delivered in latence-core, dep-light (no torch/model libs), as latence_core.providers.adapter.AdapterBase (re-exported from latence_core, __all__):

  • Optional, not a new seam. A thin base carrying the cross-cutting glue every adapter repeats; a Provider still satisfies a Capability Protocol structurally without it (the in-core reference Providers are unchanged and untested-migrated). It names no Capability method — no second protocol layer.
  • Device resolutionsuper().__init__(config) resolves config.device against the subclass's declared ProviderProfile via P3-F1's select_device; self.device exposes the DeviceDecision (skip-with-flag on a CPU-only host, never a fabricated device). Placement stays the adapter's job (ADR-0003).
  • Batchingbatched(items, run_batch) groups into a validated batch_size (≥1) when profile.batch, degrades to one-item calls otherwise; deterministic input-order output, one-batch-at-a-time (no whole-corpus buffer — the ADR-0033 streaming posture).
  • Native-exception → typed taxonomy (ADR-0034)guard("<op>") maps a native exception to ProviderError (runtime fault) or ConfigError (opted-in via config_exceptions), passes a framework LatenceError through unwrapped, and carries no record content/PII (provider + op + native type name only, never the native message).
  • Provenance/offset carrycarry_provenance / rebase_offsets / resolve_pages reuse the existing OffsetIndex / PageOffsetIndex seam (ADR-0031), not a weaker copy.

Also in P3-F2 (§2 malformed-profile fix, the deferred P3-F1 defect): the LocalRunner now FAILs a stage loud (typed ConfigError + FAIL Quality Report + config span) when a Provider declares a malformed profile, instead of silently reading it as "no profile" and running. An absent profile still means follow-request (None); an import/load error still defers to the normal execution path. profile_of was hardened to recognise a profile declaration only as a genuine class-bound @classmethod (or PROFILE ClassVar / bare ProviderProfile), so the Profiler Capability's own profile(self, …) instance method is correctly read as "no declaration" rather than mislabelled malformed (a latent P3-F1 collision this slice surfaced and fixed).

§3 (stacks), §4 (bake-off), §5 (conformance) remain proposed — P3-F3…F5.

Status note — §5 realized (P3-F3, #88)

§5 (Provider Conformance suite) is BUILT (Phase-3 Wave-0, slice P3-F3). Delivered in latence-core, dep-light (no torch/model/GPU libs), as the latence_core.conformance package + a parametrized pytest suite. "Passes conformance" is the definition of enterprise-ready, and the suite is the gate every Wave-1 adapter must pass.

  • Parametrized over EVERY registered Provider. The suite discovers all 29 Providers via ProviderRegistry.names() and runs each against its Capability's ConformanceCase. A Provider is mapped to its Capability by name prefix, so a new Provider is auto-covered once its Capability has a case; a Provider whose Capability has no case (or an unknown prefix) FAILs loudly (case_for_provider/capability_of raise) — no silent coverage gap. A test asserts the exact 29-Provider roster + count so an accidentally-uncovered Provider is caught.
  • The C1–C6 checks (all green for the in-core CPU Providers; the stub-subset green for endpoint/GPU): C1 valid typed records (Provenance/Classification + in-range offsets, reusing the S10 completeness + ADR-0031 PageOffsetIndex seams — not a weaker copy); C2 graceful failure (adversarial input → a typed LatenceError or a defined quarantine/PARSE_ERROR/empty path, never a bare crash, with no PII in the surfaced message — proven against a PII-bearing payload); C3 license recorded + permissive-or-opt-in (every Provider now declares a ProviderProfile; UNVERIFIED+verified is impossible; a restricted license must be verified/opt-in-flagged); C4 determinism-or-documented (a deterministic Provider is byte-identical over two runs; a non-deterministic one records its declaration); C5 resource/device honored (a compute="gpu" Provider on a CPU-only host is skipped-with-flag via select_device, recorded, not run, not faked); C6 no secret/PII leak (a non-Redactor does not surface raw PII in non-content record fields).
  • Every Provider now declares a ProviderProfile. P3-F1 attached profiles to only a handful; P3-F3 completes the set — all 29 Providers declare honest, verified-where-verifiable profiles (the CPU references Apache-2.0/verified/deterministic; the GLiNER/endpoint Providers compute="either", non-deterministic, model_id=None for the endpoint clients that bundle no weights, cost_per_1k=None where deployment-specific). The two Profiler Providers declare via a PROFILE ClassVar (not a profile classmethod) because the Profiler Capability's own I/O method is named profile (the P3-F2 collision).
  • Endpoint/GPU Providers are stub-scoped + flagged. *.endpoint, relation.llm, embedding.endpoint, and *.gliner run the contract/graceful-failure/license/determinism-declaration subset against a deterministic openai/gliner stub (latence_core.conformance.stub, reusing the existing endpoint/gliner test-double pattern); each is flagged stub_scoped ("conformance-verified against stub; live numbers need a rig", #64) — never a fabricated quality/throughput number.
  • The checks BITE (proven, not asserted). A throwaway broken Provider (declares deterministic=True but is not, and raises a bare RuntimeError on the adversarial input) FAILs C4 and C2; a Provider that leaks PII in its quarantine reason FAILs C2's no-PII gate; a synthetic compute="gpu" Provider is skipped-with-flag by C5 (its chunk is asserted never called). All in test_conformance.py.
  • Quality Report integration. The report gains an optional conformance roll-up (ConformanceReport, QUALITY_SCHEMA_VERSION 17 — counts only, no record content, safe to persist/share). The report models live in the pure, stage-free latence_core.conformance.report so the lean Quality contract imports no harness/fixtures; the harness/cases are lazy-loaded from the package __init__ so a bare import latence_core stays thin (no stage load).
  • CI. The suite runs in ci.yml on py3.11 + py3.12 with LATENCE_CUDA=0 (both inside the normal pytest step and as a dedicated visible gate). An E2E test runs a real pipeline through the LocalRunner and folds the conformance roll-up into its Quality Report.

A new adapter author's checklist is in docs/CONFORMANCE.md. §3 (stacks) and §4 (bake-off) remain proposed — P3-F4, P3-F5.

Status note — §1 has a real second consumer (H-A1, #107)

The audit (THEME A, mattpocock: "one adapter = hypothetical seam; two = real") flagged that AdapterBase (§1) had zero real production consumers — it was validated only by fabricated fake subclasses. H-A1 lands the first REAL Wave-1 adapter through it: latence-parser-pdfplumber (parser.pdfplumber), a CPU, page-aware PDF (+ text) Parser over the pure-Python pdfplumber library (MIT), implemented as an AdapterBase subclass. The hypothetical seam is now a real second-consumer seam.

  • Library choice — pdfplumber substitutes for the preferred-but-unsuitable kreuzberg. The issue preferred kreuzberg conditional on an early verification. That verification (2026-07-08) found it unsuitable on both counts the issue names: its current major (4.9.9) is Elastic-2.0 (non-permissive source-available — PyPI classifier License :: Other/Proprietary License; ADR-0012 requires permissive-or-opt-in), and its last permissive (MIT) 3.x line fails to import without OCR system binaries (from kreuzberg._ocr import get_ocr_backend at import). So, per the issue's explicit fallback clause, the substitute is pdfplumber (MIT, pure-Python, CPU, page-aware, no system binaries; transitive core pdfminer.six also MIT) — named latence-parser-pdfplumber. License is evidence-bearing (MIT verified from the installed dist's PyPI metadata + the upstream GitHub LICENSE, cited + dated; license_weights=None, a pure-code provider).
  • It EXERCISES AdapterBase for real, not bypasses it. guard("parse") maps pdfminer's native PdfminerException (corrupt/encrypted/spoofed PDF) → typed ProviderError (no content/PII), then _parse_one degrades it to a graceful PARSE_ERROR record (ADR-0020, the Parse C2 the conformance suite asserts). batched runs over multiple input documents (input-order-preserving, one batch at a time — ADR-0033). carry_provenance + PageMap.from_page_texts carry Provenance + build the exact page map (offsets round-trip with zero drift on a real multi-page fixture; pages correct). Device flows through the seam (compute="cpu").
  • The one AdapterBase change (a real finding, fixed not documented-around). carry_provenance was typed source: Record, but a Parser's source is a ParserInput (a raw document, ADR-0019), which carries Provenance + Classification yet is not a Record subclass — so the first real Parser could not reuse the helper without a hand-rolled cast (the "weaker mapper" the audit forbids). Fix: widen the source param to a new structural Protocol ProvenanceCarrier (just .provenance + .classification, the only two attributes the helper reads). Backward-compatible — every Record satisfies it, and so does a ParserInput — so sub-document carriers and the whole-document Parser now carry through the same base helper. The seam validation working as intended: a real consumer exposed a misfit; the seam was sharpened, not bypassed.
  • Inert-field verdict (H-A1's trim-vs-keep signal). For this adapter cost_per_1k (None, no endpoint) and memory_mb (nothing memory-gates the device seam) are inert but honest. Recommendation: keep both — they are cheap and load-bearing for the endpoint/GPU providers the bake-off (P3-F5) ranks; trimming would re-fork the descriptor. compute/license*/deterministic/batch were all live here.
  • Conformance + roster. parser.pdfplumber passes the P3-F3 conformance suite (C1–C6) as a registered provider — the first real adapter to clear the gate end to end. The registry roster is now 30 (was 29); the roster/count assertions + the E2E conformance roll-up are updated. mypy --strict/ruff/pytest green; deterministic. Heavy dep isolated to the package (ADR-0016); pure-Python + offline, so it installs into the default dev/CI env (like latence-parser-document's pypdf) and its tests + the conformance gate run offline — no dedicated heavy lane needed.

Status note — §3 realized (P3-F4, #89)

§3 (Stacks + the stack-validation harness) is BUILT (Phase-3 Wave-0, slice P3-F4). Goal A ("test the default stack, one command") is real: a blessed default stack validated end-to-end by one command.

  • stacks/ — full-spine configs, not slices. A top-level stacks/ directory holds complete, runnable Pipeline configs. stacks/default.yaml is the blessed CPU-first stack (ADR-0007): in-core deterministic Providers + the real CPU adapter where it adds value (parser.pdfplumber for PDFs+text, chunk.markdown, entity.gazetteer, relation.pattern, redaction.hybrid_rule comprehensive-by-default (H-C1), profiling.statistical, disambiguation.cascade, graph.canonical, export.knowledge_graph + export.jsonl_parquet, both Screening checkpoints) — fully offline, permissive, deterministic. stacks/gpu-sota.yaml is the GPU/high-accuracy stack (entity.gliner, relation.gliner_relex, redaction.gliner_pii, embedding.endpoint), each pinning device: cuda and its heavy package isolated (ADR-0016). A {STORAGE_URI} / {CORPUS_DIR} placeholder pair keeps a committed stack location-independent (the demo's EXAMPLES_DIR templating). stacks/<client>.yaml is the documented extension point.
  • latence stack validate <name> — the one-command gate (generalizes G1). A stack sub-command group on the latence CLI runs a named stack end-to-end on a small bundled deterministic messy corpus (latence_core.stacks.corpus — entity-rich + the four dangerous fixtures, generated in code so a seeded run is byte-reproducible) and asserts the G1-style checks in one place, each a pass/fail CheckResult: contracts (Provenance/Classification + offsets + zero drift — reuses the QualityReportBuilder completeness check + the ADR-0031 drift roll-up, not a reinvented audit); kg (non-empty content-addressed KG with per-edge Evidence, exported); rag (redacted, chunked corpus exported JSONL/Parquet, well-formed, no raw PII — redaction is load-bearing); report (schema-valid, counts-only/no-PII, per-stage + P3-F1 profile columns); determinism (a fresh-run_id re-run is byte-identical — the H-E1 export-artifact equality); resume (same-run_id resume is byte-identical); graceful_failure (planted malformed inputs quarantined/PARSE_ERROR'd, never crashing); device_honesty. It writes a signed-style summary to Storage (like G1-RESULTS.md) and exits non-zero on failure. The harness is composition over LocalRunner + QualityReportBuilder + the device/skip seam — no new pipeline logic.
  • Device honesty on a CPU-only host — a harness-level pre-flight. The runner's native skip-with-flag needs to read a provider's profile, which needs to load it; a gpu-sota learned Provider is in a heavy package not installed in the CPU dev/CI env, so a load would raise, not skip. The harness therefore pre-flights the stack against the shared registry: a Stage whose Provider is not installed OR whose declared ProviderProfile + requested device resolve (through the SAME select_device seam the Runner uses) to a GPU-skip on a CPU host is skipped-with-flag; a Stage depending on a skipped Stage is transitively dropped; what remains is the CPU-reachable spine the harness still validates. On this CPU host validate stacks/gpu-sota.yaml reports the learned Stages (+ their KG/RAG dependents) skipped-with-flag and PASSes on the CPU spine — never a fabricated number (#64). On a real GPU host with the packages installed, the learned Stages run (device: cuda) and the full spine validates.
  • The harness BITES (proven, not asserted). A wrong-wiring stack (a Relation Stage not fed by an Entity Stage) makes the harness FAIL — the run exception is caught and turned into a run FAIL check + non-zero exit, never a crash. A redaction-bypass stack (corpus Export straight off chunk) leaks the planted PII into the exported corpus and FAILs the rag check. Both in test_stack_validate.py, with the CLI exit-code asserted.
  • CI. ci.yml runs latence stack validate stacks/default.yaml and stacks/gpu-sota.yaml on every push with LATENCE_CUDA=0 — the blessed stack proven end-to-end (the real pipeline, not a config-parse smoke) on a tiny corpus, offline + deterministic, as a standing gate; the gpu-sota run proves the device-honesty posture stays green. A docs page (docs/running-a-stack.md) covers running + validating a stack. mypy --strict/ruff/pytest green; deterministic; the harness lives in the lazy-loaded latence_core.stacks package so a bare import latence_core stays thin.

Status note — §4 realized (P3-F5, #90)

§4 (Bake-off / Provider Matrix harness) is BUILT (Phase-3 Wave-0, the final slice — the capstone). Goal B is real: the per-Stage bake-off holds every OTHER Stage at stacks/default.yaml and runs the SAME bundled corpus (+ gold-set) through each candidate for ONE Stage, producing a comparison an enterprise can act on. This slice also retires the last audit thread (THEME A's inert-fields observation): ProviderProfile.memory_mb, cost_per_1k, and deterministic were flagged inert because their consumer didn't exist — the bake-off IS that consumer, so the descriptor stops being speculative (a grep now shows real readers).

  • matrix/<stage>.yaml — the candidate list per Stage. A top-level matrix/ directory: each file names one Stage (by CapabilityKind) and the candidate Providers to compare, parsed by the strict latence_core.bakeoff.matrix.MatrixSpec (an unknown key or a duplicate candidate is a hard error, ADR-0006 discipline). Ships two real CPU matrices exercising true alternatives, not mocks — matrix/parse.yaml (parser.plaintext, parser.pdfplumber, parser.document) and matrix/chunk.yaml (chunk.markdown, chunk.sentence_window) — plus matrix/entity.yaml (entity.gazetteer CPU + entity.gliner GPU, device-gated) to exercise the skip-with-flag boundary. A candidate may carry a per-candidate config override merged onto the base stage's config (so a GPU candidate pins device: cuda); an optional stage_name disambiguates when the base stack has two Stages of the kind (e.g. its two Export nodes).

  • The harness is COMPOSITION over the earlier seams — it reinvents none of them. latence_core.bakeoff.harness holds every other Stage at the base stack via the F4 machinery (latence_core.stacks.corpus bundled corpus + stacks.validate.load_stack + the _skip_reason_for_stage device pre-flight), swaps ONLY the target Stage (a real controlled comparison — every other Stage byte-identical), and measures per candidate from the Quality Report the run ALREADY produced: quality (the S10 gold-set GoldSetEvaluation score for entity/relation where the base stack declares a gold_set_uri, else the stage-specific metric off report.parse/report.chunk — no reinvented P/R/F1 or offset logic), latency/throughput (the target Stage's measured StageMetrics.duration_seconds → docs/sec — machine-relative, reported not gated), peak memory (tracemalloc Python allocations vs the declared profile.memory_mb — declared-vs-measured surfaced, with the H-E1 off-heap-RSS caveat on every row), cost (profile.cost_per_1k, else n/a), license (profile.license + license_verified + an opt-in flag, H-B1), determinism (profile.deterministic cross-checked by running each candidate twice and comparing the target Stage's checkpoint bytes — the H-E1 byte-identity discipline — flagging a deterministic=True that diverges), and error rate (StageMetrics.error_count).

  • Device honesty + degrade-per-candidate (the honest boundary the whole workstream holds). A GPU/endpoint candidate not runnable on this host (not installed OR device: cuda + no CUDA, through the SAME select_device seam) is skipped-with-flag — recorded as a row whose measured columns stay None, its skip_reason referencing the rig gap (#64), never a fabricated number. A candidate that crashes / fails conformance mid-run is caught and recorded as a failed row — the bake-off degrades per-candidate, never aborting the whole matrix.

  • Output — a signed MATRIX-RESULTS-<stage>.md + a JSON companion + a CLI. latence bake-off <matrix-file> (a new top-level CLI sub-command) runs the matrix and writes a signed-style MATRIX-RESULTS-<stage>.md (a table: candidate × every dimension above + a skipped/why column, like G1-RESULTS.md) plus a machine-readable MATRIX-RESULTS-<stage>.json companion (schema-versioned MatrixResult, so the numbers are consumable, not just prose). Deterministic + offline for the CPU matrices.

  • The checks BITE (proven, not asserted). A throwaway Provider that declares deterministic=True but perturbs its output across runs is FLAGGED (determinism.mismatch, rendered ⚠ MISMATCH); a crashing candidate is a failed row while the good candidate in the same matrix still runs + is measured; the GPU entity.gliner candidate is skipped-with-flag with no fabricated columns. All in test_bakeoff.py.

  • CI. ci.yml runs latence bake-off matrix/parse.yaml and matrix/chunk.yaml on every push with LATENCE_CUDA=0 — the bake-off proven end-to-end (the real pipeline, not a config-parse smoke) on the tiny bundled corpus, offline + deterministic + reproducible. GPU matrices are not run in CI (flagged). A docs page (docs/running-a-bakeoff.md) covers running a bake-off. mypy --strict/ruff/pytest green; the harness lives in the lazy-loaded latence_core.bakeoff package (the contracts import no runner/stages) so a bare import latence_core stays thin. The P3-F3 conformance suite + F4 stack-validate stay green.

All of ADR-0036 (§1 AdapterBase, §2 ProviderProfile+device, §3 Stacks, §4 Bake-off, §5 Conformance) is now BUILT — the Phase-3 Wave-0 provider ecosystem is complete.

Status note — the first learned Embedder adapter (W1-embed, #93)

Phase-3 Wave-1 lands the first real learned Embedder adapter through AdapterBase (§1) and the conformance gate (§5): latence-embedder-st (embedding.sentence_transformers), an in-process semantic embedder over the sentence-transformers library. It originally defaulted to intfloat/multilingual-e5-small (MIT, 384-dim); amended by ADR-0045 (2026-07-14) to default to IBM Granite Embedding r2 — GPU tier granite-embedding-311m-multilingual-r2 (Apache-2.0, 768-dim, bf16+FA2) / CPU tier granite-embedding-97m-multilingual-r2 (Apache-2.0, 384-dim, fp32); e5-small remains a documented fallback. The profile fields quoted below (model_id/license/memory) reflect the original e5-small descriptor and are superseded by ADR-0045. It is the SAME opt-in Embedder Capability as the in-core deterministic embedding.hashing reference and the embedding.endpoint performance path — swap the toy hashing embedder for a real semantic model with one config line (ADR-0007/0017), so the exported RAG corpus carries real semantic vectors a vector DB ingests, and the F5 bake-off gets a real embedding matrix (embedding.hashing vs a learned model).

  • A working plugin, not a stub. The adapter really calls sentence-transformers and returns model vectors when the dep + weights are present. Offline CI verifies it the SAME way the gliner trio is: install the package --no-deps, monkeypatch SentenceTransformer with a deterministic fake for conformance/unit tests, and gate a real-weights test on importlib.util.find_spec("sentence_transformers") (skipped offline, run where the model exists). Heavy deps (sentence-transformers → torch/transformers) live ONLY in this package (ADR-0016); nothing leaks into latence-core.
  • It EXERCISES AdapterBase for real. It is an AdapterBase subclass: device routes cpu|cuda into ST's device= (a CPU-only host is never handed a fabricated device); batched runs encode over the corpus in input-order-preserving, streaming-friendly bounded batches (memory O(batch), ADR-0033); guard("embed")/guard("load_model") map a native ST/torch exception (bad model id / OOM / encode failure) → typed ProviderError/ConfigError carrying no raw text/PII (only the native type name). e5's document-side passage: prefix + normalize_embeddings=True (L2 unit vectors) follow the model card; dimension is read from the loaded model and cross-checked against a declared width (a mismatch is a loud ProviderError, never a silently-wrong column).
  • Seam-fit report (the H-A1 discipline — does AdapterBase fit a real embedder?). Three of the four AdapterBase facilities fit cleanly and are load-bearing: device, batched, guard. The fourth — carry_provenance/rebase_offsets/resolve_pages — is inapplicable by design: an Embedder produces raw list[float] vectors, not Provenance-carrying sub-records, so there is nothing to carry offsets onto (the vector is attached to an already-Provenanced Export row by the Export Stage, which owns that carry). This is NOT a misfit to fix (unlike H-A1's carry_provenance: Record→ProvenanceCarrier widening) — it is the honest shape of the Capability: AdapterBase is a compose-what-you-need base (its own §1 framing: "the base does not force a god-class shape"), and an Embedder legitimately composes three of four. Recommendation: keep all four — the offset helpers are load-bearing for the record-producing adapters (parsers/extractors), and an Embedder simply doesn't reach for them. The seam validated correctly: a second real, different-shaped consumer (a vector producer, not a record producer) confirmed the base is granular, not all-or-nothing.
  • Profile + license + conformance (the gates). compute="either" (CPU-runnable, GPU-faster), memory_mb=512 (declared), model_id="intfloat/multilingual-e5-small", deterministic=False (learned float inference is not promised byte-identical across hardware/BLAS — declared, not assumed; C4 then records it as a documented skip rather than asserting byte-identity), batch=True, cost_per_1k=None. Evidence-bearing license (H-B1): license_code="Apache-2.0" (the sentence-transformers library), license_weights="MIT" (the e5-small checkpoint), headline license="MIT" (the effective license of what the vectors embody), license_verified=True, with a cited license_source (HF model-card + GitHub LICENSE) + license_verified_on="2026-07-08" — both sides verified permissive (MiniLM, Apache-2.0, is a documented alternative). Passes conformance C1–C6 as a registered stub-scoped provider (a fake sentence_transformers returns fixed-width deterministic vectors, exactly like the endpoint embedder's openai stub).
  • The Embedder is a first-class bake-off dimension now (a real §4 extension). The Embedder is an opt-in Export augmentation (ADR-0017), not a DAG Stage, so the bake-off's DAG-stage swap couldn't reach it. matrix/embed.yaml (stage: embedding, stage_name: export_corpus) makes embedding a first-class bake-off target: the harness swaps the candidate embedder INSIDE the named corpus Export's config['embedder'] (holding the Export Provider + every other Stage byte-identical), and the device pre-flight now inspects an Export node's nested embedder through the SAME select_device seam. So on a CPU host embedding.hashing runs real (measured, deterministic) and embedding.sentence_transformers (pinned device: cuda) is skipped-with-flag with no fabricated numbers (cites #64) — the exact device-honesty posture the whole workstream holds, extended to the nested embedder. Its CI row proves the CLI path.
  • Roster + CI. The registry roster is now 31 (was 30); the roster/count assertions + the E2E conformance roll-up + the stub-scoped set are updated. CI installs latence-embedder-st --no-deps in the heavy-provider step (its tests monkeypatch SentenceTransformer), type-checks its src+tests, builds its wheel, and runs latence bake-off matrix/embed.yaml. mypy --strict/ruff/pytest green; deterministic seeded; heavy dep isolated (ADR-0016); the full conformance + stack-validate suites stay green.

Status note — the first real Redaction adapter over AdapterBase (W1-pii, #95)

Phase-3 Wave-1 lands the first real Redaction adapter through AdapterBase (§1) and the conformance gate (§5): latence-pii-presidio (redaction.presidio), a real Microsoft Presidio (MIT) PII detector — a spaCy NER model plus pattern + context recognizers — defaulting to the en_core_web_sm spaCy model (MIT). Presidio is a genuinely different detection engine from the in-core deterministic regex redaction.hybrid_rule and the learned zero-shot redaction.gliner_pii, so it is a real alternative for the bake-off, and it exercises the part of AdapterBase the embedder didn't: the offset/Provenance carry onto record-producing output.

  • A true drop-in, a working plugin not a stub. It is the SAME PIIDetector Capability as redaction.hybrid_rule and redaction.gliner_pii — swap the provider with one config line (proved by an E2E test that swaps redaction.presidio for redaction.hybrid_rule with every other Stage identical). It really calls Presidio's AnalyzerEngine when the dep + a spaCy model are present; offline CI installs the package --no-deps, monkeypatches AnalyzerEngine (+ the nlp_engine submodule) with a deterministic fake for conformance/unit tests, and gates a real-weights test on importlib.util.find_spec("presidio_analyzer") (skipped offline, run where Presidio + a model exist). Heavy deps (presidio-analyzer → spaCy + a model) live ONLY in this package (ADR-0016); nothing leaks into latence-core. It shares the framework's masking/placeholder/policy machinery (latence_core.redaction_policy) and the H-C1 sensitive-doc no-op floor (redaction_disabled_for_sensitive) with the in-core Provider — a disabled control on a confidential doc is a loud, first-class signal, not a silent documents_with_pii:0 (regression-tested end to end).
  • It EXERCISES AdapterBase for real — including the offset/Provenance seam the embedder couldn't. It is an AdapterBase subclass composing all four facilities: device routes cpu|cuda (spaCy is CPU-first, GPU-optional); batched runs Presidio over documents in input-order-preserving bounded batches (ADR-0033), loading the analyzer once on the first document that needs it; guard("analyze")/guard("load_model") map a native Presidio/spaCy exception → typed ProviderError/ConfigError carrying no source text / no detected PII (only the native type name — the load-bearing C2/C6 check for a PII provider); and — the new one — carry_provenance + resolve_pages (the ADR-0031 PageOffsetIndex seam) carry the source Provenance/Classification onto each RedactionRecord and resolve every PIISpan's own source page, never a weaker hand-rolled mapper. Presidio returns char spans on the analysed text; because Redaction scans the parsed document markdown directly (capability.py:228), those ARE document offsets — the span round-trips to the raw value and resolves to its true page (a multi-page fixture proves it). Presidio's UPPER_SNAKE entity types (EMAIL_ADDRESS…) are mapped to the framework's lower-case pii_type vocabulary so a types: policy covers them unchanged.
  • Seam-fit report (the H-A1 discipline — does AdapterBase fit a real redactor?). All four facilities fit cleanly and are load-bearing. carry_provenance (widened to ProvenanceCarrier by H-A1) carries a DocumentRecord's lineage onto the RedactionRecord with the offset span narrowed to [0, len); resolve_pages resolves each span's page through the ported PageOffsetIndex asset — exactly the record-producing carry the embedder had no use for. No misfit found: a redactor is the shape AdapterBase's offset/provenance helpers were built for, so the second real Wave-1 adapter confirms the seam fits a record-producer as well as it fit the vector-producer differently. One deliberate nuance: build_span's page resolution is derived from a PageOffsetIndex handed in, whereas AdapterBase's resolve_pages owns the index construction; this Provider resolves pages via the AdapterBase seam (matching every other adapter) and derives the placeholder via the shared placeholder_for, so the mapping is the one-right-way seam and the raw value is still never persisted.
  • Profile + license + conformance (the gates). compute="either" (CPU-runnable, GPU-optional), memory_mb=200 (declared — en_core_web_lg would raise that), model_id="en_core_web_sm", deterministic=False (Presidio analysis IS deterministic on fixed input+config+model version, but cross-environment byte-identity across spaCy/model versions is not promised — declared, not assumed; C4 records a documented skip). Evidence-bearing license (H-B1): license_code="MIT" (the presidio-analyzer + spacy libraries), license_weights="MIT" (the en_core_web_sm model), headline license="MIT", license_verified=True, with a cited license_source (Presidio + spaCy GitHub LICENSE + the HF model card) + license_verified_on="2026-07-08" — Presidio (MIT, "Copyright © Presidio Contributors"), spaCy (MIT), and both en_core_web_sm/en_core_web_lg (MIT) verified permissive. Passes conformance C1–C6 as a registered stub-scoped provider (a fake presidio_analyzer returns canned document-offset spans keyed to the canonical fixture); the C2 no-PII-in-error and C6 no-leak checks are load-bearing here and green.
  • A real §4 bake-off row. matrix/redaction.yaml (stage: redaction) swaps ONLY the Redaction Stage: redaction.hybrid_rule runs real (measured, deterministic) and redaction.presidio (pinned device: cuda) is skipped-with-flag with no fabricated quality/throughput on a CPU host (cites #64) — a genuine comparison of two different detection engines, not two flavours of one. Its CI row proves the CLI path.
  • Roster + CI. The registry roster is now 32 (was 31); the roster/count assertions + the E2E conformance roll-up + the stub-scoped set are updated. CI installs latence-pii-presidio --no-deps in the heavy-provider step (its tests monkeypatch AnalyzerEngine), type-checks its src+tests, builds its wheel, and runs latence bake-off matrix/redaction.yaml. mypy --strict/ruff/pytest green; deterministic seeded; heavy dep isolated (ADR-0016); the full conformance + stack-validate suites stay green.

Status note — the first GPU OCR Parser adapter (W1-lighton, #91)

Phase-3 Wave-1 lands the first real GPU-class, image→markdown Parser adapter through AdapterBase (§1) and the conformance gate (§5): latence-parser-lighton (parser.lighton), a real OCR Parser over LightOn-OCR (lightonai/LightOnOCR-1B-1025, a compact 1B vision-language model — Apache-2.0 weights AND code). It unlocks what the CPU text-layer parsers (parser.pdfplumber/parser.document) cannot: a scanned / image-only page (no text layer). Those extract an existing text layer; this Provider reads pixels and emits markdown per page, taken directly onto the exact-offset PageMap.from_page_texts seam (ADR-0031) — the same page/offset path the pdfplumber Parser uses, never a reinvented mapper. It is the lighter, torch-based alternative to the dropped PaddleOCR-VL (no paddle stack) and licence-clean vs the dropped Surya (GPLv3/NC weights) — see the repo pivot commit.

  • The anti-false-green rule (the whole point of this slice, baked in from the PaddleOCR-VL false green). The offline test double MUST be faithful to the real generate/decode contract, prompt tokens included. LightOn-OCR's real API is LightOnOcrForConditionalGeneration / LightOnOcrProcessor (transformers), and per page: processor.apply_chat_template(..., return_dict=True, return_tensors="pt")output_ids = model.generate(...)slice the prompt tokens off generated_ids = output_ids[0, inputs["input_ids"].shape[1]:]processor.decode(generated_ids, skip_special_tokens=True) → the page's markdown str (verified 2026-07 from the HF model card). The slice is load-bearing: HF generate returns the full prompt+continuation sequence, so decoding without slicing off inputs["input_ids"].shape[1] prompt tokens would prepend the chat/prompt scaffolding to the OCR markdown on every page (corrupting content, PageMap offsets, size, language) — the exact false-green trap. The fakes model that: _FakeModel.generate returns prompt+continuation tokens and _FakeProcessor.decode renders the sliced continuation, so an un-sliced Provider fails rather than passing green. test_offline_fake_is_faithful_to_generate_slice_decode_contract is the anchor a reviewer points at to confirm the double matches the real generate → slice → decode contract (and that decoding the un-sliced sequence visibly includes the prompt scaffolding). A real-OCR test (test_real_lighton_ocr_on_cuda) is present, gated on transformers + a genuine torch.cuda probe, and overrides any LATENCE_CUDA=0 pin (setenv "1") so it truly routes to cuda on a rig — skipped offline. This is the single thing whose divergence made PaddleOCR-VL a false green, closed here.
  • It EXERCISES AdapterBase for real — and is the first to drive the GPU skip-with-flag branch. It is an AdapterBase subclass composing all four facilities load-bearingly: device declares compute="gpu", so on a CPU-only host the seam returns the skip-with-flag decision (the pdfplumber Parser was trivially CPU and never exercised this branch), and on a rig the resolved self.device.device flows into model.to("cuda") (placement stays the adapter's job, ADR-0003); batched batches over input documents (input-order-preserving, streaming-friendly, ADR-0033); guard("parse")/guard("load_model")/guard("rasterise") map a native transformers/CUDA/pypdfium2 fault → typed ProviderError/ConfigError carrying no image bytes / OCR text / PII (only the native type name — the load-bearing C2/C6 discipline, since OCR output is untrusted PII-bearing content); carry_provenance + PageMap.from_page_texts carry the source Provenance onto the exact-offset page seam (offsets round-trip, pages correct). An offset-0 type-spoof guard (mirroring parser.pdfplumber/#62) rejects a .pdf/image whose bytes don't match its declared type before the rasteriser; malformed input degrades to a graceful PARSE_ERROR record (ADR-0020), so one bad page never aborts a run.
  • Seam-fit report (the H-A1 discipline — does AdapterBase fit an OCR parser?). AdapterBase fit cleanly — the same four seams the pdfplumber Parser uses carried the OCR path, with the compute="gpu" device branch newly exercised for real. The one OCR-specific addition outside the base is rasterisation (PDF/image bytes → page images via pypdfium2/pillow), which is upstream of the seam (it produces the model input), not a weaker offset mapper — so it is not a misfit and needed no AdapterBase change. Markdown-per-page → PageMap.from_page_texts is the reused core seam, not a reinvented mapper. No misfit found: the third real Wave-1 adapter (a GPU record-producer over an image modality) confirms the base fits a pixels-in/markdown-out parser as cleanly as the text-layer one.
  • Profile + license + conformance (the gates). compute="gpu" (a 1B VLM needs a GPU to be practical — either is not declared: CPU inference is far too slow to run as a real Parser; the honest requirement is GPU), memory_mb=3584 (~3.5 GB VRAM bf16, declared), model_id="lightonai/LightOnOCR-1B-1025", deterministic=False (VLM float decoding is not promised byte-identical across hardware/library versions even with greedy decoding — declared, not assumed; verify on the pod), batch=True, cost_per_1k=None. Evidence-bearing license (H-B1), weights AND code recorded separately (ADR-0012 / research-diligence): license_code="Apache-2.0" (the transformers loader path + the LightOn-OCR code), license_weights="Apache-2.0" (the lightonai/LightOnOCR-1B-1025 checkpoint), headline license="Apache-2.0", license_verified=True, with a cited license_source (the HF model card) + license_verified_on="2026-07-08" — both sides verified permissive. On a CPU host the conformance verdict is device-skipped: C3 (license) PASSes, C5 records the skip-with-flag, and C1/C2/C4/C6 are recorded SKIPPED (needs a rig, #64) — the verdict still passes (a SKIP is never a FAIL), never a fabricated CPU run. The real C1/C2/C4/C6 run on the pod.
  • §4 bake-off + a §3 stack. matrix/parse.yaml adds parser.lighton (pinned device: cuda) as a fourth candidate: the three CPU parsers run real (measured), the OCR one is skipped-with-flag on a CPU host with no fabricated numbers (cites #64). A new stacks/gpu-ocr.yaml — the scanned-document (OCR-first) stack — swaps the Parse Stage for parser.lighton while keeping the deterministic in-core CPU spine downstream; on a CPU host Parse (and its dependents) skip-with-flag and the CPU-reachable head still validates, on a GPU rig the whole Source→Intake→Parse→Chunk→Content-Screening→RAG-corpus Export spine runs over scans. stacks/gpu-sota.yaml deliberately keeps parser.pdfplumber so its CPU-spine validation is not regressed by a device-skipped Parse.
  • Turnkey pod validation. scripts/validate-lighton-on-gpu.md documents the exact pod steps (the pip install, a real-OCR run over a scanned fixture, and run_conformance on a CUDA host) so the maintainer's pod run (or an SSH run) validates the real model end to end. The real quality/latency/offset-round-trip numbers come from the pod run and are folded in after — flagged "pending pod validation" until then, never fabricated.
  • Roster + CI. The registry roster is now 33 (was 32); the roster/count assertions + the E2E conformance roll-up + the new device_skipped == {"parser.lighton"} assertion are updated. CI installs latence-parser-lighton --no-deps in the heavy-provider step (its tests monkeypatch transformers/pypdfium2/PIL with shape-matching fakes), type-checks its src+tests, builds its wheel, runs latence bake-off matrix/parse.yaml, and validates stacks/gpu-ocr.yaml. mypy --strict/ruff/pytest green; deterministic seeded; heavy dep isolated (ADR-0016); the full conformance + stack-validate suites stay green.

Status note — the second GPU OCR Parser adapter, the MIT twin (W1-glm, #91)

Phase-3 Wave-1 lands a second real GPU-class, image→markdown Parser adapter alongside parser.lighton: latence-parser-glm (parser.glm), a real OCR Parser over GLM-OCR (zai-org/GLM-OCR, Zhipu/Z.ai — a compact 0.9B vision-language model, MIT weights AND code; the pipeline's PP-DocLayoutV3 layout component is Apache-2.0). It is the near-twin of the pod-validated LightOn adapter — same AdapterBase shape, same image→markdown model, same generate→slice-prompt-tokens→decode contract, same PageMap.from_page_texts offset path, same type-spoof + guard + skip-with-flag + evidence-license discipline. What changes is the model + loader + license (MIT vs Apache-2.0) — and one hardening the LightOn slice deferred.

  • Real model API + the anti-false-green rule (carried faithfully). GLM-OCR's real API is GlmOcrForConditionalGeneration / AutoProcessor (transformers), and per page: processor.apply_chat_template(..., return_dict=True, return_tensors="pt")generated_ids = model.generate(...)slice the prompt tokens off generated_ids[0][inputs["input_ids"].shape[1]:]processor.decode(..., skip_special_tokens=True) → the page's markdown str (verified 2026-07 from the HF model card). Unlike LightOn (image-only), GLM-OCR is prompted with a text recognition instruction alongside the page image ("Text Recognition:", configurable). The slice is load-bearing exactly as for LightOn; the offline fakes (_FakeModel.generate returns prompt+continuation, _FakeProcessor.decode renders the sliced continuation) model that, so an un-sliced Provider failstest_offline_fake_is_faithful_to_generate_slice_decode_contract is the anchor. A dep+CUDA-gated test_real_glm_ocr_on_cuda overrides the CPU pin and routes to cuda on a rig — skipped offline.
  • Pixel-bomb DoS cap — built in from the start (the LightOn security finding, not deferred). This Parser rasterises untrusted input, so it clamps a PDF page's render scale by a max-megapixel budget (_MAX_RENDER_MEGAPIXELS, ~25 MP — a giant page can never force a gigapixel bitmap) and sets a conservative Image.MAX_IMAGE_PIXELS so a decompression-bomb image raises rather than allocating multi-GB. Both degrade to a graceful PARSE_ERROR; fixtures assert the clamp shrinks an oversized page and a bomb image → PARSE_ERROR (never a crash / huge alloc). LightOn deferred this to a follow-up; GLM does not repeat that.
  • Profile + license + conformance. compute="gpu", memory_mb=3584 (~3–4 GB VRAM bf16, declared), model_id="zai-org/GLM-OCR", deterministic=False (VLM float decoding — declared, not assumed; verify on the pod), batch=True, cost_per_1k=None. Evidence-bearing license (H-B1), weights AND code recorded separately (ADR-0012): license_code="MIT", license_weights="MIT", headline license="MIT", license_verified=True, cited license_source (the HF model card, noting the Apache-2.0 layout component) + license_verified_on="2026-07-08" — all permissive, passes C3. On a CPU host the conformance verdict is device-skipped exactly like parser.lighton (C3 PASS, C5 skip, C1/C2/C4/C6 SKIPPED — the verdict still passes).
  • §4 bake-off + §3 stack + roster + CI. matrix/parse.yaml adds parser.glm (pinned device: cuda) as a fifth candidate — a genuine second GPU OCR row, a real LightOn-vs-GLM comparison on a rig, skipped-with-flag on a CPU host with no fabricated numbers. The registry roster is now 34 (was 33); the roster/count assertions + the E2E conformance roll-up + the device_skipped == {"parser.lighton", "parser.glm"} assertion are updated. CI + scripts/verify-local.sh install latence-parser-glm --no-deps in the heavy-provider step, type-check its src+tests, build its wheel, and run the same bake-off/stack gates. scripts/validate-glm-on-gpu.md documents the turnkey pod run. mypy --strict/ruff/pytest green; deterministic seeded; heavy dep isolated (ADR-0016); the full conformance + stack-validate suites stay green.

Status note — the first learned Disambiguation adapter, surface+context ER (W1 rework, #94)

Phase-3 Wave-1 lands the first real learned Disambiguation adapter through AdapterBase (§1) and the conformance gate (§5): latence-disambig-embedding (disambiguation.embedding), semantic entity resolution beyond the algorithmic disambiguation.cascade. It is a true drop-in for disambiguation.cascade (same DisambiguationRecord contract, one-config-line swap) that merges cross-surface duplicates the lexical cascade misses ("IBM" ↔ "International Business Machines"), anticipated by the design ("a FAISS/GPU blocking Provider is a pluggable option", the Disambiguator docstring, S8 AC). This slice supersedes PR #127 — the pod-proven bare-surface rework, below.

  • The pod finding that drove the rework (bare surface over-merges; embed surface+CONTEXT). A first-cut bare-surface embedding disambiguator was pod-validated with the real multilingual-e5-small embedder and OVER-MERGED distinct entities — short entity surfaces aren't discriminative with a sentence embedder. Pod-measured cosines: IBM ~ International Business Machines (same) 0.883, Apple ~ Apple Inc (same) 0.933, IBM ~ Apple (DISTINCT) 0.870, IBM ~ Berlin (distinct) 0.818. A distinct pair (0.870) outranks a same pair's margin, so bare surfaces have no safe threshold — enterprise-fatal (collapsing two real entities is worse than under-merging). Embedding each mention's surface + surrounding context separates them cleanly: same ≈0.96–0.99, distinct ≈0.76–0.81 (IBM ~ Apple drops to 0.808), so any ~0.90 threshold is safe AND useful. The rework: embed surface + context, defaulting the threshold to the context-safe 0.90 (not the fragile bare-surface 0.83).
  • The mechanism — thread ChunkRecords to disambiguate (minimal blast radius). The Disambiguator Protocol is widened to disambiguate(mentions, relations, chunks=()) — a third, additive, defaulted carrier, so it is backward-compatible (any existing 2-arg Provider still satisfies it; no EntityMention schema change; no NER-provider change). CascadeDisambiguator accepts chunks=() and ignores it — its algorithmic path (and every one of its tests) stays byte-identical. The Runner's disambiguation dispatch gathers the run's ChunkRecords corpus-wide (_stream_all_records_of_type — the Chunk Stage is a mention ancestor, not a Disambiguation parent, yet the Runner already holds every Stage's output/checkpoint) and threads them with the SAME #69 streaming discipline as the mention/relation gather; a run with no Chunk Stage passes an empty stream (mentions-only still works). EmbeddingDisambiguator locates each mention's chunk by chunk_record_id, takes a fixed window of its content around the mention offset, and embeds f"{surface} {context}"; no locatable chunk → graceful bare-surface fallback (never crashes).
  • A drop-in, not a fork — the overridable blocking seam. CascadeDisambiguator.disambiguate is refactored to delegate cluster-building to one overridable method, _resolve_clusters(mentions, chunks); the cascade implements it with the ported EntityResolver (ignoring chunks), and EmbeddingDisambiguator subclasses the cascade and overrides ONLY that method (embedding cosine + Union-Find). The record building, KB linking, RelationNormalizer, page-accurate Provenance/Evidence carry, and deterministic emission order are all inherited — so both Providers emit the identical contract and a stack swaps disambiguation.cascadedisambiguation.embedding with no other change (proved by an E2E drop-in test).
  • Enterprise safety — no silent over-merge (the whole point). A pair merges ONLY when cosine ≥ threshold AND (default) the entity types are compatible (the #35 same-type guard). A similar-but-below-threshold or type-incompatible pair is recorded as a rejected MergeDecision (applied=False) with its score + reason on the target cluster's audit log — never silently folded, never silently dropped. The audit confidence is clamped to [audit_floor, 1.0]. The O(n²) surface pairing is blocked (exact surfaces group free) and capped by max_pairs (a ConfigError naming the knob, not partial silent work). The regression bites two ways offline: a stub returning the pod's bare-vs-context vectors asserts that at 0.90 the genuine duplicates merge while IBM and Apple stay SEPARATE (a context_window=0 control reproduces the over-merge — proving the fix is because of context, not a higher threshold masking it), and the real in-core deterministic embedding.hashing embedder over full ChunkRecords asserts no cluster ever spans two different real-world entities.
  • It EXERCISES AdapterBase for real. An AdapterBase subclass composing device (either → cpu/cuda for a GPU embedder), batched (the surface+context texts embedded in input-order-preserving bounded batches, ADR-0033), and guard("embed") (a native embedder fault → typed ProviderError carrying no raw text / PII — only the provider + op + native type name). It resolves its Embedder from the latence.providers registry (default the in-core deterministic embedding.hashing; a learned embedding.sentence_transformers is a one-line opt-in), so the package carries no heavy dep (ADR-0016) and is CPU-viable + offline by default — it installs into the default dev env like latence-demo.
  • Profile + license + conformance. compute="either", memory_mb=128, model_id=None (pure-Python union-find + cosine; the compute follows the resolved embedder), deterministic=False (byte-identity follows the resolved embedder — the hashing default IS deterministic, a learned float embedder is not promised so across BLAS/hardware; declared, not overclaimed — C4 records the documented skip), batch=True, cost_per_1k=None. Evidence-bearing license (H-B1): license_code="Apache-2.0" (this package's own pure-Python code; a learned embedder opt-in carries its OWN verified weights license in ITS profile — this Provider never claims the embedder's license as its own), headline license="Apache-2.0", license_verified=True, license_source="LICENSE", license_verified_on="2026-07-08". Passes conformance C1–C6 for real offline (against the in-core hashing embedder — a real-shaped vector, the anti-false-green rule).
  • A real §4 bake-off row. matrix/disambiguation.yaml (stage: disambiguation) swaps ONLY the Disambiguation Stage: disambiguation.cascade and disambiguation.embedding are BOTH real, measured CPU rows (the embedding row uses the offline hashing embedder — a genuinely different blocking engine, cosine-over-context vs the lexical cascade, not two flavours of one). The quality lens is the enterprise ER metric off the DisambiguationQuality the run already computes (no reinvented scoring): the cross-surface merge_rate = merges_applied / mentions_in, plus entities_out / entities_merged / merges_below_policy / entities_linked; a real precision/recall lands here automatically when a Disambiguation gold-set is declared. Swap the embedder block to embedding.sentence_transformers (device: cuda) on a rig for the real pod separation (scripts/validate-disambig-on-gpu.md).
  • Roster + CI. The registry roster is now 35 (was 34); the roster/count assertions + the E2E conformance roll-up are updated. latence-disambig-embedding is a default-dev-env package (CPU-viable, offline), so CI's uv sync installs it; CI type-checks its src+tests (the per-package typecheck.sh loop picks it up automatically), builds its wheel, and runs latence bake-off matrix/disambiguation.yaml. scripts/validate-disambig-on-gpu.md documents the turnkey pod run that re-validates the exact IBM/IBM-full/Apple/Apple-Inc/Berlin separation with the real e5 embedder. mypy --strict/ruff/pytest green; deterministic seeded; the full conformance + stack-validate + bake-off suites stay green.

Status note — relation.gliner_relex was a FALSE GREEN; pinned to the real predict_relations (W1-relex FIX)

The pod de-risk sweep (2026-07-09) proved relation.gliner_relex never worked with real weights. The Provider called model.predict_with_relations(...) and parsed a dict (result.get("entities") / result.get("relations")), but the real knowledgator/gliner-relex-multi-v1.0 checkpoint (gliner 0.2.27) has no such method — it raises AttributeError: 'UniEncoderTokenRelexGLiNER' object has no attribute 'predict_with_relations'. The offline fake had defined a matching predict_with_relations returning a dict, so every offline test passed against a stub that mirrored a non-existent API — the exact PaddleOCR-VL false-green class, in flagrant violation of the anti-false-green rule (the stub MUST mirror the REAL model).

  • The real API (introspected + run on the pod, pinned here). GLiNER.from_pretrained("knowledgator/gliner-relex-multi-v1.0").predict_relations(text, labels, relations, flat_ner=True, threshold=0.5, adjacency_threshold=None, relation_threshold=None, multi_label=False) returns a (entities, relations) TUPLE — NOT a dict. entity_dict = {"start","end","text","label","score"} (chunk-local offsets, identical to predict_entities); relation_dict = {"head": {"start","end","text","type","entity_idx"}, "tail": {...}, "relation": str, "score": float}. Verified real output for the canonical sentence (labels [organization, person], relations [works for, partner of], threshold 0.3): Alice Johnson works for IBM (0.96), IBM partner of Acme (0.79), offsets exact.
  • The fix is a two-line call/parse correction — NOT a re-architecture. provider.py::_extract_one now unpacks raw_entities, raw_relations = model.predict_relations(chunk.content, self._labels, self._relation_labels, threshold=self._threshold). Everything downstream was ALREADY correct against the real shape and is kept verbatim: entity dicts use start/end/label/score; _resolve_endpoint keys head/tail by (start,end) (both present in the real relation dicts); _relation_sort_key reads head["start"]; offsets resolve through OffsetIndex.resolve_span UNCHANGED — the same path that made entity.gliner pass its pod probe with an exact round-trip. Sorted entities/relations + stable record_ids + drop-unresolved-endpoint determinism all carry over untouched.
  • The faithful stub + two teeth (the anti-false-green rule, finally honoured). The unit fake (tests/) and the shared conformance stub (latence_core/conformance/stub.py) now expose predict_relations(text, labels, relations, threshold=..., **kwargs) returning a (entities, relations) TUPLE of the real dict shapes (head/tail sub-dicts with start/end/text/type/entity_idx); the old predict_with_relations dict fake is deleted, and gliner_relations() is upgraded from bare head:0/tail:1 indices to the real span-keyed sub-dicts. A provider still calling predict_with_relations or doing result.get(...) on the tuple FAILS: test_dict_parse_path_fails_against_tuple_api (the anchor — a tuple has no .get; the fixed provider unpacks and emits, an un-fixed one gets nothing) and test_emitted_relations_have_real_endpoints (the provider-level guard — every emitted RelationMention resolves to real emitted-mention record_ids with a non-empty label, so a silent drop-all-relations regression bites).
  • gpu-sota.yaml wiring fix. The relations stage passed relations: [works_for, partner_of] — but the fused Provider REQUIRES both labels AND relation_labels (a missing key is a ConfigError at load). Corrected to labels: [organization, person] + relation_labels: [works for, partner of], and the entities stage labels are lower-cased ([ORG, PERSON][organization, person]; GLiNER scores 0.95–0.99 lower-case on the pod). A CPU stack-validate skips the cuda stages with-flag, so this wiring bug was invisible until a real GPU run — noted. scripts/validate-relex-on-gpu.md documents the turnkey pod re-validation (a real predict_relations run producing the two expected relations); the real GPU numbers come from the maintainer's SSH re-validation, never fabricated. The evidence-bearing Apache-2.0 license (weights + code, verified 2026-07-06) is unchanged.

Status note — the blessed gpu-sota.yaml had never run E2E; the fused-stage rule + the CPU-can't-see-cuda-wiring gap (W2-gpu-sota-fix)

The consolidated pod sweep (2026-07-09) ran the full gpu-sota stack on a real GPU for the first time — and it FAILED three times before passing. The blessed stacks/gpu-sota.yaml had never been executed end-to-end because a CPU latence stack validate skips every device: cuda Stage with-flag (the P3-F1 device-honesty posture), so the learned-Stage wiring was never exercised. Three real bugs hid behind that skip, in the order the pod hit them:

  • redact had no pii_labels. redaction.gliner_pii requires a non-empty pii_labels list; the stage carried only {device: cuda}ValueError at run. Fix: pii_labels: [person, email, phone number, address, credit card number, social security number] (lower-case, matching the GLiNER zero-shot convention and the extract labels).
  • The fused-provider miswiring (the architectural bug). The stack declared BOTH an entities (entity.gliner) Stage AND a relations (relation.gliner_relex) Stage. But relation.gliner_relex is a Fused Provider (FusedEntityRelationExtractor — joint NER+RE in one pass via extract_fused); it does NOT satisfy the standalone RelationExtractor.relate seam, so the run raised ContractError: 'relation.gliner_relex' does not satisfy the RelationExtractor capability. Pairing it with a separate entity.gliner Stage was also double-NER. Fix (the rule): extraction on the blessed SOTA path is ONE fused_entity_relation Stage (extract, relation.gliner_relex, threshold: 0.3); the Runner routes its FusedExtraction (mentions + relations) to BOTH downstream consumers, no separate Entity Stage. Downstream depends_on rewired: profiling: [parse, extract], disambiguate: [extract]. The KB entity_type stays lower-case organization (the case-sensitive KB linker, #129). The fused-provider stage rule, stated once: a Fused Provider belongs under a fused_entity_relation Stage, never a relation_extraction (or entity_extraction) Stage; a stack that wants standalone learned NER wires entity.gliner + a non-fused RE provider instead.

  • stacks/gpu-sota-pod.yaml — the self-contained pod variant. The blessed gpu-sota.yaml keeps embedding.endpoint for the RAG export as the served story (needs a running host). The new gpu-sota-pod.yaml is identical but swaps that for the in-process embedding.sentence_transformers — so the whole stack runs on a single GPU pod with no external service to stand up. This is the stack the maintainer runs out of the box.

  • The CPU capability-satisfaction guard — what closes the gap (the whole point). The reason all three bugs reached a pod is that a CPU-only stack validate skips the cuda Stages, so the Runner's dispatch-time isinstance(provider, <Protocol>) check (the one that raises ContractError for the fused/standalone mismatch) never runs in CI. latence_core.stacks.capability_guard.check_stack_capabilities closes it: for each Stage it loads the Provider class from the registry (a torch-free import — the heavy dep is deferred to first use, ADR-0016) and structurally checks it against the Protocol its Stage's CapabilityKind declares — the SAME capability→Protocol mapping the Runner enforces, declared ONCE so the two cannot drift. A runtime_checkable method-only Protocol uses issubclass; Embedder (a dimension property) falls back to a structural attribute check — either way it is class-only, no instantiation, CPU-runnable. test_stack_capability_guard.py asserts the fixed gpu-sota.yaml/gpu-sota-pod.yaml have zero findings AND that the guard bites on the exact miswiring (a fused Provider under a relation_extraction Stage IS a finding; so is a mis-typed nested Export embedder) — so this class of bug now fails CI on a CPU host, never reaching a pod. An un-installed Provider is skipped (device-honesty), never a false-positive.

  • Pod evidence (folded, never fabricated). LATENCE_CUDA=1 latence stack validate stacks/gpu-sota-pod.yaml on the RTX 2000 Ada pod (all learned Providers real): PASS — documents=3; contracts 28/28 prov+class, offsets aligned, drift clean; kg nodes=3 edges=18 evidence_coverage=1.00; rag 3 rows, no PII leak; report schema v18, 12 stage metrics; graceful_failure 3 quarantined + 1 PARSE_ERROR; determinism 6 artifacts byte-identical across two runs; resume byte-identical.

  • Verify + CI. scripts/verify-local.sh validates stacks/gpu-sota-pod.yaml alongside the others (CPU skips the cuda Stages with-flag) and the capability-satisfaction guard checks every stack's wiring. mypy --strict/ruff/pytest green; the guard test is deterministic (class inspection, LATENCE_CUDA-independent). The fused-under-relation false-green is closed at the CPU gate.

Status note — the fastino GLiNER2 Redaction adapter, the gliner-library twin (W3-pii-gliner2)

Phase-3 Wave-3 lands a fourth Redaction Provider and the twin of redaction.gliner_pii: latence-pii-gliner2 (redaction.gliner2), a learned PII detector over the fastino GLiNER2 framework (fastino/GLiNER2-Guardrails-PII-Multi, an mdeberta-v3 encoder — Apache-2.0 weights AND code). It reuses ALL the shared redaction machinery (the RedactionPolicy, masking/placeholder/action logic, PIISpan/RedactionRecord contracts, offset handling, the PARSE_ERROR pass-through, and the Classification.sensitivity-keyed policy — imported from latence_core.stages.redaction + latence_core.redaction_policy) and changes only the detection call: from the gliner library to the gliner2 framework, a different Python package with a different API. A true drop-in behind the same PIIDetector seam — swap provider: redaction.hybrid_rule (or redaction.gliner_pii) → provider: redaction.gliner2 with one config line, same RedactionRecord contract.

  • The REAL gliner2 API (introspected + run on the pod, pinned here) — a DIFFERENT shape from gliner. from gliner2 import GLiNER2; GLiNER2.from_pretrained("fastino/GLiNER2-Guardrails-PII-Multi").extract_entities(text, pii_labels, threshold=0.5, include_spans=True, include_confidence=True) returns a per-label dict of dict-rows{"entities": {"<label>": [{"text": str, "confidence": float, "start": int, "end": int}, ...], ...}} — NOT the gliner library's flat list of dicts. Verified real output for "IBM works with Alice Johnson in Berlin. Contact alice@example.com or +1-202-555-0143." with types [person, email, phone number], threshold 0.4: person Alice Johnson [15,28) 0.997, email [48,65), phone number [69,84) — offsets document-char half-open, exact. _detect_spans flattens the per-label dict → one PIISpan per row (pii_type=label, char_start=start, char_end=end, confidence=confidence, detector="gliner2:<label>"), resolves overlaps + applies the policy allow-list exactly as redaction.gliner_pii does (deterministic order: by start, then longest, then label; bounds-safe text[start:end] slice).
  • Anti-false-green (the PaddleOCR/relex lesson — honoured). The offline stub (_StubGLiNER2 in latence_core/conformance/stub.py + the unit fake) is faithful to the REAL extract_entities(..., include_spans=True, include_confidence=True) returning the {"entities": {label: [{text,confidence,start,end}]}} per-label-dict shape. A provider that mis-parses that shape as a flat list (the gliner-library shape) recovers ZERO spans — test_flat_list_misparse_fails_anchor proves both that the stub returns the nested shape (iterating out["entities"] yields label strings, not rows) AND that the faithful provider DOES recover the span end-to-end. The real-weights run is dep-gated (find_spec('gliner2')) + skipped offline; re-validated on the pod (person/email/phone masked, offsets round-trip).
  • AdapterBase + native-fault discipline. An AdapterBase subclass: compute="either" (mdeberta runs on CPU or GPU, routed by the device seam — never skipped on a CPU host, never a fabricated device), and guard("extract_entities")/guard("load_model") map a native gliner2/torch fault → a typed ProviderError carrying no record content / PII (only the provider redaction.gliner2 + op + native type name — test_native_fault_becomes_typed_error_no_pii_leak asserts the input text never appears in the message: C2/C6). The gliner2 import is deferred to first use (ADR-0016) so module import for discovery needs no torch; a missing dep raises a clear pip install latence-pii-gliner2 ImportError only at run.
  • Evidence-bearing license (H-B1), fully verified in-slice (2026-07-09). license_weights="Apache-2.0" (HF model card fastino/GLiNER2-Guardrails-PII-Multi, not gated / freely downloadable), license_code="Apache-2.0" (the gliner2 library — github.com/fastino-ai/GLiNER2 LICENSE + README Apache-2.0 badge + PyPI OSI Approved :: Apache Software License classifier; verified during the build, latest release 1.3.2), headline license="Apache-2.0", license_verified=True, license_source cites both, license_verified_on="2026-07-09". Both weights AND code permissive — nothing downloads unless a Pipeline configures this Provider. Passes conformance C3.
  • A real §4 bake-off row. matrix/redaction.yaml adds redaction.gliner2 (pinned device: cuda) as a fourth candidate — a genuinely DIFFERENT learned engine (GLiNER2's mdeberta zero-shot NER) from the regex hybrid, Presidio's spaCy+recognizers, and the gliner-library twin. Skipped-with-flag on a CPU host with no fabricated quality/throughput; runs + is measured on a rig with gliner2 installed.
  • Roster + CI. The registry roster is now 38 (was 37); the roster/count assertions + the E2E conformance roll-up + the stub-scoped set (redaction.gliner2 added) are updated. CI + scripts/verify-local.sh install latence-pii-gliner2 --no-deps in the heavy-provider step (its tests inject a faithful fake gliner2 module), type-check its src+tests, build its wheel, and run latence bake-off matrix/redaction.yaml. scripts/validate-pii-gliner2-on-gpu.md documents the turnkey pod run. mypy --strict/ruff/pytest green; deterministic seeded; heavy dep isolated (ADR-0016); the full conformance + stack-validate suites stay green. The real GPU masking + offset numbers come from the maintainer's SSH re-validation, never fabricated.

Status note — the universal any-file Parser (W5-universal-parser)

Phase-3 Wave-5 lands the universal any-file Parser: latence-parser-render (parser.render), a license-clean port of the colsearch batch converter ("any file → batches of images"). It turns ANY common file type — PDF, image, or office document (docx/pptx/xlsx) — into a DocumentRecord + PageMap, dispatching by type. It is the renderer front-end an OCR pipeline needs so a real mixed corpus (scans, photos, office files) all flows through one Parse Stage. Built THROUGH AdapterBase (§1) and the conformance gate (§5), compute="either".

  • The clean port — NO AGPL (the whole reason, ADR-0012 / research-diligence). colsearch's converter renders PDF with PyMuPDF (fitz) — AGPL-3.0, copyleft. parser.render NEVER imports or depends on fitz/PyMuPDF — a test (test_no_fitz_or_pymupdf_imported_anywhere) greps the installed package to enforce it, and a second asserts the pyproject dependency list is AGPL-free. It uses only PERMISSIVE deps, the same the framework already ships: PDF → pypdfium2 (Apache-2.0/BSD-3, the SAME renderer parser.lighton uses); images → Pillow (HPND); office → python-docx/openpyxl/python-pptx (MIT). LibreOffice (soffice, MPL-2.0) is an OPTIONAL EXTERNAL subprocess tool — not linked/vendored, so its copyleft never reaches the package; a host without it degrades office files to MIT text extraction, never a crash.
  • OCR delegation — reuse the pod-validated models, do not reinvent. parser.render renders each file to page images; the actual pixels→markdown OCR is delegated to a configured OCR Provider (ocr: {provider: parser.lighton|parser.glm, config: {...}}). It resolves that Provider from the registry (ADR-0004) and hands it each page image as a one-page png ParserInput through the same Parser.parse seam — no private method reached into, no seam forked. The office text-fallback path needs no OCR. Per-page markdown is assembled onto the exact-offset PageMap.from_page_texts (ADR-0031) — never a reinvented mapper.
  • Dispatch is graceful (ADR-0020). Unknown/unsupported types, type-spoofed files, corrupt inputs, a soffice timeout, or a missing/unknown OCR Provider all degrade to a graceful PARSE_ERROR record — never a crash. docx → one page; xlsx → one page per worksheet; pptx → one page per slide (honest split without a renderer).
  • Security — carries the parser lessons (it rasterises untrusted input). Pixel-bomb DoS cap (the parser.lighton _MAX_RENDER_MEGAPIXELS clamp + Image.MAX_IMAGE_PIXELS discipline); offset-0 type-spoof guard for PDF/image/office-ZIP (#62); the soffice subprocess runs with an explicit argument list (no shell, no injection), a hard timeout, an isolated temp profile dir, and a non-zero-exit/timeout that degrades to text with no stderr/content leak; a native fault → typed ProviderError carrying only provider + op + native type name (C2/C6).
  • Profile + license + conformance. compute="either" (rendering/text-extraction is CPU; OCR routes to the OCR Provider's device — so on a CPU host parser.render still RUNS its renderer and, with an offline/stub OCR Provider, end-to-end — it is not device-skipped). model_id=None (OCR weights belong to the delegated OCR Provider). deterministic=False (page OCR is a VLM). Evidence-bearing license (H-B1): headline/code Apache-2.0 (framework code), all deps permissive + the AGPL-free discipline cited, license_verified_on="2026-07-09" — passes C3. Conformance C1–C6 runs against a faithful offline fake (a fake pypdfium2/PIL so the REAL render code path runs, install_render_offline in conformance/stub.py, + a stub OCR wired via the Provider's ocr_registry seam): the canonical fixture text is rendered as one page so the produced offsets match the canonical page map — a genuine C1 offset check, not a no-op. parser.render gets a per-Provider case (_render_case), not device-skipped, not endpoint-stub-scoped.
  • §4 bake-off + §3 stack. matrix/parse.yaml adds parser.render (pinned device: cuda, OCR sub-provider parser.lighton) as a sixth candidate — skipped-with-flag on a CPU host (heavy render deps + GPU OCR sub-provider) with no fabricated numbers; runs + measured on a rig. A new stacks/universal-parse.yaml — the any-file stack — swaps the Parse Stage for parser.render over a mixed [pdf, png, jpg, …, docx, pptx, xlsx] corpus, keeping the deterministic in-core CPU spine downstream; on a CPU host Parse (and its dependents) skip-with-flag and the CPU-reachable head still validates, on a GPU rig the whole Source→Intake→Parse→Chunk→Content-Screening→RAG-corpus Export spine runs over the mixed corpus.
  • Roster + CI. The registry roster is now 40 (was 39); the roster/count assertions + the E2E conformance roll-up are updated (parser.render is neither device-skipped nor stub-scoped — it genuinely runs C1–C6 on CPU). CI + scripts/verify-local.sh install latence-parser-render --no-deps in the heavy-provider step (its tests use a faithful in-memory fake renderer + a stub OCR), type-check its src+tests, build its wheel, run latence bake-off matrix/parse.yaml, and validate stacks/universal-parse.yaml. scripts/validate-universal-parser-on-gpu.md documents the turnkey pod run (a real PDF + a docx via soffice/text). mypy --strict/ruff/pytest green; deterministic seeded; heavy dep isolated (ADR-0016); the full conformance + stack-validate suites stay green. The real pod numbers (real PDF + office doc) come from the maintainer's re-validation, never fabricated.

Status note — LLM-stage concurrency: async fan-out + bounded semaphore + robust retry (W6-llm-concurrency)

Phase-3 Wave-6 makes the two OpenAI-compatible LLM Stageslabel_inducer.llm (latence-schema-inducer) and relation.llm (latence-relation-llm) — throughput-first without touching their contracts, their safety nets, or the sync Runner seam. In the real lbbw E2E, schema_induction took 43.9s for 24 chunks (~1.8s/chunk) — pure per-request latency, one call at a time. The fix is the predecessor stack's async-enricher pattern (a streaming orchestrator: semaphore backpressure with non-blocking IO overlap): fan the per-item LLM calls out concurrently and the wall-clock collapses to ceil(N / max_concurrency) x latency.

  • Sync public method, async internals (the Runner is sync). induce(chunks) / relate(chunks, mentions) stay plain sync iterators. Internally each builds its per-item jobs (one per document / one per big-enough chunk), then a single asyncio.run drives an asyncio.gather(..., return_exceptions=True) over the jobs, each bounded by an asyncio.Semaphore(max_concurrency) (config, default 8; 1 = today's exact sequential behaviour). One event loop per Stage-run. The openai client is now the AsyncOpenAI async client; base_url/key sourcing is byte-identical, so the OpenAI → OpenRouter → on-prem/air-gapped vLLM (relation.llm: Ollama) swap is unchanged (config-only). A new request_timeout config knob passes a per-call timeout to the async client (a timeout is a transport fault → retried like a 5xx).
  • The ported retry, made async (latence_core.endpoint.call_with_retry_async). A new async twin of call_with_retry, the same ported retry_utils algorithm — exponential backoff + full jitter, 429/5xx/transport retryable, other-4xx terminal, capped max_retries (default 4), the final error surfaced after the last attempt — but it awaits the coroutine call and an injected async sleep. Retries fold into endpoint_retries exactly as before (the attribute the Runner folds into the Quality Report). Per-item jitter de-correlates a burst of concurrent items retrying one overloaded endpoint. Determinism/instant-under-test via the injected rng + no-op async sleep (mirrors the pre-W6 sleep=lambda _d: None).
  • ALL W4 safety nets preserved, applied PER concurrent result. For label_inducer.llm: structured json_object, truncation→doubled-budget retry, parse-and-repair, schema validation, label sanitization, and fail-open-to-config-never-crash all run inside each item's coroutine, unchanged. A single item's failure fails open for THAT item only (its induced_labels stays None → the extractors use config labels; induction_fallbacks bumped, a typed no-content log) — return_exceptions=True means it never aborts the batch; even an exception that escapes the per-item guard is caught by a belt-and-suspenders fold in _induce_all. For relation.llm: a non-JSON body still yields no relations for that document only; a document's endpoint failure still surfaces (the RE Stage fails loud) — but deterministically the first EndpointError in document order, not whichever finished first, preserving the issue-#44.3 "endpoint failure surfaces" contract while the gather never aborts sibling work early.
  • Determinism byte-identical, order-independent (Baseline bar). Each item is independent; temperature 0; results are re-assembled in input order (index-keyed dict for the inducer, input-order document index for RE) — the deterministic resolution (offset math, index resolution, provenance stamping, sanitization) runs AFTER the concurrent fetch, in input order. So a seeded run is byte-identical regardless of completion order. Proven by a shuffled-completion mock (later items complete first via descending per-call delays) that still yields input-ordered output, and a two-runs-identical model_dump_json check.
  • Anti-false-green — concurrency is REAL and BOUNDED. The faithful async mock (_fake_openai.py for the inducer; an in-test AsyncOpenAI fake for RE) returns the real chat-completions shape with a per-call asyncio.sleep delay. A timing test asserts the wall-clock for N items at concurrency C is < sequential (≈ceil(N/C) x delay, not N x delay), and the RE fake records max_in_flight to assert it is > 1 (genuinely concurrent) and <= max_concurrency (the semaphore bound). max_concurrency=1 is separately asserted to serialize (wall-clock >= (N-1) x delay). A retryable-then-success mock proves the async backoff + endpoint_retries tally; a per-item malformed/terminal mock proves per-item fail-open without aborting the batch.
  • No roster change (same providers, faster). label_inducer.llm and relation.llm keep their ProviderProfiles and entry-points; nothing is added to or removed from the registry — the roster stays 40. mypy --strict (src + tests) / ruff / pytest green across latence-core, latence-schema-inducer, latence-relation-llm; the full conformance + stack-validate + bake-off suites stay green under scripts/verify-local.sh. The real re-timed lbbw induce numbers (~44s → ~6-8s at concurrency 8) come from the maintainer's keyed pod run, never fabricated.

Status note — batching the learned ENCODER providers for GPU throughput (W7-encoder-batching)

Phase-3 Wave-7 makes §1's AdapterBase.batched promise real for the learned ENCODER Providers: the per-item model call that left the GPU idle is replaced with a true batch dimension. In the lbbw E2E these stages fed the model one chunk/document per forward passredact (GLiNER2) 60.5s/3 docs, extract (relex) 26s/24 chunks — so the GPU was under-fed. These are ENCODERS (not autoregressive LLMs), so vLLM does NOT apply: the win is a real batch + SDPA/bf16, not a server. Each learned encoder Provider now accepts a batch_size knob (default 16; the P8 autotuner overrides per-device) and issues ceil(N/batch_size) model calls, mapping each result back to its record by index.

  • What batched (the encoder batch APIs). entity.gliner and redaction.gliner_piimodel.batch_predict_entities(texts, labels, threshold=…) (returns list[list[dict]], result i for texts[i]) instead of a per-text predict_entities loop — a documented real GLiNER method. relation.gliner_relex (fused) → the batched model.inference(texts, labels=…, relations=…, threshold=…, return_relations=True) entry point (returns two parallel lists — entities-per-text, relations-per-text — indexed by input; the single-text predict_relations is the thin wrapper it kept for the API-shape teeth). redaction.gliner2model.batch_extract_entities(texts, labels, threshold=…, include_spans=True, include_confidence=True) (a list of the same per-label-dict shape, one per input). embedding.sentence_transformers (+ disambiguation.embedding's embed path) were ALREADY batched through AdapterBase.batched + ST's native encode(batch_size=…), so W7 leaves them unchanged — they already collapse the encoder to one forward pass per bounded batch.
  • ⚠ The batched APIs for relation.gliner_relex (inference) and redaction.gliner2 (batch_extract_entities) are NOT yet pod-verified — they are offline-unverifiable. The offline fakes are hand-authored to match them, exactly the false-green class that already bit relation.gliner_relex once (the predict_with_relations scar, §"FALSE GREEN" note above), so the offline suite CANNOT catch a wrong/renamed method or signature. This is de-risked two ways until the maintainer's pod run confirms them: (1) each provider hasattr-guards its batched call and raises a clear, actionable error naming the batched API AND the W1-verified single-text fallback (predict_relations for relex) rather than a bare AttributeError — covered offline by test_missing_inference_api_raises_clear_error_naming_the_fallback (relex) and test_missing_batch_extract_entities_raises_clear_error (gliner2); (2) scripts/validate-encoder-batching-on-gpu.md Gate 0 introspects the REAL checkpoints and asserts every batched entry point EXISTS with the pinned shape (a two-text smoke for inference asserting the parallel-list return) before any timing — mirroring validate-relex-on-gpu.md step 2. No throughput number is folded in until Gate 0 passes on the pod. batch_predict_entities (GLiNER) is a documented real method and is lower-risk.
  • Offsets/contracts UNCHANGED (the crux). Batching changes ONLY how many texts share a forward pass; every record's char offsets, PIISpans, mentions, relation endpoints, Evidence, Provenance, and Classification are byte-identical to the per-item path. The GLiNER-family batch call takes ONE label set, so a chunk/document whose effective label set differs (the W4 induced-label seam, per-document) flushes the accumulating batch first — never a mis-labelled call. Emission stays in input order regardless of batch grouping (skipped/near-empty records interleave correctly), so the output is record-for-record identical.
  • Memory-aware (ADR-0033). The in-flight batch is capped to batch_size: the providers accumulate at most batch_size items, call the model once, map back, release — they NEVER accumulate the whole corpus, honoring the streaming Runner's bounded-batch discipline. ProviderProfile.memory_mb stays honest for the batched footprint (a batch of batch_size texts, not the corpus). batch_size is validated >= int(1) one-right-way via the shared latence_core.providers.adapter.validate_batch_size (which AdapterBase.batch_size() now delegates to), so a non-positive/non-int knob is an operator ConfigError, never a silent fallback. Native fault → typed error, no leak — unchanged (gliner2 keeps its guard; the op label is now batch_extract_entities).
  • Anti-false-green tests (the equivalence crux + the call-count teeth). For EACH batched Provider a test_batched_equals_unbatched_record_for_record feeds N items with DISTINCT per-item results (a faithful offline fake, no weights) and asserts batched(N) equals N single-item calls record-for-record (text/label/offsets/spans/endpoints/order) — a mis-mapped batch (off-by-one / wrong order) FAILS. A test_model_called_ceil_n_over_batch_size_times asserts the fake's batched-call counter is exactly ceil(N/batch_size), not N (batching is genuinely happening). An e2e (test_gliner_e2e_runner.py) runs entity.gliner through the LocalRunner Source→Parse→Chunk→Entity spine and asserts the model was called in bounded batches (each call <= batch_size, total forward passes strictly fewer than the chunk count) with the Quality Report's mention counts intact — batching survives the Runner path. Determinism is byte-identical (input-order emission, stable sort).
  • No roster change; CI green. W7 adds NO new Provider (it batches existing ones), so the registry roster stays 40 and no stack/matrix wiring changes. ruff + mypy --strict (src + tests) + pytest green on the five touched packages (latence-core, latence-ner-gliner, latence-relation-gliner, latence-pii-gliner, latence-pii-gliner2); scripts/verify-local.sh green. scripts/validate-encoder-batching-on-gpu.md documents the turnkey pod re-time of lbbw extract + redact at batch 16 (expect the multi-× drop); those real numbers are the maintainer's to fold into the Baseline bar — never fabricated here (offline CPU proves correctness + the batch dimension; the throughput number needs the rig).

Status note — the full-transformers acceleration seam (W8-perf-accel, pod-measured 2026-07-10)

Every GPU number in this note was measured on 2026-07-10 on the RTX 2000 Ada pod (driver CUDA 12.4), and is published with that date in PERF-RESULTS.md § GPU envelope.

Phase-3 Wave-8 adds a shared performance seam so every learned Provider loads its model with the same enterprise-throughput knobs — applied once, honestly, torch-free-to-import — plus page-batched generative OCR. The gap it closes: parser.lighton/parser.glm passed NO dtype to from_pretrainedfp32 (a ~2× miss) and ran one page-image per generate() (the s/page bottleneck). Numbers come from the pod; this slice is correctness + the knobs.

  • The seam (latence_core.providers.perf, dep-light — no torch to import, ADR-0016). load_perf_kwargs(device, config) resolves the two from_pretrained kwargs: torch_dtype (default bf16 on cuda / fp32 on cpu; config["dtype"] pins it) and attn_implementation (default sdpa; flash_attention_2 only when the flash_attn package is importable AND config permits, via importlib.util.find_spec so the probe never imports it). The dtype is resolved as a name string without importing torch; a real torch.dtype is materialised only where torch is present (else the name string) — so the offline (torch-free) env still imports the module and runs the perf-seam unit tests. Two load helpers, chosen by what the underlying library accepts: load_with_fa2_fallback(loader, id, perf) spreads the perf kwargs into from_pretrained and retries once on sdpa if a FA2 load raises (for libs that accept the kwargs — gliner2, the OCR VLMs, ST via model_kwargs); load_then_cast(loader, id, config, device) loads PLAIN then model.to(bf16) on cuda (for the plain gliner 0.2.27 library, which rejects torch_dtype/attn_implementation). maybe_compile(model, config) is torch.compile(mode="reduce-overhead") when config["compile"] is truthy, guarded so any failure/missing-torch returns the uncompiled model. The three plain-GLiNER providers (not AdapterBase subclasses) call the module functions directly, each resolving its own device config through select_device at construction and passing that resolved device into load_then_cast — then place_on_device(model, device) (#191: the plain gliner library does not auto-place; from_pretrained map-locations to CPU, so a device=None caller would cast to bf16 off a bare availability probe while the weights stayed on CPU).
  • Applied across ALL learned Providers (no bare fp32), by the RIGHT path per library (pod-corrected). The plain-gliner-library providers — entity.gliner, relation.gliner_relex, redaction.gliner_pii — load via load_then_cast: plain GLiNER.from_pretrained(id) then .to(bf16) on cuda (measured 1.36×, byte-identical entities). This is a correctness fix: gliner 0.2.27's from_pretrained rejects torch_dtype/attn_implementation (pod-proven TypeError), and its encoder has no FA2 / FlashDeBERTa — so those are neither passed nor claimed. redaction.gliner2 (gliner2 lib) keeps from_pretrained(torch_dtype=…, attn_implementation=…) (loads fine) — but the honest win is bf16; mdeberta's disentangled attention means FA2 is not a speedup (attn_implementation passed only where the lib accepts it). parser.lighton/parser.glm (OCR VLMs) load via load_with_fa2_fallback (real FA2 support). embedding.sentence_transformers (ModernBERT) threads the perf dict through ST model_kwargs — the one encoder where FA2 is genuinely real. disambiguation.embedding gets it transitively. Processor/tokenizer loads stay bare. Each Provider's contract/output is identical; only load dtype/attn/compile change. bf16-vs-fp32 shifts float bits slightly but NOT the extracted text/labels/spans (pod-verified byte-identical).
  • Generative OCR page-batching (parser.lighton + parser.glm). ocr_batch_size (default 4) page images share ONE generate(): apply_chat_template(..., padding=True) LEFT-pads the batch (mandatory for a decoder-only generate so every item's continuation begins at the shared padded width L) with a per-item attention_mask; one greedy (do_sample=False) generate; each item's continuation is sliced at output_ids[i, L:]per-item, by the padded prompt width, so a variable-length page next to a short one never bleeds prompt/pad tokens into another's markdown. Batched OCR is record-for-record identical to per-page (validated offline by a faithful fake VLM + on the pod). The pixel-bomb/spoof/timeout guards are unchanged.
  • OCR knobs + defaults (pod-corrected). max_new_tokens default → 4096 (content-safe on dense pages). LightOn's own example uses 1024 and this slice initially defaulted there — but the pod benchmark caught it TRUNCATING ~25% of the OCR text on dense real German banking pages (19905→14951 chars; those pages genuinely emit >1024 tokens), silently dropping document content, worse than slow for enterprise. 4096 is the natural-EOS budget (output stable/complete). LightOn's 1024 is fine for typical pages but truncates dense multi-column documents — tune to your corpus (down for known-light pages = speed; never below a page's real token length). New max_longest_dim default → 1540 (LightOn's OFFICIAL preprocessing: "render PDFs at 200 DPI to a target longest dimension of 1540px, aspect preserved"): after the megapixel bomb-clamp, a page/image whose longest side exceeds the cap is downscaled (LANCZOS, aspect preserved), applied to BOTH rasterised PDF pages and bare images; 0/None disables. It is NOT a byte-identical no-op (OCR output changes with resolution), so it is a config knob defaulting to LightOn's recipe, and the page-batching equivalence tests hold resolution constant. (On the LBBW corpus it measured ~nil because that workload is decode-bound at a 4096-token budget — kept anyway: no downside, and it helps vision-heavy / short-output pages.)
  • The false-green that pod validation caught (and how the tests now bite). The first W8 cut spread the perf kwargs into GLiNER.from_pretrained for the 3 plain-gliner providers and passed verify-local — but ONLY because the offline fake GLiNER.from_pretrained swallowed the kwargs while the REAL gliner 0.2.27 API rejects them (TypeError), crashing all three on real weights (the classic fake-mirrors-a-nonexistent-API trap). Fix: the offline fake GLiNER.from_pretrained now faithfully raises TypeError on torch_dtype/attn_implementation (like the real lib) and the fake model exposes a .to(dtype) that records the applied dtype — so a provider that wrongly passes perf kwargs to gliner FAILS offline. New tests assert the 3 gliner providers apply bf16 via .to() on cuda and issue no perf kwargs to from_pretrained (and gracefully skip the cast on cpu / when torch is absent, never crashing).
  • Anti-false-green tests. Offline perf-seam unit tests (test_perf_seam.py): dtype bf16-on-cuda / fp32-on-cpu / config-override / invalid-raises; attn sdpa default, FA2 only when flash_attn importable (monkeypatched present/absent); load_perf_kwargs shape + real-torch.dtype-when-present; load_then_cast loads-plain-then-.to(bf16)-on-cuda / no-cast-on-cpu / skips-cast-without-torch / never-passes-perf-kwargs-to-loader; maybe_compile no-op-off / uncompiled-on-failure / uncompiled-when-torch-missing / wraps-on-success; FA2→sdpa retry + non-FA2 propagate. An AST regression guard (test_perf_fp32_regression_guard.py) scans every learned provider source and FAILS if a MODEL from_pretrained bypasses the perf seam (either helper), with a per-gliner-provider check that it routes through load_then_cast (the .to() path) and uses NO perf kwargs / load_with_fa2_fallback (which would re-introduce the crash), plus meta-tests proving it catches a bare load and accepts both seam helpers. OCR page-batching equivalence (test_ocr_page_batching_equals_per_page_record_for_record) proves batched(N variable-length pages) == per-page decode record-for-record, with a variable-length fixture so a wrong shared-length slice FAILS; test_ocr_generate_called_ceil_n_over_batch_size_times asserts ceil(N/ocr_batch_size) generate calls. A downscale unit test proves a 1200×2339 image → longest-side 1540 aspect-preserved, untouched at 0/None or when already smaller.
  • No roster change; CI green; pod-measured. W8 adds NO Provider (it accelerates existing ones), so the registry roster is unchanged and no stack/matrix wiring changes. ruff + mypy --strict (src + tests) + pytest green; scripts/verify-local.sh green. The real numbers are recorded in PERF-RESULTS.md. OCR accel ladder (LightOnOCR-2 on RTX 2000 Ada, 6 real LBBW German PDF pages, content-safe path byte-identical across rungs): fp32 44.5 s/page → bf16 1.49× (the biggest win, and mandatory — fp16 → garbage per LightOn) → +1540px cap 1.50× (~nil on this decode-bound corpus, kept) → +page-batch 4 1.77× → +FA2 1.87× (+6%) → +compile 1.85× (no gain — validates the compile-off default). Encoder (gliner): bf16 via .to() = 1.36×, byte-identical entities — and gliner 0.2.27 has no FA2/FlashDeBERTa (not claimed). max_new_tokens=1024 truncated ~25% of dense-page text → default kept at 4096. vLLM head-to-head is NOT measured — this pod's driver (CUDA 12.4) is too old for the torch (cu126+) a LightOnOCR-2-supporting vLLM needs; a documented infra constraint (needs driver ≥12.6), no vLLM numbers fabricated.

Status note — gliner2 as the unified, optimized extraction+PII engine; relex superseded (W9-gliner2-unified)

Phase-3 Wave-9 consolidates extraction onto the fastino GLiNER2 framework (gliner2 1.3.2): one backbone does entities AND relations AND PII, with native quantize+compile on from_pretrained — pod-measured 2.83× (PII) / 1.74× (combined NER+RE) at byte-identical output (Dennis's call, pod-validated). This retires the separate relation.gliner_relex architecture (knowledgator/gliner-relex) as the DEFAULT: one fewer model family, more throughput, less handling.

  • redaction.gliner2 optimized deployment (the redact fix). New quantize (default True on cuda) + compile (default True on cuda) config knobs → GLiNER2.from_pretrained(id, quantize=…, compile=…) (the gliner2 framework's NATIVE fp16 + torch.compile + fused GPU kernels, "no extra dependencies required"). This SUPERSEDES the W7 batching that REGRESSED redact (60→95s, padding waste): the honest win is the native quantize+compile, not a batch dimension. The W9 load routes through the new dep-light load_gliner2_optimized seam (in latence_core/providers/perf.py): both knobs resolve True on cuda / False on cpu (or the config override) via resolve_gliner2_knob, and the optimized load is guarded — ANY quantize/compile fault (torch/CUDA/compile) degrades to a plain from_pretrained(id) load (throughput only, never a crash). An invalid quantize/compile value is a ConfigError surfaced at construction (fail-fast, like batch_size). Output/offset contract is byte-identical (pod-verify PII spans unchanged under quantize+compile).

  • New fused extraction Provider fused_entity_relation.gliner2 (latence-extract-gliner2, fastino/gliner2-multi-v1). A drop-in swap for relation.gliner_relex: the SAME FusedEntityRelationExtractor seam (extract_fused), the SAME config shape (labels + relation_labels), the SAME mentions+relations+evidence+provenance+offset contract, reading induced ∪ config labels (the W4 seam, per slot) exactly like relex. It uses the pod-validated gliner2 API: extract_entities(..., include_spans=True, include_confidence=True){"entities": {label: [{text,confidence,start,end}]}} and extract_relations(..., include_spans=True, include_confidence=True){"relation_extraction": {rel: [{"head": {text,confidence,start,end}, "tail": {...}}]}}. Offsets (the recurring ADR-0031 coordinate trap): entity char offsets are resolved to true original-markdown offsets through the chunk's stripped→original offset_map (OffsetIndex); a relation is resolved to the two mentions it links by matching the head/tail chunk-local (start,end) spans, and its covering span is the min/max of the corrected endpoints, so it inherits the correction. quantize/compile knobs (default on cuda) via the same guarded load_gliner2_optimized seam. It also exposes extract (the NER half) for a plain Entity Stage.

  • Default swap; relex kept-but-superseded. fused_entity_relation.gliner2 is now the DEFAULT extraction Provider in the blessed stacks (gpu-sota.yaml, gpu-sota-pod.yaml, schema-induction.yaml). relation.gliner_relex (latence-relation-gliner) is kept in the roster (existing stacks unbroken — the package still installs, loads, and passes conformance) but is superseded by the unified gliner2 default: a stack that pins it still runs; the blessed path no longer does. The fused-provider stage rule (a Fused Provider belongs under a fused_entity_relation Stage, never relation_extraction) is unchanged and now satisfied by the gliner2 Provider.

  • Anti-false-green (the paddle/relex lesson — honoured). The offline fake GLiNER2 mirrors the REAL gliner2 return shapes: the per-label extract_entities dict, the per-relation-type extract_relations dict (head/tail sub-dicts carrying start/end), spans when include_spans=True, and native quantize/compile kwargs on from_pretrained. Teeth: test_flat_list_misparse_fails_anchor (a provider mis-parsing either nested dict as a flat list — the gliner-library shape — recovers ZERO records), test_offsets_round_trip_under_variable_offsets (a variable-offset fixture — three names at three distinct chunk-local starts on a non-zero-based chunk — proves every mention slice + the relation covering span round-trip to the chunk EXACTLY, so an off-by-one / wrong-base coordinate bug FAILS), test_optimized_load_fault_degrades_to_plain_load (a forced quantize/compile fault still extracts identically via the plain fallback), and test_native_fault_becomes_typed_error_no_pii_leak (a native fault echoing the input becomes a typed ProviderError carrying no content). The stub-scoped conformance case (_relation_case(gliner=True, backend="gliner2")) drives the same extract_fused contract through the faithful gliner2 stub (install_gliner2 now seeds relations too).

  • License (ADR-0012 / research-diligence, verified 2026-07-10 — weights AND code, separately). fastino/gliner2-multi-v1 checkpoint: Apache-2.0 (weights, HF model card "License: apache-2.0", NOT gated / freely downloadable). gliner2 library: Apache-2.0 (code, github.com/fastino-ai/GLiNER2 LICENSE + PyPI classifier). Both permissive → license_verified=True in the profile; nothing downloads unless a Pipeline configures the Provider. (Not UNVERIFIED — both sources confirmed permissive.)

  • Roster +1 (40 → 41); CI green. W9 adds ONE Provider (fused_entity_relation.gliner2, the 12th stub-scoped) and swaps the blessed-stack default. ruff + mypy --strict (src + tests) + pytest + conformance (C1–C6 for the new Provider) + stack-validate + bake-off green; scripts/verify-local.sh green. The pod validation is the maintainer's (scripts/validate-gliner2-unified-on-gpu.md): real fastino/gliner2-multi-v1 NER+RE offsets round-trip on real German chunks, and redaction.gliner2 PII spans unchanged under quantize+compile with the 2.83× re-measured. No GPU number is fabricated here — offline CPU proves correctness + the coordinate round-trip + the guarded-degradation; the throughput number needs the rig.

W9 pod re-validation — two real-weights gaps fixed (2026-07-10)

Pod validation on real fastino/gliner2-multi-v1 weights confirmed the offset round-trip PASSES (all mentions + relations slice back to exact text — the offset_map/OffsetIndex mapping is validated-correct, untouched). Two real-weights gaps surfaced and were fixed on the branch before merge:

  • Missing peft runtime dep. gliner2's relation inference engine (gliner2.inference.enginegliner2.training.trainer) imports peft, which the gliner2[local] extra does NOT declare — real load fails ModuleNotFoundError: No module named 'peft'. Added peft>=0.10 to BOTH latence-extract-gliner2 and latence-pii-gliner2 dependencies (the engine import is on both providers' load path). peft is Apache-2.0 (github.com/huggingface/peft, verified 2026-07-10); recorded in THIRD-PARTY-LICENSES + the pod script.

  • compile=True crashes at INFERENCE, not load — the load-time guard missed it. torch.compile compiles lazily on the first forward, so on an env whose inductor/dynamo backend cannot compile the model (pod: torch 2.4.1 + gliner2-multi-v1) the failure is a torch._dynamo BackendCompilerFailed raised INSIDE extract_entities/extract_relations/batch_extract_entities — which the provider was surfacing as a ProviderError (a CRASH, not the spec's graceful degradation). Fix — the inference-time compile guard: a new dep-light, torch-free predicate is_compile_backend_failure(exc) (matches the compile-fault class name / torch-compile-stack module path, walking the exception cause/context chain so a guard-wrapped fault is still caught — never imports torch) plus a per-provider _reload_uncompiled(): on the FIRST such fault at inference each provider re-loads ONCE with compile forced off (quantize kept — fp16 alone is fine), retries the forward on the uncompiled model, and uses it for the rest of the run (latched via _compile_disabled), so a bad compile costs only throughput, never a crash or a ProviderError. quantize (fp16) is unaffected and stays on. Tests: a fake model whose compiled forward raises BackendCompilerFailed on first call → both providers degrade to uncompiled and still return correct records (test_inference_time_compile_fault_degrades_to_uncompiled for each) + the once-across-many-chunks latch + the is_compile_backend_failure unit tests (name / module-prefix / wrapped-cause / non-compile-error-false). The W8 AST perf-guard covers the load_gliner2_optimized seam; scripts/validate-gliner2-unified-on-gpu.md §4b is the maintainer's turnkey re-validation of the compile degradation on the pod.

W9-scaling — fused_entity_relation.gliner2 completes reliably on a large corpus (Blackwell hang fixed, 2026-07-14)

Live on a real 2,802-chunk corpus (RTX PRO 4500 Blackwell, torch 2.8/cu128, gliner2 native compile+quantize) the fused provider HUNG two different ways, both leaving no extract checkpoint after tens of minutes. Root causes + robust-by-design fixes (all CPU-testable offline; the throughput/GPU-placement numbers are the maintainer's to confirm on the pod — no GPU number fabricated here):

  • compile=True recompile-thrash (the primary hang). gliner2 compiles with torch.compile(dynamic=True), which recompiles per distinct chunk-shape; on a real varied-length corpus that blows past torch._dynamo's recompile_limit (measured: worker pinned ~290% CPU, GPU 0%, no progress after ~50 min — it only completes at ~2 docs). The pod-measured 2.83×/1.74× was on uniform batch shapes, not production corpora. Fix — compile now defaults OFF for this provider (a provider-level _COMPILE_DEFAULT, injected into the load config so it is the single source of truth for both the eager validation and the lazy load). It stays an opt-in knob (compile: true) for a homogeneous workload, with a docstring warning. Note: the W9 inference-time reload-uncompiled latch catches compile crashes (BackendCompilerFailed), NOT this silent recompile thrash — so the safe default is the real fix, not the latch. The shared resolve_gliner2_knob default (True-on-cuda) is unchanged — only this provider's default flips — so redaction.gliner2 / the P8 tune grid / the perf-seam tests are untouched.

  • fp16-eager CPU-spin with the GPU idle (the second hang). With compile=False, after "Converted model to fp16" the worker ran ~520% CPU across ~129 threads, GPU 0%, no progress for 20+ min even with OMP_NUM_THREADS=8 exported. GPU-idle ⇒ inference is not reaching the GPU. Two independent mitigations, both in the dep-light perf seam (latence_core/providers/perf.py, torch-free to import):

  • Intra-op thread cap (cap_intra_op_threads + resolve_intra_op_threads). OMP_NUM_THREADS does NOT bound torch once it is imported — torch owns its own intra-op pool (default os.cpu_count()), and gliner2's internal batching then oversubscribes it to ~cpu_count threads on a many-core host. The provider now caps it via torch.set_num_threads(min(config, os.cpu_count())) at load (config intra_op_threads, default 8, <= 0 disables). Guarded → a torch-free/failed cap is a logged no-op.
  • Explicit fp16 cuda placement + device logging (ensure_gliner2_on_cuda). Best hypothesis for the GPU-idle spin: the fp16-converted model silently stayed on CPU (fp16 eager on CPU is pathologically slow and CPU-bound). On a GPU host the model is now moved to cuda best-effort (model.to("cuda") across the model/model.model wrapper shapes) and its resident device is read back and logged, so the fp16-on-CPU condition is visible, not silent. If a run is still CPU-bound with the GPU idle after placement, quantize: false (fp32 eager on cuda) is the documented escape. The exact GLiNER2 placement API + the fp16-eager Blackwell behaviour need pod validation — this is defensive insurance + diagnostics, not a pod-confirmed fix.

  • Incremental, batch-granular progress (never appears hung). extract_fused now streams the chunk iterable in bounded batches of batch_size (clamped to _MAX_BATCH_SIZE=64 so a large configured value can't run the stage silently for minutes) and logs cumulative progress (chunks / mentions / relations) after each batch, recorded on self._progress for forensics/tests. Each chunk is still ONE bounded ≤ max_len forward (this provider does not pack multiple chunks into a native call, so batch_size shapes the progress cadence, not a padded tensor); output is record-for-record identical for any positive batch_size. The Runner's existing batch-granular checkpoint WRITE (ADR-0033) is unchanged; this adds the missing extraction-time progress trail.

  • Tests (anti-false-green; CPU-testable, offline). test_compile_defaults_off_even_on_cuda (forces LATENCE_CUDA=1; asserts compile=False is threaded to from_pretrained while quantize=True still is) + test_compile_opt_in_still_enables_on_cuda; test_intra_op_threads_capped_at_load (a fake torch records the set_num_threads value — a provider that skipped the cap FAILS) + override/disable/invalid-ConfigError; test_batch_size_clamped_to_max + test_incremental_batch_progress_recorded (5 chunks @ batch_size 2 → cumulative progress (2,4,5)) + below-min_chars-not-counted; test_fp16_model_moved_to_cuda_when_gpu_present (a fake that records .to("cuda")) + no-move-on-cpu. Perf-seam unit tests cover resolve_intra_op_threads/cap_intra_op_threads/ensure_gliner2_on_cuda (apply/none/torch-free-guarded/bare-model). scripts/verify-local.sh GREEN.

W9-jit — the TorchScript JIT is neutralized too (Blackwell/CUDA-13 nvrtc crash, 2026-07-17, #190)

A live 519-page German-corpus run on a Blackwell / CUDA-13 pod crashed inside fused_entity_relation.gliner2 extraction with a native RuntimeError: ... failed to open libnvrtc-builtins.so.13.0, raised from the mdeberta-v3 encoder's @torch.jit.script-ed build_relative_position (transformers' DeBERTa-v2 modeling module). The gap: the W9 compile guard neutralizes torch.compile (dynamo/inductor) and nothing else — torch's other runtime compiler, the TorchScript JIT, was untouched, and its fuser asks nvrtc to build kernels against a builtins library that is not present for that CUDA/torch pairing. The W9 lesson repeats verbatim: the fault surfaces on the first forward, not at load, so no load-time try/except can see it, and is_compile_backend_failure does not match it (it is a bare native RuntimeError, not a dynamo fault).

  • Fix — one shared seam function (never re-implemented per Provider; its call site is per-Provider, see the next bullet). neutralize_torch_jit() (in the dep-light latence_core/providers/perf.py) drives both levers torch exposes: it sets PYTORCH_JIT=0 (read by torch at import, so a not-yet-imported torch comes up JIT-free and every module-import-time @torch.jit.script is a no-op) and calls torch.jit._state.disable() for a torch that is already imported, so scripting is neutralized before transformers lazily imports its DeBERTa modeling module. Torch-free-safe and never raises: on a host without torch the env switch is still set and the runtime lever is a logged no-op.
  • The fix is ORDERING, and the ordering is per-Provider — one shared function, called at six call sites (amended 2026-07-18, audit R2). The first cut of this ADR mandated the call be made only inside load_gliner2_optimized / load_then_cast, immediately before the native loader, and explicitly banned copying it into the Providers. That arrangement does not fix #190 and must not be restored. Every gliner-family Provider evaluates its backbone import — from gliner import GLiNER (entity.gliner, relation.gliner_relex, redaction.gliner_pii), from gliner2 import GLiNER2 (fused_entity_relation.gliner2, redaction.gliner2), from glinker import ProcessorFactory (disambiguation.glinker) — one statement before it enters the loader, and that import transitively pulls in transformers' models.deberta_v2.modeling_deberta_v2, whose relative-position helpers are @torch.jit.script-decorated and are therefore compiled into real ScriptFunctions at module import time. Neither lever can un-script them afterwards: PYTORCH_JIT is read by torch only at torch import, and torch.jit._state.disable() affects only future script() calls. So the seam logged "torch JIT neutralized" while the encoder stayed scripted and the crash was NOT fixed. The requirement, therefore: each of the five mdeberta Providers and the disambiguation.glinker backend calls the one shared neutralize_torch_jit() before its own backbone import — the same function, never a re-implementation (no Provider may touch PYTORCH_JIT itself). The calls inside the two loaders stay as defence-in-depth for a caller that reaches the seam with the backbone not yet imported; they are never sufficient on their own. Enforced structurally by test_perf_fp32_regression_guard.py::test_mdeberta_providers_neutralize_the_jit_before_importing_the_backbone (AST: the earliest neutralize_torch_jit(...) must precede the earliest backbone import in the same function) and test_no_provider_hand_rolls_the_pytorch_jit_workaround; R3/#190-0 correction: that AST scan originally walked function bodies only, so the one shape the seam can never repair — a backbone import at module level, evaluated at provider-module/entry-point import before any Provider code (and therefore any neutralize_torch_jit() call) can run — lived in no FunctionDef, produced zero offenders and passed every #190 guard clean; module scope (including class bodies, excluding never-evaluated if TYPE_CHECKING: bodies) is now scanned and such an import is flagged unconditionally, because the only fix is to defer it into the loading function (the ADR-0016 lazy-heavy-dep discipline every in-tree Provider already follows); the ordering is proven behaviourally by test_perf_jit_import_ordering.py, whose fake gliner/gliner2 scripts at module import — the only shape that can tell "neutralized before the import" from "after".
  • Throughput-only degradation, byte-identical output (the compile-guard discipline). An un-scripted function is the same Python the JIT would have compiled — same math, same values, only without fused-kernel speedup. Nothing about emitted mentions/relations/offsets/labels changes. No operator-side env tweaking is required: the workaround is encoded in each Provider's load path (the call ahead of its backbone import), not in operator configuration — there is nothing for a deployment to set.
  • Tests (anti-false-green). The offline fake torch mirrors the REAL switch semantics — torch.jit.script is a pass-through only while torch.jit._state._enabled.enabled is False, otherwise it returns a ScriptFunction that raises the exact libnvrtc-builtins.so.13.0 error when called (the real Blackwell behaviour, on the first forward). test_torch_jit_enabled_reproduces_the_blackwell_nvrtc_crash / test_scripted_encoder_reproduces_the_blackwell_nvrtc_crash keep the fake honest; test_load_then_cast_neutralizes_the_jit_for_the_gliner_path + test_load_gliner2_optimized_neutralizes_the_jit(_on_the_optimized_path) bite the seam; test_extraction_survives_the_jit_on_a_blackwell_style_host drives the real Provider end-to-end over a fake gliner2 whose encoder scripts its relative-position helper at from_pretrained and calls it in every forward; test_output_is_byte_identical_to_the_jit_free_path proves the records are identical field-for-field to an encoder that never scripted; test_the_jit_is_actually_neutralized_not_merely_configured asserts the observable pass-through, never a flag. Structural teeth in the AST guard: test_mdeberta_providers_load_through_a_jit_neutralizing_seam (a further mdeberta Provider that loads bare is caught the moment its source lands) and test_no_provider_hand_rolls_the_pytorch_jit_workaround (the PYTORCH_JIT string may not appear in any Provider source). Every one of these FAILS on the pre-fix code. R2/#190-5 correction: those guards originally scanned a hardcoded 5-path tuple, so the "caught the moment its source lands" claim was false — the sixth in-tree DeBERTa-backed Provider (disambiguation.glinker, whose GLinkerBackend._init_executor imports glinker) had zero JIT coverage. The parametrization is now DISCOVERED (_discover_deberta_backed_sources() globs packages/*/src for backbone importers), the routing subset is derived from it, test_every_in_tree_deberta_backed_source_is_scanned_by_the_jit_ordering_guard fails if any importer escapes the scan, and disambiguation.glinker additionally has behavioural import-ordering coverage in test_perf_jit_import_ordering.py.

Status note — the PRODUCTION OCR path: LightOn-OCR served by vLLM at concurrency (W10-lighton-vllm)

Phase-3 Wave-10 adds the production OCR path: parser.lighton_vllm, a drop-in Parser that hits a running vLLM OpenAI-compatible endpoint instead of loading transformers in-process. The gap it closes was called out in W8 itself: production OCR is a warm vLLM server, not in-process transformers — pod-measured, vLLM at concurrency is ~2.7x faster than optimised transformers (LightOn's recommended path; their 5.71 pg/s H100 headline is the vLLM path), and the W8 note recorded that the vLLM head-to-head could NOT be measured on that pod (driver too old for the cu126+ torch a LightOnOCR-2 vLLM needs). W10 is the provider; its real throughput is the pod maintainer's to fold in. The in-process parser.lighton (W1/W8) stays for single-box / simple. - Drop-in Parser, same Capability, one-config switch (ADR-0007). parser.lighton_vllm satisfies the SAME Parser Capability as parser.lighton and emits the SAME output contract (markdown per page → the exact-offset PageMap.from_page_texts seam), so a Pipeline swaps the in-process OCR for the served one by pointing base_url at the endpoint — no code edit, no pipeline change. Per document: rasterise → POST each page → assemble the page map. - The rasteriser is FACTORED and SHARED (the reviewer's #1 no-divergence check). Both OCR back-ends now rasterise through ONE latence_parser_lighton.rasterize.PageRasteriser — the SAME dpi (200) + megapixel bomb-cap + 1540px LANCZOS longest-side + offset-0 PDF/image spoof guards. It was extracted verbatim from parser.lighton's W8 rasteriser; the in-process Provider now delegates to it and the served Provider reuses it, so the pixel preprocessing is byte-for-byte identical and cannot silently diverge between the two paths. parser.lighton's existing W8 tests (clamp/downscale/spoof/bomb) stay green through the delegation. The Provider's own guard is injected so a native rasteriser fault maps to its typed taxonomy (no image bytes / content). - The served POST — OpenAI multimodal, LightOn's official sampling. Each rasterised page is encoded as a base64 PNG data: URL and POSTed to {base_url}/v1/chat/completions with messages=[{role:user, content:[{type:image_url,...}, {type:text, text:prompt}]}], max_tokens 4096 (content-safe — NOT 1024, the pod-corrected default that avoids the ~25% dense-page truncation W8 found), temperature 0.2, top_p 0.9 (LightOn's official OCR sampling, verified from the LightOnOCR-2-1B model card). The reply's choices[0].message.content is that page's markdown. - Async concurrency + retry + per-page fail-open (the W6/superpod pattern, reused). A document's page requests fan out concurrently under an asyncio.Semaphore(max_concurrency) (config, default 8; 1 = sequential) inside a single asyncio.run — the public parse stays SYNC (the Runner is sync). Each POST rides latence_core.endpoint.call_with_retry_async (the SAME ported exp-backoff+jitter, 429/5xx/timeout retryable, other-4xx terminal, retries tallied on endpoint_retries). A per-page endpoint failure fails open to an empty page marker for THAT page only (a 40-page scan keeps its 39 good pages); a document degrades to a per-document PARSE_ERROR only if EVERY page failed. Results are re-assembled in input page order (index-keyed), so a shuffled completion order never perturbs the byte-identical page map — the same determinism discipline relation.llm carries. The pixel-bomb / spoof / timeout guards are intact. - Profile + license + conformance. compute="either" (a thin HTTP client — rasterise on CPU, OCR behind the vLLM endpoint), so it is infra-gated like embedding.endpoint / parser.endpoint, NOT device-skipped: it is stub_scoped (conformance runs the contract/graceful-failure/license/determinism subset against a faithful async AsyncOpenAI stub, its real throughput needs a live rig #64 — flagged, never fabricated). model_id="lightonai/LightOnOCR-2-1B"evidence-bearing Apache-2.0 weights AND code (verified 2026-07-09 from the model card, reusing the same permissive LightOn-OCR checkpoint family parser.lighton carries; weights SPDX recorded separately per ADR-0012). deterministic=False; cost_per_1k=None (self-hosted). Passes conformance C1–C6. A dedicated _lighton_vllm_case (conformance/cases.py) installs the async endpoint stub + a faithful offline fake pypdfium2/PIL (install_lighton_vllm_offline in conformance/stub.py) so the REAL shared rasteriser runs offline (render clamp → 1540px downscale → base64 data-URL encode → served OCR → PageMap) — the served OCR reply is the ONLY stubbed thing. - The production vLLM serve recipe — CUDA GRAPHS ON (no --enforce-eager in prod). Documented in the Provider docstring + docs/serving-lighton-vllm.md + the pod script scripts/validate-lighton-vllm-on-gpu.md: vllm serve lightonai/LightOnOCR-2-1B --dtype bfloat16 --gpu-memory-utilization 0.90 --max-model-len 10000 --limit-mm-per-prompt '{"image": 1}' --mm-processor-cache-gb 0 --no-enable-prefix-caching --max-num-seqs 32. cudagraphs give the extra speed the in-process transformers head-to-head's enforce-eager floor did NOT capture, so production keeps them ON. Known caveat documented: on bleeding-edge Blackwell (SM 12.x) vLLM 0.24's bundled FlashInfer can't JIT cudagraphs (FlashInfer requires sm75+) → add --enforce-eager there (a ~perf floor) OR upgrade flashinfer; mainstream GPUs (Hopper/Ada/Ampere, CUDA >= 12.6) run cudagraphs fine. SUPERSEDED (2026-07-18, FM-FLASHINFER-SM120) — the live Blackwell/CUDA-13 run showed the abort comes from FlashInfer's sampler capability check misparsing 12.0 as 12, and the field resolution is VLLM_USE_FLASHINFER_SAMPLER=0, which clears it while keeping cudagraphs ON. --enforce-eager is deliberately not the remedy: it trades the abort for exactly the throughput floor this bullet argues against. The --enforce-eager sentence above is retained as the record of what was believed at W10; docs/compatibility-matrix.md and deploy/env/served-ocr-server/serve.sh are the current truth. - Anti-false-green — the mock mirrors the REAL vLLM response shape. The offline double is a faithful async openai.AsyncOpenAI: chat.completions.create is a coroutine returning choices[0].message.content (the exact real shape relation.llm / embedding.endpoint mock against — NOT a fictional API), and it decodes the base64 page image the Provider ACTUALLY POSTed back to that page's markdown, so the rasterise → data-URL → POST → decode path is genuinely exercised (a Provider that mangles the image or drops a page FAILS). It records max_in_flight (the semaphore bound) and honours a per-page asyncio.sleep so the concurrency is provably real. Tests: test_batched_pages_equal_per_page_record_for_record (concurrent == max_concurrency=1, record-for-record with offsets, over VARIABLE-LENGTH pages so a wrong/out-of-order assembly fails), test_wall_clock_is_ceil_n_over_c_not_n (timing: N pages at concurrency C take ~ceil(N/C) x delay, max_in_flight <= C AND > 1), test_max_concurrency_one_serializes, test_one_page_endpoint_failure_fails_open_document_keeps_good_pages (per-page fail-open), test_shuffled_completion_order_is_page_ordered + test_two_runs_byte_identical (determinism, index-ordered), test_request_uses_openai_multimodal_shape_and_official_sampling (max_tokens 4096 / temp 0.2 / top_p 0.9 on the request kwargs), plus the guards intact (spoof/bomb/unsupported → graceful PARSE_ERROR, no endpoint call). NO real server in CI (infra-gated, skip-with-flag). - Roster +1 (40 -> 41); CI green. The registry roster is now 41 (was 40) — the roster/count assertions (test_conformance.py, test_e2e_conformance.py) + the E2E conformance roll-up are updated (parser.lighton_vllm is stub-scoped, not device-skipped). scripts/verify-local.sh + .github/workflows/ci.yml install latence-parser-lighton-vllm --no-deps in the heavy-source step and build its wheel. ruff + mypy --strict (src + tests) + pytest green across latence-core, latence-parser-lighton (refactored to the shared rasteriser), and the new latence-parser-lighton-vllm. The heavy dep is isolated (this package ships only the light openai/pypdfium2/pillow client — no torch/transformers, ADR-0016); the real vLLM throughput (the ~2.7x claim) comes from the maintainer's pod run (stand up vLLM, OCR the LBBW pages through the provider, record pages/second at concurrency), folded into PERF-RESULTS.mdnever fabricated here (offline proves correctness + the async concurrency shape; the throughput number needs the live server).

Status note — latence tune: the per-device optimal-settings profiler, correctness-gated (W11-autotune)

Phase-3 Wave-11 adds latence tune"deploys optimally on any box." The W8 perf seam resolves the SAME knobs everywhere; W11 turns them per device under a HARD correctness gate, because a client's GPU decides which knobs win AND which even work (compile support is env-dependent: gliner2-multi-v1 + torch 2.4.1 raised BackendCompilerFailed at inference on one pod). It profiles the device, sweeps each heavy Provider's knobs on a small real sample, keeps only settings that don't crash AND don't change output, picks the fastest, and writes a per-device tuned config the runner overlays only on a fingerprint match. Throughput-first; correctness is the hard gate. Design: this note; usage: docs/tuning.md; the real sweep: scripts/tune-on-gpu.md.

  • The tune seam (latence_core.tune, split like the bake-off). A pure contract + torch-free logic halftune.report (the TuneReport/ProviderTuning/RejectedKnob/KnobSetting models
  • the signed-style Markdown renderer + the tuned/<device>.yaml load/dump), tune.fingerprint (the DeviceFingerprint), tune.sweep (the per-Provider knob grid), tune.overlay (the runner-side apply), and tune.tuner (the sweep + gate + pick) — with no runner/stage imports, so a bare import latence_core stays thin (ADR-0016). The measuring half is the real RegistrySampleProbe (tune.realprobe), reached only on a rig. The two are bridged by a narrow one-method SampleProbe seam (tune.probe): probe(provider, setting, sample_size, repeats) -> ProbeOutcome (either an error, or an output-fingerprint + timings). The real probe reuses the bake-off's timing/sample approach — it stages the SAME bundled deterministic corpus, holds the other stages at a base stack, swaps the candidate knobs onto the target stage, runs the LocalRunner, times K repeats after a warmup, and fingerprints the target-stage checkpoint bytes (labels/spans/offsets/text — the same G1 determinism subject the bake-off cross-checks). A fake probe with a known knob→(speed, correctness, crash) profile drives the IDENTICAL gate offline.
  • The CORRECTNESS GATE (the crux, reusing the W8/W9 compile-fault reasoning). The baseline candidate (fp32/plain, compile off, batch 1) is probed FIRST and defines the correctness reference — its output fingerprint is what every accelerated candidate must MATCH. A candidate is REJECTED if it (a) raised any error — crucially including an inference-time BackendCompilerFailed/dynamo/OOM (the W9 lesson: the fault surfaces on the FIRST forward, not at load), OR (b) produced a different output fingerprint (output_changed). Compile faults are detected torch-free by the new shared latence_core.providers.perf.is_compile_backend_failure (added to the W8 seam so W8/W9/W11 share ONE "is this a compile-backend failure" reasoning): it walks the exception's __cause__/__context__ chain and matches by class name (BackendCompilerFailed, InductorError, dynamo Unsupported, …), defining-module prefix (torch._dynamo/torch._inductor/torch.fx/torch._functorch), or a telltale message — so a compile crash WRAPPED in a typed ProviderError is still caught, on a torch-free host. The report names it precisely (compile: rejected (compile_failure)). Slow-but-correct always beats fast-but-crashing/wrong: the baseline is eligible by construction, so a Provider on which every acceleration is rejected still ships its safe baseline (a compile: true that crashes NEVER wins).
  • The device fingerprint + the overlay guard (prevents cross-GPU misapplication). Every tuned/<device>.yaml records a DeviceFingerprint — CUDA present (LATENCE_CUDA-overridable), GPU name + total VRAM (nvidia-smi, torch-free), logical CPU count, and the torch/CUDA version ONLY if torch is already importable (else an honest None). Its key is a stable hash over those fields; two fingerprints MATCH iff the key is equal. The runner overlay (latence run --tuned tuned/x.yaml) applies the tuned per-Provider knobs only on a match — a mismatch logs a warning and uses the stack defaults, NEVER silently applying a tune from a different GPU (where a winning compile: true could crash). LATENCE_DEVICE pins the whole fingerprint for a seeded test, so the key is reproducible on any host.
  • Overlay is config-only (no contract/offset change). The apply is the SAME shallow per-stage merge the bake-off uses — {**stage.config, **winning_knobs} — so a tuned run is byte-for-byte the stack it would be with those knobs hand-written into the YAML. A nested Export/Disambiguation config.embedder whose provider is a tuned one is merged inside its own config block (the embedder is an opt-in sub-config, ADR-0017, not a DAG node). No Provider contract, seam, or offset changes.
  • Deterministic + honest report. The knob grid is cartesian-but-pruned in a fixed order (baseline first); the winner pick is a total order (throughput desc, then fewer knobs, then label); the fingerprint is stable — so the same device+sample+seed yields a byte-identical tuned/x.yaml. The report renders WINNERS (baseline→tuned r/s + speedup + winning knobs) AND every REJECTED knob with its reason — winners AND rejections, never a fabricated number (each r/s is the median of the seeded timed runs on THAT device).
  • Anti-false-green tests (offline; the real sweep is a pod run). test_tune.py drives the gate with fake Providers: pick-correct (test_picks_the_fastest_correct_setting), reject-crash (test_rejects_inference_time_compile_crash_named_compile_failure — a fake forward that returns a BackendCompilerFailed in ProbeOutcome.error, exactly how the real probe surfaces an inference-time fault, asserted rejected + named compile_failure + NOT the winner), reject-wrong (test_rejects_a_knob_that_changes_output), fingerprint-guard (test_overlay_NOT_applied_on_fingerprint_mismatch_falls_back_to_defaults), plus determinism (test_deterministic_same_device_sample_seed_identical_config), the torch-free compile detection through the cause chain / module-prefix / message, baseline-crash-is-untunable, tie-breaking, and the CLI run --tuned match/mismatch/missing-file paths (test_cli.py). No real GPU numbers are fabricated.
  • No roster change; CI green; pod-measured. W11 adds NO Provider (it tunes existing ones), so the registry roster is unchanged and no stack/matrix wiring changes; the one W8-seam addition (is_compile_backend_failure) is additive. ruff + mypy --strict (src + tests) + pytest green; scripts/verify-local.sh green. latence tune and latence run --tuned are new CLI surfaces; the real per-device winners + the real rejected-compile case are the maintainer's to fold into a signed tune report from scripts/tune-on-gpu.md (a Blackwell/Ada sweep) — never fabricated here.

Status note — KG export node type-column consistency (W12-kg-consistency)

Phase-3 Wave-12 fixes a cross-format naming inconsistency in the S9 export.knowledge_graph Provider (latence_core.stages.graph_export): the entity type was written under the Parquet column label, while GraphML names it type (<data key="type">) and TTL names it lg:entityType. _write_nodes_parquet now names that column type (value n.label), so all three export formats name the entity type identically and a consumer reading type gets it in each.

  • Export-schema note only, NOT a record-contract change. GraphNode.label (the in-memory contract field) is unchanged; only the Parquet export column name changed (labeltype). The InducedLabels record SCHEMA_VERSION is untouched. The nodes Parquet already carried a distinct canonical_name column, and the edges Parquet label column (relation type) is unaffected.
  • No consumer left broken. No code or test read the node Parquet label column (the demo viewer reads the in-memory GraphNode.label; test_e2e_graph/test_graph_export read canonical_name for nodes and the edges label). New tests (test_nodes_parquet_entity_type_column_named_type, test_node_type_matches_across_parquet_graphml_ttl) assert the type column carries the entity type and matches GraphML/TTL byte-for-byte; byte-stability is preserved.

Status note — gliner model-window guard + tokenizer-mismatch caveat (W12-gliner-window)

Real-GPU-E2E found the gliner-family extractors silently truncating long chunks. The chunker (chunk.markdown) counts tokens in its OWN tokenizer, but the extractors run on mdeberta-v3 (the gliner / gliner2 encoder), whose tokenizer produces ~1.2× more tokens. Pod-measured: a 768-chunker-token cap yielded mdeberta token lengths of median 666 / MAX 921, and 2 of 7 chunks exceeded the 768 model window → gliner truncated them, losing tail entities/relations and making offsets past 768 unreliable. Wave-12 closes this two ways (additive, config-safe):

  1. Hard model-boundary guard (primary). Every gliner-family extraction Provider — entity.gliner, fused_entity_relation.gliner2, relation.gliner_relex, redaction.gliner_pii, redaction.gliner2 — gains a max_len config (default 768, the mdeberta window) resolved via latence_core.providers.perf.resolve_max_len. The gliner2 providers thread it as the native max_len kwarg of extract_entities / extract_relations / batch_extract_entities. The gliner-library providers apply it as model.config.max_len (the library's truncation lever) at load, before any torch.compile wrap, via the guarded apply_gliner_max_len (a model/config without a settable max_len is left unchanged — never a crash). So the model never processes past its window; content past max_len is a documented boundary (not extracted), not a silent surprise, and it is configurable per checkpoint.

  2. Gliner-safe chunk caps + the tokenizer-mismatch caveat (documented). chunk.markdown's max_tokens is counted by the CHUNKER's tokenizer, which differs from the extraction model's (~1.2×). Bigger chunks give the extractors more relation/RAG context, so the chunk size is maximized subject to the window (maintainer directive: "256 is too small — use 768 max and come as close as possible"): the shipped stacks feeding a gliner extractor (gpu-sota, gpu-sota-pod, schema-induction) set chunk.markdown max_tokens: 640 / overlap_tokens: 80 — 640 chunker-tokens ≈ ≤768 mdeberta-tokens, the calibrated safe near-max — with a NOTE recording the caveat and the rule. Non-gliner stacks are untouched. The provider max_len guard (default 768) is the hard backstop regardless.

Tests: resolve_max_len default/override/clamp + apply_gliner_max_len set + guarded-no-op (test_perf_seam); each provider threads/sets its window (default 768 + config override) and a long input is handled without a crash; a stack-lint (test_gliner_stacks_keep_chunk_within_the_ mdeberta_window) asserts every shipped gliner-feeding stack caps chunk max_tokens ≤ 640. The real truncation-avoidance numbers are the pod maintainer's to re-measure — never fabricated here.

Amendment — the load CHOREOGRAPHY moves inside the seam (C4, 2026-07-26)

Amends the W8/W9/W9-jit/W9-scaling/W12 status notes above. The perf seam's steps are unchanged and still do exactly what those notes describe; what changes is who holds their ordering.

The friction that warrants reopening it. Those notes accumulated seven ordered steps a learned Provider had to perform by hand — neutralize the JIT → import the backbone → check the DeviceDecision → load+cast → place on the resolved device → bound the token window → compile — carrying five real "before" constraints, none of which is expressible in a signature. The only thing holding them was an AST linter (test_perf_fp32_regression_guard.py, 829 lines) that parsed each Provider's source. That arrangement produced three field bugs (#190, #191, and the OCR parsers' bare model.to(device)), and its curated _LEARNED_PROVIDER_SOURCES list silently excluded sparse.splade — a registered learned Provider that consequently shipped a bare fp32 load with no device placement at all, fully green. When the only way to hold an invariant is to lint the callers, the invariant belongs inside the module.

What is now normative. Three deep entry points in latence_core.providers.perf, one per native-library family — load_gliner_model, load_gliner2_model, load_transformers_model — each taking (import_model_cls, model_id, config, decision, *, provider_name, guard=None) and returning a model ready to run. A learned Provider calls exactly one of them and knows no ordering.

Specifically superseding the W9-jit requirement ("each of the five mdeberta Providers … calls the one shared neutralize_torch_jit() before its own backbone import"): the property that requirement protects is unchanged and non-negotiable — the TorchScript JIT must be dead at the instant the backbone module is imported, or transformers' @torch.jit.script-decorated deberta_v2 relative-position helpers become ScriptFunctions that die in nvrtc on the first forward. The mechanism changes: the Provider no longer calls neutralize_torch_jit() and then imports; it hands the seam a zero-arg import factory, and the seam kills the JIT, caps the intra-op threads and then calls the factory. The ordering stops being a rule a Provider can violate and becomes three consecutive statements a caller never sees. The behavioural proof is unchanged and still authoritative: test_perf_jit_import_ordering.py, whose fake gliner/gliner2/glinker scripts at module import — the only shape that can distinguish "neutralized before the import" from "after" — passes against every Provider. disambiguation.glinker, which owns no from_pretrained (it hands the checkpoint to the glinker library's own DAG), still calls the shared neutralize_torch_jit() directly before its import; that call site is unchanged.

Consequently deleted: the AST ordering scans and the routing/accept-list scans. The sequence is tested through the interface with a recording fake that asserts the observed ORDER of real effects (test_perf_load_choreography.py). One lint-shaped rule survives, and is now DISCOVERED by globbing packages/*/src rather than listed: a learned model's from_pretrained may be referenced only inside the perf seam. That rule needs no ordering analysis and no list of Providers — which is precisely why it could not have missed sparse.splade.

§2 "device placement stays the adapter's job" is preserved. The adapter still owns its native dependency, its install hint, the model object and the DeviceDecision it resolved from its own declared profile; place_on_device continues to run against the model the adapter's library produced, exactly as before. Only the sequencing of shared helpers moved inward. The typed-error policy stayed with the adapter too, which is why the entry points take an optional guard: an adapter's config_exceptions decide "bad model id" versus "provider failure", while a missing optional dependency must still surface as its raw pip install … ImportError.