Extend decoded paths to valid (junction) nodes per OpenLR Rule 4 - #28
Merged
Conversation
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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162LFLFpwXmgBzigVq8staf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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), plusforced_continuation/forced_predecessorto follow the single non-U-turn exit from an invalid node.decoder.rs: after path selection,finalize_pathwalks each terminal along forced continuations to the first valid node, bounded bymin(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).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.*_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.pymultiplied 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 olderqa/resultsnumbers understate quality.--configalso acceptstrue/false.qa/regenerate_network.pyrebuilds the KC network withstartOsmNode/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.pngin openlr-web (headless Chromium via Playwright) capture before/after images of the real web UI.Regression results (47,931 KC codes, regenerated network)
lengthidentical.cargo test67/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:
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):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.pyagainst 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 linkBefore, 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

LRP 0 (start), 50 m zoom

LRP 1 (end), 50 m zoom

C7ywnxu/nCOIAQBqAAAjCA==— 86 m linkThe 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

LRP 0 (start), 50 m zoom

LRP 1 (end), 50 m zoom

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