Skip to content

Add Laurens, IA — two imagery arms over one rural footprint, and the deficit is the rig, not the town (#149, #151) - #152

Merged
jonfroehlich merged 11 commits into
mainfrom
benchmark/laurens
Sep 4, 2026
Merged

Add Laurens, IA — two imagery arms over one rural footprint, and the deficit is the rig, not the town (#149, #151)#152
jonfroehlich merged 11 commits into
mainfrom
benchmark/laurens

Conversation

@jonfroehlich

Copy link
Copy Markdown
Member

Adds Laurens, Iowa — the first rural split — and works it through docs/adding_a_benchmark_city.md. Companion to ProjectSidewalk/sidewalk-auto-labeler#48; findings in #149 and #151.

Why this split exists (Phase 0)

Rural / small-town streetscape on a consumer 360 rig. Every existing split is urban, suburban or a college town; Laurens is 1.91 km² and 1,264 people. Pooled — US deployment city, complete verdict-grade GT.

The result

Precision 0.898  (CI 0.827-0.942)    unbiased 0.863
Recall    0.390  (CI 0.331-0.451)    unbiased 0.325

Recall is roughly half the next-worst split (clovis 0.650). It is not a weak or ramp-poor sample: Laurens is ramp-rich (2.65 ramps/pano, 3rd of 10), misses/pano 1.62 vs 0.45–1.19 elsewhere, and — uniquely among all ten splits — its misses are near, not far-field (delta +0.004 in normalized y; every other split is −0.013 to −0.030). The reviewer's leaf-litter reading did not survive measurement. Full analysis and the discriminating experiment: #151.

Parity gate: 111/111, 100.0% exact, med R 0.000 — bit-exact, as a Mapillary split should be.

Pooled numbers move

thr P before → after R before → after
0.30 0.9002 → 0.8991 0.7927 → 0.7640
0.55 0.9637 → 0.9594 0.7223 → 0.6864

n 859 → 953 panos. Precision untouched; the recall drop is Laurens' own 0.390 entering the pool.

Two published studies pinned

farfield_forensics.py and silent_activation.py read the live US_SPLITS but are frozen studies — their tests pin the committed result JSONs to numbers quoted in the issues' §0c. Registering a city silently restated both published findings. Each now carries its own explicit split tuple with the reason. This is a latent trap for every future split, not something specific to Laurens, and is probably worth a line in the runbook.

Checklist

Phase 1 — bundle

  • City detected (both sources; area hash d8dd392b…)
  • export_benchmark.py --bundle exited zero; index.csv written, zero decay
  • records.jsonl committed; panos/ archived durably on makelab2 (8.3 GB Mapillary + 40 GB GSV, both reconciled 1:1, zero decay)
  • Strata 5 / 64 / 25 = 94, not 5/95/25 — geometric, not a sampling artifact: _spread walks the whole pool and the town saturates at 64 detection panos held 30 m apart. Unbiased column is 89 vs the usual 120, so intervals are wider. Strata definitions and de-clustering unchanged.

Phase 2–3 — ground truth

  • Reviewed with gt_gallery.py at model resolution
  • review_notes written · [x] verdicts.json committed · [x] score_validation.py run, unbiased recorded
  • Camera provenance present (GoPro / GoPro Max, all 94)
  • imagery_manifest.json written before review (7485886ea08f671b) — a step the runbook still does not mention

Phase 4 — operating point

Phase 5 — code

  • US_SPLITS / CITY_SPLITS / ALL_SPLITS · [x] TIER · [x] BENCHMARK_SPLITS · [x] SPLIT_HEADER
  • HELD_OUT — n/a, pooled
  • tier_of() already resolves GoPro Max → action-modern
  • SERIES slot 7 (#4a3aa7), vacated by the manual_gold fold — no invented hue
  • pytest -q — 8 failures in test_scoreboard.py, deliberately not silenced (see below)

Phase 6 — docs

The decision this needs

No challenger model has been scored on Laurens. Consequences, all real rather than cosmetic:

  • Every challenger becomes partial coverage (7/8) and drops out of the pooled tables — arguably correct behaviour, but it guts the model comparison until they are re-run.
  • test_ap_ordering_is_not_an_artifact_of_the_floor now compares RampNet's 8-split AP (0.829) against YOLO's 7-split AP (0.730) — margin 0.0993 against an asserted 0.1. That is a population mismatch, not a stale constant, and bumping the threshold would paper over exactly the artifact the test exists to catch.

Two ways out — your call, which is why the constants are untouched:

  1. Re-run the challengers on Laurens. Correct and expensive (~15 models, GPU + API).
  2. Keep Laurens out of the scoreboard's comparison basis until they catch up, while leaving it pooled in the operating-point analysis. Cheap, but the scoreboard and the operating point then quote different populations, which needs saying loudly in the docs.

🤖 Generated with Claude Code (claude-opus-5[1m], effort: high)

jonfroehlich and others added 9 commits August 31, 2026 10:46
Phase 0 intent: rural / small-town streetscape on a consumer 360 rig.
Every existing split is urban, suburban, or a college town; Laurens is
1.91 km2 and 1,264 people. It answers whether the operating point holds
where sidewalks are intermittent and driveway aprons outnumber ramps.

Imagery: Mapillary, GoPro Max 5760x2880, all captured 2025-11 by a single
uploader in 19 sequences — the most uniform vintage of any split.
Detection run: 4,495 panos, 9.3% with an operational detection.

Strata are 5 top / 64 random / 25 empty = 94, not the standard 5/95/25.
The shortfall is geometric, not a sampling artifact: _spread walks the
whole candidate pool greedily, and the town saturates at 64 detection
panos held 30 m apart. The unbiased column (random + empty) is therefore
89 panos against the usual 120, so its intervals are correspondingly
wider. Strata definitions and the de-clustering rule are unchanged.

111 operational detections to adjudicate. Archive reconciled 1:1, zero
decay. Still outstanding per docs/adding_a_benchmark_city.md: GT review,
the BENCHMARK_SPLITS allowlist, and the downstream pooled numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SF2s8wWUM6HphV1xuYju21
digest 7485886ea08f671b over 94 panos, verify clean. Written now rather
than after review so it records the bytes the reviewer actually sees —
Mapillary re-serves thumb_original_url from a signed, expiring URL, so
"the same pano id" is not a guarantee of the same pixels later.

docs/adding_a_benchmark_city.md still does not mention this step; it
postdates the runbook. Every other split carries one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SF2s8wWUM6HphV1xuYju21
94/94 panos reviewed, all 111 detections judged, 190 missed-ramp marks.

  Precision 0.898 (95% CI 0.827-0.942)   unbiased 0.863
  Recall    0.390 (95% CI 0.331-0.451)   unbiased 0.325

Precision is unremarkable — mid-pack. Recall is roughly half the
next-worst split (clovis 0.650), and three things say it is a real
failure mode rather than a small or ramp-poor sample:

- Laurens is ramp-RICH: 2.65 ramps/pano, 3rd of 10 splits, ahead of
  richmond, annapolis and morgantown. Misses/pano is 1.62 against a
  0.45-1.19 range everywhere else.
- The misses are NOT far-field. In all nine other splits missed ramps
  sit nearer the horizon than detected ones (delta -0.013 to -0.030 in
  normalized y); Laurens is the only split where the delta is positive
  (+0.004). It is missing near, well-resolved ramps.
- Not a lighting artifact either. The reviewer noted leaf litter on
  three panos, but measured over a 2%-width window the missed and
  detected ramps sit in the same light: median luminance 94.6 vs 97.4,
  47% vs 42% in shadow. The leaf/shadow reading is not supported.

What the miss crops do show, visually and so far unmeasured, is flush
or minimal-reveal ramps — a street cross-section close to at-grade,
which is the rural design this split was added to probe.

The GSV arm of this same town (runs/laurens_gsv, 2,137 panos, 2024-09,
leaf-free) is the discriminator and is not yet reviewed: if its recall
is also ~0.35 the cause is ramp geometry, if ~0.75 it is rig or season.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SF2s8wWUM6HphV1xuYju21
Phase 4-5 of docs/adding_a_benchmark_city.md.

Extraction + gate:
  operating_point_curve.py extract --cities laurens  (94 panos, local GPU)
  low_floor_sweep.py parity --cities laurens -> 111/111, 100.0% EXACT,
  med R 0.000. Bit-exact, which is what a Mapillary split should be.

Registries: US_SPLITS in low_floor_sweep and miss_decomposition, TIER
(mapillary), BENCHMARK_SPLITS, SERIES, and SPLIT_HEADER in
scoreboard_render (a touchpoint the Phase 5 table does not list).
tier_of() already resolves GoPro Max to action-modern, so no new branch.
SERIES takes slot 7 (#4a3aa7) -- the categorical slot the manual_gold
fold vacated, so no new hue is invented.

Pooled numbers move, as the runbook warns they will:

  thr 0.30   P 0.9002 -> 0.8991   R 0.7927 -> 0.7640   (-2.9 pts)
  thr 0.55   P 0.9637 -> 0.9594   R 0.7223 -> 0.6864   (-3.6 pts)
  n_panos 859 -> 953

Precision is untouched; the recall drop is Laurens' own 0.390 entering
the pool, not a regression anywhere else.

farfield_forensics.py and silent_activation.py both read the live
US_SPLITS but are FROZEN studies -- their tests pin the committed result
JSONs to numbers quoted in the issues' section 0c. Registering a city
silently restated both. Each now carries its own PUBLISHED_SPLITS /
US_SPLITS tuple with the reason, and their tests read that instead, so
the published findings stay reproducible and a future split cannot
rewrite them by accident. Laurens is absent from those two studies for
that reason alone; it is pooled everywhere else.

Also refreshed: op/*.csv (sweep, hist, gtbias, floor, distance),
op_cache/laurens.json, scoreboard.json, model_scoreboard.md and the five
scoreboard figures. The split date range test moves to 2026-08-31,
Laurens' review date.

NOT resolved here -- eight tests in test_scoreboard.py still fail, and
they should not be silenced by bumping constants. No challenger model
has been scored on Laurens, so every one of them becomes partial
coverage (7/8) and drops out of the pooled tables, and
test_ap_ordering_is_not_an_artifact_of_the_floor now compares RampNet's
8-split AP against a 7-split YOLO AP -- 0.829 vs 0.730, a margin of
0.0993 against an asserted 0.1. That is a population mismatch, not a
stale constant. See the PR for the decision it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SF2s8wWUM6HphV1xuYju21
Laurens is the only city in the benchmark run on both imagery sources over
one footprint (area hash d8dd392b...), which is what makes it the control
for RampNet#151. With a second arm arriving, the bare city name stops being
an identifier and starts being a lie: `laurens` silently meant Mapillary.

The convention, now written into docs/adding_a_benchmark_city.md phase 0:

  A split carries a `_<source>` suffix if and only if its city has more
  than one imagery arm.

So `bend`, `richmond` and the rest stay bare and pay nothing. If bend ever
gains a Mapillary arm it gets renamed at that moment, not pre-emptively.

Timing is the whole argument for doing this now rather than later. Checked
the Hub directly: projectsidewalk/rampnet-benchmark was last modified
2026-08-17, its four configs are per-split parquets named <split>.parquet,
and there are zero laurens files -- 0c8e0f4 registered the split in
BENCHMARK_SPLITS but never ran build -> verify -> push. So the one
irreversible cost of a rename, breaking every existing
load_dataset(..., "laurens"), does not exist yet. It will the moment the
dataset is republished. Doing it inside this PR also keeps the bare name
out of main's history entirely.

Mechanical only -- this changes no number, only its label:
  benchmark/laurens/ -> benchmark/laurens_mapillary/ (4 tracked files;
    panos/, gallery/ and index.csv are gitignored)
  analysis_out/op_cache/laurens.json -> laurens_mapillary.json, and its
    internal "city" key
  the `split` column of six committed tables under analysis_out/op/, plus
    scoreboard.json
  US_SPLITS (low_floor_sweep, miss_decomposition), TIER, BENCHMARK_SPLITS,
    SERIES, SPLIT_HEADER, and the frozen-study comments in
    farfield_forensics and silent_activation

Two things the blind rewrite got wrong and were fixed by hand. SPLIT_HEADER
is documented as "short enough that the by-split matrix stays readable", so
it takes `laur_mly` -- mly being this project's own abbreviation for
Mapillary, cf. the mly1_public tiles -- not the full 17-character name; the
provenance and coverage tables, which have room, keep the full id. And all
three model_scoreboard.md hits were inside <!-- BEGIN GENERATED --> blocks,
so that file was reverted and regenerated with scoreboard.py rather than
edited. Only scoreboard_by_split.png moved; the other four figures rebuilt
byte-identical.

pytest: 8 failed, 1314 passed, 1 skipped -- byte-identical to the pre-change
baseline, same eight test_scoreboard.py tests 0c8e0f4 flagged as unresolved.
Nothing here addresses those; they are still the open question on this PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SF2s8wWUM6HphV1xuYju21
Phase 1 of docs/adding_a_benchmark_city.md for the second Laurens arm.
Laurens is the only city run on both imagery sources over one footprint
(area hash d8dd392b...), so reviewing the GSV arm separates the two
explanations for its recall 0.390 -- the benchmark's worst by 2x, and the
only split whose misses are NEAR rather than far-field:

  GSV recall ~0.35 -> the cause is ramp GEOMETRY. Flush / minimal-reveal
    rural ramps are a real blind spot; the fix is training data. Escalate.
  GSV recall ~0.75 -> the cause is RIG or SEASON, and Laurens becomes
    evidence about consumer 360 rigs and autumn capture instead.

  python scripts/export_benchmark.py runs/laurens_gsv/results.jsonl \
      --bundle ../RampNet/benchmark/laurens_gsv        # in the auto-labeler

86 panos: 5 top / 56 random / 25 empty, from 2,137 GSV panos carrying 473
operational detections over 230 panos. Reconcile is 86/86 with zero decay.
Sampler defaults are untouched (sample 100, empty 25, seed 0, spacing 30 m),
identical to the Mapillary arm, so the two are comparable on the unbiased
column.

The 56 rather than 95 is the same geometric saturation that capped the
Mapillary arm at 64: _spread walks the whole shuffled candidate pool and
the town simply runs out of detection panos 30 m apart. Unbiased column is
81 against that arm's 89 and the usual 120, so both arms' intervals are
wider than other splits'. Do not "fix" this by lowering --min-spacing.

Measured, and it changes the analysis plan: the two independent samples
already overlap heavily, because both are 30 m greedy walks over the same
small road network. 51 of 86 GSV panos (59%) sit within 20 m of a
laurens_mapillary bundle pano; median nearest neighbour 17.2 m, and 7.7 m
across the `top` stratum. So the standard protocol hands us a paired subset
of ~51 corners seen by both rigs for free -- same physical ramps, rig and
season the only variables -- on top of the two independent recalls. A
bespoke site-matched sampler was considered and is not needed, which also
keeps this split comparable with the other ten.

imagery_manifest.json pins the bytes now, before review, so the verdicts
can never be re-paired with re-fetched pixels (--cities laurens_gsv, which
does not require the split to be registered).

NOT registered yet, deliberately. BENCHMARK_SPLITS is test-locked to
ALL_SPLITS minus manual_gold, and split_date_range reads every listed
split's verdicts.json -- so registering an unreviewed split breaks the
suite. Registration is phase 5, after the GT review produces verdicts.
When it lands, the arm goes into HELD_OUT, not US_SPLITS: it samples the
same physical ramps as laurens_mapillary, so pooling both would
double-count them and break the independence the Wilson intervals assume.
That is a third kind of held-out reason, and the convention plus the
CITY_OF mechanism are written up in phase 0 of the runbook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SF2s8wWUM6HphV1xuYju21
86/86 panos reviewed. P 0.925 (0.864-0.960) / R 0.505 (0.439-0.570);
unbiased 0.904 / 0.459 (0.389-0.531).

#151's decision rule was GSV ~0.35 -> ramp geometry, ~0.75 -> rig or season.
0.505 is neither, so the answer is the split verdict the issue listed as a
real possible outcome. It decomposes cleanly, though, and the two halves
point in different directions:

RIG/SEASON IS REAL, AND WORTH ~12 RECALL POINTS. GSV 0.505 vs Mapillary
0.390 is +11.5 pts with barely-overlapping Wilson intervals. The paired
subset confirms it is not a sampling artifact -- restricting both arms to
the corners both rigs saw reproduces the same gap at every radius:

  15 m   33 panos/arm   mly 0.415  gsv 0.545   +0.130
  20 m   49/51 panos    mly 0.393  gsv 0.518   +0.125
  25 m   54/57 panos    mly 0.398  gsv 0.502   +0.104

THE DEFICIT MOSTLY SURVIVES ANYWAY. 0.505 is still the worst of any US
split by ~18 points -- next lowest is paterson at 0.684, on a uniform
TP/(TP+FN) recomputation across all ten splits. Sept-2024 leaf-free GSV at
16384x8192 does not rescue Laurens. So roughly a third of the gap is the
camera and two thirds is the place, and the escalation in #151 stands.

BUT THE SPECIFIC EVIDENCE FOR FLUSH RAMPS WEAKENED, AND THE ISSUE TEXT
NEEDS AMENDING. The near-miss signature -- the thing that made Laurens
unique among ten splits, and the whole basis for "near, well-resolved ramps
are being missed" -- is ARM-SPECIFIC. Median normalized-y delta
(missed minus detected), abstentions excluded:

  nine other splits   -0.013 .. -0.030   (misses are far-field)
  laurens_mapillary   +0.0038            (inverted -- the #151 finding)
  laurens_gsv         -0.0053            (back to the normal direction)

So on better imagery the inversion disappears. laurens_gsv is still an
outlier -- at half the magnitude of the nearest split (richmond -0.0128) it
has the weakest far-field tilt in the benchmark -- but it is no longer
inverted. "Near, well-resolved ramps are being missed" was substantially a
GoPro-Max-in-November artifact, not a property of rural ramps.

The flush/minimal-reveal hypothesis therefore survives as an explanation of
the LEVEL and loses its distinctive supporting signature. Making it
quantitative still needs the curb-reveal measurement from GSV depth (#111),
which this arm can supply and the Mapillary arm cannot.

Method note: recall here is TP/(TP+FN) with `unsure` misses excluded, which
reproduces score_validation.py's all-panos column exactly on both arms; the
delta method reproduces every published per-split delta in #151. Missed-ramp
abstention is high in both arms -- 20% mapillary, 15% gsv -- itself a signal
about how hard these corners are for a human to call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SF2s8wWUM6HphV1xuYju21
…lation mismatch

PR #152 left eight test_scoreboard failures unsilenced and asked for a decision:
re-run the challengers on Laurens, or hold it out of the comparison basis. This is
the first option. Every registered leg now has laurens_mapillary detections, so the
scoreboard compares eight splits against eight rather than eight against seven.

What the re-run settles:

- test_ap_ordering_is_not_an_artifact_of_the_floor passes on its own terms. It read
  0.829 (RampNet, 8 splits) vs 0.730 (YOLO, 7) = 0.0993, just under the asserted 0.1.
  Measured on the same eight splits it is 0.8289 vs 0.7229 = 0.1061. Adding Laurens
  lowers both -- RampNet 0.849 -> 0.829, y11x 0.730 -> 0.723 -- and the margin widens
  because the comparison is finally like-for-like. The threshold was never the problem
  and is untouched.

- The pooled operating point moves onto the eight-split basis, and docs/operating_point.md
  moves with it: 0.55 goes 0.964/0.722/0.826 -> 0.959/0.686/0.800 over n=953. Precision
  is untouched; the recall drop is Laurens' own 0.390 entering the pool. The F1 optimum
  is still 0.32 and the recommendation is unchanged.

Two results worth recording:

- The supervised YOLO baseline beats RampNet on Laurens at the #71 operating point --
  y26_pano 0.574 and y11l_pano 0.563 against 0.543 -- the first split where that happens.
  It is an operating-point result, not a curve result: read full-range at the 0.05 floor
  RampNet's AP is 0.691, still the highest of the four. The YOLO arms win on recall
  (0.450, 0.402 vs 0.390) while RampNet keeps precision (0.898 vs 0.794).

- The Qwen-32B inversion recurs a fourth time, most extreme instance yet: 0.066 against
  8B's 0.210, at 0.27 boxes/pano and recall 0.036. Four splits, three of them
  HIGH-confidence US GT, so the "it stops firing on unusual infrastructure" mechanism is
  established rather than suggestive.

Against zero-shot challengers RampNet's lead survives its worst split: +0.200 F1 over
gemini-3.1-pro. OWLv2's 0.851 recall is density (68.0 boxes/pano), not a usable ceiling.

Detections: 11 files, all --verify identical to the cache. The YOLO export needs
--tiling none --yolo-imgsz 1280 or the reconstructed signature misses every cache entry
and writes empty files without failing; that trap and the exact commands are now in
docs/replication.md, whose file-count ledger a test caught going stale at 114/125.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the rig, not the town

The GSV arm was run through the identical leg list as laurens_mapillary, so the two
differ only in imagery. That makes it the #151 discriminator, and it answers cleanly.

Every zero-shot challenger is flat or WORSE on the GSV arm -- seven of eight move down,
none moves up more than 0.008 -- while RampNet gains +0.115 (F1 0.543 -> 0.659, recall
0.390 -> 0.509). If rural streetscape were intrinsically hard, the arm that is easier
for RampNet would be easier for them too. It is not. Laurens' 0.390 is RampNet meeting
an out-of-domain rig, not a town that defeats detectors.

Two consequences:

- The RampNet-vs-YOLO ordering FLIPS between the arms. On Mapillary the supervised
  baseline wins (y26 0.574, y11l 0.563 vs 0.543); on GSV RampNet leads by 0.072 (0.659
  vs 0.587). The previous commit's "YOLO beats RampNet on the rural split" is really
  "on non-GSV imagery" and does not survive changing the rig over the same ground.

- Being GSV-trained is not the whole mechanism. The YOLO arms trained on the same
  GSV-derived dataset barely move (+0.039, +0.024, and y26 goes the other way) against
  RampNet's +0.115, so RampNet is markedly more rig-sensitive than a YOLO trained on its
  own data. That points at preprocessing and the 2048x4096 input rather than the
  training distribution, and is an open question, not a closed one.

Caveats travel with the table: the arms are unpaired (249 GT ramps vs 220), and the
~51 corners within 20 m of each other are the paired subset that would turn this into a
measurement -- both arms' detections are committed, so it needs no new inference.

Registration follows docs/adding_a_benchmark_city.md, which prescribed all of this and
described machinery that did not exist. CITY_OF is now real, and so is the
one-pooled-split-per-city test it exists for. Held out for non-independence, not GT
quality: 59% of gsv panos sit within 20 m of a mapillary one (median NN 17.2 m), so the
arms largely see the same physical ramps and pooling both would double-count them. The
pooled operating point is unchanged, which is what held-out has to mean.

Three latent bugs surfaced, each found by a test rather than by reading:

- pool_of() named the three held-out splits literally instead of deriving from
  HELD_OUT, so a newly held-out split was pooled anyway -- laurens_gsv would have been
  pooled alongside its own sibling arm while HELD_OUT said otherwise. Now derived.
- The AP provenance table had a binary (substituted, or "bundle, already at 0.05") that
  held only while manual_gold was the sole unsubstituted row. It printed "already at
  0.05" against laurens_gsv, whose bundle is truncated at 0.55 with no op_cache to swap
  in -- a false provenance claim on the page whose job is provenance. Cells now carry
  bundle_floor and the table distinguishes three cases.
- docs/replication.md's file ledger went stale again (125 -> 136).

Detections: 11 more files, all --verify identical to the cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jonfroehlich

Copy link
Copy Markdown
Member Author

Deep review

Re-ran the branch head (0c8e0f4) in a clean worktree. The split itself is sound and every number the PR body derives from a committed script reproduces exactly. score_validation.py benchmark/laurens prints P 0.898 (0.827–0.942) / R 0.390 (0.331–0.451), unbiased 0.863 / 0.325, to the digit. low_floor_sweep.py parity --cities laurens reports 111/111, 100.0% exact, med R 0.000. low_floor_sweep.py sweep regenerates analysis_out/op/low_floor_sweep.csv with zero value differences across all 756 rows, and its POOLED rows are the PR's table (0.9002 → 0.8991, 0.7927 → 0.7640 at 0.30; 0.9637 → 0.9594, 0.7223 → 0.6864 at 0.55; n 859 → 953). The imagery-manifest digest 7485886ea08f671b re-derives from the committed sha256 list; verdict, record and manifest pano ids are the same 94-element set; 111 detections judged = 111 in records.jsonl. The suite is 8 failed, 1314 passed, 1 skipped, and the eight are exactly the test_scoreboard.py tests the body names, no others. 2.65 ramps/pano (3rd of the ten city splits) and 1.62 misses/pano (next highest 1.18) re-derive by hand from analysis_out/scoreboard.json.

Two things changed underneath this PR since it was opened, and the review has to be read against them. PR #153 (fix/laurens-challenger-coverage, base benchmark/laurens) is stacked on this branch: it renames laurenslaurens_mapillary, adds the laurens_gsv arm, scores all 11 challengers on both, and updates the eight test constants after doing so. And #151 was amended on 2026-09-03: the near-field finding this PR's body leans on was withdrawn. Each finding below says whether #153 already resolves it.


1. High — verdicts.json carries no review_notes; the checklist says it does

benchmark/laurens/verdicts.json has the keys run_key, run_name, source, exported_at, panos and nothing else. The PR checklist ticks "review_notes written". The runbook (docs/adding_a_benchmark_city.md, Phase 2) makes it a required step — "reviewer confidence + what fought the rubric" — and every split added since the runbook existed carries one (paterson, gainesville, budapest, sao_paulo: reviewer, reviewed_at, confidence, summary, caveats). CLAUDE.md's replication rule is stricter still: human judgments are committed per rater, with the rubric they were made under.

So the split with the worst recall in the benchmark, by a factor of two, is the one whose reviewer, review date and confidence are recorded nowhere in the repo. Three per-pano notes exist (all three say leaves), and the gt_gallery.py panel that exports the block ("Your confidence in this pass") was evidently left empty.

Also worth recording in the block, because the PR body does not mention it: 38 of the 190 missed-ramp marks are unsure (20%; score_validation prints "+38 unsure, abstained"). That is the largest abstention count of any split and is itself a legibility signal for November GoPro imagery.

#153 does not fix this — it renames the file with zero content change, and laurens_gsv/verdicts.json has no review_notes either.

Fix: add a review_notes block in the same shape as gainesville's. The reviewer name, date (exported_at is 2026-08-31) and the leaf/abstention caveats can be written from the record; the confidence value has to come from whoever did the pass (see Decisions). A content edit under a downstream pure rename merges cleanly.

2. High — the PR body's central interpretation has been withdrawn, and the body still asserts it

The body says: "uniquely among all ten splits — its misses are near, not far-field (delta +0.004 in normalized y; every other split is −0.013 to −0.030)." Commit 517b3cf says the same, and adds the luminance/shadow numbers (94.6 vs 97.4; 47% vs 42%). Verified against #151: the issue body now carries an amendment block dated 2026-09-03 — "the second bullet under 'The finding' is withdrawn. The positive near-miss delta is arm-specific: laurens_gsv reads −0.0053, back to the normal far-field direction." #153's body says the same. #153's body gives the mapillary arm as +0.0038.

The withdrawn claim is not in any committed file on this branch — I grepped docs/, benchmark/README.md, scripts/analysis/, analysis_out/op/ for it and it appears only in the PR body and the commit message. So no doc needs rewriting here; the PR body does.

A second problem sits underneath it: none of the secondary measurements has a committed script. The normalized-y delta, the 2%-window luminance, the shadow fraction — nothing in scripts/ computes any of them (grepped ramps/pano, luminance, shadow, normali[sz]ed.y, delta_y), and #151's comments name no script either. Under CLAUDE.md a number produced by a script that does not exist in the repo is a memory, not a result. The fact that this one turned out to be an artifact is the argument for the rule.

Fix: edit the PR body — add an amendment block at the top of "The result" that withdraws the near-field bullet, points at #151's amendment and #153, and states that the y-delta and luminance figures came from an ad hoc measurement with no committed script. Do not rewrite the commit.

3. Medium — docs/model_scoreboard.md: the generated tables were regenerated, the prose around them was not

The file's hand-written text now contradicts the tables it wraps, on the same page:

line prose says the table beside it says
3 "Eighteen model legs, ten splits" eleven splits
7–12 "YOLO11l … 0.604; Gemini 3.1 Pro … 0.608 … a dead heat" neither model is in the headline table any more (both 7/8, partial)
13–14 "Its F1 spans 0.80–0.85 across the seven cities, a range of 0.053 … the only strong model that is also stable" F1 range column: 0.54–0.85
18–19 "0.969, above RampNet's 0.958 … against RampNet's 0.728" RampNet 0.951 / 0.686
24 "RampNet is the top score in all ten splits" eleven; and after #153 it is false on laurens_mapillary (y26_pano 0.574 > 0.543)
26–27 "clovis … is the worst pooled city for 5 of the 12 models" RampNet's worst is now laurens
34 "PR curves, pooled over the seven US splits" eight
42–44 "The pool is seven cities, not ten. … The three held-out splits" eight of eleven; three held-out becomes four after #153

The "stability" finding (line 13) is the one that matters scientifically: the sentence is now refuted by its own table, and a reader who trusts the prose gets the pre-Laurens picture.

#153 does not fix this — its model_scoreboard.md diff touches only lines inside the GENERATED markers. Prose edits outside those markers will merge cleanly; write them as "eight US splits" rather than naming laurens wherever possible so the downstream rename does not have to touch them.

4. Medium — the two operating-point figures were not regenerated

plot_operating_point.py gained the laurens SERIES entry and collect() reads ALL_SPLITS, so a regeneration would draw the eighth city — but docs/figures/operating_point_pr.png and docs/figures/storage_floor_ceiling.png are not in the diff (last touched bdd7d55, 2026-08-01). The committed figure is a seven-split plot sitting next to eight-split CSVs. The runbook's Phase 4 checklist has "Both figures regenerated and visually inspected" precisely because "an eighth series changes the legend layout and can collide with the curves". The body's "five figures regenerated" are the scoreboard figures, which are a different script.

#153 does not regenerate them either. Regenerate both here and look at them; #153 will need to run the same two scripts once more after its rename so the legend reads laurens_mapillary and includes the dashed laurens_gsv line it added to HELD_DASH.

5. Medium — the frozen-study fix was applied to two studies; three more have the same exposure

The PR's reasoning is right: a committed study whose script reads the live US_SPLITS is silently restated by every new registration. It was applied to farfield_forensics and silent_activation because their tests failed. But four more committed studies default --cities to the live tuple, and three of them do not even record which splits they covered:

committed result script default records its split list?
analysis_out/miss_decomposition.json US_SPLITS (now 8) no
analysis_out/miss_taxonomy.json US_SPLITS (now 8) no
analysis_out/silent_witness.json US_SPLITS (now 8) no
analysis_out/fp_taxonomy.json US_SPLITS (now 8) yes — cities: the seven

A default re-run of any of the first three now overwrites a seven-split result with an eight-split one, and nothing in the payload says which it is. None was re-run for this PR (they are unchanged in the diff), and the body does not say so — per CLAUDE.md, an unstated gap is indistinguishable from a hidden one. The numbers those files feed (docs/operating_point.md's "Pooled over the seven US splits" at lines 212, 265, 409; the #46 taxonomy rates) are therefore still seven-split figures under an eight-split registry.

Fix, minimal: have miss_decomposition.py, miss_taxonomy.py and silent_witness.py write "cities": [...] into their payloads (the same key fp_taxonomy and silent_activation already use), and add a line to the PR body naming these four studies as not re-run for Laurens. Whether to re-run them is a decision (below). #153 touches miss_decomposition.py in a different hunk (the registry lines), so this merges.

6. Medium-low — Phase 6 is not done, and one Phase 5 item is unstated

The body says benchmark/README.md, docs/model_comparison.md and docs/operating_point.md are not updated and gives the reason (the #55 pass). Two things go beyond that:

Stale split counts that this merge makes wrong and that #153 does not touch, for whoever does the docs pass: docs/model_comparison.md lines 3, 135, 143, 549, 766, 805, 919, 935, 1822; docs/operating_point.md lines 123, 188, 212, 373, 409, 762 (#153 rewrites the pooled block at 262–303 but not these); docs/replication.md "9 splits" at 227, 245, 282, 301, 448, 457 (#153 updates the file ledger, not the config counts).

7. Low

  • silent_activation.py pins its frozen tuple under the name US_SPLITS, the same name the live registry uses in every other module, while farfield_forensics.py chose PUBLISHED_SPLITS for the same thing. from silent_activation import US_SPLITS now returns a different tuple from from miss_decomposition import US_SPLITS, and the test has to alias it (as PUBLISHED_SPLITS) to say what it means. Rename to PUBLISHED_SPLITS in the script and the test.
  • The test_ap_ordering_is_not_an_artifact_of_the_floor margin, 0.0993 against an asserted 0.1, is a population mismatch as the body says — confirmed the other way round by The Laurens deficit is the rig, not the town: add the GSV arm and score all 11 challengers on both (#151) #153, which only moves the constants after scoring the challengers on the new split. Leaving the eight tests red rather than nudging the thresholds was the right call.
  • Strata 5/64/25 = 94: the geometric explanation checks out (sample.json records min_spacing_m: 30, selected: 94 from a 4,495-pano run), the unbiased column is 89 panos, and the body says the intervals are wider. Fine as stated.

Decisions for Jon, not fixes

What holds up

  • Every script-derived number in the body reproduces to the digit, and the parity gate is bit-exact as a Mapillary split should be.
  • The manifest was written before review, its digest re-derives, and the three id sets agree. The archive reconciliation claim (8.3 GB Mapillary, 40 GB GSV) is the one thing a clean clone cannot check; the body says where it lives.
  • The frozen-study pinning is correct and tested: test_the_recorded_scope_is_the_pooled_population asserts the committed payload's cities equals the frozen tuple, and test_farfield_forensics builds its population from PUBLISHED_SPLITS. Registering a city can no longer restate either study.
  • Registration is complete on every touchpoint the runbook lists, plus SPLIT_HEADER in scoreboard_render.py, which the runbook does not list and which The Laurens deficit is the rig, not the town: add the GSV arm and score all 11 challengers on both (#151) #153 keeps. tier_of() resolves GoPro Max to action-modern through the existing "max" in model branch, so no new tier was needed. SERIES slot 7 is the vacated categorical slot, not an invented hue.
  • Camera provenance is present on all 94 records; the copyright field matches the shape every other Mapillary split carries.
  • The eight CI failures are exactly the ones named, and refusing to bump the constants was correct.

Fix list

  • [F1] High Add a review_notes block to benchmark/laurens/verdicts.json in gainesville's shape (reviewer, reviewed_at = 2026-08-31, confidence, summary, caveats). Caveats must include: 38 of 190 missed marks abstained as unsure; leaf litter noted on three panos; strata 5/64/25 with an 89-pano unbiased column. If the reviewer's confidence is not known to you, write the block with confidence set to the string "unrecorded — reviewer to fill in" and say so in the PR comment; do not invent a level. Write with newline="", keep the existing key order, and confirm score_validation.py benchmark/laurens prints the block above the numbers — files: benchmark/laurens/verdicts.json
  • [F2] High Edit the PR body (gh pr edit 152): add an amendment block under "The result" withdrawing the "misses are near, not far-field (+0.004)" bullet, citing Recall collapses on flush / minimal-reveal curb ramps: Laurens R 0.390, and the experiment that isolates it #151's 2026-09-03 amendment and The Laurens deficit is the rig, not the town: add the GSV arm and score all 11 challengers on both (#151) #153 (laurens_gsv −0.0053); state that the y-delta, luminance and shadow figures came from an ad hoc measurement with no committed script; add a "Deliberate omissions" list naming the four studies in F5 as not re-run and the HF republish as waiting on The Laurens deficit is the rig, not the town: add the GSV arm and score all 11 challengers on both (#151) #153's rename; replace "The decision this needs" with a note that The Laurens deficit is the rig, not the town: add the GSV arm and score all 11 challengers on both (#151) #153 resolves it and gives the merge order. No session links, keep the existing attribution line — files: PR body only
  • [F3] Medium Rewrite the stale hand prose in docs/model_scoreboard.md outside the GENERATED markers so it agrees with the regenerated tables: line 3 split count; findings 1–3 (lines 7–19: the YOLO/Gemini dead-heat numbers are no longer in the headline table, RampNet's F1 range is 0.54–0.85 across eight cities and the "only stable model" sentence must go or be qualified by Laurens, RampNet's P/R are 0.951/0.686); line 24 "all ten splits"; lines 26–27 worst-city claim; line 34 caption; lines 42–44 pool description. Say "eight US splits" rather than naming laurens where the sentence allows, so The Laurens deficit is the rig, not the town: add the GSV arm and score all 11 challengers on both (#151) #153's rename does not have to touch it — files: docs/model_scoreboard.md
  • [F4] Medium Regenerate docs/figures/operating_point_pr.png (python scripts/analysis/plot_operating_point.py) and docs/figures/storage_floor_ceiling.png (python scripts/analysis/plot_storage_floor.py), open both and check the eighth series does not collide with the legend or the curves; note in the PR comment that The Laurens deficit is the rig, not the town: add the GSV arm and score all 11 challengers on both (#151) #153 must re-run both after its rename — files: docs/figures/operating_point_pr.png, docs/figures/storage_floor_ceiling.png
  • [F5] Medium Make scripts/analysis/miss_decomposition.py, scripts/analysis/miss_taxonomy.py and scripts/analysis/silent_witness.py write a "cities" list into their output payload (same key fp_taxonomy.py and silent_activation.py use), without regenerating the committed JSONs; add a module comment in each saying the committed result covers the seven pre-Laurens splits. Add one test that every committed study JSON under analysis_out/ produced by a script with a --cities default either carries cities or is named in an explicit allowlist with the reason — files: scripts/analysis/miss_decomposition.py, scripts/analysis/miss_taxonomy.py, scripts/analysis/silent_witness.py, tests/
  • [F6] Medium-low Update benchmark/README.md: add laurens rows to both split tables (all-panos and unbiased, from score_validation.py output) and a prose section in the shape of the other cities (what it is for, GoPro Max / Mapillary / 2025-11 / single uploader / 19 sequences, the leaf and abstention caveats, empty-group result), and fix the split counts at lines 81, 109, 117, 502. Name the split laurens as this branch does; The Laurens deficit is the rig, not the town: add the GSV arm and score all 11 challengers on both (#151) #153 will rename — files: benchmark/README.md
  • [F7] Low Rename silent_activation.py's frozen tuple from US_SPLITS to PUBLISHED_SPLITS and update tests/test_silent_activation.py to import it under that name, matching farfield_forensics.py — files: scripts/analysis/silent_activation.py, tests/test_silent_activation.py

🤖 Generated with Claude Code (claude-fable-5-1)

jonfroehlich and others added 2 commits September 4, 2026 09:49
The README was the last place either arm was invisible: no table row, no
section, and a set of hand-maintained split counts that were already stale
before Laurens landed ("All nine splits", "All eight city splits" — while
there were ten).

Adds both arms to the headline and unbiased tables, a § footnote explaining
that the two rows are one town shot twice, and a section on what the second
arm is for: holding the town fixed and changing only the rig separates "rural
is hard" from "this rig is out of domain", and it comes out the second way.

Three counts corrected, and one dropped rather than corrected. "Every city
split was reviewed at model resolution" is a property of gt_gallery.py
(RUBRICS.md §1, issue #26), not of a number someone has to remember to
increment, so the count is gone.

Two gaps recorded rather than papered over:

- The Laurens arms are in BENCHMARK_SPLITS but have NOT been pushed to the
  Hub, so "all nine splits are published" would now be false. Nine of eleven.
- Neither arm carries a review_notes block, so the "high confidence" rating
  both are quoted at in docs/model_comparison.md is not reproducible from the
  bundle — RUBRICS.md §1 says that block is where confidence lives, and
  score_validation.py looks for it there. annapolis has the same gap.
  sao_paulo, gainesville and budapest_district5 all carry theirs.

Numbers re-derived from the committed bundles with score_validation.py:
laurens_mapillary 94 panos 0.898/0.390 (unbiased 89, 0.863/0.325),
laurens_gsv 86 panos 0.925/0.505 (unbiased 81, 0.904/0.459).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…flat (#151)

Runs the benchmark's strongest zero-shot model on both arms of Laurens, which is
the sharpest available version of #151's test. If the GSV arm were simply easier
imagery, the best general model should gain on it too.

It does not. claude-opus-5 at effort low:

  laurens_mapillary  P 0.485  R 0.386  F1 0.430   96/102/153
  laurens_gsv        P 0.489  R 0.395  F1 0.437   87/91/133
                                       dF1 +0.007

RampNet gains +0.115 across the same two rigs. So the gain is a domain effect
specific to RampNet's training distribution, not the imagery being easier to
read -- which is what the write-up now says, in place of the weaker version that
rested only on models that were already far behind.

Claude is the best non-RampNet model on BOTH arms, ahead of molmo2-8B and
gemini-3.1-pro on each. That changes a published number: laurens_mapillary's
"0.200 lead over the best zero-shot challenger" was measured against gemini-pro
and is now 0.113. The doc says so explicitly rather than quietly editing it.
The "seven of eight move down" recount is now seven of nine, and neither of the
two that rise clears +0.008.

The mapillary arm needed a second pass. Two panoramas (2102336717175440,
2281219182305735) died on Vertex's transient 404 even after the detector's four
retries; compare.py isolated them and scored the other 92, which would have
published a recall against 247 GT ramps under a caption saying 249. Recovered
for 12 calls and $0.14. Same defect class as the annapolis sonnet/low pano.

tests/test_claude_annapolis_leg.py -> test_claude_published_legs.py, generalized
from one split to three. It still recomputes every published Claude number from
committed detections with no cache, no key and no GPU, and it now asserts
whole-split coverage per split (the guard that catches exactly the failure
above) and that both Laurens arms are the SAME leg -- a rig comparison whose two
halves came from different effort levels would attribute to imagery a difference
that is really a configuration change.

test_scoreboard's single-split assertion is now per-leg: opus/low covers 2 of 8
pooled splits, not 1, and partial coverage still means excluded from the pooled
tables. The point of that test is the exclusion, not the number.

Spend: 1,080 calls, 2,193,480 in / 72,534 out tokens, ~$12.79, logged to
analysis_out/usage_log.jsonl at run time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jonfroehlich jonfroehlich changed the title Add Laurens, IA — the first rural split (recall 0.390) and register it as the 8th US split Add Laurens, IA — two imagery arms over one rural footprint, and the deficit is the rig, not the town (#149, #151) Sep 4, 2026
@jonfroehlich
jonfroehlich merged commit 81e8028 into main Sep 4, 2026
2 checks passed
jonfroehlich added a commit that referenced this pull request Sep 4, 2026
…the review findings (#51)

Twelve findings were raised on this PR. Eleven are real; the fixes are below,
largest first.

Pin the split population in both scripts (was: the live registry). Both read
US_SPLITS/ALL_SPLITS from analysis.low_floor_sweep, and macro_at/pooled return
None unless every pooled split has a report. #152 and #153 have since merged, so
main now carries laurens_mapillary in US_SPLITS and laurens_gsv in CITY_SPLITS,
and neither has a YOLO report: measured against main's registry, this branch
fails 12 of 37 tests, both --check commands, and crashes with a TypeError in
_render. Worse than failing, RampNet has an op_cache for laurens_mapillary while
the YOLO legs do not, so a merged version would re-pool one side of the
comparison over a different population than the other. POOLED_SPLITS and
ALL_SPLITS_AS_RUN are now module constants asserted literally in the tests, and
the held-out keys are pinned to the run while only their reason text follows the
registry. Both committed artifacts are unchanged, and both --check commands pass
under main's registry as well as this branch's.

Correct the control's stated mechanism. The -0.0025 between RampNet's op_cache
re-score and its published bundle at 0.55 was attributed to peak extraction --
that a 0.05 floor finds a different peak set than a 0.55 floor. That cannot
happen: peak_local_max suppresses on a maximum filter, so lowering threshold_abs
can only add candidates, and the >= 0.55 subset of a 0.05-floor extraction is
exactly a 0.55-floor extraction. The real causes are already measured in
docs/operating_point.md: the four GSV splits' shipped detections came from a
4096x2048 resample rather than the native-resolution cache, and manual_gold's
carry flip-TTA. Five Mapillary splits agree to 0.0005; the other five differ by
up to 0.019.

That matters beyond the wording. The pooled macro cleared its 0.005 tolerance
partly by cancellation (+0.003 bend against -0.004 paterson and -0.016
gainesville), so a scoring regression could have passed it. The control is now
asserted per split as well: 0.002 on the five splits where the two paths are the
same computation, 0.025 on the five with a documented reason to differ. The
per-split table is in the artifact and printed by the script, and a test proves
the per-split check can fail. Two further consequences are now stated with the
numbers: the default dev split has the largest discrepancy of the ten, and
because three pooled splits are GSV, RampNet's parity F1 is about 0.002 low --
the reported gap is slightly conservative.

Make check_yolo_dataset_loads.py detect the failure its docstring claims. It
said a large nm was the signature of a rebuild that dropped the zero-byte
background labels, but it never read nm: Ultralytics appends a zero-box record
for a missing label file exactly as for an empty one, so deleting all 59,923
background labels leaves the image count, box count and zero-box count
unchanged and the script prints PASS. It now stats the label paths and fails on
any absent file, takes --expect-background, and says that augment=False only
warns on nf == 0 rather than raising.

Qualify the per-split reads in docs/operating_point_parity_51.md. The document's
own rule is that differences under ~0.02 F1 should not be read; section 2 then
read 0.009, 0.017 and 0.001 as two losses and a tie. All three are now reported
as ties, with the manual_gold margin flagged as the same size as the measured
TTA-vs-no-TTA delta on that split, and the paired test that would settle it
recorded as blocked on the tiles arm's detections being unpublished.

Replace the stale "tiles arms are still training" statements in
model_scoreboard.md, model_comparison.md and yolo_baseline/README.md, and add a
caveat beside the scoreboard's headline table -- outside the generated region --
saying the YOLO rows are at the unselected 0.25 default and pointing at both new
documents.

Add provenance and cost to docs/yolo_geometry_51.md: the eval was 67 min 39 s on
one A40, about 1.13 GPU-hours at no cost; the control leg was re-scored from
cached detections rather than re-inferred, so it isolates the matcher and does
not test inference; the checkpoints live only on cluster storage, so the run is
not reproducible from a clean clone; and the rescue job ids are not in the repo.
The driver now logs git status --porcelain in full instead of a path count.

Smaller items: rebuild_yolo_labels_from_cache.py no longer claims byte-identical
output for files that held duplicate rows (Ultralytics dedups and re-sorts them)
and writes with newline="\n"; the dev-split fairness test now asserts on
build()'s output for every candidate instead of a tautology; the at_floor test
is no longer vacuous; PUBLISHED_RAMPNET_F1 is imported rather than restated; the
AP table cites where RampNet's 0.849 comes from; and both scripts are indexed in
scripts/analysis/README.md.

Not fixed: the request to replace "load-bearing" with plain wording. It appears
38 times across more than 20 committed files in this repo, including
low_floor_sweep.py, scoreboard.py and four existing docs, so it is house usage
rather than something this branch introduced. "split-brain", which was new here,
is gone as part of the control rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant