    size_t old_vc = old_verts.size() / 8, new_vc = new_verts.size() / 8;
    auto poly_hash = [&](const char* p, const char* verts, size_t max_v) -> uint64_t {
        uint32_t vert_offset, vert_count, name_id;
        memcpy(&vert_offset, p, 4); memcpy(&vert_count, p + 4, 4);
        memcpy(&name_id, p + 8, 4);
        uint8_t level = (uint8_t)p[12];
        uint16_t cc; memcpy(&cc, p + stride - 2, 2);
        uint64_t h = 14695981039346656037ULL;
        h = fnv_mix(h, name_id); h = fnv_mix(h, level); h = fnv_mix(h, cc);
        h = fnv_mix(h, vert_count);
        uint32_t n = std::min(vert_count, 10u);
        for (uint32_t j = 0; j < n && (vert_offset + j) < max_v; j++) {
            float lat, lng;
            memcpy(&lat, verts + (vert_offset + j) * 8, 4);
            memcpy(&lng, verts + (vert_offset + j) * 8 + 4, 4);
            h = fnv_mix(h, to_grid(lat)); h = fnv_mix(h, to_grid(lng));
        }
        return h;
    };

    std::unordered_multimap<uint64_t, uint32_t> new_map;
    new_map.reserve(new_n);
    for (uint32_t i = 0; i < new_n; i++)
        new_map.emplace(poly_hash(new_polys.data() + i * stride, new_verts.data(), new_vc), i);

    std::vector<uint32_t> result(old_n, 0xFFFFFFFF);
    size_t matched = 0;
    for (uint32_t i = 0; i < old_n; i++) {
        uint64_t h = poly_hash(old_polys.data() + i * stride, old_verts.data(), old_vc);

// geocoder-diff: Compare two deterministic geocoder builds, produce a .gcpatch.
//
// Three-level remapping for minimal patches:
// 1. String remap: old string offsets → new string offsets
// 2. Record ID remap: match records by content, map old_id → new_id
// 3. Entry remap: apply record ID remap to entry files + rebuild cell indexes
//
// Usage: geocoder-diff <old-dir> <new-dir> -o <patch-file>

#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <functional>
#include <iomanip>
#include <iostream>
#include <string>
#include <unistd.h>
#include <unordered_map>
#include <vector>

#include "patch_format.h"

// --- String remap ---

static std::unordered_map<uint32_t, uint32_t> build_string_remap(
    const std::vector<char>& old_pool, const std::vector<char>& new_pool)
{
    std::unordered_map<std::string, uint32_t> new_idx;
    size_t pos = 0;
    while (pos < new_pool.size()) {
        const char* s = new_pool.data() + pos;
        size_t len = strlen(s);
        new_idx[std::string(s, len)] = static_cast<uint32_t>(pos);
        pos += len + 1;
    }
    std::unordered_map<uint32_t, uint32_t> remap;
    pos = 0;
    while (pos < old_pool.size()) {
        const char* s = old_pool.data() + pos;
        size_t len = strlen(s);
        auto it = new_idx.find(std::string(s, len));
        if (it != new_idx.end())
            remap[static_cast<uint32_t>(pos)] = it->second;
        pos += len + 1;
    }
    return remap;
}

// --- Remap helpers ---

static void remap_addr_points(std::vector<char>& data, const std::unordered_map<uint32_t,uint32_t>& rm) {
    for (size_t i = 0; i + 16 <= data.size(); i += 16)
        for (size_t off : {8, 12}) {
            uint32_t v; memcpy(&v, data.data() + i + off, 4);
            auto it = rm.find(v);
            if (it != rm.end()) memcpy(data.data() + i + off, &it->second, 4);
        }
}

static void remap_field(std::vector<char>& data, size_t stride, size_t field_off,
                         const std::unordered_map<uint32_t,uint32_t>& rm) {
    for (size_t i = 0; i + stride <= data.size(); i += stride) {
        uint32_t v; memcpy(&v, data.data() + i + field_off, 4);
        auto it = rm.find(v);
        if (it != rm.end()) memcpy(data.data() + i + field_off, &it->second, 4);
    }
}

// --- Content-based record matching ---
// Returns old_id → new_id mapping

static uint64_t fnv_mix(uint64_t h, uint64_t v) { h ^= v; h *= 1099511628211ULL; return h; }

static std::vector<uint32_t> match_addr_points(
    const std::vector<char>& old_data, const std::vector<char>& new_data)
{
    // After string remap, matching records are byte-identical
    size_t old_n = old_data.size() / 16, new_n = new_data.size() / 16;
    // Hash each new record
    std::unordered_multimap<uint64_t, uint32_t> new_map;
    new_map.reserve(new_n);
    for (uint32_t i = 0; i < new_n; i++) {
        uint64_t h = 14695981039346656037ULL;
        const char* p = new_data.data() + i * 16;
        for (int j = 0; j < 16; j++) h = fnv_mix(h, (uint8_t)p[j]);
        new_map.emplace(h, i);
    }
    std::vector<uint32_t> result(old_n, 0xFFFFFFFF);
    size_t matched = 0;
    for (uint32_t i = 0; i < old_n; i++) {
        uint64_t h = 14695981039346656037ULL;
        const char* p = old_data.data() + i * 16;
        for (int j = 0; j < 16; j++) h = fnv_mix(h, (uint8_t)p[j]);
        auto it = new_map.find(h);
        if (it != new_map.end() &&
            memcmp(old_data.data() + i * 16, new_data.data() + it->second * 16, 16) == 0) {
            result[i] = it->second;
            new_map.erase(it);
            matched++;
        }
    }
    std::cerr << "    addr matched=" << matched << "/" << old_n << " new=" << new_map.size() << std::endl;
    return result;
}

static std::vector<uint32_t> match_ways(
    const std::vector<char>& old_ways, const std::vector<char>& old_nodes,
    const std::vector<char>& new_ways, const std::vector<char>& new_nodes,
    size_t stride)
{
    size_t name_off = (stride == 12) ? 8 : 5;
    size_t old_n = old_ways.size() / stride, new_n = new_ways.size() / stride;

    size_t old_node_count = old_nodes.size() / 8, new_node_count = new_nodes.size() / 8;
    auto way_hash = [&](const char* w, const char* nodes, size_t max_nodes) -> uint64_t {
        uint32_t node_offset, name_id; uint8_t node_count;
        memcpy(&node_offset, w, 4); node_count = (uint8_t)w[4];
        memcpy(&name_id, w + name_off, 4);
        uint64_t h = 14695981039346656037ULL;
        h = fnv_mix(h, name_id); h = fnv_mix(h, node_count);
        for (uint8_t j = 0; j < node_count && (node_offset + j) < max_nodes; j++) {
            float lat, lng;
            memcpy(&lat, nodes + (node_offset + j) * 8, 4);
            memcpy(&lng, nodes + (node_offset + j) * 8 + 4, 4);
            h = fnv_mix(h, to_grid(lat)); h = fnv_mix(h, to_grid(lng));
        }
        return h;
    };

    std::unordered_multimap<uint64_t, uint32_t> new_map;
    new_map.reserve(new_n);
    for (uint32_t i = 0; i < new_n; i++)
        new_map.emplace(way_hash(new_ways.data() + i * stride, new_nodes.data(), new_node_count), i);

    std::vector<uint32_t> result(old_n, 0xFFFFFFFF);
    size_t matched = 0;
    for (uint32_t i = 0; i < old_n; i++) {
        uint64_t h = way_hash(old_ways.data() + i * stride, old_nodes.data(), old_node_count);
        auto it = new_map.find(h);
        if (it != new_map.end()) {
            result[i] = it->second;
            // Also fix node_offset in old to match new (makes way bytes identical)
            uint32_t new_node_off;
            memcpy(&new_node_off, new_ways.data() + it->second * stride, 4);
            memcpy(const_cast<char*>(old_ways.data()) + i * stride, &new_node_off, 4);
            new_map.erase(it);
            matched++;
        }
    }
    std::cerr << "    ways matched=" << matched << "/" << old_n << " new=" << new_map.size() << std::endl;
    return result;
}

static std::vector<uint32_t> match_admin(
    const std::vector<char>& old_polys, const std::vector<char>& old_verts,
    const std::vector<char>& new_polys, const std::vector<char>& new_verts,
    size_t stride)
{
    size_t old_n = old_polys.size() / stride, new_n = new_polys.size() / stride;

    auto poly_hash = [&](const char* p, const char* verts) -> uint64_t {
        uint32_t vert_offset, vert_count, name_id;
        memcpy(&vert_offset, p, 4); memcpy(&vert_count, p + 4, 4);
        memcpy(&name_id, p + 8, 4);
        uint8_t level = (uint8_t)p[12];
        uint16_t cc; memcpy(&cc, p + stride - 2, 2);
        uint64_t h = 14695981039346656037ULL;
        h = fnv_mix(h, name_id); h = fnv_mix(h, level); h = fnv_mix(h, cc);
        h = fnv_mix(h, vert_count);
        uint32_t n = std::min(vert_count, 10u);
        for (uint32_t j = 0; j < n; j++) {
            float lat, lng;
            memcpy(&lat, verts + (vert_offset + j) * 8, 4);
            memcpy(&lng, verts + (vert_offset + j) * 8 + 4, 4);
            h = fnv_mix(h, to_grid(lat)); h = fnv_mix(h, to_grid(lng));
        }
        return h;
    };

    std::unordered_multimap<uint64_t, uint32_t> new_map;
    new_map.reserve(new_n);
    for (uint32_t i = 0; i < new_n; i++)
        new_map.emplace(poly_hash(new_polys.data() + i * stride, new_verts.data()), i);

    std::vector<uint32_t> result(old_n, 0xFFFFFFFF);
    size_t matched = 0;
    for (uint32_t i = 0; i < old_n; i++) {
        uint64_t h = poly_hash(old_polys.data() + i * stride, old_verts.data());
        auto it = new_map.find(h);
        if (it != new_map.end()) {
            result[i] = it->second;
            uint32_t new_vert_off;
            memcpy(&new_vert_off, new_polys.data() + it->second * stride, 4);
            memcpy(const_cast<char*>(old_polys.data()) + i * stride, &new_vert_off, 4);
            new_map.erase(it);
            matched++;
        }
    }
    std::cerr << "    admin matched=" << matched << "/" << old_n << " new=" << new_map.size() << std::endl;
    return result;
}

// Match interp ways by content
static std::vector<uint32_t> match_interps(
    const std::vector<char>& old_data, const std::vector<char>& old_nodes,
    const std::vector<char>& new_data, const std::vector<char>& new_nodes,
    size_t stride)
{
    size_t street_off = (stride >= 20) ? 8 : 5;
    size_t old_n = old_data.size() / stride, new_n = new_data.size() / stride;

    size_t old_nc = old_nodes.size() / 8, new_nc = new_nodes.size() / 8;
    auto ihash = [&](const char* p, const char* nodes, size_t max_n) -> uint64_t {
        uint32_t node_offset, street_id, start, end;
        uint8_t count, itype;
        memcpy(&node_offset, p, 4); count = (uint8_t)p[4];
        memcpy(&street_id, p + street_off, 4);
        memcpy(&start, p + street_off + 4, 4);
        memcpy(&end, p + street_off + 8, 4);
        itype = (street_off + 12 < stride) ? (uint8_t)p[street_off + 12] : 0;
        uint64_t h = 14695981039346656037ULL;
        h = fnv_mix(h, street_id); h = fnv_mix(h, start);
        h = fnv_mix(h, end); h = fnv_mix(h, itype); h = fnv_mix(h, count);
        for (uint8_t j = 0; j < count && (node_offset + j) < max_n; j++) {
            float lat, lng;
            memcpy(&lat, nodes + (node_offset + j) * 8, 4);
            memcpy(&lng, nodes + (node_offset + j) * 8 + 4, 4);
            h = fnv_mix(h, to_grid(lat)); h = fnv_mix(h, to_grid(lng));
        }
        return h;
    };

    std::unordered_multimap<uint64_t, uint32_t> new_map;
    new_map.reserve(new_n);
    for (uint32_t i = 0; i < new_n; i++)
        new_map.emplace(ihash(new_data.data() + i * stride, new_nodes.data(), new_nc), i);

    std::vector<uint32_t> result(old_n, 0xFFFFFFFF);
    size_t matched = 0;
    for (uint32_t i = 0; i < old_n; i++) {
        uint64_t h = ihash(old_data.data() + i * stride, old_nodes.data(), old_nc);
        auto it = new_map.find(h);
        if (it != new_map.end()) {
            result[i] = it->second;
            uint32_t new_node_off;
            memcpy(&new_node_off, new_data.data() + it->second * stride, 4);
            memcpy(const_cast<char*>(old_data.data()) + i * stride, &new_node_off, 4);
            new_map.erase(it);
            matched++;
        }
    }
    std::cerr << "    interps matched=" << matched << "/" << old_n << " new=" << new_map.size() << std::endl;
    return result;
}

// --- Entry file remapping ---
// Parse geo_cells + entries, remap IDs, rebuild entry + cell files.

static void remap_and_rebuild_entries(
    const std::string& old_dir, const std::string& new_dir, const std::string& tmpdir,
    const std::vector<uint32_t>& way_remap,
    const std::vector<uint32_t>& addr_remap,
    const std::vector<uint32_t>& interp_remap,
    const std::vector<uint32_t>& admin_remap)
{
    // For geo entries: parse old entries, remap IDs, then copy NEW entries/cells
    // (since the new files have the correct structure for the new build).
    // We write the remapped old entries + new cells to temp for zstd diffing.

    // Simpler approach: just copy new entry/cell files to temp dir.
    // The zstd diff of old vs new entry files won't benefit from ID remapping
    // unless we actually remap the old entries. Let's do the full remap.

    auto parse_entries = [](const std::vector<char>& cells_data, size_t cell_stride, size_t off_pos,
                            const std::vector<char>& entries_data)
        -> std::unordered_map<uint64_t, std::vector<uint32_t>>
    {
        std::unordered_map<uint64_t, std::vector<uint32_t>> result;
        size_t n = cells_data.size() / cell_stride;
        for (size_t i = 0; i < n; i++) {
            uint64_t cell_id; uint32_t off;
            memcpy(&cell_id, cells_data.data() + i * cell_stride, 8);
            memcpy(&off, cells_data.data() + i * cell_stride + off_pos, 4);
            if (off == 0xFFFFFFFF || off + 2 > entries_data.size()) continue;
            uint16_t count;
            memcpy(&count, entries_data.data() + off, 2);
            if (off + 2 + count * 4 > entries_data.size()) continue;
            std::vector<uint32_t> ids(count);
            memcpy(ids.data(), entries_data.data() + off + 2, count * 4);
            result[cell_id] = std::move(ids);
        }
        return result;
    };

    auto remap_ids = [](std::unordered_map<uint64_t, std::vector<uint32_t>>& cells,
                         const std::vector<uint32_t>& id_remap, bool has_flags = false) {
        for (auto& [cid, ids] : cells) {
            for (auto& id : ids) {
                uint32_t flags = has_flags ? (id & 0x80000000u) : 0;
                uint32_t masked = id & 0x7FFFFFFFu;
                if (masked < id_remap.size() && id_remap[masked] != 0xFFFFFFFF)
                    id = id_remap[masked] | flags;
            }
            std::sort(ids.begin(), ids.end());
        }
    };

    auto write_entries_cells = [](const std::string& cells_path, const std::string& entries_path,
                                   const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map,
                                   size_t cell_stride)
    {
        std::vector<uint64_t> sorted_cells;
        for (auto& [id, _] : cell_map) sorted_cells.push_back(id);
        std::sort(sorted_cells.begin(), sorted_cells.end());

        std::vector<char> entries_buf;
        std::unordered_map<uint64_t, uint32_t> offsets;
        for (uint64_t cid : sorted_cells) {
            auto it = cell_map.find(cid);
            if (it == cell_map.end() || it->second.empty()) continue;
            offsets[cid] = static_cast<uint32_t>(entries_buf.size());
            uint16_t count = static_cast<uint16_t>(it->second.size());
            entries_buf.insert(entries_buf.end(), (char*)&count, (char*)&count + 2);
            entries_buf.insert(entries_buf.end(), (char*)it->second.data(),
                              (char*)it->second.data() + it->second.size() * 4);
        }
        write_file(entries_path, entries_buf);

        uint32_t no_data = 0xFFFFFFFF;
        std::vector<char> cells_buf;
        for (uint64_t cid : sorted_cells) {
            cells_buf.insert(cells_buf.end(), (char*)&cid, (char*)&cid + 8);
            auto it = offsets.find(cid);
            uint32_t off = (it != offsets.end()) ? it->second : no_data;
            cells_buf.insert(cells_buf.end(), (char*)&off, (char*)&off + 4);
        }
        write_file(cells_path, cells_buf);
    };

    // Geo cells: parse old, remap, merge with new cell set
    std::cerr << "  Remapping geo entries..." << std::endl;
    {
        auto old_geo = read_file(old_dir + "/geo_cells.bin");
        auto old_se = read_file(old_dir + "/street_entries.bin");
        auto old_ae = read_file(old_dir + "/addr_entries.bin");
        auto old_ie = read_file(old_dir + "/interp_entries.bin");

        auto new_geo = read_file(new_dir + "/geo_cells.bin");
        auto new_se = read_file(new_dir + "/street_entries.bin");
        auto new_ae = read_file(new_dir + "/addr_entries.bin");
        auto new_ie = read_file(new_dir + "/interp_entries.bin");

        // Parse old entries
        auto sc = parse_entries(old_geo, 20, 8, old_se);
        auto ac = parse_entries(old_geo, 20, 12, old_ae);
        auto ic = parse_entries(old_geo, 20, 16, old_ie);

        // Parse new entries (for cells that are new/changed)
        auto new_sc = parse_entries(new_geo, 20, 8, new_se);
        auto new_ac = parse_entries(new_geo, 20, 12, new_ae);
        auto new_ic = parse_entries(new_geo, 20, 16, new_ie);

        // Remap old IDs
        remap_ids(sc, way_remap);
        remap_ids(ac, addr_remap);
        remap_ids(ic, interp_remap);

        // Merge: start with remapped old, overlay with new for changed/added cells
        // For cells that exist in new but not old: add from new
        // For cells that exist in old but not new: remove
        for (auto& [cid, ids] : new_sc) sc[cid] = ids;
        for (auto& [cid, ids] : new_ac) ac[cid] = ids;
        for (auto& [cid, ids] : new_ic) ic[cid] = ids;

        // Collect all cells from new build
        std::unordered_map<uint64_t, bool> new_cells;
        size_t nn = new_geo.size() / 20;
        for (size_t i = 0; i < nn; i++) {
            uint64_t cid; memcpy(&cid, new_geo.data() + i * 20, 8);
            new_cells[cid] = true;
        }
        // Remove cells not in new
        for (auto it = sc.begin(); it != sc.end(); )
            new_cells.count(it->first) ? ++it : it = sc.erase(it);
        for (auto it = ac.begin(); it != ac.end(); )
            new_cells.count(it->first) ? ++it : it = ac.erase(it);
        for (auto it = ic.begin(); it != ic.end(); )
            new_cells.count(it->first) ? ++it : it = ic.erase(it);

        // Write merged entries + geo_cells
        // (This produces files almost identical to new build's entries)
        auto write_geo = [&](const std::string& dir,
                              const std::unordered_map<uint64_t, std::vector<uint32_t>>& s,
                              const std::unordered_map<uint64_t, std::vector<uint32_t>>& a,
                              const std::unordered_map<uint64_t, std::vector<uint32_t>>& ip) {
            // Collect all cells
            std::vector<uint64_t> all_cells;
            for (auto& [id, _] : new_cells) all_cells.push_back(id);
            std::sort(all_cells.begin(), all_cells.end());

            auto write_ent = [&](const std::string& path,
                                  const std::unordered_map<uint64_t, std::vector<uint32_t>>& cm)
                -> std::unordered_map<uint64_t, uint32_t>
            {
                std::vector<char> buf;
                std::unordered_map<uint64_t, uint32_t> offsets;
                for (uint64_t cid : all_cells) {
                    auto it = cm.find(cid);
                    if (it == cm.end() || it->second.empty()) continue;
                    offsets[cid] = static_cast<uint32_t>(buf.size());
                    uint16_t count = static_cast<uint16_t>(it->second.size());
                    buf.insert(buf.end(), (char*)&count, (char*)&count + 2);
                    buf.insert(buf.end(), (char*)it->second.data(),
                              (char*)it->second.data() + it->second.size() * 4);
                }
                write_file(path, buf);
                return offsets;
            };

            auto so = write_ent(dir + "/street_entries.bin", s);
            auto ao = write_ent(dir + "/addr_entries.bin", a);
            auto io = write_ent(dir + "/interp_entries.bin", ip);

            uint32_t no_data = 0xFFFFFFFF;
            std::vector<char> buf;
            for (uint64_t cid : all_cells) {
                buf.insert(buf.end(), (char*)&cid, (char*)&cid + 8);
                auto g = [&](const auto& m) -> uint32_t {
                    auto it = m.find(cid); return (it != m.end()) ? it->second : no_data;
                };
                uint32_t sv = g(so), av = g(ao), iv = g(io);
                buf.insert(buf.end(), (char*)&sv, (char*)&sv + 4);
                buf.insert(buf.end(), (char*)&av, (char*)&av + 4);
                buf.insert(buf.end(), (char*)&iv, (char*)&iv + 4);
            }
            write_file(dir + "/geo_cells.bin", buf);
        };

        write_geo(tmpdir, sc, ac, ic);
    }

    // Admin cells: parse, remap, rebuild
    std::cerr << "  Remapping admin entries..." << std::endl;
    {
        auto old_ac = read_file(old_dir + "/admin_cells.bin");
        auto old_ae = read_file(old_dir + "/admin_entries.bin");
        auto new_ac = read_file(new_dir + "/admin_cells.bin");
        auto new_ae = read_file(new_dir + "/admin_entries.bin");

        auto cells = parse_entries(old_ac, 12, 8, old_ae);
        auto new_cells_map = parse_entries(new_ac, 12, 8, new_ae);
        remap_ids(cells, admin_remap, true);

        // Merge with new
        for (auto& [cid, ids] : new_cells_map) cells[cid] = ids;
        // Remove cells not in new
        std::unordered_map<uint64_t, bool> new_cell_set;
        size_t nn = new_ac.size() / 12;
        for (size_t i = 0; i < nn; i++) {
            uint64_t cid; memcpy(&cid, new_ac.data() + i * 12, 8);
            new_cell_set[cid] = true;
        }
        for (auto it = cells.begin(); it != cells.end(); )
            new_cell_set.count(it->first) ? ++it : it = cells.erase(it);

        write_entries_cells(tmpdir + "/admin_cells.bin", tmpdir + "/admin_entries.bin", cells, 12);
    }
}

// --- Write helpers ---
static void write_val(std::ofstream& f, const void* data, size_t size) {
    f.write(reinterpret_cast<const char*>(data), size);
}

int main(int argc, char* argv[]) {
    if (argc < 5 || std::string(argv[3]) != "-o") {
        std::cerr << "Usage: geocoder-diff <old-dir> <new-dir> -o <patch-file>" << std::endl;
        return 1;
    }
    std::string old_dir = argv[1], new_dir = argv[2], patch_path = argv[4];
    std::string tmpdir = "/tmp/geocoder-diff-" + std::to_string(getpid());
    ensure_dir(tmpdir);

    // Build string remap
    std::cerr << "Building string remap..." << std::endl;
    auto old_strings = read_file(old_dir + "/strings.bin");
    auto new_strings = read_file(new_dir + "/strings.bin");
    auto str_remap = build_string_remap(old_strings, new_strings);
    std::cerr << "  " << str_remap.size() << " strings mapped" << std::endl;

    // Detect struct strides
    auto detect = [](const std::string& path, std::vector<size_t> cs) -> size_t {
        auto d = read_file(path);
        for (size_t s : cs) if (d.size() % s == 0 && d.size() > 0) return s;
        return cs[0];
    };
    size_t way_stride = detect(old_dir + "/street_ways.bin", {12, 9});
    size_t interp_stride = detect(old_dir + "/interp_ways.bin", {20, 18});
    size_t admin_stride = detect(old_dir + "/admin_polygons.bin", {24, 20, 19});

    // Build record ID remaps (requires loading data + coordinate files)
    std::cerr << "Building record ID remaps..." << std::endl;
    std::vector<uint32_t> addr_id_remap, way_id_remap, interp_id_remap, admin_id_remap;
    {
        // addr_points: string-remap old, then match by bytes
        auto old_ap = read_file(old_dir + "/addr_points.bin");
        remap_addr_points(old_ap, str_remap);
        auto new_ap = read_file(new_dir + "/addr_points.bin");
        addr_id_remap = match_addr_points(old_ap, new_ap);
        write_file(tmpdir + "/addr_points.bin", old_ap);
    }
    {
        // street_ways: string-remap + content match (fixes node_offset too)
        auto old_w = read_file(old_dir + "/street_ways.bin");
        remap_field(old_w, way_stride, way_stride == 12 ? 8 : 5, str_remap);
        auto old_n = read_file(old_dir + "/street_nodes.bin");
        auto new_w = read_file(new_dir + "/street_ways.bin");
        auto new_n = read_file(new_dir + "/street_nodes.bin");
        way_id_remap = match_ways(old_w, old_n, new_w, new_n, way_stride);
        write_file(tmpdir + "/street_ways.bin", old_w);
        write_file(tmpdir + "/street_nodes.bin", new_n); // use new nodes (offsets now match)
    }
    {
        auto old_i = read_file(old_dir + "/interp_ways.bin");
        remap_field(old_i, interp_stride, interp_stride >= 20 ? 8 : 5, str_remap);
        auto old_n = read_file(old_dir + "/interp_nodes.bin");
        auto new_i = read_file(new_dir + "/interp_ways.bin");
        auto new_n = read_file(new_dir + "/interp_nodes.bin");
        interp_id_remap = match_interps(old_i, old_n, new_i, new_n, interp_stride);
        write_file(tmpdir + "/interp_ways.bin", old_i);
        write_file(tmpdir + "/interp_nodes.bin", new_n);
    }
    {
        auto old_p = read_file(old_dir + "/admin_polygons.bin");
        remap_field(old_p, admin_stride, 8, str_remap);
        auto old_v = read_file(old_dir + "/admin_vertices.bin");
        auto new_p = read_file(new_dir + "/admin_polygons.bin");
        auto new_v = read_file(new_dir + "/admin_vertices.bin");
        admin_id_remap = match_admin(old_p, old_v, new_p, new_v, admin_stride);
        write_file(tmpdir + "/admin_polygons.bin", old_p);
        write_file(tmpdir + "/admin_vertices.bin", new_v);
    }

    // Rebuild entry/cell files with remapped IDs
    remap_and_rebuild_entries(old_dir, new_dir, tmpdir,
                              way_id_remap, addr_id_remap, interp_id_remap, admin_id_remap);

    // Open patch file
    std::ofstream pf(patch_path, std::ios::binary);
    pf.write(GCPATCH_MAGIC, 8);
    uint32_t ver = GCPATCH_VERSION, flags = 0;
    write_val(pf, &ver, 4); write_val(pf, &flags, 4);

    // Write string remap
    {
        uint32_t marker = 0xFFFFFFFE;
        write_val(pf, &marker, 4);
        std::vector<std::pair<uint32_t,uint32_t>> entries;
        size_t pos = 0;
        while (pos < old_strings.size()) {
            uint32_t old_off = static_cast<uint32_t>(pos);
            auto it = str_remap.find(old_off);
            entries.push_back({old_off, (it != str_remap.end()) ? it->second : old_off});
            pos += strlen(old_strings.data() + pos) + 1;
        }
        uint32_t count = static_cast<uint32_t>(entries.size());
        write_val(pf, &count, 4);
        for (auto& [o, n] : entries) { write_val(pf, &o, 4); write_val(pf, &n, 4); }
        std::cerr << "  Remap: " << count << " entries" << std::endl;
    }

    // Diff each file: use remapped temp files as reference
    auto diff_file = [&](PatchFileId id, const std::string& name, bool use_temp) {
        std::cerr << "  " << name << ": ";
        std::string ref = use_temp ? tmpdir + "/" + name : old_dir + "/" + name;
        std::string tgt = new_dir + "/" + name;
        std::string zst = tmpdir + "/" + name + ".zst";

        std::string cmd = "zstd --patch-from='" + ref + "' '" + tgt + "' -o '" + zst + "' -f --quiet 2>/dev/null";
        system(cmd.c_str());

        auto zst_data = read_file(zst);
        auto old_data = read_file(old_dir + "/" + name);
        auto new_data = read_file(tgt);

        uint32_t fid = static_cast<uint32_t>(id), enc = static_cast<uint32_t>(PatchEncoding::COPY_INSERT);
        uint64_t os = old_data.size(), ns = new_data.size(), ds = zst_data.size();
        write_val(pf, &fid, 4); write_val(pf, &enc, 4);
        write_val(pf, &os, 8); write_val(pf, &ns, 8); write_val(pf, &ds, 8);
        pf.write(zst_data.data(), zst_data.size());

        double pct = ns > 0 ? ds * 100.0 / ns : 0;
        std::cerr << ds << " bytes (" << std::fixed << std::setprecision(2) << pct << "%)" << std::endl;
        remove(zst.c_str());
    };

    diff_file(PatchFileId::STRINGS, "strings.bin", false);
    diff_file(PatchFileId::ADDR_POINTS, "addr_points.bin", true);
    diff_file(PatchFileId::STREET_WAYS, "street_ways.bin", true);
    diff_file(PatchFileId::INTERP_WAYS, "interp_ways.bin", true);
    diff_file(PatchFileId::ADMIN_POLYGONS, "admin_polygons.bin", true);
    diff_file(PatchFileId::STREET_NODES, "street_nodes.bin", true);
    diff_file(PatchFileId::INTERP_NODES, "interp_nodes.bin", true);
    diff_file(PatchFileId::ADMIN_VERTICES, "admin_vertices.bin", true);
    diff_file(PatchFileId::GEO_CELLS, "geo_cells.bin", true);
    diff_file(PatchFileId::STREET_ENTRIES, "street_entries.bin", true);
    diff_file(PatchFileId::ADDR_ENTRIES, "addr_entries.bin", true);
    diff_file(PatchFileId::INTERP_ENTRIES, "interp_entries.bin", true);
    diff_file(PatchFileId::ADMIN_CELLS, "admin_cells.bin", true);
    diff_file(PatchFileId::ADMIN_ENTRIES, "admin_entries.bin", true);

    uint32_t end = 0xFFFFFFFF;
    write_val(pf, &end, 4);

    std::string rm_cmd = "rm -rf '" + tmpdir + "'";
    system(rm_cmd.c_str());

    auto total = pf.tellp();
    std::cerr << "\nTotal patch: " << total << " bytes (" << total / 1024 / 1024 << " MiB)" << std::endl;
    return 0;
}

-    auto poly_hash = [&](const char* p, const char* verts) -> uint64_t {

+    size_t old_vc = old_verts.size() / 8, new_vc = new_verts.size() / 8;

+    auto poly_hash = [&](const char* p, const char* verts, size_t max_v) -> uint64_t {

+        for (uint32_t j = 0; j < n && (vert_offset + j) < max_v; j++) {

-        new_map.emplace(poly_hash(new_polys.data() + i * stride, new_verts.data()), i);

+        new_map.emplace(poly_hash(new_polys.data() + i * stride, new_verts.data(), new_vc), i);

-        uint64_t h = poly_hash(old_polys.data() + i * stride, old_verts.data());

+        uint64_t h = poly_hash(old_polys.data() + i * stride, old_verts.data(), old_vc);


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

_cfuvid=jhcepy8gostJn8033QOWlk2YheUCLhCwwNR_OlOdLOs-1774610731.608612-1.0.1.1-9M7sBo9at0GWgwRFzmaG_A5wlKeOe88IbAR5HVco23Y; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com

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


_cfuvid=h7RaGStV3LDGtMIp_Ll8p22.do32wWpWVira_fExKM8-1774610733.0358927-1.0.1.1-UUphpH_zHN4jrVrkxpCf9WOvzv3.Wbqg_0hMYwjpvBo; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=7nLrA7b6X9_1B6J21Sc.l1XYS11bepbs1gcY_V6Su0c-1774610735.0665636-1.0.1.1-hEA7lRaq8cv0eb5g3uVovMh6SprYZaz3pGGsFguCkvM; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=f1kxF6.g.UzA7swhkalxyUL.3spimDLQb0g6DJU2Pvw-1774610738.296982-1.0.1.1-bemQrlH7Uqg1ASoz0QsKhcVpdETqIL0V7KPKBhxph.E; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=5P828NDS0rFTQOAwCrMoa1DNhoYNy.pbxzTNlSuKx4k-1774610745.2835224-1.0.1.1-kB5sZvXlBa6GIPRRuQupA5sHTlh1glT3uhVCPFVe5A4; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=WqNv8hFkm77XTI80nhTWgHSpSrPSjN5gE5KNxhhZaYE-1774610754.70243-1.0.1.1-8OGtnm.rDb6X9AIh1XX4QD.zOm4FNzZ6VngsabnSB1M; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=y_qMvOe8ikuPjgh4aTAD0q61o1wBhC223RgNJezCsK8-1774610775.3592281-1.0.1.1-RpQ_DH5BsQUaDKWxf0Bzijunfppsn6bntEfM0P1vXtU; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


Bash

cmake --build build -j$(nproc) --target geocoder-diff 2>&1 | tail -3

Build with bounds checking


[ 50%] Building CXX object CMakeFiles/geocoder-diff.dir/tools/geocoder_diff.cpp.o
[100%] Linking CXX executable geocoder-diff
[100%] Built target geocoder-diff


sshpass -p 'elon-is-insane' scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ProxyCommand="tailscale nc %h %p" /root/traccar-geocoder/builder/build/geocoder-diff michtest@100.113.88.121:~/geocoder-diff 2>&1 && sshpass -p 'elon-is-insane' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ProxyCommand="tailscale nc %h %p" michtest@100.113.88.121 '
rm -f /home/michtest/test3.gcpatch
time /home/michtest/geocoder-diff /home/michtest/det-A /home/michtest/det-new -o /home/michtest/test3.gcpatch 2>&1
echo "Patch: $(numfmt --to=iec $(stat -c%s /home/michtest/test3.gcpatch 2>/dev/null))"
echo ""
rm -rf /home/michtest/patched3
time /home/michtest/geocoder-patch /home/michtest/det-A /home/michtest/test3.gcpatch -o /home/michtest/patched3 2>&1
echo ""
all_match=true
for f in /home/michtest/det-new/*.bin; do
    b=$(basename $f); p=/home/michtest/patched3/$b
    [ -f "$p" ] && cmp -s "$f" "$p" && echo "  $b: MATCH" || { echo "  $b: FAIL"; all_match=false; }
done
$all_match && echo "ALL FILES MATCH" || echo "SOME DIFFER"
' 2>&1

Full test with bounds-checked diff


Warning: Permanently added '100.113.88.121' (ED25519) to the list of known hosts.
Warning: Permanently added '100.113.88.121' (ED25519) to the list of known hosts.
Building string remap...
  4980078 strings mapped
Building record ID remaps...
    addr matched=85825938/85891830 new=164173
    ways matched=19372891/19452480 new=95957
    interps matched=1016/16785 new=15755
    admin matched=449642/450907 new=1222
  Remapping geo entries...
  Remapping admin entries...
  Remap: 4982052 entries
  strings.bin: 71309 bytes (0.09%)
  addr_points.bin: 3770551 bytes (0.27%)
  street_ways.bin: 1515482 bytes (0.65%)
  interp_ways.bin: 66381 bytes (19.79%)
  admin_polygons.bin: 39775 bytes (0.37%)
  street_nodes.bin: 906243 bytes (0.06%)
  interp_nodes.bin: 338 bytes (0.05%)
  admin_vertices.bin: 249709 bytes (0.09%)
  geo_cells.bin: 396021062 bytes (23.65%)
  street_entries.bin: 120945 bytes (0.02%)
  addr_entries.bin: 273895 bytes (0.07%)
  interp_entries.bin: 82 bytes (0.05%)
  admin_cells.bin: 182 bytes (0.01%)
  admin_entries.bin: 1248 bytes (0.02%)

Total patch: 442894094 bytes (422 MiB)

real	6m43.519s
user	6m8.150s
sys	0m40.889s
Patch: 423M

Loaded 4982052 string remap entries
/tmp/geocoder-patch-125869/strings.bin.zst: 82573397 bytes
  strings.bin: 82573397 bytes
/tmp/geocoder-patch-125869/addr_points.bin.zst: 1375841776 bytes
  addr_points.bin: 1375841776 bytes
/tmp/geocoder-patch-125869/street_ways.bin.zst : Decoding error (36) : Restored data doesn't match checksum
FAILED: street_ways.bin (zstd exit 256)

real	0m4.713s
user	0m3.351s
sys	0m1.618s
  addr_entries.bin: FAIL
  addr_points.bin: MATCH
  admin_cells.bin: FAIL
  admin_entries.bin: FAIL
  admin_polygons.bin: FAIL
  admin_vertices.bin: FAIL
  geo_cells.bin: FAIL
  interp_entries.bin: FAIL
  interp_nodes.bin: FAIL
  interp_ways.bin: FAIL
  street_entries.bin: FAIL
  street_nodes.bin: FAIL
  street_ways.bin: FAIL
  strings.bin: MATCH
SOME DIFFER


Good progress! Most files are now tiny, but two issues:
1. `street_ways.bin` fails checksum (patch tool doesn't apply node_offset fixes)
2. `geo_cells.bin` still 396 MiB (the entry rebuild might have a bug)

The checksum failure is because the diff creates the zstd delta against the fully-remapped old (string remap + offset fixes), but the patch tool only applies string remap. I need to include the offset fixup data in the patch.

Let me fix this by including per-file record ID remap + offset fixup tables in the patch. But first let me check if the geo_cells rebuild is correct:


sshpass -p 'elon-is-insane' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ProxyCommand="tailscale nc %h %p" michtest@100.113.88.121 '
# Check if tmpdir files from diff match new files (if tmp still exists)
# They were cleaned up. Lets re-run diff without cleanup and check.
/home/michtest/geocoder-diff /home/michtest/det-A /home/michtest/det-new -o /dev/null 2>&1 | head -20
# The tmpdir was cleaned. Let me check by comparing file sizes
echo "---"
echo "This is a dead end - lets check a simpler path"
echo "geo_cells: diff of rebuilt vs new"
# Let me just focus on what actually matters: making the patch tool work.
# For geo_cells, the biggest issue is that the entry-level rebuild
# overlay-with-new approach should produce identical files. But it doesnt.
# Quick test: just diff the new entry files directly (without remap)
for f in geo_cells.bin street_entries.bin addr_entries.bin; do
    old=/home/michtest/det-A/$f
    new=/home/michtest/det-new/$f
    zstd --patch-from=$old $new -o /tmp/${f}.zst -f --quiet 2>/dev/null
    s=$(stat -c%s /tmp/${f}.zst)
    printf "  %-25s %s\n" "$f" "$(numfmt --to=iec $s)"
    rm -f /tmp/${f}.zst
done
' 2>&1

Check geo_cells baseline


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


While that runs, let me take a step back on the approach. The two remaining issues are:

1. **geo_cells.bin (396 MiB)**: The entry rebuild should theoretically produce identical output to new, but doesn't. Since the overlay replaces all new entries, the result IS the new data. The issue must be in the cell set or write order.

2. **Patch tool needs offset fixups**: For ways/interps/admin, the diff tool fixes node_offset/vertex_offset fields, but the patch tool doesn't.

For issue 2, the cleanest fix: include per-file "fixup" sections in the patch. Each fixup = list of (record_index, byte_offset_within_record, new_4byte_value). The patch tool applies these after string remap but before using the file as zstd prefix.

Let me restructure the approach to be simpler and more robust. Instead of complex per-record remapping in the patch tool, I'll use a **two-pass delta** strategy:

- **Pass 1 delta**: transforms old file → fully-remapped file (encodes ALL transformations: string remap + offset fixes)
- **Pass 2 delta**: transforms fully-remapped file → new file (tiny, only actual data changes)

The patch tool applies pass 1 delta to old, then pass 2 delta to get new. Both are zstd frames. The pass 1 delta is small because it's a targeted field modification (only string offsets and node_offsets change). Pass 2 is tiny because the remapped file is nearly identical to new.

But this doubles the patch size. Each file would have two zstd frames instead of one.

Actually, let me take an even simpler approach: **include the fully-remapped file as the patch's reference**. Instead of encoding "how to get from old to remapped", just include the remapped file directly... but that's huge.

No. The simplest correct approach: **the diff tool writes ALL transformations into a binary "fixup" blob** per file. The patch tool applies the fixup, then the zstd delta. The fixup blob format: list of (byte_offset, old_4bytes, new_4bytes). Only changed 4-byte values are recorded.

For addr_points (86M records, ~2 changes per record for string remap): ~172M changes × 12 bytes = 2 GiB. Too large.

OK, let me just go with the original approach: include the remap table in the patch, and have the patch tool apply string + offset remaps identically to the diff tool.


     1→Warning: Permanently added '100.113.88.121' (ED25519) to the list of known hosts.
     2→


_cfuvid=Na0czbU9MHBp.G6qr1_EqRRW5KlfCfJT0uEYalq2QpI-1774611488.6405234-1.0.1.1-VT5p6v2wuWzyqxoR2ldSJJ5dp5WXQwfrgwfGh83rFQg; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=IkW1mGi75YUqO7hQWca8eX31auefz_krUc7madfK7CQ-1774611489.9950485-1.0.1.1-T0cxwi44CnP5GEAqlDz.xkhCLX8zATqrfnuDZmL3bL8; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


Still running. Let me commit the current state as a checkpoint and then focus on the patch tool fix — including ID remap tables and offset fixups.


git add tools/geocoder_diff.cpp && git commit -m "$(cat <<'EOF'
feat: add record ID remapping to geocoder-diff

Content-based matching for ways (by name + node coords), addresses
(by bytes after string remap), admin polygons (by name + level +
vertices), and interpolations. Fixes node_offset/vertex_offset
fields in matched records for optimal zstd compression.

Entry files are rebuilt from remapped IDs to produce near-zero
deltas. Results: most files patch at <1%, but geo_cells still
23% (rebuild issue) and patch tool needs offset fixup support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)"

Commit checkpoint: record ID remapping


[main 3f83b05] feat: add record ID remapping to geocoder-diff
 1 file changed, 498 insertions(+), 87 deletions(-)


Now let me update the patch tool. The core issue: the patch tool needs to apply the same transformations as the diff tool before using old files as zstd prefixes. I need to include:

1. The string remap (already there)
2. Per-file record ID remap tables (old_id → new_id for matched records)
3. The new node/vertex offset values for matched records

The most compact format: for each file that needs offset fixups, include a list of (old_record_id, new_offset_value). The patch tool:
1. Applies string remap to the old file
2. For files with fixups: applies offset_value changes at record positions
3. Uses the fully transformed file as zstd prefix

Let me add this to the patch format. First, update the diff tool to write fixup sections:


   135→    size_t stride = (data.size() % 20 == 0 && count_20 > 0) ? 20 :
   136→                    (data.size() % 18 == 0 && count_18 > 0) ? 18 : 20;
   137→    size_t count = data.size() / stride;
   138→    std::vector<PatchInterpWay> result(count);
   139→    for (size_t i = 0; i < count; i++) {
   140→        const char* p = data.data() + i * stride;
   141→        memcpy(&result[i].node_offset, p, 4); p += 4;
   142→        result[i].node_count = *reinterpret_cast<const uint8_t*>(p); p += 1;
   143→        // Skip padding to align street_id
   144→        if (stride == 20) p += 3; // 3 bytes padding after node_count
   145→        memcpy(&result[i].street_id, p, 4); p += 4;
   146→        memcpy(&result[i].start_number, p, 4); p += 4;
   147→        memcpy(&result[i].end_number, p, 4); p += 4;
   148→        result[i].interpolation = *reinterpret_cast<const uint8_t*>(p);
   149→    }
   150→    return result;
   151→}
   152→
   153→// Read AdminPolygon with field-by-field parsing
   154→inline std::vector<PatchAdminPolygon> read_admin_polygons(const std::string& path) {

    size_t stride = (data.size() % 20 == 0 && count_20 > 0) ? 20 :
                    (data.size() % 18 == 0 && count_18 > 0) ? 18 : 20;
    size_t count = data.size() / stride;
    std::vector<PatchInterpWay> result(count);
    for (size_t i = 0; i < count; i++) {
        const char* p = data.data() + i * stride;
        memcpy(&result[i].node_offset, p, 4); p += 4;
        result[i].node_count = *reinterpret_cast<const uint8_t*>(p); p += 1;
        // Skip padding to align street_id
        if (stride == 20) p += 3; // 3 bytes padding after node_count
        memcpy(&result[i].street_id, p, 4); p += 4;
        memcpy(&result[i].start_number, p, 4); p += 4;
        memcpy(&result[i].end_number, p, 4); p += 4;
        result[i].interpolation = *reinterpret_cast<const uint8_t*>(p);
    }
    return result;
}

// Read AdminPolygon with field-by-field parsing
inline std::vector<PatchAdminPolygon> read_admin_polygons(const std::string& path) {


Let me add a fixup section marker to the patch format:


Edit

// --- Grid coordinate for fingerprinting ---

// Offset fixup section marker: 0xFFFFFFFD
// Format: uint32_t marker, uint32_t file_id, uint32_t stride,
//         uint32_t count, [(uint32_t record_index, uint32_t new_offset_value)] * count
// Applied to byte offset 0 of each record (node_offset/vertex_offset field).

static constexpr uint32_t FIXUP_MARKER = 0xFFFFFFFD;

// --- Grid coordinate for fingerprinting ---


The file /root/traccar-geocoder/builder/tools/patch_format.h has been updated successfully.

// Offset fixup section marker: 0xFFFFFFFD
// Format: uint32_t marker, uint32_t file_id, uint32_t stride,
//         uint32_t count, [(uint32_t record_index, uint32_t new_offset_value)] * count
// Applied to byte offset 0 of each record (node_offset/vertex_offset field).

static constexpr uint32_t FIXUP_MARKER = 0xFFFFFFFD;

// --- Grid coordinate for fingerprinting ---

#pragma once
// Shared definitions for the geocoder patch system.
// Used by geocoder-canonicalize, geocoder-diff, and geocoder-patch.

#include <algorithm>
#include <cstdint>
#include <cstring>
#include <fstream>
#include <iostream>
#include <string>
#include <vector>

// --- Binary record structs (must match types.h and server) ---

#pragma pack(push, 1)
struct PatchWayHeader {
    uint32_t node_offset;
    uint8_t node_count;
    uint32_t name_id;
};
static_assert(sizeof(PatchWayHeader) == 9, "WayHeader must be 9 bytes packed");

struct PatchAddrPoint {
    float lat;
    float lng;
    uint32_t housenumber_id;
    uint32_t street_id;
};
static_assert(sizeof(PatchAddrPoint) == 16, "AddrPoint must be 16 bytes");

struct PatchNodeCoord {
    float lat;
    float lng;
};
static_assert(sizeof(PatchNodeCoord) == 8, "NodeCoord must be 8 bytes");
#pragma pack(pop)

// InterpWay and AdminPolygon have compiler-dependent padding.
// Read them field-by-field instead of struct-casting.

struct PatchInterpWay {
    uint32_t node_offset;
    uint8_t node_count;
    uint32_t street_id;
    uint32_t start_number;
    uint32_t end_number;
    uint8_t interpolation;
};

struct PatchAdminPolygon {
    uint32_t vertex_offset;
    uint32_t vertex_count;
    uint32_t name_id;
    uint8_t admin_level;
    float area;
    uint16_t country_code;
};

// --- File I/O helpers ---

inline std::vector<char> read_file(const std::string& path) {
    std::ifstream f(path, std::ios::binary | std::ios::ate);
    if (!f) return {};
    auto size = f.tellg();
    f.seekg(0);
    std::vector<char> data(size);
    f.read(data.data(), size);
    return data;
}

inline bool write_file(const std::string& path, const char* data, size_t size) {
    std::ofstream f(path, std::ios::binary);
    if (!f) return false;
    f.write(data, size);
    return f.good();
}

inline bool write_file(const std::string& path, const std::vector<char>& data) {
    return write_file(path, data.data(), data.size());
}

inline const char* get_string(const std::vector<char>& pool, uint32_t offset) {
    if (offset >= pool.size()) return "";
    return pool.data() + offset;
}

// Read WayHeader array — detect stride from file size vs known struct sizes
inline std::vector<PatchWayHeader> read_ways(const std::string& path) {
    auto data = read_file(path);
    // Determine stride: builder writes sizeof(WayHeader) which may be 9 or 12
    // depending on compiler padding. Try 12 first (common), then 9.
    size_t stride = 12;
    if (data.size() % 12 != 0 || data.size() / 12 == 0) {
        stride = 9;
    }
    size_t count = data.size() / stride;
    std::vector<PatchWayHeader> result(count);
    for (size_t i = 0; i < count; i++) {
        const char* p = data.data() + i * stride;
        memcpy(&result[i].node_offset, p, 4);
        result[i].node_count = static_cast<uint8_t>(p[4]);
        // name_id is at offset 5 (packed) or 8 (padded to 4-byte alignment)
        size_t name_off = (stride == 12) ? 8 : 5;
        memcpy(&result[i].name_id, p + name_off, 4);
    }
    return result;
}

inline size_t detect_way_stride(const std::string& path) {
    auto data = read_file(path);
    return (data.size() % 12 == 0 && data.size() / 12 > 0) ? 12 : 9;
}

template<typename T>
inline std::vector<T> read_structs(const std::string& path) {
    auto data = read_file(path);
    size_t count = data.size() / sizeof(T);
    std::vector<T> result(count);
    if (count > 0) memcpy(result.data(), data.data(), count * sizeof(T));
    return result;
}

// Read InterpWay with field-by-field parsing to handle padding
inline std::vector<PatchInterpWay> read_interps(const std::string& path) {
    auto data = read_file(path);
    // Determine record size from file: try sizeof(InterpWay) as compiled by the builder
    // The builder uses the same struct, so sizeof should match.
    size_t rec_size = sizeof(PatchInterpWay);
    // But the builder's InterpWay has padding. Read with the builder's size.
    // From types.h the struct is: u32 + u8 + u32 + u32 + u32 + u8 = 18 bytes raw
    // but compiler pads to 20 bytes typically. Check by trying both.
    size_t count_20 = data.size() / 20;
    size_t count_18 = data.size() / 18;
    // Use whichever divides evenly
    size_t stride = (data.size() % 20 == 0 && count_20 > 0) ? 20 :
                    (data.size() % 18 == 0 && count_18 > 0) ? 18 : 20;
    size_t count = data.size() / stride;
    std::vector<PatchInterpWay> result(count);
    for (size_t i = 0; i < count; i++) {
        const char* p = data.data() + i * stride;
        memcpy(&result[i].node_offset, p, 4); p += 4;
        result[i].node_count = *reinterpret_cast<const uint8_t*>(p); p += 1;
        // Skip padding to align street_id
        if (stride == 20) p += 3; // 3 bytes padding after node_count
        memcpy(&result[i].street_id, p, 4); p += 4;
        memcpy(&result[i].start_number, p, 4); p += 4;
        memcpy(&result[i].end_number, p, 4); p += 4;
        result[i].interpolation = *reinterpret_cast<const uint8_t*>(p);
    }
    return result;
}

// Read AdminPolygon with field-by-field parsing
inline std::vector<PatchAdminPolygon> read_admin_polygons(const std::string& path) {
    auto data = read_file(path);
    // AdminPolygon: u32 + u32 + u32 + u8 + f32 + u16 = 19 raw bytes
    // Compiler likely pads to 20 or 24 bytes. Try common sizes.
    size_t stride = 0;
    for (size_t s : {24, 20, 19}) {
        if (data.size() % s == 0 && data.size() / s > 0) { stride = s; break; }
    }
    if (stride == 0) stride = 24; // fallback
    size_t count = data.size() / stride;
    std::vector<PatchAdminPolygon> result(count);
    for (size_t i = 0; i < count; i++) {
        const char* p = data.data() + i * stride;
        memcpy(&result[i].vertex_offset, p, 4); p += 4;
        memcpy(&result[i].vertex_count, p, 4); p += 4;
        memcpy(&result[i].name_id, p, 4); p += 4;
        result[i].admin_level = *reinterpret_cast<const uint8_t*>(p); p += 1;
        // padding for area alignment
        if (stride >= 20) {
            size_t skip = (stride == 24) ? 3 : (stride == 20 ? 3 : 0);
            p += skip;
        }
        memcpy(&result[i].area, p, 4); p += 4;
        memcpy(&result[i].country_code, p, 2);
    }
    return result;
}

// --- Patch file format ---

static constexpr char GCPATCH_MAGIC[8] = {'G','C','P','A','T','C','H','\0'};
static constexpr uint32_t GCPATCH_VERSION = 1;

enum class PatchFileId : uint32_t {
    STRINGS = 0,
    STREET_WAYS = 1,
    STREET_NODES = 2,
    ADDR_POINTS = 3,
    INTERP_WAYS = 4,
    INTERP_NODES = 5,
    ADMIN_POLYGONS = 6,
    ADMIN_VERTICES = 7,
    GEO_CELLS = 8,
    STREET_ENTRIES = 9,
    ADDR_ENTRIES = 10,
    INTERP_ENTRIES = 11,
    ADMIN_CELLS = 12,
    ADMIN_ENTRIES = 13,
    COUNT = 14
};

static const char* patch_file_names[] = {
    "strings.bin", "street_ways.bin", "street_nodes.bin", "addr_points.bin",
    "interp_ways.bin", "interp_nodes.bin", "admin_polygons.bin", "admin_vertices.bin",
    "geo_cells.bin", "street_entries.bin", "addr_entries.bin", "interp_entries.bin",
    "admin_cells.bin", "admin_entries.bin"
};

// Encoding types for each section
enum class PatchEncoding : uint32_t {
    RAW_REPLACE = 0,  // Full replacement (zlib compressed)
    COPY_INSERT = 1,  // COPY/INSERT byte stream (zlib compressed)
};

// Patch file header
struct PatchHeader {
    char magic[8];
    uint32_t version;
    uint32_t flags;
    // Per-file sections follow
};

// Section header (one per file)
struct PatchSection {
    uint32_t file_id;       // PatchFileId
    uint32_t encoding;      // PatchEncoding
    uint64_t old_size;      // Expected size of old file
    uint64_t new_size;      // Size of reconstructed file
    uint64_t data_size;     // Size of compressed section data
};

// COPY_INSERT opcodes
enum : uint8_t {
    OP_COPY = 0,    // Copy from old file: [u8 tag][u64 offset][u32 length]
    OP_INSERT = 1,  // Insert new data:    [u8 tag][u32 length][data...]
};

// --- Grid coordinate for fingerprinting ---

inline int to_grid(float v) {
    return (int)(v * 1e5f + (v >= 0 ? 0.5f : -0.5f));
}

// --- Directory creation ---
inline void ensure_dir(const std::string& path) {
    std::string cmd = "mkdir -p '" + path + "'";
    system(cmd.c_str());
}

+// Offset fixup section marker: 0xFFFFFFFD

+// Format: uint32_t marker, uint32_t file_id, uint32_t stride,

+//         uint32_t count, [(uint32_t record_index, uint32_t new_offset_value)] * count

+// Applied to byte offset 0 of each record (node_offset/vertex_offset field).

+static constexpr uint32_t FIXUP_MARKER = 0xFFFFFFFD;


Now update the diff tool to write fixup sections. Instead of modifying the old data in-place (which the patch tool can't replicate), the diff tool should:
1. Write fixup sections that describe the offset changes
2. Still apply them to temp files for the zstd diff


   284→            uint64_t cell_id; uint32_t off;
   285→            memcpy(&cell_id, cells_data.data() + i * cell_stride, 8);
   286→            memcpy(&off, cells_data.data() + i * cell_stride + off_pos, 4);
   287→            if (off == 0xFFFFFFFF || off + 2 > entries_data.size()) continue;
   288→            uint16_t count;
   289→            memcpy(&count, entries_data.data() + off, 2);
   290→            if (off + 2 + count * 4 > entries_data.size()) continue;
   291→            std::vector<uint32_t> ids(count);
   292→            memcpy(ids.data(), entries_data.data() + off + 2, count * 4);
   293→            result[cell_id] = std::move(ids);
   294→        }
   295→        return result;
   296→    };
   297→
   298→    auto remap_ids = [](std::unordered_map<uint64_t, std::vector<uint32_t>>& cells,
   299→                         const std::vector<uint32_t>& id_remap, bool has_flags = false) {
   300→        for (auto& [cid, ids] : cells) {
   301→            for (auto& id : ids) {
   302→                uint32_t flags = has_flags ? (id & 0x80000000u) : 0;
   303→                uint32_t masked = id & 0x7FFFFFFFu;
   304→                if (masked < id_remap.size() && id_remap[masked] != 0xFFFFFFFF)
   305→                    id = id_remap[masked] | flags;
   306→            }
   307→            std::sort(ids.begin(), ids.end());
   308→        }
   309→    };
   310→
   311→    auto write_entries_cells = [](const std::string& cells_path, const std::string& entries_path,
   312→                                   const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map,
   313→                                   size_t cell_stride)
   314→    {
   315→        std::vector<uint64_t> sorted_cells;
   316→        for (auto& [id, _] : cell_map) sorted_cells.push_back(id);
   317→        std::sort(sorted_cells.begin(), sorted_cells.end());
   318→
   319→        std::vector<char> entries_buf;
   320→        std::unordered_map<uint64_t, uint32_t> offsets;
   321→        for (uint64_t cid : sorted_cells) {
   322→            auto it = cell_map.find(cid);
   323→            if (it == cell_map.end() || it->second.empty()) continue;
   324→            offsets[cid] = static_cast<uint32_t>(entries_buf.size());
   325→            uint16_t count = static_cast<uint16_t>(it->second.size());
   326→            entries_buf.insert(entries_buf.end(), (char*)&count, (char*)&count + 2);
   327→            entries_buf.insert(entries_buf.end(), (char*)it->second.data(),
   328→                              (char*)it->second.data() + it->second.size() * 4);
   329→        }
   330→        write_file(entries_path, entries_buf);
   331→
   332→        uint32_t no_data = 0xFFFFFFFF;
   333→        std::vector<char> cells_buf;

            uint64_t cell_id; uint32_t off;
            memcpy(&cell_id, cells_data.data() + i * cell_stride, 8);
            memcpy(&off, cells_data.data() + i * cell_stride + off_pos, 4);
            if (off == 0xFFFFFFFF || off + 2 > entries_data.size()) continue;
            uint16_t count;
            memcpy(&count, entries_data.data() + off, 2);
            if (off + 2 + count * 4 > entries_data.size()) continue;
            std::vector<uint32_t> ids(count);
            memcpy(ids.data(), entries_data.data() + off + 2, count * 4);
            result[cell_id] = std::move(ids);
        }
        return result;
    };

    auto remap_ids = [](std::unordered_map<uint64_t, std::vector<uint32_t>>& cells,
                         const std::vector<uint32_t>& id_remap, bool has_flags = false) {
        for (auto& [cid, ids] : cells) {
            for (auto& id : ids) {
                uint32_t flags = has_flags ? (id & 0x80000000u) : 0;
                uint32_t masked = id & 0x7FFFFFFFu;
                if (masked < id_remap.size() && id_remap[masked] != 0xFFFFFFFF)
                    id = id_remap[masked] | flags;
            }
            std::sort(ids.begin(), ids.end());
        }
    };

    auto write_entries_cells = [](const std::string& cells_path, const std::string& entries_path,
                                   const std::unordered_map<uint64_t, std::vector<uint32_t>>& cell_map,
                                   size_t cell_stride)
    {
        std::vector<uint64_t> sorted_cells;
        for (auto& [id, _] : cell_map) sorted_cells.push_back(id);
        std::sort(sorted_cells.begin(), sorted_cells.end());

        std::vector<char> entries_buf;
        std::unordered_map<uint64_t, uint32_t> offsets;
        for (uint64_t cid : sorted_cells) {
            auto it = cell_map.find(cid);
            if (it == cell_map.end() || it->second.empty()) continue;
            offsets[cid] = static_cast<uint32_t>(entries_buf.size());
            uint16_t count = static_cast<uint16_t>(it->second.size());
            entries_buf.insert(entries_buf.end(), (char*)&count, (char*)&count + 2);
            entries_buf.insert(entries_buf.end(), (char*)it->second.data(),
                              (char*)it->second.data() + it->second.size() * 4);
        }
        write_file(entries_path, entries_buf);

        uint32_t no_data = 0xFFFFFFFF;
        std::vector<char> cells_buf;


The approach is getting complex. Let me simplify dramatically. Instead of fixing the offset problem in the patch tool, I'll make the diff tool include the record-ID remap table directly, and the patch tool will apply it using the same logic as the diff tool. This way both tools do exactly the same transformation.

The key insight: the patch tool already has the string remap table. I just need to also include:
1. Way ID remap: `[(old_way_index, new_node_offset)]` — enough to fix each way's node_offset
2. Admin ID remap: `[(old_poly_index, new_vertex_offset)]`
3. Interp ID remap: `[(old_interp_index, new_node_offset)]`

These are compact: only matched records need entries. For 19.3M matched ways: 19.3M × 8 bytes = 154 MiB raw. That's too large.

Better: since both old and new files are in canonical order, matched records are at approximately the same index. I can encode the fixups as: the SAME data as the new file's offset field. Since both files are sorted the same way, I can just walk both and copy the offset field from new to old for matched records.

Actually, the simplest approach: the patch file includes the record ID remap tables (just old_id → new_id pairs, only for matched records), and the patch tool uses these to:
1. Look up the new record in the new file (which it has after applying the zstd delta)

Wait, that's circular. The patch tool needs the offset values BEFORE applying the delta.

OK, I'll store the fixups as compressed offset arrays. For each matched way: store the delta between old_node_offset and new_node_offset. Since canonical sort is similar between builds, consecutive deltas are small and compress well.

Actually, the SIMPLEST thing: just include the new node_offset for each way record. 19.3M × 4 bytes = 77 MiB raw. With zstd: ~20 MiB (node offsets are monotonically increasing, very compressible).

Let me implement this cleanly. Update the diff tool to write fixup sections, update the patch tool to read and apply them.


   395→        auto write_geo = [&](const std::string& dir,
   396→                              const std::unordered_map<uint64_t, std::vector<uint32_t>>& s,
   397→                              const std::unordered_map<uint64_t, std::vector<uint32_t>>& a,
   398→                              const std::unordered_map<uint64_t, std::vector<uint32_t>>& ip) {
   399→            // Collect all cells
   400→            std::vector<uint64_t> all_cells;
   401→            for (auto& [id, _] : new_cells) all_cells.push_back(id);
   402→            std::sort(all_cells.begin(), all_cells.end());
   403→
   404→            auto write_ent = [&](const std::string& path,
   405→                                  const std::unordered_map<uint64_t, std::vector<uint32_t>>& cm)
   406→                -> std::unordered_map<uint64_t, uint32_t>
   407→            {
   408→                std::vector<char> buf;
   409→                std::unordered_map<uint64_t, uint32_t> offsets;
   410→                for (uint64_t cid : all_cells) {
   411→                    auto it = cm.find(cid);
   412→                    if (it == cm.end() || it->second.empty()) continue;
   413→                    offsets[cid] = static_cast<uint32_t>(buf.size());
   414→                    uint16_t count = static_cast<uint16_t>(it->second.size());
   415→                    buf.insert(buf.end(), (char*)&count, (char*)&count + 2);
   416→                    buf.insert(buf.end(), (char*)it->second.data(),
   417→                              (char*)it->second.data() + it->second.size() * 4);
   418→                }
   419→                write_file(path, buf);
   420→                return offsets;
   421→            };
   422→
   423→            auto so = write_ent(dir + "/street_entries.bin", s);
   424→            auto ao = write_ent(dir + "/addr_entries.bin", a);
   425→            auto io = write_ent(dir + "/interp_entries.bin", ip);
   426→
   427→            uint32_t no_data = 0xFFFFFFFF;
   428→            std::vector<char> buf;
   429→            for (uint64_t cid : all_cells) {
   430→                buf.insert(buf.end(), (char*)&cid, (char*)&cid + 8);
   431→                auto g = [&](const auto& m) -> uint32_t {
   432→                    auto it = m.find(cid); return (it != m.end()) ? it->second : no_data;
   433→                };
   434→                uint32_t sv = g(so), av = g(ao), iv = g(io);
   435→                buf.insert(buf.end(), (char*)&sv, (char*)&sv + 4);
   436→                buf.insert(buf.end(), (char*)&av, (char*)&av + 4);
   437→                buf.insert(buf.end(), (char*)&iv, (char*)&iv + 4);
   438→            }
   439→            write_file(dir + "/geo_cells.bin", buf);
   440→        };
   441→
   442→        write_geo(tmpdir, sc, ac, ic);
   443→    }
   444→
   445→    // Admin cells: parse, remap, rebuild
   446→    std::cerr << "  Remapping admin entries..." << std::endl;
   447→    {
   448→        auto old_ac = read_file(old_dir + "/admin_cells.bin");
   449→        auto old_ae = read_file(old_dir + "/admin_entries.bin");
   450→        auto new_ac = read_file(new_dir + "/admin_cells.bin");
   451→        auto new_ae = read_file(new_dir + "/admin_entries.bin");
   452→
   453→        auto cells = parse_entries(old_ac, 12, 8, old_ae);
   454→        auto new_cells_map = parse_entries(new_ac, 12, 8, new_ae);

        auto write_geo = [&](const std::string& dir,
                              const std::unordered_map<uint64_t, std::vector<uint32_t>>& s,
                              const std::unordered_map<uint64_t, std::vector<uint32_t>>& a,
                              const std::unordered_map<uint64_t, std::vector<uint32_t>>& ip) {
            // Collect all cells
            std::vector<uint64_t> all_cells;
            for (auto& [id, _] : new_cells) all_cells.push_back(id);
            std::sort(all_cells.begin(), all_cells.end());

            auto write_ent = [&](const std::string& path,
                                  const std::unordered_map<uint64_t, std::vector<uint32_t>>& cm)
                -> std::unordered_map<uint64_t, uint32_t>
            {
                std::vector<char> buf;
                std::unordered_map<uint64_t, uint32_t> offsets;
                for (uint64_t cid : all_cells) {
                    auto it = cm.find(cid);
                    if (it == cm.end() || it->second.empty()) continue;
                    offsets[cid] = static_cast<uint32_t>(buf.size());
                    uint16_t count = static_cast<uint16_t>(it->second.size());
                    buf.insert(buf.end(), (char*)&count, (char*)&count + 2);
                    buf.insert(buf.end(), (char*)it->second.data(),
                              (char*)it->second.data() + it->second.size() * 4);
                }
                write_file(path, buf);
                return offsets;
            };

            auto so = write_ent(dir + "/street_entries.bin", s);
            auto ao = write_ent(dir + "/addr_entries.bin", a);
            auto io = write_ent(dir + "/interp_entries.bin", ip);

            uint32_t no_data = 0xFFFFFFFF;
            std::vector<char> buf;
            for (uint64_t cid : all_cells) {
                buf.insert(buf.end(), (char*)&cid, (char*)&cid + 8);
                auto g = [&](const auto& m) -> uint32_t {
                    auto it = m.find(cid); return (it != m.end()) ? it->second : no_data;
                };
                uint32_t sv = g(so), av = g(ao), iv = g(io);
                buf.insert(buf.end(), (char*)&sv, (char*)&sv + 4);
                buf.insert(buf.end(), (char*)&av, (char*)&av + 4);
                buf.insert(buf.end(), (char*)&iv, (char*)&iv + 4);
            }
            write_file(dir + "/geo_cells.bin", buf);
        };

        write_geo(tmpdir, sc, ac, ic);
    }

    // Admin cells: parse, remap, rebuild
    std::cerr << "  Remapping admin entries..." << std::endl;
    {
        auto old_ac = read_file(old_dir + "/admin_cells.bin");
        auto old_ae = read_file(old_dir + "/admin_entries.bin");
        auto new_ac = read_file(new_dir + "/admin_cells.bin");
        auto new_ae = read_file(new_dir + "/admin_entries.bin");

        auto cells = parse_entries(old_ac, 12, 8, old_ae);
        auto new_cells_map = parse_entries(new_ac, 12, 8, new_ae);


_cfuvid=RKS7Ls29u8bLsV_FNb0EcLhgxcEMYYVoGBnlRWZhQPE-1774611566.6959033-1.0.1.1-5PiY0V9v4ZsK3XSYdcQodNSyBhcMn7bFai8l5eAKAd4; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=CKc9l7bcea1e8abFawfxbylneAGLzKI43SA7K5vOKC0-1774611569.0335732-1.0.1.1-ng8PhxB3k31h5QvdgqS3XqsYp.CFvbeghIAnx0tGG9Y; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


Let me take a completely different approach for the patch tool. Instead of having the patch tool replicate the complex remap logic, I'll make the **diff tool write TWO zstd frames per file**:

1. **Frame 1**: diff of `old_file` → `remapped_old_file` (encodes string remap + offset fixups)
2. **Frame 2**: diff of `remapped_old_file` → `new_file` (tiny, only actual data changes)

The patch tool applies them in sequence:
1. Apply Frame 1 to old → get remapped_old
2. Apply Frame 2 to remapped_old → get new

This avoids the patch tool needing to understand record structures at all. It just applies two zstd frames. The total size is Frame1 + Frame2, which should be small because:
- Frame 1 encodes targeted field changes (string offsets + node_offsets)
- Frame 2 encodes ~0.3% actual data changes


// Encoding types for each section
enum class PatchEncoding : uint32_t {
    RAW_REPLACE = 0,  // Full replacement (zlib compressed)
    COPY_INSERT = 1,  // COPY/INSERT byte stream (zlib compressed)
};

// Encoding types for each section
enum class PatchEncoding : uint32_t {
    RAW_REPLACE = 0,  // Full replacement (zlib compressed)
    COPY_INSERT = 1,  // Single zstd delta frame (zstd --patch-from)
    TWO_STAGE = 2,    // Two zstd delta frames: old→remapped, remapped→new
};


// Encoding types for each section
enum class PatchEncoding : uint32_t {
    RAW_REPLACE = 0,  // Full replacement (zlib compressed)
    COPY_INSERT = 1,  // Single zstd delta frame (zstd --patch-from)
    TWO_STAGE = 2,    // Two zstd delta frames: old→remapped, remapped→new
};

#pragma once
// Shared definitions for the geocoder patch system.
// Used by geocoder-canonicalize, geocoder-diff, and geocoder-patch.

#include <algorithm>
#include <cstdint>
#include <cstring>
#include <fstream>
#include <iostream>
#include <string>
#include <vector>

// --- Binary record structs (must match types.h and server) ---

#pragma pack(push, 1)
struct PatchWayHeader {
    uint32_t node_offset;
    uint8_t node_count;
    uint32_t name_id;
};
static_assert(sizeof(PatchWayHeader) == 9, "WayHeader must be 9 bytes packed");

struct PatchAddrPoint {
    float lat;
    float lng;
    uint32_t housenumber_id;
    uint32_t street_id;
};
static_assert(sizeof(PatchAddrPoint) == 16, "AddrPoint must be 16 bytes");

struct PatchNodeCoord {
    float lat;
    float lng;
};
static_assert(sizeof(PatchNodeCoord) == 8, "NodeCoord must be 8 bytes");
#pragma pack(pop)

// InterpWay and AdminPolygon have compiler-dependent padding.
// Read them field-by-field instead of struct-casting.

struct PatchInterpWay {
    uint32_t node_offset;
    uint8_t node_count;
    uint32_t street_id;
    uint32_t start_number;
    uint32_t end_number;
    uint8_t interpolation;
};

struct PatchAdminPolygon {
    uint32_t vertex_offset;
    uint32_t vertex_count;
    uint32_t name_id;
    uint8_t admin_level;
    float area;
    uint16_t country_code;
};

// --- File I/O helpers ---

inline std::vector<char> read_file(const std::string& path) {
    std::ifstream f(path, std::ios::binary | std::ios::ate);
    if (!f) return {};
    auto size = f.tellg();
    f.seekg(0);
    std::vector<char> data(size);
    f.read(data.data(), size);
    return data;
}

inline bool write_file(const std::string& path, const char* data, size_t size) {
    std::ofstream f(path, std::ios::binary);
    if (!f) return false;
    f.write(data, size);
    return f.good();
}

inline bool write_file(const std::string& path, const std::vector<char>& data) {
    return write_file(path, data.data(), data.size());
}

inline const char* get_string(const std::vector<char>& pool, uint32_t offset) {
    if (offset >= pool.size()) return "";
    return pool.data() + offset;
}

// Read WayHeader array — detect stride from file size vs known struct sizes
inline std::vector<PatchWayHeader> read_ways(const std::string& path) {
    auto data = read_file(path);
    // Determine stride: builder writes sizeof(WayHeader) which may be 9 or 12
    // depending on compiler padding. Try 12 first (common), then 9.
    size_t stride = 12;
    if (data.size() % 12 != 0 || data.size() / 12 == 0) {
        stride = 9;
    }
    size_t count = data.size() / stride;
    std::vector<PatchWayHeader> result(count);
    for (size_t i = 0; i < count; i++) {
        const char* p = data.data() + i * stride;
        memcpy(&result[i].node_offset, p, 4);
        result[i].node_count = static_cast<uint8_t>(p[4]);
        // name_id is at offset 5 (packed) or 8 (padded to 4-byte alignment)
        size_t name_off = (stride == 12) ? 8 : 5;
        memcpy(&result[i].name_id, p + name_off, 4);
    }
    return result;
}

inline size_t detect_way_stride(const std::string& path) {
    auto data = read_file(path);
    return (data.size() % 12 == 0 && data.size() / 12 > 0) ? 12 : 9;
}

template<typename T>
inline std::vector<T> read_structs(const std::string& path) {
    auto data = read_file(path);
    size_t count = data.size() / sizeof(T);
    std::vector<T> result(count);
    if (count > 0) memcpy(result.data(), data.data(), count * sizeof(T));
    return result;
}

// Read InterpWay with field-by-field parsing to handle padding
inline std::vector<PatchInterpWay> read_interps(const std::string& path) {
    auto data = read_file(path);
    // Determine record size from file: try sizeof(InterpWay) as compiled by the builder
    // The builder uses the same struct, so sizeof should match.
    size_t rec_size = sizeof(PatchInterpWay);
    // But the builder's InterpWay has padding. Read with the builder's size.
    // From types.h the struct is: u32 + u8 + u32 + u32 + u32 + u8 = 18 bytes raw
    // but compiler pads to 20 bytes typically. Check by trying both.
    size_t count_20 = data.size() / 20;
    size_t count_18 = data.size() / 18;
    // Use whichever divides evenly
    size_t stride = (data.size() % 20 == 0 && count_20 > 0) ? 20 :
                    (data.size() % 18 == 0 && count_18 > 0) ? 18 : 20;
    size_t count = data.size() / stride;
    std::vector<PatchInterpWay> result(count);
    for (size_t i = 0; i < count; i++) {
        const char* p = data.data() + i * stride;
        memcpy(&result[i].node_offset, p, 4); p += 4;
        result[i].node_count = *reinterpret_cast<const uint8_t*>(p); p += 1;
        // Skip padding to align street_id
        if (stride == 20) p += 3; // 3 bytes padding after node_count
        memcpy(&result[i].street_id, p, 4); p += 4;
        memcpy(&result[i].start_number, p, 4); p += 4;
        memcpy(&result[i].end_number, p, 4); p += 4;
        result[i].interpolation = *reinterpret_cast<const uint8_t*>(p);
    }
    return result;
}

// Read AdminPolygon with field-by-field parsing
inline std::vector<PatchAdminPolygon> read_admin_polygons(const std::string& path) {
    auto data = read_file(path);
    // AdminPolygon: u32 + u32 + u32 + u8 + f32 + u16 = 19 raw bytes
    // Compiler likely pads to 20 or 24 bytes. Try common sizes.
    size_t stride = 0;
    for (size_t s : {24, 20, 19}) {
        if (data.size() % s == 0 && data.size() / s > 0) { stride = s; break; }
    }
    if (stride == 0) stride = 24; // fallback
    size_t count = data.size() / stride;
    std::vector<PatchAdminPolygon> result(count);
    for (size_t i = 0; i < count; i++) {
        const char* p = data.data() + i * stride;
        memcpy(&result[i].vertex_offset, p, 4); p += 4;
        memcpy(&result[i].vertex_count, p, 4); p += 4;
        memcpy(&result[i].name_id, p, 4); p += 4;
        result[i].admin_level = *reinterpret_cast<const uint8_t*>(p); p += 1;
        // padding for area alignment
        if (stride >= 20) {
            size_t skip = (stride == 24) ? 3 : (stride == 20 ? 3 : 0);
            p += skip;
        }
        memcpy(&result[i].area, p, 4); p += 4;
        memcpy(&result[i].country_code, p, 2);
    }
    return result;
}

// --- Patch file format ---

static constexpr char GCPATCH_MAGIC[8] = {'G','C','P','A','T','C','H','\0'};
static constexpr uint32_t GCPATCH_VERSION = 1;

enum class PatchFileId : uint32_t {
    STRINGS = 0,
    STREET_WAYS = 1,
    STREET_NODES = 2,
    ADDR_POINTS = 3,
    INTERP_WAYS = 4,
    INTERP_NODES = 5,
    ADMIN_POLYGONS = 6,
    ADMIN_VERTICES = 7,
    GEO_CELLS = 8,
    STREET_ENTRIES = 9,
    ADDR_ENTRIES = 10,
    INTERP_ENTRIES = 11,
    ADMIN_CELLS = 12,
    ADMIN_ENTRIES = 13,
    COUNT = 14
};

static const char* patch_file_names[] = {
    "strings.bin", "street_ways.bin", "street_nodes.bin", "addr_points.bin",
    "interp_ways.bin", "interp_nodes.bin", "admin_polygons.bin", "admin_vertices.bin",
    "geo_cells.bin", "street_entries.bin", "addr_entries.bin", "interp_entries.bin",
    "admin_cells.bin", "admin_entries.bin"
};

// Encoding types for each section
enum class PatchEncoding : uint32_t {
    RAW_REPLACE = 0,  // Full replacement (zlib compressed)
    COPY_INSERT = 1,  // COPY/INSERT byte stream (zlib compressed)
};

// Patch file header
struct PatchHeader {
    char magic[8];
    uint32_t version;
    uint32_t flags;
    // Per-file sections follow
};

// Section header (one per file)
struct PatchSection {
    uint32_t file_id;       // PatchFileId
    uint32_t encoding;      // PatchEncoding
    uint64_t old_size;      // Expected size of old file
    uint64_t new_size;      // Size of reconstructed file
    uint64_t data_size;     // Size of compressed section data
};

// COPY_INSERT opcodes
enum : uint8_t {
    OP_COPY = 0,    // Copy from old file: [u8 tag][u64 offset][u32 length]
    OP_INSERT = 1,  // Insert new data:    [u8 tag][u32 length][data...]
};

// Offset fixup section marker: 0xFFFFFFFD
// Format: uint32_t marker, uint32_t file_id, uint32_t stride,
//         uint32_t count, [(uint32_t record_index, uint32_t new_offset_value)] * count
// Applied to byte offset 0 of each record (node_offset/vertex_offset field).

static constexpr uint32_t FIXUP_MARKER = 0xFFFFFFFD;

// --- Grid coordinate for fingerprinting ---

inline int to_grid(float v) {
    return (int)(v * 1e5f + (v >= 0 ? 0.5f : -0.5f));
}

// --- Directory creation ---
inline void ensure_dir(const std::string& path) {
    std::string cmd = "mkdir -p '" + path + "'";
    system(cmd.c_str());
}

-    COPY_INSERT = 1,  // COPY/INSERT byte stream (zlib compressed)

+    COPY_INSERT = 1,  // Single zstd delta frame (zstd --patch-from)

+    TWO_STAGE = 2,    // Two zstd delta frames: old→remapped, remapped→new


Now rewrite the diff tool's `diff_file` to use two-stage encoding when remapping is needed:


   472→
   473→// --- Write helpers ---
   474→static void write_val(std::ofstream& f, const void* data, size_t size) {
   475→    f.write(reinterpret_cast<const char*>(data), size);
   476→}
   477→
   478→int main(int argc, char* argv[]) {
   479→    if (argc < 5 || std::string(argv[3]) != "-o") {
   480→        std::cerr << "Usage: geocoder-diff <old-dir> <new-dir> -o <patch-file>" << std::endl;
   481→        return 1;
   482→    }
   483→    std::string old_dir = argv[1], new_dir = argv[2], patch_path = argv[4];
   484→    std::string tmpdir = "/tmp/geocoder-diff-" + std::to_string(getpid());
   485→    ensure_dir(tmpdir);
   486→
   487→    // Build string remap
   488→    std::cerr << "Building string remap..." << std::endl;
   489→    auto old_strings = read_file(old_dir + "/strings.bin");
   490→    auto new_strings = read_file(new_dir + "/strings.bin");
   491→    auto str_remap = build_string_remap(old_strings, new_strings);
   492→    std::cerr << "  " << str_remap.size() << " strings mapped" << std::endl;
   493→
   494→    // Detect struct strides
   495→    auto detect = [](const std::string& path, std::vector<size_t> cs) -> size_t {
   496→        auto d = read_file(path);
   497→        for (size_t s : cs) if (d.size() % s == 0 && d.size() > 0) return s;
   498→        return cs[0];
   499→    };
   500→    size_t way_stride = detect(old_dir + "/street_ways.bin", {12, 9});
   501→    size_t interp_stride = detect(old_dir + "/interp_ways.bin", {20, 18});
   502→    size_t admin_stride = detect(old_dir + "/admin_polygons.bin", {24, 20, 19});
   503→
   504→    // Build record ID remaps (requires loading data + coordinate files)
   505→    std::cerr << "Building record ID remaps..." << std::endl;
   506→    std::vector<uint32_t> addr_id_remap, way_id_remap, interp_id_remap, admin_id_remap;
   507→    {
   508→        // addr_points: string-remap old, then match by bytes
   509→        auto old_ap = read_file(old_dir + "/addr_points.bin");
   510→        remap_addr_points(old_ap, str_remap);
   511→        auto new_ap = read_file(new_dir + "/addr_points.bin");
   512→        addr_id_remap = match_addr_points(old_ap, new_ap);
   513→        write_file(tmpdir + "/addr_points.bin", old_ap);
   514→    }
   515→    {
   516→        // street_ways: string-remap + content match (fixes node_offset too)
   517→        auto old_w = read_file(old_dir + "/street_ways.bin");
   518→        remap_field(old_w, way_stride, way_stride == 12 ? 8 : 5, str_remap);
   519→        auto old_n = read_file(old_dir + "/street_nodes.bin");
   520→        auto new_w = read_file(new_dir + "/street_ways.bin");
   521→        auto new_n = read_file(new_dir + "/street_nodes.bin");

// --- Write helpers ---
static void write_val(std::ofstream& f, const void* data, size_t size) {
    f.write(reinterpret_cast<const char*>(data), size);
}

int main(int argc, char* argv[]) {
    if (argc < 5 || std::string(argv[3]) != "-o") {
        std::cerr << "Usage: geocoder-diff <old-dir> <new-dir> -o <patch-file>" << std::endl;
        return 1;
    }
    std::string old_dir = argv[1], new_dir = argv[2], patch_path = argv[4];
    std::string tmpdir = "/tmp/geocoder-diff-" + std::to_string(getpid());
    ensure_dir(tmpdir);

    // Build string remap
    std::cerr << "Building string remap..." << std::endl;
    auto old_strings = read_file(old_dir + "/strings.bin");
    auto new_strings = read_file(new_dir + "/strings.bin");
    auto str_remap = build_string_remap(old_strings, new_strings);
    std::cerr << "  " << str_remap.size() << " strings mapped" << std::endl;

    // Detect struct strides
    auto detect = [](const std::string& path, std::vector<size_t> cs) -> size_t {
        auto d = read_file(path);
        for (size_t s : cs) if (d.size() % s == 0 && d.size() > 0) return s;
        return cs[0];
    };
    size_t way_stride = detect(old_dir + "/street_ways.bin", {12, 9});
    size_t interp_stride = detect(old_dir + "/interp_ways.bin", {20, 18});
    size_t admin_stride = detect(old_dir + "/admin_polygons.bin", {24, 20, 19});

    // Build record ID remaps (requires loading data + coordinate files)
    std::cerr << "Building record ID remaps..." << std::endl;
    std::vector<uint32_t> addr_id_remap, way_id_remap, interp_id_remap, admin_id_remap;
    {
        // addr_points: string-remap old, then match by bytes
        auto old_ap = read_file(old_dir + "/addr_points.bin");
        remap_addr_points(old_ap, str_remap);
        auto new_ap = read_file(new_dir + "/addr_points.bin");
        addr_id_remap = match_addr_points(old_ap, new_ap);
        write_file(tmpdir + "/addr_points.bin", old_ap);
    }
    {
        // street_ways: string-remap + content match (fixes node_offset too)
        auto old_w = read_file(old_dir + "/street_ways.bin");
        remap_field(old_w, way_stride, way_stride == 12 ? 8 : 5, str_remap);
        auto old_n = read_file(old_dir + "/street_nodes.bin");
        auto new_w = read_file(new_dir + "/street_ways.bin");
        auto new_n = read_file(new_dir + "/street_nodes.bin");


Let me now replace the diff_file function and the file diffing section to use two-stage encoding:


   545→    }
   546→
   547→    // Rebuild entry/cell files with remapped IDs
   548→    remap_and_rebuild_entries(old_dir, new_dir, tmpdir,
   549→                              way_id_remap, addr_id_remap, interp_id_remap, admin_id_remap);
   550→
   551→    // Open patch file
   552→    std::ofstream pf(patch_path, std::ios::binary);
   553→    pf.write(GCPATCH_MAGIC, 8);
   554→    uint32_t ver = GCPATCH_VERSION, flags = 0;
   555→    write_val(pf, &ver, 4); write_val(pf, &flags, 4);
   556→
   557→    // Write string remap
   558→    {
   559→        uint32_t marker = 0xFFFFFFFE;
   560→        write_val(pf, &marker, 4);
   561→        std::vector<std::pair<uint32_t,uint32_t>> entries;
   562→        size_t pos = 0;
   563→        while (pos < old_strings.size()) {
   564→            uint32_t old_off = static_cast<uint32_t>(pos);
   565→            auto it = str_remap.find(old_off);
   566→            entries.push_back({old_off, (it != str_remap.end()) ? it->second : old_off});
   567→            pos += strlen(old_strings.data() + pos) + 1;
   568→        }
   569→        uint32_t count = static_cast<uint32_t>(entries.size());
   570→        write_val(pf, &count, 4);
   571→        for (auto& [o, n] : entries) { write_val(pf, &o, 4); write_val(pf, &n, 4); }
   572→        std::cerr << "  Remap: " << count << " entries" << std::endl;
   573→    }
   574→
   575→    // Diff each file: use remapped temp files as reference
   576→    auto diff_file = [&](PatchFileId id, const std::string& name, bool use_temp) {
   577→        std::cerr << "  " << name << ": ";
   578→        std::string ref = use_temp ? tmpdir + "/" + name : old_dir + "/" + name;
   579→        std::string tgt = new_dir + "/" + name;
   580→        std::string zst = tmpdir + "/" + name + ".zst";
   581→
   582→        std::string cmd = "zstd --patch-from='" + ref + "' '" + tgt + "' -o '" + zst + "' -f --quiet 2>/dev/null";
   583→        system(cmd.c_str());
   584→
   585→        auto zst_data = read_file(zst);
   586→        auto old_data = read_file(old_dir + "/" + name);
   587→        auto new_data = read_file(tgt);
   588→
   589→        uint32_t fid = static_cast<uint32_t>(id), enc = static_cast<uint32_t>(PatchEncoding::COPY_INSERT);
   590→        uint64_t os = old_data.size(), ns = new_data.size(), ds = zst_data.size();
   591→        write_val(pf, &fid, 4); write_val(pf, &enc, 4);
   592→        write_val(pf, &os, 8); write_val(pf, &ns, 8); write_val(pf, &ds, 8);
   593→        pf.write(zst_data.data(), zst_data.size());
   594→
   595→        double pct = ns > 0 ? ds * 100.0 / ns : 0;
   596→        std::cerr << ds << " bytes (" << std::fixed << std::setprecision(2) << pct << "%)" << std::endl;
   597→        remove(zst.c_str());
   598→    };
   599→
   600→    diff_file(PatchFileId::STRINGS, "strings.bin", false);
   601→    diff_file(PatchFileId::ADDR_POINTS, "addr_points.bin", true);
   602→    diff_file(PatchFileId::STREET_WAYS, "street_ways.bin", true);
   603→    diff_file(PatchFileId::INTERP_WAYS, "interp_ways.bin", true);
   604→    diff_file(PatchFileId::ADMIN_POLYGONS, "admin_polygons.bin", true);

    }

    // Rebuild entry/cell files with remapped IDs
    remap_and_rebuild_entries(old_dir, new_dir, tmpdir,
                              way_id_remap, addr_id_remap, interp_id_remap, admin_id_remap);

    // Open patch file
    std::ofstream pf(patch_path, std::ios::binary);
    pf.write(GCPATCH_MAGIC, 8);
    uint32_t ver = GCPATCH_VERSION, flags = 0;
    write_val(pf, &ver, 4); write_val(pf, &flags, 4);

    // Write string remap
    {
        uint32_t marker = 0xFFFFFFFE;
        write_val(pf, &marker, 4);
        std::vector<std::pair<uint32_t,uint32_t>> entries;
        size_t pos = 0;
        while (pos < old_strings.size()) {
            uint32_t old_off = static_cast<uint32_t>(pos);
            auto it = str_remap.find(old_off);
            entries.push_back({old_off, (it != str_remap.end()) ? it->second : old_off});
            pos += strlen(old_strings.data() + pos) + 1;
        }
        uint32_t count = static_cast<uint32_t>(entries.size());
        write_val(pf, &count, 4);
        for (auto& [o, n] : entries) { write_val(pf, &o, 4); write_val(pf, &n, 4); }
        std::cerr << "  Remap: " << count << " entries" << std::endl;
    }

    // Diff each file: use remapped temp files as reference
    auto diff_file = [&](PatchFileId id, const std::string& name, bool use_temp) {
        std::cerr << "  " << name << ": ";
        std::string ref = use_temp ? tmpdir + "/" + name : old_dir + "/" + name;
        std::string tgt = new_dir + "/" + name;
        std::string zst = tmpdir + "/" + name + ".zst";

        std::string cmd = "zstd --patch-from='" + ref + "' '" + tgt + "' -o '" + zst + "' -f --quiet 2>/dev/null";
        system(cmd.c_str());

        auto zst_data = read_file(zst);
        auto old_data = read_file(old_dir + "/" + name);
        auto new_data = read_file(tgt);

        uint32_t fid = static_cast<uint32_t>(id), enc = static_cast<uint32_t>(PatchEncoding::COPY_INSERT);
        uint64_t os = old_data.size(), ns = new_data.size(), ds = zst_data.size();
        write_val(pf, &fid, 4); write_val(pf, &enc, 4);
        write_val(pf, &os, 8); write_val(pf, &ns, 8); write_val(pf, &ds, 8);
        pf.write(zst_data.data(), zst_data.size());

        double pct = ns > 0 ? ds * 100.0 / ns : 0;
        std::cerr << ds << " bytes (" << std::fixed << std::setprecision(2) << pct << "%)" << std::endl;
        remove(zst.c_str());
    };

    diff_file(PatchFileId::STRINGS, "strings.bin", false);
    diff_file(PatchFileId::ADDR_POINTS, "addr_points.bin", true);
    diff_file(PatchFileId::STREET_WAYS, "street_ways.bin", true);
    diff_file(PatchFileId::INTERP_WAYS, "interp_ways.bin", true);
    diff_file(PatchFileId::ADMIN_POLYGONS, "admin_polygons.bin", true);


Now let me replace the diff_file function to support two-stage encoding. For files that have a remapped temp version, it writes TWO zstd frames: old→temp (stage1) and temp→new (stage2):


    // Diff each file: use remapped temp files as reference
    auto diff_file = [&](PatchFileId id, const std::string& name, bool use_temp) {
        std::cerr << "  " << name << ": ";
        std::string ref = use_temp ? tmpdir + "/" + name : old_dir + "/" + name;
        std::string tgt = new_dir + "/" + name;
        std::string zst = tmpdir + "/" + name + ".zst";

        std::string cmd = "zstd --patch-from='" + ref + "' '" + tgt + "' -o '" + zst + "' -f --quiet 2>/dev/null";
        system(cmd.c_str());

        auto zst_data = read_file(zst);
        auto old_data = read_file(old_dir + "/" + name);
        auto new_data = read_file(tgt);

        uint32_t fid = static_cast<uint32_t>(id), enc = static_cast<uint32_t>(PatchEncoding::COPY_INSERT);
        uint64_t os = old_data.size(), ns = new_data.size(), ds = zst_data.size();
        write_val(pf, &fid, 4); write_val(pf, &enc, 4);
        write_val(pf, &os, 8); write_val(pf, &ns, 8); write_val(pf, &ds, 8);
        pf.write(zst_data.data(), zst_data.size());

        double pct = ns > 0 ? ds * 100.0 / ns : 0;
        std::cerr << ds << " bytes (" << std::fixed << std::setprecision(2) << pct << "%)" << std::endl;
        remove(zst.c_str());
    };

    diff_file(PatchFileId::STRINGS, "strings.bin", false);
    diff_file(PatchFileId::ADDR_POINTS, "addr_points.bin", true);
    diff_file(PatchFileId::STREET_WAYS, "street_ways.bin", true);
    diff_file(PatchFileId::INTERP_WAYS, "interp_ways.bin", true);
    diff_file(PatchFileId::ADMIN_POLYGONS, "admin_polygons.bin", true);

    std::function<void(std::vector<char>&)> no_remap;
    diff_file(PatchFileId::STREET_NODES, "street_nodes.bin", true);
    diff_file(PatchFileId::INTERP_NODES, "interp_nodes.bin", true);
    diff_file(PatchFileId::ADMIN_VERTICES, "admin_vertices.bin", true);
    diff_file(PatchFileId::GEO_CELLS, "geo_cells.bin", true);
    diff_file(PatchFileId::STREET_ENTRIES, "street_entries.bin", true);
    diff_file(PatchFileId::ADDR_ENTRIES, "addr_entries.bin", true);
    diff_file(PatchFileId::INTERP_ENTRIES, "interp_entries.bin", true);
    diff_file(PatchFileId::ADMIN_CELLS, "admin_cells.bin", true);
    diff_file(PatchFileId::ADMIN_ENTRIES, "admin_entries.bin", true);

    // Diff each file using two-stage encoding:
    // Stage 1: old → remapped_old (string remap + offset fixes)
    // Stage 2: remapped_old → new (only actual data changes)
    // Patch tool applies both stages in sequence.

    auto diff_file = [&](PatchFileId id, const std::string& name, bool has_remapped) {
        std::cerr << "  " << name << ": ";
        std::string old_path = old_dir + "/" + name;
        std::string new_path = new_dir + "/" + name;
        std::string temp_path = tmpdir + "/" + name;

        auto old_data = read_file(old_path);
        auto new_data = read_file(new_path);

        if (has_remapped) {
            // Two-stage: old→remapped (stage1), remapped→new (stage2)
            std::string zst1 = tmpdir + "/" + name + ".s1.zst";
            std::string zst2 = tmpdir + "/" + name + ".s2.zst";

            std::string cmd1 = "zstd --patch-from='" + old_path + "' '" + temp_path +
                               "' -o '" + zst1 + "' -f --quiet 2>/dev/null";
            std::string cmd2 = "zstd --patch-from='" + temp_path + "' '" + new_path +
                               "' -o '" + zst2 + "' -f --quiet 2>/dev/null";
            system(cmd1.c_str());
            system(cmd2.c_str());

            auto s1 = read_file(zst1);
            auto s2 = read_file(zst2);
            auto remapped = read_file(temp_path);

            uint32_t fid = static_cast<uint32_t>(id);
            uint32_t enc = static_cast<uint32_t>(PatchEncoding::TWO_STAGE);
            write_val(pf, &fid, 4); write_val(pf, &enc, 4);
            uint64_t os = old_data.size(), rs = remapped.size(), ns = new_data.size();
            uint64_t s1s = s1.size(), s2s = s2.size();
            write_val(pf, &os, 8); write_val(pf, &rs, 8); write_val(pf, &ns, 8);
            write_val(pf, &s1s, 8); write_val(pf, &s2s, 8);
            pf.write(s1.data(), s1.size());
            pf.write(s2.data(), s2.size());

            double pct = ns > 0 ? (s1s + s2s) * 100.0 / ns : 0;
            std::cerr << "stage1=" << s1s << " stage2=" << s2s
                      << " total=" << (s1s + s2s) << " (" << std::fixed
                      << std::setprecision(2) << pct << "%)" << std::endl;

            remove(zst1.c_str()); remove(zst2.c_str());
        } else {
            // Single stage: direct old→new
            std::string zst = tmpdir + "/" + name + ".zst";
            std::string cmd = "zstd --patch-from='" + old_path + "' '" + new_path +
                              "' -o '" + zst + "' -f --quiet 2>/dev/null";
            system(cmd.c_str());
            auto zst_data = read_file(zst);

            uint32_t fid = static_cast<uint32_t>(id);
            uint32_t enc = static_cast<uint32_t>(PatchEncoding::COPY_INSERT);
            uint64_t os = old_data.size(), ns = new_data.size(), ds = zst_data.size();
            write_val(pf, &fid, 4); write_val(pf, &enc, 4);
            write_val(pf, &os, 8); write_val(pf, &ns, 8); write_val(pf, &ds, 8);
            pf.write(zst_data.data(), zst_data.size());

            double pct = ns > 0 ? ds * 100.0 / ns : 0;
            std::cerr << ds << " bytes (" << std::fixed << std::setprecision(2) << pct << "%)" << std::endl;
            remove(zst.c_str());
        }
    };

    // Files with remapped temp versions (two-stage)
    diff_file(PatchFileId::ADDR_POINTS, "addr_points.bin", true);
    diff_file(PatchFileId::STREET_WAYS, "street_ways.bin", true);
    diff_file(PatchFileId::INTERP_WAYS, "interp_ways.bin", true);
    diff_file(PatchFileId::ADMIN_POLYGONS, "admin_polygons.bin", true);
    diff_file(PatchFileId::STREET_NODES, "street_nodes.bin", true);
    diff_file(PatchFileId::INTERP_NODES, "interp_nodes.bin", true);
    diff_file(PatchFileId::ADMIN_VERTICES, "admin_vertices.bin", true);
    diff_file(PatchFileId::GEO_CELLS, "geo_cells.bin", true);
    diff_file(PatchFileId::STREET_ENTRIES, "street_entries.bin", true);
    diff_file(PatchFileId::ADDR_ENTRIES, "addr_entries.bin", true);
    diff_file(PatchFileId::INTERP_ENTRIES, "interp_entries.bin", true);
    diff_file(PatchFileId::ADMIN_CELLS, "admin_cells.bin", true);
    diff_file(PatchFileId::ADMIN_ENTRIES, "admin_entries.bin", true);

    // strings.bin: direct (no remap needed, it IS the string data)
    diff_file(PatchFileId::STRINGS, "strings.bin", false);


<tool_use_error>String to replace not found in file.
String:     // Diff each file: use remapped temp files as reference
    auto diff_file = [&](PatchFileId id, const std::string& name, bool use_temp) {
        std::cerr << "  " << name << ": ";
        std::string ref = use_temp ? tmpdir + "/" + name : old_dir + "/" + name;
        std::string tgt = new_dir + "/" + name;
        std::string zst = tmpdir + "/" + name + ".zst";

        std::string cmd = "zstd --patch-from='" + ref + "' '" + tgt + "' -o '" + zst + "' -f --quiet 2>/dev/null";
        system(cmd.c_str());

        auto zst_data = read_file(zst);
        auto old_data = read_file(old_dir + "/" + name);
        auto new_data = read_file(tgt);

        uint32_t fid = static_cast<uint32_t>(id), enc = static_cast<uint32_t>(PatchEncoding::COPY_INSERT);
        uint64_t os = old_data.size(), ns = new_data.size(), ds = zst_data.size();
        write_val(pf, &fid, 4); write_val(pf, &enc, 4);
        write_val(pf, &os, 8); write_val(pf, &ns, 8); write_val(pf, &ds, 8);
        pf.write(zst_data.data(), zst_data.size());

        double pct = ns > 0 ? ds * 100.0 / ns : 0;
        std::cerr << ds << " bytes (" << std::fixed << std::setprecision(2) << pct << "%)" << std::endl;
        remove(zst.c_str());
    };

    diff_file(PatchFileId::STRINGS, "strings.bin", false);
    diff_file(PatchFileId::ADDR_POINTS, "addr_points.bin", true);
    diff_file(PatchFileId::STREET_WAYS, "street_ways.bin", true);
    diff_file(PatchFileId::INTERP_WAYS, "interp_ways.bin", true);
    diff_file(PatchFileId::ADMIN_POLYGONS, "admin_polygons.bin", true);

    std::function<void(std::vector<char>&)> no_remap;
    diff_file(PatchFileId::STREET_NODES, "street_nodes.bin", true);
    diff_file(PatchFileId::INTERP_NODES, "interp_nodes.bin", true);
    diff_file(PatchFileId::ADMIN_VERTICES, "admin_vertices.bin", true);
    diff_file(PatchFileId::GEO_CELLS, "geo_cells.bin", true);
    diff_file(PatchFileId::STREET_ENTRIES, "street_entries.bin", true);
    diff_file(PatchFileId::ADDR_ENTRIES, "addr_entries.bin", true);
    diff_file(PatchFileId::INTERP_ENTRIES, "interp_entries.bin", true);
    diff_file(PatchFileId::ADMIN_CELLS, "admin_cells.bin", true);
    diff_file(PatchFileId::ADMIN_ENTRIES, "admin_entries.bin", true);</tool_use_error>

Error: String to replace not found in file.
String:     // Diff each file: use remapped temp files as reference
    auto diff_file = [&](PatchFileId id, const std::string& name, bool use_temp) {
        std::cerr << "  " << name << ": ";
        std::string ref = use_temp ? tmpdir + "/" + name : old_dir + "/" + name;
        std::string tgt = new_dir + "/" + name;
        std::string zst = tmpdir + "/" + name + ".zst";

        std::string cmd = "zstd --patch-from='" + ref + "' '" + tgt + "' -o '" + zst + "' -f --quiet 2>/dev/null";
        system(cmd.c_str());

        auto zst_data = read_file(zst);
        auto old_data = read_file(old_dir + "/" + name);
        auto new_data = read_file(tgt);

        uint32_t fid = static_cast<uint32_t>(id), enc = static_cast<uint32_t>(PatchEncoding::COPY_INSERT);
        uint64_t os = old_data.size(), ns = new_data.size(), ds = zst_data.size();
        write_val(pf, &fid, 4); write_val(pf, &enc, 4);
        write_val(pf, &os, 8); write_val(pf, &ns, 8); write_val(pf, &ds, 8);
        pf.write(zst_data.data(), zst_data.size());

        double pct = ns > 0 ? ds * 100.0 / ns : 0;
        std::cerr << ds << " bytes (" << std::fixed << std::setprecision(2) << pct << "%)" << std::endl;
        remove(zst.c_str());
    };

    diff_file(PatchFileId::STRINGS, "strings.bin", false);
    diff_file(PatchFileId::ADDR_POINTS, "addr_points.bin", true);
    diff_file(PatchFileId::STREET_WAYS, "street_ways.bin", true);
    diff_file(PatchFileId::INTERP_WAYS, "interp_ways.bin", true);
    diff_file(PatchFileId::ADMIN_POLYGONS, "admin_polygons.bin", true);

    std::function<void(std::vector<char>&)> no_remap;
    diff_file(PatchFileId::STREET_NODES, "street_nodes.bin", true);
    diff_file(PatchFileId::INTERP_NODES, "interp_nodes.bin", true);
    diff_file(PatchFileId::ADMIN_VERTICES, "admin_vertices.bin", true);
    diff_file(PatchFileId::GEO_CELLS, "geo_cells.bin", true);
    diff_file(PatchFileId::STREET_ENTRIES, "street_entries.bin", true);
    diff_file(PatchFileId::ADDR_ENTRIES, "addr_entries.bin", true);
    diff_file(PatchFileId::INTERP_ENTRIES, "interp_entries.bin", true);
    diff_file(PatchFileId::ADMIN_CELLS, "admin_cells.bin", true);
    diff_file(PatchFileId::ADMIN_ENTRIES, "admin_entries.bin", true);


_cfuvid=gnk74A4NNQ9bJm9OElzoQ_OU0l3iCeuRVOsoTFV4W4A-1774611652.841071-1.0.1.1-xEfMwqMZWQO8MrOZ8NoLDkOnWV5s3Le3BfF24KwocNI; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com


_cfuvid=TOgooEIr5Uf5SZUfhiiiKL97v0BRTbR44DzcUupf6Us-1774611654.2703652-1.0.1.1-Nmlz8BkI6n5YurQ7oXstF89LeIIyp.Lqv4sMKswmZ3s; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com