OTA Model Delivery
NomLens delivers Core ML model updates over-the-air — no App Store update required. This documents the iOS-side architecture, manifest format, download pipeline, and current gaps.
Overview
The OTA system is designed to be silent, safe, and non-blocking. It never interrupts the user, never corrupts a working model, and degrades gracefully when offline.
Startup sequence
// ContentView.swift → ServiceContainer.init
Task { await manager.loadStoredModel() } // 1. restore from disk (ms)
Task { await manager.checkForUpdates() } // 2. fetch manifest (background)These two tasks run concurrently. The app is usable as soon as loadStoredModel() completes. The update check happens silently behind.
iOS components
ModelManager
Services/ModelManager.swiftCentral Swift actor responsible for fetching, verifying, storing, and activating models. All state mutations are serialized with no manual locking. Runs two concurrent startup tasks: loadStoredModel() (restores from disk, milliseconds) and checkForUpdates() (fetches manifest in background).
ClassifierProxy
Services/ClassifierProxy.swiftActor that wraps the active NomClassifier and allows hot-swapping at runtime. RoutingDecoder holds a reference to the proxy for the app lifetime. ModelManager calls proxy.update(newClassifier) after a successful download — all subsequent calls use the new model automatically.
NomClassifier
Services/NomClassifier.swiftWraps a Core ML model using Vision (VNCoreMLModel + VNCoreMLRequest). Accepts .mlpackage or pre-compiled .mlmodelc. Class labels are Unicode codepoints in hex (e.g. '4EBA') — NomClassifier converts these to actual Unicode characters ('人'). Vision handles center-crop and scaling to the model's input size.
RoutingDecoder
Services/RoutingDecoder.swiftSits between ClassifierProxy and ClaudeService. For each character crop, applies confidence routing: ≥90% → accepted on-device (green), 60–90% → accepted on-device (yellow), <60% → escalated to Claude API, throws → fail-safe escalation to Claude.
Manifest format
The manifest is a small JSON file at a fixed HTTPS endpoint, fetched on every app launch.
GET https://api.nomlens.app/model/manifest.json
{
"version": "1.0.0",
"url": "https://…/models/NomLensClassifier_1.0.0.mlpackage",
"sha256": "c0018e1270fe8f821248465a6f873544f4992b2ec58c344a0ef2d918d6e08439",
"num_classes": 972,
"class_list_version": "v1",
"training_date": "2026-03-27",
"size_mb": 10.6
}| Field | Purpose |
|---|---|
| version | Compared against UserDefaults. If equal, no download occurs. |
| url | Direct download link for the .mlpackage. Can change between versions. |
| sha256 | Hex-encoded SHA-256. Verified via CryptoKit before model touches the models directory. |
| num_classes | Informational — used to sanity-check the downloaded model. |
| class_list_version | Identifies which classes/v*.txt file the model was trained against. |
| training_date | Informational metadata. |
| size_mb | Used for download progress reporting. |
Download & verification flow
- 1Fetch manifest: 10-second timeout. Non-2xx response → throw. Current model stays active.
- 2Version check: Compare manifest version against UserDefaults['nomModelVersion']. If same → stop.
- 3Download to temp: Model file downloads to a UUID path in tmp/. Partial downloads never replace a good model.
- 4SHA-256 verify: CryptoKit verifies temp file against manifest.sha256. Mismatch → delete temp file, throw. Current model stays active.
- 5Move to models dir: createDirectory for Application Support/NomLens/models/ if needed. Move verified temp file → models/{version}.mlpackage.
- 6Activate: Call activate(modelURL:version:) — see activation section below.
Model activation & compile cache
activate(modelURL:version:) steps:
1. Check if {version}.mlmodelc exists in models dir
→ If yes: load compiled binary directly (fast, no recompile)
2. If only .mlpackage exists:
→ MLModel.compileModel(at:)
→ Load compiled result
3. Cache .mlmodelc to models dir for next launch
(non-fatal if this fails)
4. proxy.update(newClassifier)
→ Hot-swap. New model is live, no restart required.
5. Persist version string to UserDefaults
6. Call onReady() on main actor
→ Sets ServiceContainer.isModelReady = true
→ Enables "Decode All" button in UIStorage layout
Application Support/
NomLens/
models/
1.0.0.mlpackage ← downloaded source model
1.0.0.mlmodelc ← compiled cache (written after first load)Hosting requirements
The iOS app needs two static files reachable over HTTPS. No dynamic backend required. Cloudflare R2 is recommended: free egress, global CDN, per-file cache control headers.
Version check on every launch. Must be at a stable, permanent URL. Cache-Control: no-cache.
10.6 MB model file. URL comes from manifest — can change between versions. Cache-Control: public, max-age=31536000.
Known gaps
Manifest server
Missinghttps://api.nomlens.app/model/manifest.json does not exist yet. checkForUpdates() fails silently. Development workaround: hardcoded local model path in ContentView.
Old model cleanup
MissingSuperseded model versions are not deleted from disk after a successful update. Disk usage grows unbounded across model versions.
Rollback logic
PartialInfrastructure supports it (previous .mlpackage stays on disk) but no explicit rollback path or UI exists. If new model fails verification, the old model stays active — but there's no deliberate revert trigger.
Zip extraction
N/A for now.mlpackage is a directory bundle. Current download writes bytes directly assuming server serves uncompressed content. If a zip is uploaded to the CDN, a decompression step must be added to ModelManager.download().
Current development workaround
Until the manifest endpoint is live, the iOS app loads the model directly from a local path. This block must be removed before production:
// ContentView.swift — REMOVE before production
Task {
let e17 = URL(fileURLWithPath:
"/Users/kt/Documents/NomLensMLModel/export/NomLensClassifier_1.0.0.mlpackage")
if FileManager.default.fileExists(atPath: e17.path) {
await manager.loadModel(at: e17)
}
}The checkForUpdates() call already exists in the startup sequence and will take over once the manifest URL is real. Two additional changes are needed: ModelManager.manifestURL must be updated to the live endpoint.