Decisions: pick from a list, with a probability¶
locus.decision asks a model questions with a fixed list of answers and gets
back a probability for every answer, from one forward pass.
Why not just generate¶
Much of what an agent decides is not writing. Is this message meant for me? Which of these thirteen commands is it? Does this reply ask for a home address? A model that writes answers such a question in a sentence, slowly, and says nothing about how sure it is. The caller then parses text, and has no number to put a threshold on.
A decision model answers the way a classifier does. Every listed answer gets a probability, read from the logits at one position. Nothing is generated past the first token. On a GPU a small trained head answers in a few milliseconds; quantised on a CPU, in tens of milliseconds. And the probability is the thing a threshold can be set on, and certified.
Fields, answers, decisions¶
from locus.decision import Choice, LogprobDecider, Score, YesNo
intent = Choice("intent", "What does the speaker want the companion to do?",
("come", "follow", "build", "give", "chat"))
personal = YesNo("personal", "Does it ask for or share personal details?")
upset = Score("upset", "How upset is the speaker?", 5) # levels "1".."5"
decider = LogprobDecider("http://127.0.0.1:8000/v1", model="my-head")
decision = await decider.decide("can u build me a castle", [intent, personal, upset])
decision["intent"].label # "build"
decision["intent"].probability # 0.97
decision["intent"].margin # top minus runner-up
decision["personal"].p_yes # 0.003
decision["upset"].expected # probability-weighted level in [0, 1]
decision["intent"].coverage # mass the listed letters had before renormalising
coverage is worth watching. It is the share of the model's probability that
went to the listed answers at all. Near 1, the model answered the question. Low,
it wanted to say something else, and the renormalised distribution is a model
out of its depth, whatever its argmax.
A provider that cannot read an answer raises DecisionError. It never guesses:
a caller thresholding a probability would act on the guess.
The wire format¶
The format is a contract, so anyone can train a head for it and any server can serve the head. Each field of an input is one chat request:
system: You answer one question about the input. Reply with the letter of one listed answer and nothing else.
user: [input]
<the input>
[question]
<the question>
[answers]
A) <first answer>
B) <second answer>
...
Answer with one letter.
YesNo is always A) yes, B) no. Up to 26 answers. The input comes first, so
the fields asked of one input share a prefix and a server with prefix caching
computes it once. render() and SYSTEM_PROMPT produce it, and a unit test pins
it character for character.
To train a head: render each labelled example with render(), put the answer's
letter as the assistant turn, and fine-tune. A head trained this way needs no
glue code to be served.
The free, local default: any OpenAI-compatible server¶
LogprobDecider sends, per field:
{"model": "my-head",
"messages": [{"role": "system", "content": "<SYSTEM_PROMPT>"},
{"role": "user", "content": "<render(...)>"}],
"max_tokens": 1, "temperature": 0, "logprobs": true, "top_logprobs": 20}
and reads the letters from choices[0]..
Whitespace around a token is ignored, so " A" counts for A. Fields of one
input go concurrently. extra_body adds options under those keys and cannot
replace them; a Qwen3 head wants {"chat_, so the first token is the answer and not the start of a thought.
It needs nothing but a server that returns logprobs:
vllm serve ./my-head --served-model-name my-head --enable-prefix-caching
llama-server -m my-head-q4_k_m.gguf --port 8080 # a CPU is enough for 0.6B
A general instruction model works too, zero-shot, through the same rendered question. A head trained for the questions answers them more sharply.
In the admission gate¶
A decision model never grants anything. Two seams, both escalate-only:
An admit head as a ControlAdvisor. approve() consults an advisor only
when policy already allows, and takes max(policy, model) on allow <
require_human < deny.
from locus.control import approve
from locus.decision import DecisionAdvisor
advisor = DecisionAdvisor(decider) # argmax of allow / require_human / deny
advisor = DecisionAdvisor(decider, hold_at=0.12) # or: hold when 1 - P(allow) >= 0.12
decision = approve(action, policy=policy, advisor=advisor)
A head that is down, slow to error or off its list is no opinion, and the policy decision stands.
Safety heads as verification. verification_from_decision() turns yes/no
answers into the VerificationResult that ControlPolicy.
weighs:
from locus.decision import verification_from_decision
safety = await decider.decide(reply_text, [personal, meeting, unkind])
verdict = verification_from_decision(safety, {"personal": 0.21, "meeting": 0.30, "unkind": 0.18})
approve(say_action, policy=policy, verdict=verdict)
A head at or past its threshold is a fatal refutation, and approve() denies.
Below every threshold, confidence is 1 - max(P(yes) / threshold), so the
policy's verification bar decides how near a threshold is near enough to want a
person.
Thresholds belong to weights¶
A threshold is worth something only if it was chosen on held-out data the head never trained on, and stated with a bound: with probability at least 1 - delta, the miss rate at this threshold is at most alpha. A Neyman–Pearson order statistic over the held-out positives gives exactly that, with no assumption about the model. The bound holds for the weights it was measured on and nothing else. Ship the threshold with the model; recalibrate when the model changes; and note that it covers ordinary traffic, not an adversary searching against the score. The escalate-only seams are what hold there.
Per tenant¶
TenantDecisionRouter is the same protocol for many tenants:
from locus.decision import LogprobDecider, TenantDecisionRouter, per_tenant_trails
router = TenantDecisionRouter(
lambda tenant: LogprobDecider(url, model=f"head-{tenant}") if tenant in trained else None,
audit_for=per_tenant_trails("audit/"), # one hash-chained file per tenant
)
decision = await router.decide(text, fields, tenant="acme")
advisor = DecisionAdvisor(router.for_tenant("acme"))
- A tenant is answered only by its own head: its own model, or its own LoRA adapter on a shared base (vLLM serves an adapter under its own model name).
- An unknown tenant is refused. A
public=head may stand in only when you name one, it must be trained on no tenant's data, and the record says it was used. - Every decision lands on that tenant's audit chain: field names, labels,
probabilities, model, latency. Not the input, unless
record_text=True— it may be a customer's words, or a child's. - If the record cannot be written, the decision is not returned.
per_tenant_trails() is the zero-infra form of the audit side. A hosted
deployment backs the same audit_for with each tenant's chain in a
row-level-secured database, and serves the heads behind the same protocol.