Four things you touch.
One file they all agree about.
A Swift app for culling, a Python engine for measuring, a web workbench for the phone, and a command line for scripts. They do not call each other. They share a directory — one SQLite file and a folder of thumbnails — whose format is written down and tested from both languages.
This page describes what exists as of October 2026, not what is planned. The planned parts are drawn dashed and are listed at the bottom with what each one is waiting for.
As built
The line to read is the gold one on the left. The app never asks anything for its data. It opens the SQLite file itself and reads thumbnails off disk, which is why a grid cell is a millisecond and why nothing in the culling loop waits on Python, a socket or another process. Python is needed to measure a shoot, which takes minutes; it is not needed to look at one.
Who talks to whom, and how
Every connection on the diagram, with the mechanism and the rule that governs it. There are fewer than it looks: the Swift and Python halves have no connection to each other at all except through the files.
| From → to | Mechanism | The rule |
|---|---|---|
Mac app → photocull.db |
SQLite C API, in-process, about 150 lines of Swift | Reads with PRAGMA query_only. Writes only to decision, only as human. Never through a server. |
Mac app → thumbs/ |
ImageIO, an actor that keeps the books while decodes run detached | Never decode an original to draw a grid. Two sizes from one decode. |
| Mac app → originals | ImageIO, full resolution | Only when zoomed past what the preview holds — "1:1" means the original's pixels. Three held at once. |
| Browser → web workbench | HTTP: JSON for data, JPEG for images | Bound to 127.0.0.1 by default, --tailscale or --lan on request. There is no password, so where it is bound is the access control. |
| Web workbench → engine | Python import; long jobs on threads with a polled progress endpoint | Job state lives in memory and is lost if the process stops. |
| Shell → command line | argv in, text and an exit code out | Every batch operation is a command, so every batch operation is scriptable. |
Engine → photocull.db |
Python's sqlite3, through one Store class |
Measurements are rewritten wholesale in a transaction. A model verdict may never overwrite a human one — (uid, source) is the key. |
| Engine → originals | OpenCV and Pillow to read; exiftool as a subprocess to write sidecars |
Never written. The only output near an original is an .xmp beside it. |
| Swift ↔ Python | Nothing directly. The files above. | The schema is the interface. Swift is tested against a fixture Python wrote; Python is tested against verdicts Swift wrote. |
Why the app does not go through a server
The tidy design would make the Mac app an HTTP client of its own backend: one code path, clean separation. It would also undo the measurement the whole design rests on. A grid cell is 1.0 ms from the cache; an HTTP round trip in front of that puts back exactly what was wrong with the browser version.
Separation of concerns is not separation of processes. The app and the engine are separated by a schema, which is a stricter boundary than an API, because it is written down and enforced by tests in both languages.
Why two writers are safe
The app and the engine can both write photocull.db while the other has
it open. SQLite in WAL mode is built for exactly that — any number of readers, one
writer at a time, a three-second wait instead of an error. And the one table both
write, decision, is keyed so that a write from each side can never
collide: yours are human, the model's are model, and the
model's are forbidden from touching yours.
Found while building it: a read-only SQLite connection cannot
open a database in WAL mode, because WAL needs a shared-memory file a read-only
connection is not allowed to create. The app would have refused to open any
library it had ever judged. "Read only" is therefore spelled as an ordinary
connection plus PRAGMA query_only, which SQLite enforces per
connection and which actually works.
What lives in the file
Four type decisions, each made for the Swift reader rather than the Python writer, and each pinned by a test because every one of them fails quietly.
| Data | Stored as | Why |
|---|---|---|
| Face and image vectors | BLOB of little-endian float32 | Exactly the memory layout Swift wants; one memcpy. JSON would triple the file and cost a parse. |
| Capture time | integer microseconds | Sorts and compares identically in both languages. It is a wall clock with no time zone — the camera never recorded one — and the app formats it in UTC so nothing shifts it by the machine's offset. |
| Booleans | 0 and 1 | SQLite has none, and pretending otherwise invites a string 'true'. |
| Perceptual hash | the unsigned bits reinterpreted as signed | SQLite's integer is signed and a hash with its top bit set does not fit. Hamming distance reads bits, so it does not care. |
Identity
A photograph's uid is derived from what is in it: its length and the
first and last 64 KB, about 0.15 ms. A separate revision, from the
size and modification time, answers the other question — has this file changed,
so is the measurement stale. The first version conflated the two, which is
correct for one machine and nothing else.
What changes next
Two of the boxes above are temporary, and one arrow is missing. The direction is settled: Swift is what ships; Python is the laboratory.
| Change | Waiting for |
|---|---|
The app spawns photocull as a subprocess, reads JSON-lines progress, reloads when the cache changes | Nothing — this is Step 2, next |
A Swift daemon under launchd replaces the Python workbench for the phone | A real test away from home over Tailscale, with the Mac asleep. Building it to find out whether remote access is pleasant would be paying for the answer before asking. |
| Face detection moves to Vision | Nothing technical. It is also half of the licence answer: the current face models are for non-commercial research only. |
| The measurements move to vImage, behind a differential test | Time. Every threshold was calibrated against OpenCV's convolutions and has to be measured again. Months, not lines. |
| Face identity | A decision. Apple has no equivalent, and nearly every public face-recognition model is research-only. |