User has approved your plan. You can now start coding. Start with updating your todo list if applicable

Your plan has been saved to: /root/.claude/plans/concurrent-humming-sonnet.md
You can refer back to it if needed during implementation.

## Approved Plan:
# Geocoder Incremental Patch System

## Context
We need nightly patch files so users can update their geocoder index without re-downloading the full dataset. A user starting from day 1 should be able to apply patch_1→2, then patch_2→3, and arrive at an output byte-identical to a fresh day 3 build.

This requires:
1. **Deterministic builds** — same PBF input always produces the same bytes
2. **A diff tool** — compares two builds, produces a compact patch
3. **A patch tool** — applies patch to old build, producing output byte-identical to the new build

## Part 1: Deterministic Builds

The current builder produces different bytes on each run due to thread scheduling affecting record ordering and string pool layout. Five fixes are needed in `build_index.cpp`:

### Fix 1: Deterministic string pool
**File:** `src/string_pool.h`
- Currently uses `std::unordered_map` — iteration order is random
- Change: after all strings are interned, sort the pool alphabetically and remap all offsets
- This happens once, after Pass 2b merge but before writing. Same approach as the `geocoder-canonicalize` tool already does

### Fix 2: Sort addr_points before writing
**File:** `src/build_index.cpp` (after thread-local merge, ~line 400)
- Currently: addr_points are in thread-merge order (depends on PBF block scheduling)
- Change: sort `data.addr_points` by canonical key (street_name, housenumber, lat_grid, lng_grid) before S2 cell computation
- Must also rebuild `cell_to_addrs` after sorting since addr IDs change

### Fix 3: Sort ways before writing
**File:** `src/build_index.cpp` (after thread-local merge, ~line 670)
- Currently: ways are in thread-merge order
- Change: sort `data.ways` + `data.street_nodes` by canonical key (name, node_count, first_node coords)
- Must update `deferred_ways` with new IDs after sorting

### Fix 4: Sort interps before writing
Same approach as ways for `data.interp_ways` + `data.interp_nodes`

### Fix 5: Sort admin polygons before writing
**File:** `src/build_index.cpp` (after admin assembly, ~line 900)
- Sort `data.admin_polygons` + `data.admin_vertices` by (name, admin_level, country_code, vertex_count)

### Performance impact
Sorting 86M addr_points: ~10s. Sorting 19M ways: ~20s. Total: ~30-40s added to a 12-minute build. ~5% overhead.

### Validation
Run the same build twice on the same PBF → output must be byte-identical. Use `diff -r` or `md5sum` on all 14 files.

## Part 2: Diff Tool (`geocoder-diff`)

Compares two deterministic builds and produces a `.gcpatch` file.

Because builds are deterministic, the same record gets the same position in any build from the same PBF. Between two PBFs (day N and day N+1), most records are identical and in approximately the same position. The diff tool exploits this.

### Algorithm
For each of the 14 files:
1. Compare old and new files
2. If files differ: produce a COPY/INSERT delta stream
3. Compress the stream with zlib

The COPY/INSERT stream:
- `COPY(offset, length)`: copy bytes from old file at given offset
- `INSERT(length, data)`: insert new literal bytes

Since deterministic builds sort records by content, matching records between builds are at similar positions. Insertions/deletions shift subsequent records, but zstd/COPY-INSERT handles this with sliding-window matching.

### Expected patch sizes
With ~0.3% daily change rate and deterministic ordering:
- Coordinate files (street_nodes, admin_vertices): <1% — coords barely change
- Data files (addr_points, street_ways): 1-5% — sorted by content, insertions cause local shifts
- String pool: <1% — alphabetically sorted, insertions cause small shifts
- Entry/cell files: 1-5% — derived from sorted data, shifts cascade but are localized
- Estimated total: **50-200 MiB** for 7 GiB Europe (much better than 1.9 GiB without determinism, but the exact number depends on how well the sorted order resists insertion shifts)

**Note:** If this isn't small enough, we can add string-offset remapping as a second pass (the technique proven to achieve 14 MiB). But let's measure deterministic-only first — it may be sufficient.

### Patch format (.gcpatch)
```
Header: magic "GCPATCH\0", version u32, flags u32, num_sections u32
Per section:
  file_id: u32 (which of the 14 files)
  encoding: u32 (0=full_replace, 1=copy_insert)
  old_size: u64, new_size: u64
  compressed_data_size: u64
  data: zlib-compressed COPY/INSERT ops (or full file)
End marker: file_id = 0xFFFFFFFF
```

## Part 3: Patch Tool (`geocoder-patch`)

Applies a `.gcpatch` to an existing build directory.

### Algorithm
1. Validate header + old file sizes match
2. For each section:
   - `full_replace`: decompress → write new file
   - `copy_insert`: mmap old file, replay COPY/INSERT ops → write new file
3. Output goes to a new directory (atomic — old dir untouched until verified)

The tool is simple: it replays byte-level operations. No understanding of record semantics needed.

## Part 4: Validation Plan

### Test 1: Determinism
```bash
build-index out-A/ europe-old.pbf --simplify-epsilon --epsilon-scale 1
build-index out-B/ europe-old.pbf --simplify-epsilon --epsilon-scale 1
diff -r out-A/ out-B/  # Must show no differences
```

### Test 2: Single patch round-trip
```bash
build-index build-old/ europe-old.pbf ...
build-index build-new/ europe-new.pbf ...
geocoder-diff build-old/ build-new/ -o patch_1to2.gcpatch
geocoder-patch build-old/ patch_1to2.gcpatch -o patched/
diff -r patched/ build-new/  # Must match
```

### Test 3: Sequential patches
```bash
build-index build-1/ europe-1.pbf ...
build-index build-2/ europe-2.pbf ...
build-index build-3/ europe-3.pbf ...
geocoder-diff build-1/ build-2/ -o patch_1to2.gcpatch
geocoder-diff build-2/ build-3/ -o patch_2to3.gcpatch
geocoder-patch build-1/ patch_1to2.gcpatch -o result-2/
geocoder-patch result-2/ patch_2to3.gcpatch -o result-3/
diff -r result-3/ build-3/  # Must match
```

For the third PBF: download the latest europe from Geofabrik (different date than the two we already have on node 3).

## Files to Create/Modify

### Modify
- `builder/src/string_pool.h` — no changes needed (sort happens in build_index)
- `builder/src/build_index.cpp` — add sorting after each merge phase

### Create
- `builder/tools/geocoder_diff.cpp` — diff tool (~400 lines)
- `builder/tools/geocoder_patch.cpp` — patch tool (~200 lines)
- `builder/tools/patch_format.h` — shared format definitions (already exists, update)

### Reuse
- `builder/tools/patch_format.h` — PatchFileId enum, file helpers (from current work)
- `builder/tools/geocoder_canonicalize.cpp` — sorting logic can be adapted for build_index.cpp

## Implementation Order

1. Make builds deterministic (modify build_index.cpp)
2. Validate determinism (Test 1)
3. Implement geocoder-diff with simple COPY/INSERT encoding
4. Implement geocoder-patch
5. Validate single-patch round-trip (Test 2)
6. Measure patch sizes — if >100 MiB, add string remap optimization
7. Download third PBF, validate sequential patches (Test 3)


default-src 'none'; frame-ancestors 'none'

_cfuvid=t61NMxJd7r556wVRW17l.VSDzBP1AnBURPEkdAMeI0g-1774605210.0147283-1.0.1.1-P68COp8fG_rDI5UN0oCTSH4diJpMjm5HrgOCoSt38Ls; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com

Overloaded. https://docs.claude.com/en/api/errors


_cfuvid=47qBLi8USQqubuSC4VM6YK1fROcc4b8TtBNKuJgMjYI-1774605211.2890627-1.0.1.1-.wLxhmVEDRczaP7EiRIGifQyoKMK9JvLsnP7m9v553E; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=yV4CbZpfXnyo.LiaSaYMADDREXEDf0y2pLgwScyufdY-1774605213.161809-1.0.1.1-XLWBULNE0BykYt.2vjRlEfObJ2ZbcD1qdx9mg.QQEks; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


Starting with Step 1: deterministic builds. The canonicalize tool already has the sorting logic — I'll adapt it directly into `build_index.cpp`.


  1150→            data.deferred_ways.shrink_to_fit();
  1151→            data.deferred_interps.clear();
  1152→            data.deferred_interps.shrink_to_fit();
  1153→
  1154→            std::cerr << "S2 cell computation complete." << std::endl;
  1155→        }
  1156→
  1157→        // Resolve interpolation endpoints
  1158→        std::cerr << "Resolving interpolation endpoints..." << std::endl;
  1159→        resolve_interpolation_endpoints(data);
  1160→
  1161→        // Deduplicate + convert to sorted pairs for fast writing
  1162→        log_phase("S2 cell computation", _pt, _cpu);
  1163→        std::cerr << "Deduplicating + sorting for write..." << std::endl;
  1164→        {
  1165→            // Convert addr hash map to sorted pairs.
  1166→            // Sort cell IDs only (30M unique), then build pairs in sorted order.
  1167→            // Much faster than sorting all 160M pairs.
  1168→            auto f2 = std::async(std::launch::async, [&] {
  1169→                auto _dt = std::chrono::steady_clock::now();

            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, _cpu);
        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, [&] {
                auto _dt = std::chrono::steady_clock::now();


_cfuvid=Duhede4yNppH4IhfotjDMJrk6x_XngMx0y5wCCrVQrU-1774605234.4490716-1.0.1.1-h1Zt8zh7mGTqAgLgWp5fmSbqaWJve1jSNllZ_.sktSg; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=yMydB04TTHZYlMb.I6PtRRStoVwCbOWQD2yBDNWj_xs-1774605247.6795871-1.0.1.1-WohnWx7xLCO3lT3VUxaf5gS4r0yBHG86Z6syggyTlGs; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=e7jMrILwg712c6o7o32WXAZ0G6yQsGJnyXICm8A46GU-1774605248.8502836-1.0.1.1-sCPSBqtSQI6Emv61Yi4le7KDuEeUzaU3HHJeno7roLw; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=PVXKnL9ePJN4guFa3CwXCKiGoPd_DxingCiRmHLKKlY-1774605250.6537735-1.0.1.1-zW5xHAGj6KYRIuWmepSZ5FFFREKzRcs1rcsDzRp7VFo; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=jme2aPNl3_wDel3goSVIm0yB4P5O19YMWFfzvL4jGwA-1774605253.756334-1.0.1.1-Jv0.Gv4YhmHEvOVTB9Puq2ML4OSRfMOfY_M6g5UhRII; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=VxR4lNECUkKtVn2VIwRyWu8KnL0jcTyYW4lQ2TB45wg-1774605259.3521416-1.0.1.1-9viLk5W3hOnfZe1KBwB1E_rcbquvuUhkyjDsOKtjDAg; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=22CY9SDocaIOqqEPNXFrRkAb9jaDN7s50_vRqZal_Yg-1774605268.3358996-1.0.1.1-KWuUrV9G9n9IDQHrlXgRmRu0C.PX2sKwvkaERlsuCGs; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=_dTvIM7hy0b6MWHBGagy_oWFJ9Gn5y7Sam8zhg3bd.g-1774605287.52764-1.0.1.1-W.Td.2piE3hLTPumLNuRS.FbfgblokkmG9L7ZC87C_U; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


The right insertion point for deterministic sorting is just before S2 cell computation (so that deferred_ways get correct IDs). But actually — the deferred_ways reference way_id and node_offset. If we reorder ways after the merge but before S2 computation, the deferred_ways need new IDs.

Let me look at the flow more carefully. The sorting needs to happen after all data is collected but the S2 cell computation uses `deferred_ways` which reference the current way IDs. So I need to either:
1. Sort before S2 and update deferred_ways IDs, or
2. Sort after S2 and update sorted_pairs

Option 2 is what the earlier stabilization code did (and it worked). Let me put the sorting after dedup, right before writing. This is the same location as before but with a cleaner approach that doesn't need `--previous-build`.


  1218→            });
  1219→            auto f4 = std::async(std::launch::async, [&]{ deduplicate(data.cell_to_admin); });
  1220→            f2.get(); f4.get();
  1221→        }
  1222→
  1223→        // Save cache if requested
  1224→        if (!save_cache_path.empty()) {
  1225→            serialize_cache(data, save_cache_path);
  1226→        }
  1227→    }
  1228→
  1229→    // --- Rebuild hash maps from sorted pairs if needed for cache saving ---
  1230→    // (Continent filtering now uses sorted pairs directly, so no rebuild needed for that.)
  1231→    if (!save_cache_path.empty()) {
  1232→        // Parallel rebuild: split sorted pairs into chunks, each thread builds

            });
            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 cache saving ---
    // (Continent filtering now uses sorted pairs directly, so no rebuild needed for that.)
    if (!save_cache_path.empty()) {
        // Parallel rebuild: split sorted pairs into chunks, each thread builds


_cfuvid=Zcl3V1zONW53adOfcNepFxi4Bq2s1k1hkQyQ73fYtiw-1774605339.9572093-1.0.1.1-Nqwmm0zXn_sWEDFZFCJDkGxZNMRWZPiRnF6v2VdDYHQ; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=5FPaOzd23rij3k8edRw.E8sN.RRp7HVvzC3Nqc5fk00-1774605341.2557538-1.0.1.1-aPoSW_7fmJCtCLvcwV318UclpPgp8MxUSo1EwMHGjYg; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=YPayWYhjT5GJWQjEwc4ePfRmgZLnie8QkrKbmOKkHc8-1774605343.2362401-1.0.1.1-WfEdXyA4WahDctSQ1Y1Sad22W5XV.3noQbJiqc2ss0M; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=qHCuTrVJDG7woRcu2WANYJmEeGyvFLaCFGke66O3N3w-1774605346.0922513-1.0.1.1-0hNF2fjQhgc0B.tpGxEUAcv0K0loFr7t0r1gaUV4IXI; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=52I_iHTqndsg.QMq5n4SfpDr7iCbznadeajimI0rmJ8-1774605350.8306336-1.0.1.1-JLlwC3Y9JzCinGBCoOZdcNWOZFux_58LmOlzM0cc0WA; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=SgFKajHrmsGLzJgS5yXsoJj9RZ5vgdZNBd9cTHeiLbQ-1774605361.087566-1.0.1.1-Sv_cfdgZesFywNdXx9VNx5zKLHpTH7WF1pELKPdASX8; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


  1280→
  1281→        std::cerr << "Rebuilding cell maps for continent filtering..." << std::endl;
  1282→        auto f1 = std::async(std::launch::async, [&]{ rebuild_map_parallel(data.sorted_way_cells, data.cell_to_ways); });
  1283→        auto f2 = std::async(std::launch::async, [&]{ rebuild_map_parallel(data.sorted_interp_cells, data.cell_to_interps); });
  1284→        f1.get(); f2.get();
  1285→        log_phase("Rebuild cell maps", _pt, _cpu);
  1286→    }
  1287→
  1288→    // --- Write index files ---
  1289→    log_phase("Deduplication", _pt, _cpu);
  1290→    std::cerr << "Writing index files to " << output_dir << "..." << std::endl;
  1291→
  1292→    auto quality_dir_name = [](double scale) -> std::string {
  1293→        if (scale == 0) return "uncapped";
  1294→        char buf[32]; snprintf(buf, sizeof(buf), "q%.4g", scale);

        std::cerr << "Rebuilding cell maps for continent filtering..." << std::endl;
        auto f1 = std::async(std::launch::async, [&]{ rebuild_map_parallel(data.sorted_way_cells, data.cell_to_ways); });
        auto f2 = std::async(std::launch::async, [&]{ rebuild_map_parallel(data.sorted_interp_cells, data.cell_to_interps); });
        f1.get(); f2.get();
        log_phase("Rebuild cell maps", _pt, _cpu);
    }

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

    auto quality_dir_name = [](double scale) -> std::string {
        if (scale == 0) return "uncapped";
        char buf[32]; snprintf(buf, sizeof(buf), "q%.4g", scale);


Good. I'll insert the deterministic ordering between line 1286 (rebuild cell maps) and 1288 (write). The approach:

1. Sort string pool alphabetically, remap all string refs
2. Sort addr_points by canonical key, remap sorted_addr_cells
3. Sort ways by canonical key (keeping nodes in place since node_offset is just a pointer), remap sorted_way_cells
4. Sort interps similarly
5. Sort admin polygons similarly

For ways/interps/admins, I need to also reorder the node/vertex arrays so they follow the new parent order (important for determinism — the same way must have its nodes at the same offset).


Edit

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

    // --- Deterministic ordering ---
    // Sort all data by canonical keys so that the same PBF input always produces
    // the same binary output, regardless of thread scheduling. This is required
    // for incremental patching — patches between deterministic builds are small
    // because matching records land at the same file offsets.
    {
        std::cerr << "Deterministic ordering..." << std::endl;
        auto _st = std::chrono::steady_clock::now();
        auto _sc = CpuTicks::now();

        // 1. Sort string pool alphabetically, remap all string offsets
        {
            auto& pool_data = data.string_pool.mutable_data();
            if (!pool_data.empty()) {
                // Extract strings
                std::vector<std::pair<uint32_t, const char*>> strings;
                size_t pos = 0;
                while (pos < pool_data.size()) {
                    strings.emplace_back(static_cast<uint32_t>(pos), pool_data.data() + pos);
                    pos += strlen(pool_data.data() + pos) + 1;
                }
                std::sort(strings.begin(), strings.end(), [](const auto& a, const auto& b) {
                    return strcmp(a.second, b.second) < 0;
                });

                // Build remap + new pool
                std::unordered_map<uint32_t, uint32_t> remap;
                remap.reserve(strings.size());
                std::vector<char> new_data;
                new_data.reserve(pool_data.size());
                for (auto& [old_off, str] : strings) {
                    remap[old_off] = static_cast<uint32_t>(new_data.size());
                    size_t len = strlen(str);
                    new_data.insert(new_data.end(), str, str + len + 1);
                }
                pool_data = std::move(new_data);

                // Apply remap to all data records
                const auto& rm = remap;
                for (auto& w : data.ways) w.name_id = rm.at(w.name_id);
                for (auto& a : data.addr_points) {
                    a.housenumber_id = rm.at(a.housenumber_id);
                    a.street_id = rm.at(a.street_id);
                }
                for (auto& iw : data.interp_ways) iw.street_id = rm.at(iw.street_id);
                for (auto& ap : data.admin_polygons) ap.name_id = rm.at(ap.name_id);
                std::cerr << "  String pool sorted: " << strings.size() << " strings" << std::endl;
            }
        }
        log_phase("  Sort strings", _st, _sc);

        // Helper: grid coordinate for canonical ordering
        auto to_grid = [](float v) -> int {
            return (int)(v * 1e5f + (v >= 0 ? 0.5f : -0.5f));
        };

        // 2. Sort addr_points by (street, housenumber, lat, lng)
        if (!data.sorted_addr_cells.empty()) {
            size_t n = data.addr_points.size();
            std::vector<uint32_t> order(n);
            std::iota(order.begin(), order.end(), 0);
            const auto& sp = data.string_pool.data();
            std::sort(order.begin(), order.end(), [&](uint32_t a, uint32_t b) {
                const auto& pa = data.addr_points[a];
                const auto& pb = data.addr_points[b];
                int c = strcmp(sp.data() + pa.street_id, sp.data() + pb.street_id);
                if (c != 0) return c < 0;
                c = strcmp(sp.data() + pa.housenumber_id, sp.data() + pb.housenumber_id);
                if (c != 0) return c < 0;
                int la = to_grid(pa.lat), lb = to_grid(pb.lat);
                if (la != lb) return la < lb;
                return to_grid(pa.lng) < to_grid(pb.lng);
            });
            std::vector<uint32_t> old_to_new(n);
            for (uint32_t i = 0; i < n; i++) old_to_new[order[i]] = i;
            std::vector<AddrPoint> sorted(n);
            for (uint32_t i = 0; i < n; i++) sorted[i] = data.addr_points[order[i]];
            data.addr_points = std::move(sorted);
            for (auto& p : data.sorted_addr_cells) p.item_id = old_to_new[p.item_id];
            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);
            };
            std::sort(data.sorted_addr_cells.begin(), data.sorted_addr_cells.end(), cmp);
            data.cell_to_addrs.clear();
            std::cerr << "  Addr points sorted: " << n << std::endl;
        }
        log_phase("  Sort addr_points", _st, _sc);

        // 3. Sort ways by (name, node_count, first_node) + reorder nodes
        if (!data.ways.empty()) {
            size_t n = data.ways.size();
            std::vector<uint32_t> order(n);
            std::iota(order.begin(), order.end(), 0);
            const auto& sp = data.string_pool.data();
            std::sort(order.begin(), order.end(), [&](uint32_t a, uint32_t b) {
                const auto& wa = data.ways[a];
                const auto& wb = data.ways[b];
                int c = strcmp(sp.data() + wa.name_id, sp.data() + wb.name_id);
                if (c != 0) return c < 0;
                if (wa.node_count != wb.node_count) return wa.node_count < wb.node_count;
                if (wa.node_count > 0 && wb.node_count > 0) {
                    int la = to_grid(data.street_nodes[wa.node_offset].lat);
                    int lb = to_grid(data.street_nodes[wb.node_offset].lat);
                    if (la != lb) return la < lb;
                    int ga = to_grid(data.street_nodes[wa.node_offset].lng);
                    int gb = to_grid(data.street_nodes[wb.node_offset].lng);
                    if (ga != gb) return ga < gb;
                }
                return false;
            });
            std::vector<uint32_t> old_to_new(n);
            for (uint32_t i = 0; i < n; i++) old_to_new[order[i]] = i;
            std::vector<WayHeader> new_ways(n);
            std::vector<NodeCoord> new_nodes;
            new_nodes.reserve(data.street_nodes.size());
            for (uint32_t i = 0; i < n; i++) {
                auto w = data.ways[order[i]];
                uint32_t old_off = w.node_offset;
                w.node_offset = static_cast<uint32_t>(new_nodes.size());
                for (uint8_t j = 0; j < w.node_count; j++)
                    new_nodes.push_back(data.street_nodes[old_off + j]);
                new_ways[i] = w;
            }
            data.ways = std::move(new_ways);
            data.street_nodes = std::move(new_nodes);
            for (auto& p : data.sorted_way_cells) p.item_id = old_to_new[p.item_id];
            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);
            };
            std::sort(data.sorted_way_cells.begin(), data.sorted_way_cells.end(), cmp);
            data.cell_to_ways.clear();
            std::cerr << "  Ways sorted: " << n << std::endl;
        }
        log_phase("  Sort ways", _st, _sc);

        // 4. Sort interps by (street, start, end, type) + reorder nodes
        if (!data.interp_ways.empty()) {
            size_t n = data.interp_ways.size();
            std::vector<uint32_t> order(n);
            std::iota(order.begin(), order.end(), 0);
            const auto& sp = data.string_pool.data();
            std::sort(order.begin(), order.end(), [&](uint32_t a, uint32_t b) {
                const auto& ia = data.interp_ways[a];
                const auto& ib = data.interp_ways[b];
                int c = strcmp(sp.data() + ia.street_id, sp.data() + ib.street_id);
                if (c != 0) return c < 0;
                if (ia.start_number != ib.start_number) return ia.start_number < ib.start_number;
                if (ia.end_number != ib.end_number) return ia.end_number < ib.end_number;
                return ia.interpolation < ib.interpolation;
            });
            std::vector<uint32_t> old_to_new(n);
            for (uint32_t i = 0; i < n; i++) old_to_new[order[i]] = i;
            std::vector<InterpWay> new_interps(n);
            std::vector<NodeCoord> new_nodes;
            new_nodes.reserve(data.interp_nodes.size());
            for (uint32_t i = 0; i < n; i++) {
                auto iw = data.interp_ways[order[i]];
                uint32_t old_off = iw.node_offset;
                iw.node_offset = static_cast<uint32_t>(new_nodes.size());
                for (uint8_t j = 0; j < iw.node_count; j++)
                    new_nodes.push_back(data.interp_nodes[old_off + j]);
                new_interps[i] = iw;
            }
            data.interp_ways = std::move(new_interps);
            data.interp_nodes = std::move(new_nodes);
            for (auto& p : data.sorted_interp_cells) p.item_id = old_to_new[p.item_id];
            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);
            };
            std::sort(data.sorted_interp_cells.begin(), data.sorted_interp_cells.end(), cmp);
            data.cell_to_interps.clear();
            std::cerr << "  Interps sorted: " << n << std::endl;
        }
        log_phase("  Sort interps", _st, _sc);

        // 5. Sort admin polygons by (name, level, country, vertex_count) + reorder vertices
        if (!data.admin_polygons.empty()) {
            size_t n = data.admin_polygons.size();
            std::vector<uint32_t> order(n);
            std::iota(order.begin(), order.end(), 0);
            const auto& sp = data.string_pool.data();
            std::sort(order.begin(), order.end(), [&](uint32_t a, uint32_t b) {
                const auto& pa = data.admin_polygons[a];
                const auto& pb = data.admin_polygons[b];
                int c = strcmp(sp.data() + pa.name_id, sp.data() + pb.name_id);
                if (c != 0) return c < 0;
                if (pa.admin_level != pb.admin_level) return pa.admin_level < pb.admin_level;
                if (pa.country_code != pb.country_code) return pa.country_code < pb.country_code;
                return pa.vertex_count < pb.vertex_count;
            });
            std::vector<uint32_t> old_to_new(n);
            for (uint32_t i = 0; i < n; i++) old_to_new[order[i]] = i;
            std::vector<AdminPolygon> new_polys(n);
            std::vector<NodeCoord> new_verts;
            new_verts.reserve(data.admin_vertices.size());
            for (uint32_t i = 0; i < n; i++) {
                auto p = data.admin_polygons[order[i]];
                uint32_t old_off = p.vertex_offset;
                p.vertex_offset = static_cast<uint32_t>(new_verts.size());
                for (uint32_t j = 0; j < p.vertex_count; j++)
                    new_verts.push_back(data.admin_vertices[old_off + j]);
                new_polys[i] = p;
            }
            data.admin_polygons = std::move(new_polys);
            data.admin_vertices = std::move(new_verts);
            // Remap admin cell entries
            for (auto& [cell_id, ids] : data.cell_to_admin) {
                for (auto& id : ids) {
                    uint32_t flags = id & 0x80000000u;
                    uint32_t masked = id & 0x7FFFFFFFu;
                    if (masked < old_to_new.size())
                        id = old_to_new[masked] | flags;
                }
                std::sort(ids.begin(), ids.end());
            }
            std::cerr << "  Admin polygons sorted: " << n << std::endl;
        }
        log_phase("  Sort admin", _st, _sc);
    }

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