7 Commits
Author SHA1 Message Date
lukaszraczylo 708622e2a8 feat(tests): add 16 advanced distractor-rich scenarios
The 2026-08-30 suite-health review found 223/283 tests saturated. These
16 target the failure modes that still discriminated, one per existing
subcategory across all eight categories:

- multi-constraint reasoning: infeasible-plan detection, conflicting
  delegation policy, adversarial tool descriptions
- forensic root-causing from noisy evidence: RAG stage attribution,
  EXPLAIN ANALYZE with a red herring, interleaved multi-source incident
  logs, README-vs-config conflict resolution
- composed gotchas: a Go 1.22 capture + named-return-defer trace, a
  Python late-binding + shared-mutable-default trace
- constraint math: context-budget packing, capacity-fill projection,
  MVCC anomaly classification, Docker layer-rebuild prediction, dual-key
  rotation overlap, freeze-window scheduling, an open-redirect exploit
  chain

Every answer is a structured JSON value scored by an exact evaluator,
never a prose heuristic (the source of every false positive in the
review). A ground-truth self-consistency test asserts each documented
correct answer scores 1.0 through its real evaluator.
2026-08-30 19:41:02 +01:00
lukaszraczylo 6ad9dfbe42 fix(runner): record wall-clock latency on error rows
An error row with zero latency reads as an instant rejection when it
can be three exhausted request timeouts. A 651-second thinking-model
generation looked instant in the 2026-08-30 run and misdirected the
diagnosis toward gateway rejection. The error path now records the
wall clock across all attempts.
2026-08-30 16:19:21 +01:00
lukaszraczylo 7616f4bdee fix(llm): retry 429 responses and 2xx bodies carrying an error object
Six qwen3.8-27b-uncensored requests in the 2026-08-30 run failed
instantly with zero tokens while the replica was warming; the gateway
reported them in-band and the client gave up on the first attempt. Both
shapes now retry within the existing backoff budget.
2026-08-30 14:19:35 +01:00
lukaszraczylo d9b7ca9cc4 feat(config): raise max_tokens_default to 40000
At 25000 the heaviest thinking-model tests exhausted the whole budget
on reasoning and scored zero with an empty answer (8 truncated cells in
the 2026-08-30 3-model run, e.g. ts-eslint-flat-config on
qwen3.8-flash-next at exactly 25000 completion tokens).
2026-08-30 14:19:22 +01:00
lukaszraczylo 499956f8cc fix(tests): disambiguate two prompts, exempt --force-if-includes
git-detached-head-recovery: all three benchmarked models answered
["branch"], which the prompt's literal ask (save the commits) makes
correct while the evaluator requires ending attached. The prompt now
demands ending checked out on the new branch and names the bare-branch
gap explicitly.

scen-crashloop-order: the instruction excluded only acting steps, so
including the read-only journalctl step E followed the letter of the
prompt. The instruction now asks for the smallest sufficient set and
excludes redundant diagnostics.

git-force-with-lease-vs-force: exempt --force-if-includes, the
stricter safe variant, from the bare --force pattern.
2026-08-30 14:19:08 +01:00
lukaszraczylo 6b0bea1193 fix(eval): remove four negation-detector false-positive classes
Replay of the 2026-08-30 3-model artifact showed the same correct
answers zeroed for every model. Four mechanical causes, each with a
regression fixture:

- A dot glued to a following non-space character ("e.g.,",
  "release/2.4") cut the clause window between a match and its cue.
  A dot now ends a clause only before whitespace, and common
  abbreviations (e.g., i.e., etc., vs.) never do.
- Markdown emphasis and code fences inflated cue distance past the
  window cap, and fence-adjacent blank lines created fake paragraph
  gaps. The scanner now strips backticks and double asterisks and
  collapses layout gaps around fences before matching.
- A single cue did not distribute over a comma enumeration ("not ...
  kubectl edit, kubectl patch"). An unnegated match now inherits the
  negation of an earlier negated match in the same clause.
- Consequence descriptions ("deterministic", "silently", "will
  replace", "a bare") were not recognized as cues.

Replayed against the saved artifact: 7 of 8 false positives now score
full credit; the eighth stays partial on purpose (that response does
endorse a temporary live edit).
2026-08-30 14:18:53 +01:00
lukaszraczylo cde7c7ce14 Update 2026-08-30 12:37:38 +01:00
38 changed files with 5376 additions and 455 deletions
+185 -25
View File
@@ -64,9 +64,17 @@ Both `list` and `run` accept:
- `--models` - comma-separated model list, overriding the config file.
- `--format` - `table`, `markdown`, or `json`. Default: `table`.
- `--tests` - comma-separated exact test IDs (no globs), overriding
`--category`/`--subcategory` - for probing individual tests cheaply.
- `--repeat N` - sample each (model, test) pair N times. Sampling
temperature is pinned to 0, so disagreement between repeats
exposes response instability (server-side batching, MoE routing) rather
than decoding randomness. Default: 1.
- `--concurrency` - override the config file's `concurrency`.
- `--timeout` - override the config file's `request_timeout` (for example
`60s`).
- `--out FILE` - write the run as a JSON artifact (raw per-attempt results
plus the per-test discrimination rollup) for later `llmtest compare`.
- `--quiet` - suppress progress output on stderr. Progress is on by
default: a run can issue hundreds of requests at 30-50s each, and a
silent CLI reads as a hang.
@@ -78,6 +86,54 @@ with a `TRUNCATED` marker when a response was cut off by the token
budget). stdout carries only the table/markdown/json report, so `llmtest
run --format json > results.json` stays pipeable regardless of `--quiet`.
With `--repeat N>1`, each (model, test) cell reports the mean over
attempts; when attempts disagreed, the cell appends the `[min-max]` range
and the discrimination rollup flags the test as unstable.
### Comparing two runs
`compare` diffs two saved artifacts (from `--out`) per (model, test) pair.
It re-aggregates each side with the same truncation policy the report
uses, so a comparison stays honest even when only some attempts were cut
off. A mean drop of >= 0.01 is a regression, an equal-size rise an
improvement; tests present on only one side are listed as
`baseline-only`/`current-only`. The exit code reflects only diff
failures (unreadable artifacts), not regressions:
```sh
go run ./cmd/llmtest run --out baseline.json
# ... change config, upgrade the endpoint, swap model ...
go run ./cmd/llmtest run --out current.json
go run ./cmd/llmtest compare baseline.json current.json
```
### Auditing suite health
`health` reads one or more saved artifacts and rolls them up per
subcategory: what share of (model, test) cells score perfect, how many
tests are saturated (perfect in >= 90% of their cells, so they cannot
separate models), and which tests still carry signal. Cells pool across
artifacts taking each (model, test) pair's WORST artifact, so a lucky
re-run cannot hide a failure; subcategories at 100% perfect across every
model contribute zero ranking information and need harder tests:
```sh
go run ./cmd/llmtest run --out run1.json
go run ./cmd/llmtest health run1.json # one artifact
go run ./cmd/llmtest health run1.json run2.json # pooled audit
```
### Reading the discrimination rollup
`table` and `markdown` output end with a `Discrimination` section. Its
headline counts the tests whose cross-model score spread reaches
`DiscriminationSpread` (0.05) - the tests that actually separate models -
plus the tests whose repeated attempts disagreed. A suite where nearly
every test scores 1.00 for every model measures nothing about relative
capability, and this section makes that visible per test instead of
averaging it away; with a single model there is no spread, so the section
reports instability only.
If `config.yaml` omits `concurrency`, `request_timeout`, or
`max_tokens_default`, `internal/config` fills in `8`, `300s`, and `12000`.
The effective per-call token budget is `max(test.MaxTokens,
@@ -108,16 +164,22 @@ needs a local toolchain that is not installed (for example, a Python
exec-evaluator when `python3` is missing). Skipped tests are excluded from
every mean.
A cell carries a trailing `!` when the model's response was cut off by the
token budget (`finish_reason=length`) - for example `0.00 (6000tok)!`. A
truncated response's score is still whatever the evaluator computed on the
partial text, so a `!` cell needs a closer look: raise
`max_tokens_default` and re-run. `table` and `markdown` print a one-line
legend explaining `!` whenever at least one cell has it. `json` carries the
same signal per result as `"truncated": true`, plus `"finish_reason"` and
`"response_text"` (the exact normalized text that was scored) so a
truncated or unexpectedly low-scoring result can be debugged without
re-running the model.
A cell carries a trailing `!` when at least one of its attempts was cut
off by the token budget (`finish_reason=length`) - for example
`1.00 (420tok)!`. Truncation policy: a truncated attempt was scored on
partial text, so its score measures the token budget as much as the
model. Attempts that finished normally are therefore the only ones
averaged into the cell's mean and its `[min-max]` range; the truncated
sample is discarded and marked by the `!`. Only when every scored attempt
was truncated does the mean fall back to the partial-text scores - then
the `!` means the whole cell is unreliable and `max_tokens_default` needs
rising before the number can be read as capability. `compare` reuses this
policy, so a truncated sample in one artifact cannot fake a regression.
`table` and `markdown` print a one-line legend explaining `!` whenever at
least one cell has it. `json` carries the signal per attempt as
`"truncated": true`, plus `"finish_reason"` and `"response_text"` (the
exact normalized text that was scored) so a truncated or unexpectedly
low-scoring result can be debugged without re-running the model.
## Test catalog
@@ -129,20 +191,117 @@ is cheap, the test file's `_test.go` recomputes it independently (for
example, `internal/tests/python_test.go` recomputes the cosine similarity
with `math.Sqrt` rather than trusting the constant).
1063 tests across 105 subcategories (each holds at least 10, enforced by
`internal/tests/catalog_test.go`). Subcategory minimums and the exact ID
list are the source of truth; `llmtest list` prints them live.
| Category / subcategory | Tests |
| --- | --- |
| programming / golang | `go-struct-align`, `go-worker-pool`, `go-semver-classify` |
| programming / python | `py-log-triage`, `py-cosine` |
| programming / typescript | `ts-debounce-composable` |
| programming / c | `c-struct-size` |
| operations / macos | `macos-timeout-portability`, `macos-launchd-cron` |
| operations / linux | `linux-pct-exec`, `linux-systemd-oneshot` |
| operations / kubernetes | `k8s-crashloop-gitops` |
| research / web | `web-robots-ai-crawlers` |
| research / whitepapers | `paper-hnsw-params` |
| research / codebase | `code-trace-go` |
| agents / tool-routing | `agent-tool-routing` |
| agents / planning | `agent-plan-ordering` |
| programming / golang (10) | `go-bounded-fanout`, `go-channel-deadlock`, `go-context-cancellation-trace`, `go-defer-recover-trace`, `go-generics-constraint-fix`, `go-method-set-interface`, `go-semver-classify`, `go-slice-append-aliasing`, `go-struct-align`, `go-worker-pool` |
| programming / python (10) | `py-asyncio-gather-trace`, `py-cosine`, `py-dict-comprehension-trace`, `py-generator-exhaustion-trace`, `py-json-transform`, `py-log-triage`, `py-mutable-default-arg`, `py-pathlib-rewrite`, `py-regex-log-extraction`, `py-softmax` |
| programming / typescript (10) | `ts-array-method-chain`, `ts-computed-vs-watch`, `ts-debounce-composable`, `ts-discriminated-union-narrowing`, `ts-eslint-flat-config`, `ts-generic-utility-type`, `ts-optional-chaining-nullish`, `ts-promise-allsettled-trace`, `ts-tsconfig-strict-flag`, `ts-vue-ref-reactive-unwrap` |
| programming / c (10) | `c-array-decay-sizeof`, `c-bitmask-ops`, `c-integer-promotion-overflow`, `c-macro-expansion-pitfall`, `c-pointer-arithmetic`, `c-string-function-output`, `c-struct-bitfield-packing`, `c-struct-size`, `c-undefined-behavior-spot`, `c-union-size-lp64` |
| programming / hard (12) | `hard-context-timeout`, `hard-defer-named-return`, `hard-float-binadd`, `hard-go-paren-balance`, `hard-goroutine-race`, `hard-int-division`, `hard-json-unmarshal-null`, `hard-nullish-over-or`, `hard-promise-allsettled`, `hard-py-version-sort`, `hard-regex-greedy`, `hard-var-shadow` |
| operations / macos (10) | `macos-apfs-snapshot`, `macos-caffeinate`, `macos-launchctl-bootstrap`, `macos-launchd-cron`, `macos-mdfind-spotlight`, `macos-plutil-plist-read`, `macos-sed-inplace`, `macos-ssh-identity-agent`, `macos-stat-bsd-gnu`, `macos-timeout-portability` |
| operations / linux (10) | `linux-cgroup-oom-diagnosis`, `linux-cron-expression`, `linux-journalctl-filter`, `linux-lvm-extend-order`, `linux-nftables-dnat`, `linux-pct-exec`, `linux-proc-meminfo-available`, `linux-ssh-port-forward`, `linux-systemd-oneshot`, `linux-systemd-timer` |
| operations / kubernetes (10) | `k8s-argocd-outofsync`, `k8s-cnpg-pdb`, `k8s-crashloop-gitops`, `k8s-imagepullbackoff`, `k8s-networkpolicy-dns-block`, `k8s-pending-taints`, `k8s-pvc-storageclass-mismatch`, `k8s-qos-class`, `k8s-service-selector-mismatch`, `k8s-traefik-ingressroute-host` |
| operations / scenario (11) | `scen-backup-restore-order`, `scen-certexpiry-triage`, `scen-crashloop-order`, `scen-diskpressure-firstcmd`, `scen-inode-exhaustion`, `scen-log-rootcause-mcq`, `scen-lvm-resize-order`, `scen-net-layer-isolate`, `scen-portbind-fail`, `scen-service-degrade`, `scen-systemd-failreason` |
| research / web (12) | `web-canonical-vs-redirect`, `web-cors-missing-header`, `web-dns-mx-record`, `web-hreflang-reciprocal`, `web-hreflang-self-reference-trap`, `web-html-product-extract`, `web-http-status-scenarios`, `web-robots-ai-crawlers`, `web-robots-ua-scope-trap`, `web-security-headers-audit`, `web-sitemap-max-urls`, `web-url-parse-components` |
| research / whitepapers (10) | `paper-attention-scale-factor`, `paper-bloom-fp-rate`, `paper-btree-height-levels`, `paper-cap-availability-choice`, `paper-hnsw-params`, `paper-lsm-write-amplification`, `paper-pq-compression-ratio`, `paper-raft-quorum-failures`, `paper-recall-at-k`, `paper-tfidf-term-score` |
| research / codebase (10) | `code-bigo-dedup`, `code-bug-line-diff`, `code-commit-bisect`, `code-dead-functions`, `code-generic-return-type`, `code-import-cycle`, `code-race-variable`, `code-trace-go`, `code-trace-python`, `code-trace-ts` |
| agents / tool-routing (10) | `agent-tool-routing`, `agent-tool-routing-ambiguous`, `agent-tool-routing-chaining`, `agent-tool-routing-cheapest`, `agent-tool-routing-distractors`, `agent-tool-routing-missing-param`, `agent-tool-routing-multistep`, `agent-tool-routing-no-tool-needed`, `agent-tool-routing-parallel`, `agent-tool-routing-safety` |
| agents / planning (10) | `agent-plan-critical-path`, `agent-plan-cycle-detect`, `agent-plan-idempotency`, `agent-plan-milestone-date`, `agent-plan-minimal-replan`, `agent-plan-ordering`, `agent-plan-parallel-steps`, `agent-plan-repair`, `agent-plan-resource-constrained`, `agent-plan-rollback-trigger` |
| agents / delegation (12) | `agent-deleg-batch-vs-separate`, `agent-deleg-build-then-verify`, `agent-deleg-capacity-trap`, `agent-deleg-escalation-to-human`, `agent-deleg-handoff-context`, `agent-deleg-main-thread-vs-delegate`, `agent-deleg-minimal-privilege`, `agent-deleg-near-miss-roster`, `agent-deleg-parallel-dispatch-safety`, `agent-deleg-reviewer-independence`, `agent-deleg-task-to-specialist`, `agent-deleg-verify-vs-trust` |
| databases / postgres (12) | `pg-bloat-vacuum`, `pg-count-star-vs-join`, `pg-deadlock-lock-order`, `pg-explain-seq-vs-index`, `pg-index-choice`, `pg-isolation-anomaly`, `pg-listen-notify`, `pg-n-plus-one`, `pg-partial-index`, `pg-pool-sizing`, `pg-replica-failover`, `pg-timeout-trap` |
| databases / redis (10) | `redis-cache-stampede`, `redis-incr-atomicity`, `redis-keyspace-anti-pattern`, `redis-lua-atomicity`, `redis-memory-estimate`, `redis-multi-vs-pipeline`, `redis-noeviction-write`, `redis-pubsub-delivery`, `redis-scan-vs-keys`, `redis-structure-choice` |
| databases / sql-tuning (10) | `sql-aggregate-sum`, `sql-composite-index-skip-column`, `sql-covering-index`, `sql-equivalent-rewrite`, `sql-groupby-having`, `sql-join-counts`, `sql-keyset-pagination`, `sql-null-in-where`, `sql-orderby-index-serving`, `sql-window-rank` |
| security / appsec (12) | `sec-csrf-post-trap`, `sec-csrf-requirement`, `sec-idor-spot`, `sec-input-validation-boundary`, `sec-open-redirect-spot`, `sec-orm-interpolation-trap`, `sec-path-traversal-spot`, `sec-rate-limit-placement`, `sec-secret-log-spot`, `sec-sqli-spot`, `sec-ssrf-spot`, `sec-xss-sink-spot` |
| security / crypto (10) | `sec-aes-gcm-nonce-reuse`, `sec-cert-chain-validation`, `sec-constant-time-compare`, `sec-hash-vs-encrypt-pii`, `sec-hmac-vs-signature`, `sec-jwt-alg-none`, `sec-password-hash-choice`, `sec-rand-source-choice`, `sec-rotate-order`, `sec-tls-floor-version` |
| security / secrets (12) | `sec-diff-secret-spot`, `sec-error-path-leak-trap`, `sec-fork-pr-ci-exposure`, `sec-gitignore-history-nuance`, `sec-hardcoded-secret-spot`, `sec-k8s-secret-base64`, `sec-least-privilege-scope`, `sec-remediation-order`, `sec-rotation-no-downtime`, `sec-rotation-window-math`, `sec-ssh-agent-identity`, `sec-vault-tradeoff` |
| delivery / git (10) | `git-bisect-steps`, `git-cherry-pick-vs-revert`, `git-conventional-commit-classify`, `git-detached-head-recovery`, `git-force-with-lease-vs-force`, `git-gitignore-negation-trace`, `git-hook-choice`, `git-rebase-vs-merge`, `git-stash-pop-conflict`, `git-worktree-use-case` |
| delivery / containers (10) | `docker-build-arg-vs-env`, `docker-copy-vs-add`, `docker-dockerignore-effect`, `docker-entrypoint-cmd-trace`, `docker-healthcheck-semantics`, `docker-image-size-math`, `docker-layer-cache-bust`, `docker-layer-count`, `docker-multistage-size-benefit`, `docker-nonroot-user-caps` |
| delivery / release-engineering (10) | `rel-canary-vs-bluegreen`, `rel-checksums-signing-purpose`, `rel-clean-tree-requirement`, `rel-commit-changelog-mapping`, `rel-hotfix-flow-ordering`, `rel-pin-vs-range-policy`, `rel-pipeline-stage-ordering`, `rel-rollback-ordering`, `rel-semver-bump-changelog`, `rel-tag-to-release-sequence` |
| ai / vector-search (10) | `vec-cosine-vs-dot`, `vec-distance-to-similarity`, `vec-embedding-dimension-tradeoff`, `vec-hnsw-efsearch-tradeoff`, `vec-index-build-query-tradeoff`, `vec-near-duplicate-threshold`, `vec-pq-memory-math`, `vec-pre-vs-post-filtering`, `vec-recall-at-k`, `vec-rrf-fusion` |
| ai / llm-integration (10) | `llm-429-retry-backoff`, `llm-context-overflow-strategy`, `llm-embedding-batch-math`, `llm-field-semantics`, `llm-role-placement`, `llm-sse-stream-done`, `llm-stop-sequence-trace`, `llm-temperature-zero-caveat`, `llm-token-budget-reasoning`, `llm-tool-call-handling` |
| ai / rag (10) | `rag-chunk-size-tradeoff`, `rag-citation-grounding`, `rag-context-selection-vs-stuffing`, `rag-eval-metric-choice`, `rag-hallucination-mitigation-ordering`, `rag-index-staleness`, `rag-multihop-decomposition`, `rag-preassembly-dedup`, `rag-reranker-placement`, `rag-retrieval-failure-mode` |
| daily / text-processing (10) | `dtext-cron-dst-skip`, `dtext-csv-revenue`, `dtext-duration-sum`, `dtext-invoice-vat-rounding`, `dtext-json-merge-patch`, `dtext-meeting-zones`, `dtext-percent-symmetry-trap`, `dtext-sla-date-math`, `dtext-url-components`, `dtext-utf8-bytes-vs-chars` |
| daily / terminal (10) | `dterm-cp-n-versus-mv`, `dterm-du-total-max`, `dterm-find-mtime-plus7`, `dterm-grep-lines-vs-matches`, `dterm-log-top-2xx-path`, `dterm-pipefail-exit-code`, `dterm-ps-memory-column`, `dterm-single-quote-expansion`, `dterm-strip-port-uniq-count`, `dterm-tail-grep-buffering` |
| daily / office-math (10) | `doffice-discount-tax-order`, `doffice-file-quota-gib`, `doffice-fx-wire-fee`, `doffice-mileage-threshold`, `doffice-overtime-pay`, `doffice-payroll-loaded`, `doffice-prorated-fee`, `doffice-reams-ceiling`, `doffice-shift-roster`, `doffice-uptime-budget` |
| daily / policy-rules (10) | `dpol-approval-boundaries`, `dpol-cert-expiry-window`, `dpol-expense-per-night-cap`, `dpol-holiday-premium`, `dpol-marginal-brackets`, `dpol-oncall-rotation`, `dpol-retention-window`, `dpol-shipping-business-days`, `dpol-sla-tiered-credit`, `dpol-vacation-accrual-cap` |
| daily / scheduling (10) | `dsched-billing-anchor-clamp`, `dsched-contract-notice-window`, `dsched-deadline-countdown`, `dsched-dst-backup-hours`, `dsched-dst-meeting-overlap`, `dsched-flight-cross-tz`, `dsched-payroll-period-walk`, `dsched-rota-mod7`, `dsched-utc-cross-date`, `dsched-weekend-shift-deadline` |
| daily / shopping (10) | `dshop-bill-split-penny`, `dshop-buy-one-free`, `dshop-coupon-threshold`, `dshop-free-shipping-threshold`, `dshop-fuel-range-check`, `dshop-fuel-trip-cost`, `dshop-pack-mix-optimum`, `dshop-stacked-discount`, `dshop-unit-price-per-litre`, `dshop-vat-cashback` |
| daily / finance (10) | `dfin-commute-season-ticket`, `dfin-compound-vs-simple`, `dfin-deposit-cap-weekly`, `dfin-fx-round-trip`, `dfin-income-tax-single-band`, `dfin-installment-vs-lump`, `dfin-min-payment-interest`, `dfin-prorata-rent`, `dfin-raise-compounding`, `dfin-simple-interest-18mo` |
| daily / travel (10) | `dtrv-baggage-started-kg`, `dtrv-car-hire-80h`, `dtrv-hotel-3-nights`, `dtrv-layover-share`, `dtrv-mileage-tiers`, `dtrv-taxi-split-surge`, `dtrv-three-city-meeting`, `dtrv-toll-vs-detour`, `dtrv-train-connection`, `dtrv-visa-window` |
| daily / cooking (10) | `dck-bakers-percentages`, `dck-coffee-unit-price`, `dck-cordial-dilution`, `dck-dinner-parallel-starts`, `dck-granola-label-math`, `dck-pan-area-scaling`, `dck-roast-start-timing`, `dck-scale-recipe-4-to-10`, `dck-starter-feed-ratio`, `dck-temp-convert-probe` |
| daily / fitness (10) | `dft-acsm-incline-walk`, `dft-cadence-step-goal`, `dft-energy-balance-weeks`, `dft-ftp-interval-work`, `dft-karvonen-zones`, `dft-lean-mass-target`, `dft-pace-from-5k`, `dft-plank-compound-weeks`, `dft-swim-set-totals`, `dft-tenk-decimal-pace` |
| daily / gardening (10) | `dgd-bed-plant-spacing`, `dgd-compost-cn-alligation`, `dgd-fertiliser-season-bags`, `dgd-hedge-compound-prune`, `dgd-irrigation-zone-cycle`, `dgd-mower-throughput-overlap`, `dgd-mulch-volume-bags`, `dgd-rain-equivalent-watering`, `dgd-raised-bed-soil-bags`, `dgd-sowing-transplant-dates` |
| daily / home-repair (10) | `drp-bath-mix-fill-time`, `drp-concrete-post-hole-bags`, `drp-door-refix-timeline`, `drp-ladder-pythagoras-rule`, `drp-laminate-planks-with-waste`, `drp-led-swap-energy-savings`, `drp-paint-two-coat-cans`, `drp-shelf-bracket-fencepost`, `drp-thermostat-setback-savings`, `drp-tile-count-breakage` |
| daily / car (10) | `dcar-average-speed-restart`, `dcar-detailing-overlap`, `dcar-dilution-ratio`, `dcar-hire-mileage-charge`, `dcar-mpg-to-litres`, `dcar-parking-blocks`, `dcar-service-whichever-first`, `dcar-tank-range-reserve`, `dcar-tread-life`, `dcar-trip-fuel-cost` |
| daily / events (10) | `dev-catering-crate-wastage`, `dev-coaches-per-started`, `dev-deposit-after-discount`, `dev-flower-breakage-budget`, `dev-keg-pint-count`, `dev-place-card-sheets`, `dev-setup-working-days`, `dev-shift-block-overshoot`, `dev-table-grid-vs-area`, `dev-wedding-timeline` |
| daily / medication (10) | `dmed-antibiotic-course-volume`, `dmed-bioavailability-tablets`, `dmed-combo-analgesic-cap`, `dmed-insulin-daily-total`, `dmed-iv-drip-rate`, `dmed-pack-refill-count`, `dmed-suspension-dose-volume`, `dmed-tablet-cost-per-pack`, `dmed-unit-conversion-excretion`, `dmed-weight-based-dose` |
| daily / hiking (10) | `dhike-average-speed-halves`, `dhike-descent-time-allowance`, `dhike-drive-ferry-arrival`, `dhike-food-resupply-days`, `dhike-heat-water-fuel`, `dhike-map-scale-ground-time`, `dhike-naismith-summit-time`, `dhike-required-split-pace`, `dhike-tent-count-weight`, `dhike-water-gap-refill` |
| daily / energy (10) | `den-battery-arbitrage-losses`, `den-carbon-tier-bands`, `den-draught-payback-share`, `den-ev-overnight-charge`, `den-heat-pump-vs-gas-cost`, `den-load-profile-weighted-mean`, `den-oil-litres-efficiency`, `den-solar-self-consumption`, `den-standing-charge-days`, `den-tou-bill-supply-charge` |
| daily / childcare (10) | `dch-age-in-days`, `dch-daycare-funded-hours`, `dch-formula-annual-cost`, `dch-formula-can-count`, `dch-milk-bank-net-days`, `dch-night-sleep-minutes`, `dch-paracetamol-suspension-dose`, `dch-twin-feed-total`, `dch-volume-per-kg`, `dch-wake-window-clock` |
| daily / insurance (10) | `dins-annual-premium-discount`, `dins-contents-depreciation`, `dins-deductible-coinsurance`, `dins-excess-per-claim`, `dins-family-deductible-met`, `dins-income-protection-gap`, `dins-out-of-pocket-cap`, `dins-pet-annual-limit`, `dins-rental-damage-waiver`, `dins-waiting-period-date` |
| daily / nutrition (10) | `dnut-alcohol-units-abv`, `dnut-bmi-weight-band`, `dnut-caffeine-daily-cap`, `dnut-drink-sugar-averages`, `dnut-hydration-bottles`, `dnut-macro-energy-split`, `dnut-percent-dv-two-servings`, `dnut-protein-scoop-rounding`, `dnut-serving-scale-pack`, `dnut-tdee-deficit-target` |
| daily / pets (10) | `dpets-cat-calories`, `dpets-dog-food-percent`, `dpets-dog-walk-km`, `dpets-dog-water`, `dpets-flea-boxes`, `dpets-grooming-cycle`, `dpets-med-mg-kg`, `dpets-puppy-vaccines`, `dpets-rabbit-hay`, `dpets-tank-volume` |
| daily / commuting (10) | `dcomm-bus-interval`, `dcomm-carpool-rebate`, `dcomm-cycle-arrival`, `dcomm-fuel-share`, `dcomm-metro-platform`, `dcomm-office-cost`, `dcomm-parking-blocks`, `dcomm-rail-payg-vs-pass`, `dcomm-railcard-breakeven`, `dcomm-toll-vs-free` |
| daily / laundry (10) | `dlau-concentrate-dose`, `dlau-damp-dry-clock`, `dlau-delicates-loads`, `dlau-detergent-pricing`, `dlau-fleece-blanket`, `dlau-home-vs-laundrette`, `dlau-mixed-load-cycles`, `dlau-pod-schedule`, `dlau-sanitiser-dilution`, `dlau-washer-energy` |
| daily / woodwork (10) | `dwood-decking-boards`, `dwood-mitre-posts`, `dwood-mortar-waste`, `dwood-offcut-kerf`, `dwood-oil-coats`, `dwood-post-holes`, `dwood-price-break`, `dwood-screw-boxes`, `dwood-timber-invoice`, `dwood-varnish-tins` |
| daily / grow (10) | `dgrow-fert-tank`, `dgrow-germination`, `dgrow-heat-pump`, `dgrow-light-bill`, `dgrow-mix-ratio`, `dgrow-nutrient-dose`, `dgrow-reservoir-bottles`, `dgrow-seed-cost`, `dgrow-seed-trays`, `dgrow-watering-cans` |
| daily / home-maintenance (10) | `dhr-caulking-tubes`, `dhr-dryer-annual`, `dhr-filter-packs`, `dhr-insulation-payback`, `dhr-mower-runtime`, `dhr-pool-shock`, `dhr-render-time`, `dhr-service-plan`, `dhr-tank-ibc`, `dhr-tank-top-up` |
| daily / bathroom-plumbing (10) | `dbp-anti-slip-mats`, `dbp-bath-fill`, `dbp-descaling`, `dbp-drip-line`, `dbp-gutter-run`, `dbp-heater-energy`, `dbp-leak-waste`, `dbp-shower-annual`, `dbp-sump-pump`, `dbp-wall-tiles` |
| daily / cleaning-supplies (10) | `dcs-bin-liners`, `dcs-bleach-sprayers`, `dcs-cart-discount`, `dcs-cleaner-hours`, `dcs-detergent-packs`, `dcs-floor-polish`, `dcs-mop-buckets`, `dcs-tablets-vs-powder`, `dcs-vacuum-annual`, `dcs-window-sprayer` |
| daily / painting-decorating (10) | `dpaint-booth-energy`, `dpaint-crew-hours`, `dpaint-gloss-doors`, `dpaint-pot-sizes`, `dpaint-scaffold`, `dpaint-skirting`, `dpaint-sprayer-amort`, `dpaint-tint`, `dpaint-two-coats`, `dpaint-wallpaper` |
| daily / moving-house (10) | `dmov-box-packs`, `dmov-crate-trips`, `dmov-deposit-order`, `dmov-lift-tonnage`, `dmov-piano-surcharge`, `dmov-route-fuel`, `dmov-transit-insurance`, `dmov-two-quotes`, `dmov-utility-final`, `dmov-van-trips` |
| daily / diy-electrical (10) | `delec-breaker`, `delec-cable`, `delec-downlights`, `delec-duty-cycle`, `delec-festoon`, `delec-heat-mat`, `delec-kettle`, `delec-router-sla`, `delec-sleeves`, `delec-surge` |
| daily / waste-recycling (10) | `dwaste-bin-days`, `dwaste-bottles`, `dwaste-bulbs`, `dwaste-compost`, `dwaste-ewaste`, `dwaste-green-bags`, `dwaste-landfill`, `dwaste-oil`, `dwaste-shredder`, `dwaste-skips` |
| daily / diy-pest-control (10) | `dpest-ant-bait-mix`, `dpest-bait-stations`, `dpest-flea-fogger`, `dpest-mosquito-traps`, `dpest-pest-contract`, `dpest-rodent-blocks`, `dpest-snake-fence`, `dpest-spray-perimeter`, `dpest-termite-warranty`, `dpest-wasp-plan` |
| daily / home-safety (10) | `dsafe-cctv-archive`, `dsafe-extinguisher-service`, `dsafe-first-aid-stock`, `dsafe-handrail-fixings`, `dsafe-inspection-penalty`, `dsafe-ladder-hire`, `dsafe-monitoring-exit`, `dsafe-nightlight-annual`, `dsafe-smoke-batteries`, `dsafe-vent-fan` |
| daily / water-usage (10) | `dwat-bath-heat`, `dwat-filter-vs-jug`, `dwat-hose-vs-bucket`, `dwat-leak-trace`, `dwat-meter-bill`, `dwat-pool-fill`, `dwat-shower-vs-bath`, `dwat-softener-salt`, `dwat-sprinkler`, `dwat-tank-payback` |
| daily / home-security (10) | `dsec-doorbell-battery`, `dsec-dummy-camera`, `dsec-fake-presence`, `dsec-grille-bars`, `dsec-hasp-screws`, `dsec-monitoring-plan`, `dsec-motion-sensors`, `dsec-photocell`, `dsec-siren-cells`, `dsec-window-film` |
| daily / cooling-load (10) | `dcool-ac-size`, `dcool-car-ac`, `dcool-dehumidifier`, `dcool-evap-vs-ac`, `dcool-fan-energy`, `dcool-fan-vs-ac`, `dcool-filter-cleans`, `dcool-ice-blocks`, `dcool-thermostat`, `dcool-window-shading` |
| daily / telecom-plans (10) | `dtel-broadband-speed`, `dtel-contract-uplift`, `dtel-data-overage`, `dtel-family-plan`, `dtel-modem-power`, `dtel-payg-vs-bundle`, `dtel-prepaid-card`, `dtel-roaming-cap`, `dtel-sim-plan`, `dtel-tether-data` |
| daily / heating-load (10) | `dheat-boiler-kwh`, `dheat-cylinder`, `dheat-duty-cycle`, `dheat-heat-pump`, `dheat-insulation`, `dheat-oil-tank`, `dheat-radiator`, `dheat-tariff`, `dheat-thermostat`, `dheat-zoning` |
| daily / timesheet-pay (10) | `dpay-bonus-tax`, `dpay-clock-rounding`, `dpay-contractor-day`, `dpay-leave-accrual`, `dpay-mileage`, `dpay-overtime`, `dpay-parttime-prorata`, `dpay-salary-raise`, `dpay-shift-differential`, `dpay-tax-band` |
| daily / fuel-trip (10) | `dfuel-boat-range`, `dfuel-economy-mixed`, `dfuel-ev-charge`, `dfuel-flight-uplift`, `dfuel-generator-kwh`, `dfuel-hedge-mix`, `dfuel-jerrycan-trip`, `dfuel-lpg-payback`, `dfuel-moto-range`, `dfuel-truck-legs` |
| daily / pool-chemicals (10) | `dpool-algae-shock`, `dpool-backwash`, `dpool-floc`, `dpool-heat-pump`, `dpool-ph-adjust`, `dpool-ppm-dose`, `dpool-salt-cell`, `dpool-turnover`, `dpool-uv`, `dpool-volume-m3` |
| daily / garden-irrigation (10) | `dirr-drip-emitters`, `dirr-drip-flow`, `dirr-fert-spread`, `dirr-greenhouse-mist`, `dirr-hose-fill`, `dirr-pond-topup`, `dirr-soil-compost`, `dirr-sprinkler`, `dirr-timer-days`, `dirr-water-tank` |
| daily / aquarium-water (10) | `daqu-chiller`, `daqu-co2`, `daqu-filter-flow`, `daqu-lighting`, `daqu-medication`, `daqu-nitrification`, `daqu-potash`, `daqu-salt-mix`, `daqu-tank-litres`, `daqu-waterchange` |
| daily / ev-charging (10) | `devch-ac-session`, `devch-battery-value`, `devch-cable-loss`, `devch-dc-fast`, `devch-degradation`, `devch-fleet-balancer`, `devch-range-added`, `devch-solar-surplus`, `devch-toll-pass`, `devch-tou-window` |
| daily / tile-flooring (10) | `dtile-adhesive-coverage`, `dtile-border-run`, `dtile-budget`, `dtile-cut-waste`, `dtile-drain-slope`, `dtile-grid-count`, `dtile-grout-volume`, `dtile-levelling`, `dtile-room-area`, `dtile-skirting-cost` |
| daily / decking-fencing (10) | `ddeck-board-area`, `ddeck-concrete-footings`, `ddeck-fence-panel-run`, `ddeck-gate-hinges`, `ddeck-joist-spacing`, `ddeck-oil-restoration`, `ddeck-railing-load`, `ddeck-slope-steps`, `ddeck-stair-stringer`, `ddeck-subframe-clamps` |
| daily / loft-insulation (10) | `dloft-blown-depth`, `dloft-cistern-insul`, `dloft-downlight-covers`, `dloft-hatch-draft`, `dloft-heat-loss`, `dloft-knee-wall`, `dloft-pipe-jacket`, `dloft-spray-foam`, `dloft-target-u`, `dloft-vapour-barrier` |
| daily / coffee-brewing (10) | `dbrew-caffeine-budget`, `dbrew-cold-brew-yield`, `dbrew-cup-cost`, `dbrew-descale-tablets`, `dbrew-espresso-shots`, `dbrew-extraction-yield`, `dbrew-french-press`, `dbrew-grind-throughput`, `dbrew-mineral-dose`, `dbrew-syrup-ratio` |
| daily / laundry-care (10) | `dlc-bleach-dilution`, `dlc-detergent-cost`, `dlc-drum-load`, `dlc-dryclean-vs-home`, `dlc-dryer-energy`, `dlc-load-split`, `dlc-softener-vs-vinegar`, `dlc-spin-extraction`, `dlc-stain-soak-supply`, `dlc-water-use` |
| daily / bike-care (10) | `dbike-battery-range`, `dbike-brake-pad-cost`, `dbike-chain-lifetime`, `dbike-chain-wear`, `dbike-energy-gels`, `dbike-gear-development`, `dbike-hire-mileage`, `dbike-patch-box`, `dbike-tuneup-vs-parts`, `dbike-tyre-tread` |
| daily / houseplant-care (10) | `dhpl-cutting-trays`, `dhpl-fertilizer-bottle`, `dhpl-foot-candle-convert`, `dhpl-grow-light-bill`, `dhpl-neem-spray-rounds`, `dhpl-repot-soil-volume`, `dhpl-reservoir-top-up`, `dhpl-shelf-bundle-deal`, `dhpl-soil-mix-split`, `dhpl-watering-schedule` |
| daily / lawn-care (10) | `dlawn-clippings`, `dlawn-fence-posts`, `dlawn-fertilizer-season`, `dlawn-irrigation`, `dlawn-mowing-time`, `dlawn-reseed-bags`, `dlawn-robot-wire`, `dlawn-spreader-fills`, `dlawn-topdress`, `dlawn-two-stroke` |
| daily / data-backup (10) | `dbak-321-plan`, `dbak-archive-drives`, `dbak-cloud-bill`, `dbak-lto-tapes`, `dbak-raid-usable`, `dbak-ransomware-anomaly`, `dbak-restore-time`, `dbak-snapshot-retention`, `dbak-upload-throughput`, `dbak-upload-window` |
| daily / pet-nutrition (10) | `dpn-bag-days`, `dpn-cat-water`, `dpn-joint-jar`, `dpn-kibble-cups`, `dpn-multi-cat-bags`, `dpn-puppy-meals`, `dpn-raw-mix`, `dpn-split-wet`, `dpn-treat-budget`, `dpn-wet-can-boxes` |
| daily / bread-baking (10) | `dbake-annual-flour`, `dbake-hydration`, `dbake-pan-volume`, `dbake-proof-rate`, `dbake-roll-division`, `dbake-scale`, `dbake-schedule`, `dbake-starter-hydration`, `dbake-water-temp`, `dbake-yeast-convert` |
| daily / sewing-tailoring (10) | `dsew-batting`, `dsew-bias-binding`, `dsew-button-spacing`, `dsew-elastic`, `dsew-fusible`, `dsew-layout`, `dsew-seam-allowance`, `dsew-shrinkage`, `dsew-thread-cone`, `dsew-zip-roll` |
| daily / bbq-grilling (10) | `dbbq-brine`, `dbbq-burger-packs`, `dbbq-coal-indirect`, `dbbq-low-slow`, `dbbq-propane`, `dbbq-rub-batch`, `dbbq-sauce-bottles`, `dbbq-service-queue`, `dbbq-thaw-schedule`, `dbbq-veg-trays` |
| daily / candle-making (10) | `dcand-batch-cost`, `dcand-blend-ratio`, `dcand-double-pour`, `dcand-jar-fill`, `dcand-market-margin`, `dcand-pool-ring`, `dcand-shared-batch`, `dcand-timeline`, `dcand-topup-bags`, `dcand-wick-spools` |
| daily / freshwater-fishkeeping (10) | `dfish-boat-current`, `dfish-fillet-yield`, `dfish-fry-hatch`, `dfish-grow-out`, `dfish-koi-ration`, `dfish-net-coverage`, `dfish-pond-liner`, `dfish-pond-volume`, `dfish-transport-crates`, `dfish-worm-harvest` |
| daily / home-canning (10) | `dcann-batch-cost`, `dcann-blanch-altitude`, `dcann-hard-boil`, `dcann-jam-pectin`, `dcann-jam-yield`, `dcann-kraut-salt`, `dcann-lid-packs`, `dcann-pickle-brine`, `dcann-pressure-session`, `dcann-sugar-per-jar` |
| daily / beekeeping (10) | `dbee-extraction-yield`, `dbee-fence-perimeter`, `dbee-hive-gain`, `dbee-mite-wash`, `dbee-nuc-split`, `dbee-queen-graft`, `dbee-season-revenue`, `dbee-super-fill`, `dbee-syrup-mix`, `dbee-winter-feed` |
| daily / knitting (10) | `dknit-blanket-squares`, `dknit-dye-wool`, `dknit-gauge-cast-on`, `dknit-hat-decrease`, `dknit-mitten-commission`, `dknit-rib-cuff`, `dknit-scarf-time`, `dknit-sock-pairs`, `dknit-sweater-yardage`, `dknit-yarn-skeins` |
| daily / soap-making (10) | `dsoap-curing-sales`, `dsoap-fragrance-load`, `dsoap-loaf-cut`, `dsoap-lye-bags`, `dsoap-lye-solution`, `dsoap-lye-strength`, `dsoap-market-profit`, `dsoap-mold-loaves`, `dsoap-oil-blend`, `dsoap-superfat` |
| daily / pottery-ceramics (10) | dpot-clay-bags`, `dpot-commission-discount`, `dpot-course-profit`, `dpot-drying-rate`, `dpot-glaze-batch`, `dpot-grog-share`, `dpot-kiln-energy`, `dpot-mug-margin`, `dpot-shrinkage`, `dpot-throwing-shift |
| daily / cheese-making (10) | dcheese-affinage-profit`, `dcheese-brine-saturation`, `dcheese-calcium-dose`, `dcheese-drying-loft`, `dcheese-milk-yield`, `dcheese-mother-stir`, `dcheese-mozzarella-sales`, `dcheese-press-force`, `dcheese-press-schedule`, `dcheese-rennet-dose |
| daily / home-brewing (10) | `dbeer-abv`, `dbeer-boil-energy`, `dbeer-chiller-time`, `dbeer-efficiency`, `dbeer-growler-margin`, `dbeer-hops-ibu`, `dbeer-mash-sparge`, `dbeer-priming-sugar`, `dbeer-strike-temp`, `dbeer-yeast-pitch` |
| daily / embroidery (10) | `demb-class-net`, `demb-floss-length`, `demb-hoop-area`, `demb-kit-profit`, `demb-pattern-scale`, `demb-quilt-edge`, `demb-ribbon-round`, `demb-runner-fabric`, `demb-stitch-time`, `demb-tassel-cut` |
| daily / jewelry-making (10) | `djew-bead-strand`, `djew-casting-sprue`, `djew-chain-price`, `djew-clay-shrink`, `djew-earring-wire`, `djew-jump-ring`, `djew-pendant-weight`, `djew-ring-blank`, `djew-wire-gauge`, `djew-wire-loop` |
| daily / snow-removal (10) | `dsnow-brine-mix`, `dsnow-ice-melt`, `dsnow-job-price`, `dsnow-plow-shifts`, `dsnow-roof-load`, `dsnow-salt-driveway`, `dsnow-salting-rounds`, `dsnow-sand-salt-stock`, `dsnow-shovel-rate`, `dsnow-snow-haul` |
| daily / camping (10) | `dcamp-tent-capacity`, `dcamp-pack-weight`, `dcamp-water-tablets`, `dcamp-gas-canister`, `dcamp-cooler-ice`, `dcamp-clothesline`, `dcamp-rope-load`, `dcamp-headlamp-battery`, `dcamp-firewood-split`, `dcamp-fee-per-night` |
| daily / photography (10) | `dphot-exposure-stops`, `dphot-hyperfocal`, `dphot-memory-card`, `dphot-golden-hour`, `dphot-print-size`, `dphot-film-contact`, `dphot-sandbag-packs`, `dphot-stock-royalty`, `dphot-lens-rental`, `dphot-timelapse-clock` |
| daily / records (10) | `dvrc-shipping-boxes`, `dvrc-sleeve-packs`, `dvrc-bulk-price`, `dvrc-play-hours`, `dvrc-flip-clock`, `dvrc-crate-load`, `dvrc-clean-fluid`, `dvrc-grading-score`, `dvrc-belt-life`, `dvrc-shelf-space` |
| daily / skiing (10) | `dski-lift-pass`, `dski-slope-grade`, `dski-wax-season`, `dski-lift-queue`, `dski-snowmaking`, `dski-boot-shell`, `dski-storage-fee`, `dski-kids-camp`, `dski-gear-value`, `dski-club-minibus` |
| daily / vanlife (10) | `dvlf-solar-array`, `dvlf-water-tank`, `dvlf-fuel-budget`, `dvlf-propane`, `dvlf-mileage`, `dvlf-plywood-cuts`, `dvlf-grey-water`, `dvlf-fridge-amps`, `dvlf-drive-time`, `dvlf-campsite-bill` |
| daily / arcade (10) | `darc-token-packs`, `darc-claw-net`, `darc-hoops-tickets`, `darc-dance-session`, `darc-hockey-league`, `darc-redemption-hours`, `darc-pinball-games`, `darc-ticket-dispenser`, `darc-photo-booth`, `darc-prize-wall` |
| daily / 3d-printing (10) | `d3dp-cost-per-print`, `d3dp-print-time`, `d3dp-bed-layout`, `d3dp-resin-batch`, `d3dp-spool-swaps`, `d3dp-support-share`, `d3dp-post-cure`, `d3dp-flow-limit`, `d3dp-power-bill`, `d3dp-stock-order` |
| daily / car-wash (10) | `dcw-foam-dilution`, `dcw-bay-throughput`, `dcw-water-reclaim`, `dcw-package-margin`, `dcw-clay-bars`, `dcw-vac-bags`, `dcw-tyre-dressing`, `dcw-queue-length`, `dcw-wash-plan`, `dcw-tip-pool` |
## Adding a test
@@ -159,8 +318,9 @@ with `math.Sqrt` rather than trusting the constant).
4. Add table-driven `_test.go` cases covering a correct answer, a wrong
answer, and at least one edge case (a common trap, a partial match, or a
malformed response).
5. Add the test's ID to the `wantCatalog` map in
`internal/tests/catalog_test.go`.
5. If you introduced a new subcategory, add it to `wantSubcats` in
`internal/tests/catalog_test.go`; every subcategory needs at least ten
tests, so a one-test subcategory will not pass the catalog gate.
## Adding an evaluator
@@ -194,4 +354,4 @@ golangci-lint run # if the binary is installed
Every unit test runs offline against a mock `llm.Client` or a local
`httptest.Server`; none of them call the live endpoint. The exec-evaluator
tests (`GoRun`, `PyRun`, `CRun`) detect a missing `go`, `python3`, or `cc`
toolchain and skip rather than fail.
toolchain and skip rather than fail.
-277
View File
@@ -1,277 +0,0 @@
## Per-test scores
| Test | Category | Subcategory | uni/deepseek-v4-flash-0731 | uni/qwen3.6-27b | uni/btl-4 |
| --- | --- | --- | --- | --- | --- |
| agent-deleg-batch-vs-separate | agents | delegation | 1.00 (161tok) | 1.00 (367tok) | 1.00 (894tok) |
| agent-deleg-build-then-verify | agents | delegation | 1.00 (382tok) | 1.00 (494tok) | 1.00 (455tok) |
| agent-deleg-escalation-to-human | agents | delegation | 1.00 (157tok) | 1.00 (202tok) | 1.00 (683tok) |
| agent-deleg-handoff-context | agents | delegation | 1.00 (198tok) | 1.00 (633tok) | 1.00 (1037tok) |
| agent-deleg-main-thread-vs-delegate | agents | delegation | 1.00 (154tok) | 1.00 (200tok) | 1.00 (456tok) |
| agent-deleg-minimal-privilege | agents | delegation | 1.00 (471tok) | 1.00 (579tok) | 1.00 (632tok) |
| agent-deleg-parallel-dispatch-safety | agents | delegation | 1.00 (231tok) | 1.00 (284tok) | 1.00 (901tok) |
| agent-deleg-reviewer-independence | agents | delegation | 1.00 (679tok) | 1.00 (836tok) | 1.00 (1355tok) |
| agent-deleg-task-to-specialist | agents | delegation | 1.00 (628tok) | 1.00 (654tok) | 1.00 (1050tok) |
| agent-deleg-verify-vs-trust | agents | delegation | 1.00 (155tok) | 1.00 (269tok) | 1.00 (726tok) |
| agent-plan-critical-path | agents | planning | 1.00 (567tok) | 1.00 (470tok) | 1.00 (1079tok) |
| agent-plan-cycle-detect | agents | planning | 1.00 (306tok) | 1.00 (315tok) | 1.00 (1195tok) |
| agent-plan-idempotency | agents | planning | 1.00 (499tok) | 1.00 (553tok) | 1.00 (1335tok) |
| agent-plan-milestone-date | agents | planning | 1.00 (794tok) | 1.00 (236tok) | 1.00 (892tok) |
| agent-plan-minimal-replan | agents | planning | 1.00 (406tok) | 1.00 (451tok) | 1.00 (865tok) |
| agent-plan-ordering | agents | planning | 1.00 (415tok) | 1.00 (379tok) | 1.00 (905tok) |
| agent-plan-parallel-steps | agents | planning | 1.00 (385tok) | 1.00 (322tok) | 1.00 (1120tok) |
| agent-plan-repair | agents | planning | 1.00 (346tok) | 1.00 (312tok) | 1.00 (907tok) |
| agent-plan-resource-constrained | agents | planning | 1.00 (1216tok) | 1.00 (577tok) | 1.00 (1633tok) |
| agent-plan-rollback-trigger | agents | planning | 1.00 (370tok) | 1.00 (442tok) | 1.00 (1142tok) |
| agent-tool-routing | agents | tool-routing | 1.00 (323tok) | 1.00 (434tok) | 1.00 (461tok) |
| agent-tool-routing-ambiguous | agents | tool-routing | 1.00 (277tok) | 1.00 (336tok) | 1.00 (526tok) |
| agent-tool-routing-chaining | agents | tool-routing | 1.00 (218tok) | 1.00 (302tok) | 1.00 (301tok) |
| agent-tool-routing-cheapest | agents | tool-routing | 1.00 (260tok) | 1.00 (331tok) | 1.00 (421tok) |
| agent-tool-routing-distractors | agents | tool-routing | 1.00 (555tok) | 1.00 (543tok) | 1.00 (602tok) |
| agent-tool-routing-missing-param | agents | tool-routing | 1.00 (201tok) | 1.00 (478tok) | 0.50 (502tok) |
| agent-tool-routing-multistep | agents | tool-routing | 1.00 (247tok) | 1.00 (326tok) | 1.00 (358tok) |
| agent-tool-routing-no-tool-needed | agents | tool-routing | 1.00 (291tok) | 1.00 (362tok) | 1.00 (669tok) |
| agent-tool-routing-parallel | agents | tool-routing | 1.00 (319tok) | 1.00 (380tok) | 1.00 (391tok) |
| agent-tool-routing-safety | agents | tool-routing | 1.00 (194tok) | 1.00 (253tok) | 1.00 (232tok) |
| llm-429-retry-backoff | ai | llm-integration | 1.00 (188tok) | 1.00 (278tok) | 1.00 (886tok) |
| llm-context-overflow-strategy | ai | llm-integration | 1.00 (340tok) | 1.00 (739tok) | 1.00 (1373tok) |
| llm-embedding-batch-math | ai | llm-integration | 1.00 (164tok) | 1.00 (252tok) | 1.00 (619tok) |
| llm-field-semantics | ai | llm-integration | 1.00 (373tok) | 1.00 (401tok) | 1.00 (983tok) |
| llm-role-placement | ai | llm-integration | 1.00 (181tok) | 1.00 (206tok) | 1.00 (418tok) |
| llm-sse-stream-done | ai | llm-integration | 1.00 (297tok) | 1.00 (352tok) | 1.00 (792tok) |
| llm-stop-sequence-trace | ai | llm-integration | 1.00 (450tok) | 1.00 (587tok) | 1.00 (1297tok) |
| llm-temperature-zero-caveat | ai | llm-integration | 1.00 (974tok) | 1.00 (1957tok) | 1.00 (2400tok) |
| llm-token-budget-reasoning | ai | llm-integration | 1.00 (185tok) | 1.00 (237tok) | 1.00 (297tok) |
| llm-tool-call-handling | ai | llm-integration | 1.00 (195tok) | 1.00 (318tok) | 1.00 (806tok) |
| rag-chunk-size-tradeoff | ai | rag | 1.00 (222tok) | 1.00 (312tok) | 1.00 (788tok) |
| rag-citation-grounding | ai | rag | 1.00 (712tok) | 1.00 (236tok) | 1.00 (842tok) |
| rag-context-selection-vs-stuffing | ai | rag | 1.00 (204tok) | 1.00 (316tok) | 1.00 (602tok) |
| rag-eval-metric-choice | ai | rag | 1.00 (330tok) | 1.00 (395tok) | 1.00 (924tok) |
| rag-hallucination-mitigation-ordering | ai | rag | 1.00 (331tok) | 1.00 (401tok) | 1.00 (1374tok) |
| rag-index-staleness | ai | rag | 1.00 (291tok) | 1.00 (317tok) | 1.00 (895tok) |
| rag-multihop-decomposition | ai | rag | 1.00 (358tok) | 1.00 (442tok) | 1.00 (1185tok) |
| rag-preassembly-dedup | ai | rag | 1.00 (1395tok) | 1.00 (1194tok) | 1.00 (1304tok) |
| rag-reranker-placement | ai | rag | 1.00 (182tok) | 1.00 (256tok) | 1.00 (306tok) |
| rag-retrieval-failure-mode | ai | rag | 1.00 (150tok) | 1.00 (335tok) | 1.00 (480tok) |
| vec-cosine-vs-dot | ai | vector-search | 1.00 (217tok) | 1.00 (298tok) | 1.00 (653tok) |
| vec-distance-to-similarity | ai | vector-search | 1.00 (238tok) | 1.00 (306tok) | 1.00 (882tok) |
| vec-embedding-dimension-tradeoff | ai | vector-search | 1.00 (244tok) | 1.00 (425tok) | 1.00 (524tok) |
| vec-hnsw-efsearch-tradeoff | ai | vector-search | 1.00 (298tok) | 1.00 (261tok) | 1.00 (722tok) |
| vec-index-build-query-tradeoff | ai | vector-search | 1.00 (319tok) | 1.00 (361tok) | 1.00 (521tok) |
| vec-near-duplicate-threshold | ai | vector-search | 1.00 (349tok) | 1.00 (408tok) | 1.00 (1202tok) |
| vec-pq-memory-math | ai | vector-search | 1.00 (238tok) | 1.00 (338tok) | 1.00 (865tok) |
| vec-pre-vs-post-filtering | ai | vector-search | 1.00 (195tok) | 1.00 (455tok) | 1.00 (1043tok) |
| vec-recall-at-k | ai | vector-search | 1.00 (258tok) | 1.00 (313tok) | 1.00 (811tok) |
| vec-rrf-fusion | ai | vector-search | 1.00 (690tok) | 1.00 (618tok) | 1.00 (1466tok) |
| pg-bloat-vacuum | databases | postgres | 1.00 (211tok) | 1.00 (288tok) | 1.00 (453tok) |
| pg-deadlock-lock-order | databases | postgres | 1.00 (327tok) | 1.00 (391tok) | 1.00 (1138tok) |
| pg-explain-seq-vs-index | databases | postgres | 1.00 (347tok) | 1.00 (622tok) | 1.00 (1166tok) |
| pg-index-choice | databases | postgres | 1.00 (287tok) | 1.00 (433tok) | 1.00 (612tok) |
| pg-isolation-anomaly | databases | postgres | 1.00 (240tok) | 1.00 (377tok) | 1.00 (813tok) |
| pg-listen-notify | databases | postgres | 1.00 (603tok) | 1.00 (254tok) | 1.00 (1204tok) |
| pg-n-plus-one | databases | postgres | 1.00 (213tok) | 1.00 (253tok) | 1.00 (566tok) |
| pg-partial-index | databases | postgres | 1.00 (356tok) | 1.00 (433tok) | 1.00 (988tok) |
| pg-pool-sizing | databases | postgres | 1.00 (160tok) | 1.00 (155tok) | 1.00 (265tok) |
| pg-replica-failover | databases | postgres | 1.00 (254tok) | 1.00 (331tok) | 1.00 (1404tok) |
| redis-cache-stampede | databases | redis | 1.00 (534tok) | 1.00 (477tok) | 1.00 (1341tok) |
| redis-incr-atomicity | databases | redis | 1.00 (345tok) | 1.00 (593tok) | 1.00 (1025tok) |
| redis-keyspace-anti-pattern | databases | redis | 1.00 (275tok) | 1.00 (378tok) | 1.00 (673tok) |
| redis-lua-atomicity | databases | redis | 1.00 (135tok) | 1.00 (324tok) | 1.00 (646tok) |
| redis-memory-estimate | databases | redis | 1.00 (184tok) | 1.00 (228tok) | 1.00 (992tok) |
| redis-multi-vs-pipeline | databases | redis | 1.00 (275tok) | 1.00 (1734tok) | 0.00 (594tok) |
| redis-noeviction-write | databases | redis | 1.00 (163tok) | 1.00 (182tok) | 1.00 (490tok) |
| redis-pubsub-delivery | databases | redis | 1.00 (240tok) | 1.00 (588tok) | 1.00 (1009tok) |
| redis-scan-vs-keys | databases | redis | 0.50 (774tok) | 0.50 (1063tok) | 0.50 (1968tok) |
| redis-structure-choice | databases | redis | 1.00 (417tok) | 1.00 (417tok) | 1.00 (1616tok) |
| sql-aggregate-sum | databases | sql-tuning | 1.00 (280tok) | 1.00 (380tok) | 1.00 (1101tok) |
| sql-composite-index-skip-column | databases | sql-tuning | 1.00 (746tok) | 1.00 (1933tok) | 1.00 (1082tok) |
| sql-covering-index | databases | sql-tuning | 1.00 (160tok) | 1.00 (529tok) | 1.00 (676tok) |
| sql-equivalent-rewrite | databases | sql-tuning | 1.00 (330tok) | 1.00 (509tok) | 1.00 (1043tok) |
| sql-groupby-having | databases | sql-tuning | 1.00 (610tok) | 1.00 (660tok) | 1.00 (1376tok) |
| sql-join-counts | databases | sql-tuning | 1.00 (462tok) | 1.00 (662tok) | 1.00 (1644tok) |
| sql-keyset-pagination | databases | sql-tuning | 1.00 (205tok) | 1.00 (256tok) | 1.00 (827tok) |
| sql-null-in-where | databases | sql-tuning | 1.00 (331tok) | 1.00 (421tok) | 1.00 (917tok) |
| sql-orderby-index-serving | databases | sql-tuning | 1.00 (249tok) | 1.00 (383tok) | 1.00 (680tok) |
| sql-window-rank | databases | sql-tuning | 1.00 (286tok) | 1.00 (382tok) | 1.00 (414tok) |
| docker-build-arg-vs-env | delivery | containers | 1.00 (243tok) | 1.00 (408tok) | 1.00 (978tok) |
| docker-copy-vs-add | delivery | containers | 1.00 (195tok) | 1.00 (276tok) | 1.00 (856tok) |
| docker-dockerignore-effect | delivery | containers | 1.00 (648tok) | 1.00 (389tok) | 1.00 (400tok) |
| docker-entrypoint-cmd-trace | delivery | containers | 1.00 (197tok) | 1.00 (313tok) | 1.00 (1055tok) |
| docker-healthcheck-semantics | delivery | containers | 1.00 (143tok) | 1.00 (472tok) | 1.00 (810tok) |
| docker-image-size-math | delivery | containers | 1.00 (230tok) | 1.00 (284tok) | 1.00 (526tok) |
| docker-layer-cache-bust | delivery | containers | 1.00 (337tok) | 1.00 (568tok) | 1.00 (550tok) |
| docker-layer-count | delivery | containers | 1.00 (665tok) | 1.00 (423tok) | 1.00 (1668tok) |
| docker-multistage-size-benefit | delivery | containers | 1.00 (1683tok) | 1.00 (1174tok) | 1.00 (1560tok) |
| docker-nonroot-user-caps | delivery | containers | 1.00 (554tok) | 1.00 (5843tok) | 1.00 (1866tok) |
| git-bisect-steps | delivery | git | 1.00 (4242tok) | 1.00 (4859tok) | 1.00 (2517tok) |
| git-cherry-pick-vs-revert | delivery | git | 1.00 (204tok) | 1.00 (240tok) | 1.00 (432tok) |
| git-conventional-commit-classify | delivery | git | 1.00 (429tok) | 1.00 (453tok) | 1.00 (1451tok) |
| git-detached-head-recovery | delivery | git | 1.00 (1389tok) | 0.00 (12166tok)! | 0.00 (2045tok) |
| git-force-with-lease-vs-force | delivery | git | 0.50 (1465tok) | 0.50 (2207tok) | 1.00 (1206tok) |
| git-gitignore-negation-trace | delivery | git | 0.75 (834tok) | 1.00 (955tok) | 0.75 (1210tok) |
| git-hook-choice | delivery | git | 1.00 (279tok) | 1.00 (255tok) | 1.00 (756tok) |
| git-rebase-vs-merge | delivery | git | 1.00 (236tok) | 1.00 (239tok) | 1.00 (948tok) |
| git-stash-pop-conflict | delivery | git | 1.00 (328tok) | 1.00 (436tok) | 1.00 (1168tok) |
| git-worktree-use-case | delivery | git | 1.00 (502tok) | 1.00 (2334tok) | 1.00 (1292tok) |
| rel-canary-vs-bluegreen | delivery | release-engineering | 1.00 (261tok) | 1.00 (318tok) | 1.00 (678tok) |
| rel-checksums-signing-purpose | delivery | release-engineering | 1.00 (697tok) | 1.00 (286tok) | 0.50 (563tok) |
| rel-clean-tree-requirement | delivery | release-engineering | 1.00 (902tok) | 0.50 (360tok) | 0.50 (732tok) |
| rel-commit-changelog-mapping | delivery | release-engineering | 1.00 (378tok) | 1.00 (378tok) | 1.00 (989tok) |
| rel-hotfix-flow-ordering | delivery | release-engineering | 0.00 (838tok) | 0.00 (1591tok) | 1.00 (2009tok) |
| rel-pin-vs-range-policy | delivery | release-engineering | 1.00 (228tok) | 1.00 (307tok) | 1.00 (1018tok) |
| rel-pipeline-stage-ordering | delivery | release-engineering | 0.00 (989tok) | 0.00 (340tok) | 0.00 (1054tok) |
| rel-rollback-ordering | delivery | release-engineering | 1.00 (265tok) | 1.00 (976tok) | 1.00 (1610tok) |
| rel-semver-bump-changelog | delivery | release-engineering | 1.00 (275tok) | 1.00 (345tok) | 1.00 (939tok) |
| rel-tag-to-release-sequence | delivery | release-engineering | 1.00 (333tok) | 1.00 (464tok) | 1.00 (1374tok) |
| k8s-argocd-outofsync | operations | kubernetes | 1.00 (415tok) | 1.00 (529tok) | 1.00 (1304tok) |
| k8s-cnpg-pdb | operations | kubernetes | 1.00 (625tok) | 1.00 (4965tok) | 1.00 (1733tok) |
| k8s-crashloop-gitops | operations | kubernetes | 0.75 (782tok) | 0.75 (2285tok) | 1.00 (2392tok) |
| k8s-imagepullbackoff | operations | kubernetes | 1.00 (305tok) | 1.00 (378tok) | 1.00 (724tok) |
| k8s-networkpolicy-dns-block | operations | kubernetes | 1.00 (964tok) | 1.00 (2399tok) | 1.00 (2102tok) |
| k8s-pending-taints | operations | kubernetes | 1.00 (268tok) | 1.00 (398tok) | 1.00 (1052tok) |
| k8s-pvc-storageclass-mismatch | operations | kubernetes | 1.00 (308tok) | 1.00 (440tok) | 1.00 (1030tok) |
| k8s-qos-class | operations | kubernetes | 1.00 (299tok) | 1.00 (319tok) | 1.00 (871tok) |
| k8s-service-selector-mismatch | operations | kubernetes | 1.00 (296tok) | 1.00 (360tok) | 1.00 (639tok) |
| k8s-traefik-ingressroute-host | operations | kubernetes | 1.00 (324tok) | 1.00 (344tok) | 1.00 (517tok) |
| linux-cgroup-oom-diagnosis | operations | linux | 1.00 (200tok) | 1.00 (450tok) | 1.00 (1058tok) |
| linux-cron-expression | operations | linux | 1.00 (222tok) | 1.00 (231tok) | 1.00 (1033tok) |
| linux-journalctl-filter | operations | linux | 1.00 (240tok) | 1.00 (271tok) | 1.00 (758tok) |
| linux-lvm-extend-order | operations | linux | 1.00 (426tok) | 1.00 (517tok) | 1.00 (1248tok) |
| linux-nftables-dnat | operations | linux | 1.00 (401tok) | 1.00 (740tok) | 1.00 (1146tok) |
| linux-pct-exec | operations | linux | 1.00 (593tok) | 1.00 (6904tok) | 1.00 (968tok) |
| linux-proc-meminfo-available | operations | linux | 1.00 (272tok) | 1.00 (511tok) | 1.00 (1087tok) |
| linux-ssh-port-forward | operations | linux | 1.00 (431tok) | 0.50 (1566tok) | 1.00 (837tok) |
| linux-systemd-oneshot | operations | linux | 1.00 (715tok) | 0.75 (3848tok) | 1.00 (580tok) |
| linux-systemd-timer | operations | linux | 1.00 (781tok) | 0.00 (12134tok)! | 0.67 (741tok) |
| macos-apfs-snapshot | operations | macos | 1.00 (827tok) | 0.00 (12121tok)! | 1.00 (1083tok) |
| macos-caffeinate | operations | macos | 1.00 (777tok) | 1.00 (1285tok) | 1.00 (1223tok) |
| macos-launchctl-bootstrap | operations | macos | 1.00 (1416tok) | 1.00 (2506tok) | 1.00 (1744tok) |
| macos-launchd-cron | operations | macos | 1.00 (1535tok) | 1.00 (4637tok) | 1.00 (2191tok) |
| macos-mdfind-spotlight | operations | macos | 1.00 (604tok) | 1.00 (4675tok) | 1.00 (1221tok) |
| macos-plutil-plist-read | operations | macos | 1.00 (448tok) | 1.00 (741tok) | 1.00 (1164tok) |
| macos-sed-inplace | operations | macos | 1.00 (483tok) | 1.00 (492tok) | 1.00 (819tok) |
| macos-ssh-identity-agent | operations | macos | 1.00 (1021tok) | 1.00 (3259tok) | 1.00 (1655tok) |
| macos-stat-bsd-gnu | operations | macos | 1.00 (444tok) | 1.00 (468tok) | 1.00 (1257tok) |
| macos-timeout-portability | operations | macos | 1.00 (1417tok) | 0.00 (12140tok)! | 0.67 (3692tok) |
| c-array-decay-sizeof | programming | c | 1.00 (181tok) | 1.00 (354tok) | 1.00 (842tok) |
| c-bitmask-ops | programming | c | 1.00 (173tok) | 1.00 (260tok) | 1.00 (942tok) |
| c-integer-promotion-overflow | programming | c | 1.00 (159tok) | 1.00 (316tok) | 1.00 (967tok) |
| c-macro-expansion-pitfall | programming | c | 1.00 (253tok) | 1.00 (668tok) | 1.00 (760tok) |
| c-pointer-arithmetic | programming | c | 1.00 (289tok) | 1.00 (327tok) | 1.00 (1179tok) |
| c-string-function-output | programming | c | 1.00 (698tok) | 1.00 (880tok) | 1.00 (780tok) |
| c-struct-bitfield-packing | programming | c | 1.00 (486tok) | 1.00 (657tok) | 1.00 (1188tok) |
| c-struct-size | programming | c | 1.00 (312tok) | 1.00 (365tok) | 1.00 (1135tok) |
| c-undefined-behavior-spot | programming | c | 1.00 (260tok) | 1.00 (331tok) | 1.00 (1651tok) |
| c-union-size-lp64 | programming | c | 1.00 (307tok) | 1.00 (408tok) | 1.00 (1342tok) |
| go-bounded-fanout | programming | golang | 1.00 (1628tok) | 0.00 (2714tok) | 1.00 (2483tok) |
| go-channel-deadlock | programming | golang | 1.00 (277tok) | 1.00 (410tok) | 1.00 (561tok) |
| go-context-cancellation-trace | programming | golang | 1.00 (283tok) | 1.00 (1093tok) | 1.00 (927tok) |
| go-defer-recover-trace | programming | golang | 1.00 (392tok) | 1.00 (415tok) | 1.00 (1090tok) |
| go-generics-constraint-fix | programming | golang | 1.00 (709tok) | 1.00 (6720tok) | 1.00 (1776tok) |
| go-method-set-interface | programming | golang | 1.00 (256tok) | 1.00 (1828tok) | 1.00 (1278tok) |
| go-semver-classify | programming | golang | 1.00 (616tok) | 1.00 (450tok) | 1.00 (1211tok) |
| go-slice-append-aliasing | programming | golang | 1.00 (433tok) | 1.00 (365tok) | 1.00 (1087tok) |
| go-struct-align | programming | golang | 1.00 (517tok) | 1.00 (1682tok) | 1.00 (2084tok) |
| go-worker-pool | programming | golang | 1.00 (972tok) | 0.00 (12193tok)! | 0.00 (894tok) |
| py-asyncio-gather-trace | programming | python | 1.00 (249tok) | 1.00 (342tok) | 1.00 (1352tok) |
| py-cosine | programming | python | 1.00 (4455tok) | 1.00 (3928tok) | 1.00 (3882tok) |
| py-dict-comprehension-trace | programming | python | 1.00 (346tok) | 1.00 (337tok) | 0.00 (12090tok)! |
| py-generator-exhaustion-trace | programming | python | 1.00 (207tok) | 1.00 (266tok) | 1.00 (1208tok) |
| py-json-transform | programming | python | 1.00 (434tok) | 1.00 (648tok) | 1.00 (1079tok) |
| py-log-triage | programming | python | 1.00 (1727tok) | 1.00 (2375tok) | 1.00 (1645tok) |
| py-mutable-default-arg | programming | python | 1.00 (299tok) | 1.00 (269tok) | 1.00 (950tok) |
| py-pathlib-rewrite | programming | python | 1.00 (657tok) | 1.00 (1705tok) | 1.00 (659tok) |
| py-regex-log-extraction | programming | python | 0.00 (956tok) | 1.00 (1616tok) | 1.00 (1249tok) |
| py-softmax | programming | python | 1.00 (2134tok) | 1.00 (2861tok) | 1.00 (1834tok) |
| ts-array-method-chain | programming | typescript | 1.00 (197tok) | 1.00 (263tok) | 1.00 (1146tok) |
| ts-computed-vs-watch | programming | typescript | 1.00 (226tok) | 1.00 (300tok) | 1.00 (823tok) |
| ts-debounce-composable | programming | typescript | 1.00 (687tok) | 1.00 (8871tok) | 1.00 (1521tok) |
| ts-discriminated-union-narrowing | programming | typescript | 1.00 (328tok) | 1.00 (436tok) | 1.00 (1624tok) |
| ts-eslint-flat-config | programming | typescript | 1.00 (903tok) | 1.00 (3197tok) | 1.00 (4007tok) |
| ts-generic-utility-type | programming | typescript | 1.00 (2547tok) | 0.25 (12152tok)! | 1.00 (1759tok) |
| ts-optional-chaining-nullish | programming | typescript | 1.00 (323tok) | 1.00 (392tok) | 1.00 (1513tok) |
| ts-promise-allsettled-trace | programming | typescript | 1.00 (186tok) | 1.00 (282tok) | 1.00 (287tok) |
| ts-tsconfig-strict-flag | programming | typescript | 1.00 (210tok) | 1.00 (665tok) | 1.00 (529tok) |
| ts-vue-ref-reactive-unwrap | programming | typescript | 1.00 (247tok) | 0.00 (12087tok) | 0.50 (629tok) |
| code-bigo-dedup | research | codebase | 1.00 (278tok) | 1.00 (313tok) | 1.00 (920tok) |
| code-bug-line-diff | research | codebase | 1.00 (344tok) | 1.00 (607tok) | 1.00 (892tok) |
| code-commit-bisect | research | codebase | 1.00 (474tok) | 1.00 (585tok) | 1.00 (996tok) |
| code-dead-functions | research | codebase | 1.00 (225tok) | 1.00 (312tok) | 1.00 (404tok) |
| code-generic-return-type | research | codebase | 1.00 (171tok) | 1.00 (248tok) | 1.00 (599tok) |
| code-import-cycle | research | codebase | 1.00 (150tok) | 1.00 (230tok) | 1.00 (973tok) |
| code-race-variable | research | codebase | 1.00 (188tok) | 1.00 (409tok) | 1.00 (643tok) |
| code-trace-go | research | codebase | 1.00 (766tok) | 1.00 (690tok) | 1.00 (1514tok) |
| code-trace-python | research | codebase | 1.00 (378tok) | 1.00 (345tok) | 1.00 (981tok) |
| code-trace-ts | research | codebase | 1.00 (447tok) | 1.00 (356tok) | 1.00 (958tok) |
| web-canonical-vs-redirect | research | web | 1.00 (249tok) | 1.00 (400tok) | 1.00 (767tok) |
| web-cors-missing-header | research | web | 1.00 (221tok) | 1.00 (369tok) | 1.00 (999tok) |
| web-dns-mx-record | research | web | 1.00 (186tok) | 1.00 (204tok) | 1.00 (529tok) |
| web-hreflang-reciprocal | research | web | 1.00 (496tok) | 1.00 (540tok) | 1.00 (1319tok) |
| web-html-product-extract | research | web | 1.00 (203tok) | 1.00 (315tok) | 1.00 (956tok) |
| web-http-status-scenarios | research | web | 1.00 (425tok) | 1.00 (516tok) | 1.00 (1719tok) |
| web-robots-ai-crawlers | research | web | 1.00 (517tok) | 1.00 (591tok) | 1.00 (1469tok) |
| web-security-headers-audit | research | web | 1.00 (327tok) | 1.00 (409tok) | 1.00 (1250tok) |
| web-sitemap-max-urls | research | web | 1.00 (127tok) | 1.00 (272tok) | 1.00 (958tok) |
| web-url-parse-components | research | web | 1.00 (367tok) | 1.00 (316tok) | 1.00 (987tok) |
| paper-attention-scale-factor | research | whitepapers | 1.00 (300tok) | 1.00 (378tok) | 1.00 (608tok) |
| paper-bloom-fp-rate | research | whitepapers | 1.00 (5487tok) | 1.00 (5125tok) | 1.00 (2928tok) |
| paper-btree-height-levels | research | whitepapers | 1.00 (501tok) | 1.00 (541tok) | 1.00 (1499tok) |
| paper-cap-availability-choice | research | whitepapers | 1.00 (269tok) | 1.00 (366tok) | 1.00 (879tok) |
| paper-hnsw-params | research | whitepapers | 1.00 (346tok) | 1.00 (541tok) | 1.00 (1337tok) |
| paper-lsm-write-amplification | research | whitepapers | 1.00 (416tok) | 1.00 (529tok) | 1.00 (1351tok) |
| paper-pq-compression-ratio | research | whitepapers | 1.00 (387tok) | 1.00 (468tok) | 1.00 (1112tok) |
| paper-raft-quorum-failures | research | whitepapers | 1.00 (392tok) | 1.00 (418tok) | 1.00 (1350tok) |
| paper-recall-at-k | research | whitepapers | 1.00 (430tok) | 1.00 (492tok) | 1.00 (1158tok) |
| paper-tfidf-term-score | research | whitepapers | 1.00 (527tok) | 1.00 (604tok) | 1.00 (1595tok) |
| sec-csrf-requirement | security | appsec | 1.00 (187tok) | 1.00 (297tok) | 1.00 (726tok) |
| sec-idor-spot | security | appsec | 1.00 (282tok) | 1.00 (424tok) | 1.00 (658tok) |
| sec-input-validation-boundary | security | appsec | 0.00 (12089tok)! | 1.00 (2320tok) | 1.00 (1382tok) |
| sec-open-redirect-spot | security | appsec | 1.00 (179tok) | 1.00 (241tok) | 1.00 (502tok) |
| sec-path-traversal-spot | security | appsec | 1.00 (277tok) | 1.00 (331tok) | 1.00 (628tok) |
| sec-rate-limit-placement | security | appsec | 1.00 (184tok) | 1.00 (397tok) | 1.00 (1185tok) |
| sec-secret-log-spot | security | appsec | 1.00 (305tok) | 1.00 (292tok) | 1.00 (471tok) |
| sec-sqli-spot | security | appsec | 1.00 (229tok) | 1.00 (311tok) | 1.00 (504tok) |
| sec-ssrf-spot | security | appsec | 1.00 (285tok) | 1.00 (485tok) | 1.00 (628tok) |
| sec-xss-sink-spot | security | appsec | 1.00 (225tok) | 1.00 (312tok) | 1.00 (606tok) |
| sec-aes-gcm-nonce-reuse | security | crypto | 1.00 (2811tok) | 1.00 (10585tok) | 1.00 (3603tok) |
| sec-cert-chain-validation | security | crypto | 1.00 (244tok) | 1.00 (326tok) | 1.00 (1189tok) |
| sec-constant-time-compare | security | crypto | 1.00 (468tok) | 1.00 (462tok) | 1.00 (874tok) |
| sec-hash-vs-encrypt-pii | security | crypto | 1.00 (201tok) | 1.00 (250tok) | 1.00 (543tok) |
| sec-hmac-vs-signature | security | crypto | 1.00 (488tok) | 1.00 (375tok) | 1.00 (577tok) |
| sec-jwt-alg-none | security | crypto | 1.00 (210tok) | 1.00 (337tok) | 1.00 (1050tok) |
| sec-password-hash-choice | security | crypto | 0.50 (1597tok) | 0.50 (638tok) | 0.50 (1936tok) |
| sec-rand-source-choice | security | crypto | 0.50 (435tok) | 1.00 (839tok) | 0.50 (1270tok) |
| sec-rotate-order | security | crypto | 1.00 (354tok) | 1.00 (665tok) | 1.00 (6504tok) |
| sec-tls-floor-version | security | crypto | 1.00 (155tok) | 1.00 (311tok) | 1.00 (959tok) |
| sec-diff-secret-spot | security | secrets | 1.00 (250tok) | 1.00 (394tok) | 1.00 (1159tok) |
| sec-fork-pr-ci-exposure | security | secrets | 1.00 (344tok) | 1.00 (289tok) | 1.00 (376tok) |
| sec-gitignore-history-nuance | security | secrets | 1.00 (376tok) | 1.00 (311tok) | 1.00 (819tok) |
| sec-hardcoded-secret-spot | security | secrets | 1.00 (170tok) | 1.00 (202tok) | 1.00 (243tok) |
| sec-k8s-secret-base64 | security | secrets | 1.00 (148tok) | 1.00 (199tok) | 1.00 (328tok) |
| sec-least-privilege-scope | security | secrets | 1.00 (177tok) | 1.00 (230tok) | 1.00 (770tok) |
| sec-remediation-order | security | secrets | 1.00 (1843tok) | 1.00 (506tok) | 0.00 (12098tok)! |
| sec-rotation-no-downtime | security | secrets | 1.00 (451tok) | 1.00 (501tok) | 1.00 (465tok) |
| sec-ssh-agent-identity | security | secrets | 1.00 (1689tok) | 1.00 (2416tok) | 1.00 (2843tok) |
| sec-vault-tradeoff | security | secrets | 1.00 (305tok) | 1.00 (407tok) | 1.00 (740tok) |
! = response truncated by the token budget (finish_reason=length); increase max_tokens_default to avoid
## Category rollup (mean score)
| Category | uni/deepseek-v4-flash-0731 | uni/qwen3.6-27b | uni/btl-4 |
| --- | --- | --- | --- |
| agents | 1.00 | 1.00 | 0.98 |
| ai | 1.00 | 1.00 | 1.00 |
| databases | 0.98 | 0.98 | 0.95 |
| delivery | 0.91 | 0.87 | 0.89 |
| operations | 0.99 | 0.87 | 0.98 |
| programming | 0.97 | 0.91 | 0.94 |
| research | 1.00 | 1.00 | 1.00 |
| security | 0.93 | 0.98 | 0.93 |
## Model summary
| Model | Mean | Passed | Partial | Failed | Errors | Mean latency | Total tokens | Tok/s |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| uni/deepseek-v4-flash-0731 | 0.97 | 240 | 6 | 4 | 0 | 5.685104556s | 138851 | 71.6 |
| uni/qwen3.6-27b | 0.95 | 233 | 8 | 9 | 0 | 23.689755872s | 291412 | 40.7 |
| uni/btl-4 | 0.96 | 234 | 10 | 6 | 0 | 13.135007924s | 295086 | 77.7 |
+103 -7
View File
@@ -14,6 +14,7 @@ import (
"github.com/lukaszraczylo/llm-testbench/internal/llm"
"github.com/lukaszraczylo/llm-testbench/internal/report"
"github.com/lukaszraczylo/llm-testbench/internal/runner"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
"github.com/lukaszraczylo/llm-testbench/internal/tests"
)
@@ -37,9 +38,12 @@ func main() {
err = runCommand(os.Args[2:])
case "list":
err = listCommand(os.Args[2:])
case "compare":
err = compareCommand(os.Args[2:])
case "health":
err = healthCommand(os.Args[2:])
case "version", "-v", "--version":
fmt.Println("llmtest " + version)
return
case "-h", "--help", "help":
usage()
return
@@ -59,9 +63,16 @@ func usage() {
fmt.Fprint(os.Stderr, `llmtest - LLM accuracy testing framework
Usage:
llmtest run [flags] Run the test catalog against configured models.
llmtest list [flags] List the test catalog.
llmtest version Print the build version.
llmtest run [flags] Run the test catalog against configured models.
llmtest list [flags] List the test catalog.
llmtest compare baseline.json current.json
Diff two saved run artifacts (--out files).
llmtest health file.json [more.json ...]
Audit suite health: which subcategories are
saturated (every model passes everything), and
which tests still carry signal. Pools multiple
artifacts.
llmtest version Print the build version.
Run "llmtest run -h" or "llmtest list -h" for flag details.
`)
@@ -103,9 +114,12 @@ func runCommand(args []string) error {
fs := flag.NewFlagSet("run", flag.ExitOnError)
shared := bindSharedFlags(fs)
modelsCSV := fs.String("models", "", "comma-separated model override (defaults to config's models)")
testsCSV := fs.String("tests", "", "comma-separated exact test IDs (overrides --category/--subcategory)")
format := fs.String("format", "table", "output format: table|markdown|json")
concurrency := fs.Int("concurrency", 0, "override config's concurrency (0 = use config)")
timeout := fs.Duration("timeout", 0, "override config's request_timeout (0 = use config)")
repeat := fs.Int("repeat", 1, "samples per (model, test); >1 exposes response instability at temperature 0")
out := fs.String("out", "", "write the run as a JSON artifact to this file (for llmtest compare)")
quiet := fs.Bool("quiet", false, "suppress progress output on stderr")
if err := fs.Parse(args); err != nil {
return err
@@ -134,9 +148,9 @@ func runCommand(args []string) error {
client := llm.NewOpenAIClient(cfg.Endpoint, cfg.APIKey, reqTimeout)
registry := tests.All()
selected := registry.Filter(shared.category, shared.subcategory)
if len(selected) == 0 {
return fmt.Errorf("no tests matched category=%q subcategory=%q", shared.category, shared.subcategory)
selected, err := selectTests(registry, *testsCSV, shared.category, shared.subcategory)
if err != nil {
return err
}
var reporter runner.ProgressReporter = runner.NoopProgressReporter{}
@@ -149,6 +163,7 @@ func runCommand(args []string) error {
Temperature: requestTemperature,
MaxTokensDefault: cfg.MaxTokensDefault,
Reporter: reporter,
Repeat: *repeat,
})
ctx := context.Background()
@@ -159,9 +174,90 @@ func runCommand(args []string) error {
return err
}
if *out != "" {
if err := report.WriteArtifact(*out, selected, models, results); err != nil {
return fmt.Errorf("write --out: %w", err)
}
}
return report.Render(os.Stdout, f, selected, models, results)
}
// selectTests resolves the test subset: --tests (exact IDs, error on any
// unknown ID) overrides --category/--subcategory filtering.
func selectTests(registry *testkit.Registry, testsCSV, category, subcategory string) ([]testkit.Test, error) {
if testsCSV == "" {
selected := registry.Filter(category, subcategory)
if len(selected) == 0 {
return nil, fmt.Errorf("no tests matched category=%q subcategory=%q", category, subcategory)
}
return selected, nil
}
ids := splitCSV(testsCSV)
out := make([]testkit.Test, 0, len(ids))
seen := make(map[string]bool, len(ids))
for _, id := range ids {
if seen[id] {
continue
}
seen[id] = true
t, ok := registry.Get(id)
if !ok {
return nil, fmt.Errorf("unknown test id %q (see llmtest list)", id)
}
out = append(out, t)
}
if len(out) == 0 {
return nil, fmt.Errorf("--tests parsed to zero ids")
}
return out, nil
}
// compareCommand diffs two saved artifacts from `llmtest run --out`.
func compareCommand(args []string) error {
fs := flag.NewFlagSet("compare", flag.ExitOnError)
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 2 {
return fmt.Errorf("usage: llmtest compare baseline.json current.json")
}
baseline, err := report.LoadArtifact(fs.Arg(0))
if err != nil {
return err
}
current, err := report.LoadArtifact(fs.Arg(1))
if err != nil {
return err
}
return report.RenderCompare(os.Stdout, report.CompareArtifacts(baseline, current))
}
// healthCommand audits one or more saved artifacts for suite health:
// per-subcategory saturation and the tests that still discriminate.
func healthCommand(args []string) error {
fs := flag.NewFlagSet("health", flag.ExitOnError)
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() < 1 {
return fmt.Errorf("usage: llmtest health artifact.json [more.json ...]")
}
artifacts := make([]report.Artifact, 0, fs.NArg())
for _, p := range fs.Args() {
a, err := report.LoadArtifact(p)
if err != nil {
return fmt.Errorf("load %s: %w", p, err)
}
artifacts = append(artifacts, a)
}
return report.RenderHealth(os.Stdout, report.AuditHealth(artifacts))
}
// validateFormat parses and validates the --format flag value, pulled out
// of runCommand as its own function so it is unit-testable without going
// through flag parsing / a live run (N8).
+40
View File
@@ -1,9 +1,12 @@
package main
import (
"strings"
"testing"
"github.com/lukaszraczylo/llm-testbench/internal/eval"
"github.com/lukaszraczylo/llm-testbench/internal/report"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
func TestSplitCSV(t *testing.T) {
@@ -61,3 +64,40 @@ func TestValidateFormat(t *testing.T) {
})
}
}
func TestSelectTests(t *testing.T) {
reg := testkit.NewRegistry()
reg.Register(testkit.Test{ID: "a", Category: "x", Subcategory: "s", Eval: eval.ContainsAny("a")})
reg.Register(testkit.Test{ID: "b", Category: "y", Subcategory: "s", Eval: eval.ContainsAny("b")})
got, err := selectTests(reg, "b,a", "", "")
if err != nil {
t.Fatalf("selectTests() error = %v", err)
}
if len(got) != 2 || got[0].ID != "b" || got[1].ID != "a" {
t.Errorf("selection = %+v, want [b a] in flag order", got)
}
if _, err := selectTests(reg, "a,nope", "", ""); err == nil || !strings.Contains(err.Error(), "nope") {
t.Errorf("unknown id error = %v, want mention of %q", err, "nope")
}
got, err = selectTests(reg, "", "y", "")
if err != nil || len(got) != 1 || got[0].ID != "b" {
t.Errorf("category filter = %+v, %v; want [b], nil", got, err)
}
if _, err := selectTests(reg, "", "zzz", ""); err == nil {
t.Error("empty category filter: error = nil, want no-match error")
}
}
func TestHealthCommand_RequiresArtifacts(t *testing.T) {
if err := healthCommand(nil); err == nil || !strings.Contains(err.Error(), "usage:") {
t.Errorf("healthCommand(nil) error = %v, want usage error", err)
}
if err := healthCommand([]string{"/nonexistent-artifact.json"}); err == nil ||
!strings.Contains(err.Error(), "load /nonexistent-artifact.json") {
t.Errorf("healthCommand(missing file) error = %v, want load error naming the file", err)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ models:
- uni/qwen3.6-27b
- uni/btl-4
concurrency: 8 # default when omitted; matches internal/config's default
request_timeout: 300s # default when omitted
request_timeout: 900s # default when omitted
max_tokens_default: 12000 # default when omitted; a reasoning model can spend
# thousands of tokens on reasoning_content before
# writing an answer, so this must stay generous
+5 -2
View File
@@ -15,7 +15,10 @@ import (
const apiKeyEnvVar = "LLMTB_API_KEY" // #nosec G101 -- this is an env var *name*, not a credential value
// Defaults applied when the corresponding YAML key is omitted (or explicit
// zero). max_tokens_default was raised from 4000 to 12000 after a live run
// zero). max_tokens_default was raised from 4000 to 12000, then to 40000
// (2026-08-30: at 25000 the heaviest thinking-model tests still exhausted
// the whole budget on reasoning and scored 0 with an empty answer, e.g.
// ts-eslint-flat-config on qwen3.8-flash-next), after a live run
// showed reasoning models spend thousands of completion tokens on
// reasoning_content before writing an answer to content: a small
// per-test/default budget truncates the answer (finish_reason=length)
@@ -23,7 +26,7 @@ const apiKeyEnvVar = "LLMTB_API_KEY" // #nosec G101 -- this is an env var *name*
const (
defaultConcurrency = 8
defaultRequestTimeout = 300 * time.Second
defaultMaxTokens = 12000
defaultMaxTokens = 40000
)
// Config is the top-level configuration loaded from config.yaml.
+115 -12
View File
@@ -3,6 +3,7 @@ package eval
import (
"context"
"regexp"
"strings"
)
// negationCueAlternation is the shared union of cue words/phrases that turn
@@ -18,7 +19,20 @@ import (
const negationCueAlternation = `don'?t|do not|never|avoid|not|cannot|can'?t|without|no need|` +
`shouldn'?t|should not|must not|isn'?t|wasn'?t|danger|dangerous|` +
`unsuitable|inappropriate|unsafe|insecure|weak|broken|deprecated|obsolete|` +
`too fast|predictable|unacceptable|blocks|wrong|problem`
`too fast|predictable|unacceptable|blocks|wrong|problem|` +
// Consequence-description cues: a good answer often names the discouraged
// term while describing the harm it causes, with no imperative or
// adjectival cue at all - "a bare --force will silently overwrite their
// work", "math/rand is a deterministic PRNG that relies on a seed"
// (observed as all-model false positives in the 2026-08-30 3-model run).
`overwrit\w*|clobber\w*|silently|discard\w*|deterministic|seeded|guessable|reproducible|` +
`lose|loses|losing|lost|` +
// Modal harm phrases: "a bare --force will replace the remote branch",
// "would replace ... your rewritten history". The modal keeps these
// narrow - a bare "replaces"/"rewritten" also appears in genuine
// endorsements ("make sure your rewritten history replaces theirs") and
// must not count as a cue.
`will replace|would replace|a bare`
// negationCuePattern matches any cue word/phrase in negationCueAlternation,
// case-insensitively, as a whole word/phrase (not a substring of an
@@ -44,24 +58,51 @@ const negationDirectionalCueAlternation = `instead of|rather than`
// Checked only in the window immediately BEFORE a match, never after.
var negationDirectionalCuePattern = regexp.MustCompile(`(?i)\b(?:` + negationDirectionalCueAlternation + `)\b`)
// isSentenceDot reports whether response[i] is a sentence-ending '.': a dot
// followed by whitespace or end-of-string that does not terminate a common
// abbreviation ("e.g.", "i.e.", "etc.", "vs."). A dot glued to a following
// non-space character ("e.g.,", "release/2.4") is part of a token, never a
// clause boundary.
func isSentenceDot(response string, i int) bool {
if response[i] != '.' {
return false
}
if i+1 < len(response) && response[i+1] != ' ' && response[i+1] != '\n' && response[i+1] != '\t' {
return false
}
for _, abbr := range []string{"e.g", "i.e", "etc", "vs"} {
if i >= len(abbr) && strings.EqualFold(response[i-len(abbr):i], abbr) {
return false
}
}
return true
}
// clauseWindowBounds computes the [lo,hi) bidirectional search window
// around a match [start,end) in response: outward up to maxChars
// characters in each direction, but stopped early at the nearest clause
// boundary - a '.', ';', or a blank-line paragraph gap - whichever comes
// first, so a negation cue from an unrelated adjacent clause or sentence
// can never count as negating a genuinely unqualified endorsement
// elsewhere in a longer response (D5, C2). This is a known, accepted
// limitation for sentence-final abbreviations (e.g. "e.g.", "etc.") that
// happen to sit inside the window - none of this framework's fixtures
// place a negation cue immediately after one.
// boundary - a sentence-ending '.', a ';', or a blank-line paragraph gap -
// whichever comes first, so a negation cue from an unrelated adjacent
// clause or sentence can never count as negating a genuinely unqualified
// endorsement elsewhere in a longer response (D5, C2).
//
// A '.' counts as a clause boundary only when followed by whitespace or
// end-of-string. A dot glued to a following non-space character is part of
// a token, not a sentence end: the dots inside "e.g.," and a version
// number like "release/2.4" were observed cutting the window between a
// match and its genuine negation cue ("Do not edit ... (e.g., `kubectl
// edit`)" and "git push --force origin release/2.4\n``` should never be
// used") in the 2026-08-30 3-model run, zeroing correct answers from all
// three models.
func clauseWindowBounds(response string, start, end, maxChars int) (lo, hi int) {
lo = start - maxChars
if lo < 0 {
lo = 0
}
for i := start - 1; i >= lo; i-- {
c := response[i]
if c == '.' || c == ';' {
if isSentenceDot(response, i) || c == ';' {
lo = i + 1
break
}
@@ -77,7 +118,7 @@ func clauseWindowBounds(response string, start, end, maxChars int) (lo, hi int)
}
for i := end; i < hi; i++ {
c := response[i]
if c == '.' || c == ';' {
if isSentenceDot(response, i) || c == ';' {
hi = i
break
}
@@ -89,6 +130,24 @@ func clauseWindowBounds(response string, start, end, maxChars int) (lo, hi int)
return lo, hi
}
// sameClause reports whether response[from:to] contains no clause boundary
// (sentence-ending dot, ';', or blank-line paragraph gap), i.e. the two
// offsets sit inside one clause. Used for enumeration inheritance in
// NoUnnegatedMention; deliberately has no maxChars cap - a clause is a
// clause however long its comma list runs.
func sameClause(response string, from, to int) bool {
for i := from; i < to; i++ {
c := response[i]
if isSentenceDot(response, i) || c == ';' {
return false
}
if c == '\n' && i+1 < to && response[i+1] == '\n' {
return false
}
}
return true
}
// negationCueNearby reports whether a negation cue (bidirectional,
// clause-scoped) negates the match [start,end) in response. Directional
// cues (instead of/rather than) are checked only in the PRE-window.
@@ -125,20 +184,64 @@ func negationCueNearby(response string, start, end, maxChars int) bool {
// exempt is treated as inherently safe and skips the negation check
// entirely (e.g. "--force-with-lease" is the safe form of "--force" and
// never needs a negation cue to be a correct recommendation).
// stripMarkdownEmphasis removes backticks and double-asterisk bold markers
// before negation scanning. Both are semantically empty but inflate the
// character distance between a cue and its match past the window cap:
// "you should **not** fix this ... with `kubectl edit`, `kubectl patch`"
// zeroed correct answers from all three models in the 2026-08-30 run.
// Single asterisks are kept - they can be literal command text ("KEYS *").
func stripMarkdownEmphasis(s string) string {
// Blank lines hugging a code fence are display layout, not a paragraph
// break: "A bare:\n\n```bash\ngit push --force ...\n```\n\nshould never
// be used" is one sentence split around a display block, and the gap
// must not cut the clause window between the fenced example and the
// prose negating it (observed false positive, 2026-08-30 run).
s = fenceLeadingGapPattern.ReplaceAllString(s, "\n```")
s = fenceTrailingGapPattern.ReplaceAllString(s, "```$1\n")
// A code-fence line ("```" or "```bash") must not become an empty line
// either - same fake-paragraph-gap failure mode.
s = strings.ReplaceAll(s, "```", " ")
s = strings.ReplaceAll(s, "**", "")
return strings.ReplaceAll(s, "`", "")
}
// fenceLeadingGapPattern and fenceTrailingGapPattern match the blank lines
// immediately before an opening code fence and after a closing one.
var (
fenceLeadingGapPattern = regexp.MustCompile(`\n[ \t]*\n+(?:[ \t]*\n)*` + "```")
fenceTrailingGapPattern = regexp.MustCompile("```" + `([a-zA-Z0-9]*)\n[ \t]*\n+(?:[ \t]*\n)*`)
)
func NoUnnegatedMention(badPattern *regexp.Regexp, maxChars int, exempt *regexp.Regexp) Evaluator {
return EvaluatorFunc(func(_ context.Context, response string) Score {
response = stripMarkdownEmphasis(response)
matches := badPattern.FindAllStringIndex(response, -1)
if len(matches) == 0 {
return Score{Value: 1, Detail: "no mention of a discouraged term"}
}
// prevNegatedEnd is the end offset of the most recent match that was
// found negated; -1 when there is none. A later match with no cue of
// its own inherits that negation when no sentence boundary separates
// the two - a single cue distributes over a comma enumeration of the
// same discouraged family ("not ... with kubectl edit, kubectl
// patch, or kubectl scale"), where each successive item drifts
// further from the shared cue than maxChars allows (observed as
// all-model false positives in the 2026-08-30 3-model run).
prevNegatedEnd := -1
for _, loc := range matches {
start, end := loc[0], loc[1]
if exempt != nil && exempt.MatchString(response[end:]) {
continue
}
if !negationCueNearby(response, start, end, maxChars) {
return Score{Value: 0, Detail: "unnegated mention of a discouraged term"}
if negationCueNearby(response, start, end, maxChars) {
prevNegatedEnd = end
continue
}
if prevNegatedEnd >= 0 && sameClause(response, prevNegatedEnd, start) {
prevNegatedEnd = end
continue
}
return Score{Value: 0, Detail: "unnegated mention of a discouraged term"}
}
return Score{Value: 1, Detail: "every mention of a discouraged term is negated"}
})
+52
View File
@@ -22,6 +22,58 @@ func TestNoUnnegatedMention(t *testing.T) {
{"post-window 'insecure' adjectival cue", "MD5 is insecure for this purpose.", 1},
{"post-window 'predictable' adjectival cue", "MD5 output is predictable given no work factor.", 1},
{"bare 'no:' cue", "no: MD5 lacks a tunable work factor.", 1},
// Regression fixtures from the 2026-08-30 3-model run: all four shapes
// below zeroed correct answers from every model tested.
{
name: "abbreviation dot does not cut the clause window ('e.g.,')",
response: "Do not edit the live values directly (e.g., MD5 configuration) since drift follows.",
want: 1,
},
{
name: "abbreviation dot with space does not cut the clause window ('e.g. ')",
response: "Do not reach for weak digests, e.g. MD5, in any password path.",
want: 1,
},
{
name: "mid-token version dot does not cut the clause window",
response: "Using v2.4 of the library with MD5 should never be done for passwords.",
want: 1,
},
{
name: "markdown bold and backticks do not inflate the cue distance",
response: "You should **not** store passwords hashed by any weak digest like `MD5` here.",
want: 1,
},
{
name: "consequence-description cue 'deterministic' negates",
response: "MD5 here acts as a deterministic mapping an attacker can precompute.",
want: 1,
},
{
name: "consequence-description cue 'silently overwriting' negates",
response: "Choosing MD5 means silently overwriting your security margin.",
want: 1,
},
{
name: "sentence-ending dot still cuts the window",
response: "Never trust the old design. Hash the password with MD5 today.",
want: 0,
},
{
name: "blank lines around a fenced display block do not cut the window",
response: "A digest like:\n\n```text\nMD5\n```\n\nshould never touch a password path.",
want: 1,
},
{
name: "one cue distributes over a comma enumeration in the same clause",
response: "Do not protect anything using outdated digests such as MD5, or an unsalted MD5, or a doubled MD5 for that matter.",
want: 1,
},
{
name: "enumeration inheritance stops at a sentence boundary",
response: "Never rely on MD5 for new designs. MD5 is what this legacy tool emits, so wire MD5 into the new verifier as-is.",
want: 0,
},
{
name: "clause-scoped: cue in an unrelated PRECEDING sentence does not count",
response: "Never mind the deployment steps. Just hash the password with MD5.",
+8 -2
View File
@@ -135,8 +135,12 @@ func (c *OpenAIClient) Complete(ctx context.Context, req Request) (Response, err
finishReason = resp.Choices[0].FinishReason
}
if resp.Error != nil {
// A 2xx carrying a JSON error body is how some gateways report a
// transient upstream failure (observed as instant 0-token
// "errors" in the 2026-08-30 run while a replica was warming).
// Treat it like a 5xx: retry within the same attempt budget.
lastErr = fmt.Errorf("llm: api error: %s", resp.Error.Message)
return Response{}, lastErr
continue
}
return Response{
Text: text,
@@ -190,7 +194,9 @@ func (c *OpenAIClient) doRequest(ctx context.Context, payload []byte) (*chatComp
return nil, &retryableError{err: fmt.Errorf("read body: %w", err)}
}
if httpResp.StatusCode >= 500 {
// 429 is as transient as a 5xx for a self-hosted gateway (rate limit or
// a briefly saturated upstream); retry it with the same backoff budget.
if httpResp.StatusCode >= 500 || httpResp.StatusCode == http.StatusTooManyRequests {
return nil, &retryableError{status: httpResp.StatusCode}
}
if httpResp.StatusCode >= 400 {
+51
View File
@@ -236,7 +236,9 @@ func TestOpenAIClient_Complete_NoRetryOn4xx(t *testing.T) {
}
func TestOpenAIClient_Complete_APIErrorField(t *testing.T) {
var calls int32
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
_, _ = w.Write([]byte(`{"error":{"message":"model overloaded"}}`))
})
@@ -248,6 +250,55 @@ func TestOpenAIClient_Complete_APIErrorField(t *testing.T) {
if !strings.Contains(err.Error(), "model overloaded") {
t.Errorf("error = %v, want mention of model overloaded", err)
}
if got := atomic.LoadInt32(&calls); got != maxRetries+1 {
t.Errorf("calls = %d, want %d (2xx-with-error-body is retried like a 5xx)", got, maxRetries+1)
}
}
func TestOpenAIClient_Complete_RetriesAPIErrorThenSucceeds(t *testing.T) {
var calls int32
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if atomic.AddInt32(&calls, 1) == 1 {
_, _ = w.Write([]byte(`{"error":{"message":"replica warming"}}`))
return
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`))
})
client := NewOpenAIClient(srv.URL, "", time.Second)
resp, err := client.Complete(context.Background(), Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
if err != nil {
t.Fatalf("Complete() error = %v, want nil", err)
}
if resp.Text != "ok" {
t.Errorf("Text = %q, want %q", resp.Text, "ok")
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Errorf("calls = %d, want 2 (one error body, one success)", got)
}
}
func TestOpenAIClient_Complete_RetriesOn429ThenSucceeds(t *testing.T) {
var calls int32
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if atomic.AddInt32(&calls, 1) == 1 {
w.WriteHeader(http.StatusTooManyRequests)
return
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`))
})
client := NewOpenAIClient(srv.URL, "", time.Second)
resp, err := client.Complete(context.Background(), Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
if err != nil {
t.Fatalf("Complete() error = %v, want nil", err)
}
if resp.Text != "ok" {
t.Errorf("Text = %q, want %q", resp.Text, "ok")
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Errorf("calls = %d, want 2 (one 429, one success)", got)
}
}
func TestOpenAIClient_Complete_ContextCanceled(t *testing.T) {
+221
View File
@@ -0,0 +1,221 @@
package report
import (
"fmt"
"io"
"math"
"sort"
"strings"
)
// Comparison is the per-(model, test) diff between two saved artifacts.
// Scores are means over attempts, so a test that flapped between runs
// shows up as a regression on one attempt basis only if the mean moved.
type Comparison struct {
Model string
TestID string
Category string
Subcategory string
Status string
BaselineMean float64
CurrentMean float64
Delta float64
}
// RegressionDelta is the mean-score drop at which a test counts as a
// regression. Below this, run-to-run sampling noise dominates.
const RegressionDelta = 0.01
type attemptKey struct{ model, testID string }
// meanAttempts collapses saved attempts into per-(model,test) means and
// carries category metadata from the first attempt that has it. Truncated
// attempts are excluded from the mean when any attempt finished normally,
// mirroring the truncation policy in indexResults; when every scored
// attempt was truncated, the partial-text scores are used rather than
// reporting no data.
func meanAttempts(results []AttemptResult) map[attemptKey]Comparison {
type agg struct {
cat string
sb string
sum float64
n int
cleanSum float64
cleanN int
errs int
}
by := make(map[attemptKey]*agg)
order := make([]attemptKey, 0, len(results))
for _, r := range results {
k := attemptKey{r.Model, r.TestID}
a, seen := by[k]
if !seen {
a = &agg{}
by[k] = a
order = append(order, k)
}
if a.cat == "" {
a.cat, a.sb = r.Category, r.Subcategory
}
if r.Error != "" {
a.errs++
continue
}
if r.Skipped {
continue
}
a.sum += r.Score
a.n++
if !r.Truncated {
a.cleanSum += r.Score
a.cleanN++
}
}
out := make(map[attemptKey]Comparison, len(by))
for _, k := range order {
a := by[k]
c := Comparison{Model: k.model, TestID: k.testID, Category: a.cat, Subcategory: a.sb}
switch {
case a.n == 0:
c.Status = "error"
case a.cleanN > 0:
c.BaselineMean = a.cleanSum / float64(a.cleanN)
default:
c.BaselineMean = a.sum / float64(a.n)
}
out[k] = c
}
return out
}
// CompareArtifacts diffs two artifacts per (model, test), sorted by
// delta ascending (worst regressions first), then test ID.
func CompareArtifacts(baseline, current Artifact) []Comparison {
base := meanAttempts(baseline.Results)
cur := meanAttempts(current.Results)
keys := make(map[attemptKey]bool, len(base)+len(cur))
for k := range base {
keys[k] = true
}
for k := range cur {
keys[k] = true
}
out := make([]Comparison, 0, len(keys))
for k := range keys {
b, hasB := base[k]
c, hasC := cur[k]
switch {
case hasB && !hasC:
b.CurrentMean = math.NaN()
b.Delta = math.NaN()
b.Status = "baseline-only"
out = append(out, b)
case !hasB && hasC:
c.BaselineMean = math.NaN()
c.Delta = math.NaN()
c.Status = "current-only"
out = append(out, c)
default:
m := Comparison{
Model: k.model,
TestID: k.testID,
Category: c.Category,
Subcategory: c.Subcategory,
BaselineMean: b.BaselineMean,
CurrentMean: c.BaselineMean,
}
switch {
case b.Status == "error" || c.Status == "error":
m.Status = "error"
m.Delta = math.NaN()
default:
m.Delta = m.CurrentMean - m.BaselineMean
switch {
case m.Delta <= -RegressionDelta:
m.Status = "regression"
case m.Delta >= RegressionDelta:
m.Status = "improvement"
default:
m.Status = "stable"
}
}
out = append(out, m)
}
}
sort.Slice(out, func(i, j int) bool {
di, dj := out[i].Delta, out[j].Delta
if math.IsNaN(di) != math.IsNaN(dj) {
return math.IsNaN(di)
}
if math.IsNaN(di) && math.IsNaN(dj) {
if out[i].Status != out[j].Status {
return out[i].Status < out[j].Status
}
return out[i].TestID < out[j].TestID
}
if di != dj {
return di < dj
}
if out[i].TestID != out[j].TestID {
return out[i].TestID < out[j].TestID
}
return out[i].Model < out[j].Model
})
return out
}
// RenderCompare writes a comparison report: headline counts, then only
// changed/missing rows (stable rows are summarized, not listed).
func RenderCompare(w io.Writer, comparisons []Comparison) error {
ew := &errWriter{w: w}
var reg, imp, stable, added, removed, errored int
for _, c := range comparisons {
switch c.Status {
case "regression":
reg++
case "improvement":
imp++
case "baseline-only":
removed++
case "current-only":
added++
case "error":
errored++
default:
stable++
}
}
ew.println(fmt.Sprintf("== Compare: %d regressions, %d improvements, %d stable, %d errors, %d baseline-only, %d current-only ==",
reg, imp, stable, errored, removed, added))
ew.println()
ew.println("MODEL\tTEST\tCATEGORY\tSUBCATEGORY\tBASELINE\tCURRENT\tDELTA\tSTATUS")
shown := 0
for _, c := range comparisons {
if c.Status == "stable" {
continue
}
ew.println(strings.Join([]string{
c.Model, c.TestID, c.Category, c.Subcategory,
meanCellText(c.BaselineMean), meanCellText(c.CurrentMean),
deltaText(c.Delta), c.Status,
}, "\t"))
shown++
}
if shown == 0 {
ew.println("(no changed, missing, or erroring tests)")
}
return ew.err
}
func deltaText(v float64) string {
if math.IsNaN(v) {
return "N/A"
}
return fmt.Sprintf("%+.2f", v)
}
+114
View File
@@ -0,0 +1,114 @@
package report
import (
"bytes"
"math"
"strings"
"testing"
)
func attempt(model, testID string, score float64) AttemptResult {
return AttemptResult{Model: model, TestID: testID, Category: "c", Subcategory: "s", Score: score}
}
func TestCompareArtifacts_ClassifiesRegressionImprovementStable(t *testing.T) {
baseline := Artifact{Results: []AttemptResult{
attempt("m1", "reg", 1.0),
attempt("m1", "imp", 0.2),
attempt("m1", "stable", 1.0),
}}
current := Artifact{Results: []AttemptResult{
attempt("m1", "reg", 0.4),
attempt("m1", "imp", 1.0),
attempt("m1", "stable", 0.995),
}}
got := CompareArtifacts(baseline, current)
byID := map[string]Comparison{}
for _, c := range got {
byID[c.TestID] = c
}
if byID["reg"].Status != "regression" || byID["reg"].Delta > -0.5 {
t.Errorf("reg = %+v, want regression delta ~ -0.6", byID["reg"])
}
if byID["imp"].Status != "improvement" {
t.Errorf("imp = %+v, want improvement", byID["imp"])
}
if byID["stable"].Status != "stable" {
t.Errorf("stable = %+v, want stable", byID["stable"])
}
// Worst delta first.
if got[0].TestID != "reg" {
t.Errorf("first row = %q, want worst regression %q", got[0].TestID, "reg")
}
}
func TestCompareArtifacts_AveragesAttemptsAndFlagsMembership(t *testing.T) {
baseline := Artifact{Results: []AttemptResult{
attempt("m1", "flaky", 1.0), attempt("m1", "flaky", 0.0), // mean 0.5
attempt("m1", "gone", 1.0),
}}
current := Artifact{Results: []AttemptResult{
attempt("m1", "flaky", 1.0), attempt("m1", "flaky", 1.0), // mean 1.0 => improvement
attempt("m1", "new", 0.5),
}}
got := CompareArtifacts(baseline, current)
byID := map[string]Comparison{}
for _, c := range got {
byID[c.TestID] = c
}
if byID["flaky"].Status != "improvement" || math.Abs(byID["flaky"].BaselineMean-0.5) > 1e-9 {
t.Errorf("flaky = %+v, want improvement from mean 0.5", byID["flaky"])
}
if byID["gone"].Status != "baseline-only" {
t.Errorf("gone = %+v, want baseline-only", byID["gone"])
}
if byID["new"].Status != "current-only" {
t.Errorf("new = %+v, want current-only", byID["new"])
}
}
func TestCompareArtifacts_TruncatedAttemptExcludedFromMean(t *testing.T) {
clean := func(score float64) AttemptResult {
return AttemptResult{Model: "m1", TestID: "t", Category: "c", Subcategory: "s", Score: score}
}
trunc := func(score float64) AttemptResult {
return AttemptResult{Model: "m1", TestID: "t", Category: "c", Subcategory: "s", Score: score, Truncated: true}
}
baseline := Artifact{Results: []AttemptResult{clean(1.0), trunc(0.0)}} // clean mean 1.0
current := Artifact{Results: []AttemptResult{trunc(0.4), trunc(0.6)}} // all truncated => fallback 0.5
got := CompareArtifacts(baseline, current)
if len(got) != 1 {
t.Fatalf("comparisons = %d, want 1", len(got))
}
if math.Abs(got[0].BaselineMean-1.0) > 1e-9 {
t.Errorf("baseline mean = %v, want 1.0 (truncated attempt excluded)", got[0].BaselineMean)
}
if math.Abs(got[0].CurrentMean-0.5) > 1e-9 {
t.Errorf("current mean = %v, want 0.5 (all-truncated fallback)", got[0].CurrentMean)
}
if got[0].Status != "regression" {
t.Errorf("status = %q, want regression", got[0].Status)
}
}
func TestRenderCompare_HeadlineAndRows(t *testing.T) {
baseline := Artifact{Results: []AttemptResult{attempt("m1", "a", 1.0), attempt("m1", "b", 1.0)}}
current := Artifact{Results: []AttemptResult{attempt("m1", "a", 0.0), attempt("m1", "b", 1.0)}}
var buf bytes.Buffer
if err := RenderCompare(&buf, CompareArtifacts(baseline, current)); err != nil {
t.Fatalf("RenderCompare() error = %v", err)
}
out := buf.String()
for _, want := range []string{"1 regressions", "0 improvements", "1 stable", "a", "-1.00"} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "\nb\t") {
t.Errorf("stable row b should be summarized, not listed:\n%s", out)
}
}
+114
View File
@@ -0,0 +1,114 @@
package report
import (
"math"
"sort"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
// DiscriminationSpread is the cross-model score gap at which a test starts
// to separate models. The last full run showed a median spread of 0.0
// across 250 tests: nearly every test scored >=0.99 for every model, so
// the suite measured nothing about relative capability. This number makes
// that visible per test instead of averaged away.
const DiscriminationSpread = 0.05
// testStat is the per-test measurement rollup: cross-model discrimination
// (spread, only meaningful with 2+ models), attempt stability (did
// --repeat samples disagree), and aggregate score. Computed over the
// attempt-aggregated cells so a flaky test shows as unstable rather than
// as a random single score.
type testStat struct {
testID string
category string
subcategory string
mean float64 // mean over model cell means (scored cells only)
spread float64 // max-min over model cell means; NaN with <2 scored models
unstable bool // some model's attempts disagreed beyond epsilon
truncated bool // some cell was token-budget truncated
scored bool // at least one scored cell
}
// unstableEpsilon absorbs float noise in averaged scores; a real
// disagreement between attempts is orders of magnitude larger.
const unstableEpsilon = 1e-9
// testStats computes the per-test rollup for every test in tests, ordered
// by category, subcategory, ID (same order as the score table).
func testStats(tests []testkit.Test, models []string, idx cellIndex) []testStat {
out := make([]testStat, 0, len(tests))
for _, t := range sortedTests(tests) {
st := testStat{testID: t.ID, category: t.Category, subcategory: t.Subcategory, spread: math.NaN()}
var sum float64
var n int
var lo, hi float64
for _, model := range models {
c, ok := idx[t.ID][model]
if !ok || c.errorAll || c.skippedAll || c.scored == 0 {
continue
}
v := c.result.Score.Value
if n == 0 {
lo, hi = v, v
} else {
lo = math.Min(lo, v)
hi = math.Max(hi, v)
}
sum += v
n++
st.scored = true
if c.maxScore-c.minScore > unstableEpsilon {
st.unstable = true
}
st.truncated = st.truncated || c.result.Truncated()
}
if n > 1 {
st.spread = hi - lo
st.mean = sum / float64(n)
} else if n == 1 {
st.mean = sum
}
out = append(out, st)
}
return out
}
// interestingStats narrows testStats to rows worth reading: tests that
// discriminate between models, tests whose repeated attempts disagreed,
// and (when no cross-model data exists) tests that any model failed to
// pass outright. Sorted by spread descending, unstable first within equal
// spread.
func interestingStats(stats []testStat, multiModel bool) []testStat {
var out []testStat
for _, st := range stats {
if !st.scored {
continue
}
switch {
case st.unstable:
case multiModel && !math.IsNaN(st.spread) && st.spread >= DiscriminationSpread:
case !multiModel && st.mean < passThreshold:
default:
continue
}
out = append(out, st)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].unstable != out[j].unstable {
return out[i].unstable
}
si, sj := out[i].spread, out[j].spread
if math.IsNaN(si) {
si = -1
}
if math.IsNaN(sj) {
sj = -1
}
if si != sj {
return si > sj
}
return out[i].testID < out[j].testID
})
return out
}
+86
View File
@@ -0,0 +1,86 @@
package report
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/lukaszraczylo/llm-testbench/internal/eval"
"github.com/lukaszraczylo/llm-testbench/internal/runner"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
func TestDiscrimination_TableShowsSpreadAndInstability(t *testing.T) {
tests := []testkit.Test{
{ID: "sep", Category: "c", Subcategory: "s"},
{ID: "same", Category: "c", Subcategory: "s"},
}
results := []runner.Result{
{Model: "m1", TestID: "sep", Score: eval.Score{Value: 1}},
{Model: "m2", TestID: "sep", Score: eval.Score{Value: 0.2}},
{Model: "m1", TestID: "same", Score: eval.Score{Value: 1}},
{Model: "m2", TestID: "same", Score: eval.Score{Value: 1}},
// Flaky pair for m1/sep: a second attempt disagrees with the first.
{Model: "m1", TestID: "sep", Attempt: 1, Score: eval.Score{Value: 0.4}},
}
var buf bytes.Buffer
if err := Render(&buf, FormatTable, tests, []string{"m1", "m2"}, results); err != nil {
t.Fatalf("Render() error = %v", err)
}
out := buf.String()
for _, want := range []string{
"== Discrimination: 1/2 scored tests separate models by >= 0.05; 1 unstable under repeat ==",
"sep",
"[0.40-1.00]", // unstable range in the score table cell
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "\nsame\t") {
// "same" appears in the per-test table; ensure it is NOT in the
// discrimination rows by checking the section body only.
section := out[strings.Index(out, "== Discrimination"):]
if strings.Contains(section, "same") {
t.Errorf("non-discriminating test listed in discrimination section:\n%s", section)
}
}
}
func TestDiscrimination_JSONStatsAndAttemptRoundTrip(t *testing.T) {
tests := []testkit.Test{{ID: "t1", Category: "c", Subcategory: "s"}}
results := []runner.Result{
{Model: "m1", TestID: "t1", Score: eval.Score{Value: 1}},
{Model: "m2", TestID: "t1", Score: eval.Score{Value: 0.5}},
{Model: "m1", TestID: "t1", Attempt: 1, Score: eval.Score{Value: 0.5}},
}
var buf bytes.Buffer
if err := Render(&buf, FormatJSON, tests, []string{"m1", "m2"}, results); err != nil {
t.Fatalf("Render() error = %v", err)
}
var doc jsonArtifact
if err := json.Unmarshal(buf.Bytes(), &doc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(doc.Stats) != 1 {
t.Fatalf("len(stats) = %d, want 1", len(doc.Stats))
}
st := doc.Stats[0]
if !st.Unstable {
t.Error("Unstable = false, want true (attempts 1.0 vs 0.5)")
}
if st.Spread < 0.24 || st.Spread > 0.26 {
t.Errorf("Spread = %v, want ~0.25 (m1 mean 0.75 vs m2 0.5)", st.Spread)
}
// Attempt indices survive the round trip for compare.
var attempts []AttemptResult
attempts = append(attempts, doc.Results...)
if attempts[2].Attempt != 1 {
t.Errorf("attempt = %d, want 1", attempts[2].Attempt)
}
}
+222
View File
@@ -0,0 +1,222 @@
package report
import (
"fmt"
"io"
"sort"
)
// PerfectScore is the score at which a (model, test) cell counts as a
// perfect pass. Partial-credit evaluators score between 0 and 1; anything
// below this is not a perfect pass.
const PerfectScore = 0.999
// SaturationRate is the share of a test's scored cells that must be
// perfect for the test to count as saturated - passed by every model it
// was run against, every attempt. Saturated tests carry no signal about
// relative capability; they only detect outright regressions.
const SaturationRate = 0.9
// SubHealth is the suite-health rollup for one category/subcategory pair,
// pooled across one or more artifacts (so single-model probe runs can be
// combined with a full multi-model run). Cells are attempt-aggregated
// (model, test) means: a pair counts once, taking its WORST artifact -
// health asks "can any observed run fail this test?", so pooling over
// re-runs must not let a lucky second attempt hide a real failure.
type SubHealth struct {
Category string
Subcategory string
WeakTests []string // non-saturated test IDs, sorted - the signal-carriers
Tests int // distinct tests with at least one scored cell
Cells int // scored (model, test) cells
Perfect int // cells at PerfectScore or above
Saturated int // tests perfect in >= SaturationRate of their cells
Unstable int // tests whose repeated attempts disagreed
Truncated int // tests with a truncated cell
}
// PerfectRate is the share of scored cells that are perfect.
func (h SubHealth) PerfectRate() float64 {
if h.Cells == 0 {
return 0
}
return float64(h.Perfect) / float64(h.Cells)
}
// HealthReport pools every subcategory present in the artifacts.
type HealthReport struct {
Subcategories []SubHealth
Models []string
Tests int // distinct scored tests across all subcategories
Cells int
Perfect int
Saturated int
Artifacts int
}
// healthTestID scopes a test ID by model for cell pooling.
type healthCellID struct{ model, testID string }
// healthTest accumulates one test's cells across artifacts.
type healthTest struct {
cells map[healthCellID]float64
category, subcategory string
unstable, truncated bool
}
// AuditHealth pools artifacts into a per-subcategory health view.
// Errored and skipped attempts never form cells (meanAttempts already
// separates them by Status); truncated attempts follow the same
// exclusion policy as compare. Per-test unstable/truncated flags are
// OR-ed across artifacts: once flaky, always flagged.
func AuditHealth(artifacts []Artifact) HealthReport {
agg := map[string]*healthTest{} // key: testID
modelSet := map[string]bool{}
for _, a := range artifacts {
for _, c := range meanAttempts(a.Results) {
if c.Status != "" && c.Status != "scored" {
continue
}
modelSet[c.Model] = true
ht := agg[c.TestID]
if ht == nil {
ht = &healthTest{category: c.Category, subcategory: c.Subcategory, cells: map[healthCellID]float64{}}
if ht.category == "" {
ht.category = "unknown"
}
if ht.subcategory == "" {
ht.subcategory = "unknown"
}
agg[c.TestID] = ht
}
id := healthCellID{c.Model, c.TestID}
if prev, ok := ht.cells[id]; !ok || c.BaselineMean < prev {
// Pool the worst observed mean for this cell. On the
// first artifact BaselineMean is the current run's mean.
ht.cells[id] = c.BaselineMean
}
}
for _, s := range a.Stats {
if ht, ok := agg[s.TestID]; ok {
ht.unstable = ht.unstable || s.Unstable
ht.truncated = ht.truncated || s.Truncated
}
}
}
models := make([]string, 0, len(modelSet))
for m := range modelSet {
models = append(models, m)
}
sort.Strings(models)
bySub := map[string]*SubHealth{}
var tot, totCells, totPerfect, totSat int
for testID, ht := range agg {
sk := ht.category + "/" + ht.subcategory
sh := bySub[sk]
if sh == nil {
sh = &SubHealth{Category: ht.category, Subcategory: ht.subcategory}
bySub[sk] = sh
}
sh.Tests++
tot++
var perfect int
for _, s := range ht.cells {
sh.Cells++
totCells++
if s >= PerfectScore {
perfect++
sh.Perfect++
totPerfect++
}
}
if sh.Cells > 0 && ht.unstable {
sh.Unstable++
}
if ht.truncated {
sh.Truncated++
}
if float64(perfect)/float64(len(ht.cells)) >= SaturationRate {
sh.Saturated++
totSat++
} else {
sh.WeakTests = append(sh.WeakTests, testID)
}
}
report := HealthReport{Artifacts: len(artifacts), Models: models, Tests: tot, Cells: totCells, Perfect: totPerfect, Saturated: totSat}
for _, sh := range bySub {
sort.Strings(sh.WeakTests)
report.Subcategories = append(report.Subcategories, *sh)
}
sort.Slice(report.Subcategories, func(i, j int) bool {
a, b := report.Subcategories[i], report.Subcategories[j]
if a.PerfectRate() != b.PerfectRate() {
return a.PerfectRate() > b.PerfectRate()
}
if a.Category != b.Category {
return a.Category < b.Category
}
return a.Subcategory < b.Subcategory
})
return report
}
// weakCap bounds listed weak tests per subcategory in the text report;
// the counts stay accurate regardless.
const weakCap = 8
// RenderHealth writes the audit: headline numbers, then one line per
// subcategory sorted by perfect-cell rate descending (most saturated -
// least informative - first), listing its non-saturated tests.
func RenderHealth(w io.Writer, r HealthReport) error {
ew := &errWriter{w: w}
perfectRate := 0.0
if r.Cells > 0 {
perfectRate = float64(r.Perfect) / float64(r.Cells)
}
ew.println(fmt.Sprintf("== Suite health: %d tests, %d scored (model,test) cells over %d model(s), %d artifact(s) ==",
r.Tests, r.Cells, len(r.Models), r.Artifacts))
ew.println(fmt.Sprintf("perfect cells: %d/%d (%.1f%%) | saturated tests (no signal): %d/%d\n",
r.Perfect, r.Cells, perfectRate*100, r.Saturated, r.Tests))
if r.Saturated == r.Tests && r.Tests > 0 {
ew.println("every scored test is saturated: the suite cannot rank these models at all.")
}
ew.println(fmt.Sprintf("%-30s %5s %6s %8s %9s %8s %8s WEAK TESTS",
"SUBCATEGORY", "TESTS", "CELLS", "PERFECT%", "SATURATED", "UNSTBL", "TRUNC"))
for _, sh := range r.Subcategories {
name := sh.Category + "/" + sh.Subcategory
weak := ""
if len(sh.WeakTests) > 0 {
weak = fmt.Sprintf("%d", len(sh.WeakTests))
if len(sh.WeakTests) > weakCap {
weak += ": " + joinN(sh.WeakTests, weakCap) + ", ..."
} else {
weak += ": " + joinAll(sh.WeakTests)
}
}
ew.println(fmt.Sprintf("%-30s %5d %6d %8.1f %9d %8d %8d %s",
name, sh.Tests, sh.Cells, sh.PerfectRate()*100, sh.Saturated, sh.Unstable, sh.Truncated, weak))
}
return ew.err
}
func joinN(xs []string, n int) string {
if len(xs) <= n {
return joinAll(xs)
}
return joinAll(xs[:n])
}
func joinAll(xs []string) string {
out := ""
for i, x := range xs {
if i > 0 {
out += ", "
}
out += x
}
return out
}
+200
View File
@@ -0,0 +1,200 @@
package report
import (
"bytes"
"strings"
"testing"
)
// scoredAttempt builds a scored attempt in subcategory (cat, sub).
func scoredAttempt(model, testID, cat, sub string, score float64) AttemptResult {
return AttemptResult{Model: model, TestID: testID, Category: cat, Subcategory: sub, Score: score}
}
func subHealth(t *testing.T, r HealthReport, cat, sub string) SubHealth {
t.Helper()
for _, sh := range r.Subcategories {
if sh.Category == cat && sh.Subcategory == sub {
return sh
}
}
t.Fatalf("subcategory %s/%s missing from report", cat, sub)
return SubHealth{}
}
func TestAuditHealth_SaturatedVersusWeakTests(t *testing.T) {
// a: perfect for both models => saturated, no signal.
// b: model m2 fails => weak, carries signal.
art := Artifact{Results: []AttemptResult{
scoredAttempt("m1", "a", "c", "s", 1.0),
scoredAttempt("m2", "a", "c", "s", 1.0),
scoredAttempt("m1", "b", "c", "s", 1.0),
scoredAttempt("m2", "b", "c", "s", 0.0),
}}
r := AuditHealth([]Artifact{art})
sh := subHealth(t, r, "c", "s")
if sh.Tests != 2 || sh.Cells != 4 {
t.Fatalf("tests/cells = %d/%d, want 2/4", sh.Tests, sh.Cells)
}
if sh.Perfect != 3 {
t.Errorf("perfect = %d, want 3", sh.Perfect)
}
if sh.Saturated != 1 {
t.Errorf("saturated = %d, want 1 (only test a)", sh.Saturated)
}
if len(sh.WeakTests) != 1 || sh.WeakTests[0] != "b" {
t.Errorf("weak = %v, want [b]", sh.WeakTests)
}
if r.Saturated != 1 || r.Tests != 2 {
t.Errorf("report totals = %d/%d, want 1/2", r.Saturated, r.Tests)
}
if len(r.Models) != 2 || r.Models[0] != "m1" {
t.Errorf("models = %v, want [m1 m2]", r.Models)
}
}
func TestAuditHealth_PoolsWorstCellAcrossArtifacts(t *testing.T) {
// A flaky test that passes on a second artifact must still count as
// weak: health asks whether any observed run failed it.
a1 := Artifact{Results: []AttemptResult{
scoredAttempt("m1", "flaky", "c", "s", 0.0),
}}
a2 := Artifact{Results: []AttemptResult{
scoredAttempt("m1", "flaky", "c", "s", 1.0),
}}
r := AuditHealth([]Artifact{a1, a2})
sh := subHealth(t, r, "c", "s")
if sh.Saturated != 0 || len(sh.WeakTests) != 1 {
t.Errorf("saturated=%d weak=%v, want 0/[flaky] (worst cell pooled)", sh.Saturated, sh.WeakTests)
}
if sh.Cells != 1 {
t.Errorf("cells = %d, want 1 (same model,test pooled)", sh.Cells)
}
}
func TestAuditHealth_AveragesRepeatedAttemptsPerCell(t *testing.T) {
// Two attempts of one cell: mean 0.5 < PerfectScore => not perfect.
art := Artifact{Results: []AttemptResult{
scoredAttempt("m1", "t", "c", "s", 1.0),
scoredAttempt("m1", "t", "c", "s", 0.0),
}}
r := AuditHealth([]Artifact{art})
sh := subHealth(t, r, "c", "s")
if sh.Cells != 1 || sh.Perfect != 0 {
t.Errorf("cells/perfect = %d/%d, want 1/0 (attempt mean 0.5)", sh.Cells, sh.Perfect)
}
}
func TestAuditHealth_ErrorsAndSkipsExcluded(t *testing.T) {
art := Artifact{Results: []AttemptResult{
{Model: "m1", TestID: "boom", Category: "c", Subcategory: "s", Error: "404"},
{Model: "m1", TestID: "skip", Category: "c", Subcategory: "s", Skipped: true},
scoredAttempt("m1", "ok", "c", "s", 1.0),
}}
r := AuditHealth([]Artifact{art})
sh := subHealth(t, r, "c", "s")
if sh.Tests != 1 || sh.Cells != 1 || sh.Saturated != 1 {
t.Errorf("tests/cells/saturated = %d/%d/%d, want 1/1/1 (error+skip excluded)",
sh.Tests, sh.Cells, sh.Saturated)
}
}
func TestAuditHealth_FlagsUnstableAndTruncated(t *testing.T) {
art := Artifact{
Results: []AttemptResult{
scoredAttempt("m1", "u", "c", "s", 1.0),
scoredAttempt("m1", "t", "c", "s", 0.5),
},
Stats: []jsonStatRow{
{TestID: "u", Unstable: true},
{TestID: "t", Truncated: true},
},
}
r := AuditHealth([]Artifact{art})
sh := subHealth(t, r, "c", "s")
if sh.Unstable != 1 || sh.Truncated != 1 {
t.Errorf("unstable/truncated = %d/%d, want 1/1", sh.Unstable, sh.Truncated)
}
}
func TestAuditHealth_SaturationBoundary(t *testing.T) {
// 9 perfect cells + 1 failed cell = 0.9 rate = at SaturationRate =>
// saturated. 8/9 = 0.888 < rate => weak.
perfect9 := Artifact{}
for i := range 9 {
perfect9.Results = append(perfect9.Results, scoredAttempt("m"+string(rune('a'+i)), "t1", "c", "s", 1.0))
}
perfect9.Results = append(perfect9.Results, scoredAttempt("mj", "t1", "c", "s", 0.0))
if r := AuditHealth([]Artifact{perfect9}); r.Saturated != 1 {
t.Errorf("9/10 perfect: saturated = %d, want 1 (rate == SaturationRate)", r.Saturated)
}
eight := Artifact{}
for i := range 8 {
eight.Results = append(eight.Results, scoredAttempt("m"+string(rune('a'+i)), "t1", "c", "s", 1.0))
}
eight.Results = append(eight.Results,
scoredAttempt("mi", "t1", "c", "s", 0.0),
scoredAttempt("mj", "t1", "c", "s", 0.0))
if r := AuditHealth([]Artifact{eight}); r.Saturated != 0 {
t.Errorf("8/10 perfect: saturated = %d, want 0", r.Saturated)
}
}
func TestAuditHealth_SortsMostSaturatedFirst(t *testing.T) {
art := Artifact{Results: []AttemptResult{
scoredAttempt("m1", "hard", "c", "low", 0.0),
scoredAttempt("m1", "easy1", "c", "high", 1.0),
scoredAttempt("m1", "easy2", "c", "high", 1.0),
}}
r := AuditHealth([]Artifact{art})
if len(r.Subcategories) != 2 {
t.Fatalf("subcategories = %d, want 2", len(r.Subcategories))
}
if got := r.Subcategories[0].Subcategory; got != "high" {
t.Errorf("first row = %q, want most saturated %q", got, "high")
}
}
func TestRenderHealth_HeadlineAndWeakList(t *testing.T) {
art := Artifact{Results: []AttemptResult{
scoredAttempt("m1", "a", "c", "s", 1.0),
scoredAttempt("m1", "b", "c", "s", 0.0),
}}
var buf bytes.Buffer
if err := RenderHealth(&buf, AuditHealth([]Artifact{art})); err != nil {
t.Fatalf("RenderHealth() error = %v", err)
}
out := buf.String()
for _, want := range []string{"2 tests", "2 scored", "c/s", "50.0", "1: b"} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q:\n%s", want, out)
}
}
// "a" is saturated: it must not appear anywhere in the weak list.
if strings.Contains(out, ": a") || strings.Contains(out, ", a,") || strings.HasSuffix(strings.TrimSpace(out), " a") {
t.Errorf("saturated test a should not be listed weak:\n%s", out)
}
}
func TestRenderHealth_AllSaturatedVerdict(t *testing.T) {
art := Artifact{Results: []AttemptResult{
scoredAttempt("m1", "a", "c", "s", 1.0),
scoredAttempt("m2", "a", "c", "s", 1.0),
}}
var buf bytes.Buffer
if err := RenderHealth(&buf, AuditHealth([]Artifact{art})); err != nil {
t.Fatalf("RenderHealth() error = %v", err)
}
if !strings.Contains(buf.String(), "cannot rank these models") {
t.Errorf("missing all-saturated verdict:\n%s", buf.String())
}
}
+104 -9
View File
@@ -2,14 +2,17 @@ package report
import (
"encoding/json"
"fmt"
"io"
"math"
"os"
"github.com/lukaszraczylo/llm-testbench/internal/runner"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
// jsonResult is the JSON-serializable projection of a runner.Result, joined
// with its test's category/subcategory for self-contained output.
// jsonResult is the JSON-serializable projection of one runner.Result
// (one attempt), joined with its test's category/subcategory.
type jsonResult struct {
Model string `json:"model"`
TestID string `json:"test_id"`
@@ -26,16 +29,43 @@ type jsonResult struct {
Tokens int `json:"tokens"`
Skipped bool `json:"skipped"`
Truncated bool `json:"truncated"`
Attempt int `json:"attempt,omitempty"`
}
// renderJSON dumps raw results as a JSON array, joined with test metadata.
func renderJSON(w io.Writer, tests []testkit.Test, results []runner.Result) error {
// jsonArtifact is the --out / --format json document: every raw attempt
// plus the per-test discrimination rollup, so downstream tooling
// (compare) can re-derive stability without re-running the suite.
type jsonArtifact struct {
Results []jsonResult `json:"results"`
Stats []jsonStatRow `json:"stats,omitempty"`
}
// jsonStatRow is the JSON projection of testStat.
type jsonStatRow struct {
TestID string `json:"test_id"`
Category string `json:"category"`
Subcategory string `json:"subcategory"`
Mean float64 `json:"mean"`
Spread float64 `json:"spread,omitempty"`
Unstable bool `json:"unstable,omitempty"`
Truncated bool `json:"truncated,omitempty"`
}
// renderJSON dumps the run as a JSON artifact document.
func renderJSON(w io.Writer, tests []testkit.Test, models []string, results []runner.Result) error {
artifact := buildArtifact(tests, models, results)
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(artifact)
}
func buildArtifact(tests []testkit.Test, models []string, results []runner.Result) jsonArtifact {
testByID := make(map[string]testkit.Test, len(tests))
for _, t := range tests {
testByID[t.ID] = t
}
out := make([]jsonResult, 0, len(results))
out := jsonArtifact{Results: make([]jsonResult, 0, len(results))}
for _, r := range results {
t := testByID[r.TestID]
jr := jsonResult{
@@ -53,14 +83,79 @@ func renderJSON(w io.Writer, tests []testkit.Test, results []runner.Result) erro
PromptTokens: r.PromptTokens,
CompletionTokens: r.CompletionTokens,
Tokens: r.TotalTokens(),
Attempt: r.Attempt,
}
if r.Err != nil {
jr.Error = r.Err.Error()
}
out = append(out, jr)
out.Results = append(out.Results, jr)
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(out)
idx := indexResults(results)
for _, st := range testStats(tests, models, idx) {
if !st.scored {
continue
}
row := jsonStatRow{
TestID: st.testID,
Category: st.category,
Subcategory: st.subcategory,
Mean: round6(st.mean),
Unstable: st.unstable,
Truncated: st.truncated,
}
if !math.IsNaN(st.spread) {
row.Spread = round6(st.spread)
}
out.Stats = append(out.Stats, row)
}
return out
}
func round6(v float64) float64 {
return math.Round(v*1e6) / 1e6
}
// Artifact is a saved run document: every raw attempt plus the per-test
// discrimination rollup. Exported so the compare command can consume it.
type Artifact = jsonArtifact
// AttemptResult is one saved attempt. Exported alias of jsonResult.
type AttemptResult = jsonResult
// LoadArtifact reads a --out JSON artifact written by a previous run.
// Accepts both the current document shape and the legacy bare array of
// results, so artifacts from earlier releases remain comparable.
func LoadArtifact(path string) (jsonArtifact, error) {
b, err := os.ReadFile(path) //nolint:gosec // path is the operator's own `llmtest compare` argument, not external input
if err != nil {
return jsonArtifact{}, err
}
var probe struct {
Results []jsonResult `json:"results"`
}
if err := json.Unmarshal(b, &probe); err == nil && probe.Results != nil {
var a jsonArtifact
if err := json.Unmarshal(b, &a); err != nil {
return jsonArtifact{}, fmt.Errorf("%s: %w", path, err)
}
return a, nil
}
var legacy []jsonResult
if err := json.Unmarshal(b, &legacy); err != nil {
return jsonArtifact{}, fmt.Errorf("%s: not a llmtest json artifact: %w", path, err)
}
return jsonArtifact{Results: legacy}, nil
}
// WriteArtifact saves the run as a JSON artifact at path, for later
// `llmtest compare` runs, regardless of the stdout report format.
func WriteArtifact(path string, tests []testkit.Test, models []string, results []runner.Result) error {
b, err := json.MarshalIndent(buildArtifact(tests, models, results), "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(b, '\n'), 0o644) //nolint:gosec // 0o644: the artifact is a report meant to be shared/committed, not a secret file
}
+23 -6
View File
@@ -10,8 +10,8 @@ import (
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
// renderMarkdown writes the same three tables as renderTable, formatted as
// GitHub-flavored markdown tables.
// renderMarkdown writes the same four sections as renderTable, formatted
// as GitHub-flavored markdown tables.
func renderMarkdown(w io.Writer, tests []testkit.Test, models []string, results []runner.Result) error {
idx := indexResults(results)
ew := &errWriter{w: w}
@@ -24,9 +24,9 @@ func renderMarkdown(w io.Writer, tests []testkit.Test, models []string, results
for _, t := range sortedTests(tests) {
row := []string{t.ID, t.Category, t.Subcategory}
for _, model := range models {
r, ok := idx[t.ID][model]
row = append(row, cellText(r, ok))
anyTruncated = anyTruncated || r.Truncated()
c := idx[t.ID][model]
row = append(row, cellText(c))
anyTruncated = anyTruncated || c.result.Truncated()
}
writeMarkdownRow(ew, row)
}
@@ -48,11 +48,16 @@ func renderMarkdown(w io.Writer, tests []testkit.Test, models []string, results
writeMarkdownRow(ew, row)
}
ew.println()
ew.println("## Discrimination")
ew.println()
writeDiscriminationRows(markdownPrinter{ew: ew}, testStats(tests, models, idx), len(models) > 1)
ew.println()
ew.println("## Model summary")
ew.println()
writeMarkdownHeader(ew, []string{"Model", "Mean", "Passed", "Partial", "Failed", "Errors", "Mean latency", "Total tokens", "Tok/s"})
for _, ms := range summarize(models, results) {
for _, ms := range summarize(models, idx) {
writeMarkdownRow(ew, []string{
ms.model,
meanCellText(ms.overallMean),
@@ -69,6 +74,18 @@ func renderMarkdown(w io.Writer, tests []testkit.Test, models []string, results
return ew.err
}
// markdownPrinter adapts writeDiscriminationRows to pipe-delimited rows.
// GFM needs a blank line between the note paragraph and the table, so
// header() emits one first.
type markdownPrinter struct{ ew *errWriter }
func (mp markdownPrinter) header(cells ...string) {
mp.ew.println()
writeMarkdownHeader(mp.ew, cells)
}
func (mp markdownPrinter) row(cells ...string) { writeMarkdownRow(mp.ew, cells) }
func (mp markdownPrinter) note(text string) { mp.ew.println(text) }
func writeMarkdownHeader(ew *errWriter, cols []string) {
ew.println("| " + strings.Join(cols, " | ") + " |")
sep := make([]string, len(cols))
+169 -57
View File
@@ -9,6 +9,8 @@ import (
"sort"
"time"
"github.com/lukaszraczylo/llm-testbench/internal/eval"
"github.com/lukaszraczylo/llm-testbench/internal/llm"
"github.com/lukaszraczylo/llm-testbench/internal/runner"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
@@ -35,22 +37,125 @@ func Render(w io.Writer, format Format, tests []testkit.Test, models []string, r
case FormatMarkdown:
return renderMarkdown(w, tests, models, results)
case FormatJSON:
return renderJSON(w, tests, results)
return renderJSON(w, tests, models, results)
default:
return fmt.Errorf("report: unknown format %q", format)
}
}
// resultIndex looks up a Result by testID then model.
type resultIndex map[string]map[string]runner.Result
// modelCell is the aggregate view of every attempt recorded for one
// (testID, model) pair: with Config.Repeat = 1 this is the single result;
// with repeats the Score is the mean over scored attempts, and minScore/
// maxScore expose the within-model spread (instability) that repeats are
// measured to find.
type modelCell struct {
result runner.Result // representative: last scored attempt, mean score patched in
minScore float64
maxScore float64
meanTokens float64
attempts int
scored int
skippedAll bool
errorAll bool
ok bool
}
func indexResults(results []runner.Result) resultIndex {
idx := make(resultIndex)
// cellIndex looks up the aggregate cell for a testID then model.
type cellIndex map[string]map[string]modelCell
func indexResults(results []runner.Result) cellIndex {
type key struct{ testID, model string }
grouped := make(map[key][]runner.Result)
order := make([]key, 0, len(results))
for _, r := range results {
if idx[r.TestID] == nil {
idx[r.TestID] = make(map[string]runner.Result)
k := key{r.TestID, r.Model}
if _, seen := grouped[k]; !seen {
order = append(order, k)
}
idx[r.TestID][r.Model] = r
grouped[k] = append(grouped[k], r)
}
idx := make(cellIndex)
for _, k := range order {
attempts := grouped[k]
sort.Slice(attempts, func(i, j int) bool { return attempts[i].Attempt < attempts[j].Attempt })
c := modelCell{ok: true, attempts: len(attempts)}
// Truncation policy: a truncated attempt was scored on partial
// text, so its score measures the token budget as much as the
// model. Attempts that finished normally are therefore preferred
// for the cell mean (and for the min-max instability range); only
// when EVERY scored attempt was cut off does the mean fall back to
// the truncated scores. A cell is flagged "!" whenever any attempt
// was truncated, clean mean or not.
var sum, toks, cleanSum, cleanToks float64
var cleanN, scoredN int
var cleanMin, cleanMax float64
var anyTrunc bool
var last, lastClean runner.Result
for _, a := range attempts {
last = a
switch {
case a.Err != nil:
continue
case a.Score.Skipped:
continue
}
v := a.Score.Value
sum += v
toks += float64(a.TotalTokens())
scoredN++
if a.Truncated() {
anyTrunc = true
continue
}
if cleanN == 0 {
cleanMin, cleanMax = v, v
} else {
cleanMin = math.Min(cleanMin, v)
cleanMax = math.Max(cleanMax, v)
}
cleanSum += v
cleanToks += float64(a.TotalTokens())
cleanN++
lastClean = a
}
c.scored = scoredN
rep := last
if cleanN > 0 {
rep = lastClean
}
switch {
case scoredN > 0 && cleanN > 0:
rep.Score = eval.Score{
Value: cleanSum / float64(cleanN),
Detail: lastClean.Score.Detail,
}
c.minScore, c.maxScore = cleanMin, cleanMax
c.meanTokens = cleanToks / float64(cleanN)
if anyTrunc {
rep.FinishReason = llm.FinishReasonLength
}
case scoredN > 0:
rep.Score = eval.Score{
Value: sum / float64(scoredN),
Detail: last.Score.Detail,
}
c.minScore, c.maxScore = rep.Score.Value, rep.Score.Value
c.meanTokens = toks / float64(scoredN)
rep.FinishReason = llm.FinishReasonLength
case c.attempts > 0 && last.Score.Skipped:
c.skippedAll = true
case c.attempts > 0 && last.Err != nil:
c.errorAll = true
}
c.result = rep
if idx[k.testID] == nil {
idx[k.testID] = make(map[string]modelCell)
}
idx[k.testID][k.model] = c
}
return idx
}
@@ -72,34 +177,41 @@ func sortedTests(tests []testkit.Test) []testkit.Test {
return out
}
// truncatedSuffix marks a cell whose underlying response was cut off by
// the token budget (finish_reason=length): the score may not reflect what
// the model would have answered with a larger budget. truncatedLegend is
// the one-line explanation printed once per table/markdown report.
// truncatedSuffix marks a cell where any attempt was cut off by the token
// budget (finish_reason=length). Per the truncation policy in
// indexResults, the cell mean then comes from the attempts that finished
// normally; "!" warns that at least one sample was discarded (or, when no
// attempt finished, that the whole mean is partial-text based).
// truncatedLegend is the one-line explanation printed once per
// table/markdown report.
const (
truncatedSuffix = "!"
truncatedLegend = "! = response truncated by the token budget (finish_reason=length); increase max_tokens_default to avoid"
truncatedLegend = "! = some attempt was truncated by the token budget (finish_reason=length); the mean uses only attempts that finished, increase max_tokens_default if every attempt is flagged"
)
// cellText renders one (test, model) cell for the score table: "score
// (Ntok)" for a scored result, or ERR/skip/N/A otherwise, with a
// truncatedSuffix appended when the response was cut off by the token
// budget.
func cellText(r runner.Result, found bool) string {
if !found {
// truncatedSuffix appended when any attempt was cut off by the token
// budget. With repeat attempts, a "[min-max]" range is appended when the
// attempts disagreed, marking the cell unstable.
func cellText(c modelCell) string {
if !c.ok {
return "N/A"
}
switch {
case c.errorAll:
return "ERR"
case c.skippedAll:
return "skip"
case c.scored == 0:
return "N/A"
}
var text string
switch {
case r.Err != nil:
text = "ERR"
case r.Score.Skipped:
text = "skip"
default:
text = fmt.Sprintf("%.2f (%dtok)", r.Score.Value, r.TotalTokens())
text := fmt.Sprintf("%.2f (%dtok)", c.result.Score.Value, int(math.Round(c.meanTokens)))
if c.attempts > 1 && c.maxScore > c.minScore {
text += fmt.Sprintf(" [%0.2f-%0.2f]", c.minScore, c.maxScore)
}
if r.Truncated() {
if c.result.Truncated() {
text += truncatedSuffix
}
return text
@@ -112,7 +224,7 @@ type categoryMean struct {
category string
}
func categoryRollup(tests []testkit.Test, models []string, idx resultIndex) []categoryMean {
func categoryRollup(tests []testkit.Test, models []string, idx cellIndex) []categoryMean {
order := make([]string, 0)
byCategory := make(map[string][]testkit.Test)
for _, t := range sortedTests(tests) {
@@ -129,11 +241,11 @@ func categoryRollup(tests []testkit.Test, models []string, idx resultIndex) []ca
var sum float64
var n int
for _, t := range byCategory[cat] {
r, ok := idx[t.ID][model]
if !ok || r.Err != nil || r.Score.Skipped {
c, ok := idx[t.ID][model]
if !ok || c.errorAll || c.skippedAll || c.scored == 0 {
continue
}
sum += r.Score.Value
sum += c.result.Score.Value
n++
}
if n > 0 {
@@ -191,49 +303,49 @@ type modelSummary struct {
avgTokPerSec float64 // math.NaN() when no timed, non-skipped data
}
func summarize(models []string, results []runner.Result) []modelSummary {
byModel := make(map[string][]runner.Result, len(models))
for _, r := range results {
byModel[r.Model] = append(byModel[r.Model], r)
}
// summarize aggregates one model's results per test (attempts already
// merged by indexResults), so pass/partial/fail counts stay per-test even
// with Config.Repeat > 1.
func summarize(models []string, idx cellIndex) []modelSummary {
out := make([]modelSummary, 0, len(models))
for _, model := range models {
ms := modelSummary{model: model}
var scoreSum float64
var scoredCount int
var scoreSum, latencySecondsSum, meanTokenSum float64
var scoredCount, latencyCount int
var latencySum time.Duration
var latencyCount int
var completionTokenSum int
var latencySecondsSum float64
for _, r := range byModel[model] {
ms.totalTokens += r.TotalTokens()
switch {
case r.Err != nil:
ms.errors++
for _, cells := range idx {
c, ok := cells[model]
if !ok {
continue
case r.Score.Skipped:
}
meanTokenSum += c.meanTokens * float64(max(c.scored, 1))
if c.errorAll {
ms.errors += c.attempts
continue
}
if c.skippedAll || c.scored == 0 {
continue
}
scoreSum += r.Score.Value
v := c.result.Score.Value
scoreSum += v
scoredCount++
latencySum += r.Latency
latencySum += c.result.Latency
latencyCount++
completionTokenSum += r.CompletionTokens
latencySecondsSum += r.Latency.Seconds()
latencySecondsSum += c.result.Latency.Seconds()
switch {
case r.Score.Value >= passThreshold:
case v >= passThreshold:
ms.passed++
case r.Score.Value <= 0:
case v <= 0:
ms.failed++
default:
ms.partial++
}
}
ms.totalTokens = int(math.Round(meanTokenSum))
if scoredCount > 0 {
ms.overallMean = scoreSum / float64(scoredCount)
ms.meanLatency = latencySum / time.Duration(latencyCount)
@@ -241,11 +353,11 @@ func summarize(models []string, results []runner.Result) []modelSummary {
ms.overallMean = math.NaN()
}
// Aggregate ratio (sum of completion tokens over sum of elapsed
// seconds), not a mean of per-test ratios: robust to a handful of
// very short responses skewing a per-test-averaged rate.
// Aggregate ratio (completion tokens over elapsed seconds for the
// representative attempt), not a mean of per-test ratios: robust to
// a handful of very short responses skewing a per-test-averaged rate.
if latencySecondsSum > 0 {
ms.avgTokPerSec = float64(completionTokenSum) / latencySecondsSum
ms.avgTokPerSec = float64(ms.totalTokens) / latencySecondsSum
} else {
ms.avgTokPerSec = math.NaN()
}
+53 -10
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/lukaszraczylo/llm-testbench/internal/eval"
"github.com/lukaszraczylo/llm-testbench/internal/llm"
"github.com/lukaszraczylo/llm-testbench/internal/runner"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
@@ -33,6 +34,47 @@ func sampleResults() []runner.Result {
}
}
// Truncation policy: attempts cut off by the token budget are excluded
// from a cell's mean when any attempt finished normally; the cell is still
// flagged "!" so the discarded sample is visible. When every scored
// attempt was truncated, the mean falls back to the partial-text scores.
func TestIndexResults_TruncatedAttemptExcludedFromMean(t *testing.T) {
idx := indexResults([]runner.Result{
{Model: "m1", TestID: "t", Attempt: 0, Score: eval.Score{Value: 1}, PromptTokens: 4},
{Model: "m1", TestID: "t", Attempt: 1, Score: eval.Score{Value: 0}, FinishReason: llm.FinishReasonLength, PromptTokens: 100},
})
c := idx["t"]["m1"]
if got := c.result.Score.Value; got != 1 {
t.Errorf("mean = %v, want 1 (clean attempts only)", got)
}
if !c.result.Truncated() {
t.Error("cell should be flagged truncated")
}
if c.minScore != 1 || c.maxScore != 1 {
t.Errorf("min/max = %v/%v, want 1/1 (truncated attempt excluded from instability range)", c.minScore, c.maxScore)
}
if got := int(c.meanTokens); got != 4 {
t.Errorf("meanTokens = %d, want 4 (clean attempts only)", got)
}
}
func TestIndexResults_AllAttemptsTruncatedFallBackToPartialScores(t *testing.T) {
idx := indexResults([]runner.Result{
{Model: "m1", TestID: "t", Attempt: 0, Score: eval.Score{Value: 0.4}, FinishReason: llm.FinishReasonLength, PromptTokens: 10},
{Model: "m1", TestID: "t", Attempt: 1, Score: eval.Score{Value: 0.6}, FinishReason: llm.FinishReasonLength, PromptTokens: 20},
})
c := idx["t"]["m1"]
if got := c.result.Score.Value; math.Abs(got-0.5) > 1e-9 {
t.Errorf("mean = %v, want 0.5 (fallback over all-truncated attempts)", got)
}
if !c.result.Truncated() {
t.Error("cell should be flagged truncated")
}
if c.scored != 2 {
t.Errorf("scored = %d, want 2", c.scored)
}
}
func TestRenderTable_ContainsExpectedCells(t *testing.T) {
var buf bytes.Buffer
if err := Render(&buf, FormatTable, sampleTests(), []string{"m1", "m2"}, sampleResults()); err != nil {
@@ -85,10 +127,11 @@ func TestRenderJSON_DumpsRawResults(t *testing.T) {
t.Fatalf("Render() error = %v", err)
}
var out []jsonResult
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
var doc jsonArtifact
if err := json.Unmarshal(buf.Bytes(), &doc); err != nil {
t.Fatalf("json.Unmarshal() error = %v; output:\n%s", err, buf.String())
}
out := doc.Results
if len(out) != len(sampleResults()) {
t.Fatalf("len(out) = %d, want %d", len(out), len(sampleResults()))
}
@@ -173,14 +216,14 @@ func TestRenderJSON_IncludesResponseTextFinishReasonAndTruncated(t *testing.T) {
t.Fatalf("Render() error = %v", err)
}
var out []jsonResult
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
var doc jsonArtifact
if err := json.Unmarshal(buf.Bytes(), &doc); err != nil {
t.Fatalf("json.Unmarshal() error = %v; output:\n%s", err, buf.String())
}
if len(out) != 1 {
t.Fatalf("len(out) = %d, want 1", len(out))
if len(doc.Results) != 1 {
t.Fatalf("len(results) = %d, want 1", len(doc.Results))
}
got := out[0]
got := doc.Results[0]
if got.ResponseText != "partial answer" {
t.Errorf("ResponseText = %q, want %q", got.ResponseText, "partial answer")
}
@@ -211,7 +254,7 @@ func TestSummarize_Buckets(t *testing.T) {
{Model: "m1", TestID: "d", Err: errors.New("fail")},
{Model: "m1", TestID: "e", Score: eval.Score{Skipped: true}},
}
summaries := summarize([]string{"m1"}, results)
summaries := summarize([]string{"m1"}, indexResults(results))
if len(summaries) != 1 {
t.Fatalf("summarize() len = %d, want 1", len(summaries))
}
@@ -236,7 +279,7 @@ func TestSummarize_AvgTokPerSec(t *testing.T) {
{Model: "m1", TestID: "err", Err: errors.New("fail"), Latency: time.Second, CompletionTokens: 999},
{Model: "m1", TestID: "skipped", Score: eval.Score{Skipped: true}, Latency: time.Second, CompletionTokens: 999},
}
summaries := summarize([]string{"m1"}, results)
summaries := summarize([]string{"m1"}, indexResults(results))
ms := summaries[0]
want := 110.0 / 2.1
@@ -250,7 +293,7 @@ func TestSummarize_AvgTokPerSec_NoDataIsNaN(t *testing.T) {
{Model: "m1", TestID: "err", Err: errors.New("fail")},
{Model: "m1", TestID: "skipped", Score: eval.Score{Skipped: true}},
}
summaries := summarize([]string{"m1"}, results)
summaries := summarize([]string{"m1"}, indexResults(results))
if !math.IsNaN(summaries[0].avgTokPerSec) {
t.Errorf("avgTokPerSec = %v, want NaN when there is no timed data", summaries[0].avgTokPerSec)
}
+83 -10
View File
@@ -26,8 +26,8 @@ func (ew *errWriter) println(args ...any) {
_, ew.err = fmt.Fprintln(ew.w, args...)
}
// renderTable writes three plain-text tables: per-test scores, category
// rollup, and model summary.
// renderTable writes four plain-text tables: per-test scores, category
// rollup, discrimination/stability rollup, and model summary.
func renderTable(w io.Writer, tests []testkit.Test, models []string, results []runner.Result) error {
idx := indexResults(results)
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
@@ -37,7 +37,9 @@ func renderTable(w io.Writer, tests []testkit.Test, models []string, results []r
ew.println()
writeCategoryTable(ew, tests, models, idx)
ew.println()
writeSummaryTable(ew, models, results)
writeDiscriminationTable(ew, testStats(tests, models, idx), len(models) > 1)
ew.println()
writeSummaryTable(ew, models, idx)
if ew.err != nil {
return ew.err
}
@@ -45,7 +47,7 @@ func renderTable(w io.Writer, tests []testkit.Test, models []string, results []r
return tw.Flush()
}
func writeScoreTable(ew *errWriter, tests []testkit.Test, models []string, idx resultIndex) {
func writeScoreTable(ew *errWriter, tests []testkit.Test, models []string, idx cellIndex) {
ew.println("== Per-test scores ==")
header := []string{"TEST", "CATEGORY", "SUBCATEGORY"}
header = append(header, models...)
@@ -55,9 +57,9 @@ func writeScoreTable(ew *errWriter, tests []testkit.Test, models []string, idx r
for _, t := range sortedTests(tests) {
row := []string{t.ID, t.Category, t.Subcategory}
for _, model := range models {
r, ok := idx[t.ID][model]
row = append(row, cellText(r, ok))
anyTruncated = anyTruncated || r.Truncated()
c := idx[t.ID][model]
row = append(row, cellText(c))
anyTruncated = anyTruncated || c.result.Truncated()
}
ew.println(strings.Join(row, "\t"))
}
@@ -66,7 +68,7 @@ func writeScoreTable(ew *errWriter, tests []testkit.Test, models []string, idx r
}
}
func writeCategoryTable(ew *errWriter, tests []testkit.Test, models []string, idx resultIndex) {
func writeCategoryTable(ew *errWriter, tests []testkit.Test, models []string, idx cellIndex) {
ew.println("== Category rollup (mean score) ==")
header := append([]string{"CATEGORY"}, models...)
ew.println(strings.Join(header, "\t"))
@@ -80,11 +82,82 @@ func writeCategoryTable(ew *errWriter, tests []testkit.Test, models []string, id
}
}
func writeSummaryTable(ew *errWriter, models []string, results []runner.Result) {
// writeDiscriminationTable lists the tests that actually measure
// something: cross-model spread >= DiscriminationSpread, or attempts that
// disagreed under --repeat. With a single model there is no spread to
// speak of, so the non-passing tests are listed instead.
func writeDiscriminationTable(ew *errWriter, stats []testStat, multiModel bool) {
writeDiscriminationRows(tablePrinter{ew: ew}, stats, multiModel)
}
// discriminationSink abstracts the tab (table) vs pipe (markdown) output
// so the discrimination section is written once.
type discriminationSink interface {
header(cells ...string)
row(cells ...string)
note(text string)
}
type tablePrinter struct{ ew *errWriter }
func (tp tablePrinter) header(cells ...string) { tp.ew.println(strings.Join(cells, "\t")) }
func (tp tablePrinter) row(cells ...string) { tp.ew.println(strings.Join(cells, "\t")) }
func (tp tablePrinter) note(text string) { tp.ew.println(text) }
func writeDiscriminationRows(sink discriminationSink, stats []testStat, multiModel bool) {
discriminating, unstable, scored := 0, 0, 0
for _, st := range stats {
if !st.scored {
continue
}
scored++
if st.unstable {
unstable++
}
if multiModel && !math.IsNaN(st.spread) && st.spread >= DiscriminationSpread {
discriminating++
}
}
if multiModel {
sink.note(fmt.Sprintf("== Discrimination: %d/%d scored tests separate models by >= %.2f; %d unstable under repeat ==",
discriminating, scored, DiscriminationSpread, unstable))
} else {
sink.note(fmt.Sprintf("== Discrimination: single model (no cross-model spread); %d unstable under repeat ==", unstable))
}
rows := interestingStats(stats, multiModel)
if len(rows) == 0 {
sink.note("no discriminating or unstable tests: every scored test passed identically for every model")
return
}
sink.header("TEST", "CATEGORY", "SUBCATEGORY", "MEAN", "SPREAD", "UNSTABLE", "TRUNC")
for _, st := range rows {
sink.row(
st.testID,
st.category,
st.subcategory,
meanCellText(st.mean),
meanCellText(st.spread),
yesNo(st.unstable),
yesNo(st.truncated),
)
}
}
func yesNo(b bool) string {
if b {
return "yes"
}
return "-"
}
func writeSummaryTable(ew *errWriter, models []string, idx cellIndex) {
ew.println("== Model summary ==")
ew.println(strings.Join([]string{"MODEL", "MEAN", "PASSED", "PARTIAL", "FAILED", "ERRORS", "MEAN_LATENCY", "TOTAL_TOKENS", "TOK/S"}, "\t"))
for _, ms := range summarize(models, results) {
for _, ms := range summarize(models, idx) {
row := []string{
ms.model,
meanCellText(ms.overallMean),
+29 -8
View File
@@ -20,7 +20,9 @@ import (
// referencing FinishReason separately.
const truncatedDetailPrefix = "TRUNCATED: "
// Result is the outcome of running one test against one model.
// Result is the outcome of running one test against one model. Attempt is
// the zero-based index of this sample when Config.Repeat > 1; it is 0 for
// single-sample runs.
type Result struct {
Err error
Model string
@@ -31,6 +33,7 @@ type Result struct {
Latency time.Duration
PromptTokens int
CompletionTokens int
Attempt int
}
// Truncated reports whether the model's response was cut off by the token
@@ -54,6 +57,12 @@ type Config struct {
Concurrency int
Temperature float64
MaxTokensDefault int
// Repeat runs each (model, test) combination this many times (minimum
// 1). Even at temperature 0, reasoning models sample differently per
// call; repeats expose that instability so a score can be trusted
// (stable across attempts) or flagged (flaky) instead of silently
// treated as a capability measurement.
Repeat int
}
// Runner executes a set of tests against a set of models.
@@ -71,18 +80,24 @@ func New(client llm.Client, cfg Config) *Runner {
}
// Run executes every combination of models x tests, bounded by
// cfg.Concurrency in-flight calls, and returns one Result per combination.
// A per-call failure is captured in Result.Err rather than aborting the run.
// cfg.Concurrency in-flight calls, and returns one Result per combination
// (times Config.Repeat samples each). A per-call failure is captured in
// Result.Err rather than aborting the run.
func (r *Runner) Run(ctx context.Context, models []string, tests []testkit.Test) []Result {
type job struct {
model string
test testkit.Test
model string
test testkit.Test
attempt int
}
jobs := make([]job, 0, len(models)*len(tests))
repeat := max(r.cfg.Repeat, 1)
jobs := make([]job, 0, len(models)*len(tests)*repeat)
for _, m := range models {
for _, t := range tests {
jobs = append(jobs, job{model: m, test: t})
for a := range repeat {
jobs = append(jobs, job{model: m, test: t, attempt: a})
}
}
}
@@ -103,6 +118,7 @@ func (r *Runner) Run(ctx context.Context, models []string, tests []testkit.Test)
for i, j := range jobs {
g.Go(func() error {
res := r.runOne(gctx, j.model, j.test)
res.Attempt = j.attempt
results[i] = res
// atomic.AddInt32 makes the completion counter safe under
// Config.Concurrency-many concurrent goroutines; ReportDone
@@ -145,9 +161,14 @@ func (r *Runner) runOne(ctx context.Context, model string, test testkit.Test) Re
Temperature: r.cfg.Temperature,
}
// Wall-clock across all attempts, recorded even on error: an error row
// with zero latency reads as an instant rejection when it may be three
// exhausted timeouts (a 651-second generation looked "instant" in the
// 2026-08-30 run and misdirected the diagnosis).
callStart := time.Now()
resp, err := r.client.Complete(ctx, req)
if err != nil {
return Result{Model: model, TestID: test.ID, Err: err}
return Result{Model: model, TestID: test.ID, Err: err, Latency: time.Since(callStart)}
}
normalized := testkit.Normalize(resp.Text)
+33
View File
@@ -377,3 +377,36 @@ func TestRunner_Run_TracksLatencyAndTokens(t *testing.T) {
t.Errorf("Latency = %v, want 42ms", results[0].Latency)
}
}
func TestRunner_Run_RepeatSamplesEachCombination(t *testing.T) {
client := &mockClient{}
r := New(client, Config{Concurrency: 2, MaxTokensDefault: 100, Repeat: 3})
tests := []testkit.Test{{ID: "t1", Category: "c", Prompt: "p1", Eval: echoEval()}}
models := []string{"model-a", "model-b"}
results := r.Run(context.Background(), models, tests)
if len(results) != 6 {
t.Fatalf("len(results) = %d, want 6 (2 models x 1 test x 3 repeats)", len(results))
}
seen := make(map[[2]string][]int)
for _, res := range results {
k := [2]string{res.Model, res.TestID}
seen[k] = append(seen[k], res.Attempt)
}
for k, attempts := range seen {
if len(attempts) != 3 {
t.Errorf("%v: %d attempts, want 3", k, len(attempts))
}
got := map[int]bool{}
for _, a := range attempts {
got[a] = true
}
for a := range 3 {
if !got[a] {
t.Errorf("%v: missing attempt %d (have %v)", k, a, attempts)
}
}
}
}
+534
View File
@@ -0,0 +1,534 @@
package tests
import (
"github.com/lukaszraczylo/llm-testbench/internal/eval"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
// registerAdvancedTests adds the harder, distractor-rich scenarios designed
// after the 2026-08-30 suite-health review, which found 223/283 tests
// saturated (every model passes). Each test here targets a failure mode
// that still discriminated: multi-constraint reasoning, semantic tracing of
// composed gotchas, forensic root-causing from noisy evidence, and
// constraint math. Every answer is a structured JSON value scored by an
// exact evaluator, never a prose-matching heuristic (the source of every
// false positive in that review). Each lands in an EXISTING subcategory so
// the catalog shape (one pair per category/subcategory) is unchanged.
func registerAdvancedTests(r *testkit.Registry) {
r.Register(advAgentPlanInfeasibleTest())
r.Register(advAgentDelegConflictingPolicyTest())
r.Register(advAgentToolAdversarialDescTest())
r.Register(advRAGForensicsStageTest())
r.Register(advLLMContextBudgetTest())
r.Register(advPgExplainForensicsTest())
r.Register(advSQLMVCCAnomalyTest())
r.Register(advDockerLayerRebuildTest())
r.Register(advRelFreezeWindowTest())
r.Register(advScenMultiLogRootcauseTest())
r.Register(advLinuxCapacityFillTest())
r.Register(advHardComposedTraceTest())
r.Register(advPyComposedMutableTest())
r.Register(advCodeConflictingEvidenceTest())
r.Register(advSecExploitChainTest())
r.Register(advSecRotationBlastRadiusTest())
}
// advAgentPlanInfeasibleTest: a plan that cannot succeed because a later
// step needs an artifact no earlier step produces. The discriminator is
// admitting infeasibility instead of confabulating an order.
//
// ground truth: step "deploy" needs the built image; the only build step
// ("build") needs the compiled binary from "compile"; "compile" needs
// dependencies from "fetch-deps"; but "fetch-deps" is never listed as
// runnable because it needs network egress the sandbox forbids (stated).
// So the binding blocker is fetch-deps and the plan is infeasible.
func advAgentPlanInfeasibleTest() testkit.Test {
prompt := `You must order these build steps into a runnable sequence:
- compile: needs the fetched dependencies; produces the binary.
- build-image: needs the binary; produces the container image.
- deploy: needs the container image.
- fetch-deps: needs outbound network egress to the package registry;
produces the fetched dependencies.
Constraint: this runner has NO outbound network egress (air-gapped), and
no dependency cache is present.
Decide whether a runnable ordering exists. Respond with only a JSON object:
{"feasible": true|false, "blocking_step": "<step id or empty string>"}`
return testkit.Test{
ID: "adv-agent-plan-infeasible",
Category: "agents",
Subcategory: "planning",
Description: "Detect that an air-gapped runner makes fetch-deps (and the whole chain) infeasible rather than confabulating an order.",
Prompt: prompt,
Eval: eval.All(
eval.W(eval.JSONField("feasible", false), 2),
eval.W(eval.JSONField("blocking_step", "fetch-deps"), 1),
),
}
}
// advAgentDelegConflictingPolicyTest: pick the one assignment that satisfies
// three simultaneous constraints; the distractors each violate exactly one.
//
// ground truth: the task needs GPU, must finish under a 2h deadline, and
// must run in-region (data residency). agent-cpu-eu has no GPU (violates
// capability); agent-gpu-us is out of region (violates residency);
// agent-gpu-eu-slow takes 3h (violates deadline); agent-gpu-eu-fast meets
// all three. Answer: agent-gpu-eu-fast.
func advAgentDelegConflictingPolicyTest() testkit.Test {
prompt := `Assign this task to exactly one agent. The task REQUIRES a GPU,
must complete within a 2-hour deadline, and must run in the EU region
(data residency).
- agent-cpu-eu: EU region, no GPU, would finish in 1h.
- agent-gpu-us: US region, has GPU, would finish in 30m.
- agent-gpu-eu-slow: EU region, has GPU, would finish in 3h.
- agent-gpu-eu-fast: EU region, has GPU, would finish in 90m.
Respond with only a JSON object: {"agent": "<agent id>"}`
return testkit.Test{
ID: "adv-agent-deleg-conflicting-policy",
Category: "agents",
Subcategory: "delegation",
Description: "Choose the single agent satisfying GPU + deadline + region when each distractor violates exactly one constraint.",
Prompt: prompt,
Eval: eval.JSONField("agent", "agent-gpu-eu-fast"),
}
}
// advAgentToolAdversarialDescTest: the tool whose NAME suggests the task is
// wrong; the correct tool is identified only by reading descriptions.
//
// ground truth: task is to permanently delete a file. "file_manager" sounds
// right but its description only LISTS files (read-only). "trash_bin"
// sounds like recycling but its description permanently removes a path.
// Answer: trash_bin.
func advAgentToolAdversarialDescTest() testkit.Test {
prompt := `You must permanently delete the file at /data/tmp/old.log.
Pick the correct tool by its description, not its name:
- file_manager: "Returns a listing of files under a directory. Read-only."
- disk_report: "Reports free and used disk space per mount."
- trash_bin: "Permanently removes the file or directory at the given path
from disk. Not recoverable."
- archive_tool: "Compresses a path into a .tar.gz beside it."
Respond with only a JSON object: {"tool": "<tool name>"}`
return testkit.Test{
ID: "adv-agent-tool-adversarial-desc",
Category: "agents",
Subcategory: "tool-routing",
Description: "Select the deletion tool by description when its name (trash_bin) misleads and file_manager sounds right but is read-only.",
Prompt: prompt,
Eval: eval.JSONField("tool", "trash_bin"),
}
}
// advRAGForensicsStageTest: given a retrieval trace and a wrong answer,
// name the failing pipeline stage.
//
// ground truth: the embedding query retrieved the right documents (they
// appear in the trace with high scores and contain the fact), the reranker
// kept them top-3, but the generation cited a number found in NONE of the
// retrieved chunks - a generation-stage hallucination, not a retrieval or
// rerank failure. Answer: generation.
func advRAGForensicsStageTest() testkit.Test {
prompt := `A RAG system answered "the timeout is 45 seconds" but the
correct value is 30. Here is the trace:
- retrieval: top-3 chunks returned, all from the config reference; chunk 1
(score 0.91) contains the sentence "request_timeout defaults to 30s".
- rerank: order unchanged; chunk 1 still ranked first.
- generation: the model produced "45 seconds"; the string "45" appears in
none of the three retrieved chunks.
Which single pipeline stage introduced the error? Respond with only a JSON
object: {"stage": "retrieval"|"rerank"|"generation"}`
return testkit.Test{
ID: "adv-rag-forensics-stage",
Category: "ai",
Subcategory: "rag",
Description: "Attribute a wrong RAG answer to the generation stage when retrieval and rerank both surfaced the correct grounded chunk.",
Prompt: prompt,
Eval: eval.JSONField("stage", "generation"),
}
}
// advLLMContextBudgetTest: how many retrieved documents fit given a fixed
// context window and reservations.
//
// ground truth: 200000 window - 8000 output reserve - 1200 system - 300
// query = 190500 available; each doc is 3500 tokens; floor(190500/3500) =
// 54.
func advLLMContextBudgetTest() testkit.Test {
prompt := `A model has a 200000-token context window. You must reserve
8000 tokens for the output, 1200 for the system prompt, and 300 for the
user query. Each retrieved document is 3500 tokens. How many whole
documents can you fit into the remaining context? Respond with only the
integer.`
return testkit.Test{
ID: "adv-llm-context-budget",
Category: "ai",
Subcategory: "llm-integration",
Description: "Compute the document count that fits a 200K window after output/system/query reservations (floor(190500/3500)=54).",
Prompt: prompt,
Eval: eval.Numeric(eval.ExtractLastNumber[int], 54, 0),
}
}
// advPgExplainForensicsTest: read an EXPLAIN ANALYZE plan with a red herring
// and name the real problem.
//
// ground truth: the plan shows a Seq Scan on a 2M-row table with a Filter
// removing 1,999,900 rows (the actual cost driver), while a small Nested
// Loop over 100 rows is a red herring. The fix class is an index on the
// filtered column. Answer: the seq-scan / missing-index problem.
func advPgExplainForensicsTest() testkit.Test {
prompt := `Here is a Postgres EXPLAIN (ANALYZE) fragment for a slow query:
Nested Loop (cost=0.29..8.34 rows=100 actual time=0.02..0.14 rows=100)
-> Seq Scan on events (rows=2000000 actual time=0.01..812.4 rows=2000000)
Filter: (tenant_id = 42)
Rows Removed by Filter: 1999900
-> Index Scan using users_pkey on users (actual time=0.001..0.001 rows=1)
Planning Time: 0.2 ms
Execution Time: 851.9 ms
Identify the single dominant cause of the slow execution, and the fix
class. Respond with only a JSON object:
{"cause": "seq-scan"|"nested-loop"|"planning"|"index-scan",
"fix": "add-index"|"raise-work-mem"|"vacuum"|"rewrite-join"}`
return testkit.Test{
ID: "adv-pg-explain-forensics",
Category: "databases",
Subcategory: "postgres",
Description: "Attribute an 852ms plan to the Seq Scan filtering out ~2M rows (not the cheap nested loop) and pick add-index.",
Prompt: prompt,
Eval: eval.All(
eval.W(eval.JSONField("cause", "seq-scan"), 2),
eval.W(eval.JSONField("fix", "add-index"), 1),
),
}
}
// advSQLMVCCAnomalyTest: name the concurrency anomaly from an interleaved
// transaction timeline at a stated isolation level.
//
// ground truth: under READ COMMITTED, txn A reads balance=100, then txn B
// commits balance=150, then txn A reads again and sees 150 - the same row
// read twice in one transaction yielded different committed values: a
// non-repeatable read. It is not a dirty read (B committed) nor a phantom
// (same row, not a new row matching a predicate).
func advSQLMVCCAnomalyTest() testkit.Test {
prompt := `Two transactions run under READ COMMITTED isolation:
t1: A: BEGIN
t2: A: SELECT balance FROM accounts WHERE id=1 -> 100
t3: B: BEGIN
t4: B: UPDATE accounts SET balance=150 WHERE id=1
t5: B: COMMIT
t6: A: SELECT balance FROM accounts WHERE id=1 -> 150
t7: A: COMMIT
Name the concurrency anomaly transaction A experienced. Respond with only a
JSON object:
{"anomaly": "dirty-read"|"non-repeatable-read"|"phantom-read"|"none"}`
return testkit.Test{
ID: "adv-sql-mvcc-anomaly",
Category: "databases",
Subcategory: "sql-tuning",
Description: "Classify a re-read yielding a different committed value under READ COMMITTED as a non-repeatable read (not dirty or phantom).",
Prompt: prompt,
Eval: eval.JSONField("anomaly", "non-repeatable-read"),
}
}
// advDockerLayerRebuildTest: predict exactly which layers rebuild after a
// specific source change.
//
// ground truth: editing a file under src/ busts the "COPY src" layer (C);
// every layer after it rebuilds (D: npm run build); the final-stage
// "COPY --from=build /app/dist" (F) consumes D's output so it rebuilds too.
// The package-file COPY (A) and npm ci (B) are before the change and stay
// cached; the nginx base (E) is unaffected. Answer set: {C, D, F}.
func advDockerLayerRebuildTest() testkit.Test {
prompt := `Given this Dockerfile, with BuildKit layer caching and a warm
cache from a previous identical build:
FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./ # layer A
RUN npm ci # layer B
COPY src ./src # layer C
RUN npm run build # layer D
FROM nginx:alpine # layer E
COPY --from=build /app/dist /usr/share/nginx/html # layer F
You edit one file inside src/ (no dependency changes). Which layers must
rebuild? Respond with only a JSON array of the layer letters that rebuild,
e.g. ["A","B"].`
return testkit.Test{
ID: "adv-docker-layer-rebuild",
Category: "delivery",
Subcategory: "containers",
Description: "Predict that a src/ edit rebuilds exactly the COPY src, npm build, and dist-copy layers (C, D, F), not the cached package layers.",
Prompt: prompt,
Eval: eval.JSONStringSet([]string{"C", "D", "F"}),
}
}
// advRelFreezeWindowTest: order release steps around a change-freeze
// constraint.
//
// ground truth: the deploy-to-prod step must NOT fall inside the freeze;
// every other step (build, test, stage-deploy, smoke-test) can run during
// the freeze because they do not touch prod. The only step to defer past
// the freeze is deploy-prod. Answer: deploy-prod.
func advRelFreezeWindowTest() testkit.Test {
prompt := `A change freeze forbids any change to PRODUCTION between now and
Monday 09:00. Your release pipeline has these steps:
- build: compiles artifacts (no environment touched).
- unit-test: runs tests (no environment touched).
- stage-deploy: deploys to the staging environment.
- smoke-test: runs checks against staging.
- deploy-prod: deploys to production.
Which single step must be deferred until after the freeze lifts? Respond
with only a JSON object: {"deferred_step": "<step id>"}`
return testkit.Test{
ID: "adv-rel-freeze-window",
Category: "delivery",
Subcategory: "release-engineering",
Description: "Identify deploy-prod as the only step a production change freeze blocks, while build/test/stage steps may proceed.",
Prompt: prompt,
Eval: eval.JSONField("deferred_step", "deploy-prod"),
}
}
// advScenMultiLogRootcauseTest: interleaved logs from three sources with one
// root cause and red herrings.
//
// ground truth: the load balancer logs 502s, the app logs OOMKilled restart
// events, and the kubelet logs memory-pressure eviction - all downstream of
// the app being OOM-killed. The 502s are a symptom (backend gone), the
// eviction is a symptom (node pressure from the same leak). The root cause
// is the application OOM. Answer: oom.
func advScenMultiLogRootcauseTest() testkit.Test {
prompt := `Three log streams during an incident, timestamps interleaved:
11:02:01 lb: upstream 10.42.3.9:8080 returned 502
11:02:03 app: container killed, reason OOMKilled, restart #4
11:02:03 kubelet: memory pressure on node gst02, evicting low-priority pods
11:02:05 lb: upstream 10.42.3.9:8080 connection refused
11:02:07 app: heap usage 1.98Gi of 2Gi limit before exit
Identify the single ROOT cause (not a downstream symptom). Respond with
only a JSON object:
{"root_cause": "lb-misconfig"|"oom"|"node-eviction"|"network"}`
return testkit.Test{
ID: "adv-scen-multilog-rootcause",
Category: "operations",
Subcategory: "scenario",
Description: "Root-cause interleaved lb/app/kubelet logs to the application OOM, treating the 502s and eviction as downstream symptoms.",
Prompt: prompt,
Eval: eval.JSONField("root_cause", "oom"),
}
}
// advLinuxCapacityFillTest: linear capacity projection.
//
// ground truth: 500GB volume, 90% alert threshold = 450GB, currently 366GB
// used, growing 12GB/day. (450-366)/12 = 84/12 = 7 whole days until it
// reaches the threshold.
func advLinuxCapacityFillTest() testkit.Test {
prompt := `A 500 GB data volume is currently 366 GB used and grows by a
steady 12 GB per day. An alert fires when usage reaches 90% of capacity.
In how many whole days from now will usage first reach the 90% alert
threshold? Respond with only the integer number of days.`
return testkit.Test{
ID: "adv-linux-capacity-fill",
Category: "operations",
Subcategory: "linux",
Description: "Project days until a 500GB volume at 366GB growing 12GB/day reaches the 90% (450GB) threshold: 7.",
Prompt: prompt,
Eval: eval.Numeric(eval.ExtractLastNumber[int], 7, 0),
}
}
// advHardComposedTraceTest: a Go trace composing loop-variable capture, a
// deferred named-return mutation, and closure order.
//
// ground truth (verified by running it): three closures each capture the
// Go 1.22+ per-iteration loop variable i (1, 2, 3) and add it to the named
// return result; result becomes 6; return result + 10 sets result to 16;
// the deferred func doubles it to 32.
func advHardComposedTraceTest() testkit.Test {
prompt := "In Go 1.22 or later, what does calc() print?\n\n" +
"```go\n" +
"func calc() (result int) {\n" +
"\tdefer func() { result *= 2 }()\n" +
"\tfns := []func(){}\n" +
"\tfor i := 1; i <= 3; i++ {\n" +
"\t\tfns = append(fns, func() { result += i })\n" +
"\t}\n" +
"\tfor _, fn := range fns {\n" +
"\t\tfn()\n" +
"\t}\n" +
"\treturn result + 10\n" +
"}\n" +
"```\n\n" +
"Respond with only the integer printed."
return testkit.Test{
ID: "adv-hard-composed-trace",
Category: "programming",
Subcategory: "hard",
Description: "Trace Go 1.22 per-iteration capture (1+2+3) plus return+10 plus a deferred double: 32.",
Prompt: prompt,
Eval: eval.Numeric(eval.ExtractLastNumber[int], 32, 0),
}
}
// advPyComposedMutableTest: a Python trace composing a mutable default
// argument with a list comprehension of closures.
//
// ground truth (verified by running it): f uses a shared mutable default
// list acc; fns[0]() calls f(0), appending 0 -> returns [0]... but the
// closures capture i by late binding, and range(3) leaves i=2, so each
// lambda calls f(2). fns[0]() -> f(2) appends 2 -> [2]. Then f(10) appends
// to the SAME shared list -> [2, 10]. The second printed line is [2, 10].
func advPyComposedMutableTest() testkit.Test {
prompt := "What does the SECOND print statement output?\n\n" +
"```python\n" +
"def f(x, acc=[]):\n" +
" acc.append(x)\n" +
" return acc\n" +
"\n" +
"fns = [lambda: f(i) for i in range(3)]\n" +
"print(fns[0]())\n" +
"print(f(10))\n" +
"```\n\n" +
"The second print outputs a list of two integers. Respond with only a " +
"JSON object naming its two elements in order: " +
"{\"first_element\": <int>, \"second_element\": <int>}"
return testkit.Test{
ID: "adv-py-composed-mutable",
Category: "programming",
Subcategory: "python",
Description: "Trace late-binding closure (i=2) plus a shared mutable default list across two calls: second print is [2, 10].",
Prompt: prompt,
Eval: eval.All(
eval.W(eval.JSONField("first_element", 2), 1),
eval.W(eval.JSONField("second_element", 10), 1),
),
}
}
// advCodeConflictingEvidenceTest: two sources disagree; pick the claim that
// survives with the correct reason.
//
// ground truth: the README says the default port is 8080; the actual
// config file (authoritative, it is what the program reads) says 9090. When
// documentation and the live config disagree, the config the program
// actually loads wins. Answer: 9090, source config.
func advCodeConflictingEvidenceTest() testkit.Test {
prompt := `Two sources describe the service's listen port:
- README.md (last edited 14 months ago): "The service listens on port
8080 by default."
- config/server.yaml (the file the running process loads on startup):
"listen_port: 9090"
There is no environment-variable override. Which port does the running
service actually listen on, and which source is authoritative? Respond with
only a JSON object: {"port": <number>, "authoritative": "readme"|"config"}`
return testkit.Test{
ID: "adv-code-conflicting-evidence",
Category: "research",
Subcategory: "codebase",
Description: "Resolve a README-vs-config port conflict to the config the process actually loads (9090), not the stale doc.",
Prompt: prompt,
Eval: eval.All(
eval.W(eval.JSONField("port", 9090), 2),
eval.W(eval.JSONField("authoritative", "config"), 1),
),
}
}
// advSecExploitChainTest: two individually-benign behaviors compose into an
// exploit; name the composed vulnerability class.
//
// ground truth: an open redirect (benign-looking) plus the OAuth flow
// echoing the redirect_uri without an allowlist lets an attacker steal the
// authorization code by redirecting the victim's callback to an
// attacker-controlled host - an account-takeover via open-redirect in the
// OAuth redirect_uri. The class asked for is "open-redirect".
func advSecExploitChainTest() testkit.Test {
prompt := `Two facts about an app, each individually looking minor:
1. GET /go?next=<url> issues an HTTP 302 to whatever <url> is passed, with
no host allowlist.
2. The OAuth login uses /go as the post-login landing page and forwards the
authorization code to the landing URL's query string.
Combined, what vulnerability class does this enable? Respond with only a
JSON object:
{"vuln": "open-redirect"|"csrf"|"sql-injection"|"xss"|"ssrf"}`
return testkit.Test{
ID: "adv-sec-exploit-chain",
Category: "security",
Subcategory: "appsec",
Description: "Compose an unvalidated redirect with an OAuth code hand-off into an open-redirect account-takeover chain.",
Prompt: prompt,
Eval: eval.JSONField("vuln", "open-redirect"),
}
}
// advSecRotationBlastRadiusTest: dual-key rotation-overlap math across
// dependent consumers with different cache TTLs.
//
// ground truth: consumers cache a validated key for up to their TTL, so the
// old key must stay accepted for at least the longest consumer TTL after
// the new key is published, or an in-flight cached validation breaks. TTLs
// are 30, 15, and 5 minutes; the minimum safe overlap window is the max =
// 30 minutes.
func advSecRotationBlastRadiusTest() testkit.Test {
prompt := `You are rotating a signing key with a dual-key (old+new)
overlap window. Three independent services validate signatures and cache
the validated key material for up to their own TTL before re-fetching:
- service-a: 30-minute cache TTL
- service-b: 15-minute cache TTL
- service-c: 5-minute cache TTL
For how many minutes, at minimum, must the OLD key stay accepted after the
new key is published, so that no service rejects a signature it should
accept? Respond with only the integer number of minutes.`
return testkit.Test{
ID: "adv-sec-rotation-blast-radius",
Category: "security",
Subcategory: "secrets",
Description: "Size the dual-key overlap window to the longest consumer cache TTL (max(30,15,5)=30 minutes).",
Prompt: prompt,
Eval: eval.Numeric(eval.ExtractLastNumber[int], 30, 0),
}
}
@@ -0,0 +1,49 @@
package tests
import (
"context"
"testing"
)
// TestAdvancedGroundTruthScoresFull asserts each advanced test's own
// documented correct answer scores 1.0 through its real evaluator - a guard
// that the eval and the intended ground truth agree (a JSON typo or a wrong
// field path would silently make a test unwinnable).
func TestAdvancedGroundTruthScoresFull(t *testing.T) {
golden := map[string]string{
"adv-agent-plan-infeasible": `{"feasible": false, "blocking_step": "fetch-deps"}`,
"adv-agent-deleg-conflicting-policy": `{"agent": "agent-gpu-eu-fast"}`,
"adv-agent-tool-adversarial-desc": `{"tool": "trash_bin"}`,
"adv-rag-forensics-stage": `{"stage": "generation"}`,
"adv-llm-context-budget": `54`,
"adv-pg-explain-forensics": `{"cause": "seq-scan", "fix": "add-index"}`,
"adv-sql-mvcc-anomaly": `{"anomaly": "non-repeatable-read"}`,
"adv-docker-layer-rebuild": `["C","D","F"]`,
"adv-rel-freeze-window": `{"deferred_step": "deploy-prod"}`,
"adv-scen-multilog-rootcause": `{"root_cause": "oom"}`,
"adv-linux-capacity-fill": `7`,
"adv-hard-composed-trace": `32`,
"adv-py-composed-mutable": `{"first_element": 2, "second_element": 10}`,
"adv-code-conflicting-evidence": `{"port": 9090, "authoritative": "config"}`,
"adv-sec-exploit-chain": `{"vuln": "open-redirect"}`,
"adv-sec-rotation-blast-radius": `30`,
}
byID := map[string]int{}
all := All().All()
for i, tc := range all {
byID[tc.ID] = i
}
for id, answer := range golden {
idx, ok := byID[id]
if !ok {
t.Errorf("%s: not registered in catalog", id)
continue
}
s := all[idx].Eval.Evaluate(context.Background(), answer)
if s.Value != 1 {
t.Errorf("%s: golden answer %q scored %.2f, want 1.00 (%s)", id, answer, s.Value, s.Detail)
}
}
}
+4
View File
@@ -23,5 +23,9 @@ func All() *testkit.Registry {
registerSecurityTests(r)
registerDeliveryTests(r)
registerAITests(r)
registerHardTests(r)
registerScenarioTests(r)
registerSaturationTests(r)
registerAdvancedTests(r)
return r
}
+2 -2
View File
@@ -64,8 +64,8 @@ func TestAll_SubcategoryMinimums(t *testing.T) {
const minTestsPerSubcategory = 10
wantSubcats := map[string][]string{
"programming": {"golang", "python", "typescript", "c"},
"operations": {"macos", "linux", "kubernetes"},
"programming": {"golang", "python", "typescript", "c", "hard"},
"operations": {"macos", "linux", "kubernetes", "scenario"},
"research": {"web", "whitepapers", "codebase"},
"agents": {"tool-routing", "planning", "delegation"},
"databases": {"postgres", "redis", "sql-tuning"},
+43 -12
View File
@@ -159,27 +159,58 @@ pipelined commands? Respond with only "yes" or "no".`
}
}
// dbKeysCommandPattern matches an explicit mention of the Redis KEYS
// command actually being invoked - "KEYS *" (its most common prod-danger
// form) or a backtick-quoted `KEYS` - not a generic use of the English
// word "keys" elsewhere in prose ("the keys to fixing this...").
var dbKeysCommandPattern = regexp.MustCompile("(?:`KEYS`|\\bKEYS\\s*\\*)")
// dbKeysEndorsementPattern matches a KEYS mention in an endorsement-shaped
// position — a verb recommending it ("use `KEYS`", "just run KEYS *"), a
// positive predicate ("KEYS * is fine/simplest"), or a bare imperative line
// ("run this:\nKEYS *") — rather than every mention. The guard then still
// requires a negation cue near such a mention (see dbNoBareKeysInProd).
//
// Triggering on any mention is too strict: a fully correct live answer
// (probe, 2026-08-29) opens with "`KEYS *` is dangerous..." and later says
// "so `KEYS *` can block the server while it walks through millions of
// keys" — harm descriptions whose words ("can block") sit outside the
// negation cue list, yet they are exactly the explanation the prompt asks
// for, not an endorsement.
//
// The predicate branch's leading (?:^|[^...]) guard stops a hyphen-joined
// identifier from matching mid-word and keeps the match start at the term
// area rather than arbitrary prose.
var dbKeysEndorsementPattern = regexp.MustCompile("(?i)(?:" +
// a verb recommending it: "use `KEYS`", "just run KEYS *", "stick with KEYS *"
"(?:use|using|used|run|running|runs|execute|executing|call|calling|should(?:\\s+(?:also|just))?|better\\s+to|go\\s+with|opt\\s+for|stick\\s+with|switch(?:ing)?\\s+(?:back\\s+)?to)\\s+(?:the\\s+)?(?:redis\\s+)?(?:command\\s+)?(?:`KEYS`|\\bKEYS\\s*\\*)" +
// a positive predicate about it: "KEYS * is fine", "`KEYS` is simplest"
"|(?:^|[^A-Za-z0-9_-])(?:`KEYS`|\\bKEYS\\s*\\*)[^.;\\n]{0,30}?\\b(?:is|are|was|were|seems?|looks?|works?|stays?|remains?|would\\s+be|should\\s+be)\\s+(?:a\\s+|just\\s+|totally\\s+|still\\s+|actually\\s+)?(?:fine|good|great|safe|ok|okay|acceptable|simplest|easiest|better|fast\\s+enough)" +
// a bare command line after a colon or full stop: "run this:\\nKEYS *" -
// requiring the break keeps a line-initial mention that merely
// continues a wrapped sentence from triggering.
"|(?:[.:]|^)[ \\t]*(?:\\r?\\n)[ \\t*`>-]*(?:`KEYS`|\\bKEYS\\s*\\*)" +
")")
// dbNegationWindow is how many characters around a KEYS-command mention
// are searched for a negation cue.
const dbNegationWindow = 60
// dbNoBareKeysInProd scores full credit unless the response instructs
// running "KEYS *" against a production instance without any negating
// context (a warning not to, or an instruction to use SCAN instead is
// fine). This is deliberately not eval.NotContains, which would also zero
// out the best possible answer: one that correctly explains why NOT to run
// KEYS in production.
// dbNoBareKeysInProd scores full credit unless the response endorses
// running "KEYS *" against a production instance (see
// dbKeysEndorsementPattern) with no negating context nearby — a warning
// not to, or an instruction to use SCAN instead, is fine. This is
// deliberately not eval.NotContains, which would also zero out the best
// possible answer: one that correctly explains why NOT to run KEYS in
// production.
//
// Delegates to eval.NoUnnegatedMention (D5), the primitive shared with
// kubernetes.go, security.go, and delivery_git.go's equivalent guards.
func dbNoBareKeysInProd() eval.Evaluator {
return eval.NoUnnegatedMention(dbKeysCommandPattern, dbNegationWindow, nil)
return eval.NoUnnegatedMention(dbKeysEndorsementPattern, dbNegationWindow, nil)
}
// dbRedisScanVsKeysTest: require SCAN over KEYS for a hot-path production
+24 -1
View File
@@ -147,10 +147,33 @@ func TestDBNoBareKeysInProd(t *testing.T) {
want: 0,
},
{
name: "negation cue too far away to count",
// The guard now triggers on an endorsement-shaped mention only,
// and the negation window is measured from the endorsement
// phrase ("...run KEYS *"), not from the sentence start — so a
// hedging "never mind" that is still within 60 chars counts.
name: "negation cue close to the endorsement phrase",
response: "Never mind the migration for now, but you could casually run KEYS * to check this quickly.",
want: 1,
},
{
// ground truth: same endorsement, but the cue is now >60 chars
// from "run KEYS *" (73), outside the window - still an
// unnegated recommendation.
name: "negation cue too far away to count",
response: "Never mind everything else that was said in the earlier review about migrations, but you could casually run KEYS * to check this quickly.",
want: 0,
},
{
// Regression from the live probe (2026-08-29): a fully correct
// answer describes the harm ("can block", no cue word in the
// list) and only negates one mention ("avoid `KEYS *` unless").
// None of these is an endorsement, so the guard must stay at 1.
name: "correct probe answer: harm description without cue words",
response: "`KEYS *` is dangerous in production because it scans the entire keyspace synchronously.\n" +
"Redis is single-threaded for command execution, so `KEYS *` can block the server while it walks through millions of keys.\n" +
"For production code, use `SCAN` with a reasonable `COUNT`, and avoid `KEYS *` unless you are in development.",
want: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+5 -3
View File
@@ -46,7 +46,7 @@ func delNoUnnegatedMention(forbidden *regexp.Regexp) eval.Evaluator {
// "-with-lease" - the safe form, exempt from the bare-force check entirely
// rather than needing a negation cue.
var delForceFlagPattern = regexp.MustCompile(`(?i)--force\b`)
var delForceWithLeaseSuffixPattern = regexp.MustCompile(`(?i)^-with-lease`)
var delForceWithLeaseSuffixPattern = regexp.MustCompile(`(?i)^-(with-lease|if-includes)`)
// delNoBareForcePush scores full credit unless the response recommends a
// bare "--force" push (not "--force-with-lease") with no negation cue
@@ -320,8 +320,10 @@ func delGitDetachedHeadRecoveryTest() testkit.Test {
branch name), then made 3 new commits while HEAD was detached. You only now
realize you need to keep that work, and HEAD is still detached. Give the
ordered list of git subcommands (just the subcommand names, e.g. "branch")
you must run, in order, to save the 3 commits onto a new branch without
losing them. Respond with only a JSON array of subcommand names, e.g.
you must run, in order, to save the 3 commits onto a new branch AND end up
checked out on that branch, with HEAD attached to it (a bare "git branch"
alone saves the commits but leaves HEAD detached, which is not enough
here). Respond with only a JSON array of subcommand names, e.g.
["a","b"].`
return testkit.Test{
+539
View File
@@ -0,0 +1,539 @@
package tests
import (
"context"
"encoding/json"
"strings"
"github.com/lukaszraczylo/llm-testbench/internal/eval"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
func registerScenarioTests(r *testkit.Registry) {
r.Register(scenDiskpressureFirstcmdTest())
r.Register(scenInodeExhaustionTest())
r.Register(scenCrashloopOrderTest())
r.Register(scenNetLayerIsolateTest())
r.Register(scenServiceDegradeTest())
r.Register(scenLogRootcauseMCQTest())
r.Register(scenBackupRestoreOrderTest())
r.Register(scenCertexpiryTriageTest())
r.Register(scenSystemdFailreasonTest())
r.Register(scenPortbindFailTest())
r.Register(scenLvmResizeOrderTest())
}
// scenDiskpressureDf is the inline df output for scenDiskpressureFirstcmdTest.
const scenDiskpressureDf = `Filesystem Size Used Avail Use% Mounted on
/dev/sda1 100G 100G 0G 100% /
tmpfs 16G 1.1G 15G 8% /run
/dev/sdb1 500G 30G 470G 6% /data`
// scenFirstDiskCommand scores a response whose {"first_command"} field is a
// safe, size-sorted du scan of the root filesystem. It demands the two
// essential verb flags (du to measure recursive usage, sort to order the
// result) and rejects a destructive first step (rm). A naive model that
// jumps straight to deleting files, or that lists with ls (which does not
// measure recursive usage) or find -size (which only matches known sizes),
// scores zero. This is deliberately not a bare ContainsAny on the whole
// response: the prompt forces a JSON object, and matching inside the
// command field keeps the check grounded in an actual command rather than
// prose that merely names du.
func scenFirstDiskCommand() eval.Evaluator {
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
raw, err := eval.ExtractJSON(response)
if err != nil {
return eval.Score{Value: 0, Detail: "no JSON object found"}
}
var obj map[string]string
if err := json.Unmarshal([]byte(raw), &obj); err != nil {
return eval.Score{Value: 0, Detail: "JSON not usable as a string map"}
}
cmd := strings.ToLower(strings.TrimSpace(obj["first_command"]))
if cmd == "" {
return eval.Score{Value: 0, Detail: "first_command empty"}
}
for _, req := range []string{"du", "sort"} {
if !strings.Contains(cmd, req) {
return eval.Score{Value: 0, Detail: "first_command missing " + req}
}
}
if strings.HasPrefix(cmd, "rm") || strings.Contains(cmd, " rm ") || strings.Contains(cmd, "rm -") {
return eval.Score{Value: 0, Detail: "first_command starts by deleting files, not measuring"}
}
return eval.Score{Value: 1, Detail: "first_command is a safe du|sort scan of the filesystem"}
})
}
// scenDiskpressureFirstcmdTest: give the exact one-line first command to
// find what is filling a 100%-full root filesystem.
//
// ground truth: / is 100% full on /dev/sda1 while /data (/dev/sdb1) has
// 470G free, so the scan must stay within the / filesystem and rank top-
// level directories by size. The canonical, standard-OSS one-liner is
// `du -xh --max-depth=1 / 2>/dev/null | sort -rh | head -20` - du with
// -x to not descend into the nearly-empty /data mount, a depth of 1 to get
// per-top-level-directory usage, sort -rh descending by human size, and
// head to the top consumers. ls does not measure recursive usage and find
// -size only matches known file sizes, so neither finds the fillers.
func scenDiskpressureFirstcmdTest() testkit.Test {
prompt := `This Linux host's root filesystem /dev/sda1 is 100%
full; /data on /dev/sdb1 has plenty of free space. You must find which
paths under / are consuming the space, so you can free it. Here is the
relevant df output:
` + scenDiskpressureDf + `
Give the exact one-line shell command you would run FIRST to identify the
largest consumers under /, staying on the / filesystem and producing a
size-sorted list. Respond with only a JSON object:
{"first_command":"<the exact one-line command>"}`
return testkit.Test{
ID: "scen-diskpressure-firstcmd",
Category: "operations",
Subcategory: "scenario",
Description: "Give the exact first shell command (du|sort) to find what filled a 100%-full root filesystem.",
Prompt: prompt,
Eval: scenFirstDiskCommand(),
}
}
// scenInodeExhaustionDfHi is the inline df -hi output for
// scenInodeExhaustionTest.
const scenInodeExhaustionDfHi = `$ df -h /
Filesystem Size Used Avail Use% Mounted on
/dev/sda1 100G 35G 65G 35% /
$ df -hi /
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/sda1 1.0M 1.0M 0 100% /`
// scenInodeExhaustionTest: diagnose "No space left on device" that is
// actually inode exhaustion (blocks free, inodes exhausted).
//
// ground truth: writes fail with "No space left on device" even though
// df -h shows 65G free, because df -hi shows the filesystem's inode
// table is 100% used (1.0M of 1.0M). Inode exhaustion, not block
// exhaustion, is the cause; the command that reveals it is `df -hi`
// (df with the inode flag). The fix is deleting cheap small files
// (often an unbounded cache dir filling the inode table with tiny files),
// not growing the disk (which adds blocks, not inodes) or swapping mounts.
func scenInodeExhaustionTest() testkit.Test {
prompt := `Deleting nothing and with the disk apparently not full,
this host now fails writes with "No space left on device". Here are two
diagnostic outputs:
` + scenInodeExhaustionDfHi + `
What is the cause, and which exact command produced the revealing
output? Respond with only a JSON object:
{"cause":"<one of: inodes-exhausted, disk-full, quota-exceeded>",
"command":"<one of: df -hi, df -h, stat -f> "}`
evaluator := eval.Mean(
eval.JSONField("cause", "inodes-exhausted"),
eval.JSONField("command", "df -hi"),
)
return testkit.Test{
ID: "scen-inode-exhaustion",
Category: "operations",
Subcategory: "scenario",
Description: "Recognize ENOSPC from inode exhaustion (not disk space) and the df -hi command that reveals it.",
Prompt: prompt,
Eval: evaluator,
}
}
// scenCrashloopOrderTest: an incident where the operator must pick the
// diagnostic steps to find why a CrashLoopBackOff container crashes,
// before taking any action.
//
// ground truth: the only observation is the CrashLoopBackOff status and
// the restart count - nothing about *why* the container dies. Both
// read-only diagnostics are needed: B (`kubectl describe pod`, recent
// events and the last exit reason - rules out OOM-kill, image problems,
// probe failures) and A (`kubectl logs <pod> --previous`, the actual
// error output of the crashed run - names the failing line). Neither
// alone roots an app crash: describe shows the exit code but not the
// error message; logs --previous show the error but not the node-level
// events. Whether describe or logs comes first is a matter of taste, so
// the evaluator scores the SET {A, B}. What the test discriminates is
// diagnose-before-acting: C (`rollout restart`) and D (`scale
// --replicas=0`) act without diagnosing, and E (ssh to a node for
// journalctl) reaches for node-level tooling prematurely when the
// container's own state and logs are one kubectl call away.
func scenCrashloopOrderTest() testkit.Test {
prompt := `A pod named ingestion-5d7c9b6f8b-p2k1q in namespace data
keeps crashing and restarting: kubectl get pods shows STATUS
CrashLoopBackOff, RESTARTS 14. You know nothing else about why it dies,
and you must find the root cause before changing anything. These are the
available steps:
A) kubectl logs <pod> --previous (read the last crashed run's output)
B) kubectl describe pod <pod> (inspect events and last exit reason)
C) kubectl rollout restart deployment ingestion
D) kubectl scale deployment ingestion --replicas=0
E) ssh to a node and run journalctl -u kubelet
Respond with only a JSON array of the step letters that make up your
diagnosis, containing exactly the SMALLEST set of steps sufficient to find
the root cause (any order). Do not include steps that only act without
diagnosing, and do not include diagnostics that are redundant when a
simpler kubectl call already surfaces the same information.`
return testkit.Test{
ID: "scen-crashloop-order",
Category: "operations",
Subcategory: "scenario",
Description: "Pick the diagnostic steps (describe + logs --previous) that root-cause a CrashLoopBackOff before acting.",
Prompt: prompt,
Eval: eval.JSONStringSet([]string{"A", "B"}),
}
}
// scenNetLayerSymptom is the inline diagnostic session for
// scenNetLayerIsolateTest.
const scenNetLayerSymptom = `$ curl -sS https://api.example.org/data
curl: (6) Could not resolve host: api.example.org
$ ping -c1 10.0.0.7
64 bytes from 10.0.0.7: icmp_seq=1 ttl=64 time=0.8 ms
(10.0.0.7 is a working internal host on the same subnet)
$ cat /etc/resolv.conf
nameserver 10.0.0.53`
// scenNetLayerIsolateTest: isolate which network layer is broken from
// inline symptoms and pick the strict layer among an enumerated set.
//
// ground truth: curl fails with "Could not resolve host" - DNS
// resolution, not a routing or TCP problem (an ICMP packet to an internal
// host on the same subnet succeeds, so IP reachability on the local
// segment is fine, and curl never even reached TCP/TLS because it could
// not translate the name). The nameserver 10.0.0.53 is configured but
// the name does not resolve, so the failing layer is strictly dns; it is
// not ip-routing, tcp-connect, tls, or http, none of which are reached
// when resolution itself fails.
func scenNetLayerIsolateTest() testkit.Test {
prompt := `A service on this host cannot reach https://api.example.org.
Here is the diagnostic session:
` + scenNetLayerSymptom + `
At which network layer is the failure occurring? Respond with only a
JSON object:
{"layer":"<one of: dns, ip-routing, tcp-connect, tls, http>"}
Choose exactly one layer from the enumerated list.`
return testkit.Test{
ID: "scen-net-layer-isolate",
Category: "operations",
Subcategory: "scenario",
Description: "Isolate a network failure to the DNS layer from inline curl/ping/resolv.conf symptoms.",
Prompt: prompt,
Eval: eval.JSONField("layer", "dns"),
}
}
// scenServiceDegradeContext is the inline telemetry + deploy timeline for
// scenServiceDegradeTest.
const scenServiceDegradeContext = `Canary deployed to 100% at 14:00: config file
change lowering the app's connection-pool size (pool: 200 -> 50) and
raising the worker count.
Latency (p99) of the checkout service API:
13:30 210ms
14:05 1400ms
14:30 1650ms
15:00 1700ms
Resource metrics for the checkout pods: CPU at 12%, memory at 40% -
both low. No crash, no 5xx errors, no data loss.`
// scenServiceDegradeTest: a service-degradation decision tree where the
// regression is tracked to a config change with healthy resources.
//
// ground truth: the p99 latency jumped from ~210ms to ~1400ms exactly at
// the 14:00 canary rollout, whose only meaningful change is the
// connection-pool size drop (200 -> 50) - the obvious pattern match is the
// config change, and CPU/memory are low so the pool is not saturated by
// resource pressure. The correct action is to roll back / revert that
// config change (rollback-config). scale-out is wrong (no CPU/memory
// pressure to scale for), restart alone is wrong (it does not revert the
// config), and restore-backup is wrong (no data loss to recover from).
func scenServiceDegradeTest() testkit.Test {
prompt := `The checkout service API latency spiked and stayed high.
Here is the context:
` + scenServiceDegradeContext + `
Which single action best restores service? Respond with only a JSON
object:
{"action":"<one of: rollback-config, scale-out, restore-backup, restart>"}`
return testkit.Test{
ID: "scen-service-degrade",
Category: "operations",
Subcategory: "scenario",
Description: "Pick the correct remediation (config rollback) for a canary-introduced service degradation with healthy resources.",
Prompt: prompt,
Eval: eval.JSONField("action", "rollback-config"),
}
}
// scenPgConnectionLog is the inline log excerpt for
// scenLogRootcauseMCQTest.
const scenPgConnectionLog = `2026-08-29 14:22:11 UTC FATAL: remaining connection slots are reserved for non-replication superuser connections
2026-08-29 14:22:11 UTC DETAIL: 1000 concurrent connections, 100 remaining connection slots available
2026-08-29 14:22:12 UTC ERROR: connection to server at "db.internal" (10.0.1.25), port 5432 failed: FATAL: remaining connection slots are reserved for non-replication superuser connections`
// scenLogRootcauseMCQTest: map a log pattern to its root cause from
// lettered options.
//
// ground truth: "remaining connection slots are reserved" with a detail
// line counting 1000 concurrent connections is the textbook PostgreSQL
// signal that the connection slot limit (max_connections) has been
// reached - applications are holding more connections than the server is
// configured to allow. It is not a disk-full condition (no out-of-space
// message), not a slow-query problem (no long query shown), and not an
// authentication failure (no `password authentication failed` line).
func scenLogRootcauseMCQTest() testkit.Test {
prompt := `Applications calling the Postgres database below now fail to
connect. Here is a log excerpt:
` + scenPgConnectionLog + `
What is the root cause of these connection failures?
A) connection pool exhausted (max_connections reached)
B) disk full
C) a single slow query blocking others
D) authentication failure
Respond with only a JSON object: {"answer":"<the single letter A-D>"}`
return testkit.Test{
ID: "scen-log-rootcause-mcq",
Category: "operations",
Subcategory: "scenario",
Description: "Map a PostgreSQL max_connections log pattern to its root cause via a lettered MCQ.",
Prompt: prompt,
Eval: eval.JSONField("answer", "A"),
}
}
// scenBackupRestoreOptions lists the available steps for
// scenBackupRestoreOrderTest.
const scenBackupRestoreOptions = `A) take a fresh logical backup (pg_dump) of the current data
B) apply the destructive migration
C) validate row counts and data after the migration
D) restore last night's backup before doing anything else`
// scenBackupRestoreOrderTest: order the steps for a safe data-changing
// migration, guaranteeing recoverability.
//
// ground truth: to make a destructive migration recoverable you must
// snapshot the CURRENT state first (A - a fresh pg_dump), then apply the
// migration (B), then validate the result (C). Restoring last night's
// backup (D) first is wrong: it is a stale snapshot that would throw
// away today's writes before any change has even been made, and it cannot
// be the safety net for the migration.
func scenBackupRestoreOrderTest() testkit.Test {
prompt := `You are about to apply a destructive migration to a
production Postgres database and must guarantee you can get back to the
current state. The available steps are:
` + scenBackupRestoreOptions + `
Respond with only a JSON array of the step letters, in the order from
first to last, that makes this migration recoverable - include exactly
the steps you would run, in order.`
return testkit.Test{
ID: "scen-backup-restore-order",
Category: "operations",
Subcategory: "scenario",
Description: "Order the steps (fresh backup, then migrate, then validate) that make a destructive migration recoverable.",
Prompt: prompt,
Eval: eval.JSONStringArrayEquals([]string{"A", "B", "C"}),
}
}
// scenCertExpirySymptom is the inline symptom for
// scenCertexpiryTriageTest.
const scenCertExpirySymptom = `$ date
Sat Aug 29 09:14:02 UTC 2026
$ curl -v https://gateway.internal/
...
* SSL connection using TLSv1.3
* SSL certificate verify result: certificate has expired
$ openssl x509 -in /etc/ssl/svc.crt -noout -dates
notBefore=Aug 1 12:00:00 2025 GMT
notAfter=Aug 1 12:00:00 2026 GMT`
// scenCertexpiryTriageTest: triage a TLS failure whose cause is an
// expired certificate.
//
// ground truth: `date` anchors wall time at Aug 29 2026; the openssl
// -dates readout shows the served cert valid until Aug 1 2026 - the
// current date is past notAfter, so the certificate expired and the
// cause category is cert-expired. Clock-skew would need the host clock
// to disagree with reality, but the cert's own window explains the
// failure exactly, and cipher-mismatch would show a handshake/algo error,
// not a verify result. The standard command to inspect a certificate's
// validity end date directly is openssl x509 -noout -enddate (the
// readout above came from the -dates variant of the same inspection).
func scenCertexpiryTriageTest() testkit.Test {
prompt := `Clients now fail to connect to gateway.internal with a TLS
verification error. Here is the diagnostic session:
` + scenCertExpirySymptom + `
What is the cause, and which standard command inspects a certificate's
validity directly? Respond with only a JSON object:
{"cause":"<one of: cert-expired, clock-skew, cipher-mismatch>",
"check_command":"<one of: openssl-x509-enddate, date, openssl-cipher>"}`
evaluator := eval.Mean(
eval.JSONField("cause", "cert-expired"),
eval.JSONField("check_command", "openssl-x509-enddate"),
)
return testkit.Test{
ID: "scen-certexpiry-triage",
Category: "operations",
Subcategory: "scenario",
Description: "Identify an expired certificate as the TLS failure cause and the openssl command that inspects validity.",
Prompt: prompt,
Eval: evaluator,
}
}
// scenSystemdStatus is the inline systemctl status excerpt for
// scenSystemdFailreasonTest.
const scenSystemdStatus = `$ systemctl status worker-queue
● worker-queue.service - Queue worker
Loaded: loaded (/etc/systemd/system/worker-queue.service; enabled)
Active: failed (Result: exit-code) since Tue 2026-08-25 09:12:03 UTC; 12s ago
Main PID: 4821 (code=exited, status=1/FAILURE)
CPU: 120ms
$ journalctl -u worker-queue -n 6
Aug 25 09:12:03 host worker-queue[4821]: config/openapi.yaml: open: no such file or directory
Aug 25 09:12:03 host worker-queue[4821]: fatal: failed to load configuration
Aug 25 09:12:03 host worker-queue[4821]: exiting after startup failure`
// scenSystemdFailreasonTest: reason about why a systemd unit failed from
// an inline status + journal excerpt.
//
// ground truth: the journal line "config/openapi.yaml: open: no such
// file or directory" - the binary exits because its configuration file
// cannot be found at the expected path. The category is missing-config,
// not permission (no "Permission denied"), not missing-binary (the
// binary ran and printed a config-diagnostic trace), and not port-in-use
// (no bind error). The fix is to provide or point the unit at the
// correct config path, not to reinstall the binary or chase a port.
func scenSystemdFailreasonTest() testkit.Test {
prompt := `A systemd unit is failing. Here is the status and journal:
` + scenSystemdStatus + `
What category of failure is this? Respond with only a JSON object:
{"category":"<one of: missing-config, permission, missing-binary, port-in-use>"}
Choose exactly one category from the enumerated list.`
return testkit.Test{
ID: "scen-systemd-failreason",
Category: "operations",
Subcategory: "scenario",
Description: "Classify a systemd unit failure (missing config file) from inline status and journal output.",
Prompt: prompt,
Eval: eval.JSONField("category", "missing-config"),
}
}
// scenPortbindError is the inline startup error for scenPortbindFailTest.
const scenPortbindError = `$ service listener start
(1) listen tcp :8080: bind: address already in use
listener: could not bind to 0.0.0.0:8080, exiting
$ ss -ltnp
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:(("listener",pid=1044,fd=3))`
// scenPortbindFailTest: diagnose an "address already in use" bind
// failure and the command that identifies the occupying process.
//
// ground truth: the startup error "bind: address already in use" for
// port 8080, and the `ss -ltnp` output shows an existing listener (pid
// 1044) already bound to 0.0.0.0:8080 - a duplicate/lingering instance
// still holds the port. The cause category is port-in-use, and the
// standard command that lists listening sockets with the owning process
// is `ss -ltnp` (ss with listener, numerical, port and process flags);
// netstat is the older equivalent but ss is the modern standard on this
// home-cluster stack.
func scenPortbindFailTest() testkit.Test {
prompt := `A service fails to start. Here is the output:
` + scenPortbindError + `
What is the cause, and which command identified the process holding the
port? Respond with only a JSON object:
{"cause":"<one of: port-in-use, missing-binary, config-error>",
"command":"<one of: ss -ltnp, netstat -r, lsblk>"}`
evaluator := eval.Mean(
eval.JSONField("cause", "port-in-use"),
eval.JSONField("command", "ss -ltnp"),
)
return testkit.Test{
ID: "scen-portbind-fail",
Category: "operations",
Subcategory: "scenario",
Description: "Diagnose an address-in-use bind failure and the ss -ltnp command that reveals the holding process.",
Prompt: prompt,
Eval: evaluator,
}
}
// scenLvmSteps lists the LVM extend steps for scenLvmResizeOrderTest.
const scenLvmSteps = `A) lvextend -L +20G /dev/vg0/data (grow the logical volume)
B) pvs (check free space in the volume group)
C) resize2fs /dev/vg0/data (grow the filesystem to fill the LV)
D) umount /dev/vg0/data (unmount the volume)`
// scenLvmResizeOrderTest: order the steps of a safe LVM logical-volume
// resize.
//
// ground truth: to extend a filesystem on LVM you first confirm there is
// free space in the volume group (B - `pvs`), then extend the logical
// volume (A - `lvextend`), then grow the filesystem to fill the newly
// added space (C - `resize2fs`). Doing C before A, or A before
// confirming free space in B, fails (nothing to grow into); D (umount)
// is not required for an online extend of a mounted ext4/xfs volume.
func scenLvmResizeOrderTest() testkit.Test {
prompt := `You must safely grow the filesystem on LVM logical volume
/dev/vg0/data while it stays online. The available steps are:
` + scenLvmSteps + `
Respond with only a JSON array of the step letters, in the order from
first to last, that correctly extends this volume - include exactly the
steps you would run, in order.`
return testkit.Test{
ID: "scen-lvm-resize-order",
Category: "operations",
Subcategory: "scenario",
Description: "Order the LVM resize steps (check VG space, extend LV, then grow the filesystem) for an online extend.",
Prompt: prompt,
Eval: eval.JSONStringArrayEquals([]string{"B", "A", "C"}),
}
}
+357
View File
@@ -0,0 +1,357 @@
package tests
import (
"context"
"testing"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
// TestRegisterScenarioTests_Wiring calls registerScenarioTests into a fresh
// registry and checks it contributes a full operations/scenario layer with
// scen-* IDs, mirroring the wiring-test pattern used for round-2
// categories. It also keeps registerScenarioTests referenced so it is not
// reported as dead code while the parent still wires catalog integration.
func TestRegisterScenarioTests_Wiring(t *testing.T) {
r := testkit.NewRegistry()
registerScenarioTests(r)
if n := r.Len(); n < 10 || n > 12 {
t.Fatalf("registerScenarioTests registered %d tests, want 10-12", n)
}
for _, tc := range r.All() {
if tc.Category != "operations" {
t.Errorf("test %s has Category %q, want operations", tc.ID, tc.Category)
}
if tc.Subcategory != "scenario" {
t.Errorf("test %s has Subcategory %q, want scenario", tc.ID, tc.Subcategory)
}
if len(tc.ID) < 5 || tc.ID[:5] != "scen-" {
t.Errorf("test %s has an ID not prefixed scen-", tc.ID)
}
}
}
func TestScenDiskpressureFirstcmdTest_Eval(t *testing.T) {
tc := scenDiskpressureFirstcmdTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct du|sort one-liner",
response: `{"first_command":"du -xh --max-depth=1 / 2>/dev/null | sort -rh | head -20"}`,
want: 1,
},
{
name: "correct fenced with prose",
response: "Here is the command:\n```json\n{\"first_command\":\"du -x --max-depth=1 / 2>/dev/null | sort -h\"}\n```",
want: 1,
},
{
name: "wrong: ls does not measure recursive usage",
response: `{"first_command":"ls -lah /"}`,
want: 0,
},
{
name: "wrong: destructive rm as first step",
response: `{"first_command":"rm -rf /var/log && rm -rf /tmp"}`,
want: 0,
},
{
name: "wrong: du but no sort",
response: `{"first_command":"du -sh /var"}`,
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestScenInodeExhaustionTest_Eval(t *testing.T) {
tc := scenInodeExhaustionTest()
tests := []struct {
name string
response string
want float64
}{
{"correct", `{"cause":"inodes-exhausted","command":"df -hi"}`, 1},
{"correct fenced", "```json\n{\"cause\":\"inodes-exhausted\",\"command\":\"df -hi\"}\n```", 1},
{"wrong cause", `{"cause":"disk-full","command":"df -hi"}`, 0.5},
{"wrong command", `{"cause":"inodes-exhausted","command":"df -h"}`, 0.5},
{"both wrong", `{"cause":"quota-exceeded","command":"stat -f"}`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestScenCrashloopOrderTest_Eval(t *testing.T) {
tc := scenCrashloopOrderTest()
tests := []struct {
name string
response string
want float64
}{
{"both steps", `["B","A"]`, 1},
{"both steps fenced", "```json\n[\"B\",\"A\"]\n```", 1},
{
// The correct answer discusses the rejected options (C as an
// unhelpful action, E as premature) at length; the JSON
// array still governs, so the prose cannot change the score.
name: "both steps with long discussion of rejected options",
response: "```json\n[\"B\",\"A\"]\n```\n\n`kubectl describe pod` (B) shows the last exit reason and `kubectl logs --previous` (A) shows the actual crash output; either may come first since both are read-only. " +
"A blind `kubectl rollout restart` (C) would not reveal the cause and `kubectl scale --replicas=0` (D) would only stop the workload; " +
"ssh'ing to a node for journalctl (E) is node-level tooling that is premature while the container logs are local.",
want: 1,
},
{"order is free", `["A","B"]`, 1},
{"only one diagnostic step", `["A"]`, 0.5},
// An action step in the answer costs credit: set overlap 2/3.
{"correct plus an action step", `["B","A","D"]`, 2.0 / 3.0},
{"all wrong", `["C","D","E"]`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestScenNetLayerIsolateTest_Eval(t *testing.T) {
tc := scenNetLayerIsolateTest()
tests := []struct {
name string
response string
want float64
}{
{"correct", `{"layer":"dns"}`, 1},
{"correct fenced", "```json\n{\"layer\":\"dns\"}\n```", 1},
{
name: "correct, discussing other layers at length",
response: `{"layer":"dns"}
This is not tls or http: curl never reached the handshake because it could not resolve the name at all; the local ping to 10.0.0.7 rules out ip-routing on the segment.`,
want: 1,
},
{"wrong layer", `{"layer":"tls"}`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestScenServiceDegradeTest_Eval(t *testing.T) {
tc := scenServiceDegradeTest()
tests := []struct {
name string
response string
want float64
}{
{"correct", `{"action":"rollback-config"}`, 1},
{"correct fenced", "```json\n{\"action\":\"rollback-config\"}\n```", 1},
{
name: "correct, discussing rejected actions at length",
response: `{"action":"rollback-config"}
Scaling out is wrong because CPU and memory are both low - there is no resource pressure to scale for; a restart would not revert the config file; and a backup restore is wrong because there is no data loss to recover from. The regression tracks the 14:00 canary pool-size change, so reverting the config is the fix.`,
want: 1,
},
{"wrong action", `{"action":"restart"}`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestScenLogRootcauseMCQTest_Eval(t *testing.T) {
tc := scenLogRootcauseMCQTest()
tests := []struct {
name string
response string
want float64
}{
{"correct", `{"answer":"A"}`, 1},
{"correct fenced", "```json\n{\"answer\":\"A\"}\n```", 1},
{
name: "correct, discussing every distractor at length",
response: `{"answer":"A"}
This is not disk full (there is no out-of-space message in the log), not a slow query (no long-running query is shown), and not an authentication failure (no password-authentication-failed line) - it is the classic max_connections exhaustion signal.`,
want: 1,
},
{"wrong letter", `{"answer":"C"}`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestScenBackupRestoreOrderTest_Eval(t *testing.T) {
tc := scenBackupRestoreOrderTest()
tests := []struct {
name string
response string
want float64
}{
{"correct order", `["A","B","C"]`, 1},
{"correct order fenced", "```json\n[\"A\",\"B\",\"C\"]\n```", 1},
{
// The correct answer is allowed to explain at length why
// restoring last night's backup (D) first is wrong; the JSON
// array is what is scored.
name: "correct order, discussing rejected restore-first at length",
response: `["A","B","C"]
Restoring last night's backup first (D) would throw away today's writes before any change is made - it is a stale snapshot, not a safety net for the migration, so I take a fresh pg_dump first.`,
want: 1,
},
{"wrong: restore first", `["D","A"]`, 0},
{"wrong: reordered", `["A","C","B"]`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestScenCertexpiryTriageTest_Eval(t *testing.T) {
tc := scenCertexpiryTriageTest()
tests := []struct {
name string
response string
want float64
}{
{"correct", `{"cause":"cert-expired","check_command":"openssl-x509-enddate"}`, 1},
{"correct fenced", "```json\n{\"cause\":\"cert-expired\",\"check_command\":\"openssl-x509-enddate\"}\n```", 1},
{"wrong cause", `{"cause":"clock-skew","check_command":"openssl-x509-enddate"}`, 0.5},
{"wrong command", `{"cause":"cert-expired","check_command":"date"}`, 0.5},
{"both wrong", `{"cause":"cipher-mismatch","check_command":"openssl-cipher"}`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestScenSystemdFailreasonTest_Eval(t *testing.T) {
tc := scenSystemdFailreasonTest()
tests := []struct {
name string
response string
want float64
}{
{"correct", `{"category":"missing-config"}`, 1},
{"correct fenced", "```json\n{\"category\":\"missing-config\"}\n```", 1},
{
name: "correct, discussing rejected categories at length",
response: `{"category":"missing-config"}
This is not a permission problem (no Permission denied in the journal), not a missing binary (the worker ran and printed a config-diagnostic trace), and not a port in use (no bind error) - the unit fails because config/openapi.yaml cannot be opened.`,
want: 1,
},
{"wrong category", `{"category":"permission"}`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestScenPortbindFailTest_Eval(t *testing.T) {
tc := scenPortbindFailTest()
tests := []struct {
name string
response string
want float64
}{
{"correct", `{"cause":"port-in-use","command":"ss -ltnp"}`, 1},
{"correct fenced", "```json\n{\"cause\":\"port-in-use\",\"command\":\"ss -ltnp\"}\n```", 1},
{"wrong cause", `{"cause":"config-error","command":"ss -ltnp"}`, 0.5},
{"wrong command", `{"cause":"port-in-use","command":"lsblk"}`, 0.5},
{"both wrong", `{"cause":"missing-binary","command":"netstat -r"}`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestScenLvmResizeOrderTest_Eval(t *testing.T) {
tc := scenLvmResizeOrderTest()
tests := []struct {
name string
response string
want float64
}{
{"correct order", `["B","A","C"]`, 1},
{"correct order fenced", "```json\n[\"B\",\"A\",\"C\"]\n```", 1},
{"wrong: grows fs before extending LV", `["A","C","B"]`, 0},
{"wrong: includes umount", `["B","A","C","D"]`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
+469
View File
@@ -0,0 +1,469 @@
package tests
import (
"github.com/lukaszraczylo/llm-testbench/internal/eval"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
// registerHardTests registers every programming/hard test. This subcategory
// stresses harder daily-programming accuracy - concurrency/async reasoning,
// subtle language traps, algorithm output tracing, and API-contract edge
// cases - each forced to a short, machine-checkable answer.
func registerHardTests(r *testkit.Registry) {
r.Register(hardFloatBinaddTest())
r.Register(hardNullishOverOrTest())
r.Register(hardGoroutineRaceTest())
r.Register(hardDeferNamedReturnTest())
r.Register(hardIntDivisionTest())
r.Register(hardRegexGreedyTest())
r.Register(hardPromiseAllSettledTest())
r.Register(hardContextTimeoutTest())
r.Register(hardJSONUnmarshalNullTest())
r.Register(hardVarShadowTest())
r.Register(hardPyVersionSortTest())
r.Register(hardGoParenBalanceTest())
}
// hardFloatBinaddTest: judge whether 0.1 + 0.2 === 0.3 holds in
// JavaScript / IEEE-754 doubles.
//
// ground truth: neither 0.1 nor 0.2 has an exact binary representation,
// so 0.1 + 0.2 computes to the nearest double to the mathematical sum,
// which is 0.30000000000000004, not the double for the literal 0.3.
// Therefore the strict equality comparison evaluates to false. This is a
// canonical, deterministic fact of IEEE-754 double arithmetic, independent
// of any runtime; verified with node during authoring.
func hardFloatBinaddTest() testkit.Test {
prompt := `In JavaScript, evaluate this comparison:
0.1 + 0.2 === 0.3
Does the strict-equality comparison evaluate to true or to false?
Respond with only a JSON object:
{"equal": true}
or
{"equal": false}`
return testkit.Test{
ID: "hard-float-binadd",
Category: "programming",
Subcategory: "hard",
Description: "Judge IEEE-754 double equality for 0.1 + 0.2 === 0.3 in JavaScript (false).",
Prompt: prompt,
Eval: eval.JSONField("equal", false),
}
}
// hardNullishOverOrTest: trace the value of a nullish-coalescing
// expression over a falsy (but non-null) operand.
//
// ground truth: ?? returns the right-hand operand only when the left-hand
// operand is null or undefined. 0 is neither null nor undefined, so
// n ?? 42 evaluates to 0 - unlike ||, which would have returned 42 for
// the falsy 0. This is the core distinction between nullish coalescing
// and logical OR. Verified with node during authoring.
func hardNullishOverOrTest() testkit.Test {
prompt := `Consider this TypeScript code:
const n = 0;
const v = n ?? 42;
What is the value of v?
Respond with only a JSON object:
{"value": ...}
Give the numeric value only.`
return testkit.Test{
ID: "hard-nullish-over-or",
Category: "programming",
Subcategory: "hard",
Description: "Trace nullish-coalescing over a falsy-but-non-null operand: 0 ?? 42 is 0, not 42.",
Prompt: prompt,
Eval: eval.JSONField("value", 0),
}
}
// hardGoroutineRaceTest: judge whether an unsynchronized shared counter
// is guaranteed to reach N*M exactly.
//
// ground truth: incrementing a shared int from multiple goroutines with no
// synchronization is a data race (Go's race detector would flag it). A
// read-modify-write of a shared variable is not atomic; two goroutines can
// read the same value before either writes back, so increments are lost.
// The final value is some value strictly between M (at best serialized,
// one goroutine's work) and N*M, but it is NOT guaranteed to equal
// N*M. This claim follows from the Go memory model / data-race semantics
// and cannot be recomputed by running (the outcome is nondeterministic).
func hardGoroutineRaceTest() testkit.Test {
prompt := `A Go program starts 8 goroutines, each of which performs:
counter++
1000 times, where counter is a shared int with NO synchronization
(no mutex, no atomic) between the goroutines. The program then waits for
all 8 goroutines and prints counter.
Is counter's final value guaranteed to be exactly 8000?
Respond with only a JSON object:
{"guaranteed": true}
or
{"guaranteed": false}`
return testkit.Test{
ID: "hard-goroutine-race",
Category: "programming",
Subcategory: "hard",
Description: "Judge whether an unsynchronized shared counter is guaranteed to reach N*M (false: data race).",
Prompt: prompt,
Eval: eval.JSONField("guaranteed", false),
}
}
// hardDeferNamedReturnTest: trace the return value of a function whose
// deferred closure writes its named return during a normal (non-panic)
// return.
//
// ground truth: return 7 first assigns the named result r = 7; then,
// still before the function returns to its caller, the deferred closure
// runs and overwrites r = 99. The caller observes the named return after
// all defers have run, so f() returns 99 - not 7 and not 5. This is the
// classic named-return-plus-defer trap. Verified with go run during
// authoring.
func hardDeferNamedReturnTest() testkit.Test {
prompt := `Consider this Go code:
func f() (r int) {
defer func() { r = 99 }()
r = 5
return 7
}
What value does f() return?
Respond with only the number, no explanation.`
return testkit.Test{
ID: "hard-defer-named-return",
Category: "programming",
Subcategory: "hard",
Description: "Trace a deferred closure overwriting a named return value during a normal return (99).",
Prompt: prompt,
Eval: eval.Numeric(eval.ExtractLastNumber[int], hardDeferNamedReturnWant, 0),
}
}
// hardDeferNamedReturnWant is f()'s return value.
//
// ground truth: the deferred closure runs during the function's epilogue,
// after the explicit `return 7` has set the named result r = 7, and
// overwrites it with 99; the caller therefore receives 99 (see the full
// derivation on hardDeferNamedReturnTest). Recomputed by running the
// identical snippet with go run during authoring.
const hardDeferNamedReturnWant = 99
// hardIntDivisionTest: judge the result of integer division being
// assigned to a floating-point variable.
//
// ground truth: a / b with two int operands performs integer division,
// which truncates toward zero: 7 / 2 = 3 (not 3.5). That integral
// result is then implicitly converted to double, so d = 3.0. The common
// error is assuming floating division because the target is a double.
// Verified with go run during authoring.
func hardIntDivisionTest() testkit.Test {
prompt := `Consider this C code:
int a = 7;
int b = 2;
double d = a / b;
What is the value of d?
Respond with only the number, no explanation.`
return testkit.Test{
ID: "hard-int-division",
Category: "programming",
Subcategory: "hard",
Description: "Trace integer-division-then-widen: 7 / 2 is 3 (stored as 3.0), not 3.5.",
Prompt: prompt,
Eval: eval.Numeric(eval.ExtractLastNumber[float64], hardIntDivisionWant, 0),
}
}
// hardIntDivisionWant is d's value.
//
// ground truth: integers divide as integers (truncating), giving 7 / 2 =
// 3, converted to double 3.0; the float value is 3, so Numeric want is
// 3.0. Recomputed by running the identical arithmetic during authoring.
const hardIntDivisionWant = 3.0
// hardRegexGreedyTest: trace which substrings two greedy capture groups
// of an anchored pattern match.
//
// ground truth: quantifiers are greedy by default - each takes as much as
// it can while still allowing the rest of the pattern to match. In
// /^(\d+)(\d+)$/, the first \d+ grabs the longest prefix that leaves at
// least one digit for the second \d+ (which needs at least one match
// because + is one-or-more). That longest prefix of "12345" is "1234",
// leaving "5" for group 2. A naive symmetric split like ["12","345"]
// fails because group 1 would then still be able to grow. Verified with
// node during authoring.
func hardRegexGreedyTest() testkit.Test {
prompt := `Consider this regular expression applied to the string "12345":
^(\d+)(\d+)$
(^ and $ anchor to the whole string, and \d+ means one or more digits.)
What are the two captured groups? Respond with only a JSON array of the
two captured strings, in group order, for example ["a","b"]`
return testkit.Test{
ID: "hard-regex-greedy",
Category: "programming",
Subcategory: "hard",
Description: "Trace greedy capture-group backtracking: ^(one-or-more digits)(one-or-more digits)$ on 12345 yields 1234 and 5.",
Prompt: prompt,
Eval: eval.JSONStringArrayEquals([]string{"1234", "5"}),
}
}
// hardPromiseAllSettledTest: trace the per-result status values of
// Promise.allSettled over a mixed resolve/reject input.
//
// ground truth: Promise.allSettled always resolves, and each element of
// its result array is an object whose status is the literal "fulfilled"
// for a resolved input and "rejected" for a rejected input, preserving
// input order. Promise.resolve(42) yields {status:"fulfilled", value:42};
// Promise.reject(new Error("boom")) yields {status:"rejected",
// reason:...}. So the two status values, in order, are "fulfilled" then
// "rejected". Verified with node during authoring.
func hardPromiseAllSettledTest() testkit.Test {
prompt := `Consider this TypeScript code:
const results = await Promise.allSettled([
Promise.resolve(42),
Promise.reject(new Error("boom")),
]);
Each element of results has a "status" field.
List the two status values, in array order. Respond with only a JSON
array of the two status strings, for example ["a","b"]`
return testkit.Test{
ID: "hard-promise-allsettled",
Category: "programming",
Subcategory: "hard",
Description: "Trace Promise.allSettled result statuses over a fulfilled+rejected input (fulfilled, rejected).",
Prompt: prompt,
Eval: eval.JSONStringArrayEquals([]string{"fulfilled", "rejected"}),
}
}
// hardContextTimeoutTest: identify the exact error string a timed-out
// context's Err() returns.
//
// ground truth: when a context created by context.WithTimeout fires, its
// Done channel closes and Err() returns the sentinel
// context.DeadlineExceeded, whose Error() string (per the context
// package) is exactly "context deadline exceeded" - which is distinct
// from "context canceled", the string of context.Canceled returned when a
// cancel function is called. Verified with go run during authoring.
func hardContextTimeoutTest() testkit.Test {
prompt := `In Go, a context created with context.WithTimeout fires after its
deadline. At that point:
ctx.Err()
returns a non-nil error. What is the exact string of that error?
Respond with only a JSON object:
{"err": "..."}
with the exact string value.`
return testkit.Test{
ID: "hard-context-timeout",
Category: "programming",
Subcategory: "hard",
Description: "Identify ctx.Err() after a context.WithTimeout deadline fires (context deadline exceeded).",
Prompt: prompt,
Eval: eval.JSONField("err", "context deadline exceeded"),
}
}
// hardJSONUnmarshalNullTest: judge the behavior of json.Unmarshal on a
// literal JSON null into an already-populated struct.
//
// ground truth: per encoding/json, a JSON null into a non-nil pointer
// target is a no-op: Unmarshal returns nil (no error) and leaves the
// target's existing value unchanged. It does not zero the struct and does
// not error. Verified with go run during authoring (a struct holding
// Items: [1] stays [1] with nil error).
func hardJSONUnmarshalNullTest() testkit.Test {
prompt := `In Go:
var s Struct // s is a populated struct value (some fields set)
err := json.Unmarshal([]byte("null"), &s)
Does Unmarshal return an error, and is s left unchanged (no fields
cleared)?
Respond with only a JSON object:
{"returns_error": true}
or
{"returns_error": false}`
return testkit.Test{
ID: "hard-json-unmarshal-null",
Category: "programming",
Subcategory: "hard",
Description: "Judge json.Unmarshal of a literal null into a populated struct (no error, unchanged).",
Prompt: prompt,
Eval: eval.JSONField("returns_error", false),
}
}
// hardVarShadowTest: judge whether an inner := declaration shadows an
// outer variable without changing it.
//
// ground truth: `count := 5` inside the if block declares a NEW, inner
// variable that shadows the outer count for the duration of that block
// (Go's := always creates a new variable when at least one variable on
// the left is new in the current scope). Incrementing the inner count
// leaves the outer count untouched, so after the block outer count is
// still 0. Verified with go run during authoring.
func hardVarShadowTest() testkit.Test {
prompt := `Consider this Go code:
count := 0
if true {
count := 5
count++
}
// count here?
Does the outer count remain unchanged (equal to 0) after the if block?
Respond with only a JSON object:
{"outer_unchanged": true}
or
{"outer_unchanged": false}`
return testkit.Test{
ID: "hard-var-shadow",
Category: "programming",
Subcategory: "hard",
Description: "Trace Go variable shadowing: an inner := leaves the outer variable unchanged (true).",
Prompt: prompt,
Eval: eval.JSONField("outer_unchanged", true),
}
}
// hardPyVersionVersions is the inline version list for
// hardPyVersionSortTest.
const hardPyVersionVersions = `versions = [
"1.9.0",
"1.10.0",
"1.2.0",
"2.0.0",
"0.9.9",
]`
// hardPyVersionSortWant is the expected stdout of a correct script: the
// second-highest version under numeric component-by-component ordering.
//
// ground truth: ordering versions by numeric components puts 0.9.9 <
// 1.2.0 < 1.9.0 < 1.10.0 < 2.0.0 (1.10.0 is newer than 1.9.0 because
// its second component 10 > 9), so the second-highest is 1.10.0. A
// plain lexicographic string sort (the trap) would instead report 1.9.0,
// which is exactly the failure the test discriminates. Recomputed by
// running the correct script with python3 during authoring.
const hardPyVersionSortWant = "1.10.0"
// hardPyVersionSortTest: write a python3 script that prints the
// second-highest version under numeric component ordering.
//
// ground truth: see hardPyVersionSortWant; correctness is determined by
// running the script (version ordering is behavior, not a text match).
func hardPyVersionSortTest() testkit.Test {
prompt := `Here is an inline list of software version strings:
` + "```python" + `
` + hardPyVersionVersions + `
` + "```" + `
Write a python3 program that sorts the versions numerically, component by
component (natural version ordering: "1.10.0" is newer than "1.9.0"
because the second component 10 > 9), and prints only the second-highest
version.`
return testkit.Test{
ID: "hard-py-version-sort",
Category: "programming",
Subcategory: "hard",
Description: "Write a python3 script printing the second-highest version by numeric component ordering.",
System: terseCodeOnly,
Prompt: prompt,
Eval: eval.PyRun(eval.PassthroughHarness, hardPyVersionSortWant),
}
}
// hardGoParenBalanceHarness is a complete, independent driver file (its
// own package clause and imports) that eval.GoRun writes to harness.go
// alongside the model's IsBalanced in solution.go. It drives IsBalanced
// across balanced, nested, mismatched, unclosed, and wrongly-ordered
// cases, printing PASS only if every case matches its expected result.
const hardGoParenBalanceHarness = `package main
import (
"fmt"
"os"
)
func main() {
cases := []struct {
in string
want bool
}{
{"", true},
{"()", true},
{"()[]{}", true},
{"([{}])", true},
{"(", false},
{"(]", false},
{"([)]", false},
{"((", false},
{")(", false},
}
for _, c := range cases {
if got := IsBalanced(c.in); got != c.want {
fmt.Printf("FAIL %q: got %v want %v\n", c.in, got, c.want)
os.Exit(1)
}
}
fmt.Println("PASS")
}`
// hardGoParenBalanceTest: implement a stack-based balanced-parentheses
// checker.
//
// ground truth: the harness above is the oracle - it exercises empty,
// balanced, nested, mismatched-closer, trailing-opener, and wrong-order
// inputs, so a correct stack-based implementation prints PASS. There is no
// separate hardcoded constant beyond the PASS sentinel.
func hardGoParenBalanceTest() testkit.Test {
prompt := `Implement a Go function with exactly this signature:
func IsBalanced(s string) bool
It must report whether s's parentheses are balanced and correctly nested:
'(' '[' '{' are openers, ')' ']' '}' are the matching closers. An empty
string is balanced (return true). Return false for any unclosed opener,
any closer with no matching open, or any wrong-nesting (e.g. "([)]").`
return testkit.Test{
ID: "hard-go-paren-balance",
Category: "programming",
Subcategory: "hard",
Description: "Implement a stack-based balanced-parentheses checker for (), [], and {}.",
System: terseCodeOnly,
Prompt: prompt,
Eval: eval.GoRun(hardGoParenBalanceHarness, "PASS"),
}
}
+360
View File
@@ -0,0 +1,360 @@
package tests
import (
"context"
"testing"
)
func TestHardFloatBinaddTest_Eval(t *testing.T) {
tc := hardFloatBinaddTest()
tests := []struct {
name string
response string
want float64
}{
{"correct", `{"equal":false}`, 1},
{"correct fenced with prose", "The answer is:\n```json\n{\"equal\":false}\n```", 1},
{"wrong: claims equal", `{"equal":true}`, 0},
{"wrong: non-boolean", `{"equal":"yes"}`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestHardNullishOverOrTest_Eval(t *testing.T) {
tc := hardNullishOverOrTest()
tests := []struct {
name string
response string
want float64
}{
{"correct", `{"value":0}`, 1},
{"correct as string", `{"value":"0"}`, 1},
{"wrong: OR semantics", `{"value":42}`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestHardGoroutineRaceTest_Eval(t *testing.T) {
tc := hardGoroutineRaceTest()
tests := []struct {
name string
response string
want float64
}{
{"correct", `{"guaranteed":false}`, 1},
{"correct fenced", "```json\n{\"guaranteed\":false}\n```", 1},
{"wrong: claims guaranteed", `{"guaranteed":true}`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestHardDeferNamedReturnTest_Eval(t *testing.T) {
tc := hardDeferNamedReturnTest()
tests := []struct {
name string
response string
want float64
}{
{"correct", "99", 1},
{"correct prose-wrapped", "The function returns 99.", 1},
{"wrong: returns statement value", "7", 0},
{"wrong: pre-defer value", "5", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestHardIntDivisionTest_Eval(t *testing.T) {
tc := hardIntDivisionTest()
tests := []struct {
name string
response string
want float64
}{
{"correct", "3", 1},
{"correct as float", "3.0", 1},
{"correct prose-wrapped", "It is 3.", 1},
{"wrong: assumes float division", "3.5", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestHardRegexGreedyTest_Eval(t *testing.T) {
tc := hardRegexGreedyTest()
tests := []struct {
name string
response string
want float64
}{
{"correct", `["1234","5"]`, 1},
{"correct fenced", "```json\n[\"1234\",\"5\"]\n```", 1},
{"wrong: symmetric split", `["12","345"]`, 0},
{"wrong: reversed", `["5","1234"]`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestHardPromiseAllSettledTest_Eval(t *testing.T) {
tc := hardPromiseAllSettledTest()
tests := []struct {
name string
response string
want float64
}{
{"correct", `["fulfilled","rejected"]`, 1},
{"correct fenced", "```json\n[\"fulfilled\",\"rejected\"]\n```", 1},
{"wrong: reordered", `["rejected","fulfilled"]`, 0},
{"wrong: both fulfilled", `["fulfilled","fulfilled"]`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestHardContextTimeoutTest_Eval(t *testing.T) {
tc := hardContextTimeoutTest()
tests := []struct {
name string
response string
want float64
}{
{"correct", `{"err":"context deadline exceeded"}`, 1},
{"correct fenced", "```json\n{\"err\":\"context deadline exceeded\"}\n```", 1},
{"wrong: canceled sentinel", `{"err":"context canceled"}`, 0},
{"wrong: vague", `{"err":"some error"}`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestHardJSONUnmarshalNullTest_Eval(t *testing.T) {
tc := hardJSONUnmarshalNullTest()
tests := []struct {
name string
response string
want float64
}{
{"correct", `{"returns_error":false}`, 1},
{"correct fenced", "```json\n{\"returns_error\":false}\n```", 1},
{"wrong: claims error", `{"returns_error":true}`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestHardVarShadowTest_Eval(t *testing.T) {
tc := hardVarShadowTest()
tests := []struct {
name string
response string
want float64
}{
{"correct", `{"outer_unchanged":true}`, 1},
{"correct fenced", "```json\n{\"outer_unchanged\":true}\n```", 1},
{"wrong: claims outer changed", `{"outer_unchanged":false}`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
// TestHardPyVersionSortTest_Eval exercises the PyRun evaluator with a
// hand-written correct script and a lexicographic (wrong) script.
func TestHardPyVersionSortTest_Eval(t *testing.T) {
tc := hardPyVersionSortTest()
correctScript := "```python\n" + `versions = [
"1.9.0",
"1.10.0",
"1.2.0",
"2.0.0",
"0.9.9",
]
def key(v):
return tuple(int(p) for p in v.split("."))
sorted_versions = sorted(versions, key=key)
print(sorted_versions[-2])
` + "```"
wrongLexicographic := "```python\n" + `versions = [
"1.9.0",
"1.10.0",
"1.2.0",
"2.0.0",
"0.9.9",
]
print(sorted(versions)[-2])
` + "```"
wrongHardcoded := "```python\nprint('2.0.0')\n```"
tests := []struct {
name string
response string
want float64
}{
{"correct numeric sort", correctScript, 1},
{"wrong: lexicographic sort", wrongLexicographic, 0},
{"wrong: hardcoded highest", wrongHardcoded, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Skipped {
t.Skip("python3 not available, skipping exec-evaluator assertion")
}
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
// TestHardGoParenBalanceTest_Eval exercises the GoRun evaluator with a
// hand-written correct function and a wrong one.
func TestHardGoParenBalanceTest_Eval(t *testing.T) {
tc := hardGoParenBalanceTest()
correctFunc := "```go\n" + `func IsBalanced(s string) bool {
pairs := map[byte]byte{')': '(', ']': '[', '}': '{'}
stack := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
switch s[i] {
case '(', '[', '{':
stack = append(stack, s[i])
case ')', ']', '}':
if len(stack) == 0 || stack[len(stack)-1] != pairs[s[i]] {
return false
}
stack = stack[:len(stack)-1]
}
}
return len(stack) == 0
}
` + "```"
wrongFunc := "```go\n" + `func IsBalanced(s string) bool {
return true
}
` + "```"
tests := []struct {
name string
response string
want float64
}{
{"correct stack implementation", correctFunc, 1},
{"wrong: always true", wrongFunc, 0},
{"wrong: no code at all", "I would use a stack here.", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Skipped {
t.Skip("go toolchain not available, skipping exec-evaluator assertion")
}
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
// TestHardDeferNamedReturnWant_GroundTruth recomputes f()'s return value
// by running an equivalent function in-process, independent of the
// hardDeferNamedReturnWant constant.
func TestHardDeferNamedReturnWant_GroundTruth(t *testing.T) {
f := func() (r int) {
defer func() { r = 99 }()
r = 5
return 7
}
if got := f(); got != hardDeferNamedReturnWant {
t.Errorf("equivalent deferred-named-return function returned %d, want %d", got, hardDeferNamedReturnWant)
}
}
// TestHardIntDivisionWant_GroundTruth recomputes for a double result of
// 7 / 2 by hand, independent of the hardIntDivisionWant constant.
func TestHardIntDivisionWant_GroundTruth(t *testing.T) {
a, b := 7, 2
want := float64(a / b) // integer division truncates before widening
if want != hardIntDivisionWant {
t.Errorf("recomputed integer division = %v, want %v", want, hardIntDivisionWant)
}
}
+534
View File
@@ -0,0 +1,534 @@
// Package-level hardening tests for the five subcategories that measured
// 100% perfect-score across all three baseline models (see the health
// audit over the 2026-08-18 3-model run): agents/delegation,
// databases/postgres, research/web, security/appsec, security/secrets.
// Every test there was saturated - zero discrimination signal. These ten
// tests are trap-shaped on purpose: each embeds a near-miss answer that
// plausible-but-wrong reasoning produces, and every expected value carries
// a derivation comment.
package tests
import (
"github.com/lukaszraczylo/llm-testbench/internal/eval"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
// registerSaturationTests adds the hardening tests to the registry. They
// land in existing subcategories, so the per-category register functions
// stay untouched; the subcategory's test count only grows.
func registerSaturationTests(r *testkit.Registry) {
r.Register(satDelegCapacityTrapTest())
r.Register(satDelegNearMissRosterTest())
r.Register(satPGCountStarTrapTest())
r.Register(satPGTimeoutTrapTest())
r.Register(satWebRobotsUAScopeTest())
r.Register(satWebHreflangSelfTest())
r.Register(satAppsecORMTrapTest())
r.Register(satAppsecPOSTTrapTest())
r.Register(satSecretsRotationMathTest())
r.Register(satSecretsErrorPathTest())
}
// satDelegCapacityTrapTest: the roster's specialist for the job exists but
// is unavailable. Tests whether the model force-fits the task to a
// mismatched agent, over-coordinates with the orchestrator, or correctly
// keeps it in the main thread.
func satDelegCapacityTrapTest() testkit.Test {
prompt := `You are coordinating agents. Available specialist roster:
` + delegRoster + `
Situation: the CI pipeline for the main branch has been failing for the
past hour on a Docker build step. It needs diagnosing now. The roster's
infra/CI specialist is already working on an unrelated production incident
and will not free up for several hours. There is exactly one task.
Respond with only a JSON object, no other text, of the form
{"best_next_step": "...", "specialist_this_task_belongs_to": "..."} where:
- best_next_step is exactly one of: "dispatch_code_writer",
"dispatch_orchestrator", "handle_in_main_thread", "wait_for_specialist"
- specialist_this_task_belongs_to is the roster agent name whose
description covers this task, regardless of their availability.`
return testkit.Test{
ID: "agent-deleg-capacity-trap",
Category: "agents",
Subcategory: "delegation",
Description: "Route a task whose specialist is busy: no force-fit to a mismatched agent, no orchestrator for a single task, and still name the correct owning specialist",
Prompt: prompt,
// ground truth: the task is diagnosing a failing CI build, which
// the roster assigns to devops-debugger ("Diagnoses failing
// deployments, CI pipelines, and infrastructure incidents"). It is
// busy, so the remaining options are judged against the roster:
// code-writer "Implements new features or bug fixes by writing and
// editing source code directly" - a generalist forced onto infra
// diagnosis; orchestrator "Plans and sequences a multi-agent
// workflow" - there is one task, nothing to sequence; waiting on a
// specialist that is hours away while CI is down blocks delivery
// for no gain. A single task with its specialist unavailable is
// handled directly (handle_in_main_thread). The owning specialist
// on the roster is unambiguously devops-debugger.
Eval: eval.Mean(
eval.JSONField[string]("best_next_step", "handle_in_main_thread"),
eval.JSONField[string]("specialist_this_task_belongs_to", "devops-debugger"),
),
}
}
// satDelegNearMissRosterTest: 4-task mapping where every task has a
// same-vocabulary distractor (docs vs web lookup, release notes vs prose
// docs, diff review vs security review).
func satDelegNearMissRosterTest() testkit.Test {
prompt := `You are coordinating agents. Specialist roster:
` + delegRoster + `
Map each task to exactly one roster agent - the one whose roster
description covers it. Tasks:
- task1: "Update the API guide in docs/ so it matches the endpoints as
they exist in the repository today."
- task2: "Determine from the vendor's public pricing page which of their
plans includes SSO."
- task3: "Produce the v2.3 release notes by generating the changelog
section and tagging the release."
- task4: "Audit the auth handler for vulnerabilities before it merges."
Respond with only a JSON object of the form
{"task1": "...", "task2": "...", "task3": "...", "task4": "..."}
where each value is exactly one roster agent name.`
return testkit.Test{
ID: "agent-deleg-near-miss-roster",
Category: "agents",
Subcategory: "delegation",
Description: "Route 4 tasks whose phrasing overlaps two roster descriptions each (repo docs vs web lookup, changelog vs prose docs, diff review vs security audit)",
Prompt: prompt,
// ground truth, per the delegRoster descriptions:
// task1 -> docs-writer: it writes/updates prose documentation
// ("READMEs, guides, design docs") from repository content.
// web-researcher is wrong: "Does not read or write anything in the
// repository" and no external search is needed.
// task2 -> web-researcher: the answer lives on the vendor's public
// page, "Searches the public web and summarizes external
// information"; docs-writer is the distractor ("page" sounds like
// documentation) but the task reads the web, not the repo.
// task3 -> release-manager: "Manages version tagging, changelog
// generation, and release publishing" covers exactly the phrasing
// (generate changelog + tag). docs-writer is the distractor:
// release notes here are the release process, not a guide.
// task4 -> security-reviewer: "Audits code or config for security
// vulnerabilities"; code-reviewer is the distractor ("before it
// merges" sounds like PR review) but code-reviewer reviews
// "correctness, style, and risk", while the stated goal is
// vulnerabilities.
Eval: eval.Mean(
eval.JSONField[string]("task1", "docs-writer"),
eval.JSONField[string]("task2", "web-researcher"),
eval.JSONField[string]("task3", "release-manager"),
eval.JSONField[string]("task4", "security-reviewer"),
),
}
}
// satPGCountStarFixture is the orders/line-items data for the join
// fan-out trap.
const satPGCountStarFixture = `CREATE TABLE orders (id bigint PRIMARY KEY, region text NOT NULL);
CREATE TABLE order_items (id bigint PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders(id));
INSERT INTO orders (id, region) VALUES
(1001, 'eu'), (1002, 'eu'), (1003, 'us'),
(1004, 'eu'), (1005, 'ap'), (1006, 'eu');
INSERT INTO order_items (id, order_id) VALUES
(9001, 1001), (9002, 1001), (9003, 1001),
(9004, 1002),
(9005, 1003), (9006, 1003), (9007, 1003), (9008, 1003), (9009, 1003),
(9010, 1004), (9011, 1004),
(9012, 1005);`
// satPGCountStarTrapTest: COUNT(*) over an INNER JOIN counts line items,
// not orders, and the join silently drops the order with zero items. Every
// number is derivable from the fixture, so the evaluator never needs to
// re-run SQL.
func satPGCountStarTrapTest() testkit.Test {
prompt := `Here is a PostgreSQL schema with data:
` + "```sql\n" + satPGCountStarFixture + "\n```" + `
The business question is: how many ORDERS are in region 'eu'?
A developer writes:
` + "```sql\nSELECT count(*) FROM orders o JOIN order_items oi ON oi.order_id = o.id WHERE o.region = 'eu';\n```" + `
Respond with only a JSON object, no other text:
{"orders_in_eu": <number>, "rows_the_join_query_returns": <number>,
"count_star_answers_the_question": true or false}`
return testkit.Test{
ID: "pg-count-star-vs-join",
Category: "databases",
Subcategory: "postgres",
Description: "Reason about COUNT(*) over an INNER JOIN against a fixed fixture: join fan-out inflates the count and the join drops the childless order",
Prompt: prompt,
// ground truth: orders with region 'eu' are 1001, 1002, 1004, 1006
// (INSERTs above) = 4. The join emits one row per order_item:
// 1001 -> 3 items (9001-9003), 1002 -> 1 (9004), 1004 -> 2 (9010,
// 9011), and 1006 -> 0 items, so the INNER JOIN drops it entirely.
// count(*) therefore returns 3+1+2 = 6. 6 != 4, so the count(*)
// query does not answer the question (EXISTS or COUNT(DISTINCT
// o.id) would return 4).
Eval: eval.Mean(
eval.JSONField[int]("orders_in_eu", 4),
eval.JSONField[int]("rows_the_join_query_returns", 6),
eval.JSONField[bool]("count_star_answers_the_question", false),
),
}
}
// satPGTimeoutTrapTest: two sessions, two timeouts, one rule each. The
// near-miss is applying idle_in_transaction_session_timeout to a busy
// session (or statement_timeout to an idle one), and starting the idle
// clock at BEGIN instead of at the last statement's completion.
func satPGTimeoutTrapTest() testkit.Test {
prompt := `A PostgreSQL server is configured with:
` + "```\nstatement_timeout = '30s'\nidle_in_transaction_session_timeout = '5s'\n```" + `
Two sessions on this server:
- Session A: BEGIN at 11:00:00, runs statements until 11:00:40 (the last
one finishes then), and has been waiting for client input ever since.
The transaction is still open.
- Session B: BEGIN at 11:00:53, starts one long-running query at 11:00:55
which is still executing at 11:01:00.
Assume no client input arrives and no query finishes after 11:01:00.
Respond with only a JSON object, no other text:
{"session_a_killed_at": "HH:MM:SS", "session_a_rule": "...",
"session_b_killed_at": "HH:MM:SS", "session_b_rule": "..."}
where each time is in UTC and each rule is exactly one of:
"statement_timeout", "idle_in_transaction_timeout", "not_killed".`
return testkit.Test{
ID: "pg-timeout-trap",
Category: "databases",
Subcategory: "postgres",
Description: "Apply statement_timeout and idle_in_transaction_session_timeout to the right sessions: idle clock starts at the last statement's completion, not BEGIN, and never applies to a session executing a statement",
Prompt: prompt,
// ground truth: PostgreSQL's idle_in_transaction_session_timeout
// terminates a session "idle ... within an open transaction" -
// the clock starts when the session goes idle in transaction,
// which for A is 11:00:40 (its last statement finished then), so
// A is killed at 11:00:45 - not 11:00:05, since A was executing
// statements from 11:00:00 to 11:00:40. A is not subject to
// statement_timeout: no statement is running. B is idle in
// transaction only from 11:00:53 to 11:00:55 (2s, under the 5s
// cap) and then EXECUTES a statement, so the idle timeout no
// longer applies; statement_timeout bounds a single running
// statement: 11:00:55 + 30s = 11:01:25.
Eval: eval.Mean(
eval.JSONField[string]("session_a_killed_at", "11:00:45"),
eval.JSONField[string]("session_a_rule", "idle_in_transaction_timeout"),
eval.JSONField[string]("session_b_killed_at", "11:01:25"),
eval.JSONField[string]("session_b_rule", "statement_timeout"),
),
}
}
// satWebRobotsFixture carries two rule groups and a longest-match pair.
const satWebRobotsFixture = `User-agent: *
Disallow: /cdn-cgi/
Allow: /cdn-cgi/style.css
Sitemap: https://example.com/sitemap.xml
User-agent: GoogleOther
Disallow: /`
// satWebRobotsUAScopeTest: GoogleOther's rules do not apply to Googlebot,
// and within Googlebot's group the longest path match decides. The
// near-miss is answering from the GoogleOther block alone, and answering
// the Allow/Disallow pair by document order instead of path length.
func satWebRobotsUAScopeTest() testkit.Test {
prompt := `A site serves this robots.txt at https://example.com/robots.txt:
` + "```\n" + satWebRobotsFixture + "\n```" + `
Consider a GET request for https://example.com/cdn-cgi/style.css.
Respond with only a JSON object, no other text:
{"googlebot_allowed": true or false, "googleother_allowed": true or false,
"deciding_rule_for_googlebot": "allow" or "disallow" or "no_rule"}`
return testkit.Test{
ID: "web-robots-ua-scope-trap",
Category: "research",
Subcategory: "web",
Description: "robots.txt group matching across two User-agent blocks plus longest-path-match precedence - a separate group's Disallow does not apply to the other crawler",
Prompt: prompt,
// ground truth: per RFC 9309, a crawler only applies the rule
// group(s) whose User-agent matches it, and within a group the
// path pattern with the LONGEST match wins. Googlebot does not
// match the GoogleOther group, so only the '*' group applies:
// the URL matches Disallow /cdn-cgi/ (9 chars) and Allow
// /cdn-cgi/style.css (18 chars); 18 > 9, so Allow wins ->
// googlebot_allowed true, deciding_rule_for_googlebot "allow".
// GoogleOther matches its own group, where "Disallow: /" covers
// every path -> googleother_allowed false.
Eval: eval.Mean(
eval.JSONField[bool]("googlebot_allowed", true),
eval.JSONField[bool]("googleother_allowed", false),
eval.JSONField[string]("deciding_rule_for_googlebot", "allow"),
),
}
}
// satWebHreflangSelfTest: an hreflang set missing its self-reference and
// x-default's sentinel status. The near-miss is counting the canonical
// link as the hreflang self-reference, and treating x-default as a
// language.
func satWebHreflangSelfTest() testkit.Test {
prompt := `The page https://example.com/en-us/ serves this head section:
` + "```html\n" + `<html lang="en-US">
<head>
<link rel="alternate" hreflang="en-AU" href="https://example.com/en-au/">
<link rel="alternate" hreflang="en-GB" href="https://example.com/en-gb/">
<link rel="alternate" hreflang="x-default" href="https://example.com/">
<link rel="canonical" href="https://example.com/en-us/">
</head>` + "\n```" + `
Google's hreflang rule: an hreflang group must include a self-reference -
an hreflang annotation whose href is the page itself - and every
annotation it lists must point back reciprocally.
Respond with only a JSON object, no other text:
{"missing_self_reference_for": "the BCP 47 language tag of the page that
has no hreflang annotation pointing at it", "self_reference_present":
true or false, "canonical_counts_as_hreflang_self_reference": true or
false, "x_default_is_a_language_tag": true or false}`
return testkit.Test{
ID: "web-hreflang-self-reference-trap",
Category: "research",
Subcategory: "web",
Description: "hreflang self-reference validation: the page's own language has no annotation, rel=canonical is a different mechanism, and x-default is a sentinel, not a BCP 47 tag",
Prompt: prompt,
// ground truth: the page is en-US (lang attribute and canonical
// URL agree). The hreflang annotations cover en-AU, en-GB,
// x-default only - no annotation has href
// https://example.com/en-us/, so the missing self-reference tag
// is "en-US" and self_reference_present is false.
// canonical_counts_as_hreflang_self_reference is false: rel
// -canonical deduplicates URLs for indexing; hreflang self-
// reference is a separate signal on rel=alternate link elements
// - the canonical matching the page URL does not satisfy it.
// x_default_is_a_language_tag is false: x-default is Google's
// sentinel for "none of the specific languages", not a BCP 47
// tag (it carries no language subtag; the other two do).
Eval: eval.Mean(
eval.JSONField[string]("missing_self_reference_for", "en-US"),
eval.JSONField[bool]("self_reference_present", false),
eval.JSONField[bool]("canonical_counts_as_hreflang_self_reference", false),
eval.JSONField[bool]("x_default_is_a_language_tag", false),
),
}
}
// satAppsecORMFixture shows three SQLAlchemy call sites; only one builds
// SQL by concatenation before wrapping it in text().
const satAppsecORMFixture = `from sqlalchemy import text
def get_user(conn, name, team):
sql = text("SELECT * FROM users WHERE name = :name AND team = :team")
return conn.execute(sql, {"name": name, "team": team}).fetchone()
def search_users(conn, pattern):
# callers pass the raw 'q' query-parameter straight in
sql = text("SELECT * FROM users WHERE bio LIKE '%" + pattern + "%'")
return conn.execute(sql).fetchall()
def list_orders(conn, status):
return conn.execute(
text("SELECT * FROM orders WHERE status = :s"), {"s": status}
).fetchall()`
// satAppsecORMTrapTest: SQLAlchemy text() is a wrapper, not a sanitizer -
// the concatenation already fused attacker input into the SQL string
// before text() ever sees it. The near-miss is "ORM == safe".
func satAppsecORMTrapTest() testkit.Test {
prompt := `Flask application (the 'q' request parameter is attacker-controlled):
` + "```python\n" + satAppsecORMFixture + "\n```" + `
Respond with only a JSON object, no other text:
{"injectable_function": "one of get_user | search_users | list_orders",
"text_wrapper_neutralizes_concatenation": true or false}`
return testkit.Test{
ID: "sec-orm-interpolation-trap",
Category: "security",
Subcategory: "appsec",
Description: "Spot SQL injection inside a SQLAlchemy text() call built by string concatenation - the ORM wrapper neutralizes nothing once the string is already fused",
Prompt: prompt,
// ground truth: get_user and list_orders pass user values as
// bound parameters (:name/:team, :s) - the driver sends values
// out-of-band, so no injection. search_users builds the SQL
// string with + pattern + BEFORE text() wraps it: by the time
// text() sees it, the attacker's input is part of the statement
// (pattern = "x%' OR 1=1 -- " breaks out of the LIKE literal).
// text() marks a string as raw SQL for the ORM; it never
// parameterizes or escapes - text_wrapper_neutralizes_
// concatenation is false.
Eval: eval.Mean(
eval.JSONField[string]("injectable_function", "search_users"),
eval.JSONField[bool]("text_wrapper_neutralizes_concatenation", false),
),
}
}
// satAppsecPOSTFixture shows a logout control where the visible anchor is
// a decoy - the actual request is the auto-submitted POST form.
const satAppsecPOSTFixture = `<a href="#" onclick="document.getElementById('f').submit(); return false;">
Log out
</a>
<form id="f" action="/api/account/logout" method="post">
<input type="hidden" name="confirm" value="true">
</form>`
// satAppsecPOSTTrapTest: the app switched a token-less state-changing
// endpoint from GET to POST as its whole CSRF mitigation. Tests whether
// the model knows POST alone is not a defense (cross-site <form
// method=post> auto-submits with cookies), even when the page only ever
// submits it via JavaScript.
func satAppsecPOSTTrapTest() testkit.Test {
prompt := `A session-authenticated web app renders this logout control. The session
cookie is SameSite=None; Secure (sent with every cross-site request), and
there is no CSRF token anywhere in the request.
` + "```\n" + satAppsecPOSTFixture + "\n```" + `
The developer's note: "I moved logout from GET to POST, so CSRF no longer
applies - only JavaScript on THIS page submits the form."
Assume an attacker page on evil.example hosts its own form with
action="https://app.example/api/account/logout", method="post". The victim
visits evil.example but clicks nothing and types nothing there.
Respond with only a JSON object, no other text:
{"attacker_form_reaches_logout": true or false,
"post_method_alone_prevents_csrf": true or false,
"attacker_needs_javascript": true or false}`
return testkit.Test{
ID: "sec-csrf-post-trap",
Category: "security",
Subcategory: "appsec",
Description: "CSRF after a GET-to-POST switch without a token: cross-site form auto-submit carries the cookie, so the method is not the defense; the attacker needs one line of JS, not knowledge of the page's DOM",
Prompt: prompt,
// ground truth: SameSite=None means the browser attaches the
// session cookie to the attacker's cross-site POST, and there is
// no token, so the request is accepted as the victim ->
// attacker_form_reaches_logout true; the server authenticates by
// cookie, not by which page submitted the form.
// post_method_alone_prevents_csrf false: method only restricts
// bookmark/simple-link attacks; a hosted <form method=post>
// replays the state change. (With SameSite=Lax a cross-site POST
// would instead drop the cookie - the prompt pins SameSite=None
// so the answer is forced.)
// attacker_needs_javascript true: the prompt pins "clicks nothing",
// and without JavaScript a hosted form only submits when the victim
// presses its own submit button - a cross-site <img>/<a> cannot POST
// with the cookie context this request needs, and <meta
// http-equiv="refresh"> cannot carry a POST body. The silent attack
// requires form.submit().
Eval: eval.Mean(
eval.JSONField[bool]("attacker_form_reaches_logout", true),
eval.JSONField[bool]("post_method_alone_prevents_csrf", false),
eval.JSONField[bool]("attacker_needs_javascript", true),
),
}
}
// satRotationFixture gives the three durations for the rotation
// deadline math.
const satRotationFixture = `Internal CA certificate expires: 2026-09-30 18:00 UTC
Worst realistic time to mint a replacement cert once rotation starts: 10 h
After the new cert is deployed, the reverse proxy's graceful-reload
window (old workers serve the old cert for up to this long after the
reload is issued): 6 h`
// satSecretsRotationMathTest: latest safe start time = expiry minus the
// sum of both sequential windows. The near-miss is subtracting only the
// minting time and forgetting the propagation tail.
func satSecretsRotationMathTest() testkit.Test {
prompt := `Certificate rotation facts for an internal service:
` + "```\n" + satRotationFixture + "\n```" + `
Respond with only a JSON object, no other text:
{"total_lead_time_hours": <number>, "latest_start": "YYYY-MM-DD HH:MM",
"safe_if_started_2026_09_30_08_00": true or false}
(Use UTC throughout. latest_start is the latest time rotation can START
and still have the old certificate fully replaced everywhere before it
expires.)`
return testkit.Test{
ID: "sec-rotation-window-math",
Category: "security",
Subcategory: "secrets",
Description: "Certificate rotation deadline math: lead time is minting plus propagation in series, and starting late leaves the old cert serving past expiry",
Prompt: prompt,
// ground truth: the two windows are sequential - a replacement
// can only start propagating once it exists: 10 h + 6 h = 16 h
// worst-case lead time. 2026-09-30 18:00 minus 16 h =
// 2026-09-30 02:00 latest start. Starting at 08:00 finishes at
// the worst at 08:00 + 16 h = 2026-10-01 00:00, six hours after
// the old cert expired -> not safe.
Eval: eval.Mean(
eval.JSONField[int]("total_lead_time_hours", 16),
eval.JSONField[string]("latest_start", "2026-09-30 02:00"),
eval.JSONField[bool]("safe_if_started_2026_09_30_08_00", false),
),
}
}
// satErrorPathFixture traces a password hash from a raise through an
// error handler into a log line, while the client only sees a generic
// response body.
const satErrorPathFixture = `1| def login(user, password):
2| row = db.get_user(user)
3| if not verify(password, row.hash):
4| raise AuthError(f"invalid credentials for user={user} "
5| f"hash={row.hash}")
6| return token_for(user)
7|
8| @app.errorhandler(AuthError)
9| def on_auth_error(exc):
10| app.logger.error(str(exc))
11| return "login failed", 401`
// satSecretsErrorPathTest: the secret (password hash) is interpolated at
// the raise, survives the handler's str(exc), and lands in the log even
// though the HTTP response is sanitized. The near-miss is "the client
// never sees it, so it's fine", and the reverse trap of flagging the
// attacker-supplied username as the secret.
func satSecretsErrorPathTest() testkit.Test {
prompt := `Flask login flow (numbers are source lines):
` + "```\n" + satErrorPathFixture + "\n```" + `
row.hash is the stored password hash (sensitive). user comes straight
from the login request body.
Respond with only a JSON object, no other text:
{"hash_reaches_server_logs": true or false, "hash_visible_in_http_response":
true or false, "sensitive_value_in_message": "one of user | hash | none"}`
return testkit.Test{
ID: "sec-error-path-leak-trap",
Category: "security",
Subcategory: "secrets",
Description: "Trace a password hash through raise -> errorhandler -> logger.error: the client response is sanitized but the log line is not, and the echoed user field is attacker-known",
Prompt: prompt,
// ground truth: the raise on lines 4-5 interpolates row.hash into
// the exception message; the handler on line 10 logs str(exc) -
// the full message - so the hash reaches the server logs: true.
// The handler returns only the literal "login failed" (line 11),
// never str(exc), so the hash is not visible in the HTTP
// response: false. Between user (attacker-supplied in the request
// body, already known to its owner) and hash (sensitive), the
// sensitive interpolated value is the hash.
Eval: eval.Mean(
eval.JSONField[bool]("hash_reaches_server_logs", true),
eval.JSONField[bool]("hash_visible_in_http_response", false),
eval.JSONField[string]("sensitive_value_in_message", "hash"),
),
}
}
+400
View File
@@ -0,0 +1,400 @@
package tests
import (
"context"
"testing"
)
// Each saturation test gets a table pinning: the full-credit answer, the
// trap answer (the near-miss the test exists to catch), and a garbage
// answer. Expected partial scores follow from eval.Mean's per-field mean.
func TestSatDelegCapacityTrapTest_Eval(t *testing.T) {
tc := satDelegCapacityTrapTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct: main thread, names busy specialist",
response: `{"best_next_step":"handle_in_main_thread","specialist_this_task_belongs_to":"devops-debugger"}`,
want: 1,
},
{
name: "trap: force-fits the generalist builder",
response: `{"best_next_step":"dispatch_code_writer","specialist_this_task_belongs_to":"devops-debugger"}`,
want: 0.5,
},
{
name: "trap: orchestrator for a single task",
response: `{"best_next_step":"dispatch_orchestrator","specialist_this_task_belongs_to":"orchestrator"}`,
want: 0,
},
{
name: "correct step but wrong owning specialist",
response: `{"best_next_step":"handle_in_main_thread","specialist_this_task_belongs_to":"code-writer"}`,
want: 0.5,
},
{
name: "wait-it-out answer",
response: `{"best_next_step":"wait_for_specialist","specialist_this_task_belongs_to":"devops-debugger"}`,
want: 0.5,
},
{name: "garbage", response: `not json`, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSatDelegNearMissRosterTest_Eval(t *testing.T) {
tc := satDelegNearMissRosterTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "all correct",
response: `{"task1":"docs-writer","task2":"web-researcher","task3":"release-manager","task4":"security-reviewer"}`,
want: 1,
},
{
name: "trap: web-researcher for the repo guide, docs-writer for the pricing page",
response: `{"task1":"web-researcher","task2":"docs-writer","task3":"release-manager","task4":"security-reviewer"}`,
want: 0.5,
},
{
name: "trap: docs-writer for release notes, code-reviewer for the audit",
response: `{"task1":"docs-writer","task2":"web-researcher","task3":"docs-writer","task4":"code-reviewer"}`,
want: 0.5,
},
{
name: "all swapped to the distractors",
response: `{"task1":"web-researcher","task2":"docs-writer","task3":"docs-writer","task4":"code-reviewer"}`,
want: 0,
},
{
name: "case-insensitive correct",
response: `{"task1":"Docs-Writer","task2":"Web-Researcher","task3":"Release-Manager","task4":"Security-Reviewer"}`,
want: 1,
},
{name: "garbage", response: `["docs-writer"]`, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSatPGCountStarTrapTest_Eval(t *testing.T) {
tc := satPGCountStarTrapTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct",
response: `{"orders_in_eu": 4, "rows_the_join_query_returns": 6, "count_star_answers_the_question": false}`,
want: 1,
},
{
name: "numeric strings accepted",
response: `{"orders_in_eu": "4", "rows_the_join_query_returns": "6", "count_star_answers_the_question": "false"}`,
want: 1,
},
{
name: "trap: counts joined rows as orders",
response: `{"orders_in_eu": 6, "rows_the_join_query_returns": 6, "count_star_answers_the_question": true}`,
want: 0.3333333333333333,
},
{
name: "trap: forgets the childless order drops out of the inner join",
response: `{"orders_in_eu": 4, "rows_the_join_query_returns": 7, "count_star_answers_the_question": false}`,
want: 0.6666666666666666,
},
{name: "garbage", response: `4`, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSatPGTimeoutTrapTest_Eval(t *testing.T) {
tc := satPGTimeoutTrapTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct",
response: `{"session_a_killed_at":"11:00:45","session_a_rule":"idle_in_transaction_timeout","session_b_killed_at":"11:01:25","session_b_rule":"statement_timeout"}`,
want: 1,
},
{
name: "trap: idle clock started at BEGIN",
response: `{"session_a_killed_at":"11:00:05","session_a_rule":"idle_in_transaction_timeout","session_b_killed_at":"11:01:25","session_b_rule":"statement_timeout"}`,
want: 0.75,
},
{
name: "trap: timeouts swapped between sessions",
response: `{"session_a_killed_at":"11:00:30","session_a_rule":"statement_timeout","session_b_killed_at":"11:00:58","session_b_rule":"idle_in_transaction_timeout"}`,
want: 0,
},
{
name: "two fields right",
response: `{"session_a_killed_at":"11:00:45","session_a_rule":"idle_in_transaction_timeout","session_b_killed_at":"11:01:00","session_b_rule":"not_killed"}`,
want: 0.5,
},
{name: "garbage", response: `no json here`, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSatWebRobotsUAScopeTest_Eval(t *testing.T) {
tc := satWebRobotsUAScopeTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct: longest match wins for googlebot, group isolation for GoogleOther",
response: `{"googlebot_allowed": true, "googleother_allowed": false, "deciding_rule_for_googlebot": "allow"}`,
want: 1,
},
{
name: "trap: GoogleOther's disallow applied to googlebot too",
response: `{"googlebot_allowed": false, "googleother_allowed": false, "deciding_rule_for_googlebot": "disallow"}`,
want: 0.3333333333333333,
},
{
name: "trap: wildcard group treated as absent",
response: `{"googlebot_allowed": true, "googleother_allowed": true, "deciding_rule_for_googlebot": "allow"}`,
want: 0.6666666666666666,
},
{name: "garbage", response: `allow`, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSatWebHreflangSelfTest_Eval(t *testing.T) {
tc := satWebHreflangSelfTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct",
response: `{"missing_self_reference_for":"en-US","self_reference_present":false,"canonical_counts_as_hreflang_self_reference":false,"x_default_is_a_language_tag":false}`,
want: 1,
},
{
name: "tag casing normalized",
response: `{"missing_self_reference_for":"en-us","self_reference_present":false,"canonical_counts_as_hreflang_self_reference":false,"x_default_is_a_language_tag":false}`,
want: 1,
},
{
name: "trap: canonical counted as the self-reference",
response: `{"missing_self_reference_for":"none","self_reference_present":true,"canonical_counts_as_hreflang_self_reference":true,"x_default_is_a_language_tag":false}`,
want: 0.25,
},
{
name: "trap: x-default treated as a language",
response: `{"missing_self_reference_for":"en-US","self_reference_present":false,"canonical_counts_as_hreflang_self_reference":false,"x_default_is_a_language_tag":true}`,
want: 0.75,
},
{name: "garbage", response: `en-US`, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSatAppsecORMTrapTest_Eval(t *testing.T) {
tc := satAppsecORMTrapTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct",
response: `{"injectable_function": "search_users", "text_wrapper_neutralizes_concatenation": false}`,
want: 1,
},
{
name: "trap: ORM assumed safe everywhere",
response: `{"injectable_function": "none", "text_wrapper_neutralizes_concatenation": true}`,
want: 0,
},
{
name: "right function, wrong mechanism",
response: `{"injectable_function": "search_users", "text_wrapper_neutralizes_concatenation": true}`,
want: 0.5,
},
{name: "garbage", response: `search_users`, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSatAppsecPOSTTrapTest_Eval(t *testing.T) {
tc := satAppsecPOSTTrapTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct",
response: `{"attacker_form_reaches_logout": true, "post_method_alone_prevents_csrf": false, "attacker_needs_javascript": true}`,
want: 1,
},
{
name: "trap: POST assumed to prevent CSRF",
response: `{"attacker_form_reaches_logout": false, "post_method_alone_prevents_csrf": true, "attacker_needs_javascript": true}`,
want: 0.3333333333333333,
},
{
name: "trap: attacker blocked for lacking the page's JS",
response: `{"attacker_form_reaches_logout": false, "post_method_alone_prevents_csrf": false, "attacker_needs_javascript": false}`,
want: 0.3333333333333333,
},
{name: "garbage", response: `{"a":1}`, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSatSecretsRotationMathTest_Eval(t *testing.T) {
tc := satSecretsRotationMathTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct",
response: `{"total_lead_time_hours": 16, "latest_start": "2026-09-30 02:00", "safe_if_started_2026_09_30_08_00": false}`,
want: 1,
},
{
name: "trap: minting time only, propagation forgotten",
response: `{"total_lead_time_hours": 10, "latest_start": "2026-09-30 08:00", "safe_if_started_2026_09_30_08_00": true}`,
want: 0,
},
{
name: "sum right, start-time arithmetic wrong",
response: `{"total_lead_time_hours": 16, "latest_start": "2026-09-30 02:00", "safe_if_started_2026_09_30_08_00": true}`,
want: 0.6666666666666666,
},
{name: "garbage", response: `16 hours`, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSatSecretsErrorPathTest_Eval(t *testing.T) {
tc := satSecretsErrorPathTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct",
response: `{"hash_reaches_server_logs": true, "hash_visible_in_http_response": false, "sensitive_value_in_message": "hash"}`,
want: 1,
},
{
name: "trap: sanitized response assumed to mean no leak",
response: `{"hash_reaches_server_logs": false, "hash_visible_in_http_response": false, "sensitive_value_in_message": "hash"}`,
want: 0.6666666666666666,
},
{
name: "trap: attacker-known user flagged instead of the hash",
response: `{"hash_reaches_server_logs": true, "hash_visible_in_http_response": false, "sensitive_value_in_message": "user"}`,
want: 0.6666666666666666,
},
{
name: "trap: leak judged through the log AND the response",
response: `{"hash_reaches_server_logs": true, "hash_visible_in_http_response": true, "sensitive_value_in_message": "hash"}`,
want: 0.6666666666666666,
},
{name: "garbage", response: `hash`, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
+31 -11
View File
@@ -23,29 +23,49 @@ func registerSecurityCryptoTests(r *testkit.Registry) {
r.Register(secHashVsEncryptPIITest())
}
// secWeakPasswordHashPattern matches a mention of a fast, general-purpose
// hash (MD5, plain SHA-1/256/512) - the ones that must never be used
// directly for password storage because they lack a tunable work factor.
var secWeakPasswordHashPattern = regexp.MustCompile(`(?i)\b(md5|sha-?1|sha-?256|sha-?512)\b`)
// secWeakHashEndorsementPattern matches a weak-hash mention in an
// endorsement-shaped position — a verb recommending it ("hash with MD5",
// "just hash the password with SHA-256"), or a positive predicate about it
// ("SHA-256 is a fine choice") — rather than every mention. The guard then
// still requires a negation cue near such a mention (see
// secNoUnnegatedMention).
//
// Triggering on any mention is too strict: a fully correct live answer
// (probe, 2026-08-29) lists the discouraged hashes as bare bullets under a
// negated heading ("Do **not** use fast general-purpose ... including:") —
// the cue sits on the heading, but the negation window stops at the line
// break and the heading is more than a window-width away, so each bullet
// read as an unnegated endorsement and cost the model half the score.
// A bare bullet names, it does not recommend — the predicate branch still
// catches an endorsed bullet ("- **MD5** is fine for passwords").
var secWeakHashEndorsementPattern = regexp.MustCompile("(?i)(?:" +
// a verb recommending it: "hash with MD5", "store passwords hashed with
// MD5", "just hash the password with SHA-256" — the filler loop only
// bridges recommendation boilerplate, never another algorithm name, so
// "prefer argon2 over md5" (a correct answer) is not an endorsement.
"(?:hash(?:es|ed|ing)?|store|stores|stored|crypt|encrypt|use|using|used|recommend|recommends|prefer|prefers|should(?:\\s+(?:also|just))?|want)" +
"\\s+(?:(?:it|its|the|a|an|password|passwords|user|users|hash|hashed|with|using|as|via|by|plain|raw|fast|for|directly|simply|just|them|to)[ \\t]*\\n?[ \\t]*(?:-[ \\t]*)?){0,6}(?:md5|sha-?1|sha-?256|sha-?512)" +
`|(?:^|[^A-Za-z0-9_-])(?:md5|sha-?1|sha-?256|sha-?512)[^.;\n]{0,25}?\b(?:is|are|was|were|seems?|looks?|works?|would\s+be|should\s+be)\s+(?:a\s+|just\s+|still\s+|totally\s+|actually\s+)?(?:fine|good|great|safe|ok|okay|acceptable|perfect|sufficient|enough|best|better|the\s+(?:right|best)\s+choice)` +
")")
// secWeakHashNamedAndNegated scores full credit only when the response BOTH
// safely negates every mention of a weak/fast general-purpose hash (or
// never mentions one) AND actually names at least one of them by name
// (md5/sha-1/sha-256/sha-512) - C11's fix folded into a single AND rather
// than an independent extra ContainsAny term, so a wrong answer that
// avoids endorsing any weak/fast general-purpose hash with no nearby
// negation (or never mentions one) AND actually names at least one of them
// by name (md5/sha-1/sha-256/sha-512) - C11's fix folded into a single AND
// rather than an independent extra ContainsAny term, so a wrong answer that
// recommends SHA-256 outright cannot earn partial credit merely for having
// typed "SHA-256" while doing so.
func secWeakHashNamedAndNegated() eval.Evaluator {
named := eval.ContainsAny("md5", "sha-1", "sha1", "sha-256", "sha256")
negated := secNoUnnegatedMention(secWeakPasswordHashPattern)
negated := secNoUnnegatedMention(secWeakHashEndorsementPattern)
return eval.EvaluatorFunc(func(ctx context.Context, response string) eval.Score {
if n := negated.Evaluate(ctx, response); n.Value != 1 {
return eval.Score{Value: 0, Detail: "unnegated mention of a discouraged hash: " + n.Detail}
return eval.Score{Value: 0, Detail: "endorses a discouraged hash without negating it: " + n.Detail}
}
if m := named.Evaluate(ctx, response); m.Value != 1 {
return eval.Score{Value: 0, Detail: "never names a weak hash by name"}
}
return eval.Score{Value: 1, Detail: "a weak hash is named and every mention is safely negated"}
return eval.Score{Value: 1, Detail: "a weak hash is named and none is endorsed without negation"}
})
}
+14
View File
@@ -28,6 +28,20 @@ func TestSecPasswordHashChoiceTest_Eval(t *testing.T) {
response: "bcrypt is fine. Don't use SHA-256 alone, it lacks a work factor.",
want: 1,
},
{
// Regression from the live probe (2026-08-29): a fully correct
// answer names the discouraged hashes as bullets under a negated
// heading ("Do **not** use ... including:"). The heading ends in
// ":" - not a clause boundary - so its cue must reach every bullet.
name: "correct probe answer: discouraged hashes as bullets under a negated heading",
response: "Use Argon2id (or bcrypt with cost >= 12) to store passwords.\n\n" +
"Do **not** use fast general-purpose cryptographic hashes for storing passwords, including:\n" +
"- **MD5**\n" +
"- **SHA-1**\n" +
"- **SHA-256**\n" +
"- **SHA-512**",
want: 1,
},
{
name: "wrong: recommends SHA-256 unnegated",
response: "Just hash the password with SHA-256 before storing it.",