Next-token-logit multiple-choice classifier (Jev in 25 lines) as offline API+CLI. CPU, modelless.
56
Multiple-choice classification from next-token logits, served as a local HTTP API. The method is the one from NobodyWho's Jev in 25 lines of Python: present the choices as lettered options, run one forward pass, read the logits of the option letters at the last position, softmax over just those. No generation, no sampling, deterministic, milliseconds per request on CPU.
n8500x/jev — llama-cpp-python
0.3.36 (vendors llama.cpp from 2026-10-01) compiled from source, CPU-only,
no model baked in: mount any instruct GGUF already on-prem.HF_HUB_OFFLINE=1, LLAMA_CURL=OFF). Dependencies are pinned and the
exact lock file ships inside the image (docker run --rm n8500x/jev requirements).n8500x/llamacpp — /models, MODEL_PATH, PORT,
CTX_SIZE, THREADS, API_KEY; non-root UID 1001 / group 0 (OpenShift
restricted-v2 clean).docker run -d --name jev -p 8080:8080 \
-v /srv/models/Qwen3-4B-Q4_K_M.gguf:/models/model.gguf:ro \
n8500x/jev
Or docker compose up -d — the bundled docker-compose.yml
carries the annotated configuration. The image is self-documenting:
docker run --rm n8500x/jev compose > docker-compose.yml
docker run --rm n8500x/jev readme
docker run --rm n8500x/jev requirements # pip freeze of the image
docker run --rm n8500x/jev docs # index of everything below
docker run --rm -v "$PWD":/out n8500x/jev export # copy it all out
Nothing in a gated network can follow a link, so the whole of this
directory's documentation is baked in under /usr/share/jev and reachable
three ways: docker run --rm n8500x/jev docs <path>, … export into a
mounted directory, or GET /docs / GET /docs/<path> on a running instance.
| Path | What |
|---|---|
README.md | This file (also the Docker Hub overview) |
docker-compose.yml | Annotated compose configuration |
requirements.lock | Exact pip freeze of the image |
docs/API.md | Every endpoint, field, error code; curl / Python / PowerShell clients |
docs/OPERATIONS.md | Air-gap transfer, models, sizing, threads, CPU builds, security, troubleshooting, upgrades |
docs/METHOD.md | How the scoring works, choice_mass, prompt construction, differences from the post, evaluation |
docs/PROMPTS.md | Ready-made request bodies: phishing, ticket routing, log triage, yes/no gates, sentiment, intent, code, few-shot |
examples/client.py | Python client, standard library only; library + CLI |
examples/evaluate.py | Score a labelled JSONL file: accuracy, per-class P/R/F1, confusion matrix, low-mass rows |
examples/sample.jsonl | 15 labelled emails to try evaluate.py on |
examples/curl.sh, classify.json, batch.json | Shell walkthrough and request bodies |
examples/k8s/ | Deployment + Service + PVC for Kubernetes/OpenShift, with model-loading notes |
docker run --rm n8500x/jev docs OPERATIONS.md
docker run --rm n8500x/jev python3 /usr/share/jev/examples/evaluate.py \
--url http://jev:8080 --data /usr/share/jev/examples/sample.jsonl --input-label Email
curl -s localhost:8080/docs/docs/PROMPTS.md
Any instruct GGUF works. Size is a real trade-off: a 0.6B answers a 50-token
prompt in ~0.3 s on a laptop CPU but is only reliable for coarse, obvious
classes; an 8B takes ~2.4 s and handles nuance. On the bundled
examples/sample.jsonl (15 emails, legitimate / spam / phishing) Qwen3-0.6B
scores 6/15 and Qwen3-8B 13/15 — run examples/evaluate.py on your
own labelled sample before choosing. Use the coder models you already have
for code-shaped inputs (log lines, stack traces, SQL) and a general instruct
model for prose. Qwen3 / Qwen3.6 templates (including the MoE coder builds)
are handled: thinking is switched off through the template's own
enable_thinking flag.
Reference numbers (i5-12450H laptop, Docker Desktop/WSL2, Haswell-baseline
build, the post's 50-token prompt via demo, warm):
| Model | Threads | Per request | Verdict on the post's email |
|---|---|---|---|
| Qwen3-0.6B Q8_0 | 4 | ~0.3 s | Phishing 0.897 |
| Qwen3-0.6B Q8_0 | 12 | ~0.5 s | Phishing 0.897 |
| Qwen3-8B Q4_K_M | 12 | ~2.4 s | Phishing 1.000 |
The whole cost is one llama_decode prefill (tokenize + logits copy are
< 1 ms); a server-class CPU with AVX-512 and a matching build is several
times faster.
curl -s localhost:8080/v1/classify -H 'content-type: application/json' -d '{
"input": "Payroll asks for your password on a non-company sign-in page.",
"choices": ["Legitimate", "Spam", "Phishing"],
"input_label": "Email"
}'
{
"choice": "Phishing", "label": "C", "index": 2, "confidence": 0.677,
"probabilities": {"Legitimate": 0.168, "Spam": 0.155, "Phishing": 0.677},
"logprobs": {"Legitimate": -1.783, "Spam": -1.864, "Phishing": -0.390},
"logits": {"Legitimate": 27.800, "Spam": 27.719, "Phishing": 29.193},
"choice_mass": 0.982,
"labels": {"A": "Legitimate", "B": "Spam", "C": "Phishing"},
"label_tokens": {"A": 32, "B": 33, "C": 34},
"prompt_tokens": 56, "latency_ms": 477.1,
"model": "Qwen3 0.6B Instruct", "template": "gguf"
}
Measured with this image on Qwen3-0.6B-Q8_0.gguf, the model the post used,
with the image's default instruction. With the post's own instruction
("instruction": "Choose one option.", which is what demo sends) the same
build gives 26.271 / 27.041 / 29.589 → Phishing 0.897, against the post's
26.254 / 27.262 / 29.614 → 0.885; the small drift is the CPU kernel.
Qwen3-8B-Q4_K_M on the same email: Phishing 1.000, choice_mass 1.000 either
way.
| Endpoint | Purpose |
|---|---|
POST /v1/classify | One input → probabilities over the choices |
POST /v1/classify/batch | {"items": [...]} — up to 256 inputs, one call |
GET /health | Liveness + model/engine info (unauthenticated) |
GET /v1/models | OpenAI-style model list, for tooling that probes it |
GET /docs, GET /docs/{path} | The baked-in docs and examples (unauthenticated) |
GET /openapi.json | Schema (Swagger UI is off — it needs a CDN) |
Request fields:
| Field | Default | Meaning |
|---|---|---|
input | required | Text to classify |
choices | required | 2–26 candidate answers, shown as A. …, B. … |
instruction | Choose one option. Answer with the letter only. | System prompt; null = no system turn. The post used just Choose one option. — see below for why the default adds a sentence |
input_label | Input | Prefix on the input line (Email, Ticket, Log line); null = none |
labels | A B C … | Option letters. Each is scored by its first token |
raw_prompt | – | Skip templating and score this exact text (must list the options itself) |
include_prompt | false | Echo the rendered prompt — use it when debugging a new model |
choice_mass is the share of the model's whole next-token distribution that
lands on the option letters. Close to 1 means the model is answering the
question; a small value means it wanted to write something else (thinking not
switched off, wrong template, instruction too vague) and the probabilities
are not to be trusted. test.ps1 fails below 0.5.
The GGUF's own chat template is rendered with system + user turns and
add_generation_prompt, with thinking disabled (enable_thinking=False, which
Qwen3-family templates honour). If the model is a thinking family and the
template did not already close an empty <think></think> block, one is
appended so the first generated token is the answer letter — exactly the trick
in the post. CHAT_TEMPLATE=chatml forces the post's literal Qwen ChatML
prompt instead; THINK_SKIP=always|never overrides the detection.
The user turn is "{input_label}: {input}\n\n{A. choice}\n{B. choice}…", as in
the post.
Why the default instruction adds "Answer with the letter only." Larger
models read the post's bare Choose one option. as an invitation to explain,
and then the letter logits are a forced ranking rather than an answer.
choice_mass makes that visible; the extra sentence fixes it:
| Model | Prompt | choice_mass with Choose one option. | … + Answer with the letter only. |
|---|---|---|---|
| Qwen3-8B | Oracle log line, 4 choices | 0.000 | 1.000 |
| Qwen3-0.6B | Oracle log line, 4 choices | 0.651 | 0.951 |
| Qwen3-0.6B | the post's email | 0.938 | 0.982 |
The ranking (choice) was the same in every case; what changes is whether the
probabilities mean anything. Pass "instruction": "Choose one option." to get
the post's exact prompt.
docker run --rm -v ./m.gguf:/models/m.gguf:ro n8500x/jev demo
docker run --rm -v ./m.gguf:/models/m.gguf:ro n8500x/jev classify \
-i "ORA-01555: snapshot too old" -c Network Storage Database Application --input-label "Log line"
demo reproduces the post's example with the post's exact instruction and
prints its three lines (logits, log probabilities, probabilities). classify
uses the image default instruction (override with --instruction). --json
gives the full API result, --show-prompt prints what the model saw.
| Var | Default | Purpose |
|---|---|---|
MODEL_PATH | first /models/*.gguf | Model to load |
PORT | 8080 | Listen port |
CTX_SIZE | 4096 | Context window; longer prompts get HTTP 413 |
THREADS | CPUs visible to the container | Decode threads. Small models want fewer: 4 beat 12 by a third on a 0.6B. Count physical cores, not SMT siblings |
THREADS_BATCH | = THREADS | Prefill threads |
BATCH_SIZE / UBATCH_SIZE | 1024 / 512 | Prefill batch sizes |
CHAT_TEMPLATE | auto | auto = GGUF template, chatml = the post's literal prompt |
THINK_SKIP | auto | Close an empty think block: auto (thinking families only), always, never |
API_KEY | (off) | Bearer auth on /v1/* |
SPLASH / NO_COLOR | 1 / (unset) | Startup banner |
VERBOSE | 0 | 1 = llama.cpp load logs |
Memory ≈ model file + KV cache (CTX_SIZE × layers; ~150 MB per 1k tokens on
an 8B, far less on small models). The model is mmaped, so leave page-cache
headroom in mem_limit. One model instance per container, requests
serialised; scale with replicas.
| Post | Image | Why |
|---|---|---|
Llama.from_pretrained(repo_id=…) | model_path= from the mount | No network on-prem |
logits_all=True + model.scores[n-1] | llama_get_logits_ith(-1) | logits_all allocates n_ctx × n_vocab floats (~2.5 GB at 4k ctx for Qwen's 151k vocab) |
Hard-coded Qwen ChatML with <think>\n\n</think> | GGUF chat template, thinking off, ChatML fallback | Works for non-Qwen GGUFs too |
n_ctx=512 | CTX_SIZE=4096 | Real emails/tickets |
| script | FastAPI + CLI, bearer auth, batch, health | Daemon |
The arithmetic is unchanged: logprobs = logits − logaddexp.reduce(logits),
probabilities = exp(logprobs).
.\build.ps1 # compiles llama-cpp-python (~2 min on 12 cores), tags date+latest
.\test.ps1 # fetches Qwen3-0.6B-Q8_0 (610 MB, test only), runs API + CLI checks
.\test.ps1 -Port 18081 -ModelsDir ..\llamacpp\models # same checks against another GGUF dir
.\build.ps1 -Push # push both tags, record .push_tag
test.ps1 asserts the post's verdict (Phishing), a choice_mass ≥ 0.5, the
batch endpoint, input validation, and the demo / classify CLI paths. It
stops the server container before the CLI runs because each CLI call loads
its own copy of the model.
The CPU ISA is fixed at build time (pip cannot build ggml's runtime-dispatched variants): the default is a Haswell baseline (AVX2/FMA/F16C/BMI2) that runs on any x86-64 server since 2013. For AVX-512 hosts build a second tag:
.\build.ps1 -CmakeArgs "-DGGML_NATIVE=OFF -DGGML_AVX=ON -DGGML_AVX2=ON -DGGML_FMA=ON -DGGML_F16C=ON -DGGML_BMI2=ON -DGGML_AVX512=ON -DGGML_AVX512_VNNI=ON -DGGML_AVX512_BF16=ON"
The flags used are recorded in the image label ai.jev.ggml_cmake_args.
Build-time pip trusts the index hosts (PIP_TRUSTED_HOST, build stage only)
because of host TLS interception; the runtime stage has no pip and no CA
material.
To bump llama-cpp-python: change the pin in requirements.txt and the
LLAMA_CPP_PYTHON_VERSION arg in the Dockerfile (they are cross-checked).
/v1/classify/batch.Content type
Image
Digest
sha256:ed5f1da1b…
Size
83.8 MB
Last updated
about 11 hours ago
docker pull n8500x/jev