Shadow Brain · small apps track · on-device ML
Inside LookFor
How a phone plays hot & cold: from the words “blue-green book” to a box on your screen — every step on-device, nothing in the cloud. Press play below and walk the pipeline one stage at a time.
the pipeline, stage by stage
One search, thirteen stops
This follows a single real search — the first one that ever worked on device: finding a mug, then a blue-green book, on an iPhone 11 Pro Max. Click any stop, use Prev/Next, or press Play. Each stage links to its deep dive below.
the architecture in one picture
CLIP guides, YOLOE confirms
The whole design exists because of one constraint: an open-vocabulary detector is too slow to run every frame on an older iPhone, and a CLIP similarity score is fast but can’t draw a box. So a cheap loop runs always, and an expensive loop runs only when the cheap one says it’s worth it.
every stage, with the real numbers
Deep dives
Everything below comes from the LookFor repo — the design spec, the M0 spike report, and the
shipped source in lookfor/src/. Numbers were measured on an iPhone 11 Pro Max (A13),
the slowest hardware the app targets.
stages 1–2Ask — capture and compose the query
▶
Voice in, on-device. The mic button uses expo-speech-recognition over Apple’s
SFSpeechRecognizer with on-device recognition required — audio never leaves the phone,
matching the app’s “Data Not Collected” privacy label. While the recogniser session is open,
haptic feedback is muted (iOS silences haptics during audio capture), and the session is closed
before guide feedback resumes. Typing works identically; voice is just another way to fill the
same query field.
The composer is the conversational trick. src/finder/queryComposer.ts is pure
TypeScript with simple, predictable rules — no LLM in v1:
| You say | Rule | Query becomes |
|---|---|---|
| “book” | new noun | book |
| “it’s blue-green” | adjective / colour / material → prepend | blue-green book |
| “no, the mug” | “no, …” → replace | mug |
| “start over” / ✕ | clear | — |
Because refinement just rewrites a string, and the next stage re-encodes any changed string, “it’s blue-green” updates the search within 1–2 frames. The refined text matters twice: it steers the guide loop and it is the thing boxes are re-ranked against at the end.
stages 3–4Encode — from words to a 512-number arrow
▶
Tokenize. CLIP’s text tower doesn’t read letters; it reads byte-pair-encoded tokens.
LookFor ships its own TypeScript BPE tokenizer (src/ml/clipTokenizer.ts) that reproduces
the reference open_clip tokenizer exactly — special SOT/EOT tokens, lowercasing, a 77-token context.
Embed with a prompt ensemble. The query isn’t encoded bare. Five templates
(“a photo of a {q}”, “a {q} on a table”, “a {q} on the floor”, “a close-up of a {q}”,
“a {q} in a room”) are each encoded, then averaged and L2-normalised into one unit vector
t. Averaging washes out template-specific quirks, leaving the meaning — a standard
zero-shot CLIP trick from the original paper. A fixed negative set (“a room”, “furniture”,
“a wall”, “a floor”, “background”, “a person”) is encoded once at startup; these give the softmax
something to compare against, so an empty wall can’t score “warm”.
The bug that almost sank it: the off-the-shelf react-native-executorch CLIP text tower was only cosine 0.9277 to the open_clip reference — close enough to look right, wrong enough to flatten every image–text cosine into mush. The fix: a self-exported Core ML text tower + the own tokenizer, verified on device at cosine 0.9999998 against desktop reference embeddings. Lesson: in embedding pipelines, “almost identical” encoders are silently broken — always run a numeric parity check against a reference.
Text encoding runs once per query change, so its latency (~hundreds of ms) never gates the frame loop.
stages 5–7See — frames, crops, and the Neural Engine
▶
Frames stay native. react-native-vision-camera delivers preview-sized RGB frames
to a frame-processor plugin as native buffers. Pixels never cross the JS bridge — only 512-float
embeddings and small box arrays do. A scheduler enforces cadence (Battery / Balanced / Max =
2 / 4 / max-achievable guide calls per second), pauses when the accelerometer says the phone is
still, and backs off automatically on thermal pressure.
The rotating-cell economy. Scoring the full frame plus all nine 3×3 cells every frame
would cost ~10 × 48 ms ≈ 480 ms — too slow on the A13. Instead each frame encodes the
full frame + 2–3 rotating cells (~150–200 ms per batched Core ML call), cycling so every
cell refreshes about every 3 frames (~0.5 s). A CosCache keeps the last score per cell
and merges each frame’s partial results; the guide only reacts once it holds a full 10-crop
snapshot. stepFinder recovers each crop’s index from its rect, so a frame’s cache-merge
always matches the plan that actually produced its embeddings.
Why a native Swift module exists at all. The M0 spike measured the alternatives:
| Runtime | CLIP-B/32, one 224×224 crop | Verdict |
|---|---|---|
| react-native-executorch, XNNPACK fp32 (CPU) | 707–1008 ms | no-go |
| ExecuTorch Core ML delegate | 114 ms | works, but YOLOE crashed on load |
Native Core ML, .all (routes to GPU) | 114 ms | 40% slower than ANE |
Native Core ML, .cpuAndNeuralEngine | 84 ms · 48.5 ms/crop batched | shipped |
Counter-intuitive: computeUnits = .all — “use everything” — is the trap. It
routes the ViT to the GPU, which is 40% slower than the Neural Engine on A13. Explicitly
excluding the GPU made it faster. On-device ML lesson: measure per compute unit; never trust
the “auto” setting.
The Swift module (modules/lookfor-ml) also does crop + resize + CLIP mean/std
normalisation natively, in one batched call, and exposes thermalState() so the scheduler
can downshift cadence before iOS throttles the whole app.
stages 8–9Guide — cosine, softmax, and warmth
▶
Cosine similarity is the whole trick. CLIP was trained contrastively so that an image
and a text that describe the same thing land near each other on a unit sphere. For each crop
embedding e, the app computes cos(e, t) and cos(e, negᵢ) for the six
negatives. Raw CLIP cosines live in a narrow band (roughly 0.15–0.35 — nothing near 1.0), which is
why the next two steps exist.
Softmax with temperature τ turns “is the target cosine meaningfully above the background cosines?” into a probability. Small τ sharpens the contrast; τ is tuned offline in the replay harness against recorded frames. An absolute cosine floor (~0.20–0.25) additionally suppresses warmth when even the best cosine is background-level — softmax alone would happily declare a winner among six losers.
Warmth is a smoothed, debounced signal. The instantaneous max p_target is noisy
frame to frame, so warmth is an exponential moving average (α ≈ 0.3) with hysteresis at the
thresholds — it takes sustained evidence to cross into warm, and slightly more absence to
fall back out, so the ring never flickers. Calibrated from real device data: warmOn = 0.45.
The measured bands that justify it: real objects scored warmth 0.46–0.57 on device; background
scenes stayed ≤ 0.24. The GuideAggregator also reports the best 3×3 cell (the heat blob)
and a direction cue (“try left”) from where that cell sits.
stage 10Orchestrate — the state machine and one feedback owner
▶
Phases: idle → searching → warm → found → lost, in
src/finder/stateMachine.ts — pure TypeScript, no React, no native calls. The per-frame
heartbeat stepFinder (src/finder/finderStep.ts) wires it: score crops → merge
into the cache → update the aggregator → feed the machine → collect cues. Because the whole
decision layer is pure, it runs in Jest with fake embeddings (162 tests), and
scripts/replay.ts replays recorded frame sequences offline — time-to-find and
false-positive rate gate every release.
One owner for all feedback. A single FeedbackController consumes state-machine
events and drives everything sensory: ring colour (cold blue → hot orange), tone pitch and haptic
tempo rising with warmth, speech only on state changes (“getting warmer”, “try left”,
“lost it” — never a running commentary), and queued VoiceOver announcements. One owner means
modalities never fight — the chime can’t talk over the speech, haptics pause during voice capture.
Design pattern worth stealing: ML at the edges, pure functions in the middle. Every
native/ML call sits behind a thin adapter (TextEncoder, GuideScorer,
Detector, ReRanker); everything that decides is deterministic TypeScript
you can unit-test and replay. This is why a solo developer could calibrate thresholds from
recorded traces instead of waving a phone around for every tweak.
stages 11–12Confirm — YOLOE finds books, CLIP picks yours
▶
Open-vocabulary detection. A classic detector has a fixed class list baked in. YOLOE’s
text-prompt variant instead takes class embeddings as a runtime input — LookFor exports it
un-fused so the model signature is (image, classEmbeds[k,D]) → boxes. That single
export decision is what makes the app open-vocabulary: new classes cost an embedding lookup, not
a retrain.
The vocabulary table. ~400 household nouns are pre-embedded offline, twice: once with
YOLOE’s own text tower (vocab.yoloe.bin — must match its head) and once with the app’s
permissive CLIP tower (vocab.guide.bin — used to map your query to its nearest nouns at
runtime). Per confirm pass, the class set = top-8 vocab nouns nearest the query noun, plus the raw
query embedding. So “book” also hunts as “notebook”, “magazine”, “folder” — near-synonyms the
detector head understands.
Re-rank: the division of labour. YOLOE proposes candidate boxes (confidence ≥ 0.25) but
only knows nouns. Each candidate is cropped, pushed through the CLIP image tower, and scored
by cosine against the full refined query embedding t. YOLOE finds the books;
CLIP picks the blue-green one. The first real on-device find: warmth 0.57 triggered the
confirm loop, YOLOE boxed the mug, re-rank scored p = 0.61 against foundScore = 0.55.
k-of-n before celebrating. A box must beat the found threshold in 3 of the last 5 confirm frames before the app declares found. Single-frame spikes — a glint, a motion blur, one lucky crop — get filtered out. Same debouncing idea as the EMA, applied as a vote instead of a smoother.
stage 13Found & lost — closing the loop
▶
Found is a moment, not a state to hover in: box + label, one haptic, a chime, speech (“Found: blue-green book, upper left” — the position comes from where the box sits on screen), and a freeze-frame card with Keep looking / Done. Scanning pauses so the phone stops burning battery on a solved problem.
Lost handles the physical world: if the box disappears for more than 2 seconds (you
turned, something occluded it), the app drops to lost, pins a “last seen here”
marker at the box’s final screen position, and keeps the guide loop running. Re-acquiring the
object snaps straight back to found.
Cold start. The 42 MB binary ships with no models. First launch downloads ~320 MB (CLIP image tower ~168 MB fp16, text tower ~120 MB, detector ~24 MB, vocab < 2 MB) from GitHub Releases — resumable, SHA-256-verified, cellular only with an explicit toggle. The app becomes usable in guide-only (hot/cold, no boxes) as soon as the guide model lands; the detector arrives after. Every downloaded model also passes an on-device parity check against desktop reference embeddings before it’s trusted.
feel the math
Two things to play with
1 · The softmax playground
This is stage 8 in miniature. Raw CLIP cosines are small and close together — the softmax-over-negatives with temperature τ is what turns them into a usable signal. Drag the sliders and watch when p_target becomes confident.
2 · The warmth & state-machine simulator
Stages 9–12 running live. You control the instantaneous signal (what one frame reports); the simulator applies the EMA, the hysteresis, and — once warm — the k-of-n found vote. Notice how the smoothed warmth lags your slider: that lag is the anti-flicker design, not a bug.
measured, not estimated
The numbers ledger
All measured on the target floor device — iPhone 11 Pro Max (A13, 2019). Newer chips run the same pipeline 2–4× faster.
| Quantity | Value | Where it bites |
|---|---|---|
| CLIP-B/32 image encode, single crop, ANE | 84 ms | guide loop budget |
| CLIP-B/32 image encode, batched (B=10) | 48.5 ms / crop | why crops are batched in one call |
Same model on GPU (.all) | 114 ms (+40%) | why the GPU is excluded |
| Same model on CPU (XNNPACK fp32) | 707–1008 ms | why a native module exists |
| Guide call: full frame + 2–3 cells | 150–200 ms | ~4–8 Hz guide loop |
| YOLOE-11s pass (XNNPACK fp32) | 277–416 ms | why confirm only runs when warm |
| warmOn threshold | 0.45 | calibrated from device bands |
| foundScore threshold (re-rank) | 0.55 | calibrated; first find scored 0.61 |
| Object warmth band, on device | 0.46–0.57 | the gap that makes thresholds possible |
| Background warmth band, on device | ≤ 0.24 | |
| Text-tower parity vs open_clip | cos 0.9999998 | vs 0.9277 for the broken library tower |
| First-run model download | ~320 MB | guide-only mode until detector lands |
| Pure-TS test suite | 162 tests | replay harness gates every release |
the words
Glossary
- Embedding
- A learned list of numbers (here 512) that places a thing in a space where distance means dissimilarity. Images and texts land in the same space — that’s CLIP’s whole contribution.
- Cosine similarity
- The cosine of the angle between two vectors. On L2-normalised embeddings it’s just their dot product; 1 = same direction, 0 = unrelated.
- L2 normalisation
- Scaling a vector to length 1 so only its direction carries meaning. Makes cosines comparable across queries.
- Softmax temperature
τ - Divisor applied to scores before softmax. Small τ exaggerates differences (confident), large τ flattens them (cautious). Tuned offline in the replay harness.
- Prompt ensemble
- Encoding several phrasings of the same query and averaging — a free accuracy boost for zero-shot CLIP, straight from the original paper.
- Zero-shot
- Recognising a category the model was never explicitly trained to classify, purely because its text description embeds near matching images.
- EMA
- Exponential moving average:
s += α·(x − s). Cheap smoothing with one tunable knob; α ≈ 0.3 here. - Hysteresis
- Different thresholds for entering and leaving a state, so a value hovering at the boundary can’t make the UI flicker. Same idea as a thermostat.
- k-of-n
- Require k positives out of the last n frames before acting (3 of 5 here). Debouncing as a vote.
- Un-fused export
- Exporting YOLOE with class embeddings as a runtime input instead of baked-in weights — the key to open-vocabulary detection on device.
- Class embedding
- A text embedding used as a detector classifier weight. Swap the embedding, swap what the detector looks for — no retraining.
- ANE
- Apple Neural Engine — the NPU in every A/M-series chip. Fastest path for fp16 transformer inference, but only for ops it supports (PE-Core’s RoPE fell off it).
.mlpackage/ fp16- Core ML’s modern model format; 16-bit floats halve size and are the ANE’s native precision.
- Frame processor
- VisionCamera’s hook for running native code per camera frame — how pixels get processed without ever entering JavaScript.
- Replay harness
- Running the full decision pipeline offline over recorded frame data. Turns “does it feel better?” into measurable time-to-find and false-positive rates.
- Parity check
- Comparing on-device model outputs numerically against a desktop reference. The check that caught the 0.9277 text tower.
where to go deeper
Further studies
A graded path. Start here gives you the concepts this page leans on; go deeper is the engineering under them; rabbit holes are where LookFor’s future versions live.
CLIP — the paper behind both towers
Radford et al., 2021. Contrastive pre-training on 400M image–text pairs; why one shared embedding space lets “blue-green book” score camera crops with no training.
In LookFor: stages 4, 7–8, and the re-rank all are this paper.
arxiv.org/abs/2103.00020Vision Transformer (ViT)
Dosovitskiy et al., 2020. Images as sequences of 32×32 patches — the “B/32” in CLIP ViT-B/32, and why inputs are fixed 224×224 crops.
In LookFor: explains the crop-and-resize step and the model’s cost profile.
arxiv.org/abs/2010.11929Byte-pair encoding
Sennrich et al., 2016. How subword tokenization works and why “blue-green” may split into pieces the model still understands.
In LookFor: stage 3 — the TS tokenizer that had to match open_clip exactly.
arxiv.org/abs/1508.07909Contrastive learning & InfoNCE
Oord et al., 2018. The loss family that makes cosine distances meaningful — and why raw CLIP cosines cluster in a narrow band instead of spanning 0–1.
In LookFor: the reason stage 8 needs softmax-over-negatives at all.
arxiv.org/abs/1807.03748Calibration & temperature
Guo et al., 2017. What it means for a probability to be trustworthy, and temperature scaling — the same τ LookFor tunes in its replay harness.
In LookFor: why warmOn 0.45 / foundScore 0.55 were measured, not guessed.
arxiv.org/abs/1706.04599Open-vocabulary detection
YOLO-World (2024) then YOLOE (2025): how text embeddings become detector classifier weights, and what “prompt-then-detect” means.
In LookFor: stage 11’s un-fused export and the vocab table are these papers applied.
YOLO-World · YOLOE · Ultralytics docsTransformers on the Apple Neural Engine
Apple’s engineering article on ANE-friendly attention layouts — the “3–5× for free” optimisation still on LookFor’s backlog.
In LookFor: would let the guide loop score all 10 crops every frame on A13.
machinelearning.apple.com · coremltools guidesControl-signal hygiene
EMA filters, hysteresis, and debouncing — old control-engineering ideas doing the UX heavy lifting in any real-time ML product. Search: “exponential smoothing”, “Schmitt trigger”, “debounce”.
In LookFor: stages 9 and 12; the difference between a demo and a product.
exponential smoothing · Schmitt triggerSegment Anything → “precision mode”
The SAM line of segmentation models. iOS 27’s Core AI ships a text-promptable SAM-3-lite — planned to replace LookFor’s confirm loop with pixel-accurate masks.
In LookFor: v1.1’s precision mode; same Detector contract, better boxes.
Distilled CLIP for mobile
TinyCLIP and MobileCLIP: shrinking the towers 2–5× with distillation while keeping zero-shot accuracy — but mind the licences (MobileCLIP’s encoder is research-only).
In LookFor: the other backlog path to a 10-crop guide loop; also why
vocab.yoloe.bin is a documented licence grey zone.
Teach-your-item (v1.2)
Few-shot personalisation: from “a black remote” to “my remote”. Look at image-embedding enrolment, prototype averaging, and personalised open-vocab detection.
In LookFor: the most-requested future feature; the embedding machinery on this page is 80% of what it needs.
PerSAM & friends (start: personalisation surveys)On-device runtimes
ExecuTorch, Core ML, XNNPACK, and the conversion minefield between PyTorch and the phone — where most of LookFor’s spike time actually went.
In LookFor: the M0 spike report (lookfor/spike/README.md) is a war
story in exactly this territory.