NomLens

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.swift

Central 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.swift

Actor 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.swift

Wraps 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.swift

Sits 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
}
FieldPurpose
versionCompared against UserDefaults. If equal, no download occurs.
urlDirect download link for the .mlpackage. Can change between versions.
sha256Hex-encoded SHA-256. Verified via CryptoKit before model touches the models directory.
num_classesInformational — used to sanity-check the downloaded model.
class_list_versionIdentifies which classes/v*.txt file the model was trained against.
training_dateInformational metadata.
size_mbUsed for download progress reporting.
The manifest must have Cache-Control: no-cache, no-store so version checks are always fresh. The model file itself can use aggressive CDN caching — it's content-addressed by version.

Download & verification flow

  1. 1
    Fetch manifest: 10-second timeout. Non-2xx response → throw. Current model stays active.
  2. 2
    Version check: Compare manifest version against UserDefaults['nomModelVersion']. If same → stop.
  3. 3
    Download to temp: Model file downloads to a UUID path in tmp/. Partial downloads never replace a good model.
  4. 4
    SHA-256 verify: CryptoKit verifies temp file against manifest.sha256. Mismatch → delete temp file, throw. Current model stays active.
  5. 5
    Move to models dir: createDirectory for Application Support/NomLens/models/ if needed. Move verified temp file → models/{version}.mlpackage.
  6. 6
    Activate: 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 UI

Storage 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.

manifest.json

Version check on every launch. Must be at a stable, permanent URL. Cache-Control: no-cache.

NomLensClassifier_1.0.0.mlpackage

10.6 MB model file. URL comes from manifest — can change between versions. Cache-Control: public, max-age=31536000.

Constraints: No auth headers (URLSession download sends no credentials). HTTPS required (iOS ATS enforced). Content-Length header recommended for progress reporting.

Known gaps

Manifest server

Missing

https://api.nomlens.app/model/manifest.json does not exist yet. checkForUpdates() fails silently. Development workaround: hardcoded local model path in ContentView.

Old model cleanup

Missing

Superseded model versions are not deleted from disk after a successful update. Disk usage grows unbounded across model versions.

Rollback logic

Partial

Infrastructure 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.