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

photocull architecture as built The macOS app, the web workbench and the command line sit at the top. The app reads and writes the shared cache directly; the web workbench and the command line both go through the Python engine. The cache — a SQLite database, a thumbnail directory and a decision backup — sits in the middle. The original photographs sit at the bottom and are only ever read; XMP sidecars are written beside them. SWIFT · macOS 14+ The Mac app 3,494 lines · no third-party packages Photocull SwiftUI · AppKit Session · Sidebar · Grid · Frame · Compare Filmstrip · Keyboard (NSEvent monitor) PhotocullCore library Connection — SQLite through its C API Library · Decisions · History · Filter · Cursor Thumbnails (ImageIO) · Viewport · Command ANY BROWSER Phone · tablet · laptop thumb buttons below 820px PYTHON · FastAPI Web workbench photocull review 23 routes · one HTML file, no build step JobRunner — scans run in-process threads SHELL Terminal · scripts · cron everything batch is a command PYTHON · Typer The command line scan · score · learn · people · scenes duplicates · search · editstyle · export backup · retouch · review PYTHON 3.12 · the laboratory The engine — measuring and learning OpenCV · NumPy sharpness, exposure, scene ONNX Runtime + CoreML InsightFace · CLIP · NIMA SciPy · scikit-learn clusters · Bradley–Terry exiftool a subprocess THE CONTRACT — the only thing the two languages share .photocull/ format in tables.py · tested from both sides photocull.db SQLite · WAL · several readers, one writer at a time featurea photograph · 51 columns facea face · vector as BLOB filea file · copies are one decision(uid, source) personname, priority metaidentity version thumbs/ uid.g.jpg512px · the grid uid.jpg1400px · a frame same names from Swift and Python, so either fills the other's cache decisions.jsonl the backup of the one table that cannot be recomputed sorted, diffable, commit it YOUR FILES — card · SSD · NAS The originals: read, never moved, never written identity is derived from the bytes — size plus both ends — so a verdict survives a rename, a move or a restore IMG_0001.CR3 IMG_0001.xmp ← the only write HTTP · JSON + JPEG argv · exit imports it imports it SQLite C API, in-process reads query_only · writes as "human" ImageIO · 1.0 ms a cell writes reads pixels · writes .xmp ImageIO · originals at 1:1
Swift Python browser the shared cache planned

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 → toMechanismThe 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.

DataStored asWhy
Face and image vectorsBLOB of little-endian float32Exactly the memory layout Swift wants; one memcpy. JSON would triple the file and cost a parse.
Capture timeinteger microsecondsSorts 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.
Booleans0 and 1SQLite has none, and pretending otherwise invites a string 'true'.
Perceptual hashthe unsigned bits reinterpreted as signedSQLite'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.

photocull architecture, planned The planned shape. The Mac app spawns the command line as a subprocess and reloads when the cache changes. A Swift daemon run by launchd serves the phone and replaces the Python web workbench. Over time the measurements move from the Python engine into Swift on Apple's frameworks, with Python kept as the oracle that checks the port. SWIFT · the product Mac app export, people, scan — no terminal ProjectWatcher: FSEvents, reloads on change Step 2 ANY BROWSER Phone, away from home SWIFT · launchd photocull-serve the read endpoints, in Swift survives the window closing and App Nap after Step 1 proves remote access is wanted PYTHON · the laboratory The engine, shrinking spawned as a subprocess progress as JSON lines on stdout kept as the oracle: every port is checked against it on a real shoot Step 4 moves its work across APPLE FRAMEWORKS Where the measuring goes Vision — faces, landmarks, head pose ImageIO — RAW, with as-shot white balance vImage · Accelerate · Core ML · Metal every threshold re-derived by measurement UNCHANGED .photocull/ the same file, the same schema, the same rule about whose verdict wins this is what makes every change above possible one piece at a time HTTP direct, as today reads the cache writes measurements spawn
ChangeWaiting for
The app spawns photocull as a subprocess, reads JSON-lines progress, reloads when the cache changesNothing — this is Step 2, next
A Swift daemon under launchd replaces the Python workbench for the phoneA 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 VisionNothing 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 testTime. Every threshold was calibrated against OpenCV's convolutions and has to be measured again. Months, not lines.
Face identityA decision. Apple has no equivalent, and nearly every public face-recognition model is research-only.