Project logSep 7 to Sep 14, 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 14, night
landed
PR #46
health on track
built Claude Sonnet 5
read Claude Fable 5.1

Search results now visible above the fold

Problem: On 1280×720, results heading sat at 567px — user had to scroll to see any matches.

Fix: Compact header on query pages: - Title: 88px → 32px Bricolage h1 - Description removed - 7-word mono tag identifies site on shared links - Key hints on one line

Results:

PageResults headingFirst row ends
?q=a man pointing at a map266px673px (3 cards)
?by=Helicopt1953:384289px696px (2 cards)

Frame search tile: 72px → 44px, labels to screen-reader only.

Test fix: Fold check now builds Worker locally, confirms Bricolage loaded, asserts Results ≤300px.

Perf (b2187ef): search.knn p50 43ms, search.why p50 696ms (P40: 101/639ms). Embed slower (2,265ms vs 1,275ms) — cold text model, too few spans to call regression.

Tests: 1,209 web, 759 index/evals, 82 tower, 74 tower Worker, 55 budget — all green.

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

Problem: P33 wrote index 2nd time as Lance on R2; P34 served search from it. This issue = post about what that showed. 2nd write-up after deictic sampler, built same way: table → 3 findings → measurement → what it doesn't show.

Pipeline: - evals/post_table.py prints by-store table from evals/results.json (8 columns post uses). Test holds committed snapshot vs script output today. Eval rerun moving number fails suite → catches post falling behind. - evals/stores_figure.py writes 3 SVGs (light, dark, dark no ground for page painting own plate). - web/scripts/export-figure.mjs rasterizes at write-up column width with site's 3 faces in Chromium. - make figure-stores runs the pair.

Review found 4 places where number described less than row: - Figure footer named 1 runner + 1 site SHA for doc folding 3 passes. Percentiles = over 100 ground-truth cells (only ones route timed); caption said 127. - dispatched_at arrived after pgvector passes → cold column only ever holds retry. Now reads "not recorded" (was "no cold cell"). - Tick inside bar read as median mark → 1st one carries label.

Lead table:

metricpgvectorlance
guided knn p50 (route)98ms776ms
guided recall@10 (tuned)0.230.23
uniform knn p95 (warm)1,687ms1,668ms
container 1st query of life—11,561ms

2nd store returns 1st store's answers; charges for them elsewhere. Post argues: sampling policy decides vector count; store decides query cost. Two measured apart.

Post at: https://eknuth.dev/writing/two-stores-one-eval/. DB issue lessons as store findings: statement timeout never in effect, cold DB failing 1st search, plan cache switching at 6th query, guided path never touching graph it walked. Each carries number + span/query that found it.

No request path changed. docs/cost.md untouched (default store same). pgvector still answers every page search. Lance still on ?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

Problem: P23 moved search timeout 8s→15s. Number behind move = 1 measurement (8.5s, stub vector, no policy filter, never the slow path). 90s post-deploy: 2 searches cancelled at 15,125/15,131ms. This issue = write-up of what happened next, published at eknuth.dev/writing/the-budget-that-measured-itself/.

Argument: Timeout = instrument that reports own setting. Every failure past 1s in those 2 days sits at deployed ceiling: 8,119–8,213ms (build 4208685), 15,125/15,131 (b26a6fa). 11 shorter cancellations on 4208685 = leaked timeout from earlier caller (mechanism 002 leaves open). Read as durations → look like slower query news. Read as what they are → same query cut at different number. Tail censored at ceiling → looks like fixed cost → invites fix aimed at wrong thing.

2 plates (tools/budget-figure.py in eknuth.dev drafting identity): - Lead: every search.knn span on builds either side of ceiling change (231 spans). 1 mark/span. Cancellations = own shape at duration cut. Band between slowest answered span and ceiling drawn empty on purpose. Script reads span dump checked in under site's tools/data/. Dump carries query + window → rerun regenerates plate from same rows.

Build b26a6fa (210 spans, 21:05–21:22 UTC):

metricms
p501,249
p902,204
p998,896
slowest answered8,933
answered 8,933–15,000none
cancelled15,125 & 15,131

Inline figure: trace dd1502a08c47641cfe64d58764538406 (build b5ec1d5), span-by-span: root 10,227ms, tower health 263ms, embed 1,051ms, knn 7,983ms, why 652ms, render 0ms. Children = 9,949 of root's 10,227. Gaps = 278ms (162 either side of DB span). Time = inside query execution. Plate draws same function beside at same scale, measured from script on Neon pooled endpoint (no Hyperdrive). Read: 46ms uniform, 405–434ms warm guided. Span carries search.path, no policy (policy attr arrived later build). 7 requests in 70s after: 407–542ms (guided's warm band). Post marks as inference from timing.

Post states what P25, P29, P30 found vs P25's hypothesis: - Plan-cache half held: PG18 caches SQL function plan per backend session. Unknown limit + policy filter's unknown selectivity each push generic plan off graph. Hyperdrive reusing session across requests = what lets 6th execution happen. - Guided half wrong: no switch to make; row estimate right (24,982 vs 24,738). Price wrong: vector(1152) in TOAST → seq scan costed on 20MB heap while 1,322MB detoasted costs nothing. 40s reading stays, explained. Same plan-cache transition on compute with nothing resident. Generic plan reads 173,340 blocks off disk vs 294,408kB shared_buffers. Caught at 41,367ms on 5th warm reading (6th execution); 48ms with force_custom_plan. P25's 3 readings >30s all within 1min of postmaster restart. 41,367 run records no compute state → post keeps it beside set, outside it.

Site now carries 3 field notes. Nothing in repo changed except log, plan row, 2 pinned date ranges in 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

Problem: Every path runs on SigLIP over project-sampled frames. This adds column from commercial video embedding model (Marengo 3.0) via vendor's free tier: 10hrs video vs 66.4hr corpus. Films chosen by ground truth, byte order of archive ID, until 10hrs ran out. 42 films in, 5,456 segments (~6s each). Vendor meter closed at 9h51m. 1 film refused (320×240 < vendor floor). Last 4 refused when allowance ran out. Eval scores vendor path over 42 queries whose film it holds; rest = "not covered" (separate state from miss).

Results (42 covered queries):

pathrecall@1recall@10film in top 10MRRmedian seek err (s)
marengo0.480.570.980.526.5
frames/uniform0.290.430.86——
frames/none0.260.400.86——
frames/guided0.210.210.69——
keyword0.170.190.31——

Vendor result = segment. Card: segment 4:24 to 4:30, seeks to start. Vendor text embed ≈220ms. NN over 5,456 rows = 17ms. Floor = 0.0749 (same 20 negatives). 5/16 tuned negatives turned away at no hit cost.

Vendor searched smaller haystack (said so in entry). Index = 42 films (7× smaller than SigLIP paths). Right film in top 10: 98/100 covered queries. Uniform: 86. Surviving number = seek. Of films each finds, vendor lands within 10s on 24/41. Uniform: 18/36. Vendor median seek = 6.5s vs uniform 8.5s (same 42 films). Uniform's 10.8 in evals/results.md = median over all 100. Corpus cost at vendor rate: $2.50/hr indexing → $166 for 66.4hrs. Lance dataset = $0.02/mo.

Vendor path: path=marengo query param. MCP tool takes it. Home page: no control. Result carries span. Tool echoes coverage next to answer. Missing key / spent daily budget = notice with own sentence (distinct from tower failure).

Review caught column presenting as more than it is: - Page labelled vendor search "hybrid" (label table had no row). - Floor null → every negative cleared. Eval table would print vendor rejecting nothing as measurement. Now "not judged" until floor exists (now exists). - Card stamped 6s span as matched frame (threw away span end). - Decision record quoted per-minute rate from vendor's indexing product. Explained 4 refusals as 2nd ceiling when hours simply ran out. All fixed. Deployed build = 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

Problem: Guided samples 1/10 frames of uniform. Thesis: cost should be 1/10 too. Until now: search cost more. Reason in DB's numbers: NN index over all frames = 2GB vs 230MB memory for pages. Guided search walked whole graph with filter on joined table. Passed 9 uniform frames per guided 1 before 200 candidates → ~26,000 index pages/query (4× unfiltered). Cold from disk = 5s. Warm = 36ms. Traces read 2nd-or-so = mix.

Fix: Guided gets graph its own size. Policy list mirrored onto vector table; trigger keeps mirror right on resample. 3 partial indexes for 3 small policies: guided 193MB, scenecut 174MB, deictic 30MB. Query filters on mirror → planner picks small graph.

Results:

metricbeforeafter
index pages guided search touches~26,000926
NN step p50 (40 searches)1,029ms51ms
NN step p951,692ms183ms
guided recall@10 (tuned)0.230.23

Guided search now costs what its frame count says. Returns same frames. Migration ran live at midnight with search paused 10min.

Review earned place 2×: 1. Migration 1st written: filled new column with big index standing. Every row touched inserted into graph again with full walk → 15min/100k rows on Mac (index in memory). Live = most of hour blocked search + 30min vacuum. Dropping indexes 1st + rebuilding in same transaction → fill = seconds, outage = rebuild itself. 2. 2nd pass: rebuild inside transaction that did fill indexes every row 2× → live run 10min vs docs' 8min. Also: runner printed nothing whole time → now says which path in 1st second. Unfiltered search also faster (full index rebuilt from scratch). Docs credit rebuild.

Unfixed (written down): Uniform + any-policy still walk 2GB graph. Next step: bigger compute or 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

Problem: Slowest number = 49s (1st thumbnail on throttled connection). Card thumbnail = crop from film's whole sprite sheet (1 JPEG, every frame at 2s intervals). 24 cards start 24 sheets (up to 2.3MB) at once. Throttled: 1st finish = most of minute. Matched frame = 1 tile (~5KB); card knows which tile before asking.

Two approaches, measurement picked: - Crop from sheet on edge = decode whole sheet. Longest sheet = 64MB pixels before decoder buffers → needed 194MB vs 128MB edge isolate limit. Half corpus couldn't crop on any plan. - Write every tile as own file: $0.54 once, <$0.01/mo, no compute/request. Tiles = 119,262 files beside sheets. Script reads sheet, splits, uploads tiles. Resume marker only after every other tile of film lands.

Results:

metricbeforeafter
bytes/card before thumbnail paintsfilm's whole sheet (0.7–2.3 MB)1 tile (~5 KB)
bytes/page of 2411–19 MB~115 KB (then sheets behind)
1st thumbnail (throttled)49,374ms3,584ms

Card paints matched frame from small file 1st. Asks for sheet only after that arrives, swaps to sheet's crop when it lands (no shift, no flicker). Scrubbing still waits for sheet. Film whose tiles not there yet renders as before.

Review earned place on fallback: - Until upload runs, every tile missing → missing tile left image element mounted. Chromium draws broken-image glyph → every card wore it for whole sheet wait. Card now drops element on error; fallback = byte-identical to old card. - Upload script resume marker = film's last tile; 1 worker could write it while another's tile failed → rerun skips film with hole, card silently papers over with sheet. Last tile now waits for rest. - Measurement script took any response as 1st tile (incl. 404). Now counts only 200. - Media route forbade caching → sheet sometimes fetched 2×/card. Sheets + tiles now immutable + say so.

After column (deployed build, all tiles in bucket, 3 throttled loads): 1st tile = 3,565 / 3,584 / 3,855ms. 1st whole sheet = 50,256 / 50,322 / 58,236ms. Sheets take same time; thumbnail paints without. 1st measurement script recorded nothing 3×, reported pass. Run before tripped site rate limit → throttled page = error page no cards. Script now fails when run measures nothing; test feeds those 3 runs to prove it.

Changes: Card + 1 header on media route. Search spans = 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

Problem: 13 films (~4% corpus) never had vectors for any frame. Not recorded. Embedding run marked failed with ffmpeg color-range message. Diagnosed pixel format problem — but 13 report same pixel format as working films.

Root cause: Last sample fails. Video duration = last frame time + 1 frame period. When fractional part < 1 frame period, whole second at floor(duration) sits after last frame. All 13: fractional part 0.005–0.048s. Accurate seek there decodes nothing. JPEG encoder opens on 0 frames → misleading message. 1 unextractable frame fails whole film.

Fix: Guard in sampler: no policy emits time > duration − 0.25s. 0.25s from worst measured gap (0.130s) + margin. Films resampled + embedded: 10,795 frames in 51min. Frames without vector = 0 (first time since index built).

Eval rerun:

metricuniformguidedany policy
strict hits gained on 13 films' queries212
strict hits lost elsewhere101
film found on all 5 of their queriesyes4/5yes

Search airliner, helicopter, Pontiac, speech lesson → frame there now. Index has every sampled frame of every film.

Review found story wrong 2× before right: 1. 1st cause: audio track past video → holds for 5/13, opposite on 8. 2. 1st guard: 0.1s < 1 film's measured gap. 3. Eval note filed 2 real losses as noise. Helicopter film (newly vectored) took 1st from right airliner answer. Another 13 entered top 10, pushed right frame past 10th. Net tuned: +1 strict hit on each frames path. 4. Guard measurements now fixture file in repo → numbers in docs re-derivable.

No request path changed. Spans = 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

Problem: P33 wrote index 2nd time as Lance dataset on R2. This issue serves search from it. Worker can't run Lance. Text tower's container already holds query vector when embed finishes → gained 1 route that embeds + answers NN itself. Web app's search function takes store param; only moves which index answers. Grouping, keyword leg, floor, why line, render = same code on both stores → eval's 2 columns comparable. Container reads bucket with read-only token.

Eval: 3 frame paths × live route on 2nd store = 381 requests. Fold = both stores in 1 table.

Results:

pathstorerecall@10 (tuned)MRRseek err (s)knn p50knn p95route p50
uniformpgvector0.380.3010.8935ms1,687ms2,914ms
uniformlance0.400.3210.5800ms1,668ms2,158ms
guidedpgvector0.230.1835.498ms194ms1,828ms
guidedlance0.230.1835.4776ms1,141ms2,171ms
nonepgvector0.380.2910.5117ms146ms1,728ms
nonelance0.390.3110.5664ms988ms1,998ms

2nd store returns 1st store's answers: same guided cells to last digit, same top result 98/100 queries. On other 2: returns better one. HNSW walk returned weaker frame of right film, ranked 12th. Cost = query: 438ms median in container, ~750ms as Worker sees it vs 98ms pgvector on P40 partial index. Container 1st query = 11.6s (fills index cache from object storage). Next 3 = 4.5s, 3.1s, 2.7s.

Store column = what 2nd index costs. Cost = time; recall holds. Deployed: open dataset 302ms (once), 1st query 11,561ms, median query in container 438ms (386 spans, p95 950ms). Worker sees 660–840ms median. Diff = round-trip through tower Worker → container → back with 200 rows JSON (pgvector skips). Lance query ~same on every path (prefiltered IVF reads same partitions + re-rank rows whatever filter keeps). pgvector costs what its index is. Lance wins uniform by 135ms (pgvector filters full graph). Loses none by 5.5×, guided by 8× (P40 gave pgvector index = answer size). Warm query = 38 range reads off R2; median says ~11ms each with reader's parallelism.

Review found 3 critical numbers: 1. Missing distance column defaulted every score to 1.0 → every search cleared every floor → store column would compare real index vs constant. 2. Object store error text (endpoint included) rode exception chain into span sampler always exports. 3. Failed query dropped dataset handle + warm cache → 1 5xx would hand next reader 11s 1st query. All 3 fixed; test with in-memory exporter asserts no exported span holds key or account ID.

No page change. Reader wanting 2nd store: store=lance on URL (what eval does). 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

Problem: Index in pgvector on Neon (4GB on sleeping compute). Hosting decision day 1. Thesis: which frames to embed. 2nd store tests portability: same 264,626 frames, same vectors, same provenance, written once as Lance dataset, read from object storage with no DB awake. This issue writes it + records cost. Serving from it = next issue.

Dataset: 1 row/frame, 12 typed columns. Every column in Postgres. 1.53GB in bucket beside films. Check reads back: compares row counts, 1 stored query's top result, 200 rows byte-for-byte. Measures recall on 100 real text queries vs exact brute force over dataset vectors.

Results:

indexdatasetrecall@1recall@10p50/query (local)
PQ, 1st build1.25 GB0.020.1154ms
PQ + re-rank1.25 GB0.440.3360ms
scalar quant, shipped1.53 GB0.830.923.7ms
scalar quant + re-rank (as queried)1.53 GB1.000.993.9ms
flat (2nd copy vectors)2.44 GB0.990.9917.5ms

Whole index in bucket ≈ $0.02/mo. Query from Mac against it → pgvector answer, 0.99 at rank 1, 457ms median, 2s p95. Same query = 4ms local disk. Gap = cost of reading pages from object storage (next issue measures from container).

Review caught recall number twice: 1. 1st build reported 0.99. Reviewer showed self-query artifact: sample queries = stored rows → any index finds vector's own row. Real text queries: PQ index found right frame 1st 3/100. Gap between text query's top 2 matches ≈ 0.0025 cosine; 12 floats/sub-vector can't rank at that resolution. Scalar quant kept right answer in candidates. 2nd pass found knob: re-ranking candidates against exact vectors (cost 0.3ms). Re-rank does nothing for smaller index (right answer missing from candidates). Finding: index type sets candidates; re-rank sets order. Smallest index fails.

2 smaller things: - Policy filter + NN query returned nothing at library defaults. Filter ran after top 10; now runs through label index first. - Check spent 8min DB time on question PK read answers in 0.33s. Now reads by key.

No request path changed. Search spans = 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

Problem: Evaluate view = licensing argument. Judged by being opened from someone else's link in whatever state it lands in. First walk through every state from cold browser since view built. Walk done in code → table: 27 states, condition/URL, before/after.

9 dead ends fixed: - Manifest export on paused index → took reader to raw JSON "index paused". Now: click caught, page prints same sentence as home (period reset date), nothing saved. - Set of 1 film → said nothing was near-duplicate without comparing. Now: "pair needs 2 films, this set has 1". - Set past 500-film limit → showed server error only. Now: names count, limit, how many to remove. - DB timeout after numbers on screen → silent. Now: reason prints above unrefreshed numbers + working retry. - Every empty histogram → says what would fill it.

Open shared evaluate link in any state → page says what it is + what to do next. Holds for paused index, 1-film set, DB no answer, phone, keyboard. Nothing goes blank, spins forever, drops on JSON.

Review (12 findings): State table named share URLs never minted → click → "could not be found". Tests rendered retry button, none clicked it. Narrow-viewport fix explanation blamed already-correct grid track; real cause = 1 unbroken 64-char archive ID in link (fix: let it wrap). Branch's "reading your set" line (shown before browser read set) never cleared with scripting off = exact dead end issue forbids. All fixed + test each. Keyboard help line now names only always-on-page controls.

No request path changed. Tower never asked from this page; 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

Problem: Search "ninja" → 10 cards back (frames, seconds, seeks). No ninjas in 300 educational films. NN search always returns nearest neighbours → 10th neighbour of no-answer query drawn exactly like real match. One place piece argued provenance then failed to deliver: frame shown for reader to check claim, but no claim existed.

Fix: Floor on best score in list. 1 file holds it; search, page, MCP tool, eval all read it. Calibrated on 20 no-answer queries (written by reading corpus, deciding thing absent; none by looking at index returns). Split tuned/held-out like 100 positive queries. Rule: most negatives floor turns away without costing a tuned query its answer.

Floors:

pathfloornegs turned away (tuned/16)held out (4)answers lost (tuned)held out
keywordnone (empty list = answer)16400
frames, every policy (default)0.138111201
frames, uniform0.138111201
frames, guided0.143414300

1 held-out answer cost: "teenage girl with schoolbooks" scores 0.1255 on uniform → now no match. Held-out recall@10 on that path: 0.45→0.40. 7 control queries corpus answers: rocket launch, surgeon operating, classroom — all clear on every frames path. "ninja" + keyboard mash clear on none.

Search "ninja" → page says "No match in this corpus". Names nearest score + floor missed. Shows 10 frames anyway on dashed rule with scores (index returned them; reader sees how close). No red, no error word.

Limit: On default path, 7/20 negatives still come back as ordinary grid. Hard ones: cash machine in bank wall, skateboarder in concrete bowl, cyclist in hard helmet. Modern objects in scenes corpus full of; floor costing no real answer can't reach them. Hybrid floor = judgement on 2 legs before fuse. Fused score = rank only, nothing about match quality. Frame query always clears (source frame scores 1.0 vs itself).

Review (24 findings): Most mattered: default search runs over all policies' frames; no negative measured on union → floor borrowed from uniform as lower bound. Measuring took 120 requests after daily allowance reset. Union floor = same number (union's nearest neighbour almost always uniform frame). Number = measurement now; before = guess that happened to be safe. 2nd pass: 7 controls asserted in notes, recorded in no file → kind of claim project exists to avoid. Now a file the runner drives.

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

Three pictures of it

Problem: Reader judging piece in 10s gets more from 3 pictures than paragraphs. Pictures: what runs where, how film becomes vectors, what happens between typing query and player stopping on frame. Drawings built from repo as-is; every box/arrow points at file/binding. List on issue for reviewer.

Fix: - Drawings inline on page, painted with site's colour tokens + 3 faces → follow theme, load no own font. - Component types: 2 weights of ink, hairline, mono tag. Each drawing spends 1 red on 1 thing that matters: budget checker's hand on DB, 13 films index has no vectors for, player stopping on matched frame. - Standalone light/dark exports + 3 PNGs in repo for reuse.

Log page opens "How it is built" with 3 drawings. Each in own scrolling frame with numbers under: films, hours, frames/policy, latest search percentiles.

Review: 15 findings, 1 blocking. Labels at 4–7px at column width. Fix: inline drawings, give each min-width keeping smallest label at 11px → system/pipeline drawings wider than frames on desktop, scroll inside. Count cards (per-policy frame counts) dropped by export → now under each figure. Query sequence had 3 code contradictions: sprite index read through Worker storage binding (not direct), video through Worker range route (not straight to storage), hybrid legs run side-by-side (not series). Drawing now shows frames path + caption for hybrid diff.

Lighthouse (mobile → desktop): perf 86 (1st run, late 1st paint) → 98 (2nd run 1min later) → 97. A11y 100 all 3. Page +190KB markup for drawings. Doc already ~1MB uncompressed (26 entries). Over wire: drawings add 111KB.

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

Problem: Deictic sampler takes frame where narrator points ("here", "this", "look at"). Until now: took frame at sentence start. Narrator usually runs sentence+ before landing on pointing word → stored frame often 1s+ before thing pointed at (sometimes 10s). Write-up's figure: 2 hits ~6s early.

Fix: Whisper times every word + segment. Transcript rows keep word times. Sampler finds matched word in list, takes frame at word's own start.

Measurement (4,335 pointing segments, 296 narrated films):

rulesegmentsmedian lagp90>2s>4smax
segment start4,3351.18s4.40s34%13%9.62s
word start4,3350s————

2nd row = zero by construction (confirms code does what it says). Write-up's 2 early hits: 122.76s→129.18s, 151.42s→157.68s (onto frames narrator meant).

Corpus re-sampled + re-embedded. Frame counts unchanged (fix only moves deictic frames):

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

Embed: 3,623 new vectors over 230 films in 1h48m (moved deictic + guided folding them). First run to record: 13 films (~4% corpus) never had vector for any frame. ffmpeg refuses to write frames as JPEG → own issue. Frames without vector: 10,807 (must reach zero).

Review side effect (implementer missed): Keyword index built from whole row → every word appeared 2× (segment text + word list). Ranking moved 14–70%/film. Fix: index from segment text alone (immutable function). 6 probe queries reproduced old ranking exactly. Film page + set stats query stopped pulling word list (made row 6× larger than needed).

Traces (build 8e3d2f9, 40 searches, 1/2.5s, warm tower, half frames/half hybrid):

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

vs prev entry: embed ↓150ms median, ↓590ms p95 (warm vs half-warm tower). why ↓160ms median (puts prev rise back). Route own time: 139ms median. knn ↑290ms median, ↑770ms p95 (issue didn't touch query; front-end measurement same hour on prev build after reindex read knn 965ms median → move = DB state post-reindex, this build clear; filed as own issue).

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

Problem: All searches started from typed text. Index = joint space (frames + sentences in same vector space). Frame = as good a query as sentence; frame vectors already stored. Reader clicks result card or scrub strip mark → frames like it. Licensing reader has shot, wants similar shots; typing description = step to skip.

Fix: - No model call. Query vector read from row tile came from (1 select on same connection as NN search). Search runs same as text query. Text tower never asked. - URL carries frame → shareable, back button returns to text search. - Count line: "frames like <title> at 2:12". Source frame tile sits where typed words would be → reader sees what was searched with. - Film page: strip link follows playhead until mark chosen → "more like frame 0:42" = frame on screen.

Review (12 findings, 3 fix-before-ready): 1. Frame search page still showed keyword/hybrid links (paths can't take frame → errored). 2. Retried vector lookup ending in no vector released wrong connection, leaked other. Test now ends both. 3. Reference parser accepted 1e40 second + 20-digit film ID. Postgres rejected as server error (hand-edited URL). Both now bad request. 4. 2nd pass: traced every connection path + parser input → nothing further.

Cost: 3 statements on 3 connections/page click (text query = 2). Three: catalog counts, source tile, vector read. Counts + tile overlap in same paint.

Latency (20 frame queries at 2.5s, deployed build, traces after midnight UTC):

spanp50p95n
frame query (whole request)2,045ms3,161ms20
reading stored vector36ms39ms20
nearest neighbours1,193ms1,923ms20

Text query (earlier same day): 2,635ms median in browser (1,399ms embedding). Frame query saves embedding, pays 36ms vector read → ~900ms sooner. NN step = same slow one from reindex (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

Problem: Neither provider sells hard cap on dollars. Neon: email at threshold, keeps running. Cloudflare: no account budget for Workers Paid. Both tell you; neither stops. Built the thing that stops.

Solution: 1 small Worker on hourly cron. Each run: 1. Reads current billing period cost at Neon, prices from dated config rates. 2. Adds Cloudflare side, writes total to shared KV. 3. <80% ceiling: records + returns. 4. 80%: records warning. 5. 100%: writes pause flag → clamps DB compute to floor + suspends it. Order matters: suspend first = nothing (next query wakes compute). Spend stopped by site declining to query; flag = what site reads.

Ceiling: $25/month across both providers.

First real reading (1hr after key set, period Sep 8–Oct 1, 06:00 UTC):

linecost
Neon compute, 12.22 CU-hrs$1.26
Neon storage + restore$0.08
Workers base$5.00
R2, 25.1 GB$0.23
container (modeled)$0.00
total$6.57 of $25

Container modeled: tower records each container start. Checker charges 1 start = 11min standard-2 (10 awake + 1 min work). Cloudflare analytics API exposes real meter; checker reads when account token set. Until then, status doc says which produced number.

Fails open: Neon error or throwing run → stamps status stale, pauses nothing. Standing caps bound damage: max 2 CU, asleep after 5min, 1 container instance. 1hr unnoticed runaway = ~$0.20. Checker pausing demo on every API bad minute > cost saved. 1st run did exactly this: key scoped to project, consumption endpoint = org → 404. Run stamped stale, site stayed up.

At ceiling: search answers 1 plain sentence with period reset date. No 500, no empty grid. Evaluate + log say same. Agent on MCP told same date. Nothing on site can wake DB/container past that point. Tower answers ≤500 queries/day.

Review: Storage unit wrong by 730×. Neon's 2 doc pages disagree on byte-hours vs byte-months; code followed wrong one. Fix followed page with worked example; 1st live read settled it. Full day at 4GB = 116M (4GB × 1 day's share of month). Other reading = 96B. Same read showed billing period starts at upgrade instant, ends at calendar month (reviewer guessed opposite).

Traces (build 66ff111, 20 searches, 1/2.5s, first 10 just-woken tower, last 10 warm):

spanp50p95n
GET /api/search3,023ms4,158ms20
search.embed1,580ms2,168ms20
search.knn739ms920ms20
search.why547ms956ms20
GET /api/health210ms wall—1

Added to search path: 1 KV read before model call. Route's own time outside 3 children: 157ms median (prev 290ms). Read doesn't show in timing. embed span up ~300ms median (half set ran on still-warming model; P20 just-woken set = fair comparison, this under it). why span up ~180ms median (untouched; next entry shows if noise).

Rule outside code: Never point uptime monitor at route querying Postgres. Ping/5min defeats scale-to-zero → $3/mo → $77/mo. Health route reading only checker's last run exists for this.

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

Problem: Every issue built same way; this issue writes that way down with numbers behind it. Transcripts only go back to day 2 → counts = window on later half.

Process: 1. Orchestrator reads issue, branches, hands spec to implementer subagent (model per issue label). 2. Fable subagent reviews with 2 lenses: correctness + front-end lead view. 3. Every finding must have reproduction. Unreproducible = question. Nothing merges until Ed says so. 4. One command merges, marks issue done, posts status update, writes entry.

From transcript window (day 2 onward):

metriccount
tool calls4,452
in subagents3,216
shell segments9,575
subagent spawns26 (12 Fable, 9 Sonnet, 4 Opus, 1 inherited)
refused by permission classifier21
refused by permission rule12
refused by secrets hook13
my turns100 (96 under 200 chars)

Across 24 landed issues: 16 Sonnet, 8 Opus implementers. Every review = Fable. 21 entries with counts = 292 findings (12 blocking, 14 fix-before-ready).

README has "How it was built" section. Long form with all tables in repo. Tables regenerated by test, compared byte-for-byte → doc can't drift from script.

Review caught overreaching numbers that would lose reader trust: - Status updates described as 1/landing, all after merge — neither held. - Classifier blocks counted twice under 2 names. - Hook refusals missing from count; once added, counter matched hook source when tool read it (15→13). - Secrets-file family matched example file; then matched process.env in source being written (46→27→17). - Commit message with heredoc + unbalanced quote → argument fragment as command shape 25×. - All now tests.

No request path changed. Spans = previous entry's: search 2,990ms median, 3,811ms 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

Problem: Front-end README half: what site costs a person on real connection. All measured against deployed URL.

Method: Playwright runs 30 queries through search route. Reads server timings from response header, loads page per query → times 1st painted result card. Loads query page 3× on throttled profile, clicks scrub strip 20× on 1 film → times seek. Lighthouse ×6. Range request checked on player path. Raw files in repo; method = 3 sentences. All from build 755cb03, warm tower.

Server-side (response header):

measurementp50p95
embed1,399ms1,568ms
nearest neighbours866ms1,577ms
why307ms446ms

Browser:

measurementp50p95
search request2,635ms3,611ms
first painted result card3,125ms4,011ms
scrub click → video seeking224ms535ms
1st sprite sheet (slow link, median of 3)49,374ms—

Lighthouse:

pageperfa11ybest practices
home, mobile9710096
home, desktop10010096
query, mobile10010096
query, desktop7610096
film, mobile7510096
film, desktop9810096

Best practices = 96 everywhere (missing favicon). 2 perf scores <80 = same thing: query page desktop = card thumbnail waiting on sprite sheet; film page mobile = video element.

Slow link: Card thumbnail = crop from whole sprite sheet. Every card starts own sheet at once (8–12 × up to 2.3MB). Throttled: 1st thumbnail waits ~50s while 11MB share 1 pipe. Filed as own issue — next grid need.

Review caught 3 wrong numbers: 1. Render timing in table → site never sent. Parser defaulted to 0 (Workers clock can't advance across sync code). 2. Paint measurement loaded same query script just fetched → cache made page look faster than request it depends on. Now uses 30 different queries. 3. Slow-link paragraph blamed 1 700KB file; network audit showed 12 sheets in flight. 4. 1st run had deploy mid-run → whole set rerun on 1 build, no caveat.

No request path changed. Numbers = browser view of same spans prev entry read from traces on build 8e3d2f9. Search median: 2,990ms (traces) vs 2,635ms (browser) on different 30 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

Problem: P25 found frames/guided on seq scan. Guess: planner mis-estimates policy filter selectivity. pg_stats says otherwise — row estimate 24,982 vs actual 24,738. Estimate right, plan still wrong.

Root cause: Planner gets price wrong. vector(1152) = 4,616 bytes → every vector in TOAST. frame_vectors heap = 20MB, vectors = 1,322MB. Seq scan costed on heap. Detoasting for distance = costed at nothing. Planner prices guided scan at 11,174 (reads 211,655 buffers) vs index walk at 19,891 (reads 23,468) → takes scan.

Why uniform works: Both prices move with rows passing filter. Detoast uncharged same, but uncharged cost can't move estimate. Scan charged part grows with passing rows (sort + distance eval). Walk charged part falls (fewer index rows/output row). Verified with EXPLAIN disabling one plan at a time. Uniform scan priced at 19,194 vs walk at 11,290. Guided scan at 11,174 vs walk at 19,891.

P25 stopped at guided. scenecut + deictic same plan. 3 of 5 app calls = full scan.

Fix: 4th SET clause on frame_search: enable_seqscan = off (scoped to function).

Warm (10 queries, live DB):

policyframesbeforeafter
uniform239,12440–47ms47–52ms
guided24,738497–874ms61–67ms
scenecut22,257273–286ms67–73ms
deictic3,88191–212ms143–173ms
none253,83145–50ms44–48ms

deictic left off index (correct). HNSW walk priced at 72,350; frames_pkey at 20,910. 1.5% rows pass → walk visits most graph for 200 rows. Timing in own spread (143–173ms vs 91–212ms).

Cold guided (verified suspend+resume):

readingbeforeafter
cold8,974ms4,209ms
warm 18,587ms59ms
warm 22,209ms66ms
warm 3–15506–663ms60–72ms

Both runs cleared postmaster gate → cold by evidence. 3-reading tail scan needed to settle = gone. Query after cold = already at warm floor.

Per-session cost (16 queries, 1 session): - Scan: session pays 211,655 buffers once. Walk: ~23,000 blocks scattered in 1,978MB index. Cache < index. Blocks needed depend on vector. Every 1st query pays for own region as random reads from Neon pageserver (~0.2ms/block). - 6 different queries on just-resumed compute:

query1st touchrepeat
q0104,320ms65ms
q0206,137ms79ms
q0305,091ms94ms
q0404,584ms270ms
q0505,550ms72ms
q0604,341ms97ms

No decay across 6. Live route same: 6,229, 4,340, 3,339ms. Before: 8,974 → 8,587 → 2,209 → 506–663ms.

Per guided search on resumed compute:

query in sessionbeforeafter
18,974ms4,209ms
28,587ms~5,000ms
32,209ms~5,000ms
4+ each506–663ms~5,000ms
already asked506–663ms65–270ms

1 search after resume = 2x faster. 6 different searches = ~30s DB time vs ~21s before. Crossover at 3–4 queries. Autosuspend = 300s → resumed compute = ordinary state. P30 traded shared-session cost for per-query cost. Nothing in function can lower per-query cost (walk reads minimum).

Hosting decision (Ed's): - Cron keeping compute awake → all readers on 65–270ms row, but bills every idle hour. - Decision 001 (text tower) answered no. - Min CU >0.25 holds more graph between queries, bills floor whenever awake across suspend. - pg_prewarm moves read to resume. Index 1,978MB > cache → helps only first few searches. - Decision doc has costs. Missing measurement: searches per real session (doesn't exist).

Eval revelation: Seq scan = exact NN. Index walk = approximate. If guided=scan + uniform=index, eval compared 2 retrieval methods, called difference a sampling policy difference. Timings rule out. Per-cell knn in results.json: both rows served by scan. Guided: 311–564ms (all 99 cells). Uniform: 1,209–2,413ms (98/100). Index plan = 45ms. Both exact.

Offline verification: 100 ground-truth queries against both plans → every guided cell identical (splits + by-style). Says metrics only read ground-truth film rank + returned t. Retrievals differ elsewhere: mean frame-level recall 0.944, 9/100 different top-10 order. None moved ground-truth film across rank boundary. results.md now notes which retrieval produced it (inferred from timing).

Re-run needed: Published numbers from retrieval site no longer performs. Uniform row also under P29's plan. Command: make eval (~13 min wall). Zone rate limit: 300 req paced 2.6s. Tower: 200 embeds + 1 cold start (~50s if slept). DB: ~20s query time at new warm costs, stays awake 13 min. Overwrites evals/results.json/.md.

Traces (build cce1eb6, 6 first-time queries/policy, idle DB):

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

All 4: p50 1,641ms, p95 6,256ms. Prev build: p50 1,506ms, p95 8,209ms (no policy in spans, mix unknown). All readings = first touch (matches first-time query table). Uniform pays own smaller per-query cost on live path.

First-time guided search: 4–6s on live site after DB idle. Once run: 65–270ms. Before: 9s for 1st search of session, 0.5s after. Recall table stands; 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

Problem: P25 found plan-cache regression but left it alone (migration needs own issue; P25 was timeout). This issue applies fix.

Fix: frame_search adds SET plan_cache_mode = force_custom_plan as 3rd SET clause (with hnsw.ef_search, hnsw.iterative_scan). PG 18 caches SQL-function plan per backend session. From 6th execution, considers generic plan. Two params independently push generic plan off HNSW: - limit k: unknown LIMIT → planner assumes 10% output - policy: unknown selectivity → hash join looks cheaper Either alone → seq scan. force_custom_plan = replan from scratch every execution (~6ms/call). Cached generic plan = 2–3s.

Live DB measurement (13 executions, same session, same backend pid):

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

Ruled out: keyword_search (GIN index, generic plans handle), set-db.ts duplicate-pairs (inline SQL, unnamed statements → PG18 per-function plan caching never applies).

Live site now serves every frames/uniform query off HNSW index. Guided's permanent seq scan (P30) still there. evals/results.json per-cell timing measured under generic plan. P12 will measure latency from scratch on fixed DB.

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

Problem: Only consumer was person at page. Now agent can use it too.

Fix: - Web Worker serves MCP at /mcp (streamable HTTP). No install, no key: paste URL into Claude Code/Claude Desktop/Cursor → agent gets 3 tools. - search_clips: query → matched films with second, score, sprite sheet, tile to crop frame. Link opens player at that second. - get_film: film metadata + archive.org source. - get_clip: snap second to sampled frame at/before → frame's policies + transcript line under it. - Tools call exact search function page uses. 1 query path, 1 ranking. Test: page route + MCP tool against same search → ids same order. Agent sees what page shows.

Architecture: - First draft: Durable Object per client session (platform's agents lib default). Review measured: class deprecated, each session left DB until client goodbye, Worker bundle >5x. Stateless handler replaced same day.

Bundle size:

buildsizegzipped
before240 KB65 KB
DO draft1,356 KB357 KB
stateless shipped550 KB145 KB

Cold tower: agent answered within ~3s. Search finding asleep model returns structured result (says so, gives retry hint). Page's warming rule applies. Live Claude Code check hit this: 4 cold calls at 3.0s each, client waited hint, 5th returned 3 films in 5.8s + fetched 1st film record. Each tool call = own span in tracing, parented to request. Handler streams answer before tool finishes → request span alone never sees failure.

Abuse protection (3 layers): 1. Zone rate limit (covers search API) now covers /mcp: 5 req/10s per address. 2. Worker-internal: 10 searches/min + 60 lookups/min per address. Over-budget → structured answer + retry hint. 3. Global ceiling on tower daily spend (cost issue later).

Traces (build 7438401, 10 page + 10 agent searches, warm tower, 2.5s apart):

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

Agent path = page path cost (same code). 1 agent call at 7.5s = p95 (novel query paying index walk). Every search span below prev build (prev had 6 searches on just-woken tower; this has none). Change touched nothing in search path, didn't move it.

Add 1 URL to agent → it searches corpus, gets same frames + deep links page shows, told plainly when model still waking.

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

Problem: Text tower in container sleeping after 10min idle. Wake = new instance + load SigLIP text model. First request after sleep failed before either finished. Library gave up → flat 500 (reads same as crash). Page worked around with short probe + warming state. Changed: what tower itself says.

Fix: - Container class owns the wait. Request arriving while container placing/loading held inside Durable Object until port opens or ceiling passes. - Container still loading → 503 + Retry-After + body saying so. - Container crashed → 502 + crash text. - Crash recorded via class's error hook → stale crash never brands later timeout. - Warm request = no extra call.

Live measurements (local tests couldn't show): - Library timeouts = try counts. Port ping to unbound container: ~0.9s/try (platform) vs <1ms (local). Nominal 20s budget held 60s on first deploy. - AbortSignal.timeout inside Durable Object = real ceiling: 150s embed, 20s health check. - Cold start = 2 phases library treats as 1. Instance placement: 20–66s (5 starts today). Model load: 9–34s. 49.7s (2 days ago) = short placement + full load.

5 cold starts (from tower spans):

buildafterplacementmodel loadsingle embed
6223e2edeploy42s19shealth 503 at 60.4s, then 200
6223e2e11min idle—16s200 in 48.3s
97fb2d511min idle66s34s503 at 75s ceiling, retry 200 in 31.3s
3634de8deploy52s10s200 in 67.6s
3634de811min idle62s9s200 in 75.3s

Last 2 = final build. Both single cold embeds succeeded in 1 request. 2nd would've missed earlier 75s ceiling by 0.3s. Warm embeds: 1.2–1.5s (unchanged).

Tests: Real container lib vs fake Durable Object at 1/100 scale = 38 cases in <4s. 1 case: budget too close to instance budget marks container healthy without port check.

Traces (build 61541ce, 12 searches, 6 on just-woken tower):

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

kNN p50 = 1,641ms (prev build). embed p95 = container's 1st forward pass after wake (6–11s on every start). This change didn't touch that pass.

Decision: No keep-warm cron. First visitor after sleep wakes tower, sees warming state. Most time = instance placement (no code change shortens). Paying to keep idle container warm overnight = off table.

Cold tower request now gets honest answer: 503 + retry hint while model loads, 502 if container died, working embedding in 1 request if caller waits. Before: all were 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

Problem: P23 moved search budget 8s→15s to survive cold DB. Number from 1 measurement (stub vector, no policy filter). First 2 uniform requests post-deploy cancelled at 15.1s each. Ceiling moved, failure moved with it.

Root cause (2 parts): - Uniform: Plan-cache regression. First 5 frame_search executions use custom plan (HNSW, ~45ms). At execution 6, Postgres switches to generic plan → sequential scan of all 229,292 uniform frame vectors. Transition: 40s cold, 2–3s warm. Mechanism: PG 18 aggressive SQL-function plan caching (local pg17 dev doesn't reproduce). - Guided: Never touches HNSW. Planner sees guided = ~9% of table → permanent sequential scan: 9.5s cold, 400ms warm. No plan-cache switch.

Both slow paths = full scans. Coldness = expensive. Neon compute: shared_buffers 230MB vs scan touching ~1.65GB heap.

Latency:

pathcoldwarmmechanism
keyword520ms360msGIN scan, small
frames/uniform40s (1st transition), 2.9s plateau45ms (custom plan)plan-cache switch at exec 6
frames/guided9,500ms400mspermanent seq scan

15s budget stays. Routine costs top out <9s; 40s transition = plan bug no tolerable timeout covers. Two fixes filed: 1. plan_cache_mode = force_custom_plan (DB migration) 2. Index/query rewrite for guided's permanent scan (P30)

Neon: autosuspend 300s, 0.25 CU, 230MB shared_buffers. Reader in 5–10min idle window: guided ~12s (3s resume + 9s scan) — inside budget. Uniform after plan switch: 40s on first post-resume query — outside any tolerable budget. Fix = plan.

Trace now carries `search.policy` beside `search.path`. Cancellation names policy. Measurement script gates cold on pg_postmaster_start_time change. 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

Problem: Both pages opened on counts. Double rule, title, sentence, heading, 3 figures at 68px + 24px air = ~140px row. Stack above = 350px. On laptop: search field at 915px on 720px screen. Thing site is for sat 2 scrolls below thing it counted.

Fix: Counts set like film can label: small, mono, one ruled line, tabular numerals. Search field now first thing after masthead (where red mark already was). Catalog/pipeline notes moved below results. Query returns under field reader typed into.

Search field position:

viewportbeforeafter
1280×720915–969378–432
1440×900915–969378–432
375×8121,117–1,161350–394

Evaluate page, first histogram (12 films):

viewportbeforeafter
1280×720833–958508–633
1440×900833–958508–633
375×812978–1,094568–684

Measured in browser against prod build with full catalog. On laptop, first screen now ends inside pipeline notes, below catalog heading + count strip.

The failure: Placeholder = dashes same length as number. Claim: fixed char count in mono = fixed width. True once mono loaded. Before that, browser draws in stand-in from proportional face → each glyph has own width.

6 chars at strip sizemonostand-in
digits68px84.9px
em dashes68px152.6px
en dashes68px84.9px

In wrapping row, difference reflowed strip 2× on every first load: numbers replaced dashes, then real face replaced stand-in. On phone: 2→3 rows (25px shift). Fix: placeholder = en dashes (stand-in draws at digit width). Strip = fixed grid → screen width alone decides rows. Every state, both faces, 7 widths (375–1440): one height each.

CLS (cache cleared):

pagebeforeafter
home, 12800.01990.0199
home, 37500
evaluate, 12800.16260.1423
evaluate, 37500

Tests: 704 → 727. 3 new. 1 runs in real browser (breaks invisible to markup comparison).

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

Problem: Frames path returning 500 on most requests. Trace said query cancelled by statement timeout at 6ms, but caller asks for 8s. 8s timeout can't fire at 6ms.

Root cause: statement_timeout as constructor parameter sent as startup param → through pooled endpoint did nothing. Query given 50ms ran 413ms (12/12). Only timeout ever applied was 8s on DB role (set month ago). Plain statement SET works but worse — setting outlives connection; next caller inherits abandoned value. 5 fresh clients cancelled at 63ms by previous client's value.

Timeout methods tested (pooled endpoint):

methodresult
constructor param, 50msnever fired, 413ms
plain statement, 50ms, same clientfired at 63ms
inherited by next clientfired at 63ms
SET LOCAL in transactionscoped to transaction

Fix: Timeout on transaction. Connection opens 1 transaction at connect, covers search + provenance lookups. Release commits before close (pooler throws away connection disconnected mid-transaction). Cancellation <1s = poisoned connection → dropped, query retries once on fresh connection.

Second failure (unfixed): Cold DB slow enough to hit ceiling. After 11 idle min, first search 8.5s, next 3.8s. Budget moved to 15s for paths that fail on cancel. Catalog counts/log strip keep smaller numbers (show dash when cancelled by design).

Post-deploy (20 req each, paced):

metricguideduniform
answered 20020/2020/20
search query median521 ms1,307 ms
search query p951,631 ms2,261 ms
whole request median2,884 ms2,634 ms
warm-up before counting13

15s too short: first 2 uniform requests cancelled at 15.1s before 3rd answered. 8.5s measurement used stub vector, not heaviest policy → undersized worst path. Real cold time > ceiling.

Eval rerun: Numbers match previous run → failure was in answering requests, index returns unchanged.

Decision note: All measurements, ruled out, unproven. Mechanism reproduced only through app's pooler. Retry is a bet: burst failed across 4 connections in 23s; 1 retry wouldn't survive.

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

Problem: Search already measured stages (embed, knn, why, sprite fetch) but numbers went to requester then vanished. No answer to "what was median last Tuesday" or "are slow queries slow in same place".

Fix: - Each stage = span in trace. Trace = one request; span = timed step. - Traces → self-hosted collector. 3 runtimes report: web app, text tower, index scripts (laptop). - Single search touching first two = one trace with nested hops. "Where did 9s go?" now answerable anytime. - Spans carry existing numbers. Stage span stamped with search function's figure; start time moved back by that amount. One clock > two slightly disagreeing clocks. - Query privacy: prod spans carry query length + hash (groups repeats, impossible to read). Raw text only on laptop. DB spans = statement name, never statement.

Tests added:

runtimebeforeafter
index scripts363376
text tower1226
web app645682
tower fronting worker017

Tower fronting worker was separate package; test suite never reached it. Now runs with typecheck/install.

Review: 4 blocking findings: 1. Container auto-instrumentation switched on in startup handler — runs AFTER framework assembled/cached middleware. Auto-instrumentation replaces chain. Call succeeded but no request span produced. Container spans floated in trace with no parent. Dashboard looked fine until someone looked for a request and found none. 2-3. Stage spans opened before slow call, closed after, no catch for throw. Unclosed span = never sent. The 2 failures this explains (DB timeout, cold model) produced no evidence. 4. Sampling decision (keep 20% ordinary + all errors/slow) made independently per runtime. Rolled separately → whole trace survives ~4%. Now decided once at front door, carried along.

Next: Dashboard, alerts, one real trace 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

Problem: Need results table where every number traces to 1 query + 1 answer. Eval runs all 100 ground-truth queries through live search route (same route + function pages call). 3 paths: keyword (transcripts), frames/uniform (1fps), frames/guided. Records: right film rank, returned second, distance from truth second, top 10 films. All committed → single cell pointable/arguable.

Hit definition: right film in top 10 + returned second within 10s of truth. Film-only = drops second, asks only if film found. Thresholds tuned on 80 rows. 20 held-out run once, reported separately.

Results (tuned 80, Sep 9 run):

pathrecall@1recall@10film-only@10MRRmedian seek err (s)frames 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

Held-out 20: keyword 0.25, uniform 0.45, guided 0.10 (recall@10). Median seek error = over all rows where film found (whether second within tolerance). Strict hit = bounded by tolerance → median over strict hits alone says nothing. Frames in index = counted from index. Wall min = frame count × embedding rate of run that built it = cost of building that policy's index alone.

By style (recall@10 tuned):

pathvisualspokenvague
keyword0.000.950.05
frames/uniform0.530.100.35
frames/guided0.330.100.10

Keyword finds 0.95 spoken, 0 visual (only matches words said). Uniform finds 0.53 visual, 0.10 spoken. Guided = 1/10 uniform's frames; keeps 0.87 of uniform's film-only recall, loses more on exact second (expected cost of sample skipping most seconds).

Example: "rocket launch" (row 4). Keyword returns 1 film (1968 Day in America, narrator says words over TV). Frame written against = Saturn V lifting off in food prep film whose transcript never mentions it.

Doc lists 5 worst queries/path + route's answer. "What search gets wrong" section writable from those cases. Run = 13 min. Site rate-limits search route; runner obeys. 2 runs (before/after review fixes) = 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

Problem: Need ground truth file of 100 queries written by looking at films, never at search results.

Fix: - 100 rows: query, film, second, tolerance (how far off still counts) - Each row written by watching 20s of film (+ transcript for spoken rows) - Search never run while writing - Draw: seed over catalog → shuffle once, shuffle again + append (film ≤2x). Random timestamp with 20s margin at each end (measured against shorter of archive/encoded runtime). 124 films have runtime diff >2s; archive longer for 19. - Writer walks candidates, writes row or logs skip with reason. 10 skipped (6 silent on spoken turn).

Ground truth (seed 20260909):

metricvalue
rows100
visual (on screen)50
spoken (said)25
vague (2–4 words)25
held out20
candidates walked110
skipped (with reason)10
films used twice0
tolerance10s

20 held out (10 visual, 5 spoken, 5 vague). Thresholds tuned on 80; 20 run once at end, reported separately.

Contact sheet: 7 pages, each row's frame over query + 1-line note. Checkable by eye in minutes — how review found wrong rows.

Example: "a hand pulling a plug out of a wall outlet" (row 93) = white wall, 2-gang plate, hand on twin cord at 713.5s in 1961 projector maintenance film.

Review: Replayed seeded draw against skip log. All rows in candidate windows; spoken queries' words in named transcript segments. 5 fixes in reader-visible output: timestamp outside window, 2 frames not showing query, paragraph arguing wrong direction, ID spilling. 20 tests now guard file 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

Problem: Need a portable set of clips for licensing decisions — add from results/film pages, share via link, evaluate with stats + near-duplicate detection.

Fix: - Set lives in browser until shared. Share = write to store under 10-char content-derived ID (same set = same link; link carries strip size/seed). - /evaluate: films, runtime, narrated count (catalog numerals). Duration, resolution, decade, speech as bars with counts. WPM for narrated films. Missing catalog films listed by name. All computed from 4 facts: duration, year, resolution, speech flag. - Near-duplicates (pooled frame vectors >0.92 cosine) shown side-by-side in red, each with Drop button. - Stratified sample: 6/12/24 films across decade+speech. Reshuffle changes seed. - Manifest export: JSON with summary, each film's media URLs, duration, resolution, WPM, midpoint timestamp.

Preview stats (6 films):

querytime
stats<0.2 s
near-dup self-join0.19 s
pairs >0.921
similarity of pair0.944

Pair: distinct films, same decade/kind of work. Mean over all frames = vector finding 0.944. Whether 0.92 = "paid twice" is open; ground truth in next piece can answer.

Corpus embed run (finished Sep 9):

metricvalue
films with frames embedded283
frames embedded249,833
uniform225,676
scenecut21,151
deictic3,599
guided23,452
embed wall time12.0 h
frames/sec5.1
frame vectors on disk3.3 GB

Tests: 434 → 645. Review: 18 findings, all in-scope fixed pre-merge. Blocker: write path with no size limit on client storage. UX fixes: Drop reloaded everything, Reshuffle couldn't change strata, empty-set flash.

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

Problem: Search results need to show matched frames in a grid; film page needs sampling thesis visible as picture.

Fix: - Grid of frames: each card shows matched frame as crop from sprite sheet (positioned by %, works at any width). One sheet per film downloaded. Card: "frame 12:34, matched 12:35". Mouse scrubs film on same image. Arrow keys/Enter navigate. / focuses search, Escape clears. - Page paints before search: heading, field, filter rail, placeholder grid on screen while query in flight. Results land in existing layout. Fused path: transcript matches arrive first, frame matches appended after — visible order = arrival order. Page discloses this. - Film page: strip of sampled frames under player; 4 lanes of tick marks (one per policy). Uniform=solid comb, scenecut=clusters at cuts, deictic=narrator points (empty on silent films), guided=scenecut+deictic. Click mark → seeks to frame; click in lane → snaps to that lane's frames. Transcript follows, every line seeks. - Filter rail in URL: index, sampling policy (re-query), decade/audio/length (browser filter). Counts computed over what other filters leave. Mobile: 2-col grid, rail as bottom sheet.

Frames in 24-min narrated film:

policyframes
uniform1,453
scenecut49
deictic16
guided63

Mobile Lighthouse:

pagehome w/resultsfilm
Performance9870
Accessibility100100
LCP2.2 s14.1 s

Fused search (live Chrome):

eventtime
placeholder grid0.3 s
transcript matches0.6 s
frame matches appended0.8 s

Film page LCP = video element. Poster from matched tile paints first on normal connection; on slow 3G video beats 1MB+ sheet. Fix: small poster per film at transcode time (recorded for perf pass).

Tests: 242 → 434. Review: 16 findings, 15 fixed pre-merge. Most at seams: fusion, facet counts, tab stop under virtualization, tick snapping, transcript scroll — each right alone, wrong at intersections.

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

The app can only read

Problem: Read-only security review found app used schema owner identity — any query bug could rewrite index.

Fix: - App role: SELECT on 5 tables + 2 search functions only; no CREATE/INSERT/DELETE - Query timeout: 8s - Owner cred stays with migrations/indexer (run on Mac) - Hardening headers + CSP (self + 1 analytics host) on all responses - Video/sprite route: serves only 3 pipeline file shapes, 400 else - Day 0 probe routes + version string removed - Secrets hook expanded: scans all shell commands, refuses naming secrets file outside git, more key shapes; installed as real git pre-commit hook - Tool allowlist: removed pipeline targets writing to DB, open-ended curl

Post-deploy checks:

checkresult
tables app role reads5
tables app role writes0
search/10s before 4295
curl on tower wake403
tests (index/tower/web)307/12/242

Deploy issue: Routing all requests through Worker for headers broke static files (404 on scripts/styles/fonts). Fixed: asset store first, fallback to framework.

Review: Most useful on secrets check — reproduced 7 bypasses + 1 false positive → all now tests. One hole by design: script body reaching secrets file without spelling 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

Problem: P2's look failed with real content — thin display serif read as decoration, looked like a template.

Fix: Bulletin theme (industrial/utilitarian, 1950s technical bulletin + film can label): - Blunt display headlines, long-read serif for prose, mono for counts/timestamps - Hairline rules, no card boxes, one red mark per view - Three self-hosted faces only: Bricolage Grotesque (headings/counters), Source Serif 4 (prose), IBM Plex Mono (data/labels) - Test refuses 4th face; test measures contrast from token values in stylesheet header

Font payload:

stagesizefiles
first cut351 KB5
drop unused italic/mono weight169 KB3
shipped (static weights)100 KB3

Mobile Lighthouse:

pagehomelog
Performance9694
Accessibility100100
LCP2.5 s2.8 s

Review: Blocking finding — Tailwind uses cascade layer; site globals were unlayered, so all accents lost since P2. Red mark/ink/links rendered grey. Fixed: globals moved into layers. Deploy now cleans build dir (was shipping 73 font files, only 14 used).

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

Problem: Build log (this page) should live in repo as markdown, render at build time, need no tracker at request time.

Fix: - One entry per landed issue, checked in as markdown - Renders at build time, no runtime tracker calls - Each entry: numbers produced, review findings, implementer/reviewer models - Plan table has model column; "How it is built" section explains 4 steps + models - Closing step writes next entry + flips plan row → lands with tracker status update → deploys; page and tracker never disagree - Test parses every entry; bad field fails build

Mobile Lighthouse:

metricvalue
Performance98
Accessibility100
LCP2.3 s
HTML (gzipped)22 KB

Stat strip reads from DB: films, hours, frames sampled, frames embedded (dashes if DB down).

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

One query seeks to one frame

Problem: Search must take typed sentence → player sitting on matched frame. Text half of SigLIP embeds query; SQL finds nearest frames; best frame per film wins; player seeks to that second.

Fix: - Same SQL function serves transcript keyword path and eval (no separate eval mode) - Provenance on every row: frame results show sprite tile (cropped from film's sheet); transcript results show sentence with matched words underlined - Tile = sampled frame at/after even second below match (≤2s early); row discloses this; grid fetches exact frame - Cold tower: page polls model health before sending; shows clock + retry at 5s intervals; results draw in place when ready

Frame search latency (k=200, 81 films with vectors):

policy filterrowslatency
none20087 ms
uniform20028 ms
scenecut2002,748 ms
deictic20078 ms
guided200114 ms

Before review: all read 40 (filtered paths fell off index to seq scan). Fix: two session-level settings on function (pooled connection → settings would leak otherwise).

Mobile Lighthouse:

pagehome with resultsfilm page
Performance9974
Accessibility100100

Server-Timing (warm search): embed 1,359 ms / knn 48 ms / why 451 ms

Film page score limited by video element (largest paint, no poster). Scenecut latency = sparse filter walking graph.

E2E test gated: headless Chrome lacks H.264 decoder; hidden tabs block media. Verified in real browser with 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

Problem: Embed all sampled frames through SigLIP image tower on Mac MPS; mean-pool into clip vectors per film/policy.

Fix: - SigLIP image tower on Mac GPU, one vector per frame - Mean-pool policy frames → clip vectors - Drop HNSW before load, rebuild after (faster than row-by-row)

Performance (4 films):

metricvalue
frame extraction (8 workers)98 fps
embedding (batch 16, float32 MPS)8.3 fps
blended (extraction overlapped)6.1 fps
projected corpus (264,638 frames)9–12 h
vector + index entry~15 KB
projected total~4 GB

Review: 12 findings + 8 questions. Critical: DB was on 512 MB plan — corpus load would die at ~60 films. Moved to usage-priced plan with 2 CU cap + spend alert. Other fixes: index-runs row mixed cumulative/per-run counts, derived number labeled as wall time, memory-stepping loop dead code, truncated frame files would be re-embedded.

Smoke test: "a man pointing at a map" over 4 films → man in lab coat reading sheet at 269s in 1952 siding film (no map, no pointing). 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

Problem: Need frame sampling policies that choose which frames to embed; must handle silent films and support resume.

Fix: - Four policies: uniform (1 fps), scenecut (ffmpeg scene cuts), deictic (narration pointers: "here", "this", "look at"), guided (scenecut + deictic) - Frames picked by multiple policies stored once with all policy names - Per-film policy tracking enables resume

Frame counts:

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

- Corpus run: 30 min wall time; guided picks 9.7× fewer frames than uniform - Deictic example: "you will notice the operator has to hand wind the take-up reel" → operator at projector

Review: 8 findings; critical: resume logic keyed off frame rows broke for silent films (policy sampling nothing looked like never-run). Open: bare "this" = 11 of 12 deictic hits on 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

Problem: Need transcript search as keyword baseline; same SQL function must serve web app and eval (no separate eval mode).

Fix: - Whisper on Mac GPU transcribes narration into ≤10s segments - Keyword query returns segment with matched words marked - Single SQL function serves both web and eval paths

Transcription stats:

metricvalue
films with speech241 of 300
words in table365,425
13-min film transcribed34 s
common single-word query0.36 s
no-match query1 ms

Review (2 passes): Fixed fallback returning made-up time of 0 for split phrases; fixed per-film cap hiding later occurrences of common words. Both would have passed retrieval eval but failed seek accuracy.

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

Problem: Need all films transcoded to 720p faststart on R2 with sprite sheets for instant frame access.

Fix: - 720p faststart encode per film on R2 - Sprite sheet + JSON index mapping tiles to frames - Player seeks to time; scrubber shows frame without round trip - Range request on media domain returns 206

Transcode results:

filmshoursGB on R2audio above floorno audio track
30066.427.032504

- ~51 s/film, 4.3 h wall time, one pass on Mac reading from NAS

Review: Caught critical bug — sprite tile t held frame from ~1s after t due to ffmpeg fps filter rounding. Fixed; test now verifies frame index matches tile label.

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

Problem: Need Postgres schema with vector indexes, local dev database, and a home page that works before indexing completes.

Fix: - Schema: films, frames, frame_vectors, clip_vectors, transcripts with HNSW cosine indexes and generated tsvector - Test ties 1152 vector width to checkpoint constant (suite fails on checkpoint change) - make dev spins pgvector in Docker, applies schema, runs web app - Home page paints heading before DB answers; counters show zeros while unindexed, dashes when DB unavailable; explains why search disabled

Review: 15 findings; 3 critical fixed before merge: wrong trigger arg on generated column, counter overflow on mobile, missing loading state.

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

Problem: Need to choose and verify hosting stack before building features.

Fix: - Next.js on Cloudflare Workers for app - R2 for media (Range requests return 206/416/200 correctly) - Neon Postgres via Hyperdrive (66 ms median) - SigLIP text tower in Cloudflare Container behind token-gated Worker

Embed latency:

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

Mitigation: Cache query vectors; stream keyword hits before vector results; int8 ONNX text tower as structural fix if needed.

Review: 14 findings fixed and re-verified live. Two critical: Range handler returned full file on malformed header; crawler could bill tower endpoint indefinitely.

Sep 7, evening
landed
health on track

The repo exists and the corpus is downloading

Problem: Need a corpus of Prelinger films and baseline tooling before any search can be built.

Fix: - Selected 300 films (2–30 min, no home movies, h.264 preferred) with fixed seed - Stdlib Python downloader pulls to NAS with resume support - Ported secret-check hook and lint-after-commit hook from prior project - Ported issue-start, review, issue-close loop

Corpus:

filmshourssizewith a year
30066.325.0 GB257 (1906–2001)

Notes: Day 0 predates the review loop; no implementer/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
P42Four findings and a reader note. The fold check passed on a dev page that loads no stylesheet, the frame search page missed the 300 px target by 66 px, a test name claimed a line count it never measured, and the query check was written out four times. The reader note was that a shared query URL never said what the site is.
P3517 findings, every number in post traced to file + confirmed. Figure printed 1 build + 1 runner under 6 bars from 3 passes on 3 builds. Cold column on pgvector rows = retries only (passes predated dispatch stamp); post said rule applied to them. Month written as read off billing page when page read $1.41 to date but $4.60 was plan. 2 negation-framed sentences.
P2718 findings, every number verified against span store + record. Figure numbers ran backwards from reader order. Post credited 2 fixes with 3rd fix number; dated worst case to wrong build. 11 sub-second cancellations folded into sentence putting every failure at ceiling. Uniform transition carried guided scan block count. 4 questions record couldn't answer → marked unknown in post.
P1426 findings (2 passes). Page labelled vendor search "hybrid" (no row in label table). Floor left null → every negative read as cleared; eval table would print vendor rejecting nothing as measured. Card stamped 6s span as matched frame (threw away span end). Decision doc quoted per-minute rate from different product. Budget notice printed raw error fragment; "not configured" notice named vendor on page.
P409 findings (1st), 10 (2nd), 1 blocking. Backfilling new column under existing vector index = graph walk/row (15min/100k rows on Mac with index in memory) → would block search most of hour. Migration reordered: drop indexes first. Trigger fired on every resample whether anything changed. Docs had outage at 8min; rebuild inside own transaction indexed every row 2×.
P3810 findings. Until tiles uploaded, missing tile left image element mounted → Chromium drew broken-image glyph over every card for sheet wait. Upload resume marker could be written while earlier tile failed → rerun skipped film with hole. Measurement script would report 404 response time as 1st-tile median. Sheet sometimes fetched 2× (media route forbade caching).
P3715 findings (1st), 14 (2nd). 1st cause (audio track past video) wrong for 8/13 films. 1st guard < 1 film's measured gap; docstring argued backwards. Eval note filed 2 real losses as drift. Newly vectored film took 1st place from right answer on 1 query, pushed right frame out of top 10 on another.
P3423 findings (2 passes). Exception chain carried object store message (endpoint included) into error span sampler always exports. Missing distance column defaulted every score to 1.0 → cleared every floor. Cold Neon wake booked to 1 store, nowhere on other. Dropping dataset handle on failed query threw warm index cache away → 1 5xx turned next reader into container's 1st query.
P338 findings (1st), 9 (2nd). Recall 0.99 = self-query artifact (sample queries = stored rows; any index finds vector's own row). Real text queries: shipped index scored 3/100. Policy filter + NN query returned nothing at library defaults. Missing knob: re-ranking candidates against exact vectors → 0.99.
P3212 findings. Share URLs in state table didn't exist. No test clicked retry. Narrow-viewport fix blamed already-correct grid track; real cause = unbroken 64-char archive ID in link (fix: let it wrap). With scripting off, new "reading your set" line never cleared → dead end branch introduced.
P2612 findings (1st pass), 12 (2nd). Page's default search runs over all policies at once; no negative measured on that union → floor borrowed from 1 policy. No-match state appeared on hybrid before 2nd leg answered. Keyword floor presented as if it did rejecting when empty list did. 7 control queries asserted everywhere, recorded nowhere.
P3915 findings, 1 blocking. At column width, labels in all 3 drawings rendered at 4–7px (drawing tool sizes type in drawing units; drawing scales to column). Count cards (incl. 4 per-policy frame counts) were viewer panels export dropped → on no page. Query sequence: sprite index wrong path, video straight to storage (code routes through Worker), 2 hybrid legs drawn in series.
P36Storing word times in transcript row doubled every term in keyword index → ranking moved 14–70% per film. Index now built from segment text alone (immutable function). 6 probe queries reproduced old ranking exactly before/after.
P3112 findings, none blocking. 2nd pass traced every connection path + probed reference parser with 30 inputs. Frame search page still offered keyword/hybrid links (errored). Retried vector lookup ending in no vector leaked fresh DB connection. Hand-edited second of 1e40 reached Postgres → server error (should be bad request).
P1614 findings (1st pass), 2 blockers. Checker priced Neon storage in byte-hours; metric is byte-months → price 730× too low. Launch plan can't set scale-to-zero to number → clamp would be refused hourly. Both fixed + proven against live read (also corrected reviewer: billing period starts at upgrade, ends at calendar month).
P1514 findings (1st pass), 6 (2nd) — nearly all numbers prose overreached. Branch marked active while another issue active (plan test forbids). Hook refusals not counted, then counter matched hook source when tool read it. Secrets-file family counted example file, then matched `process.env` in source being written. Each fix moved a number; tables regenerated.
P1213 findings (1st pass), 12 nits (2nd). Render timing reported that site never sent (Workers clock doesn't advance across sync code). Page-load latency test reused query API half just fetched → per-isolate embedding cache made paint faster than API call. Slow-link explanation blamed 1 sprite sheet; network audit showed 12 in flight.
P307 findings, 1 blocker. Every table timed repeated query (flatters index). 6 first-time guided queries on resumed compute: 4–6s each, no decay (old scan: 0.5s after first 2). Write-up rebuilt around that number, hosting trade put to Ed. Uniform explanation wrong (uncharged cost can't move estimate); deictic plan had no number — both fixed.
P296 findings, 3 fix-before-ready. Mechanism explanation wrong in 3 places. Generic plan loses HNSW due to unknown `limit k` (10% assumption) + policy filter's unknown selectivity. Draft blamed parameterized vector. pg17 description backwards. Both fixed pre-merge.
P211 pass, 13 findings, none blocking. Tool calls invisible in tracing (handler streams before tool finishes). Internal errors swallowed, no log. Doc's error/latency claims off. 1 test tautological; same-order test proved mapping not equality. All fixed; foundation swapped on review's size/deprecation numbers.
P203 passes, 25 findings. First: wait bolted outside Durable Object (AbortSignal can't cross stub); short budget could mark container healthy without port check; every warm request paid ping + storage write. Second: one caller's abort answered every joiner; exit-code claim code couldn't keep. Third (wall-clock ceiling): prose + 1 loose test. Live measurements sent numbers back twice.
P2518 findings across 2 reviews. First: guided measurement was warm, connection shape differed from app's, prepare_threshold guard vacuous, doc overcorrected away from coldness as issue. Second: 10 double hyphens for em dashes, conclusion described mechanism but left out cost driver.
P248 findings, 1 at heart of change. Argument that strip held still: "6 chars = 6 chars in monospace." True of real face, false of proportional stand-in browser uses while real loads. In that window, em-dash row measured ~2x width of count it replaced. On phone: strip 2→3 rows on every first load. Test compared markup, but this is geometry. E2E test measured heading above histogram — bars under fold would pass.
P2310 findings, 1 blocking. First fix wrapped each query in own transaction — follow-ups on same connection stayed exposed to leak. Measurements taken as DB owner (different pool from app). Note described removing line never on main. Live failure mostly from cold DB hitting real timeout; bug being fixed was small part.
P2220 findings, 4 blocking. Container auto-instrumentation never turned on (set up in startup handler after framework cached middleware chain; reported success anyway). Stage spans started before slow call, ended after with no guard → failed query lost span naming failure. Sampling rolled dice independently in 3 runtimes → ~4 traces/100 whole. Flag called "cold start" reported 60s cache expiry.
P1113 findings, 6 fix-before-ready. Frame count in table from 1 indexing run's row → disagreed with corpus doc by 14k frames; now counted from index. Partial run could overwrite committed table with partial. Generated doc carried em dashes; worst-5 lists held 5 lowest IDs among misses (should be most confident wrong answers); MRR never defined.
P1014 findings, 5 fix-before-ready. 1 spoken row timestamp 10s outside watched window. 2 visual rows described absent content (plane flown out, baby not in frame). README argued runtime mismatch wrong direction. Long film ID ran across contact sheet into next cell.
P918 findings, 1 blocking. Share route accepted any-length IDs → loop could fill KV with multi-MB values. Silent films counted in narrated WPM histogram. Seeded Reshuffle couldn't change strata when sample < strata count. Add button was 2nd tab stop ahead of card. Every load flashed empty set before reading real one. Drop blanked whole page.
P816 findings, none blocking. Fusion overwrote sheet tile on transcript-only films (showed blanks). Rail counted facets over whole set (offered "silent 2" from decade-filtered-out films). Grid lost tab stop when focused card scrolled out of virtual window. Scenecut mark seeked to nearest uniform frame. Transcript scrolled page every 10s. Hybrid fetched every sprite sheet twice.
P1916 findings, none blocking. 7 ways around secrets check reproduced (git commit msg reading secrets, script piped to shell, uppercase filename on case-insensitive disk, curl upload). Rate limit would blank provenance tiles (10 fetches/sec on results page).
P1811 findings, one blocking. Every accent and ink override lost to unlayered global CSS (red mark, ink rules, accent links never rendered). Also: contrast test only checked floor, typography test allowed 4th face, data text 11px on phone, 4 links under 44px.
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 whatever 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 any crawler could have kept a tower endpoint 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 querysonnetdone

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.