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 (computecpu|gpu|either,memory_mb,model_id, SPDXlicense+license_verified,deterministic,batch,cost_per_1k), with the UNVERIFIED invariant enforced (license == "UNVERIFIED"⇒license_verifiedmust beFalse, per ADR-0012). A provider declares it via aprofileclassmethod (preferred) or aPROFILEClassVar; theHasProfileruntime_checkableProtocol +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 underLATENCE_CUDA=0|1(deterministic for CI) and defaultsFalsewhen unknown;select_device(requested, profile)returns a typedDeviceDecisionand 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
LocalRunnerroutes each Stage'sdeviceconfig value against its provider's profile before running; a skipped GPU stage produces an empty output + recordsskipped=True+ the profile columns in the Quality Report'sStageMetrics(QUALITY_SCHEMA_VERSION 16). The in-core reference providers (gazetteer, pattern, markdown chunker, intake screener) andentity.glinerdeclare honest profiles;license_verified=Trueappears 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 resolution —
super().__init__(config)resolvesconfig.deviceagainst the subclass's declaredProviderProfilevia P3-F1'sselect_device;self.deviceexposes theDeviceDecision(skip-with-flag on a CPU-only host, never a fabricated device). Placement stays the adapter's job (ADR-0003). - Batching —
batched(items, run_batch)groups into a validatedbatch_size(≥1) whenprofile.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 toProviderError(runtime fault) orConfigError(opted-in viaconfig_exceptions), passes a frameworkLatenceErrorthrough unwrapped, and carries no record content/PII (provider + op + native type name only, never the native message). - Provenance/offset carry —
carry_provenance/rebase_offsets/resolve_pagesreuse the existingOffsetIndex/PageOffsetIndexseam (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'sConformanceCase. 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_ofraise) — 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
PageOffsetIndexseams — not a weaker copy); C2 graceful failure (adversarial input → a typedLatenceErroror 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 aProviderProfile;UNVERIFIED+verifiedis impossible; a restricted license must be verified/opt-in-flagged); C4 determinism-or-documented (adeterministicProvider is byte-identical over two runs; a non-deterministic one records its declaration); C5 resource/device honored (acompute="gpu"Provider on a CPU-only host is skipped-with-flag viaselect_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 Providerscompute="either", non-deterministic,model_id=Nonefor the endpoint clients that bundle no weights,cost_per_1k=Nonewhere deployment-specific). The twoProfilerProviders declare via aPROFILEClassVar (not aprofileclassmethod) because the Profiler Capability's own I/O method is namedprofile(the P3-F2 collision). - Endpoint/GPU Providers are stub-scoped + flagged.
*.endpoint,relation.llm,embedding.endpoint, and*.glinerrun 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 flaggedstub_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=Truebut is not, and raises a bareRuntimeErroron the adversarial input) FAILs C4 and C2; a Provider that leaks PII in its quarantine reason FAILs C2's no-PII gate; a syntheticcompute="gpu"Provider is skipped-with-flag by C5 (itschunkis asserted never called). All intest_conformance.py. - Quality Report integration. The report gains an optional
conformanceroll-up (ConformanceReport,QUALITY_SCHEMA_VERSION17 — counts only, no record content, safe to persist/share). The report models live in the pure, stage-freelatence_core.conformance.reportso the lean Quality contract imports no harness/fixtures; the harness/cases are lazy-loaded from the package__init__so a bareimport latence_corestays thin (no stage load). - CI. The suite runs in
ci.ymlon py3.11 + py3.12 withLATENCE_CUDA=0(both inside the normalpyteststep and as a dedicated visible gate). An E2E test runs a real pipeline through theLocalRunnerand 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_backendat import). So, per the issue's explicit fallback clause, the substitute is pdfplumber (MIT, pure-Python, CPU, page-aware, no system binaries; transitive corepdfminer.sixalso MIT) — namedlatence-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 nativePdfminerException(corrupt/encrypted/spoofed PDF) → typedProviderError(no content/PII), then_parse_onedegrades it to a gracefulPARSE_ERRORrecord (ADR-0020, the Parse C2 the conformance suite asserts).batchedruns over multiple input documents (input-order-preserving, one batch at a time — ADR-0033).carry_provenance+PageMap.from_page_textscarry 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_provenancewas typedsource: Record, but a Parser's source is aParserInput(a raw document, ADR-0019), which carries Provenance + Classification yet is not aRecordsubclass — 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 ProtocolProvenanceCarrier(just.provenance+.classification, the only two attributes the helper reads). Backward-compatible — everyRecordsatisfies it, and so does aParserInput— 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) andmemory_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/batchwere all live here. - Conformance + roster.
parser.pdfplumberpasses 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 (likelatence-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-levelstacks/directory holds complete, runnable Pipeline configs.stacks/default.yamlis the blessed CPU-first stack (ADR-0007): in-core deterministic Providers + the real CPU adapter where it adds value (parser.pdfplumberfor PDFs+text,chunk.markdown,entity.gazetteer,relation.pattern,redaction.hybrid_rulecomprehensive-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.yamlis the GPU/high-accuracy stack (entity.gliner,relation.gliner_relex,redaction.gliner_pii,embedding.endpoint), each pinningdevice: cudaand 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>.yamlis the documented extension point.latence stack validate <name>— the one-command gate (generalizes G1). Astacksub-command group on thelatenceCLI 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/failCheckResult: contracts (Provenance/Classification + offsets + zero drift — reuses theQualityReportBuildercompleteness 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_idre-run is byte-identical — the H-E1 export-artifact equality); resume (same-run_idresume is byte-identical); graceful_failure (planted malformed inputs quarantined/PARSE_ERROR'd, never crashing); device_honesty. It writes a signed-style summary to Storage (likeG1-RESULTS.md) and exits non-zero on failure. The harness is composition overLocalRunner+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-sotalearned 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 declaredProviderProfile+ requesteddeviceresolve (through the SAMEselect_deviceseam 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 hostvalidate stacks/gpu-sota.yamlreports 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
runFAIL check + non-zero exit, never a crash. A redaction-bypass stack (corpus Export straight offchunk) leaks the planted PII into the exported corpus and FAILs theragcheck. Both intest_stack_validate.py, with the CLI exit-code asserted. - CI.
ci.ymlrunslatence stack validate stacks/default.yamlandstacks/gpu-sota.yamlon every push withLATENCE_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-loadedlatence_core.stackspackage so a bareimport latence_corestays 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-levelmatrix/directory: each file names one Stage (byCapabilityKind) and the candidate Providers to compare, parsed by the strictlatence_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) andmatrix/chunk.yaml(chunk.markdown,chunk.sentence_window) — plusmatrix/entity.yaml(entity.gazetteerCPU +entity.glinerGPU, device-gated) to exercise the skip-with-flag boundary. A candidate may carry a per-candidateconfigoverride merged onto the base stage's config (so a GPU candidate pinsdevice: cuda); an optionalstage_namedisambiguates 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.harnessholds every other Stage at the base stack via the F4 machinery (latence_core.stacks.corpusbundled corpus +stacks.validate.load_stack+ the_skip_reason_for_stagedevice 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-setGoldSetEvaluationscore for entity/relation where the base stack declares agold_set_uri, else the stage-specific metric offreport.parse/report.chunk— no reinvented P/R/F1 or offset logic), latency/throughput (the target Stage's measuredStageMetrics.duration_seconds→ docs/sec — machine-relative, reported not gated), peak memory (tracemallocPython allocations vs the declaredprofile.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.deterministiccross-checked by running each candidate twice and comparing the target Stage's checkpoint bytes — the H-E1 byte-identity discipline — flagging adeterministic=Truethat 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 SAMEselect_deviceseam) is skipped-with-flag — recorded as a row whose measured columns stayNone, itsskip_reasonreferencing 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-styleMATRIX-RESULTS-<stage>.md(a table: candidate × every dimension above + a skipped/why column, likeG1-RESULTS.md) plus a machine-readableMATRIX-RESULTS-<stage>.jsoncompanion (schema-versionedMatrixResult, 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=Truebut 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 GPUentity.glinercandidate is skipped-with-flag with no fabricated columns. All intest_bakeoff.py. -
CI.
ci.ymlrunslatence bake-off matrix/parse.yamlandmatrix/chunk.yamlon every push withLATENCE_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-loadedlatence_core.bakeoffpackage (the contracts import no runner/stages) so a bareimport latence_corestays 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-transformersand 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, monkeypatchSentenceTransformerwith a deterministic fake for conformance/unit tests, and gate a real-weights test onimportlib.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 intolatence-core. - It EXERCISES AdapterBase for real. It is an
AdapterBasesubclass:deviceroutes cpu|cuda into ST'sdevice=(a CPU-only host is never handed a fabricated device);batchedrunsencodeover 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) → typedProviderError/ConfigErrorcarrying no raw text/PII (only the native type name). e5's document-sidepassage:prefix +normalize_embeddings=True(L2 unit vectors) follow the model card;dimensionis read from the loaded model and cross-checked against a declared width (a mismatch is a loudProviderError, 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 rawlist[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'scarry_provenance: Record→ProvenanceCarrierwidening) — it is the honest shape of the Capability:AdapterBaseis 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"(thesentence-transformerslibrary),license_weights="MIT"(the e5-small checkpoint), headlinelicense="MIT"(the effective license of what the vectors embody),license_verified=True, with a citedlicense_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 fakesentence_transformersreturns 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) makesembeddinga first-class bake-off target: the harness swaps the candidate embedder INSIDE the named corpus Export'sconfig['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 SAMEselect_deviceseam. So on a CPU hostembedding.hashingruns real (measured, deterministic) andembedding.sentence_transformers(pinneddevice: 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-depsin the heavy-provider step (its tests monkeypatchSentenceTransformer), type-checks its src+tests, builds its wheel, and runslatence 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
PIIDetectorCapability asredaction.hybrid_ruleandredaction.gliner_pii— swap the provider with one config line (proved by an E2E test that swapsredaction.presidioforredaction.hybrid_rulewith every other Stage identical). It really calls Presidio'sAnalyzerEnginewhen the dep + a spaCy model are present; offline CI installs the package--no-deps, monkeypatchesAnalyzerEngine(+ thenlp_enginesubmodule) with a deterministic fake for conformance/unit tests, and gates a real-weights test onimportlib.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 intolatence-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 silentdocuments_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
AdapterBasesubclass composing all four facilities:deviceroutes cpu|cuda (spaCy is CPU-first, GPU-optional);batchedruns 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 → typedProviderError/ConfigErrorcarrying 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-0031PageOffsetIndexseam) carry the source Provenance/Classification onto each RedactionRecord and resolve everyPIISpan'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-casepii_typevocabulary so atypes: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 toProvenanceCarrierby H-A1) carries aDocumentRecord's lineage onto the RedactionRecord with the offset span narrowed to[0, len);resolve_pagesresolves each span's page through the portedPageOffsetIndexasset — 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 aPageOffsetIndexhanded in, whereas AdapterBase'sresolve_pagesowns the index construction; this Provider resolves pages via the AdapterBase seam (matching every other adapter) and derives the placeholder via the sharedplaceholder_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_lgwould 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"(thepresidio-analyzer+spacylibraries),license_weights="MIT"(theen_core_web_smmodel), headlinelicense="MIT",license_verified=True, with a citedlicense_source(Presidio + spaCy GitHub LICENSE + the HF model card) +license_verified_on="2026-07-08"— Presidio (MIT, "Copyright © Presidio Contributors"), spaCy (MIT), and bothen_core_web_sm/en_core_web_lg(MIT) verified permissive. Passes conformance C1–C6 as a registered stub-scoped provider (a fakepresidio_analyzerreturns 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_ruleruns real (measured, deterministic) andredaction.presidio(pinneddevice: 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-depsin the heavy-provider step (its tests monkeypatchAnalyzerEngine), type-checks its src+tests, builds its wheel, and runslatence 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 offgenerated_ids = output_ids[0, inputs["input_ids"].shape[1]:]→processor.decode(generated_ids, skip_special_tokens=True)→ the page's markdownstr(verified 2026-07 from the HF model card). The slice is load-bearing: HFgeneratereturns the full prompt+continuation sequence, so decoding without slicing offinputs["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.generatereturns prompt+continuation tokens and_FakeProcessor.decoderenders the sliced continuation, so an un-sliced Provider fails rather than passing green.test_offline_fake_is_faithful_to_generate_slice_decode_contractis 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 ontransformers+ a genuine torch.cuda probe, and overrides anyLATENCE_CUDA=0pin (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
AdapterBasesubclass composing all four facilities load-bearingly:devicedeclarescompute="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 resolvedself.device.deviceflows intomodel.to("cuda")(placement stays the adapter's job, ADR-0003);batchedbatches over input documents (input-order-preserving, streaming-friendly, ADR-0033);guard("parse")/guard("load_model")/guard("rasterise")map a native transformers/CUDA/pypdfium2 fault → typedProviderError/ConfigErrorcarrying 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_textscarry the source Provenance onto the exact-offset page seam (offsets round-trip, pages correct). An offset-0 type-spoof guard (mirroringparser.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?).
AdapterBasefit cleanly — the same four seams the pdfplumber Parser uses carried the OCR path, with thecompute="gpu"device branch newly exercised for real. The one OCR-specific addition outside the base is rasterisation (PDF/image bytes → page images viapypdfium2/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_textsis 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 —eitheris 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"(thelightonai/LightOnOCR-1B-1025checkpoint), headlinelicense="Apache-2.0",license_verified=True, with a citedlicense_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.yamladdsparser.lighton(pinneddevice: 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 newstacks/gpu-ocr.yaml— the scanned-document (OCR-first) stack — swaps the Parse Stage forparser.lightonwhile 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.yamldeliberately keepsparser.pdfplumberso its CPU-spine validation is not regressed by a device-skipped Parse. - Turnkey pod validation.
scripts/validate-lighton-on-gpu.mddocuments the exact pod steps (thepip install, a real-OCR run over a scanned fixture, andrun_conformanceon 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 installslatence-parser-lighton --no-depsin the heavy-provider step (its tests monkeypatch transformers/pypdfium2/PIL with shape-matching fakes), type-checks its src+tests, builds its wheel, runslatence bake-off matrix/parse.yaml, and validatesstacks/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 offgenerated_ids[0][inputs["input_ids"].shape[1]:]→processor.decode(..., skip_special_tokens=True)→ the page's markdownstr(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.generatereturns prompt+continuation,_FakeProcessor.decoderenders the sliced continuation) model that, so an un-sliced Provider fails —test_offline_fake_is_faithful_to_generate_slice_decode_contractis the anchor. A dep+CUDA-gatedtest_real_glm_ocr_on_cudaoverrides 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 conservativeImage.MAX_IMAGE_PIXELSso 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", headlinelicense="MIT",license_verified=True, citedlicense_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 likeparser.lighton(C3 PASS, C5 skip, C1/C2/C4/C6 SKIPPED — the verdict still passes). - §4 bake-off + §3 stack + roster + CI.
matrix/parse.yamladdsparser.glm(pinneddevice: 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 + thedevice_skipped == {"parser.lighton", "parser.glm"}assertion are updated. CI +scripts/verify-local.shinstalllatence-parser-glm --no-depsin 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.mddocuments 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-smallembedder 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 ~ Appledrops to 0.808), so any ~0.90 threshold is safe AND useful. The rework: embedsurface + context, defaulting the threshold to the context-safe 0.90 (not the fragile bare-surface 0.83). - The mechanism — thread
ChunkRecords todisambiguate(minimal blast radius). TheDisambiguatorProtocol is widened todisambiguate(mentions, relations, chunks=())— a third, additive, defaulted carrier, so it is backward-compatible (any existing 2-arg Provider still satisfies it; noEntityMentionschema change; no NER-provider change).CascadeDisambiguatoracceptschunks=()and ignores it — its algorithmic path (and every one of its tests) stays byte-identical. The Runner's disambiguation dispatch gathers the run'sChunkRecords 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).EmbeddingDisambiguatorlocates each mention's chunk bychunk_record_id, takes a fixed window of itscontentaround the mention offset, and embedsf"{surface} {context}"; no locatable chunk → graceful bare-surface fallback (never crashes). - A drop-in, not a fork — the overridable blocking seam.
CascadeDisambiguator.disambiguateis refactored to delegate cluster-building to one overridable method,_resolve_clusters(mentions, chunks); the cascade implements it with the portedEntityResolver(ignoring chunks), andEmbeddingDisambiguatorsubclasses 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 swapsdisambiguation.cascade→disambiguation.embeddingwith 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 ≥
thresholdAND (default) the entity types are compatible (the #35 same-type guard). A similar-but-below-threshold or type-incompatible pair is recorded as a rejectedMergeDecision(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 bymax_pairs(aConfigErrornaming 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 (acontext_window=0control reproduces the over-merge — proving the fix is because of context, not a higher threshold masking it), and the real in-core deterministicembedding.hashingembedder over fullChunkRecords asserts no cluster ever spans two different real-world entities. - It EXERCISES AdapterBase for real. An
AdapterBasesubclass composingdevice(either→ cpu/cuda for a GPU embedder),batched(the surface+context texts embedded in input-order-preserving bounded batches, ADR-0033), andguard("embed")(a native embedder fault → typedProviderErrorcarrying no raw text / PII — only the provider + op + native type name). It resolves its Embedder from thelatence.providersregistry (default the in-core deterministicembedding.hashing; a learnedembedding.sentence_transformersis 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 likelatence-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), headlinelicense="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.cascadeanddisambiguation.embeddingare 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 theDisambiguationQualitythe run already computes (no reinvented scoring): the cross-surfacemerge_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 theembedderblock toembedding.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-embeddingis a default-dev-env package (CPU-viable, offline), so CI'suv syncinstalls it; CI type-checks its src+tests (the per-packagetypecheck.shloop picks it up automatically), builds its wheel, and runslatence bake-off matrix/disambiguation.yaml.scripts/validate-disambig-on-gpu.mddocuments 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 topredict_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_onenow unpacksraw_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 usestart/end/label/score;_resolve_endpointkeys head/tail by(start,end)(both present in the real relation dicts);_relation_sort_keyreadshead["start"]; offsets resolve throughOffsetIndex.resolve_spanUNCHANGED — the same path that madeentity.glinerpass 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 exposepredict_relations(text, labels, relations, threshold=..., **kwargs)returning a(entities, relations)TUPLE of the real dict shapes (head/tail sub-dicts withstart/end/text/type/entity_idx); the oldpredict_with_relationsdict fake is deleted, andgliner_relations()is upgraded from barehead:0/tail:1indices to the real span-keyed sub-dicts. A provider still callingpredict_with_relationsor doingresult.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) andtest_emitted_relations_have_real_endpoints(the provider-level guard — every emittedRelationMentionresolves to real emitted-mention record_ids with a non-empty label, so a silent drop-all-relations regression bites). gpu-sota.yamlwiring fix. Therelationsstage passedrelations: [works_for, partner_of]— but the fused Provider REQUIRES bothlabelsANDrelation_labels(a missing key is aConfigErrorat load). Corrected tolabels: [organization, person]+relation_labels: [works for, partner of], and theentitiesstage 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.mddocuments the turnkey pod re-validation (a realpredict_relationsrun 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:
redacthad nopii_labels.redaction.gliner_piirequires a non-emptypii_labelslist; the stage carried only{device: cuda}→ValueErrorat 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 arelations(relation.gliner_relex) Stage. Butrelation.gliner_relexis a Fused Provider (FusedEntityRelationExtractor— joint NER+RE in one pass viaextract_fused); it does NOT satisfy the standaloneRelationExtractor.relateseam, so the run raisedContractError: 'relation.gliner_relex' does not satisfy the RelationExtractor capability. Pairing it with a separateentity.glinerStage was also double-NER. Fix (the rule): extraction on the blessed SOTA path is ONEfused_entity_relationStage (extract,relation.gliner_relex,threshold: 0.3); the Runner routes itsFusedExtraction(mentions + relations) to BOTH downstream consumers, no separate Entity Stage. Downstreamdepends_onrewired:profiling: [parse, extract],disambiguate: [extract]. The KBentity_typestays lower-caseorganization(the case-sensitive KB linker, #129). The fused-provider stage rule, stated once: a Fused Provider belongs under afused_entity_relationStage, never arelation_extraction(orentity_extraction) Stage; a stack that wants standalone learned NER wiresentity.gliner+ a non-fused RE provider instead. -
stacks/gpu-sota-pod.yaml— the self-contained pod variant. The blessedgpu-sota.yamlkeepsembedding.endpointfor the RAG export as the served story (needs a running host). The newgpu-sota-pod.yamlis identical but swaps that for the in-processembedding.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 validateskips the cuda Stages, so the Runner's dispatch-timeisinstance(provider, <Protocol>)check (the one that raisesContractErrorfor the fused/standalone mismatch) never runs in CI.latence_core.stacks.capability_guard.check_stack_capabilitiescloses 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'sCapabilityKinddeclares — the SAME capability→Protocol mapping the Runner enforces, declared ONCE so the two cannot drift. Aruntime_checkablemethod-only Protocol usesissubclass;Embedder(adimensionproperty) falls back to a structural attribute check — either way it is class-only, no instantiation, CPU-runnable.test_stack_capability_guard.pyasserts the fixedgpu-sota.yaml/gpu-sota-pod.yamlhave zero findings AND that the guard bites on the exact miswiring (a fused Provider under arelation_extractionStage 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.yamlon 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.shvalidatesstacks/gpu-sota-pod.yamlalongside 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 theglinerlibrary'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:personAlice Johnson [15,28) 0.997,email[48,65),phone number[69,84) — offsets document-char half-open, exact._detect_spansflattens the per-label dict → onePIISpanper row (pii_type=label,char_start=start,char_end=end,confidence=confidence,detector="gliner2:<label>"), resolves overlaps + applies the policy allow-list exactly asredaction.gliner_piidoes (deterministic order: by start, then longest, then label; bounds-safetext[start:end]slice). - Anti-false-green (the PaddleOCR/relex lesson — honoured). The offline stub (
_StubGLiNER2inlatence_core/conformance/stub.py+ the unit fake) is faithful to the REALextract_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 (thegliner-library shape) recovers ZERO spans —test_flat_list_misparse_fails_anchorproves both that the stub returns the nested shape (iteratingout["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
AdapterBasesubclass:compute="either"(mdeberta runs on CPU or GPU, routed by the device seam — never skipped on a CPU host, never a fabricated device), andguard("extract_entities")/guard("load_model")map a nativegliner2/torch fault → a typedProviderErrorcarrying no record content / PII (only the providerredaction.gliner2+ op + native type name —test_native_fault_becomes_typed_error_no_pii_leakasserts the input text never appears in the message: C2/C6). Thegliner2import is deferred to first use (ADR-0016) so module import for discovery needs no torch; a missing dep raises a clearpip install latence-pii-gliner2ImportError only at run. - Evidence-bearing license (H-B1), fully verified in-slice (2026-07-09).
license_weights="Apache-2.0"(HF model cardfastino/GLiNER2-Guardrails-PII-Multi, not gated / freely downloadable),license_code="Apache-2.0"(thegliner2library —github.com/fastino-ai/GLiNER2LICENSE + README Apache-2.0 badge + PyPIOSI Approved :: Apache Software Licenseclassifier; verified during the build, latest release 1.3.2), headlinelicense="Apache-2.0",license_verified=True,license_sourcecites 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.yamladdsredaction.gliner2(pinneddevice: 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 withgliner2installed. - Roster + CI. The registry roster is now 38 (was 37); the roster/count assertions + the E2E conformance roll-up + the stub-scoped set (
redaction.gliner2added) are updated. CI +scripts/verify-local.shinstalllatence-pii-gliner2 --no-depsin the heavy-provider step (its tests inject a faithful fakegliner2module), type-check its src+tests, build its wheel, and runlatence bake-off matrix/redaction.yaml.scripts/validate-pii-gliner2-on-gpu.mddocuments 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.renderNEVER imports or depends onfitz/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 rendererparser.lightonuses); 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.renderrenders 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-pagepngParserInputthrough the sameParser.parseseam — no private method reached into, no seam forked. The office text-fallback path needs no OCR. Per-page markdown is assembled onto the exact-offsetPageMap.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_ERRORrecord — 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_MEGAPIXELSclamp +Image.MAX_IMAGE_PIXELSdiscipline); 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 → typedProviderErrorcarrying 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 hostparser.renderstill 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 fakepypdfium2/PILso the REAL render code path runs,install_render_offlineinconformance/stub.py, + a stub OCR wired via the Provider'socr_registryseam): 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.rendergets a per-Provider case (_render_case), not device-skipped, not endpoint-stub-scoped. - §4 bake-off + §3 stack.
matrix/parse.yamladdsparser.render(pinneddevice: cuda, OCR sub-providerparser.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 newstacks/universal-parse.yaml— the any-file stack — swaps the Parse Stage forparser.renderover 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.renderis neither device-skipped nor stub-scoped — it genuinely runs C1–C6 on CPU). CI +scripts/verify-local.shinstalllatence-parser-render --no-depsin the heavy-provider step (its tests use a faithful in-memory fake renderer + a stub OCR), type-check its src+tests, build its wheel, runlatence bake-off matrix/parse.yaml, and validatestacks/universal-parse.yaml.scripts/validate-universal-parser-on-gpu.mddocuments 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 Stages — label_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 singleasyncio.rundrives anasyncio.gather(..., return_exceptions=True)over the jobs, each bounded by anasyncio.Semaphore(max_concurrency)(config, default 8;1= today's exact sequential behaviour). One event loop per Stage-run. Theopenaiclient is now theAsyncOpenAIasync 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 newrequest_timeoutconfig 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 ofcall_with_retry, the same portedretry_utilsalgorithm — exponential backoff + full jitter, 429/5xx/transport retryable, other-4xx terminal, cappedmax_retries(default 4), the final error surfaced after the last attempt — but itawaits the coroutine call and an injected asyncsleep. Retries fold intoendpoint_retriesexactly 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 injectedrng+ no-op async sleep (mirrors the pre-W6sleep=lambda _d: None). - ALL W4 safety nets preserved, applied PER concurrent result. For
label_inducer.llm: structuredjson_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 (itsinduced_labelsstaysNone→ the extractors use config labels;induction_fallbacksbumped, a typed no-content log) —return_exceptions=Truemeans 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. Forrelation.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_jsoncheck. - Anti-false-green — concurrency is REAL and BOUNDED. The faithful async mock (
_fake_openai.pyfor the inducer; an in-testAsyncOpenAIfake for RE) returns the real chat-completions shape with a per-callasyncio.sleepdelay. A timing test asserts the wall-clock for N items at concurrency C is< sequential(≈ceil(N/C) x delay, notN x delay), and the RE fake recordsmax_in_flightto assert it is> 1(genuinely concurrent) and<= max_concurrency(the semaphore bound).max_concurrency=1is separately asserted to serialize (wall-clock>= (N-1) x delay). A retryable-then-success mock proves the async backoff +endpoint_retriestally; a per-item malformed/terminal mock proves per-item fail-open without aborting the batch. - No roster change (same providers, faster).
label_inducer.llmandrelation.llmkeep theirProviderProfiles and entry-points; nothing is added to or removed from the registry — the roster stays 40.mypy --strict(src + tests) /ruff/ pytest green acrosslatence-core,latence-schema-inducer,latence-relation-llm; the full conformance + stack-validate + bake-off suites stay green underscripts/verify-local.sh. The real re-timed lbbwinducenumbers (~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 pass — redact (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.glinerandredaction.gliner_pii→model.batch_predict_entities(texts, labels, threshold=…)(returnslist[list[dict]], resultifortexts[i]) instead of a per-textpredict_entitiesloop — a documented real GLiNER method.relation.gliner_relex(fused) → the batchedmodel.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-textpredict_relationsis the thin wrapper it kept for the API-shape teeth).redaction.gliner2→model.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 throughAdapterBase.batched+ ST's nativeencode(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) andredaction.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 bitrelation.gliner_relexonce (thepredict_with_relationsscar, §"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 providerhasattr-guards its batched call and raises a clear, actionable error naming the batched API AND the W1-verified single-text fallback (predict_relationsfor relex) rather than a bareAttributeError— covered offline bytest_missing_inference_api_raises_clear_error_naming_the_fallback(relex) andtest_missing_batch_extract_entities_raises_clear_error(gliner2); (2)scripts/validate-encoder-batching-on-gpu.mdGate 0 introspects the REAL checkpoints and asserts every batched entry point EXISTS with the pinned shape (a two-text smoke forinferenceasserting the parallel-list return) before any timing — mirroringvalidate-relex-on-gpu.mdstep 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 mostbatch_sizeitems, call the model once, map back, release — they NEVER accumulate the whole corpus, honoring the streaming Runner's bounded-batch discipline.ProviderProfile.memory_mbstays honest for the batched footprint (a batch ofbatch_sizetexts, not the corpus).batch_sizeis validated>= int(1)one-right-way via the sharedlatence_core.providers.adapter.validate_batch_size(whichAdapterBase.batch_size()now delegates to), so a non-positive/non-int knob is an operatorConfigError, never a silent fallback. Native fault → typed error, no leak — unchanged (gliner2 keeps itsguard; the op label is nowbatch_extract_entities). - Anti-false-green tests (the equivalence crux + the call-count teeth). For EACH batched Provider a
test_batched_equals_unbatched_record_for_recordfeeds 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. Atest_model_called_ceil_n_over_batch_size_timesasserts the fake's batched-call counter is exactlyceil(N/batch_size), not N (batching is genuinely happening). An e2e (test_gliner_e2e_runner.py) runsentity.glinerthrough theLocalRunnerSource→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) +pytestgreen on the five touched packages (latence-core,latence-ner-gliner,latence-relation-gliner,latence-pii-gliner,latence-pii-gliner2);scripts/verify-local.shgreen.scripts/validate-encoder-batching-on-gpu.mddocuments the turnkey pod re-time of lbbwextract+redactat 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_pretrained ⇒ fp32 (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 twofrom_pretrainedkwargs:torch_dtype(default bf16 on cuda / fp32 on cpu;config["dtype"]pins it) andattn_implementation(defaultsdpa;flash_attention_2only when theflash_attnpackage is importable AND config permits, viaimportlib.util.find_specso the probe never imports it). The dtype is resolved as a name string without importing torch; a realtorch.dtypeis 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 intofrom_pretrainedand retries once onsdpaif a FA2 load raises (for libs that accept the kwargs — gliner2, the OCR VLMs, ST viamodel_kwargs);load_then_cast(loader, id, config, device)loads PLAIN thenmodel.to(bf16)on cuda (for the plaingliner0.2.27 library, which rejectstorch_dtype/attn_implementation).maybe_compile(model, config)istorch.compile(mode="reduce-overhead")whenconfig["compile"]is truthy, guarded so any failure/missing-torch returns the uncompiled model. The three plain-GLiNER providers (notAdapterBasesubclasses) call the module functions directly, each resolving its owndeviceconfig throughselect_deviceat construction and passing that resolved device intoload_then_cast— thenplace_on_device(model, device)(#191: the plainglinerlibrary does not auto-place;from_pretrainedmap-locations to CPU, so adevice=Nonecaller 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 viaload_then_cast: plainGLiNER.from_pretrained(id)then.to(bf16)on cuda (measured 1.36×, byte-identical entities). This is a correctness fix:gliner0.2.27'sfrom_pretrainedrejectstorch_dtype/attn_implementation(pod-provenTypeError), and its encoder has no FA2 / FlashDeBERTa — so those are neither passed nor claimed.redaction.gliner2(gliner2 lib) keepsfrom_pretrained(torch_dtype=…, attn_implementation=…)(loads fine) — but the honest win is bf16; mdeberta's disentangled attention means FA2 is not a speedup (attn_implementationpassed only where the lib accepts it).parser.lighton/parser.glm(OCR VLMs) load viaload_with_fa2_fallback(real FA2 support).embedding.sentence_transformers(ModernBERT) threads the perf dict through STmodel_kwargs— the one encoder where FA2 is genuinely real.disambiguation.embeddinggets 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 ONEgenerate():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 widthL) with a per-itemattention_mask; one greedy (do_sample=False)generate; each item's continuation is sliced atoutput_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_tokensdefault → 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). Newmax_longest_dimdefault → 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/Nonedisables. 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_pretrainedfor the 3 plain-gliner providers and passed verify-local — but ONLY because the offline fakeGLiNER.from_pretrainedswallowed the kwargs while the REALgliner0.2.27 API rejects them (TypeError), crashing all three on real weights (the classic fake-mirrors-a-nonexistent-API trap). Fix: the offline fakeGLiNER.from_pretrainednow faithfully raisesTypeErrorontorch_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 tofrom_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 whenflash_attnimportable (monkeypatched present/absent);load_perf_kwargsshape + real-torch.dtype-when-present;load_then_castloads-plain-then-.to(bf16)-on-cuda / no-cast-on-cpu / skips-cast-without-torch / never-passes-perf-kwargs-to-loader;maybe_compileno-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 MODELfrom_pretrainedbypasses the perf seam (either helper), with a per-gliner-provider check that it routes throughload_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_timesassertsceil(N/ocr_batch_size)generate calls. A downscale unit test proves a 1200×2339 image → longest-side 1540 aspect-preserved, untouched at0/Noneor 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) +pytestgreen;scripts/verify-local.shgreen. The real numbers are recorded inPERF-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=1024truncated ~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.gliner2optimized deployment (the redact fix). Newquantize(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-lightload_gliner2_optimizedseam (inlatence_core/providers/perf.py): both knobs resolve True on cuda / False on cpu (or the config override) viaresolve_gliner2_knob, and the optimized load is guarded — ANY quantize/compile fault (torch/CUDA/compile) degrades to a plainfrom_pretrained(id)load (throughput only, never a crash). An invalidquantize/compilevalue is aConfigErrorsurfaced at construction (fail-fast, likebatch_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 forrelation.gliner_relex: the SAMEFusedEntityRelationExtractorseam (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}]}}andextract_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→originaloffset_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/compileknobs (default on cuda) via the same guardedload_gliner2_optimizedseam. It also exposesextract(the NER half) for a plain Entity Stage. -
Default swap; relex kept-but-superseded.
fused_entity_relation.gliner2is 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 afused_entity_relationStage, neverrelation_extraction) is unchanged and now satisfied by the gliner2 Provider. -
Anti-false-green (the paddle/relex lesson — honoured). The offline fake
GLiNER2mirrors the REAL gliner2 return shapes: the per-labelextract_entitiesdict, the per-relation-typeextract_relationsdict (head/tail sub-dicts carryingstart/end), spans wheninclude_spans=True, and nativequantize/compilekwargs onfrom_pretrained. Teeth:test_flat_list_misparse_fails_anchor(a provider mis-parsing either nested dict as a flat list — thegliner-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), andtest_native_fault_becomes_typed_error_no_pii_leak(a native fault echoing the input becomes a typedProviderErrorcarrying no content). The stub-scoped conformance case (_relation_case(gliner=True, backend="gliner2")) drives the sameextract_fusedcontract through the faithfulgliner2stub (install_gliner2now seeds relations too). -
License (ADR-0012 / research-diligence, verified 2026-07-10 — weights AND code, separately).
fastino/gliner2-multi-v1checkpoint: Apache-2.0 (weights, HF model card "License: apache-2.0", NOT gated / freely downloadable).gliner2library: Apache-2.0 (code, github.com/fastino-ai/GLiNER2 LICENSE + PyPI classifier). Both permissive →license_verified=Truein 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.shgreen. The pod validation is the maintainer's (scripts/validate-gliner2-unified-on-gpu.md): realfastino/gliner2-multi-v1NER+RE offsets round-trip on real German chunks, andredaction.gliner2PII 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
peftruntime dep. gliner2's relation inference engine (gliner2.inference.engine→gliner2.training.trainer) importspeft, which thegliner2[local]extra does NOT declare — real load failsModuleNotFoundError: No module named 'peft'. Addedpeft>=0.10to BOTHlatence-extract-gliner2andlatence-pii-gliner2dependencies (the engine import is on both providers' load path).peftis Apache-2.0 (github.com/huggingface/peft, verified 2026-07-10); recorded in THIRD-PARTY-LICENSES + the pod script. -
compile=Truecrashes at INFERENCE, not load — the load-time guard missed it.torch.compilecompiles 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 atorch._dynamo BackendCompilerFailedraised INSIDEextract_entities/extract_relations/batch_extract_entities— which the provider was surfacing as aProviderError(a CRASH, not the spec's graceful degradation). Fix — the inference-time compile guard: a new dep-light, torch-free predicateis_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 withcompileforced off (quantizekept — 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 aProviderError.quantize(fp16) is unaffected and stays on. Tests: a fake model whose compiled forward raisesBackendCompilerFailedon first call → both providers degrade to uncompiled and still return correct records (test_inference_time_compile_fault_degrades_to_uncompiledfor each) + the once-across-many-chunks latch + theis_compile_backend_failureunit tests (name / module-prefix / wrapped-cause / non-compile-error-false). The W8 AST perf-guard covers theload_gliner2_optimizedseam;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=Truerecompile-thrash (the primary hang). gliner2 compiles withtorch.compile(dynamic=True), which recompiles per distinct chunk-shape; on a real varied-length corpus that blows pasttorch._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 —compilenow 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 sharedresolve_gliner2_knobdefault (True-on-cuda) is unchanged — only this provider's default flips — soredaction.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 withOMP_NUM_THREADS=8exported. 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_THREADSdoes NOT bound torch once it is imported — torch owns its own intra-op pool (defaultos.cpu_count()), and gliner2's internal batching then oversubscribes it to ~cpu_count threads on a many-core host. The provider now caps it viatorch.set_num_threads(min(config, os.cpu_count()))at load (configintra_op_threads, default 8,<= 0disables). 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 themodel/model.modelwrapper 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_fusednow streams the chunk iterable in bounded batches ofbatch_size(clamped to_MAX_BATCH_SIZE=64so a large configured value can't run the stage silently for minutes) and logs cumulative progress (chunks / mentions / relations) after each batch, recorded onself._progressfor forensics/tests. Each chunk is still ONE bounded≤ max_lenforward (this provider does not pack multiple chunks into a native call, sobatch_sizeshapes the progress cadence, not a padded tensor); output is record-for-record identical for any positivebatch_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(forcesLATENCE_CUDA=1; assertscompile=Falseis threaded tofrom_pretrainedwhilequantize=Truestill is) +test_compile_opt_in_still_enables_on_cuda;test_intra_op_threads_capped_at_load(a faketorchrecords theset_num_threadsvalue — 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 coverresolve_intra_op_threads/cap_intra_op_threads/ensure_gliner2_on_cuda(apply/none/torch-free-guarded/bare-model).scripts/verify-local.shGREEN.
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-lightlatence_core/providers/perf.py) drives both levers torch exposes: it setsPYTORCH_JIT=0(read by torch at import, so a not-yet-imported torch comes up JIT-free and every module-import-time@torch.jit.scriptis a no-op) and callstorch.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 realScriptFunctions at module import time. Neither lever can un-script them afterwards:PYTORCH_JITis read by torch only at torch import, andtorch.jit._state.disable()affects only futurescript()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 thedisambiguation.glinkerbackend calls the one sharedneutralize_torch_jit()before its own backbone import — the same function, never a re-implementation (no Provider may touchPYTORCH_JITitself). 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 bytest_perf_fp32_regression_guard.py::test_mdeberta_providers_neutralize_the_jit_before_importing_the_backbone(AST: the earliestneutralize_torch_jit(...)must precede the earliest backbone import in the same function) andtest_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 anyneutralize_torch_jit()call) can run — lived in noFunctionDef, produced zero offenders and passed every #190 guard clean; module scope (including class bodies, excluding never-evaluatedif 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 bytest_perf_jit_import_ordering.py, whose fakegliner/gliner2scripts 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
torchmirrors the REAL switch semantics —torch.jit.scriptis a pass-through only whiletorch.jit._state._enabled.enabledis False, otherwise it returns a ScriptFunction that raises the exactlibnvrtc-builtins.so.13.0error 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_crashkeep 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_hostdrives the real Provider end-to-end over a fake gliner2 whose encoder scripts its relative-position helper atfrom_pretrainedand calls it in every forward;test_output_is_byte_identical_to_the_jit_free_pathproves the records are identical field-for-field to an encoder that never scripted;test_the_jit_is_actually_neutralized_not_merely_configuredasserts 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) andtest_no_provider_hand_rolls_the_pytorch_jit_workaround(thePYTORCH_JITstring 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, whoseGLinkerBackend._init_executorimportsglinker) had zero JIT coverage. The parametrization is now DISCOVERED (_discover_deberta_backed_sources()globspackages/*/srcfor backbone importers), the routing subset is derived from it,test_every_in_tree_deberta_backed_source_is_scanned_by_the_jit_ordering_guardfails if any importer escapes the scan, anddisambiguation.glinkeradditionally has behavioural import-ordering coverage intest_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.md — never 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 half —tune.report(theTuneReport/ProviderTuning/RejectedKnob/KnobSettingmodels - the signed-style Markdown renderer + the
tuned/<device>.yamlload/dump),tune.fingerprint(theDeviceFingerprint),tune.sweep(the per-Provider knob grid),tune.overlay(the runner-side apply), andtune.tuner(the sweep + gate + pick) — with no runner/stage imports, so a bareimport latence_corestays thin (ADR-0016). The measuring half is the realRegistrySampleProbe(tune.realprobe), reached only on a rig. The two are bridged by a narrow one-methodSampleProbeseam (tune.probe):probe(provider, setting, sample_size, repeats) -> ProbeOutcome(either anerror, 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 theLocalRunner, 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 sharedlatence_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, dynamoUnsupported, …), defining-module prefix (torch._dynamo/torch._inductor/torch.fx/torch._functorch), or a telltale message — so a compile crash WRAPPED in a typedProviderErroris 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 (acompile: truethat crashes NEVER wins). - The device fingerprint + the overlay guard (prevents cross-GPU misapplication). Every
tuned/<device>.yamlrecords aDeviceFingerprint— 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 honestNone). Itskeyis 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 winningcompile: truecould crash).LATENCE_DEVICEpins 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/Disambiguationconfig.embedderwhose 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.pydrives 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 aBackendCompilerFailedinProbeOutcome.error, exactly how the real probe surfaces an inference-time fault, asserted rejected + namedcompile_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 CLIrun --tunedmatch/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) +pytestgreen;scripts/verify-local.shgreen.latence tuneandlatence run --tunedare new CLI surfaces; the real per-device winners + the real rejected-compile case are the maintainer's to fold into a signed tune report fromscripts/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 (label→type). TheInducedLabelsrecordSCHEMA_VERSIONis untouched. The nodes Parquet already carried a distinctcanonical_namecolumn, and the edges Parquetlabelcolumn (relation type) is unaffected. - No consumer left broken. No code or test read the node Parquet
labelcolumn (the demo viewer reads the in-memoryGraphNode.label;test_e2e_graph/test_graph_exportreadcanonical_namefor nodes and the edgeslabel). New tests (test_nodes_parquet_entity_type_column_named_type,test_node_type_matches_across_parquet_graphml_ttl) assert thetypecolumn 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):
-
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 amax_lenconfig (default 768, the mdeberta window) resolved vialatence_core.providers.perf.resolve_max_len. The gliner2 providers thread it as the nativemax_lenkwarg ofextract_entities/extract_relations/batch_extract_entities. The gliner-library providers apply it asmodel.config.max_len(the library's truncation lever) at load, before anytorch.compilewrap, via the guardedapply_gliner_max_len(a model/config without a settablemax_lenis left unchanged — never a crash). So the model never processes past its window; content pastmax_lenis a documented boundary (not extracted), not a silent surprise, and it is configurable per checkpoint. -
Gliner-safe chunk caps + the tokenizer-mismatch caveat (documented).
chunk.markdown'smax_tokensis 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) setchunk.markdownmax_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 providermax_lenguard (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.