  3094→                }
  3095→                for (auto& t : threads) t.join();
  3096→            }
  3097→            log_phase("  S2: interp ways (parallel)", _s2t);
  3098→
  3099→            // Merge thread-local pairs into single vectors, sort, build cell maps
  3100→            std::cerr << "  Sorting and grouping cell pairs..." << std::endl;
  3101→            // Concatenate + sort helper for flat CellItem pairs
  3102→            // Parallel sort each thread's pairs, then k-way merge directly into
  3103→            // hash map + sorted output. No intermediate merged vector needed.
  3104→            auto parallel_sort_and_build = [](
  3105→                std::vector<std::vector<CellItem>>& thread_pairs,
  3106→                std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map,
  3107→                std::vector<CellItemPair>& sorted_out
  3108→            ) {
  3109→                // Step 1: Convert + sort each thread's data in parallel
  3110→                size_t total = 0;
  3111→                for (auto& v : thread_pairs) total += v.size();
  3112→
  3113→                std::vector<std::vector<CellItemPair>> chunks(thread_pairs.size());
  3114→                {
  3115→                    std::vector<std::thread> sort_threads;
  3116→                    for (size_t t = 0; t < thread_pairs.size(); t++) {
  3117→                        sort_threads.emplace_back([&, t]() {
  3118→                            auto& src = thread_pairs[t];
  3119→                            auto& dst = chunks[t];
  3120→                            dst.reserve(src.size());
  3121→                            for (auto& ci : src) dst.push_back({ci.cell_id, ci.item_id});
  3122→                            src.clear(); src.shrink_to_fit();
  3123→                            std::sort(dst.begin(), dst.end(), [](const CellItemPair& a, const CellItemPair& b) {
  3124→                                return a.cell_id < b.cell_id || (a.cell_id == b.cell_id && a.item_id < b.item_id);
  3125→                            });
  3126→                        });
  3127→                    }
  3128→                    for (auto& t : sort_threads) t.join();
  3129→                }
  3130→
  3131→                // Step 2: Concatenate sorted chunks and merge using parallel tree.
  3132→                // Each chunk is already sorted. Record run boundaries, concatenate,
  3133→                // then merge adjacent runs pairwise in parallel at each level.
  3134→                auto cmp = [](const CellItemPair& a, const CellItemPair& b) {
  3135→                    return a.cell_id < b.cell_id || (a.cell_id == b.cell_id && a.item_id < b.item_id);
  3136→                };
  3137→
  3138→                // Record run boundaries before concatenation
  3139→                std::vector<size_t> run_bounds = {0};
  3140→                sorted_out.clear();
  3141→                sorted_out.reserve(total);
  3142→                for (auto& chunk : chunks) {
  3143→                    sorted_out.insert(sorted_out.end(), chunk.begin(), chunk.end());
  3144→                    run_bounds.push_back(sorted_out.size());
  3145→                    chunk.clear(); chunk.shrink_to_fit();
  3146→                }
  3147→
  3148→                // Parallel tree merge: at each level, merge adjacent run pairs in parallel.
  3149→                // Level 0: merge runs (0,1), (2,3), ... → N/2 runs
  3150→                // Level 1: merge runs (01,23), (45,67), ... → N/4 runs
  3151→                // ... until one sorted run remains.
  3152→                while (run_bounds.size() > 2) {
  3153→                    std::vector<size_t> new_bounds = {0};
  3154→                    std::vector<std::thread> merge_threads;
  3155→                    for (size_t i = 0; i + 2 < run_bounds.size(); i += 2) {
  3156→                        size_t left = run_bounds[i];
  3157→                        size_t mid = run_bounds[i + 1];
  3158→                        size_t right = run_bounds[i + 2];
  3159→                        merge_threads.emplace_back([&, left, mid, right] {
  3160→                            std::inplace_merge(sorted_out.begin() + left,
  3161→                                               sorted_out.begin() + mid,
  3162→                                               sorted_out.begin() + right, cmp);
  3163→                        });
  3164→                        new_bounds.push_back(right);
  3165→                    }
  3166→                    // If odd number of runs, carry the last one forward
  3167→                    if (run_bounds.size() % 2 == 0) {
  3168→                        new_bounds.push_back(run_bounds.back());
  3169→                    }
  3170→                    for (auto& t : merge_threads) t.join();
  3171→                    run_bounds = std::move(new_bounds);
  3172→                }
  3173→
  3174→                // Skip building hash map — sorted_out is used directly for writing.
  3175→                // cell_map stays empty (only needed for cache/continent modes).
  3176→            };
  3177→
  3178→
  3179→            // Sort each thread's pairs in parallel, then k-way merge directly
  3180→            // into hash map + sorted output. No intermediate merged vector.
  3181→            auto f_ways = std::async(std::launch::async, [&] {
  3182→                parallel_sort_and_build(way_pairs, data.cell_to_ways, data.sorted_way_cells);
  3183→            });
  3184→            auto f_interps = std::async(std::launch::async, [&] {
  3185→                std::vector<CellItemPair> interp_sorted; // not needed for writing
  3186→                parallel_sort_and_build(interp_pairs, data.cell_to_interps, interp_sorted);
  3187→            });
  3188→            f_ways.get();
  3189→            f_interps.get();
  3190→            log_phase("  S2: sort + group into cell maps", _s2t);
  3191→
  3192→            // Free deferred work items
  3193→            data.deferred_ways.clear();
  3194→            data.deferred_ways.shrink_to_fit();
  3195→            data.deferred_interps.clear();
  3196→            data.deferred_interps.shrink_to_fit();
  3197→
  3198→            std::cerr << "S2 cell computation complete." << std::endl;
  3199→        }
  3200→
  3201→        // Resolve interpolation endpoints
  3202→        std::cerr << "Resolving interpolation endpoints..." << std::endl;
  3203→        resolve_interpolation_endpoints(data);
  3204→
  3205→        // Deduplicate + convert to sorted pairs for fast writing
  3206→        log_phase("S2 cell computation", _pt);
  3207→        std::cerr << "Deduplicating + sorting for write..." << std::endl;
  3208→        {
  3209→            // Convert addr hash map to sorted pairs.
  3210→            // Sort cell IDs only (30M unique), then build pairs in sorted order.
  3211→            // Much faster than sorting all 160M pairs.
  3212→            auto f2 = std::async(std::launch::async, [&] {
  3213→                // Extract and sort unique cell IDs
  3214→                std::vector<uint64_t> sorted_cells;
  3215→                sorted_cells.reserve(data.cell_to_addrs.size());
  3216→                size_t total_pairs = 0;
  3217→                for (auto& [cell_id, ids] : data.cell_to_addrs) {
  3218→                    sorted_cells.push_back(cell_id);
  3219→                    total_pairs += ids.size();
  3220→                }
  3221→                std::sort(sorted_cells.begin(), sorted_cells.end());
  3222→
  3223→                // Build sorted pairs by iterating sorted cell IDs
  3224→                std::vector<CellItemPair> pairs;
  3225→                pairs.reserve(total_pairs);
  3226→                for (uint64_t cell_id : sorted_cells) {
  3227→                    auto& ids = data.cell_to_addrs[cell_id];
  3228→                    std::sort(ids.begin(), ids.end()); // sort within cell
  3229→                    ids.erase(std::unique(ids.begin(), ids.end()), ids.end());
  3230→                    for (auto id : ids) pairs.push_back({cell_id, id});
  3231→                }
  3232→                data.sorted_addr_cells = std::move(pairs);
  3233→            });
  3234→            auto f4 = std::async(std::launch::async, [&]{ deduplicate(data.cell_to_admin); });
  3235→            f2.get(); f4.get();
  3236→        }
  3237→
  3238→        // Save cache if requested
  3239→        if (!save_cache_path.empty()) {
  3240→            serialize_cache(data, save_cache_path);
  3241→        }
  3242→    }
  3243→
  3244→    // --- Write index files ---
  3245→    log_phase("Deduplication", _pt);
  3246→    std::cerr << "Writing index files to " << output_dir << "..." << std::endl;
  3247→
  3248→    if (multi_output) {
  3249→        // Write all 3 index modes in parallel (they read shared data, write to separate dirs)
  3250→        auto wf1 = std::async(std::launch::async, [&]{ write_index(data, output_dir + "/full", IndexMode::Full); });
  3251→        auto wf2 = std::async(std::launch::async, [&]{ write_index(data, output_dir + "/no-addresses", IndexMode::NoAddresses); });
  3252→        auto wf3 = std::async(std::launch::async, [&]{ write_index(data, output_dir + "/admin-only", IndexMode::AdminOnly); });
  3253→        wf1.get(); wf2.get(); wf3.get();
  3254→    } else {
  3255→        write_index(data, output_dir, mode);
  3256→    }
  3257→
  3258→    if (generate_continents) {
  3259→        // Process continents in parallel (each filters independently from shared data)
  3260→        std::vector<std::future<void>> continent_futures;
  3261→        std::mutex cerr_mutex;
  3262→        for (const auto& continent : kContinents) {
  3263→            continent_futures.push_back(std::async(std::launch::async, [&, continent]() {
  3264→                {
  3265→                    std::lock_guard<std::mutex> lock(cerr_mutex);
  3266→                    std::cerr << "Filtering for continent: " << continent.name << "..." << std::endl;
  3267→                }
  3268→                auto subset = filter_by_bbox(data, continent);
  3269→                std::string base = output_dir + "/" + continent.name;
  3270→                ensure_dir(base);
  3271→                if (multi_output) {
  3272→                    // Write modes in parallel within each continent
  3273→                    auto cf1 = std::async(std::launch::async, [&]{ write_index(subset, base + "/full", IndexMode::Full); });
  3274→                    auto cf2 = std::async(std::launch::async, [&]{ write_index(subset, base + "/no-addresses", IndexMode::NoAddresses); });
  3275→                    auto cf3 = std::async(std::launch::async, [&]{ write_index(subset, base + "/admin-only", IndexMode::AdminOnly); });
  3276→                    cf1.get(); cf2.get(); cf3.get();
  3277→                } else {
  3278→                    write_index(subset, base, mode);
  3279→                }
  3280→                {
  3281→                    std::lock_guard<std::mutex> lock(cerr_mutex);
  3282→                    std::cerr << "Done continent: " << continent.name << std::endl;
  3283→                }
  3284→            }));
  3285→        }
  3286→        for (auto& f : continent_futures) {
  3287→            f.get();
  3288→        }
  3289→    }
  3290→
  3291→    log_phase("Index file writing", _pt);
  3292→    std::cerr << "Done." << std::endl;
  3293→    return 0;
  3294→}
  3295→


Now let me also check the comparison tool and the validation report.


     1→# Parallel Admin Assembly Validation Report
     2→
     3→**Date**: 2026-03-22
     4→**Baseline**: commit 93fe6e4 (pre-parallel, sequential osmium assembler)
     5→**Parallel**: commit a80d4e9 (all fixes, greedy+backtracking hybrid stitcher)
     6→
     7→## Planet (86GB PBF) — Global Comparison
     8→
     9→| Component | Baseline | Parallel | Status |
    10→|---|---|---|---|
    11→| Street ways | 47,826,371 | 47,826,371 | **100% PASS** |
    12→| Address points | 160,091,817 | 160,091,817 | **100% PASS** |
    13→| Interpolation ways | 72,817 | 72,817 | ~100% |
    14→| Admin polygons | 943,638 | 947,358 | — |
    15→| Admin (unique name\|level) | 642,275 | 644,809 | **10 missing** (0.0016%), 2,544 extras |
    16→| Build time | ~5 hours | ~40 minutes | **~7.5x faster** |
    17→
    18→### 10 Missing Admin Boundaries (of 642,275 = 99.998%)
    19→
    20→All level 8-10 small municipality subdivisions:
    21→- Argentina: Alto Los Cardales|8, Pringles|9, COVIMER 2|10, FOPROVI|10, Antonio Palacios|10, Tres Esquinas|9
    22→- Peru: ATE II|10
    23→- Brazil: Jardim Mônaco (proposto)|10, Residencial Melville|10
    24→- Indonesia: RT 002 RW 015 Kel. Mekarjaya|10
    25→
    26→Root cause: greedy ring stitcher makes wrong branch choices at junctions
    27→where multiple admin areas share boundary ways. Backtracking would fix
    28→these but causes planet-scale stalls (exponential search) for complex
    29→relations with >30 sub-ways. Matching osmium exactly would require
    30→reimplementing its full segment graph assembler (~1200 lines).
    31→
    32→**Reverse geocoding impact**: Zero. These 10 areas are small subdivisions
    33→where the parent admin level (city/district) is still correctly returned.
    34→A query in these areas gets the right city name; only the neighborhood-level
    35→name differs.
    36→
    37→## Europe (31GB PBF)
    38→
    39→| Component | Baseline | Parallel | Status |
    40→|---|---|---|---|
    41→| Street ways | 19,452,480 | 19,452,480 | **100% PASS** |
    42→| Address points | 85,891,830 | 85,891,830 | **100% PASS** |
    43→| Interpolation ways | 13,988 | 13,988 | **100% PASS** |
    44→| Admin (unique name\|level) | 327,854 | 327,882 | **0 missing**, 28 extras |
    45→
    46→## Germany (4.5GB PBF)
    47→
    48→| Component | Baseline | Parallel | Status |
    49→|---|---|---|---|
    50→| Street ways | 3,312,673 | 3,312,673 | **100% PASS** |
    51→| Address points | 19,951,156 | 19,951,156 | **100% PASS** |
    52→| Interpolation ways | 78 | 78 | **100% PASS** |
    53→| Admin (unique name\|level) | 37,034 | 37,034+ | **0 missing** |
    54→
    55→## Bugs Found and Fixed (21 commits)
    56→
    57→1. **String interleaving** — way names swapped with building addr strings (~62% of ways)
    58→2. **Building address corruption** — `"\0"` null separator never embedded (~16M addresses)
    59→3. **Ring stitcher** — coordinate matching, split-at-shared-nodes, greedy+backtracking hybrid
    60→4. **Closed-way admin polygons** — standalone boundary ways not handled
    61→5. **Inner-way retry** — osmium ignores roles, inner ways bridge outer gaps
    62→6. **Border-line filter** — level 2 non-country relations (name pattern check)
    63→7. **Self-intersection detection** — invalid assembled rings discarded
    64→8. **Duplicate coordinate filter** — prevents hole+outer boundary merging
    65→9. **Planet-scale performance** — greedy stitcher with budgeted backtracking retry
    66→10. **Parallel index writes** — entry files + raw data files written concurrently (~2.5x faster)
    67→
    68→## Performance
    69→
    70→| Phase | Sequential baseline | Parallel (initial) | Parallel (optimized) |
    71→|---|---|---|---|
    72→| Total build (planet) | ~5 hours | ~40 minutes | **~14 minutes** |
    73→| Admin assembly | ~2 hours | ~5 minutes | ~2.6 minutes |
    74→| S2 cell computation | — | ~17 minutes | ~5.8 minutes |
    75→| Index writing | ~25-30 minutes | ~10 minutes | ~4.2 minutes |
    76→
    77→### Optimization Summary
    78→
    79→| Optimization | Impact |
    80→|---|---|
    81→| thread_local S2RegionCoverer | Avoid 335M constructor calls |
    82→| cover_edge fast-path (same/adjacent cell) | Skip S2 library for ~80% of edges (3.6x) |
    83→| Flat-array S2 cell collection | Replace hash map merge with sort (2.7x) |
    84→| Sweep-line self-intersection check | O(n log n) vs O(n²) for large rings (2x admin) |
    85→| Merge-join entry writing | Sequential scan vs hash lookups (1.8x) |
    86→| Parallel index file writes | 8 files written concurrently |
    87→| Buffered IO | Single write calls vs millions of small writes |
    88→
    89→## Admin Polygon Gap Journey
    90→
    91→| Stage | Germany | Europe | Planet |
    92→|---|---|---|---|
    93→| Pre node-ID fix | -11,675 | — | — |
    94→| After node-ID fix | -43 | — | — |
    95→| After coordinate matching | -29 | — | — |
    96→| After closed-way + inner-way | 0 missing | 0 missing | — |
    97→| After greedy+backtracking | +1 | 0 missing | **10 missing** |
    98→


     1→cmake_minimum_required(VERSION 3.16)
     2→project(traccar-geocoder-build LANGUAGES CXX)
     3→
     4→set(CMAKE_CXX_STANDARD 17)
     5→set(CMAKE_CXX_STANDARD_REQUIRED ON)
     6→
     7→find_package(Threads REQUIRED)
     8→find_package(ZLIB REQUIRED)
     9→find_package(BZip2 REQUIRED)
    10→find_package(EXPAT REQUIRED)
    11→find_package(s2 QUIET)
    12→
    13→add_executable(build-index src/build_index.cpp)
    14→
    15→if(EXISTS /opt/homebrew/include)
    16→    target_include_directories(build-index PRIVATE /opt/homebrew/include)
    17→endif()
    18→
    19→if(s2_FOUND)
    20→    target_link_libraries(build-index PRIVATE
    21→        s2::s2
    22→        ZLIB::ZLIB
    23→        BZip2::BZip2
    24→        EXPAT::EXPAT
    25→        Threads::Threads
    26→    )
    27→else()
    28→    target_include_directories(build-index PRIVATE /usr/include/s2)
    29→    target_link_libraries(build-index PRIVATE
    30→        s2
    31→        ZLIB::ZLIB
    32→        BZip2::BZip2
    33→        EXPAT::EXPAT
    34→        Threads::Threads
    35→    )
    36→endif()
    37→


I now have a thorough understanding of the entire codebase. Here is my structured analysis and plan.

---

## Refactoring Analysis of build_index.cpp

### 1. Functions That Are Too Long and Should Be Split

**`main()` (lines 2121-3294, ~1173 lines)** -- This is the most critical problem. The main function contains the entire pipeline orchestration including:
- Argument parsing (lines 2142-2190) -- extract to `parse_args()`
- DenseIndex class definition (lines 2218-2263) -- extract to its own struct/file
- Node processing worker lambda (lines 2331-2382, ~50 lines)
- Way processing worker lambda (lines 2484-2703, ~220 lines) -- this single lambda processes interpolation ways, building addresses, highway ways, way geometry collection, AND closed-way admin boundaries all inline
- Thread-local data merge (lines 2762-2998, ~236 lines)
- S2 cell computation (lines 3017-3199, ~182 lines)
- Deduplication and writing (lines 3200-3294)

**The way processing worker lambda (lines 2484-2703)** is especially problematic at ~220 lines with 10+ levels of nesting. It duplicates logic from `BuildHandler::process_interpolation_way`, `BuildHandler::process_highway`, and `BuildHandler::process_building_address` but adapted for the parallel path.

**`assemble_outer_rings()` (lines 595-824, ~230 lines)** contains the entire ring stitching algorithm: sub-way splitting, adjacency building, greedy stitching, and backtracking. It should be split into at least:
- `split_ways_at_shared_nodes()`
- `build_coord_adjacency()`
- `greedy_stitch_rings()`
- `backtrack_stitch_rings()`

**`filter_by_bbox()` (lines 1535-1754, ~220 lines)** has four nearly identical copy-paste blocks for filtering ways, addrs, interps, and admin polygons.

**`write_index()` (lines 1925-2117, ~192 lines)** orchestrates many parallel writes but mixes cell ID extraction, parallel entry writing, buffer filling, and file output.

### 2. Code Duplication That Should Be Extracted

**Duplicate #1: Way/interp/building-address processing appears twice** -- once in `BuildHandler` (lines 923-1129, the sequential path) and again in the parallel way worker lambda (lines 2484-2703). The parallel path reimplements the same tag checks, node location resolution, and struct construction. A shared function like `classify_and_process_way(way, index, local_data)` would eliminate this.

**Duplicate #2: `filter_by_bbox()` ID collection and remapping** -- Lines 1538-1576 repeat the same pattern four times:
```cpp
std::unordered_set<uint32_t> used_X_ids;
for (const auto& [cell_id, ids] : full.cell_to_X) {
    if (cell_in_bbox(cell_id, bbox)) {
        for (uint32_t id : ids) used_X_ids.insert(id);
    }
}
```
And lines 1578-1642 repeat another pattern four times for remapping. A template helper `collect_used_ids(cell_map, bbox)` and `remap_items(...)` would cut ~80 lines.

**Duplicate #3: Cell map remapping** (lines 1644-1701) -- four nearly identical blocks for ways, addrs, interps, admin. Same structure, different remap tables.

**Duplicate #4: `coord_key` lambda** is defined identically in three places:
- Line 605 (inside `assemble_outer_rings`)
- Line 2915 (inside the parallel admin worker lambda)
- Line 36 of `test_ring_assembly.cpp`

It should be a single static function (one already exists as a lambda -- promote it).

**Duplicate #5: S2 cell computation for ways and interps** (lines 3030-3096) -- the street way loop and interpolation way loop are structurally identical. Only the source arrays (`deferred_ways`/`deferred_interps` and `street_nodes`/`interp_nodes`) differ. A template or function extracting the common pattern would eliminate ~30 lines.

**Duplicate #6: Admin/postal boundary tag parsing** appears twice -- in `BuildHandler::area()` (lines 966-1020) and in `RelationCollector::relation()` (lines 1138-1196). The tag extraction logic (boundary type check, admin_level parsing, postal code name extraction, country code extraction) is copy-pasted.

### 3. Magic Numbers That Should Be Named Constants

| Value | Location(s) | Suggested Name |
|-------|------------|----------------|
| `1e7` | lines 606-607, 2917 | `kNanodegreeFactor` |
| `500` | line 894 | `kMaxSimplifiedVertices` |
| `32` | line 539 | `kBruteForceIntersectionThreshold` |
| `200` | line 379, 701 | `kMaxS2CoverCells`, `kMaxBacktrackDepth` |
| `100000` | line 699 | `kMaxBacktrackCalls` |
| `500000` | line 784 | `kMaxTotalBacktrackCalls` |
| `30` | line 777 | `kMaxBacktrackSubWays` |
| `64` | lines 2318, 2477 | `kMaxQueueDepth` |
| `8192` | line 2334 | `kLocationBatchSize` |
| `1000` | lines 2372, 2700 | `kBlockProgressInterval` |
| `10000` | line 2970 | `kAdminProgressInterval` |
| `1000000` | line 869, 1125 | `kProgressInterval` |
| `100000` | line 1215-1216 | `kCoordPrecision` (note: different from the `1e7` nanodegree precision used elsewhere -- potential bug?) |
| `65535` | lines 906, 1788, 1856, 1916 | `kMaxUint16` or just use `std::numeric_limits<uint16_t>::max()` |
| `255` | lines 1083, 1117, 2536, 2610 | `kMaxNodeCount` / `std::numeric_limits<uint8_t>::max()` |
| `11` | lines 982, 1153, 2652-2654 | `kPostalCodeAdminLevel` |
| `20` | line 483 | `kSimplifyBinarySearchIterations` |

### 4. Functions That Should Be Moved to Separate Files

| Module | Contents | Lines |
|--------|----------|-------|
| `s2_helpers.h/cpp` | `cover_edge()`, `cover_polygon()`, `point_to_cell()`, `polygon_area()`, `kStreetCellLevel`, `kAdminCellLevel` | ~150 lines |
| `geometry.h/cpp` | `dp_simplify()`, `simplify_polygon()`, `segments_intersect()`, `ring_has_self_intersection()`, `ring_has_duplicate_coords()`, `coord_key()` | ~120 lines |
| `ring_assembly.h/cpp` | `assemble_outer_rings()` and the `BacktrackState` struct | ~230 lines |
| `string_pool.h` | `StringPool` class | ~20 lines |
| `binary_structs.h` | `WayHeader`, `AddrPoint`, `InterpWay`, `AdminPolygon`, `NodeCoord`, `CellItemPair`, `DeferredWay`, `DeferredInterp`, `CollectedRelation` | ~60 lines |
| `cache.h/cpp` | All cache serialization/deserialization code | ~200 lines |
| `continent_filter.h/cpp` | `ContinentBBox`, `kContinents`, `cell_in_bbox()`, `filter_by_bbox()` | ~250 lines |
| `index_writer.h/cpp` | `write_entries()`, `write_entries_from_sorted()`, `write_cell_index()`, `write_index()` | ~200 lines |
| `admin_cover_pool.h` | `AdminCoverPool` class | ~90 lines |

### 5. Other Code Quality Issues

**Potential precision bug**: `resolve_interpolation_endpoints()` uses `* 100000` precision (line 1215-1216) for coordinate matching, while `coord_key` uses `* 1e7` (nanodegrees). This is a ~100x precision difference. If an interpolation way endpoint has a slightly different float representation than the corresponding address point, the 100000 multiplier could fail to match where 1e7 would succeed (or vice versa).

**Mutable global state**: `kStreetCellLevel`, `kAdminCellLevel`, and `kMaxAdminLevel` (lines 286-288) are mutable file-scope globals modified by argument parsing. These should be passed as configuration or made const after initialization.

**Exception-based control flow**: The way processing lambda uses `try { ... } catch (...) {}` (lines 2511-2514, 2560-2566, 2593-2594, 2624) for node location lookups. The `DenseIndex::get()` method (line 2249) never throws -- it returns an invalid Location. These try/catch blocks are dead code adding noise.

**Missing error handling for file writes**: `write_cell_index()`, `write_entries()`, and the data file writes in `write_index()` never check if the ofstream opened successfully or if writes succeeded.

**`BuildHandler` class is unused in the parallel path**: When `parallel_admin` is true (the default), the `BuildHandler` only processes node addresses via its `node()` method. The `way()` and `area()` methods are only used in the sequential fallback. This creates confusion about which code path is actually active.

---

## Test Coverage Gaps and Additional Tests Needed

### Unit Tests to Add

**A. Ring Assembly Algorithm (highest priority)**

1. **Simple two-way ring stitching**: Two open ways whose endpoints connect to form a closed ring. Validates basic adjacency building and greedy stitching.

2. **Multi-way ring with reversal**: Three or more ways where some must be reversed to form a ring. Tests the `is_last` / reversed logic in the greedy stitcher.

3. **Backtracking at a branch point**: Create a scenario with 4+ sub-ways forming a T-junction where the greedy pass makes the wrong initial choice, requiring backtracking to close the ring. Verify backtracking pass produces more rings.

4. **Multiple disjoint rings from one relation**: Provide 6+ ways that form 2 separate closed rings (e.g., two islands of a country). Verify both rings are assembled.

5. **Closed single-way detection**: A single way whose first node equals its last node should be immediately recognized as a closed ring without any stitching.

6. **Way splitting at shared internal nodes**: Create two ways that share an internal node (not just endpoints). Verify sub-way splitting produces the correct number of sub-ways and that stitching still works.

7. **Budget-limited backtracking**: Create a relation with >30 sub-ways to verify that backtracking is skipped (the `sub_ways.size() <= 30` guard) and greedy results are used.

8. **Inner-way retry**: Create an outer ring with a gap that can only be closed by including an "inner"-role way. Verify that the first pass returns 0 rings and the retry with `include_all_roles=true` succeeds.

**B. Geometry Utilities**

9. **Douglas-Peucker simplification**: Test with a known polygon. Verify that `simplify_polygon(pts, max)` returns <= `max` vertices and that the simplified shape preserves key points.

10. **simplify_polygon passthrough**: If input has fewer vertices than `max_vertices`, verify input is returned unchanged.

11. **Ring self-intersection with large ring (sweep-line path)**: Create a ring with >32 vertices that has a self-intersection. Verify the sweep-line code path (not brute force) detects it.

12. **Ring self-intersection with adjacent-segment false positive**: Verify that adjacent segments sharing an endpoint are not flagged as intersecting.

13. **polygon_area for non-trivial shapes**: Test with a known triangle or irregular polygon and compare against expected area.

**C. StringPool**

14. **Intern returns same offset for duplicate strings**: Intern "hello" twice, verify same uint32_t returned.

15. **Intern returns different offsets for different strings**: Intern "hello" and "world", verify different offsets.

16. **Intern empty string**: Verify interning "" works and returns a valid offset where data()[offset] == '\0'.

17. **Intern long string / string with special characters**: UTF-8 strings, strings with embedded special characters.

**D. S2 Cell Computation**

18. **cover_edge same-cell fast path**: Two points in the same S2 cell return exactly 1 cell ID.

19. **cover_edge adjacent-cell path**: Two points in adjacent cells with midpoint in one of them return exactly 2 cells.

20. **cover_edge long-edge path**: Two distant points that require S2RegionCoverer. Verify result contains more than 2 cells.

21. **cover_polygon deduplication**: Verify no duplicate cell IDs in the result.

22. **cover_polygon interior marking**: Verify that cells fully inside a large polygon are marked `is_interior=true`.

23. **cover_polygon with <3 points**: Returns empty vector.

**E. Cache Serialization**

24. **Round-trip serialize/deserialize for vectors**: Create a ParsedData with known values, serialize, deserialize, verify equality.

25. **Round-trip for cell maps**: Same for cell_to_ways etc.

26. **Cache version mismatch**: Verify that a cache with wrong version is rejected.

27. **Cache with truncated data**: Verify graceful failure on corrupted input.

**F. Continent Filtering**

28. **cell_in_bbox basic**: Verify a known Monaco cell is in the Europe bbox but not in Africa.

29. **filter_by_bbox preserves referenced data**: Filter a small dataset, verify that all ways/addrs referenced by the surviving cell map are present in the output.

30. **filter_by_bbox string pool compaction**: Verify the output string pool only contains strings referenced by surviving items.

**G. Write Phase**

31. **write_entries_from_sorted with empty input**: Verify no crash and empty output.

32. **write_entries merge-join with gaps**: Verify NO_DATA offsets for cells with no items (already partially tested, but expand to test the parallel chunked path).

**H. parse_house_number**

33. **Leading digits**: `parse_house_number("42a")` returns 42.

34. **No digits**: `parse_house_number("abc")` returns 0.

35. **Null input**: `parse_house_number(nullptr)` returns 0.

36. **Pure number**: `parse_house_number("123")` returns 123.

**I. add_admin_polygon normalization**

37. **Ring rotation normalization**: Two rings with the same vertices but different starting points should produce the same normalized output after `add_admin_polygon`.

38. **Vertex count clamped to uint16_t max**: Verify behavior with >65535 vertices.

---

## Recommended Implementation Sequence

**Phase 1: Extract testable utility functions** (enables unit testing without osmium)
1. Move `coord_key` to a standalone function
2. Extract geometry utilities to `geometry.h/cpp`
3. Extract `StringPool` to `string_pool.h`
4. Extract binary structs to `binary_structs.h`

**Phase 2: Write unit tests for extracted utilities**
- Tests for StringPool, coord_key, geometry functions, parse_house_number
- Tests for ring assembly with controlled inputs

**Phase 3: Extract larger modules**
- S2 helpers, cache serialization, continent filter, index writer
- Ring assembly module

**Phase 4: Refactor main()**
- Extract argument parsing
- Extract DenseIndex to its own header
- Break the way processing lambda into smaller functions
- Eliminate dead code (try/catch blocks, unused sequential BuildHandler methods)

### Critical Files for Implementation

- `/root/traccar-geocoder/builder/src/build_index.cpp` - The 3295-line monolith to refactor; contains all logic
- `/root/traccar-geocoder/builder/test/test_ring_assembly.cpp` - Existing 12 unit tests to extend with 30+ new tests
- `/root/traccar-geocoder/builder/CMakeLists.txt` - Must be updated to compile new source files and test targets
- `/root/traccar-geocoder/builder/test/test_integration.sh` - Integration test to verify refactoring preserves behavior
- `/root/traccar-geocoder/builder/test/VALIDATION_REPORT.md` - Documents expected behavior/precision for regression testing