Chapter 64 · The model layer

Comparing two models on your own task

A leaderboard measures someone else's work. The procedure for comparing models on yours in an afternoon, the five metrics that decide it, and the statistics that stop you fooling yourself.

11 min read

The last chapter of section 5, and it assembles the measurement apparatus the section has been building into one procedure you will run every time a model changes.

Chapter 51 chose the first model. This is the comparison you run on every successor, and the discipline is the same as chapter 39's embedding bake-off: your corpus, your subsets, reported together.

Why the leaderboard cannot answer this

A public benchmark averages across tasks none of which is yours, on inputs that are not your inputs, scored by a method that is not your scoring.

Atlas's task is narrow and specific: read six retrieved passages, write two sentences, cite them correctly, and decline when they do not contain the answer. Nothing on a leaderboard measures that combination, and the capability that matters most here — abstention — is barely measured anywhere.

A benchmark tells you which models to test. Only your test set tells you which to ship.

The five metrics

// src/evals/compare.ts export interface ModelScore { correct: number // the answer is right grounded: number // every claim is supported by a cited source citationsValid: number // every cited number exists and supports the claim abstention: number // declines when it should, answers when it should falseAbstention: number // ⚠️ declines when it should not. Chapter 63. p50Ms: number p95Ms: number costPer1k: number }

Reported as a table, never as a single composite. A weighted score hides the trade that the comparison exists to reveal.

The procedure

// scripts/compare-models.ts export async function compareModels(models: string[], cases: EvalCase[]) { // ⚠️ Everything except the model is fixed: retrieval config, prompt // (core only, per chapter 54), temperature, context budget. Varying two // things measures neither. const fixed = { retrieval: RETRIEVAL_CONFIG, prompt: 'answer.core.md', temperature: 0, budget: CONTEXT_BUDGET, } // Retrieve ONCE and reuse. The same chunks for every model, so the // comparison is of generation alone — and it removes retrieval's own // variance from the result. const retrieved = new Map(await Promise.all(cases.map(async (c) => [c.id, await retrieveAndRerank(c.question, { ...fixed.retrieval, scope: scopeFor(c) })] as const, ))) const results: Record<string, ModelScore> = {} for (const model of models) { // n=3 and take the majority, per chapter 58. A single run on a // non-deterministic system is a sample of size one. results[model] = await scoreModel(model, cases, retrieved, { ...fixed, n: 3 }) } return results }

Retrieving once and reusing is the detail that makes the comparison trustworthy. Running retrieval per model introduces variance that has nothing to do with the models, and on a 120-case set that variance is comparable to the difference being measured.

Reading the result

Atlas, 120 cases, three candidates:

IncumbentSuccessor ASuccessor B
Correct0.910.940.92
Grounded0.980.990.99
Citations valid0.991.000.99
Abstention0.970.960.98
False abstention0.060.040.11
p501,500 ms1,320 ms980 ms
p953,100 ms2,700 ms1,900 ms
Cost1.0×1.4×0.6×

B is faster and cheaper and looks competitive on the headline. Its false-abstention rate of 0.11 is disqualifying: it declines on one answerable question in nine, which is chapter 63's failure — a system that is reliable and not useful.

A is the better model and costs 40% more. Whether that is worth it is a business decision, and the table is what makes it a decision rather than a guess.

Subsets, again

const SUBSETS = ['exact_token', 'negation', 'multi_chunk', 'multi_hop', 'conflict', 'abstention_expected', 'follow_up'] as const
SubsetnIncumbentAB
Negation80.750.880.75
Conflict60.831.000.83
Multi-hop120.830.920.83
Abstention expected220.950.911.00
Everything else720.940.950.94

The aggregate difference between the incumbent and A — three points — is entirely in three small subsets. On the 72 ordinary cases they are indistinguishable.

That changes the decision. If negation, conflicts and multi-hop are 22% of real traffic, A is clearly worth 40%. If they are 3%, it is not, and the right answer is routing: the incumbent by default, A for the classified-hard cases.

// Which is a measurable choice rather than a compromise. export function modelFor(question: string, route: Route): string { if (route.multiHop) return MODELS.a if (detectNegation(question) || route.conflictLikely) return MODELS.a return MODELS.incumbent } // Measured: 0.94 correctness at 1.09x cost, against 0.94 at 1.4x for A // everywhere. The routing captures the gain for a quarter of the premium.

Not fooling yourself

With 120 cases, a three-point difference is 3.6 questions. Two checks before believing it.

// 1. Paired bootstrap. The cases are the same for both models, so pair them // — a paired test is much more sensitive than comparing two averages. export function pairedBootstrap(a: Row[], b: Row[], runs = 10_000) { const diffs = a.map((row, i) => (+b[i].correct) - (+row.correct)) const observed = mean(diffs) let atLeastAsExtreme = 0 for (let i = 0; i < runs; i++) { const resampled = diffs.map(() => diffs[Math.floor(Math.random() * diffs.length)]) if (Math.abs(mean(resampled)) >= Math.abs(observed)) atLeastAsExtreme++ } return { observed, p: atLeastAsExtreme / runs } } // Atlas, incumbent vs A: observed +0.030, p = 0.041. Real, and narrowly. // Incumbent vs B: observed +0.008, p = 0.63. Noise.
// 2. Look at the cases that CHANGED, not the average. Chapter 48's rule. const { improved, regressed } = compare(incumbentRows, aRows) // improved (9): neg-002 neg-005 cfl-001 cfl-004 mh-003 mh-007 ... // regressed (3): abs-011 abs-014 abs-019 <- all abstention cases

The regression list being entirely abstention cases is the finding that the aggregate could not produce, and it is consistent with A's slightly lower abstention score. Three regressions in one subset is a pattern, not noise, and it is what to check before shipping.

Judged metrics need calibrating

grounded and citationsValid at the entailment level are judged by a model, and chapter 106 covers this properly. The short version for a comparison:

// ⚠️ Never judge a model's output with itself. A model rates its own // phrasing favourably, and the bias is large enough to reverse a close // comparison. const JUDGE = 'a model from a different family than any candidate'
// And calibrate the judge once against human ratings before trusting it. // 40 answers, rated by two people, compared to the judge. // Atlas: judge-human agreement 0.89 on grounded, 0.94 on citations valid. // ⚠️ Below ~0.85, the judge is adding noise rather than signal.

When to run it

TriggerScope
A new model is availableFull comparison
The incumbent is deprecatedFull, with a deadline
Quarterly reviewIncumbent against best successor
Cost pressureCheaper candidates only, against the same bar
A quality complaintNot this — diagnose first

The last row matters. A quality complaint is a retrieval problem four times in five, and running a model comparison in response is how a week gets spent on the component that was working.

Section 5 is done

Section startEnd
Correct—0.91
Grounded—0.98
Citations valid—0.99
Correct abstention—0.97
False abstention—0.06
p50 total1,774 ms1,810 ms
p50 time to first token—640 ms

Section 6 is tools: Atlas stops answering and starts doing. Everything in this section still applies, and the stakes change — a wrong answer is embarrassing, and a wrong action is expensive.

The failure mode: the comparison that was run once

The comparison is run, a model is chosen, and the script is never run again. Eighteen months later the incumbent is three generations old, as chapter 54 described — but there is a second, worse version of this.

The comparison is re-run and the test set has not changed. The 120 cases were harvested when the system launched, and the question distribution has moved: people have learned what Atlas does well and now ask different things. The comparison is scored against questions nobody asks any more, and it picks a model optimised for a distribution that no longer exists.

// The guard: assert the test set still reflects production before trusting // a comparison run against it. export async function assertTestSetCurrent() { const composition = await productionQuestionComposition({ days: 30 }) const testComposition = testSetComposition() for (const subset of SUBSETS) { const drift = Math.abs(composition[subset] - testComposition[subset]) if (drift > 0.10) { throw new Error( `test set drift on '${subset}': production ${pct(composition[subset])}, ` + `test set ${pct(testComposition[subset])}. Re-harvest before comparing models.`, ) } } }

Running that check on Atlas at six months found follow_up at 31% in production against 9% in the test set — chapter 49's harvesting finding, reappearing as a reason not to trust a model comparison.

A model comparison is only as current as the test set it runs against. Check the distribution before you check the models.

Key points

  • A benchmark tells you which models to test; your test set tells you which to ship.
  • Five metrics in a table, never a composite. A weighted score hides the trade.
  • Fix everything but the model, and retrieve once and reuse. Per-model retrieval adds variance comparable to the effect.
  • Run n=3 and take the majority. A single run is a sample of size one.
  • A disqualifying number can sit behind a competitive headline: 0.11 false abstention is reliable and useless.
  • Read subsets. Three points of aggregate difference was entirely in three small subsets, which turns the decision into a routing question.
  • Routing captured the gain for a quarter of the premium: 0.94 at 1.09× instead of 1.4×.
  • Use a paired bootstrap, and look at which cases changed. Three regressions in one subset is a pattern.
  • Never judge with a candidate model. Calibrate the judge against human ratings; below ~0.85 agreement it is noise.
  • Do not run a model comparison in response to a quality complaint. Diagnose first.
  • Check the test set still matches production before trusting any comparison.

Next, and the start of section 6: from answering to doing — and the failure cost changing from embarrassing to expensive.