Nine things this project taught me
All of them cost something: a bug, an outage, a bad deletion, or a month of work I had to undo. The chapter behind each one is the receipt.
Writing code got cheap. Deciding what should exist didn't.
My bottleneck used to be how fast I could type. Now it's whether I can tell what shouldn't be built at all. Nobody working a lane ever stops to ask if the ticket should exist.
cost: 1,133 commits in one month, and 41 database tables nothing ever wrote to On reviewWhoever wrote it can't be the one to bless it.
Point a different model at the change and tell it to prove the thing is broken. Asking for a review gets you praise. Asking for a refutation gets you bugs.
cost: five real regressions that shipped past a normal review On knowing thingsRun it. Reading the code will lie to you.
Every real bug in the rewrite turned up by running the app. None came from reading. An empty grid sitting behind a 200. A livelock. A missing table that would have broken every first install.
Delete the question, not the answer.
Nearly every big cut started the same way. I'd notice that nothing was asking the question a subsystem existed to answer. It had usually been dead for months and nobody had buried it.
cost: 9,160 lines of thumbnail machinery that became one loop On big filesFind the coupling, not the file.
Huge files are usually propped up by two or three small connections to the rest of the app. Cut those and the file falls over by itself. Go after the file first and you break things.
cost: 1,661 lines kept alive by a single 8-line function On dataAnything you store is something that can disagree with the truth.
Work out which part of your data you genuinely cannot recompute. It's usually far smaller than you think. Derive the rest, and you stop paying to keep it all in agreement.
cost: years of reconciliation bugs guarding 0.06% worth of real data On safety checksA guard usually means the operation is shaped wrong.
When a fix needs a new check bolted on, there's often a version of the operation that needs no check at all. Write that one instead. Rules in comments rot; a ValueError doesn't.
rm -rf whose only safety was a marker file
On being wrong
Log your mistakes where other people can read them.
AI is confidently wrong a lot. So am I. If the record only contains wins it stops being useful, so the reverts and the retracted claims go in beside everything else.
cost: three deletions I had to undo, and 285 tests I nearly threw away On metricsDon't optimise a number you can game.
Line count shows whether the design is working. Chase it directly and you just get denser, worse code. I never set a target for it, and that turned out to be the right call.
cost: nothing, which is the pointThe toy that refused to die
You cannot honestly star-rate ten thousand photos. But you can always answer one question: this one, or that one?
The very first commit is a 228-line Python script called PhotoRanker. It opens a black Tkinter window, shows you two of your photos side by side, and waits. You click the better one. It updates a chess Elo rating for both, loads the next pair, and waits again.
That's the whole idea, and it's a good one. A 1–5 star scale collapses under its own subjectivity by the third shoot — is this sunset a 4 or a 5? Elo sidesteps the question entirely. Every click is a tiny, honest comparison, and thousands of them resolve into a single global order. The best photos naturally rise; the pairing is even biased toward the current top ten, so your leaders keep getting stress-tested against each other.

def update_elo_rank(winner_elo, loser_elo, K): expected_winner = 1 / (1 + 10 ** ((loser_elo - winner_elo) / 400)) winner_elo += K * (1 - expected_winner) loser_elo += K * (expected_winner - 1) return winner_elo, loser_elo # adaptive K: swing hard when ratings are close, gently when far apart K = 32 if abs(winner_elo - loser_elo) < 100 else 16
That adaptive K-factor is the entire ranking philosophy in two lines, and — remarkably — it survives, almost unchanged, into the app you can run today. Everything else was thrown away and rebuilt. This wasn't.
Try it. This is the real update rule, wired to a handful of Sean's photos:
"Propagated" is a preview of a much later idea (Chapter 03): when you pick a winner, its visual look-alikes should move too. Here it's faked with tile-adjacency; in the real app it's cosine similarity over CLIP embeddings.
Then the toy went quiet. One commit in September 2023, three in August 2024 (blacklisting, controller support), and then eighteen months of silence. The idea was right. The shell was a dead end. It would take a completely different kind of tooling to wake it up.
The web rebirth
Same idea. New body. The comparison leaves the desktop and moves to where the library actually lives — the browser.
In February 2026 the project restarts as a web app. Two commits. The Tkinter window becomes a FastAPI server and a dark HTML page. But the interesting change isn't the stack — it's a new way to ask the question.
Instead of a strict two-up duel, there's now a mosaic: a grid of candidates where you pick the single standout and everything else takes a small loss. More judgments per click, less fatigue. The same Elo math, fed faster.


The mind awakens
In a single day, the ranker learned to see. And by the last commit, it had a new name.
April 21st is the most important day in the whole history. Read the commit list in order and you can watch a tool become a product:
CLIP active learning → a unified bottom bar → an AI panel with effective-Elo pairing → a Library page with a justified grid and AI search → Qwen3-VL-Embedding-8B for state-of-the-art search → rename PhotoRanker → photoArchive → lightbox, find-similar, duplicate detection, EXIF, k-means auto-collections → Qwen3-VL-2B-int4 "to fit the GPU alongside other apps."
The core realization: a photo library shouldn't make you type tags. It should understand the pictures. Embeddings turn every image into a vector; suddenly you can search "golden hour over water" in plain language, find everything visually similar to a frame you love, and cluster a shoot automatically — all without a single keyword.

The state-of-the-art 8-billion-parameter embedding model was chosen at 10am and abandoned by evening for a 2-billion-parameter int4 version. Why? It had to share a single 8GB GPU with Sean's other resident AI models. "Fits the GPU alongside other apps" is the first appearance of a constraint that never leaves: this is a real machine, not a datacenter. Half the engineering in this log is a negotiation with 8GB of VRAM.
The need for speed
A four-day sprint that set the law the app still lives by: profile first, and never widen a budget to hide slowness.
With intelligence in place, photoArchive got obsessed with speed — the way only a tool you use every single day can. Over four days the commit log reads like a benchmark changelog, and each win came from the same move: stop asking the slow disk, and answer from memory.
The headline win — thumbnails 30–50× faster — is almost embarrassingly simple in hindsight. Every thumbnail request was doing an os.stat() on a spinning HDD plus a lock-contended SQLite lookup, even for images already sitting on the SSD. The fix: build a (size, image_id) → path dictionary once at startup and never touch the database on the hot path again.
# In-memory index: (size, image_id) -> disk path. Built once, updated on writes. _disk_path_index: dict[tuple[str, int], str] = {} def fast_disk_read(size: str, image_id: int) -> bytes | None: """Fast path: SSD read via in-memory index. No SQLite, no locks, no HDD stat.""" if not _disk_index_built: _build_disk_path_index() path = _disk_path_index.get((size, image_id)) if path is None: return None with open(path, "rb") as f: return f.read()
The same sprint refined the Elo propagation the demo above hinted at. When you pick a winner, its embedding-neighbors are nudged too — so ranking a 20,000-image archive doesn't require comparing every pair. The trick is a cubic falloff: a near-identical 0.99-similar frame gets 89% of the change, but a barely-qualifying 0.75 match gets almost nothing. That's why they could raise the neighbor count to 100 for free.
def _nonlinear_weight(similarity: float) -> float: """Cubic remap so near-identical images get strong propagation, barely-qualifying ones get almost none. linear: 0.75→0.75 0.90→0.90 0.99→0.99 cubic: 0.75→0.00 0.90→0.22 0.99→0.89 """ t = (similarity - SIMILARITY_THRESHOLD) / (1.0 - SIMILARITY_THRESHOLD) return t * t * t # cubic
The fastest photo app ever made. Profile first; never widen a timing budget to mask slowness.
The great refactor
The least glamorous chapter — and the one that made every chapter after it possible.
Over a single weekend, ~130 commits with names like refactor: drain compare route facades land back to back. A monolithic app.py is dismantled into feature modules; the thumbnail cache becomes its own package; every route, every controller — loupe, mosaic, date-scrubber, pregeneration — is extracted and given a home.
It's tedious to read and it was surely tedious to do. But you cannot bolt a physically-modeled darkroom onto a ten-thousand-line file. This weekend is the foundation the entire second half of the story is built on. Boring, and load-bearing.
One library, three faces
A charter is written: mobile is Google Photos. Desktop is Lightroom Classic. Same library, two personalities — and a native Android app for the third.
By summer the product vision crystallizes into a single sentence and a hard split. On the phone, photoArchive should feel like Google Photos — effortless, scrollable, one-handed. On the desktop, it should feel like Lightroom Classic — dense, keyboard-driven, serious. In June a native Kotlin/Compose Android client is born; in early July the desktop /d and mobile /m surfaces get a unified redesign with real writes, stacks, safe trash, share links, and smart collections.



Search v2 added a vision-language caption model for a text understanding index. Getting a 4-bit 7B VLM to load beside a resident voice daemon on one 8GB card took a string of commits — whole-model GPU placement, a hard 1280px cap on vision input ("uncapped previews demand >9GiB of attention memory"), and finally a retreat to a 3B captioner. Honest engineering against a wall that doesn't move.
The darkroom
photoArchive stops being a manager and becomes an editor — aiming, explicitly, to beat Lightroom, darktable, and Affinity pillar by pillar.
This is the technical peak of the whole project. In three days, a full RAW develop module appears: a live WebGL2 pipeline with Lightroom-ordered panels, an interactive tone curve, HSL, a clipping histogram, crop, masking, and history — plus its own RAW decoder, its own color science, and a film emulation engine that models actual photochemistry.

The twin: a fast lie and a slow truth that must agree
Here's the central discipline of the whole module. Every editing operation exists twice: once in NumPy (the export truth) and once in GLSL (the live preview). If they disagree, your edit looks one way on screen and exports another — the cardinal sin of a photo editor. So both twins are pinned to a single shared constants table, and a test enforces that they land within 0.4 of 255 on every pixel, across 53 operations.
TINT_UV_SCALE = 3000.0 LUMA_RED = 0.2126; LUMA_GREEN = 0.7152; LUMA_BLUE = 0.0722 TONE_GAMMA = 2.2 # ... 50+ more named constants ... # PARITY_TABLE auto-collects every uppercase constant, so no value can # silently drift out of the GL↔numpy contract. PARITY_TABLE = { name: value for name, value in tuple(globals().items()) if name.isupper() and name != "PARITY_TABLE" }
You can feel the parity yourself. Drag the exposure below — the left canvas is the "GL" fast path, the right is the "numpy" reference. The per-pixel max difference stays at zero:
The film engine: halation from the physics
The showpiece is film.py — a physically-modeled film emulation engine. It is not a LUT or a color grade. It models the photochemical chain: spectral layer exposure, halation, H&D characteristic curves, DIR coupler inhibition, and per-layer grain. The famous CineStill 800T red glow around bright lights isn't painted on. It emerges, because bright scene light physically bounces off the film base and back into the red-sensitive layer.
# halation: bright scene light bounces off the base into the red layer hal = t["halation"] amount = hal["amount"] * halation_scale if amount > 0.0: luma = rgb @ np.float32([0.2126, 0.7152, 0.0722]) excess = np.maximum(luma - hal["threshold"], 0.0) glow = _gaussian_blur(excess, sigma) layer[..., 0] += amount * glow # red bleed layer[..., 1] += amount * hal["green_fraction"] * glow # a touch of green
That's the actual algorithm below — a synthetic night scene, threshold the bright spots, blur them, bleed them back into red. Turn halation up and watch the glow appear on the lights. This is the film engine's core, running live:
Eight film stocks were datasheet-anchored, then tuned against 53 real San Marcos lab scans (Portra 400, Gold 200, Fuji 400, HP5). The halation radius came from measuring the actual red-minus-blue falloff around lights in Sean's own scanned negatives.
Decoding files LibRaw refuses
Lightroom's lossy DNGs (JPEG XL, DNG 1.7) can't be unpacked by LibRaw at all. So the develop module grew its own decoder: read the LinearRaw pyramid directly, then apply the DNG spec's color pipeline by hand — black/white levels, AsShotNeutral white balance, ForwardMatrix to XYZ, a Bradford-adapted matrix to linear sRGB, BaselineExposure. A 2048px base decodes in 0.4 seconds. And the whole thing is calibrated to be pixel-matched to Sean's actual Lightroom exports — a B&W conversion pair came out "near-indistinguishable."

Measured wins
Every number here is lifted straight from a commit message. Nothing was widened to hide a regression — that was the rule.
The field
The library stops living on one machine. A laptop in the field, a hub at home, a phone in your pocket — one catalog, converging.
The three faces from Chapter 05 needed to actually share a library. So the app grew a satellite ↔ hub architecture: a catalog mirror, tiered thumbnails, predictive prefetch, and an oplog that converges rather than a request that blocks. The governing rule — the laptop never waits on the server. You cull on the plane; it reconciles when you land. Geodata backfill, a Google Timeline importer, and keyword/IPTC support land in the same window.


Becoming Azimuth
photoArchive gets a real name, a real installer, and a real front door — the turn from "my tool" to "a thing other people can run."
photoArchive → Azimuth Photo. A brand layer across the wordmark, the PWA manifest, the Android launcher, the Tauri desktop shell; bundle IDs move to app.azimuthphoto.*. Crucially, the internal identifiers, paths, and database names are left untouched and sequenced behind in-flight work — a rename done like an engineer, not a marketer. A Docker image, a compose file, an Unraid template, and an INSTALL.md for NAS boxes turn it into something installable. A full Google-Photos-replacement Android client — timeline, auto-backup, free-up-space — ships the same week.
The name Azimuth wasn't the developer's idea. It was suggested by his beautiful, creative, and clever girlfriend — an azimuth is the compass bearing to a point on the horizon, which is a rather perfect name for a tool that helps you find your way through tens of thousands of photographs. He takes no credit for it, and gladly so.
Commit the phone client source — it was untracked on every machine. Git is the backup.
War stories
The scariest commits in the log. Every one is a real incident — mostly caught in review, some in production.
The phone deletes a local photo once the hub reports it "backed up." The manifest counted trashed, missing, and mirror rows as backed up — so the feature could delete a photo the hub didn't actually have. The fix requires on-disk byte proof: a live original, present, at the expected size.
A nightly 04:00 job used shared temp filenames across instances. Two instances collided and left prod with zero sealed snapshots. Fixed with per-process temp names, an orphan sweep, and a weekly restore drill proven against a real 150k-image snapshot.
The website publish hook was a shell command the client could set — a verified RCE. Closed by making the hook server-side-only and adding default-deny owner authentication to every non-public route.
Scanning a source that happened to be offline marked every photo missing. A data-loss guard now refuses to let an empty online scan blank the catalog.
The 4-bit 7B vision model's CPU-offload path crashed on this transformers/bnb pairing. The resolution: whole-model GPU placement so a clean OOM is at least recoverable — and a permanent retreat to a 3B model.
The fleet
The most surprising thing in the git graph: for the final push, the app was built out by a fleet of AI models, each owning a lane, checking each other's work.
July 2026 has 1,133 commits — 72% of the entire project. Read them and a structure emerges: a session-lane charter, an explicit org model of "CEO / CTO / managers / subagents," and hundreds of merges from named branches — qfix-workers, ux2-perf, errhonesty, dt-gfilter. This is a human designer directing a fleet of frontier models, each taking a lane, with cross-model review as a hard rule: whoever wrote it cannot be the one to bless it.
Owns the vision, the specs, the naming, and the final diff review. Decides what ships. Never trusts a delegate's own summary of its work.
Flat-rate coding lanes that take a tight spec and return a branch. Literal, fast, and required to prove their work with a passing gate.
Parallel reviewers fired liberally at every merge — confident, occasionally wrong, so every finding is independently verified before it counts.
A different model tries to refute each change. Five real regressions in one night's UX wave were caught exactly this way.
The lane that names this era is error-honesty: the app must never lie about its own state. Toasts only after the write commits. Loading, empty, and "preparing" states that tell the truth. Worker waits that read as active work. "Photos exist on screen immediately, and it is never a void." An entire quality program — dozens of qfix merges in a loop-until-dry rhythm — enforcing the idea that a confident-looking lie is worse than an honest "working on it."
- Breadth, fast. A film emulation engine, DNG colour science, masking, HDR merge, panorama stitching and a Windows test runner all landed inside a few weeks.
- Not getting bored. 95 Windows platform failures taken to zero. 285 tests restored one at a time. Work I would have abandoned around hour three.
- Parallel attention. Four lanes auditing different dimensions of the same change at once, which is not something one person does.
- Adversarial review. Pointing a second model at a change and asking it to prove the thing was broken caught five real regressions in one night.
- Decide what shouldn't exist. Every lane closed its tickets. Not one asked whether its ticket should have been written. That question only ever came from me, and for months I didn't ask it either.
- Notice a dead question. 41 tables got built, wired, tested and never written to. Each was reasonable on its own; nothing was tracking the whole.
- Feel the weight. Nothing in a lane registers that the app is getting harder to work in. That shows up as 24% of commits being repairs, and nobody inside the loop is measuring that.
- Be trusted about its own work. Delegate summaries were confident regardless of outcome. Re-running the acceptance commands myself was the only thing that reliably worked.
A fleet of models will build you almost anything you can specify. It will not tell you that you shouldn't have specified it.
So the job changed. Less writing, much more deciding: what to build, what to refuse, what to throw away, and when the honest answer is that a month of good work needs to come out. That judgement is now the scarce thing, and it's the part I'd want to be hired for.

This is the part I would have left out if I were writing as I went. The fleet was very good at producing software. Left to itself, it was not good at producing the right software.
Every lane closed its tickets. Every merge got reviewed. The tests were green. Underneath all that, two things were quietly true: 24% of the 2,203 commit subjects were repair-shaped (fixes, hotfixes, regressions, seams), and simple changes had started taking days. It took me a while to see that those were the same problem.
Then, in August, the owner wrote down the sentence that turns this whole story around.
The turn
“Azimuth does not work well and nobody uses it daily.”
What was actually in there
Before deleting anything, one question got asked properly for the first time: of everything this app stores, how much of it could we never get back?
The catalog was measured on the live library. 2.4 GB across 84 tables — and 41 of those tables were empty. Not sparse. Empty. Machinery that had been built, wired, tested, and never had a single row put in it.
Then the real question. Of everything in the 43 tables that did hold data, how much of it was a decision a human being actually made — a keep, a star, an edit, a name? Everything else is a machine's opinion about bytes it can read again.
| develop settings | 79,487 |
| stars | 6,165 |
| comparison pairs | 2,532 |
| develop presets | 994 |
| quality marks | 89 |
| keywords | 77 |
| flags | 32 |
| trashings | 2 |
| everything you ever decided | ≈ 89,378 |
The irreplaceable part of Azimuth fits in one append-only table — and it is 0.06% of the rows in the catalog holding it.
That number changed how I read the previous two years. The machinery wasn't protecting anything precious. It was bookkeeping: backlogs tracking what still needed computing, cursors remembering where they'd got to, presence tables recording what each disk held, and more tables reconciling those against each other. Storage you don't need isn't free. You pay for it in reconciliation, every day, forever.
It also meant smaller and safer pointed the same way for once. Usually those two fight each other.
The gutting
This wasn't tidying. It was a deliberate program with a kill order and a running score, aimed at making the codebase stupid simple.
I want the codebase to be 5% the size it currently is. Smaller if we can get away with it. Gut and slim it down to make it stupid simple — otherwise we will burn tokens like mad just trying to navigate it.
The reasoning is blunt, and I think correct. A codebase isn't only something you run. It's something you and your tools have to move around in. Every dead subsystem taxes every future change and every attempt to work out what is actually going on. Past a certain size the code stops being an asset and starts being terrain.
So the waves went in, each one committing green — boot, film import, grid, develop — with git history as the archive:
Killed outright. It precached versioned URLs; every engine swap changed every stamp; its cache-miss path answered a synthetic 504 Offline — including for the app's own bootstrap. A perfectly healthy build would show “0 photos.”
Publish, share, shared, publishing — four overlapping systems for handing someone a photo.
The single biggest cut. The laptop and its drive are the system; the hub was an architecture built for a problem that had stopped existing.
All the encoder plumbing goes; cached query vectors still rank semantically, and lexical fusion carries fresh queries.
The surviving tests are the load-bearing behaviour net. Machinery tests had already died with their machinery; only orphaned scaffolding went.
app_factory merged into app.py — one file you read top to bottom: create, mount, arm.
The part that makes it trustworthy
A deletion program that only reports its wins isn't worth much. I kept the errors in the history too, and the commit subjects say it plainly:
Client galleries and per-face correction came back — each with the bound they'd never had in the first place.
The sweep had been keyed on callers, and behaviour tests have no callers.
Followed immediately by “Name the real cause: the drawer repaints every couple of seconds.”
The investigation found the investigator.
Then: “There was no anomaly: the test runs 5.5s on a quiet machine.” A retracted claim, kept in the record.
I'd point at these before any of the feature work. Keeping an honest score against yourself, in public, is the part that makes the rest of the record worth trusting.
The core, derived from first principles
This wasn't a refactor. Each surface got rewritten on a new core, with its old machinery deleted in the same commit. The lessons carried forward. The shapes didn't.
Once I accepted that 99.94% of the catalog could be recomputed, a much smaller design fell out on its own. Azimuth needs to know four things about a photograph: what it is (its bytes), where the copies are, what you decided about it, and what the machine worked out from it. Only the third of those can't be rebuilt.
Four facts became five tables and seven functions:
drives (id, uuid, root, is_record)
photos (id, hash, version_of, tail,
taste, <your decisions>,
<computed memo>)
copies (photo_id, drive_id, tail, seen_at)
decisions (subject, family, value, at)
cache (hash, kind, recipe, state,
path, value, bytes)
identify(file) what photo is this open(photo) give me the file put(file, drive) write it, then identify saw(photo, drive) record a copy (a hint) make(photo, kind) thumbnail / embedding decide(subject) append to the log sweep(drive) check what's on a drive
The bug that was hiding in a filepath
The best single insight in the rewrite is small enough to fit in one line, and it explains years of bugs:
An absolute path is a drive plus a tail — and we stored them fused.
That one decision is behind the drive-probing, the hub_remote column, missing_at, the mass-missing circuit breaker and rebind_moved_source. Five separate subsystems, all trying to recover a drive letter from a string that had swallowed it. None of them were bad code. They were careful, tested answers to a question that only existed because of how I'd stored a path.
Store the tail and compute the path instead. Now a renamed root is one row. A new drive letter is one row. The same photo sitting on two disks stops being a reconciliation problem and just becomes the two-tier setup working as intended. I didn't fix those five subsystems. They stopped having a reason to exist.
The same trick worked on background work. Every backlog table, progress ledger and resume cursor existed to answer one question: what still needs doing? Ask that as a query instead of maintaining the answer, and it collapses to a single anti-join for photos with no cache row of that kind. Nothing to resume, nothing to expire when a worker dies, because there was never really a queue.
# One anti-join replaces every backlog, ledger and cursor in the app. # Nothing to maintain, nothing to resume, nothing to reconcile. def owed(kind, recipe): return """ SELECT p.id FROM photos p LEFT JOIN cache c ON c.hash = p.hash AND c.kind = ? AND c.recipe = ? WHERE c.hash IS NULL """
The results weren't subtle. All measured on the live 157k-photo catalog. Worth noticing why the grid got fast: no new cache, just 86 indexes on one table dropping to 5. Most of them had been maintaining answers to questions nobody asked any more.
The scariest bug of the rewrite shipped silently. model/schema.sql was read by the test suite and by nothing in the running app — the core tables had been created once, by hand, in a live database. Measured on a catalog built from nothing through the real boot path: drives, copies, decisions, cache all missing. Every flag, star, rotation, tile and sweep would have failed on a first run — the one path the developer never takes.
The fix is one function. The lesson is a rule: verify by building a fresh catalog, never by reading the file.
Seven rules, each earned by a deletion
Not borrowed from a book. Every one of these was paid for by something in this repository, and they're ordered by how much they removed.
Delete the question, not the answer.
Almost every large cut followed noticing that something had stopped being asked. thumbnails/ was 9,160 lines answering when should a tile be made and where should it go. Owed became a query and “where” became a filename — both questions vanished, and took the module with them. Ask what a subsystem is deciding. If nothing is deciding it any more, it's already dead.
A four-figure file is held up by two or three small couplings.
features/library/service.py was 1,661 lines kept alive by one 8-line function that belonged in collections. The people stack came out because a search resolver took two injected callables. Find the coupling, not the file — the file falls over on its own.
If a fix needs a guard, find the version that needs no guard.
“Clear cache” was shutil.rmtree(root) behind a marker check — the only thing standing between a settings typo and someone's Documents folder. Deleting exactly the files we recorded writing made the guard unnecessary rather than better. A guard is a sign the operation is shaped wrong.
Make the invariant mechanical, not advisory.
“A recipe is never a timestamp” was a comment for years, and broke anyway. Now a kind declares its parameters and canonical() refuses anything else. Doctrine decays; a ValueError does not.
One table per idea, and derive the rest.
decisions and cache absorbed roughly ten tables between them. Elo, the folder tree, edit history, snapshots, status counts and tag lists are all computed — and each one stopped being a thing that could disagree with the truth. Storage is a liability you pay for in reconciliation.
One job in one place, then let concurrency be a number.
Three preview workers arbitrating through a governor became one loop plus a lane index. Lanes need no claims, leases or visibility timeouts, because a worker that dies leaves nothing to expire — the queue is a query.
Verify by running it.
Every real bug in the rewrite was found this way and none by reading: an empty grid behind a 200, a livelock that had stopped tiles at 95, a middleware import that 500'd everything, a table whose absence would break every fresh install, 75 sidecars offered as photographs. app OK proves imports resolve and nothing else.
It would be easy to end on “182,688 lines became 105,632.” But line count is not the measure — it's gameable, and optimising it produces denser, worse code. Asked to choose a target, the owner refused to: “idk the final number — lets just try to write the most elegant code as possible.”
So elegance is the objective and the line count is only its evidence. Which turns the one gate into something more useful than a target: a budget is a ratchet. Measure the tree today, let the number only descend, and make raising it cost an explicit one-line commit. That needs no forecast to be correct — and it prices a second way of doing something at the exact moment someone writes it.
Elegance is fit, not polish
I spent two years assuming those were the same thing. The literature is fairly clear that they aren't, and my own repo agrees.
The cleanest evidence is forty years old. In 1986 Jon Bentley set a word-frequency problem in his Programming Pearls column. Donald Knuth answered with a literate program in WEB/Pascal: ten-plus pages, a purpose-built data structure, beautifully documented. Doug McIlroy answered with six shell commands.
Knuth — 10+ pages of literate WEB/Pascal, custom data structure, fully documented and admired
McIlroy — tr -cs A-Za-z '\n' | sort | uniq -c | sort -rn | sed 10q
McIlroy's verdict on Knuth's program was that it was an over-refined museum piece. The detail usually lost in the retelling: his objection was architectural, not about length. He faulted Knuth for programming “monolithically and from scratch” instead of composing parts that already existed. And he defended the pipeline on engineering grounds — separation of concerns, piecewise debugging, reuse — rather than on taste.
So the more crafted artifact was the less elegant one. That reframed the whole thing for me. Elegance didn't live in the workmanship. It lived in noticing the problem was already expressible in what existed.
Dijkstra supplies the other half of the definition, and he takes it straight from a dictionary: elegance is “ingeniously simple and effective.” The conjunction does real work. It rules out minimalism and code golf on the spot, because a shorter thing that does less has not won anything.
Elegant code is code whose structure matches the structure of the problem. Small is what happens next, not what you aim at.
The corollary is where the working advice comes from. If elegance is structural agreement, then bloat is the residue left wherever your model of the problem and the real problem disagree. Every wrapper, flag, guard and status column is a patch over one of those gaps. Which means refactoring isn't tidying. It's correcting the model, and the machinery falls away on its own because it only existed to hold the mismatch together.
Three people arrive at the same place from different directions. Ousterhout defines complexity as dependencies plus obscurity — and notably treats obscurity as part of the definition, which makes readability a technical property rather than a style preference. Hickey names the unit: complecting, the braiding together of two things. His line is that multiple instances of a thing don't make a system complex; interleaving does. Brooks splits difficulty into essential and accidental, and points out the arithmetic: since tools only attack the accidental, a tenfold gain requires the accidental to have been more than 90% of the work.
Hickey's other distinction is the one I use most. Simple is objective — one fold, one braid, a property you can inspect in the artifact. Easy is relative to you, from a root meaning “to lie near.” Familiar code feels simple and often isn't.
I tested this on my own repo, against a hard bar
Everyone's heuristic is that big files are bad. But the metrics literature has a warning that should stop anyone from trusting that. In a study of a large industrial C++ system with faults traced to real field failures, the standard object-oriented metrics predicted fault-proneness only until class size was controlled for. Afterwards, none of them survived. The authors concluded the existing body of validation work was cast into doubt, and set the bar every metric has to clear:
Does this metric predict defects beyond size?
So I ran that test here. 312 surviving source files, 2,304 commits, tests excluded. The outcome is how many repair commits touch a file. The coupling measure is how many non-repair commits touch it, which keeps the predictor disjoint from the thing being predicted.
| predictor | correlation with repairs | controlling for the other |
|---|---|---|
| coupling — how often unrelated work drags the file in | 0.71 | 0.56 · survives |
| size — how many lines it has | 0.55 | 0.19 · mostly evaporates |
The file that makes it concrete is web/app.py. It is 120 lines and it has the worst repairs-per-line in the entire repository. It's the assembly point, so everything eventually needs it. By size it looks like the safest file in the tree. By coupling it's the most dangerous, and coupling turned out to be right.
Which gives the most useful thing I took from any of this: stop asking how big a file is. Ask how often unrelated work has to touch it. That's computable from git history on any repository, with no instrumentation at all.
The strongest argument against all of this
Richard Gabriel's Worse Is Better is usually cited as proof that elegance loses to pragmatism. When I actually read it, the picture was stranger and more useful. It's a thesis about piecemeal evolutionary growth, much closer to Gall's law — a complex system that works is invariably found to have evolved from a simple system that worked — than to an argument that low quality is desirable. Gabriel then argued the opposite side himself, publishing an attack on his own thesis under a pseudonym. A decade later at an OOPSLA panel he submitted both a pro and an anti position paper and said he still couldn't decide.
The genuinely useful part is how he frames the two camps. Both of them rank simplicity highly. The decisive difference is where the simplicity is placed: one camp holds that interface simplicity outranks implementation simplicity, the other holds the reverse. That isn't elegance versus pragmatism at all. It's a question about whose life you're making simpler, the caller's or the implementer's. Ousterhout's deep module is just the first position stated as a rule.
I went looking for evidence that elegant code is more dependable and did not find it. Dijkstra ties reliability to simplicity, but concedes there is no method for achieving simplicity — the field knows it's required and has managed it in individual cases. Parnas, in the founding paper of modular design, grounds changeability in argument but explicitly labels the comprehensibility claim a personal subjective judgement with no evidence offered. Gall's version is an aphorism. And Gabriel supplies a counterexample where the elegant answer needed about a hundred pages of assembly, making correctness practically unreachable.
So the link is plausible, widely believed, argued by serious people, and not established. The part most likely to be true is the mechanism my own numbers touch: entanglement predicts breakage.
Two arguments worth reconciling
Carmack is cited constantly against small functions, usually for the wrong reason. His argument isn't performance and isn't readability. It's state: pulling code into a named subfunction creates a callable shortcut that someone later uses to do a partial update, and most bugs come from execution state differing from the programmer's mental model. His 2014 retrospective reframes it further — the load-bearing idea is controlling unexpected dependency and mutation, and inlining is just one fairly poor tactic for that. He also ranks duplication as worse than the problems shared functions cause.
Sandi Metz points the other way: duplication is cheaper than the wrong abstraction. Her mechanism is precise. A near-fitting requirement arrives, someone adds a parameter and a conditional rather than revisiting the abstraction, and enough loops of that turn it into a procedure made of special cases. It survives because of sunk cost, since existing code reads as evidence of its own necessity. Her remedy runs against instinct: re-inline it into every caller, delete what each caller doesn't need, then derive the abstraction again.
They're answering different questions. Metz is talking about abstracting before you know the shape. Carmack is talking about copy-pasting logic whose shape you already know.
The rule that satisfies both: duplicate until the shape is obvious, then unify once — and never patch an abstraction to fit a case it wasn't shaped for.
Steering a model away from slop
Models don't write slop because they're bad at code. They write it because of what we ask for, and what we accept back.
Every model I used could write good code. Most of the bad code I got was my doing: I asked for a task instead of a shape, took the first thing that worked, and let it tell me it was finished instead of checking. Before the patterns, though, it's worth knowing what the measured effect of all this actually is, because it surprised me.
| measured effect of AI-assisted development | change |
|---|---|
| Duplicated code blocks (GitClear, 623M changes) | +81% |
| Copy/pasted lines | 9.4% → 15.7% |
| “Moved” lines — their proxy for refactoring | ~21% → 3.8% |
| Defensive / error-masking constructs | +47% |
| Maintenance of older code | −74% |
| Experienced devs on their own mature repos (METR RCT) | 19% slower |
Two things in there changed how I work. The first is GitClear's reading of the mechanism, which I think is correct: this is an incentive mismatch, not a model defect. Assistants get pointed at maximising the volume of code written, and volume-oriented use is what produces the clone-and-churn signature. My own July is that signature exactly — 1,491 commits and nearly a third of them repairs.
The second is the METR finding that assistance degrades most in codebases with high standards and a lot of implicit, unwritten requirements. Which is to say: AI helps least exactly where the code is already good. The better your codebase gets, the more the value moves from writing to deciding, and the more the deciding has to come from you.
The prompts that changed my results most
None of these are clever. They're mostly just refusing to accept the first working answer.
“Add support for photos that live on a second drive.”
“Photos can live on a second drive. Before writing anything: what does the current model get wrong about where a photo lives? If a field is doing two jobs, say so.”
Why it works: the first prompt accepts my model of the problem and adds to it. The second puts the model up for review. This exact exchange is how “a path is a drive plus a tail” surfaced, and that one sentence removed five subsystems.
“Fix the bug where thumbnails don't regenerate after an edit.”
“Thumbnails don't regenerate after an edit. Find the cause and tell me what it is before proposing a fix. Then: what does the fix make unnecessary?”
Why it works: the second half is the important half. Asking what a change makes redundant, in the same breath as the change, is what turns a growing codebase into one that can shrink while gaining features.
“Review this diff.”
“Here is a diff. Find the case where it is wrong. Assume it is broken and show me how. Don't tell me what it does well.”
Why it works: a review request gets answered agreeably by default. A refutation request has a different success condition. Run it with a different model than the one that wrote the code and it can't defend its own reasoning. Five real regressions came out of one night of this.
“Implement the import pipeline.”
“Before any code: give me one sentence that states the shape of this, and a list of what that shape makes unnecessary.”
Why it works: the two best modules in this codebase both open with exactly that artifact — a sentence stating the shape, and the machinery it kills. “Owed is what should exist, minus what is cached” deleted a 93,220-row backlog, six triggers, three ledgers, five cursor tables and seven schedulers. That's a specifiable deliverable, and asking for it first is what converts a model from volume-producer into design partner.
Two rules I'd keep above the rest
Make the invariant mechanical. “A recipe is never a timestamp” sat in a comment for a year and got violated anyway, by me and by every model that touched it. It stopped being violated the day it became a function that raises. Written rules decay under pressure. A ValueError doesn't care how late it is.
Don't iterate on bad output. Take it back. When something came back mediocre my instinct was to explain more and try again, and that almost always cost more than doing it myself. The second attempt usually inherits the shape of the first. Now a weak result means I stop delegating that piece.
A model optimises the question you asked. It has no opinion about whether it was the right question.
So the whole job is asking better ones and then actually checking the answer by running it. Everything above is a variation on those two things.
Every screenshot is the real software, at that moment
No mockups, no re-creations. Each era's UI was captured by time-traveling the codebase and running it.
The screenshots in this log aren't reconstructions of how the app looked — they're the actual historical software, booted and photographed. For each milestone commit, a small rig checks it out into an isolated git worktree on a scratch port with a scratch database, scans a fixed set of 64 of Sean's real photos, warms the thumbnail cache, and drives a headless browser to shoot the UI. Nothing touches the real library.
Time travel has a cost: old code needs old dependencies. The February–April 2026 eras render their pages with a Jinja template call that modern Starlette flat-out removed, so they run on a pinned Starlette 0.37 interpreter. The May-schema eras won't boot on any current aiosqlite at all — they wrap schema setup in a transaction and then call executescript, which today raises cannot commit transaction — SQL statements in progress. A later commit fixed exactly that, which is why the darkroom shots are from the current build. The 2023 Tkinter app? Run verbatim, only the folder-picker auto-answered — the same accommodation as disabling AI for the web eras.
def capture_era(label, commit, shots, port, server_py=None): wt = ensure_worktree(label, commit) # detached git worktree home = SCRATCH / label # scratch DB + caches proc = start_server(wt, port, home, server_py) # era-matched interpreter wait_http(base); scan_and_wait(base, SAMPLE) # ingest the 64 fixtures wait_ready(base) # warm previews til the grid paints capture_routes(base, shots) # Playwright, per era
The whole rig — harness.py, per-era config, three pinned Python environments, and the boot-and-hold server used for the interactive Develop/Film shots — lives beside this page. It is, fittingly, its own tiny piece of software written to tell the story of another one.
What it cost, what it taught
Written as I went, this would have been a list of things I added. Looking back, it's a shorter and more useful story than that.
One decent idea, which is that you can't honestly rate a photograph but you can always pick between two. Then two years of machinery piling up on top of it. Then a month spent digging it back out. That's the whole shape.
What I'd tell someone starting a project like this. The hard part was never getting code written. Once I had a fleet of models working lanes, code was the cheapest thing in the building. The hard part was deciding what deserved to exist, and I was slow to understand that the answer to "should we build this?" almost never comes back from the thing building it.
Speed is something you defend, not something you report. Profile first, and don't widen a budget to hide slowness. That's why boot went from 65s to about 5s, and why the grid later went from 180ms to 0.22ms by taking 81 indexes out rather than putting a cache in.
Honesty turned out to be structural. A UI that won't fake a success toast. A backup that insists on byte proof before it calls a photo safe. A live preview that has to match its export to within 0.4 of 255. A deletion program that writes down the three deletions it got wrong. Same move every time: don't let the system, or the write-up, claim something it can't back.
Knowing what's irreplaceable is what makes you brave. Every scary moment in three years was the same fear in different clothes, which is losing a photograph I can't get back. Working out that 0.06% of the catalog was the part I couldn't rebuild is what made it safe to delete the rest without flinching.
The best code I wrote this year was code that stopped being necessary. Five subsystems didn't get fixed. They stopped having a reason to exist once a path was stored as a drive plus a tail. Every backlog and cursor collapsed into one anti-join. I don't know a refactoring pattern that beats noticing a question nobody asks any more, because it's the only move that makes a codebase smaller and more capable at once.
Azimuth 2.0 isn't finished. The UI is staying: 39k lines of grid, loupe and keyboard work that were always the good part. It's the backend underneath that's learning to be small.
Everything in this log is real — and it's yours to run.
Azimuth Photo is open source and self-hosted. It runs on your own computer, NAS, or server; your photos never leave your hardware. It ships empty and hungry.
AZIMUTH · FIELD LOG — a development log built from the real git history of Azimuth Photo.
Every screenshot on this page was captured by checking out the exact commit and running that era's software against a fixed set of Sean's real photographs — the 2023 Tkinter app included. Every benchmark, line count and code excerpt is quoted or measured from the commit that shipped it.
Span 2023-08-11 → 2026-08-16 · Commits 2,304 · Peak month 1,133 · Peak size 182,688 lines → 105,632 · The working core ~3,500 · Irreplaceable 0.06% of rows · Twin parity <0.4/255.