crowdsec, external-dns-unifi, netbird-ingress and velero were in the
infrastructure Kustomization, while the ExternalSecrets that create their
Secrets were in infrastructure-crds, which depends on infrastructure. On a
cold start each was therefore installed before its Secret could exist: the
pods sat in CreateContainerConfigError until OpenBao was unsealed and ESO
synced. For the HelmReleases (crowdsec, external-dns-unifi, velero) Helm's
wait timed out meanwhile; with remediation.retries: 3 they give up after
four attempts and stay failed even once the Secret appears. Velero failing
that way stops every restore.
| workload | Secrets |
|---|---|
| crowdsec | crowdsec-bouncer-key (crowdsec, ingress), crowdsec-edge-bouncer-key |
| external-dns-unifi | external-dns-unifi-secret |
| netbird-ingress | netbird-ingress-setup-key |
| velero (+ backup-verify CronJob) | cloud-credentials, cloud-credentials-hetzner, hcloud-backup-verify |
Each now has a Kustomization of its own (clusters/homelab/
infrastructure-services.yaml), depending on infrastructure and
infrastructure-crds, with its ExternalSecrets beside it. The openbao
ClusterSecretStore in infrastructure-crds turns Ready only once OpenBao is
unsealed, so the Secret exists before the consumer installs. One
Kustomization each so that a broken service holds back only itself.
Velero's Schedules need the velero.io CRDs from its HelmRelease, so they move
from post-crds to infrastructure/velero-schedules with a Kustomization after
velero.
post-crds keeps cloudflare-api-token (consumed by its own ClusterIssuer) and
flux-git. The directories themselves stay where they are, so paths such as
the CI test infrastructure/velero/backup-verify/tests/run.sh are unchanged.
netbird-ingress gets 10m to roll its DaemonSet over three nodes
(maxUnavailable 1, minReadySeconds 45). The crowdsec comments name the
Kustomization it now runs in, and flux-graph.sh colours the new nodes.
Production rollout: infrastructure and infrastructure-crds must run with
prune: false (the stack's first PR) when this merges. Pruned, the velero
HelmRelease would be uninstalled, and the moved ExternalSecrets would take
their Secrets with them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reads every kustomize.toolkit.fluxcd.io/v1 Kustomization in
kubernetes/clusters/homelab and emits Graphviz DOT, one edge per dependsOn
entry, pointing from the dependency to its dependent. Each node carries its
wait mode and timeout, drawn as Flux actually checks it: a Kustomization
with healthChecks and no wait is health-checked (as infrastructure is), one
with neither has no check at all, whatever its healthCheckExprs say.
Infrastructure is green, the Velero restore chain blue, wait: false dashed,
suspended Kustomizations grey.
With no argument or a .dot path it writes the full graph; with a .svg or .png
path it renders the transitive reduction (tred). Generated rather than
checked in, so it cannot drift from the manifests. Works with either yq
(mikefarah's or the Python wrapper): yq only converts YAML to JSON, jq builds
the DOT.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
run.sh asks a Mistral agent (mistral-large-4-0) through the Conversations API. The agent reads the repo and the PR through the read-only Gitea connector, so the review step no longer holds a Claude token or a local checkout. The Claude CLI invocation, the model picker and the context clones stay in run.sh as comments. The answer is cut at the first heading and empty or None-only sections are dropped before the review event is derived. A tool call that is not read-only fails the run and nothing is posted. A failed request now fails the pipeline.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Embeds a labelled Paperless corpus and scores nearest-neighbour correspondent and document-type accuracy against ceiling and floor. Standard library only.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Comments that pointed at other YAML as `file.yaml:N` went stale on any
edit above the cited line; three drift fixes had been merged by hand
(REN-181). The cited block now carries `# anchor: <name>` and the
comment cites `path.yaml#<name>`.
- Sweep: all 32 line-number citations in YAML comments converted.
Several were already stale at head and now point at the right block
(see PR). Vendored files (Flux gotk-components, IMC CRD) get no
anchor; those two citations name the object/schema path instead.
- Guard: scripts/ci/yaml-citations.sh in ci-manifests-test.yml; fails
on a line-number citation or a path#name whose file/anchor is gone.
Self-test in ci-scripts-test.yml.
- CONTRIBUTING.md: the convention in three lines.
Comment-only under kubernetes/: every file parses to identical objects
before and after, so Flux applies nothing and no ConfigMap data changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing in this repo checked an alert expression. ci-manifests-test.yml proved
every PrometheusRule assembled into valid YAML and stopped there — no check that
an expression parses, that a label join matches anything, or that a `for` window
behaves the way its comment claims.
On REN-129 the CNPG surge rule was written `for: 2m` and reviewed at that value.
Against Iris's seven measured nights it fires on six of them: two owner emails a
night, 04:16 and 04:22 local, for a WAL backlog that clears itself in under ten
minutes. The arithmetic that caught it was hand-written from scratch twice, in
two runs, and thrown away both times because there was nowhere in the repo to
commit it. It is now a negative control.
Adds:
* scripts/ci/promtool-rules.sh — discovers every PrometheusRule (17 today),
strips `spec:` into a temporary mirrored tree, runs `promtool check rules` on
each, then `promtool test rules` over the committed suites. Discovery floors
on both, as in the sibling steps.
* a `promtool` step in ci-manifests-test.yml, `depends_on: []` like the other
four, on prom/prometheus:v3.15.0. The 3.x pin is load-bearing: the cluster
runs kube-prometheus-stack >= 72.0.0, and alert `for` evaluation is
version-sensitive, so a 2.x promtool would check a different evaluator than
production. The script asserts the major version it is handed.
* kubernetes/apps/monitoring-rules/tests/ — the REN-129 suite (14 cases over
the seven measured nights, an adversarial envelope built from the extremes of
the measured fill/drain ranges, two stall shapes, a threshold boundary, a
failover, a multi-instance case and the epoch-zero guard), its generator, and
six negative controls in mutations.txt.
* a mutation phase. Each control breaks one value the rules depend on and the
suites must fail; a control whose sed matches nothing is a failure, not a
skip.
* scripts/ci/test-promtool-rules.sh, run from ci-scripts-test.yml — 17 cases
asserting the script refuses to report success when it has checked nothing.
The mutation phase is not belt-and-braces, it is the point. Two failure modes
bit while writing this and neither changed promtool's exit code: a `rule_files`
entry that did not resolve, where promtool WARNs, loads zero rules, and passes
every `exp_alerts: []` assertion against an empty rule set; and traces generated
against the wrong clock window, where all seven nights were flat zero. Both were
green. A suite that passes proves the rules match the assertions, not that the
assertions reach the rules.
Production: nothing applied to the cluster. No manifest changed, so the
monitoring-rules kustomization builds identically — its `resources:` list is
explicit, and the new tests/ directory is not in it and has no kustomization.yaml
of its own, so Flux does not see it. Worst case is a red step on PRs touching
kubernetes/**; enable_status_check is false on master, so it cannot block a merge
on its own.
Verified with real promtool 3.15.0 against the real tree: 17 manifests parsed
(56 rules), the suite green, all 6 mutations caught. Run again under BusyBox
1.35.0 sh/awk/sed/find/xargs, the toolchain prom/prometheus actually ships.
Guard tests 17/17 under both bash and BusyBox sh. shellcheck 0.11.0 clean at
every severity. woodpecker-cli 3.18.0 lint clean on all 17 pipeline files.
Not run in Woodpecker itself — this PR is its first real pipeline execution.
Revert: revert this commit. It adds files and one step; nothing else reads them.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First live pick after #893 chose claude-sonnet-4-5 at 0.23 over
claude-sonnet-4-6: same-family models split the probability for a
mid-weight task, and "prefer newer" is a policy, not a judgment. New
scripts/ci/choose-model.sh applies it to the distribution: among
candidates within CHOOSE_MODEL_MARGIN (0.7) of the best probability the
most recently released wins; a clear best stays; NONE yields nothing.
The picker logs the top probabilities. Four policy tests added.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The picker ran a full claude -p in plan mode per selection and parsed
JSON out of prose. With TYPESAFE_API_KEY set it now sends the task and
the catalog rows as state and asks one Choice over the model ids, each
with its catalog row as criterion plus NONE; the id is validated against
the pristine catalog as before. No key or a failed request falls through
to the claude picker unchanged.
Measured on the two-model fixture (Opus 5 / Haiku 4.5): issue-fix and
security-fix -> Opus 0.99, alert triage -> Opus 0.62, wiki audit ->
Haiku 0.94, update audit -> Haiku 0.46, distill -> Haiku 0.63; about a
second per pick. Dry-run test added to test-model-selection.sh.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
None of this is reconciled by Flux — bootstrap.sh, the vmtest Job and
the Talos hcloud patches are operator tooling, applied only when a
rebuild test runs. Zero effect on the live cluster from merging.
New: an hcloudtest phase that provisions three cpx42 servers with
private cluster/storage networks, a this-host-only firewall and one raw
OSD volume per node, then hands over to the ordinary talos phase through
the same four variables vmtest uses. Servers carrying the test label are
adopted and rebuilt from the Talos snapshot instead of recreated —
capacity, not cleanliness, is the scarce resource. Network ranges and
volume sizes are derived from what the cluster itself declares (the site
patch and the machine patches), never stated a second time.
hcloudclean deletes everything by label.
The vmtest Job clones from the GitHub mirror instead of in-cluster
Gitea: a rebuild rehearsal cannot depend on the thing whose loss it
rehearses, and a run died on resolving gitea.renner.uno before the
first VM booted.
bootstrap.sh hardening from a week of rehearsals: the OpenBao unseal
loops until the StatefulSet converges instead of unsealing whichever
pods existed at one instant, require_secrets takes a subset, secrets can
be streamed into a waiting run, retries around talosctl bootstrap and
server placement, and node waits print progress and refuse an expected
count of zero.
import-cluster-access.sh keeps its default AWS profile but stops
overriding one the caller already chose.
Known boundary, documented in site-hcloud.yaml: from master the hcloud
site validates provisioning through cluster-Ready; the SAN path needs
the follow-up rollout PR that renames the storage NIC by role.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>