An on-device model should not leak its tensor names and shapes through a SwiftUI view. A view wants an application value, such as a score, a label, or a generated next step. A Core AI model wants an NDArray whose name, scalar type, and shape exactly match the exported function. Keeping those concerns together makes model updates and UI changes independent.
Core AI gives the app three main runtime types: AIModel loads the specialized model asset, InferenceFunction owns the resources needed to execute one graph, and NDArray carries multidimensional scalar data. Treat the first two as feature-level infrastructure and make one small type responsible for translating app data into the function’s inputs.
Inspect the function before writing the adapter
Open the .aimodel in Xcode before writing Swift code. Its function signature is the source of truth for input names, output names, shapes, and dynamic dimensions. At runtime, functionDescriptor(for:) provides the same information for an AIModel.
This is more reliable than carrying dimensions through comments or assuming the Python module name becomes the Swift function name. If conversion changes the interface, the adapter is the one place that should need to change.
Load the runnable function once
AIModel is responsible for loading a model asset. The InferenceFunction loaded from it holds model weights and intermediate buffers, so it is the value to keep with a feature that runs repeatedly. Do this outside a button action or a body evaluation.
import CoreAI
struct ActionPredictor {
private let function: InferenceFunction
init(modelURL: URL) async throws {
let model = try await AIModel(contentsOf: modelURL)
guard let function = try model.loadFunction(named: "main") else {
throw PredictorError.missingMainFunction
}
self.function = function
}
}The constructor is asynchronous because loading can include specialization work for an unspecialized asset. The feature layer should represent that preparation state explicitly rather than blocking an interaction that already needs an answer.
Translate app values into one named input
Build an NDArray with the scalar type and shape required by the function, fill it from already validated app data, then run inference using the exported input name. Extract the named output defensively. A missing or unexpected output is a model integration error, not a condition for the view to improvise around.
extension ActionPredictor {
func logits(for features: [Float]) async throws -> NDArray {
let input = NDArray(
scalars: features,
shape: [1, features.count]
)
var outputs = try await function.run(inputs: ["features": input])
guard let logits = outputs.remove("logits")?.ndArray else {
throw PredictorError.missingLogits
}
return logits
}
}The code is deliberately unglamorous. It says exactly which app data becomes the model input and exactly which value is expected back. Higher-level code can turn the returned tensor into an enum, confidence score, or domain result without learning the model's storage layout.
Separate model failure from feature failure
A model can be absent, incompatible, still preparing, or unable to produce a usable output. Those states should map to a feature decision. Some products can disable an optional intelligent action. Others can use a deterministic fallback or let the person retry later.
Avoid treating every error as a generic alert. Log enough context to distinguish asset availability, model loading, input preparation, and inference. These stages have different owners and different fixes.
Add lower-level control only after measuring
InferenceFunction also supports passing state and output views, and it can encode asynchronous work onto a compute stream. Those APIs are valuable for recurrent loops or a tightly coupled compute pipeline. They are not the right starting point for a feature that performs occasional single inference calls.
Begin with the straightforward loading and run(inputs:) path. Profile the real feature, then move allocations, layouts, or scheduling only when the trace identifies them as a cost.
Key takeaway
Core AI integration stays maintainable when the model boundary is narrow. Load the function once, own tensor conversion in one adapter, validate named outputs, and expose domain values to the rest of the app.
