11. Typed decisions¶
nnx.decisions is NNx's inference branch: a provider-neutral way to ask a
typed question and get labelled probabilities back, instead of reading
positional logits. A question is one of three primitives. A provider
declares what it can answer and returns validated results. Three adapters
are providers:
- the fixed-head adapter turns a trained NNx classifier into a provider;
- the NLI adapter scores candidates supplied at inference with a caller-supplied NLI model;
- the Jev adapter asks hosted Jev models through the TypeSafe SDK.
Nothing here trains, exports or executes actions. Importing it starts no provider or model backend and needs no hosted-SDK extra.
from nnx.decisions import Choice, FixedHeadProvider, Option
question = Choice(
"Which animal is in the photo?",
(Option("sp-fox", "A fox"), Option("sp-cat", "A cat"), Option("sp-dog", "A dog")),
)
provider = FixedHeadProvider(model, option_map={"sp-cat": "cat", "sp-dog": "dog", "sp-fox": "fox"})
results = provider.decide(question, photos) # one ChoiceResult per row
results[0].distribution # (("sp-fox", 0.01), ("sp-cat", 0.94), ("sp-dog", 0.05))
results[0].top # "sp-cat"
examples/decision_fixed_head.py runs
this end to end on a deterministic local model.
1. Questions¶
| Primitive | Holds | Answered by |
|---|---|---|
Choice(prompt, options) |
2+ Option(id, description) with unique ids |
a distribution over the options |
Boolean(prompt) |
the prompt | one p_true in [0, 1] |
Score(prompt, levels) |
2+ ordered Option(level_id, description), lowest first |
a distribution over the levels |
Options may be given as (id, description) pairs; anything else (a bare
string, say) raises InvalidDecisionRequest. kind ("choice",
"boolean", "score") is the discriminator. state() /
question_from_state(...) round-trip a question as plain data. Malformed
state raises InvalidDecisionRequest, never a bare KeyError.
Bookkeeping ids vs model-facing text. An option's id is how results are
keyed and never reaches a model; its description is what a model reads.
question.model_view() returns exactly the model-facing part: the prompt and
the option texts in order, no ids. So a text provider can render a question
without leaking routing keys such as "team-7f3a".
Digest. question.digest() is a SHA-256 over the kind, the prompt and
every option's id and description in order. Reordering the options or
rewording a description changes it. Every result records the digest of the
question it answers, so a cached or batched result can never be matched to a
different question.
2. Results and validation¶
ChoiceResult and ScoreResult carry a distribution: (id, p) pairs in
the question's order. It must hold 2+ unique ids, each p finite in
[0, 1], summing to 1 within PROBABILITY_TOLERANCE = 1e-6. BooleanResult
carries one p_true (p_false is derived). A provider's own output stays in
raw (kept out of equality) and never mixes with the normalized fields.
validate_response(question, response) is the one validator every provider
uses. For a Choice or Score, response is the provider's keyed output — a
mapping or (id, p) pairs in any order. It is reordered into the question's
order. These raise InvalidDecisionResponse:
- a missing, duplicate, unknown or unlabeled (empty or non-string) id;
- a probability outside
[0, 1](one too large for a float included); - a distribution that does not sum to 1.
A malformed distribution is never renormalized: dividing by the sum would
hide a provider bug. For a Boolean, response is p_true or
{"true": p, "false": 1 - p}.
Scores are ordinal. ScoreResult.expected_index is sum(i * p_i) over
zero-based levels. It is a position between levels, useful for ranking. But
the levels' spacing is undefined, so it is not an interval-scale score. A provider
that reports its own number (for example a 1–10 rating) passes it as
vendor_score, which is kept separate from the distribution.
3. Providers and capabilities¶
A DecisionProvider has two methods: capabilities() and
decide(question, inputs) -> list[result] (one result per input).
Capabilities declares:
primitives— the question kinds it answers;modalities— the inputs it reads ("tensor","text", …);dynamic_labels/labels— whether a request may bring any option set, or must map onto a fixed label space;max_batch— the most inputs per call;inference/training/export— what the backend can do. A hosted provider declares inference only; it inherits no training or export capability.
capabilities().check(question, modality=..., batch_size=..., label_ids=None)
raises UnsupportedCapability for anything undeclared. Without dynamic
labels, that includes a Choice or Score whose ids are not exactly the
declared labels. The same holds for label_ids, the provider's own mapping
of those ids. With no labels declared, option sets are not checked.
Providers call it before any model call or network I/O.
4. The fixed-head adapter¶
FixedHeadProvider(model, labels=None, option_map=None, ordinal=False,
max_batch=None, name="nnx.fixed_head", question=None) adapts a trained NNModel. The head decides
what it can answer:
| Head | Answers |
|---|---|
categorical (softmax over C classes) |
Choice; also Score with ordinal=True, whose levels must follow the head's class order |
one Bernoulli logit (BCEWithLogitsLoss, or a one-output multilabel task) |
the one Boolean it was trained for (question=) |
| regression | nothing — rejected at construction |
| multi-output multilabel | nothing — rejected at construction when the head's width is known (a task's count or a built-in net's output_dim). Otherwise it is refused on the first call, after the model ran. |
- Label space.
labelsnames the head's columns in order. It defaults to the model'sTaskSpeclabels and must equal them when both are given. - Label count. A label count that differs from the head's width is
rejected. When the width is known (a task's count or a built-in net's
output_dim), this happens at construction. Otherwise it is rejected withInvalidDecisionRequeston the first call. - Request ids. A request's option ids must be exactly those labels (in
any order), or
option_mapmust map them one-to-one onto the labels. An unseen or missing label raisesUnsupportedCapabilitybefore themodel_callscounter advances. - A Boolean head's question. A one-logit head is one probability bound to
no prompt, so
question=Boolean(...)names the question it was trained for. It is required for such a head and refused for a categorical one (which answers by its labels).ordinal=andoption_map=are refused for a one-logit head: a Boolean has no options to order or map. - Other Booleans. Any other Boolean — another digest, however close its
wording — raises
UnsupportedCapabilitybefore themodel_callscounter advances. So a decision job refuses it and a benchmark records itunsupported, never answered with the head's probability. - Columns. Logits come from
model.predict_proba(the prediction contract). Probabilities are recomputed from them in float64, so a half-precision head still meets the1e-6tolerance. They are reordered into the request's option order and passed throughvalidate_response. Each row's logits (a copy) becomeraw. An empty batch returns[]without calling the model. - Modes. Prediction runs in eval mode and restores every submodule's training mode, on success and on failure.
- Failures. An exception from the model itself becomes
ProviderFailure, with the original error as__cause__. - Checking without calling.
check(question, inputs)runs every checkdecidemakes before the model call and calls nothing. Those checks cover question, modality, batch size and label space, or a Boolean's digest. A decision job (§7) uses it to refuse a request up front.
5. The NLI baseline adapter¶
A fixed head answers only the labels it was trained on. NLIProvider is a
local label-conditioned baseline. It scores a text (the premise) against
candidate descriptions that arrive with each request, each rendered into a
hypothesis. Scoring uses a natural-language-inference (NLI) cross-encoder
the caller supplies. So no candidate needs to exist when the provider is
built, and no output head is resized.
from nnx.decisions import Boolean, Choice, NLIProvider
provider = NLIProvider(
model, tokenizer, # loaded by the caller (a local path, a pinned revision)
entailment_id="entailment", # or the class id; names resolve through model.config.label2id
contradiction_id="contradiction",
hypothesis_template="This text is about {}.",
revision="<the revision you loaded>",
)
provider.decide(Choice("Topic?", (("t-sport", "sports"), ("t-econ", "the economy"))), texts)
provider.decide(Boolean("This review is positive."), texts)
- Choice scoring, explicit. A
Choicescores every (text, candidate) pair and softmaxes the entailment logits across the candidates (choice_scoring="entailment_softmax"). Logitslog(3)and0give(0.75, 0.25), keyed by the request's option ids in its order. Only the descriptions are scored: the Choice's prompt is not part of any hypothesis, so write it intohypothesis_templatewhen it matters. - Boolean scoring, explicit. A
Booleanscores one pair per text, its prompt rendered byboolean_template. It softmaxes that pair's contradiction and entailment logits alone (boolean_scoring="entailment_vs_contradiction").0andlog(4)givep_true = 0.8; texts are never normalized together.Scoreis unsupported and refused before any model call. - Validated settings. The entailment and contradiction ids — given or
resolved from names — must differ. When the model's config declares a
class count (
num_labels), they must fit it and the logits must have exactly that many classes. A label name must be one its config knows. An integer id that contradicts the config's ownentailment/contradictionnames is refused. - Templates and policies. Templates hold exactly one bare
{}(no conversion or format spec). Unknown scoring or truncation policies are refused at construction. - The provider contract. NNx imports no NLI library and downloads
nothing. Importing
nnx.decisionsor constructing the provider calls nofrom_pretrained. The model is called asmodel(**encoded), returning logits or an object with.logits. - The tokenizer call. The tokenizer is called HuggingFace-style:
tokenizer(premises, hypotheses, truncation=..., max_length=..., padding=True, return_tensors="pt"). It also gets unpadded measurement calls withtruncation=False, padding=False. Theirattention_masksums orinput_idsrow lengths give each pair's token count. - Execution. Pairs go in chunks of
pair_batch_size(the last may be shorter) to the model's device. The model runs in eval mode under no-grad. Every submodule's training flag is restored on success and failure. A model error becomesProviderFailure. - Truncation, reported. Pairs are measured untruncated first.
truncation="only_first"(default) cuts the premise tomax_lengthand marks which pairs were cut inraw["truncated"]. A hypothesis that leaves no room for the premise (measured with an empty premise) is refused (InvalidDecisionRequest) before any model call.truncation="error"refuses an over-long request before any model call. - Records, never calibrated. Each result's
rawholds the pair logits, the truncation report andprovider.record(), with"calibrated": False. The record holds the templates, label ids, scoring methods, truncation policy and modelrevision. - Transfer is empirical. These are zero-shot scores from a model trained
for another task, not calibrated probabilities. How well they transfer to
a decision task stays empirical. Measure it on labelled records, as
examples/decision_nli.pydoes. Report accuracy, macro-F1, categorical NLL and Brier, and record the split, revision and settings.
It does not extend nnx.embeddings.embed_texts or the FAISS export, whose
signatures are unchanged. Those embed texts with a bi-encoder you trained;
this scores pairs with a cross-encoder you supply.
6. The Jev adapter¶
JevProvider (the jev extra: pip install "thekaveh-nnx[jev]") answers
Choice, Boolean and Score questions about text on Jev models. It goes
through the TypeSafe Python SDK's system_one call; NNx adds no transport
stack of its own. The adapter is tested against typesafe-sdk>=0.7,<0.8
(nnx.decisions.jev.SDK_RANGE). Importing nnx or nnx.decisions never
imports the SDK. When the SDK is missing, building a provider without an
injected client raises an ImportError naming thekaveh-nnx[jev].
from nnx.decisions import Boolean, Choice, JevProvider, Score
with JevProvider(model="jev-1.13.0") as provider: # TYPESAFE_API_KEY from the environment
topic, urgent, severity = provider.decide_many(
[Choice("Topic?", (("t-bill", "billing"), ("t-ship", "shipping"))),
Boolean("Does this need an answer today?"),
Score("How severe?", (("s0", "minor"), ("s1", "serious"), ("s2", "critical")))],
texts, # one system_one request per text
)
topic[0].distribution # (("t-bill", 0.7), ("t-ship", 0.3)), in the question's order
topic[0].raw # {"provider": "jev", "model": "jev-1.13.0", "requested_model": "jev-1.13.0",
# "request_id": ..., "usage": {...}, "provider_confidence": 0.7}
- Translation. The prompt is the
instructions, and each question becomes a Jev type:- a Choice becomes a
choicekeyed by its options' descriptions — the model-facing text; option ids are never sent; - a Score becomes a
scorewhose criteria are its levels' descriptions, lowest first; - a Boolean becomes a
noul.
- a Choice becomes a
- Batching. Every question of one
decide_manyshares the request for each text (max_questionsis unbounded), so a decision job batches them into it (§7). - Answers. Answers are realigned to each question's own option order and
checked by
validate_response(§2). A Choice whose options repeat a description is refused before any request. A Score's expected score stays invendor_score. - Confidence. A Choice's or Score's
confidenceis Jev's own certainty in its selection, recorded asraw["provider_confidence"]. It is not a probability that the answer is correct — use the distribution, and measure correctness on labelled records (§8). A Boolean has none. -
Metadata.
rawis plain JSON with these fields:provider;- the
modelthe service resolved (even when an alias was requested); requested_modelwhen one was set;request_idand the reportedusagetoken counts.
A field the service did not report is absent. Clients, headers and credentials are never recorded, and
record()(the benchmark identity) holds none either. -
Retries. The SDK's
RetryPolicyowns retries. Passretry=when the provider builds its client, or configure the client you inject. The adapter sends each request once and adds no retry loop, nor does a decision job or the benchmark. -
Failures. What still fails is typed and keeps the SDK error as
__cause__. When the service answered, it also keeps itsrequest_id(a timeout or a lost connection has none). All areProviderFailures:JevTimeout(also aTimeoutError);JevAuthenticationError(401 / 403);JevRateLimited(429, withretry_after_ms);JevMalformedResponse(also anInvalidDecisionResponse): an unparseable body, a missing answer or a distribution that does not fit;JevErrorfor the rest.
Crossing a decision job or the benchmark keeps the type and request id (
JobFailed.__cause__, a record'sreason). -
Cost. Every text is its own request, so one provider call over
ntexts sendsnrequests. A benchmark'sBudget.max_callsand a job'sLimits.max_requestscount provider calls, not requests. Setmax_batchto bound the requests per call. A failure on one text stops the call; answers already received for earlier texts are not returned. - Injected clients. A
client/async_clientyou inject stays yours. The provider never closes it and never builds a second client beside it.- With only a sync client injected, the provider is sync-only.
adecide/adecide_manyareNone, soDecisionJob.arunruns it in its worker thread, one call at a time. - With only an async client injected, the sync methods are refused.
- With only a sync client injected, the provider is sync-only.
- Built clients. With none injected, the provider builds its own,
lazily, at the first call. It uses
api_key/base_url/timeout/retryor the SDK's environment variables; a missing key fails as aJevError.close()(with) closes a built sync client, andaclose()(async with) closes both — after an error or a cancellation too. - Event loops. A built async client belongs to one event loop. A call or
aclose()from another loop drops it with aResourceWarning, so close it withasync withinside eachasyncio.run. - Async.
adecide/adecide_manyuse the async client (built, or injected);DecisionJob.arunawaits them directly.
Ordinary tests need no credentials or network. tests/test_decision_jev.py
drives a recording fake of system_one that returns real SDK responses.
examples/decision_jev.py runs real SDK clients
over an offline mock transport, sync and async.
The opt-in live smoke, scripts/smoke_jev_live.py, sends one request to the
pinned jev-1.13.0 (never an alias). It prints the resolved model, SDK
version, request id, token usage and wall time. It spends real quota, so CI
never runs it.
7. Decision jobs: batching and chaining¶
A DecisionJob is an immutable, deferred description of decision work.
Building one calls no provider, runs no callback and draws no RNG; run and
arun are the only effect boundaries.
from nnx.decisions import DecisionJob as Job, Follow, Limits
flags = Job.collect({ # independent questions, keyed
"goal": Job.ask(Boolean("Mentions goal"), id="goal"),
"bank": Job.ask(Boolean("Mentions bank"), id="bank"),
})
routed = Job.ask(topic, id="topic").then( # a dependent question
lambda answers: Follow(Job.ask(keeper, id="keeper"), state=sports_texts(answers))
)
result = Job.collect({"flags": flags, "routed": routed}).run(provider, state=texts, limits=Limits(max_questions=8))
result.value["flags"]["goal"] # one result per text
result.outcomes["keeper"].kind # "answered"
result.calls # provider calls made
| Builder | Value |
|---|---|
Job.ask(question, id=..., policy=None, model_id=None) |
the provider's results, one per input row; with an abstention policy (concepts §19), the selective decisions |
Job.collect({key: job, ...}) |
a mapping of each key to its job's value, in the given key order |
job.map(fn) |
fn(value): a pure function, no provider call |
job.then(fn) |
fn(value) returns Follow(next_job, state=...); the value is the next job's |
- Batching. Independent questions over the same state are sent in the
fewest calls:
ceil(count / cap)per state object. Continuations that return the same object share it; equal but distinct objects do not. Questions go in the order they were collected, keeping their ids and thecollectkey order. - The batch cap.
capis the provider's. A provider withdecide_many(questions, inputs)declaresmax_questions(None: any number per call). A provider with onlydecide, such asFixedHeadProvider, answers one question per call.Limits.max_questionslowers it. -
Refused before any call. These raise
InvalidJobwith no call made:- duplicate question ids;
- a question the provider cannot serve (its
check(question, inputs)when it has one, elsecapabilities().check(...)); - a
Limits.max_tokenscap the provider cannot enforce (it needscount_tokens(questions, inputs)); - an abstention
policywhose labels ormodel_iddo not fit its question (checked byask).
A continuation's questions are checked when the continuation runs, before they are sent.
-
Dependent questions. A
thencontinuation runs once, after its prerequisite succeeded. It maps the answer explicitly into the follow-upstate; it never feeds a sibling question.Limits.max_depthbounds nesting andLimits.max_requeststhe run's calls (JobLimitExceeded). - Answers are checked. A call's answers must hold one result list per
question and one result per input row. Each must be a result of that
question (its digest). Answers in the wrong order, short rows or a flat
list are an
InvalidDecisionResponsefailure, never filed under the wrong id. - Fail-fast. Nothing more is scheduled when a call raises, or when a
provider hook fails. A hook fails when its
check,capabilities()orcount_tokensraises anything but a declared refusal. - What
JobFailedcarries.JobFailedcarries four fields:outcomes(every outcome so far, in scheduling order) andcompleted(the answered ones). Alsofailed(the questions of that call) andskipped(known questions never sent).- Continuations waiting on a failed answer never run, and nothing completed is re-run.
- A job refused before any call has no outcomes and nothing skipped.
- The error pickles across a process boundary. A provider error that
does not survive the round trip is replaced by a
ProviderFailurenaming it. - The job never retries: retries belong to the provider.
- A partial result is only what the error carries:
JobFailedhas novalue.
- Async.
await job.arun(provider, state=..., limits=..., cancel=None)runs up toLimits.max_concurrencycalls at once through the provider'sadecide_many/adecide.arunstill needs the provider's synchronousdecide(the decision protocol);adecide/adecide_manyare used when present. - Synchronous providers. A provider with only synchronous methods is called in a worker thread, never alongside another call. So its methods need not be thread-safe. A running thread cannot be interrupted, so the run waits for it before returning. A cancellation that arrives meanwhile is delivered once it is done.
- The
cancelevent. Setting thecancelevent stops scheduling; no continuation runs after it. The run returns aJobResultwithstatus="cancelled"and no value.- The event is an
asyncio.Eventof the running loop. One bound to another loop is anInvalidJob, never a cancellation. - The event counts if set at any point, even if cleared again.
- A cancel set in the same tick as the last answer still cancels the run: its result has no value.
- The event is an
- Other cancellations. A cancellation the provider raises itself is a provider failure, not a cancelled job. Cancelling the task cancels only the job's own tasks and re-raises.
- Timeouts and limits.
Limits.timeoutraisesJobTimeout. A request or depth limit lets the calls already in flight finish, then raisesJobLimitExceeded. A request whose call began is reported withsent=True, never as rolled back: whatever the provider did with it stays done. - Hooks in
arun. The provider's hooks (check,capabilities(),count_tokens) are synchronous and run on the event loop inarun. Keep them local and fast (no network round trip). The timeout is checked between hook calls, so a slow hook overruns it by at most one call. - Outcomes.
result.outcomes[id].kindis one of four kinds, and the four never blur:"answered"(rows may still be"abstained"under a policy);"failed"(witherror);"skipped"(never sent: a failure, a limit or the timeout stopped scheduling);"cancelled"(withsent): the run was cancelled, a sibling call failed first, or the timeout elapsed while the call was in flight (arun).
- The provider is borrowed.
runandarunnever close it, and it stays usable after a failure or a cancellation. - Serialisation. A job of
askandcollectonly pickles as plain data (job.state()). A job holding a runtime function (map,then) refuses to pickle: rebuild it where it runs.
Limits of value equivalence. Two equalities hold when the provider is
deterministic and answers a question the same way whatever it is batched
with. job.map(lambda x: x) and job have the same value, and
job.map(f).map(g) equals job.map(lambda x: g(f(x))). A sampling provider,
or one whose answer depends on the other questions in its call, keeps the
job's call structure but not that equality.
Batching is computational, not statistical. Answers asked together are separate marginals, and the job never multiplies them into a joint probability.
What it is not. It is not LogitsChainBuilder. That is a mutable builder
of LM-decoding processors that build() sorts into a canonical order, while
a job is immutable and describes provider requests. It is not an
ExperimentPlan (nnx.plans). A plan compiles a training run, while a job
only asks questions of an already-trained or hosted provider.
examples/decision_jobs.py runs batching, a
continuation, fail-fast and the async path end to end.
8. Benchmarking providers¶
nnx.decisions.benchmark scores decision providers on identical
samples, offline first. A live collection runs once. Every report after
that is a replay of saved records: no provider call, no credentials, no
network, nothing fitted.
from nnx.decisions.benchmark import Budget, Sample, collect, evaluate, read_records, write_records
samples = [Sample("pet-0", question, row, "sp-cat", family="pets"), ...]
collection = collect(provider, samples, provider_id="fixed-head-v1", budget=Budget(max_calls=20))
write_records("records.jsonl", collection.records) # once, live
report = evaluate(samples, read_records("records.jsonl"), split="animals-v1") # any time, offline
print(report.text())
- Samples carry the join key (
id) and the typed question (its digest is the schema identity). They also carry the provider input, the true label and the taskfamily(heldout=Truefamilies report apart). Finally, they carry the grouping unit (group) and theperturbationthat produced them. A sample's input is a text, a tensor or array, or a tuple of them (several inputs per sample, batched part by part). - Perturbations. Perturbed samples keep their original's grouping unit.
These helpers derive them:
permute_options;redescribe(new label descriptions);add_distractorsandadd_none_of_the_above;add_context(long irrelevant context);rewrite_input(a multilingual or adversarial rewrite of the text input).
- Replay format. One
Recordper provider output, one JSON object per line (nnx.decision-record/1, strict JSON). Each record holds:- sample id, question digest, provider and status;
- the answer (
distributionorp_true), or thereasonthere is none; - model
revision; prompt_identity(by default a digest of the provider'srecord());executionmetadata (batch, size,partial_batch, seconds, attempt).
-
Coverage statuses. Records join samples by sample id and question digest, never by row position. Each sample has one status:
eligible(one answered record);missingorduplicate;mismatched(a record for another question digest);invalid(an answer that does not fit its question);unsupportedorfailed(each with its reasons).
Records for no sample are the report's
extracount. The CSV carries every count, so they add up to each slice's samples. -
Capabilities. A provider may declare that it cannot serve a request. For instance,
FixedHeadProviderdeclares so outside its label space (or itsoption_map), or when asked a Boolean other than its own. It declares through its owncheck(question, inputs)or, without one, its declaredcapabilities().- Such a request gives
unsupportedrecords with the provider's own reason, before any call and without spending budget. - A check that itself raises gives
failedrecords, also without a call. - These records are counted in the slice's coverage, never in a metric's denominator, and never scored as wrong.
- Such a request gives
- Batch size. Batches never exceed the provider's declared
max_batch. - Budgets.
collectneeds an explicit provider, provider id andBudget(max_calls, max_samples=None). It attempts each batch once (retries are the provider's own). It stops when the budget is spent: the rest ismissing, andCollection.stoppedsays why. It marks a batch the sample budget cut short aspartial_batch. - Answer checks. Each answer is checked against its sample's question (digest) and put into the question's option order, one by one. A malformed answer fails its own sample only. A provider error or inputs that cannot form one batch fail the batch. Neither ends the collection.
-
Metrics, per slice (
in_family,heldout,family:<name>,perturbation:<name>):- accuracy and macro-F1;
- NLL, exact by default:
+infwhen a true label has probability 0;epsilon=floors it; - Brier, and ECE with reliability bins;
- given an
nnx.abstentionpolicy, applied as given, selective coverage and risk. The policy applies to Choice and Score rows only. Boolean rows and rows the policy does not fit are left out of the coverage denominator, and its note says how many.
Each is a
MetricValuewith its denominator, or unavailable with the reason; a note qualifies an available value. NLL and Brier follow the definitions of the namednll/briermetrics. One difference: the replay NLL is exact by default, where the namednllfloors probabilities at1e-12. A Boolean is the options("true", "false"). -
Resources (
Resources) say how cost was obtained: warmup, hardware, timing boundary, concurrency, batch count and seconds. They also say whether the numbers weremeasured(hardware required) orsupplied. Anything not declared staysnull. - Intervals.
bootstrap_intervalresamples grouping units (whole groups, never single rows of one) with a recorded seed. It runs over the same provider's recordsevaluatescores. It flags a degenerate interval: fewer than two units, no variation, bounds that coincide, or a non-finite bound such as an exact NLL of+inf. The reason says which. - Variant ids. Perturbation helpers give each variant a distinct id, or
the
id=you pass. The id is the original id, the kind and a digest of the change. The digest is the same in every process for text, numeric arrays and tensors, tuples of them and JSON-able values. An input with no stable digest needsid=. - Exports.
to_json(),to_csv()andtext()agree on units, eligible, failure andextracounts, unavailable states and notes (the CSV'snotecolumn). A non-finite value is"Infinity"in the JSON and the CSV. - Comparison.
compare_reportsgivesb - aper slice and metric only for reports that share all of these:- the same split;
- the same metric identity (metric set,
epsilon,n_bins, policy); - the same sample set (a digest of the samples' ids, questions, labels and slicing), when both reports record it.
- Duplicates.
collectrefuses duplicate sample ids before any call.
It is not a leaderboard. No paid remote run is a default, and it never
tunes a threshold or a prompt on test outcomes.
examples/decision_benchmark_offline.py
collects once and replays with sockets disabled.
9. Offline teacher distributions¶
A decision provider's answers are distributions over a question's options.
Stored, they can teach a student offline: nnx.paradigms.offline_distillation
trains on them without calling the provider again:
from nnx.paradigms.offline_distillation import TeacherDataset, TeacherRecord, write_teacher_records
records = [
TeacherRecord(
sample_id=sample.id, question_id="pet-kind",
candidates=tuple(option_id for option_id, _ in result.distribution),
probabilities=tuple(p for _, p in result.distribution),
teacher="fixed-head-v1", revision="r42", schema_digest=question.digest(),
semantics="predictive",
provenance={"source": "records.jsonl, 2026-09 collection", "training_rights": "declared: internal use"},
label=sample.label,
)
for sample, result in answered
]
write_teacher_records("teacher.jsonl", records)
data = TeacherDataset(records, inputs, candidates=("sp-fox", "sp-cat", "sp-dog"), schema_digest=question.digest())
student.train(params=..., objective=data.objective(alpha=0.7))
- What a record states. The sample and question ids, and the ordered
candidate ids (the question's option ids) with their probabilities. Also
the teacher and its revision, the schema digest (the question's
digest()), the probability semantics and the provenance. - Record validation. A record without one of these fails validation.
read_teacher_recordsnames thepath:lineof a malformed line in itsTeacherRecordError, a number too large for a float included. - Provenance is declared, not verified.
provenancenames the record'ssourceand itstraining_rights, as the exporter declares them. The rights state how the record may train a student. NNx checks that the declaration is there; it cannot check that it is true. Whether a provider's terms allow training on its outputs is yours to establish. - Probabilities, not logits. Live distillation
(
kd_train_step_factory,kd_objective) softens a running teacher's logits at a temperature and scales the KL byT². A stored distribution has no logits to soften. So the offline objective isKL(teacher ‖ student)at temperature 1, plus an optional hard cross-entropy on the label — nothing invented, noT². - Agreement is not quality. The reports keep teacher agreement and imitation loss (over every record) apart from labelled quality (over the labelled records). Each has its own denominator. A student that imitates its teacher perfectly is wrong wherever the teacher is.
See Concepts §10.1 and
examples/offline_teacher_distillation.py.
10. Errors¶
All are nnx.decisions.DecisionErrors, and their names are stable:
| Error | Raised for |
|---|---|
InvalidDecisionRequest (also a ValueError) |
malformed questions: fewer than 2 options, duplicate or empty ids, empty text; misconfigured adapters |
InvalidDecisionResponse (also a ValueError) |
responses that do not fit their question (see §2) |
UnsupportedCapability |
undeclared primitives, modalities, batch sizes or label spaces — before any model call |
ProviderFailure (also a RuntimeError) |
the backend failing on a valid, supported request |
JevError and its JevTimeout, JevAuthenticationError, JevRateLimited, JevMalformedResponse |
a Jev request failing (§6): __cause__ is the SDK error, request_id the service's |
JobError |
the base of the decision-job errors (§7); outcomes holds every outcome so far, completed the answered ones |
InvalidJob (also a ValueError) |
a job that cannot run as described — before any call or, for a continuation's questions or return value, when the continuation runs |
JobFailed |
a provider call failing inside a job (fail-fast): failed, skipped, __cause__ |
JobLimitExceeded |
a job's max_depth or max_requests stopping it before the next call |
JobTimeout (also a TimeoutError) |
a job's timeout elapsing |
10.1. Errors as values: nnx.result¶
Every API above raises. A caller may rather branch on a value — when
validating a batch of user-supplied requests, say. For that, the opt-in
nnx.result module offers Ok / Err and boundary wrappers; nothing else
changes. The same flow both ways:
# Exceptions
try:
question = question_from_state({"kind": "choice", "prompt": prompt, "options": options})
except InvalidDecisionRequest as error:
report("invalid_decision_request", error)
raise
try:
results = provider.decide(question, texts) # a provider's own InvalidDecisionRequest propagates
except UnsupportedCapability as error:
report("unsupported", error)
except InvalidDecisionResponse as error: # before ProviderFailure: a malformed Jev answer is both
report("invalid_decision_response", error)
except ProviderFailure as error:
report("provider_failure", error)
# Result
from nnx.result import decide_result, validate_decision_request_result
outcome = validate_decision_request_result("choice", prompt, options).bind(
lambda question: decide_result(provider, question, texts)
)
# Ok(results) — or Err(BoundaryError(code="invalid_decision_request" | "invalid_decision_response"
# | "provider_failure" | "unsupported", ...))
validate_decision_request_result(kind, prompt, options)builds the question from plain data inside its boundary. AnyInvalidDecisionRequestbecomesErr(code="invalid_decision_request"), and the provider is never called. That covers duplicate or empty ids, too few options, an empty prompt and an unknown kind.-
decide_result(provider, question, inputs, policy=None, model_id=None)maps three error types to codes:- any
InvalidDecisionResponse— an answer that does not fit the question, such as aJevMalformedResponse— becomes"invalid_decision_response"; - any other
ProviderFailure(an outage, a timeout, a refused credential, a rate limit) becomes"provider_failure"; UnsupportedCapabilitybecomes"unsupported".
The first two keep the error's
request_idin the context. Apolicythat does not fit raises before the provider is called, and is not returned as anErr. That is a policy that is not anAbstentionPolicy, or one for aBooleanquestion, other labels or anothermodel_id. - any
-
Codes follow the type. The codes follow the exception type, not whether a retry would help. A refused credential is a
"provider_failure"too, so branch on thecausewhen that matters. AJevProvidercomposes as is: each typed Jev error is itscause. - Abstention. With an abstention policy, abstaining is a success: the
Okholds each row'sSelectiveDecision, probabilities and reason included. - Each
Errholds aBoundaryError(code, where, context, cause); any other exception propagates. - Combinators and unwrapping.
- Combinators (
map,bind,map_error,recover) never catch a callback's exception. - A
bind/recovercallback that returns a non-ResultraisesTypeError. unwrap()on anErrraisesUnwrapErrorwith the typed error.- A
Resulthas no truth value (Ok(0)is a success). Branch withisinstance(outcome, Ok)ormatch, which also narrow the type for a checker.
- Combinators (
See Concepts §27 and
examples/result_boundaries.py.
11. Consumers¶
Decision features share this digest, the kind discriminators and
validate_response rather than defining their own. The schema and provider
core of nnx.decisions depends on none of them, although the package
re-exports the jobs, NLI and JEV features. The consumers are:
- decision jobs (§7);
- the provider benchmark (§8);
- offline teacher-distribution datasets (§9);
- the local label-conditioned baseline,
NLIProvider(§5); - the Jev SDK adapter,
JevProvider(§6); - the optional
Resultat fallible boundaries,nnx.result(§10.1).
12. What this does not do¶
- It does not claim every classifier is a universal decision-maker: the fixed-head adapter answers only what its head justifies.
- It does not execute actions based on a decision.
- It does not assume that separately asked questions describe independent events; each question is answered on its own.
- It does not call hosted models except through an adapter you build.
JevProvider(§6) is the only one. It needs thejevextra and your credentials, and NNx never falls back to it silently. It does not fine-tune Jev models or ship credentials, and vendor numbers are not benchmarks — measure them (§8). - It does not download or train models:
NLIProvideruses the NLI model the caller supplies, as is, and its scores are not calibrated. Training a student on stored answers isnnx.paradigms.offline_distillation(§9).