GeometryEnricher: tier-3 heading-delta fallback (issue #29) #122

Merged
robert merged 1 commit from area/geometry-enricher into main 2026-09-06 12:30:54 +02:00
Owner

Closes part of #29 (see the flagged exception below).

What this does

Implements GeometryEnricher (tier 3, EnrichmentTier.GEOMETRY): the always-available, offline, no-street-names last resort in the enrichment chain (D26/D40). Per the issue's 2026-09-01 update, this is the default cue-sheet path for most new users today (BRouter, tier 1/#27, needs a separate install plus segment files; map matching, tier 2/#28, is online and opt-in per D13) - so this implements the tightened design, not the original 5m-segment spec.

Design

  • Window: heading compared over a ±20 m window (40 m total span) around each already-simplified polyline vertex, reusing RouteGeodesy.bearingDegrees/pointAtDistanceMeters (added for #55) rather than diffing consecutive RDP segments directly.
  • Thresholds: over 25° windowed delta = turn candidate, over 70° = sharp. Sign gives left/right (same convention CueSheetReviewAnalysis already uses).
  • "Must complete within a short distance": this falls out of the window design itself, deliberately with no cross-vertex accumulation. A sharp corner sits entirely inside one 40 m window and reads its full angle; a gradual bend spread over hundreds of meters only ever shows a small slice of its total change in any single window. See the sweeping-bend-vs-sharp-turn.gpx fixture: a ~57° gentle bend (radius 300 m) produces nothing, a ~60° sharp turn (radius 8 m) of near-identical total magnitude produces a cue - the explicit "a naive total-delta accumulator would get this wrong" case.
  • Cluster merging: consecutive same-side candidates within 30 m of each other merge into one cue (the sharpest point in the run), so RDP's own oversampling of one physical curve/roundabout/hairpin doesn't become a burst of cues. A side change always breaks a cluster even within 30 m - that's a real direction reversal (a second switchback), not resampling of one feature. Full rationale for the 30 m figure (derived from RDP's own sagitta/tolerance math at typical curve radii) is in the file's KDoc.
  • Cues carry tier = EnrichmentTier.GEOMETRY (the field the wire layer reads into NAV_CUE_CONFIDENCE = 0, FR-N17) and streetName = null always.
  • Tier 3 has no next tier to fall back to: "no turn found anywhere" is a real Success(emptyList()), not a decline signal the way tier 0's empty-GPX-cue-text case is.

Fixtures (companion/core/src/test/resources/gpx/)

All synthetic-but-exact closed-form geometry (straight segments + circular arcs of known radius/angle), not scraped tracks - see GeometryEnricherTest's own KDoc for why that's the right call specifically for "hand-checked expected cue counts" (independently re-derived via a second haversine/bearing reimplementation outside the Kotlin code, not by trusting the implementation to grade its own homework):

Fixture Construction Hand-checked expectation
winding-descent.gpx 4 alternating r=8m/140° hairpins, 200m apart exactly 4 cues: SHARP_RIGHT, SHARP_LEFT, SHARP_RIGHT, SHARP_LEFT
roundabout.gpx single r=15m/200° arc exactly 1 cue (SHARP_RIGHT), not one per RDP-kept vertex around the curve
sweeping-bend-vs-sharp-turn.gpx r=300m/57.3° gentle bend, then r=8m/60° sharp turn 0 cues from the gentle bend, exactly 1 (RIGHT, not SHARP) from the sharp turn
straight-route.gpx pure straight line 0 cues (positive result)

Also included

  • Wired GeometryEnricher into RouteStore's tier3 slot and bumped CURRENT_ENRICHER_VERSION to 2, per that constant's own KDoc ("wiring a new tier in here is one of the two events that constant must be bumped for") - otherwise the new tier would exist but never actually run for a real user.

Flagged, not faked

The issue's "output compared against a BRouter cue sheet on a known route and the differences documented" criterion needs BRouterEnricher (#27), which does not exist in this codebase yet (confirmed by search - only mentioned in comments/KDoc). Rather than build a stub/fake BRouter comparison, I've added a dependency edge (#29 depends on #27) via the API. That comparison should happen once #27 lands.

Testing

Real ./gradlew :companion:core:test --rerun-tasks run: 353 tests, 0 failures, 0 errors (44 test classes, including the new 6-case GeometryEnricherTest). Also ran :companion:route:compileDebugKotlin and :companion:route:test to confirm the RouteStore wiring compiles and the module's existing (currently empty) unit test task still passes.

https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt

Closes part of #29 (see the flagged exception below). ## What this does Implements `GeometryEnricher` (tier 3, `EnrichmentTier.GEOMETRY`): the always-available, offline, no-street-names last resort in the enrichment chain (D26/D40). Per the issue's 2026-09-01 update, this is the *default* cue-sheet path for most new users today (BRouter, tier 1/#27, needs a separate install plus segment files; map matching, tier 2/#28, is online and opt-in per D13) - so this implements the tightened design, not the original 5m-segment spec. ## Design - **Window**: heading compared over a ±20 m window (40 m total span) around each already-simplified polyline vertex, reusing `RouteGeodesy.bearingDegrees`/`pointAtDistanceMeters` (added for #55) rather than diffing consecutive RDP segments directly. - **Thresholds**: over 25° windowed delta = turn candidate, over 70° = sharp. Sign gives left/right (same convention `CueSheetReviewAnalysis` already uses). - **"Must complete within a short distance"**: this falls out of the window design itself, deliberately with no cross-vertex accumulation. A sharp corner sits entirely inside one 40 m window and reads its full angle; a gradual bend spread over hundreds of meters only ever shows a small slice of its total change in any single window. See the `sweeping-bend-vs-sharp-turn.gpx` fixture: a ~57° gentle bend (radius 300 m) produces nothing, a ~60° sharp turn (radius 8 m) of near-identical *total* magnitude produces a cue - the explicit "a naive total-delta accumulator would get this wrong" case. - **Cluster merging**: consecutive same-side candidates within 30 m of each other merge into one cue (the sharpest point in the run), so RDP's own oversampling of one physical curve/roundabout/hairpin doesn't become a burst of cues. A side change always breaks a cluster even within 30 m - that's a real direction reversal (a second switchback), not resampling of one feature. Full rationale for the 30 m figure (derived from RDP's own sagitta/tolerance math at typical curve radii) is in the file's KDoc. - Cues carry `tier = EnrichmentTier.GEOMETRY` (the field the wire layer reads into `NAV_CUE_CONFIDENCE = 0`, FR-N17) and `streetName = null` always. - Tier 3 has no next tier to fall back to: "no turn found anywhere" is a real `Success(emptyList())`, not a decline signal the way tier 0's empty-GPX-cue-text case is. ## Fixtures (`companion/core/src/test/resources/gpx/`) All synthetic-but-exact closed-form geometry (straight segments + circular arcs of known radius/angle), not scraped tracks - see `GeometryEnricherTest`'s own KDoc for why that's the right call specifically for "hand-checked expected cue counts" (independently re-derived via a second haversine/bearing reimplementation outside the Kotlin code, not by trusting the implementation to grade its own homework): | Fixture | Construction | Hand-checked expectation | |---|---|---| | `winding-descent.gpx` | 4 alternating r=8m/140° hairpins, 200m apart | exactly 4 cues: SHARP_RIGHT, SHARP_LEFT, SHARP_RIGHT, SHARP_LEFT | | `roundabout.gpx` | single r=15m/200° arc | exactly 1 cue (SHARP_RIGHT), not one per RDP-kept vertex around the curve | | `sweeping-bend-vs-sharp-turn.gpx` | r=300m/57.3° gentle bend, then r=8m/60° sharp turn | 0 cues from the gentle bend, exactly 1 (RIGHT, not SHARP) from the sharp turn | | `straight-route.gpx` | pure straight line | 0 cues (positive result) | ## Also included - Wired `GeometryEnricher` into `RouteStore`'s tier3 slot and bumped `CURRENT_ENRICHER_VERSION` to 2, per that constant's own KDoc ("wiring a new tier in here is one of the two events that constant must be bumped for") - otherwise the new tier would exist but never actually run for a real user. ## Flagged, not faked The issue's "output compared against a BRouter cue sheet on a known route and the differences documented" criterion needs `BRouterEnricher` (#27), which does not exist in this codebase yet (confirmed by search - only mentioned in comments/KDoc). Rather than build a stub/fake BRouter comparison, I've added a dependency edge (#29 depends on #27) via the API. That comparison should happen once #27 lands. ## Testing Real `./gradlew :companion:core:test --rerun-tasks` run: 353 tests, 0 failures, 0 errors (44 test classes, including the new 6-case `GeometryEnricherTest`). Also ran `:companion:route:compileDebugKotlin` and `:companion:route:test` to confirm the `RouteStore` wiring compiles and the module's existing (currently empty) unit test task still passes. https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
Add GeometryEnricher: tier-3 heading-delta fallback (issue #29)
Some checks failed
dev-artifact / build-pbw (push) Failing after 0s
dev-artifact / build-apk (push) Failing after 0s
dev-artifact / publish (push) Has been skipped
fast-lane / host-c-tests (push) Failing after 0s
fast-lane / jvm-tests (push) Failing after 0s
fast-lane / pebble-build (push) Failing after 0s
fast-lane / lint-and-secrets (push) Failing after 0s
fast-lane / meta-declares-required-jobs (push) Failing after 0s
fast-lane / host-c-tests (pull_request) Failing after 0s
fast-lane / jvm-tests (pull_request) Failing after 0s
fast-lane / pebble-build (pull_request) Failing after 0s
fast-lane / lint-and-secrets (pull_request) Failing after 0s
fast-lane / meta-declares-required-jobs (pull_request) Failing after 0s
821b04cff1
Implements the always-available, offline, no-street-names last resort in the
enrichment chain, per D26/D40/D27. Since BRouter (tier 1, #27) needs a
separate app install plus segment files, and map matching (tier 2, #28) is
online/opt-in (D13), this is the *default* cue-sheet path for most new users
until #64's onboarding lands - so it implements the issue's 2026-09-01
update ("this is the default path, so it has to be less crude"), not just
the original spec.

Design (see GeometryEnricher.kt's own KDoc for full rationale):
- Heading is compared over a +/-20 m window (40 m total span) around each
  polyline vertex, reusing RouteGeodesy's existing bearingDegrees/
  pointAtDistanceMeters (added for #55) rather than diffing consecutive
  RDP-simplified segments directly - the original 5 m-segment approach fires
  on every curve, roundabout and switchback.
- Over 25 degrees of windowed delta is a turn candidate; over 70 degrees is
  sharp. Sign gives left/right (matching CueSheetReviewAnalysis's existing
  convention).
- The window is itself what satisfies "a turn must complete within a short
  distance": nothing in this class accumulates heading change across
  vertices, so a sharp corner (fully inside one 40 m window) reads its full
  angle, while a gradual bend spread over hundreds of meters never shows
  more than a small slice of its own total change in any single window.
- Consecutive same-side candidates within 30 m of each other are merged into
  one cue (the sharpest point in the cluster), so RDP's own oversampling of
  one physical curve/roundabout/hairpin doesn't become a burst of cues. A
  side change always breaks a cluster, even within 30 m, since that's a real
  direction reversal (an S-curve or a genuine second switchback), not
  resampling of one feature.
- Cues carry EnrichmentTier.GEOMETRY (tier's own KDoc: this is the field the
  wire layer reads into NAV_CUE_CONFIDENCE = 0, FR-N17) and streetName =
  null always - no map data, nothing to name a road from.
- Unlike tier 0, this backend has no next tier to fall back to: "no turn
  found" is a real Success(emptyList()), not a decline signal.

Fixtures (companion/core/src/test/resources/gpx/): winding-descent.gpx (four
alternating r=8m/140deg hairpins), roundabout.gpx (one r=15m/200deg arc),
sweeping-bend-vs-sharp-turn.gpx (a gentle r=300m/57deg bend that must NOT
cue, next to a sharp r=8m/60deg turn of near-identical total magnitude that
MUST - the explicit "naive total-delta accumulator would get this wrong"
regression case), and straight-route.gpx. All are synthetic-but-exact
closed-form geometry (straights + circular arcs), not scraped tracks -
GeometryEnricherTest's own KDoc explains why that is the right call for
"hand-checked expected cue counts" specifically (independently
re-derived via a second haversine/bearing reimplementation, not by trusting
the Kotlin code to grade its own homework).

Also wires GeometryEnricher into RouteStore's tier3 slot and bumps
CURRENT_ENRICHER_VERSION to 2 (CueSheetCache.kt's own KDoc: wiring a
previously-null tier is one of the two events that constant exists to
track), so a route that previously fell through to "no cues" now gets a
real, confidence-0 cue sheet.

Out of scope, flagged rather than faked: the issue's "output compared
against a BRouter cue sheet on a known route" criterion needs BRouterEnricher
(#27), which does not exist in this codebase yet. Added a dependency edge
(#29 depends on #27) instead of building a stub/fake comparison.

Real test run: ./gradlew :companion:core:test --rerun-tasks (353 tests,
0 failures) and :companion:route:compileDebugKotlin to confirm the RouteStore
wiring compiles.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
robert merged commit 1cf1f47dfd into main 2026-09-06 12:30:54 +02:00
Sign in to join this conversation.
No description provided.