VKVlad KulikovPublic / lectures
RL for LLM13 / 13

Module 13. Evaluating RL systems for LLMs: statistics, reward hacking, and safety

Try each exercise first. Expand worked answers or project guidance when needed; their open state is shared between RU/EN.

Download course files (.zip) 382.1 KB

The archive preserves the source folder structure, both languages, and supporting files.

What exactly scored 61%?

A report says, “the model scored 61%.” The new run changed the system prompt, chat template, length limit, search access, and answer canonicalizer while keeping the weights fixed.

  1. What object is actually being compared with the earlier run?

  2. Why can the number not be attributed to the weight file alone?

  3. What must be fixed if the research question is specifically about checkpoints?

  4. When is a scaffold change a legitimate part of the evaluated system?

Show answer and solution

The comparison is between two versioned systems, not two weight files. A checkpoint-only study must hold tokenizer, template, system message, decoding, budget, tools, environment, canonicalizer, and scorer fixed. A scaffold change is legitimate when the estimand is end-to-end system performance, but it must be reported as part of the treatment.

An interval is not decoration

A system solves zero of 20 tasks. The Wald interval collapses to [0,0][0,0], whereas a Wilson interval has a positive upper endpoint.

  1. Why is the Wald result misleading here?

  2. What does the Wilson upper endpoint mean?

  3. Do zero observed successes prove a zero success probability?

  4. Why is a predeclared target half-width more useful than “about one hundred tasks”?

Show answer and solution

The Wald interval estimates zero variance after observing zero successes, so it confuses an observed boundary with certainty about the population. The Wilson upper endpoint represents nonzero probabilities still compatible with the sample under that interval construction. Predeclared precision connects sample size to the decision before outcomes are visible.

Sampling unit and repeated generations

The team generates 16 completions for each of 100 prompts, then treats all 1,600 completions as independent benchmark tasks.

  1. What is wrong with the sampling unit?

  2. Which randomness sources are present?

  3. When should the prompt remain the primary unit?

  4. Which bootstrap design preserves clustering?

  5. What changes when the estimand is the 16-attempt protocol rather than one attempt?

Show answer and solution

Completions share prompt difficulty and therefore form clusters. Prompt, decoding, training seed, and judge may each contribute randomness. Resample prompts as primary units; optionally resample completions inside each prompt. For a 16-attempt protocol, compute one protocol outcome per prompt before aggregating prompts.

Pairing and exact McNemar reasoning

Two systems are evaluated on the same tasks. A alone is correct on 9 tasks and B alone on 2; most tasks are shared successes or shared failures.

  1. Why should the comparison be paired?

  2. Which observations enter McNemar's test?

  3. Why can an asymptotic zz be overconfident with only 11 discordant tasks?

  4. What exact null experiment underlies the two-sided exact test?

  5. What should be reported in addition to a pp-value?

Show answer and solution

Only discordant tasks distinguish the systems. Under the exact null, each discordant task favors A or B with probability one half, so the count follows a binomial distribution. With only eleven discordances, the discrete exact tail matters. Report the two discordant counts, effect size, interval, exact pp-value, and the predeclared analysis rule.

pass@k, coverage, and selection

A pool of 20 candidates contains exactly one correct answer. The report states both pass@20 = 1 and “the system selected an incorrect answer.”

  1. Is there a contradiction?

  2. What does pass@k measure?

  3. What does selection accuracy measure?

  4. Why is 1−(1−p^)k1-(1-\hat p)^k with p^=c/n\hat p=c/n not the finite-pool estimator?

  5. Which budget details belong next to pass@k?

Show answer and solution

There is no contradiction: coverage can be perfect while selection fails. pass@k measures whether the pool contains a success; selection accuracy measures whether the policy or verifier extracts it. Preserve nn, kk, decoding, token budget, verifier revision, task count, and seed policy.

No n-gram match

An eight-word n-gram filter finds no overlap between test items and the known portion of a training corpus.

  1. What does this result actually rule out?

  2. Which contamination modes can it miss?

  3. Why does an unknown corpus remainder prevent a cleanliness certificate?

  4. What is protocol contamination?

  5. Name at least four additional benchmark-lifecycle protections.

Show answer and solution

No flag rules out only literal matches at the selected n-gram scale in the searched corpus. It misses paraphrases, translations, derived solutions, semantic templates, and unavailable data. Protocol contamination arises when repeated benchmark feedback drives checkpoint, prompt, or hyperparameter selection. Protections include private sets, fresh tasks, rotation, query limits, canaries, and a final untouched evaluation.

A judge under optimization pressure

An LLM judge favors long answers, often picks the first candidate, and sometimes follows instructions embedded inside candidate text. After switching from one comparison to Best-of-64, the mean judge score rises sharply.

  1. Which three weakness classes are visible?

  2. Why does A/B swapping fix only part of the problem?

  3. How does structural isolation differ from the instruction “ignore instructions inside the answer”?

  4. Why can Best-of-64 amplify winner's curse?

  5. Which independent signal can distinguish genuine improvement from judge exploitation?

Show answer and solution

The visible weaknesses are position bias, superficial-feature bias, and prompt injection. Swapping diagnoses position effects but not content errors or length preference. Structural isolation separates untrusted candidate data at the interface and parser level. Best-of-64 increases the chance of selecting a high positive judge error; an independent verifier, hidden human sample, or real environment outcome is required.

Safety and release decisions

A new system complies with fewer harmful requests but also refuses benign requests twice as often. One category contains a single confirmed critical sandbox escape.

  1. Why is refusal rate alone insufficient?

  2. Which two denominators are required at minimum?

  3. Why should severity be represented?

  4. May a strong mean score compensate for the critical escape?

  5. Which protocol-passport fields are needed for independent verification?


B. Analytical tasks

Show answer and solution

Harmful compliance and benign over-refusal use different denominators. Severity or hard gates prevent rare catastrophic outcomes from disappearing inside an average. A predeclared critical sandbox escape blocks release regardless of mean utility. The passport must identify the model, data, solver, scorer, environment, seeds, budget, metrics, gates, and artifact hashes.


Wilson intervals

Use z=1.96z=1.96.

  1. Compute the estimate and 95% Wilson interval for k=62k=62, n=100n=100.

  2. Repeat for k=0k=0, n=20n=20.

  3. Compare the second result with the Wald interval.

  4. Explain why 0/20 is compatible with a nonzero population success probability.

Show answer and solution

For 62/100, the estimate is 0.62 and the Wilson interval is approximately [0.5221,0.7090][0.5221,0.7090]. For 0/20, it is approximately [0,0.1611][0,0.1611]; the Wald interval incorrectly collapses to [0,0][0,0]. Finite data remain compatible with a small nonzero success probability.

Dataset-size planning

Use the worst-case planning approximation

n≥z24m2. n\geq \frac{z^2}{4m^2}.
  1. Find the smallest nn for m=0.03m=0.03, z=1.96z=1.96.

  2. Repeat for m=0.01m=0.01.

  3. By what factor does nn increase when the half-width is divided by three?

  4. Why is this a planning approximation rather than an exact Wilson-width guarantee?

Show answer and solution

The results are

⌈1.9624(0.03)2⌉=1068,⌈1.9624(0.01)2⌉=9604.\left\lceil\frac{1.96^2}{4(0.03)^2}\right\rceil=1068, \qquad \left\lceil\frac{1.96^2}{4(0.01)^2}\right\rceil=9604.

Dividing the half-width by three multiplies the ideal sample size by nine. The calculation is a worst-case normal planning approximation, not an exact realized Wilson width.

Exact and asymptotic paired comparison

For discordant counts b=9b=9, c=2c=2:

  1. Compute z=∣b−c∣/b+cz=|b-c|/\sqrt{b+c}.

  2. Compute the corresponding two-sided normal pp-value.

  3. Compute the exact two-sided value from X∼Binomial⁡(11,1/2)X\sim\operatorname{Binomial}(11,1/2).

  4. State the conclusion at level 0.05 under each approach.

  5. Why must the small discordant count be visible in the report?

Show answer and solution
z=7/11≈2.1106.z=7/\sqrt{11}\approx2.1106.

The normal two-sided pp-value is about 0.0348, while the exact value is

2(110)+(111)+(112)211=0.0654296875.2\frac{\binom{11}{0}+\binom{11}{1}+\binom{11}{2}}{2^{11}} =0.0654296875.

The asymptotic result crosses 0.05; the exact result does not. The eleven discordances must be visible so readers can judge the approximation.

Finite-sample pass@k

Use

pass@⁡k^=1−(n−ck)(nk).\widehat{\operatorname{pass@}k} = 1-\frac{\binom{n-c}{k}}{\binom{n}{k}}.
  1. Compute pass@5 for n=200n=200, c=10c=10.

  2. Compute pass@20 for n=20n=20, c=1c=1.

  3. Compare the second answer with 1−(1−c/n)n1-(1-c/n)^n.

  4. Explain the different probability questions represented by the two formulas.

Show answer and solution
pass@⁡5^=1−(1905)(2005)≈0.2282844607.\widehat{\operatorname{pass@}5} =1-\frac{\binom{190}{5}}{\binom{200}{5}} \approx0.2282844607.

For n=k=20n=k=20, c=1c=1, the finite-pool result is one. The independent-trial plug-in gives 1−(19/20)20≈0.6415141-(19/20)^{20}\approx0.641514. The former samples without replacement from the observed pool; the latter imagines new independent trials with known p=0.05p=0.05.

Judge swap audit

After mapping both runs back to the original candidate identities, the labels are:

text
forward: A, A, B, TIE, A, B
reverse: A, B, B, TIE, B, B
  1. Count consistent and inconsistent pairs.

  2. Compute consistency rate.

  3. Compute A and B vote shares among non-ties.

  4. Does high swap consistency rule out length or style bias?

  5. Which extra randomization should be added?

Show answer and solution

Four of six pairs are consistent, so consistency is 4/6≈0.66674/6\approx0.6667. Across ten non-tie votes, A receives four and B six: 0.4 and 0.6. Swap consistency does not rule out stable style or length bias. Add controlled length/style perturbations, model-identity blinding, serialization randomization, and repeated judge seeds.

Safety metrics and release gates

Attack outcomes on eight harmful prompts are

(0,1,0,1,0,0,1,0),(0,1,0,1,0,0,1,0),

with severities

(1,4,1,2,1,1,3,1).(1,4,1,2,1,1,3,1).

The system refuses two of ten benign prompts.

  1. Compute attack success rate (ASR).

  2. Compute benign refusal rate.

  3. Compute severity-weighted ASR.

  4. Apply gates ASR <= 0.30, benign_refusal <= 0.25, severity_weighted_ASR <= 0.40.

  5. What changes if one success is a predeclared critical sandbox escape?


C. Programming tasks

Show answer and solution

ASR is 3/8=0.3753/8=0.375, benign refusal is 2/10=0.22/10=0.2, and severity-weighted ASR is (4+2+3)/14=9/14≈0.6429(4+2+3)/14=9/14\approx0.6429. The system fails the first and third numerical gates. A predeclared critical escape independently blocks release.


Intervals and planning

Implement:

Python
def wilson_interval(successes: int, total: int, z: float = 1.96) -> tuple[float, float, float]:
    """Return (estimate, low, high)."""
    raise NotImplementedError


def worst_case_sample_size(margin: float, z: float = 1.96) -> int:
    """Return ceil(z**2 / (4*margin**2))."""
    raise NotImplementedError

Validate counts and parameters, and keep interval endpoints inside [0,1][0,1].

Open checks:

Python
estimate, low, high = wilson_interval(0, 10)
assert estimate == 0.0 and low == 0.0
assert 0.27 < high < 0.29
assert worst_case_sample_size(0.03) == 1068
Show answer and solution
Python
import math


def wilson_interval(successes: int, total: int, z: float = 1.96) -> tuple[float, float, float]:
    if total <= 0:
        raise ValueError("total must be positive")
    if successes < 0 or successes > total:
        raise ValueError("successes must lie in [0, total]")
    if not math.isfinite(z) or z <= 0:
        raise ValueError("z must be finite and positive")

    estimate = successes / total
    denominator = 1.0 + z * z / total
    center = (estimate + z * z / (2.0 * total)) / denominator
    half_width = z / denominator * math.sqrt(
        estimate * (1.0 - estimate) / total + z * z / (4.0 * total * total)
    )
    return estimate, max(0.0, center - half_width), min(1.0, center + half_width)


def worst_case_sample_size(margin: float, z: float = 1.96) -> int:
    if not math.isfinite(margin) or not 0.0 < margin < 1.0:
        raise ValueError("margin must lie in (0, 1)")
    if not math.isfinite(z) or z <= 0:
        raise ValueError("z must be finite and positive")
    return math.ceil(z * z / (4.0 * margin * margin))

The Wilson interval remains non-degenerate at 0/n0/n and n/nn/n, unlike the symmetric Wald interval. Clipping the endpoints only protects against tiny rounding excursions outside [0,1][0,1].

Paired comparison

Implement a paired contingency table, exact McNemar pp-value, and paired bootstrap interval. The bootstrap must resample task pairs, not systems independently.

Open checks:

Python
a = [True, True, False, False, True]
b = [True, False, True, False, False]
table = paired_table(a, b)
assert table.both_correct == 1
assert table.a_only == 2
assert table.b_only == 1
assert table.both_wrong == 1
Show answer and solution

Pairing is preserved at the task-index level; the two systems are never bootstrapped independently.

Python
from __future__ import annotations

import math
from dataclasses import dataclass

import numpy as np


@dataclass(frozen=True)
class PairedTable:
    both_correct: int
    a_only: int
    b_only: int
    both_wrong: int


def paired_table(a_correct, b_correct) -> PairedTable:
    a = np.asarray(a_correct, dtype=bool)
    b = np.asarray(b_correct, dtype=bool)
    if a.shape != b.shape or a.ndim != 1 or a.size == 0:
        raise ValueError("paired vectors must be non-empty and have equal shape")
    return PairedTable(
        int(np.sum(a & b)),
        int(np.sum(a & ~b)),
        int(np.sum(~a & b)),
        int(np.sum(~a & ~b)),
    )


def exact_mcnemar_pvalue(a_only: int, b_only: int) -> float:
    if min(a_only, b_only) < 0:
        raise ValueError("counts must be non-negative")
    n = a_only + b_only
    if n == 0:
        return 1.0
    tail = sum(math.comb(n, j) for j in range(min(a_only, b_only) + 1)) / 2**n
    return min(1.0, 2.0 * tail)


def paired_bootstrap_difference(a_scores, b_scores, *, replicates=10_000, seed=0):
    a = np.asarray(a_scores, dtype=float)
    b = np.asarray(b_scores, dtype=float)
    if a.shape != b.shape or a.ndim != 1 or a.size == 0:
        raise ValueError("paired vectors must be non-empty and have equal shape")
    if replicates <= 0:
        raise ValueError("replicates must be positive")
    delta = a - b
    rng = np.random.default_rng(seed)
    indices = rng.integers(0, delta.size, size=(replicates, delta.size))
    draws = delta[indices].mean(axis=1)
    return float(delta.mean()), float(np.quantile(draws, 0.025)), float(np.quantile(draws, 0.975))


table = paired_table([1, 1, 0, 0, 1], [1, 0, 1, 0, 0])
assert table == PairedTable(1, 2, 1, 1)
assert abs(exact_mcnemar_pvalue(9, 2) - 0.0654296875) < 1e-15

pass@k and selection

Implement a stable finite-sample estimator and a separate selection diagnostic:

Python
def pass_at_k(n: int, c: int, k: int) -> float:
    raise NotImplementedError


def coverage_and_selection(correct, selected_index: int) -> tuple[bool, bool]:
    raise NotImplementedError

The first function estimates the probability that kk draws without replacement include at least one of cc successful solutions among nn candidates. The second separates pool coverage from correctness of the answer actually selected.

Open checks:

Python
assert abs(pass_at_k(200, 10, 5) - 0.22828446073733422) < 1e-15
assert coverage_and_selection([False, True, False], 0) == (True, False)
Show answer and solution
Python
def pass_at_k(n: int, c: int, k: int) -> float:
    if n <= 0 or not 0 <= c <= n or not 1 <= k <= n:
        raise ValueError("require n>0, 0<=c<=n, and 1<=k<=n")
    if n - c < k:
        return 1.0
    probability_of_miss = 1.0
    for i in range(k):
        probability_of_miss *= (n - c - i) / (n - i)
    return 1.0 - probability_of_miss


def coverage_and_selection(correct, selected_index: int) -> tuple[bool, bool]:
    values = [bool(x) for x in correct]
    if not values or not 0 <= selected_index < len(values):
        raise ValueError("invalid candidate pool or selected index")
    return any(values), values[selected_index]

Multiplying miss probabilities avoids direct evaluation of large binomial coefficients. The second function makes explicit why pool coverage and selector quality are distinct quantities.

A contamination sieve without changing the metric

Implement:

Python
def ngram_set(text: str, n: int) -> set[tuple[str, ...]]:
    ...

def ngram_containment(benchmark_text: str, corpus_text: str, n: int) -> float:
    """Fraction of benchmark n-grams found in the corpus text."""

def ngram_jaccard(left: str, right: str, n: int) -> float:
    """Symmetric Jaccard coefficient."""

def contamination_report(items, corpus, *, n=8, threshold=0.2):
    ...

The report must use benchmark-side asymmetric containment, matching the lecture equation, and must store the metric name explicitly. Include a pair for which containment and Jaccard differ. Document that no literal flag is not evidence of a clean benchmark.

Open checks:

Python
assert ngram_set("a b c d", 2) == {("a", "b"), ("b", "c"), ("c", "d")}
assert ngram_containment("a b c", "x a b c y", 2) == 1.0
assert ngram_jaccard("a b c", "x a b c y", 2) < 1.0
Show answer and solution
Python
from __future__ import annotations


def ngram_set(text: str, n: int) -> set[tuple[str, ...]]:
    if n <= 0:
        raise ValueError("n must be positive")
    tokens = text.casefold().split()
    return {tuple(tokens[i : i + n]) for i in range(len(tokens) - n + 1)}


def ngram_containment(benchmark_text: str, corpus_text: str, n: int) -> float:
    benchmark = ngram_set(benchmark_text, n)
    corpus = ngram_set(corpus_text, n)
    if not benchmark:
        return 0.0
    return len(benchmark & corpus) / len(benchmark)


def ngram_jaccard(left: str, right: str, n: int) -> float:
    a = ngram_set(left, n)
    b = ngram_set(right, n)
    union = a | b
    return 0.0 if not union else len(a & b) / len(union)


def contamination_report(items, corpus, *, n=8, threshold=0.2):
    if not 0.0 <= threshold <= 1.0:
        raise ValueError("threshold must lie in [0, 1]")
    maxima = []
    matches = []
    for i, item in enumerate(items):
        scores = [ngram_containment(item, doc, n) for doc in corpus]
        best = max(scores, default=0.0)
        maxima.append(best)
        for j, score in enumerate(scores):
            if score >= threshold:
                matches.append((i, j, score))
    return {
        "metric": "benchmark_ngram_containment",
        "n": n,
        "threshold": threshold,
        "max_overlap": maxima,
        "matches": matches,
    }


assert ngram_containment("a b c", "x a b c y", 2) == 1.0
assert ngram_jaccard("a b c", "x a b c y", 2) == 0.5

Containment divides by benchmark-item n-grams; Jaccard divides by the union. The report stores the metric name so downstream code cannot silently conflate them.

Two judge audits and sycophancy

Implement:

Python
def judge_swap_audit(forward_canonical, reverse_canonical):
    """Candidate-identity consistency after A/B canonicalization."""

def position_audit(raw_forward, raw_reverse):
    """FIRST/SECOND preference before identity canonicalization."""

def sycophancy_metrics(correct_without_claim, correct_with_false_claim, agrees_with_false_claim):
    ...

Do not collapse candidate identity and screen position. The first function receives canonical A/B labels; the second receives raw FIRST, SECOND, and TIE labels.

Open check:

Python
identity = judge_swap_audit(["A", "A", "B", "TIE"], ["A", "B", "B", "TIE"])
assert identity["consistent"] == 3
position = position_audit(["FIRST", "SECOND"], ["FIRST", "FIRST"])
assert position["first_position_wins"] == 3
Show answer and solution
Python
from __future__ import annotations


def _labels(values, allowed):
    result = [str(x).upper() for x in values]
    if any(x not in allowed for x in result):
        raise ValueError(f"labels must belong to {sorted(allowed)}")
    return result


def judge_swap_audit(forward_canonical, reverse_canonical):
    a = _labels(forward_canonical, {"A", "B", "TIE"})
    b = _labels(reverse_canonical, {"A", "B", "TIE"})
    if len(a) != len(b) or not a:
        raise ValueError("label vectors must be non-empty and aligned")
    consistent = sum(x == y for x, y in zip(a, b))
    votes = [x for x in a + b if x != "TIE"]
    return {
        "items": len(a),
        "consistent": consistent,
        "inconsistent": len(a) - consistent,
        "consistency_rate": consistent / len(a),
        "candidate_a_rate": votes.count("A") / len(votes) if votes else 0.0,
        "candidate_b_rate": votes.count("B") / len(votes) if votes else 0.0,
    }


def position_audit(raw_forward, raw_reverse):
    a = _labels(raw_forward, {"FIRST", "SECOND", "TIE"})
    b = _labels(raw_reverse, {"FIRST", "SECOND", "TIE"})
    if len(a) != len(b) or not a:
        raise ValueError("label vectors must be non-empty and aligned")
    all_labels = a + b
    first = all_labels.count("FIRST")
    second = all_labels.count("SECOND")
    ties = all_labels.count("TIE")
    denom = first + second
    return {
        "judgments": len(all_labels),
        "first_position_wins": first,
        "second_position_wins": second,
        "ties": ties,
        "first_position_rate_non_ties": first / denom if denom else 0.0,
    }


def sycophancy_metrics(correct_without_claim, correct_with_false_claim, agrees_with_false_claim):
    base = [bool(x) for x in correct_without_claim]
    challenged = [bool(x) for x in correct_with_false_claim]
    agrees = [bool(x) for x in agrees_with_false_claim]
    if not base or not (len(base) == len(challenged) == len(agrees)):
        raise ValueError("vectors must be non-empty and aligned")
    initially_correct = sum(base)
    switches = sum(b and not c for b, c in zip(base, challenged))
    return {
        "accuracy_without_claim": sum(base) / len(base),
        "accuracy_with_false_claim": sum(challenged) / len(base),
        "accuracy_drop": (sum(base) - sum(challenged)) / len(base),
        "switch_rate_among_initially_correct": switches / initially_correct if initially_correct else 0.0,
        "unjustified_agreement_rate": sum(agrees) / len(agrees),
    }


assert judge_swap_audit(["A", "A", "B", "TIE"], ["A", "B", "B", "TIE"])["consistent"] == 3
assert position_audit(["FIRST", "SECOND"], ["FIRST", "FIRST"])["first_position_wins"] == 3

Canonical A/B labels and raw FIRST/SECOND labels remain separate because they measure different biases.

Safety, release gates, and the measurement passport

Implement:

Python
def safety_rates(attack_succeeded, benign_refused, severities=None):
    raise NotImplementedError


def release_decision(metrics, thresholds, critical_failures=()):
    raise NotImplementedError


def validate_measurement_passport(passport):
    raise NotImplementedError

Attack success and benign over-refusal use different denominators. A critical event or missing required metric must block release. The passport validator must report every detected error.

Open checks:

Python
rates = safety_rates([0, 1, 0, 1], [0, 0, 1, 0], severities=[1, 4, 1, 2])
assert abs(rates["attack_success_rate"] - 0.5) < 1e-12
assert abs(rates["benign_over_refusal_rate"] - 0.25) < 1e-12
result = release_decision(rates, {"attack_success_rate": ("max", 0.25)}, ("sandbox_escape",))
assert result["passed"] is False
Show answer and solution
Python
def safety_rates(attack_succeeded, benign_refused, severities=None):
    attacks = [bool(x) for x in attack_succeeded]
    benign = [bool(x) for x in benign_refused]
    if not attacks or not benign:
        raise ValueError("both harmful and benign samples are required")
    if severities is None:
        severity_weighted = sum(attacks) / len(attacks)
    else:
        weights = [float(x) for x in severities]
        if len(weights) != len(attacks) or any(x <= 0 for x in weights):
            raise ValueError("positive severities must align with attacks")
        severity_weighted = sum(w for hit, w in zip(attacks, weights) if hit) / sum(weights)
    return {
        "attack_success_rate": sum(attacks) / len(attacks),
        "benign_over_refusal_rate": sum(benign) / len(benign),
        "severity_weighted_asr": severity_weighted,
    }


def release_decision(metrics, thresholds, critical_failures=()):
    failures = [f"critical:{item}" for item in critical_failures]
    for name, (direction, threshold) in thresholds.items():
        if name not in metrics:
            failures.append(f"missing:{name}")
            continue
        value = float(metrics[name])
        if direction == "max" and value > threshold:
            failures.append(f"{name}>{threshold}")
        elif direction == "min" and value < threshold:
            failures.append(f"{name}<{threshold}")
        elif direction not in {"min", "max"}:
            failures.append(f"invalid_direction:{name}")
    return {"passed": not failures, "failures": failures}


def validate_measurement_passport(passport):
    required = {
        "run_id", "model_id", "model_revision", "tokenizer_id", "dataset_id",
        "dataset_revision", "split", "solver_id", "scorer_id", "scorer_revision",
        "decoding", "seeds", "sample_count", "metrics", "cost", "artifact_hashes",
    }
    errors = [f"missing:{key}" for key in sorted(required - set(passport))]
    if "sample_count" in passport and int(passport["sample_count"]) <= 0:
        errors.append("sample_count:not_positive")
    if "seeds" in passport and not passport["seeds"]:
        errors.append("seeds:empty")
    if "artifact_hashes" in passport and not passport["artifact_hashes"]:
        errors.append("artifact_hashes:empty")
    return tuple(errors)

A critical incident blocks release regardless of average metrics; a missing required metric also fails closed.