Skip to content

The street-level review instrument (#103): production-path sheet, probe, reduction, and a pre-registered Denver pilot - #105

Open
jonfroehlich wants to merge 6 commits into
feat/location-precision-96from
feat/street-review-103
Open

The street-level review instrument (#103): production-path sheet, probe, reduction, and a pre-registered Denver pilot#105
jonfroehlich wants to merge 6 commits into
feat/location-precision-96from
feat/street-review-103

Conversation

@jonfroehlich

@jonfroehlich jonfroehlich commented Aug 3, 2026

Copy link
Copy Markdown
Member

Builds the instrument #103 designed: review location precision against the street-level imagery Stage 1 actually consumes — the panorama, the exact ±18.37° crop, and the government point's projected bearing — with the reviewer's verdict recorded as a signed angular offset in §5j's residual convention, so human review of candidate cities cross-validates against the automatic corpus null. Stacked on #97's branch (retarget to main after #97 merges). Design brief and decisions: #103; the §5o pre-registration in this PR fixes the Denver pass/fail criteria before the review happens.

What's here

  • rampnet/gsv.py — the production GSV path (fetch_panorama, both projections, heading_to_azimuth) lifted verbatim out of download_dataset.py, which was unimportable (its inference_isolator import loads the round-2 checkpoint at module scope). download_dataset.py now imports from here — one definition each, the KeypointModel consolidation precedent. Plus new pure helpers: the click↔angle maps, pinned against crop_half_angle_deg().
  • Two production fixes found by running it, both now shared by Stage 1 and the instrument:
    • the tile endpoint now 403s python-requests' default User-Agent — the paper-era fetch_panorama fetches nothing today; any explicit UA (ours: RampNet-sourcing/1.0) works. Two lines, documented at the constant.
    • GetMetadata 502s under per-pano metadata bursts (15/59 sites on the first probe pass, all recoverable) — cached_search retries transient statuses with backoff; schema drift still raises immediately.
  • street_review_sheet.py — the sheet: full 90° production render with the strip edges drawn where they truly are (asymmetric, −18.458°/+18.368°), the bearing crosshair, a nonlinear degree ruler, and neighbour records' bearings (production 35 m inclusion) always visible to resolve which-ramp ambiguity. One pano per record by a recorded rule (nearest 4–30 m, capture ≥ record date, tie-break newest); mandatory reason tags on unjudgeable verdicts so the street instrument's selection bias is measured, not assumed. §5h made structural: pano absences are readable JSON markers (--refetch-absent), failed searches never cache, every input record leaves a terminal status. §5l's two-path bug class prevented by construction: chips and templates extend one base dict, and the JS export iterates the same Python field list.
  • probe_panos_at_sites.py — the sheet's dry run at exactly its sites, running the same imported pick rule.
  • street_review_summary.py — the reduction: frac-inside-strip against the true edges (the gate quantity), the distribution via stage1_bearing_residual.summarize() verbatim (§5j-column-comparable), Wilson rates + reason breakdown, a sign-flip null (never a test against zero), per-stratum rows, and the paired aerial calibration: each aerial click_px vector projected through the chosen pano's geometry into a predicted residual (radial error predicts ~0°, per §5g).
  • docs §5o — instrument description + pre-registered Denver criteria, amended once with probe-measured numbers, all before any review.
  • 74 new tests (642 total green), including a Node harness that pins the JS click-to-angle map against the Python definition to 1e-6, drives the page's real click/keydown/button handlers (mutation-checked: breaking any of four clearing rules is caught), and reads the real export payload's key set.
  • Code review applied in cbb3191 — six substantive findings and five minors, the load-bearing one being that the gate quantity censored its own denominator: ramp_outside_view records are certainly outside the strip but classified unjudgeable, so §5o now pre-registers and reports a bound that counts them as failures. See the review comment for the full list. sheet_build is unchanged at 5035ec33, so the committed Denver sheet stays valid.

The Denver pilot, as built

Probe: 58/59 sites pick a pano (median range 5.75 m, captures 2016–2026 median ~2021; the failure is a genuine GSV coverage gap, 68791, recorded as its own status). Build: 58/59 rendered in one pass, 19 MB sheet, build 5035ec33; the unfilled verdicts template is committed as the sheet's provenance (per-record pano choice, range, bearing, status). Export→summary round trip smoke-tested against the real sheet and the real aerial pairing.

What is deliberately NOT here

The review itself (Jon's hour), the resulting verdicts and §-write-up, the Seattle sheet, and the alternate-pano pass over the unjudgeable subset. The aerial sheet is not retired — this instrument yields no metric number (#103's stated price). The sheet HTML stays untracked (embedded Google imagery), same policy as the aerial sheets.

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

jonfroehlich and others added 5 commits August 3, 2026 14:51
The street-level review instrument must render exactly the crop Stage 1
cuts, and the production functions were unimportable: download_dataset.py
imports inference_isolator, which loads the round-2 checkpoint from a
relative path at module import time. Move fetch_panorama, both projections,
and heading_to_azimuth verbatim into rampnet/gsv.py -- the consolidation
CLAUDE.md already endorses (the KeypointModel precedent) -- and have
download_dataset.py import them, so each still has exactly one definition.

Only edits in the move are import wiring: cv2/requests/torch become
function-local because requirements-dev.txt deliberately excludes
cv2/requests and the test suite imports the module for its pure helpers.

New pure helpers for #103: perspective_col_to_azimuth_deg and its inverse,
the click-to-angle map. tests/test_gsv.py pins them against
crop_half_angle_deg() (one definition of the crop geometry), pins the
asymmetric strip edges (-18.4577/+18.3678), and drives a synthetic pano
through the real renderer to pin the section-5j sign convention end to end
(positive = clockwise = right of centre). 592 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…duction

Three scripts, assembling the production pieces #103 inventoried:

street_review_sheet.py -- renders, per record, the exact production view
(rampnet.gsv fetch + projection, 90 deg at the record bearing, pitch -30)
with the crop strip edges drawn where they truly are (asymmetric,
-18.458/+18.368, from the one crop definition), the government bearing as
the crosshair, a nonlinear degree ruler, and other records within the
production 35 m inclusion radius as always-visible bearing markers. The
verdict is a click -> signed ANGULAR offset in the section-5j residual
convention. One pano per record by a recorded rule (nearest in 4-30 m,
capture >= record date, tie-break newest); unjudgeable verdicts carry a
mandatory reason tag so the street instrument's selection bias is measured,
not assumed. Sites come from a built aerial sheet (--sites-from-verdicts,
the Denver pairing) or fresh sampling via the aerial samplers unchanged.

The section-5h lessons are structural, not remembered: pano absences are
readable JSON markers (never zero-byte) with --refetch-absent; a failed
search is never cached as a result; every input record leaves a terminal
status in the manifest. The section-5l two-path bug class is prevented by
construction: chips and verdict templates extend ONE base dict, and the JS
export copies provenance by iterating the same Python field list.

probe_panos_at_sites.py -- the sheet's dry run at exactly its sites (the
aerial sheet's records), running the same imported pick rule, warming the
same search cache, and reporting pick rate / date coverage / per-site
failures with reasons.

street_review_summary.py -- the reduction: frac-inside-strip against the
true asymmetric edges (the gate quantity), the angular distribution through
stage1_bearing_residual.summarize() verbatim so rows read against the
section-5j corpus null, phantom/unjudgeable Wilson rates with the reason
breakdown, a sign-flip null for systematic shift (never a test against
zero), per-stratum rows, and the paired aerial calibration: each aerial
click vector projected through the chosen pano geometry into a predicted
residual (radial error predicts ~0, per section 5g).

40 new tests, including a Node harness that pins the JS click-to-angle map
against the Python definition to 1e-6 and reads the real export payload.
The new template drops the {{ }} escaping layer entirely -- plain __TOKEN__
substitution -- removing the blank-page hazard class rather than testing
for it. 632 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Written BEFORE the review, on purpose: the pass/fail conditions for the
street-level instrument on the one city whose answer is trusted (at most 1
of ~52 measured records outside the strip; |median| at the 1-3 deg floor;
sign-flip p >= 0.05; phantom rate Wilson-compatible with the aerial 5.5%,
ideally the same three records; reasons complete). If Denver fails, the
instrument is wrong, not Denver. Also records the design facts a reader of
the verdicts needs (sign convention, asymmetric edges, pano-pick rule,
mandatory unjudgeable reasons) and what the instrument still cannot do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
)

The probe ran before the build, as section 5o requires, and it earned its
keep twice. First, coverage: 58 of 59 aerial sites pick a panorama under
the rule (median chosen range 5.75 m, captures 2016-2026 median ~2021);
the one failure, 68791, is a genuine GSV coverage gap recorded as its own
status. Second, it reproduced the section-5h failure class on the new
endpoint: 15 of 59 sites failed with HTTP 502 from GetMetadata -- rate
limiting on pano-dense corners, all recoverable -- so cached_search now
retries transient statuses with backoff before believing them (cheap:
get_date_of_panorama is lru-cached, so a retry only re-POSTs what the last
attempt missed), while schema drift still raises immediately. Tested both
ways.

Section 5o amended BEFORE any review: the pick rule samples the near field,
so criterion 1's expectation is ~0.5 outside-strip records (5x the naive
pooled figure), and the capture-vintage point -- street judges 2021+
reality where aerial judged ~2016 -- is stated next to the criteria, since
a ramp rebuilt in between can legitimately disagree between instruments.

633 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eet (#103)

The Denver build's first two panos failed, and the reason turned out to be
bigger than the build: the streetviewpixels tile endpoint now returns HTTP
403 PERMISSION_DENIED to python-requests' default User-Agent, so the
paper-era fetch_panorama -- verbatim -- fetches nothing at all today. Any
explicit UA, including our honest RampNet-sourcing/1.0, gets the JPEG.
The two-line fix lands in rampnet/gsv.py, where Stage 1 and the review
instrument now share it; it is the one deliberate behavioural change to the
otherwise-verbatim production code, documented at the constant.

With that: 58 of 59 sites rendered in one pass (the sole drop is 68791,
no_panos -- the genuine GSV coverage gap the probe predicted), 19 MB sheet,
build 5035ec33. The unfilled verdicts template is committed as the built
sheet's provenance: per-record pano choice, range, bearing, and terminal
status, fixed before any review. The export->summary round trip is smoke-
tested against the real sheet and the real aerial pairing (reviewer "smoke",
not committed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er the gate honestly

Code review of PR #105 found six things worth fixing before Jon's review hour.
The first is the one that could only be fixed BEFORE the review happens.

1. The gate quantity censored its own denominator. `ramp_outside_view` marks a
   ramp visible but beyond the +/-45 deg render -- the largest coordinate error
   the sample can contain, and certainly outside a +/-18.4 deg strip -- yet it
   classified as unjudgeable and never reached `frac_inside_strip`. Criterion 1
   was therefore conditional on judgeability, censored in exactly the direction
   that makes the instrument pass. The summary now reports
   `frac_inside_strip_bound` (every outside-view record counted as a failure)
   and SS5o gates on the bound. Occlusion unjudgeables stay out of both: they are
   missing at an *unknown* offset, which is what the second-vantage pass is for.
   SS5o gains an amendment log, because a pre-registration is only worth
   something if its edits are visible.

2. `phantom_disagreements` conflated "the aerial sheet could not look" with
   "the aerial sheet saw a ramp": an aerial-unjudgeable record has no `no_ramp`
   to compare against. The Denver pilot deliberately renders all 4 aerial
   unjudgeables, so this could have inflated the count by 4 of 58 at exactly
   the point criterion 4 is read. Now both instruments must have judged.

3. A rebuild silently overwrote a reviewed `verdicts.json` -- and the plan
   already includes two rebuilds after the review (`--refetch-absent`, the
   second-vantage pass). The build now refuses unless `--force`, and refuses
   BEFORE spending ~2,000 tile requests.

4. The Node harness re-implemented the page's state machine instead of driving
   it, so a regression in the real click/keydown handlers would have passed.
   It now drives `stage.onclick`, the captured keydown listener, and the seg()
   buttons the page itself wires up. Verified by mutation: breaking any of four
   clearing rules in the page is now caught; before, all four survived.

5. The renderer sign-convention test -- the only thing pinning SS5j's convention
   through the real projection -- was skipped in CI, because requirements-dev
   ships torch but no OpenCV. Added opencv-python-headless.

6. The summary reduced with this code's strip edges rather than the ones the
   sheet recorded, so re-reducing an old verdicts.json could silently change
   the gate. It now reads `manifest['projection']` and falls back, the same way
   `paired_calibration` already read the aerial manifest.

Plus the minors: `--limit` truncated after the provenance strings were built,
so a smoke run wrote a manifest claiming the full sample; the sampling path had
no id-integrity check (a duplicate id silently handed a record another row's
date); a partially reviewed sheet now says so loudly instead of quietly
diluting every denominator; and the pano cache's cold-vs-warm JPEG generation
is documented, since `sheet_build` hashes logic and not pixels.

Sheet build is unchanged at 5035ec33, so the committed Denver sheet and its
verdicts template stay valid. 74 tests across the five files (was 65).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jonfroehlich

Copy link
Copy Markdown
Member Author

Code review — and the fixes, in cbb3191

Reviewed the whole diff against feat/location-precision-96, ran the new tests and the full suite, and reproduced two of the findings before believing them. All six substantive findings and all five minors are fixed in cbb3191 on this branch — details under each. Posting the review as found, rather than only the fixes, so the record shows what the instrument looked like before Denver's review hour was spent on it.

Verified up front: the rampnet/gsv.py lift really is verbatim — I diffed it line-for-line against the deleted block, and the only changes are the lazy cv2/requests/torch imports and the User-Agent header. I also grepped download_dataset.py for every name dropped from its import list (as_completed, io, Image, torch, F, requests, HTTPAdapter): zero remaining references, so the trimmed imports are correct. Full suite before the fixes: 633 passed / 1 skipped, matching the PR body.


1. The gate quantity censored its own denominator — and §5o didn't say so

angular_block() computed frac_inside_strip over classify(r) == "measured" only. But a record whose ramp is visible beyond the ±45° render — the largest coordinate error the sample can contain — is tagged ramp_outside_view, classifies as unjudgeable, and never reached the denominator. Criterion 1 was therefore conditional on judgeability, and the censoring runs in exactly the direction that makes the instrument pass.

The code already knew this: the UNREADABLE_REASONS comment calls ramp_outside_view "a coordinate error too large for this instrument to measure". The pre-registration just didn't inherit it. Post-hoc this is unfixable, which is why it led.

Fixed. The summary now reports frac_inside_strip_bound — every ramp_outside_view record counted as a failure — with its own Wilson interval, and §5o gates on the bound. Occlusion unjudgeables (van, pole, sun, quality, too-far) stay out of both denominators: they are missing at an unknown offset, and assuming the worst of them would be as wrong as assuming the best. §5o also gains an amendment log, since a pre-registration is only worth something if its edits are visible; both amendments predate any recorded verdict.

2. phantom_disagreements conflated "couldn't look" with "saw a ramp"

Reproduced before fixing:

aerial: {'id':'X','unreadable':True}     street: {'id':'X','no_ramp':True}
→ aerial_only_unjudgeable: {'n':1,'ids':['X']}
→ phantom_disagreements : {'n':1,'ids':['X']}     ← should not fire

a.get("no_ramp") is falsy for an unreadable aerial record, so any street phantom over an aerial unjudgeable was double-counted. This pilot deliberately renders all 4 aerial unjudgeables, and criterion 4 is about phantom-rate agreement — so it could have inflated the count by 4 of 58 at exactly the point the pre-registration gets read.

Fixed. Both instruments must have judged. The always-true trailing clause went with it, and there's a regression test.

3. A rebuild silently overwrote a reviewed verdicts.json

main() wrote the blank template to review_<city>-gsv/verdicts.json unconditionally — the same tracked path the reviewer exports over. This PR plans two rebuilds after the review: --refetch-absent for 68791, and the second-vantage pass over the unjudgeable subset. Either destroys the review hour unless it happened to be committed first. (Inherited from inventory_review_sheet.py:1322, so not a regression — but the street workflow makes the rerun expected rather than hypothetical.)

Fixed. The build refuses when the existing file carries any human verdict unless --force, and refuses before spending ~2,000 tile requests rather than after. A 0.0° verdict counts as a verdict — the "click the crosshair for ~0" rubric case would otherwise have stayed silently overwritable. A corrupt JSON blob counts as 0 on purpose: refusing to build over one would be a worse failure than overwriting it.

4. The Node harness re-implemented the state machine instead of driving it

The export assertions drove the real handler (document.getElementById("export").onclick()) — exactly right. But the click and terminal-state assertions copied the page's clearing logic into the harness ("Simulate the stage handler's effect directly"). Drop v.no_ramp = false from the real stage.onclick and every one of those assertions still passed. That's the two-path hazard this PR is otherwise proud of designing out.

Fixed. The harness now drives stage.onclick, the captured keydown listener, and the seg() buttons the page itself wires up (the stub memoises querySelectorAll("button") so b.onclick = … lands on objects the test can press). Confirmed by mutation rather than assertion count — four clearing rules broken one at a time in the page:

CAUGHT    click no longer clears no_ramp
CAUGHT    'u' no longer clears the disowned click
CAUGHT    seg toggle-off no longer clears the reason
CAUGHT    note field no longer guarded against shortcuts
SURVIVING MUTANTS: none

All four survived the old harness.

5. The renderer sign-convention test was skipped in CI

test_gsv.py said "a convention error anywhere in the #103 overlay stack cannot survive CI" — but pytest.importorskip("cv2") guards both renderer tests and requirements-dev.txt shipped torch and no OpenCV. In CI those 5 parametrized cases skipped; the claim was only true on a machine with the full env. That test is the only thing pinning the overlay convention against the real projection, which is what made it worth the dependency.

Fixed. opencv-python-headless added to requirements-dev.txt, with the reason at the line. equirectangular_to_perspective needs cv2 only for Rodrigues, and torch was already a dev dep.

6. The summary reduced with the code's constants, not the manifest's

STRIP_LEFT_DEG/STRIP_RIGHT_DEG were imported from the sheet module, so re-reducing an old verdicts.json with newer code would silently change the gate quantity — while paired_calibration set the right precedent by reading metres_per_pixel/span_px out of the aerial manifest.

Fixed. strip_edges(manifest) prefers manifest['projection'] and falls back to the constants, and the summary records which it used, so a number can be read without knowing which version of the script produced it.


Minors — all fixed

  • --limit truncated after the provenance was built, so a smoke run wrote a manifest and page header claiming the full 58. Now truncates first, and records limit in sites_source.
  • No id-integrity check on the sampling path. Sites fell back to str(i) when --id-field was missing → KeyError; duplicate ids silently took another row's date. The --sites-from-verdicts path had a good explicit check; the sampling path now has the same floor, with a message that names the field.
  • A partially reviewed sheet reduced silently. incomplete_review is now set and render() prints !! REVIEW INCOMPLETE — the same treatment convention_check already got.
  • Cold vs warm cache differ by one JPEG generation (raw assembly on first fetch, q95 round trip after). Immaterial at review resolution, but sheet_build hashes template + rubric only, so it's now documented rather than implied.
  • Count drift: §5o said "40 tests", the PR body says 41. Actual is 74 collected across the five files; §5o corrected.

sheet_build is unchanged at 5035ec33, so the committed Denver sheet and its verdicts template stay valid — no rebuild needed. Full suite after the fixes: 642 passed / 1 skipped (the skip is pyproj not installed, pre-existing and local-only).


Things this PR gets right, specifically

  • test_render_puts_a_feature_at_its_predicted_column pinning the §5j sign through the real projection on a synthetic pano is the right test to have written — it kills the whole class of 90°-off and sign-flip errors. That's why finding 5 was worth a dependency rather than a softened docstring.
  • _stripe_centre_column using an intensity centroid rather than argmax, with the reason in the docstring, is the kind of detail that would otherwise have surfaced as a phantom leftward bias.
  • The absence-marker trio — never cache a failure, JSON markers carrying reason and attempt count, --refetch-absent — is a genuinely structural fix for §5h rather than a test for it.
  • Building chips and the verdict template from one make_base_record and exporting via META.shared_fields does collapse §5l's third path, and test_base_record_keys_are_exactly_the_shared_fields locks it.
  • _TRANSIENT_MARKERS being measured (15/59 HTTP 502s on the first probe pass) rather than guessed, with schema drift still raising immediately, is the right split.

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

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