decision-toolkit 0.1.0__py3-none-any.whl

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 (36) hide show
  1. decision_toolkit/__init__.py +102 -0
  2. decision_toolkit/__main__.py +8 -0
  3. decision_toolkit/adapters/__init__.py +165 -0
  4. decision_toolkit/adapters/base.py +134 -0
  5. decision_toolkit/adapters/gliner2.py +144 -0
  6. decision_toolkit/adapters/gliner2_decide.py +117 -0
  7. decision_toolkit/adapters/julia.py +168 -0
  8. decision_toolkit/adapters/laya.py +225 -0
  9. decision_toolkit/adapters/opendecider.py +144 -0
  10. decision_toolkit/calibration.py +79 -0
  11. decision_toolkit/catalog.py +472 -0
  12. decision_toolkit/cli.py +1277 -0
  13. decision_toolkit/config.py +90 -0
  14. decision_toolkit/contracts/__init__.py +58 -0
  15. decision_toolkit/contracts/common.py +61 -0
  16. decision_toolkit/contracts/mapping.py +165 -0
  17. decision_toolkit/contracts/openai_decisions_beta.py +133 -0
  18. decision_toolkit/contracts/systemone_v1.py +140 -0
  19. decision_toolkit/decision_model.py +692 -0
  20. decision_toolkit/engine/__init__.py +111 -0
  21. decision_toolkit/errors.py +138 -0
  22. decision_toolkit/limits.py +114 -0
  23. decision_toolkit/metrics.py +198 -0
  24. decision_toolkit/models.py +588 -0
  25. decision_toolkit/observability.py +145 -0
  26. decision_toolkit/py.typed +0 -0
  27. decision_toolkit/registry.py +283 -0
  28. decision_toolkit/results.py +355 -0
  29. decision_toolkit/schema.py +516 -0
  30. decision_toolkit/server.py +930 -0
  31. decision_toolkit/tracing.py +44 -0
  32. decision_toolkit-0.1.0.dist-info/METADATA +306 -0
  33. decision_toolkit-0.1.0.dist-info/RECORD +36 -0
  34. decision_toolkit-0.1.0.dist-info/WHEEL +4 -0
  35. decision_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  36. decision_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,168 @@
1
+ """`decision_toolkit.adapters.julia` — Julia-1's single-graph layout.
2
+
3
+ Contract (ported from the official `julia/data.py` sequence builder and the
4
+ ONNX repository's browser runtime):
5
+
6
+ [CLS] <type> question: <instructions> [SEP] ([MASK] opt0) ([MASK] opt1) … [SEP] <state> [SEP]
7
+ one ONNX graph: input_ids, attention_mask, marker_pos, marker_mask, qtype -> logits
8
+ probabilities = softmax(logits); noul options are [false, true], so p(true) = p[1]
9
+
10
+ Unlike Laya there is no separate head and no temperature: the exported graph
11
+ scores the options directly.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from collections.abc import Mapping
18
+ from typing import Any
19
+
20
+ from ..engine import OnnxEngine, encode
21
+ from ..errors import AdapterError
22
+ from .base import (
23
+ build_choice_answer,
24
+ build_noul_answer,
25
+ build_score_answer,
26
+ question_options,
27
+ softmax,
28
+ state_to_text,
29
+ )
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ QTYPES = {"choice": 0, "score": 1, "noul": 2}
34
+
35
+
36
+ class JuliaAdapter:
37
+ """System One over a Julia-1 ONNX export (one graph, marker/qtype inputs)."""
38
+
39
+ name = "julia"
40
+
41
+ def __init__(self, *, max_length: int = 1024, head_length: int = 256) -> None:
42
+ self.max_length = int(max_length)
43
+ self.head_length = int(head_length)
44
+
45
+ # ------------------------------------------------------------------ setup
46
+ @staticmethod
47
+ def _special_ids(tokenizer: Any) -> tuple[int, int, int]:
48
+ for mask_name, bos_name, eos_name in (
49
+ ("<mask>", "<bos>", "<eos>"),
50
+ ("[MASK]", "[CLS]", "[SEP]"),
51
+ ):
52
+ ids = (
53
+ tokenizer.token_to_id(mask_name),
54
+ tokenizer.token_to_id(bos_name),
55
+ tokenizer.token_to_id(eos_name),
56
+ )
57
+ if all(token is not None for token in ids):
58
+ return ids # type: ignore[return-value]
59
+ raise AdapterError(
60
+ "the tokenizer exposes neither <mask>/<bos>/<eos> nor [MASK]/[CLS]/[SEP]; "
61
+ "this is not a Julia-1 tokenizer"
62
+ )
63
+
64
+ @staticmethod
65
+ def _render_options(question: Mapping[str, Any]) -> list[str]:
66
+ criteria = question.get("criteria")
67
+ kind = question.get("type")
68
+ if kind == "choice":
69
+ return [f"{key}: {value}" if value else key for key, value in criteria.items()]
70
+ if kind == "score":
71
+ return [str(level) for level in criteria]
72
+ criteria = criteria or {}
73
+ return [
74
+ "false: " + (criteria.get("false") or "false"),
75
+ "true: " + (criteria.get("true") or "true"),
76
+ ]
77
+
78
+ def _sequence(
79
+ self,
80
+ tokenizer: Any,
81
+ state_text: str,
82
+ question: Mapping[str, Any],
83
+ options: list[str],
84
+ ) -> tuple[list[int], list[int], bool]:
85
+ mask, bos, eos = self._special_ids(tokenizer)
86
+ truncated = False
87
+ instructions = str(question.get("instructions") or "")
88
+ head_ids = encode(tokenizer, f"{question['type']} question: {instructions}")
89
+ option_ids: list[list[int]] = []
90
+ for option in options:
91
+ encoded = encode(tokenizer, " " + option)
92
+ if len(encoded) > 48:
93
+ truncated = True
94
+ option_ids.append([mask, *encoded[:48]])
95
+
96
+ budget = self.head_length - sum(len(option) for option in option_ids)
97
+ if budget < 16:
98
+ truncated = True
99
+ per = max(4, (self.head_length - 16) // max(1, len(option_ids)))
100
+ option_ids = [option[:per] for option in option_ids]
101
+ budget = self.head_length - sum(len(option) for option in option_ids)
102
+ if len(head_ids) > max(8, budget):
103
+ truncated = True
104
+ head_ids = head_ids[: max(8, budget)]
105
+
106
+ ids = [bos, *head_ids, eos]
107
+ markers: list[int] = []
108
+ for option in option_ids:
109
+ markers.append(len(ids))
110
+ ids.extend(option)
111
+ ids.append(eos)
112
+
113
+ room = max(1, self.max_length - len(ids) - 1)
114
+ state_ids = encode(tokenizer, state_text)
115
+ if len(state_ids) > room:
116
+ truncated = True
117
+ state_ids = state_ids[:room]
118
+ all_ids = (ids + state_ids + [eos])[: self.max_length]
119
+ if len(markers) > sum(1 for marker in markers if marker < self.max_length):
120
+ truncated = True
121
+ return all_ids, [marker for marker in markers if marker < self.max_length], truncated
122
+
123
+ # -------------------------------------------------------------- inference
124
+ def system_one(
125
+ self, engine: OnnxEngine, state: Any, questions: Mapping[str, Mapping[str, Any]]
126
+ ) -> Mapping[str, Mapping[str, Any]]:
127
+ import numpy as np
128
+
129
+ tokenizer = engine.tokenizer
130
+ state_text = state_to_text(state)
131
+ answers: dict[str, dict[str, Any]] = {}
132
+
133
+ for name, question in questions.items():
134
+ kind, labels, _ = question_options(question)
135
+ options = self._render_options(question)
136
+ ids, markers, truncated = self._sequence(tokenizer, state_text, question, options)
137
+ if len(markers) != len(options):
138
+ raise AdapterError(
139
+ f"{name!r}: the options do not fit in {self.head_length} tokens"
140
+ )
141
+
142
+ input_ids = np.asarray([ids], dtype=np.int64)
143
+ attention = np.ones_like(input_ids)
144
+ logits = engine.session("model").run(None, {
145
+ "input_ids": input_ids,
146
+ "attention_mask": attention,
147
+ "marker_pos": np.asarray([markers], dtype=np.int64),
148
+ "marker_mask": np.ones((1, len(markers)), dtype=bool),
149
+ "qtype": np.asarray([QTYPES[kind]], dtype=np.int64),
150
+ })[0]
151
+
152
+ probabilities = softmax(np.asarray(logits)[0][: len(markers)].tolist())
153
+ if kind == "choice":
154
+ answer = build_choice_answer(labels, probabilities)
155
+ elif kind == "noul":
156
+ answer = build_noul_answer(probabilities[1], probabilities[0])
157
+ else:
158
+ answer = build_score_answer(labels, probabilities)
159
+ if truncated:
160
+ answer["truncated"] = True
161
+ answer["warnings"] = [
162
+ f"input was truncated to fit max_length={self.max_length}"
163
+ ]
164
+ answers[name] = answer
165
+ return answers
166
+
167
+
168
+ __all__ = ["JuliaAdapter"]
@@ -0,0 +1,225 @@
1
+ """`decision_toolkit.adapters.laya` — Laya's split encoder+head layout.
2
+
3
+ Contract (ported from the reference implementation and validated against the
4
+ mininvox export, where it reproduced the torch runtime's answers):
5
+
6
+ [CLS] <type> question: <instructions> [SEP] ([MASK] opt0) ([MASK] opt1) … [SEP] <state> [SEP]
7
+ encoder(input_ids, attention_mask) -> last_hidden_state
8
+ head(hidden_states, marker_pos, marker_mask, qtype, attention_mask)
9
+ -> logits per marker + act_logits
10
+ choice / score: softmax(logits / temperature)
11
+ noul: options are [false, true], so p(true) = p[1]
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import math
18
+ from collections.abc import Mapping
19
+ from typing import Any
20
+
21
+ from ..engine import OnnxEngine, encode
22
+ from ..errors import AdapterError
23
+ from .base import (
24
+ build_choice_answer,
25
+ build_noul_answer,
26
+ build_score_answer,
27
+ question_options,
28
+ softmax,
29
+ state_to_text,
30
+ )
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ QTYPES = {"choice": 0, "score": 1, "noul": 2}
35
+ QTYPE_NAMES = ["choice", "score", "noul"]
36
+
37
+
38
+ class LayaAdapter:
39
+ """System One over a Laya split export (encoder + head + rl_agent_config)."""
40
+
41
+ name = "laya"
42
+
43
+ # ------------------------------------------------------------------ setup
44
+ @staticmethod
45
+ def _special_ids(tokenizer: Any) -> tuple[int, int, int]:
46
+ # Laya checkpoints ship two tokenizer serializations of the same contract:
47
+ # multilingual: <mask>/<bos>/<eos> · English: [MASK]/[CLS]/[SEP]
48
+ for mask_name, bos_name, eos_name in (
49
+ ("<mask>", "<bos>", "<eos>"),
50
+ ("[MASK]", "[CLS]", "[SEP]"),
51
+ ):
52
+ ids = (
53
+ tokenizer.token_to_id(mask_name),
54
+ tokenizer.token_to_id(bos_name),
55
+ tokenizer.token_to_id(eos_name),
56
+ )
57
+ if all(token is not None for token in ids):
58
+ return ids # type: ignore[return-value]
59
+ raise AdapterError(
60
+ "the tokenizer exposes neither <mask>/<bos>/<eos> nor [MASK]/[CLS]/[SEP]; "
61
+ "this is not a Laya tokenizer"
62
+ )
63
+
64
+ @staticmethod
65
+ def _render_options(question: Mapping[str, Any]) -> list[str]:
66
+ criteria = question.get("criteria")
67
+ kind = question.get("type")
68
+ if kind == "choice":
69
+ return [f"{key}: {value}" if value else key for key, value in criteria.items()]
70
+ if kind == "score":
71
+ return [f"level {index}: {level}" for index, level in enumerate(criteria)]
72
+ criteria = criteria or {}
73
+ return [
74
+ "false: " + (criteria.get("false") or "no, the statement does not hold"),
75
+ "true: " + (criteria.get("true") or "yes, the statement holds"),
76
+ ]
77
+
78
+ def _sequence(
79
+ self,
80
+ tokenizer: Any,
81
+ state_text: str,
82
+ question: Mapping[str, Any],
83
+ options: list[str],
84
+ config: Mapping[str, Any],
85
+ ) -> tuple[list[int], list[int], bool]:
86
+ max_len = int(config.get("max_len", 1024))
87
+ head_max_len = int(config.get("head_max_len", 256))
88
+ mask, bos, eos = self._special_ids(tokenizer)
89
+ truncated = False
90
+
91
+ instructions = str(question.get("instructions") or "")
92
+ head_ids = encode(tokenizer, f"{question['type']} question: {instructions}")
93
+ option_ids: list[list[int]] = []
94
+ for option in options:
95
+ encoded = encode(tokenizer, " " + option)
96
+ if len(encoded) > 48:
97
+ truncated = True
98
+ option_ids.append([mask, *encoded[:48]])
99
+
100
+ budget = head_max_len - sum(len(option) for option in option_ids)
101
+ if budget < 16:
102
+ truncated = True
103
+ per = max(4, (head_max_len - 16) // max(1, len(option_ids)))
104
+ option_ids = [option[:per] for option in option_ids]
105
+ budget = head_max_len - sum(len(option) for option in option_ids)
106
+ if len(head_ids) > max(8, budget):
107
+ truncated = True
108
+ head_ids = head_ids[: max(8, budget)]
109
+
110
+ ids = [bos, *head_ids, eos]
111
+ markers: list[int] = []
112
+ for option in option_ids:
113
+ markers.append(len(ids))
114
+ ids.extend(option)
115
+ ids.append(eos)
116
+
117
+ room = max(0, max_len - len(ids) - 1)
118
+ state_ids = encode(tokenizer, state_text)
119
+ if len(state_ids) > room:
120
+ truncated = True
121
+ state_ids = state_ids[:room]
122
+ all_ids = (ids + state_ids + [eos])[:max_len]
123
+ if len(markers) > sum(1 for marker in markers if marker < max_len):
124
+ truncated = True
125
+ return all_ids, [marker for marker in markers if marker < max_len], truncated
126
+
127
+ @staticmethod
128
+ def _temperature(config: Mapping[str, Any], qtype: int, count: int) -> float:
129
+ size = "2" if count <= 2 else "3-5" if count <= 5 else "6-10" if count <= 10 else "11+"
130
+ bucket = f"{QTYPE_NAMES[qtype]}:{size}"
131
+ by_options = config.get("temperature_by_options") or {}
132
+ temperatures = config.get("temperature") or [1.0, 1.0, 1.0]
133
+ value = float(by_options.get(bucket, temperatures[qtype]))
134
+ # the torch runtime clamps temperatures outside [0.5, 5] before applying them
135
+ return min(5.0, max(0.5, value))
136
+
137
+ @staticmethod
138
+ def _confidence(probabilities: list[float]) -> float:
139
+ count = len(probabilities)
140
+ if count < 2:
141
+ return 1.0
142
+ entropy = -sum(p * math.log(max(p, 1e-12)) for p in probabilities)
143
+ return 1 - entropy / math.log(count)
144
+
145
+ # -------------------------------------------------------------- inference
146
+ def system_one(
147
+ self, engine: OnnxEngine, state: Any, questions: Mapping[str, Mapping[str, Any]]
148
+ ) -> Mapping[str, Mapping[str, Any]]:
149
+ import numpy as np
150
+
151
+ config = engine.read_json("config")
152
+ tokenizer = engine.tokenizer
153
+ state_text = state_to_text(state)
154
+ answers: dict[str, dict[str, Any]] = {}
155
+
156
+ for name, question in questions.items():
157
+ kind, labels, _ = question_options(question)
158
+ options = self._render_options(question)
159
+ ids, markers, truncated = self._sequence(
160
+ tokenizer, state_text, question, options, config
161
+ )
162
+ if len(markers) != len(options):
163
+ raise AdapterError(
164
+ f"{name!r}: the options do not fit in {config.get('head_max_len')} tokens"
165
+ )
166
+ qtype = QTYPES[kind]
167
+
168
+ input_ids = np.asarray([ids], dtype=np.int64)
169
+ attention = np.ones_like(input_ids)
170
+ hidden = engine.session("encoder").run(
171
+ None, {"input_ids": input_ids, "attention_mask": attention}
172
+ )[0]
173
+
174
+ head = engine.session("head")
175
+ inputs = {item.name: item for item in head.get_inputs()}
176
+ hidden_name = next(
177
+ (name for name in ("hidden_states", "hidden", "last_hidden_state") if name in inputs),
178
+ None,
179
+ )
180
+ if hidden_name is None:
181
+ raise AdapterError(
182
+ f"the head exposes no hidden-state input; inputs: {sorted(inputs)}"
183
+ )
184
+ qtype_value = (
185
+ np.asarray([qtype], dtype=np.int64)
186
+ if len(inputs["qtype"].shape) == 1
187
+ else np.asarray([[qtype]], dtype=np.int64)
188
+ )
189
+ logits, act_logits = head.run(None, {
190
+ hidden_name: hidden,
191
+ "attention_mask": attention,
192
+ "marker_pos": np.asarray([markers], dtype=np.int64),
193
+ "marker_mask": np.ones((1, len(markers)), dtype=bool),
194
+ "qtype": qtype_value,
195
+ })
196
+
197
+ raw = np.asarray(logits)[0][: len(markers)]
198
+ scaled = (raw / self._temperature(config, qtype, len(markers))).tolist()
199
+ probabilities = softmax(scaled)
200
+ act_probability = softmax(np.asarray(act_logits)[0].tolist())[0]
201
+
202
+ if kind == "choice":
203
+ answer = build_choice_answer(labels, probabilities)
204
+ elif kind == "noul":
205
+ # Laya's options are [false, true]: p[1] is the probability of true
206
+ answer = build_noul_answer(probabilities[1], probabilities[0])
207
+ else:
208
+ answer = build_score_answer(labels, probabilities)
209
+
210
+ if kind in {"choice", "score"}:
211
+ answer["answer_confidence"] = answer["confidence"]
212
+ answer["distribution_confidence"] = self._confidence(probabilities)
213
+ answer["confidence_kind"] = "probability"
214
+ answer["action"] = {"act_probability": float(act_probability)}
215
+ if truncated:
216
+ answer["truncated"] = True
217
+ answer["warnings"] = [
218
+ f"input was truncated to fit max_len={config.get('max_len', 1024)}"
219
+ ]
220
+ answers[name] = answer
221
+
222
+ return answers
223
+
224
+
225
+ __all__ = ["LayaAdapter"]
@@ -0,0 +1,144 @@
1
+ """`decision_toolkit.adapters.opendecider` — OpenDecider nano's single-graph layout.
2
+
3
+ Contract (from the project's `opendecider/prompt.py::nano_ids` and the export's
4
+ `opendecider.json`):
5
+
6
+ [CLS] question: <instructions> [SEP] ([MASK] opt) … [SEP] input: <state> [SEP]
7
+ one ONNX graph: (input_ids, attention_mask) -> logits per token
8
+ the answer is softmax over the logits at the [MASK] positions
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from collections.abc import Mapping
15
+ from typing import Any
16
+
17
+ from ..engine import OnnxEngine, encode
18
+ from ..errors import AdapterError
19
+ from .base import (
20
+ build_choice_answer,
21
+ build_noul_answer,
22
+ build_score_answer,
23
+ softmax,
24
+ state_to_text,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class OpendeciderAdapter:
31
+ """System One over the official OpenDecider nano ONNX build."""
32
+
33
+ name = "opendecider"
34
+
35
+ # ------------------------------------------------------------------ setup
36
+ @staticmethod
37
+ def _render_options(question: Mapping[str, Any]) -> dict[str, str | None]:
38
+ kind = question.get("type")
39
+ criteria = question.get("criteria")
40
+ if kind == "choice":
41
+ return {str(key): (None if value is None else str(value)) for key, value in criteria.items()}
42
+ if kind == "score":
43
+ return {str(level): None for level in criteria}
44
+ criteria = criteria or {}
45
+ return {
46
+ "false": None if criteria.get("false") is None else str(criteria.get("false")),
47
+ "true": None if criteria.get("true") is None else str(criteria.get("true")),
48
+ }
49
+
50
+ @staticmethod
51
+ def _special_ids(config: Mapping[str, Any]) -> tuple[int, int, int]:
52
+ ids = config.get("special_ids") or {}
53
+ try:
54
+ return int(ids["cls"]), int(ids["sep"]), int(ids["mask"])
55
+ except (KeyError, TypeError, ValueError) as error:
56
+ raise AdapterError(
57
+ "opendecider.json must declare special_ids.cls/sep/mask"
58
+ ) from error
59
+
60
+ def _sequence(
61
+ self,
62
+ tokenizer: Any,
63
+ state_text: str,
64
+ instructions: str,
65
+ options: Mapping[str, str | None],
66
+ special_ids: tuple[int, int, int],
67
+ max_len: int,
68
+ ) -> tuple[list[int], list[int], bool]:
69
+ cls_id, sep_id, mask_id = special_ids
70
+ truncated = False
71
+ ids = [cls_id, *encode(tokenizer, f"question: {instructions}"), sep_id]
72
+ markers: list[int] = []
73
+ for key, value in options.items():
74
+ markers.append(len(ids))
75
+ ids.append(mask_id)
76
+ text = f" {key}: {value}" if value and value != key else f" {key}"
77
+ ids.extend(encode(tokenizer, text))
78
+ ids.append(sep_id)
79
+
80
+ state_ids = encode(tokenizer, f"input: {state_text}")
81
+ room = max(max_len - len(ids) - 1, 0)
82
+ if len(state_ids) > room:
83
+ truncated = True
84
+ ids = ids + state_ids[:room] + [sep_id]
85
+ if len(ids) > max_len:
86
+ truncated = True
87
+ if len(markers) > sum(1 for marker in markers if marker < max_len):
88
+ truncated = True
89
+ return ids[:max_len], [marker for marker in markers if marker < max_len], truncated
90
+
91
+ def _scores(
92
+ self,
93
+ engine: OnnxEngine,
94
+ state_text: str,
95
+ instructions: str,
96
+ options: Mapping[str, str | None],
97
+ special_ids: tuple[int, int, int],
98
+ max_len: int,
99
+ ) -> tuple[list[float], bool]:
100
+ import numpy as np
101
+
102
+ ids, markers, truncated = self._sequence(
103
+ engine.tokenizer, state_text, instructions, options, special_ids, max_len
104
+ )
105
+ input_ids = np.asarray([ids], dtype=np.int64)
106
+ logits = engine.session("model").run(None, {
107
+ "input_ids": input_ids,
108
+ "attention_mask": np.ones_like(input_ids),
109
+ })[0][0]
110
+ return [float(logits[position]) for position in markers], truncated
111
+
112
+ # -------------------------------------------------------------- inference
113
+ def system_one(
114
+ self, engine: OnnxEngine, state: Any, questions: Mapping[str, Mapping[str, Any]]
115
+ ) -> Mapping[str, Mapping[str, Any]]:
116
+ config = engine.read_json("config")
117
+ special_ids = self._special_ids(config)
118
+ max_len = int(config.get("max_len", 2048))
119
+ state_text = state_to_text(state)
120
+ answers: dict[str, dict[str, Any]] = {}
121
+
122
+ for name, question in questions.items():
123
+ options = self._render_options(question)
124
+ scores, truncated = self._scores(
125
+ engine, state_text, str(question.get("instructions") or ""),
126
+ options, special_ids, max_len,
127
+ )
128
+ probabilities = softmax(scores)
129
+ kind = question.get("type")
130
+ labels = list(options)
131
+ if kind == "choice":
132
+ answer = build_choice_answer(labels, probabilities)
133
+ elif kind == "noul":
134
+ answer = build_noul_answer(probabilities[1], probabilities[0])
135
+ else:
136
+ answer = build_score_answer(labels, probabilities)
137
+ if truncated:
138
+ answer["truncated"] = True
139
+ answer["warnings"] = [f"input was truncated to fit max_len={max_len}"]
140
+ answers[name] = answer
141
+ return answers
142
+
143
+
144
+ __all__ = ["OpendeciderAdapter"]
@@ -0,0 +1,79 @@
1
+ """`decision_toolkit.calibration` — do the probabilities mean what they say?
2
+
3
+ Decision models answer with probabilities; whether those probabilities are
4
+ *calibrated* (a 0.8 answer is right ~80% of the time) varies by model, by
5
+ quantization and by domain. The toolkit keeps this small but first-class:
6
+ measure before you threshold.
7
+
8
+ A *pair* is ``(probability_given_to_the_chosen_answer, was_it_right)`` — build
9
+ it from your own labelled rows with :func:`pairs_from_rows`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Iterable, Sequence
15
+
16
+ Pair = tuple[float, bool]
17
+
18
+
19
+ def pairs_from_rows(rows: Iterable[tuple[float, bool]]) -> list[Pair]:
20
+ """Normalize/validate ``(probability, correct)`` rows."""
21
+ pairs: list[Pair] = []
22
+ for probability, correct in rows:
23
+ p = float(probability)
24
+ if not 0.0 <= p <= 1.0:
25
+ raise ValueError(f"probability out of [0, 1]: {p}")
26
+ pairs.append((p, bool(correct)))
27
+ return pairs
28
+
29
+
30
+ def brier_score(pairs: Sequence[Pair]) -> float:
31
+ """Mean squared error of the probabilities (lower is better; 0 is perfect)."""
32
+ if not pairs:
33
+ raise ValueError("brier_score needs at least one pair")
34
+ return sum((probability - float(correct)) ** 2 for probability, correct in pairs) / len(pairs)
35
+
36
+
37
+ def expected_calibration_error(pairs: Sequence[Pair], n_bins: int = 10) -> float:
38
+ """Expected calibration error: |accuracy - confidence| averaged over bins."""
39
+ if not pairs:
40
+ raise ValueError("expected_calibration_error needs at least one pair")
41
+ bins: list[list[Pair]] = [[] for _ in range(n_bins)]
42
+ for probability, correct in pairs:
43
+ index = min(int(probability * n_bins), n_bins - 1)
44
+ bins[index].append((probability, correct))
45
+
46
+ total = len(pairs)
47
+ error = 0.0
48
+ for bucket in bins:
49
+ if not bucket:
50
+ continue
51
+ confidence = sum(p for p, _ in bucket) / len(bucket)
52
+ accuracy = sum(1.0 for _, correct in bucket if correct) / len(bucket)
53
+ error += (len(bucket) / total) * abs(accuracy - confidence)
54
+ return error
55
+
56
+
57
+ def threshold_sweep(pairs: Sequence[Pair], thresholds: Sequence[float] = (0.5, 0.6, 0.7, 0.8, 0.9)
58
+ ) -> dict[float, dict[str, float]]:
59
+ """Coverage and kept accuracy at each confidence threshold.
60
+
61
+ ``coverage`` is the share of items the model answers at or above the
62
+ threshold; ``accuracy_kept`` is how often those answers are right. An item
63
+ counts as right when its probability side (>= 0.5) matches its label —
64
+ pass ``correct=True/False`` directly to decide a different rule.
65
+ """
66
+ if not pairs:
67
+ raise ValueError("threshold_sweep needs at least one pair")
68
+ report: dict[float, dict[str, float]] = {}
69
+ for threshold in thresholds:
70
+ kept = [(p, c) for p, c in pairs if p >= threshold]
71
+ report[float(threshold)] = {
72
+ "coverage": len(kept) / len(pairs),
73
+ "accuracy_kept": (sum(1.0 for _, c in kept) / len(kept)) if kept else 0.0,
74
+ "n_kept": float(len(kept)),
75
+ }
76
+ return report
77
+
78
+
79
+ __all__ = ["Pair", "brier_score", "expected_calibration_error", "pairs_from_rows", "threshold_sweep"]