Honest pins and paths: geocode precision, type-gated paths, impossible-speed stops (#236) #274

Merged
logan merged 2 commits from feat/236-honest-pins into main 2026-10-05 03:33:21 -04:00
Owner

Make the map honest about how exact a pin or path is.

Backend (display-only)

  • intelligence._geocode_location returns {lat, lng, location_type}. The coords dict goes onto the call doc unchanged, and through incident_correlator._resolve_location_pair (not edited) onto the incident pin.
  • No correlation decision changes. Every correlator and LLM read of location_coords indexes ["lat"]/["lng"] directly, and the LLM prompt only sees the location string. Checked callers: place_verifier._geocode_in_anchor (truthiness only), upload.py, recorrelation_sweep, replay. No coords-dict equality comparisons exist in app/.
  • Tests: test_geocode_named_region.py assertions updated. New test_geocode_location_type.py checks that each accepted type is returned, that APPROXIMATE, empty and unknown types still return None, and that the type survives onto the incident pin.

Frontend

  • types.ts: GeoPoint adds an optional location_type. IncidentRecord adds location_coords_source.
  • liveWall.ts, pure helpers shared by the map and the spine:
    • isMovementIncident is police/accident with a pursuit tag. The tag list mirrors _PURSUIT_TAGS.
    • pathStops produces the stops. A stop is ghosted when reaching it means faster than 8 km/min, past 0.5 km; this mirrors the backend speed check. A bad first stop is detected when stops 1 and 2 agree with each other but not with it.
    • A segment is dashed when the gap is over 10 min or the talkgroup changes.
    • mayBeMerged is set when the kept path is over 20 km.
    • incidentPoints now drops ghosted stops, so camera matching stops chasing a bad geocode.
  • MapView.tsx:
    • Precision: rooftop pins are unchanged. RANGE_INTERPOLATED gets a small ring at the tip. GEOMETRIC_CENTER gets a dashed ring at the tip plus a 250 m dashed ground circle whose tooltip is the spoken location (permanent when the incident is selected). Pins with no location_coords_source are ghosted with a dashed outline. The popup states the precision.
    • Paths: only movement incidents get numbered paths. Other incidents get small unnumbered call dots. Ghosted stops get a faint ? marker and a dotted spur. Paths over 20 km get a "may be merged · N km" badge.
    • New optional prop pulseIncidentIds?: Set<string> adds a pulsing ring to single and clustered pins. Nothing passes it yet; #238 wires it.
    • Legend: a new "Pin precision & paths" section. It is collapsed by default because the 352 px incident-page map would overflow a legend that cannot scroll.
  • WallPanels.tsx: stopOf comes from pathStops, so the spine numbers exactly what the map numbers. Ghosted stops show a dashed ?. The header shows the "may be merged" badge. The #233 spine changes are kept.

Acceptance

  • GEOMETRIC_CENTER vs ROOFTOP: dashed tip ring and ground circle, versus a plain teardrop.
  • Fire with 3 geocoded calls: pathStops gives movement=false and no stop numbers, so no numbered path.
  • #189: not replayed. Replay needs Gemini/OpenAI credit (#196), and per-call coords can't be exported (#197). I built the case from its shape instead: an accident with no pursuit tag, 7 stops 25 min apart across TG 9048/9600, and one bad geocode 1 min after a stop. Results:
    • No numbered path.
    • Every segment dashed.
    • The bad stop ghosted.
    • "may be merged" set.
    • With a pursuit tag added, it numbers 7 stops, still ghosts the bad one, and dashes every segment.
  • Correlation decisions: unchanged (see above).

Not done / caveats

  • Pins geocoded before this change have no location_type. They draw as before, and the popup says "Precision not recorded".
  • 250 m is a nominal "around here" radius. Google gives no error estimate.
  • Ghosting can't catch an outlier that sits after a long gap, because 8 km/min allows ~80 km in 10 min. Those segments are dashed instead.

Results

  • c2-core pytest: 930 passed, 4 warnings
  • frontend tsc --noEmit: exit 0

Closes #236

🤖 Generated with Claude Code

Make the map honest about how exact a pin or path is. **Backend (display-only)** - `intelligence._geocode_location` returns `{lat, lng, location_type}`. The coords dict goes onto the call doc unchanged, and through `incident_correlator._resolve_location_pair` (not edited) onto the incident pin. - No correlation decision changes. Every correlator and LLM read of `location_coords` indexes `["lat"]`/`["lng"]` directly, and the LLM prompt only sees the location *string*. Checked callers: `place_verifier._geocode_in_anchor` (truthiness only), `upload.py`, `recorrelation_sweep`, `replay`. No coords-dict equality comparisons exist in `app/`. - Tests: `test_geocode_named_region.py` assertions updated. New `test_geocode_location_type.py` checks that each accepted type is returned, that APPROXIMATE, empty and unknown types still return None, and that the type survives onto the incident pin. **Frontend** - `types.ts`: `GeoPoint` adds an optional `location_type`. `IncidentRecord` adds `location_coords_source`. - `liveWall.ts`, pure helpers shared by the map and the spine: - `isMovementIncident` is police/accident with a pursuit tag. The tag list mirrors `_PURSUIT_TAGS`. - `pathStops` produces the stops. A stop is ghosted when reaching it means faster than 8 km/min, past 0.5 km; this mirrors the backend speed check. A bad *first* stop is detected when stops 1 and 2 agree with each other but not with it. - A segment is dashed when the gap is over 10 min or the talkgroup changes. - `mayBeMerged` is set when the kept path is over 20 km. - `incidentPoints` now drops ghosted stops, so camera matching stops chasing a bad geocode. - `MapView.tsx`: - Precision: rooftop pins are unchanged. RANGE_INTERPOLATED gets a small ring at the tip. GEOMETRIC_CENTER gets a dashed ring at the tip plus a 250 m dashed ground circle whose tooltip is the spoken location (permanent when the incident is selected). Pins with no `location_coords_source` are ghosted with a dashed outline. The popup states the precision. - Paths: only movement incidents get numbered paths. Other incidents get small unnumbered call dots. Ghosted stops get a faint `?` marker and a dotted spur. Paths over 20 km get a "may be merged · N km" badge. - New optional prop `pulseIncidentIds?: Set<string>` adds a pulsing ring to single and clustered pins. Nothing passes it yet; #238 wires it. - Legend: a new "Pin precision & paths" section. It is collapsed by default because the 352 px incident-page map would overflow a legend that cannot scroll. - `WallPanels.tsx`: `stopOf` comes from `pathStops`, so the spine numbers exactly what the map numbers. Ghosted stops show a dashed `?`. The header shows the "may be merged" badge. The #233 spine changes are kept. **Acceptance** - GEOMETRIC_CENTER vs ROOFTOP: dashed tip ring and ground circle, versus a plain teardrop. - Fire with 3 geocoded calls: pathStops gives `movement=false` and no stop numbers, so no numbered path. - #189: **not replayed**. Replay needs Gemini/OpenAI credit (#196), and per-call coords can't be exported (#197). I built the case from its shape instead: an accident with no pursuit tag, 7 stops 25 min apart across TG 9048/9600, and one bad geocode 1 min after a stop. Results: - No numbered path. - Every segment dashed. - The bad stop ghosted. - "may be merged" set. - With a pursuit tag added, it numbers 7 stops, still ghosts the bad one, and dashes every segment. - Correlation decisions: unchanged (see above). **Not done / caveats** - Pins geocoded before this change have no `location_type`. They draw as before, and the popup says "Precision not recorded". - 250 m is a nominal "around here" radius. Google gives no error estimate. - Ghosting can't catch an outlier that sits after a long gap, because 8 km/min allows ~80 km in 10 min. Those segments are dashed instead. **Results** - c2-core pytest: `930 passed, 4 warnings` - frontend `tsc --noEmit`: exit 0 Closes #236 🤖 Generated with [Claude Code](https://claude.com/claude-code)
logan added 1 commit 2026-10-05 03:28:42 -04:00
Backend: _geocode_location now returns {lat, lng, location_type}, so the
precision rides onto the call doc and, unchanged via _resolve_location_pair,
onto the incident pin. Display-only: every correlator read indexes lat/lng.

Frontend: pins styled by precision (rooftop / interpolated ring /
GEOMETRIC_CENTER dashed uncertainty circle labelled with the spoken place /
unverified pre-#23 pins ghosted). liveWall.pathStops() is shared by the map
and the call spine: only police/accident incidents with a pursuit tag draw a
numbered path; others get unnumbered call dots. Segments over >10 min gaps or
talkgroup changes are dashed, stops implying >8 km/min are ghosted off the
path, paths >20 km get a "may be merged" badge. New optional MapView prop
pulseIncidentIds (wired by #238).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
logan added 1 commit 2026-10-05 03:32:57 -04:00
The /incidents/[id] page kept its own stop numbering (every geocoded call,
1..N) and path length, while its MapView now numbers only movement
incidents' non-ghosted stops. A fire with 3 geocoded calls showed stops
1-3 on the spine and unnumbered dots on the map. Use the shared
pathStops()/stopNumbers(), show 'km path' only for movement incidents,
and surface the 'may be merged' flag in the header.

Legend: a plain pin is also what an older (pre-#236) pin with no recorded
precision looks like; say so instead of claiming 'Street address'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
logan merged commit 0d613019e0 into main 2026-10-05 03:33:21 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logan/server-26#274