Skip to the entries

Ground PortalProject log

Sep 17 to Sep 21, 202629 entries40 issues

Project log

The rule: each issue is built on its own branch by the model its label names, and Fable reviews that branch in a fresh context before the pull request is marked ready. Ed reads the result and says merge. Where an issue did not go that way its own entry says so, and the count beside the reviews below is how often the rule held.

Contents

  1. G38A cold tile link says the table is wakingSep 21
  2. G34The findings that were not about looks, and a log that can say who built whatSep 21
  3. G26The eval wakes the table before it asksSep 21
  4. G22The e2e suite picks its tiles from the tableSep 21
  5. G37A span that ends when the page doesSep 21
  6. G36One cursor, numbered marks, three platesSep 21
  7. G35The deployed pieces get a historySep 21
  8. G33Thirty seconds, measured on a phoneSep 21
  9. G30What a set costs before you click downloadSep 21
  10. G31A card that unfurls as its own groundSep 20
  11. G15The argument, written downSep 20
  12. G32The clock, above the foldSep 20
  13. G14Forty-eight seconds, said out loudSep 20
  14. G24The query service, deployed, with a meter on itSep 20
  15. G21A floor under the height termSep 19
  16. G11Gather tiles, then ask what they holdSep 19
  17. G10The points themselves, in the browserSep 19
  18. G16The log becomes a pageSep 19
  19. G9A second vector, from heightSep 19
  20. G8Lidar over the countySep 19
  21. G13The table that can say noSep 19
  22. G7The page becomes a map sheetSep 19
  23. G12Fifty questions, written blindSep 18
  24. G6A phrase lights a tileSep 18
  25. G5Every tile gets a picture vectorSep 18
  26. G4Every tile gets wordsSep 18
  27. G3The county becomes rowsSep 18
  28. G2The skeleton, and a map that rendersSep 17
  29. G1Which model, where the query runs, how big a tileSep 17

Log

G38
landed
, late
pr
#31
built
opus
read
fable
health
on track

A cold tile link says the table is waking

Problem: A shared tile link to a sleeping query service held the stream open for the whole wake, one GET /tile/{id} at 50.60 s against a p50 of 0.65 s for the other twelve. For those 50 s the reader saw the photo, "Reading the table", and two empty plates, with nothing saying the wait was a wake or that it would end.

Fix:

  • The sheet's streamed half probes before it reads, the way the search page has since G14. A cold service costs 3 s to find out, then the sheet says so.
  • The cold sheet: h1 "The table is waking", a "Waiting" label with a mono clock on the same line, one sentence. The aerial plate stays, since it comes from storage. The three plates keep their grid rows and their size to the pixel.
  • The clock, the poll, and the give-up boundary are one hook shared with the search page's warming view. At 180 s the give-up sentence replaces the body sentence rather than stacking under it.
  • One live region outside the streamed subtree, written on mount, on ready, on pause, and on give-up. A refresh swaps the whole sheet, so a region inside it would announce nothing.
  • The point cloud viewer mounts nothing while the sheet is cold. One /tile/{id} request across a whole cold-then-wake run.
  • probe.page on the probe span, so a cold tile probe can be told from a cold search probe.

Results: a scratch dev server against a fake service that answers starting, 2026-09-21.

statefirst responseplates' grid rowsplate size
cold1.05 s2 to 4same to the pixel
loaded, after the refreshno reload2 to 4same to the pixel
  • The plates sit 95 px lower while the block is up and rise together when the table answers. Every zero-shift slot is below the fold on a phone, and a wait explained off screen is not explained.
  • With a stalled probe the block lands at 3.03 s, the probe's own timeout.

A stranger arriving from a shared link to a sleeping service sees the photo, a sentence that says what the wait is, and a number that moves, and the facts land without a reload.

Review: 2 fix now, 3 nits, 0 blocking, reproduced against a fake service with a held-asleep tower.

  • The give-up was silent to a screen reader: page.clock.runFor(181_000), the line in the DOM, the region unchanged, zero mutations.
  • The give-up line landed between the heading and "waits about a minute" after three minutes, and shifted the plates 28.5 px at 390 px.
  • "READING THE TABLE" at 30 px over "THE TABLE IS WAKING" at 15 px: two verbs for one state.
  • The arrival sentence said "the sheet is filling in" for an unknown id about to read "No such tile".

Unfixed (written down):

  • A pause found by the poll mid-wake leaves aria-busy true for good; a pause found by the probe renders the error branch. Two shapes for one fact.
  • A refresh that lands cold again would have announced arrival over a sheet still waking.
  • The fallback numbers the Tags row 8 (placeholder key rows 5 to 7) until the table answers, then 5 on a tile with no lidar. From G36, and now on screen for the whole wake.

Tests: web 473 → 483. e2e/warming.spec.ts 15 → 18, one worker. axe and feedback 47 of 47.

G34
landed
, late
pr
#27
built
sonnet
read
fable
health
on track

The findings that were not about looks, and a log that can say who built what

Problem: Three earlier issues had been reviewed after they merged, and the findings that were about correctness sat unfixed. The log had stopped six issues back, and its schema could only describe an issue built by one of two models and reviewed by a third.

Fix:

  • tools/pull_manifest.py: a key must match the three real key shapes, a destination outside --out is refused, the size is checked before the file replaces anything, and a request times out at 60 s.
  • docs/licenses.md and the export's source list: the buildings theme is ODbL with CC BY 4.0 footprints that carry their own credit, and 3DEP names the bucket it is read from.
  • The evaluate page divides rasters and point clouds by the tiles that have one, says ", partial" when a size is short, and names its rows "Point count" and "Point clouds, LAZ".
  • e2e/warming.spec.ts: each scratch server has its own vars file, so the shared server is never repointed at a fake tower.
  • lib/log.ts: implementer may be fable, reviewer may be opus or none, and none reads "no review ran".
  • Six entries written, G30 to G33, G35, and G36, and the plan runs G1 to G37 with a canceled status for the one issue that was.

Results: local dev server and local query service, 2026-09-21.

suitebeforeafter
index and evals462469
web unit379417
e2e, whole suite in one runn/a121 of 121
warming.spec.ts alone, twicen/a15 of 15, both

The log can now say an issue had no review.

Review: retroactive, after merge and deploy, 2026-09-21. Eight findings and a run of nits, each one reproduced. Fixed in a follow-up the same day.

  • The set download size read as whole when only imagery was complete: its qualifier asked about imagery alone, so a set whose point clouds were the short category said nothing at all.
  • tools/pull_manifest.py's key pattern took a Unicode digit and a trailing newline, paired any prefix with any extension, and dropped a refused key in silence.
  • The same file opened <dest>.part<pid> without O_NOFOLLOW, so a symlink planted there would have been written through.
  • The license note named a buildings source with no coverage in this country, and rested the 3DEP terms on a page written about a different service.
  • The masthead said every issue was built by Opus or Sonnet and read by Fable before it was ready. Six entries on the page said otherwise.
  • G21 and G24 were still done in the plan with no entry, and the comment above their allowlist said so rather than closing it.

Also caught before merge, in the branch's own browser run:

  • Two new entries had a table with an empty first heading → the accessibility check failed on the log page, 4 of 4 rows. The cells are named and a unit rule catches it now.
  • The warming spec, run in parallel as asked, started two scratch servers on one port and one stopped the other. It runs in one worker, in order.

Unfixed (written down):

  • The warming spec is in order, not parallel: three dev servers in one directory do not all come up.

Tests: index 462 → 469, web 379 → 417, query service unchanged at 214 and 98.

G26
landed
, late
pr
#32
built
sonnet
read
fable
health
on track

The eval wakes the table before it asks

Problem: make eval against the deployed query service wrote hag and fused as not run (G9) when the container was merely asleep, because the probe turned a 10 s timeout into the same False a 422 produced and a cold wake is 48.8 s median. The run looked complete, was half missing, and named the wrong cause.

Fix:

  • wake_tower polls /healthz before any probe, up to 180 s, tied to the Worker's own 150 s start budget and the ~100 s live wake. Only a connection error, a timeout, or 503 starting count as not ready.
  • A paused month raises with the service's own sentence. A 502, a 404, a page that is not JSON, anything else, raises at once with the code and the detail.
  • The three probes share one call; unreachable raises, never False. Only 422 and 501 mean "the service does not serve this path"; a 429, 500, or 503 at probe time raises with the service's sentence.
  • not run (G9): 422 in the results, with the code that produced it. The previous results file stays untouched when a run stops.
  • One line to stderr with the UTC time on a stop, exit 1. wake_seconds in results.json and the run header.

Results: the deployed query service after 27 quiet minutes, the night of 2026-09-21, times in UTC.

runstartedfirst query answeredpaths filledwall
this branch, --exact03:28:01Z03:29:46Z4 of 46 min 40 s
same command before, 2026-09-20n/an/a2 of 4n/a
  • Recall identical to the committed results at table version 21,757.

An unattended eval against a sleeping service fills all four paths, and a run that never reached the service stops on the spot instead of committing a half-empty table with the wrong cause under it.

Review: 6 fix now, 4 nits, 0 blocking, reproduced with stubs against the Worker's own response shapes.

  • A broken container (502) was polled 24 times over two minutes with its detail dropped.
  • A 404 or a URL without a scheme was retried for the whole 120 s.
  • A 200 that was not JSON escaped as a raw traceback.
  • Errors reached the user as a six-frame traceback.
  • A 429 from the daily cap was written as a G9 refusal.
  • The 120 s default sat inside the Worker's own 150 s start budget.

Tests: evals 64 → 93. make test green: index 510, tower 214 + 98, web 473.

G22
landed
, late
pr
#33
built
sonnet
read
fable
health
on track

The e2e suite picks its tiles from the table

Problem: Two specs named tile ids whose lidar state was true the day they were written. The county lidar run gave one of them lidar and the point export moved the same ground again, so a spec written against "this id is covered" failed once the table moved on.

Fix:

  • web/e2e/fixtures.ts: a tile by state (covered, none, pending, exported, unexported), picked from the local service at run time. The service has no filtered listing, so it samples 200 ids through one fused search, reads the full rows, and takes the first id in sort order that matches. A rerun on the same table picks the same tile.
  • Three kinds of failure with their own sentence: no tile of that state in the sample, the service could not be reached, the service answered an error. Only the first skips the spec; a down service fails it.
  • Three specs use it. The axe spec keeps its two literal ids with a line saying which state each is in and why they stay literal.
  • A spec that checks each pick against GET /tile/{id} and annotates the sample's state mix.
  • The honest fix is a filtered listing on the service, which the CLI issue will build; the sampler says it is waiting for it.

Results: the local service, table version 21,757, 2026-09-21.

statepickin the sample of 200
coveredt_002011_019744121
nonet_001986_01975579
exportedt_002011_019744121
pendingskip0
unexportedskip0
  • The sample is county-wide (x 1,986 to 2,304 of 1,975 to 2,312) and stable: 100 of 100 ids overlap across three calls, a fresh service, and a different thread count.
  • A closed port: three tests fail with could not connect (connect ECONNREFUSED).

The suite says which tile each lidar and points test ran against and why a state skipped, and stays green after the next export moves the ground.

Review: 4 fix now, 7 nits, 0 blocking, reproduced against the running service, a closed port, and a hung port.

  • The closed-port message said "did not answer within 10000ms" after 33 ms; the cause sat in err.cause.
  • Every fixture error became a skip, so a down service turned nine tests green in 33 ms each.
  • The two retained literal ids carried no reason line.
  • The implementer's reported picks were not in the sample; the real ones are in the table above.

Tests: web 473 → 483. Full e2e 125 passed, 1 skipped; after the fixes, fixtures + lidar + points + feedback 24 passed, 3 skipped (the two states with zero rows).

G37
landed
, night
pr
#26
built
opus
read
fable
health
on track

A span that ends when the page does

Problem: Every request span the site sent measured 0 ms, and the search span inside a streamed page never reached the collector. The span ended when the Response object was returned, and a page streams after that.

Root cause: a Worker's clock only moves on I/O. The shell of a page does none before it is returned, so start and end were the same millisecond. The export ran at that same moment, before the streamed search had started its own span.

Fix:

  • lib/otel.ts: a pull-based pass-through around the response body that tells three endings apart: the page finished, the body failed, the client hung up.
  • The root span ends, the tail sampling rule is asked, and the batch is exported once, at the end of the stream.
  • One waitUntil promise is registered before the handler runs, so a finished stream cannot drop its export.
  • A null body, a HEAD, a 101, a WebSocket, and a body that already carries content-encoding pass through untouched.
  • Status, headers, and bytes are unchanged, and nothing is buffered.

Results: the built site under a local Worker runtime, exporter pointed at a local listener, one curl of a search page, 2026-09-21.

spanparentbeforeafter
GET /root0 ms1,590 ms
search.towerGET /not exported1,359 ms
search.probeGET /not exported218 ms
  • curl measured 1,594 ms for the same request. One batch, three spans, page whole at 45,473 bytes.

A slow search can now be told from a fast one, and the rule that keeps every request over 2 s can fire for a page that streams.

Review: retroactive, after merge and deploy, 2026-09-21. 4 fix now, 3 nits, 0 blocking, reproduced in the repo's own Worker runtime rather than in Node alone. Fixed in a follow-up the same day.

  • The runtime never calls cancel on a JS-backed body when the client hangs up, so an abandoned request exported 0 spans at +1.6 s, +4.6 s, +12.6 s and +35.6 s, and held its waitUntil to the platform's own limit.
  • Every fixed-length response lost Content-Length and went out chunked: Response.json read content-length: 19 unwrapped and transfer-encoding: chunked through the wrapper.
  • A child span still open when the root ended was dropped, and its late end landed in a batch nothing sends, so the query service's spans named a parent id that exists nowhere.
  • Three guards had no test: mutants that removed the HEAD carve-out, stopped cancel reaching upstream, or dropped the body-error end all passed 31 of 31.

Unfixed (written down):

  • A consumer that neither reads nor cancels the body leaves the span open. No timeout guards it.
  • A body carrying content-encoding keeps the 0 ms reading.
  • Spans sent before this build cannot be repaired.
  • Whether the production edge delivers the hang-up the way a local socket does has no reproduction.

Tests: otel.test.ts 23 → 31, web 379 → 387. Six older tests now drain the body before they wait on the export, since the export waits on the body.

G36
landed
, evening
pr
#23
built
fable
read
opus
health
on track

One cursor, numbered marks, three plates

Problem: Three interactions needed a server round trip before anything changed on screen: a search submit, a result-row click, a map-mark click, each one reading as a missed click. The list showed two selections at once, and the tile sheet's aerial photo sat 24 px below the height raster it should have lined up with.

Fix:

  • search-panel.tsx: submit and retry show "Searching", dim the list, and draw a progress rule before the URL changes.
  • One cursor: pointer over a row or a map mark moves activeIndex; the map still flies on keys only.
  • map-badge.ts: each result's mark on the map wears its row's number, rank 1 on top, tile in hand magenta.
  • A clicked row or mark says "Opening" until the sheet replaces the page, and gives up after 10 s.
  • tile/[id]/page.tsx: aerial, height, and point cloud sit as three plates on one line, picture tops equal to the pixel.

Results: local dev against the live query service, 1,272 px wide, 2026-09-21.

checkbeforeafter
picture tops, three plates24 px apart0 px apart
skeleton children per row45
web unit tests373378

A search, a row, and a map mark all say what they are doing on the same frame the click lands, instead of going quiet until the server answers.

Review: retroactive, Opus reading Fable's two commits, read only, 2026-09-21. 18 findings, 1 blocking, 6 fix now, 11 nits, two reproduced with vitest against the real modules. 17 fixed in a follow-up the same day, which added the tests the review said were missing, and 1 kept on purpose.

  • Blocking: a stale retrying flag survived a completed request, so retry then a cleared field left the idle page reading as still searching, with the field arrows and Enter dead.
  • The client's request key came from raw inputs while the server's came from the parsed URL, so a bound crossing the antimeridian, or a coordinate that rounds to zero and back, produced a key no delivery carried.
  • Five more fix-now findings: no give-up on a failed retry, a half-width no-points plate between 768 and 1,100 px, a palette fallback that drifted from the real tokens, no test read the built stylesheet, and hover moving the cursor so Enter opens the row the pointer last crossed.
  • That last one is the finding kept: a pointer that moves the cursor is the behavior this issue asked for, so e2e/feedback.spec.ts pins it and the stale comments around it were corrected.

Unfixed (written down):

  • 20 badges at 22 px overlap heavily at county zoom, with no dodge and no count.
  • The sheet's fact numbers, 1 to 8, refer to nothing on the sheet. They are G7's numbered notes and stay as they are.
  • Reviewer questions 2 and 3, a superseded push delivering under the wrong key and byPointer under concurrent rendering, have no reproduction.

Tests: make test and make lint green under the review. Web unit tests 373 to 378. e2e/feedback.spec.ts 5 of 5.

G35
landed
, afternoon
pr
#22
built
opus
read
no review ran
health
on track

The deployed pieces get a history

Problem: Ground Portal sent nothing to SigNoz. Neither ground-portal-tower nor ground-portal-web appeared as a service, so cold starts and warm p50/p95 had to be measured by hand, once, from one machine.

Fix:

  • tower/otel.py: FastAPI request spans on /search, /tile/{id}, /tiles, /embed, /healthz, with the model load and the Lance open as their own spans; a search carries path, k, and the table version as attributes.
  • tower/worker/otel.ts and web/lib/otel.ts: dependency-free tracers, no npm package added to either Worker.
  • The Durable Object emits tower.container_start over the placement and the port wait. The container cannot see its own cold start, since uvicorn binds the port after the model load returns, so the first request of a process is marked tower.cold_start = true instead.
  • The budget gate's pass, paused, and capped decisions are attributes and events, and the meter emits awake seconds accrued, the month's running total, and the two priced terms behind its estimate.
  • Sampling: 100% of errors, anything over 2,000 ms, every work path, and the container's first 120 s; 10% of the rest. The roll happens once at the site and every hop inherits it.
  • Fails open: no endpoint means tracing is off and the tower serves exactly as it does with tracing on. The key is a Worker secret deployed and a .env name locally, in no committed file.

Results: SigNoz service list, last 24 hours, read 2026-09-21. Tower ops seen: POST /search, POST /tiles, GET /tile/{id}, GET /healthz, tower.meter_settle, tower.budget_check.

servicecallsp99
ground-portal-web744.49 s
ground-portal-tower24611.05 s

The deployed pieces now have a history: a search's path and its table version show up as their own spans instead of a number taken by hand once.

Review: No review ran on this branch.

Unfixed (written down):

  • No results comment was posted when this merged, so the acceptance beyond the service list above is unchecked from this entry.

Tests: tower pytest 177 → 214, tower vitest 63 → 98, web vitest 350 → 373, index pytest 462 unchanged. make test and make lint green.

G33
landed
, midday
pr
#21
built
opus
read
no review ran
health
on track

Thirty seconds, measured on a phone

Problem: G31 and G32 shipped a card and a warming clock, but the retroactive reviews on both found they stopped short of a stranger arriving from a link on a phone. Every non-home route unfurled as the home page, the card's type was unreadable at the size it is shown, and the clock still missed the fold on a real phone viewport.

Fix:

  • routeOpenGraph(path) in web/lib/site.ts: siteMetadata.openGraph now carries only type and siteName; every route writes its own title, description, and url.
  • render-brand.mjs: every type size floored around 26 px, five plates dropped to four, the accent legend now says what the magenta mark means instead of using the site's own idiom.
  • opengraph-image.jpg at quality 90 replaces the PNG, which the file convention accepts.
  • The warming clock's fixed 32dvh became clamp(22dvh, calc(100dvh - 536px), 32dvh), gated on .warming__clock rather than the wrapper.
  • web/test/head.test.ts (new): renders the head through vinext's own pipeline, asserts no two routes share og:title or og:url.

Results: real build and real viewports, 2026-09-21.

filebeforeafter
server bundle844,723 B430,340 B
card, PNG vs JPEG q90462,366 B151,836 B
fold clearance, 390 x 66432 px past34 px clear
fold clearance, 375 x 66730 px past37 px clear
fold clearance, 320 x 568138 px past81 px past

A shared /log link now unfurls as the log, and the tile card's ids are legible at 350 px instead of a blur.

Review: No review ran on this branch.

Unfixed (written down):

  • 375 x 553 and 320 x 568 still miss the fold; clearing them needs the map cut to 69 px and 44 px, neither of which is a map today.

Tests: make test, make lint, and pnpm build all exit 0: 462 + 177 pytest, 63 + 350 vitest. e2e/warming.spec.ts 15 passed.

G30
landed
, morning
pr
#20
built
sonnet
read
fable
health
on track

What a set costs before you click download

Problem: The manifest /evaluate exports named the tiles in a set but said nothing about what taking it would cost in bytes or points, or under what terms each source is published. No command turned a manifest into files on disk.

Fix:

  • manifestOf in web/app/evaluate/export-buttons.tsx adds points_key, point count, LAZ bytes, point_density, lidar_date, naip_date, naip_scene, and a license and source URL per source.
  • /evaluate shows the same totals on the page before download: points, imagery, raster, and point-cloud bytes, capture ranges, in the existing tokens.
  • index/schema.py gains thumb_bytes, hag_bytes, laz_points, laz_bytes, all nullable; index/rows.py's ensure_columns() makes upsert_tiles send only the columns the table actually has.
  • tools/pull_manifest.py (new): downloads every key a manifest names from R2_PUBLIC_BASE, verifies bytes, resumable, 8 parallel GETs at most.
  • docs/licenses.md (new): the terms each source publishes under, with the URL and the date read.

Results: backfill against the real table, 2026-09-21, table version 21613 to 21757.

columntiles written
thumb_bytes18,355
hag_bytes7,731
laz_points7,731
laz_bytes7,731

A reader can see what a set costs in bytes and points before clicking download, not after.

Review: retroactive, 2026-09-21, deferred to G34.

  • Blocks: tools/pull_manifest.py writes outside --out on a hand-edited manifest, since Path / "/abs" replaces the target directory outright.
  • The Overture license claim was narrower than what Overture publishes: the Buildings theme mixes ODbL OpenStreetMap footprints with CC BY 4.0 ones from other components.
  • Two denominator bugs on /evaluate: summary.found stood in for the count of tiles with a raster or with points, so a complete sum could read as missing data.
  • The replace ran before the size check, so a bad download landed at its destination for the next run to find and trust. Size is the whole check either way, since the manifest carries no hash, so a same-size corrupt file still passes. urlopen had no timeout.

Tests: make test green, 462 + 177 pytest, 63 + 336 vitest. make lint green. e2e/evaluate.spec.ts 5 of 5.

G31
landed
, late
pr
#19
built
opus
read
fable
health
on track

A card that unfurls as its own ground

Problem: A link to the live site unfurled with no image and no icon. A shared tile link was the worst of it: each of 18,355 sheets showed the same county blurb, even though its own thumbnail already sat on tiles.eknuth.dev.

Fix:

  • web/lib/site.ts: SITE_ORIGIN, SITE_NAME, SITE_DESCRIPTION, and siteMetadata with metadataBase, openGraph, and twitter at summary_large_image.
  • web/app/opengraph-image.png, 1200 x 630, rendered in the three Quadrangle faces out of .vinext/fonts, five real tile thumbnails in a strip, west to east.
  • web/app/icon.svg and web/app/favicon.ico, a real three-entry directory at 16, 32, 48 px.
  • web/scripts/render-brand.mjs behind make brand, with a fonts guard and a face-load assertion.
  • generateMetadata on the tile route writes its own openGraph and twitter whole, pointing at that tile's own thumbnail.

Results: checked against the real built head.

routeog:image
/the site card, 1200 x 630
/tile/t_002050_019690its own thumbnail, 512 x 512
/tile/nopefalls back to the site card

A shared tile link unfurls as its own ground instead of the county's blurb.

Review: retroactive, the next day, deferred to G33 and G34.

  • Every route without its own openGraph unfurled as the home page, since title, description, and url lived inside siteMetadata.openGraph. Fixed in G33.
  • The card was 73% of the server bundle, a 462 KB PNG where a JPEG at q90 measured 185 KB. Fixed in G33.
  • Type was set at 1200 px and shown at 350 to 600 px, so four of seven lines fell under 8 px. Fixed in G33.
  • A wrong clause in the bridge tile's alt text, and two brand test gaps that could not see a broken ICO offset chain or a missing trailing newline. Fixed in G34.

Tests: make test, make lint, and pnpm build all green: 434 + 172 pytest, 63 + 324 vitest. brand.test.ts 8 new tests.

G15
landed
, late
pr
#15
built
opus
read
fable
health
on track

The argument, written down

Problem: The piece had no page that said what it is, and no written path from a clean checkout to a live address. Numbers lived in five docs and nothing tied them together.

Fix:

  • A README that makes the case: what it is, what earns a vector, how the county became rows, what the table says, what it does not claim, and how it was built.
  • Both result tables pasted from their sources with their run dates.
  • pnpm run deploy, the custom domain route, and a deploy doc with the commands in order, the two custom domains, and the storage rule the point viewer needs.
  • The log is linked from the map sheet's top collar, first in the tab order, 44 px tall on a phone.
  • A name scrub, and a check that walks every doc and eval artifact rather than a list of files.

Results: local run, 2026-09-20.

checkresult
files the name check walks13 → 20
names removed from committed files10 in one doc, plus 4 elsewhere
eval points recited by feature id5
README em dashes0
log spec17 of 17

A reader can open one page and see what the piece claims, what it measured, and what it leaves undone.

Root cause, the scrub's real gap: the check never split on backticks, so a name written in backticks inside a doc was invisible to it. That was the exact shape the walk had just been pointed at.

Review:

  • The deploy command would have shipped a site searching a local address, with every thumbnail a bare id.
  • A person's account handle sat in a decision doc and again as a live id in a spike script.
  • The storage bucket's own hostname was committed in a doc that told readers to fetch from it.
  • Three files in the eval directory were never walked, and one named a company eleven lines above a sentence saying the repo names none.
  • Four README numbers did not trace to a source. One was invented, and now cites a run that happened.

Unfixed (written down):

  • The search has no deployed service yet, so the address serves everything but a query. That is its own issue.
  • No budget guard before a container runs.
  • The front end table waits on its own issue.

Tests: index 383 with 2 skips, query service 151, web 274.

G32
landed
, night
pr
#18
built
sonnet
read
fable
health
on track

The clock, above the fold

Problem: G14's warming view held at 390 px but sat below the fold. A reader had to scroll past the map, the legend, and the finder to reach the clock that makes a 48 s wait legible.

Fix:

  • .sheet:has(.warming) .frame takes the map from 44dvh to 32dvh, and .legend:has(.warming) .points-toggle drops the toggle that has nothing to toggle yet, both gated on .warming being mounted.
  • web/e2e/warming.spec.ts gains the fold measurement and axe checks at 390 and 1280 px, light and dark.
  • docs/frontend.md's warming section carries the before and after numbers.

Results: boundingBox() at 390 x 844, tower held cold. Desktop 1280 x 900 unchanged: clock bottom 472 px, map at its full 779 px.

build.warming topclock bottomvs the 844 px fold
before71790359 px past
after56875490 px clear

The clock that makes the wait legible is on screen the moment the page loads, on a phone.

Review: retroactive, the next day, deferred to G33 and G34.

  • The fold was measured at the iPhone's screen height, 844 px, where Safari's own bars leave 664 px on first paint, so the clock landed 32 px past the fold on a real phone. Fixed in G33's clamp.
  • The e2e spec wrote to the shared .dev.vars file, which the dev server watches and restarts on, repointing the shared port at the fake tower under parallel test runs. Fixed in G34.
  • The paused branch, which has no clock, kept the map at 32dvh for no reason. Fixed in G33 by gating both rules on .warming__clock instead of the wrapper.

Tests: make test green, 434 + 172 pytest, 63 + 316 vitest. make lint green. e2e/warming.spec.ts 6 of 6.

G14
landed
, night
pr
#17
built
sonnet
read
fable
health
on track

Forty-eight seconds, said out loud

Problem: A container that has gone to sleep takes 48 s to answer the first search, and the site showed nothing at all while it did. No front end number had been measured in one place: not search latency from a browser, not time to a visible thumbnail, not time to a first point, not a Lighthouse score.

Fix:

  • A readiness probe against the query service's health route, 3 s timeout, 60 s cache, so a cold service is known in 3 s instead of discovered after 48.
  • A waiting view in the results column: what is being waited on, one sentence on why, and an m:ss clock. No spinner and no skeleton, because both promise the wait is nearly over.
  • Polls every 5 s, gives up at 180 s, and puts the real results in the moment the service answers. The query stays in the address, so nothing is retyped.
  • A committed measurement script that takes every number below against a live address rather than a local one.
  • No keep-warm timer. The first visitor wakes the service; nobody pays to keep an idle container alive overnight.

Results: measured 2026-09-20 against the live site, M3 Max over home broadband.

measurementvalue
cold first search, wall clock48.13 s
same query 48 s later1.57 s
search embed, p50 / p95, n=201,065.5 / 1,276.7 ms
search knn, p50 / p95, n=20131.8 / 450.0 ms
search total, p50 / p95, n=201,420.5 / 1,664.2 ms
first lit tile, Fast 3G9,947 ms
first point, p50 tile, 16.1 pts/m²2,298 ms
first point, p90 tile, 28.7 pts/m²2,197 ms
Lighthouse performance, 6 runs85 to 100
Lighthouse accessibility, 6 runs100

A reader who arrives after a quiet spell is told what is happening and how long it has been, instead of watching an empty column for the better part of a minute.

What the numbers say:

  • Embed = 75% of the p50 round trip. Nearest neighbor over 18,355 rows = 131.8 ms, so the table is not what a reader waits on.
  • Point density does not move time to first point. The p90 tile carries 78% more points and arrives 101 ms sooner, inside the run-to-run spread → the number is the deferred start and the round trip, not the file size.
  • 48.13 s measured here vs 48.8 s median measured the same day by a different method → 0.7 s apart, which is why the copy commits to "about a minute".
  • Accessibility was already 100 on all six pages, so the rule that anything under 90 gets fixed here never fired and no score moved.

Review:

  • The waiting view could never resolve. The results slot latched delivery on a boolean and the boundary is keyed on the query, not on readiness, so the refresh handed it a new result onto the same fiber and it was dropped → every wait ran to the give-up line. An end to end test now flips a fake service to ready after two polls and asserts the results land.
  • A paused service read as a cold start. The budget ceiling answers the same status code as a waking container → a month-long pause told the reader to wait about a minute. Now it shows the real sentence and stops polling.
  • Every other failure read as "starting", including a crashed container: ~36 fresh start attempts per open tab over 180 s, while the reader was told it was coming up.
  • A live region that announced nothing: text already inside a region when it mounts is never read out.
  • Two nouns for one search path. The button says tags, the heading said keyword.

Two bugs the script found by being run:

  • It waited on a thumbnail selector that matched nothing, so time to first lit tile was always empty, and the failure message blamed the wrong thing.
  • It polled a route that does not exist on a target for the full 180 s → three minutes spent on a number that meant nothing.

Unfixed (written down):

  • Ten minutes of quiet does not get a cold container. An 11 minute wait measured 3.2 s; 25 minutes measured 48.13 s. The sleep setting is a floor on when it may stop, not a promise it has.
  • At 390 px the waiting block sits below the fold, so the clock needs a scroll.
  • The script's own cold path has still never run against a genuinely cold service.

Tests: index 434 with 2 deselected, query service 172 and 63, web 299 → 313.

G24
landed
, morning
pr
#16
built
opus
read
fable
health
on track

The query service, deployed, with a meter on it

Problem: The site was live and its search had nothing to call. The query service ran on one laptop, and a container that wakes on demand has a bill nobody had put a ceiling on.

Fix:

  • A container image for the query service with the model checkpoint baked in, 1.69 GB compressed.
  • The table copied to object storage, 33 files, 253,731,999 bytes, uploaded in 98 s. One setting switches the service between the local directory and the bucket.
  • One instance at most, asleep after 10 quiet minutes, woken by a request, no keep-warm job.
  • A budget guard in the Worker in front: awake seconds metered inside the Durable Object, an hourly check, a $10 ceiling, a cap of 2,000 requests a day, and a pause the site reads.
  • Two scripts that take the cold start and the warm percentiles, so the numbers below can be taken again from the repo.

Results: the deployed service, 2026-09-20.

measurevalue
cold start, median of 3 wakes48.8 s
of that, before the service does any work41 to 44 s
second query of a container's life1,690 ms
warm wall p50, image, n=301,458 ms
warm embed p50, image1,066 ms
warm nearest-neighbor p50, image181 ms
warm wall p50, keyword548 ms
warm wall p50, fused2,513 ms

The eval against it, 50 queries, k=10, table version 21613:

pathrecall@10, localrecall@10, deployed
keyword40.0%40.0%
image66.0%66.0%
height16.0%16.0%
fused52.0%68.0%
  • Three rows match to the digit. Fused differs because the deployed service scores under the weighted floor and the local column predates it.

A search on the live site returns tiles, and the month cannot cost more than the ceiling.

Review: three passes. The first blocked the merge.

  • The guard counted wakes. A request every nine minutes never lets the container sleep → 1 wake recorded, $0 priced, about $41 billed. It meters awake seconds now, and the same poll pauses at $10.05 in hour 181.
  • The meter's read and write were not transactional. State moved into the Durable Object's own storage, mirrored out for the hourly check, so a lost mirror write is stale and never wrong.
  • A capped day answered with a message that invited the retries the cap exists to stop.
  • Six error paths quoted raw storage text carrying an account id. All redacted.

Unfixed (written down):

  • Build and first deploy wall clock were never timed. Both rows read n/a with that reason.
  • The cost table stays empty until a full month has run.
  • A caller gets no signal during the 48.8 s cold start. A later issue built the warming view.
  • The deployed service is public and unauthenticated. One instance, the sleep, the daily cap, and the ceiling bound it.

Tests: index 434, evals 172, query service 63, web 281.

G21
landed
, night
pr
#14
built
opus
read
fable
health
on track

A floor under the height term

Problem: The fused search combined the image list and the height list by plain rank fusion, and it lost to the image search alone: 22 of 40 queries hit in the top 10 against 28. A height list that has nothing to say about a query still got an equal vote.

Fix:

  • search_fused takes a named scoring: rrf (still selectable), weighted, floor, weighted_floor. The default is a weighted floor, height weight 0.3, cosine floor 0.10.
  • POST /search accepts the scoring fields, refuses an illegal setting or a scoring field on a path that does not fuse, and says in the response which scoring ran.
  • The eval runs a second fused row over HTTP and probes first, so a query service that ignores the fields records "not run" and never a wrong number.
  • A sweep script pulls each query's two pools once and rescores them under every candidate. It drops the holdout rows before it opens the table.

Results: 40 non-holdout queries, county table, 2026-09-19.

pathhit@10height queries (10)MRR
image alone28/404/100.463
fused, weighted floor29/405/100.419
fused, plain rank fusion22/402/100.236
  • 64 settings swept. Neither knob alone beats 28, and 13 of 39 weight and floor pairs reach 29.
  • At floor 0.10, 11 of 40 queries keep any pooled height row, 318 of 4,000 rows. On the other 29 the fused list is the image list exactly.
  • Holdout, reported once and used for nothing: image 5/10, weighted floor 5/10, plain fusion 4/10.

The fused search now beats the path it replaced on every measure, and says on screen when no row matched on height.

Review: one pass, 11 findings, all fixed.

  • The note under a fused list called a tile "too weak to count" when it had never been in the height pool at all. It is three cases now, worded apart.
  • The decision doc's "plateau" was false at two cells, and its "not lucky" claim contradicted its own data.
  • The doc never said the fused list is the image list on 29 of 40 queries. It does now.
  • The sweep was miscounted, 30 rows against 64, and two different cosines were cited for one measurement.

Also fixed, measured in a browser:

  • The home page scrolled sideways 364 px at 1,024, 1,280 and 1,440 px, since the legend strip wanted 566 px in a 400 px column. The strip wraps now and costs 45 px of map height.
  • A tile page scrolled 48 px at 375 px when a thumbnail was missing, since a tile id has no break opportunity.
  • New sideways scroll checks on four pages at 1,280 and 375 px. 3 of 10 fail against the old layout.

Tests: index and evals 382, query service 151, web 274. Search e2e 24 of 24.

G11
landed
, evening
pr
#12
built
sonnet
read
fable
health
on track

Gather tiles, then ask what they hold

Problem: The map answered one question at a time and had no way to hold a group of tiles. A reader could not ask what a stretch of the county actually holds before taking it.

Fix:

  • Pick tiles from the results list, the tile page, or a lasso on the map over the delivered results.
  • The set rides in ?set=, delta and range encoded: 500 tiles in under 50 characters. Every link the app writes carries it.
  • /evaluate: coverage on the map with the area in km², point density and capture date histograms, tag coverage, the three lidar states, and a sample of 12 tiles, one per twelfth of the set's own density range.
  • JSON manifest and GeoJSON, sorted by tile id so a rerun gives the same file.
  • POST /tiles in the query service, capped at 5,000 ids, its rows pinned to the same shape one tile returns.

Results: local run, 2026-09-19.

checkresult
e2e75 pass, 1 skip
axe, 4 pages, light and dark, 1280 and 375 pxpass
tiles in a 50-character link500
batch cap5,000

A reader can gather a stretch of the county and download its manifest.

Review:

  • Back links dropped the set → a helper puts it on every link the app writes.
  • The decoder had no size limit → it stops at the batch cap and drops a range that would pass it.
  • An out-of-range id read as an outage, and a repeated id counted as a missing tile.
  • The pick was unreachable for screen readers, and the key hint that documented it was hidden on phones.
  • The lasso disabled panning on touch → pointer events.
  • The pick's hit area was 14 px inside a link → 24 px, 44 px on touch.

Root cause, the flaky download test: the export button was clicked before /evaluate's own code had loaded, so the click did nothing. 4 of 5 runs failed before the wait was added, 0 of 10 after.

Tests: index 350 with 1 skip, query service 111, web 254.

G10
landed
, evening
pr
#13
built
opus
read
fable
health
on track

The points themselves, in the browser

Problem: A tile's lidar showed only as a flat grey height image. The shape the points describe, a roof against a tree against the ground, was not something a reader could look at.

Fix:

  • export_points.py: reads a covered tile's points, thins to at most 250,000 with Poisson disk sampling, stores height above ground in centimetres, and writes a LAZ to storage.
  • The tile's row carries points_key once its file lands, so the table says which tiles are exported. A backfill mode fills it in for files written before the column existed.
  • A viewer band on the tile page and an inset in the results panel, both coloured on the same 0 to 40 m ramp as the height plate.
  • Arrow keys pan, shift and arrow keys orbit, plus and minus zoom, and Reset returns the camera home.
  • Four states a tile can be in, each named: points ready, no lidar, lidar read but no file yet, and the query service unreachable.
  • Points load after the page's images, so they never delay the photo.

Results: export run, 2026-09-19, read from its own ledger.

measurevalue
tiles exported405
bytes in storage522 MB
point cap per tile250,000
tiles thinned from above the cap386
time to first point, p50~1.0 s
covered tiles with a file405 of 7,731

A reader can turn a tile's own point cloud and see the roofs stand up off the ground.

Review:

  • Reset view did nothing after an orbit, because the viewer was handed a camera it treated as unchanged.
  • The key hints were inverted against what the library does.
  • The inset's sheet link dropped the search, so the sheet it opened had no score and no way back.
  • A missing file read as "the lidar is read" even when the query service was down.
  • The export saved its progress every 10 tiles, so a crash re-uploaded up to 9.

Unfixed (written down):

  • 7,326 covered tiles are still unexported. That run is about 6 hours.

Tests: index 423, query service 113, web 271. e2e 87 pass, 1 skip.

G16
landed
, midday
pr
#8
built
opus
read
fable
health
on track

The log becomes a page

Problem: The build log lived in Linear and in markdown files nobody outside the repo could open. A reader had no way to see who built each piece, who reviewed it, or what the review caught.

Fix:

  • /log: one entry per issue from web/content/log/*.md, parsed at build time, with a contents list and the plan as 18 squares.
  • A review table across entries: issue, implementer, reviewer, what the review caught.
  • Every entry checked in make test: schema, the devlog shape, sentence limits per block, and hygiene (no em dashes, nothing about who the piece is for, no name from a hashed denylist).
  • A plan row marked done needs an entry, and no other status may have one.
  • G1 and G2 rewritten in the devlog shape. G4 drops a company name from its caught line.
  • Quadrangle faces and tokens only. The entry a link lands on gets a magenta bar in the gutter.

Results: local build, 2026-09-19.

checkresult
entries parsed9
bad-field schema cases17
name positions the denylist catches8 of 8
e2e log.spec.ts14 of 14
axe, light and dark, 1280 and 375 px4 of 4

Anyone with the link can read how each piece was built and what its review found, next to the plan.

Review:

  • Phone tables overflowed with no cue → they wrap under 768 px, and the two that still scroll show a shaded edge. A new e2e check fails on the old CSS.
  • G1's 2.4 ms contradicted decision 002 → 2.5 ms.
  • caught was never shape-checked, and three entries ran to three sentences → a two-sentence rule and three entries rewritten.
  • The audience check flagged "embed job", "roof pitch" and "HTTP client" → phrase matches instead of bare words.
  • Names before ? or ! got past the denylist → fixed here and in evals/test_queries.py.

Unfixed (written down):

  • Nothing links to /log yet. The link lands after the open panel and tile page changes merge.
  • The name dropped from G4 is still in git history → the history rewrite before the repo goes public.

Tests: index 323, query service 74, web 169 → 177. oxlint now covers lib/ and e2e/.

G9
landed
, midday
pr
#9
built
sonnet
read
fable
health
on track

A second vector, from height

Problem: Every tile had one vector, from the aerial photo, so a question only height can answer had nothing to match against. Image search scored 31% on those questions in G13's run.

Fix:

  • hag_vec: a second vector per tile, embedded from its height-above-ground raster. embed.py --column hag_vec writes a sidecar, and --apply merges it into the table only when the lidar pass is idle.
  • A tile is re-embedded when its raster's sha256 changes.
  • POST /search takes path = img, keyword, hag or fused. Fused = reciprocal rank fusion, k = 60, over the top 100 on each vector.
  • Four-way toggle on the map, Image still the default. The field label, the score's scale word, the screen reader text and the legend follow the path.
  • "Matched on image" and "matched on height" sit by the plate that ranked the tile. The height label and score wait for the query service to confirm the tile has a height vector.
  • A link carries only a path, ranks and a score, each dropped when out of range.

Results: eval run on the 1,302 tiles with lidar when G9 ran, 2026-09-19, evals/g9_priority/.

pathhit@10, 40 questionsholdout, 10
image285
fused266
concatenated vectors20n/a
keyword16n/a
height alone13n/a
  • Fused finds height targets image misses (q030, q049, q053) and loses five image hits.
  • Top-1 cosine under a text query: image 0.159, height 0.074. Height alone is a weak text match.
  • Concatenating the two vectors scored below fusing their ranks → fusion kept.
  • Every number names the script that produced it, under tools/spikes/g9_*.py or the eval runner. Rerunning them reproduced each figure.

A search can now ask the lidar as well as the photo, and a tile says which one found it.

Review: three passes.

  • Provenance came from the URL. A link could print "matched on height, rank 1" over a plate that said "no lidar" → the tile page now asks the query service.
  • The score was still URL-trusted and unbounded → each scale has a range, and a score outside it is dropped.
  • Height searches said "No tiles matched" when nothing had a height vector yet → the query service's note, in reader words.
  • Screen readers heard "BM25" on every fused and height row.
  • make test failed whenever R2_PUBLIC_BASE was set, after 65 s of real network calls.

Unfixed (written down):

  • The real table has no height vectors yet. The apply runs after the lidar branch merges, and the full-county eval rerun follows it.

Tests: index and evals 339, query service 74 → 93, web 89 → 107. e2e 52 pass, 1 skip.

G8
landed
, midday
pr
#10
built
sonnet
read
fable
health
on track

Lidar over the county

Problem: Every tile had an aerial photo and nothing about its height. A roof, a tree and a parking lot can look alike from above and stand at very different heights.

Fix:

  • index/fetch_lidar.py: one PDAL pipeline per tile, readers.ept → filters.hag_nn → writers.gdal at 1 m. A 512 px height PNG, 0 to 40 m, per covered tile.
  • Each row gets point_density, lidar_date and hag_thumb_key. A sidecar holds canopy cover, canopy height and max structure height per tile.
  • A footprint prefilter skips tiles no lidar project touches: 50-tile sample 76.9 s → 58.8 s.
  • Resumable from the sidecar. --priority-file runs the eval's answer tiles first.
  • fork() after PDAL loads crashes the child on macOS → every later subprocess starts through posix_spawn.
  • A crash ledger, retries with backoff, and a guard that excludes a tile after repeated crashes.

Results: county run, 23:36 to 05:40 PDT, 2026-09-18 into 2026-09-19.

outcometiles
covered7,731
no lidar coverage10,624
errored0
total18,355
  • 7.82 B points read. Density p10 / p50 / p90 = 5.48 / 14.14 / 28.44 points per m².
  • 3 projects: 6,280, 1,077 and 374 tiles. Captures from 2010 to 2019.
  • The footprints predicted 8,818 tiles. The 1,091 inside them with no points are footprint edges (452) or open water.
  • 9 restarts: 5 PDAL aborts, all recovered, 3 manual, 1 from an earlier attempt.
  • No project classifies vegetation here → canopy uses height above 2 m outside building footprints on every covered tile.

Every tile now says whether public lidar covers it, and the covered half has a height raster the second vector reads.

Review: two passes.

  • The first came before the run: resume, structure height, the canopy fallback, real capture dates, the prefilter.
  • The second: the spawn runner discarded stdout → the NAS presence check always returned nothing. Fixed, with a test through the real spawn.
  • The doc said 5 aborts against 9 log entries → each entry classified from the run's transcripts.
  • One project named 2018 flew in March 2019 → 2018 is its funding year, checked against the agency's own record. Dates stand.

Tests: index and evals 339 → 400, query service 93, web 210.

G13
landed
, morning
pr
#6
built
sonnet
read
fable
health
on track

The table that can say no

Problem: Fifty questions existed and nothing scored the search against them. The table has to be able to say the thesis is wrong.

Fix:

  • make eval: every search path against every question, over the query service or the local table.
  • Hit = a returned tile whose center is within the question's radius of its point or any listed alternate. Recall@10, MRR with a miss counted as 0, median distance of the first hit.
  • Blocks: all, holdout, by style, and the questions whose answer has lidar. Any block under 5 questions prints counts, not percentages.
  • Height and fused paths are probed each run and switch on once served.
  • An exact scan beside the index, the table version in the output, and a guard that refuses to write tag text.

Results: real table, 2026-09-19, before the height vector landed.

blockkeywordimage
all 5040.0%66.0%
holdout, 1040.0%50.0%
seen from above, 2548.0%76.0%
height, 1315.4%30.8%
vague, 1250.0%83.3%
  • Index vs exact scan: the same hit on all 50; 1 tile of 500 differs.
  • Keyword "boat ramp" pulls airport apron tags into its top 10. Shown, not fixed.

Image search finds the stadium, the cemetery, and the solar roof at rank 1 where keywords miss them, and drops to 31% on questions only height can answer.

Review:

  • Company names from the tags were committed in the results page → a yes/no column and a write guard.
  • The exact-scan scores were distances printed as cosine. Rankings held, labels were wrong.
  • "Agree on every query" held for the hit only; one full list differed.
  • No definitions of hit, recall, or MRR on the page; a one-question block printed as percentages.
  • Two runs on the same table came out byte-identical, and all 150 entries recomputed by hand matched.

Unfixed (written down):

  • Numbers stay provisional until the day-later spot check of the questions.
  • The full rerun with height and fused waits for the county lidar pass.

Tests: index 322, query service 74, web 80. Lint clean.

G7
landed
, morning
pr
#7
built
opus
read
fable
health
on track

The page becomes a map sheet

Problem: The slice worked in the browser default font with no identity, and a result did not say where it came from without a second click.

Three directions, one picked: Bulletin (the sister project's ruled sheet), Quadrangle (a printed topographic sheet), Light Table (a photo interpreter's contact sheet). Quadrangle won: its neatline and corner coordinates answer "where" before a word is read.

Fix:

  • Double neatline around the map, corners in degrees, minutes, seconds. The results are the sheet's legend. Under 768 px the frame drops to a rule.
  • Barlow Condensed, Public Sans, DM Mono, self-hosted, 135 KB. Light and dark tokens with a tested contrast table. One accent, magenta, for the tile in hand.
  • Search lives in the URL and streams: sheet and map first, list after.
  • Legend list: thumbnail, score on its scale, tags, distance from where the map was when you searched. Arrow keys fly the map; Enter opens; Escape returns; rows are links.
  • Tile sheet: the aerial photo and the height raster as a pair, numbered notes, a land-use heading, three lidar states (covered, none, not read yet), and "more like this" from the tile's own vector.

Results: production build on this Mac.

measurevalue
browser specs24 of 24
accessibility checks, 3 pages × 2 themes × 2 widths12 of 12
hover, list → map, p950.41 ms
hover, map → list, p951.82 ms
"this view" deep link, searches sent1 (was 8)

Arrow down the legend and the map flies to each tile; open one and the photo sits beside the height of everything standing on it.

Review:

  • "Search this view" looped on its own bounds and rewrote the shared URL to the county → the reader's viewport is left alone.
  • "No lidar" covered two different facts, no coverage and not read yet → three states read from the lidar footprints and sidecar.
  • A marina tile was titled with a business name from its tags → titled by land use.
  • With the table down, the sheet dropped the aerial photo while its text said the photo was below → the photo stays.
  • Rows were not links, every page had one title, fixtures carried real business names.

Unfixed (written down):

  • Hover timing is from this Mac, not the older laptop the spec names.
  • The height plate's "matched on" label waits for the fused search.

Tests: index 271, query service 72, web 80. Lint clean.

G12
landed
, late
pr
#5
built
opus
read
fable
health
on track

Fifty questions, written blind

Problem: The eval is worth what its questions are worth. The two rows from the model spike were written with the tiles in view, and their points did not match their text.

Fix:

  • 50 query → location rows, written without running a search or reading a vector. The table was read only to turn a point into a tile id.
  • Known places from Wikipedia's lists of significant places in the county, checked against each tile's thumbnail and its eight neighbors.
  • OSM features for the plain categories (quarries, marinas, orchards, sewage plants), cited by id.
  • 6 tiles drawn at random from the grid (seed 1467), so the set is more than famous places.
  • 10 holdout rows, each > 600 m from every other row's answers. The fusion choice never sees them.
  • Tests: schema, inside the county, tile id matches the point, holdout gap, seen queries ≤ 8 words, outside names checked by hash.

Results: evals/queries.jsonl as merged.

measurevalue
seen / height / vague25 / 13 / 12
known place / OSM / map inspection31 / 11 / 8
holdout, seen / height / vague5 / 3 / 2
extra correct answers on vague rows26
hit radius, default300 m

"golf", "gravel pit", "houseboats", "a cleared power line corridor through tall forest": each has a place on the ground the search has to find.

Review:

  • 4 of 10 holdout answers sat 0 to 274 m from answers in the tuning rows → re-picked, and a test holds the gap.
  • A butte, a summit, and a waterfall cliff were filed as height rows. Height above ground reads relief as 0 → reworded or moved to seen, with new rows for dead snags, a power line cut, a grain elevator, a tree strip, and high-rises.
  • One cemetery row described an amphitheater and headstones that sit in the next tiles → rewritten to what the tile shows.
  • Some sources were named for people or companies → cite the OSM feature instead.
  • seen rows ran to 17 words, a caption rather than a search → cut to 8.

Unfixed (written down):

  • The day-later spot check has not happened. Every row says so.
  • 13 height rows wait on the lidar rasters, and lidar covers ~48% of the county.
  • 11 answer tiles are shared across rows, listed in the README.

Tests: index and evals 271 → 288, web 43. Lint clean.

G6
landed
, late
pr
#4
built
sonnet
read
fable
health
on track

A phrase lights a tile

Problem: Tiles had vectors and words, and nothing could search them from a page. The piece has a hole until one phrase lights one tile and a click shows why.

Fix:

  • Local query service (tower/): text tower from a synced copy of the checkpoint constants, kNN over img_vec at ef=100 or full text over tags_text, optional H3 r8 prefilter with validated cells.
  • GET /api/search → tile id, score, center, bbox, thumbnail, and which column matched, with Server-Timing for the embed and the search.
  • Viewport → r8 cells, clipped to the county, grown one ring so no tile in view drops out, and no filter past 500 cells.
  • Page: one input, Image or Tags, a map that fits the results, list rows with a thumbnail and the matched tag, hover linked both ways.
  • Tile page: score and its scale, matched tag, every tag, capture date, beside the aerial patch.
  • Browser default font, no typeface yet.

Results: real table, 18,355 rows, p50 over 30 warm calls.

pathembedsearch
Image~112 ms2.9 ms
Tags, no filter0 ms2.7 ms
Tags, 37-cell filter0 ms2.2 ms
  • At county zoom the filter is 4,189 cells → 21 ms, slower than none → skipped there.
  • "boat ramp" on Tags → Sauvie Island Boat Ramp first; airport apron tags ("Central Ramp") at 5 and 6.

Type "boat ramp", the map flies to Sauvie Island, and a click shows the ramp, the houseboats, and the date the photo was taken.

Review:

  • Submit plus the typing debounce → 2 identical requests → the list unmounted mid-render → the acceptance test failed 4 of 4.
  • A crafted H3 cell pasted into the filter string returned rows and leaked planner paths. Cells are validated now.
  • Zooming out with "search this view" → 440k cells, 5.1 s search, panel stuck on "Searching…".
  • Cell cover by centroid dropped 11% to 25% of tiles in view.
  • Tile page took its thumbnail from the URL → any image under any id.
  • Browser check: results were 2 px specks under the panel, the list showed only tile ids, dark panel on a light basemap.

Unfixed (written down):

  • Keyword "ramp" matches airport aprons. Ranking belongs to the eval.
  • Everything runs locally; the live URL comes with the ship step.

Tests: index 271, query service 49, web 43, browser 4 specs × 3 runs. Lint clean, build passes.

G5
landed
, night
pr
#3
built
sonnet
read
fable
health
on track

Every tile gets a picture vector

Problem: A tile had words and no picture vector. A typed phrase could only match text tags, and the imagery-only row in the eval table needs one vector per tile, with the count and cost measured.

Fix:

  • SigLIP so400m image tower on the Mac's GPU (MPS) over every 512 px thumbnail, resized to 384 → unit-length img_vec, 1,152 dims.
  • Batched merge_insert writes of 3,000 rows. The run resumes by null img_vec and builds the index whenever it is missing.
  • Unquantized HNSW index on img_vec, cosine, searched at ef=100.
  • Thumbnails mirrored to local storage → a second copy exists; the embed reads it first and falls back to the bucket.
  • smoke_query.py: 1 phrase → 10 tiles with scores and thumbnails, with and without an H3 viewport filter.
  • recall_check.py and tower_crosscheck.py rerun the numbers below.

Results: county run of 2026-09-18, 61.3 min wall.

metricvalue
tiles embedded18,355 of 18,355
download / decode failures0 / 0
end-to-end rate4.99 tiles/s
forward pass alone5.99 tiles/s
index build1.97 s
index on disk88.4 MB

Recall@10 against an exact scan, 30 text queries, recall_check.py:

settingmeanminp50
default ef0.9130.201.4 ms
ef=1000.9900.801.5 ms
exact scan1.0001.008.7 ms
H3 r8 filter, 37 cells1.0001.001.6 ms
  • Text tower cross-check, "a river with a bridge": Hawthorne Bridge tile 0.1296 vs a farm tile 0.0550. The query service's text tower and the Mac's give the same vector, cosine 1.0000.
  • ~10 min of the 61.3 were thumbnail downloads from the bucket's public dev URL, which returned HTTP 429 at 16 parallel requests → 8.

A typed phrase now finds ground that looks like it: "a river with a bridge" → the Sellwood Bridge, then the I-205 bridge over the Columbia, county wide.

Review:

  • HNSW at default ef → 0.913 mean recall, 0.20 on "a park with trees and grass". Nobody had measured it. ef=100 → 0.990 at 1.5 ms.
  • 2 writers to local storage shared one scratch dir; each deleted it first → one batch lost. Now one scratch dir per call.
  • A failed file listing read as an empty mirror → would silently re-download all 18,355. Raises now.
  • Rerun after a crash during the index build → 0 tiles to embed → index skipped, exit 0. Builds whenever missing.
  • The doc called the unfiltered result a miss. Its top 2 are bridges over rivers; the Hawthorne tile ranks 66th because the query named no bridge.
  • Doc misread its own numbers: 384 vs 512 px, recall 0.20 as "a fifth missed", single cold calls as p50, downloads as free.

Unfixed (written down):

  • Thumbnails still come from a rate-limited dev URL until a custom domain goes in front of the bucket.
  • Index at ef=100 vs exact scan for the live query path is still open.
  • The mirror checks file names only, not sizes.

Tests: index 220 → 271, plus 2 model tests run on their own; web 1. Lint clean.

G4
landed
, evening
pr
#2
built
sonnet
read
fable
health
on track

Every tile gets words

Problem: A tile had pixels and no words. The keyword baseline every catalog already has, and the third input to the fused search, needs a line of text per tile that says what is on it.

Fix:

  • Overture Maps building, place, land_use, land (release 2026-08-19.0) for the county bbox, plus the Geofabrik Oregon OSM extract (2026-09-17). OSM clip keeps named points and ways, drops ordinary streets, keeps bridges and paths.
  • STRtree join to the 256 m grid. Per tile: buildings:<count>, building and land classes, up to 15 place categories, up to 5 names.
  • Names ranked by the share of the tile each covers. A point or line counts as a 36 m footprint, 0.02 of a tile.
  • OSM names only where Overture has < 2. Which source filled each tile → a sidecar file.
  • Column written in 4 batched merge_insert calls carrying only tile_id and tags_text → image and lidar columns untouched. Full text index with positions → quoted names work.
  • search_keyword(query) → top 10 tiles with scores.

Results: make tags, county run of 2026-09-18.

metricvalue
Overture buildings642,032
Overture places86,101
Overture land_use / land30,739 / 53,161
OSM named features after clip41,836
tiles18,355
with a building8,420
with a place4,981
with a name15,501
empty59
names from Overture / OSM / both / neither11,698 / 1,676 / 2,127 / 2,854
tags_text median / max100 / 634 chars
full text index1.51 MB
wall6 s
  • First real run wrote one table version per tile → 70.5 min, 18 GB on disk. Batched write → 6 s, 4.3 MB.
  • Eval via search_keyword: "a boat ramp on the Willamette" hit, 98 m. "a warehouse cluster in the Columbia Corridor" miss: the tile 18 m from the point says warehouse and ranks 241st.

A word typed into the index now finds the ground it names: "boat ramp" → the Sauvie Island ramp, "Sellwood Riverfront Park" → the park.

Review:

  • Point or street name weighted as covering the whole tile → Forest Park and a named retreat center lost to 5 street names, 237 tiles. First fix, point = half a tile, dropped a named polygon on 7,614 tiles ("Lents Park" → a Bitcoin ATM). Point = 0.02 → 0 tiles drop a polygon covering ≥ 10%.
  • Quoted query raised "position is not found" → index built with positions.
  • Doc said no warehouse-tagged tile sat near the missed eval point. One sits 18 m away; BM25 lets "corridor" pull Columbia Gorge tiles and favours short strings. Paragraph rewritten.
  • OSM fallback = 96% street names → streets dropped, then bridges and trails restored (252 bridge segments, ~6,000 paths).
  • Compaction deleted unverified files → could remove another writer's in-flight data. Dropped.
  • OSM cache skip keyed on file time → a smoke run could feed the county run its names. Now keyed on box and filter version.

Unfixed (written down):

  • Keyword score = BM25 as is. Sentence queries drift to rare words.
  • A point or line never outranks a named polygon covering > ~2% of a tile. A school point can lose to a small named building.
  • "Sellwood Bridge" stays out of the 4 tiles its deck crosses: each has ≥ 3 Overture names, so OSM never runs.

Tests: index 179 → 220, web 1. Lint clean.

G3
landed
, midday
pr
#1
built
sonnet
read
fable
health
on track

The county becomes rows

Problem: Nothing downstream exists without a tile grid: no tags, no vectors, no map. The imagery side had to become rows first, one per 256 m of ground, each with a thumbnail a result card can paint.

Fix:

  • STAC search on the public naip collection by the county polygon, not its bounding box. Newest year whose scenes cover the county = 2022 at 0.3 m (100%). 2023 shows up in the search and covers 30%, a neighbouring state's flight.
  • Scenes pulled 12 at a time to local storage. Each lands under a temporary name, size checked against Content-Length, then renamed. A retry signs a fresh URL and resumes the partial file.
  • Fixed grid in EPSG:26910 anchored at the UTM origin → same grid on every rerun. Tile kept when its centre is inside the county.
  • Per tile: 512 px JPEG at 0.5 m per pixel, mosaicked across every scene that fills it, bbox in WGS 84, centre, H3 cells at r8 and r9, capture date and scene id from the scene that gave the most pixels.
  • Walk goes scene by scene and opens a neighbour only when the first scene leaves pixels empty. Rows and uploads flush every 500 tiles → an interrupted run resumes.
  • Rows upsert on tile_id and keep the columns later issues fill. BTREE index on h3_r8.

Results: make naip, county run of 2026-09-18.

metricvalue
scenes59
scene bytes87.90 GB
fetch wall, 12 connections194.8 min
grid tiles18,355
tiles written18,355
skipped for nodata0
failed tiles / failed uploads0 / 0
thumbnails on R21.151 GB, 62.7 KB mean
r8 cells1,885
tiles per r8 cell9.7
area, tiles / boundary1,202.91 / 1,203.57 km²
end to end331.2 min
  • One connection to the scene store = 0.93 MB/s. 16 in parallel = ~6.5 MB/s. The run held 7.5 MB/s mean at 12.
  • Thumbnail work alone = 0.062 s a tile (200 tiles, one local scene). Full run = 0.98 s a tile. The gap = scene copies and one upload process per object.
  • Range request on one thumbnail → 206, 1,024 bytes, JPEG marker at the front.

Any tile's aerial thumbnail now loads by its id, and every row says which capture it came from. Nothing a visitor can see yet.

Review:

  • Mask read with fractional offsets returned all False in the east and south eighth of a raster. 117 of 540 interior tiles of a real scene read as empty, ~2,240 of 18,355 county-wide. The smoke box sat mid scene and missed it. Window rounded before both reads → 0 skipped on the county run.
  • Band 4 of NAIP = near infrared, not alpha. First version masked on it → water and hard shadow counted as nodata. Now masks only on a band tagged alpha.
  • Verbose mode turned on the HTTP library's debug logger → signed URL in the log. The test faked the request, so it could not see it. Now tested against a real local server.
  • A failed scene left no manifest row → its tiles counted as nodata and the run exited 0. Pipeline now stops, and the manifest is checked against the search it came from.
  • A rerun nulled the columns later issues fill. Upsert now carries them forward.
  • Bounding box search → 82 scenes, 23 never touch the county (~35 GB). Polygon search → 59.

Unfixed (written down):

  • Pure black ground reads as nodata and can pull pixels from a neighbouring scene. 16 interior tiles.
  • bbox = two reprojected corners. Off from the true envelope by 0.24 m in the west, 3.75 m in the east, ~7 thumbnail pixels.
  • r8 is not always r9's parent: 1,333 of 18,355 tiles, each cell indexed from the centre.

Tests: index 179, web 1. Lint clean.

G2
landed
, night
pr
n/a
built
sonnet
read
fable
health
on track

The skeleton, and a map that renders

Problem: No repo existed. The tile index, the web side, the one table everything reads, and the build loop's tooling all needed a place to land.

Fix:

  • Python side on uv that will build the tile index.
  • Web side on Next.js running on Cloudflare Workers.
  • Schema for the one table everything reads: one row per 256 m tile, a slot for an imagery vector, a second slot for a lidar-derived one.
  • 2 example ground truth rows.
  • Build loop tooling: a secrets scan on every commit, a lint pass after every commit, and the skills that start, review, and close an issue.
  • The one thing a person can look at: a basemap of Multnomah County.
  • The map library's worker and the shared file it imports copied into the static directory before every build, and the map pointed at them.

Results: the skeleton as merged.

whatcount
plan rows18
example ground truth rows2
skills for the build loop3
Python tests73
web tests1

What it can do now: render the county, answer a health check, and refuse a commit that carries a key.

Review:

  • First version of the map = a white page. The build passed, the health route answered, no tile was ever fetched: the bundler had moved the map library's worker script away from the file it imports → both files are copied before every build.
  • The report had offered a green build as evidence the page rendered.
  • Both example ground truth rows pointed at the wrong places.
  • A generated-file skip in the secrets hook had switched off every pattern for that filename.

Tests: Python 73, web 1. A browser test asserts that basemap tiles arrive.

G1
landed
, night
pr
n/a
built
opus
read
fable
health
on track

Which model, where the query runs, how big a tile

Problem: Every later issue imports a model, a query host, and a tile size. None of the three had a measurement behind it.

Fix:

  • Image model = SigLIP so400m. 20 NAIP tiles cut by hand over the county, checked by eye, 2 sets of 10 text queries written against them.
  • First set too easy: SigLIP and RemoteCLIP (a CLIP tuned on aerial imagery) both put the right tile first every time.
  • Second set written to be confusable: a park vs a golf course, warehouses vs an industrial yard, a runway vs a freeway.
  • SkyCLIP untested: the checkpoints on Hugging Face are image encoders with no text tower.
  • SigLIP = the checkpoint the previous piece already serves → the query side reuses a container that exists.
  • Query host = the container that serves the text tower, reading a Lance table on object storage. A Worker cannot open the table, and the container holds the query vector the instant the embed finishes.
  • New measurement: an H3 cell column on every row → a search scores only the tiles on screen.
  • Tiles = 256 m. Prefilter column = H3 resolution 8, ~10 tiles per cell.
  • Constants in one file each (index/checkpoint.py, index/config.py), reasons in docs/decisions/.

Results: hard query set, 20 tiles, this Mac.

modelhard p@1hard p@3tiles per second on MPS
SigLIP so400m9/1010/105.4
RemoteCLIP ViT-L-144/108/1024.2

H3 prefilter spike, synthetic rows at SigLIP's width:

measurevalue
synthetic rows23,182
filtered query, median2.5 ms
  • Filtered query faster than an unfiltered one → no coarser cell needed in front of it.

What it can do now: nothing a visitor can see yet. The constants every later issue imports are in one file each, and the reasons are written down with the numbers.

Review:

  • Both decision records said the three hour time box had run out. Reflog and file times → the whole spike took 28 minutes.
  • IVF latency rows compared against exact search without saying the index returned about a quarter of the exact top 50 on random vectors.
  • p95 column did not reproduce.
  • Several counts in the prose disagreed with the data files.

Tests: none added. The spike scripts in tools/spikes/ rerun every number above.

What the reviews caught

IssueBuilt byRead byWhat the review caught
G38opusfableThe 180 s give-up was silent to a screen reader on the sheet, and its line landed between the heading and a sentence it contradicted, shifting the plates a second time. Two stacked display headings for one state.
G34sonnetfableRetroactive review, after merge and deploy: the set download size stayed silent when point clouds were the short category, since its qualifier asked about imagery alone. Seven more findings and a run of nits, from a key pattern that accepted Unicode digits to a masthead that said every issue was read before it was ready.
G26sonnetfableA broken container was polled 24 times over two minutes with its own sentence dropped, and a 429 or 503 at probe time was still written into the results as a path the service does not serve, the shape of the bug the issue exists to fix.
G22sonnetfableA closed port failed in 33 ms and the message claimed a 10 s wait; a down service skipped nine tests in green instead of failing them; the reported fixture picks were not in the sample at all.
G37opusfableRetroactive review, after merge and deploy: a request the reader abandons exported nothing, since the runtime never calls a JS-backed body's cancel, so the span stayed open and the batch was never sent. Every fixed-length JSON response also lost its content-length and went out chunked.
G36fableopus18 findings, 1 blocking: a stale retrying flag survived a completed request, so a retry followed by a cleared field left the page saying Searching forever, with the field and Enter dead. 17 were fixed in a follow-up the same day and 1 was kept on purpose.
G35opusno review ranNo review ran on this branch.
G33opusno review ranNo review ran on this branch.
G30sonnetfableRetroactive review, 2026-09-21: a hand-edited manifest could write outside --out, since Path / "/abs" replaces the target directory outright. Five more findings, all deferred to G34: the Overture license claim, two denominator bugs on /evaluate, a verify-before-replace bug, and the 3DEP channel name.
G31opusfableRetroactive review, the next day: every route without its own openGraph unfurled as the home page, since siteMetadata set title, description, and url inside it, and two link-preview scrapers treat og:url as canonical. Two more findings, on card legibility and bundle size, all three fixed in G33; the small corrections stayed for G34.
G15opusfable14 findings, 1 blocking: the documented deploy command carried a `--` that made the tool ignore both variables, so the first deploy would have published a site searching a local address. Three more would have been published as written.
G32sonnetfableRetroactive review, the next day: the fold was measured at the phone's screen height, 844 px, where Safari's own bars leave only 664 px on first paint, so the clock actually landed 32 px past the fold. A second finding: the spec wrote to the shared .dev.vars file the dev server watches, repointing the shared port at the fake tower under parallel runs.
G14sonnetfableOne blocking: the waiting view could never resolve, because the results slot latched its first delivery and dropped the real results when they arrived, so every wait ran to the give-up line. Four more, including a month-long budget pause reading as a slow morning.
G24opusfableThree passes, and the first blocked the merge: the budget guard counted wakes where the bill counts awake seconds, so one request every nine minutes would have recorded one wake, priced it at zero, and billed about $41. The second pass found the meter's state in storage that is not transactional, where a stop during a settle recorded 1,081 s for 540 awake.
G21opusfable11 findings, all fixed. The worst was a bug in the on-screen note: every row with a height vector and no height rank was called too weak to count, including tiles that were never in the top 100 on height, which live was 7 of 10 results on one query.
G11sonnetfable13 findings, 2 blocking: the app's own back links dropped the set from the URL, and the set decoder had no size limit, so `?set=5-zzzzz` expanded to 60 million ids and held the server 13 s.
G10opusfable9 findings, none blocking: Reset view did nothing once the reader had orbited, and the keyboard hints named the opposite of what the keys do. The inset's link to the tile sheet threw away the reader's search.
G16opusfable8 findings, none blocking: at 375 px the review and plan tables ran 201 px and 105 px past the screen with no scroll cue. The G1 entry cited 2.4 ms where decision 002 says 2.5 ms, and nothing checked the length of `caught`.
G9sonnetfableThe tile page printed "matched on" and a free-text note straight from the link → a crafted URL could claim a height match on a tile with no lidar. The second pass found the score still unbounded (a cosine of 5.000 printed).
G8sonnetfableThe fork crash fix threw away stdout → the NAS presence check always came back empty, and every batch re-pushed its rasters. The doc counted 5 aborts against a crash log of 9 entries.
G13sonnetfable15 findings. Tag text naming companies was committed in the results page; the exact-scan scores were distances labeled as cosine; a one-query block printed "keyword 100%, image 0%".
G7opusfable14 findings: "Search this view" fed its own bounds back into the search → a shared downtown link fired 8 searches in 9 s and walked out to the whole county. The lidar note said "no lidar" for tiles the lidar pass had not reached yet.
G12opusfable17 findings. 4 of 10 holdout answers sat inside the tuning rows' answers; height rows described terrain relief that a height-above-ground raster reads as zero; one row described an amphitheater its tile does not show.
G6sonnetfable17 findings plus a browser check. A double request unmounted the results, a crafted H3 cell returned rows, zooming out built a 440k-cell filter that hung the panel, and the results were 2 px specks under the panel.
G5sonnetfable15 findings, then 14. The HNSW index at default settings lost 8 of the true top 10 on one query; two NAS writers could delete each other's batch; the doc read a correct search result as a miss.
G4sonnetfable11 findings, then 6, then nits: name ranking counted a point or street as covering the whole tile → parks lost their names to street segments on 237 tiles; the first fix dropped a named park or campus on 7,614. Phrase queries crashed, and the doc misread its own eval miss.
G3sonnetfable16 findings, 2 blockers: a mask read on fractional windows returned all False near a raster's east and south edges → about 2,240 of 18,355 tiles would have been dropped as nodata. Verbose logging printed the signed storage URL, token included, through the HTTP library's own debug line.
G2sonnetfableThe basemap page was white in dev and in the built Worker because the bundler moved MapLibre's main module without its worker and shared files, and the report had offered a green build as evidence it rendered. Both example ground truth rows also pointed at the wrong places, and a generated-file skip in the secrets hook had switched off every pattern for that filename.
G1opusfableBoth decision records said the three hour time box had run out; the reflog and file times showed the whole spike took 28 minutes. The IVF latency rows were compared against exact search without saying the index returned about a quarter of the exact top 50 on random vectors, the p95 column did not reproduce, and several counts in the prose disagreed with the data files.

The plan

#IssueModelStatus
G1Day 0 decisionsopusdone
G2Repo skeletonsonnetdone
G3NAIP tiles over Multnomah Countysonnetdone
G4Tags per tile from Overture and OSMsonnetdone
G5Imagery embedding runsonnetdone
G6Search route and vertical slicesonnetdone
G7Map, source view, visual identityopusdone
G83DEP lidar to height above groundsonnetdone
G9The second vector and fused searchsonnetdone
G10Point cloud in the browseropusdone
G11Evaluate viewsonnetdone
G12Ground truth queriesopusdone
G13Eval runner and results tablesonnetdone
G14Front end numberssonnetdone
G15Shipopusdone
G16Project log on the live siteopusdone
G17A derived capture through the same ingestsonnetplanned
G18How it was builtopusplanned
G19Building height words from Overturesonnetcanceled
G20Lidar-derived tagssonnetplanned
G21Weighted fusion beats img_vecopusdone
G22e2e fixtures from the tablesonnetdone
G23Open source the repoopusplanned
G24Deploy the query serviceopusdone
G25A resumed export skips points_keysonnetplanned
G26A cold tower marks hag and fused not runsonnetdone
G27The three minute recordingsonnetplanned
G28Structure columns, a third vectoropusnext
G29Points in the table as a training batchopusplanned
G30The manifest as a dataset previewsonnetdone
G31A social card and the head tagsopusdone
G32The warming clock above the foldsonnetdone
G33The first thirty seconds from a linkopusdone
G34Review findings and the log entries owedsonnetdone
G35Telemetry for both halves of the toweropusdone
G36One cursor, three platesfabledone
G37The site's span ends with the streamopusdone
G38A cold tile link says it is wakingopusdone
G39A CLI over the table, and a skill that teaches a session to use itopusplanned
G40What this build asked of Lance, with the numbersopusplanned