Toolnexus.Classifier (toolnexus v0.18.1)

Copy Markdown View Source

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

question()

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

canonical_request(model, questions)

@spec canonical_request(String.t(), %{required(String.t()) => question()}) :: binary()

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.

choice_over(instructions, items)

@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(opts \\ [])

@spec create(keyword() | map()) :: {:ok, t()} | {:error, String.t()}

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 — extra Req options 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()}). body is the canonical request BINARY
  • :retry_base_ms (default 500) — base of the retry backoff in ms (base * 2^attempt, no jitter); a Retry-After header still wins
  • :retries (default 2) and :on_error — REUSES the §8 %{error?, status?, attempt, retryable} -> :retry | :fail classifier and the Retry-After delay-seconds rule verbatim. There is no second retry policy, and no :suspend tier here either. :retries covers 408/429/500/502/503/504/529 plus network errors
  • :retryable_statuses — a list of extra HTTP statuses to treat as retryable, ADDED to the default set (429/500/502/503/504/529 plus 408 here). It can only widen: a host cannot remove 429 and lose Retry-After handling with it. It sets the DEFAULT classification only — :on_error still runs per attempt and has the final say, so :on_error returning :fail overrides a status listed here. Example: a Cloudflare-fronted origin that answers 520–527. Retry-After handling is untouched
  • :request_params / :body_transform — §8 Gap 1, same ordering: base body → :request_params merge (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 §8 Toolnexus.Client to 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

default_api_key_env()

The NAME of the env var holding the credential (§8B default).

default_base_url()

The System One endpoint base (§8B default).

default_model()

The floating model alias. Pin it once thresholds are tuned.

default_timeout()

Per-request timeout in ms — a classifier has no loop to bound.

evaluate(c, state, questions)

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

max_choice_options()

Client-side cap on a choice's named options (§8B).

max_score_levels()

min_score_levels()

Bounds on a score rubric (§8B).

near_uniform?(probabilities)

@spec near_uniform?(map()) :: boolean()

Whether a choice answer's probability map is indistinguishable from flat (§8B):

near_uniform  ⇔  max over i of |p_i - 1/n|  <=  0.05

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

near_uniform_tolerance()

The near_uniform tolerance: 0.05, absolute, inclusive.