Skip to content

Low-floor operating-point sweep: recommend lowering the peak threshold 0.55 → 0.30 (#54, #55) - #79

Merged
jonfroehlich merged 12 commits into
mainfrom
feat/low-floor-sweep-54
Jul 29, 2026
Merged

Low-floor operating-point sweep: recommend lowering the peak threshold 0.55 → 0.30 (#54, #55)#79
jonfroehlich merged 12 commits into
mainfrom
feat/low-floor-sweep-54

Conversation

@jonfroehlich

@jonfroehlich jonfroehlich commented Jul 28, 2026

Copy link
Copy Markdown
Member

Closes #54. Closes #55.

Draws the precision/recall curve below RampNet's deployed 0.55 peak threshold — which had never been characterised — across all seven benchmark splits, completes the #55 GT-completeness correction on every city split, and recommends a deployment operating point.

Recommendation: 0.55 → 0.30, uniform

Pooled over the five US/VA splits (n = 609 panos), with #55's correction applied:

at 0.55 (deployed) at 0.30
precision 0.965 0.917 corrected (0.897 raw, band high 0.928)
recall 0.744 0.821 corrected
F1 0.840 0.866 corrected
detections/pano 1.85 2.23

+7.7 recall points for −4.8 precision points. Recall is RampNet's weak metric on every split; precision is not.

The correction changed the answer

On raw numbers F1 peaked at 0.32 and the conservative pick was 0.35. With the GT-completeness correction applied, corrected F1 peaks at 0.30, where corrected precision (0.917) exceeds the raw precision at 0.35. That is exactly what #55 existed to determine.

@JonF tagged the four splits that had never been spot-checked, so all six now carry a verdict on every incremental false positive — an unmatched prediction scoring in [0.25, 0.55), i.e. one that only a lowered threshold surfaces:

  • A — a real curb ramp the ground truth missed. The model was right and the GT was incomplete, so this should count as a true positive.
  • B — a genuine false positive (driveway apron, crosswalk paint, stairs, concrete patch, hood, seam artifact). The model was wrong.
  • unsure — not callable from the imagery. Abstains from the corrected figure and widens the reported band instead of forcing a guess.

The A-rate is A over the total, i.e. what share of the apparent new false positives were real ramps all along — the correction factor on low-threshold precision.

split items A B unsure A-rate
richmond 29 5 22 2 17.2%
bend 24 7 15 2 29.2%
clovis 23 7 13 3 30.4%
morgantown 30 4 25 1 13.3%
annapolis 27 6 15 6 22.2%
budapest_district5 89 23 59 7 25.8%

Pooled, 26.9% of the incremental false positives in [0.30, 0.55) were real curb ramps the ground truth had missed.

Findings worth reading even if you skip the rest

The 0.92 minimum does not gate AI label submission. AiController.submitAiLabel checks an internal API key plus the per-city boolean ai-label-submission-enabled — there is no accuracy threshold on that path. ai-validation-min-accuracy belongs to the DINOv2 validator subsystem. And label_ai_info persists each label's confidence, so a lower floor is reversible server-side. The constraint is a policy call about review burden, not a gate.

Sub-0.55 precision is a lower bound, by construction. Decomposing every TP by the origin of the GT point it matched: below the review floor, every true positive comes from a reviewer missed-mark and none from a reviewed detection, on every split. The city GT was built from ≥0.55 detections, so a real ramp nobody flagged is scored as a false positive. low_floor_sweep.py gtbias reproduces this.

The A-rate does not track imagery quality. Morgantown — the cleanest split in the benchmark — has the lowest A-rate (13%) and clovis, the softest, the highest (30%). So no cross-split GT-completeness constant is quoted; the correction is applied per split.

Clovis is the cheapest place to lower the threshold, not the binding constraint. It gains +10.3 recall points, the largest of any US split.

Not per-tier. Tier optima cluster at 0.32–0.35 across a 2018 GoPro Fusion and a survey-grade Trimble MX7. Only GSV/bend differs (0.50) and it is confounded three ways — one split, in-domain, and the resample caveat below. A per-tier policy would fit noise.

This is not recall-by-carpet. Density rises 1.85 → 2.23 detections/pano, leaving RampNet 25–40× sparser than the open detectors at their operating points (docs/model_comparison.md's null-recall finding).

The recall gain stacks with multi-view. It is uniform across distance bands (+0.071 near / +0.078 mid / +0.072 far), so it does not overlap with what labeler#27 would recover.

Verification

Parity gate — peaks at ≥0.55 must reproduce each split's committed records.jsonl. Measured in match radii, since bit-exactness is the wrong bar:

split identical cell within 0.5 R max
richmond / clovis / morgantown / annapolis / budapest 100% 100% 0.000 R
bend 79.0% 98.4% 0.439 R
manual_gold † 80.9% 99.9% 0.472 R

Every Mapillary split reproduces bit-exactly, on different hardware. Bend is the sole exception for a structural reason: it is the only GSV split, and the GSV production path built a 4096×2048 intermediate, so production fed the model a different resample than the native-res bundle does. This was predicted before the four remaining splits ran, and held. (Consequence: bend's GT points and predictions come from different resamples, making its numbers mildly pessimistic.)

manual_gold is deliberately not gated — its committed detections used flip-TTA, so its row is a TTA delta. Free data point for #78: TTA yields 3.5% more detections at ≥0.55, 99.9% co-located.

Re-extraction is lossless. richmond re-extracted on an L40S reproduced the earlier cache with 0 of 537 peaks moved (max confidence delta 6e-05), and all 222 committed #55 tags across six splits still resolve (low_floor_sweep.py tagcheck, which exists because tag ids are keyed to peak coordinates and a re-extraction could silently orphan reviewer work).

What is in here

  • scripts/analysis/low_floor_sweep.pyparity | sweep | hist | gtbias | distance | corrected | tagcheck, all CPU-only off the committed caches
  • scripts/analysis/run_low_floor_extract.slurm — the one GPU step (one L40S, 41 min for 1,625 panos)
  • operating_point_curve.pyextract now reads both bundle kinds, so manual_gold (independent YOLO GT, no verdict review) joins the sweep; per-split skip makes a preempted job resumable
  • docs/operating_point.md — the full analysis
  • analysis_out/op_cache/*.json — 780 KB of image-free detections, committed so every number re-derives without a GPU
  • 44 new unit tests (222 total, green)

Honest limits

  • Tuned on the benchmark — no separate validation split, so 0.30 is optimistic. Mitigated by the F1 curve being flat (0.849–0.857 across 0.25–0.55): the choice is robust even where the argmax is not.
  • The correction rests on one rater, who also produced the GT.
  • Clovis is the split to watch — corrected precision 0.883 at 0.30 against 0.914 deployed, the only US split giving up meaningfully more than the others.
  • Budapest is excluded from the recommendation (single-rater low-confidence GT) and needs its own decision.
  • A claim that the anchoring bias "runs in both directions" was removed during analysis: the aggregate check did not support it (sub-0.50 precision 0.195 anchored vs 0.208 un-anchored). docs/operating_point.md reports that tension rather than the stronger claim.

Hand-offs

  • labeler#20 applies the constant; nothing in this PR touches labeler code.
  • labeler#27 stage 4 gets the calibration histograms — see the doc's final section. The measured single-view crossover is ≈0.40–0.45 on both anchored and un-anchored GT; the promotion floor should sit below it, but by how much is not answerable from 30 m-thinned per-pano data.

🤖 Generated with Claude Code (claude-opus-5[1m])

jonfroehlich and others added 6 commits July 28, 2026 15:52
One low-floor extraction, four consumers (#54 operating point, #55 GT
completeness, labeler#20 deployment constant, labeler#27 stage-4 promotion
floor). operating_point_curve.py already built the per-city half for richmond
and bend; this adds the cross-split layer and extends extraction to every split.

- operating_point_curve.py: `extract` now resolves ground truth for BOTH bundle
  kinds, so benchmark/manual_gold (gt_source.json + independently-labelled YOLO
  points, no verdict review) joins the sweep alongside the verdict-reviewed city
  splits. Per-split cache skip makes a preempted Slurm job resumable, and the
  cache meta now records the model repo and device.

- low_floor_sweep.py: parity gate, per-split/pooled/per-tier curves, confidence
  calibration, and distance stratification — all CPU-only off the cached
  detections, so every number re-derives without a GPU.

Three choices worth stating:

* Tiers are assigned PER PANO from camera provenance, not per split. Richmond
  alone mixes iSTAR Pulsar and GoPro Max, so split-level grouping would smear
  the camera effect that benchmark/README.md shows precision tracking.

* Parity is measured in match radii, not pixels. Bit-exactness is the wrong bar:
  the GSV production path built a 4096x2048 intermediate, so bend's committed
  detections came from a different resample than the native-res bundle pano.
  Richmond reproduces 100% exactly; bend is 79% exact, 98.4% within tolerance,
  max displacement 0.44 R. The gate asks whether any drift could change a
  scoring outcome, which is the question downstream actually depends on.

* budapest_district5 and manual_gold are swept but held out of the pooled and
  per-tier rows, each with a stated reason printed alongside the results — an
  omission with no reason is indistinguishable from a withheld result.

manual_gold is NOT parity-gated: its committed detections were exported with
flip-TTA at a 0.05 floor, so that row is a TTA delta rather than drift (#78).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The confidence-calibration table showed a cliff at the deployed floor: pooled
P(real) is 0.42 in the 0.45-0.50 bin and 0.93 in 0.50-0.55. That is not a
property of the model, and `gtbias` shows why.

Decomposing every true positive by the origin of the GT point it matched
(reviewer-confirmed detection vs reviewer's missed-ramp mark) gives a clean
structural result on both cities: BELOW 0.55, every single TP comes from a
missed mark and none from a reviewed detection. It cannot be otherwise — the
city splits' GT was assembled from detections at or above the deployed floor,
so in the band this sweep opens up, a prediction is credited only if a human
independently flagged that ramp during the missed-ramp pass. A real curb ramp
nobody marked is scored as a false positive.

Two consequences worth carrying:

* Sub-0.55 precision on the city splits is a lower bound with a KNOWN
  mechanism, not an unquantified worry — so the measured F1-optimal threshold
  is biased HIGH and the true optimum sits at or below it. That is a
  directional guarantee the recommendation can lean on.

* benchmark/manual_gold is the control, not just another split: its GT was
  labelled independently of RampNet, so it carries none of this bias at any
  threshold. Comparing its precision-vs-threshold shape against the city
  splits estimates the anchoring effect directly.

Also fixes a float-floor bug found by the tests: 0.9 // 0.1 is 8.0, so a 0.9
detection was being binned as 0.8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-extracting on different hardware for one clean provenance risks something
easy to miss: the #55 A/B tag ids are keyed to peak COORDINATES
({pano}_{x:.5f}_{y:.5f}), so a marginal peak that moves one heatmap cell
orphans its tag. Nothing errors — the tag just stops contributing and the
GT-completeness correction quietly shrinks, which is exactly the kind of silent
loss of reviewer effort that is hard to notice after the fact.

`tagcheck` makes that failure loud: it re-derives the incremental FPs from a
cache and reports resolved vs orphaned tags, defaulting to requiring 100%.

Baseline against the pre-re-extraction cache: richmond 29/29 and bend 24/24
resolve, which reproduces the documented 17% (5/29) and 29% (7/24) A-rates and
confirms the check is calibrated rather than vacuous.

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

jonf tagged the incremental false positives for the four splits that had never
been spot-checked, completing the correction that previously covered only
richmond and bend. Every unmatched prediction in [0.25, 0.55) is now tagged A
(a real ramp the GT missed), B (a genuine FP) or unsure:

  clovis      23 items  A=7  B=13  U=3   (A-rate 30.4%)
  morgantown  30 items  A=4  B=25  U=1   (13.3%)
  annapolis   27 items  A=6  B=15  U=6   (22.2%)
  budapest    89 items  A=23 B=59  U=7   (25.8%)

plus the existing richmond 17.2% and bend 29.2%.

This settles a tension the manual_gold control had left open. Comparing
anchored city GT against un-anchored manual_gold showed the *mechanism* (a
discontinuity at the review floor that only the anchored splits have) but NOT a
large aggregate gap — sub-0.50 precision is 0.195 anchored vs 0.208 un-anchored.
The tagging resolves it: 26.9% of incremental FPs in [0.30, 0.55) are real
ramps, so the effect is real, and the aggregate similarity is better explained
by manual_gold being in-distribution.

**The correction changed the recommendation.** On raw numbers F1 peaked at 0.32
and the conservative pick was 0.35; corrected, F1 peaks at 0.30 with pooled
precision 0.917 — higher than the RAW precision at 0.35. That is exactly what
#55 existed to determine.

Adds a `corrected` subcommand so the per-split and pooled correction is
reproducible rather than hand-computed, and docs/operating_point.md with the
full analysis and the recommendation (0.30, uniform, not per-tier).

Two things deliberately NOT claimed: no cross-split GT-completeness constant
(the A-rate spans 13-30% with no ordering by imagery quality), and no
per-tier threshold (four of five tiers agree within 0.03; the GSV outlier is
confounded three ways).

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

The GT-completeness column had four ❌s and a caveat saying "only richmond and
bend have been corrected". All six are now corrected (A-rates 13%-30%), so the
matrix, the caveat, and the "two cities is too few" note are updated.

Keeps the substantive conclusion unchanged and sharpens it: the spread still
does not support a single cross-city constant, and it does not order by imagery
quality — morgantown (cleanest imagery) has the LOWEST A-rate and clovis
(softest) the highest, which rules out the tempting "worse imagery means more
missed ramps" reading.

Also records manual_gold's role correctly. It was an ❌ in that column; it is
not a gap but the control — its GT is independent of RampNet, so there is no
anchoring to correct, and comparing the two regimes is what isolates the effect.

Links the new docs/operating_point.md, which is where those A-rates are
consumed.

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

Every number in docs/operating_point.md came from one GPU pass on Hyak. Without
the caches, checking any of them means re-running inference over 1,625 panos --
which also needs the gitignored native-res imagery. That is the failure mode
CLAUDE.md warns about: a result nobody can re-derive later.

The seven caches are image-free (x, y, confidence) + derived GT, 780 KB total,
so they are committed for the same reason benchmark/*/records.jsonl is.

.gitignore needed restructuring, not just a negation: git cannot re-include a
file whose PARENT DIRECTORY is excluded, so `analysis_out/` + `!.../*.json` is
silently a no-op. It now ignores `analysis_out/*` (contents) and re-includes
`analysis_out/op_cache/*.json`. Verified both directions -- the caches are
tracked, the galleries and regenerable CSVs are still ignored.

richmond and bend are the re-extracted versions, so all seven now share one
provenance (one L40S, one env, one code revision). Re-extraction was confirmed
lossless: richmond reproduced the earlier cache with 0 of 537 peaks moved (max
confidence delta 6e-05), and all 222 committed #55 tags across six splits still
resolve.

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

Two questions from PR review, both answerable from the committed caches with no
GPU, and both about a threshold that -- unlike the submission threshold -- is
NOT reversible: a peak below the labeler's DETECTION_STORAGE_FLOOR is never
written, so no downstream consensus can promote it.

`low_floor_sweep.py floor` answers them in one pass. Greedy matching is
highest-confidence-first, so dropping low-confidence predictions never changes
what a higher one matched -- which means one pass over the cache yields, per GT
ramp, the confidence of its best candidate, and every floor's answer falls out
of that single vector.

1. WHAT A 0.1 FLOOR DISCARDS. Pooled over the 5 US splits, 38 of 1393 GT ramps
   (2.73%) have their best candidate in [0.05, 0.10) -- permanently
   unrecoverable at that floor. Per split 2.33%-3.08%; manual_gold only 0.48%.

2. THE RECALL CEILING. 0.872 at the 0.1 floor vs 0.899 at the 0.05 extraction
   floor and 0.744 deployed. labeler#27 stage 4 has +12.8 points of headroom
   over the deployed threshold and +5.4 over the 0.30 recommended here, but is
   capped by storage at 0.872 rather than by the model's 0.899.

VERDICT: lower the storage floor to 0.05. The decisive fact is that THE TOP-50
CAP NEVER BINDS -- the busiest pano in the whole benchmark holds 14 candidates
at a 0.1 floor, and still 14 at 0.05, against a cap of 50. So the cap is not the
volume bound the design assumed; the floor is, and it is silently costing 2.7%
of findable ramps for about one extra candidate per pano. That is labeler-side
work; recorded here because this is where the measurement lives.

Figure: docs/figures/storage_floor_ceiling.png, from plot_storage_floor.py.
Bars are a SHARE of each split's GT, not raw counts -- manual_gold has 3,919 GT
ramps against ~300 for a city split, so counts would make it tower while its
rate is actually the lowest of any split. Palettes (a categorical pair, and a
blue ordinal ramp for the three ordered floors) were run through the palette
validator rather than eyeballed.

Also commits the derived result tables (~80 KB) so a reader can check any number
in the prose against the table it came from. The 181 MB of gallery crops stay
ignored -- they regenerate from the caches, and their irreplaceable part (the
human A/B tags) is already committed under benchmark/.

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

Copy link
Copy Markdown
Member Author

Both additions are in — and (1) comes out against the current floor

Pushed as c4921e7. New subcommand low_floor_sweep.py floor + plot_storage_floor.py, both CPU-only off the committed caches. 231 tests green.

One implementation note: greedy matching is highest-confidence-first, so dropping low-confidence predictions can never change what a higher one matched. That means a single pass yields, per GT ramp, the confidence of its best candidate — and every floor's answer falls out of that one vector. There's a test asserting recall_at[f] equals the swept recall at f, so the two paths can't silently drift.

(1) GT-matched counts by band — this falsifies DETECTION_STORAGE_FLOOR = 0.1

Ramps whose best candidate lands in each band, i.e. what a 0.1 floor makes permanently unrecoverable:

split GT best in [0.05, 0.10) share best in [0.10, 0.20)
richmond 310 8 2.58% 5
bend 327 8 2.45% 5
clovis 195 6 3.08% 5
morgantown 267 7 2.62% 12
annapolis 294 9 3.06% 10
POOLED (5 US) 1393 38 2.73% 37
budapest* 300 7 2.33% 18
manual_gold† 3919 19 0.48% 48

The decisive fact isn't the 2.73% — it's that the top-50 cap never binds. At a 0.1 floor the busiest pano in the entire benchmark holds 14 candidates against a cap of 50 (medians 2–5). At a 0.05 floor the maximum is still 14.

So the cap is not the volume bound labeler#28's design assumed. The floor is, and it's costing ~2.7% of findable ramps for a saving of roughly one extra candidate per pano. labeler#28's own argument — storing too little is irreversible, storing too much only costs disk, and the cap bounds it regardless — is correct and points at 0.05, not 0.1.

Recommendation: lower DETECTION_STORAGE_FLOOR to 0.05. Labeler-side change; recorded here because this is where the measurement lives.

(2) Recall-at-floor — the ceiling on stage-4 consensus

pooled (5 US)
deployed 0.55 0.744
recommended 0.30 0.818
ceiling at the 0.10 storage floor 0.872
ceiling at the 0.05 extraction floor 0.899

labeler#27 stage 4 has +12.8 recall points of headroom over the deployed threshold, +5.4 over the 0.30 recommended here — but it is capped at 0.872 by storage, not by the 0.899 the model actually produces. Whatever k consensus requires, it cannot promote a candidate that was never written.

Figure

storage floor and recall ceiling

Two panels rather than one, because they're different questions and a shared axis would misrepresent both. Two things I changed after rendering and looking at it: the left panel plots a share of each split's GT, not raw counts (manual_gold has 3,919 GT ramps against ~300 for a city split — counts made its bar tower while its rate is in fact the lowest of any split, which is exactly backwards), and the legends moved below the axes where they were colliding with the POOLED bars. Palettes were run through the validator, not eyeballed.

On checking in analysis_out/

Already partly done in c7098be — the seven detection caches (780 KB, image-free) were committed there, which is what makes every number re-derivable on CPU. This commit adds the derived result tables (~80 KB: the swept grid, calibration, corrected P/R, distance, storage floor), so any figure quoted in the prose can be checked against the table it came from.

Deliberately still ignored: the 181 MB of gallery crops. They regenerate from the caches plus the panos, and their irreplaceable part — @JonF's A/B tags — is already committed under benchmark/<city>/incremental_fp_tags.json. Happy to include them if you'd rather, but 181 MB of regenerable PNGs in git seemed like the wrong trade.

.gitignore needed restructuring rather than a negation, incidentally: git can't re-include a file whose parent directory is excluded, so the obvious analysis_out/ + !analysis_out/op_cache/*.json is silently a no-op.

*budapest GT is single-rater, low confidence. †manual_gold is in-distribution GSV with independent GT.

🤖 Generated with Claude Code (claude-opus-5[1m])

#54 asks for "the full PR curve (precision & recall vs. threshold) ... with the
deployed 0.55 point marked", per split. I built the storage-floor figure for the
review question and never made this one — the headline artifact was missing.

Left panel: precision against recall, traced by sweeping the threshold, one line
per split, deployed 0.55 hollow and recommended 0.30 filled on each curve. The
shape is the point — nearly flat across the operating region, turning down hard
only past ~0.85 recall.

Right panel: F1 against threshold, which exists to make a NEGATIVE point. The
pooled curve varies by under 0.01 across 0.25-0.55, so F1-optimality is not a
sufficient reason to pick a number; the recommendation rests on recall-first,
the density budget and reversibility instead. Per-split optima cluster tightly
while the levels differ — the same pattern as the per-tier table.

Two encoding notes:

* Line style, not colour, carries held-out status (budapest and manual_gold
  dashed), leaving colour free to carry identity alone and consistent across
  both panels.

* Direct labels were tried and REMOVED. Three of the seven categorical slots sit
  below 3:1 on a light surface, so the palette's relief rule demands visible
  labels or a table view; per-line labels were the first choice, but in the PR
  panel the splits converge into a cluster around (0.80, 0.88) where seven
  labels overlap illegibly — a worse accessibility outcome than a shared legend
  plus the committed CSV, which is the route taken.

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

Copy link
Copy Markdown
Member Author

Added the PR-response figure — you're right, that's the headline artifact

Pushed as c6fc89f. I'd built the storage-floor figure for the review question and skipped the one #54 actually asks for ("the full PR curve (precision & recall vs. threshold) … with the deployed 0.55 point marked"). scripts/analysis/plot_operating_point.py, CPU-only off the committed caches.

precision-recall response per split

Left — the PR curve, one line per split, threshold swept from 0.05 to 0.90. Hollow marker = deployed 0.55, filled = recommended 0.30, on every curve. The shape is the argument: the curves run almost flat across the operating region, so recall is bought at a shallow precision cost right up until they turn down hard past ~0.85 recall. The two operating points sit on the flat part on every split.

Right — F1 vs threshold, and it's there to make a negative point. The pooled curve varies by under 0.01 across 0.25–0.55. So "F1-optimal" isn't a sufficient reason to pick any number, and the 0.30 recommendation rests on the recall-first policy, the density budget and reversibility rather than on the argmax. The per-split optima (dots) cluster tightly while the levels differ a lot — the same pattern as the per-tier table.

Both panels colour by split consistently, so a line is the same entity in each. Held-out splits (budapest, manual_gold) are dashed, so line style carries the held-out status and colour is free to carry identity alone.

One design note worth recording

I tried per-line direct labels first — three of the seven palette slots sit below 3:1 on a light surface, so the relief rule demands visible labels or a table view. They were illegible: in the PR panel the splits converge into a cluster around (0.80, 0.88) and seven labels can't be placed there without overlapping. Screenshotting and looking at it is what caught it. Switched to a shared legend plus the committed low_floor_sweep.csv as the table view, which discharges the same requirement without the collision.

Same class of fix as the storage-floor figure, where rendering it revealed that raw counts made manual_gold tower on the strength of having 3,919 GT ramps.

Both figures are now in docs/operating_point.md — the PR curve leads the Results section, the storage-floor one sits with the labeler#28 analysis.

🤖 Generated with Claude Code (claude-opus-5[1m])

jonfroehlich and others added 2 commits July 28, 2026 17:58
That README is the repo's index of analysis tooling — PR #65 was literally
adding a row to it — and this branch added eleven entry points without touching
it. Adds a row per `low_floor_sweep.py` subcommand plus the two plot scripts and
a note on the Slurm launcher.

Also corrects two statements in the intro that this branch made wrong:

* "Everything here reads the committed benchmark bundles (benchmark/{richmond,
  bend}/)" — it is all seven splits now, and low_floor_sweep.py plus the plot
  scripts need no imagery at all.

* "Outputs go to analysis_out/, git-ignored" — no longer true, and the exception
  is the point: op_cache/*.json and op/*.csv|json are committed so the
  operating-point numbers re-derive on CPU from a clean checkout. Spells out
  what stays ignored (the 181 MB of gallery crops) and where the irreplaceable
  part of those galleries lives (the committed A/B tags).

Adds the parity-gate explanation too, since "bend fails to reproduce bit-exactly
and that is expected" is exactly the kind of thing a future reader would
otherwise file as a bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
benchmark/README.md had a five-row table for adding a city that stops at "add
the split to the HF dataset". That was accurate when the benchmark was two
splits and one scorer. It is now badly incomplete: a new split silently
invalidates the pooled operating-point numbers, the per-tier curves, the #55
correction, both figures and four documents, and nothing tells you which.

docs/adding_a_benchmark_city.md is the end-to-end runbook, with a checklist to
copy into the PR.

Three things it captures that were only in people's heads:

* PHASE 0 — decide what the city is FOR before spending the review. Every
  existing split answers a question the others cannot; a city that duplicates an
  existing answer costs a day of review and buys a table row. Names the two
  highest-value additions now (a second GSV city, to disentangle bend's
  GSV-vs-in-domain confound; and a second rater on budapest, which is a review
  rather than a city).

* PHASE 4 — the step that gets skipped. Extraction, the parity gate, the five
  sweep commands, the #55 tagging at op-threshold 0.25 (matching every existing
  split so A-rates stay comparable), tagcheck, and figure regeneration.

* PHASE 5 — the code touchpoints that fail SILENTLY. US_SPLITS membership is what
  puts a split in the pooled recommendation; a missing tier_of() branch dumps a
  new rig into "unknown"; the figure's SERIES needs a palette colour in slot
  order. None of these error — they quietly omit or mislabel the city.

Also records the traps with the evidence behind them: never review above model
resolution (the error that forced a re-review of richmond and bend, correcting
recall 0.895 -> 0.765 and 0.831 -> 0.758), don't pool GT you don't trust, and
the A/B tags are committed human effort that a careless re-extraction can orphan.

Every code reference was checked against the source rather than written from
memory, including the still-hardcoded build_benchmark_dataset.py, flagged in
place as the known HF-dataset lag.

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

Copy link
Copy Markdown
Member Author

Added a city-onboarding protocol — and everything is checked in

Two things in this push.

1. Everything is committed and pushed

Working tree clean, HEAD == origin, 231 tests green, 10 commits on the branch. Verified rather than assumed:

  • 7 detection caches (analysis_out/op_cache/*.json, 780 KB image-free) — every number re-derives on CPU
  • 9 derived result tables (analysis_out/op/*.csv|json) — so a figure quoted in prose can be checked against its source table
  • 6 incremental_fp_tags.json — the irreplaceable human A/B pass
  • 2 figures, 4 scripts, 1 Slurm launcher, 43 new tests, 3 docs

I also found a gap while checking: scripts/analysis/README.md indexes every analysis script (PR #65 was literally adding a row to it) and this branch had added eleven entry points without touching it. Fixed, plus two intro statements the branch had made wrong — it is no longer "benchmark/{richmond,bend}" and analysis_out/ is no longer entirely git-ignored.

Deliberately still ignored: the 181 MB of gallery crops. Regenerable, and the human tags are committed.

2. docs/adding_a_benchmark_city.md

benchmark/README.md had a five-row table for adding a city that stops at "add the split to the HF dataset." That was right when the benchmark was two splits and one scorer. It is now badly incomplete — a new split silently invalidates the pooled numbers, the per-tier curves, the #55 correction, both figures and four documents.

The protocol covers phases 0–6 with a checklist to paste into the PR. Three parts worth calling out:

Phase 0 — decide what the city is for before spending the review. Every existing split answers a question the others cannot; one that duplicates an existing answer costs a day of review and buys a table row. On that basis the two highest-value additions right now are a second GSV city (to disentangle bend's GSV-vs-in-domain confound) and a second rater on budapest — which is a review, not a city.

Phase 4 — the step that gets skipped. Extraction → the parity gate → the five sweep commands → #55 tagging at op-threshold 0.25 (matching every existing split, or the A-rates aren't comparable) → tagcheck → regenerate figures.

Phase 5 — the code touchpoints that fail silently. US_SPLITS membership is what puts a split into the pooled recommendation; a missing tier_of() branch dumps a new rig into unknown; the figure's SERIES needs a palette colour in slot order. None of these raise — they quietly omit or mislabel the city.

It also records the traps with their evidence: never review above model resolution (the error that forced re-reviewing richmond and bend, correcting recall 0.895 → 0.765 and 0.831 → 0.758), don't pool GT you don't trust, and panos/ is git-ignored and irreplaceable if Mapillary decays.

Every code reference was checked against the source rather than written from memory — including build_benchmark_dataset.py, still hardcoded to bend + richmond, flagged in place as the known HF-dataset lag (#21).

Linked from benchmark/README.md and docs/operating_point.md.

🤖 Generated with Claude Code (claude-opus-5[1m])

jonfroehlich and others added 2 commits July 28, 2026 20:52
Review of docs/adding_a_benchmark_city.md against the actual CLIs and
committed data turned up five errors, applied here:

- Phase 1: both auto-labeler commands were wrong as written. main.py takes
  the geojson positionally (there is no --area flag), and export_benchmark's
  --bundle takes the destination directory as its argument (--out only
  overrides the panos dir); the old line died in argparse.
- Phase 3: the empty-group gate claimed detections on empty panos are FPs
  worth reading — impossible, the stratum is zero-detection by construction
  (verified across all six splits). What appears there is missed marks,
  i.e. recall failures (annapolis: 4 of 25 held 10 of them).
- Phase 1: "125 panos total" — richmond is 124, so bend is not the only
  deviation.
- Phase 2: the abstention range low end is bend at 1.9% (5 of 265), not
  annapolis at 2.2%. benchmark/README.md carried the same error twice:
  annapolis is the lowest of the Mapillary splits, not "of any split", and
  morgantown's "lowest abstention rate of any split" holds only for missed
  marks (21.7%), not detections. Both sentences now state exactly what the
  verdicts support.

All other runbook claims verified against low_floor_sweep.py, the exporter,
gt_gallery.py, score_validation.py, the gitignore, and the committed
verdicts/tags; 231 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gtiDzVGepFkABX16iFDLM
Follow-ups from the same review that produced 3c47739 — not errors, but
places where following the runbook verbatim would stumble:

- The sbatch block now includes mkdir -p logs, which the launcher's own
  usage requires (its #SBATCH output paths are under logs/ and Slurm kills
  the job if the directory is missing).
- The parity paragraph now states the actual gate (>=95% of detections
  within 0.5 R, count delta <=5%) alongside the expectation, so a GSV split
  at 98% within tolerance is read as the pass it is.
- The #55 section now warns that corrected's --op-threshold defaults to
  0.35 (not the recommended 0.30, so the flag is load-bearing) and that the
  multi-point corrected tables in docs/operating_point.md take one run per
  row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gtiDzVGepFkABX16iFDLM
@jonfroehlich
jonfroehlich merged commit 9783338 into main Jul 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant