You open the repo on your first morning, the README says "run the app, ask it something," and forty seconds later an answer appears on the phone. Nobody on the team can tell you whether that text came from the bundled on-device model, from a remote endpoint someone configured last quarter, or from a fixture left in a debug build. That uncertainty is not a small onboarding annoyance, because every later measurement you record inherits it.
This post is a first-hour exercise for a junior engineer joining a mobile AI repo. The goal is not to ship a feature, and it is not to improve latency or accuracy; the goal is to make provenance visible, so that when you do open your first PR you already know which execution path you exercised. Everything below is a proposed harness that you run on your own device, not a report of numbers I measured on yours.
In a normal backend repo you can read the config and know which database answered. In a mobile AI repo the same request can be served by three different things depending on build type, network state, and whether an asset survived the packaging step. If you skip this check, you will eventually write a careful benchmark, show it to your reviewer, and discover the phone was calling a cached response the whole time.
The exercise below gives you three things you can rely on for the rest of onboarding: a build that fails loudly when the model asset is missing, a debug banner that names the source of the last response, and a repeatable way to exercise the remote path on purpose.
Your banner only needs to name which of these produced the last result. It does not need to be pretty, and it should not ship in release builds.
Before you touch any inference code, prove that the file you think is packaged actually is. Add a verification task to the app module and wire it into the check lifecycle. Substitute your own asset name and the checksum your team stores alongside the artifact.
// app/build.gradle.kts (proposed harness)
val modelDir = layout.projectDirectory.dir("src/main/assets/models")
// Placeholder on purpose: copy the real digest from your artifact record.
val expectedDigests = mapOf(
"classifier-small.tflite" to "<sha256-from-your-artifact-record>"
)
tasks.register("verifyModelAssets") {
val dir = modelDir
inputs.dir(dir)
doLast {
expectedDigests.forEach { (name, want) ->
val file = dir.file(name).asFile
require(file.exists()) { "Missing model asset: $name" }
val got = java.security.MessageDigest.getInstance("SHA-256")
.digest(file.readBytes())
.joinToString("") { "%02x".format(it) }
require(got == want) { "Checksum mismatch for $name\nexpected=$want\n got=$got" }
}
println("verifyModelAssets: OK")
}
}
tasks.named("preBuild") { dependsOn("verifyModelAssets") }
Expected observation on a healthy checkout is a single verifyModelAssets: OK line before compilation. Then deliberately rename the asset once, run ./gradlew :app:assembleDebug, and confirm the build stops with the message you wrote. If it compiles anyway, your app has a runtime download path you have not found yet, and that is the most important thing you will learn all day.
A tiny recorder next to your inference entry point costs almost nothing and removes the guessing. Keep it debug-only, and never log prompt text or user content in it.
// Proposed harness. Field names follow your app's existing types.
sealed interface ModelSource {
data class Bundled(val asset: String, val digestPrefix: String) : ModelSource
data class Remote(val host: String, val path: String) : ModelSource
data class Fixture(val name: String) : ModelSource
}
object Provenance {
private val seen = ArrayDeque<ModelSource>()
fun record(source: ModelSource) {
seen.addLast(source)
if (seen.size > 20) seen.removeFirst()
if (BuildConfig.DEBUG) Log.d("Provenance", describe(source))
}
fun last(): ModelSource? = seen.lastOrNull()
private fun describe(s: ModelSource): String = when (s) {
is ModelSource.Bundled -> "bundled ${s.asset} sha256:${s.digestPrefix}"
is ModelSource.Remote -> "remote ${s.host}${s.path}"
is ModelSource.Fixture -> "fixture ${s.name}"
}
}
Call Provenance.record(...) exactly where the response is produced, not where the request is built, because a retry can change the answer's origin. If you use Compose, a two-line debug overlay reading Provenance.last() is enough; on iOS the same idea maps to a #if DEBUG view or an os_log line.
Many teams use a hosted endpoint for early integration work, and some projects offer free model access plus a free server option so a new contributor can get a working call without a billing conversation. The open-source project MonkeyCode is one of these, and its operator describes a free model-access tier and a free server option for trying the workflow; the same operator-supplied description puts the free allowance at 10 million tokens. Treat that figure as a starting point, not a contract, and re-read the project's current terms before you plan any budget around it.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The useful part for onboarding is narrower than the marketing: a shared free endpoint lets you prove that your client's request shape, timeout, and error handling work before you depend on a host only three people can reach. Time the first call rather than trusting it.
BASE_URL="https://<your-endpoint-from-the-project-docs>"
for i in 1 2 3; do
curl -sS -o /dev/null \
-w "attempt=%{http_code} ttfb=%{time_starttransfer}s total=%{time_total}s\n" \
-H "Content-Type: application/json" \
-d '{"prompt":"ping","max_tokens":8}' \
"$BASE_URL/v1/complete"
done
Record three numbers per attempt and note whether the first one is slower than the rest, which would suggest a cold path rather than a steady one. Do not run a load test against shared free infrastructure; three sequential calls teach you what a first-hour contributor needs, and concurrency experiments belong on a host your team controls.
| Symptom | Likely broken path | First check |
|---|---|---|
| Build passes with the asset deleted | Silent download or remote fallback | Grep for the model URL in build config |
Banner says remote in airplane mode |
Cached response or fixture | Clear app storage, rerun |
Banner says bundled but answers vary |
Prompt template changed | Diff template file against main |
| First remote call takes much longer | Cold endpoint or cold radio | Repeat three times, note radio state |
| Works after reinstall only | Asset copied at first launch | Inspect first-run copy logic |
Each row is a rollback condition, not a curiosity. If your banner reports remote while the device has no network, you have found something worth raising before you write feature code.
Fill this in on your own hardware and paste it into the PR description or your onboarding notes. Reviewers trust a filled table far more than a sentence claiming it works.
This harness assumes your app can load a model from local storage. If your product is server-only inference, a bundled-asset check adds noise and you should stop after step 2 and document the endpoint instead. Teams with a managed model registry may already enforce digests in CI, in which case duplicating the check locally is waste. Checksumming a multi-hundred-megabyte asset on every build slows feedback loops noticeably, so scope the task to debug variants or run it on a schedule.
Finally, the banner is diagnostic tooling. It must be stripped or gated in release builds, because hostnames and asset digests are information you gain nothing from publishing.
If you run this in your own repo, I would like to see the environment rather than the conclusion: device, OS, framework versions, the transition steps you used, and whether the banner recovered, restarted, or silently disappeared. If you want a place to start the remote path without provisioning a host first, the project's free tier and free server option are the shortest route to a first honest measurement.