decidr 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
decidr-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anmol Sharma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
decidr-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.5
2
+ Name: decidr
3
+ Version: 0.1.0
4
+ Summary: Typed decisions from local LLMs in one forward pass, via Ollama
5
+ Project-URL: Homepage, https://github.com/devanmolsharma/decidr
6
+ Project-URL: Issues, https://github.com/devanmolsharma/decidr/issues
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: classification,decisions,llm,logprobs,ollama,routing
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Requires-Python: >=3.10
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=7; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # decidr
21
+
22
+ **Typed decisions from local LLMs, in one forward pass.**
23
+
24
+ Most decisions an application asks an LLM to make are small: *route this ticket*, *is this evidence sufficient*, *how angry is this customer*. A chat model can answer them, but it generates a sentence, or JSON, which your code then parses back into an `if` statement.
25
+
26
+ `decidr` skips that. It gives the model your options, runs **one forward pass**, and reads the probability of each option directly out of the model's own logits. No answer sentence. No JSON to repair. No decoding loop.
27
+
28
+ ```python
29
+ from decidr import Client
30
+
31
+ client = Client(model="qwen3.5:4b")
32
+
33
+ decision = client.decide({
34
+ "id": "route-1",
35
+ "state": "Customer cannot access an account after a password reset. The reset email never arrived.",
36
+ "question": "Which queue should handle this request?",
37
+ "options": [
38
+ {"id": "access", "description": "Account access and authentication support."},
39
+ {"id": "billing", "description": "Billing and payment support."},
40
+ {"id": "sales", "description": "Sales and product evaluation."},
41
+ ],
42
+ })
43
+
44
+ decision.choice # 'access'
45
+ decision.confidence # 0.9999
46
+ decision.probabilities # {'access': 0.9999, 'billing': 0.0001, 'sales': 0.0}
47
+ decision.is_reliable() # True
48
+ ```
49
+
50
+ Works with any model you already have in [Ollama](https://ollama.com). No fine-tuning, no extra runtime, no separate model to download.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install decidr
56
+ ```
57
+
58
+ Requires Python 3.10+ and a running Ollama. Zero dependencies — it's stdlib `urllib` and `math`.
59
+
60
+ ## How it works
61
+
62
+ Three things, in order:
63
+
64
+ **1. Options become letters.** Each option is presented to the model as `A`, `B`, `C`… rather than by its own name. This is not cosmetic: real labels like `billing` or `SYS_OUTAGE` are usually *several* tokens, and you cannot read a single-token probability for a multi-token string. Letters are single tokens in every vocabulary. The meaning lives in the descriptions, which is where the model actually reads it.
65
+
66
+ **2. One forward pass, no generation.** The prompt ends where the answer begins, and generation is capped at a single token. Reasoning mode is explicitly disabled — on a hybrid-reasoning model, a `<think>` preamble would put thinking tokens in the answer slot, and the next token would stop being the decision.
67
+
68
+ **3. The scores are read, not sampled.** Instead of taking whichever letter the model emitted, `decidr` reads the log probability of *every* option letter and normalizes over them. You get a distribution, not just a pick — so you can threshold on confidence, route ambiguous cases to a human, or log calibration over time.
69
+
70
+ ## Two modes, picked automatically
71
+
72
+ How completely `decidr` can read those scores depends on your Ollama build. It probes once per process and tells you which mode you're in via `decision.mode`.
73
+
74
+ | Mode | When | Behavior |
75
+ |---|---|---|
76
+ | `ranked` | **Stock Ollama** (what you have today) | Falls back to `top_logprobs` (capped at 20 by the API). Options whose letters don't surface in that window are reported in `decision.unscored`. |
77
+ | `exact` | Ollama with [`logprob_tokens`](https://github.com/ollama/ollama/pull/18580) | Every option's probability is read directly from the full distribution, regardless of rank. |
78
+
79
+ **Why this distinction exists:** stock Ollama can only tell you about tokens that rank in the model's top 20 guesses. For a 3-option decision, the letters almost always make that cut and `ranked` is fine. For a 12-option decision, they often don't:
80
+
81
+ ```
82
+ 12 options, same model, same prompt:
83
+ exact -> scored 12/12 reliable=True
84
+ ranked -> scored 11/12 reliable=False unscored: ['cat9']
85
+ ```
86
+
87
+ `decidr` never invents a number for an option it couldn't measure. It reports it as unscored, `is_reliable()` returns `False`, and the remaining probabilities are normalized over what was actually observed. A fabricated floor value would be indistinguishable from a real measurement, which is the one thing a probability API must never do.
88
+
89
+ ### Getting `exact` mode
90
+
91
+ `logprob_tokens` is a change I've proposed upstream to Ollama — **it is not merged yet**:
92
+
93
+ - Issue: [ollama/ollama#18579](https://github.com/ollama/ollama/issues/18579)
94
+ - PR: [ollama/ollama#18580](https://github.com/ollama/ollama/pull/18580)
95
+
96
+ Until it lands (if it lands), `decidr` works today in `ranked` mode against stock Ollama. Nothing here depends on that PR being accepted — `exact` mode is an upgrade, not a requirement. If you want it now, you can build from [the branch](https://github.com/devanmolsharma/ollama/tree/classification-logprobs).
97
+
98
+ ## API
99
+
100
+ ### `Client(model, host=..., timeout=..., temperature=..., force_mode=None)`
101
+
102
+ - `model` — any Ollama model name, e.g. `"qwen3.5:4b"`.
103
+ - `host` — defaults to `http://127.0.0.1:11434`.
104
+ - `temperature` — applied to the softmax over option logprobs, **not** to sampling. Higher values flatten confidence. Use this to calibrate against a labeled set (see below).
105
+ - `force_mode` — skip the capability probe and pin `"exact"` or `"ranked"`.
106
+
107
+ ### `client.decide(row) -> Decision`
108
+
109
+ `row` needs `id`, `state`, `question`, and 2–16 `options`, each with an `id` and a `description`. `state` may be a string, dict, or list. Malformed rows raise `DecisionError` naming the problem rather than silently producing a confident wrong answer.
110
+
111
+ ### `Decision`
112
+
113
+ | Field | |
114
+ |---|---|
115
+ | `choice` | option id with the highest probability |
116
+ | `confidence` | probability of `choice` |
117
+ | `probabilities` | option id → probability, sums to 1 **over scored options** |
118
+ | `logprobs` | raw log probabilities before normalizing |
119
+ | `mode` | `"exact"` or `"ranked"` |
120
+ | `unscored` | options the server could not report |
121
+ | `is_reliable()` | `False` when anything went unscored |
122
+ | `raw_answer` | the letter the model actually emitted |
123
+
124
+ `client.decide_all(rows)` runs a list sequentially.
125
+
126
+ ## Calibration, honestly
127
+
128
+ The probabilities are **conditional on the option set you supplied** and are *not* calibrated out of the box. A raw instruction-tuned model's logits are not a calibrated probability distribution — they're systematically overconfident, and binary yes/no framings in particular carry a strong prior-driven skew.
129
+
130
+ What that means in practice: `choice` is usually trustworthy; `confidence` is directional, not a true probability. Before thresholding on it (e.g. "auto-route above 0.9"), fit `temperature` against a labeled sample from your own workload and measure whether the numbers track observed accuracy. Purpose-built decision models are trained against proper scoring rules precisely because this step matters.
131
+
132
+ If you need calibrated confidence out of the box more than you need "runs on anything you already have," use a trained decision model instead. `decidr`'s bet is that zero setup is worth more for most use cases, and that you should be told plainly where the limits are.
133
+
134
+ ## What this is not
135
+
136
+ - **Not a new model.** It's a way of reading models you already run.
137
+ - **Not a fine-tune.** Nothing is trained; taxonomies change per request.
138
+ - **Not novel.** Reading option logits in a single pass is an established technique — [TypeSafe's Jev](https://www.typesafe.ai), [Laya](https://huggingface.co/convaiinnovations/laya), and [SemIf](https://github.com/TheoLeeCJ/SemIf) all do versions of it, and SemIf in particular has done far more rigorous benchmarking and calibration work than this has. `decidr`'s only claim is a narrow one: it's the smallest possible version that runs against an Ollama you already have, with zero dependencies and no model downloads.
139
+
140
+ ## Limitations
141
+
142
+ - Sequential — no batching yet. One decision, one request.
143
+ - 2–16 options (letters `A`–`P`).
144
+ - `ranked` mode's completeness degrades as option count grows.
145
+ - Probabilities uncalibrated by default (see above).
146
+ - Tested against `qwen3.5:4b`. Small models (<1B) often won't treat a bare letter as a plausible next token; `decidr` raises a clear error rather than returning noise if none of the letters are scored.
147
+
148
+ ## License
149
+
150
+ MIT
decidr-0.1.0/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # decidr
2
+
3
+ **Typed decisions from local LLMs, in one forward pass.**
4
+
5
+ Most decisions an application asks an LLM to make are small: *route this ticket*, *is this evidence sufficient*, *how angry is this customer*. A chat model can answer them, but it generates a sentence, or JSON, which your code then parses back into an `if` statement.
6
+
7
+ `decidr` skips that. It gives the model your options, runs **one forward pass**, and reads the probability of each option directly out of the model's own logits. No answer sentence. No JSON to repair. No decoding loop.
8
+
9
+ ```python
10
+ from decidr import Client
11
+
12
+ client = Client(model="qwen3.5:4b")
13
+
14
+ decision = client.decide({
15
+ "id": "route-1",
16
+ "state": "Customer cannot access an account after a password reset. The reset email never arrived.",
17
+ "question": "Which queue should handle this request?",
18
+ "options": [
19
+ {"id": "access", "description": "Account access and authentication support."},
20
+ {"id": "billing", "description": "Billing and payment support."},
21
+ {"id": "sales", "description": "Sales and product evaluation."},
22
+ ],
23
+ })
24
+
25
+ decision.choice # 'access'
26
+ decision.confidence # 0.9999
27
+ decision.probabilities # {'access': 0.9999, 'billing': 0.0001, 'sales': 0.0}
28
+ decision.is_reliable() # True
29
+ ```
30
+
31
+ Works with any model you already have in [Ollama](https://ollama.com). No fine-tuning, no extra runtime, no separate model to download.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install decidr
37
+ ```
38
+
39
+ Requires Python 3.10+ and a running Ollama. Zero dependencies — it's stdlib `urllib` and `math`.
40
+
41
+ ## How it works
42
+
43
+ Three things, in order:
44
+
45
+ **1. Options become letters.** Each option is presented to the model as `A`, `B`, `C`… rather than by its own name. This is not cosmetic: real labels like `billing` or `SYS_OUTAGE` are usually *several* tokens, and you cannot read a single-token probability for a multi-token string. Letters are single tokens in every vocabulary. The meaning lives in the descriptions, which is where the model actually reads it.
46
+
47
+ **2. One forward pass, no generation.** The prompt ends where the answer begins, and generation is capped at a single token. Reasoning mode is explicitly disabled — on a hybrid-reasoning model, a `<think>` preamble would put thinking tokens in the answer slot, and the next token would stop being the decision.
48
+
49
+ **3. The scores are read, not sampled.** Instead of taking whichever letter the model emitted, `decidr` reads the log probability of *every* option letter and normalizes over them. You get a distribution, not just a pick — so you can threshold on confidence, route ambiguous cases to a human, or log calibration over time.
50
+
51
+ ## Two modes, picked automatically
52
+
53
+ How completely `decidr` can read those scores depends on your Ollama build. It probes once per process and tells you which mode you're in via `decision.mode`.
54
+
55
+ | Mode | When | Behavior |
56
+ |---|---|---|
57
+ | `ranked` | **Stock Ollama** (what you have today) | Falls back to `top_logprobs` (capped at 20 by the API). Options whose letters don't surface in that window are reported in `decision.unscored`. |
58
+ | `exact` | Ollama with [`logprob_tokens`](https://github.com/ollama/ollama/pull/18580) | Every option's probability is read directly from the full distribution, regardless of rank. |
59
+
60
+ **Why this distinction exists:** stock Ollama can only tell you about tokens that rank in the model's top 20 guesses. For a 3-option decision, the letters almost always make that cut and `ranked` is fine. For a 12-option decision, they often don't:
61
+
62
+ ```
63
+ 12 options, same model, same prompt:
64
+ exact -> scored 12/12 reliable=True
65
+ ranked -> scored 11/12 reliable=False unscored: ['cat9']
66
+ ```
67
+
68
+ `decidr` never invents a number for an option it couldn't measure. It reports it as unscored, `is_reliable()` returns `False`, and the remaining probabilities are normalized over what was actually observed. A fabricated floor value would be indistinguishable from a real measurement, which is the one thing a probability API must never do.
69
+
70
+ ### Getting `exact` mode
71
+
72
+ `logprob_tokens` is a change I've proposed upstream to Ollama — **it is not merged yet**:
73
+
74
+ - Issue: [ollama/ollama#18579](https://github.com/ollama/ollama/issues/18579)
75
+ - PR: [ollama/ollama#18580](https://github.com/ollama/ollama/pull/18580)
76
+
77
+ Until it lands (if it lands), `decidr` works today in `ranked` mode against stock Ollama. Nothing here depends on that PR being accepted — `exact` mode is an upgrade, not a requirement. If you want it now, you can build from [the branch](https://github.com/devanmolsharma/ollama/tree/classification-logprobs).
78
+
79
+ ## API
80
+
81
+ ### `Client(model, host=..., timeout=..., temperature=..., force_mode=None)`
82
+
83
+ - `model` — any Ollama model name, e.g. `"qwen3.5:4b"`.
84
+ - `host` — defaults to `http://127.0.0.1:11434`.
85
+ - `temperature` — applied to the softmax over option logprobs, **not** to sampling. Higher values flatten confidence. Use this to calibrate against a labeled set (see below).
86
+ - `force_mode` — skip the capability probe and pin `"exact"` or `"ranked"`.
87
+
88
+ ### `client.decide(row) -> Decision`
89
+
90
+ `row` needs `id`, `state`, `question`, and 2–16 `options`, each with an `id` and a `description`. `state` may be a string, dict, or list. Malformed rows raise `DecisionError` naming the problem rather than silently producing a confident wrong answer.
91
+
92
+ ### `Decision`
93
+
94
+ | Field | |
95
+ |---|---|
96
+ | `choice` | option id with the highest probability |
97
+ | `confidence` | probability of `choice` |
98
+ | `probabilities` | option id → probability, sums to 1 **over scored options** |
99
+ | `logprobs` | raw log probabilities before normalizing |
100
+ | `mode` | `"exact"` or `"ranked"` |
101
+ | `unscored` | options the server could not report |
102
+ | `is_reliable()` | `False` when anything went unscored |
103
+ | `raw_answer` | the letter the model actually emitted |
104
+
105
+ `client.decide_all(rows)` runs a list sequentially.
106
+
107
+ ## Calibration, honestly
108
+
109
+ The probabilities are **conditional on the option set you supplied** and are *not* calibrated out of the box. A raw instruction-tuned model's logits are not a calibrated probability distribution — they're systematically overconfident, and binary yes/no framings in particular carry a strong prior-driven skew.
110
+
111
+ What that means in practice: `choice` is usually trustworthy; `confidence` is directional, not a true probability. Before thresholding on it (e.g. "auto-route above 0.9"), fit `temperature` against a labeled sample from your own workload and measure whether the numbers track observed accuracy. Purpose-built decision models are trained against proper scoring rules precisely because this step matters.
112
+
113
+ If you need calibrated confidence out of the box more than you need "runs on anything you already have," use a trained decision model instead. `decidr`'s bet is that zero setup is worth more for most use cases, and that you should be told plainly where the limits are.
114
+
115
+ ## What this is not
116
+
117
+ - **Not a new model.** It's a way of reading models you already run.
118
+ - **Not a fine-tune.** Nothing is trained; taxonomies change per request.
119
+ - **Not novel.** Reading option logits in a single pass is an established technique — [TypeSafe's Jev](https://www.typesafe.ai), [Laya](https://huggingface.co/convaiinnovations/laya), and [SemIf](https://github.com/TheoLeeCJ/SemIf) all do versions of it, and SemIf in particular has done far more rigorous benchmarking and calibration work than this has. `decidr`'s only claim is a narrow one: it's the smallest possible version that runs against an Ollama you already have, with zero dependencies and no model downloads.
120
+
121
+ ## Limitations
122
+
123
+ - Sequential — no batching yet. One decision, one request.
124
+ - 2–16 options (letters `A`–`P`).
125
+ - `ranked` mode's completeness degrades as option count grows.
126
+ - Probabilities uncalibrated by default (see above).
127
+ - Tested against `qwen3.5:4b`. Small models (<1B) often won't treat a bare letter as a plausible next token; `decidr` raises a clear error rather than returning noise if none of the letters are scored.
128
+
129
+ ## License
130
+
131
+ MIT
@@ -0,0 +1,76 @@
1
+ """Route support tickets, score evidence, and read tone -- three decisions,
2
+ one forward pass each, against whatever model you already have in Ollama.
3
+
4
+ python examples/triage.py [model] [host]
5
+ """
6
+
7
+ import sys
8
+ import time
9
+
10
+ from decidr import Client, DecisionError
11
+
12
+ ROWS = [
13
+ {
14
+ "id": "route",
15
+ "state": "Customer cannot access an account after a password reset. The reset email never arrived.",
16
+ "question": "Which queue should handle this request?",
17
+ "options": [
18
+ {"id": "access", "description": "Account access and authentication support."},
19
+ {"id": "billing", "description": "Billing and payment support."},
20
+ {"id": "sales", "description": "Sales and product evaluation."},
21
+ ],
22
+ },
23
+ {
24
+ "id": "evidence",
25
+ "state": "The deployment completed at 14:02 UTC. Health checks passed in all three zones. No rollback was initiated.",
26
+ "question": "Is there evidence that the deployment succeeded?",
27
+ "options": [
28
+ {"id": "yes", "description": "The deployment succeeded."},
29
+ {"id": "no", "description": "The deployment did not succeed."},
30
+ {"id": "insufficient", "description": "The evidence is insufficient to decide."},
31
+ ],
32
+ },
33
+ {
34
+ "id": "tone",
35
+ "state": "This is the THIRD time you've charged me twice. I want a refund NOW or I'm calling my bank.",
36
+ "question": "How frustrated is the customer?",
37
+ "options": [
38
+ {"id": "calm", "description": "Calm and neutral."},
39
+ {"id": "annoyed", "description": "Mildly annoyed."},
40
+ {"id": "angry", "description": "Very angry."},
41
+ ],
42
+ },
43
+ ]
44
+
45
+
46
+ def main() -> int:
47
+ model = sys.argv[1] if len(sys.argv) > 1 else "qwen3.5:4b"
48
+ host = sys.argv[2] if len(sys.argv) > 2 else "http://127.0.0.1:11434"
49
+
50
+ client = Client(model=model, host=host)
51
+ try:
52
+ mode = "exact" if client.supports_exact() else "ranked"
53
+ except DecisionError as e:
54
+ print(f"could not reach Ollama: {e}")
55
+ return 1
56
+
57
+ print(f"model={model} mode={mode}\n")
58
+ for row in ROWS:
59
+ started = time.perf_counter()
60
+ try:
61
+ d = client.decide(row)
62
+ except DecisionError as e:
63
+ print(f"{row['id']:10s} error: {e}")
64
+ continue
65
+ ms = round((time.perf_counter() - started) * 1000)
66
+
67
+ ranked = sorted(d.probabilities.items(), key=lambda kv: -kv[1])
68
+ spread = " ".join(f"{k}={v:.3f}" for k, v in ranked)
69
+ print(f"{row['id']:10s} {d.choice:13s} {d.confidence:6.1%} {ms:5d}ms {spread}")
70
+ if not d.is_reliable():
71
+ print(f"{'':10s} unscored (outside the top-20 window): {d.unscored}")
72
+ return 0
73
+
74
+
75
+ if __name__ == "__main__":
76
+ raise SystemExit(main())
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "decidr"
7
+ version = "0.1.0"
8
+ description = "Typed decisions from local LLMs in one forward pass, via Ollama"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ keywords = ["llm", "ollama", "classification", "routing", "logprobs", "decisions"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.optional-dependencies]
23
+ dev = ["pytest>=7"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/devanmolsharma/decidr"
27
+ Issues = "https://github.com/devanmolsharma/decidr/issues"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/decidr"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
@@ -0,0 +1,22 @@
1
+ """decidr -- typed decisions from local LLMs, in one forward pass."""
2
+
3
+ from .core import (
4
+ LETTERS,
5
+ Client,
6
+ Decision,
7
+ DecisionError,
8
+ build_messages,
9
+ softmax,
10
+ validate_row,
11
+ )
12
+
13
+ __all__ = [
14
+ "Client",
15
+ "Decision",
16
+ "DecisionError",
17
+ "LETTERS",
18
+ "build_messages",
19
+ "softmax",
20
+ "validate_row",
21
+ ]
22
+ __version__ = "0.1.0"
@@ -0,0 +1,256 @@
1
+ """Typed semantic decisions against any local Ollama model, in one forward pass.
2
+
3
+ Send state + question + options, get probabilities back. No answer sentence, no
4
+ JSON to repair, no decoding loop -- the answer is read out of the option tokens'
5
+ log probabilities at a single position.
6
+
7
+ Rows use the same shape other single-pass decision engines accept, so they port
8
+ between implementations:
9
+
10
+ {"id": "route-1",
11
+ "state": "Customer cannot access an account after a password reset.",
12
+ "question": "Which queue should handle this request?",
13
+ "options": [{"id": "access", "description": "Account access support."},
14
+ {"id": "billing", "description": "Billing support."}]}
15
+
16
+ Options are presented to the model as letters (A, B, C...) rather than as their
17
+ own names. Letters are single tokens in every vocabulary, which sidesteps the
18
+ fact that most real labels ("billing", "SYS_OUTAGE") are not -- the meaning
19
+ lives in the descriptions, where the model can actually read it.
20
+
21
+ Two ways of reading the letters' scores, picked automatically:
22
+
23
+ exact - the server supports `logprob_tokens` (ollama/ollama#18579), so every
24
+ option's logprob is read directly from the full distribution
25
+ regardless of rank.
26
+ ranked - stock Ollama: fall back to `top_logprobs` (max 20) and match the
27
+ letters that appear. Options outside that window are reported as
28
+ unscored rather than guessed at.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import math
35
+ import urllib.error
36
+ import urllib.request
37
+ from dataclasses import dataclass, field
38
+ from typing import Any, Literal
39
+
40
+ LETTERS = "ABCDEFGHIJKLMNOP"
41
+
42
+ SYSTEM = (
43
+ "Apply the supplied criterion to the supplied evidence. Choose exactly one listed option. "
44
+ "Respond with only its uppercase letter, with no explanation or reasoning."
45
+ )
46
+
47
+ DEFAULT_HOST = "http://127.0.0.1:11434"
48
+
49
+
50
+ class DecisionError(ValueError):
51
+ """Bad input row, or a server that could not answer it."""
52
+
53
+
54
+ @dataclass
55
+ class Decision:
56
+ id: str
57
+ choice: str # option id with the highest probability
58
+ probabilities: dict[str, float] # option id -> probability, sums to 1 over scored options
59
+ logprobs: dict[str, float] # option id -> raw logprob, before normalizing
60
+ mode: Literal["exact", "ranked"] # how the scores were read; see module docstring
61
+ unscored: list[str] = field(default_factory=list) # options the server could not report
62
+ raw_answer: str | None = None # the letter the model actually emitted
63
+
64
+ @property
65
+ def confidence(self) -> float:
66
+ return self.probabilities.get(self.choice, 0.0)
67
+
68
+ def is_reliable(self) -> bool:
69
+ """False when some option went unscored, so the distribution is incomplete
70
+ and `probabilities` is normalized over a subset of what was asked."""
71
+ return not self.unscored
72
+
73
+
74
+ def validate_row(row: dict) -> None:
75
+ """Reject a malformed row up front, with a message naming what's wrong --
76
+ a bad row should fail here, not produce a confident-looking wrong answer."""
77
+ required = {"id", "state", "question", "options"}
78
+ if not required <= row.keys():
79
+ raise DecisionError(f"row is missing fields: {sorted(required - row.keys())}")
80
+ if not all(isinstance(row[k], str) and row[k] for k in ("id", "question")):
81
+ raise DecisionError("id and question must be nonempty strings")
82
+ state = row["state"]
83
+ if not isinstance(state, (str, dict, list)) or not state:
84
+ raise DecisionError("state must be a nonempty string, object, or array")
85
+ try:
86
+ json.dumps(state, ensure_ascii=False, allow_nan=False)
87
+ except (TypeError, ValueError) as e:
88
+ raise DecisionError("state must be finite JSON-compatible data") from e
89
+ options = row["options"]
90
+ if not isinstance(options, list) or not 2 <= len(options) <= len(LETTERS):
91
+ raise DecisionError(f"options must contain 2-{len(LETTERS)} entries")
92
+ ids = []
93
+ for opt in options:
94
+ if not isinstance(opt, dict) or not isinstance(opt.get("id"), str) or not isinstance(opt.get("description"), str):
95
+ raise DecisionError("each option needs string id and description fields")
96
+ ids.append(opt["id"])
97
+ if len(ids) != len(set(ids)):
98
+ raise DecisionError("option ids must be unique")
99
+
100
+
101
+ def build_messages(row: dict) -> list[dict]:
102
+ payload = {
103
+ "evidence": row["state"],
104
+ "criterion": row["question"],
105
+ "options": [
106
+ {"letter": LETTERS[i], "description": opt["description"]}
107
+ for i, opt in enumerate(row["options"])
108
+ ],
109
+ }
110
+ return [
111
+ {"role": "system", "content": SYSTEM},
112
+ {"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
113
+ ]
114
+
115
+
116
+ def softmax(logprobs: list[float], temperature: float = 1.0) -> list[float]:
117
+ """Normalize over the supplied options only. The result is conditional on
118
+ this option set -- it says nothing about tokens outside it."""
119
+ if not logprobs:
120
+ return []
121
+ scaled = [lp / temperature for lp in logprobs]
122
+ top = max(scaled)
123
+ exp = [math.exp(v - top) for v in scaled]
124
+ total = sum(exp)
125
+ return [v / total for v in exp] if total else [1.0 / len(exp)] * len(exp)
126
+
127
+
128
+ class Client:
129
+ """Talks to one Ollama server. Probes once for `logprob_tokens` support and
130
+ reuses the answer, so the capability check costs one request per process."""
131
+
132
+ def __init__(self, model: str, host: str = DEFAULT_HOST, timeout: float = 120.0,
133
+ temperature: float = 1.0, force_mode: Literal["exact", "ranked"] | None = None):
134
+ self.model = model
135
+ self.host = host.rstrip("/")
136
+ self.timeout = timeout
137
+ self.temperature = temperature
138
+ self._supports_exact: bool | None = {"exact": True, "ranked": False}.get(force_mode or "")
139
+ if force_mode is None:
140
+ self._supports_exact = None
141
+
142
+ # ---- transport ---------------------------------------------------------
143
+
144
+ def _post(self, path: str, body: dict) -> dict:
145
+ req = urllib.request.Request(
146
+ f"{self.host}{path}",
147
+ data=json.dumps(body).encode(),
148
+ headers={"Content-Type": "application/json"},
149
+ )
150
+ try:
151
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
152
+ return json.loads(resp.read())
153
+ except urllib.error.HTTPError as e:
154
+ detail = e.read().decode(errors="replace")
155
+ try:
156
+ detail = json.loads(detail).get("error", detail)
157
+ except json.JSONDecodeError:
158
+ pass
159
+ raise DecisionError(f"{path} failed ({e.code}): {detail}") from e
160
+ except urllib.error.URLError as e:
161
+ raise DecisionError(f"cannot reach Ollama at {self.host}: {e.reason}") from e
162
+
163
+ def _chat(self, messages: list[dict], letters: list[str]) -> dict:
164
+ body: dict[str, Any] = {
165
+ "model": self.model,
166
+ "messages": messages,
167
+ "stream": False,
168
+ # A reasoning preamble would put thinking tokens in the answer slot,
169
+ # so the very next token stops being the decision.
170
+ "think": False,
171
+ "options": {"num_predict": 1, "temperature": 0},
172
+ "logprobs": True,
173
+ "top_logprobs": 20,
174
+ }
175
+ if self.supports_exact():
176
+ body["logprob_tokens"] = letters
177
+ return self._post("/api/chat", body)
178
+
179
+ def supports_exact(self) -> bool:
180
+ """True when the server accepts `logprob_tokens`. Probed once with a
181
+ throwaway request; a server that rejects the field answers the probe
182
+ with a 400, which is how we detect stock Ollama."""
183
+ if self._supports_exact is None:
184
+ probe = {
185
+ "model": self.model,
186
+ "messages": [{"role": "user", "content": "hi"}],
187
+ "stream": False,
188
+ "think": False,
189
+ "options": {"num_predict": 1},
190
+ "logprob_tokens": ["A"],
191
+ }
192
+ try:
193
+ resp = self._post("/api/chat", probe)
194
+ got = resp.get("logprobs") or [{}]
195
+ self._supports_exact = bool(got[0].get("requested_logprobs"))
196
+ except DecisionError:
197
+ self._supports_exact = False
198
+ return self._supports_exact
199
+
200
+ # ---- decisions ---------------------------------------------------------
201
+
202
+ def decide(self, row: dict) -> Decision:
203
+ validate_row(row)
204
+ options = row["options"]
205
+ letters = [LETTERS[i] for i in range(len(options))]
206
+ resp = self._chat(build_messages(row), letters)
207
+
208
+ entries = resp.get("logprobs") or []
209
+ if not entries:
210
+ raise DecisionError(
211
+ f"server returned no logprobs for row {row['id']!r}; the model may not "
212
+ "support them, or `logprobs` was rejected"
213
+ )
214
+ first = entries[0]
215
+ mode: Literal["exact", "ranked"] = "exact" if first.get("requested_logprobs") else "ranked"
216
+
217
+ # Both paths yield {token: logprob}; exact covers every letter, ranked
218
+ # only those that surfaced in the top-20 window.
219
+ if mode == "exact":
220
+ found = {e["token"]: e["logprob"] for e in first["requested_logprobs"]}
221
+ else:
222
+ found = {}
223
+ if first.get("token"):
224
+ found[first["token"].strip()] = first.get("logprob", 0.0)
225
+ for alt in first.get("top_logprobs", []):
226
+ found.setdefault(alt["token"].strip(), alt["logprob"])
227
+
228
+ logprobs: dict[str, float] = {}
229
+ unscored: list[str] = []
230
+ for letter, opt in zip(letters, options):
231
+ if letter in found:
232
+ logprobs[opt["id"]] = found[letter]
233
+ else:
234
+ unscored.append(opt["id"])
235
+
236
+ if not logprobs:
237
+ raise DecisionError(
238
+ f"row {row['id']!r}: none of the option letters {letters} were scored. "
239
+ "The model did not treat a bare letter as a plausible next token here."
240
+ )
241
+
242
+ ids = list(logprobs)
243
+ probs = softmax([logprobs[i] for i in ids], self.temperature)
244
+ probabilities = dict(zip(ids, probs))
245
+ return Decision(
246
+ id=row["id"],
247
+ choice=max(probabilities, key=probabilities.get),
248
+ probabilities=probabilities,
249
+ logprobs=logprobs,
250
+ mode=mode,
251
+ unscored=unscored,
252
+ raw_answer=(resp.get("message") or {}).get("content"),
253
+ )
254
+
255
+ def decide_all(self, rows: list[dict]) -> list[Decision]:
256
+ return [self.decide(row) for row in rows]
@@ -0,0 +1,163 @@
1
+ import json
2
+ import math
3
+
4
+ import pytest
5
+
6
+ from decidr import Client, DecisionError, build_messages, softmax, validate_row
7
+
8
+ ROW = {
9
+ "id": "route-1",
10
+ "state": "Customer cannot access an account after a password reset.",
11
+ "question": "Which queue should handle this request?",
12
+ "options": [
13
+ {"id": "access", "description": "Account access support."},
14
+ {"id": "billing", "description": "Billing support."},
15
+ ],
16
+ }
17
+
18
+
19
+ def test_validate_accepts_a_good_row():
20
+ validate_row(ROW)
21
+
22
+
23
+ @pytest.mark.parametrize(
24
+ "mutate, expected",
25
+ [
26
+ (lambda r: r.pop("question"), "missing fields"),
27
+ (lambda r: r.update(question=""), "nonempty strings"),
28
+ (lambda r: r.update(state=""), "state must be"),
29
+ (lambda r: r.update(options=r["options"][:1]), "options must contain"),
30
+ (lambda r: r.update(options=[{"id": "a"}, {"id": "b"}]), "id and description"),
31
+ (lambda r: r.update(options=[{"id": "x", "description": "d"}] * 2), "unique"),
32
+ ],
33
+ )
34
+ def test_validate_rejects_bad_rows(mutate, expected):
35
+ row = json.loads(json.dumps(ROW))
36
+ mutate(row)
37
+ with pytest.raises(DecisionError, match=expected):
38
+ validate_row(row)
39
+
40
+
41
+ def test_options_are_presented_as_letters():
42
+ # Real labels are usually multi-token; letters always aren't. The meaning
43
+ # has to survive in the descriptions instead.
44
+ messages = build_messages(ROW)
45
+ payload = json.loads(messages[-1]["content"])
46
+ assert [o["letter"] for o in payload["options"]] == ["A", "B"]
47
+ assert payload["options"][0]["description"] == "Account access support."
48
+ # Only letter + description reach the model; the option ids stay client-side,
49
+ # so an id like "SYS_OUTAGE" never has to tokenize cleanly.
50
+ assert all(set(o) == {"letter", "description"} for o in payload["options"])
51
+
52
+
53
+ def test_softmax_normalizes_over_supplied_options_only():
54
+ probs = softmax([-0.1, -2.0, -8.0])
55
+ assert math.isclose(sum(probs), 1.0)
56
+ assert probs[0] > probs[1] > probs[2]
57
+
58
+
59
+ def test_softmax_handles_degenerate_input():
60
+ assert softmax([]) == []
61
+ equal = softmax([-1.0, -1.0])
62
+ assert math.isclose(equal[0], 0.5)
63
+
64
+
65
+ def test_temperature_flattens_the_distribution():
66
+ sharp = softmax([-0.1, -3.0], temperature=1.0)
67
+ flat = softmax([-0.1, -3.0], temperature=5.0)
68
+ assert flat[0] < sharp[0] # higher temperature is less confident
69
+
70
+
71
+ class FakeClient(Client):
72
+ """Client with the transport stubbed, so decision parsing is tested without
73
+ a live server."""
74
+
75
+ def __init__(self, response, supports_exact):
76
+ super().__init__(model="test", force_mode="exact" if supports_exact else "ranked")
77
+ self._response = response
78
+
79
+ def _post(self, path, body):
80
+ self._last_body = body
81
+ return self._response
82
+
83
+
84
+ def test_exact_mode_reads_requested_logprobs():
85
+ client = FakeClient(
86
+ {
87
+ "message": {"content": "A"},
88
+ "logprobs": [
89
+ {
90
+ "token": "A",
91
+ "logprob": -0.05,
92
+ "requested_logprobs": [
93
+ {"token": "A", "logprob": -0.05},
94
+ {"token": "B", "logprob": -9.0},
95
+ ],
96
+ }
97
+ ],
98
+ },
99
+ supports_exact=True,
100
+ )
101
+ d = client.decide(ROW)
102
+ assert d.mode == "exact"
103
+ assert d.choice == "access"
104
+ assert d.is_reliable()
105
+ assert d.probabilities["access"] > 0.99
106
+ assert client._last_body["logprob_tokens"] == ["A", "B"]
107
+
108
+
109
+ def test_ranked_mode_falls_back_to_top_logprobs():
110
+ client = FakeClient(
111
+ {
112
+ "message": {"content": "B"},
113
+ "logprobs": [
114
+ {
115
+ "token": "B",
116
+ "logprob": -0.2,
117
+ "top_logprobs": [
118
+ {"token": "B", "logprob": -0.2},
119
+ {"token": "A", "logprob": -1.9},
120
+ ],
121
+ }
122
+ ],
123
+ },
124
+ supports_exact=False,
125
+ )
126
+ d = client.decide(ROW)
127
+ assert d.mode == "ranked"
128
+ assert d.choice == "billing"
129
+ assert "logprob_tokens" not in client._last_body
130
+
131
+
132
+ def test_options_outside_the_window_are_reported_not_guessed():
133
+ # Only A came back. B must be surfaced as unscored rather than assigned
134
+ # some floor value that would look like a real measurement.
135
+ client = FakeClient(
136
+ {
137
+ "message": {"content": "A"},
138
+ "logprobs": [{"token": "A", "logprob": -0.01, "top_logprobs": [{"token": "A", "logprob": -0.01}]}],
139
+ },
140
+ supports_exact=False,
141
+ )
142
+ d = client.decide(ROW)
143
+ assert d.unscored == ["billing"]
144
+ assert not d.is_reliable()
145
+ assert math.isclose(d.probabilities["access"], 1.0) # normalized over what was scored
146
+
147
+
148
+ def test_no_scored_options_is_an_error_not_a_coin_flip():
149
+ client = FakeClient(
150
+ {
151
+ "message": {"content": "hello"},
152
+ "logprobs": [{"token": "hello", "logprob": -0.5, "top_logprobs": [{"token": "hi", "logprob": -1.0}]}],
153
+ },
154
+ supports_exact=False,
155
+ )
156
+ with pytest.raises(DecisionError, match="none of the option letters"):
157
+ client.decide(ROW)
158
+
159
+
160
+ def test_missing_logprobs_is_an_error():
161
+ client = FakeClient({"message": {"content": "A"}}, supports_exact=False)
162
+ with pytest.raises(DecisionError, match="no logprobs"):
163
+ client.decide(ROW)