Skip to content

PageClassifier and ChunkClassifier

The classifier ports asked once per page and once per chunk, rather than once per document.

Protocols:

  • PageClassifier.id: ClassifierId
  • PageClassifier.classify(text: str) -> Mapping[str, tuple[LabelScore, ...]]
  • PageClassifierProvider.page_classifiers() -> tuple[PageClassifier, ...]
  • ChunkClassifier.id: ClassifierId
  • ChunkClassifier.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.

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.

  • 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.

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.