DecisionNode Edge runtime
The official runtime for the DecisionNode Edge models from Bynn Intelligence. Load a model, send a request in the DecisionNode API format, and receive typed, calibrated decisions. It runs on NVIDIA GPUs, Apple silicon and CPUs, entirely offline.
Models: DecisionNode Edge and DecisionNode Edge Mini on Hugging Face.
Install
pip install decisionnode-edge # torch, transformers, safetensors
pip install "decisionnode-edge[images]" # adds Pillow and torchvision, for pictures
The model folders carry a copy of this package in runtime/ for machines without network access: pip install ./runtime (or "./runtime[images]") from the model folder installs the same thing.
Optional, for speed on NVIDIA GPUs with the PyTorch backend: flash-linear-attention and causal-conv1d. transformers uses them when they are installed. Without them the model gives the same answers about twice as slowly on long inputs.
The fastest install for your device
The runtime reads the model with PyTorch unless a faster backend is installed. Install the one for your device:
| Device | Install | Backend |
|---|---|---|
| NVIDIA GPU (Linux) | pip install "decisionnode-edge[vllm]" |
vllm |
| x86 CPU | pip install "decisionnode-edge[openvino]", then build the OpenVINO files once (below) |
openvino |
| Apple silicon | pip install "decisionnode-edge[mlx]" |
mlx |
| Any device llama.cpp supports, including NVIDIA Jetson | llama.cpp's llama-server; the model folder ships the 8-bit GGUF file (below) |
llamacpp |
Every backend agrees with the PyTorch reference on at least 98% of top answers in our tests, and the remaining differences fall on near ties. A backend only computes the model's last hidden state; the runtime then applies the model's own answer table (readout.safetensors) and calibration (calibration.json) in float32, as it does on PyTorch.
Measured with one truth question per request, one request at a time, median time of the whole decide call:
| Device (as measured) | Backend | Edge, about 500 / 2,000 tokens | Edge Mini | Answers kept (Edge, Edge Mini) | PyTorch on the same device (Edge; Edge Mini) |
|---|---|---|---|---|---|
| NVIDIA RTX PRO 6000 Blackwell | vllm |
9.5 / 23 ms | 7.3 / 15 ms | 99.7%, 99.2% | 14 / 28 ms; 14 / 16 ms (with the optional kernels) |
| AMD Ryzen 7 9800X3D (8 cores) | openvino |
0.46 / 1.7 s | 0.24 / 0.90 s | 98.6%, 98.4% | bfloat16: 0.49 / 2.6 s; 0.28 / 1.4 s. float32, the default: 1.6 / 7.8 s; 0.66 / 4.0 s |
| Apple M5 Max | mlx |
35 / 127 ms | 17 / 65 ms | 99.5%, 99.3% | device="mps": 0.30 / 1.3 s; 0.28 / 1.2 s |
| Apple M5 Max | llamacpp (Metal) |
55 / 212 ms | 30 / 112 ms | 99.4%, 99.0% | |
| NVIDIA RTX PRO 6000 Blackwell | llamacpp (CUDA) |
14 / 44 ms | 10.5 / 30 ms | 99.2%, 98.4% | |
| AMD Ryzen 7 9800X3D (8 cores) | llamacpp (CPU) |
1.1 / 5.1 s | 0.58 / 2.7 s | 99.8%, 98.1% (412 questions up to 512 tokens) |
At about 8,000 tokens: vllm 87 ms (Edge) and 50 ms (Edge Mini), mlx 625 and 297 ms, llamacpp 218 and 160 ms on CUDA and 0.92 and 0.55 s on Metal. OpenVINO hands prompts of that length to PyTorch.
"Answers kept" is how often the backend's top answer equals the PyTorch reference (bfloat16 on an NVIDIA GPU) on a fixed set of 2,056 held-out questions; for OpenVINO, on the 1,936 of them up to 2,048 tokens, the longest it is built for.
The GPU is an NVIDIA RTX PRO 6000 Blackwell workstation card, the CPU an AMD Ryzen 7 9800X3D (8 cores, 16 threads), the Mac an Apple M5 Max. Versions: vLLM 0.32.0, OpenVINO 2026.4.1, MLX 0.32.4 with mlx-lm 0.32.2, llama.cpp at commit f174553, PyTorch 2.13 and 2.14, transformers 5.18 and 5.19. All backends ran in bfloat16 except llama.cpp, which ran the 8-bit (Q8_0) file.
Use
from decisionnode_edge import EdgeModel
model = EdgeModel.load("path/to/decisionnode-edge") # CUDA if present, else the CPU
response = model.decide({
"state": {"ticket": "I was charged twice and nobody has replied for 3 days."},
"questions": {
"route": {
"type": "choice",
"instructions": "Where should `ticket` go?",
"criteria": {"billing": "money", "bug": "something broken", "account": "login and access"},
},
"urgency": {
"type": "score",
"instructions": "How urgent is `ticket`?",
"criteria": ["routine", "today", "urgent", "critical"],
},
"escalate": {"type": "truth", "instructions": "Should a human take over now?"},
"days": {"type": "number", "instructions": "How many days has the customer waited?", "min": 0, "max": 30},
},
})
response["answers"]["route"] # {"type": "choice", "choice": ..., "confidence": ..., "probabilities": {...}}
response["answers"]["escalate"] # {"type": "truth", "truth": ...}
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.
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).
From the command line:
python -m decisionnode_edge path/to/decisionnode-edge request.json
cat request.json | python -m decisionnode_edge path/to/decisionnode-edge -
Serving over HTTP
python -m decisionnode_edge serve path/to/decisionnode-edge --host 127.0.0.1 --port 8000
python -m decisionnode_edge serve path/to/decisionnode-edge --api-key key.txt # callers send Authorization: Bearer <key>
A small server on Python's standard library, with nothing more to install. It loads the model with the backend EdgeModel.load picks (or --backend, --artifacts, --device, --dtype), answers one request at a time, and listens on 127.0.0.1 unless --host says otherwise.
| Route | What |
|---|---|
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 |
POST /v1/decide |
the request and answers of this runtime (below), number, truth, point and box included |
GET /v1/models |
{"models": [{"name", "description", "release_date"}]} |
GET /healthz |
{"status": "ok", "model", "backend"}, no key needed |
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:
- an
assetsmap,{"<ref>": {"type": "image", "format": "png", "data_base64": "..."}}with formatpng,jpgorjpeg(the bytes decide when they say otherwise). Wherever a reference appears in the state, an instruction or a criterion, the text readsPicture <n>and the asset becomes picture n (imagewhen it is the only one, elseimage_<n>), numbered in the order the references first appear. An asset no text refers to is refused. A score'slegendshows its levels as sent, references included. image_urlcontent parts ({"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}) in the same places, andimagesas a list of data URLs.
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.
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.
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.
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:
| Limit | /v1/systemone body |
|---|---|
| longer than the model's limit | {"detail": {"error_type": "max_tokens_exceeded", "message": "The request does not fit the model's maximum context length (...)."}} |
| more than 255 options | {"detail": "Too many choices. Must have at most 255 choices (255 options per choice)."} |
| 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)."} |
Options
EdgeModel.load(path, device=None, dtype=None, backend=None, artifacts=None, options=None):
device:"cuda","cuda:1","cpu"or"mps". Default: CUDA when present, else the CPU. It also sets the device class the backend is chosen for.dtype:"bfloat16"or"float32", for PyTorch. Default: bfloat16 on a GPU (the reference precision) and float32 on a CPU. On CPUs with bfloat16 support (recent AMD and Intel parts)dtype="bfloat16"was about three times faster in our tests, with answers as close to the GPU's as float32 gives.backend:"auto"(the default),"torch","vllm","openvino","mlx"or"llamacpp".artifacts: the directory the build tool wrote, foropenvinoandllamacpp.options: the backend's settings (below).
Choosing a backend
By default EdgeModel.load takes the fastest backend that is installed and can run on the machine:
| Device class | Order |
|---|---|
| NVIDIA GPU | vllm, llamacpp, torch |
| Apple silicon | mlx, llamacpp, torch |
| Any other CPU | openvino, llamacpp, torch |
openvino counts only when artifacts holds an OpenVINO build, and llamacpp only when artifacts holds a GGUF build and llama-server is found (or options names a running server). Asked for by name, openvino and llamacpp also find a build in the model folder itself, such as the GGUF file that ships there. model.backend says which one was taken.
To choose yourself:
model = EdgeModel.load("path/to/decisionnode-edge", backend="torch")
model = EdgeModel.load("path/to/decisionnode-edge", backend="openvino", artifacts="path/to/edge-openvino")
model = EdgeModel.load("path/to/decisionnode-edge", backend="llamacpp", options={"url": "http://127.0.0.1:8080"})
The same from the environment: DECISIONNODE_EDGE_BACKEND, DECISIONNODE_EDGE_ARTIFACTS, and DECISIONNODE_EDGE_OPTIONS (a JSON object). Arguments win over the environment. On the command line: python -m decisionnode_edge path/to/decisionnode-edge request.json --backend openvino --artifacts path/to/edge-openvino.
The faster backends read text. PyTorch reads what they do not take: pictures, and prompts longer than the backend's limit (an OpenVINO build's longest length, 2,048 tokens by default; a llama.cpp context, 16,384 tokens by default). It is loaded the first time it is needed, on device in dtype, and stays loaded. If most of your requests carry pictures, use backend="torch".
Building what a backend loads
The build tools read the model directory and write a new directory. They never change the model directory.
OpenVINO. One file per static input length, 512 and 2,048 tokens by default:
pip install "decisionnode-edge[openvino]"
python -m decisionnode_edge.build openvino path/to/decisionnode-edge --out path/to/edge-openvino
python -m decisionnode_edge.build openvino path/to/decisionnode-edge --out path/to/edge-openvino --lengths 512,1024,2048
The export runs on the GPU when there is one (--device cuda, then the default) and on the CPU otherwise. On our machine it took 14 minutes for 512 tokens and 19 minutes for 2,048, per length. The files keep the weights in float32 (exact copies of the bfloat16 weights) and OpenVINO runs them in bfloat16, so the build takes 6.2 GB for Edge Mini and 15.2 GB for Edge. Before it ends, the tool runs each file once against PyTorch.
A prompt is padded to the next length it fits, so a 600-token prompt costs about what a 2,048-token one does. Add a length (for example --lengths 512,1024,2048) if many of your prompts fall between two lengths. Lengths above a few thousand tokens take hours to export; longer prompts go to PyTorch.
GGUF for llama.cpp. The model folder ships the 8-bit file (decisionnode-edge-0.9.1-q8_0.gguf, decisionnode-edge-mini-0.9.1-q8_0.gguf) with its build record, so EdgeModel.load("path/to/decisionnode-edge", backend="llamacpp") uses it with no build step. To make it yourself (8-bit keeps the answers; 4-bit does not, so the tool does not offer it):
git clone https://github.com/ggml-org/llama.cpp
cmake -S llama.cpp -B llama.cpp/build -DCMAKE_BUILD_TYPE=Release # add -DGGML_CUDA=ON on an NVIDIA GPU or a Jetson
cmake --build llama.cpp/build --target llama-server -j
pip install -r llama.cpp/requirements/requirements-convert_hf_to_gguf.txt
python -m decisionnode_edge.build gguf path/to/decisionnode-edge --out path/to/edge-gguf --llama-cpp llama.cpp
The conversion takes seconds and writes one file: 2.0 GB for Edge, 0.8 GB for Edge Mini. The same file serves a CPU, an NVIDIA GPU, a Jetson and a Mac; llama.cpp itself is built for each.
The runtime then starts llama-server on 127.0.0.1 itself (from your PATH, or options={"server": "path/to/llama-server"}) and stops it with the model. To run the server yourself, start it with the embedding endpoint and the last-position pooling, with a batch as large as the context:
llama-server -m edge-gguf/model-q8_0.gguf --embeddings --pooling last -c 16384 -b 16384 -ub 16384 -ngl 999 --port 8080
and pass options={"url": "http://127.0.0.1:8080"}. The runtime sends token ids, so llama.cpp's own tokenizer is never used.
Backend options
Options can be flat ({"threads": 16}) or under a backend's name ({"openvino": {"threads": 16}}), so one set of options can serve whichever backend is taken.
| Backend | Option | Default |
|---|---|---|
vllm |
any vLLM engine argument, such as gpu_memory_utilization or enforce_eager |
a 2 GiB KV cache; the share of the GPU that covers the weights, that cache and 4 GiB for activations |
openvino |
threads |
OpenVINO's choice |
openvino |
precision: "bf16" or "f32" |
"bf16" (OpenVINO falls back to f32 on CPUs without bfloat16) |
openvino |
config: other OpenVINO CPU properties |
|
llamacpp |
url: a running llama-server |
start one |
llamacpp |
server, gguf: the program and the model file |
llama-server on PATH, the build's file |
llamacpp |
gpu_layers, threads |
999 (all layers on the GPU when there is one), llama.cpp's choice |
llamacpp |
context: the longest prompt llama.cpp takes |
16,384 |
vLLM runs its engine inside your process and uses the first visible GPU (choose it with CUDA_VISIBLE_DEVICES). Its first load takes about half a minute while it compiles. MLX uses the Apple GPU.
Pictures
Send pictures beside the state, up to 16, each with an id:
{"images": [{"id": "receipt", "data": "<base64>", "media_type": "image/jpeg"}]} # as with the hosted API
{"images": [{"id": "receipt", "path": "/data/receipt.jpg"}]} # a local file, Edge only
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".
Points and boxes
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:
response = model.decide({
"images": [{"id": "belt", "path": "/data/belt.jpg"}],
"questions": {
"pick": {"type": "point", "instructions": "The yellow package closest to the robot arm."},
"damage": {"type": "box", "instructions": "The damaged package.", "units": "normalized"},
"parcels": {"type": "box", "instructions": "Every parcel on the belt.", "max": 10},
},
})
response["answers"]["pick"]
# {"type": "point", "x": ..., "y": ..., "present": ..., "confidence": ..., "image": "belt", "width": 1920, "height": 1080}
response["answers"]["parcels"]
# {"type": "box", "count": ..., "present": ..., "complete": ..., "boxes": [{"x1": ..., "y1": ..., "x2": ..., "y2": ..., "confidence": ...}, ...],
# "image": "belt", "width": 1920, "height": 1080}
| Field | Notes |
|---|---|
instructions |
what to find, required: a phrase ("the yellow package"), a description with conditions, or a question ("Where is the crack?") |
image |
the id of the picture to look in; needed when the request has two or more pictures |
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 |
max |
2 to 16: list up to that many places in place of one |
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.
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.
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.
The request
The request and the answers follow the DecisionNode API's /v1/decide:
| Field | Notes |
|---|---|
state |
a string (used as it is), an object or an array (compact JSON, key order kept); missing, null and "" are the empty state |
questions |
id to question, at least one; the id is never shown to the model |
images |
optional, see above |
model |
ignored |
| Question type | Fields |
|---|---|
choice |
instructions (optional), criteria: {option: description}, 1 to 255 options; a description may be a string, an object, an array or null |
score |
instructions (optional), criteria: [level, ...] low to high, 1 to 10 levels |
truth |
instructions and/or criteria: {"true": ..., "false": ...} |
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 |
point, box |
instructions, image (with two or more pictures), units (pixels or normalized), max (2 to 16, a list); see Points and boxes |
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.
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.
Not on Edge: videos, boosters, image URLs and uploads, sessions and batch jobs. These are features of the hosted API.
How it works
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.
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.
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.
License
The runtime code, like the model weights, is licensed under the GNU Affero General Public License, version 3 only (SPDX: AGPL-3.0-only). For use in closed products and services, Bynn Intelligence Inc. offers a commercial license: licensing@bynn.com.
Metadata
Release files for decisionnode-edge 0.9.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| decisionnode_edge-0.9.2.tar.gz | 77.5 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| decisionnode_edge-0.9.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 152.6 kB
Release files / decisionnode_edge-0.9.2.tar.gz
| Download URL | decisionnode_edge-0.9.2.tar.gz |
|---|---|
| Size | 77.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2f1da8de0be9edb3a58c3804dc024cede75b53dcea9aa82a42645e1a189ccbd0
|
|
BLAKE2b-256 checksum How to use checksums |
ff2e731a4132bcca0641f7bd41f938ee0fe53ae191637bb1d19914b52557697b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.10.20
|
Release files / decisionnode_edge-0.9.2-py3-none-any.whl
| Download URL | decisionnode_edge-0.9.2-py3-none-any.whl |
|---|---|
| Size | 75.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
dfb0b48c1233c32a106d8eee4d6c320dc3454026a1119851787f345b2f5c6317
|
|
BLAKE2b-256 checksum How to use checksums |
e9f4c34cbdacd2d3c12c5916300a90794286e7aba3f5acad314ebf092f57bc7f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.10.20
|
Security for decisionnode-edge 0.9.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.