On-device AI: keep wrong numbers out of your forms
Apple's on-device AI misread my mileage, then my app picked the wrong oil change. Field-level rules, an editable task, and the fallback the fix still misses. →
Software engineer · Málaga

On this page
Short answer: Apple’s on-device AI turned 12.300 km into 12.3 kilometers in my app. I gave explicit numbers and relative dates precedence over the model’s output, then made the proposed maintenance task editable. Reading the code for this article also exposed a fallback that still lets an unjustified task through.
The most annoying part wasn’t the mileage. While testing Rydger, my maintenance app for motorcycles and cars, I entered a generic oil change for a BMW F 850 GS. The app picked Fork oil. Then it showed me the task as fixed text, with no way to change it.
I could see exactly what was wrong. My own review screen wouldn’t let me review it.
This happened on November 5, on my English-language iPhone, while I was entering Spanish text and number formats. These are observations from that test, followed by code changes and regression fixtures. I don’t have a model accuracy benchmark or a measured failure rate.
The type was right; the mileage wasn’t
The one-line entry feature uses Apple’s Foundation Models to extract a Swift structure from what the rider types. With guided generation, Apple guarantees that the generated result fits the defined types. That’s useful. It doesn’t tell the app what the rider meant by a period.
In my input, 12.300 km meant twelve thousand three hundred kilometers. The model read it as twelve point three. It also failed to recognize plain oil as maintenance. This longer entry was misread too:
cambio de aceite en Motos Pepe 89,90 € a 15.000 km ayer
That’s an oil change at Motos Pepe for €89.90, at 15,000 km, yesterday. Nothing exotic. The phone’s language and the notation I use when writing simply don’t match.
A positive-number check won’t catch the mileage error: 12.3 is a perfectly good positive Double. A valid field can still contradict the input. The check has to reach back to the sentence, not stop at the generated type.
Explicit fields get their own authority
The first fix added OneLineEntryTextHints, a set of deterministic rules for the parts of the sentence the app can recognize directly. When a rule finds a value, it takes precedence over the model’s value. Here’s the amount field in OneLineEntryOnDevice.proposal:
let amount = OneLineEntryTextHints.amount(in: request.text)
?? positive(reading.amount)
The rules look for evidence attached to the number. Distance needs a recognized unit. An amount needs a euro marker. Otherwise the odometer reading could helpfully become the bill as well.
The numeric parser handles grouped thousands and decimal commas under the conventions used by this feature. Relative-day rules resolve words such as ayer and yesterday against the request’s reference day. When no rule matches, the model’s field remains available. Other information, such as the shop name, can still come from the model.
The regression test for the Motos Pepe sentence deliberately feeds the proposal function a bad model response: an expense, 15 km, and an amount of 8,990. It expects a service at 15,000 km for 89.90, yesterday, while keeping the vendor.
That bad response is synthetic. It isn’t a transcript captured from the phone. The test’s job is to hold the model wrong on purpose and check that the surrounding code corrects it. A lucky generation shouldn’t be able to make this test pass.
“Oil” is a match, not enough evidence for “fork oil”
The task-linking failure was more mundane. Several maintenance tasks contained oil, and the matcher took the first one. For the BMW in my test, that meant fork oil.
The fix makes the matcher consider specificity. A candidate naming a component the entry never mentions loses to a candidate without that extra requirement. A Spanish mention of horquilla can support the English task title Fork oil; a generic oil change can’t.
These cases come from the deterministic matcher’s regression test:
| Input | Available tasks | Expected match |
|---|---|---|
cambio de aceite | Fork oil, Final drive oil, Engine oil, Brake fluid | Engine oil |
aceite de horquilla | Fork oil, Engine oil | Fork oil |
cambio de aceite | Fork oil, Final drive oil | None |
The last row is the useful one to steal. Removing the reasonable candidate should not promote an unreasonable one. The helper returns nil and leaves a choice for the rider.
This is a product convention, not proof that every unspecified oil change means engine oil. The task remains a proposal. The next part of the feature has to treat it that way.
A review screen needs controls for the fields that can be wrong
The next change added a task picker to the service form when task options are supplied. It includes None. In the one-line entry flow, saving now resolves the link from the edited draft’s task title.
That last detail matters. Updating the label while saving the original link would leave the bug intact behind a nicer screen.
The form itself warns that a linked service marks the task done and resets the reference for its next due date. This isn’t just choosing a label for the history list. The proposed task carries meaning elsewhere in the app.
I had asked the rider to confirm a decision while withholding the control needed to change it. No improvement in extraction quality makes that a sensible interface.
The rules have limits, and one fallback still escapes them
Here’s where the neat “a regex fixed my AI” story breaks down. While checking the source for this article, an agent found a gap my incident note didn’t capture.
The deterministic matcher abstains when all the remaining oil tasks name unmentioned components. But the function combining that result with the model’s choice rejects an unjustified component only when a deterministic alternative exists. If the model picks Fork oil and the helper has no alternative, the model’s choice can survive.
That’s a finding from the current code, not another result I observed on the phone. It also means the table’s last row describes the helper, not the complete proposal function. The picker provides a way out, but the fallback still needs its own regression case.
There are less subtle limits too. A separator followed by three digits is treated as a thousands separator. That suits these odometer inputs; I wouldn’t copy the convention into an app that needs three decimal places. Job words and component names come from finite lists. Someone has to maintain them.
Start with a model response you know is wrong
The cheapest useful check is at the boundary that turns generated fields into a form draft. Take a real failing input and inject the wrong answer there. This is adapted from Rydger’s regression test and uses its project types:
let request = OneLineEntryRequest(
text: "oil 60 € a 12.300 km",
now: Date(timeIntervalSince1970: 1_793_900_000),
locale: Locale(identifier: "en_US"), currency: "EUR",
odometerKm: 12_000, items: ["Oil change"]
)
let wrong = OneLineEntryReading(
kind: "expense", odometerKm: 12.3,
amount: 60, category: "maintenance"
)
let result = OneLineEntryOnDevice.proposal(from: wrong, request: request)
#expect(result?.odometerKm == 12_300)
#expect(result?.kind == .service)
Then remove the good task candidate. With only Fork oil available, feed a generic oil change and a model response that insists on linking it. Test the final proposal, not just the helper. That’s the missing case in my implementation.
Sources
- Apple, Foundation Models, read November 8, 2026, for the scope of guided generation.
- My November 5 device tests and the private Rydger repository:
OneLineEntryTextHints, the proposal logic inOneLineEntryOnDevice, andServiceRecordForm. An agent checked the article’s claims against the current code. The cited tests were read, not rerun for this article; this is not a measurement of post-fix model accuracy.
Related reading: the checks around a model that keep its mistakes from becoming your app’s behavior.
P.S. Which AI-filled field in your app is hardest for a user to correct? Tell me on LinkedIn.


