AZIMUTH · FIELD LOG ← azimuthphoto.com BEARING 000°
A field log · 2023 → 2026 · 2,304 commits · one photo app

I let AI write 182,688 lines. Then I deleted 42% of them — and it got faster.

I built Azimuth Photo mostly by directing AI: a self-hosted photo library with a physically-modeled film engine. This is what that taught me, written down honestly, including the parts I got wrong and had to undo. The app is the evidence. The lessons are what I actually want you to take away.

1,133
commits in one month, by an AI fleet
24%
of commits were repair-shaped
41
database tables built, never used
0.06%
of the catalog was irreplaceable
1800.22ms
grid, after deleting 81 indexes
Read this first / the whole log in 60 seconds

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.

On building with AI

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.

On review

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

On knowing things

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

On elegance

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.

On big files

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

On data

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

On safety checks

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

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.

On metrics

Don'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.

What follows is the long version. Three years, an archive of 150,000 photographs, and software that grew, overgrew, and got cut back down. The screenshots are the real app at each point in its history, and the numbers come from the commits that shipped them.
Chapter 00 / Aug 2023 – Aug 2024 / f60ab3c…4586172 · 12 commits

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

PhotoRanker · Main.pyd97f2aae5 · 2023
The 2023 PhotoRanker desktop app: two photos side by side with Left/Right ranking buttons
The original, run verbatim from the 2023 commit for this log. Two of Sean's night frames, Left / Right to pick a winner, and an honest little counter: "Unrated Images in Current Folder: 23." DNG support via rawpy, a threaded preload queue, even Xbox controller support for long couch culling sessions.
pythonMain.pythe seed of everything · d97f2aae5
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:

Liveelo_ranker.js — the 2023 rule, in your browserclick the better photo
candidate photo
1200
VS
candidate photo
1200
comparisons 0
propagated 0
top rating 1200

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

Chapter 01 / Feb 2026 / db3b4ad…60cb19f · 2 commits

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.

PhotoRanker (web) · mosaic mode60cb19f39 · 2026-02
Web PhotoRanker mosaic: a grid of candidate photos with Mosaic / Swiss / Top 50 modes
Booted from the February 2026 commit. Still called PhotoRanker. "64 images in pool — mosaic mode," with Mosaic / Swiss / Top 50 tournament styles and an Explore / Compete / Top Cut strategy row. The bones of a real culling tool are here; the intelligence is not — yet.
PhotoRanker (web) · rankingsthe leaderboard
The rankings leaderboard: photos sorted by Elo rating
The payoff view — every photo sorted by its earned Elo. This is the same idea as the 2023 desktop toy's "View Top Ranked" button, finally with room to breathe.
Chapter 02 / April 21, 2026 / one astonishing day · ~16 commits

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.

photoArchive · Library (the day it was born)c0d7f5441 · 2026-04-21
photoArchive Library: edge-to-edge justified grid with an AI search bar and Top Rated / AI Ranked sorting
The newborn photoArchive, booted from the April 21 commit. An edge-to-edge justified grid, a search bar that reads "e.g. sunset, portrait, building," and sorting by Top Rated / AI Ranked / Most Compared. Heritage-flight jets, Starbase, coastlines, aurora — Sean's real archive, now searchable by meaning.
◈ the constraint that shaped everything

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.

Chapter 03 / April 22–25, 2026 / the doctrine is born

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.

Measuredthe speed sprint, from the commit messagesreal before → after
Thumbnail serving6b2f46443
baseline · disk + SQLite/req
30–50× faster · in-memory path index
Sort & filter3ea37e76b
baseline · table scan
5–20× faster · composite indexes
Search / similarbf6a3e2ed
baseline · recompute per call
near-instant · shared embedding cache

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.

pythonweb/thumbnails.pyno SQLite, no locks, no HDD stat · 6b2f46443
# 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.

pythonweb/elo_propagation.pyweak matches contribute ~0 · 93e12896e
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 doctrine that starts here and never leaves the codebase
Chapter 04 / May 17–18, 2026 / ~130 commits in 48 hours

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.

Chapter 05 / Jun – Jul 8, 2026 / the "one" redesign

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.

Desktop · Library grid + live ranking panel2026-07-08
The desktop library: justified grid of landscapes with a live Elo ranking panel showing 1492, top 3% of scope
The Lightroom-Classic desktop face. A justified grid of Sean's landscapes, and on the right the ranking panel reads a real Elo: 1492 · top 3% of scope · 105 comparisons · 28 propagated · Confident. The whole culling philosophy from Chapter 00, now a quiet sidebar readout.
Loupelights on
Loupe view of a landscape with metadata panels
The loupe — full-frame review with live panels.
Refine · mosaic duelpick the keeper
Refine mosaic: a grid of similar photos to pick the best from
Refine — the 2023 mosaic idea, grown up.
◈ still negotiating with 8GB

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.

Chapter 06 / July 9–11, 2026 / dev1 → dev7 · 611 tests green

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.

Develop · the live WebGL2 editorD-key entry
The Develop editor: presets rail, tone/presence sliders, RGB histogram, and an interactive tone curve
The darkroom. Basic / Tone / Presence panels, a live RGB histogram, an interactive tone curve, presets (including Sean's own imported Fuji look), masking, heal, and soft-proofing — all rendering live in a WebGL2 shader.

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.

pythondevelop/ops_constants.pythe contract is derived, not hand-kept · ef81437ab
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:

LiveGL preview vs numpy export — same math, same pixelsdrag exposure
GL PREVIEW fast
NUMPY EXPORT truth
exposure +0.60 EV
max pixel Δ 0.00 / 255

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.

pythondevelop/film.pythe 800T glow is not painted — it emerges · cc6071721
# 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:

Physicshalation — faithful port of film.pydrag the sliders

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

Develop · a long-exposure in the editorRGB16F, live
The develop editor on a long-exposure sunset over water, with a live RGB histogram and a preset rail
A different frame in the same darkroom — a long-exposure lake sunset, its full-range histogram spread live across the RGB channels. Every edit is non-destructive and reproducible, and because the numpy twin renders the identical math, it exports exactly what you see. The presets rail on the left holds 994 imported Lightroom looks and Sean's own film emulation.
◆ Interlude

Measured wins

Every number here is lifted straight from a commit message. Nothing was widened to hide a regression — that was the rule.

Cold boot65 s~5 s59758bc2e · quick_check only after unclean shutdown
Cold search2.1 s0.7 se7c7d55ae · targeted IN-query, not 87k materialize
Develop open483 ms30 msb310546c7 · on-demand Adobe profile store
Suggestions10.5 sin-memf4aa36f85 · tag co-occurrence, not a SQL self-join
/api/stats @139k<200 ms633d97173 · warm
Lossy-DNG 2048px(fails)0.4 se68b0973e · LinearRaw pyramid
Windows QA95 fail01cbb60da2 · first Windows runner, on real GPU
GL↔numpy twin<0.4/255874b8a705 · 53 ops exact
Chapter 07 / July 11–13, 2026 / FIELD_SPEC · one library everywhere

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.

Timeline lensdate river
Timeline lens: a date-river of months with photo counts and a scrubber
The date-river — months, day-rows, per-day thumbnails, and a scrubber on the right edge that jumps years at catalog scale. Alongside it: EXIF geodata backfill, a Google Timeline importer, and keyword/IPTC support, so the same library is navigable by when and where, not just what.
Mobile · /mGoogle-Photos face
The mobile library, one-handed Google-Photos-style
The phone face — installable PWA, one-handed, offline-aware, with the same library underneath.
Chapter 08 / July 12–13, 2026 / c5fc4579a · the rebrand

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.

◈ credit where it's due

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.

— d4ea3fa30, a lesson learned the hard way
◆ Interlude

War stories

The scariest commits in the log. Every one is a real incident — mostly caught in review, some in production.

01
"Free-up-space could delete your last copy" a9a1829

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.

02
Production had zero valid backups 5c07f6344

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.

03
A remote-code-execution hole in publishing e340a84ca

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.

04
An empty scan wiped the library 4a35e5728

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.

05
The captioner OOM'd an 8GB card 8cf59a6d2

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.

Chapter 09 / July 15–20, 2026 / 1,133 commits · the mega-sprint

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.

The Orchestrator
architecture · taste · adjudication

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.

The Implementers
features · refactors · tests

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.

The Swarm
bug hunts · audits · second opinions

Parallel reviewers fired liberally at every merge — confident, occasionally wrong, so every finding is independently verified before it counts.

The Adversary
cross-model review

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

✓ What the fleet was genuinely good at
  • 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.
✕ What it could not do for me
  • 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.
The lesson I'd hand to someone else

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.

Azimuth Photo · at its largestb9a701bac · 2026-07-20
Azimuth Photo at the end of the July sprint: the desktop grid with live ranking and metadata panels
The app at the end of the sprint, booted from that exact commit. Three years from a black Tkinter window — understood, ranked, geolocated, one keystroke from a physically-modeled darkroom. It is also, at this moment, 182,688 lines — and about to lose more than a third of them in a single night.

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.

Act II · August 2026

The turn

“Azimuth does not work well and nobody uses it daily.”

— the owner's own correction to the document that was arguing for the rewrite
Chapter 10 / August 2026 / the measurement that settles it

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.

EVERY ROW IN THE CATALOG · measured on the live library, 2026-08-16
derived — indexes, backlogs, ledgers, cursors, scan state, FTS shadows, presence tables
recomputable from the photos themselves 99.94% irreplaceable decisions 0.06%
develop settings79,487
stars6,165
comparison pairs2,532
develop presets994
quality marks89
keywords77
flags32
trashings2
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.

— docs/CORE.md, “the measurement that settles the argument”

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.

Chapter 11 / Aug 14, 2026 / G0 → G5 · one night

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 order, logged verbatim, 2026-08-14

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:

G0
The offline service worker d8aa7b8f

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

G1
The sharing quartet 412414d9 · −9,376

Publish, share, shared, publishing — four overlapping systems for handing someone a photo.

G2
The hub-sync machinery e8c982a1 · −17,052

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.

G3
The AI derivation fleet 10e29ebd · −4,308

All the encoder plumbing goes; cached query vectors still rank semantically, and lexical fusion carries fresh queries.

G4
The tests — a verdict, not a cut 9670f11f

The surviving tests are the load-bearing behaviour net. Machinery tests had already died with their machinery; only orphaned scaffolding went.

G5
One assembly, then the orphans 18c04e7c · 05ba3aef

app_factory merged into app.py — one file you read top to bottom: create, mount, arm.

One night's work182,688148,348−34,340 lines · G0 through G5
Two days later148,348105,632the core rewrite · 42% of peak

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:

“Log the three deletions that were wrong and why” d554627b

Client galleries and per-face correction came back — each with the bound they'd never had in the first place.

“Restore 285 behavioural tests the carve took with the dead code” 6951d52a

The sweep had been keyed on callers, and behaviour tests have no callers.

“The drawer was never broken; my probe was” c942f450

Followed immediately by “Name the real cause: the drawer repaints every couple of seconds.”

“A preview stuck for half an hour, and it was my rm -rf” 126b38b7

The investigation found the investigator.

“Withdraw the three-second route claim; it was not the route” b668d7ad

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.

Chapter 12 / Aug 15–16, 2026 / Azimuth 2.0 · ~3,500 lines

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:

Five tables
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)
Seven functions
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.

— docs/CORE.md, § Five tables

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.

pythonweb/work.pyowed is a query · 1bfa9ad1
# 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.

Grid page180 ms0.22 msee9dc375 · 86 indexes → 5
Status counts188 ms8.3 mscomputed, not stored
Semantic search19,538 ms50 ms832b4195 · vectors keyed on bytes
Tile generation~20× faster5a6ce0f6 · six lanes over one query
thumbnails/9,160 linesone loop6fc7e31c · three workers become one
The working core~3,500 linesproven on the live catalog
◈ a table created once by hand is a table that does not exist

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.

Chapter 13 / the payload / 7da64850 · “How it stays small”

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.

◈ the trap this log had to avoid too

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.

Chapter 14 / the study / what elegance actually means

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.

✕ more refined

Knuth — 10+ pages of literate WEB/Pascal, custom data structure, fully documented and admired

✓ more elegant

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.

predictorcorrelation with repairscontrolling for the other
coupling — how often unrelated work drags the file in0.710.56 · survives
size — how many lines it has0.550.19 · mostly evaporates
Coupling clears the bar. Size nearly fails it, because most of the size signal was coupling wearing a disguise. Limits: one codebase, repairs inferred from commit subjects, surviving files only, change-coupling rather than static dependency. A replication in a new setting, not a proof.

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.

◈ the honest negative

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.

How both can be right

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.

Chapter 15 / the practical one / what I'd hand a colleague

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 developmentchange
Duplicated code blocks (GitClear, 623M changes)+81%
Copy/pasted lines9.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
In 2024, copy/pasted lines exceeded moved lines for the first time in GitClear's measured history — the moment codebases stopped being reorganised and started being duplicated. Caveat the research states itself: vendor data, not peer-reviewed, correlational. The METR trial is a randomised controlled trial of 16 experienced developers across 246 real issues, and the participants believed they were faster.

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 pattern
Why it happens
What stops it
The bolt-on
Asked to handle a new case, a model adds a flag, a branch or a parameter. It's the smallest change that closes the ticket, and closing the ticket is what it's optimising for.
Ask for the shape, not the fix: “this should be expressible in what already exists. If it isn't, tell me which primitive is missing.” That question sometimes comes back with a better design than I had.
Pattern-matching the mess
Models read your codebase for style. If there are nine facades, the tenth is nearly certain. Slop compounds, and the more you have the faster it accumulates.
Make it search for the thing before writing it, by concept rather than by name. Most-skipped step, most expensive to skip, because a duplicate primitive is worse than either copy alone.
Defensive padding
Try/except around everything, null checks on values that can't be null, branches "just in case". Each one is locally reasonable and none can be removed later without fear. Industry-wide this is the +47% above.
Treat a guard as a design smell rather than a safety feature. Ask for the version of the operation that needs no check. It usually exists and is shorter.
Ceremony tests
Ask for tests and you get tests. A lot of them assert that the mock was called, which proves the harness works and nothing else.
A test earns its place by holding a property that already broke. I deleted 4,000 lines that asserted their own mocks and the suite got more useful.
Confident narration
"Verified, all tests pass." Sometimes true. The report reads identically either way, which is the actual problem.
Re-run the acceptance command yourself. Treat the summary as a claim to check, never a result to accept.
Silent accumulation
Nothing inside a single lane can feel the codebase getting heavier. Every change is locally justified. There is no local signal for global weight.
Keep a number that only moves one way, and make raising it an explicit decision. Weight has to be measured from outside the loop, because inside it everything looks fine.

The prompts that changed my results most

None of these are clever. They're mostly just refusing to accept the first working answer.

✕ gets you a bolt-on

“Add support for photos that live on a second drive.”

✓ gets you a design

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

✕ grows the codebase

“Fix the bug where thumbnails don't regenerate after an edit.”

✓ shrinks it

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

✕ gets you praise

“Review this diff.”

✓ gets you bugs

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

✕ produces volume

“Implement the import pipeline.”

✓ produces a design

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

What it all comes down to

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.

◆ How this log was made

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.

pythoncaptures/harness.pyboot a commit in isolation, then shoot it
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.

Chapter 16 / the ledger

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.

◆ Find your bearing

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.

From "which one is better?" to a darkroom that models the chemistry of film — then back down to a core you can hold in your head.

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.