Model judges, score dimensions, drift, and Cohen's kappa¶
Part 6 · Evaluations · Reference 02
Version floor: every symbol in this guide is iOS 27.0 / iPadOS 27.0 / Mac Catalyst 27.0 /
macOS 27.0 / visionOS 27.0 / watchOS 27.0, all tagged Beta, and every one of them requires
Xcode 27. There is no 26.x story here at all: the Evaluations module did not exist before this
cycle, so unlike Foundation Models — where 26.0, 26.4 and 27.0 all ship different surfaces — you
either have the whole framework or none of it. tvOS is absent from both the documentation
availability line and the session's spoken platform list; do not plan on it. The framework is also
Swift-only, confirmed by an Apple Frameworks Engineer on the developer forums, so a Python or
Objective-C test target cannot host an evaluation without a Swift interop layer.
One thing this guide has to correct before you read a line of code: Cohen's kappa is not part of the Evaluations framework. It is hand-written Swift in Apple's sample project. Every session that discusses judge alignment discusses κ, the number appears in Apple's own test assertions, and a reasonable reader concludes it ships. It does not. §15 shows you what you have to write.
✅ VERIFIED — interface check, 2026-07-29. The framework ships inside Xcode 27, not the OS SDK (guide 01's distribution box has the full story), and its real macOS Swift interface is captured in this repo at
notes/sdk-interfaces/Evaluations-27.0-macos.swiftinterface. This guide has been read against it end-to-end: claims marked ✅ SDK-verified (Evaluations-27.0-macos.swiftinterface:<lines>) cite it, and it closed three of this guide's GAPs —ScoringMode's cases,ModelJudgeError's cases, andModelJudgePrompt.reference's second parameter (§20). An interface settles spellings, signatures, defaults and case lists, never runtime behaviour — and it confirms the κ correction above: there is no agreement statistic anywhere in the module's interface. Absence from it means "not present in the Xcode 27 beta interface", not "does not exist".
What this covers¶
Define judge dimensions and scales, compare judges against human labels, and measure agreement and drift. A model judge uses the same evaluator/metric interfaces as code-based criteria.
What you need¶
- Xcode 27 and a 27.0 SDK. There is no back-deployment path.
- An existing evaluation. This guide is about upgrading an evaluation with qualitative metrics and
then calibrating them; it assumes you already have
Evaluation,dataset,subject(from:),evaluatorsandaggregateMetrics(using:)working. If you do not, read guide01-foundations-and-hill-climbing.mdin this part first. - A real device or Mac for anything you intend to trust. Judge calls are model inferences and inherit
every availability, guardrail and quota constraint from
part-02/06-availability-errors-and-guardrails.md. - A human willing to score 30–100 rows by hand. There is no way around this. The entire second half of this guide is about extracting maximum leverage from that one afternoon of work.
Contents¶
- When a metric has to be qualitative
- A judge is just another
Evaluator - Choosing the judge model
- The four parts of a judge, and the one you write
ScoringScale: numeric, pass/fail, customScoreDimension: name, description, scale- The key technique: split the question
ModelJudgePrompt- Rationales are the debugging loop
- Pairwise judging
- Mixing judges and code evaluators
- Drift
- Why accuracy is the wrong alignment measure
- Cohen's kappa
- Implementing kappa — the framework does not ship it
- The meta-evaluation, end to end
- Hill-climbing the judge: four iterations
- Overfitting the alignment score
- ⚠️ Silent failures in judge alignment
- What is still unknown
- Quick reference
- Sources
1. When a metric has to be qualitative¶
Apple's rule for deciding is one sentence long and it is worth memorising, because it saves you from
the two opposite mistakes: writing a judge for something a count would have answered, and writing a
regex for something that is irreducibly a matter of taste.
✅ VERIFIED — WWDC26 session 298, spoken verbatim (
298:275-278): "Use heuristics to measure quantifiable traits. These rule-of-thumb metrics are a great way to start understanding your feature. The rule-of-thumb is: if you can measure it in code, then it's quantitative. And if you can only describe it in words, then you need a qualitative metric, using aModelJudgeEvaluator."
The running example across all three Evaluations sessions and the sample project is Book Tracker,
an app whose BookTaggingService generates tags for a book from a review the user wrote. Its
informal spec has five expectations, and the split falls out cleanly:
| Expectation | Kind | How |
|---|---|---|
| Generate 3–8 tags | quantitative | subject.value.tags.count |
| No multi-word tags (they break the UI) | quantitative | tag.contains(" ") |
| At least one tag identifies a literary genre | quantitative | membership in a known-genre set |
| Tags do not repeat the book's title | quantitative | string comparison |
| Tags are informative, relevant to the book, and helpful for browsing your library | qualitative | a model judge |
Three of five are if statements. The fifth is not, and the fifth is the one that determines whether
the feature is any good.
The failure that motivates the judge is worth reproducing, because it is the exact shape of bug that a green test suite hides. Session 298 shows tags generated for an Alice in Wonderland review:
✅ VERIFIED —
298:191-198: "Six tags, single word or hyphenated, with tags identifying genre. Every quantitative metric we built with Rob passed. But look closer. 'Overrated' and 'pretentious' doesn't describe the book — they describe how the reader felt about it. And 'whodunit' isn't even the right genre. The model picked it up from 'riddles he never answers.' It latched onto the language of the review without understanding the book. Our metrics are passing, but they're not giving us the right signals back."
Every heuristic is green. The feature is broken. That gap is the entire justification for the second half of the framework, and Apple's own definition of a judge follows directly from it:
✅ VERIFIED —
298:203-204: "A Model Judge is a language model used to score your feature's output. It gives you a subjective rating — the kind of judgment call a person would make — but applied consistently across your entire dataset."
Note the two halves of that sentence, because the second half is what you are buying. A judge is not more accurate than you; it is more consistent than you, and it is available at 3 a.m. against a thousand samples. Session 335's closing remark makes the trade explicit:
✅ VERIFIED —
335:255-258: "Models can generate ratings much faster than humans can. So by keeping them aligned, you get useful signal as your dataset grows to cover more and more use cases."
"By keeping them aligned" is doing an enormous amount of work in that sentence. It is §12 onward.
1.1 The order to add things in¶
Book Tracker's own layering is the recommended sequence, and it is worth stating as a ladder because each rung is cheaper than the one above it and catches a different class of defect:
#Playground— a#Playground { }block in the service file, two hand-written reviews, run inline in Xcode. Seconds. Catches "the model does something completely different from what I imagined."- Heuristic
Evaluators over a curatedArrayLoader— deterministic, no inference in the scoring path, cheap enough to run on every commit. ModelJudgeEvaluatorwithScoreDimensions — this guide, §2–§11.SampleGeneratorto reach coverage — guide 03 in this part.- κ-calibration of the judge against human scores — this guide, §12–§18.
.evaluates(evaluation, info:)so runs carry the prompt text and are diffable in the Compare view.
✅ VERIFIED — this ladder is Apple's, not ours: it is the file layout of the Book Tracker sample.
BookTaggingService.swift:76-101is the#Playground;BookTrackerEvaluations/BookTags.swiftis heuristics plus judge;BookSampleGenerator/main.swiftis the generator;HillClimbingEvaluations/is the κ calibration.
Do not skip rung 2 to get to rung 3 faster. A judge costs an inference per sample per run; a
count costs nothing, and it is the count metric that caught Book Tracker's most instructive bug
(a @Guide(.count(3...8)) that produced a 100% pass rate by generating exactly eight tags every
single time — range compliance perfect, distribution degenerate). Judges are for what heuristics
cannot see, not for what you could not be bothered to write.
2. A judge is just another Evaluator¶
This is the architectural fact that makes everything else easy, and it is stated more plainly in the session than in the documentation:
✅ VERIFIED —
298:222-224: "In the Evaluations framework, a model judge is just anotherEvaluator. It conforms to the same protocol as the quantitative evaluators and produces the sameMetrictype. So you can mix them freely within a single evaluation."
Concretely, ModelJudgeEvaluator conforms to EvaluatorProtocol exactly as the closure-based
Evaluator does, so both are legal expressions inside the same @EvaluatorsBuilder block:
✅ VERIFIED — declarations from
/documentation/evaluations/evaluatorprotocoland/documentation/evaluations/modeljudgeevaluator:
Note metrics(subject:input:) returns [Metric], plural. That is why a multi-dimension judge
can emit a Relevance score and a Usefulness score from one call — it returns two Metrics from a
single invocation, and the documentation confirms the efficiency consequence:
✅ VERIFIED —
/documentation/evaluations/scoring-with-model-as-judge-evaluators: "The evaluator scores all dimensions in a single call to the model as judge, so you get multiple metrics without extra latency."
That is not a small detail. If you write two single-dimension judges you pay two inferences per sample; one two-dimension judge pays one. On a 1,000-sample dataset that is the difference between a coffee break and an overnight run.
Here is the shape, verbatim from Apple's shipping sample, with the heuristic evaluators left in place so you can see that there is genuinely no seam between the two kinds:
✅ VERIFIED —
BookTrackerEvaluations/BookTags.swift:35-123, an Apple sample project compiled against the 27.0 SDK. This is the highest-precedence evidence in the corpus.
import Evaluations
import Foundation
import FoundationModels
import Testing
@testable import BookTracker
struct BookTaggingEvaluation: Evaluation {
// Heuristic metrics — identifiers and result carriers at once.
let tagCount = Metric("Tag Count")
let tagTotal = Metric("Tag Total")
let hasGenreTag = Metric("Has Genre Tag")
let wordCount = Metric("Word Count")
// Qualitative dimensions — each one carries its own Metric, reachable as `.metric`.
let relevance = ScoreDimension(
"Relevance",
description: """
Whether each tag describes a quality, theme, or tone
of the book itself rather than incidental details or
the reader's personal reactions.
""",
scale: .numeric([
4: "Every tag describes the book itself",
3: "Most tags describe the book",
2: "Some tags describe personal reactions",
1: "Tags don't meaningfully describe the book"
])
)
// …usefulness declared the same way…
var evaluators: Evaluators {
// Code-based: is the count in range?
Evaluator { _, subject in
let count = subject.value.tags.count
if count >= 3 && count <= 8 {
return tagCount.passing(rationale: "\(count) tags")
}
return tagCount.failing(rationale: "Got \(count) tags, expected 3–8")
}
// Code-based: record the raw count, so you can see the distribution.
Evaluator { _, subject in
tagTotal.scoring(Double(subject.value.tags.count))
}
// Code-based: single-word or hyphenated only.
Evaluator { _, subject in
for tag in subject.value.tags where tag.contains(" ") {
return wordCount.failing(rationale: "Tag \(tag) contains multiple words")
}
return wordCount.passing()
}
// Model-based: exactly the same kind of thing, in the same block.
ModelJudgeEvaluator(
judge: SystemLanguageModel.default,
dimensions: [relevance, usefulness],
prompt: ModelJudgePrompt(
instructions: """
You are evaluating tags generated for a personal book-tracking app where users
organize their library by browsing and filtering tags.
""",
evaluationTarget: { value in
"\(value.tags.count) Generated tags: " + value.tags.joined(separator: ", ")
},
reference: { input, _ in
let expectedTags = input.expected?.tags.joined(separator: ", ")
return ["Expected Tags": expectedTags ?? "No expected tags defined"]
}
)
)
}
}
Four spellings in there are easy to get wrong and all four are confirmed by the sample:
var evaluators: Evaluators— the associated type is a result-builder type namedEvaluators, plural. It is notsome Evaluator, which is what a reconstruction from the spoken session produced and which does not compile.Evaluator { input, subject in … }is a two-argument closure. First argument is theModelSample, second is theModelSubject. All of Book Tracker's heuristics discard the first as_— worth noticing, because it means a heuristic usually judges the output against a rule, while a judge usually judges it against the input.Metricis both identifier and result carrier.let tagCount = Metric("Tag Count")is the identity;tagCount.passing(rationale:)is a newMetricvalue with the result stored inside. You return the latter and aggregate against the former.subject.valueis the typed model output (here aBookTags), reached throughModelSubject.
✅ VERIFIED — the five result factories, from
/documentation/evaluations/metric:passing(rationale:),failing(rationale:),scoring(_:rationale:),ignore(rationale:), and the barepassing()/failing()forms.ignoreis documented as "excluded from aggregation".Metric.rationaleisString?andMetric.name"is used as the DataFrame column name".
That last point matters more than it looks: metric names become column headings in a DataFrame, and
they are what you type into result.aggregateValue(...) and read in the Xcode report. Name them the
way you want to read them at 6 p.m. on a Friday.
3. Choosing the judge model¶
3.1 The rule as stated¶
✅ VERIFIED —
298:208-210: "You can use a second model as a judge to evaluate your feature. Your judge should be at least as capable as the model you're evaluating. In our case, we can use a more capable model from Private Cloud Compute."
And later in the same session:
✅ VERIFIED —
298:221: "And finally, we've specified Private Cloud Compute as our judge model, giving us a more capable evaluator than the on-device model we're evaluating."
The principle is sound and you should hold it. A judge weaker than the system under test cannot reliably detect the failures you care about; at best it adds noise, at worst it systematically rewards the failure mode it shares with the feature.
3.2 What Apple's code actually does — and this is a real conflict¶
🔴 SOURCES DISAGREE. The session says PCC. The shipping sample uses the on-device model as the judge for an on-device feature, in both of its evaluations.
✅ VERIFIED — three call sites in the Book Tracker archive:
Site Judge File:line Tag-quality evaluation judge: SystemLanguageModel.defaultBookTags.swift:108Same evaluation over the synthetic dataset judge: SystemLanguageModel.defaultSyntheticBookTags.swift:98Judge-alignment (κ) evaluation judge: SystemLanguageModel()ModelJudgeAlignmentEvaluation.swift:213And the feature under test is built as
SystemLanguageModel(guardrails: .permissiveContentTransformations)(BookTaggingService.swift:40). Same model family, judging itself.
Apple's documentation is consistent with the sample rather than the session: every example on
/documentation/evaluations/scoring-with-model-as-judge-evaluators passes
judge: SystemLanguageModel.default.
Our evidence precedence puts compiling sample code above spoken session narration, so the honest statement is: an on-device judge for an on-device feature is a shipped, Apple-authored pattern. Three things make it defensible, and they are worth understanding rather than just accepting:
- PCC is entitlement-gated and quota-metered. A CI job that runs a 1,000-sample evaluation
through a judge on Private Cloud Compute is spending a per-user quota on a machine that has no
user. 🟡 RECONSTRUCTED — the entitlement requirement is attested in WWDC26 session 241
narration (
241:43) and appears in the docs index ascom.apple.developer.private-cloud-compute; we have not read the entitlement's own documentation page. Seepart-04/01-private-cloud-compute.md. - The κ calibration is what buys back the capability gap. This is the real answer. You do not
need a judge that is a priori more capable if you can demonstrate that its ratings agree with
an expert's at κ ≥ 0.6 on your dataset. That is precisely what
HillClimbingEvaluations/exists to prove, and it is why Apple can ship an on-device judge with a straight face. - A well-prompted small model on a narrow, well-specified rubric is a different task from open generation. Scoring four tags against four written anchor levels is a classification problem with a lot of scaffolding, not a reasoning problem.
Practical advice. Start with SystemLanguageModel.default because it is free, offline and fast.
Then run the alignment evaluation in §16. If you cannot get κ above 0.6 after sharpening the
dimensions and adding a few worked examples, that is your signal to move the judge to PCC — and
you will have a measured reason rather than a vibe.
3.3 Passing a PCC model as the judge¶
🟡 RECONSTRUCTED usage, ✅ SDK-verified type.
ModelJudgeEvaluator'sjudge:parameter is declaredany FoundationModels.LanguageModelon all four initialisers and bothpairwisestatics, with the promptless forms defaulting it toSystemLanguageModel()(Evaluations-27.0-macos.swiftinterface:323-330, checked 2026-07-29). Sojudge: PrivateCloudComputeLanguageModel()typechecks so long as that model conforms toLanguageModel. What remains unverified is everything past the type system: we have still not seen a PCC judge in any compiling source or documentation example, and nobody has reported how it behaves against quota, latency, or the judge's JSON-decoding path.Session 298 narrates PCC as the judge, and an Apple engineer on forum thread 832053 confirms
ModelJudgeEvaluatorworks with PCC — but the code they posted sets up PCC as the subject's session, not the judge's:let session = LanguageModelSession(model: PrivateCloudComputeLanguageModel()) let response = try await session.respond(to: "Analyze this document...")Safe default until you have run it yourself: write
judge: SystemLanguageModel.default, calibrate with κ, and treat a PCC judge as an experiment you verify on hardware before you build a CI pipeline on it. If it misbehaves in practice, the fallback that certainly works is to make PCC the subject model and keep the on-device judge.
3.4 The judge must not be the same session as the feature¶
Not a documented rule, but a structural one that follows from how subject(from:) works. The
evaluation constructs the feature's session itself:
✅ VERIFIED —
SearchBooks.swift:525-563, and the same pattern inBookTags.swift: the evaluation buildsSystemLanguageModel(guardrails: .permissiveContentTransformations)insidesubject(from:)because that is how the app builds it (BookTaggingService.swift:40).
The consequence is worth spelling out: if your evaluation constructs the model differently from
your app — different guardrails, different instructions, different GenerationOptions — you are
evaluating a different system and your scores are about something you do not ship. Book Tracker
avoids this by having the evaluation call BookTaggingService.generateTags(for:) directly rather
than reimplementing it. Do that.
4. The four parts of a judge, and the one you write¶
A model judge is a prompt. Understanding which parts of that prompt the framework assembles and which part is yours is the difference between iterating productively and flailing.
✅ VERIFIED —
298:211-216, the four components as narrated:
- The instruction — "tells the model it will be given book reviews, and how it should evaluate it."
- The feature input — "the prompt given to the feature being judged, in our case, its the book review."
- The feature output — "the tags our service generated."
- The scoring guide — "tells the model how to evaluate and score the feature."
And then the load-bearing sentence: "The Evaluations framework handles most of this for you, so you can focus on the scoring guide."
Mapped onto the API:
| Component | Who supplies it | Type |
|---|---|---|
| Instruction | you (optional) | ModelJudgePrompt.instructions |
| Feature input | the framework, from the ModelSample |
— |
| Feature output | the framework, JSON-serialised from subject.value — overridable |
ModelJudgePrompt.evaluationTarget |
| Reference material | you (optional) | ModelJudgePrompt.reference |
| Scoring guide | you | [ScoreDimension], each with a ScoringScale |
✅ VERIFIED —
/documentation/evaluations/modeljudgeevaluator: "ModelJudgeEvaluatorsends the query, response, and optional reference data to a judge model, which returns scores for one or more dimensions. The response is automatically serialized as JSON, becauseOutputTypeisCodable, or is customizable viaModelJudgePrompt."
So the minimum viable judge is a name and a scale, and everything else has a default:
✅ VERIFIED —
/documentation/evaluations/modeljudgeprompt: "If you omit theModelJudgePromptentirely, the evaluator uses default instructions that ask the model as judge to rate the response using the scoring scale." There is astatic var defaultInstructions: Stringon bothModelJudgeEvaluatorandModelJudgePrompt.
4.1 The initialiser matrix¶
✅ VERIFIED — from
/documentation/evaluations/modeljudgeevaluator:struct ModelJudgeEvaluator<Input> where Input : ModelSampleProtocol // Single dimension — the name and scale are given inline. init(_:scale:judge:scoringMode:) init(_:scale:judge:scoringMode:prompt:) // Multiple dimensions — each ScoreDimension carries its own name and scale. init(judge:dimensions:scoringMode:) init(judge:dimensions:scoringMode:prompt:) // Pairwise — see §10. static func pairwise(_:scale:judge:scoringMode:evaluationTarget:) static func pairwise(judge:dimensions:scoringMode:evaluationTarget:) // Members static var defaultInstructions: String func judgePrompt(for:output:) // "Builds and returns the full judge prompt for // inspection, debugging, or logging." var dimensions var scoringMode✅ SDK-verified — the interface fills in what the doc page left out (
Evaluations-27.0-macos.swiftinterface:317-340):judge:isany LanguageModeland defaults toSystemLanguageModel()on the two promptless initialisers;scoringMode:defaults to.discreteon all four initialisers and bothpairwisestatics;pairwise'sevaluationTarget:is((Input.ExpectedValue) -> String)? = nil; andjudgePrompt(for:output:)isasync throwsand returns a FoundationModelsPrompt, not a plain string (:331-333).
Two things to take from that list.
First, judgePrompt(for:output:) is the debugging tool nobody mentions. It renders the assembled
prompt — your instructions, the framework's framing, the serialised output, the reference sections and
the scale anchors — as a Prompt you can print. Note it is async throws, so the call is
try await evaluator.judgePrompt(for: sample, output: value). When a judge behaves inexplicably, print the prompt
before you change anything else. Half the time the answer is visible immediately: a reference section
that says "No expected tags defined" for every sample, an evaluationTarget that stringified a struct
into BookTags(tags: ["a", "b"]), an instruction that never mentions your app.
Second, scoringMode is no longer a hole in our knowledge — its cases are pinned, its semantics
are not.
✅ SDK-verified — GAP closed (2026-07-29).
ScoringModehas exactly two cases:(
Evaluations-27.0-macos.swiftinterface:306-314), and.discreteis the default on all fourModelJudgeEvaluatorinitialisers and bothpairwisestatics (:323-330). What the interface cannot settle is behaviour: the names read as "constrain the judge to the scale's defined values" versus "allow scores between the anchors" — consistent with the framework index's "scoring constraint mode" description, but that is a reading of two identifiers, not a documented fact.Safe default unchanged: omit the parameter, exactly as Apple's sample does at all three of its judge call sites — you get
.discrete. Treat.continuousas a deliberate experiment, and check what lands in the DataFrame if you try it: scores between anchors would break any downstream statistic that assumes contiguous categories, κ included (§14.4).
5. ScoringScale: numeric, pass/fail, custom¶
The scale is the part of the judge you actually author, and its shape is a design decision with measurable consequences.
✅ VERIFIED —
/documentation/evaluations/scoringscale:struct ScoringScale // Sendable static func numeric(_:) // [Double: String] — level -> description static func passFail(passDescription:failDescription:) static func custom(_:) // a ScoreLevel-conforming enum type init(options:) var options // "ordered from highest to lowest value" struct ScaleOption protocol ScoreLevel { var guideDescription: String { get }; var value: Double { get } }✅ SDK-verified (
Evaluations-27.0-macos.swiftinterface:366-405):numeric(_ scale: [Double : String]),passFail(passDescription:failDescription:)andcustom<Level>(_ level: Level.Type) where Level : ScoreLevelare all real, as isinit(options: [ScaleOption]). The fullScoreLevelprotocol isCaseIterable & Hashable & Sendable, requiringlabel,guideDescriptionandvalue: Double— with a default implementation oflabel, which is why the enum example below gets away without writing one.ScaleOptionisinit(label:guideDescription:value:).
All three forms, from Apple's documentation examples:
// 1. Numeric — a dictionary of level -> anchor text.
let quality = ScoringScale.numeric([
4: "Every tag describes the book itself",
3: "Most tags describe the book",
2: "Some tags describe personal reactions",
1: "Tags don't meaningfully describe the book"
])
// 2. Binary.
let safety = ScoringScale.passFail(
passDescription: "The response is safe and appropriate",
failDescription: "The response contains harmful content"
)
// 3. Your own ordered enum.
enum SafetyLevel: ScoreLevel {
case safe, unsafe
var guideDescription: String { self == .safe ? "Safe" : "Unsafe" }
var value: Double { self == .safe ? 1 : 0 }
}
let typed = ScoringScale.custom(SafetyLevel.self)
⚠️ A spelling discrepancy worth knowing about. The documentation types
numeric(_:)'s argument as[Double: String]; Apple's sample writes integer literals (4:,3:,2:,1:). Both are correct — Swift's integer literals coerce toDoublein a[Double: String]context — so you can write4:and think "level four". Just do not be surprised when a signature dump saysDouble, and do not assume you are limited to whole numbers.
5.1 Why an even number of levels¶
This is the most quotable design rule in the framework, and it is stated twice — once in the session and once, more precisely, in the docs.
✅ VERIFIED —
298:218-220: "We've defined a 'TagQuality' metric on a 1 to 4 scale, with each level describing what that score means. An even number of options prevents the judge from defaulting to a neutral middle score. Four levels provides just enough distinction without diluting the meaning of each rating."✅ VERIFIED —
/documentation/evaluations/designing-effective-model-judges: "Use a small, even number of levels for subjective quality. … an even number removes the noncommittal middle the model as judge can otherwise default to."
The mechanism is worth stating plainly because it changes how you read your own results. Given a 1–5 scale and a hard case, a language model — like a human filling in a survey — will pick 3. Given 1–4 there is no 3-that-means-nothing; 3 means "most tags describe the book" and the judge has to commit to most or some. Every sample becomes a decision. That is the entire trick.
The same article gives a scale-selection table:
✅ VERIFIED —
/documentation/evaluations/designing-effective-model-judges, verbatim:
Scale Best for Reliability Binary, for example pass or fail Safety, compliance, format checks, factual correctness Highest 1–4, or another even-numbered range General quality, subjective dimensions like tone, clarity, helpfulness Good Custom categories Domain-specific distinctions, for example, safe, borderline, or unsafe Varies by design
And the corresponding warning, which is the mirror image of the even-number rule:
✅ VERIFIED — same article: "Start with binary scales for binary judgments. … Forcing a multi-point judgment on a binary dimension adds noise without adding signal, because the judge clusters around the middle."
So: "is this safe?" is .passFail. "How good are these tags?" is .numeric with four levels. Do not
put safety on a 1–4 scale because you want a nice chart.
🔴 An internal contradiction in Apple's own documentation, which you should know about before it confuses you. The
designing-effective-model-judgesarticle insists on even-numbered scales. Thescoring-with-model-as-judge-evaluatorsarticle's firstScoreDimensionexample is:ScoreDimension("Grammar", scale: .numeric([ 5: "Flawless grammar throughout", 3: "Some errors but generally readable", 1: "Pervasive errors making text difficult to understand" ]))— three levels, with a middle. Follow the advice article, not that example. Every dimension in the Book Tracker sample archive uses four levels. Note also what that example demonstrates incidentally and correctly: you do not have to define every integer in your range. The judge sees only the anchors you write. A 5/3/1 scale is a three-point scale whose values happen to be spaced two apart, and it will produce scores of 5, 3 and 1 — which then breaks any downstream statistic that assumes contiguous categories, κ very much included (see §14.4).
5.2 Anchors must describe observable features¶
✅ VERIFIED —
/documentation/evaluations/designing-effective-model-judges: "Each level needs to describe observable features rather than restating a quality gradient. 'Tags accurately represent the book and are useful for browsing' gives the model as judge something concrete to check. 'Excellent quality' does not."
This is the single highest-yield edit you can make to a badly-behaved judge, and it costs nothing. Compare:
// Bad — a gradient restated four times. The judge has nothing to check,
// so it falls back on overall vibe and its scores will be unstable run to run.
.numeric([
4: "Excellent",
3: "Good",
2: "Fair",
1: "Poor"
])
// Good — each level names a countable property of the output.
.numeric([
4: "Every tag describes the book itself",
3: "Most tags describe the book",
2: "Some tags describe personal reactions",
1: "Tags don't meaningfully describe the book"
])
The second version tells the judge to walk the tags and count, which is how session 298 describes authoring a level in the first place:
✅ VERIFIED —
298:245-249: "To score these tags, you'd walk through each one. Identify which tags are bad and which are good, based on whether or not they meaningfully describe the book. You'd repeat this for every tag. In this case, all of the tags are good, which earns a score of 4 on our 1 to 4 scale. You would repeat the same process to define each scale in the scoring guide."
Write the procedure you would follow, then write down what each outcome of that procedure is worth. That is a scoring guide.
6. ScoreDimension: name, description, scale¶
✅ VERIFIED —
/documentation/evaluations/scoredimension:struct ScoreDimension // Sendable init(_ name: String, description: String?, scale: ScoringScale) var name: String // "used as the DataFrame column name" var metric: Metric // "A metric identifier derived from this dimension's name." var scale: ScoringScale var description: String?✅ SDK-verified —
init(_ name: String, description: String? = nil, scale: ScoringScale);descriptiondefaults tonil, andmetricis a computed property (Evaluations-27.0-macos.swiftinterface:395-405).
Three parts, confirmed independently by the session:
✅ VERIFIED —
298:250: "And that's our 'Relevance' metric with the metric name, description, and scale that the model judge can use."
The name is positional. The description is what the dimension means in your app's terms. The
scale is how it is graded. And .metric is the bridge back to aggregation — it is how you write
aggregator.computeMean(of: relevance.metric) without declaring a separate Metric yourself.
✅ VERIFIED —
BookTags.swift:129-142, the aggregation over a mixed evaluation:func aggregateMetrics(using aggregator: inout MetricsAggregator) { aggregator.group("Heuristics") { aggregator in aggregator.computeMean(of: tagCount) aggregator.computeStandardDeviation(of: tagTotal) aggregator.computeMean(of: tagTotal) aggregator.computeVariance(of: tagTotal) aggregator.computeMean(of: wordCount) aggregator.computeMean(of: hasGenreTag) } aggregator.group("Quality") { group in group.computeMean(of: relevance.metric) group.computeMean(of: usefulness.metric) } }Note the signature takes
inout MetricsAggregator, and.group(_:)nests a sub-aggregator whose label becomes a heading in the Xcode report.
Two consequences of name being a DataFrame column name:
- Names must be stable across runs or the Compare view has nothing to compare. If you rename "Relevance" to "Topicality" mid-hill-climb you have thrown away your baseline.
- Names must be unique. Two dimensions with the same name collide in the same column. Nothing in our corpus says what happens then; do not find out.
6.1 The description is not decoration¶
The dimension's description is the sentence that tells the judge what the dimension is, separately
from what each score level means. Session 298 shows how to derive it — you write down what you
meant by the word:
✅ VERIFIED —
298:243-244: "When we say the tags are relevant we mean that each tag describes a quality, theme, or tone of the book itself rather than small details or the reader's personal reactions. And we can write that as the description for ourScoreDimension."
That is a definition, and it is doing the work the word "relevant" failed to do. Which brings us to the technique this whole guide is built around.
7. The key technique: split the question¶
If you take one thing from this guide, take this section.
7.1 The failure¶
Book Tracker's first judge was a single dimension called TagQuality on a 1–4 scale. Run against a
set of tags including overrated, pretentious and whodunit, it returned 3, with a rationale
flagging only whodunit and detective-fiction as irrelevant. The developer disagreed: those
opinion tags should have been flagged too.
And then the session says the thing that reframes the entire activity:
✅ VERIFIED —
298:232-235: "And here's the thing: by the scale we wrote, the judge is actually right. Every tag connects to something that the user wrote. The judge is faithfully following the scoring guide we provided. We meant something specific by relevant and useful for browsing, and the judge interpreted those words differently than we did."
The judge was not wrong. The rubric was ambiguous. This is almost always true, and internalising it changes your debugging posture from arguing with the model to editing your own writing.
7.2 The diagnosis¶
✅ VERIFIED —
298:238-241: "Looking back, the problem with our first model judge was that it was too broad. It was asking two different questions. … When you find yourself disagreeing with a score, you should try and see if you can split the questions. In our case, relevance and usefulness are actually two different metrics."
TagQuality was conflating:
- Relevance — does this tag describe the book, as opposed to the reader's feelings about it or an incidental phrase lifted from the review?
- Usefulness — would this tag work as a search term when browsing a personal library?
Those are orthogonal. psychological is relevant to Frankenstein and useless for browsing.
overrated is neither. A single 1–4 score cannot express "right subject, wrong granularity", so the
judge picks a number in the middle and you learn nothing.
7.3 The fix, and why it works¶
let relevance = ScoreDimension(
"Relevance",
description: """
Whether each tag describes a quality, theme, or tone
of the book itself rather than incidental details or
the reader's personal reactions.
""",
scale: .numeric([
4: "Every tag describes the book itself",
3: "Most tags describe the book",
2: "Some tags describe personal reactions",
1: "Tags don't meaningfully describe the book"
])
)
let usefulness = ScoreDimension(
"Usefulness",
description: "Whether each tag works as a search term for browsing a personal library.",
scale: .numeric([
4: "Every tag would help someone find this book while browsing",
3: "Most tags are useful for browsing but a couple are too narrow or generic",
2: "About half the tags are useful; the rest are too narrow or generic",
1: "The tags would not help someone browse a library"
])
)
// One evaluator, two dimensions, one inference per sample.
ModelJudgeEvaluator(
judge: SystemLanguageModel.default,
dimensions: [relevance, usefulness],
prompt: bookTagJudgePrompt
)
✅ VERIFIED — the
relevancedimension above is verbatim fromBookTags.swift:43-56. Theusefulnessanchor text is verbatim from Apple's documentation example on/documentation/evaluations/scoring-with-model-as-judge-evaluators, where the same two-dimension book-tagging judge appears with the first dimension namedAccuracyrather thanRelevance. Both spellings are Apple's; the sample's is the one to copy, since it is the one that compiles alongside the κ-calibration evaluation.
And now the payoff, which is the sentence that makes splitting worth the extra dimension:
✅ VERIFIED —
298:263-267: "In place of Quality we now have a relevance and usefulness score… Notice how the two rationales separate the diagnosis. Relevance tells us what kind of tag is wrong. And Usefulness tells us how the wrong tags fail at browsing."
Read that again with a debugger's eye. You now have a two-dimensional error signature per sample, and each cell of the resulting matrix points at a different fix:
| Relevance | Usefulness | What it means | What to change in the feature |
|---|---|---|---|
| 4 | 4 | Good output | Nothing |
| 4 | 2 | Right subject matter, wrong granularity — tags like quiet-steadiness, visual-dimension |
Tell the generator to prefer established vocabulary over phrases lifted from the review |
| 2 | 4 | Reader-reaction tags that happen to be browsable — overrated, poignant |
Tell the generator to describe the book, not the review |
| 2 | 2 | Extractive noise | Instructions, or the model |
| 4 | 1 | Every tag is about the book and none is searchable | Almost always over-specificity; consider a controlled vocabulary |
That table is not in Apple's material — it is the natural reading of the two dimensions, and it is why the split earns its keep. A single "quality: 3" gives you none of those rows.
7.4 When to split, and when to stop¶
Split when: you disagree with a score and can articulate two different reasons it might be wrong; or when all your scores come out the same, which session 298 names explicitly as the symptom of an over-broad question.
✅ VERIFIED —
298:282-285: "Use rationales to drive your next change. If the scores are all the same, your question is too broad. If you can't isolate the problem, split the dimensions. And if the judge doesn't understand your app, add context."
That is a three-line decision procedure and it is complete. Uniform scores → split. Cannot localise
the problem → split. Judge misunderstands your domain → ModelJudgePrompt.instructions (§8).
Stop when a dimension can no longer be decomposed into things you would fix differently. Two dimensions that always move together are one dimension with extra latency — and the κ evaluation in §16 will show you this directly, because their alignment scores will track each other exactly.
There is one hard-won instruction in Apple's calibration prompt that belongs here:
✅ VERIFIED —
ModelJudgeAlignmentEvaluation.swift:216-283, the calibration judge's prompt contains the explicit instruction "Score Relevance and Usefulness independently, even when one tag affects both."
Because the judge scores both dimensions in a single inference, its Relevance answer is in context when it writes its Usefulness answer, and it will anchor. Telling it not to is free and it works.
8. ModelJudgePrompt¶
Dimensions tell the judge what to measure. They do not tell it what your app is.
✅ VERIFIED —
298:253-257: "But dimensions alone aren't enough. They tell the judge what to measure, but not how to think about your app. Without that context, a judge evaluating tags for Book Tracker might treat a reader's criticism as a valid book descriptor. It has no way to know that Book Tracker is a personal library, not a review platform. And that's where theModelJudgePromptcomes in."
That example is precise and worth sitting with. overrated is a perfectly good tag on Goodreads.
It is a terrible tag in a personal library you are browsing, because you will never search for it. The
judge cannot know which app it is looking at unless you tell it.
8.1 The type¶
✅ VERIFIED —
/documentation/evaluations/modeljudgeprompt:struct ModelJudgePrompt<Input> where Input : ModelSampleProtocol init(instructions:evaluationTarget:reference:) static var defaultInstructions: String var instructions: String // "The system instructions for the judge model." var evaluationTarget // closure: response -> String var reference // closure: (input, response) -> [String: String] labeled sections✅ SDK-verified — the exact declarations (
Evaluations-27.0-macos.swiftinterface:353-363):instructionsdefaults toModelJudgePrompt.defaultInstructions;evaluationTargetis(@Sendable (Input.ExpectedValue) -> String)? = nil; andreferenceis(@Sendable (Input, Input.ExpectedValue) async throws -> [String : String])? = nil— note the reference closure may beasyncand maythrow, which no doc example uses.⚠️ CORRECTION —
referencereturns a dictionary, not a string. This is the single most likely thing to get wrong from memory, and material in circulation has it as aString. It is[String: String], and each pair becomes a labelled section of the prompt.✅ VERIFIED twice over. Documentation: "The
referenceclosure receives the input sample and the model's response, and returns a[String: String]dictionary. Each key-value pair becomes a labeled section in the judge's prompt." Sample:BookTags.swift:107-123, reproduced below.
8.2 The worked example¶
✅ VERIFIED —
BookTags.swift:107-123, verbatim:
ModelJudgeEvaluator(
judge: SystemLanguageModel.default,
dimensions: [relevance, usefulness],
prompt: ModelJudgePrompt(
instructions: """
You are evaluating tags generated for a personal book-tracking app where users
organize their library by browsing and filtering tags.
""",
evaluationTarget: { value in
"\(value.tags.count) Generated tags: " + value.tags.joined(separator: ", ")
},
reference: { input, _ in
let expectedTags = input.expected?.tags.joined(separator: ", ")
return ["Expected Tags": expectedTags ?? "No expected tags defined"]
}
)
)
Three notes on that code, each of which will bite someone:
evaluationTarget takes the value, not the sample. Its parameter is your @Generable output
type (BookTags), and it returns the string the judge will read. Without it the judge gets JSON:
{"tags":["gothic","horror","epistolary"]}. That is not wrong, and for a flat struct it is fine. It
becomes wrong when your output type has fields the judge should not weigh, or when the JSON is large
enough to bury the part that matters. Note also that Apple's version prefixes the count —
"3 Generated tags: gothic, horror, epistolary" — which hands the judge a fact it would otherwise
have to derive.
reference takes (input, _). The first parameter is the ModelSample, which is where the
expected value lives. The second is discarded at both call sites in the archive.
✅ SDK-verified — GAP closed (2026-07-29). The second parameter is the model's output value, typed
Input.ExpectedValue— the full closure type is(Input, Input.ExpectedValue) async throws -> [String : String](Evaluations-27.0-macos.swiftinterface:361). ForModelSample<BookTags>the discarded_is therefore the generatedBookTags— the same valueevaluationTargetreceives — not aModelSubject, as previously guessed. Discarding it stays reasonable: the framework already shows the judge the output, and repeating it in a reference section would duplicate it in the prompt. Reach for it when the reference section should react to the output — say, listing only the expected tags the model missed.
The ?? "No expected tags defined" is deliberate. A prompt-only sample with no expected value
still gets a reference section, and it says so in words the judge can act on. The alternative —
returning [:] — is also legal and Apple's documentation shows it:
reference: { input, _ in
guard let expected = input.expected else { return [:] }
return ["Expected Tags": expected.tags.joined(separator: ", ")]
}
Prefer Apple's sample's version. An explicit "no expected tags defined" is a fact; a silently absent section is a difference between prompts that you cannot see in the report.
8.3 What good instructions contain¶
✅ VERIFIED —
/documentation/evaluations/designing-effective-model-judges, verbatim: good instructions have three parts — - "A role that frames the judge's expertise" - "Criteria that list the specific dimensions to assess" - "Evaluation steps that give the judge a procedure to follow before assigning a score"and the reason for the third: "Including steps promotes consistent evaluation by preventing the model as judge from jumping to a score based on a first impression."
Here is that structure applied, written out at the length a real judge prompt runs to. This is our composition of Apple's documented three-part rule with the vocabulary from the Book Tracker sample — the structure is Apple's, the specific prose is ours:
let bookTagJudgePrompt = ModelJudgePrompt<ModelSample<BookTags>>(
instructions: """
ROLE
You are a professional librarian who catalogues a personal book collection. You care about
whether someone browsing their own shelves six months from now can find a book again.
CONTEXT
These tags appear in a personal library app. They are used to browse and filter a
collection the reader already owns. This is not a review site: the tags are not opinions
about whether a book is good, and no one will ever search their own library for
"overrated".
CRITERIA
- Relevance: does each tag describe the book itself — its genre, themes, setting, tone —
rather than the reader's reaction to it or a phrase lifted verbatim from the review?
- Usefulness: would each tag work as a browsing filter? Established vocabulary a reader
would think to type is useful; a phrase so specific that it applies to one book is not.
EVALUATION STEPS
1. Read the review and form your own idea of what the book is about.
2. Take each generated tag in turn. Decide whether it describes the book or the reader.
3. Take each tag again. Decide whether you would ever click it as a filter.
4. Only then assign a score for each dimension, using the scale descriptions.
Score Relevance and Usefulness independently, even when one tag affects both.
""",
evaluationTarget: { value in
"\(value.tags.count) Generated tags: " + value.tags.joined(separator: ", ")
},
reference: { input, _ in
["Expected Tags": input.expected?.tags.joined(separator: ", ")
?? "No expected tags defined"]
}
)
Note that the review itself is nowhere in that prompt. The framework supplies the feature input
from the ModelSample; you do not interpolate it yourself. Doing so would duplicate it in the
prompt, which wastes context and sometimes causes the judge to score the second copy.
⚠️ SILENT FAILURE — a judge with no context returns confident, wrong, stable numbers. If you omit
ModelJudgePromptentirely, the evaluator falls back todefaultInstructionsand rates the response against your scale anyway. Nothing throws. Nothing warns. You get a full column of plausible 3s and 4s produced by a model that does not know what your app is, and — worse — those numbers will be consistent, so a low variance will read as a well-behaved judge. The tell is in the rationales: a context-free judge writes generic praise ("the tags are descriptive and appropriate") rather than app-specific reasoning ("quiet-steadinessis unlikely to be used as a browsing filter"). Read the rationales on your first run, always. If you have not read them, you do not know whether your judge is working.
9. Rationales are the debugging loop¶
✅ VERIFIED —
298:230-231: "With model judges, rationales are essential. They give you a window into why the judge scored what it scored."✅ VERIFIED —
/documentation/evaluations/designing-effective-model-judges: "When the model as judge scores a response, it also produces a written rationale explaining its reasoning. These rationales appear in the detailed results alongside the score for each sample. When scores seem wrong or inconsistent, the rationales usually show you why."
And the advice that should govern how you spend your first hour:
✅ VERIFIED —
298:279-281: "Start simple with your model judge. Define your scoring dimension, run it, and read the rationales. You'll learn more from a single run than from hours of careful planning."
That sentence is not a platitude. Judge behaviour is not predictable from the prompt by inspection — you cannot reason your way to the right rubric, because the whole problem is that words like "relevant" mean something slightly different to the judge than to you, and the only way to find out what they mean to the judge is to look at what it did with them.
9.1 Where the rationales are¶
The Xcode 27 Evaluations report, reached the same way for judge metrics as for heuristic ones:
✅ VERIFIED —
298:103-111and335:56-58: run the test, open the report navigator, select Evaluations under the test report, double-click the suite row. "On the top are our aggregate metric charts. And below is our table of results." Select a row in the table and the assistant editor shows the detail panel: "The detail panel shows the prompt, and each measurement for theModelSample. At the bottom, you see the entire response from the model."
Programmatically, they are on the Metric (var rationale: String?) and reachable through the
detailed DataFrame:
✅ VERIFIED —
/documentation/evaluations/evaluationresultand its documented example: the per-sample frame supports two subscript forms,result.detailed[someResultColumn]andresult.detailed[metric: someMetric].
@Test("Book Tag Evaluations", .evaluates(evaluation, info: evaluationInfo))
func evaluateBookTagging() async throws {
let result = EvaluationContext.current.result
// Print every judge rationale where Relevance came in below 3.
let inputs = result.detailed[Self.evaluation.inputColumn]
let scores = result.detailed[metric: Self.evaluation.relevance.metric]
for row in 0..<scores.count {
guard let metric = scores[row], let value = metric.doubleValue, value < 3 else { continue }
let prompt = inputs[row]?.promptDescription ?? "<missing>"
print("[\(value)] \(prompt.prefix(60))…\n \(metric.rationale ?? "<no rationale>")")
}
#expect(result.aggregateValue(.mean(of: Self.evaluation.relevance.metric)) >= 3.0)
}
🟡 RECONSTRUCTED — the loop body above composes verified pieces:
EvaluationContext.current.result, the two subscript forms,inputColumn,metric.rationale,metric.doubleValueandaggregateValue(.mean(of:))are each individually documented, and the surrounding structure follows Apple's owninspectDetailedResultsexample verbatim in shape. The specific filter is ours. NotedoubleValueis documented as optional-ish ("or … for ignored metrics"), hence theguard.
9.2 Reading a rationale, worked¶
Session 298's diagnostic moment, verbatim:
✅ VERIFIED —
298:227-229: "The model judge gave this a quality score of 3. If we look at the rationale, we can identify that the model flagged 'whodunit' and 'detective-fiction' as not relevant to the book. But, we also expected it to flag all of these other tags that either reflect the reader's opinion or are not helpful for browsing."
The reading procedure that generalises from that:
- Does the rationale cite specific items from the output? If it says "the tags are appropriate", your judge is not actually looking. Sharpen the anchors (§5.2) or add evaluation steps (§8.3).
- Does it flag some of what you would flag? Then the rubric is under-specified in a knowable direction — the judge caught relevance failures and missed usefulness failures, which is the signal to split (§7).
- Does it flag things you would not? Then it is over-applying a criterion, usually because an
anchor is stricter than you meant. Session 335 hits exactly this: "I noticed that all the scores
are either a 3 or 2, which is way too harsh" (
335:183). - Are all the rationales the same shape? Uniform reasoning means a broad question. Split.
And then the framing sentence that turns this into a loop rather than an argument:
✅ VERIFIED —
298:236-237: "By asking the model to provide judgement for my feature, in my place, I expected it to provide a similar score to how I would have scored these tags. When there is a mismatch between the model judge and us, we can refine the model judge until it can stand in for our own judgement."
"Until it can stand in for our own judgement" is a testable claim, and §16 is the test.
10. Pairwise judging¶
Everything so far is pointwise: score this output on its own merits. There is a second mode, and it is better suited to the question you actually ask during a hill-climb — "is the new prompt better than the old one?"
✅ VERIFIED —
/documentation/evaluations/modeljudgeevaluator:static func pairwise(_:scale:judge:scoringMode:evaluationTarget:) static func pairwise(judge:dimensions:scoringMode:evaluationTarget:)and the semantics, verbatim: "Unlike pointwise evaluation, the pairwise method uses its own built-in prompt and automatically sends the sample's
expectedvalue to the model as judge as the baseline."
var evaluators: Evaluators {
ModelJudgeEvaluator.pairwise(
"ExplanationComparison",
scale: .numeric([
4: "Response is significantly clearer, more accurate, and more engaging than the baseline.",
3: "Response is noticeably better than the baseline in most areas.",
2: "Baseline is noticeably better than the response in most areas.",
1: "Baseline is significantly clearer, more accurate, and more engaging than the response.",
]),
judge: SystemLanguageModel.default
)
}
✅ VERIFIED — verbatim from
/documentation/evaluations/scoring-with-model-as-judge-evaluators.
Three properties you have to know before using it:
The scale has no midpoint, on purpose.
✅ VERIFIED: "A score of 4 means the response is much better than the baseline, and a score of 1 means the baseline is much better. The 1–4 scale has no neutral midpoint, so the judge has to decide which side of the comparison wins on every sample."
The aggregate reads against 2.5, not against a pass rate.
✅ VERIFIED: "A mean score above 2.5 indicates the model's responses are generally better than the baselines. A mean score below 2.5 indicates regressions. Scores near 2.5 suggest comparable quality."
instructions and reference do not apply.
✅ VERIFIED —
/documentation/evaluations/modeljudgeprompt: "Pairwise evaluation builds its own prompt internally. Theinstructionsandreferencecomponents only apply to pointwise evaluators. Pairwise evaluation supportsevaluationTargetthrough its own parameter."
That last one is a trap with a very quiet failure mode: if you pass a ModelJudgePrompt full of
carefully-written app context to a pairwise evaluator, the instructions are ignored and you get
Apple's built-in comparison prompt instead. The scores will look fine. Nothing will tell you your
context was dropped. Which is why the pairwise statics take evaluationTarget: as a direct
parameter rather than a prompt — the API shape is trying to warn you.
10.1 Pointwise or pairwise?¶
| Question | Mode |
|---|---|
| "Is this output good?" | pointwise |
| "Did my prompt change make things better?" | pairwise, with the old output as expected |
| "Can I track quality over months of OS updates?" | pointwise — pairwise has no fixed origin |
| "Do I need per-dimension diagnosis?" | pointwise; pairwise dimensions exist but collapse to better/worse |
| "Am I calibrating the judge against a human?" | pointwise — κ needs absolute ratings on both sides |
The last row is the important one for the rest of this guide. Everything from §12 onward requires the
judge and the human to produce comparable absolute scores on the same scale, so the alignment
evaluation is necessarily pointwise. Apple's HillClimbingEvaluations target uses the pointwise
init(judge:dimensions:) accordingly.
Note also that pairwise is a different answer to the same problem as Xcode's Compare view. Compare diffs two pointwise runs; pairwise asks one judge to weigh two outputs side by side within one run. Apple's sessions demonstrate the Compare route, not the pairwise route, so the pairwise material here is documentation-sourced and has no worked precedent in the sample archive.
11. Mixing judges and code evaluators¶
Because they are the same protocol, one Evaluation can carry both, and the documentation shows the
canonical two-group aggregation:
✅ VERIFIED —
/documentation/evaluations/designing-effective-model-judges, verbatim:
private let nonEmpty = Metric("NonEmpty")
private let quality = Metric("Quality")
var evaluators: Evaluators {
Evaluator { input, subject in
return subject.value.isEmpty ? nonEmpty.failing() : nonEmpty.passing()
}
ModelJudgeEvaluator(
"Quality",
scale: .numeric([...]),
judge: SystemLanguageModel.default,
prompt: ModelJudgePrompt(instructions: """...""")
)
}
func aggregateMetrics(using aggregator: inout MetricsAggregator) {
aggregator.group("Validation") { group in
group.computeMean(of: nonEmpty)
}
aggregator.group("Judge") { group in
group.computeMean(of: quality)
}
}
Note the single-dimension initialiser here: ModelJudgeEvaluator("Quality", scale:judge:prompt:)
declares its name and scale inline rather than through a ScoreDimension. Use it when you have
exactly one qualitative criterion; move to dimensions: the moment you have two, because that is the
form that gets you both scores from one inference.
11.1 Why the grouping matters more than it looks¶
Grouping is not cosmetic. aggregator.group(_:) produces the headings in the Xcode report and in
EvaluationResult.groupedSummary, and it is what lets you scan a run in five seconds: Heuristics
all green, Quality dropped from 3.4 to 3.1. Book Tracker groups by "Heuristics" and "Quality";
the κ evaluation groups by "Relevance" and "Usefulness" so each dimension's mean, standard
deviation and alignment score sit together.
11.2 The full aggregator surface¶
✅ VERIFIED —
/documentation/evaluations/metricsaggregator:struct MetricsAggregator func computeMean(of:) func computeMedian(of:) func computeMode(of:) func computeMinimum(of:) func computeMaximum(of:) func computeStandardDeviation(of:) func computeVariance(of:) func custom(of:label:_:) // "Computes a custom aggregation from a single metric's results." func group(_:_:) struct MetricsAggregator.Group✅ SDK-verified — that is the complete public surface: the interface shows exactly those nine members on
MetricsAggregator, and the same eight compute/custom methods again onMetricsAggregator.Group(Evaluations-27.0-macos.swiftinterface:753-780). The absence of an agreement statistic is no longer an inference from a documentation member list; it is the shipped interface, checked 2026-07-29.
There is no computeCorrelation, no computeAgreement, and no kappa. custom(of:label:_:) is
the extension point, and §15 is what you put in it.
11.3 Always pair a mean with a spread¶
A judge metric summarised by its mean alone can hide the degenerate case completely. This is the same
lesson the Tag Count heuristic taught in guide 01 — a 100% pass rate produced by generating exactly
eight tags every time — transposed into the qualitative half:
- A judge that scores everything 4 has a mean of 4.0 and a standard deviation of 0.
- A judge that scores half the samples 4 and half 2 also has a mean of 3.0 with visible spread.
- A judge that is actually working has a distribution that looks like your dataset's quality distribution.
Apple's κ evaluation computes mean and standard deviation for each dimension for exactly this reason:
✅ VERIFIED —
335:126-129: "In addition to just Cohen's kappa, I'll also calculate the mean and standard deviation of each score dimension. This will be helpful to know if the scores of the judge are going up or down." Confirmed in code atModelJudgeAlignmentEvaluation.swift:303-332, where each group callscomputeMean,computeStandardDeviationandcustomon the same metric.
A zero standard deviation on a judge metric is not a clean result. It is a broken judge.
12. Drift¶
Everything up to here assumed the judge is a reasonable proxy for you. This section and the four that follow are about proving it, and they are the most intellectually demanding material in this series.
12.1 The definition¶
✅ VERIFIED —
335:72: "This discrepancy between model and human is known as drift, and it is a problem faced by all developers trying to evaluate intelligent features."
Note what drift is not. It is not model drift in the MLOps sense (a deployed model degrading as the world changes), and it is not the OS-update problem where Apple ships a new on-device model under you. Both of those are real and both are covered elsewhere in this series. Drift here is a property of the measuring instrument: a systematic, reproducible disagreement between your judge and your expert.
12.2 The mechanics, and the part that should alarm you¶
✅ VERIFIED —
335:74-79: "Say I have an evaluation with 10 samples. I then ask a model judge and a person to rate each sample. The model and person then give their ratings on a scale from 1 to 4, and at the end we average those scores to build an aggregate. If the model and the human tend to disagree in their ratings, then their average scores will diverge from one another, hence the name drift. As your data set continues to grow and grow the drift will get wider and wider. At which point, it'll be hard for you to know whether or not your feature is being properly evaluated."
Read that penultimate sentence again: "as your dataset continues to grow, the drift will get wider and wider." This is the property that makes drift dangerous rather than merely annoying, and it inverts the intuition you brought from unit testing, where more test cases straightforwardly means more confidence.
Here is why it happens. A judge with a systematic bias — say, it rates over-specific tags one point
more generously than you do — produces an error on every sample that exhibits that pattern. With 13
samples, maybe two exhibit it, and the aggregate is off by 2/13 of a point: invisible. With 1,000
samples generated by SampleGenerator to maximise coverage, hundreds exhibit it, because coverage is
exactly what surfaces edge cases. The bias does not average out — it is a bias, not noise. It
accumulates.
The concrete consequence, which session 299 demonstrates in the opposite direction:
✅ VERIFIED —
299:100-102: "I've went ahead and ran the evaluation with our new dataset of 100 samples. Now, we can compare the two evaluations using the Compare button and we're expecting the scores to drop! And we were correct! The quality scores have dropped. Our tag generation feature looked like it was performing well earlier because we weren't testing it with a comprehensive dataset."
When you go from 13 to 100 samples and the scores move, you have two competing explanations and no way to distinguish them from the aggregate alone:
- Your feature performs worse on the harder samples. (Good news: your evaluation is working.)
- Your judge disagrees with you more on the harder samples. (Bad news: your evaluation is lying, and every decision you make from here is downstream of a broken instrument.)
Nothing in the score tells you which. That is what the alignment evaluation is for, and it is why the sequencing matters: calibrate the judge before you scale the dataset, or you will spend a week hill-climbing a feature against a ruler that was never straight.
⚠️ SILENT FAILURE — drift never throws, never warns, and gets worse as you do the right thing. Growing your dataset is unambiguously good practice, and it is also the thing that turns a small, invisible judge bias into a large, invisible one. There is no API that will tell you this is happening. The only detector is the κ evaluation in §16, and it is not automatic — you have to build it.
12.3 The disagreement, on record¶
Apple's session 335 shows the developer's own ratings against the judge's, and it is worth having the numbers because they show that drift is usually dimension-specific:
✅ VERIFIED —
335:65-66(Treasure Island): "I would have scored these tags a 4 for relevance and a 2 for usefulness. My model judge also gave the tags a relevance score of 4 which is great, but it also gave usefulness a score of 4, which isn't right."
335:70(Little Women): "Once again, I think relevance should be a 4 and usefulness should be a 2."
| Sample | Human relevance | Judge relevance | Human usefulness | Judge usefulness |
|---|---|---|---|---|
| Treasure Island | 4 | 4 | 2 | 4 |
| Little Women | 4 | (4) | 2 | (high) |
Relevance is aligned. Usefulness is not, and it is wrong in a consistent direction — the judge is too generous about whether an over-specific tag would work as a browsing filter. That is a bias, it is diagnosable, and it is fixable by editing the usefulness anchors. But you cannot see it from the means, because a mean usefulness of 3.6 looks like a perfectly healthy feature.
12.4 Why you cannot just look at the numbers¶
The obvious move — compare the judge's mean to your mean — fails for a reason that is easy to miss. Two raters can produce identical means and disagree on every single sample. If the judge scores sample A a 4 and sample B a 2, and you score A a 2 and B a 4, both means are 3.0. Perfect apparent agreement, zero actual agreement.
You need a per-sample comparison, which is what the next two sections build.
13. Why accuracy is the wrong alignment measure¶
The natural per-sample comparison is: count the samples where the two ratings match, divide by the total. Apple names it and then dismantles it.
✅ VERIFIED —
335:82-84: "One way to accomplish this would be to line up the ratings of the expert and mark where the two match. You can then use this to generate a percentage. This percentage is called accuracy, and it is a great way to measure alignment if every value in your scoring scale is equally likely to appear."
That conditional is the whole argument. And then:
✅ VERIFIED —
335:85-89: "However, it's more likely that your dataset will contain values that have an uneven distribution of scores. Think about it, datasets often contain examples of high quality output. Therefore it is often the case that a human rater is likely to rate items in the dataset with higher scores. If a model then happens to judge your smaller dataset with high scores, it may seem like the two are aligned. But then when unleashed on a larger dataset with more variations in scores, it's tendency to score high will still result in drift."
13.1 Work the arithmetic, because the size of the effect is surprising¶
Take a 30-sample dataset where the expert rated 24 samples a 4 and six spread across 1–3. This is not a contrived distribution; it is what you get when you hand-write samples from books you like, and it is what you get when a generator produces samples the feature handles well.
Now consider a completely useless judge that returns 4 for everything.
- Exact agreement: 24 of 30 = 80%.
- On a bar chart next to your 0.8 pass-rate thresholds: looks great.
- Actual information content: zero. The judge has not looked at anything.
Now scale to 1,000 samples with better coverage. The proportion of genuinely-4 samples falls to, say, 55% because the generator found the hard cases. The same useless judge now scores 55% — and you will read that fall as the feature regressed, because that is what a falling number normally means. It did not. Your instrument was always broken; the dataset just stopped flattering it.
This is drift in its purest form, and it is why the alignment measure has to discount for luck:
✅ VERIFIED —
335:90: "So we need an alternative to accuracy, one that accounts for the weighted nature of our dataset and the chance that the model might guess the right answer."
13.2 The uncomfortable corollary about your dataset¶
"Datasets often contain examples of high quality output" deserves more attention than it usually gets. Your evaluation dataset is skewed toward good output for at least four structural reasons:
- You wrote the samples, and you naturally wrote ones your feature handles.
- You wrote the
expectedvalues, which anchor both the generator and the judge. SampleGeneratorseeds from your existing samples as in-context examples, so it inherits and amplifies whatever distribution you started from. (See guide 03 in this part onsamplingStrategy.)- Failures get fixed. The moment you find a bad case you improve the feature, so the dataset's quality distribution ratchets upward over the life of the project.
Every one of those is good practice. Together they guarantee the exact condition under which accuracy is a bad alignment measure. You do not get to opt out of this.
14. Cohen's kappa¶
14.1 Provenance and definition¶
✅ VERIFIED —
335:91-92: "Cohen's kappa coefficient is a mathematical formula made popular by statistician and psychologist Jacob Cohen in 1960. … Cohen's kappa measures alignment, that is how often do two raters agree."✅ VERIFIED —
335:93-97: "To do that, we need to know what percentage of the time the raters agreed, better known as accuracy … But now we need to calculate a new value. Coincidence, which represents the chance that one rater might get lucky and happen to align. This luck is then weighted based on the chances certain answers are more likely to appear."
14.2 The formula¶
✅ VERIFIED —
335:98-101, narrated step by step: "To calculate alignment, we start with our accuracy score. From the accuracy score we subtract the possibility of two raters randomly agreeing. Finally, we divide the difference by the inverse of random agreement, namely the chance that the two raters intentionally agreed. The result of that gives us alignment."
accuracy − p_chance
κ = ─────────────────────
1 − p_chance
where
accuracy = fraction of samples on which the two raters gave the same score
p_chance = probability the two raters agree by luck alone, computed from the
marginal distribution of each rater's scores:
p_chance = Σ P_judge(k) · P_expert(k)
k
i.e. for each score level k, multiply the fraction of samples the
judge assigned k by the fraction the expert assigned k, and sum.
Read the formula as a normalisation: the numerator is how much better than luck you did, and the denominator is how much better than luck you could have done. κ = 1 is perfect agreement, κ = 0 is exactly chance, and κ < 0 means the raters agree less often than random assignment would — which is rare and always worth investigating, because it usually means an inverted scale somewhere.
Apply it to the useless judge from §13.1:
- The judge assigns 4 to 100% of samples, so
P_judge(4) = 1.0and every otherP_judge(k) = 0. - The expert assigned 4 to 80%, so
P_expert(4) = 0.8. p_chance = 1.0 × 0.8 = 0.8.accuracy = 0.8.κ = (0.8 − 0.8) / (1 − 0.8) = 0 / 0.2 = **0**.
Eighty percent accuracy, zero kappa. That is the whole point of the statistic, in one line of arithmetic.
14.3 The 0.6 bar¶
✅ VERIFIED —
335:131-134: "For this test, I've set an expectation that my ratings and the judges ratings should produce an alignment score of 0.6. We've chosen this number because according to statisticians, an alignment score of 0.6 represents a meaningful level of agreement."✅ VERIFIED — Apple's sample asserts it strictly greater than, not greater-or-equal (
ModelJudgeAlignmentEvaluation.swift:344-352):
0.6 is the conventional boundary of "substantial agreement" in the inter-rater-reliability literature, and Apple adopts it without further justification. Treat it as a floor for shipping decisions, not a target to optimise toward — see §18 for why pushing κ higher and higher is actively dangerous.
14.4 Two ways κ misleads, neither of which Apple mentions¶
These are analytic observations about the statistic, not claims about Apple's API. They matter because you will hit both.
The prevalence paradox. When one score level dominates the dataset, κ is pessimistic: high
accuracy can coexist with near-zero κ, because p_chance is nearly as high as accuracy. This is the
inverse of the problem κ was introduced to solve, and it means that on a very skewed dataset a genuinely
decent judge can fail the 0.6 bar. The diagnostic is simple: if accuracy is high, κ is low, and the
score distribution is lopsided, fix the dataset before you touch the judge. Add samples that
exercise the low end of the scale. A dataset with no bad outputs in it cannot calibrate a judge's
ability to recognise bad outputs.
Unweighted κ treats all disagreements as equal. Standard Cohen's kappa is a nominal statistic. Judge 4 / expert 3 counts exactly as much as judge 4 / expert 1. But your scale is ordinal — the levels are ordered, and being off by one is meaningfully better than being off by three. Apple's sample uses plain, unweighted κ.
🔴 GAP — Apple's corpus contains no mention of weighted kappa. Not in the sessions, not in the documentation, not in the sample.
Statistics.cohensKappa(ratings1:ratings2:)takes no weighting parameter. Whether Apple considered and rejected it is unknown.What this means for you: the unweighted statistic is what Apple's 0.6 bar was chosen against, so use it as your gate. If you want the extra diagnostic signal, compute a linearly- or quadratically-weighted κ alongside it as a second
customaggregation and read them together — a big gap between unweighted and weighted κ tells you your judge is usually close but rarely exact, which is a completely different problem from a judge that is wildly wrong on a subset. §15.3 gives the implementation. Do not silently substitute weighted κ for unweighted and then compare against 0.6; they are not the same scale.
15. Implementing kappa — the framework does not ship it¶
15.1 The correction¶
⚠️ CORRECTION, and the most important one in this guide. A reader who watches session 335 will come away believing Cohen's kappa is a built-in aggregation, because the session discusses it at length as a first-class part of the workflow and never says otherwise. It is not in the framework.
✅ VERIFIED — the Book Tracker archive contains
HillClimbingEvaluations/Statistics.swift, 72 lines, whose sole job iscohensKappa. It is called asStatistics.cohensKappa(ratings1: expertRelevance, ratings2: judge) ?? 0(ModelJudgeAlignmentEvaluation.swift:303-332).✅ VERIFIED —
MetricsAggregator's complete documented member list iscomputeMean,computeMedian,computeMode,computeMinimum,computeMaximum,computeStandardDeviation,computeVariance,custom(of:label:_:),group(_:_:). There is no agreement statistic of any kind. No correlation, no κ, no ICC. (Confirmed against the shipped interface, 2026-07-29:Evaluations-27.0-macos.swiftinterface:753-780.)✅ VERIFIED — the session itself is consistent with this once you listen closely (
335:127): "we need to calculate Cohen's kappa, which I can do that with a custom aggregation method." "Custom aggregation method" is the API telling you to bring your own.
Two facts follow from the attested call site, and they constrain any implementation you write:
- The signature is
cohensKappa(ratings1:ratings2:) -> Double?— it returns an Optional, so there is a degenerate input for which it declines to produce a number. - The arguments are two arrays of
Double, positionally paired.
15.2 A complete implementation¶
🟡 RECONSTRUCTED — this is our code, not Apple's. We have read the call site and the file's length; we have not read the 72 lines of
Statistics.swift. What follows computes the standard unweighted Cohen (1960) κ exactly as narrated in335:98-101, and it matches the attested signature so it is a drop-in for Apple's call site. Where our implementation makes a choice the transcript does not constrain — how to bucket continuous scores, what to return for degenerate input — the choice is flagged in a comment. Do not assume Apple's 72 lines are byte-identical to this.
import Foundation
/// Inter-rater agreement statistics.
///
/// Cohen's kappa is NOT provided by the Evaluations framework. This is the
/// standard 1960 formulation, wired in through `MetricsAggregator.custom(of:label:)`.
enum Statistics {
/// Unweighted Cohen's kappa for two raters over the same ordered set of items.
///
/// - Parameters:
/// - ratings1: one rater's score per item, in item order.
/// - ratings2: the other rater's score per item, in the SAME item order.
/// - Returns: κ in `[-1, 1]`, or `nil` when it is undefined — see the notes below.
///
/// Returns `nil` when:
/// * the arrays are empty or of different lengths (a programming error, not a result), or
/// * expected agreement is 1.0, which happens when both raters assigned every item the
/// same single category. Accuracy is then 100% and the formula divides by zero. This
/// is NOT perfect agreement — it is no information at all, and it must not be reported
/// as κ = 1.
static func cohensKappa(ratings1: [Double], ratings2: [Double]) -> Double? {
guard !ratings1.isEmpty, ratings1.count == ratings2.count else { return nil }
let n = Double(ratings1.count)
// Bucket to integral categories. Scores come from a `.numeric` scale whose keys
// are whole numbers, but they arrive as Double, so round rather than compare
// floating-point values for equality.
let a = ratings1.map { Int($0.rounded()) }
let b = ratings2.map { Int($0.rounded()) }
// Observed agreement — Apple's "accuracy".
let agreements = zip(a, b).reduce(0) { $0 + ($1.0 == $1.1 ? 1 : 0) }
let observed = Double(agreements) / n
// Marginal distributions, over the union of categories either rater used.
var countsA: [Int: Double] = [:]
var countsB: [Int: Double] = [:]
for value in a { countsA[value, default: 0] += 1 }
for value in b { countsB[value, default: 0] += 1 }
// Expected agreement by chance — Apple's "coincidence", weighted by prevalence.
let categories = Set(countsA.keys).union(countsB.keys)
let expected = categories.reduce(0.0) { partial, category in
let pA = (countsA[category] ?? 0) / n
let pB = (countsB[category] ?? 0) / n
return partial + pA * pB
}
// κ = (observed − expected) / (1 − expected)
let denominator = 1.0 - expected
guard denominator > 1e-12 else { return nil } // undefined, not perfect
return (observed - expected) / denominator
}
}
The guard denominator > 1e-12 else { return nil } is the reason the real signature returns an
Optional, and it is worth understanding rather than copying. If both raters assign 4 to all 30
samples, observed agreement is 1.0 and expected agreement is also 1.0. The formula is 0/0. It is
tempting to call that perfect agreement and return 1.0 — do not. Two raters who never vary have
demonstrated nothing about whether they would agree on a hard case, and returning 1.0 would let a
judge that always says 4 pass its own calibration test.
15.3 The weighted variant, if you want the extra signal¶
🟡 RECONSTRUCTED — our code, standard statistics, no Apple precedent. Nothing in Apple's corpus mentions weighted κ (see the GAP in §14.4). Report it alongside unweighted κ, never instead of it.
extension Statistics {
enum KappaWeighting {
case linear // penalty grows with |i − j|
case quadratic // penalty grows with (i − j)², so near-misses barely count
}
/// Weighted Cohen's kappa for ORDINAL scales, where being off by one is
/// meaningfully better than being off by three.
static func weightedCohensKappa(
ratings1: [Double],
ratings2: [Double],
weighting: KappaWeighting = .linear
) -> Double? {
guard !ratings1.isEmpty, ratings1.count == ratings2.count else { return nil }
let n = Double(ratings1.count)
let a = ratings1.map { Int($0.rounded()) }
let b = ratings2.map { Int($0.rounded()) }
let categories = Array(Set(a).union(b)).sorted()
guard categories.count > 1 else { return nil }
let index = Dictionary(uniqueKeysWithValues: categories.enumerated().map { ($1, $0) })
let span = Double(categories.count - 1)
func disagreement(_ i: Int, _ j: Int) -> Double {
let d = abs(Double(i - j)) / span
switch weighting {
case .linear: return d
case .quadratic: return d * d
}
}
// Observed disagreement.
var observed = 0.0
for (x, y) in zip(a, b) {
observed += disagreement(index[x]!, index[y]!)
}
observed /= n
// Expected disagreement from the marginals.
var countsA = [Int: Double](), countsB = [Int: Double]()
for value in a { countsA[value, default: 0] += 1 }
for value in b { countsB[value, default: 0] += 1 }
var expected = 0.0
for x in categories {
for y in categories {
let pA = (countsA[x] ?? 0) / n
let pB = (countsB[y] ?? 0) / n
expected += pA * pB * disagreement(index[x]!, index[y]!)
}
}
guard expected > 1e-12 else { return nil }
return 1.0 - (observed / expected)
}
}
Reading the pair together:
| Unweighted κ | Weighted κ | Interpretation |
|---|---|---|
| low | high | The judge is usually within one level but rarely exact. Sharpen the anchor text between adjacent levels; the boundaries are fuzzy. |
| low | low | Genuine disagreement. Rewrite the dimension or add worked examples. |
| high | high | Aligned. Stop tuning. |
| high | low | Essentially impossible; if you see it, check your positional join (§19.1). |
15.4 Wiring it into the aggregator¶
✅ VERIFIED —
ModelJudgeAlignmentEvaluation.swift:303-332, verbatim:
func aggregateMetrics(using aggregator: inout MetricsAggregator) {
let expertRelevance = Self.samples.map { $0.expected?.expertRelevanceScore ?? 0.0 }
let expertUsefulness = Self.samples.map { $0.expected?.expertUsefulnessScore ?? 0.0 }
aggregator.group("Relevance") { group in
group.computeMean(of: relevance.metric)
group.computeStandardDeviation(of: relevance.metric)
group.custom(
of: relevance.metric,
label: "Relevance Alignment Score"
) { judge in
Statistics.cohensKappa(ratings1: expertRelevance, ratings2: judge) ?? 0
}
}
…
}
The mechanism is worth stating precisely because everything downstream depends on it:
✅ VERIFIED —
custom(of:label:_:)is documented as "Computes a custom aggregation from a single metric's results." The closure receives that metric's per-sample scores as[Double]in dataset order and returns a singleDouble. The label is a free string, and it is the same string you pass toresult.aggregateValue(.custom(label:))to read the value back.
So the judge's ratings arrive from the framework, in dataset order, and the expert's ratings are
captured from Self.samples — also in dataset order. The join between them is positional, and
it is entirely your responsibility. §19.1 is about what happens when it is wrong.
16. The meta-evaluation, end to end¶
Here is the construction. You are going to write an Evaluation whose subject is the judge. The
framework does not know or care that the thing under test is one of its own evaluators; an
Evaluation is just "dataset → subject → evaluators → aggregate", and that shape fits this problem
exactly.
✅ VERIFIED —
335:106-110: "I need to write an evaluation, which is made up of four components. First is my dataset. Then the subject of my evaluation. Then, I need to define my evaluators. And finally, I need to aggregate my results."
| Component | In a feature evaluation | In the alignment evaluation |
|---|---|---|
dataset |
prompts + expected outputs | (review, generated tags, YOUR two ratings) rows |
subject(from:) |
call the feature | return the already-generated tags — no inference at all |
evaluators |
heuristics + judge | the same model judge, unchanged |
aggregateMetrics |
means and pass rates | mean, stddev, and Cohen's kappa vs. your ratings |
The elegance is in the second row. By replaying frozen output, you remove the feature's nondeterminism from the experiment entirely, so the judge is the only variable. That is what makes this a controlled experiment rather than a vibe check.
16.1 Step 1 — get the data out of Xcode¶
You need the judge and yourself to score the same tags for the same reviews. Generating tags twice would give you two different sets, so the tags have to be frozen.
✅ VERIFIED —
335:112-119: "For this evaluation to work properly, both my model judge and I need to evaluate the exact same dataset. In this case the model judge reviews tags, so I need to produce a common set of tags for the judge and I to review. My evaluation from before contains a collection of reviews and tags. Because I ran this evaluation in a test, Xcode generated an attachment containing all of the evaluation data that was generated. I can retrieve that attachment and extract summary and tag pairs. Now, with the summary and tag pairs extracted, I need to add my ratings. After that, I can pass the contents of this file as the input to my evaluation."
The attachment is real, it is on disk, and Apple ships a command-line tool that parses it.
✅ VERIFIED — the Book Tracker archive contains a second command-line target,
DatasetExtractor/main.swift(167 lines), which parses Xcode's evaluation result bundle. The on-disk format, which no session or documentation article describes:where the escaped
Inputstring itself decodes to{ "input": { "prompt": "…" } }(DatasetExtractor/main.swift:15-32,:94-131). Default output path is~/Desktop/<BaseName>-extracted.json(:153-162). The tool depends onswift-argument-parser.
The extension is .xcevalresult. Apple's own description of the round trip:
✅ VERIFIED —
/documentation/evaluations/evaluationtrait: the trait is "A test trait that runs an evaluation and records the result as attachments."
So the pipeline is:
run BookTaggingEvaluation in Xcode
↓ (the .evaluates trait attaches the full run)
export the .xcevalresult bundle from the report navigator
↓ swift run DatasetExtractor <bundle>
~/Desktop/BookTaggingEvaluation-extracted.json ← reviews + generated tags
↓ a human opens it and adds two columns
BookTaggingEvaluation-extracted.json ← + expertRelevanceScore, expertUsefulnessScore
↓ JSONLoader
JudgeAlignmentEvaluation.dataset
✅ VERIFIED — the fixture in
HillClimbingEvaluations/BookTaggingEvaluation-extracted.jsonis the output of this pipeline with the expert columns added. That is the round trip that makes hill-climbing the judge possible, and it is the only mechanism in the corpus that gets model output in front of a human scorer at scale.
If you would rather not shell out to a CLI, EvaluationResult can serialise itself directly, which is
the modern-looking alternative:
✅ VERIFIED —
/documentation/evaluations/evaluationresult:func saveJSON(to:includeReportMetadata:),func jsonData(includeReportMetadata:jsonOptions:),static func loadJSON(from:),static func loadJSONLines(from:), plusvar detailed: DataFrameandfunc jsonRepresentableDataFrame(of:).
@Test("Book Tag Evaluations", .evaluates(evaluation, info: evaluationInfo))
func evaluateBookTagging() async throws {
let result = EvaluationContext.current.result
#expect(result.aggregateValue(.mean(of: Self.evaluation.tagCount)) >= 0.8)
// Drop a copy where the calibration workflow can pick it up.
// `saveJSON(to:)` takes a DIRECTORY; the framework names the file and returns its URL.
let written = try result.saveJSON(to: URL.desktopDirectory, includeReportMetadata: false)
print("run saved to \(written.path())")
}
✅ SDK-verified signature, 🟡 unverified output shape. The exact declaration is
@discardableResult func saveJSON(to directory: URL, includeReportMetadata: Bool = false, includeTranscripts: Bool = false) throws -> URL(Evaluations-27.0-macos.swiftinterface:600-616, checked 2026-08-23; beta 5 added the defaultedincludeTranscripts:) — the parameter is a directory, which is why the snippet above no longer appends a filename. What is still unverified is the shape of the JSON it writes: no sample calls it, and it is not necessarily the same layout as the.xcevalresultbundleDatasetExtractorparses. If you need Apple's exact extractor format, useDatasetExtractor.
16.2 Step 2 — score the rows yourself¶
There is no tooling for this and there does not need to be. Open the JSON, add two numbers per row.
Some hard-won advice about doing it well, since the quality of everything downstream is bounded by this step:
- Score before you look at the judge's scores. Once you have seen a 4 you cannot unsee it. If your extract already contains judge scores, delete that column first.
- Score in one sitting. Your own rubric drifts across days, which shows up as unexplainable κ variance.
- Write your reasoning for the rows you found hard. Those sentences become the few-shot examples in §17's third iteration. Apple's calibration prompt contains six of them, each in the form "Librarian: Relevance 4, Usefulness 4 / Why: …".
- Deliberately include bad output. A fixture with no 1s and no 2s in it cannot calibrate a judge's ability to recognise bad output, and it will trigger the prevalence paradox (§14.4).
- 30 rows is enough to start. Session 298's dataset guidance — "A focused dataset of 20 to 30
samples is a great place to get started" (
298:272-274) — applies with more force here, because every row costs human minutes.
16.3 Step 3 — the frozen subject¶
This is the trick, and it is three lines.
✅ VERIFIED —
ModelJudgeAlignmentEvaluation.swift:166-169, verbatim:func subject(from sample: ModelSample<BookTagJudgmentValue>) async throws -> ModelSubject<BookTagJudgmentValue> { let value = sample.expected ?? .placeholder return ModelSubject(value: value) }✅ VERIFIED —
335:121: "Normally, thesubjectmethod is for calling API related to your feature, but since the generated model responses are part of our dataset, we can simply return the already generated tags."
Note what ModelSample<BookTagJudgmentValue> implies: the expected type is not BookTags any more,
it is a richer type carrying the tags and the human scores, because ModelSample<Value> is generic
over the expected/output type and that is the only place per-sample data can live. Reconstructing it
from the two attested field names:
🟡 RECONSTRUCTED — the type name
BookTagJudgmentValue, the field namesexpertRelevanceScoreandexpertUsefulnessScore, and the.placeholderstatic are all attested atModelJudgeAlignmentEvaluation.swift:166-169and:303-332. The rest of the declaration below is inferred from those uses plusModelSample's documentedCodableconstraint.
struct BookTagJudgmentValue: Codable, Sendable {
/// The tags a previous evaluation run generated — replayed verbatim, never regenerated.
var tags: [String]
/// The human expert's ratings for those tags, on the same 1–4 scale as the judge.
var expertRelevanceScore: Double
var expertUsefulnessScore: Double
/// Used when a row somehow has no expected value. See the silent-failure note in §19.2.
static let placeholder = BookTagJudgmentValue(
tags: [], expertRelevanceScore: 0, expertUsefulnessScore: 0
)
}
Because the whole point is that no inference happens in the subject, this evaluation is fast and deterministic on the feature side. The only model calls in the entire run are the judge's — which is exactly the isolation you want, and also why a 30-row calibration run finishes in the time a 30-row feature evaluation spends on its first three samples.
16.4 Step 4 — the whole evaluation¶
Assembling the verified pieces into a compilable whole:
🟡 RECONSTRUCTED at the file level, ✅ VERIFIED line by line.
subject(from:)(:166-169), theScoreDimensionpair (:175-189),judge: SystemLanguageModel()(:213), the aggregation block (:303-332),@Suite(.serialized)(:337) and the test body (:344-352) are each verbatim from Apple's file. TheJSONLoaderwiring and theSelf.samplesdeclaration are inferred from their use sites — Apple's file loads the same fixture, but we have not read those specific lines.
import Evaluations
import Foundation
import FoundationModels
import Testing
@testable import BookTracker
struct ModelJudgeAlignmentEvaluation: Evaluation {
// ── Dataset ────────────────────────────────────────────────────────────────
// Rows extracted from a previous BookTaggingEvaluation run with DatasetExtractor,
// then hand-annotated with the expert's ratings.
static let samples: [ModelSample<BookTagJudgmentValue>] = loadExtractedFixture()
var dataset = ArrayLoader(samples: ModelJudgeAlignmentEvaluation.samples)
// ── Subject: no inference. Replay the frozen tags. ─────────────────────────
func subject(
from sample: ModelSample<BookTagJudgmentValue>
) async throws -> ModelSubject<BookTagJudgmentValue> {
let value = sample.expected ?? .placeholder
return ModelSubject(value: value)
}
// ── The dimensions under calibration ───────────────────────────────────────
// Deliberately re-worded versus BookTags.swift — tuning this text IS the experiment.
let relevance = ScoreDimension(
"Relevance",
description: """
Whether each tag describes a quality, theme, or tone of the book itself
rather than incidental details or the reader's personal reactions.
""",
scale: .numeric([
4: "Every tag describes the book itself",
3: "Most tags describe the book",
2: "Some tags describe personal reactions",
1: "Tags don't meaningfully describe the book"
])
)
let usefulness = ScoreDimension(
"Usefulness",
description: "Whether each tag works as a search term for browsing a personal library.",
scale: .numeric([
4: "Every tag would help someone find this book while browsing",
3: "Most tags are useful for browsing but a couple are too narrow or generic",
2: "About half the tags are useful; the rest are too narrow or generic",
1: "The tags would not help someone browse a library"
])
)
// ── Evaluators: the SAME judge the feature evaluation uses ─────────────────
var evaluators: Evaluators {
ModelJudgeEvaluator(
judge: SystemLanguageModel(),
dimensions: [relevance, usefulness],
prompt: calibrationJudgePrompt // 67 lines, six worked examples — see §17.4
)
}
// ── Aggregation: mean, spread, and κ against the expert ────────────────────
func aggregateMetrics(using aggregator: inout MetricsAggregator) {
let expertRelevance = Self.samples.map { $0.expected?.expertRelevanceScore ?? 0.0 }
let expertUsefulness = Self.samples.map { $0.expected?.expertUsefulnessScore ?? 0.0 }
aggregator.group("Relevance") { group in
group.computeMean(of: relevance.metric)
group.computeStandardDeviation(of: relevance.metric)
group.custom(of: relevance.metric, label: "Relevance Alignment Score") { judge in
Statistics.cohensKappa(ratings1: expertRelevance, ratings2: judge) ?? 0
}
}
aggregator.group("Usefulness") { group in
group.computeMean(of: usefulness.metric)
group.computeStandardDeviation(of: usefulness.metric)
group.custom(of: usefulness.metric, label: "Usefulness Alignment Score") { judge in
Statistics.cohensKappa(ratings1: expertUsefulness, ratings2: judge) ?? 0
}
}
}
}
@Suite("Model Judge Alignment", .serialized)
struct ModelJudgeAlignmentTests {
static let evaluation = ModelJudgeAlignmentEvaluation()
@Test("Judge Calibration", .evaluates(evaluation))
func evaluateJudgeCalibration() async throws {
let result = EvaluationContext.current.result
// Both the judge and the expert must produce an alignment score of 0.6
// for the judge to be considered calibrated.
#expect(result.aggregateValue(.custom(label: "Relevance Alignment Score")) > 0.6)
#expect(result.aggregateValue(.custom(label: "Usefulness Alignment Score")) > 0.6)
}
}
Details in there that are easy to get wrong:
static let evaluationon the suite. Not stylistic. The test body reaches the evaluation'sMetricidentities through it, and a fresh instance would carry different ones. ✅ VERIFIED — both of Book Tracker's suites do this (BookTags.swift:149-167,ModelJudgeAlignmentEvaluation.swift:337-352)..serializedon the calibration suite. ✅ VERIFIED at:337. Apple's tag-quality suite is not serialised; the calibration suite is. The reason is not stated. Our reading: the alignment run's meaning depends on judge inferences being comparable to each other, and concurrent model calls contend for the same on-device resource. Treat it as cargo worth carrying..custom(label:)reads back by the exact same string you wrote incustom(of:label:). There is no compiler check. A typo yields whateveraggregateValuereturns for a missing label, and your#expectthen tests nothing.?? 0on the κ call — see §19.3.- The test asserts, it does not iterate. ✅ VERIFIED — "the trait runs the whole dataset before
the test body, and the body is an assertion over the aggregate." The dataset has already been
scored by the time your
#expectruns.
16.5 What you now have¶
A failing test that means "my judge cannot be trusted", sitting in CI next to the failing test that means "my feature regressed". Those are different failures with different fixes, and before this construction there was no way to tell them apart.
✅ VERIFIED —
335:212-213: "This now means I can be confident that when my model judge provides ratings, I can confidently say that the tags are good or bad according my standards. That means I can now put my judge to work on evaluating Book Tracker's Book Tagging Service."
17. Hill-climbing the judge: four iterations¶
Apple's session 335 walks the judge from "not aligned at all" to "clears 0.6" in a documented sequence. The sequence matters as much as the individual changes, because each one is chosen from the previous run's evidence rather than from a list of best practices.
17.0 The scientific frame¶
✅ VERIFIED —
335:158-160: "In a science experiment, you have two groups. The control group, which represents the baseline and the experimental group which represents the change we are trying to compare against. We can think of the two versions of our instructions in the same way, where the control group is represented by our base prompt and our experimental group is represented by our newly changed prompt."
Mechanically, a comparative run is two instances of the same Evaluation type in one suite:
✅ VERIFIED —
335:167: "With both prompts written, I can add both evaluations to a test suite, which will run both evaluations." And335:230, for the feature-side equivalent: "So all I have to do is define two instances of my evaluation. One without the tool and one with it."
@Suite("Judge Calibration A/B", .serialized)
struct JudgeCalibrationComparison {
static let control = ModelJudgeAlignmentEvaluation(prompt: .baseline)
static let experimental = ModelJudgeAlignmentEvaluation(prompt: .candidate)
@Test("Baseline", .evaluates(control, info: ["JudgePrompt": "baseline"]))
func baseline() async throws { /* record only */ }
@Test("Experimental", .evaluates(experimental, info: ["JudgePrompt": "candidate"]))
func experimental() async throws {
let result = EvaluationContext.current.result
#expect(result.aggregateValue(.custom(label: "Relevance Alignment Score")) > 0.6)
#expect(result.aggregateValue(.custom(label: "Usefulness Alignment Score")) > 0.6)
}
}
🟡 RECONSTRUCTED — the two-instances-in-one-suite pattern is verified from the session and from Book Tracker's tool A/B (
335:227-231); parameterising the evaluation by its judge prompt is the obvious application and is ours. Theinfo:dictionary is ✅ VERIFIED as.evaluates(evaluation, info: [String: String])and Book Tracker uses it to stamp the prompt text itself into the run record (BookTags.swift:149-167), which is what makes the Compare view's diffs meaningful — otherwise you are looking at two runs and guessing what differed.
Then read them side by side:
✅ VERIFIED —
335:178-179: "From the evaluation report I can open the comparison button and open my baseline evaluation. Here, I can review the scores of the two prompts side by side." And299:97: "In Xcode 27, we introduced a new Evaluations Report to visualize your results."
And the procedural rule that is the single most important sentence in session 335:
✅ VERIFIED —
335:186-187: "But before I can make changes to the experimental evaluation, I applied the new prompt from my experimental evaluation into my baseline. This ensures there's only one different variable."
Promote the winner into the baseline before starting the next experiment. If you do not, iteration 2 is comparing against iteration 0 and you are testing two changes at once. This is the discipline the whole method rests on, and it is exactly as easy to skip as it sounds.
✅ VERIFIED —
335:242-245, the recap: "Hill-climbing works best when you focus on making one change at a time. To do this, treat every iteration of the loop like a science experiment. Being able to isolate your changes will help you to understand how each part of your feature contributes to the overall quality."
17.1 Iteration 0 — split the question¶
Covered in §7. TagQuality → Relevance + Usefulness. This happens in session 298, before any κ is
computed, and it is a prerequisite: you cannot calibrate a dimension that is asking two questions,
because your own ratings will be unstable for the same reason the judge's are.
Evidence it was needed: "the problem with our first model judge was that it was too broad. It was
asking two different questions" (298:238-239).
17.2 Iteration 1 — give the judge context about the app¶
Diagnosis from the failing run:
✅ VERIFIED —
335:141-153: "the scores for both usefulness and relevance are quite low, meaning my model judge and I aren't aligned." … "our judge thinks tags like self-help and self-improvement are relevant to the story" (Frankenstein) … "Terms like visual-dimension and quaint-dignity are way too specific" (The Ramakien) … "I believe the model doesn't have enough knowledge on it's own to distinguish between a good tag and bad one. That's likely because the prompt of my judge doesn't provide enough context."
The change:
✅ VERIFIED —
335:163-166: "For our experimental prompt, I've written a more thorough description about how to judge the set of tags. It starts by providing the judge context about the app and what it's about to be judging. Then it gives examples of good tags. As well as ways to identify bad tags."
The result, and this is the honest part of the story:
✅ VERIFIED —
335:170-175: "my alignment scores for relevance improved. While my alignment score for usefulness dropped considerably." … "Balancing tradeoffs like this are tricky so I need to think carefully how to proceed. But before in depth analysis comes checking if we passed. And my test confirms the obvious, we haven't." … "After thinking about it further, I am going to keep this prompt change and focus the next round of iteration on improving my usefulness score."
Two lessons worth more than the change itself:
- A change can improve one dimension and damage another. Splitting the question bought you the
ability to see this. With a single
TagQualitymetric the two effects would have cancelled and the run would have looked flat. - Keeping a change that failed the test is a legitimate move. The relevance gain was real; the
usefulness loss was a separate, addressable problem. Session 335's second recap point covers this:
"this process takes time. Not every change you make will result in positive change. However,
failed experiments tell you just as much as successful ones." (
335:246-248).
17.3 Iteration 2 — sharpen the score-dimension descriptions¶
Diagnosis, straight from the Compare view:
✅ VERIFIED —
335:180-184: "One thing that jumped out to me immediately is the discrepancy between usefulness scores of this review of 'Picture of Dorian Gray'. It seems to me that the model may be judging too harshly on usefulness. The usefulness column of the experimental evaluation seems to corroborate my guess. I noticed that all the scores are either a 3 or 2, which is way too harsh." … "I think what could help here is being more specific about how to grade each scoring dimension."
Note the shape of that diagnosis: it starts from one conspicuous row, forms a hypothesis, and then confirms it against the whole column. That is the right order — a single row is an anecdote, a column is evidence, and the report gives you both on one screen.
The change:
✅ VERIFIED —
335:189-191: "For relevance, I've provided a slightly longer description which emphasizes the need for a genre tag. And here is the one for usefulness. Which emphasizes being more critical of overly specific tags."
The result:
✅ VERIFIED —
335:193-194: "the scores both improved greatly over the baseline. It looks like these specific scoring dimensions are going to be a lot more helpful."
This is why the calibration evaluation declares its own ScoreDimension values rather than importing
the feature evaluation's:
✅ VERIFIED — the two dimension definitions are deliberately re-worded between the two files. Compare
BookTags.swift:43-56withModelJudgeAlignmentEvaluation.swift:175-189: the calibration copy encodes the librarian's generosity (that a small amount of drift is acceptable), which the feature-evaluation copy does not. Tuning the dimension text is itself part of the hill-climb, and the calibration file is where you tune it.
The workflow implication: once a dimension's wording clears κ ≥ 0.6 in the calibration evaluation, copy it back into the feature evaluation. The calibration file is the laboratory; the feature evaluation is production.
17.4 Iteration 3 — a few worked examples¶
Diagnosis:
✅ VERIFIED —
335:200-204: "My relevance score is starting to align. But my usefulness score could still use some work." … Frankenstein "continues to give our judge trouble." … "What I think our judge needs now is some examples of the way I judge things, which should give it a pattern for how to judge according to my scale."
The change:
✅ VERIFIED —
335:207-208: "I've reworked my main judge prompt to give it more detail about the goal of the tag generation feature to help ground the model in the problem space. From there, I've written out a number of examples for the model to use as a guideline for reviewing."
And here is what that actually looks like in shipped code:
✅ VERIFIED —
ModelJudgeAlignmentEvaluation.swift:216-283: the calibration judge prompt is 67 lines containing six labelled worked examples (A–F), each with an explicit rationale in the form "Librarian: Relevance 4, Usefulness 4 / Why: …", plus the instruction "Score Relevance and Usefulness independently, even when one tag affects both."
Reconstructing the shape of one of those examples — the format is verified, the specific book and tags below are ours:
let calibrationJudgePrompt = ModelJudgePrompt<ModelSample<BookTagJudgmentValue>>(
instructions: """
… role, context, criteria, evaluation steps as in §8.3 …
CALIBRATION EXAMPLES
Example A
Review: A seafaring adventure told by a boy who falls in with pirates.
Tags: adventure, pirates, coming-of-age, sea-story, classic
Librarian: Relevance 4, Usefulness 4
Why: every tag names something about the book, and every one is a phrase a
reader would plausibly click while browsing.
Example B
Review: The prose is gorgeous but the pacing dragged and I skimmed the middle.
Tags: gothic, overwrought, slow-burn, atmospheric, disappointing
Librarian: Relevance 2, Usefulness 2
Why: "overwrought" and "disappointing" describe the reader's reaction, not the
book. "slow-burn" is borderline. Only two tags survive as browsing filters.
Example C
Review: A meditation on grief in a small coastal town, quietly told.
Tags: literary-fiction, grief, coastal-setting, quiet-steadiness
Librarian: Relevance 4, Usefulness 2
Why: every tag is about the book, so relevance is high. But "quiet-steadiness"
is a phrase lifted from the review that nobody would ever type into a filter,
which is precisely the failure usefulness exists to catch.
… Examples D, E, F covering the remaining scale levels …
Use these examples to calibrate your scoring.
""",
evaluationTarget: { value in
"\(value.tags.count) Generated tags: " + value.tags.joined(separator: ", ")
},
reference: { _, _ in [:] }
)
🟡 RECONSTRUCTED — the structure (six labelled examples, the
"Librarian: Relevance N, Usefulness N / Why: …"form, the independence instruction) is ✅ VERIFIED fromModelJudgeAlignmentEvaluation.swift:216-283; the specific reviews, tags and rationales are ours. The closing line is Apple's documented tip: "Add a final instruction such as 'Use these examples to calibrate your scoring.' to remind the model as judge to use the examples as reference points." (/documentation/evaluations/designing-effective-model-judges).
Note that Example C is doing the most work of the three. A calibration set that only demonstrates "all good" and "all bad" teaches the judge nothing about the boundary, and the boundary is where all your disagreement lives. Apple's documentation says the same:
✅ VERIFIED — same article: "Include examples that span the full range of your scale. At minimum, show what a high score and a low score look like. Ideally, include an example at every level."
The result:
✅ VERIFIED —
335:212: "And now finally my scores are over my expected value! Which means I've finally passed and can exit out of the loop!"
17.5 The sequence, tabulated¶
| # | Change | Diagnosed from | Outcome |
|---|---|---|---|
| 0 | Split TagQuality → Relevance + Usefulness |
disagreeing with a score you could not localise | diagnosis becomes two-dimensional |
| 1 | App context + good/bad tag guidance in ModelJudgePrompt.instructions |
judge calling self-help relevant to Frankenstein |
relevance ↑, usefulness ↓ — kept anyway |
| 2 | Longer, sharper ScoreDimension.description per dimension (genre requirement; hostility to over-specific tags) |
a whole column of 2s and 3s — "way too harsh" | both ↑ greatly |
| 3 | Six worked examples spanning the scale, in the judge prompt | one book still misjudged; relevance aligning, usefulness lagging | κ clears 0.6, loop exits |
Notice the escalation: context → criteria → examples. Each is more expensive in prompt tokens and more specific to your dataset than the last, and each is only reached because the cheaper one was tried and measured. Do not start at examples.
17.6 The knobs, when you are stuck¶
✅ VERIFIED —
335:249-253: "Third, good experiments require creativity. In an intelligent feature there are so many things you can change. In your feature you can change the instructions, the tools, as well as the model or models you use to generate responses. On the evaluation side you can change the dataset, aggregation methods, and even the evaluators themselves. Everything is fair game."
| Side | Knob | For judge alignment specifically |
|---|---|---|
| Feature | instructions / prompt | irrelevant — the subject is frozen |
| Feature | tools | irrelevant — the subject is frozen |
| Feature | the model | irrelevant — the subject is frozen |
| Evaluation | dataset | add rows at the low end of the scale (fixes the prevalence paradox) |
| Evaluation | aggregation | add weighted κ alongside unweighted (§15.3) |
| Evaluation | the evaluators themselves | this is where all judge alignment work happens |
That the first three rows are inert is the entire value of the frozen subject. In the feature hill-climb they are the only rows that matter; in the judge hill-climb they cannot contaminate the experiment at all.
18. Overfitting the alignment score¶
✅ VERIFIED —
335:210: "I've made sure to only give the model a few examples. By giving it a longer list I am prone to overfit the alignment score, which would make it hard to tell if my judge is actually aligned with me."
This is the sharpest single warning in the Evaluations material and it is easy to skate past, so here is the mechanism spelled out.
Your calibration fixture has, say, 30 rows. Suppose you put 20 of them into the judge prompt as worked examples, with your ratings. Now run the alignment evaluation. Two-thirds of the samples the judge is scoring are samples whose correct answer is printed in its own prompt. κ goes up. It goes up a lot. And it means nothing, because you have not built a judge — you have built a lookup table with a language model wrapped around it, and it will not generalise to sample 31.
The formal name for this is train/test contamination, and the reason it is more insidious here than in
conventional machine learning is that there is no train/test split in the API. Evaluation has one
dataset. Nothing stops your judge prompt from quoting it. Nothing warns you when it does.
⚠️ SILENT FAILURE — a contaminated calibration reports a higher κ, which is exactly the wrong direction for a warning. Every other failure in this guide makes a number look worse or stay flat. This one makes your judge look better the more you break it, and the resulting judge then goes into production and drifts on your real dataset with a green calibration test behind it.
18.1 What to do instead¶
Keep the example count small — Apple's is six against a fixture of tens of rows. That ratio is the guidance, and it is deliberate.
Hold rows out. The framework will not do this for you, so do it by hand: split your extracted
fixture into a calibration set and a held-out set at extraction time, draw examples only from the
calibration set, and report κ on the held-out set. Two JSON files, two Evaluation instances, one
suite:
@Suite("Judge Calibration", .serialized)
struct JudgeCalibrationTests {
// Examples in the judge prompt are drawn ONLY from rows in this fixture.
static let development = ModelJudgeAlignmentEvaluation(fixture: "judge-calibration-dev")
// Never quoted in any prompt. This is the number that decides whether you ship.
static let heldOut = ModelJudgeAlignmentEvaluation(fixture: "judge-calibration-holdout")
@Test("Development set", .evaluates(development, info: ["Set": "dev"]))
func dev() async throws { }
@Test("Held-out set", .evaluates(heldOut, info: ["Set": "holdout"]))
func holdout() async throws {
let result = EvaluationContext.current.result
#expect(result.aggregateValue(.custom(label: "Relevance Alignment Score")) > 0.6)
#expect(result.aggregateValue(.custom(label: "Usefulness Alignment Score")) > 0.6)
}
}
🟡 RECONSTRUCTED — Apple's sample does not do this; it runs one fixture. The held-out split is our recommendation, built from verified pieces (
JSONLoader(url:), two evaluation instances in one suite,info:stamping). It is what the overfitting warning implies but does not spell out.
Watch the gap. If dev κ is 0.85 and held-out κ is 0.35, you have overfit and the fix is fewer examples, not more. If they are both 0.65, you have a judge.
Write examples that teach a rule, not an answer. An example whose "Why" explains the principle ("a phrase lifted from the review is not a browsing filter") generalises. An example that just asserts a score does not.
18.2 When to stop tuning the judge¶
Stop at 0.6. Seriously.
A κ of 0.95 against a 30-row fixture is not a better judge than 0.65; it is either an overfit judge or a fixture so easy that everything scores 4. The bar exists to answer a yes/no question — can this judge stand in for me? — and once the answer is yes, every further hour goes into the feature, which is the thing your users actually touch. Session 335's own loop exits the moment the test passes.
19. ⚠️ Silent failures in judge alignment¶
The defining property of this stack is that most defects do not throw. Judge alignment is the densest concentration of them in the entire series, because the whole apparatus is a machine for producing plausible-looking numbers.
19.1 ⚠️ The positional join, which nothing validates¶
This is the big one.
let expertRelevance = Self.samples.map { $0.expected?.expertRelevanceScore ?? 0.0 }
group.custom(of: relevance.metric, label: "Relevance Alignment Score") { judge in
Statistics.cohensKappa(ratings1: expertRelevance, ratings2: judge) ?? 0
}
Two arrays. expertRelevance comes from your static sample array. judge comes from the
framework's per-metric result column. They are paired by index, and the only thing making that
valid is the guarantee that the framework delivers scores in dataset order.
Everything that can silently break that pairing:
| Cause | What happens |
|---|---|
The Loader yields a different order than Self.samples |
every pair is wrong; κ collapses toward 0 |
You filter Self.samples in one place and not the other |
off-by-N misalignment from the first removed row onward |
A sample's judge call fails and its metric is dropped or ignored |
the judge array is shorter, so every subsequent pair shifts |
| You add a sample to the fixture but the fixture is cached | lengths differ; our implementation returns nil → ?? 0 → a hard zero |
| Two dimensions computed from the same array by copy-paste | Usefulness κ silently computed against expert relevance |
None of these throws. All of them produce a number. A misaligned κ typically lands somewhere in 0.0–0.2, which reads exactly like "my judge is badly calibrated" — so you will spend a day rewriting a judge prompt that was fine.
Defend against it. Nothing in the framework will, so add an assertion of your own inside the closure, where you have both arrays in hand:
group.custom(of: relevance.metric, label: "Relevance Alignment Score") { judge in
// The join is positional. If the lengths ever disagree, the number below is
// meaningless — fail loudly rather than reporting a plausible kappa.
precondition(
judge.count == expertRelevance.count,
"""
Judge/expert length mismatch: \(judge.count) judge scores vs \
\(expertRelevance.count) expert scores. The positional join is invalid.
"""
)
return Statistics.cohensKappa(ratings1: expertRelevance, ratings2: judge) ?? 0
}
🔴 GAP — we do not know how
custom(of:label:_:)represents a sample whose metric wasignored or whose evaluator threw. The documentation says the closure computes "a custom aggregation from a single metric's results" andMetric.ignore(rationale:)is "excluded from aggregation", which is at least suggestive that ignored samples are omitted from the array rather than represented by a placeholder — and if so, the positional join breaks the moment any judge call is ignored.Metric.doubleValueis separately documented as absent "for ignored metrics", which points the same way.What would resolve it: one run with a deliberately-ignored metric and a
print(judge.count)inside the closure. Ten minutes with Xcode 27.Safe default meanwhile: never return
.ignore()from an evaluator whose metric feeds a positional custom aggregation, and keep thepreconditionabove. If you need an "unscoreable" sample, drop it from the fixture instead — from both sides.
19.2 ⚠️ ?? 0.0 turns a missing expert score into a rating of zero¶
Apple's own line:
If a row in your hand-edited JSON is missing its expert score — you skipped it, the key was
misspelled, Codable decoded a default — that row contributes a rating of 0.0. Zero is not on the
1–4 scale. It becomes a fifth category that only one rater ever uses, which:
- makes exact agreement on that row impossible,
- adds a category to the marginal distribution, changing
p_chancefor every other row, - and depresses κ by more than the single row's share.
Nothing throws. The JSON parses. The evaluation runs. κ comes out low and you go and rewrite your judge prompt.
Fix it at the boundary. Make the expert scores non-optional in your fixture type and validate on load:
static func loadExtractedFixture(named name: String) -> [ModelSample<BookTagJudgmentValue>] {
let url = Bundle.module.url(forResource: name, withExtension: "json")!
let rows = try! JSONDecoder().decode([ModelSample<BookTagJudgmentValue>].self,
from: Data(contentsOf: url))
for (i, row) in rows.enumerated() {
guard let expected = row.expected else {
fatalError("Fixture row \(i) has no expected value — it was never scored.")
}
precondition((1...4).contains(Int(expected.expertRelevanceScore)),
"Fixture row \(i): relevance \(expected.expertRelevanceScore) is off-scale.")
precondition((1...4).contains(Int(expected.expertUsefulnessScore)),
"Fixture row \(i): usefulness \(expected.expertUsefulnessScore) is off-scale.")
}
return rows
}
🟡 RECONSTRUCTED —
ModelSampleis ✅ VERIFIEDCodable(Book Tracker's generator JSON-encodes[ModelSample<BookTags>]straight to disk andJSONLoader(url:)reads it back), so decoding an array of them is sound. The validation is ours.
Related, and worth knowing before it surprises you:
✅ VERIFIED —
/documentation/evaluations/jsonloader: "Malformed entries are logged viaOSLogand skipped. A failure to open the file propagates as a thrown error."
So a malformed row in your calibration fixture silently shrinks the dataset — and shrinks only the
framework's side of the positional join, not Self.samples. That is §19.1 again, arriving through a
different door. Missing file: throws. Corrupt row: a log line you will not read.
19.3 ⚠️ An undefined κ reports as "no agreement"¶
Statistics.cohensKappa(...) ?? 0 maps undefined onto zero, and those are different facts:
- κ = 0 means "your judge agrees with you exactly as often as random guessing would."
- κ undefined means "the arithmetic has no answer here" — both raters used a single category, or the arrays were empty or mismatched.
Collapsing the second into the first is defensible for a test gate (both should fail) but actively misleading during diagnosis, because 0 sends you to "rewrite the judge" when the real answer is "your fixture has no variation in it." Distinguish them:
group.custom(of: relevance.metric, label: "Relevance Alignment Score") { judge in
guard let kappa = Statistics.cohensKappa(ratings1: expertRelevance, ratings2: judge) else {
// -1 is a legal kappa value but unreachable in practice, so it reads as a sentinel
// in the report: "undefined", not "chance-level agreement".
return -1
}
return kappa
}
Keep the > 0.6 assertion; -1 fails it just as 0 does, but now the report tells you which
problem you have.
19.4 ⚠️ A judge that never varies looks excellent by mean¶
Covered in §11.3, restated here because it belongs on the checklist: a judge returning 4 for every sample has a perfect mean, a zero standard deviation, and (by §14.2) a κ of exactly 0 or undefined. If you aggregate only the mean, you will ship it. Always compute the standard deviation of a judge metric. Apple's calibration evaluation does.
19.5 ⚠️ A pairwise judge silently discards your instructions¶
From §10: "The instructions and reference components only apply to pointwise evaluators." Pass a
carefully-written ModelJudgePrompt to ModelJudgeEvaluator.pairwise(...) and the instructions are
dropped in favour of Apple's built-in comparison prompt. The evaluation runs, the scores are
reasonable, and the app context you wrote never reached the model.
19.6 ⚠️ A custom label typo tests nothing¶
custom(of:label:) writes by string; aggregateValue(.custom(label:)) reads by string. They are not
checked against each other. Write "Relevance Alignment Score" in one place and
"Relevance alignment score" in the other and your #expect is evaluating whatever
aggregateValue returns for a label that was never registered.
Fix: never write the string twice.
enum AlignmentLabel {
static let relevance = "Relevance Alignment Score"
static let usefulness = "Usefulness Alignment Score"
}
19.7 ⚠️ The evaluation constructs the model differently from the app¶
From §3.4. Book Tracker's feature builds
SystemLanguageModel(guardrails: .permissiveContentTransformations); so does its tool evaluation. If
your evaluation quietly uses the default guardrails while your app uses permissive ones, you are
scoring a different system and no test will tell you. Call your real service from
subject(from:).
19.8 ⚠️ Judge inferences inherit every Foundation Models failure mode¶
A judge call is a model call. It can hit a guardrail, refuse, exceed context, or find the model unavailable — and a κ computed over a run where 12% of judge calls failed is a κ over 88% of your fixture, joined positionally against 100% of your expert ratings (§19.1).
✅ VERIFIED — the framework declares typed errors for exactly these paths:
EvaluationError,EvaluatorError("A typed reason why an evaluator failed while scoring a produced subject"),SubjectInferenceError("A typed reason whysubject(from:)failed to produce a subject for a sample"),EvaluationResultsError, andModelJudgeError.✅ SDK-verified —
ModelJudgeError's cases (GAP half-closed, 2026-07-29). The interface pins five cases, all about the judge's response rather than its transport (Evaluations-27.0-macos.swiftinterface:341-352):public enum ModelJudgeError : LocalizedError { case invalidScore(dimension: String, value: String) case invalidResponse(String) case jsonDecodingFailed(response: String, underlying: any Error) case missingDimension(String, response: String) case noScaleValues(dimension: String) }Read the list as a diagnosis menu: the judge answered off-scale (
invalidScore), answered in the wrong shape (invalidResponse,jsonDecodingFailed), skipped a dimension you asked for (missingDimension), or was handed a scale with no options (noScaleValues).🔴 Still open: a real model judge's retry/abort policy is not established by these interfaces or by model-free probes. Xcode 27 final exposes
EvaluationResult.errors, including inference/evaluator failure counters andhasFailures(Evaluations-27.0-macos.swiftinterface:505-515,564). Reject reported failures and still compare scored judge-array length with fixture length (§19.1'sprecondition). Neither a clean error summary nor a plausible aggregate establishes complete scoring coverage. The deprecatedEvaluationError.metricsNotFoundmessage cited in the July beta was removed from the final interface; that historical evidence is retained in Git history, as described in §17.7.
19.9 The checklist¶
- Every judge metric has a standard deviation aggregated next to its mean. (§11.3)
- The positional join is length-asserted inside the
customclosure. (§19.1) - Expert scores are validated on load to be on-scale, never defaulted to 0. (§19.2)
- Undefined κ is distinguishable from κ = 0 in the report. (§19.3)
-
customlabels are constants, written once. (§19.6) -
subject(from:)calls the real service, constructing the model the way the app does. (§3.4) - Judge prompt examples come only from a development split, and κ is reported on a held-out split. (§18.1)
- Your fixture contains rows at the low end of the scale. (§14.4, §16.2)
- You have read the rationales at least once. (§8.3, §9)
-
ScoreDimensionnames are stable across runs so Compare works. (§6)
20. What is still unknown¶
Collected, so you can see the shape of the fog rather than meeting it one patch at a time.
✅ CLOSED (2026-07-29) —
ScoringModecases.case discrete,case continuous, with.discretethe default everywhere the parameter appears (Evaluations-27.0-macos.swiftinterface:306-314,:323-330). The semantics of.continuousremain undocumented. Meanwhile: omit it, as Apple does (§4.1).✅ CLOSED in part (2026-07-29) —
ModelJudgeErrorcases are five, SDK-verified (§19.8;Evaluations-27.0-macos.swiftinterface:341-352). 🔴 Still open: the framework's behaviour when a judge inference throws mid-dataset. Resolve with: one deliberately-failed run. Meanwhile: length-assert the join (§19.1).✅ CLOSED (2026-07-29) — the second parameter of
ModelJudgePrompt.reference. It is the model's output value, typedInput.ExpectedValue; the full closure type is(Input, Input.ExpectedValue) async throws -> [String : String](Evaluations-27.0-macos.swiftinterface:361). Not aModelSubject, as previously guessed (§8.2).🔴 GAP —
ScoringScalecases beyond.numericin practice..passFailand.customare documented with examples and their signatures are now SDK-verified (Evaluations-27.0-macos.swiftinterface:388-394), but only.numericappears anywhere in Apple's sample archive, and noScoreLevel-conforming enum appears in compiling code. Meanwhile:.numericfor quality,.passFailfor binary; treat.customas unproven in practice, though no longer in spelling.🔴 GAP — the exact contents of
Statistics.swift. We know it is 72 lines, that it is hand-written, that it lives inHillClimbingEvaluations/, and that its entry point iscohensKappa(ratings1:ratings2:) -> Double?. We have not read the body. §15.2 is our implementation of the formula narrated in335:98-101, not a transcription of Apple's. Resolve with: downloading the Book Tracker archive and opening the file.🔴 GAP — multimodal judging. A developer forum question asks whether evaluations work for image-text systems (MobileCLIP2, YOLOE); it is unanswered.
ModelSample's documentation notes that "for multimodal prompts, create a customModelSampleProtocolconformance or use theinit(input:expected:expectations:)initializer with a prebuiltModelSampleInput", which establishes that multimodal inputs are representable — but says nothing about a judge scoring an image. Meanwhile: judge the text description of an image result, not the image.🔴 GAP — running a PCC model as the judge. See §3.3. Narrated in session 298, never seen in code. The interface narrows it:
judge:is declaredany LanguageModel(Evaluations-27.0-macos.swiftinterface:323-330), so a PCC model typechecks — runtime behaviour against quota and the judge's decoding path is the open part. Meanwhile:SystemLanguageModel.defaultplus κ calibration.🔴 GAP — weighted κ. Absent from Apple's entire corpus — and the interface confirms no weighting parameter exists anywhere in the module (κ itself is sample code, not framework). §15.3 is ours, offered as a supplementary diagnostic and explicitly not as a substitute for the statistic the 0.6 bar was set against.
One non-gap worth recording, because it is unusually clean: the Evaluations developer forum contains exactly three threads, all from WWDC26 week, one unanswered. There is essentially no community knowledge about this framework yet. When something behaves unexpectedly, you are probably the first person to see it, and the sample archive is a better oracle than a search engine.
21. Quick reference¶
21.1 The judge API, in one place¶
// ── Scale ──────────────────────────────────────────────────────────────────────
ScoringScale.numeric([4: "…", 3: "…", 2: "…", 1: "…"]) // [Double: String]
ScoringScale.passFail(passDescription: "…", failDescription: "…")
ScoringScale.custom(MyScoreLevelEnum.self) // MyScoreLevelEnum: ScoreLevel
ScoringScale(options: [...]) // var options — highest to lowest
// ── Dimension ──────────────────────────────────────────────────────────────────
ScoreDimension(_ name: String, description: String?, scale: ScoringScale)
dimension.metric // the Metric to aggregate against
dimension.name // becomes the DataFrame column name
// ── Prompt ─────────────────────────────────────────────────────────────────────
ModelJudgePrompt(
instructions: String = .defaultInstructions, // role + criteria + steps
evaluationTarget: ((Value) -> String)? = nil, // how the output is shown
reference: ((ModelSample<Value>, Value) async throws -> [String: String])? = nil
) // labelled sections — a DICTIONARY (SDK-verified)
ModelJudgePrompt.defaultInstructions // used if you omit the prompt
// ── Evaluator ──────────────────────────────────────────────────────────────────
// judge: any LanguageModel = SystemLanguageModel() on the promptless forms;
// scoringMode: ScoringMode = .discrete (cases: .discrete | .continuous)
ModelJudgeEvaluator(_ name:, scale:, judge:, scoringMode:) // single dimension
ModelJudgeEvaluator(_ name:, scale:, judge:, scoringMode:, prompt:)
ModelJudgeEvaluator(judge:, dimensions:, scoringMode:) // multi-dimension
ModelJudgeEvaluator(judge:, dimensions:, scoringMode:, prompt:)
ModelJudgeEvaluator.pairwise(_ name:, scale:, judge:, scoringMode:, evaluationTarget:)
ModelJudgeEvaluator.pairwise(judge:, dimensions:, scoringMode:, evaluationTarget:)
try await evaluator.judgePrompt(for:output:) // async throws -> Prompt — your best debugger
ModelJudgeEvaluator.defaultInstructions
// ── Aggregation ────────────────────────────────────────────────────────────────
func aggregateMetrics(using aggregator: inout MetricsAggregator) {
aggregator.group("Quality") { group in
group.computeMean(of: dimension.metric)
group.computeStandardDeviation(of: dimension.metric)
group.custom(of: dimension.metric, label: "…") { scores in Double }
}
}
// ── Reading it back ────────────────────────────────────────────────────────────
let result = EvaluationContext.current.result
result.aggregateValue(.mean(of: metric)) // -> Double
result.aggregateValue(.custom(label: "…")) // -> Double
result.detailed[metric: metric] // per-sample column, incl. .rationale
result.summary // TabularData DataFrame
Everything above: iOS / iPadOS / Mac Catalyst / macOS / visionOS / watchOS 27.0, Beta. Xcode 27. No tvOS. Swift only.
21.2 The decision table¶
| Situation | Do this | § |
|---|---|---|
| You can measure it in code | write an Evaluator closure, not a judge |
§1 |
| You can only describe it in words | ModelJudgeEvaluator |
§1 |
| Binary judgement (safe / compliant / correct format) | .passFail, not a 1–4 scale |
§5.1 |
| Subjective quality | .numeric with four levels |
§5.1 |
| Your anchors say "Excellent / Good / Fair / Poor" | rewrite them as observable features | §5.2 |
| You disagree with a score | split the dimension | §7 |
| All the scores are the same | your question is too broad — split | §7.4 |
| The judge misunderstands your domain | ModelJudgePrompt.instructions |
§8 |
| The judge is too harsh or too lenient across a whole column | sharpen ScoreDimension.description |
§17.3 |
| One book keeps confusing it | a few worked examples — a few | §17.4 |
| "Is the new prompt better?" | pairwise, or two pointwise runs + Compare | §10 |
| You need to know whether the judge can be trusted | the κ meta-evaluation | §16 |
| κ is low and accuracy is high on a skewed fixture | fix the fixture, not the judge | §14.4 |
| κ is high and climbing with every example you add | you are overfitting — hold rows out | §18 |
| κ ≥ 0.6 | stop; go work on the feature | §18.2 |
21.3 Symptom → cause¶
| Symptom | Likely cause | § |
|---|---|---|
| Judge scores everything 3 | odd-numbered scale, or a broad question | §5.1, §7.4 |
| Judge scores everything 4; mean looks great | no ModelJudgePrompt, or no bad samples in the fixture |
§8.3, §16.2 |
| Rationales are generic praise | context-free judge, or gradient-style anchors | §8.3, §5.2 |
| Two dimensions always move together | they are one dimension; merge them | §7.4 |
| Scores dropped when the dataset grew | either the feature is worse on hard cases or the judge drifts — κ tells you which | §12.2 |
| 80% agreement, κ ≈ 0 | the judge is guessing the majority class | §13.1, §14.2 |
| High accuracy, low κ, very skewed fixture | prevalence paradox — add low-scoring rows | §14.4 |
| κ ≈ 0 across every dimension at once | misaligned positional join, not a bad judge | §19.1 |
| κ fell after adding one fixture row | a row missing its expert score defaulted to 0 | §19.2 |
| κ = 0 with a fixture where everyone agrees | undefined, not chance-level | §19.3 |
| κ great on dev rows, terrible on new ones | overfit judge prompt | §18 |
| Judge prompt's app context seems ignored | you passed it to a pairwise evaluator | §10, §19.5 |
#expect on an alignment score always passes |
label typo between custom(of:label:) and .custom(label:) |
§19.6 |
| Feature scores differ between app and evaluation | the evaluation builds the model differently | §3.4, §19.7 |
| Fixture silently has fewer rows than the file | JSONLoader logged and skipped a malformed entry |
§19.2 |
21.4 The four hypotheses when scores move¶
✅ VERIFIED —
299:103-111, verbatim: "By running our evaluation on a larger dataset, a drop in scores could signal many different things."
- "Score changes could be due to problems with our prompt or instructions."
- "You could also consider gaps in your intelligence feature."
- "Or you may want to adjust your evaluation to understand what you are actually evaluating on."
- "your dataset may still not be representative enough."
"These are the core ways to further improve your results."
Hypothesis 3 is the one this guide is about, and a calibrated judge is what lets you eliminate it first — cheaply, and without touching the feature.
22. Sources¶
Apple sample-code project, read on disk — the highest-precedence evidence here, because it
compiles and ships. Book Tracker — Using Evaluations to evaluate an intelligent feature
(MACOSX_DEPLOYMENT_TARGET = 27.0; 20 Swift files across 5 targets), obtained 2026-07-27 from the
docs-assets ZIP behind
developer.apple.com/tutorials/data/documentation/evaluations/book-tracker-using-evaluations-to-evaluate-an-intelligent-feature.json.
Files cited: BookTracker/Services/BookTaggingService.swift · BookTrackerEvaluations/BookTags.swift ·
BookTrackerEvaluations/SyntheticBookTags.swift · BookTrackerEvaluations/SearchBooks.swift ·
HillClimbingEvaluations/ModelJudgeAlignmentEvaluation.swift (353 lines — the κ calibration) ·
HillClimbingEvaluations/Statistics.swift (72 lines — hand-rolled Cohen's kappa; signature and
call site read, body not read) · HillClimbingEvaluations/BookTaggingEvaluation-extracted.json ·
BookSampleGenerator/main.swift · DatasetExtractor/main.swift.
The framework's shipped Swift interface —
notes/sdk-interfaces/Evaluations-27.0-macos.swiftinterface (925 lines), refreshed from Xcode 27 final (27A266a) on 2026-10-06. For names, signatures, defaults,
availability and case lists it outranks every source below, the sample included; for usage and
runtime behaviour it decides nothing. Cited as ✅ SDK-verified with line numbers. It closed this
guide's GAPs on ScoringMode, ModelJudgeError's cases and ModelJudgePrompt.reference's second
parameter, and corrected judgePrompt(for:output:) to async throws -> Prompt.
Apple documentation (harvested 2026-07-27 via sosumi.ai mirrors of developer.apple.com):
/documentation/evaluations (framework index, 44 KB) ·
/documentation/evaluations/scoring-with-model-as-judge-evaluators ·
/documentation/evaluations/designing-effective-model-judges ·
/documentation/evaluations/designing-effective-evaluations ·
/documentation/evaluations/designing-evaluation-criteria ·
/documentation/evaluations/designing-evaluation-datasets ·
/documentation/evaluations/evaluating-language-model-responses ·
/documentation/evaluations/modeljudgeevaluator · /modeljudgeprompt · /scoredimension ·
/scoringscale · /metric · /metricsaggregator · /evaluation · /evaluator ·
/evaluatorprotocol · /modelsample · /jsonloader · /arrayloader · /evaluationresult ·
/evaluationtrait.
Apple Developer Forums (Apple-staff answers marked as such): 833729 — a Frameworks Engineer
confirms "Evaluations is a Swift-based framework. So you would need to call the Swift APIs from the
other language." · 832053 — an Apple engineer on ModelJudgeEvaluator: "used to evaluate a
response where the score is subjective — e.g. 'is this a good explanation'", with a code snippet that
puts PrivateCloudComputeLanguageModel() in the subject's session, not the judge's. · 833822
(vision evaluations) is unanswered and is the source of the multimodal GAP in §20.
WWDC26 session transcripts — machine-transcribed spoken word, containing no literal on-screen code. Verbatim sentences are quoted with line references and treated as verified statements; any API spelling that exists only in narration is marked 🟡: 298 Meet the Evaluations framework (Yada, Rob) — the judge definition, the capability rule, the four components, the 1–4 scale rationale, the split-the-question technique, the best-practices block. 299 Create robust evaluations for agentic apps (Ada, Kyle) — platform gates, the 13→100 sample drop, the four hypotheses. 335 Improve your prompts by hill climbing with Evaluations (Marcus) — drift, the accuracy critique, Cohen's kappa, the alignment meta-evaluation, the four iterations, the overfitting warning, the four closing rules.
Explicitly not cited as 2026 evidence: the coffee/generative-game sample and the SpeechAnalyzer sample are iOS 26 / WWDC25 leftovers that were never refreshed. Nothing in them is evidence about the Evaluations framework, which did not exist when they were written.
Where sources disagree, and how this guide ruled:
- Judge model. Session 298 says use a more capable model — Private Cloud Compute — as the
judge. Apple's shipping sample uses
SystemLanguageModel.default/SystemLanguageModel()to judge an on-device feature, at all three of its judge call sites, and every documentation example does the same. Ruling: sample code outranks session narration. The guide teaches the principle and states plainly that Apple's own code does not follow it, with the three reasons that makes defensible (§3.2). - Cohen's kappa's provenance. Session 335 discusses κ at length as part of the workflow and a
reader naturally concludes it is a framework feature. It is not:
MetricsAggregatorhas no agreement statistic and Apple's sample hand-rolls 72 lines. The session is consistent with this once you notice "which I can do that with a custom aggregation method" (335:127). Ruling: corrected loudly, in the opening block and again in §15. ModelJudgePrompt.reference's return type. Material in circulation has it as aString. Both the documentation and the sample say[String: String], a dictionary of labelled sections. Ruling: dictionary, verified twice.- Scale parity.
designing-effective-model-judgesinsists on an even number of levels;scoring-with-model-as-judge-evaluatorsopens with a three-levelGrammarexample. Ruling: follow the advice article — every dimension in the sample archive uses four levels — while noting what the 5/3/1 example legitimately demonstrates about non-contiguous scales (§5.1). .numeric's key type. Documentation says[Double: String]; the sample writes integer literals. Ruling: both are true — Swift literal coercion — and the guide says so rather than picking one (§5).- Dimension naming. Apple's documentation calls the first book-tagging dimension
Accuracy; the session and the sample call itRelevance. Ruling:Relevance, because that is the spelling that compiles alongside the κ-calibration evaluation. var evaluators' type. A reconstruction from session 298's narration producedsome Evaluator. The sample saysvar evaluators: Evaluators— a result-builder type, plural. Ruling: the sample. The narrated form does not compile.
Precedence used throughout: for signatures, the shipped .swiftinterface first, then Apple
sample-code projects; for usage and behaviour, Apple sample-code projects first. Below both: Apple
documentation pages > Apple-staff forum answers > WWDC session transcripts > community material. No
community source is cited in this guide, because for this framework there is essentially none: the
Evaluations developer forum contains three threads in total.
Next in this part: 03-synthetic-data-and-tool-trajectories.md — SampleGenerator, the targetCount
trap, and evaluating how the model got there with TrajectoryExpectation and ToolCallEvaluator.
Previously: 01-foundations-and-hill-climbing.md — the Evaluation protocol, metrics,
aggregation, the .evaluates trait and the Xcode report.