Skip to content

Extend decoded paths to valid (junction) nodes per OpenLR Rule 4 - #28

Merged
bnaul merged 7 commits into
mainfrom
worktree-valid-node-snap
Aug 26, 2026
Merged

Extend decoded paths to valid (junction) nodes per OpenLR Rule 4#28
bnaul merged 7 commits into
mainfrom
worktree-valid-node-snap

Conversation

@bnaul

@bnaul bnaul commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Closes #27.

Problem

Decoded paths sometimes stopped one short edge before the real intersection, ending on a node with no routing choice (1-in/1-out, or a 2-in/2-out through-node such as an OSM crossing/barrier node). OpenLR whitepaper Rule 4 says LRPs sit on valid nodes, so a terminal invalid node is evidence the path stopped short.

Fix

  • graph.rs: RoadNetwork::is_valid_node (Rule 4 test), plus forced_continuation / forced_predecessor to follow the single non-U-turn exit from an invalid node.
  • decoder.rs: after path selection, finalize_path walks each terminal along forced continuations to the first valid node, bounded by min(max_snap_extension_m, max_snap_extension_fraction × DNP) and the segment's remaining length tolerance. The added length is folded into the positive/negative offsets so the LRP-to-LRP geometry is unchanged — only the edge set grows. Shared between line and point-along-line decoding (removes duplicated offset code).
  • LRP guard: the extension is only applied when the LRP coordinate is closer to the new terminal node than to the current one. This protects against topology asymmetry — HERE having a junction our OSM car graph lacks (e.g. a track or non-car way), so the LRP genuinely sits on what looks to us like a through-node.
  • New config (Rust + Python): snap_to_valid_nodes=true, max_snap_extension_m=25. The cap is deliberately not scaled by DNP: HERE links are often only 20–40 m long and still stop a ~7–10 m crossing stub short of the junction, so a fractional cap made the snap a no-op exactly where it's needed.
  • API note: *_offset_fraction (relative to the first/last edge) can now exceed 1.0 when the offset spans an appended edge; the meter offsets are authoritative. Docs updated.

Benchmark tooling fixes (same PR)

  • qa/benchmark.py multiplied the per-edge offset fraction by the whole path length — only correct for single-edge paths, and it broke completely once an offset spanned an appended edge. It now uses the meter offsets as fractions of the whole path. This alone moves the baseline from Hausdorff p90 14.6 m / 793 "bad" to p90 9.8 m / 3 "bad", so older qa/results numbers understate quality. --config also accepts true/false.
  • qa/regenerate_network.py rebuilds the KC network with startOsmNode/endOsmNode (required since Use startOsmNode/endOsmNode to fix barrier node graph gaps #25); the regenerated network lifts the decode rate from 93.4 % → 98.1 %.
  • qa/screenshot.py + a new /api/screenshot.png in openlr-web (headless Chromium via Playwright) capture before/after images of the real web UI.

Regression results (47,931 KC codes, regenerated network)

main branch, snap off branch, snap on
Decoded 47,004 (98.1 %) 47,004 47,004
Hausdorff p50 / p90 2.5 / 9.8 m 2.5 / 9.8 m 2.5 / 9.8 m
Bad (>100 m) 3 3 3
  • main → branch (snap off): edge sets and offsets bit-identical for every code.
  • snap off → on: 1,621 codes (3.4 %) gain edges (1,756 extensions). In every case the old path is a contiguous subsequence of the new one; length identical.
  • cargo test 67/67 (9 new), clippy/fmt clean.

Precision check against HERE endpoints

Because the snap is geometry-neutral, Hausdorff can't judge it. Instead, for every extension I compared the HERE reference geometry's endpoint (≈ where HERE placed the LRP) to the old vs. new terminal node:

extensions landed ≤5 m from HERE endpoint (true positive) overshot a node already ≤5 m from it (false positive) indeterminate (endpoint far from both)
unguarded, 25 m ∧ 10 % DNP cap 1,257 704 103 (8 %) 450
LRP guard, 25 m ∧ 10 % DNP cap 735 704 0 31
LRP guard, 25 m cap (final) 1,756 1,609 0 147 (median 7 m from endpoint)

The guard keeps every true positive and removes every false positive; dropping the DNP-fraction cap more than doubles the true positives with no new false positives.

Chicago intersection failures

Scored on a 3,000-pair random sample of model-159019.meshkat.unmatched_chicago_intersections (pairs of HERE links whose decoded edges should pass through an OSM junction), decoded against the same network that produced it (street_export.street_export_enrichment_usa_2025_Q4_1_8_0, Chicago bbox):

criterion before after failing → fixed broken
pair's matched edges touch a degree ≥3 node 90.0 % 98.0 % 300 → 239 (80 %) 0
each code's edges touch a degree ≥3 node 73.4 % 87.9 % 797 → 436 (55 %) 0
per code 78.3 % 91.6 % 1,040 → 637 (61 %) 0

Most of what remains is either a stub longer than 25 m or a case where the LRP sits on the current end node (the guard correctly declines). Worst-case cost of a residual mistake is one ≤25 m stub edge on the same road with a matching offset — never a wrong road or changed geometry.

Before / after

Screenshots of the real web UI (left = main / snap off, right = snap on), captured with qa/screenshot.py against the regenerated KC network. Pink = decoded path (offset-trimmed geometry — unchanged by this PR); blue = decoded edge length beyond the offsets; numbered markers = LRPs. The sidebar shows edge count and offsets in meters.

C7y2vBvDXCOHBgHOAAEjBw== — 384 m residential link

Before, the path is a single edge that stops on 2-in/2-out through-nodes ~7 m short of the 3-way junctions at each end; after, two stub edges are added (3 edges, offsets +7.1 / −6.6 m) and the trimmed geometry is identical.

Overview
C7y2vBvDXCOHBgHO_overview

LRP 0 (start), 50 m zoom
C7y2vBvDXCOHBgHO_lrp0

LRP 1 (end), 50 m zoom
C7y2vBvDXCOHBgHO_lrp1

C7ywnxu/nCOIAQBqAAAjCA== — 86 m link

The start is already at a junction (LRP 0 sits on it, so the guard leaves it alone); the end snaps 6.6 m to the junction under LRP 1.

Overview
C7ywnxunCOIAQBqA_overview

LRP 0 (start), 50 m zoom
C7ywnxunCOIAQBqA_lrp0

LRP 1 (end), 50 m zoom
C7ywnxunCOIAQBqA_lrp1

Possible follow-up

After snapping, the LRP could be re-projected onto the extended terminal edge instead of the original one — in the examples above LRP1 sits at the junction, so that would shrink the negative offset to ~0 and move the reported geometry to the junction too. Left out here to keep this change geometry-neutral.

🤖 Generated with Claude Code

https://claude.ai/code/session_0162LFLFpwXmgBzigVq8staf

bnaul and others added 7 commits August 26, 2026 08:09
Closes #27.

Decoded paths sometimes stopped one short edge before the real
intersection, on a node with no routing choice. OpenLR Rule 4 says LRPs
sit on valid nodes, so a terminal invalid node is evidence the path
stopped short.

- graph.rs: add RoadNetwork::is_valid_node (Rule 4 test), plus
  forced_continuation / forced_predecessor to follow the single
  non-U-turn exit from an invalid node.
- decoder.rs: after path selection, finalize_path walks each terminal
  along forced continuations to the first valid node, bounded by
  min(max_snap_extension_m, max_snap_extension_fraction * DNP) and the
  segment's max_valid_distance. Added length is folded into the
  positive/negative offsets so the LRP-to-LRP geometry is unchanged.
  Shared between line and point-along-line decoding (removes duplicated
  offset code). New config: snap_to_valid_nodes (default true),
  max_snap_extension_m (25), max_snap_extension_fraction (0.10); exposed
  in the Python DecoderConfig.
- qa/benchmark.py: apply offsets from the meter values as fractions of
  the whole path. It previously multiplied the per-edge fraction by the
  total path length, which was only correct for single-edge paths and
  broke entirely once an offset spanned an appended edge. Also accept
  true/false in --config.

KC benchmark (47,931 codes): snap changes the edge set of 1,021 decoded
codes (2.3%; 835 +1 edge, 185 +2, 1 +3; ~half at start, half at end),
Hausdorff/Fréchet unchanged (max delta 1.0m), no decode-rate change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The KC benchmark network now carries startOsmNode/endOsmNode (487K edges,
pulled from the Replica street export via the Storage Read API). On the
regenerated network the decode rate rises from 93.4% to 98.1% (barrier
node gaps fixed by #25 are now reflected in the corpus).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wraps the new openlr-web /api/screenshot.png endpoint to render overview
and per-LRP zoom PNGs comparing two DecoderConfigs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162LFLFpwXmgBzigVq8staf
qa/screenshot.py now drives openlr-web's Playwright-backed
/api/screenshot.png so before/after images are exactly what the app
shows; output is JPEG by default (satellite tiles make PNGs huge).
Replace the #27 screenshots with real-UI captures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162LFLFpwXmgBzigVq8staf
Guards the Rule 4 extension against topology asymmetry: when HERE has a
junction our OSM car graph lacks, the LRP sits on what looks to us like
a through-node and extending would overshoot it. Extending only if the
LRP coordinate is nearer the new node than the current one fixes that.

Measured against HERE reference endpoints on the KC corpus (1,257
unguarded extensions): all 704 extensions landing within 5 m of HERE's
endpoint are kept, all 103 that overshot an already-correct node are
dropped, and 419 of 450 extensions moving away from a far-off endpoint
are dropped. 735 extensions remain (618 codes, 1.3%). Aggregate
Hausdorff/Fréchet and decode rate unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162LFLFpwXmgBzigVq8staf
The 10%-of-DNP cap made the snap a no-op for short HERE links: on a
Chicago intersection-failure set (model-159019.meshkat.unmatched_chicago_
intersections, decoded on the 2025 Q4 1.8.0 street export) the median
still-failing link had DNP 29m, so the cap was ~3m while the missing
crossing stub was ~9m. The absolute 25m cap, the length-tolerance
headroom check and the LRP-proximity guard remain.

Effect:
- Chicago sample (3,000 pairs): pairs whose matched edges touch a
  degree>=3 node 90.0% -> 98.0% (239 of 300 failing fixed, 0 broken);
  per code 78.3% -> 91.6% (637 of 1,040 fixed, 0 broken).
- KC corpus vs HERE endpoints: extensions 735 -> 1,756; landing within
  5m of HERE's endpoint 704 -> 1,609; overshoots of an already-correct
  node still 0. Hausdorff/Fréchet and decode rate unchanged.

Removes DecoderConfig.max_snap_extension_fraction (added earlier in this
branch, never released).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162LFLFpwXmgBzigVq8staf
@bnaul
bnaul merged commit 18481d6 into main Aug 26, 2026
4 checks passed
@bnaul
bnaul deleted the worktree-valid-node-snap branch August 26, 2026 20:46
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.

Extend decoded paths to valid (junction) nodes per OpenLR Rule 4

1 participant