Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
After tokenization, a model looks up an embedding for each token ID. Those vectors are the starting geometry for prediction. The remaining question is narrower: when the real next token appears, how much probability did the model assign to it?
Suppose a held-out incident log begins The request returned. A model that assigns high probability to 500 and almost none to volcano fits this local pattern. A model that treats both as equally plausible doesn't. Perplexity turns that held-out behavior into one number you can compare across checkpoints, as long as the measurement contract stays fixed.
That number can still point in the wrong product direction. One checkpoint may predict incident prose better yet recommend an unsafe rollback, while another scores worse on tokens and passes the source and policy checks. Perplexity tells you which model fits this text; it doesn't choose the release.
Prediction-fit metric: Perplexity is a next-token fit metric for causal language models. It's useful when you keep the evaluation contract fixed. It isn't a score for factuality, helpfulness, or safe product behavior.
From surprise to a metric
A causal language model assigns a probability to every possible next token. During evaluation, you don't reward the model for a token it could have emitted. You score the probability it assigned to the token that occurred in held-out text.
If the observed token has probability , its negative log-likelihood (NLL), or surprise, is:
A certain correct prediction has probability 1 and surprise 0. A probability close to 0 produces a large penalty. Confident misses hurt language-model loss the most.
Before running the small calculation, predict which token contributes the largest surprise. It should be volcano: its probability is closest to zero.
1import math
2
3probabilities = {
4 "500": 0.60,
5 "200": 0.25,
6 "volcano": 0.001,
7}
8
9for token in ["500", "200", "volcano"]:
10 surprise = -math.log(probabilities[token])
11 print(f"{token:9s} probability={probabilities[token]:.3f} surprise={surprise:.3f} nats")1500 probability=0.600 surprise=0.511 nats
2200 probability=0.250 surprise=1.386 nats
3volcano probability=0.001 surprise=6.908 natsThe held-out token matters. If the log says 500, the first score counts. It doesn't matter that 200 also sounded reasonable: likelihood evaluates the text the model was asked to predict.
A model assigns probability 0.8 to the observed token on one step and 0.02 on another. Which step dominates its loss, and why?
Answer
The 0.02 step dominates because negative log-likelihood grows as assigned probability falls. A confident miss contributes far more surprise than an already likely observed token.
Average surprise becomes perplexity
For held-out tokens , the average NLL is:
For one-hot next-token targets, that average NLL is also the causal cross-entropy loss: the model's average penalty on the observed next tokens.
Perplexity exponentiates that average:
This is the standard definition used for autoregressive, or causal, language models. Speech-recognition work already treated perplexity as an effective branching factor; causal LMs report the same exponentiated-loss form today.[1] It isn't the standard metric for masked models such as BERT, because they predict masked positions rather than the next token in sequence.[2]
Start with three observed token probabilities. The 0.10 token should dominate the average NLL, but the final PPL should be computed only after that average:
1import math
2
3observed_probabilities = [0.50, 0.10, 0.80]
4token_nll = [-math.log(probability) for probability in observed_probabilities]
5average_nll = sum(token_nll) / len(token_nll)
6perplexity = math.exp(average_nll)
7
8print(f"token NLL: {[round(value, 3) for value in token_nll]}")
9print(f"average NLL: {average_nll:.3f} nats")
10print(f"perplexity: {perplexity:.2f}")1token NLL: [0.693, 2.303, 0.223]
2average NLL: 1.073 nats
3perplexity: 2.92The output means the model behaved, on average, as though it faced about 2.92 equally likely choices at each prediction step. That effective choice count is an interpretation, not a claim that exactly 2.92 vocabulary tokens were available.

A uniform guess among options has probability and perplexity . That's why people read PPL as a branching factor:
1import math
2
3for equally_likely_options in [1, 4, 20, 100]:
4 probability = 1 / equally_likely_options
5 loss = -math.log(probability)
6 perplexity = math.exp(loss)
7 print(f"{equally_likely_options:3d} options -> loss={loss:.3f}, PPL={perplexity:.1f}")11 options -> loss=-0.000, PPL=1.0
2 4 options -> loss=1.386, PPL=4.0
3 20 options -> loss=2.996, PPL=20.0
4100 options -> loss=4.605, PPL=100.0Training logs usually show the same average in nats (natural log). Information theory often uses bits. They're the same surprise in different units: divide nats by to get bits per token, and .
1import math
2
3average_nll_nats = 1.073
4bits_per_token = average_nll_nats / math.log(2)
5from_nats = math.exp(average_nll_nats)
6from_bits = 2 ** bits_per_token
7
8print(f"{bits_per_token:.3f} bits/token")
9print(f"exp(nats)={from_nats:.2f}")
10print(f"2**(bits)={from_bits:.2f}")11.548 bits/token
2exp(nats)=2.92
32**(bits)=2.92Because the map is exponential, a constant loss step multiplies PPL by a constant factor. Adding nats doubles the score:

Those formulas assume you already have probabilities. A real evaluator starts from logits, and a naive conversion can fail before the average is ever computed.
Compute from logits without numerical failure
Models produce logits, not probabilities. A naive implementation calls exp(logit) directly. Large logits can overflow even though the eventual softmax probabilities are ordinary values. Stable log-softmax subtracts the largest logit before exponentiating.
Before running the example, predict which path fails: raw exp(1000), not the shifted log-softmax calculation.
1import math
2
3def stable_log_softmax(logits: list[float]) -> list[float]:
4 maximum = max(logits)
5 log_normalizer = maximum + math.log(
6 sum(math.exp(value - maximum) for value in logits)
7 )
8 return [value - log_normalizer for value in logits]
9
10logits = [1000.0, 998.0, 997.0]
11observed_token_id = 0
12
13try:
14 math.exp(logits[observed_token_id])
15except OverflowError:
16 print("naive exp(logit) overflowed")
17
18log_probabilities = stable_log_softmax(logits)
19nll = -log_probabilities[observed_token_id]
20print(f"stable NLL={nll:.3f}, PPL={math.exp(nll):.3f}")1naive exp(logit) overflowed
2stable NLL=0.170, PPL=1.185In a framework evaluator, cross-entropy normally applies this stable computation for you. You still need to know the principle when debugging inf losses, implementing metrics, or reviewing a custom evaluation loop.
Why should an evaluator accumulate negative log-likelihood instead of multiplying token probabilities together?
Answer
Products of many probabilities underflow toward zero. Sums of log-probabilities stay numerically usable, and exponentiating the average at the end yields the same perplexity.
A finite PPL is still only a number. The next problem is whether two of those numbers are even comparable.
The comparison contract
A perplexity score is never complete without its units and conditioning rules. At minimum, log:
| Contract field | Why it changes the score |
|---|---|
| Dataset and split | An incident-log corpus isn't a legal-contract corpus; train data isn't held-out data. |
| Tokenizer revision | Tokens set the denominator and the events being predicted. |
| Context length and stride | More usable left context generally makes token prediction easier. |
| Special-token and masking policy | Scoring or skipping initial and padding tokens changes the aggregate. |
| Model objective | A causal next-token model isn't directly comparable to a masked-language objective. |
Matching those fields makes a checkpoint comparison interpretable, but it doesn't make one corpus represent every domain. Paloma (Perplexity Analysis for Language Model Assessment) was built because perplexity on a single held-out distribution can hide large gaps elsewhere: a model that looks healthy on one source can look much worse on another, even under a controlled protocol.[3]
Log each domain separately. Don't treat an incident-log score as a legal-contract score.

Represent that contract in code before you compare checkpoints:
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class EvaluationContract:
5 dataset: str
6 tokenizer: str
7 context_tokens: int
8 stride_tokens: int
9 objective: str = "causal-next-token"
10 special_token_policy: str = "skip-padding"
11 first_token_policy: str = "skip-unconditioned-first-token"
12 masking_policy: str = "score-each-target-once"
13
14def comparable(left: EvaluationContract, right: EvaluationContract) -> bool:
15 return left == right
16
17baseline = EvaluationContract("incident-holdout-v3", "bpe-v7", 2048, 512)
18new_checkpoint = EvaluationContract("incident-holdout-v3", "bpe-v7", 2048, 512)
19short_context_run = EvaluationContract("incident-holdout-v3", "bpe-v7", 512, 512)
20different_masking_run = EvaluationContract(
21 "incident-holdout-v3",
22 "bpe-v7",
23 2048,
24 512,
25 masking_policy="score-all-window-targets",
26)
27
28print("baseline vs new checkpoint:", comparable(baseline, new_checkpoint))
29print("baseline vs short context:", comparable(baseline, short_context_run))
30print("baseline vs different masking:", comparable(baseline, different_masking_run))1baseline vs new checkpoint: True
2baseline vs short context: False
3baseline vs different masking: FalseNow compare two model checkpoints on the same token outcomes:
1import math
2
3def perplexity(observed_probabilities: list[float]) -> float:
4 average_nll = sum(-math.log(p) for p in observed_probabilities) / len(
5 observed_probabilities
6 )
7 return math.exp(average_nll)
8
9held_out_probabilities = {
10 "checkpoint-0400": [0.31, 0.44, 0.18, 0.52, 0.24],
11 "checkpoint-0800": [0.42, 0.59, 0.29, 0.61, 0.35],
12}
13
14for name, probabilities in held_out_probabilities.items():
15 print(f"{name}: PPL={perplexity(probabilities):.2f}")1checkpoint-0400: PPL=3.18
2checkpoint-0800: PPL=2.31The result supports a narrow statement: checkpoint-0800 predicts tokens in this held-out set better under this protocol. It doesn't yet prove better incident answers. It also doesn't prove the same ranking on a different domain.
Aggregate loss once
Evaluation windows are rarely the same size. Averaging each window's already exponentiated PPL gives every window equal influence, so a short window can offset a long one too much. Sum NLL weighted by scored-token count, divide once, and exponentiate once.
1import math
2
3windows = [
4 {"average_nll": 0.50, "scored_tokens": 2},
5 {"average_nll": 2.00, "scored_tokens": 8},
6]
7
8wrong = sum(math.exp(window["average_nll"]) for window in windows) / len(windows)
9total_nll = sum(
10 window["average_nll"] * window["scored_tokens"] for window in windows
11)
12total_tokens = sum(window["scored_tokens"] for window in windows)
13right = math.exp(total_nll / total_tokens)
14
15print(f"average of window PPLs: {wrong:.2f}")
16print(f"token-weighted corpus PPL: {right:.2f}")1average of window PPLs: 4.52
2token-weighted corpus PPL: 5.47Run A reports PPL 12 and run B reports PPL 10. What must be checked before declaring B better?
Answer
Check that dataset split, tokenizer, context length, stride, masking and special-token policy, and model objective match. If any of those changed, raw PPL no longer isolates model improvement.
A matched contract still assumes both models emit the same kind of token. When vocabularies differ, the denominator itself has changed.
Different tokenizers need common units
Token-level perplexity depends on tokenization. The string Compile CI log may be four subword tokens for one model and fourteen character tokens for another. A probability event per large subword isn't the same unit as a probability event per character. Hugging Face's perplexity documentation explicitly warns that tokenization affects PPL comparisons.[2]
For the same raw UTF-8 evaluation text, bits per byte (BPB) gives both models one shared denominator. The Pile preferred BPB as a practical comparison unit when tokenizers differ.[4]
Paloma adopts that unit when vocabulary can't be held fixed, and still prefers one shared vocabulary whenever the experiment allows it. BPB scores the canonical token sequence each tokenizer chose; it doesn't marginalize over every valid segmentation of the same bytes.[3]
where is the byte count of the original text. A related metric, bits per character, is useful when a benchmark defines character units instead of bytes.

Predict the ranking before running the example: raw token PPL should favor the character model because its loss is spread across more tokens. BPB should favor the subword model because its total NLL is lower on the same bytes.
1import math
2
3text = "Compile CI log"
4byte_count = len(text.encode("utf-8"))
5evaluations = [
6 {"name": "subword model", "tokens": 4, "total_nll": 8.4},
7 {"name": "character model", "tokens": 14, "total_nll": 9.0},
8]
9
10for run in evaluations:
11 ppl = math.exp(run["total_nll"] / run["tokens"])
12 bpb = run["total_nll"] / (byte_count * math.log(2))
13 print(f"{run['name']:15s} token PPL={ppl:.2f}, BPB={bpb:.3f}")
14
15print("Lower BPB identifies less surprise on identical bytes.")1subword model token PPL=8.17, BPB=0.866
2character model token PPL=1.90, BPB=0.927
3Lower BPB identifies less surprise on identical bytes.The character model looks dramatically better under raw token PPL because it predicts smaller units. BPB reverses that ranking here: the subword model assigned less surprise to the same bytes.
Raw token PPL mixes event size with model fit. A larger vocabulary can emit fewer, coarser tokens, while a larger choice set can change per-token surprise. Those effects can pull in opposite directions, so don't infer a tokenizer or model win from vocabulary size alone. BPB fixes the raw-byte denominator, but it doesn't erase every segmentation effect. Keep logging the tokenizer and use a fixed vocabulary when you can.
BPB fixes the unit. It doesn't tell you how to score a document that's longer than the model's context.
Long documents need a scoring policy
A real evaluation file may contain thousands of tokens, while a model accepts only a fixed number of context tokens. Cutting text into disjoint blocks is fast, but tokens at each block boundary lose usable left context. A strided sliding window reuses context and scores only newly exposed target tokens.
Hugging Face demonstrates this protocol for GPT-2 Large on WikiText-2: a no-overlap stride = 1024 run reports PPL 19.44, while stride = 512 reports 16.44 for the same model and corpus. More context improved the score; the model weights didn't change.[2]
The no-overlap number sits near the 19.93 WikiText-2 result reported for the 762M GPT-2 model. That paper used its own preprocessing and invertible de-tokenizers, so treat 19.44 versus 19.93 as the same ballpark rather than a reproduction.[5]

Before reading the output, predict which positions contribute loss: A supplies initial context, and B through J should each be scored exactly once.
Now simulate which positions a sliding-window loop scores:
1tokens = list("ABCDEFGHIJ")
2windows = [
3 {"context": (0, 5), "score": (1, 5)},
4 {"context": (3, 8), "score": (5, 8)},
5 {"context": (5, 10), "score": (8, 10)},
6]
7
8scored_tokens: list[str] = []
9for index, window in enumerate(windows, start=1):
10 begin, end = window["context"]
11 score_begin, score_end = window["score"]
12 context = "".join(tokens[begin:end])
13 scored = "".join(tokens[score_begin:score_end])
14 scored_tokens.extend(tokens[score_begin:score_end])
15 print(f"window {index}: context={context}, newly scored={scored}")
16
17print("scored exactly once:", scored_tokens == tokens[1:])1window 1: context=ABCDE, newly scored=BCDE
2window 2: context=DEFGH, newly scored=FGH
3window 3: context=FGHIJ, newly scored=IJ
4scored exactly once: TrueThe first token is input context because a causal model needs a previous position before it can score a next-token target. In a framework implementation, context-only labels are commonly masked with -100 so cross-entropy ignores them.[2]
This dependency-free evaluation loop uses precomputed token NLL values. A real model supplies the losses; aggregation logic stays the same.
1import math
2
3new_target_losses = [
4 [0.30, 0.72, 0.51, 0.43],
5 [0.27, 0.61, 0.38],
6 [0.56, 0.48],
7]
8
9total_nll = sum(sum(window) for window in new_target_losses)
10scored_tokens = sum(len(window) for window in new_target_losses)
11perplexity = math.exp(total_nll / scored_tokens)
12
13print(f"scored tokens={scored_tokens}")
14print(f"average NLL={total_nll / scored_tokens:.3f}")
15print(f"PPL={perplexity:.2f}")1scored tokens=9
2average NLL=0.473
3PPL=1.61For every reported PPL, store max_context_tokens, stride_tokens, the first-token policy, and the masking policy beside the score. Those details are measurement settings, not implementation trivia.
A carefully measured PPL still answers only one question: how surprised was the model by held-out tokens?
PPL answers one question, not every question
Suppose your incident assistant predicts common status-log language fluently but recommends the wrong rollback step. Perplexity can reward fluent next-token prediction without detecting that operational failure. Likewise, changing decoding strategy can change generated text quality even when the underlying model is unchanged, as Holtzman et al. demonstrated when studying repetitive neural generation.[6]
Use PPL for the question it answers:
| Decision | Useful measurement |
|---|---|
| Did a base-model checkpoint get better at held-out next-token prediction? | PPL under fixed protocol, or BPB across tokenizers |
| Did the assistant provide the correct incident status and cite supplied evidence? | Task-specific deterministic checks |
| Did an open-ended reply follow a rubric for clarity and groundedness? | Calibrated judge or human review |
| Is a release safe for a high-impact workflow? | Task regressions plus human-reviewed edge cases |
1candidates = [
2 {"name": "fluent-wrong", "ppl": 8.9, "policy_checks_passed": 1},
3 {"name": "grounded-answer", "ppl": 9.8, "policy_checks_passed": 3},
4]
5
6best_language_fit = min(candidates, key=lambda row: row["ppl"])
7best_product_answer = max(candidates, key=lambda row: row["policy_checks_passed"])
8
9print("best held-out language fit:", best_language_fit["name"])
10print("best incident answer result:", best_product_answer["name"])1best held-out language fit: fluent-wrong
2best incident answer result: grounded-answer
Status, citation ID, and a forbidden runbook claim are deterministic. Score those first, before anyone reaches for a model judge.
1EXPECTED_STATUS = "blocked"
2REQUIRED_SOURCE = "incident_policy_483"
3
4def score_answer(answer: dict[str, str]) -> tuple[int, list[str]]:
5 failures: list[str] = []
6 if answer["status"] != EXPECTED_STATUS:
7 failures.append("wrong status")
8 if answer["source"] != REQUIRED_SOURCE:
9 failures.append("missing evidence")
10 return 2 - len(failures), failures
11
12answers = [
13 {"name": "A", "status": "blocked", "source": "incident_policy_483"},
14 {"name": "B", "status": "approved", "source": "incident_policy_483"},
15]
16
17for answer in answers:
18 score, failures = score_answer(answer)
19 print(answer["name"], score, failures or ["pass"])1A 2 ['pass']
2B 1 ['wrong status']Open-ended tone and partial credit may need a rubric. LLM judges can scale that review, but they bring their own measurement problems: position bias, verbosity bias, and a preference for model-like answers.[7]
Treat a judge as a calibrated instrument, not as ground truth. LLM Benchmarks & Limitations builds those controls after you have source documents you can cite. Keep PPL in its lane here: it doesn't substitute for product evidence.
Even a clean split between PPL and product checks fails if the evaluation records were in the training set.
Keep evaluation data clean
PPL needs held-out text. If training data includes your evaluation records, lower loss may reflect memorization rather than generalization. Product task suites have the same failure: if prompt examples or fine-tuning rows include hidden test tickets, release metrics lose meaning.
1training_record_ids = {"ticket-101", "ticket-102", "ticket-103"}
2validation_record_ids = {"ticket-201", "ticket-202", "ticket-103"}
3
4overlap = training_record_ids & validation_record_ids
5if overlap:
6 print("FAIL leaked record ids:", sorted(overlap))
7else:
8 print("PASS validation set is disjoint")1FAIL leaked record ids: ['ticket-103']For public LLM benchmarks, test content can also enter later training corpora. LiveBench addresses that risk with frequently updated questions from recent sources and objective ground-truth scoring; it limits contamination risk rather than making every future score immune to leakage.[8]

Build an evaluation report
An engineering metric becomes useful when it ships with enough context to reproduce a decision. A compact report should include metric value, protocol fields, leakage checks, and product task gates.
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class Report:
5 checkpoint: str
6 perplexity: float
7 dataset: str
8 tokenizer: str
9 context_tokens: int
10 stride_tokens: int
11 objective: str
12 special_token_policy: str
13 first_token_policy: str
14 masking_policy: str
15 leaked_records: int
16 policy_pass_rate: float
17
18def release_gate(report: Report) -> str:
19 if report.leaked_records:
20 return "BLOCK: contaminated evaluation set"
21 if report.policy_pass_rate < 1.0:
22 return "BLOCK: product regressions"
23 return "PASS: protocol recorded and product checks passed"
24
25report = Report(
26 checkpoint="incident-lm-0800",
27 perplexity=9.81,
28 dataset="incident-holdout-v3",
29 tokenizer="bpe-v7",
30 context_tokens=2048,
31 stride_tokens=512,
32 objective="causal-next-token",
33 special_token_policy="skip-padding",
34 first_token_policy="skip-unconditioned-first-token",
35 masking_policy="score-each-target-once",
36 leaked_records=0,
37 policy_pass_rate=1.0,
38)
39
40print(f"{report.checkpoint}: PPL={report.perplexity} @ {report.context_tokens}/{report.stride_tokens}")
41print(
42 "protocol:",
43 report.objective,
44 report.special_token_policy,
45 report.first_token_policy,
46 report.masking_policy,
47)
48print(release_gate(report))1incident-lm-0800: PPL=9.81 @ 2048/512
2protocol: causal-next-token skip-padding skip-unconditioned-first-token score-each-target-once
3PASS: protocol recorded and product checks passedThe report refuses two common shortcuts: treating an untrusted held-out set as evidence, and treating language fit as a substitute for application correctness.
Perplexity rules worth keeping
- Perplexity is
exp(average NLL): an interpretable view of held-out next-token surprise. - Raw PPL comparison requires the same dataset, tokenizer, objective, context, stride, and masking policy. One domain still doesn't stand in for another.
- Bits per byte puts models with different tokenizers onto one raw-text denominator. A shared vocabulary is even better when you can hold it fixed.
- Long-document evaluation must score new target tokens once while reusing context and aggregating loss before exponentiating.
- Low PPL doesn't establish factual, useful, or safe outputs; application checks and calibrated review answer those questions.
- Leakage invalidates confident evaluation claims, whether the set measures PPL or product behavior.
Mastery check
Key concepts
- Held-out next-token likelihood
- Cross-entropy to perplexity
- Stable log-probability scoring
- Evaluation protocol contracts
- Bits-per-byte normalization
- Strided context windows
- Domain-specific held-out fit
- Intrinsic versus product quality
- Leakage-resistant evaluation sets
Evaluation rubric
- Foundational: Computes token surprise, average NLL, and PPL from observed probabilities.
- Intermediate: Explains effective choice count without treating it as a vocabulary-size claim.
- Intermediate: Rejects invalid raw PPL comparisons by checking protocol fields.
- Intermediate: Refuses to treat one domain's PPL as a score for every other domain.
- Advanced: Uses BPB when tokenizers differ and aggregates strided loss correctly.
- Advanced: Designs a report that separates language-fit metrics from product and leakage gates.
Follow-up questions
Why can't two models be compared by raw PPL when their tokenizers differ?
Answer
PPL averages surprise per token, and each tokenizer defines different prediction events and token counts. Use a shared denominator such as raw UTF-8 bytes for a fairer comparison, and still report tokenizers because BPB doesn't remove every segmentation effect.
Why does decreasing stride often lower PPL for the same fixed-context model?
Answer
A smaller stride reuses more left context for newly scored tokens. Predictions become better conditioned, so the protocol usually produces lower loss even though the model weights didn't change.
A runbook model has lower PPL but fails rollback-safety checks. Which model should ship?
Answer
Don't use PPL to override a product correctness failure. PPL says the model fits held-out text better; an incident-assistant release must pass task-specific safety and evidence checks.
Common pitfalls
Comparing scores without a protocol
-
Symptom: A team declares victory from PPL 10 versus PPL 12 but can't name the tokenizer, data split, or stride.
-
Cause: The score was treated as a universal model rating instead of a metric with units and conditioning rules.
-
Fix: Store the evaluation contract with every result and compare raw PPL only when contracts match.
Averaging window perplexities
-
Symptom: Long-document PPL changes when window boundaries move, even though scored token losses are unchanged.
-
Cause: Per-window perplexities were averaged directly.
-
Fix: Sum token NLL across all windows, divide by scored-token count once, then exponentiate.
Selecting chat behavior using language fit alone
-
Symptom: A fluent model ships a wrong policy answer because it had the lowest PPL.
-
Cause: Intrinsic next-token evaluation was confused with application correctness.
-
Fix: Gate releases on deterministic task checks and calibrated review in addition to base-model fit metrics.
Treating one domain as the whole story
-
Symptom: Incident-log PPL improved, so the team skips measuring legal-contract or code holdouts.
-
Cause: A single held-out mix was treated as a universal language-fit score.
-
Fix: Keep the protocol fixed, then measure each domain you care about. Paloma exists because those gaps can be large.
Testing on leaked records
-
Symptom: Evaluation looks unusually strong, then fails on genuinely new tickets.
-
Cause: Training or prompt examples overlap with hidden evaluation data.
-
Fix: Enforce disjoint identifiers, keep private held-out records, and rotate realistic challenge cases.