pydecide 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.
Files changed (43) hide show
  1. pydecide-0.1.0/.github/workflows/ci.yml +15 -0
  2. pydecide-0.1.0/.gitignore +7 -0
  3. pydecide-0.1.0/CHANGELOG.md +29 -0
  4. pydecide-0.1.0/LICENSE +21 -0
  5. pydecide-0.1.0/PKG-INFO +379 -0
  6. pydecide-0.1.0/README.md +340 -0
  7. pydecide-0.1.0/decide/__init__.py +53 -0
  8. pydecide-0.1.0/decide/backends/__init__.py +67 -0
  9. pydecide-0.1.0/decide/backends/_http.py +99 -0
  10. pydecide-0.1.0/decide/backends/base.py +91 -0
  11. pydecide-0.1.0/decide/backends/crossencoder.py +416 -0
  12. pydecide-0.1.0/decide/backends/laya.py +176 -0
  13. pydecide-0.1.0/decide/backends/laya_mlx.py +64 -0
  14. pydecide-0.1.0/decide/backends/llm.py +358 -0
  15. pydecide-0.1.0/decide/backends/openrouter.py +12 -0
  16. pydecide-0.1.0/decide/backends/typesafe.py +82 -0
  17. pydecide-0.1.0/decide/cli.py +294 -0
  18. pydecide-0.1.0/decide/client.py +477 -0
  19. pydecide-0.1.0/decide/errors.py +71 -0
  20. pydecide-0.1.0/decide/gate.py +56 -0
  21. pydecide-0.1.0/decide/server.py +124 -0
  22. pydecide-0.1.0/decide/types.py +146 -0
  23. pydecide-0.1.0/decide/wire.py +327 -0
  24. pydecide-0.1.0/docs/superpowers/specs/2026-09-21-decide-design.md +217 -0
  25. pydecide-0.1.0/pyproject.toml +67 -0
  26. pydecide-0.1.0/scripts/dump_typesafe_schema.py +35 -0
  27. pydecide-0.1.0/tests/__init__.py +0 -0
  28. pydecide-0.1.0/tests/conftest.py +90 -0
  29. pydecide-0.1.0/tests/fixtures/typesafe_schema.json +605 -0
  30. pydecide-0.1.0/tests/test_backends.py +23 -0
  31. pydecide-0.1.0/tests/test_cli.py +297 -0
  32. pydecide-0.1.0/tests/test_client.py +513 -0
  33. pydecide-0.1.0/tests/test_crossencoder.py +177 -0
  34. pydecide-0.1.0/tests/test_gate.py +41 -0
  35. pydecide-0.1.0/tests/test_laya.py +342 -0
  36. pydecide-0.1.0/tests/test_llm.py +465 -0
  37. pydecide-0.1.0/tests/test_openrouter.py +22 -0
  38. pydecide-0.1.0/tests/test_schema_golden.py +77 -0
  39. pydecide-0.1.0/tests/test_server.py +420 -0
  40. pydecide-0.1.0/tests/test_types.py +65 -0
  41. pydecide-0.1.0/tests/test_typesafe.py +189 -0
  42. pydecide-0.1.0/tests/test_wire.py +445 -0
  43. pydecide-0.1.0/uv.lock +2468 -0
@@ -0,0 +1,15 @@
1
+ name: ci
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ strategy:
7
+ matrix: { python: ["3.10", "3.11", "3.12", "3.13", "3.14"] }
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+ - uses: astral-sh/setup-uv@v3
11
+ - run: uv python install ${{ matrix.python }}
12
+ - run: uv sync --python ${{ matrix.python }}
13
+ - run: uv run ruff check .
14
+ - run: uv run ruff format --check .
15
+ - run: uv run pytest
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .superpowers/
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-09-22)
4
+
5
+ - Add `Choice`, `Score`, `Noul`, `Request`, `Response` and their answer types
6
+ as frozen, validated dataclasses (`decide/types.py`).
7
+ - Add `Client`/`AsyncClient` with an ordered backend fallback chain,
8
+ confidence gating via `Gate`, `Client.from_env`/`AsyncClient.from_env`
9
+ environment-based configuration, and `decide_batch` with partial-failure
10
+ reporting through `AllBackendsFailed`.
11
+ - Add six backends: `typesafe` (TypeSafe/Jev HTTP API), `openrouter`
12
+ (OpenRouter Decisions endpoint), `laya` (local PyTorch), `laya_mlx`
13
+ (local MLX, Apple Silicon), `crossencoder` (any sentence-transformers
14
+ CrossEncoder, with generic and KaLM-Jev templates), and `llm` (JSON-prompted
15
+ fallback over any OpenAI-compatible chat-completions server).
16
+ - Add a Jev/TypeSafe wire-compatible HTTP server (`decide serve`, the
17
+ `server` extra) exposing `POST /v1/systemone`, `GET /health` and
18
+ `GET /v1/models` over any configured backend chain.
19
+ - Add a command line interface: `decide ask`, `decide backends`, `decide serve`.
20
+
21
+ ### Known limitations
22
+
23
+ - LLM emulation (`llm` backend) probabilities are self-reported by the
24
+ model, not measured or calibrated.
25
+ - CrossEncoder (`crossencoder` backend) outputs are normalized scores
26
+ (softmax/sigmoid over raw logits), not calibrated probabilities.
27
+ - `AsyncClient.decide_batch` calls each backend's `adecide` concurrently
28
+ per request; it does not use a backend's batch path.
29
+ - The `mlx` extra (`laya_mlx` backend) needs Python 3.11 or newer.
pydecide-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gopalji Gaur
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.
@@ -0,0 +1,379 @@
1
+ Metadata-Version: 2.5
2
+ Name: pydecide
3
+ Version: 0.1.0
4
+ Summary: One client for every decision model: Choice, Score and Noul over Jev, OpenRouter, laya, MLX, CrossEncoders and an LLM fallback.
5
+ Project-URL: Homepage, https://github.com/gopaljigaur/decide
6
+ Project-URL: Issues, https://github.com/gopaljigaur/decide/issues
7
+ Author: Gopalji Gaur
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: classification,decision model,jev,laya,routing,system one,typesafe
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: httpx>=0.27
21
+ Provides-Extra: all
22
+ Requires-Dist: fastapi>=0.110; extra == 'all'
23
+ Requires-Dist: laya-mlx>=0.1; (python_version >= '3.11') and extra == 'all'
24
+ Requires-Dist: laya>=0.3; extra == 'all'
25
+ Requires-Dist: sentence-transformers>=3.0; extra == 'all'
26
+ Requires-Dist: torch; extra == 'all'
27
+ Requires-Dist: uvicorn>=0.29; extra == 'all'
28
+ Provides-Extra: laya
29
+ Requires-Dist: laya>=0.3; extra == 'laya'
30
+ Provides-Extra: mlx
31
+ Requires-Dist: laya-mlx>=0.1; (python_version >= '3.11') and extra == 'mlx'
32
+ Provides-Extra: server
33
+ Requires-Dist: fastapi>=0.110; extra == 'server'
34
+ Requires-Dist: uvicorn>=0.29; extra == 'server'
35
+ Provides-Extra: st
36
+ Requires-Dist: sentence-transformers>=3.0; extra == 'st'
37
+ Requires-Dist: torch; extra == 'st'
38
+ Description-Content-Type: text/markdown
39
+
40
+ # pydecide
41
+
42
+ [![CI](https://github.com/gopaljigaur/decide/actions/workflows/ci.yml/badge.svg)](https://github.com/gopaljigaur/decide/actions/workflows/ci.yml)
43
+ [![PyPI](https://img.shields.io/pypi/v/pydecide)](https://pypi.org/project/pydecide/)
44
+
45
+ `decide` is one Python client for typed decisions - Choice, Score and Noul -
46
+ over any "System One" decision model: TypeSafe's hosted Jev, OpenRouter's
47
+ Decisions endpoint, the open-weight laya family (PyTorch and MLX), any
48
+ sentence-transformers CrossEncoder, and a JSON-prompted LLM fallback. A
49
+ `Client` takes an ordered list of backends and a confidence policy: if a
50
+ backend errors or answers with low confidence, the next backend is tried.
51
+ The library also ships a small HTTP server that speaks TypeSafe's wire
52
+ protocol, so existing TypeSafe clients can point at a local model instead.
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ pip install pydecide
58
+ ```
59
+
60
+ Local model backends and the server are optional extras:
61
+
62
+ ```bash
63
+ pip install "pydecide[server]" # decide serve (FastAPI + uvicorn)
64
+ pip install "pydecide[laya]" # laya backend (PyTorch)
65
+ pip install "pydecide[mlx]" # laya_mlx backend (Apple Silicon; needs Python 3.11+)
66
+ pip install "pydecide[st]" # crossencoder backend (sentence-transformers)
67
+ pip install "pydecide[all]" # everything above
68
+ ```
69
+
70
+ The `mlx` extra depends on `laya-mlx`, which requires Python 3.11 or newer;
71
+ on 3.10 the extra installs nothing and the `laya_mlx` backend is
72
+ unavailable.
73
+
74
+ ## Quickstart: the fallback chain
75
+
76
+ `Client.from_env()` builds a backend chain from whatever is installed and
77
+ configured in the process environment. Pass an explicit `env=` mapping
78
+ instead (e.g. in tests) to configure from something other than
79
+ `os.environ`:
80
+
81
+ ```python
82
+ from decide import Client, Choice, Score, Noul
83
+
84
+ client = Client.from_env()
85
+
86
+ r = client.decide(
87
+ state={"ticket": "I was charged twice, please refund."},
88
+ questions={
89
+ "team": Choice(
90
+ "Which team handles this?",
91
+ {
92
+ "billing": "charges, refunds",
93
+ "engineering": "bugs and outages",
94
+ "sales": "pricing and upgrades",
95
+ },
96
+ ),
97
+ "severity": Score("How severe is this?", ["minor", "degraded", "blocked"]),
98
+ "refund": Noul("Does the customer ask for money back?"),
99
+ },
100
+ )
101
+
102
+ r.choices["team"].choice # "billing"
103
+ r.choices["team"].probabilities # {"billing": 1.0, "engineering": 0.0, "sales": 0.0}
104
+ r.scores["severity"].score # 1.5522 (expected level index, 0..len(levels)-1)
105
+ r.scores["severity"].probabilities # [0.0197, 0.4083, 0.5719]
106
+ r.nouls["refund"].noul # 0.9461
107
+ r.meta.backend # "laya_mlx"
108
+ r.meta.latency_ms # 47.9
109
+ r.meta.route # ["laya_mlx:ok"]
110
+ ```
111
+
112
+ This was run against the `laya_mlx` backend
113
+ (`DECIDE_LOCAL_MODEL=aac6fef/laya-multilingual-mlx`); every value above is
114
+ the real output of that run, not illustrative.
115
+
116
+ `from_env` picks the chain in this order, using whatever is both installed
117
+ and configured: `laya_mlx` or `laya` (if `DECIDE_LOCAL_MODEL` is set),
118
+ `typesafe` (if `TYPESAFE_API_KEY` is set), `openrouter` (if
119
+ `OPENROUTER_API_KEY` is set), `llm` (if `DECIDE_LLM_BASE_URL` or
120
+ `OPENAI_API_KEY` is set). Set `DECIDE_BACKENDS="laya,typesafe"` to override
121
+ the order explicitly. If nothing is configured, `from_env` raises
122
+ `ConfigError` naming every variable it checked.
123
+
124
+ `AsyncClient` has the same surface, `await`ed: `await client.decide(...)`,
125
+ `await client.decide_batch(...)`, `AsyncClient.from_env(...)`.
126
+
127
+ ## Backends
128
+
129
+ | Module | Backend name | Extra | Notes |
130
+ |---|---|---|---|
131
+ | `typesafe.py` | `typesafe` | none (httpx only) | `POST {base_url}/v1/systemone`, bearer auth. Default base URL `https://api.typesafe.ai`, default model `jev-latest`. |
132
+ | `openrouter.py` | `openrouter` | none | Same wire shape as `typesafe`, `POST https://openrouter.ai/api/alpha/decisions`, default model `typesafe/jev-latest`. |
133
+ | `laya.py` | `laya` | `pydecide[laya]` | Local PyTorch `laya.Agent`, default model `convaiinnovations/laya` when constructed directly. |
134
+ | `laya_mlx.py` | `laya_mlx` | `pydecide[mlx]` (Python 3.11+) | Local MLX `laya_mlx.Agent` (Apple Silicon), default model `aac6fef/laya-multilingual-mlx` when constructed directly. |
135
+ | `crossencoder.py` | `crossencoder` | `pydecide[st]` | Local `sentence_transformers.CrossEncoder`. Not configurable from the environment; construct it directly and pass it to `Client([...])`. |
136
+ | `llm.py` | `llm` | none (httpx only) | Any OpenAI-compatible chat-completions server, default base URL `https://api.openai.com/v1`, default model `gpt-4o-mini`. The least trustworthy backend (see below). |
137
+
138
+ `crossencoder` is built by hand, for example:
139
+
140
+ ```python
141
+ from decide import Client, Choice
142
+ from decide.backends.crossencoder import CrossEncoderBackend
143
+
144
+ backend = CrossEncoderBackend("cross-encoder/ms-marco-MiniLM-L6-v2")
145
+ client = Client([backend])
146
+
147
+ r = client.decide(
148
+ "I was charged twice for the same order last week, please refund the duplicate charge.",
149
+ {
150
+ "team": Choice(
151
+ "Which team should handle this?",
152
+ {
153
+ "billing": "Charges, invoices, payment problems, refunds",
154
+ "eng": "Bugs, crashes, broken features",
155
+ "shipping": "Delivery status, delays, lost packages",
156
+ },
157
+ )
158
+ },
159
+ )
160
+ r.choices["team"].choice # "billing"
161
+ r.choices["team"].probabilities # {"billing": 0.947, "eng": 0.019, "shipping": 0.034}
162
+ ```
163
+
164
+ ### Environment variables
165
+
166
+ | Variable | Backend | Meaning |
167
+ |---|---|---|
168
+ | `DECIDE_LOCAL_MODEL` | `laya`, `laya_mlx` | Hugging Face repo id (or local path) of the model to load. Required to auto-select either backend. |
169
+ | `TYPESAFE_API_KEY` | `typesafe` | API key, sent as `Authorization: Bearer`. Required to auto-select `typesafe`. |
170
+ | `TYPESAFE_BASE_URL` | `typesafe` | Overrides the default `https://api.typesafe.ai`. |
171
+ | `OPENROUTER_API_KEY` | `openrouter` | API key, sent as `Authorization: Bearer`. Required to auto-select `openrouter`. |
172
+ | `DECIDE_LLM_BASE_URL` | `llm` | Base URL of an OpenAI-compatible chat-completions server. Setting it (or `OPENAI_API_KEY`) auto-selects `llm`. |
173
+ | `OPENAI_API_KEY` | `llm` | API key, sent as `Authorization: Bearer`, if the server needs one. |
174
+ | `DECIDE_LLM_MODEL` | `llm` | Model name to request; defaults to `gpt-4o-mini`. |
175
+ | `DECIDE_BACKENDS` | `Client.from_env` | Comma-separated backend names, overriding auto-detection entirely. |
176
+ | `DECIDE_MIN_CONFIDENCE` | `Client.from_env` (`Gate`) | Float threshold for the default `Gate` built by `from_env`, when no explicit `policy` is passed. |
177
+ | `DECIDE_API_KEY` | `decide serve` | Bearer token required to call the server, when `--api-key` is not passed. The flag takes precedence over this variable. |
178
+
179
+ ## Gating and fallback
180
+
181
+ ```python
182
+ from decide import Gate
183
+
184
+ Gate(
185
+ min_confidence=0.0, # top probability of a Choice, max(noul, 1-noul) for a Noul
186
+ per_question=None, # optional {"question_name": threshold} overrides
187
+ on_error="next", # or "raise" to stop the chain on the first BackendError
188
+ )
189
+ ```
190
+
191
+ For each backend in order: call it. On `BackendError` with `on_error="next"`,
192
+ append `"<name>:error"` to `meta.route` and try the next backend (with
193
+ `on_error="raise"`, the error propagates immediately instead). If the
194
+ response fails the gate, append `"<name>:low_confidence:<question>=<value><threshold>"`
195
+ and try the next backend. If it passes, append `"<name>:ok"` and return.
196
+ Score answers are never gated (there is no single confidence number for an
197
+ expected value over levels).
198
+
199
+ If every backend is exhausted without a passing response, the best response
200
+ seen so far (highest minimum confidence across its gated answers) is
201
+ returned, with `meta.route[-1] == "<name>:accepted_low_confidence"`. If no
202
+ backend produced any response at all, `Client.decide` raises
203
+ `AllBackendsFailed(route, errors)`.
204
+
205
+ Route strings you will see in `meta.route`:
206
+
207
+ - `"<name>:ok"` - the backend answered and passed the gate.
208
+ - `"<name>:error"` - the backend raised a `BackendError`.
209
+ - `"<name>:low_confidence:<question>=<value><threshold>"` - the backend
210
+ answered but at least one gated question fell below its threshold.
211
+ - `"<name>:accepted_low_confidence"` - appended once, at the end of the
212
+ route, when no backend passed the gate and the best low-confidence
213
+ response was returned instead.
214
+
215
+ `Client.decide_batch`/`AsyncClient.decide_batch` run the same chain per
216
+ input state, preserving input order; a state that clears the gate on an
217
+ earlier backend is not sent to later ones. `Client.decide_batch` uses a
218
+ backend's real batch path when `capabilities().batch` is true, looping
219
+ `decide` per request otherwise. `AsyncClient.decide_batch` always loops
220
+ `adecide` per state, concurrently via `asyncio.gather`; it does not use a
221
+ backend's batch path in v1. If any state in the batch is left unrouted,
222
+ `AllBackendsFailed` carries `partial` (every `Response` that did resolve,
223
+ keyed by input index) and `failed` (the route so far for every state that
224
+ did not), so the resolved siblings are not silently lost.
225
+
226
+ ## Server: point TypeSafe's SDK at a local model
227
+
228
+ Requires the `server` extra:
229
+
230
+ ```bash
231
+ pip install "pydecide[server]"
232
+ DECIDE_LOCAL_MODEL=aac6fef/laya-multilingual-mlx decide serve --backends laya_mlx --port 8811
233
+ ```
234
+
235
+ `GET /health` reports liveness and the configured backend names;
236
+ `GET /v1/models` lists them in an OpenAI-style shape; `POST /v1/systemone`
237
+ takes a TypeSafe `SystemOneRequest` body and returns a
238
+ `SystemOneResponse`-shaped body plus a `decide` extension carrying our own
239
+ backend/route metadata. Errors come back as
240
+ `{"error": {"message": ..., "type": ...}}` with a matching HTTP status.
241
+
242
+ If `--api-key`/`DECIDE_API_KEY` is set, requests must send a matching
243
+ `Authorization: Bearer <token>` header; the key must be ASCII, since HTTP
244
+ header bytes are latin-1 decoded by the server before comparison.
245
+
246
+ A raw request against the running server above:
247
+
248
+ ```bash
249
+ curl -s -X POST http://127.0.0.1:8811/v1/systemone \
250
+ -H "Content-Type: application/json" \
251
+ -d '{
252
+ "state": {"ticket": "I was charged twice, please refund."},
253
+ "questions": {
254
+ "team": {"type": "choice", "instructions": "Which team handles this?",
255
+ "criteria": {"billing": "charges, refunds", "engineering": "bugs and outages", "sales": "pricing and upgrades"}},
256
+ "severity": {"type": "score", "instructions": "How severe is this?", "criteria": ["minor", "degraded", "blocked"]},
257
+ "refund": {"type": "noul", "instructions": "Does the customer ask for money back?"}
258
+ }
259
+ }'
260
+ ```
261
+
262
+ ```json
263
+ {
264
+ "model": "aac6fef/laya-multilingual-mlx",
265
+ "answers": {
266
+ "team": {"type": "choice", "choice": "billing", "confidence": 1.0,
267
+ "probabilities": {"billing": 1.0, "engineering": 0.0, "sales": 0.0}},
268
+ "severity": {"type": "score", "score": 1.5522, "confidence": 0.5719,
269
+ "legend": {"0": "minor", "1": "degraded", "2": "blocked"},
270
+ "probabilities": {"0": 0.0197, "1": 0.4083, "2": 0.5719}},
271
+ "refund": {"type": "noul", "noul": 0.9461}
272
+ },
273
+ "usage": {"input_tokens": 0, "output_tokens": 0},
274
+ "decide": {"backend": "laya_mlx", "latency_ms": 50.5, "route": ["laya_mlx:ok"]}
275
+ }
276
+ ```
277
+
278
+ And the same request through TypeSafe's own SDK, pointed at the local
279
+ server (no API key is needed since this server has none configured, but the
280
+ SDK requires a non-empty string):
281
+
282
+ ```python
283
+ from typesafe_sdk import TypeSafeClient, Choice, Score, Noul
284
+
285
+ client = TypeSafeClient(api_key="anything", base_url="http://127.0.0.1:8811")
286
+
287
+ resp = client.system_one(
288
+ state={"ticket": "I was charged twice, please refund."},
289
+ questions={
290
+ "team": Choice(
291
+ instructions="Which team handles this?",
292
+ criteria={
293
+ "billing": "charges, refunds",
294
+ "engineering": "bugs and outages",
295
+ "sales": "pricing and upgrades",
296
+ },
297
+ ),
298
+ "severity": Score(
299
+ instructions="How severe is this?", criteria=["minor", "degraded", "blocked"]
300
+ ),
301
+ "refund": Noul(instructions="Does the customer ask for money back?"),
302
+ },
303
+ )
304
+ ```
305
+
306
+ ```text
307
+ model='aac6fef/laya-multilingual-mlx' usage=Usage(input_tokens=0, output_tokens=0)
308
+ answers={'team': ChoiceAnswer(type='choice', choice='billing', confidence=1.0,
309
+ probabilities={'billing': 1.0, 'engineering': 0.0, 'sales': 0.0}),
310
+ 'severity': ScoreAnswer(type='score', score=1.5522, confidence=0.5719,
311
+ legend={0: 'minor', 1: 'degraded', 2: 'blocked'},
312
+ probabilities={0: 0.0197, 1: 0.4083, 2: 0.5719}),
313
+ 'refund': NoulAnswer(type='noul', noul=0.9461)}
314
+ ```
315
+
316
+ TypeSafe's SDK parsed our server's response without modification: it is a
317
+ genuine `SystemOneResponse`, not a hand-shaped dict.
318
+
319
+ ## CLI
320
+
321
+ ```bash
322
+ decide ask "I was charged twice, please refund." \
323
+ --choice "team=billing,engineering,sales" \
324
+ --score "severity=minor,degraded,blocked" \
325
+ --noul "refund=Does the customer ask for money back?"
326
+ ```
327
+
328
+ ```text
329
+ NAME TYPE ANSWER PROBABILITIES
330
+ team choice billing billing=0.93 engineering=0.02 sales=0.05
331
+ severity score degraded 1.09 (degraded)
332
+ refund noul true 0.87
333
+ backend=laya_mlx route=laya_mlx:ok latency=36.9ms
334
+ ```
335
+
336
+ `--json` prints a machine-readable payload instead of the table.
337
+ `--min-confidence` and `--model` are also available on `ask`.
338
+
339
+ ```bash
340
+ decide backends
341
+ ```
342
+
343
+ ```text
344
+ typesafe installed=yes configured=no
345
+ openrouter installed=yes configured=no
346
+ laya installed=yes configured=yes
347
+ laya_mlx installed=yes configured=yes
348
+ crossencoder installed=yes configured=n/a
349
+ llm installed=yes configured=no
350
+ ```
351
+
352
+ `decide serve --backends a,b --host 127.0.0.1 --port 8811 [--api-key TOKEN] [--min-confidence FLOAT]`
353
+ runs the HTTP server described above.
354
+
355
+ ## What the probabilities mean
356
+
357
+ Every probability in a `ChoiceAnswer`, `ScoreAnswer` or `NoulAnswer` is
358
+ whatever the backend reported; `decide` does not calibrate, smooth or
359
+ verify it. What that means differs by backend: `typesafe`, `openrouter` and
360
+ `laya`/`laya_mlx` are purpose-built decision models, but their outputs are
361
+ still self-reported by the model and not audited by this library. The
362
+ `crossencoder` backend turns a relevance reranker's raw logits into a
363
+ softmax or sigmoid - the result is a *normalized score* forced to distribute
364
+ mass over the supplied candidates, not a calibrated probability; a `Choice`
365
+ will still pick a winner even when every candidate is a bad fit, and a
366
+ `Noul` of 0.9 does not mean the condition holds 90% of the time. The `llm`
367
+ backend is the least trustworthy of all: it prompts a general chat model to
368
+ estimate its own confidence in JSON, with no guarantee the model attends to
369
+ every candidate or keeps its numbers well calibrated. Treat all of this
370
+ accordingly - as a signal to gate and fall back on, not as ground truth.
371
+
372
+ ## Status
373
+
374
+ `pydecide` is at 0.1.0. The public API may still change before a 1.0
375
+ release.
376
+
377
+ ## License
378
+
379
+ MIT, see [LICENSE](LICENSE).