DocumentClassifier
The port that says what a document is, when a request asks.
Protocols:
DocumentClassifier.id: ClassifierIdDocumentClassifier.classify(text: str) -> Mapping[str, tuple[LabelScore, ...]]DocumentClassifierProvider.document_classifiers() -> tuple[DocumentClassifier, ...]
DocumentClassifier is the boundary for saying what a document is: its type,
the unit that produced it, the industry it belongs to. It is asked once per
document, after every reader has produced its pages, and it is handed text
rather than bytes — the full text the document was read as.
The answer maps a facet to labels. A facet is a name the classifier chose
(document_type, industry), and nothing in indx enumerates them; each label is
a LabelScore, a name and a number in [0, 1], ordered highest first. A facet
absent from the answer is “no opinion”, and the next classifier the request
enabled is asked for it. Unlike a LanguageScore,
this is a claim about subject matter — which is why it is a separate port
under a separate reserved key.
Three distributions ship through the same entry-point group a third party uses:
indx-classifier-words (word lists and thresholds, no extra, the one a default
install carries), indx-classifier-zeroshot (a multilingual NLI model through
onnxruntime, behind the zeroshot extra) and indx-classifier-llm (any model
LiteLLM reaches, behind the llm extra and INDX_CLASSIFIER_LLM_MODEL).
A classifier is not a capability kind. CapabilityKind is closed, the ladder
over it is closed, and a label routes nothing — it is the
LanguageDetector shape: a port
applied after reading, changing execution output rather than a routing decision.
It differs from a detector in one way that shapes the rest. A detector is free
and runs whenever installed; a classifier costs a call — a model pass, a token,
or an off-box request — so nothing runs that the request did not enable.
That is why a classifier carries an id where a detector carries none, why
EncodeRequest.classification names the IDs it wants in the order it wants
them asked, and why the installed IDs are advertised on the capability snapshot
so a caller can discover them. They ride the snapshot outside its content hash,
like resolvable: enabling is per request, so installing a classifier changes
no decision an outstanding plan made.
It is one of five ports rather than one of one. The grid is the unit handed in — document, page, chunk — against the return shape — labels or spans — and this port is the document-and-labels cell. The page and chunk classifiers are the same answer at a finer unit; the entity extractors are the other return shape. All five share one ID namespace, so no two may claim one string.
Contract
Section titled “Contract”The input is the full text the document was read as, in page order. Sampling is
not the contract’s: an implementation that needs a bound — a token window, a
cost ceiling, a scan it does not want to run twice — applies its own, configured
by its own settings model and using the dependency-free helper in
indx-interfaces
(ADR-0033).
A classifier that reads everything it is given is paying for a 300-page filing
what it does not pay for a three-page one, and that is now its own decision to
make rather than one the request made for it.
A raise is a verdict about the classifier rather than about the source, so the executor logs it and asks the next one: an encode that already paid to read a document must not fail over an annotation asked for afterwards.
Two refusals fire before the source is fetched, both 422 invalid_classifier.
An ID nothing installed declares is unknown_classifier, and the message
enumerates the IDs that are. A classifier whose device is external is
data_residency when the request carries that constraint — refused rather than
skipped, because a classifier silently left out is an answer the caller believes
was given and was not.
Four attributes sit outside the protocol deliberately, read with defaults:
device (CPU when absent), cost_usd set after classify() the way
VectorEncoder sets it and added to the run’s usage.cost_usd,
builtin = True for one indx ships, and facets, a tuple[str, ...] naming
what the classifier can answer.
facets is what makes “no facet outstanding” knowable. A request names
classifiers and never facets, so without the declaration the executor had to ask
every enabled classifier and bill the call even when the first one had already
taken every facet the second could have answered. Absent means unknown, and an
undeclared classifier is always asked. Declared, the classifier is skipped
whole — no call, no cost, and not named in the trace — once every facet in the
tuple is taken for that unit. The three shipped classifiers declare the facets
of the taxonomy they loaded, so classification.document_ids = ["words", "llm"]
over the shipped table pays for the LLM call only for the facets the word lists
left open.
Responsibilities and guarantees
Section titled “Responsibilities and guarantees”- Answer per facet, and leave a facet out rather than guessing at it. A label under the classifier’s own floor is no label.
- Order each facet descending, so a caller reading
[0]reads the answer. - Report a number that means what the classifier can defend. The word-signature ratio and the NLI entailment probability are both self-reports, and neither is calibrated against a labelled corpus.
- Keep module scope cheap. Discovery imports every provider module while
building a snapshot, so the engine import belongs inside
classify()and building the model behind the first call. - Advertise nothing when unable to run. Without its extra, or without a model configured, a distribution declares no classifier at all rather than a broken one — the hosted-embedding rule.
Place in the system
Section titled “Place in the system”DocumentExecutor.encode resolves the enabled IDs and checks residency first,
reads the document, asks each enabled classifier in request order, and writes the merged answer to the document block’s metadata under
CLASSIFICATION_METADATA_KEY as {facet: [{"label", "confidence"}, ...]}. The
first classifier with an opinion on a facet wins it; a document nothing could
read is never classified and carries no key.
The key is reserved: EncodeRequest.metadata refuses classification outright,
as it refuses languages, so a caller’s label is never silently replaced.
POLICY_VERSION did not move for any of this, and the capability snapshot ID is
byte-identical before and after: a label is an annotation on output, and the
snapshot field that names the classifiers is excluded from the hash by design.