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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
"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>
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>
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>
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>
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>
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>
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>
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>
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>
A paperless instance points at exactly one chat endpoint, so comparing
two candidates means two paperless instances. The pod is therefore the
unit, not the namespace: llama-server, redis and paperless as native
sidecars, the eval script as the one regular container. When it exits the
sidecars are torn down and the Job completes, which a plain multi-
container pod behind a llama-server would not.
Sidecar ordering also removes a failure seen on the smoke test, where
paperless started while the database was still restoring and exited after
its 60s wait.
The script is Python rather than the bash-and-jq version, because the
image has no jq and because the thing that was missing is easier this
way: it writes one row per document with both engines' verdicts. Two
aggregates cannot answer the question worth asking — 74% against 72%
could overlap almost entirely or barely, and that difference is the whole
value of running an LLM beside a classifier that costs 0.6s and keeps
running regardless. The summary now reports how often the LLM rescues a
document the classifier got wrong, and how often it breaks one the
classifier had right.
/data is copied into the pod rather than mounted. LLM_INDEX_DIR is
hardcoded to DATA_DIR/"llm_index", the store behind it is SQLite, and
SQLite locking across pods over CephFS is not dependable — but leaving it
out is worse than sharing it: an empty index makes retrieve_similar_nodes
return nothing, so a job would measure the no-RAG path while reporting a
RAG result. ~160 MiB, seconds, and each job gets a private writable copy.
That volume had to be renamed. paperless-eval-data was RWO and a bound
claim's spec is immutable, so paperless-eval-golden replaces it — named
for its role, since the smoke-test deployment builds it and the jobs read
it.
Anti-affinity puts one measurement per node. Two on a node would share
its iGPU and every timing would then describe contention rather than the
model, which is how 4.7 tokens/s got blamed on context length once
already.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The instance deployed and got nowhere. Both ExternalSecrets reported
"could not get secret data from provider", so hetzner-s3-creds never
existed, and the CNPG restore job died on it:
can't get credentials for cluster pprleval-pg: while getting secret
hetzner-s3-creds: secrets "hetzner-s3-creds" not found
The openbao ClusterSecretStore carries an explicit namespace allowlist in
spec.conditions, and paperless-eval was not on it. Nothing in the
manifests shows that — it is cluster state, which is why neither the
review nor `kubectl kustomize` caught it, and why the failure only
appeared on deploy.
The paperless container also gets production's securityContext back. The
review was right about this and it was dismissed as being about the
scanner and mail bridge sidecars, which those manifests were only cited
as evidence for the pattern. The paperless-ngx entrypoint starts as root,
chowns the mounted volumes and drops to uid 1000 via setgid/setuid, so
drop: ["ALL"] with nothing added fails that sequence with EPERM. It would
have bitten as soon as the restore succeeded and the pod actually tried
to start — and harder here than in production, whose volumes were chowned
once and kept it, while these are fresh on every rebuild.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The egress grant to llama-embed was there; the matching ingress in
ai-ns.yaml was not, so Cilium would have dropped every embedding call —
the one dependency an eval has outside its own pod. The same pairing was
done correctly for home-assistant in #759 and simply missed here.
Scoped to the namespace rather than an app label, unlike the grants
around it. That namespace exists only to run evaluations, and its
workload is jobs created per measurement whose labels vary with what is
being measured; a label match would present later as an embedding call
silently dropped.
Also from the review:
secret.reloader annotation alongside the configmap one, matching
production. The secret refreshes on the same 10m cycle, and without the
annotation a rotated key would sit unused until something else restarted
the pod.
The retention argument in cluster.yaml was too thin, and checking it made
it worse before it made it better: the plugin runs retention from the
instance sidecar on a timer
(instanceSidecarConfiguration.retentionPolicyIntervalSeconds, default
1800), not as part of a backup — so "this cluster never backs up" would
not have covered it.
What does cover it is a second property: retention runs
barman-cloud-backup-delete, which takes a server name and operates inside
<destinationPath>/<serverName>. The production store's status keys its
recovery window by "pprl-pg", confirming that layout; this cluster is
pprleval-pg, so even an accidental retention policy would address an
empty prefix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The startupProbe was production's 10 minutes, and production sized that
budget for exactly this case — a from-scratch boot on the same ~3900
documents, per its own comment — so it would probably have held. The
difference is frequency, not size: production's volume is Retain and
keeps the index, making the expensive path a one-off, while this one is
ephemeral and rebuilds on every re-test. A killed pod restarts the work
rather than resuming, so a slow boot becomes a crash-loop that stalls
Flux behind wait: true. 60 minutes now, matching the Kustomization.
The three-hour LLM index is not in that budget and was never at risk: it
is a celery task, and boot only runs init-llmindex-migrate.
Also from the review:
The shared secret refreshes at 10m like production rather than 1h. The
point of sharing the key is that both hold the same value, which a slower
refresh would undo for an hour after a rotation.
The data PVC comment carries measured numbers instead of an unexplained
10Gi: 36 MiB search index and 125 MiB LLM index on production for the
same corpus. The margin is deliberate — phase two varies the embedding
model, and one with wider vectors than bge-m3's 1024 dimensions would
grow that index.
The ApplicationConfiguration warning now carries the query to check it
with. That row arrives in the restore and silently overrides the
ConfigMap, so an eval could measure production's settings while appearing
to measure the job's. Currently all four columns are null on production,
so the environment does apply — but that is a fact about today, not a
property of the design.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both blocking findings from the review, and both were already written
down in this repo.
kubernetes/RECOVERY.md:198-201 says the barman plugin MUST be declared
for bootstrap.recovery, with isWALArchiver: false to keep it read-only —
and notes that an earlier version of that runbook said to omit it, which
fails at instance start with "Unknown plugin:
barman-cloud.cloudnative-pg.io". externalClusters only says where the
backup is; it does not get the plugin injected into the pod. So the
read-only guarantee comes from the flag, not from leaving the plugin
out, and the previous commit would not have restored at all.
The namespace also never admitted the database namespace. The CNPG
operator dials each instance at https://<pod-ip>:8000/pg/status, and
without it the Cluster reports "Instance Status Extraction Error" while
Postgres serves normally (issue #433). Every other CNPG-backed namespace
policy here carries the rule; this one is scoped the same way, to the
pprleval-pg endpoints and that one port.
Storage moves to ceph-block-ephemeral for all five volumes while fixing
the first point, because RECOVERY.md makes the same choice for the same
reason: ceph-block is Retain, so a rebuilt-on-demand copy would leave
Released PVs and orphaned RBD images behind every time (issue #262).
The images keep their pinned tags without $imagepolicy markers, now with
a comment saying so: a measurement is comparable only against a fixed
version.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Choosing a chat model, and later an embedding model, means running the
same 50 documents through each candidate. That has been happening against
the live archive: the embedding model was swapped under it, the LLM index
rebuilt twice, the chat server broken for an afternoon, and the UI left
spinning while an eval held the only GPU slot. None of that belongs there.
The database is 90 MB and the AI path never opens a file — it reads
title, content and filename out of Postgres — so a copy costs a restore
and an empty media volume rather than a second copy of the documents.
Restores from paperless's backup store and archives nothing back. Three
omissions carry that, rather than any setting pointing somewhere safe:
no spec.plugins the cluster has no barman plugin at all, so no
WAL is archived
no retentionPolicy the source ObjectStore points at the production
prefix; with a retention policy this namespace
could expire paperless's backups
no ScheduledBackup nothing here is worth keeping
pg_basebackup would have avoided touching the store at all, but it needs
the production cluster's credentials, and those live in a Secret in the
paperless namespace that this one cannot read.
Everything that exists to serve people is left out: no ingress, no gate
outpost, no hostname, no remote-user auth, no Tika or Gotenberg, no mail
or FTP consumption. Nothing signs in and nothing is consumed, so the
instance is unreachable from outside the cluster and the documents are
not exposed a second time.
The deployment here is a smoke test — it proves the restore is usable and
that the search index builds. The evaluation runs as jobs that bring
their own paperless and llama-server, since a paperless instance can only
point at one chat endpoint and two candidates need to run at once.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both agents stopped reading on 2026-08-27 and had read nothing since:
k8s-1 at 12:15:41Z, k8s-2 at 19:14:27Z, each frozen at its line count
(35449 and 29917) while Traefik kept logging normally.
Each had hit its first kubelet log rotation after starting. The paths
under /var/log/containers are symlinks into /var/log/pods, and kubelet
rotates the target at containerLogMaxSize; the inotify tailer reopens
the path, waits for a file that is already present, and never returns.
Nothing about the result looks broken: pods Running and Ready, LAPI
answering, bouncers pulling, cumulative counters still showing large
numbers. The only symptom is that no scenario fires again, which reads
as quiet rather than blind.
So this was not a one-off. Rotation happens roughly every eight hours
here, and the agents die on the first one after every start, which
bounds local detection to a few hours at a time.
CrowdSec diagnoses this itself and names the fix, at agent startup:
File /var/log/containers/traefik-...log is a symlink, but inotify
polling is enabled. Crowdsec will not be able to detect rotation.
Consider setting poll_without_inotify to true in your configuration
Upstream carries the identical case as crowdsec#2449 (k3s + Traefik),
and the chart gained this field in helm-charts#109 to fix it. It does
not conflict with the force_inotify: true the chart hardcodes: that
governs the fsnotify watch on the directory, used to discover the log
of a replacement Traefik pod, while this governs how an already
discovered file is read. Verified by rendering the chart.
The acquisition ConfigMap is checksummed into the agent pod template,
so this rolls the DaemonSet and the stalled agents restart with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four Paperless requests overlapped and all four died:
decode: failed to find free space in the KV cache
srv decode: Context size has been exceeded.
They wanted 6209, 6472, 4303 and 1271 tokens from a shared pool of
16384. Auto slot count opens four of them and is also what enables
--kv-unified, so they draw on one buffer rather than a quarter each --
generous for a single caller, and impossible for four.
They overlapped because a client timeout does not stop the generation
behind it. The eval abandons a request after 300 s and moves to the next
document; the abandoned one keeps consuming the budget the next one
needs, and one slow document is enough to start the pile-up. With one
slot they queue instead, which is what should happen: nothing calls this
server in parallel on purpose, and Paperless's celery worker runs at
concurrency 1.
Deliberately no liveness probe alongside this. /health answered 200 in
0.4 ms throughout, which looks like the probe failing at its job -- but
the server drained the batch and served again by itself, so a probe that
restarted the pod would have turned a self-healing incident into an
outage. With a single slot, a probe asking for a real completion would
also queue behind live work and kill healthy pods mid-document.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#767 broke the model. Asked to classify a document and answer with JSON
only, the server returned 1025 tokens of "!" against a 650-token prompt.
That is the KV cache emitting numbers rather than language, and it takes
everything downstream with it: Paperless ran past its 300 s timeout on
every document, Home Assistant got an empty completion once and a
hallucinated list of devices that do not exist another time.
None of that looked like a backend fault from the outside. The server
starts, /props reports the requested size, /health passes, and the
OpenAI response is well-formed with finish_reason "stop". Only the
generated text gives it away, which is why three separate symptoms got
blamed on prompt size, on tokenizer behaviour and on the RAG context
before anyone read the output.
The boundary is between the two changes rather than at either: #765 left
16384 per slot and produced coherent answers; #767 raised it to 32768 and
produced this. The same Vulkan/Mesa stack already fails at --ubatch-size
2048 with ErrorDeviceLost, so a ceiling here is not a surprise -- only
that it degrades quietly instead of erroring.
--parallel goes with it. Unset, slots return to auto, which is what keeps
--kv-unified enabled ("if number of slots is auto"), so the auto slots
share one buffer and each may draw the full 16384.
Home Assistant needed 18910 tokens for one Assist request and cannot have
them at this ceiling. That path is closed, and Paperless -- which fits in
16k and never needed more -- is what this restores.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
proxy-2 has been enforcing since 14:21:54 without incident, so the role
default moves to nftables and the host_vars override goes away rather than
being duplicated for proxy-1. proxy-2's rendered config is unchanged by this,
so it will not restart; only proxy-1 does.
The staged rollout did its job and can be read off the deploy log:
proxy-1: deployed in mode "dry-run"
proxy-2: deployed in mode "nftables"
and only proxy-2 restarted, which showed up as a 1,887,882-byte startup pull
from its address alone.
The delivery path was tested in enforcing mode with a decision on 192.0.2.1
(TEST-NET-1): both hosts had the ban 3s later as a 205-byte delta, and the
revocation as a 203-byte delta 1-2s after deletion.
What is still not proven is that a packet is actually dropped, and the
measurement for that argues for finishing the rollout rather than waiting.
Requests from banned addresses reaching Traefik stopped after proxy-2 armed —
1 at 14:02, 3 at 14:07, 22 at 14:08, 1 at 14:18, none after — but 25 of those
27 arrived in a single burst beforehand, so at this volume that is consistent
with enforcement rather than evidence of it. With both hosts armed the claim
becomes falsifiable instead: no request from an address in the decision set
should reach Traefik at all, and a single one would prove otherwise.
dry-run remains available per host through inventory/host_vars, which is what
a newly added proxy should get before it enforces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Arming the bouncer on proxy-2 (#766) merged and deployed nothing. The switch
lives in ansible/inventory/host_vars/proxy-2.renner.uno.yml, and the trigger
list covered ansible/roles/** and the play but no inventory path at all. The
change sat in git looking applied while the host kept running dry-run, and
nothing reported a problem — there was simply no pipeline run to fail.
The bouncer's LAPI key has the same shape: it lives in
group_vars/netbird_proxy/vault.yml, so a rotation would never have reached the
hosts either.
Both paths are now listed. pve_hosts.yml deliberately is not: it is the single
inventory file for the whole fleet, so listing it would redeploy the proxies
for any unrelated host edit. The cost is that a change to [netbird_proxy] group
membership needs a manual deployment run.
Merging this triggers the run itself, since .woodpecker/deploy-netbird-proxy.yml
is in its own trigger list — so the arming from #766 applies with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`llama-server --help` documents both halves of what #765 got wrong, and
it ships in the image:
-np, --parallel N number of server slots (default: -1, -1 = auto)
-kvu, --kv-unified use single unified KV buffer shared across all
sequences (default: enabled if number of slots
is auto)
So leaving --parallel unset did not merely pick a slot count, it enabled
a shared KV buffer that every slot could draw the full --ctx-size from.
That is why this server reported n_ctx_slot 16384 across four slots, and
the startup log said so outright: kv_unified = 'true'. Naming a slot
count switches that off and splits the context.
The value stays 65536. Only the reasoning in the comment changes, from an
inference drawn off two /props readings to the documented behaviour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#765 claimed --ctx-size was per slot and halved the slot count to pay for
doubling it. Both halves of that were wrong. With --parallel set,
llama.cpp divides --ctx-size across the slots, so 32768 over two slots
gave 16384 each -- exactly what was there before -- and the Assist
request fails with the same message it did then:
request (18910 tokens) exceeds the available context size (16384)
The flag does behave differently without --parallel, which is where the
wrong reading came from: the four-slot server reported n_ctx_slot equal
to --ctx-size rather than a quarter of it. Adding --parallel changed
that, and the memory reading confirms it -- the pod fell from 24979 MiB
to 21601 MiB, so total KV halved rather than holding steady.
65536 across two slots is 32768 each, and puts total KV back where it was
before #765 along with the memory that came with it.
Verify rather than assume this time:
kubectl -n ai exec deploy/llama-server -- curl -sS \
http://127.0.0.1:8080/props | jq .default_generation_settings.n_ctx
must report 32768.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 16:13:35 +02:00
26 changed files with 2599 additions and 44 deletions
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.