I'm working on /root/traccar-geocoder. The main source file is builder/src/build_index.cpp (~3300 lines). I need to plan:

1. What additional tests to write beyond the existing 12 unit tests in builder/test/test_ring_assembly.cpp and the integration test script
2. What refactoring opportunities exist in build_index.cpp

Read builder/src/build_index.cpp and identify:
- Functions that are too long and should be split
- Code duplication that should be extracted
- Magic numbers that should be named constants
- Functions that could be moved to separate files for better organization
- Any other code quality issues

Also identify edge cases we should test that aren't covered by the existing unit tests. Look at the actual algorithm code (ring assembly, S2 cell computation, string interning, parallel merge, write phase) for testable behaviors.

Don't make any changes — just research and report back with a structured plan.


I'll start by reading the main source file and the existing tests to understand the codebase.


     1→#include <algorithm>
     2→#include <chrono>
     3→#include <atomic>
     4→#include <cmath>
     5→#include <condition_variable>
     6→#include <cstdint>
     7→#include <cstring>
     8→#include <deque>
     9→#include <fstream>
    10→#include <future>
    11→#include <iostream>
    12→#include <mutex>
    13→#include <set>
    14→#include <string>
    15→#include <thread>
    16→#include <unordered_map>
    17→#include <unordered_set>
    18→#include <vector>
    19→
    20→#include <osmium/handler.hpp>
    21→#include <osmium/io/pbf_input.hpp>
    22→#include <osmium/visitor.hpp>
    23→#include <osmium/handler/node_locations_for_ways.hpp>
    24→#include <fcntl.h>
    25→#include <unistd.h>
    26→#include <sys/mman.h>
    27→#include <sys/stat.h>
    28→// FlexMem no longer used — replaced by DenseIndex mmap
    29→#include <osmium/area/assembler.hpp>
    30→#include <osmium/area/multipolygon_manager.hpp>
    31→
    32→#include <s2/s2cell_id.h>
    33→#include <s2/s2latlng.h>
    34→#include <s2/s2region_coverer.h>
    35→#include <s2/s2polyline.h>
    36→#include <s2/s2polygon.h>
    37→#include <s2/s2loop.h>
    38→#include <s2/s2builder.h>
    39→
    40→// --- Directory creation ---
    41→
    42→static void ensure_dir(const std::string& path) {
    43→    mkdir(path.c_str(), 0755);
    44→}
    45→
    46→// --- Binary format structs ---
    47→
    48→struct WayHeader {
    49→    uint32_t node_offset;
    50→    uint8_t node_count;
    51→    uint32_t name_id;
    52→};
    53→
    54→struct AddrPoint {
    55→    float lat;
    56→    float lng;
    57→    uint32_t housenumber_id;
    58→    uint32_t street_id;
    59→};
    60→
    61→struct InterpWay {
    62→    uint32_t node_offset;
    63→    uint8_t node_count;
    64→    uint32_t street_id;
    65→    uint32_t start_number;
    66→    uint32_t end_number;
    67→    uint8_t interpolation;
    68→};
    69→
    70→struct AdminPolygon {
    71→    uint32_t vertex_offset;
    72→    uint16_t vertex_count;
    73→    uint32_t name_id;
    74→    uint8_t admin_level;
    75→    float area;
    76→    uint16_t country_code;
    77→};
    78→
    79→struct NodeCoord {
    80→    float lat;
    81→    float lng;
    82→};
    83→
    84→static const uint32_t INTERIOR_FLAG = 0x80000000u;
    85→static const uint32_t ID_MASK = 0x7FFFFFFFu;
    86→
    87→// --- Index mode ---
    88→
    89→enum class IndexMode { Full, NoAddresses, AdminOnly };
    90→
    91→// --- String interning ---
    92→
    93→class StringPool {
    94→public:
    95→    uint32_t intern(const std::string& s) {
    96→        auto it = index_.find(s);
    97→        if (it != index_.end()) {
    98→            return it->second;
    99→        }
   100→        uint32_t offset = static_cast<uint32_t>(data_.size());
   101→        index_[s] = offset;
   102→        data_.insert(data_.end(), s.begin(), s.end());
   103→        data_.push_back('\0');
   104→        return offset;
   105→    }
   106→
   107→    const std::vector<char>& data() const { return data_; }
   108→    std::vector<char>& mutable_data() { return data_; }
   109→
   110→private:
   111→    std::unordered_map<std::string, uint32_t> index_;
   112→    std::vector<char> data_;
   113→};
   114→
   115→// --- Phase timer ---
   116→static void log_phase(const char* name, std::chrono::steady_clock::time_point& t) {
   117→    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
   118→        std::chrono::steady_clock::now() - t).count();
   119→    std::cerr << "  [" << ms/1000 << "." << (ms%1000)/100 << "s] " << name << std::endl;
   120→    t = std::chrono::steady_clock::now();
   121→}
   122→
   123→// --- Deferred S2 work items (computed in parallel after PBF read) ---
   124→
   125→struct DeferredWay {
   126→    uint32_t way_id;
   127→    uint32_t node_offset;
   128→    uint8_t node_count;
   129→};
   130→
   131→struct DeferredInterp {
   132→    uint32_t interp_id;
   133→    uint32_t node_offset;
   134→    uint8_t node_count;
   135→};
   136→
   137→// Collected relation data for parallel admin assembly
   138→struct CollectedRelation {
   139→    int64_t id;
   140→    uint8_t admin_level;
   141→    std::string name;
   142→    std::string country_code;
   143→    bool is_postal;
   144→    std::vector<std::pair<int64_t, std::string>> members; // (way_id, role)
   145→};
   146→
   147→// Forward declaration
   148→static std::vector<std::pair<S2CellId, bool>> cover_polygon(const std::vector<std::pair<double,double>>& vertices);
   149→
   150→// --- Thread pool for concurrent admin polygon S2 covering ---
   151→
   152→class AdminCoverPool {
   153→public:
   154→    struct WorkItem {
   155→        uint32_t poly_id;
   156→        std::vector<std::pair<double,double>> vertices;
   157→    };
   158→
   159→    explicit AdminCoverPool(size_t num_threads) : stop_(false) {
   160→        for (size_t i = 0; i < num_threads; i++) {
   161→            workers_.emplace_back([this]() { worker_loop(); });
   162→        }
   163→    }
   164→
   165→    ~AdminCoverPool() {
   166→        {
   167→            std::lock_guard<std::mutex> lock(mutex_);
   168→            stop_ = true;
   169→        }
   170→        cv_.notify_all();
   171→        for (auto& t : workers_) t.join();
   172→    }
   173→
   174→    void submit(uint32_t poly_id, std::vector<std::pair<double,double>>&& vertices) {
   175→        {
   176→            std::lock_guard<std::mutex> lock(mutex_);
   177→            queue_.push_back({poly_id, std::move(vertices)});
   178→        }
   179→        cv_.notify_one();
   180→    }
   181→
   182→    // Wait for all pending work to complete, then return merged results
   183→    std::unordered_map<uint64_t, std::vector<uint32_t>> drain() {
   184→        // Wait until queue is empty and all workers are idle
   185→        {
   186→            std::unique_lock<std::mutex> lock(mutex_);
   187→            done_cv_.wait(lock, [this]() { return queue_.empty() && active_workers_ == 0; });
   188→        }
   189→
   190→        // Merge all thread-local results
   191→        std::unordered_map<uint64_t, std::vector<uint32_t>> merged;
   192→        for (auto& local : thread_results_) {
   193→            for (auto& [cell_id, ids] : local) {
   194→                auto& target = merged[cell_id];
   195→                target.insert(target.end(), ids.begin(), ids.end());
   196→            }
   197→        }
   198→        return merged;
   199→    }
   200→


     1→// Unit tests for ring assembly algorithms.
     2→// Tests the key edge cases that caused bugs during development:
     3→// - Coordinate-based endpoint matching (split/replaced nodes)
     4→// - Backtracking at branch points
     5→// - Closed-way admin polygon detection
     6→// - Inner-role way retry
     7→// - Self-intersection detection
     8→// - Border-line relation filtering
     9→// - Duplicate coordinate detection (hole merging)
    10→//
    11→// Build: g++ -O2 -std=c++17 -o test_ring_assembly test_ring_assembly.cpp
    12→// Run:   ./test_ring_assembly
    13→
    14→#include <algorithm>
    15→#include <cassert>
    16→#include <cmath>
    17→#include <cstdint>
    18→#include <iostream>
    19→#include <string>
    20→#include <unordered_map>
    21→#include <vector>
    22→
    23→// --- Minimal types matching build_index.cpp ---
    24→
    25→struct CellItemPair { uint64_t cell_id; uint32_t item_id; };
    26→
    27→namespace ParsedData {
    28→    struct WayGeometry {
    29→        std::vector<std::pair<double,double>> coords;
    30→        int64_t first_node_id;
    31→        int64_t last_node_id;
    32→    };
    33→}
    34→
    35→// Coordinate key matching osmium's Location (int32_t nanodegrees)
    36→static int64_t coord_key(double lat, double lng) {
    37→    int32_t ilat = static_cast<int32_t>(lat * 1e7 + (lat >= 0 ? 0.5 : -0.5));
    38→    int32_t ilng = static_cast<int32_t>(lng * 1e7 + (lng >= 0 ? 0.5 : -0.5));
    39→    return (static_cast<int64_t>(ilat) << 32) | static_cast<uint32_t>(ilng);
    40→}
    41→
    42→// --- Test helpers ---
    43→
    44→static int tests_run = 0;
    45→static int tests_passed = 0;
    46→
    47→#define TEST(name) do { \
    48→    tests_run++; \
    49→    std::cerr << "  TEST: " << name << "... "; \
    50→} while(0)
    51→
    52→#define PASS() do { \
    53→    tests_passed++; \
    54→    std::cerr << "PASS" << std::endl; \
    55→} while(0)
    56→
    57→#define FAIL(msg) do { \
    58→    std::cerr << "FAIL: " << msg << std::endl; \
    59→} while(0)
    60→
    61→template<typename T> std::string to_str(const T& v) { return std::to_string(v); }
    62→inline std::string to_str(const std::string& v) { return "\"" + v + "\""; }
    63→inline std::string to_str(const char* v) { return std::string("\"") + v + "\""; }
    64→
    65→#define ASSERT_EQ(a, b) do { \
    66→    if ((a) != (b)) { FAIL(#a " != " #b " (" + to_str(a) + " vs " + to_str(b) + ")"); return; } \
    67→} while(0)
    68→
    69→#define ASSERT_TRUE(cond) do { \
    70→    if (!(cond)) { FAIL(#cond " is false"); return; } \
    71→} while(0)
    72→
    73→// --- Tests ---
    74→
    75→void test_coord_key_basic() {
    76→    TEST("coord_key basic");
    77→    // Same coordinates should produce same key
    78→    ASSERT_EQ(coord_key(51.5, -0.1), coord_key(51.5, -0.1));
    79→    // Different coordinates should produce different keys
    80→    ASSERT_TRUE(coord_key(51.5, -0.1) != coord_key(51.5, -0.2));
    81→    ASSERT_TRUE(coord_key(51.5, -0.1) != coord_key(51.6, -0.1));
    82→    PASS();
    83→}
    84→
    85→void test_coord_key_split_nodes() {
    86→    TEST("coord_key handles split/replaced nodes at same location");
    87→    // Two different OSM nodes at the same coordinates should have the same key
    88→    // (even though they have different node IDs)
    89→    double lat = 49.5576001, lng = 8.8074189;
    90→    int64_t k1 = coord_key(lat, lng);
    91→    int64_t k2 = coord_key(lat, lng);
    92→    ASSERT_EQ(k1, k2);
    93→    PASS();
    94→}
    95→
    96→void test_coord_key_negative_coords() {
    97→    TEST("coord_key with negative coordinates");
    98→    // Southern/western hemisphere
    99→    int64_t k1 = coord_key(-33.8688, 151.2093); // Sydney
   100→    int64_t k2 = coord_key(-33.8688, 151.2093);
   101→    ASSERT_EQ(k1, k2);
   102→    // Different cities
   103→    ASSERT_TRUE(coord_key(-33.8688, 151.2093) != coord_key(-22.9068, -43.1729));
   104→    PASS();
   105→}
   106→
   107→void test_self_intersection_simple_square() {
   108→    TEST("self-intersection: simple square has no crossing");
   109→    // A simple square should not self-intersect
   110→    std::vector<std::pair<double,double>> square = {
   111→        {0, 0}, {0, 1}, {1, 1}, {1, 0}, {0, 0}
   112→    };
   113→    // Simple brute-force check
   114→    bool has_cross = false;
   115→    size_t n = square.size();
   116→    for (size_t i = 0; i + 1 < n && !has_cross; i++) {
   117→        for (size_t j = i + 2; j + 1 < n && !has_cross; j++) {
   118→            if (j == i + 1 || (i == 0 && j == n - 2)) continue;
   119→            // Cross product intersection test
   120→            auto cross = [](double ox, double oy, double ax, double ay, double bx, double by) {
   121→                return (ax - ox) * (by - oy) - (ay - oy) * (bx - ox);
   122→            };
   123→            double d1 = cross(square[j].first, square[j].second, square[j+1].first, square[j+1].second, square[i].first, square[i].second);
   124→            double d2 = cross(square[j].first, square[j].second, square[j+1].first, square[j+1].second, square[i+1].first, square[i+1].second);
   125→            double d3 = cross(square[i].first, square[i].second, square[i+1].first, square[i+1].second, square[j].first, square[j].second);
   126→            double d4 = cross(square[i].first, square[i].second, square[i+1].first, square[i+1].second, square[j+1].first, square[j+1].second);
   127→            if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
   128→                ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
   129→                has_cross = true;
   130→            }
   131→        }
   132→    }
   133→    ASSERT_TRUE(!has_cross);
   134→    PASS();
   135→}
   136→
   137→void test_self_intersection_bowtie() {
   138→    TEST("self-intersection: bowtie (figure-8) detected");
   139→    // A bowtie shape crosses itself at (0.5, 0.5)
   140→    std::vector<std::pair<double,double>> bowtie = {
   141→        {0, 0}, {1, 1}, {1, 0}, {0, 1}, {0, 0}
   142→    };
   143→    bool has_cross = false;
   144→    size_t n = bowtie.size();
   145→    for (size_t i = 0; i + 1 < n && !has_cross; i++) {
   146→        for (size_t j = i + 2; j + 1 < n && !has_cross; j++) {
   147→            if (j == i + 1 || (i == 0 && j == n - 2)) continue;
   148→            auto cross = [](double ox, double oy, double ax, double ay, double bx, double by) {
   149→                return (ax - ox) * (by - oy) - (ay - oy) * (bx - ox);
   150→            };
   151→            double d1 = cross(bowtie[j].first, bowtie[j].second, bowtie[j+1].first, bowtie[j+1].second, bowtie[i].first, bowtie[i].second);
   152→            double d2 = cross(bowtie[j].first, bowtie[j].second, bowtie[j+1].first, bowtie[j+1].second, bowtie[i+1].first, bowtie[i+1].second);
   153→            double d3 = cross(bowtie[i].first, bowtie[i].second, bowtie[i+1].first, bowtie[i+1].second, bowtie[j].first, bowtie[j].second);
   154→            double d4 = cross(bowtie[i].first, bowtie[i].second, bowtie[i+1].first, bowtie[i+1].second, bowtie[j+1].first, bowtie[j+1].second);
   155→            if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
   156→                ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
   157→                has_cross = true;
   158→            }
   159→        }
   160→    }
   161→    ASSERT_TRUE(has_cross);
   162→    PASS();
   163→}
   164→
   165→void test_duplicate_coords_detection() {
   166→    TEST("duplicate coordinate detection in ring");
   167→    // A ring that visits a coordinate twice (figure-8 from merged hole)
   168→    std::vector<std::pair<double,double>> ring = {
   169→        {0, 0}, {1, 0}, {1, 1}, {0.5, 0.5}, {0, 1}, {0.5, 0.5}, {1, 1}, {0, 0}
   170→    };
   171→    // Check for duplicates
   172→    std::unordered_map<int64_t, int> seen;
   173→    bool has_dup = false;
   174→    for (size_t i = 0; i + 1 < ring.size(); i++) {
   175→        int64_t k = coord_key(ring[i].first, ring[i].second);
   176→        if (++seen[k] > 1) { has_dup = true; break; }
   177→    }
   178→    ASSERT_TRUE(has_dup);
   179→    PASS();
   180→}
   181→
   182→void test_no_duplicate_coords_simple_ring() {
   183→    TEST("no duplicate coords in simple ring");
   184→    std::vector<std::pair<double,double>> ring = {
   185→        {0, 0}, {1, 0}, {1, 1}, {0, 1}, {0, 0}
   186→    };
   187→    std::unordered_map<int64_t, int> seen;
   188→    bool has_dup = false;
   189→    for (size_t i = 0; i + 1 < ring.size(); i++) {
   190→        int64_t k = coord_key(ring[i].first, ring[i].second);
   191→        if (++seen[k] > 1) { has_dup = true; break; }
   192→    }
   193→    ASSERT_TRUE(!has_dup);
   194→    PASS();
   195→}
   196→
   197→void test_border_line_name_pattern() {
   198→    TEST("border-line relation name detection");
   199→    // Level 2 relations with " - " or " — " in name are border lines, not countries
   200→    auto is_border_line = [](const std::string& name) {
   201→        return name.find(" - ") != std::string::npos ||
   202→               name.find(" \xe2\x80\x94 ") != std::string::npos; // em dash
   203→    };
   204→    ASSERT_TRUE(is_border_line("Deutschland - Österreich"));
   205→    ASSERT_TRUE(is_border_line("France - Deutschland"));
   206→    ASSERT_TRUE(is_border_line("Deutschland \xe2\x80\x94 Schweiz / Suisse / Svizerra"));
   207→    ASSERT_TRUE(!is_border_line("Deutschland"));
   208→    ASSERT_TRUE(!is_border_line("Akrotiri and Dhekelia")); // not a border line
   209→    ASSERT_TRUE(!is_border_line("Österreich"));
   210→    PASS();
   211→}
   212→
   213→void test_string_separator_bug() {
   214→    TEST("string null separator (the \\0 bug)");
   215→    // The original bug: std::string("x") + "\0" doesn't embed a null
   216→    std::string bad = std::string("42") + "\0" + std::string("Main St");
   217→    // This produces "42Main St" — the \0 is an empty string in the concatenation
   218→    // The fix uses std::pair<std::string, std::string> instead
   219→
   220→    // Verify the bug exists
   221→    ASSERT_EQ(bad, std::string("42Main St")); // NOT "42\0Main St"
   222→    ASSERT_EQ(bad.size(), 9u); // "42Main St" = 9 chars (no null embedded)
   223→
   224→    // Verify the fix: pair stores them separately
   225→    std::pair<std::string, std::string> fixed = {"42", "Main St"};
   226→    ASSERT_EQ(fixed.first, "42");
   227→    ASSERT_EQ(fixed.second, "Main St");
   228→    PASS();
   229→}
   230→
   231→void test_cell_item_pair_sorting() {
   232→    TEST("CellItemPair sorting by cell_id then item_id");
   233→    std::vector<CellItemPair> pairs = {
   234→        {100, 3}, {50, 1}, {100, 1}, {50, 2}, {100, 2}
   235→    };
   236→    std::sort(pairs.begin(), pairs.end(), [](const CellItemPair& a, const CellItemPair& b) {
   237→        return a.cell_id < b.cell_id || (a.cell_id == b.cell_id && a.item_id < b.item_id);
   238→    });
   239→    ASSERT_EQ(pairs[0].cell_id, 50u);
   240→    ASSERT_EQ(pairs[0].item_id, 1u);
   241→    ASSERT_EQ(pairs[1].cell_id, 50u);
   242→    ASSERT_EQ(pairs[1].item_id, 2u);
   243→    ASSERT_EQ(pairs[2].cell_id, 100u);
   244→    ASSERT_EQ(pairs[2].item_id, 1u);
   245→    PASS();
   246→}
   247→
   248→void test_parallel_merge_join_correctness() {
   249→    TEST("merge-join produces correct offsets");
   250→    // Simulate: sorted_cells has cells 10,20,30,40,50
   251→    // sorted_pairs has items for cells 10,30,50
   252→    std::vector<uint64_t> sorted_cells = {10, 20, 30, 40, 50};
   253→    std::vector<CellItemPair> sorted_pairs = {
   254→        {10, 1}, {10, 2},
   255→        {30, 3},
   256→        {50, 4}, {50, 5}, {50, 6}
   257→    };
   258→
   259→    // Simulate merge-join
   260→    const uint32_t NO_DATA = 0xFFFFFFFF;
   261→    std::vector<uint32_t> offsets(sorted_cells.size(), NO_DATA);
   262→    uint32_t current = 0;
   263→    size_t pi = 0;
   264→    for (uint32_t si = 0; si < sorted_cells.size() && pi < sorted_pairs.size(); si++) {
   265→        if (sorted_cells[si] < sorted_pairs[pi].cell_id) continue;
   266→        while (pi < sorted_pairs.size() && sorted_pairs[pi].cell_id < sorted_cells[si]) pi++;
   267→        if (pi >= sorted_pairs.size() || sorted_pairs[pi].cell_id != sorted_cells[si]) continue;
   268→        offsets[si] = current;
   269→        size_t start = pi;
   270→        while (pi < sorted_pairs.size() && sorted_pairs[pi].cell_id == sorted_cells[si]) pi++;
   271→        current += sizeof(uint16_t) + (pi - start) * sizeof(uint32_t);
   272→    }
   273→
   274→    // Cell 10 (index 0) should have offset 0
   275→    ASSERT_EQ(offsets[0], 0u);
   276→    // Cell 20 (index 1) has no items
   277→    ASSERT_EQ(offsets[1], NO_DATA);
   278→    // Cell 30 (index 2) should have offset after cell 10's data
   279→    uint32_t expected_30 = sizeof(uint16_t) + 2 * sizeof(uint32_t); // count(2) + 2 items
   280→    ASSERT_EQ(offsets[2], expected_30);
   281→    // Cell 40 (index 3) has no items
   282→    ASSERT_EQ(offsets[3], NO_DATA);
   283→    // Cell 50 (index 4) should have offset after cells 10+30
   284→    uint32_t expected_50 = expected_30 + sizeof(uint16_t) + 1 * sizeof(uint32_t);
   285→    ASSERT_EQ(offsets[4], expected_50);
   286→    PASS();
   287→}
   288→
   289→void test_ring_area_computation() {
   290→    TEST("ring area computation (shoelace formula)");
   291→    // Unit square: area should be 1.0
   292→    std::vector<std::pair<double,double>> square = {
   293→        {0, 0}, {1, 0}, {1, 1}, {0, 1}, {0, 0}
   294→    };
   295→    double area = 0;
   296→    for (size_t i = 0, n = square.size(); i < n; i++) {
   297→        size_t j = (i + 1) % n;
   298→        area += square[i].first * square[j].second;
   299→        area -= square[j].first * square[i].second;
   300→    }
   301→    area = std::abs(area) / 2.0;
   302→    ASSERT_TRUE(std::abs(area - 1.0) < 1e-10);
   303→    PASS();
   304→}
   305→
   306→// --- Main ---
   307→
   308→int main() {
   309→    std::cerr << "Running ring assembly unit tests..." << std::endl;
   310→
   311→    test_coord_key_basic();
   312→    test_coord_key_split_nodes();
   313→    test_coord_key_negative_coords();
   314→    test_self_intersection_simple_square();
   315→    test_self_intersection_bowtie();
   316→    test_duplicate_coords_detection();
   317→    test_no_duplicate_coords_simple_ring();
   318→    test_border_line_name_pattern();
   319→    test_string_separator_bug();
   320→    test_cell_item_pair_sorting();
   321→    test_parallel_merge_join_correctness();
   322→    test_ring_area_computation();
   323→
   324→    std::cerr << std::endl;
   325→    std::cerr << tests_passed << "/" << tests_run << " tests passed." << std::endl;
   326→
   327→    return (tests_passed == tests_run) ? 0 : 1;
   328→}
   329→


   200→
   201→private:
   202→    void worker_loop() {
   203→        size_t my_idx;
   204→        {
   205→            std::lock_guard<std::mutex> lock(mutex_);
   206→            my_idx = thread_results_.size();
   207→            thread_results_.emplace_back();
   208→        }
   209→
   210→        while (true) {
   211→            WorkItem item;
   212→            {
   213→                std::unique_lock<std::mutex> lock(mutex_);
   214→                cv_.wait(lock, [this]() { return stop_ || !queue_.empty(); });
   215→                if (stop_ && queue_.empty()) return;
   216→                item = std::move(queue_.front());
   217→                queue_.pop_front();
   218→                active_workers_++;
   219→            }
   220→
   221→            // Compute S2 covering (no locks needed — pure computation)
   222→            auto cell_ids = cover_polygon(item.vertices);
   223→            auto& local = thread_results_[my_idx];
   224→            for (const auto& [cell_id, is_interior] : cell_ids) {
   225→                uint32_t entry = is_interior ? (item.poly_id | INTERIOR_FLAG) : item.poly_id;
   226→                local[cell_id.id()].push_back(entry);
   227→            }
   228→
   229→            {
   230→                std::lock_guard<std::mutex> lock(mutex_);
   231→                active_workers_--;
   232→            }
   233→            done_cv_.notify_all();
   234→        }
   235→    }
   236→
   237→    std::vector<std::thread> workers_;
   238→    std::deque<WorkItem> queue_;
   239→    std::vector<std::unordered_map<uint64_t, std::vector<uint32_t>>> thread_results_;
   240→    std::mutex mutex_;
   241→    std::condition_variable cv_;
   242→    std::condition_variable done_cv_;
   243→    size_t active_workers_ = 0;
   244→    bool stop_;
   245→};
   246→
   247→// --- Parsed data container ---
   248→
   249→// Sorted cell-item pair for direct entry writing (avoids hash map)
   250→struct CellItemPair { uint64_t cell_id; uint32_t item_id; };
   251→
   252→struct ParsedData {
   253→    StringPool string_pool;
   254→    std::vector<WayHeader> ways;
   255→    std::vector<NodeCoord> street_nodes;
   256→    std::unordered_map<uint64_t, std::vector<uint32_t>> cell_to_ways;
   257→    std::vector<AddrPoint> addr_points;
   258→    std::unordered_map<uint64_t, std::vector<uint32_t>> cell_to_addrs;
   259→    std::vector<InterpWay> interp_ways;
   260→    std::vector<NodeCoord> interp_nodes;
   261→    std::unordered_map<uint64_t, std::vector<uint32_t>> cell_to_interps;
   262→    std::vector<AdminPolygon> admin_polygons;
   263→    std::vector<NodeCoord> admin_vertices;
   264→    std::unordered_map<uint64_t, std::vector<uint32_t>> cell_to_admin;
   265→
   266→    // Deferred work for parallel S2 computation (ways + interps)
   267→    std::vector<DeferredWay> deferred_ways;
   268→    std::vector<DeferredInterp> deferred_interps;
   269→
   270→    // Sorted (cell_id, item_id) pairs — kept for direct entry writing
   271→    std::vector<CellItemPair> sorted_way_cells;
   272→    std::vector<CellItemPair> sorted_addr_cells;
   273→
   274→    // Collected data for parallel admin assembly
   275→    std::vector<CollectedRelation> collected_relations;
   276→    struct WayGeometry {
   277→        std::vector<std::pair<double,double>> coords;
   278→        int64_t first_node_id;
   279→        int64_t last_node_id;
   280→    };
   281→    std::unordered_map<int64_t, WayGeometry> way_geometries;
   282→};
   283→
   284→// --- S2 helpers ---
   285→
   286→static int kStreetCellLevel = 17;
   287→static int kAdminCellLevel = 10;
   288→static int kMaxAdminLevel = 0;  // 0 means no filtering
   289→
   290→// Fast cover_edge: compute cells covered by a line segment.
   291→// Fast path for short edges (same cell or adjacent) avoids S2RegionCoverer entirely.
   292→// ~80% of street edges are within 1-2 cells, so this eliminates most S2 library calls.
   293→static void cover_edge(double lat1, double lng1, double lat2, double lng2,
   294→                        std::vector<S2CellId>& out) {
   295→    out.clear();
   296→
   297→    // Fast: compute cell IDs directly from coordinates
   298→    S2CellId c1 = S2CellId(S2LatLng::FromDegrees(lat1, lng1)).parent(kStreetCellLevel);
   299→    S2CellId c2 = S2CellId(S2LatLng::FromDegrees(lat2, lng2)).parent(kStreetCellLevel);
   300→
   301→    // Same cell — most common case for short edges
   302→    if (c1 == c2) {
   303→        out.push_back(c1);
   304→        return;
   305→    }
   306→
   307→    // Adjacent cells — check midpoint
   308→    S2CellId cm = S2CellId(S2LatLng::FromDegrees((lat1+lat2)/2, (lng1+lng2)/2)).parent(kStreetCellLevel);
   309→    if (cm == c1 || cm == c2) {
   310→        out.push_back(c1);
   311→        out.push_back(c2);
   312→        return;
   313→    }
   314→
   315→    // Longer edge — use S2RegionCoverer for correctness (handles geodesic curves)
   316→    thread_local S2RegionCoverer coverer = []() {
   317→        S2RegionCoverer::Options options;
   318→        options.set_fixed_level(kStreetCellLevel);
   319→        return S2RegionCoverer(options);
   320→    }();
   321→
   322→    S2Point p1 = S2LatLng::FromDegrees(lat1, lng1).ToPoint();
   323→    S2Point p2 = S2LatLng::FromDegrees(lat2, lng2).ToPoint();
   324→    if (p1 == p2) { out.push_back(c1); return; }
   325→
   326→    std::vector<S2Point> points = {p1, p2};
   327→    S2Polyline polyline(points);
   328→    S2CellUnion covering = coverer.GetCovering(polyline);
   329→    out = std::move(covering.cell_ids());
   330→}
   331→
   332→// Legacy overload for code that still uses return-by-value
   333→static std::vector<S2CellId> cover_edge(double lat1, double lng1, double lat2, double lng2) {
   334→    std::vector<S2CellId> result;
   335→    cover_edge(lat1, lng1, lat2, lng2, result);
   336→    return result;
   337→}
   338→
   339→static S2CellId point_to_cell(double lat, double lng) {
   340→    return S2CellId(S2LatLng::FromDegrees(lat, lng)).parent(kStreetCellLevel);
   341→}
   342→
   343→// Approximate polygon area in square degrees
   344→static float polygon_area(const std::vector<std::pair<double,double>>& vertices) {
   345→    double area = 0;
   346→    size_t n = vertices.size();
   347→    for (size_t i = 0; i < n; i++) {
   348→        size_t j = (i + 1) % n;
   349→        area += vertices[i].first * vertices[j].second;
   350→        area -= vertices[j].first * vertices[i].second;
   351→    }
   352→    return static_cast<float>(std::fabs(area) / 2.0);
   353→}
   354→
   355→// Returns pairs of (cell_id, is_interior)
   356→static std::vector<std::pair<S2CellId, bool>> cover_polygon(const std::vector<std::pair<double,double>>& vertices) {
   357→    std::vector<S2Point> points;
   358→    points.reserve(vertices.size());
   359→    for (const auto& [lat, lng] : vertices) {
   360→        S2Point p = S2LatLng::FromDegrees(lat, lng).ToPoint();
   361→        if (!points.empty() && points.back() == p) continue;
   362→        points.push_back(p);
   363→    }
   364→    // Remove closing duplicate if first == last
   365→    if (points.size() > 1 && points.front() == points.back()) {
   366→        points.pop_back();
   367→    }
   368→    if (points.size() < 3) return {};
   369→
   370→    // Build S2Loop (must be CCW), skip invalid polygons
   371→    S2Error error;
   372→    auto loop = std::make_unique<S2Loop>(points, S2Debug::DISABLE);
   373→    loop->Normalize();
   374→    if (loop->FindValidationError(&error)) return {};
   375→
   376→    S2Polygon polygon(std::move(loop));
   377→
   378→    S2RegionCoverer::Options options;
   379→    options.set_max_level(kAdminCellLevel);
   380→    options.set_max_cells(200);
   381→
   382→    S2RegionCoverer coverer(options);
   383→    S2CellUnion covering = coverer.GetCovering(polygon);
   384→    S2CellUnion interior = coverer.GetInteriorCovering(polygon);
   385→
   386→    // Build set of interior cell IDs for fast lookup
   387→    std::unordered_set<uint64_t> interior_set;
   388→    for (const auto& cell : interior.cell_ids()) {
   389→        if (cell.level() <= kAdminCellLevel) {
   390→            auto begin = cell.range_min().parent(kAdminCellLevel);
   391→            auto end = cell.range_max().parent(kAdminCellLevel);
   392→            for (auto c = begin; c != end; c = c.next()) {
   393→                interior_set.insert(c.id());
   394→            }
   395→            interior_set.insert(end.id());
   396→        } else {
   397→            interior_set.insert(cell.parent(kAdminCellLevel).id());
   398→        }
   399→    }
   400→
   401→    // Normalize all covering cells to kAdminCellLevel
   402→    std::vector<std::pair<S2CellId, bool>> result;
   403→    for (const auto& cell : covering.cell_ids()) {
   404→        if (cell.level() <= kAdminCellLevel) {
   405→            auto begin = cell.range_min().parent(kAdminCellLevel);
   406→            auto end = cell.range_max().parent(kAdminCellLevel);
   407→            for (auto c = begin; c != end; c = c.next()) {
   408→                result.emplace_back(c, interior_set.count(c.id()) > 0);
   409→            }
   410→            result.emplace_back(end, interior_set.count(end.id()) > 0);
   411→        } else {
   412→            auto parent = cell.parent(kAdminCellLevel);
   413→            result.emplace_back(parent, interior_set.count(parent.id()) > 0);
   414→        }
   415→    }
   416→
   417→    // Deduplicate by cell_id, keeping interior=true if any duplicate is interior
   418→    std::sort(result.begin(), result.end(), [](const auto& a, const auto& b) {
   419→        return a.first < b.first;
   420→    });
   421→    auto it = result.begin();
   422→    for (auto curr = result.begin(); curr != result.end(); ) {
   423→        auto next = curr + 1;
   424→        bool is_interior = curr->second;
   425→        while (next != result.end() && next->first == curr->first) {
   426→            is_interior = is_interior || next->second;
   427→            ++next;
   428→        }
   429→        *it = {curr->first, is_interior};
   430→        ++it;
   431→        curr = next;
   432→    }
   433→    result.erase(it, result.end());
   434→    return result;
   435→}
   436→
   437→// --- Douglas-Peucker simplification ---
   438→
   439→static void dp_simplify(const std::vector<std::pair<double,double>>& pts,
   440→                        size_t start, size_t end, double epsilon,
   441→                        std::vector<bool>& keep) {
   442→    if (end <= start + 1) return;
   443→
   444→    double max_dist = 0;
   445→    size_t max_idx = start;
   446→
   447→    double ax = pts[start].first, ay = pts[start].second;
   448→    double bx = pts[end].first, by = pts[end].second;
   449→    double dx = bx - ax, dy = by - ay;
   450→    double len_sq = dx * dx + dy * dy;
   451→
   452→    for (size_t i = start + 1; i < end; i++) {
   453→        double px = pts[i].first - ax, py = pts[i].second - ay;
   454→        double dist;
   455→        if (len_sq == 0) {
   456→            dist = std::sqrt(px * px + py * py);
   457→        } else {
   458→            double t = std::max(0.0, std::min(1.0, (px * dx + py * dy) / len_sq));
   459→            double proj_x = t * dx - px, proj_y = t * dy - py;
   460→            dist = std::sqrt(proj_x * proj_x + proj_y * proj_y);
   461→        }
   462→        if (dist > max_dist) {
   463→            max_dist = dist;
   464→            max_idx = i;
   465→        }
   466→    }
   467→
   468→    if (max_dist > epsilon) {
   469→        keep[max_idx] = true;
   470→        dp_simplify(pts, start, max_idx, epsilon, keep);
   471→        dp_simplify(pts, max_idx, end, epsilon, keep);
   472→    }
   473→}
   474→
   475→static std::vector<std::pair<double,double>> simplify_polygon(
   476→    const std::vector<std::pair<double,double>>& pts, size_t max_vertices) {
   477→    if (pts.size() <= max_vertices) return pts;
   478→
   479→    // Binary search for epsilon that gives ~max_vertices
   480→    double lo = 0, hi = 1.0;
   481→    std::vector<std::pair<double,double>> result;
   482→
   483→    for (int iter = 0; iter < 20; iter++) {
   484→        double epsilon = (lo + hi) / 2;
   485→        std::vector<bool> keep(pts.size(), false);
   486→        keep[0] = true;
   487→        keep[pts.size() - 1] = true;
   488→        dp_simplify(pts, 0, pts.size() - 1, epsilon, keep);
   489→
   490→        size_t count = 0;
   491→        for (bool k : keep) if (k) count++;
   492→
   493→        if (count > max_vertices) {
   494→            lo = epsilon;
   495→        } else {
   496→            hi = epsilon;
   497→        }
   498→    }
   499→
   500→    std::vector<bool> keep(pts.size(), false);
   501→    keep[0] = true;
   502→    keep[pts.size() - 1] = true;
   503→    dp_simplify(pts, 0, pts.size() - 1, hi, keep);
   504→
   505→    result.clear();
   506→    for (size_t i = 0; i < pts.size(); i++) {
   507→        if (keep[i]) result.push_back(pts[i]);
   508→    }
   509→    return result;
   510→}
   511→
   512→// --- Ring self-intersection check (matches osmium's segment crossing detection) ---
   513→
   514→static bool segments_intersect(double ax1, double ay1, double ax2, double ay2,
   515→                                double bx1, double by1, double bx2, double by2) {
   516→    // Check if segment (a1,a2) crosses segment (b1,b2)
   517→    // Using cross product orientation test
   518→    auto cross = [](double ox, double oy, double ax, double ay, double bx, double by) -> double {
   519→        return (ax - ox) * (by - oy) - (ay - oy) * (bx - ox);
   520→    };
   521→    double d1 = cross(bx1, by1, bx2, by2, ax1, ay1);
   522→    double d2 = cross(bx1, by1, bx2, by2, ax2, ay2);
   523→    double d3 = cross(ax1, ay1, ax2, ay2, bx1, by1);
   524→    double d4 = cross(ax1, ay1, ax2, ay2, bx2, by2);
   525→    if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
   526→        ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
   527→        return true;
   528→    }
   529→    return false;
   530→}
   531→
   532→static bool ring_has_self_intersection(const std::vector<std::pair<double,double>>& ring) {
   533→    // Sweep-line inspired check: sort segments by min-x, then only check
   534→    // overlapping x-ranges. Much faster than O(n²) for large rings.
   535→    size_t n = ring.size();
   536→    if (n < 4) return false;
   537→
   538→    // For small rings, brute force is fine
   539→    if (n <= 32) {
   540→        for (size_t i = 0; i + 1 < n; i++) {
   541→            for (size_t j = i + 2; j + 1 < n; j++) {
   542→                if (j == i + 1 || (i == 0 && j == n - 2)) continue;
   543→                if (segments_intersect(ring[i].first, ring[i].second,
   544→                                        ring[i+1].first, ring[i+1].second,
   545→                                        ring[j].first, ring[j].second,
   546→                                        ring[j+1].first, ring[j+1].second))
   547→                    return true;
   548→            }
   549→        }
   550→        return false;
   551→    }
   552→
   553→    // For larger rings, sort segments by min-x and prune non-overlapping
   554→    struct Seg { double min_x, max_x; size_t idx; };
   555→    std::vector<Seg> segs(n - 1);
   556→    for (size_t i = 0; i + 1 < n; i++) {
   557→        double x1 = ring[i].second, x2 = ring[i+1].second; // use lng as x
   558→        segs[i] = {std::min(x1,x2), std::max(x1,x2), i};
   559→    }
   560→    std::sort(segs.begin(), segs.end(), [](const Seg& a, const Seg& b) {
   561→        return a.min_x < b.min_x;
   562→    });
   563→
   564→    for (size_t a = 0; a + 1 < segs.size(); a++) {
   565→        for (size_t b = a + 1; b < segs.size(); b++) {
   566→            if (segs[b].min_x > segs[a].max_x) break; // no more x-overlap
   567→            size_t i = segs[a].idx, j = segs[b].idx;
   568→            // Skip adjacent segments
   569→            if (j == i + 1 || i == j + 1) continue;
   570→            if ((i == 0 && j == n - 2) || (j == 0 && i == n - 2)) continue;
   571→            if (segments_intersect(ring[i].first, ring[i].second,
   572→                                    ring[i+1].first, ring[i+1].second,
   573→                                    ring[j].first, ring[j].second,
   574→                                    ring[j+1].first, ring[j+1].second))
   575→                return true;
   576→        }
   577→    }
   578→    return false;
   579→}
   580→
   581→// Check if a ring has duplicate coordinates (spikes/figure-8 from merged holes)
   582→// A valid simple polygon visits each coordinate exactly once (except closing point).
   583→static bool ring_has_duplicate_coords(const std::vector<std::pair<double,double>>& ring,
   584→                                       decltype(std::function<int64_t(double,double)>()) coord_key_fn) {
   585→    std::unordered_set<int64_t> seen;
   586→    for (size_t i = 0; i + 1 < ring.size(); i++) { // skip closing point
   587→        int64_t k = coord_key_fn(ring[i].first, ring[i].second);
   588→        if (!seen.insert(k).second) return true; // duplicate
   589→    }
   590→    return false;
   591→}
   592→
   593→// --- Parallel admin: assemble outer rings from collected way geometries ---
   594→
   595→static std::vector<std::vector<std::pair<double,double>>> assemble_outer_rings(
   596→    const std::vector<std::pair<int64_t, std::string>>& members,
   597→    const std::unordered_map<int64_t, ParsedData::WayGeometry>& way_geoms,
   598→    bool include_all_roles = false)
   599→{


     1→#!/bin/bash
     2→# Integration test: build Monaco PBF with both parallel and sequential
     3→# admin assembly, verify identical output for ways/addrs/interps,
     4→# and check admin polygon counts are within expected range.
     5→#
     6→# Usage: ./test_integration.sh <build-index-binary> [monaco.osm.pbf]
     7→#
     8→# Downloads Monaco PBF if not provided.
     9→
    10→set -e
    11→
    12→BINARY="${1:?Usage: $0 <build-index-binary> [monaco.osm.pbf]}"
    13→PBF="${2:-}"
    14→COMPARE="${3:-}"
    15→TMPDIR=$(mktemp -d)
    16→
    17→cleanup() { rm -rf "$TMPDIR"; }
    18→trap cleanup EXIT
    19→
    20→# Download Monaco if needed
    21→if [ -z "$PBF" ]; then
    22→    echo "Downloading Monaco PBF..."
    23→    wget -q -O "$TMPDIR/monaco.osm.pbf" "https://download.geofabrik.de/europe/monaco-latest.osm.pbf"
    24→    PBF="$TMPDIR/monaco.osm.pbf"
    25→fi
    26→
    27→# Build compare tool if not provided
    28→if [ -z "$COMPARE" ]; then
    29→    COMPARE="$TMPDIR/compare_indexes"
    30→    SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
    31→    echo "Building comparison tool..."
    32→    g++ -O2 -std=c++17 -o "$COMPARE" "$SCRIPT_DIR/compare_indexes.cpp"
    33→fi
    34→
    35→echo "=== Integration Test ==="
    36→echo "Binary: $BINARY"
    37→echo "PBF: $PBF"
    38→echo ""
    39→
    40→# Test 1: Build with parallel admin assembly
    41→echo "--- Test 1: Parallel admin assembly ---"
    42→mkdir -p "$TMPDIR/parallel"
    43→"$BINARY" "$TMPDIR/parallel" "$PBF" --parallel-admin 2>&1 | grep -E 'polygon|Done|street ways|address'
    44→
    45→# Test 2: Build with sequential admin assembly
    46→echo ""
    47→echo "--- Test 2: Sequential admin assembly ---"
    48→mkdir -p "$TMPDIR/sequential"
    49→"$BINARY" "$TMPDIR/sequential" "$PBF" --no-parallel-admin 2>&1 | grep -E 'polygon|Done|street ways|address'
    50→
    51→# Test 3: Compare outputs
    52→echo ""
    53→echo "--- Test 3: Exact comparison ---"
    54→"$COMPARE" "$TMPDIR/parallel" "$TMPDIR/sequential" 2>&1
    55→
    56→# Test 4: Semantic comparison
    57→echo ""
    58→echo "--- Test 4: Semantic comparison ---"
    59→"$COMPARE" "$TMPDIR/parallel" "$TMPDIR/sequential" --semantic 2>&1
    60→
    61→# Test 5: Verify all index files exist
    62→echo ""
    63→echo "--- Test 5: Index files present ---"
    64→EXPECTED_FILES="admin_cells.bin admin_entries.bin admin_polygons.bin admin_vertices.bin \
    65→addr_entries.bin addr_points.bin geo_cells.bin interp_entries.bin interp_nodes.bin \
    66→interp_ways.bin street_entries.bin street_nodes.bin street_ways.bin strings.bin"
    67→ALL_PRESENT=true
    68→for f in $EXPECTED_FILES; do
    69→    if [ ! -f "$TMPDIR/parallel/$f" ]; then
    70→        echo "MISSING: $f"
    71→        ALL_PRESENT=false
    72→    fi
    73→done
    74→if $ALL_PRESENT; then
    75→    echo "All 14 index files present."
    76→fi
    77→
    78→# Test 6: Verify files are non-empty (except interp which can be empty for Monaco)
    79→echo ""
    80→echo "--- Test 6: Non-empty files ---"
    81→for f in admin_cells.bin admin_polygons.bin geo_cells.bin street_ways.bin street_nodes.bin addr_points.bin strings.bin; do
    82→    SIZE=$(stat --format='%s' "$TMPDIR/parallel/$f" 2>/dev/null || echo 0)
    83→    if [ "$SIZE" -eq 0 ]; then
    84→        echo "FAIL: $f is empty"
    85→        ALL_PRESENT=false
    86→    fi
    87→done
    88→if $ALL_PRESENT; then
    89→    echo "All required files non-empty."
    90→fi
    91→
    92→echo ""
    93→echo "=== Integration test complete ==="
    94→


     1→#!/bin/bash
     2→# Test that admin boundary processing produces identical output
     3→# regardless of whether osmium's multipolygon assembler or our
     4→# custom parallel assembler is used.
     5→#
     6→# Usage: ./test_admin_assembly.sh <build-index-binary> <test.osm.pbf>
     7→#
     8→# Downloads Monaco PBF if no test file provided.
     9→
    10→set -e
    11→
    12→BINARY="${1:?Usage: $0 <build-index-binary> [test.osm.pbf]}"
    13→PBF="${2:-}"
    14→TMPDIR=$(mktemp -d)
    15→
    16→cleanup() { rm -rf "$TMPDIR"; }
    17→trap cleanup EXIT
    18→
    19→# Download Monaco PBF if none provided
    20→if [ -z "$PBF" ]; then
    21→    PBF="$TMPDIR/monaco.osm.pbf"
    22→    echo "Downloading Monaco PBF..."
    23→    curl -fSL -o "$PBF" "https://download.geofabrik.de/europe/monaco-latest.osm.pbf" 2>/dev/null
    24→fi
    25→
    26→echo "=== Test 1: Full build produces valid output ==="
    27→mkdir -p "$TMPDIR/full"
    28→"$BINARY" "$TMPDIR/full" "$PBF" > "$TMPDIR/full.log" 2>&1
    29→echo "Full build: $(ls "$TMPDIR/full/"*.bin | wc -l) files"
    30→
    31→# Check all expected files exist
    32→for f in geo_cells street_entries street_ways street_nodes addr_entries addr_points \
    33→         interp_entries interp_ways interp_nodes admin_cells admin_entries \
    34→         admin_polygons admin_vertices strings; do
    35→    if [ ! -f "$TMPDIR/full/${f}.bin" ]; then
    36→        echo "FAIL: missing $f.bin"
    37→        exit 1
    38→    fi
    39→done
    40→echo "PASS: All 14 index files present"
    41→
    42→# Check admin data is non-empty
    43→ADMIN_SIZE=$(stat -c%s "$TMPDIR/full/admin_polygons.bin" 2>/dev/null || stat -f%z "$TMPDIR/full/admin_polygons.bin")
    44→if [ "$ADMIN_SIZE" -eq 0 ]; then
    45→    echo "FAIL: admin_polygons.bin is empty"
    46→    exit 1
    47→fi
    48→echo "PASS: admin_polygons.bin is ${ADMIN_SIZE} bytes"
    49→
    50→ADMIN_VERTS=$(stat -c%s "$TMPDIR/full/admin_vertices.bin" 2>/dev/null || stat -f%z "$TMPDIR/full/admin_vertices.bin")
    51→echo "PASS: admin_vertices.bin is ${ADMIN_VERTS} bytes"
    52→
    53→echo ""
    54→echo "=== Test 2: Multi-output produces all 3 modes ==="
    55→mkdir -p "$TMPDIR/multi"
    56→"$BINARY" "$TMPDIR/multi" "$PBF" --multi-output > "$TMPDIR/multi.log" 2>&1
    57→
    58→for mode in full no-addresses admin-only; do
    59→    if [ ! -d "$TMPDIR/multi/$mode" ]; then
    60→        echo "FAIL: missing $mode directory"
    61→        exit 1
    62→    fi
    63→    if [ ! -f "$TMPDIR/multi/$mode/admin_cells.bin" ]; then
    64→        echo "FAIL: missing $mode/admin_cells.bin"
    65→        exit 1
    66→    fi
    67→done
    68→echo "PASS: All 3 output modes present"
    69→
    70→# Admin data should be identical across all modes
    71→for f in admin_cells admin_entries admin_polygons admin_vertices; do
    72→    if ! diff -q "$TMPDIR/multi/full/${f}.bin" "$TMPDIR/multi/no-addresses/${f}.bin" > /dev/null 2>&1; then
    73→        echo "FAIL: ${f}.bin differs between full and no-addresses"
    74→        exit 1
    75→    fi
    76→    if ! diff -q "$TMPDIR/multi/full/${f}.bin" "$TMPDIR/multi/admin-only/${f}.bin" > /dev/null 2>&1; then
    77→        echo "FAIL: ${f}.bin differs between full and admin-only"
    78→        exit 1
    79→    fi
    80→done
    81→echo "PASS: Admin data identical across all modes"
    82→
    83→# Full mode should have geo files, admin-only should not
    84→if [ ! -f "$TMPDIR/multi/full/geo_cells.bin" ]; then
    85→    echo "FAIL: full mode missing geo_cells.bin"
    86→    exit 1
    87→fi
    88→if [ -f "$TMPDIR/multi/admin-only/geo_cells.bin" ]; then
    89→    echo "FAIL: admin-only mode should not have geo_cells.bin"
    90→    exit 1
    91→fi
    92→echo "PASS: Correct files per mode"
    93→
    94→echo ""
    95→echo "=== Test 3: Cache round-trip produces identical output ==="
    96→mkdir -p "$TMPDIR/cached"
    97→"$BINARY" "$TMPDIR/cached" "$PBF" --save-cache "$TMPDIR/test.cache" > "$TMPDIR/cached.log" 2>&1
    98→
    99→mkdir -p "$TMPDIR/from-cache"
   100→"$BINARY" "$TMPDIR/from-cache" --load-cache "$TMPDIR/test.cache" > "$TMPDIR/from-cache.log" 2>&1
   101→
   102→DIFF_COUNT=0
   103→for f in "$TMPDIR/cached/"*.bin; do
   104→    fname=$(basename "$f")
   105→    if ! diff -q "$f" "$TMPDIR/from-cache/$fname" > /dev/null 2>&1; then
   106→        echo "FAIL: $fname differs between direct and cache-loaded build"
   107→        DIFF_COUNT=$((DIFF_COUNT + 1))
   108→    fi
   109→done
   110→if [ "$DIFF_COUNT" -eq 0 ]; then
   111→    echo "PASS: Cache round-trip produces identical output"
   112→else
   113→    echo "FAIL: $DIFF_COUNT files differ"
   114→    exit 1
   115→fi
   116→
   117→echo ""
   118→echo "=== Test 4: Continent filtering produces valid subsets ==="
   119→mkdir -p "$TMPDIR/continents"
   120→"$BINARY" "$TMPDIR/continents" "$PBF" --continents > "$TMPDIR/continents.log" 2>&1
   121→
   122→# Monaco is in Europe — check that europe has admin data
   123→if [ -d "$TMPDIR/continents/europe" ]; then
   124→    EUR_ADMIN=$(stat -c%s "$TMPDIR/continents/europe/admin_polygons.bin" 2>/dev/null || stat -f%z "$TMPDIR/continents/europe/admin_polygons.bin")
   125→    if [ "$EUR_ADMIN" -gt 0 ]; then
   126→        echo "PASS: Europe continent has admin data (${EUR_ADMIN} bytes)"
   127→    else
   128→        echo "FAIL: Europe continent admin_polygons.bin is empty"
   129→        exit 1
   130→    fi
   131→else
   132→    echo "WARN: No europe directory (Monaco may not match bounding box)"
   133→fi
   134→
   135→echo ""
   136→echo "=== Test 5: Admin boundary data integrity ==="
   137→# Verify admin polygon struct sizes are consistent
   138→# AdminPolygon is 24 bytes, NodeCoord is 8 bytes
   139→POLY_SIZE=$(stat -c%s "$TMPDIR/full/admin_polygons.bin" 2>/dev/null || stat -f%z "$TMPDIR/full/admin_polygons.bin")
   140→VERT_SIZE=$(stat -c%s "$TMPDIR/full/admin_vertices.bin" 2>/dev/null || stat -f%z "$TMPDIR/full/admin_vertices.bin")
   141→
   142→POLY_COUNT=$((POLY_SIZE / 24))
   143→VERT_COUNT=$((VERT_SIZE / 8))
   144→
   145→if [ $((POLY_SIZE % 24)) -ne 0 ]; then
   146→    echo "FAIL: admin_polygons.bin size ($POLY_SIZE) not divisible by 24"
   147→    exit 1
   148→fi
   149→if [ $((VERT_SIZE % 8)) -ne 0 ]; then
   150→    echo "FAIL: admin_vertices.bin size ($VERT_SIZE) not divisible by 8"
   151→    exit 1
   152→fi
   153→echo "PASS: $POLY_COUNT polygons, $VERT_COUNT vertices (struct alignment correct)"
   154→
   155→echo ""
   156→echo "=== ALL TESTS PASSED ==="
   157→


   599→{
   600→    // Coordinate-based ring assembly: split ways at shared internal nodes,
   601→    // then stitch sub-ways at endpoints with backtracking.
   602→    // This matches osmium's segment-level approach.
   603→
   604→    // Coordinate key matching osmium's Location (int32_t nanodegrees)
   605→    auto coord_key = [](double lat, double lng) -> int64_t {
   606→        int32_t ilat = static_cast<int32_t>(lat * 1e7 + (lat >= 0 ? 0.5 : -0.5));
   607→        int32_t ilng = static_cast<int32_t>(lng * 1e7 + (lng >= 0 ? 0.5 : -0.5));
   608→        return (static_cast<int64_t>(ilat) << 32) | static_cast<uint32_t>(ilng);
   609→    };
   610→
   611→    // Collect coordinates from member ways.
   612→    // First pass uses outer/empty-role ways only. If that produces 0 rings,
   613→    // retry with ALL ways (matching osmium's check_roles=false fallback) —
   614→    // inner ways sometimes bridge gaps between outer ways.
   615→    std::unordered_map<int64_t, int> coord_count;
   616→    std::vector<const std::vector<std::pair<double,double>>*> way_coords;
   617→    for (const auto& [way_id, role] : members) {
   618→        if (!include_all_roles && role != "outer" && !role.empty()) continue;
   619→        auto it = way_geoms.find(way_id);
   620→        if (it == way_geoms.end() || it->second.coords.empty()) continue;
   621→        way_coords.push_back(&it->second.coords);
   622→        for (const auto& [lat, lng] : it->second.coords) {
   623→            coord_count[coord_key(lat, lng)]++;
   624→        }
   625→    }
   626→
   627→    // A coordinate is a "split point" if it appears in 2+ ways' node lists.
   628→    // Endpoints always count, internal nodes that appear in multiple ways need splitting.
   629→    // Also, all way endpoints are natural split points.
   630→    std::unordered_set<int64_t> split_points;
   631→    for (auto* coords : way_coords) {
   632→        split_points.insert(coord_key(coords->front().first, coords->front().second));
   633→        split_points.insert(coord_key(coords->back().first, coords->back().second));
   634→    }
   635→    for (auto& [ck, cnt] : coord_count) {
   636→        if (cnt > 1) split_points.insert(ck);
   637→    }
   638→
   639→    // Split each way at split points to create sub-ways
   640→    struct SubWay {
   641→        std::vector<std::pair<double,double>> coords;
   642→    };
   643→    std::vector<SubWay> sub_ways;
   644→    for (auto* coords : way_coords) {
   645→        size_t seg_start = 0;
   646→        for (size_t i = 1; i < coords->size(); i++) {
   647→            int64_t ck = coord_key((*coords)[i].first, (*coords)[i].second);
   648→            if (split_points.count(ck) && i > seg_start) {
   649→                // Create sub-way from seg_start to i (inclusive)
   650→                SubWay sw;
   651→                sw.coords.assign(coords->begin() + seg_start, coords->begin() + i + 1);
   652→                if (sw.coords.size() >= 2) {
   653→                    sub_ways.push_back(std::move(sw));
   654→                }
   655→                seg_start = i;
   656→            }
   657→        }
   658→        // Handle remaining segment (if not already added)
   659→        if (seg_start < coords->size() - 1) {
   660→            SubWay sw;
   661→            sw.coords.assign(coords->begin() + seg_start, coords->end());
   662→            if (sw.coords.size() >= 2) {
   663→                sub_ways.push_back(std::move(sw));
   664→            }
   665→        }
   666→    }
   667→
   668→    // Build adjacency by sub-way coordinate endpoints
   669→    struct WayRef {
   670→        int64_t way_id;
   671→        const ParsedData::WayGeometry* geom; // unused for sub-ways
   672→    };
   673→    // We'll adapt the BacktrackState to work with sub_ways directly
   674→    std::unordered_map<int64_t, std::vector<std::pair<size_t, bool>>> coord_adj;
   675→    for (size_t i = 0; i < sub_ways.size(); i++) {
   676→        auto& first = sub_ways[i].coords.front();
   677→        auto& last = sub_ways[i].coords.back();
   678→        coord_adj[coord_key(first.first, first.second)].push_back({i, false});
   679→        coord_adj[coord_key(last.first, last.second)].push_back({i, true});
   680→    }
   681→
   682→    std::vector<bool> used(sub_ways.size(), false);
   683→    std::vector<std::vector<std::pair<double,double>>> rings;
   684→
   685→    // Ring closure: greedy first (fast), then full backtracking retry from
   686→    // scratch if greedy left unused sub-ways. The second pass gets clean
   687→    // used flags so backtracking makes optimal branch choices.
   688→
   689→    // Backtracking with depth limit (for fallback only)
   690→    struct BacktrackState {
   691→        const std::vector<SubWay>& sways;
   692→        const std::unordered_map<int64_t, std::vector<std::pair<size_t, bool>>>& adj;
   693→        std::vector<bool>& used;
   694→        decltype(coord_key)& key_fn;
   695→        int calls;
   696→
   697→        bool try_close(int64_t first_key, int64_t last_key,
   698→                       std::vector<std::pair<size_t, bool>>& path, int depth) {
   699→            if (++calls > 100000) return false; // limit total work, not just depth
   700→            if (!path.empty() && first_key == last_key) return true;
   701→            if (depth > 200) return false;
   702→
   703→            auto it = adj.find(last_key);
   704→            if (it == adj.end()) return false;
   705→
   706→            for (const auto& [wi, is_last] : it->second) {
   707→                if (used[wi]) continue;
   708→                auto& endpoint = is_last ? sways[wi].coords.front() : sways[wi].coords.back();
   709→                int64_t new_key = key_fn(endpoint.first, endpoint.second);
   710→
   711→                used[wi] = true;
   712→                path.push_back({wi, is_last});
   713→
   714→                if (try_close(first_key, new_key, path, depth + 1)) return true;
   715→
   716→                path.pop_back();
   717→                used[wi] = false;
   718→            }
   719→            return false;
   720→        }
   721→    };
   722→
   723→    BacktrackState bt{sub_ways, coord_adj, used, coord_key, 0};
   724→
   725→    // Pass 1: Greedy (fast, O(n) per ring)
   726→    for (size_t start_idx = 0; start_idx < sub_ways.size(); start_idx++) {
   727→        if (used[start_idx]) continue;
   728→
   729→        const auto& sg = sub_ways[start_idx];
   730→        int64_t first_key = coord_key(sg.coords.front().first, sg.coords.front().second);
   731→        int64_t last_key = coord_key(sg.coords.back().first, sg.coords.back().second);
   732→
   733→        if (sg.coords.size() >= 4 && first_key == last_key) {
   734→            used[start_idx] = true;
   735→            rings.push_back(sg.coords);
   736→            continue;
   737→        }
   738→
   739→        used[start_idx] = true;
   740→        std::vector<size_t> attempt_used = {start_idx};
   741→        std::vector<std::pair<double,double>> ring = sg.coords;
   742→        int64_t current_last = last_key;
   743→        bool extended = true;
   744→        while (extended && current_last != first_key) {
   745→            extended = false;
   746→            auto it = coord_adj.find(current_last);
   747→            if (it == coord_adj.end()) break;
   748→            for (const auto& [wi, is_last] : it->second) {
   749→                if (used[wi]) continue;
   750→                const auto& sw = sub_ways[wi];
   751→                bool reversed = is_last;
   752→                auto& endpoint = reversed ? sw.coords.front() : sw.coords.back();
   753→                used[wi] = true;
   754→                attempt_used.push_back(wi);
   755→                if (!reversed) ring.insert(ring.end(), sw.coords.begin() + 1, sw.coords.end());
   756→                else for (auto rit = sw.coords.rbegin() + 1; rit != sw.coords.rend(); ++rit) ring.push_back(*rit);
   757→                current_last = coord_key(endpoint.first, endpoint.second);
   758→                extended = true;
   759→                break;
   760→            }
   761→        }
   762→        if (ring.size() >= 4 && current_last == first_key &&
   763→            !ring_has_self_intersection(ring)) {
   764→            rings.push_back(std::move(ring));
   765→        } else {
   766→            for (auto wi : attempt_used) used[wi] = false;
   767→        }
   768→    }
   769→
   770→    // Pass 2: If greedy left unused sub-ways, retry from scratch with backtracking.
   771→    // Backtracking makes correct branch choices but is slower.
   772→    {
   773→        bool any_unused = false;
   774→        for (size_t i = 0; i < used.size(); i++) {
   775→            if (!used[i]) { any_unused = true; break; }
   776→        }
   777→        if (any_unused && sub_ways.size() <= 30) {
   778→            // Only retry for small relations — large ones stall the build
   779→            std::vector<bool> bt_used(sub_ways.size(), false);
   780→            std::vector<std::vector<std::pair<double,double>>> bt_rings;
   781→
   782→            BacktrackState bt2{sub_ways, coord_adj, bt_used, coord_key, 0};
   783→            int total_bt_calls = 0;
   784→            constexpr int MAX_TOTAL_BT_CALLS = 500000; // total budget for entire relation
   785→
   786→            for (size_t si = 0; si < sub_ways.size(); si++) {
   787→                if (bt_used[si]) continue;
   788→                if (total_bt_calls >= MAX_TOTAL_BT_CALLS) break; // budget exceeded
   789→                const auto& sg = sub_ways[si];
   790→                int64_t fk = coord_key(sg.coords.front().first, sg.coords.front().second);
   791→                int64_t lk = coord_key(sg.coords.back().first, sg.coords.back().second);
   792→                if (sg.coords.size() >= 4 && fk == lk) {
   793→                    bt_used[si] = true;
   794→                    bt_rings.push_back(sg.coords);
   795→                    continue;
   796→                }
   797→                bt_used[si] = true;
   798→                bt2.calls = 0;
   799→                std::vector<std::pair<size_t, bool>> path;
   800→                if (bt2.try_close(fk, lk, path, 0)) {
   801→                    std::vector<std::pair<double,double>> r = sg.coords;
   802→                    for (const auto& [wi, rev] : path) {
   803→                        const auto& c = sub_ways[wi].coords;
   804→                        if (!rev) r.insert(r.end(), c.begin() + 1, c.end());
   805→                        else for (auto it2 = c.rbegin() + 1; it2 != c.rend(); ++it2) r.push_back(*it2);
   806→                    }
   807→                    if (r.size() >= 4 && !ring_has_self_intersection(r)) {
   808→                        bt_rings.push_back(std::move(r));
   809→                        continue;
   810→                    }
   811→                }
   812→                bt_used[si] = false;
   813→                total_bt_calls += bt2.calls;
   814→            }
   815→
   816→            // Keep whichever pass produced more rings
   817→            if (bt_rings.size() > rings.size()) {
   818→                rings = std::move(bt_rings);
   819→            }
   820→        }
   821→    }
   822→
   823→    return rings;
   824→}
   825→
   826→// --- Highway filter ---
   827→
   828→static const std::vector<std::string> kExcludedHighways = {
   829→    "footway", "path", "track", "steps", "cycleway",
   830→    "service", "pedestrian", "bridleway", "construction"
   831→};
   832→
   833→static bool is_included_highway(const char* value) {
   834→    for (const auto& excluded : kExcludedHighways) {
   835→        if (excluded == value) return false;
   836→    }
   837→    return true;
   838→}
   839→
   840→// --- Parse house number (leading digits) ---
   841→
   842→static uint32_t parse_house_number(const char* s) {
   843→    if (!s) return 0;
   844→    uint32_t n = 0;
   845→    while (*s >= '0' && *s <= '9') {
   846→        n = n * 10 + (*s - '0');
   847→        s++;
   848→    }
   849→    return n;
   850→}
   851→
   852→// --- Add an address point ---
   853→
   854→static void add_addr_point(ParsedData& data, double lat, double lng,
   855→                           const char* housenumber, const char* street,
   856→                           uint64_t& addr_count_total) {
   857→    uint32_t addr_id = static_cast<uint32_t>(data.addr_points.size());
   858→    data.addr_points.push_back({
   859→        static_cast<float>(lat),
   860→        static_cast<float>(lng),
   861→        data.string_pool.intern(housenumber),
   862→        data.string_pool.intern(street)
   863→    });
   864→
   865→    S2CellId cell = point_to_cell(lat, lng);
   866→    data.cell_to_addrs[cell.id()].push_back(addr_id);
   867→
   868→    addr_count_total++;
   869→    if (addr_count_total % 1000000 == 0) {
   870→        std::cerr << "Collected " << addr_count_total / 1000000 << "M addresses..." << std::endl;
   871→    }
   872→}
   873→
   874→// --- Add an admin polygon ---
   875→
   876→static void add_admin_polygon(ParsedData& data,
   877→                               const std::vector<std::pair<double,double>>& vertices_in,
   878→                               const char* name, uint8_t admin_level,
   879→                               const char* country_code,
   880→                               AdminCoverPool* admin_pool = nullptr) {
   881→    // Normalize ring rotation: start at the vertex with smallest (lat, lng)
   882→    // This ensures identical output regardless of ring assembly order.
   883→    auto vertices = vertices_in;
   884→    if (vertices.size() >= 4 &&
   885→        std::fabs(vertices.front().first - vertices.back().first) < 1e-7 &&
   886→        std::fabs(vertices.front().second - vertices.back().second) < 1e-7) {
   887→        vertices.pop_back();
   888→        auto min_it = std::min_element(vertices.begin(), vertices.end());
   889→        std::rotate(vertices.begin(), min_it, vertices.end());
   890→        vertices.push_back(vertices.front());
   891→    }
   892→
   893→    // Simplify large polygons
   894→    auto simplified = simplify_polygon(vertices, 500);
   895→    if (simplified.size() < 3) return;
   896→
   897→    uint32_t poly_id = static_cast<uint32_t>(data.admin_polygons.size());
   898→    uint32_t vertex_offset = static_cast<uint32_t>(data.admin_vertices.size());
   899→
   900→    for (const auto& [lat, lng] : simplified) {
   901→        data.admin_vertices.push_back({static_cast<float>(lat), static_cast<float>(lng)});
   902→    }
   903→
   904→    AdminPolygon poly{};
   905→    poly.vertex_offset = vertex_offset;
   906→    poly.vertex_count = static_cast<uint16_t>(std::min(simplified.size(), size_t(65535)));
   907→    poly.name_id = data.string_pool.intern(name);
   908→    poly.admin_level = admin_level;
   909→    poly.area = polygon_area(simplified);
   910→    poly.country_code = (country_code && country_code[0] && country_code[1])
   911→        ? static_cast<uint16_t>((country_code[0] << 8) | country_code[1])
   912→        : 0;
   913→    data.admin_polygons.push_back(poly);
   914→
   915→    // Submit S2 cell coverage to thread pool (runs concurrently with PBF reading)
   916→    if (admin_pool) {
   917→        admin_pool->submit(poly_id, std::move(simplified));
   918→    }
   919→}
   920→
   921→// --- OSM handler ---
   922→
   923→class BuildHandler : public osmium::handler::Handler {
   924→public:
   925→    explicit BuildHandler(ParsedData& data, AdminCoverPool* admin_pool = nullptr)
   926→        : data_(data), admin_pool_(admin_pool) {}
   927→
   928→    void node(const osmium::Node& node) {
   929→        const char* housenumber = node.tags()["addr:housenumber"];
   930→        if (!housenumber) return;
   931→        const char* street = node.tags()["addr:street"];
   932→        if (!street) return;
   933→        if (!node.location().valid()) return;
   934→
   935→        add_addr_point(data_, node.location().lat(), node.location().lon(),
   936→                       housenumber, street, addr_count_total_);
   937→    }
   938→
   939→    void way(const osmium::Way& way) {
   940→        // Address interpolation
   941→        const char* interpolation = way.tags()["addr:interpolation"];
   942→        if (interpolation) {
   943→            process_interpolation_way(way, interpolation);
   944→            return;
   945→        }
   946→
   947→        // Building addresses
   948→        const char* housenumber = way.tags()["addr:housenumber"];
   949→        if (housenumber) {
   950→            const char* street = way.tags()["addr:street"];
   951→            if (street) {
   952→                process_building_address(way, housenumber, street);
   953→            }
   954→        }
   955→
   956→        // Highway ways
   957→        const char* highway = way.tags()["highway"];
   958→        if (highway && is_included_highway(highway)) {
   959→            const char* name = way.tags()["name"];
   960→            if (name) {
   961→                process_highway(way, name);
   962→            }
   963→        }
   964→    }
   965→
   966→    void area(const osmium::Area& area) {
   967→        const char* boundary = area.tags()["boundary"];
   968→        if (!boundary) return;
   969→
   970→        bool is_admin = (std::strcmp(boundary, "administrative") == 0);
   971→        bool is_postal = (std::strcmp(boundary, "postal_code") == 0);
   972→        if (!is_admin && !is_postal) return;
   973→
   974→        uint8_t admin_level = 0;
   975→        if (is_admin) {
   976→            const char* level_str = area.tags()["admin_level"];
   977→            if (!level_str) return;
   978→            admin_level = static_cast<uint8_t>(std::atoi(level_str));
   979→            if (admin_level < 2 || admin_level > 10) return;
   980→        } else {
   981→            admin_level = 11; // use 11 for postal codes
   982→        }
   983→        if (kMaxAdminLevel > 0 && admin_level > kMaxAdminLevel) return;
   984→
   985→        const char* name = area.tags()["name"];
   986→        if (!name && is_admin) return;
   987→
   988→        // For postal codes, use postal_code tag as name
   989→        std::string name_str;
   990→        if (is_postal) {
   991→            const char* postal_code = area.tags()["postal_code"];
   992→            if (!postal_code) postal_code = name;
   993→            if (!postal_code) return;
   994→            name_str = postal_code;
   995→        } else {
   996→            name_str = name;
   997→        }
   998→
   999→        // Extract country code for level 2 boundaries
  1000→        const char* country_code = (admin_level == 2)
  1001→            ? area.tags()["ISO3166-1:alpha2"]
  1002→            : nullptr;
  1003→        // Extract outer ring vertices
  1004→        for (const auto& outer_ring : area.outer_rings()) {
  1005→            std::vector<std::pair<double,double>> vertices;
  1006→            for (const auto& node_ref : outer_ring) {
  1007→                if (node_ref.location().valid()) {
  1008→                    vertices.emplace_back(node_ref.location().lat(), node_ref.location().lon());
  1009→                }
  1010→            }
  1011→            if (vertices.size() >= 3) {
  1012→                add_admin_polygon(data_, vertices, name_str.c_str(), admin_level, country_code, admin_pool_);
  1013→            }
  1014→        }
  1015→
  1016→        admin_count_++;
  1017→        if (admin_count_ % 10000 == 0) {
  1018→            std::cerr << "Processed " << admin_count_ / 1000 << "K admin boundaries..." << std::endl;
  1019→        }
  1020→    }
  1021→
  1022→    uint64_t way_count() const { return way_count_; }
  1023→    uint64_t building_addr_count() const { return building_addr_count_; }
  1024→    uint64_t interp_count() const { return interp_count_; }
  1025→    uint64_t admin_count() const { return admin_count_; }
  1026→    uint64_t addr_count_total() const { return addr_count_total_; }
  1027→
  1028→private:
  1029→    ParsedData& data_;
  1030→    AdminCoverPool* admin_pool_ = nullptr;
  1031→    uint64_t way_count_ = 0;
  1032→    uint64_t building_addr_count_ = 0;
  1033→    uint64_t interp_count_ = 0;
  1034→    uint64_t admin_count_ = 0;
  1035→    uint64_t addr_count_total_ = 0;
  1036→
  1037→    void process_building_address(const osmium::Way& way, const char* housenumber, const char* street) {
  1038→        const auto& wnodes = way.nodes();
  1039→        if (wnodes.empty()) return;
  1040→
  1041→        double sum_lat = 0, sum_lng = 0;
  1042→        int valid = 0;
  1043→        for (const auto& nr : wnodes) {
  1044→            if (!nr.location().valid()) continue;
  1045→            sum_lat += nr.location().lat();
  1046→            sum_lng += nr.location().lon();
  1047→            valid++;
  1048→        }
  1049→        if (valid == 0) return;
  1050→
  1051→        add_addr_point(data_, sum_lat / valid, sum_lng / valid,
  1052→                       housenumber, street, addr_count_total_);
  1053→        building_addr_count_++;
  1054→    }
  1055→
  1056→    void process_interpolation_way(const osmium::Way& way, const char* interpolation) {
  1057→        const auto& wnodes = way.nodes();
  1058→        if (wnodes.size() < 2) return;
  1059→
  1060→        for (const auto& nr : wnodes) {
  1061→            if (!nr.location().valid()) return;
  1062→        }
  1063→
  1064→        const char* street = way.tags()["addr:street"];
  1065→        if (!street) return;
  1066→
  1067→        uint8_t interp_type = 0;
  1068→        if (std::strcmp(interpolation, "even") == 0) interp_type = 1;
  1069→        else if (std::strcmp(interpolation, "odd") == 0) interp_type = 2;
  1070→
  1071→        uint32_t interp_id = static_cast<uint32_t>(data_.interp_ways.size());
  1072→        uint32_t node_offset = static_cast<uint32_t>(data_.interp_nodes.size());
  1073→
  1074→        for (const auto& nr : wnodes) {
  1075→            data_.interp_nodes.push_back({
  1076→                static_cast<float>(nr.location().lat()),
  1077→                static_cast<float>(nr.location().lon())
  1078→            });
  1079→        }
  1080→
  1081→        InterpWay iw{};
  1082→        iw.node_offset = node_offset;
  1083→        iw.node_count = static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)));
  1084→        iw.street_id = data_.string_pool.intern(street);
  1085→        iw.start_number = 0;
  1086→        iw.end_number = 0;
  1087→        iw.interpolation = interp_type;
  1088→        data_.interp_ways.push_back(iw);
  1089→
  1090→        // Defer S2 cell computation to parallel phase
  1091→        data_.deferred_interps.push_back({interp_id, node_offset,
  1092→            static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)))});
  1093→
  1094→        interp_count_++;
  1095→    }
  1096→
  1097→    void process_highway(const osmium::Way& way, const char* name) {
  1098→        const auto& wnodes = way.nodes();


  1098→        const auto& wnodes = way.nodes();
  1099→        if (wnodes.size() < 2) return;
  1100→
  1101→        for (const auto& nr : wnodes) {
  1102→            if (!nr.location().valid()) return;
  1103→        }
  1104→
  1105→        uint32_t way_id = static_cast<uint32_t>(data_.ways.size());
  1106→        uint32_t node_offset = static_cast<uint32_t>(data_.street_nodes.size());
  1107→
  1108→        for (const auto& nr : wnodes) {
  1109→            data_.street_nodes.push_back({
  1110→                static_cast<float>(nr.location().lat()),
  1111→                static_cast<float>(nr.location().lon())
  1112→            });
  1113→        }
  1114→
  1115→        WayHeader header{};
  1116→        header.node_offset = node_offset;
  1117→        header.node_count = static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)));
  1118→        header.name_id = data_.string_pool.intern(name);
  1119→        data_.ways.push_back(header);
  1120→
  1121→        // Defer S2 cell computation to parallel phase
  1122→        data_.deferred_ways.push_back({way_id, node_offset, header.node_count});
  1123→
  1124→        way_count_++;
  1125→        if (way_count_ % 1000000 == 0) {
  1126→            std::cerr << "Collected " << way_count_ / 1000000 << "M street ways..." << std::endl;
  1127→        }
  1128→    }
  1129→};
  1130→
  1131→// --- Relation collector for parallel admin assembly (runs during pass 1) ---
  1132→
  1133→struct RelationCollector : public osmium::handler::Handler {
  1134→    std::vector<CollectedRelation>& relations;
  1135→
  1136→    explicit RelationCollector(std::vector<CollectedRelation>& r) : relations(r) {}
  1137→
  1138→    void relation(const osmium::Relation& rel) {
  1139→        const char* boundary = rel.tags()["boundary"];
  1140→        if (!boundary) return;
  1141→
  1142→        bool is_admin = (std::strcmp(boundary, "administrative") == 0);
  1143→        bool is_postal = (std::strcmp(boundary, "postal_code") == 0);
  1144→        if (!is_admin && !is_postal) return;
  1145→
  1146→        uint8_t admin_level = 0;
  1147→        if (is_admin) {
  1148→            const char* level_str = rel.tags()["admin_level"];
  1149→            if (!level_str) return;
  1150→            admin_level = static_cast<uint8_t>(std::atoi(level_str));
  1151→            if (admin_level < 2 || admin_level > 10) return;
  1152→        } else {
  1153→            admin_level = 11; // use 11 for postal codes
  1154→        }
  1155→        if (kMaxAdminLevel > 0 && admin_level > kMaxAdminLevel) return;
  1156→
  1157→        const char* name = rel.tags()["name"];
  1158→        if (!name && is_admin) return;
  1159→
  1160→        // For postal codes, use postal_code tag as name
  1161→        std::string name_str;
  1162→        if (is_postal) {
  1163→            const char* postal_code = rel.tags()["postal_code"];
  1164→            if (!postal_code) postal_code = name;
  1165→            if (!postal_code) return;
  1166→            name_str = postal_code;
  1167→        } else {
  1168→            name_str = name;
  1169→        }
  1170→
  1171→        // Extract country code for level 2 boundaries
  1172→        std::string country_code;
  1173→        if (admin_level == 2) {
  1174→            const char* cc = rel.tags()["ISO3166-1:alpha2"];
  1175→            if (cc) country_code = cc;
  1176→        }
  1177→
  1178→        // Collect member way IDs and roles
  1179→        CollectedRelation cr;
  1180→        cr.id = rel.id();
  1181→        cr.admin_level = admin_level;
  1182→        cr.name = std::move(name_str);
  1183→        cr.country_code = std::move(country_code);
  1184→        cr.is_postal = is_postal;
  1185→
  1186→        for (const auto& member : rel.members()) {
  1187→            if (member.type() == osmium::item_type::way) {
  1188→                cr.members.emplace_back(member.ref(), member.role());
  1189→            }
  1190→        }
  1191→
  1192→        if (!cr.members.empty()) {
  1193→            relations.push_back(std::move(cr));
  1194→        }
  1195→    }
  1196→};
  1197→
  1198→// --- Resolve interpolation way endpoint house numbers ---
  1199→
  1200→static void resolve_interpolation_endpoints(ParsedData& data) {
  1201→    struct CoordKey {
  1202→        int32_t lat;
  1203→        int32_t lng;
  1204→        bool operator==(const CoordKey& o) const { return lat == o.lat && lng == o.lng; }
  1205→    };
  1206→    struct CoordHash {
  1207→        size_t operator()(const CoordKey& k) const {
  1208→            return std::hash<int64_t>()(((int64_t)k.lat << 32) | (uint32_t)k.lng);
  1209→        }
  1210→    };
  1211→
  1212→    std::unordered_map<CoordKey, uint32_t, CoordHash> addr_by_coord;
  1213→    for (uint32_t i = 0; i < data.addr_points.size(); i++) {
  1214→        CoordKey key{
  1215→            static_cast<int32_t>(data.addr_points[i].lat * 100000),
  1216→            static_cast<int32_t>(data.addr_points[i].lng * 100000)
  1217→        };
  1218→        addr_by_coord[key] = i;
  1219→    }
  1220→
  1221→    uint32_t resolved = 0;
  1222→    for (auto& iw : data.interp_ways) {
  1223→        if (iw.node_count < 2) continue;
  1224→
  1225→        const auto& start = data.interp_nodes[iw.node_offset];
  1226→        CoordKey start_key{
  1227→            static_cast<int32_t>(start.lat * 100000),
  1228→            static_cast<int32_t>(start.lng * 100000)
  1229→        };
  1230→        auto it_start = addr_by_coord.find(start_key);
  1231→
  1232→        const auto& end = data.interp_nodes[iw.node_offset + iw.node_count - 1];
  1233→        CoordKey end_key{
  1234→            static_cast<int32_t>(end.lat * 100000),
  1235→            static_cast<int32_t>(end.lng * 100000)
  1236→        };
  1237→        auto it_end = addr_by_coord.find(end_key);
  1238→
  1239→        if (it_start != addr_by_coord.end()) {
  1240→            const char* hn = data.string_pool.data().data() + data.addr_points[it_start->second].housenumber_id;
  1241→            iw.start_number = parse_house_number(hn);
  1242→        }
  1243→        if (it_end != addr_by_coord.end()) {
  1244→            const char* hn = data.string_pool.data().data() + data.addr_points[it_end->second].housenumber_id;
  1245→            iw.end_number = parse_house_number(hn);
  1246→        }
  1247→
  1248→        if (iw.start_number > 0 && iw.end_number > 0) resolved++;
  1249→    }
  1250→
  1251→    std::cerr << "Resolved " << resolved << "/" << data.interp_ways.size()
  1252→              << " interpolation ways" << std::endl;
  1253→}
  1254→
  1255→// --- Deduplicate IDs per cell ---
  1256→
  1257→template<typename Map>
  1258→static void deduplicate(Map& cell_map) {
  1259→    for (auto& [cell_id, ids] : cell_map) {
  1260→        std::sort(ids.begin(), ids.end());
  1261→        ids.erase(std::unique(ids.begin(), ids.end()), ids.end());
  1262→    }
  1263→}
  1264→
  1265→// --- Cache serialization ---
  1266→
  1267→enum class SectionType : uint32_t {
  1268→    STRING_POOL = 0,
  1269→    WAYS = 1,
  1270→    STREET_NODES = 2,
  1271→    CELL_TO_WAYS = 3,
  1272→    ADDR_POINTS = 4,
  1273→    CELL_TO_ADDRS = 5,
  1274→    INTERP_WAYS = 6,
  1275→    INTERP_NODES = 7,
  1276→    CELL_TO_INTERPS = 8,
  1277→    ADMIN_POLYGONS = 9,
  1278→    ADMIN_VERTICES = 10,
  1279→    CELL_TO_ADMIN = 11,
  1280→};
  1281→
  1282→static const char CACHE_MAGIC[8] = {'T','G','C','A','C','H','E','\0'};
  1283→static const uint32_t CACHE_VERSION = 1;
  1284→static const uint32_t CACHE_SECTION_COUNT = 12;
  1285→
  1286→// Serialize a vector of structs to a binary blob
  1287→template<typename T>
  1288→static std::vector<char> serialize_vector(const std::vector<T>& vec) {
  1289→    std::vector<char> buf;
  1290→    uint64_t count = vec.size();
  1291→    buf.resize(sizeof(uint64_t) + count * sizeof(T));
  1292→    std::memcpy(buf.data(), &count, sizeof(uint64_t));
  1293→    if (count > 0) {
  1294→        std::memcpy(buf.data() + sizeof(uint64_t), vec.data(), count * sizeof(T));
  1295→    }
  1296→    return buf;
  1297→}
  1298→
  1299→// Deserialize a vector of structs from a binary blob
  1300→template<typename T>
  1301→static bool deserialize_vector(const char* data, uint64_t length, std::vector<T>& vec) {
  1302→    if (length < sizeof(uint64_t)) return false;
  1303→    uint64_t count;
  1304→    std::memcpy(&count, data, sizeof(uint64_t));
  1305→    if (length < sizeof(uint64_t) + count * sizeof(T)) return false;
  1306→    vec.resize(count);
  1307→    if (count > 0) {
  1308→        std::memcpy(vec.data(), data + sizeof(uint64_t), count * sizeof(T));
  1309→    }
  1310→    return true;
  1311→}
  1312→
  1313→// Serialize a cell map (uint64_t -> vector<uint32_t>)
  1314→static std::vector<char> serialize_cell_map(const std::unordered_map<uint64_t, std::vector<uint32_t>>& map) {
  1315→    // Calculate total size
  1316→    size_t total = sizeof(uint64_t); // entry count
  1317→    for (const auto& [cell_id, ids] : map) {
  1318→        total += sizeof(uint64_t) + sizeof(uint32_t) + ids.size() * sizeof(uint32_t);
  1319→    }
  1320→    std::vector<char> buf(total);
  1321→    char* ptr = buf.data();
  1322→
  1323→    uint64_t entry_count = map.size();
  1324→    std::memcpy(ptr, &entry_count, sizeof(uint64_t));
  1325→    ptr += sizeof(uint64_t);
  1326→
  1327→    for (const auto& [cell_id, ids] : map) {
  1328→        std::memcpy(ptr, &cell_id, sizeof(uint64_t));
  1329→        ptr += sizeof(uint64_t);
  1330→        uint32_t id_count = static_cast<uint32_t>(ids.size());
  1331→        std::memcpy(ptr, &id_count, sizeof(uint32_t));
  1332→        ptr += sizeof(uint32_t);
  1333→        if (id_count > 0) {
  1334→            std::memcpy(ptr, ids.data(), id_count * sizeof(uint32_t));
  1335→            ptr += id_count * sizeof(uint32_t);
  1336→        }
  1337→    }
  1338→    return buf;
  1339→}
  1340→
  1341→// Deserialize a cell map
  1342→static bool deserialize_cell_map(const char* data, uint64_t length,
  1343→                                  std::unordered_map<uint64_t, std::vector<uint32_t>>& map) {
  1344→    if (length < sizeof(uint64_t)) return false;
  1345→    const char* ptr = data;
  1346→    const char* end = data + length;
  1347→
  1348→    uint64_t entry_count;
  1349→    std::memcpy(&entry_count, ptr, sizeof(uint64_t));
  1350→    ptr += sizeof(uint64_t);
  1351→
  1352→    map.reserve(entry_count);
  1353→    for (uint64_t i = 0; i < entry_count; i++) {
  1354→        if (ptr + sizeof(uint64_t) + sizeof(uint32_t) > end) return false;
  1355→        uint64_t cell_id;
  1356→        std::memcpy(&cell_id, ptr, sizeof(uint64_t));
  1357→        ptr += sizeof(uint64_t);
  1358→        uint32_t id_count;
  1359→        std::memcpy(&id_count, ptr, sizeof(uint32_t));
  1360→        ptr += sizeof(uint32_t);
  1361→        if (ptr + id_count * sizeof(uint32_t) > end) return false;
  1362→        std::vector<uint32_t> ids(id_count);
  1363→        if (id_count > 0) {
  1364→            std::memcpy(ids.data(), ptr, id_count * sizeof(uint32_t));
  1365→            ptr += id_count * sizeof(uint32_t);
  1366→        }
  1367→        map[cell_id] = std::move(ids);
  1368→    }
  1369→    return true;
  1370→}
  1371→
  1372→static void serialize_cache(const ParsedData& data, const std::string& path) {
  1373→    std::cerr << "Saving cache to " << path << "..." << std::endl;
  1374→    std::ofstream f(path, std::ios::binary);
  1375→    if (!f.is_open()) {
  1376→        std::cerr << "Error: could not open cache file for writing: " << path << std::endl;
  1377→        return;
  1378→    }
  1379→
  1380→    f.write(CACHE_MAGIC, 8);
  1381→    f.write(reinterpret_cast<const char*>(&CACHE_VERSION), sizeof(uint32_t));
  1382→    f.write(reinterpret_cast<const char*>(&CACHE_SECTION_COUNT), sizeof(uint32_t));
  1383→
  1384→    // Helper to write a section
  1385→    auto write_section = [&](SectionType type, const std::vector<char>& blob) {
  1386→        uint32_t t = static_cast<uint32_t>(type);
  1387→        uint64_t len = blob.size();
  1388→        f.write(reinterpret_cast<const char*>(&t), sizeof(uint32_t));
  1389→        f.write(reinterpret_cast<const char*>(&len), sizeof(uint64_t));
  1390→        f.write(blob.data(), len);
  1391→    };
  1392→
  1393→    // STRING_POOL: raw char vector
  1394→    {
  1395→        const auto& sp = data.string_pool.data();
  1396→        std::vector<char> blob(sizeof(uint64_t) + sp.size());
  1397→        uint64_t sz = sp.size();
  1398→        std::memcpy(blob.data(), &sz, sizeof(uint64_t));
  1399→        if (sz > 0) std::memcpy(blob.data() + sizeof(uint64_t), sp.data(), sz);
  1400→        write_section(SectionType::STRING_POOL, blob);
  1401→    }
  1402→
  1403→    write_section(SectionType::WAYS, serialize_vector(data.ways));
  1404→    write_section(SectionType::STREET_NODES, serialize_vector(data.street_nodes));
  1405→    write_section(SectionType::CELL_TO_WAYS, serialize_cell_map(data.cell_to_ways));
  1406→    write_section(SectionType::ADDR_POINTS, serialize_vector(data.addr_points));
  1407→    write_section(SectionType::CELL_TO_ADDRS, serialize_cell_map(data.cell_to_addrs));
  1408→    write_section(SectionType::INTERP_WAYS, serialize_vector(data.interp_ways));
  1409→    write_section(SectionType::INTERP_NODES, serialize_vector(data.interp_nodes));
  1410→    write_section(SectionType::CELL_TO_INTERPS, serialize_cell_map(data.cell_to_interps));
  1411→    write_section(SectionType::ADMIN_POLYGONS, serialize_vector(data.admin_polygons));
  1412→    write_section(SectionType::ADMIN_VERTICES, serialize_vector(data.admin_vertices));
  1413→    write_section(SectionType::CELL_TO_ADMIN, serialize_cell_map(data.cell_to_admin));
  1414→
  1415→    std::cerr << "Cache saved." << std::endl;
  1416→}
  1417→
  1418→static bool deserialize_cache(ParsedData& data, const std::string& path) {
  1419→    std::cerr << "Loading cache from " << path << "..." << std::endl;
  1420→    std::ifstream f(path, std::ios::binary);
  1421→    if (!f.is_open()) {
  1422→        std::cerr << "Error: could not open cache file: " << path << std::endl;
  1423→        return false;
  1424→    }
  1425→
  1426→    char magic[8];
  1427→    f.read(magic, 8);
  1428→    if (std::memcmp(magic, CACHE_MAGIC, 8) != 0) {
  1429→        std::cerr << "Error: invalid cache magic" << std::endl;
  1430→        return false;
  1431→    }
  1432→
  1433→    uint32_t version;
  1434→    f.read(reinterpret_cast<char*>(&version), sizeof(uint32_t));
  1435→    if (version != CACHE_VERSION) {
  1436→        std::cerr << "Error: unsupported cache version " << version << std::endl;
  1437→        return false;
  1438→    }
  1439→
  1440→    uint32_t section_count;
  1441→    f.read(reinterpret_cast<char*>(&section_count), sizeof(uint32_t));
  1442→
  1443→    for (uint32_t s = 0; s < section_count; s++) {
  1444→        uint32_t type;
  1445→        uint64_t length;
  1446→        f.read(reinterpret_cast<char*>(&type), sizeof(uint32_t));
  1447→        f.read(reinterpret_cast<char*>(&length), sizeof(uint64_t));
  1448→
  1449→        std::vector<char> blob(length);
  1450→        f.read(blob.data(), length);
  1451→
  1452→        switch (static_cast<SectionType>(type)) {
  1453→        case SectionType::STRING_POOL: {
  1454→            if (length < sizeof(uint64_t)) return false;
  1455→            uint64_t sz;
  1456→            std::memcpy(&sz, blob.data(), sizeof(uint64_t));
  1457→            auto& sp = data.string_pool.mutable_data();
  1458→            sp.resize(sz);
  1459→            if (sz > 0) std::memcpy(sp.data(), blob.data() + sizeof(uint64_t), sz);
  1460→            break;
  1461→        }
  1462→        case SectionType::WAYS:
  1463→            if (!deserialize_vector(blob.data(), length, data.ways)) return false;
  1464→            break;
  1465→        case SectionType::STREET_NODES:
  1466→            if (!deserialize_vector(blob.data(), length, data.street_nodes)) return false;
  1467→            break;
  1468→        case SectionType::CELL_TO_WAYS:
  1469→            if (!deserialize_cell_map(blob.data(), length, data.cell_to_ways)) return false;
  1470→            break;
  1471→        case SectionType::ADDR_POINTS:
  1472→            if (!deserialize_vector(blob.data(), length, data.addr_points)) return false;
  1473→            break;
  1474→        case SectionType::CELL_TO_ADDRS:
  1475→            if (!deserialize_cell_map(blob.data(), length, data.cell_to_addrs)) return false;
  1476→            break;
  1477→        case SectionType::INTERP_WAYS:
  1478→            if (!deserialize_vector(blob.data(), length, data.interp_ways)) return false;
  1479→            break;
  1480→        case SectionType::INTERP_NODES:
  1481→            if (!deserialize_vector(blob.data(), length, data.interp_nodes)) return false;
  1482→            break;
  1483→        case SectionType::CELL_TO_INTERPS:
  1484→            if (!deserialize_cell_map(blob.data(), length, data.cell_to_interps)) return false;
  1485→            break;
  1486→        case SectionType::ADMIN_POLYGONS:
  1487→            if (!deserialize_vector(blob.data(), length, data.admin_polygons)) return false;
  1488→            break;
  1489→        case SectionType::ADMIN_VERTICES:
  1490→            if (!deserialize_vector(blob.data(), length, data.admin_vertices)) return false;
  1491→            break;
  1492→        case SectionType::CELL_TO_ADMIN:
  1493→            if (!deserialize_cell_map(blob.data(), length, data.cell_to_admin)) return false;
  1494→            break;
  1495→        default:
  1496→            std::cerr << "Warning: unknown cache section type " << type << ", skipping" << std::endl;
  1497→            break;
  1498→        }
  1499→    }
  1500→
  1501→    std::cerr << "Cache loaded: " << data.ways.size() << " ways, "
  1502→              << data.addr_points.size() << " addrs, "
  1503→              << data.interp_ways.size() << " interps, "
  1504→              << data.admin_polygons.size() << " admin polygons" << std::endl;
  1505→    return true;
  1506→}
  1507→
  1508→// --- Continent bounding box filtering ---
  1509→
  1510→struct ContinentBBox {
  1511→    const char* name;
  1512→    double min_lat, max_lat, min_lng, max_lng;
  1513→};
  1514→
  1515→static const ContinentBBox kContinents[] = {
  1516→    {"africa",            -35.0,  37.5,  -25.0,  55.0},
  1517→    {"asia",              -12.0,  82.0,   25.0, 180.0},
  1518→    {"europe",             35.0,  72.0,  -25.0,  45.0},
  1519→    {"north-america",       7.0,  84.0, -170.0, -50.0},
  1520→    {"south-america",     -56.0,  13.0,  -82.0, -34.0},
  1521→    {"oceania",           -50.0,   0.0,  110.0, 180.0},
  1522→    {"central-america",     7.0,  23.5, -120.0, -57.0},
  1523→    {"antarctica",        -90.0, -60.0, -180.0, 180.0},
  1524→};
  1525→
  1526→static bool cell_in_bbox(uint64_t cell_id, const ContinentBBox& bbox) {
  1527→    S2CellId cell(cell_id);
  1528→    S2LatLng center = cell.ToLatLng();
  1529→    double lat = center.lat().degrees();
  1530→    double lng = center.lng().degrees();
  1531→    return lat >= bbox.min_lat && lat <= bbox.max_lat &&
  1532→           lng >= bbox.min_lng && lng <= bbox.max_lng;
  1533→}
  1534→
  1535→static ParsedData filter_by_bbox(const ParsedData& full, const ContinentBBox& bbox) {
  1536→    ParsedData out;
  1537→
  1538→    // --- Filter cell_to_ways and collect referenced way IDs ---
  1539→    std::unordered_set<uint32_t> used_way_ids;
  1540→    for (const auto& [cell_id, ids] : full.cell_to_ways) {
  1541→        if (cell_in_bbox(cell_id, bbox)) {
  1542→            for (uint32_t id : ids) {
  1543→                used_way_ids.insert(id);
  1544→            }
  1545→        }
  1546→    }
  1547→
  1548→    // --- Filter cell_to_addrs and collect referenced addr IDs ---
  1549→    std::unordered_set<uint32_t> used_addr_ids;
  1550→    for (const auto& [cell_id, ids] : full.cell_to_addrs) {
  1551→        if (cell_in_bbox(cell_id, bbox)) {
  1552→            for (uint32_t id : ids) {
  1553→                used_addr_ids.insert(id);
  1554→            }
  1555→        }
  1556→    }
  1557→
  1558→    // --- Filter cell_to_interps and collect referenced interp IDs ---
  1559→    std::unordered_set<uint32_t> used_interp_ids;
  1560→    for (const auto& [cell_id, ids] : full.cell_to_interps) {
  1561→        if (cell_in_bbox(cell_id, bbox)) {
  1562→            for (uint32_t id : ids) {
  1563→                used_interp_ids.insert(id);
  1564→            }
  1565→        }
  1566→    }
  1567→
  1568→    // --- Filter cell_to_admin and collect referenced admin IDs ---
  1569→    std::unordered_set<uint32_t> used_admin_ids;
  1570→    for (const auto& [cell_id, ids] : full.cell_to_admin) {
  1571→        if (cell_in_bbox(cell_id, bbox)) {
  1572→            for (uint32_t id : ids) {
  1573→                used_admin_ids.insert(id & ID_MASK);
  1574→            }
  1575→        }
  1576→    }
  1577→
  1578→    // --- Build remapped ways ---
  1579→    std::unordered_map<uint32_t, uint32_t> way_remap; // old_id -> new_id
  1580→    {
  1581→        std::vector<uint32_t> sorted_ids(used_way_ids.begin(), used_way_ids.end());
  1582→        std::sort(sorted_ids.begin(), sorted_ids.end());
  1583→        for (uint32_t old_id : sorted_ids) {
  1584→            uint32_t new_id = static_cast<uint32_t>(out.ways.size());
  1585→            way_remap[old_id] = new_id;
  1586→            const auto& w = full.ways[old_id];
  1587→            WayHeader nw = w;
  1588→            nw.node_offset = static_cast<uint32_t>(out.street_nodes.size());
  1589→            out.ways.push_back(nw);
  1590→            for (uint8_t n = 0; n < w.node_count; n++) {
  1591→                out.street_nodes.push_back(full.street_nodes[w.node_offset + n]);
  1592→            }
  1593→        }
  1594→    }
  1595→
  1596→    // --- Build remapped addr points ---
  1597→    std::unordered_map<uint32_t, uint32_t> addr_remap;