So write_streets is true for Full mode, and it just writes the ways. This shouldn't cause any filtering.

Let me reconsider the original question. The user says the baseline produces 3,298,645 ways but the current produces 3,312,673 ways. That's 14,028 MORE ways. This means something in the current code is ADDING ways that weren't added in the baseline.

Let me check if there's something about how we're handling the "name" requirement. In process_highway, we have:
```cpp
const char* name = way.tags()["name"];
if (name) {
    process_highway(way, name);
}
```

This requires the way to have a "name" tag. Let me verify this hasn't changed:


Bash

git diff 93fe6e4..3984d21 -- builder/src/build_index.cpp | grep -B 5 -A 5 'way.tags\(\)\["name"\]' | head -50


(Bash completed with no output)


Let me check both versions specifically:


git show 93fe6e4:builder/src/build_index.cpp | grep -A 10 "// Highway ways"


        // 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) {


   675→        admin_pool->submit(poly_id, std::move(simplified));
   676→    }
   677→}
   678→
   679→// --- OSM handler ---
   680→
   681→class BuildHandler : public osmium::handler::Handler {
   682→public:
   683→    explicit BuildHandler(ParsedData& data, AdminCoverPool* admin_pool = nullptr)
   684→        : data_(data), admin_pool_(admin_pool) {}


   713→
   714→        // Highway ways
   715→        const char* highway = way.tags()["highway"];
   716→        if (highway && is_included_highway(highway)) {
   717→            const char* name = way.tags()["name"];
   718→            if (name) {
   719→                process_highway(way, name);
   720→            }
   721→        }
   722→    }


Both versions require the "name" tag. So that's not it either.

Wait, let me re-examine the output from the user more carefully. They said the baseline is 3,298,645 ways and current is 3,312,673. They're asking me to find what changed that would ADD 14,028 ways. Let me check if there's a specific logging difference that shows what the counts were:


git show 93fe6e4:builder/src/build_index.cpp | grep "street ways"


            std::cerr << "Processed " << way_count_ / 1000000 << "M street ways..." << std::endl;
    std::cerr << "  " << handler.way_count() << " street ways" << std::endl;


grep "street ways\|Collected.*street" /root/traccar-geocoder/builder/src/build_index.cpp


            std::cerr << "Collected " << way_count_ / 1000000 << "M street ways..." << std::endl;
                    // Merge street ways
        std::cerr << "  " << data.ways.size() << " street ways" << std::endl;
            std::cerr << "  Processing " << data.deferred_ways.size() << " street ways..." << std::endl;


The logging has changed but the logic hasn't. Let me take a completely different approach. Maybe the issue is with parallel processing causing some ways to be processed differently. Let me check if there's something in the parallel way processing loop in pass 3:


grep -n "pass 3\|pass 2\|pass 1" /root/traccar-geocoder/builder/src/build_index.cpp | head -20


889:// --- Relation collector for parallel admin assembly (runs during pass 1) ---


Let me search in the main function for how passes are handled:


grep -n "pass\|osmium::apply" /root/traccar-geocoder/builder/src/build_index.cpp | head -40


889:// --- Relation collector for parallel admin assembly (runs during pass 1) ---
1834:                    osmium::apply(reader1, mp_manager, rel_collector);
1836:                    osmium::apply(reader1, mp_manager);
2218:                        osmium::apply(reader_rels, dense_loc_handler,
2220:                                osmium::apply(buffer, handler);


Let me look at line 1834 and around it to see how ways are processed:


  1820→
  1821→            // --- Pass 1: collect relation members for multipolygon assembly ---
  1822→            std::cerr << "  Pass 1: scanning relations..." << std::endl;
  1823→
  1824→            osmium::area::Assembler::config_type assembler_config;
  1825→            osmium::area::MultipolygonManager<osmium::area::Assembler> mp_manager{assembler_config};
  1826→
  1827→            // Also collect admin/postal relation metadata for parallel assembly
  1828→            RelationCollector rel_collector(data.collected_relations);
  1829→
  1830→            {
  1831→                osmium::io::Reader reader1{input_file, osmium::osm_entity_bits::relation};
  1832→                if (parallel_admin) {
  1833→                    // Run both: mp_manager (for fallback compatibility) and rel_collector
  1834→                    osmium::apply(reader1, mp_manager, rel_collector);
  1835→                } else {
  1836→                    osmium::apply(reader1, mp_manager);
  1837→                }
  1838→                reader1.close();
  1839→                mp_manager.prepare_for_lookup();
  1840→            }
  1841→            if (parallel_admin) {
  1842→                std::cerr << "  Collected " << data.collected_relations.size()
  1843→                          << " admin/postal relations for parallel assembly." << std::endl;
  1844→            }
  1845→
  1846→            // --- Pass 2: nodes (streaming parallel block processing) ---
  1847→            std::cerr << "  Pass 2: processing nodes in parallel..." << std::endl;
  1848→
  1849→            {
  1850→                // Producer-consumer: reader feeds blocks, workers process them
  1851→                // Bounded queue prevents memory blowup
  1852→                std::mutex queue_mutex;
  1853→                std::condition_variable queue_cv;
  1854→                std::deque<osmium::memory::Buffer> block_queue;
  1855→                bool reader_done = false;
  1856→                const size_t MAX_QUEUE = 64; // limit buffered blocks
  1857→
  1858→                struct NodeThreadLocal {
  1859→                    std::vector<std::pair<double,double>> addr_coords;
  1860→                    std::vector<std::pair<std::string,std::string>> addr_strings;
  1861→                    uint64_t count = 0;
  1862→                };
  1863→                std::vector<NodeThreadLocal> ntld(num_threads);
  1864→                std::atomic<uint64_t> blocks_done{0};
  1865→
  1866→                // Worker threads
  1867→                std::vector<std::thread> node_workers;
  1868→                for (unsigned int t = 0; t < num_threads; t++) {
  1869→                    node_workers.emplace_back([&, t]() {
  1870→                        auto& local = ntld[t];
  1871→                        std::vector<std::pair<osmium::unsigned_object_id_type, osmium::Location>> loc_batch;
  1872→                        loc_batch.reserve(8192);
  1873→
  1874→                        while (true) {
  1875→                            osmium::memory::Buffer block;
  1876→                            {
  1877→                                std::unique_lock<std::mutex> lock(queue_mutex);
  1878→                                queue_cv.wait(lock, [&]{ return !block_queue.empty() || reader_done; });
  1879→                                if (block_queue.empty() && reader_done) break;
  1880→                                block = std::move(block_queue.front());
  1881→                                block_queue.pop_front();
  1882→                            }
  1883→                            queue_cv.notify_one(); // wake reader if it was waiting
  1884→
  1885→                            for (const auto& item : block) {
  1886→                                if (item.type() == osmium::item_type::node) {
  1887→                                    const auto& node = static_cast<const osmium::Node&>(item);
  1888→                                    if (!node.location().valid()) continue;
  1889→                                    loc_batch.push_back({node.positive_id(), node.location()});
  1890→
  1891→                                    const char* housenumber = node.tags()["addr:housenumber"];
  1892→                                    if (housenumber) {
  1893→                                        const char* street = node.tags()["addr:street"];
  1894→                                        if (street) {
  1895→                                            local.addr_coords.push_back({node.location().lat(), node.location().lon()});
  1896→                                            local.addr_strings.push_back({housenumber, street});
  1897→                                            local.count++;
  1898→                                        }
  1899→                                    }
  1900→                                }
  1901→                            }
  1902→
  1903→                            // Flush location batch (sharded index handles its own locking)
  1904→                            if (!loc_batch.empty()) {
  1905→                                index.set_batch(loc_batch);
  1906→                                loc_batch.clear();
  1907→                            }
  1908→
  1909→                            uint64_t n = blocks_done.fetch_add(1) + 1;
  1910→                            if (n % 1000 == 0) {
  1911→                                std::cerr << "  Processed " << n << " node blocks..." << std::endl;
  1912→                            }
  1913→                        }
  1914→
  1915→                        // Flush remaining
  1916→                        if (!loc_batch.empty()) {
  1917→                            index.set_batch(loc_batch);
  1918→                        }
  1919→                    });


Let me look at pass 3 where ways are processed:


grep -n "Pass 3:" /root/traccar-geocoder/builder/src/build_index.cpp


1958:            // --- Pass 3: ways + relations (parallel way processing) ---
1970:            std::cerr << "  Pass 3: processing ways and areas with " << num_threads


  1958→            // --- Pass 3: ways + relations (parallel way processing) ---
  1959→            // Build set of way IDs needed for admin assembly (to avoid storing all way geometries)
  1960→            std::unordered_set<int64_t> admin_way_ids;
  1961→            if (parallel_admin) {
  1962→                for (const auto& rel : data.collected_relations) {
  1963→                    for (const auto& [way_id, role] : rel.members) {
  1964→                        admin_way_ids.insert(way_id);
  1965→                    }
  1966→                }
  1967→                std::cerr << "  Admin assembly needs " << admin_way_ids.size() << " way geometries." << std::endl;
  1968→            }
  1969→
  1970→            std::cerr << "  Pass 3: processing ways and areas with " << num_threads
  1971→                      << " threads..." << std::endl;
  1972→
  1973→            {
  1974→                osmium::io::Reader reader_ways{input_file,
  1975→                    osmium::osm_entity_bits::way | osmium::osm_entity_bits::relation};
  1976→
  1977→                // Thread-local data for parallel way processing
  1978→                struct ThreadLocalData {
  1979→                    std::vector<WayHeader> ways;
  1980→                    std::vector<NodeCoord> street_nodes;
  1981→                    std::vector<DeferredWay> deferred_ways;
  1982→                    std::vector<InterpWay> interp_ways;
  1983→                    std::vector<NodeCoord> interp_nodes;
  1984→                    std::vector<DeferredInterp> deferred_interps;
  1985→                    std::vector<AddrPoint> building_addrs;
  1986→                    std::vector<std::pair<double, double>> building_addr_coords; // lat,lng for S2 cell
  1987→                    std::vector<std::string> way_strings;      // way name strings
  1988→                    std::vector<std::string> addr_strings;     // building addr "hn\0street" strings
  1989→                    std::vector<std::string> interp_strings;   // interp street name strings
  1990→                    uint64_t way_count = 0;
  1991→                    uint64_t building_addr_count = 0;
  1992→                    uint64_t interp_count = 0;
  1993→                    // Way geometries for parallel admin assembly
  1994→                    struct WayGeomEntry {
  1995→                        int64_t way_id;
  1996→                        std::vector<std::pair<double,double>> coords;
  1997→                        int64_t first_node_id;
  1998→                        int64_t last_node_id;
  1999→                    };
  2000→                    std::vector<WayGeomEntry> way_geoms;
  2001→                };
  2002→
  2003→                // Read buffers and dispatch to thread pool
  2004→                // Streaming producer-consumer for way blocks
  2005→                std::mutex way_queue_mutex;
  2006→                std::condition_variable way_queue_cv;
  2007→                std::deque<osmium::memory::Buffer> way_block_queue;
  2008→                bool way_reader_done = false;
  2009→                const size_t WAY_MAX_QUEUE = 64;
  2010→
  2011→                std::vector<ThreadLocalData> tld(num_threads);
  2012→                std::atomic<uint64_t> blocks_done{0};
  2013→
  2014→                std::vector<std::thread> workers;
  2015→                for (unsigned int t = 0; t < num_threads; t++) {
  2016→                    workers.emplace_back([&, t]() {
  2017→                        auto& local = tld[t];
  2018→                        while (true) {
  2019→                            osmium::memory::Buffer block;
  2020→                            {
  2021→                                std::unique_lock<std::mutex> lock(way_queue_mutex);
  2022→                                way_queue_cv.wait(lock, [&]{ return !way_block_queue.empty() || way_reader_done; });
  2023→                                if (way_block_queue.empty() && way_reader_done) break;
  2024→                                block = std::move(way_block_queue.front());
  2025→                                way_block_queue.pop_front();
  2026→                            }
  2027→                            way_queue_cv.notify_one();
  2028→
  2029→                            for (const auto& item : block) {
  2030→                                if (item.type() == osmium::item_type::way) {
  2031→                                    const auto& way = static_cast<const osmium::Way&>(item);
  2032→
  2033→                                    // Resolve node locations (read-only from shared index)
  2034→                                    // We need to check if nodes have valid locations
  2035→                                    const auto& wnodes = way.nodes();
  2036→
  2037→                                    // Address interpolation
  2038→                                    const char* interpolation = way.tags()["addr:interpolation"];
  2039→                                    if (interpolation) {
  2040→                                        if (wnodes.size() >= 2) {
  2041→                                            bool all_valid = true;
  2042→                                            for (const auto& nr : wnodes) {
  2043→                                                try {
  2044→                                                    auto loc = index.get(nr.positive_ref());
  2045→                                                    if (!loc.valid()) { all_valid = false; break; }
  2046→                                                } catch (...) { all_valid = false; break; }
  2047→                                            }
  2048→                                            if (all_valid) {
  2049→                                                const char* street = way.tags()["addr:street"];
  2050→                                                if (street) {
  2051→                                                    uint32_t interp_id = static_cast<uint32_t>(local.interp_ways.size());
  2052→                                                    uint32_t node_offset = static_cast<uint32_t>(local.interp_nodes.size());
  2053→
  2054→                                                    for (const auto& nr : wnodes) {
  2055→                                                        auto loc = index.get(nr.positive_ref());
  2056→                                                        local.interp_nodes.push_back({
  2057→                                                            static_cast<float>(loc.lat()),
  2058→                                                            static_cast<float>(loc.lon())
  2059→                                                        });
  2060→                                                    }
  2061→
  2062→                                                    uint8_t interp_type = 0;
  2063→                                                    if (std::strcmp(interpolation, "even") == 0) interp_type = 1;
  2064→                                                    else if (std::strcmp(interpolation, "odd") == 0) interp_type = 2;
  2065→
  2066→                                                    InterpWay iw{};
  2067→                                                    iw.node_offset = node_offset;
  2068→                                                    iw.node_count = static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)));
  2069→                                                    iw.street_id = 0; // placeholder, intern later
  2070→                                                    iw.start_number = 0;
  2071→                                                    iw.end_number = 0;
  2072→                                                    iw.interpolation = interp_type;
  2073→                                                    local.interp_ways.push_back(iw);
  2074→                                                    local.interp_strings.push_back(street);
  2075→                                                    local.deferred_interps.push_back({interp_id, node_offset, iw.node_count});
  2076→                                                    local.interp_count++;
  2077→                                                }
  2078→                                            }
  2079→                                        }
  2080→                                        continue;
  2081→                                    }
  2082→
  2083→                                    // Building addresses
  2084→                                    const char* housenumber = way.tags()["addr:housenumber"];
  2085→                                    if (housenumber) {
  2086→                                        const char* street = way.tags()["addr:street"];
  2087→                                        if (street && !wnodes.empty()) {
  2088→                                            double sum_lat = 0, sum_lng = 0;
  2089→                                            int valid = 0;
  2090→                                            for (const auto& nr : wnodes) {
  2091→                                                try {
  2092→                                                    auto loc = index.get(nr.positive_ref());
  2093→                                                    if (loc.valid()) {
  2094→                                                        sum_lat += loc.lat();
  2095→                                                        sum_lng += loc.lon();
  2096→                                                        valid++;
  2097→                                                    }
  2098→                                                } catch (...) {}
  2099→                                            }
  2100→                                            if (valid > 0) {
  2101→                                                // Store coords for later S2 cell computation
  2102→                                                local.building_addr_coords.push_back({sum_lat / valid, sum_lng / valid});
  2103→                                                // Placeholder addr point — string IDs filled in during merge
  2104→                                                local.building_addrs.push_back({
  2105→                                                    static_cast<float>(sum_lat / valid),
  2106→                                                    static_cast<float>(sum_lng / valid),
  2107→                                                    0, 0 // placeholder string IDs
  2108→                                                });
  2109→                                                // Store strings for later interning
  2110→                                                local.addr_strings.push_back(std::string(housenumber) + "\0" + std::string(street));
  2111→                                                local.building_addr_count++;
  2112→                                            }
  2113→                                        }
  2114→                                    }
  2115→
  2116→                                    // Highway ways
  2117→                                    const char* highway = way.tags()["highway"];
  2118→                                    if (highway && is_included_highway(highway)) {
  2119→                                        const char* name = way.tags()["name"];
  2120→                                        if (name && wnodes.size() >= 2) {
  2121→                                            bool all_valid = true;
  2122→                                            for (const auto& nr : wnodes) {
  2123→                                                try {
  2124→                                                    auto loc = index.get(nr.positive_ref());
  2125→                                                    if (!loc.valid()) { all_valid = false; break; }
  2126→                                                } catch (...) { all_valid = false; break; }
  2127→                                            }
  2128→                                            if (all_valid) {
  2129→                                                uint32_t way_id = static_cast<uint32_t>(local.ways.size());
  2130→                                                uint32_t node_offset = static_cast<uint32_t>(local.street_nodes.size());
  2131→
  2132→                                                for (const auto& nr : wnodes) {
  2133→                                                    auto loc = index.get(nr.positive_ref());
  2134→                                                    local.street_nodes.push_back({
  2135→                                                        static_cast<float>(loc.lat()),
  2136→                                                        static_cast<float>(loc.lon())
  2137→                                                    });
  2138→                                                }
  2139→
  2140→                                                WayHeader header{};
  2141→                                                header.node_offset = node_offset;
  2142→                                                header.node_count = static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)));
  2143→                                                header.name_id = 0; // placeholder, intern later
  2144→                                                local.ways.push_back(header);
  2145→                                                local.way_strings.push_back(name);
  2146→                                                local.deferred_ways.push_back({way_id, node_offset, header.node_count});
  2147→                                                local.way_count++;
  2148→                                            }
  2149→                                        }
  2150→                                    }
  2151→
  2152→                                    // Store way geometry only for admin boundary member ways
  2153→                                    if (parallel_admin && !wnodes.empty() && admin_way_ids.count(way.id())) {
  2154→                                        std::vector<std::pair<double,double>> geom;
  2155→                                        for (const auto& nr : wnodes) {
  2156→                                            try {
  2157→                                                auto loc = index.get(nr.positive_ref());


WAIT! I found something very important! Look at line 2120:

Current code:
```cpp
if (name && wnodes.size() >= 2) {
    bool all_valid = true;
    for (const auto& nr : wnodes) {
        try {
            auto loc = index.get(nr.positive_ref());
            if (!loc.valid()) { all_valid = false; break; }
        } catch (...) { all_valid = false; break; }
    }
    if (all_valid) {
        // Add way
    }
}
```

The current code uses try/catch to handle potential index.get() exceptions, whereas the baseline version just calls location() directly. This could be the issue! Let me verify that the baseline doesn't have this try/catch pattern. The baseline was single-threaded with osmium node location handling built into the way object. The current code reads locations from a shared index that could throw exceptions.

But wait, that doesn't explain more ways being ADDED. If anything, catching exceptions and skipping would ADD fewer ways, not more.

Let me reconsider. The baseline is commit 93fe6e4 which was single-threaded. The current code (after 6fe46b1) is parallel. Actually, wait - let me check the earlier single-threaded BuildHandler code in the baseline more carefully:


git show 93fe6e4:builder/src/build_index.cpp | grep -B 5 -A 40 "class BuildHandler"


    }
}

// --- OSM handler (pass 2) ---

class BuildHandler : public osmium::handler::Handler {
public:
    void node(const osmium::Node& node) {
        if (kAdminOnly || kNoAddresses) return;
        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
            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);
            }


So in the baseline single-threaded code, the way node locations are pre-populated by osmium during the PBF read, so we never need to explicitly look them up - they're already there.

In the current parallel code, nodes are looked up from a shared index during pass 3, and there's exception handling for that lookup.

But the key difference I haven't explored yet is: could there be a difference in WHICH ways get processed when handling addr:interpolation?

In the BASELINE, look at this:
```cpp
if (!kNoAddresses) {
    if (interpolation) {
        process_interpolation_way(way, interpolation);
        return;  // EARLY RETURN
    }
    // building addresses...
}
// highway processing...
```

When kNoAddresses is FALSE (default), if a way has addr:interpolation, it returns early and doesn't process highways.

In the CURRENT code:
```cpp
if (interpolation) {
    process_interpolation_way(way, interpolation);
    continue;  // CONTINUE (skip to next item)
}
// building addresses...
// highway processing...
```

It uses `continue` which skips to the next block item, which should be equivalent to return...

Actually wait, that's in a loop! Let me check the context:


  2029→                            for (const auto& item : block) {
  2030→                                if (item.type() == osmium::item_type::way) {
  2031→                                    const auto& way = static_cast<const osmium::Way&>(item);
  2032→
  2033→                                    // Resolve node locations (read-only from shared index)
  2034→                                    // We need to check if nodes have valid locations
  2035→                                    const auto& wnodes = way.nodes();
  2036→
  2037→                                    // Address interpolation
  2038→                                    const char* interpolation = way.tags()["addr:interpolation"];
  2039→                                    if (interpolation) {
  2040→                                        if (wnodes.size() >= 2) {
  2041→                                            bool all_valid = true;
  2042→                                            for (const auto& nr : wnodes) {
  2043→                                                try {
  2044→                                                    auto loc = index.get(nr.positive_ref());
  2045→                                                    if (!loc.valid()) { all_valid = false; break; }
  2046→                                                } catch (...) { all_valid = false; break; }
  2047→                                            }
  2048→                                            if (all_valid) {
  2049→                                                const char* street = way.tags()["addr:street"];
  2050→                                                if (street) {
  2051→                                                    uint32_t interp_id = static_cast<uint32_t>(local.interp_ways.size());
  2052→                                                    uint32_t node_offset = static_cast<uint32_t>(local.interp_nodes.size());
  2053→
  2054→                                                    for (const auto& nr : wnodes) {
  2055→                                                        auto loc = index.get(nr.positive_ref());
  2056→                                                        local.interp_nodes.push_back({
  2057→                                                            static_cast<float>(loc.lat()),
  2058→                                                            static_cast<float>(loc.lon())
  2059→                                                        });
  2060→                                                    }
  2061→
  2062→                                                    uint8_t interp_type = 0;
  2063→                                                    if (std::strcmp(interpolation, "even") == 0) interp_type = 1;
  2064→                                                    else if (std::strcmp(interpolation, "odd") == 0) interp_type = 2;
  2065→
  2066→                                                    InterpWay iw{};
  2067→                                                    iw.node_offset = node_offset;
  2068→                                                    iw.node_count = static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)));
  2069→                                                    iw.street_id = 0; // placeholder, intern later
  2070→                                                    iw.start_number = 0;
  2071→                                                    iw.end_number = 0;
  2072→                                                    iw.interpolation = interp_type;
  2073→                                                    local.interp_ways.push_back(iw);
  2074→                                                    local.interp_strings.push_back(street);
  2075→                                                    local.deferred_interps.push_back({interp_id, node_offset, iw.node_count});
  2076→                                                    local.interp_count++;
  2077→                                                }
  2078→                                            }
  2079→                                        }
  2080→                                        continue;
  2081→                                    }
  2082→
  2083→                                    // Building addresses
  2084→                                    const char* housenumber = way.tags()["addr:housenumber"];
  2085→                                    if (housenumber) {
  2086→                                        const char* street = way.tags()["addr:street"];
  2087→                                        if (street && !wnodes.empty()) {
  2088→                                            double sum_lat = 0, sum_lng = 0;


Good - the `continue` at line 2080 skips to the next item in the block. So the logic is the same - if a way has addr:interpolation, it doesn't process highways.

So the behavior should be equivalent. Unless... wait, let me re-examine the condition. In the current code on line 2080, the `continue` is INSIDE the `if (interpolation)` block. So it only continues if there IS interpolation. But the code is checking:

```cpp
if (interpolation) {
    if (wnodes.size() >= 2) {
        // validate nodes
        if (all_valid) {
            if (street) {
                // add interp way
            }
        }
    }
    continue;  // Always continue if interpolation tag exists
}
```

So even if the way doesn't satisfy the conditions (not enough nodes, invalid nodes, no street tag), if it has the addr:interpolation tag, it will still skip highway processing!

Let me compare with the baseline more carefully:


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


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

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

        const char* street = way.tags()["addr:street"];
        if (!street) return;

        uint8_t interp_type = 0;
        if (std::strcmp(interpolation, "even") == 0) interp_type = 1;
        else if (std::strcmp(interpolation, "odd") == 0) interp_type = 2;

        uint32_t interp_id = static_cast<uint32_t>(interp_ways.size());
        uint32_t node_offset = static_cast<uint32_t>(interp_nodes.size());

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

        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.start_number = 0;
        iw.end_number = 0;
        iw.interpolation = interp_type;
        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());
            }
        }
        for (uint64_t cell_id : interp_cells) {
            cell_to_interps[cell_id].push_back(interp_id);
        }


And in the baseline way() handler:

```cpp
if (!kNoAddresses) {
    if (interpolation) {
        process_interpolation_way(way, interpolation);
        return;  // <-- Always returns if interpolation tag exists
    }
    // building addresses...
}
// highway processing follows
```

So in the baseline, if a way has addr:interpolation tag, we call process_interpolation_way() (which may or may not actually add the way depending on validation), and then we RETURN early from the way() handler, skipping highway processing.

In the current code:

```cpp
if (interpolation) {
    if (wnodes.size() >= 2) {
        bool all_valid = true;
        // ... validate ...
        if (all_valid) {
            if (street) {
                // add interp way
            }
        }
    }
    continue;  // Always skips highway processing if interpolation tag exists
}
// highway processing follows if no interpolation tag
```

Both skip highway processing if the addr:interpolation tag exists. So that's not it.

Let me think about this differently. The user said 14,028 more ways are being included in the current code. What if the issue is NOT with the way filtering logic itself, but with how multiple PBF passes work?

Actually, I just realized something. The user says they're comparing "baseline build (commit 93fe6e4)" to "current code" from "the same Germany PBF". Let me check if there's a difference in how admin boundary ways are handled!

In the current code with parallel admin assembly (commit 58349a5: "feat: parallel admin boundary assembly (--parallel-admin)"), maybe ways that are part of admin boundaries are being processed differently?

Let me check commit 58349a5:


git log --oneline | grep "parallel.*admin\|58349a5"


58349a5 feat: parallel admin boundary assembly (--parallel-admin)


commit 58349a54ae4ff20d27ec1c01e7d37c3de7bc13cd
Author: Zack Pollard <zackpollard@Zacks-MacBook-Pro.local>
Date:   Sat Mar 21 00:37:54 2026 +0000

    feat: parallel admin boundary assembly (--parallel-admin)

    Replaces the sequential osmium multipolygon assembly with a fully
    parallel approach:

    1. CollectedRelation struct stores relation metadata during pass 1
    2. Way geometries stored during pass 3 way processing
    3. Ring stitching (assemble_outer_rings) runs in parallel across all cores
    4. Results merged sequentially into ParsedData

    Can be toggled with --parallel-admin (default on) / --no-parallel-admin.
    The sequential osmium path is preserved as fallback.

    Includes test scripts for validating output correctness and comparing
    admin output between the two code paths.


That's about admin boundaries, not regular ways. Let me reconsider the question from scratch.

The user is asking: "why does a baseline build (commit 93fe6e4) produce 3,298,645 street ways while the current code produces 3,312,673 ways (14,028 more) from the same Germany PBF?"

They want me to find what changed in way filtering/processing that would cause MORE ways to be included.

Let me review all my findings so far:
1. kExcludedHighways list is the same
2. is_included_highway() function is the same
3. process_highway() validation (min 2 nodes, all nodes valid) is the same
4. Name requirement is the same

But wait - I should double-check if there's a subtle difference in the validation logic. In the baseline, osmium provides node locations directly. In the current parallel code, node locations are looked up from an index. Could there be a difference in how locations are validated?

Let me check the baseline's process_highway validation very carefully:


git show 93fe6e4:builder/src/build_index.cpp | sed -n '/void process_highway/,/way_count_++/p'


    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_++;


And the current version's parallel way processing:


  2116→                                    // Highway ways
  2117→                                    const char* highway = way.tags()["highway"];
  2118→                                    if (highway && is_included_highway(highway)) {
  2119→                                        const char* name = way.tags()["name"];
  2120→                                        if (name && wnodes.size() >= 2) {
  2121→                                            bool all_valid = true;
  2122→                                            for (const auto& nr : wnodes) {
  2123→                                                try {
  2124→                                                    auto loc = index.get(nr.positive_ref());
  2125→                                                    if (!loc.valid()) { all_valid = false; break; }
  2126→                                                } catch (...) { all_valid = false; break; }
  2127→                                            }
  2128→                                            if (all_valid) {
  2129→                                                uint32_t way_id = static_cast<uint32_t>(local.ways.size());
  2130→                                                uint32_t node_offset = static_cast<uint32_t>(local.street_nodes.size());
  2131→
  2132→                                                for (const auto& nr : wnodes) {
  2133→                                                    auto loc = index.get(nr.positive_ref());
  2134→                                                    local.street_nodes.push_back({
  2135→                                                        static_cast<float>(loc.lat()),
  2136→                                                        static_cast<float>(loc.lon())
  2137→                                                    });
  2138→                                                }
  2139→
  2140→                                                WayHeader header{};
  2141→                                                header.node_offset = node_offset;
  2142→                                                header.node_count = static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)));
  2143→                                                header.name_id = 0; // placeholder, intern later
  2144→                                                local.ways.push_back(header);
  2145→                                                local.way_strings.push_back(name);
  2146→                                                local.deferred_ways.push_back({way_id, node_offset, header.node_count});
  2147→                                                local.way_count++;
  2148→                                            }
  2149→                                        }
  2150→                                    }
  2151→
  2152→                                    // Store way geometry only for admin boundary member ways
  2153→                                    if (parallel_admin && !wnodes.empty() && admin_way_ids.count(way.id())) {
  2154→                                        std::vector<std::pair<double,double>> geom;
  2155→                                        for (const auto& nr : wnodes) {


WAIT! I think I found it! Look at the order of conditions:

**Baseline:**
```cpp
if (highway && is_included_highway(highway)) {
    const char* name = way.tags()["name"];
    if (name) {
        process_highway(way, name);
    }
}
```

**Current:**
```cpp
if (highway && is_included_highway(highway)) {
    const char* name = way.tags()["name"];
    if (name && wnodes.size() >= 2) {
        bool all_valid = true;
        for (const auto& nr : wnodes) {
            ...
        }
        if (all_valid) {
            // add way
        }
    }
}
```

In the baseline, the `if (name)` check comes FIRST, then we call `process_highway(way, name)` which checks node count and validity internally.

But WAIT - that's just a reordering of the same checks. The actual validation should be identical.

Hmm, but I notice: in the baseline, node count validation in process_highway is:
```cpp
if (wnodes.size() < 2) return;
```

In the current code, it's:
```cpp
if (name && wnodes.size() >= 2) {
```

Both check that size >= 2, so that's the same.

Actually, let me re-read the current code. It has:
```cpp
if (name && wnodes.size() >= 2) {
    bool all_valid = true;
    for (const auto& nr : wnodes) {
        try {
            auto loc = index.get(nr.positive_ref());
            if (!loc.valid()) { all_valid = false; break; }
        } catch (...) { all_valid = false; break; }
    }
    if (all_valid) {
        // ADD WAY
    }
}
```

So the condition to ADD a way in current code is: `highway && is_included_highway(highway) && name && wnodes.size() >= 2 && all nodes valid && no exceptions`

But wait - there's the try/catch! What if the baseline doesn't throw exceptions because it uses preprocessed node locations, but the current code throws exceptions because of a bug in the DenseIndex?

If exceptions are being thrown and caught, that would SKIP ways, making fewer ways, not more.

Let me think differently. Could the issue be that the current code is processing ways that the baseline code DIDN'T process due to some other difference?

Wait! Let me check: does the baseline code process ways in a "full" build mode or something different?

Actually, I should verify whether the user is actually comparing apples-to-apples. They said "baseline build" - maybe they're running with special flags?

Let me create a summary of what I've found and report it clearly to the user.

Based on my investigation of the code changes between commits 93fe6e4 and 3984d21, here's my findings:

## Summary of Findings

The 14,028 additional ways (3,298,645 -> 3,312,673) being included in the current code likely stems from changes in the parallel processing architecture, specifically in **commit 6fe46b1** ("feat: parallel planet processing with sharded node index"). Here are the key changes:

### 1. **Removal of Global Mode Guards (Commit 6fe46b1)**
In the baseline (93fe6e4), way processing was guarded by `kAdminOnly` and `kNoAddresses` flags:
- Old code: `if (kAdminOnly) return;` and `if (!kNoAddresses) { ... }`
- Current code: No such guards - all ways are collected unconditionally

However, since the default mode is `IndexMode::Full`, both should process the same ways during collection.

### 2. **Parallel Way Processing with Exception Handling (Commit 6fe46b1 onwards)**
The baseline uses osmium's built-in node location resolution during PBF reading. The current code uses a **DenseIndex** that's queried during pass 3 with try/catch exception handling:

```cpp
// Current code (pass 3, lines 2122-2127)
for (const auto& nr : wnodes) {
    try {
        auto loc = index.get(nr.positive_ref());
        if (!loc.valid()) { all_valid = false; break; }
    } catch (...) { all_valid = false; break; }
}
```

This exception handling shouldn't ADD ways - it would skip them.

### 3. **Possible Causes of the Increase**

The increase of 14,028 ways could be due to:

- **Changes in node validation between passes**: The baseline validates nodes during a single pass where osmium handles locations. The current code validates locations from a separate index that's built in pass 2, potentially handling edge cases differently.

- **Bounds/coordinate precision differences**: The node index in the current code uses int64_t node IDs (with bounds checking added in commit 0a2bb57: "increase dense index to 15B max node ID with bounds checking"). This could include ways that were previously rejected.

- **Changes to covered cell computation**: The old code computed S2 cell coverage immediately in `process_highway()`. The current code defers it (lines 2146, 880), which shouldn't affect way collection but the parallel processing could have different rounding or edge case handling.

### Recommendations to Debug

To find the exact cause, I would suggest:

1. Add logging in both versions to print exactly which ways are being skipped and why
2. Compare the "before" and "after" for a few specific ways that exist in 3,312,673 but not 3,298,645
3. Check if any commit between 6fe46b1 and the current version specifically changed node validation logic or bounds checking

Files of interest:
- `/root/traccar-geocoder/builder/src/build_index.cpp` - lines 2116-2150 (current parallel way processing)
- Commit 0a2bb57 for bounds checking changes
- Commit 6fe46b1 for the switch to parallel processing with the DenseIndex