Image-Text-to-Text
Transformers
Safetensors
GGUF
qwen3_5
system-one
decision-making
classification
calibration
structured-output
on-device
multimodal
Instructions to use bynn-intelligence-com/decisionnode-edge with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use bynn-intelligence-com/decisionnode-edge with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("image-text-to-text", model="bynn-intelligence-com/decisionnode-edge")# pip install -U transformers accelerate # Load model directly from transformers import AutoProcessor, AutoModelForMultimodalLM processor = AutoProcessor.from_pretrained("bynn-intelligence-com/decisionnode-edge") model = AutoModelForMultimodalLM.from_pretrained("bynn-intelligence-com/decisionnode-edge", device_map="auto") - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- llama.cpp
How to use bynn-intelligence-com/decisionnode-edge with llama.cpp:
Install (macOS, Linux)
curl -LsSf https://llama.app/install.sh | sh # Start a local OpenAI-compatible server with a web UI: llama serve -hf bynn-intelligence-com/decisionnode-edge:Q8_0 # Run inference directly in the terminal: llama cli -hf bynn-intelligence-com/decisionnode-edge:Q8_0
Install from WinGet (Windows)
winget install llama.cpp # Start a local OpenAI-compatible server with a web UI: llama serve -hf bynn-intelligence-com/decisionnode-edge:Q8_0 # Run inference directly in the terminal: llama cli -hf bynn-intelligence-com/decisionnode-edge:Q8_0
Use pre-built binary
# Download pre-built binary from: # https://github.com/ggerganov/llama.cpp/releases # Start a local OpenAI-compatible server with a web UI: ./llama-server -hf bynn-intelligence-com/decisionnode-edge:Q8_0 # Run inference directly in the terminal: ./llama-cli -hf bynn-intelligence-com/decisionnode-edge:Q8_0
Build from source code
git clone https://github.com/ggerganov/llama.cpp.git cd llama.cpp cmake -B build cmake --build build -j --target llama-server llama-cli # Start a local OpenAI-compatible server with a web UI: ./build/bin/llama-server -hf bynn-intelligence-com/decisionnode-edge:Q8_0 # Run inference directly in the terminal: ./build/bin/llama-cli -hf bynn-intelligence-com/decisionnode-edge:Q8_0
Use Docker
docker model run hf.co/bynn-intelligence-com/decisionnode-edge:Q8_0
- Llama new
- LM Studio
- Jan
- vLLM
How to use bynn-intelligence-com/decisionnode-edge with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "bynn-intelligence-com/decisionnode-edge" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "bynn-intelligence-com/decisionnode-edge", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }'Use Docker
docker model run hf.co/bynn-intelligence-com/decisionnode-edge:Q8_0
- SGLang
How to use bynn-intelligence-com/decisionnode-edge with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "bynn-intelligence-com/decisionnode-edge" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "bynn-intelligence-com/decisionnode-edge", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "bynn-intelligence-com/decisionnode-edge" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "bynn-intelligence-com/decisionnode-edge", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }' - Ollama
How to use bynn-intelligence-com/decisionnode-edge with Ollama:
ollama run hf.co/bynn-intelligence-com/decisionnode-edge:Q8_0
- Unsloth Desktop
- Docker Model Runner
How to use bynn-intelligence-com/decisionnode-edge with Docker Model Runner:
docker model run hf.co/bynn-intelligence-com/decisionnode-edge:Q8_0
- Lemonade
How to use bynn-intelligence-com/decisionnode-edge with Lemonade:
Pull the model
# Download Lemonade from https://lemonade-server.ai/ lemonade pull bynn-intelligence-com/decisionnode-edge:Q8_0
Run and chat with the model
lemonade run user.decisionnode-edge-Q8_0
List all available models
lemonade list
- Atomic Chat
DecisionNode Edge 0.9.2: point and box questions, the /v1/systemone route as hosted, a box calibration
Browse files- MANIFEST.sha256 +13 -10
- README.md +33 -14
- box_calibration.json +16 -0
- decisionnode_edge.json +2 -1
- runtime/README.md +60 -8
- runtime/pyproject.toml +1 -1
- runtime/src/decisionnode_edge/__init__.py +1 -1
- runtime/src/decisionnode_edge/answers.py +58 -6
- runtime/src/decisionnode_edge/model.py +78 -20
- runtime/src/decisionnode_edge/places.py +536 -0
- runtime/src/decisionnode_edge/request.py +45 -4
- runtime/src/decisionnode_edge/server.py +62 -40
- runtime/src/decisionnode_edge/systemone.py +597 -0
- runtime/src/decisionnode_edge/template.py +15 -6
MANIFEST.sha256
CHANGED
|
@@ -2,30 +2,33 @@
|
|
| 2 |
50cbab8a892c5f2993b8c7351a99182507472def3b1374558308605d99b86b32 ./LICENSE-base
|
| 3 |
2014f13a6fe1d9178c84cde47bb087a7a74adbe38650f1823a944077081e86d0 ./LICENSING.md
|
| 4 |
e4dcac398e39a098e07f625ed239949296a2000d95dcc275cb0d8083ebb51dc9 ./NOTICE
|
| 5 |
-
|
| 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 |
-
|
| 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 |
-
|
| 18 |
e0a87595ad939efa5585b61844d3a2a70d29ba7752f68b8139f2195753a6bfde ./runtime/examples/request.json
|
| 19 |
-
|
| 20 |
-
|
| 21 |
245003271a41ecab8fe982194acf84f9c4fc0b686762583931a9444ad2aa6354 ./runtime/src/decisionnode_edge/__main__.py
|
| 22 |
-
|
| 23 |
c34f84c5da93d012d329762b6e099bc5aa7b7d998f790b88471f75399cd3eee8 ./runtime/src/decisionnode_edge/backends.py
|
| 24 |
4ace977690bb7ae16010a298c478f1a5056caaf724c0d696b5ddac6699225f6b ./runtime/src/decisionnode_edge/build.py
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
| 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
|
| 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.
|
| 26 |
|
| 27 |
## Highlights
|
| 28 |
|
| 29 |
-
- **Typed decisions instead of text.**
|
| 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
|
| 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.
|
| 114 |
|
| 115 |
## Model details
|
| 116 |
|
| 117 |
| | |
|
| 118 |
|---|---|
|
| 119 |
| Developer | Bynn Intelligence Inc. |
|
| 120 |
-
| Version | 0.9.
|
| 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`
|
| 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
|
| 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
|
| 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
|
| 241 |
-
| DecisionNode Edge
|
| 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,
|
| 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 `
|
| 100 |
-
| `POST /v1/decide` | the request and answers of this runtime ([below](#the-request)), `number` and `
|
| 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`,
|
| 105 |
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
|
|
|
|
|
|
|
| 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.
|
| 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.
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 14 |
-
"format": "png" | "jpg" | "jpeg", "data_base64": ...}},
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 74 |
-
"""The /v1/systemone `
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
if
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 153 |
-
raise RequestError(f"question {qid!r}: the
|
| 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 |
-
|
| 157 |
-
body = {
|
| 158 |
-
|
| 159 |
-
|
|
|
|
| 160 |
with self.lock:
|
| 161 |
-
response = self.model.
|
| 162 |
answers = {}
|
| 163 |
for qid, answer in response["answers"].items(): # a noul is answered under its own name, as the format has it
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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] = []
|