I need to understand why a baseline build (commit 93fe6e4) produces 3,298,645 street ways while the current code produces 3,312,673 ways (14,028 more) from the same Germany PBF.

Look at the git history between commits 93fe6e4 and 3984d21 in /root/traccar-geocoder. Read the relevant parts of builder/src/build_index.cpp at both commits to find what changed in way filtering/processing that would cause more ways to be included. Focus on:
1. Highway filtering logic (is_included_highway)
2. Way validation (node count, valid locations)
3. Any other changes that affect which ways get added

Use `git diff 93fe6e4..3984d21 -- builder/src/build_index.cpp` to see the full diff, and search for changes related to way processing.

Don't make any code changes - just research and report what you find.


I'll help you investigate the differences between these two commits that resulted in more ways being included. Let me start by examining the git history and the specific file changes.


Bash

git log --oneline 93fe6e4..3984d21 -- builder/src/build_index.cpp | head -20


3984d21 fix: split interned_strings into per-type vectors to fix name misassignment
1a25143 fix: use node IDs for ring stitching instead of coordinates
acf403e fix: only store way geometries for admin boundary members
b79a003 fix: normalize ring rotation in add_admin_polygon for consistent output
58349a5 feat: parallel admin boundary assembly (--parallel-admin)
0a2bb57 fix: increase dense index to 15B max node ID with bounds checking
a19cb3c feat: replace sharded FlexMem with lockless dense array index
3117db8 fix: reduce shards to 16 and stream way blocks to avoid OOM
6fe46b1 feat: parallel planet processing with sharded node index


Now let me see the full diff of build_index.cpp between these commits:


git diff 93fe6e4..3984d21 -- builder/src/build_index.cpp | head -500


builder/src/build_index.cpp
  @@ -1,11 +1,17 @@
  +#include <atomic>
   #include <cmath>
  +#include <condition_variable>
   #include <cstdint>
   #include <cstring>
  +#include <deque>
   #include <fstream>
  +#include <future>
   #include <iostream>
  +#include <mutex>
  ... (truncated)
  @@ -16,7 +22,9 @@
  -#include <osmium/index/map/sparse_file_array.hpp>
  +#include <sys/mman.h>
  +#include <sys/stat.h>
  +// FlexMem no longer used — replaced by DenseIndex mmap
   #include <osmium/area/assembler.hpp>
   #include <osmium/area/multipolygon_manager.hpp>

  @@ -28,6 +36,12 @@
  +// --- Directory creation ---
  +
  +static void ensure_dir(const std::string& path) {
  +    mkdir(path.c_str(), 0755);
  +}
  +
   // --- Binary format structs ---

   struct WayHeader {
  @@ -69,6 +83,10 @@
  +// --- Index mode ---
  +
  +enum class IndexMode { Full, NoAddresses, AdminOnly };
  +
   // --- String interning ---

   class StringPool {
  @@ -86,42 +104,172 @@
  +    std::vector<char>& mutable_data() { return data_; }

   private:
       std::unordered_map<std::string, uint32_t> index_;
       std::vector<char> data_;
   };

  -// --- Collected data ---
  +// --- Deferred S2 work items (computed in parallel after PBF read) ---

  ... (truncated)
  @@ -146,6 +294,18 @@
  +// Approximate polygon area in square degrees
  +static float polygon_area(const std::vector<std::pair<double,double>>& vertices) {
  +    double area = 0;
  +    size_t n = vertices.size();
  +    for (size_t i = 0; i < n; i++) {
  +        size_t j = (i + 1) % n;
  +        area += vertices[i].first * vertices[j].second;
  +        area -= vertices[j].first * vertices[i].second;
  +    }
  +    return static_cast<float>(std::fabs(area) / 2.0);
  ... (truncated)
  @@ -228,19 +388,8 @@
  -// Approximate polygon area in square degrees
  -static float polygon_area(const std::vector<std::pair<double,double>>& vertices) {
  -    double area = 0;
  -    size_t n = vertices.size();
  -    for (size_t i = 0; i < n; i++) {
  -        size_t j = (i + 1) % n;
  -        area += vertices[i].first * vertices[j].second;
  -        area -= vertices[j].first * vertices[i].second;
  -    }
  -    return static_cast<float>(std::fabs(area) / 2.0);
  ... (truncated)
  @@ -314,6 +463,85 @@
  +// --- Parallel admin: assemble outer rings from collected way geometries ---
  +
  +static std::vector<std::vector<std::pair<double,double>>> assemble_outer_rings(
  +    const std::vector<std::pair<int64_t, std::string>>& members,
  +    const std::unordered_map<int64_t, ParsedData::WayGeometry>& way_geoms)
  +{
  +    // Match way endpoints by OSM node ID (exact) rather than coordinates (lossy)
  +    struct WayRef {
  +        int64_t way_id;
  +        const ParsedData::WayGeometry* geom;
  ... (truncated)
  @@ -342,19 +570,19 @@
  -static uint64_t addr_count_total = 0;
  -
  -static void add_addr_point(double lat, double lng, const char* housenumber, const char* street) {
  -    uint32_t addr_id = static_cast<uint32_t>(addr_points.size());
  -    addr_points.push_back({
  +static void add_addr_point(ParsedData& data, double lat, double lng,
  +                           const char* housenumber, const char* street,
  +                           uint64_t& addr_count_total) {
  +    uint32_t addr_id = static_cast<uint32_t>(data.addr_points.size());
  +    data.addr_points.push_back({
  ... (truncated)
  @@ -364,72 +592,83 @@
  -static void add_admin_polygon(const std::vector<std::pair<double,double>>& vertices,
  +static void add_admin_polygon(ParsedData& data,
  +                               const std::vector<std::pair<double,double>>& vertices_in,
                                  const char* name, uint8_t admin_level,
  -                               const char* country_code) {
  +                               const char* country_code,
  +                               AdminCoverPool* admin_pool = nullptr) {
  +    // Normalize ring rotation: start at the vertex with smallest (lat, lng)
  +    // This ensures identical output regardless of ring assembly order.
  +    auto vertices = vertices_in;
  ... (truncated)
  +298 -69


Let me get more of the diff, specifically looking at the way processing sections:


git diff 93fe6e4..3984d21 -- builder/src/build_index.cpp | grep -A 20 -B 5 "is_included_highway\|add_way\|valid.*way\|node.*count" | head -200


-static StringPool string_pool;
+struct DeferredWay {
+    uint32_t way_id;
+    uint32_t node_offset;
+    uint8_t node_count;
+};

-// Streets
-static std::vector<WayHeader> ways;
-static std::vector<NodeCoord> street_nodes;
-static std::unordered_map<uint64_t, std::vector<uint32_t>> cell_to_ways;
+struct DeferredInterp {
+    uint32_t interp_id;
+    uint32_t node_offset;
+    uint8_t node_count;
+};

-// Addresses
-static std::vector<AddrPoint> addr_points;
-static std::unordered_map<uint64_t, std::vector<uint32_t>> cell_to_addrs;
+// Collected relation data for parallel admin assembly
+struct CollectedRelation {
+    int64_t id;
+    uint8_t admin_level;
+    std::string name;
+    std::string country_code;
+    bool is_postal;
+    std::vector<std::pair<int64_t, std::string>> members; // (way_id, role)
+};
+
+// Forward declaration
+static std::vector<std::pair<S2CellId, bool>> cover_polygon(const std::vector<std::pair<double,double>>& vertices);
+
+// --- Thread pool for concurrent admin polygon S2 covering ---

--
                 static_cast<float>(nr.location().lon())
             });
@@ -556,27 +800,15 @@ private:
         InterpWay iw{};
         iw.node_offset = node_offset;
         iw.node_count = static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)));
-        iw.street_id = string_pool.intern(street);
+        iw.street_id = data_.string_pool.intern(street);
         iw.start_number = 0;
         iw.end_number = 0;
         iw.interpolation = interp_type;
-        interp_ways.push_back(iw);
+        data_.interp_ways.push_back(iw);

-        std::unordered_set<uint64_t> interp_cells;
-        for (size_t i = 0; i + 1 < wnodes.size(); i++) {
-            double lat1 = wnodes[i].location().lat();
-            double lng1 = wnodes[i].location().lon();
-            double lat2 = wnodes[i + 1].location().lat();
-            double lng2 = wnodes[i + 1].location().lon();
-
-            auto cell_ids = cover_edge(lat1, lng1, lat2, lng2);
-            for (const auto& cell_id : cell_ids) {
-                interp_cells.insert(cell_id.id());
-            }
-        }
--
                 static_cast<float>(nr.location().lon())
             });
@@ -602,35 +834,89 @@ private:
         WayHeader header{};
         header.node_offset = node_offset;
         header.node_count = static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)));
-        header.name_id = string_pool.intern(name);
-        ways.push_back(header);
+        header.name_id = data_.string_pool.intern(name);
+        data_.ways.push_back(header);

-        std::unordered_set<uint64_t> way_cells;
-        for (size_t i = 0; i + 1 < wnodes.size(); i++) {
-            double lat1 = wnodes[i].location().lat();
-            double lng1 = wnodes[i].location().lon();
-            double lat2 = wnodes[i + 1].location().lat();
-            double lng2 = wnodes[i + 1].location().lon();
+        // Defer S2 cell computation to parallel phase
+        data_.deferred_ways.push_back({way_id, node_offset, header.node_count});

-            auto cell_ids = cover_edge(lat1, lng1, lat2, lng2);
-            for (const auto& cell_id : cell_ids) {
-                way_cells.insert(cell_id.id());
-            }
+        way_count_++;
+        if (way_count_ % 1000000 == 0) {
+            std::cerr << "Collected " << way_count_ / 1000000 << "M street ways..." << std::endl;
         }
-        for (uint64_t cell_id : way_cells) {
-            cell_to_ways[cell_id].push_back(way_id);
+    }
+};
+
+// --- Relation collector for parallel admin assembly (runs during pass 1) ---
+
+struct RelationCollector : public osmium::handler::Handler {
+    std::vector<CollectedRelation>& relations;
+
+    explicit RelationCollector(std::vector<CollectedRelation>& r) : relations(r) {}
--
     }

     uint32_t resolved = 0;
-    for (auto& iw : interp_ways) {
+    for (auto& iw : data.interp_ways) {
         if (iw.node_count < 2) continue;

-        const auto& start = interp_nodes[iw.node_offset];
+        const auto& start = data.interp_nodes[iw.node_offset];
         CoordKey start_key{
             static_cast<int32_t>(start.lat * 100000),
             static_cast<int32_t>(start.lng * 100000)
         };
         auto it_start = addr_by_coord.find(start_key);

-        const auto& end = interp_nodes[iw.node_offset + iw.node_count - 1];
+        const auto& end = data.interp_nodes[iw.node_offset + iw.node_count - 1];
         CoordKey end_key{
             static_cast<int32_t>(end.lat * 100000),
             static_cast<int32_t>(end.lng * 100000)
@@ -670,18 +956,18 @@ static void resolve_interpolation_endpoints() {
         auto it_end = addr_by_coord.find(end_key);

         if (it_start != addr_by_coord.end()) {
-            const char* hn = string_pool.data().data() + addr_points[it_start->second].housenumber_id;
+            const char* hn = data.string_pool.data().data() + data.addr_points[it_start->second].housenumber_id;
             iw.start_number = parse_house_number(hn);
         }
         if (it_end != addr_by_coord.end()) {
-            const char* hn = string_pool.data().data() + addr_points[it_end->second].housenumber_id;
+            const char* hn = data.string_pool.data().data() + data.addr_points[it_end->second].housenumber_id;
             iw.end_number = parse_house_number(hn);
         }

         if (iw.start_number > 0 && iw.end_number > 0) resolved++;
     }

--
+            way_remap[old_id] = new_id;
+            const auto& w = full.ways[old_id];
+            WayHeader nw = w;
+            nw.node_offset = static_cast<uint32_t>(out.street_nodes.size());
+            out.ways.push_back(nw);
+            for (uint8_t n = 0; n < w.node_count; n++) {
+                out.street_nodes.push_back(full.street_nodes[w.node_offset + n]);
+            }
+        }
+    }
+
+    // --- Build remapped addr points ---
+    std::unordered_map<uint32_t, uint32_t> addr_remap;
+    {
+        std::vector<uint32_t> sorted_ids(used_addr_ids.begin(), used_addr_ids.end());
+        std::sort(sorted_ids.begin(), sorted_ids.end());
+        for (uint32_t old_id : sorted_ids) {
+            uint32_t new_id = static_cast<uint32_t>(out.addr_points.size());
+            addr_remap[old_id] = new_id;
+            out.addr_points.push_back(full.addr_points[old_id]);
+        }
+    }
+
+    // --- Build remapped interp ways ---
+    std::unordered_map<uint32_t, uint32_t> interp_remap;
+    {
--
+            interp_remap[old_id] = new_id;
+            const auto& iw = full.interp_ways[old_id];
+            InterpWay niw = iw;
+            niw.node_offset = static_cast<uint32_t>(out.interp_nodes.size());
+            out.interp_ways.push_back(niw);
+            for (uint8_t n = 0; n < iw.node_count; n++) {
+                out.interp_nodes.push_back(full.interp_nodes[iw.node_offset + n]);
+            }
+        }
+    }
+
+    // --- Build remapped admin polygons ---
+    std::unordered_map<uint32_t, uint32_t> admin_remap;
+    {
+        std::vector<uint32_t> sorted_ids(used_admin_ids.begin(), used_admin_ids.end());
+        std::sort(sorted_ids.begin(), sorted_ids.end());
+        for (uint32_t old_id : sorted_ids) {
+            uint32_t new_id = static_cast<uint32_t>(out.admin_polygons.size());
+            admin_remap[old_id] = new_id;
+            const auto& ap = full.admin_polygons[old_id];
+            AdminPolygon nap = ap;
+            nap.vertex_offset = static_cast<uint32_t>(out.admin_vertices.size());
+            out.admin_polygons.push_back(nap);
+            for (uint16_t v = 0; v < ap.vertex_count; v++) {
+                out.admin_vertices.push_back(full.admin_vertices[ap.vertex_offset + v]);
+            }
--
+                                                    if (std::strcmp(interpolation, "even") == 0) interp_type = 1;
+                                                    else if (std::strcmp(interpolation, "odd") == 0) interp_type = 2;
+
+                                                    InterpWay iw{};


Let me look at the actual way filtering logic more carefully by checking both versions of the file:


     1→#include <algorithm>
     2→#include <atomic>
     3→#include <cmath>
     4→#include <condition_variable>
     5→#include <cstdint>
     6→#include <cstring>
     7→#include <deque>
     8→#include <fstream>
     9→#include <future>
    10→#include <iostream>
    11→#include <mutex>
    12→#include <set>
    13→#include <string>
    14→#include <thread>
    15→#include <unordered_map>
    16→#include <unordered_set>
    17→#include <vector>
    18→
    19→#include <osmium/handler.hpp>
    20→#include <osmium/io/pbf_input.hpp>
    21→#include <osmium/visitor.hpp>
    22→#include <osmium/handler/node_locations_for_ways.hpp>
    23→#include <fcntl.h>
    24→#include <unistd.h>
    25→#include <sys/mman.h>
    26→#include <sys/stat.h>
    27→// FlexMem no longer used — replaced by DenseIndex mmap
    28→#include <osmium/area/assembler.hpp>
    29→#include <osmium/area/multipolygon_manager.hpp>
    30→
    31→#include <s2/s2cell_id.h>
    32→#include <s2/s2latlng.h>
    33→#include <s2/s2region_coverer.h>
    34→#include <s2/s2polyline.h>
    35→#include <s2/s2polygon.h>
    36→#include <s2/s2loop.h>
    37→#include <s2/s2builder.h>
    38→
    39→// --- Directory creation ---
    40→
    41→static void ensure_dir(const std::string& path) {
    42→    mkdir(path.c_str(), 0755);
    43→}
    44→
    45→// --- Binary format structs ---
    46→
    47→struct WayHeader {
    48→    uint32_t node_offset;
    49→    uint8_t node_count;
    50→    uint32_t name_id;
    51→};
    52→
    53→struct AddrPoint {
    54→    float lat;
    55→    float lng;
    56→    uint32_t housenumber_id;
    57→    uint32_t street_id;
    58→};
    59→
    60→struct InterpWay {
    61→    uint32_t node_offset;
    62→    uint8_t node_count;
    63→    uint32_t street_id;
    64→    uint32_t start_number;
    65→    uint32_t end_number;
    66→    uint8_t interpolation;
    67→};
    68→
    69→struct AdminPolygon {
    70→    uint32_t vertex_offset;
    71→    uint16_t vertex_count;
    72→    uint32_t name_id;
    73→    uint8_t admin_level;
    74→    float area;
    75→    uint16_t country_code;
    76→};
    77→
    78→struct NodeCoord {
    79→    float lat;
    80→    float lng;
    81→};
    82→
    83→static const uint32_t INTERIOR_FLAG = 0x80000000u;
    84→static const uint32_t ID_MASK = 0x7FFFFFFFu;
    85→
    86→// --- Index mode ---
    87→
    88→enum class IndexMode { Full, NoAddresses, AdminOnly };
    89→
    90→// --- String interning ---
    91→
    92→class StringPool {
    93→public:
    94→    uint32_t intern(const std::string& s) {
    95→        auto it = index_.find(s);
    96→        if (it != index_.end()) {
    97→            return it->second;
    98→        }
    99→        uint32_t offset = static_cast<uint32_t>(data_.size());
   100→        index_[s] = offset;
   101→        data_.insert(data_.end(), s.begin(), s.end());
   102→        data_.push_back('\0');
   103→        return offset;
   104→    }
   105→
   106→    const std::vector<char>& data() const { return data_; }
   107→    std::vector<char>& mutable_data() { return data_; }
   108→
   109→private:
   110→    std::unordered_map<std::string, uint32_t> index_;
   111→    std::vector<char> data_;
   112→};
   113→
   114→// --- Deferred S2 work items (computed in parallel after PBF read) ---
   115→
   116→struct DeferredWay {
   117→    uint32_t way_id;
   118→    uint32_t node_offset;
   119→    uint8_t node_count;
   120→};
   121→
   122→struct DeferredInterp {
   123→    uint32_t interp_id;
   124→    uint32_t node_offset;
   125→    uint8_t node_count;
   126→};
   127→
   128→// Collected relation data for parallel admin assembly
   129→struct CollectedRelation {
   130→    int64_t id;
   131→    uint8_t admin_level;
   132→    std::string name;
   133→    std::string country_code;
   134→    bool is_postal;
   135→    std::vector<std::pair<int64_t, std::string>> members; // (way_id, role)
   136→};
   137→
   138→// Forward declaration
   139→static std::vector<std::pair<S2CellId, bool>> cover_polygon(const std::vector<std::pair<double,double>>& vertices);
   140→
   141→// --- Thread pool for concurrent admin polygon S2 covering ---
   142→
   143→class AdminCoverPool {
   144→public:
   145→    struct WorkItem {
   146→        uint32_t poly_id;
   147→        std::vector<std::pair<double,double>> vertices;
   148→    };
   149→
   150→    explicit AdminCoverPool(size_t num_threads) : stop_(false) {


Now let me check the current version to find the is_included_highway function:


grep -n "is_included_highway" /root/traccar-geocoder/builder/src/build_index.cpp


552:static bool is_included_highway(const char* value) {
677:        if (highway && is_included_highway(highway)) {
2079:                                    if (highway && is_included_highway(highway)) {


   552→static bool is_included_highway(const char* value) {
   553→    for (const auto& excluded : kExcludedHighways) {
   554→        if (excluded == value) return false;
   555→    }
   556→    return true;
   557→}
   558→
   559→// --- Parse house number (leading digits) ---
   560→
   561→static uint32_t parse_house_number(const char* s) {
   562→    if (!s) return 0;
   563→    uint32_t n = 0;
   564→    while (*s >= '0' && *s <= '9') {
   565→        n = n * 10 + (*s - '0');
   566→        s++;
   567→    }
   568→    return n;
   569→}
   570→
   571→// --- Add an address point ---
   572→
   573→static void add_addr_point(ParsedData& data, double lat, double lng,
   574→                           const char* housenumber, const char* street,
   575→                           uint64_t& addr_count_total) {
   576→    uint32_t addr_id = static_cast<uint32_t>(data.addr_points.size());
   577→    data.addr_points.push_back({
   578→        static_cast<float>(lat),
   579→        static_cast<float>(lng),
   580→        data.string_pool.intern(housenumber),
   581→        data.string_pool.intern(street)
   582→    });
   583→
   584→    S2CellId cell = point_to_cell(lat, lng);
   585→    data.cell_to_addrs[cell.id()].push_back(addr_id);
   586→
   587→    addr_count_total++;
   588→    if (addr_count_total % 1000000 == 0) {
   589→        std::cerr << "Collected " << addr_count_total / 1000000 << "M addresses..." << std::endl;
   590→    }
   591→}
   592→
   593→// --- Add an admin polygon ---
   594→
   595→static void add_admin_polygon(ParsedData& data,
   596→                               const std::vector<std::pair<double,double>>& vertices_in,
   597→                               const char* name, uint8_t admin_level,
   598→                               const char* country_code,
   599→                               AdminCoverPool* admin_pool = nullptr) {
   600→    // Normalize ring rotation: start at the vertex with smallest (lat, lng)
   601→    // This ensures identical output regardless of ring assembly order.
   602→    auto vertices = vertices_in;
   603→    if (vertices.size() >= 4 &&
   604→        std::fabs(vertices.front().first - vertices.back().first) < 1e-7 &&
   605→        std::fabs(vertices.front().second - vertices.back().second) < 1e-7) {
   606→        vertices.pop_back();
   607→        auto min_it = std::min_element(vertices.begin(), vertices.end());
   608→        std::rotate(vertices.begin(), min_it, vertices.end());
   609→        vertices.push_back(vertices.front());
   610→    }
   611→
   612→    // Simplify large polygons
   613→    auto simplified = simplify_polygon(vertices, 500);
   614→    if (simplified.size() < 3) return;
   615→
   616→    uint32_t poly_id = static_cast<uint32_t>(data.admin_polygons.size());
   617→    uint32_t vertex_offset = static_cast<uint32_t>(data.admin_vertices.size());
   618→
   619→    for (const auto& [lat, lng] : simplified) {
   620→        data.admin_vertices.push_back({static_cast<float>(lat), static_cast<float>(lng)});
   621→    }
   622→
   623→    AdminPolygon poly{};
   624→    poly.vertex_offset = vertex_offset;
   625→    poly.vertex_count = static_cast<uint16_t>(std::min(simplified.size(), size_t(65535)));
   626→    poly.name_id = data.string_pool.intern(name);
   627→    poly.admin_level = admin_level;
   628→    poly.area = polygon_area(simplified);
   629→    poly.country_code = (country_code && country_code[0] && country_code[1])
   630→        ? static_cast<uint16_t>((country_code[0] << 8) | country_code[1])
   631→        : 0;
   632→    data.admin_polygons.push_back(poly);
   633→
   634→    // Submit S2 cell coverage to thread pool (runs concurrently with PBF reading)
   635→    if (admin_pool) {
   636→        admin_pool->submit(poly_id, std::move(simplified));
   637→    }
   638→}
   639→
   640→// --- OSM handler ---
   641→
   642→class BuildHandler : public osmium::handler::Handler {
   643→public:
   644→    explicit BuildHandler(ParsedData& data, AdminCoverPool* admin_pool = nullptr)
   645→        : data_(data), admin_pool_(admin_pool) {}
   646→
   647→    void node(const osmium::Node& node) {
   648→        const char* housenumber = node.tags()["addr:housenumber"];
   649→        if (!housenumber) return;
   650→        const char* street = node.tags()["addr:street"];
   651→        if (!street) return;


Now let me check what kExcludedHighways is defined as:


grep -n "kExcludedHighways" /root/traccar-geocoder/builder/src/build_index.cpp | head -5


547:static const std::vector<std::string> kExcludedHighways = {
553:    for (const auto& excluded : kExcludedHighways) {


   547→static const std::vector<std::string> kExcludedHighways = {
   548→    "footway", "path", "track", "steps", "cycleway",
   549→    "service", "pedestrian", "bridleway", "construction"
   550→};
   551→
   552→static bool is_included_highway(const char* value) {
   553→    for (const auto& excluded : kExcludedHighways) {
   554→        if (excluded == value) return false;
   555→    }
   556→    return true;
   557→}
   558→
   559→// --- Parse house number (leading digits) ---
   560→
   561→static uint32_t parse_house_number(const char* s) {


Now let me check the old version at commit 93fe6e4:


git show 93fe6e4:builder/src/build_index.cpp | grep -A 10 "kExcludedHighways\|is_included_highway" | head -30


static const std::vector<std::string> kExcludedHighways = {
    "footway", "path", "track", "steps", "cycleway",
    "service", "pedestrian", "bridleway", "construction"
};

static bool is_included_highway(const char* value) {
    for (const auto& excluded : kExcludedHighways) {
        if (excluded == value) return false;
    }
    return true;
}

// --- Parse house number (leading digits) ---

static uint32_t parse_house_number(const char* s) {
    if (!s) return 0;
    uint32_t n = 0;
--
        if (highway && is_included_highway(highway)) {
            const char* name = way.tags()["name"];
            if (name) {
                process_highway(way, name);
            }
        }
    }

    void area(const osmium::Area& area) {
        const char* boundary = area.tags()["boundary"];
        if (!boundary) return;


The excluded highways list is the same. Now let me check the full diff more carefully by looking at way processing differences. Let me search for changes to the way addition logic:


git diff 93fe6e4..3984d21 -- builder/src/build_index.cpp | grep -A 30 "void way\|process_highway" | head -100


     void way(const osmium::Way& way) {
-        if (kAdminOnly) return;
-
-        if (!kNoAddresses) {
-            // Address interpolation
-            const char* interpolation = way.tags()["addr:interpolation"];
-            if (interpolation) {
-                process_interpolation_way(way, interpolation);
-                return;
-            }
-
-            // Building addresses
-            const char* housenumber = way.tags()["addr:housenumber"];
-            if (housenumber) {
-                const char* street = way.tags()["addr:street"];
-                if (street) {
-                    process_building_address(way, housenumber, street);
-                }
+        // Address interpolation
+        const char* interpolation = way.tags()["addr:interpolation"];
+        if (interpolation) {
+            process_interpolation_way(way, interpolation);
+            return;
+        }
+
+        // Building addresses
+        const char* housenumber = way.tags()["addr:housenumber"];
+        if (housenumber) {
+            const char* street = way.tags()["addr:street"];
+            if (street) {
+                process_building_address(way, housenumber, street);
--
+                            void way(osmium::Way& way) {
+                                for (auto& nr : way.nodes()) {
+                                    nr.set_location(idx.get(nr.positive_ref()));
+                                }
+                            }
+                        } dense_loc_handler{index};
+
+                        osmium::io::Reader reader_rels{input_file};
+                        osmium::apply(reader_rels, dense_loc_handler,
+                            mp_manager.handler([&handler](osmium::memory::Buffer&& buffer) {
+                                osmium::apply(buffer, handler);
+                            }));
+                        reader_rels.close();
+                    }
+                }
+
+                // Merge thread-local way/interp data into main ParsedData
+                std::cerr << "  Merging thread-local data..." << std::endl;
+                uint64_t total_ways = 0, total_building_addrs = 0, total_interps = 0;
+                for (auto& local : tld) {
+                    uint32_t way_base = static_cast<uint32_t>(data.ways.size());
+                    uint32_t node_base = static_cast<uint32_t>(data.street_nodes.size());
+                    uint32_t interp_base = static_cast<uint32_t>(data.interp_ways.size());
+                    uint32_t interp_node_base = static_cast<uint32_t>(data.interp_nodes.size());
+
+                    // Merge street ways
+                    for (size_t i = 0; i < local.ways.size(); i++) {
+                        auto h = local.ways[i];
+                        h.node_offset += node_base;
+                        h.name_id = data.string_pool.intern(local.way_strings[i]);
+                        data.ways.push_back(h);


That's interesting! Let me see what the kAdminOnly and kNoAddresses logic was doing in the old version:


git show 93fe6e4:builder/src/build_index.cpp | grep -B 5 "if (kAdminOnly)" | head -20


        add_addr_point(node.location().lat(), node.location().lon(), housenumber, street);
    }

    void way(const osmium::Way& way) {
        if (kAdminOnly) return;
--
              << handler.building_addr_count() << " from buildings)" << std::endl;
    std::cerr << "  " << handler.interp_count() << " interpolation ways" << std::endl;
    std::cerr << "  " << handler.admin_count() << " admin/postcode boundaries ("
              << admin_polygons.size() << " polygon rings)" << std::endl;

    if (kAdminOnly) {


Let me check what kAdminOnly is and how it's set:


git show 93fe6e4:builder/src/build_index.cpp | grep -n "kAdminOnly\|kNoAddresses" | head -20


122:static bool kAdminOnly = false;
124:static bool kNoAddresses = false;
405:        if (kAdminOnly || kNoAddresses) return;
416:        if (kAdminOnly) return;
418:        if (!kNoAddresses) {
755:    if (!kAdminOnly) {
759:        if (!kNoAddresses) {
769:        if (!kNoAddresses) {
798:    if (!kAdminOnly) {
811:        if (!kNoAddresses) {
871:            kAdminOnly = true;
873:            kNoAddresses = true;
929:    if (kAdminOnly) {
932:    if (kNoAddresses) {
936:    if (!kAdminOnly && !kNoAddresses) {
942:    if (!kAdminOnly) {
944:        if (!kNoAddresses) {


git show 93fe6e4:builder/src/build_index.cpp | grep -A 5 "kAdminOnly = true"


            kAdminOnly = true;
        } else if (arg == "--no-addresses") {
            kNoAddresses = true;
        } else if (arg == "--max-admin-level" && i + 1 < argc) {
            kMaxAdminLevel = std::atoi(argv[++i]);
        } else {


Now let me check if those flags still exist in the current version:


grep -n "kAdminOnly\|kNoAddresses\|--admin-only\|--no-addresses" /root/traccar-geocoder/builder/src/build_index.cpp | head -20


1650:        std::cerr << "  --admin-only           Shorthand for --mode admin-only" << std::endl;
1651:        std::cerr << "  --no-addresses         Shorthand for --mode no-addresses" << std::endl;
1672:        } else if (arg == "--admin-only") {
1674:        } else if (arg == "--no-addresses") {


So the flags were removed and replaced with an IndexMode enum. Let me check the way() method in the current version to see if filtering logic was changed:


grep -n "void way" /root/traccar-geocoder/builder/src/build_index.cpp


658:    void way(const osmium::Way& way) {
2171:                            void way(osmium::Way& way) {


   658→    void way(const osmium::Way& way) {
   659→        // Address interpolation
   660→        const char* interpolation = way.tags()["addr:interpolation"];
   661→        if (interpolation) {
   662→            process_interpolation_way(way, interpolation);
   663→            return;
   664→        }
   665→
   666→        // Building addresses
   667→        const char* housenumber = way.tags()["addr:housenumber"];
   668→        if (housenumber) {
   669→            const char* street = way.tags()["addr:street"];
   670→            if (street) {
   671→                process_building_address(way, housenumber, street);
   672→            }
   673→        }
   674→
   675→        // Highway ways
   676→        const char* highway = way.tags()["highway"];
   677→        if (highway && is_included_highway(highway)) {
   678→            const char* name = way.tags()["name"];
   679→            if (name) {
   680→                process_highway(way, name);
   681→            }
   682→        }
   683→    }
   684→
   685→    void area(const osmium::Area& area) {
   686→        const char* boundary = area.tags()["boundary"];
   687→        if (!boundary) return;
   688→
   689→        bool is_admin = (std::strcmp(boundary, "administrative") == 0);
   690→        bool is_postal = (std::strcmp(boundary, "postal_code") == 0);
   691→        if (!is_admin && !is_postal) return;
   692→
   693→        uint8_t admin_level = 0;
   694→        if (is_admin) {
   695→            const char* level_str = area.tags()["admin_level"];
   696→            if (!level_str) return;
   697→            admin_level = static_cast<uint8_t>(std::atoi(level_str));
   698→            if (admin_level < 2 || admin_level > 10) return;
   699→        } else {
   700→            admin_level = 11; // use 11 for postal codes
   701→        }
   702→        if (kMaxAdminLevel > 0 && admin_level > kMaxAdminLevel) return;
   703→
   704→        const char* name = area.tags()["name"];
   705→        if (!name && is_admin) return;
   706→
   707→        // For postal codes, use postal_code tag as name
   708→        std::string name_str;
   709→        if (is_postal) {
   710→            const char* postal_code = area.tags()["postal_code"];
   711→            if (!postal_code) postal_code = name;
   712→            if (!postal_code) return;
   713→            name_str = postal_code;
   714→        } else {
   715→            name_str = name;
   716→        }
   717→


Now let me check the old version's way() method:


git show 93fe6e4:builder/src/build_index.cpp | grep -A 30 "void way(const osmium::Way& way)"


    void way(const osmium::Way& way) {
        if (kAdminOnly) return;

        if (!kNoAddresses) {
            // Address interpolation
            const char* interpolation = way.tags()["addr:interpolation"];
            if (interpolation) {
                process_interpolation_way(way, interpolation);
                return;
            }

            // Building addresses
            const char* housenumber = way.tags()["addr:housenumber"];
            if (housenumber) {
                const char* street = way.tags()["addr:street"];
                if (street) {
                    process_building_address(way, housenumber, street);
                }
            }
        }

        // Highway ways
        const char* highway = way.tags()["highway"];
        if (highway && is_included_highway(highway)) {
            const char* name = way.tags()["name"];
            if (name) {
                process_highway(way, name);
            }
        }
    }


Aha! I found a critical change! In the old version, there was `if (!kNoAddresses)` wrapping the address interpolation and building address processing. In the new version, those checks are removed. Let me verify this more carefully by examining what mode is being used:


grep -n "IndexMode\|kMode" /root/traccar-geocoder/builder/src/build_index.cpp | head -30


88:enum class IndexMode { Full, NoAddresses, AdminOnly };
1531:static void write_index(const ParsedData& data, const std::string& output_dir, IndexMode mode) {
1534:    bool write_streets = (mode != IndexMode::AdminOnly);
1535:    bool write_addresses = (mode == IndexMode::Full);
1664:    IndexMode mode = IndexMode::Full;
1673:            mode = IndexMode::AdminOnly;
1675:            mode = IndexMode::NoAddresses;
1693:                mode = IndexMode::Full;
1695:                mode = IndexMode::NoAddresses;
1697:                mode = IndexMode::AdminOnly;
2470:        auto wf1 = std::async(std::launch::async, [&]{ write_index(data, output_dir + "/full", IndexMode::Full); });
2471:        auto wf2 = std::async(std::launch::async, [&]{ write_index(data, output_dir + "/no-addresses", IndexMode::NoAddresses); });
2472:        auto wf3 = std::async(std::launch::async, [&]{ write_index(data, output_dir + "/admin-only", IndexMode::AdminOnly); });
2493:                    auto cf1 = std::async(std::launch::async, [&]{ write_index(subset, base + "/full", IndexMode::Full); });
2494:                    auto cf2 = std::async(std::launch::async, [&]{ write_index(subset, base + "/no-addresses", IndexMode::NoAddresses); });
2495:                    auto cf3 = std::async(std::launch::async, [&]{ write_index(subset, base + "/admin-only", IndexMode::AdminOnly); });


Wait, let me re-check this. The current code does NOT have the `if (!kNoAddresses)` guards in the way() method anymore. But I need to understand if the baseline was being run with a special mode. Let me check if there were other differences in the logic. Let me search for changes to the process_highway function:


git diff 93fe6e4..3984d21 -- builder/src/build_index.cpp | grep -A 50 "process_highway" | head -100


(Bash completed with no output)


Let me check if the process_highway function signature or logic changed:


grep -n "void process_highway\|void process_building_address\|void process_interpolation_way" /root/traccar-geocoder/builder/src/build_index.cpp


756:    void process_building_address(const osmium::Way& way, const char* housenumber, const char* street) {
775:    void process_interpolation_way(const osmium::Way& way, const char* interpolation) {
816:    void process_highway(const osmium::Way& way, const char* name) {


   816→    void process_highway(const osmium::Way& way, const char* name) {
   817→        const auto& wnodes = way.nodes();
   818→        if (wnodes.size() < 2) return;
   819→
   820→        for (const auto& nr : wnodes) {
   821→            if (!nr.location().valid()) return;
   822→        }
   823→
   824→        uint32_t way_id = static_cast<uint32_t>(data_.ways.size());
   825→        uint32_t node_offset = static_cast<uint32_t>(data_.street_nodes.size());
   826→
   827→        for (const auto& nr : wnodes) {
   828→            data_.street_nodes.push_back({
   829→                static_cast<float>(nr.location().lat()),
   830→                static_cast<float>(nr.location().lon())
   831→            });
   832→        }
   833→
   834→        WayHeader header{};
   835→        header.node_offset = node_offset;
   836→        header.node_count = static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)));
   837→        header.name_id = data_.string_pool.intern(name);
   838→        data_.ways.push_back(header);
   839→
   840→        // Defer S2 cell computation to parallel phase
   841→        data_.deferred_ways.push_back({way_id, node_offset, header.node_count});
   842→
   843→        way_count_++;
   844→        if (way_count_ % 1000000 == 0) {
   845→            std::cerr << "Collected " << way_count_ / 1000000 << "M street ways..." << std::endl;
   846→        }
   847→    }
   848→};
   849→
   850→// --- Relation collector for parallel admin assembly (runs during pass 1) ---
   851→
   852→struct RelationCollector : public osmium::handler::Handler {
   853→    std::vector<CollectedRelation>& relations;
   854→
   855→    explicit RelationCollector(std::vector<CollectedRelation>& r) : relations(r) {}
   856→
   857→    void relation(const osmium::Relation& rel) {
   858→        const char* boundary = rel.tags()["boundary"];
   859→        if (!boundary) return;
   860→
   861→        bool is_admin = (std::strcmp(boundary, "administrative") == 0);
   862→        bool is_postal = (std::strcmp(boundary, "postal_code") == 0);
   863→        if (!is_admin && !is_postal) return;
   864→
   865→        uint8_t admin_level = 0;


Now let me check the old version's process_highway:


git show 93fe6e4:builder/src/build_index.cpp | grep -A 50 "void process_highway"


    void process_highway(const osmium::Way& way, const char* name) {
        const auto& wnodes = way.nodes();
        if (wnodes.size() < 2) return;

        for (const auto& nr : wnodes) {
            if (!nr.location().valid()) return;
        }

        uint32_t way_id = static_cast<uint32_t>(ways.size());
        uint32_t node_offset = static_cast<uint32_t>(street_nodes.size());

        for (const auto& nr : wnodes) {
            street_nodes.push_back({
                static_cast<float>(nr.location().lat()),
                static_cast<float>(nr.location().lon())
            });
        }

        WayHeader header{};
        header.node_offset = node_offset;
        header.node_count = static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)));
        header.name_id = string_pool.intern(name);
        ways.push_back(header);

        std::unordered_set<uint64_t> way_cells;
        for (size_t i = 0; i + 1 < wnodes.size(); i++) {
            double lat1 = wnodes[i].location().lat();
            double lng1 = wnodes[i].location().lon();
            double lat2 = wnodes[i + 1].location().lat();
            double lng2 = wnodes[i + 1].location().lon();

            auto cell_ids = cover_edge(lat1, lng1, lat2, lng2);
            for (const auto& cell_id : cell_ids) {
                way_cells.insert(cell_id.id());
            }
        }
        for (uint64_t cell_id : way_cells) {
            cell_to_ways[cell_id].push_back(way_id);
        }

        way_count_++;
        if (way_count_ % 1000000 == 0) {
            std::cerr << "Processed " << way_count_ / 1000000 << "M street ways..." << std::endl;
        }
    }
};

// --- Resolve interpolation way endpoint house numbers ---

static void resolve_interpolation_endpoints() {
    struct CoordKey {


I see! There's a potential difference. The old version added ways to `cell_to_ways` in the process_highway function immediately. The new version defers S2 cell computation. Let me check if this affects which ways are actually included. Let me examine the full diff more systematically, looking for way filtering changes:


git diff 93fe6e4..3984d21 -- builder/src/build_index.cpp | grep -B 10 -A 10 "way_cells\|cell_to_ways" | head -150


-static StringPool string_pool;
+struct DeferredWay {
+    uint32_t way_id;
+    uint32_t node_offset;
+    uint8_t node_count;
+};

-// Streets
-static std::vector<WayHeader> ways;
-static std::vector<NodeCoord> street_nodes;
-static std::unordered_map<uint64_t, std::vector<uint32_t>> cell_to_ways;
+struct DeferredInterp {
+    uint32_t interp_id;
+    uint32_t node_offset;
+    uint8_t node_count;
+};

-// Addresses
-static std::vector<AddrPoint> addr_points;
-static std::unordered_map<uint64_t, std::vector<uint32_t>> cell_to_addrs;
+// Collected relation data for parallel admin assembly
--
+    size_t active_workers_ = 0;
+    bool stop_;
+};
+
+// --- Parsed data container ---
+
+struct ParsedData {
+    StringPool string_pool;
+    std::vector<WayHeader> ways;
+    std::vector<NodeCoord> street_nodes;
+    std::unordered_map<uint64_t, std::vector<uint32_t>> cell_to_ways;
+    std::vector<AddrPoint> addr_points;
+    std::unordered_map<uint64_t, std::vector<uint32_t>> cell_to_addrs;
+    std::vector<InterpWay> interp_ways;
+    std::vector<NodeCoord> interp_nodes;
+    std::unordered_map<uint64_t, std::vector<uint32_t>> cell_to_interps;
+    std::vector<AdminPolygon> admin_polygons;
+    std::vector<NodeCoord> admin_vertices;
+    std::unordered_map<uint64_t, std::vector<uint32_t>> cell_to_admin;
+
+    // Deferred work for parallel S2 computation (ways + interps)
--
             });
@@ -602,35 +834,89 @@ private:
         WayHeader header{};
         header.node_offset = node_offset;
         header.node_count = static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)));
-        header.name_id = string_pool.intern(name);
-        ways.push_back(header);
+        header.name_id = data_.string_pool.intern(name);
+        data_.ways.push_back(header);

-        std::unordered_set<uint64_t> way_cells;
-        for (size_t i = 0; i + 1 < wnodes.size(); i++) {
-            double lat1 = wnodes[i].location().lat();
-            double lng1 = wnodes[i].location().lon();
-            double lat2 = wnodes[i + 1].location().lat();
-            double lng2 = wnodes[i + 1].location().lon();
+        // Defer S2 cell computation to parallel phase
+        data_.deferred_ways.push_back({way_id, node_offset, header.node_count});

-            auto cell_ids = cover_edge(lat1, lng1, lat2, lng2);
-            for (const auto& cell_id : cell_ids) {
-                way_cells.insert(cell_id.id());
-            }
+        way_count_++;
+        if (way_count_ % 1000000 == 0) {
+            std::cerr << "Collected " << way_count_ / 1000000 << "M street ways..." << std::endl;
         }
-        for (uint64_t cell_id : way_cells) {
-            cell_to_ways[cell_id].push_back(way_id);
+    }
+};
+
+// --- Relation collector for parallel admin assembly (runs during pass 1) ---
+
+struct RelationCollector : public osmium::handler::Handler {
+    std::vector<CollectedRelation>& relations;
+
+    explicit RelationCollector(std::vector<CollectedRelation>& r) : relations(r) {}
+
--
+        const auto& sp = data.string_pool.data();
+        std::vector<char> blob(sizeof(uint64_t) + sp.size());
+        uint64_t sz = sp.size();
+        std::memcpy(blob.data(), &sz, sizeof(uint64_t));
+        if (sz > 0) std::memcpy(blob.data() + sizeof(uint64_t), sp.data(), sz);
+        write_section(SectionType::STRING_POOL, blob);
+    }
+
+    write_section(SectionType::WAYS, serialize_vector(data.ways));
+    write_section(SectionType::STREET_NODES, serialize_vector(data.street_nodes));
+    write_section(SectionType::CELL_TO_WAYS, serialize_cell_map(data.cell_to_ways));
+    write_section(SectionType::ADDR_POINTS, serialize_vector(data.addr_points));
+    write_section(SectionType::CELL_TO_ADDRS, serialize_cell_map(data.cell_to_addrs));
+    write_section(SectionType::INTERP_WAYS, serialize_vector(data.interp_ways));
+    write_section(SectionType::INTERP_NODES, serialize_vector(data.interp_nodes));
+    write_section(SectionType::CELL_TO_INTERPS, serialize_cell_map(data.cell_to_interps));
+    write_section(SectionType::ADMIN_POLYGONS, serialize_vector(data.admin_polygons));
+    write_section(SectionType::ADMIN_VERTICES, serialize_vector(data.admin_vertices));
+    write_section(SectionType::CELL_TO_ADMIN, serialize_cell_map(data.cell_to_admin));
+
+    std::cerr << "Cache saved." << std::endl;
--
+            if (sz > 0) std::memcpy(sp.data(), blob.data() + sizeof(uint64_t), sz);
+            break;
+        }
+        case SectionType::WAYS:
+            if (!deserialize_vector(blob.data(), length, data.ways)) return false;
+            break;
+        case SectionType::STREET_NODES:
+            if (!deserialize_vector(blob.data(), length, data.street_nodes)) return false;
+            break;
+        case SectionType::CELL_TO_WAYS:
+            if (!deserialize_cell_map(blob.data(), length, data.cell_to_ways)) return false;
+            break;
+        case SectionType::ADDR_POINTS:
+            if (!deserialize_vector(blob.data(), length, data.addr_points)) return false;
+            break;
+        case SectionType::CELL_TO_ADDRS:
+            if (!deserialize_cell_map(blob.data(), length, data.cell_to_addrs)) return false;
+            break;
+        case SectionType::INTERP_WAYS:
+            if (!deserialize_vector(blob.data(), length, data.interp_ways)) return false;
+            break;
--
+    S2LatLng center = cell.ToLatLng();
+    double lat = center.lat().degrees();
+    double lng = center.lng().degrees();
+    return lat >= bbox.min_lat && lat <= bbox.max_lat &&
+           lng >= bbox.min_lng && lng <= bbox.max_lng;
+}
+
+static ParsedData filter_by_bbox(const ParsedData& full, const ContinentBBox& bbox) {
+    ParsedData out;
+
+    // --- Filter cell_to_ways and collect referenced way IDs ---
+    std::unordered_set<uint32_t> used_way_ids;
+    for (const auto& [cell_id, ids] : full.cell_to_ways) {
+        if (cell_in_bbox(cell_id, bbox)) {
+            for (uint32_t id : ids) {
+                used_way_ids.insert(id);
+            }
+        }
+    }
+
+    // --- Filter cell_to_addrs and collect referenced addr IDs ---
+    std::unordered_set<uint32_t> used_addr_ids;


Now let me look at the deferred ways processing to see if there's a difference in which ways actually get added to cell_to_ways:


git diff 93fe6e4..3984d21 -- builder/src/build_index.cpp | grep -B 5 -A 30 "deferred_ways\|process.*deferred" | head -150


+    std::vector<AdminPolygon> admin_polygons;
+    std::vector<NodeCoord> admin_vertices;
+    std::unordered_map<uint64_t, std::vector<uint32_t>> cell_to_admin;
+
+    // Deferred work for parallel S2 computation (ways + interps)
+    std::vector<DeferredWay> deferred_ways;
+    std::vector<DeferredInterp> deferred_interps;
+
+    // Collected data for parallel admin assembly
+    std::vector<CollectedRelation> collected_relations;
+    struct WayGeometry {
+        std::vector<std::pair<double,double>> coords;
+        int64_t first_node_id;
+        int64_t last_node_id;
+    };
+    std::unordered_map<int64_t, WayGeometry> way_geometries;
+};

 // --- S2 helpers ---

 static int kStreetCellLevel = 17;
 static int kAdminCellLevel = 10;
-static bool kAdminOnly = false;
 static int kMaxAdminLevel = 0;  // 0 means no filtering
-static bool kNoAddresses = false;

 static std::vector<S2CellId> cover_edge(double lat1, double lng1, double lat2, double lng2) {
     S2Point p1 = S2LatLng::FromDegrees(lat1, lng1).ToPoint();
@@ -146,6 +294,18 @@ static S2CellId point_to_cell(double lat, double lng) {
     return S2CellId(S2LatLng::FromDegrees(lat, lng)).parent(kStreetCellLevel);
 }

+// Approximate polygon area in square degrees
+static float polygon_area(const std::vector<std::pair<double,double>>& vertices) {
+    double area = 0;
+    size_t n = vertices.size();
--
-            double lat1 = wnodes[i].location().lat();
-            double lng1 = wnodes[i].location().lon();
-            double lat2 = wnodes[i + 1].location().lat();
-            double lng2 = wnodes[i + 1].location().lon();
+        // Defer S2 cell computation to parallel phase
+        data_.deferred_ways.push_back({way_id, node_offset, header.node_count});

-            auto cell_ids = cover_edge(lat1, lng1, lat2, lng2);
-            for (const auto& cell_id : cell_ids) {
-                way_cells.insert(cell_id.id());
-            }
+        way_count_++;
+        if (way_count_ % 1000000 == 0) {
+            std::cerr << "Collected " << way_count_ / 1000000 << "M street ways..." << std::endl;
         }
-        for (uint64_t cell_id : way_cells) {
-            cell_to_ways[cell_id].push_back(way_id);
+    }
+};
+
+// --- Relation collector for parallel admin assembly (runs during pass 1) ---
+
+struct RelationCollector : public osmium::handler::Handler {
+    std::vector<CollectedRelation>& relations;
+
+    explicit RelationCollector(std::vector<CollectedRelation>& r) : relations(r) {}
+
+    void relation(const osmium::Relation& rel) {
+        const char* boundary = rel.tags()["boundary"];
+        if (!boundary) return;
+
+        bool is_admin = (std::strcmp(boundary, "administrative") == 0);
+        bool is_postal = (std::strcmp(boundary, "postal_code") == 0);
+        if (!is_admin && !is_postal) return;
+
+        uint8_t admin_level = 0;
--
+
+                // Thread-local data for parallel way processing
+                struct ThreadLocalData {
+                    std::vector<WayHeader> ways;
+                    std::vector<NodeCoord> street_nodes;
+                    std::vector<DeferredWay> deferred_ways;
+                    std::vector<InterpWay> interp_ways;
+                    std::vector<NodeCoord> interp_nodes;
+                    std::vector<DeferredInterp> deferred_interps;
+                    std::vector<AddrPoint> building_addrs;
+                    std::vector<std::pair<double, double>> building_addr_coords; // lat,lng for S2 cell
+                    std::vector<std::string> way_strings;      // way name strings
+                    std::vector<std::string> addr_strings;     // building addr "hn\0street" strings
+                    std::vector<std::string> interp_strings;   // interp street name strings
+                    uint64_t way_count = 0;
+                    uint64_t building_addr_count = 0;
+                    uint64_t interp_count = 0;
+                    // Way geometries for parallel admin assembly
+                    struct WayGeomEntry {
+                        int64_t way_id;
+                        std::vector<std::pair<double,double>> coords;
+                        int64_t first_node_id;
+                        int64_t last_node_id;
+                    };
+                    std::vector<WayGeomEntry> way_geoms;
+                };
+
+                // Read buffers and dispatch to thread pool
+                // Streaming producer-consumer for way blocks
+                std::mutex way_queue_mutex;
+                std::condition_variable way_queue_cv;
+                std::deque<osmium::memory::Buffer> way_block_queue;
+                bool way_reader_done = false;
+                const size_t WAY_MAX_QUEUE = 64;
+
+                std::vector<ThreadLocalData> tld(num_threads);
--
+                                                header.node_offset = node_offset;
+                                                header.node_count = static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)));
+                                                header.name_id = 0; // placeholder, intern later
+                                                local.ways.push_back(header);
+                                                local.way_strings.push_back(name);
+                                                local.deferred_ways.push_back({way_id, node_offset, header.node_count});
+                                                local.way_count++;
+                                            }
+                                        }
+                                    }
+
+                                    // Store way geometry only for admin boundary member ways
+                                    if (parallel_admin && !wnodes.empty() && admin_way_ids.count(way.id())) {
+                                        std::vector<std::pair<double,double>> geom;
+                                        for (const auto& nr : wnodes) {
+                                            try {
+                                                auto loc = index.get(nr.positive_ref());
+                                                if (loc.valid()) geom.push_back({loc.lat(), loc.lon()});
+                                            } catch (...) {}
+                                        }
+                                        if (!geom.empty()) {
+                                            int64_t first_nid = wnodes.front().positive_ref();
+                                            int64_t last_nid = wnodes.back().positive_ref();
+                                            local.way_geoms.push_back({way.id(), std::move(geom), first_nid, last_nid});
+                                        }
+                                    }
+                                }
+                            }
+
+                            uint64_t done = blocks_done.fetch_add(1) + 1;
+                            if (done % 1000 == 0) {
+                                std::cerr << "  Processed " << done << " way blocks..." << std::endl;
+                            }
+                        }
+                    });
+                }
--
+                    }
+                    data.street_nodes.insert(data.street_nodes.end(),


Let me find where the deferred_ways are actually processed and ways are added to cell_to_ways:


grep -n "deferred_ways\|process.*deferred\|cell_to_ways.*push_back" /root/traccar-geocoder/builder/src/build_index.cpp | head -50


255:    std::vector<DeferredWay> deferred_ways;
841:        data_.deferred_ways.push_back({way_id, node_offset, header.node_count});
1942:                    std::vector<DeferredWay> deferred_ways;
2107:                                                local.deferred_ways.push_back({way_id, node_offset, header.node_count});
2207:                    for (auto dw : local.deferred_ways) {
2210:                        data.deferred_ways.push_back(dw);
2362:            std::cerr << "  Processing " << data.deferred_ways.size() << " street ways..." << std::endl;
2371:                            if (i >= data.deferred_ways.size()) break;
2372:                            const auto& dw = data.deferred_ways[i];
2437:            data.deferred_ways.clear();
2438:            data.deferred_ways.shrink_to_fit();


  2362→            std::cerr << "  Processing " << data.deferred_ways.size() << " street ways..." << std::endl;
  2363→            {
  2364→                std::atomic<size_t> way_idx{0};
  2365→                std::vector<std::thread> threads;
  2366→                for (unsigned int t = 0; t < num_threads; t++) {
  2367→                    threads.emplace_back([&, t]() {
  2368→                        auto& local = thread_maps[t].ways;
  2369→                        while (true) {
  2370→                            size_t i = way_idx.fetch_add(1);
  2371→                            if (i >= data.deferred_ways.size()) break;
  2372→                            const auto& dw = data.deferred_ways[i];
  2373→                            std::unordered_set<uint64_t> cells;
  2374→                            for (uint8_t j = 0; j + 1 < dw.node_count; j++) {
  2375→                                const auto& n1 = data.street_nodes[dw.node_offset + j];
  2376→                                const auto& n2 = data.street_nodes[dw.node_offset + j + 1];
  2377→                                auto edge_cells = cover_edge(n1.lat, n1.lng, n2.lat, n2.lng);
  2378→                                for (const auto& c : edge_cells) {
  2379→                                    cells.insert(c.id());
  2380→                                }
  2381→                            }
  2382→                            for (uint64_t cell_id : cells) {
  2383→                                local[cell_id].push_back(dw.way_id);
  2384→                            }
  2385→                        }
  2386→                    });
  2387→                }
  2388→                for (auto& t : threads) t.join();
  2389→            }
  2390→            std::cerr << "  Street ways done." << std::endl;
  2391→
  2392→            // Process interpolations in parallel
  2393→            std::cerr << "  Processing " << data.deferred_interps.size() << " interpolation ways..." << std::endl;
  2394→            {
  2395→                std::atomic<size_t> interp_idx{0};
  2396→                std::vector<std::thread> threads;
  2397→                for (unsigned int t = 0; t < num_threads; t++) {
  2398→                    threads.emplace_back([&, t]() {
  2399→                        auto& local = thread_maps[t].interps;
  2400→                        while (true) {
  2401→                            size_t i = interp_idx.fetch_add(1);
  2402→                            if (i >= data.deferred_interps.size()) break;
  2403→                            const auto& di = data.deferred_interps[i];
  2404→                            std::unordered_set<uint64_t> cells;
  2405→                            for (uint8_t j = 0; j + 1 < di.node_count; j++) {
  2406→                                const auto& n1 = data.interp_nodes[di.node_offset + j];
  2407→                                const auto& n2 = data.interp_nodes[di.node_offset + j + 1];
  2408→                                auto edge_cells = cover_edge(n1.lat, n1.lng, n2.lat, n2.lng);
  2409→                                for (const auto& c : edge_cells) {
  2410→                                    cells.insert(c.id());
  2411→                                }
  2412→                            }
  2413→                            for (uint64_t cell_id : cells) {
  2414→                                local[cell_id].push_back(di.interp_id);
  2415→                            }
  2416→                        }
  2417→                    });
  2418→                }
  2419→                for (auto& t : threads) t.join();
  2420→            }
  2421→            std::cerr << "  Interpolation ways done." << std::endl;
  2422→
  2423→            // Merge thread-local maps into data
  2424→            std::cerr << "  Merging thread-local results..." << std::endl;
  2425→            for (auto& tm : thread_maps) {
  2426→                for (auto& [cell_id, ids] : tm.ways) {
  2427→                    auto& target = data.cell_to_ways[cell_id];
  2428→                    target.insert(target.end(), ids.begin(), ids.end());
  2429→                }
  2430→                for (auto& [cell_id, ids] : tm.interps) {
  2431→                    auto& target = data.cell_to_interps[cell_id];
  2432→                    target.insert(target.end(), ids.begin(), ids.end());
  2433→                }
  2434→            }
  2435→
  2436→            // Free deferred work items
  2437→            data.deferred_ways.clear();
  2438→            data.deferred_ways.shrink_to_fit();
  2439→            data.deferred_interps.clear();
  2440→            data.deferred_interps.shrink_to_fit();
  2441→


Now let me check the old version to see how this was done differently. Let me look for whether there were any filtering changes related to ways:


git show 93fe6e4:builder/src/build_index.cpp | grep -n "if (kNoAddresses)\|if (kAdminOnly)" | head -20


416:        if (kAdminOnly) return;
929:    if (kAdminOnly) {
932:    if (kNoAddresses) {


Let me look at where kAdminOnly and kNoAddresses were used during way processing in the old version. Let me check line 416:


git show 93fe6e4:builder/src/build_index.cpp | sed -n '410,430p'


        if (!node.location().valid()) return;

        add_addr_point(node.location().lat(), node.location().lon(), housenumber, street);
    }

    void way(const osmium::Way& way) {
        if (kAdminOnly) return;

        if (!kNoAddresses) {
            // Address interpolation
            const char* interpolation = way.tags()["addr:interpolation"];
            if (interpolation) {
                process_interpolation_way(way, interpolation);
                return;
            }

            // Building addresses
            const char* housenumber = way.tags()["addr:housenumber"];
            if (housenumber) {
                const char* street = way.tags()["addr:street"];
                if (street) {


So in the old version, the `if (kNoAddresses)` guard was around address interpolation and building address processing, but NOT around highway processing. That means highway processing would still occur. Now let me verify that the current code also processes highways without those guards. Let me check what the actual way() function does:


   658→    void way(const osmium::Way& way) {
   659→        // Address interpolation
   660→        const char* interpolation = way.tags()["addr:interpolation"];
   661→        if (interpolation) {
   662→            process_interpolation_way(way, interpolation);
   663→            return;
   664→        }
   665→
   666→        // Building addresses
   667→        const char* housenumber = way.tags()["addr:housenumber"];
   668→        if (housenumber) {
   669→            const char* street = way.tags()["addr:street"];
   670→            if (street) {
   671→                process_building_address(way, housenumber, street);
   672→            }
   673→        }
   674→
   675→        // Highway ways
   676→        const char* highway = way.tags()["highway"];
   677→        if (highway && is_included_highway(highway)) {
   678→            const char* name = way.tags()["name"];
   679→            if (name) {
   680→                process_highway(way, name);
   681→            }
   682→        }
   683→    }
   684→
   685→    void area(const osmium::Area& area) {
   686→        const char* boundary = area.tags()["boundary"];
   687→        if (!boundary) return;


So the current code ALWAYS processes highways, address interpolation, and building addresses. In the old code, they were guarded by kAdminOnly/kNoAddresses. Let me search through the entire git log to see which commits changed this:


git log --oneline 93fe6e4..3984d21 -- builder/src/build_index.cpp


3984d21 fix: split interned_strings into per-type vectors to fix name misassignment
1a25143 fix: use node IDs for ring stitching instead of coordinates
acf403e fix: only store way geometries for admin boundary members
b79a003 fix: normalize ring rotation in add_admin_polygon for consistent output
58349a5 feat: parallel admin boundary assembly (--parallel-admin)
0a2bb57 fix: increase dense index to 15B max node ID with bounds checking
a19cb3c feat: replace sharded FlexMem with lockless dense array index
3117db8 fix: reduce shards to 16 and stream way blocks to avoid OOM
6fe46b1 feat: parallel planet processing with sharded node index


Now let me check which commit removed the kAdminOnly/kNoAddresses guards. Let me check commit 6fe46b1:


git show 6fe46b1:builder/src/build_index.cpp 2>/dev/null | grep -A 30 "void way(const osmium::Way& way)" | head -40


    void way(const osmium::Way& way) {
        // Address interpolation
        const char* interpolation = way.tags()["addr:interpolation"];
        if (interpolation) {
            process_interpolation_way(way, interpolation);
            return;
        }

        // Building addresses
        const char* housenumber = way.tags()["addr:housenumber"];
        if (housenumber) {
            const char* street = way.tags()["addr:street"];
            if (street) {
                process_building_address(way, housenumber, street);
            }
        }

        // Highway ways
        const char* highway = way.tags()["highway"];
        if (highway && is_included_highway(highway)) {
            const char* name = way.tags()["name"];
            if (name) {
                process_highway(way, name);
            }
        }
    }

    void area(const osmium::Area& area) {
        const char* boundary = area.tags()["boundary"];
        if (!boundary) return;


So commit 6fe46b1 ("feat: parallel planet processing with sharded node index") already removed the guards. Let me check the exact diff for that commit:


git show 6fe46b1 -- builder/src/build_index.cpp | grep -B 5 -A 20 "void way(const osmium::Way& way)" | head -50


-        add_addr_point(node.location().lat(), node.location().lon(), housenumber, street);
+        add_addr_point(data_, node.location().lat(), node.location().lon(),
+                       housenumber, street, addr_count_total_);
     }

     void way(const osmium::Way& way) {
-        if (kAdminOnly) return;
-
-        if (!kNoAddresses) {
-            // Address interpolation
-            const char* interpolation = way.tags()["addr:interpolation"];
-            if (interpolation) {
-                process_interpolation_way(way, interpolation);
-                return;
-            }
-
-            // Building addresses
-            const char* housenumber = way.tags()["addr:housenumber"];
-            if (housenumber) {
-                const char* street = way.tags()["addr:street"];
-                if (street) {
-                    process_building_address(way, housenumber, street);
-                }
+        // Address interpolation
+        const char* interpolation = way.tags()["addr:interpolation"];
+        if (interpolation) {


Perfect! I found the issue! Commit 6fe46b1 removed the `if (kAdminOnly) return;` and `if (!kNoAddresses)` guards from the way() method. This means in commit 6fe46b1 and all subsequent commits, ALL ways are collected for all index modes, not just the ones needed for a particular mode.

Let me verify this by checking how the code filtered ways later (in write_index or during file writing):


git show 6fe46b1:builder/src/build_index.cpp | grep -n "write_streets\|AdminOnly\|NoAddresses" | head -20


87:enum class IndexMode { Full, NoAddresses, AdminOnly };
1356:    bool write_streets = (mode != IndexMode::AdminOnly);
1359:    if (write_streets) {
1402:    if (write_streets) {
1492:            mode = IndexMode::AdminOnly;
1494:            mode = IndexMode::NoAddresses;
1510:                mode = IndexMode::NoAddresses;
1512:                mode = IndexMode::AdminOnly;
2116:        auto wf2 = std::async(std::launch::async, [&]{ write_index(data, output_dir + "/no-addresses", IndexMode::NoAddresses); });
2117:        auto wf3 = std::async(std::launch::async, [&]{ write_index(data, output_dir + "/admin-only", IndexMode::AdminOnly); });
2139:                    auto cf2 = std::async(std::launch::async, [&]{ write_index(subset, base + "/no-addresses", IndexMode::NoAddresses); });
2140:                    auto cf3 = std::async(std::launch::async, [&]{ write_index(subset, base + "/admin-only", IndexMode::AdminOnly); });


So in commit 6fe46b1, the filtering was moved to write time. But wait - the user's baseline is commit 93fe6e4 and current is 3984d21. Let me double-check what version 93fe6e4 actually has. Let me check if 93fe6e4 is even in the commit history:


93fe6e4 refactor: rename strings global to string_pool to avoid s2 namespace conflict


3984d21 fix: split interned_strings into per-type vectors to fix name misassignment
1a25143 fix: use node IDs for ring stitching instead of coordinates
acf403e fix: only store way geometries for admin boundary members
b79a003 fix: normalize ring rotation in add_admin_polygon for consistent output
58349a5 feat: parallel admin boundary assembly (--parallel-admin)
0a2bb57 fix: increase dense index to 15B max node ID with bounds checking
a19cb3c feat: replace sharded FlexMem with lockless dense array index
3a61e35 feat: add kubernetes manifests for planet PBF server
3117db8 fix: reduce shards to 16 and stream way blocks to avoid OOM
6fe46b1 feat: parallel planet processing with sharded node index
93fe6e4 refactor: rename strings global to string_pool to avoid s2 namespace conflict
f402124 Add auth section
ad19ac0 Fix planet URL
50e58b6 Add missing API key
9d42152 Add license and update readme
4cf1785 Skip invalid S2 polygons with crossing edges
5959d7a Handle identical vertices in S2 polyline coverage
b0dc2c4 Deduplicate cell-way pairs per way during collection
5d43870 Use disk-backed node location index for large PBF builds
5e24871 Add Docker Hub publish workflow
4630afc Configurable search distance
e0cc472 Configurable cell levels
3942190 Use inline dropdown for rate-by-IP option in user creation form
ee35302 Add per-IP rate limiting option for users
64f700d Add admin user management, rate limiting, and daily usage display
c706cf8 Add user management and API token authentication
ff93025 Match Nominatim reverse geocoding request and response format
41e55eb Add formatted address and country code to response
e60192b Replace context document with project README
d91923c Add Docker support with auto-download, build, and serve modes


Good, so 93fe6e4 is right before 6fe46b1. So between 93fe6e4 and 3984d21, commit 6fe46b1 removed the filtering guards. This means all ways are now being collected during parsing. Let me verify this is the real cause by checking what the commit messages say:


3984d21 fix: split interned_strings into per-type vectors to fix name misassignment
1a25143 fix: use node IDs for ring stitching instead of coordinates
acf403e fix: only store way geometries for admin boundary members
b79a003 fix: normalize ring rotation in add_admin_polygon for consistent output
58349a5 feat: parallel admin boundary assembly (--parallel-admin)
0a2bb57 fix: increase dense index to 15B max node ID with bounds checking
a19cb3c feat: replace sharded FlexMem with lockless dense array index
3a61e35 feat: add kubernetes manifests for planet PBF server
3117db8 fix: reduce shards to 16 and stream way blocks to avoid OOM
6fe46b1 feat: parallel planet processing with sharded node index


Now let me check the commit message for 6fe46b1:


commit 6fe46b1b92c2010b360a79177128c4ec36738c66
Author: Zack Pollard <zackpollard@Zacks-MacBook-Pro.local>
Date:   Fri Mar 20 20:14:32 2026 +0000

    feat: parallel planet processing with sharded node index

    - Sharded FlexMem index (64 shards) eliminates lock contention for
      parallel node location storage
    - Streaming producer-consumer for pass 2 node blocks (bounded queue,
      no memory blowup)
    - Parallel PBF block processing for ways in pass 3
    - Concurrent admin polygon S2 covering via thread pool
    - Parallel deduplication, index writing, and continent filtering
    - Binary cache serialization (--save-cache / --load-cache)
    - Multi-output mode (--multi-output) and continent generation (--continents)

    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

 builder/src/build_index.cpp | 1622 +++++++++++++++++++++++++++++++++++++------
 entrypoint.sh               |   45 +-
 server/src/main.rs          |  239 ++++---
 3 files changed, 1597 insertions(+), 309 deletions(-)


Now let me check if there were any other changes that might affect way counts. Let me look at the specific changes in how ways were filtered. Let me check the old 93fe6e4 version more carefully for whether there was filtering based on mode at parse time:


git show 93fe6e4:builder/src/build_index.cpp | grep -B 10 -A 10 "if (kAdminOnly)" | head -50


        const char* housenumber = node.tags()["addr:housenumber"];
        if (!housenumber) return;
        const char* street = node.tags()["addr:street"];
        if (!street) return;
        if (!node.location().valid()) return;

        add_addr_point(node.location().lat(), node.location().lon(), housenumber, street);
    }

    void way(const osmium::Way& way) {
        if (kAdminOnly) return;

        if (!kNoAddresses) {
            // Address interpolation
            const char* interpolation = way.tags()["addr:interpolation"];
            if (interpolation) {
                process_interpolation_way(way, interpolation);
                return;
            }

            // Building addresses
--
    }

    std::cerr << "Done reading:" << std::endl;
    std::cerr << "  " << handler.way_count() << " street ways" << std::endl;
    std::cerr << "  " << addr_count_total << " address points ("
              << handler.building_addr_count() << " from buildings)" << std::endl;
    std::cerr << "  " << handler.interp_count() << " interpolation ways" << std::endl;
    std::cerr << "  " << handler.admin_count() << " admin/postcode boundaries ("
              << admin_polygons.size() << " polygon rings)" << std::endl;

    if (kAdminOnly) {
        std::cerr << "Admin-only mode: skipped streets, addresses, and interpolation" << std::endl;
    }
    if (kNoAddresses) {
        std::cerr << "No-addresses mode: skipped address points and interpolation" << std::endl;
    }

    if (!kAdminOnly && !kNoAddresses) {
        std::cerr << "Resolving interpolation endpoints..." << std::endl;
        resolve_interpolation_endpoints();
    }


Perfect! So in the baseline (93fe6e4), when running in the default mode (not kAdminOnly, not kNoAddresses), the way() method would:
1. Skip if kAdminOnly
2. Check `if (!kNoAddresses)` before processing address interpolation and building addresses
3. Then process highways (if highway and is_included_highway)

But there's an important detail - let me see if there were any ways that had BOTH address interpolation/building address tags AND highway tags that might have caused differences. Actually wait, I see a return statement after processing interpolation - so if a way had addr:interpolation, it would return early and not process highways.

But more importantly, the logic shows that in 93fe6e4:
- If kAdminOnly, ALL ways processing was skipped (return)
- If !kNoAddresses, address-related ways were processed
- Highway processing happened regardless (after the !kNoAddresses block)

In the current code (6fe46b1 onwards):
- Address interpolation is processed
- Building addresses are processed
- Highway processing happens

So the only difference would be if the default mode was being used differently. But wait - let me check what the default compilation did. Was there a default index mode being used that made the difference? Let me check what happens when the code writes out the indexes:


git show 93fe6e4:builder/src/build_index.cpp | grep -A 20 "// Main program\|int main"


int main(int argc, char* argv[]) {
    if (argc < 3) {
        std::cerr << "Usage: build-index <output-dir> <input.osm.pbf> [input2.osm.pbf ...] [--street-level N] [--admin-level N] [--admin-only] [--no-addresses] [--max-admin-level N]" << std::endl;
        return 1;
    }

    std::string output_dir = argv[1];
    std::vector<std::string> input_files;
    for (int i = 2; i < argc; i++) {
        std::string arg = argv[i];
        if (arg == "--street-level" && i + 1 < argc) {
            kStreetCellLevel = std::atoi(argv[++i]);
        } else if (arg == "--admin-level" && i + 1 < argc) {
            kAdminCellLevel = std::atoi(argv[++i]);
        } else if (arg == "--admin-only") {
            kAdminOnly = true;
        } else if (arg == "--no-addresses") {
            kNoAddresses = true;
        } else if (arg == "--max-admin-level" && i + 1 < argc) {
            kMaxAdminLevel = std::atoi(argv[++i]);
        } else {


So in the old version, you could pass --admin-only or --no-addresses flags. But by default, they're both false, so ways would be processed normally.

Wait, but I need to reconsider the user's statement. They said the baseline produces 3,298,645 ways. That's the SAME data source. So if both versions are processing the same Germany PBF with the same default settings, the only difference would be in the filtering logic.

Let me re-examine. The user said "baseline build (commit 93fe6e4) produces 3,298,645 street ways while the current code produces 3,312,673 ways (14,028 more)".

This means 14,028 MORE ways are being included in the current code. This suggests something changed that now INCLUDES more ways. Let me think about what could cause this:

1. The removal of `if (kAdminOnly) return;` and `if (!kNoAddresses)` guards - but these wouldn't affect the default mode
2. Changes to the is_included_highway function or excluded highways list - but we verified this is the same
3. Changes to the highway processing logic - let me check this more carefully

Actually, wait. Let me re-read the old code. In the old version, address processing was wrapped in `if (!kNoAddresses)`. But that shouldn't affect highway processing. Unless...

Let me check if there were any ways that might have had both address:interpolation and highway tags, where the early return would have prevented highway processing in the old version:


git show 93fe6e4:builder/src/build_index.cpp | grep -A 35 "void way(const osmium::Way& way)"


    void way(const osmium::Way& way) {
        if (kAdminOnly) return;

        if (!kNoAddresses) {
            // Address interpolation
            const char* interpolation = way.tags()["addr:interpolation"];
            if (interpolation) {
                process_interpolation_way(way, interpolation);
                return;
            }

            // Building addresses
            const char* housenumber = way.tags()["addr:housenumber"];
            if (housenumber) {
                const char* street = way.tags()["addr:street"];
                if (street) {
                    process_building_address(way, housenumber, street);
                }
            }
        }

        // Highway ways
        const char* highway = way.tags()["highway"];
        if (highway && is_included_highway(highway)) {
            const char* name = way.tags()["name"];
            if (name) {
                process_highway(way, name);
            }
        }
    }

    void area(const osmium::Area& area) {
        const char* boundary = area.tags()["boundary"];
        if (!boundary) return;

        bool is_admin = (std::strcmp(boundary, "administrative") == 0);


AH! I SEE IT! In the old version:

```cpp
if (!kNoAddresses) {
    // Address interpolation
    const char* interpolation = way.tags()["addr:interpolation"];
    if (interpolation) {
        process_interpolation_way(way, interpolation);
        return;    // <-- EARLY RETURN
    }
```

The `return` statement is INSIDE the `if (!kNoAddresses)` block! So only if kNoAddresses is FALSE would an address interpolation way cause an early return. But in the current code:

```cpp
// Address interpolation
const char* interpolation = way.tags()["addr:interpolation"];
if (interpolation) {
    process_interpolation_way(way, interpolation);
    return;  // <-- EARLY RETURN ALWAYS
}
```

This means in the current code, ANY way with addr:interpolation tag will return early and NOT be processed as a highway, even if it also has a highway tag.

So the question is: in the baseline (93fe6e4), were there ways with addr:interpolation that were ALSO streets (with highway tags), and would have been processed as highways if kNoAddresses was not set?

Actually, wait. In the baseline, kNoAddresses is FALSE by default. So the `if (!kNoAddresses)` block WOULD execute, and the early return for addr:interpolation WOULD happen. So that's not it.

Let me re-examine. Actually the logic is:
- Old code: `if (!kNoAddresses) { if (interpolation) { ... return; } }`
- New code: `if (interpolation) { ... return; }`

These are functionally equivalent when kNoAddresses is false (which is the default). So this isn't the issue.

Let me look for other differences. Maybe there's an issue with the filtering in the "filtering by bounding box" logic? Let me check commit acf403e which says "only store way geometries for admin boundary members":


commit acf403e48b0cb9f2f3bcd6fe4185c5c3a94c7391
Author: Zack Pollard <zackpollard@Zacks-MacBook-Pro.local>
Date:   Sat Mar 21 06:00:47 2026 +0000

    fix: only store way geometries for admin boundary members

    Previously stored geometry for ALL ways during pass 3, consuming
    tens of GB. Now only stores geometry for ways referenced by admin
    relations (~2M ways vs ~300M total), reducing memory by ~30GB.

    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

 builder/src/build_index.cpp | 15 +++++++++++++--
 1 file changed, 13 insertions(+), 2 deletions(-)


That's about memory optimization, not way count. Let me look at commit 3984d21 which is the current commit:


commit 3984d219e97c6f324a9169cf6d7a6ae42898ca40
Author: Claude <noreply@anthropic.com>
Date:   Sat Mar 21 11:58:00 2026 +0000

    fix: split interned_strings into per-type vectors to fix name misassignment

    The parallel way processing pushed strings for ways, building addresses,
    and interpolations into a single interned_strings vector in interleaved
    processing order. The merge code read them assuming grouped-by-type order,
    causing way names to be swapped with building address strings when a block
    contained mixed types.

    Split into way_strings, addr_strings, and interp_strings vectors so each
    type is indexed correctly during merge.

    Also rewrites compare_indexes.cpp to use multiset fingerprint comparison
    (handles any ordering differences between parallel and sequential builds).

    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

 builder/src/build_index.cpp      |  19 ++-
 builder/test/compare_indexes.cpp | 335 +++++++++++++++++++++------------------
 2 files changed, 191 insertions(+), 163 deletions(-)


That's about name misassignment, not way filtering. Let me think about this differently. The user is comparing baseline 93fe6e4 to current 3984d21. The issue is that 14,028 more ways are being included. Let me check if there's a simple difference - maybe the filtering logic in process_highway changed, or the is_included_highway list changed in a way I missed.

Let me directly compare the is_included_highway lists and all highway-related logic more carefully:


git show 93fe6e4:builder/src/build_index.cpp | grep -A 5 "kExcludedHighways"


static const std::vector<std::string> kExcludedHighways = {
    "footway", "path", "track", "steps", "cycleway",
    "service", "pedestrian", "bridleway", "construction"
};

static bool is_included_highway(const char* value) {
    for (const auto& excluded : kExcludedHighways) {
        if (excluded == value) return false;
    }
    return true;
}


The excluded highways are the same. Let me now look at the entire process_highway function from both versions and check if there are any subtle changes to node validation:


git show 93fe6e4:builder/src/build_index.cpp | sed -n '/void process_highway/,/^    }/p' | head -50


    void process_highway(const osmium::Way& way, const char* name) {
        const auto& wnodes = way.nodes();
        if (wnodes.size() < 2) return;

        for (const auto& nr : wnodes) {
            if (!nr.location().valid()) return;
        }

        uint32_t way_id = static_cast<uint32_t>(ways.size());
        uint32_t node_offset = static_cast<uint32_t>(street_nodes.size());

        for (const auto& nr : wnodes) {
            street_nodes.push_back({
                static_cast<float>(nr.location().lat()),
                static_cast<float>(nr.location().lon())
            });
        }

        WayHeader header{};
        header.node_offset = node_offset;
        header.node_count = static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)));
        header.name_id = string_pool.intern(name);
        ways.push_back(header);

        std::unordered_set<uint64_t> way_cells;
        for (size_t i = 0; i + 1 < wnodes.size(); i++) {
            double lat1 = wnodes[i].location().lat();
            double lng1 = wnodes[i].location().lon();
            double lat2 = wnodes[i + 1].location().lat();
            double lng2 = wnodes[i + 1].location().lon();

            auto cell_ids = cover_edge(lat1, lng1, lat2, lng2);
            for (const auto& cell_id : cell_ids) {
                way_cells.insert(cell_id.id());
            }
        }
        for (uint64_t cell_id : way_cells) {
            cell_to_ways[cell_id].push_back(way_id);
        }

        way_count_++;
        if (way_count_ % 1000000 == 0) {
            std::cerr << "Processed " << way_count_ / 1000000 << "M street ways..." << std::endl;
        }
    }


And the current version:


   816→        if (wnodes.size() < 2) return;
   817→
   818→        for (const auto& nr : wnodes) {
   819→            if (!nr.location().valid()) return;
   820→        }
   821→
   822→        const char* street = way.tags()["addr:street"];
   823→        if (!street) return;
   824→
   825→        uint8_t interp_type = 0;
   826→        if (std::strcmp(interpolation, "even") == 0) interp_type = 1;
   827→        else if (std::strcmp(interpolation, "odd") == 0) interp_type = 2;
   828→
   829→        uint32_t interp_id = static_cast<uint32_t>(data_.interp_ways.size());
   830→        uint32_t node_offset = static_cast<uint32_t>(data_.interp_nodes.size());
   831→
   832→        for (const auto& nr : wnodes) {
   833→            data_.interp_nodes.push_back({
   834→                static_cast<float>(nr.location().lat()),
   835→                static_cast<float>(nr.location().lon())
   836→            });
   837→        }
   838→
   839→        InterpWay iw{};
   840→        iw.node_offset = node_offset;
   841→        iw.node_count = static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)));
   842→        iw.street_id = data_.string_pool.intern(street);
   843→        iw.start_number = 0;
   844→        iw.end_number = 0;
   845→        iw.interpolation = interp_type;
   846→        data_.interp_ways.push_back(iw);
   847→
   848→        // Defer S2 cell computation to parallel phase
   849→        data_.deferred_interps.push_back({interp_id, node_offset,
   850→            static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)))});


That's the interpolation function. Let me get the process_highway function:


   816→        if (wnodes.size() < 2) return;
   817→
   818→        for (const auto& nr : wnodes) {
   819→            if (!nr.location().valid()) return;
   820→        }
   821→
   822→        const char* street = way.tags()["addr:street"];
   823→        if (!street) return;
   824→
   825→        uint8_t interp_type = 0;
   826→        if (std::strcmp(interpolation, "even") == 0) interp_type = 1;
   827→        else if (std::strcmp(interpolation, "odd") == 0) interp_type = 2;
   828→
   829→        uint32_t interp_id = static_cast<uint32_t>(data_.interp_ways.size());
   830→        uint32_t node_offset = static_cast<uint32_t>(data_.interp_nodes.size());
   831→
   832→        for (const auto& nr : wnodes) {
   833→            data_.interp_nodes.push_back({
   834→                static_cast<float>(nr.location().lat()),
   835→                static_cast<float>(nr.location().lon())
   836→            });
   837→        }
   838→
   839→        InterpWay iw{};
   840→        iw.node_offset = node_offset;
   841→        iw.node_count = static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)));
   842→        iw.street_id = data_.string_pool.intern(street);
   843→        iw.start_number = 0;
   844→        iw.end_number = 0;
   845→        iw.interpolation = interp_type;
   846→        data_.interp_ways.push_back(iw);
   847→
   848→        // Defer S2 cell computation to parallel phase
   849→        data_.deferred_interps.push_back({interp_id, node_offset,
   850→            static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)))});
   851→
   852→        interp_count_++;
   853→    }
   854→
   855→    void process_highway(const osmium::Way& way, const char* name) {
   856→        const auto& wnodes = way.nodes();
   857→        if (wnodes.size() < 2) return;
   858→
   859→        for (const auto& nr : wnodes) {
   860→            if (!nr.location().valid()) return;
   861→        }
   862→
   863→        uint32_t way_id = static_cast<uint32_t>(data_.ways.size());
   864→        uint32_t node_offset = static_cast<uint32_t>(data_.street_nodes.size());
   865→


   855→    void process_highway(const osmium::Way& way, const char* name) {
   856→        const auto& wnodes = way.nodes();
   857→        if (wnodes.size() < 2) return;
   858→
   859→        for (const auto& nr : wnodes) {
   860→            if (!nr.location().valid()) return;
   861→        }
   862→
   863→        uint32_t way_id = static_cast<uint32_t>(data_.ways.size());
   864→        uint32_t node_offset = static_cast<uint32_t>(data_.street_nodes.size());
   865→
   866→        for (const auto& nr : wnodes) {
   867→            data_.street_nodes.push_back({
   868→                static_cast<float>(nr.location().lat()),
   869→                static_cast<float>(nr.location().lon())
   870→            });
   871→        }
   872→
   873→        WayHeader header{};
   874→        header.node_offset = node_offset;
   875→        header.node_count = static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)));
   876→        header.name_id = data_.string_pool.intern(name);
   877→        data_.ways.push_back(header);
   878→
   879→        // Defer S2 cell computation to parallel phase
   880→        data_.deferred_ways.push_back({way_id, node_offset, header.node_count});
   881→
   882→        way_count_++;
   883→        if (way_count_ % 1000000 == 0) {
   884→            std::cerr << "Collected " << way_count_ / 1000000 << "M street ways..." << std::endl;
   885→        }
   886→    }
   887→};
   888→
   889→// --- Relation collector for parallel admin assembly (runs during pass 1) ---


The process_highway logic is identical - both check `if (wnodes.size() < 2) return;` and validate all node locations.

Hmm, wait. Let me re-read the code more carefully. In the baseline:
1. Reads ways and adds to `ways` vector and `cell_to_ways` map
2. In write_index, filters based on mode

In the current code:
1. Reads ways and adds to `data_.ways` vector
2. Defers S2 cell computation
3. Later processes deferred ways and adds to `cell_to_ways`
4. In write_index, filters based on mode

Both should produce the same number of ways collected. Unless... let me check if there's something in the parallel processing that causes ways to be skipped. Let me check the multi-output mode:


grep -n "multi-output\|--multi-output" /root/traccar-geocoder/builder/src/build_index.cpp


1686:        std::cerr << "  --multi-output         Write full, no-addresses, admin-only indexes" << std::endl;
1721:        } else if (arg == "--multi-output") {


Let me check what multi-output does and if it somehow filters ways differently:


  1721→        } else if (arg == "--multi-output") {
  1722→            multi_output = true;
  1723→        } else if (arg == "--continents") {
  1724→            generate_continents = true;
  1725→        } else if (arg == "--parallel-admin") {
  1726→            parallel_admin = true;
  1727→        } else if (arg == "--no-parallel-admin") {
  1728→            parallel_admin = false;
  1729→        } else if (arg == "--mode" && i + 1 < argc) {
  1730→            std::string mode_str = argv[++i];


Let me search for where multi_output is used to write ways:


grep -n "multi_output" /root/traccar-geocoder/builder/src/build_index.cpp


1700:    bool multi_output = false;
1722:            multi_output = true;
2507:    if (multi_output) {
2530:                if (multi_output) {


  2507→    if (multi_output) {
  2508→        // Write all 3 index modes in parallel (they read shared data, write to separate dirs)
  2509→        auto wf1 = std::async(std::launch::async, [&]{ write_index(data, output_dir + "/full", IndexMode::Full); });
  2510→        auto wf2 = std::async(std::launch::async, [&]{ write_index(data, output_dir + "/no-addresses", IndexMode::NoAddresses); });
  2511→        auto wf3 = std::async(std::launch::async, [&]{ write_index(data, output_dir + "/admin-only", IndexMode::AdminOnly); });
  2512→        wf1.get(); wf2.get(); wf3.get();
  2513→    } else {
  2514→        write_index(data, output_dir, mode);
  2515→    }
  2516→
  2517→    if (generate_continents) {
  2518→        // Process continents in parallel (each filters independently from shared data)
  2519→        std::vector<std::future<void>> continent_futures;
  2520→        std::mutex cerr_mutex;
  2521→        for (const auto& continent : kContinents) {
  2522→            continent_futures.push_back(std::async(std::launch::async, [&, continent]() {
  2523→                {
  2524→                    std::lock_guard<std::mutex> lock(cerr_mutex);
  2525→                    std::cerr << "Filtering for continent: " << continent.name << "..." << std::endl;
  2526→                }
  2527→                auto subset = filter_by_bbox(data, continent);
  2528→                std::string base = output_dir + "/" + continent.name;
  2529→                ensure_dir(base);
  2530→                if (multi_output) {
  2531→                    // Write modes in parallel within each continent
  2532→                    auto cf1 = std::async(std::launch::async, [&]{ write_index(subset, base + "/full", IndexMode::Full); });
  2533→                    auto cf2 = std::async(std::launch::async, [&]{ write_index(subset, base + "/no-addresses", IndexMode::NoAddresses); });
  2534→                    auto cf3 = std::async(std::launch::async, [&]{ write_index(subset, base + "/admin-only", IndexMode::AdminOnly); });
  2535→                    cf1.get(); cf2.get(); cf3.get();
  2536→                } else {
  2537→                    write_index(subset, base, mode);
  2538→                }
  2539→                {
  2540→                    std::lock_guard<std::mutex> lock(cerr_mutex);
  2541→                    std::cerr << "Done continent: " << continent.name << std::endl;
  2542→                }
  2543→            }));
  2544→        }
  2545→        for (auto& f : continent_futures) {
  2546→            f.get();


So multi_output just writes to multiple modes, it doesn't affect way collection. Let me think about this from a different angle. Maybe the user is comparing the outputs of different modes? Or maybe there's a subtle change in how ways are being validated...

Actually, wait. Let me check what specific command the user might be running. They said "baseline build" and "current code". Maybe they're comparing two consecutive builds without specifying flags? Let me check what the default mode is:


grep -n "IndexMode mode = " /root/traccar-geocoder/builder/src/build_index.cpp


1703:    IndexMode mode = IndexMode::Full;


The default is Full. So both builds should be using the same mode. Let me now check if there's anything in write_index that might affect things:


  1531→        uint16_t count = static_cast<uint16_t>(std::min(it->second.size(), size_t(65535)));
  1532→        f.write(reinterpret_cast<const char*>(&count), sizeof(count));
  1533→        f.write(reinterpret_cast<const char*>(it->second.data()), it->second.size() * sizeof(uint32_t));
  1534→        current += sizeof(uint16_t) + it->second.size() * sizeof(uint32_t);
  1535→    }
  1536→    return offsets;
  1537→}
  1538→
  1539→static void write_cell_index(
  1540→    const std::string& cells_path,
  1541→    const std::string& entries_path,
  1542→    const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map
  1543→) {
  1544→    std::vector<std::pair<uint64_t, std::vector<uint32_t>>> sorted(
  1545→        cell_map.begin(), cell_map.end());
  1546→    std::sort(sorted.begin(), sorted.end());
  1547→
  1548→    {
  1549→        std::ofstream f(cells_path, std::ios::binary);
  1550→        uint32_t current_offset = 0;
  1551→        for (const auto& [cell_id, ids] : sorted) {
  1552→            f.write(reinterpret_cast<const char*>(&cell_id), sizeof(cell_id));
  1553→            f.write(reinterpret_cast<const char*>(&current_offset), sizeof(current_offset));
  1554→            current_offset += sizeof(uint16_t) + ids.size() * sizeof(uint32_t);
  1555→        }
  1556→    }
  1557→
  1558→    {
  1559→        std::ofstream f(entries_path, std::ios::binary);
  1560→        for (const auto& [cell_id, ids] : sorted) {
  1561→            uint16_t count = static_cast<uint16_t>(std::min(ids.size(), size_t(65535)));
  1562→            f.write(reinterpret_cast<const char*>(&count), sizeof(count));
  1563→            f.write(reinterpret_cast<const char*>(ids.data()), ids.size() * sizeof(uint32_t));
  1564→        }
  1565→    }
  1566→}
  1567→
  1568→// --- Write all index files ---
  1569→
  1570→static void write_index(const ParsedData& data, const std::string& output_dir, IndexMode mode) {
  1571→    ensure_dir(output_dir);
  1572→
  1573→    bool write_streets = (mode != IndexMode::AdminOnly);
  1574→    bool write_addresses = (mode == IndexMode::Full);
  1575→
  1576→    if (write_streets) {
  1577→        // Merged geo cell index for streets, addresses, and interpolation
  1578→        std::set<uint64_t> all_geo_cells;
  1579→        for (const auto& [id, _] : data.cell_to_ways) all_geo_cells.insert(id);
  1580→        if (write_addresses) {