Project logSep 7 to Sep 13, 2026Edwin Knuth

Clip Portal, a frame-sampling and provenance experiment over a public film archive

Search over a large video catalog is a sampling and provenance problem before it is a model problem. This one embeds only the frames the content says matter, the scene cuts plus the moments the narrator points at something, and shows the exact frame behind every result so a reader can check the claim instead of taking it. Guided sampling picks about ten times fewer frames than one frame a second, and whether it still finds the same clips is the question the eval answers.

clip-portal.eknuth.deveknuth.devRepo goes public at P13

The corpus at a glance

films
unknown
hours of footage
unknown
frames sampled
unknown
frames embedded
unknown

The log

I write an entry when an issue merges: what shipped, the numbers it produced, and what the review turned up. The entries are markdown files in the repo, next to the code.

Sep 13, morning
landed
PR #42
health on track
built Claude Opus 5
read Claude Fable 5.1

The write-up that says what two stores cost

P33 wrote the index a second time as a Lance dataset on R2 and P34 served search from it. This issue is the post about what that showed, the second write-up after the deictic sampler one and built the same way: the table first, then the three findings, then how it was measured, then what it does not show.

The table and the figure are generated rather than retyped. evals/post_table.py prints the by-store table out of evals/results.json in the eight columns the post uses, and a test holds the committed snapshot against what the script prints today, so an eval rerun that moves a number fails the suite instead of leaving the post a version behind. evals/stores_figure.py writes three SVGs of the same numbers, light, dark, and a dark one with no ground for a page that paints its own plate, and web/scripts/export-figure.mjs rasterises them at the write-up's column width with the site's three faces loaded in Chromium. make figure-stores runs the pair.

The review found four places where a number described less than the row it sat on. The figure's footer named a runner and a site sha for a document that folds three passes. The percentiles are over the 100 ground-truth cells, which are the only ones the route timed, and the caption said 127. dispatched_at arrived after the pgvector passes ran, so their cold column can only ever hold a retry and now reads "not recorded" rather than "no cold cell". And a tick inside a bar read as a median mark, so the first one carries a label.

what the post leads withpgvectorlance
guided, knn p50 through the route98 ms776 ms
guided, recall@10 on the tuned rows0.230.23
uniform, knn p95 warm1,687 ms1,668 ms
the first query of a container's life11,561 ms

The second store returns the first store's answers and charges for them somewhere else. The argument the post makes is that the sampling policy decides how many vectors there are, the store decides what a query over them costs, and the two are measured apart.

The post is at https://eknuth.dev/writing/two-stores-one-eval/. The lessons from the database issues are in it as store findings rather than as history: the statement timeout that was never in effect, the cold database that failed the first search, the plan cache that switched at the sixth query, and the guided path that never touched the graph it was walking. Each one carries its number and the span or the query that found it.

Nothing about the request path changed. docs/cost.md is untouched, because the default store did not change: pgvector still answers every search the page makes, and Lance is still the thing on the end of ?store=lance.

Sep 13, morning
landed
PR #43
health on track
built Claude Opus 5
read Claude Fable 5.1

The budget was sized from its own ceiling, and the traces said so

P23 moved the search statement timeout from eight seconds to fifteen. The number behind that move was one measurement, 8.5 seconds, taken with a stub vector of zeros and no policy filter, on a path that was never the slow one. Ninety seconds after the deploy two searches were cancelled at 15,125 and 15,131 ms. This issue is the write-up of what happened next, published at eknuth.dev/writing/the-budget-that-measured-itself.

The argument is that a timeout is an instrument that reports its own setting. Every failure in those two days that ran past a second sits at whatever ceiling was deployed: 8,119 to 8,213 ms on build 4208685, 15,125 and 15,131 on b26a6fa. Eleven shorter cancellations on 4208685 are a leaked timeout from an earlier caller, which 002 leaves open as a mechanism. Read as durations they look like news about a slower query. Read as what they are, they are the same query cut at a different number. The tail is censored at the ceiling, and a censored tail looks like a fixed cost, which invites a fix aimed at the wrong thing.

Two plates carry it, both drawn by tools/budget-figure.py in the eknuth.dev drafting identity. The lead figure is every search.knn span on the builds either side of the ceiling change, 231 of them, one mark per span, cancellations in their own shape at the duration each was cut at. The band between the slowest answered span and the ceiling is drawn empty on purpose. It reads a span dump checked in under the site's tools/data/, which carries the query and the window it was pulled with, so a rerun regenerates the plate from the same rows.

build b26a6fa, 210 spans, 21:05 to 21:22 UTCms
p501,249
p902,204
p998,896
slowest answered span8,933
answered between 8,933 and 15,000none
cancelled15,125 and 15,131

The inline figure is trace dd1502a08c47641cfe64d58764538406 on build b5ec1d5, span by span: root 10,227 ms, the tower health probe 263, search.embed 1,051, search.knn 7,983, search.why 652, search.render 0. The children account for 9,949 of the root's 10,227 and the gaps between them hold the other 278, of which 162 sit either side of the database span. So the time is inside query execution, and the plate draws the same function beside it at the same scale, measured from a script on Neon's pooled endpoint without Hyperdrive in the path: 46 ms on uniform and 405 to 434 warm on guided. That span carries search.path and no policy, because the attribute naming the policy arrived on a later build; the seven requests in the seventy seconds after it read 407 to 542 ms, which is guided's warm band, and the post marks that as an inference from timing.

The post states what P25, P29 and P30 actually found against the hypothesis P25 was filed on. The plan-cache half held: Postgres 18 caches a SQL function's plan per backend session, an unknown limit and the policy filter's unknown selectivity each push the generic plan off the graph, and Hyperdrive reusing a session across requests is what lets a sixth execution happen at all. The guided half did not: guided had no switch to make, and its row estimate was right, 24,982 against an actual 24,738. The price was wrong, because a vector(1152) lives in TOAST and a sequential scan is costed on a 20 MB heap while the 1,322 MB it detoasts costs nothing. The 40 second reading is explained rather than dropped: it is the same plan-cache transition on a compute with nothing resident, whose generic plan reads 173,340 blocks off disk against 294,408 kB of shared buffers, caught at 41,367 ms on the fifth warm reading, which is that session's sixth execution, and at 48 ms with plan_cache_mode = force_custom_plan in place. P25's three readings above 30 seconds all landed within a minute of a postmaster restart; the 41,367 run records no compute state, so the post keeps it beside that set rather than in it.

The site now carries three field notes. Nothing in this repo changed except the log, the plan row and the two pinned date ranges in the log tests.

Sep 13, late
landed
PR #45
health on track
built Claude Sonnet 5
read Claude Fable 5.1

A vendor column on the free tier, over the 42 films it could hold

Every path on this site runs on one image model, SigLIP, over frames this project sampled. This issue adds a column from a commercial video embedding model, Marengo 3.0, through its vendor's free tier: ten hours of video against a corpus of 66.4. The films were chosen by the ground truth, in byte order of the archive id, until the ten hours ran out. 42 films went in, 5,456 segments of about six seconds each, and the vendor's meter closed at 9 hours 51 minutes. One film was refused for being 320 by 240, under the vendor's floor, and the last four of the plan were refused when the allowance ran out. The eval scores the vendor path over the 42 queries whose film it holds and prints the rest as not covered rather than as misses.

pathqueriesrecall@1recall@10film in top 10MRRmedian seek error s
marengothe 42 covered0.480.570.980.526.5
frames/uniformthe same 420.290.430.86
frames/nonethe same 420.260.400.86
frames/guidedthe same 420.210.210.69
keywordthe same 420.170.190.31

A vendor result is a segment, so the card says segment 4:24 to 4:30 and seeks to its start. The vendor's text embedding answers in about 220 ms and the nearest neighbour step over 5,456 rows in 17 ms. Its floor is 0.0749, calibrated from the same twenty negatives as the others: five of sixteen tuned negatives turned away at no cost to a hit.

The column is not a fair fight and the entry says so. The vendor's index holds 42 films, so its search runs over a corpus seven times smaller than the one the SigLIP paths search, and the right film sits in its top ten for 98 of every 100 covered queries where uniform's does for 86. The number that survives that is the seek: of the films each path finds, the vendor lands within ten seconds of the moment on 24 of 41 and uniform on 18 of 36, and the vendor's median seek error is 6.5 seconds against uniform's 8.5 over those same 42 films. Uniform's 10.8 in evals/results.md is its median over all 100 queries, not over the covered slice. What the corpus would cost at the vendor's rate is in the decision record: $2.50 an hour of indexing, so $166 for 66.4 hours, against the two cents a month the Lance dataset costs to keep.

The vendor path is a query parameter, path=marengo, and the MCP tool takes it too. The home page has no control for it. A result carries its span, the tool echoes the coverage next to every answer, and a missing key or a spent daily budget is a notice with its own sentence rather than a failure that looks like the tower's.

The review caught the column presenting itself as more than it is. The page labelled a vendor search "hybrid" because its label table had no row for it. A floor left null made every negative read as cleared, and the eval table would have printed the vendor rejecting nothing as if that were a measurement; those rows now read "not judged" until a floor exists, and one exists now. The card stamped a six second span as a matched frame with the span's end thrown away. The decision record quoted a per minute rate that belongs to the vendor's indexing product and explained the four refusals as a second ceiling when the hours had simply run out. All of it is fixed, and the deployed build is 2091c47.

Sep 12, late
landed
PR #37
health on track
built Claude Sonnet 5
read Claude Fable 5.1

The guided search gets a graph its own size

The guided policy samples a tenth of the frames uniform does, and the thesis of this piece is that its cost should be a tenth too. Until tonight its search cost more. The reason was in the database's own numbers: the nearest-neighbour index over every frame is two gigabytes against 230 megabytes of memory the database can hold pages in, and a guided search walked that whole graph with a filter on the joined table, passing nine uniform frames for every guided one before it had two hundred candidates. That is about 26,000 index pages per query, four times what an unfiltered search walks; cold from disk it took five seconds, warm it took 36 milliseconds, and the second-or-so figure the traces had been reading was the mix.

The fix gives guided a graph its own size. The policy list is mirrored onto the vector table, a trigger keeps the mirror right when a resample changes it, and three partial indexes exist for the three small policies: 193 megabytes for guided, 174 for scene cuts, 30 for the narrator's pointing frames. The query filters on the mirror, so the planner picks the small graph on its own.

beforeafter
index pages a guided search touchesabout 26,000926
nearest-neighbour step, p50 over forty searches1,029 ms51 ms
nearest-neighbour step, p951,692 ms183 ms
guided recall at ten, tuned rows0.230.23

A guided search now costs what its frame count says it should, and returns the same frames it did. The migration ran on the live database at midnight with search paused for ten minutes.

The review earned its place twice. The migration as first written filled the new column with the big index still standing, and every row it touched was inserted into the graph again with a full walk: fifteen minutes per hundred thousand rows on the Mac with the index in memory, most of an hour of blocked search live, plus a half-hour vacuum. Dropping the indexes first and rebuilding them in the same transaction made the fill a matter of seconds and the outage the rebuild itself. The second pass found that a rebuild inside the transaction that did the fill indexes every row twice, which is why the live run took ten minutes and not eight, and that the runner printed nothing for the whole of it; it now says which path it is on in the first second. The unfiltered search also got faster, because the full index came back rebuilt from scratch; the docs say that plainly rather than crediting the partial index with all of it.

What this does not fix is also written down. Uniform and the any-policy search still walk the two-gigabyte graph, and the next step there is a bigger compute or a smaller graph.

Sep 12, morning
landed
PR #38
health on track
built Claude Opus 5
read Claude Fable 5.1

The first thumbnail stops waiting for the whole sheet

The slowest number on this site was 49 seconds: the time to the first thumbnail on a throttled connection. A result card's thumbnail is a crop out of its film's whole sprite sheet, one JPEG of every frame at two-second intervals, and twenty-four cards start twenty-four sheets of up to 2.3 MB at once. On a slow link the first to finish is most of a minute in. The matched frame is one tile of about 5 KB, and the card knows which tile before it asks for anything.

Two ways to get that tile, and the measurement picked. Cropping it out of the sheet on the edge means decoding the whole sheet, and the longest one is 64 MB of pixels before the decoder's own buffers; it needed 194 MB against the 128 MB an edge isolate has. Half the corpus could not be cropped there on any plan. Writing every tile as its own file costs 54 cents once and under a cent a month, and no compute per request. So the tiles are files, 119,262 of them beside the sheets, cut by a script that reads each sheet, splits it, and uploads with a resume marker written only after every other tile of the film has landed.

beforeafter
bytes a card needs before its thumbnail can paintits film's whole sheet, 0.7 to 2.3 MBone tile, about 5 KB
bytes for a page of twenty-four11 to 19 MBabout 115 KB, then the sheets behind
first thumbnail on a throttled link49,374 ms3,584 ms

A card paints its matched frame from the small file first, asks for the sheet only once that has arrived, and swaps to the sheet's crop when it lands, with no shift and no flicker. Scrubbing still waits for the sheet. A film whose tiles are not there yet renders exactly as it did before.

The review earned its place on the fallback. A tile that does not exist yet, which is every tile until the upload runs, left an image element mounted, and Chromium draws its broken-image glyph over such an element; every card would have worn one for the whole sheet wait. The card now drops the element on error and the fallback is byte-identical to the old card. The upload script's resume marker, the film's last tile, could be written by one worker while another worker's tile had failed, and the rerun would skip the film with a hole in it that the card would silently paper over with the sheet. The last tile now waits for the rest. The measurement script took any response as a first tile, including a 404's; it counts only a 200 now. And the media route forbade caching, so the sheet was sometimes fetched twice per card; sheets and tiles are immutable now and say so.

The after column was measured on the deployed build with every tile in the bucket, three throttled loads: the first tile at 3,565, 3,584, and 3,855 ms, the first whole sheet at 50,256, 50,322, and 58,236 ms. The sheets take as long as they always did; the thumbnail no longer waits for one. The first measurement script recorded nothing three times and reported a pass, because the run before it had tripped the site's own rate limit and the throttled page it loaded was an error page with no cards; the script now fails when a run measures nothing, and a test feeds it those three runs to prove it.

This issue changed the card and one header on the media route. The search spans are the previous entry's.

Sep 12, late
landed
PR #35
health on track
built Claude Sonnet 5
read Claude Fable 5.1

The last four percent of the corpus gets its vectors

Thirteen films, about four percent of the corpus, had never had a vector for any frame, and nothing had recorded it: the embedding run marked them failed with an ffmpeg message about colour range and moved on. The issue that filed this diagnosed a pixel format problem. It was not one. The 13 report the same pixel format as every film that worked.

What fails is the last sample. A video stream's duration is its last frame's time plus one frame period, so the whole second at the floor of the duration sits after the last frame whenever the fractional part of the duration is under one frame period. All 13 have it between 0.005 and 0.048 seconds, an accurate seek there decodes nothing, and the JPEG encoder opening on zero frames prints the misleading message. One unextractable frame fails the whole film. The fix is a guard in the sampler: no policy emits a time later than the duration less a quarter second, derived from the worst measured gap of 0.130 seconds with margin. The films were resampled and embedded, 10,795 frames in 51 minutes, and frames without a vector reads zero for the first time since the index was built.

what the rerun of the eval saysframes, uniformframes, guidedframes, any policy
strict hits gained on the 13 films' own queries212
strict hits lost elsewhere101
film found on all five of their queriesyes4 of 5yes

Search for the airliner, the helicopter, the Pontiac, or the speech lesson, and the frame is there now. The index has every sampled frame of every film.

The review found the story wrong twice before it was right. The first cause written down blamed an audio track running past the video, which holds for five of the 13 and is the opposite on eight. The first guard of a tenth of a second was under one film's measured gap. And the eval note filed two real losses as noise: the helicopter film, newly vectored, took first place from a right answer about an airliner, and another of the 13 entered a top ten and pushed the right frame past tenth. The net for the tuned rows is one strict hit gained on each frames path. The measurements behind the guard are now a fixture file in the repository, so the numbers the docs cite can be re-derived.

This issue changed no request path. The spans on the deployed build are the previous entry's.

Sep 12, night
landed
PR #41
health on track
built Claude Opus 5
read Claude Fable 5.1

The second store answers searches, and the eval has a store column

P33 wrote the index a second time as a Lance dataset on R2. This issue serves search from it. A Worker cannot run Lance, so the text tower's container, which already holds the query vector the instant the embed finishes, gained one route that embeds and answers the nearest neighbour question itself, and the web app's search function takes a store parameter that moves nothing but which index answers. Grouping, the keyword leg, the floor, the why line and the render are the same code on both stores, which is what makes the eval's two columns comparable. The container reads the bucket with a token that can only read it.

The eval ran the three frame paths against the live route on the second store, 381 requests, and the fold puts both stores in one table.

pathstorerecall@10, tunedMRRseek error sknn p50knn p95route p50
uniformpgvector0.380.3010.8935 ms1,687 ms2,914 ms
uniformlance0.400.3210.5800 ms1,668 ms2,158 ms
guidedpgvector0.230.1835.498 ms194 ms1,828 ms
guidedlance0.230.1835.4776 ms1,141 ms2,171 ms
nonepgvector0.380.2910.5117 ms146 ms1,728 ms
nonelance0.390.3110.5664 ms988 ms1,998 ms

The second store returns the first store's answers: the same guided cells to the last digit, the same top result on 98 of 100 queries, and on two queries a better one, where the HNSW walk had returned a weaker frame of the right film and ranked it twelfth. What it costs is the query: 438 ms at the median inside the container and about 750 ms as the Worker sees it, against 98 ms for pgvector on the partial index P40 built. The container's first query of its life took 11.6 seconds, filling its index cache from object storage, and the three after it 4.5, 3.1 and 2.7 seconds.

The store column says what a second index costs, and it is not recall. Read from the deployed build: opening the dataset took 302 ms once, the first query 11,561 ms, and the median query inside the container 438 ms over 386 spans, with 950 ms at the ninety-fifth percentile. The Worker sees 660 to 840 ms at the median, and the difference is the round trip through the tower Worker to the container and back with two hundred rows of JSON, which the pgvector path does not pay. A Lance query costs about the same on every path, because a prefiltered IVF query reads the same partitions and the same re-rank rows whatever the filter keeps. A pgvector query costs what its index is: Lance wins uniform by 135 ms, where pgvector filters the full graph, and loses no policy by five and a half times and guided by eight, where P40 gave pgvector an index the size of the answer. A warm query is 38 range reads off R2, and the median says each one costs about 11 ms with the parallelism the reader gets.

The review found the number that would have hidden all of this: a missing distance column that defaulted every score to 1.0, so every search cleared every floor and the store column would have compared a real index against a constant. It found the object store's own error text, endpoint included, riding an exception chain into a span the sampler always exports, and a failed query that dropped the dataset handle and its warm cache with it, so one 5xx would have handed the next reader the eleven second first query. All three are fixed, and a test with an in-memory exporter asserts that no exported span holds either key or the account id.

Nothing about the page changed. A reader who wants the second store puts store=lance on the URL, which is what the eval does; the facet links drop it on purpose.

Sep 12, evening
landed
PR #39
health on track
built Claude Sonnet 5
read Claude Fable 5.1

The same index, written once, read from object storage

The index lives in pgvector on Neon, four gigabytes on a compute that sleeps. That store was a hosting decision on the first day, not a finding. The thesis of this piece is about which frames to embed, and a second store tests whether the index is portable: the same 264,626 frames, the same vectors, the same provenance, written once as a Lance dataset and read from object storage with no database awake at all. This issue writes it and records what it cost. Serving from it is the next issue; the write-up follows.

The dataset is one row per frame with twelve typed columns, nothing in it that is not already in Postgres, 1.53 gigabytes in the bucket beside the films it indexes. A check reads it back and compares it with Postgres: row counts, one stored query's top result, two hundred rows byte for byte, and recall on the hundred real text queries against exact brute force over the dataset's own vectors.

indexdataset bytesrecall@1recall@10p50 per query, local
product quantization, first build1.25 GB0.020.1154 ms
product quantization with re-ranking1.25 GB0.440.3360 ms
scalar quantization, shipped1.53 GB0.830.923.7 ms
scalar quantization with re-ranking, as queried1.53 GB1.000.993.9 ms
flat, a second copy of the vectors2.44 GB0.990.9917.5 ms

The whole index sits in the bucket for about two cents a month, and a query from the Mac against it returns pgvector's answer, 0.99 of the time at rank one, in 457 ms at the median and 2 seconds at the ninety-fifth percentile. The same query is 4 ms on local disk. That gap, the cost of reading pages from object storage, is the number the next issue measures from the container.

The review found the recall number twice. The first build reported 0.99, and the reviewer showed it was a self-query artifact: the sample queries were stored rows, and any index finds a vector's own row. On the real text queries the product-quantized index found the right frame first 3 times in 100, because the gap between a text query's top two matches is about 0.0025 in cosine and twelve floats a sub-vector cannot rank at that resolution. Scalar quantization kept the right answer among the candidates, and the second pass found the knob that turns that into rank one: re-ranking the candidates against the exact vectors, which costs a third of a millisecond. Re-ranking does not rescue the smaller index, because the right answer is not among its candidates to begin with. That is the finding for the write-up: the index type sets what the candidates are, re-ranking sets how they are ordered, and the smallest index is the one that does not work.

Two smaller things the review caught are the kind a later reader would have hit first. A policy filter combined with a nearest-neighbour query returned nothing at the library's defaults, because the filter ran after the top ten rather than before; it runs through the label index first now. And the check spent eight minutes of database time on a question a primary-key read answers exactly in a third of a second; it reads by key now.

This issue changed no request path. The search spans on the deployed build are the previous entries'.

Sep 12, late
landed
PR #36
health on track
built Claude Opus 5
read Claude Fable 5.1

The evaluate view, in every state a link can land in

The evaluate view is the licensing argument: the same page over a de-identified imaging set is a hospital's decision, and it is judged by being opened from a link somebody else sent, in whatever state that link lands in. Nobody had walked every state from a cold browser since it was built. This issue did, in code, and wrote the table: twenty-seven states, the condition or the URL that produces each, what the page showed before, what it shows now.

Nine of them were dead ends. The worst was the manifest export on a paused index, which took the reader off the page to a screen of raw JSON saying the index was paused. Now the click is caught, the page prints the same sentence the home page prints, with the date the period resets, and nothing is saved. A set of one film said nothing in it was a near-duplicate without having compared anything; it now says a pair needs two films and this set holds one. A set past the 500-film limit showed the server's error and nothing else; it names the count, the limit, and how many to take out. A database timeout after numbers were on screen was silent; the reason prints above the numbers it could not refresh, with a retry that works. Every empty histogram says what would fill it.

Open a shared evaluate link in any state, a paused index, a set of one, a database that did not answer, a phone, a keyboard, and the page says what it is and what to do next. Nothing on it goes blank, spins forever, or drops the reader on a JSON file.

The review's twelve findings were the kind this page exists to catch. The state table named share URLs that had never been minted, so a reader clicking them landed in the "could not be found" state. No test actually clicked a retry, only rendered the button. The explanation for the narrow-viewport fix blamed a grid track that the stylesheet had already sized correctly, when the real cause was one unbroken 64-character archive id in a link, and the fix was letting it wrap. And the branch's own "reading your set" line, shown before the browser had read the set, never cleared with scripting off, which is the exact dead end the issue forbids. All fixed, with a test each, and the keyboard help line now names only the controls that are always on the page.

This issue changed no request path. The tower is never asked anything from this page, and a test holds that.

Sep 12, night
landed
PR #31
health on track
built Claude Opus 5
read Claude Fable 5.1

A query with no answer says so

Search "ninja" on this site and, until tonight, ten cards came back, each with a frame, a second, and a seek. There are no ninjas in 300 educational films. A nearest-neighbour search always returns its nearest neighbours, and a tenth-nearest neighbour of a query with no answer was drawn exactly like a real match. This was the one place the piece argued provenance and then did not deliver it: the frame was shown so a reader could check the claim, and there was no claim.

The instrument is a floor on the best score in the list, read from one file by the search, the page, the MCP tool, and the eval. It is calibrated on twenty no-answer queries written by reading the corpus and deciding a thing is not in it, never by looking at what the index returns, split into tuned and held-out rows the way the hundred positive queries are. The rule is the most negatives a floor can turn away without costing a single tuned query its answer.

pathfloornegatives turned away, tuned of 16held out of 4answers lost, tunedheld out
keywordnone needed, an empty list is the answer16400
frames, every policy (the page's default)0.138111201
frames, uniform0.138111201
frames, guided0.143414300

The one held-out answer it costs is "teenage girl with schoolbooks", which scores 0.1255 on the uniform path and now reads as no match; held-out recall at ten on that path goes from 0.45 to 0.40. Seven control queries the corpus does answer, a rocket launch, a surgeon operating, a classroom, are a committed file the eval runs, and all seven clear on every frames path. "ninja" and a keyboard mash do not clear on any.

Search "ninja" and the page says "No match in this corpus", names the nearest score and the floor it missed, and shows the ten frames anyway on a dashed rule with their scores, because the index did return them and the reader can see how close they came. No red, no error word.

Said plainly, because it is the limit: on the page's default path seven of the twenty negatives still come back as an ordinary grid. They are the hard ones, a cash machine in a bank wall, a skateboarder in a concrete bowl, a cyclist in a hard helmet, modern objects in scenes the corpus is full of, and a floor that costs no real answer cannot reach them. The floor for the hybrid path is a judgement on the two legs before they are fused, since the fused score carries rank and nothing about match quality; a frame query always clears, because its source frame scores 1.0 against itself.

The review's two passes found twenty-four things. The one that mattered most: the page's default search runs over every policy's frames at once, and no negative had been measured on that union, so its floor was borrowed from the uniform policy as a lower bound. Measuring it took 120 requests after the daily allowance reset, and the union's floor came out at the same number, because the union's nearest neighbour is almost always a uniform frame; it is a measurement now rather than a guess that happened to be safe. The second pass found the seven controls asserted in the notes and recorded in no file, which is the kind of claim this project exists to avoid; they are a file the runner drives now.

Sep 11, night
landed
PR #33
health at risk
built Claude Opus 5
read Claude Fable 5.1

Three pictures of it

A reader who judges the piece in ten seconds gets more from three pictures than from the paragraphs above them: what runs where, how a film becomes vectors, and what happens between typing a query and the player stopping on a frame. This issue draws them. They are built from the repository as it is, not from a plan, and every box and arrow points at a file or a binding; the list is on the issue so a reviewer can check it.

The drawings are inline on this page, painted with the site's own colour tokens and set in its own three faces, so they follow the reader's theme and never load a font of their own. Component types are told apart by two weights of ink, the hairline, and the mono tag, and each drawing spends its one red on the one thing that matters in it: the budget checker's hand on the database, the thirteen films the index has no vectors for, and the player stopping on the matched frame. Standalone light and dark exports and three PNGs sit in the repository for reuse.

The log page now opens its "How it is built" section with the three drawings, each in its own scrolling frame with the numbers under it: films, hours, frames per policy, and the latest search percentiles.

The review found fifteen things, one of them blocking. At the column width every label in every drawing came out at four to seven pixels, because the drawing tool sets type in drawing units and the drawing shrinks to its column; the fix was to inline the drawings and give each a minimum width that keeps its smallest label at eleven pixels, which means the system and pipeline drawings scroll inside their frames on a desktop rather than fit. The count cards the drawings carried in the tool's viewer, the per-policy frame counts among them, were dropped by the export and reached no page; they are now under each figure. And the query sequence had three facts wrong that the code contradicted: the sprite index is read through the Worker's storage binding, the video goes through the Worker's range route and never straight to storage, and the two legs of a hybrid search run side by side, not one after the other. The drawing now shows the frames path and the caption says how hybrid differs.

Lighthouse on this page as deployed, mobile then desktop: performance 86 on a first run whose first paint came late, 98 on a second run a minute later, and 97; accessibility 100 on all three. The page carries about 190 KB more markup for the three drawings, on a document that already ran to a megabyte uncompressed for twenty-six entries, and 111 KB over the wire.

Sep 11, midday
landed
PR #27
health at risk
built Claude Sonnet 5
read Claude Fable 5.1

The deictic sampler seeks to the word, not the sentence

The deictic sampler takes a frame where the narrator points: "here", "this", "look at". Until this issue it took that frame at the start of the sentence the word was in. A narrator usually runs a sentence or more before landing on the pointing word, so the frame the index stored was often a second or more before the thing being pointed at, and sometimes ten. The write-up's own figure had two hits on one film that were each about six seconds early for this reason.

The fix is small and the measurement is the point. Whisper gives a time for every word, not only every segment. The transcript rows now keep those word times, the sampler finds the matched word in that list, and the frame is taken at the word's own start. Measured over the 4,335 pointing segments in the 296 narrated films, against the stored segment shape:

rulematched segmentsmedian lagp90over 2 sover 4 smax
segment start4,3351.18 s4.40 s34%13%9.62 s
word start4,3350 s

The second row is zero by construction, which is a check that the code does what it says rather than a finding. The two early hits on the write-up's film move from 122.76 s to 129.18 s and from 151.42 s to 157.68 s, onto the frames the narrator meant.

The corpus was re-sampled and re-embedded under the new rule. The frame counts do not move, because the fix changes where a deictic frame is and not how many there are:

policyframes
uniform239,124
scenecut22,257
guided24,738
deictic3,881

The embed wrote 3,623 new vectors over 230 films in an hour and 48 minutes on the Mac, the moved deictic frames and the guided frames that fold them in. It also surfaced something no earlier run had recorded: 13 films, about four percent of the corpus, have never had a vector for any frame, because ffmpeg refuses to write their frames as JPEG. That is its own issue now and the count of frames without a vector, 10,807, is the number it has to bring to zero.

A search for a pointed-at thing seeks to the frame the narrator was pointing at, not to the start of the sentence that led up to it. The keyword ranking is the same as it was.

The review found that keeping the word times inside the transcript row had a side effect the implementer did not see: the keyword index was built from the whole row, so every word in it appeared twice, once in the segment text and once in the word list, and the keyword ranking moved by 14 to 70 percent per film. The index is now built from the segment text alone through a function the database can treat as immutable, and six probe queries reproduced the old ranking exactly before and after the migration. The film page and the set statistics query stopped pulling the word list, which made the row six times larger than they need.

Read back from the traces on the build that shipped this entry (8e3d2f9), forty distinct searches, one every 2.5 s, all on a warm tower, half on the frames path and half hybrid:

spanp50p95n
GET /api/search2,990 ms3,811 ms38
search.embed1,433 ms1,582 ms38
search.knn1,029 ms1,692 ms38
search.why389 ms544 ms38

Against the previous entry's set, embed is down about 150 ms at the median and 590 at p95, which is a warm tower against a half-warm one, and why is down about 160 ms at the median, which puts the previous entry's rise back where it was. The route's own time outside its children is 139 ms at the median. The one span that moved the wrong way is knn: up about 290 ms at the median and 770 ms at p95. This issue did not touch the query. The front end measurement that ran the same hour on the previous build, after the reindex had finished, read knn at 965 ms at the median, so the move sits with the state of the database after the reindex and not with this build. It is filed as its own issue rather than folded into the next one.

Sep 11, evening
landed
PR #30
health at risk
built Claude Opus 5
read Claude Fable 5.1

Click a frame, get the frames like it

Every search until now started from typed text. The index is a joint space, so a frame is as good a query as a sentence, and the frame vectors are already stored. This issue lets a reader click any result card, or any mark on a film's scrub strip, and ask for the frames like it. A licensing reader does exactly this: they have a shot, they want the rest of the shots like it, and they do not want to describe it in words.

It costs no model call. The query vector is read from the row the tile came from, one select on the same connection the nearest-neighbour search uses, and the search runs the same way a text query does. The text tower is never asked. The URL carries the frame, so the query is shareable and the back button returns to the text search. The count line says "frames like <title> at 2:12" rather than echoing a query that does not exist, and the source frame's own tile sits where the typed words would be, so a reader can see what was searched with.

A frame in the search field's place, with the count line under it naming the film and the second it came from. On the film page the strip's link follows the playhead until a mark is chosen, so "more like frame 0:42" is always the frame on screen.

The review found twelve things, none of them blocking, and three worth fixing before the pull request was ready. A frame search page still showed the links to the keyword and hybrid paths, which cannot take a frame and answered with an error. A vector lookup that had to retry on a fresh database connection and then found no vector released the wrong connection and leaked the other; a test now ends both. And the reference parser accepted a second of 1e40 and a twenty-digit film id, which Postgres rejected as a server error for what was a hand-edited URL; both are now a bad request. A second pass traced every connection path and every parser input and found nothing further.

The cost is stated plainly: a page click runs three statements on three connections, the catalog counts, the source tile, and the vector read, where a text query runs two. The counts and the tile overlap in the same paint. The one number this issue owes, the latency of a frame query beside the text query's, was read from the traces just after midnight UTC, twenty frame queries at 2.5 s on the deployed build:

spanp50p95n
the frame query, whole request2,045 ms3,161 ms20
reading the stored vector36 ms39 ms20
nearest neighbours1,193 ms1,923 ms20

Beside the text query measured earlier in the day (2,635 ms at the median in the browser, 1,399 ms of it the embedding), a frame query saves the embedding and pays 36 ms to read a vector, so it comes back about 900 ms sooner. The nearest-neighbour step on this set is the same slow one the reindex left behind, and it has its own issue.

Sep 11, morning
landed
PR #26
health at risk
built Claude Opus 5
read Claude Fable 5.1

A ceiling on what this costs

Neither provider this runs on sells a hard cap on dollars. Neon sends an email at a threshold and keeps running. Cloudflare has no account budget for Workers Paid at all. Both will tell you. Neither will stop. So this issue built the thing that stops.

It is one small Worker on an hourly cron. Each run reads what the current billing period has cost at Neon, prices it from rates held in config with a date on them, adds the Cloudflare side, and writes the total to a shared key-value store. Under 80 percent of the ceiling it records and returns. At 80 percent it records a warning. At 100 percent it writes a pause flag first, then clamps the database compute to its floor and puts it to sleep. The order matters: suspending first accomplishes nothing, because the next query wakes the compute straight back up. What stops the spend is the site declining to query, and the flag is what the site reads.

The ceiling is $25 a month across both providers. The first real reading, an hour after the key was set:

line, period Sep 8 to Oct 1at 06:00 UTC
Neon compute, 12.22 CU-hours$1.26
Neon storage and restore$0.08
Workers base$5.00
R2, 25.1 GB$0.23
container, modeled$0.00
total$6.57 of $25

The container line is modeled, not measured. The text tower records each time its container starts, and the checker charges one start as eleven minutes of a standard-2: the ten it stays awake plus a minute for the work that woke it. Cloudflare's analytics API does expose the real meter and the checker reads it when an account token is set; until then the status document says which of the two produced the number.

The checker fails open. A Neon error or a run that throws stamps the status stale and pauses nothing, because the standing caps already bound the damage: at most 2 CU, asleep after five minutes, one container instance. An hour of unnoticed runaway is about twenty cents. A checker that paused the demo every time an API had a bad minute would cost more than it saves. The first run did exactly this: the key was scoped to the project and the consumption endpoint belongs to the organization, so it answered 404, the run stamped stale, and the site stayed up.

When the ceiling is hit, a search answers one plain sentence with the date the period resets, not a 500 and not an empty grid. The evaluate view and the log page say the same words. An agent on the MCP server is told the date rather than told to retry. Nothing on the site can wake the database or the container past that point, and the tower answers at most 500 queries a day.

The review found the storage unit wrong by a factor of 730. Neon's two documentation pages disagree on whether its storage metric is byte-hours or byte-months, and the code had followed the wrong one. The fix followed the page with a worked example, and the first live read settled it: a full day at four gigabytes came back as 116 million, which is four gigabytes times one day's share of a month. Under the other reading it would have been 96 billion. The same read showed that a billing period here starts at the instant of the upgrade and ends at the calendar month, which the reviewer had guessed the other way.

Read back from the traces on the build that shipped this entry (66ff111), twenty distinct searches, one every 2.5 s, the first ten on a tower that had just woken and the second ten warm:

spanp50p95n
GET /api/search3,023 ms4,158 ms20
search.embed1,580 ms2,168 ms20
search.knn739 ms920 ms20
search.why547 ms956 ms20
GET /api/health210 ms wall1

What this issue added to the search path is one key-value read before the model is called. The route's own time outside its three children is 157 ms at the median here against 290 ms on the previous build, so the read is not visible. The embed span is up about 300 ms at the median on the previous build's warm set because half of this set ran on a model that was still warming; the P20 entry's just-woken set is the fair comparison and this sits under it. The why span is up about 180 ms at the median, which this change did not touch and which the next entry's set will show as noise or not.

The one rule that is not code: never point an uptime monitor at a route that queries Postgres. A ping every five minutes defeats scale to zero and turns a $3 month into a $77 one. There is a health route that reads only the checker's last run, for exactly that purpose.

Sep 11, midday
landed
PR #28
health at risk
built Claude Opus 5
read Claude Fable 5.1

How it was built, with the numbers

Every issue in this repo is built the same way, and this issue writes that way down with the numbers behind it. One session orchestrates. It reads the issue, branches, and hands the whole spec to an implementer subagent on the model the issue is labeled for. A separate Fable subagent reviews the result with two lenses, correctness and what a front end lead would see, and every finding it calls a finding has to come with a reproduction; anything it cannot reproduce is filed as a question. Nothing merges until I say so. Then one command merges, marks the issue done, posts the status update, and writes the entry you are reading.

The transcripts the tool reads only go back to the second day of the build, so the counts are a window on the later half and the entry says so. From that window:

whatcount
tool calls4,452
of which in subagents3,216
shell segments9,575
subagent spawns26 (12 Fable, 9 Sonnet, 4 Opus, 1 inherited)
refused by the permission classifier21
refused by a permission rule12
refused by the secrets hook13
my turns100, 96 of them under 200 characters

Across all 24 issues that have landed, 16 implementers were Sonnet and 8 were Opus, every review was Fable, and the 21 entries that give a count list 292 findings between them, 12 labeled blocking and 14 more marked fix before the PR is ready.

The README now has a "How it was built" section a reader can use to answer how the agent's code gets reviewed, and the long form with every table is in the repo. The tables are regenerated by a test and compared byte for byte, so the doc cannot drift from the script that made it.

The review found the doc's numbers overreaching in a way that would have cost it a reader's trust. Status updates were described as one per landing and all after a merge; neither held. The classifier's blocks were counted twice under two names. Hook refusals were not counted, and when they were, the counter also matched the hook's own source code whenever a tool read it, so 15 was 13. The family that counts commands naming the secrets file matched the example file and then matched process.env inside source files being written, so 46 was 27 was 17. A commit message with a heredoc and an unbalanced quote turned an argument fragment into a command shape 25 times. Every one of these is now a test.

This issue changed no request path, so the spans it would be measured on are the previous entry's: search 2,990 ms at the median and 3,811 ms at p95 on build 8e3d2f9.

Sep 11, evening
landed
PR #29
health at risk
built Claude Sonnet 5
read Claude Fable 5.1

The front end numbers, measured on the live site

This issue is the half of the README a front end reader reads first: what the site costs a person on a real connection, measured against the deployed URL and not a laptop. A Playwright script runs thirty queries through the search route, reads the timings the server reports in its response header, and loads a page for each to time the first painted result card. It then loads the query page three times on a throttled profile and clicks the scrub strip twenty times on one film to time the seek. Lighthouse runs six times, and a Range request is checked on the path the player uses. The raw files are in the repo, the method is three sentences, and everything below is from one build, 755cb03, on a warm tower.

measurementp50p95
embed, server side1,399 ms1,568 ms
nearest neighbours, server side866 ms1,577 ms
why, server side307 ms446 ms
search request, in the browser2,635 ms3,611 ms
first painted result card3,125 ms4,011 ms
scrub click to the video seeking224 ms535 ms
first sprite sheet received on a slow link, median of 349,374 ms
Lighthouseperformanceaccessibilitybest practices
home, mobile9710096
home, desktop10010096
query, mobile10010096
query, desktop7610096
film, mobile7510096
film, desktop9810096

Best practices is 96 on every page for one reason, a missing favicon. The two performance scores under 80 are the same thing seen twice: on the query page at desktop width the largest paint is a card thumbnail waiting on its sprite sheet, and on the film page at mobile width it is the video element.

The slow-link number is the one to read twice. A result card's thumbnail is a crop out of its film's whole sprite sheet, and every card on the page starts its own sheet at once, eight to twelve of them at up to 2.3 MB each, so on a throttled connection the first thumbnail waits about fifty seconds while eleven megabytes share one pipe. That is filed as its own issue and it is the next thing the grid needs.

A reader can now open one page and see what a search costs end to end, in the browser, with the raw files beside it. The script reruns with one command against whatever is deployed.

The review caught three things that would each have put a wrong number in front of that reader. A render timing was in the table that the site had never sent; the parser had defaulted it to zero, and on this platform it could only ever be zero. The paint measurement loaded the same query the script had just fetched, so a cache made the page look faster than the request it depends on; the page loads now use thirty different queries. And the slow-link paragraph blamed one 700 KB file when the network audit showed twelve sheets in flight. The whole set was then rerun on one build so the page carries no caveat about a deploy landing mid-run, which is what happened the first time.

This issue changed no request path. Its own numbers above are the browser's view of the same spans the previous entry read from the traces on build 8e3d2f9: search 2,990 ms at the median there against 2,635 ms in the browser here, on a different set of thirty queries.

Sep 10, late
landed
PR #23
health at risk
built Claude Opus 5
read Claude Fable 5.1

Postgres does not charge for the vectors it reads

P25 found frames/guided on a sequential scan and guessed the planner was mis-estimating how selective the policy filter is. It is not. pg_stats carries element statistics for frames.policies, and the planner's row estimate for guided is 24,982 against an actual 24,738. The estimate is right and the plan is still wrong.

What the planner gets wrong is the price. A vector(1152) is 4,616 bytes, so every vector lives in TOAST: frame_vectors' own heap is 20 MB and the vectors beside it are 1,322 MB. A sequential scan is costed on the heap, and detoasting the vectors to compute distances is costed at nothing. So the planner prices guided's scan at 11,174 while it reads 211,655 buffers, prices the index walk that reads 23,468 at 19,891, and takes the scan.

uniform is on the right side of that comparison because both prices move with how many rows the filter passes, not because anything about its own uncharged cost is different. An uncharged cost cannot move an estimate. The scan's charged part grows with the rows that pass, since the sort and the distance evaluation run over all of them, and the walk's falls, since fewer index rows are needed per output row. Read off plain EXPLAIN with one plan disabled at a time: uniform's scan is priced at 19,194 against its walk at 11,290, and guided's scan at 11,174 against its walk at 19,891.

Nobody had looked at the other two policies. scenecut and deictic were on the same plan. Three of the five calls the app can make were doing a full scan.

The fix is a fourth SET clause on frame_search, enable_seqscan = off, scoped to the function so it changes no other query on the connection. Measured on the live database, one query asked ten times, against the same endpoint but not the same cache: the before set ran on backend pid 7150 after an hour awake, the after set on pid 1198 ninety seconds after a verified resume.

policyframesbeforeafter
frames/uniform239,12440-47 ms47-52 ms
frames/guided24,738497-874 ms61-67 ms
frames/scenecut22,257273-286 ms67-73 ms
frames/deictic3,88191-212 ms143-173 ms
frames (no policy)253,83145-50 ms44-48 ms

deictic is the one policy the clause does not move onto the index, and that is the right answer. Its HNSW path is priced at 72,350 against 20,910 for the frames_pkey plan it takes instead, because 1.5 percent of rows passing means the walk would visit most of the graph to fill 200 rows. It moves within the spread of its own readings, 143-173 ms against 91-212 ms.

Cold, on a compute verified to have actually suspended and resumed, frames/guided:

readingbeforeafter
cold8,974 ms4,209 ms
warm 18,587 ms59 ms
warm 22,209 ms66 ms
warm 3 to 15506-663 ms60-72 ms

Both runs cleared the postmaster gate, so both cold readings are cold on evidence rather than on an idle timer. The three-reading tail the scan needed before it settled is gone: the query after the cold one is already at the warm floor.

That is one query asked sixteen times, and it is the shape that flatters an index walk most, because every reading after the first asks for a region of the graph the first one pulled in. The scan read the same 211,655 buffers whatever the vector was, so a session paid for them once. The walk reads about 23,000 blocks scattered through a 1,978 MB index that this compute's cache cannot hold, and which ones depends on the vector, so every query nobody has asked before pays for its own region as random reads from Neon's pageserver at roughly 0.2 ms a block. Six different queries on a compute that had just resumed, each asked once and then repeated:

queryfirst touchrepeat
q0104,320 ms65 ms
q0206,137 ms79 ms
q0305,091 ms94 ms
q0404,584 ms270 ms
q0505,550 ms72 ms
q0604,341 ms97 ms

No decay across the six, and the live route showed the same at 6,229, 4,340 and 3,339 ms. Before this change, the same six would have cost about 8,974 ms, then 8,587, then 2,209, then 506-663 ms each, because the second query needed the pages the first had already read. So per guided search on a compute that resumed at the start of the session:

query in the sessionbeforeafter
18,974 ms4,209 ms
28,587 msabout 5,000 ms
32,209 msabout 5,000 ms
4 onward, each506-663 msabout 5,000 ms
one already asked this session506-663 ms65-270 ms

One search after a resume is twice as fast as it was. Six different searches in a session cost about 30 seconds of database time against about 21 before. The crossover is at three or four queries, and with autosuspend at 300 seconds the resumed compute is the ordinary state, not an edge case. What P30 has really done is trade a cost that was shared across a session for one paid per query, and nothing inside the function can bring the per-query cost down: the walk already reads the minimum it can read.

Closing that gap is a hosting decision and it is Ed's. A cron that keeps the compute from suspending would put every reader on the 65-270 ms row and would bill for every hour it keeps an idle compute awake, which is the same question docs/decisions/001 answered no to for the text tower. A higher minimum CU than 0.25 holds more of the graph between queries and bills for the floor whenever the compute is awake, and does nothing across a suspend. Prewarming the index with pg_prewarm moves the read from the reader to the resume, but it is 1,978 MB into a cache smaller than that, so it would help the first few searches and not the rest. The decision doc has what each would cost. Nobody has counted how many searches a real session on this site contains, which is the measurement that would settle it.

Then the question the eval could not be left standing without an answer to. A sequential scan returns exact nearest neighbours and an index walk returns approximate ones, so if guided was on the scan and uniform was on the index, evals/results.md was comparing two retrieval methods and calling the difference a difference between sampling policies. It was not. The per-cell knn timings already in results.json say both rows were served by the scan: guided at 311-564 ms on all 99 cells, uniform at 1,209-2,413 ms on 98 of 100, against an index plan that costs 45 ms. Both were exact.

Running all 100 ground-truth queries against both plans offline settles the rest. Every cell of the guided table comes out identical either way, splits and by-style rows included. That is a statement about what these metrics read, not about the two retrievals agreeing: every metric reads only where the ground-truth film ranks and what t came back with it. Elsewhere they differ, at 0.944 mean frame-level recall, with 9 of 100 queries returning a different top-ten film order. None of those differences moved a ground-truth film across a rank boundary on this query set. results.md now says at the table which retrieval produced it, and that the reading is an inference from timing.

The re-run is still worth doing, once this is deployed, because the published numbers were produced by a retrieval the site will no longer perform and because frames/uniform's row was taken under the plan P29 fixed. It is make eval (cd index && uv run ../evals/run.py), the three ship paths over the committed 100 rows, and it costs about 13 minutes of wall time. The zone's rate limit sets that, not the work: 300 requests paced 2.6 s apart. The tower pays 200 embeds and one cold start of about 50 s if the container has gone to sleep. The database pays about 20 seconds of query time in total at the new warm costs, and stays awake for the 13 minutes. It overwrites evals/results.json and evals/results.md, this note at the table included.

Read back from the traces on the build that shipped this entry (cce1eb6), search.knn span duration through the live route, six queries per policy that nobody had asked before, on a database that had been idle:

policyp50p95
guided4,840 ms6,804 ms
uniform1,641 ms1,870 ms
deictic1,510 ms1,888 ms
scenecut598 ms1,392 ms

Across all four, p50 1,641 ms and p95 6,256 ms, against p50 1,506 ms and p95 8,209 ms on the previous build, whose spans carry no policy and whose mix of policies is not known. Every one of these is a first-touch reading, which is what the novel-query table above predicts, and uniform pays its own smaller version of the same per-query cost on the live path.

A guided search nobody has run before costs 4 to 6 seconds on the live site after the database has been idle, and 65 to 270 milliseconds once it has been run. Before this it was 9 seconds for the first search of a session and half a second for every one after. The recall table stands as written, and it now says which retrieval wrote it.

Sep 10, late
landed
PR #22
health at risk
built Claude Sonnet 5
read Claude Fable 5.1

The sixth query stops costing 45x

P25 found the plan-cache regression and deliberately did not fix it: a migration against the live database is not a rider on an issue about a timeout number. This issue applies the fix.

frame_search now carries set plan_cache_mode = force_custom_plan as a third SET clause, alongside hnsw.ef_search and hnsw.iterative_scan. On PG 18, the planner caches a SQL-language function's plan per backend session and from the sixth execution considers switching to a generic plan. Two parameters independently push the generic plan off the HNSW index: limit k, because an unknown LIMIT makes the planner assume 10% of the output, and policy, because the filter's unknown selectivity makes a hash join look cheaper. Either one alone produces a sequential scan. force_custom_plan tells Postgres to replan from scratch on every execution, at a cost of about 6 ms per call, instead of ever switching to the cached generic plan that costs 2 to 3 seconds.

Measured before and after on the live database, thirteen executions on one session, same backend pid throughout:

readingbeforeafter
warm 1-448-55 ms47-53 ms
warm 5 (transition)41,367 ms48 ms
warm 6-12 (plateau)2,100-2,650 ms47-53 ms

keyword_search and the set-db.ts duplicate-pairs query were both considered and ruled out. Keyword uses a GIN index that generic plans handle. The duplicate-pairs query is inline SQL with unnamed statements, not subject to PG 18's per-function plan caching.

The live site now serves every frames/uniform query off the HNSW index. The guided path's permanent sequential scan (P30) is still there, and the per-cell timing in evals/results.json was measured under the generic plan. P12 will measure latency from scratch on the fixed database.

Sep 10, evening
landed
PR #25
health at risk
built Claude Sonnet 5
read Claude Fable 5.1

An agent can search the corpus through the same path the page uses

The piece had one consumer, a person at the page. Now an agent can use it too. The web Worker serves the Model Context Protocol at /mcp over streamable HTTP, no install and no key: paste the URL into Claude Code, Claude Desktop, or Cursor and the agent gets three tools. search_clips runs a query and returns each matched film with the second it matched at, the score, the sprite sheet and the tile to crop the frame from, and the link that opens the player at that second. get_film returns a film's metadata and its archive.org source. get_clip snaps a second to the sampled frame at or before it and returns that frame's policies and the transcript line under it.

The tools call the exact search function the page calls. There is no second query path and no separate ranking, and a test runs the page's search route and the MCP tool against the same search and checks the ids come back in the same order. What an agent sees is what the page shows, nothing more.

The first draft sat on a Durable Object per client session, which is what the platform's agents library offers by default. The review measured it: the class is deprecated in the current release, each session left a database behind until the client said goodbye, and the Worker bundle grew more than five times. The stateless handler replaced it the same day.

buildWorker bundlegzipped
before this change240 KB65 KB
Durable Object draft1,356 KB357 KB
stateless, shipped550 KB145 KB

A cold tower does not hang an agent. A search that finds the model asleep answers within about three seconds with a structured result that says so and gives a retry hint, and the page's own warming rule applies. The live check from Claude Code hit exactly that: four calls answered cold in 3.0 s each, the client waited the hint, and the fifth returned three films in 5.8 s, then fetched the first film's record. Each tool call is its own span in tracing, parented to the request that carried it, because the handler streams its answer back before the tool has finished and the request span alone would never see a failure.

Abuse has three layers. The zone rate limit that already covers the search API now covers /mcp too, five requests per ten seconds per address. Inside the Worker, two counters run before any work: ten searches a minute per address, sixty lookups a minute per address, and an over-budget call gets a structured answer with a retry hint rather than a hung connection. The third layer, a global ceiling on what the tower will spend in a day, is the cost issue that comes later in the plan.

Read back from the traces on the build that shipped this entry (7438401), ten distinct page searches and ten distinct agent searches, all on a warm tower, one request every 2.5 s:

spanp50p95n
GET /api/search2,723 ms3,195 ms11
mcp search_clips2,585 ms5,472 ms10
search.embed1,270 ms1,958 ms21
search.knn796 ms1,157 ms21
search.why367 ms502 ms21

The agent path and the page path cost the same, which is the point: they are the same code. The one agent call at 7.5 s is the p95 and was a novel query paying its index walk. Every search span sits below the previous build's numbers; that build's set included six searches on a tower that had just woken, and this one has none. This change touched nothing in the search path and did not move it.

Add one URL to an agent and it can search this corpus, get the same frames and deep links the page shows, and be told plainly when the model is still waking up.

Sep 10, midday
landed
PR #24
health at risk
built Claude Sonnet 5
read Claude Fable 5.1

A cold start has two phases, and the library's timeouts are try counts

The text tower runs in a container that sleeps after ten minutes idle. Waking it means placing a new instance and loading the SigLIP text model, and the first request after a sleep used to fail before either finished: the library fronting the container gave up and answered a flat 500 that read the same as a crash. The page worked around that with a short probe and a warming state, and it still does. What changed is what the tower itself says.

The container class now owns the wait. A request that arrives while the container is placing or loading is held inside the Durable Object until the port opens or a ceiling passes. A container still loading answers 503 with a Retry-After header and a body that says so. A container that crashed answers 502 with the crash text, recorded through the class's own error hook so a stale crash never brands a later timeout. A warm request makes no extra call.

Two things came out of the live measurements that the local tests could not show. The library's timeouts are try counts, not wall clocks: a port ping to a container whose port is not bound yet costs about 0.9 s per try on the platform against under a millisecond locally, so a nominal 20 s budget held for 60 s on the first deploy. A fresh AbortSignal.timeout inside the Durable Object is now the real ceiling, 150 s for an embed and 20 s for a health check. And a cold start has two phases the library does not distinguish. Instance placement ran from 20 s to 66 s across five starts today, and model load from 9 s to 34 s. The 49.7 s figure measured two days earlier was a short placement plus a full load.

Five cold starts today, end to end, from the tower's own spans:

buildstartplacementmodel loadsingle embed
6223e2eafter deploy42 s19 shealth 503 at 60.4 s, then 200
6223e2eafter 11 min idle16 s200 in 48.3 s
97fb2d5after 11 min idle66 s34 s503 at the 75 s ceiling, retry 200 in 31.3 s
3634de8after deploy52 s10 s200 in 67.6 s
3634de8after 11 min idle62 s9 s200 in 75.3 s

The last two are the final build. Both single cold embeds succeeded in one request, and the second would have missed the earlier 75 s ceiling by three tenths of a second. Warm embeds on the same build ran 1.2 to 1.5 s, unchanged from before.

The tests run the real container library against a fake Durable Object state at one hundredth scale, 38 cases in under four seconds, including the case where a budget too close to the instance budget marks the container healthy without ever checking the port.

Read back from the traces on the web build that shipped this entry (61541ce), twelve distinct searches, six of them on a tower that had just woken:

spanp50p95
GET /api/search3,760 ms6,923 ms
search.embed1,467 ms5,217 ms
search.knn1,691 ms1,956 ms
search.why474 ms751 ms

The kNN span sits where the previous build left it, 1,641 ms p50 on uniform. The embed p95 is the container's first forward pass after a wake, 6 to 11 s on every start today, which this change did not touch and did not move.

The decision that came with this: no keep-warm cron. The first visitor after a sleep wakes the tower and sees the warming state while it comes up. Most of that time is instance placement, which no code change shortens, and nobody pays to keep an idle container warm through the night.

A request sent to a cold tower now gets an honest answer: 503 and a retry hint while the model loads, 502 if the container died, and a working embedding in one request if the caller waits. Before this every one of those was the same 500.

Sep 9, night
landed
PR #21
health at risk
built Claude Sonnet 5
read Claude Fable 5.1

Both slow paths are full scans, and a cold cache is what makes them expensive

P23 moved the search budget from 8 s to 15 s to survive a cold database. That number came from one measurement with a stub vector and no policy filter. The first two requests on the uniform path after the deploy were cancelled at 15.1 s each. The ceiling moved and the failure moved with it.

The real answer has two parts, one per search policy. The uniform path hits a plan-cache regression: the first five executions of frame_search use a custom plan that walks the HNSW index in about 45 ms, then at execution six Postgres switches to a generic plan that scans all 229,292 uniform frame vectors sequentially. That transition costs 40 s on a cold cache, 2 to 3 s warm. The mechanism is PG 18's aggressive caching of SQL-function plans, which the local pg17 dev database does not reproduce.

The guided path never touches the HNSW index at all. The planner sees that guided frames are about 9% of the table and chooses a sequential scan permanently: 9.5 s cold, 400 ms warm. No plan-cache switch is involved.

Both slow paths are full scans. Coldness is what makes them expensive. The Neon compute's shared_buffers is 230 MB against a scan that touches about 1.65 GB of heap.

pathcoldwarmmechanism
keyword520 ms360 msGIN scan, small
frames/uniform40 s on first transition, 2.9 s plateau45 ms (custom plan)plan-cache switch at execution 6
frames/guided9,500 ms400 mspermanent seq scan

The 15 s budget stays. Every routine cost tops out under 9 s, and the 40 s transition is a plan bug that no tolerable timeout can cover. Two fixes are filed as their own issues: plan_cache_mode = force_custom_plan as a database migration, and an index or query rewrite for guided's permanent scan.

The Neon compute autosuspends after 300 s, runs at 0.25 CU with 230 MB of shared_buffers, and is not changing. A reader whose first search happens inside the 5 to 10 minute idle window will wait about 12 s (3 s resume plus 9 s scan) on the guided path, which is inside the budget. On the uniform path, after the plan has switched, the same reader will wait 40 s on the first post-resume query, which is outside any budget a person would tolerate. The fix for that is the plan, not the timeout.

The search span now carries search.policy beside search.path, so a cancellation in a trace names which policy it was on. A measurement script gates cold on pg_postmaster_start_time changing from a baseline, and 14 tests cover it.

Sep 9, night
landed
PR #20
health at risk
built Claude Opus 5
read Claude Fable 5.1

The count row was spending the first viewport

Both pages opened on their own counts. A double rule, the title, a sentence, a heading, and then three figures set at 68 pixels with 24 pixels of air above and below each one. The row alone was about 140 pixels and the stack above it was another 350. On a laptop the search field started 915 pixels down a 720 pixel screen, so the thing the site is for was two scrolls below the thing it had counted. The evaluate page did the same to its histograms.

The counts are set the way a film can label sets them now. Small, mono, one ruled line, the same tabular numerals. The search field is the first thing after the masthead, which is where the one red mark on the page already was. The catalog and the notes on the pipeline moved below the results, so a query comes back directly under the field a reader typed it into rather than behind a screen of numbers about the index.

home page, search fieldbeforeafter
1280 by 720915 to 969378 to 432
1440 by 900915 to 969378 to 432
375 by 8121117 to 1161350 to 394
evaluate page, first histogram, twelve filmsbeforeafter
1280 by 720833 to 958508 to 633
1440 by 900833 to 958508 to 633
375 by 812978 to 1094568 to 684

Measured in a browser against a production build of the site with the full catalog behind it, not computed from the type scale. On a laptop the first screen now ends inside the notes on the pipeline, below the catalog heading and the whole count strip, instead of ending on a number.

The interesting failure was in the argument for the strip standing still. When the counts are not known the page prints a row of dashes the same length as the number it is standing in for, and the claim was that a fixed character count in a monospace face is a fixed width. It is, once the monospace face has loaded. Before that the browser draws the page in a stand-in built from a proportional face, and there the glyphs are not one width.

six characters, at the strip's sizemonospace facestand-in face
digits68px84.9px
em dashes68px152.6px
en dashes68px84.9px

In a row that wraps, that difference re-flowed the strip twice on every first load, once when the numbers replaced the dashes and again when the real face replaced the stand-in. On a phone it took the strip from two rows to three: 25 pixels that moved everything under it. The placeholder is en dashes now, which the stand-in draws at exactly a digit's width, and the strip is a fixed grid rather than a row that wraps, so how many rows it has is decided by the screen width and never by how wide the figures happen to set. Every state, both faces, seven widths from 375 to 1440: one height each.

cumulative layout shift, cache clearedbeforeafter
home, 12800.01990.0199
home, 37500
evaluate, 12800.16260.1423
evaluate, 37500

Open the site on a laptop. The masthead, the title, one sentence, and the search field, with the catalog heading and its counts still on screen under them. Type a sentence and the results come back where the field is, not below a screen of numbers.

Tests went from 704 to 727 in the web project. Three of them are new and one of those runs in a real browser, because the thing that broke here could not be seen by a test that compares markup.

Sep 9, evening
landed
PR #19
health at risk
built Claude Sonnet 5
read Claude Fable 5.1

The statement timeout was never in effect

The frames path had been returning 500 on most requests, with the trace saying the query was cancelled by a statement timeout six milliseconds after it was sent. The caller asks for eight seconds. An eight second timeout cannot fire at six milliseconds, and that gap is the whole issue.

The answer is that the timeout was never in effect. Passing statement_timeout to the client constructor sends it as a startup parameter, and through the pooled endpoint it did nothing at all. A query given fifty milliseconds ran for four hundred and thirteen, twelve times out of twelve. The only timeout that has ever applied on this path is the eight seconds attached to the database role a month ago. Setting the timeout as a plain statement does work, and is worse. It outlives the connection that set it, so the next caller inherits a number it never asked for. Five fresh clients out of five were cancelled at sixty three milliseconds by a value a previous client had set and abandoned.

a timeout set four ways, against the pooled endpointwhat it did
constructor parameter, 50 msnever fired, 413 ms
plain statement, 50 ms, same clientfired at 63 ms
inherited by the next client, never set by itfired at 63 ms
set local inside a transactionscoped to that transaction

So the timeout now lives on a transaction rather than on a connection. The connection opens one transaction as soon as it connects, which covers the provenance lookups that run after the search query as well as the search itself. Releasing a connection commits before it closes, because a client that disconnects mid transaction makes a pooler throw the connection away rather than reuse it. A cancellation arriving in under a second cannot be a real timeout, so that connection is treated as poisoned, dropped, and the query is run once more on a fresh one.

The second failure is the one a reader would actually feel, and it is not fixed. A cold database is slow enough to hit the ceiling on its own. Measured after eleven idle minutes, the first search query took eight and a half seconds and the next took three and eight. The budget moved to fifteen seconds for the paths that fail a request when they are cancelled. The catalog counts and the log strip kept their smaller numbers, because they show a dash by design rather than hold a page open.

Fifteen seconds is not enough. Right after the deploy, the first two requests on the uniform path were cancelled at fifteen point one seconds each before the third answered. The eight and a half second measurement had used a stub vector and had not gone through the heaviest policy in the corpus, so it undersized the worst path. What is known is that the real cold time is above the ceiling, which is exactly what was known before the ceiling moved.

after the deploy, twenty requests each, pacedguideduniform
answered 20020 of 2020 of 20
search query, median521 ms1307 ms
search query, 95th1631 ms2261 ms
whole request, median2884 ms2634 ms
warm-up requests before counting13

Every error on the new build is a real timeout. The fast phantom cancellation that opened the issue does not appear on it once. The last one, at three milliseconds, is on the previous build twenty minutes earlier.

The retrieval eval was rerun because the issue assumed its frames rows had been measured while this was happening. They had not. The numbers are the same as the previous run, which is worth more than an improvement would have been, since it says the failure was in answering requests rather than in what the index returns.

The decision note carries every measurement, what was ruled out, and what is still unproven. The mechanism has not been reproduced through the pooler the app actually goes through, only through the one behind it. The retry is a bet: the burst that opened the issue kept failing across four connections in twenty three seconds, which one retry would not have survived.

Sep 9, midday
landed
PR #18
health at risk
built Claude Sonnet 5
read Claude Fable 5.1

The system now says where its time goes

The piece already measured itself. Every search returned how long it spent embedding the query, how long the vector search took, and how long it took to fetch the sprite sheets behind the result cards. Those numbers went to the reader who asked for them and then they were gone. Nobody could ask what the median was last Tuesday, or whether the slow queries are slow in the same place.

Now each of those stages is a span in a trace, and the traces go to a self hosted collector. Three separate runtimes report: the web application, the text tower that turns a query into a vector, and the indexing scripts that run on a laptop. A single search that touches the first two shows up as one trace with the hops nested inside it, so the question "where did the nine seconds go" has an answer that does not depend on anyone having watched at the time.

The spans carry the numbers the code already had, rather than measuring the same work twice. Each stage span is stamped with the figure the search function returns, and its start time is moved back by exactly that much, so a chart of span durations and the retrieval eval are reading the same measurement. Two clocks that disagree slightly would have been worse than one clock.

Queries themselves never leave. In production a span carries the length of the query and a hash of it, which is enough to group repeats and see that one query is being run over and over, and not enough to read what anyone typed. The raw text is attached only when the code is running on a laptop. Database spans carry the name of the statement, never the statement.

runtimetests beforetests after
index scripts363376
text tower1226
web application645682
tower's fronting worker017

That last row was its own small finding. The worker in front of the tower lives in a separate package with its own dependencies, and the command that runs the whole test suite had never reached into it. It does now, along with the type check and the install step.

The review found twenty things and four of them would have shipped a system that looked instrumented and was not. The best of them: the container's automatic framework instrumentation was being switched on inside the startup handler, which runs after the framework has already assembled and cached the middleware chain that instrumentation works by replacing. The call returned success. No request span was ever produced, and the container's own spans floated off in a trace with no parent. It would have looked fine on a dashboard until someone went looking for a request and found none.

Two others were about failure. Stage spans were opened before the slow call and closed after it with nothing in between to catch a throw, and a span that is never closed is never sent. So the two failures this work exists to explain, a database query hitting its timeout and a cold model server, were the two cases that produced no evidence. And the sampling decision, which keeps a fifth of ordinary traffic and all of the errors and slow requests, was being made independently in each runtime. Rolled separately at each hop, a whole trace survives about four percent of the time. It is decided once now, at the front door, and carried along with the request.

Next: the dashboard and the alerts, and one real trace of one real search, end to end.

Sep 9, late
landed
PR #17
health at risk
built Claude Sonnet 5
read Claude Fable 5.1

Every query through the live route, every answer on the record

There is now a results table, and every number in it can be traced to one query and one answer. The eval runner sends all one hundred ground truth queries through the live search route, the same route and the same function the pages call, once for each of three paths: keyword search over the transcripts, frame search over one frame a second, and frame search over the guided sample. For each answer it records where the right film ranked, the second the route returned, how far that second was from the one the ground truth names, and the ten films that came back. All of it is committed, so a single cell can be pointed at and argued about.

A hit is the right film in the top ten with the returned second within ten seconds of the truth. Film-only drops the second and asks only whether the film was found. The eighty tuned rows are the ones sampling thresholds are chosen on; the twenty held-out rows are reported on their own line and were run once.

path, tuned 80 rows, the September 9 runrecall@1recall@10film-only@10MRRmedian seek error sframes in indexindex wall min
keyword0.230.250.340.232.8transcript168
frames, uniform0.240.380.780.3010.8229,292662
frames, guided0.190.210.680.2031.523,80269

The held-out twenty: keyword 0.25, uniform 0.45, guided 0.10 at recall@10. The median seek error is taken over the rows where the film was found, not only the strict hits, because a strict hit is bounded by the tolerance and a median over those would say nothing. Frames in the index are counted from the index; wall minutes are that count at the measured embedding rate of the run that built it, so each row is the cost of building that policy's index alone.

By style the blended numbers come apart. Keyword finds 0.95 of the spoken rows and none of the visual ones, because it can only match words that were said. Uniform frames find 0.53 of the visual rows and 0.10 of the spoken ones. Guided, at a tenth of uniform's frames, keeps 0.87 of its film-only recall and loses more on landing at the exact second, which is what a sample that skips most seconds would be expected to do.

by style, recall@10 on the tuned rowsvisualspokenvague
keyword0.000.950.05
frames, uniform0.530.100.35
frames, guided0.330.100.10

"rocket launch" is row 4. Keyword returns one film, a 1968 Day in America reel where the narrator says the words over a television set. The frame the query was written against is a Saturn V lifting off in a cloud of exhaust, inside a food preparation film whose transcript never mentions it.

The document lists the five worst queries per path with the answer the route gave, so the section on what the search gets wrong can be written from specific cases. The run takes thirteen minutes because the site rate-limits its own search route and the runner respects that rather than asking for an exception. Two runs, before and after the review's fixes, produced the same numbers cell for cell.

Sep 9, evening
landed
PR #16
health at risk
built Claude Opus 5
read Claude Fable 5.1

One hundred queries written with the index closed

There is now a ground truth file. One hundred rows, each a query, the film it belongs to, the second it belongs at, and how far off a result can land and still count. Every row was written by looking at twenty seconds of the film and, for the spoken rows, at the transcript for those seconds. Nothing was written by looking at what the search returns. The search was not run once while the file was being written, and the README names the four tools that were off limits.

The draw is a seed over the catalog. Every film is shuffled once, then again and appended, so a film can come up at most twice, and each candidate gets one timestamp drawn inside the film with twenty seconds of margin at each end. The margin is measured against the shorter of the runtime archive.org reports and the runtime the encoded file actually has, because the two disagree by more than two seconds for 124 of the 300 films and the archive's number is the longer one for 19 of them. The writer walks the candidates in order and either writes a row or logs a skip with a reason. Ten were skipped, six of them silent films drawn on a turn that needed spoken words.

ground truth, seed 20260909
rows100
visual, what is on screen50
spoken, what is said25
vague, two to four words25
held out20
candidates walked110
skipped, with a reason each10
films used twice0
tolerance, seconds10

Twenty rows are held out, ten visual, five spoken, five vague. Sampling thresholds and ranking choices get tuned on the other eighty. The twenty are run once, at the end, and reported on their own line.

A contact sheet renders the whole set as seven pages, each row's frame over its query and a one line note on why that frame, so the file can be checked by eye in a few minutes. That is how the review found the rows that were wrong.

"a hand pulling a plug out of a wall outlet" is row 93. The frame is a white wall, a two-gang plate, and a hand on a twin cord, at 713.5 seconds into a 1961 projector maintenance film.

The review replayed the seeded draw against the skip log and confirmed every row sits inside its candidate's window and every spoken query's words are inside the transcript segment its note names. The five things it made us fix were all in what a reader would see: a timestamp outside its window, two frames that did not show what their query said, a paragraph arguing the wrong direction, and an id spilling into the next cell. Twenty tests now guard the file's shape offline.

Sep 9, midday
landed
PR #15
health at risk
built Claude Sonnet 5
read Claude Fable 5.1

The evaluate page, and a set a reader can carry

There is now a set. Every result card and every film page has an Add button, s does the same for the card under the keyboard, and a count appears in the corner once something is in it. The set lives in the browser and goes nowhere on its own; sharing it is a button that writes it to a store under a ten-character id derived from its contents, so the same set always gets the same link and the link carries the sample strip's own size and seed.

The /evaluate page says what the set is. Films, runtime, and how many are narrated, in the same display numerals as the catalog counts on the front page. Under them, duration, resolution, decade, and speech as bars with the count printed beside each one, and words per minute for the films that talk. Films the catalog no longer has are listed by name rather than quietly dropped. Everything on the page is computed from four facts about each item, a duration, a year, a resolution, and a speech flag, and nothing about it is specific to film.

Near-duplicates are the one thing on the page in red. Every pair of films whose pooled frame vectors sit above 0.92 cosine similarity is shown side by side, each at its own midpoint frame, with a Drop button under each. The stratified sample picks six, twelve, or twenty-four films across decade and speech so the strip looks like the set rather than whatever sorted first, and Reshuffle changes the seed. The manifest export is a JSON download with the summary, every film's real media URLs, its duration and resolution, its words per minute, and the one sampled timestamp the page offers, the midpoint.

checked on a preview against the live database and store
stats query, six filmsunder 0.2 s
near-duplicate self join, six films0.19 s
pairs above 0.92 in that set1
similarity of that pair0.944

That one pair is worth a sentence. The two films are not the same shot; they are two films from the same decade about the same kind of work, and a mean over all of a film's frames is exactly the vector that finds that. Whether 0.92 on pooled vectors is the right cut for "paid for twice" is an open question the ground truth in the next piece can answer.

The corpus embedding run that started yesterday evening finished this morning.

corpus embed run, finished Sep 9
films with frames embedded283
frames embedded249,833
uniform225,676
scenecut21,151
deictic3,599
guided23,452
embed wall12.0 h
frames per second5.1
frame vectors on disk3.3 GB

Search "tractor", press Add on four cards, and open Evaluate. The page shows 40 minutes of 1950s film, all of it narrated at 90 to 130 words a minute, and one pair flagged in red at 0.944 with a Drop button under each side.

Tests went from 434 to 645 in the web project. The review found eighteen things and every one in scope was fixed before the merge. The one that blocked was a write path with no size limit on what a client could store; the ones that changed the feel of the page were a Drop that reloaded everything, a Reshuffle that could not change strata, and an empty-set message that flashed before the real set was read.

Sep 9, late
landed
PR #14
health at risk
built Claude Opus 5
read Claude Fable 5.1

The grid, and the strip that shows the thesis

The search results are a grid of frames now. Every card shows the frame that matched, and it costs no request of its own: the picture is a crop out of the film's sprite sheet, positioned by percentage so the same crop works at any card width, and a page of results downloads one sheet per film. Under each frame is a line that says what it is. A sheet samples one frame every two seconds, so the tile covering a match at 12:35 is the frame at 12:34, and the card says "frame 12:34, matched 12:35" rather than letting the picture claim more than it shows. Moving a mouse across a card scrubs through the whole film off the same image. Arrow keys move between cards, Enter opens one, / puts the cursor in the search field from anywhere, and Escape clears.

The page paints before the search runs. The heading, the field, the filter rail, and a grid of placeholder slots are on screen while the query is still on its way to the model, and the results land into the layout that was already there. On the fused path, which searches the frames and the transcripts at once, the transcript matches arrive first and the frame matches are appended after them. Nothing already on screen moves when the second list lands. That means the visible order is the order of arrival rather than the fused rank, and the page says so under the grid and offers the ranked order as a link.

The film page is where the sampling argument becomes a picture. Under the player is the whole film as a strip of its own sampled frames, and under that, four lanes of tick marks, one per sampling policy, with a mark at every frame that policy sampled. Uniform is a solid comb. Scenecut clusters at the cuts and goes quiet through a long static shot. Deictic marks only where the narrator pointed at something, so on a silent film the lane is empty and the emptiness is the finding. Clicking a mark seeks to that frame, and a click in one lane snaps to that lane's frames rather than to whichever uniform frame happens to be a second away. The transcript follows the film, every line seeks, and a reader who scrolls it themselves is left alone until they click a line.

a 24 minute narrated film, sampled frames per policyframes
uniform1453
scenecut49
deictic16
guided63

The filter rail puts the view in the address bar: which index answers, which sampling policy the frame search is limited to, and the decade, audio, and length of the films shown. The first two run the query again; the last three are applied in the browser over the rows already there, and each group is counted over what the other two leave, so no filter offers a number the click does not deliver. On a phone the grid is two columns and the rail is a bottom sheet.

measured on the deployed build, mobilehome with resultsfilm page
Lighthouse performance9870
Lighthouse accessibility100100
largest contentful paint2.2 s14.1 s
fused search, live in Chrome
placeholder grid on screen0.3 s
transcript matches on screen0.6 s
frame matches appended0.8 s

The film page's largest paint is still the video. A poster cropped from the matched tile paints before the video on a normal connection, but the sheet it is cropped from is over a megabyte, and on a slow simulated phone connection the video's own first frame beats it. A small poster image per film, written when the film is transcoded, is the fix, and it is recorded for the performance pass rather than done here.

Type "a man pointing at a map". The first row is six men at maps. Press Tab, arrow to the fifth card, press Enter, and the player opens on a man at a chalkboard map at 21:12 with the stamp reading "at 21:12". Under it, four lanes of marks show where each sampling policy looked.

Tests went from 242 to 434 in the web project. The review found sixteen things and fifteen were fixed before the merge, most of them at the seams: the fusion, the facet counts, the tab stop under virtualization, the tick snapping, and the transcript's scroll were each right on their own and wrong where they met.

Sep 8, late
landed
PR #13
health at risk
built Claude Sonnet 5
read Claude Fable 5.1

The app can only read

A read-only security review ran earlier the same day against the search slice and found one high finding and eight lower ones. Nothing critical, but the highest one was plain: the identity the app used to reach the database was the schema owner, so any bug in the query path was a rewrite of the index rather than a read. This entry is the fix list, in the review's order.

The app now connects as a role that can select from the five tables and call the two search functions, and nothing else. It cannot create, insert, or delete, and a query it starts is cut off after eight seconds. The owner credential stays with the migrations and the indexer, which run on the Mac. Every response the site sends now carries the standard hardening headers and a content security policy that names the page's own origin and one analytics host. The route that streams video and sprite sheets serves only the three file shapes the pipeline writes and answers 400 to anything else. The four probe routes from Day 0 are gone, along with the version string one of them printed.

The check that keeps keys out of the repository grew. It used to inspect only commands that mentioned git; now it inspects every shell command the tooling runs, refuses one that names the secrets file outside git, and knows more key shapes. The same scan is installed as a real git pre-commit hook, so a commit made from a terminal is covered too. The tool allowlist lost its two pipeline targets that write to the database and its open-ended curl.

check, after deployresult
tables the app role can read5
tables it can write0
search calls per 10 seconds before a 4295
curl on the tower wake route403
tests, index / tower / web307 / 12 / 242

The first deploy broke the site for a few minutes: routing every request through the Worker so the headers reach static files meant the framework stopped serving those files at all, and every script, stylesheet, and font answered 404. The fix asks the asset store first and falls through to the framework on a miss.

Nothing looks different. Type the same sentence, click the same result, and the player seeks to the same frame. The difference is what a bug in that path could now do, which is read.

The review's most useful work was on the secrets check. It reproduced seven ways past it and one false positive, and each became a test. One hole stays open by design: a script body that reaches the secrets file without spelling its name.

Sep 8, night
landed
PR #10
health on track
built Claude Opus 5
read Claude Fable 5.1

The site gets its look, and every accent on it starts to render

The first look, chosen in P2, did not hold up once the log page had real content on it: a thin display serif read as decoration, and the whole thing looked like a template. Three directions were drawn up and one picked. Bulletin: industrial and utilitarian, set the way a 1950s technical bulletin and a film can label are set, which is what the corpus is made of. Blunt display headlines, a long-read serif for prose, a monospace for every count and timestamp, hairline rules instead of card boxes, and one red mark per view.

Three faces, self-hosted, and no others: Bricolage Grotesque for headings and counters, Source Serif 4 for prose, IBM Plex Mono for data and labels. A test refuses a fourth face through any import path. Contrast is measured from the token values by a test that parses the table in the stylesheet's header comment, so a nudged color fails the build rather than going stale.

fonts on first paintvalue
first cut of the branch351 KB, 5 files
after dropping the unused italic and a mono weight169 KB, 3 files
shipped, static weights, one file per family100 KB, 3 files
deployed, mobilehomelog
Lighthouse performance9694
Lighthouse accessibility100100
largest contentful paint2.5 s2.8 s

The review's blocking finding was not in the new look. Tailwind puts its utilities in a cascade layer and the site's global rules were unlayered, so every accent and ink override on the site had been losing since P2: the red mark rendered grey, the ink rules rendered grey, and the log's links had never been red. The globals moved into layers, and the deploy now cleans the build directory first, since earlier deploys had been shipping 73 font files where 14 are used.

What the live piece can do now: read as one committed idea on a phone and a laptop, in light and dark, with the red on the one thing per page that matters: the search field on the home page and the newest entry's stamp here.

Sep 8, late
landed
PR #8
health on track
built Claude Opus 5
read Claude Fable 5.1

The build log moves onto the site

This page. One entry per landed issue, checked into the repo as a markdown file next to the code it describes, rendered at build time with no call to the tracker at request time. A reader without a seat in the issue tracker gets the whole build here, and can go from an entry to the thing it describes in one click.

Each entry carries the numbers the issue produced and what the review caught, and names the model that built it and the model that read it. The plan table below has a model column. The "How it is built" section says what each of the four steps does and which model runs it.

deployed, mobilevalue
Lighthouse performance98
Lighthouse accessibility100
largest contentful paint2.3 s
HTML on the wire, gzipped22 KB

The closing step of every issue now writes the next entry and flips the plan row in the same change as the tracker's status update, then deploys, so this page and the tracker never disagree. A test parses every entry and fails on a bad field, so a wrong date or a ragged table fails the build rather than the page.

What the live piece can do now: show its own build log at /log, with the stat strip above reading films, hours, frames sampled, and frames embedded from the database and printing dashes when it does not answer.

Sep 8, late
landed
PR #12
health on track
built Claude Sonnet 5
read Claude Fable 5.1

One query seeks to one frame

The piece had a hole until a sentence typed into the search field ended with the player sitting on the frame that matched it. This entry closes it. The text half of the same SigLIP checkpoint embeds the query, one SQL function finds the nearest frames, the best frame per film wins, and the film page seeks the player to that second. The same function serves the keyword path from the transcripts, and it is the function the evaluation will call later, so there is no separate search for measuring.

Provenance is on every row. A frames result shows the sprite tile nearest the matched second, cropped out of the film's own sheet; a transcript result shows the sentence with the matched words underlined. The tile is the sampled frame at or after the even second below the match, so it can sit up to two seconds early. That is stated rather than hidden, and the grid work fetches the exact frame.

The text model runs in a container that sleeps after ten minutes and takes about fifty seconds to wake. The first visitor after a quiet spell used to wait close to a minute and then be told to try again. Now the page asks the model whether it is up before sending anything, and when it is not, says so, shows a clock, checks every five seconds, and redraws the results in place the moment it answers. Nothing to retype.

frame_search, k = 200, 81 films with vectorsrowslatency
no policy filter20087 ms
uniform20028 ms
scenecut2002748 ms
deictic20078 ms
guided200114 ms

Before the review, every one of those rows read 40, and the filtered ones were falling off the index onto a full scan. The fix is two settings on the function itself rather than on the connection, because the connection is pooled and a session setting would leak onto the next request.

deployed, mobilehome with resultsfilm page
Lighthouse performance9974
Lighthouse accessibility100100
Server-Timing on a warm search, embed / knn / why1359 / 48 / 451 ms

The film page's score is the video element itself, which is its largest paint and has no poster yet. The scenecut latency is the sparse filter walking a long way through the graph. Both are recorded for the issues that own them.

Type "a man pointing at a map". Click the first result. The player opens at 9:39 in a 1954 civics film, on a man in front of a wall map, and the stamp under it reads "at 9:39".

The two things that looked like bugs and were not: a headless browser without an H.264 decoder cannot load these files at all, and Chrome will not load media in a tab that is not visible. The seek was working the whole time. The end-to-end test is gated behind a flag for exactly that reason, and the check that counts was done in a real browser with the tab in front.

Sep 8, evening
landed
PR #7
health on track
built Claude Sonnet 5
read Claude Fable 5.1

The image tower runs over every sampled frame

Each film's frames go through the SigLIP image tower on the Mac's GPU, one vector per frame, and then each policy's frame vectors are mean-pooled into one clip vector per film and policy. HNSW indexes are dropped before the load and rebuilt after it, because building them row by row costs more than rebuilding them once.

measured on four filmsvalue
frame extraction, 8 ffmpeg workers98 fps
embedding alone, batch 16, float32 on MPS8.3 fps
blended, extraction overlapped, the Mac busy6.1 fps
projected corpus run, 264,638 frames9 to 12 h
a frame vector with its index entryabout 15 KB
projected frame vectors plus indexabout 4 GB

The review's first finding was not in the code. The database was on a plan capped at 512 MB, which the projection above passes, with the index dropped for the load, about 60 films in, so the corpus run would have died most of the way through a 10-hour job. The database moved to a usage-priced plan with the compute capped at 2 CU and a spending alert on it before anything started.

The other eleven findings were fixed on the branch. An index-runs row mixed cumulative counts with per-run counts, so a resumed run would have reported numbers nobody could add up. A derived number was labeled as wall time. A memory-stepping loop could never run. A frame file truncated by a kill would have been picked up and embedded by the next run.

The smoke query "a man pointing at a map" over four films returns a man in a lab coat reading a large sheet, 269 seconds into a 1952 film about house siding. Not a map, and nobody pointing. The corpus run started at merge.

Sep 8, midday
landed
PR #6
health on track
built Claude Sonnet 5
read Claude Fable 5.1

Sampling policies decide which frames get embedded

Four policies pick frames. Uniform takes one frame a second. Scenecut takes ffmpeg's scene cuts. Deictic takes the moments the narration points at something, the "here", "this", "you can see", "look at". Guided is scene cuts plus deictic. A frame that two policies pick is one row with both names on it, so it is embedded once, and each film records which policies have run so a rerun picks up where it stopped.

policyframesper hour
uniform239,1243,602
scenecut22,257335
deictic3,88159
guided24,738373

The corpus run over all 300 films took 30 minutes of wall time. Guided picks 9.7 times fewer frames than uniform. The deictic frame at "you will notice the operator has to hand wind the take-up reel" is the operator at the projector, which is the kind of frame a narrator hands you for free.

The review caught 8 things, the biggest being resume logic keyed off frame rows, which broke for silent films: a policy that legitimately samples nothing looked like a policy that had never run. One question is still open, that bare "this" is 11 of the 12 deictic hits on the smoke film.

Sep 8, morning
landed
PR #4
health on track
built Claude Sonnet 5
read Claude Fable 5.1

Transcripts and a keyword baseline to beat

Every film's narration is transcribed on the Mac's GPU into segments no longer than 10 seconds. A keyword query returns the segment where the words were said, with the matched words marked, through one SQL function that the web app and the eval both call. There is no separate eval mode, which is the only way the eval numbers mean anything about the app.

transcription, full corpusvalue
films with speech241 of 300
words in the table365,425
one 13-minute film, transcribed34 s
common single-word query, Postgres time0.36 s
query that matches nothing1 ms

The review took two passes and both were worth it. It found a fallback that answered a split phrase with a made-up time of zero, and a per-film cap that made any later occurrence of a common word unreachable. Both would have shown up in the eval as wrong seeks, and both would have looked like a retrieval result rather than a bug.

Sep 8, morning
landed
PR #5
health on track
built Claude Sonnet 5
read Claude Fable 5.1

Corpus encoded, sprite sheets built, media on R2

Every film is now a 720p faststart encode on R2 with a sprite sheet beside it and a JSON index that says which frame sits in which tile. A player can seek to a time and a scrubber can show the frame at that time without a round trip.

filmshoursGB on R2audio above the floorno audio track
30066.427.032504

About 51 seconds a film, 4.3 hours of wall time, one pass on the Mac with the raw files pulled from the NAS. A Range request on the media domain returns 206.

The review caught the one thing that would have undercut the whole piece: the sprite tile labeled t held the frame from almost a second after t, because ffmpeg's fps filter rounds to the nearest slot. It is fixed, and a test now reads the frame index back out of each tile rather than trusting the math.

Sep 7, late
landed
PR #2
health on track
built Claude Sonnet 5
read Claude Fable 5.1

Schema, local database, and a designed home page

The Postgres schema creates films, frames, frame vectors, clip vectors, and transcripts, with HNSW cosine indexes on both vector tables and a generated tsvector on transcripts. The 1152 vector width is tied to the checkpoint constant by a test, so a change of checkpoint fails the suite instead of silently writing the wrong width. make dev brings up pgvector in Docker, applies the schema, and runs the web app against it.

What the live piece can do now: the home page paints its heading before the database answers, shows three card-catalog counters that read zero while the corpus is unindexed and a row of dashes when the database does not answer at all, and says why search is disabled. Cormorant Garamond, Crimson Pro, and JetBrains Mono, self-hosted, light and dark.

The review found 15 things. The three that mattered were a wrong argument to a trigger over a generated column, counters that overflowed their column on a phone, and no loading state at all. All three were fixed before merge.

Sep 7, night
landed
PR #1
health on track
built Claude Opus 5
read Claude Fable 5.1

The hosting decision, measured rather than argued

The app runs as Next.js on Cloudflare Workers, and every probe passed on the deployed Worker: a streamed shell at 0.21 s ahead of a 1.6 s child, a route handler that flushes chunks 500 ms apart, R2 byte ranges that answer 206, 416, or 200 correctly, and Postgres on Neon through Hyperdrive at a 66 ms median. The text half of SigLIP runs in a Cloudflare Container behind a token-gated Worker. Vectors from the container match the Mac to 2e-07.

whereembed p50cold start
Mac, CPU81 ms1.6 s load
Container, standard-21,162 msabout 50 s

That container number is the risk to "instant" and the decision doc says so. The cheap answers come first: cache query vectors, and stream keyword hits before the vector results arrive. An int8 ONNX text tower is the structural fix if that is not enough.

The adversarial review found fourteen things, every one fixed and re-verified against the live Worker. Two of them mattered: a Range handler that returned the whole file on a malformed header, and a tower endpoint that any crawler could have kept billing.

Sep 7, evening
landed
health on track

The repo exists and the corpus is downloading

Corpus selection picked 300 Prelinger films with a fixed seed: 2 to 30 minutes long, no home movies, an h.264 derivative preferred where the archive has one. A stdlib Python script pulls them to a NAS and resumes if it is interrupted. The hours and the size below are the archive's own metadata, before any encode; the 66.4 hours in the later entries is what came out of the transcode.

filmshourssizewith a year
30066.325.0 GB257, 1906 to 2001

The build tooling from an earlier project is ported: a hook that refuses a commit carrying a key-shaped line, lint after every commit, and the issue-start, review, issue-close loop that produces these entries. Day 0 predates the loop, so it is the one entry here with no implementer and no reviewer named.

How it is built

Three pictures of it

Each one is drawn from the repository rather than from a plan, so every box is a file or a binding and every arrow is a call the code makes. The sources are under web/content/diagrams.

01 / SystemWhat runs where
Clip Portal: what runs where A map of the deployed system. A browser and an MCP client call the web Worker on Cloudflare. The Worker reads film rows and frame vectors from Neon Postgres through Hyperdrive, reads sprite sheet JSON from R2 over its MEDIA binding, and posts a query to the token-gated text tower container. An hourly budget checker writes a pause flag to shared KV when the period reaches its ceiling, and clamps and suspends the Neon endpoint at the same moment. An offline indexing host and a NAS sit outside Cloudflare and write vectors and media in. Browser · clip-portal.eknuth.dev · Architecture component Browser clip-portal.eknuth.dev MCP client · search_clips, get_film, get_clip · Architecture component MCP client search_clips, get_film, get_clip Web Worker · Next.js on vinext · Cloudflare · clip-portal-web Web Worker Next.js on vinext clip-portal-web Hyperdrive · connection pool · Cloudflare · DB Hyperdrive connection pool DB R2 · video, sprite sheets · Cloudflare · MEDIA R2 video, sprite sheets MEDIA Tower Worker · token gate, daily cap · Cloudflare › TOWER_TOKEN, 500 embeds a day · clip-portal-tower Tower Worker token gate, daily cap clip-portal-tower Text tower · FastAPI, SigLIP text · Cloudflare › TOWER_TOKEN, 500 embeds a day · standard-2 Text tower FastAPI, SigLIP text standard-2 Workers KV · pause flag and counters · Cloudflare · BUDGET Workers KV pause flag and counters BUDGET Budget Worker · hourly cron, $25 ceiling · Cloudflare · clip-portal-budget Budget Worker hourly cron, $25 ceiling clip-portal-budget Neon Postgres · frames, vectors, text · Architecture component · pgvector Neon Postgres frames, vectors, text pgvector Indexing host · SigLIP image tower · Offline · MPS Indexing host SigLIP image tower MPS NAS · raw and encoded film · Offline · 300 films NAS raw and encoded film 300 films HTTPS POST /mcp frame_search pooled endpoint Range 206 POST /embed, token cold start, 150 s ceiling embeds today reads the pause at the ceiling clamps CU, suspends frames, vectors video, sprite sheet reads film Cloudflare TOWER_TOKEN, 500 embeds a day Offline Legend Backend Database Cloud External
Everything a request touches runs on Cloudflare. The indexing host is offline and no reader ever reaches it. Dashed edges are the offline ones.
Request time
A search embeds the text on the tower, then runs frame_search through Hyperdrive · Film rows come back from Neon; only sprite.json comes out of R2 · Video and sprite sheets reach the browser through the Worker's own /api/range
Offline
Dashed edges never run during a request · The Mac reads film off the NAS, uploads the encode to R2, and writes vectors to Neon · Nothing in the browser path touches the NAS or the Mac
The ceiling
Neither provider sells a hard cap on dollars · The checker runs hourly and writes budget:paused only when the period reaches $25 · At the ceiling it also clamps the Neon endpoint's max CU and suspends it
02 / PipelineHow a film becomes vectors
Clip Portal: how a film becomes vectors The indexing pipeline in five stages. 300 films picked off archive.org are downloaded to a NAS, encoded to 720p with a sprite sheet, and transcribed with word times. A sampler applies four policies over 264,638 frames, and the SigLIP image tower on a Mac embeds them into pgvector; a tail guard drops any timestamp within 0.25s of a film's own duration before extraction, since a video stream's last decodable frame sits about one frame period before it. 01 / Select 02 / Fetch 03 / Prepare 04 / Sample 05 / Embed archive.org · public domain · 01 / Select · 300 of 1,508 archive.org public domain 300 of 1,508 NAS · raw mp4 · 02 / Fetch · 25.1 GB NAS raw mp4 25.1 GB transcode.py · 720p faststart · 03 / Prepare · 66.4 hours transcode.py 720p faststart 66.4 hours transcribe.py · whisper, word times · 03 / Prepare · 365,425 words transcribe.py whisper, word times 365,425 words R2 · video, sprite sheet · 05 / Embed · 27.03 GB R2 video, sprite sheet 27.03 GB sample.py · four policies · 04 / Sample · 264,638 frames sample.py four policies 264,638 frames tail guard · duration boundary · 05 / Embed · 0.25s margin tail guard duration boundary 0.25s margin embed.py · SigLIP image tower · 05 / Embed · MPS embed.py SigLIP image tower MPS pgvector · frame and clip · 05 / Embed · 1,152 dims pgvector frame and clip 1,152 dims fetch.py then download.py raw film one at a time audio track 241 narrated video and sheet to the browser duration, cuts scene over 0.3 here, this, look word start sampled frames one row a timestamp boundary timestamp dropped before extraction one vector a frame pooled per policy Legend primary data tail guard data store uniform 239,124, scenecut 22,257, guided 24,738, deictic 3,881
Four sampling policies decide which frames are worth a vector. Guided takes 9.7 times fewer frames than uniform over the same 66.4 hours.
Frames per policy
uniform 239,124, one frame a second · scenecut 22,257, ffmpeg scene change over 0.3 · guided 24,738, the two together, deictic wins a collision · deictic 3,881, the frame at the narrator's pointing word
What the run does not cover
A frame two policies both sampled is one row and one vector, tagged with both · A timestamp within 0.25s of a film's own duration is dropped before extraction: a video stream's last decodable frame sits about one frame period before its recorded duration
03 / QueryFrom a typed query to a seeked frame
Clip Portal: from a typed query to a seeked frame The request sequence for one search. The Worker embeds the query text on the tower, runs frame_search over frames through Hyperdrive, loads the ranked films' rows from Neon, reads one sprite sheet index out of R2 over the MEDIA binding, and answers with JSON. Clicking a result opens the film page, which asks the Worker for a byte range of the video and seeks the player to the matched second. q, path, policy /embed, token header 1,152 floats, cached frame_search(qv, model, policy, k) best frame kept per film film rows for the ranked ids title, year, sprite key sprite.json, MEDIA the tile at t 200 JSON, Server-Timing /film/<id>?t= film page, poster at t GET /api/range video.mp4, 206 206, and the player seeks to the matched frame search.embed search.knn search.why the seek Browser · Sequence participant Browser Web Worker · Sequence participant Web Worker Text tower · Sequence participant Text tower Neon · Sequence participant Neon R2 · Sequence participant R2 Legend request return the seek default message
This is the frames path. The hybrid path runs a keyword leg alongside it, each leg loading its own film rows before the two are fused at k = 60. The last arrow is the point: the player opens on the frame that matched, not on the title card.
The measured path
Read off the traces on the 8e3d2f9 build, forty searches on a warm tower, n 38 · GET /api/search 2,990 ms at the median, embed 1,433, knn 1,029, why 389 · The embedding is cached in the isolate, so a repeated query never pays the tower again
Why the last arrow is the point
Every result carries the second its frame was sampled at · The card links to that second, not to the film · The player opens on the frame that matched, not on the title card

The loop

Every issue runs through the same four steps, and the tooling for the steps is checked into the repo as skills and hooks. The person in the loop is Ed, who writes the issues, answers the review's questions, makes the design and spending calls, and says merge.

StepModelWhat it does
/issue-startClaude Fable 5.1, the orchestratorReads the handoff memory and the issue, branches off main, marks the issue In Progress, and hands the whole spec to one implementer subagent on the model the issue is labeled for. The orchestrator does not write the code.
implementClaude Sonnet 5 or Claude Opus 5, per the issue's labelWrites the code and the tests and commits on the branch. It never pushes, never touches the browser, and never posts to Linear. Opus gets design, ground truth, and writing; Sonnet gets well-specified implementation.
/reviewClaude Fable 5.1, fresh contextAdversarial, two lenses, with no memory of having written the thing it is reading. Findings go back to the implementer. A second review only happens when the method or the numbers changed.
/issue-closeClaude Fable 5.1, after Ed says mergeMerges the PR, marks the issue Done with a results comment, posts the status update, writes the entry above and the plan row below in the same commit, and leaves a handoff memory for the next session.

The two review lenses

Correctness
Every claim in the implementer's report is checked against the code or against a live query. A finding has to be reproduced to be a finding. Anything the reviewer cannot reproduce is filed as a question instead.
Reader
Would a UX-trained front end lead find the change more direct, faster, or more honest about where a result came from. A change that passes correctness and fails this one still goes back.

What holds it in

A git pre-commit hook, installed with make hooks, scans the staged diff for key-shaped lines and a staged .env before a commit lands. A second check runs before every shell command the tooling itself runs and refuses one that names .env outside git. A third hook runs lint after every commit and says so when it goes red. The permissions allowlist admits read-only commands, local builds and test runs, curl against the project's own hosts, and the Linear read tools, and nothing on the list writes to GitHub, Linear, Cloudflare, or Neon. Browser work runs over the Chrome DevTools Protocol against Ed's signed-in browser and stops at a login wall, at MFA, and at a payment step.

What the reviews caught

IssueWhat the review caught
P3517 findings, every number in the post traced to a file and confirmed. The figure printed one build and one runner under six bars that came from three passes on three builds. The cold column on the pgvector rows was retries only, because those passes predate the dispatch stamp, and the post said a rule had been applied to them. The month was written as read off the billing page when the page read $1.41 to date and $4.60 was the plan. Two negation-framed sentences.
P2718 findings, every number verified against the span store and the record. The figure numbers ran backwards from the order a reader meets them. The post credited two fixes with a third fix number and dated a worst case to the wrong build. The eleven sub-second cancellations were folded into a sentence that put every failure at the ceiling. The uniform transition carried the guided scan block count. Four questions the record could not answer are now marked as unknown in the post.
P1426 findings over two passes. The page labelled a vendor search as hybrid and had no branch for its two failure states. A floor left null made every negative read as cleared, and the eval table would have printed the vendor rejecting nothing as if measured. The card stamped a six second span as a matched frame. The decision doc quoted a per minute rate that belongs to a different product. The budget notice printed a raw error fragment and the not configured notice named the vendor on the page.
P409 findings on the first pass and 10 on the second, one blocking. Backfilling the new column under the existing vector index was a graph walk per row, measured at fifteen minutes per hundred thousand rows on the Mac, which would have blocked search for most of an hour; the migration was reordered to drop the indexes first. The trigger fired on every resample whether or not anything changed. The docs had the outage at eight minutes when the rebuild inside its own transaction indexes every row twice.
P3810 findings. Until the tiles were uploaded, a missing tile left an image element mounted and Chromium drew its broken-image glyph over every card for as long as the sheet took. The upload's resume marker could be written while an earlier tile had failed, hiding a hole nobody would see. The measurement script would have reported a 404's response time as a first-tile median. The sheet was sometimes fetched twice because the media route forbade caching.
P3715 findings on the first pass and 14 on the second. The first cause written down, an audio track running past the video, was wrong for 8 of the 13 films. The first guard was smaller than one film's measured gap and its docstring argued backwards. The eval note filed two real losses as drift: a newly vectored film took first place from a right answer on one query and pushed the right frame out of the top ten on another.
P3423 findings over two passes. An exception chain carried the object store message, endpoint included, into an error span the sampler always exports. A missing distance column defaulted every score to 1.0 and cleared every floor. A cold Neon wake was booked to one store and nowhere on the other. Dropping the dataset handle on any failed query threw the warm index cache away, so a single 5xx turned the next reader into a container's first query.
P338 findings on the first pass and 9 on the second. The recall number, 0.99, was a self-query artifact: the sample queries were stored rows, and any index finds a vector's own row. Measured on the real text queries the shipped index scored 3 of 100. A policy filter combined with a nearest-neighbour query returned nothing at the library's defaults. And the knob that mattered was missing: re-ranking the candidates against the exact vectors takes the replacement index to 0.99.
P3212 findings. The share URLs in the state table did not exist yet. No test clicked a retry. The story behind the narrow-viewport fix blamed a grid track that was already sized right, when the fix was letting a long archive id wrap. And with scripting off, the new "reading your set" line never cleared, a dead end the branch itself had introduced.
P2612 findings on the first pass and 12 on the second. The page's default search runs over every policy at once and no negative had been measured on that union, so its floor was borrowed from one policy. The no-match state appeared on a hybrid search before the second leg had answered. The keyword floor was presented as if it had done the rejecting when the empty list had. The seven control queries were asserted everywhere and recorded nowhere.
P3915 findings, one blocking. At the column width the labels in all three drawings rendered at four to seven pixels, because the drawing tool sizes type in drawing units while the drawing scales to its column. The count cards, including the four per-policy frame counts, were viewer panels the export dropped, so they were on no page. The query sequence had the sprite index going through the wrong path, the video going to storage directly instead of through the Worker, and the two hybrid legs drawn in series.
P36Storing the time of every word inside the transcript row doubled every term in the keyword index, which moved the keyword ranking by 14 to 70 percent per film. The index is now built from the segment text alone, and six probe queries reproduced the old ranking exactly before and after the change.
P3112 findings, none blocking, and a second pass that traced every connection path and probed the reference parser with thirty inputs. A frame search page still offered links to the keyword and hybrid paths, each of which answered with an error. A retried vector lookup that ended in no vector leaked the fresh database connection. A hand-edited second of 1e40 reached Postgres and came back as a server error instead of a bad request.
P1614 findings on the first pass, two of them blockers. Neon's storage metric is byte-months, not byte-hours, so the checker priced storage 730 times too low; and the Launch plan cannot set scale to zero to a number, so the clamp it sent would have been refused every hour. Both fixed and then proven against a live read, which also corrected the reviewer on one point: a first billing period ends at the calendar month, not one month after it started.
P1514 findings on the first pass and 6 on the second, nearly all of them numbers the prose had overreached. The branch had marked itself active while another issue already was, which the plan test forbids. Hook refusals were not counted at all, then the counter matched the hook's own source when a tool read it. The secrets-file family counted the example file and then matched process.env in source being written. Each fix moved a number and the tables were regenerated.
P1213 findings on the first pass and 12 nits on the second. A render timing was reported that the site had never sent, and could never send, because the Workers clock does not advance across synchronous code. The page-load half of the latency test reused the query the API half had just fetched, so a per-isolate embedding cache made the paint number faster than the API call itself. The slow-link explanation blamed one sprite sheet when the network audit showed twelve in flight at once.
P307 findings, 1 blocker. Every table timed one query repeated, the shape that flatters an index walk. Six novel guided queries on a resumed compute cost 4 to 6 s each with no decay, where the old scan cost half a second after its first two. The write-up was rebuilt around that number and the hosting trade put to Ed. The uniform explanation was wrong (an uncharged cost cannot move an estimate) and deictic's plan had no number behind it; both fixed.
P296 findings, 3 fix-before-ready. The mechanism explanation was wrong in three places. The generic plan loses HNSW because of unknown `limit k` (10% assumption) and the policy filter's unknown selectivity, not because of the parameterized vector. The pg17 description was backwards. Both fixed before merge.
P21One pass, 13 findings, none blocking. The tool calls were invisible in tracing because the handler streams its response before the tool finishes, internal errors were swallowed with no log, the doc's error and latency claims were off, one test was tautological, and the same-order test proved the mapping rather than the equality. All fixed, and the foundation was then swapped on the review's size and deprecation numbers.
P20Three passes, 25 findings. The first found the wait bolted on outside the Durable Object, where an AbortSignal cannot cross the stub, a short budget could mark the container healthy without a port check, and every warm request paid a ping and a storage write. The second found one caller's abort answering every joiner and an exit-code claim the code could not keep. The third, on the wall-clock ceiling, found prose and one loose test. The live measurements sent the numbers back twice.
P2518 findings across two reviews. The first found that the guided measurement was not actually cold, that the connection shape did not match the app, that a prepare_threshold guard was vacuous, and that the document overcorrected away from coldness being the issue. The second found 10 double hyphens standing in for em dashes and a conclusion that described the mechanism without stating the cost driver.
P248 findings, one that went to the heart of the change. The strip was argued not to move on the grounds that six characters is six characters in a monospace face, which is true of the real face and false of the proportional one the browser uses while it loads. In that window a row of em dashes measured nearly twice the width of the count it stood in for, and on a phone that took the strip from two rows to three every time the numbers arrived. The test written to prove the strip did not move compared markup, and this is geometry. The end-to-end test also measured a histogram's heading rather than the histogram, so bars falling under the fold would have passed.
P2310 findings, one blocking. The first version of the fix wrapped each query in its own transaction, which left every follow-up query on the same connection exposed to the leak it was meant to close. The measurements behind the decision note were taken as the database owner, whose pool is not the pool the app uses. The note described removing a line that had never been on main. And the live failure the acceptance gate counted was dominated by a cold database hitting a real timeout, not by the bug being fixed.
P2220 findings, four of them blocking. The container's automatic instrumentation never actually turned on, because it was set up inside the startup handler, after the framework had already built and cached the thing it patches, and it reported success anyway. Stage spans were started before the slow call and ended after it with no guard, so a query that failed lost the very span that would have named the failure. Sampling rolled its own dice in each of the three runtimes, which would have left about four traces in a hundred whole. And a flag called cold start was reporting a sixty second cache expiry instead.
P1113 findings, six to fix before the PR was ready. The frame count in the table came from one indexing run's row and disagreed with the corpus document by fourteen thousand frames; it is now counted from the index itself. A partial run could have overwritten the committed table with part of one. The generated document carried em dashes, the worst-five lists were the five lowest ids among the misses rather than confident wrong answers, and mean reciprocal rank was never defined.
P1014 findings, five to fix before the PR was ready. One spoken row's timestamp sat ten seconds outside the window the writer had watched. Two visual rows described something the frame at that second did not show, a plane that had already flown out of the shot and a baby who was not in it. The README argued the runtime mismatch in the wrong direction. A long film id ran across the contact sheet into the next cell.
P918 findings, one blocking. The share route accepted ids of any length, so one loop could fill the key-value store with multi-megabyte values under fresh keys. Silent films were counted in the narrated words-per-minute histogram. A seeded Reshuffle could never change which strata appeared when the sample was smaller than the strata count. The Add button was a second tab stop ahead of the card. Every load flashed an empty set before reading the real one, and one Drop blanked the whole page.
P816 findings, none blocking. The fusion step overwrote the sheet tile on every film only the transcript index had found, so the one path that was supposed to show more showed blanks. The rail counted facets over the whole result set, so it could offer "silent 2" from two films the decade filter had already removed and empty the grid on the click. A grid whose only tab stop was the focused card lost it when that card scrolled out of the virtual window. Clicking a scenecut mark seeked to the nearest uniform frame beside it. The transcript scrolled the whole page every ten seconds. Hybrid fetched every sprite sheet twice.
P1916 findings, none blocking. Most were ways around the new secrets check that the reviewer reproduced one by one, a git commit message that read the secrets file, a script piped to a shell, an uppercase filename on a case-insensitive disk, a curl that uploads. And the rate limit as first deployed would have blanked the provenance tiles, because a results page fetches ten of them in a second.
P1811 findings, one blocking. Every accent and ink override on the site lost to unlayered global CSS, so the red mark, the ink rules, and the accent links had never rendered. Also a contrast test that only checked the floor, a typography test a fourth face could slip past, data text at 11 px on a phone, and four links under 44 px.
P1717 findings. Two-column tables hid their numbers off-canvas on a phone, page strings the loop would never update, a skill step that claimed a squash merge could carry a second change, a protocol-relative link past the safe-href check, and an invalid month rendering as "undefined".
P717 findings, two blocking. The nearest-neighbour query returned at most 40 frames no matter what limit it was given, because pgvector's HNSW scan stops at its candidate pool and nothing had raised it. And the sprite tile every result was supposed to show never rendered, because the column it read was never written by anything. Also a cold-start path that made the first visitor wait a minute to be told to try again, a timing header that measured R2 under the name of the vector query, and a seek stamp that could state a time the player never went to.
P612 findings and 8 questions. The first was the database plan's 512 MB cap, which the corpus run would have hit about 60 films in.
P58 findings. Resume logic keyed off frame rows broke for silent films, where a policy that legitimately samples nothing looked like a policy that never ran.
P4Two passes. A fallback answered a split phrase with a made-up time of zero, and a per-film cap made any later occurrence of a common word unreachable.
P3The sprite tile labeled t held the frame from almost a second after t. That one would have undercut the whole piece, because the frame is the provenance.
P215 findings. A wrong argument to a trigger over a generated column, counters that overflowed their column on a phone, and no loading state at all.
P114 findings, every one fixed and re-verified against the live Worker. A Range handler returned the whole file on a malformed header, and a tower endpoint any crawler could have kept billing.

The plan

This table lists every issue, the model it is labeled for, and where it stands. The log above covers the ones marked done.

#IssueModelStatus
P1Day 0 decision: Cloudflare hosting spike and where the text tower runs, checkpoint pinnedopusdone
P2Repo skeleton, Postgres and pgvector schema, designed home pagesonnetdone
P3Corpus: 300 Prelinger films, 720p faststart, sprite sheets, R2sonnetdone
P4Transcripts with timestamps, tsvector keyword baseline, speech flagsonnetdone
P5Sampling policies: uniform, scene cuts, deictic, guidedsonnetdone
P6Embedding run: per-frame vectors and pooled clip vectors into pgvectorsonnetdone
P7Search API and the vertical slice: one result that seeks to its framesonnetdone
P8Results grid and film page: virtualized grid, keyboard navigation, scrub strip, a provenance frame on every cardopusdone
P9Evaluation set: selected clips, distributions, the speech and silent split, near-duplicates, manifest exportsonnetdone
P10Ground truth: about one hundred query to film and timestamp pairs, written against clips and never against the indexopusdone
P11Eval runner and results table: recall@10, MRR, median seek error, frames embedded, index timesonnetdone
P12Front end numbers: p50 and p95 query latency from the browser, time to first thumbnail, Lighthouse, scrub to seek latencysonnetdone
P13Ship: live URL, README written as the argument, a three minute video, MIT license, repo publicopusnext
P14A second retrieval path as a comparison columnsonnetdone
P15How it was built: the agentic loop as part of the piece, with numbers from the session transcriptsopusdone
P16Cost ceiling: hard caps where the providers allow them, a budget checker that suspends compute where they do notopusdone
P17Project log on the live site, one entry per landed issue, written when it landsopusdone
P18Visual identity, Bulletin: Bricolage Grotesque, Source Serif 4, IBM Plex Mono, cool paper and one red markopusdone
P19Security fixes before the repo goes public: read-only app role, security headers, rate limit, allowlist prune, range key validation, secrets hooksonnetdone
P20Tower cold start: the fronting Worker gives up on the container before the model has loadedsonnetdone
P21Agentic use: a remote MCP server on the web Worker, three read-only tools over the same search pathsonnetdone
P22OpenTelemetry across the web Worker, the text tower, and the index scripts, with one search trace and one dashboardsonnetdone
P23Frames path returns 500: search.knn cancelled at 6 ms with a statement timeout on a pooled connectionsonnetdone
P24The count row spends the first viewport: the search field and the histograms move above the foldopusdone
P25A cold database still fails the first search, and the 15 s budget was sized from the wrong querysonnetdone
P26A query with no answer in the corpus returns ten results that look exactly like ten good onesopusdone
P27Write-up: the budget that was sized from the ceiling, and what the traces said insteadopusdone
P28Remove P-number prefixes from reader-facing surfacessonnetcut
P29plan_cache_mode migration: force_custom_plan so the HNSW index stays in usesonnetdone
P30Fix guided's permanent sequential scan: an index or query rewrite so it uses HNSWopusdone
P31Click a tile to search: any result frame becomes the next query, from its stored vectoropusdone
P32Evaluate view state pass: every state a reader can reach from a shared URL renders, with no dead endopusdone
P33The index as one Lance table on R2, with build time and size recorded against pgvector'ssonnetdone
P34Serve search from Lance through the container, store as a query parameter, a store column in the evalopusdone
P35Write-up: two stores, one evalopusdone
P36Deictic sampler seeks to the pointing word, not the segment start (P5 follow-up)sonnetdone
P37Thirteen films have no vectors: ffmpeg refuses their frames as non full-range YUV, and the embed run never said sosonnetdone
P38The first thumbnail on a slow link waits for the whole sprite sheet: serve the matched tile as its own small image firstopusdone
P39Three architecture drawings on the live site: what runs where, how a film becomes vectors, and what happens between a query and the seekopusdone
P40The guided search walks a 2 GB graph for a 200 MB answer: a per-policy partial index so its cost matches its frame countsonnetdone
P41The perf script's query-latency test trips the zone rate limit at its 27th query, and its last four paint numbers are lostsonnetplanned
P42On a query page the first row of cards sits below a 720 px fold; the header compacts once there is a querysonnetin progress

Model column: opus is Claude Opus 5, sonnet is Claude Sonnet 5. The orchestrator and the reviewer are Claude Fable 5.1 on every issue.