Taxonomy: TVR is a car
10:35 pm AWST · 0e00635
A 1994 TVR reached the review queue with model "Other". The make is real
— British sports cars, out of production since 2006 — and the feed
simply had nothing useful to say about the model, which is not a reason
to drop the car.
Added to CANONICAL_MAKES and mapped. Queue back to zero.
Audit breakers: the page that panned, the page that weighed 4MB, and five more
10:16 pm AWST · a2fd4c2
Seven broken findings from the six-section audit, all fixed but the one
that belongs to the crawl track:
Every grid child on the site now carries min-width:0. Grid items default
to min-width:auto, which lets a wide table or chart propagate its
intrinsic width THROUGH its overflow-x-auto wrapper: the wrapper never
scrolls and the whole viewport pans instead. Measured at 375px, /market
was 643px wide, /coverage 803px, home 390px. One zero-specificity rule
in globals.css restores the scroll boundary to the wrapper, and the two
flex rows that still forced width (shrink-0 right-hand spans in the
two-price and demo panels) wrap onto their own line on phones. All three
pages now measure exactly 375px at phone width.
/value stops shipping the whole catalogue. The full pick-list serialised
to 3.9MB and rode into the page as a client prop, 632KB gzipped on the
wire before the picker could hydrate, on the site's primary tool. The
page now ships the makes list plus the slice for the URL's own subject
(so shared links hydrate complete), and the picker fetches per-make and
per-model slices from /value/options, served off the same server cache
the page reads. The page is now ~48KB raw before compression, a
thirteenfold wire reduction, and picker counts gained their thousands
separators on the way through. A failed slice fetch says so in the
placeholder rather than presenting an empty list as a fact about the
corpus.
km=abc no longer values a phantom. Strip-then-Number turned any
digit-free string into 0, and the car was valued as a zero-kilometre
example while the hero hid the 0 as falsy. A string with no digit now
refuses with 'must be a number', and a genuine 0 km prints, because a
valuation whose subject is invisible is unfalsifiable.
The market state board knows the eight states: anything else folds into
the honest UNKNOWN bucket at build time, so a bad feed can never mint a
ninth state again. The board was rebuilt; PAKENHAM is gone from it.
The /api sample JSON was black-on-near-black inside the yellow panel,
1.1:1 contrast on the page's flagship honesty block. The Code component
states its colour instead of inheriting the panel's ink.
Autotrader AU's extractor regression (zero yield since 13 Aug after
8,875/1,878 the day before) is real and is the crawl track's to fix; a
task chip carries the full diagnosis. The coverage page was truthfully
reporting a dead crawl and needed no change.
Review fixes: a ninth state, an orphaned tape, and a fact that looked frozen
10:02 pm AWST · 6dadda3
Front-to-back review feedback, each item run to its root:
Pakenham is a suburb. Pakenham Mazda prints it where its platform keeps
a state, the listing-stated path trusted extractors where the
dealer-location fallback never did, and 154 listings put a ninth state
of Australia on the market board. The ingest gate now normalises the
listing-stated state through the same eight-code rule as the fallback,
the rows are repaired to exactly what guarded code would have written
(VIC, dealer_location provenance, migration 0133), and the state panel
renders the null bucket as 'not stated', last, instead of as UNKNOWN
among the states. The panel also drops anything else non-state as a
belt.
The Time and Sales tape was the third child of a two-column grid,
wrapping alone into a half-width orphan beside empty space. It spans
the full row now, with the tape itself in two internal columns so forty
events read as a block rather than a tower.
Coverage grew past 700 sources and outgrew scrolling: a search box and
state filter, in the house idiom, with the server rendering every row
and the client only toggling hidden. 'mazda' filters 731 to 39
instantly, plus WA to 6, and the count line always states n of total.
The on-roads panel comes off the market page. The measurement is real
and the 300-pair bar is honest, but three qualifying states read as a
coverage gap, not as rigour. The board keeps computing and the panel
can return when five states clear the bar.
The rates strip moves from /market to /reports: a reader of live stock
does not need the cash rate, and a reader of a weekly report is exactly
who wants the backdrop.
The insight was never frozen. It is retested after every crawl, four
hypotheses each time, and the colour premium keeps being the only one
to clear the bar; it had moved 6 to 7 percent during the very days it
looked static. The panel now says so: retested N hours ago, held
through 15 recomputes since 12 August, the only one of 4 tested to
pass. Stability now reads as re-earned rather than stale.
Weekly and monthly reports, three drafts of honest
09:34 pm AWST · 97284f9
The reports mechanism: market_report holds immutable snapshots of closed
UTC periods, written by cron the morning a period ends (weekly Mondays
00:15, monthly on the 1st at 00:25) and never rewritten, the same way an
observation is never edited. /reports renders the archive plus the
running week, measured live by the same generate_market_report_data the
cron uses, so the preview and the eventual report cannot disagree. The
print stylesheet is the PDF export; the share paragraph is the report in
one block for wherever Taj is posting; the first weekly report writes
itself on 2026-08-17.
It took three drafts of the measurement to deserve the page, and the
drafts are the story:
v1 measured the crawler and called it the market. 189,308 "new to
market" in a 190,000-listing corpus was discovery, not listing; a live
delta of +127,150 was coverage; the ask movers were composition (BMW X3
"up 39.9 percent" on an n that went 93 to 413). Caught by reading the
output against the corpus before anyone else saw it.
v2 removed those, framed corpus growth as corpus growth, replaced movers
with repricing counts, and aligned valuations with the site counter's
exact filter, proven by both reading 545. The adversarial review then
found what v2 left standing, and it was blocking: sales counted delisted
listings, and dealer-group syndication exits one sold car as up to
thirteen listings. 5,895 "confirmed sales" were 3,719 physical cars; the
repricing table had the same disease under a caption claiming coverage
could not move it, which the corpus tripling inside the window
falsified.
v3 counts cars everywhere. A car is confirmed sold in the week of its
final exit, none of its listings still live, its value the final ask of
its last listing, once. A repricing is one car cut on one day, however
many syndicated copies repeat it; the dedup landed on 7,577 for the
window, the reviewer's independently computed figure to the digit. The
basis strings ride in the payload and the page renders them from data,
so the copy cannot drift from what the SQL measured. The share paragraph
carries its units ("cars confirmed sold", "in final asking prices",
"once per car per day") because it is the one artefact designed to leave
the page without its footnotes, and an archived report's corpus figure
says "at close" rather than "now".
Also from the review: window bounds are pinned to UTC rather than the
calling session's timezone, so a manual run from Perth cannot write a
shifted week into an immutable row; a failed archive read renders as a
failure instead of the factual claim "no closed period yet"; the
publish-date sentence is derived so it cannot go stale; and the
week-so-far cache key carries its Monday, so the first hour of a new
week cannot serve the closed week as the running one.
One reviewer claim was dismissed with evidence: the valuations filter
allegedly diverged from the deployed counter, but the live function
carries the api exemption and both figures read 545.
Withheld, with reasons in the payload's absent array: new-to-market and
median-ask movement, until coverage stabilises enough that a week's n
holds still. The writer is proven idempotent and refuses open periods;
the pre-monitoring test row was removed rather than published, because a
report of zeros for a week nobody watched is true and misleading at
once.
Migrations 0129 to 0132, byte-identical to the ledger, tree in sync at
132 = 132. Integrity 13 of 13.
Brushing, spent where the plan admits it: the comparables table
08:59 pm AWST · 389a6c2
The plan grants brushing exactly once: on the odometer axis, to filter
the comparables table, never touching the fit. Both halves land
together, because the exception without its table would have nothing to
filter and no reason to exist.
The comparables table lists the same cars the cloud plots, one row per
physical car, each linking to its listing, sorted lowest-odometer first
and saying so rather than posing as representative. It is served in
full and default-trimmed by the hidden attribute, so the brush works
entirely by toggling rows the server already sent: no request, no
invented data.
Drag across the cloud and the table filters to the window. The status
line restates on every brush that the chart, its fit and its rung still
use all cars, because a filtered table beside an unfiltered fit is
exactly where a reader would assume otherwise. A sub-8px drag is a
click and clears; Escape clears brush and readout both; the aria path
is the table itself, which is complete without the pointer.
The client keeps its ignorance: the brush selects marks by screen
position and reads the window's km bounds off data attributes the
server wrote. Selection is mark containment, not axis arithmetic.
setPointerCapture failure is swallowed rather than allowed to abort the
gesture, found by driving the brush with a deliberately bogus pointer.
Cost, stated because the admission was a measured number:
ChartInteraction was admitted at 1.3kB for readout and focus, landed at
1,442 bytes, and now carries readout, focus, sync and brush at 3,114
bytes gzipped, against the 107.6kB the rejected library wanted.
The chart set is built. What remains is time: price_over_time earns its
ribbon as the rollup accumulates days, and Phase 4 unlocks by SQL.
The rates strip: econ_series gains its first reader
08:50 pm AWST · 5a56023
Chart set #8, and the smallest of them: the RBA cash rate under the
market panels, 432 monthly points held since the table existed and read
by nothing until now.
Drawn as steps, captioned as averages. The series is the monthly average
of the cash rate target, so each month's value holds flat across the
month it describes and no diagonal invents a glide the rate never made.
Readout rides the same ChartInteraction as every other chart, one mark
per month.
Copy is locked to context, not causation: the strip states the rate and
claims nothing about its effect on cars, which no chart here has earned.
It wears no evidence rung, deliberately, and says so on the page. The
ladder measures what our corpus supports, and the RBA's series is not
thinner because our crawler had a slow week. What official data owes is
stated provenance instead: source table, licence, and the month it runs
to, all carried on the strip.
No migration: this is a reader, and the context job has been maintaining
the table all along. d3 stays server-side (client chunks verified free
of it), integrity 13 of 13.
The Vintage Ladder: asks by year, and the review that made it honest
08:43 pm AWST · c50266c
Chart set #4 ships: used asks per model year on one shared, labelled
axis, one price per physical car, the same basis as the range bar and
the book so the three never disagree about what an ask is. The copy
holds the line the chart set drew when it named this chart: a
cross-section of today, not a price history. The depreciation claim
belongs to model.price_over_time and has to be earned there.
The ladder joins the evidence spine as model.by_year, requirements
mirroring model.book because both are the same claim about where asks
sit, one sliced by price and one by year. The rung gates the form
exactly as chart_rung declares it: rung 1 draws a median tick per
qualifying year, rung 2 earns the middle-half boxes. Within a rung the
house rules ride per row: a box needs 8 used cars, a tick needs 3, and
a year under 3 is absent here and listed in the years table. Live at
both rungs: Ranger draws 15 boxes and 3 ticks at rung 2; FJ Cruiser
draws 6 ticks and no boxes at rung 1, its strip reading "needs priced
cars 30, this cohort has 24". Readout and range-sync reuse
ChartInteraction unchanged, the client component is byte-identical, and
the active year highlights on year routes.
model_chart_board's years rows carry the new per-year quartiles,
vehicle-deduped, validated exactly against live_snapshot before
anything read them: Ranger 2021 agrees on all four figures at n=347.
The adversarial review of the diff found three real defects, each
confirmed against live data before fixing:
- The axis took its domain from quartiles it never draws. Tick-only
years carry p25/p75 in the payload, and the domain used them while the
render did not: 172 live cohorts had inflated axes, Cupra Formentor at
80 percent empty chart. One predicate now decides both what draws and
what scales; Formentor's drawn span went from 17 to 61 percent.
- The hand-rolled ticks reintroduced the exact zero-spread collapse
CHARTS-PLAN cites as the reason d3-scale was admitted, and the helper
had never actually been installed. Volkswagen Jetta rendered one
gridline labelled $10k over a $1,135 window. d3-scale is installed
now, server-only as admitted (client chunks verified free of it, and
ChartInteraction still 1,442 bytes); Jetta reads seven distinct ticks
at $200 precision. The scatter's own y-axis, the original site of the
bug, gets the same treatment, and the range bar's min and max labels
are keyed by anchor so a one-price cohort cannot collide keys.
- The refusal branch conflated unmeasured with insufficient: on a null
rung it printed "not enough priced used cars", a fabricated thinness
claim whenever the ladder read fails, under a header that may show
thousands of cars. Four states are told apart now, and the genuinely
thin branch states the measured count, because a thin corpus is
reported with its real number, never with an adjective.
Migrations 0127 and 0128 are byte-identical to the ledger; the tree
remains in sync at 128 = 128. Integrity 13 of 13 before and after.
Phase 3: interaction, and a facet cannot outrank its evidence
08:14 pm AWST · 65c373e
The charts gain their one client component. ChartInteraction carries the
two client primitives the plan admits, readout and focus, and stops
because our reads are a fetch schedule and a smooth transition between
them would be an invention.
Every scale stays on the server. Marks carry their full label and, where
sync applies, a precomputed percentage; the client does nearest-mark hit
testing in screen space, positions a crosshair and a readout box, and
reads labels into an aria-live region for arrow-key traversal. That
division is what keeps it at 1,442 bytes gzipped against the 1.3kB the
Phase 0 evaluation admitted, and it is why the component never learns
what an axis means.
The readout restates n every time. A dot on the cloud reads "2021 used ·
$45,990 · 63,247 km · one car", because "$45,990" alone is exactly the
unearned precision the tooltip rule bans. Book buckets read their range
and count, and the shelf buckets say they are the shelf.
The synchronised range panel: pointing at a used car on the cloud marks
where it sits in today's range bar. The price-to-percentage maths
happened on the server against the bar's own domain; demo cars carry no
sync, because placing a demo ask on a used-only scale is the overlay
rule's cross-basis mistake in miniature.
The facet invariant is the substance of the change. A year chip thins
the data, and the drawn rung is now min(the cohort's ladder ceiling,
what the slice's own evidence supports), resolved by first failure
against chart_rung_requirement, server-side, in the same render, so no
paint exists where a thin slice wears the whole model's rung. Proven in
both directions on the same cohort: hyundai/kona draws its scatter at
rung 2, and /2026, with 177 cars spanning only 11,014 km, draws the same
chart at rung 1 with the strip reading "next rung needs km spread (km)
20,000, the 2026 slice has 11,014". Near-new years are exactly where a
depreciation fit lies, and now the frame says so before the fit rule
even declines.
Found while proving it: a year slice filtered from the 800-mark cap is
not a count of that year's market, and the empty-state copy claimed it
was. Old Ranger years read "only 0 cars with a price and an odometer"
when the corpus holds them and the cap dropped them. The line now states
the cap when it binds. The book and aged panels also say "all years" on
year routes, since they are not year-faceted and every chart beside them
just filtered.
Verified in the browser end to end: readout, crosshair and sync fire on
pointer; focus announces "800 marks", ArrowRight and End traverse, and
Escape clears including the sync marker; integrity 13 of 13; tsc and
build clean.
Deferred, stated rather than implied: the Vintage Ladder until
model_chart_board carries per-year percentiles, the rates strip until
econ_series has a reader, and brushing until a comparables table exists,
since the plan admits brushing only to filter one.
Ladder: evidence follows the taxonomy, so a merged spelling cannot rot
07:53 pm AWST · 08d94ed
The first crawl run on the reconciled tree failed integrity honestly:
oldest ladder measurement 13.0 hours old. 27 cohort keys had been renamed
out of the taxonomy by spelling merges (Honda|CR V and Honda|CRV both,
twelve Mercedes|* Class variants) and refresh_evidence_series only ever
upserted what the live corpus showed it, so their evidence froze at the
2026-08-13 bootstrap stamp forever.
Dead keys are reaped from evidence and ladder_state after the
rollup-derived insert, which would otherwise resurrect them from history
each cycle; ladder_event keeps its append-only past. Taxonomy keys with
nothing live get their live-derived measures written as zero with a fresh
stamp, because nothing live is a measurement, not a skipped one, and it
is exactly what immediate demotion needs. check_ladder_spec returns
clean, integrity 13 of 13, tree still in sync at 126 = 126.
Docs: a backfill is undone by a process still running the old code
07:47 pm AWST · 8134ba9
Fixed the cars4us badge at ingest, backfilled the history, and two hours
later the running crawl pass re-reached that source with the previous
build and wrote 30 junk badges back — 378 distinct badges became 408.
Nothing alerted. It was found by going to look.
Recorded in entry 20 because the trap is general and not about badges: a
value fixed at ingest plus a backfill is half a repair while a
long-running process still holds the old code, and the half that is
missing gets silently reverted for as long as that process runs.
The tree rebuilds the database exactly, for the first time
07:43 pm AWST · 6d35826
Three sessions applied migrations to the same database in parallel, and
files are numbered by the order the ledger applied them, so each session
shifted the others' numbering. The drift check counted 40 differences and
failed the last two scheduled crawls at their final step, which is the
trap this project keeps naming: a workflow red for a known reason is a
workflow nobody believes when integrity genuinely fails.
Reconciled per the runbook now in OPERATIONS.md:
- Two files on disk had slugs the ledger had never seen at all:
raw_capture_pending_drop_idx and raw_capture_pending_upload_idx, both
built with CREATE INDEX CONCURRENTLY, which cannot pass through
apply_migration. Both indexes exist in the catalogue, so each file's
exact content was applied as a recording migration first: a no-op
against the live database that gives the ledger its missing rows. Their
content survives byte-identical at 0124 and 0125.
- sync-migrations --write then emitted all 125 ledger entries at their
true numbers: 24 new files, including the six unfiled migrations from
the crawl and storage session (unmatched_marketplace_dealers,
cohort_median, raw_capture_bodies_move_to_object_storage,
source_max_found_with_parse_rate, cohort_listings,
source_max_found_with_fetch_rate), which close the runtime landmine
where a rebuilt database served a three-column source_max_found to a
script reading four.
- The 18 stale old-numbered files are removed in this same commit,
because --write exits 0 while they remain and the check would stay red
forever otherwise. Eight of them carried richer commentary than the
ledger recorded; that prose lives on in git history at the old paths,
and the check is right that the tree should say what the database
actually saw.
The check now prints "in sync: the tree rebuilds the database exactly"
and exits 0. Replay order is also correct end to end: the live_snapshot
migration creates three indexes at position 103 and the recorded drop
removes them at 114, matching the catalogue, which holds only the
primary key.
125 applied, 125 files, integrity 13 of 13.
Migration: model_ticker retires, the boards it fed are live
07:40 pm AWST · a75ce3b
Phase 2: the ticker reads a board, and the page that never rendered renders
07:40 pm AWST · 242c3b2
Ford Ranger could not open. model_ticker assembled the whole position at
request time against the live corpus, that outgrew the API's 8 second
ceiling at the three largest models, and a page that has never rendered
once has nothing to serve stale, so the cache could not save it either.
The single most valuable page opened with an apology exactly where the
most people would see it.
model_chart_board holds the assembled JSON per slug pair and
model_event_board holds the tape as rows, both rebuilt in the six-hourly
cycle from the same live_snapshot as every other board, so the whole page
agrees about when "now" was. The request path is a primary key read:
0.155ms for Ranger. Ranger, X-Trail and Tucson all serve in under a
second. model_ticker has no callers left.
The scatter stops lying about what a dot is. It plotted listings under a
caption that said cars, and used syndication runs 1.80x, so a car on many
sites was many dots. One dot per physical car now, its most recently
observed listing, and points_total counts cars: CX-30's 537 listings
became 263 cars. The header's used count is relabelled listings for the
same reason, that one the other way around.
The Book and The Shelf ship, first of the chart set. Fixed $1,000
buckets, one count per car, the same buckets the daily rollup captures,
so the cross-section and the eventual price_over_time ribbon are one
measurement at two timescales. The shelf shares are stated on the chart:
Ranger asks sit 16.8 percent in the thousand below a ten-thousand
boundary against 4.8 above. Copy locked to "where dealers price, not
where cars sell" until exit evidence clears rung 5.
The daily rollup starts capturing that histogram today, per model,
because the 12 August hole proved the rule: a day the rollup did not
capture cannot be recovered, and the shape of a distribution cannot be
rebuilt from its median. 2026-08-13 and earlier are honestly null. The
first cut of this died on max(jsonb), which does not exist; plpgsql only
parses SQL at first execution, so running it is what found it.
Aged stock is split by condition on the page, because 75 percent of aged
stock corpus-wide is new or demo and the "nobody wants this" reading only
holds for used. Conditions under 30 dated listings are stated, not drawn.
The rung becomes visible. Each chart sits in a frame keyed off its
ladder_state rung, pure CSS: dashed while evidence is thin, solid once it
holds, yellow at the top rungs, and a missing rung draws the rung 0 frame
because absence of evidence about the evidence is the thinnest state
there is. Under the frame, the static evidence strip states the rung and
what the cohort lacks for the next one, read straight from the ladder's
block columns. Interaction is Phase 3; honesty did not wait for it.
Migration files 0112 to 0116 are byte-identical to what the ledger
recorded, the discipline the sync audit taught: 0115 carries an ugly
create-then-drop scaffolding statement because that is genuinely what was
applied, and a prettier file would be another lie for the drift check to
find. Verified: all five match the ledger by content, board counts match
the retired RPC on every scalar for CX-30, both new boards are in
board_spec, and the integrity suite passes 13 of 13 before and after.
Taxonomy: clear the review queue, in both directions
07:40 pm AWST · 918a449
Two vehicles had been sitting in review all day, and they wanted
opposite answers.
**Barker Trailers 2014** is a trailer builder. Added to NON_CAR_MAKES as
"barker trailers" — matched on make and model together, because the make
alone is a surname and could be a dealership's own name. Marking it
ignored by hand would have failed the integrity check that every
out-of-scope vehicle is one the rules exclude, which is exactly what
that check is for.
**Rambler American 440, 1967, 4d sedan** is a car. Rambler is a real
marque, defunct rather than unrecognised, and a 1967 sedan at a dealer
is a classic somebody is selling. Mapped.
The queue is meant to mean "a human needs to decide this". Two entries
is not much, but a queue that never empties stops being read.
All 13 integrity checks pass.
Eval: stripping descriptors out of badges does not help, so it is not shipped
07:40 pm AWST · 431adc2
Entry 20 left this open as the obvious next fix: 8,770 live listings
carry a model-year code, colour, body type or transmission inside the
badge, and those cars can never match another car's trim. The plan was
to strip them and see.
Measured on held-out folds, 46,172 predictions across the 30 largest
trim-varying models:
shipped (normaliseBadge) $1,519
+ model year, transmission, body $1,517 -0.2%
+ colours $1,517 -0.2%
Noise. Not shipped.
Two reasons it does so little. The 5.3% is concentrated in a handful of
sources rather than spread through the corpus, so it rarely reaches a
model with enough cars to measure. And badge levels already do the work
string matching was being asked for: a comparable of another trim is
moved onto the subject's by its measured price difference, which makes
exact agreement matter much less than it appears to.
The risk was always an invisible mismatch — merging a Black Edition with
a black car — and paying that for 0.2% would be a bad trade made on a
hunch. Keeping the harness so it can be re-run if an extractor change
ever makes badges dirtier.
Docs: DATA-QUALITY 20, what is in the badge field
07:40 pm AWST · e56c5e3
Half fixed, half measured and left open, which is the honest split.
live listings carried a trim nothing could match. Stripped and
backfilled.
White 8 Speed Sports Automatic Wagon". Across 164,491 badged live
listings, 8,770 (5.3%) carry a model-year code, colour, body type or
transmission in the badge, concentrated in a few sources: zaimotors has
177 distinct badges for 178 listings.
Not fixed here because it has no safe handle. These tokens share a
vocabulary with real trims — Sport is a descriptor in "M Sport" and a
trim on a Ranger, Black Edition is a trim and Black is a colour — and
normaliseBadge is conservative precisely for that reason. Loosening it
trades a visible duplicate for an invisible mismatch.
Written down with the measurement and the method: extend eval-badge.ts,
which already compares badge strategies on held-out folds, and ship only
if it beats what ships. Same bar the trim restriction cleared, which is
why that one can be trusted.
Taxonomy: a listing id in the badge field made 857 cars unmatchable
07:40 pm AWST · d5ca718
cars4us publishes its trim as "110TSI Comfortline For Sale ID72363".
The id is unique per car, so **857 of its 881 live listings had a badge
nothing else could ever match** — 857 distinct badges where 378 real
ones exist.
A trim that matches nothing is worse than no trim. The valuation weights
an exact-badge comparable at 1.5 and everything else at 0.8, and
restricts to the subject's own trim once it has eight of them — so these
cars were penalised against their own identical twins, and could never
form a cohort.
Found while looking into why a 2023 Cupra Ateca VZX was hidden from the
year picker: its 2022 sibling is recorded "Vzx For Sale ID67438", which
is the same defect on a different source.
Stripped at ingest in tidyModelString and backfilled across vehicle.
Deliberately narrow — the pattern is anchored at the end and requires
the literal "for sale id" followed by digits, because badge text is
where real trims live and a greedy cleaner here would eat one. Verified
it leaves "GLX (4WD) 5 Seat", "Sale" and "For Sale" untouched.
cars4us now has 378 distinct badges across 880 listings, and its cars
can find each other.
Valuation 0.8.0: confidence now asks whether the odometer was accounted for
07:40 pm AWST · 7707002
A 2023 Cupra Ateca VZX listed at $33,000 was valued at $45,868 and
labelled **high confidence, 0.76**. Reported by Taj.
Two faults behind it.
**The picker hid the year.** Years were narrowed by the selected badge,
and the same trim is spelled differently across years because dealers
append series and model-year codes: the 2023 VZX is recorded `Vzx KH`,
the 2024 `Vzx`, the 2022 `Vzx For Sale ID67438` — a listing title leaked
into the badge field. Choosing VZX therefore offered 2024 and 2022 and
silently dropped 2023, so the only way to ask about the car was to
describe it as a year newer. Narrowing badges by year is right and
stops the picker offering a 2019 Wildtrak; narrowing years by badge
hides data we hold. Years are now model-wide.
**Confidence was flattered by its own blind spot.** The three subscores
ask how much evidence there is, how much it agrees and how fresh it is.
When the kilometre slope cannot be fitted, all three are inflated by the
same fact — a cohort of nearly-new cars is numerous, tightly priced and
recently listed. Every 2024 Ateca sits at ~3,400km; the subject had
65,000; with no spread there is no slope. The workings said exactly that
("comparable set too small or km spread too narrow to fit a slope
honestly") and the label said the answer was trustworthy. Widening the
range was right and was not enough: a wide range with a confident label
still reads as a confident answer.
An unadjusted estimate is now trusted in proportion to how little
correcting it needed — exp(-gap/60,000km), multiplied through rather
than averaged in, because it is a discount on all the evidence and not
another kind of it. Exactly 1 whenever the slope was fitted, so the
ordinary case is untouched.
Ateca 2024 VZX @65,000km high 0.76 -> low 0.30
Ateca 2023 @65,000km medium 0.54 -> insufficient 0.22
RAV4 2022 @60,000km high 0.80 -> high 0.80
Sanity suite unchanged, largest gap 5.5%. The user also now sees why:
"the comparables average 55k km away from this car and no odometer slope
could be fitted, so nothing corrects for that difference".
Migration: index the upload queue too
07:40 pm AWST · 4476486
The body drop finished — 181,059 cleared, 31 failures, all of them
objects that were momentarily unreadable and correctly left with their
body_gzip intact. 220,529 rows now hold their bytes only in object
storage.
23,125 remain in the old column: rows the crawler wrote during the pass
that was still running the old code. Collecting them means restarting
the upload loop, and a restart begins with an empty cursor and scans
past every row already migrated before it finds work. That is the query
that timed out at 3,400 rows the first time, now with 220,529 rows in
front of it.
So the upload queue gets the same treatment 0096 gave the drop queue: a
partial index over exactly the rows still waiting. Both shrink as their
queue drains and can be dropped once no row anywhere holds a body_gzip.
Docs: corpus figures as at 2026-08-14 15:35
07:40 pm AWST · 0deb834
174,914 live listings across 698 dealer sources, 766,585 observations,
11,276 price changes, 5,363 delistings, 89 makes, 1,492 models. The
morning's figures were 9.5 hours old and understated the corpus by
13,836 listings — a day's crawling with the raised caps.
Ops: the four-hour rule for a single source
07:40 pm AWST · 8d3960e
Records the time bound on maxDetailPages in the runbook: why it exists
(7-8 seconds a page is the site's latency, not ours, so stock-only
sizing wrote 24-hour budgets), that it is the only rule permitted to
lower a cap, and the two thresholds that stop it churning configs.
Crawl: a raise has to be worth the write
07:40 pm AWST · 55b405a
An hour after setting seven caps, fit-detail-caps wanted to move them
1,750 -> 1,800: a config write and a line of report for about three
minutes of extra fetching. The lowering path already required a source
to be meaningfully over its limit before acting; the raising path had no
such threshold and would nudge every cap up to the time bound fifty
pages at a time.
A raise now has to clear 100 pages or 10%. Eight proposals become three,
and the three left are the ones that matter: osborneparkkia 400 -> 600
(956 URLs found, 400 held after one crawl), westsideauto and bartons
100 each.
The point is that the report should list changes worth making. A tool
that proposes noise trains you to apply it without reading, which is
exactly when it will propose something that is not noise.
Crawl: a budget is a promise about time, and mine could not be kept
07:40 pm AWST · 2154924
fit-detail-caps sized every budget against stock and never asked how
long that would take. Measured across finished runs, most dealer sites
answer in **7 to 8 seconds a page** — their latency plus our politeness,
neither ours to change — so the caps I wrote this morning were:
tonywhitegroup 9,800 pages 24.2 hours
brisbanecars 8,150 19.9
countrycars 6,200 14.9
booran 4,350 9.7
johnhughes 3,950 9.3
Twelve sources were over four hours, and the current pass has been
sitting on its tail for six of them: tonywhitegroup 3,826 pages done of
9,800, another ten hours to go, while 690 other sources wait for the
pass to end so they can be re-observed.
So the cap is now bounded by time as well as stock: four hours at the
source's own measured rate. It costs those sources nothing over time —
they are rate-limited either way and the detail rotation takes
successive slices across passes — it just stops one yard deciding when
everyone else gets looked at again. Net -18,272 pages per pass, which is
not lost coverage but the same fetches spread over shorter passes.
This bound may lower a cap, which nothing else here may do. The rest of
the sizing is judgement and a hand-set number deserves to survive it; a
budget that cannot complete in a day is not judgement, it is an
arithmetic error, and this one was mine.
Hysteresis at 1.25x, because the first version trimmed klosters from
2,000 to 1,900 for being 4.2 hours instead of 4.0 — churning a config to
buy twelve minutes, on a rate averaged from a handful of runs. Five
hours is over; four and a bit is not.
Takes effect next pass: the running one read its caps at source start.
Docs: DATA-QUALITY 19, a limit with no ordering decided what cars were worth
07:40 pm AWST · fcd4207
valueCar fetched comparables with `.limit(400)` and no ordering, so for
any model with more than 400 live listings it got an arbitrary 400 — 86
models, 71% of the corpus. A 2022 Outlander was valued from 9 of the 241
that exist.
Recorded because of how it stayed hidden and what made it critical. A
biased sample of a large set still lands near the middle when every
comparable is corrected onto the subject and averaged, so the error was
real and small and nothing pointed at it. 0.7.0 — restricting
comparables to the subject's own model year, a change that cut held-out
error 19.3% and is right — turned that mild bias into the entire basis
of the estimate. I shipped it without asking what fed it.
The tell was sitting in sanity-valuations.ts for hours: worst case 7.9%,
inside its own 10% threshold and easy to read as ordinary dispersion.
Asking why rather than accepting it is what found this.
Two rules out of it: a limit without an order by is a sampling decision,
not a safety valve; and a change that narrows what evidence is used
makes every upstream sampling flaw newly load-bearing.
Sanity check: measure both sides in the same money
07:40 pm AWST · 88c262a
The check compared an estimate that excludes government charges against
a raw median ask mixing drive-away ads with excl-charges ones. A
drive-away ad in NSW carries roughly 3% stamp duty, so a cohort half
advertised that way reads about 1.5% high before the model has done
anything at all — part of every gap it reported was the yardstick, not
the model.
The cohort rows now come back and are restated with the same
priceExcludingDuty the valuation itself applies. Of the twelve cases,
every one had drive-away ads in it: Ranger 152 of 737, Hilux 72 of 397,
RAV4 54 of 356.
**The honest numbers are slightly worse than the ones I reported an hour
ago**, and that is the point: Outlander +2.0% -> +4.6%, X-trail +0.5% ->
+2.3%, Model Y 2023 -0.1% -> +1.9%. Nothing regressed; the comparison
stopped flattering itself. Mean absolute gap 2.5%, largest 5.8%, and a
mean *signed* gap of -0.1% — no systematic bias in either direction,
which is the thing worth knowing and which the old measurement could not
have told us.
Valuation 0.7.1: fetch the subject's own model year, all of it
07:40 pm AWST · b45bc41
The comparable query was one `.limit(400)` with no ordering, so for a
popular model it returned an arbitrary 400 rows out of thousands.
of 158,540 — 71% of the corpus**. A 2022 Outlander valuation was working
from **9 of the 241 that exist**, because nine happened to fall in the
slice.
That was survivable while every comparable was corrected onto the
subject and averaged; a biased sample of a large set still lands near
the middle. It stopped being survivable in 0.7.0, which restricts to the
subject's own model year when it has at least eight. The restriction is
only ever as good as the sample of that year, and this one built an
estimate from nine arbitrary cars while 232 sat unread. I shipped that
restriction this morning without checking what fed it.
Now the subject's year is fetched on its own and in full, with
neighbouring years fetched separately and bounded — their job is to
measure the shape of depreciation, not to outvote the subject's year.
The largest make/model/year cohort in the corpus is 1,890 and only
twelve exceed 1,000, so the 1,000-row limit reaches all of all but
twelve, and those are new-car cohorts the used gate discards anyway. The
two run in parallel, so latency is the slower of them rather than the
Twelve cohort-median cars against their cohort's median ask:
Outlander 2022 +7.9% -> +2.0%
Corolla 2021 -4.7% -> -1.9%
X-trail 2021 +1.6% -> +0.5%
Model Y 2023 -0.6% -> -0.1%
Hilux 2021 (395 comps, now resolves)
Mean absolute gap 2.57% -> 1.98%, worst case 7.9% -> 5.6%.
Found by asking why the worst case in sanity-valuations.ts was 7.9%
rather than accepting it as within tolerance. The answer was not the
model; it was that the model could not see.
Boards state their own age, and the ledger learns about three dropped indexes
07:40 pm AWST · 6644c96
Two things, both about a number being believed for longer than it should be.
**The index drop the ledger never heard about.** `live_snapshot` created three
indexes and ad-hoc SQL dropped them, so the migration record and the catalogue
`pg_stat_statements` still shows each create and its matching
`drop index if exists` at one call each. 0110 records the drop, idempotently,
and puts the reasoning on the table comment so nobody restores them from the
plan alone. They went because every consumer aggregates the whole 44MB table,
so a sequential scan is the intended plan and an index only cost write time on
the truncate-and-insert.
**The cadence the pages could not see.** The board list was a `VALUES` block
inside `check_stale_boards()`. The integrity suite therefore knew each board's
refresh cadence and the pages drawing from those boards did not, so a board
whose refresh had stopped kept rendering in the present tense on the site while
the staleness was reported to a checker nobody reading the page runs. That is
the same shape as every other defect this project has found: the check existed
and fired into an empty room.
`board_spec` holds it once and both sides read it. `check_stale_boards()` keeps
its exact signature and columns, so `check-integrity.ts` and `health.ts` are
untouched. `board_status()` answers the same question on the request path,
optionally for one board so a page pays for what it draws from rather than
counting all nineteen.
Four states: `fresh` draws no dateline, because a dateline on every number all
the time is noise a reader learns to skip. `ageing` and `stale` date the figure
and `stale` says the refresh has not run. `empty` separates a board that should
hold rows from one where holding none is the honest answer, which is the
distinction that matters: an empty `markdown_board` means no cohort had 30
listings showing a cut, an empty `market_make_board` means something broke.
Nothing is ever hidden because its board is old. A six-hour-old median is still
the best answer we have and withholding it would be a worse lie than dating it.
Proven by forcing each transition and restoring the exact prior values:
- `fresh` -> `ageing` -> `stale` on `aged_board`, then back to `fresh`
- `empty` on `ladder_event`, which is genuinely empty, with
`check_stale_boards()` flagging it at `must_have_rows` true and not flagging
it at false. Probe row removed, `board_spec` back to 19 rows
- end to end in the browser on /market: no dateline while fresh, the dated
qualifier once `market_state_board` was forced stale, and gone again after
restoring. 13/13 integrity checks before and after
The `asAt` stamp is built from two `toLocaleString` calls rather than one,
because a single call puts a comma between the date and the time and
"14 Aug, 00:25 UTC" reads like a list rather than a timestamp.
Refuse to create a listing under a retired source id
11:42 am AWST · 22ac6f3
The duplicate-URL check found 277 URLs live under two source ids again on
2026-08-13, twelve hours after the merge that was supposed to end that, and
403 rows were retired to clear it. This is where they came from.
200 rows appeared under dealer:cherymoorooka and 200 under
dealer:cheryspringwood at 16:46 and 16:49 — hours after 0083 merged both into
dealer:motoramachery, disabled them, and asserted neither held a live
listing. Nothing has been written to either since, so it was a single run
rather than a leak: all three ids point at motoramachery's own sitemap, so
whatever walked it created the same page three times.
Three defences all missed it for the same reason. crawl.ts only walks sources
where enabled is true. 0082's constraint refuses to enable a source that
still names its survivor. Both govern *reaching* a source through the
scheduled path, and neither stands between a source id and observeListing —
the only function that creates a listing row. Anything holding an id could
write to it.
So observeListing now refuses, naming the survivor in the error. Creation
row that records the same page, which is the correct reading rather than a
resurrection. The source lookup is cached per process and folded into the
existing state fallback, so this costs one query per source per run and
removes one per new listing.
Verified end to end rather than reasoned: observeListing called against
dealer:cherymoorooka with a URL not in the corpus threw "was merged into
dealer:motoramachery; refusing to create a listing under a retired source
id", and the row count for that URL was 0 before and 0 after. A live source
returns mergedInto=null, so the guard does not fire on the normal path.
The distinction this records in DATA-QUALITY 14: 0084's check is a monitor,
this is the defence. A check that reports the same breach every morning is a
subscription to the problem. Third time in that file a rule existed, was
correct, and was never in the path that mattered.
Left open deliberately: the guard is application code, so a direct insert
still bypasses it. The trigger version is the structural fix and belongs in a
migration — not written now, because three sessions were live against this
database and a numbering reconciliation was already in progress.
Integrity: two failures, one real and one the check inventing work
11:29 am AWST · fb55966
**277 URLs live under two source ids.** 274 of them were one cluster:
cherymoorooka, cheryspringwood and motoramachery all configured with
motoramachery's own sitemap as their indexUrl, so three sources created
the same listing three times. My first reading was that merged sources
were still being crawled; all 24 are in fact already disabled, which
makes these leftovers from before that — and worse than ordinary
duplicates, because a disabled source is never re-crawled and so its
listings can never be delisted. They would have stayed live forever.
403 rows retired to `merged`, each naming its survivor. The first
attempt did not, and the schema refused it — listing_merged_names_its_
survivor is a CHECK that status = 'merged' implies a survivor, and it
caught exactly the sloppiness it exists for. Live 170,178 -> 169,787,
duplicate URLs -> 0.
**The second finding was the check's own fault.** It reported a Fiat
Ducato and an LDV Deliver 9 "held out of scope by nothing but a past
UPDATE". I nearly reversed both on the documented policy that vans are
kept deliberately — then read the rows: badge `Motorhome`, badge
`Campervan`, body_type `truck`. Every one excluded by a rule that
exists.
The check applied isNonCarMake and isNonCarBodyType and not
isNonCarBadge; it did not even select the badge column, while ingest
excludes on all three (`outOfScope` in store.ts). So it was manufacturing
findings about correctly classified data — worse than missing them,
because a finding gets acted on. It now applies the same three rules as
the thing it checks.
All 13 checks pass.
Migration: the drop pass, made possible twice over
10:55 am AWST · f9e5d06
Upload finished: 182,839 bodies in object storage, 6 failures, all of
them transient gateway errors whose rows kept body_gzip and never got a
key — intact, just unmigrated. 500 of 500 verified byte-identical by
sha256 before anything was cleared.
Then the drop pass, which was wrong in two independent ways.
**Writing.** One UPDATE per row, each rewriting a row that carries a
45KB TOAST value while the crawler competes for the same table:
0.4 rows/s, which is 130 hours for 187,839. Now the batch is verified
concurrently and cleared in a single statement — fifty downloads and one
write. A failed batch clears nothing and is picked up next run, so
nothing is ever half-done.
**Reading.** The scan could not find its own work. The predicate looks
selective and is not: 187,839 of 574,000 rows match, but the oldest
captures come first by id and are mostly ones whose bodies were
deduplicated away long ago, so it removed 23,942 rows by filter to find
50 — 24,168 buffers, 1,562ms, a timeout on the first query of the run.
A partial index over exactly the rows still to clear takes it to 0.300ms
and 50 buffers. It shrinks as the work proceeds and vanishes when the
migration is done, which is the pleasant property of indexing a work
queue rather than a table.
The read-back before clearing stays. It is the difference between
believing an object is there and having just read it, and what it
guards is the only remaining copy of pages that can never be re-crawled.
Four hours in the background is a fair price for that.
Ops: runbook for raw bodies in object storage
09:59 am AWST · 87301f3
Where bodies live, why every reader must go through rawBody(), why
writes are object-first-row-second and must not be reordered, how to run
and resume the migration, and what each failure mode means.
The three that matter: verify before --drop, because it removes the only
other copy of pages that can never be re-crawled; VACUUM FULL takes an
exclusive lock and belongs between passes, not during one; and an
orphaned object is harmless while a row pointing at a missing object is
not, which is the whole reason for the write order.
Re-parse verified end-to-end through the new path: rawBody reads storage
byte-identically (200/200 by sha256) and reparse.ts reads through
rawBody — 5 listings re-parsed from 5 captures, none without a
retrievable body. The capability that justified the migration still
works.
Migration: prove the objects hold the original bytes before dropping them
09:32 am AWST · ba628a9
While a row still carries both body_gzip and a body_key, the migration
can be checked rather than trusted: read it back through rawBody(),
which prefers the object, and hash against the body_sha256 recorded when
the page was first fetched. A match proves the object holds exactly the
bytes the crawler saw, judged by a hash written before object storage
existed.
200 of 200 byte-identical.
This is the gate for --drop, and it needs to be. Clearing body_gzip
removes the only other copy of a page that in many cases can never be
re-crawled — the listing is delisted and gone — so "the upload returned
no error" is not a good enough reason. Refuses with a non-zero exit if
any body fails to match.
Sampled rather than exhaustive on purpose: it reads whole bodies out of
TOAST *and* out of storage, so checking all 182,071 would cost more than
the migration itself, and the drop pass re-reads every object it clears
anyway. This answers the different question — whether the two copies
agree — which a sample answers well.
Discovery: a WAF verdict no longer skips the sitemap check
09:02 am AWST · 48fdc4a
probe-candidates returned "WAF challenge on inventory pages" and stopped
there, ahead of the sitemap fallback. The reasoning was that a site
refusing our guessed paths refuses everything.
canningvaletoyota disproved the reasoning: a challenge on the guessed
paths, and a sitemap advertised in its own robots.txt naming 66 vehicle
URLs that fetch without complaint. Its robots.txt is also explicit that
we are welcome — Disallow: /wp-admin/ with Allow:
/wp-admin/admin-ajax.php, which is a site owner deliberately permitting
the endpoint its own pages call.
The fallback's existing comment already made this argument about 404s —
"a guessed path answering 404 says nothing about whether a dealer
publishes its inventory" — and a challenge on a path we invented says
just as little. The WAF verdict survives as the last word rather than
the first, and now reads "and no usable sitemap", which is the thing
that earns it.
**It did not rescue this source.** canningvaletoyota's detail pages are
client-rendered too: 200, no price, no JSON-LD, nothing to parse. The
probe was right to reject it and wrong about why, and only the second
half is fixed here. Its verdict now reads "jsonld recognised at
/vehicles but it lists no vehicles", which points at the real problem.
Worth having anyway: the ordering was wrong independently of whether
this particular site was behind it, and two sources have been rescued by
that fallback before.
Docs: what the discovery pipeline is running into
08:41 am AWST · a139b31
Second sweep of the marketplace-name channel returned 4 of 91, against
17% on the first. Same channel, and the fall is not noise: the first
sweep took the dealers whose trading name is their domain, and the tail
is names that are not — "CarPlace", "First Choice Freo", "Next Ride
Malaga". Guessing a domain from a business name stops working before the
names run out.
The more useful finding came from fixing the probe's verdicts. Once
"never found the page" and "reached it and could not read it" stopped
being the same message, the rejects turned out to share a shape:
canningvaletoyota (a real dealer with 1,000+ cars), carplace,
coastlineautos, morleycityhyundai and eliteautodeals all answer 200 on
their stock page with no vehicle data in the HTML — no JSON-LD, no
listing links. carplace is Astro, morleycityhyundai is WordPress, so
this is not one platform missing one extractor. It is a rendering style,
and it is becoming common.
Seeded tradeprestige (68 URLs) from this sweep. Eight sources today.
Crawl: autopact reads its whole sitemap now, and a heuristic I disproved
07:59 am AWST · 8709a6b
The sameSite fix landed: autopact reports found=3250 where every
previous pass found 24, 200 of 200 attempted parsing at 100%. Its cap
was never raised to match, because fit-detail-caps ran before the fix
when the source still looked like it had 24 URLs. Raised 200 -> 2000 by
hand with the evidence recorded on the source row.
Then tried to generalise it so the next unlocked source needs no human:
trust `found` when a source parses >=95% of what it attempts. Added the
parse rate to source_max_found, wired it in, ran the dry run — and it
proposed lifting pattersoncheney from 700 to 4,850.
That is the exact waste this script exists to prevent. pattersoncheney
finds 3,872 URLs and holds 766 cars after many passes of rotation; the
other ~3,100 are demonstrably not cars. Its parse rate is 99% anyway,
because parse rate is measured over pages *attempted* and a source only
attempts URLs its extractor recognises. It says nothing about the URLs
found and never fetched, which are the ones the question is about.
So the heuristic is reverted rather than shipped, with why it fails
written where the next person will reach for it. What actually separates
autopact from pattersoncheney is coverage history — how much of its
found set a source has had the chance to fetch — and that deserves
measuring properly rather than approximating with a number that happened
to be to hand. The ratchet handles it meanwhile: live grows, the cap
follows, a few passes instead of one.
parse_rate stays on the RPC. It cost nothing, it is real, and it will be
wanted when coverage history is done properly.
Caps applied with the correct rule: pattersoncheney 700 -> 1,100 (766
live plus headroom, not 4,850), tonywhitegroup 7,500 -> 9,800,
cranbournetoyota 450 -> 700, blackburnkia 200 -> 300. +1,910 pages/pass.
One snapshot per cycle, and the detoasting that was the real cost
07:43 am AWST · 3ccfb77
Six refresh jobs each scanned the corpus, five through `listing_current`
and its two laterals. `refresh_depth_boards` paid twice on its own: `_cur`
through the view, then `_md` re-running the same observation lateral purely
to reach `displayed_previous_price`, which the view does not project.
`live_snapshot` does that once. Live listings only, mirroring
`listing_current` exactly including the merged exclusion and the
`fields_empty` back-lookup, plus the parsed values the boards read and an
`advertised_days` resolved against one clock so every job in a cycle agrees
what "today" meant.
**The scan was never the expensive part.** The first version blew the two
minute ceiling, and EXPLAIN put the blame somewhere I had not looked: the
nested loop finished in 436ms on a 1-in-33 sample while the aggregate above
it took 3,843ms, with 122,000 buffer hits the scan had not touched. Those
are detoast reads. Every mention of `coalesce(f.fields, o.fields)` fetches
and decompresses the whole payload again, and the query named it fifteen
times per row: three each for `views`, `dapprice` and `egcprice`, and six
more inside `advertised_days`, which reads two keys three times each and is
a SQL function, so it inlines into the caller.
`jsonb_to_record` cut that to one detoast and the pass to 109s. Still wrong,
and the full plan said why: 93 of those seconds were that single remaining
detoast, 115,701 block reads. Only 40,918 of 161,134 current observations
carry a payload at all and those total 172 MB, but they sit scattered
through a 1,642 MB toast table shared with every historical observation, so
reaching 172 MB of data costs about 900 MB of reads. It was growing with the
history, not with the number of live cars.
So the five values are extracted when the row is written and `fields` is
still in memory and untoasted, by the trigger that already sets
`fields_empty`. The refresh reads narrow columns and never touches the toast.
**21 seconds.** Depth boards 7m58s to 6s. The daily rollup had failed on its
full 15 minute ceiling on both 12 and 13 August, so the daily series was
being written by nothing but ad hoc runs; it now takes 7 seconds.
Verified rather than reasoned about, because this rewrites what every board
is built from:
- all 223,952 non-empty observations: every `f_*` column byte-identical to
the `fields->>key` it replaces, so the expressions could stay untouched
and no board can move
- 156,116 listings compared column by column, `live_snapshot` against
`listing_current`: zero differences on every time-independent column, and
`advertised_days` off by exactly the 0.1 that 15 minutes of elapsed clock
rounds to on its `createdAt` branch
- boards rebuilt against the previous function's output: identical but for
eight cohorts whose counts had moved by one to three listings in the three
minutes between runs, every one downward, which is the corpus growing
- the two halves of the rollup that could not move to a live-only snapshot,
checked against the view directly: exits agree at 2,445 listings and
$71,194,413, events across all 401 make and model groups
Guards, because the failure mode here is silence rather than an error. Both
consumers refuse a snapshot older than two hours, and the truncates are in
the same transaction, so a refusal leaves the previous boards standing
instead of replacing them with thin ones. `refresh_live_snapshot()` refuses
while any observation is still unextracted, since an unextracted row reads
as null rather than as a fault. `live_snapshot` joins `check_stale_boards()`.
`observation_pick_current_idx` stores the ordering expression rather than
`source_view`, though only 'detail' and 'index' exist today and 'detail'
happens to sort first. A plain column index would work and would quietly
stop meaning "detail wins" the moment a third view name appeared. It and
`observation_facts_todo_idx` were built CONCURRENTLY against the live
database and so recorded in 0105 rather than left as catalogue objects with
no migration behind them, which is how 0088 went missing.
Migration: page by cursor, and keep it, or this stops at 3,400 rows
07:30 am AWST · 897dbdc
Two failures, same root, found by running it rather than reasoning about
it.
The loop asked for "the next rows with a body and no key, ordered by id"
each time, which re-scans every row already migrated to find the next
batch. Each batch costs more than the last. It ran at 19/s to 3,400 rows
and then died on `canceling statement due to statement timeout` — fast,
then suddenly not, which is how this always presents.
A cursor fixes the loop and moves the problem to startup: a restart with
an empty cursor has to scan past everything already done before it finds
work, and that first query times out for the same reason. `raw_capture.id`
is a uuid, so there is no "highest migrated id" to infer the position
from — the order is effectively random and the only record of where we
got to is one we keep. So the cursor is written to disk after every
batch. The --drop loop had the identical flaw for the identical reason;
fixed there too rather than waiting to rediscover it.
Batch size down 200 -> 50. The cost is the payload, not the row count:
each row drags ~45KB of TOAST, so 200 is a 9MB response that times out
against a busy database while 50 sustains the same 18 rows/second.
Raw bodies move to object storage, and stop being thrown away
07:23 am AWST · c852794
raw_capture was 8,654 MB of an 11 GB database — 79%, almost all TOAST.
The disk bill was the least of it: every body went through the WAL and
into every backup, 8.5 GB of blobs that are read approximately never
competed for a 512 MB shared_buffers with the rows the site queries, and
the largest-payload insert was the first to fail under load. That is the
write failures the 2026-08-14 pass logged, climbing 0.15% -> 0.32% as
the table grew.
**The reason to do it is not the size, it is what the size was costing.**
shouldRetainBody kept one body per URL, so a page that changed after its
first capture had the new version discarded — including the version
where the price changed, which is the one a re-parse would most want.
290,114 distinct bodies exist; 182,071 were kept. 37% of the raw record
thrown away to hold down a table that should not have held it, against a
SPEC promise that a better parser can re-read old captures without
re-crawling.
The old rule even contained its own refutation: "the second capture of an
unchanged page is already deduplicated by sha". If unchanged pages cost
nothing, the rule was only ever dropping changed ones.
Bodies are already content-addressed by sha256, which is exactly the
object-storage access pattern — the key *is* the hash, identical bytes
collapse to one object with no bookkeeping, and an upload is idempotent,
so unlike the database write path this one is safe to retry. Retaining
every distinct body costs ~13 GB against the 100 GB Pro includes.
Shape of it:
body-store.ts put/get/exists, keyed sha256, two-level fan-out
store.ts object first, then row. A row pointing at a missing
object reads as data loss; an object with no row is a
few kilobytes nobody references. Order the failure onto
the harmless side.
raw.ts four fallbacks: own object, own legacy column, sibling
object, sibling legacy column. Spans the migration and
keeps working afterwards for anything unmoved.
shouldRetainBody now returns true and the query it needed is gone —
one fewer round trip per detail page, on the path that
was timing out.
Verified round-trip before touching real data: object written, body_gzip
null, bytes identical on read-back, and a second capture of identical
content resolving to the same key.
The backfill is separate and resumable, and --drop is a separate flag
is cleared. It ran at 2.5 rows/s serially — twenty hours — because each
row cost three sequential round trips; dropping the sibling lookup (the
upload answers that question by doing it) and running eight at a time
puts it at 17.8/s.
Migration is additive, so the old code kept running against it
untouched while this was written.
Ops: where the storage is going, and the write failures it explains
07:08 am AWST · b32f615
raw_capture is 8,654 MB of an 11 GB database — 79%, and almost all of it
TOAST rather than index. 571,141 capture rows, 181,907 with a body at
roughly 48KB each. That is the design working: one body per listing
rather than one per observation, which is what SPEC's re-parse promise
costs.
It surfaces as write failures. Through the 2026-08-14 pass they climbed
0.15% -> 0.23% -> 0.32% of extractions, almost entirely
`raw_capture insert failed`, with occasional `observation insert failed:
<!DOCTYPE html>` — a gateway error page, which is what a timeout looks
like from the client. Not host-specific, not size-specific, clustered in
often as the table grows.
Nothing corrupts. Failures are counted rather than dropped, cannot
produce a false delisting, and the listing is re-observed next pass.
Writes down the decision rather than making it: storage grows ~48KB per
listing on a project committed to donations-only funding, and every
lever trades against SPEC's retention promise except moving bodies to
object storage, which keeps the promise and is the largest change. Taj's
call.
Taxonomy: one model, two spellings, two cohorts
07:04 am AWST · c6b0d46
Grouping live listings on their punctuation-stripped model name found 39
models carrying more than one spelling. Most are single-digit and
harmless. One is not: **163 of 481 GLC-class listings sat under "GLC
Class" while the other 318 sat under "Glc-class"**, so a GLC valuation
drew on two-thirds of the comparables that exist and had no way to know
it. Mercedes-Benz accounts for most of the rest, because every one of
its ranges is a "<letters> Class": GLE 45, GLA 31, C 30, A 26, GLB 16,
CLA 14. Outside it, Omoda 5 (27) and F-150 (22).
Written out rather than derived by collapsing separators, which is the
obvious general rule and is wrong twice in this very list: LDV's "G10"
and "G10+" are different vans, and Lexus's "RX450h" and "Rx450H+" are a
hybrid and a plug-in hybrid. That rule would trade a visible duplicate
for an invisible mismatch, which is the worse of the two.
Targets are the spelling each model already predominantly uses, so
recanonicalise moves the minority across rather than churning every row.
It runs in the cron chain after each crawl, so this applies to the
existing corpus as well as new ingests.
Also adds sanity-valuations.ts: twelve common cars, each placed at its
own cohort's median odometer, estimate against that cohort's median ask.
It is not a replacement for the held-out evaluation — a model returning
the same-year median would score perfectly here and ignore odometer
entirely — but it answers the question people actually ask. Largest gap
is Outlander 2022 at +7.9%; Model Y 2025 is +0.7% where it used to be
11% low.
The splits surfaced because that script reported "thin cohort" for
Hilux and X-Trail, two of Australia's best-selling vehicles. The cause
was my own casing, but chasing it found the real thing. A check that
quietly skips what it cannot find will eventually skip the thing that
broke, so the spellings are now the corpus's own.
Discovery: stop reporting "404" when we reached the page and could not read it
06:59 am AWST · a9a0123
probe-candidates returned `inventory HTTP 404` for canningvaletoyota,
a real dealer with 1,000+ cars whose /vehicle-inventory answers 200 on
both apex and www. The 404 came from a different guessed path. I spent
several fetches hunting for the right URL when the URL was never the
problem.
That is the conflation the WAF branch immediately above already exists
to prevent — "a site we may never crawl" against "a path guess that was
wrong" — with a third case nobody had separated: reached the inventory
page, could not read it. Only that one says anything about the site.
Verdicts now distinguish:
<path> answers 200 but no extractor recognises it
<platform> recognised at <path> but it lists no vehicles
inventory HTTP <status> (no path ever answered)
no recognised inventory page (nothing to say)
Re-probed with it, three hosts that all read "no recognised inventory
page" or "404" now read: canningvaletoyota is a WAF challenge, carplace
and coastlineautos serve a platform we have no extractor for. Three
different problems with three different answers, previously indexed
under one non-fact.
/vehicle-inventory added to the path list too, on its own merits.
Discovery: mine marketplace dealer names for sites we do not hold
06:50 am AWST · cc7cfe4
Autotrader and CarsGuide print the selling dealer on every listing,
which makes them a directory of dealer sites we have not found. As at
2026-08-14 that is 1,066 distinct dealer names across 13,549 marketplace
listings against 700 sources, and 102 unmatched names carry 20+ live
cars each.
This is the highest-volume of the three channels measured yesterday — 33
of 190, 17% — and the only one that prices the prize before you probe:
the marketplace listing count is a floor on what that dealer's own site
holds. It also does not run dry the way the sibling guess does, because
marketplaces keep listing dealers nobody has looked at.
The name is not the domain, so several spellings are emitted per dealer
and the probe sorts them out. "Purely commercials Bibra Lake" is
purelycommercials.com.au; "Osborne Park Kia - Used 2" is
osborneparkkia.com.au — groups name a site after the franchise, not
after the lot.
Matching leans toward excluding candidates: a name counts as known if it
prefix-matches a source name or hostname either way. That drops some we
genuinely lack — "Melville MG" is excluded by melvillemazda — but a
false exclusion costs one missed candidate while a false inclusion costs
a probe against a site we already crawl, and probes are the scarce
thing.
First run: 2 of 23 usable. osborneparkkia (950 vehicle URLs) and
purelycommercials (19), both seeded. Most of the rest resolved to
nothing, a 404, or a WAF, which is the argument for probing before
seeding rather than after.
Six sources seeded today: four from the sibling guess, two from here.
Phase 1: the evidence ladder, measured rather than asserted
06:47 am AWST · f06ec27
Charts now declare the evidence they need, a scheduled evaluator measures
every cohort against the declaration, and the resolved rung decides what
each may draw. This is the mechanism DEPTH-PLAN's unlock table wanted and
never had: the pre-registered Tell was written to publish at 2,000
observed cuts, that passed days ago at 5,997, and nothing fired, because
a gate written in prose is not a gate.
Schema in 0098, seeds in 0099, evaluator in 0100, invariants in 0101.
Three charts to start, chosen so the mechanism is proven rather than
merely declared. price_vs_odometer and book mostly pass and discriminate;
price_over_time must refuse for every cohort in the corpus. Thresholds
are not invented: each repeats a bar this codebase already enforces
somewhere, which is the point, since the ladder is where those scattered
constants become visible and checkable.
Design decisions that earn their complexity. One comparator, measure >=
threshold, so a requirement cannot hide an opinion in a where clause.
First failure rather than highest pass, so a thin cohort cannot skip a
rung it fails. Cohort keyed on make|model and not the slug, because 26
slugs map to two distinct pairs. Measures count vehicles and not
listings, because syndication inflates a Ranger 2.2x and a measure
counting adverts would promote cohorts that do not exist. A null
block_have means unmeasured, which is not zero. Promotion waits for the
evidence to hold twice; demotion is immediate.
Verified against the live corpus, 1,442 cohorts and 4,326 resolved
states. Geely EX5 resolves to rung 1 on the scatter, blocked by
km_spread_km at 5,686 against the 20,000 a robust fit needs, so it will
not draw a line through a cohort with no spread. price_over_time is rung
0 for every cohort, blocked at 1 contiguous day against 6, which is the
honest state of a corpus whose two rollup days do not touch.
check_ladder_spec asserts monotonicity, produced measures, ungapped
rungs, resolved charts and evidence freshness, and runs in
check-integrity.ts, which the crawl workflow actually executes. Proved by
making it fail: relaxing a rung-2 threshold below its rung-1 counterpart
produced "model.price_vs_odometer rung 1 requires priced_km_cars >= 3 but
rung 2 requires only >= 2", and the threshold was restored.
Nothing reads the ladder yet. That is Phase 2.
Crawl: make a failed write say what went wrong
06:44 am AWST · b30f368
The 2026-08-14 pass logged `raw_capture insert failed: undefined`
repeatedly, on two unrelated hosts. That is the least useful thing a
diagnostic can say: the write failed and the log cannot distinguish a
payload too large from a gateway timeout from a constraint violation.
One sibling failure arrived as `observation insert failed: <!DOCTYPE
html>` — the gateway answering with an error page instead of JSON, which
leaves `message` empty while `status` is not.
describeDbError reports code, status, message, details and hint, and
falls back to the serialised object when a driver hands back something
with none of them. Truncated, because one of these failures was a whole
HTML document and a stack trace carrying a web page helps nobody.
Deliberately no retry. These are ~5 failures in 11,000 observations
(0.04%), the listing is counted failed rather than silently dropped, it
cannot cause a false delisting — the URL was in the index, and
delistings come from index absence — and it is re-observed next pass.
Observations are append-only, so a retry after a *lost response* rather
than a failed write would insert the same observation twice and corrupt
exactly the counts the append-only log exists to protect. Worth doing
properly with an idempotency key, not reflexively.
Discovery: a script for the sibling guess, and what it is worth
06:31 am AWST · b2ac54c
Australian dealer groups name sites <location><marque>.com.au regularly
enough that the gaps are guessable — knowing melvillekia and
melvillemazda exist is evidence for melvillehyundai. suggest-siblings.ts
emits the combinations we do not hold, for probe-candidates.ts to test.
Which locations are worth guessing is the measured part, and the reason
the script defaults to recently-seeded ones. Over 224 probes on
2026-08-13:
siblings of a group found that same day 4/16 25%
dealer names taken from a marketplace 33/190 17%
siblings of a long-established group 0/18 0%
The zero is the useful number. Castle Hill, Frankston and Mandurah carry
many marques each and had already been mined, so what is still missing
there are the marques those groups do not sell. A group's gaps deplete
once worked.
This run: 4 of 45 usable, 8.9% — below yesterday's 25% and above the 0%
of established groups, which fits. Melville had five marques seeded
yesterday and gave up two more; the rest of the batch was mostly a
location parser reading group names like "western" and "valley" as
places, which is noise the probe filters cheaply.
(21), melvillerenault (12). About 182 listings.
Nothing is written by this script. It prints candidates; the probe
decides what is real, because seeding a WAF or an unsupported platform
costs a crawl cycle and leaves a permanently broken row on /coverage.
Declare generateStaticParams so the ticker is actually cached
06:17 am AWST · 1e3fd6b
revalidate alone did nothing. Both model routes still answered with
cache-control private, no-store and x-vercel-cache MISS, because a
dynamic segment without generateStaticParams is rendered fresh per
request whatever its revalidate says. Declaring the function is what
flips the route from dynamic to statically generated and revalidated;
the build marker goes from f to a filled circle.
Both return an empty list on purpose. Seeding the twelve largest models
was tried and reverted: Ranger, X-Trail and Tucson all time out at
build, so the deploy baked their degraded page in and would have served
it for the whole fifteen minute window. Prebuilding nothing means a
cohort is only ever cached in the state it was genuinely in when
somebody asked for it, and revalidation heals it once model_chart_board
lands.
Docs: DATA-QUALITY 18, the concurrency guard had already failed
06:16 am AWST · 8948fe1
Entry 17 filed double-crawling a host as a risk the stale window made
possible. It had already happened: market:autotrader has two overlapping
run rows on 2026-08-13, 12:40 and 21:10, and late in the overlap the log
records a 503 from www.autotrader.com.au. Per-host politeness is
enforced per process, so two crawlers is two request streams and neither
knows the real rate.
Exactly one source overlapped, not dozens, so this was a targeted run
started against autotrader while the first was still fetching it — let
through because the 12:40 row was eight hours old and the guard only
looked back two.
Already fixed by the liveness change: an open row is a claim regardless
of age, and reaping needs old-and-quiet. Recording it because the
difference between "this could happen" and "this did happen, here is the
503" is the difference between a note and a lesson.
Ops: bound the autotrader bite so it stops gating the pass
06:12 am AWST · 6b6e815
autotrader answers in about four seconds a page. There is no Crawl-delay
in its robots.txt and our own minimum is one second, so the site's own
latency is the limit and we are already slower than we ask to be. At a
budget of 8,000 that is a nine-hour run for one source, and it runs
alone — the other 689 finished hours before it started.
The ordering pins it there. A pass sorts stalest-first on started_at, so
whichever source starts last carries the newest timestamp and sorts last
again next pass. Harmless for a small source; compounding for a large
one, because nothing is in flight to overlap with and its hours are
added to the pass rather than absorbed by it.
Lowered to 3,000, about three hours. It costs autotrader nothing over
time — it is rate-limited either way, and the detail rotation takes
successive bites of its ~11,000 listings across passes. What it buys is
the other 690 sources being re-observed sooner, which is where price
changes and delistings come from.
The proper fix is scheduling: dispatch the longest sources first so
their hours overlap rather than trail. Not done here, because the
current order also protects the least-covered sources when a pass is
interrupted, and trading that away deserves measurement rather than a
6am guess. Written down in OPERATIONS with the inputs it needs.
Crawl: the pass order was being built from a truncated read
06:11 am AWST · e41d22d
A pass is ordered never-crawled first, then stalest, so an interrupted
run costs the least-covered sources nothing. That order was built by
selecting every crawl_run and reducing client-side, with no limit and
`const { data } = await`.
crawl_run is at 4,990 rows across 722 sources and grows by ~690 a pass.
PostgREST caps a response at 1,000, so the reduction ran over the newest
1,000 runs and 12 sources were absent from it entirely — read as never
crawled and sorted to the front. The count printed at the top of every
pass was wrong by the same 12: it would have said ~20 never crawled
where the real number is 8.
The truncation is the mild half. The unread error is the other one: a
timeout on an unbounded scan arrives as null, every source then looks
never-crawled, and the order collapses to alphabetical — precisely the
failure this rotation exists to prevent, which once left 88 of 183
sources never visited. Silent, and it would look like a crawl working
normally.
So the aggregate moves into SQL, bounded by source count rather than run
count, and a pass that cannot read its own history now throws instead of
crawling in an arbitrary order and starving its tail. DATA-QUALITY entry
16 is the class; this is the fifth instance and the first in the
scheduler.
Docs: corpus figures as at 2026-08-14
06:00 am AWST · c526e56
161,078 live listings across 690 dealer sources, 589,888 observations,
7,950 price changes, 4,439 delistings, 88 makes, 1,455 models. The
previous figures were a day old and understated the corpus by about
9,500 listings.
Valuation 0.7.0: compare a model year against itself when it can
05:55 am AWST · 5b855f7
A 2025 Model Y read $58,582 against a $65,888 median ask. The comp set
held 16 cars from 2025 and 269 from 2022-23, and the year steps are not
plus 2025 stock averaging 21,000km against 52,000.
The year term already knew this. The cap stopped it acting: moving a
$40,480 2022 car to 2025 needs +$25,400, and the shift is capped at 35%
of ask, $14,168. It landed at $54,648, and 114 such cars outvoted the 16
that needed no adjustment at all. This is the trim problem from 0.6.0 in
a different clothes, and it takes the same answer — a year with enough
of its own listings is compared against itself.
Held out over 43,337 cars in 30 year-varying models: **median error
-19.3%**, and **-32.0%** on subjects whose model year jumped more than
20%, which is the shape this is for.
Model Y 2025 @21,000km $58,582 -> $65,888 (median ask $65,888)
Pajero Sport GLX 2022 $42,000 -> $37,193 (listings $35-38k)
Two things measured and rejected, worth recording because both looked
better than they were:
- Down-weighting comps whose shift was capped, in proportion to what was
clipped off, is the more elegant idea and does nothing: -0.7% overall,
and 2.8% *worse* on the very case it targets.
- Restricting unconditionally scores -22.1%, better than what shipped
here, but part of that is survivorship. It leaves 287 of 43,337
subjects with no estimate at all, and those are hard cases its average
no longer has to carry. Guarding the restriction so it gives way
rather than empty the set answers every subject the old model answered
and still gets -19.3%.
Restrictions nest by giving way in order — trim first, then year — since
eight of a subject's year and eight of its trim can have no car in
common.
The narrowing is now stated in the workings. An estimate built from 13
listings when the model shows 274 looks like a mistake until the page
says it was a choice, and the user asked for the basis of these numbers
to be legible.
One correction to my own measurement along the way: the first harness
left restrictToTrim out of the baseline, so it was scoring year
restriction against a weaker model than the one that ships and would
have credited it with the trim fix's work too. Against the real baseline
the effect is larger, not smaller — -22.6% where the flawed run said
-12.0%.
Record the rollup cohort index migration
05:34 am AWST · ba46785
Phase 0: a refused median, an index for the reader, and a ticker that stops 500ing
05:34 am AWST · 39018c4
Three of the five Phase 0 items from CHARTS-PLAN.
/moving quoted an aggregate median and a "cut before selling" percentage
whenever a filter matched a single sale. NT printed 43 days and 100%
from five sales, where the median was noise and the percentage was five
coin flips landing the same way. Both now answer to the same bar of 20
the home and market pages already use before quoting days to sell, and
below it the page states the real count and says the sales are real
while their average would not be. The per-model rows keep their existing
bar of 4, where the count sits in the next column.
daily_model_rollup was keyed for its writer and not its readers: the
only index led on day, so every per-cohort series would have scanned
every day ever written. A covering index on (make, model, day) including
the plotted columns makes it an index-only scan, verified with zero heap
fetches. At 1,190 rows this changes nothing measurable, which is exactly
why it is cheap to add before the table is large.
model_ticker was recomputed on every visit. Measured in production:
Mazda CX-5 2.9s, Ford Ranger over the API role's 8 second ceiling and
returning a 500. The page is now revalidated every fifteen minutes
rather than force-dynamic, which takes every model that renders at all
off the request path.
Caching cannot rescue Ranger, because a page that has never rendered
once has nothing to serve stale, and this is what model_chart_board in
Phase 2 exists to fix. What it must not do meanwhile is 404. A thrown
timeout was falling through to notFound(), so the largest cohort in the
corpus read as a model that does not exist. Unknown model and known
model we cannot yet summarise are now different states, and the second
says so and offers the views that do cover it.
Crawl: age keeps a vote, so a dead pass cannot deadlock the next one
01:09 am AWST · fed2731
Making the reaper require quiet fixed live runs being closed mid-fetch
and introduced a way to hang. A pass that dies leaves open rows;
anotherCrawlIsRunning() reads them as a live claim and refuses to start.
Normally the corpus falls quiet within seconds of the death and the next
scheduled pass reaps them — but a pass chained to start immediately
after another begins while observations are still seconds old, sees "not
quiet", reaps nothing, and refuses on rows whose process is long gone.
That is not hypothetical here: a pass is chained behind the current one
so the raised caps and the sitemap fix land now rather than at 06:43.
So a row is reaped by either route. Quiet: older than two hours and
nothing being written anywhere, which is the normal path and the one
that spares a four-hour brisbanecars run. Hard: older than twelve hours
regardless, a threshold no legitimate run reaches — the largest budget
is 8,150 pages, about four and a half hours. Twelve hours is not a slow
source, it is a corpse.
Docs: DATA-QUALITY 17, a guard whose premise moved under it
12:46 am AWST · 1c96dc8
The delisting alert was a false alarm with a real cause, and the class is
new to this register: not a read that failed quietly (9-12), but a guard
that documented its justification in terms of a number owned by another
file. The comment was true when written and falsified by a config change
that had no reason to know it existed.
Crawl: stop reaping run rows belonging to crawls that are working
12:45 am AWST · 21f8e9d
The 00:36 monitor alerted on "25 delisting(s) from an incomplete crawl".
The delistings were real — each page answers 200 with the dealer's own
title reading "SOLD 2026 Nissan Patrol Ti Y62 …". What was wrong was the
run row they hung off, and the cause was mine.
closeAbandonedRuns() reaps open run rows older than STALE_RUN_HOURS = 2,
justified by a comment that measured the thing it was guarding: "no
single source takes anywhere near this: the largest budget is ~250
detail fetches at ~9s, about 40 minutes." Fitting the detail budgets to
real stock a few hours earlier raised brisbanecars to 8,150 and
countrycars to 6,200 — three to four and a half hours of fetching — and
the reaper started closing live runs mid-traversal, stamping them
finished, incomplete, zero pages. Both went on to finish correctly
(countrycars: complete, 2,562 pages, 58 delistings), so the data was
never wrong; the run rows were, for a few hours each.
The false alarm was the visible half. The same constant gates
anotherCrawlIsRunning(), and a run dismissed as stale stops being a
claim on the source — so a second pass could have started against hosts
the first was still fetching, doubling the request rate on every shared
host. The cron wrapper's pgrep would have caught it locally, but the
database claim is there for a crawl started by hand or from another
machine, and it had quietly stopped covering the two largest sources.
First fix was to derive the window from the largest budget. It works and
it is the wrong trade: at 8,150 pages it computes to nineteen hours, so
a crawl that really died would block every scheduled pass for most of a
day. That is the failure the window exists to prevent, moved rather than
removed.
Age was never the right signal. A crawler writes observations
continuously, so whether one is alive is observable rather than inferred
from a clock: a run is abandoned only if it is old *and* nothing has
been written for twenty minutes. A four-hour brisbanecars run recording
cars every few seconds is plainly working; a dead process stops writing
within one page fetch.
Liveness is checked corpus-wide, not per source. Per source it would
still reap a run whose pages happen to be 404ing for a stretch — that
source writes nothing while its process is fine — and reaping a live run
is the whole bug. Any recent observation proves a crawler exists, and if
one exists its rows are not abandoned. An unreadable check returns "not
quiet": unknown must never license reaping.
Verified against the running pass: last observation 0m ago, so nothing
would be reaped, and dealer:peterwarren — open 2h+, mid-traversal —
would have been reaped by the old rule. It was about to happen a third
time.
The charting plan: an evidence ladder, and bespoke SVG stays
12:39 am AWST · 5f26de6
From a fourteen-agent pass whose five adversarial verifiers all returned
needs-change. Their corrections are in the document rather than the
drafts they corrected.
Three measurements decided it. model_ticker('ford','ranger') takes 19.1
seconds against an 8 second cap, so /model is failing now and cannot
take charts until it moves off the request path. daily_model_rollup
holds two non-contiguous days and the longest contiguous run for all 723
cohorts is 1, while live went 49,673 to 137,764 between them, so any
line drawn there is a 177 percent rally made of crawler expansion. And
Ford Ranger is 5,820 listings against 2,644 vehicles, so vehicle dedupe
is mandatory, after which exactly nine models corpus-wide exceed 800
points and the downsampling debate disappears.
Bespoke SVG stays. Observable Plot lost because it cannot run in a
server component at all and because its defaults fabricate. Measured
d3-array 9.3, our interaction layer 1.3, against a site that ships
174.9 kB today.
The spine is an evidence ladder: charts declare what they need, a
6-hourly evaluator resolves a rung per cohort, and interaction can only
lower it. Resolution is by first failure rather than highest pass,
because the intuitive form lets a cohort skip a rung it fails.
The finding that justifies the whole mechanism: the pre-registered
Tell's 2,000-cut gate opened days ago at 5,997 cuts and nothing fired,
because a gate written in prose is not a gate.
Restore the market board refresh, and record a migration the tree had lost
12:37 am AWST · 1cc0530
Two defects, both found by the charting research reading cron.job and the
migration record against the tree rather than trusting either.
Migration 0087 rewrote the depth-boards cron command to add
refresh_sale_speed_board() and dropped refresh_market_boards() while doing
it, because the command is one string replaced whole rather than appended
to. The four market boards had been frozen since their last good run and
/market was serving figures in the present tense that had stopped moving.
They were four hours old when caught, inside the twelve hour limit, so
check_stale_boards() was correctly quiet and would have alerted tonight.
The check works. It cannot see a job that will never run again, only the
staleness that follows, which is an argument for asserting the schedule
itself and not only its output.
sale_speed_board arrived in 0087 and was never added to the freshness
spec, so it was unasserted. It is in it now.
Separately, 0088's two partial indexes existed in the database but the
database held no record of them: they were built live with CREATE INDEX
CONCURRENTLY, which cannot run inside a transaction and so could not go
through apply_migration. The file sat in the tree describing a change the
migration record had never heard of, the mirror of the thirteen
migrations that once existed only in the database. Recorded properly
rather than deleted, since the change is real and idempotent.
Docs: two coverage ceilings that were config, not platform
12:17 am AWST · a8bdde5
Records the detail-budget sizing and the www/sitemap fix in
COVERAGE-CEILING.md, alongside the open question they leave: fifteen
sources whose sitemaps read fine standalone but whose traversal still
reports 12 or 24 found. The prediction to check on the next full pass is
written down rather than assumed, because their last runs predate the
config that should fix them.
Crawl: a www prefix was discarding a source's entire sitemap
12:15 am AWST · 825bf72
Autopact advertises 3,250 vehicle URLs in its sitemap. Every pass for
weeks reported `found=12` or `found=24`, and those came from the HTML
index — not one sitemap entry survived to be fetched.
crawl.ts compares each sitemap <loc> against `sourceHost`, derived from
the configured indexUrl, with `u.hostname !== sourceHost`. Autopact's
indexUrl is the apex autopact.com.au; its sitemap lists canonical URLs
on www.autopact.com.au. Every entry was dropped on the prefix, before
any vehicle test looked at it. The same equality gated child sitemaps,
so a www child sitemap was never queued either.
sameSite() discounts a leading www on both sides and nothing else — a
sitemap listing another domain is still somebody else's inventory. It
lives in sitemap-scan.ts with the other patterns, which exists precisely
because a second copy of this logic drifting apart is what cost 97
sources their inventory once already.
Measured on real sitemaps, strict-host against the fix:
autopact 0 -> 3250
pakenhammazda 0 -> 182 (base-url host)
mandurahgwmhaval 0 -> 167 (base-url host)
The narrower claim is the correct one. The harness first compared
against base_url and reported gains for cherywindsor and dubbohyundai
too; crawl.ts:305 derives sourceHost from indexUrl, and against that
host both already matched at 136 and 57. Fixing the harness dropped the
headline from 3,472 to autopact's 3,250 — the other two are real
sources with a real ceiling, but this is not the fix for them, and
crediting it with sources it does not touch would have hidden that.
Those remaining sources have a separate ceiling: 15 of them hold 624
live listings between them while their traversal finds 12 or 24 a pass.
They are why the delist guard has been busy — each becomes a delisting
candidate every pass and is saved only by the 200-response check. Their
sitemaps read fine standalone (cherywindsor 136, dubbohyundai 57), so
the fault is between the config and the traversal, not in the sitemap.
Next thread to pull.
Crawl: fit each source's detail budget to the stock it has
12:02 am AWST · d9ee133
126 sources were capped below their own yards. Between them they found
85,483 listing URLs and could reach 46,200 in a pass; the other 39,283
were discovered and then left for a later pass. That is roughly a
quarter of the corpus waiting on a config default.
Deferred, not lost, and the distinction is the whole reason this is a
tuning change and not a bug fix. pendingDetail is ordered never-seen
first then oldest-seen first, so truncation rotates and every URL is
reached eventually; delistings are inferred from presentUrls — the full
index traversal — so a short detail budget has never been able to
fabricate a sale.
What it costs is freshness. A yard of 3,869 cars read 200 at a time
needs twenty passes to be seen once, and at six hours a pass the first
observation is five days old before the last car is read at all. Prices
move faster than that, and a series assembled from observations five
days apart is a worse valuation input than one six hours apart.
Sized against live stock rather than listings_found, because found
counts URLs and not all of them are cars. Most sources are honest —
brisbanecars finds 1.05 URLs per live listing, countrycars 1.00, booran
0.98 — but pattersoncheney finds 3,872 and holds 468. Sizing that to
found would have bought it a 4,850-page budget and spent ~3,400 polite
fetches a pass on URLs that have never yielded a car; it gets 700.
Net +33,741 detail pages per pass, about +1.75h at the measured rate.
Per-host politeness is untouched: politeFetch still serialises per host
and still waits the greater of our delay and robots Crawl-delay, so a
bigger budget reads a host for longer, never faster.
Two things found by checking rather than assuming:
- The first formula ratcheted. max(live, current) * 1.4 multiplies the
cap it set last time, so a second run wanted 17 more raises after the
first wrote 126. Headroom belongs on observed stock; "never lower" is
a separate clause. It converges now — a re-run reports zero.
- The headline claimed 52,150 by counting budget granted rather than
pages fetched. A source only fetches what it found. 33,741 is the
honest number and it is the one reported.
source_max_found() aggregates in SQL: crawl_run is well past PostgREST's
1,000-row cap and grows 686 rows a pass, so reducing it client-side
would have started silently reading a fraction of the runs. It counts
live rows from `listing`, not listing_current — the view resolves two
laterals and a correlated event count per row to render a page, none of
which answers "how many URLs will the crawler fetch", and going through
it exceeded even a 60-second ceiling.