SUB/WAVE LLM model benchmark
Two harnesses, one job: measure how well a provider + model handles the
station's real LLM calls before trusting it on air.
llm-bench (controller/scripts/llm-bench/, npm run llm-bench) — the
primary harness. A matrix runner over every on-air call kind: pool picks
(pickNextTrack), agent picks (djAgentPick), pool + agent segments
(generateSegment / djAgentSegment), request matching (matchRequest /
djAgentRequest), the free-text scripts (intro, link, station ID, hourly),
banter, and the programme family (plan, beats, exchanges). Scores
reliability + deterministic rule checks, prints a per-model comparison
table, writes a diffable JSON report.
picker-test.mjs — the legacy picker-only deep-dive. Still useful for
high-iteration picker runs comparable with historical results, and it has a
bundled orchestration script (see the legacy section at the end).
Both import the live prompts and schemas from src/ (never copies), fake
everything external (library, tools, weather/news data), and override
provider/model only inside their own short-lived process — the live
controller's configured model is never touched.
The two things that surprise people
- Routing matters as much as the model. The same model can pass through
one provider and fail through another, because each
@ai-sdk/* provider
translates tools / structured output differently. Canonical case:
deepseek-v4-flash scored 0/4 via the deepseek direct provider but
4/4 via openrouter. Always benchmark through the routing you'll
actually deploy — "is model X good?" is the wrong question; "is
provider+X good?" is the right one.
- The stress scenarios are where truth lives. Easy cells make every model
look fine. The verdict cells are:
djAgentPick/long-context (full prompt +
tool loop), generateSegment/dull-weather (the model must decline to air
— small models botch the silence encoding; a huge wall-clock here means the
structured-output retry rescued a failed first attempt), and the
multi-voice JSON kinds (generateBanter, generateProgrammeExchange).
When NOT to use this
- Changing the live model — that's an admin Settings change, not this skill.
- Diagnosing general runtime behaviour —
subwave-log-analysis.
- Starting/stopping the stack —
subwave-control.
Running llm-bench
Runs on the host from a repo clone (it imports TS source via tsx; prod
containers ship only compiled dist/). The running stack is not required —
keys resolve from state/secrets.env and controller/.env automatically
(existing env vars win). If a key exists only inside a prod container's env,
copy it out first: export OPENROUTER_API_KEY=$(docker exec sub-wave-controller printenv OPENROUTER_API_KEY).
cd <repo>/controller
npm run llm-bench -- \
--models openrouter:google/gemma-4-31b-it,ollama:qwen3:8b \
--iterations 3
Flags:
--models (required) — comma list of provider:model, split on the FIRST
colon, so Ollama tags keep theirs (ollama:qwen3:8b). Providers: ollama |
openai-compatible | anthropic | openai | google | deepseek | openrouter |
requesty | gateway.
--kinds — groups (pick,segment,request,scripts,banter,programme) or
exact kind names. Default: all.
--modes — pool,agent (default both). Mode-independent kinds run once
either way. If the operator runs pool mode live (Agentic picker off),
--modes pool is the honest benchmark — agent cells they'll never use
just add noise and cost.
--iterations — per scenario. 1 to smoke, 3 to screen (default), 5+ to
confirm a winner. Full matrix ≈ 32 scenario cells, so ~100 calls per model
at 3 iterations.
--out — report path (default scripts/llm-bench/reports/<ts>.json,
gitignored).
A full run takes many minutes (every call is a real model call, agent cells
can take 45 s each). Run it in the background with a long timeout and don't
poll; OLLAMA_URL=http://localhost:11434 translates the container-internal
Ollama address for host runs.
Reading llm-bench results
Each run is ok, violation (call succeeded, named rule checks failed), or
thrown (bucketed: no-object-generated, timeout, unreachable,
thrown). The summary prints pass% + p50 latency per cell, then per-model
histograms of rule failures and thrown buckets.
Judge in this order:
- Thrown rate — a model that can't produce the schema at all is out.
no-object-generated on structured kinds = weak structured output;
everything landing at ~45 s = too slow for the loop, not incapable.
unreachable = provider/config problem, not the model — fix and re-run.
- Violations — the named rules tell you the failure character:
hallucinated-id (invents track ids — dangerous), banned-phrase:* /
stage-direction:* / wrapping-quotes (would be read aloud by TTS),
digits-in-spoken-time / clock-leak (hourly/link discipline),
opener-repeat (ignores anti-repeat lists), unusable-exchange /
single-voice (can't hold multi-voice JSON), variety:same-artist
(editorial-pressure miss — informative, not disqualifying).
- Latency — p50 per cell; a pick has a whole track of slack so even 20 s
works, but segments and links air sooner. Watch for retry-rescued cells
(pass with 100×-median wall clock).
- Pool vs agent gap — a model that's clean in pool cells but falls apart
in agent cells is a "candidate pool" model; that's a valid deployment, just
say so in the recommendation.
Reports are JSON with every run record — keep old ones to diff a model across
prompt changes or across model updates.
Producing the recommendation
- A compact table: one row per provider+model, columns for thrown rate,
violation rate, p50 latency, and the dominant failure.
- A clear call naming the best provider + model + mode ("gemma via
OpenRouter, pool mode — clean structured output, agent cells time out").
- Call out disqualifiers and deferrals (5xx throughout = re-test later).
Do not apply the change. The operator sets llm.provider + llm.model
(and the Agentic picker toggle) in admin Settings — those are global, their
call.
Legacy: picker-test.mjs deep-dive
For picker-only, high-iteration runs comparable with historical results:
# DEV stack (src bind-mounted in container):
docker exec sub-wave-controller npx tsx scripts/picker-test.mjs <provider> <model> [iterations] [short|long]
# Host (works regardless of stack):
( cd <repo>/controller && STATE_DIR=<repo>/state OLLAMA_URL=http://localhost:11434 \
npx tsx scripts/picker-test.mjs <provider> <model> [iterations] [short|long] )
long is the verdict mode (realistic session window); short is a sanity
check. The bundled scripts/assess-models.sh (in this skill's directory) runs
one or more models in both modes, auto-detects dev/prod, and summarises event-
log failures: assess-models.sh <provider> [iterations] <model>... — with
ollama and no models it discovers and tests everything installed.
Failure strings glossary (from the event log, kind: pickerTest):
agent did not call the done tool before stopping — model ignored the
tool protocol. Check routing before rejecting the model (see surprise #1).
- Every failure at ~`agentTimeoutMs
(45000) withtools=0` — a latency
failure, not incapability; the deadline aborted it mid-loop.
Failed after N attempts … Internal Server Error — provider outage;
re-run later, don't reject the model on this.
hallucinated-id / no-object-generated — weak structured output.
Cross-check live behaviour in /admin/debug → llm.recentCalls: the via
field shows which path produced each result (ai-sdk:agent:native = clean
native path; …:recovery = rescued by the done-tool recovery; a thrown
djAgentPick falls back to the pool picker, so the station never goes
silent).
Notes
- Both harnesses append telemetry rows to the controller event log
(
state/logs/events-*.jsonl) and, if LLM_DEBUG_RAW is on, raw request
bodies to state/logs/llm-debug.log. Harmless, filterable.
- The docs page mapping every call kind to its code lives at
docs/llm-calls.md; the harness design spec at
docs/superpowers/specs/2026-07-09-llm-bench-design.md.
1---2name: subwave-llm-bench3description: Benchmark and compare LLM models for SUB/WAVE's on-air calls — track picks, segments, listener requests, DJ scripts, banter, and programme beats — in both candidate-pool and agent modes, using controller/scripts/llm-bench (the matrix harness) or the legacy picker-only controller/scripts/picker-test.mjs. Use this skill whenever the user wants to assess, benchmark, compare, or test which LLM model to run the station on — phrases like "which model should I use", "benchmark the picker / the DJ / this model", "test these models", "is <model> good enough for the radio", "compare the ollama models", "will a small model work", "run llm-bench", "run picker-test", or when diagnosing slow/failing djAgentPick / djAgentSegment / generate* calls and the model choice is suspect. Trigger it even if the user doesn't name a harness — any request to evaluate model reliability or choose a model for SUB/WAVE belongs here. This skill only measures and recommends; it does NOT change the live station's configured model.4---56# SUB/WAVE LLM model benchmark78Two harnesses, one job: measure how well a **provider + model** handles the9station's real LLM calls before trusting it on air.1011- **`llm-bench`** (`controller/scripts/llm-bench/`, `npm run llm-bench`) — the12 primary harness. A matrix runner over **every on-air call kind**: pool picks13 (`pickNextTrack`), agent picks (`djAgentPick`), pool + agent segments14 (`generateSegment` / `djAgentSegment`), request matching (`matchRequest` /15 `djAgentRequest`), the free-text scripts (intro, link, station ID, hourly),16 banter, and the programme family (plan, beats, exchanges). Scores17 reliability + deterministic rule checks, prints a per-model comparison18 table, writes a diffable JSON report.19- **`picker-test.mjs`** — the legacy picker-only deep-dive. Still useful for20 high-iteration picker runs comparable with historical results, and it has a21 bundled orchestration script (see the legacy section at the end).2223Both import the **live prompts and schemas from `src/`** (never copies), fake24everything external (library, tools, weather/news data), and override25provider/model **only inside their own short-lived process** — the live26controller's configured model is never touched.2728## The two things that surprise people29301. **Routing matters as much as the model.** The *same* model can pass through31 one provider and fail through another, because each `@ai-sdk/*` provider32 translates tools / structured output differently. Canonical case:33 `deepseek-v4-flash` scored **0/4 via the `deepseek` direct provider** but34 **4/4 via `openrouter`**. Always benchmark through the routing you'll35 actually deploy — "is model X good?" is the wrong question; "is36 provider+X good?" is the right one.372. **The stress scenarios are where truth lives.** Easy cells make every model38 look fine. The verdict cells are: `djAgentPick/long-context` (full prompt +39 tool loop), `generateSegment/dull-weather` (the model must *decline* to air40 — small models botch the silence encoding; a huge wall-clock here means the41 structured-output retry rescued a failed first attempt), and the42 multi-voice JSON kinds (`generateBanter`, `generateProgrammeExchange`).4344## When NOT to use this4546- Changing the live model — that's an admin Settings change, not this skill.47- Diagnosing general runtime behaviour — `subwave-log-analysis`.48- Starting/stopping the stack — `subwave-control`.4950## Running llm-bench5152Runs on the **host from a repo clone** (it imports TS source via `tsx`; prod53containers ship only compiled `dist/`). The running stack is not required —54keys resolve from `state/secrets.env` and `controller/.env` automatically55(existing env vars win). If a key exists only inside a prod container's env,56copy it out first: `export OPENROUTER_API_KEY=$(docker exec sub-wave-controller printenv OPENROUTER_API_KEY)`.5758```bash59cd <repo>/controller60npm run llm-bench -- \61 --models openrouter:google/gemma-4-31b-it,ollama:qwen3:8b \62 --iterations 363```6465Flags:6667- `--models` (required) — comma list of `provider:model`, split on the FIRST68 colon, so Ollama tags keep theirs (`ollama:qwen3:8b`). Providers: ollama |69 openai-compatible | anthropic | openai | google | deepseek | openrouter |70 requesty | gateway.71- `--kinds` — groups (`pick,segment,request,scripts,banter,programme`) or72 exact kind names. Default: all.73- `--modes` — `pool,agent` (default both). Mode-independent kinds run once74 either way. **If the operator runs pool mode live (Agentic picker off),75 `--modes pool` is the honest benchmark** — agent cells they'll never use76 just add noise and cost.77- `--iterations` — per scenario. 1 to smoke, 3 to screen (default), 5+ to78 confirm a winner. Full matrix ≈ 32 scenario cells, so ~100 calls per model79 at 3 iterations.80- `--out` — report path (default `scripts/llm-bench/reports/<ts>.json`,81 gitignored).8283A full run takes many minutes (every call is a real model call, agent cells84can take 45 s each). Run it in the background with a long timeout and don't85poll; `OLLAMA_URL=http://localhost:11434` translates the container-internal86Ollama address for host runs.8788## Reading llm-bench results8990Each run is `ok`, `violation` (call succeeded, named rule checks failed), or91`thrown` (bucketed: `no-object-generated`, `timeout`, `unreachable`,92`thrown`). The summary prints pass% + p50 latency per cell, then per-model93histograms of rule failures and thrown buckets.9495Judge in this order:96971. **Thrown rate** — a model that can't produce the schema at all is out.98 `no-object-generated` on structured kinds = weak structured output;99 everything landing at ~45 s = too slow for the loop, not incapable.100 `unreachable` = provider/config problem, not the model — fix and re-run.1012. **Violations** — the named rules tell you the failure *character*:102 `hallucinated-id` (invents track ids — dangerous), `banned-phrase:*` /103 `stage-direction:*` / `wrapping-quotes` (would be read aloud by TTS),104 `digits-in-spoken-time` / `clock-leak` (hourly/link discipline),105 `opener-repeat` (ignores anti-repeat lists), `unusable-exchange` /106 `single-voice` (can't hold multi-voice JSON), `variety:same-artist`107 (editorial-pressure miss — informative, not disqualifying).1083. **Latency** — p50 per cell; a pick has a whole track of slack so even 20 s109 works, but segments and links air sooner. Watch for retry-rescued cells110 (pass with 100×-median wall clock).1114. **Pool vs agent gap** — a model that's clean in pool cells but falls apart112 in agent cells is a "candidate pool" model; that's a valid deployment, just113 say so in the recommendation.114115Reports are JSON with every run record — keep old ones to diff a model across116prompt changes or across model updates.117118## Producing the recommendation1191201. A compact table: one row per provider+model, columns for thrown rate,121 violation rate, p50 latency, and the dominant failure.1222. A clear call naming the best **provider + model + mode** ("gemma via123 OpenRouter, pool mode — clean structured output, agent cells time out").1243. Call out disqualifiers and deferrals (5xx throughout = re-test later).125126Do **not** apply the change. The operator sets `llm.provider` + `llm.model`127(and the Agentic picker toggle) in admin Settings — those are global, their128call.129130## Legacy: picker-test.mjs deep-dive131132For picker-only, high-iteration runs comparable with historical results:133134```bash135# DEV stack (src bind-mounted in container):136docker exec sub-wave-controller npx tsx scripts/picker-test.mjs <provider> <model> [iterations] [short|long]137# Host (works regardless of stack):138( cd <repo>/controller && STATE_DIR=<repo>/state OLLAMA_URL=http://localhost:11434 \139 npx tsx scripts/picker-test.mjs <provider> <model> [iterations] [short|long] )140```141142`long` is the verdict mode (realistic session window); `short` is a sanity143check. The bundled `scripts/assess-models.sh` (in this skill's directory) runs144one or more models in both modes, auto-detects dev/prod, and summarises event-145log failures: `assess-models.sh <provider> [iterations] <model>...` — with146`ollama` and no models it discovers and tests everything installed.147148Failure strings glossary (from the event log, `kind: pickerTest`):149150- `agent did not call the done tool before stopping` — model ignored the151 tool protocol. Check routing before rejecting the model (see surprise #1).152- Every failure at ~`agentTimeoutMs` (45000) with `tools=0` — a latency153 failure, not incapability; the deadline aborted it mid-loop.154- `Failed after N attempts … Internal Server Error` — provider outage;155 re-run later, don't reject the model on this.156- `hallucinated-id` / `no-object-generated` — weak structured output.157158Cross-check live behaviour in `/admin/debug` → `llm.recentCalls`: the `via`159field shows which path produced each result (`ai-sdk:agent:native` = clean160native path; `…:recovery` = rescued by the done-tool recovery; a thrown161`djAgentPick` falls back to the pool picker, so the station never goes162silent).163164## Notes165166- Both harnesses append telemetry rows to the controller event log167 (`state/logs/events-*.jsonl`) and, if `LLM_DEBUG_RAW` is on, raw request168 bodies to `state/logs/llm-debug.log`. Harmless, filterable.169- The docs page mapping every call kind to its code lives at170 `docs/llm-calls.md`; the harness design spec at171 `docs/superpowers/specs/2026-07-09-llm-bench-design.md`.