Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5 8123021ea4 fix(paperless-eval): say whether the probe is loading or just not scheduled
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The Hermes probe reported "never became healthy" after an hour. It had not
failed to load — it had never been scheduled, because both iGPUs were busy with
other jobs and there are only two.

Same blind spot as the eval pods sitting in Init: from outside, "waiting" and
"will never start" look identical, and the message blamed the model. It now
prints the pod phase every two minutes and exits immediately with the
scheduler's own reason when it cannot place the pod.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 16:03:23 +02:00
claude-botandClaude Opus 5 1ec6825b66 feat(paperless-eval): erase the slot between documents so failures are independent
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The measurements were coupled. One request that corrupts the slot leaves it
returning a single repeated token for everything after it, so a run counted
that fault once per remaining document. "Qwen3-8B failed 4 of 5" was really
"document 1970 succeeded, document 363 broke the slot, and the remaining three
measured the wreckage".

Proved mid-run rather than inferred: with two documents failed, the model
answered "Nenne die Hauptstadt von Frankreich" with

    " \" \" \" \" \" \" \" \" \" \" ...

llama-server clears a slot on POST /slots/0?action=erase, but answers 501
without --slot-save-path, so the flag goes on the sidecar and eval.py calls it
after every AI document. It costs the prefix cache and some prefill speed,
which is the right trade for numbers that mean what they say.

Also worth recording: a synthetic reproduction of the poisoning failed. The
real paperless request corrupts the slot; my own 5000-token probe with the same
14-field schema does not, and the canary stayed healthy through it. Whatever
triggers this lives in the parts of a real request I was not reproducing — the
system prompt, the RAG context, the full taxonomy lists. Third time today that
a synthesised test passed where the real one fails, so the harness should stop
trying to stand in for the real request and instead detect when it has gone
wrong, which is what the canary now does.

Re-screening qwen3-8b to find out whether it scores more than one of five once
the documents are actually independent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:44:45 +02:00
claude-botandClaude Opus 5 3f50fa4e60 fix(paperless-eval): check the instrument at the end too, not only on failure
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The canary ran before the documents, every tenth document, and on abort. A
five-document screen whose first document succeeds trips none of those after
the first, so it could finish having never re-verified the instrument it just
used — which is the exact hole the canary exists to close.

It now runs at the end of every AI engine pass regardless of outcome. Healthy
before and broken after means the failures in between are one fault, and the
run says so rather than leaving it to be worked out later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:31:07 +02:00
claude-botandClaude Opus 5 f54b1dc7fa fix(paperless-eval): test for the defect, not for the right answer
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Third false verdict from this script in a row, all the same shape: it asked
whether "Paris" came back, and a working model failed twice for reasons that
had nothing to do with the defect.

  read only `content`     -> a thinking model writes to reasoning_content
  64-token budget         -> a thinking model never reaches the answer

Both were my bounds, not the model's, which is the error that has run through
this whole exercise.

So it now tests the defect itself. The signature is a single token repeated —
" de de de", " phosphorus phosphorus", whitespace — and a run is BROKEN when
one token is more than half the output, EMPTY when nothing comes back. That
survives truncation, does not care which field the model writes to, and does
not require the model to finish or to be right.

The general lesson, since it cost three attempts: an implausible reading is the
only kind that gets checked. BROKEN at 154 prompt tokens was obviously wrong,
so it got investigated. A boundary reported at 4000 would have been believed —
it would have fit the expectation. That is exactly how the 768-token cap, the
Q8 comparison and the 35B threshold all survived as long as they did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:13:02 +02:00
claude-botandClaude Opus 5 63ae57ce6e fix(paperless-eval): the probe read one output field and failed a working model
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
First run of probe-length.sh called Qwen3-8B BROKEN at 154 prompt tokens. No
model degenerates at 154 tokens — the verdict was wrong, not the model.

It read only `content`, and a thinking model puts its answer in
`reasoning_content` and leaves content empty. Empty content is not a broken
model, and this is the same mistake that made Qwen3.6 look slow and broken this
morning before --reasoning off went in.

Now reads both fields.

Noting it because the script exists specifically to stop me judging models on
bad readings, and its first reading was bad. A tool that checks the instrument
still has to be checked itself — the 154 is what gave it away, since it was
implausible rather than merely unwelcome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:11:13 +02:00
claude-botandClaude Opus 5 73db641d47 chore(paperless-eval): re-enable the rejected candidates as probe targets
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Not for scoring. Their classification numbers were taken against a slot that
may already have been corrupted, so the only honest use for them now is finding
where each one's prompt-length boundary actually sits.

Also repairs qwen3-30b-a3b's row, which kept the old repo:QUANT shape because
it was commented out when the file moved to explicit filenames.

Probing hermes4-14b and qwen3-8b first: both were written up as capability
failures on evidence that cannot support it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:02:33 +02:00
claude-botandClaude Opus 5 220bf6bfc3 feat(paperless-eval): check the instrument, don't assume it
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
The tests have to get better, and this is the change that would have caught
today's whole error class on document one instead of after six candidates.

Every AI run now asks "Nenne die Hauptstadt von Frankreich" before the
documents, every ten documents, and again when it aborts. No Paris, no run.

Why it goes mid-run and not only at the start: failures are not always
clustered. Ministral-3 3B failed 29 of 50 scattered through the run, which a
start-of-run check would have passed straight through. A slot that dies at
document 12 otherwise produces 38 more "failures" that are one event.

What the harness did wrong, so it is written down rather than relearned:

  - it measured classification accuracy while a confound (slot corruption)
    dominated, and never established that it could tell the two apart
  - it counted sequential failures on a shared slot as independent evidence
  - it recorded status codes and token counts and never once looked at what the
    model actually emitted — twice the answer was one curl away
  - it treated my own bounds, --n-predict 768 and a 300 s timeout, as
    properties of the models hitting them

A run whose canary breaks is void, not a result. Failures adjacent to the break
are one fault, and probe-length.sh says where the model's boundary really is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:01:45 +02:00
claude-botandClaude Opus 5 85768cd7e4 feat(paperless-eval): probe the length boundary — the test that should have been first
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Everything this harness measured about failing candidates was the wrong thing.

On this Vulkan/Iris Xe stack every model has a prompt length past which
llama.cpp stops producing language and repeats a single token — " de de de",
" the the the", " phosphorus phosphorus", or whitespace. Silent: HTTP 200,
finish_reason "stop", nothing in the log. And stateful: one request past the
boundary breaks the slot for everything after it until the pod restarts.

Which means a 50-document run measures where each model's boundary sits
relative to paperless's ~5300-token request, and nothing else. "Failed 3 of 3"
is one measurement, not three — the first document took the other two with it.

Demonstrated on gemma-4-26B-A4B, which I had written up as failing on
capability:

    fresh pod, "Nenne die Hauptstadt von Frankreich"   -> "Die Hauptstadt von
                                                          Frankreich ist Paris."
    same pod after one 5000-token request              -> " phosphorus phosphorus..."
    fresh pod, 2334-token prompt, no tools at all      -> whitespace

The model is fine. Its boundary is under 2334 tokens, so it can never serve a
paperless request, and every classification number I recorded for it described
a corrupted slot.

So the size threshold, the generation theory and the capability rankings are
all withdrawn. What correlated with success was having a boundary above 5300 —
Qwen3.6 measured coherent at 13588 — not being larger or newer.

probe-length.sh answers that in minutes instead of ninety. A fresh pod per
size, because probing sizes in sequence against one server would measure the
first bad request and nothing after it.

The galling part is that this defect is already documented in my own notes from
this morning, boundary and slot-poisoning both, and I did not apply it when
reading the results.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 14:52:22 +02:00
claude-botandClaude Opus 5 5f2955cdde docs(paperless-eval): the Q8 comparison was invalid, and why
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Final numbers were 30 scored, 20 failed — and all 20 failures sat exactly on
the 768-token cap. That does not measure precision, it measures how often a
wordier model hits a bound I chose. Discarded rather than recorded.

Q8's uncapped answers run 460-520 tokens against Q4's 280-443, roughly 60%
longer for the same task, which is enough to turn one cap hit in fifty into
twenty. Re-running at --n-predict 2048 with a 600 s client bound.

Q4's baseline stays comparable despite running at 768: it hit that cap once in
fifty documents, so raising it can move at most one document. Stated rather
than assumed.

README now documents both bounds as harness properties rather than model
properties, with the rule that follows: before comparing two configurations,
check that neither is sitting on the cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 13:56:33 +02:00
claude-botandClaude Opus 5 7136a39f3a fix(paperless-eval): the client timeout is a second bound, and it hid the first
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
Retrying gemma-4 at --n-predict 4096 did not answer the question — it moved the
wall. At 6.6 tok/s a 4096-token budget needs 620 s of generation, and the
client bound was still 300, so the run failed with TimeoutError instead of
hitting the cap. Two bounds of mine, and lifting one just exposed the other.

EVAL_TIMEOUT is now an environment variable and run-candidate.sh takes it as a
fourth argument, so a diagnostic can raise both together:

    ./run-candidate.sh gemma4-26b-a4b 3 4096 900

What the aborted attempt did show: gemma-4 reached 1634 generated tokens and
was still going when the clock ran out. A model needing 900 tokens for a valid
call would have finished long before that, so this looks like genuine
non-termination rather than a cap set too tight — but "looks like" is exactly
the reasoning that put 768 in here unexamined, so it is running properly now.

The same caveat applies to the Q8 comparison and I have not resolved it. Q8's
uncapped responses run 460-520 tokens against Q4's 280-443, roughly 60% longer,
and 20 of its requests hit 768 against Q4's one. So "Q8 performs worse" may be
"Q8 is more verbose and my cap catches it", which is a much weaker claim. That
comparison needs redoing at a raised cap before its numbers mean anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 13:53:50 +02:00
claude-botandClaude Opus 5 4ba68b91aa fix(paperless-eval): 768 is my number, not the model's — make it settable
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Fair challenge I had not put to myself: --n-predict 768 is a bound I chose this
morning because Qwen3.6 once ran to 10269 tokens and cost 900 s a document. It
is not a property of any model.

Which means every candidate recorded as failing at exactly 768 generated
tokens — Hermes-4-14B, gemma-4-26B-A4B, both Ministrals, Granite, Qwen3-8B — is
failing against MY bound, and I wrote them up as incapable. A model that needs
900 tokens to emit a valid tool call is indistinguishable, in that record, from
one that never terminates at all.

run-candidate.sh takes the cap as a third argument:

    ./run-candidate.sh gemma4-26b-a4b 5 4096

gemma-4 is re-running at 4096 against the same three documents it failed, since
it is the newest model in the field and the cleanest failure of the set. If it
produces valid calls given room, six rejections in this file are wrong and the
size threshold I committed an hour ago is built on an artefact. If it still
runs to the new cap, the cap only changed how long it took to find out.

Worth the node time either way. A working answer takes 241-291 tokens, so 768
looked generous — but "looked generous" is not evidence, and I should have
tested the bound before drawing conclusions from models hitting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 13:46:02 +02:00
claude-botandClaude Opus 5 47ed4383f1 docs(paperless-eval): the size threshold survives a deliberate attempt to kill it
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
gemma-4-26B-A4B aborted 3 of 3, all HTTP 400 rather than timeouts, and the
server log shows it ran to the --n-predict cap on every one — the same
capability failure as the small dense models, not a template mismatch.

It was chosen precisely because it could have refuted the threshold: newest
generation in the whole field, 26B total. It did not.

  Qwen3.6-35B-A3B   35B   newest    works
  Qwen3.5-35B-A3B   35B   older     works
  Qwen3-30B-A3B     30B   oldest    fails
  gemma-4-26B-A4B   26B   NEWEST    fails
  Hermes-4-14B      14B   —         fails
  dense 3-8B        —     —         fail

Two generations work at 35B total; everything at 30B and below fails. After
three hypotheses that died on contact, this is the first one that predicted a
result in advance and was right, so it becomes the operating rule: do not spend
a screening slot on anything under ~35B total.

Deliberately not pinning it further. Whether the line is at 31B or 34B is
interesting and changes no decision — there are two working candidates and the
useful next number is the second one measured over all 50 documents, which is
now running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 13:36:43 +02:00
claude-botandClaude Opus 5 ec849f2e29 docs(paperless-eval): glm47-flash is out on speed, and settles nothing
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Aborted 3 of 3 — but every failure was a TimeoutError rather than an invalid
answer, so the experiment does not answer the question it was started for.
Document 1 did reach the --n-predict cap without producing a tool call; 2 and 3
were cut off by the 300 s client bound before anything could be concluded.

Disqualified on speed regardless, and that part is unambiguous: 31 tok/s
prefill against Qwen3.6's 100 and a dense 8B's 240, plus 4.9 tok/s decode. That
is over seven minutes per document, about six hours for a full run.

Prefill is now recorded in candidates.tsv as a selection criterion. It varies
more than sixfold across architectures and is roughly half of a run's wall
clock, and I had not been tracking it at all.

gemma4-26b-a4b replaces it as the size-versus-generation discriminator: 26B
total, well under 35, newest generation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 12:50:58 +02:00
claude-botandClaude Opus 5 43634e46c9 fix(paperless-eval): the startup budget was sized for the wrong thing
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
GLM-4.7-Flash was killed mid-download at 17.8 GB and restarted from the
beginning. The startupProbe allowed 60 * 20s = 20 minutes, and the comment
explaining that number gives the reason it was wrong: "~20 GB of weights off
CephFS". It was sized for reading a warm PVC, not for a first download from
HuggingFace, which is what actually happens the first time a candidate runs.

Too small a budget here is not a slow start — the container is killed, restarts,
and downloads again, so it never finishes. Raised to 180 * 20s = 60 minutes.

Worth writing down because the signature is misleading: a probe kill shows as
exitCode 137 with reason "Error", not OOMKilled, and 137 reads like a memory
limit. The pod meanwhile sits in Init, which by now has hidden three separate
faults in this harness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 12:31:03 +02:00
claude-botandClaude Opus 5 7658cf8bb6 docs(paperless-eval): precision is nearly free on this MoE, and my formula was wrong
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Measured, same model, same documents, only the quantisation differs:

    Qwen3.6-35B-A3B Q4_K_M   ~21 GB   11.0 tok/s
    Qwen3.6-35B-A3B Q8_0     ~34 GB   10.3 tok/s

I predicted ~6 tok/s for Q8 — a halving, on the reasoning that doubling bytes
per active parameter doubles what crosses the memory bus. It cost ten percent.

So decode is not bandwidth-bound for this MoE at all. It is compute-bound on
many small per-expert matmuls, which retroactively explains a measurement I
recorded but never followed through: the MoE reached 21.4 GB/s against a
53.9 GB/s ceiling. It was already leaving 60% of the bus idle, so more bytes
cost almost nothing.

That formula has been steering decisions in this file — it is what ranked
candidates by size and what I used to predict runtimes. It predicts dense
models and not this one.

Practical consequence: higher precision is nearly free on an MoE here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 12:19:58 +02:00
claude-botandClaude Opus 5 1ae0e59ea6 docs(paperless-eval): qwen3.5-35b-a3b passes, killing the generation theory
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
5 of 5, no failures, 584 s. Qwen3.5 is a generation older than the working
Qwen3.6 and handles the 14-field schema cleanly, so "only the newest models can
do this" does not survive either. That is the third hypothesis to die here.

What the two working models share is 35B total parameters against the failing
MoE's 30B:

  Qwen3.6-35B-A3B   35B total   newest   works
  Qwen3.5-35B-A3B   35B total   older    works
  Qwen3-30B-A3B     30B total   oldest   4 of 5 failed

Confounded, and I am not going to call it. Qwen3-30B-A3B is both smaller and
older; three points cannot separate size from generation.

glm47-flash is the experiment that can: 30B total, newest generation. Works
means generation matters and 35B is not a threshold. Fails means total size is,
and there is no point screening anything below ~35B.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 12:04:33 +02:00
claude-botandClaude Opus 5 faebeb1594 fix(paperless-eval): the crashloop was a full disk, not an ambiguous reference
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Correcting the previous commit, which named the wrong cause with some
confidence. The llama-models volume was 100% full — 120 GiB, nothing free.
llama-server could not write its metadata cache, so it could not resolve the
repository listing, and reported

    no GGUF files found in repository <repo>
    Available GGUF files:
      - <whatever it had cached before the disk filled>

That reads as a bad model reference, and I read it that way. It is a full disk.

Nine screened candidates filled the volume: 2.5 GB for a small dense model,
17-25 GB for an MoE, 34 GB for a Q8. Freed 56.5 GB by removing the weights of
candidates already rejected — they are re-downloadable, so that costs only
bandwidth.

--hf-file stays. Naming the exact file removes a genuine ambiguity (ggml-org
ships mmproj-, mtp- and dflash- variants at Q8_0 beside the model) and costs
nothing. It just was not the fault here, and the comment claiming it was is
rewritten.

README now says to check `df -h /models` before believing anything llama-server
says about a repository.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 11:48:14 +02:00
claude-botandClaude Opus 5 baf3b3e1b7 fix(paperless-eval): name the exact .gguf, the :QUANT shorthand is ambiguous
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
Two jobs crashlooped for three hours without producing a single document, and
I did not notice because the pods sat in Init the whole time — which looks
exactly like a slow model download.

    llama_model_load_from_file_impl: exactly one out metadata, path_model,
    and file must be defined

That reads as a loading error. It is an ambiguous reference. `--hf-repo
repo:QUANT` matches by substring across the entire repository, and repos
routinely carry several files bearing one tag: ggml-org ships mmproj-, mtp- and
dflash- variants at Q8_0 beside the model, bartowski ships
kld_results/kld_Q4_K_M.txt beside the weights. Q4_K_M happened to be
unambiguous for the baseline, which is why this never surfaced before.

candidates.tsv now carries the exact filename as its own column, resolved from
the HF API for all fifteen entries, and run-candidate.sh refuses to start a
candidate that has none rather than guessing. --hf-file overrides the quant.

Also fixing the monitoring gap that hid it. The watch treated PodInitializing
as "keep waiting" indefinitely, so a container failing to start was
indistinguishable from one still pulling 34 GB. It now checks init container
states for CrashLoopBackOff/Error/ImagePullBackOff and reports with the last
lines of the failing container's log.

Silence was not success, and the tooling should have said so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 11:45:00 +02:00
claude-botandClaude Opus 5 319968c0f1 docs(paperless-eval): the MoE hypothesis is dead too
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
qwen3-30b-a3b — MoE, 30B total, 3B active, the shape that was supposed to
explain the whole pattern — scored 1 of 5 and failed 4. Indistinguishable from
the dense 8B it was meant to outclass.

That is the second architectural theory to die here. First "a smaller dense
model gains at both ends", which the timings contradicted. Now "capability
comes from total parameters, so MoE gets it cheaply".

  Qwen3.6-35B-A3B   MoE 35B/3B   Apr 2026   2 of 50 failed
  Qwen3-30B-A3B     MoE 30B/3B   older      4 of 5 failed
  Qwen3-8B          dense 8B     older      4 of 5 failed
  Hermes-4-14B      dense 14B    —          3 of 3 failed

Neither architecture nor parameter count predicts success. The only thing that
correlates so far is how recent and capable the model is — which is an
unsatisfying hypothesis, because unlike the other two it predicts nothing
cheaply and cannot be used to narrow a search.

qwen35-35b-a3b is the experiment that isolates it: identical architecture,
total and active parameters to the working baseline, one generation older. If
it fails, generation is the variable and every shape argument in this file was
noise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 08:41:16 +02:00
claude-botandClaude Opus 5 043d1af4d4 feat(paperless-eval): four more MoE candidates, and Gemma was wrongly excluded
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Five dense models have failed and the one MoE works, so the list was searched
again for that shape specifically — 20-60B total, under ~6B active:

  glm47-flash          GLM-4.7-Flash, 30B/3B, 17.8 GB
  gemma4-26b-a4b       gemma-4-26B-A4B-it, 26B/4B, 16.9 GB
  qwen35-35b-a3b       Qwen3.5-35B-A3B, 35B/3B, 21.5 GB
  nemotron35-30b-a3b   Nemotron-3.5-Lightning-30B-A3B, 30B/3B, 25.5 GB

Correcting an exclusion of mine: Gemma is not out. Gemma 3 has no tool support
at any size — verified, "tool" appears zero times in its template — but Gemma 4
26B-A4B does, and claims 140+ languages pretrained. Writing off a family by its
previous generation was sloppy.

Two carry a Vulkan risk that has to be checked in the server log rather than
assumed: Nemotron-3.5 interleaves real Mamba2 layers and Qwen3.5 uses DeltaNet
linear attention, and llama.cpp's Vulkan SSM shaders are less exercised on Iris
Xe than on Arc. Issue #19957 reported exactly this for Qwen3.5-35B-A3B, and it
turned out to be a stale build fixed well before b10731 — but a silent CPU
fallback would read as a slow model, not as an error, so confirm the layers are
offloaded.

Rejections with reasons are in the file: ERNIE-4.5-21B-A3B has tool calling
only in its Thinking variant, Ling-3.0-flash is 124B total, Granite-4.0-H-Small
is 9B active, gpt-oss-20b is a reasoning-effort model in Harmony format.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 08:30:45 +02:00
claude-botandClaude Opus 5 11b0def055 docs(paperless-eval): five dense models fail, the one MoE works
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Screening results so far, and they point somewhere I did not expect:

  qwen35        MoE 35B total / 3B active   works, 2 of 50 failed
  qwen3-8b      dense 8B                    4 of 5 failed
  hermes4-14b   dense 14B                   3 of 3 failed, aborted
  ministral8b   dense 8B                    3 of 3 failed, aborted
  ministral3b   dense 3B                    29 of 50 failed
  granite8b     dense 8B                    every document failed

Hermes is the cleanest failure of the set and the reason this is worth writing
down. Its <tool_call> JSON is an explicitly tested fixture in llama.cpp's own
test suite, so the parser demonstrably handles the format — the failure has to
be the model, not the plumbing. That also retroactively weakens the Granite
verdict, which I attributed to llama.cpp alone.

Working hypothesis: holding a long grammar-constrained structure needs model
capacity, and what supplies it is total parameters rather than active ones.
qwen3-30b-a3b tests it directly at 30B total / 3B active.

If it holds, the search for a smaller faster model is over before it started —
and the consolation is that the fastest shape on this hardware is the only one
that works, since decode cost scales with active parameters while capability
appears to scale with total.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 08:17:14 +02:00
claude-botandClaude Opus 5 71a4c1b3a7 feat(paperless-eval): measure what Q4 costs, instead of assuming it costs nothing
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Everything in this comparison is Q4_K_M, including the baseline, and the
quantisation damage has never been measured. That is an assumption sitting
underneath every number in the README.

ggml-org ships Q4_K_M, Q8_0 and BF16 of Qwen3.6-35B-A3B in one repo, so the
control is exact: same model, same 50 documents, same flags, precision the only
variable.

There is a specific suspicion behind this, not just diligence. Ministral-3
leaking [THINK] and [TOOL_CALLS] into a tool call as literal text is the kind
of fault 4-bit quantisation can produce, and Q8 would separate "the model
cannot do this" from "Q4 broke it". Three candidates have been rejected on
behaviour that no one has ruled out as a quantisation artefact.

Q8 before BF16 deliberately: ~34 GB and ~6 tok/s against Q4's ~21 GB and
11 tok/s, so roughly double the wall clock, where BF16 would be ~65 GB at
~3.5 tok/s and near five hours. If Q8 matches Q4 within the noise floor, BF16
almost certainly does too and the expensive run is unnecessary. If it does not,
BF16 becomes worth its cost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 07:57:42 +02:00
claude-botandClaude Opus 5 0c46a219e3 feat(paperless-eval): eleven verified candidates, and what the search ruled out
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Delegated research across nine model families, every repo:quant checked against
the HF API by filename and byte size, and every chat template inspected for
tool support rather than assumed.

Ordered by the corrected size rule — MoE with few active parameters first,
then dense 3-9B with German named in the model's own card, then the rest:

  qwen3-30b-a3b       Qwen3-30B-A3B-Instruct-2507, 18.6 GB, ~3B active
  nemotron3-30b-a3b   Nemotron-3-Nano-30B-A3B, 24.7 GB, ~3B active
  glm4-9b             GLM-4-9B-Chat, 5.8 GB, BFCL 81.00, German explicit
  commandr7b          Command-R7B, 4.7 GB, native tool grammar, German explicit
  qwen25-7b           Qwen2.5-7B, 4.7 GB, German named, no thinking mode at all
  llama31-8b          Llama-3.1-8B, 4.9 GB, German in Meta's official set
  nemotron-nano-12b   Nemotron-Nano-12B-v2, 7.0 GB, German verified
  phi4-mini           Phi-4-mini, 2.5 GB, German documented
  qwen3-4b-2507       Qwen3-4B-Instruct-2507, 2.5 GB
  qwen35-9b           Qwen3.5-9B, 6.2 GB

The exclusions are in the file too, because they cost real search time and
should not be re-derived. Gemma 3 at every size, phi-4 14B, Aya and InternLM
have no tool support in their templates at all — inspected, not inferred.
Falcon 3 states EN/FR/ES/PT and German is simply not on it. Every German
fine-tune checked (SauerkrautLM, DiscoLM, LeoLM, Occiglot, Teuken) overwrites
its base template with a minimal ChatML loop that drops tool calling, even
where the base had it.

The most useful thing found explains the Granite failure. Current llama.cpp has
no per-vendor tool-call parser table; common/chat.cpp derives the grammar from
the model's own template via a PEG auto-parser. Hermes-style <tool_call> JSON
is an explicit tested fixture; Granite's shape is not. So the earlier note
saying llama.cpp "cannot parse Granite" is better read as its template being
outside what the auto-parser was validated against.

Two candidates dropped after verification: Qwen3.8-27B is dense 27.8B at
19 GB, on the wrong side of the size rule, and carries the only negative
German signal found anywhere — users reporting fabricated German words on its
own HF discussion page. aya-expanse-8b has no tool support.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 07:51:35 +02:00
claude-botandClaude Opus 5 17e23202d1 feat(paperless-eval): second wave of candidates, and the size rule was backwards
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
Eight candidates now, every repo:quant verified present via the HF API and
every one a ggml-org / bartowski / lmstudio-community / unsloth conversion
rather than the vendor's own.

The selection rule in this file was wrong and is corrected. It said a smaller
dense model gains at both ends, and the Ministral pair was chosen on that
basis. Measured:

    Ministral-3 8B dense, ~4.7 GB weights   7.1-8.2 tok/s
    Qwen3.6 35B-A3B MoE,  ~20 GB weights    11 tok/s

The largest model is the fastest one here. Decode is bandwidth-bound on the
ACTIVE parameter set, which for an A3B mixture is ~3B however much is resident;
total size only has to fit, and 96 GiB per node means it always does. So the
order is MoE with few active parameters, then dense 3-9B, then dense 10-14B
only for an exceptional model — that is ~4 tok/s, near three hours for 50
documents — and nothing dense above 14B.

hermes4-14b is in the list despite being on the wrong side of that line: five
screening documents are cheap, and it answers whether llama.cpp's named
Hermes tool-call parser handles what Granite's format could not. That answer
transfers to smaller Hermes sizes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 07:50:05 +02:00
claude-botandClaude Opus 5 851e1b9ef6 feat(paperless-eval): screen candidates at five documents before measuring
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The setup is now good enough to test everything with a chance, so the gate
matters more than any single run. run-candidate.sh takes a sample size:

    ./run-candidate.sh <label> 5     screen
    ./run-candidate.sh <label>       measure

With ABORT_AFTER a hopeless candidate is rejected in about five minutes. Three
of the first four were rejections, and each was left running for an hour or
more first.

Screening deliberately uses genuine paperless requests rather than
probe-toolcall.sh. The standalone probe is cheaper and still worth a first
pass, but it passed two candidates that failed on document one — a synthesised
request cannot reproduce the RAG neighbours and the taxonomy candidate lists
that make a real prompt what it is. Document 1970 is 1976 characters and still
produces a 5333-token request.

A screen is a gate, not a measurement. Numbers are never compared across
sample sizes; the comparison happens at 50.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 07:24:24 +02:00
claude-botandClaude Opus 5 f6b0789c01 feat(paperless-eval): both remaining candidates measured, and a noise floor
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Qwen3.6-35B-A3B re-measured with --reasoning off:
72.9 / 77.1 / 85.4 / 94.9 / 44.4, two failures, 5494 s. Complement 30 both
right, 5 rescued, 4 broken, 9 both wrong.

Ministral-3 3B: 21 scored, 29 failed. Its correspondent numbers over those 21
sit within a document of Qwen3.6's, which is not a near-tie — the 21 are the
documents it could answer at all, a set selected for being easy, and the
classifier does better on them too (11 of 21 both right against 30 of 48). A
model that declines three documents in five has no usable accuracy number.
Commented out.

The re-run was worth more than the row it produced. Both Qwen3.6 runs used the
same 50 documents and differ by exactly one document on every metric — one of
nine on the catchall. That is the noise floor, and it is now written down: a
difference between two candidates smaller than about two documents means
nothing. Decoding is sampled; the same model on the same input does not answer
the same way twice.

I had called the re-run "better" at the 10-document mark on the strength of a
0-vs-1 failure count. It finished 2-vs-2. That was the same mistake as reading
a probe pass as a verdict, and it is the reason the noise floor is in the
README rather than in my head.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 06:29:50 +02:00
claude-botandClaude Opus 5 44636a8268 docs(paperless-eval): ministral8b fails, cleanly this time
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The earlier attempt ended on three failures of which one was my own token
collision, so the verdict was not defensible. Re-run alone, with bartowski's
conversion, --reasoning off and its own API user: three documents, three
HTTP 400s, aborted after 531 s. Generation runs to the --n-predict cap and no
tool call ever comes back. Commented out.

The 3B of the same family gets through documents the 8B cannot, which is not
what I expected from a size ablation and is left enabled because it produces
real numbers with failures mixed in.

qwen35 is re-running on the freed node with --reasoning off. Its measured
result used the deprecated --chat-template-kwargs, and while that flag does
what it says on Qwen's template, a comparison table whose only filled row was
produced under different flags than everything after it is not one I would
trust.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 22:04:11 +02:00
claude-botandClaude Opus 5 440cbaec90 docs(paperless-eval): the conversion was not the whole story
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
I claimed bartowski's GGUF was "the whole difference between a working
candidate and a failing one" on the strength of a probe pass. The real run
disproved it: both conversions still run to the --n-predict cap against a
~5300-token paperless request.

What bartowski genuinely fixes is narrower — at probe scale mistralai's file
leaks [THINK] and [TOOL_CALLS] as text where bartowski's does not, and
llama-server defaults to -sp off, so those tokens are simply not marked special
in mistralai's conversion. That is worth knowing and is not sufficient.

Also correcting a verdict: Ministral-3 3B does not fail everything. Its run
never tripped ABORT_AFTER, which only fires when the first three documents all
fail — so at least one succeeded, and the absence of the abort was itself the
evidence. I read three error lines and called the model broken without noticing
what sat next to them.

probe-toolcall.sh now states plainly that a FAIL is conclusive and a PASS is
not, and why: it cannot reproduce the RAG neighbours or the taxonomy candidate
lists that inflate a real prompt. Document 1970 is 1976 characters and still
produces a 5333-token request. The real gate is ABORT_AFTER against genuine
requests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:54:06 +02:00
claude-botandClaude Opus 5 b64f971373 fix(paperless-eval): two concurrent runs were revoking each other's token
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Every parallel run so far was quietly broken, and it took a 401 sitting in the
middle of three genuine model failures to notice.

api_token() took the first superuser and deleted its existing token before
inserting a new one, because Django's authtoken holds one token per user. Both
candidates share this database, so the second job to start silently revoked the
first one's credentials. In the log it appears as

    50173: HTTP 401: b'{"detail":"Invalid token."}'

among HTTP 400s from the model, where it reads like one more broken document.
It was counted toward ABORT_AFTER, so a harness fault helped end a run and
would have been recorded as a model verdict.

Two changes. Each run now creates its own eval-<label> superuser, so the token
belongs to the run and concurrency is safe — only the user can differ, the
database cannot. And a 401 is now fatal with an explicit message rather than
another row in the error column: it is never a fact about the model, and every
number after it is worthless.

The qwen35 result predates this and ran alone, so it is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:42:20 +02:00
claude-botandClaude Opus 5 9ff85f7d1f fix(paperless-eval): probe at production prompt length too
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
The probe used the real 14-field schema but a 1191-token prompt, while a real
paperless request carries ~5300 tokens once the document and its RAG
neighbours are in. Ministral-3 8B passed the probe and then ran to the
--n-predict cap on the first document of the real run.

Whether that document is pathological or the probe is systematically too easy
is not yet settled — it has failed under every engine tried so far, including
the one that works. But a probe is only worth having if it fails where
production fails, and it costs nothing to make it realistic in both dimensions
rather than one. ~5000 tokens of German filler now precedes the document text.

This is the same mistake twice: the first version was too easy on schema
width, this one was too easy on prompt length, and both times a pass sent a
ninety-minute run after a model that could not do the job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:39:24 +02:00
claude-botandClaude Opus 5 22d92d158d feat(paperless-eval): probe tool calling in two minutes, not ninety
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
"These are all invocation errors" — correct, and I had written two of them off
as properties of the models.

probe-toolcall.sh starts a candidate, sends paperless's real 14-field
DocumentClassifierSchema, and passes only on finish_reason "tool_calls". The
payload is the actual schema rather than a stand-in, which is the entire point:
a three-field probe got a clean call out of Ministral-3 while the real schema
made it degenerate, and that false pass cost an hour of chasing prompt length.

What it turned up immediately:

  Ministral-3 8B and 3B pass with bartowski's conversion and fail with
  mistralai's own, at identical quant and flags. llama-server defaults to
  -sp off, so [THINK] and [TOOL_CALLS] should never have appeared as text at
  all — they are not marked special in mistralai's file. A conversion defect,
  not the model. Both candidates now point at bartowski and are running.

  Granite fails with bartowski too, finish_reason "stop" and no tool_calls, so
  its cause is different and unchanged: llama.cpp b10731 cannot read Granite's
  tool-call format back whoever converted it. The remaining lever there is
  --chat-template-file with a template the server can parse, or a newer build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:36:59 +02:00
claude-botandClaude Opus 5 b00ca34783 fix(paperless-eval): abort a run once it is clear nothing works
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Three candidates in a row failed every document, and each time I let it run.
The harness now stops after ABORT_AFTER = 3 consecutive failures from the
first document, because document one already said everything the remaining
forty-nine would.

Not a ratio — a model failing a third of the time is a result worth measuring
in full. This only catches "nothing has ever worked".

What the three were, since none of them is a verdict on the model:

  granite8b     classifies correctly, but llama.cpp b10731 hands its tool call
                back as content with finish_reason "stop" and no tool_calls
                array, so llama_index raises "Expected at least one tool call,
                but got 0".

  ministral8b   given paperless's real 14-field DocumentClassifierSchema, opens
  ministral3b   a valid tool call and then fills its first string with its own
                control tokens until the cap:
                    {"title": "[TOOL_CALLS][ARGS][THINK][/THINK][THINK]...
                The grammar allows any character inside a JSON string, so those
                are "valid" and it never closes.

I chased the Ministral failure as a prompt-length problem for an hour on the
strength of a three-field probe that worked. It reproduces at a 1191-token
prompt with the real schema, and a 5799-token prompt with the simple one gets a
clean tool call — the schema is the variable, not the length.

So the first thing to establish about a candidate is not German, or speed, or
classification quality. It is whether it can emit one valid call against a
14-field schema, and that is a two-minute probe rather than a ninety-minute
run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:15:05 +02:00
claude-botandClaude Opus 5 c2f47e58bc fix(paperless-eval): --reasoning off, not --chat-template-kwargs
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
I wrote in the last commit that passing '{"enable_thinking": false}' to every
candidate was a no-op on templates that never read the variable. That is
wrong, and it cost a run.

Ministral-3-8B produced token salad on a 681-token prompt — far below the ~14k
corruption boundary, so not that defect:

    1UnComplete1Self1UnCompleteSDirect11S1S111Document1CompleteFromS11Un...

Every document failed, and each one generated to the --n-predict cap. The
identical request against a server started without the flag returned a clean
tool call and the correct answer. Plain German was fine throughout — "Die
Hauptstadt von Frankreich ist Paris" — so it only ever broke with tools, which
is what made it look like a model that cannot do function calling.

llama-server prints "Setting 'enable_thinking' via --chat-template-kwargs is
deprecated. Use --reasoning on / --reasoning off instead." at startup. It means
it. --reasoning off is verified to give Ministral a proper tool_calls response
before this went near a real run.

Qwen3.6's template does consume enable_thinking, so its measured result stands
— but its run used the old flag, and the configuration should be uniform
across candidates before the numbers are compared. Worth a re-run.

granite8b is commented out of candidates.tsv, and this one is not my flag: it
classifies correctly and llama.cpp b10731 simply cannot read its tool calls
back, returning them as content with finish_reason "stop". Reproduced on an
idle server both with and without the flag. Details are in the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 20:58:30 +02:00
claude-botandClaude Opus 5 a7bba7acbb feat(paperless-eval): one manifest, a candidate list, and the first result
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The point of this harness is comparing chat models, and it was shaped for
comparing engines. Four candidates meant four 300-line copies of the same Job,
which is exactly how --chat-template-kwargs ended up on the isolation pod but
not on the first eval job — two hours of empty completions before anyone
looked.

So: job-template.yaml holds the sidecar configuration once, candidates.tsv
holds the five values that differ, and run-candidate.sh substitutes them.
Adding a model is a line, not a file.

Candidates, all first-party GGUFs verified to exist with a Q4_K_M:

  ministral8b   mistralai/Ministral-3-8B-Instruct-2512
  ministral3b   mistralai/Ministral-3-3B-Instruct-2512
  granite8b     ibm-granite/granite-3.3-8b-instruct

Instruct, never Reasoning — a model tuned to think first does by design what
cost Qwen3.6 two of fifty documents. Sizes are chosen because decode is
bandwidth-bound at ~11 tok/s and prefill was 51% of the first run's wall clock,
so a smaller dense model gains at both ends; 3B against 8B is the size
ablation, granite against ministral the family one.

The classifier stays in every run as a fixed reference line, not as a quality
bar: train() fits on every non-inbox document with no holdout, so the
evaluation documents are in its training set and its 100% on document type is
memorisation. Irrelevant to ranking models — same constant under all of them —
and the README now says so rather than inviting the comparison.

First result, Qwen3.6-35B-A3B Q4_K_M over 50 documents: correspondent 70.8
offered / 75.0 recognised against the classifier's 72.0, document type 83.3
against 100, and 33.3 against 100 on the "Sonstige" catch-all. 5 rescued
against 5 broken — but two of those five are difflib rejecting a correct
answer, "GOTOOLS GmbH" against the stored "GOTOOLS", an exact prefix scoring
~0.74 under MATCH_THRESHOLD = 0.8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 20:14:15 +02:00
claude-botandClaude Opus 5 cb751cbedc fix(paperless-eval): bound generation server-side; the model does not always stop
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Three of the first eleven documents in a run never terminated. One reached
10269 tokens on a classification that needs ~300, at a perfectly healthy
11 tok/s — so it read as slowness and was neither slow nor a context problem.

llama-server's --n-predict defaults to -1, infinity, and paperless sets no
token budget of its own and waits. Nothing anywhere bounded the request. Four
documents cost most of an hour.

--n-predict 768 on the sidecar caps it, and a truncated response is better
evidence than a timeout: it shows what the model was doing instead of
stopping. The client timeout drops to 300 s and becomes a backstop.

Also:

- the degeneracy check fired on 'Rechnung 2025-5425541' — 8 letters in 21
  characters, below the 40% Latin threshold. Digits and punctuation are normal
  in a title; look for characters from other scripts directly instead of
  inferring them from a shortage of Latin ones.

- the ctx-size comment still carried the claim bisection disproved in #777,
  that 32768 corrupts inference. The boundary is prompt length.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 14:51:56 +02:00
claude-botandClaude Opus 5 34c250ad2a fix(paperless-eval): two more, found by actually running it
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Ran the harness against the eval instance instead of waiting for review,
which is the point of it not being Flux-managed. Two faults surfaced in
the first two attempts, and the third run scored 10/10 cleanly.

seed-data used `cp -a`. Preserving ownership needs CHOWN, which the
container does not have, so cp copied everything and then exited
non-zero, and `set -e` killed the pod before any sidecar started. It
copies with `-r` now and runs as uid 1000, which also gets the ownership
right — paperless writes to the LLM index while serving, and a copy made
by root would have been read-only to it.

The degeneracy check fired on every classic row. The classifier answers
with resolved ids and no `title` at all, so "empty title" is its normal
shape rather than broken inference; the check now runs for the AI engine
only. Left as it was, every classic run would have reported zero scored
documents and a 100% degenerate rate.

Validated: 10 documents, 0 failed, 0 degenerate, correspondent 80.0%,
document type 100.0% — matching what the bash version measured against
production, so the port to Python changed nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 11:08:46 +02:00
claude-botandClaude Opus 5 e261089666 fix(paperless-eval): three faults the first job run exposed
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The Job from #776 ran and failed, which was more useful than it sounds.

It died before scoring anything: psycopg returns Postgres numerics as
Decimal, and `100.0 * top / total` raises TypeError rather than coercing.
Both operands are cast now.

It also seeded nothing. The golden volume was empty, `cp -a` copied
nothing, and `du ... || true` swallowed it — so the run would have
measured the no-RAG path and reported it as a RAG result, which is
precisely the failure the initContainer was written to prevent. It now
refuses to start when llm_index is under 1 MiB, and says how to build it.

And the script had no way to tell broken inference from a wrong guess.
Past roughly 14k prompt tokens this stack returns empty completions and
then runs of digits and CJK characters, with status 200 and
finish_reason "stop", and one such request poisons the llama.cpp slot for
everything after it. Scored as wrong answers those would quietly depress
whichever candidate happened to trip it, and take the rest of that run
down with it. They are flagged, excluded from the accuracy figures, and
counted separately — a non-zero `degenerate` means the run is suspect
from that point on rather than that the model is bad.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 11:01:19 +02:00
k.renner c3a5662f72 Merge pull request 'fix(llama-server): the limit is prompt length; take the eval harness out of GitOps' (#777) from eval-out-of-iac into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#777
2026-09-03 08:57:34 +00:00
claude-botandClaude Opus 5 c47c2e6bc1 refactor(paperless-eval): take the measurement harness out of GitOps
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
A Job is immutable and a measurement is finished when it finishes —
neither is something Flux should converge on. Re-running a configuration
means deleting and recreating the Job, and a managed one comes straight
back. Worse, eval.py was a kustomize-generated ConfigMap, so editing the
script between runs and applying it by hand would have been reverted on
the next reconcile: the iteration loop the harness exists for was closed
off by where it was put.

The namespace stays managed. It holds a database clone, PVCs and a
network policy — state that should converge and should vanish when the
Kustomization is removed. Only the harness moves to scripts/, alongside
the other tools nobody deploys, with a README covering how to run one,
how to add a candidate, and how to read the output.

Two failure modes are written down there rather than left to be
rediscovered: prompts past ~14k tokens make this stack emit garbage
silently and poison the slot until restart, and ground truth is a filing
convention that has drifted over ten years, so the comparison between
engines is the durable result rather than the absolute numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 10:52:58 +02:00
claude-botandClaude Opus 5 d4e1f49d78 docs(llama-server): the limit is prompt length, not --ctx-size
The comment blamed --ctx-size 32768 for corrupting inference. Bisection
on a scratch pod disproves that: at --ctx-size 65536 with a single slot,
so four times the headroom, the same server answers a 650-token prompt
correctly seven times in a row. What breaks it is how long the prompt is.

    13588 prompt tokens   coherent answer
    15086                 one token, immediate EOS, empty content
    16088                 CJK characters and digit runs

And one request past the boundary poisons the slot: the 13588-token
prompt that had just answered cleanly returns one empty token afterwards
and stays broken until the pod restarts. That explains what looked
intermittent, and why a 650-token classification once came back as 1025
tokens of "!" — it followed Home Assistant's ~19k prompts through the
same slot.

The practical consequence changes with it. The rule is not "keep this
flag low", it is "keep callers under roughly 14k tokens". Paperless sits
near 5k and was never at risk; Home Assistant's Assist prompt measured
18910 and never had a chance, which is a better explanation of its empty
completions and invented device list than the model being weak at long
contexts.

Nothing about the failure is visible from outside: finish_reason "stop",
a well-formed OpenAI response, HTTP 200, /health green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 10:52:04 +02:00
k.renner f7f041e71f Merge pull request 'feat(paperless-eval): run a measurement as one self-contained job' (#776) from paperless-eval-jobs into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#776
2026-09-03 07:51:59 +00:00
10 changed files with 1147 additions and 56 deletions
+30 -12
View File
@@ -75,19 +75,37 @@ spec:
# many small per-expert matmuls that sets the pace, not bandwidth.
- --threads
- "4"
# 16k, and going above it corrupts inference rather than failing.
# At 32768 per slot this server answered a 650-token classification
# prompt with 1025 tokens of "!" — the KV cache producing numbers
# rather than language. It does not crash, it does not warn, and
# the OpenAI response looks well-formed, so the damage only shows
# in what callers get back: Paperless ran past its 300 s timeout,
# and Home Assistant got an empty completion and a hallucinated
# device list.
# 16k is room to spare, not a safety limit. The limit that matters
# is not set here at all.
#
# The same Vulkan/Mesa stack already fails hard at --ubatch-size
# 2048; this is the quiet version of that limit. Raise this only
# against a generation that is read, not against a server that
# merely starts.
# Isolated by bisection on a scratch pod: this stack breaks on
# PROMPT length, and the value of --ctx-size makes no difference to
# it. Measured at --ctx-size 65536 with a single slot, so with four
# times the headroom:
#
# 13588 prompt tokens coherent answer
# 15086 one token, immediate EOS, empty content
# 16088 CJK characters and digit runs
#
# A single request past that boundary then poisons the slot: the
# same 13588-token prompt that answered cleanly a minute earlier
# returns one empty token afterwards, and stays broken until the
# pod restarts. That is why it looked intermittent, and why a
# 650-token classification once came back as 1025 tokens of "!" —
# it followed Home Assistant's ~19k prompts through the same slot.
#
# Nothing reports any of it: finish_reason "stop", a well-formed
# OpenAI response, HTTP 200, /health green.
#
# So the operating rule is about callers, not this flag. Paperless
# sits near 5k and is safe; Home Assistant's Assist prompt measured
# 18910 and never had a chance. An earlier version of this comment
# blamed --ctx-size 32768, which the bisection disproves — 32768
# and 65536 both answer correctly as long as the prompt stays under
# the boundary.
#
# The same stack also fails hard at --ubatch-size 2048, which is
# the loud version of a limit this one keeps quiet about.
#
- --ctx-size
- "16384"
@@ -1,17 +1,13 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: paperless-eval
# The instance only. The measurement harness lives in scripts/paperless-eval/
# and is applied by hand — a Job is immutable, re-running one means deleting and
# recreating it, and Flux would just put the old one back. Nothing here should
# converge on a finished measurement.
resources:
- secrets.yaml
- cluster.yaml
- pvcs.yaml
- redis.yaml
- paperless.yaml
- job-qwen35.yaml
configMapGenerator:
# Hash-suffixed, so editing eval.py produces a new ConfigMap name and any Job
# referencing it has to be recreated rather than silently running the old
# script against a new name.
- name: paperless-eval-script
files:
- eval.py
+198
View File
@@ -0,0 +1,198 @@
# Comparing chat models on Paperless suggestions
Which local LLM is worth running behind paperless-ngx's AI suggestions, measured
against documents a human already filed, in the `paperless-eval` namespace
rather than against the live archive.
**Applied by hand, not by Flux.** A Job is immutable, so re-running a
configuration means deleting and recreating it, and a Flux-managed Job would
simply come back. The namespace itself *is* managed
(`kubernetes/apps/paperless-eval/`); only the measurement is not.
## Running one
Screen first, always:
```sh
./run-candidate.sh qwen3-8b 5 # ~5 min to a rejection, ~10 to a pass
kubectl -n paperless-eval logs -f job/eval-qwen3-8b -c eval
kubectl -n paperless-eval delete job eval-qwen3-8b
./run-candidate.sh qwen3-8b # the real measurement, 50 documents
```
Three of the first four candidates could not do the task at all, and each was
left running for an hour before anyone looked. eval.py now stops after three
consecutive failures from the first document, so a screening run rejects a
hopeless candidate in about five minutes.
Screening uses genuine paperless requests. That matters — `probe-toolcall.sh`
is cheaper still and worth a first pass, but it passed two candidates that then
failed on document one, because a synthesised request cannot reproduce the RAG
neighbours and taxonomy candidate lists that make a real prompt what it is.
Never compare numbers across sample sizes. A screen is a gate; the comparison
happens at 50.
Candidates live in `candidates.tsv`; the runner substitutes the model-specific
values into `job-template.yaml` and refreshes the `eval.py` ConfigMap. Adding a
candidate is a line in that file, not a copy of the manifest — the sidecar
configuration deliberately exists once.
Two run at a time: the pod anti-affinity puts one per node, and only two nodes
have a free iGPU. A third waits.
Re-running after an edit needs the old Job gone first:
`kubectl -n paperless-eval delete job eval-<label>`.
Results land on the `paperless-eval-results` PVC as `<label>.jsonl` (one row per
document) and `<label>.summary.json`, and in the pod log.
## What is being compared
The candidates against each other. The scikit-learn classifier runs in every
job as a fixed reference line — it costs 15 seconds and keeps running in
production whatever the LLM does, so `llm_rescued` against `llm_only_wrong`
says what *this* model adds on top of it.
Do not read the classifier's own score as a quality bar. `train()` fits on every
non-inbox document with no holdout, so the evaluation documents are in its
training set and its numbers are memorisation. That does not matter for ranking
models — it is the same constant under all of them.
## Reading the output
`correspondent_offered` against `correspondent_recognised` is the
`MATCH_THRESHOLD = 0.8` gap: the model names the right entity and difflib
rejects it. Measured, not hypothetical — "GOTOOLS GmbH" against the stored
"GOTOOLS" scores ~0.74 and is thrown away despite being an exact prefix.
`type_specific` against `type_catchall` splits on whether the true type is
"Sonstige", which holds 1133 of ~3900 documents and is the one label with no
semantic content. A model reading the document has to infer "none of the above".
`failed` counts documents where the model produced nothing usable. With
`--n-predict 768` in place, that is mostly non-termination: paperless reports
the truncated response as a flat `Invalid AI configuration` and discards the
content, so what the model was doing is visible only in the llama-server log.
## Results so far
| model | corr. offered | corr. recognised | type | specific | catchall | failed | seconds |
|---|---|---|---|---|---|---|---|
| *classifier (reference)* | 72.0 | 72.0 | 100.0 | 100.0 | 100.0 | 0 | 18 |
| Qwen3.6-35B-A3B Q4_K_M | 72.9 | 77.1 | 85.4 | 94.9 | 44.4 | 2 | 5494 |
| Ministral-3 3B Q4_K_M | 71.4 | 76.2 | 76.2 | 83.3 | 33.3 | **29** | 5511 |
| Ministral-3 8B Q4_K_M | — | — | — | — | — | 3/3 | aborted |
| Granite 3.3 8B Q4_K_M | — | — | — | — | — | all | not measurable |
Read the Ministral-3 3B row with its failure count, not without it. It scored 21
documents and failed 29 — and on those 21 its correspondent numbers sit within
the noise floor of Qwen3.6's. That is not a near-tie: the 21 are the documents
it could answer at all, which is a set selected for being easy, and the
classifier does better on them too (11 of 21 both right against 30 of 48).
A model that declines three documents in five has no usable accuracy number.
Qwen3.6 complement: 30 both right, 5 rescued, 4 broken, 9 both wrong — and two
of those four "broken" are the difflib gap above, not model errors.
### How big a difference means anything
Qwen3.6 was measured twice over the same 50 documents, once with the deprecated
--chat-template-kwargs and once with --reasoning off:
70.8 / 75.0 / 83.3 / 94.9 / 33.3 two failures
72.9 / 77.1 / 85.4 / 94.9 / 44.4 two failures
Every one of those gaps is a single document out of 48 — the catchall one is
one document out of nine. So that is the noise floor: **a difference between
two candidates smaller than about two documents says nothing.** Decoding is
sampled, and the same model on the same input does not give the same answer
twice.
Worth knowing before reading a 2-point lead as a result.
## The instrument is checked, not assumed
Every AI run asks the model "Nenne die Hauptstadt von Frankreich" before the
documents, every ten documents, and again if it aborts. If Paris does not come
back, the run stops and says so.
This exists because a whole day of results turned out to be measuring a broken
instrument. On this Vulkan stack a prompt past some model-specific length stops
llama.cpp producing language — it repeats one token, " de de de" or
" phosphorus phosphorus" or whitespace — at HTTP 200 with finish_reason "stop"
and nothing in the log. The slot then stays broken for every request after it
until the pod restarts.
So a run against a poisoned slot reports fifty failures that are one event, and
reads exactly like a model that cannot classify. Six candidates were written up
that way. gemma-4-26B-A4B answers "Die Hauptstadt von Frankreich ist Paris."
perfectly on a fresh pod and produces whitespace at a 2334-token prompt; its
recorded classification scores described a corrupted slot and nothing else.
**A run whose canary breaks is void, not a result.** Failures adjacent to the
break are one fault. Use `probe-length.sh` to find where the model's boundary
actually sits before judging it — paperless needs ~5300 tokens.
## Bounds of the harness, not of the model
Two numbers in here are mine and both have produced misleading results:
`--n-predict` caps generation server-side, default 768. A candidate that stops
exactly there has hit MY bound. Six candidates were written up as incapable on
that signal before anyone checked whether they would have finished at 900
tokens. A working answer takes 241-291 tokens, so 768 looked generous — and
"looked generous" is not evidence.
`EVAL_TIMEOUT` bounds the client, default 300 s. Raising the token cap alone
just moves the failure here: at 6 tok/s a 4096-token budget needs 620 s of
generation, so the run fails with TimeoutError instead. Raise both together.
./run-candidate.sh <label> <sample> <n_predict> <timeout>
./run-candidate.sh gemma4-26b-a4b 3 4096 900
The Q8 precision comparison had to be thrown away over this. Q8's uncapped
answers run 460-520 tokens against Q4's 280-443 — about 60% longer for the same
task — so 20 of 50 documents hit the 768 cap against Q4's one, and the run
looked like "Q8 classifies worse" when it measured "Q8 is wordier than my
bound". Before comparing two configurations, check that neither is sitting on
the cap.
## What to watch for
**Prompt length.** Past roughly 14k tokens this stack stops producing language
and starts producing garbage or nothing, without any error — and a single such
request poisons the llama.cpp slot until the pod restarts. Paperless prompts sit
near 5k, but the 252 documents over 14000 characters push toward the boundary
with their RAG neighbours. A run that starts scoring zeros mid-way is more
likely this than the model.
**Ground truth.** It is the current filing, and over ten years conventions
change. A disagreement with an old document is not automatically an error.
## The models volume fills up
Each candidate leaves its weights on the shared `llama-models` PVC — 2.5 GB for
a small dense model, 17-25 GB for an MoE, 34 GB for a Q8. The volume is 120 GiB
and nine screened candidates filled it completely.
A full volume does not announce itself. llama-server cannot write its metadata
cache, so it cannot resolve the repository listing, and says:
no GGUF files found in repository <repo>
Available GGUF files:
- <whatever it had cached before the disk filled>
which reads as a wrong model reference. The pod then sits in
`Init:CrashLoopBackOff`, which from outside is indistinguishable from a slow
34 GB download. Two jobs spent three hours that way.
**Check `df -h /models` before believing anything llama-server says about a
repository.** Clear out rejected candidates when the volume gets tight — the
weights are re-downloadable, so deleting them costs only bandwidth:
```sh
kubectl -n paperless-eval exec <a pod mounting llama-models> -- \
sh -c 'cd /models && du -sh * | sort -h'
```
+238
View File
@@ -0,0 +1,238 @@
# label hf-repo hf-file alias mem-request mem-limit
#
# Every candidate must do function calling: paperless calls the model through
# chat_with_tools(..., tool_required=True) with DocumentClassifierSchema as a
# tool, not via JSON mode. A model whose chat template has no tool support does
# not score badly here, it fails outright.
#
# Instruct variants, never Reasoning ones. Qwen3.6 spent two of fifty documents
# generating until something cut it off, and a model tuned to think first does
# that by design. The one exception is Qwen3.6 itself, which is measured with
# thinking switched off.
#
# Sizes. An earlier version of this note said "a smaller dense model gains at
# both ends" and chose the Ministral pair on that basis. Measured, it is wrong:
#
# Ministral-3 8B dense, ~4.7 GB weights 7.1-8.2 tok/s
# Qwen3.6 35B-A3B MoE, ~20 GB weights 11 tok/s
#
# The largest model is the fastest one here. But the bandwidth explanation I
# gave for it is wrong, and measuring Q8 disproved it:
#
# Qwen3.6-35B-A3B Q4_K_M ~21 GB 11.0 tok/s
# Qwen3.6-35B-A3B Q8_0 ~34 GB 10.3 tok/s
#
# Doubling the bytes per active parameter cost ten percent, not half. So decode
# is NOT bandwidth-bound for this MoE — it is compute-bound on many small
# per-expert matmuls, which also explains the earlier measurement of 21.4 GB/s
# effective against a 53.9 GB/s ceiling: it was already leaving 60% of the bus
# unused.
#
# Consequences: the bytes-per-token formula predicts dense models and not this
# one, and higher precision is nearly free on an MoE here — about 10% slower
# for double the weight precision.
#
# So the order to prefer is: MoE with few active parameters, then dense 3-9B,
# then dense 10-14B only for an exceptional model (~4 tok/s, roughly three
# hours for 50 documents), and nothing dense above 14B.
qwen35 ggml-org/Qwen3.6-35B-A3B-GGUF Qwen3.6-35B-A3B-Q4_K_M.gguf qwen3.6-35b-a3b 24Gi 32Gi
#
# The same model at higher precision, to find out what Q4 costs. Everything in
# this file is Q4_K_M, so the whole comparison could be sitting on quantisation
# damage nobody has measured — and there is a concrete suspicion, not just a
# worry: Ministral-3's leak of [THINK] and [TOOL_CALLS] as literal text is the
# kind of fault a 4-bit quantisation can produce, and Q8 would separate "the
# model cannot do this" from "Q4 broke it".
#
# ~34 GB resident against Q4's ~21, and roughly 6 tok/s against 11, so about
# double the wall clock. Read the result against the noise floor in README.md:
# below about two documents of difference, this says nothing.
qwen35-q8 ggml-org/Qwen3.6-35B-A3B-GGUF Qwen3.6-35B-A3B-Q8_0.gguf qwen3.6-35b-a3b-q8 40Gi 48Gi
#
# --- MoE, few active parameters: the shape that wins on this hardware ---
qwen3-30b-a3b bartowski/Qwen_Qwen3-30B-A3B-Instruct-2507-GGUF Qwen_Qwen3-30B-A3B-Instruct-2507-Q4_K_M.gguf qwen3-30b-a3b 22Gi 28Gi
nemotron3-30b-a3b bartowski/nvidia_Nemotron-3-Nano-30B-A3B-GGUF nvidia_Nemotron-3-Nano-30B-A3B-Q4_K_M.gguf nemotron3-30b-a3b 28Gi 34Gi
glm47-flash bartowski/zai-org_GLM-4.7-Flash-GGUF zai-org_GLM-4.7-Flash-Q4_K_M.gguf glm-4.7-flash 20Gi 26Gi
gemma4-26b-a4b unsloth/gemma-4-26B-A4B-it-GGUF gemma-4-26B-A4B-it-UD-Q4_K_M.gguf gemma-4-26b-a4b 20Gi 26Gi
qwen35-35b-a3b bartowski/Qwen_Qwen3.5-35B-A3B-GGUF Qwen_Qwen3.5-35B-A3B-Q4_K_M.gguf qwen3.5-35b-a3b 24Gi 30Gi
nemotron35-30b-a3b bartowski/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-GGUF NVIDIA-Nemotron-3.5-Lightning-30B-A3B-Q4_K_M.gguf nemotron3.5-30b-a3b 28Gi 34Gi
#
# --- dense, small, with German named in the model's own card ---
glm4-9b bartowski/glm-4-9b-chat-GGUF glm-4-9b-chat-Q4_K_M.gguf glm-4-9b 10Gi 14Gi
commandr7b bartowski/c4ai-command-r7b-12-2024-GGUF c4ai-command-r7b-12-2024-Q4_K_M.gguf command-r7b 10Gi 14Gi
qwen25-7b bartowski/Qwen2.5-7B-Instruct-GGUF Qwen2.5-7B-Instruct-Q4_K_M.gguf qwen2.5-7b 10Gi 14Gi
llama31-8b bartowski/Meta-Llama-3.1-8B-Instruct-GGUF Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf llama-3.1-8b 10Gi 14Gi
nemotron-nano-12b bartowski/nvidia_NVIDIA-Nemotron-Nano-12B-v2-GGUF nvidia_NVIDIA-Nemotron-Nano-12B-v2-Q4_K_M.gguf nemotron-nano-12b 12Gi 16Gi
phi4-mini bartowski/microsoft_Phi-4-mini-instruct-GGUF microsoft_Phi-4-mini-instruct-Q4_K_M.gguf phi-4-mini 8Gi 12Gi
#
# --- dense, small, no German claim but no thinking risk either ---
qwen3-4b-2507 bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf qwen3-4b 8Gi 12Gi
qwen35-9b bartowski/Qwen_Qwen3.5-9B-GGUF Qwen_Qwen3.5-9B-Q4_K_M.gguf qwen3.5-9b 10Gi 14Gi
#
# WHAT THE RESEARCH RULED OUT, so nobody re-derives it
#
# Hard-excluded on tool calling, chat template inspected directly rather than
# assumed — the string "tool" appears zero times in any of them:
# Gemma 3, every size (4B/12B/27B) phi-4 14B (the mini DOES have it)
# Cohere Aya, all variants InternLM 2.5 / 3
#
# Hard-excluded on German, stated by the vendor rather than merely missing:
# Falcon 3 — "supports EN, FR, ES, PT", German is not on the list
# EXAONE — the small ones are English/Korean; the ones with German are 33B+
#
# German fine-tunes are a dead end, and for one consistent reason: SauerkrautLM,
# DiscoLM, LeoLM, Occiglot and Teuken all overwrite their base model's chat
# template with a minimal ChatML loop that drops tool calling. VAGOsolutions
# does this even on bases that have it (Llama-3.1, Qwen2.5). Assume any German
# tune is tool-incapable until its own tokenizer_config.json says otherwise.
#
# On llama.cpp's tool parsing, which explains the Granite failure: current
# builds have no per-vendor parser table any more. common/chat.cpp exposes
# PEG_SIMPLE / PEG_NATIVE / PEG_GEMMA4 / PEG_MINIMAX_M3 and derives the grammar
# from the model's own template. Hermes-style <tool_call> JSON is a first-class
# tested fixture in tests/test-chat.cpp; Granite's format is not. So this is not
# "Granite is unsupported" so much as "Granite's template shape is outside what
# the auto-parser was validated against".
#
# SCREENING RESULTS, and a pattern that reframes the whole search
#
# qwen35 MoE 35B total / 3B active works, 2 of 50 failed
# qwen3-8b dense 8B 4 of 5 failed
# hermes4-14b dense 14B 3 of 3 failed, aborted
# ministral8b dense 8B 3 of 3 failed, aborted
# ministral3b dense 3B 29 of 50 failed
# granite8b dense 8B every document failed
#
# Five dense models from 3B to 14B cannot produce a valid call against
# paperless's 14-field schema; the one mixture-of-experts model can. Hermes is
# the cleanest of these failures: its <tool_call> JSON is an explicitly tested
# fixture in llama.cpp's own test suite, so the parser demonstrably handles the
# format and the failure has to be the model.
#
# Working hypothesis: holding a long grammar-constrained structure needs model
# capacity, and what supplies it is TOTAL parameters, not active ones. If that
# is right, "go smaller for speed" is a dead end here — and the consolation is
# that the fastest shape on this hardware, MoE with few active parameters, is
# also the only one that works.
#
# qwen3-30b-a3b tests it directly: 30B total, 3B active.
#
# CORRECTION: Gemma is not excluded after all. Gemma *3* has no tool support —
# "tool" appears zero times in its chat template at every size, checked
# directly. Gemma *4* 26B-A4B does have it, documented in the template, plus
# "140+ languages pretrained, 35+ out of the box". Excluding a family by its
# previous generation was sloppy.
#
# Two of these carry a Vulkan risk worth checking in the llama-server log
# before trusting a run: Nemotron-3.5 interleaves real Mamba2 layers and
# Qwen3.5 uses DeltaNet linear attention, and llama.cpp's Vulkan SSM shaders
# are less exercised on Iris Xe than on Arc. Issue #19957 reported exactly this
# for Qwen3.5-35B-A3B and it turned out to be a stale build, fixed months
# before b10731 — but confirm layers are actually offloaded rather than
# silently falling back to CPU, which would read as a slow model.
#
# Ruled out after checking: ERNIE-4.5-21B-A3B (only the Thinking variant has
# tool calling; the instruct one has none), Ling-3.0-flash (124B total),
# Granite-4.0-H-Small (9B active), DeepSeek-V2-Lite and OLMoE (under 20B
# total), Cohere North Mini (code-only, no ggml-org/bartowski conversion,
# cohere2_moe support in llama.cpp unverified), gpt-oss-20b (a reasoning-effort
# model in Harmony format rather than plain instruct).
#
# THE MoE HYPOTHESIS IS DEAD. qwen3-30b-a3b — MoE, 30B total, 3B active, the
# shape that was supposed to explain everything — scored 1 of 5 and failed 4,
# indistinguishable from the dense 8B.
#
# So neither architecture nor size predicts success:
#
# Qwen3.6-35B-A3B MoE 35B/3B Apr 2026 2 of 50 failed
# Qwen3-30B-A3B MoE 30B/3B older 4 of 5 failed
# Qwen3-8B dense 8B older 4 of 5 failed
# Hermes-4-14B dense 14B — 3 of 3 failed
#
# The only thing correlating with success so far is how new and how strong the
# model is. That is an unsatisfying hypothesis because it predicts nothing
# cheaply — but it is what the data says, and two architectural theories have
# already died here.
#
# qwen35-35b-a3b isolates it: same architecture, same total and active
# parameters as the working baseline, one generation older. If it fails, the
# generation is the variable and shape is noise.
#
# qwen35-35b-a3b PASSES the screen: 5 of 5, no failures, 584 s. So the
# generation hypothesis is dead too — Qwen3.5 is a generation OLDER than the
# working Qwen3.6 and handles the schema cleanly.
#
# What the two working models share is 35B total parameters; the one failing
# MoE has 30B. But that is confounded — Qwen3-30B-A3B is both smaller and
# older, and three data points cannot separate the two.
#
# Qwen3.6-35B-A3B 35B total newest works
# Qwen3.5-35B-A3B 35B total older works
# Qwen3-30B-A3B 30B total oldest 4 of 5 failed
#
# glm47-flash separates them: 30B total, newest generation. If it works,
# generation is what matters and 35B is not a threshold. If it fails, total
# size is, and the search should stop looking below ~35B.
#
# PREFILL SPEED IS A SELECTION CRITERION AND I WAS NOT TRACKING IT.
# Measured on this hardware, prompt processing varies more than sixfold across
# architectures, and it is roughly half of a run's wall clock:
#
# dense 8B (Ministral) 240 tok/s
# Qwen3.6-35B-A3B 100 tok/s
# GLM-4.7-Flash 31 tok/s decode 4.9 tok/s
#
# GLM-4.7-Flash is disqualified on that alone. At 31 tok/s a 5300-token prompt
# takes 168 s, times ~2.6 requests per document, is over seven minutes per
# document and about six hours for a run — before considering whether its
# answers are any good.
#
# It also shows why the 300 s REQUEST_TIMEOUT needs care: 4453 prompt tokens at
# 31.5 tok/s plus 768 generated at 4.9 tok/s is 298 s, so a slow model turns a
# content failure into a TimeoutError and muddies the diagnosis. Read the
# llama-server timings, not just the eval error.
#
# glm47-flash: aborted, 3 of 3 — but every one a TimeoutError, not an invalid
# answer, so this does NOT settle the size-versus-generation question it was
# started to settle. It failed on speed. Document 1 did reach the --n-predict
# cap without producing a call, but 2 and 3 were cut off by the 300 s client
# bound before anything could be concluded about them.
#
# Disqualified regardless: 31 tok/s prefill and 4.9 tok/s decode is over seven
# minutes per document and about six hours for a full run.
#
# gemma4-26b-a4b is the better discriminator — 26B total, well under 35, and
# newest generation. Works means generation and quality matter rather than a
# 35B threshold; fails is consistent with total size being the line.
#
# gemma4-26b-a4b: aborted, 3 of 3, all HTTP 400 rather than timeouts, and the
# server log shows it ran to the --n-predict cap on every one. So it is the
# same capability failure as the small dense models, not a template mismatch.
# (Prefill 45-50 tok/s, decode 6 tok/s — also not fast.)
#
# THE SIZE THRESHOLD SURVIVED ITS FIRST REAL TEST. gemma-4 was chosen
# specifically because it could refute it — newest generation, 26B total — and
# it did not:
#
# Qwen3.6-35B-A3B 35B newest works
# Qwen3.5-35B-A3B 35B older works
# Qwen3-30B-A3B 30B oldest fails
# gemma-4-26B-A4B 26B NEWEST fails
# Hermes-4-14B 14B — fails
# dense 3-8B — — fail
#
# Two generations work at 35B total; everything at 30B and below fails,
# including the newest model in the field. Operating rule until something
# contradicts it: do not spend a screening slot on anything under ~35B total.
#
# Not pinned further on purpose. Whether the line sits at 31B or 34B is
# interesting and not decision-relevant — there are two working candidates and
# the useful next number is a full 50-document run of the second one.
#
# Re-enabled for length probing, not for scoring. Their classification numbers
# were taken against a slot that may already have been corrupted, so the only
# honest thing to do with them is find out where each one's boundary sits.
hermes4-14b bartowski/NousResearch_Hermes-4-14B-GGUF NousResearch_Hermes-4-14B-Q4_K_M.gguf hemes-4-14b 14Gi 20Gi
qwen3-8b bartowski/Qwen_Qwen3-8B-GGUF Qwen_Qwen3-8B-Q4_K_M.gguf qwen3-8b 12Gi 16Gi
ministral8b bartowski/mistralai_Ministral-3-8B-Instruct-2512-GGUF mistralai_Ministral-3-8B-Instruct-2512-Q4_K_M.gguf ministral-3-8b 12Gi 16Gi
granite8b bartowski/ibm-granite_granite-3.3-8b-instruct-GGUF ibm-granite_granite-3.3-8b-instruct-Q4_K_M.gguf granite-3.3-8b 12Gi 16Gi
Can't render this file because it contains an unexpected character in line 13 and column 47.
@@ -78,10 +78,20 @@ ENGINES = os.environ.get("EVAL_ENGINES", "classic,ai").split(",")
LABEL = os.environ.get("EVAL_LABEL", "unlabelled")
OUT_DIR = os.environ.get("EVAL_OUT", "/results")
# Long enough that a slow document is measured rather than abandoned. A client
# timeout does not stop the generation behind it, and with a single llama.cpp
# slot an abandoned request holds the queue for everything after it.
REQUEST_TIMEOUT = 900
# A backstop, not the real bound. Generation is capped server-side by
# --n-predict on the llama-server sidecar, which is what actually stops a model
# that will not terminate; this catches everything else — a wedged sidecar, a
# request that never reaches it.
#
# 300 s against the ~73 s a healthy document takes end to end, prompt included.
# It does work: llama-server logs `stop: cancel task` on the disconnect and
# starts the next request 0.4 s later.
REQUEST_TIMEOUT = int(os.environ.get("EVAL_TIMEOUT", "300"))
# Consecutive failures from the very first document that mean the engine is
# unusable rather than merely weak. Three, because one could be a single hard
# document and two could be coincidence.
ABORT_AFTER = 3
CATCHALL_TYPE = "Sonstige"
@@ -91,20 +101,37 @@ def log(msg: str) -> None:
def api_token(conn) -> str:
"""A token for this run, created in the database.
"""A token for this run, against a user belonging to this run alone.
The API needs one and this database is a disposable clone, so the run
issues its own against a superuser rather than depending on a token that
happened to come across in the restore.
issues its own rather than depending on a token that happened to come
across in the restore.
The per-label user is not tidiness. Django's authtoken holds one token per
user, so an earlier version took the first superuser and deleted its
existing token first — which meant the second of two candidates running
concurrently silently revoked the first one's credentials. That surfaced as
a mid-run `HTTP 401: Invalid token` sitting among genuine model failures,
where it reads like one more broken document rather than the harness
sabotaging itself. Both jobs share this database; only the user can differ.
"""
token = secrets.token_hex(20)
username = f"eval-{LABEL}"
with conn.cursor() as cur:
cur.execute(
"select id from auth_user where is_superuser and is_active order by id limit 1",
)
cur.execute("select id from auth_user where username = %s", (username,))
row = cur.fetchone()
if row is None:
raise SystemExit("no active superuser in the restored database")
cur.execute(
"""
insert into auth_user (password, is_superuser, username,
first_name, last_name, email, is_staff,
is_active, date_joined)
values ('!unusable', true, %s, '', '', '', true, true, now())
returning id
""",
(username,),
)
row = cur.fetchone()
user_id = row[0]
cur.execute("delete from authtoken_token where user_id = %s", (user_id,))
cur.execute(
@@ -165,7 +192,9 @@ def floor(conn) -> float:
""",
)
top, total = cur.fetchone()
return 100.0 * top / total if total else 0.0
# float() on both: psycopg returns Postgres numerics as Decimal, and
# mixing that with a float literal raises TypeError rather than coercing.
return 100.0 * float(top) / float(total) if total else 0.0
def fetch(path: str, token: str) -> tuple[dict | None, str | None, float]:
@@ -198,6 +227,32 @@ def recognised(payload: dict, field: str, want_name: str) -> bool:
return any(want <= normalise(name) for name in payload.get(field) or [])
def degenerate(payload: dict) -> str | None:
"""Whether this looks like broken inference rather than a wrong guess.
Past roughly 14k prompt tokens this stack stops producing language — first
empty completions, then runs of digits and CJK characters — and one such
request poisons the llama.cpp slot until the pod restarts. Nothing in the
HTTP response says so: status 200, finish_reason "stop", well-formed JSON.
Scored as a wrong answer it would silently depress a candidate's numbers,
and because the slot stays poisoned it would take the rest of the run with
it. Flagged instead, so the failure is visible as itself.
"""
title = payload.get("title") or ""
if not title.strip():
return "empty title"
# Digits, punctuation and whitespace are normal in a document title —
# "Rechnung 2025-5425541" is 8 letters in 21 characters and tripped an
# earlier version of this check. What broken inference produces is
# characters from other scripts, so look for those directly rather than
# inferring them from a shortage of Latin ones.
foreign = sum(c.isalpha() and not (c.isascii() or c in "äöüÄÖÜßáéèêíóúàçñ") for c in title)
if foreign > 0.2 * len(title):
return f"non-latin title: {title[:40]!r}"
return None
def score_document(payload: dict, doc: dict) -> dict:
offered_corr = doc["correspondent_id"] in (payload.get("correspondents") or [])
offered_type = doc["document_type_id"] in (payload.get("document_types") or [])
@@ -214,9 +269,56 @@ def score_document(payload: dict, doc: dict) -> dict:
}
LLM_ENDPOINT = os.environ.get("PAPERLESS_AI_LLM_ENDPOINT", "http://127.0.0.1:8080/v1")
def canary() -> tuple[bool, str]:
"""Ask the model something it cannot get wrong, and check it did not.
The instrument has to be shown working before its readings mean anything,
and this one can fail silently in a way that looks like a bad model. On
this Vulkan stack a prompt past some model-specific length stops llama.cpp
producing language: it repeats one token — " de de de", " phosphorus
phosphorus", whitespace — at HTTP 200 with finish_reason "stop". The slot
stays broken for every request after it until the pod restarts.
So a run that measured 50 documents against a poisoned slot reports 50
failures that are really one event, and reads as a model that cannot
classify. That is exactly what happened before this existed.
Run before the documents and again after: healthy then broken means the
failures in between are one fault, not evidence about the model.
"""
body = json.dumps({
"model": os.environ.get("PAPERLESS_AI_LLM_MODEL", "probe"),
"max_tokens": 40,
"messages": [{"role": "user", "content": "Nenne die Hauptstadt von Frankreich."}],
}).encode()
req = urllib.request.Request(
f"{LLM_ENDPOINT}/chat/completions",
data=body,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
payload = json.loads(resp.read())
text = payload["choices"][0]["message"].get("content") or ""
text += payload["choices"][0]["message"].get("reasoning_content") or ""
return ("Paris" in text), text[:80]
except Exception as err: # noqa: BLE001
return False, f"{type(err).__name__}: {err}"
def run(engine: str, suffix: str, docs: list[dict], token: str) -> list[dict]:
log(f"=== {engine}: {len(docs)} documents")
rows = []
if engine == "ai":
ok, text = canary()
log(f" canary before: {'OK' if ok else 'BROKEN'} — {text!r}")
if not ok:
log(" The model is not answering a trivial question correctly, so")
log(" nothing measured after this describes its classification.")
return [{"engine": engine, "canary_before": False, "canary_text": text}]
for n, doc in enumerate(docs, 1):
payload, error, elapsed = fetch(
f"/api/documents/{doc['id']}/{suffix}",
@@ -231,13 +333,103 @@ def run(engine: str, suffix: str, docs: list[dict], token: str) -> list[dict]:
"catchall_type": doc["document_type_name"] == CATCHALL_TYPE,
}
if payload is None:
# An authentication failure is never a fact about the model, and
# counted alongside real ones it reads like one more broken
# document. Stop loudly instead: something revoked this run's
# credentials mid-flight, and every number after it is worthless.
if error and error.startswith("HTTP 401"):
raise SystemExit(
f"[eval] FATAL: {error}\n"
f"[eval] This run's token stopped working part-way through. "
f"Django's authtoken holds one token per user, so another "
f"job sharing this database and this user revoked it.",
)
row["error"] = error
log(f" {doc['id']}: {error}")
else:
row.update(score_document(payload, doc))
# Only the AI path returns a title; the classifier answers with ids
# alone, so an "empty title" there is normal rather than broken.
if engine == "ai" and (broken := degenerate(payload)) is not None:
row["degenerate"] = broken
log(f" {doc['id']}: DEGENERATE — {broken}")
rows.append(row)
# Clear the slot between documents so the next one is measured on a
# clean server. The corruption this guards against is stateful: one bad
# request leaves the slot returning a single repeated token for
# everything after it, and without this a run counts that once per
# remaining document.
if engine == "ai":
try:
urllib.request.urlopen(
urllib.request.Request(
f"{LLM_ENDPOINT.rsplit('/v1', 1)[0]}/slots/0?action=erase",
data=b"",
method="POST",
),
timeout=30,
)
except Exception as err: # noqa: BLE001 — best effort, never fatal
if n == 1:
log(f" slot erase unavailable ({type(err).__name__}) — "
f"failures after a corrupt document will not be independent")
if n % 10 == 0:
log(f" {n}/{len(docs)}")
# Re-check the instrument, not just the progress. A slot corrupted
# part-way through turns every remaining document into a failure
# that says nothing about the model, and scattered failures — 29 of
# 50 in one earlier run — are exactly the shape that a
# start-of-run check alone would miss.
if engine == "ai":
ok, text = canary()
if not ok:
log(f" CANARY BROKEN at {n}/{len(docs)} — {text!r}")
log(" The model can no longer answer a trivial question, so")
log(" everything from here would measure a dead slot. Stopping.")
log(" Failures recorded before this point are still usable;")
log(" the ones adjacent to it are one fault, not several.")
rows.append({"engine": engine, "canary_failed_at": n, "canary_text": text})
break
# Stop early when a model cannot do the task at all, rather than
# spending an hour writing the same error fifty times. Three candidates
# in a row failed this way — Granite because llama.cpp cannot read its
# tool calls back, both Ministral sizes because they fill the tool
# call's first string with their own [THINK]/[TOOL_CALLS] control
# tokens until the token cap. In each case document one already said
# so, and the remaining forty-nine said nothing new.
#
# Deliberately not a ratio: a model that fails a third of the time is a
# result worth measuring in full. This only catches the case where
# nothing has ever worked.
if len(rows) == ABORT_AFTER and all("error" in r for r in rows):
ok, text = canary()
log(f" canary after {ABORT_AFTER} failures: {'OK' if ok else 'BROKEN'} — {text!r}")
if not ok:
log(" The slot is corrupted, so these are ONE fault and not")
log(f" {ABORT_AFTER} independent results. Probe the model's prompt-length")
log(" boundary with probe-length.sh before judging it.")
log(f" ABORT — first {ABORT_AFTER} documents all failed:")
log(f" {rows[0]['error']}")
log(" Nothing has succeeded, so the rest would measure nothing.")
log(" Check the model's tool calling before re-running:")
log(" curl -s $ENDPOINT/v1/chat/completions -d @probe.json")
log(" A usable candidate answers with finish_reason 'tool_calls'.")
break
# And once at the end, whatever happened. A short run whose first
# document succeeds trips neither the abort check nor the every-tenth
# one, so without this a five-document screen can finish having never
# re-verified the instrument it just used.
if engine == "ai":
ok, text = canary()
log(f" canary after: {'OK' if ok else 'BROKEN'} — {text!r}")
if not ok:
log(" Healthy before, broken after: the failures in this run are")
log(" one fault and not independent results.")
rows.append({"engine": engine, "canary_after": False, "canary_text": text})
return rows
@@ -246,17 +438,23 @@ def pct(hits: int, total: int) -> float:
def summarise(rows: list[dict], engine: str) -> dict:
scored = [r for r in rows if r["engine"] == engine and "error" not in r]
failed = [r for r in rows if r["engine"] == engine and "error" in r]
mine = [r for r in rows if r["engine"] == engine]
# Degenerate rows are excluded from the accuracy figures: they measure the
# backend giving up, not the model choosing wrongly, and leaving them in
# would quietly punish whichever candidate happened to trip the slot.
scored = [r for r in mine if "error" not in r and "degenerate" not in r]
failed = [r for r in mine if "error" in r]
broken = [r for r in mine if "degenerate" in r]
specific = [r for r in scored if not r["catchall_type"]]
catchall = [r for r in scored if r["catchall_type"]]
return {
"engine": engine,
"scored": len(scored),
"failed": len(failed),
"seconds_total": round(
sum(r["seconds"] for r in rows if r["engine"] == engine),
),
# Non-zero means the run is suspect from that point on, not that the
# model is bad — the slot stays poisoned until the pod restarts.
"degenerate": len(broken),
"seconds_total": round(sum(r["seconds"] for r in mine)),
"correspondent_offered": pct(
sum(r["correspondent_offered"] for r in scored), len(scored)
),
@@ -281,7 +479,8 @@ def complement(rows: list[dict]) -> dict | None:
"""
by_engine = {"classic": {}, "ai": {}}
for row in rows:
if "error" not in row and row["engine"] in by_engine:
usable = "error" not in row and "degenerate" not in row
if usable and row["engine"] in by_engine:
by_engine[row["engine"]][row["document_id"]] = row
both = set(by_engine["classic"]) & set(by_engine["ai"])
if not both or not by_engine["ai"]:
@@ -16,7 +16,7 @@
apiVersion: batch/v1
kind: Job
metadata:
name: eval-qwen35
name: eval-__LABEL__
namespace: paperless-eval
labels:
app.kubernetes.io/name: paperless-eval
@@ -69,9 +69,27 @@ spec:
- -c
- |
set -e
cp -a /golden/. /data/
echo "seeded /data:"
du -sh /data/index /data/llm_index 2>/dev/null || true
# -r, not -a: preserving ownership needs CHOWN, which this
# container does not have, and cp then exits non-zero after
# copying the files anyway. Running as 1000 makes the copy land
# with the ownership paperless needs — it writes to the LLM index
# while serving.
cp -r /golden/. /data/
du -sh /data/index /data/llm_index
# Refuse to start rather than measure the wrong thing. An empty
# llm_index makes retrieve_similar_nodes return nothing, and the
# run would report a RAG result having never retrieved anything —
# which is exactly what the first attempt at this job did, because
# the golden volume had not been populated yet. The index is
# ~125 MiB once built; anything under 1 MiB is not one.
bytes=$(du -sb /data/llm_index | cut -f1)
if [ "$bytes" -lt 1000000 ]; then
echo "FATAL: llm_index is ${bytes} bytes — build it on the" >&2
echo "smoke-test deployment first:" >&2
echo " kubectl -n paperless-eval exec deploy/paperless -- \\" >&2
echo " python manage.py document_llmindex rebuild" >&2
exit 1
fi
volumeMounts:
- name: golden
mountPath: /golden
@@ -79,6 +97,8 @@ spec:
- name: data
mountPath: /data
securityContext:
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
@@ -87,24 +107,81 @@ spec:
restartPolicy: Always
args:
- --hf-repo
- ggml-org/Qwen3.6-35B-A3B-GGUF:Q4_K_M
- __HF_REPO__
# The exact filename rather than the :QUANT shorthand. The
# shorthand matches by substring across the whole repo and repos do
# carry several files under one tag — ggml-org ships mmproj-, mtp-
# and dflash- variants at Q8_0 beside the model — so naming the file
# removes a real ambiguity and costs nothing.
#
# It is NOT what caused the three-hour crashloop that prompted this,
# and an earlier version of this comment claimed it was. The real
# cause was the llama-models volume being 100% full: llama-server
# could not write its metadata cache, so it could not resolve the
# repo listing, and reported
# no GGUF files found in repository <repo>
# followed by a truncated list of what it had cached before the
# disk filled. That reads as a bad reference and is a full disk.
#
# If a model will not load, check `df -h /models` before believing
# anything llama-server says about the repository.
- --hf-file
- __HF_FILE__
- --alias
- qwen3.6-35b-a3b
- __ALIAS__
- --n-gpu-layers
- "99"
- --threads
- "4"
# The production ceiling, and for the same reason: 32768 per slot
# corrupts inference on this Vulkan stack — 1025 tokens of "!"
# against a 650-token prompt, with /props still reporting the
# requested size. A candidate needing more context than this cannot
# be measured here, and that is itself a result.
# The production ceiling. Not because the flag value itself breaks
# anything — bisection showed the corruption boundary is prompt
# length, around 14000 tokens, whatever --ctx-size says — but
# because measuring against a different ceiling than production
# runs would not describe production.
- --ctx-size
- "16384"
- --parallel
- "1"
- --ubatch-size
- "1024"
# The default is -1, infinity: nothing on the server caps how long
# a request generates, and paperless sets no budget of its own and
# waits. A classification that needs ~300 tokens ran to 10269 at a
# healthy 11 tok/s, and three of the first eleven documents did it
# — 900 s each, most of an hour spent on four documents.
#
# 768 is generous against the ~300 a correct answer takes. Note
# that paperless reports a truncated response as a flat
# "Invalid AI configuration" and discards the content, so what the
# model was actually doing is visible only in this container's log.
- --n-predict
- "__N_PREDICT__"
# Without this, Qwen3.6 spends the response on reasoning_content
# and returns empty content — 983 tokens into a classification that
# needs a hundred, at a perfectly healthy 11 tok/s. It reads as a
# slow or broken model and is neither.
#
# NOT --chat-template-kwargs '{"enable_thinking": false}'. That is
# deprecated, llama-server says so at startup, and it is not the
# harmless no-op it looks like on a template that never reads the
# variable: injecting it into Mistral's render context produced
# token salad — "1UnComplete1Self1UnCompleteSDirect11S..." — on a
# 681-token prompt, while the identical request against a server
# without the flag returned a clean tool call. It reads exactly
# like a broken model and is a broken prompt.
- --reasoning
- "off"
# Enables POST /slots/0?action=erase, which the eval calls between
# documents. Without it the endpoint answers 501.
#
# This is what makes the measurements independent. A request that
# corrupts the slot otherwise takes every document after it, so a
# run reports N failures for one fault — "4 of 5 failed" was really
# "document 2 broke the slot". Erasing costs the prefix cache and
# some prefill speed, which is the right trade for numbers that
# mean what they say.
- --slot-save-path
- /tmp/slots
- --no-webui
env:
- name: LLAMA_CACHE
@@ -115,20 +192,27 @@ spec:
volumeMounts:
- name: models
mountPath: /models
# ~20 GB of weights off CephFS, so a generous budget: 60 * 20s.
# 60 minutes. Sized for a FIRST DOWNLOAD from HuggingFace, not for
# reading a warm PVC — an earlier version allowed 20 minutes on the
# latter reasoning and killed GLM-4.7-Flash mid-download at 17.8 GB.
# The container then restarts and begins again, so too small a budget
# is not "slow start", it is a loop that never finishes.
#
# The kill shows up as exitCode 137 with reason "Error" rather than
# OOMKilled, which is how to tell a probe kill from a memory limit.
startupProbe:
httpGet:
path: /health
port: llm
periodSeconds: 20
failureThreshold: 60
failureThreshold: 180
resources:
requests:
cpu: "1"
memory: 24Gi
memory: __MEM_REQ__
gpu.intel.com/i915: "1"
limits:
memory: 32Gi
memory: __MEM_LIM__
gpu.intel.com/i915: "1"
securityContext:
allowPrivilegeEscalation: false
@@ -168,7 +252,7 @@ spec:
- name: PAPERLESS_AI_LLM_ENDPOINT
value: http://127.0.0.1:8080/v1
- name: PAPERLESS_AI_LLM_MODEL
value: qwen3.6-35b-a3b
value: __ALIAS__
- name: PAPERLESS_REDIS
value: redis://127.0.0.1:6379
- name: PAPERLESS_SECRET_KEY
@@ -224,9 +308,15 @@ spec:
command: ["python3", "/eval/eval.py"]
env:
- name: EVAL_LABEL
value: qwen35
value: __LABEL__
- name: EVAL_SAMPLE
value: "50"
value: "__SAMPLE__"
# Raised alongside --n-predict for a diagnostic run. The two bounds
# interact: lifting the token cap alone just moves the failure from
# "hit the cap" to "hit the clock", which is what happened the first
# time gemma-4 was retried at 4096.
- name: EVAL_TIMEOUT
value: "__TIMEOUT__"
- name: PGHOST
value: pprleval-pg-rw
- name: PGDATABASE
+149
View File
@@ -0,0 +1,149 @@
#!/bin/sh
# Where does this model's output stop being language?
#
# ./probe-length.sh <label>
#
# THE TEST THAT SHOULD HAVE COME FIRST. On this Vulkan/Iris Xe stack every
# model has a prompt length past which llama.cpp stops producing language and
# starts repeating a single token — " de de de", " the the the", " phosphorus
# phosphorus", or plain whitespace. It is silent: HTTP 200, finish_reason
# "stop" or "length", nothing in the log.
#
# It is also STATEFUL. One request past the boundary leaves the slot broken for
# every request after it, until the pod restarts. So in a 50-document run the
# first bad document takes the rest with it, and "failed 3 of 3" is one
# measurement, not three.
#
# A real paperless request is ~5300 tokens once the document, its RAG
# neighbours and the taxonomy lists are in. So the only question that matters
# about a candidate is whether its boundary is above that — and answering it
# takes minutes, against ninety for a classification run that would have
# measured the same thing badly.
#
# Measured so far:
# Qwen3.6-35B-A3B coherent at 13588, broken at 15086
# gemma-4-26B-A4B coherent at ~350, whitespace at 2334
set -eu
label="${1:?usage: probe-length.sh <label> (labels are in candidates.tsv)}"
here="$(dirname "$0")"
ns=paperless-eval
pod="lenprobe-$(echo "$label" | tr 'A-Z._' 'a-z--' | cut -c1-40)"
row="$(grep -v '^#' "$here/candidates.tsv" | awk -v l="$label" '$1==l')"
[ -n "$row" ] || { echo "unknown candidate '$label'" >&2; exit 1; }
repo="$(echo "$row" | cut -f2)"
hffile="$(echo "$row" | cut -f3)"
memreq="$(echo "$row" | cut -f5)"
memlim="$(echo "$row" | cut -f6)"
cleanup() { kubectl -n "$ns" delete pod "$pod" --wait=false >/dev/null 2>&1 || true; }
trap cleanup EXIT INT TERM
cleanup; sleep 2
kubectl -n "$ns" apply -f - >/dev/null <<EOF
apiVersion: v1
kind: Pod
metadata: {name: $pod, namespace: $ns}
spec:
restartPolicy: Never
containers:
- name: llama-server
image: ghcr.io/ggml-org/llama.cpp:server-vulkan-b10731
args: ["--hf-repo","$repo","--hf-file","$hffile","--alias","probe",
"--n-gpu-layers","99","--threads","4","--ctx-size","16384",
"--parallel","1","--ubatch-size","1024","--n-predict","200","--no-webui"]
env: [{name: LLAMA_CACHE, value: /models}]
volumeMounts: [{name: models, mountPath: /models}]
resources:
requests: {cpu: "1", memory: $memreq, gpu.intel.com/i915: "1"}
limits: {memory: $memlim, gpu.intel.com/i915: "1"}
securityContext: {allowPrivilegeEscalation: false, capabilities: {drop: ["ALL"]}}
volumes:
- name: models
persistentVolumeClaim: {claimName: llama-models}
EOF
# Say which of the two it is doing. There are only two iGPUs, so a probe
# started while both are busy sits Pending indefinitely — and an earlier run
# spent an hour reporting nothing before giving up with "never became healthy",
# which reads as a broken model rather than a queue.
echo "waiting for $label"
i=0
until kubectl -n "$ns" exec "$pod" -- curl -sf --max-time 10 http://127.0.0.1:8080/health >/dev/null 2>&1; do
i=$((i+1))
if [ $(( i % 8 )) -eq 0 ]; then
ph=$(kubectl -n "$ns" get pod "$pod" -o jsonpath='{.status.phase}' 2>&1)
why=$(kubectl -n "$ns" get pod "$pod" -o jsonpath='{.status.conditions[?(@.type=="PodScheduled")].message}' 2>&1)
echo " ${ph}${why:+ — $why}"
[ "$ph" = "Pending" ] && [ -n "$why" ] && { echo "FAIL: cannot schedule — free a GPU first"; exit 1; }
fi
[ "$i" -gt 240 ] && { echo "FAIL: never became healthy after 60 min"; exit 1; }
sleep 15
done
# Fresh pod per size. The defect poisons the slot, so probing sizes in sequence
# against one server would measure the first bad request and nothing after it.
for reps in 4 20 40 70 110 160; do
kubectl -n "$ns" delete pod "$pod" >/dev/null 2>&1 || true
sleep 2
kubectl -n "$ns" apply -f - >/dev/null <<EOF2
apiVersion: v1
kind: Pod
metadata: {name: $pod, namespace: $ns}
spec:
restartPolicy: Never
containers:
- name: llama-server
image: ghcr.io/ggml-org/llama.cpp:server-vulkan-b10731
args: ["--hf-repo","$repo","--hf-file","$hffile","--alias","probe",
"--n-gpu-layers","99","--threads","4","--ctx-size","16384",
"--parallel","1","--ubatch-size","1024","--n-predict","200","--no-webui"]
env: [{name: LLAMA_CACHE, value: /models}]
volumeMounts: [{name: models, mountPath: /models}]
resources:
requests: {cpu: "1", memory: $memreq, gpu.intel.com/i915: "1"}
limits: {memory: $memlim, gpu.intel.com/i915: "1"}
securityContext: {allowPrivilegeEscalation: false, capabilities: {drop: ["ALL"]}}
volumes:
- name: models
persistentVolumeClaim: {claimName: llama-models}
EOF2
until kubectl -n "$ns" exec "$pod" -- curl -sf --max-time 10 http://127.0.0.1:8080/health >/dev/null 2>&1; do sleep 10; done
filler=""; n=0
while [ $n -lt $reps ]; do
filler="${filler}Die Rechnung wurde am fuenften Maerz ausgestellt und umfasst mehrere Positionen aus dem laufenden Wartungsvertrag. "
n=$((n+1))
done
printf '{"model":"probe","max_tokens":200,"messages":[{"role":"user","content":"%s Nenne die Hauptstadt von Frankreich."}]}' "$filler" > /tmp/lp.json
kubectl -n "$ns" cp /tmp/lp.json "$pod":/tmp/lp.json >/dev/null 2>&1 || true
r=$(kubectl -n "$ns" exec "$pod" -- curl -s --max-time 400 -X POST http://127.0.0.1:8080/v1/chat/completions \
-H 'Content-Type: application/json' -d @/tmp/lp.json)
pt=$(printf '%s' "$r" | grep -oE '"prompt_tokens":[0-9]+' | head -1 | cut -d: -f2)
# Test for DEGENERATION, not for the right answer. Two earlier versions of
# this check asked whether "Paris" came back and failed a working model
# twice: once because a thinking model leaves `content` empty and puts its
# answer in `reasoning_content`, then because 64 tokens is not enough for one
# to think its way to the answer at all.
#
# What actually marks the defect is a single token repeated — " de de de",
# " phosphorus phosphorus", whitespace. That signature survives truncation
# and does not care which field the model writes to or whether it finished,
# so it tests the thing itself instead of a proxy for it.
txt=$(printf '%s' "$r" | grep -oE '"(reasoning_)?content":"[^"]*' | sed 's/"[a-z_]*content":"//' | tr '\n' ' ')
rep=$(printf '%s' "$txt" | tr -s ' ' '\n' | grep -c . || echo 0)
top=$(printf '%s' "$txt" | tr -s ' ' '\n' | sort | uniq -c | sort -rn | head -1 | awk '{print $1}')
: "${top:=0}"
if [ "$rep" -gt 8 ] && [ $(( top * 2 )) -gt "$rep" ]; then
verdict="BROKEN" # one token is more than half the output
elif [ "$rep" -le 1 ]; then
verdict="EMPTY" # nothing came back at all — also not healthy
else
verdict="OK"
fi
printf '%-6s prompt=%-6s %s %s\n' "$verdict" "${pt:-?}" "$(printf '%.50s' "$txt")" ""
[ "$verdict" != "OK" ] && { echo; echo "boundary is below ${pt:-?} prompt tokens"; echo "paperless needs ~5300 — $([ "${pt:-0}" -lt 6000 ] && echo 'DISQUALIFIED' || echo 'still above the bar')"; exit 0; }
done
echo
echo "coherent through the largest size probed — boundary is above that"
+95
View File
@@ -0,0 +1,95 @@
#!/bin/sh
# Can this model make one valid tool call against paperless's real schema?
#
# ./probe-toolcall.sh ggml-org/Ministral-3-8B-Instruct-2512-GGUF:Q8_0
#
# Two minutes, against ninety for a full run. Every candidate must pass this
# before it is worth measuring, because three in a row failed here and the
# remaining forty-nine documents of each run said nothing the first one had not.
#
# WHAT THIS CAN AND CANNOT TELL YOU
#
# A FAIL is conclusive: the model cannot make the call, do not spend a run.
# A PASS is not. This payload has been too easy twice — first with three fields
# instead of fourteen, then with a 1191-token prompt instead of production's
# ~5300 — and each time a pass sent a ninety-minute run after a model that
# failed on document one.
#
# It carries the real DocumentClassifierSchema and ~5000 tokens now, but it
# still cannot reproduce what actually inflates a paperless prompt: the RAG
# neighbours and the full taxonomy candidate lists, hundreds of names with IDs
# the model must copy exactly and align by position. Document 1970 is 1976
# characters and still produced a 5333-token prompt.
#
# So treat this as a cheap smoke test, not a gate. The real gate is
# ABORT_AFTER in eval.py, which stops a run after three failed documents —
# five minutes lost instead of ninety, using the genuine request.
set -eu
repo="${1:?usage: probe-toolcall.sh <hf-repo:quant> [extra llama-server args...]}"
shift || true
here="$(dirname "$0")"
ns=paperless-eval
pod="toolprobe-$(echo "$repo" | tr 'A-Z/:._' 'a-z----' | cut -c1-40 | sed 's/-*$//')"
cleanup() { kubectl -n "$ns" delete pod "$pod" --wait=false >/dev/null 2>&1 || true; }
trap cleanup EXIT INT TERM
cleanup
sleep 2
echo "probing $repo"
kubectl -n "$ns" apply -f - >/dev/null <<EOF
apiVersion: v1
kind: Pod
metadata:
name: $pod
spec:
restartPolicy: Never
containers:
- name: llama-server
image: ghcr.io/ggml-org/llama.cpp:server-vulkan-b10731
args: ["--hf-repo","$repo","--alias","probe","--n-gpu-layers","99",
"--threads","4","--ctx-size","16384","--parallel","1",
"--ubatch-size","1024","--n-predict","768","--reasoning","off",
"--no-webui"$(for a in "$@"; do printf ',"%s"' "$a"; done)]
env: [{name: LLAMA_CACHE, value: /models}]
volumeMounts: [{name: models, mountPath: /models}]
resources:
requests: {cpu: "1", memory: 12Gi, gpu.intel.com/i915: "1"}
limits: {memory: 20Gi, gpu.intel.com/i915: "1"}
securityContext:
allowPrivilegeEscalation: false
capabilities: {drop: ["ALL"]}
volumes:
- name: models
persistentVolumeClaim: {claimName: llama-models}
EOF
echo "waiting for the model to load (a first download takes minutes)"
i=0
until kubectl -n "$ns" exec "$pod" -- curl -sf --max-time 10 http://127.0.0.1:8080/health >/dev/null 2>&1; do
i=$((i + 1))
[ "$i" -gt 120 ] && { echo "FAIL: server never became healthy"; kubectl -n "$ns" logs "$pod" --tail=20; exit 1; }
sleep 10
done
sed "s/\"MODEL\"/\"probe\"/" "$here/toolcall-probe.json" > /tmp/tcp.json
kubectl -n "$ns" cp /tmp/tcp.json "$pod":/tmp/probe.json >/dev/null 2>&1 || true
r=$(kubectl -n "$ns" exec "$pod" -- curl -s --max-time 300 \
-X POST http://127.0.0.1:8080/v1/chat/completions \
-H 'Content-Type: application/json' -d @/tmp/probe.json)
finish=$(printf '%s' "$r" | grep -oE '"finish_reason":"[a-z_]+"' | head -1 | cut -d'"' -f4)
echo
echo "finish_reason: ${finish:-<none>}"
printf '%s' "$r" | grep -oE '"arguments":"[^"]{0,300}' | head -1 || true
echo
# finish_reason "tool_calls" is the whole test. "length" means it never stopped,
# "stop" with no tool_calls means the server could not read the call back.
if [ "$finish" = "tool_calls" ]; then
echo "PASS — usable, worth a full run"
else
echo "FAIL — do not spend a run on this until the cause is understood"
exit 1
fi
+90
View File
@@ -0,0 +1,90 @@
#!/bin/sh
# Start one candidate's measurement.
#
# ./run-candidate.sh ministral8b
#
# Exists so the sidecar configuration lives in one file. Four copies of a
# 300-line Job manifest is how --chat-template-kwargs ended up on the isolation
# pod but not on the first eval job, and the run that followed measured empty
# completions for two hours before anyone noticed.
set -eu
label="${1:?usage: run-candidate.sh <label> [sample] (labels are in candidates.tsv)}"
# Screen before you measure. A candidate that cannot do the task at all is
# rejected by eval.py's ABORT_AFTER within three documents, so a screening run
# costs about five minutes; a full run costs ninety. Three of the first four
# candidates were rejections, and each one was allowed to run for an hour or
# more before anyone looked.
#
# ./run-candidate.sh <label> 5 screen
# ./run-candidate.sh <label> measure, 50 documents
#
# Screening uses genuine paperless requests, which matters: the standalone
# probe-toolcall.sh passed two candidates that then failed on document one,
# because a synthesised request cannot reproduce the RAG neighbours and the
# taxonomy candidate lists that make a real prompt what it is.
#
# Never compare numbers across sample sizes. A screen is a gate, not a
# measurement; the comparison happens at 50.
sample="${2:-50}"
# Third argument raises the server-side generation cap for a diagnostic run.
#
# The default 768 is MINE, not a property of any model, and every candidate
# recorded as failing at exactly 768 tokens is failing against my bound rather
# than demonstrably failing the task. A model that needs 900 tokens for a valid
# call looks identical to one that never terminates. Raise it and find out
# before calling a model incapable:
#
# ./run-candidate.sh gemma4-26b-a4b 5 4096
npredict="${3:-768}"
# Client-side bound, raised with the cap. At 6 tok/s a 4096-token budget needs
# 620 s of generation alone, so leaving this at 300 turns the diagnostic into a
# different failure rather than an answer.
timeout="${4:-300}"
here="$(dirname "$0")"
row="$(grep -v '^#' "$here/candidates.tsv" | awk -v l="$label" '$1==l')"
if [ -z "$row" ]; then
echo "unknown candidate '$label'. Known:" >&2
grep -v '^#' "$here/candidates.tsv" | awk 'NF{print " " $1}' >&2
exit 1
fi
repo="$(echo "$row" | cut -f2)"
hffile="$(echo "$row" | cut -f3)"
alias="$(echo "$row" | cut -f4)"
memreq="$(echo "$row" | cut -f5)"
memlim="$(echo "$row" | cut -f6)"
if [ -z "$hffile" ]; then
echo "candidate '$label' has no hf-file column — refusing to guess." >&2
echo "Resolve the exact .gguf filename from the HF API and add it." >&2
exit 1
fi
# The script is mounted from a ConfigMap, so it has to be current before the
# Job starts. Shared by every candidate — they run the same measurement.
kubectl -n paperless-eval create configmap paperless-eval-script \
--from-file=eval.py="$here/eval.py" --dry-run=client -o yaml | kubectl apply -f -
if [ "$sample" -lt 50 ]; then
echo "SCREENING $label at $sample documents — a gate, not a measurement"
fi
echo "candidate=$label repo=$repo alias=$alias mem=$memreq/$memlim sample=$sample n_predict=$npredict timeout=$timeout"
sed -e "s#__LABEL__#$label#g" \
-e "s#__HF_REPO__#$repo#g" \
-e "s#__ALIAS__#$alias#g" \
-e "s#__MEM_REQ__#$memreq#g" \
-e "s#__MEM_LIM__#$memlim#g" \
-e "s#__SAMPLE__#$sample#g" \
-e "s#__HF_FILE__#$hffile#g" \
-e "s#__N_PREDICT__#$npredict#g" \
-e "s#__TIMEOUT__#$timeout#g" \
"$here/job-template.yaml" | kubectl apply -f -
echo
echo "follow: kubectl -n paperless-eval logs -f job/eval-$label -c eval"
echo "re-run: kubectl -n paperless-eval delete job eval-$label # a Job is immutable"
File diff suppressed because one or more lines are too long