Single-pass Neural Answer Probabilities
Typed decisions from unstructured state — one forward pass, zero generated text.
local · deterministic · drop-in Jev compatible
Your LLM pipeline doesn't need to write anything. It needs to decide.
Yet somewhere in your stack there's a prompt asking for JSON, a parser that mostly works, a retry loop for when it doesn't — and a token bill for thousands of generated words you immediately throw away. All that machinery exists for one reason: the only way you can interrogate a chat model is to let it talk.
SNAP never lets it talk.
Every question compiles to a prompt whose first answer token must be a
letter — A through Z, one per option. SNAP reads the logits at that
single position, pools the letter variants, and softmaxes them into a
distribution. The jaw snaps shut on the answer. One llama_decode per
question. No prose, no parsing, no retries, no hallucinated JSON keys.
- Decisions, not documents. The output surface is a fixed alphabet of letters. Structured by construction — there is nothing downstream to parse, validate, or repair.
- The prompt is the API.
noul,choice,score,numeric, abstention (__abstain__), out-of-range anchors — typed contracts with explicit uncertainty instead of a confident hallucination. - Probabilities you can threshold.
snap calibratefits a temperature per question type on your labeled cases — bound to the model and prompt version, with ECE printed before and after. - Choices past the alphabet. More than 26 options? Each candidate gets its own yes/no probe, batched over the same shared prefix, and the scores merge into one distribution. Up to 256.
- State amortized. Every prompt of a request decodes as one shared-prefix trie in a single batched call: the state — and any text several questions share — is evaluated once, on every architecture, hybrids included. The constant prompt head stays resident between requests.
- Questions amortized too.
layout: question_firstflips the prompt — the question head is identical across requests and stays cached on its own KV sequence (LRU-bounded). Repeat workloads — the same triage questions on a stream of tickets — decode only the state. - Honest internals.
x_snapreports exactly what happened:prompt_tokensagainst the tokens actually decoded (usage.input_tokens),cached_head_tokens,shared_prefix_tokens,cache_hits/misses,waves, decode/total ms. Every answer also carriescoverage— how much of the model's raw next-token mass landed on the allowed letters at all.
POST /v1/systemone speaks the TypeSafe/Jev wire format. Point your
existing client at a local snap serve and it works — noul, choice,
score, same response shape. One surface, no parallel API.
The same request accepts optional snap extensions; Jev clients can ignore them and defaults preserve Jev semantics:
| field | extension |
|---|---|
question "type": "numeric" |
{min, max, granularity} — distribution over a numeric range |
question allow_abstain |
default false; true adds an __abstain__ slot (status abstained) |
request mode |
shared (default: shared text decoded once, spans cached across requests) or direct (every question decoded alone — the reference path) |
request layout |
auto (default), state_first, question_first, header, catalog |
request expand |
probes (default) or pages — how >26-option choices expand |
request compact_state |
default false; true renders object states as compact lines/csv rows |
layout controls where the question sits relative to the state.
question_first makes the question head cacheable across requests and
reads more accurately on short states; state_first prefills a long
document once for all questions; header lists every question before the
state so the document is encoded with all of them in view. auto picks
question_first only when the question heads outweigh the state — the
case where warm caches win (qf re-decodes the state per question, sf
decodes it once). It falls back to state_first for states > 2000 chars
(even a single question reads a long document better after it), whenever any question has allow_abstain (an
abstain slot read before the evidence primes abstention), or when the
state is bigger than the heads. catalog lists every question (numbered,
instructions only) before the state, then each item is just a
QUESTION i — name pointer plus its OPTIONS — the [head+catalog] span
caches across requests on the same question set, so a stream of different
states decodes only the state once plus a ~20-token tail per question.
The resolved layout is in x_snap.
Answers carry extras on top of the Jev shape — status, confidence,
full probabilities, and typed fields (boolean, level, value) —
which Jev clients simply ignore.
choice is not bounded by the alphabet: past 26 options (256 max) the
question expands. expand: probes (default) scores each option with an
independent probe — is this candidate the correct answer? — batched in
waves over the shared prefix, then yes-masses normalize into the returned
distribution; with allow_abstain, no candidate reaching 0.5 abstains.
expand: pages instead splits candidates into equal-size pages of ≤26
real choices — far fewer decode items, but page-conditional probabilities
and no abstention.
- Data never leaves the machine. Weights are a GGUF on disk, downloaded once from HuggingFace. No API key, no telemetry, no third party reading your customers' tickets.
- Deterministic. Same input, same distribution. Log it, replay it, threshold it — every answer ships with its full probability map.
- Nothing to jailbreak. The model cannot emit a payload because it cannot emit text. Prompt injection can nudge a distribution; it cannot speak.
- One static binary.
cargo build --release→snap. A container, a VM, an air-gapped box — no Python, no runtime deps.
Apple M1 Max, Metal, in-process snap bench --requests 20, p50. Every
request carries a fresh state — a stream of new documents — so only what
production can reuse gets reused: the template head and the question
heads.
| scenario | minicpm5-2b Q4_K_M | spark-4b Q8_0 | qwen3.8-4b Q4_K_M |
|---|---|---|---|
| single question | 50 ms | 89 ms | 82 ms |
| 4 questions | 126 ms (32 ms/q) | 232 ms (58 ms/q) | 227 ms (57 ms/q) |
| 8 questions | 252 ms (32 ms/q) | 406 ms (51 ms/q) | 472 ms (59 ms/q) |
8 questions, mode: direct (nothing shared) |
1170 ms (146 ms/q) | 2158 ms (270 ms/q) | 2381 ms (298 ms/q) |
| 8 questions on a 1.8 KB document | 1887 ms (236 ms/q) | 3278 ms (410 ms/q) | 3327 ms (416 ms/q) |
| 4 KB state, 1 question | 1364 ms | 2476 ms | 2164 ms |
The direct row decodes every question on its own: the gap to the row
above it is what prefix sharing and the cached question heads buy. The
hybrid qwen3.8 runs the same batched path, with 17 parallel sequences
instead of 65.
Ollama 0.34.3 serving openbmb/minicpm5-2b (its own packaging of the
same MiniCPM5-2B Q4_K_M), same machine, both over HTTP, p50 of 15, each
engine's requests back to back, and every request a state that engine
has never seen. Ollama runs think:false at temperature 0, with the
fixed question text first so its prefix cache reuses it the way SNAP's
does. python3 eval/vs_ollama.py reproduces the table.
| workload | Ollama, JSON + probabilities | Ollama, JSON answers | Ollama, one letter each | SNAP | vs fastest Ollama |
|---|---|---|---|---|---|
| 1 question | 898 ms (69 tok) | 163 ms (9 tok) | 61 ms | 54 ms | −11% |
| 4 questions | 3704 ms (282 tok) | 342 ms (26 tok) | 257 ms | 135 ms | −48% |
| 8 questions | 7874 ms (562 tok) | 732 ms (50 tok) | 504 ms | 263 ms | −48% |
| 5 KB state, 1 question | 3872 ms (73 tok) | 2738 ms (8 tok) | 2650 ms | 2226 ms | −16% |
- One letter per question is Ollama's floor: a letter of text to
parse, no probabilities, one request per question (asked for every
letter in one reply,
"A,C,B", it stopped early at 4 and 8 questions). On a single question it comes close to SNAP (61 vs 54 ms): same prompt, one position read. SNAP pulls ahead as questions are added, because they share one pass instead of one request each. - JSON is what a pipeline actually wires, under a schema. Asked for what a SNAP answer carries (key, confidence, a probability per option) Ollama writes every token of it, and those probabilities are text the model made up; SNAP's come from the logits.
- A long state with one question is bound by prefill on both sides; the gap there is prefill speed, not the method.
snap evaluate eval/cases.jsonl — one file, one report: accuracy and
balanced accuracy (mean per-class recall) against ground truth, plus
the quality of the distributions themselves: Brier score and ECE
(expected calibration error on the top probability). Cases can ship
variants — paraphrased state/question fields — and the report adds
a consistency block: answer agreement and mean probability drift,
broken down per perturbation kind. SNAP also auto-generates stability
probes per case — option-order reversal (positional bias), a
meaning-preserving criterion rewording, and an unrelated-context
injection — --no-perturb skips them. Line format:
{"id", "state", "question", "expect", "variants"?, "requires_abstain"?, "layout"?, "expand"?, "compact_state"?}. Base cases use <domain>-NN
ids, adversarial cases edge-<stress>-NN — the tables below split by
tier.
Measured with the API's own defaults — layout: auto, no abstain slot
unless a case asks for one — i.e. exactly what /v1/systemone serves:
| model | base (52) | edge (19) | ms/case |
|---|---|---|---|
| qwen3.8-4b Q4_K_M | 94.2% | 79.0% | ~210 |
| spark-4b Q8_0 | 92.3% | 79.0% | ~180 |
| minicpm5-2b Q4_K_M | 80.8% | 57.9% | ~95 |
These sets are small: the 95% interval is about ±7–11 points on the 52 base cases and ±17–20 on the 19 edge cases, so a few points between the 4B models is noise.
Distribution quality (lower is better; same runs):
| model | brier base | brier edge | ECE base | ECE edge |
|---|---|---|---|---|
| qwen3.8-4b Q4_K_M | 0.126 | 0.201 | 0.159 | 0.151 |
| spark-4b Q8_0 | 0.157 | 0.370 | 0.101 | 0.190 |
| minicpm5-2b Q4_K_M | 0.300 | 0.627 | 0.079 | 0.281 |
The ECE column is why calibration exists: qwen is underconfident (73%
mean confidence at 94% accuracy on base), spark is overconfident where
it's weaker (87% confidence at 79% on edge) — both fixable by
snap calibrate, both invisible to accuracy alone.
Stability on base — how often the answer survives a perturbation that shouldn't change it:
| model | options reversed | instruction reworded | unrelated context added |
|---|---|---|---|
| qwen3.8-4b | 91% | 90% | 90% |
| spark-4b | 91% | 81% | 92% |
| minicpm5-2b | 68% | 81% | 88% |
Reversing the option order flips a third of MiniCPM's choices: letter and position bias is the main weakness of small models answering by letter. MiniCPM-2B is the speed/footprint option; the 4B models are the production pick.
eval/typesafe_public.py runs the 20 public cases from
evals.typesafe.ai — four workflows, 373
decisions, reference = frontier consensus — against a running snap serve,
then scores agreement the same way
jev-on-a-laptop does, so
numbers are comparable across Jev reproductions (extraction logic adapted
from it, MIT). TypeSafe's raw case data is fetched at runtime, never
committed.
python3 eval/typesafe_public.py fetch && python3 eval/typesafe_public.py extract
snap serve --model qwen3.8-4b --ctx 32768 & # invoices need room
python3 eval/typesafe_public.py answer --out results/typesafe-public/snap-qwen38.json
python3 eval/typesafe_public.py score results/typesafe-public/snap-qwen38.jsonqwen3.8-4b on an M1 Max: 73.2% agreement over the 373 decisions
(Security 35/48, AgentTrace 32/49, Invoice 130/184, CustomerSvc 76/92) —
87% on yes/no questions, 56% on scores, 48% on choices, ~1.1 s per
decision. The misses are not random: half are on the invoices, where
whole question families cross-check a ~7k-token document (is this line
really delivered, is this price really approved) and a 4B model
answering in one token disagrees systematically with reasoning frontier
models — the kind of question to route elsewhere when a calibrated
answer comes back contested.
Raw letter logits are honest but uncalibrated: 0.9 does not mean
"right 90% of the time" until you measure it. snap calibrate runs
your eval cases, collects every emitted distribution with its
ground-truth target, and fits one temperature per question type:
snap calibrate --model qwen3.8-4b eval/cases.jsonl -o calibration.json
# fitted on 69 cases (2 skipped)
# boolean T=0.477 choice T=0.474 numeric T=1.000 score T=0.774
# ece 0.127 raw -> 0.089 in-sample | 0.106 out-of-fold CI95 [0.055, 0.185]
# caveat: OOF interval overlaps raw ECE — gain not proven at this n
snap serve --model qwen3.8-4b --calibration calibration.json(T < 1 sharpens: qwen3.8 is underconfident on this set. The caveat line is printed whenever the evidence doesn't separate — that's the point of reporting it.)
Three ECE numbers are printed, and they mean different things. Raw is where you start. In-sample is scored on the same cases the fit saw — optimistic by construction, kept for reference. Out-of-fold is the honest one: every case is scored with a temperature fit on the other folds (5-fold, group-disjoint — a case's variants never leak across folds). The 95% bootstrap interval resamples whole cases, and the report flags whether the gain actually separates from raw at that confidence — at these sample sizes, read the interval, not the point.
The file binds to the exact model id and prompt version — a calibration
fitted on another build refuses to load. --calibration is accepted by
serve, -p, evaluate, bench. This is post-hoc scaling, not
retraining: a single scalar corrects over/under-confidence, not the
shape of the distribution, and it holds only as far as your eval data
resembles production traffic.
Prebuilt binaries on GitHub Releases:
macOS (Apple Silicon) via Homebrew:
brew install emnlmn/snap/snapor the tarball:
mkdir -p ~/snap && curl -L https://github.com/emnlmn/snap/releases/latest/download/snap-macos-arm64.tar.gz | tar xz -C ~/snapLinux x86_64:
mkdir -p ~/snap && curl -L https://github.com/emnlmn/snap/releases/latest/download/snap-linux-x86_64.tar.gz | tar xz -C ~/snapsnap and the lib*.so* files must stay in the same directory
($ORIGIN rpath) — that's why it installs to a folder, not a bin dir.
~/snap/snap runs as-is; ln -sf ~/snap/snap ~/.local/bin/snap puts
it on PATH. Other assets: linux-x86_64-v3 (single-file AVX2),
linux-x86_64-musl (fully static), linux-aarch64,
linux-x86_64-vulkan. macOS isn't notarized — a browser-quarantined
tarball may need xattr -d com.apple.quarantine ~/snap/snap. First
inference pulls the model GGUF (~1.5 GB), then offline.
Once a day snap checks GitHub for a newer release and prints a line on
stderr — never on stdout, never in -p. SNAP_NO_UPDATE_CHECK=1 turns
it off.
Or build from source — llama.cpp is vendored and compiled at first build (~2 min), Metal on by default on Apple Silicon:
make setup # rustup + cmake/clang check
make build # → ./target/release/snap
make test # unit tests + functional smoke (pulls minicpm, ~1.5 GB)
make lint # cargo fmt --check + clippy -D warningsThe default build targets the host: Metal on Apple Silicon, baseline CPU elsewhere. GPU and portable-CPU variants are cargo features:
make build FEATURES="cuda" # NVIDIA — needs CUDA toolkit
make build FEATURES="vulkan" # AMD/Intel/generic GPU driver
make build FEATURES="dynamic-backends" # every CPU variant, dispatched at loaddynamic-backends ships snap plus libggml*/libllama* runtime libs
and libggml-cpu-*.so modules — the portable pick for servers. Single-file instead: RUSTFLAGS="-C target-cpu=x86-64-v3" cargo build --release (AVX2, ~2013+ CPUs). CI builds all of them per
platform — see .github/workflows/.
snap models # known shortcuts
snap -p '{"state":"…","questions":{"urgent":{"type":"noul"}}}' # one-shot, claude-style
snap -p request.json # same thing, from a file (stdin works too)
snap serve --model qwen3.8-4b --port 8018 # HTTP server
snap ps # running servers (pid, model, uptime, state)
snap stop # stop the one server; --all / --port / --pid for more
snap evaluate eval/cases.jsonl # accuracy + brier/ece + consistency
snap calibrate eval/cases.jsonl -o cal.json # fit temperatures -> calibration file
snap bench --requests 20 # latency/throughput--model takes a name from snap models — the tested set only. A GGUF
that loads is not a GGUF that answers correctly; new candidates get a
row in src/models.rs after they pass the eval cases.
--ctx (default 8192 tokens ≈ ~6k words) is the KV pool: every prompt —
state + question + options — must fit in it, and the questions in flight
share it with the cache (more questions than fit just run in more
waves). It is reserved in full at startup: ~42 KB/token for minicpm5-2b,
~144 KB/token for spark-4b (its sliding-window layers keep full-size KV so
prefixes can be forked), ~32 KB/token for qwen3.8-4b plus ~0.85 GB of
recurrent state. 8192 ≈ 0.34 / 1.1 / 0.25 GB, 32768 ≈ 1.3 / 4.5 /
1 GB on top of the weights. Long inputs are rejected 422 (never
truncated), so raise it only when your states need it:
snap serve --ctx 32768. Deliberately not "model max": spark advertises
1M tokens, which would be ~140 GB of reservation.
--threads (default 0 = all available cores) sets llama.cpp's decode
threads — relevant on CPU-only builds; on GPU backends it barely matters.
llama.cpp's own default is 4, which starves prefill on a bigger box.
| endpoint | purpose |
|---|---|
POST /v1/systemone |
the API — Jev wire (noul / choice / score) + numeric, abstain, mode extensions |
GET /v1/models |
served model id |
GET /healthz |
readiness — answers only after engine warm-up |
GET /playground |
built-in console, embedded in the binary |
snap serve registers a pidfile so other terminals can see it: snap ps
lists every running server (including starting while the model loads
and strays answering on :8018), snap stop shuts one down — bare stop
when there's exactly one, --port/--pid/--all otherwise. SIGTERM
drains in-flight requests; --force is SIGKILL.
snap serve already carries it — no assets, no build step:
snap serve --model spark-4b # then open http://localhost:8018/playground/playground— an API console. Build a request channel per question (all five types), flip between builder, raw JSON and cURL, and read every answer as a probability map on a shared 0–100% scale plus thex_snapinternals (decode/total ms, tokens decoded vs prompt tokens, cache hits, waves).⌘↵runs.
./snap serve --model qwen3.8-4b --host 0.0.0.0 --port 8018- One process = one resident model. Requests serialize on the engine; parallelism lives inside a request (one batched decode per wave). Scale with N processes behind a load balancer.
- Memory ≈ weights + KV (
--ctx× the per-token cost above) + recurrent state on hybrids. Peak RSS at 8192: minicpm5-2b 1.9 GB, qwen3.8-4b 3.8 GB, spark-4b 5.4 GB. - Boot ~4 s on an M1 Max once the GGUF is downloaded (mmap, resident
head, warm-up requests).
/healthzis your readiness probe. - Logs quiet by default (warnings only);
--debugorRUST_LOG=llamac=infofor llama.cpp internals on stderr. - Over-context input → 422. Never silently truncated.
[Service]
ExecStart=/usr/local/bin/snap serve --model qwen3.8-4b --port 8018
Restart=on-failure
Environment=RUST_LOG=infochoicescales to 256 options via per-option probes;scoreandnumericstay inside the 26-letter alphabet.- Hybrid/recurrent models run the same batched path with fewer parallel sequences (17 instead of 65 — each pins a recurrent-state row), so large question sets take more waves there.
- Probabilities are calibrated only after
snap calibrate— and only as far as your eval data resembles production.
One pass. One distribution. The jaw snaps shut.
