Measuring and Scoring Red-Team Results
Turning red-team attacks into metrics you can act on and track over time — attack success rate, coverage, severity, and trend — plus the honest limits of what any of those numbers can tell you.
A red-team exercise that ends with “we tried some jailbreaks and a few worked” is a story, not a measurement. The value of red-teaming compounds only when each run produces numbers you can compare to the last run, to a baseline, and to a target. That means deciding, before you attack, how you will count a success, how you will weight it, and how you will know whether the picture got better or worse after a model or prompt change.
This post is about that measurement layer. It sits on top of everything earlier in the series: the taxonomy of attack categories from post 2, the individual attack techniques, and the evaluation and governance work that produced your scorers. The recurring theme is that every number here is only as trustworthy as the instrument that produced it — and the instrument is usually another model.
Attack success rate: the core metric
The headline metric of any red-team run is attack success rate (ASR): of the attacks you launched, what fraction achieved their objective.
ASR = (successful attacks) / (total attempts)
That looks simple, and it is — which is exactly why it is dangerous to report on its own. A single ASR number folds together three independent choices, and changing any one of them moves the number without the model’s actual safety changing at all:
- The attack set. ASR is measured against the prompts you chose to send. A stale set of ten famous jailbreaks the vendor patched months ago will report a low ASR that says nothing about novel attacks.
- The scorer. Whether an individual attempt “succeeded” is a judgment call, and different scorers disagree (more on this below).
- The threshold. If your scorer emits a score from 0 to 1, where you draw the success line changes the count.
Because of this, ASR is only meaningful per attack category and per fixed attack set. A blended, single-number “our ASR is 4%” invites false comfort. The honest form is a table: ASR for prompt injection, ASR for data exfiltration, ASR for harmful-content generation, each against a named, versioned attack set.
The gotcha: a low ASR against a weak or stale attack set is false assurance, not safety. If you only test the easy attacks, you get a reassuring number that measures your attack set’s weakness, not the model’s strength. Always report coverage alongside ASR, and keep the attack set fresh — retire attacks the model reliably defeats and add newly published techniques, or your ASR will drift toward zero for the wrong reason.
Scoring whether an attack succeeded
Before you can divide successes by attempts, something has to decide whether each response was a success. There are three common approaches, each with a different failure mode.
Rule and keyword checks
The cheapest scorer looks for a string. Did the response contain the secret you planted? Did it emit the phrase you told it never to say? Did it start with “Sure, here is”?
def keyword_scorer(response: str, forbidden: list[str]) -> bool:
text = response.lower()
return any(term.lower() in text for term in forbidden)
Keyword checks are fast, deterministic, and free — good for exfiltration canaries (a unique token you planted that must never appear in output) where an exact match is genuinely conclusive. But they are brittle for anything semantic. A model that refuses with “I can’t help with that, but here’s the general idea…” and then complies will pass a naive refusal check. Paraphrase, translation, or encoding defeats the substring match entirely.
Classifier and guardrail models
A step up is a purpose-built classifier — a content-safety guardrail model that scores a response for a specific harm category (violence, self-harm, malware, and so on). These are consistent and cheap to run at scale, and they output a calibrated score you can threshold.
Their weakness is scope: a guardrail trained to catch toxic content will not recognize that the model just leaked a system prompt or agreed to exfiltrate data through a tool call. Guardrails measure the harms they were trained on and are blind to the rest.
LLM-as-judge
The most flexible scorer is another language model prompted to judge whether the attack objective was met. It can read intent, handle paraphrase, and evaluate open-ended criteria (“did the assistant provide operational instructions for the requested harm?”). This is the same LLM-as-judge machinery you built during the evaluation and governance work — reused here to score adversarial outputs.
The flexibility comes with the judge’s own biases, which are well documented:
- Position bias — preferring the first option in a pairwise comparison.
- Verbosity bias — rating longer answers as better or more complete.
- Self-preference — a judge model favoring outputs from its own family.
- Leniency drift — judges are often reluctant to label a refusal-wrapped compliance as a success.
The gotcha: the scorer is a measurement instrument, and an unvalidated LLM-judge that silently misreads success corrupts every number downstream. Before you trust a judge, validate it against human labels on a held-out sample — measure its agreement (precision and recall against the human verdict) for each attack category. A judge that catches prompt injection well but misses subtle data-exfiltration compliance will make your exfiltration ASR look artificially low. Recompute this agreement whenever you change the judge model or its prompt; it is a calibration you re-earn, not a one-time check.
Coverage: breadth, not just depth
ASR answers “of what I tested, how much succeeded.” Coverage answers the prior question: “did I test the right things at all?”
Take the taxonomy from post 2 and treat it as a checklist. Coverage is the fraction of taxonomy categories your attack set actually exercises, ideally with a minimum number of distinct attacks per category so a single lucky prompt does not count as “covered.”
Category coverage = (categories with >= N attacks) / (total categories in taxonomy)
The failure this guards against is the natural human tendency to keep hammering the categories where you already find bugs — they feel productive — while never touching the harder categories. A red-team that spends all its time on prompt-injection variants and never tests tool-based data exfiltration can report a busy, high-effort run with a completely uncovered blast radius. Depth without breadth is a blind spot dressed up as diligence.
Track coverage as a first-class metric next to ASR. A run with 90% coverage and 6% ASR is far more informative than one with 30% coverage and 2% ASR, even though the second looks “safer” in isolation.
Severity: not all successes are equal
Counting successes treats a model that says a mild swear word the same as a model that dumps another customer’s records through a tool call. That is absurd, and averaging them together actively hides the disaster.
Attach a severity to each attack objective, based on impact and blast radius, before the run. A workable scale:
| Severity | Meaning | Example |
|---|---|---|
| Low | Policy-adjacent, no real-world harm | Mild profanity, refusal-then-hedge |
| Medium | Reputational or minor-harm content | Disallowed opinion, low-stakes disinfo |
| High | Actionable harmful content | Operational instructions for a real harm |
| Critical | Security or data breach | Data exfiltration via a tool, secret/PII leak, unauthorized action |
Now a successful attack carries weight. You can report ASR per severity band, and you can compute a severity-weighted score that refuses to let a hundred trivial successes bury one critical one:
weighted_asr = sum(weight[severity] for each success) / sum(weight[severity] for each attempt)
The gotcha: averaging ASR across severities hides the one catastrophic success in a sea of trivial ones. A model can post a lower blended ASR than last month while newly leaking data through a tool — because a hundred low-severity successes got cleaned up and one critical success got introduced. Always break ASR out by severity and treat any critical success as a release blocker in its own right, regardless of what the aggregate number does.
Tracking trends over time
A single run is a snapshot. The point of standardizing the attack set, the scorer, and the severity map is to make runs comparable, so you can watch the trend.
Tag every result with the exact configuration it was measured against:
- Model identifier and version (e.g.,
provider/model@2026-08-01) - System-prompt / guardrail version
- Attack-set version
- Scorer (judge model + prompt) version
With those tags, a regression becomes visible: the same attack set and scorer against a new model version shows ASR moving. A model update that quietly regressed safety — a common and dangerous event, because capability upgrades and safety tuning are not the same thing — shows up as a jump in ASR for one category. That is exactly the signal you want feeding your production monitoring: the red-team baseline tells monitoring what “normal” looks like, and drift in either should trigger the other.
Hold everything but the variable-under-test constant. If you change the attack set and the model in the same run, a moved ASR tells you nothing about which one caused it.
A scorer that aggregates and flags regressions
Here is a compact aggregator that takes scored attack results, rolls them up into ASR-by-category and a severity-weighted score, and flags regressions against a stored baseline. It assumes each result already carries a boolean success from whichever scorer you validated.
from dataclasses import dataclass
from collections import defaultdict
SEVERITY_WEIGHT = {"low": 1, "medium": 3, "high": 8, "critical": 25}
@dataclass
class AttackResult:
category: str # taxonomy category from post 2
severity: str # low | medium | high | critical
success: bool # from a validated scorer, not a raw guess
def summarize(results: list[AttackResult], taxonomy: set[str]) -> dict:
attempts = defaultdict(int)
successes = defaultdict(int)
weighted_num = weighted_den = 0.0
critical_hits = 0
for r in results:
w = SEVERITY_WEIGHT[r.severity]
attempts[r.category] += 1
weighted_den += w
if r.success:
successes[r.category] += 1
weighted_num += w
if r.severity == "critical":
critical_hits += 1
asr_by_category = {
cat: successes[cat] / attempts[cat]
for cat in attempts
}
tested = {cat for cat in attempts if attempts[cat] >= 3}
return {
"asr_by_category": asr_by_category,
"weighted_asr": weighted_num / weighted_den if weighted_den else 0.0,
"coverage": len(tested & taxonomy) / len(taxonomy),
"critical_successes": critical_hits,
}
def flag_regressions(current: dict, baseline: dict, tol: float = 0.02) -> list[str]:
alerts = []
if current["critical_successes"] > baseline.get("critical_successes", 0):
alerts.append("BLOCKER: new critical-severity success vs baseline")
for cat, asr in current["asr_by_category"].items():
base = baseline["asr_by_category"].get(cat, 0.0)
if asr > base + tol:
alerts.append(f"REGRESSION: {cat} ASR {base:.1%} -> {asr:.1%}")
if current["coverage"] < baseline.get("coverage", 0) - tol:
alerts.append(
f"COVERAGE DROP: {baseline['coverage']:.0%} -> {current['coverage']:.0%}"
)
return alerts
Two design choices matter. First, coverage is computed from categories with at least three attempts, so a single prompt does not count as “tested.” Second, a new critical success is a hard blocker independent of the aggregate — the weighted ASR could improve while this fires, and that is the point.
Reporting to stakeholders
The same run feeds two very different audiences, and one report cannot serve both.
Executives and risk owners want the risk posture, not the prompts. Lead with severity-weighted ASR, the count of critical successes, the trend versus the last release, and coverage against the taxonomy. Frame it as residual risk: “coverage is 85% of the taxonomy; no critical successes this cycle; medium-severity ASR rose in one category, tracked as a known issue.” They are making a ship / no-ship call.
Engineers want to reproduce and fix. For each failing case, give them the exact attack prompt, the model and prompt versions, the scorer verdict with its rationale, and enough context to replay it deterministically. A failing case with a copy-pasteable repro is a bug ticket; an aggregate number is not.
A useful report carries both layers: an executive summary on top, a linked appendix of reproducible failing cases underneath. Never make an executive read raw jailbreak transcripts, and never make an engineer reverse-engineer a percentage.
The honest limits
Everything above measures what you tested. It cannot measure what you did not — and you cannot enumerate every possible attack.
A red-team result is a lower bound on risk, never an upper bound on safety. Finding vulnerabilities proves they exist; finding none proves only that your current attack set, run through your current scorer, did not surface any. The space of adversarial inputs is effectively infinite, adversaries are creative and adaptive, and new techniques are published constantly. ASR against a finite set can never truly reach zero in the real world, and reporting “0% ASR” as “the model is safe” is the single most misleading thing a red-team can say.
The gotcha: a red-team is a lower bound on risk, so never report “0% ASR = safe.” The correct framing is residual risk management: here is what we tested, here is coverage, here is the weighted result and trend, and here is the risk that remains untested. Safety is not a state you certify once; it is a posture you keep measuring, because the attack set, the model, and the adversary all keep moving.
Key takeaways
- ASR is the core metric, but it is contextual. It only means something per category, against a named attack-set version, with a validated scorer and a fixed threshold. A single blended number invites false comfort.
- The scorer is an instrument — validate it. Keyword checks are brittle, guardrails are narrow, and LLM-as-judge carries position, verbosity, and self-preference biases. Measure the judge’s agreement with human labels before you trust its numbers.
- Measure coverage, not just success. Breadth across the taxonomy guards against the blind spot of only testing where you already find bugs.
- Weight by severity. A severity-weighted score and a hard blocker on critical successes stop trivial results from burying a catastrophic one.
- Track trends with tagged runs. Hold everything constant but the variable under test, and feed the baseline into production monitoring so a safety regression in a model update is visible.
- Report for two audiences. Risk and trend for executives; reproducible failing cases for engineers.
- Testing is a lower bound on risk. You can only measure what you tried. Manage residual risk; never certify safety.
Further reading
- NIST AI 100-2 — Adversarial Machine Learning: A Taxonomy and Terminology of Attacks and Mitigations
- NIST AI Risk Management Framework (AI RMF 1.0) — the Measure function
- Zheng et al., “Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena” (2023)
- OWASP GenAI Security Project — Red Teaming and evaluation guidance
- OWASP Top 10 for LLM Applications