Skip to content

Provider base & profile

The optional AdapterBase convenience base and the frozen ProviderProfile descriptor — the two halves of the Provider seam (ADR-0036). See Authoring a Provider for the guide.

AdapterBase

adapter

AdapterBase — the thin, optional base every provider adapter reuses (ADR-0036 §1).

A Provider is an adapter over a native library (CONTEXT Provider); the Capability Protocol (ADR-0004) stays the stable I/O contract. Every adapter repeats the same cross-cutting glue — pick a device, batch its inputs, turn a native library exception into a typed :class:~latence_core.errors.LatenceError, and carry :class:~latence_core.contracts.Provenance / :class:~latence_core.contracts.Classification + character offsets through the native call. P3-F2 factors that glue into one optional :class:AdapterBase so a Wave-1 adapter is small and consistent, and those concerns are done one right way instead of five subtly-different ways.

Non-negotiable framing (issue P3-F2):

  • Optional, not a new seam. :class:AdapterBase is a convenience base. A Provider may still satisfy a Capability Protocol structurally without it — the shipping in-core reference Providers do, and are NOT force-migrated. No change to the Capability protocols; ADR-0036 rejected a second protocol layer.
  • Dep-light (ADR-0016). This lives in latence-core and pulls in no heavy deps (no torch, no model libs). It only orchestrates + maps; the adapter subclass owns its native dep. Device placement stays the adapter's job (it moves its own model, ADR-0003); :class:AdapterBase only resolves the :class:~latence_core.providers.device.DeviceDecision and hands it over.

The four reusable pieces (compose them; the base does not force a god-class shape):

  1. :meth:AdapterBase.device — resolve config.device (auto|cpu|cuda) against the subclass's declared :class:~latence_core.providers.profile.ProviderProfile via P3-F1's :func:~latence_core.providers.device.select_device, exposing the chosen device (or the skip decision) to the subclass. A compute="gpu" adapter on a CPU-only host sees the skip decision, never a fabricated device.
  2. :meth:AdapterBase.batched — group an input iterable into config.batch_size chunks and call the subclass's _run_batch when the profile is batch-capable, degrading to one-item calls otherwise. Deterministic input-order output regardless of batching, and no whole-corpus buffering (the streaming Runner's friend, ADR-0033).
  3. :meth:AdapterBase.guard — a context manager that catches an exception raised inside the native call and re-raises it as the right :class:~latence_core.errors.LatenceError subtype (a genuine provider/runtime failure → :class:~latence_core.errors.ProviderError; an operator bad-config → :class:~latence_core.errors.ConfigError), tagged with the provider name + op and carrying no record content / PII. A framework :class:~latence_core.errors.LatenceError passes through unwrapped.
  4. :meth:AdapterBase.carry_provenance + :meth:AdapterBase.rebase_offsets + :meth:AdapterBase.resolve_pages — map a native library's output back onto the input's Provenance/Classification and character offsets, reusing the existing :class:~latence_core.offsetmap.OffsetIndex seam and the document-scoped :class:~latence_core.page_index.PageIndexResolver seam (ADR-0031) — never a weaker hand-rolled offset mapper, and never a throwaway per-chunk page index — so the "offsets/page-provenance intact" invariant is the default.

ProvenanceCarrier

Bases: Protocol

The structural source :meth:AdapterBase.carry_provenance reads: anything with a frozen :class:~latence_core.contracts.Provenance + :class:~latence_core.contracts.Classification.

P3-F2's helper originally typed its source as a :class:~latence_core.contracts.Record, but the FIRST real adapter over it (H-A1, the pdfplumber Parser) exposed a genuine misfit: a Parser's source is a :class:~latence_core.contracts.ParserInput (a raw document, ADR-0019), which carries Provenance + Classification but is not a Record subclass — so a real Parser could not reuse the helper for a whole-document carry without a hand-rolled cast (the exact "weaker mapper" the audit forbids). Widening the source to this structural Protocol is the minimal, backward-compatible fix: every Record satisfies it (it has both attributes), and so does a ParserInput, so both the sub-document carriers (mentions/chunks, Records) and the whole-document Parser carry through the same base helper. The helper only ever reads .provenance (frozen, model_copy-ed) and .classification (returned unchanged), so the Protocol names exactly those and nothing more.

AdapterBase

AdapterBase(config: dict[str, Any] | None = None)

Optional base carrying the cross-cutting glue every provider adapter repeats (ADR-0036 §1).

A subclass declares its :class:~latence_core.providers.profile.ProviderProfile (a profile classmethod or a PROFILE ClassVar, the P3-F1 forms) and receives the Stage config dict at construction. It then composes the four helpers:

  • :meth:device for the resolved :class:~latence_core.providers.device.DeviceDecision,
  • :meth:batched for deterministic, streaming-friendly batching,
  • :meth:guard for native-exception → typed-taxonomy mapping,
  • :meth:carry_provenance / :meth:rebase_offsets for Provenance/offset carry.

It is not a Capability — it names no parse/extract/… method and imposes no protocol. A subclass still implements whichever Capability method it fulfils, so isinstance(provider, Parser) (etc.) is satisfied structurally exactly as for a Provider that does not use this base. The base is convenience, never a required layer.

Source code in packages/latence-core/src/latence_core/providers/adapter.py
def __init__(self, config: dict[str, Any] | None = None) -> None:
    self._config: dict[str, Any] = dict(config or {})
    # Resolve the device once at construction against the declared profile: the decision
    # is a cheap, pure routing over the profile (no model import, no torch), and a subclass
    # that must move its model reads ``self.device`` right after ``super().__init__``.
    self._device_decision = self._resolve_device()

config property

config: dict[str, Any]

The Stage config dict the Provider was constructed with (a defensive copy).

provider_name property

provider_name: str

A stable, human-readable provider name for error tags (never carries content).

Defaults to the subclass's class name — used only to label a mapped :class:~latence_core.errors.LatenceError (provider + op), never to carry any record content or PII. A subclass may override to surface its registered entry-point name.

device property

device: DeviceDecision

The resolved :class:~latence_core.providers.device.DeviceDecision for this adapter.

The subclass reads self.device.device ("cpu"/"cuda"/None) to place its model, and self.device.skipped to know it was skipped-with-flag on a host that cannot run it. Device placement stays the adapter's job (ADR-0003) — this only resolves the decision.

declared_profile classmethod

declared_profile() -> ProviderProfile | None

This adapter's declared :class:ProviderProfile, read via :func:profile_of.

Reads whichever declaration form the subclass uses (a profile classmethod or a PROFILE ClassVar), reusing the P3-F1 reader — never fabricating one. A subclass that declares none returns None (the device seam then follows the request, treating it as compute="either"). A malformed declaration surfaces as TypeError from :func:profile_of — the same fail-loud contract the Runner now honors (issue P3-F2 §2).

Source code in packages/latence-core/src/latence_core/providers/adapter.py
@classmethod
def declared_profile(cls) -> ProviderProfile | None:
    """This adapter's declared :class:`ProviderProfile`, read via :func:`profile_of`.

    Reads whichever declaration form the subclass uses (a ``profile`` classmethod or a
    ``PROFILE`` ClassVar), reusing the P3-F1 reader — never fabricating one. A subclass
    that declares none returns ``None`` (the device seam then follows the request, treating
    it as ``compute="either"``). A **malformed** declaration surfaces as ``TypeError`` from
    :func:`profile_of` — the same fail-loud contract the Runner now honors (issue P3-F2 §2).
    """
    return profile_of(cls)

load_perf_kwargs

load_perf_kwargs() -> dict[str, Any]

The load-time perf kwargs (dtype + attn) to spread into from_pretrained (W8).

Resolves the load dtype (bf16 on GPU / fp32 on CPU by default, or config["dtype"] override) and the attention implementation (sdpa default, flash_attention_2 only when the flash_attn package is importable and the config permits it) against this adapter's resolved device (:attr:device), returning the two kwargs HF from_pretrained reads::

 model = Model.from_pretrained(model_id, **self.load_perf_kwargs())

Applied once per load so a learned Provider never loads bare fp32 (the ~2× miss). An invalid dtype config is an operator :class:~latence_core.errors.ConfigError naming the provider never a silent wrong dtype. The FA2 load can still raise on a model that does not support it — the adapter wraps the load so that surfaces as a graceful sdpa retry, never a crash.

Source code in packages/latence-core/src/latence_core/providers/adapter.py
def load_perf_kwargs(self) -> dict[str, Any]:
    """The load-time perf kwargs (dtype + attn) to spread into ``from_pretrained`` (W8).

    Resolves the load **dtype** (bf16 on GPU / fp32 on CPU by default, or ``config["dtype"]``
    override) and the **attention implementation** (``sdpa`` default, ``flash_attention_2`` only
    when the ``flash_attn`` package is importable and the config permits it) against this
    adapter's resolved device (:attr:`device`), returning the two kwargs HF ``from_pretrained``
    reads::

        model = Model.from_pretrained(model_id, **self.load_perf_kwargs())

    Applied once per load so a learned Provider never loads bare fp32 (the ~2× miss). An invalid
   ``dtype`` config is an operator :class:`~latence_core.errors.ConfigError` naming the provider
    never a silent wrong dtype. The FA2 *load* can still raise on a model that does not support
    it — the adapter wraps the load so that surfaces as a graceful sdpa retry, never a crash.
    """
    try:
        return _resolve_perf_kwargs(self.device.device, self._config)
    except ValueError as exc:
        msg = f"{self.provider_name}: {exc}"
        raise ConfigError(msg) from exc

maybe_compile

maybe_compile(model: _Out) -> _Out

torch.compile the model when config["compile"] is truthy — guarded (W8).

Off by default (returns the model unchanged); on, wraps the forward with torch.compile(mode="reduce-overhead") behind a broad guard so any compile/warmup failure logs and returns the uncompiled model — a bad compile costs only throughput, never correctness or a crash. The compiled module is a drop-in for the eager one (same outputs).

Source code in packages/latence-core/src/latence_core/providers/adapter.py
def maybe_compile(self, model: _Out) -> _Out:
    """``torch.compile`` the model when ``config["compile"]`` is truthy — guarded (W8).

    Off by default (returns the model unchanged); on, wraps the forward with
    ``torch.compile(mode="reduce-overhead")`` behind a broad guard so any compile/warmup failure
    logs and returns the **uncompiled** model — a bad compile costs only throughput, never
    correctness or a crash. The compiled module is a drop-in for the eager one (same outputs).
    """
    compiled = _maybe_compile(model, self._config, provider_name=self.provider_name)
    return cast("_Out", compiled)

batch_size

batch_size() -> int

The validated batch size (config.batch_size, default :data:DEFAULT_BATCH_SIZE).

Must be >= 1 — a non-positive or non-int batch_size is an operator :class:~latence_core.errors.ConfigError (a batch of zero would never make progress).

Source code in packages/latence-core/src/latence_core/providers/adapter.py
def batch_size(self) -> int:
    """The validated batch size (``config.batch_size``, default :data:`DEFAULT_BATCH_SIZE`).

    Must be ``>= 1`` — a non-positive or non-int ``batch_size`` is an operator
    :class:`~latence_core.errors.ConfigError` (a batch of zero would never make progress).
    """
    raw = self._config.get("batch_size", DEFAULT_BATCH_SIZE)
    return validate_batch_size(raw, provider_name=self.provider_name)

batched

batched(
    items: Iterable[_In],
    run_batch: Callable[[list[_In]], Iterable[_Out]],
) -> Iterator[_Out]

Group items and call run_batch per group, yielding outputs in input order.

The batching helper the streaming Runner (ADR-0033) is a friend of:

  • When the declared profile is batch-capable (profile.batch is True), items are grouped into :meth:batch_size chunks and run_batch is called once per chunk.
  • When it is not batch-capable (or no profile is declared), it degrades to one-item batches — run_batch is called with a single-element list at a time — so a non-batch adapter reuses the exact same code path.

Ordering is deterministic: outputs are yielded in the order run_batch returns them, which for a correct adapter mirrors input order (a batch preserves its members' order; the batches themselves are consumed in input order). The input iterable is consumed lazily one batch at a time — the whole corpus is never buffered, so a streamed input stays streamed (memory bound O(batch_size), not O(corpus)).

run_batch maps a list of inputs to an iterable of outputs; a subclass wraps its native call there (typically inside :meth:guard). This helper adds no error mapping of its own — the subclass owns that so the op label is precise.

Source code in packages/latence-core/src/latence_core/providers/adapter.py
def batched(
    self,
    items: Iterable[_In],
    run_batch: Callable[[list[_In]], Iterable[_Out]],
) -> Iterator[_Out]:
    """Group ``items`` and call ``run_batch`` per group, yielding outputs in **input order**.

    The batching helper the streaming Runner (ADR-0033) is a friend of:

    * When the declared profile is **batch-capable** (``profile.batch is True``), ``items``
      are grouped into :meth:`batch_size` chunks and ``run_batch`` is called once per chunk.
    * When it is **not** batch-capable (or no profile is declared), it degrades to
      one-item batches — ``run_batch`` is called with a single-element list at a time — so a
      non-batch adapter reuses the exact same code path.

    Ordering is **deterministic**: outputs are yielded in the order ``run_batch`` returns
    them, which for a correct adapter mirrors input order (a batch preserves its members'
    order; the batches themselves are consumed in input order). The input iterable is
    consumed **lazily one batch at a time** — the whole corpus is never buffered, so a
    streamed input stays streamed (memory bound O(batch_size), not O(corpus)).

    ``run_batch`` maps a list of inputs to an iterable of outputs; a subclass wraps its
    native call there (typically inside :meth:`guard`). This helper adds no error mapping of
    its own — the subclass owns that so the op label is precise.
    """
    size = self.batch_size() if self._is_batch_capable() else 1
    batch: list[_In] = []
    for item in items:
        batch.append(item)
        if len(batch) >= size:
            yield from run_batch(batch)
            batch = []
    if batch:
        yield from run_batch(batch)

guard

guard(op: str) -> Iterator[None]

Map an exception raised inside the with block to the typed taxonomy (ADR-0034).

Wrap the native library call so a native exception becomes the right :class:~latence_core.errors.LatenceError, tagged with the provider name + op and carrying no record content / PII (only the provider, the op, and the native exception's type name — never its message, which could echo an input):

  • a native exception whose type is in :attr:config_exceptions → a :class:~latence_core.errors.ConfigError (an operator-supplied bad config: a bad model_id, a bad parameter). Fix the config, not the code.
  • any other native exception → a :class:~latence_core.errors.ProviderError (a genuine provider/runtime failure). retryable=False — the adapter does not presume a native fault is transient; an endpoint adapter that knows better raises its own retryable ProviderError directly.
  • a framework :class:~latence_core.errors.LatenceError raised by the framework (e.g. a contract check the adapter itself made) passes through unwrapped — it is already typed and already carries the right category; re-wrapping would relabel it.

The op is a short, static verb the adapter names ("parse", "extract", "embed") — not derived from input — so the message is a stable, greppable label. Crucially the caught exception's message is deliberately not interpolated into the raised message (only type(exc).__name__), matching the Quality Report's no-PII discipline: a native lib that echoes the offending input text in its error must not leak it into the framework error a report or log records.

Source code in packages/latence-core/src/latence_core/providers/adapter.py
@contextmanager
def guard(self, op: str) -> Iterator[None]:
    """Map an exception raised **inside** the ``with`` block to the typed taxonomy (ADR-0034).

    Wrap the native library call so a native exception becomes the right
    :class:`~latence_core.errors.LatenceError`, tagged with the provider name + ``op`` and
    **carrying no record content / PII** (only the provider, the op, and the native
    exception's *type name* — never its message, which could echo an input):

    * a native exception whose type is in :attr:`config_exceptions` → a
      :class:`~latence_core.errors.ConfigError` (an operator-supplied bad config: a bad
      model_id, a bad parameter). Fix the config, not the code.
    * any other native exception → a :class:`~latence_core.errors.ProviderError` (a genuine
      provider/runtime failure). ``retryable=False`` — the adapter does not presume a native
      fault is transient; an endpoint adapter that knows better raises its own retryable
      ProviderError directly.
    * a framework :class:`~latence_core.errors.LatenceError` raised *by* the framework (e.g.
      a contract check the adapter itself made) **passes through unwrapped** — it is already
      typed and already carries the right category; re-wrapping would relabel it.

    The ``op`` is a short, static verb the adapter names (``"parse"``, ``"extract"``,
    ``"embed"``) — not derived from input — so the message is a stable, greppable label.
    Crucially the caught exception's *message* is deliberately **not** interpolated into the
    raised message (only ``type(exc).__name__``), matching the Quality Report's no-PII
    discipline: a native lib that echoes the offending input text in its error must not leak
    it into the framework error a report or log records.
    """
    try:
        yield
    except LatenceError:
        # Already a framework-typed error — it carries the right category and no content.
        # Pass it through unwrapped rather than relabelling it as a provider fault.
        raise
    except Exception as exc:  # noqa: BLE001 - deliberate boundary: map native → typed
        native_type = type(exc).__name__
        if isinstance(exc, self.config_exceptions):
            msg = (
                f"{self.provider_name}: config error in {op!r} "
                f"(native {native_type})"
            )
            raise ConfigError(msg) from exc
        msg = (
            f"{self.provider_name}: provider failure in {op!r} "
            f"(native {native_type})"
        )
        raise ProviderError(msg) from exc

carry_provenance staticmethod

carry_provenance(
    source: ProvenanceCarrier,
    *,
    char_start: int | None = None,
    char_end: int | None = None,
    page_start: int | None = None,
    page_end: int | None = None
) -> tuple[Provenance, Classification]

Carry a source's Provenance + Classification onto a produced record.

source is any :class:ProvenanceCarrier — a :class:~latence_core.contracts.Record (a sub-document mention/chunk) OR a :class:~latence_core.contracts.ParserInput (a Parser's raw-document source, ADR-0019); both carry the two fields this helper reads, so a real Parser reuses it for a whole-document carry rather than hand-rolling the chain (H-A1).

The default so an adapter never hand-rolls the Provenance chain: it takes the source's immutable :class:~latence_core.contracts.Provenance and narrows only the offset/page fields the produced record occupies (a sub-record's own span), leaving the source lineage (source_uri/document_id/file_*) intact, and returns the source :class:~latence_core.contracts.Classification unchanged (Classification is inherited by every downstream record, CONTEXT Classification).

Only the offset/page fields that are passed are overridden; the rest of the Provenance is copied verbatim via model_copy(update=...) (Provenance is frozen). Passing none returns the source Provenance/Classification exactly (a pure carry).

Source code in packages/latence-core/src/latence_core/providers/adapter.py
@staticmethod
def carry_provenance(
    source: ProvenanceCarrier,
    *,
    char_start: int | None = None,
    char_end: int | None = None,
    page_start: int | None = None,
    page_end: int | None = None,
) -> tuple[Provenance, Classification]:
    """Carry a source's Provenance + Classification onto a produced record.

    ``source`` is any :class:`ProvenanceCarrier` — a :class:`~latence_core.contracts.Record`
    (a sub-document mention/chunk) OR a :class:`~latence_core.contracts.ParserInput` (a Parser's
    raw-document source, ADR-0019); both carry the two fields this helper reads, so a real
    Parser reuses it for a whole-document carry rather than hand-rolling the chain (H-A1).

    The default so an adapter never hand-rolls the Provenance chain: it takes the source's
    immutable :class:`~latence_core.contracts.Provenance` and narrows only the
    offset/page fields the produced record occupies (a sub-record's own span), leaving the
    source lineage (``source_uri``/``document_id``/``file_*``) intact, and returns the
    source :class:`~latence_core.contracts.Classification` **unchanged** (Classification is
    inherited by every downstream record, CONTEXT ``Classification``).

    Only the offset/page fields that are passed are overridden; the rest of the Provenance
    is copied verbatim via ``model_copy(update=...)`` (Provenance is frozen). Passing none
    returns the source Provenance/Classification exactly (a pure carry).
    """
    update: dict[str, object] = {}
    if char_start is not None:
        update["char_start"] = char_start
    if char_end is not None:
        update["char_end"] = char_end
    if page_start is not None:
        update["page_start"] = page_start
    if page_end is not None:
        update["page_end"] = page_end
    provenance = source.provenance.model_copy(update=update) if update else source.provenance
    return provenance, source.classification

rebase_offsets staticmethod

rebase_offsets(
    local_start: int,
    local_end: int,
    *,
    chunk_char_start: int,
    offset_map: OffsetMap | None
) -> tuple[int, int]

Rebase a chunk-local half-open span to true original-markdown offsets (ADR-0031).

Reuses the existing :class:~latence_core.offsetmap.OffsetIndex seam — not a weaker hand-rolled mapper (issue P3-F2). Chunk content is markup-stripped, so the naive chunk_char_start + local shift is only a lower bound once markup was stripped before the span within its chunk; the carried :class:~latence_core.contracts.OffsetMap corrects it. When a chunk carries no offset map (a pre-v11 chunk, or a markup-free chunk), this degrades to the exact chunk_char_start + local shift — the identity path, unchanged.

Returns the half-open [doc_char_start, doc_char_end) span in the parent document's assembled markdown, ready for :meth:resolve_pages and :meth:carry_provenance.

Source code in packages/latence-core/src/latence_core/providers/adapter.py
@staticmethod
def rebase_offsets(
    local_start: int,
    local_end: int,
    *,
    chunk_char_start: int,
    offset_map: OffsetMap | None,
) -> tuple[int, int]:
    """Rebase a chunk-local half-open span to true original-markdown offsets (ADR-0031).

    Reuses the existing :class:`~latence_core.offsetmap.OffsetIndex` seam — **not** a weaker
    hand-rolled mapper (issue P3-F2). Chunk ``content`` is markup-stripped, so the naive
    ``chunk_char_start + local`` shift is only a *lower bound* once markup was stripped before
    the span within its chunk; the carried :class:`~latence_core.contracts.OffsetMap` corrects
    it. When a chunk carries no offset map (a pre-v11 chunk, or a markup-free chunk), this
    degrades to the exact ``chunk_char_start + local`` shift — the identity path, unchanged.

    Returns the half-open ``[doc_char_start, doc_char_end)`` span in the parent document's
    assembled markdown, ready for :meth:`resolve_pages` and :meth:`carry_provenance`.
    """
    if offset_map is not None:
        index = OffsetIndex(offset_map)
        return index.resolve_span(local_start, local_end)
    return chunk_char_start + local_start, chunk_char_start + local_end

resolve_pages staticmethod

resolve_pages(
    chunk: ChunkRecord,
    doc_char_start: int,
    doc_char_end: int,
    *,
    resolver: PageIndexResolver
) -> tuple[int, int]

Resolve a document-offset span to its source page(s) via the page seam (ADR-0031).

Goes through :class:~latence_core.page_index.PageIndexResolver — the ONE seam that owns offset→page resolution — so a produced sub-record cites its own page, not merely the parent chunk's inherited range. The resolver owns the half-open end clamp and the corpus-wide drift roll-up.

doc_char_start/doc_char_end must be offsets inside chunk, which is what :meth:resolve_offsets produces. Since v19 the chunk carries its own page spans, so resolution needs nothing but the record in hand: any order, any subset of the stream, no observe and no per-document state::

resolver = PageIndexResolver()          # one per extract() call, for the roll-up
for chunk in chunks:
    ...
    pages = self.resolve_pages(chunk, doc_start, doc_end, resolver=resolver)

A chunk whose producing Chunker stamped no page spans raises :class:~latence_core.errors.PageSliceMissingError. It does NOT degrade to the chunk's inherited range: that produced a page that looks resolved and is not, which is the exact provenance regression (audit R2/R4) this seam exists to prevent.

For a span whose endpoints may lie in DIFFERENT chunks — a relation's covering span — use :func:~latence_core.page_index.pages_for_covering_span instead; no single chunk can answer for it.

Source code in packages/latence-core/src/latence_core/providers/adapter.py
@staticmethod
def resolve_pages(
    chunk: ChunkRecord,
    doc_char_start: int,
    doc_char_end: int,
    *,
    resolver: PageIndexResolver,
) -> tuple[int, int]:
    """Resolve a document-offset span to its source page(s) via the page seam (ADR-0031).

    Goes through :class:`~latence_core.page_index.PageIndexResolver` — the ONE seam that
    owns offset→page resolution — so a produced sub-record cites **its own** page, not
    merely the parent chunk's inherited range. The resolver owns the half-open end clamp
    and the corpus-wide drift roll-up.

    ``doc_char_start``/``doc_char_end`` must be offsets **inside ``chunk``**, which is what
    :meth:`resolve_offsets` produces. Since v19 the chunk carries its own page spans, so
    resolution needs nothing but the record in hand: any order, any subset of the stream,
    no ``observe`` and no per-document state::

        resolver = PageIndexResolver()          # one per extract() call, for the roll-up
        for chunk in chunks:
            ...
            pages = self.resolve_pages(chunk, doc_start, doc_end, resolver=resolver)

    A chunk whose producing Chunker stamped no page spans raises
    :class:`~latence_core.errors.PageSliceMissingError`. It does NOT degrade to the chunk's
    inherited range: that produced a page that looks resolved and is not, which is the
    exact provenance regression (audit R2/R4) this seam exists to prevent.

    For a span whose endpoints may lie in DIFFERENT chunks — a relation's covering span —
    use :func:`~latence_core.page_index.pages_for_covering_span` instead; no single chunk
    can answer for it.
    """
    return resolver.pages_for_span(chunk, doc_char_start, doc_char_end)

validate_batch_size

validate_batch_size(
    raw: object, *, provider_name: str
) -> int

Validate a config.batch_size value to a positive int, or raise ConfigError (W7).

The shared, dep-light validator the learned ENCODER providers reuse so a batch_size knob is checked one right way whether or not the provider subclasses :class:AdapterBase (:meth:AdapterBase.batch_size delegates here). A batch_size must be an int >= 1 — a batch of zero would never make progress, and a bool/float/str is a config typo, not a valid size — so a non-positive or non-int value is an operator :class:~latence_core.errors.ConfigError naming the provider, never a silent fallback.

Source code in packages/latence-core/src/latence_core/providers/adapter.py
def validate_batch_size(raw: object, *, provider_name: str) -> int:
    """Validate a ``config.batch_size`` value to a positive int, or raise ConfigError (W7).

    The shared, dep-light validator the learned ENCODER providers reuse so a ``batch_size`` knob
    is checked **one right way** whether or not the provider subclasses :class:`AdapterBase`
    (:meth:`AdapterBase.batch_size` delegates here). A ``batch_size`` must be an ``int >= 1`` — a
    batch of zero would never make progress, and a ``bool``/float/str is a config typo, not a
    valid size — so a non-positive or non-int value is an operator
    :class:`~latence_core.errors.ConfigError` naming the provider, never a silent fallback.
    """
    if isinstance(raw, bool) or not isinstance(raw, int):
        msg = f"{provider_name}: batch_size must be an int >= 1, got {raw!r}"
        raise ConfigError(msg)
    if raw < 1:
        msg = f"{provider_name}: batch_size must be >= 1, got {raw}"
        raise ConfigError(msg)
    return raw

ProviderProfile

profile

ProviderProfile — a provider's declared, typed descriptor (ADR-0036 §2).

Every Provider (CONTEXT Provider) declares a small, frozen ProviderProfile: what compute it needs, its approximate memory footprint, its model id + verified SPDX license, whether it is deterministic and batch-capable, and (for endpoint/API providers) its per-1k cost. The registry surfaces it (ProviderRegistry.profile), and it drives the Phase-3 harnesses: skipping GPU providers on a CPU-only host (:mod:~latence_core.providers.device, the G1 posture), device routing, and the license/cost/compute columns of the bake-off (P3-F5) — all from one declared object, so adapters and harnesses never churn.

This module is deliberately dependency-light — a Pydantic v2 contract in the same style as :mod:latence_core.contracts, with no torch/heavy deps (ADR-0016) — so pip install latence-core stays lean and the registry can read a provider's profile off the class object it already loads.

A provider declares its profile via a profile classmethod (preferred — a provider can compute it from config) OR a PROFILE ClassVar; :class:HasProfile is the runtime_checkable protocol the registry uses to detect either structurally, and :func:profile_of reads whichever a provider class declares.

ProviderProfile

Bases: BaseModel

A provider's declared capabilities + enterprise-readiness columns (ADR-0036 §2).

Frozen and strict (extra="forbid") — the descriptor is a stable, immutable fact about a provider, read by the registry, the device seam, and the bake-off. Fabricating one is forbidden: a provider that declares none surfaces as None from the registry, never a synthesised default (P3-F1 scope §3).

Fields (ADR-0036 §2):

  • compute"cpu" | "gpu" | "either": what the adapter needs.
  • memory_mb — approximate peak resident footprint in MiB, declared by the provider (not measured here). >= 0.
  • model_id — the HF/model identifier, or None for a pure-code provider (gazetteer, pattern) that ships no weights.
  • license — the headline/effective SPDX identifier (e.g. "Apache-2.0", "MIT") or the sentinel :data:UNVERIFIED_LICENSE when it could not be verified in-slice. Must be consistent with license_code / license_weights.
  • license_verifiedTrue only when BOTH weights and code were checked per ADR-0012, and the verification evidence is present (H-B1: a bare True with no evidence is un-constructable). The :data:UNVERIFIED_LICENSE invariant forbids a verified unknown.
  • license_code — SPDX of the code/library (a provider's own package code is the repo's "Apache-2.0"). ADR-0012 records code separately from weights.
  • license_weights — SPDX of the weights, or None for a pure-code provider that ships no model (model_id is None). ADR-0012 records these separately.
  • license_source — a citation for the verification: the HF model-card URL, the GitHub LICENSE URL, or the repo's own LICENSE path — where a human actually checked. Required whenever license_verified is True.
  • license_verified_on — ISO date (YYYY-MM-DD) the check was performed. Required whenever license_verified is True.
  • deterministic — whether identical input + config yields identical output (the Baseline/G1 determinism signal a bake-off reports).
  • batch — whether the adapter supports batched calls.
  • cost_per_1k — for an endpoint/API provider, the cost per 1k units; None for a local/in-process provider (no per-call cost). >= 0 when present.

HasProfile

Bases: Protocol

Structural protocol: a provider class that declares a :class:ProviderProfile.

A provider surfaces its profile via a profile classmethod (preferred, so a provider can compute it from config) OR a PROFILE ClassVar. This protocol names the profile classmethod shape so the registry can detect a declaration runtime_checkable-y — without importing the provider's heavy deps beyond the class object it already loaded. :func:profile_of is the reader that accepts either the classmethod or the ClassVar form.

profile classmethod

profile() -> ProviderProfile

Return the provider's declared :class:ProviderProfile.

Source code in packages/latence-core/src/latence_core/providers/profile.py
@classmethod
def profile(cls) -> ProviderProfile:
    """Return the provider's declared :class:`ProviderProfile`."""
    ...

profile_of

profile_of(provider_cls: type) -> ProviderProfile | None

Read a provider class's declared profile, or None if it declares none.

Detects either declaration form on the class object the registry already loaded, never fabricating a profile for a provider that declares none (P3-F1 §3):

  • a profile classmethod (preferred) — called with no args; or
  • a PROFILE ClassVar holding a :class:ProviderProfile.

A provider with neither returns None. A provider whose profile attribute is present but is not a callable/classmethod returning a :class:ProviderProfile (or a PROFILE that is not one) raises TypeError — a malformed declaration is a hard error, not a silently-dropped profile, so a typo surfaces rather than reading as "no profile declared".

A subtlety the reader MUST respect (else it corrupts a legitimate Provider): the :class:~latence_core.capability.Profiler Capability method is itself named profile — an instance method profile(self, documents, mentions), not a profile declaration. Calling that with no args would raise a spurious TypeError and mislabel a perfectly valid Provider as "malformed". So the profile-declaration form is recognised only as a genuine @classmethod (accessed on the class, it is bound with __self__ is provider_cls); a plain instance method named profile is NOT a declaration and falls through to the PROFILE ClassVar / None path. The malformed-declaration contract still holds for a real profile classmethod that returns a non-:class:ProviderProfile.

Source code in packages/latence-core/src/latence_core/providers/profile.py
def profile_of(provider_cls: type) -> ProviderProfile | None:
    """Read a provider class's declared profile, or ``None`` if it declares none.

    Detects either declaration form on the class object the registry already loaded,
    **never fabricating** a profile for a provider that declares none (P3-F1 §3):

    * a ``profile`` **classmethod** (preferred) — called with no args; or
    * a ``PROFILE`` **ClassVar** holding a :class:`ProviderProfile`.

    A provider with neither returns ``None``. A provider whose ``profile`` attribute is
    present but is not a callable/classmethod returning a :class:`ProviderProfile` (or a
    ``PROFILE`` that is not one) raises ``TypeError`` — a malformed declaration is a
    hard error, not a silently-dropped profile, so a typo surfaces rather than reading
    as "no profile declared".

    A subtlety the reader MUST respect (else it corrupts a legitimate Provider): the
    :class:`~latence_core.capability.Profiler` **Capability** method is itself named
    ``profile`` — an *instance* method ``profile(self, documents, mentions)``, not a profile
    declaration. Calling that with no args would raise a spurious ``TypeError`` and mislabel a
    perfectly valid Provider as "malformed". So the profile-declaration form is recognised only
    as a genuine ``@classmethod`` (accessed on the class, it is bound with ``__self__ is
    provider_cls``); a plain instance method named ``profile`` is NOT a declaration and falls
    through to the ``PROFILE`` ClassVar / ``None`` path. The malformed-declaration contract
    still holds for a real ``profile`` classmethod that returns a non-:class:`ProviderProfile`.
    """
    # Prefer the classmethod form. A genuine ``@classmethod`` accessed on the class is a
    # bound method whose ``__self__`` is the class itself; that is how we tell a profile
    # DECLARATION apart from the Profiler Capability's ``profile(self, ...)`` INSTANCE method
    # (which has no ``__self__`` and expects args). Only the former is invoked with no args.
    prof_attr = getattr(provider_cls, "profile", None)
    if callable(prof_attr) and getattr(prof_attr, "__self__", None) is provider_cls:
        result = prof_attr()
        if not isinstance(result, ProviderProfile):
            msg = (
                f"Provider {provider_cls.__name__!r} has a 'profile' classmethod that did "
                f"not return a ProviderProfile (got {type(result).__name__})."
            )
            raise TypeError(msg)
        return result

    class_var = getattr(provider_cls, "PROFILE", None)
    if class_var is not None:
        if not isinstance(class_var, ProviderProfile):
            msg = (
                f"Provider {provider_cls.__name__!r} has a 'PROFILE' attribute that is not "
                f"a ProviderProfile (got {type(class_var).__name__})."
            )
            raise TypeError(msg)
        return class_var

    # A ``profile`` attribute that is a bare :class:`ProviderProfile` assigned to ``profile``
    # (instead of ``PROFILE``) is accepted as a declaration — a convenient ClassVar-style form.
    if isinstance(prof_attr, ProviderProfile):
        return prof_attr
    # A ``profile`` that is a plain **instance method** (the Profiler Capability's
    # ``profile(self, documents, mentions)``, callable but not a class-bound classmethod) is
    # NOT a profile declaration — it is the Stage's own I/O method. Read it as "no profile
    # declared" (``None``) rather than mislabelling a valid Provider as malformed.
    if callable(prof_attr):
        return None
    # Anything else present under ``profile`` but neither a ProviderProfile, a classmethod,
    # nor a callable (e.g. a stray string/int assigned to ``profile``) is a malformed
    # declaration — surface it rather than reading as "no profile".
    if prof_attr is not None:
        msg = (
            f"Provider {provider_cls.__name__!r} has a 'profile' attribute that is neither a "
            f"classmethod returning a ProviderProfile nor a ProviderProfile "
            f"(got {type(prof_attr).__name__}); declare a 'profile' classmethod or a "
            f"'PROFILE' ClassVar."
        )
        raise TypeError(msg)
    return None

Device selection

device

Dep-light device routing seam (ADR-0036 §1/§2, ADR-0016).

Device placement stays the adapter's job (it moves its own model to the resolved device, ADR-0003/0036 §2); this seam does NOT move models. It answers two questions, dependency-light and without importing torch (torch is not a core dep, ADR-0016):

  • :func:cuda_available — is a CUDA GPU present? Deterministic under an explicit LATENCE_CUDA=0|1 env override (for CPU-only CI and seeded tests); otherwise a best-effort probe that defaults to False when unknown.
  • :func:select_device — given a requested device (auto|cpu|cuda) and a provider's :class:~latence_core.providers.profile.ProviderProfile, return a :class:DeviceDecision: the effective device ("cpu"/"cuda"), or a skip-with-flag decision (skipped=True + a reason) when a GPU-only provider is requested on a CPU-only host. It never raises for that case — the G1 posture is flag-and-skip, so the stack harness records the skip in its report rather than crashing (no fabricated GPU numbers).

DeviceDecision dataclass

DeviceDecision(
    device: str | None, skipped: bool, reason: str
)

The typed outcome of :func:select_device — a routing result, not a bare string.

So a harness can record a skip in its report (never a fabricated GPU number):

  • device — the effective device ("cpu" or "cuda") to place the model on, or None when skipped (there is no device to place on).
  • skippedTrue when a GPU-only provider was requested on a CPU-only host: the provider is skipped-with-flag (the G1 posture), not run and not crashed.
  • reason — a human-readable explanation of the routing (why this device, or why the skip), for the bake-off / stack-validation report.

cuda_available

cuda_available() -> bool

Detect a CUDA GPU without importing torch (ADR-0016), override-driven for CI.

Probe order (deterministic under the override):

  1. Honor an explicit LATENCE_CUDA=0|1 env override first — the CI/test knob that makes a CPU-only host reproducible and lets a test drive the cuda path.
  2. Otherwise, a best-effort torch-free probe: the presence of the nvidia-smi tool on PATH OR an /dev/nvidia* device file. Either is a strong signal a CUDA device exists; neither imports torch or a CUDA library.
  3. Default to False when unknown — a CPU-only host is the safe assumption, so a GPU provider is skipped-with-flag rather than attempted and crashed.

Under the override this is fully deterministic (no filesystem/PATH dependence), which is what the seeded conformance/stack tests rely on.

Source code in packages/latence-core/src/latence_core/providers/device.py
def cuda_available() -> bool:
    """Detect a CUDA GPU **without importing torch** (ADR-0016), override-driven for CI.

    Probe order (deterministic under the override):

    1. Honor an explicit ``LATENCE_CUDA=0|1`` env override first — the CI/test knob that
       makes a CPU-only host reproducible and lets a test drive the cuda path.
    2. Otherwise, a best-effort torch-free probe: the presence of the ``nvidia-smi``
       tool on ``PATH`` OR an ``/dev/nvidia*`` device file. Either is a strong signal a
       CUDA device exists; neither imports torch or a CUDA library.
    3. Default to ``False`` when unknown — a CPU-only host is the safe assumption, so a
       GPU provider is skipped-with-flag rather than attempted and crashed.

    Under the override this is fully deterministic (no filesystem/PATH dependence), which
    is what the seeded conformance/stack tests rely on.
    """
    override = _env_override()
    if override is not None:
        return override

    # Best-effort, torch-free. ``nvidia-smi`` on PATH is the usual driver-present signal.
    if shutil.which("nvidia-smi") is not None:
        return True
    # A CUDA device node is a second signal on a Linux host without the CLI on PATH.
    try:
        for entry in os.listdir("/dev"):
            if entry.startswith("nvidia") and entry != "nvidiactl":
                # nvidia0, nvidia1, ... are per-GPU device nodes. ``nvidiactl`` exists
                # even without a usable GPU on some setups, so it is not counted alone.
                return True
    except OSError:
        # No /dev, or not readable (non-Linux, sandbox) — unknown, default False below.
        pass
    return False

select_device

select_device(
    requested: str, profile: ProviderProfile | None
) -> DeviceDecision

Route a requested device against a provider's profile (skip-with-flag, never raise).

requested is the device config value (auto|cpu|cuda); profile is the provider's declared :class:~latence_core.providers.profile.ProviderProfile (or None when it declares none). The G1 posture is skip-with-flag, never raise for the GPU-on-CPU-only case, so the stack harness records the skip.

Routing (per ADR-0036 §2):

  • profile.compute == "cpu" → always "cpu". Even requested="cuda" resolves to CPU (with a reason noting the override) — a CPU-only provider has no GPU path, so this is not a skip, just a clamp.
  • profile.compute == "gpu" → needs a GPU. If CUDA is available, resolve to the requested device (cpu is still honored if explicitly asked, else cuda); if NOT available, skip-with-flagrequested="auto" or "cuda" both skip, requested="cpu" clamps to CPU only if the provider could run there, which a compute=="gpu" provider cannot, so it also skips.
  • profile.compute == "either" (or profile is None) → follow the request: cpu → CPU; cuda → CUDA if available else skip-with-flag; auto → CUDA when available, else CPU.

Never raises for the skip case; a genuinely malformed requested string is still a ValueError (config error), surfaced before any routing.

Source code in packages/latence-core/src/latence_core/providers/device.py
def select_device(requested: str, profile: ProviderProfile | None) -> DeviceDecision:
    """Route a requested device against a provider's profile (skip-with-flag, never raise).

    ``requested`` is the ``device`` config value (``auto|cpu|cuda``); ``profile`` is the
    provider's declared :class:`~latence_core.providers.profile.ProviderProfile` (or
    ``None`` when it declares none). The G1 posture is **skip-with-flag, never raise** for
    the GPU-on-CPU-only case, so the stack harness records the skip.

    Routing (per ADR-0036 §2):

    * ``profile.compute == "cpu"`` → always ``"cpu"``. Even ``requested="cuda"`` resolves
      to CPU (with a reason noting the override) — a CPU-only provider has no GPU path, so
      this is not a skip, just a clamp.
    * ``profile.compute == "gpu"`` → needs a GPU. If CUDA is available, resolve to the
      requested device (``cpu`` is still honored if explicitly asked, else ``cuda``); if
      NOT available, **skip-with-flag** — ``requested="auto"`` or ``"cuda"`` both skip,
      ``requested="cpu"`` clamps to CPU only if the provider could run there, which a
      ``compute=="gpu"`` provider cannot, so it also skips.
    * ``profile.compute == "either"`` (or ``profile is None``) → follow the request:
      ``cpu`` → CPU; ``cuda`` → CUDA if available else **skip-with-flag**; ``auto`` →
      CUDA when available, else CPU.

    Never raises for the skip case; a genuinely malformed ``requested`` string is still a
    ``ValueError`` (config error), surfaced before any routing.
    """
    req = _normalize_requested(requested)
    has_cuda = cuda_available()
    compute = profile.compute if profile is not None else "either"

    if compute == "cpu":
        # A CPU-only provider always resolves to CPU; an explicit cuda request is a clamp,
        # not a skip (the provider simply has no GPU path).
        if req == "cuda":
            return DeviceDecision(
                device="cpu",
                skipped=False,
                reason="provider is cpu-only; requested cuda clamped to cpu",
            )
        return DeviceDecision(device="cpu", skipped=False, reason="provider is cpu-only")

    if compute == "gpu":
        if not has_cuda:
            # The G1 posture: a GPU-only provider on a CPU-only host is skipped-with-flag,
            # never run and never crashed — the harness records the skip (no fake numbers).
            return DeviceDecision(
                device=None,
                skipped=True,
                reason="provider requires a GPU but no CUDA device is available (skipped)",
            )
        if req == "cpu":
            # CUDA is present but the caller explicitly pinned CPU for a GPU-only provider:
            # it cannot run on CPU, so skip-with-flag rather than silently mis-placing it.
            return DeviceDecision(
                device=None,
                skipped=True,
                reason="provider requires a GPU but cpu was explicitly requested (skipped)",
            )
        return DeviceDecision(
            device="cuda", skipped=False, reason="provider requires a GPU; cuda available"
        )

    # compute == "either" (or no profile): follow the request against availability.
    if req == "cpu":
        return DeviceDecision(device="cpu", skipped=False, reason="cpu requested")
    if req == "cuda":
        if has_cuda:
            return DeviceDecision(
                device="cuda", skipped=False, reason="cuda requested; cuda available"
            )
        return DeviceDecision(
            device=None,
            skipped=True,
            reason="cuda requested but no CUDA device is available (skipped)",
        )
    # auto: prefer CUDA when present, else CPU.
    if has_cuda:
        return DeviceDecision(
            device="cuda", skipped=False, reason="auto resolved to cuda (available)"
        )
    return DeviceDecision(device="cpu", skipped=False, reason="auto resolved to cpu (no cuda)")