# Benchmarks Measured, reproducible, and reported whether or not the number flatters the project. A tool that tells you what broke at 3 AM has to be honest about how often it is wrong. Last runs: 2026-09-09 and 2026-09-14 with `qwen2.5:7b` via Ollama, CPU only (no GPU); 2026-09-13/14 with `gemini-3.6-flash` through the OpenAI-compatible adapter (free tier). Cost $0.00 per alert in both. ## Method `python -m app.replay ` replays recorded scenarios through the real pipeline. Each scenario carries the alert plus the exact context a live cluster would have produced, so a run is deterministic and needs no cluster. Grading is by declared keywords, checked **against the stated root cause only**. Not an LLM judge, so the score is reproducible and anyone can see what counted. Grading deliberately ignores the evidence list. An earlier version accepted a keyword appearing anywhere in the hypothesis, and it scored a pass for an answer whose root cause was the misleading one the scenario was built to punish — the right word merely appeared in a cited log line. That inflated the hard-set score from 2/5 to 3/5. The engineer acts on the cause that is stated; if that is wrong, they go the wrong way regardless of what the evidence contains. ## Results | Set | Scenarios | qwen2.5:7b, CPU | gemini-3.6-flash, API | |---|---|---|---| | Easy — signal stated plainly in the context | 6 | **6/6**, 31.5 s avg | **6/6**, 7.2 s avg | | Hard — the obvious signal points the wrong way | 5 | **2/5**, 30.3 s avg | **5/5**, 7.6 s avg | Same pipeline, same prompt, same scenarios, one environment variable changed. ### 2026-09-14: the prompt asks for elimination first, and two scenarios are added The system prompt now spells out a method: name the obvious explanation, look for what contradicts it, discard it if contradicted; a symptom is not a cause when the thing it points at is shown healthy; when two things mismatch, suspect the one that changed. Two scenarios were added to the hard set: pod IP exhaustion (AWS VPC CNI, node looks healthy) and a config mismatch where both a wrong env var and a NetworkPolicy are visible and the error message names the wrong one. | Set | Scenarios | qwen2.5:7b, CPU (prompt v1) | qwen2.5:7b, CPU (prompt v2) | gemini-3.6-flash | |---|---|---|---|---| | Hard, original five | 5 | 2/5 | **3/5** (missing Secret now found) | 5/5 | | Hard, all seven | 7 | | **5/7**, 46 s avg | 6 of 7 distinct passes across two runs; the seventh never served | The Gemini column needs a caveat: the free tier answered 503 "high demand" and then 429 rate limits for most of the afternoon, so the seven-scenario run never completed in one pass. Across the two partial runs every scenario it did answer was correct (missing Secret, NetworkPolicy, rollout, sidecar, unrotated log, IP exhaustion); the config-mismatch scenario was never served. Reported as such rather than as 7/7. The replay harness gained `MINUS20_REPLAY_PAUSE_SECONDS` for metered tiers, and the adapter retries 429/503 twice with short delays. On the 7B model the prompt change moved one scenario from wrong to right and none the other way. Its config-mismatch answer, "DNS resolution failure for DB_HOST", passes the keyword grader by naming the variable and is still half wrong: it calls a symptom the cause. The grader counts it; the reader should not. ### 2026-10-14: next steps that change the cluster are marked, and a grader fix The prompt now asks for read-only checks first and for `REQUIRES APPROVAL: ` in front of any step that would change the cluster; code applies the same mark whatever the model does (`app/models.py`, CC-65), so the guarantee does not depend on the prompt. Rerun of the hard set on `qwen2.5:7b`, CPU: | Scenario | 2026-09-17 | 2026-10-14 (prompt with the approval rule) | |---|---|---| | 7 original hard scenarios | 5/7 | 5/7, same passes and misses | | Spot interruption storm | ❌ | ❌ "Redis connection issues" | | Pod Identity denied | ❌ | ❌ "AWS S3 PutObject permission denied" | | EBS volume stuck on a replaced node | ✅ "VolumeInUse by terminated instance" | graded ❌, "VolumeInUse error due to node replacement" | | **Total, as graded** | **6/10** | **5/10** | Read by hand, the EBS answer names the real cause: the node was replaced and the volume is still in use. The grader missed it because its keywords had "replaced" and not "replacement". The keyword list now includes "node replacement"; with it the run is 6/10, the same as before, so the approval rule cost no accuracy on this set. Both numbers are kept here because the fix was made after seeing the result. ### 2026-09-17: three AWS-shaped scenarios (hard set = 10) Where the comparison with AWS DevOps Agent will be made is on EKS, so the hard set gained three faults that only exist there, each with a plausible wrong answer sitting in plain sight: | Scenario | The bait | The cause | qwen2.5:7b, CPU | cloud model | |---|---|---|---|---| | Spot interruption storm | every app log says "lost connection to Redis"; Redis is healthy | the Spot node was reclaimed, Karpenter drained it, 30 pods restarted | ❌ "Redis connection issues", 69 s | pending (free-tier quota) | | Pod Identity denied | `AccessDenied` on S3 right after a deploy | the deploy renamed the ServiceAccount; the Pod Identity association points at the old name, the pod runs as the node role | ❌ "S3 PutObject permission denied", 73 s | pending | | EBS volume stuck | `Multi-Attach error`, looks like a storage bug | the managed node group replaced the node; the volume is still attached to the terminated instance's stale VolumeAttachment | ✅ "VolumeInUse by terminated instance", 87 s | pending | Local model on the AWS three: **1/3**, and the two misses are the same failure mode as before: the symptom in the application log is named as the cause even though the context shows the named component healthy (Redis answering, the policy unchanged and a different role in the error). Hard set overall on `qwen2.5:7b`: 6/10. The cloud column is filled in when a key with a usable quota is available; the scenarios are in `scenarios/hard/`. ### Hard set, case by case | Scenario | qwen2.5:7b said | Verdict | gemini-3.6-flash said | Verdict | |---|---|---|---|---| | OOM caused by a sidecar | "Memory pressure due to log-shipper container consuming excessive memory" | ✅ | "The log-shipper sidecar container in pod checkout-api…" | ✅ | | Volume full from an unrotated log | "Log files are filling up the PersistentVolume" | ✅ | "An unrotated application log file (/var/lib/postgresql/data/…)" | ✅ | | CrashLoop from a missing Secret | "failed database connection attempts" | ❌ took the bait | "The pod 'orders-api-…' is failing to start because [the Secret]…" | ✅ | | DNS failing because of a NetworkPolicy | "DNS resolution failure" | ❌ blamed DNS | "The newly applied NetworkPolicy 'demo-default-deny'…" | ✅ | | 5xx caused by a rollout | "Database timeout causing high 5xx error rate" | ❌ blamed the database | "The deployment of payments-api version v2.4.0 (replicaset …)" | ✅ | ## What the pattern says The two passes and the three failures split cleanly: - **It succeeds when the answer is present in the context as text.** `log-shipper` appears in the metrics; `app.log is 8.4GiB` appears in the logs. The model reads it and names it. - **It fails when the answer requires reasoning by elimination.** "The database is answering normally, *therefore* the database is not the cause." "CoreDNS is healthy, *therefore* something else is blocking resolution." In all three failures the disproving evidence was in the context and was not used. This was a limit of a 7B model, not of the pipeline, and the second run proves it: with nothing changed but `MINUS20_LLM_PROVIDER`, a current cloud model gets all five, in a quarter of the time. The three cases the 7B model failed were exactly the ones needing elimination ("the database answers normally, so it is not the database"), and the larger model does that reasoning unprompted. What that means in practice: the local, zero-egress backend is the right default for privacy and cost, and it handles the plainly-stated majority; for misleading incidents a cloud model is measurably better, and switching is one value. Prompt work for the small model ("rule out the obvious first") is still worth doing, and is the next item. ## How to read these numbers - Real incidents are mostly the easy kind: the signal is there and the cost is the twenty minutes of assembling it. That is what this automates. - On genuinely misleading incidents it is right about half the time, so it is an assistant, not an oracle. That is why every hypothesis ships with its evidence and the cheapest way to disprove it: checking a wrong answer takes seconds. - Nothing here has been measured against real production incidents. These scenarios were authored for this benchmark, and an author writing their own exam is a real limitation. - **The hard scenarios carry context the live collectors do not gather yet** (found 2026-10-15). The events collector keeps only the alerting pod's events in its namespace (`app/collectors.py`), and the Prometheus collector runs three fixed per-pod queries. Several hard scenarios rely on signals outside that: node events (`InstanceTerminating`, `NodeNotReady`), the health of a dependency (`redis_up`), rollout history, other pods. So the hard-set score measures reasoning over a context richer than what an installed Minus20 sees today; on a live cluster the same incident can score lower. Collecting the disproving evidence deterministically (node events, dependency health, recent rollouts) is the next accuracy work, and the hard set will be rerun on live-collectable context only, both numbers published. ## Reproduce ```bash cd services/analyzer-worker pip install -r requirements-dev.txt MINUS20_LLM_PROVIDER=ollama \ MINUS20_OLLAMA_MODEL=qwen2.5:7b \ MINUS20_LLM_TIMEOUT_SECONDS=300 \ python -m app.replay scenarios/hard ``` Swap `scenarios/hard` for the default directory to run the easy set. For the cloud run: ```bash MINUS20_LLM_PROVIDER=openai \ MINUS20_OPENAI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/ \ MINUS20_OPENAI_MODEL=gemini-3.6-flash \ MINUS20_OPENAI_API_KEY=... \ python -m app.replay scenarios/hard ``` Any OpenAI-compatible endpoint works the same way: `https://api.mistral.ai/v1` (EU, free Experiment tier), `https://api.deepseek.com` (the adapter's default), Groq, vLLM. Only the base URL, the model name and the key change. Note on the free tier: Google states that free-tier data may be used to improve its products. Fine for a benchmark on synthetic scenarios; not a production setting for real logs. The 2026-09-13 run was executed inside the cluster so the key never left it. ## Growing the set from real incidents "An author writing their own exam" is the limitation above. The way past it is the engineer's verdict: `/ok ` and `/wrong ` in the bot record what actually happened, and every such incident already holds the redacted context the model saw. So the store is an eval set: ```bash MINUS20_POSTGRES_DSN=postgres://... \ python -m app.replay --from-store --days 90 ``` replays every incident with a verdict through the current model and grades the new hypothesis against the verdict (`/ok` makes the recorded hypothesis the expectation, `/wrong` makes the engineer's cause the expectation). Add `--export scenarios/from-verdicts` to write them out as scenario files, the same shape as `scenarios/hard/`, for the ones worth committing. No fixture is written by hand, and the set grows one verdict at a time. Incidents without a verdict are history, not a test, and are skipped. ### 2026-09-17: first run from the store Three incidents on the kind cluster carried a verdict (two `/ok`, one `/wrong`), replayed against `qwen2.5:7b` on CPU: | Incident | Context recorded at the time | Verdict | Replay | Grade | |---|---|---|---|---| | `9e95b7f1` billing-api crash loop, 2026-09-14 18:21 | events only (238 chars, before pod logs were collected by default) | wrong: `postgres:5432 unreachable` | "Image pull failure" | FAIL | | `f77f5269` billing-api crash loop, 19:13 | events + logs, the log names `postgres:5432` | ok | "Postgres database is unreachable or misconfigured" | PASS | | `85509212` orders-1 storm leader, 21:38 | events + logs, `FATAL secret db-credentials not found` | ok | "Secret 'db-credentials' not found in the pod" | PASS | 2/3, average 36 s per hypothesis. Two things the first run exposed: - The FAIL is a **collector gap, not a model gap**: with no log line in the context, "image pull" is a reasonable guess for a busybox pod restarting. The same alert with pod logs collected (the next row) was answered right. Pod logs are collected by default since 2026-09-15. - The first pass scored 3/3, and one PASS was false. The `/wrong` text was "postgres:5432 unreachable, no secret involved", the grader extracted `secret` from it, and a repeat of the wrong "Secret not found" hypothesis matched. Keywords now come from the first clause only (name the cause first, commentary after a semicolon) and words every hypothesis contains (`error`, `pod`, `container`, `log`) never count. The corrected run is the table above. One of the three verdicts was also entered wrongly by the author and corrected before this table was written: an eval set built from verdicts is only as good as the engineer's attention when writing them.