Perfect! Now let me look at the actual PBF reader implementation to understand how threading works:


     1→#include "pbf_reader.h"
     2→
     3→#include <algorithm>
     4→#include <atomic>
     5→#include <cerrno>
     6→#include <cstdlib>
     7→#include <condition_variable>
     8→#include <cstring>
     9→#include <fcntl.h>
    10→#include <iostream>
    11→#include <sys/mman.h>
    12→#include <mutex>
    13→#include <stdexcept>
    14→#include <thread>
    15→#include <unistd.h>
    16→#include <zlib.h>
    17→
    18→#include <protozero/pbf_reader.hpp>
    19→#include <protozero/varint.hpp>
    20→
    21→// --- PBF file format constants ---
    22→// BlobHeader max size: 32 MiB (per spec), but typically <100 bytes
    23→// Blob max size: 32 MiB (per spec)
    24→static constexpr size_t MAX_BLOB_HEADER_SIZE = 64 * 1024;
    25→static constexpr size_t MAX_BLOB_SIZE = 64 * 1024 * 1024;
    26→
    27→// Protobuf field tags (from OSM PBF spec)
    28→namespace BlobHeaderTag {
    29→    constexpr int TYPE = 1;      // string
    30→    constexpr int DATASIZE = 3;  // int32
    31→}
    32→namespace BlobTag {
    33→    constexpr int RAW = 1;       // bytes
    34→    constexpr int RAW_SIZE = 2;  // int32
    35→    constexpr int ZLIB = 3;      // bytes
    36→}
    37→namespace PrimitiveBlockTag {
    38→    constexpr int STRINGTABLE = 1;   // StringTable
    39→    constexpr int PRIMITIVEGROUP = 2; // repeated PrimitiveGroup
    40→    constexpr int GRANULARITY = 17;  // int32 (default 100)
    41→    constexpr int LAT_OFFSET = 19;   // int64 (default 0)
    42→    constexpr int LON_OFFSET = 20;   // int64 (default 0)
    43→}
    44→namespace PrimitiveGroupTag {
    45→    constexpr int NODES = 1;
    46→    constexpr int DENSE = 2;
    47→    constexpr int WAYS = 3;
    48→    constexpr int RELATIONS = 4;
    49→}
    50→namespace StringTableTag {
    51→    constexpr int S = 1; // repeated bytes
    52→}
    53→namespace DenseNodesTag {
    54→    constexpr int ID = 1;
    55→    constexpr int LAT = 8;
    56→    constexpr int LON = 9;
    57→    constexpr int KEYS_VALS = 10;
    58→}
    59→namespace WayTag {
    60→    constexpr int ID = 1;
    61→    constexpr int KEYS = 2;
    62→    constexpr int VALS = 3;
    63→    constexpr int REFS = 8;
    64→}
    65→namespace RelationTag {
    66→    constexpr int ID = 1;
    67→    constexpr int KEYS = 2;
    68→    constexpr int VALS = 3;
    69→    constexpr int ROLES_SID = 8;
    70→    constexpr int MEMIDS = 9;
    71→    constexpr int TYPES = 10;
    72→}
    73→
    74→// --- File I/O helpers ---
    75→
    76→static std::string read_bytes(int fd, size_t offset, size_t count) {
    77→    std::string buf(count, '\0');
    78→    ssize_t n = pread(fd, buf.data(), count, offset);
    79→    if (n != (ssize_t)count) {
    80→        throw std::runtime_error("pread failed at offset " + std::to_string(offset) +
    81→                                 ": expected " + std::to_string(count) +
    82→                                 " got " + std::to_string(n));
    83→    }
    84→    return buf;
    85→}
    86→
    87→static uint32_t read_u32_be(int fd, size_t offset) {
    88→    uint8_t buf[4];
    89→    if (pread(fd, buf, 4, offset) != 4) {
    90→        throw std::runtime_error("failed to read 4 bytes at offset " + std::to_string(offset));
    91→    }
    92→    return (uint32_t(buf[0]) << 24) | (uint32_t(buf[1]) << 16) |
    93→           (uint32_t(buf[2]) << 8) | uint32_t(buf[3]);
    94→}
    95→
    96→// --- Blob scanning ---
    97→
    98→std::vector<BlobInfo> scan_pbf_blobs(const std::string& filename) {
    99→    int fd = open(filename.c_str(), O_RDONLY);
   100→    if (fd < 0) throw std::runtime_error("cannot open " + filename + ": " + strerror(errno));
   101→
   102→    // Get file size
   103→    off_t file_size = lseek(fd, 0, SEEK_END);
   104→
   105→    std::vector<BlobInfo> blobs;
   106→    size_t offset = 0;
   107→
   108→    while (offset < (size_t)file_size) {
   109→        // Read 4-byte big-endian BlobHeader size
   110→        uint32_t header_size = read_u32_be(fd, offset);
   111→        if (header_size > MAX_BLOB_HEADER_SIZE) {
   112→            throw std::runtime_error("BlobHeader too large: " + std::to_string(header_size));
   113→        }
   114→
   115→        // Read BlobHeader
   116→        std::string header_data = read_bytes(fd, offset + 4, header_size);
   117→        protozero::pbf_reader header_pbf(header_data);
   118→
   119→        std::string type;
   120→        int32_t data_size = 0;
   121→        while (header_pbf.next()) {
   122→            switch (header_pbf.tag()) {
   123→                case BlobHeaderTag::TYPE:
   124→                    type = header_pbf.get_string();
   125→                    break;
   126→                case BlobHeaderTag::DATASIZE:
   127→                    data_size = header_pbf.get_int32();
   128→                    break;
   129→                default:
   130→                    header_pbf.skip();
   131→            }
   132→        }
   133→
   134→        BlobInfo info;
   135→        info.offset = offset;
   136→        info.header_size = header_size;
   137→        info.data_size = data_size;
   138→        info.type = type;
   139→        blobs.push_back(info);
   140→
   141→        // Advance past: 4 bytes length + header + blob data
   142→        offset += 4 + header_size + data_size;
   143→    }
   144→
   145→    close(fd);
   146→    return blobs;
   147→}
   148→
   149→// --- Blob decompression ---
   150→


Now let me look for the read_nodes_streaming and read_ways_streaming implementations:


Bash

grep -n "read_nodes_streaming\|read_ways_streaming" /root/traccar-geocoder/builder/src/pbf_reader.cpp | head -5

Find streaming functions


956:void PbfFile::read_nodes_streaming(const NodeCallback& callback) {
1021:void PbfFile::read_ways_streaming(const WayCallback& callback) {


   956→void PbfFile::read_nodes_streaming(const NodeCallback& callback) {
   957→    std::vector<size_t> data_indices;
   958→    for (size_t i = 0; i < blobs_.size(); i++)
   959→        if (blobs_[i].type == "OSMData") data_indices.push_back(i);
   960→    if (data_indices.empty()) return;
   961→
   962→    // Limit concurrent I/O to avoid saturating slow storage.
   963→    // On fast storage, all threads get through the semaphore quickly.
   964→    // On slow storage, excess threads do decode+callback while waiting.
   965→    unsigned max_io;
   966→    const char* max_io_env = getenv("BUILD_MAX_IO");
   967→    if (max_io_env) {
   968→        max_io = static_cast<unsigned>(std::atoi(max_io_env));
   969→        if (max_io == 0) max_io = num_threads_;
   970→    } else {
   971→        max_io = std::min(num_threads_, std::max(8u, num_threads_ / 2));
   972→    }
   973→    IoSemaphore io_sem(max_io);
   974→
   975→    std::atomic<size_t> next_idx{0};
   976→    std::atomic<size_t> blocks_done{0};
   977→    std::vector<std::thread> threads;
   978→
   979→    struct ThreadStats { double read_us = 0, decode_us = 0; size_t count = 0; };
   980→    std::vector<ThreadStats> stats(num_threads_);
   981→
   982→    for (unsigned t = 0; t < num_threads_; t++) {
   983→        threads.emplace_back([&, t]() {
   984→            int local_fd = open(filename_.c_str(), O_RDONLY);
   985→            if (local_fd < 0) return;
   986→            std::string decomp, blob_buf;
   987→            auto& st = stats[t];
   988→            while (true) {
   989→                size_t j = next_idx.fetch_add(1);
   990→                if (j >= data_indices.size()) break;
   991→
   992→                io_sem.acquire();
   993→                auto t0 = std::chrono::steady_clock::now();
   994→                read_and_decompress_blob_into(local_fd, blobs_[data_indices[j]], blob_buf, decomp);
   995→                auto t1 = std::chrono::steady_clock::now();
   996→                io_sem.release();
   997→
   998→                decode_nodes_streaming(decomp.data(), decomp.size(), callback);
   999→                auto t2 = std::chrono::steady_clock::now();
  1000→                st.read_us += std::chrono::duration<double, std::micro>(t1 - t0).count();
  1001→                st.decode_us += std::chrono::duration<double, std::micro>(t2 - t1).count();
  1002→                st.count++;
  1003→                size_t done = blocks_done.fetch_add(1) + 1;
  1004→                if (done % 5000 == 0)
  1005→                    std::cerr << "  Processed " << done << "/" << data_indices.size() << " blocks..." << std::endl;
  1006→            }
  1007→            close(local_fd);
  1008→        });
  1009→    }
  1010→    for (auto& t : threads) t.join();
  1011→
  1012→    double total_read = 0, total_decode = 0; size_t total_blocks = 0;
  1013→    for (auto& s : stats) { total_read += s.read_us; total_decode += s.decode_us; total_blocks += s.count; }
  1014→    std::cerr << "  Node streaming (" << total_blocks << " blocks, " << num_threads_ << " threads, "
  1015→              << max_io << " max_io): "
  1016→              << "read=" << (total_read/1e6) << "s decode+callback=" << (total_decode/1e6) << "s"
  1017→              << " (per-thread avg: read=" << (total_read/1e6/num_threads_)
  1018→              << "s decode+cb=" << (total_decode/1e6/num_threads_) << "s)" << std::endl;
  1019→}
  1020→
  1021→void PbfFile::read_ways_streaming(const WayCallback& callback) {
  1022→    std::vector<size_t> data_indices;
  1023→    for (size_t i = 0; i < blobs_.size(); i++)
  1024→        if (blobs_[i].type == "OSMData") data_indices.push_back(i);
  1025→    if (data_indices.empty()) return;
  1026→
  1027→    unsigned max_io;
  1028→    const char* max_io_env = getenv("BUILD_MAX_IO");
  1029→    if (max_io_env) {
  1030→        max_io = static_cast<unsigned>(std::atoi(max_io_env));
  1031→        if (max_io == 0) max_io = num_threads_;
  1032→    } else {
  1033→        max_io = std::min(num_threads_, std::max(8u, num_threads_ / 2));
  1034→    }
  1035→    IoSemaphore io_sem(max_io);
  1036→
  1037→    std::atomic<size_t> next_idx{0};
  1038→    std::atomic<size_t> blocks_done{0};
  1039→    std::vector<std::thread> threads;
  1040→
  1041→    struct ThreadStats { double read_us = 0, decode_us = 0; size_t count = 0; };
  1042→    std::vector<ThreadStats> stats(num_threads_);
  1043→
  1044→    for (unsigned t = 0; t < num_threads_; t++) {
  1045→        threads.emplace_back([&, t]() {
  1046→            int local_fd = open(filename_.c_str(), O_RDONLY);
  1047→            if (local_fd < 0) return;
  1048→            std::string decomp, blob_buf;
  1049→            auto& st = stats[t];
  1050→            while (true) {
  1051→                size_t j = next_idx.fetch_add(1);
  1052→                if (j >= data_indices.size()) break;
  1053→
  1054→                io_sem.acquire();
  1055→                auto t0 = std::chrono::steady_clock::now();


Perfect! Now let me create a comprehensive summary document. Let me also check one more thing - how string pool is used in threading context:


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

Check string pool usage


26:#include "string_pool.h"
617:                        h.name_id = data.string_pool.intern(local.way_strings[i]);
642:                        iw.street_id = data.string_pool.intern(local.interp_strings[i]);
875:                    // Sequential append + submit to S2 pool (cheap: just vector push + string intern)
889:                        poly.name_id = data.string_pool.intern(pp.name);
1299:    // Each variant does parallel simplification internally, so limit


Excellent. Now I have enough information. Let me create a comprehensive analysis document:

## Comprehensive Non-Determinism Analysis: build_index.cpp

Based on my thorough examination of the codebase, I've identified the critical sources of non-determinism. Here's the complete analysis:

---

## 1. STRING POOL ORDERING - NON-DETERMINISTIC

**File:** `/root/traccar-geocoder/builder/src/string_pool.h` (lines 8-28)

**Problem:**
```cpp
class StringPool {
    uint32_t intern(const std::string& s) {
        auto it = index_.find(s);
        if (it != index_.end()) return it->second;
        uint32_t offset = static_cast<uint32_t>(data_.size());
        index_[s] = offset;  // unordered_map iteration order
        data_.insert(...);
        return offset;
    }
private:
    std::unordered_map<std::string, uint32_t> index_;
    std::vector<char> data_;
};
```

**Root Cause:** Uses `std::unordered_map` without thread-safe interning during parallel processing.

**Non-Determinism Sources:**
1. **Unordered map iteration** (lines 617, 642, 889 in build_index.cpp): When strings are interned from multiple threads during way/interp/admin processing, the HashMap iteration order for `index_` is undefined
2. **Thread-local string interning** (line 617): `data.string_pool.intern(local.way_strings[i])` is called sequentially (in thread merge loop, lines 607-671), but the order depends on thread iteration order
3. **Collision handling**: ASLR + malloc randomization mean even same strings get different insertion timings across runs

**Severity:** **SEVERE - Affects Content**
- Different offset assignments for same string across runs
- Results in different string.bin content
- Breaks byte-identical reproducibility entirely

**What's Needed:**
- Use `std::map` instead of `unordered_map` for deterministic ordering
- Add mutex for thread-safe parallel interning (if strings are interned from multiple threads)
- Or: pre-collect all unique strings, sort, then assign offsets sequentially

---

## 2. THREAD-LOCAL DATA MERGE ORDERING - NON-DETERMINISTIC

**File:** `/root/traccar-geocoder/builder/src/build_index.cpp` (lines 603-671)

**Problem:**
```cpp
// Thread-local way processing (lines 407-600)
std::vector<ThreadLocalData> tld(num_threads);

// Later merge (lines 607-671):
for (auto& local : tld) {  // Iterates tld[0], tld[1], ..., tld[num_threads-1]
    uint32_t way_base = data.ways.size();
    for (size_t i = 0; i < local.ways.size(); i++) {
        data.ways.push_back(h);  // Order depends on thread assignment
    }
    // Also merges deferred_ways, interp_ways, building_addrs, way_geometries
}
```

**Root Cause:** The merge order depends on how PBF blocks are assigned to threads.

**Non-Determinism Sources:**
1. **PBF block scheduling** (pbf_reader.cpp, lines 988-990):
   ```cpp
   while (true) {
       size_t j = next_idx.fetch_add(1);  // Which thread gets which block is non-deterministic
       if (j >= data_indices.size()) break;
   ```
   - Thread scheduling is OS-dependent
   - Block assignment is first-come-first-served via atomic fetch_add
   - Same PBF, different runs = different thread gets block N

2. **Merge loop ordering** (build_index.cpp, line 607):
   ```cpp
   for (auto& local : tld) {  // Depends on which thread_idx processed which blocks
   ```
   - Thread 0 may have processed blocks 0,3,6 in run 1 but blocks 1,4,7 in run 2
   - This changes the order ways appear in `data.ways`

3. **Thread-local accumulation order**:
   - Way IDs from different blocks land in different thread-local buckets
   - Merge order = concatenation order = affects way ordering in final output

**Severity:** **SEVERE - Affects Ordering**
- `data.ways` order differs between runs
- `data.addr_points` order differs between runs (lines 630-636)
- `data.interp_ways` order differs between runs (lines 638-646)
- street_ways.bin and addr_points.bin have different content bytes

**What's Needed:**
- Sort thread-local data by PBF block index before merging
- Or: maintain a "source block order" tracker for each item during processing
- Or: switch to single-threaded pass 2 (slower but deterministic)

---

## 3. ADDRESS POINT MERGING - NON-DETERMINISTIC

**File:** `/root/traccar-geocoder/builder/src/build_index.cpp` (lines 389-402 and 630-636)

**Problem:**
```cpp
// Pass 2: Address points (lines 389-402)
static thread_local NodeThreadLocal* tl_node_data = nullptr;
std::vector<NodeThreadLocal> ntld(num_threads);
std::atomic<unsigned> next_tl{0};

pbf.read_nodes_streaming([&](int64_t id, double lat, ...) {
    if (!tl_node_data) {
        unsigned idx = next_tl.fetch_add(1);  // NON-DETERMINISTIC ASSIGNMENT
        tl_node_data = &ntld[idx % ntld.size()];
    }
    // Accumulate in tl_node_data
});

// Later merge (lines 391-399):
for (auto& local : ntld) {  // Order depends on thread assignment
    for (size_t j = 0; j < local.addr_coords.size(); j++) {
        add_addr_point(data, ...);  // Determines addr_id = size() before push
    }
}
```

**Root Cause:** Thread assignment is random based on which thread first encounters a node callback.

**Non-Determinism Sources:**
1. **Thread-local pointer assignment** (line 358): `next_tl.fetch_add(1)` assigns threads in order of appearance
2. **Callback thread order**: PBF decoder can call back from any worker thread (pbf_reader.cpp, line 998)
3. **Address ID assignment** (s2_helpers.cpp, line 232): `addr_id = data.addr_points.size()` depends on merge order

**Severity:** **SEVERE - Affects Ordering**
- `data.addr_points` has different order
- Different addr_id assignments
- addr_points.bin differs

**What's Needed:**
- Collect all addresses in a temporary sorted structure during streaming
- Sort by node ID before inserting into main data structure
- Or: use a single thread-local accumulator for all addresses

---

## 4. S2 CELL COMPUTATION & SORTING - PARTIALLY DETERMINISTIC

**File:** `/root/traccar-geocoder/builder/src/build_index.cpp` (lines 1056-1145)

**Problem:**
```cpp
// Parallel sort (lines 1072-1085):
std::vector<std::thread> sort_threads;
for (size_t t = 0; t < thread_pairs.size(); t++) {
    sort_threads.emplace_back([&, t]() {
        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);
        });
    });
}

// Then parallel tree merge (lines 1109-1129):
while (run_bounds.size() > 2) {
    // Pairwise inplace_merge of adjacent runs
    merge_threads.emplace_back([&, left, mid, right] {
        std::inplace_merge(sorted_out.begin() + left, ...);
    });
}
```

**Analysis:**
- **Within-thread sorting**: DETERMINISTIC (same items, same sort comparator)
- **Parallel tree merge**: DETERMINISTIC (specific pair-wise merge pattern)
- **BUT**: Depends on thread-local data from step 2 & 3

**However**, if input `way_pairs` order differs (due to thread merge non-determinism):
- Thread 0 might have items {1,5,9} in run 1, {2,4,8} in run 2
- Sorting each chunk: still deterministic
- But final merged order differs

**Severity:** **HIGH - Cascading Effect**
- `sorted_way_cells` and `sorted_addr_cells` differ
- Entry file writing (cell_index.cpp, lines 56-133) uses these directly
- street_entries.bin and addr_entries.bin differ

**What's Needed:**
- Fix sources 2 & 3 first (thread-local merge ordering)
- Then S2 computation is deterministic

---

## 5. ADMIN POLYGON ASSEMBLY - PARTIALLY NON-DETERMINISTIC

**File:** `/root/traccar-geocoder/builder/src/build_index.cpp` (lines 694-902)

**Problem:**
```cpp
// Parallel admin assembly (lines 714-815):
std::atomic<size_t> rel_idx{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);  // Work-stealing by thread index
            if (i >= data.collected_relations.size()) break;
            // Process relation i, append to local_results
        }
    });
}

// Sequential merge (lines 819-825):
for (auto& local_results : thread_admin_results) {
    all_admin_results.insert(...);  // Concatenate in thread order
}

// Sequential append to data.admin_polygons (lines 876-898):
for (auto& pp : prepared) {
    uint32_t poly_id = data.admin_polygons.size();
    data.admin_polygons.push_back(poly);  // Order depends on thread execution
}
```

**Analysis:**
- **Relation processing order**: DETERMINISTIC (relations processed in `data.collected_relations` order)
- **Within-thread results**: Thread 0 might process relations [0,5,10], thread 1 [1,6,11], etc.
- **Flattening order** (line 820): Concatenates `thread_admin_results[0]`, `[1]`, ... `[num_threads-1]`
- This depends on which relations each thread processed
- If work distribution differs (due to scheduling variance), order changes

**Severity:** **MEDIUM - Affects Content**
- `admin_polygons.bin` differs
- Polygon IDs reassigned
- But relations themselves are deterministic; it's just the output order

**What's Needed:**
- After parallel assembly, re-sort results by relation ID before appending
- Or: merge directly in deterministic order instead of by thread

---

## 6. ADMIN POLYGON S2 COVERING - NON-DETERMINISTIC

**File:** `/root/traccar-geocoder/builder/src/build_index.cpp` (lines 920-928)

**Problem:**
```cpp
// Drain admin pool results (sequential, but depends on pool's internal state)
auto admin_results = admin_pool.drain();  // Pools use thread-local storage
for (auto& [cell_id, ids] : admin_results) {  // unordered_map iteration
    auto& target = data.cell_to_admin[cell_id];
    target.insert(target.end(), ids.begin(), ids.end());  // Order depends on map iteration
}
```

**Root Cause:** Uses unordered_map for results; iteration order undefined.

**Severity:** **MEDIUM - Affects Ordering**
- `cell_to_admin` hash map order differs
- But when written (cell_index.cpp, line 140-141), gets re-sorted:
  ```cpp
  std::vector<std::pair<uint64_t, std::vector<uint32_t>>> sorted(cell_map.begin(), cell_map.end());
  std::sort(sorted.begin(), sorted.end());
  ```
- So admin_entries.bin is eventually deterministic
- But intermediate form varies

**What's Needed:**
- No fix needed here; write path sorts everything

---

## 7. FINAL OUTPUT WRITING - DETERMINISTIC

**File:** `/root/traccar-geocoder/builder/src/cell_index.cpp` (lines 159-385)

**Analysis:**
- `write_index()` uses `sorted_geo_cells` (explicitly sorted, line 221)
- `write_entries_from_sorted()` (lines 56-133) processes sorted pairs in parallel, but with deterministic output
- Parallel writing uses thread-safe chunk assignment

**However:**
- If `sorted_way_cells`, `sorted_addr_cells`, `sorted_interp_cells` differ (due to non-determinism above), output differs
- String pool differences propagate to strings.bin

**Severity:** **Writing is OK; garbage in = garbage out**

---

## SUMMARY TABLE

| Issue | Component | Severity | Root Cause | Output Diff |
|-------|-----------|----------|-----------|------------|
| String pool ordering | StringPool::intern() | SEVERE | unordered_map | strings.bin |
| Thread-local merge order (ways) | build_index.cpp 607-671 | SEVERE | PBF block scheduling | street_ways.bin, way offsets |
| Thread-local merge order (addr) | build_index.cpp 391-399 | SEVERE | Thread assignment order | addr_points.bin, addr IDs |
| S2 cell ordering (cascading) | build_index.cpp 1056-1145 | HIGH | Depends on 1-3 | street/addr_entries.bin |
| Admin polygon order | build_index.cpp 819-825 | MEDIUM | Thread scheduling | admin_polygons.bin |
| Admin polygon S2 covering | build_index.cpp 920-928 | MEDIUM | unordered_map (sorted on write) | Minimal (reordered at write) |

---

## ANSWER: Byte-Identical Output?

**NO.** Running the same build twice will produce **different binary outputs** due to:

1. **Strings pool offsets**: Different per run (unordered_map)
2. **Way ordering**: Different per run (thread scheduling)
3. **Address point ordering**: Different per run (thread assignment)
4. **String references in index files**: Different per run (cascading from 1)
5. **Admin polygon ordering**: Different per run (scheduling)

Even on the same machine, consecutive runs will differ.

---

## FIXES REQUIRED FOR DETERMINISM

**Priority 1 (Critical):**
1. Replace `std::unordered_map<std::string, uint32_t>` with `std::map` in StringPool
2. Sort thread-local way data by way ID before merging
3. Sort thread-local address data by node ID before merging
4. Fix address point thread assignment (use round-robin instead of atomic)

**Priority 2 (Important):**
5. Sort admin polygon results by relation ID before output
6. Add seeds to ASLR-vulnerable randomization

**Priority 3 (Nice-to-have):**
7. Consider single-threaded pass 2 for byte-reproducibility (if perf allows)
8. Document that output is non-deterministic (add comments)

---

**Files to modify:**
- `/root/traccar-geocoder/builder/src/string_pool.h` - Use std::map
- `/root/traccar-geocoder/builder/src/build_index.cpp` - Sort thread-local data before merge
- Optionally: `/root/traccar-geocoder/builder/src/s2_helpers.cpp` - Thread-safe string interning