cortexkit-role-classifier 0.3.0

Wire contract of the CortexKit classifier/v1 role: role.describe, classify.run, question and answer shapes, batch identity and error codes.
Documentation
# `classifier/v1`: role contract

Stability: **alpha**. This document is the role definition; the Rust types
in this crate are its wire shapes, `test-vectors/classifier-v1/` holds its
vectors, and the sibling `cortexkit-role-classifier-conformance` crate holds its suite
(§11).

The provider shapes are from the providers' documentation (§10).

Every rule is marked **[pinned]**: the role states it. A module must do
it, and a caller may rely on it. §12 lists the readings this version leaves
open.

The batch rules `classifier/v1` shares with the other model-batch roles
(idempotency, single flight, stored outcomes, the spend ceiling, the error
vocabulary, provider status mapping, images, number text) are stated once,
in `cortexkit-model-batch`'s
[`RULES.md`](../cortexkit-model-batch/RULES.md), cited here as "RULES
§n". They are pinned for this role as written there; this document states
only what is this role's own.

## 1. Role identity

- [pinned] A module serving the role lists `classifier/v1` (`PROVIDES`) in
  its manifest's `capabilities.provides`. It is a valid subc capability
  identifier (`subc_protocol::manifest::is_valid_capability_identifier`).
- [pinned] The module serves `role.describe` and `classify.run`
  (`REQUIRED_OPS`), each a request `{method, params}` on its management
  route.
- [pinned] The module takes a batch of items and a set of questions, calls
  one hosted classifier model for each item, and returns the provider's
  answers untouched. It does no inference itself. Local inference, when it
  exists, is another module under the same capability.
- [pinned] A caller picks the module by **model id**, through the catalog
  row's serving module, not by capability alone.
- [pinned] Starting providers: Cloudflare Workers AI `@cf/cloudflare/clef`
  (catalog ids `cloudflare-workers-ai/@cf/cloudflare/clef`,
  `cloudflare-workers-ai/@cf/cloudflare/clef-flash`) and TypeSafe
  `jev-latest` (catalog id `typesafe/jev-1.13.0`, `POST
  https://api.typesafe.ai/v1/systemone`). Each provider call takes one
  item's `state` with every question of the batch.
- [pinned] A catalog id is as RULES §1 states it.

## 2. `role.describe`

- [pinned] The answer is (`RoleDescribe`):

  ```
  {
    role: "classifier/v1",
    limits: { max_items, max_request_bytes },
    models: [ {
      model,                      // catalog id
      max_questions, max_items,   // per model: the tighter of catalog and module limits
      context_tokens?,            // from the catalog; absent when it states no exact figure
      images: { max: n } | null,  // null = no image support; always stated
      price?: { input_per_mtok_usd?, output_per_mtok_usd? },  // absent if unpriced
      egress_host                 // the provider host item text is sent to
    } ]
  }
  ```

- [pinned] It is resolved from the module's catalog plus its own routing
  pins, so a caller validates a manifest at install time against what this
  module will actually do, and can name the outside host on a card.
- [pinned] A model's `max_items` is never above `limits.max_items`
  (`check_describe`).
- [pinned] `images` is always present: `null` states that the model takes no
  images. A price direction the catalog does not price is absent, never
  zero.
- [pinned] `context_tokens` is the catalog's exact context figure, and the
  provider truncates `state` beyond it. It is absent when the catalog states
  no exact figure, under the same rule as `price`: absent is not zero, and
  the module never guesses one (a provider that says only "64k" gets
  neither 64,000 nor 65,536). A stated `context_tokens` is never zero
  (`check_describe`).

## 3. `classify.run` request

- [pinned] The request is (`ClassifyRequest`):

  ```
  {
    batch_id: string,            // required; 1-128 printable ASCII; the idempotency key
    model: string,               // required; catalog id "<provider>/<model>"; never substituted
    questions: { <id>: Question },  // 1..model.max_questions; ids 1-64 [a-z0-9_-]
    items: [ { state, images? } ],  // 1..model.max_items, answered in this order
    max_cost_usd?: number        // optional spend ceiling for the batch (§6.4)
  }
  ```

- [pinned] `state` is a string, an object or an array, forwarded to the
  provider verbatim. Null, numbers and booleans are refused
  `invalid_params` with `detail.field` `items[i].state` (the real index).
- [pinned] `images` is accepted only where the model's catalog row allows
  it, up to its `images.max` (clef: at most 4). TypeSafe takes text only.
- [pinned] Truncation: the provider truncates a `state` longer than the
  model's context without saying so. Neither provider reports truncation,
  so the reply carries no truncation flag: a guessed flag would read as a
  measurement. A caller keeps `state` under `context_tokens` where the model
  states one; the per-item `usage.input_tokens`, when the provider reports
  it, is how the caller sees an item ran near the limit. The module does not
  refuse a `state` by a byte heuristic.

## 4. Questions

`Question` is the providers' shared shape, forwarded as the caller wrote it,
with the providers' own field names (`question` module):

- [pinned] Every question has `type` (`"noul"`, `"choice"` or `"score"`)
  and `instructions` (a non-empty string, or an object or array).
- [pinned] `noul`: optional `criteria: {"true": string, "false": string}`.
- [pinned] `choice`: required `criteria`, an object from option id to its
  description (a string, object, array or null), with 2 to 255 options.
- [pinned] `score`: required `criteria`, an array of level descriptions,
  lowest first, indexed from 0, with 2 to 10 levels. The request has no
  levels or legend field.
- [pinned] Images are the shared item image shape, validated and sized as
  RULES §13 states (Workers AI's limits: 4 MiB and 16 megapixels each, 8
  MiB in total); clef takes at most 4 per item.
- [pinned] Unknown question types split by direction. In a request, a
  question whose `type` is not one of the three is refused
  `invalid_params` with `detail.field` `questions.<id>.type`, naming that
  question's key: the module can neither validate nor map a type it does
  not know. The type is checked before the rest of the question, which
  depends on it. The types may still model an open question type for
  decoding (`QuestionType::Other`); the request validator refuses it
  (`check_question`). In a reply, the type decodes open (§5).
- The role's question id (`[a-z0-9_-]`, 1-64) is narrower than clef's
  (letters, digits, `_`, `.` and `-`, up to 100), so every role id is a
  valid clef id.

## 5. Reply and answers

- [pinned] The reply is (`ClassifyReply`):

  ```
  {
    batch_id, model,
    items: [ {
      index: n,                         // position in the request
      answers?: { <id>: Answer },       // present iff the item succeeded
      error?: { code, class, message, retry_after_ms? },  // present iff it failed
      usage?: { input_tokens?, output_tokens? }
    } ],
    usage: { input_tokens?, output_tokens? },   // summed over answered items
    cost_usd?: number                           // the BATCH's cumulative cost
  }
  ```

- [pinned] Items come back in request order; `items[i].index == i`.
- [pinned] `Answer` is the provider's answer object as returned:
  - `noul`: `{type, noul}`. `noul` is the probability that the answer is
    yes, from 0 (no) to 1 (yes) (providers' docs: "Probability the answer is
    yes").
  - `choice`: `{type, choice, probabilities: {<option>: number},
    confidence}`.
  - `score`: `{type, score, legend: {"0": desc, ...}, probabilities: {"0":
    number, ...}, confidence}`. `score` is fractional, from 0 to levels - 1,
    and can land between levels.
- [pinned] Each number reaches the reply with the provider's spelling
  (RULES §12); forward the answer object's text.
- [pinned] A reply decodes open: an answer of a type this crate does not
  name keeps its type string, and a member the provider adds that this
  crate does not name is kept as given (`Answer::extra`), so a new provider
  field passes through.

## 6. Pinned rules

The shared batch rules apply as RULES §2 to §11 state them. The subsections
keep their numbers, and say what is this role's own.

### 6.1 Idempotent by `batch_id`

- [pinned] As RULES §2. **Body identity** is the model, the questions, and
  each item (its images in canonical form) (`BatchIdentity`).
- [pinned] `detail.field` of `batch_id_reuse` names the first field that
  differs: `model`, `questions`, `items` (a different item count) or
  `items[i]` (the first differing item), checked in that order
  (`BatchIdentity::first_difference`).

### 6.2 Single flight per `batch_id`

- [pinned] As RULES §3.

### 6.3 Stored errors on a re-send

- [pinned] As RULES §4.

### 6.4 `max_cost_usd` is a batch ceiling

- [pinned] As RULES §5, with the estimate this role's §6.9 states.

### 6.5 `cost_usd` is the batch total

- [pinned] As RULES §6. It is present whenever the model is priced.

### 6.6 Never all-or-nothing after admission

- [pinned] As RULES §7: each item gets `answers` or `error`, never both.

### 6.7 Validation before any provider call

- [pinned] Checked against the catalog row before anything is written or
  sent: question count, question ids, question types, choice options 2-255,
  score levels 2-10, `state` kinds, images (RULES §13), `max_items`,
  `max_request_bytes`
  (`parse_request`, `check_request_bytes`, `check_request`).
- [pinned] `detail.field` paths: `batch_id`, `model`, `questions`,
  `questions.<id>`, `questions.<id>.type`, `questions.<id>.instructions`,
  `questions.<id>.criteria`, `items`, `items[i]`, `items[i].state`,
  `items[i].images`, `items[i].images[j]`, `max_cost_usd`, and `params` for
  the request as a whole, under RULES §8. A dot cannot be ambiguous,
  because a question id cannot contain one (§3).
- [pinned] A malformed image names `items[i].images[j]`, and an object
  missing `batch_id` names `batch_id` (`parse_request`,
  `test-vectors/classifier-v1/field-paths.json`).

### 6.8 Model pinned

- [pinned] As RULES §9: refused `model_unknown` if absent from the
  catalog, and `model_not_classifier` if the row's `kind` isn't
  `classifier` (`check_catalog_row`). A row that states no kind is §12's
  first open reading.

### 6.9 Spend guard

- [pinned] An estimate of items x input tokens x catalog price above
  `max_cost_usd`, or an account at its limit, is refused before dispatch
  (`cost_exceeded`, `account_walled`). The estimate is the module's own; see
  §12.

### 6.10 Absent is not zero

- [pinned] As RULES §10 (`sum_usage`).

### 6.11 Synchronous, bounded

- [pinned] As RULES §11.

## 7. Error codes

The vocabulary, the refusal and item error shapes, every code's meaning,
detail and class, and which codes are stored and stop the call are RULES
§14 (`errors`). `classifier/v1` answers with these codes:

- [pinned] Admission refusals (`ADMISSION_CODES`): `invalid_params`,
  `batch_id_reuse`, `model_unknown`, `model_not_classifier`,
  `cost_exceeded`, `account_walled`, `batch_in_progress`, `auth_failed`,
  `model_unavailable`.
- [pinned] Per-item errors (`ITEM_CODES`): `rate_limited`,
  `provider_error`, `provider_refused`, `invalid_item`, `cost_exceeded`,
  `auth_failed`, `model_unavailable`.
- [pinned] The batch-level account codes clef's errors map to (§8,
  `BATCH_LEVEL_CODES`): `account_limit_exhausted` and
  `account_not_entitled`, as an admission refusal when the call's first
  provider call meets one, and otherwise as the per-item error of every
  item the call leaves unanswered.
- `answer_too_large` is in the shared vocabulary, but this role states no
  answer bound and never answers with it.

## 8. Provider status mapping

- [pinned] TypeSafe documents no error codes, so its errors map by status
  (RULES §15, the first table; `item_code_for_status`): its 401 is
  `auth_failed`, 422 `invalid_item`, 429 and 529 `rate_limited`.
- [pinned] clef is a Cloudflare Workers AI model, so its errors map by
  Workers AI code first (RULES §15, the second table;
  `workers_ai_item_code`). Against 0.1.0's status-only mapping that changes
  four clef errors: 429 `3036` is `account_limit_exhausted`, not
  `rate_limited`; 403 `5035`, `5016` and `3023` are `account_not_entitled`,
  not `auth_failed`; 400 `5007` is `model_unavailable`, not `invalid_item`
  (`test-vectors/classifier-v1/workers-ai-codes.json`).
- [pinned] A stop, a stored error and an item that needs no provider call
  behave as RULES §14 states.

## 9. Provider shapes

Both providers share one shape; Cloudflare calls clef "fully Jev-API
compatible" (`provider` module).

- Request: `{model, state, questions, images?}`. clef takes `model: "clef"`
  (or `"clef-flash"`); TypeSafe takes `"jev-latest"`. The module maps the
  catalog id to the provider's own value. `state` is a string or structured
  data (an object or array); the provider truncates a long state silently.
  `questions` maps id to question (clef: 1 to 64 entries). `images` is clef
  only: at most 4, each a `data:` URL or `{content_type, base64}`; 4 MiB and
  16 megapixels each, 8 MiB in total, a body of at most 13 MiB, no remote
  URLs.
- Response: `{model, answers: {<id>: Answer}, usage: {input_tokens,
  output_tokens}}`.
- Errors: clef answers Workers AI errors, an HTTP status with
  `errors[].code` (the codes RULES §15 maps); there is no documented 529
  and no content-refusal code. TypeSafe answers 401, 422 (validation, naming
  the field), 429, and 529 Overloaded, with no documented error body.

## 10. Sources

- Cloudflare Workers AI, clef model page:
  <https://developers.cloudflare.com/workers-ai/models/clef/>
- Cloudflare Workers AI, errors:
  <https://developers.cloudflare.com/workers-ai/platform/errors/>
- Cloudflare Workers AI, clef-flash model page:
  <https://developers.cloudflare.com/workers-ai/models/clef-flash/>
- TypeSafe, Jev API reference (request and answer shapes, `jev-latest`):
  <https://jevtypesafeai.com/docs>; the endpoint is `POST
  https://api.typesafe.ai/v1/systemone`
  (<https://www.jevtypesafeai.com/how-to-use>).

`test-vectors/classifier-v1/provider-examples.json` holds the providers'
examples verbatim.

## 11. Vectors and conformance

- `test-vectors/classifier-v1/` holds the role's vectors, pinned by its
  `SHA256SUMS` file. They ship in this crate's package, and the
  `test-vectors` feature compiles them into the `vectors` module
  (`vectors::FILES`, plus a constant per file), so a module's tests can
  read the same bytes. Each is decoded into this crate's types and
  re-encoded byte for byte by `tests/vectors.rs`, which needs
  `--features test-vectors`:
  - `provider-examples.json`: the providers' documented examples;
  - `question-noul.json`, `question-choice.json`, `question-score.json`:
    per question type, per provider, the provider request and response and
    the `classify.run` request and reply that wrap them;
  - `state-object.json`, `state-array.json`: structured `state`;
  - `admission-refusals.json`: one refusal per admission code;
  - `item-errors.json`: one per-item error per code, with `auth_failed`
    and `model_unavailable` showing the item the stop left unsent;
  - `field-paths.json`: an unknown question type refused naming
    `questions.<id>.type`, and a refusal naming the narrowest path that
    applies rather than `params`;
  - `image-identity.json`: one image as a `data:` URL and re-sent as
    `{content_type, base64}` with one identity, and malformed base64
    refused naming `items[0].images[0]`;
  - `workers-ai-codes.json`: clef's errors by Workers AI code, each with
    its class, storage and stop, and the 0.1.0 code where it differs;
    TypeSafe's by status, unchanged; and the account codes' first-call
    refusals;
  - `replay-retry.json`: a retry returning the stored answers with the same
    `cost_usd`;
  - `replay-batch-id-reuse.json`: a reuse refused naming the field;
  - `role-describe.json`: three models, one with images, one with
    `images: null`, and one with no `context_tokens`.
- The conformance suite (`cortexkit-role-classifier-conformance`) drives a
  live module against a call-counting stand-in provider; its `README.md`
  lists the checks.

## 12. Open readings

- A catalog row that states no `kind`. `check_catalog_row` takes the
  row's kind as an `Option`, where `None` means no row, so a kindless row
  cannot be told apart from a missing one; a later version pins it with the
  `model.kind` gate (RULES §9 states the shared rule).
- The spend guard's estimate (§6.9) is the module's own. This role may
  adopt the shared conservative estimate (RULES §5) in a later version.

Every other point likely to be read more than one way is a pinned rule in
its section: every admission refusal carries `detail.class` (RULES §14);
`batch_id_reuse` names the first differing field in the order model,
questions, items, `items[i]` (§6.1); `detail.field` is `params` only when
no narrower path applies (RULES §8); 401 and 403 map to `auth_failed` and
404 to `model_unavailable`, never `invalid_item` (RULES §15); an unknown
question type is refused in a request and kept in a reply (§4, §5); and a
lower `max_cost_usd` on a re-send becomes the recorded ceiling (RULES §5).