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.

coldhot · the page’s color axis is the app’s warmth signal

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.

Stage 1 / 13 · Ask

Say it or type it

Read the deep dive ↓

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.

“blue-green book” BPE tokenizer (TypeScript) CLIP text tower Core ML · once/query t ∈ ℝ⁵¹² 5-prompt ensemble Camera VisionCamera · native buffers CLIP image tower ANE · frame + 2–3 cells cosine → softmax/τ vs t and negatives warmth · direction EMA + hysteresis ring · tone · haptics state machine cues 4–8 Hz embeddings only compared to t YOLOE-11s un-fused · runtime classes vocab table (~400 nouns) top-8 nearest + raw query CLIP re-rank box crops · cos vs t k-of-n → FOUND score ≥ 0.55 in 3 of 5 frames class embeds boxes ≥ 0.25 only when warm ≥ 0.45 t reused for re-rank — “blue-green” decides here GUIDE LOOP · always on · cheap CONFIRM LOOP · only when warm · expensive QUERY · once per change
The two-brain split. The query path (left) runs once per query change and produces the embedding t. The guide loop (middle) compares cheap crop embeddings to t a few times a second. Only when warmth crosses the threshold does the confirm loop (right) spend a detector pass — and CLIP still gets the last word via re-rank.

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–2

Ask — 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 sayRuleQuery becomes
“book”new nounbook
“it’s blue-green”adjective / colour / material → prependblue-green book
“no, the mug”“no, …” → replacemug
“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–4

Encode — 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–7

See — 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:

RuntimeCLIP-B/32, one 224×224 cropVerdict
react-native-executorch, XNNPACK fp32 (CPU)707–1008 msno-go
ExecuTorch Core ML delegate114 msworks, but YOLOE crashed on load
Native Core ML, .all (routes to GPU)114 ms40% slower than ANE
Native Core ML, .cpuAndNeuralEngine84 ms · 48.5 ms/crop batchedshipped

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–9

Guide — 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.

p_target = softmax([cos(e,t), cos(e,neg₁)…cos(e,neg₆)] / τ)[0]

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 10

Orchestrate — 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–12

Confirm — 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 13

Found & 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.

p_target = e^(cosT/τ) / ( e^(cosT/τ) + 6 · e^(cosN/τ) ) · floor: cosT < 0.22 → warmth suppressed
p_target

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.

warmth += 0.3 · (p − warmth)
searching → warm at ≥ 0.45 · warm → searching below ≈ 0.37
found = clipScore ≥ 0.55 in 3 of last 5 frames
phase searching
tone 220 Hz
haptic 1200 ms

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.

QuantityValueWhere it bites
CLIP-B/32 image encode, single crop, ANE84 msguide loop budget
CLIP-B/32 image encode, batched (B=10)48.5 ms / cropwhy 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 mswhy a native module exists
Guide call: full frame + 2–3 cells150–200 ms~4–8 Hz guide loop
YOLOE-11s pass (XNNPACK fp32)277–416 mswhy confirm only runs when warm
warmOn threshold0.45calibrated from device bands
foundScore threshold (re-rank)0.55calibrated; first find scored 0.61
Object warmth band, on device0.46–0.57the gap that makes thresholds possible
Background warmth band, on device≤ 0.24
Text-tower parity vs open_clipcos 0.9999998vs 0.9277 for the broken library tower
First-run model download~320 MBguide-only mode until detector lands
Pure-TS test suite162 testsreplay 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.

Start here

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.00020
Start here

Vision 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.11929
Start here

Byte-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.07909
Go deeper

Contrastive 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.03748
Go deeper

Calibration & 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.04599
Go deeper

Open-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 docs
Go deeper

Transformers 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 guides
Go deeper

Control-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 trigger
Rabbit holes

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

SAM paper
Rabbit holes

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.

TinyCLIP · MobileCLIP
Rabbit holes

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)
Rabbit holes

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.

ExecuTorch · open_clip