jevdecision 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,25 @@
1
+ # Changelog
2
+
3
+ All notable changes are documented here, following [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/).
4
+ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [0.1.0] - 2026-10-13
7
+
8
+ ### Added
9
+ - Installable standard-library-only package, annotated public API, MIT license, source/sdist examples, and `jev-decision` / `jev-decision-eval` commands.
10
+ - Typed per-question refusals, refusal counts/rates, and score Brier, log-loss and ECE metrics.
11
+ - Versioned decision/evaluation/provenance records with producer metadata and canonical JSON.
12
+
13
+ ### Changed
14
+ - Live evaluation durably appends responses and case results in a sibling staging directory; completed output is published atomically. Authentication failures stop with exit 2.
15
+ - Decision CLI returns exit 1 for a result containing refusals while retaining sibling answers.
16
+
17
+ ### Fixed
18
+ - Correctly rounded floating-point accumulation gives identical bundled replay metrics on Python 3.11 and 3.13.
19
+ - Examples ship once in the source/sdist, rather than being duplicated in the installed package.
20
+ - Missing score probabilities stay unknown; only complete distributions can derive a score. Missing metric inputs fail requirements.
21
+ - Probability values must be finite and within [0,1]; complete distributions must sum to 1 within rounding tolerance.
22
+ - HTTP protocol failures become retryable `APIError`; HTTP 408 is retryable.
23
+
24
+ ### Security
25
+ - Remote provider URLs require HTTPS; HTTP is limited to loopback hosts. URL credentials are rejected and authenticated POST redirects are never followed.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bicheng Wang
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,2 @@
1
+ include CHANGELOG.md
2
+ recursive-include examples *.json *.jsonl
@@ -0,0 +1,364 @@
1
+ Metadata-Version: 2.4
2
+ Name: jevdecision
3
+ Version: 0.1.0
4
+ Summary: Typed AI-assisted decisions and replayable offline evaluation records
5
+ Author: Bicheng Wang
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/BichengWang/ai-decision-system/tree/main/ai-workflows/jev-decision
8
+ Project-URL: Source, https://github.com/BichengWang/ai-decision-system
9
+ Project-URL: Issues, https://github.com/BichengWang/ai-decision-system/issues
10
+ Project-URL: Changelog, https://github.com/BichengWang/ai-decision-system/blob/main/ai-workflows/jev-decision/CHANGELOG.md
11
+ Keywords: decisions,evaluation,replay,probability,gating
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Dynamic: license-file
26
+
27
+ # Jev decision model (`jevdecision`)
28
+
29
+ Replayable decision records for AI-assisted go/no-go decisions. A decision model answers bounded
30
+ questions about a state with **typed answers and reported probabilities** instead of
31
+ generated text: a yes/no probability, one label from a fixed set, or a position
32
+ on an ordered scale. These can inform gating agent tool calls, routing tickets,
33
+ and triage; missing choice uncertainty never becomes permission to route.
34
+
35
+ [Jev](https://docs.typesafe.ai/api) (TypeSafe's System One model) is the default
36
+ backend. The [OpenAI Decisions API](https://developers.openai.com/api/docs/guides/decisions)
37
+ is a drop-in alternative: questions are defined once and answers come back in the
38
+ same types from either provider. The package uses only the Python standard
39
+ library, so it has no dependencies and no lockfile.
40
+
41
+ ## Install
42
+
43
+ Requires Python 3.11 or later; runtime dependencies: none. Examples live in the clone
44
+ and source distribution under `examples/`; they are not installed in the wheel.
45
+
46
+ ```bash
47
+ # Once 0.1.0 is published:
48
+ pip install jevdecision
49
+ ```
50
+
51
+ The distribution includes `jev-decision` and `jev-decision-eval`; existing
52
+ `python -m jevdecision` and `python -m jevdecision.evaluation` commands remain supported.
53
+ Use the `examples/` directory in a clone or unpacked source distribution.
54
+
55
+ ## Question types
56
+
57
+ | Type | Jev request | OpenAI request | Answer |
58
+ |---|---|---|---|
59
+ | `Noul` (alias `Predicate`) | `noul` | `predicate` | `NoulAnswer.probability`: probability of yes |
60
+ | `Choice` | `choice`, `criteria` = label → description | `choice`, `choices` = `[{value, description}]` | `ChoiceAnswer.choice`, `.probabilities` per label, `.confidence` |
61
+ | `Score` | `score`, `criteria` = ordered levels | `score`, `levels` = `[{label}]` | `ScoreAnswer.score` (probability-weighted level index), `.level`, `.probabilities` per level |
62
+
63
+ Questions are validated before anything is sent: names are unique identifiers,
64
+ instructions are non-empty, a choice has 1 to 255 options, and a score has 2 to
65
+ 10 distinct levels. Responses are checked against the questions they answer.
66
+ Every question must have a matching answer or an explicit `type: "refusal"` result; a choice must be one of the
67
+ options, and probabilities and scores must lie in range. Probabilities must be finite and within [0,1]. Complete distributions must sum
68
+ to 1 within `1e-6 * number_of_options + 1e-3`; partial collections retain unknown
69
+ entries and are not treated as complete distributions. Invalid responses raise
70
+ `ResponseError`, so the application never acts on a malformed answer.
71
+
72
+ ## Usage
73
+
74
+ ```python
75
+ from jevdecision import Choice, DecisionModel, Noul, Score
76
+
77
+ model = DecisionModel.from_env() # DECISION_BACKEND=jev (default) or openai
78
+
79
+ decision = model.decide(
80
+ "Help! My payouts have been failing for 3 days.",
81
+ [
82
+ Noul("refund", "Is the customer asking for a refund?"),
83
+ Choice("department", "Which team should handle this?", {
84
+ "billing": "Payments, invoicing, refunds",
85
+ "technical": "Bugs, outages, integrations",
86
+ "sales": "Pricing, upgrades, new accounts",
87
+ }),
88
+ Score("frustration", "How frustrated is the customer?", ["Calm", "Frustrated", "Very angry"]),
89
+ ],
90
+ )
91
+ decision["department"].choice # "technical"
92
+ decision["frustration"].level # "Frustrated"
93
+
94
+ # Single-question helpers
95
+ model.gate(state, refund_question, threshold=0.8) # bool
96
+ model.route(state, department_question, min_confidence=0.7,
97
+ fallback="human_review") # label or fallback
98
+ ```
99
+
100
+ `route` uses the provider's `confidence` when it returns one, otherwise the
101
+ reported probability of the chosen label. If neither is supplied, it returns
102
+ `fallback` (default `None`), even with `min_confidence=0.0`. A reported confidence
103
+ still takes precedence over a reported choice probability; neither is a calibrated
104
+ safety guarantee or a replacement for application policy.
105
+
106
+ ### Refusals and score uncertainty
107
+
108
+ An explicit per-question refusal becomes `RefusalAnswer` with `.name` and
109
+ `.type == "refusal"`. `ask`/`decide` retain valid sibling answers. `gate` returns
110
+ `False` for a refusal, even at threshold zero; `route` returns its fallback.
111
+ The OpenAI guide documents this marker; TypeSafe's reference does not document
112
+ refusal fields, so no reason or other provider-specific field is assumed.
113
+
114
+ Score probabilities are a tuple in level order, with `None` for missing entries.
115
+ `.probability` is the reported probability of the nearest level, or `None`.
116
+ A supplied score remains usable without probabilities; deriving a missing score
117
+ requires a complete distribution. Missing values are never zero-filled.
118
+
119
+ ### Choice uncertainty contract
120
+
121
+ `ask` and `decide` retain a valid choice even when its uncertainty is unknown:
122
+
123
+ - `.probabilities` contains only labels whose probabilities were actually supplied,
124
+ in question-option order. Missing, null, or empty probability collections yield `{}`.
125
+ - `.probability` is `None` when the chosen label has no reported probability.
126
+ A supplied `confidence` does not manufacture a choice probability.
127
+ - `.ranked()` ranks only reported probabilities. `to_dict()` preserves missing
128
+ entries instead of fabricating a complete distribution.
129
+ - A reported `0.0` remains zero, distinct from an absent value.
130
+
131
+ **Safety correction:** earlier code fabricated probability `1.0` for a choice
132
+ without probabilities and filled omitted labels with `0.0`. Callers must use
133
+ `.probability` or `.probabilities.get(label)` and handle `None`; do not rely on
134
+ every label being present. To inspect an unscored recommendation, use `ask` or
135
+ `decide`, not a confidence-gated route with an invented probability.
136
+
137
+ ## Configuration
138
+
139
+ | Variable | Backend | Meaning |
140
+ |---|---|---|
141
+ | `DECISION_BACKEND` | both | `jev` (default) or `openai` |
142
+ | `TYPESAFE_API_KEY` | `jev` | Bearer token for `https://api.typesafe.ai/v1/systemone` |
143
+ | `JEV_MODEL` | `jev` | Model route (default `jev-latest`) |
144
+ | `OPENAI_API_KEY` | `openai` | Bearer token for `https://api.openai.com/v1/decisions` |
145
+ | `OPENAI_DECISIONS_MODEL` | `openai` | Model (default `gpt-6-luna`) |
146
+
147
+ Every backend also takes `api_key`, `model`, `base_url`, `path`, `timeout`,
148
+ `max_retries`, `backoff`, `max_retry_wait`, and `transport` arguments.
149
+ `max_retries` must be a non-negative integer; zero disables retries. `backoff`
150
+ must be a finite non-negative number of seconds; zero permits immediate retries
151
+ unless `Retry-After` requests a wait. Invalid settings raise `DecisionError`
152
+ before any request is sent. Use `base_url` and `path` for a gateway
153
+ that serves Jev elsewhere, for example `path="/v1/decisions"` with
154
+ `model="typesafe/jev"`. Request timeout (408), rate-limit (429), server (5xx), connection and HTTP protocol errors are
155
+ retried with exponential backoff (2 retries by default). When the provider sends a
156
+ `Retry-After` header (seconds or an HTTP date), the client waits at least that long
157
+ before retrying. A provider that asks for more than `max_retry_wait` seconds (60 by
158
+ default) fails the request at once with `APIError`, whose `retry_after` attribute
159
+ carries the requested wait, instead of blocking the caller. Other HTTP errors are
160
+ raised as `APIError` immediately. `transport` is any callable
161
+ `(url, headers, body_bytes, timeout) -> dict`, which tests use to replace the
162
+ network.
163
+
164
+ Remote `base_url` values require HTTPS. HTTP is accepted only for `localhost`
165
+ or a literal loopback IP address (IPv4 or IPv6). Credentials, query strings and
166
+ fragments in the base URL are rejected with `QuestionError`. Authenticated POSTs
167
+ never follow redirects: every 3xx response raises a non-retryable `APIError`.
168
+ Custom transports must enforce equivalent credential protection themselves.
169
+
170
+ The structured state can be text, a JSON object, or a list of non-empty strings. Jev
171
+ receives it unchanged. The OpenAI backend sends text, so it serializes an object
172
+ as JSON with sorted keys and joins a list with blank lines.
173
+
174
+ ## Command line
175
+
176
+ From this directory, with Python 3.11 or later:
177
+
178
+ ```bash
179
+ python3 -m unittest discover -s tests
180
+ python3 -m jevdecision --questions examples/support-ticket.json \
181
+ --state "Help! My payouts have been failing for 3 days." --dry-run
182
+ python3 -m jevdecision --questions examples/support-ticket.json \
183
+ --state-file ticket.json --backend openai
184
+ ```
185
+
186
+ The questions file uses Jev's native question map
187
+ ([`examples/support-ticket.json`](examples/support-ticket.json)). `predicate` is
188
+ accepted as an alias of `noul`. A `.json` state file is sent as structured
189
+ state, and any other file is sent as text. `--dry-run` prints the provider request
190
+ without sending it, so it needs no API key; the state is validated first either
191
+ way. The decision is printed as JSON. Exit codes: 0 for success, 1 for a result containing refusals or a provider
192
+ or response error, 2 for invalid input (questions, state, or a `--timeout` that is
193
+ not a positive number of seconds).
194
+
195
+ From the repository root, `make jev-test` runs the tests and
196
+ `make jev-dry-run` prints the example request for both backends.
197
+
198
+ ## Evaluating a decision model
199
+
200
+ Measure the model on labeled cases before software acts on its answers.
201
+ `jevdecision.evaluation` scores every question against the expected answers and
202
+ can gate the result on thresholds you set.
203
+
204
+ A case file is JSON Lines with a state and the expected answer to some or all
205
+ questions: `true` or `false` for a `noul`, a label for a `choice`, and a level
206
+ (or its index) for a `score`:
207
+
208
+ ```json
209
+ {"id": "t02", "state": "I was charged twice for March. Please refund the duplicate charge.",
210
+ "labels": {"refund": true, "department": "billing", "frustration": "Frustrated"}}
211
+ ```
212
+
213
+ | Type | Metrics |
214
+ |---|---|
215
+ | `noul` | `accuracy` (at probability 0.5), `brier`, `log_loss`, `ece`, `base_rate`, `mean_probability` |
216
+ | `choice` | `accuracy`, `brier` (summed over labels), `log_loss`, `ece` (probability of the chosen label against correctness), a confusion matrix |
217
+ | `score` | `accuracy` (nearest level), `within_one`, `mae` (in level steps), `brier`, `log_loss`, `ece` (nearest level probability) |
218
+
219
+ Every question also reports `n`, the number of cases that label it, and
220
+ `accuracy_lower` and `accuracy_upper`, the 95% Wilson score interval for
221
+ `accuracy`. With 12 cases, 11 correct answers give an accuracy of 0.917 but an
222
+ interval of [0.646, 0.985], so a gate on `accuracy_lower` asks for enough cases as
223
+ well as a high hit rate. `ece` is the
224
+ expected calibration error over ten equal-width probability bins. Log loss clips
225
+ probabilities to [1e-6, 1 - 1e-6].
226
+
227
+ Every question also reports `refusals` and `refusal_rate` among its labeled,
228
+ parsed results. Refusals count in `n`, accuracy and its Wilson interval as
229
+ incorrect, and make probability metrics unmeasured. Score MAE and within-one
230
+ are also unmeasured if any result refuses. Request/parse errors remain separate.
231
+
232
+ Missing choice probabilities do not exclude otherwise valid answers from `n`,
233
+ accuracy, its interval, or the confusion matrix. Probability metrics require the
234
+ inputs they measure on **every scored case**, not a selectively observed subset:
235
+
236
+ - Brier score requires a reported probability for every option.
237
+ - Log loss requires the expected label's reported probability.
238
+ - ECE requires the chosen label's reported probability; provider `confidence`
239
+ is not substituted for that probability.
240
+
241
+ If any scored case lacks a metric's required inputs, that metric is `null` in
242
+ JSON and `unmeasured` in the report. A requirement on it fails. Per-case records
243
+ retain the prediction and a null chosen probability, and raw responses remain
244
+ available for replay. Re-evaluating older responses with missing probabilities
245
+ may therefore fail gates that previously scored fabricated values. Complete
246
+ probability responses retain their previous scores. Score Brier requires every
247
+ level probability, log loss requires the expected level probability, and ECE
248
+ requires the nearest level probability on every case. Missing inputs yield
249
+ `null` and fail metric requirements, as for choices.
250
+
251
+ A requirement file supplies explicit question metric bounds, each a `min` or
252
+ `max`. The overall JSON field is `status` (not a boolean `pass`):
253
+
254
+ - `PASS`: at least one valid question metric bound was supplied, and all checks pass.
255
+ - `FAIL`: an explicit metric bound or the error-rate check fails.
256
+ - `UNGATED`: all checks pass but there are no question metric bounds. Omitting
257
+ `--require`, `{}`, `{"max_error_rate": 0}`, and `{"questions": {}}` are all
258
+ ungated, not model-quality validation.
259
+
260
+ `max_error_rate` (default 0) bounds the share of cases whose request or response
261
+ failed; it alone is not a question gate. A failed case counts as an error, never
262
+ as a wrong answer. A bound on an unmeasured metric fails. A valid question bound
263
+ means a finite numeric `min`/`max` on a supported metric; it does not mean the
264
+ threshold is scientifically adequate. For example, an `n`-only gate checks sample
265
+ count, not accuracy. Select meaningful thresholds for the decision being made.
266
+ Malformed specs (including JSON `null`, `false`, arrays, empty per-question metric
267
+ objects and empty bounds) are invalid input, not ungated runs. In the Python
268
+ `evaluate()` API, `None` means no requirement spec. Individual requirement rows
269
+ retain boolean `pass` values; the overall report does not.
270
+
271
+ ```json
272
+ {"max_error_rate": 0.0,
273
+ "questions": {"refund": {"n": {"min": 10}, "accuracy_lower": {"min": 0.8}, "brier": {"max": 0.1}}}}
274
+ ```
275
+
276
+ ```bash
277
+ # Replay supplied responses: no API key; origin remains unverified
278
+ python3 -m jevdecision.evaluation --questions examples/support-ticket.json \
279
+ --cases examples/support-ticket-cases.jsonl \
280
+ --responses examples/support-ticket-responses.jsonl \
281
+ --require examples/support-ticket-requirements.json --out eval-run
282
+
283
+ # Ask the provider live (needs its API key); no question bounds means UNGATED
284
+ python3 -m jevdecision.evaluation --questions examples/support-ticket.json \
285
+ --cases examples/support-ticket-cases.jsonl --backend openai --out live-run
286
+ ```
287
+
288
+ `--out` must be new or empty. Files are prepared in a sibling `.NAME-*` staging
289
+ directory, then published with `os.replace` after all summaries are complete.
290
+ Live runs print the staging path and append/fsync each completed response and
291
+ case result before requesting the next case, including malformed JSON responses
292
+ that parsed as JSON but failed answer validation. Interrupted runs retain these
293
+ JSONL files for inspection and replay; the target remains available for a rerun.
294
+ A 401/403 stops immediately with exit 2 and retains progress without a final report.
295
+ There is no automatic resume; replay the saved responses with the original cases
296
+ (missing responses count as errors), or start a new live run.
297
+
298
+ Each run writes `evaluation.json` (canonical JSON),
299
+ a readable `EVALUATION.md`, `responses.jsonl` with the supplied replay responses
300
+ or responses obtained through the live backend, and `cases.jsonl` with one row
301
+ per case: each labeled question's expected and predicted answer (with probability
302
+ or score) and whether it was correct, or the case's error. `EVALUATION.md` lists
303
+ the first 20 incorrect answers. Exported responses can be passed as `--responses`
304
+ to re-score without calling a provider. The bundled replay produces byte-identical
305
+ `evaluation.json`, `provenance.json`,
306
+ `responses.jsonl` and `cases.jsonl` on Python 3.11 and 3.13; live-to-replay
307
+ metrics remain the same, but the report's `source` changes.
308
+
309
+ Decision records use `schema_version: 1`; evaluation and provenance records use
310
+ `schema_version: 2`. They carry `produced_by: {"name": "jevdecision", "version": "0.1.0"}`.
311
+ Raw provider response replay remains compatible with existing unversioned fixtures.
312
+ JSON records use sorted keys, compact separators and UTF-8 without ASCII escaping;
313
+ SHA-256 digests are lowercase hexadecimal. JSONL rows use the same encoding.
314
+
315
+ CLI runs also write a separate deterministic `provenance.json`:
316
+
317
+ - `inputs` records SHA-256 of the exact question, case, requirement and response
318
+ file bytes read for the run, with `null` for absent optional inputs.
319
+ - `response_mode` is `live` or `replay`. Replay origin is explicitly **unverified**:
320
+ files may be hand-written, and their `model` fields do not authenticate provider output.
321
+ - `outputs` records SHA-256 of `evaluation.json`, `EVALUATION.md`, `responses.jsonl`
322
+ and `cases.jsonl`. The manifest does not hash itself, include timestamps, paths,
323
+ headers or credentials, or enter the metric records. Whitespace-only input
324
+ changes can therefore change provenance without changing `evaluation.json`.
325
+
326
+ Hashes identify bytes, not authenticity, reviewer independence or model quality.
327
+ Raw responses and labeled cases may contain sensitive material; inspect them
328
+ before sharing. `--backend` names the response format to replay (`jev` by default).
329
+ The CLI and Markdown display the same overall status. Exit codes: **0** for PASS,
330
+ **1** for FAIL or a provider that cannot be used, **2** for invalid input/unreadable files or live 401/403 (authentication failures
331
+ retain staged progress; invalid input produces no final artifacts), and **3** for UNGATED.
332
+
333
+ The example responses are hand-written in Jev's response format to show the
334
+ workflow. They are not output from Jev or any other model, and the example
335
+ report says nothing about any model's quality.
336
+
337
+ From the repository root, `make jev-eval` runs the example into a temporary directory.
338
+
339
+ ## Limitations
340
+
341
+ - Both provider APIs are new. The Jev request and response shapes follow
342
+ TypeSafe's published examples. The OpenAI Decisions API is in public beta, so
343
+ the parser accepts both its object and its list answer layouts. Recheck the
344
+ field mapping against the provider references before you depend on it in
345
+ production.
346
+ - Image inputs and per-request options beyond the model are not modeled yet.
347
+ - Unit tests use recorded response shapes and never call a provider.
348
+ - Only accuracy carries a confidence interval. Brier, log loss, calibration
349
+ error and MAE are point estimates, which are noisy with a few dozen cases, so set
350
+ `n` minimums that match the thresholds you rely on.
351
+ - A live evaluation sends one request per case, sequentially.
352
+ - Float sums use `math.fsum` for correctly rounded accumulation. The bundled replay
353
+ has identical bytes and hashes on Python 3.11 and 3.13; this check is not a
354
+ guarantee for every platform, math library or future algorithm version.
355
+
356
+ ## Local software verification
357
+
358
+ Release checks cover refusals, unknown probabilities, malformed distributions,
359
+ HTTP retries and redirect protection, interrupted live progress, authentication
360
+ stops and atomic publication. Tests use synthetic provider responses and a local
361
+ HTTP fixture. Fresh wheel installs exercise both backend dry runs and the gated
362
+ 12-case replay example; exported responses reproduce the evaluation bytes across
363
+ Python 3.11 and 3.13. These checks are not live-provider calls, calibration results,
364
+ or deployment-safety evidence.