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.
- decidr-0.1.0/.gitignore +7 -0
- decidr-0.1.0/LICENSE +21 -0
- decidr-0.1.0/PKG-INFO +150 -0
- decidr-0.1.0/README.md +131 -0
- decidr-0.1.0/examples/triage.py +76 -0
- decidr-0.1.0/pyproject.toml +33 -0
- decidr-0.1.0/src/decidr/__init__.py +22 -0
- decidr-0.1.0/src/decidr/core.py +256 -0
- decidr-0.1.0/tests/test_core.py +163 -0
decidr-0.1.0/.gitignore
ADDED
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)
|