Anchor

Film Taste Engine

A film rating app that anchors each rating to the user's own reference films instead of a drifting absolute scale, with a recommendation engine that learns the user's taste from those ratings.

Status
Live
Stack
  • FastAPI
  • PostgreSQL
  • React
  • NumPy

What it is

Star ratings drift. A 4.0 you gave three years ago rarely means what a 4.0 means to you today, and nothing on a normal rating site asks you to reconcile the two.

Anchor fixes that by anchoring every rating to films you are already sure of. You mark a few films as anchors, your definitive 5.0s and your definitive 3.5s. To rate a new film, you pick its half-star rating while looking at the anchors for each rating, so every pick is a comparison against your own references. Every rated film then sits in a row of posters for its rating, in an order you set by dragging, and nothing in that order ever moves unless you move it.

On top of your ratings, Anchor learns your taste. It ranks your watchlist by how much you are likely to love each film, and recommends films you have never added, each with a sentence explaining the pick in terms of films you rated. A new user can import a Letterboxd export and start with their ratings already in place.

The live demo opens a read-only account with 70 rated films, so you can look around without signing up.

Anchor’s Rated page in dark mode: the 5.0 row of the demo account, six posters in the order the user set, three of them marked as anchors

How it’s built

A React single-page app talks to a FastAPI backend over PostgreSQL. It all runs on one rented server: Caddy, a web server, serves the app over HTTPS, and one Docker image runs as two processes, a web process that answers requests and a background worker that does the slow work. PostgreSQL is the only datastore. It also holds the job queue, so a job is queued in the same transaction as the change that caused it, and no second database is needed to run and back up.

Anchor's architecture The browser loads the React app from Caddy, which also forwards API calls to the FastAPI web process. The web process and the background worker share one PostgreSQL database that holds both the data and the job queue. Both call TMDB for film data. Only the worker calls Claude. Browser React app Caddy HTTPS and static files Web process FastAPI Background worker retraining, recommendations PostgreSQL app data and the job queue TMDB film data and posters Claude reranking, taste summary
Both processes run from one image and share one database. Only the worker can call Claude.

The recommendation engine has two parts. A logistic-regression model, built from scratch in NumPy, learns which film features (genres, directors, cast, keywords) predict where you placed a film, and scores any film in the catalog, including ones nobody has rated. It retrains from scratch after every change to your ratings rather than patching itself, so it always summarizes the ratings as they stand. Claude then reranks the model’s shortlist, and each recommendation arrives with a one-line reason.

Anchor’s recommendations page in dark mode: three films the demo account has not seen, each with its poster, director, genres, and a sentence tying the pick to films the user rated highly

Claude only runs in the background worker, and a test enforces it: it boots the web process and asserts the module that calls Claude was never imported. So no page ever waits on a model. Every call is recorded in a spend ledger and checked against a monthly cap per user and one for the whole site, and hitting a cap falls back to cached results instead of breaking a page.

The backend has more than 600 tests. Every behavior test talks HTTP to the real app over its own throwaway PostgreSQL database, cloned from a migrated template so a fresh database costs one CREATE DATABASE. Background jobs run inline inside the test, so a flow that spans both processes is still one test. Nothing inside the engine is mocked; fakes stand only at the edges where Anchor calls outside services: TMDB, Claude, the email provider, and Letterboxd. Those tests and 15 Playwright browser journeys gate every deploy in GitHub Actions.

The hard parts

Removing the core mechanism after real use

Anchor was first built around pairwise comparisons. Placing a film meant answering “which is better?” against films already rated, one question at a time, until its position was found, and the rating was derived from that position. Importing a Letterboxd library parked its films in provisional groups that had to be sorted out the same way, one comparison at a time.

I shipped that, then used it on an imported library of a few hundred films. My decision record estimates that sorting it out needed thousands of answers before recommendations unlocked. Worse, most of those answers were forced: between two films of about the same standing I had no honest preference, so every such answer put noise into the one layer that has to stay honest. And the order was invisible while it was being built, so I could not see what my answers were doing.

So I replaced it. Your rating is now the half-star row you pick, and the order inside a row is whatever you drag it to on a visible wall of posters. Comparisons survived only as an optional way to teach the engine about a film. The hard part was that six other features stood on the old mechanism. Two modules and 82 tests were deleted, and one pull request in the change removed more than 11,000 lines. A database migration carried every existing rating across to the new shape, with ten tests pinning it.

The engine needed a new reading of the order too. Pairs of films in different rows train the model at full weight. Pairs inside a row are weighted by how far apart you put them, so neighbors train as near-equals. That lets a strict order stand in for judgments you cannot honestly make between neighbors.

The scorer, and the out-of-memory crash it caused

The model learns from pairs of films: for each pair, which one you ranked higher. Before the redesign, a Letterboxd import put hundreds of films into ten provisional groups, and every pair inside and across them became a training row. For one real library that was roughly 80,000 rows against a few thousand features. Stored as one row per pair, that matrix is over a gigabyte, and it killed the worker on the live server.

The fix reformulated the fit so it only ever holds a film-by-feature matrix, never a pair-by-feature one, and capped how many opponents each film is sampled against. The regression test uses Python’s tracemalloc to assert that peak memory during a fit stays under a bound computed from the number of films, features, and pairs, so the crash cannot come back quietly.

Building the model from scratch also meant owning its edge cases. The model reads the difference between two films’ features and has no intercept, because a bias term would mean “the first film wins by default”, which says nothing about taste. Its step size is measured from the data on each fit, and a library whose films differ only in genre makes every pair read as identical to that measurement, which would hand the fit a step a thousand times too long, so that case has a named test too.

Three silent failures between “tested” and “working in production”

The recommendation engine shipped with its tests green, and on the live site it recommended nothing. It took four tickets to make it work, each fix uncovering the next.

  1. The Claude API key was never passed to the production server, so every Claude call was skipped.
  2. With the key in place, Claude rejected every request that writes the user’s taste summary, because the answer schema used a keyword the API does not support. The server logged only the status code, so diagnosing it meant rebuilding the request by hand on the server, and making the next error readable became its own fix.
  3. After that deployed, the summary still did not generate: the model was spending its whole token budget thinking before it wrote any text.

All three hid behind a rule I had designed on purpose: a Claude call that cannot run is skipped, and the page serves what it has cached. That kept pages from breaking, and it also made a missing key, a malformed request, and a truncated answer look identical from outside: nothing to recommend yet. The tests could not see it either, because the fakes standing in for Claude never checked Claude’s rules. In the pull request’s words, “633 tests passed against a request shape the real API refuses”.

Each fix went after the class of failure as well as the instance. The fakes now check every request against the provider’s documented schema rules, and fail the test outright rather than answering with an error the skip rule would swallow. The provider’s own error message now reaches the logs. And the public health endpoint reports whether a key is configured and how much of each spending cap is used, so an operator can tell the three failures apart without a shell on the box.

What I’d do differently

Use the core mechanism on real data before building on it. The pairwise design took real use on a real library to fail, and by then six features depended on it. A rough version tried against my own Letterboxd export would have shown the cost before anything was built on it.

Make test doubles hold what production holds. Twice, a fake passed what the real service would not: the Claude fake accepted a schema the API rejects, and the demo’s build passed in CI against a fake film catalog made from the demo’s own films, then failed in production on a same-named short film the fake catalog did not include. I would check fakes against the real contract from the first test, and seed them with the messy cases, not just the expected ones.

Measure recommendation quality, not only correctness. I built the SQL queries that would tell me whether recommendations are working: how often a recommended film gets added, how often it gets dismissed. I haven’t run them against production data yet, so I can say the engine works but not yet how well.