Russell Bloxwichfield notes ·
  • On-Device AI
  • LLM Evaluation
  • AI Agents
  • TypeScript
  • Software Architecture
On-device AI in Gratin

Picking a Language Model Small Enough to Live on Your Phone

Gratin has one promise: your recipes never leave your device. The obvious next features all cut against it. Estimate the nutrition of this recipe. Auto-tag it. Clean up this messy paste. Every one is an AI feature, and the easy way to build an AI feature is to call a frontier API, which means sending your dinner to a datacenter. That breaks the promise.

So I asked a smaller question. Can I run a language model on the device instead, and would it be good enough to ship?

The tasks help. Nutrition estimates, tags, and extraction are background jobs. Nobody is watching a cursor blink. I can take three seconds, or thirty, to fill in a nutrition estimate after an import. The outputs are tiny, a JSON object with seven numbers or a few tags. That matters more than it sounds. A small KV cache and no need for interactive speed are exactly the two things that make on-device models painful, and this workload needs neither.

But "the tasks are forgiving" isn't the same as "it works." I had three open questions, and I wanted each one settled with measurement rather than assumption. How big a model actually fits on a phone. Which model is best at my specific tasks. And, the one that turned into a research project of its own, what I grade a nutrition model against when there's no such thing as the true nutrition of a recipe.

How big a model fits on a phone

There are a lot of confident, wrong answers to this online, so I didn't want to rely on any of them. I ran it as an automated research pass instead: a fan-out of agents pulled sixteen sources and extracted twenty-five specific claims, then a second set of adversarial agents tried to refute each claim against primary documentation. Eighteen survived, seven were refuted, and some of the seven were things I'd have built against.

The big one: iOS has no fixed per-app memory limit. People design against a number, 1.5GB or 3GB, whatever they read once. There isn't one. The limit moves with the device and the moment, and when you cross it the kernel kills your app. The only reliable way to know your budget is to ask the device at runtime with os_proc_available_memory(). In practice, on an 8GB iPhone you get about 4 to 5GB before you're playing with fire.

What fits under that budget comes from real quantized model files, not the back-of-envelope math. The usual "half a byte per parameter" rule of thumb is wrong: real 4-bit weights are closer to 0.6, and runtime memory runs higher still because of the cache and compute buffers.

Peak RAM (Q4_K_M) vs the usable budget on an 8 GB iPhoneweights + KV cache + compute buffers · practical budget ≈ 4-5 GB before the jetsam killerbudget 4-5 GB02 GB4 GB6 GB3B2.1 GBample headroom4B2.8 GBcomfortable7B5.5 GBover budget
The 3-4B sweet spot, from the memory side. A 7B model consumes essentially the entire usable budget on an 8 GB iPhone, which is impractical. This was the first, independent reason to cap at 4B, before a single accuracy number was measured.

Two more findings settled early. Small models aren't bad at JSON. With grammar-constrained decoding the output is guaranteed to parse and match the schema, whatever the model. What varies is whether the numbers inside are right. And the engine picked itself: llama.cpp. Metal on iOS, Vulkan on Android, one core across the desktop and mobile builds, and grammar constraints built in. The engine ships in the binary; the weights download afterwards to a folder that doesn't get backed up.

So the target is 3 to 4B. That's the answer to "how big," but it says nothing about which model. "Newest is best" is a hypothesis, not a result, and I wanted to validate it against the actual tasks.

The harness

The obvious shortcut is to trust a public leaderboard. MMLU, IFEval, the Hugging Face rankings. None of them measure "estimate the fat in a Danish soup from its ingredient list," which is the job. So I wrote a small test harness that runs candidate models against Gratin's actual tasks and scores four things: is the answer correct, is it valid structured JSON, how fast is it, and does it fit in the device budget.

Two decisions in the harness mattered later. First, it's honest about what it can't measure. Speed runs on my Mac, so it's a proxy for phone speed and the reports say so; correctness and validity are the same on any hardware, so those are real. Second, it's resumable. A full run is every model times every prompt times every task times every example, which is thousands of generations and hours of wall time. So it writes each result to disk the moment it finishes, and on restart it skips everything that's already done. That sounds like a nicety. It's the difference between a benchmark that finishes and one you give up on at hour three when the SSH connection drops. A model with no weights downloaded yet just gets skipped, and you fill it in later.

Now I had a grader. Which exposed the harder problem: what do you grade it against?

There is no true nutrition number

This took more time than anything else in the project. There is no ground truth for recipe nutrition. Not "it's hard to find." It doesn't exist. A recipe is a list of ingredients, and its nutrition is a calculation over portion sizes and food-database matches, and every professional source makes different calls at every step. Scoring a model's estimate means first deciding what the right answer is, and that decision was the real work.

The approach that held up was to stop trusting any single source: compute the nutrition several independent ways, and keep the number only where they agree.

Three sources vote on every recipe

The three-way consensus pipelineAuthor’s publishedscraped from the recipeUSDA recomputeblind, from ingredientsEdamam APIcommercial engineConsensus±15% gate,anchored on caloriesAll agree → trust~83% · high-confidencegoldTwo vs one → suspect~7% · gold likely low,mostly fatty meatScatter → keep, label~10% · irreduciblyuncertain
Three independent estimates per recipe flow into one gate. A row is marked suspect only when two independent sources concur against it. A single disagreement never flags anything, because dropping on one source would delete fatty-meat recipes wholesale, since that is where published nutrition runs low, and bias the benchmark against its highest-variance category. Only a small, well-corroborated fraction is removed.

Making the sources disagree surfaces errors that a "just scrape the author's number" pipeline swallows whole. A bulgogi whose published nutrition was for skin-on chicken, when the recipe called for skinless. A Danish soup whose published fat counted the stock meat, the meat the recipe strains out and discards. These aren't typos. A single source can't catch them; two independent sources disagreeing can.

Scaling past the initial 182-recipe pilot meant a public dataset of 14,000, which was filthy the way scraped data always is. The first filter is the Atwater check. A recipe's calories should be about 4·protein + 4·carbs + 9·fat; if the published calories are far from what the macros imply, the row is internally inconsistent and gets dropped. It runs on the row's own numbers, with no external lookup. 94.8% survived.

Two professional nutrition engines, given the same recipe, routinely disagree by 15 to 25%. There is no single true number. The nutrition of a recipe is a range, not a value, and a benchmark that treats it as a value is really measuring how closely a model reproduces one source's arbitrary choices.

That spread also sets the scoring tolerance. Because the sources themselves disagree by up to 25%, a prediction counts as correct when it lands within ±25% of the reference. Scoring tighter than the reference data's own disagreement would just be measuring noise.

So the reference set, the gold that every model is scored against, is tiered by confidence instead of collapsed to a single answer. A row is dropped only when two independent sources agree it's wrong; one source is never enough. Dropping on a single source would delete fatty-meat recipes across the board, since that is where published numbers run low, and bias the benchmark against its highest-variance category.

The benchmark fixture13,675 rows cleared the Atwater consistency gate; consensus then tiered them by confidence12,719 high
12,719 high, three-source concordant952 medium, cleared the gate4 confirmed-bad, droppedOnly 4 rows were confirmed bad and dropped, because the two-source rule is deliberately conservative about what it deletes. The result: 13,671 recipes, each carrying its provenance and its reasoning, as the yardstick every model is measured against.

Which model wins

With the corpus and harness in place, the model comparison could run. I tested nine models in the 2 to 4.5B range, in two passes: a broad first pass over all of them, then a deeper second pass over the top five at 100 examples per task for a ranking I trust.

Model leaderboard: overall accuracyqwen3.5-4b4B · 100% valid · 26 tok/s · 3.2 GB49%ministral-3-3b3B · 100% valid · 41 tok/s · 2.7 GB46%phi-4-mini3.8B · 99% valid · 37 tok/s · 3.2 GB43%granite-4.0-micro3B · 100% valid · 35 tok/s · 2.6 GB40%smollm3-3b3B · 100% valid · 42 tok/s · 2.4 GB36%
Deep pass · 100 examples/task · the reliable ranking. All values ✅ fit an 8 GB iPhone. In the 25-example broad pass Ministral “won” at 54%; at 100 examples Qwen3.5-4B pulls ahead and Ministral drops to second. The small-sample lead was noise, which is exactly why the design is tiered. And validity is solved at this tier: every top model is 99-100% schema-valid under grammar constraint.

Bigger doesn't help

A wider sweep, from 0.5B up to 7.6B, shows the shape plainly: accuracy climbs to about 3B and then flattens. A 3.8B model tied the 7.6B model outright, on a budget it fits comfortably in. Two independent reasons not to ship 7B, then. It doesn't fit, and it wouldn't have helped if it did.

Accuracy vs model size: the plateauAccuracy plateaus at 3-4Bplateau02B4B6B8B0%20%40%model size (parameters)gemma-2 (harness bug, 0%)twice the size, identical scorephi-3.5 · 3.8B · 42%qwen2.5 · 7.6B · 42%
Accuracy climbs steeply to ~3B and then flattens; the 7.6B model doesn’t beat the 3.8B one. The hollow point is gemma-2 scoring literal zero, from a chat-template and grammar-decoding bug in the harness, not a verdict on the model. A 0% is almost always a bug, not a result.

The prompt barely matters

Sweeping several system prompts per task, baseline, detailed, strict, and a dietitian persona for nutrition, moved the score by 2 to 3 points. Swapping the model moved it by over 40. So the effort went into model selection, and prompt tuning stayed a footnote.

Where the accuracy actually comes fromModel choice~42 ptsPrompt choice~3 pts
best-vs-worst spread across the same tasks. Model choice moved accuracy by more than 10× what prompt choice did. The best prompts here were dietitian for nutrition and strict for tagging, but the gap sits inside the noise.

The model does almost none of the work

Nutrition reads like a language task, which points at the best small language model. It doesn't work out that way.

RAG is the obvious next step: give the model real USDA numbers instead of making it recall nutrition from training. Two versions got tested. In the naive one, the model gets the reference numbers and does the math itself. That was unsurprisingly a disaster. Hand a small model a wall of per-ingredient numbers and ask it to multiply each by a portion, scale by servings, and add them up, and it drowns. It's never going to be as good as a calculator at arithmetic. One model dropped from 40% to 24%. In a gentler version, a calculator does the math and the model only reviews the result and corrects obvious errors. That rescued the weak models, but did almost nothing for the strong one at the top.

The anchor rescues the weak, shackles the strong23%52%qwen3.5-2bweak · rescued27%52%nanbeige4.1-3bweak · rescued53%43%qwen3.5-4bstrong · slightly hurt
bare+ hybrid-anchor RAGA “hybrid-anchor” RAG hands the model a pre-computed estimate to review, never to do the math. Weak models lean on the anchor and leap; a strong model estimates better holistically than by patching an imperfect anchor, so it slips. Model-side RAG is a dead end for the model you’d actually ship. The right move is to take the arithmetic out of the model entirely.

Either way, feeding reference data into the model was a dead end. The fix points the other way: take the arithmetic out of the model, not put more into it.

Compose the number

The alternative is to ask a model for one number per recipe. That can work, but it's the less reliable option: the same recipe can come back with slightly different totals on different runs, and there's no way to see which ingredient drove the result.

Composing the number is steadier. Nutrition as a sum of per-ingredient contributions is deterministic (the same recipe always sums to the same total), explainable (every number traces to an ingredient), and it updates cleanly on an edit: change one ingredient and one term in the sum changes. That reliability, more than any single accuracy number, is why the pipeline composes the answer instead of asking for it.

The model is the smallest component

The compositional nutrition pipelinefor each ingredient lineparse → gramsUSDA portion weightsconfident match?quality-aware lookupconfident ~79%USDA per-100g rowdeterministic · instantambiguous ~21%model makes a judgmentwhich variant · how much is eaten· per-100g for gaps · cachednutrition = Σ (grams / 100 × per-100g) ÷ servingspure deterministic arithmetic · re-sums instantly when any ingredient changes
Deterministic USDA lookup handles the ~79% of ingredient lines that match confidently. The model is used only for the ambiguous rest, and only for a bounded judgment: which food variant this is, how much is actually eaten (frying oil, non-foods), or a per-100g estimate for a genuine gap. Its answers are cached per ingredient, and the arithmetic stays deterministic, so nutrition recomputes the instant you hit edit.

So the deterministic calculator does the math, and it's already the better answer. The compositional pipeline edges past the best bare model on accuracy, and unlike the model it updates the instant you edit a recipe.

Nutrition accuracy: calories within ±25% of goldthe deterministic pipeline beats the best bare model, and stays editableBest bare model (qwen3.5-4b)53%Naive deterministic50%Deterministic pipeline59%
Best bare model (qwen3.5-4b) · not editableaccurate, but a black box that can't be editedNaive deterministic · editableMyFoodData + token matchDeterministic pipeline · editableUSDA whole foods, quality-aware matching, real portion weightsThe deterministic pipeline edges past the best bare model, and it's editable, which the model's single opaque number can never be. That's the base to build on. It still has a fat tail of errors, which the next figures pull apart and fix.

That's the base to build on, not the finish line. Because the pipeline is deterministic, its errors can be read off field by field against the corpus.

Where the errors actually landsigned % error of the deterministic pipeline vs gold, per field · green = within ±25%Calories59% within ±25 · median 18%−100%0+200%Fat59% within ±25 · median 17%−100%0+200%Carbs54% within ±25 · median 22%−100%0+200%Protein48% within ±25 · median 28%−100%0+200%
Every field piles up in the ±5% bucket, then tails off in both directions. Calories and fat land inside ±25% about 59% of the time; protein is the worst at 48%, because fatty-meat protein is exactly the case the gold itself disagrees on. The errors are roughly balanced between over and under, so nothing is systematically biased high or low.

Fixing the fat tail

About 59% of calorie estimates land within ±25%, and the tail is heavy. A recipe app that's wrong on two recipes in five isn't shippable.

The tail isn't a smooth haze. It's four specific bugs.

The tail isn't a haze, it's four systematic bugswhere the deterministic pipeline's big misses actually come from1 Cooking media counted as eaten~27 recipes2 Count → gram bugs~72 recipes3 Variant mismatcheseither direction4 Bad goldgold too low
1 Cooking media counted as eaten · ~27 recipes"oil for frying" summed in fullovercounts2 Count → gram bugs · ~72 recipes"8 slices pepperoni" → 952 govercounts3 Variant mismatches"milk" → dried powder; plant-milk → nutseither direction4 Bad goldESHA undercounts fatty meatsgold too lowThis matters because it says what to fix. The errors aren't random noise you'd need a better model to smooth out. They're a handful of specific, nameable mistakes. And you can't just blocklist the obvious ones: a non-food stoplist backfires, because "1 jar marinara sauce" is real food. Whether an ingredient is eaten is a judgment call, which is exactly the kind of thing a model is good at.

The fix follows straight from the diagnosis. Three of the four buckets are judgment calls, not arithmetic: is this the whole-milk row or the powdered-milk row, is the frying oil actually eaten, is "1 jar marinara" a real ingredient or a non-food token. So I let the model make exactly those judgments, per ingredient, and kept the arithmetic deterministic. Confident matches skip the model entirely. Only the ambiguous lines, about a fifth of them, get a bounded question: which variant, how much is eaten, or a per-100g estimate for a genuine gap.

I call it LLM-resolve. On a 40-recipe sample, half known catastrophes and half random, it lifted calories within ±25% from 68% to 75%, and all four macros from 25% to 40%. The model touched about a fifth of the ingredient lines.

LLM-resolve pulls the worst misses toward gold-75-50-250+25+50+75% error vs goldButtermilk Fried ChickenChicken ParmesanBlackened TunaPB Banana SmoothieGorgonzola Flat IronsDubai Chocolate TrifleGnocchi Mac & CheeseBlueberry PiePotato SoupPork Noodle CasseroleWhite Chocolate Flan
calories ±25%: 68% 75% · all-4 macros: 25%40% · model touches ~21% of lines deterministic (before) LLM-resolve, closer to gold LLM-resolve, no better green band = within ±25%Most of the catastrophes collapse toward zero: the fried chicken stops dropping the batter and frying oil, the smoothie stops double-counting. A few don't move (the casseroles the model left alone), one overcorrects past the target (the chicken parmesan), and one drifts slightly wider. It's not magic. It fixes the nameable mistakes and mostly leaves the rest.

The ceiling is the gold, not the model

Seventy-five percent sounds low, but there's no true answer to measure against. Two professional nutrition engines only agree with each other about 83% of the time. Against a gold that noisy, 75% is close to the ceiling, and the gap that's left is a mix of real pipeline error and the gold disagreeing with itself.

The ceiling is the gold, not the modelcalories within the ±25% toleranceceiling ~83%~75%0%50%100%
reached by the pipelineheadroom to the ceilingno ground truth beyond itTwo professional nutrition engines agree with each other only about 83% of the time, so ~83% is the most any pipeline can verifiably reach. Past it there's no reference left to be right against. The pipeline sits at ~75%, and the ~8-point gap that remains is mostly the gold arguing with itself, not the model getting things wrong.

Pushing the gold cleaner further would mean paying for a service like Edamam, and that's not an obvious win: it's another professional engine carrying the same 15 to 25% spread as everything else, not a source of truth worth being certain of yet. So the only judge on hand is the small on-device model, and it's a biased one. It called 81% of the gold "too high," because it undercounts. Taken at face value, that would delete a fifth of the corpus. So only its "way too low" verdicts counted, and only when the deterministic pipeline independently agreed the gold was off by 1.5x or more. That confirmed twelve genuinely bad rows out of a 2,000-recipe sample. By the measures available without an independent source, the gold is about 99% clean. The three-way consensus, back when Edamam was in it, put the suspect share nearer 7%, but confirming which of those are really wrong, rather than the model being blind to portions, needs an independent second opinion.

The one lever left is a second independent professional source, an Edamam or a Nutritionix: a better ingredient-to-food matcher than token matching, and a second opinion to clean the gold further. But it's a paid dependency whose own numbers carry the same 15 to 25% spread, so it's a considered option, not a certain fix.

So no, these numbers won't impress on their own, and they don't need to. Nutrition has no precise answer to reach: the sources that define it disagree by 15 to 25%, so beyond a point, chasing accuracy just fits the model to one source's noise. What ships is an honest estimate, not a promise. It lands in the right range, every number traces back to an ingredient, and any line a user disagrees with can be corrected. For a background figure that fills itself in after an import, that clears the bar. And it's a floor rather than a finish: the harness lifts it every time a better model lands.

Why the harness sticks around

It would have been quicker to grab a model off a leaderboard and move on. The harness is worth keeping for three reasons, and they're the same three reasons it'll keep running after the current answer is out of date.

New models keep arriving. When the next Qwen or Gemma or Phi lands, checking whether it beats the current pick on these tasks is a one-command re-run against the real workload.

Smallest-that-works is a moving target. The harness tells me the smallest model I can ship for a given accuracy, and every gigabyte I don't spend on weights is headroom against the memory limit and a faster download.

Prompts are part of the model. The same run that ranks the models also surfaces which prompt each one does best with, per task.

Underneath the recipe specifics, one method carried the whole thing: never trust a single source, and require independent agreement before acting on a number. Adversarial verification on the feasibility claims. Three estimators voting on every gold value. A consistency check that rejects internally broken rows before any external lookup. The counterintuitive results, that bigger didn't help and that RAG hurt more than it helped, came straight out of that discipline.

The most useful output of the gold-data work wasn't the gold. It was the finding that there is no single true nutrition number, which changes the product from "compute the right answer" to "compute a number you can defend, explain, and edit." And the language model kept shrinking as the design got more accurate. It started as the whole point and ended as a judge for the one-in-five ingredients the database can't resolve on its own, with plain arithmetic doing the real work. That isn't the model failing. It's what putting a model on a phone looks like when you account for what the phone can hold, what the model is good at, and what the app has to do on an edit.

What ships

Qwen3.5-4BQ4_K_M · one model for every task
100% schema-valid ~3.2 GB resident fits an 8 GB iPhone ~26 tok/s
75%
nutrition · calories ±25%
59% deterministic base
40%
nutrition · all-4 macros
up from 25%
51%
tagging
F1 vs gold tags
48%
extraction
field accuracy

Nutrition runs as the compositional LLM-resolve pipeline: deterministic USDA math for the confident lines, the model only for the ambiguous ~21% (which food variant, how much is eaten, or a per-100g estimate for a gap).

The gold agrees with itself only ~83% of the time, so 75% is near the practical ceiling, and the ±25% tolerance is the dataset’s own variance, not a target pulled from the air.

This is the engineering behind the AI features in Gratin, a local-first recipe app I build and maintain.