  1300→        log_phase("Rebuild cell maps", _pt, _cpu);
  1301→    }
  1302→
  1303→    // --- Stabilize write order for patchability ---
  1304→    // Reorder data so that the same geographic data lands at the same file offset
  1305→    // across builds, making binary delta patches (zstd --patch-from) dramatically smaller.
  1306→    // Without this, PBF processing order + thread merge order creates non-deterministic
  1307→    // record positions, causing ~27% patch ratios. With stable ordering: ~2-5%.
  1308→    {
  1309→        std::cerr << "Stabilizing write order for patchability..." << std::endl;
  1310→        auto _st = std::chrono::steady_clock::now();
  1311→        auto _sc = CpuTicks::now();
  1312→
  1313→        // Step 1: Sort string pool alphabetically.
  1314→        // This ensures the same string always gets the same offset across builds.
  1315→        {
  1316→            auto& pool_data = data.string_pool.mutable_data();
  1317→            if (!pool_data.empty()) {
  1318→                // Extract all strings with current offsets
  1319→                std::vector<std::pair<uint32_t, const char*>> strings;
  1320→                size_t pos = 0;
  1321→                while (pos < pool_data.size()) {
  1322→                    strings.emplace_back(static_cast<uint32_t>(pos), pool_data.data() + pos);
  1323→                    pos += strlen(pool_data.data() + pos) + 1;
  1324→                }
  1325→
  1326→                // Sort by string content
  1327→                std::sort(strings.begin(), strings.end(), [](const auto& a, const auto& b) {
  1328→                    return strcmp(a.second, b.second) < 0;
  1329→                });
  1330→
  1331→                // Build new pool and offset mapping
  1332→                std::unordered_map<uint32_t, uint32_t> remap;
  1333→                remap.reserve(strings.size());
  1334→                std::vector<char> new_data;
  1335→                new_data.reserve(pool_data.size());
  1336→                for (auto& [old_off, str] : strings) {
  1337→                    uint32_t new_off = static_cast<uint32_t>(new_data.size());
  1338→                    remap[old_off] = new_off;
  1339→                    size_t len = strlen(str);
  1340→                    new_data.insert(new_data.end(), str, str + len + 1);
  1341→                }
  1342→
  1343→                // Apply remap to all data (parallel for large arrays)
  1344→                unsigned nthreads = std::max(1u, std::thread::hardware_concurrency());
  1345→                auto parallel_remap = [nthreads](auto* data, size_t count, auto remap_fn) {
  1346→                    if (count == 0) return;
  1347→                    size_t chunk = (count + nthreads - 1) / nthreads;
  1348→                    std::vector<std::thread> threads;
  1349→                    for (unsigned t = 0; t < nthreads; t++) {
  1350→                        size_t start = t * chunk;
  1351→                        size_t end = std::min(start + chunk, count);
  1352→                        if (start >= count) break;
  1353→                        threads.emplace_back([data, start, end, &remap_fn]() {
  1354→                            for (size_t i = start; i < end; i++) remap_fn(data[i]);
  1355→                        });
  1356→                    }
  1357→                    for (auto& t : threads) t.join();
  1358→                };
  1359→
  1360→                const auto& rm = remap; // const ref for thread-safe concurrent reads
  1361→                parallel_remap(data.ways.data(), data.ways.size(),
  1362→                    [&rm](WayHeader& w) { w.name_id = rm.find(w.name_id)->second; });
  1363→                parallel_remap(data.addr_points.data(), data.addr_points.size(),
  1364→                    [&rm](AddrPoint& a) { a.housenumber_id = rm.find(a.housenumber_id)->second; a.street_id = rm.find(a.street_id)->second; });
  1365→                parallel_remap(data.interp_ways.data(), data.interp_ways.size(),
  1366→                    [&rm](InterpWay& iw) { iw.street_id = rm.find(iw.street_id)->second; });
  1367→                parallel_remap(data.admin_polygons.data(), data.admin_polygons.size(),
  1368→                    [&rm](AdminPolygon& ap) { ap.name_id = rm.find(ap.name_id)->second; });
  1369→
  1370→                pool_data = std::move(new_data);
  1371→                std::cerr << "  String pool sorted: " << strings.size() << " strings" << std::endl;
  1372→            }
  1373→        }
  1374→        log_phase("  Stabilize: sort string pool", _st, _sc);
  1375→
  1376→        // Step 2: Reorder addr_points by S2 cell.
  1377→        // sorted_addr_cells is [(cell_id, old_addr_id), ...] sorted by cell_id.
  1378→        // Each addr maps to exactly one cell, so this is a complete permutation.
  1379→        if (!data.sorted_addr_cells.empty()) {
  1380→            std::vector<AddrPoint> new_points(data.sorted_addr_cells.size());
  1381→            for (size_t i = 0; i < data.sorted_addr_cells.size(); i++) {
  1382→                new_points[i] = data.addr_points[data.sorted_addr_cells[i].item_id];
  1383→                data.sorted_addr_cells[i].item_id = static_cast<uint32_t>(i);
  1384→            }
  1385→            data.addr_points = std::move(new_points);
  1386→            // Invalidate hash map (no longer matches new IDs)
  1387→            data.cell_to_addrs.clear();
  1388→            std::cerr << "  Addr points reordered: " << data.addr_points.size() << " points" << std::endl;
  1389→        }
  1390→        log_phase("  Stabilize: reorder addr_points", _st, _sc);
  1391→
  1392→        // Step 3: Reorder ways by primary S2 cell + name.
  1393→        // Primary cell = cell of first node. Sorting by this groups geographically
  1394→        // nearby ways together, producing stable file positions across builds.
  1395→        if (!data.ways.empty()) {
  1396→            size_t nways = data.ways.size();
  1397→
  1398→            // Compute primary cell for each way
  1399→            std::vector<uint64_t> primary_cells(nways);
  1400→            for (size_t i = 0; i < nways; i++) {
  1401→                auto& w = data.ways[i];
  1402→                if (w.node_count > 0) {
  1403→                    auto& n = data.street_nodes[w.node_offset];
  1404→                    primary_cells[i] = S2CellId(S2LatLng::FromDegrees(n.lat, n.lng)).parent(kStreetCellLevel).id();
  1405→                }
  1406→            }
  1407→
  1408→            // Build permutation: sort by (primary_cell, name)
  1409→            const auto& sp = data.string_pool.data();
  1410→            std::vector<uint32_t> order(nways);
  1411→            std::iota(order.begin(), order.end(), 0);
  1412→            std::sort(order.begin(), order.end(), [&](uint32_t a, uint32_t b) {
  1413→                if (primary_cells[a] != primary_cells[b]) return primary_cells[a] < primary_cells[b];
  1414→                return strcmp(sp.data() + data.ways[a].name_id, sp.data() + data.ways[b].name_id) < 0;
  1415→            });
  1416→
  1417→            // Build old→new mapping
  1418→            std::vector<uint32_t> old_to_new(nways);
  1419→            for (uint32_t new_id = 0; new_id < nways; new_id++)
  1420→                old_to_new[order[new_id]] = new_id;
  1421→
  1422→            // Reorder ways and nodes
  1423→            std::vector<WayHeader> new_ways(nways);
  1424→            std::vector<NodeCoord> new_nodes;
  1425→            new_nodes.reserve(data.street_nodes.size());
  1426→            for (uint32_t new_id = 0; new_id < nways; new_id++) {
  1427→                auto w = data.ways[order[new_id]];
  1428→                w.node_offset = static_cast<uint32_t>(new_nodes.size());
  1429→                for (uint8_t j = 0; j < w.node_count; j++)
  1430→                    new_nodes.push_back(data.street_nodes[data.ways[order[new_id]].node_offset + j]);
  1431→                new_ways[new_id] = w;
  1432→            }
  1433→            data.ways = std::move(new_ways);
  1434→            data.street_nodes = std::move(new_nodes);
  1435→
  1436→            // Update sorted_way_cells with new IDs
  1437→            for (auto& pair : data.sorted_way_cells)
  1438→                pair.item_id = old_to_new[pair.item_id];
  1439→            // Re-sort within each cell for deterministic entry file content
  1440→            auto cmp = [](const CellItemPair& a, const CellItemPair& b) {
  1441→                return a.cell_id < b.cell_id || (a.cell_id == b.cell_id && a.item_id < b.item_id);
  1442→            };
  1443→            std::sort(data.sorted_way_cells.begin(), data.sorted_way_cells.end(), cmp);
  1444→
  1445→            data.cell_to_ways.clear();
  1446→            std::cerr << "  Ways reordered: " << nways << " ways, "
  1447→                      << data.street_nodes.size() << " nodes" << std::endl;
  1448→        }
  1449→        log_phase("  Stabilize: reorder ways", _st, _sc);
  1450→
  1451→        // Step 4: Reorder interpolations by primary S2 cell + street name.
  1452→        if (!data.interp_ways.empty()) {
  1453→            size_t ninterps = data.interp_ways.size();
  1454→            std::vector<uint64_t> primary_cells(ninterps);
  1455→            for (size_t i = 0; i < ninterps; i++) {
  1456→                auto& iw = data.interp_ways[i];
  1457→                if (iw.node_count > 0) {
  1458→                    auto& n = data.interp_nodes[iw.node_offset];
  1459→                    primary_cells[i] = S2CellId(S2LatLng::FromDegrees(n.lat, n.lng)).parent(kStreetCellLevel).id();
  1460→                }
  1461→            }
  1462→
  1463→            const auto& sp = data.string_pool.data();
  1464→            std::vector<uint32_t> order(ninterps);
  1465→            std::iota(order.begin(), order.end(), 0);
  1466→            std::sort(order.begin(), order.end(), [&](uint32_t a, uint32_t b) {
  1467→                if (primary_cells[a] != primary_cells[b]) return primary_cells[a] < primary_cells[b];
  1468→                return strcmp(sp.data() + data.interp_ways[a].street_id,
  1469→                              sp.data() + data.interp_ways[b].street_id) < 0;
  1470→            });
  1471→
  1472→            std::vector<uint32_t> old_to_new(ninterps);
  1473→            for (uint32_t new_id = 0; new_id < ninterps; new_id++)
  1474→                old_to_new[order[new_id]] = new_id;
  1475→
  1476→            std::vector<InterpWay> new_interps(ninterps);
  1477→            std::vector<NodeCoord> new_nodes;
  1478→            new_nodes.reserve(data.interp_nodes.size());
  1479→            for (uint32_t new_id = 0; new_id < ninterps; new_id++) {

        log_phase("Rebuild cell maps", _pt, _cpu);
    }

    // --- Stabilize write order for patchability ---
    // Reorder data so that the same geographic data lands at the same file offset
    // across builds, making binary delta patches (zstd --patch-from) dramatically smaller.
    // Without this, PBF processing order + thread merge order creates non-deterministic
    // record positions, causing ~27% patch ratios. With stable ordering: ~2-5%.
    {
        std::cerr << "Stabilizing write order for patchability..." << std::endl;
        auto _st = std::chrono::steady_clock::now();
        auto _sc = CpuTicks::now();

        // Step 1: Sort string pool alphabetically.
        // This ensures the same string always gets the same offset across builds.
        {
            auto& pool_data = data.string_pool.mutable_data();
            if (!pool_data.empty()) {
                // Extract all strings with current offsets
                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;
                }

                // Sort by string content
                std::sort(strings.begin(), strings.end(), [](const auto& a, const auto& b) {
                    return strcmp(a.second, b.second) < 0;
                });

                // Build new pool and offset mapping
                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) {
                    uint32_t new_off = static_cast<uint32_t>(new_data.size());
                    remap[old_off] = new_off;
                    size_t len = strlen(str);
                    new_data.insert(new_data.end(), str, str + len + 1);
                }

                // Apply remap to all data (parallel for large arrays)
                unsigned nthreads = std::max(1u, std::thread::hardware_concurrency());
                auto parallel_remap = [nthreads](auto* data, size_t count, auto remap_fn) {
                    if (count == 0) return;
                    size_t chunk = (count + nthreads - 1) / nthreads;
                    std::vector<std::thread> threads;
                    for (unsigned t = 0; t < nthreads; t++) {
                        size_t start = t * chunk;
                        size_t end = std::min(start + chunk, count);
                        if (start >= count) break;
                        threads.emplace_back([data, start, end, &remap_fn]() {
                            for (size_t i = start; i < end; i++) remap_fn(data[i]);
                        });
                    }
                    for (auto& t : threads) t.join();
                };

                const auto& rm = remap; // const ref for thread-safe concurrent reads
                parallel_remap(data.ways.data(), data.ways.size(),
                    [&rm](WayHeader& w) { w.name_id = rm.find(w.name_id)->second; });
                parallel_remap(data.addr_points.data(), data.addr_points.size(),
                    [&rm](AddrPoint& a) { a.housenumber_id = rm.find(a.housenumber_id)->second; a.street_id = rm.find(a.street_id)->second; });
                parallel_remap(data.interp_ways.data(), data.interp_ways.size(),
                    [&rm](InterpWay& iw) { iw.street_id = rm.find(iw.street_id)->second; });
                parallel_remap(data.admin_polygons.data(), data.admin_polygons.size(),
                    [&rm](AdminPolygon& ap) { ap.name_id = rm.find(ap.name_id)->second; });

                pool_data = std::move(new_data);
                std::cerr << "  String pool sorted: " << strings.size() << " strings" << std::endl;
            }
        }
        log_phase("  Stabilize: sort string pool", _st, _sc);

        // Step 2: Reorder addr_points by S2 cell.
        // sorted_addr_cells is [(cell_id, old_addr_id), ...] sorted by cell_id.
        // Each addr maps to exactly one cell, so this is a complete permutation.
        if (!data.sorted_addr_cells.empty()) {
            std::vector<AddrPoint> new_points(data.sorted_addr_cells.size());
            for (size_t i = 0; i < data.sorted_addr_cells.size(); i++) {
                new_points[i] = data.addr_points[data.sorted_addr_cells[i].item_id];
                data.sorted_addr_cells[i].item_id = static_cast<uint32_t>(i);
            }
            data.addr_points = std::move(new_points);
            // Invalidate hash map (no longer matches new IDs)
            data.cell_to_addrs.clear();
            std::cerr << "  Addr points reordered: " << data.addr_points.size() << " points" << std::endl;
        }
        log_phase("  Stabilize: reorder addr_points", _st, _sc);

        // Step 3: Reorder ways by primary S2 cell + name.
        // Primary cell = cell of first node. Sorting by this groups geographically
        // nearby ways together, producing stable file positions across builds.
        if (!data.ways.empty()) {
            size_t nways = data.ways.size();

            // Compute primary cell for each way
            std::vector<uint64_t> primary_cells(nways);
            for (size_t i = 0; i < nways; i++) {
                auto& w = data.ways[i];
                if (w.node_count > 0) {
                    auto& n = data.street_nodes[w.node_offset];
                    primary_cells[i] = S2CellId(S2LatLng::FromDegrees(n.lat, n.lng)).parent(kStreetCellLevel).id();
                }
            }

            // Build permutation: sort by (primary_cell, name)
            const auto& sp = data.string_pool.data();
            std::vector<uint32_t> order(nways);
            std::iota(order.begin(), order.end(), 0);
            std::sort(order.begin(), order.end(), [&](uint32_t a, uint32_t b) {
                if (primary_cells[a] != primary_cells[b]) return primary_cells[a] < primary_cells[b];
                return strcmp(sp.data() + data.ways[a].name_id, sp.data() + data.ways[b].name_id) < 0;
            });

            // Build old→new mapping
            std::vector<uint32_t> old_to_new(nways);
            for (uint32_t new_id = 0; new_id < nways; new_id++)
                old_to_new[order[new_id]] = new_id;

            // Reorder ways and nodes
            std::vector<WayHeader> new_ways(nways);
            std::vector<NodeCoord> new_nodes;
            new_nodes.reserve(data.street_nodes.size());
            for (uint32_t new_id = 0; new_id < nways; new_id++) {
                auto w = data.ways[order[new_id]];
                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[data.ways[order[new_id]].node_offset + j]);
                new_ways[new_id] = w;
            }
            data.ways = std::move(new_ways);
            data.street_nodes = std::move(new_nodes);

            // Update sorted_way_cells with new IDs
            for (auto& pair : data.sorted_way_cells)
                pair.item_id = old_to_new[pair.item_id];
            // Re-sort within each cell for deterministic entry file content
            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 reordered: " << nways << " ways, "
                      << data.street_nodes.size() << " nodes" << std::endl;
        }
        log_phase("  Stabilize: reorder ways", _st, _sc);

        // Step 4: Reorder interpolations by primary S2 cell + street name.
        if (!data.interp_ways.empty()) {
            size_t ninterps = data.interp_ways.size();
            std::vector<uint64_t> primary_cells(ninterps);
            for (size_t i = 0; i < ninterps; i++) {
                auto& iw = data.interp_ways[i];
                if (iw.node_count > 0) {
                    auto& n = data.interp_nodes[iw.node_offset];
                    primary_cells[i] = S2CellId(S2LatLng::FromDegrees(n.lat, n.lng)).parent(kStreetCellLevel).id();
                }
            }

            const auto& sp = data.string_pool.data();
            std::vector<uint32_t> order(ninterps);
            std::iota(order.begin(), order.end(), 0);
            std::sort(order.begin(), order.end(), [&](uint32_t a, uint32_t b) {
                if (primary_cells[a] != primary_cells[b]) return primary_cells[a] < primary_cells[b];
                return strcmp(sp.data() + data.interp_ways[a].street_id,
                              sp.data() + data.interp_ways[b].street_id) < 0;
            });

            std::vector<uint32_t> old_to_new(ninterps);
            for (uint32_t new_id = 0; new_id < ninterps; new_id++)
                old_to_new[order[new_id]] = new_id;

            std::vector<InterpWay> new_interps(ninterps);
            std::vector<NodeCoord> new_nodes;
            new_nodes.reserve(data.interp_nodes.size());
            for (uint32_t new_id = 0; new_id < ninterps; new_id++) {


  1478→            new_nodes.reserve(data.interp_nodes.size());
  1479→            for (uint32_t new_id = 0; new_id < ninterps; new_id++) {
  1480→                auto iw = data.interp_ways[order[new_id]];
  1481→                uint32_t old_offset = iw.node_offset;
  1482→                iw.node_offset = static_cast<uint32_t>(new_nodes.size());
  1483→                for (uint8_t j = 0; j < iw.node_count; j++)
  1484→                    new_nodes.push_back(data.interp_nodes[old_offset + j]);
  1485→                new_interps[new_id] = iw;
  1486→            }
  1487→            data.interp_ways = std::move(new_interps);
  1488→            data.interp_nodes = std::move(new_nodes);
  1489→
  1490→            for (auto& pair : data.sorted_interp_cells)
  1491→                pair.item_id = old_to_new[pair.item_id];
  1492→            auto cmp = [](const CellItemPair& a, const CellItemPair& b) {
  1493→                return a.cell_id < b.cell_id || (a.cell_id == b.cell_id && a.item_id < b.item_id);
  1494→            };
  1495→            std::sort(data.sorted_interp_cells.begin(), data.sorted_interp_cells.end(), cmp);
  1496→
  1497→            data.cell_to_interps.clear();
  1498→            std::cerr << "  Interps reordered: " << ninterps << " interps" << std::endl;
  1499→        }
  1500→        log_phase("  Stabilize: reorder interps", _st, _sc);
  1501→    }
  1502→
  1503→    // --- Write index files ---
  1504→    log_phase("Deduplication + stabilization", _pt, _cpu);
  1505→    std::cerr << "Writing index files to " << output_dir << "..." << std::endl;
  1506→
  1507→    auto quality_dir_name = [](double scale) -> std::string {

            new_nodes.reserve(data.interp_nodes.size());
            for (uint32_t new_id = 0; new_id < ninterps; new_id++) {
                auto iw = data.interp_ways[order[new_id]];
                uint32_t old_offset = 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_offset + j]);
                new_interps[new_id] = iw;
            }
            data.interp_ways = std::move(new_interps);
            data.interp_nodes = std::move(new_nodes);

            for (auto& pair : data.sorted_interp_cells)
                pair.item_id = old_to_new[pair.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 reordered: " << ninterps << " interps" << std::endl;
        }
        log_phase("  Stabilize: reorder interps", _st, _sc);
    }

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

    auto quality_dir_name = [](double scale) -> std::string {


Now let me replace the entire stabilization block. The new approach:
1. **No string sorting** (handled by --previous-build instead)
2. **Reorder addr_points** by S2 cell (same as before — good for entry files)
3. **Reorder way/interp headers** by S2 cell BUT **keep node arrays in original order** (preserve natural PBF node stability)


Edit

    // --- Stabilize write order for patchability ---
    // Reorder data so that the same geographic data lands at the same file offset
    // across builds, making binary delta patches (zstd --patch-from) dramatically smaller.
    // Without this, PBF processing order + thread merge order creates non-deterministic
    // record positions, causing ~27% patch ratios. With stable ordering: ~2-5%.
    {
        std::cerr << "Stabilizing write order for patchability..." << std::endl;
        auto _st = std::chrono::steady_clock::now();
        auto _sc = CpuTicks::now();

        // Step 1: Sort string pool alphabetically.
        // This ensures the same string always gets the same offset across builds.
        {
            auto& pool_data = data.string_pool.mutable_data();
            if (!pool_data.empty()) {
                // Extract all strings with current offsets
                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;
                }

                // Sort by string content
                std::sort(strings.begin(), strings.end(), [](const auto& a, const auto& b) {
                    return strcmp(a.second, b.second) < 0;
                });

                // Build new pool and offset mapping
                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) {
                    uint32_t new_off = static_cast<uint32_t>(new_data.size());
                    remap[old_off] = new_off;
                    size_t len = strlen(str);
                    new_data.insert(new_data.end(), str, str + len + 1);
                }

                // Apply remap to all data (parallel for large arrays)
                unsigned nthreads = std::max(1u, std::thread::hardware_concurrency());
                auto parallel_remap = [nthreads](auto* data, size_t count, auto remap_fn) {
                    if (count == 0) return;
                    size_t chunk = (count + nthreads - 1) / nthreads;
                    std::vector<std::thread> threads;
                    for (unsigned t = 0; t < nthreads; t++) {
                        size_t start = t * chunk;
                        size_t end = std::min(start + chunk, count);
                        if (start >= count) break;
                        threads.emplace_back([data, start, end, &remap_fn]() {
                            for (size_t i = start; i < end; i++) remap_fn(data[i]);
                        });
                    }
                    for (auto& t : threads) t.join();
                };

                const auto& rm = remap; // const ref for thread-safe concurrent reads
                parallel_remap(data.ways.data(), data.ways.size(),
                    [&rm](WayHeader& w) { w.name_id = rm.find(w.name_id)->second; });
                parallel_remap(data.addr_points.data(), data.addr_points.size(),
                    [&rm](AddrPoint& a) { a.housenumber_id = rm.find(a.housenumber_id)->second; a.street_id = rm.find(a.street_id)->second; });
                parallel_remap(data.interp_ways.data(), data.interp_ways.size(),
                    [&rm](InterpWay& iw) { iw.street_id = rm.find(iw.street_id)->second; });
                parallel_remap(data.admin_polygons.data(), data.admin_polygons.size(),
                    [&rm](AdminPolygon& ap) { ap.name_id = rm.find(ap.name_id)->second; });

                pool_data = std::move(new_data);
                std::cerr << "  String pool sorted: " << strings.size() << " strings" << std::endl;
            }
        }
        log_phase("  Stabilize: sort string pool", _st, _sc);

        // Step 2: Reorder addr_points by S2 cell.
        // sorted_addr_cells is [(cell_id, old_addr_id), ...] sorted by cell_id.
        // Each addr maps to exactly one cell, so this is a complete permutation.
        if (!data.sorted_addr_cells.empty()) {
            std::vector<AddrPoint> new_points(data.sorted_addr_cells.size());
            for (size_t i = 0; i < data.sorted_addr_cells.size(); i++) {
                new_points[i] = data.addr_points[data.sorted_addr_cells[i].item_id];
                data.sorted_addr_cells[i].item_id = static_cast<uint32_t>(i);
            }
            data.addr_points = std::move(new_points);
            // Invalidate hash map (no longer matches new IDs)
            data.cell_to_addrs.clear();
            std::cerr << "  Addr points reordered: " << data.addr_points.size() << " points" << std::endl;
        }
        log_phase("  Stabilize: reorder addr_points", _st, _sc);

        // Step 3: Reorder ways by primary S2 cell + name.
        // Primary cell = cell of first node. Sorting by this groups geographically
        // nearby ways together, producing stable file positions across builds.
        if (!data.ways.empty()) {
            size_t nways = data.ways.size();

            // Compute primary cell for each way
            std::vector<uint64_t> primary_cells(nways);
            for (size_t i = 0; i < nways; i++) {
                auto& w = data.ways[i];
                if (w.node_count > 0) {
                    auto& n = data.street_nodes[w.node_offset];
                    primary_cells[i] = S2CellId(S2LatLng::FromDegrees(n.lat, n.lng)).parent(kStreetCellLevel).id();
                }
            }

            // Build permutation: sort by (primary_cell, name)
            const auto& sp = data.string_pool.data();
            std::vector<uint32_t> order(nways);
            std::iota(order.begin(), order.end(), 0);
            std::sort(order.begin(), order.end(), [&](uint32_t a, uint32_t b) {
                if (primary_cells[a] != primary_cells[b]) return primary_cells[a] < primary_cells[b];
                return strcmp(sp.data() + data.ways[a].name_id, sp.data() + data.ways[b].name_id) < 0;
            });

            // Build old→new mapping
            std::vector<uint32_t> old_to_new(nways);
            for (uint32_t new_id = 0; new_id < nways; new_id++)
                old_to_new[order[new_id]] = new_id;

            // Reorder ways and nodes
            std::vector<WayHeader> new_ways(nways);
            std::vector<NodeCoord> new_nodes;
            new_nodes.reserve(data.street_nodes.size());
            for (uint32_t new_id = 0; new_id < nways; new_id++) {
                auto w = data.ways[order[new_id]];
                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[data.ways[order[new_id]].node_offset + j]);
                new_ways[new_id] = w;
            }
            data.ways = std::move(new_ways);
            data.street_nodes = std::move(new_nodes);

            // Update sorted_way_cells with new IDs
            for (auto& pair : data.sorted_way_cells)
                pair.item_id = old_to_new[pair.item_id];
            // Re-sort within each cell for deterministic entry file content
            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 reordered: " << nways << " ways, "
                      << data.street_nodes.size() << " nodes" << std::endl;
        }
        log_phase("  Stabilize: reorder ways", _st, _sc);

        // Step 4: Reorder interpolations by primary S2 cell + street name.
        if (!data.interp_ways.empty()) {
            size_t ninterps = data.interp_ways.size();
            std::vector<uint64_t> primary_cells(ninterps);
            for (size_t i = 0; i < ninterps; i++) {
                auto& iw = data.interp_ways[i];
                if (iw.node_count > 0) {
                    auto& n = data.interp_nodes[iw.node_offset];
                    primary_cells[i] = S2CellId(S2LatLng::FromDegrees(n.lat, n.lng)).parent(kStreetCellLevel).id();
                }
            }

            const auto& sp = data.string_pool.data();
            std::vector<uint32_t> order(ninterps);
            std::iota(order.begin(), order.end(), 0);
            std::sort(order.begin(), order.end(), [&](uint32_t a, uint32_t b) {
                if (primary_cells[a] != primary_cells[b]) return primary_cells[a] < primary_cells[b];
                return strcmp(sp.data() + data.interp_ways[a].street_id,
                              sp.data() + data.interp_ways[b].street_id) < 0;
            });

            std::vector<uint32_t> old_to_new(ninterps);
            for (uint32_t new_id = 0; new_id < ninterps; new_id++)
                old_to_new[order[new_id]] = new_id;

            std::vector<InterpWay> new_interps(ninterps);
            std::vector<NodeCoord> new_nodes;
            new_nodes.reserve(data.interp_nodes.size());
            for (uint32_t new_id = 0; new_id < ninterps; new_id++) {
                auto iw = data.interp_ways[order[new_id]];
                uint32_t old_offset = 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_offset + j]);
                new_interps[new_id] = iw;
            }
            data.interp_ways = std::move(new_interps);
            data.interp_nodes = std::move(new_nodes);

            for (auto& pair : data.sorted_interp_cells)
                pair.item_id = old_to_new[pair.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 reordered: " << ninterps << " interps" << std::endl;
        }
        log_phase("  Stabilize: reorder interps", _st, _sc);
    }

    // --- Write index files ---
    log_phase("Deduplication + stabilization", _pt, _cpu);

    // --- Stabilize write order for patchability ---
    // Reorder data records by geographic S2 cell so that the same data
    // lands at approximately the same file offset across builds.
    // Combined with --previous-build (stable string offsets), this enables
    // efficient binary delta patches (zstd --patch-from).
    // Node coordinate arrays are NOT reordered — they naturally patch well
    // in PBF order since most coordinates don't change between builds.
    {
        std::cerr << "Stabilizing write order for patchability..." << std::endl;
        auto _st = std::chrono::steady_clock::now();
        auto _sc = CpuTicks::now();

        // Step 1: Reorder addr_points by S2 cell.
        // sorted_addr_cells is [(cell_id, old_addr_id), ...] sorted by cell_id.
        // Each addr maps to exactly one cell, so this is a complete permutation.
        if (!data.sorted_addr_cells.empty()) {
            std::vector<AddrPoint> new_points(data.sorted_addr_cells.size());
            for (size_t i = 0; i < data.sorted_addr_cells.size(); i++) {
                new_points[i] = data.addr_points[data.sorted_addr_cells[i].item_id];
                data.sorted_addr_cells[i].item_id = static_cast<uint32_t>(i);
            }
            data.addr_points = std::move(new_points);
            data.cell_to_addrs.clear();
            std::cerr << "  Addr points reordered: " << data.addr_points.size() << " points" << std::endl;
        }
        log_phase("  Stabilize: reorder addr_points", _st, _sc);

        // Step 2: Reorder way headers by primary S2 cell + name.
        // Node arrays stay in original order (PBF order is naturally stable).
        // Way headers get updated node_offsets pointing to original positions.
        if (!data.ways.empty()) {
            size_t nways = data.ways.size();

            // Compute primary cell for each way (cell of first node)
            std::vector<uint64_t> primary_cells(nways);
            for (size_t i = 0; i < nways; i++) {
                auto& w = data.ways[i];
                if (w.node_count > 0) {
                    auto& n = data.street_nodes[w.node_offset];
                    primary_cells[i] = S2CellId(S2LatLng::FromDegrees(n.lat, n.lng)).parent(kStreetCellLevel).id();
                }
            }

            // Sort by (primary_cell, name)
            const auto& sp = data.string_pool.data();
            std::vector<uint32_t> order(nways);
            std::iota(order.begin(), order.end(), 0);
            std::sort(order.begin(), order.end(), [&](uint32_t a, uint32_t b) {
                if (primary_cells[a] != primary_cells[b]) return primary_cells[a] < primary_cells[b];
                return strcmp(sp.data() + data.ways[a].name_id, sp.data() + data.ways[b].name_id) < 0;
            });

            // Build old→new mapping and reorder headers (node_offsets unchanged)
            std::vector<uint32_t> old_to_new(nways);
            std::vector<WayHeader> new_ways(nways);
            for (uint32_t new_id = 0; new_id < nways; new_id++) {
                old_to_new[order[new_id]] = new_id;
                new_ways[new_id] = data.ways[order[new_id]]; // keeps original node_offset
            }
            data.ways = std::move(new_ways);

            // Update sorted_way_cells with new IDs
            for (auto& pair : data.sorted_way_cells)
                pair.item_id = old_to_new[pair.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 reordered: " << nways << " ways (nodes kept in place)" << std::endl;
        }
        log_phase("  Stabilize: reorder ways", _st, _sc);

        // Step 3: Reorder interpolation headers (same approach, nodes stay in place).
        if (!data.interp_ways.empty()) {
            size_t ninterps = data.interp_ways.size();
            std::vector<uint64_t> primary_cells(ninterps);
            for (size_t i = 0; i < ninterps; i++) {
                auto& iw = data.interp_ways[i];
                if (iw.node_count > 0) {
                    auto& n = data.interp_nodes[iw.node_offset];
                    primary_cells[i] = S2CellId(S2LatLng::FromDegrees(n.lat, n.lng)).parent(kStreetCellLevel).id();
                }
            }

            const auto& sp = data.string_pool.data();
            std::vector<uint32_t> order(ninterps);
            std::iota(order.begin(), order.end(), 0);
            std::sort(order.begin(), order.end(), [&](uint32_t a, uint32_t b) {
                if (primary_cells[a] != primary_cells[b]) return primary_cells[a] < primary_cells[b];
                return strcmp(sp.data() + data.interp_ways[a].street_id,
                              sp.data() + data.interp_ways[b].street_id) < 0;
            });

            std::vector<uint32_t> old_to_new(ninterps);
            std::vector<InterpWay> new_interps(ninterps);
            for (uint32_t new_id = 0; new_id < ninterps; new_id++) {
                old_to_new[order[new_id]] = new_id;
                new_interps[new_id] = data.interp_ways[order[new_id]];
            }
            data.interp_ways = std::move(new_interps);

            for (auto& pair : data.sorted_interp_cells)
                pair.item_id = old_to_new[pair.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 reordered: " << ninterps << " interps (nodes kept in place)" << std::endl;
        }
        log_phase("  Stabilize: reorder interps", _st, _sc);
    }

    // --- Write index files ---
    log_phase("Deduplication + stabilization", _pt, _cpu);