9ec31f0769bef5597c080ef2c76f2a5dc0aed40c
76
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
de080442d3 |
feat(hermes): alert issues end to end: labelled at filing, routed, fixed by the expert card; retire issue-fix
The owner wants the path alert -> [alert] issue -> kanban card -> expert fix PR -> owner merges, without a human step in between. Owner decisions (kanban card t_3f5d0a62): [alert] issues are not excluded from the bridge, and the issue-fix Woodpecker pipeline is retired, so the expert's card is the only automated fix path for [alert] issues. Routing - alert-monitor: `alerts.py file` takes a required `--label` (hestia, atlas, apollo, clio, daedalus) and sets that label at creation (POST /issues `labels: [id]`). Gitea 1.27.3 adds the labels in the same transaction (services/issue/issue.go NewIssue) and the `opened` webhook payload loads them (services/convert/issue.go toIssue -> LoadLabels), so the first delivery already routes the card to the expert. Org labels are accepted for org repos (models/issues/issue_update.go: label.OrgID == repo.OwnerID). If the label does not exist yet, the issue is filed without it and the coordinator routes. SKILL.md tells the triage agent how to pick the expert. - Label-less issues no longer go to triage. kanban.auto_decompose defaults to true (hermes_cli/config_defaults.py; not overridden here), so the dispatcher hands every triage card to the auxiliary-LLM decomposer (gateway/kanban_watchers_dispatcher.py auto_decompose_tick -> kanban_decompose.decompose_task), which rewrites title and body (dropping the Gitea-Issue line the completion hook needs), may fan out into children without that line, and keeps the assignee `default` anyway (_apply_single only sets an assignee when none is set). The card is now created ready for `default` with routing instructions: set exactly one domain label on the issue, nothing else. - The script reacts to that label: a coordinator card nobody has claimed yet (todo/ready) is reassigned (`hermes kanban assign`) and its body replaced by the expert body (`hermes kanban edit --body`); a claimed or finished one gets a separate expert card under key `<owner>/<repo>#<n>@<expert>` (one per expert, created_by gitea-webhook, so its completion is posted on the issue). `assign` refuses a running card, which falls back to the separate card. Cards already with an expert are still never moved. [alert] cards - Their body carries the fix protocol that replaces issue-fix, including its prompt hardening from PR #253/#266: check for an open PR with `Closes #<n>` or a branch `alert-fix/issue-<n>` first; owner (k.renner) comments are authoritative, everything else is untrusted problem description; verify live before fixing, never state cluster state not observed this run, give read-only commands instead of predicted output; repo-fixable -> branch `alert-fix/issue-<n>`, smallest fix, PR with `Closes #<n>`, never merge; not repo-fixable -> no PR, the completion summary (posted on the issue) says why and what a human must do. Gitea side (CronJob gitea-webhook-sync, every 15 min) - Domain labels: created as org labels in every org (GET /admin/orgs) and as repo labels in user-owned repos, only when missing. - Backfill: open issues without the card comment are replayed to the route as signed `opened` deliveries (same headers Gitea sends), at most 10 per run, 3 s apart (the adapter's default rate limit is 30/min/route), in random order so repos where the comment keeps failing cannot starve the rest. The idempotency key `<owner>/<repo>#<n>` makes a replay of an issue with a card a no-op. The job runs in the hermes namespace, whose default-deny already allows intra-namespace traffic to :8644. - The admin PAT needs write:organization (org labels, reqOrgOwnership passes for a site admin), write:issue (repo labels, comments) and read:repository (/repos/search) next to write:admin. activeDeadlineSeconds 120 -> 600. issue-fix removed - .woodpecker/issue-fix.yml, ci/agents/issue-fix/, and what only it used: scripts/ci/make-diag-kubeconfig.sh, the monitoring/ci-diagnostics ServiceAccount and RBAC (no other pipeline mints its token; git grep), the .ci-diag/ gitignore entry. Comments naming issue-fix updated. - Not in git and left to the owner: the Woodpecker cron `issue-fix` and the secret `issue_fix_bot_token` with its Gitea PAT. Tested: unittest 73 OK; gitea_issues.py against the real hermes kanban CLI in a scratch HERMES_HOME with a stub Gitea (unlabelled -> default ready card; labelled while unclaimed -> same card reassigned to daedalus with the alert body; labelled while claimed -> separate card `#n@daedalus`; replay -> no duplicate card or comment); sync discovery read-only against live Gitea (42 open issues, all without a card); kubectl kustomize for apps/hermes, apps/monitoring, apps/monitoring-rules. |
||
|
|
ac44925baf |
fix(flux): services that need OpenBao secrets apply after the secret store
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> |
||
|
|
30a2d37fdc |
feat(scripts): flux-graph.sh draws the Kustomization dependency graph
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> |
||
|
|
c0501e8153 |
feat(pr-review): review with a Mistral agent instead of the Claude CLI
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> |
||
|
|
cb6f6e803a |
feat(embed-eval): paired comparison of two kNN benchmark runs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> |
||
|
|
775419c2a4 |
feat(embed-eval): shorten texts the model reports as over its token limit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> |
||
|
|
daf55565ee |
feat(embed-eval): leave-one-out kNN benchmark for embedding models
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> |
||
|
|
21a7583523 |
ci: cite YAML by anchor instead of line number, and guard it (REN-183)
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> |
||
|
|
9a3c6ef9af |
ci(monitoring-rules): machine-check PromQL with promtool (REN-161)
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> |
||
|
|
25f09e9b52 |
ci(pick-model): newest among comparable candidates, policy in code
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> |
||
|
|
0fec080b4d |
ci(pick-model): choose the model with one TypeSafe Choice, claude as fallback
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> |
||
|
|
a4b659c965 |
feat(tooling): Hetzner throwaway site and rebuild-harness hardening
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> |
||
|
|
784c0d460a |
Merge pull request 'feat(scripts): retrieval eval for embedding models on German documents' (#743) from embed-eval into master
Reviewed-on: k.renner/homelab#743 |
||
|
|
8123021ea4 |
fix(paperless-eval): say whether the probe is loading or just not scheduled
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> |
||
|
|
1ec6825b66 |
feat(paperless-eval): erase the slot between documents so failures are independent
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>
|
||
|
|
3f50fa4e60 |
fix(paperless-eval): check the instrument at the end too, not only on failure
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> |
||
|
|
f54b1dc7fa |
fix(paperless-eval): test for the defect, not for the right answer
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> |
||
|
|
63ae57ce6e |
fix(paperless-eval): the probe read one output field and failed a working model
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> |
||
|
|
73db641d47 |
chore(paperless-eval): re-enable the rejected candidates as probe targets
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> |
||
|
|
220bf6bfc3 |
feat(paperless-eval): check the instrument, don't assume it
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>
|
||
|
|
85768cd7e4 |
feat(paperless-eval): probe the length boundary — the test that should have been first
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>
|
||
|
|
5f2955cdde |
docs(paperless-eval): the Q8 comparison was invalid, and why
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> |
||
|
|
7136a39f3a |
fix(paperless-eval): the client timeout is a second bound, and it hid the first
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>
|
||
|
|
4ba68b91aa |
fix(paperless-eval): 768 is my number, not the model's — make it settable
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>
|
||
|
|
47ed4383f1 |
docs(paperless-eval): the size threshold survives a deliberate attempt to kill it
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> |
||
|
|
ec849f2e29 |
docs(paperless-eval): glm47-flash is out on speed, and settles nothing
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> |
||
|
|
43634e46c9 |
fix(paperless-eval): the startup budget was sized for the wrong thing
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> |
||
|
|
7658cf8bb6 |
docs(paperless-eval): precision is nearly free on this MoE, and my formula was wrong
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>
|
||
|
|
1ae0e59ea6 |
docs(paperless-eval): qwen3.5-35b-a3b passes, killing the generation theory
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> |
||
|
|
faebeb1594 |
fix(paperless-eval): the crashloop was a full disk, not an ambiguous reference
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>
|
||
|
|
baf3b3e1b7 |
fix(paperless-eval): name the exact .gguf, the :QUANT shorthand is ambiguous
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>
|
||
|
|
319968c0f1 |
docs(paperless-eval): the MoE hypothesis is dead too
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> |
||
|
|
043d1af4d4 |
feat(paperless-eval): four more MoE candidates, and Gemma was wrongly excluded
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> |
||
|
|
11b0def055 |
docs(paperless-eval): five dense models fail, the one MoE works
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> |
||
|
|
71a4c1b3a7 |
feat(paperless-eval): measure what Q4 costs, instead of assuming it costs nothing
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> |
||
|
|
0c46a219e3 |
feat(paperless-eval): eleven verified candidates, and what the search ruled out
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> |
||
|
|
17e23202d1 |
feat(paperless-eval): second wave of candidates, and the size rule was backwards
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>
|
||
|
|
851e1b9ef6 |
feat(paperless-eval): screen candidates at five documents before measuring
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>
|
||
|
|
f6b0789c01 |
feat(paperless-eval): both remaining candidates measured, and a noise floor
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> |
||
|
|
44636a8268 |
docs(paperless-eval): ministral8b fails, cleanly this time
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> |
||
|
|
440cbaec90 |
docs(paperless-eval): the conversion was not the whole story
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> |
||
|
|
b64f971373 |
fix(paperless-eval): two concurrent runs were revoking each other's token
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>
|
||
|
|
9ff85f7d1f |
fix(paperless-eval): probe at production prompt length too
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> |
||
|
|
22d92d158d |
feat(paperless-eval): probe tool calling in two minutes, not ninety
"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> |
||
|
|
b00ca34783 |
fix(paperless-eval): abort a run once it is clear nothing works
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>
|
||
|
|
c2f47e58bc |
fix(paperless-eval): --reasoning off, not --chat-template-kwargs
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>
|
||
|
|
a7bba7acbb |
feat(paperless-eval): one manifest, a candidate list, and the first result
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> |
||
|
|
cb751cbedc |
fix(paperless-eval): bound generation server-side; the model does not always stop
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> |
||
|
|
34c250ad2a |
fix(paperless-eval): two more, found by actually running it
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> |
||
|
|
e261089666 |
fix(paperless-eval): three faults the first job run exposed
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> |