decidr
Typed decisions from an LLM, in one forward pass. Give it a question and a set of named options; get back which one the model picked and a real probability for each, without the model generating a single word.
pip install decidr
Quickstart: local Ollama
Needs Ollama running with a model already pulled. No fine-tuning, no other setup.
from decidr import Client
client = Client(model="qwen3.5:4b")
decision = client.decide({
"id": "ticket-1",
"state": "Customer cannot access their account after a password reset. The reset email never arrived.",
"question": "Which team should handle this?",
"options": [
{"id": "access", "description": "Account access and authentication issues."},
{"id": "billing", "description": "Billing and payment issues."},
{"id": "sales", "description": "Sales and product questions."},
],
})
decision.choice # 'access'
decision.confidence # 0.9965
decision.probabilities # {'access': 0.9965, 'billing': 0.0017, 'sales': 0.0018}
That's the whole thing: state is what the model reads, question is what it's deciding, options is the closed set of possible answers, each with an id you'll match on in your own code and a description for the model to read.
Quickstart: a hosted model (OpenAI, Anthropic, etc.)
pip install decidr[litellm]
Same decide() call, same row shape — only the Client construction changes:
from decidr import Client, LiteLLMBackend
client = Client(
model="gpt-4o-mini",
backend=LiteLLMBackend(api_key="sk-..."),
)
decision = client.decide({
"id": "ticket-1",
"state": "Customer cannot access their account after a password reset. The reset email never arrived.",
"question": "Which team should handle this?",
"options": [
{"id": "access", "description": "Account access and authentication issues."},
{"id": "billing", "description": "Billing and payment issues."},
{"id": "sales", "description": "Sales and product questions."},
],
})
LiteLLMBackend routes through LiteLLM, so anything LiteLLM can reach, decidr can: model="claude-3-5-haiku-20241022" with LiteLLMBackend(api_key="...") for Anthropic, model="bedrock/anthropic.claude-3-haiku..." for Bedrock, and so on — the model string is whatever LiteLLM expects for that provider. Details and a real caveat worth knowing first: docs/PROVIDERS.md.
Writing a row
| Field | Type | Meaning |
|---|---|---|
id |
string | Your own identifier for this decision, echoed back on the result. Not sent to the model. |
state |
string, dict, or list | The evidence the model reads to make the call. |
question |
string | What it's deciding. |
options |
list of {id, description} |
The closed set of answers. id is what comes back as decision.choice; description is what the model reads. |
Option ids: lowercase letters/digits in underscore-separated segments (billing, access_denied), under 40 characters, and no id may be a prefix of another (billing and billing_refund can't coexist — see docs/NAMING_IDS.md). This is checked before any request is sent, so a bad id fails immediately with a message naming the problem, not partway through.
There's no cap on how many options a decision can have. Above a handful, decidr uses the ids' own underscore segments as a hierarchy to keep every individual comparison small and reliable instead of racing everything at once — see docs/HIERARCHY.md for what that means for your ids and for request cost.
Reading a Decision
| Field | |
|---|---|
choice |
option id with the highest probability |
confidence |
probability of choice |
probabilities |
option id → probability for every option that was actually compared |
unscored |
options that genuinely could not be measured — a real gap, be cautious about trusting the result if this is non-empty |
eliminated |
options that lost a real comparison but weren't explored further (only with exhaustive=False, see below) — not a gap, just not a full distribution |
is_reliable() |
False when unscored is non-empty; unaffected by eliminated |
logprobs |
raw log probabilities behind probabilities, before normalizing |
raw_answer |
the model's own first reply, for debugging |
if not decision.is_reliable():
# some option's real answer never showed up in the model's response at
# all -- decide what your app should do here (retry, fall back, flag it)
...
route_to(decision.choice)
Calibration
confidence out of the box is directional, not a calibrated probability — a raw model's logits usually aren't well-calibrated. If you have labeled examples (rows where you already know the right id), fit a correction:
from decidr.calibrate import fit_temperature, evaluate_out_of_fold
labeled = [(client.decide(row), row["correct_id"]) for row in your_labeled_rows]
evaluate_out_of_fold(labeled, folds=5)
# CalibrationResult(temperature=1.8, n=200, ece_before=0.15, ece_after=0.06, accuracy=0.83)
temperature is a scalar you can then pass to Client(..., temperature=1.8) to make future confidence values track real accuracy more closely. It only rescales confidence — choice never changes.
API
Client(model, host="http://127.0.0.1:11434", temperature=1.0, backend=None, exhaustive=True)
model— the model name, meaning depends on the backend (an Ollama tag by default, a LiteLLM-style model string withLiteLLMBackend).host— only used when nobackendis given; builds the defaultOllamaBackend(host=host).backend—OllamaBackend()(default) orLiteLLMBackend(...)for a hosted provider. See docs/PROVIDERS.md.exhaustive—True(default) compares every option against every sibling for a fullprobabilitiesdistribution, more requests.Falsefollows only the winning branch at each level, fewer requests, incomplete branches go toeliminated.
client.decide(row) -> Decision — one decision. client.decide_all(rows) -> list[Decision] — a list, sequentially.
Why this works without the model generating anything
A model computes a probability over its whole vocabulary before it samples a word. decidr reads that distribution directly — one forward pass, no decoding loop, no text to parse back into a decision. It's not novel as a technique (TypeSafe's Jev and SemIf both do versions of it); this is a small, dependency-light implementation of it.
Reading it out reliably is the actual engineering problem, and it's covered in three linked docs rather than here, since none of it is required to just use the library:
- docs/PREFIX_MATCHING.md — how a multi-token option id gets scored at all, since there's no way to ask a model how a string tokenizes in advance
- docs/HIERARCHY.md — why large option sets need more than one comparison, measured, and what
eliminatedactually means - docs/PROVIDERS.md — the
Backendinterface, LiteLLM specifics, writing your own
Limitations
- Sequential, no batching.
- An option can land in
unscoredif its real next token misses the model's top-20 logprob window within one comparison — rare with a reasonable hierarchy, not impossible. LiteLLMBackendis unit-tested against LiteLLM's documented response shape; a live call to a real hosted provider hasn't been run in this project (no API key available here).- Verified against
qwen3.5:4b. Small models (under 1B parameters) often won't treat a bare option id as a plausible next answer at all.
License
MIT
Metadata
Release files for decidr 0.7.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| decidr-0.7.1.tar.gz | 37.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| decidr-0.7.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 59.4 kB
Release files / decidr-0.7.1.tar.gz
| Download URL | decidr-0.7.1.tar.gz |
|---|---|
| Size | 37.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
958e68de3230df22309af1442266c823a9036f317111edf1482396868b566543
|
|
BLAKE2b-256 checksum How to use checksums |
f95ba0214fda75c486249d1e09a272264872b8e884059d4f22029a52acdec613
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / decidr-0.7.1-py3-none-any.whl
| Download URL | decidr-0.7.1-py3-none-any.whl |
|---|---|
| Size | 21.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a15cc8d62f90ea929c199a3dd9f0da067e40226a93adfb25dfa6cc3c929d9878
|
|
BLAKE2b-256 checksum How to use checksums |
3ebddc5c5534cc44831d56ab09b3feb855a0d98383afabd92fad23f7fbdb7f68
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|