Skip to main content

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 with LiteLLMBackend).
  • host — only used when no backend is given; builds the default OllamaBackend(host=host).
  • backend — OllamaBackend() (default) or LiteLLMBackend(...) for a hosted provider. See docs/PROVIDERS.md.
  • exhaustive — True (default) compares every option against every sibling for a full probabilities distribution, more requests. False follows only the winning branch at each level, fewer requests, incomplete branches go to eliminated.

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 eliminated actually means
  • docs/PROVIDERS.md — the Backend interface, LiteLLM specifics, writing your own

Limitations

  • Sequential, no batching.
  • An option can land in unscored if its real next token misses the model's top-20 logprob window within one comparison — rare with a reasonable hierarchy, not impossible.
  • LiteLLMBackend is 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)

Source distribution for decidr 0.7.1
File Size Uploaded
decidr-0.7.1.tar.gz 37.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for decidr 0.7.1
File Interpreter ABI Platform
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

Release history Release notifications | RSS feed

0.9.0

2 release files

0.8.0

2 release files

This release

0.7.1 This release

2 release files

0.7.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page