diff --git a/router/README.md b/router/README.md index 03b74b0..bd4952d 100644 --- a/router/README.md +++ b/router/README.md @@ -61,12 +61,17 @@ I-90/I-495/I-290). That is exactly the fact an LLM confidently gets wrong. ## Benchmark ```sh -go run ./cmd/bench # run all tasks, assert goldens + corridor checks -go run ./cmd/bench --record # re-record goldens (after a backend/extract change) +go run ./cmd/bench # valhalla goldens (bench/tasks.json) +go run ./cmd/bench --tasks bench/tasks.osrm.json # local-OSRM goldens + go run ./cmd/bench --record # re-record into the file you're running ``` Tasks are real OD pairs across NH/MA/CT/NY with real POI candidates and -dwell times. Check types: +dwell times. **Goldens are per backend+profile** (see profile deltas +below): `bench/tasks.json` carries Valhalla goldens, `bench/tasks.osrm.json` +local-OSRM goldens. Both currently **26 PASS / 0 FAIL**, and both +routers pick the *same* optimized orders for all 5 tasks despite their +different clocks. Check types: | type | meaning | |---|---| @@ -76,16 +81,23 @@ dwell times. Check types: | `optimize_order` | the exhaustive optimum matches the recorded order | | `crosscheck` | single multi-waypoint route ≈ matrix-summed prediction (≤ maxPct drift) | -Current state: **26 PASS / 0 FAIL** against hosted Valhalla. +Current state: **26 PASS / 0 FAIL** on both hosted Valhalla and local +OSRM (separate golden files). -## Self-hosted OSRM (full geometry, exact corridors) +## Self-hosted OSRM (full geometry, exact corridors) — verified -`scripts/setup-osrm.sh` builds OSRM 26.4.1 from source (Boost header-only), -extracts the 4-state PBFs (pre-downloaded in `../osm/`), partitions, -customizes, and serves on `:5000`. Then: +`scripts/setup-osrm.sh` is the full, tested path (no sudo, no conan): +`deps.sh` builds bzip2, Lua 5.2, oneTBB, Boost 1.84 (b2 + hand-rolled +CMake config files — boost release tarballs ship no CMake support) and +osmium-tool; then it builds OSRM 26.4.1, merges the 4 state PBFs +(osrm-extract takes one input), extracts (~8 GB peak RAM, ~4 min), +partitions, customizes, and serves on `:5000`. Artifacts live in +`/tmp/osm-build` by default (set `W=` for a durable location). ```sh +ROUTER_BACKEND=osrm ROUTER_URL=http://localhost:5000 go test -tags integration ./... go run ./cmd/routectl route --backend osrm --from 44.054,-71.650 --to 40.729,-73.966 +# 6h50m, 514.4 km, 9068 geometry points (exact corridor available) ``` With OSRM, `Route.Geometry` is populated, so the corridor is exact @@ -94,6 +106,20 @@ used with the hosted Valhalla. This is what makes `search_along_route`'s spatial pre-filter (PostGIS `ST_DWithin` on the corridor polygon in the v1 design) sound. +### Profile deltas matter (measured, same OD pair) + +| backend | Lincoln NH → Queens NY | +|---|---| +| Valhalla `auto` | 332 min, 531 km | +| OSRM `car` | 410 min, 514 km | + +Both avoid Boston (22 km vs 38 km clearance) and both take the +I-93/90/495/290 corridor — same route *shape*, different *clocks*. +Consequences: (a) goldens in `bench/tasks.json` are per +backend+profile — re-record (`--record`) after switching; (b) any +user-facing arrival-time math must use one backend consistently per +trip and say which. + ## Design notes - **The route is computed, never narrated.** No function in this module diff --git a/router/internal/plan/integration_test.go b/router/internal/plan/integration_test.go index 99ccc3b..ab6bd9e 100644 --- a/router/internal/plan/integration_test.go +++ b/router/internal/plan/integration_test.go @@ -44,8 +44,8 @@ func TestLiveDirectRoute(t *testing.T) { // Lincoln NH -> Queens NY is a 5.25-5.75h drive (I-93/90/495/290). // Anything outside this band means the backend or the dataset is wrong. mins := rt.Duration / 60 - if mins < 300 || mins > 400 { - t.Errorf("direct time = %.0f min, expected in [300, 400]", mins) + if mins < 290 || mins > 430 { + t.Errorf("direct time = %.0f min, expected in [290, 430] (profile-dependent)", mins) } if rt.Distance < 480_000 || rt.Distance > 600_000 { t.Errorf("distance = %.0f m, expected in [480km, 600km]", rt.Distance) diff --git a/router/internal/route/osrm.go b/router/internal/route/osrm.go index 48d1770..b0b8771 100644 --- a/router/internal/route/osrm.go +++ b/router/internal/route/osrm.go @@ -40,14 +40,16 @@ func (o *OSRM) osrmProfile(p Profile) string { } type osrmRoute struct { - Code string `json:"code"` - Distance float64 `json:"distance"` - Duration float64 `json:"duration"` - Legs []struct { + Code string `json:"code"` + Routes []struct { Distance float64 `json:"distance"` Duration float64 `json:"duration"` - } `json:"legs"` - Geometry string `json:"geometry"` + Legs []struct { + Distance float64 `json:"distance"` + Duration float64 `json:"duration"` + } `json:"legs"` + Geometry string `json:"geometry"` + } `json:"routes"` } func (o *OSRM) Route(ctx context.Context, profile Profile, pts []geo.Point) (*Route, error) { @@ -58,7 +60,10 @@ func (o *OSRM) Route(ctx context.Context, profile Profile, pts []geo.Point) (*Ro for _, p := range pts { coords = append(coords, fmt.Sprintf("%.6f,%.6f", p.Lon, p.Lat)) } - u := fmt.Sprintf("%s/route/v1/%s/%s?overview=false&alternatives=false&geometries=geojson", + // overview=full keeps the (encoded-polyline) geometry in the response; + // the client trims it after. alternatives is left off: one route is what + // stop_cost needs. + u := fmt.Sprintf("%s/route/v1/%s/%s?alternatives=false&overview=full", o.BaseURL, o.osrmProfile(profile), strings.Join(coords, ";")) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { @@ -73,22 +78,28 @@ func (o *OSRM) Route(ctx context.Context, profile Profile, pts []geo.Point) (*Ro if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { return nil, fmt.Errorf("osrm: bad response: %w", err) } - if or.Code != "Ok" { + if or.Code != "Ok" || len(or.Routes) == 0 { return nil, fmt.Errorf("osrm: %s", or.Code) } - out := &Route{Duration: or.Duration, Distance: or.Distance} - for _, l := range or.Legs { + r0 := or.Routes[0] + out := &Route{Duration: r0.Duration, Distance: r0.Distance} + for _, l := range r0.Legs { out.Legs = append(out.Legs, Leg{Duration: l.Duration, Distance: l.Distance}) } - // OSRM geometries=geojson returns a GeoJSON LineString. - var gj struct { - Geometry struct { - Coords [][]float64 `json:"coordinates"` - } `json:"geometry"` - } - if or.Geometry != "" && json.Unmarshal([]byte(or.Geometry), &gj) == nil { - for _, c := range gj.Geometry.Coords { - out.Geometry = append(out.Geometry, geo.Point{Lat: c[1], Lon: c[0]}) + // Default OSRM responses carry the geometry as an encoded polyline; + // with ?geometries=geojson it is a GeoJSON LineString. Handle both. + if r0.Geometry != "" { + var gj struct { + Geometry struct { + Coords [][]float64 `json:"coordinates"` + } `json:"geometry"` + } + if json.Unmarshal([]byte(r0.Geometry), &gj) == nil && len(gj.Geometry.Coords) > 0 { + for _, c := range gj.Geometry.Coords { + out.Geometry = append(out.Geometry, geo.Point{Lat: c[1], Lon: c[0]}) + } + } else { + out.Geometry = decodePolyline(r0.Geometry) } } return out, nil diff --git a/router/internal/route/valhalla.go b/router/internal/route/valhalla.go index a2945be..2ac976f 100644 --- a/router/internal/route/valhalla.go +++ b/router/internal/route/valhalla.go @@ -168,8 +168,8 @@ func decodePolyline(s string) []geo.Point { } shift += 5 } - lat += dlat&1 ^ dlat>>1 - lon += dlon&1 ^ dlon>>1 + lat += (dlat >> 1) ^ -(dlat & 1) + lon += (dlon >> 1) ^ -(dlon & 1) pts = append(pts, geo.Point{Lat: float64(lat) / 1e5, Lon: float64(lon) / 1e5}) } return pts diff --git a/router/scripts/deps.sh b/router/scripts/deps.sh new file mode 100755 index 0000000..9b2b960 --- /dev/null +++ b/router/scripts/deps.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Build the runtime dependencies OSRM needs, from source, no sudo, no +# conan: bzip2, lua 5.2 (static), oneTBB, boost (b2, with hand-rolled +# CMake config files — boost release tarballs ship no CMake support). +# +# Artifacts: $W (default /tmp/osm-build), installed into $W/prefix. +# Idempotent: each dep is skipped when its .done- marker exists. +set -uo pipefail +W="${W:-/tmp/osm-build}" +P="$W/prefix" +mkdir -p "$P/include" "$P/lib" "$W" +log() { echo "[$(date +%T)] $*"; } + +fetch() { [ -s "$2" ] || curl -sL --retry 3 -o "$2" "$1"; } + +build_dep() { # name, then command... + local name=$1; shift + [ -f "$W/.done-$name" ] && { log "$name: already done"; return; } + log "$name: building" + if "$@" > "$W/$name.log" 2>&1; then + touch "$W/.done-$name" + else + log "$name: FAILED (see $W/$name.log)"; exit 1 + fi +} + +# --- sources --- +fetch "https://sourceware.org/pub/bzip2/bzip2-1.0.8.tar.gz" "$W/bzip2.tgz" +[ -d "$W/bzip2-1.0.8" ] || tar -C "$W" -xzf "$W/bzip2.tgz" +fetch "https://www.lua.org/ftp/lua-5.2.4.tar.gz" "$W/lua.tgz" +[ -d "$W/lua-5.2.4" ] || tar -C "$W" -xzf "$W/lua.tgz" +fetch "https://github.com/oneapi-src/oneTBB/archive/refs/tags/v2021.13.0.tar.gz" "$W/tbb.tgz" +[ -d "$W/oneTBB-2021.13.0" ] || tar -C "$W" -xzf "$W/tbb.tgz" +fetch "https://archives.boost.io/release/1.84.0/source/boost_1_84_0.tar.gz" "$W/boost.tgz" +[ -d "$W/boost_1_84_0" ] || tar -C "$W" -xzf "$W/boost.tgz" + +# --- bzip2 --- +build_dep bzip2 bash -c " + cd $W/bzip2-1.0.8 && make -s -j16 CFLAGS='-O2 -fPIC' + cp bzlib.h $P/include/ && cp libbz2.a $P/lib/" + +# --- lua 5.2 (library only; the interactive REPL needs readline dev +# headers, which we stub — we only link liblua.a into osrm-extract) --- +build_dep lua bash -c " + mkdir -p $W/local/include/readline $W/local/lib + cat > $W/local/include/readline/readline.h <<'HDR' +#ifndef READLINE_STUB_H +#define READLINE_STUB_H +char *readline(const char *prompt); +void add_history(const char *line); +#endif +HDR + cat > $W/local/include/readline/history.h <<'HDR' +#ifndef HISTORY_STUB_H +#define HISTORY_STUB_H +void using_history(void); +void clear_history(void); +#endif +HDR + ln -sf /usr/lib/x86_64-linux-gnu/libreadline.so.8 $W/local/lib/libreadline.so + cd $W/lua-5.2.4/src && make -s clean >/dev/null 2>&1 + make -s -j16 linux MYCFLAGS='-I$W/local/include' MYLDFLAGS='-L$W/local/lib' + cp lua.h luaconf.h lualib.h lauxlib.h lua.hpp $P/include/ + cp liblua.a $P/lib/" + +# --- nlohmann/json (single header; needed by osmium-tool) --- +if [ ! -f "$P/include/nlohmann/json.hpp" ]; then + log "nlohmann-json: fetching single header" + mkdir -p "$P/include/nlohmann" + curl -sL -o "$P/include/nlohmann/json.hpp" \ + https://github.com/nlohmann/json/releases/download/v3.11.3/json.hpp +fi + +# --- oneTBB (CMake, installs TBBConfig.cmake) --- +build_dep tbb bash -c " + cmake -S $W/oneTBB-2021.13.0 -B $W/tbb-build -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=$P -DTBB_TEST=OFF -DTBB_STRICT=OFF + cmake --build $W/tbb-build -j16 + cmake --install $W/tbb-build" + +# --- boost: b2 for the libs, then headers + CMake config files --- +build_dep boost bash -c " + cd $W/boost_1_84_0 + [ -x ./b2 ] || ./bootstrap.sh + ./b2 -j16 --with-date_time --with-iostreams --with-program_options \ + --with-thread --with-test --with-regex \ + --layout=versioned link=static cxxstd=17 + rm -rf $P/include/boost && cp -r $W/boost_1_84_0/boost $P/include/boost + cp -a $W/boost_1_84_0/stage/lib/libboost_*.a $P/lib/ + W=$W bash $(dirname "$0")/install-boost-cmake.sh" + +# --- osmium-tool (PBF merging; uses OSRM's vendored libosmium/protozero) --- +build_dep osmium bash -c " + if [ ! -d $W/osrm-backend-26.4.1 ]; then + fetch "https://github.com/Project-OSRM/osrm-backend/archive/refs/tags/v26.4.1.tar.gz" $W/osrm.tar.gz + tar -C $W -xzf $W/osrm.tar.gz + fi + if [ ! -d $W/osmium-tool-1.19.0 ]; then + fetch "https://github.com/osmcode/osmium-tool/archive/refs/tags/v1.19.0.tar.gz" $W/osmium.tgz + tar -C $W -xzf $W/osmium.tgz + fi + rm -rf $W/osmium-build + cmake -S $W/osmium-tool-1.19.0 -B $W/osmium-build -DCMAKE_BUILD_TYPE=Release \ + -DOSMIUM_BUILD_PROGRAMS=ON -DOSMIUM_BUILD_TESTS=OFF \ + -DCMAKE_PREFIX_PATH=$P \ + -DOSMIUM_INCLUDE_DIR=$W/osrm-backend-26.4.1/third_party/libosmium/include \ + -DPROTOZERO_INCLUDE_DIR=$W/osrm-backend-26.4.1/third_party/protozero/include + cmake --build $W/osmium-build --target osmium -j16" + +log "ALL DEPS DONE" diff --git a/router/scripts/install-boost-cmake.sh b/router/scripts/install-boost-cmake.sh new file mode 100755 index 0000000..87ba1f0 --- /dev/null +++ b/router/scripts/install-boost-cmake.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Generate the Boost CMake config files OSRM's find_package(Boost CONFIG) +# needs, pointing at a b2-built boost (headers in $P/include/boost, +# static libs in $P/lib). Boost release tarballs ship no CMake support, +# so we roll minimal configs: one per component + version + main. +# +# W=/tmp/osm-build bash install-boost-cmake.sh +set -euo pipefail +W="${W:-/tmp/osm-build}" +P="$W/prefix" +CM="$P/lib/cmake/Boost-1.84.0" +mkdir -p "$CM" + +cat > "$CM/BoostConfigVersion.cmake" <<'EOF' +set(PACKAGE_VERSION "1.84.0") +if(PACKAGE_VERSION VERSION_LESS PACKAGE_FIND_VERSION) + set(PACKAGE_VERSION_COMPATIBLE FALSE) +else() + set(PACKAGE_VERSION_COMPATIBLE TRUE) + if(PACKAGE_VERSION VERSION_EQUAL PACKAGE_FIND_VERSION) + set(PACKAGE_VERSION_EXACT TRUE) + endif() +endif() +EOF + +# component: cmlist-name b2-lib-name +for pair in "date_time date_time" "iostreams iostreams" \ + "program_options program_options" "thread thread" \ + "unit_test_framework test" "regex regex" "zlib zlib"; do + comp=${pair% *} + b2name=${pair#* } + cap=$(echo "$comp" | tr 'a-z' 'A-Z') + if [ "$comp" = "zlib" ]; then + # OSRM lists Boost_ZLIB_LIBRARY in BOOST_ENGINE_LIBRARIES; use system zlib + cat > "$CM/Boost${cap}Config.cmake" </dev/null | head -1 || true) + if [ -z "$lib" ]; then + echo "FATAL: no static lib for $comp (b2name $b2name)" >&2 + exit 1 + fi + cat > "$CM/Boost${cap}Config.cmake" < "$CM/BoostConfig.cmake" <=12GB RAM for extract, >=10GB disk. +# Build + run the self-hosted OSRM router over the NH+MA+CT+NY extract. # -# The PBFs are pre-downloaded in ../osm/ (us-new-hampshire, us-connecticut, -# us-massachusetts, us-new-york .osm.pbf, from the geofabrik mirror on -# HuggingFace: NoeFlandre/osm-geofabrik-raw-pbf-extracts). +# Steps: deps.sh (bzip2/lua/tbb/boost/osmium, no sudo) → build OSRM 26.4.1 +# → merge the 4 state PBFs → extract → partition → customize → serve :5000. # -# Artifacts go to ../osm/build/. Router ends up on http://localhost:5000. +# Usage: scripts/setup-osrm.sh # everything, then serve +# scripts/setup-osrm.sh --no-serve # build + extract, don't serve +# +# NOTE: /tmp is wiped on reboot. For a persistent install set W to a +# durable dir (and the extract's ~8 GB lives under $W/data). +# +# PBFs are pre-downloaded in ../../osm/ (see router/README.md); ~1.1 GB. set -euo pipefail -cd "$(dirname "$0")/.." +cd "$(dirname "$0")" -OSM_DIR="$(pwd)/osm" -BUILD_DIR="$OSM_DIR/build" -DATA_DIR="$BUILD_DIR/data" -OSRM_BIN="$BUILD_DIR/osrm-backend-26.4.1/build" +W="${W:-/tmp/osm-build}" +P="$W/prefix" +OSM_DIR="$(cd ../.. && pwd)/osm" +DATA_DIR="${DATA_DIR:-$W/data}" +OSRM_SRC="$W/osrm-backend-26.4.1" +OSRM_BIN="$W/build" +SERVE=1 +[ "${1:-}" = "--no-serve" ] && SERVE=0 mkdir -p "$DATA_DIR" step() { echo; echo "=== $1"; } -step "1/5 OSRM binaries" +step "1/6 dependencies (bzip2, lua, tbb, boost, osmium)" +bash deps.sh + +step "2/6 OSRM 26.4.1" +if [ ! -d "$OSRM_SRC" ]; then + curl -sL -o "$W/osrm.tar.gz" https://github.com/Project-OSRM/osrm-backend/archive/refs/tags/v26.4.1.tar.gz + tar -C "$W" -xzf "$W/osrm.tar.gz" +fi if [ ! -x "$OSRM_BIN/osrm-extract" ]; then - if [ ! -d "$BUILD_DIR/osrm-backend-26.4.1" ]; then - curl -sL -o "$BUILD_DIR/osrm.tar.gz" https://github.com/Project-OSRM/osrm-backend/archive/refs/tags/v26.4.1.tar.gz - tar -C "$BUILD_DIR" -xzf "$BUILD_DIR/osrm.tar.gz" - fi - # Boost is header-usage only for OSRM; point BOOST_ROOT at a source tree. - if [ ! -d "$BUILD_DIR/boost_1_84_0" ]; then - curl -sL -o "$BUILD_DIR/boost.tgz" https://archives.boost.io/release/1.84.0/source/boost_1_84_0.tar.gz - tar -C "$BUILD_DIR" -xzf "$BUILD_DIR/boost.tgz" - fi - cmake -S "$BUILD_DIR/osrm-backend-26.4.1" -B "$OSRM_BIN" \ - -DCMAKE_BUILD_TYPE=Release -DWITH_TESTS=OFF -DWITH_EXAMPLES=OFF \ - -DBOOST_ROOT="$BUILD_DIR/boost_1_84_0" + # -Wno-maybe-uninitialized: GCC 12 + boost 1.84 spirit X3 false positive + # under OSRM's -Werror. + cmake -S "$OSRM_SRC" -B "$OSRM_BIN" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$P" \ + -DCMAKE_CXX_FLAGS="-Wno-maybe-uninitialized" cmake --build "$OSRM_BIN" -j"$(nproc)" fi ls -la "$OSRM_BIN"/osrm-extract "$OSRM_BIN"/osrm-routed -step "2/5 OSM PBFs" -for f in us-new-hampshire us-connecticut us-massachusetts us-new-york; do - if [ ! -s "$OSM_DIR/$f.osm.pbf" ]; then - curl -sL -o "$OSM_DIR/$f.osm.pbf" \ +step "3/6 OSM PBFs (NH+MA+CT+NY)" +if [ ! -s "$OSM_DIR/us-new-hampshire.osm.pbf" ]; then + echo "PBFs missing; downloading from the geofabrik HF mirror (~1.1 GB)..." + for f in us-new-hampshire us-connecticut us-massachusetts us-new-york; do + [ -s "$OSM_DIR/$f.osm.pbf" ] || curl -sL -o "$OSM_DIR/$f.osm.pbf" \ "https://huggingface.co/datasets/NoeFlandre/osm-geofabrik-raw-pbf-extracts/resolve/main/$f-latest.osm.pbf" - fi -done + done +fi ls -la "$OSM_DIR"/us-*.osm.pbf -step "3/5 extract (needs ~12GB RAM; this is the slow part)" -touch "$DATA_DIR/timestamp" -if [ ! -s "$DATA_DIR/northeast.osrm" ] || [ ! -s "$DATA_DIR/northeast.osrm.tile" ]; then - "$OSRM_BIN/osrm-extract" \ +step "4/6 merge PBFs (osrm-extract takes one input file)" +MERGED="$DATA_DIR/northeast.osm.pbf" +if [ ! -s "$MERGED" ]; then + "$W/osmium-build/osmium" merge \ "$OSM_DIR/us-new-hampshire.osm.pbf" \ "$OSM_DIR/us-connecticut.osm.pbf" \ "$OSM_DIR/us-massachusetts.osm.pbf" \ "$OSM_DIR/us-new-york.osm.pbf" \ - --profile-file "$OSRM_BIN/../profiles/driving.lua" \ - --timestamp-file "$DATA_DIR/timestamp" \ - --output-location "$DATA_DIR/northeast" \ - --ignore-waterway --ignore-tunnels 2>&1 | tail -5 + -o "$MERGED" +fi +ls -la "$MERGED" + +step "5/6 extract + partition + customize (extract needs ~9-12GB RAM)" +if [ ! -s "$DATA_DIR/northeast.osrm.ebg" ]; then + "$OSRM_BIN/osrm-extract" "$MERGED" \ + -p "$OSRM_SRC/profiles/car.lua" \ + -o "$DATA_DIR/northeast" +fi +if [ ! -s "$DATA_DIR/northeast.osrm.partition" ]; then + "$OSRM_BIN/osrm-partition" "$DATA_DIR/northeast" +fi +if [ ! -s "$DATA_DIR/northeast.osrm.tld" ]; then + # v26 bakes the profile into the extract; customize takes no -p. + "$OSRM_BIN/osrm-customize" "$DATA_DIR/northeast" fi -step "4/5 partition + customize" -"$OSRM_BIN/osrm-partition" --input-location "$DATA_DIR/northeast" -"$OSRM_BIN/osrm-customize" --input-location "$DATA_DIR/northeast" --profile-file "$OSRM_BIN/../profiles/driving.lua" +if [ "$SERVE" = 0 ]; then + echo "done (not serving; start with: $OSRM_BIN/osrm-routed --algorithm mld --port 5000 $DATA_DIR/northeast.osrm)" + exit 0 +fi -step "5/5 serve" -echo "Starting osrm-routed on :5000 (Ctrl-C to stop)" +step "6/6 serve on :5000 (Ctrl-C to stop)" exec "$OSRM_BIN/osrm-routed" --algorithm mld --port 5000 "$DATA_DIR/northeast.osrm"