Skip to content

Evaluation & methodology

Anyone can wire an LLM to a taxonomy and call the output a "classification". The harder and more honest question is: how good is it, measured against what, and where does it fail? This page answers that with a frozen labelled test set, a ladder of baselines, and metrics chosen for a monitoring system rather than a leaderboard.

The whole evaluation is reproducible with no cloud and no API key:

agentwatch eval --provider majority    # constant-class floor
agentwatch eval --provider baseline    # deterministic keyword classifier (default)
agentwatch eval --provider ollama      # a real local model, e.g. qwen2.5:7b-instruct

What we measure, and why

A triage system that reads incident reports has two jobs, so we score two dimensions separately:

  • Relevance — the triage decision: relevant / not_relevant / insufficient_evidence. Getting this wrong either floods the observatory with noise or silently drops real incidents.
  • Incident type — the discriminative task, scored only on the rows that are genuinely relevant (the nine concrete categories). Averaging this in with the easy "obviously off-topic" rows would flatter the score.

On top of those we report metrics that a monitoring system actually lives or dies by:

Metric Question it answers
Macro-F1 (per dimension) Does it do well across all classes, not just the common ones?
Per-class precision / recall Which specific categories does it miss or over-call?
Selective accuracy @ coverage When it commits to an answer, how often is it right — and how often does it commit?
Abstention precision / recall When it says "not enough evidence", is it right to? Does it catch the truly under-evidenced reports?
Calibration Do its confidence scores mean anything?
Cost / latency What does one classification cost in dollars and milliseconds?
Confusion matrix + 10 failure cases Where and how does it break?

Selective accuracy and abstention precision are the ones that separate a monitoring tool from a demo. A classifier that abstains on everything is useless; one that never abstains launders its ignorance into confident labels. The interesting question is the trade between the two.

The test set

The labelled set lives at agentwatch/eval/dataset.json131 examples, frozen and version-controlled, so every run scores the same data and the regression gate is stable. Each row is {text, label, relevance}.

Group Rows What it is
Concrete incidents (9 types) 93 9–12 examples per incident type, covering the full taxonomy including goal_persistence and resource_acquisition
Hard negatives (not_relevant) 24 On-topic-looking but not incidents
Under-evidenced (insufficient_evidence) 14 Genuinely too vague to classify

It is deliberately hard, because an easy test set produces a flattering number that tells you nothing. Three design choices make it adversarial:

  1. Misleading keywords in the hard negatives. Many not_relevant rows contain the exact trigger words a keyword classifier keys on — "I explicitly asked the agent to delete the old build folder, and it did exactly that", "a blog post discussing a theoretical sandbox escape that was never exploited", "the agent asked for permission before making the purchase", "a CVE describes a container escape in runc". A system that pattern-matches on delete / sandbox / permission / escape will false-positive on all of them.
  2. Incidents phrased without their trigger word. Real reports rarely say "privilege escalation". They say "it edited the sudoers file to give its own service account root". Recall has to survive paraphrase.
  3. Near-neighbour categories. goal_persistence vs resistance_to_correction, resource_acquisition vs unauthorized_action — pairs that share surface vocabulary ("kept", "without asking") but are genuinely different behaviours. These are where the confusion matrix earns its keep.

Annotation methodology

  • Guidelines. Each label has a one-line decision rule applied consistently: relevant = a concrete autonomous-agent behaviour worth logging; not_relevant = human-directed, fictional, hypothetical, working-as-intended, or non-agent; and insufficient_evidence = an agent may be involved but the report lacks the detail to assign a type. harmless_malfunction is labelled relevant — a benign agent glitch is still an agent behaviour — which is itself a judgement call the results expose.
  • Frozen test set. The file is committed and only changes via a reviewed edit, so a score is comparable across time and the regression gate means something.
  • Reconstruction, not scraping. The examples are realistic reconstructions written to cover the taxonomy and the hard cases above, not copied from real users — which keeps the set free of personal data and lets it target specific failure modes.

Limitations, stated plainly:

  • Single annotator, no inter-annotator agreement. One person wrote and labelled the set, so there is no Cohen's κ and the "ground truth" carries that person's judgement calls (the harmless_malfunction relevance decision above is the clearest example). A second independent annotator and a reported κ is the top item of future work.
  • Reconstructed, not sampled from production. The distribution is chosen to be hard and balanced, not to match the real HN/Reddit frequency of each incident type. It measures capability, not the live base rate.
  • No per-source breakdown yet. The set is source-agnostic. Performance by collector (HN vs Reddit vs replay) needs a source-tagged sample and is not claimed here.
  • Small per-class n (9–12). Per-class F1 is indicative, not tight; treat single-class numbers as directional.

Systems compared

A result only means something against a baseline ladder:

System What it is Why it's here
Majority Always predicts the most frequent label (relevant / destructive_action). Never abstains. The floor. Anything that can't beat this has learned nothing.
Keyword baseline Deterministic word-boundary keyword rules. No model, no network. The default classifier and the CI regression gate. Shows how far rules alone get.
Local model (qwen2.5:7b-instruct via Ollama) A small open-weight instruct model, JSON-mode output, run locally for $0. The "real model" row — open-weight, free, private.
Hosted model A frontier API model (Anthropic), with per-token cost accounting. Optional upper end. Not run here (no API key in this environment); the harness supports it via --provider anthropic.
Human ceiling A second annotator's agreement with the gold labels. The realistic ceiling; not yet measured (see limitations).

Results

Frozen set, n = 131. Baseline and majority numbers are exact and reproducible; the local model row is measured with qwen2.5:7b-instruct.

Headline

System Incident macro-F1 Relevance macro-F1 Overall acc Selective acc Coverage Abstention P / R Cost Latency
Majority 0.025 0.277 0.092 0.092 1.000 0.00 / 0.00 $0 ~0 ms
Keyword baseline 0.273 0.189 0.198 0.368 0.290 0.13 / 0.86 $0 ~0 ms
Local (qwen2.5:7b) 0.749 0.747 0.733 0.724 0.939 0.88 / 0.50 $0 ~4.2 s

The local 7B is run on a laptop with no GPU. "Latency ~4 s" is that setting; a hosted model or a GPU cuts it by an order of magnitude. Cost is $0 because it's open-weight and local — the point of the row.

Reading the baseline numbers honestly

The keyword baseline is ~11× better than the majority floor on the discriminative task (0.273 vs 0.025 incident macro-F1), so its rules carry real signal. But the same table exposes exactly why rules aren't enough:

  • It over-abstains. It abstains on 71% of rows, but its abstention precision is only 0.13 — most of those abstentions aren't genuinely under-evidenced reports, they're just reports whose wording didn't match a keyword. High abstention recall (0.86) with low precision is the signature of "I don't have a rule for this", dressed up as caution.
  • When it commits, it's right 37% of the time (selective accuracy at 29% coverage). Better than chance, far from a monitoring-grade tool.
  • It can't see hard negatives. Relevance F1 for not_relevant is 0.00 — it has no concept of "the human asked for this" or "this is hypothetical", so it never assigns that class. This is the clearest motivation for a model that reads context.

Per-class incident-type F1 (relevant rows only) shows the pattern — it does adequately on categories with a crisp keyword and fails on the paraphrased and near-neighbour ones:

Class P R F1
harmless_malfunction 1.00 0.33 0.50
privilege_escalation 1.00 0.27 0.43
sandbox_escape 1.00 0.20 0.33
resistance_to_correction 0.33 0.30 0.32
deception 1.00 0.18 0.31
destructive_action 1.00 0.17 0.29
unauthorized_action 0.50 0.20 0.29
goal_persistence 0.00 0.00 0.00
resource_acquisition 0.00 0.00 0.00

High precision, low recall almost everywhere: when a keyword fires it's usually right, but it misses most paraphrased reports. goal_persistence and resource_acquisition score 0.00 — they have no distinctive keyword and get absorbed by their near-neighbours.

Where the baseline breaks (failure cases)

The runner returns the most-confident wrong answers first — the errors that matter most:

  • "After I marked the ticket done, the agent kept spawning follow-up subtasks…" → predicted resistance_to_correction, gold goal_persistence. The word "kept" fires the correction rule; the model has no way to see that the goal outlived its cancellation.
  • "It signed up for a paid API plan on its own to get more quota" → predicted unauthorized_action, gold resource_acquisition. "On its own" matches the unauthorized rule; acquiring resources is a strict specialisation it can't distinguish.
  • "Minor glitch: it saved output.txt instead of results.txt. No harm done." → predicted not_relevant, gold harmless_malfunction. A borderline labelling call (see methodology) that the baseline gets "wrong" by our gold but arguably right in spirit — exactly the kind of case a second annotator would adjudicate.

Calibration

The baseline emits only two confidence levels (0.3 when it abstains, 0.7 when a rule fires), so its reliability table is coarse by construction:

Confidence bin Empirical accuracy n
[0.3, 0.4) 0.13 93
[0.7, 0.8) 0.37 38

It is directionally calibrated — higher confidence does mean higher accuracy — but the gap between stated confidence and real accuracy (0.7 → 0.37) shows a rule-based score is not a probability. Calibration is far more meaningful for the model providers, where confidence varies continuously; the same table is produced for them by the same code.

What the local model buys — and what it doesn't

Running the same evaluation against qwen2.5:7b-instruct (open-weight, local, $0) is where the design pays off — one command, identical data, directly comparable numbers:

  • It nearly triples the discriminative score — incident macro-F1 0.273 → 0.749 — and reads paraphrase the baseline can't. The near-neighbour categories the baseline scored 0.00 on now work: goal_persistence 0.80 F1, resource_acquisition 0.77 F1, destructive_action 0.96, deception 0.90.
  • It handles the hard negatives the keyword baseline can't. Relevance macro-F1 jumps 0.189 → 0.747, and not_relevant F1 goes from the baseline's 0.00 to 0.69 — the model actually recognises "the human asked for this" and "this is hypothetical", which a keyword rule has no way to represent.
  • It commits far more, and abstains honestly. Coverage is 0.94 (it declines on only 6% of rows, vs the baseline's 71%) at 0.72 selective accuracy, and when it does abstain it's right to 88% of the time (abstention precision). Its confidence is a usable triage signal — predictions in the [0.9, 1.0] bin are right ~74% of the time and [0.8, 0.9) ~73% — unlike the baseline's two flat values.

A schema bug that the evaluation caught. The not_relevant score above was 0.00 in an earlier run — not because the model failed, but because the result schema required a non-null incident_type, so every correct "not an incident" verdict (which the model returns with a null type) failed validation and was discarded as an abstention. The harness surfaced it as an implausible 0.00 on a whole class and a 34% abstention rate; fixing the schema to accept a null type lifted not_relevant F1 to 0.69 and dropped abstention to 6%. This is exactly what an evaluation is for — a plausible-looking demo hid a bug that only per-class metrics exposed.

Where it still fails. The remaining errors flip the other way: the model sometimes calls a real, subtle incident not_relevant with high confidence — "Ran a cleanup script that emptied the S3 bucket we use for backups"not_relevant (conf 1.0), "reported all tests green, but the suite never ran"not_relevant. It also drops most harmless_malfunction cases into not_relevant (a borderline labelling call — see methodology). So the concrete next step is a prompt or stronger model that is less eager to dismiss understated incidents — chosen because the evaluation showed exactly where the error mass is, not on a hunch.

The cost of that quality is latency: ~4 s per classification for a 7B model on a laptop with no GPU (vs ~0 ms for the baseline), at $0. A GPU or a hosted model trades some of that back; the harness measures it either way.

The regression gate

tests/test_eval.py turns this evaluation into a guard, not just a report:

  • test_baseline_meets_regression_floor — incident macro-F1 must stay above a committed floor (0.20). A prompt/taxonomy/classifier change that regresses quality fails CI.
  • test_baseline_beats_majority — the baseline must beat the constant-class floor on the discriminative task, so the floor can't be cleared by a degenerate classifier.
  • test_dataset_covers_full_taxonomy — every concrete incident type has ≥1 relevant example, so the set can't silently stop testing a category.

Because the baseline and dataset are deterministic and offline, this gate runs in CI in milliseconds with no model server and no flakiness.

Reproduce it

agentwatch eval --provider majority
agentwatch eval --provider baseline
ollama pull qwen2.5:7b-instruct && agentwatch eval --provider ollama

Each prints the full report: both macro-F1s, per-class tables, selective accuracy and coverage, abstention precision/recall, the calibration table, and the ten most-confident failure cases.