Evaluating a Core AI model
At a glance
| Item | Summary |
|---|---|
| Purpose | Export an open-source language model with Core AI, run it through a Foundation Models LanguageModelSession, and use the Evaluations framework to compare its three-way sentiment classification with the system’s default on-device model. |
| App architecture | Two Xcode targets. A small SwiftUI macOS app (CoreAIEvals): one view owns an @Observable screen model, which calls a stateless SentimentClassifier service. A unit-test bundle (CoreAIEvalsTests) hosted by the app reuses that same service as the evaluation subject. |
| Main patterns | Service object (SentimentClassifier, shared by app and tests), Actor isolation (CoreAIModelStore caches the loaded model), Binding-based state propagation (@Bindable view bindings into the model) |
| Project style | Feature folders inside the app target (Models/, Inference/, Views/), a separate test target for evaluations and the labeled dataset, and a shell script that exports the model into a bundled folder reference. |
| Execution model | The app target compiles with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, so undeclared app types are main-actor isolated. The one explicit actor is CoreAIModelStore. The screen model creates one retained, cancellable Task. The test target has no default-isolation setting. |
| State/event model | Observation (@Observable ClassifierModel, held in the view’s @State), plus a closed Phase enum for the classification lifecycle. |
| Key frameworks/packages | Swift modules SwiftUI, Observation, Foundation, FoundationModels, and CoreAILanguageModels (app), plus Testing and Evaluations (tests). The CoreAILM product comes from the remote coreai-models package and is linked only into the app target. |
Project structure
CoreAIEvals.xcodeproj # 2 targets: app + hosted unit-test bundle
CoreAIEvals/ # app target (synchronized folder)
├── CoreAIEvalsApp.swift # @main, one WindowGroup
├── Models/
│ └── ClassifierModel.swift # @Observable screen state + Task lifecycle
├── Inference/
│ ├── ClassifierBackend.swift # .coreAI / .onDeviceDefault selector
│ ├── CoreAIModelStore.swift # actor: load-once model cache
│ ├── SentimentClassifier.swift # stateless service (app + tests)
│ └── SentimentVerdict.swift # @Generable output schema
└── Views/
├── SentimentClassifierView.swift
├── ResultView.swift
└── VerdictView.swift
CoreAIEvalsTests/ # test target, @testable import CoreAIEvals
├── SentimentClassificationEvaluationTests.swift
└── SentimentDataset.swift # 60 labeled ModelSample values
scripts/prepare-model.sh # exports Models/coreai-model (not committed)
Structure observations
- The project has two native targets.
CoreAIEvalsis an application (CoreAIEvals.xcodeproj/project.pbxproj:133).CoreAIEvalsTestsis a unit-test bundle (CoreAIEvals.xcodeproj/project.pbxproj:156) that depends on the app (CoreAIEvals.xcodeproj/project.pbxproj:146) and runs inside it throughTEST_HOST(CoreAIEvals.xcodeproj/project.pbxproj:469). - The test target links no package product of its own. It reaches app code through
@testable import CoreAIEvals(CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:11). That is why the evaluation exercises the shipping classification path instead of a copy. - The model is not source code.
prepare-model.shexportsQwen/Qwen3-0.6BintoModels/coreai-model(scripts/prepare-model.sh:27,scripts/prepare-model.sh:28). The project adds that folder to the app’s Resources phase (CoreAIEvals.xcodeproj/project.pbxproj:206), andCoreAIModelStorelooks it up by name at runtime. - The folders reflect responsibility:
Inference/holds everything that talks to models,Models/holds UI-facing state, andViews/holds rendering only.
Overall architecture
flowchart LR
App["CoreAIEvalsApp"] --> View["SentimentClassifierView"]
View -->|"@State owns, @Bindable edits"| Model["ClassifierModel (@Observable)"]
Model -->|"Task: classify(_:using:)"| Svc["SentimentClassifier (service)"]
Tests["SentimentClassificationEvaluation"] -->|"subject(from:)"| Svc
Svc -->|".coreAI"| Store["CoreAIModelStore (actor)"]
Store --> CoreAI["CoreAILanguageModel"]
Svc -->|".onDeviceDefault"| FM["LanguageModelSession (.default)"]
Reference code
CoreAIEvals/Inference/SentimentClassifier.swift:37 — makeSession(for:) is the single branch point between the two backends
private static func makeSession(for backend: ClassifierBackend) async throws -> LanguageModelSession {
switch backend {
case .coreAI:
let coreAIModel = try await CoreAIModelStore.shared.model()
return LanguageModelSession(model: coreAIModel, instructions: instructions)
case .onDeviceDefault:
return LanguageModelSession(model: .default, instructions: instructions)
}
}CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:113 — the evaluation calls the same entry point the UI uses
func subject(from sample: ModelSample<SentimentVerdict>) async throws -> ModelSubject<SentimentVerdict> {
ModelSubject(value: try await SentimentClassifier.classify(sample.prompt, using: backend))
}Interpretation
Observed: both the UI (CoreAIEvals/Models/ClassifierModel.swift:96) and the evaluation (CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:114) call SentimentClassifier.classify(_:using:). The only difference between the two measured configurations is the ClassifierBackend value. Both backends go through the same LanguageModelSession API, with the same instructions and the same @Generable output type.
Inference: this is the main architectural lesson of the sample. Because the classifier boundary is the single seam that both production code and evaluation code call, the evaluation scores exactly what the app ships. Comparing two models becomes a one-parameter change, not two code paths.
Ownership and state
classDiagram
SentimentClassifierView *-- ClassifierModel : @State (created in init default)
ClassifierModel *-- Phase : private(set) phase
ClassifierModel *-- Task : private task (retained, cancelled)
ClassifierModel --> SentimentClassifier : calls static classify
SentimentClassifier --> CoreAIModelStore : shared.model()
CoreAIModelStore *-- CoreAILanguageModel : private cached
SentimentClassificationEvaluationTests *-- SentimentClassificationEvaluation : static let per backend
Ownership evidence
CoreAIEvals/Views/SentimentClassifierView.swift:12 — the view creates and owns the screen model
struct SentimentClassifierView: View {
@State private var model: ClassifierModel
/// Accepts a model so previews can supply one parked in a fixed phase;
/// defaults to a live model for the running app.
init(model: ClassifierModel = ClassifierModel()) {
_model = State(initialValue: model)
}
// ...
}CoreAIEvals/Inference/CoreAIModelStore.swift:29 — a process-wide actor owns the loaded model
actor CoreAIModelStore {
static let shared = CoreAIModelStore()
// ...
private var cached: CoreAILanguageModel?
// ...
}| Owner | Object or state | Relationship | Mutation authority |
|---|---|---|---|
SentimentClassifierView |
ClassifierModel |
Creates it by default (or accepts one for previews) and stores it in @State (CoreAIEvals/Views/SentimentClassifierView.swift:16) |
The view writes text and useOnDeviceDefault through bindings, and calls classify() |
ClassifierModel |
text, useOnDeviceDefault |
Stored, implicitly internal vars (CoreAIEvals/Models/ClassifierModel.swift:29, CoreAIEvals/Models/ClassifierModel.swift:32) |
Any module code. In practice, the view’s $model bindings |
ClassifierModel |
phase: Phase |
private(set) (CoreAIEvals/Models/ClassifierModel.swift:35) |
Only ClassifierModel, including its same-file #if DEBUG preview factory (CoreAIEvals/Models/ClassifierModel.swift:137) |
ClassifierModel |
task: Task<Void, Never>? |
private. Retains the most recent task (CoreAIEvals/Models/ClassifierModel.swift:102) |
classify() only |
CoreAIModelStore |
cached: CoreAILanguageModel? |
private, filled once on first use (CoreAIEvals/Inference/CoreAIModelStore.swift:50) |
The actor’s own model() method |
SentimentClassifier |
LanguageModelSession |
Creates a fresh session on every call and returns it to classify without storing it (CoreAIEvals/Inference/SentimentClassifier.swift:60) |
No stored state. It is a caseless enum |
SentimentClassificationEvaluationTests |
One SentimentClassificationEvaluation per backend |
static let values (CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:121, CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:129) |
Immutable after construction |
Derived values are computed properties, not stored state. These are selectedBackend (CoreAIEvals/Models/ClassifierModel.swift:43), unavailableMessage (CoreAIEvals/Models/ClassifierModel.swift:52), and canClassify (CoreAIEvals/Models/ClassifierModel.swift:71). So the only lifecycle variable the view has to observe is phase.
Concurrency, scheduling, and thread safety
Evidence limit: actor isolation, async/await, or Task creation does not by itself prove background-thread execution. A Sendable conformance alone does not prove thread-safe mutation.
| Concern | Source mechanism | Verified placement or handoff | Evidence |
|---|---|---|---|
| Main-actor isolation (target-wide) | SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor on the app target |
App declarations without explicit isolation, including ClassifierModel, SentimentClassifier, and the views, are main-actor isolated. No source file writes @MainActor. |
CoreAIEvals.xcodeproj/project.pbxproj:402 |
| Isolation opt-out | nonisolated var displayName |
The one explicit opt-out in the app. The test target has no default-isolation setting, and it reads displayName synchronously in name and in @Test(... info:) arguments. |
CoreAIEvals/Inference/ClassifierBackend.swift:17, CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:40, CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:123 |
| Task creation | Plain Task { ... } in classify() |
Created from a main-actor-isolated method, so the closure inherits the main actor. Its writes to phase happen on the main actor. The model work happens across the await inside it. |
CoreAIEvals/Models/ClassifierModel.swift:87 |
| Task retention and cancellation | private var task, task?.cancel() |
ClassifierModel cancels any in-flight task before starting a new one, stores the new one, and returns it (@discardableResult). The view discards the return value. No deinit cancels the task. |
CoreAIEvals/Models/ClassifierModel.swift:84, CoreAIEvals/Models/ClassifierModel.swift:102, CoreAIEvals/Views/SentimentClassifierView.swift:35 |
| Actor isolation | actor CoreAIModelStore |
Serializes access to cached. Callers must await CoreAIModelStore.shared.model(). |
CoreAIEvals/Inference/CoreAIModelStore.swift:29, CoreAIEvals/Inference/SentimentClassifier.swift:40 |
| Suspension inside the actor | try await CoreAILanguageModel(resourcesAt:) |
model() suspends while loading the model, before it assigns cached. Actor reentrancy allows another caller in during that suspension. |
CoreAIEvals/Inference/CoreAIModelStore.swift:49 |
| Framework async calls | session.respond(to:generating:) |
An awaited Foundation Models call. Its executor is not established by the sample source. | CoreAIEvals/Inference/SentimentClassifier.swift:61 |
| Test-side async | async throws subject(from:) and @Test functions |
These are nonisolated in the test target. Calling the main-actor SentimentClassifier.classify from here requires await. |
CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:113 |
No DispatchQueue, lock, Task.detached, MainActor.run, Combine, or completion handler appears in the source. The project enables SWIFT_APPROACHABLE_CONCURRENCY on both targets (CoreAIEvals.xcodeproj/project.pbxproj:401, CoreAIEvals.xcodeproj/project.pbxproj:465) under Swift 6 (CoreAIEvals.xcodeproj/project.pbxproj:405). The sample does not depend on any behavior of those settings beyond what the table shows.
Reference code
CoreAIEvals/Models/ClassifierModel.swift:83 — cancel the previous task, publish .classifying, then retain the new task
func classify() -> Task<Void, Never> {
task?.cancel()
phase = .classifying
let task = Task {
// ...
if let unavailableMessage {
phase = .failed(unavailableMessage)
return
}
do {
let verdict = try await SentimentClassifier.classify(Prompt(text), using: selectedBackend)
phase = .classified(verdict)
} catch {
phase = .failed(Self.message(for: error))
}
}
self.task = task
return task
}CoreAIEvals/Inference/CoreAIModelStore.swift:42 — the load-once path
func model() async throws -> CoreAILanguageModel {
if let cached { return cached }
guard let url = Bundle.main.url(forResource: Self.modelResourceName, withExtension: nil) else {
throw CoreAIModelStoreError.modelNotBundled(resourceName: Self.modelResourceName)
}
let model = try await CoreAILanguageModel(resourcesAt: url)
cached = model
return model
}Observations and inferences on these boundaries:
- Observed: the task body checks neither
Task.isCancellednor which task is current. Inference: if a caller cancels a run that then finishes or throws, the old task can still writephase. In the shipped UI this cannot happen through the button:canClassifyreturnsfalsewhilephaseis.classifying(CoreAIEvals/Models/ClassifierModel.swift:72), and the button is disabled on that value (CoreAIEvals/Views/SentimentClassifierView.swift:37). Thecancel()path matters only to programmatic callers of the returned task. - Observed:
CoreAIModelStore.model()checkscachedbefore theawaitand assigns it after. Inference: two concurrent first callers could each start a load. The actor keepscachedfree of data races, but it does not deduplicate in-flight loads. The source does not show whether the Evaluations framework callssubject(from:)concurrently. - Inference: the explicit
nonisolatedonClassifierBackend.displayNameexists because the test target is nonisolated by default. The tests use the name synchronously when they buildEvaluation.nameand the@Testtrait arguments.
State propagation, frameworks, and dependencies
Evidence limit: an import proves a source-level compilation dependency at the cited line. It does not prove runtime use, architectural adoption, or whether a Swift package is a direct application dependency.
| Category | Mechanism or module | Verified role | Evidence |
|---|---|---|---|
| State propagation | @Observable macro |
ClassifierModel publishes reads of text, useOnDeviceDefault, and phase to SwiftUI |
CoreAIEvals/Models/ClassifierModel.swift:14 |
| State propagation | @State + @Bindable |
The view owns the model in @State and creates a local @Bindable to derive $model.text and $model.useOnDeviceDefault |
CoreAIEvals/Views/SentimentClassifierView.swift:21, CoreAIEvals/Views/SentimentClassifierView.swift:26, CoreAIEvals/Views/SentimentClassifierView.swift:32 |
| State propagation | Value passing | ResultView receives phase and unavailableMessage as let values. VerdictView receives a SentimentVerdict. |
CoreAIEvals/Views/ResultView.swift:13, CoreAIEvals/Views/VerdictView.swift:12 |
| Swift module import | Foundation, Observation, FoundationModels |
Screen model. FoundationModels supplies SystemLanguageModel availability and LanguageModelError |
CoreAIEvals/Models/ClassifierModel.swift:9, CoreAIEvals/Models/ClassifierModel.swift:10, CoreAIEvals/Models/ClassifierModel.swift:11 |
| Swift module import | FoundationModels |
LanguageModelSession, @Generable, @Guide |
CoreAIEvals/Inference/SentimentClassifier.swift:9, CoreAIEvals/Inference/SentimentVerdict.swift:9 |
| Swift module import | CoreAILanguageModels |
Supplies CoreAILanguageModel(resourcesAt:) |
CoreAIEvals/Inference/CoreAIModelStore.swift:9 |
| Swift module import | SwiftUI |
App entry and views | CoreAIEvals/CoreAIEvalsApp.swift:8 |
| Swift module import (tests) | Testing, Evaluations, @testable CoreAIEvals |
Test target. CoreAIEvals is the local app target’s module, not a package |
CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:9, CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:10, CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:11 |
| Package declaration | Remote coreai-models, branch main, product CoreAILM |
The product is a direct package dependency of the app target only. The project file does not say which Swift module(s) the product vends; the app imports CoreAILanguageModels. |
CoreAIEvals.xcodeproj/project.pbxproj:129, CoreAIEvals.xcodeproj/project.pbxproj:534, CoreAIEvals.xcodeproj/project.pbxproj:546 |
| Resource dependency | Folder reference Models/coreai-model |
Copied into the app bundle. Found by name with Bundle.main.url(forResource:withExtension:) |
CoreAIEvals.xcodeproj/project.pbxproj:29, CoreAIEvals/Inference/CoreAIModelStore.swift:45 |
The test code reaches the bundled model through Bundle.main, which works because the bundle is hosted in the app (TEST_HOST). Inference: this is why the tests need no resource or package setup of their own.
Class and protocol design
classDiagram
class Evaluation {
<<protocol>>
}
class SentimentClassificationEvaluation {
backend: ClassifierBackend
dataset
evaluators
subject(from:)
}
class SentimentClassifier {
<<enum namespace>>
classify(_:using:)
}
class ClassifierBackend {
<<enum>>
coreAI
onDeviceDefault
}
SentimentClassificationEvaluation ..|> Evaluation
SentimentClassificationEvaluation --> SentimentClassifier
SentimentClassificationEvaluation --> ClassifierBackend
ClassifierModel --> SentimentClassifier
SentimentClassifier --> ClassifierBackend
Reference code
CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:37 — one evaluation type, parameterized by backend
struct SentimentClassificationEvaluation: Evaluation {
let backend: ClassifierBackend
var name: String { backend.displayName }
let dataset = SentimentDataset.all
/// Fraction of all labeled samples whose predicted sentiment matched the label.
let accuracy = Metric("Accuracy")
// ...
}CoreAIEvals/Inference/SentimentVerdict.swift:23 — the guided-generation schema is also the evaluation’s expected-value type
@Generable
struct SentimentVerdict: Codable, Equatable {
@Guide(description: "One short sentence explaining why this sentiment was chosen.")
let reasoning: String
@Guide(description: "How confident you are in the sentiment choice.", .range(0...100))
let confidence: Int
@Guide(description: "The overall sentiment: negative, neutral, or positive.")
let sentiment: Sentiment
}| Type | Responsibility | Depends on |
|---|---|---|
CoreAIEvalsApp |
@main entry. Hosts one WindowGroup |
SentimentClassifierView |
SentimentClassifierView |
Input, backend toggle, Classify button, result area | ClassifierModel |
ClassifierModel |
UI-facing state, availability gate, task lifecycle, error-to-copy mapping | SentimentClassifier, SystemLanguageModel, LanguageModelError |
SentimentClassifier |
Stateless classification service. Builds a session per call and requests a SentimentVerdict |
CoreAIModelStore, LanguageModelSession |
CoreAIModelStore |
Locates the bundled model folder and caches the loaded model | CoreAILanguageModel, Bundle.main |
ClassifierBackend |
Closed choice of model. Supplies display names to the UI and to reports | — |
SentimentVerdict / Sentiment |
@Generable output schema, also the dataset’s expected-value type |
FoundationModels |
SentimentClassificationEvaluation |
Evaluation conformance: dataset, 10 metrics, evaluators, aggregation, subject |
SentimentClassifier, SentimentDataset |
SentimentDataset |
60 labeled ModelSample values in an ArrayLoader |
Evaluations |
The only protocol conformances are to framework protocols: App, View, Evaluation, LocalizedError, Codable, and Equatable. The sample declares no local protocol. Backend selection uses an enum switch, not protocol substitution, so this analysis does not claim protocol-oriented design or a Strategy object.
Access control
| Symbol | Access | Verified effect | Likely rationale |
|---|---|---|---|
ClassifierModel.phase |
private(set) |
Code outside ClassifierModel’s file can read but not assign it. The same-file preview(text:phase:) extension can assign it (CoreAIEvals/Models/ClassifierModel.swift:137). |
Inference: classify() stays the only production path that moves the lifecycle, while previews can still park a phase. |
ClassifierModel.task |
private |
Only ClassifierModel can read, cancel, or replace the in-flight task (CoreAIEvals/Models/ClassifierModel.swift:38). |
Inference: the model has sole authority over cancellation. |
SentimentClassifierView.model |
private (@State) |
Only the view can reach the storage (CoreAIEvals/Views/SentimentClassifierView.swift:12). |
Inference: the view owns the model’s lifetime. Previews inject a model through init instead. |
CoreAIModelStore.cached, modelResourceName |
private, private static |
Callers cannot bypass model() or change the resource name (CoreAIEvals/Inference/CoreAIModelStore.swift:34, CoreAIEvals/Inference/CoreAIModelStore.swift:37). |
Inference: keep the load-once invariant inside the actor. |
SentimentClassifier.instructions, makeSession(for:) |
private static |
Callers must go through classify(_:using:) (CoreAIEvals/Inference/SentimentClassifier.swift:14, CoreAIEvals/Inference/SentimentClassifier.swift:37). |
Inference: the app and the tests cannot drift on prompt or session setup. |
recallEvaluator, precisionEvaluator, SentimentEvaluator |
file private |
Visible only in the test file (CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:15, CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:18, CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:28). |
Inference: scoring helpers are local implementation details. |
VerdictView.color |
private |
Visible only inside VerdictView (CoreAIEvals/Views/VerdictView.swift:35). |
Inference: a presentation detail. |
The remaining app types are implicitly internal. The test target can see them only because it uses @testable import (CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:11). The sample declares nothing public or open.
Reference code
CoreAIEvals/Models/ClassifierModel.swift:34 — read-only lifecycle and private task handle
@Observable
final class ClassifierModel {
// ...
/// The current stage of the classification lifecycle.
private(set) var phase: Phase = .idle
/// Any in-flight classification task.
private var task: Task<Void, Never>?
// ...
}Logic ownership and placement
| Logic | Owning type or file | Placement rationale |
|---|---|---|
| Prompt instructions, session construction, guided generation | SentimentClassifier |
Observed: kept in one private place and called by both the app and the evaluation. Inference: one definition of “the classifier” for both. |
| Model location and one-time loading | CoreAIModelStore |
Observed: an actor with a shared instance. Inference: loading is expensive (the comment says so, CoreAIEvals/Inference/CoreAIModelStore.swift:5), so it is cached process-wide. |
| Availability gating for the default model | ClassifierModel.unavailableMessage |
Observed: only .onDeviceDefault is gated, through SystemLanguageModel.default.availability (CoreAIEvals/Models/ClassifierModel.swift:54). Core AI load failures surface as thrown errors instead. |
| Error-to-user-copy mapping | ClassifierModel.message(for:) |
Observed: maps LanguageModelError cases to strings (CoreAIEvals/Models/ClassifierModel.swift:109). The evaluation does not use this mapping. |
| Result rendering per phase | ResultView, VerdictView |
Observed: pure functions of the values passed in. |
| Metrics (accuracy, per-class precision/recall, confidence splits) and aggregation | SentimentClassificationEvaluation |
Observed: evaluators and aggregateMetrics(using:) (CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:62, CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift:98). |
| Ground-truth data | SentimentDataset.all |
Observed: an inline ArrayLoader<ModelSample<SentimentVerdict>> (CoreAIEvalsTests/SentimentDataset.swift:14). |
| Model export | scripts/prepare-model.sh |
Observed: outside the build. It runs uv run coreai.llm.export and writes to Models/coreai-model. |
Design patterns
| Pattern | Source evidence | Purpose or tradeoff |
|---|---|---|
| Service object | CoreAIEvals/Inference/SentimentClassifier.swift:12 (enum SentimentClassifier). The file abstract calls it “The classification service” and says it is “shared by the app and the evaluations” (CoreAIEvals/Inference/SentimentClassifier.swift:5) |
A stateless caseless-enum service gives the UI and the evaluation one shared seam. The tradeoff: its static methods cannot be swapped by injection. The backend enum is the only variation point. |
| Actor isolation | CoreAIEvals/Inference/CoreAIModelStore.swift:29 (actor CoreAIModelStore) |
Protects the cached model from data races. It does not deduplicate concurrent first loads (see Concurrency). |
| Binding-based state propagation | CoreAIEvals/Views/SentimentClassifierView.swift:21 (@Bindable var model = model) and the $model.text / $model.useOnDeviceDefault bindings |
The view edits model-owned state directly. ClassifierModel stays the source of truth. |
Removed from the draft: “Central store”. CoreAIModelStore is a load-once cache for one framework object, not a store of application state, and it has no dispatch or reducer path. The Store suffix describes its caching role only. The sample is not labeled MVVM. ClassifierModel does play a screen-model role, but the source has only one view and one model and declares no view-model abstraction.
Main application flow
sequenceDiagram
actor User
participant View as SentimentClassifierView
participant Model as ClassifierModel
participant Svc as SentimentClassifier
participant Store as CoreAIModelStore
User->>View: Classify (Cmd-Return)
View->>Model: classify()
Model->>Model: cancel old task, phase = .classifying
Model->>Svc: await classify(Prompt(text), using: backend)
Svc->>Store: await shared.model() (coreAI only)
Store-->>Svc: cached or newly loaded model
Svc->>Svc: session.respond(generating: SentimentVerdict)
Svc-->>Model: SentimentVerdict or error
Model-->>View: phase = .classified / .failed (observed)
Reference code
CoreAIEvals/Inference/SentimentClassifier.swift:56 — session per call, typed result
static func classify(
_ prompt: Prompt,
using backend: ClassifierBackend
) async throws -> SentimentVerdict {
let session = try await makeSession(for: backend)
let response = try await session.respond(to: prompt, generating: SentimentVerdict.self)
return response.content
}The evaluation path is the same from Svc onward. Evaluations calls subject(from:) for each dataset sample, and the evaluators score subject.value against input.expected.
Naming conventions
- Types: role suffixes match observed roles.
…Appis the entry,…Viewrenders,ClassifierModelholds UI state,CoreAIModelStorecaches (a cache, not a state store),SentimentClassifieris the service,ClassifierBackendis the choice enum,SentimentVerdictis the result value, and…Evaluation/…Tests/…Datasetare the test roles.CoreAIModelStoreErrorpairs its error type with its owner. - Protocols: no local protocol. Conformances use framework names (
Evaluation,LocalizedError). - Methods: verbs for commands (
classify(),classify(_:using:)),make…for construction (makeSession(for:)), noun-style async accessors (model()), andcan…for Boolean gates (canClassify).recallEvaluator(for:metric:)/precisionEvaluator(for:metric:)name what they build. - Enum cases: lifecycle states read as adjectives or past participles (
idle,classifying,classified,failed). - Files and extensions: one primary type per file, named after it. The
#if DEBUGpreview factory sits in an extension in the model’s own file, so it can reach theprivate(set)setter.
Architecture takeaways
- Put the model call behind one seam (
SentimentClassifier.classify(_:using:)) and have the UI and the evaluation both call it. Then the evaluation measures the shipping path, and comparing models is a one-enum-case change. - Use the guided-generation output type (
SentimentVerdict) as the evaluation’s expected type too. That way the schema, the UI, and the scoring share one definition. - Target-wide
MainActordefault isolation removes most annotations. But it creates a boundary with a test target that is nonisolated by default, and the sample handles that with an explicitnonisolatedmember. - A retained, cancellable
Taskplus a closedPhaseenum is enough lifecycle machinery for a single-request screen. The UI guard (canClassify), not task identity, is what prevents overlapping runs. - An actor makes a cache race-free, but not deduplicated. If you need single-flight loading, store the in-flight
Tasktoo.
Source map
| Source file | Relevant symbols |
|---|---|
CoreAIEvals/CoreAIEvalsApp.swift |
CoreAIEvalsApp |
CoreAIEvals/Views/SentimentClassifierView.swift |
SentimentClassifierView, @State model, @Bindable |
CoreAIEvals/Views/ResultView.swift |
ResultView |
CoreAIEvals/Views/VerdictView.swift |
VerdictView, color |
CoreAIEvals/Models/ClassifierModel.swift |
ClassifierModel, Phase, classify(), unavailableMessage, canClassify, message(for:), preview(text:phase:) |
CoreAIEvals/Inference/SentimentClassifier.swift |
SentimentClassifier, instructions, makeSession(for:), classify(_:using:) |
CoreAIEvals/Inference/CoreAIModelStore.swift |
CoreAIModelStore, shared, cached, model(), CoreAIModelStoreError |
CoreAIEvals/Inference/ClassifierBackend.swift |
ClassifierBackend, displayName |
CoreAIEvals/Inference/SentimentVerdict.swift |
Sentiment, SentimentVerdict |
CoreAIEvalsTests/SentimentClassificationEvaluationTests.swift |
SentimentClassificationEvaluation, SentimentClassificationEvaluationTests, recallEvaluator, precisionEvaluator |
CoreAIEvalsTests/SentimentDataset.swift |
SentimentDataset.all |
CoreAIEvals.xcodeproj/project.pbxproj |
Targets, SWIFT_DEFAULT_ACTOR_ISOLATION, TEST_HOST, CoreAILM package product, coreai-model resource |
scripts/prepare-model.sh |
HF_MODEL_ID, MODEL_NAME, export command |