bynn-intelligence commited on
Commit
7706b50
·
verified ·
1 Parent(s): 82aab3f

DecisionNode Edge 0.9.2: point and box questions, the /v1/systemone route as hosted, a box calibration

Browse files
MANIFEST.sha256 CHANGED
@@ -2,30 +2,33 @@
2
  50cbab8a892c5f2993b8c7351a99182507472def3b1374558308605d99b86b32 ./LICENSE-base
3
  2014f13a6fe1d9178c84cde47bb087a7a74adbe38650f1823a944077081e86d0 ./LICENSING.md
4
  e4dcac398e39a098e07f625ed239949296a2000d95dcc275cb0d8083ebb51dc9 ./NOTICE
5
- cdd6a52790c6f10d69eb5891d0c9a3d0698b794af0c29969eabea26b52361d58 ./README.md
6
  c44489690e63adb3abd8c65ede675fadc5714126e3f866cbd292df824880cb77 ./assets/decisionnode-banner.png
 
7
  5febf5c3cb5843034f2251d959c475a4a629dec65bf3ea494b4fed0a3b8aa3aa ./calibration.json
8
  28b44a18f60b7e3039c503971b6d194b51c723f0c8a62d56bd6ce7f7f34e9f87 ./config.json
9
  8da27e0c66f1196243e6a4220213050001be1837b502ba8b20bd4e787bb1091a ./decisionnode-edge-0.9.1-q8_0.gguf
10
- 288aeaff4fb2db60e3a706a5380d9f4a0f937bec77634513064589b26e95e19d ./decisionnode_edge.json
11
  82f54898bde6ff4f1af907db2d2423fc5e2efed77a4622f0b79a7b5d486d054b ./decisionnode_edge_build.json
12
  e0a87595ad939efa5585b61844d3a2a70d29ba7752f68b8139f2195753a6bfde ./examples/request.json
13
  d3d84ca9d2701443b84960ba25b4ee21f5125cdf03007b1dc3633c315447ba6a ./model.safetensors
14
  27225450ac9c6529872ee1924fcb0962ff5634834f817040f444118116f4e516 ./preprocessor_config.json
15
  04603015c9b8bc70dd5e7a5c8f0ff14a3ac52aaa359b16b23f662f8f51edd5cd ./readout.safetensors
16
  0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0 ./runtime/LICENSE
17
- 41e6c8237772a353f80aea98ef4dd63a1e0df73447d236bc2fd403b46507992b ./runtime/README.md
18
  e0a87595ad939efa5585b61844d3a2a70d29ba7752f68b8139f2195753a6bfde ./runtime/examples/request.json
19
- bdcc0ff2b9f4b90b20337fb98ff2817288d9c194c7fd3ab07e85b11938686b77 ./runtime/pyproject.toml
20
- d2c1e3b3032e5499c75dc1932d26477ff1e4b8e0cf2f23043ac5c0329a117abe ./runtime/src/decisionnode_edge/__init__.py
21
  245003271a41ecab8fe982194acf84f9c4fc0b686762583931a9444ad2aa6354 ./runtime/src/decisionnode_edge/__main__.py
22
- 1751dfc288ffcdd03719738000ac073a8e0b8ffe2a4e04bfc0f1ea97419bcd51 ./runtime/src/decisionnode_edge/answers.py
23
  c34f84c5da93d012d329762b6e099bc5aa7b7d998f790b88471f75399cd3eee8 ./runtime/src/decisionnode_edge/backends.py
24
  4ace977690bb7ae16010a298c478f1a5056caaf724c0d696b5ddac6699225f6b ./runtime/src/decisionnode_edge/build.py
25
- 4575c765a97ed27e40a57021a6d2ecb3bd4ff79835119e50895cd42eaae06f31 ./runtime/src/decisionnode_edge/model.py
26
- 460a3d7a3fb93531d82bd54f0ca70e25a69cd06ee6a8b41643ed205c2a0dfb6e ./runtime/src/decisionnode_edge/request.py
27
- 0d7a3a43ec25e21d98a3fbe9a958fb3c9cc2cc0c9b1c9a7335b73ff1310fa801 ./runtime/src/decisionnode_edge/server.py
28
- 463a7cef3ae1cc97a989dbc664e4a9d970cdb80fec9950c718d3b68af695ec35 ./runtime/src/decisionnode_edge/template.py
 
 
29
  6e9f31870a35eb9372bbc8719603e5d02f2e07eff90af9f7b77fe3b17d3628ff ./tokenizer.json
30
  429721f730f42043159445b48039714682fac2cef2ad0e82917dfa0c8ca5f430 ./tokenizer_config.json
31
  d039cd7d88b3502a99edd455496b75b44ecf7dd3b3669748bafac37e6cecc085 ./video_preprocessor_config.json
 
2
  50cbab8a892c5f2993b8c7351a99182507472def3b1374558308605d99b86b32 ./LICENSE-base
3
  2014f13a6fe1d9178c84cde47bb087a7a74adbe38650f1823a944077081e86d0 ./LICENSING.md
4
  e4dcac398e39a098e07f625ed239949296a2000d95dcc275cb0d8083ebb51dc9 ./NOTICE
5
+ 9862c836bb1e4ce3a083862e649fd5bfc2cefadf5ee85edfe98a3113cfb1d1b8 ./README.md
6
  c44489690e63adb3abd8c65ede675fadc5714126e3f866cbd292df824880cb77 ./assets/decisionnode-banner.png
7
+ 544b3d40cc9e355b16f8c131426c21245c1b5847a46c40c1b4be2c33a447b4fa ./box_calibration.json
8
  5febf5c3cb5843034f2251d959c475a4a629dec65bf3ea494b4fed0a3b8aa3aa ./calibration.json
9
  28b44a18f60b7e3039c503971b6d194b51c723f0c8a62d56bd6ce7f7f34e9f87 ./config.json
10
  8da27e0c66f1196243e6a4220213050001be1837b502ba8b20bd4e787bb1091a ./decisionnode-edge-0.9.1-q8_0.gguf
11
+ 420be98b7242ec41ede64a3b72a461c8af30ddebbe766f4d13c4b7183658fd60 ./decisionnode_edge.json
12
  82f54898bde6ff4f1af907db2d2423fc5e2efed77a4622f0b79a7b5d486d054b ./decisionnode_edge_build.json
13
  e0a87595ad939efa5585b61844d3a2a70d29ba7752f68b8139f2195753a6bfde ./examples/request.json
14
  d3d84ca9d2701443b84960ba25b4ee21f5125cdf03007b1dc3633c315447ba6a ./model.safetensors
15
  27225450ac9c6529872ee1924fcb0962ff5634834f817040f444118116f4e516 ./preprocessor_config.json
16
  04603015c9b8bc70dd5e7a5c8f0ff14a3ac52aaa359b16b23f662f8f51edd5cd ./readout.safetensors
17
  0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0 ./runtime/LICENSE
18
+ 4343f7ad5a0283d7ecd8c1630ee1c82fde7e8f5c18696f5444629e9399902415 ./runtime/README.md
19
  e0a87595ad939efa5585b61844d3a2a70d29ba7752f68b8139f2195753a6bfde ./runtime/examples/request.json
20
+ af77c7510284ebcae3fe0fd6ebd951c2b6bcf88d0b0d73b85c8e45b03b1b8776 ./runtime/pyproject.toml
21
+ 1551cc741199ad4ec30b8d16b2a3b82432471e4ef36a75f8677bad7c50771338 ./runtime/src/decisionnode_edge/__init__.py
22
  245003271a41ecab8fe982194acf84f9c4fc0b686762583931a9444ad2aa6354 ./runtime/src/decisionnode_edge/__main__.py
23
+ 736741c822983f1d7d8ed26ea01227f2f2e902c98dc7d0c21d8fa5abcf219150 ./runtime/src/decisionnode_edge/answers.py
24
  c34f84c5da93d012d329762b6e099bc5aa7b7d998f790b88471f75399cd3eee8 ./runtime/src/decisionnode_edge/backends.py
25
  4ace977690bb7ae16010a298c478f1a5056caaf724c0d696b5ddac6699225f6b ./runtime/src/decisionnode_edge/build.py
26
+ 3e2d2198d9fd4fac076773e655a0c73c810a932be07ce99460edee64fb7004bc ./runtime/src/decisionnode_edge/model.py
27
+ 3be9ac7eb2807241d0b9b49cb5797049395f7fbca6ddee9a645274edd1c3e4a5 ./runtime/src/decisionnode_edge/places.py
28
+ fb1b35692c68ad904d44743f580e2145ff68d14ac7b94267c7c19402f88b2e5f ./runtime/src/decisionnode_edge/request.py
29
+ bc5ebac62720ff5f0317ab757126eb25621af046da94cb7564ec41d482b277f2 ./runtime/src/decisionnode_edge/server.py
30
+ 969e07b2698c7b746cf7e5c390c8110756053c2a6495a30d84a411d54a9054c6 ./runtime/src/decisionnode_edge/systemone.py
31
+ 670afbb7b0fcc7f65f1c7d11f868a3daf143c2210eed0d528a27938d24c2327a ./runtime/src/decisionnode_edge/template.py
32
  6e9f31870a35eb9372bbc8719603e5d02f2e07eff90af9f7b77fe3b17d3628ff ./tokenizer.json
33
  429721f730f42043159445b48039714682fac2cef2ad0e82917dfa0c8ca5f430 ./tokenizer_config.json
34
  d039cd7d88b3502a99edd455496b75b44ecf7dd3b3669748bafac37e6cecc085 ./video_preprocessor_config.json
README.md CHANGED
@@ -18,15 +18,15 @@ tags:
18
 
19
  # DecisionNode Edge
20
 
21
- DecisionNode Edge is an open-weight System One decision model from [Bynn Intelligence](https://decisionnode.com). It reads text and images and answers each question with a typed, calibrated decision: a choice, a score, a yes-or-no probability or a number. It never generates free text. It runs entirely on your own hardware, from a datacenter GPU to a laptop CPU, and uses the same request and response format as the [DecisionNode API](https://decisionnode.com/docs/api).
22
 
23
  A System One model is a class of AI model that returns typed, probabilistic decisions from a fixed answer space instead of generating text. Given a piece of state and a set of questions defined in code, it answers each one with a schema-valid value such as a chosen option, a score, or a probability, and then stops.
24
 
25
- This repository contains DecisionNode Edge 0.9.1, the larger of the two open DecisionNode Edge models. [DecisionNode Edge Mini](https://huggingface.co/bynn-intelligence-com/decisionnode-edge-mini) is a 0.85-billion-parameter sibling for CPUs and constrained devices. When accuracy matters most, use the hosted models on the [DecisionNode API](https://decisionnode.com/docs/api): DecisionNode, the most accurate model in the family, or DecisionNode Flash.
26
 
27
  ## Highlights
28
 
29
- - **Typed decisions instead of text.** Four question types, each answered as a probability distribution over the allowed values, ready to use in code without parsing.
30
  - **Calibrated.** Probabilities are calibrated on held-out data, so a fixed threshold such as 0.7 means the same thing from one question to the next.
31
  - **Constant cost per question.** Every question is a single forward pass, whether it has 2 options or 255.
32
  - **Fast everywhere.** Optimized backends for NVIDIA GPUs, Apple silicon, x86 CPUs and llama.cpp devices, selected automatically, with consistent answers across all of them.
@@ -52,7 +52,7 @@ Available only on the hosted platform:
52
  - **Boosters.** Specialist models that read each image before the decision is made: Human Age, AI-generated image detection, Face detection, Face recognition, OCR, Depth Vision and Segment Vision. See [boosters](https://decisionnode.com/docs/concepts/boosters).
53
  - **Knowledge.** Connect your own documents, and the Knowledge booster retrieves the passages each decision needs. See [knowledge](https://decisionnode.com/docs/knowledge).
54
  - **Fine-tuning.** Adapt DecisionNode to your own decisions and data. See [fine-tuning](https://decisionnode.com/docs/fine-tuning).
55
- - **Video and localization.** Video input, and points and bounding boxes in images.
56
  - **Scale.** Streaming sessions for high-frequency decisions, batch jobs at a discount, file uploads and inputs of up to 65,536 tokens.
57
  - **Low cost.** Pricing starts at a few cents per million input tokens, output tokens are free, and every account includes a free daily allowance. See [pricing](https://decisionnode.com/#pricing).
58
 
@@ -110,18 +110,18 @@ def decide(request, threshold=0.7):
110
  return answers # still below the threshold: take the safe action
111
  ```
112
 
113
- Pass images as base64 `data` with a `media_type` (not a local `path`), so the same request is valid on the device and on the hosted API. Send point and box questions to the hosted API directly. Only the questions that are still unsure are sent, and a decision never changes when the same request is sent again, so each question costs at most one call per hosted model.
114
 
115
  ## Model details
116
 
117
  | | |
118
  |---|---|
119
  | Developer | Bynn Intelligence Inc. |
120
- | Version | 0.9.1 (October 2026) |
121
  | Parameters | 2.21 billion (1.88 billion in the language model, 0.33 billion in the vision encoder) |
122
  | Base model | [Qwen3.5-2B-Base](https://huggingface.co/Qwen/Qwen3.5-2B-Base) |
123
  | Input | Text, and up to 16 images per request (JPEG, PNG or WebP) |
124
- | Output | Typed decisions: `choice`, `score`, `truth` and `number`; `point` and `box` through the hosted API |
125
  | Context | Evaluated on inputs of up to 8,192 tokens; the runtime accepts up to 16,384 |
126
  | Weights | bfloat16 safetensors, plus an 8-bit GGUF build (2.0 GB) |
127
  | License | AGPL-3.0, or a commercial license from Bynn Intelligence |
@@ -198,13 +198,30 @@ The runtime also runs from the command line (`python -m decisionnode_edge <model
198
  | `score` | 1 to 10 ordered levels, from low to high | the expected level, a confidence and a probability for every level | DecisionNode Edge and the hosted API |
199
  | `truth` | a statement or question, with optional `true` and `false` criteria | the probability that the answer is yes | DecisionNode Edge and the hosted API |
200
  | `number` | `min`, `max` and `step` (up to 4,096 values) | the most probable value, the expected value, a confidence and the probable values | DecisionNode Edge and the hosted API |
201
- | `point` | an image and what to point to | a point (`x`, `y`) in pixels or fractions of the image, the probability that the thing is there, and a confidence | the hosted API: DecisionNode Flash or DecisionNode |
202
- | `box` | an image and what to box | a bounding box (`x1`, `y1`, `x2`, `y2`), the probability that the thing is there, and a confidence | the hosted API: DecisionNode Flash or DecisionNode |
203
-
204
- Point and box questions are answered today by the hosted models through the [DecisionNode API](https://decisionnode.com/docs/api), with the same request format; send them there directly.
205
 
206
  Write what the question means into `instructions` and `criteria`; the question's key is never shown to the model. Images are seen by every question in the request.
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  ## Performance
209
 
210
  `EdgeModel.load` selects the fastest backend installed on your machine. Every backend computes the same internal representation and applies the same decision head and calibration, so answers are consistent across devices. Median time for one question at about 500 and 2,000 tokens of input:
@@ -237,8 +254,8 @@ We evaluate the DecisionNode models on an internal suite of 15 held-out decision
237
 
238
  | Model | Mean accuracy |
239
  |---|---|
240
- | DecisionNode Edge 0.9.1 (bfloat16) | 74.2% |
241
- | DecisionNode Edge 0.9.1 (8-bit GGUF) | 74.2% |
242
 
243
  Compared with DecisionNode Edge Mini, Edge is about 5 points more accurate and roughly half as fast on a CPU. The 8-bit build matches the bfloat16 weights on all 15 benchmarks; 4-bit builds lose accuracy and are not offered.
244
 
@@ -250,6 +267,7 @@ DecisionNode Edge is designed for the frequent, well-defined judgments inside so
250
  - classification and scoring against criteria you define in the request
251
  - validation and guardrail checks before an action runs
252
  - reading values from images, such as whether a total matches or how many items are present
 
253
  - on-device decisions in robots, drones, vehicles and cameras, with the hosted models for the hard cases
254
  - applications that must run offline or keep data on the device
255
 
@@ -260,7 +278,7 @@ DecisionNode Edge is designed for the frequent, well-defined judgments inside so
260
  - **Accuracy varies by domain and language.** Evaluate the model on your own data before relying on it.
261
  - **Input length.** The model was evaluated on inputs of up to 8,192 tokens. The runtime accepts up to 16,384 tokens and rejects longer inputs rather than truncating them.
262
  - **Not a generative model.** DecisionNode Edge does not produce text. Use it through the runtime; loading it in a text-generation pipeline yields meaningless output.
263
- - **Scope.** Video input, object localization, boosters, knowledge and fine-tuning are available on the hosted DecisionNode platform, not in DecisionNode Edge.
264
 
265
  ## Files
266
 
@@ -268,6 +286,7 @@ DecisionNode Edge is designed for the frequent, well-defined judgments inside so
268
  |---|---|
269
  | `model.safetensors`, `config.json` | model weights (bfloat16) and configuration |
270
  | `readout.safetensors`, `calibration.json` | decision head and calibration |
 
271
  | `tokenizer.json`, `tokenizer_config.json` | tokenizer |
272
  | `preprocessor_config.json`, `video_preprocessor_config.json` | image preprocessing settings |
273
  | `decisionnode_edge.json` | prompt format and limits used by the runtime |
 
18
 
19
  # DecisionNode Edge
20
 
21
+ DecisionNode Edge is an open-weight System One decision model from [Bynn Intelligence](https://decisionnode.com). It reads text and images and answers each question with a typed decision: a choice, a score, a yes-or-no probability, a number, or a place in an image (a point or a box). It never generates free text. It runs entirely on your own hardware, from a datacenter GPU to a laptop CPU, and uses the same request and response format as the [DecisionNode API](https://decisionnode.com/docs/api).
22
 
23
  A System One model is a class of AI model that returns typed, probabilistic decisions from a fixed answer space instead of generating text. Given a piece of state and a set of questions defined in code, it answers each one with a schema-valid value such as a chosen option, a score, or a probability, and then stops.
24
 
25
+ This repository contains DecisionNode Edge 0.9.2, the larger of the two open DecisionNode Edge models. [DecisionNode Edge Mini](https://huggingface.co/bynn-intelligence-com/decisionnode-edge-mini) is a 0.85-billion-parameter sibling for CPUs and constrained devices. When accuracy matters most, use the hosted models on the [DecisionNode API](https://decisionnode.com/docs/api): DecisionNode, the most accurate model in the family, or DecisionNode Flash.
26
 
27
  ## Highlights
28
 
29
+ - **Typed decisions instead of text.** Six question types (choice, score, truth, number, point and box), answered as values with probabilities, ready to use in code without parsing.
30
  - **Calibrated.** Probabilities are calibrated on held-out data, so a fixed threshold such as 0.7 means the same thing from one question to the next.
31
  - **Constant cost per question.** Every question is a single forward pass, whether it has 2 options or 255.
32
  - **Fast everywhere.** Optimized backends for NVIDIA GPUs, Apple silicon, x86 CPUs and llama.cpp devices, selected automatically, with consistent answers across all of them.
 
52
  - **Boosters.** Specialist models that read each image before the decision is made: Human Age, AI-generated image detection, Face detection, Face recognition, OCR, Depth Vision and Segment Vision. See [boosters](https://decisionnode.com/docs/concepts/boosters).
53
  - **Knowledge.** Connect your own documents, and the Knowledge booster retrieves the passages each decision needs. See [knowledge](https://decisionnode.com/docs/knowledge).
54
  - **Fine-tuning.** Adapt DecisionNode to your own decisions and data. See [fine-tuning](https://decisionnode.com/docs/fine-tuning).
55
+ - **Video.** Video input, read as frames.
56
  - **Scale.** Streaming sessions for high-frequency decisions, batch jobs at a discount, file uploads and inputs of up to 65,536 tokens.
57
  - **Low cost.** Pricing starts at a few cents per million input tokens, output tokens are free, and every account includes a free daily allowance. See [pricing](https://decisionnode.com/#pricing).
58
 
 
110
  return answers # still below the threshold: take the safe action
111
  ```
112
 
113
+ Pass images as base64 `data` with a `media_type` (not a local `path`), so the same request is valid on the device and on the hosted API. Only the questions that are still unsure are sent, and a decision never changes when the same request is sent again, so each question costs at most one call per hosted model.
114
 
115
  ## Model details
116
 
117
  | | |
118
  |---|---|
119
  | Developer | Bynn Intelligence Inc. |
120
+ | Version | 0.9.2 (October 2026): the 0.9.1 weights with runtime 0.9.2, which adds point and box questions |
121
  | Parameters | 2.21 billion (1.88 billion in the language model, 0.33 billion in the vision encoder) |
122
  | Base model | [Qwen3.5-2B-Base](https://huggingface.co/Qwen/Qwen3.5-2B-Base) |
123
  | Input | Text, and up to 16 images per request (JPEG, PNG or WebP) |
124
+ | Output | Typed decisions: `choice`, `score`, `truth`, `number`, `point` and `box` |
125
  | Context | Evaluated on inputs of up to 8,192 tokens; the runtime accepts up to 16,384 |
126
  | Weights | bfloat16 safetensors, plus an 8-bit GGUF build (2.0 GB) |
127
  | License | AGPL-3.0, or a commercial license from Bynn Intelligence |
 
198
  | `score` | 1 to 10 ordered levels, from low to high | the expected level, a confidence and a probability for every level | DecisionNode Edge and the hosted API |
199
  | `truth` | a statement or question, with optional `true` and `false` criteria | the probability that the answer is yes | DecisionNode Edge and the hosted API |
200
  | `number` | `min`, `max` and `step` (up to 4,096 values) | the most probable value, the expected value, a confidence and the probable values | DecisionNode Edge and the hosted API |
201
+ | `point` | an image and what to point to | a point (`x`, `y`) in pixels or fractions of the image, the probability that the thing is there, and a confidence | DecisionNode Edge and the hosted API |
202
+ | `box` | an image and what to box | a bounding box (`x1`, `y1`, `x2`, `y2`), the probability that the thing is there, and a confidence | DecisionNode Edge and the hosted API |
 
 
203
 
204
  Write what the question means into `instructions` and `criteria`; the question's key is never shown to the model. Images are seen by every question in the request.
205
 
206
+ ### Points and boxes
207
+
208
+ A `point` question asks where something is in a picture, a `box` question asks for a rectangle around it. Add `max` (2 to 16) to get every match as a list:
209
+
210
+ ```python
211
+ model.decide({
212
+ "images": [{"id": "belt", "path": "belt.jpg"}],
213
+ "questions": {
214
+ "pick": {"type": "point", "instructions": "The yellow package closest to the robot arm."},
215
+ "damage": {"type": "box", "instructions": "The damaged package.", "units": "normalized"},
216
+ "parcels": {"type": "box", "instructions": "Every parcel on the belt.", "max": 10},
217
+ },
218
+ })
219
+ ```
220
+
221
+ Each answer gives the place (`x`, `y` for a point; `x1`, `y1`, `x2`, `y2` for a box) in pixels or fractions of the picture, `present` (the probability that the thing is in the picture at all) and a `confidence` for the place. A point takes 3 short prompts on the picture and a box 5.
222
+
223
+ On 300 held-out photos with one object each, Edge puts a point on the object 94.3% of the time, and its box overlaps the true box by at least half (IoU 0.5) 85.0% of the time, with a mean IoU of 0.71. Asked for every match on 197 pictures with up to 16 objects, its points reach a precision of 63% and a recall of 59%, its boxes 51% and 42%. On the same photos, [DecisionNode](https://decisionnode.com/docs/models/decisionnode) places points at 96.0% and boxes at 92.7%, and [DecisionNode Flash](https://decisionnode.com/docs/models/flash) at 95.0% and 89.7%.
224
+
225
  ## Performance
226
 
227
  `EdgeModel.load` selects the fastest backend installed on your machine. Every backend computes the same internal representation and applies the same decision head and calibration, so answers are consistent across devices. Median time for one question at about 500 and 2,000 tokens of input:
 
254
 
255
  | Model | Mean accuracy |
256
  |---|---|
257
+ | DecisionNode Edge (bfloat16) | 74.2% |
258
+ | DecisionNode Edge (8-bit GGUF) | 74.2% |
259
 
260
  Compared with DecisionNode Edge Mini, Edge is about 5 points more accurate and roughly half as fast on a CPU. The 8-bit build matches the bfloat16 weights on all 15 benchmarks; 4-bit builds lose accuracy and are not offered.
261
 
 
267
  - classification and scoring against criteria you define in the request
268
  - validation and guardrail checks before an action runs
269
  - reading values from images, such as whether a total matches or how many items are present
270
+ - locating things in images: a point for a robot to pick, a box around a defect or a person at the door
271
  - on-device decisions in robots, drones, vehicles and cameras, with the hosted models for the hard cases
272
  - applications that must run offline or keep data on the device
273
 
 
278
  - **Accuracy varies by domain and language.** Evaluate the model on your own data before relying on it.
279
  - **Input length.** The model was evaluated on inputs of up to 8,192 tokens. The runtime accepts up to 16,384 tokens and rejects longer inputs rather than truncating them.
280
  - **Not a generative model.** DecisionNode Edge does not produce text. Use it through the runtime; loading it in a text-generation pipeline yields meaningless output.
281
+ - **Scope.** Video input, boosters, knowledge and fine-tuning are available on the hosted DecisionNode platform, not in DecisionNode Edge.
282
 
283
  ## Files
284
 
 
286
  |---|---|
287
  | `model.safetensors`, `config.json` | model weights (bfloat16) and configuration |
288
  | `readout.safetensors`, `calibration.json` | decision head and calibration |
289
+ | `box_calibration.json` | the calibration of box confidence on the `/v1/systemone` route |
290
  | `tokenizer.json`, `tokenizer_config.json` | tokenizer |
291
  | `preprocessor_config.json`, `video_preprocessor_config.json` | image preprocessing settings |
292
  | `decisionnode_edge.json` | prompt format and limits used by the runtime |
box_calibration.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "format": "dn-box-calibration-1",
3
+ "model": "decisionnode-edge-0.9.1",
4
+ "event": "the box is right: IoU of 0.5 or more with the true box (an absent thing: never right)",
5
+ "features": [
6
+ "logit_present",
7
+ "logit_confidence"
8
+ ],
9
+ "clip": 0.005,
10
+ "intercept": -3.115027,
11
+ "coef": [
12
+ 0.987029,
13
+ 0.160141
14
+ ],
15
+ "note": "Maps a box answer's present and confidence to the probability that the box is right. Fitted on held-out data; no benchmark item was used."
16
+ }
decisionnode_edge.json CHANGED
@@ -287,5 +287,6 @@
287
  "max_options": 255,
288
  "max_levels": 10,
289
  "max_grid_values": 4096
290
- }
 
291
  }
 
287
  "max_options": 255,
288
  "max_levels": 10,
289
  "max_grid_values": 4096
290
+ },
291
+ "box_calibration_sha256": "544b3d40cc9e355b16f8c131426c21245c1b5847a46c40c1b4be2c33a447b4fa"
292
  }
runtime/README.md CHANGED
@@ -74,7 +74,7 @@ response["answers"]["route"] # {"type": "choice", "choice": ..., "confiden
74
  response["answers"]["escalate"] # {"type": "truth", "truth": ...}
75
  ```
76
 
77
- `model.predict(request)` gives the unrounded calibrated probabilities per question instead: one per option or level in request order, `[yes, no]` for a truth question, one per grid value for a number question.
78
 
79
  A response names the model that answered: `decisionnode-edge-0.9.1` for DecisionNode Edge, `decisionnode-edge-mini-0.9.1` for DecisionNode Edge Mini (the `name` in the model directory's `decisionnode_edge.json`).
80
 
@@ -96,14 +96,29 @@ A small server on Python's standard library, with nothing more to install. It lo
96
 
97
  | Route | What |
98
  |---|---|
99
- | `POST /v1/systemone` | the `/v1/systemone` format used by public decision-model leaderboards: `{"model", "state", "questions", "assets"}` with `noul`, `choice` and `score` questions. A `noul` is answered `{"type": "noul", "noul": p}`, a choice and a score as on `/v1/decide` (probabilities that sum to 1). `model` is required, and any name is accepted |
100
- | `POST /v1/decide` | the request and answers of this runtime ([below](#the-request)), `number` and `truth` included |
101
  | `GET /v1/models` | `{"models": [{"name", "description", "release_date"}]}` |
102
  | `GET /healthz` | `{"status": "ok", "model", "backend"}`, no key needed |
103
 
104
- Every answer carries `usage`: `input_tokens` and `output_tokens` (always 0). Pictures go as base64: the runtime's `images` with `data` (a local `path` is refused over HTTP, so a caller can never make the server read a file), or, on `/v1/systemone`, an `assets` map, `{"<ref>": {"type": "image", "format": "png", "data_base64": "..."}}` with format `png`, `jpg` or `jpeg`. Each asset becomes a picture whose id is its reference.
105
 
106
- Errors come as `{"detail": {"error_type": ..., "message": ...}}` (a 422 as a list of field errors). A request longer than the model's limit is a 400 whose message says "maximum context length"; a choice with more than 255 options is a 400 that says "options per choice".
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
  ### Options
109
 
@@ -203,6 +218,40 @@ Send pictures beside the state, up to 16, each with an `id`:
203
 
204
  A picture is turned upright from its EXIF orientation, converted to RGB and scaled so its longest side is at most 2,048 pixels. Every question sees every picture. The prompt names a picture `Picture <n> (<id>)`, so instructions can refer to "Picture 2" or to "the receipt".
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  ## The request
207
 
208
  The request and the answers follow the DecisionNode API's `/v1/decide`:
@@ -220,18 +269,21 @@ The request and the answers follow the DecisionNode API's `/v1/decide`:
220
  | `score` | `instructions` (optional), `criteria`: [level, ...] low to high, 1 to 10 levels |
221
  | `truth` | `instructions` and/or `criteria`: {"true": ..., "false": ...} |
222
  | `number` | `instructions`, `min`, `max`, `step` (defaults 0, 255, 1), up to 4,096 values; more than 256 values are answered in pages of 256 and composed |
 
223
 
224
  Answers are rounded as the API rounds them: probabilities to 2 decimals (3 for number questions), with the largest entries moved by 0.01 until a list sums to 1.
225
 
226
- A request the model cannot answer raises `decisionnode_edge.RequestError`, whose `status` is the HTTP status the hosted API gives the same fault (400 or 422). The runtime checks what the model needs; the hosted API's error bodies are not reproduced word for word. Requests longer than the model's limit (`limits.max_input_tokens` in `decisionnode_edge.json`, 16,384 tokens) are refused, never cut.
227
 
228
- Not on Edge: videos, points and boxes, boosters, image URLs and uploads, sessions and batch jobs. These are features of the hosted API.
229
 
230
  ## How it works
231
 
232
  Each question becomes one prompt: the state block (pictures first, then the state text), then the question block (the type, the instructions, then one line per option, each led by an answer code token), then the word `Answer` and an answer marker. The codes of a question are drawn from a hash of the question alone, so the same question always gets the same codes. The model reads the prompt in one forward pass, and the last hidden state is scored against the rows of `readout.safetensors` that belong to the question's codes (or to its yes and no tokens). `calibration.json` turns those scores into probabilities. The model never generates text.
233
 
234
- The code is a few short modules: `template.py` (the prompt and the codes), `request.py` (reading a request), `answers.py` (calibration and the answer shapes), `backends.py` (what reads the backbone: PyTorch, vLLM, OpenVINO, MLX or llama.cpp), `build.py` (the OpenVINO and GGUF builds), `server.py` (the HTTP server), with `model.py` tying them to the weights.
 
 
235
 
236
  ## License
237
 
 
74
  response["answers"]["escalate"] # {"type": "truth", "truth": ...}
75
  ```
76
 
77
+ `model.predict(request)` gives the unrounded calibrated probabilities per question instead: one per option or level in request order, `[yes, no]` for a truth question, one per grid value for a number question. Point and box questions are answered by `decide` only.
78
 
79
  A response names the model that answered: `decisionnode-edge-0.9.1` for DecisionNode Edge, `decisionnode-edge-mini-0.9.1` for DecisionNode Edge Mini (the `name` in the model directory's `decisionnode_edge.json`).
80
 
 
96
 
97
  | Route | What |
98
  |---|---|
99
+ | `POST /v1/systemone` | the `/v1/systemone` format used by public decision-model leaderboards: `{"model", "state", "questions", "assets"}` with `noul`, `choice`, `score` and `box` questions. A `noul` is answered `{"type": "noul", "noul": p}`, a choice and a score as on `/v1/decide` with probabilities that sum to exactly 1, a box as below. `model` is required, and any name is accepted: a local server has no model catalogue, and the answer names the model that answered. Number and point questions go to `/v1/decide` |
100
+ | `POST /v1/decide` | the request and answers of this runtime ([below](#the-request)), `number`, `truth`, `point` and `box` included |
101
  | `GET /v1/models` | `{"models": [{"name", "description", "release_date"}]}` |
102
  | `GET /healthz` | `{"status": "ok", "model", "backend"}`, no key needed |
103
 
104
+ Every answer carries `usage`: `input_tokens` and `output_tokens` (always 0). Pictures go as base64: the runtime's `images` with `data` (a local `path` is refused over HTTP, so a caller can never make the server read a file), or, on `/v1/systemone`, the format's own:
105
 
106
+ - an `assets` map, `{"<ref>": {"type": "image", "format": "png", "data_base64": "..."}}` with format `png`, `jpg` or `jpeg` (the bytes decide when they say otherwise). Wherever a reference appears in the state, an instruction or a criterion, the text reads `Picture <n>` and the asset becomes picture n (`image` when it is the only one, else `image_<n>`), numbered in the order the references first appear. An asset no text refers to is refused. A score's `legend` shows its levels as sent, references included.
107
+ - `image_url` content parts (`{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}`) in the same places, and `images` as a list of data URLs.
108
+
109
+ This is the 0.9.2 behaviour, the same as the hosted `/v1/systemone`. In 0.9.1 each asset became a picture whose id was its reference, and the text stayed as sent. `/v1/decide` reads pictures as before.
110
+
111
+ A `box` question on `/v1/systemone` is `{"type": "box", "instructions": "..."}`. Its answer is `{"type": "box", "box": {"top", "bottom", "left", "right"}, "confidence"}`, the edges as fractions of the picture's height and width. When the request holds several pictures, the box looks in the one a path in backticks names in the state (`` `photo` ``, `` `cams.door` ``), else in the first picture of the state; with the runtime's own `images`, name it with `image`, as on `/v1/decide`. A box with `max` keeps the list answer of `/v1/decide`.
112
+
113
+ `confidence` is the probability that the box is right (an intersection over union of 0.5 or more with the true box) when the model folder holds a box calibration, `box_calibration.json`. Without one it is `present`, the probability that the thing is in the picture. The server reads the file once at start and refuses to start on a file it cannot check. `--box-calibration FILE` uses another file and `--box-calibration none` uses none; `--box-calibration-sha256` (or `box_calibration_sha256` in `decisionnode_edge.json`) pins the file's sha256. `/v1/decide` never reads it.
114
+
115
+ Errors come as `{"detail": {"error_type": ..., "message": ...}}` (a 422 as a list of field errors). A request longer than the model's limit is a 400 whose message says "maximum context length"; a choice with more than 255 options is a 400 that says "options per choice". On `/v1/systemone` these limits come in the hosted route's bodies, which benchmark harnesses read to record a request as beyond the model:
116
+
117
+ | Limit | `/v1/systemone` body |
118
+ |---|---|
119
+ | longer than the model's limit | `{"detail": {"error_type": "max_tokens_exceeded", "message": "The request does not fit the model's maximum context length (...)."}}` |
120
+ | more than 255 options | `{"detail": "Too many choices. Must have at most 255 choices (255 options per choice)."}` |
121
+ | more than 10 levels | `{"detail": "Too many score levels. Must have at most 10 levels (a score takes 2 to 10 levels; one level is answered without the model)."}` |
122
 
123
  ### Options
124
 
 
218
 
219
  A picture is turned upright from its EXIF orientation, converted to RGB and scaled so its longest side is at most 2,048 pixels. Every question sees every picture. The prompt names a picture `Picture <n> (<id>)`, so instructions can refer to "Picture 2" or to "the receipt".
220
 
221
+ ### Points and boxes
222
+
223
+ A `point` question asks where something is in a picture, a `box` question asks for a rectangle around it. Add `max` to get every match as a list:
224
+
225
+ ```python
226
+ response = model.decide({
227
+ "images": [{"id": "belt", "path": "/data/belt.jpg"}],
228
+ "questions": {
229
+ "pick": {"type": "point", "instructions": "The yellow package closest to the robot arm."},
230
+ "damage": {"type": "box", "instructions": "The damaged package.", "units": "normalized"},
231
+ "parcels": {"type": "box", "instructions": "Every parcel on the belt.", "max": 10},
232
+ },
233
+ })
234
+
235
+ response["answers"]["pick"]
236
+ # {"type": "point", "x": ..., "y": ..., "present": ..., "confidence": ..., "image": "belt", "width": 1920, "height": 1080}
237
+ response["answers"]["parcels"]
238
+ # {"type": "box", "count": ..., "present": ..., "complete": ..., "boxes": [{"x1": ..., "y1": ..., "x2": ..., "y2": ..., "confidence": ...}, ...],
239
+ # "image": "belt", "width": 1920, "height": 1080}
240
+ ```
241
+
242
+ | Field | Notes |
243
+ |---|---|
244
+ | `instructions` | what to find, required: a phrase ("the yellow package"), a description with conditions, or a question ("Where is the crack?") |
245
+ | `image` | the id of the picture to look in; needed when the request has two or more pictures |
246
+ | `units` | `"pixels"` (the default): whole pixels of the picture as sent, after its EXIF orientation. `"normalized"`: fractions of the width and height with 4 decimals |
247
+ | `max` | 2 to 16: list up to that many places in place of one |
248
+
249
+ In an answer, `x` and `y` are measured from the top-left corner, `x` to the right and `y` down. A box is its top-left corner (`x1`, `y1`) and its bottom-right corner (`x2`, `y2`). `present` is the probability that the thing is in the picture at all; when it is low, the place is only the best guess. `confidence` says how sure the model is of the place: the product of the peak probabilities of its coordinate readings. It is not calibrated yet, so use it to compare answers, not as a probability. `image`, `width` and `height` name the picture and its size.
250
+
251
+ With `max`, `count` is the number of places listed, `complete` the probability that there are no more than `max`, and the places come from the left, ties from the top, each with its own `confidence`. Two boxes that overlap with an intersection over union of 0.7 or more are one thing: the less confident one is left out.
252
+
253
+ A point or box question takes a few short prompts on the same picture: whether the thing is there, then where it is across the width, then down the height at that spot, and for a box its width and height around that centre. A point takes 3 prompts, a box 5, a list of n boxes 2 + 4n. Each prompt reads the picture again, so a box costs about five times a `truth` question on the same picture. As with every question that carries pictures, PyTorch reads them whichever backend is chosen.
254
+
255
  ## The request
256
 
257
  The request and the answers follow the DecisionNode API's `/v1/decide`:
 
269
  | `score` | `instructions` (optional), `criteria`: [level, ...] low to high, 1 to 10 levels |
270
  | `truth` | `instructions` and/or `criteria`: {"true": ..., "false": ...} |
271
  | `number` | `instructions`, `min`, `max`, `step` (defaults 0, 255, 1), up to 4,096 values; more than 256 values are answered in pages of 256 and composed |
272
+ | `point`, `box` | `instructions`, `image` (with two or more pictures), `units` (`pixels` or `normalized`), `max` (2 to 16, a list); see [Points and boxes](#points-and-boxes) |
273
 
274
  Answers are rounded as the API rounds them: probabilities to 2 decimals (3 for number questions), with the largest entries moved by 0.01 until a list sums to 1.
275
 
276
+ A request the model cannot answer raises `decisionnode_edge.RequestError`, whose `status` is the HTTP status the hosted API gives the same fault (400 or 422). For point and box questions, `errors` lists every faulty field as the hosted API's 422 does. The runtime checks what the model needs; the hosted API's error bodies are not reproduced word for word. Requests longer than the model's limit (`limits.max_input_tokens` in `decisionnode_edge.json`, 16,384 tokens) are refused, never cut.
277
 
278
+ Not on Edge: videos, boosters, image URLs and uploads, sessions and batch jobs. These are features of the hosted API.
279
 
280
  ## How it works
281
 
282
  Each question becomes one prompt: the state block (pictures first, then the state text), then the question block (the type, the instructions, then one line per option, each led by an answer code token), then the word `Answer` and an answer marker. The codes of a question are drawn from a hash of the question alone, so the same question always gets the same codes. The model reads the prompt in one forward pass, and the last hidden state is scored against the rows of `readout.safetensors` that belong to the question's codes (or to its yes and no tokens). `calibration.json` turns those scores into probabilities. The model never generates text.
283
 
284
+ A point or box question is a short series of such prompts. Its coordinate prompts list no options: they are answered by the 256 code tokens in order, code k meaning step k of 256 along a side of the picture, and the answer is the probability-weighted mean of the steps around the most probable one. A later prompt is given what the earlier ones read: the y is asked at the x already chosen, and a box's size around its centre.
285
+
286
+ The code is a few short modules: `template.py` (the prompt and the codes), `request.py` (reading a request), `answers.py` (calibration and the answer shapes), `places.py` (point and box questions), `backends.py` (what reads the backbone: PyTorch, vLLM, OpenVINO, MLX or llama.cpp), `build.py` (the OpenVINO and GGUF builds), `server.py` (the HTTP server) with `systemone.py` (the `/v1/systemone` format), and `model.py` tying them to the weights.
287
 
288
  ## License
289
 
runtime/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "decisionnode-edge"
7
- version = "0.9.1"
8
  description = "Run DecisionNode Edge locally: typed, calibrated decisions in the shape of the DecisionNode API."
9
  readme = "README.md"
10
  requires-python = ">=3.10"
 
4
 
5
  [project]
6
  name = "decisionnode-edge"
7
+ version = "0.9.2"
8
  description = "Run DecisionNode Edge locally: typed, calibrated decisions in the shape of the DecisionNode API."
9
  readme = "README.md"
10
  requires-python = ">=3.10"
runtime/src/decisionnode_edge/__init__.py CHANGED
@@ -10,4 +10,4 @@ from .model import EdgeModel
10
  from .request import RequestError
11
 
12
  __all__ = ["EdgeModel", "RequestError"]
13
- __version__ = "0.9.1"
 
10
  from .request import RequestError
11
 
12
  __all__ = ["EdgeModel", "RequestError"]
13
+ __version__ = "0.9.2"
runtime/src/decisionnode_edge/answers.py CHANGED
@@ -4,6 +4,7 @@ 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
 
8
  A parameter set exists per input kind ("text", "image"); a missing one is the identity.
9
 
@@ -13,9 +14,14 @@ The answers, rounded as the hosted API rounds them:
13
  score {"type", "score", "confidence", "legend": {"0": level}, "probabilities": {"0": p}}
14
  truth {"type", "truth"}
15
  number {"type", "number", "expected", "confidence", "probabilities": {value: p}}
 
 
 
16
 
17
  `choice`, `score`, `number` and the confidences come from the unrounded probabilities. A probability list is rounded
18
  to 2 decimals entry by entry, then its largest entries move by 0.01 until it sums to 1 (`round_probabilities`).
 
 
19
  """
20
 
21
  from __future__ import annotations
@@ -43,6 +49,8 @@ def _temperature(params: dict, k: int) -> float:
43
  def calibrated(logits: Sequence[float], kind: str, calibration: dict, has_images: bool) -> list[float]:
44
  """Calibrated class probabilities from a question's code logits (class order: options or levels as given,
45
  [yes, no] for truth)."""
 
 
46
  params = (calibration.get("image" if has_images else "text") or {}).get("choice" if kind == "number" else kind) or {}
47
  z = [float(x) for x in logits]
48
  if kind == "truth":
@@ -58,6 +66,17 @@ def calibrated(logits: Sequence[float], kind: str, calibration: dict, has_images
58
  return [math.exp(x - top - log_total) for x in scaled]
59
 
60
 
 
 
 
 
 
 
 
 
 
 
 
61
  # ---------------------------------------------------------------------------
62
  # Rounding
63
  # ---------------------------------------------------------------------------
@@ -73,12 +92,41 @@ def round2(x: float) -> float:
73
  return 0.0 if value == 0 else value
74
 
75
 
76
- def round_probabilities(probs: Sequence[float]) -> list[float]:
 
 
 
 
 
 
 
 
 
77
  """Round to 2 decimals, then move the |n| largest entries by 0.01, n = int((1 - fsum(rounded)) / 0.01).
78
- A moved entry is not rounded again (0.81 + 0.01 is 0.8200000000000001), as the hosted API returns it."""
 
 
 
79
  p = [float(x) for x in probs]
80
  units = [_units(x) for x in p]
81
  rounded = [u / 100.0 for u in units]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  n = int((1.0 - math.fsum(rounded)) / UNIT)
83
  if n:
84
  step = 1 if n > 0 else -1
@@ -100,15 +148,19 @@ def _argmax_first(probs: Sequence[float]) -> int:
100
  # ---------------------------------------------------------------------------
101
 
102
 
103
- def choice_answer(keys: Sequence[str], probs: Sequence[float]) -> dict:
 
 
104
  best = _argmax_first(probs)
105
  k = len(probs)
106
  confidence = 1.0 if k == 1 else min(1.0, max(0.0, (k * probs[best] - 1.0) / (k - 1.0)))
107
- shown = round_probabilities(probs)
108
  return {"type": "choice", "choice": keys[best], "confidence": round2(confidence), "probabilities": dict(sorted(zip(keys, shown)))}
109
 
110
 
111
- def score_answer(levels: Sequence, probs: Sequence[float]) -> dict:
 
 
112
  k = len(probs)
113
  if k == 1:
114
  confidence = 1.0
@@ -121,7 +173,7 @@ def score_answer(levels: Sequence, probs: Sequence[float]) -> dict:
121
  "score": round2(sum(i * p for i, p in enumerate(probs))),
122
  "confidence": round2(confidence),
123
  "legend": {str(i): level for i, level in enumerate(levels)},
124
- "probabilities": {str(i): x for i, x in enumerate(round_probabilities(probs))},
125
  }
126
 
127
 
 
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
 
 
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
 
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":
 
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
  # ---------------------------------------------------------------------------
 
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
 
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
 
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
 
runtime/src/decisionnode_edge/model.py CHANGED
@@ -10,6 +10,9 @@ The model reads the prompt once per question and never generates text. Its answe
10
  (readout.safetensors), one row per answer code, and only the rows of the question's codes are read.
11
  Calibration (calibration.json) turns those scores into probabilities.
12
 
 
 
 
13
  The backbone is read by torch (transformers) or by a faster backend (backends.py: vLLM, OpenVINO, MLX,
14
  llama.cpp); the readout and calibration are this module's in every case.
15
  """
@@ -26,7 +29,7 @@ from typing import Any
26
  import torch
27
  from safetensors.torch import load_file
28
 
29
- from . import answers, backends, request as requests
30
  from .template import Template
31
 
32
  CONFIG_FILE = "decisionnode_edge.json"
@@ -115,15 +118,21 @@ class EdgeModel:
115
 
116
  def decide(self, body: dict) -> dict:
117
  """Answer a `/v1/decide` request body. Returns {"model", "answers", "usage"} as the hosted API does."""
118
- parsed = requests.parse(body)
119
- probs, input_tokens = self._predict(parsed)
 
 
 
120
  out = {}
121
  for q in parsed.questions:
 
 
 
122
  p = probs[q.id]
123
  if q.type == "choice":
124
- out[q.id] = answers.choice_answer(q.keys, p)
125
  elif q.type == "score":
126
- out[q.id] = answers.score_answer(q.levels, p)
127
  elif q.type == "truth":
128
  out[q.id] = answers.truth_answer(p)
129
  else:
@@ -132,33 +141,58 @@ class EdgeModel:
132
 
133
  def predict(self, body: dict) -> dict[str, list[float]]:
134
  """The unrounded calibrated probabilities of every question: per option or level in request order, [yes, no]
135
- for truth, per grid value for number."""
136
- return self._predict(requests.parse(body))[0]
 
 
 
 
137
 
138
  # -- internals --------------------------------------------------------
139
 
140
- def _predict(self, parsed: requests.Request) -> tuple[dict[str, list[float]], int]:
 
141
  images = self._load_images(parsed.images) if parsed.images else None
142
  image_ids = [image["id"] for image in parsed.images]
143
  out: dict[str, list[float]] = {}
144
  state_tokens, total = None, 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  for q in parsed.questions:
146
- answered = []
147
- for row in q.rows:
148
- encoded = self.template.encode(row, parsed.state, image_ids, images[2] if images else None)
149
- if len(encoded.input_ids) > self.limits["max_input_tokens"]:
150
- raise requests.RequestError(f"question {q.id!r} needs {len(encoded.input_ids):,} tokens; the maximum context length is {self.limits['max_input_tokens']:,} tokens",
151
- error_type="max_tokens_exceeded") # fmt: skip
152
- logits = self._logits(encoded, images)
153
- answered.append(answers.calibrated(logits, row["type"], self.calibration, bool(image_ids)))
154
- # the state and its pictures count once, every question's own tokens on top (as the hosted usage counts)
155
- total += len(encoded.input_ids) - (encoded.state_tokens if state_tokens is not None else 0)
156
- state_tokens = encoded.state_tokens
157
  if q.type == "number":
158
  out[q.id] = [1.0] if not q.rows else requests.compose(q.grid, answered)
159
  else:
160
  out[q.id] = answered[0] if answered else [1.0]
161
- return out, total
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
  @torch.inference_mode()
164
  def _logits(self, encoded: Any, images: tuple | None) -> list[float]:
@@ -188,6 +222,9 @@ class EdgeModel:
188
  hidden = self.backbone.model(input_ids=input_ids, attention_mask=torch.ones_like(input_ids), use_cache=False, **extra).last_hidden_state
189
  return hidden[0, -1].float()
190
 
 
 
 
191
  def _load_images(self, images: list[dict]) -> tuple[torch.Tensor, torch.Tensor, dict[str, int]]:
192
  """(pixel values, grid, placeholder tokens per image id) of a request's pictures: EXIF orientation applied,
193
  RGB, the longest side at most max_image_side, then the backbone's own image processor."""
@@ -213,3 +250,24 @@ class EdgeModel:
213
  merge = int(getattr(self._images, "merge_size", 2))
214
  counts = (out["image_grid_thw"].prod(dim=-1) // merge**2).tolist()
215
  return out["pixel_values"], out["image_grid_thw"], {image["id"]: int(n) for image, n in zip(images, counts)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  (readout.safetensors), one row per answer code, and only the rows of the question's codes are read.
11
  Calibration (calibration.json) turns those scores into probabilities.
12
 
13
+ A point or box question is a few such prompts, pass by pass (places.py): whether the thing is in the picture,
14
+ then where along each side, each later prompt given what the earlier ones read.
15
+
16
  The backbone is read by torch (transformers) or by a faster backend (backends.py: vLLM, OpenVINO, MLX,
17
  llama.cpp); the readout and calibration are this module's in every case.
18
  """
 
29
  import torch
30
  from safetensors.torch import load_file
31
 
32
+ from . import answers, backends, places, request as requests
33
  from .template import Template
34
 
35
  CONFIG_FILE = "decisionnode_edge.json"
 
118
 
119
  def decide(self, body: dict) -> dict:
120
  """Answer a `/v1/decide` request body. Returns {"model", "answers", "usage"} as the hosted API does."""
121
+ return self._decide(requests.parse(body))
122
+
123
+ def _decide(self, parsed: requests.Request, exact_sum: bool = False) -> dict:
124
+ """The answers of a parsed request; `exact_sum`: choice and score lists that sum to exactly 1.00 (/v1/systemone)."""
125
+ probs, input_tokens, placed = self._predict(parsed)
126
  out = {}
127
  for q in parsed.questions:
128
+ if q.place is not None:
129
+ out[q.id] = placed[q.id]
130
+ continue
131
  p = probs[q.id]
132
  if q.type == "choice":
133
+ out[q.id] = answers.choice_answer(q.keys, p, exact_sum)
134
  elif q.type == "score":
135
+ out[q.id] = answers.score_answer(q.levels, p, exact_sum)
136
  elif q.type == "truth":
137
  out[q.id] = answers.truth_answer(p)
138
  else:
 
141
 
142
  def predict(self, body: dict) -> dict[str, list[float]]:
143
  """The unrounded calibrated probabilities of every question: per option or level in request order, [yes, no]
144
+ for truth, per grid value for number. A point or box question is answered by `decide` only."""
145
+ parsed = requests.parse(body)
146
+ for q in parsed.questions:
147
+ if q.place is not None:
148
+ raise requests.RequestError(f"question {q.id!r}: a {q.type} question is answered by decide(), not predict()")
149
+ return self._predict(parsed)[0]
150
 
151
  # -- internals --------------------------------------------------------
152
 
153
+ def _predict(self, parsed: requests.Request) -> tuple[dict[str, list[float]], int, dict[str, dict]]:
154
+ """(the probabilities of every question but the point and box ones, the input tokens, the point and box answers)."""
155
  images = self._load_images(parsed.images) if parsed.images else None
156
  image_ids = [image["id"] for image in parsed.images]
157
  out: dict[str, list[float]] = {}
158
  state_tokens, total = None, 0
159
+
160
+ def read(qid: str, row: dict) -> list[float]:
161
+ nonlocal state_tokens, total
162
+ encoded = self.template.encode(row, parsed.state, image_ids, images[2] if images else None)
163
+ if len(encoded.input_ids) > self.limits["max_input_tokens"]:
164
+ raise requests.RequestError(f"question {qid!r} needs {len(encoded.input_ids):,} tokens; the maximum context length is {self.limits['max_input_tokens']:,} tokens",
165
+ error_type="max_tokens_exceeded") # fmt: skip
166
+ probs = self._probabilities(encoded, row, images, bool(image_ids))
167
+ # the state and its pictures count once, every question's own tokens on top (as the hosted usage counts)
168
+ total += len(encoded.input_ids) - (encoded.state_tokens if state_tokens is not None else 0)
169
+ state_tokens = encoded.state_tokens
170
+ return probs
171
+
172
+ specs = []
173
  for q in parsed.questions:
174
+ if q.place is not None:
175
+ specs.append(q.place)
176
+ continue
177
+ answered = [read(q.id, row) for row in q.rows]
 
 
 
 
 
 
 
178
  if q.type == "number":
179
  out[q.id] = [1.0] if not q.rows else requests.compose(q.grid, answered)
180
  else:
181
  out[q.id] = answered[0] if answered else [1.0]
182
+ placed: dict[str, dict] = {}
183
+ if specs: # every point and box question's readings, pass by pass, each pass given the readings before it
184
+ plan = places.Plan(specs)
185
+ while True:
186
+ rows = plan.rows()
187
+ if not rows:
188
+ break
189
+ plan.take(rows, [read(plan.chains[n].spec.qid, row) for n, row in rows])
190
+ placed = plan.answers(self._image_sizes(parsed.images))
191
+ return out, total, placed
192
+
193
+ def _probabilities(self, encoded: Any, row: dict, images: tuple | None, has_images: bool) -> list[float]:
194
+ """One prompt's class probabilities: the readout's scores of its codes, calibrated (a coordinate row's softmax)."""
195
+ return answers.calibrated(self._logits(encoded, images), row["type"], self.calibration, has_images)
196
 
197
  @torch.inference_mode()
198
  def _logits(self, encoded: Any, images: tuple | None) -> list[float]:
 
222
  hidden = self.backbone.model(input_ids=input_ids, attention_mask=torch.ones_like(input_ids), use_cache=False, **extra).last_hidden_state
223
  return hidden[0, -1].float()
224
 
225
+ def _image_sizes(self, images: list[dict]) -> dict[str, tuple[int, int]]:
226
+ return image_sizes(images)
227
+
228
  def _load_images(self, images: list[dict]) -> tuple[torch.Tensor, torch.Tensor, dict[str, int]]:
229
  """(pixel values, grid, placeholder tokens per image id) of a request's pictures: EXIF orientation applied,
230
  RGB, the longest side at most max_image_side, then the backbone's own image processor."""
 
250
  merge = int(getattr(self._images, "merge_size", 2))
251
  counts = (out["image_grid_thw"].prod(dim=-1) // merge**2).tolist()
252
  return out["pixel_values"], out["image_grid_thw"], {image["id"]: int(n) for image, n in zip(images, counts)}
253
+
254
+
255
+ def image_sizes(images: list[dict]) -> dict[str, tuple[int, int]]:
256
+ """Per picture id, its width and height as sent after its EXIF orientation (the sides swapped when the orientation
257
+ turns it a quarter, 5 to 8), read from the file's header: the size a point or box answer is given in."""
258
+ from PIL import Image
259
+
260
+ out = {}
261
+ for image in images:
262
+ source = image["path"] if image.get("path") is not None else io.BytesIO(base64.b64decode(image["data"]))
263
+ try:
264
+ with Image.open(source) as im:
265
+ width, height = im.size
266
+ try:
267
+ orientation = int(im.getexif().get(0x0112, 1))
268
+ except Exception:
269
+ orientation = 1
270
+ except (OSError, ValueError) as error:
271
+ raise requests.RequestError(f"Image {image['id']!r} could not be read: {error}") from None
272
+ out[image["id"]] = (height, width) if orientation in (5, 6, 7, 8) else (width, height)
273
+ return out
runtime/src/decisionnode_edge/places.py ADDED
@@ -0,0 +1,536 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Point and box questions: where in a picture the thing the instructions name is.
2
+
3
+ {"type": "point", "instructions": "Point to the yellow package closest to the robot arm.", "image": "belt"}
4
+ {"type": "point", "x": 812, "y": 455, "present": 0.97, "confidence": 0.91, "image": "belt", "width": 1920, "height": 1080}
5
+ {"type": "box", "x1": 300, "y1": 120, "x2": 520, "y2": 410, "present": 0.95, "confidence": 0.88, "image": "belt", ...}
6
+
7
+ The question. `instructions` (a string, object or array; required), `image` (the id of a picture of the request;
8
+ optional when the request carries exactly one), `units` ("pixels", the default, or "normalized"), `max` (an integer from
9
+ 2 to 16: a list of up to that many places in place of one). `criteria` and any other field are ignored. A request with a
10
+ point or box question needs a picture. Every faulty field is listed in one 422 (`question_errors`, the question's type
11
+ in the path: ["body", "questions", "pick", "point", "image"]); a missing `instructions` is then the 400 of every type.
12
+
13
+ How one is answered: a few readings of the model, pass by pass, each a prompt of its own, a later pass given what the
14
+ earlier ones read:
15
+
16
+ point pass 0: presence, x pass 1: y given x
17
+ box pass 0: presence, x (centre) pass 1: y (centre) given x pass 2: width and height given the centre
18
+
19
+ The presence reading is a yes/no question ("is it in the image?") and gives `present`. A coordinate reading lists no
20
+ options: code k is step k of 256 along one side of the picture (step k stands for the fraction (k + 0.5) / 256 of the
21
+ side), its scores read as a plain softmax. Its value is the most probable step (the lowest on a tie), then the
22
+ probability-weighted mean of the steps within WINDOW = 2 of it (the answer between steps; a distant second peak, another
23
+ object, stays out of the mean), and its peak mass is the probability within that window. A later reading is given the
24
+ earlier ones rounded to whole steps. `confidence` is the product of the coordinate readings' peak masses (provisional:
25
+ not calibrated).
26
+
27
+ Units: `pixels` are whole pixels of the picture as sent, after its EXIF orientation, halves up: a point within
28
+ [0, width - 1] x [0, height - 1], a box's corners within [0, width] x [0, height]; `normalized` gives fractions of the
29
+ width and height with 4 decimals, halves up, within [0, 1]. A box's corners are its centre minus and plus half its
30
+ size, clipped to the picture, so x1 <= x2 and y1 <= y2. `present` and `confidence` to 2 decimals.
31
+
32
+ Several places (`max`):
33
+
34
+ pass 0: presence, count pass 1: every place's x pass 2: every y given its x
35
+ pass 3 (a box): every width and height given its centre
36
+
37
+ The count reading answers 0 to 16, or 17 for more (codes 0 to 17): `count` is min(level, max), `complete` the
38
+ probability of a level at or under `max`. The k-th place's readings carry its rank among the level read ("of 5, the
39
+ 2nd from the left"). The answer lists the places from the left by the centre's x, ties from the top, each with its own
40
+ confidence; two boxes at an intersection over union of MERGE_IOU = 0.7 or more are one thing, and the one of lower
41
+ confidence is dropped (`count` is the places listed). A count of 0 ends after pass 0.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import math
47
+ from dataclasses import dataclass, field
48
+ from typing import Any, Iterator, Sequence
49
+
50
+ from .answers import round2
51
+
52
+ TYPES = ("point", "box")
53
+ UNITS = ("pixels", "normalized")
54
+ N_STEPS = 256 # steps per side: the model's 256 answer codes
55
+ WINDOW = 2 # a coordinate reading: the steps within this many of the most probable one are averaged
56
+ COUNT_MAX = 16 # the most places a list holds
57
+ COUNT_MORE = COUNT_MAX + 1 # the count reading's level for more than COUNT_MAX
58
+ COUNT_LEVELS = COUNT_MORE + 1 # the count reading's codes: 0 to 17
59
+ MAX_RANGE = (2, COUNT_MAX) # a question's `max`
60
+ MERGE_IOU = 0.7 # two listed boxes overlapping at this intersection over union or more are one thing
61
+ CHAIN = {"point": (("x", 0), ("y", 1)), "box": (("x", 0), ("y", 1), ("width", 2), ("height", 2))} # (axis, pass)
62
+ GIVEN = {"x": (), "y": ("x",), "width": ("x", "y"), "height": ("x", "y")} # the earlier readings an axis is given
63
+
64
+ _CONTENT_MEMBERS = (
65
+ ("string_type", "str", "Input should be a valid string"),
66
+ ("dict_type", "dict[any,any]", "Input should be a valid dictionary"),
67
+ ("list_type", "list[any]", "Input should be a valid list"),
68
+ )
69
+
70
+
71
+ def is_place_question(question: Any) -> bool:
72
+ return isinstance(question, dict) and question.get("type") in TYPES
73
+
74
+
75
+ @dataclass
76
+ class PlaceSpec:
77
+ """One point or box question of a request."""
78
+
79
+ qid: str
80
+ shape: str # "point" or "box"
81
+ instructions: str # as the prompt prints them
82
+ image: str # the id of the picture to look in
83
+ number: int # that picture's place among the request's pictures, from 1: how the readings name it
84
+ units: str = "pixels"
85
+ max: int | None = None # the most places to list (2 to 16); None: the one-place answer
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Validation
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ def field_error(kind: str, loc: Sequence[Any], msg: str, value: Any, **extra: Any) -> dict:
94
+ """One entry of a 422's `detail` list."""
95
+ return {"type": kind, "loc": list(loc), "msg": msg, "input": value, **extra}
96
+
97
+
98
+ def max_errors(value: Any, here: Sequence[Any]) -> Iterator[dict]:
99
+ """The entries of a question's `max`: not an integer (a bool is not one), under 2, over 16. Null is no `max`."""
100
+ lo, hi = MAX_RANGE
101
+ if value is None:
102
+ return
103
+ if isinstance(value, bool) or not isinstance(value, int):
104
+ yield field_error("int_type", (*here, "max"), "Input should be a valid integer", value)
105
+ elif value < lo:
106
+ yield field_error("greater_than_equal", (*here, "max"), f"Input should be greater than or equal to {lo}", value, ctx={"ge": lo})
107
+ elif value > hi:
108
+ yield field_error("less_than_equal", (*here, "max"), f"Input should be less than or equal to {hi}", value, ctx={"le": hi})
109
+
110
+
111
+ def question_errors(question: dict, loc: Sequence[Any], image_ids: Sequence[str] | None) -> Iterator[dict]:
112
+ """The 422 entries of one point or box question (`loc` ends in its id), in order: `instructions` of another kind than
113
+ a string, an object or an array, `units`, `max`, then `image` against the ids of the request's pictures. `image_ids`
114
+ is None when the pictures themselves are faulty (their own error names them), so only the question's fields are
115
+ checked. A missing `instructions` is not one of them (`missing_instructions`)."""
116
+ shape = question["type"]
117
+ here = (*loc, shape)
118
+ instructions = question.get("instructions")
119
+ if instructions is not None and not isinstance(instructions, (str, dict, list)):
120
+ for kind, member, msg in _CONTENT_MEMBERS:
121
+ yield field_error(kind, (*here, "instructions", member), msg, instructions)
122
+ units = question.get("units")
123
+ if units is not None and units not in UNITS:
124
+ yield field_error("literal_error", (*here, "units"), "Input should be 'pixels' or 'normalized'", units, ctx={"expected": "'pixels' or 'normalized'"})
125
+ yield from max_errors(question.get("max"), here)
126
+ image = question.get("image")
127
+ if image_ids is None:
128
+ if image is not None and not isinstance(image, str):
129
+ yield field_error("string_type", (*here, "image"), "Input should be a valid string", image)
130
+ return
131
+ if not image_ids:
132
+ yield field_error("image_required", here, f"A {shape} question needs an image in the request", question)
133
+ elif image is None:
134
+ if len(image_ids) > 1:
135
+ yield field_error("missing", (*here, "image"), "Field required", question)
136
+ elif not isinstance(image, str): # a picture's number is not an id: "Picture 2" goes in the instructions
137
+ yield field_error("string_type", (*here, "image"), "Input should be a valid string", image)
138
+ elif image not in image_ids:
139
+ yield field_error("unknown_image", (*here, "image"), "Input should be the id of an image of the request", image)
140
+
141
+
142
+ def missing_instructions(questions: Sequence[tuple[str, dict]]) -> str | None:
143
+ """The 400's message for the first point or box question without instructions, or None."""
144
+ for qid, question in questions:
145
+ if question.get("instructions") is None or question.get("instructions") == "":
146
+ return f"{question['type'].capitalize()} question must have instructions: {qid}"
147
+ return None
148
+
149
+
150
+ def where(entry: dict) -> str:
151
+ """Where a 422 entry points, for the error's message: body.questions.'pick'.'point'.'image': Field required."""
152
+ parts = (part[:100] + "..." if isinstance(part, str) and len(part) > 100 else part for part in entry["loc"])
153
+ return ".".join(str(part) if i < 2 else repr(part) for i, part in enumerate(parts)) + ": " + entry["msg"]
154
+
155
+
156
+ def reason(errors: Sequence[dict], shown: int = 5) -> str:
157
+ text = ", ".join(where(e) for e in errors[:shown])
158
+ return text + (f" and {len(errors) - shown} more" if len(errors) > shown else "")
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # The readings' texts
163
+ # ---------------------------------------------------------------------------
164
+
165
+ PRESENCE_ANSWER = "answer: is it in the image?"
166
+ COUNT_ANSWER = f"answer: how many are in the image, from 0 to {COUNT_MAX}; {COUNT_MORE} if more"
167
+ FIND = {"point": "find: the point", "box": "find: the box"}
168
+ AXIS_ANSWER = {
169
+ ("point", "x"): "answer: x of the point, from 0 (left edge) to 255 (right edge)",
170
+ ("point", "y"): "answer: y of the point, from 0 (top edge) to 255 (bottom edge)",
171
+ ("box", "x"): "answer: x of the box centre, from 0 (left edge) to 255 (right edge)",
172
+ ("box", "y"): "answer: y of the box centre, from 0 (top edge) to 255 (bottom edge)",
173
+ ("box", "width"): "answer: width of the box, from 0 (none) to 255 (the whole width)",
174
+ ("box", "height"): "answer: height of the box, from 0 (none) to 255 (the whole height)",
175
+ }
176
+
177
+
178
+ def image_line(spec: PlaceSpec) -> str:
179
+ """The picture a reading looks in, in the words of the label in front of it (template.image_label)."""
180
+ return f"image: Picture {int(spec.number)} ({spec.image})"
181
+
182
+
183
+ def presence_text(spec: PlaceSpec) -> str:
184
+ """The presence reading's instructions (a yes/no question): the instructions, the picture, the question."""
185
+ return f"{spec.instructions}\n{image_line(spec)}\n{PRESENCE_ANSWER}"
186
+
187
+
188
+ def count_text(spec: PlaceSpec) -> str:
189
+ """The count reading's text: the presence reading's lines with another question (a point and a box count alike)."""
190
+ return f"{spec.instructions}\n{image_line(spec)}\n{COUNT_ANSWER}"
191
+
192
+
193
+ def ordinal(k: int) -> str:
194
+ """1st, 2nd, 3rd, 4th, ..., 11th, 12th, 13th, ..., 21st."""
195
+ k = int(k)
196
+ suffix = "th" if 10 <= k % 100 <= 20 else {1: "st", 2: "nd", 3: "rd"}.get(k % 10, "th")
197
+ return f"{k}{suffix}"
198
+
199
+
200
+ def rank_line(rank: int, rank_of: int) -> str:
201
+ """A listed place's line: `given: of 5, the 2nd from the left` (`of more than 16` for the level 17)."""
202
+ of = f"more than {COUNT_MAX}" if int(rank_of) == COUNT_MORE else str(int(rank_of))
203
+ return f"given: of {of}, the {ordinal(rank)} from the left"
204
+
205
+
206
+ def coordinate_text(spec: PlaceSpec, axis: str, given: dict, rank: int | None = None, rank_of: int | None = None) -> str:
207
+ """A coordinate reading's text, line by line: the instructions as they are, the picture, what to find, a listed
208
+ place's rank, the earlier readings as whole steps, the axis with its 0 and 255:
209
+
210
+ the damaged package
211
+ image: Picture 1 (belt)
212
+ find: the box
213
+ given: centre x 128, y 65
214
+ answer: width of the box, from 0 (none) to 255 (the whole width)
215
+ """
216
+ lines = [spec.instructions, image_line(spec), FIND[spec.shape]]
217
+ if rank is not None:
218
+ lines.append(rank_line(rank, rank_of))
219
+ if axis == "y":
220
+ lines.append(f"given: x {given['x']}" if spec.shape == "point" else f"given: centre x {given['x']}")
221
+ elif axis in ("width", "height"):
222
+ lines.append(f"given: centre x {given['x']}, y {given['y']}")
223
+ lines.append(AXIS_ANSWER[(spec.shape, axis)])
224
+ return "\n".join(lines)
225
+
226
+
227
+ def presence_row(spec: PlaceSpec) -> dict:
228
+ return {"type": "truth", "instructions": presence_text(spec)}
229
+
230
+
231
+ def count_row(spec: PlaceSpec) -> dict:
232
+ return {"type": "coord", "instructions": count_text(spec), "classes": COUNT_LEVELS, "axis": "count"}
233
+
234
+
235
+ def coordinate_row(spec: PlaceSpec, axis: str, given: dict, rank: int | None = None, rank_of: int | None = None) -> dict:
236
+ row = {"type": "coord", "instructions": coordinate_text(spec, axis, given, rank, rank_of), "classes": N_STEPS, "axis": axis}
237
+ if rank is not None:
238
+ row["rank"] = rank
239
+ return row
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Readings
244
+ # ---------------------------------------------------------------------------
245
+
246
+
247
+ class ReadingError(ValueError):
248
+ """Probabilities a reading cannot be read from (a fault of the model or the backend, never the caller's)."""
249
+
250
+
251
+ @dataclass(frozen=True)
252
+ class Reading:
253
+ """One coordinate reading: the step (between steps), the most probable step and the peak's mass."""
254
+
255
+ step: float
256
+ argmax: int
257
+ peak: float
258
+
259
+ @property
260
+ def fraction(self) -> float:
261
+ return step_fraction(self.step)
262
+
263
+
264
+ @dataclass(frozen=True)
265
+ class CountReading:
266
+ """The count reading: the level (0 to 16, 17 for more), `count` = min(level, max), `complete` = the probability
267
+ of a level at or under max, and the level's own probability."""
268
+
269
+ level: int
270
+ count: int
271
+ complete: float
272
+ peak: float
273
+
274
+
275
+ def step_fraction(step: float) -> float:
276
+ """The fraction of the side a (continuous) step stands for: (s + 0.5) / 256."""
277
+ return (float(step) + 0.5) / N_STEPS
278
+
279
+
280
+ def given_step(step: float) -> int:
281
+ """A reading as the whole step a later reading is given: halves up, within [0, 255]."""
282
+ return min(N_STEPS - 1, max(0, int(math.floor(float(step) + 0.5))))
283
+
284
+
285
+ def _argmax_first(p: Sequence[float]) -> int:
286
+ best = 0
287
+ for k, x in enumerate(p):
288
+ if x > p[best]:
289
+ best = k
290
+ return best
291
+
292
+
293
+ def check(probs: Sequence[float], n: int, what: str) -> None:
294
+ """A list's readings are read only from n probabilities that are finite, not negative and sum to 1."""
295
+ p = [float(x) for x in probs]
296
+ if len(p) != n:
297
+ raise ReadingError(f"a {what} reading has {n} probabilities, got {len(p)}")
298
+ if any(not math.isfinite(x) or x < -1e-6 for x in p) or abs(math.fsum(p) - 1.0) > 1e-3:
299
+ raise ReadingError(f"{what} probabilities must be finite, not negative, and sum to 1")
300
+
301
+
302
+ def read(probs: Sequence[float], window: int = WINDOW) -> Reading:
303
+ """A coordinate reading from its 256 step probabilities (the module text)."""
304
+ p = [max(0.0, float(x)) for x in probs]
305
+ if len(p) != N_STEPS:
306
+ raise ReadingError(f"a coordinate reading has {N_STEPS} probabilities, got {len(p)}")
307
+ best = _argmax_first(p)
308
+ lo, hi = max(0, best - window), min(N_STEPS - 1, best + window)
309
+ mass = math.fsum(p[k] for k in range(lo, hi + 1))
310
+ step = math.fsum(k * p[k] for k in range(lo, hi + 1)) / mass if mass > 0 else float(best)
311
+ return Reading(step=step, argmax=best, peak=min(1.0, mass))
312
+
313
+
314
+ def read_count(probs: Sequence[float], max_places: int) -> CountReading:
315
+ """The count reading from its 18 level probabilities for a list of at most `max_places` places."""
316
+ check(probs, COUNT_LEVELS, "count")
317
+ p = [float(x) for x in probs]
318
+ best = _argmax_first(p)
319
+ complete = min(1.0, math.fsum(max(0.0, x) for x in p[: max_places + 1]))
320
+ return CountReading(level=best, count=min(best, max_places), complete=complete, peak=max(0.0, p[best]))
321
+
322
+
323
+ # ---------------------------------------------------------------------------
324
+ # Answers
325
+ # ---------------------------------------------------------------------------
326
+
327
+
328
+ def _half_up(x: float, decimals: int = 0) -> float:
329
+ scale = 10.0**decimals
330
+ return math.floor(x * scale + 0.5 + 1e-9) / scale
331
+
332
+
333
+ def to_pixels(fraction: float, size: int, *, inclusive_end: bool) -> int:
334
+ """A fraction of a side of `size` pixels as a whole coordinate, halves up: a point names a pixel (0 to size - 1), a
335
+ box corner may lie on the far edge (0 to size)."""
336
+ value = int(_half_up(float(fraction) * int(size)))
337
+ return min(int(size) if inclusive_end else int(size) - 1, max(0, value))
338
+
339
+
340
+ def to_normalized(fraction: float) -> float:
341
+ """A fraction with 4 decimals, halves up, within [0, 1]."""
342
+ return min(1.0, max(0.0, _half_up(float(fraction), 4)))
343
+
344
+
345
+ def box_corners(cx: float, cy: float, w: float, h: float) -> tuple[float, float, float, float]:
346
+ """(x1, y1, x2, y2) as fractions from the centre and the size, clipped to the picture: x1 <= x2 and y1 <= y2."""
347
+ w, h = max(0.0, float(w)), max(0.0, float(h))
348
+ clip = lambda v: min(1.0, max(0.0, v)) # noqa: E731
349
+ return clip(cx - w / 2.0), clip(cy - h / 2.0), clip(cx + w / 2.0), clip(cy + h / 2.0)
350
+
351
+
352
+ def box_iou(a: Sequence[float], b: Sequence[float]) -> float:
353
+ """The intersection over union of two boxes (x1, y1, x2, y2) in fractions of the sides (0 when both are empty)."""
354
+ w = max(0.0, min(a[2], b[2]) - max(a[0], b[0]))
355
+ h = max(0.0, min(a[3], b[3]) - max(a[1], b[1]))
356
+ inter = w * h
357
+ union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
358
+ return inter / union if union > 0 else 0.0
359
+
360
+
361
+ def merge_boxes(places: Sequence[tuple[tuple[float, ...], float]], threshold: float = MERGE_IOU) -> list[int]:
362
+ """The boxes (fractions, confidence) kept when two on one thing are one: taken by confidence, highest first (ties:
363
+ the earlier in the list), a box that overlaps a box already kept at `threshold` or more is dropped. Returns the
364
+ indexes kept, in list order."""
365
+ kept: list[int] = []
366
+ for i in sorted(range(len(places)), key=lambda i: (-places[i][1], i)):
367
+ worst = max((box_iou(places[i][0], places[j][0]) for j in kept), default=0.0)
368
+ if worst < threshold:
369
+ kept.append(i)
370
+ return sorted(kept)
371
+
372
+
373
+ def place_order(fractions: Sequence[float]) -> tuple[float, float]:
374
+ """A listed place's key: from the left by the centre's x, ties from the top (a box's centre, or the point), as
375
+ fractions rounded to 6 digits."""
376
+ if len(fractions) == 2:
377
+ cx, cy = fractions
378
+ else:
379
+ cx, cy = (fractions[0] + fractions[2]) / 2.0, (fractions[1] + fractions[3]) / 2.0
380
+ return round(float(cx), 6), round(float(cy), 6)
381
+
382
+
383
+ def _place(shape: str, units: str, f: Sequence[float], width: int, height: int) -> dict:
384
+ """A point's x and y, or a box's corners, in the question's units."""
385
+ if shape == "point":
386
+ if units == "pixels":
387
+ return {"x": to_pixels(f[0], width, inclusive_end=False), "y": to_pixels(f[1], height, inclusive_end=False)}
388
+ return {"x": to_normalized(f[0]), "y": to_normalized(f[1])}
389
+ if units == "pixels":
390
+ x1, y1, x2, y2 = (to_pixels(v, side, inclusive_end=True) for v, side in zip(f, (width, height, width, height)))
391
+ else:
392
+ x1, y1, x2, y2 = (to_normalized(v) for v in f)
393
+ return {"x1": x1, "y1": y1, "x2": x2, "y2": y2}
394
+
395
+
396
+ # ---------------------------------------------------------------------------
397
+ # The passes of a request
398
+ # ---------------------------------------------------------------------------
399
+
400
+
401
+ @dataclass
402
+ class Chain:
403
+ """One place: the presence and the coordinate readings so far."""
404
+
405
+ spec: PlaceSpec
406
+ present: float | None = None
407
+ readings: dict = field(default_factory=dict) # axis -> Reading
408
+
409
+ @property
410
+ def passes(self) -> int:
411
+ return 1 + max(at for _, at in CHAIN[self.spec.shape])
412
+
413
+ def answer(self, width: int, height: int) -> dict:
414
+ spec = self.spec
415
+ confidence = 1.0
416
+ for axis, _ in CHAIN[spec.shape]:
417
+ confidence *= self.readings[axis].peak
418
+ r = self.readings
419
+ f = (r["x"].fraction, r["y"].fraction) if spec.shape == "point" else box_corners(r["x"].fraction, r["y"].fraction, r["width"].fraction, r["height"].fraction)
420
+ return {"type": spec.shape, **_place(spec.shape, spec.units, f, width, height), "present": round2(self.present), "confidence": round2(confidence),
421
+ "image": spec.image, "width": int(width), "height": int(height)} # fmt: skip
422
+
423
+
424
+ @dataclass
425
+ class ListChain:
426
+ """Several places (`max`): the presence, the count, and per listed place (rank 1 first) its readings so far."""
427
+
428
+ spec: PlaceSpec
429
+ present: float | None = None
430
+ counting: CountReading | None = None
431
+ readings: list = field(default_factory=list) # per place: {axis: Reading}
432
+ passes_done: int = 0
433
+
434
+ @property
435
+ def passes(self) -> int:
436
+ """1 for a count of 0, else 1 plus a single place's passes (a point 3, a box 4); before pass 0 the most."""
437
+ if self.counting is not None and self.counting.count == 0:
438
+ return 1
439
+ return 2 + max(at for _, at in CHAIN[self.spec.shape])
440
+
441
+ @property
442
+ def done(self) -> bool:
443
+ return self.passes_done >= self.passes
444
+
445
+ def rows(self) -> list[dict]:
446
+ """The next pass's readings: pass 0 the presence and the count, then place by place one pass of each chain."""
447
+ spec = self.spec
448
+ if self.passes_done == 0:
449
+ return [presence_row(spec), count_row(spec)]
450
+ at, level = self.passes_done - 1, self.counting.level
451
+ out = []
452
+ for k in range(1, self.counting.count + 1):
453
+ for axis, when in CHAIN[spec.shape]:
454
+ if when == at:
455
+ given = {name: given_step(self.readings[k - 1][name].step) for name in GIVEN[axis]}
456
+ out.append(coordinate_row(spec, axis, given, rank=k, rank_of=level))
457
+ return out
458
+
459
+ def take(self, rows: Sequence[dict], probs: Sequence[Sequence[float]]) -> None:
460
+ if self.passes_done == 0:
461
+ if len(probs[0]) != 2:
462
+ raise ReadingError("the presence reading has 2 probabilities")
463
+ self.present = float(probs[0][0])
464
+ self.counting = read_count(probs[1], self.spec.max)
465
+ self.readings = [{} for _ in range(self.counting.count)]
466
+ else:
467
+ for row, p in zip(rows, probs):
468
+ check(p, N_STEPS, "coordinate")
469
+ self.readings[row["rank"] - 1][row["axis"]] = read(p)
470
+ self.passes_done += 1
471
+
472
+ def answer(self, width: int, height: int) -> dict:
473
+ spec = self.spec
474
+ places = []
475
+ for r in self.readings:
476
+ confidence = 1.0
477
+ for axis, _ in CHAIN[spec.shape]:
478
+ confidence *= r[axis].peak
479
+ f = (r["x"].fraction, r["y"].fraction) if spec.shape == "point" else box_corners(r["x"].fraction, r["y"].fraction, r["width"].fraction, r["height"].fraction)
480
+ places.append((f, confidence))
481
+ kept = merge_boxes(places) if spec.shape == "box" else list(range(len(places)))
482
+ listed = [{**_place(spec.shape, spec.units, places[i][0], width, height), "confidence": round2(places[i][1])} for i in sorted(kept, key=lambda i: (place_order(places[i][0]), i))]
483
+ return {"type": spec.shape, "count": len(listed), "present": round2(self.present), "complete": round2(self.counting.complete),
484
+ "boxes" if spec.shape == "box" else "points": listed, "image": spec.image, "width": int(width), "height": int(height)} # fmt: skip
485
+
486
+
487
+ class Plan:
488
+ """The point and box questions of one request, answered pass by pass: `rows()` gives the next pass's readings (each
489
+ with the index of its question), `take` their probabilities in that order, until `rows()` is empty; then `answers`.
490
+ Pass 0 holds every single place's presence reading first, then per question its first readings."""
491
+
492
+ def __init__(self, specs: Sequence[PlaceSpec]):
493
+ self.chains: list = [ListChain(spec) if spec.max is not None else Chain(spec) for spec in specs]
494
+ self.passes_run = 0
495
+
496
+ @property
497
+ def passes(self) -> int:
498
+ return max((chain.passes for chain in self.chains), default=0)
499
+
500
+ def rows(self) -> list[tuple[int, dict]]:
501
+ at = self.passes_run
502
+ if at >= self.passes:
503
+ return []
504
+ out = [(n, presence_row(chain.spec)) for n, chain in enumerate(self.chains) if isinstance(chain, Chain)] if at == 0 else []
505
+ for n, chain in enumerate(self.chains):
506
+ if isinstance(chain, ListChain):
507
+ if not chain.done and chain.passes_done == at:
508
+ out += [(n, row) for row in chain.rows()]
509
+ continue
510
+ for axis, when in CHAIN[chain.spec.shape]:
511
+ if when == at:
512
+ given = {name: given_step(chain.readings[name].step) for name in GIVEN[axis]}
513
+ out.append((n, coordinate_row(chain.spec, axis, given)))
514
+ return out
515
+
516
+ def take(self, rows: Sequence[tuple[int, dict]], probs: Sequence[Sequence[float]]) -> None:
517
+ if len(rows) != len(probs):
518
+ raise ReadingError(f"expected {len(rows)} probability lists, got {len(probs)}")
519
+ lists: dict[int, tuple[list, list]] = {}
520
+ for (n, row), p in zip(rows, probs):
521
+ chain = self.chains[n]
522
+ if isinstance(chain, ListChain):
523
+ taken = lists.setdefault(n, ([], []))
524
+ taken[0].append(row)
525
+ taken[1].append([float(x) for x in p])
526
+ elif row["type"] == "truth":
527
+ chain.present = float(p[0])
528
+ else:
529
+ chain.readings[row["axis"]] = read(p)
530
+ for n, (rows_of, probs_of) in lists.items():
531
+ self.chains[n].take(rows_of, probs_of)
532
+ self.passes_run += 1
533
+
534
+ def answers(self, sizes: dict[str, tuple[int, int]]) -> dict[str, dict]:
535
+ """Every question's answer by id; `sizes` holds each picture's width and height as sent."""
536
+ return {chain.spec.qid: chain.answer(*sizes[chain.spec.image]) for chain in self.chains}
runtime/src/decisionnode_edge/request.py CHANGED
@@ -14,20 +14,26 @@ What a request becomes (the same rules as the hosted API):
14
  number min, max and step (default 0, 255, 1): the answer is a value of that grid. Up to 256 values
15
  are one internal choice over the values; up to 4,096 are answered as pages of 256 (one choice
16
  over the pages, one per page) and composed. A grid of one value is answered without the model.
 
 
 
17
  images [{"id", "data" (base64) and "media_type"}] or, on Edge, [{"id", "path"}] for a local file.
18
  Every question sees every picture, in request order.
19
 
20
- Edge has no videos, boosters, uploads, image URLs, points or boxes: a request that names them is refused.
21
  """
22
 
23
  from __future__ import annotations
24
 
 
25
  import json
26
  import math
27
  from dataclasses import dataclass, field
28
  from decimal import Decimal, localcontext
29
  from typing import Any
30
 
 
 
31
  MAX_OPTIONS = 255
32
  MAX_LEVELS = 10
33
  MAX_IMAGES = 16
@@ -38,16 +44,21 @@ MAX_GRID_VALUES = 4096
38
  MAX_DECIMALS = 6
39
  NUMBER_DEFAULTS = {"min": 0, "max": 255, "step": 1}
40
  NOT_ON_EDGE = ("videos", "boosters")
 
41
 
42
 
43
  class RequestError(ValueError):
44
  """A request the model cannot answer. `status` is the HTTP status the hosted API gives the same fault, `error_type`
45
- the kind of fault its error body names (the local server's, server.py)."""
 
 
46
 
47
- def __init__(self, message: str, status: int = 400, error_type: str = "api_usage_error"):
48
  super().__init__(message)
49
  self.status = status
50
  self.error_type = error_type
 
 
51
 
52
 
53
  def text_of(value: Any) -> str:
@@ -186,6 +197,7 @@ class Question:
186
  levels: list | None = None # score: the levels as sent, for `legend`
187
  keys: list[str] | None = None # choice: the options in request order
188
  grid: Grid | None = None # number
 
189
 
190
 
191
  @dataclass
@@ -202,16 +214,45 @@ def parse(body: Any) -> Request:
202
  for name in NOT_ON_EDGE:
203
  if body.get(name):
204
  raise RequestError(f"`{name}` is not available on DecisionNode Edge")
 
205
  state = body.get("state")
206
  _content(state, "state")
207
  questions = body.get("questions")
208
  if not isinstance(questions, dict) or not questions:
209
  raise RequestError("questions must be an object with at least one question", 422)
210
  state_text = text_of(state)
211
- out = [_question(qid, q) for qid, q in questions.items()]
212
  return Request(state=state_text, questions=out, images=_images(body.get("images")))
213
 
214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  def _question(qid: str, q: Any) -> Question:
216
  if not isinstance(q, dict) or "type" not in q:
217
  raise RequestError(f"question {qid!r} must be an object with a type", 422)
 
14
  number min, max and step (default 0, 255, 1): the answer is a value of that grid. Up to 256 values
15
  are one internal choice over the values; up to 4,096 are answered as pages of 256 (one choice
16
  over the pages, one per page) and composed. A grid of one value is answered without the model.
17
+ point, box instructions (required), image (the id of a picture; optional with one picture), units ("pixels"
18
+ or "normalized"), max (2 to 16: a list of places): answered in a few readings (places.py). Their
19
+ faults come first, every one in one 422 (RequestError.errors).
20
  images [{"id", "data" (base64) and "media_type"}] or, on Edge, [{"id", "path"}] for a local file.
21
  Every question sees every picture, in request order.
22
 
23
+ Edge has no videos, boosters, uploads or image URLs: a request that names them is refused.
24
  """
25
 
26
  from __future__ import annotations
27
 
28
+ import itertools
29
  import json
30
  import math
31
  from dataclasses import dataclass, field
32
  from decimal import Decimal, localcontext
33
  from typing import Any
34
 
35
+ from . import places
36
+
37
  MAX_OPTIONS = 255
38
  MAX_LEVELS = 10
39
  MAX_IMAGES = 16
 
44
  MAX_DECIMALS = 6
45
  NUMBER_DEFAULTS = {"min": 0, "max": 255, "step": 1}
46
  NOT_ON_EDGE = ("videos", "boosters")
47
+ MAX_ERRORS = 1000 # the entries a 422 lists at most
48
 
49
 
50
  class RequestError(ValueError):
51
  """A request the model cannot answer. `status` is the HTTP status the hosted API gives the same fault, `error_type`
52
+ the kind of fault its error body names (the local server's, server.py), `errors` a 422's field errors when it lists
53
+ them ({"type", "loc", "msg", "input"}, as the hosted API's body), `detail` the error body's detail when it is not the
54
+ server's own shape (the hosted API's plain-text details on /v1/systemone)."""
55
 
56
+ def __init__(self, message: str, status: int = 400, error_type: str = "api_usage_error", errors: list[dict] | None = None, detail: Any = None):
57
  super().__init__(message)
58
  self.status = status
59
  self.error_type = error_type
60
+ self.errors = errors
61
+ self.detail = detail
62
 
63
 
64
  def text_of(value: Any) -> str:
 
197
  levels: list | None = None # score: the levels as sent, for `legend`
198
  keys: list[str] | None = None # choice: the options in request order
199
  grid: Grid | None = None # number
200
+ place: places.PlaceSpec | None = None # point and box (answered by places.Plan, not by rows of its own)
201
 
202
 
203
  @dataclass
 
214
  for name in NOT_ON_EDGE:
215
  if body.get(name):
216
  raise RequestError(f"`{name}` is not available on DecisionNode Edge")
217
+ specs = _place_specs(body)
218
  state = body.get("state")
219
  _content(state, "state")
220
  questions = body.get("questions")
221
  if not isinstance(questions, dict) or not questions:
222
  raise RequestError("questions must be an object with at least one question", 422)
223
  state_text = text_of(state)
224
+ out = [Question(qid, specs[qid].shape, [], place=specs[qid]) if qid in specs else _question(qid, q) for qid, q in questions.items()]
225
  return Request(state=state_text, questions=out, images=_images(body.get("images")))
226
 
227
 
228
+ def _place_specs(body: dict) -> dict[str, places.PlaceSpec]:
229
+ """The point and box questions by id, read before the rest of the request: their 422 (every faulty field), then
230
+ the 400 of one without instructions."""
231
+ questions = body.get("questions")
232
+ if not isinstance(questions, dict):
233
+ return {}
234
+ found = [(qid, q) for qid, q in questions.items() if places.is_place_question(q)]
235
+ if not found:
236
+ return {}
237
+ images = body.get("images")
238
+ image_ids = [image.get("id") for image in images if isinstance(image, dict) and isinstance(image.get("id"), str)] if isinstance(images, list) else []
239
+ # pictures the image check refuses (not a list, an id that is not a string, an id used twice) are its fault to name
240
+ faulty = images is not None and (not isinstance(images, list) or len(image_ids) != len(images) or len(set(image_ids)) != len(image_ids))
241
+ entries = (entry for qid, q in found for entry in places.question_errors(q, ("body", "questions", qid), None if faulty else image_ids))
242
+ errors = list(itertools.islice(entries, MAX_ERRORS))
243
+ if errors:
244
+ raise RequestError(places.reason(errors), 422, errors=errors)
245
+ missing = places.missing_instructions(found)
246
+ if missing:
247
+ raise RequestError(missing)
248
+ out = {}
249
+ for qid, q in found:
250
+ image = q.get("image") if q.get("image") is not None else (image_ids[0] if image_ids else "")
251
+ number = image_ids.index(image) + 1 if image in image_ids else 0
252
+ out[qid] = places.PlaceSpec(str(qid), q["type"], text_of(q.get("instructions")), image, number, q.get("units") or "pixels", q.get("max"))
253
+ return out
254
+
255
+
256
  def _question(qid: str, q: Any) -> Question:
257
  if not isinstance(q, dict) or "type" not in q:
258
  raise RequestError(f"question {qid!r} must be an object with a type", 422)
runtime/src/decisionnode_edge/server.py CHANGED
@@ -3,20 +3,28 @@
3
  python -m decisionnode_edge serve path/to/model [--host 127.0.0.1] [--port 8000] [--api-key FILE] [--backend ...]
4
 
5
  POST /v1/systemone the /v1/systemone format used by public decision-model leaderboards:
6
- {"model", "state", "questions", "assets"}; noul, choice and score questions; a noul is
7
- answered {"type": "noul", "noul": p}, a choice and a score as on /v1/decide
 
 
8
  POST /v1/decide the DecisionNode API's call: the runtime's own request and answers (EdgeModel.decide)
9
  GET /v1/models {"models": [{"name", "description", "release_date"}]}
10
  GET /healthz {"status": "ok", "model", "backend"} (no key needed)
11
 
12
  Pictures: `images` as the runtime takes them, base64 `data` only (a local `path` is refused over HTTP: a caller must
13
- never make the server read its files), and on /v1/systemone the `assets` map of that format, {"<ref>": {"type": "image",
14
- "format": "png" | "jpg" | "jpeg", "data_base64": ...}}, each asset a picture whose id is its reference (the state names
15
- it), in the map's order.
 
 
 
 
16
 
17
  Errors carry a `detail` body: {"detail": {"error_type", "message"}} (a 422 as a list of field errors). A
18
  request over the model's length limit says "maximum context length", a choice over 255 options "options per choice",
19
- the words leaderboard harnesses look for to count a request as beyond the model rather than as a failure.
 
 
20
 
21
  The model answers one request at a time (a lock); the server listens on the loopback address unless told otherwise.
22
  `model` is required on /v1/systemone, as on the hosted API, and any name is accepted: the answer names the model that
@@ -29,6 +37,7 @@ import argparse
29
  import hmac
30
  import json
31
  import logging
 
32
  import sys
33
  import threading
34
  import time
@@ -36,11 +45,11 @@ import traceback
36
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
37
  from typing import Any
38
 
 
39
  from .request import RequestError
40
 
41
  MAX_BODY_BYTES = 64 << 20 # 16 pictures as base64 with room to spare
42
- SYSTEMONE_TYPES = ("noul", "choice", "score")
43
- ASSET_MEDIA_TYPES = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg"}
44
  AUTH_MESSAGE = "Cannot authenticate with the server. Please check your API key and try again."
45
  ROUTES = {"/v1/systemone": "POST", "/v1/decide": "POST", "/v1/models": "GET", "/healthz": "GET"}
46
 
@@ -53,9 +62,9 @@ class HTTPError(Exception):
53
  self.status, self.error_type, self.message, self.headers = status, error_type, message, headers or {}
54
 
55
 
56
- def error_body(status: int, error_type: str, message: str) -> dict:
57
  if status == 422:
58
- return {"detail": [{"type": "value_error", "loc": ["body"], "msg": message, "input": None}]}
59
  return {"detail": {"error_type": error_type, "message": message}}
60
 
61
 
@@ -70,34 +79,33 @@ def _images_over_http(images: Any) -> list:
70
  return list(images)
71
 
72
 
73
- def _asset_images(assets: Any) -> list[dict]:
74
- """The /v1/systemone `assets` map as the runtime's images: the reference is the picture's id."""
75
- if assets is None:
76
- return []
77
- if not isinstance(assets, dict):
78
- raise RequestError("assets must be an object of references", 422)
79
- out = []
80
- for ref, asset in assets.items():
81
- if not isinstance(asset, dict) or asset.get("type", "image") not in ("image", None):
82
- raise RequestError(f"Asset {ref!r}: only image assets are taken.")
83
- media_type = ASSET_MEDIA_TYPES.get(str(asset.get("format", "")).lower())
84
- if media_type is None:
85
- raise RequestError(f"Asset {ref!r}: format must be one of png, jpg, jpeg.")
86
- if not isinstance(asset.get("data_base64"), str) or not asset["data_base64"]:
87
- raise RequestError(f"Asset {ref!r} needs data_base64.", 422)
88
- out.append({"id": ref, "data": asset["data_base64"], "media_type": media_type})
89
- return out
90
 
91
 
92
  class Service:
93
- """The routes, without the socket: handle(method, path, headers, body) gives (status, JSON body, extra headers)."""
 
94
 
95
- def __init__(self, model: Any, api_key: str | None = None, description: str | None = None, release_date: str | None = None):
96
  self.model = model
97
  self.api_key = api_key
98
  self.name = model.config["name"]
99
  self.description = description or model.config.get("description") or f"{self.name}: typed, calibrated decisions, run locally"
100
  self.release_date = release_date or model.config.get("release_date")
 
101
  self.lock = threading.Lock()
102
 
103
  def handle(self, method: str, path: str, headers: dict[str, str], body: bytes) -> tuple[int, dict, dict]:
@@ -106,7 +114,11 @@ class Service:
106
  except HTTPError as error:
107
  return error.status, error_body(error.status, error.error_type, error.message), error.headers
108
  except RequestError as error:
109
- return error.status, error_body(error.status, error.error_type, str(error)), {}
 
 
 
 
110
  except Exception: # a fault of the model or the backend, never the caller's
111
  log.error("the model could not answer:\n%s", traceback.format_exc())
112
  return 500, error_body(500, "internal_error", "The model could not answer this request."), {}
@@ -149,20 +161,25 @@ class Service:
149
  if isinstance(questions, dict):
150
  for qid, question in questions.items():
151
  kind = question.get("type") if isinstance(question, dict) else None
152
- if kind == "number":
153
- raise RequestError(f"question {qid!r}: the number type is not available on /v1/systemone; send it to /v1/decide")
154
  if isinstance(question, dict) and "type" in question and kind not in SYSTEMONE_TYPES:
155
  raise RequestError(f"question {qid!r}: unknown question type {kind!r}")
156
- images = _images_over_http(request.get("images")) + _asset_images(request.get("assets"))
157
- body = {"state": request.get("state"), "questions": questions}
158
- if images:
159
- body["images"] = images
 
160
  with self.lock:
161
- response = self.model.decide(body)
162
  answers = {}
163
  for qid, answer in response["answers"].items(): # a noul is answered under its own name, as the format has it
164
- answers[qid] = {"type": "noul", "noul": answer["truth"]} if answer["type"] == "truth" else answer
165
- return {"model": response["model"], "answers": answers, "usage": response["usage"]}
 
 
 
 
166
 
167
 
168
  class Handler(BaseHTTPRequestHandler):
@@ -235,6 +252,8 @@ def main(argv: list[str] | None = None) -> int:
235
  parser.add_argument("--artifacts", default=None, help="the directory python -m decisionnode_edge.build wrote")
236
  parser.add_argument("--description", default=None, help="the description GET /v1/models gives")
237
  parser.add_argument("--release-date", default=None, help="the release date GET /v1/models gives (YYYY-MM-DD)")
 
 
238
  args = parser.parse_args(argv)
239
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
240
  api_key = None
@@ -244,9 +263,12 @@ def main(argv: list[str] | None = None) -> int:
244
  if not api_key:
245
  raise SystemExit(f"{args.api_key} is empty")
246
  model = EdgeModel.load(args.model, device=args.device, dtype=args.dtype, backend=args.backend, artifacts=args.artifacts)
 
247
  model.decide({"state": "The color is red.", "questions": {"warmup": {"type": "choice", "instructions": "Which color is named?", "criteria": {"red": "red", "blue": "blue"}}}})
248
- server = make_server(model, args.host, args.port, api_key, description=args.description, release_date=args.release_date)
249
  log.info("serving %s (%s backend) on http://%s:%d%s", model.config["name"], model.backend, args.host, server.server_address[1], ", key required" if api_key else "")
 
 
250
  try:
251
  server.serve_forever()
252
  except KeyboardInterrupt:
 
3
  python -m decisionnode_edge serve path/to/model [--host 127.0.0.1] [--port 8000] [--api-key FILE] [--backend ...]
4
 
5
  POST /v1/systemone the /v1/systemone format used by public decision-model leaderboards:
6
+ {"model", "state", "questions", "assets"}; noul, choice, score and box questions; a noul is
7
+ answered {"type": "noul", "noul": p}, a choice and a score as on /v1/decide but with lists
8
+ that sum to exactly 1.00, a box as {"type": "box", "box": {"top", "bottom", "left", "right"},
9
+ "confidence"} (systemone.py)
10
  POST /v1/decide the DecisionNode API's call: the runtime's own request and answers (EdgeModel.decide)
11
  GET /v1/models {"models": [{"name", "description", "release_date"}]}
12
  GET /healthz {"status": "ok", "model", "backend"} (no key needed)
13
 
14
  Pictures: `images` as the runtime takes them, base64 `data` only (a local `path` is refused over HTTP: a caller must
15
+ never make the server read its files), and on /v1/systemone the format's own: the `assets` map, {"<ref>": {"type":
16
+ "image", "format": "png" | "jpg" | "jpeg", "data_base64": ...}}, whose references in the text become `Picture <n>`,
17
+ `image_url` content parts and `images` as data URLs (systemone.translate).
18
+
19
+ Point and box questions are taken on /v1/decide. On /v1/systemone a box is taken (in the format's shape, its
20
+ `confidence` the model folder's box calibration where there is one, box_calibration.json, else `present`), and point
21
+ and number questions are refused.
22
 
23
  Errors carry a `detail` body: {"detail": {"error_type", "message"}} (a 422 as a list of field errors). A
24
  request over the model's length limit says "maximum context length", a choice over 255 options "options per choice",
25
+ and on /v1/systemone a score over 10 levels "a score takes 2 to 10 levels": the words leaderboard harnesses look for to
26
+ count a request as beyond the model rather than as a failure. On /v1/systemone these three come in the hosted route's
27
+ bodies (systemone.mark_capacity).
28
 
29
  The model answers one request at a time (a lock); the server listens on the loopback address unless told otherwise.
30
  `model` is required on /v1/systemone, as on the hosted API, and any name is accepted: the answer names the model that
 
37
  import hmac
38
  import json
39
  import logging
40
+ import os
41
  import sys
42
  import threading
43
  import time
 
45
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
46
  from typing import Any
47
 
48
+ from . import request as requests, systemone
49
  from .request import RequestError
50
 
51
  MAX_BODY_BYTES = 64 << 20 # 16 pictures as base64 with room to spare
52
+ SYSTEMONE_TYPES = ("noul", "choice", "score", "box")
 
53
  AUTH_MESSAGE = "Cannot authenticate with the server. Please check your API key and try again."
54
  ROUTES = {"/v1/systemone": "POST", "/v1/decide": "POST", "/v1/models": "GET", "/healthz": "GET"}
55
 
 
62
  self.status, self.error_type, self.message, self.headers = status, error_type, message, headers or {}
63
 
64
 
65
+ def error_body(status: int, error_type: str, message: str, errors: list[dict] | None = None) -> dict:
66
  if status == 422:
67
+ return {"detail": errors or [{"type": "value_error", "loc": ["body"], "msg": message, "input": None}]}
68
  return {"detail": {"error_type": error_type, "message": message}}
69
 
70
 
 
79
  return list(images)
80
 
81
 
82
+ def box_calibration(model: Any, path: str | None = None, sha256: str | None = None) -> systemone.BoxCalibration | None:
83
+ """The box calibration /v1/systemone reads: `path` (or "none" for none), else the model folder's
84
+ box_calibration.json when there is one. `sha256` (else `box_calibration_sha256` in decisionnode_edge.json), when
85
+ set, must be the file's. A file that does not check is a BoxCalibrationError: the server does not start on it."""
86
+ if path == "none":
87
+ return None
88
+ sha256 = sha256 if sha256 is not None else model.config.get("box_calibration_sha256") or ""
89
+ if path is None:
90
+ path = os.path.join(model.path, systemone.BOX_CALIBRATION_FILE)
91
+ if not os.path.isfile(path):
92
+ if sha256:
93
+ raise systemone.BoxCalibrationError(f"{path}: the model names a box calibration that is not there")
94
+ return None
95
+ return systemone.BoxCalibration.load(path, sha256)
 
 
 
96
 
97
 
98
  class Service:
99
+ """The routes, without the socket: handle(method, path, headers, body) gives (status, JSON body, extra headers).
100
+ `calibration`: the box calibration of /v1/systemone (default: box_calibration(model))."""
101
 
102
+ def __init__(self, model: Any, api_key: str | None = None, description: str | None = None, release_date: str | None = None, calibration: Any = "model"):
103
  self.model = model
104
  self.api_key = api_key
105
  self.name = model.config["name"]
106
  self.description = description or model.config.get("description") or f"{self.name}: typed, calibrated decisions, run locally"
107
  self.release_date = release_date or model.config.get("release_date")
108
+ self.calibration = box_calibration(model) if calibration == "model" else calibration
109
  self.lock = threading.Lock()
110
 
111
  def handle(self, method: str, path: str, headers: dict[str, str], body: bytes) -> tuple[int, dict, dict]:
 
114
  except HTTPError as error:
115
  return error.status, error_body(error.status, error.error_type, error.message), error.headers
116
  except RequestError as error:
117
+ if path.split("?", 1)[0] == "/v1/systemone":
118
+ error = systemone.mark_capacity(error)
119
+ if error.detail is not None:
120
+ return error.status, {"detail": error.detail}, {}
121
+ return error.status, error_body(error.status, error.error_type, str(error), error.errors), {}
122
  except Exception: # a fault of the model or the backend, never the caller's
123
  log.error("the model could not answer:\n%s", traceback.format_exc())
124
  return 500, error_body(500, "internal_error", "The model could not answer this request."), {}
 
161
  if isinstance(questions, dict):
162
  for qid, question in questions.items():
163
  kind = question.get("type") if isinstance(question, dict) else None
164
+ if kind in ("number", "point"):
165
+ raise RequestError(f"question {qid!r}: the {kind} type is not available on /v1/systemone; send it to /v1/decide")
166
  if isinstance(question, dict) and "type" in question and kind not in SYSTEMONE_TYPES:
167
  raise RequestError(f"question {qid!r}: unknown question type {kind!r}")
168
+ _images_over_http(request.get("images"))
169
+ body = {name: request[name] for name in ("state", "questions", "images", "assets") if name in request}
170
+ body, done = systemone.translate(body, max_images=requests.MAX_IMAGES)
171
+ parsed = requests.parse(body)
172
+ systemone.normalised_boxes(q.place for q in parsed.questions if q.place is not None) # the format's edges are fractions
173
  with self.lock:
174
+ response = self.model._decide(parsed, exact_sum=True)
175
  answers = {}
176
  for qid, answer in response["answers"].items(): # a noul is answered under its own name, as the format has it
177
+ if answer["type"] == "truth":
178
+ answer = {"type": "noul", "noul": answer["truth"]}
179
+ elif answer["type"] == "score" and qid in done.legends and len(done.legends[qid]) == len(answer["legend"]):
180
+ answer = {**answer, "legend": {str(i): level for i, level in enumerate(done.legends[qid])}} # the levels as sent
181
+ answers[qid] = answer
182
+ return systemone.box_answers({"model": response["model"], "answers": answers, "usage": response["usage"]}, self.calibration)
183
 
184
 
185
  class Handler(BaseHTTPRequestHandler):
 
252
  parser.add_argument("--artifacts", default=None, help="the directory python -m decisionnode_edge.build wrote")
253
  parser.add_argument("--description", default=None, help="the description GET /v1/models gives")
254
  parser.add_argument("--release-date", default=None, help="the release date GET /v1/models gives (YYYY-MM-DD)")
255
+ parser.add_argument("--box-calibration", default=None, help="the box calibration of /v1/systemone (default: the model folder's box_calibration.json when there is one; none for none)")
256
+ parser.add_argument("--box-calibration-sha256", default=None, help="the sha256 the box calibration file must have")
257
  args = parser.parse_args(argv)
258
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
259
  api_key = None
 
263
  if not api_key:
264
  raise SystemExit(f"{args.api_key} is empty")
265
  model = EdgeModel.load(args.model, device=args.device, dtype=args.dtype, backend=args.backend, artifacts=args.artifacts)
266
+ calibration = box_calibration(model, args.box_calibration, args.box_calibration_sha256)
267
  model.decide({"state": "The color is red.", "questions": {"warmup": {"type": "choice", "instructions": "Which color is named?", "criteria": {"red": "red", "blue": "blue"}}}})
268
+ server = make_server(model, args.host, args.port, api_key, description=args.description, release_date=args.release_date, calibration=calibration)
269
  log.info("serving %s (%s backend) on http://%s:%d%s", model.config["name"], model.backend, args.host, server.server_address[1], ", key required" if api_key else "")
270
+ if calibration is not None:
271
+ log.info("/v1/systemone box confidence: the box calibration %s (sha256 %s)", calibration.model or "of this model", calibration.sha256)
272
  try:
273
  server.serve_forever()
274
  except KeyboardInterrupt:
runtime/src/decisionnode_edge/systemone.py ADDED
@@ -0,0 +1,597 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """POST /v1/systemone: the request and answer format public decision-model benchmarks use, read onto the runtime's own.
2
+
3
+ translate(body) the format's pictures onto the runtime's, before the parse:
4
+ - `assets`: {reference: {"type": "image", "format": "png" | "jpg" | "jpeg", "data_base64"}}.
5
+ Every place a reference appears (as a whole string value or inside a longer string, in the
6
+ state, a question's instructions or its criteria; never in an object key) is the picture
7
+ "in its place". The model reads every picture of a request before the state, labelled
8
+ `Picture <n> (<id>):`, so each reference is replaced in the text by `Picture <n>` and the
9
+ asset becomes picture n. A picture is read once however many times it is referenced, and
10
+ every question sees every picture.
11
+ - chat content parts, `{"type": "image_url", "image_url": {"url": "data:..."}}` (or
12
+ `"image_url": "data:..."`), anywhere in the state, the instructions or the criteria: the
13
+ same, the part replaced by `Picture <n>`; an array made only of such parts and
14
+ `{"type": "text", "text"}` parts is read as its text, the parts joined by a line break.
15
+ - `images` as a list of data URLs.
16
+ - `box` questions: in the picture the instructions name (`image_for_box`) when the request
17
+ holds several of the format's pictures; answered in fractions (`normalised_boxes`, after
18
+ the checks). A box with `max` keeps the runtime's list answer.
19
+ The legend of a score whose levels held a reference echoes the levels as sent.
20
+ box_answer(answer) a one-box answer in the format's shape: {"type": "box", "box": {"top", "bottom", "left",
21
+ "right"}, "confidence"}, the edges as fractions of the picture's height and width, and
22
+ `confidence` the probability that the box is right where the model folder has a box
23
+ calibration (BoxCalibration), else `present`.
24
+ mark_capacity(...) the words benchmark harnesses look for in an error ("maximum context length", "options per
25
+ choice", "a score takes 2 to 10 levels"), so that a limit of the model is recorded as one.
26
+
27
+ Choice and score probabilities on this route are normalised first and rounded to lists that sum to exactly 1.00
28
+ (answers.round_probabilities with exact_sum). Headers the format defines for pictures and long inputs are accepted and
29
+ never required. A multipart body (`part` assets) is not taken: an asset with `part` is a 400 that says to send
30
+ `data_base64`.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import base64
36
+ import binascii
37
+ import hashlib
38
+ import json
39
+ import math
40
+ import re
41
+ from dataclasses import dataclass, field
42
+ from typing import Any, Iterable
43
+
44
+ from .answers import round2
45
+ from .request import IMAGE_MEDIA_TYPES, MAX_LEVELS, MAX_OPTIONS, RequestError
46
+
47
+ ASSET_FORMATS = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg"} # the format's Asset.format
48
+ MAX_REFERENCE_BYTES = 1024 # one reference, UTF-8
49
+ MAX_REFERENCES_BYTES = 65536 # every reference of a request together
50
+ MAX_NESTING = 128 # a body nested deeper is left as it is (the parse refuses it)
51
+ PICTURE = "Picture {n}" # what the text reads in a reference's place: the model's own label of picture n
52
+ BOX_CALIBRATION_FILE = "box_calibration.json" # in the model folder, optional
53
+ BOX_CALIBRATION_FORMAT = "dn-box-calibration-1"
54
+ BOX_CALIBRATION_FEATURES = ["logit_present", "logit_confidence"]
55
+ _WHITESPACE = re.compile(r"[\t\n\x0b\x0c\r ]")
56
+ _BACKTICKED = re.compile(r"`([^`\n]{1,512})`")
57
+ _PATH_PART = re.compile(r"([^.\[\]]+)|\[(\d+)\]")
58
+
59
+
60
+ @dataclass
61
+ class Translation:
62
+ """What translate() did to a body, for the parse and the answer."""
63
+
64
+ images: list[dict] = field(default_factory=list) # the image objects it added, in picture order
65
+ legends: dict[str, list] = field(default_factory=dict) # score question id -> its levels as sent
66
+ boxes: list[str] = field(default_factory=list) # the box questions, answered in the format's shape
67
+ references: int = 0 # the references replaced in the text
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Errors
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ def field_error(kind: str, loc: Iterable[Any], msg: str, value: Any, **extra: Any) -> dict:
76
+ return {"type": kind, "loc": list(loc), "msg": msg, "input": value, **extra}
77
+
78
+
79
+ def _invalid(entry: dict) -> RequestError:
80
+ return RequestError(f"{'.'.join(str(part) for part in entry['loc'])}: {entry['msg']}", 422, errors=[entry])
81
+
82
+
83
+ def _asset_error(ref: str, problem: str) -> RequestError:
84
+ shown = ref if len(ref) <= 80 else ref[:77] + "..."
85
+ return RequestError(f"Asset {shown!r}: {problem}.")
86
+
87
+
88
+ def _too_large(message: str) -> RequestError:
89
+ return RequestError(message, 413, "request_too_large_error")
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Pictures
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ def _sniff(text: str) -> str | None:
98
+ """The type of a base64 picture from its first bytes (PNG, JPEG, WebP), or None."""
99
+ head = text[:64]
100
+ try:
101
+ blob = base64.b64decode(head[: len(head) - len(head) % 4])
102
+ except (binascii.Error, ValueError):
103
+ return None
104
+ if blob.startswith(b"\x89PNG\r\n\x1a\n"):
105
+ return "image/png"
106
+ if blob.startswith(b"\xff\xd8\xff"):
107
+ return "image/jpeg"
108
+ if blob[:4] == b"RIFF" and blob[8:12] == b"WEBP":
109
+ return "image/webp"
110
+ return None
111
+
112
+
113
+ def _clean_base64(text: str) -> str:
114
+ """Base64 text as the decoder reads it: a `data:` prefix and any ASCII whitespace dropped, missing padding added."""
115
+ if text[:5].lower() == "data:":
116
+ text = text.partition(",")[2]
117
+ if _WHITESPACE.search(text):
118
+ text = _WHITESPACE.sub("", text)
119
+ if len(text) % 4 in (2, 3) and "=" not in text:
120
+ text += "=" * (4 - len(text) % 4)
121
+ return text
122
+
123
+
124
+ def _asset_image(ref: str, asset: Any) -> dict:
125
+ """The runtime's image object (without its id) for one asset. Raises RequestError."""
126
+ if asset.get("type") is not None and asset.get("type") != "image":
127
+ raise _invalid(field_error("literal_error", ("body", "assets", ref, "type"), "Input should be 'image'", asset.get("type"), ctx={"expected": "'image'"}))
128
+ if asset.get("part") is not None:
129
+ raise _asset_error(ref, "multipart image parts are not taken by this server; send the image base64-encoded in data_base64 in a JSON body")
130
+ data = asset.get("data_base64")
131
+ if data is None:
132
+ raise _asset_error(ref, "data_base64 is required")
133
+ if not isinstance(data, str):
134
+ raise _invalid(field_error("string_type", ("body", "assets", ref, "data_base64"), "Input should be a valid string", data))
135
+ fmt = asset.get("format")
136
+ declared = None
137
+ if fmt is not None:
138
+ if not isinstance(fmt, str) or fmt not in ASSET_FORMATS:
139
+ raise _asset_error(ref, "format must be png, jpg or jpeg")
140
+ declared = ASSET_FORMATS[fmt]
141
+ data = _clean_base64(data)
142
+ if not data:
143
+ raise _asset_error(ref, "data_base64 is empty")
144
+ media = _sniff(data) or declared # the format is read from the bytes
145
+ if media is None:
146
+ raise _asset_error(ref, "the data is not a PNG or JPEG image")
147
+ return {"media_type": media, "data": data}
148
+
149
+
150
+ def _part_image(part: dict) -> dict:
151
+ """The runtime's image object (without its id) for an `image_url` content part. Raises RequestError."""
152
+ url = part.get("image_url")
153
+ if isinstance(url, dict):
154
+ url = url.get("url")
155
+ if not isinstance(url, str) or not url:
156
+ raise RequestError("An image_url part needs a url: a data:image/...;base64 URL.")
157
+ scheme = url[:8].lower()
158
+ if scheme.startswith("data:"):
159
+ head, comma, data = url.partition(",")
160
+ fields = head[5:].split(";")
161
+ if not comma or len(fields) < 2 or fields[-1].strip().lower() != "base64":
162
+ raise RequestError("An image_url data URL must be data:<media type>;base64,<data>.")
163
+ data = _clean_base64(data)
164
+ declared = fields[0].strip().lower()
165
+ declared = "image/jpeg" if declared == "image/jpg" else declared
166
+ media = _sniff(data) or (declared if declared in IMAGE_MEDIA_TYPES else None)
167
+ if media is None:
168
+ raise RequestError("An image_url part must hold a PNG, JPEG or WebP image.")
169
+ return {"media_type": media, "data": data}
170
+ if scheme == "https://":
171
+ raise RequestError("Image URLs are not fetched on this deployment: send the image as a base64 data URL.")
172
+ raise RequestError("An image_url part must be a base64 data URL.")
173
+
174
+
175
+ def _is_image_part(value: Any) -> bool:
176
+ return isinstance(value, dict) and value.get("type") == "image_url" and "image_url" in value
177
+
178
+
179
+ def _is_text_part(value: Any) -> bool:
180
+ return isinstance(value, dict) and value.get("type") == "text" and isinstance(value.get("text"), str) and set(value) <= {"type", "text"}
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # The walk
185
+ # ---------------------------------------------------------------------------
186
+
187
+
188
+ class _Walk:
189
+ """Replaces references and image parts in the free-form parts of a request, numbering the pictures as it meets them."""
190
+
191
+ def __init__(self, refs: Iterable[str], first: int):
192
+ self.refs = list(refs)
193
+ self.first = first # the picture number of the first picture this walk adds, less one (the body's own images)
194
+ self.order: list[tuple[str, Any]] = [] # ("ref", reference) or ("part", image object), in picture order
195
+ self.by_ref: dict[str, int] = {}
196
+ self.by_url: dict[str, int] = {}
197
+ self.replaced = 0
198
+ ordered = sorted(self.refs, key=len, reverse=True) # at one position the longest reference wins
199
+ self.pattern = re.compile("|".join(re.escape(ref) for ref in ordered)) if ordered else None
200
+ self.whole = set(self.refs)
201
+
202
+ def _number_of_ref(self, ref: str) -> int:
203
+ if ref not in self.by_ref:
204
+ self.order.append(("ref", ref))
205
+ self.by_ref[ref] = self.first + len(self.order)
206
+ return self.by_ref[ref]
207
+
208
+ def _number_of_part(self, part: dict) -> int:
209
+ image = _part_image(part)
210
+ key = image.get("data") or ""
211
+ if key not in self.by_url:
212
+ self.order.append(("part", image))
213
+ self.by_url[key] = self.first + len(self.order)
214
+ return self.by_url[key]
215
+
216
+ def text(self, value: str) -> str:
217
+ if self.pattern is None:
218
+ return value
219
+ if value in self.whole: # a whole value first
220
+ self.replaced += 1
221
+ return PICTURE.format(n=self._number_of_ref(value))
222
+ if not any(ref in value for ref in self.refs):
223
+ return value
224
+
225
+ def one(match: "re.Match[str]") -> str:
226
+ self.replaced += 1
227
+ return PICTURE.format(n=self._number_of_ref(match.group(0)))
228
+
229
+ return self.pattern.sub(one, value)
230
+
231
+ def value(self, value: Any) -> Any:
232
+ """`value` with every reference and image part replaced (a new object where anything changed)."""
233
+ if isinstance(value, str):
234
+ return self.text(value)
235
+ if _is_image_part(value):
236
+ self.replaced += 1
237
+ return PICTURE.format(n=self._number_of_part(value))
238
+ if isinstance(value, list):
239
+ if value and any(_is_image_part(item) for item in value) and all(_is_image_part(item) or _is_text_part(item) for item in value):
240
+ # a content-part array: its text, the pictures in their places
241
+ return "\n".join(self.value(item) if _is_image_part(item) else self.text(item["text"]) for item in value)
242
+ out = [self.value(item) for item in value]
243
+ return out if any(a is not b for a, b in zip(out, value)) else value
244
+ if isinstance(value, dict):
245
+ out = {key: self.value(item) for key, item in value.items()} # keys stay text
246
+ return out if any(out[key] is not value[key] for key in value) else value
247
+ return value
248
+
249
+
250
+ def _question(walk: _Walk, question: Any) -> Any:
251
+ """A question with its instructions and criteria walked (its type and every other field as sent)."""
252
+ if not isinstance(question, dict):
253
+ return question
254
+ out = dict(question)
255
+ if "instructions" in out:
256
+ out["instructions"] = walk.value(out["instructions"])
257
+ criteria = out.get("criteria")
258
+ if isinstance(criteria, dict):
259
+ out["criteria"] = {key: walk.value(item) for key, item in criteria.items()} # an option's name is a key: never replaced
260
+ elif isinstance(criteria, list):
261
+ out["criteria"] = [walk.value(item) for item in criteria]
262
+ return out
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # Box questions
267
+ # ---------------------------------------------------------------------------
268
+
269
+
270
+ def _resolve(state: Any, path: str) -> Any:
271
+ """The value a backticked path names in the state (`photo`, `frames[2].frame`, `faces.happy`), or None."""
272
+ value = state
273
+ for name, index in _PATH_PART.findall(path.strip()):
274
+ if name:
275
+ if not isinstance(value, dict) or name not in value:
276
+ return None
277
+ value = value[name]
278
+ else:
279
+ if not isinstance(value, list) or int(index) >= len(value):
280
+ return None
281
+ value = value[int(index)]
282
+ return value
283
+
284
+
285
+ def _first_ref(value: Any, refs: set[str]) -> str | None:
286
+ """The first reference held whole by a string inside `value` (depth first, in order)."""
287
+ if isinstance(value, str):
288
+ return value if value in refs else None
289
+ items = value.values() if isinstance(value, dict) else value if isinstance(value, list) else ()
290
+ for item in items:
291
+ found = _first_ref(item, refs)
292
+ if found is not None:
293
+ return found
294
+ return None
295
+
296
+
297
+ def image_for_box(instructions: Any, state: Any, refs: set[str], numbers: dict[str, int], in_state: list[int], own: set[int], count: int) -> int:
298
+ """The picture number a box question looks in when the request holds several pictures (the path in backticks names
299
+ which one to search): the picture a backticked path of the instructions names in the state (a reference held there,
300
+ whole); else the first picture of the state; else the first picture the instructions do not hold themselves (a
301
+ picture in a box's instructions shows the target, it is not the picture to search); else picture 1."""
302
+ text = instructions if isinstance(instructions, str) else "" if instructions is None else str(instructions)
303
+ for path in _BACKTICKED.findall(text):
304
+ found = _first_ref(_resolve(state, path), refs)
305
+ if found is not None and found in numbers:
306
+ return numbers[found]
307
+ if in_state:
308
+ return in_state[0]
309
+ for n in range(1, count + 1):
310
+ if n not in own:
311
+ return n
312
+ return 1
313
+
314
+
315
+ def question_refs(question: dict, refs: Iterable[str]) -> set[str]:
316
+ """The references a question's own instructions and criteria hold (whole or inside a string)."""
317
+ found: set[str] = set()
318
+ wanted = list(refs)
319
+
320
+ def visit(value: Any) -> None:
321
+ if isinstance(value, str):
322
+ found.update(ref for ref in wanted if ref in value)
323
+ elif isinstance(value, dict):
324
+ for item in value.values():
325
+ visit(item)
326
+ elif isinstance(value, list):
327
+ for item in value:
328
+ visit(item)
329
+
330
+ visit(question.get("instructions"))
331
+ visit(question.get("criteria"))
332
+ return found
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # The request
337
+ # ---------------------------------------------------------------------------
338
+
339
+
340
+ def _within_nesting(value: Any, limit: int = MAX_NESTING) -> bool:
341
+ """At most `limit` containers inside each other (walked without recursion)."""
342
+ stack = [(value, 0)]
343
+ while stack:
344
+ item, depth = stack.pop()
345
+ if isinstance(item, (dict, list)):
346
+ if depth + 1 > limit:
347
+ return False
348
+ stack.extend((child, depth + 1) for child in (item.values() if isinstance(item, dict) else item))
349
+ return True
350
+
351
+
352
+ def _check_assets(assets: Any) -> dict:
353
+ if not isinstance(assets, dict):
354
+ raise _invalid(field_error("dict_type", ("body", "assets"), "Input should be a valid dictionary", assets))
355
+ if not assets:
356
+ raise _invalid(field_error("too_short", ("body", "assets"), "Dictionary should have at least 1 item after validation, not 0", assets, ctx={"field_type": "Dictionary", "min_length": 1, "actual_length": 0}))
357
+ total = 0
358
+ for ref, asset in assets.items():
359
+ if not isinstance(asset, dict):
360
+ raise _invalid(field_error("model_attributes_type", ("body", "assets", ref), "Input should be a valid dictionary or object to extract fields from", asset))
361
+ if not ref.strip():
362
+ raise RequestError("An asset reference cannot be empty or only whitespace.")
363
+ size = len(ref.encode("utf-8", "surrogatepass"))
364
+ if size > MAX_REFERENCE_BYTES:
365
+ raise _too_large(f"An asset reference is at most {MAX_REFERENCE_BYTES} bytes of UTF-8.")
366
+ total += size
367
+ if total > MAX_REFERENCES_BYTES:
368
+ raise _too_large(f"The asset references of a request are at most {MAX_REFERENCES_BYTES} bytes of UTF-8 together.")
369
+ return assets
370
+
371
+
372
+ def _may_hold_parts(value: Any) -> bool:
373
+ """Cheap: whether a value may hold an image_url part (any dict with that type somewhere), walked without recursion."""
374
+ stack = [value]
375
+ while stack:
376
+ item = stack.pop()
377
+ if isinstance(item, dict):
378
+ if item.get("type") == "image_url":
379
+ return True
380
+ stack.extend(child for child in item.values() if isinstance(child, (dict, list)))
381
+ elif isinstance(item, list):
382
+ stack.extend(child for child in item if isinstance(child, (dict, list)))
383
+ return False
384
+
385
+
386
+ def translate(body: Any, *, max_images: int) -> tuple[Any, Translation]:
387
+ """A /v1/systemone body with the format's pictures on the runtime's (module docstring), and what was done. A body
388
+ that is not an object with a question map is returned as it is (the parse refuses it). Raises RequestError for an
389
+ asset or an image part the runtime cannot take."""
390
+ done = Translation()
391
+ if not isinstance(body, dict) or not isinstance(body.get("questions"), dict):
392
+ return body, done
393
+ assets = body.get("assets")
394
+ has_assets = assets is not None
395
+ questions = body["questions"]
396
+ has_parts = _may_hold_parts(body.get("state")) or any(_may_hold_parts(q.get("instructions")) or _may_hold_parts(q.get("criteria")) for q in questions.values() if isinstance(q, dict))
397
+ boxes = [qid for qid, q in questions.items() if isinstance(q, dict) and q.get("type") == "box"]
398
+ url_images = isinstance(body.get("images"), list) and any(isinstance(image, str) for image in body["images"])
399
+ if not has_assets and not has_parts and not boxes and not url_images:
400
+ if "assets" in body: # `"assets": null` equals no assets
401
+ body = {name: value for name, value in body.items() if name != "assets"}
402
+ return body, done
403
+ if not _within_nesting(body):
404
+ return body, done # the parse refuses it as it refuses any body that deep
405
+ out = {name: value for name, value in body.items() if name != "assets"}
406
+ refs: list[str] = list(_check_assets(assets)) if has_assets else []
407
+ if len(refs) > max_images:
408
+ raise RequestError(f"Too many images: {len(refs)}, the limit is {max_images}.")
409
+ own_images = body.get("images") if isinstance(body.get("images"), list) else []
410
+ if url_images:
411
+ # `images` as a list of data URLs: each an image object, in the list's order
412
+ if len(own_images) > max_images:
413
+ raise RequestError(f"Too many images: {len(own_images)}, the limit is {max_images}.")
414
+ listed = []
415
+ for i, image in enumerate(own_images):
416
+ if isinstance(image, str):
417
+ image = {"id": "image" if len(own_images) == 1 else f"image_{i + 1}", **_part_image({"image_url": image})}
418
+ listed.append(image)
419
+ own_images = listed
420
+ out["images"] = listed
421
+ walk = _Walk(refs, len(own_images))
422
+ state = body.get("state")
423
+ out["state"] = walk.value(state)
424
+ in_state = sorted(set(walk.by_ref.values()) | set(walk.by_url.values()))
425
+ walked: dict[str, Any] = {}
426
+ for qid, question in questions.items():
427
+ walked[qid] = _question(walk, question)
428
+ if isinstance(question, dict) and question.get("type") == "score" and walked[qid].get("criteria") != question.get("criteria"):
429
+ done.legends[str(qid)] = question.get("criteria") # the legend echoes the levels as sent
430
+ unused = [ref for ref in refs if ref not in walk.by_ref]
431
+ if unused:
432
+ raise _asset_error(unused[0], "it is not referenced in the state, the instructions or the criteria")
433
+ added = len(walk.order)
434
+ total = len(own_images) + added
435
+ if total > max_images:
436
+ raise RequestError(f"Too many images: {total}, the limit is {max_images}.")
437
+ taken = {image.get("id") for image in own_images if isinstance(image, dict)}
438
+ images = []
439
+ for i, (kind, item) in enumerate(walk.order):
440
+ n = len(own_images) + i + 1
441
+ image_id = "image" if total == 1 else f"image_{n}"
442
+ while image_id in taken:
443
+ image_id += "_"
444
+ taken.add(image_id)
445
+ image = _asset_image(item, assets[item]) if kind == "ref" else dict(item)
446
+ images.append({"id": image_id, **image})
447
+ if images:
448
+ out["images"] = list(own_images) + images
449
+ done.images = images
450
+ done.references = walk.replaced
451
+ ids = [image.get("id") for image in out.get("images") or [] if isinstance(image, dict)]
452
+ numbers = dict(walk.by_ref)
453
+ for qid in boxes:
454
+ # the picture is chosen only for the format's pictures (assets, image parts, data URLs), which no `image` field
455
+ # can name; with image objects alone a box without `image` among several stays the 422 of the runtime's parse
456
+ if walked[qid].get("image") is None and len(ids) > 1 and (added or url_images):
457
+ question = dict(walked[qid])
458
+ own = {numbers[ref] for ref in question_refs(questions[qid], refs) if ref in numbers}
459
+ n = image_for_box(questions[qid].get("instructions"), state, set(refs), numbers, in_state, own, len(ids))
460
+ question["image"] = ids[n - 1]
461
+ walked[qid] = question
462
+ done.boxes.append(str(qid))
463
+ out["questions"] = walked
464
+ return out, done
465
+
466
+
467
+ def normalised_boxes(specs: Iterable[Any]) -> None:
468
+ """The box questions of a /v1/systemone request answered in fractions of the picture (the format's edges), set on the
469
+ parsed specs (places.PlaceSpec) after their checks. A box with `max` keeps the units the caller sent."""
470
+ for spec in specs:
471
+ if spec.shape == "box" and spec.max is None:
472
+ spec.units = "normalized"
473
+
474
+
475
+ # ---------------------------------------------------------------------------
476
+ # The answer
477
+ # ---------------------------------------------------------------------------
478
+
479
+
480
+ class BoxCalibrationError(ValueError):
481
+ """A box calibration file the runtime does not take (the server does not start on it)."""
482
+
483
+
484
+ @dataclass(frozen=True)
485
+ class BoxCalibration:
486
+ """The `confidence` of a box on /v1/systemone: the probability that the box is right (an intersection over union of
487
+ 0.5 or more with the true box), read from the one-box answer's own two numbers, `present` and `confidence`, as the
488
+ answer writes them (2 decimals). A logistic map, fitted per model and kept beside it as box_calibration.json:
489
+
490
+ {"format": "dn-box-calibration-1", "features": ["logit_present", "logit_confidence"], "clip": 0.005,
491
+ "intercept": a, "coef": [b, c], "model": ..., "fit": {...}}
492
+
493
+ p = sigmoid(a + b logit(present) + c logit(confidence)), each input clipped to [clip, 1 - clip] first, the result
494
+ rounded to 2 decimals. Monotone: no coefficient below 0, so neither number can lower the probability by rising."""
495
+
496
+ intercept: float
497
+ coef: tuple[float, float]
498
+ clip: float
499
+ sha256: str
500
+ model: str = ""
501
+
502
+ @classmethod
503
+ def load(cls, path: str, sha256: str = "") -> "BoxCalibration":
504
+ """Read and check a calibration file; `sha256`, when given, must be the file's."""
505
+ try:
506
+ with open(path, "rb") as f:
507
+ data = f.read()
508
+ except OSError as error:
509
+ raise BoxCalibrationError(f"box calibration {path}: {error}") from None
510
+ digest = hashlib.sha256(data).hexdigest()
511
+ if sha256 and sha256.lower() != digest:
512
+ raise BoxCalibrationError(f"box calibration {path}: sha256 {digest}, expected {sha256}")
513
+ try:
514
+ spec = json.loads(data)
515
+ except ValueError as error:
516
+ raise BoxCalibrationError(f"box calibration {path}: not JSON ({error})") from None
517
+ if not isinstance(spec, dict) or spec.get("format") != BOX_CALIBRATION_FORMAT:
518
+ raise BoxCalibrationError(f"box calibration {path}: the format is not {BOX_CALIBRATION_FORMAT}")
519
+ if spec.get("features") != BOX_CALIBRATION_FEATURES:
520
+ raise BoxCalibrationError(f"box calibration {path}: features must be {BOX_CALIBRATION_FEATURES}")
521
+ coef, intercept, clip = spec.get("coef"), spec.get("intercept"), spec.get("clip")
522
+ numbers = [intercept, clip] + (list(coef) if isinstance(coef, list) else [])
523
+ if not isinstance(coef, list) or len(coef) != 2 or any(isinstance(x, bool) or not isinstance(x, (int, float)) or x != x or abs(x) == float("inf") for x in numbers):
524
+ raise BoxCalibrationError(f"box calibration {path}: intercept, coef (two numbers) and clip must be finite numbers")
525
+ if any(c < 0 for c in coef):
526
+ raise BoxCalibrationError(f"box calibration {path}: a coefficient below 0 is not monotone")
527
+ if not 0 < clip < 0.5:
528
+ raise BoxCalibrationError(f"box calibration {path}: clip must be above 0 and below 0.5")
529
+ return cls(float(intercept), (float(coef[0]), float(coef[1])), float(clip), digest, str(spec.get("model") or ""))
530
+
531
+ def probability(self, present: float, confidence: float) -> float:
532
+ """The unrounded probability that the box is right."""
533
+
534
+ def logit(p: float) -> float:
535
+ p = min(1.0 - self.clip, max(self.clip, float(p)))
536
+ return math.log(p / (1.0 - p))
537
+
538
+ z = self.intercept + self.coef[0] * logit(present) + self.coef[1] * logit(confidence)
539
+ return 1.0 / (1.0 + math.exp(-z)) if z >= 0 else math.exp(z) / (1.0 + math.exp(z))
540
+
541
+ def __call__(self, present: float, confidence: float) -> float:
542
+ return round2(self.probability(present, confidence))
543
+
544
+
545
+ def box_answer(answer: dict, calibration: BoxCalibration | None = None) -> dict:
546
+ """A one-box answer (normalized units) in the format's shape. `confidence`: the calibrated probability that the box
547
+ is right where there is a calibration, else `present`."""
548
+ return {
549
+ "type": "box",
550
+ "box": {"top": answer["y1"], "bottom": answer["y2"], "left": answer["x1"], "right": answer["x2"]},
551
+ "confidence": calibration(answer["present"], answer["confidence"]) if calibration is not None else answer["present"],
552
+ }
553
+
554
+
555
+ def box_answers(body: dict, calibration: BoxCalibration | None = None) -> dict:
556
+ """The response body with every one-box answer in the format's shape (a list answer, `max`, stays the runtime's)."""
557
+ answers = body.get("answers")
558
+ if isinstance(answers, dict):
559
+ for qid, answer in answers.items():
560
+ if isinstance(answer, dict) and answer.get("type") == "box" and "x1" in answer:
561
+ answers[qid] = box_answer(answer, calibration)
562
+ return body
563
+
564
+
565
+ # ---------------------------------------------------------------------------
566
+ # Errors with the capacity words
567
+ # ---------------------------------------------------------------------------
568
+
569
+ CONTEXT_MARKER = "maximum context length"
570
+ OPTIONS_MARKER = "options per choice"
571
+ LEVELS_MARKER = "a score takes 2 to 10 levels"
572
+ TOO_MANY_LEVELS = f"Too many score levels. Must have at most {MAX_LEVELS} levels."
573
+
574
+
575
+ def mark_capacity(error: RequestError) -> RequestError:
576
+ """A refusal of /v1/systemone for a limit of the model, in the words and bodies benchmark harnesses read:
577
+
578
+ over the length limit {"detail": {"error_type": "max_tokens_exceeded", "message": "The request does not fit
579
+ the model's maximum context length (<the count and the limit>)."}}
580
+ more than 255 options {"detail": "Too many choices. Must have at most 255 choices (255 options per choice)."}
581
+ more than 10 levels {"detail": "Too many score levels. Must have at most 10 levels (a score takes 2 to 10
582
+ levels; one level is answered without the model)."}
583
+
584
+ Every other refusal is returned as it is."""
585
+ message = str(error)
586
+ if error.status != 400:
587
+ return error
588
+ if error.error_type == "max_tokens_exceeded":
589
+ text = f"The request does not fit the model's {CONTEXT_MARKER} ({message})."
590
+ return RequestError(text, 400, error.error_type, detail={"error_type": error.error_type, "message": text})
591
+ if message.startswith("Too many choices"):
592
+ text = f"Too many choices. Must have at most {MAX_OPTIONS} choices ({MAX_OPTIONS} {OPTIONS_MARKER})."
593
+ return RequestError(text, 400, error.error_type, detail=text)
594
+ if message == TOO_MANY_LEVELS:
595
+ text = f"Too many score levels. Must have at most {MAX_LEVELS} levels ({LEVELS_MARKER}; one level is answered without the model)."
596
+ return RequestError(text, 400, error.error_type, detail=text)
597
+ return error
runtime/src/decisionnode_edge/template.py CHANGED
@@ -4,16 +4,20 @@ One prompt per question. The state block comes first, then the question block, t
4
  word "Answer" and the <|answer|> token, whose position the answer is read from:
5
 
6
  <|state|>\\n{Picture n (id): <|vision_start|>pads<|vision_end|>\\n ...}{state}\\n
7
- <|question|>\\ntype: {choice|score|noul}\\n{instructions}\\n{option lines}Answer<|answer|>
8
 
9
  A choice option line is `<|opt_NNN|> key: description\\n` (`<|opt_NNN|> key\\n` when the
10
  description is empty), a score level line `<|opt_NNN|> index: level\\n`. A truth question
11
  has no option lines (the prompt calls it `noul`) and carries
12
  its criteria as `yes: ...\\n` and `no: ...\\n` lines. It is answered by <|yes|> and <|no|>.
13
 
14
- Each option gets one of 256 code tokens. The codes are drawn from a stream seeded by a
15
- SHA-256 of the question alone (never the state), so the same question always gets the
16
- same codes. The seed's first part is a fixed string from the model directory
 
 
 
 
17
  (`code_seed_hex` in decisionnode_edge.json): the model gives its published answers only
18
  with that string.
19
 
@@ -39,7 +43,7 @@ CODES = tuple(f"<|opt_{i:03d}|>" for i in range(N_CODES))
39
  ADDED_TOKENS = (STATE, QUESTION, ANSWER, YES, NO) + CODES # 261 tokens, in id order
40
  ANSWER_CUE = "Answer"
41
  # The type word each question type carries in the prompt.
42
- TYPE_WORDS = {"choice": "choice", "score": "score", "truth": "noul"}
43
 
44
  IMAGE_PAD = "<|image_pad|>"
45
  VISION_START = "<|vision_start|>"
@@ -115,6 +119,8 @@ def n_classes(question: dict) -> int:
115
  return len(question["keys"])
116
  if question["type"] == "score":
117
  return len(question["levels"])
 
 
118
  return 2
119
 
120
 
@@ -200,7 +206,10 @@ class Template:
200
  return [self.first_id + 5 + c for c in codes]
201
 
202
  def encode(self, question: dict, state: str, image_ids: Sequence[str] = (), image_tokens: dict[str, int] | None = None) -> Encoded:
203
- codes = assign_codes(self.seed, question) if question["type"] in ("choice", "score") else []
 
 
 
204
  segments = build_segments(question, state, image_ids, codes)
205
  table = self._tokenize([value for kind, value in segments if kind in (TEXT, STATE_TEXT)])
206
  ids: list[int] = []
 
4
  word "Answer" and the <|answer|> token, whose position the answer is read from:
5
 
6
  <|state|>\\n{Picture n (id): <|vision_start|>pads<|vision_end|>\\n ...}{state}\\n
7
+ <|question|>\\ntype: {choice|score|noul|coord}\\n{instructions}\\n{option lines}Answer<|answer|>
8
 
9
  A choice option line is `<|opt_NNN|> key: description\\n` (`<|opt_NNN|> key\\n` when the
10
  description is empty), a score level line `<|opt_NNN|> index: level\\n`. A truth question
11
  has no option lines (the prompt calls it `noul`) and carries
12
  its criteria as `yes: ...\\n` and `no: ...\\n` lines. It is answered by <|yes|> and <|no|>.
13
 
14
+ A coordinate row (one reading of a point or box question, places.py) is `type: coord`, its
15
+ text and no option lines. It is answered by the codes 0 to n - 1 in order (code k is step k
16
+ of a side of the picture, or a count of k), the same codes for every such row, never drawn.
17
+
18
+ Each option of a choice or a score gets one of 256 code tokens. The codes are drawn from a
19
+ stream seeded by a SHA-256 of the question alone (never the state), so the same question
20
+ always gets the same codes. The seed's first part is a fixed string from the model directory
21
  (`code_seed_hex` in decisionnode_edge.json): the model gives its published answers only
22
  with that string.
23
 
 
43
  ADDED_TOKENS = (STATE, QUESTION, ANSWER, YES, NO) + CODES # 261 tokens, in id order
44
  ANSWER_CUE = "Answer"
45
  # The type word each question type carries in the prompt.
46
+ TYPE_WORDS = {"choice": "choice", "score": "score", "truth": "noul", "coord": "coord"}
47
 
48
  IMAGE_PAD = "<|image_pad|>"
49
  VISION_START = "<|vision_start|>"
 
119
  return len(question["keys"])
120
  if question["type"] == "score":
121
  return len(question["levels"])
122
+ if question["type"] == "coord":
123
+ return int(question["classes"])
124
  return 2
125
 
126
 
 
206
  return [self.first_id + 5 + c for c in codes]
207
 
208
  def encode(self, question: dict, state: str, image_ids: Sequence[str] = (), image_tokens: dict[str, int] | None = None) -> Encoded:
209
+ if question["type"] in ("choice", "score"):
210
+ codes = assign_codes(self.seed, question)
211
+ else: # a coordinate row's codes are fixed, a truth question has none
212
+ codes = list(range(n_classes(question))) if question["type"] == "coord" else []
213
  segments = build_segments(question, state, image_ids, codes)
214
  table = self._tokenize([value for kind, value in segments if kind in (TEXT, STATE_TEXT)])
215
  ids: list[int] = []