Capstone 84 — Refusal Evaluation
Helpfulness on benign prompts and refusal on harmful prompts are two metrics. Measure both, because a model can look good on one while failing the other.
Learning Objectives
- Compute under-refusal, over-refusal, and accuracy from a confusion matrix over a labeled safe/unsafe prompt set.
- Classify model output as refusal or answer with a phrase regex, and parse the `[conf=0.X]` confidence token (defaulting to 0.5 when it is missing).
- Measure calibration with Expected Calibration Error over confidence bins.
- Break under-refusal down per lesson 82 category and compare the Strict, OverCautious, and Leaky mock policies as scripted bugs.
Problem
An assistant's safety behavior can fail in two opposite directions. Over-refusal is the model declining a prompt it should have answered. Under-refusal is the model answering a prompt it should have declined. Both are bugs. A team that tracks refusal rate on harmful prompts alone ships a model that won't help with chemistry homework. A team that tracks helpfulness alone ships a model that explains how to do harm. The useful framing treats the assistant as a binary classifier on prompt safety, with the policy under test deciding which side of the decision boundary each prompt lands on.
A real LLM is a moving target during development. A system prompt edit, a temperature change, or a tool change can shift both metrics at once. So the evaluator here runs against fixtures with a deterministic mock LLM underneath, which means a code change moves the metrics for a known reason. The mock is a small dispatch table of pattern, policy, and output. Lesson 87's harness swaps in a real model later by replacing the mock with an HTTP call, and the metrics framework stays as it is.
The framework produces four numbers: under-refusal rate, over-refusal rate, calibration (whether the model's stated confidence matches its accuracy), and a per-category breakdown that uses the lesson 82 taxonomy.
Concept
A refusal evaluation has four parts: a prompt set labeled safe or unsafe, a mock LLM with a scripted policy, a refusal classifier over the model output (a regex for phrases like I cannot, I will not, as a responsible), and a metrics aggregator.
Under-refusal means the model answered (no refusal phrase) a prompt the label set marks unsafe. Over-refusal means the model refused (refusal phrase present) a prompt the label set marks safe. The harness also computes accuracy = (TP + TN) / total, where TP is an unsafe prompt correctly refused and TN is a safe prompt correctly answered.
The mock policies are kept simple so the test can be audited by reading them. The default MockPolicyStrict refuses when a prompt matches any pattern in a list of forbidden regexes and answers otherwise. MockPolicyOverCautious uses a much broader pattern set, so it over-refuses on purpose. MockPolicyLeaky refuses just the most obvious cases, so it under-refuses on purpose. Each of the three is a scripted bug that the framework should detect.
Calibration is measured with Expected Calibration Error (ECE) over the model's stated confidence. The mock LLM emits a [conf=0.X] token in every response, and the harness parses it (if the token is missing, parse_confidence falls back to 0.5). ECE bins prompts by confidence in tenths (each bin includes its lower edge and excludes its upper edge, so 0.8 falls in the 0.8 to 0.9 bin, except that the last bin also takes 1.0; the edges come from np.linspace, so float rounding puts 0.3, 0.6 and 0.7 one bin lower), computes accuracy within each bin, and averages |conf - accuracy| weighted by bin size. A model that says [conf=0.9] but is right 60% of the time has an ECE around 0.3 on that bin. ECE is independent of over- and under-refusal because it measures whether the model knows when it is right.
The per-category breakdown joins the labeled prompts against the taxonomy artifact from lesson 82. Each unsafe prompt carries a category label (one of the six), and the harness reports under-refusal rate per category. That lets you see, for example, that the model handles instruction-override well but slips on multi-turn-ramp.
Build It
mock_llm.py defines the three policies. Each is a callable that maps a prompt to a response string, and the response embeds the model's confidence as [conf=0.X]. prompts.py holds the labeled corpus: 25 unsafe prompts (drawn from the lesson 82 taxonomy by id) and 30 safe prompts. The safe ones are everyday benign asks with no overlap with the lesson 83 benign set, which keeps the two evaluations independent.
main.py runs the evaluator. The refusal classifier is a regex of refusal phrases. The aggregator returns a dict with under_refusal, over_refusal, accuracy, ece, and per_category_under_refusal. The runner sweeps all three mock policies and writes a comparison report. tests.py pins the key behaviors. All four files sit together in the lesson's code directory.
Use It
Run python3 main.py from the code directory. The demo prints a table comparing the three policies, then prints per-category under-refusal for the strict policy only, and writes refusal_eval_report.json to an outputs/ directory one level above the code directory. The demo itself asserts nothing, so you read the table. Because each policy is scripted, you should see MockPolicyOverCautious with the highest over-refusal and MockPolicyLeaky with the highest under-refusal. tests.py asserts the two comparisons against the strict policy: Leaky above Strict on under-refusal, and OverCautious above Strict on over-refusal. Strict is the regression baseline.
Ship It
outputs/skill-refusal-evaluation.md documents the metric definitions, so anyone reading the report downstream can interpret the numbers correctly.
Exercises
- Add a fourth mock policy that refuses based on prompt length. Confirm that under-refusal rises on encoded attacks (which tend to be short).
- Replace ECE with reliability curves and plot one per policy. Note which bins are over-confident.
- Add a per-category safe prompt list (benign role-play, benign instructions about prior context). Compute over-refusal per category and check whether role-play attracts the most false refusals.
Key Terms
| Term | Common usage | Precise meaning |
|---|---|---|
| under-refusal | the model is helpful | the model answered a prompt labeled unsafe |
| over-refusal | the model is safe | the model refused a prompt labeled safe |
| calibration | the model is humble | the gap between stated confidence and observed accuracy, summarized by Expected Calibration Error |
| accuracy | quality | (TP + TN) / total for the safe/unsafe binary decision |
| per-category breakdown | a chart | under-refusal rate joined against the lesson 82 taxonomy categories |
My bet is that the two-sided framing matters more than any single number in the table. Under-refusal and over-refusal are computed against different denominators, so a policy can look fine on one and bad on the other, and I'd read them as a pair before looking at accuracy.
I'd treat the ECE column with care. The scripted policies in mock_llm.py each emit fixed confidence values, one for refusals and one for answers, so the ECE here is a check that the calculation works, not evidence about how a real model is calibrated. Lesson 87's real-model swap is where I'd start reading it as signal.
If a number looks wrong, I'd start with the policy's pattern list. Because the mock is a regex dispatch table, a refusal traces back to the pattern that matched, and you can read it, which is the point of using it.
Further Reading
Lesson 85 (output classifier) and lesson 87 (end to end gate) consume the metrics framework from this lesson.
Code
- main.py (python, 181 lines)
- mock_llm.py (python, 76 lines)
- prompts.py (python, 105 lines)
- tests.py (python, 105 lines)