Typed decisions — Classifier (SPEC.md §8B).
A sibling of the §8 client, not a style inside it. A System One model takes a
state plus pre-declared, typed questions and returns calibrated answers with no
free text; it has no messages, no tool calling and no streaming, so it never
enters the client loop. Toolnexus.Tool is the contract for an action;
Toolnexus.Classifier is the contract for a judgment.
{:ok, classifier} = Toolnexus.Classifier.create(style: "systemone")
{:ok, decision} =
Toolnexus.Classifier.evaluate(classifier, "Order 4021 arrived smashed", %{
"is_refund_request" => %Toolnexus.Classifier.Noul{
instructions: "Is the customer asking for a refund?"
}
})A classifier interprets; it never authorises. Its output is a reading of intent inside a boundary, never the boundary. Numeric limits, permission checks and allowlists stay in code. Schema validity is not correctness: a decision can be confidently wrong, and "cannot hallucinate" means only that the returned value is in the declared schema. Nothing here is a security control.
A host that constructs no classifier observes byte-identical behaviour to a build without this module, and constructing one alters no request the client loop makes.
Summary
Functions
The bytes the byte-identity claim covers: model + questions, keys sorted
recursively in ASCII order, arrays never reordered, compact separators, and
<>&'" plus non-ASCII transmitted raw.
Build a Choice from any (name, description) pairs — a tool, a skill, an agent,
an A2A card skill. The description must say what picking that option would MEAN;
see the encoding obligation on Choice.
Create a classifier, applying the §8B defaults and rejecting a style whose required option is missing before any call is made.
The NAME of the env var holding the credential (§8B default).
The System One endpoint base (§8B default).
The floating model alias. Pin it once thresholds are tuned.
Per-request timeout in ms — a classifier has no loop to bound.
The whole contract: a state plus typed questions in, a Decision out.
Client-side cap on a choice's named options (§8B).
Bounds on a score rubric (§8B).
Whether a choice answer's probability map is indistinguishable from flat (§8B)
The near_uniform tolerance: 0.05, absolute, inclusive.
Types
@type question() :: Toolnexus.Classifier.Noul.t() | Toolnexus.Classifier.Choice.t() | Toolnexus.Classifier.Score.t()
@type t() :: %Toolnexus.Classifier{ api_key_env: term(), base_url: term(), body_transform: term(), client: term(), decisions: term(), evaluate: term(), headers: term(), http_options: term(), model: term(), on_error: term(), on_metric: term(), request_params: term(), retries: term(), retry_base_ms: term(), retryable_statuses: term(), static: term(), style: term(), timeout: term(), transport: term(), warned: term() }
Functions
The bytes the byte-identity claim covers: model + questions, keys sorted
recursively in ASCII order, arrays never reordered, compact separators, and
<>&'" plus non-ASCII transmitted raw.
state is deliberately NOT here. It is transmitted verbatim as the host
supplied it and is outside the claim, because numbers do not canonicalise across
languages (-0.0 renders four ways across our own seven runtimes). Do not
re-widen this: a caller who needs their state pinned canonicalises it themselves
before handing it over.
@spec choice_over(String.t(), %{required(String.t()) => String.t()}) :: Toolnexus.Classifier.Choice.t()
Build a Choice from any (name, description) pairs — a tool, a skill, an agent,
an A2A card skill. The description must say what picking that option would MEAN;
see the encoding obligation on Choice.
Create a classifier, applying the §8B defaults and rejecting a style whose required option is missing before any call is made.
Options (ClassifierOptions, §8B — mirrors §8 ClientOptions wherever a field
makes sense, so a host that has configured one has configured the other):
:style—"systemone"(default) |"llm"|"custom"|"static":base_url— default"https://api.typesafe.ai/v1":model— default"jev-latest"; pin it once thresholds are tuned:api_key_env— the name of the env var, never the value. Default"TYPESAFE_API_KEY"; read at call time and never logged:headers— extra headers; values expand${ENV_VAR}from the environment at call time and are never logged, identically to remote-MCP headers (§2):timeout— ms per request (default 10_000); a classifier has no loop to bound:http_options— extraReqoptions for the classifier path (§8 Gap 2):transport— §8 Gap 2, an injectable HTTP transport(%{method:, url:, headers:, body:, receive_timeout:} -> {:ok, %{status:, headers:, body:}} | {:error, Exception.t()}).bodyis the canonical request BINARY:retry_base_ms(default 500) — base of the retry backoff in ms (base * 2^attempt, no jitter); aRetry-Afterheader still wins:retries(default 2) and:on_error— REUSES the §8%{error?, status?, attempt, retryable} -> :retry | :failclassifier and theRetry-Afterdelay-seconds rule verbatim. There is no second retry policy, and no:suspendtier here either.:retriescovers408/429/500/502/503/504/529plus network errors:retryable_statuses— a list of extra HTTP statuses to treat as retryable, ADDED to the default set (429/500/502/503/504/529plus408here). It can only widen: a host cannot remove429and loseRetry-Afterhandling with it. It sets the DEFAULT classification only —:on_errorstill runs per attempt and has the final say, so:on_errorreturning:failoverrides a status listed here. Example: a Cloudflare-fronted origin that answers520–527.Retry-Afterhandling is untouched:request_params/:body_transform— §8 Gap 1, same ordering: base body →:request_paramsmerge (a caller key wins) →:body_transform→ marshal:on_metric— emits"classifier.evaluate"events into the same §8 sink, and carries the degenerate-criteria warning as"classifier.warning":client—style: "llm"only; the §8Toolnexus.Clientto emulate over:evaluate—style: "custom"only;(state, questions -> {:ok, Decision} | {:error, term}). Every wire option is ignored:decisions—style: "static"only; a list of%{state: …, questions: …, response: map_or_binary}recorded decisions
The NAME of the env var holding the credential (§8B default).
The System One endpoint base (§8B default).
The floating model alias. Pin it once thresholds are tuned.
Per-request timeout in ms — a classifier has no loop to bound.
@spec evaluate(t(), term(), %{required(String.t()) => question()}) :: {:ok, Toolnexus.Classifier.Decision.t()} | {:error, String.t()}
The whole contract: a state plus typed questions in, a Decision out.
state is a string, a map, or a list — whatever the host already has, and it is
transmitted verbatim. questions is a map from caller-chosen keys to
question structs. Questions are independent: one answer is never context for
another.
Client-side cap on a choice's named options (§8B).
Bounds on a score rubric (§8B).
Whether a choice answer's probability map is indistinguishable from flat (§8B):
near_uniform ⇔ max over i of |p_i - 1/n| <= 0.05n is the number of ENTRIES IN THE MAP and the values are taken AS RETURNED —
not renormalised, not sorted, not rounded; an offered option absent from the map
counts as 0 by not being an entry. The tolerance is ABSOLUTE and the comparison
INCLUSIVE. n == 1 is trivially uniform; an empty map has no distribution at all
and is reported false.
The near_uniform tolerance: 0.05, absolute, inclusive.