decisionnode-edge 0.9.2__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.
@@ -0,0 +1,13 @@
1
+ """DecisionNode Edge: typed, calibrated decisions from a model that runs on your own hardware.
2
+
3
+ from decisionnode_edge import EdgeModel
4
+
5
+ model = EdgeModel.load("path/to/decisionnode-edge")
6
+ model.decide({"state": "...", "questions": {"escalate": {"type": "truth", "instructions": "Escalate now?"}}})
7
+ """
8
+
9
+ from .model import EdgeModel
10
+ from .request import RequestError
11
+
12
+ __all__ = ["EdgeModel", "RequestError"]
13
+ __version__ = "0.9.2"
@@ -0,0 +1,42 @@
1
+ """Answer one request from the command line, or serve the model over HTTP (server.py).
2
+
3
+ python -m decisionnode_edge path/to/decisionnode-edge request.json # or "-" to read the request from stdin
4
+ python -m decisionnode_edge serve path/to/decisionnode-edge --port 8000
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+
13
+ from . import EdgeModel, RequestError
14
+
15
+
16
+ def main(argv: list[str] | None = None) -> int:
17
+ argv = sys.argv[1:] if argv is None else argv
18
+ if argv[:1] == ["serve"]:
19
+ from .server import main as serve
20
+
21
+ return serve(argv[1:])
22
+ parser = argparse.ArgumentParser(prog="python -m decisionnode_edge", description="Answer a /v1/decide request with a local DecisionNode Edge model.")
23
+ parser.add_argument("model", help="the model directory")
24
+ parser.add_argument("request", help="a JSON file with the request body, or - for stdin")
25
+ parser.add_argument("--device", default=None, help="cuda or cpu (default: cuda when present)")
26
+ parser.add_argument("--dtype", default=None, help="bfloat16 or float32 (default: bfloat16 on cuda, float32 on cpu)")
27
+ parser.add_argument("--backend", default=None, help="auto, torch, vllm, openvino, mlx or llamacpp (default: auto, the fastest installed)")
28
+ parser.add_argument("--artifacts", default=None, help="the directory python -m decisionnode_edge.build wrote (openvino, llamacpp)")
29
+ args = parser.parse_args(argv)
30
+ body = json.load(sys.stdin if args.request == "-" else open(args.request, encoding="utf-8"))
31
+ model = EdgeModel.load(args.model, device=args.device, dtype=args.dtype, backend=args.backend, artifacts=args.artifacts)
32
+ try:
33
+ response = model.decide(body)
34
+ except RequestError as error:
35
+ print(json.dumps({"error": str(error), "status": error.status}), file=sys.stderr)
36
+ return 1
37
+ print(json.dumps(response, ensure_ascii=False, indent=2))
38
+ return 0
39
+
40
+
41
+ if __name__ == "__main__":
42
+ sys.exit(main())
@@ -0,0 +1,234 @@
1
+ """Calibration and the answer objects of the hosted API.
2
+
3
+ Calibration (calibration.json):
4
+
5
+ choice, score p = softmax(z / T(K)), T(K) = t0 + t1 * ln K, K clamped to the fitted range
6
+ truth p_yes = sigmoid((z_yes - z_no) / T + b)
7
+ coord p = softmax(z), a point or box question's reading, never calibrated (places.py)
8
+
9
+ A parameter set exists per input kind ("text", "image"); a missing one is the identity.
10
+
11
+ The answers, rounded as the hosted API rounds them:
12
+
13
+ choice {"type", "choice", "confidence", "probabilities": {option: p}} options in alphabetical order
14
+ score {"type", "score", "confidence", "legend": {"0": level}, "probabilities": {"0": p}}
15
+ truth {"type", "truth"}
16
+ number {"type", "number", "expected", "confidence", "probabilities": {value: p}}
17
+ point {"type", "x", "y", "present", "confidence", "image", "width", "height"} places.py
18
+ box {"type", "x1", "y1", "x2", "y2", "present", "confidence", "image", "width", "height"}
19
+ with `max`: {"type", "count", "present", "complete", "points" | "boxes": [...], "image", "width", "height"}
20
+
21
+ `choice`, `score`, `number` and the confidences come from the unrounded probabilities. A probability list is rounded
22
+ to 2 decimals entry by entry, then its largest entries move by 0.01 until it sums to 1 (`round_probabilities`).
23
+ `exact_sum` (the rounding of /v1/systemone, where benchmarks check the sum): the probabilities are normalised first, and
24
+ the list sums to exactly 1.00 in hundredths, every value a clean 2-decimal number.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import math
30
+ from decimal import Decimal, localcontext
31
+ from typing import Sequence
32
+
33
+ T_MIN, T_MAX = 0.05, 100.0
34
+ UNIT = 0.01
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Calibration
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ def _temperature(params: dict, k: int) -> float:
43
+ if params.get("k_min") is not None:
44
+ k = min(max(k, params["k_min"]), params["k_max"])
45
+ t = float(params.get("t0", 1.0)) + float(params.get("t1", 0.0)) * math.log(k)
46
+ return min(T_MAX, max(T_MIN, t))
47
+
48
+
49
+ def calibrated(logits: Sequence[float], kind: str, calibration: dict, has_images: bool) -> list[float]:
50
+ """Calibrated class probabilities from a question's code logits (class order: options or levels as given,
51
+ [yes, no] for truth)."""
52
+ if kind == "coord":
53
+ return softmax(logits)
54
+ params = (calibration.get("image" if has_images else "text") or {}).get("choice" if kind == "number" else kind) or {}
55
+ z = [float(x) for x in logits]
56
+ if kind == "truth":
57
+ x = (z[0] - z[1]) / float(params.get("temperature", 1.0)) + float(params.get("bias", 0.0))
58
+ p = 1.0 / (1.0 + math.exp(-x)) if x >= 0 else math.exp(x) / (1.0 + math.exp(x))
59
+ return [p, 1.0 - p]
60
+ if len(z) == 1:
61
+ return [1.0]
62
+ t = _temperature(params, len(z))
63
+ scaled = [x / t for x in z]
64
+ top = max(scaled)
65
+ log_total = math.log(sum(math.exp(x - top) for x in scaled))
66
+ return [math.exp(x - top - log_total) for x in scaled]
67
+
68
+
69
+ def softmax(logits: Sequence[float]) -> list[float]:
70
+ """A plain softmax (the largest score subtracted first): a coordinate row's probabilities."""
71
+ z = [float(x) for x in logits]
72
+ if not z or not all(math.isfinite(x) for x in z):
73
+ raise ValueError("scores must be a list of finite numbers")
74
+ top = max(z)
75
+ e = [math.exp(x - top) for x in z]
76
+ total = math.fsum(e)
77
+ return [x / total for x in e]
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Rounding
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ def _units(x: float) -> int:
86
+ """x in hundredths, halves up (the epsilon absorbs binary error: 0.805 rounds to 0.81)."""
87
+ return int(math.floor(x * 100.0 + 0.5 + 1e-9))
88
+
89
+
90
+ def round2(x: float) -> float:
91
+ value = _units(x) / 100.0
92
+ return 0.0 if value == 0 else value
93
+
94
+
95
+ def normalised(probs: Sequence[float]) -> list[float]:
96
+ """The probabilities divided by their float64 sum (a sum within 1e-9 of 1); a list that sums to 0 as it is."""
97
+ p = [float(x) for x in probs]
98
+ total = math.fsum(p)
99
+ if total <= 0 or not math.isfinite(total):
100
+ return p
101
+ return [x / total for x in p]
102
+
103
+
104
+ def round_probabilities(probs: Sequence[float], exact_sum: bool = False) -> list[float]:
105
+ """Round to 2 decimals, then move the |n| largest entries by 0.01, n = int((1 - fsum(rounded)) / 0.01).
106
+ A moved entry is not rounded again (0.81 + 0.01 is 0.8200000000000001), as the hosted API returns it.
107
+
108
+ `exact_sum` (pass normalised probabilities): n is 100 less the sum of the rounded hundredths, and every value is the
109
+ clean 2-decimal number, so the hundredths sum to exactly 100."""
110
+ p = [float(x) for x in probs]
111
+ units = [_units(x) for x in p]
112
+ rounded = [u / 100.0 for u in units]
113
+ if exact_sum:
114
+ left = abs(100 - sum(units))
115
+ step = 1 if sum(units) < 100 else -1
116
+ order = sorted(range(len(p)), key=lambda i: (-p[i], i))
117
+ while left: # every entry moves once, the largest first; a list that needs more moves round again
118
+ progressed = False
119
+ for i in order:
120
+ if not left:
121
+ break
122
+ if units[i] + step < 0:
123
+ continue
124
+ units[i] += step
125
+ left -= 1
126
+ progressed = True
127
+ if not progressed:
128
+ raise ValueError("probabilities cannot be rounded to a list that sums to 1")
129
+ return [0.0 if u == 0 else u / 100.0 for u in units]
130
+ n = int((1.0 - math.fsum(rounded)) / UNIT)
131
+ if n:
132
+ step = 1 if n > 0 else -1
133
+ for i in sorted(range(len(p)), key=lambda i: (-p[i], i))[: abs(n)]:
134
+ rounded[i] = rounded[i] + UNIT if step > 0 else rounded[i] - UNIT
135
+ return [0.0 if x == 0 else x for x in rounded]
136
+
137
+
138
+ def _argmax_first(probs: Sequence[float]) -> int:
139
+ best = 0
140
+ for i, p in enumerate(probs):
141
+ if p > probs[best]:
142
+ best = i
143
+ return best
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Answers
148
+ # ---------------------------------------------------------------------------
149
+
150
+
151
+ def choice_answer(keys: Sequence[str], probs: Sequence[float], exact_sum: bool = False) -> dict:
152
+ if exact_sum:
153
+ probs = normalised(probs)
154
+ best = _argmax_first(probs)
155
+ k = len(probs)
156
+ confidence = 1.0 if k == 1 else min(1.0, max(0.0, (k * probs[best] - 1.0) / (k - 1.0)))
157
+ shown = round_probabilities(probs, exact_sum)
158
+ return {"type": "choice", "choice": keys[best], "confidence": round2(confidence), "probabilities": dict(sorted(zip(keys, shown)))}
159
+
160
+
161
+ def score_answer(levels: Sequence, probs: Sequence[float], exact_sum: bool = False) -> dict:
162
+ if exact_sum:
163
+ probs = normalised(probs)
164
+ k = len(probs)
165
+ if k == 1:
166
+ confidence = 1.0
167
+ else:
168
+ mode = _argmax_first(probs)
169
+ spread = sum(p * abs(i - mode) for i, p in enumerate(probs))
170
+ confidence = min(1.0, max(0.0, 1.0 - spread / ((k * k // 4) / k)))
171
+ return {
172
+ "type": "score",
173
+ "score": round2(sum(i * p for i, p in enumerate(probs))),
174
+ "confidence": round2(confidence),
175
+ "legend": {str(i): level for i, level in enumerate(levels)},
176
+ "probabilities": {str(i): x for i, x in enumerate(round_probabilities(probs, exact_sum))},
177
+ }
178
+
179
+
180
+ def truth_answer(probs: Sequence[float]) -> dict:
181
+ return {"type": "truth", "truth": round2(probs[0])}
182
+
183
+
184
+ def _expected_text(value: float, decimals: int) -> str:
185
+ """Halves up to `decimals` (at most 15 significant digits), with a decimal point, never -0.0."""
186
+ with localcontext() as ctx:
187
+ ctx.prec = 200
188
+ exact = Decimal(value)
189
+ whole = abs(int(exact))
190
+ decimals = min(decimals, 15 - (len(str(whole)) if whole else 0))
191
+ units = (exact * Decimal(10) ** decimals + Decimal("0.5") + Decimal("1e-9")).to_integral_value(rounding="ROUND_FLOOR")
192
+ text = format(units * Decimal(10) ** -decimals, "f")
193
+ if "." in text:
194
+ text = text.rstrip("0")
195
+ text = text + "0" if text.endswith(".") else text
196
+ else:
197
+ text += ".0"
198
+ return "0.0" if text == "-0.0" else text
199
+
200
+
201
+ def _number(text: str) -> int | float:
202
+ return float(text) if "." in text else int(text)
203
+
204
+
205
+ def number_answer(grid, probs: Sequence[float]) -> dict:
206
+ """The most probable value, the probability-weighted mean (`expected`), the probability of the value
207
+ (`confidence`) and every value above 0.001 to 3 decimals (rounded entries that sum above 1 lose 0.001 each,
208
+ the largest first, the answer's own last)."""
209
+ best = _argmax_first(probs)
210
+ texts = grid.texts()
211
+ expected = math.fsum(float(text) * p for text, p in zip(texts, probs))
212
+ listed = [i for i in range(grid.n) if probs[i] > 0.001 or i == best]
213
+ millis = {i: int(math.floor(probs[i] * 1000.0 + 0.5 + 1e-9)) for i in listed}
214
+ excess = sum(millis.values()) - 1000
215
+ order = sorted((i for i in listed if i != best), key=lambda i: (-probs[i], i))
216
+ while excess > 0:
217
+ lowered = False
218
+ for i in order:
219
+ if excess <= 0:
220
+ break
221
+ if millis[i] > 0:
222
+ millis[i] -= 1
223
+ excess -= 1
224
+ lowered = True
225
+ if not lowered:
226
+ millis[best] -= 1
227
+ excess -= 1
228
+ return {
229
+ "type": "number",
230
+ "number": _number(texts[best]),
231
+ "expected": float(_expected_text(expected, max(2, grid.d))),
232
+ "confidence": round2(probs[best]),
233
+ "probabilities": {texts[i]: millis[i] / 1000.0 for i in listed},
234
+ }