CueSheetEnricher interface, canonical Direction enum, and tier-0 GpxCueEnricher (#26) #79

Merged
robert merged 1 commit from area/cue-sheet-enricher-interface into main 2026-09-04 00:13:27 +02:00
Owner

Closes #26.

Scope note

Read the issue body plus its 2026-09-01 "four tiers, and cue confidence" update before starting — that update materially changes scope from a single-backend interface to a four-tier fallback chain with per-cue tier/confidence, and this PR follows the update, not the original text.

What this builds

  • Direction (core/route/enrich/Direction.kt) — the canonical turn vocabulary, adopted from the komoot BLE Connect spec (github.com/palto42/BLEConnect) per the issue. Every value from the spec's Direction table is represented except the explicitly-reserved/future-extension codes; bleConnectCode is kept only as a traceability breadcrumb (this project never speaks BLE Connect's wire protocol).
  • DirectionVocabulary — the single place a backend's vocabulary is translated into Direction (the issue's "mapping lives in exactly one place" criterion). Ships fromGpxCueText today for tier 0's text (D58/D59: bikerouter.de/BRouter emit "left, 80 m"-shaped text, English and German, never a street name); #27/#28 are expected to add their own fromXxx functions to this same object rather than inventing private tables.
  • CueSheetEnricher (core/route/enrich/CueSheetEnricher.kt) — EnrichmentTier, Cue (polyline index, direction, street name, distance-along-route, plus which tier produced it), CueEnrichmentSource, CueSheetResult, the CueSheetEnricher interface itself, and TieredCueSheetEnricher, which orchestrates tier0..tier3 in order — enforced by the constructor's fixed four slots, not by caller discipline over a List — stopping at the first non-empty result and recording which tier won.
  • GpxCueEnricher (#63, tier 0) — implemented, not stubbed, because it fell out of the interface work with time to spare. Reads <rtept>/<trkpt> name/desc text, classifies it, and locates each cue onto the already-simplified GpxRoute polyline (#24) by forward-only, segment-interpolated projection (RouteGeodesy.locateAlongPolyline) rather than nearest-vertex snapping — RDP simplification can and does drop the exact point a cue was anchored to, and a naive nearest-point search breaks on out-and-back/figure-eight routes. streetName is always null (D58: tier 0 never has one); text the vocabulary doesn't recognise is dropped, never guessed (FR-N17).

Deliberately not built here

BRouterEnricher (#27), MatchingEnricher (#28), GeometryEnricher (#29) — separate issues, separate PRs, no network/AIDL/BRouter dependency introduced by this PR. TieredCueSheetEnricher accepts null for any unwired tier, so the chain is honest about only having tier 0 today rather than carrying fake stub implementations.

Known gap, flagged rather than hidden

GpxCueEnricher only covers D59's plain-text encoding, not the other seven GPX cue encodings D59 catalogued (alignment-keyed, vendor <extensions>, XML-comment-hidden timode=4, icon-id-only). That's real #63 scope. The test fixture (bikerouter-style-cues.gpx) is hand-built to match D58/D59's documented text shape rather than a scraped live export — #38's "real fixtures" principle is a gap #63's own PR should close once a live bikerouter.de sample is available.

Where it lives, and why

companion/core/.../route/enrich/ — inside :companion:core (pure JVM) rather than :companion:route (an Android library needing AGP/SDK), so it's unit-testable in a sandbox with no Android SDK. Package path still satisfies the issue's .../route/enrich/... file location.

Testing

./gradlew :companion:core:test --no-configuration-cache — 27 new tests across 5 spec classes (DirectionVocabularyTest, RouteGeodesyTest, CueSheetEnricherTest, GpxCueEnricherTest, plus the existing suite), all green. --no-configuration-cache is needed in this sandbox because of a headless-JDK/toolchain quirk unrelated to this change; plain ./gradlew :companion:core:test should work in a normal dev environment or CI.

https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt

Closes #26. ## Scope note Read the issue body plus its 2026-09-01 "four tiers, and cue confidence" update before starting — that update materially changes scope from a single-backend interface to a four-tier fallback chain with per-cue tier/confidence, and this PR follows the update, not the original text. ## What this builds - **`Direction`** (`core/route/enrich/Direction.kt`) — the canonical turn vocabulary, adopted from the komoot BLE Connect spec (github.com/palto42/BLEConnect) per the issue. Every value from the spec's Direction table is represented except the explicitly-reserved/future-extension codes; `bleConnectCode` is kept only as a traceability breadcrumb (this project never speaks BLE Connect's wire protocol). - **`DirectionVocabulary`** — the single place a backend's vocabulary is translated into `Direction` (the issue's "mapping lives in exactly one place" criterion). Ships `fromGpxCueText` today for tier 0's text (D58/D59: bikerouter.de/BRouter emit "left, 80 m"-shaped text, English and German, never a street name); #27/#28 are expected to add their own `fromXxx` functions to this same object rather than inventing private tables. - **`CueSheetEnricher`** (`core/route/enrich/CueSheetEnricher.kt`) — `EnrichmentTier`, `Cue` (polyline index, direction, street name, distance-along-route, **plus which tier produced it**), `CueEnrichmentSource`, `CueSheetResult`, the `CueSheetEnricher` interface itself, and `TieredCueSheetEnricher`, which orchestrates tier0..tier3 in order — enforced by the constructor's fixed four slots, not by caller discipline over a `List` — stopping at the first non-empty result and recording which tier won. - **`GpxCueEnricher`** (#63, tier 0) — implemented, not stubbed, because it fell out of the interface work with time to spare. Reads `<rtept>`/`<trkpt>` `name`/`desc` text, classifies it, and locates each cue onto the already-*simplified* `GpxRoute` polyline (#24) by forward-only, segment-interpolated projection (`RouteGeodesy.locateAlongPolyline`) rather than nearest-vertex snapping — RDP simplification can and does drop the exact point a cue was anchored to, and a naive nearest-point search breaks on out-and-back/figure-eight routes. `streetName` is always `null` (D58: tier 0 never has one); text the vocabulary doesn't recognise is dropped, never guessed (FR-N17). ## Deliberately not built here `BRouterEnricher` (#27), `MatchingEnricher` (#28), `GeometryEnricher` (#29) — separate issues, separate PRs, no network/AIDL/BRouter dependency introduced by this PR. `TieredCueSheetEnricher` accepts `null` for any unwired tier, so the chain is honest about only having tier 0 today rather than carrying fake stub implementations. ## Known gap, flagged rather than hidden `GpxCueEnricher` only covers D59's plain-text encoding, not the other seven GPX cue encodings D59 catalogued (alignment-keyed, vendor `<extensions>`, XML-comment-hidden `timode=4`, icon-id-only). That's real #63 scope. The test fixture (`bikerouter-style-cues.gpx`) is hand-built to match D58/D59's documented text shape rather than a scraped live export — #38's "real fixtures" principle is a gap #63's own PR should close once a live bikerouter.de sample is available. ## Where it lives, and why `companion/core/.../route/enrich/` — inside `:companion:core` (pure JVM) rather than `:companion:route` (an Android library needing AGP/SDK), so it's unit-testable in a sandbox with no Android SDK. Package path still satisfies the issue's `.../route/enrich/...` file location. ## Testing `./gradlew :companion:core:test --no-configuration-cache` — 27 new tests across 5 spec classes (`DirectionVocabularyTest`, `RouteGeodesyTest`, `CueSheetEnricherTest`, `GpxCueEnricherTest`, plus the existing suite), all green. `--no-configuration-cache` is needed in this sandbox because of a headless-JDK/toolchain quirk unrelated to this change; plain `./gradlew :companion:core:test` should work in a normal dev environment or CI. https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
Implements #26 per its 2026-09-01 update (four tiers, per-cue tier/confidence), not the
original single-backend scope:

- Direction: the canonical turn vocabulary, adopted from the komoot BLE Connect spec
  (github.com/palto42/BLEConnect) as issue #26 specifies. Carries the source bleConnectCode
  as a traceability breadcrumb only (this project never speaks BLE Connect's wire format).
- DirectionVocabulary: the one place a backend's own vocabulary is translated into Direction
  (issue #26's "mapping lives in exactly one place"). Ships fromGpxCueText for tier 0's D58/
  D59-documented "direction, distance" text; tiers 1/2 (#27/#28) add their own fromXxx here.
- CueSheetEnricher: the four-tier interface (EnrichmentTier, Cue, CueEnrichmentSource,
  CueSheetResult) plus TieredCueSheetEnricher, which tries tier0..tier3 in order (enforced by
  the constructor shape, not caller discipline), stops at the first non-empty result, and
  records which tier won. Cue carries its producing tier so it can become NAV_CUE_CONFIDENCE
  later (FR-N17) without the field being bolted on after the fact.
- GpxCueEnricher (#63, tier 0): a real, if intentionally narrow, implementation. Reads
  <rtept>/<trkpt> name/desc text, classifies it via DirectionVocabulary, and locates each cue
  onto the already-simplified GpxRoute polyline by forward-only, segment-interpolated
  projection (RouteGeodesy.locateAlongPolyline) — not nearest-vertex snapping, which would be
  wrong whenever RDP simplification (#24) drops the cue's own point on a long straight, and
  not a global nearest-point search, which breaks on out-and-back/figure-eight routes the way
  Meridian's charter warns about. streetName is always null per D58 (bikerouter.de/BRouter
  tier 0 never has one); text this vocabulary doesn't recognise is dropped, never guessed.

Deliberately not implemented here (separate issues, separate PRs): BRouterEnricher (#27),
MatchingEnricher (#28), GeometryEnricher (#29) — TieredCueSheetEnricher accepts null for any
tier not yet wired in, so the interface is honest about what's real today without stub
implementations pretending otherwise.

Known gap: GpxCueEnricher only covers D59's plain-text encoding, not its other seven observed
GPX cue encodings (alignment-keyed, vendor extensions, XML-comment-hidden, icon-id-only) — real
#63 scope. The fixture GPX is hand-built to match D58/D59's documented text shape, not a
scraped real export (#38's "real fixtures" principle is a gap to close in #63's own PR, when a
live bikerouter.de sample is available).

Lives in :companion:core (pure JVM) rather than :companion:route (an Android library needing
AGP) so it's unit-testable in this sandbox, which has no Android SDK — same constraint the
last two PRs hit. `./gradlew :companion:core:test` passes (27 new tests, 0 failures).

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
robert merged commit 2550af9c43 into main 2026-09-04 00:13:27 +02:00
Sign in to join this conversation.
No description provided.