The file /root/traccar-geocoder/builder/src/build_index.cpp has been updated successfully.

    // Set global simplification mode
    kSimplifyMode = simplify_mode;
    kSimplifyEpsilonOverride = simplify_epsilon_override;
    if (simplify_mode == SimplifyMode::ErrorBounded) {
        if (simplify_epsilon_override > 0) {
            std::cerr << "Simplification: error-bounded, fixed " << simplify_epsilon_override << "m epsilon" << std::endl;
        } else {
            std::cerr << "Simplification: error-bounded, scale=" << kEpsilonScale
                      << ", epsilons: L2=" << admin_epsilon_meters(2)
                      << "m L4=" << admin_epsilon_meters(4)
                      << "m L6=" << admin_epsilon_meters(6)
                      << "m L8=" << admin_epsilon_meters(8) << "m" << std::endl;
        }
    }

#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstring>
#include <deque>
#include <future>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>
#include <unordered_set>
#include <vector>

#include <osmium/io/pbf_input.hpp>
#include <osmium/visitor.hpp>
#include <sys/mman.h>

#include <s2/s2cell_id.h>

#include "types.h"
#include "string_pool.h"
#include "geometry.h"
#include "parsed_data.h"
#include "s2_helpers.h"
#include "ring_assembly.h"
#include "relation_collector.h"
#include "interpolation.h"
#include "cache.h"
#include "continent_filter.h"
#include "cell_index.h"

// --- Main ---

int main(int argc, char* argv[]) {
    if (argc < 2) {
        std::cerr << "Usage: build-index <output-dir> <input.osm.pbf> [options]" << std::endl;
        std::cerr << "       build-index <output-dir> --load-cache <path> [options]" << std::endl;
        std::cerr << std::endl;
        std::cerr << "Options:" << std::endl;
        std::cerr << "  --street-level N       S2 cell level for streets (default: 17)" << std::endl;
        std::cerr << "  --admin-level N        S2 cell level for admin (default: 10)" << std::endl;
        std::cerr << "  --max-admin-level N    Only include admin levels <= N" << std::endl;
        std::cerr << "  --save-cache <path>    Save parsed data to cache file" << std::endl;
        std::cerr << "  --load-cache <path>    Load from cache instead of PBF" << std::endl;
        std::cerr << "  --multi-output         Write full, no-addresses, admin-only indexes" << std::endl;
        std::cerr << "  --continents           Also generate per-continent indexes" << std::endl;
        std::cerr << "  --mode <mode>          Index mode: full, no-addresses, admin-only (default: full)" << std::endl;
        std::cerr << "  --admin-only           Shorthand for --mode admin-only" << std::endl;
        std::cerr << "  --no-addresses         Shorthand for --mode no-addresses" << std::endl;
        std::cerr << "  --simplify-epsilon     Use error-bounded simplification (per-level defaults)" << std::endl;
        std::cerr << "  --simplify-epsilon N   Use fixed epsilon of N meters for all levels" << std::endl;
        std::cerr << "  --epsilon-scale F      Multiply all per-level epsilons by F (e.g. 2.0 = 2x coarser)" << std::endl;
        std::cerr << "  --epsilon-levels L2,L3,...,L8  Set epsilon in meters per level (7 values)" << std::endl;
        return 1;
    }

    std::string output_dir = argv[1];
    std::vector<std::string> input_files;
    std::string save_cache_path;
    std::string load_cache_path;
    bool multi_output = false;
    bool generate_continents = false;
    IndexMode mode = IndexMode::Full;
    SimplifyMode simplify_mode = SimplifyMode::MaxVertices;
    double simplify_epsilon_override = 0; // 0 = use per-level defaults

    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") {
            mode = IndexMode::AdminOnly;
        } else if (arg == "--no-addresses") {
            mode = IndexMode::NoAddresses;
        } else if (arg == "--max-admin-level" && i + 1 < argc) {
            kMaxAdminLevel = std::atoi(argv[++i]);
        } else if (arg == "--save-cache" && i + 1 < argc) {
            save_cache_path = argv[++i];
        } else if (arg == "--load-cache" && i + 1 < argc) {
            load_cache_path = argv[++i];
        } else if (arg == "--multi-output") {
            multi_output = true;
        } else if (arg == "--continents") {
            generate_continents = true;
        } else if (arg == "--simplify-epsilon") {
            simplify_mode = SimplifyMode::ErrorBounded;
            // Optional: next arg might be a number (fixed epsilon in meters)
            if (i + 1 < argc) {
                char* end;
                double val = std::strtod(argv[i+1], &end);
                if (*end == '\0' && val > 0) {
                    simplify_epsilon_override = val;
                    i++;
                }
            }
        } else if (arg == "--epsilon-scale" && i + 1 < argc) {
            kEpsilonScale = std::strtod(argv[++i], nullptr);
        } else if (arg == "--epsilon-levels" && i + 1 < argc) {
            // Parse comma-separated values: L2,L3,L4,L5,L6,L7,L8
            std::string vals = argv[++i];
            int level = 2;
            size_t pos = 0;
            while (pos < vals.size() && level <= 8) {
                size_t next = vals.find(',', pos);
                if (next == std::string::npos) next = vals.size();
                double v = std::strtod(vals.substr(pos, next - pos).c_str(), nullptr);
                if (v > 0) kAdminEpsilonMeters[level] = v;
                level++;
                pos = next + 1;
            }
            // Also set levels 9-11 to same as level 8
            for (int l = level; l <= 11; l++) kAdminEpsilonMeters[l] = kAdminEpsilonMeters[8];
        } else if (arg == "--mode" && i + 1 < argc) {
            std::string mode_str = argv[++i];
            if (mode_str == "full") {
                mode = IndexMode::Full;
            } else if (mode_str == "no-addresses") {
                mode = IndexMode::NoAddresses;
            } else if (mode_str == "admin-only") {
                mode = IndexMode::AdminOnly;
            } else {
                std::cerr << "Error: unknown mode '" << mode_str << "'" << std::endl;
                return 1;
            }
        } else {
            input_files.push_back(arg);
        }
    }

    // Set global simplification mode
    kSimplifyMode = simplify_mode;
    kSimplifyEpsilonOverride = simplify_epsilon_override;
    if (simplify_mode == SimplifyMode::ErrorBounded) {
        if (simplify_epsilon_override > 0) {
            std::cerr << "Simplification: error-bounded, fixed " << simplify_epsilon_override << "m epsilon" << std::endl;
        } else {
            std::cerr << "Simplification: error-bounded, per-level epsilon (15-500m)" << std::endl;
        }
    }

    ParsedData data;
    auto _pt = std::chrono::steady_clock::now();

    if (!load_cache_path.empty()) {
        // Load from cache
        if (!deserialize_cache(data, load_cache_path)) {
            std::cerr << "Error: failed to load cache" << std::endl;
            return 1;
        }
    } else {
        // Parse PBF files (always collect everything)
        if (input_files.empty()) {
            std::cerr << "Error: no input files specified and no --load-cache" << std::endl;
            return 1;
        }

        // Create thread pool for concurrent admin polygon S2 covering
        unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency() > 4 ? std::thread::hardware_concurrency() - 4 : 1u);
        std::cerr << "Using " << num_threads << " worker threads." << std::endl;
        AdminCoverPool admin_pool(num_threads);
        // BuildHandler no longer used — parallel processing handles everything

        // Dense array node location index — lockless parallel writes
        // Planet OSM node IDs max ~12.5 billion. 8 bytes per Location = 100GB virtual.
        // MAP_NORESERVE means OS only allocates pages on write (~80GB for 10B nodes).
        static const size_t MAX_NODE_ID = MAX_NODE_ID_DEFAULT;
        struct DenseIndex {
            osmium::Location* data;
            size_t capacity;

            DenseIndex() : capacity(MAX_NODE_ID) {
                void* ptr = mmap(nullptr, capacity * sizeof(osmium::Location),
                    PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE,
                    -1, 0);
                if (ptr == MAP_FAILED) {
                    std::cerr << "Error: failed to mmap dense node index ("
                              << (capacity * sizeof(osmium::Location) / (1024*1024*1024)) << "GB virtual)" << std::endl;
                    std::exit(1);
                }
                data = static_cast<osmium::Location*>(ptr);
                std::cerr << "Allocated dense node index: " << (capacity * sizeof(osmium::Location) / (1024*1024*1024))
                          << "GB virtual address space" << std::endl;
            }

            ~DenseIndex() {
                munmap(data, capacity * sizeof(osmium::Location));
            }

            // Lockless — each node ID maps to a unique array slot
            void set(osmium::unsigned_object_id_type id, osmium::Location loc) {
                if (id >= capacity) {
                    std::cerr << "FATAL: node ID " << id << " exceeds dense index capacity " << capacity << std::endl;
                    std::exit(1);
                }
                data[id] = loc;
            }

            osmium::Location get(osmium::unsigned_object_id_type id) const {
                if (id >= capacity) return osmium::Location{};
                return data[id];
            }

            void set_batch(const std::vector<std::pair<osmium::unsigned_object_id_type, osmium::Location>>& batch) {
                for (const auto& [id, loc] : batch) {
                    if (id >= capacity) {
                        std::cerr << "FATAL: node ID " << id << " exceeds dense index capacity " << capacity << std::endl;
                        std::exit(1);
                    }
                    data[id] = loc;
                }
            }
        } index;

        for (const auto& input_file : input_files) {
            std::cerr << "Processing " << input_file << "..." << std::endl;

            // --- Pass 1: collect relation members for parallel admin assembly ---
            std::cerr << "  Pass 1: scanning relations..." << std::endl;
            RelationCollector rel_collector(data.collected_relations);

            {
                osmium::io::Reader reader1{input_file, osmium::osm_entity_bits::relation};
                osmium::apply(reader1, rel_collector);
                reader1.close();
            }
            std::cerr << "  Collected " << data.collected_relations.size()
                      << " admin/postal relations for parallel assembly." << std::endl;

            // --- Combined Pass 2+3: nodes then ways in a single PBF read ---
            // PBF guarantees ordering: nodes → ways → relations.
            // We read everything in one pass, processing nodes first, then
            // transitioning to way processing when the first way block arrives.
            log_phase("Pass 1: relation scanning", _pt);

            // Build admin_way_ids BEFORE the combined pass (needed during way processing)
            std::unordered_set<int64_t> admin_way_ids;
            for (const auto& rel : data.collected_relations) {
                for (const auto& [way_id, role] : rel.members) {
                    admin_way_ids.insert(way_id);
                }
            }
            std::cerr << "  Admin assembly needs " << admin_way_ids.size() << " way geometries." << std::endl;

            std::cerr << "  Pass 2+3: processing nodes and ways in single read..." << std::endl;

            {
                // Shared queue infrastructure for both phases
                std::mutex queue_mutex;
                std::condition_variable queue_cv;
                std::deque<osmium::memory::Buffer> block_queue;
                bool reader_done = false;
                const size_t MAX_QUEUE = MAX_BLOCK_QUEUE;

                struct NodeThreadLocal {
                    std::vector<std::pair<double,double>> addr_coords;
                    std::vector<std::pair<std::string,std::string>> addr_strings;
                    uint64_t count = 0;
                };
                std::vector<NodeThreadLocal> ntld(num_threads);
                std::atomic<uint64_t> blocks_done{0};

                // Worker threads
                std::vector<std::thread> node_workers;
                for (unsigned int t = 0; t < num_threads; t++) {
                    node_workers.emplace_back([&, t]() {
                        auto& local = ntld[t];
                        std::vector<std::pair<osmium::unsigned_object_id_type, osmium::Location>> loc_batch;
                        loc_batch.reserve(8192);

                        while (true) {
                            osmium::memory::Buffer block;
                            {
                                std::unique_lock<std::mutex> lock(queue_mutex);
                                queue_cv.wait(lock, [&]{ return !block_queue.empty() || reader_done; });
                                if (block_queue.empty() && reader_done) break;
                                block = std::move(block_queue.front());
                                block_queue.pop_front();
                            }
                            queue_cv.notify_one(); // wake reader if it was waiting

                            for (const auto& item : block) {
                                if (item.type() == osmium::item_type::node) {
                                    const auto& node = static_cast<const osmium::Node&>(item);
                                    if (!node.location().valid()) continue;
                                    loc_batch.push_back({node.positive_id(), node.location()});

                                    const char* housenumber = node.tags()["addr:housenumber"];
                                    if (housenumber) {
                                        const char* street = node.tags()["addr:street"];
                                        if (street) {
                                            local.addr_coords.push_back({node.location().lat(), node.location().lon()});
                                            local.addr_strings.push_back({housenumber, street});
                                            local.count++;
                                        }
                                    }
                                }
                            }

                            // Flush location batch (sharded index handles its own locking)
                            if (!loc_batch.empty()) {
                                index.set_batch(loc_batch);
                                loc_batch.clear();
                            }

                            uint64_t n = blocks_done.fetch_add(1) + 1;
                            if (n % 1000 == 0) {
                                std::cerr << "  Processed " << n << " node blocks..." << std::endl;
                            }
                        }

                        // Flush remaining
                        if (!loc_batch.empty()) {
                            index.set_batch(loc_batch);
                        }
                    });
                }

                // Single reader for all entity types — processes nodes first,
                // then transitions to way processing when first way block arrives.
                osmium::io::Reader combined_reader{input_file};
                bool nodes_done = false;
                osmium::memory::Buffer first_way_buf; // stash the first way block

                // Phase 1: Feed node blocks to node workers
                while (auto buf = combined_reader.read()) {
                    // Check if this buffer contains nodes or ways/relations
                    bool has_node = false;
                    for (const auto& item : buf) {
                        if (item.type() == osmium::item_type::node) { has_node = true; break; }
                    }

                    if (has_node) {
                        std::unique_lock<std::mutex> lock(queue_mutex);
                        queue_cv.wait(lock, [&]{ return block_queue.size() < MAX_QUEUE; });
                        block_queue.push_back(std::move(buf));
                        queue_cv.notify_one();
                    } else {
                        // First non-node block — stash it and transition
                        first_way_buf = std::move(buf);
                        nodes_done = true;
                        break;
                    }
                }

                // Signal node workers done
                {
                    std::lock_guard<std::mutex> lock(queue_mutex);
                    reader_done = true;
                }
                queue_cv.notify_all();
                for (auto& w : node_workers) w.join();

                // Merge address points
                uint64_t total_addrs = 0;
                for (auto& local : ntld) {
                    for (size_t j = 0; j < local.addr_coords.size(); j++) {
                        uint64_t dummy = 0;
                        add_addr_point(data, local.addr_coords[j].first, local.addr_coords[j].second,
                                       local.addr_strings[j].first.c_str(),
                                       local.addr_strings[j].second.c_str(), dummy);
                    }
                    total_addrs += local.count;
                }
                std::cerr << "  Node processing complete: " << total_addrs
                          << " address points collected." << std::endl;
                log_phase("Pass 2: node processing", _pt);
                std::cerr << "  Processing ways with " << num_threads << " threads..." << std::endl;

                // --- Phase 2: Way processing using same reader ---

                // 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::pair<std::string,std::string>> addr_strings; // building addr {hn, street}
                    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;
                    // Closed-way admin polygons (ways with boundary=administrative)
                    struct ClosedWayAdmin {
                        std::vector<std::pair<double,double>> vertices;
                        std::string name;
                        uint8_t admin_level;
                        std::string country_code;
                    };
                    std::vector<ClosedWayAdmin> closed_way_admins;
                };

                // 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 = MAX_BLOCK_QUEUE;

                std::vector<ThreadLocalData> tld(num_threads);
                std::atomic<uint64_t> way_blocks_done{0};

                std::vector<std::thread> workers;
                for (unsigned int t = 0; t < num_threads; t++) {
                    workers.emplace_back([&, t]() {
                        auto& local = tld[t];
                        while (true) {
                            osmium::memory::Buffer block;
                            {
                                std::unique_lock<std::mutex> lock(way_queue_mutex);
                                way_queue_cv.wait(lock, [&]{ return !way_block_queue.empty() || way_reader_done; });
                                if (way_block_queue.empty() && way_reader_done) break;
                                block = std::move(way_block_queue.front());
                                way_block_queue.pop_front();
                            }
                            way_queue_cv.notify_one();

                            for (const auto& item : block) {
                                if (item.type() == osmium::item_type::way) {
                                    const auto& way = static_cast<const osmium::Way&>(item);

                                    // Resolve node locations (read-only from shared index)
                                    // We need to check if nodes have valid locations
                                    const auto& wnodes = way.nodes();

                                    // Address interpolation
                                    const char* interpolation = way.tags()["addr:interpolation"];
                                    if (interpolation) {
                                        if (wnodes.size() >= 2) {
                                            bool all_valid = true;
                                            for (const auto& nr : wnodes) {
                                                auto loc = index.get(nr.positive_ref());
                                                if (!loc.valid()) { all_valid = false; break; }
                                            }
                                            if (all_valid) {
                                                const char* street = way.tags()["addr:street"];
                                                if (street) {
                                                    uint32_t interp_id = static_cast<uint32_t>(local.interp_ways.size());
                                                    uint32_t node_offset = static_cast<uint32_t>(local.interp_nodes.size());

                                                    for (const auto& nr : wnodes) {
                                                        auto loc = index.get(nr.positive_ref());
                                                        local.interp_nodes.push_back({
                                                            static_cast<float>(loc.lat()),
                                                            static_cast<float>(loc.lon())
                                                        });
                                                    }

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

                                                    InterpWay iw{};
                                                    iw.node_offset = node_offset;
                                                    iw.node_count = static_cast<uint8_t>(std::min(wnodes.size(), size_t(255)));
                                                    iw.street_id = 0; // placeholder, intern later
                                                    iw.start_number = 0;
                                                    iw.end_number = 0;
                                                    iw.interpolation = interp_type;
                                                    local.interp_ways.push_back(iw);
                                                    local.interp_strings.push_back(street);
                                                    local.deferred_interps.push_back({interp_id, node_offset, iw.node_count});
                                                    local.interp_count++;
                                                }
                                            }
                                        }
                                        continue;
                                    }

                                    // Building addresses
                                    const char* housenumber = way.tags()["addr:housenumber"];
                                    if (housenumber) {
                                        const char* street = way.tags()["addr:street"];
                                        if (street && !wnodes.empty()) {
                                            double sum_lat = 0, sum_lng = 0;
                                            int valid = 0;
                                            for (const auto& nr : wnodes) {
                                                auto loc = index.get(nr.positive_ref());
                                                if (loc.valid()) {
                                                    sum_lat += loc.lat();
                                                    sum_lng += loc.lon();
                                                    valid++;
                                                }
                                            }
                                            if (valid > 0) {
                                                // Store coords for later S2 cell computation
                                                local.building_addr_coords.push_back({sum_lat / valid, sum_lng / valid});
                                                // Placeholder addr point — string IDs filled in during merge
                                                local.building_addrs.push_back({
                                                    static_cast<float>(sum_lat / valid),
                                                    static_cast<float>(sum_lng / valid),
                                                    0, 0 // placeholder string IDs
                                                });
                                                // Store strings for later interning
                                                local.addr_strings.push_back({housenumber, street});
                                                local.building_addr_count++;
                                            }
                                        }
                                    }

                                    // Highway ways
                                    const char* highway = way.tags()["highway"];
                                    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) {
                                                auto loc = index.get(nr.positive_ref());
                                                if (!loc.valid()) { all_valid = false; break; }
                                            }
                                            if (all_valid) {
                                                uint32_t way_id = static_cast<uint32_t>(local.ways.size());
                                                uint32_t node_offset = static_cast<uint32_t>(local.street_nodes.size());

                                                for (const auto& nr : wnodes) {
                                                    auto loc = index.get(nr.positive_ref());
                                                    local.street_nodes.push_back({
                                                        static_cast<float>(loc.lat()),
                                                        static_cast<float>(loc.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 = 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 (!wnodes.empty() && admin_way_ids.count(way.id())) {
                                        std::vector<std::pair<double,double>> geom;
                                        for (const auto& nr : wnodes) {
                                            auto loc = index.get(nr.positive_ref());
                                            if (loc.valid()) geom.push_back({loc.lat(), loc.lon()});
                                        }
                                        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});
                                        }
                                    }

                                    // Handle closed ways that are admin boundaries themselves
                                    // Osmium creates areas from closed ways independently of
                                    // multipolygon relations, even if the way is a relation member.
                                    {
                                        const char* boundary = way.tags()["boundary"];
                                        if (boundary) {
                                            bool is_admin = (std::strcmp(boundary, "administrative") == 0);
                                            bool is_postal = (std::strcmp(boundary, "postal_code") == 0);
                                            if ((is_admin || is_postal) && wnodes.size() >= 4 &&
                                                wnodes.front().ref() == wnodes.back().ref()) {
                                                // Closed way forming an admin polygon
                                                uint8_t al = 0;
                                                if (is_admin) {
                                                    const char* level_str = way.tags()["admin_level"];
                                                    if (level_str) al = static_cast<uint8_t>(std::atoi(level_str));
                                                } else {
                                                    al = 11;
                                                }
                                                int max_al = is_postal ? 11 : 10;
                                                if (al >= 2 && al <= max_al && (kMaxAdminLevel == 0 || al <= kMaxAdminLevel)) {
                                                    const char* aname = way.tags()["name"];
                                                    if (!aname && is_admin) continue; // admin needs name
                                                    std::string name_str;
                                                    if (is_postal) {
                                                        const char* pc = way.tags()["postal_code"];
                                                        if (!pc) pc = aname;
                                                        if (!pc) continue; // postal needs postal_code or name
                                                        name_str = pc;
                                                    } else {
                                                        name_str = aname;
                                                    }
                                                    {
                                                        std::vector<std::pair<double,double>> verts;
                                                        bool all_valid = true;
                                                        for (const auto& nr : wnodes) {
                                                            auto loc = index.get(nr.positive_ref());
                                                            if (loc.valid()) {
                                                                verts.push_back({loc.lat(), loc.lon()});
                                                            } else { all_valid = false; break; }
                                                        }
                                                        if (all_valid && verts.size() >= 3) {
                                                            std::string cc;
                                                            if (al == 2) {
                                                                const char* iso = way.tags()["ISO3166-1:alpha2"];
                                                                if (iso) cc = iso;
                                                            }
                                                            local.closed_way_admins.push_back({
                                                                std::move(verts),
                                                                std::move(name_str),
                                                                al, std::move(cc)
                                                            });
                                                        }
                                                    }
                                                }
                                            }
                                        }
                                    }
                                }
                            }

                            uint64_t done = way_blocks_done.fetch_add(1) + 1;
                            if (done % 1000 == 0) {
                                std::cerr << "  Processed " << done << " way blocks..." << std::endl;
                            }
                        }
                    });
                }

                // Way reader: use the combined_reader (continuing from where nodes left off)
                {
                    // First, push the stashed way buffer
                    if (first_way_buf) {
                        std::unique_lock<std::mutex> lock(way_queue_mutex);
                        way_queue_cv.wait(lock, [&]{ return way_block_queue.size() < WAY_MAX_QUEUE; });
                        way_block_queue.push_back(std::move(first_way_buf));
                        way_queue_cv.notify_one();
                    }
                    // Continue reading remaining blocks from the same reader
                    while (auto buf = combined_reader.read()) {
                        {
                            std::unique_lock<std::mutex> lock(way_queue_mutex);
                            way_queue_cv.wait(lock, [&]{ return way_block_queue.size() < WAY_MAX_QUEUE; });
                            way_block_queue.push_back(std::move(buf));
                        }
                        way_queue_cv.notify_one();
                    }
                    combined_reader.close();
                    {
                        std::lock_guard<std::mutex> lock(way_queue_mutex);
                        way_reader_done = true;
                    }
                    way_queue_cv.notify_all();
                }

                for (auto& w : workers) w.join();
                std::cerr << "  Parallel way processing complete." << std::endl;

                // Process areas/multipolygons — sequential fallback path
                // 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);
                    }
                    data.street_nodes.insert(data.street_nodes.end(),
                        local.street_nodes.begin(), local.street_nodes.end());

                    // Remap deferred ways
                    for (auto dw : local.deferred_ways) {
                        dw.way_id += way_base;
                        dw.node_offset += node_base;
                        data.deferred_ways.push_back(dw);
                    }

                    // Merge building addresses
                    for (size_t i = 0; i < local.building_addrs.size(); i++) {
                        uint64_t dummy = 0;
                        add_addr_point(data, local.building_addrs[i].lat, local.building_addrs[i].lng,
                                       local.addr_strings[i].first.c_str(),
                                       local.addr_strings[i].second.c_str(), dummy);
                    }

                    // Merge interpolation ways
                    for (size_t i = 0; i < local.interp_ways.size(); i++) {
                        auto iw = local.interp_ways[i];
                        iw.node_offset += interp_node_base;
                        iw.street_id = data.string_pool.intern(local.interp_strings[i]);
                        data.interp_ways.push_back(iw);
                    }
                    data.interp_nodes.insert(data.interp_nodes.end(),
                        local.interp_nodes.begin(), local.interp_nodes.end());

                    // Remap deferred interps
                    for (auto di : local.deferred_interps) {
                        di.interp_id += interp_base;
                        di.node_offset += interp_node_base;
                        data.deferred_interps.push_back(di);
                    }

                    total_ways += local.way_count;
                    total_building_addrs += local.building_addr_count;
                    total_interps += local.interp_count;

                    // Merge way geometries for admin assembly
                    {
                        for (auto& wg : local.way_geoms) {
                            ParsedData::WayGeometry g;
                            g.coords = std::move(wg.coords);
                            g.first_node_id = wg.first_node_id;
                            g.last_node_id = wg.last_node_id;
                            data.way_geometries[wg.way_id] = std::move(g);
                        }
                        local.way_geoms.clear();
                        local.way_geoms.shrink_to_fit();
                    }
                }
                std::cerr << "  Merged: " << total_ways << " ways, "
                          << total_building_addrs << " building addrs, "
                          << total_interps << " interps from parallel processing." << std::endl;

                // --- Merge closed-way admin polygons ---
                {
                    uint64_t closed_way_admin_count = 0;
                    for (auto& local : tld) {
                        for (auto& cwa : local.closed_way_admins) {
                            const char* cc = cwa.country_code.empty() ? nullptr : cwa.country_code.c_str();
                            add_admin_polygon(data, cwa.vertices, cwa.name.c_str(),
                                              cwa.admin_level, cc, &admin_pool);
                            closed_way_admin_count++;
                        }
                        local.closed_way_admins.clear();
                    }
                    if (closed_way_admin_count > 0) {
                        std::cerr << "  Added " << closed_way_admin_count
                                  << " admin polygons from closed ways." << std::endl;
                    }
                }

                // --- Parallel admin boundary assembly ---
                {
                    log_phase("Pass 2b: way processing", _pt);
                    std::cerr << "  Assembling admin polygons in parallel ("
                              << data.collected_relations.size() << " relations, "
                              << data.way_geometries.size() << " way geometries)..." << std::endl;

                    // Thread-local admin results (to avoid locking add_admin_polygon)
                    struct AdminResult {
                        std::vector<std::pair<double,double>> vertices;
                        std::string name;
                        uint8_t admin_level;
                        std::string country_code;
                    };

                    std::vector<std::vector<AdminResult>> thread_admin_results(num_threads);
                    std::atomic<size_t> rel_idx{0};
                    std::atomic<uint64_t> assembled_count{0};

                    std::vector<std::thread> admin_workers;
                    for (unsigned int t = 0; t < num_threads; t++) {
                        admin_workers.emplace_back([&, t]() {
                            auto& local_results = thread_admin_results[t];
                            while (true) {
                                size_t i = rel_idx.fetch_add(1);
                                if (i >= data.collected_relations.size()) break;

                                const auto& rel = data.collected_relations[i];

                                // Skip level 2 border-line relations — these have names like
                                // "Deutschland - Österreich" (two countries separated by
                                // " - " or " — "). They are boundary lines, not country
                                // polygons, and produce self-intersecting geometry.
                                if (rel.admin_level == 2 && rel.country_code.empty() &&
                                    (rel.name.find(" - ") != std::string::npos ||
                                     rel.name.find(" \xe2\x80\x94 ") != std::string::npos)) { // " — " (em dash)
                                    assembled_count.fetch_add(1);
                                    continue;
                                }

                                // Skip relations with missing member ways —
                                // these cross the extract boundary and would produce
                                // incorrect partial polygons
                                bool has_missing = false;
                                for (const auto& [way_id, role] : rel.members) {
                                    if (data.way_geometries.find(way_id) == data.way_geometries.end()) {
                                        has_missing = true;
                                        break;
                                    }
                                }
                                if (has_missing) {
                                    assembled_count.fetch_add(1);
                                    continue;
                                }

                                auto rings = assemble_outer_rings(rel.members, data.way_geometries);
                                // If outer-only assembly failed, retry with all ways.
                                // Osmium ignores roles during assembly (check_roles=false) —
                                // inner ways sometimes form the boundary or bridge gaps.
                                // Filter retry results: discard rings with duplicate coords
                                // (figure-8 shapes from merging holes with outer boundary).
                                if (rings.empty()) {
                                    auto retry = assemble_outer_rings(rel.members, data.way_geometries, true);
                                    for (auto& ring : retry) {
                                        if (!ring_has_duplicate_coords(ring)) {
                                            rings.push_back(std::move(ring));
                                        }
                                    }
                                }

                                // Diagnostic: log relations with 0 rings produced
                                if (rings.empty() && !rel.members.empty()) {
                                    int total_ways = 0, found = 0, missing = 0;
                                    for (const auto& [way_id, role] : rel.members) {
                                        total_ways++;
                                        auto it = data.way_geometries.find(way_id);
                                        if (it != data.way_geometries.end() && !it->second.coords.empty()) {
                                            found++;
                                        } else {
                                            missing++;
                                        }
                                    }
                                    if (missing == 0 && found > 0) {
                                        std::string detail = "  DEBUG '" + rel.name + "': ways:";
                                        for (const auto& [way_id, role] : rel.members) {
                                            auto it = data.way_geometries.find(way_id);
                                            if (it == data.way_geometries.end()) continue;
                                            detail += " [w" + std::to_string(way_id) +
                                                      " first=" + std::to_string(it->second.first_node_id) +
                                                      " last=" + std::to_string(it->second.last_node_id) +
                                                      " n=" + std::to_string(it->second.coords.size()) + "]";
                                        }
                                        std::cerr << detail << std::endl;
                                    }
                                    std::cerr << "  WARN: relation '" << rel.name
                                              << "' (level " << (int)rel.admin_level
                                              << ", " << rel.members.size() << " members): "
                                              << "0 rings from " << total_ways << " ways ("
                                              << found << " found, " << missing << " missing)" << std::endl;
                                }

                                for (auto& ring : rings) {
                                    if (ring.size() >= 3) {
                                        AdminResult ar;
                                        ar.vertices = std::move(ring);
                                        ar.name = rel.name;
                                        ar.admin_level = rel.admin_level;
                                        ar.country_code = rel.country_code;
                                        local_results.push_back(std::move(ar));
                                    }
                                }

                                uint64_t done = assembled_count.fetch_add(1) + 1;
                                if (done % 10000 == 0) {
                                    std::cerr << "  Assembled " << done / 1000
                                              << "K admin relations..." << std::endl;
                                }
                            }
                        });
                    }
                    for (auto& w : admin_workers) w.join();
                    log_phase("    Admin: ring assembly", _pt);

                    // Flatten all admin results into a single vector for parallel processing
                    std::vector<AdminResult> all_admin_results;
                    for (auto& local_results : thread_admin_results) {
                        all_admin_results.insert(all_admin_results.end(),
                            std::make_move_iterator(local_results.begin()),
                            std::make_move_iterator(local_results.end()));
                        local_results.clear();
                    }
                    uint64_t total_admin_rings = all_admin_results.size();

                    // Parallel simplification: the expensive work (normalize, simplify,
                    // compute area) is done per-polygon with no shared state.
                    struct PreparedPolygon {
                        std::vector<std::pair<double,double>> simplified;
                        std::string name;
                        uint8_t admin_level;
                        std::string country_code;
                        float area;
                    };
                    std::vector<PreparedPolygon> prepared(total_admin_rings);
                    {
                        std::atomic<size_t> prep_idx{0};
                        std::vector<std::thread> prep_workers;
                        for (unsigned int t = 0; t < num_threads; t++) {
                            prep_workers.emplace_back([&]() {
                                while (true) {
                                    size_t i = prep_idx.fetch_add(1);
                                    if (i >= total_admin_rings) break;
                                    auto& ar = all_admin_results[i];
                                    auto& pp = prepared[i];

                                    // Normalize ring rotation
                                    auto& vertices = ar.vertices;
                                    if (vertices.size() >= 4 &&
                                        std::fabs(vertices.front().first - vertices.back().first) < 1e-7 &&
                                        std::fabs(vertices.front().second - vertices.back().second) < 1e-7) {
                                        vertices.pop_back();
                                        auto min_it = std::min_element(vertices.begin(), vertices.end());
                                        std::rotate(vertices.begin(), min_it, vertices.end());
                                        vertices.push_back(vertices.front());
                                    }

                                    pp.simplified = simplify_admin_polygon(vertices, ar.admin_level);
                                    if (pp.simplified.size() >= 3) {
                                        pp.area = polygon_area(pp.simplified);
                                    }
                                    pp.name = std::move(ar.name);
                                    pp.admin_level = ar.admin_level;
                                    pp.country_code = std::move(ar.country_code);
                                }
                            });
                        }
                        for (auto& w : prep_workers) w.join();
                    }
                    all_admin_results.clear();
                    log_phase("    Admin: parallel simplify", _pt);

                    // Sequential append + submit to S2 pool (cheap: just vector push + string intern)
                    for (auto& pp : prepared) {
                        if (pp.simplified.size() < 3) continue;

                        uint32_t poly_id = static_cast<uint32_t>(data.admin_polygons.size());
                        uint32_t vertex_offset = static_cast<uint32_t>(data.admin_vertices.size());

                        for (const auto& [lat, lng] : pp.simplified) {
                            data.admin_vertices.push_back({static_cast<float>(lat), static_cast<float>(lng)});
                        }

                        AdminPolygon poly{};
                        poly.vertex_offset = vertex_offset;
                        poly.vertex_count = static_cast<uint16_t>(std::min(pp.simplified.size(), size_t(MAX_VERTEX_COUNT)));
                        poly.name_id = data.string_pool.intern(pp.name);
                        poly.admin_level = pp.admin_level;
                        poly.area = pp.area;
                        const char* cc = pp.country_code.empty() ? nullptr : pp.country_code.c_str();
                        poly.country_code = (cc && cc[0] && cc[1])
                            ? static_cast<uint16_t>((cc[0] << 8) | cc[1]) : 0;
                        data.admin_polygons.push_back(poly);

                        admin_pool.submit(poly_id, std::move(pp.simplified));
                    }

                    std::cerr << "  Parallel admin assembly complete: "
                              << total_admin_rings << " polygon rings from "
                              << data.collected_relations.size() << " relations." << std::endl;
                    log_phase("    Admin: append + submit S2", _pt);

                    // Free collected data
                    data.collected_relations.clear();
                    data.collected_relations.shrink_to_fit();
                    data.way_geometries.clear();
                    std::unordered_map<int64_t, ParsedData::WayGeometry>().swap(data.way_geometries);
                }
            }
        }

        std::cerr << "Done reading:" << std::endl;
        std::cerr << "  " << data.ways.size() << " street ways" << std::endl;
        std::cerr << "  " << data.addr_points.size() << " address points" << std::endl;
        std::cerr << "  " << data.interp_ways.size() << " interpolation ways" << std::endl;
        std::cerr << "  " << data.admin_polygons.size() << " admin polygon rings" << std::endl;

        // Drain admin polygon thread pool (may still be processing)
        std::cerr << "Waiting for admin polygon S2 covering to complete..." << std::endl;
        auto admin_results = admin_pool.drain();
        log_phase("    Admin: S2 covering drain", _pt);
        for (auto& [cell_id, ids] : admin_results) {
            auto& target = data.cell_to_admin[cell_id];
            target.insert(target.end(), ids.begin(), ids.end());
        }
        std::cerr << "Admin polygon S2 covering complete (" << data.cell_to_admin.size() << " cells)." << std::endl;

        // --- Parallel S2 cell computation for ways and interpolation ---
        {
            log_phase("Admin assembly", _pt);
            std::cerr << "Computing S2 cells for ways with " << num_threads << " threads..." << std::endl;

            // Flat-array approach: threads emit (cell_id, item_id) pairs into
            // thread-local vectors, then we sort globally and group by cell_id.
            // This avoids hash maps entirely — sort is cache-friendly O(n log n).

            struct CellItem { uint64_t cell_id; uint32_t item_id; };

            auto _s2t = std::chrono::steady_clock::now();

            // Process streets: emit (cell_id, way_id) pairs
            std::cerr << "  Processing " << data.deferred_ways.size() << " street ways..." << std::endl;
            std::vector<std::vector<CellItem>> way_pairs(num_threads);
            {
                std::atomic<size_t> way_idx{0};
                std::vector<std::thread> threads;
                for (unsigned int t = 0; t < num_threads; t++) {
                    threads.emplace_back([&, t]() {
                        auto& local = way_pairs[t];
                        local.reserve(data.deferred_ways.size() / num_threads * 3);
                        std::vector<S2CellId> edge_cells;
                        std::vector<uint64_t> way_cells;
                        while (true) {
                            size_t i = way_idx.fetch_add(1);
                            if (i >= data.deferred_ways.size()) break;
                            const auto& dw = data.deferred_ways[i];
                            way_cells.clear();
                            for (uint8_t j = 0; j + 1 < dw.node_count; j++) {
                                const auto& n1 = data.street_nodes[dw.node_offset + j];
                                const auto& n2 = data.street_nodes[dw.node_offset + j + 1];
                                cover_edge(n1.lat, n1.lng, n2.lat, n2.lng, edge_cells);
                                for (const auto& c : edge_cells) way_cells.push_back(c.id());
                            }
                            std::sort(way_cells.begin(), way_cells.end());
                            way_cells.erase(std::unique(way_cells.begin(), way_cells.end()), way_cells.end());
                            for (uint64_t cell_id : way_cells) {
                                local.push_back({cell_id, dw.way_id});
                            }
                        }
                    });
                }
                for (auto& t : threads) t.join();
            }
            log_phase("  S2: street ways (parallel)", _s2t);

            // Process interpolations: emit (cell_id, interp_id) pairs
            std::cerr << "  Processing " << data.deferred_interps.size() << " interpolation ways..." << std::endl;
            std::vector<std::vector<CellItem>> interp_pairs(num_threads);
            {
                std::atomic<size_t> interp_idx{0};
                std::vector<std::thread> threads;
                for (unsigned int t = 0; t < num_threads; t++) {
                    threads.emplace_back([&, t]() {
                        auto& local = interp_pairs[t];
                        std::vector<S2CellId> edge_cells;
                        std::vector<uint64_t> way_cells;
                        while (true) {
                            size_t i = interp_idx.fetch_add(1);
                            if (i >= data.deferred_interps.size()) break;
                            const auto& di = data.deferred_interps[i];
                            way_cells.clear();
                            for (uint8_t j = 0; j + 1 < di.node_count; j++) {
                                const auto& n1 = data.interp_nodes[di.node_offset + j];
                                const auto& n2 = data.interp_nodes[di.node_offset + j + 1];
                                cover_edge(n1.lat, n1.lng, n2.lat, n2.lng, edge_cells);
                                for (const auto& c : edge_cells) way_cells.push_back(c.id());
                            }
                            std::sort(way_cells.begin(), way_cells.end());
                            way_cells.erase(std::unique(way_cells.begin(), way_cells.end()), way_cells.end());
                            for (uint64_t cell_id : way_cells) {
                                local.push_back({cell_id, di.interp_id});
                            }
                        }
                    });
                }
                for (auto& t : threads) t.join();
            }
            log_phase("  S2: interp ways (parallel)", _s2t);

            // Merge thread-local pairs into single vectors, sort, build cell maps
            std::cerr << "  Sorting and grouping cell pairs..." << std::endl;
            // Concatenate + sort helper for flat CellItem pairs
            // Parallel sort each thread's pairs, then k-way merge directly into
            // hash map + sorted output. No intermediate merged vector needed.
            auto parallel_sort_and_build = [](
                std::vector<std::vector<CellItem>>& thread_pairs,
                std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map,
                std::vector<CellItemPair>& sorted_out
            ) {
                // Step 1: Convert + sort each thread's data in parallel
                size_t total = 0;
                for (auto& v : thread_pairs) total += v.size();

                std::vector<std::vector<CellItemPair>> chunks(thread_pairs.size());
                {
                    std::vector<std::thread> sort_threads;
                    for (size_t t = 0; t < thread_pairs.size(); t++) {
                        sort_threads.emplace_back([&, t]() {
                            auto& src = thread_pairs[t];
                            auto& dst = chunks[t];
                            dst.reserve(src.size());
                            for (auto& ci : src) dst.push_back({ci.cell_id, ci.item_id});
                            src.clear(); src.shrink_to_fit();
                            std::sort(dst.begin(), dst.end(), [](const CellItemPair& a, const CellItemPair& b) {
                                return a.cell_id < b.cell_id || (a.cell_id == b.cell_id && a.item_id < b.item_id);
                            });
                        });
                    }
                    for (auto& t : sort_threads) t.join();
                }

                // Step 2: Concatenate sorted chunks and merge using parallel tree.
                // Each chunk is already sorted. Record run boundaries, concatenate,
                // then merge adjacent runs pairwise in parallel at each level.
                auto cmp = [](const CellItemPair& a, const CellItemPair& b) {
                    return a.cell_id < b.cell_id || (a.cell_id == b.cell_id && a.item_id < b.item_id);
                };

                // Record run boundaries before concatenation
                std::vector<size_t> run_bounds = {0};
                sorted_out.clear();
                sorted_out.reserve(total);
                for (auto& chunk : chunks) {
                    sorted_out.insert(sorted_out.end(), chunk.begin(), chunk.end());
                    run_bounds.push_back(sorted_out.size());
                    chunk.clear(); chunk.shrink_to_fit();
                }

                // Parallel tree merge: at each level, merge adjacent run pairs in parallel.
                // Level 0: merge runs (0,1), (2,3), ... → N/2 runs
                // Level 1: merge runs (01,23), (45,67), ... → N/4 runs
                // ... until one sorted run remains.
                while (run_bounds.size() > 2) {
                    std::vector<size_t> new_bounds = {0};
                    std::vector<std::thread> merge_threads;
                    for (size_t i = 0; i + 2 < run_bounds.size(); i += 2) {
                        size_t left = run_bounds[i];
                        size_t mid = run_bounds[i + 1];
                        size_t right = run_bounds[i + 2];
                        merge_threads.emplace_back([&, left, mid, right] {
                            std::inplace_merge(sorted_out.begin() + left,
                                               sorted_out.begin() + mid,
                                               sorted_out.begin() + right, cmp);
                        });
                        new_bounds.push_back(right);
                    }
                    // If odd number of runs, carry the last one forward
                    if (run_bounds.size() % 2 == 0) {
                        new_bounds.push_back(run_bounds.back());
                    }
                    for (auto& t : merge_threads) t.join();
                    run_bounds = std::move(new_bounds);
                }

                // Skip building hash map — sorted_out is used directly for writing.
                // cell_map stays empty (only needed for cache/continent modes).
            };

            // Sort each thread's pairs in parallel, then k-way merge directly
            // into hash map + sorted output. No intermediate merged vector.
            auto f_ways = std::async(std::launch::async, [&] {
                parallel_sort_and_build(way_pairs, data.cell_to_ways, data.sorted_way_cells);
            });
            auto f_interps = std::async(std::launch::async, [&] {
                parallel_sort_and_build(interp_pairs, data.cell_to_interps, data.sorted_interp_cells);
            });
            f_ways.get();
            f_interps.get();
            log_phase("  S2: sort + group into cell maps", _s2t);

            // Free deferred work items
            data.deferred_ways.clear();
            data.deferred_ways.shrink_to_fit();
            data.deferred_interps.clear();
            data.deferred_interps.shrink_to_fit();

            std::cerr << "S2 cell computation complete." << std::endl;
        }

        // Resolve interpolation endpoints
        std::cerr << "Resolving interpolation endpoints..." << std::endl;
        resolve_interpolation_endpoints(data);

        // Deduplicate + convert to sorted pairs for fast writing
        log_phase("S2 cell computation", _pt);
        std::cerr << "Deduplicating + sorting for write..." << std::endl;
        {
            // Convert addr hash map to sorted pairs.
            // Sort cell IDs only (30M unique), then build pairs in sorted order.
            // Much faster than sorting all 160M pairs.
            auto f2 = std::async(std::launch::async, [&] {
                // Extract and sort unique cell IDs
                std::vector<uint64_t> sorted_cells;
                sorted_cells.reserve(data.cell_to_addrs.size());
                size_t total_pairs = 0;
                for (auto& [cell_id, ids] : data.cell_to_addrs) {
                    sorted_cells.push_back(cell_id);
                    total_pairs += ids.size();
                }
                std::sort(sorted_cells.begin(), sorted_cells.end());

                // Build sorted pairs by iterating sorted cell IDs
                std::vector<CellItemPair> pairs;
                pairs.reserve(total_pairs);
                for (uint64_t cell_id : sorted_cells) {
                    auto& ids = data.cell_to_addrs[cell_id];
                    std::sort(ids.begin(), ids.end()); // sort within cell
                    ids.erase(std::unique(ids.begin(), ids.end()), ids.end());
                    for (auto id : ids) pairs.push_back({cell_id, id});
                }
                data.sorted_addr_cells = std::move(pairs);
            });
            auto f4 = std::async(std::launch::async, [&]{ deduplicate(data.cell_to_admin); });
            f2.get(); f4.get();
        }

        // Save cache if requested
        if (!save_cache_path.empty()) {
            serialize_cache(data, save_cache_path);
        }
    }

    // --- Rebuild hash maps from sorted pairs if needed for continent filtering ---
    if (generate_continents || !save_cache_path.empty()) {
        auto rebuild_map = [](const std::vector<CellItemPair>& sorted,
                              std::unordered_map<uint64_t, std::vector<uint32_t>>& map) {
            if (sorted.empty() || !map.empty()) return;
            for (const auto& p : sorted) {
                map[p.cell_id].push_back(p.item_id);
            }
        };
        std::cerr << "Rebuilding cell maps for continent filtering..." << std::endl;
        auto f1 = std::async(std::launch::async, [&]{ rebuild_map(data.sorted_way_cells, data.cell_to_ways); });
        auto f2 = std::async(std::launch::async, [&]{ rebuild_map(data.sorted_interp_cells, data.cell_to_interps); });
        f1.get(); f2.get();
        log_phase("Rebuild cell maps", _pt);
    }

    // --- Write index files ---
    log_phase("Deduplication", _pt);
    std::cerr << "Writing index files to " << output_dir << "..." << std::endl;

    if (multi_output) {
        // Write all 3 index modes in parallel (they read shared data, write to separate dirs)
        auto wf1 = std::async(std::launch::async, [&]{ write_index(data, output_dir + "/full", IndexMode::Full); });
        auto wf2 = std::async(std::launch::async, [&]{ write_index(data, output_dir + "/no-addresses", IndexMode::NoAddresses); });
        auto wf3 = std::async(std::launch::async, [&]{ write_index(data, output_dir + "/admin-only", IndexMode::AdminOnly); });
        wf1.get(); wf2.get(); wf3.get();
    } else {
        write_index(data, output_dir, mode);
    }

    if (generate_continents) {
        // Process continents in parallel (each filters independently from shared data)
        std::vector<std::future<void>> continent_futures;
        std::mutex cerr_mutex;
        for (size_t ci = 0; ci < kContinentCount; ci++) {
            const auto& continent = kContinents[ci];
            continent_futures.push_back(std::async(std::launch::async, [&, continent]() {
                {
                    std::lock_guard<std::mutex> lock(cerr_mutex);
                    std::cerr << "Filtering for continent: " << continent.name << "..." << std::endl;
                }
                auto subset = filter_by_bbox(data, continent);
                std::string base = output_dir + "/" + continent.name;
                ensure_dir(base);
                if (multi_output) {
                    // Write modes in parallel within each continent
                    auto cf1 = std::async(std::launch::async, [&]{ write_index(subset, base + "/full", IndexMode::Full); });
                    auto cf2 = std::async(std::launch::async, [&]{ write_index(subset, base + "/no-addresses", IndexMode::NoAddresses); });
                    auto cf3 = std::async(std::launch::async, [&]{ write_index(subset, base + "/admin-only", IndexMode::AdminOnly); });
                    cf1.get(); cf2.get(); cf3.get();
                } else {
                    write_index(subset, base, mode);
                }
                {
                    std::lock_guard<std::mutex> lock(cerr_mutex);
                    std::cerr << "Done continent: " << continent.name << std::endl;
                }
            }));
        }
        for (auto& f : continent_futures) {
            f.get();
        }
    }

    log_phase("Index file writing", _pt);
    std::cerr << "Done." << std::endl;
    return 0;
}

-            std::cerr << "Simplification: error-bounded, per-level epsilon (15-500m)" << std::endl;

+            std::cerr << "Simplification: error-bounded, scale=" << kEpsilonScale

+                      << ", epsilons: L2=" << admin_epsilon_meters(2)

+                      << "m L4=" << admin_epsilon_meters(4)

+                      << "m L6=" << admin_epsilon_meters(6)

+                      << "m L8=" << admin_epsilon_meters(8) << "m" << std::endl;


Bash

cd /root/traccar-geocoder/builder/build && make -j$(nproc) 2>&1 | tail -3

Build


[ 50%] Building CXX object CMakeFiles/build-index.dir/src/s2_helpers.cpp.o
[ 62%] Linking CXX executable build-index
[100%] Built target build-index


Now let me test a few scale values to find the ~200 MiB compressed target. From our earlier data, the per-level defaults gave 354 MiB compressed. We need ~1.7x coarser to hit 200 MiB. Let me test scale factors:


scp /root/traccar-geocoder/builder/build/build-index node3:~/build-index-epsilon2 2>&1 && ssh node3 "
# Also rebuild the comparison tool with the new epsilon table
g++ -O3 -std=c++17 -pthread -o simplify_compare ~/simplify_compare.cpp 2>&1

# Test different scales using the fast C++ tool
for scale in 1.0 1.5 2.0 2.5 3.0; do
    echo -n \"scale \${scale}x: \"
    # Multiply default epsilons by scale and pass as fixed per-level
    python3 -c \"
defaults = [500,200,100,50,30,20,15]
scaled = [d*\${scale} for d in defaults]
# Use the comparison tool with custom fixed epsilons
# Just compute the vertex count for the average epsilon
avg = sum(scaled)/len(scaled)
print('L2={:.0f}m L4={:.0f}m L6={:.0f}m L8={:.0f}m'.format(scaled[0],scaled[2],scaled[4],scaled[6]))
\"
done
echo ''
echo 'Running actual builds at different scales...'
for scale in 1.5 2.0 2.5; do
    rm -rf /tmp/test-scale-\$scale && mkdir /tmp/test-scale-\$scale
    ~/build-index-epsilon2 /tmp/test-scale-\$scale ~/pbf/planet.osm.pbf --admin-only --max-admin-level 8 --simplify-epsilon --epsilon-scale \$scale 2>&1 | grep -E 'Simplif|admin_vert|strings|admin index'
    vsize=\$(stat --format='%s' /tmp/test-scale-\$scale/admin_vertices.bin 2>/dev/null || echo 0)
    total=\$(du -sb /tmp/test-scale-\$scale/ | cut -f1)
    cd /tmp/test-scale-\$scale && tar cf - *.bin | zstd -19 -T0 -q -f -o /tmp/scale-\$scale.tar.zst
    csize=\$(stat --format='%s' /tmp/scale-\$scale.tar.zst)
    echo \"  scale \${scale}: verts=\$(numfmt --to=iec-i \$vsize) total=\$(numfmt --to=iec-i \$total) compressed=\$(numfmt --to=iec-i \$csize)\"
    cd ~
done
" 2>&1

Test scale factors to find 200MB target


Command running in background with ID: bru8n5v1i. Output is being written to: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bru8n5v1i.output


for i in $(seq 1 40); do sleep 30; out=$(cat /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bru8n5v1i.output 2>/dev/null); if echo "$out" | grep -q "scale 2.5"; then echo "$out"; break; fi; latest=$(echo "$out" | grep "scale" | tail -1); echo "$((i*30))s: $latest"; done

Monitor scale tests


Command running in background with ID: b413ww4e3. Output is being written to: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b413ww4e3.output


sleep 30 && cat /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b413ww4e3.output 2>/dev/null | tail -5

Check monitor


510s:
540s:
570s:
600s:
630s:


The grep isn't matching. Let me check the raw output directly:


cat /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bru8n5v1i.output 2>/dev/null | tail -20

Check raw output


Warning: Permanently added '100.113.88.121' (ED25519) to the list of known hosts.
Permission denied, please try again.
Permission denied, please try again.
michtest@100.113.88.121: Permission denied (publickey,password).
scp: Connection closed


SSH mux dropped again. Let me reconnect and run directly:


rm -f /tmp/ssh-mux/node3 && sshpass -p 'elon-is-insane' ssh -M -o ServerAliveInterval=30 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ControlPath=/tmp/ssh-mux/node3 -o ControlPersist=4h -o ProxyCommand="tailscale nc %h %p" michtest@100.113.88.121 "sleep 99999" &
sleep 3 && scp /root/traccar-geocoder/builder/build/build-index node3:~/build-index-epsilon2 2>&1 && ssh node3 "echo connected && free -h | head -2" 2>&1

Reconnect and send binary


Warning: Permanently added '100.113.88.121' (ED25519) to the list of known hosts.
connected
               total        used        free      shared  buff/cache   available
Mem:           174Gi       2.3Gi       147Gi       353Mi        26Gi       172Gi


Good. Now run the scale tests one at a time to avoid any issues:


ssh node3 "
for scale in 1.5 2.0 2.5; do
    rm -rf /tmp/test-scale && mkdir /tmp/test-scale
    ~/build-index-epsilon2 /tmp/test-scale ~/pbf/planet.osm.pbf --admin-only --max-admin-level 8 --simplify-epsilon --epsilon-scale \$scale > /tmp/scale.log 2>&1
    grep 'Simplif' /tmp/scale.log
    total=\$(du -sb /tmp/test-scale/ | cut -f1)
    cd /tmp/test-scale && tar cf - *.bin | zstd -19 -T0 -q -f -o /tmp/scale-\$scale.tar.zst
    csize=\$(stat --format='%s' /tmp/scale-\$scale.tar.zst)
    echo \"  scale \${scale}x: uncomp=\$(numfmt --to=iec-i --suffix=B \$total) compressed=\$(numfmt --to=iec-i --suffix=B \$csize)\"
    echo ''
done
echo 'For reference:'
echo '  scale 1.0x: uncomp=555MiB compressed=354MiB (already tested)'
" 2>&1

Run scale tests sequentially


Command running in background with ID: bnry2i94z. Output is being written to: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bnry2i94z.output


for i in $(seq 1 50); do sleep 30; out=$(cat /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bnry2i94z.output 2>/dev/null); if echo "$out" | grep -q "reference"; then echo "$out"; break; fi; latest=$(echo "$out" | grep -E "scale|Simplif" | tail -1); echo "$((i*30))s: $latest"; done

Monitor until all 3 done


Command running in background with ID: bs7i2p39r. Output is being written to: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bs7i2p39r.output


sleep 120 && cat /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bnry2i94z.output 2>/dev/null

Check after 2 min


(Bash completed with no output)


ssh node3 "ps -o etime,pcpu,args -C build-index-ep --no-headers 2>/dev/null || echo 'not running'; cat /tmp/scale.log 2>/dev/null | tail -3" 2>&1

Check status directly


not running
admin index: 2031867 cells, 427587 polygons
  [0.5s] Index file writing
Done.


First scale (1.5x) is done and compressing. Let me wait for all three:


sleep 30 && ssh node3 "ps -o etime,pcpu -C build-index-ep --no-headers 2>/dev/null || echo 'not running'; ls -lh /tmp/scale-*.tar.zst 2>/dev/null" 2>&1

Check progress


not running
-rw-rw-r-- 1 michtest michtest 294M Mar 23 14:50 /tmp/scale-1.5.tar.zst


1.5x = 294 MiB. Now running 2.0x:


sleep 30 && ssh node3 "ps -o etime -C build-index-ep --no-headers 2>/dev/null || echo 'between runs'; ls -lh /tmp/scale-*.tar.zst 2>/dev/null" 2>&1

Check after 30s


between runs
-rw-rw-r-- 1 michtest michtest 294M Mar 23 14:50 /tmp/scale-1.5.tar.zst


sleep 30 && ssh node3 "ps -o etime -C build-index-ep --no-headers 2>/dev/null || echo 'between runs'; ls -lh /tmp/scale-*.tar.zst 2>/dev/null" 2>&1

Check after 30s


Hmm, the background SSH session may have died between runs. Let me check the output:


Check background task output


Simplification: error-bounded, scale=1.5, epsilons: L2=750m L4=150m L6=45m L8=22.5m
  scale 1.5x: uncomp=459MiB compressed=294MiB


Only the first one completed — the SSH session timed out waiting for the second build. Let me run the remaining two directly:


ssh node3 "rm -rf /tmp/test-scale && mkdir /tmp/test-scale && ~/build-index-epsilon2 /tmp/test-scale ~/pbf/planet.osm.pbf --admin-only --max-admin-level 8 --simplify-epsilon --epsilon-scale 2.0 > /tmp/scale.log 2>&1 && cd /tmp/test-scale && tar cf - *.bin | zstd -19 -T0 -q -f -o /tmp/scale-2.0.tar.zst && echo DONE_2.0 && ls -lh /tmp/scale-2.0.tar.zst && du -sh /tmp/test-scale/" 2>&1

Run scale 2.0x


Command running in background with ID: blc5hlmbi. Output is being written to: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/blc5hlmbi.output


for i in $(seq 1 30); do sleep 30; out=$(cat /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/blc5hlmbi.output 2>/dev/null); if echo "$out" | grep -q "DONE"; then echo "$out"; break; fi; echo "$((i*30))s: waiting..."; done

Monitor scale 2.0


Command running in background with ID: bbs4u36vx. Output is being written to: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bbs4u36vx.output


sleep 300 && cat /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/blc5hlmbi.output 2>/dev/null

Check after 5 min


bash: line 1: 46913 Killed                  ~/build-index-epsilon2 /tmp/test-scale ~/pbf/planet.osm.pbf --admin-only --max-admin-level 8 --simplify-epsilon --epsilon-scale 2.0 > /tmp/scale.log 2>&1


OOM killed again. The previous scale 1.5 build left residual memory. Let me wait and try with a clean state:


sleep 30 && ssh node3 "free -h | head -2" 2>&1

Check memory


               total        used        free      shared  buff/cache   available
Mem:           174Gi        28Gi       121Gi       358Mi        26Gi       146Gi


121 GiB free — should be fine now. Let me run 2.0x and 2.5x:


ssh node3 "
rm -rf /tmp/test-scale && mkdir /tmp/test-scale
time ~/build-index-epsilon2 /tmp/test-scale ~/pbf/planet.osm.pbf --admin-only --max-admin-level 8 --simplify-epsilon --epsilon-scale 2.0 2>&1 | grep -E 'Simplif|Done'
total=\$(du -sb /tmp/test-scale/ | cut -f1)
cd /tmp/test-scale && tar cf - *.bin | zstd -19 -T0 -q -f -o /tmp/scale-2.0.tar.zst
csize=\$(stat --format='%s' /tmp/scale-2.0.tar.zst)
echo \"2.0x: uncomp=\$(numfmt --to=iec-i --suffix=B \$total) compressed=\$(numfmt --to=iec-i --suffix=B \$csize)\"
" 2>&1

Run scale 2.0x


Command running in background with ID: bpgnpc8ib. Output is being written to: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bpgnpc8ib.output


for i in $(seq 1 30); do sleep 30; out=$(cat /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bpgnpc8ib.output 2>/dev/null); if echo "$out" | grep -q "2.0x:"; then echo "$out"; break; fi; echo "$((i*30))s"; done

Monitor 2.0x


Command running in background with ID: b33i1zvaq. Output is being written to: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b33i1zvaq.output


sleep 600 && cat /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bpgnpc8ib.output 2>/dev/null

Check after 10 min


Simplification: error-bounded, scale=2, epsilons: L2=1000m L4=200m L6=60m L8=30m
Done reading:
Done.

real	12m20.122s
user	144m20.911s
sys	17m46.146s
2.0x: uncomp=399MiB compressed=256MiB


2.0x = **256 MiB compressed**. Now 2.5x:


ssh node3 "
rm -rf /tmp/test-scale && mkdir /tmp/test-scale
time ~/build-index-epsilon2 /tmp/test-scale ~/pbf/planet.osm.pbf --admin-only --max-admin-level 8 --simplify-epsilon --epsilon-scale 2.5 2>&1 | grep -E 'Simplif|Done'
total=\$(du -sb /tmp/test-scale/ | cut -f1)
cd /tmp/test-scale && tar cf - *.bin | zstd -19 -T0 -q -f -o /tmp/scale-2.5.tar.zst
csize=\$(stat --format='%s' /tmp/scale-2.5.tar.zst)
echo \"2.5x: uncomp=\$(numfmt --to=iec-i --suffix=B \$total) compressed=\$(numfmt --to=iec-i --suffix=B \$csize)\"
" 2>&1

Run scale 2.5x


Command running in background with ID: b9p6jvhc3. Output is being written to: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b9p6jvhc3.output


sleep 600 && cat /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b9p6jvhc3.output 2>/dev/null

Check after 10 min


(Bash completed with no output)


sleep 120 && cat /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b9p6jvhc3.output 2>/dev/null

Check after 2 more min


Simplification: error-bounded, scale=2.5, epsilons: L2=1250m L4=250m L6=75m L8=37.5m
Done reading:
Done.

real	12m2.909s
user	142m56.753s
sys	17m26.959s


sleep 30 && cat /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b9p6jvhc3.output 2>/dev/null

Check for final size


Simplification: error-bounded, scale=2.5, epsilons: L2=1250m L4=250m L6=75m L8=37.5m
Done reading:
Done.

real	12m2.909s
user	142m56.753s
sys	17m26.959s
2.5x: uncomp=359MiB compressed=228MiB


Got all three. Here's the full table:

| Scale | L2 (country) | L4 (state) | L6 (county) | L8 (city) | Uncompressed | **Compressed** |
|---|---|---|---|---|---|---|
| 1.0x (default) | 500m | 100m | 30m | 15m | 555 MiB | **354 MiB** |
| 1.5x | 750m | 150m | 45m | 22m | 459 MiB | **294 MiB** |
| 2.0x | 1000m | 200m | 60m | 30m | 399 MiB | **256 MiB** |
| 2.5x | 1250m | 250m | 75m | 38m | 359 MiB | **228 MiB** |
| max 500 (old) | — | — | — | — | 883 MiB | **509 MiB** |

For ~200 MiB you'd need about **3.0x** scale (L8=45m accuracy). Usage:

```
build-index output/ planet.osm.pbf --admin-only --max-admin-level 8 --simplify-epsilon --epsilon-scale 3.0
```

Or you can set exact per-level values:
```
build-index output/ planet.osm.pbf --admin-only --max-admin-level 8 --simplify-epsilon --epsilon-levels 1500,600,300,150,100,60,45
```

Let me commit:


cd /root/traccar-geocoder && git add builder/src/geometry.h builder/src/build_index.cpp && git commit -m "$(cat <<'EOF'
feat: configurable epsilon levels (--epsilon-scale, --epsilon-levels)

Add CLI options to tune error-bounded simplification:
  --epsilon-scale F       Multiply all per-level epsilons by F
  --epsilon-levels L2,...,L8  Set exact epsilon in meters per level

Scale results (admin-only L2-8, planet, compressed):
  1.0x: 354 MiB (L8=15m)
  1.5x: 294 MiB (L8=22m)
  2.0x: 256 MiB (L8=30m)
  2.5x: 228 MiB (L8=38m)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)" && git push origin main 2>&1

Commit and push


ok
To github.com:zp-forks/traccar-geocoder.git
06c7804..6b271b3  main -> main

[main 6b271b3] feat: configurable epsilon levels (--epsilon-scale, --epsilon-levels)
 2 files changed, 51 insertions(+), 15 deletions(-)
To github.com:zp-forks/traccar-geocoder.git
   06c7804..6b271b3  main -> main


Committed and pushed. To hit your ~200 MiB target, use `--epsilon-scale 3.0` which would give L8 cities ~45m accuracy. Or use `--epsilon-levels` to set exact values per level if you want finer control — e.g. keep cities at 30m but be more aggressive on countries.


<task-notification>
<task-id>bru8n5v1i</task-id>
<tool-use-id>toolu_01R2Wti2bPP7STbNfibwtJcH</tool-use-id>
<output-file>/tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bru8n5v1i.output</output-file>
<status>failed</status>
<summary>Background command "Test scale factors to find 200MB target" failed with exit code 255</summary>
</task-notification>
Read the output file to retrieve the result: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bru8n5v1i.output


<task-notification>
<task-id>b413ww4e3</task-id>
<tool-use-id>toolu_01J8gkqiHw2z3Kgv62Po1r6S</tool-use-id>
<output-file>/tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b413ww4e3.output</output-file>
<status>completed</status>
<summary>Background command "Monitor scale tests" completed (exit code 0)</summary>
</task-notification>
Read the output file to retrieve the result: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b413ww4e3.output


<task-notification>
<task-id>blc5hlmbi</task-id>
<tool-use-id>toolu_01Bxqtp1PpiKL45fGjkg64nC</tool-use-id>
<output-file>/tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/blc5hlmbi.output</output-file>
<status>failed</status>
<summary>Background command "Run scale 2.0x" failed with exit code 137</summary>
</task-notification>
Read the output file to retrieve the result: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/blc5hlmbi.output


<task-notification>
<task-id>bs7i2p39r</task-id>
<tool-use-id>toolu_01EnhpvbzVEohe46erzt3hhE</tool-use-id>
<output-file>/tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bs7i2p39r.output</output-file>
<status>completed</status>
<summary>Background command "Monitor until all 3 done" completed (exit code 0)</summary>
</task-notification>
Read the output file to retrieve the result: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bs7i2p39r.output


<task-notification>
<task-id>bbs4u36vx</task-id>
<tool-use-id>toolu_01UnY8V15S5Dq1b84h3KKuB1</tool-use-id>
<output-file>/tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bbs4u36vx.output</output-file>
<status>completed</status>
<summary>Background command "Monitor scale 2.0" completed (exit code 0)</summary>
</task-notification>
Read the output file to retrieve the result: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bbs4u36vx.output


<task-notification>
<task-id>bnry2i94z</task-id>
<tool-use-id>toolu_01NubaFtVH1sM9XQ8oJuiqeA</tool-use-id>
<output-file>/tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bnry2i94z.output</output-file>
<status>completed</status>
<summary>Background command "Run scale tests sequentially" completed (exit code 0)</summary>
</task-notification>
Read the output file to retrieve the result: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bnry2i94z.output


<task-notification>
<task-id>bpgnpc8ib</task-id>
<tool-use-id>toolu_0176Mq5j3TfAC6DLnVLjb41x</tool-use-id>
<output-file>/tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bpgnpc8ib.output</output-file>
<status>completed</status>
<summary>Background command "Run scale 2.0x" completed (exit code 0)</summary>
</task-notification>
Read the output file to retrieve the result: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/bpgnpc8ib.output


<task-notification>
<task-id>b33i1zvaq</task-id>
<tool-use-id>toolu_01JrYdR2M5TYAMaRr8niiaBR</tool-use-id>
<output-file>/tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b33i1zvaq.output</output-file>
<status>completed</status>
<summary>Background command "Monitor 2.0x" completed (exit code 0)</summary>
</task-notification>
Read the output file to retrieve the result: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b33i1zvaq.output


<task-notification>
<task-id>b9p6jvhc3</task-id>
<tool-use-id>toolu_01GSszZKSaDf5rxBhJmWZ945</tool-use-id>
<output-file>/tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b9p6jvhc3.output</output-file>
<status>completed</status>
<summary>Background command "Run scale 2.5x" completed (exit code 0)</summary>
</task-notification>
Read the output file to retrieve the result: /tmp/claude-0/-root-traccar-geocoder/af1c4baa-0f9a-4420-a8b3-ce20dc28c8b9/tasks/b9p6jvhc3.output


All already handled — the successful runs gave us the scale comparison table (1.5x=294M, 2.0x=256M, 2.5x=228M). The failed ones were SSH drops and the OOM incident from zombie processes. Everything committed and pushed.


Can you overlay these onto the map so we can see what they look like?


ssh node3 "python3 << 'PYEOF'
import struct, json, math

admin_data = open('/home/michtest/test-cities-compact/admin_polygons.bin', 'rb').read()
verts_data = open('/home/michtest/test-cities-compact/admin_vertices.bin', 'rb').read()
strings_data = open('/home/michtest/test-cities-compact/strings.bin', 'rb').read()

fmt = '<IH2xIB3xfH2x'
poly_count = len(admin_data) // 24

def get_str(off):
    if off >= len(strings_data): return '???'
    end = strings_data.index(b'\\x00', off)
    return strings_data[off:end].decode('utf-8', errors='replace')

def get_verts(vo, vc):
    out = []
    for j in range(vc):
        lat, lng = struct.unpack_from('<ff', verts_data, (vo+j)*8)
        out.append((float(lat), float(lng)))
    return out

targets = [
    ('France', 2, 47.0, 2.0, 5.0),
    ('Deutschland', 2, 51.0, 10.0, 5.0),
    ('Italia', 2, 42.0, 12.0, 5.0),
    ('Bayern', 4, 48.5, 11.5, 3.0),
    ('Nordrhein-Westfalen', 4, 51.5, 7.5, 3.0),
    ('Berlin', 4, 52.5, 13.4, 1.0),
    ('Greater London', 5, 51.5, -0.1, 2.0),
    ('Roma Capitale', 6, 41.9, 12.5, 2.0),
    ('München', 6, 48.1, 11.5, 0.5),
    ('Paris', 7, 48.85, 2.35, 0.3),
    ('Amsterdam', 8, 52.3, 4.9, 0.5),
    ('Madrid', 8, 40.4, -3.7, 0.5),
]

found = []
for tname, tlevel, tlat, tlng, max_dist in targets:
    best_i = None; best_vc = 0
    for i in range(poly_count):
        vo, vc, name_id, level, area, cc = struct.unpack_from(fmt, admin_data, i*24)
        if level != tlevel: continue
        if get_str(name_id) != tname: continue
        pts = get_verts(vo, vc)
        clat = sum(p[0] for p in pts)/len(pts)
        clng = sum(p[1] for p in pts)/len(pts)
        dist = ((clat-tlat)**2 + (clng-tlng)**2)**0.5
        if dist < max_dist and vc > best_vc:
            best_vc = vc; best_i = i
    if best_i is not None:
        vo, vc, name_id, level, area, cc = struct.unpack_from(fmt, admin_data, best_i*24)
        found.append({'name': get_str(name_id), 'level': level, 'verts': get_verts(vo, vc)})

def dp_dist(p, a, b):
    dx, dy = b[0]-a[0], b[1]-a[1]
    lsq = dx*dx+dy*dy
    if lsq == 0: return math.sqrt((p[0]-a[0])**2+(p[1]-a[1])**2)
    t = max(0, min(1, ((p[0]-a[0])*dx+(p[1]-a[1])*dy)/lsq))
    return math.sqrt((a[0]+t*dx-p[0])**2 + (a[1]+t*dy-p[1])**2)

def dp_simplify_eps(pts, eps):
    if len(pts) <= 4: return pts
    keep = [False]*len(pts); keep[0]=keep[-1]=True
    stk = [(0, len(pts)-1)]
    while stk:
        s,e = stk.pop()
        if e<=s+1: continue
        md, mi = 0, s
        for i in range(s+1,e):
            d = dp_dist(pts[i], pts[s], pts[e])
            if d>md: md,mi=d,i
        if md>eps: keep[mi]=True; stk.append((s,mi)); stk.append((mi,e))
    return [p for p,k in zip(pts,keep) if k]

def meters_to_deg(m, lat):
    c = math.cos(lat * math.pi / 180.0)
    if c < 0.01: c = 0.01
    return m / (111320.0 * c)

defaults = {2:500, 3:200, 4:100, 5:50, 6:30, 7:20, 8:15}

# Modes to show: baseline (max500), then scales 1.0x, 1.5x, 2.0x, 2.5x
scales = [
    ('1.0x (354MB)', 1.0, '#e41a1c'),
    ('1.5x (294MB)', 1.5, '#0066ff'),
    ('2.0x (256MB)', 2.0, '#00cc44'),
    ('2.5x (228MB)', 2.5, '#ff8800'),
]

features = []
for city in found:
    pts = city['verts']
    level = city['level']
    label = '{} (L{})'.format(city['name'], level)
    base_eps = defaults.get(level, 15)

    for sname, scale, color in scales:
        eps_m = base_eps * scale
        eps_d = meters_to_deg(eps_m, pts[0][0])
        sp = dp_simplify_eps(pts, eps_d)
        coords = [[p[1], p[0]] for p in sp]
        if coords[0] != coords[-1]: coords.append(coords[0])
        features.append({
            'type': 'Feature',
            'properties': {
                'name': label, 'level': level,
                'mode': sname,
                'mode_detail': '{} (L{} = {:.0f}m)'.format(sname, level, eps_m),
                'actual_verts': len(sp),
                'color': color
            },
            'geometry': {'type': 'Polygon', 'coordinates': [coords]}
        })

geojson = {'type': 'FeatureCollection', 'features': features}
with open('/home/michtest/test-cities-compact/comparison.geojson', 'w') as f:
    json.dump(geojson, f)
print('Wrote {} features for {} cities'.format(len(features), len(found)))
PYEOF" 2>&1

Generate GeoJSON with scale comparisons


Wrote 48 features for 12 cities


ssh node3 "python3 -c \"
html = open('/home/michtest/simplification_map.html').read()
geojson = open('/home/michtest/test-cities-compact/comparison.geojson').read()
html = html.replace('GEOJSON_PLACEHOLDER', geojson)
open('/home/michtest/simplification_viewer.html', 'w').write(html)
print(len(html))
\"" 2>&1 && scp node3:~/simplification_viewer.html /root/traccar-geocoder/builder/tools/ 2>&1 && echo "Updated"

Rebuild viewer


730725
Updated


Refresh **http://100.68.107.4:8080/simplification_viewer.html**

Now shows the 4 scale levels that correspond to our actual tested compressed sizes:

- **Red** = 1.0x scale (354 MiB compressed) — default per-level epsilon
- **Blue** = 1.5x scale (294 MiB compressed)
- **Green** = 2.0x scale (256 MiB compressed)
- **Orange** = 2.5x scale (228 MiB compressed)

Hover over any line to see the exact epsilon used for that polygon's level (e.g. "2.0x (256MB) (L8 = 30m)" for a city at 2x scale). Toggle layers to compare — the difference is most visible on complex coastline boundaries like Italia, Greater London, and Roma Capitale.


What are the physical distances used for the different scales?


Here are the per-level epsilons (max boundary displacement) at each scale:

| Level | Description | 1.0x (354MB) | 1.5x (294MB) | 2.0x (256MB) | 2.5x (228MB) |
|---|---|---|---|---|---|
| L2 | Country | 500m | 750m | 1000m | 1250m |
| L3 | Large region | 200m | 300m | 400m | 500m |
| L4 | State/province | 100m | 150m | 200m | 250m |
| L5 | District | 50m | 75m | 100m | 125m |
| L6 | County | 30m | 45m | 60m | 75m |
| L7 | Municipality | 20m | 30m | 40m | 50m |
| L8 | City/town | 15m | 22m | 30m | 38m |

These are the **worst case** — the maximum any simplified boundary point can deviate from the true OSM boundary. Most points along the boundary will be much closer. For reverse geocoding, this means a query point within that distance of a boundary *could* return a different result than the exact boundary — but in practice the error rate is much lower since DP preserves the shape's critical points.


Can you include the baseline version again too for comparison? Would it maybe even be possible to see what a version with no caps at all looked like, and what the file sizes were like for that?


ssh node3 "python3 << 'PYEOF'
import struct, json, math

admin_data = open('/home/michtest/test-cities-compact/admin_polygons.bin', 'rb').read()
verts_data = open('/home/michtest/test-cities-compact/admin_vertices.bin', 'rb').read()
strings_data = open('/home/michtest/test-cities-compact/strings.bin', 'rb').read()

fmt = '<IH2xIB3xfH2x'
poly_count = len(admin_data) // 24

def get_str(off):
    if off >= len(strings_data): return '???'
    end = strings_data.index(b'\\x00', off)
    return strings_data[off:end].decode('utf-8', errors='replace')

def get_verts(vo, vc):
    out = []
    for j in range(vc):
        lat, lng = struct.unpack_from('<ff', verts_data, (vo+j)*8)
        out.append((float(lat), float(lng)))
    return out

# First: check how many polygons are at the 500 cap (i.e. were simplified)
at_cap = 0
total_verts = 0
for i in range(poly_count):
    vo, vc, name_id, level, area, cc = struct.unpack_from(fmt, admin_data, i*24)
    total_verts += vc
    if vc >= 498:  # near cap
        at_cap += 1

print('Polygons at/near 500 cap: {:,} ({:.1f}%)'.format(at_cap, at_cap*100/poly_count))
print('Current total vertices: {:,}'.format(total_verts))
print()

# The current data IS already capped at 500. We can not recover the original uncapped
# vertices from this data. But we CAN check the baseline old-code index which used
# the same cap. The truly uncapped version would need a fresh build.
# Let's check what the old baseline looks like for comparison.
print('To get uncapped data, we need a build with --simplify-epsilon with a very small epsilon')
print('(effectively no simplification), or a code change to skip simplification entirely.')
PYEOF" 2>&1

Check how many polygons hit the 500 cap


Polygons at/near 500 cap: 73,345 (16.0%)
Current total vertices: 105,413,344

To get uncapped data, we need a build with --simplify-epsilon with a very small epsilon
(effectively no simplification), or a code change to skip simplification entirely.