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>
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>
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>
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>
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>
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>