PageClassifier and ChunkClassifier
The classifier ports asked once per page and once per chunk, rather than once per document.
Protocols:
PageClassifier.id: ClassifierIdPageClassifier.classify(text: str) -> Mapping[str, tuple[LabelScore, ...]]PageClassifierProvider.page_classifiers() -> tuple[PageClassifier, ...]ChunkClassifier.id: ClassifierIdChunkClassifier.classify(text: str) -> Mapping[str, tuple[LabelScore, ...]]ChunkClassifierProvider.chunk_classifiers() -> tuple[ChunkClassifier, ...]
These are DocumentClassifier with
the unit changed and nothing else. The same classify(text) signature, the same
facet-to-LabelScore answer, the same reserved key — asked once per page, or
once per chunk, instead of once for the whole document.
The unit is what they are for. A filing whose cover page is a form and whose body is correspondence has no single document type, and a chunk that quotes a statute is not the same kind of text as the paragraph arguing about it. A document-level answer over that is not wrong so much as unable to say the thing worth saying.
The three shipped classifiers are declared through both ports as well as the document one, each as one object through three hooks. The ports exist because splitting the classifier on its unit is what made room for entity extraction, and leaving the label-shaped half of that grid unbuilt would have made the split look like it was about entities rather than about units.
The alternative was one port with a target field, and it fails on the merge
rule. DocumentClassifier resolves a facet by first-enabled-wins, which is
right when the answer is about the whole document — a filing has one document
type — and wrong per page, where two classifiers answering about different pages
would collide on one facet and the first would silently take the document. A
port whose merge rule depends on how it was called is a port that gets merged
wrongly.
Splitting the unit into the protocol makes the rule fall out of the type instead
of being configured: a document facet has one answer, a per-unit facet is a
mapping keyed by block. It also makes each implementation’s cost legible. A
model that costs a call per unit costs one call on DocumentClassifier and
several hundred on ChunkClassifier, and that is a difference a caller should
choose rather than discover.
Contract
Section titled “Contract”The input is the full text of one unit — the page’s text as its reader produced it, or the chunk’s text as its chunker cut it. It is not a sample: sampling left the contract, and an implementation that needs a bound applies its own (ADR-0033).
A ChunkClassifier requires chunk granularity. Naming one in a request that did
not ask for CHUNK is a 422 before the source is fetched, because there will
be no chunks to classify and an empty answer would look like no opinion. A page
classifier has no such requirement: pages are always produced.
IDs are one namespace across all five classifier and extractor ports, so a page
classifier may not share an ID with a chunk classifier or an extractor, and the
registry rejects the collision when it builds. Unknown IDs and external
implementations under data_residency refuse exactly as
DocumentClassifier’s do, before the fetch, and for the same reasons.
A raise is a verdict about the classifier and not about the source: the executor
logs it, skips that unit, and asks the next classifier. device, cost_usd and
builtin sit outside the protocol and are read with defaults, unchanged.
Responsibilities and guarantees
Section titled “Responsibilities and guarantees”- Answer about the text you were handed and nothing else. A page classifier cannot see the document, and inferring one from the other is the document-level port’s job.
- Leave a facet out rather than guessing at it, and order each facet descending.
- Bound your own input if you need it bounded, and say so in your settings. There is no contract ceiling to hide behind any more.
- Keep module scope cheap. Discovery imports every provider module while
building a snapshot, so the engine import belongs inside
classify(). - Advertise nothing when unable to run — no extra, no model, no classifier, rather than a broken one.
- Expect to be asked many times. A per-unit port is called once per page or once per chunk, so per-call setup belongs behind the first call, not in it.
Place in the system
Section titled “Place in the system”DocumentExecutor.encode resolves the enabled IDs and checks residency before
the fetch, reads the document, and asks each enabled classifier for each unit in
request order. Page answers are written to each page block’s metadata under
CLASSIFICATION_METADATA_KEY; chunk answers are written to the document
block under CHUNK_CLASSIFICATION_METADATA_KEY, keyed by chunk block ID,
because chunk blocks carry no metadata
(ADR-0031).
The first classifier with an opinion wins a facet for that unit, not for the
document.
A page nothing could read is never classified and carries no key. The key stays
reserved on EncodeRequest.metadata.
POLICY_VERSION does not move: a label is an annotation on output. The
capability snapshot ID does not move either — the field naming these IDs is
advertisement, excluded from the content hash so installing one invalidates no
outstanding plan.