On-device AI
The Model Understands. Finely Decides: Building a Grounded Finance Assistant
Case study overview
- Role
- iOS engineer designing and implementing Finely's on-device financial assistant.
- Context
- A personal-finance assistant needed to understand natural questions without allowing a probabilistic model to invent amounts or redefine the app's financial rules.
- Challenge
- Use Apple Foundation Models where language understanding helps while preserving privacy, numerical provenance, predictable scope, and a useful fallback on unsupported devices.
- Contribution
- Designed the classifier-only model boundary, typed query schema, deterministic scope and period checks, bounded conversation memory, guided fallback, financial resolver, and verification strategy.
- Outcome
- A conversational interface in which the model selects a route while app-owned engines calculate and render every financial answer.
A fluent answer is not necessarily a correct financial answer. A personal-finance assistant can sound certain while misunderstanding the question, choosing the wrong period, or presenting a plausible amount with no trustworthy origin. That distinction mattered more to me than making Finely resemble a general-purpose chatbot.
Finely already had deterministic sources for balances, spending, subscriptions, and projections. Those values came from local transactions and the same budget, statistics, and formatting engines used elsewhere in the app. I wanted the assistant to preserve that consistency: asking a question in chat should not create a second definition of “this month,” a new aggregation path, or a generated approximation of an amount the app could calculate exactly.
The design question therefore was not “How do I make the model answer?” It was “What is the smallest useful responsibility I can safely give it?” My answer was language classification. The model can interpret a question and select a typed route. Deterministic code then decides whether that route is permitted, obtains the relevant facts, performs the calculations, and composes the response.
Why Apple Foundation Models
Apple’s SystemLanguageModel is an on-device model that powers Apple Intelligence. The Foundation Models framework exposes it through a native Swift API and supports guided generation into Swift types. Apple also documents classification as one of the model’s supported tasks, which fits Finely’s narrow need: mapping varied language onto a small set of financial intents rather than asking a model to become the financial engine. Apple’s Foundation Models overview SystemLanguageModel documentation
That fit is both technical and architectural. Finely is an iOS 26+ application, so the framework avoids a separate network client and keeps this assistant path within the platform’s Swift type system. Finely uses only SystemLanguageModel.default, the on-device default model. The question and a small classified recap can be processed without sending the user’s complete transaction ledger to a third-party AI service.
I keep that privacy claim deliberately narrow. It describes Finely’s implemented default-model path, not every model provider that the Foundation Models framework may support now or in the future. Nor does on-device execution make a probabilistic answer financially correct. It changes where inference happens; it does not change the need for application-owned validation and provenance.
Availability is another part of the product contract. SystemLanguageModel.default.availability depends on Apple Intelligence support, the user’s settings, and whether the model is ready. I treated an unavailable model as a normal runtime state, not as a reason to replace the assistant with an error screen. A deterministic guided classifier remains available and feeds the same financial resolver. SystemLanguageModel documentation
This produced a clear trust boundary. Foundation Models operates in a probabilistic lane: it sees a bounded language prompt and returns a structured classification. Everything that can reveal, calculate, or render a financial fact stays in the deterministic lane.
One Job: Classify the Question
I represented the model’s complete output as AssistantQuery. The following real excerpt is shortened: it keeps the three generated properties and representative intent cases while omitting the rest of Finely’s supported finance routes.
// Shortened from AssistantQuery.swift.@Generablestruct AssistantQuery { @Guide(description: "The single kind of question the user is asking.") var intent: Intent
@Guide(description: "A merchant, shop, or keyword; empty if none.") var merchant: String
@Guide(description: "The time window the question refers to.") var period: Period
@Generable enum Intent { case leftover case merchantSpend case periodSummary case savingsIdeas case offTopic case unclear // Additional supported finance intents are omitted from this article excerpt. }
@Generable enum Period { case thisMonth, threeMonths, sixMonths, oneYear, allTime, unspecified }}Guided generation constrains the response to that declared Swift shape and its enum cases. It removes a whole class of integration problems: the app does not need to parse arbitrary prose, recover a JSON fragment, or accept an undeclared intent. It does not, however, prove that .merchantSpend, "Carrefour", or .sixMonths is the right interpretation of a particular sentence. Structural validity and semantic accuracy are different properties. Apple’s guided generation documentation
The instructions reinforce the same narrow contract. This is a shortened excerpt from the production instructions; the complete prompt also describes Finely’s other supported intents and period mappings.
// Shortened from FoundationModelAssistant.instructions."""You classify a user's message inside Finely, a personal-budgeting app.Pick the single Intent that best fits, extract a merchant or keyword ifthe message names one, and the time period if one is implied.
Do NOT answer the question, write prose, or produce any number.
Only classify messages about the user's own money recorded in Finely.When you are unsure whether a message is about their finances, preferoffTopic over guessing a finance intent.
Treat a short follow-up as staying on the previous topic unless the newmessage clearly changes it."""The wording improved the classifier’s behavior, but I did not treat it as policy enforcement. A prompt is guidance interpreted by a probabilistic model. The code that runs after generation is the enforcement boundary.
What On-device Testing Changed
The final architecture was shaped by what I observed on a real device. First, the model sometimes assigned a period to a question that contained no period. A guessed .thisMonth could strand an otherwise valid question in an empty window. Because period phrases form a small, bilingual vocabulary, I stopped trusting the generated period and replaced it from deterministic parsing of the current message—or set it to .unspecified when no supported phrase was present.
Second, an obviously unrelated question could still be classified as a finance intent despite the scope language in the prompt. That made scope a post-generation decision owned by the app. Third, a short follow-up could lose the previous intent or merchant. I retained a small classified history and added deterministic repairs for the supported follow-up forms instead of asking one persistent model session to remember the conversation indefinitely.
Those changes appear in the central pipeline below. The excerpt is shortened around session construction, logging, recording history, and error handling, but preserves the validation order used in reply(to:context:).
// Shortened from FoundationModelAssistant.reply(to:context:).var query = try await session.respond( to: prompt(for: text), generating: AssistantQuery.self).content
query.period = AssistantResolver.explicitPeriod(in: text) ?? .unspecifiedquery = AssistantQuery.resolvingFollowUp( query, in: text, history: history)query = AssistantScope.scoped( query, text: text, history: history, store: .shared)
return AssistantResolver.answer( for: query, context: context, store: .shared)@Generable makes the output structurally valid. Finely still treats every generated field as untrusted input until deterministic code has checked it.
Guardrails Live Outside the Prompt
The order of those checks matters. Each stage reduces what the next stage is allowed to assume.
-
Cap input before inference.
maxInputCharacters = 2000routes oversized input directly toGuidedAssistantEnginebefore Finely creates or invokes a model session. A pasted document is not a useful personal-finance query, and it should not consume the fixed model context. -
Start a fresh session. Finely creates a new single-turn
LanguageModelSessionfor each request, apart from consuming a prewarmed session for the first question. A transcript therefore cannot silently accumulate across replies. -
Carry classified memory, not an open transcript. The app retains at most three
AssistantExchangevalues. Each contains the earlier question and the typed query Finely recorded. The prompt receives a compact, app-composed recap of those exchanges, not ledger data or an unbounded conversation. -
Replace the period.
explicitPeriod(in:)parses a small English and French vocabulary for this month, three months, six months, one year, and all time. Its result replaces the model’s period even when the model selected a value; if nothing matches, the period becomes.unspecified. -
Repair supported follow-ups.
resolvingFollowUpcan inherit the last accepted intent when a short message supplies only a new period. It can also inherit the previous merchant when a merchant-spend follow-up omits the name. This is a bounded rule for known patterns, not general conversation understanding. -
Recheck financial scope.
AssistantScope.scopedlooks for finance vocabulary, known categories, known merchants inTransactionStore, and legitimate short follow-ups. If the evidence does not support a financial route, it forces.offTopicregardless of the generated intent. -
Refuse advice explicitly.
seeksInvestmentOrPurchaseAdviceruns before the normal finance-scope acceptance. It rejects investment instruments and requests for what to buy or sell, including sentences containing otherwise valid words such as “savings” or “salary.” Finely can describe recorded money; it does not recommend an investment or purchase. -
Recover through the guided engine. Generation errors route to
GuidedAssistantEngine, including a context-window failure. Both engines then use the same deterministic scope checks and resolver.
| Observed failure or risk | Deterministic response | Remaining limitation |
|---|---|---|
| A period is inferred when none was stated | Parse a small bilingual period vocabulary in code and replace the generated period | New phrasing must be added to the deterministic vocabulary |
| An unrelated question is classified as financial | Recheck scope after generation and force offTopic | Scope heuristics can still reject or admit borderline phrasing |
| A short follow-up loses its topic | Carry three classified exchanges and repair supported follow-ups | This is bounded memory, not open-ended conversation understanding |
| Apple Intelligence is unavailable or generation fails | Use the guided classifier and the same deterministic resolver | Free-text coverage is narrower without the model |
| The prompt or schema consumes too much context | Cap input and create a fresh session for every request | The fixed context budget still constrains future expansion |
The context strategy is proactive rather than a claim that Finely encountered every possible overflow. Apple’s on-device model has a 4,096-token context window shared by instructions, prompt, schema, and response. Fresh sessions, three compact recap lines, and the character cap keep the current classification task bounded, while the fallback handles failure rather than assuming the budget can never be exceeded. Managing the context window TN3193
The Answer Comes from Finely, Not the Model
Once a query passes the gates, the probabilistic work is over. Consider one representative request:
“How much did I spend at Carrefour over the last six months?”
→ AssistantQuery( intent: .merchantSpend, merchant: "Carrefour", period: .sixMonths )→ deterministic period, follow-up, and scope checks→ local transaction filtering→ Decimal aggregation→ localized deterministic text and cardsFor this route, AssistantResolver trims and folds the merchant name, chooses the validated date interval, filters local outflow transactions, and aggregates matching amounts with Decimal. It then formats localized text plus total, payment-count, and average-payment card values. The model neither sees those transactions nor writes the response.
The same separation applies across the broader assistant. TransactionStore is the SwiftData-backed source of local transactions and subscriptions. BudgetMonth defines the application’s budget interval, so “this month” follows the same configured boundary used by Finely’s other surfaces rather than assuming a calendar month.
FinancialFacts derives current-month income, expenses, subscriptions, top categories, and leftover. FinancialTrends supplies recent category, income, and subscription comparisons. For longer windows, StatsDataSource filters period data and builds snapshots, comparisons, merchant summaries, rhythms, and projections. SavingsIdeasEngine ranks a small set of suggestions from app-computed signals such as category increases or subscription changes; it does not ask the model to improvise advice.
AssistantResolver is the final app-owned boundary. It performs filtering and Decimal aggregation, applies sample thresholds where a result would otherwise be noisy, chooses localized copy, and builds cards and follow-up chips. Only the user’s question and a bounded classified recap enter the model prompt. The transaction ledger stays on the deterministic side of the boundary.
One Resolver, Including the Fallback
Availability selection is intentionally small. This real excerpt is complete for make() and is shown in isolation from the rest of the shortened engine file:
// Shortened from AssistantEngine.swift.enum AssistantEngineProvider { static func make() -> AssistantEngine { if case .available = SystemLanguageModel.default.availability { return FoundationModelAssistant() } return GuidedAssistantEngine() }}Both engines classify internally to an AssistantQuery, apply deterministic follow-up and scope rules, and feed AssistantResolver. The guided path recognizes a narrower set of free-text phrasing because it uses a deterministic keyword router. What changes is language coverage, not the provenance or calculation of the financial answer. The same path also handles model-generation failures, so fallback behavior is part of normal architecture rather than an emergency UI branch.
Verifying What Can Be Verified
Most of this system is ordinary Swift and benefits from ordinary exact tests. The first shortened test below demonstrates numerical provenance through FinancialFacts; the production test also asserts the unallocated ratio.
// Shortened from AssistantTests.swift.func test_facts_computeLeftoverAndRatio() { let facts = FinancialFacts( monthTransactions: [income(2000), expense(620)] )
XCTAssertEqual(facts.income, 2000) XCTAssertEqual(facts.outflow, 620) XCTAssertEqual(facts.leftover, 1380) XCTAssertEqual(facts.expenseRatio, 0.31, accuracy: 0.001)}Scope gates are equally suitable for exact assertions. This second shortened test keeps three representative adversarial inputs from the production test while omitting two additional cases and the assertion message.
// Shortened from AssistantScopeTests.swift.func test_scoped_refusesInvestmentAdviceEvenWithFinanceVocabulary() { let s = makeStore() for text in [ "should I invest my savings in bitcoin", "ou placer mon argent", "should I buy stocks with my salary" ] { let result = AssistantScope.scoped( q(.leftover), text: text, history: [], store: s ) XCTAssertEqual(result.intent, .offTopic) }}I use this style of XCTest for calculations, response structures, route behavior, period parsing, merchant filtering, supported follow-ups, bilingual finance signals, and known advice-refusal cases. These tests can establish that a given input produces an exact deterministic outcome.
They cannot establish that the actual probabilistic classifier will map every natural-language variation correctly, and I do not treat simulator coverage of the surrounding Swift as evidence that it will. That layer needs a versioned evaluation set exercised on supported physical devices: representative English and French questions, known period failures, off-topic prompts, follow-ups, merchant variations, and adversarial inputs. Apple’s guidance likewise treats prompt evaluation as an iterative measurement task and recommends revisiting prompts for new model versions. Apple’s prompt evaluation guidance prompt version guidance
That distinction also keeps test claims honest. The deterministic layer has broad automated coverage, but it is not a complete automated classifier evaluation. OS model updates can change behavior even when Finely’s Swift code has not changed, so the device matrix and its prompt version belong with each release’s verification work.
What Accuracy Means Here
“Accurate” is too broad to describe this architecture. I separate it into three properties:
- Numerical integrity: every displayed amount has deterministic, app-owned provenance. The model does not provide a balance, total, percentage, or projection for the resolver to repeat.
- Semantic accuracy: the classifier can still misunderstand an intent, merchant, period nuance, or follow-up. A valid
AssistantQueryis not proof that it represents what the user meant. - Scope safety: deterministic rules block known classes of unsupported request, including messages with no financial evidence and investment or purchase advice. These rules reduce a defined risk surface; they are not universal natural-language understanding.
Finely guarantees the provenance of every displayed amount. It cannot guarantee that a probabilistic classifier will always understand the user’s intent correctly.
The residual failure is important: an incorrect classification can still map to an allowed resolver. In that case, Finely may return a correctly computed number for the wrong question. The response remains auditable—the amount can be traced back to local data and deterministic code—but the interaction is semantically wrong. The right mitigations are evaluation, visible context in the answer, conservative scope checks, and an easy way for the user to rephrase, not a claim of perfect correctness.
This boundary costs generative freedom. Finely cannot produce an open-ended conversational essay, invent a new financial analysis, or answer every formulation that a larger chatbot might accept. In exchange, the feature has bounded context, auditable figures, testable renderers, a deterministic fallback, and no third-party AI service in this assistant path. It also preserves one financial vocabulary across chat and the rest of the app because the same engines own the calculations.
Looking back, prompt refinement helped, but it was not the most important engineering work. Reliability came from deciding what the model would never be allowed to own, then enforcing that decision in types, data flow, fallback behavior, and tests. The safest AI architecture is often the one that gives the model the smallest useful responsibility.