Skip to content

Add majority-cluster outlier rejection as a third curb-calibration fallback tier - #46

Merged
Pascal-ZeGerman merged 6 commits into
mainfrom
ship/majority-cluster-curb-fit
Sep 13, 2026
Merged

Pascal-ZeGerman merged 6 commits into
mainfrom
ship/majority-cluster-curb-fit

Conversation

@Pascal-ZeGerman

Copy link
Copy Markdown
Owner

Summary

  • Implements spike 009's majority-cluster outlier rejection as a third fallback-retry tier in curb_calibration.py, after the existing 25ft endpoint-trim retry (quick-260907-c5d, merged in Fix availability false-unavailable + add curb calibration endpoint-trim retry #44).
  • Targets the ~74% of spread-gate rejections the endpoint trim can't touch: spread distributed through the block interior (driveway aprons, bus stops, loading zones) rather than concentrated at intersection corners.
  • Mechanism: within each side's pooled curb samples, split at the single widest gap (mirrors the widest-gap-split technique already used in production by gps2asp.resolver.calibration.estimate_center_offset, repurposed from "two real lanes" to "dominant cluster vs. contamination"). If the dominant cluster holds ≥80% of that side's samples, trust it; otherwise leave the side — and the segment — untouched and non-calibrated.
  • MAJORITY_THRESHOLD = 0.8 is a fixed module constant, chosen from spike 009's sweep of 0.6/0.7/0.8 over real data: 0.8 gives 18% flip rate with 92% independent-roadbed corroboration, vs 0.6's 31%/73% — the extra yield at looser thresholds is mostly segments the independent roadbed source doesn't corroborate.
  • Fallback chain is strict: untrimmed → trim-only → trim+majority-cluster. An already-calibrated segment is never recomputed; if all three tiers fail, the untrimmed baseline is returned so stored rejection spreads stay inspectable.
  • scripts/build_index.py wired to the new three-tier wrapper; vendored mirror regenerated via sync_vendored.py.

Real-data validation (not just synthetic fixtures)

Ran a full local citywide rebuild reusing the exact same curb/roadbed data snapshot as the prior endpoint-trim-only build, to isolate this change's effect:

  • +2,209 calibrated segments (65,774 → 67,983) from this tier alone, ~5.6% of the previously-non-calibrated pool.
  • Re-resolved the live GPS coordinates that originally motivated this work: the Vanderbilt Ave block at the center of the original bug report (physicalid 60498) is now calibrated: true (spreads 0.21/0.56ft, was ~17-19ft). The adjacent block (60499) the actual GPS fix lands on remains correctly non-calibrated — genuinely near-even split, safely self-flagged rather than silently miscalibrated.
  • Spot-checked other known wide/divided streets (Brighton Beach Ave, Flatbush Ave, White Plains Rd) — all remain correctly non-calibrated, confirming the technique doesn't clean geometry it shouldn't.

Test plan

  • tests/test_curb_calibration.py — 18 new tests (TestWidestGapMajority, TestMajorityCluster, TestMajorityClusterRetry), 20 pre-existing tests unmodified
  • tests/test_build_index.py — new TestCurbCalibrationMajorityCluster end-to-end offline build regression; existing TestCurbCalibration/TestCurbCalibrationEndpointTrim unmodified
  • Full relevant suite: 107 passed
  • ruff check . / ruff format --check . clean
  • mypy src/gps2asp/ and mypy custom_components/asp_parking/ clean (run separately — combined invocation produces a false-positive module-identity conflict between source and vendored mirror)
  • scripts/sync_vendored.py --dry-run — vendor-guard parity confirmed
  • Real citywide rebuild (local only) + resolver spot-checks against live coordinates (see above)
  • Citywide GitHub Actions rebuild (index-rebuild.yml) + live HA index deployment — deliberately deferred, separate follow-up

🤖 Generated with Claude Code

…o curb derivation

- MAJORITY_THRESHOLD = 0.8 module constant (spike 009: 92% roadbed-survival at
  0.8 vs 73% at 0.6; the extra yield at 0.6 is mostly uncorroborated segments)
- widest_gap_majority() mirrors calibration.estimate_center_offset's widest-gap
  split locally (different contract: dominant cluster + share, not two lanes)
- majority_threshold=None parameter on derive_segment_calibration reduces each
  side independently before the medians/spread gate; None is a true off switch
- derive_segment_calibration_with_fallback_retries() layers tier (c) around the
  existing two-tier wrapper: untrimmed -> 25 ft trim -> trim+majority-at-0.8
- regenerated the vendored mirror via scripts/sync_vendored.py
…ee-tier policy

- mid-block-apron fixture (detached 40 ft apron at x=120..160, 300 ft block,
  50 ft width) asserted to fail untrimmed (spread_n ~15.31), to STILL fail under
  the 25 ft trim alone (~16.59, the trim makes it worse), and to calibrate under
  trim-plus-majority (share 0.854 -> c=1.0, width=34.0)
- divided-geometry fixture (two full-length north curbs, share 0.50) asserted to
  stay rejected, so ambiguous geometry self-flags instead of being cleaned
- TestWidestGapMajority pins the split, the low-cluster tie-break, degenerate
  input, and the harmless uniform-side no-op
- TestMajorityClusterRetry pins all six tier branches, including the 0.8-vs-0.6
  load-bearing check (long apron, share 0.661: refused at 0.8, cleaned at 0.6)
- 18 new tests; the 20 pre-existing ones are byte-unchanged (additive diff only)
…fline index build

- _derive_segment_fields now calls derive_segment_calibration_with_fallback_retries;
  the independent roadbed cross-check block is byte-unchanged, so a
  majority-rescued segment faces the same second gate as a trim-rescued one
- updated all three prose references (step 2 of _derive_segment_fields, the
  inline comment in _build_rtree_and_metadata, the build_index docstring) to name
  the untrimmed -> 25 ft trim -> majority-at-0.8 chain
- TestCurbCalibrationMajorityCluster proves a mid-block-apron segment reaches
  segments.json with calibrated: true, c ~= 1.0, width ~= 34.0, count 1;
  verified it lands non-calibrated (spread_n 15.31) under the old two-tier wrapper
- no download/cache/CLI code touched; no citywide rebuild run (hard requirement 8)
Comment thread tests/test_build_index.py Fixed
Comment thread tests/test_build_index.py Fixed
Simplify pass on the majority-cluster fallback tier: trim doc/comment
duplication between the module docstring, the MAJORITY_THRESHOLD constant,
and the fallback-retry docstring in curb_calibration.py (mirrored copy
included), collapse the same three-tier explanation repeated three times in
build_index.py, and extract a shared _CurbBuildHarness mixin for the
copy-pasted CSCL/download-patching fixtures across the three curb-calibration
test classes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread tests/test_build_index.py Fixed
Comment thread tests/test_build_index.py Fixed
Pascal-ZeGerman and others added 2 commits September 12, 2026 09:41
…test harness

CodeQL flagged the no-arg lambdas in _CurbBuildHarness._patch_downloads
(wrapping self._make_cscl_gdf and set with no added args) as unnecessary —
pass the callables directly instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ruff format --check flagged the shortened monkeypatch.setattr call from the
previous CodeQL fix as needing reformatting (fits on one line now).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Pascal-ZeGerman
Pascal-ZeGerman merged commit d7e60f4 into main Sep 13, 2026
17 checks passed
@Pascal-ZeGerman
Pascal-ZeGerman deleted the ship/majority-cluster-curb-fit branch September 13, 2026 01:22
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.

2 participants