Building blocks, Swift Testing integration, and evaluation-driven development¶
Part 6 · Evaluations · Reference 01
Version floor: the Evaluations framework is new in the 27 cycle and does not back-deploy. Every
symbol in it is marked iOS 27.0+ · iPadOS 27.0+ · Mac Catalyst 27.0+ · macOS 27.0+ · visionOS 27.0+ ·
watchOS 27.0+, and the whole framework index is tagged Beta. There is no tvOS. You need
Xcode 27 — the .evaluates test trait and the Evaluations report are Xcode features, not just
library code. The feature you point it at can be older: SystemLanguageModel is iOS 26.0 (watchOS 27.0),
tokenCount(for:) and contextSize are 26.4, and PrivateCloudComputeLanguageModel,
Transcript.structuredTranscript and SystemLanguageModel(guardrails:) are 27.0. So you can
evaluate a 26.0-era feature, but you can only run the evaluation on 27.
✅ VERIFIED — availability. "Everything in this framework is
iOS 27.0+ Beta, iPadOS 27.0+ Beta, Mac Catalyst 27.0+ Beta, macOS 27.0+ Beta, visionOS 27.0+ Beta, watchOS 27.0+ Betaand tagged Beta on the index. Swift module name isEvaluations(import Evaluations)." —/documentation/evaluationsindex. WWDC26 session 299 states the same thing out loud and omits tvOS: "This framework is new in Xcode 27 and supports macOS, iOS, watchOS and visionOS." (299:2). The doc index adds iPadOS and Mac Catalyst; neither source lists tvOS. Do not claim tvOS support.✅ VERIFIED — Swift only. An Apple Frameworks Engineer, answering forum thread 833729 (accepted): "Evaluations is a Swift-based framework. So you would need to call the Swift APIs from the other language. For that, you can look at our documentation on language interoperability." If you work in Python, Apple's guidance is the Python Foundation Models SDK plus your own scoring code — covered in
../../part-05-prototyping-profiling-non-swift/references/02-fm-cli-and-python-sdk.md.✅ SDK-verified — Evaluations ships in Xcode's platform developer frameworks. Its location is
<Xcode>/Contents/Developer/Platforms/<Platform>.platform/Developer/Library/Frameworks/Evaluations.framework, alongside testing frameworks rather than under the OS SDK. Test targets resolve it by default; non-test tools need that framework search path. The captured interface importsTesting, requires OS 27, and marks tvOS unavailable. Seenotes/sdk-interfaces/capture-manifest.jsonfor the current stable capture; interface evidence establishes declarations, not runtime behavior.
What this covers¶
Build an evaluation dataset, map inputs to ModelSubject, combine evaluators and metrics, aggregate results, and run the checks through Swift Testing. Use repeatable comparisons to improve a stochastic feature.
What you need¶
- Xcode 27 and a 27 SDK. Nothing here compiles against Xcode 26. (The framework itself ships
inside Xcode, like XCTest — see the distribution box above — so "a 27 SDK" gates the OS symbols
you evaluate, not the
Evaluationsmodule.) - A feature worth measuring, and a written list of what "good" means for it. If you cannot write that list, §1 is where to start, not §4.
- Familiarity with
@Generable,@Guide,LanguageModelSessionandTool. See../../part-02-foundation-models-everyday-api/references/02-guided-generation-and-streaming.mdand../../part-02-foundation-models-everyday-api/references/03-tools-and-tool-calling.md. - Swift Testing (
import Testing,@Suite,@Test,#expect). Evaluations runs inside Swift Testing; it is not an alternative to it. - Apple's Book Tracker sample. Almost every ✅ in this guide is quoted from it. It is at
/documentation/evaluations/book-tracker-using-evaluations-to-evaluate-an-intelligent-feature. Download it before you read §4.
Contents¶
- The contract that generative features break
- Evaluations is not an LLM framework
- The five steps, and the four components
- Step 1 —
subject(from:), the code under measurement - Step 2 — the dataset:
ModelSampleand the loaders - Step 3 — evaluators and metrics
- Step 4 —
aggregateMetrics(using:) - Step 5 — the test that runs it
- The whole thing, in one file
- The quantitative / qualitative rule of thumb
- The Xcode 27 Evaluations report
- The attachment, and the meta-evaluation it unlocks
- Hill climbing: the loop, and why it needs science
- Control, experimental, one variable, backport
- Hill-climbing something that is not a prompt
- Everything you can turn
- ⚠️ The silent failures
- Why this framework exists: there is no model version pinning
- Quick reference
- Sources, and where they disagree
1. The contract that generative features break¶
Every unit test you have ever written rests on one assumption, and it is so basic that you have
probably never articulated it: the same input produces the same output. That assumption is what
lets #expect(sut.slug(for: "Hello World") == "hello-world") mean anything. It is also what lets a
green test on your machine imply a green result on a customer's device.
A language model does not honour it.
✅ VERIFIED — WWDC26 session 298, Meet the Evaluations framework, opening argument, verbatim (
298:7-14):"Building app features with generative AI poses new testing challenges, because the same input can produce different outputs. These models break a contract that is fundamental to software testing."
"Consider traditional software, where a particular input always produces a particular output. You can easily verify this behavior with a unit test. You're guaranteed the same input will produce the same output on any device, including your customers'."
"With intelligent software, you cannot rely on functional consistency to verify behavior. Which means that unit tests are insufficient. Unverified behavior can erode customer confidence."
Two things in that passage deserve unpacking, because the word "insufficient" is doing a lot of work.
First: it is not just sampling noise. Apple's prompt-evaluation article names two independent sources of variation, and the second one is the dangerous one:
✅ VERIFIED —
/documentation/foundationmodels/evaluating-prompts-to-measure-performance-and-improve-model-responses: "The response you get from a model can vary even though you provide the same exact input. This variation comes from the probabilistic nature of how the model generates text, and from updates to the underlying model that you don't control."
Temperature-zero decoding would flatten the first source. Nothing you can write in Swift flattens the second. §18 is about that.
Second: "insufficient" does not mean "useless". A unit test still catches your BookTags decoder
crashing on an empty array, still catches a tool that throws on a nil argument, still catches the
plumbing. What it cannot do is express "the tags are good." The moment your assertion has to be about
the content of a model's output, hardcoding a string is not a weak test — it is a test that will fail
for reasons unrelated to quality, on a schedule you do not control, and whose failures teach you
nothing.
The framework's answer is to move the assertion up one level of abstraction. You stop asserting "this input produces this output" and start asserting "across this dataset, this measurable property holds often enough." The threshold is yours, and it is a design decision:
✅ VERIFIED (
298:95-96): "I expect the service to produce the correct number of tags 80% of the time. Why 80%? If the service performance dips below 80%, I want to know and a failing test is great signal."And, crucially (
298:115-116): "Remember back in our test definition? This is where we defined our optimization target. We're saying the feature behaves as expected, if the correct number of tags were generated, 80% of the time."
That reframing is the whole idea. #expect(rate >= 0.8) is simultaneously a regression alarm and a
statement of intent — the number you are trying to move up. A test suite becomes a scoreboard.
The three questions the framework is designed to answer¶
✅ VERIFIED (
298:19), verbatim: "We need to know: how often does my app produce unexpected results? How often does the agent take an unexpected path to generate answers? And under what circumstances does the feature produce unsafe results?"
Note that the middle question is not about output at all — it is about trajectory, and it is the
explicit reason TrajectoryExpectation and ToolCallEvaluator exist. A model can hand you a
plausible-looking answer having never called the tool that would make the answer true. Tool-call
evaluation is covered in its own Part 6 guide; this one gets you to the point where adding it is a
three-line change.
Where evaluations actually start: a #Playground¶
Before any of this, Apple's own demo does the least scalable thing possible, on purpose. Session 298
opens by dropping a #Playground macro into BookTaggingService.swift and reading the output:
✅ VERIFIED (
298:36-51): "Let's add a#Playgroundmacro toBookTaggingService.swift." … "9 tags is more than I was expecting." … "I don't want the book's name as a tag, either." … "Multi-word tags are gonna be a problem in the UI, so we should avoid those as well." … "Okay, we've just completed our first evaluation of the service. We created a list of expectations and used our human judgement to measure how the service performed."
That is the definition of an evaluation, arrived at backwards:
✅ VERIFIED (
298:52): "Every evaluation measures how well an intelligent feature performs against our expectations."
And the pivot (298:53-55): "Unfortunately human judgement doesn't scale. But we've created a way to
automate and scale evaluations. All you have to do is add import Evaluations, and implement the
Evaluation protocol."
The #Playground block is not a throwaway. It survives into the shipping sample:
✅ VERIFIED — Book Tracker keeps
import Playgroundsand a#Playground { … }block inBookTracker/Services/BookTaggingService.swift:76-101, exercising two hand-written reviews, alongside a full evaluation suite in two separate test bundles. The fast inner loop and the slow outer loop coexist. Use the playground while you are typing; use the evaluation before you commit.
The five expectations that came out of that manual session became the spec for everything after
(298:42-49, 298:173, 298:183):
| # | Expectation | Measurable how? |
|---|---|---|
| 1 | Generate the correct number of tags (settled at 3–8) | code |
| 2 | Do not emit the book's title as a tag | code |
| 3 | No multi-word tags — they break the UI | code |
| 4 | Tags should identify a literary genre | code, against a known list |
| 5 | Tags should be informative, relevant, and helpful for browsing | not code — a model judge |
Four of five are heuristics. One is not. §10 is the rule that tells you which is which, and it is worth memorising.
2. Evaluations is not an LLM framework¶
This is the most commonly missed fact about the framework, and it is stated outright in the first two minutes of the introductory session:
✅ VERIFIED (
298:20-22), verbatim: "The Evaluations framework is a flexible system of provided types and protocols." … "This video will focus on evaluating intelligent features powered by language models. But you can evaluate any stochastic system, such as classifiers and linear regression models."
Structurally that claim holds up, because the coupling to Foundation Models is thinner than it looks.
subject(from:) calls your code and returns your value. Nothing in the protocol requires that a
model was involved:
✅ VERIFIED — the
Evaluationprotocol's own requirements (/documentation/evaluations/evaluation):protocol Evaluation : Sendable associatedtype Sample // "The type of input samples in the evaluation dataset." associatedtype SampleLoader // "The type of the sample loader used to provide the evaluation dataset." var dataset: Self.SampleLoader { get } associatedtype Subject // "The type of the subject produced by the system under test." func subject(from sample: Self.Sample) async throws -> Self.Subject var name: String { get } // "The default name, derived from the type name." var evaluators: Self.Evaluators { get } typealias Evaluators // "Shorthand for the evaluator array type, resolved per-conformance." func aggregateMetrics(using aggregator: inout MetricsAggregator)✅ SDK-verified nuance: in the shipped interface
nameis not a protocol requirement at all — it is a computed property in an extension (Evaluations-27.0-macos.swiftinterface:482-487), so a conformance never has to supply it; and theevaluatorsrequirement carries the builder attribute directly —@EvaluatorsBuilder var evaluators(:469-479).
Sample, Subject and SampleLoader are all associated types. ModelSample and ModelSubject are
the language-model-shaped conformances of SampleProtocol / EvaluationSubject, not the protocol
itself. If you have an image classifier whose top-1 label wobbles between runs, or a recommender whose
ranking depends on a random seed, the same harness applies: a dataset of inputs, a subject that runs
your model, evaluators that score it, an aggregate, a threshold.
🔴 GAP — non-text evaluation is unproven and Apple has not answered the question. A developer asked on the Apple forums, verbatim: "Are evaluations just for Text-text, or is there an efficient way to evaluate image-text, like for MobileClip2, or YOLOE?" (thread 833822). It received zero replies. Separately, the
ModelSampledocs say multimodal prompts require "a customModelSampleProtocolconformance or … theinit(input:expected:expectations:)initializer with a prebuiltModelSampleInput" — so a hook exists, but no sample, doc example or session demonstrates a non-text evaluation end to end.What would resolve it: a compiling
SampleProtocolconformance over a non-text input type, or an Apple answer on 833822. Safe default meanwhile: for a non-LLM system, drive it through the generic protocol requirements — your ownSampletype, your ownSubjecttype, plainEvaluatorclosures — and stay away fromModelJudgeEvaluator,ToolCallEvaluatorandSampleGenerator, all three of which are constrained toModelSampleProtocol(constraints ✅ SDK-verified —Evaluations-27.0-macos.swiftinterface:166,311,859).
The practical consequence of taking Apple at their word here: an Evaluations suite is a reasonable
place to put your Core ML or MLX regression tests too. You get the same report UI, the same
run-to-run comparison, and the same .evaluates plumbing. See Part 14 for the bridge material.
3. The five steps, and the four components¶
Apple describes the same construction two different ways in two different sessions. Both are correct; they differ only in whether "run it" counts as a step.
✅ VERIFIED — session 298, verbatim (
298:58-63): "There are five steps to building and running an evaluation. You define what code you're measuring. Then, define what data you're sending the code. Next, define what measurements you're making and how. Then, summarize your measurements. And then, finally, create a test to run your evaluation."✅ VERIFIED — session 335, verbatim (
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."
And the documentation gives a third phrasing, which is the one to keep in your head because it names the purpose of each part:
✅ VERIFIED —
/documentation/evaluations/evaluating-language-model-responses, verbatim: "- Provide input as a dataset of samples with expected outputs. - Define the subject, the intelligence-powered feature you are testing. - Add evaluators that score each response against metrics you define. - Aggregate those scores into a metric summary you compare across runs."
That last clause — compare across runs — is the tell. The aggregate is not decoration. It is the number that hill climbing moves.
Mapped onto real API:
| Step | Question | API | Where it lives |
|---|---|---|---|
| 1 | What code am I measuring? | func subject(from:) async throws -> ModelSubject<T> |
a method on your Evaluation |
| 2 | What data am I sending it? | var dataset: some Loader — ArrayLoader / JSONLoader of ModelSample<T> |
a stored property |
| 3 | What am I measuring, and how? | let m = Metric("…") + var evaluators: Evaluators |
properties on your Evaluation |
| 4 | How do I summarise it? | func aggregateMetrics(using aggregator: inout MetricsAggregator) |
a method |
| 5 | How do I run it? | @Test(.evaluates(evaluation)) + EvaluationContext.current.result |
a Swift Testing suite |
All of steps 1–4 are requirements of one protocol, on one type. That is deliberate:
✅ VERIFIED —
/documentation/evaluations/designing-effective-evaluations: "In Evaluations, theEvaluationprotocol captures these three attributes directly: it bundles the feature under test, the test dataset, the evaluators, and the result aggregation into a single, runnable definition." And, two lines later: "Treat evaluations as your living specification."
The shape, before the details¶
Here is the smallest complete conformance Apple publishes. Read it once now; §§4–8 take it apart.
✅ VERIFIED — verbatim from
/documentation/evaluations/evaluation:struct MyEvaluation: Evaluation { let metric = Metric("Match") let dataset = ArrayLoader(samples: [ ModelSample(prompt: "One plus one is...", expected: "Two.") ]) func subject(from sample: ModelSample<String>) async throws -> ModelSubject<String> { ModelSubject(value: "Two.") } var evaluators: Evaluators { Evaluator { sample, subject in let metric = Metric("Match") guard let expected = sample.expected else { return metric.ignore() } return subject.value == expected ? metric.passing() : metric.failing() } } func aggregateMetrics(using aggregator: inout MetricsAggregator) { aggregator.computeMean(of: metric) } }
Four things to notice, all of which contradict something that has been written about this framework:
subject(from:)returnsModelSubject<T>, not the raw value. This type was missing from every pre-sample reconstruction in circulation, including our own. It is not optional and it is not sugar —ToolCallEvaluatorcannot work without its second initialiser (§6).datasetis aLoader, not an array.ArrayLoader(samples:)wraps one.evaluatorsisEvaluators— a result-builder type name — and the closure takes two arguments,(sample, subject), in that order.aggregateMetricstakes the aggregatorinout, and the type isMetricsAggregator(plural "Metrics").
⚠️ Note the shadowing in Apple's own snippet:
let metric = Metric("Match")is declared as a stored property and again inside the closure. Do not copy that habit — see §17.4 for why it is a hazard and what the safe form is.
4. Step 1 — subject(from:), the code under measurement¶
This is where you call your feature. Apple's phrasing (298:64-65): "we add the call to the
BookTaggingService, and return it's output inside of the subject(from:) method. These generated
tags are the subject of our evaluation."
✅ VERIFIED — Book Tracker,
BookTrackerEvaluations/BookTags.swift:17-30, verbatim:import Evaluations import Foundation import FoundationModels import Testing @testable import BookTracker struct BookTaggingEvaluation: Evaluation { func subject(from sample: ModelSample<BookTags>) async throws -> ModelSubject<BookTags> { let result = try await BookTaggingService.generateTags(for: sample.promptDescription) return ModelSubject(value: result) }
Five load-bearing details in those six lines.
@testable import BookTracker. The evaluation lives in a test bundle and calls the app's real
service type. It is not a copy of your prompt; it is your app's code path. If you reimplement
generateTags inside the test target, you are evaluating the reimplementation.
ModelSample<BookTags> is generic over the expected type, not the input. This trips everyone up
once. The BookTags in the angle brackets is the shape of the answer, and the prompt is always text.
✅ VERIFIED —
ModelSample's declaration (/documentation/evaluations/modelsample):struct ModelSample<ExpectedValue> where ExpectedValue : Decodable, Encodable, Sendable // Conforms: ModelSampleProtocol, SampleProtocol, Codable, Sendable init(prompt:expected:instructions:generationSchema:expectations:) // String-based init(prompt:expected:instructions:generationSchema:expectations:) // FoundationModels `Prompt` overload init(input:expected:expectations:) // prebuilt ModelSampleInput var prompt, promptDescription, instructions, instructionsDescription, input var expected, output var expectations // "The expected pattern of tool calls for this sample." var generationSchema // "The output schema for the model's response."
Two ways to read the input back out. sample.promptDescription is a String;
sample.prompt is a FoundationModels Prompt. Book Tracker uses both, in different evaluations —
promptDescription where the service signature takes a String (BookTags.swift:19) and prompt
where it goes straight into session.respond(to:) (SearchBooks.swift:551). Pick whichever matches
the function you are calling; do not stringify a Prompt by hand.
ModelSubject wraps the output.
✅ VERIFIED —
/documentation/evaluations/modelsubject:protocol EvaluationSubject // associatedtype Value; var value: Self.Value struct ModelSubject // "The subject type for language model evaluations." init(value: Value, transcript: StructuredTranscript?) var value: Value var transcript: StructuredTranscript? var toolCalls: [Transcript.ToolCall]Book Tracker uses both spellings:
ModelSubject(value:)for the tagging evaluation (BookTags.swift:20) andModelSubject(value:transcript:)for the tool-calling one (SearchBooks.swift:562-566).
The transcript is opt-in, and it is what tool-call evaluation runs on. If you ever intend to add
ToolCallEvaluator, pass it now. Because the accessor is declared by an Evaluations extension—not by
FoundationModels—the source file must import both modules:1
✅ VERIFIED — the canonical
subject(from:)from/documentation/evaluations/evaluating-language-model-responses, verbatim including comments:func subject(from sample: ModelSample<Int>) async throws -> ModelSubject<Int> { // Create the language model session; you can customize this with instructions and you can // choose the model you want to use. let session = LanguageModelSession() // Create the model response the same way you do in your app. let response = try await session.respond(to: sample.prompt, generating: Int.self) // Return the model's response along with the transcript. return ModelSubject( value: response.content, transcript: session.transcript.structuredTranscript ) }
Transcript.structuredTranscriptis an Evaluations extension onFoundationModels.Transcript(27.0+), returningEvaluations.StructuredTranscript. It is not a FoundationModels member, and it is unavailable at the use site unless that source file imports Evaluations.
Note the comment Apple wrote into their own sample: "Create the model response the same way you do in your app." That is not filler. It is the single most consequential rule in this section, and Book Tracker enforces it in a way that is easy to miss:
⚠️ SILENT FAILURE — a mismatched model configuration evaluates a different system, and nothing tells you. Book Tracker constructs its model with a non-default guardrail setting in the app service and repeats it verbatim in the evaluation:
// BookTracker/Services/BookTaggingService.swift:40 let session = LanguageModelSession( model: SystemLanguageModel(guardrails: .permissiveContentTransformations), instructions: instructions )// BookTrackerEvaluations/SearchBooks.swift:553-555 let model = SystemLanguageModel( guardrails: .permissiveContentTransformations )✅ Both call sites verified in the sample archive. Drop
guardrails:from one of them and everything still compiles, still runs, and still produces a number — a number about a model you do not ship. Guardrail behaviour,useCase:,GenerationOptions, the instructions string, and whichLanguageModelyou pass are all part of the system under test.The defence is structural, not vigilant. Put session construction in exactly one place that both the app and the evaluation call. Book Tracker does this by making
generateTagsastatic funcon the service and calling that fromsubject(from:); the only reasonSearchBooks.swiftrebuilds a session by hand is that it needs the transcript back, which the service does not return.
Subjects that do no inference at all¶
subject(from:) is not obliged to call a model. Session 335 uses that fact to build a meta-evaluation
of its own judge, and the sample implements it:
✅ VERIFIED —
HillClimbingEvaluations/ModelJudgeAlignmentEvaluation.swift:166-169:func subject(from sample: ModelSample<BookTagJudgmentValue>) async throws -> ModelSubject<BookTagJudgmentValue> { let value = sample.expected ?? .placeholder return ModelSubject(value: value) }Session 335 narrates the motive (
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."
This is the frozen-output pattern, and it is worth naming because it generalises. Replaying a fixed
dataset through subject(from:) removes the feature's nondeterminism entirely, so the only thing
varying between runs is whatever you are actually studying — in 335's case, the judge. Use it whenever
you want to isolate one stochastic component from another. §12 walks the full workflow.
5. Step 2 — the dataset: ModelSample and the loaders¶
✅ VERIFIED — Book Tracker,
BookTags.swift:24-30, verbatim including the doc comment:
Three corrections live in those five lines, and each one has been got wrong in reconstructions of this API:
dataset is a stored property, not computed. var dataset = ArrayLoader(…), no braces, no return.
Writing var dataset: [ModelSample<BookTags>] { … } will not satisfy the protocol.
It is a Loader, not an array. The protocol requirement is var dataset: Self.SampleLoader.
✅ VERIFIED — the loader surface (
/documentation/evaluations):protocol Loader struct ArrayLoader<Sample> where Sample : SampleProtocol // init(samples:) struct JSONLoader<Sample> where Sample : SampleProtocol // init(url:) struct StreamLoaderBook Tracker uses
ArrayLoaderfor its 13 hand-curated books (BookTags.swift:25) andJSONLoaderfor the 100 synthetic ones — declared with an explicit type,var dataset: JSONLoader<ModelSample<BookTags>>(SyntheticBookTags.swift:25), reading back a file theBookSampleGeneratorCLI wrote.
ModelSample(prompt:expected:) is the two-argument form you will use most. The expected value is
your reference answer, and it is optional in the sense that evaluators must handle its absence:
✅ VERIFIED —
/documentation/evaluations/generating-synthetic-evaluation-datasets: "For evaluations that score output without a reference answer, such as model-as-judge assessments of tone or fluency, omit the expected value and generate prompt-only samples." EveryEvaluatorin Apple's doc examples opens withguard let expected = sample.expected else { return metric.ignore() }for exactly this reason.
That expected value carries more weight than "the right answer", though. Session 298 makes an
argument for using it as a style channel:
✅ VERIFIED (
298:142): "If you want to teach the feature how to write tags like you, start by including more of your personal style in the expected values of the samples."
It is also what a model judge compares against — ModelJudgePrompt's reference closure reads
input.expected and hands it to the judge as labelled context (BookTags.swift:118-121).
The JSONLoader file format, and its one sharp edge¶
✅ VERIFIED —
/documentation/evaluations/jsonloader, verbatim: "- If the first non-whitespace character is[, the file is treated as a JSON array ([{...}, {...}]) and decoded in one pass. - Otherwise, the file is treated as JSONL (JSON Lines), where each non-empty line is decoded as an individual sample. Malformed entries are logged viaOSLogand skipped. A failure to open the file propagates as a thrown error."
Read that last sentence twice. It is §17.1.
ModelSample is Codable, which is what makes the round trip work: Book Tracker's
BookSampleGenerator CLI JSON-encodes [ModelSample<BookTags>] straight to
synthetic_book_samples.json (BookSampleGenerator/main.swift:82-86), and JSONLoader(url:) reads it
back with no bespoke serialisation on either side. ✅ verified in the archive.
How big should the dataset be?¶
The sessions give two numbers that look contradictory and are not.
✅ VERIFIED (
298:134): "Good evaluations have thousands of samples to extract trends, but also to exercise your feature in many different ways."✅ VERIFIED (
298:272-274): "Start small. A focused dataset of 20 to 30 samples is a great place to get started. Spec out your app by thinking about how you want the model to behave."
The reconciliation is the workflow, not a compromise: hand-write 20–30, then scale with
SampleGenerator. Book Tracker is literally this — 13 curated books in ArrayLoader, expanded to 100
by a CLI, loaded back through JSONLoader as a second evaluation
(SyntheticBookTags.swift) that runs alongside the first rather than replacing it. Keeping both is the
right call: the curated set is deterministic and cheap and you know every row; the synthetic set is
where coverage lives.
And the reason coverage matters more than count:
✅ VERIFIED (
299:40): "What matters far more than quantity is coverage! So instead of asking how many samples do I need? Ask yourself, have I covered the meaningful variety of ways this feature will actually be used?"✅ VERIFIED (
299:22-23): "These 13 samples might feel like a reasonable starting point, but this small dataset only give us a narrow window into how our feature performs. Our evaluation results could look great and still be completely misleading."
That prediction was borne out on camera. Running the same evaluation against 100 samples instead of 13
made the quality scores drop (299:100-102), and the diagnosis was: "Our tag generation feature
looked like it was performing well earlier because we weren't testing it with a comprehensive
dataset." A small dataset does not give you a noisy estimate of quality — it gives you a biased one,
because the samples you thought of yourself are the samples your prompt already handles.
The dimensions of variety Apple names explicitly (298:135-149) are worth copying as a checklist:
| Dimension | What it means for your feature |
|---|---|
| Subject-matter variety | "We want the service to recognize different genres." |
| Length variety | "We can't assume every user will give it a verbose review." |
| Category variety | The axes real users browse by — fiction vs non-fiction, etc. |
| Form variety | "novels, short stories, and essays" |
| Adversarial content | "Let's makes it hard on the model too. Sprinkle in personal opinions, so we can measure how well the service ignores those." |
| Style-carrying expected values | see above — teach the feature your voice through expected |
The concrete personas the session wrote by hand are a good template because each targets one axis
(298:144-149): The Secret Garden reviewed "as though we were an avid gardener"; Treasure Island as
"a personal review from a mother reading it to her son. Lots of personal opinions in this review";
Romance of the Three Kingdoms from "a board game enthusiast [who] needed multiple paragraphs"; and a
Sherlock Holmes review from "a casual reader [who] described a famous British detective's sidekick in a
single sentence." Long, short, opinionated, off-centre. Four samples, four failure modes.
Dataset design — golden sets, user profiles, challenge cases — and SampleGenerator get their own
guide in this part. What you need here is: start at 20–30 hand-written rows in an ArrayLoader, make
them different from each other, and do not go near synthesis until the curated set is telling you
something.
6. Step 3 — evaluators and metrics¶
This is the part every reconstruction in circulation got wrong, so it is worth being pedantic.
Metric is both the identifier and the result carrier¶
✅ VERIFIED —
/documentation/evaluations/metric:struct Metric // Copyable, CustomStringConvertible, Equatable, Sendable init(_ name: String) func passing(rationale:) -> Metric func failing(rationale:) -> Metric func scoring(_:rationale:) -> Metric func ignore(rationale:) -> Metric // "excluded from aggregation" var name: String // "used as the DataFrame column name" var value: Metric.Value var doubleValue var rationale: String? enum Metric.ValueAnd the design note, verbatim: "The factory methods (
passing,failing,scoring,ignore) return a newMetricwith the result stored inside."
So Metric is not an enum of verdicts and it is not a bag of numbers. let tagCount = Metric("Tag
Count") declared as a stored property is the identifier — the thing you aggregate by and the
DataFrame column name. tagCount.passing(rationale: "6 tags") is a result value you return from an
evaluator. Same type, two roles.
The five factories, all observed in shipping Apple code:
| Factory | Meaning | Seen at |
|---|---|---|
.passing() |
boolean success | BookTags.swift:88 |
.passing(rationale:) |
success + a string that shows up in the report | BookTags.swift:38 |
.failing() |
boolean failure | doc example |
.failing(rationale:) |
failure + explanation | BookTags.swift:40 |
.scoring(_ value: Double) |
a number, not a verdict | BookTags.swift:46 |
.ignore() / .ignore(rationale:) |
excluded from aggregation for this sample | doc example |
.ignore() deserves a moment. It is how you say "this sample cannot be scored on this metric" without
poisoning the average with a false failure — most commonly when sample.expected is nil. Do not reach
for .failing() there; a missing reference answer is not a defect in the feature.
Evaluator takes a two-argument closure, and the metric is not a parameter¶
✅ VERIFIED — Book Tracker,
BookTags.swift:35-104, verbatim:let tagCount = Metric("Tag Count") let tagTotal = Metric("Tag Total") let hasGenreTag = Metric("Has Genre Tag") let wordCount = Metric("Word Count") var evaluators: Evaluators { // Tag count is within the required 3–8 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") } // Records raw tag count. Evaluator { _, subject in let count = subject.value.tags.count return tagTotal.scoring(Double(count)) } // Tags must be single-word or hyphenated. Evaluator { _, subject in for tag in subject.value.tags where tag.contains(" ") { return wordCount.failing(rationale: "Tag \(tag) contains multiple words") } return wordCount.passing() } … }
Compare that against what was circulating before the sample shipped —
var evaluators: some Evaluator { Evaluator(tagCount) { output, sample in … .pass } } — and count the
errors: the property type, the metric-as-init-argument, the closure argument order, and the result
spelling. Four out of four wrong. If you have code or documentation that looks like that, it was
reconstructed from spoken narration and it does not compile.
The corrected shape:
var evaluators: Evaluators— plural, and it is atypealiason the protocol, resolved per conformance. It is fed by a result builder.Evaluator { input, subject in … }— two arguments. First is theModelSample; second is theModelSubject. All four heuristics in Book Tracker discard the first as_, which is exactly why the order is easy to get backwards: you rarely see both used.- The metric is captured, not passed. The closure returns
someMetric.passing(); nothing hands the metric toEvaluator's initialiser. subject.valueis your typed output —BookTags, in this case, sosubject.value.tagsis a[String].
✅ VERIFIED — the underlying protocol and the concrete struct (
/documentation/evaluations/evaluatorprotocol,/documentation/evaluations/evaluator):protocol EvaluatorProtocol<Input, Subject> : Sendable associatedtype Input associatedtype Subject func metrics(subject: Self.Subject, input: Self.Input) async throws -> [Metric] struct Evaluator<Input> where Input : SampleProtocol, Input.ExpectedValue : Decodable, Input.ExpectedValue : Encodable, Input.ExpectedValue : SendableNote that
metrics(subject:input:)returns[Metric]— plural. That is how one evaluator can emit several metrics from one pass, which is whatModelJudgeEvaluatordoes with multipleScoreDimensions.✅ SDK-verified — both match the interface, and the interface also pins the closure type the docs never printed:
Evaluator.init(_ evaluate: (Input, ModelSubject<Input.ExpectedValue>) async throws -> Metric)— sample first, subject second, returning oneMetric(Evaluations-27.0-macos.swiftinterface:295-303; the protocol at:669-675).
If a closure is not enough — you need stored state, or you want to emit several metrics — conform directly:
✅ VERIFIED — verbatim from
/documentation/evaluations/evaluatorprotocol:
The result builder, and the if/else you cannot write¶
✅ VERIFIED —
/documentation/evaluations/evaluatorsbuilder:@resultBuilder EvaluatorsBuilder static func buildBlock(any EvaluatorProtocol<Sample, Subject>...) -> [any EvaluatorProtocol<Sample, Subject>] static func buildExpression(any EvaluatorProtocol<Sample, Subject>) -> any EvaluatorProtocol<Sample, Subject> static func buildOptional([any EvaluatorProtocol<Sample, Subject>]?) -> [any EvaluatorProtocol<Sample, Subject>]✅ SDK-verified — still no
buildEither, so the branching rule stands (Evaluations-27.0-macos.swiftinterface:678-685, checked 2026-08-23): the members arebuildExpression,buildOptionaland four pairwisebuildPartialBlockoverloads — beta 5 replaced the earlier capture's single variadicbuildBlock(a source-compatible builder-protocol swap; checked 2026-07-29 the list wasbuildExpression/buildBlock/buildOptional). Under Swift's result-builder rules a bareifstill works and anif/elsestill does not — we still have not compiled the negative case, but the member list is no longer an inference from a documentation page; it is the shipped interface. Safe default stands: write two bareifs with complementary conditions rather than anif/else, or hoist the branch outside the builder and build the evaluator list in a helper.
Evaluators are per-sample; aggregation is per-run¶
✅ VERIFIED (
298:77-78), verbatim: "Evaluators run over a single sample at a time. But we can measure trends and look for patterns measured over all of our samples in theaggregateMetrics(using:)method."
That boundary is not negotiable and it determines what you can express. Anything that needs to see the whole corpus — a distribution, a variance, an agreement statistic against a second rater — belongs in step 4, not here. §7 and §12 are both consequences of this rule.
Model judges are evaluators, and that is the important architectural fact¶
✅ VERIFIED (
298:222-224), verbatim: "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."
Which is why Book Tracker's evaluators block ends with a ModelJudgeEvaluator(judge:dimensions:prompt:)
sitting directly underneath four Evaluator { _, subject in … } closures, in the same result builder,
scored in the same run, landing in the same report. Apple's docs show the same mixing pattern:
✅ 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: """...""") ) }
The judge's design — scales, dimensions, prompts, drift, calibration — is a separate guide. The thing to take from here is only that adding one is additive: you do not restructure anything.
7. Step 4 — aggregateMetrics(using:)¶
Per-sample metrics are the raw data. The aggregate is the number you assert on, chart, and compare across runs — so this method is where an evaluation stops being a log and becomes a measurement.
✅ VERIFIED — Book Tracker,
BookTags.swift:129-142, verbatim: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) } }✅ VERIFIED — the full aggregator surface (
/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(_:_:) // "Creates a group of related metrics." struct MetricsAggregator.Group
Four things worth stating outright.
The parameter is inout. func aggregateMetrics(using aggregator: inout MetricsAggregator). Get
this wrong and you get a protocol-conformance error that does not obviously point at the inout.
The type is MetricsAggregator — plural "Metrics". Not MetricAggregator. This one is a
transcription hazard: nobody can hear the s.
computeMean over a pass/fail metric gives you a pass rate. This is the mechanism behind every
>= 0.8 threshold in this guide. passing() and failing() reduce to 1 and 0, and the mean of that
across the dataset is "how often did it pass." You do not compute a percentage yourself.
.group(_:) nests a sub-aggregator and gives the report its section headings. Note that Book
Tracker shadows the outer name inside the first closure ({ aggregator in }) and renames it in the
second ({ group in }) — both compile; the second reads better.
Pairing a pass/fail metric with a scored one¶
Look again at what Book Tracker does with tag counts. There are two metrics over the same quantity:
tagCount— pass/fail, "is the count within 3–8?"tagTotal—.scoring(Double(count)), "what is the count?"
and tagTotal is aggregated three ways: mean, standard deviation, variance. That is not
belt-and-braces. It is the fix for a failure the session hit live:
✅ VERIFIED (
298:127-130), verbatim: "All right, I made the change and I re-ran the evaluation. My test passed, and my TagCount passes a 100% of the time. But I notice a potentially strange behavior: after my change, the service always generates eight tags. Hmmm."And after expanding the dataset (
298:158-159): "my TagCount average is still 100%, and the service generated eight tags for all of them. Now we know there's a weird behavior in the service."⚠️ SILENT FAILURE — a green pass-rate over a range metric hides a degenerate distribution. A
(3...8).contains(n)metric reads 100% whether the model produces a healthy spread of 3, 5, 4, 7, 6 or the same value every single time. Adding@Guide(.count(3...8))fixed the range and quietly collapsed the distribution to a constant 8 — arguably a worse feature, reported as a perfect score. Nothing threw, nothing warned, and the test went green.The fix is a second metric, not a smarter first one:
let tagCount = Metric("Tag Count") // pass/fail: is it in range? let tagTotal = Metric("Tag Total") // score: what is it?aggregator.computeMean(of: tagCount) // range compliance aggregator.computeMean(of: tagTotal) // central tendency aggregator.computeStandardDeviation(of: tagTotal) // ← the one that catches it aggregator.computeVariance(of: tagTotal)A standard deviation of 0 over a metric that should vary is the signal. Session 298's own summary of the fix (
298:163-166): "First, I define a new Metric, 'TagTotal', that will record the number of generated tags… Then, we record a measurement using a scoring value, instead of a pass/fail value. Using the 'TagTotal' and 'TagCount' metrics we evaluate range compliance and the distribution of generated tags."Generalise it: any time you write a metric of the form "is X within bounds", write a second one that records X, and aggregate its spread. Three of Book Tracker's six heuristic aggregations exist to serve this one idea.
Custom aggregation: statistics the framework does not ship¶
custom(of:label:_:) hands you the raw per-sample scores for one metric and takes back a single
Double. Book Tracker uses it for the only genuinely statistical thing in the archive:
✅ VERIFIED —
HillClimbingEvaluations/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 } } … }
Two facts about that block matter more than the statistics.
The closure receives [Double] in dataset order. That ordering guarantee is the only reason the
positional join against expertRelevance is valid — row i of the judge's scores lines up with row
i of the human's. If the framework reordered rows, this pattern would be silently wrong. It does not,
and Apple's sample depends on it.
Cohen's kappa is hand-rolled.
✅ VERIFIED — the framework ships no agreement statistic.
Statistics.cohensKappais 72 lines of ordinary Swift inHillClimbingEvaluations/Statistics.swift, written by the sample. There is no κ in theMetricsAggregatormember list, no κ in the framework's symbol inventory, and session 335 says so obliquely (335:127): "we need to calculate Cohen's kappa, which I can do that with a custom aggregation method." If you want judge-alignment measurement, you are writing the statistic yourself. Everyone who has read the session assumes otherwise; it is not there.
custom(of:label:_:) is a general escape hatch, not a κ-specific one. Anything you can compute from a
metric's per-sample scores — a percentile, a trimmed mean, a Gini coefficient, a count of samples below
a floor — goes here, and comes back out of the result by the same label (§8).
8. Step 5 — the test that runs it¶
Evaluations does not have a runner. Swift Testing is the runner.
✅ SDK-verified — with one footnote: the library can drive itself. The interface exposes
Evaluation.run(info: [String : String] = [:]) async throws -> EvaluationResult(Evaluations-27.0-macos.swiftinterface:490-494) — presumably what the.evaluatestrait calls internally, and the hook a command-line harness would use to run an evaluation outside a test. No Apple sample or doc article calls it; running through the trait is what gets you the Xcode report and the attachment (§11–§12). Treatrun(info:)as the escape hatch, not the norm.✅ Probe-verified, 2026-07-31 — the escape hatch works, offline, under XCTest. The probe suite's five Evaluations probes (
probes/, run on the 27.0 sim runtime) all driveEvaluation.run(info:)programmatically from XCTest cases — no Swift Testing trait, no Xcode report, no model (canned subjects) — and get realEvaluationResults back. Every measured Evaluations finding in this guide (§8.2, §8.4, §17.4, §17.5, §17.7) came through this path, which is itself the existence proof that the runner has no hidden dependency on the trait, the report UI, or a live language model.✅ VERIFIED (
298:83): "Evaluations integrates with Swift Testing, so you can run your evaluations in your app's test targets."✅ VERIFIED — Book Tracker,
BookTags.swift:149-167, verbatim including the doc comment:@Suite("Book Tag Evaluations") struct BookTagEvaluationTests { static let evaluation = BookTaggingEvaluation() /// Metadata recorded alongside each run. static let evaluationInfo: [String: String] = [ "Prompt": BookTaggingService.instructions, "ModelName": "SystemLanguageModel", "AppVersion": "1.0", "Feature": "Automatic tag generation from book reviews" ] @Test("Book Tag Evaluations", .evaluates(evaluation, info: evaluationInfo)) func evaluateBookTagging() async throws { let result = EvaluationContext.current.result let rangeMetric = BookTagEvaluationTests.evaluation.tagCount #expect(result.aggregateValue(.mean(of: rangeMetric)) >= 0.8) } }
That is nineteen lines and there are five non-obvious things in it.
8.1 The trait is .evaluates, and the second label is info:¶
✅ VERIFIED —
/documentation/evaluations/evaluationtrait:struct EvaluationTrait // conforms to Testing.TestScoping, Testing.TestTrait, Testing.Trait // "A test trait that runs an evaluation and records the result as attachments."Both spellings appear in the sample:
.evaluates(evaluation)bare (SearchBooks.swift:572,ModelJudgeAlignmentEvaluation.swift:344) and.evaluates(evaluation, info: evaluationInfo)(BookTags.swift:161).✅ SDK-verified — one declaration, not two:
static func evaluates(_ evaluation: any Evaluation, info: [String : String] = [:], recordTranscripts: Bool = false)(Evaluations-27.0-macos.swiftinterface:418-420; beta 5 added the defaultedrecordTranscripts:, checked 2026-08-23); the bare form is the defaultedinfo:.
⚠️ The parameter is info:, taking [String: String]. Session 298 describes it as "a notes
dictionary" in narration (298:88-89) and every reconstruction spelled it notes:. It is info:.
The sample and the documentation agree; the transcript is a paraphrase, not an API.
Note also the trait's own doc sentence: "records the result as attachments." That is §12.
8.2 The evaluation must be a static let on the suite¶
Not stylistic. The test body needs to reach the same Metric values the evaluation used, and it does
that by reading them off the evaluation instance: BookTagEvaluationTests.evaluation.tagCount. A fresh
BookTaggingEvaluation() constructed inside the test body would be a different instance.
✅ Probe-verified, 2026-07-31 —
Metricidentity is BY NAME. (was a 🔴 GAP;probes/eval.metric-identity, run offline on the 27.0 sim runtime viaEvaluation.run(info:).) Two evaluators each constructing a freshMetric("Match")instance produce one detailed column and one"Mean of Match"summary column — andaggregateValue(.mean(of: Metric("Match")))through yet another fresh instance works, returning 0.5. That 0.5 is the second half of the finding: same-named metrics from different evaluators POOL into one aggregate (both evaluators' values averaged together). Apple's minimal example constructingMetric("Match")inside the evaluator closure was the correct reading; Book Tracker's hold-the-instance structure is a style choice, not a requirement.The safe default barely changes, and the reason sharpens: declare each metric exactly once as a stored property and reference it everywhere — no longer because instance identity might matter (it does not), but because name-keyed pooling means a typo'd or duplicated name silently forks or merges columns (§17.4). Fresh instances with the same name are safe; same names for different measurements are the hazard.
8.3 The dataset runs before the test body, and the body never iterates¶
This is the single most counter-intuitive part of the integration, and it is what makes the code look so short.
✅ VERIFIED —
EvaluationContext(/documentation/evaluations/evaluationcontext):
The trait runs the entire evaluation — every sample, every evaluator, the aggregation — and then runs
your test function with the finished EvaluationResult sitting in EvaluationContext.current. Your
body is an assertion over aggregates. There is no for sample in … anywhere in the suite.
⚠️ A consequence for the shape of the function: the result does not arrive as a parameter. A test
signature like func f(results: EvaluationResults) async throws — which is what several
reconstructions of this API show — does not exist. The signature is a plain
func f() async throws, and the type is EvaluationResult, singular.
8.4 Reading values back out: aggregateValue(_:)¶
✅ VERIFIED — historical documentation excerpt, July 2026 —
EvaluationResult(/documentation/evaluations/evaluationresult):struct EvaluationResult // Sendable var summary: DataFrame // "Aggregated statistics for each metric in the evaluation." var detailed: DataFrame // "Individual results for each sample in the evaluation." let evaluationInfo: [String : String] // "such as the model name, prompt version, or dataset" let evaluationID: String let resultID: UUID var reportMetadata: [String : any Sendable] func aggregateValue(_ op: AggregationOperation) -> Double var startTime, endTime, duration var groupedSummary // "A formatted description of summary metrics organized by groups." func jsonRepresentableDataFrame(of:) func saveJSON(to:includeReportMetadata:) func jsonData(includeReportMetadata:jsonOptions:) static func loadJSON(from:) static func loadJSONLines(from:) // "Loads an array of evaluation results from a JSONL file on disk." init(jsonData:) enum EvaluationResult.DataFrameKind struct ResultColumn✅ SDK-verified — refreshed 2026-10-06. The retained documentation list above omits the final SDK's
let errors: EvaluationRunErrors(:564); see §17.7. The current result and serialization APIs are atEvaluations-27.0-macos.swiftinterface:548-629. The beta-5 serialization change is retained:saveJSONandjsonDataeach gained a defaultedincludeTranscripts: Bool = falseparameter. The interface also fixes two details the docs left loose:saveJSON(to:)'s parameter is labelledto directory:— it takes a directory, writes a file into it, and returns the file's URL (@discardableResult) — andjsonData's options default to[.prettyPrinted, .sortedKeys](:600-629).
aggregateValue takes an AggregationOperation and returns a Double. Two forms are attested in
shipping code:
// Built-in statistic, keyed by the Metric you aggregated:
#expect(result.aggregateValue(.mean(of: rangeMetric)) >= 0.8) // BookTags.swift:165
// Custom statistic, keyed by the label you gave `custom(of:label:)`:
#expect(result.aggregateValue(.custom(label: "Relevance Alignment Score")) > 0.6)
// ModelJudgeAlignmentEvaluation.swift:348
✅ both verified in the sample. Note the symmetry: whatever you asked for in aggregateMetrics is what
you can read here, addressed the same way you registered it.
✅ Probe-verified, 2026-07-31 — what
aggregateValuereturns when there is nothing to read:-1.0, always. (probes/eval.mean-over-all-ignoredand the empty-aggregateMetricsrun, 27.0 sim runtime.) An evaluation whoseaggregateMetrics(using:)registers nothing yields an emptysummaryDataFrame, andaggregateValuereturns-1.0for every metric you ask about — the same-1.0you get for a mean over all-ignored samples (§17.5).-1.0is the framework's universal "no value" sentinel: it never throws, never traps, and never tells you why there was no value. Assert row counts (§17.1) alongside everyaggregateValueassertion.
summary and detailed are TabularData DataFrames, which is a much bigger door than
aggregateValue alone. If you want to inspect rather than assert:
✅ VERIFIED — verbatim from
/documentation/evaluations/evaluationresult:@Test(.evaluates(Self.evaluation)) func inspectDetailedResults() async throws { let result = EvaluationContext.current.result // Read typed columns out of the per-sample DataFrame. let inputs = result.detailed[Self.evaluation.inputColumn] let expected = result.detailed[Self.evaluation.expectedColumn] let scores = result.detailed[metric: Self.evaluation.exactMatch] // Surface the prompts where the model's count disagreed with the expected count. for row in 0..<scores.count where scores[row]?.value == .failing { let prompt = inputs[row]?.promptDescription ?? "<missing>" let target = expected[row].map(String.init) ?? "?" print("Missed (expected \(target)): \(prompt)") } #expect(scores.count == 5) }Note the two subscript forms:
detailed[someResultColumn]for the typed input/expected/response columns declared on theEvaluation(inputColumn,responseColumn,expectedColumn), anddetailed[metric: someMetric]for a metric's per-sample results.
That is your escape hatch for anything the report UI will not show you, and — with
saveJSON(to:) / loadJSONLines(from:) — for keeping a run history in CI outside Xcode.
8.5 info: is what makes runs diffable¶
The info dictionary is free-form [String: String] and it lands on the result as
evaluationInfo. What Book Tracker puts in it is the interesting part:
✅ verified, BookTags.swift:151. The entire instructions string is stamped into the run record.
Not a version number, not a hash — the text. So when you open two runs side by side three weeks later,
the report can tell you not just that the score moved but what the prompt said on each side. That is
the difference between a comparison and a diff, and it costs one line.
Copy the whole dictionary shape; every key earns its place:
| Key | Why |
|---|---|
"Prompt" |
the actual instructions text — the thing you are most likely to be changing |
"ModelName" |
which backend produced these numbers |
"AppVersion" |
ties a run to a build |
"Feature" |
human-readable, so a stranger reading the report knows what they are looking at |
Add "Dataset" if you have more than one, and "OSBuild" if you care about §18 — which you should.
8.6 Serialised suites¶
✅ VERIFIED —
@Suite("…", .serialized)is used for the judge-calibration suite in Book Tracker (ModelJudgeAlignmentEvaluation.swift:337), while the plain tagging suite is not serialised (BookTags.swift:149).
Swift Testing runs tests in parallel by default. Two evaluations racing each other for the on-device model is not obviously wrong, but it is obviously slower and it makes timing noise. The sample's split suggests the rule: serialise suites where several evaluations contend for the same model, leave a single-evaluation suite alone. The OS also limits concurrent Foundation Models requests independently of anything you do (Apple, forum thread 833642), so parallelism buys less than you would hope.
9. The whole thing, in one file¶
Below is a complete, copyable evaluation of a tag-generation feature. Every API spelling in it is ✅
verified against Book Tracker or Apple's documentation, cited in the preceding sections; the glue
between them — the knownGenres set, the title-tag check, the specific rationale strings — is ordinary
Swift and is mine. Where a fragment is quoted verbatim from the sample it is noted in a comment.
The feature under test first. This is the app's real code, in the app target:
// BookTracker/Services/BookTaggingService.swift
import Foundation
import FoundationModels
@Generable
struct BookTags: Codable, Equatable {
// ✅ verbatim, BookTaggingService.swift:14-15 — note the two-argument @Guide form:
// a description AND a variadic guide, with .count taking a ClosedRange.
@Guide(description: "Descriptive tags capturing themes, genres, moods, and topics from the review",
.count(3...8))
var tags: [String]
}
struct BookTaggingService {
/// Kept as a `static let` so the evaluation can stamp it into the run record verbatim.
static let instructions = """
You are a librarian and literary analyst. Generate tags for a book from a reader's review.
Rules:
- Return between 3 and 8 tags.
- Every tag must be a single word, or two words joined by a hyphen.
- At least one tag must name a literary genre.
- Describe the book, not the reader's opinion of it.
- Never use the book's title or the author's name as a tag.
"""
/// The known genre vocabulary the UI browses by. The evaluation reads this too,
/// so the metric and the feature can never drift apart.
static let knownGenres: Set<String> = [
"romance", "gothic", "horror", "mystery", "adventure", "historical-fiction",
"science-fiction", "fantasy", "biography", "essay", "satire", "epic"
]
// ✅ the session construction is verbatim in shape from BookTaggingService.swift:38-45.
// `guardrails:` is NOT optional decoration — see §4.
static func generateTags(for review: String) async throws -> BookTags {
let session = LanguageModelSession(
model: SystemLanguageModel(guardrails: .permissiveContentTransformations),
instructions: instructions
)
let response = try await session.respond(
to: Prompt("Generate tags for this review:\n\n\(review)"),
generating: BookTags.self
)
return response.content
}
}
Now the evaluation, in the test bundle:
// BookTrackerEvaluations/BookTags.swift
import Evaluations
import Foundation
import FoundationModels
import Testing
@testable import BookTracker
struct BookTaggingEvaluation: Evaluation {
// ── Step 1: what code am I measuring? ────────────────────────────────────
// ✅ verbatim, BookTags.swift:18-21
func subject(from sample: ModelSample<BookTags>) async throws -> ModelSubject<BookTags> {
let result = try await BookTaggingService.generateTags(for: sample.promptDescription)
return ModelSubject(value: result)
}
// ── Step 2: what data am I sending it? ───────────────────────────────────
// ✅ verbatim, BookTags.swift:24-30. A STORED property, wrapping an ArrayLoader.
/// Pairs each curated review with the maintainer's reference tags.
var dataset = ArrayLoader(samples:
Book.sampleBooks.map { book in
ModelSample(prompt: book.review, expected: BookTags(tags: book.tags))
}
)
// ── Step 3: what am I measuring, and how? ────────────────────────────────
// ✅ metric names verbatim, BookTags.swift:35-38
let tagCount = Metric("Tag Count") // pass/fail: is the count in 3...8?
let tagTotal = Metric("Tag Total") // score: what IS the count?
let wordCount = Metric("Word Count") // pass/fail: single-word or hyphenated?
let hasGenreTag = Metric("Has Genre Tag") // pass/fail: at least one known genre?
let referenceOverlap = Metric("Reference Overlap") // pass/fail: agrees with reference tags
var evaluators: Evaluators {
// Tag count is within the required 3–8 range. ✅ verbatim, BookTags.swift:60-66
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")
}
// Records raw tag count. ✅ verbatim, BookTags.swift:69-72
// Scored, not pass/fail — this is the one that catches a collapsed distribution.
Evaluator { _, subject in
let count = subject.value.tags.count
return tagTotal.scoring(Double(count))
}
// Tags must be single-word or hyphenated. ✅ verbatim, BookTags.swift:75-80
Evaluator { _, subject in
for tag in subject.value.tags where tag.contains(" ") {
return wordCount.failing(rationale: "Tag \(tag) contains multiple words")
}
return wordCount.passing()
}
// At least one tag names a known literary genre.
// Concept attested at 298:169-171; this implementation is ordinary Swift.
Evaluator { _, subject in
let genres = subject.value.tags.filter {
BookTaggingService.knownGenres.contains($0.lowercased())
}
return genres.isEmpty
? hasGenreTag.failing(rationale: "No genre among: \(subject.value.tags.joined(separator: ", "))")
: hasGenreTag.passing(rationale: "Genre tags: \(genres.joined(separator: ", "))")
}
// At least one generated tag agrees with the maintainer's reference tags.
// This is the one evaluator that needs the FIRST closure argument —
// everything above discards it as `_`.
Evaluator { input, subject in
// No reference answer means nothing to compare against: exclude the sample
// from this metric rather than failing it.
guard let expected = input.expected else { return referenceOverlap.ignore() }
let reference = Set(expected.tags.map { $0.lowercased() })
let hits = subject.value.tags.filter { reference.contains($0.lowercased()) }
return hits.isEmpty
? referenceOverlap.failing(rationale: "No overlap with reference tags")
: referenceOverlap.passing(rationale: "Matched: \(hits.joined(separator: ", "))")
}
}
// ── Step 4: how do I summarise it? ───────────────────────────────────────
// ✅ shape and method names verbatim, BookTags.swift:129-142. Note `inout`.
func aggregateMetrics(using aggregator: inout MetricsAggregator) {
aggregator.group("Heuristics") { group in
group.computeMean(of: tagCount) // range-compliance RATE
group.computeMean(of: wordCount)
group.computeMean(of: hasGenreTag)
group.computeMean(of: referenceOverlap)
}
aggregator.group("Distribution") { group in
group.computeMean(of: tagTotal) // average number of tags
group.computeStandardDeviation(of: tagTotal) // ← 0 here means degenerate output
group.computeVariance(of: tagTotal)
}
}
}
And the suite that runs it:
// BookTrackerEvaluations/BookTagTests.swift
import Evaluations
import Testing
@testable import BookTracker
@Suite("Book Tag Evaluations")
struct BookTagEvaluationTests {
// Must be `static let` — the test body reaches through it for Metric identity.
static let evaluation = BookTaggingEvaluation()
/// Metadata recorded alongside each run. ✅ verbatim, BookTags.swift:151-156
static let evaluationInfo: [String: String] = [
"Prompt": BookTaggingService.instructions,
"ModelName": "SystemLanguageModel",
"AppVersion": "1.0",
"Feature": "Automatic tag generation from book reviews"
]
@Test("Book Tag Evaluations", .evaluates(evaluation, info: evaluationInfo))
func evaluateBookTagging() async throws {
// The trait has already run every sample through every evaluator and aggregated
// the results. This body never iterates the dataset.
let result = EvaluationContext.current.result
let evaluation = BookTagEvaluationTests.evaluation
// The optimization target: correct tag count at least 80% of the time.
#expect(result.aggregateValue(.mean(of: evaluation.tagCount)) >= 0.8)
// Single-word tags are a hard UI constraint, so hold this one higher.
#expect(result.aggregateValue(.mean(of: evaluation.wordCount)) >= 0.95)
// Every book should get at least one genre tag.
#expect(result.aggregateValue(.mean(of: evaluation.hasGenreTag)) >= 0.9)
// A standard deviation of zero means the model is emitting a constant count.
// Passing range compliance with a collapsed distribution is a regression, not a win.
#expect(result.aggregateValue(.standardDeviation(of: evaluation.tagTotal)) > 0)
}
}
✅ SDK-verified —
.standardDeviation(of:)is a real case, and the case list is now closed.AggregationOperation's cases aremean(of:),median(of:),mode(of:),minimum(of:),maximum(of:),standardDeviation(of:),variance(of:)— each taking aMetric— pluscustom(label:)(Evaluations-27.0-macos.swiftinterface:431-444, checked 2026-07-29). The enum mirrors thecompute…registration methods one-for-one, so anything you can register inaggregateMetrics(using:)you can read back throughaggregateValue(_:)with the matching case. Only.mean(of:)and.custom(label:)appear in shipping Apple code; the rest are now verified spellings rather than inferences.
Run it with ⌘U. What you get is not a green checkmark; it is a report. §11.
10. The quantitative / qualitative rule of thumb¶
Five expectations went into §1's table. Four of them became Evaluator closures in §9. The fifth —
"tags should be informative, relevant to the book, and helpful for browsing your library" — did not,
and no amount of cleverness will turn it into one.
Apple states the dividing line as a rule, and it is worth memorising verbatim because it settles an argument you will otherwise have with yourself on every metric:
✅ VERIFIED (
298:275-278), verbatim: "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."
Applied to the five expectations:
| Expectation | Measurable in code? | Mechanism |
|---|---|---|
| 3–8 tags | yes — .count |
Evaluator + Metric |
| No title as a tag | yes — string comparison | Evaluator + Metric |
| No multi-word tags | yes — contains(" ") |
Evaluator + Metric |
| Identifies a genre | yes — set membership against a known list | Evaluator + Metric |
| Informative, relevant, helpful for browsing | no | ModelJudgeEvaluator |
The test for "measurable in code" is not "could I write some code for this". It is whether the code you would write is the thing you actually mean. You could write a regex for "informative". It would not measure informativeness; it would measure whether the tag matched your regex, and you would spend a month tuning the regex instead of the feature.
The failure this rule is protecting you from is documented, on camera, and it is the reason model judges exist at all. Session 298 ran the heuristics against a review of Alice in Wonderland:
✅ VERIFIED (
298:191-198), verbatim: "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."
Four green metrics and a bad feature. That is not a bug in the metrics — each one measured exactly what it claimed to. It is a gap in coverage of the spec, and it is only visible because someone read the output.
Two practical corollaries:
Heuristics first, always. They are deterministic, they cost nothing, they run in milliseconds, and they will catch the majority of your early regressions. Apple's ladder starts here for a reason. A model judge that you add before you have a working heuristic layer is a judge whose disagreements you cannot interpret.
A green board is not a finished loop. Session 335 opens on exactly this state — every expectation met, output still not good enough:
✅ VERIFIED (
335:52): "In this case, my Evaluation met all of my expectations; however, because I know the tags aren't as good as I'd like them to be, I need to investigate further."
When your tests pass and your output is bad, the defect is in your measurements, not your feature. Either you are missing a metric, or the metric you have is asking the wrong question, or — if it is a judge — your rubric says something other than what you meant. Sessions 298 and 335 spend most of their runtime on that third case; it gets the model-judge guide in this part.
11. The Xcode 27 Evaluations report¶
✅ VERIFIED (
299:97): "In Xcode 27, we introduced a new Evaluations Report to visualize your results."
Running an evaluation produces a test result like any other, but the interesting output is not in the test navigator. It is a separate report:
✅ VERIFIED —
/documentation/evaluations/evaluating-language-model-responses, verbatim: "When the run finishes, open the Report navigator and select the Evaluations item beneath the test run to open the evaluation report."Session 298 narrates the same path (
298:103-105): "Click on the report navigator, and then select Evaluations in the test report." … "Here's the evaluation report for the test suite. Let's double click the row to find out more."
The layout¶
✅ VERIFIED (
335:56-58), verbatim: "This brings up the Evaluation detail view. On the top are our aggregate metric charts. And below is our table of results."
So: charts of your aggregate metrics on top, a per-sample results table underneath. Those charts
are exactly what you registered in aggregateMetrics(using:) — which is the concrete reason the
grouping in §7 matters. aggregator.group("Heuristics") and aggregator.group("Distribution") are not
bookkeeping; they are the sections of the chart area.
Session 298's summary of what that produced for Book Tracker (298:175-176): "We track our three
expectations using five aggregate metrics. Here, we can see the distribution of tags, along
with range compliance and containing genre tags." Three expectations, five charts — because
tag count contributes both a compliance rate and a distribution.
The results table, and the assistant editor¶
✅ VERIFIED (
298:106-111), verbatim: "my TagCount metric only passed 50% of the time." … "a quick look at the full results table shows me that my 'Pride & Prejudice' sample produced a failure. But my 'Dracula' sample produced the correct number of tags." … "I can select each row in the table to see more details, using the assistant editor in Xcode." … "The detail panel shows the prompt, and each measurement for the ModelSample. At the bottom, you see the entire response from the model."
Three things per row, then:
- The prompt — the sample's input, as sent.
- Each measurement — every metric that fired for that sample, with its rationale string. This is
what those
rationale:arguments in §6 buy you.tagCount.failing(rationale: "Got 9 tags, expected 3–8")is the difference between a red cell and a diagnosis. - The full model response — at the bottom, in full, not truncated.
For a model judge, the rationale is not garnish; it is the payload:
✅ VERIFIED (
298:230-231): "With model judges, rationales are essential. They give you a window into why the judge scored what it scored." And from/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."
Session 335's whole analysis phase is done in this panel (335:143): "To do that, I need to open
the assistant and view the results in detail." If you are hill climbing and you are not reading
individual rows, you are guessing.
Compare: the button the loop is built on¶
✅ VERIFIED —
/documentation/evaluations/evaluating-language-model-responses: "For a side-by-side view, choose Compare and select a run for each side."✅ VERIFIED (
299:100-101): "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!"✅ VERIFIED (
335:156): "Fortunately, in Xcode 27, we've made so you can compare the results of two evaluations against each other." And (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."
Two distinct uses appear in the sessions and they are worth separating:
- Run-to-run — same evaluation, two points in time. "Did my change help?"
- Evaluation-to-evaluation — two
Evaluationinstances in one suite, one run. "Which of these two configurations is better?" This is the control/experimental pattern (§14) and it is why session 335 runs both evaluations in the same suite: "With both prompts written, I can add both evaluations to a test suite, which will run both evaluations." (335:167).
The comparison is only as informative as the info: you stamped on each run. A side-by-side of two
numbers tells you which is bigger. A side-by-side of two numbers plus the two prompt texts tells you
why — and lets you find the run again in three weeks. §8.5.
Session 335 reads the comparison view at the row level, not just the summary:
✅ VERIFIED (
335:180-183): "One thing that jumped out to me immediately is the discrepancy between usefulness scores of this review of 'Picture of Dorian Gray'… I noticed that all the scores are either a 3 or 2, which is way too harsh."
That diagnosis — a clustered score distribution meaning a badly calibrated rubric, not a bad feature — is only visible if you look at the spread of individual rows. Aggregates hide it.
🔴 GAP — no screenshots, no keyboard shortcuts, no export path from the report UI. Everything above is reconstructed from spoken narration plus two doc sentences. We cannot tell you what the Compare picker looks like, whether it can diff more than two runs, whether it can pin a run as a permanent baseline, whether the charts are exportable, or whether the report is reachable from
xcodebuildoutput in CI. What would resolve it: Xcode 27 on a Mac and twenty minutes. Safe default meanwhile: do not build a workflow that depends on a UI affordance nobody has confirmed.EvaluationResult.saveJSON(to:)andloadJSONLines(from:)(§8.4) are ✅ documented API, and a run history you write yourself as JSONL will outlive any report-navigator behaviour.
12. The attachment, and the meta-evaluation it unlocks¶
This is the least-known part of the framework and the one that makes the hardest thing in Part 6 — calibrating a model judge against a human — actually possible. Teach it, because reading the session without noticing it leaves you stuck.
Every run writes its data out¶
✅ VERIFIED —
EvaluationTrait's own documentation, verbatim: "A test trait that runs an evaluation and records the result as attachments."✅ VERIFIED (
335:115-118), verbatim: "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."
So: you do not have to instrument anything to capture what your feature produced. Running the evaluation already captured it. Every prompt, every response, every measurement, for every sample.
The on-disk format¶
Book Tracker ships a second command-line target whose entire job is to parse that artefact, which is how we know its shape at all:
✅ VERIFIED —
DatasetExtractor/main.swift, a 167-line CLI target in the Book Tracker archive that parses Xcode's.xcevalresultbundle. The structure (main.swift:15-32,:94-131):where the escaped
Inputstring decodes to{ "input": { "prompt": "…" } }. Default output is~/Desktop/<BaseName>-extracted.json(:153-162). The tool depends onArgumentParser.This format is documented nowhere else — not in a session, not in a doc article. The sample is the only source.
Note the closing comment the sample's author left, which is a genuinely useful Swift fact and not
about evaluations at all (DatasetExtractor/main.swift:165-167):
// @main cannot be used in main.swift — Swift's implicit top-level entry point and
// @main are mutually exclusive. Calling .main() explicitly is the equivalent.
DatasetExtractorCommand.main()
The round trip¶
Put those pieces together and you get the workflow that session 335's judge calibration is built on:
1. Run BookTaggingEvaluation in Xcode (⌘U)
↓ Xcode writes an attachment
2. Export the .xcevalresult bundle
↓
3. swift run DatasetExtractor <bundle>
↓ ~/Desktop/BookTaggingEvaluation-extracted.json
4. A human expert scores every row ← the only manual step
↓ adds expertRelevanceScore / expertUsefulnessScore columns
5. Ship the scored file back into the test bundle
↓ BookTaggingEvaluation-extracted.json
6. ModelJudgeAlignmentEvaluation reads it as its dataset
↓ subject(from:) does NO inference — it replays the frozen output
↓ the SAME ModelJudgeEvaluator scores each row
7. aggregateMetrics computes Cohen's kappa between judge and human
↓
8. #expect(result.aggregateValue(.custom(label: "…")) > 0.6)
✅ VERIFIED — the bundled fixture
HillClimbingEvaluations/BookTaggingEvaluation-extracted.jsonis literally the output of that pipeline plus the expert columns, andModelJudgeAlignmentEvaluation.swiftreads it as its dataset. Step 6's no-inference subject is quoted in §4; step 7's custom aggregation is quoted in §7; step 8's assertion is quoted in §8.4.
Three properties of this loop are what make it work, and each is a technique on its own:
The judge and the human score identical rows. Not "the same prompts" — the same outputs.
✅ VERIFIED (
335:112-113): "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."
If you re-ran the feature to generate fresh output for the judge, you would be measuring the judge and the feature's nondeterminism at once, and you could not attribute a disagreement to either.
The subject does no inference, so the meta-evaluation is fast, free, and repeatable. You can hill climb the judge's prompt a dozen times in an afternoon without burning a single feature inference.
The evaluator is the same ModelJudgeEvaluator instance-shape as production.
✅ VERIFIED (
335:123-124): "my evaluator is the exact same model judge evaluator as in our book tags evaluation. This is where the judge provides its rating."
⚠️ With one wrinkle the sample makes visible and the session does not mention: the two
ScoreDimension definitions are deliberately re-worded between BookTags.swift:43-56 and
ModelJudgeAlignmentEvaluation.swift:175-189 (✅ both in the archive). The calibration copy encodes the
human's generosity — "small drift … is acceptable". Tuning the dimension text is itself part of the
hill climb, so "the same evaluator" means the same type and role, not a byte-identical prompt.
The mechanics of κ, the 0.6 threshold, drift, and the six labelled worked examples in Book Tracker's 67-line judge prompt belong to the model-judge guide in this part. What belongs here is the structural move: an evaluation's output is data, and data can be the input to another evaluation. Once you see that, "evaluate your evaluators" stops sounding recursive and starts sounding obvious.
✅ VERIFIED (
335:255-256): "Finally, watch out for drift. It can feel a bit meta to evaluate your evaluators but a well tuned model evaluator will save you time in the long run."
Doing it without the CLI¶
You do not have to reproduce DatasetExtractor to get the round trip. EvaluationResult can write
itself out from inside the test body:
@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)
// Keep a durable, diffable record independent of Xcode's report UI.
// `saveJSON(to:)` takes a DIRECTORY; the framework names the file and returns its URL.
let runsDirectory = URL.documentsDirectory.appending(path: "runs")
let written = try result.saveJSON(to: runsDirectory, includeReportMetadata: true)
print("run saved to \(written.path())")
}
✅ SDK-verified signature, 🟡 unverified usage. 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, not a file path, which is why the snippet above no longer builds a filename fromevaluationID/resultIDby hand. The same block pins the rest of the round trip:static loadJSON(from:),init(jsonData:), an asyncstatic loadJSONLines(from:), and — easy to miss because it hangs offCollection—[EvaluationResult].saveJSONLines(to:includeReportMetadata:)for appending a run history as JSONL (:598-610). No sample calls any of them, and whethersaveJSON's output matches the.xcevalresultshapeDatasetExtractorparses is still unknown — the.xcevalresultroute remains the one with a compiling reference implementation.
Either way, the durable-record habit is what turns a test suite into a time series, and a time series is what §18 needs.
13. Hill climbing: the loop, and why it needs science¶
Everything so far builds one measurement. Hill climbing is what you do with it.
✅ VERIFIED (
335:8), verbatim: "The Evaluations framework also allows you to hill climb, which is a process of iteratively improving the quality of your feature using the scores of your evaluation as a guide."
The loop has three named phases (335:9-12), verbatim:
- Develop — "making some change you want to measure against your existing feature."
- Evaluate — "Once all your changes are made, you then need to run the evaluation. And see if the results have passed your expectations."
- Analyze — "From there, you analyze the results to understand how your feature could be further improved."
Session 298 gives the same loop a name once you organise your work around it:
✅ VERIFIED (
298:181), verbatim: "When you take our hill-climbing feedback loop, and center your development process around it, we call it evaluation-driven development."
And the term "hill climbing" is introduced at the exact moment someone forms a hypothesis and tests it
(298:123-126): "This is an interesting theory. Let's make that change. Then re-run the evaluation to
see if I'm right. We call this process hill-climbing."
Two prerequisites, stated up front by the session and worth honouring:
✅ VERIFIED (
335:17-18): "this video is about the process of hill-climbing an existing evaluation. That means you've already written the foundations of an evaluation pipeline, which provides a wholistic understanding of the strengths and weaknesses of your intelligence-powered feature."
You cannot climb a hill you cannot measure. If §§4–9 are not done, iterating on your prompt is not hill climbing; it is the same vibes-based tuning you were doing before, with more ceremony.
The second prerequisite is the one people skip:
✅ VERIFIED (
335:13-14), verbatim: "Leveraging the hill-climbing process is a great way to systematically improve your feature, but effective hill-climbing takes a little bit more than just following the loop. It also takes a little bit of… Science!"
One full turn of the loop, as it actually happened¶
Session 298's first iteration is short enough to reproduce end to end, and it is instructive because it succeeded and created a new problem:
| Phase | What happened | Evidence |
|---|---|---|
| Measure | TagCount passes 50% of the time |
298:106 |
| Analyse | The failing sample generated 9 tags; the passing one 7 | 298:107-108 |
| Hypothesise | "I have a hunch… I could specify a count property in that @Guide, which can take a range." |
298:119-122 |
| Develop | Add .count(3...8) to the @Guide on BookTags.tags |
✅ shipped in BookTaggingService.swift:14-15 |
| Evaluate | "My test passed, and my TagCount passes a 100% of the time." | 298:127-128 |
| Analyse | "But I notice a potentially strange behavior: after my change, the service always generates eight tags." | 298:129-130 |
| Next turn | Add a scored TagTotal metric and aggregate its spread |
298:163-166 |
That is the loop working correctly. The change was right, the metric went green, and the analysis step caught what the metric could not see. Skipping analysis because the board is green is how you ship a feature that emits eight tags forever.
Note the belt-and-braces that fell out of it, which Book Tracker keeps in the shipping code: the 3–8
range is asserted three times — in the @Guide, in the instructions prose, and in a heuristic
Evaluator. ✅ all three verified in the archive. The implicit lesson is the sample's, not ours: a
@Guide is a hint, not a guarantee, so measure it anyway. (More on the limits of guides in
../../part-02-foundation-models-everyday-api/references/02-guided-generation-and-streaming.md.)
The instructions become a changelog¶
✅ VERIFIED (
298:177-180): "Using our hill-climbing methodology, we've iterated on our instructions for the service. Here's where we started at the beginning. After several updates to our evaluation and multiple runs through our loop. And we can track each change to our instructions, by an expectation we added to our evaluation to verify that change."
Read that last clause carefully, because it is a discipline you can adopt today and it costs nothing: every line you add to your instructions should have an expectation that verifies it. A rule in the prompt with no metric behind it is an assertion you have never tested. Book Tracker's instructions and its five heuristics line up nearly one-to-one, which is not an accident.
The reverse is also true and is the reason info: ["Prompt": …] exists: when a score moves, you want
to see the prompt text on both sides of the comparison, not a version number you have to go look up.
14. Control, experimental, one variable, backport¶
The "science" in §13 is not a metaphor. Session 335 lays out the experimental design explicitly.
✅ VERIFIED (
335:158-160), verbatim: "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, that means two Evaluation instances in one suite:
✅ VERIFIED (
335:167): "With both prompts written, I can add both evaluations to a test suite, which will run both evaluations." And (335:230): "So all I have to do is define two instances of my evaluation. One without the tool and one with it."
Which is why §15's evaluation takes its variable as an initialiser parameter rather than hardcoding it. Parameterise the thing you are varying; instantiate it twice.
@Suite("Tag Quality — prompt experiment")
struct TagPromptComparison {
static let control = BookTaggingEvaluation(instructions: Prompts.baseline)
static let experimental = BookTaggingEvaluation(instructions: Prompts.candidate)
@Test("control", .evaluates(control, info: ["Prompt": Prompts.baseline]))
func baseline() async throws { … }
@Test("experimental", .evaluates(experimental, info: ["Prompt": Prompts.candidate]))
func candidate() async throws { … }
}
🟡 RECONSTRUCTED — the two-instance-in-one-suite pattern is ✅ attested twice in session 335 and every symbol above (
.evaluates(_:info:),static let) is ✅ verified, but no sample file shows two evaluations in a single suite with aninfo:on each. Book Tracker's control/experimental pair lives across two test bundles rather than one suite. The shape is right; treat the exact layout as provisional.
Rule 1 — one variable at a time¶
✅ VERIFIED (
335:242-245), verbatim: "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. Knowing how each part works individually will also help you to know where you might need to make changes to resolve a bug or unwanted pattern later down the line."
The second half of that quote is the underrated part. One-variable-at-a-time is not only about attribution today; it is about being able to localise a regression six months from now. If you changed four things and the score went up, you have learned that four things together are better than nothing, which is almost no information.
Rule 2 — backport the winner into the baseline before the next experiment¶
This is the procedural detail that makes rule 1 actually hold, and it is the single most skippable step in the whole method:
✅ VERIFIED (
335:186-187), verbatim: "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."
Think about what happens if you skip it. Round 1: baseline B vs experimental B+x. The experimental wins, so you keep x. Round 2: you add y to the experimental — but the baseline is still B. You are now comparing B against B+x+y, and a win tells you nothing about y. Two rounds in, your "control" is a historical artefact and every subsequent comparison is measuring the accumulated diff.
So the cycle is four steps, not three:
1. run control vs experimental (one variable apart)
2. decide: keep the change, or discard it
3. IF KEPT: copy the change INTO the control ← the step everyone skips
4. make the next change in the experimental (again one variable apart)
Note that step 2 is a judgement, not a threshold. Session 335's first iteration produced a genuinely mixed result and was kept anyway:
✅ 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."
A failing test did not end the loop; it directed the next iteration. That is the correct relationship between the suite and your judgement — the numbers tell you where to look, and you decide what to do.
Rule 3 — failed experiments are results¶
✅ VERIFIED (
335:246-248), verbatim: "Second, 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."
A change that moved nothing tells you the model was never attending to that part of the prompt. A change that made things worse tells you which direction the gradient points. Both are information you did not have this morning, and both are cheap because the evaluation is automated. The thing to avoid is the un-run experiment — the change you reasoned about, convinced yourself of, and shipped.
Rule 4 — three iterations, and what each one taught¶
Session 335's three prompt iterations are a good template because each targets a different kind of deficiency:
| # | Change | Result | Lesson |
|---|---|---|---|
| 1 | "a more thorough description about how to judge… context about the app… examples of good tags. As well as ways to identify bad tags" (335:163-166) |
relevance ↑, usefulness ↓ (335:170-171) |
Context helps unevenly. Trade-offs are normal; pick which score to chase next. |
| 2 | "being more specific about how to grade each scoring dimension" — relevance "emphasizes the need for a genre tag", usefulness "emphasizes being more critical of overly specific tags" (335:184-191) |
"the scores both improved greatly over the baseline" (335:193-194) |
Sharpen the rubric before you sharpen the prompt. Vague criteria produce clustered scores. |
| 3 | few-shot: "examples of the way I judge things, which should give it a pattern for how to judge according to my scale" (335:204-208) |
"finally my scores are over my expected value! Which means I've finally passed and can exit out of the loop!" (335:212) |
Examples beat description once the description is already specific. |
And the warning attached to iteration 3, which applies to every few-shot prompt you will ever write:
⚠️ ✅ VERIFIED (
335:210), verbatim: "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."
Overfitting to your own eval is the failure mode that a passing suite cannot detect, because the suite is the thing you overfit to. The defences are the ordinary ones: keep the example count small, keep some samples out of the prompt entirely, and grow the dataset (§5) faster than you grow the prompt.
15. Hill-climbing something that is not a prompt¶
Every worked hill-climb up to this point has changed a string. Session 335 closes by changing the feature's architecture instead, and it is the more interesting example because of what it forced on the API design.
✅ VERIFIED (
335:214): "So far we've seen how to hill climb on prompts… now I'd like to show you how to improve your feature through something other than your prompts."
The hypothesis:
✅ VERIFIED (
335:217-221), verbatim: "What I want to do is give the model some more context about the book it's generating tags for. I think the additional context will help the model generate more relevant and useful tags. Better still, Book Tracker already has the data needed for this because we store the author's name and book title when they write their review. So, to help the tag generator, I've created a tool to get additional information on the book, which provides the book title and author if they are available. Adding this tool is a form of hill-climbing because we are attempting to improve the quality of our feature through an incremental change."
Note what the tool actually does: it returns data the app already has. This is not retrieval from the internet; it is handing the model context it was previously being denied. That is often the highest -value tool you can add, and it is invisible if you only ever tune prompts.
The API-design move¶
Here is the part worth stealing, and it is one line:
✅ VERIFIED (
335:224-225), verbatim: "BookTaggingServicenow takes a list of tools as input. I also set the default to an empty array so my existing evaluation won't need any changes."
struct BookTaggingService {
static func generateTags(
for review: String,
tools: [any Tool] = [] // ← the whole trick
) async throws -> BookTags {
let session = LanguageModelSession(
model: SystemLanguageModel(guardrails: .permissiveContentTransformations),
tools: tools,
instructions: instructions
)
let response = try await session.respond(
to: Prompt("Generate tags for this review:\n\n\(review)"),
generating: BookTags.self
)
return response.content
}
}
🟡 RECONSTRUCTED — the
tools:parameter with an= []default and its motive are ✅ quoted verbatim from335:224-225; the surrounding body is §9's ✅-verified session construction withtools:threaded through.LanguageModelSession(model:tools:instructions:)is ✅ verified in Book Tracker (SearchBooks.swift:556-560). The exact signature of the shippedgenerateTagsafter this change is not in our corpus.
Three things follow from that default, and they are all worth naming as a design lesson rather than a trick:
The existing evaluation kept compiling and kept meaning the same thing. subject(from:) calls
generateTags(for:) with no tools: argument, gets an empty array, and produces exactly the behaviour
it produced yesterday. Your baseline did not move.
The control group is now free. Before the change, "no tools" was the only possible configuration. After it, "no tools" is a value you can pass, which means control and experimental differ by an argument rather than by a git branch. Session 335's suite is two instances of one evaluation type:
✅ VERIFIED (
335:227-229), verbatim: "Here is the new evaluation I wrote. It's exactly the same as the other evaluation. The only difference is I now pass my new lookup tool in the tools array."
struct BookTaggingWithToolsEvaluation: Evaluation {
let tools: [any Tool] // the ONE variable
func subject(from sample: ModelSample<BookTags>) async throws -> ModelSubject<BookTags> {
let result = try await BookTaggingService.generateTags(
for: sample.promptDescription,
tools: tools
)
return ModelSubject(value: result)
}
// …dataset, evaluators, aggregateMetrics: byte-identical to BookTaggingEvaluation
}
@Suite("Tag quality — book-lookup tool")
struct TagToolComparison {
static let control = BookTaggingWithToolsEvaluation(tools: [])
static let experimental = BookTaggingWithToolsEvaluation(tools: [BookLookupTool()])
…
}
Defaulted parameters are how you make a feature A/B-able without forking it. Generalise past tools:
model: any LanguageModel = SystemLanguageModel(…), instructions: String = Self.instructions,
options: GenerationOptions = .init(). Every one of those becomes a hill you can climb, and every one
of them keeps every existing caller — including your evaluation suite — working untouched. If you are
about to duplicate an evaluation file to change one thing, add a parameter instead.
What the experiment found, and the honest ending¶
✅ VERIFIED (
335:233-234): "my service which uses tools met all my expectations, so things are looking good. But, my dataset for Book Tracker contains only 13 book and review pairs, that doesn't cover the wide variety of books and reviews a user might submit for tagging."✅ VERIFIED (
335:236-237): "I can see that the service with the tool is performing better, however it does seem like my tool isn't being called in all the places I think it needs to. What I really need is a way to tell whether or not my tool has been called in the right situations."
Two open problems, and each one is the subject of another guide in this part:
- "only 13 book and review pairs" → synthetic data.
SampleGeneratorandmakeSamples, and the reality check that arrives when you go from 13 to 100 and the scores drop (299:100-102). - "whether or not my tool has been called in the right situations" → trajectory evaluation.
ToolCallEvaluator(allPass:percentagePass:),TrajectoryExpectation,ToolExpectationand the nineArgumentMatchercases including.naturalLanguage(argumentName:criteria:).
The second one is worth previewing here only because of the framing, which is the best sentence in session 299:
✅ VERIFIED (
299:122-124), verbatim: "Here's the thing. A model might give you a reasonable-sounding answer without ever calling the right tool. The final output can look correct while the path to get there isn't right."
An output-only evaluation of a tool-using feature is a partial evaluation, and it is partial in exactly
the direction that hides your worst bugs. Once you have added a tool as a hill-climb, you need to
measure the trajectory too — and because ModelSubject(value:transcript:) is already sitting in the
API you have been using since §4, that turns out to be a small change.
16. Everything you can turn¶
The knobs are not just prompt text, and session 335's recap is the most complete enumeration anyone has published:
✅ VERIFIED (
335:249-253), verbatim: "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 | What changing it looks like | Where it is covered |
|---|---|---|---|
| Feature | Instructions | rewrite the prompt; add examples; add a reasoning field | §13–14; Part 2 guide 1 |
| Feature | Tools | add, remove, or re-describe a tool | §15; Part 2 guide 3 |
| Feature | Model | SystemLanguageModel() → PrivateCloudComputeLanguageModel(); a different useCase:; a BYO backend |
Part 4 |
| Feature | Guides & schema | @Guide(.count(3...8)); simplify a @Generable type |
Part 2 guide 2 |
| Feature | Guardrails / options | SystemLanguageModel(guardrails:), GenerationOptions |
Part 2 guide 6 |
| Evaluation | Dataset | more samples; more coverage; adversarial rows; synthetic expansion | §5; the datasets guide |
| Evaluation | Aggregation methods | add a standard deviation; add a custom(of:label:) statistic |
§7 |
| Evaluation | The evaluators themselves | tighten a heuristic; split a judge dimension; reword a scale | §6; the judge guide |
Two of those deserve a warning label.
Changing the model changes everything at once. Swapping SystemLanguageModel for
PrivateCloudComputeLanguageModel changes capability, context window, latency, cost, availability and
privacy posture simultaneously, and it is the least "one variable" change on the list. Do it as its own
experiment, never bundled with a prompt change. (Session 298 makes the judge-side version of this
argument explicitly: "Your judge should be at least as capable as the model you're evaluating." —
298:208-210.) Also note that Book Tracker's tagging service deliberately stays on-device for a
product reason, not a technical one:
✅ VERIFIED (
298:207): "Our BookTaggingService runs on-device because it needs to be fast and local for every user interaction." And (335:216): "readers tend to be in all kinds of places when cataloging books, so using the on-device model ensures they can generate tags no matter where they are."
Changing the evaluators moves the goalposts. Tightening a heuristic or rewording a scale changes
what "0.85" means, which invalidates comparison against every earlier run. That is sometimes exactly
right — session 335 spends three iterations doing precisely this to its rubric — but be deliberate
about it, and stamp something in info: that lets future-you tell metric-change runs from
feature-change runs. A "EvalVersion" key costs nothing.
What to do when a change makes scores drop¶
Session 299 enumerates the four candidate explanations, and the order is not accidental — check them in
this order (299:103-110), verbatim:
- "Score changes could be due to problems with our prompt or instructions. You could refine one or both to better capture your needs."
- "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 and need to capture more variation."
✅ VERIFIED (
299:111): "These are the core ways to further improve your results."
The one people jump to is 1 and the one that is usually true early on is 4. A drop that appears the moment you expand the dataset is almost never a regression — it is your first honest measurement.
17. ⚠️ The silent failures¶
The defining property of this stack is that most defects do not throw. An evaluation framework has a particularly nasty version of the problem, because a broken evaluation still produces a number, and a number is exactly the thing you have decided to trust.
Here they are collected, worst first.
17.1 A corrupt dataset shrinks your evaluation instead of failing it¶
⚠️ SILENT FAILURE —
JSONLoaderskips malformed rows and only tellsOSLog.✅ VERIFIED —
/documentation/evaluations/jsonloader, verbatim: "Malformed entries are logged viaOSLogand skipped. A failure to open the file propagates as a thrown error."
Read the asymmetry: a missing file throws, and your test goes red. A file with 100 rows of which 63 fail to decode loads 37 rows, runs cleanly, and reports an aggregate over 37 samples. Your test still passes. Your report still renders. The dataset you thought you were measuring against silently became a third of itself, biased toward whichever rows happened to survive.
This bites hardest exactly where it is most likely: the synthetic-data workflow (§5) writes JSON with
one tool and reads it with another, across a schema you are actively changing. Rename a property on
your @Generable expected type, forget to regenerate, and JSONLoader will quietly hand you an empty
dataset.
The defence is a count assertion, and it belongs in every suite that uses JSONLoader:
@Test("Synthetic Book Tags", .evaluates(evaluation, info: evaluationInfo))
func evaluateSynthetic() async throws {
let result = EvaluationContext.current.result
// Guard the dataset before you trust anything computed from it.
let scored = result.detailed[metric: Self.evaluation.tagCount]
#expect(scored.count == 100, "JSONLoader dropped rows — check OSLog for decode failures")
#expect(result.aggregateValue(.mean(of: Self.evaluation.tagCount)) >= 0.8)
}
The detailed[metric:] subscript and scores.count are ✅ verified in Apple's own
inspectDetailedResults example (§8.4), where the last line is literally #expect(scores.count == 5).
Apple wrote the defence into the doc sample without calling it one.
17.2 A perfect pass rate over a collapsed distribution¶
Covered in full in §7. Restated here because it is the one that will actually happen to you: a
(3...8).contains(n) metric reads 100% whether the model produces a healthy spread or the identical
value on every sample. @Guide(.count(3...8)) fixed the range and collapsed the output to a constant 8,
and the board went green (298:127-130).
Rule: every "is X in range" metric needs a companion .scoring(X) metric with a standard deviation
in the aggregate. A σ of 0 where you expect variance is the alarm.
17.3 Evaluating a differently-configured system¶
Covered in §4. SystemLanguageModel(guardrails: .permissiveContentTransformations) appears in both
Book Tracker's app service and its evaluation (✅ both call sites verified). Drop it from one and
everything compiles, runs, and reports a number about a model you do not ship. The same applies to
instructions, useCase:, GenerationOptions, and which LanguageModel you construct.
Rule: exactly one function builds the session. The app calls it and the evaluation calls it. If
your subject(from:) contains a LanguageModelSession(...) literal, ask why the app cannot call the
same code — and if the answer is "I need the transcript back", change the service to return it rather
than duplicating the constructor.
17.4 Two Metrics with the same name, or one metric constructed twice¶
Apple's minimal Evaluation example (§3) declares let metric = Metric("Match") as a stored property
and then shadows it inside the evaluator closure with a locally constructed Metric("Match"). Book
Tracker never does this, and its test body deliberately reaches through static let evaluation to get
the instance.
✅ Probe-verified, 2026-07-31 — identity is by NAME, so the merge arm is the live hazard. (was a 🔴 GAP;
probes/eval.metric-identity, 27.0 sim runtime — full result in §8.2.) Two metrics with the same string silently merge into one column and one pooled aggregate, and nothing throws. The instance arm of the old dilemma is dead: a locally constructedMetric("Match")is found byaggregateMetricsandaggregateValuejust fine.Safe default unchanged, sharper reason: one stored property per metric, unique names, referenced everywhere. The thing to police is the name: two evaluators reusing a name unintentionally pools unrelated measurements into one mean, silently.
17.5 An aggregate computed over nothing¶
.ignore() excludes a sample from a metric's aggregation, and the idiom Apple shows for it is
guard let expected = sample.expected else { return metric.ignore() }. Now consider a prompt-only
dataset — which Apple explicitly recommends for judge-scored evaluations (§5) — run through an
evaluator written for a reference-carrying one. Every sample ignores. The metric aggregates over zero
values.
✅ Probe-verified, 2026-07-31 — the answer is a
-1.0sentinel. (was a 🔴 GAP;probes/eval.mean-over-all-ignored, 27.0 sim runtime: an evaluator returning.ignore()unconditionally over 4 detailed rows yieldsaggregateValue(.mean(of:)) == -1.0.) Not zero, not NaN, no trap. The good news:#expect(mean >= threshold)fails loudly for any sane positive threshold. The bad news:-1.0is also whataggregateValuereturns for a metric that was never registered at all (the probe suite confirmed that too, on an evaluation with emptyaggregateMetrics), so a-1.0cannot tell you which of the two nothings you measured.The safe default therefore stands unchanged: if you mix reference-carrying and prompt-only samples, assert the scored row count as in §17.1 before asserting the aggregate. The sentinel saves you from a false green; only the row-count assertion tells you what actually ran.
17.6 A green suite over a bad feature¶
Not an API defect — a coverage defect, and the most common one. Four heuristics passed on tags that
included "overrated", "pretentious" and a wrong genre (298:191-198). Session 335 opens on a fully
green evaluation whose author knows the output is not good enough (335:52).
Rule: when tests pass and output is bad, fix the measurement, not the feature. Re-read §10 and ask which of your five expectations has no metric behind it.
17.7 A failing sample that vanishes instead of failing¶
✅ SDK-verified — Xcode 27 final (
27A266a), refreshed 2026-10-06.EvaluationResult.errorsexposesinferenceFailureCount,evaluatorFailureCount,failingEvaluatorTypes,anyInferenceProduced,metricsNotFound, andhasFailures. Check these before accepting an aggregate. The final interface is captured innotes/sdk-interfaces/Evaluations-27.0-macos.swiftinterface:505-515,564.Historical runtime measurement, 2026-07-31: the beta Simulator completed a five-sample run with two subject failures, retained five detailed rows, and reported mean 1.0 over the three survivors. Earlier beta interfaces lacked this public result error summary. The superseded interface, including the deprecated
EvaluationError.metricsNotFoundmessage, remains in the reviewed beta evidence.Safe default: require
!result.errors.hasFailures, then assert the scored metric count and fixture length. A clean error summary alone does not prove that ignored scores or dropped loader rows covered the complete fixture. Model-free failure probes are inprobes/Tests/ProbesTests/EvaluationsProbes.swift; their observed outcomes are recorded in the final-SDK evidence note.
func requireCompleteScoring(_ result: EvaluationResult, metric: Metric, expectedRows: Int) {
precondition(!result.errors.hasFailures, "Evaluation recorded inference or scoring failures")
precondition(result.errors.inferenceFailureCount == 0)
precondition(result.errors.evaluatorFailureCount == 0)
precondition(result.detailed.rows.count == expectedRows, "Fixture rows are missing")
precondition(result.detailed[metric: metric].count == expectedRows, "Scored coverage is incomplete")
}
The pattern behind all seven¶
Six of these seven are the same shape: something reduced the number of samples, or the meaning of a sample, and the aggregate still computed. An aggregate is a lossy summary and it cannot tell you what went into it.
So make the denominator an assertion, not an assumption. One line at the top of every test body:
#expect(result.detailed[metric: Self.evaluation.someAlwaysFiringMetric].count == expectedSampleCount)
It costs nothing, it survives every unresolved GAP above, and it converts four silent failures into loud ones.
18. Why this framework exists: there is no model version pinning¶
Everything in this guide has been presented as a development technique. It is also, structurally, the only insurance policy available to you — and that is not our framing, it is Apple's.
✅ VERIFIED — Apple Frameworks Engineer, developer forums thread 833642, answering a direct question about model versioning: there is no pinning API and no version-retrieval API. The recommended mitigation named in the same answer is the Evaluations framework, to catch regressions between OS updates.
Sit with the shape of that. You cannot ask which model version you are running. You cannot request a specific one. And the model does change:
✅ VERIFIED —
/documentation/foundationmodels/systemlanguagemodel, verbatim: "Apple periodically updatesSystemLanguageModelin routine OS updates... Currently there are 3 model versions that align with: - iOS, iPadOS, macOS, and visionOS 26.0 - 26.3 - iOS, iPadOS, macOS, and visionOS 26.4 - iOS, iPadOS, macOS, visionOS, and watchOS 27.0"
Three model versions in roughly a year, delivered by point releases your users install automatically. And the changes were not cosmetic. Apple's own release notes for 26.4, verbatim:
✅ VERIFIED — FoundationModels release notes: "Use the latest on-device large language model that improves instruction-following and tool-calling abilities. Because the model changes when a person updates to iOS 26.4, iPadOS 26.4, macOS 26.4, and visionOS 26.4, test your prompts with the new model…"
Plus a separate round of guardrail retuning:
✅ VERIFIED (
241:17-19): "You may have noticed adjustments in iOS 26.4 to reduce the number of false [positives]… continuing to make even more improvements in iOS 27."
Any of those is capable of moving your feature's behaviour, in either direction, on a device you do not control, on a day you did not choose.
What Apple offers instead of pinning¶
Two things, and they are complementary rather than alternative.
Version-gated prompts. The Foundation Models documentation has an entire article on rewriting a prompt per model version, and its examples are worth internalising:
✅ VERIFIED —
/documentation/foundationmodels/updating-prompts-for-new-model-versions, verbatim:if #available(iOS 26.4, macOS 26.4, visionOS 26.4, *) { return String(localized: "support-ticket-summarizer-v1.1", table: "Prompts") } else { return String(localized: "support-ticket-summarizer-v1.0", table: "Prompts") }"Order the availability attribute from the newest version to the oldest version… The availability of the Foundation Models framework starts at 26.0, so you don't need to check for versions prior to that."
Note what that pattern concedes: prompts are version-specific artefacts, like any other piece of code with an availability annotation. Which brings us to the sentence in that article that is really about Evaluations:
✅ VERIFIED, verbatim: "Because the older model is only included as part of the beta program, it's essential to produce a record of what output your prompt produces with the prior model."
A record of what output your prompt produces. That is an evaluation run. That is info: with the prompt
text stamped in it, saved as JSON, kept.
An evaluation suite as the regression gate. This is the load-bearing one. When 27.1 or 28.0 lands, you do not reason about whether the model changed — you re-run the suite against the new OS and read the diff. The pieces you need are already assembled:
- The dataset is stable, so the inputs are identical across OS versions.
- The metrics are defined independently of the model, so the criteria are identical. Apple says
exactly this: "Because you define your metrics before tuning prompts or switching models, every
change is measured against the same criteria." (✅
/documentation/evaluations/evaluating-language-model-responses.) - The
info:dictionary records which prompt, which model, which build produced each run. - The Compare view (§11) diffs two runs side by side.
EvaluationResult.saveJSON(to:)/loadJSONLines(from:)keep a history that outlives an Xcode install.
Add "OSBuild" and "ModelVersionBand" keys to your info: today. You cannot query the model version,
but you can record the OS build that produced a run, and that is a sufficient proxy for the three-band
table above:
static let evaluationInfo: [String: String] = [
"Prompt": BookTaggingService.instructions,
"ModelName": "SystemLanguageModel",
"OSBuild": ProcessInfo.processInfo.operatingSystemVersionString,
"AppVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?",
"Feature": "Automatic tag generation from book reviews"
]
🟡 RECONSTRUCTED —
.evaluates(_:info:)taking[String: String]is ✅ verified, and every key above is free-form. The specific choice ofoperatingSystemVersionStringis ours; nothing in Apple's material suggests a canonical key set beyond the four Book Tracker uses.
The wider claim¶
There is one more reason to care, and it generalises past the built-in model. The whole 2026 stack is
built on the premise that you might swap backends —
SystemLanguageModel, PrivateCloudComputeLanguageModel, an MLX model, a Core AI bundle, an
OpenAI-compatible endpoint — behind one session API (Part 4). Every one of those substitutions is a
behaviour change you cannot reason your way through. The only honest way to make that choice is to
measure both, on your dataset, against your criteria.
Which is why Evaluations is a cross-cutting part of this series rather than a corner of Part 2. It is not a testing convenience. It is the instrument that makes every other choice in the stack decidable — and, given the absence of a pinning API, the only thing standing between a routine OS update and a feature that quietly got worse.
19. Quick reference¶
The whole protocol, on one screen¶
import Evaluations
struct MyEvaluation: Evaluation { // Sendable
let accuracy = Metric("Accuracy") // identifier + result carrier
var dataset = ArrayLoader(samples: [ // STORED, and a Loader
ModelSample(prompt: "…", expected: MyOutput(…))
])
func subject(from sample: ModelSample<MyOutput>) // generic over the EXPECTED type
async throws -> ModelSubject<MyOutput> {
let value = try await MyFeature.run(sample.promptDescription)
return ModelSubject(value: value) // or (value:transcript:) for tool evals
}
var evaluators: Evaluators { // result-builder type, plural
Evaluator { input, subject in // TWO args: (sample, subject)
subject.value.isGood ? accuracy.passing() : accuracy.failing(rationale: "…")
}
}
func aggregateMetrics(using aggregator: inout MetricsAggregator) { // inout
aggregator.group("Quality") { group in
group.computeMean(of: accuracy) // pass/fail mean = pass RATE
}
}
}
@Suite("My Feature")
struct MyFeatureTests {
static let evaluation = MyEvaluation() // MUST be static let
static let info = ["Prompt": MyFeature.instructions, "ModelName": "SystemLanguageModel"]
@Test("My Feature", .evaluates(evaluation, info: info))
func run() async throws {
let result = EvaluationContext.current.result // dataset already ran
#expect(result.aggregateValue(.mean(of: Self.evaluation.accuracy)) >= 0.8)
}
}
Version floor¶
| Symbol | Earliest OS | Notes |
|---|---|---|
Everything in Evaluations |
27.0 — iOS / iPadOS / Mac Catalyst / macOS / visionOS / watchOS | Beta. No tvOS. No back-deployment. |
The .evaluates trait, the Evaluations report |
Xcode 27 | Report is an Xcode feature, not library code |
Transcript.structuredTranscript |
27.0 (Evaluations extension) | requires import Evaluations; required for ToolCallEvaluator |
PrivateCloudComputeLanguageModel |
27.0 | needs an entitlement; see Part 4 guide 1 |
SystemLanguageModel(guardrails:) |
27.0 | .permissiveContentTransformations used by Book Tracker |
SystemLanguageModel |
26.0 (watchOS 27.0) | three model versions: 26.0–26.3, 26.4, 27.0 |
contextSize, tokenCount(for:) |
26.4 | back-deployed attribute on contextSize |
Tool |
26.0 (watchOS 27.0) |
The corrections, if you have older material¶
| Spelling in circulation | ✅ Actual |
|---|---|
var evaluators: some Evaluator |
var evaluators: Evaluators |
Evaluator(metric) { output, sample in … } |
Evaluator { input, subject in … } — metric captured, not passed; args in that order |
.pass / .fail / .score(_) |
metric.passing() / .failing() / .scoring(_) / .ignore(), each with an optional rationale: |
subject(from:) -> T |
subject(from:) async throws -> ModelSubject<T> |
var dataset: [ModelSample] { … } |
var dataset = ArrayLoader(samples: …) — stored, and a Loader |
MetricAggregator |
MetricsAggregator, passed inout |
aggregator.average(of:) |
aggregator.computeMean(of:) |
.evaluates(evaluation, notes: …) |
.evaluates(evaluation, info: [String: String]) |
func test(results: EvaluationResults) |
func test() async throws + EvaluationContext.current.result (type is EvaluationResult, singular) |
results.aggregateValue(for: "TagCount") |
result.aggregateValue(.mean(of: metric)) / .custom(label:) |
| framework-provided Cohen's kappa | hand-rolled — 72 lines in the sample's Statistics.swift |
The five steps¶
| # | Question | API |
|---|---|---|
| 1 | What code am I measuring? | func subject(from:) async throws -> ModelSubject<T> |
| 2 | What data am I sending it? | var dataset = ArrayLoader(samples:) / JSONLoader(url:) of ModelSample<T> |
| 3 | What am I measuring, and how? | let m = Metric("…") + var evaluators: Evaluators |
| 4 | How do I summarise it? | func aggregateMetrics(using: inout MetricsAggregator) |
| 5 | How do I run it? | @Test(.evaluates(evaluation, info:)) + EvaluationContext.current.result |
The hill-climbing cycle¶
develop → run → check expectations → analyse → repeat
· control vs experimental, as two instances of ONE parameterised Evaluation
· exactly ONE variable different
· when the experimental wins, BACKPORT it into the control before the next round
· a failed experiment is a result
· a green board is not the end of the loop
The rule of thumb¶
"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 a
ModelJudgeEvaluator." —298:277-278
Checks to put in every test body¶
let result = EvaluationContext.current.result
// 1. Reported failures, then the denominator. Neither replaces the other.
#expect(!result.errors.hasFailures)
#expect(result.errors.inferenceFailureCount == 0)
#expect(result.errors.evaluatorFailureCount == 0)
#expect(result.detailed[metric: Self.evaluation.alwaysFires].count == expectedRowCount)
// 2. The optimization target.
#expect(result.aggregateValue(.mean(of: Self.evaluation.primary)) >= 0.8)
// 3. The distribution, wherever you have a range metric.
// (.standardDeviation(of:) is an SDK-verified AggregationOperation case — §9.)
#expect(result.aggregateValue(.standardDeviation(of: Self.evaluation.primaryTotal)) > 0)
20. Sources, and where they disagree¶
Evidence used, in precedence order¶
- The framework's shipped Swift interface —
Evaluations-27.0-macos.swiftinterface(925 lines), refreshed from Xcode 27 final (27A266a) on 2026-10-06 intonotes/sdk-interfaces/in this repo. For names, signatures, defaults, availability and case lists it outranks everything below, including the sample; for usage and runtime behaviour it decides nothing. Cited inline as ✅ SDK-verified with line numbers. - Apple sample code — Book Tracker
(
/documentation/evaluations/book-tracker-using-evaluations-to-evaluate-an-intelligent-feature). 20 Swift files,MACOSX_DEPLOYMENT_TARGET = 27.0, five targets: the app, two test bundles (BookTrackerEvaluations,HillClimbingEvaluations) and two command-line tools (BookSampleGenerator,DatasetExtractor). This is the strongest evidence in the corpus and it outranks everything below wherever they conflict. Files cited here:BookTags.swift(200 lines),SearchBooks.swift(578),SyntheticBookTags.swift(133),ModelJudgeAlignmentEvaluation.swift(353),Statistics.swift(72),BookTracker/Services/BookTaggingService.swift(101),BookSearchTools.swift(267),BookSampleGenerator/main.swift(87),DatasetExtractor/main.swift(167). - Apple documentation —
/documentation/evaluations(framework index, full symbol inventory) and its articles:evaluating-language-model-responses,designing-effective-evaluations,designing-evaluation-criteria,designing-evaluation-datasets,designing-effective-model-judges,scoring-with-model-as-judge-evaluators,evaluating-tool-calling-behavior,generating-synthetic-evaluation-datasets. Plus, on the FoundationModels side,updating-prompts-for-new-model-versions,systemlanguagemodel, andevaluating-prompts-to-measure-performance-and-improve-model-responses. - Apple Developer Forums — thread 833642 (Frameworks Engineer: no model version pinning API,
use Evaluations for regression testing), thread 833729 (Frameworks Engineer, accepted: Evaluations
is Swift-only), thread 832053 (Apple Engineer, recommended:
ModelJudgeEvaluatorscope, and that it works againstPrivateCloudComputeLanguageModel), thread 833822 (unanswered: image-text evaluation). - WWDC26 session transcripts — 298 Meet the Evaluations framework (Yada, Rob), 299
Create robust evaluations for agentic apps (Ada, Kyle), 335 Improve your prompts by hill
climbing with Evaluations (Marcus), 241 (framework overview), 334 (Swift vs Python for
evaluation). Cited as
<session>:<line>. These are machine-transcribed spoken word: sentence-level quotes are reliable, Swift identifiers heard aloud are not.
Where the sources disagree, and how it was ruled¶
Every disagreement below is the same kind: a session transcript's spoken API name versus the sample's written one. The sample wins in every case. They are listed because reconstructions built from the transcripts are in wide circulation, and several of them look entirely plausible.
| Claim | Session transcript | Book Tracker / docs | Ruling |
|---|---|---|---|
| Evaluators property | var evaluators: some Evaluator (reconstruction from 298:73) |
var evaluators: Evaluators |
Sample. Evaluators is a protocol typealias; EvaluatorsBuilder is the result builder. |
| Evaluator closure | Evaluator(metric) { output, sample in } |
Evaluator { input, subject in } |
Sample. Two args, (sample, subject), metric captured not passed. |
| Metric results | .pass / .fail / .score(_) |
.passing() / .failing() / .scoring(_) / .ignore() |
Sample + docs. |
| Subject return type | the raw output type | ModelSubject<T> |
Sample. ModelSubject was absent from every pre-sample symbol list, including ours. |
| Dataset | var dataset: [ModelSample] { … } |
var dataset = ArrayLoader(samples:) |
Sample. Stored, and a Loader. |
| Aggregator type | MetricAggregator, by value |
MetricsAggregator, inout |
Sample + docs. |
| Aggregation call | aggregator.average(of:) |
aggregator.computeMean(of:) |
Docs + sample. |
| Trait label | .evaluates(eval, notes:) (298:85-89) |
.evaluates(eval, info:) |
Sample + docs. "notes" is narration, not a label. |
| Test signature | func f(results: EvaluationResults) |
func f() async throws + EvaluationContext.current.result |
Sample + docs. No such parameter exists; the type is singular. |
| Reading an aggregate | results.aggregateValue(for: "TagCount") |
result.aggregateValue(.mean(of: metric)) |
Sample + docs. Keyed by AggregationOperation, not by string. |
| Cohen's kappa | implied to be available (335:91-101) |
hand-rolled, 72 lines, Statistics.swift |
Sample. The framework ships no agreement statistic. 335:127 agrees on close reading: "a custom aggregation method". |
| The attachment round trip | "I can retrieve that attachment and extract summary and tag pairs" (335:115-118) — sounds like an in-Xcode gesture |
a 167-line ArgumentParser CLI parsing an exported .xcevalresult bundle |
Sample. There is real work here that the narration compresses to one sentence. Budget for it. |
| Judge model | "we've specified Private Cloud Compute as our judge model" (298:221) |
judge: SystemLanguageModel.default in BookTags.swift:108; SystemLanguageModel() in ModelJudgeAlignmentEvaluation.swift:213 |
Both true, different runs. The capability rule (298:208-210) stands; the shipped sample judges on-device. Apple confirmed on thread 832053 that PCC works as a judge. |
| Dataset size | "Good evaluations have thousands of samples" (298:134) vs "20 to 30 samples is a great place to get started" (298:273) |
13 curated → 100 synthetic | Not a conflict — a workflow. Hand-write 20–30, expand with SampleGenerator, keep both suites. |
| Generator type name | SyntheticGenerator (299:91) |
SampleGenerator (3 of 4 mentions, plus BookSampleGenerator/main.swift) |
SampleGenerator. One transcription slip. |
| Sample type name | ModelSamples (299:35) |
ModelSample<T> |
ModelSample. ModelSampleProtocol also exists; ModelSamples does not. |
| Platforms | "macOS, iOS, watchOS and visionOS" (299:2) |
index adds iPadOS + Mac Catalyst | Both. The session's list is incomplete, not wrong. Neither lists tvOS. |
Still open¶
Consolidated from the 🔴 boxes above, updated after the 2026-07-29 interface pass. Closed items are kept and marked, so you can see what moved:
- Non-text / multimodal evaluation (§2). Hook exists (
ModelSampleInputand the generic protocols, all present in the interface); no compiling example anywhere; the forum question is unanswered. Open. Metricidentity — by name or by instance? (§8.2, §17.4). Closed, probe-verified 2026-07-31: by NAME, and same-named metrics pool into one aggregate (probes/eval.metric-identity, 27.0 sim runtime).AggregationOperation's full case list (§9). Closed: seven statistic cases pluscustom(label:), ✅ SDK-verified (Evaluations-27.0-macos.swiftinterface:431-444).- What an all-
.ignore()metric aggregates to (§17.5). Closed, probe-verified 2026-07-31: the-1.0sentinel — indistinguishable from an unregistered metric, so keep asserting row counts (probes/eval.mean-over-all-ignored, 27.0 sim runtime). - What happens to a run when
subject(from:)throws for some samples (§17.7). Historical measurement, probe-verified 2026-07-31: the run continues and failed samples are excluded from the aggregate while still occupying detailed rows — the silently-improved-score hazard is reproduced fact (probes/eval.subject-throws, 27.0 sim runtime). if/elseinside theevaluatorsbuilder (§6). Effectively closed: the interface confirmsbuildExpression/buildOptional/ fourbuildPartialBlockoverloads — beta 5 swapped out the variadicbuildBlock— and nobuildEither(:659-666), so a bareifbuilds and anif/elseshould not. Not compile-tested.- The Evaluations report UI beyond four narrated sentences (§11) — no screenshots, no CI story, no confirmation that Compare handles more than two runs. Open.
EvaluationResult.saveJSON(to:includeReportMetadata:includeTranscripts:)'s exact signature (§12). Closed: it takes a directory and returns the written file's URL, ✅ SDK-verified (:581-597). Whether its output matches the.xcevalresultshape is still open.ScoreDimension.scalecases other than.numeric—.passFail(passDescription:failDescription:)and.custom(_:)signatures are now ✅ SDK-verified (:392-393), but no sample exercises them; every dimension in Book Tracker is a 4-point numeric scale. Usage still unproven.ModelJudgePrompt.reference's second closure parameter. Closed: it is the model's output value, typedInput.ExpectedValue— the full closure type is(Input, Input.ExpectedValue) async throws -> [String : String], ✅ SDK-verified (:349-357). Not theModelSubject, as previously guessed.
Where to go next in Part 6¶
- Model judges —
ScoreDimension,ScoringScale(.numeric/.passFail/.custom),ModelJudgePrompt(instructions:evaluationTarget:reference:),ScoringMode, pairwise judging, and why an even-numbered scale removes the noncommittal middle. - Judge drift and calibration — Cohen's kappa, why plain accuracy is not enough on a skewed dataset,
the 0.6 threshold, and the
DatasetExtractorround trip from §12. - Datasets and synthetic generation —
makeSamples(_:targetCount:sessionProvider:validator:),SampleGenerator'ssessionProvider/samplingStrategy/validator, thetargetCountinclusive-count gotcha, and why the validator cannot check corpus-level properties. - Tool-call and trajectory evaluation —
ToolCallEvaluator(allPass:percentagePass:),TrajectoryExpectation's four initialisers,ToolExpectation, and all nineArgumentMatchercases.
Elsewhere in the series¶
../../part-01-orientation-and-gating/references/02-platform-and-version-gating.md— the 26.0 / 26.4 / 27.0 ladder in full.../../part-02-foundation-models-everyday-api/references/02-guided-generation-and-streaming.md—@Generable,@Guide, and why a guide is a hint.../../part-02-foundation-models-everyday-api/references/03-tools-and-tool-calling.md— theToolprotocol, and the transcript anatomyStructuredTranscriptmirrors.../../part-04-beyond-the-built-in-model/references/01-private-cloud-compute.md— PCC as a judge or a generator, and the entitlement it needs.../../part-05-prototyping-profiling-non-swift/references/02-fm-cli-and-python-sdk.md— what to do when Evaluations' Swift-only constraint blocks you.
-
Apple documents
StructuredTranscriptin the Evaluations framework and usessession.transcript.structuredTranscriptin its language-model evaluation flow. The captured Xcode 27 interface provides the ownership detail the abbreviated sample omits:notes/sdk-interfaces/Evaluations-27.0-macos.swiftinterface:288-291declares the accessor in anextension FoundationModels.Transcript, with return typeEvaluations.StructuredTranscript; the FoundationModels interface contains no declaration. Thereforeimport Evaluations, rather than framework linkage alone, brings the accessor into scope. ↩