GLiNER2.5-Decide β€” a decision model for llama.cpp

GLiNER2.5-Decide is a zero-shot classifier: you give it a label set and some text, it scores every label in a single forward pass. This package ports it to llama.cpp as a decision model, served behind upstream's /v1/systemone endpoint.

The decision head is a 2-layer MLP (2.1M parameters) that reads the final hidden state at each [L] label marker and emits one logit per label. Here it lives inside the GGUF and llama.cpp applies it for you β€” there is no per-token head to run in your own process, and no /embedding round trip.

$ curl -s localhost:8080/v1/systemone -H 'Content-Type: application/json' -d '{
    "state": "Patient reports crushing chest pain radiating to the left arm.",
    "questions": {
      "urgency": {"type": "choice", "instructions": "How urgent is this?",
                  "criteria": {"routine": null, "soon": null, "emergency": null}}
    }
  }' | jq '.answers.urgency'
{
  "type": "choice",
  "choice": "emergency",
  "probabilities": {"routine": 0.06, "soon": 0.34, "emergency": 0.60},
  "confidence": 0.40
}

Quick start

Requires llama.cpp built with the decision port β€” plain upstream master carries it as of PR #29818 (2026-10-02). There are no prebuilt binaries here; build it yourself.

llama-server --model gliner-decision-q8_0.gguf --ctx-size 512 -ngl 99

--ctx-size 512 is not arbitrary. DeBERTa's disentangled attention materialises an [n_embd, n_row, n_col] tensor per term, so activations are O(n_token^2 * n_embd) β€” quadratic in sequence length. At n=2048 the two attention terms alone are ~17 GB each and the context reserve asks for ~35 GB, which will OOM a 24 GB card. At n=512 they are ~1.1 GB each. Real decision prompts measured 19–85 tokens, so 512 is comfortable.

If the model fails to load with wrong number of tensors; expected 394, got 390, the binary is from a pre-decision tree that cannot see decision.* tensors. Check grep -c COMMON_DECISION_TYPE_GLINER common/common.h in your checkout β€” it must be non-zero.


Contents

models/    gliner-decision-{f16,q8_0,q4_k_m}.gguf
patches/   0001-deberta-v3-upstream-port.mbox β€” the port as a reviewable patch
bench/     results.json β€” the measurement behind every number below
scripts/   bench_decision.py, gliner2_gate0.py, verify_phase{1,2}_*.py, ...
gliner.sh  annotated llama-server launcher with the reasoning for each flag

Models

file size argmax flips vs f16 max |dp|
gliner-decision-f16.gguf 836 MB β€” (reference) β€”
gliner-decision-q8_0.gguf 446 MB 0 / 40 0.0109
gliner-decision-q4_k_m.gguf 286 MB 2 / 40 0.0632

Measured over a pinned 10-state Γ— 4-question probe set (40 comparisons), CPU backend, scripts/bench_decision.py. Per-question probabilities, sha256s and raw output are in bench/results.json.

Use Q8_0. It is 1.9x smaller than f16 and flips nothing on this probe. Q4_K_M is smaller again and its two flips are knife-edge rather than failures β€” one crossed the noul 0.5 threshold by 0.021, the other sat on a case where f16's own top-two margin was 0.0035 β€” but on a genuinely near-tied question it is the one that will move. Validate it against your own data before relying on it.


Calibration: use short label keys

This is the one thing worth reading before you write a single request.

The probe set ran the same 10 states twice against the same server, once with short label keys and once with sentence descriptions. Median top-minus-runner-up margin:

criteria style example median margin
short keys {"routine": null, "soon": null, "emergency": null} 0.2375
sentences {"routine": "can wait several days, no deadline pressure", ...} 0.0512

Short keys separate 4.6x better.

This is the opposite of the upstream blog post, which measured the same effect in the other direction for a different model β€” describing the options helped Julia-1 route "I was charged twice" from shipping to billing. Here it hurts. GLiNER scores one marker token per label, so a long criteria string dilutes the marker instead of sharpening it.

Write labels, not sentences:

"criteria": { "routine": null, "soon": null, "emergency": null }   // good
"criteria": { "routine": "can wait several days", "soon": "today" } // diluted

This also decides your quantization risk. A 0.05 median margin is only about 2x the Q4_K_M drift of 0.0632, so a poorly-separated question can flip under Q4_K_M; the same question under Q8_0 (drift 0.0109) will not. If your label set cannot be expressed as short keys, stay on Q8_0.


Question types

Three, all answered in one forward pass over the state.

type you send you get
choice an option set, each with an optional description top option + probability per option + confidence
score 2–10 ordered levels, lowest first expected level (may fall between two) + probabilities
noul a yes/no question probability of yes

Questions in one request are answered independently, and the state is processed once. state may be a string or a chat-message list. This model is text-only.


What changed on 2026-10-04 (and why older copies are wrong)

Earlier revisions of this repository published gliner2-decide-encoder-{f32,q4_k,q8_0}.gguf. Those were never decision models. They were the Sep 26 encoder-only conversion, quantized before the decision head was merged, and they carried no decision.* metadata at all. The check is the deberta-v3.decision.type key, which only exists when a head is present:

$ grep -ac 'deberta-v3\.decision\.type' gliner-decision-q8_0.gguf      # -> 1
$ grep -ac 'decision\.head_hidden\.weight' gliner-decision-q8_0.gguf   # -> 1
$ grep -ac 'deberta-v3\.decision\.type' gliner2-decide-encoder-q4_k.gguf   # -> 0

(Count lines rather than use grep -q: on binary data several keys can share a line, and what you want is the count of distinct markers present.)

Served against them, llama.cpp correctly answers 501 This model is not a decision model. The file even announced itself as general.name = "Gliner Decide Staged", so it identified as a decision model while holding none of one. Nothing caught it because it could not answer a single decision question, so it was never load-bearing for anything.

The rule this illustrates: merge the decision head before quantizing, then quantize the whole thing. Merging a head into an already-quantized base is a known footgun β€” on llama.cpp PR #29818 it flipped argmax on 10/17 questions with max |dp| 0.875. llama-quantize itself handles decision heads correctly; the ordering is the entire discipline.

If you pinned an older copy of this repo, the filenames you have do not match what is published now.


Quantization and the decision head

You may see older advice saying "do not quantize the decision head". That is half right, and the current measurement says so more precisely. After llama-quantize:

tensor f16 q8_0 q4_k_m
decision.head_hidden.weight [1024, 2048] F16 Q8_0 Q4_K
decision.head_hidden.bias [2048] F32 F32 F32
decision.scorer_out.weight [2048, 1] F16 F16 F16
decision.scorer_out.bias [1] F32 F32 F32

The 2M-parameter hidden matrix is quantized, and Q8_0 still produces zero flips. The final output projection is protected automatically: llama-quantize will not quantize a 1-wide tensor, so biases and the output layer stay at high precision on their own. Quantization decisions can be left to the tool.

(The quantized files record scorer_out.weight with shape [2048] rather than [2048, 1]; ggml drops trailing unit dimensions on quantize. Both are 2048 elements and it is read by name. Not a defect.)


Patches

patches/0001-deberta-v3-upstream-port.mbox is the port as a reviewable mail box, against upstream. Plain upstream master already contains the decision support (COMMON_DECISION_TYPE_GLINER in common/common.h), so you only need this patch for older checkouts. patches/deberta-v3-upstream-port.diff is the same change as a plain diff, kept for tools that do not read mbox.


Status

milestone state
serves through upstream /v1/systemone pass β€” HTTP 200, verified end to end
decision head merged into GGUF, pre-quantization pass
q8_0 quantization drift vs f16 pass β€” 0/40 flips, max|dp| 0.0109
q4_k_m quantization drift vs f16 2/40 flips, both knife-edge
block structure vs PyTorch (embedding, attention, FFN, both post-LN residuals) pass β€” max|d| 0.00e+00 across all 24 layers
end-to-end parity vs PyTorch not closed
vision input not supported (text-only)

Read the third row from the bottom carefully. Every individual block matches PyTorch's output_hidden_states=True exactly, yet the end-to-end residual against a true float64 reference is not closed. It is deterministic β€” bit- identical at -t 1 and -t 8 β€” and the graph is not transposed or mis-scaled, which rules out the obvious structural explanations. A per-layer error profile has not been run, and that is the measurement which would distinguish accumulated numerical error from a localized fault. Until it is, treat this as a working decision model, not a parity-cleared one, and check it against PyTorch on your own data.


Scripts

script purpose
bench_decision.py quantization drift and separation, as in bench/results.json
gliner2_gate0.py validates the head against the torch reference in numpy (max_abs_delta 5.9e-07); regenerates the prompt serialisation
verify_phase1_gguf.py tensor inventory and bucket-function check (512Γ—512 vs make_log_bucket_position)
verify_phase2_parity.py full-prompt parity against the PyTorch reference
dump_torch_hidden.py per-token torch hidden states, for the parity gate
stage_encoder.py builds the decision GGUF from the HF checkpoint

Run the bench with:

python3 scripts/bench_decision.py --server $(which llama-server) \
    --models models/gliner-decision-f16.gguf models/gliner-decision-q8_0.gguf \
    --out bench/results.json

The first --models entry is the reference. It runs CPU-only (-ngl 0) so the numbers reproduce anywhere.

Downloads last month
233
GGUF
Model size
0.4B params
Architecture
deberta-v3
Hardware compatibility
Log In to add your hardware

4-bit

8-bit

16-bit

Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support