Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A large language model (LLM) judge gives one supported replacement request a 0.78 score when written formally and 0.67 when written conversationally. At a 0.70 threshold, the first request reaches auto-service and the second waits for a human reviewer. Aggregate agreement can still look healthy while equivalent customers wait for different reasons.
The previous judge lesson measured whether scores agree with reviewers. This fairness audit asks a different question: when request facts, policy evidence, and the supported remedy stay fixed, does changing only wording change who gets the fast path? That route difference is an allocative failure because it changes access to service.
To keep the example honest, these fixtures are invented and labeled. The wording variants are test conditions, not demographic groups or a claim about any community's speech. A real language-variety audit needs representative data, informed review, privacy controls, and careful group definitions.
Keep the two fixture dimensions separate: variant is the wording slice (formal or conversational), while channel is transport metadata (chat or email). In this lesson, chat never stands in for the conversational wording slice.
Fairness starts with a consequence
Start with consequence, not metric. Representational harm occurs when output stereotypes, demeans, or erases a group. Allocative harm changes access to a benefit or burden, such as whether an eligible customer gets an immediate supported answer or waits for review. Gallegos et al. use allocational for this second category in their survey of bias and fairness in LLM systems.[1]
Our running case is allocative. The remedy is already authorized by the selected policy evidence, so the route is the outcome to inspect:
| Decision component | Held fixed or measured? | Why it matters |
|---|---|---|
| Policy source and version | Held fixed | A fairness audit can't repair unsupported claims. |
| Replacement eligibility | Held fixed within each matched pair | Each pair should deserve the same answer. |
| Language-variety fixture | Varied within each pair | It's the audit condition. |
| Judge score and route | Measured | Unequal routing is customer impact. |
Agreement with reviewers doesn't answer whether routing is even. Compare each paired score with the shared 0.70 auto-serve threshold. A pair flips when its two scores sit on opposite sides of that line.

Build a matched-pair audit
A matched pair changes one audit condition while preserving task semantics. Reviewers must first confirm that both prompts describe the same customer facts and should receive the same route. Swapping words without that check can manufacture a fairness failure or hide a justified difference.
Pair p4 passes that review. Both versions describe the same purchase and authorized replacement, and selected policy evidence already supports the proposed reply.
| Variant | Customer request | Judge score | Route at 0.70 |
|---|---|---|---|
| Formal | "I bought a blender 12 days ago. It stopped working. Please replace it under the 30-day policy." | 0.78 | auto_serve |
| Conversational | "got a blender 12 days ago and it already stopped working. can you replace it? the 30-day policy should cover this." | 0.67 | human_review |
Predict the route before running an audit: a shared threshold should send both versions of a matched pair the same way. Here, same facts and same remedy produce a 0.11 score gap. The threshold straddles it, so the formal request is auto-served while the conversational one waits.

Ten such pairs make this lab fixture. In six, the proposed reply is supported and clear enough for auto-service. In four, it should go to review because the proposed reply is unclear. Both versions in a pair share one expected outcome. The code keeps that expected outcome separate from the judge route so later rates can distinguish delayed ready replies from unsafe fast paths.
1from collections import defaultdict
2from dataclasses import dataclass
3from math import sqrt
4
5@dataclass(frozen=True)
6class AuditRow:
7 pair_id: str
8 variant: str
9 expected_auto_serve: bool
10 judge_score: float
11 channel: str
12 evidence_passed: bool = True
13
14THRESHOLD = 0.70
15
16def route(row: AuditRow) -> str:
17 if not row.evidence_passed:
18 return "blocked_by_evidence"
19 return "auto_serve" if row.judge_score >= THRESHOLD else "human_review"
20
21# Synthetic observations: (pair, expected outcome, channel, formal score, conversational score)
22observations = [
23 ("p1", True, "chat", 0.92, 0.88),
24 ("p2", True, "chat", 0.88, 0.73),
25 ("p3", True, "chat", 0.84, 0.72),
26 ("p4", True, "email", 0.78, 0.67),
27 ("p5", True, "email", 0.74, 0.56),
28 ("p6", True, "email", 0.66, 0.61),
29 ("n1", False, "chat", 0.71, 0.60),
30 ("n2", False, "chat", 0.62, 0.55),
31 ("n3", False, "email", 0.60, 0.52),
32 ("n4", False, "email", 0.68, 0.63),
33]
34
35rows: list[AuditRow] = []
36for pair_id, expected, channel, formal_score, conversational_score in observations:
37 rows.extend([
38 AuditRow(pair_id, "formal", expected, formal_score, channel),
39 AuditRow(pair_id, "conversational", expected, conversational_score, channel),
40 ])
41
42assert len(rows) == 20
43assert all(row.evidence_passed for row in rows)
44assert {
45 row.pair_id: row.expected_auto_serve for row in rows if row.variant == "formal"
46} == {
47 row.pair_id: row.expected_auto_serve for row in rows if row.variant == "conversational"
48}
49
50print("Fixture type: synthetic matched wording audit")
51print(f"Matched pairs: {len(observations)}; scored rows: {len(rows)}")
52print(f"Routing threshold: {THRESHOLD:.2f}")1Fixture type: synthetic matched wording audit
2Matched pairs: 10; scored rows: 20
3Routing threshold: 0.70This fixture intentionally includes failures. An all-pass set would demonstrate arithmetic, but it wouldn't give us anything to diagnose.
Look for pair flips first
A pair flip is the simplest warning sign: equivalent requests receive different routes. It doesn't prove a population-level disparity, but it identifies the cases the team should investigate first.
Before calculating group rates, predict what the shared threshold should do: every matched pair should have one route. The flip check surfaces three counterexamples.
1def rows_by_pair(audit_rows: list[AuditRow]) -> dict[str, list[AuditRow]]:
2 grouped: dict[str, list[AuditRow]] = defaultdict(list)
3 for row in audit_rows:
4 grouped[row.pair_id].append(row)
5 return grouped
6
7def matched_pair_flips(audit_rows: list[AuditRow]) -> list[tuple[str, str, str]]:
8 flips: list[tuple[str, str, str]] = []
9 for pair_id, pair_rows in rows_by_pair(audit_rows).items():
10 outcomes = {row.variant: route(row) for row in pair_rows}
11 if len(set(outcomes.values())) > 1:
12 flips.append((pair_id, outcomes["formal"], outcomes["conversational"]))
13 return flips
14
15by_pair = rows_by_pair(rows)
16flips = matched_pair_flips(rows)
17assert flips == [
18 ("p4", "auto_serve", "human_review"),
19 ("p5", "auto_serve", "human_review"),
20 ("n1", "auto_serve", "human_review"),
21]
22
23print("Flipped matched pairs:")
24for pair_id, formal_route, conversational_route in flips:
25 print(f" {pair_id}: formal={formal_route}, conversational={conversational_route}")1Flipped matched pairs:
2 p4: formal=auto_serve, conversational=human_review
3 p5: formal=auto_serve, conversational=human_review
4 n1: formal=auto_serve, conversational=human_reviewTwo eligible replies lose the fast path under the conversational condition. One unclear reply gains the fast path under the formal condition. A single approval-rate number can't explain both errors.
Choose a metric from the harm
Once flips appear, choose a denominator from the harm. Four group metrics answer different product questions:
| Metric | Calculation | Question for the router |
|---|---|---|
| Selection rate | Auto-served / all requests | Does one slice receive fast service more often? |
| True positive rate (TPR) | Auto-served / replies reviewers say are ready | Do ready replies receive fast service equally often? |
| False positive rate (FPR) | Auto-served / replies reviewers say need review | Does one slice receive unsafe fast service more often? |
| Calibration | Observed ready rate among equal score bands | Does a 0.80 score carry the same meaning across slices? |
Equal opportunity compares TPR across slices. Equalized odds compares both TPR and FPR. Hardt, Price, and Srebro formalized those error-rate criteria for supervised decision systems.[2]
Our main harm is delayed eligible help, so TPR gap is the primary release metric. FPR gap remains a guardrail: reducing delay by auto-serving unclear replies would trade one harm for another.
For this router, let denote the wording condition and the route:
The gaps we gate on are and .
A confusion matrix makes the denominators visible. TPR reads only the ready-reply row; FPR reads only the unclear-reply row. Selection rate combines both rows and hides which error moved.

Walk the ready row by hand first. Formal auto-serves 5 of 6 ready replies, so TPR is . Conversational auto-serves 3 of 6, so TPR is . The TPR gap is 33.3 points.
Now inspect the unclear row. Formal auto-serves 1 of 4 unclear replies (FPR ); conversational auto-serves none (FPR ). The FPR gap is 25 points. Those two false negatives on the conversational ready row are delayed service. The one false positive on the formal unclear row is an unsafe fast path.
Calculate slice rates
1@dataclass(frozen=True)
2class Rates:
3 selection: float
4 tpr: float
5 fpr: float
6 positive_count: int
7 negative_count: int
8
9def slice_rates(slice_rows: list[AuditRow]) -> Rates:
10 positives = [row for row in slice_rows if row.expected_auto_serve]
11 negatives = [row for row in slice_rows if not row.expected_auto_serve]
12 selected = [row for row in slice_rows if route(row) == "auto_serve"]
13 true_positives = [row for row in positives if route(row) == "auto_serve"]
14 false_positives = [row for row in negatives if route(row) == "auto_serve"]
15 return Rates(
16 selection=len(selected) / len(slice_rows),
17 tpr=len(true_positives) / len(positives),
18 fpr=len(false_positives) / len(negatives),
19 positive_count=len(positives),
20 negative_count=len(negatives),
21 )
22
23rates = {
24 variant: slice_rates([row for row in rows if row.variant == variant])
25 for variant in ("formal", "conversational")
26}
27
28def gap(metric: str) -> float:
29 return abs(getattr(rates["formal"], metric) - getattr(rates["conversational"], metric))
30
31for variant, result in rates.items():
32 print(
33 f"{variant:14} selection={result.selection:.1%} "
34 f"TPR={result.tpr:.1%} FPR={result.fpr:.1%}"
35 )
36print(f"TPR gap={gap('tpr'):.1%}; FPR gap={gap('fpr'):.1%}")1formal selection=60.0% TPR=83.3% FPR=25.0%
2conversational selection=30.0% TPR=50.0% FPR=0.0%
3TPR gap=33.3%; FPR gap=25.0%The rate table makes the failure directional. Among replies reviewers marked ready, the conversational condition is routed to human review more often. Among replies that need review, the formal condition is incorrectly auto-served once.
Why isn't a 30 percentage point selection-rate gap enough to diagnose the failure?
Answer
Selection rate mixes correct and incorrect decisions. Here, one condition loses ready replies while the other incorrectly gains an unclear reply. TPR and FPR separate delayed service from unsafe fast service.
Turn metric choice into a gate
Now turn metric choice into a gate. A release contract makes the choice reviewable. Thresholds below are product decisions for this lab, not universal definitions of fairness.
1@dataclass(frozen=True)
2class FairnessContract:
3 primary_metric: str
4 max_tpr_gap: float
5 max_fpr_gap: float
6 min_positive_per_slice: int
7 min_negative_per_slice: int
8
9contract = FairnessContract(
10 primary_metric="equal_opportunity",
11 max_tpr_gap=0.10,
12 max_fpr_gap=0.10,
13 min_positive_per_slice=50,
14 min_negative_per_slice=30,
15)
16
17metric_checks = {
18 "TPR gap": gap("tpr") <= contract.max_tpr_gap,
19 "FPR guardrail": gap("fpr") <= contract.max_fpr_gap,
20}
21
22for name, passed in metric_checks.items():
23 print(f"{name}: {'PASS' if passed else 'FAIL'}")
24assert metric_checks == {"TPR gap": False, "FPR guardrail": False}1TPR gap: FAIL
2FPR guardrail: FAILThe code fails both checks, as expected. The contract names equal opportunity as primary and keeps FPR as a guardrail. Fairness metrics can conflict, so don't promote every popular criterion into a hard gate.
That trade-off has mathematical limits. When outcome prevalence differs across groups and predictions aren't perfect, a score that's calibrated within each group generally can't also equalize false-positive and false-negative rates across groups. Kleinberg, Mullainathan, and Raghavan proved that calibration plus both error-rate balances can't hold together except in degenerate cases.[3] Chouldechova showed the same incompatibility for risk scoring: calibration and equalized odds pull apart when base rates differ.[4]
Engineering response isn't to give up or chase a universal score. Name the customer harm, pick a primary metric, monitor important counter-metrics, and document the accepted trade-off.
In this lab, the chosen outcome is rapid access to a supported reply. Equal opportunity is primary because it asks whether replies reviewers mark ready reach the fast path similarly. The FPR guardrail prevents a superficial fix that merely auto-serves more unclear replies.
Why not make calibration, equal TPR, and equal FPR universal hard gates for every model?
Answer
When outcome prevalence differs across slices and predictions aren't perfect, those criteria can conflict. Name the customer harm, choose a primary metric, keep important counter-metrics visible, and document the accepted trade-off instead of pretending one configuration satisfies every goal.
Both checks failed. That still isn't a production disparity estimate. Before making a population claim, ask whether the slices are large enough to support any rate claim at all.
Tiny slices can't certify fairness
The audit found an actionable regression, but not a production disparity estimate. Six ready examples per wording condition are too few for a stable rate, and these fixtures don't identify a population. The observed flips remain useful regression cases.
Predict the uncertainty before calculating it: with six rows, each TPR estimate should have a wide range. A Wilson confidence interval makes that range visible under binomial sampling. For successes in trials and , the interval is centered at . It isn't a complete statistical analysis, but it prevents a tiny dataset from looking decisive.
1def wilson_interval(successes: int, total: int, z: float = 1.96) -> tuple[float, float]:
2 proportion = successes / total
3 denominator = 1 + z * z / total
4 center = (proportion + z * z / (2 * total)) / denominator
5 radius = z * sqrt(
6 (proportion * (1 - proportion) + z * z / (4 * total)) / total
7 ) / denominator
8 return center - radius, center + radius
9
10for variant in ("formal", "conversational"):
11 variant_rows = [
12 row for row in rows
13 if row.variant == variant and row.expected_auto_serve
14 ]
15 successes = sum(route(row) == "auto_serve" for row in variant_rows)
16 low, high = wilson_interval(successes, len(variant_rows))
17 print(
18 f"{variant:14} TPR={successes}/{len(variant_rows)} "
19 f"95% interval=[{low:.1%}, {high:.1%}]"
20 )
21
22enough_support = all(
23 result.positive_count >= contract.min_positive_per_slice
24 and result.negative_count >= contract.min_negative_per_slice
25 for result in rates.values()
26)
27print(f"Minimum slice support: {'PASS' if enough_support else 'FAIL'}")
28assert not enough_support1formal TPR=5/6 95% interval=[43.6%, 97.0%]
2conversational TPR=3/6 95% interval=[18.8%, 81.2%]
3Minimum slice support: FAILThe intervals are wide because the fixture is small. They don't erase the flips. Keep those cases as regressions while collecting governed, reviewed evaluation data before making a population claim.
Calibration also needs slice support
The previous lesson used calibration to ask whether a judge agrees with reviewers. Here the stricter question is whether a similar score carries similar meaning across slices. With ten rows per condition, score bands are diagnostic only.
Predict what the output can support: bins with one to five rows can't certify score meaning. The next snippet reports those small cells instead of turning them into a release claim.
1def score_band(score: float) -> str:
2 if score < 0.70:
3 return "below 0.70"
4 if score < 0.90:
5 return "0.70 to 0.89"
6 return "0.90 and above"
7
8calibration_cells: dict[tuple[str, str], list[AuditRow]] = defaultdict(list)
9for row in rows:
10 calibration_cells[(row.variant, score_band(row.judge_score))].append(row)
11
12for (variant, band), cell in sorted(calibration_cells.items()):
13 observed_ready = sum(row.expected_auto_serve for row in cell) / len(cell)
14 print(f"{variant:14} {band:14}: n={len(cell)}, ready={observed_ready:.1%}")
15
16assert max(len(cell) for cell in calibration_cells.values()) < 10
17print("Calibration decision: insufficient support")1conversational 0.70 to 0.89 : n=3, ready=100.0%
2conversational below 0.70 : n=7, ready=42.9%
3formal 0.70 to 0.89 : n=5, ready=80.0%
4formal 0.90 and above: n=1, ready=100.0%
5formal below 0.70 : n=4, ready=25.0%
6Calibration decision: insufficient supportPlan intersections without pretending to measure them
A slice-wide average can hide a failure limited to one channel, locale, or accessibility setting. Plan intersectional reports for those combinations, then impose minimum support: repeatedly slicing a small audit produces unstable numbers and privacy risks.

1intersection_counts: dict[tuple[str, str], int] = defaultdict(int)
2for row in rows:
3 if row.expected_auto_serve:
4 intersection_counts[(row.channel, row.variant)] += 1
5
6for (channel, variant), count in sorted(intersection_counts.items()):
7 status = "eligible" if count >= contract.min_positive_per_slice else "insufficient"
8 print(f"{channel:5} / {variant:14}: n={count}, {status}")
9
10assert all(count == 3 for count in intersection_counts.values())1chat / conversational: n=3, insufficient
2chat / formal : n=3, insufficient
3email / conversational: n=3, insufficient
4email / formal : n=3, insufficientLive group definitions may involve sensitive attributes. Collect and expose them only under an approved purpose, access controls, privacy review, and any required consent or legal basis. A public dashboard with tiny protected-group cells can create harm while trying to measure it.
Find the failing stage before applying a fix
The pair audit has separated cause from symptom: policy evidence and human labels match, but routes diverge after judge scoring. That localizes this lab's failure to the soft-evaluation and threshold layer. Rewriting customer text into a preferred register would conceal the symptom and ask customers to adapt to the system.

1def changed_stage(pair_rows: list[AuditRow]) -> str:
2 if len({row.evidence_passed for row in pair_rows}) > 1:
3 return "evidence_gate"
4 if len({route(row) for row in pair_rows}) > 1:
5 return "judge_or_route"
6 return "no_observed_flip"
7
8attribution = {
9 pair_id: changed_stage(pair_rows)
10 for pair_id, pair_rows in by_pair.items()
11 if pair_id in {pair[0] for pair in flips}
12}
13
14print(attribution)
15assert set(attribution.values()) == {"judge_or_route"}1{'p4': 'judge_or_route', 'p5': 'judge_or_route', 'n1': 'judge_or_route'}Data intervention follows evidence. If a later investigation localizes a disparity to training data, counterfactual data augmentation (CDA) is one candidate experiment: add paired examples that alter an identity-related attribute while preserving the intended label. Gallegos et al. survey it as a pre-processing method, not as a first patch for a deployed judge.[1]
CDA isn't the first repair here because the observed failure is in scoring and routing, not a proven training-set defect. It also needs review: careless swaps can change meaning, produce implausible text, or hide the group-specific harms you meant to measure.
For this failure, start with an evaluator intervention: revise the judge rubric and prompt to score remedy correctness and actionable next steps rather than writing register. Keep the route threshold shared while testing it. A threshold change is a product-policy choice, so if policy changes the threshold, rerun the same pair and slice audits.
The candidate rerun below has equal rates and no matched-pair flips. Read both checks together because offsetting flips can cancel out in aggregate. It still isn't release evidence: it uses the same synthetic cases that exposed the defect.
1candidate_scores = {
2 "p1": (0.92, 0.91), "p2": (0.86, 0.84), "p3": (0.81, 0.80),
3 "p4": (0.77, 0.75), "p5": (0.72, 0.71), "p6": (0.66, 0.65),
4 "n1": (0.62, 0.61), "n2": (0.60, 0.58), "n3": (0.55, 0.56),
5 "n4": (0.64, 0.62),
6}
7
8candidate_rows: list[AuditRow] = []
9for pair_id, expected, channel, _, _ in observations:
10 formal_score, conversational_score = candidate_scores[pair_id]
11 candidate_rows.extend([
12 AuditRow(pair_id, "formal", expected, formal_score, channel),
13 AuditRow(pair_id, "conversational", expected, conversational_score, channel),
14 ])
15
16candidate_rates = {
17 variant: slice_rates([row for row in candidate_rows if row.variant == variant])
18 for variant in ("formal", "conversational")
19}
20candidate_tpr_gap = abs(candidate_rates["formal"].tpr - candidate_rates["conversational"].tpr)
21candidate_fpr_gap = abs(candidate_rates["formal"].fpr - candidate_rates["conversational"].fpr)
22candidate_flips = matched_pair_flips(candidate_rows)
23
24print(f"Candidate TPR gap={candidate_tpr_gap:.1%}; FPR gap={candidate_fpr_gap:.1%}")
25print(f"Candidate pair flips={candidate_flips}")
26print("Interpretation: regression repaired on synthetic pairs, not validated for release")
27assert candidate_tpr_gap == 0
28assert candidate_fpr_gap == 0
29assert candidate_flips == []1Candidate TPR gap=0.0%; FPR gap=0.0%
2Candidate pair flips=[]
3Interpretation: regression repaired on synthetic pairs, not validated for releaseWhy shouldn't the team transform conversational customer requests into formal language before judging them?
Answer
That hides a measured failure instead of establishing equal treatment, may change meaning, and treats one register as the product default. Repair and validate the evaluator on representative, reviewed inputs rather than asking users to conform to it.
A candidate rerun has zero TPR and FPR gaps, but two matched pairs still flip in opposite directions. Has the synthetic regression suite passed?
Answer
No. Aggregate rates can cancel out while equivalent requests still receive different routes. Require both acceptable slice gaps and no matched-pair flips on retained regression cases.
Public benchmarks and product audits play different roles
Matched pairs test the actual route customers experience. Public benchmarks widen regression coverage, but they answer different questions:
| Evaluation source | What it tests | Appropriate use here |
|---|---|---|
| Matched product pairs | Routing consistency for supported policy replies | Primary product release audit |
| Word Embedding Association Test (WEAT) / Sentence Encoder Association Test (SEAT) | Whether word or sentence representations encode tested association patterns | Diagnostic probe when you can inspect embedding behavior; not a routing outcome measure[5][6] |
| StereoSet | Whether a language model assigns stronger preference to stereotypical than anti-stereotypical continuations in its test contexts | Probability-level stereotype regression probe[7] |
| RealToxicityPrompts / Bias in Open-Ended Language Generation Dataset (BOLD) | Toxic degeneration from prompts and open-ended generation about demographic groups | Generation-level audit set that needs human review and product-specific slices[8][9] |
| Bias Benchmark for Question Answering (BBQ) | Whether question answering relies on stereotypes when context is ambiguous or disambiguated | Broad stereotype regression probe[10] |
| Reviewed toxicity slices | Whether a safety evaluator flags language varieties unevenly | Evaluator audit; Sap et al. showed dialect-related false-positive risk in hate-speech detection.[11] |
Use these as layered evidence. WEAT or SEAT can expose associations in representations even when generated outputs look harmless. RealToxicityPrompts or BOLD can expose output harms without explaining which internal representation caused them. Neither runs this product's judge, threshold, or route.
A benchmark pass doesn't prove customer routing is fair: each benchmark tests its own prompt distribution and label design. A clean product slice doesn't provide full safety coverage either. Use both, and keep each limitation attached to its report.
A passing public probe plus a clean synthetic rerun still doesn't make a ship decision. Ask which product evidence is still missing.
Write a release decision, not a fairness slogan
The final artifact should read like a release decision. It says what was tested, what failed, and which evidence is still missing. That keeps a clean toy rerun from being promoted into an unsupported production claim.

1release_requirements = {
2 "synthetic_regression_pairs_pass": not candidate_flips
3 and candidate_tpr_gap <= contract.max_tpr_gap
4 and candidate_fpr_gap <= contract.max_fpr_gap,
5 "representative_reviewed_slice_set": False,
6 "minimum_positive_and_negative_support": False,
7 "approved_group_definition_and_privacy_review": False,
8 "production_monitoring_owner": False,
9}
10
11failures = [
12 requirement
13 for requirement, passed in release_requirements.items()
14 if not passed
15]
16decision = "APPROVED" if not failures else "BLOCKED"
17
18print(f"Metric promotion: {decision}")
19for failure in failures:
20 print(f" missing: {failure}")
21
22assert decision == "BLOCKED"1Metric promotion: BLOCKED
2 missing: representative_reviewed_slice_set
3 missing: minimum_positive_and_negative_support
4 missing: approved_group_definition_and_privacy_review
5 missing: production_monitoring_ownerA blocked result is progress. The team now has reproducible regressions, a primary metric, counter-metric guardrails, a likely failing stage, and explicit evidence still needed before release.