<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Beyond the Model Series on Dhruv Patel - Lead Android Engineer</title><link>https://deardhruv.com/posts/ai_playground_series/</link><description>Recent content in Beyond the Model Series on Dhruv Patel - Lead Android Engineer</description><generator>Hugo -- gohugo.io</generator><language>en</language><managingEditor>dhruv.time@gmail.com (Dhruv Patel)</managingEditor><webMaster>dhruv.time@gmail.com (Dhruv Patel)</webMaster><copyright>&lt;a href="https://creativecommons.org/licenses/by-nc/4.0/" target="_blank" rel="noopener"&gt;CC BY-NC 4.0&lt;/a&gt;</copyright><lastBuildDate>Mon, 05 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://deardhruv.com/posts/ai_playground_series/index.xml" rel="self" type="application/rss+xml"/><item><title>Part 5 - At Some Point I Stopped Building a Chat App</title><link>https://deardhruv.com/posts/ai_playground_series/05-at-some-point-i-stopped-building-a-chat-app/</link><pubDate>Mon, 05 Oct 2026 14:00:00 +0530</pubDate><author>dhruv.time@gmail.com (Dhruv Patel)</author><guid>https://deardhruv.com/posts/ai_playground_series/05-at-some-point-i-stopped-building-a-chat-app/</guid><description>&lt;h1 id="at-some-point-i-stopped-building-a-chat-app"&gt;At Some Point I Stopped Building a Chat App&lt;/h1&gt;
&lt;h2 id="part-5---the-project-became-a-platform-because-every-feature-needed-a-place-to-live"&gt;Part 5 - The project became a platform because every feature needed a place to live.&lt;/h2&gt;
&lt;p&gt;There was a point where I stopped looking at AI Playground as:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chat + models
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;and started seeing:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;runtime orchestration
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;+
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;document ingestion
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;+
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;retrieval
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;+
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;vision
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;+
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;memory policy
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;+
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;image generation
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;+
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;diagnostics
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;+
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;model delivery
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;+
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;platform integration
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;At that point, adding another feature without architecture would have been reckless.&lt;/p&gt;</description><content type="html"><![CDATA[<h1 id="at-some-point-i-stopped-building-a-chat-app">At Some Point I Stopped Building a Chat App</h1>
<h2 id="part-5---the-project-became-a-platform-because-every-feature-needed-a-place-to-live">Part 5 - The project became a platform because every feature needed a place to live.</h2>
<p>There was a point where I stopped looking at AI Playground as:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">chat + models
</span></span></code></pre></div><p>and started seeing:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">runtime orchestration
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">document ingestion
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">retrieval
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">vision
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">memory policy
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">image generation
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">diagnostics
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">model delivery
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">platform integration
</span></span></code></pre></div><p>At that point, adding another feature without architecture would have been reckless.</p>
<p>The application is built around <strong>Clean Architecture with an MVI presentation layer</strong>, using Kotlin Multiplatform and Compose Multiplatform where shared code is useful, while keeping Android and iOS runtime work native where it should be.</p>
<p>The architecture is not there to make the code look “enterprise”.</p>
<p>It is there because the project has enough independent lifecycles that without boundaries, every change would become risky.</p>
<hr>
<h1 id="1-the-layer-map">1. The layer map</h1>
<p>The simplified flow is:</p>
<pre class="mermaid">flowchart TD
    Action["User action"] --> UI["Composable"] --> Store["MVI Store"] --> UC["Use Case"] --> Repo["Repository / Coordinator"] --> Runtime["Platform runtime"]
    Runtime -.->|StateFlow| Store
    Store -.-> UI
</pre>
<p>The UI renders state.</p>
<p>The UI does not become the owner of model loading, parsing, native lifecycles and memory policy.</p>
<p>That single rule eliminates a lot of accidental coupling.</p>
<hr>
<h1 id="2-the-project-is-deliberately-modular">2. The project is deliberately modular</h1>
<p>The repository is organized into decoupled Gradle subprojects, including areas such as:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">:features:chat
</span></span><span class="line"><span class="cl">:features:discover
</span></span><span class="line"><span class="cl">:features:settings
</span></span><span class="line"><span class="cl">:features:diagnostics
</span></span><span class="line"><span class="cl">:features:attachment
</span></span><span class="line"><span class="cl">:features:navigation
</span></span><span class="line"><span class="cl">:gpu_engine
</span></span><span class="line"><span class="cl">:whisper-stt
</span></span><span class="line"><span class="cl">:testing
</span></span><span class="line"><span class="cl">:shared
</span></span><span class="line"><span class="cl">:composeApp
</span></span><span class="line"><span class="cl">iosApp
</span></span></code></pre></div><p>The module names tell a story.</p>
<p>For example:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">:features:attachment
</span></span></code></pre></div><p>owns:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">document ingestion
</span></span><span class="line"><span class="cl">file validation
</span></span><span class="line"><span class="cl">format capabilities
</span></span><span class="line"><span class="cl">ZIP/Deflate processing
</span></span><span class="line"><span class="cl">semantic extraction
</span></span></code></pre></div><p>while:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">:features:diagnostics
</span></span></code></pre></div><p>owns:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">RAM
</span></span><span class="line"><span class="cl">GPU/Vulkan inspection
</span></span><span class="line"><span class="cl">hardware diagnostics
</span></span><span class="line"><span class="cl">benchmarking
</span></span></code></pre></div><p>And:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">:gpu_engine
</span></span></code></pre></div><p>owns the separate image-generation native C++ JNI backend.</p>
<p>This means the codebase has <strong>organizational boundaries that match architectural boundaries</strong>.</p>
<hr>
<h1 id="3-the-mvi-store-should-never-become-the-applications-brain">3. The MVI store should never become the application&rsquo;s brain</h1>
<p>One of the easiest mistakes in an MVI architecture is to keep adding functionality to the store because the store already has state.</p>
<p>A store starts like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ChatStore</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">fun</span> <span class="nf">send</span><span class="p">()</span> <span class="p">{}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Then it grows:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">model loading
</span></span><span class="line"><span class="cl">model switching
</span></span><span class="line"><span class="cl">streaming
</span></span><span class="line"><span class="cl">watchdog
</span></span><span class="line"><span class="cl">history
</span></span><span class="line"><span class="cl">export
</span></span><span class="line"><span class="cl">attachments
</span></span><span class="line"><span class="cl">memory
</span></span><span class="line"><span class="cl">voice
</span></span><span class="line"><span class="cl">navigation
</span></span></code></pre></div><p>At that point:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">ChatStore
</span></span></code></pre></div><p>is a monolith.</p>
<p>The project therefore extracts focused coordinators.</p>
<p>Examples include:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">ChatModelCoordinator
</span></span><span class="line"><span class="cl">ChatGenerationCoordinator
</span></span><span class="line"><span class="cl">ChatStreamParser
</span></span><span class="line"><span class="cl">ChatExportManager
</span></span><span class="line"><span class="cl">ChatTitleSummarizer
</span></span><span class="line"><span class="cl">SettingsParamTracker
</span></span></code></pre></div><p>The store coordinates.</p>
<p>The focused class owns one lifecycle or responsibility.</p>
<hr>
<h1 id="4-why-this-matters-especially-for-ai">4. Why this matters especially for AI</h1>
<p>AI features tend to be long-running and stateful.</p>
<p>For example:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">model loading
</span></span></code></pre></div><p>has a different lifecycle from:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">generation
</span></span></code></pre></div><p>which is different from:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">chat persistence
</span></span></code></pre></div><p>which is different from:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">memory monitoring
</span></span></code></pre></div><p>If one object owns all four, cancellation and failure paths become tangled.</p>
<p>The project instead makes boundaries explicit:</p>
<pre class="mermaid">flowchart TD
    ML["Model lifecycle"] --> CMC["ChatModelCoordinator"]
    GL["Generation lifecycle"] --> CGC["ChatGenerationCoordinator"]
    MP["Memory policy"] --> MAM["Memory admission / monitor"]
</pre>
<p>That is plain software engineering.</p>
<p>The AI aspect just makes the cost of getting it wrong higher.</p>
<hr>
<h1 id="5-heavy-work-must-remain-off-the-main-thread">5. Heavy work must remain off the main thread</h1>
<p>The project explicitly routes heavy work to:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Dispatchers.IO
</span></span><span class="line"><span class="cl">Dispatchers.Default
</span></span></code></pre></div><p>Examples:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">file I/O
</span></span><span class="line"><span class="cl">model verification
</span></span><span class="line"><span class="cl">database work
</span></span><span class="line"><span class="cl">document parsing
</span></span><span class="line"><span class="cl">hashing
</span></span><span class="line"><span class="cl">native inference dispatch
</span></span><span class="line"><span class="cl">image decoding
</span></span></code></pre></div><p>A representative pattern:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">scope</span><span class="p">.</span><span class="n">launch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">data</span> <span class="p">=</span> <span class="n">withContext</span><span class="p">(</span><span class="nc">Dispatchers</span><span class="p">.</span><span class="n">IO</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">repository</span><span class="p">.</span><span class="n">loadData</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">state</span><span class="p">.</span><span class="n">update</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">it</span><span class="p">.</span><span class="n">copy</span><span class="p">(</span><span class="k">data</span> <span class="p">=</span> <span class="k">data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>The main thread should mostly be doing:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">input
</span></span><span class="line"><span class="cl">layout
</span></span><span class="line"><span class="cl">render
</span></span><span class="line"><span class="cl">accessibility
</span></span><span class="line"><span class="cl">animation
</span></span></code></pre></div><p>not:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">read a 4 GB model package
</span></span></code></pre></div><hr>
<h1 id="6-the-ui-should-appear-immediately">6. The UI should appear immediately</h1>
<p>One recurring class of performance issue was not a slow algorithm.</p>
<p>It was <strong>blocking screen initialization</strong>.</p>
<p>A user taps Settings.</p>
<p>The application starts inspecting everything.</p>
<p>The screen waits.</p>
<p>Then, several seconds later, content appears.</p>
<p>I would rather do:</p>
<pre class="mermaid">flowchart TD
    Tap["tap"] --> Shell["render shell immediately"]
    Shell --> BG["load data in background"]
    Shell --> Progress["show progress / placeholders"]
    BG --> Content["populate content"]
    Progress --> Content
</pre>
<p>This is particularly important on lower-end devices.</p>
<p>Perceived performance matters.</p>
<hr>
<h1 id="7-platform-native-identity-still-matters">7. Platform-native identity still matters</h1>
<p>KMP gives me a way to share the parts that genuinely benefit from sharing.</p>
<p>It does not require me to make Android and iOS look identical.</p>
<p>For Android, I want:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Material 3
</span></span><span class="line"><span class="cl">Android interaction patterns
</span></span><span class="line"><span class="cl">Android navigation
</span></span><span class="line"><span class="cl">Android accessibility conventions
</span></span></code></pre></div><p>For iOS, I want:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">SwiftUI
</span></span><span class="line"><span class="cl">Apple navigation patterns
</span></span><span class="line"><span class="cl">native typography
</span></span><span class="line"><span class="cl">native interaction
</span></span><span class="line"><span class="cl">glass-style treatment where appropriate
</span></span></code></pre></div><p>The shared layer can say:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">generation is active
</span></span></code></pre></div><p>The Android layer can decide how that feels like Android.</p>
<p>The iOS layer can decide how that feels like iOS.</p>
<p>That is a much better interpretation of multiplatform development than cloning one platform&rsquo;s UI onto the other.</p>
<hr>
<h1 id="8-accessibility-is-easier-when-it-is-designed-in">8. Accessibility is easier when it is designed in</h1>
<p>The application uses shared design tokens and explicit policies for things such as:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">touch targets
</span></span><span class="line"><span class="cl">reduced motion
</span></span><span class="line"><span class="cl">localized strings
</span></span><span class="line"><span class="cl">RTL layout
</span></span><span class="line"><span class="cl">contrast
</span></span></code></pre></div><p>The project targets a minimum touch size of:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">48 dp
</span></span></code></pre></div><p>and keeps motion behavior behind an explicit reduced-motion policy.</p>
<p>Localization is also treated as a system constraint rather than a final translation pass.</p>
<p>The current project maintains eleven locales.</p>
<p>That matters because the amount of dynamic content in an AI application makes layout assumptions fragile.</p>
<hr>
<h1 id="9-invariants-are-more-useful-than-best-practices">9. Invariants are more useful than “best practices”</h1>
<p>As the project grew, I started writing down statements that must remain true.</p>
<p>Examples:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Inference must not require network access.
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Memory admission happens before expensive model loading.
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Memory failure does not trigger meaningless backend retries.
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Heavy work never blocks the UI thread.
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Native runtimes have explicit lifecycle and unload paths.
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Production builds cannot report fake or simulated inference success.
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Attachment controls reflect actual model capabilities.
</span></span></code></pre></div><p>These statements are much easier to test than vague guidance such as:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&#34;Keep things fast.&#34;
</span></span></code></pre></div><p>An invariant can be turned into a regression test.</p>
<hr>
<h1 id="10-the-production-reality-invariant-is-particularly-important">10. The production-reality invariant is particularly important</h1>
<p>I want to call this one out because it keeps the project honest.</p>
<p>An AI demo can be made to look successful very easily:</p>
<pre class="mermaid">flowchart TD
    Start["start"] --> Prog["animate progress"] --> Place["return placeholder"] --> Done["show 'done'"]
</pre>
<p>That is not inference.</p>
<p>The project has an explicit production-reality gate that rejects simulated/emulated execution in production paths and prevents synthetic success from being presented as a real model result.</p>
<p>This matters even more when integrating third-party runtimes.</p>
<p>The application should either:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">run the real runtime
</span></span></code></pre></div><p>or:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">say that the runtime is unavailable
</span></span></code></pre></div><p>There should not be a third state called:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">looks like it worked
</span></span></code></pre></div><hr>
<h1 id="11-testing-mirrors-the-architecture">11. Testing mirrors the architecture</h1>
<p>The project contains tests across shared domain logic, Android host behavior, Compose/UI behavior and native integration boundaries.</p>
<p>The most useful way to understand them is by category:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Model tests
</span></span><span class="line"><span class="cl">    -&gt; compatibility
</span></span><span class="line"><span class="cl">    -&gt; capability
</span></span><span class="line"><span class="cl">    -&gt; memory
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Runtime tests
</span></span><span class="line"><span class="cl">    -&gt; load
</span></span><span class="line"><span class="cl">    -&gt; routing
</span></span><span class="line"><span class="cl">    -&gt; cancellation
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Document tests
</span></span><span class="line"><span class="cl">    -&gt; parsing
</span></span><span class="line"><span class="cl">    -&gt; malformed inputs
</span></span><span class="line"><span class="cl">    -&gt; format detection
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Image tests
</span></span><span class="line"><span class="cl">    -&gt; memory safety
</span></span><span class="line"><span class="cl">    -&gt; resolution
</span></span><span class="line"><span class="cl">    -&gt; package validation
</span></span><span class="line"><span class="cl">    -&gt; progress
</span></span><span class="line"><span class="cl">    -&gt; race conditions
</span></span><span class="line"><span class="cl">    -&gt; true cancellation
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Persistence tests
</span></span><span class="line"><span class="cl">    -&gt; migrations
</span></span><span class="line"><span class="cl">    -&gt; history integrity
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Architecture tests
</span></span><span class="line"><span class="cl">    -&gt; invariant enforcement
</span></span></code></pre></div><p>That is the testing strategy I want for a system like this.</p>
<hr>
<h1 id="12-a-database-bug-reminded-me-why-architecture-tests-matter">12. A database bug reminded me why architecture tests matter</h1>
<p>The project uses SQLDelight for persistence.</p>
<p>A migration added cascading foreign keys from chat messages and generated images back to their thread.</p>
<p>That made the intended relational behavior better:</p>
<pre class="mermaid">flowchart TD
    Thread["delete thread"] --> Msg["delete dependent messages"]
    Thread --> Img["delete dependent images"]
</pre>
<p>But a replace-style write path could accidentally delete the parent row and trigger the cascade.</p>
<p>The persistence operation looked like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">upsert
</span></span></code></pre></div><p>but the semantics were closer to:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">delete
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">cascade
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">insert
</span></span></code></pre></div><p>The fix was to use:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">UPDATE
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">INSERT OR IGNORE
</span></span></code></pre></div><p>instead of a destructive replace.</p>
<p>That is a perfect example of why I like testable boundaries.</p>
<p>Two individually reasonable database features can interact into a very unreasonable behavior.</p>
<hr>
<h1 id="13-model-catalogs-are-becoming-a-first-class-subsystem">13. Model catalogs are becoming a first-class subsystem</h1>
<p>At the beginning, a model catalog could have been:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">name
</span></span><span class="line"><span class="cl">download button
</span></span></code></pre></div><p>It is now much closer to a compatibility database:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">identity
</span></span><span class="line"><span class="cl">format
</span></span><span class="line"><span class="cl">architecture
</span></span><span class="line"><span class="cl">quantization
</span></span><span class="line"><span class="cl">runtime
</span></span><span class="line"><span class="cl">platform
</span></span><span class="line"><span class="cl">modality
</span></span><span class="line"><span class="cl">required artifacts
</span></span><span class="line"><span class="cl">memory profile
</span></span><span class="line"><span class="cl">resolution profile
</span></span><span class="line"><span class="cl">installation state
</span></span></code></pre></div><p>That enables queries like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Find a model that:
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    accepts images
</span></span><span class="line"><span class="cl">    works offline
</span></span><span class="line"><span class="cl">    supports this platform
</span></span><span class="line"><span class="cl">    fits the current memory budget
</span></span></code></pre></div><p>The UI can then show useful information without hardcoding knowledge into every component.</p>
<hr>
<h1 id="14-i-want-model-selection-to-become-adaptive">14. I want model selection to become adaptive</h1>
<p>Today, users still understand models better when the catalog exposes the details.</p>
<p>The next step I want is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">intent
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">device
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">memory
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">capability
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">thermal/battery state
</span></span></code></pre></div><p>producing:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">model
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">runtime
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">context
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">resolution
</span></span></code></pre></div><p>Imagine two phones.</p>
<h3 id="device-a">Device A</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">6 GB RAM
</span></span><span class="line"><span class="cl">moderate pressure
</span></span></code></pre></div><h3 id="device-b">Device B</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">12 GB RAM
</span></span><span class="line"><span class="cl">low pressure
</span></span></code></pre></div><p>The same question:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Summarize this 80-page document.
</span></span></code></pre></div><p>does not necessarily need the same execution plan.</p>
<p>I would rather let the application choose:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Device A
</span></span><span class="line"><span class="cl">    -&gt; smaller model
</span></span><span class="line"><span class="cl">    -&gt; tighter context
</span></span><span class="line"><span class="cl">    -&gt; fewer retrieved chunks
</span></span></code></pre></div><p>than force a model choice that makes the app unreliable.</p>
<hr>
<h1 id="15-local-agents-come-after-the-foundations-not-before">15. Local agents come after the foundations, not before</h1>
<p>The obvious next feature is an agent.</p>
<p>But I do not want an agent that quietly runs around the application.</p>
<p>A local agent should have:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">capability resolver
</span></span><span class="line"><span class="cl">tool registry
</span></span><span class="line"><span class="cl">permission boundary
</span></span><span class="line"><span class="cl">memory budget
</span></span><span class="line"><span class="cl">execution budget
</span></span><span class="line"><span class="cl">cancellation
</span></span><span class="line"><span class="cl">visible action log
</span></span></code></pre></div><p>The first tool set can stay tiny:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">read local file
</span></span><span class="line"><span class="cl">search local index
</span></span></code></pre></div><p>Then add more.</p>
<p>A reliable agent with two tools is more useful to me than an unpredictable agent with twenty.</p>
<hr>
<h1 id="16-offline-speech-is-still-on-the-roadmap">16. Offline speech is still on the roadmap</h1>
<p>The repository contains a Whisper-based on-device STT module, but the current v1.7.1 development branch explicitly defers offline Whisper STT to v1.8.0 and keeps the corresponding feature disabled.</p>
<p>That is exactly the kind of distinction I want to preserve in public writing.</p>
<p>The architecture is there.</p>
<p>The work is not being represented as shipped merely because a module exists.</p>
<p>The direction is:</p>
<pre class="mermaid">flowchart TD
    Mic["microphone"] --> STT["local STT"] --> LLM["local LLM"] --> TTS["local TTS"] --> Speaker["speaker"]
</pre>
<p>But the project&rsquo;s release discipline matters more than the diagram.</p>
<hr>
<h1 id="17-model-delivery-deserves-product-level-engineering">17. Model delivery deserves product-level engineering</h1>
<p>A multi-file model package needs:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">manifest
</span></span><span class="line"><span class="cl">checksums
</span></span><span class="line"><span class="cl">artifact validation
</span></span><span class="line"><span class="cl">partial-download handling
</span></span><span class="line"><span class="cl">canonical identity
</span></span><span class="line"><span class="cl">repair
</span></span></code></pre></div><p>The project already separates model package validation and delivery from the core runtime.</p>
<p>That gives a clean boundary:</p>
<pre class="mermaid">flowchart TD
    Cat["catalog"] --> DL["download"] --> Val["validate"] --> Inst["install"] --> Run["runtime"]
</pre>
<p>A runtime should not have to discover a corrupt download at the worst possible moment.</p>
<hr>
<h1 id="18-benchmarking-has-to-become-a-data-set">18. Benchmarking has to become a data set</h1>
<p>I do not want future performance discussions to be:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&#34;Model X feels fast.&#34;
</span></span></code></pre></div><p>I want something reproducible:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Device
</span></span><span class="line"><span class="cl">RAM
</span></span><span class="line"><span class="cl">SoC
</span></span><span class="line"><span class="cl">OS
</span></span><span class="line"><span class="cl">Model
</span></span><span class="line"><span class="cl">Quantization
</span></span><span class="line"><span class="cl">Runtime
</span></span><span class="line"><span class="cl">Backend
</span></span><span class="line"><span class="cl">Context
</span></span><span class="line"><span class="cl">Time to first token
</span></span><span class="line"><span class="cl">Tokens / second
</span></span><span class="line"><span class="cl">Peak memory
</span></span><span class="line"><span class="cl">Generation time
</span></span></code></pre></div><p>For image generation:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Device
</span></span><span class="line"><span class="cl">RAM
</span></span><span class="line"><span class="cl">Model
</span></span><span class="line"><span class="cl">Resolution
</span></span><span class="line"><span class="cl">Steps
</span></span><span class="line"><span class="cl">Runtime
</span></span><span class="line"><span class="cl">Peak memory
</span></span><span class="line"><span class="cl">Generation time
</span></span><span class="line"><span class="cl">Cancel latency
</span></span></code></pre></div><p>Then model recommendations can come from actual observations.</p>
<p>That will make the catalog better over time.</p>
<hr>
<h1 id="19-if-i-were-starting-again">19. If I were starting again</h1>
<p>I would build the project in this order:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">1. single local model
</span></span><span class="line"><span class="cl">2. runtime abstraction
</span></span><span class="line"><span class="cl">3. capability inspection
</span></span><span class="line"><span class="cl">4. memory admission
</span></span><span class="line"><span class="cl">5. hardware diagnostics
</span></span><span class="line"><span class="cl">6. persistence
</span></span><span class="line"><span class="cl">7. document ingestion
</span></span><span class="line"><span class="cl">8. RAG
</span></span><span class="line"><span class="cl">9. vision
</span></span><span class="line"><span class="cl">10. background inference
</span></span><span class="line"><span class="cl">11. image generation
</span></span><span class="line"><span class="cl">12. adaptive selection
</span></span><span class="line"><span class="cl">13. local agent
</span></span></code></pre></div><p>The sequence is not accidental.</p>
<p>Each layer makes the next layer safer.</p>
<hr>
<h1 id="20-projects-i-would-give-a-senior-engineer">20. Projects I would give a senior engineer</h1>
<h2 id="project-a---mobile-runtime-resolver">Project A - Mobile Runtime Resolver</h2>
<p>Input:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">task
</span></span><span class="line"><span class="cl">device telemetry
</span></span><span class="line"><span class="cl">model catalog
</span></span></code></pre></div><p>Output:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">runtime
</span></span><span class="line"><span class="cl">model
</span></span><span class="line"><span class="cl">context
</span></span></code></pre></div><p>Add an explanation trace:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">selected_model = X
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">because:
</span></span><span class="line"><span class="cl">- vision required
</span></span><span class="line"><span class="cl">- platform supported
</span></span><span class="line"><span class="cl">- peak memory below budget
</span></span><span class="line"><span class="cl">- runtime available
</span></span><span class="line"><span class="cl">- installed locally
</span></span></code></pre></div><p>Now the selection system is observable.</p>
<hr>
<h2 id="project-b---resource-aware-local-agent">Project B - Resource-aware local agent</h2>
<p>Build an agent that must remain under:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">RAM budget
</span></span><span class="line"><span class="cl">tool-call budget
</span></span><span class="line"><span class="cl">time budget
</span></span></code></pre></div><p>Then make every action cancellable.</p>
<p>This is where local AI gets genuinely interesting.</p>
<hr>
<h2 id="project-c---mobile-ai-benchmark-harness">Project C - Mobile AI benchmark harness</h2>
<p>Build a command that records:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">device
</span></span><span class="line"><span class="cl">model
</span></span><span class="line"><span class="cl">backend
</span></span><span class="line"><span class="cl">latency
</span></span><span class="line"><span class="cl">throughput
</span></span><span class="line"><span class="cl">peak memory
</span></span><span class="line"><span class="cl">temperature trend
</span></span><span class="line"><span class="cl">battery change
</span></span></code></pre></div><p>Then store the results as JSON so they can become catalog data later.</p>
<p>That project would benefit the entire ecosystem.</p>
<hr>
<h1 id="what-i-learned-from-building-this">What I learned from building this</h1>
<p>The biggest lesson is not:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&#34;Local AI is possible.&#34;
</span></span></code></pre></div><p>I knew that because the underlying runtimes already existed.</p>
<p>The lesson is:</p>
<blockquote>
<p><strong>Once the runtimes exist, the application engineer still has a large systems problem to solve.</strong></p>
</blockquote>
<p>You have to integrate:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">model formats
</span></span><span class="line"><span class="cl">runtime capabilities
</span></span><span class="line"><span class="cl">native libraries
</span></span><span class="line"><span class="cl">GPU backends
</span></span><span class="line"><span class="cl">memory policy
</span></span><span class="line"><span class="cl">file processing
</span></span><span class="line"><span class="cl">retrieval
</span></span><span class="line"><span class="cl">lifecycle
</span></span><span class="line"><span class="cl">persistence
</span></span><span class="line"><span class="cl">platform UX
</span></span></code></pre></div><p>and then make the whole thing feel like one product.</p>
<p>That is the part I find interesting.</p>
<hr>
<h1 id="the-title-of-this-entire-journey">The title of this entire journey</h1>
<p>I think the project is best described as:</p>
<h1 id="beyond-the-model"><strong>Beyond the Model</strong></h1>
<p>Because the model is only the center of the system.</p>
<p>Around it sits everything that makes the experience usable:</p>
<pre class="mermaid">flowchart TD
    Model["MODEL"]
    Model --> Runtime["runtime"]
    Model --> Memory["memory"]
    Model --> Input["input"]
    Model --> Lifecycle["lifecycle"]
    Model --> UX["UX"]
    Runtime --> App["AI Playground"]
    Memory --> App
    Input --> App
    Lifecycle --> App
    UX --> App
</pre>
<p>That is the work I set out to do.</p>
<p>Not to replace llama.cpp.</p>
<p>Not to replace LiteRT.</p>
<p>Not to replace MLX.</p>
<p>Not to rewrite the inference research.</p>
<p><strong>To integrate the pieces, respect their constraints, and build a product around them.</strong></p>
<hr>
<h1 id="what-i-need-from-people-who-read-this">What I need from people who read this</h1>
<p>I want the feedback that comes from real devices and real workflows.</p>
<p>Tell me:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Which device did you use?
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Which model?
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Which runtime?
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">What were you trying to do?
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">What was unexpectedly slow?
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">What failed?
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">What should the application have explained better?
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">What capability would make you use it every day?
</span></span></code></pre></div><p>A report like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&#34;8 GB phone, Q4 model, 4096 context,
</span></span><span class="line"><span class="cl">model loaded, generation stable, image generation failed
</span></span><span class="line"><span class="cl">because available memory was too low&#34;
</span></span></code></pre></div><p>is incredibly useful.</p>
<p>A generic:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&#34;Great app!&#34;
</span></span></code></pre></div><p>is nice.</p>
<p>But it doesn&rsquo;t help me improve the runtime decisions.</p>
<hr>
<h1 id="final-thought">Final thought</h1>
<p>I started with one constraint:</p>
<blockquote>
<p><strong>Keep the core AI experience on the device.</strong></p>
</blockquote>
<p>I then discovered that the constraint creates a chain of engineering problems:</p>
<pre class="mermaid">flowchart TD
    A["existing runtimes"] --> B["integration"] --> C["capabilities"] --> D["memory"] --> E["native lifecycle"] --> F["documents / retrieval"] --> G["vision"] --> H["image generation"] --> I["platform UX"] --> J["testing + invariants"] --> K["adaptive execution"]
</pre>
<p>That is why I call this project <strong>Beyond the Model</strong>.</p>
<p>The model is essential.</p>
<p>But <strong>the software around the model is what turns an inference runtime into a product</strong>.</p>
<p>And I am still building it.</p>
<hr>
<h2 id="the-series">The series</h2>
<ul>
<li><a href="/posts/ai_playground_series/01-i-wanted-the-model-to-stay-on-the-phone/">Part 1 - I Wanted the Model to Stay on the Phone</a></li>
<li><a href="/posts/ai_playground_series/02-the-day-a-chat-box-became-a-document-engine/">Part 2 - The Day a Chat Box Became a Document Engine</a></li>
<li><a href="/posts/ai_playground_series/03-the-model-was-fine-the-phone-wasnt/">Part 3 - The Model Was Fine. The Phone Wasn&rsquo;t.</a></li>
<li><a href="/posts/ai_playground_series/04-i-put-an-image-generator-inside-the-phone/">Part 4 - I Put an Image Generator Inside the Phone</a></li>
<li><strong>Part 5 - At Some Point I Stopped Building a Chat App</strong></li>
</ul>
<p><strong>Project:</strong> AI Playground / Model Playground<br>
<strong>Website:</strong> <a href="https://deardhruv.com">https://deardhruv.com</a><br>
<strong>Repository:</strong> <a href="https://github.com/DearDhruv/Model-Playground">https://github.com/DearDhruv/Model-Playground</a></p>
]]></content></item><item><title>Part 4 - I Put an Image Generator Inside the Phone</title><link>https://deardhruv.com/posts/ai_playground_series/04-i-put-an-image-generator-inside-the-phone/</link><pubDate>Mon, 05 Oct 2026 13:00:00 +0530</pubDate><author>dhruv.time@gmail.com (Dhruv Patel)</author><guid>https://deardhruv.com/posts/ai_playground_series/04-i-put-an-image-generator-inside-the-phone/</guid><description>&lt;h1 id="i-put-an-image-generator-inside-the-phone"&gt;I Put an Image Generator Inside the Phone&lt;/h1&gt;
&lt;h2 id="part-4---i-did-not-build-a-diffusion-engine-i-integrated-image-generation-runtimes-and-then-had-to-engineer-everything-around-them"&gt;Part 4 - I did not build a diffusion engine. I integrated image-generation runtimes and then had to engineer everything around them.&lt;/h2&gt;
&lt;p&gt;Text generation had already forced me to learn about:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;runtime selection
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;memory
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;native code
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;lifecycle
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;fallback
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Image generation brought all of those problems back, but in a different shape.&lt;/p&gt;
&lt;p&gt;The important distinction at the start is the same one I made in Part 1:&lt;/p&gt;</description><content type="html"><![CDATA[<h1 id="i-put-an-image-generator-inside-the-phone">I Put an Image Generator Inside the Phone</h1>
<h2 id="part-4---i-did-not-build-a-diffusion-engine-i-integrated-image-generation-runtimes-and-then-had-to-engineer-everything-around-them">Part 4 - I did not build a diffusion engine. I integrated image-generation runtimes and then had to engineer everything around them.</h2>
<p>Text generation had already forced me to learn about:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">runtime selection
</span></span><span class="line"><span class="cl">memory
</span></span><span class="line"><span class="cl">native code
</span></span><span class="line"><span class="cl">lifecycle
</span></span><span class="line"><span class="cl">fallback
</span></span></code></pre></div><p>Image generation brought all of those problems back, but in a different shape.</p>
<p>The important distinction at the start is the same one I made in Part 1:</p>
<blockquote>
<p><strong>I did not invent the diffusion algorithms or write a new mobile image-generation framework from scratch.</strong></p>
</blockquote>
<p>AI Playground integrates established technologies and model ecosystems, including <strong>Google AI Edge/LiteRT multi-graph execution, MediaPipe image-generation paths, stable-diffusion.cpp/GGUF, Apple MLX and Core ML paths</strong>.</p>
<p>The engineering work was to give those runtimes a coherent product surface:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">model catalog
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">package delivery
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">capability checks
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">memory admission
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">runtime selection
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">progress
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">cancellation
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">history
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">metadata
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">device-aware defaults
</span></span></code></pre></div><p>That is where this feature became interesting.</p>
<hr>
<h1 id="1-image-generation-is-a-pipeline-not-one-model-call">1. Image generation is a pipeline, not one model call</h1>
<p>A simplified diffusion-style path looks like:</p>
<pre class="mermaid">flowchart TD
    Prompt["prompt"] --> Enc["text encoding"] --> Cond["conditioning"] --> Denoise["denoising / DiT"] --> Latent["latent representation"] --> VAE["VAE decode"] --> Pixels["pixels"]
</pre>
<p>The project therefore treats image generation as a distinct runtime family instead of pretending it is the same thing as chat.</p>
<p>A simplified contract:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">interface</span> <span class="nc">LocalImageGenerationRuntime</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">loadedModel</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">StateFlow</span><span class="p">&lt;</span><span class="n">LoadedImageGenerationModel</span><span class="p">?&gt;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">suspend</span> <span class="k">fun</span> <span class="nf">load</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">model</span><span class="p">:</span> <span class="n">ImageGenerationModelRef</span>
</span></span><span class="line"><span class="cl">    <span class="p">):</span> <span class="n">Result</span><span class="p">&lt;</span><span class="n">ImageGenerationLoadInfo</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">fun</span> <span class="nf">generate</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">request</span><span class="p">:</span> <span class="n">ImageGenerationRequest</span>
</span></span><span class="line"><span class="cl">    <span class="p">):</span> <span class="n">Flow</span><span class="p">&lt;</span><span class="n">ImageGenerationEvent</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">suspend</span> <span class="k">fun</span> <span class="nf">cancel</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">suspend</span> <span class="k">fun</span> <span class="nf">unload</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>That interface gives me a clean boundary around the image pipeline.</p>
<hr>
<h1 id="2-why-a-separate-runtime-was-worth-it">2. Why a separate runtime was worth it</h1>
<p>Text generation cares about:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">tokens
</span></span><span class="line"><span class="cl">context
</span></span><span class="line"><span class="cl">KV cache
</span></span><span class="line"><span class="cl">streaming text
</span></span></code></pre></div><p>Image generation cares about:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">steps
</span></span><span class="line"><span class="cl">latents
</span></span><span class="line"><span class="cl">denoising
</span></span><span class="line"><span class="cl">VAE
</span></span><span class="line"><span class="cl">pixel buffers
</span></span><span class="line"><span class="cl">output encoding
</span></span></code></pre></div><p>Forcing both into a single giant interface would make the abstraction harder to understand.</p>
<p>The better architecture is:</p>
<pre class="mermaid">flowchart TD
    LMR["LocalModelRuntime"] --> Text["text generation"]
    LIGR["LocalImageGenerationRuntime"] --> Image["image generation"]
</pre>
<p>Then the domain layer can orchestrate both without pretending they have identical mechanics.</p>
<hr>
<h1 id="3-the-android-image-stack-has-multiple-execution-paths">3. The Android image stack has multiple execution paths</h1>
<p>The Android application currently integrates several image-generation paths.</p>
<p>The production v1.7.0 release includes:</p>
<pre class="mermaid">flowchart TD
    LiteRT["Google LiteRT 2.2.0"] --> MultiGraph["multi-graph image pipeline"]
    subgraph MultiGraphPipeline["Pipeline"]
        TextEnc["Text Encoder"] --> DiT["DiT / denoising"] --> VAE["VAE"]
    end
    MultiGraph --> MultiGraphPipeline

    SDCPP["stable-diffusion.cpp"] --> GGUF["GGUF image models"]
</pre>
<p>as well as MediaPipe-based image-generation support for supported model families.</p>
<p>The application chooses the appropriate path through its image runtime abstraction.</p>
<p>That is orchestration work.</p>
<p>The underlying graph execution and model math come from the runtime/model ecosystems.</p>
<hr>
<h1 id="4-bonsai-4b-was-a-good-example-of-integration-complexity">4. Bonsai 4B was a good example of integration complexity</h1>
<p>One of the most technically interesting model families in the project is <strong>Bonsai Image Ternary 4B</strong>.</p>
<p>The project integrates a mobile-oriented quantized model bundle for the LiteRT path, and the image documentation describes its FLUX.2-klein-style execution details.</p>
<p>The interesting part for me was not “I found a 4B model”.</p>
<p>It was getting the entire artifact and execution contract to line up:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">model package
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">tokenization/text encoding
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">position information
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">FlowMatch-Euler sampling
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">latent packing
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">denoising
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">unpatchify
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">VAE decode
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">image output
</span></span></code></pre></div><p>When those pieces do not align, the failure can be:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">model won&#39;t load
</span></span></code></pre></div><p>or worse:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">it runs
</span></span><span class="line"><span class="cl">but the output is wrong
</span></span></code></pre></div><p>That is why I treat model support as a verification problem, not a download problem.</p>
<hr>
<h1 id="5-multi-graph-execution-changed-the-memory-problem">5. Multi-graph execution changed the memory problem</h1>
<p>A naive implementation might keep every major component resident:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">text encoder
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">denoiser
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">VAE
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">temporary tensors
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">output
</span></span></code></pre></div><p>That can create a very high simultaneous peak.</p>
<p>The project instead uses a multi-stage graph path:</p>
<pre class="mermaid">flowchart TD
    A["text encoder"] --> B["conditioning"] --> C["denoising"] --> D["latent"] --> E["VAE"] --> F["RGB output"]
</pre>
<p>The engineering objective is:</p>
<blockquote>
<p><strong>Keep the maximum simultaneously active working set under the device&rsquo;s safe envelope.</strong></p>
</blockquote>
<p>That is more useful than simply quoting the total model bundle size.</p>
<hr>
<h1 id="6-resolution-is-a-resource-policy">6. Resolution is a resource policy</h1>
<p>A user sees:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">256 × 256
</span></span><span class="line"><span class="cl">512 × 512
</span></span></code></pre></div><p>as image-quality settings.</p>
<p>The runtime sees:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">width
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">height
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">latent dimensions
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">intermediate tensors
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">VAE buffers
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">backend workspace
</span></span></code></pre></div><p>So the project does not treat resolution as an arbitrary UI slider.</p>
<p>The model profile describes supported resolutions and native constraints, and the suitability logic considers those constraints during admission.</p>
<p>A simplified resolver:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">resolveBestResolution</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">availableBytes</span><span class="p">:</span> <span class="n">Long</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">supported</span><span class="p">:</span> <span class="n">List</span><span class="p">&lt;</span><span class="n">ResolutionRequirement</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">):</span> <span class="n">Resolution</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">supported</span>
</span></span><span class="line"><span class="cl">        <span class="p">.</span><span class="n">filter</span> <span class="p">{</span> <span class="k">it</span><span class="p">.</span><span class="n">estimatedPeakBytes</span> <span class="o">&lt;=</span> <span class="n">availableBytes</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">.</span><span class="n">maxByOrNull</span> <span class="p">{</span> <span class="k">it</span><span class="p">.</span><span class="n">width</span> <span class="p">*</span> <span class="k">it</span><span class="p">.</span><span class="n">height</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="o">?.</span><span class="n">resolution</span>
</span></span><span class="line"><span class="cl">        <span class="o">?:</span> <span class="k">throw</span> <span class="n">NoSafeResolutionException</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>The actual project has a richer policy.</p>
<p>The important idea is:</p>
<blockquote>
<p><strong>The safest configuration is not necessarily the smallest configuration. It is the largest configuration the current runtime can honestly support.</strong></p>
</blockquote>
<hr>
<h1 id="7-i-learned-that-logical-downscaling-is-not-always-a-real-memory-optimization">7. I learned that logical downscaling is not always a real memory optimization</h1>
<p>One of the most useful bugs in the image work involved a very small requested resolution.</p>
<p>The UI could ask for something like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">128 × 128
</span></span></code></pre></div><p>but some models still performed their expensive internal work at a larger native resolution.</p>
<p>So:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">requested size smaller
</span></span></code></pre></div><p>did not necessarily mean:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">native peak smaller
</span></span></code></pre></div><p>That made the memory estimator depend on the model&rsquo;s <strong>native resolution floor</strong>, not merely the final output dimensions.</p>
<p>The lesson is broader:</p>
<blockquote>
<p><strong>A UI control is only a performance control when the underlying runtime actually becomes cheaper.</strong></p>
</blockquote>
<hr>
<h1 id="8-measured-peaks-are-more-useful-than-guesses">8. Measured peaks are more useful than guesses</h1>
<p>The project tracks measured memory peaks for selected image-model/configuration combinations and uses those measurements to improve suitability decisions.</p>
<p>Examples documented in the current image-generation work include values around:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Bonsai 512      ~3.1 GB
</span></span><span class="line"><span class="cl">Bonsai 256      ~2.9 GB
</span></span><span class="line"><span class="cl">SDXS-512        ~1.2 GB
</span></span></code></pre></div><p>These are not universal truths about every phone.</p>
<p>They are <strong>specific measurements used to calibrate the application&rsquo;s own decisions</strong>.</p>
<p>That distinction is essential.</p>
<p>A benchmark is useful when it says:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">this device
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">this model
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">this runtime
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">this resolution
</span></span></code></pre></div><p>not when it becomes a marketing number detached from the conditions under which it was measured.</p>
<hr>
<h1 id="9-few-step-models-matter-on-a-phone">9. Few-step models matter on a phone</h1>
<p>The stable v1.7.0 release added verified few-step image models including model families such as:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">DreamShaper 8 LCM
</span></span><span class="line"><span class="cl">Realistic Vision HyperVAE
</span></span><span class="line"><span class="cl">DreamShaper DMD2
</span></span><span class="line"><span class="cl">SD-Turbo
</span></span></code></pre></div><p>The reason is obvious from the device perspective.</p>
<p>If a desktop workflow can afford dozens of denoising steps, a mobile workflow benefits enormously when the selected model can produce useful output in very few steps.</p>
<p>For a documented SDXS-512 test, the project observed:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">1 step -&gt; ~31 s
</span></span><span class="line"><span class="cl">2 steps -&gt; ~49 s
</span></span></code></pre></div><p>on a OnePlus 7 Pro.</p>
<p>That observation shaped model ranking.</p>
<p>The best mobile model is not automatically the biggest model.</p>
<p>It can be the model that gives the best <strong>quality / time / memory</strong> trade-off.</p>
<hr>
<h1 id="10-progress-is-a-real-ux-feature">10. Progress is a real UX feature</h1>
<p>A three-minute operation cannot realistically show:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Generating...
</span></span></code></pre></div><p>and expect the user to stay calm.</p>
<p>The image store therefore models explicit stages such as:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">IDLE
</span></span><span class="line"><span class="cl">PREPARING
</span></span><span class="line"><span class="cl">GENERATING
</span></span><span class="line"><span class="cl">DECODING
</span></span><span class="line"><span class="cl">SAVING
</span></span><span class="line"><span class="cl">COMPLETE
</span></span></code></pre></div><p>and exposes step progress.</p>
<p>Conceptually:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Step 1 / 4
</span></span><span class="line"><span class="cl">Step 2 / 4
</span></span><span class="line"><span class="cl">Step 3 / 4
</span></span><span class="line"><span class="cl">Step 4 / 4
</span></span></code></pre></div><p>The UI can then explain that the first step may include the expensive warmup/paging phase rather than leaving the user wondering why the first increment takes longer.</p>
<hr>
<h1 id="11-i-made-cancellation-cross-the-native-boundary">11. I made cancellation cross the native boundary</h1>
<p>The image generation path supports true cancellation rather than only hiding the progress UI.</p>
<p>The architecture is:</p>
<pre class="mermaid">flowchart TD
    UI["Compose"] --> Store["ImageGenerationStore"] --> Runtime["LocalImageGenerationRuntime"] --> Bridge["platform bridge"] --> Loop["native generation loop"]
</pre>
<p>The project specifically tests cancellation and generation race conditions.</p>
<p>That includes resetting stale cancellation state at the beginning of a new generation.</p>
<p>The invariant is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Cancel run A
</span></span><span class="line"><span class="cl">    |
</span></span><span class="line"><span class="cl">    v
</span></span><span class="line"><span class="cl">run A stops
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Run B starts
</span></span><span class="line"><span class="cl">    |
</span></span><span class="line"><span class="cl">    v
</span></span><span class="line"><span class="cl">Run B starts clean
</span></span></code></pre></div><p>not:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Run A cancelled
</span></span><span class="line"><span class="cl">    |
</span></span><span class="line"><span class="cl">    v
</span></span><span class="line"><span class="cl">Run B inherits A&#39;s state
</span></span></code></pre></div><hr>
<h1 id="12-session-continuation-changed-the-product-behavior">12. Session continuation changed the product behavior</h1>
<p>The stable release added support for continuing image generation while navigating between application surfaces.</p>
<p>The user can move from:</p>
<pre class="mermaid">flowchart LR
    Image["Image"] --> Chat["Chat"] --> Discover["Discover"]
</pre>
<p>without automatically destroying the running image-generation session.</p>
<p>At the same time, chat model switching is guarded during active image generation.</p>
<p>The user can be given an explicit choice:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Image generation in progress.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">[ Stop &amp; Load ]
</span></span><span class="line"><span class="cl">[ Keep Generating ]
</span></span></code></pre></div><p>That is a good example of application-level orchestration around a native runtime.</p>
<hr>
<h1 id="13-image-outputs-should-be-reproducible">13. Image outputs should be reproducible</h1>
<p>The project embeds generation information into PNG output using W3C-style <code>tEXt</code> chunks.</p>
<p>The metadata path includes values such as:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Title
</span></span><span class="line"><span class="cl">Author
</span></span><span class="line"><span class="cl">Description
</span></span><span class="line"><span class="cl">Software
</span></span><span class="line"><span class="cl">Source
</span></span><span class="line"><span class="cl">Comment
</span></span><span class="line"><span class="cl">Parameters
</span></span></code></pre></div><p>A simplified metadata record:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">data</span> <span class="k">class</span> <span class="nc">PngGenerationMetadata</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">prompt</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">modelId</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">modelName</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">width</span><span class="p">:</span> <span class="n">Int</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">height</span><span class="p">:</span> <span class="n">Int</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">steps</span><span class="p">:</span> <span class="n">Int</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">seed</span><span class="p">:</span> <span class="n">Long</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">guidanceScale</span><span class="p">:</span> <span class="n">Float</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span></code></pre></div><p>The encoder then writes those fields into the PNG.</p>
<p>This is a tiny feature with a surprisingly useful outcome:</p>
<blockquote>
<p><strong>The image can remember where it came from.</strong></p>
</blockquote>
<hr>
<h1 id="14-atomic-file-output-matters">14. Atomic file output matters</h1>
<p>Writing a generated image directly to its final path creates a nasty failure mode.</p>
<p>If the application dies halfway through, the gallery can end up with:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">partially written image
</span></span></code></pre></div><p>The image storage layer therefore stages writes through temporary files before promoting them to the final destination.</p>
<p>Conceptually:</p>
<pre class="mermaid">flowchart TD
    Gen["generate bytes"] --> Tmp[".tmp file"] --> Val["validate"] --> Rename["rename"] --> PNG["final PNG"]
</pre>
<p>And importantly, saving is decoupled from successful generation.</p>
<p>The project supports a save-failure outcome so a generated in-memory result does not have to be thrown away just because the filesystem failed at the last step.</p>
<p>That is exactly the kind of edge case that disappears in a demo and matters in a product.</p>
<hr>
<h1 id="15-model-delivery-is-part-of-the-feature">15. Model delivery is part of the feature</h1>
<p>A multi-file image model is not a single download.</p>
<p>The current image system separates:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">application feature delivery
</span></span></code></pre></div><p>from:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">model package delivery
</span></span></code></pre></div><p>The package validator checks the expected bundle and supports repair actions when the downloaded model is incomplete or invalid.</p>
<p>That gives me a clean flow:</p>
<pre class="mermaid">flowchart TD
    Cat["catalog"] --> Comp["compatibility check"] --> DL["download package"] --> Val["validate manifest / files"] --> Inst["install"]
</pre>
<p>I do not want the runtime to discover a broken model package only after allocating gigabytes of memory.</p>
<hr>
<h1 id="16-image-model-catalogs-need-canonical-identity">16. Image model catalogs need canonical identity</h1>
<p>As multiple resolutions and legacy bundle layouts appeared, the catalog itself needed cleanup.</p>
<p>The v1.7.1 work consolidated Bonsai 4B into a canonical multi-resolution catalog entry while keeping compatibility with legacy installations.</p>
<p>That matters because these should not become different logical products:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Bonsai 4B 256
</span></span><span class="line"><span class="cl">Bonsai 4B 512
</span></span></code></pre></div><p>if they can be treated as:</p>
<pre class="mermaid">flowchart TD
    Model["Bonsai 4B"] --> R1["supported resolution = 256"]
    Model --> R2["supported resolution = 512"]
</pre>
<p>The catalog should describe the <strong>model identity</strong>.</p>
<p>The runtime configuration should describe the <strong>execution choice</strong>.</p>
<hr>
<h1 id="17-android-and-ios-must-not-be-presented-as-identical">17. Android and iOS must not be presented as identical</h1>
<p>For Android, the current production path has strong, concrete local image-generation integration.</p>
<p>For iOS, the project contains native MLX and Core ML integration points, including an <code>MlxImageEngineBridge</code> and the shared iOS runtime boundary.</p>
<p>I want to be careful here because the repository itself distinguishes <strong>production reality from simulation</strong>.</p>
<p>The project has an explicit invariant that production builds must not emit synthetic success events or pretend that simulated inference is real.</p>
<p>So I do not want this article to say:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&#34;iOS image generation is completely finished and production-equivalent.&#34;
</span></span></code></pre></div><p>unless the release artifacts and production gates prove that.</p>
<p>The honest statement is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">The iOS architecture includes native MLX/Core ML integration
</span></span><span class="line"><span class="cl">paths and shared runtime contracts, while the Android path is
</span></span><span class="line"><span class="cl">the clearer production reference in v1.7.0. Current iOS image
</span></span><span class="line"><span class="cl">work remains subject to the repository&#39;s production-reality
</span></span><span class="line"><span class="cl">gates and active development.
</span></span></code></pre></div><p>That may be less flashy.</p>
<p>It is also much more useful to another engineer reading the code.</p>
<hr>
<h1 id="18-the-ios-bridge-still-teaches-an-architectural-lesson">18. The iOS bridge still teaches an architectural lesson</h1>
<p>The bridge exists to keep Swift-native concerns behind a narrow boundary:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kr">final</span> <span class="kd">class</span> <span class="nc">MlxImageEngineBridge</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">func</span> <span class="nf">loadModel</span><span class="p">(...)</span>
</span></span><span class="line"><span class="cl">    <span class="kd">func</span> <span class="nf">generate</span><span class="p">(...)</span>
</span></span><span class="line"><span class="cl">    <span class="kd">func</span> <span class="nf">cancel</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="kd">func</span> <span class="nf">unload</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>The KMP layer does not need to know how MLX manages:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Metal
</span></span><span class="line"><span class="cl">memory
</span></span><span class="line"><span class="cl">Swift tasks
</span></span><span class="line"><span class="cl">native lifecycle
</span></span></code></pre></div><p>It only needs a stable contract.</p>
<p>That is exactly the same architectural principle as JNI on Android.</p>
<hr>
<h1 id="19-build-this-yourself">19. Build this yourself</h1>
<h2 id="project-1---tiny-diffusion-studio">Project 1 - Tiny diffusion studio</h2>
<p>Start with:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">one model
</span></span><span class="line"><span class="cl">one resolution
</span></span><span class="line"><span class="cl">one runtime
</span></span></code></pre></div><p>Then add:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">progress
</span></span><span class="line"><span class="cl">cancel
</span></span><span class="line"><span class="cl">seed
</span></span><span class="line"><span class="cl">resolution
</span></span><span class="line"><span class="cl">gallery
</span></span><span class="line"><span class="cl">metadata
</span></span><span class="line"><span class="cl">memory admission
</span></span></code></pre></div><p>Do not start with sixteen models.</p>
<p>Make the lifecycle correct first.</p>
<hr>
<h2 id="project-2---adaptive-resolution-selector">Project 2 - Adaptive resolution selector</h2>
<p>Build:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">data</span> <span class="k">class</span> <span class="nc">ResolutionRequirement</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">width</span><span class="p">:</span> <span class="n">Int</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">height</span><span class="p">:</span> <span class="n">Int</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">estimatedPeakBytes</span><span class="p">:</span> <span class="n">Long</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span></code></pre></div><p>and choose the largest safe resolution.</p>
<p>Then compare:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">estimated peak
</span></span></code></pre></div><p>with:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">measured peak
</span></span></code></pre></div><p>on physical devices.</p>
<hr>
<h2 id="project-3---reproducible-image-artifacts">Project 3 - Reproducible image artifacts</h2>
<p>Write:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">model
</span></span><span class="line"><span class="cl">prompt
</span></span><span class="line"><span class="cl">seed
</span></span><span class="line"><span class="cl">steps
</span></span><span class="line"><span class="cl">guidance
</span></span><span class="line"><span class="cl">resolution
</span></span><span class="line"><span class="cl">runtime
</span></span><span class="line"><span class="cl">timestamp
</span></span></code></pre></div><p>into PNG metadata.</p>
<p>Then build:</p>
<pre class="mermaid">flowchart TD
    Open["open image"] --> Read["read metadata"] --> Recreate["recreate request"]
</pre>
<p>That gives you a reproducible experimentation loop.</p>
<hr>
<h1 id="the-image-generation-lesson">The image-generation lesson</h1>
<p>By now the pattern was unmistakable.</p>
<p>I did not need another abstraction because “AI is complicated”.</p>
<p>I needed explicit boundaries because <strong>different runtimes, different resources and different platforms have genuinely different behavior</strong>.</p>
<p>The project now had:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">text runtime
</span></span><span class="line"><span class="cl">document ingestion
</span></span><span class="line"><span class="cl">retrieval
</span></span><span class="line"><span class="cl">vision
</span></span><span class="line"><span class="cl">image runtime
</span></span><span class="line"><span class="cl">memory policy
</span></span><span class="line"><span class="cl">native bridges
</span></span></code></pre></div><p>That was a lot of moving parts.</p>
<p>At this point the obvious question was:</p>
<blockquote>
<p><strong>What architecture lets me keep adding these capabilities without turning the codebase into a giant application class?</strong></p>
</blockquote>
<p>That is where the story stops being about individual features and becomes about engineering the product as a platform.</p>
<p><strong>Part 5:</strong> <a href="/posts/ai_playground_series/05-at-some-point-i-stopped-building-a-chat-app/">At Some Point I Stopped Building a Chat App</a></p>
]]></content></item><item><title>Part 3 - The Model Was Fine. The Phone Wasn't.</title><link>https://deardhruv.com/posts/ai_playground_series/03-the-model-was-fine-the-phone-wasnt/</link><pubDate>Mon, 05 Oct 2026 12:00:00 +0530</pubDate><author>dhruv.time@gmail.com (Dhruv Patel)</author><guid>https://deardhruv.com/posts/ai_playground_series/03-the-model-was-fine-the-phone-wasnt/</guid><description>&lt;h1 id="the-model-was-fine-the-phone-wasnt"&gt;The Model Was Fine. The Phone Wasn&amp;rsquo;t.&lt;/h1&gt;
&lt;h2 id="part-3---the-difficult-bugs-were-memory-native-lifecycle-gpu-drivers-and-operating-system-behavior"&gt;Part 3 - The difficult bugs were memory, native lifecycle, GPU drivers and operating-system behavior.&lt;/h2&gt;
&lt;p&gt;There is a point in a local AI project where the model stops being the hardest part.&lt;/p&gt;
&lt;p&gt;For me, that point was when I started testing the application on actual phones instead of treating a successful development run as proof of correctness.&lt;/p&gt;
&lt;p&gt;A model would load.&lt;/p&gt;
&lt;p&gt;A prompt would work.&lt;/p&gt;</description><content type="html"><![CDATA[<h1 id="the-model-was-fine-the-phone-wasnt">The Model Was Fine. The Phone Wasn&rsquo;t.</h1>
<h2 id="part-3---the-difficult-bugs-were-memory-native-lifecycle-gpu-drivers-and-operating-system-behavior">Part 3 - The difficult bugs were memory, native lifecycle, GPU drivers and operating-system behavior.</h2>
<p>There is a point in a local AI project where the model stops being the hardest part.</p>
<p>For me, that point was when I started testing the application on actual phones instead of treating a successful development run as proof of correctness.</p>
<p>A model would load.</p>
<p>A prompt would work.</p>
<p>Then I would increase the context.</p>
<p>Add a document.</p>
<p>Switch the model.</p>
<p>Turn the screen off.</p>
<p>Run image generation.</p>
<p>And suddenly the “AI bug” turned out to be:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">memory accounting
</span></span><span class="line"><span class="cl">GPU initialization
</span></span><span class="line"><span class="cl">native allocation
</span></span><span class="line"><span class="cl">process lifecycle
</span></span><span class="line"><span class="cl">stale state
</span></span><span class="line"><span class="cl">background execution
</span></span></code></pre></div><p>That changed the question I was asking.</p>
<p>Not:</p>
<blockquote>
<p>“Does this model work?”</p>
</blockquote>
<p>But:</p>
<blockquote>
<p><strong>“When is it responsible to start this operation on this device?”</strong></p>
</blockquote>
<p>That distinction became one of the foundations of AI Playground.</p>
<hr>
<h1 id="1-model-size-is-not-peak-memory">1. Model size is not peak memory</h1>
<p>Suppose a model artifact is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">3 GB
</span></span></code></pre></div><p>That number describes the artifact.</p>
<p>It does not describe the full runtime footprint.</p>
<p>The operation may need:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">weights
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">KV cache
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">runtime state
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">temporary tensors
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">GPU buffers
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">image/vision components
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">application memory
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">allocator overhead
</span></span></code></pre></div><p>The project therefore maintains explicit memory estimation code and tests.</p>
<p>The conceptual model is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">estimated peak
</span></span><span class="line"><span class="cl">=
</span></span><span class="line"><span class="cl">weights
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">KV cache
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">runtime overhead
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">modality overhead
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">safety margin
</span></span></code></pre></div><p>This is deliberately a <strong>policy input</strong>, not a fake claim of perfect precision.</p>
<hr>
<h1 id="2-kv-cache-is-why-context-settings-matter">2. KV cache is why context settings matter</h1>
<p>A user sees:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Context: 4096
</span></span></code></pre></div><p>as a text setting.</p>
<p>The runtime sees more work and more memory.</p>
<p>A simplified attention KV-cache relationship is roughly proportional to:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">2
</span></span><span class="line"><span class="cl">× layers
</span></span><span class="line"><span class="cl">× KV heads
</span></span><span class="line"><span class="cl">× head dimension
</span></span><span class="line"><span class="cl">× context tokens
</span></span><span class="line"><span class="cl">× bytes per element
</span></span></code></pre></div><p>The exact value depends on architecture and implementation.</p>
<p>The practical lesson is enough:</p>
<pre class="mermaid">flowchart TD
    Context["larger context"] --> KV["larger KV cache"]
    Context --> Work["more prefill work"]
    Context --> Latency["potentially higher latency"]
</pre>
<p>This is why context budgeting from Part 2 is also a memory feature.</p>
<hr>
<h1 id="3-i-added-an-admission-step-before-model-loading">3. I added an admission step before model loading</h1>
<p>Instead of:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">loadModel</span><span class="p">()</span>
</span></span></code></pre></div><p>the application thinks in stages:</p>
<pre class="mermaid">flowchart TD
    Req["request"] --> Est["estimate"] --> Adm["admission"]
    Adm --> Allow["ALLOW"]
    Adm --> Warn["WARN"]
    Adm --> Reclaim["RECLAIM"]
    Adm --> Unload["UNLOAD_OTHER"]
    Adm --> Deny["DENY"]
</pre>
<p>The admission result can explain itself:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">data</span> <span class="k">class</span> <span class="nc">AdmissionResult</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">decision</span><span class="p">:</span> <span class="n">AdmissionDecision</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">estimatedPeakBytes</span><span class="p">:</span> <span class="n">Long</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">safeAvailableBytes</span><span class="p">:</span> <span class="n">Long</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">reason</span><span class="p">:</span> <span class="n">String</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span></code></pre></div><p>That last field is important.</p>
<p>The user should not have to reverse-engineer a native exception.</p>
<hr>
<h1 id="4-physical-ram-is-not-the-same-as-usable-ram">4. Physical RAM is not the same as usable RAM</h1>
<p>An “8 GB phone” does not mean my process gets 8 GB.</p>
<p>The runtime cares about:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">physical RAM
</span></span><span class="line"><span class="cl">available RAM
</span></span><span class="line"><span class="cl">process RSS
</span></span><span class="line"><span class="cl">native memory
</span></span><span class="line"><span class="cl">current model residency
</span></span><span class="line"><span class="cl">system pressure
</span></span></code></pre></div><p>The project therefore exposes a platform-neutral telemetry contract:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">interface</span> <span class="nc">RuntimeMemoryTelemetry</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">fun</span> <span class="nf">snapshot</span><span class="p">():</span> <span class="n">RuntimeMemorySnapshot</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Android and iOS can then gather their own native signals without leaking platform details into shared domain code.</p>
<hr>
<h1 id="5-memory-pressure-changes-over-time">5. Memory pressure changes over time</h1>
<p>A static preflight check is not enough.</p>
<p>Imagine:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">T0
</span></span><span class="line"><span class="cl">model fits
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">T1
</span></span><span class="line"><span class="cl">user opens another app
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">T2
</span></span><span class="line"><span class="cl">system pressure rises
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">T3
</span></span><span class="line"><span class="cl">image preprocessing allocates buffers
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">T4
</span></span><span class="line"><span class="cl">native backend reaches another allocation peak
</span></span></code></pre></div><p>So the project watches memory during long-running work as well.</p>
<p>The conceptual path is:</p>
<pre class="mermaid">flowchart TD
    Preflight["preflight"] --> Load["load"] --> Gen["generation"]
    Gen --> Monitor["memory pressure monitor"]
    Monitor --> Normal["normal"]
    Monitor --> Warning["warning"]
    Monitor --> Critical["critical"]
</pre>
<p>At higher pressure, the application can stop starting new heavy operations and reclaim disposable resources.</p>
<hr>
<h1 id="6-the-stale-pressure-bug-was-a-good-lesson">6. The stale-pressure bug was a good lesson</h1>
<p>At one point, a critical memory-pressure signal could outlive the condition that caused it.</p>
<p>That meant the application could later observe:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">live available memory: healthy
</span></span><span class="line"><span class="cl">historical pressure: critical
</span></span></code></pre></div><p>and still refuse an operation.</p>
<p>The fix was a <strong>decaying memory-pressure monitor</strong>.</p>
<p>Conceptually:</p>
<pre class="mermaid">flowchart TD
    A["platform event"] --> B["remember severity"] --> C["time-based decay"] --> D["combine with live memory"] --> E["decision"]
</pre>
<p>I prefer that approach to simply ignoring pressure callbacks.</p>
<p>The historical signal still matters.</p>
<p>It just should not own the decision forever.</p>
<hr>
<h1 id="7-another-bug-counting-the-same-memory-twice">7. Another bug: counting the same memory twice</h1>
<p>Image generation exposed a different class of accounting problem.</p>
<p>The application already knew the current image runtime had a resident footprint.</p>
<p>Another part of the decision logic counted the same weight footprint again.</p>
<p>The result was:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">available memory
</span></span><span class="line"><span class="cl">&lt;
</span></span><span class="line"><span class="cl">reported requirement
</span></span></code></pre></div><p>even though the resident model was already loaded and the request did not need to pay the full cost again.</p>
<p>The fix was to distinguish:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">resident footprint
</span></span></code></pre></div><p>from:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">incremental peak
</span></span></code></pre></div><p>That distinction is essential whenever multiple features share a runtime.</p>
<hr>
<h1 id="8-switching-models-became-a-resource-decision">8. Switching models became a resource decision</h1>
<p>Suppose:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">chat model loaded
</span></span></code></pre></div><p>and then the user asks for image generation.</p>
<p>The application should not simply say:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">memory too low
</span></span></code></pre></div><p>without considering whether the chat model can be unloaded safely.</p>
<p>A better decision tree is:</p>
<pre class="mermaid">flowchart TD
    Req["new request"] --> Est["estimate incremental cost"]
    Est -->|fits with current residency| Run["run"]
    Est -->|doesn't fit| Unload["unload reclaimable model"]
    Unload --> Recheck["re-check"]
    Recheck -->|fit| Run
    Recheck -->|fail| Fail["fail"]
</pre>
<p>This is why the model lifecycle coordinator and memory admission policy are separate concepts.</p>
<p>One owns lifecycle.</p>
<p>The other decides whether the lifecycle transition is responsible.</p>
<hr>
<h1 id="9-gpu-fallback-is-only-useful-if-it-is-safe">9. GPU fallback is only useful if it is safe</h1>
<p>The Android GGML integration uses a hierarchical backend approach:</p>
<pre class="mermaid">flowchart TD
    Vulkan["Vulkan"] -->|fallback| OpenCL["OpenCL"] -->|fallback| CPU["CPU"]
</pre>
<p>But fallback should not happen blindly.</p>
<p>The runtime distinguishes things like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">GPU initialization failure
</span></span><span class="line"><span class="cl">unsupported backend
</span></span><span class="line"><span class="cl">missing binary
</span></span><span class="line"><span class="cl">native runtime error
</span></span><span class="line"><span class="cl">insufficient memory
</span></span></code></pre></div><p>The first few may justify trying another backend.</p>
<p>The last one may not.</p>
<p>That is the difference between:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">fallback
</span></span></code></pre></div><p>and:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">retry until the app dies
</span></span></code></pre></div><hr>
<h1 id="10-why-the-vulkan-probe-runs-in-another-process">10. Why the Vulkan probe runs in another process</h1>
<p>This is one of the most unusual decisions in the project, and one of the most practical.</p>
<p>A GPU driver is native software outside my control.</p>
<p>A buggy vendor implementation can crash a process during probing.</p>
<p>If the probe happens in the main application process:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">GPU probe crash
</span></span><span class="line"><span class="cl">      |
</span></span><span class="line"><span class="cl">      v
</span></span><span class="line"><span class="cl">whole app dies
</span></span></code></pre></div><p>So the project isolates Vulkan probing in a child process:</p>
<pre class="mermaid">flowchart TD
    Parent["parent process"] -->|fork| Child["child process"]
    Child --> Probe["Vulkan probe"]
    Probe -->|success| OK["report OK"]
    Probe -->|crash| Survives["parent survives"]
</pre>
<p>The point is not that Vulkan is “bad”.</p>
<p>The point is:</p>
<blockquote>
<p><strong>An optional acceleration path should not be able to take down the product while you are deciding whether it is available.</strong></p>
</blockquote>
<p>That is a systems-engineering decision around someone else&rsquo;s runtime.</p>
<hr>
<h1 id="11-native-libraries-need-build-time-guarantees-too">11. Native libraries need build-time guarantees too</h1>
<p>The Android application packages native pieces for the selected runtime paths, including the llama/GGML stack and GPU backends, along with the separate image-generation JNI module.</p>
<p>The repository also contains scripts for engine setup, native builds, QA and invariant checks.</p>
<p>That matters because:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">source code says backend exists
</span></span></code></pre></div><p>does not mean:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">APK actually contains backend
</span></span></code></pre></div><p>Packaging is part of runtime correctness.</p>
<hr>
<h1 id="12-background-execution-is-another-resource-problem">12. Background execution is another resource problem</h1>
<p>A local generation can run longer than a user expects.</p>
<p>The user starts it.</p>
<p>Then:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">screen off
</span></span></code></pre></div><p>or:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">navigate away
</span></span></code></pre></div><p>The operating system is now part of the runtime.</p>
<p>The production Android architecture uses a foreground inference path for long-running inference, with the appropriate service type and a visible user-facing notification. The image-generation feature also supports session continuation while navigating between application surfaces.</p>
<p>The lifecycle looks conceptually like:</p>
<pre class="mermaid">flowchart TD
    Start["start"] --> Session["foreground inference session"] --> Work["native work"]
    Work --> Cancel["cancel"]
    Work --> Finish["finish"]
    Cancel --> Release["release"]
    Finish --> Release
</pre>
<p>That is not an implementation detail.</p>
<p>It is how I tell the operating system:</p>
<blockquote>
<p><strong>This work is important, visible and currently active.</strong></p>
</blockquote>
<hr>
<h1 id="13-cancellation-is-a-real-contract">13. Cancellation is a real contract</h1>
<p>A button labeled:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Cancel
</span></span></code></pre></div><p>does not prove cancellation.</p>
<p>Real cancellation has to travel through the stack:</p>
<pre class="mermaid">flowchart TD
    UI["UI"] --> Store["Store"] --> Coord["Coordinator"] --> Cancel["runtime.cancel()"] --> Native["native cancellation"] --> Exit["native loop exits"]
</pre>
<p>The project has explicit tests around true cancellation and race conditions because stale cancellation state can otherwise poison the next generation.</p>
<p>A subtle bug looked like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Run A
</span></span><span class="line"><span class="cl">  |
</span></span><span class="line"><span class="cl">cancel
</span></span><span class="line"><span class="cl">  |
</span></span><span class="line"><span class="cl">Run A fails
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Run B
</span></span><span class="line"><span class="cl">  |
</span></span><span class="line"><span class="cl">stale isCancelled = true
</span></span><span class="line"><span class="cl">  |
</span></span><span class="line"><span class="cl">Run B immediately stops
</span></span></code></pre></div><p>The fix was to reset cancellation state at the start of every new run.</p>
<p>Simple.</p>
<p>Easy to miss.</p>
<p>Exactly the kind of thing that deserves a regression test.</p>
<hr>
<h1 id="14-screen-awake-state-needs-ownership-too">14. Screen-awake state needs ownership too</h1>
<p>The project uses a reference-counted screen-awake controller.</p>
<p>The logic is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">acquire
</span></span><span class="line"><span class="cl">  -&gt; count + 1
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">release
</span></span><span class="line"><span class="cl">  -&gt; count - 1
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">count &gt; 0
</span></span><span class="line"><span class="cl">  -&gt; keep awake
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">count == 0
</span></span><span class="line"><span class="cl">  -&gt; restore normal behavior
</span></span></code></pre></div><p>Why?</p>
<p>Because multiple operations can overlap.</p>
<p>If operation A turns the state off when it completes while operation B is still active, B loses its protection.</p>
<p>Reference counting makes ownership explicit.</p>
<hr>
<h1 id="15-trimming-should-target-disposable-memory-first">15. Trimming should target disposable memory first</h1>
<p>When the system reports pressure, I do not want the first response to be:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">unload the model
</span></span></code></pre></div><p>Model loading is expensive.</p>
<p>The better sequence is to reclaim things that are genuinely disposable:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">image caches
</span></span><span class="line"><span class="cl">temporary buffers
</span></span><span class="line"><span class="cl">native allocator caches
</span></span><span class="line"><span class="cl">short-lived objects
</span></span></code></pre></div><p>and preserve expensive resident state where the resource envelope still permits it.</p>
<p>This leads to a general principle:</p>
<blockquote>
<p><strong>Reclaim what is cheap to recreate before destroying what is expensive to rebuild.</strong></p>
</blockquote>
<hr>
<h1 id="16-resource-management-code-needs-real-tests">16. Resource-management code needs real tests</h1>
<p>The repository contains tests for things such as:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">KV-cache estimation
</span></span><span class="line"><span class="cl">model-memory estimation
</span></span><span class="line"><span class="cl">memory-pressure classification
</span></span><span class="line"><span class="cl">resident model footprint
</span></span><span class="line"><span class="cl">model-load decisions
</span></span><span class="line"><span class="cl">model switching
</span></span><span class="line"><span class="cl">image memory admission
</span></span><span class="line"><span class="cl">runtime memory snapshots
</span></span></code></pre></div><p>That is not accidental.</p>
<p>The memory layer can produce incorrect user-visible behavior even when the inference engine itself is perfectly healthy.</p>
<p>So it deserves unit-level coverage independent of native inference.</p>
<hr>
<h1 id="17-the-hardware-test-matrix-matters">17. The hardware test matrix matters</h1>
<p>I think about testing in dimensions:</p>
<pre class="mermaid">flowchart TD
    Hardware["Hardware Test Matrix"]
    Hardware --> RAM["RAM Tier<br/>(low / mid / high)"]
    Hardware --> Model["Model<br/>(text / vision / image)"]
    Hardware --> Op["Operation<br/>(load / generate / switch / background / cancel)"]
</pre>
<p>The question is:</p>
<blockquote>
<p><strong>Where are the boundaries?</strong></p>
</blockquote>
<p>Not:</p>
<blockquote>
<p>“Does my phone work?”</p>
</blockquote>
<p>That is how I want to collect real device knowledge over time.</p>
<hr>
<h1 id="build-this-yourself">Build this yourself</h1>
<h2 id="project-1---mobile-model-admission-library">Project 1 - Mobile model admission library</h2>
<p>Create:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">data</span> <span class="k">class</span> <span class="nc">DeviceMemory</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">physicalBytes</span><span class="p">:</span> <span class="n">Long</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">availableBytes</span><span class="p">:</span> <span class="n">Long</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">processRssBytes</span><span class="p">:</span> <span class="n">Long</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">data</span> <span class="k">class</span> <span class="nc">ModelRequirement</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">weightsBytes</span><span class="p">:</span> <span class="n">Long</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">kvCacheBytes</span><span class="p">:</span> <span class="n">Long</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">runtimeBytes</span><span class="p">:</span> <span class="n">Long</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span></code></pre></div><p>and:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">decide</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">memory</span><span class="p">:</span> <span class="n">DeviceMemory</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">requirement</span><span class="p">:</span> <span class="n">ModelRequirement</span>
</span></span><span class="line"><span class="cl"><span class="p">):</span> <span class="n">AdmissionDecision</span>
</span></span></code></pre></div><p>Then add:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">resident model state
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">memory pressure
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">safety margin
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">reclaim strategy
</span></span></code></pre></div><p>Test the decision engine before connecting a real model.</p>
<hr>
<h2 id="project-2---gpu-crash-isolated-probe">Project 2 - GPU crash-isolated probe</h2>
<p>Build:</p>
<pre class="mermaid">flowchart TD
    Main["main process"] -->|fork| Child["child process"]
    Child --> Probe["GPU probe"]
    Probe --> Report["report success / failure"]
</pre>
<p>Intentionally make the child fail.</p>
<p>Your parent should survive and choose a safe fallback.</p>
<hr>
<h2 id="project-3---long-running-local-inference">Project 3 - Long-running local inference</h2>
<p>Build a local inference session that survives:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">screen off
</span></span><span class="line"><span class="cl">activity recreation
</span></span><span class="line"><span class="cl">navigation
</span></span><span class="line"><span class="cl">cancellation
</span></span></code></pre></div><p>and records:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">start
</span></span><span class="line"><span class="cl">finish
</span></span><span class="line"><span class="cl">duration
</span></span><span class="line"><span class="cl">cancel latency
</span></span><span class="line"><span class="cl">peak memory
</span></span><span class="line"><span class="cl">completion state
</span></span></code></pre></div><p>That is a much more realistic mobile-AI exercise than another chat screen.</p>
<hr>
<h1 id="the-systems-lesson">The systems lesson</h1>
<p>By now, I had learned something I wish I had written down on day one:</p>
<blockquote>
<p><strong>Mobile AI reliability is mostly about making fewer bad assumptions.</strong></p>
</blockquote>
<p>Do not assume:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">8 GB RAM = 8 GB available to me
</span></span></code></pre></div><p>Do not assume:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">GPU available = GPU reliable
</span></span></code></pre></div><p>Do not assume:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">model file size = peak memory
</span></span></code></pre></div><p>Do not assume:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">cancel button = cancellation
</span></span></code></pre></div><p>Do not assume:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">background process = process that will remain alive
</span></span></code></pre></div><p>And do not assume:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">runtime failure = model failure
</span></span></code></pre></div><p>Once I started treating those as explicit contracts, the application became much easier to reason about.</p>
<p>Then I did something that added a completely different class of pressure to the system.</p>
<p>I tried to generate images locally.</p>
<p><strong>Part 4:</strong> <a href="/posts/ai_playground_series/04-i-put-an-image-generator-inside-the-phone/">I Put an Image Generator Inside the Phone</a></p>
]]></content></item><item><title>Part 2 - The Day a Chat Box Became a Document Engine</title><link>https://deardhruv.com/posts/ai_playground_series/02-the-day-a-chat-box-became-a-document-engine/</link><pubDate>Mon, 05 Oct 2026 11:00:00 +0530</pubDate><author>dhruv.time@gmail.com (Dhruv Patel)</author><guid>https://deardhruv.com/posts/ai_playground_series/02-the-day-a-chat-box-became-a-document-engine/</guid><description>&lt;h1 id="the-day-a-chat-box-became-a-document-engine"&gt;The Day a Chat Box Became a Document Engine&lt;/h1&gt;
&lt;h2 id="part-2---i-thought-attach-a-pdf-would-be-a-ui-feature-it-became-an-ingestion-pipeline"&gt;Part 2 - I thought “attach a PDF” would be a UI feature. It became an ingestion pipeline.&lt;/h2&gt;
&lt;p&gt;Once local chat was reliable enough, I wanted the next capability that makes a local assistant genuinely useful:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Let me ask questions about my own files without uploading them.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That sounds like:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;paperclip icon
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;+
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;file picker
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It is not.&lt;/p&gt;
&lt;p&gt;A PDF is a container. A Word document is structured data. An Excel workbook is a workbook model. A scanned document may have no text layer at all. An image may contain words, diagrams, tables and visual context simultaneously.&lt;/p&gt;</description><content type="html"><![CDATA[<h1 id="the-day-a-chat-box-became-a-document-engine">The Day a Chat Box Became a Document Engine</h1>
<h2 id="part-2---i-thought-attach-a-pdf-would-be-a-ui-feature-it-became-an-ingestion-pipeline">Part 2 - I thought “attach a PDF” would be a UI feature. It became an ingestion pipeline.</h2>
<p>Once local chat was reliable enough, I wanted the next capability that makes a local assistant genuinely useful:</p>
<blockquote>
<p><strong>Let me ask questions about my own files without uploading them.</strong></p>
</blockquote>
<p>That sounds like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">paperclip icon
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">file picker
</span></span></code></pre></div><p>It is not.</p>
<p>A PDF is a container. A Word document is structured data. An Excel workbook is a workbook model. A scanned document may have no text layer at all. An image may contain words, diagrams, tables and visual context simultaneously.</p>
<p>And a mobile language model should not receive an entire 300-page document just because the user asked one question.</p>
<p>That is the point where the application changed from a chat client into a <strong>local document-processing system</strong>.</p>
<hr>
<h1 id="1-the-simplest-version-is-also-the-wrong-one">1. The simplest version is also the wrong one</h1>
<p>The obvious implementation is:</p>
<pre class="mermaid">flowchart TD
    File["file"] --> Extract["extract everything"]
    Extract --> Huge["huge string"]
    Huge --> Prompt["prompt"]
    Prompt --> LLM["LLM"]
</pre>
<p>For a tiny file, this works.</p>
<p>For anything serious, it creates three problems at once:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">too much context
</span></span><span class="line"><span class="cl">too much memory
</span></span><span class="line"><span class="cl">too much irrelevant information
</span></span></code></pre></div><p>So the application needs an intermediate representation.</p>
<hr>
<h1 id="2-i-gave-attachments-their-own-architecture">2. I gave attachments their own architecture</h1>
<p>The repository separates attachment processing into a dedicated feature module.</p>
<p>The pipeline is:</p>
<pre class="mermaid">flowchart TD
    A["selected file"] --> B["file identity"]
    B --> C["capability validation"]
    C --> D["format processor"]
    D --> E["semantic document blocks"]
    E --> F["chunking"]
    F --> G["local index"]
    G --> H["retrieval"]
    H --> I["context budget"]
    I --> J["local model"]
</pre>
<p>This is deliberately different from chat.</p>
<p>The chat feature should not know how to parse a DOCX package.</p>
<p>The document feature should not know how a Composable renders a message bubble.</p>
<p>That separation kept the scope under control.</p>
<hr>
<h1 id="3-i-stopped-trusting-file-extensions">3. I stopped trusting file extensions</h1>
<p>A filename can say:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">report.pdf
</span></span></code></pre></div><p>while the bytes are not actually a PDF.</p>
<p>So the attachment pipeline uses <strong>magic-byte inspection</strong> and file identity checks before handing content to a processor.</p>
<p>Conceptually:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">detectType</span><span class="p">(</span><span class="n">bytes</span><span class="p">:</span> <span class="n">ByteArray</span><span class="p">):</span> <span class="n">FileType</span> <span class="p">=</span>
</span></span><span class="line"><span class="cl">    <span class="k">when</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">startsWith</span><span class="p">(</span><span class="n">bytes</span><span class="p">,</span> <span class="s2">&#34;%PDF-&#34;</span><span class="p">)</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">            <span class="nc">FileType</span><span class="p">.</span><span class="n">PDF</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">looksLikePng</span><span class="p">(</span><span class="n">bytes</span><span class="p">)</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">            <span class="nc">FileType</span><span class="p">.</span><span class="n">PNG</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">looksLikeZipContainer</span><span class="p">(</span><span class="n">bytes</span><span class="p">)</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">            <span class="n">detectOfficeOrOpenDocument</span><span class="p">(</span><span class="n">bytes</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">else</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">            <span class="nc">FileType</span><span class="p">.</span><span class="n">UNKNOWN</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span></code></pre></div><p>The principle is:</p>
<blockquote>
<p><strong>The extension tells me what the user called the file. The content tells me what it is.</strong></p>
</blockquote>
<p>That distinction is useful for correctness and for avoiding parser confusion.</p>
<hr>
<h1 id="4-the-kmp-decision-mattered-here">4. The KMP decision mattered here</h1>
<p>The document engine supports a broad family of text and structured formats through shared code, including formats represented in the project as:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">TXT
</span></span><span class="line"><span class="cl">LOG
</span></span><span class="line"><span class="cl">MD
</span></span><span class="line"><span class="cl">CSV
</span></span><span class="line"><span class="cl">TSV
</span></span><span class="line"><span class="cl">JSON
</span></span><span class="line"><span class="cl">XML
</span></span><span class="line"><span class="cl">YAML
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">DOCX
</span></span><span class="line"><span class="cl">DOC
</span></span><span class="line"><span class="cl">XLSX
</span></span><span class="line"><span class="cl">XLS
</span></span><span class="line"><span class="cl">PPTX
</span></span><span class="line"><span class="cl">PPT
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">RTF
</span></span><span class="line"><span class="cl">ODT
</span></span><span class="line"><span class="cl">ODS
</span></span><span class="line"><span class="cl">ODP
</span></span></code></pre></div><p>The important architectural choice was to keep the core ingestion path <strong>pure Kotlin Multiplatform</strong> where practical.</p>
<p>I did not want:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Android parser
</span></span></code></pre></div><p>and:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">iOS parser
</span></span></code></pre></div><p>to slowly diverge.</p>
<p>Instead:</p>
<pre class="mermaid">flowchart TD
    Contracts["shared parser contracts"] --> Repr["shared semantic representation"]
    Repr --> Android["Android UI"]
    Repr --> iOS["iOS UI"]
</pre>
<p>That is exactly the kind of work KMP is good at.</p>
<hr>
<h1 id="5-container-formats-are-where-things-stop-looking-like-documents">5. Container formats are where things stop looking like “documents”</h1>
<p>Several office formats are effectively package containers.</p>
<p>Inside a single user-visible file I may have:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">XML
</span></span><span class="line"><span class="cl">relationships
</span></span><span class="line"><span class="cl">media
</span></span><span class="line"><span class="cl">styles
</span></span><span class="line"><span class="cl">metadata
</span></span><span class="line"><span class="cl">embedded objects
</span></span></code></pre></div><p>That pushed the document subsystem toward a bounded ZIP/Deflate implementation.</p>
<p>The key word is <strong>bounded</strong>.</p>
<p>I do not want a parser to blindly inflate an archive into memory.</p>
<p>The conceptual path is:</p>
<pre class="mermaid">flowchart TD
    A["archive entry"] --> B["validate entry"]
    B --> C["enforce output limit"]
    C --> D["bounded decompression"]
    D --> E["parse required content"]
</pre>
<p>That is normal systems engineering.</p>
<p>It just happens to be sitting underneath an AI feature.</p>
<hr>
<h1 id="6-i-needed-a-semantic-representation">6. I needed a semantic representation</h1>
<p>A giant <code>String</code> loses structure.</p>
<p>Suppose the document contains:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Chapter 4
</span></span><span class="line"><span class="cl">4.1 Introduction
</span></span><span class="line"><span class="cl">paragraph...
</span></span><span class="line"><span class="cl">table...
</span></span><span class="line"><span class="cl">image...
</span></span></code></pre></div><p>I want the retrieval layer to understand that <code>4.1 Introduction</code> is not just another sentence.</p>
<p>A simplified representation looks like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">sealed</span> <span class="k">interface</span> <span class="nc">DocumentBlock</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">data</span> <span class="k">class</span> <span class="nc">Heading</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">level</span><span class="p">:</span> <span class="n">Int</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">text</span><span class="p">:</span> <span class="n">String</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span> <span class="p">:</span> <span class="n">DocumentBlock</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">data</span> <span class="k">class</span> <span class="nc">Paragraph</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">text</span><span class="p">:</span> <span class="n">String</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span> <span class="p">:</span> <span class="n">DocumentBlock</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">data</span> <span class="k">class</span> <span class="nc">Table</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">rows</span><span class="p">:</span> <span class="n">List</span><span class="p">&lt;</span><span class="n">List</span><span class="p">&lt;</span><span class="n">String</span><span class="p">&gt;&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span> <span class="p">:</span> <span class="n">DocumentBlock</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">data</span> <span class="k">class</span> <span class="nc">ImageReference</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">id</span><span class="p">:</span> <span class="n">String</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span> <span class="p">:</span> <span class="n">DocumentBlock</span>
</span></span></code></pre></div><p>This gives later stages enough structure to carry provenance.</p>
<hr>
<h1 id="7-retrieval-is-where-local-rag-becomes-useful">7. Retrieval is where local RAG becomes useful</h1>
<p>A large document can be indexed once and queried many times.</p>
<p>The flow becomes:</p>
<pre class="mermaid">flowchart TD
    Doc["document"] --> Parse["parse"] --> Chunks["chunks"] --> Index["local index"]
</pre>
<p>Then:</p>
<pre class="mermaid">flowchart TD
    Q["user question"] --> R["retrieve relevant chunks"] --> Rank["rank"] --> Budget["budget"] --> LLM["LLM"]
</pre>
<p>This is not only about quality.</p>
<p>On a phone, it is also about <strong>resource control</strong>.</p>
<hr>
<h1 id="8-why-context-budgeting-matters-on-a-phone">8. Why context budgeting matters on a phone</h1>
<p>Suppose my retrieved document text is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">25,000 tokens
</span></span></code></pre></div><p>Even if the model accepts that context, sending all of it means more:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">prefill work
</span></span><span class="line"><span class="cl">KV cache
</span></span><span class="line"><span class="cl">latency
</span></span><span class="line"><span class="cl">memory
</span></span><span class="line"><span class="cl">battery
</span></span></code></pre></div><p>So the retrieval layer is deliberately followed by a strict context budget.</p>
<p>The architecture is:</p>
<pre class="mermaid">flowchart TD
    A["retrieve generously"] --> B["rank carefully"] --> C["budget aggressively"] --> D["generate"]
</pre>
<p>A simplified version:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">var</span> <span class="py">used</span> <span class="p">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="p">(</span><span class="n">result</span> <span class="k">in</span> <span class="n">rankedResults</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">snippet</span> <span class="p">=</span> <span class="n">formatForContext</span><span class="p">(</span><span class="n">result</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">used</span> <span class="p">+</span> <span class="n">estimateTokens</span><span class="p">(</span><span class="n">snippet</span><span class="p">)</span> <span class="p">&gt;</span> <span class="n">budget</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">break</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">context</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">snippet</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">used</span> <span class="o">+=</span> <span class="n">estimateTokens</span><span class="p">(</span><span class="n">snippet</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>The exact estimator can vary.</p>
<p>The policy should not.</p>
<hr>
<h1 id="9-why-i-prefer-hybrid-retrieval">9. Why I prefer hybrid retrieval</h1>
<p>Keyword search is excellent at exact identifiers:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">API-1847
</span></span><span class="line"><span class="cl">SKU-42
</span></span><span class="line"><span class="cl">Section 9.2
</span></span></code></pre></div><p>Semantic retrieval is better when the user asks:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">How does the company verify customer identity?
</span></span></code></pre></div><p>but the document says:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Identity verification is performed during onboarding...
</span></span></code></pre></div><p>So I want both signals:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">             query
</span></span><span class="line"><span class="cl">               |
</span></span><span class="line"><span class="cl">       +-------+-------+
</span></span><span class="line"><span class="cl">       |               |
</span></span><span class="line"><span class="cl">       v               v
</span></span><span class="line"><span class="cl"> lexical search   semantic search
</span></span><span class="line"><span class="cl">       |               |
</span></span><span class="line"><span class="cl">       +-------+-------+
</span></span><span class="line"><span class="cl">               |
</span></span><span class="line"><span class="cl">               v
</span></span><span class="line"><span class="cl">         merge + rank
</span></span><span class="line"><span class="cl">               |
</span></span><span class="line"><span class="cl">               v
</span></span><span class="line"><span class="cl">          top chunks
</span></span></code></pre></div><p>The project exposes a hybrid retrieval coordinator so this logic stays in the domain layer.</p>
<hr>
<h1 id="10-provenance-matters">10. Provenance matters</h1>
<p>A local document assistant should not just produce:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">The contract allows termination after 30 days.
</span></span></code></pre></div><p>I want it to be possible to say:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Source:
</span></span><span class="line"><span class="cl">contract.pdf
</span></span><span class="line"><span class="cl">Page 17
</span></span><span class="line"><span class="cl">Section 8.2
</span></span></code></pre></div><p>The retrieval result therefore carries citation information.</p>
<p>Conceptually:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">data</span> <span class="k">class</span> <span class="nc">ChunkCitation</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">chunkId</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">documentId</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">fileName</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">section</span><span class="p">:</span> <span class="n">String</span><span class="p">?,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">pageNumber</span><span class="p">:</span> <span class="n">Int</span><span class="p">?,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">score</span><span class="p">:</span> <span class="n">Float</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span></code></pre></div><p>This is one of the places where I think product trust and systems design meet.</p>
<p>The answer is only as useful as the user&rsquo;s ability to verify it.</p>
<hr>
<h1 id="11-then-i-had-to-handle-images">11. Then I had to handle images</h1>
<p>Documents are often visual.</p>
<p>A scanned PDF may contain:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">image of page
</span></span></code></pre></div><p>with no usable text layer.</p>
<p>A screenshot may contain:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">UI
</span></span><span class="line"><span class="cl">buttons
</span></span><span class="line"><span class="cl">text
</span></span><span class="line"><span class="cl">icons
</span></span><span class="line"><span class="cl">charts
</span></span></code></pre></div><p>A photo may contain:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">objects
</span></span><span class="line"><span class="cl">people
</span></span><span class="line"><span class="cl">text
</span></span><span class="line"><span class="cl">spatial relationships
</span></span></code></pre></div><p>That means there are two different tools:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">OCR
</span></span></code></pre></div><p>and:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">vision understanding
</span></span></code></pre></div><hr>
<h1 id="12-ocr-and-vision-are-not-the-same-feature">12. OCR and vision are not the same feature</h1>
<p>OCR asks:</p>
<blockquote>
<p><strong>What text is visible?</strong></p>
</blockquote>
<p>Vision asks broader questions:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">What is this?
</span></span><span class="line"><span class="cl">What is happening?
</span></span><span class="line"><span class="cl">How are these objects related?
</span></span><span class="line"><span class="cl">What does this chart appear to show?
</span></span></code></pre></div><p>A screenshot might produce:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">OCR
</span></span><span class="line"><span class="cl">----
</span></span><span class="line"><span class="cl">Settings
</span></span><span class="line"><span class="cl">Account
</span></span><span class="line"><span class="cl">Privacy
</span></span><span class="line"><span class="cl">Notifications
</span></span></code></pre></div><p>while a vision-capable model can reason:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">This is the Settings screen.
</span></span><span class="line"><span class="cl">The Privacy option appears below Account.
</span></span></code></pre></div><p>I do not want to choose one permanently.</p>
<p>The application can use:</p>
<pre class="mermaid">flowchart TD
    Img["image"] --> OCR["OCR"]
    Img --> Vision["vision"]
    OCR --> Combined["combined context"]
    Vision --> Combined
</pre>
<p>depending on the model and the task.</p>
<hr>
<h1 id="13-vision-is-another-capability-check">13. Vision is another capability check</h1>
<p>This is where the model capability system proved useful again.</p>
<p>A user picks a text-only model.</p>
<p>Then attaches a photo.</p>
<p>The worst UX is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Send
</span></span><span class="line"><span class="cl">  |
</span></span><span class="line"><span class="cl">  v
</span></span><span class="line"><span class="cl">native runtime exception
</span></span></code></pre></div><p>The better UX is:</p>
<pre class="mermaid">flowchart TD
    Model["selected model"] --> Resolver["capability resolver"]
    Resolver -->|image input supported| Enable["enable"]
    Resolver -->|not supported| Disable["explain limitation &<br/>disable incompatible action"]
</pre>
<p>This is a direct consequence of treating model support as <strong>data</strong>.</p>
<hr>
<h1 id="14-image-dimensions-are-part-of-the-resource-policy">14. Image dimensions are part of the resource policy</h1>
<p>A camera photo might be:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">4032 × 3024
</span></span></code></pre></div><p>That does not mean the vision model should receive those exact dimensions.</p>
<p>A mobile image path needs something like:</p>
<pre class="mermaid">flowchart TD
    A["original image"] --> B["orientation correction"] --> C["model-aware resize"] --> D["bounded pixel representation"] --> E["vision runtime"]
</pre>
<p>Otherwise a simple attachment can create an unnecessary memory spike.</p>
<p>Again:</p>
<blockquote>
<p><strong>Input preprocessing is part of the runtime contract.</strong></p>
</blockquote>
<hr>
<h1 id="15-the-hard-cases-are-the-real-feature">15. The hard cases are the real feature</h1>
<p>A parser demo with one clean PDF is not enough.</p>
<p>The test matrix I care about looks more like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">empty file
</span></span><span class="line"><span class="cl">truncated file
</span></span><span class="line"><span class="cl">corrupt PDF
</span></span><span class="line"><span class="cl">corrupt ZIP
</span></span><span class="line"><span class="cl">large archive
</span></span><span class="line"><span class="cl">wrong extension
</span></span><span class="line"><span class="cl">invalid text encoding
</span></span><span class="line"><span class="cl">image-only PDF
</span></span><span class="line"><span class="cl">scanned PDF
</span></span><span class="line"><span class="cl">very long document
</span></span><span class="line"><span class="cl">duplicate attachment
</span></span><span class="line"><span class="cl">unsupported type
</span></span><span class="line"><span class="cl">unsupported model modality
</span></span><span class="line"><span class="cl">missing runtime companion artifact
</span></span></code></pre></div><p>The project contains dedicated attachment and capability tests because this is exactly where regressions appear.</p>
<p>A good document system is not the one that parses one beautiful document.</p>
<p>It is the one that fails <strong>predictably</strong> when the input is ugly.</p>
<hr>
<h1 id="16-the-final-local-document-loop">16. The final local document loop</h1>
<p>The whole workflow now looks like:</p>
<pre class="mermaid">flowchart TD
    Q["'Find the termination clause in my contract.'"] --> Index["local document index"]
    Index --> Retrieval["hybrid retrieval"]
    Retrieval --> Chunks["relevant chunks"]
    Chunks --> Budget["context budget"]
    Budget --> LLM["local LLM"]
    LLM --> Ans["answer + source"]
</pre>
<p>The contract never needed to become an upload.</p>
<p>That was the original reason for doing this locally.</p>
<hr>
<h1 id="17-build-this-yourself">17. Build this yourself</h1>
<h2 id="project-1---offline-pdf-qa">Project 1 - Offline PDF Q&amp;A</h2>
<p>Start simple:</p>
<pre class="mermaid">flowchart TD
    PDF["PDF"] --> Extract["text extraction"] --> Chunk["chunking"] --> Keyword["keyword retrieval"] --> Model["local model"]
</pre>
<p>Then add:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">embeddings
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">keyword search
</span></span></code></pre></div><p>Then add citations.</p>
<p>Do not start with an agent.</p>
<p>Learn retrieval first.</p>
<hr>
<h2 id="project-2---a-visionocr-laboratory">Project 2 - A vision/OCR laboratory</h2>
<p>Build an app with:</p>
<pre class="mermaid">flowchart TD
    Source["gallery / camera"] --> Check["capability check"] --> Resize["image resize"]
    Resize --> OCR["OCR"]
    Resize --> Vision["vision"]
    OCR --> Answer["answer"]
    Vision --> Answer
</pre>
<p>Test it on:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">receipt
</span></span><span class="line"><span class="cl">chart
</span></span><span class="line"><span class="cl">screenshot
</span></span><span class="line"><span class="cl">scanned form
</span></span><span class="line"><span class="cl">street photo
</span></span><span class="line"><span class="cl">product label
</span></span></code></pre></div><p>The interesting results will not all be successful.</p>
<p>That is the point.</p>
<hr>
<h2 id="project-3---file-capability-matrix">Project 3 - File capability matrix</h2>
<p>Build a matrix like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">                    Model A     Model B
</span></span><span class="line"><span class="cl">TXT                    ✓           ✓
</span></span><span class="line"><span class="cl">PDF                    ✓           ✓
</span></span><span class="line"><span class="cl">DOCX                   ✓           ✓
</span></span><span class="line"><span class="cl">IMAGE                  ✗           ✓
</span></span></code></pre></div><p>Make the UI derive attachment availability from it.</p>
<p>Now your UI is no longer guessing.</p>
<hr>
<h1 id="what-changed-because-of-documents">What changed because of documents</h1>
<p>Before attachments, the core question was:</p>
<blockquote>
<p>“How do I run a model locally?”</p>
</blockquote>
<p>After attachments, the real question became:</p>
<blockquote>
<p><strong>“What should the model see?”</strong></p>
</blockquote>
<p>That is a more interesting product problem.</p>
<p>And once documents, images and models all started competing for the same phone resources, another question became unavoidable:</p>
<p><strong>What happens when the phone itself says no?</strong></p>
<p><strong>Part 3:</strong> <a href="/posts/ai_playground_series/03-the-model-was-fine-the-phone-wasnt/">The Model Was Fine. The Phone Wasn&rsquo;t.</a></p>
]]></content></item><item><title>Part 1 - I Wanted the Model to Stay on the Phone</title><link>https://deardhruv.com/posts/ai_playground_series/01-i-wanted-the-model-to-stay-on-the-phone/</link><pubDate>Mon, 05 Oct 2026 10:00:00 +0530</pubDate><author>dhruv.time@gmail.com (Dhruv Patel)</author><guid>https://deardhruv.com/posts/ai_playground_series/01-i-wanted-the-model-to-stay-on-the-phone/</guid><description>&lt;h1 id="beyond-the-model-building-a-private-ai-playground-for-android--ios"&gt;Beyond the Model: Building a Private AI Playground for Android &amp;amp; iOS&lt;/h1&gt;
&lt;h2 id="part-1---i-wanted-the-model-to-stay-on-the-phone"&gt;Part 1 - I Wanted the Model to Stay on the Phone&lt;/h2&gt;
&lt;h3 id="the-engineering-journey-of-integrating-established-on-device-ai-runtimes-into-one-multimodal-mobile-product---and-everything-it-takes-to-make-the-pieces-behave-like-a-coherent-system"&gt;The engineering journey of integrating established on-device AI runtimes into one multimodal mobile product - and everything it takes to make the pieces behave like a coherent system&lt;/h3&gt;
&lt;p&gt;There was one constraint behind AI Playground that shaped almost every architectural decision that came after it:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;I did not want the core AI workflow to depend on a server.&lt;/strong&gt;&lt;/p&gt;</description><content type="html"><![CDATA[<h1 id="beyond-the-model-building-a-private-ai-playground-for-android--ios">Beyond the Model: Building a Private AI Playground for Android &amp; iOS</h1>
<h2 id="part-1---i-wanted-the-model-to-stay-on-the-phone">Part 1 - I Wanted the Model to Stay on the Phone</h2>
<h3 id="the-engineering-journey-of-integrating-established-on-device-ai-runtimes-into-one-multimodal-mobile-product---and-everything-it-takes-to-make-the-pieces-behave-like-a-coherent-system">The engineering journey of integrating established on-device AI runtimes into one multimodal mobile product - and everything it takes to make the pieces behave like a coherent system</h3>
<p>There was one constraint behind AI Playground that shaped almost every architectural decision that came after it:</p>
<blockquote>
<p><strong>I did not want the core AI workflow to depend on a server.</strong></p>
</blockquote>
<p>I want to be precise about what that means.</p>
<p>I did <strong>not</strong> build a new mobile inference engine. I did <strong>not</strong> invent a new way to execute transformer models on phones. The project stands on top of established work from the open-source and platform ecosystems - <code>llama.cpp</code>, GGML, Google AI Edge/LiteRT-LM, MediaPipe, Apple MLX, and other native technologies.</p>
<p>My job was different.</p>
<p>I wanted to take those building blocks and make them behave like <strong>one application</strong>.</p>
<p>That meant answering the questions that only appear once you move from “model demo” to “mobile product”:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Which runtime should handle this model?
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Does this model actually support the requested input?
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Can this device safely load it?
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">What happens when acceleration fails?
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">How do I keep native work away from the UI thread?
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">What happens if the user switches models mid-operation?
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">How do I make Android and iOS feel like themselves
</span></span><span class="line"><span class="cl">without duplicating the entire application?
</span></span></code></pre></div><p>That is where the project became interesting.</p>
<hr>
<h1 id="1-the-promise-is-simple-the-engineering-is-not">1. The promise is simple. The engineering is not</h1>
<p>From a user&rsquo;s point of view, the experience sounds almost boring:</p>
<pre class="mermaid">flowchart TD
    A["Install application"] --> B["Download model"] --> C["Turn off network"] --> D["Start conversation"] --> E["Receive answer"]
</pre>
<p>The repository makes the offline-first boundary explicit.</p>
<p>The inference path is local:</p>
<pre class="mermaid">flowchart TD
    Prompt["Prompt"] --> App["Shared application logic"]
    App --> Runtime["Selected local runtime"]
    Runtime --> Native["Native inference"]
    Native --> Response["Local response / history"]
</pre>
<p>The internet-enabled pieces are separated from inference:</p>
<pre class="mermaid">flowchart TD
    Network["Network"] --> Discovery["Model discovery"]
    Network --> Download["Model download"]
    Network --> Search["Live Hugging Face search"]
    Network --> Other["Other explicitly allowed surfaces"]
</pre>
<p>That separation is more important than the phrase “offline”.</p>
<p>A product can call itself local while still quietly sending data through a logging service, a fallback endpoint, a remote parser, or a convenience API.</p>
<p>I wanted the architecture to make accidental data movement harder.</p>
<hr>
<h1 id="2-i-needed-android-and-ios-to-share-the-right-things">2. I needed Android and iOS to share the right things</h1>
<p>The project uses <strong>Kotlin Multiplatform</strong> and <strong>Compose Multiplatform</strong>, but I never wanted “multiplatform” to mean “pretend both platforms are identical”.</p>
<p>The architecture is closer to:</p>
<pre class="mermaid">flowchart TD
    subgraph KMP["Shared KMP"]
        Domain["Domain"]
        Data["Data"]
    end
    KMP --> Boundaries["Runtime boundaries"]
    Boundaries --> Android["Android<br/><b>Kotlin / C++</b>"]
    Boundaries --> iOS["iOS<br/><b>Swift / Metal</b>"]
</pre>
<p>The shared layer is responsible for:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">models
</span></span><span class="line"><span class="cl">use cases
</span></span><span class="line"><span class="cl">repositories
</span></span><span class="line"><span class="cl">policies
</span></span><span class="line"><span class="cl">state
</span></span><span class="line"><span class="cl">feature contracts
</span></span></code></pre></div><p>Platform code owns things that actually depend on the operating system or native acceleration stack.</p>
<p>That is the line I keep coming back to:</p>
<blockquote>
<p><strong>Share the decision. Keep the hardware-specific execution native.</strong></p>
</blockquote>
<hr>
<h1 id="3-the-first-abstraction-i-cared-about-was-the-runtime-boundary">3. The first abstraction I cared about was the runtime boundary</h1>
<p>Once the app had more than one way to execute a model, the UI could no longer be allowed to know all of them.</p>
<p>I did not want:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">model</span><span class="p">.</span><span class="n">isLlamaCpp</span><span class="p">())</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// llama-specific UI logic
</span></span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">model</span><span class="p">.</span><span class="n">isMediaPipe</span><span class="p">())</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// MediaPipe-specific UI logic
</span></span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">model</span><span class="p">.</span><span class="n">isMLX</span><span class="p">())</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// MLX-specific UI logic
</span></span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>That kind of condition spreads everywhere.</p>
<p>Instead, the project converged on a common runtime contract.</p>
<p>A simplified version is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">interface</span> <span class="nc">LocalModelRuntime</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">loadedModel</span><span class="p">:</span> <span class="n">StateFlow</span><span class="p">&lt;</span><span class="n">LoadedModel</span><span class="p">?&gt;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">suspend</span> <span class="k">fun</span> <span class="nf">load</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">model</span><span class="p">:</span> <span class="n">LocalModelRef</span>
</span></span><span class="line"><span class="cl">    <span class="p">):</span> <span class="n">Result</span><span class="p">&lt;</span><span class="n">ModelLoadInfo</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">fun</span> <span class="nf">generate</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">request</span><span class="p">:</span> <span class="n">GenerationRequest</span>
</span></span><span class="line"><span class="cl">    <span class="p">):</span> <span class="n">Flow</span><span class="p">&lt;</span><span class="n">GenerationEvent</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">suspend</span> <span class="k">fun</span> <span class="nf">cancel</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">suspend</span> <span class="k">fun</span> <span class="nf">unload</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>The UI asks for:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">load
</span></span><span class="line"><span class="cl">generate
</span></span><span class="line"><span class="cl">cancel
</span></span><span class="line"><span class="cl">unload
</span></span></code></pre></div><p>The runtime implementation decides how those operations are actually performed.</p>
<hr>
<h1 id="4-that-does-not-mean-the-runtimes-are-interchangeable">4. That does not mean the runtimes are interchangeable</h1>
<p>The abstraction hides implementation details.</p>
<p>It does <strong>not</strong> erase real capability differences.</p>
<p>The current project integrates runtime families including:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Android
</span></span><span class="line"><span class="cl">-------
</span></span><span class="line"><span class="cl">GGUF -&gt; llama.cpp / GGML
</span></span><span class="line"><span class="cl">.task / .litertlm -&gt; LiteRT-LM / MediaPipe
</span></span><span class="line"><span class="cl">LiteRT multi-graph -&gt; image generation
</span></span><span class="line"><span class="cl">stable-diffusion.cpp -&gt; GGUF image models
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">iOS
</span></span><span class="line"><span class="cl">---
</span></span><span class="line"><span class="cl">llama.cpp / Metal path
</span></span><span class="line"><span class="cl">Apple MLX
</span></span><span class="line"><span class="cl">Core ML
</span></span></code></pre></div><p>The application has to know which path is appropriate.</p>
<p>That led to one of the most useful concepts in the project:</p>
<blockquote>
<p><strong>A model is not “supported” just because the file downloaded successfully.</strong></p>
</blockquote>
<hr>
<h1 id="5-model-support-became-a-capability-problem">5. Model support became a capability problem</h1>
<p>I started thinking of a model as a tuple of properties:</p>
<pre class="mermaid">flowchart TD
    Model["Model"]
    Model --> F["Format"]
    Model --> A["Architecture"]
    Model --> M["Modality"]
    Model --> R["Runtime"]
    Model --> P["Platform"]
    Model --> C["Companion artifacts"]
    Model --> Req["Minimum runtime requirements"]
    Model --> Res["Resource profile"]
</pre>
<p>A model can be:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">GGUF                 = yes
</span></span><span class="line"><span class="cl">Android              = yes
</span></span><span class="line"><span class="cl">llama.cpp             = yes
</span></span><span class="line"><span class="cl">vision                = no
</span></span></code></pre></div><p>or:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">.task                 = yes
</span></span><span class="line"><span class="cl">MediaPipe runtime     = yes
</span></span><span class="line"><span class="cl">text                  = yes
</span></span><span class="line"><span class="cl">image input           = no
</span></span></code></pre></div><p>Or even:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">architecture supported
</span></span><span class="line"><span class="cl">runtime available
</span></span><span class="line"><span class="cl">companion file missing
</span></span></code></pre></div><p>The last case is particularly important.</p>
<p>The UI needs to know before it sends the user into a native error path.</p>
<hr>
<h1 id="6-capability-detection-belongs-below-the-ui">6. Capability detection belongs below the UI</h1>
<p>The application has Android-side inspectors for GGUF and LiteRT-related artifacts.</p>
<p>The broader architecture looks like:</p>
<pre class="mermaid">flowchart TD
    Artifact["Model artifact"] --> Inspection["Binary / metadata inspection"]
    Inspection --> Report["Capability report"]
    Report --> Resolver["Runtime resolver"]
    Resolver --> UI["UI"]
</pre>
<p>A simplified data structure:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">data</span> <span class="k">class</span> <span class="nc">ModelCapabilityReport</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">textInput</span><span class="p">:</span> <span class="n">Boolean</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">imageInput</span><span class="p">:</span> <span class="n">Boolean</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">audioInput</span><span class="p">:</span> <span class="n">Boolean</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">supportedRuntimes</span><span class="p">:</span> <span class="n">Set</span><span class="p">&lt;</span><span class="n">Backend</span><span class="p">&gt;,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">platformSupported</span><span class="p">:</span> <span class="n">Boolean</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">missingArtifacts</span><span class="p">:</span> <span class="n">List</span><span class="p">&lt;</span><span class="n">String</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span></code></pre></div><p>The actual project goes deeper than this example, but the principle is the important part.</p>
<p><strong>Capability is data.</strong></p>
<p>It should not be duplicated as assumptions in five screens.</p>
<hr>
<h1 id="7-memory-turned-out-to-be-the-next-abstraction">7. Memory turned out to be the next abstraction</h1>
<p>The next surprise was how misleading model file size can be.</p>
<p>If a model artifact is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">2 GB on disk
</span></span></code></pre></div><p>the application should not make a decision from:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">fileSize</span> <span class="p">&lt;</span> <span class="n">availableRam</span><span class="p">)</span>
</span></span></code></pre></div><p>The runtime also needs:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">weights
</span></span><span class="line"><span class="cl">+ KV cache
</span></span><span class="line"><span class="cl">+ native overhead
</span></span><span class="line"><span class="cl">+ temporary allocations
</span></span><span class="line"><span class="cl">+ accelerator buffers
</span></span><span class="line"><span class="cl">+ multimodal components
</span></span><span class="line"><span class="cl">+ application pressure
</span></span></code></pre></div><p>So the application has a model-memory estimation layer.</p>
<p>Conceptually:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">estimatedPeakBytes</span> <span class="p">=</span>
</span></span><span class="line"><span class="cl">      <span class="n">weightBytes</span>
</span></span><span class="line"><span class="cl">    <span class="p">+</span> <span class="n">kvCacheBytes</span>
</span></span><span class="line"><span class="cl">    <span class="p">+</span> <span class="n">runtimeOverheadBytes</span>
</span></span><span class="line"><span class="cl">    <span class="p">+</span> <span class="n">modalityOverheadBytes</span>
</span></span><span class="line"><span class="cl">    <span class="p">+</span> <span class="n">safetyMarginBytes</span>
</span></span></code></pre></div><p>This is an <strong>admission heuristic</strong>, not a promise that the native allocator will use exactly that amount.</p>
<p>The job of the admission layer is:</p>
<blockquote>
<p><strong>Do I have enough evidence to start this expensive operation safely?</strong></p>
</blockquote>
<hr>
<h1 id="8-mmap-changed-how-i-thought-about-model-files">8. mmap changed how I thought about model files</h1>
<p>For large native model artifacts, memory mapping is useful because it changes the relationship between:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">storage
</span></span><span class="line"><span class="cl">virtual address space
</span></span><span class="line"><span class="cl">resident memory
</span></span></code></pre></div><p>The conceptual difference is:</p>
<pre class="mermaid">flowchart LR
    subgraph ReadAll["Read-all approach"]
        direction TB
        F1["file"] --> B1["large memory buffer"]
    end
    subgraph MMap["mmap approach"]
        direction TB
        F2["file"] --> R2["mapped address range"]
        R2 --> P2["pages become resident as needed"]
    end
</pre>
<p>This does not make memory pressure disappear.</p>
<p>It does make it possible to reason more accurately about <strong>file size versus actual resident pages</strong>.</p>
<hr>
<h1 id="9-gpu-acceleration-is-another-integration-layer">9. GPU acceleration is another integration layer</h1>
<p>The Android GGML integration uses a modular backend layout.</p>
<p>The project packages a baseline CPU/native stack and loads acceleration backends dynamically.</p>
<p>The intended backend order is:</p>
<pre class="mermaid">flowchart TD
    Vulkan["Vulkan<br/><i>Preferred accelerated path</i>"]
    OpenCL["OpenCL<br/><i>Fallback accelerated path</i>"]
    CPU["CPU<br/><i>Final stable fallback</i>"]
    Vulkan -->|fallback| OpenCL
    OpenCL -->|fallback| CPU
</pre>
<p>That is a project-level decision.</p>
<p>It is not an attempt to replace the GPU runtimes themselves.</p>
<p>The useful engineering work is in deciding:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">when to use
</span></span><span class="line"><span class="cl">when to fall back
</span></span><span class="line"><span class="cl">how to detect failure
</span></span><span class="line"><span class="cl">how to keep the application alive
</span></span></code></pre></div><hr>
<h1 id="10-why-i-classified-failures">10. Why I classified failures</h1>
<p>This became important very quickly.</p>
<p>Consider these two errors:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">GPU initialization failed
</span></span></code></pre></div><p>and:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">not enough memory
</span></span></code></pre></div><p>They both mean:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">load failed
</span></span></code></pre></div><p>but they are not the same problem.</p>
<p>The first may reasonably trigger:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Vulkan -&gt; OpenCL -&gt; CPU
</span></span></code></pre></div><p>The second often should not.</p>
<p>If RAM is the limiting factor, trying a different GPU backend does not magically create RAM.</p>
<p>So the routing logic is intentionally reason-aware:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">for</span> <span class="p">(</span><span class="n">backend</span> <span class="k">in</span> <span class="n">candidateBackends</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">result</span> <span class="p">=</span> <span class="n">runtimeFor</span><span class="p">(</span><span class="n">backend</span><span class="p">).</span><span class="n">load</span><span class="p">(</span><span class="n">model</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">result</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isInsufficientMemory</span><span class="p">())</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">result</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="n">lastFailure</span>
</span></span></code></pre></div><p>That small distinction eliminates a surprising amount of pointless retry behavior.</p>
<hr>
<h1 id="11-native-code-is-a-boundary-not-a-magic-escape-hatch">11. Native code is a boundary, not a magic escape hatch</h1>
<p>The Android app has native C++ integration around llama.cpp and a separate native image-generation module.</p>
<p>The conceptual stack is:</p>
<pre class="mermaid">flowchart TD
    Kotlin["Kotlin"] --> JNI["JNI"]
    JNI --> CPP["C / C++"]
    CPP --> Runtime["Third-party inference runtime"]
    CPP --> Backend["Accelerator backend"]
</pre>
<p>Once a feature crosses JNI, I need to think about:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">ownership
</span></span><span class="line"><span class="cl">lifecycle
</span></span><span class="line"><span class="cl">threading
</span></span><span class="line"><span class="cl">cancellation
</span></span><span class="line"><span class="cl">error mapping
</span></span><span class="line"><span class="cl">resource cleanup
</span></span><span class="line"><span class="cl">process crashes
</span></span></code></pre></div><p>I learned to treat every native context like a resource with an explicit lifecycle:</p>
<pre class="mermaid">flowchart TD
    Load["load"] --> Resident["resident"]
    Resident --> Generate["generate"]
    Resident --> Cancel["cancel"]
    Resident --> Unload["unload"]
</pre>
<p>The UI should never own that lifecycle directly.</p>
<hr>
<h1 id="12-why-i-kept-heavy-work-away-from-the-main-thread">12. Why I kept heavy work away from the main thread</h1>
<p>The project has an explicit concurrency model:</p>
<pre class="mermaid">flowchart TD
    UI["UI"] --> Store["MVI Store"]
    Store --> UseCase["Use Case"]
    UseCase --> Repo["Repository / Runtime"]
    Repo --> IO["Dispatchers.IO"]
    Repo --> Default["Dispatchers.Default"]
</pre>
<p>Heavy work includes:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">file reads
</span></span><span class="line"><span class="cl">SHA-256 verification
</span></span><span class="line"><span class="cl">model inspection
</span></span><span class="line"><span class="cl">database queries
</span></span><span class="line"><span class="cl">document parsing
</span></span><span class="line"><span class="cl">native inference dispatch
</span></span><span class="line"><span class="cl">image decoding
</span></span><span class="line"><span class="cl">generation orchestration
</span></span></code></pre></div><p>A simple example:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">viewModelScope</span><span class="p">.</span><span class="n">launch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">result</span> <span class="p">=</span> <span class="n">withContext</span><span class="p">(</span><span class="nc">Dispatchers</span><span class="p">.</span><span class="n">IO</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">repository</span><span class="p">.</span><span class="n">inspectModel</span><span class="p">(</span><span class="n">modelPath</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">state</span><span class="p">.</span><span class="n">update</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">it</span><span class="p">.</span><span class="n">copy</span><span class="p">(</span><span class="n">model</span> <span class="p">=</span> <span class="n">result</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>The point is not “always use IO”.</p>
<p>The point is:</p>
<blockquote>
<p><strong>Do not make the UI thread your accidental systems-processing thread.</strong></p>
</blockquote>
<hr>
<h1 id="13-streaming-needs-pacing-too">13. Streaming needs pacing too</h1>
<p>A native model can produce a lot of tiny generation events.</p>
<p>The UI does not need to render each event as a separate expensive state transition.</p>
<p>The project keeps a dedicated generation coordinator that coalesces updates at roughly a <strong>65 ms UI cadence</strong> and uses longer watchdog windows for stalled generation.</p>
<p>Conceptually:</p>
<pre class="mermaid">flowchart TD
    Events["Native events"] --> Coalesce["Coalesce (~65ms)"]
    Coalesce --> StateFlow["StateFlow"]
    StateFlow --> Compose["Compose"]
</pre>
<p>That is a small detail with a large effect on perceived performance.</p>
<hr>
<h1 id="14-i-wanted-failure-to-be-understandable">14. I wanted failure to be understandable</h1>
<p>One of the first product lessons was that:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&#34;Load failed&#34;
</span></span></code></pre></div><p>is almost useless.</p>
<p>A better message tells the user what happened:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">This model is estimated to exceed the device&#39;s safe
</span></span><span class="line"><span class="cl">memory budget at the selected context length.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Try:
</span></span><span class="line"><span class="cl">- a smaller quantization
</span></span><span class="line"><span class="cl">- a shorter context
</span></span><span class="line"><span class="cl">- unloading the current model
</span></span></code></pre></div><p>The engineering policy becomes part of the UX.</p>
<p>That is something I want AI applications to do more often.</p>
<hr>
<h1 id="15-what-the-first-version-changed-in-my-thinking">15. What the first version changed in my thinking</h1>
<p>I started with:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">chat screen
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">local model
</span></span></code></pre></div><p>I ended up with:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">chat
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">runtime contracts
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">model capability inspection
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">memory admission
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">hardware diagnostics
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">native boundaries
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">persistence
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">MVI
</span></span><span class="line"><span class="cl">+
</span></span><span class="line"><span class="cl">KMP
</span></span></code></pre></div><p>The model was still the important dependency.</p>
<p>But the <strong>application around it had become the real system</strong>.</p>
<p>And that led to the next question:</p>
<blockquote>
<p>What happens when the input is not text?</p>
</blockquote>
<p><strong>A PDF.</strong></p>
<p><strong>A spreadsheet.</strong></p>
<p><strong>A screenshot.</strong></p>
<p><strong>A photo.</strong></p>
<p>That is where the next phase started.</p>
<hr>
<h1 id="build-this-yourself">Build this yourself</h1>
<h2 id="project-1---offline-pocket-llm">Project 1 - Offline Pocket LLM</h2>
<p>Do not start with a giant model.</p>
<p>Start with:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">one GGUF
</span></span><span class="line"><span class="cl">one runtime
</span></span><span class="line"><span class="cl">one chat screen
</span></span><span class="line"><span class="cl">local persistence
</span></span><span class="line"><span class="cl">streaming
</span></span><span class="line"><span class="cl">cancel
</span></span><span class="line"><span class="cl">unload
</span></span></code></pre></div><p>Instrument:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">time to first token
</span></span><span class="line"><span class="cl">tokens / second
</span></span><span class="line"><span class="cl">load time
</span></span><span class="line"><span class="cl">peak process memory
</span></span><span class="line"><span class="cl">UI frame stability
</span></span></code></pre></div><p>Run the same test on CPU and accelerated paths.</p>
<p>The exercise is not to beat a server.</p>
<p>It is to understand what your device is actually doing.</p>
<hr>
<h2 id="project-2---capability-aware-model-catalog">Project 2 - Capability-aware model catalog</h2>
<p>Build a model details screen that answers:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Can this model:
</span></span><span class="line"><span class="cl">    run on this device?
</span></span><span class="line"><span class="cl">    use this runtime?
</span></span><span class="line"><span class="cl">    accept this input?
</span></span><span class="line"><span class="cl">    fit the current memory budget?
</span></span><span class="line"><span class="cl">    find all required artifacts?
</span></span></code></pre></div><p>Make the UI derive its attachment controls from this report.</p>
<p>That single project teaches an important product lesson:</p>
<blockquote>
<p><strong>The UI should reflect runtime truth, not just model metadata.</strong></p>
</blockquote>
<hr>
<h1 id="what-i-want-to-measure-next">What I want to measure next</h1>
<p>I still want better answers for:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">How should thermal state affect runtime choice?
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">How much should battery state affect model selection?
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">How accurate can memory admission become without being expensive?
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">At what point is a smaller model with smarter retrieval
</span></span><span class="line"><span class="cl">better than a larger model with a giant context?
</span></span></code></pre></div><p>Those questions belong in the next layers of the platform.</p>
<p>And that is exactly what happened.</p>
<p><strong>Part 2:</strong> <a href="/posts/ai_playground_series/02-the-day-a-chat-box-became-a-document-engine/">The Day a Chat Box Became a Document Engine</a></p>
]]></content></item></channel></rss>