// geocoder-diff v3: Fully custom patch format using merge-sequence encoding.
//
// For each data file: walk old (string-remapped) and new in parallel,
// match records by content, emit MATCH/INSERT/DELETE operations.
// For entry/cell files: include changed cell entry data directly.
// geo_cells rebuilt by patch tool from entries (not delta-patched).
//
// Patch format: custom binary, zstd-compressed as a whole for transport.
//
// 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 <mutex>
#include <string>
#include <thread>
#include <unistd.h>
#include <unordered_map>
#include <unordered_set>
#include <vector>

#include "patch_format.h"

// --- Merge sequence encoding ---
// Walk two sorted arrays (old remapped, new), matching by record bytes.
// Emit: MATCH(n) = copy n records from old, INSERT(n,data) = add n new records,
//       DELETE(n) = skip n old records.

enum MergeOp : uint8_t { OP_MATCH_RUN = 0, OP_INSERT_RUN = 1, OP_DELETE_RUN = 2 };

struct MergeSequence {
    std::vector<char> data; // serialized ops

    void add_match(uint32_t count) {
        uint8_t op = OP_MATCH_RUN;
        data.insert(data.end(), (char*)&op, (char*)&op + 1);
        data.insert(data.end(), (char*)&count, (char*)&count + 4);
    }
    void add_delete(uint32_t count) {
        uint8_t op = OP_DELETE_RUN;
        data.insert(data.end(), (char*)&op, (char*)&op + 1);
        data.insert(data.end(), (char*)&count, (char*)&count + 4);
    }
    void add_insert(const char* records, uint32_t count, size_t stride) {
        uint8_t op = OP_INSERT_RUN;
        data.insert(data.end(), (char*)&op, (char*)&op + 1);
        data.insert(data.end(), (char*)&count, (char*)&count + 4);
        data.insert(data.end(), records, records + count * stride);
    }
};

// Build merge sequence for a data file.
// old_data has string remap already applied.
// Records are compared by byte equality (stride bytes).
static MergeSequence build_merge_seq(
    const std::vector<char>& old_data, const std::vector<char>& new_data,
    size_t stride)
{
    size_t old_n = old_data.size() / stride;
    size_t new_n = new_data.size() / stride;
    size_t oi = 0, ni = 0;
    MergeSequence seq;
    uint32_t match_run = 0, del_run = 0;

    auto flush_match = [&]() { if (match_run > 0) { seq.add_match(match_run); match_run = 0; } };
    auto flush_del = [&]() { if (del_run > 0) { seq.add_delete(del_run); del_run = 0; } };

    // For small strides (<=8), records aren't unique enough for hash matching.
    // Use simple sequential scan instead.
    bool use_hash = (stride > 8);

    // Pre-build hash index of new records for fast mismatch resolution
    auto record_hash = [&](const char* p, size_t s) -> uint64_t {
        uint64_t h = 14695981039346656037ULL;
        for (size_t i = 0; i < s; i++) { h ^= (uint8_t)p[i]; h *= 1099511628211ULL; }
        return h;
    };
    std::unordered_multimap<uint64_t, uint32_t> new_hash;
    if (use_hash) {
        new_hash.reserve(new_n);
        for (uint32_t i = 0; i < new_n; i++)
            new_hash.emplace(record_hash(new_data.data() + i * stride, stride), i);
    }

    while (oi < old_n && ni < new_n) {
        const char* op = old_data.data() + oi * stride;
        const char* np = new_data.data() + ni * stride;

        if (memcmp(op, np, stride) == 0) {
            flush_del();
            match_run++;
            oi++; ni++;
        } else if (use_hash) {
            // Hash-based mismatch resolution (for records with unique content)
            uint64_t oh = record_hash(op, stride);
            auto range = new_hash.equal_range(oh);
            uint32_t best_ni = UINT32_MAX;
            for (auto it = range.first; it != range.second; ++it) {
                if (it->second >= ni && it->second < ni + 10000 &&
                    memcmp(op, new_data.data() + it->second * stride, stride) == 0) {
                    if (it->second < best_ni) best_ni = it->second;
                }
            }
            if (best_ni != UINT32_MAX && best_ni > ni) {
                flush_match(); flush_del();
                seq.add_insert(new_data.data() + ni * stride, best_ni - ni, stride);
                ni = best_ni;
            } else if (best_ni == ni) {
                flush_del(); match_run++; oi++; ni++;
            } else {
                flush_match(); del_run++; oi++;
            }
        } else {
            // Sequential scan (for coordinate files where records aren't unique)
            size_t lookahead = std::min((size_t)200, std::min(old_n - oi, new_n - ni));
            bool found = false;
            for (size_t k = 1; k <= lookahead; k++) {
                if (ni + k < new_n && memcmp(op, new_data.data() + (ni + k) * stride, stride) == 0) {
                    flush_match(); flush_del();
                    seq.add_insert(new_data.data() + ni * stride, k, stride);
                    ni += k; found = true; break;
                }
                if (oi + k < old_n && memcmp(np, old_data.data() + (oi + k) * stride, stride) == 0) {
                    flush_match(); del_run += k; oi += k; found = true; break;
                }
            }
            if (!found) { flush_match(); del_run++; oi++; flush_del(); seq.add_insert(np, 1, stride); ni++; }
        }
    }

    flush_match(); flush_del();
    // Remaining old records: deletions
    if (oi < old_n) seq.add_delete(old_n - oi);
    // Remaining new records: insertions
    if (ni < new_n) seq.add_insert(new_data.data() + ni * stride, new_n - ni, stride);

    return seq;
}

// --- 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 + fixup 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 matching for ways (by name + nodes, ignoring node_offset)
static uint64_t fnv_mix(uint64_t h, uint64_t v) { h ^= v; h *= 1099511628211ULL; return h; }

static void fixup_way_offsets(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_nc = old_nodes.size() / 8, new_nc = new_nodes.size() / 8;

    auto way_hash = [&](const char* w, const char* nodes, size_t max_n) -> 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_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(way_hash(new_ways.data() + i * stride, new_nodes.data(), new_nc), i);

    for (uint32_t i = 0; i < old_n; i++) {
        uint64_t h = way_hash(old_ways.data() + i * stride, old_nodes.data(), old_nc);
        auto it = new_map.find(h);
        if (it != new_map.end()) {
            // Fix node_offset to match new way
            uint32_t new_node_off;
            memcpy(&new_node_off, new_ways.data() + it->second * stride, 4);
            memcpy(old_ways.data() + i * stride, &new_node_off, 4);
            new_map.erase(it);
        }
    }
}

// Same for admin polygons (vertex_offset)
static void fixup_admin_offsets(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;
    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 + 20, 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);
        for (uint32_t j = 0; j < std::min(vert_count, 10u) && (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);

    for (uint32_t i = 0; i < old_n; i++) {
        uint64_t h = poly_hash(old_polys.data() + i * stride, old_verts.data(), old_vc);
        auto it = new_map.find(h);
        if (it != new_map.end()) {
            uint32_t new_vert_off;
            memcpy(&new_vert_off, new_polys.data() + it->second * stride, 4);
            memcpy(old_polys.data() + i * stride, &new_vert_off, 4);
            new_map.erase(it);
        }
    }
}

// Same for interp ways
static void fixup_interp_offsets(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);
    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()) {
            uint32_t new_off; memcpy(&new_off, new_data.data() + it->second * stride, 4);
            memcpy(old_data.data() + i * stride, &new_off, 4);
            new_map.erase(it);
        }
    }
}

// --- Secondary matching for modified records ---
// After the merge sequence is built, match DELETE'd and INSERT'd records by a
// relaxed key to recover ID mappings for records that changed (e.g. geometry edit)
// but represent the same logical entity. Only matches when the key group has
// equal counts on both sides to avoid mismatches.
template<typename KeyFn>
static std::unordered_map<uint32_t, uint32_t> secondary_match_from_merge(
    const MergeSequence& seq,
    const std::vector<char>& old_data,
    const std::vector<char>& new_data,
    size_t stride,
    KeyFn key_fn)
{
    std::vector<uint32_t> del_indices, ins_indices;
    size_t pos = 0, old_rec = 0, new_rec = 0;
    while (pos < seq.data.size()) {
        uint8_t op = static_cast<uint8_t>(seq.data[pos]); pos++;
        uint32_t count; memcpy(&count, seq.data.data() + pos, 4); pos += 4;
        if (op == OP_MATCH_RUN) {
            old_rec += count; new_rec += count;
        } else if (op == OP_INSERT_RUN) {
            for (uint32_t k = 0; k < count; k++)
                ins_indices.push_back(static_cast<uint32_t>(new_rec + k));
            pos += count * stride;
            new_rec += count;
        } else if (op == OP_DELETE_RUN) {
            for (uint32_t k = 0; k < count; k++)
                del_indices.push_back(static_cast<uint32_t>(old_rec + k));
            old_rec += count;
        }
    }

    // Group by key
    std::unordered_map<uint64_t, std::vector<uint32_t>> del_by_key, ins_by_key;
    for (uint32_t di : del_indices) {
        if ((size_t)(di + 1) * stride > old_data.size()) continue;
        del_by_key[key_fn(old_data.data() + di * stride)].push_back(di);
    }
    for (uint32_t ii : ins_indices) {
        if ((size_t)(ii + 1) * stride > new_data.size()) continue;
        ins_by_key[key_fn(new_data.data() + ii * stride)].push_back(ii);
    }

    // Match only when equal counts per key (safe pairwise match in index order)
    std::unordered_map<uint32_t, uint32_t> remap;
    for (auto& [key, del_vec] : del_by_key) {
        auto it = ins_by_key.find(key);
        if (it == ins_by_key.end()) continue;
        auto& ins_vec = it->second;
        if (del_vec.size() == ins_vec.size()) {
            std::sort(del_vec.begin(), del_vec.end());
            std::sort(ins_vec.begin(), ins_vec.end());
            for (size_t i = 0; i < del_vec.size(); i++)
                remap[del_vec[i]] = ins_vec[i];
        }
    }
    return remap;
}

// --- Derive ID remap from merge sequence (same logic as patch tool) ---
static std::vector<uint32_t> derive_id_remap_from_merge(
    const MergeSequence& seq, size_t old_count, size_t stride)
{
    std::vector<uint32_t> id_map(old_count, 0xFFFFFFFF);
    size_t pos = 0, old_rec = 0, new_rec = 0;
    while (pos < seq.data.size()) {
        uint8_t op = static_cast<uint8_t>(seq.data[pos]); pos++;
        uint32_t count; memcpy(&count, seq.data.data() + pos, 4); pos += 4;
        if (op == OP_MATCH_RUN) {
            for (uint32_t k = 0; k < count; k++)
                if (old_rec + k < id_map.size())
                    id_map[old_rec + k] = static_cast<uint32_t>(new_rec + k);
            old_rec += count; new_rec += count;
        } else if (op == OP_INSERT_RUN) {
            pos += count * stride;
            new_rec += count;
        } else if (op == OP_DELETE_RUN) {
            old_rec += count;
        }
    }
    return id_map;
}

// --- Per-file merge result (computed in parallel, serialized sequentially) ---
struct FileMergeResult {
    PatchFileId id;
    std::string name;
    size_t stride;
    uint64_t old_size, new_size;
    MergeSequence seq;
    std::vector<std::pair<uint32_t,uint32_t>> fixups; // (record_idx, new_offset)
};

// Serialize a pre-computed merge result into the patch buffer
static void serialize_merge(std::vector<char>& patch, const FileMergeResult& r) {
    auto wv = [&](const void* data, size_t size) {
        patch.insert(patch.end(), (const char*)data, (const char*)data + size);
    };
    uint32_t fid = static_cast<uint32_t>(r.id);
    uint32_t st = static_cast<uint32_t>(r.stride);
    uint32_t n_fixups = static_cast<uint32_t>(r.fixups.size());
    wv(&fid, 4); wv(&st, 4); wv(&r.old_size, 8); wv(&r.new_size, 8);
    wv(&n_fixups, 4);
    if (n_fixups > 0) {
        std::vector<char> delta_buf;
        uint32_t prev_idx = 0, prev_val = 0;
        for (auto& [idx, val] : r.fixups) {
            write_varint(delta_buf, idx - prev_idx);
            write_varint(delta_buf, val - prev_val);
            prev_idx = idx; prev_val = val;
        }
        uint32_t delta_size = static_cast<uint32_t>(delta_buf.size());
        wv(&delta_size, 4);
        patch.insert(patch.end(), delta_buf.begin(), delta_buf.end());
    }
    uint64_t ss = r.seq.data.size();
    wv(&ss, 8);
    patch.insert(patch.end(), r.seq.data.begin(), r.seq.data.end());
}

static std::mutex log_mutex;
static void log_merge(const FileMergeResult& r) {
    std::lock_guard<std::mutex> lock(log_mutex);
    std::cerr << "  " << r.name << ": seq=" << r.seq.data.size()
              << " fixups=" << r.fixups.size()
              << " (" << std::fixed << std::setprecision(2)
              << (r.new_size > 0 ? r.seq.data.size() * 100.0 / r.new_size : 0) << "%)" << std::endl;
}

// --- Write helpers ---
static void wval(std::vector<char>& buf, const void* data, size_t size) {
    buf.insert(buf.end(), (const char*)data, (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);

    // Detect 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", {24, 20, 18});
    size_t admin_stride = detect(old_dir + "/admin_polygons.bin", {24, 20, 19});

    // Build patch data (uncompressed, will be zstd-compressed at the end)
    std::vector<char> patch;
    patch.insert(patch.end(), GCPATCH_MAGIC, GCPATCH_MAGIC + 8);
    uint32_t ver = 2, flags = 0; // version 2 = custom format
    wval(patch, &ver, 4); wval(patch, &flags, 4);

    // --- Section: String remap ---
    // When string-level diff is present, the remap is derived by the patch tool.
    // Only include explicit remap if string diff is NOT used.
    // For now, always include empty remap (string diff handles everything).
    {
        uint32_t marker = 0xFFFFFFFE, count = 0;
        wval(patch, &marker, 4); wval(patch, &count, 4);
        std::cerr << "  String remap: derived from string diff (0 explicit entries)" << std::endl;
    }

    // --- Section: Per-file merge sequences (computed in parallel) ---
    // Stored merge sequences for ID remap derivation
    std::unordered_map<uint32_t, MergeSequence> stored_merges;

    // strings.bin: string-level diff (both pools are sorted alphabetically)
    {
        std::vector<std::string> old_strs, new_strs;
        { size_t p = 0; while (p < old_strings.size()) { old_strs.push_back(old_strings.data()+p); p += strlen(old_strings.data()+p)+1; } }
        { size_t p = 0; while (p < new_strings.size()) { new_strs.push_back(new_strings.data()+p); p += strlen(new_strings.data()+p)+1; } }
        std::vector<std::string> added_strings;
        std::vector<uint32_t> deleted_indices;
        size_t oi = 0, ni = 0;
        while (oi < old_strs.size() && ni < new_strs.size()) {
            int c = old_strs[oi].compare(new_strs[ni]);
            if (c == 0) { oi++; ni++; }
            else if (c < 0) { deleted_indices.push_back(oi); oi++; }
            else { added_strings.push_back(new_strs[ni]); ni++; }
        }
        while (oi < old_strs.size()) { deleted_indices.push_back(oi); oi++; }
        while (ni < new_strs.size()) { added_strings.push_back(new_strs[ni]); ni++; }
        uint32_t str_diff_marker = 0xFFFFFFF7;
        uint32_t n_added = added_strings.size(), n_deleted = deleted_indices.size();
        wval(patch, &str_diff_marker, 4); wval(patch, &n_added, 4); wval(patch, &n_deleted, 4);
        for (auto& s : added_strings) { patch.insert(patch.end(), s.begin(), s.end()); patch.push_back('\0'); }
        for (auto idx : deleted_indices) wval(patch, &idx, 4);
        std::cerr << "  strings.bin: +" << n_added << " -" << n_deleted << " strings" << std::endl;
    }

    // Build merge sequences for all data files in parallel (4 groups)
    // Group 1: addr_points (independent)
    // Group 2: street_ways → street_nodes (sequential within group)
    // Group 3: interp_ways → interp_nodes (sequential within group)
    // Group 4: admin_polygons → admin_vertices (sequential within group)
    FileMergeResult res_addr, res_ways, res_nodes, res_interp_w, res_interp_n, res_admin_p, res_admin_v;

    // Helper to build parent-aware node merge from a parent way merge
    // count_u32: true for admin_polygons (vertex_count is uint32_t at offset 4)
    //            false for street_ways/interp_ways (node_count is uint8_t at offset 4)
    auto build_child_merge = [](const MergeSequence& parent_seq,
                                 const std::vector<char>& old_parent, const std::vector<char>& new_parent,
                                 const std::vector<char>& old_child, const std::vector<char>& new_child,
                                 size_t parent_stride, bool count_u32) -> MergeSequence {
        MergeSequence seq;
        auto read_count = [&](const char* rec) -> uint32_t {
            if (count_u32) { uint32_t v; memcpy(&v, rec + 4, 4); return v; }
            return static_cast<uint32_t>(static_cast<uint8_t>(rec[4]));
        };
        size_t p_oi = 0, p_ni = 0, ppos = 0;
        while (ppos < parent_seq.data.size()) {
            uint8_t op = static_cast<uint8_t>(parent_seq.data[ppos]); ppos++;
            uint32_t count; memcpy(&count, parent_seq.data.data() + ppos, 4); ppos += 4;
            if (op == OP_MATCH_RUN) {
                for (uint32_t k = 0; k < count; k++) {
                    uint32_t old_off; memcpy(&old_off, old_parent.data() + (p_oi+k)*parent_stride, 4);
                    uint32_t vc = read_count(old_parent.data() + (p_oi+k)*parent_stride);
                    uint32_t new_off; memcpy(&new_off, new_parent.data() + (p_ni+k)*parent_stride, 4);
                    if (vc > 0) {
                        bool match = (old_off + vc) * 8 <= old_child.size() &&
                                    (new_off + vc) * 8 <= new_child.size() &&
                                    memcmp(old_child.data() + old_off*8, new_child.data() + new_off*8, vc*8) == 0;
                        if (match) seq.add_match(vc);
                        else { seq.add_delete(vc); seq.add_insert(new_child.data() + new_off*8, vc, 8); }
                    }
                }
                p_oi += count; p_ni += count;
            } else if (op == OP_INSERT_RUN) {
                for (uint32_t k = 0; k < count; k++) {
                    const char* rec = parent_seq.data.data() + ppos + k * parent_stride;
                    uint32_t off; memcpy(&off, rec, 4);
                    uint32_t vc = read_count(rec);
                    if (vc > 0 && (off + vc) * 8 <= new_child.size())
                        seq.add_insert(new_child.data() + off*8, vc, 8);
                }
                ppos += count * parent_stride;
                p_ni += count;
            } else if (op == OP_DELETE_RUN) {
                for (uint32_t k = 0; k < count; k++) {
                    uint32_t vc = read_count(old_parent.data() + (p_oi+k)*parent_stride);
                    if (vc > 0) seq.add_delete(vc);
                }
                p_oi += count;
            }
        }
        return seq;
    };

    std::cerr << "Building merge sequences (parallel)..." << std::endl;

    // Group 1: addr_points
    std::thread t_addr([&]() {
        auto old_data = read_file(old_dir + "/addr_points.bin");
        remap_addr_points(old_data, str_remap);
        auto new_data = read_file(new_dir + "/addr_points.bin");
        res_addr = {PatchFileId::ADDR_POINTS, "addr_points.bin", 16,
                    old_data.size(), new_data.size(),
                    build_merge_seq(old_data, new_data, 16), {}};
        log_merge(res_addr);
    });

    // Group 2: street_ways → street_nodes
    std::thread t_street([&]() {
        auto old_w = read_file(old_dir + "/street_ways.bin");
        remap_field(old_w, way_stride, way_stride == 12 ? 8 : 5, str_remap);
        auto new_w = read_file(new_dir + "/street_ways.bin");
        auto old_n = read_file(old_dir + "/street_nodes.bin");
        auto new_n = read_file(new_dir + "/street_nodes.bin");
        // Fixup node_offsets
        size_t wn = old_w.size() / way_stride;
        std::vector<uint32_t> old_offsets(wn);
        for (size_t i = 0; i < wn; i++) memcpy(&old_offsets[i], old_w.data() + i * way_stride, 4);
        fixup_way_offsets(old_w, old_n, new_w, new_n, way_stride);
        std::vector<std::pair<uint32_t,uint32_t>> fixups;
        for (size_t i = 0; i < wn; i++) {
            uint32_t new_off; memcpy(&new_off, old_w.data() + i * way_stride, 4);
            if (new_off != old_offsets[i]) fixups.push_back({static_cast<uint32_t>(i), new_off});
        }
        auto way_seq = build_merge_seq(old_w, new_w, way_stride);
        res_ways = {PatchFileId::STREET_WAYS, "street_ways.bin", way_stride,
                    old_w.size(), new_w.size(), way_seq, std::move(fixups)};
        log_merge(res_ways);
        // Now build parent-aware node merge from way merge
        auto old_w_orig = read_file(old_dir + "/street_ways.bin"); // original offsets
        auto node_seq = build_child_merge(way_seq, old_w_orig, new_w, old_n, new_n, way_stride, false);
        res_nodes = {PatchFileId::STREET_NODES, "street_nodes.bin", 8,
                     old_n.size(), new_n.size(), std::move(node_seq), {}};
        log_merge(res_nodes);
    });

    // Group 3: interp_ways → interp_nodes
    std::thread t_interp([&]() {
        auto old_data = read_file(old_dir + "/interp_ways.bin");
        auto new_data = read_file(new_dir + "/interp_ways.bin");
        if (interp_stride == 24) {
            for (size_t i = 0; i + interp_stride <= old_data.size(); i += interp_stride)
                { memset(old_data.data()+i+5, 0, 3); memset(old_data.data()+i+21, 0, 3); }
            for (size_t i = 0; i + interp_stride <= new_data.size(); i += interp_stride)
                { memset(new_data.data()+i+5, 0, 3); memset(new_data.data()+i+21, 0, 3); }
        }
        remap_field(old_data, interp_stride, interp_stride >= 20 ? 8 : 5, str_remap);
        auto old_n = read_file(old_dir + "/interp_nodes.bin");
        auto new_n = read_file(new_dir + "/interp_nodes.bin");
        size_t n = old_data.size() / interp_stride;
        std::vector<uint32_t> old_offsets(n);
        for (size_t i = 0; i < n; i++) memcpy(&old_offsets[i], old_data.data() + i * interp_stride, 4);
        fixup_interp_offsets(old_data, old_n, new_data, new_n, interp_stride);
        std::vector<std::pair<uint32_t,uint32_t>> fixups;
        for (size_t i = 0; i < n; i++) {
            uint32_t new_off; memcpy(&new_off, old_data.data() + i * interp_stride, 4);
            if (new_off != old_offsets[i]) fixups.push_back({static_cast<uint32_t>(i), new_off});
        }
        auto iw_seq = build_merge_seq(old_data, new_data, interp_stride);
        res_interp_w = {PatchFileId::INTERP_WAYS, "interp_ways.bin", interp_stride,
                        old_data.size(), new_data.size(), iw_seq, std::move(fixups)};
        log_merge(res_interp_w);
        auto old_iw_orig = read_file(old_dir + "/interp_ways.bin");
        auto in_seq = build_child_merge(iw_seq, old_iw_orig, new_data, old_n, new_n, interp_stride, false);
        res_interp_n = {PatchFileId::INTERP_NODES, "interp_nodes.bin", 8,
                        old_n.size(), new_n.size(), std::move(in_seq), {}};
        log_merge(res_interp_n);
    });

    // Group 4: admin_polygons → admin_vertices
    std::thread t_admin([&]() {
        auto old_data = read_file(old_dir + "/admin_polygons.bin");
        auto new_data = read_file(new_dir + "/admin_polygons.bin");
        if (admin_stride == 24) {
            for (size_t i = 0; i + admin_stride <= old_data.size(); i += admin_stride)
                memset(old_data.data() + i + 13, 0, 3);
            for (size_t i = 0; i + admin_stride <= new_data.size(); i += admin_stride)
                memset(new_data.data() + i + 13, 0, 3);
        }
        remap_field(old_data, admin_stride, 8, str_remap);
        auto old_v = read_file(old_dir + "/admin_vertices.bin");
        auto new_v = read_file(new_dir + "/admin_vertices.bin");
        size_t n = old_data.size() / admin_stride;
        std::vector<uint32_t> old_offsets(n);
        for (size_t i = 0; i < n; i++) memcpy(&old_offsets[i], old_data.data() + i * admin_stride, 4);
        fixup_admin_offsets(old_data, old_v, new_data, new_v, admin_stride);
        std::vector<std::pair<uint32_t,uint32_t>> fixups;
        for (size_t i = 0; i < n; i++) {
            uint32_t new_off; memcpy(&new_off, old_data.data() + i * admin_stride, 4);
            if (new_off != old_offsets[i]) fixups.push_back({static_cast<uint32_t>(i), new_off});
        }
        auto ap_seq = build_merge_seq(old_data, new_data, admin_stride);
        res_admin_p = {PatchFileId::ADMIN_POLYGONS, "admin_polygons.bin", admin_stride,
                       old_data.size(), new_data.size(), ap_seq, std::move(fixups)};
        log_merge(res_admin_p);
        auto old_ap_orig = read_file(old_dir + "/admin_polygons.bin");
        auto av_seq = build_child_merge(ap_seq, old_ap_orig, new_data, old_v, new_v, admin_stride);
        res_admin_v = {PatchFileId::ADMIN_VERTICES, "admin_vertices.bin", 8,
                       old_v.size(), new_v.size(), std::move(av_seq), {}};
        log_merge(res_admin_v);
    });

    t_addr.join(); t_street.join(); t_interp.join(); t_admin.join();
    std::cerr << "All merge sequences built." << std::endl;

    // Serialize merge results to patch in canonical order
    serialize_merge(patch, res_addr);
    stored_merges[static_cast<uint32_t>(PatchFileId::ADDR_POINTS)] = std::move(res_addr.seq);
    serialize_merge(patch, res_ways);
    stored_merges[static_cast<uint32_t>(PatchFileId::STREET_WAYS)] = std::move(res_ways.seq);
    serialize_merge(patch, res_nodes);
    serialize_merge(patch, res_interp_w);
    stored_merges[static_cast<uint32_t>(PatchFileId::INTERP_WAYS)] = std::move(res_interp_w.seq);
    serialize_merge(patch, res_interp_n);
    serialize_merge(patch, res_admin_p);
    stored_merges[static_cast<uint32_t>(PatchFileId::ADMIN_POLYGONS)] = std::move(res_admin_p.seq);
    serialize_merge(patch, res_admin_v);

    // --- Section: Entry/cell files as full new data ---
    // These are small relative to data files and rebuilt by patch tool isn't reliable.
    // Include them as full replacement. zstd transport compression handles the rest.
    // Entry/cell files: NOT included.
    // The patch tool reconstructs them from the derived ID remaps
    // (extracted implicitly from the merge sequences above).
    // geo_cells.bin, admin_cells.bin, and all entry files are rebuilt.
    std::cerr << "  (entry/cell files: omitted, rebuilt by patch tool)" << std::endl;

    // Include cell_id changes for entry reconstruction
    {
        // Geo cell changes
        auto new_geo = read_file(new_dir + "/geo_cells.bin");
        auto old_geo = read_file(old_dir + "/geo_cells.bin");
        std::unordered_set<uint64_t> old_set, new_set;
        for (size_t i = 0; i < old_geo.size() / 20; i++) {
            uint64_t c; memcpy(&c, old_geo.data() + i * 20, 8); old_set.insert(c);
        }
        for (size_t i = 0; i < new_geo.size() / 20; i++) {
            uint64_t c; memcpy(&c, new_geo.data() + i * 20, 8); new_set.insert(c);
        }
        std::vector<uint64_t> g_added, g_removed;
        for (auto c : new_set) if (!old_set.count(c)) g_added.push_back(c);
        for (auto c : old_set) if (!new_set.count(c)) g_removed.push_back(c);
        std::sort(g_added.begin(), g_added.end());
        std::sort(g_removed.begin(), g_removed.end());

        uint32_t marker = CELL_CHANGES_GEO_MARKER;
        uint32_t na = g_added.size(), nr = g_removed.size();
        wval(patch, &marker, 4); wval(patch, &na, 4); wval(patch, &nr, 4);
        for (auto c : g_added) wval(patch, &c, 8);
        for (auto c : g_removed) wval(patch, &c, 8);
        std::cerr << "  Geo cell changes: +" << na << " -" << nr << " ("
                  << (na + nr) * 8 << " bytes)" << std::endl;

        // Admin cell changes
        auto new_ac = read_file(new_dir + "/admin_cells.bin");
        auto old_ac = read_file(old_dir + "/admin_cells.bin");
        old_set.clear(); new_set.clear();
        for (size_t i = 0; i < old_ac.size() / 12; i++) {
            uint64_t c; memcpy(&c, old_ac.data() + i * 12, 8); old_set.insert(c);
        }
        for (size_t i = 0; i < new_ac.size() / 12; i++) {
            uint64_t c; memcpy(&c, new_ac.data() + i * 12, 8); new_set.insert(c);
        }
        std::vector<uint64_t> a_added, a_removed;
        for (auto c : new_set) if (!old_set.count(c)) a_added.push_back(c);
        for (auto c : old_set) if (!new_set.count(c)) a_removed.push_back(c);
        std::sort(a_added.begin(), a_added.end());
        std::sort(a_removed.begin(), a_removed.end());

        marker = CELL_CHANGES_ADMIN_MARKER;
        na = a_added.size(); nr = a_removed.size();
        wval(patch, &marker, 4); wval(patch, &na, 4); wval(patch, &nr, 4);
        for (auto c : a_added) wval(patch, &c, 8);
        for (auto c : a_removed) wval(patch, &c, 8);
        std::cerr << "  Admin cell changes: +" << na << " -" << nr << std::endl;

        // Derive ID remaps from stored merge sequences (same as patch tool does)
        auto derive_remap = [&](PatchFileId id, size_t old_size, size_t stride) -> std::unordered_map<uint32_t,uint32_t> {
            auto it = stored_merges.find(static_cast<uint32_t>(id));
            if (it == stored_merges.end()) return {};
            auto vec = derive_id_remap_from_merge(it->second, old_size / stride, stride);
            std::unordered_map<uint32_t,uint32_t> m;
            for (uint32_t i = 0; i < vec.size(); i++)
                if (vec[i] != 0xFFFFFFFF) m[i] = vec[i];
            return m;
        };

        auto w_rm_d = derive_remap(PatchFileId::STREET_WAYS,
            read_file(old_dir + "/street_ways.bin").size(), way_stride);
        auto a_rm_d = derive_remap(PatchFileId::ADDR_POINTS,
            read_file(old_dir + "/addr_points.bin").size(), 16);
        auto i_rm_d = derive_remap(PatchFileId::INTERP_WAYS,
            read_file(old_dir + "/interp_ways.bin").size(), interp_stride);
        auto ad_rm_d = derive_remap(PatchFileId::ADMIN_POLYGONS,
            read_file(old_dir + "/admin_polygons.bin").size(), admin_stride);

        // Secondary matching: recover additional ID mappings for modified records
        // that didn't byte-match but represent the same entity (e.g. geometry edits).
        // These are emitted into the patch so the patch tool can apply the same remaps.
        std::unordered_map<uint32_t, uint32_t> soft_w, soft_a, soft_i, soft_ad;
        {
            size_t way_name_off = (way_stride == 12) ? 8 : 5;

            // Street ways: match by name_id + node_count
            auto old_w2 = read_file(old_dir + "/street_ways.bin");
            remap_field(old_w2, way_stride, way_name_off, str_remap);
            auto new_w2 = read_file(new_dir + "/street_ways.bin");
            soft_w = secondary_match_from_merge(
                stored_merges[static_cast<uint32_t>(PatchFileId::STREET_WAYS)],
                old_w2, new_w2, way_stride,
                [way_name_off](const char* rec) -> uint64_t {
                    uint32_t name_id; memcpy(&name_id, rec + way_name_off, 4);
                    uint8_t nc = static_cast<uint8_t>(rec[4]);
                    return ((uint64_t)name_id << 8) | nc;
                });
            std::cerr << "  Secondary way matches: " << soft_w.size() << std::endl;

            // Addr points: match by (street_id, housenumber_id)
            auto old_a2 = read_file(old_dir + "/addr_points.bin");
            remap_addr_points(old_a2, str_remap);
            auto new_a2 = read_file(new_dir + "/addr_points.bin");
            soft_a = secondary_match_from_merge(
                stored_merges[static_cast<uint32_t>(PatchFileId::ADDR_POINTS)],
                old_a2, new_a2, 16,
                [](const char* rec) -> uint64_t {
                    uint32_t hn_id, st_id;
                    memcpy(&hn_id, rec + 8, 4); memcpy(&st_id, rec + 12, 4);
                    return ((uint64_t)st_id << 32) | hn_id;
                });
            std::cerr << "  Secondary addr matches: " << soft_a.size() << std::endl;

            // Interp ways: match by (street_id, start_number)
            size_t ist_off = (interp_stride >= 20) ? 8 : 5;
            auto old_i2 = read_file(old_dir + "/interp_ways.bin");
            remap_field(old_i2, interp_stride, ist_off, str_remap);
            auto new_i2 = read_file(new_dir + "/interp_ways.bin");
            soft_i = secondary_match_from_merge(
                stored_merges[static_cast<uint32_t>(PatchFileId::INTERP_WAYS)],
                old_i2, new_i2, interp_stride,
                [ist_off](const char* rec) -> uint64_t {
                    uint32_t street_id, start;
                    memcpy(&street_id, rec + ist_off, 4);
                    memcpy(&start, rec + ist_off + 4, 4);
                    return ((uint64_t)street_id << 32) | start;
                });
            std::cerr << "  Secondary interp matches: " << soft_i.size() << std::endl;

            // Admin polygons: match by (name_id, admin_level, country_code)
            auto old_ap2 = read_file(old_dir + "/admin_polygons.bin");
            remap_field(old_ap2, admin_stride, 8, str_remap);
            auto new_ap2 = read_file(new_dir + "/admin_polygons.bin");
            soft_ad = secondary_match_from_merge(
                stored_merges[static_cast<uint32_t>(PatchFileId::ADMIN_POLYGONS)],
                old_ap2, new_ap2, admin_stride,
                [](const char* rec) -> uint64_t {
                    uint32_t name_id; memcpy(&name_id, rec + 8, 4);
                    uint8_t level = static_cast<uint8_t>(rec[12]);
                    uint16_t cc; memcpy(&cc, rec + 20, 2);
                    return ((uint64_t)name_id << 24) | ((uint64_t)level << 16) | cc;
                });
            std::cerr << "  Secondary admin matches: " << soft_ad.size() << std::endl;
        }

        // Emit secondary remap section into patch (before entry corrections)
        {
            uint32_t marker = SECONDARY_REMAP_MARKER;
            wval(patch, &marker, 4);
            // Count non-empty file remaps
            auto emit_remap = [&](PatchFileId fid, const std::unordered_map<uint32_t,uint32_t>& rm) {
                uint32_t file = static_cast<uint32_t>(fid);
                uint32_t count = static_cast<uint32_t>(rm.size());
                wval(patch, &file, 4);
                wval(patch, &count, 4);
                for (auto& [o, n] : rm) {
                    wval(patch, &o, 4); wval(patch, &n, 4);
                }
            };
            uint32_t n_files = 4;
            wval(patch, &n_files, 4);
            emit_remap(PatchFileId::STREET_WAYS, soft_w);
            emit_remap(PatchFileId::ADDR_POINTS, soft_a);
            emit_remap(PatchFileId::INTERP_WAYS, soft_i);
            emit_remap(PatchFileId::ADMIN_POLYGONS, soft_ad);
            size_t total_pairs = soft_w.size() + soft_a.size() + soft_i.size() + soft_ad.size();
            std::cerr << "  Secondary remap section: " << total_pairs << " pairs ("
                      << total_pairs * 8 << " bytes)" << std::endl;
        }

        // Merge secondary matches into derived remaps
        for (auto& [o, n] : soft_w) w_rm_d[o] = n;
        for (auto& [o, n] : soft_a) a_rm_d[o] = n;
        for (auto& [o, n] : soft_i) i_rm_d[o] = n;
        for (auto& [o, n] : soft_ad) ad_rm_d[o] = n;

        // Rebuild entries using derived remaps (same as patch tool)
        auto old_se2 = read_file(old_dir + "/street_entries.bin");
        auto old_ae2 = read_file(old_dir + "/addr_entries.bin");
        auto old_ie2 = read_file(old_dir + "/interp_entries.bin");
        auto derived = rebuild_geo_from_remap(old_geo, old_se2, old_ae2, old_ie2,
                                               w_rm_d, a_rm_d, i_rm_d, g_added, g_removed);
        auto old_admc = read_file(old_dir + "/admin_cells.bin");
        auto old_adme = read_file(old_dir + "/admin_entries.bin");
        auto admin_derived = rebuild_admin_from_remap(old_admc, old_adme, ad_rm_d, a_added, a_removed);

        // Cell-level entry corrections: compare derived vs new entries cell by cell.
        // Include only cells whose entry data differs.
        // This is fully custom — no zstd, no temp files.
        auto write_entry_correction = [&](PatchFileId fid, const std::string& fname,
                                           const std::vector<char>& derived_entries,
                                           const std::vector<char>& derived_cells, size_t cell_stride,
                                           size_t offset_pos) {
            auto new_entries = read_file(new_dir + "/" + fname);
            auto new_cells_data = read_file(new_dir + "/" +
                (fid == PatchFileId::ADMIN_ENTRIES ? "admin_cells.bin" : "geo_cells.bin"));

            // Parse both derived and new entries by cell
            auto parse_all = [](const std::vector<char>& cells, size_t stride, size_t off_pos,
                                 const std::vector<char>& entries)
                -> std::vector<std::pair<uint64_t, std::vector<uint32_t>>>
            {
                size_t n = cells.size() / stride;
                std::vector<std::pair<uint64_t, std::vector<uint32_t>>> result;
                result.reserve(n);
                for (size_t i = 0; i < n; i++) {
                    uint64_t cid; memcpy(&cid, cells.data() + i * stride, 8);
                    uint32_t off; memcpy(&off, cells.data() + i * stride + off_pos, 4);
                    std::vector<uint32_t> ids;
                    if (off != 0xFFFFFFFF && off + 2 <= entries.size()) {
                        uint16_t count; memcpy(&count, entries.data() + off, 2);
                        if (off + 2 + count * 4 <= entries.size()) {
                            ids.resize(count);
                            memcpy(ids.data(), entries.data() + off + 2, count * 4);
                        }
                    }
                    result.push_back({cid, std::move(ids)});
                }
                return result;
            };

            auto derived_parsed = parse_all(derived_cells, cell_stride, offset_pos, derived_entries);
            auto new_parsed = parse_all(new_cells_data,
                cell_stride == 12 ? 12 : 20,
                cell_stride == 12 ? 8 : offset_pos,
                new_entries);

            // Build new cell map for lookup
            std::unordered_map<uint64_t, const std::vector<uint32_t>*> new_map;
            for (auto& [cid, ids] : new_parsed) new_map[cid] = &ids;

            // Find differing cells
            std::vector<char> corr_data;
            uint32_t diff_count = 0;
            for (auto& [cid, d_ids] : derived_parsed) {
                auto it = new_map.find(cid);
                const std::vector<uint32_t>* n_ids = nullptr;
                if (it != new_map.end()) n_ids = it->second;

                bool differs = false;
                if (!n_ids) { differs = !d_ids.empty(); }
                else if (d_ids.size() != n_ids->size()) { differs = true; }
                else if (!d_ids.empty() && memcmp(d_ids.data(), n_ids->data(), d_ids.size() * 4) != 0) { differs = true; }

                if (differs) {
                    wval(corr_data, &cid, 8);
                    uint16_t count = n_ids ? static_cast<uint16_t>(n_ids->size()) : 0;
                    wval(corr_data, &count, 2);
                    if (n_ids && !n_ids->empty())
                        corr_data.insert(corr_data.end(), (const char*)n_ids->data(),
                                        (const char*)n_ids->data() + n_ids->size() * 4);
                    diff_count++;
                }
            }
            // Also check for cells only in new (not in derived)
            std::unordered_set<uint64_t> derived_set;
            for (auto& [cid, _] : derived_parsed) derived_set.insert(cid);
            for (auto& [cid, ids] : new_parsed) {
                if (!derived_set.count(cid) && !ids.empty()) {
                    wval(corr_data, &cid, 8);
                    uint16_t count = static_cast<uint16_t>(ids.size());
                    wval(corr_data, &count, 2);
                    corr_data.insert(corr_data.end(), (const char*)ids.data(),
                                    (const char*)ids.data() + ids.size() * 4);
                    diff_count++;
                }
            }

            uint32_t marker = ENTRY_CORRECTION_MARKER;
            uint32_t file = static_cast<uint32_t>(fid);
            wval(patch, &marker, 4);
            wval(patch, &file, 4);
            wval(patch, &diff_count, 4);
            patch.insert(patch.end(), corr_data.begin(), corr_data.end());
            std::cerr << "  " << fname << ": " << diff_count << " cell corrections ("
                      << corr_data.size() << " bytes)" << std::endl;
        };

        // Geo entries: street, addr, interp
        write_entry_correction(PatchFileId::STREET_ENTRIES, "street_entries.bin",
            derived.street_entries_data, derived.geo_cells_data, 20, 8);
        write_entry_correction(PatchFileId::ADDR_ENTRIES, "addr_entries.bin",
            derived.addr_entries_data, derived.geo_cells_data, 20, 12);
        write_entry_correction(PatchFileId::INTERP_ENTRIES, "interp_entries.bin",
            derived.interp_entries_data, derived.geo_cells_data, 20, 16);
        // Admin entries
        write_entry_correction(PatchFileId::ADMIN_ENTRIES, "admin_entries.bin",
            admin_derived.admin_entries_data, admin_derived.admin_cells_data, 12, 8);

        // Cell flag corrections: identify cells whose has_street/has_addr/has_interp
        // flags differ between old and new. Only ~3K cells typically.
        // The patch tool uses these to correctly assign entry offsets during geo_cells rebuild.
        {
            auto new_geo_data = read_file(new_dir + "/geo_cells.bin");
            std::unordered_map<uint64_t, uint8_t> old_cell_flags;
            for (size_t i = 0; i < old_geo.size() / 20; i++) {
                uint64_t cid; memcpy(&cid, old_geo.data() + i * 20, 8);
                uint32_t s, a, ip;
                memcpy(&s, old_geo.data()+i*20+8, 4); memcpy(&a, old_geo.data()+i*20+12, 4);
                memcpy(&ip, old_geo.data()+i*20+16, 4);
                uint8_t flags = (s != 0xFFFFFFFF ? 1 : 0) | (a != 0xFFFFFFFF ? 2 : 0) | (ip != 0xFFFFFFFF ? 4 : 0);
                old_cell_flags[cid] = flags;
            }
            std::vector<std::pair<uint64_t, uint8_t>> flag_corrections;
            for (size_t i = 0; i < new_geo_data.size() / 20; i++) {
                uint64_t cid; memcpy(&cid, new_geo_data.data() + i * 20, 8);
                uint32_t s, a, ip;
                memcpy(&s, new_geo_data.data()+i*20+8, 4); memcpy(&a, new_geo_data.data()+i*20+12, 4);
                memcpy(&ip, new_geo_data.data()+i*20+16, 4);
                uint8_t new_flags = (s != 0xFFFFFFFF ? 1 : 0) | (a != 0xFFFFFFFF ? 2 : 0) | (ip != 0xFFFFFFFF ? 4 : 0);
                auto it = old_cell_flags.find(cid);
                uint8_t old_f = (it != old_cell_flags.end()) ? it->second : 0;
                if (new_flags != old_f)
                    flag_corrections.push_back({cid, new_flags});
            }
            uint32_t fm = CELL_FLAGS_MARKER;
            uint32_t fc = static_cast<uint32_t>(flag_corrections.size());
            wval(patch, &fm, 4); wval(patch, &fc, 4);
            for (auto& [cid, flags] : flag_corrections) {
                wval(patch, &cid, 8); wval(patch, &flags, 1);
            }
            std::cerr << "  Cell flag corrections: " << fc << " cells ("
                      << fc * 9 << " bytes)" << std::endl;
        }
    }

    // End marker
    uint32_t end_marker = 0xFFFFFFFF;
    wval(patch, &end_marker, 4);

    std::cerr << "\nUncompressed patch: " << patch.size() << " bytes ("
              << patch.size() / 1024 / 1024 << " MiB)" << std::endl;

    // Compress whole patch with zstd for transport
    {
        std::string raw_path = tmpdir + "/patch.raw";
        write_file(raw_path, patch);
        std::string cmd = "zstd -19 '" + raw_path + "' -o '" + patch_path + "' -f --quiet 2>/dev/null";
        system(cmd.c_str());
        auto compressed = read_file(patch_path);
        std::cerr << "Compressed patch: " << compressed.size() << " bytes ("
                  << compressed.size() / 1024 / 1024 << " MiB)" << std::endl;
        remove(raw_path.c_str());
    }

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

-        auto av_seq = build_child_merge(ap_seq, old_ap_orig, new_data, old_v, new_v, admin_stride);

+        auto av_seq = build_child_merge(ap_seq, old_ap_orig, new_data, old_v, new_v, admin_stride, true);


Bash

make -j$(nproc) geocoder-diff geocoder-patch 2>&1

Rebuild with fix


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


Good. Now deploy and test again:


sshpass -p 'elon-is-insane' scp -o ProxyCommand="tailscale nc %h %p" -o StrictHostKeyChecking=no /root/traccar-geocoder/builder/build/geocoder-diff /root/traccar-geocoder/builder/build/geocoder-patch michtest@100.113.88.121:/home/michtest/ 2>&1

Deploy fixed tools


(Bash completed with no output)


sshpass -p 'elon-is-insane' ssh -o ProxyCommand="tailscale nc %h %p" -o StrictHostKeyChecking=no michtest@100.113.88.121 "cd /home/michtest && time ./geocoder-diff det-A/ det-today/ -o patch-europe-parallel.gcpatch 2>&1 && echo '---' && echo -n 'Patch identical to validated: ' && if cmp -s patch-europe-parallel.gcpatch validated-patches/europe-mar21-to-mar27-v2.gcpatch; then echo YES; else echo NO; ls -l patch-europe-parallel.gcpatch validated-patches/europe-mar21-to-mar27-v2.gcpatch; fi" 2>&1

Run Europe diff with parallel tool and validate


Building string remap...
  String remap: derived from string diff (0 explicit entries)
  strings.bin: +4152 -2396 strings
Building merge sequences (parallel)...
  interp_ways.bin: seq=545 fixups=13395 (0.16%)
  interp_nodes.bin: seq=71135 fixups=0 (9.81%)
  admin_polygons.bin: seq=95018 fixups=449047 (0.88%)
  admin_vertices.bin: seq=7106042 fixups=0 (2.44%)
  street_ways.bin: seq=2714467 fixups=19361289 (1.16%)
  street_nodes.bin: seq=108326877 fixups=0 (7.49%)
  addr_points.bin: seq=4405789 fixups=0 (0.32%)
All merge sequences built.
  (entry/cell files: omitted, rebuilt by patch tool)
  Geo cell changes: +58772 -12627 (571192 bytes)
  Admin cell changes: +0 -0
  Secondary way matches: 29127
  Secondary addr matches: 55444
  Secondary interp matches: 8
  Secondary admin matches: 2320
  Secondary remap section: 86899 pairs (695192 bytes)
  street_entries.bin: 549325 cell corrections (9065358 bytes)
  addr_entries.bin: 64334 cell corrections (1668720 bytes)
  interp_entries.bin: 37 cell corrections (466 bytes)
  admin_entries.bin: 1764 cell corrections (55132 bytes)
  Cell flag corrections: 83178 cells (748602 bytes)

Uncompressed patch: 175387197 bytes (167 MiB)
Compressed patch: 34388648 bytes (32 MiB)
---
Patch identical to validated:
real	7m27.323s
user	8m10.957s
sys	0m9.297s
YES


**Europe patch: IDENTICAL to validated.** And the time went from 8m8s to 7m27s — the parallel merge building saved ~40 seconds. The bigger savings will be on planet where the merge builds are much heavier.

Now verify the patch tool too:


sshpass -p 'elon-is-insane' ssh -o ProxyCommand="tailscale nc %h %p" -o StrictHostKeyChecking=no michtest@100.113.88.121 "cd /home/michtest && rm -rf patched-europe-par/ && time ./geocoder-patch det-A/ patch-europe-parallel.gcpatch -o patched-europe-par/ 2>&1 && echo '---' && for f in det-today/*; do b=\$(basename \$f); echo -n \"\$b: \"; if cmp -s patched-europe-par/\$b det-today/\$b; then echo MATCH; else echo MISMATCH; fi; done" 2>&1

Apply Europe patch with mmap tool and verify


The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.

Note: The user's next message may contain a correction or preference. Pay close attention — if they explain what went wrong or how they'd prefer you to work, consider saving that to memory for future sessions.


[Request interrupted by user for tool use]


Surely we can speed the process up much more than that?


You're right — the merge parallelization only shaved ~40s because the real bottlenecks are the secondary matching (re-reads all files a second time = 6+ GiB wasted I/O) and the entry correction phase (reads all entries, rebuilds, compares). Let me fix both:

1. **Move secondary matching into the parallel merge groups** — compute it while files are already in memory, zero extra I/O
2. **Parallelize entry corrections** — the 4 correction comparisons are independent
3. **For the patch tool: parallelize merge replays** — pre-scan section boundaries, replay all files in parallel


   695→    // These are small relative to data files and rebuilt by patch tool isn't reliable.
   696→    // Include them as full replacement. zstd transport compression handles the rest.
   697→    // Entry/cell files: NOT included.
   698→    // The patch tool reconstructs them from the derived ID remaps
   699→    // (extracted implicitly from the merge sequences above).
   700→    // geo_cells.bin, admin_cells.bin, and all entry files are rebuilt.
   701→    std::cerr << "  (entry/cell files: omitted, rebuilt by patch tool)" << std::endl;
   702→
   703→    // Include cell_id changes for entry reconstruction
   704→    {
   705→        // Geo cell changes
   706→        auto new_geo = read_file(new_dir + "/geo_cells.bin");
   707→        auto old_geo = read_file(old_dir + "/geo_cells.bin");
   708→        std::unordered_set<uint64_t> old_set, new_set;
   709→        for (size_t i = 0; i < old_geo.size() / 20; i++) {
   710→            uint64_t c; memcpy(&c, old_geo.data() + i * 20, 8); old_set.insert(c);
   711→        }
   712→        for (size_t i = 0; i < new_geo.size() / 20; i++) {
   713→            uint64_t c; memcpy(&c, new_geo.data() + i * 20, 8); new_set.insert(c);
   714→        }
   715→        std::vector<uint64_t> g_added, g_removed;
   716→        for (auto c : new_set) if (!old_set.count(c)) g_added.push_back(c);
   717→        for (auto c : old_set) if (!new_set.count(c)) g_removed.push_back(c);
   718→        std::sort(g_added.begin(), g_added.end());
   719→        std::sort(g_removed.begin(), g_removed.end());
   720→
   721→        uint32_t marker = CELL_CHANGES_GEO_MARKER;
   722→        uint32_t na = g_added.size(), nr = g_removed.size();
   723→        wval(patch, &marker, 4); wval(patch, &na, 4); wval(patch, &nr, 4);
   724→        for (auto c : g_added) wval(patch, &c, 8);
   725→        for (auto c : g_removed) wval(patch, &c, 8);
   726→        std::cerr << "  Geo cell changes: +" << na << " -" << nr << " ("
   727→                  << (na + nr) * 8 << " bytes)" << std::endl;
   728→
   729→        // Admin cell changes
   730→        auto new_ac = read_file(new_dir + "/admin_cells.bin");
   731→        auto old_ac = read_file(old_dir + "/admin_cells.bin");
   732→        old_set.clear(); new_set.clear();
   733→        for (size_t i = 0; i < old_ac.size() / 12; i++) {
   734→            uint64_t c; memcpy(&c, old_ac.data() + i * 12, 8); old_set.insert(c);
   735→        }
   736→        for (size_t i = 0; i < new_ac.size() / 12; i++) {
   737→            uint64_t c; memcpy(&c, new_ac.data() + i * 12, 8); new_set.insert(c);
   738→        }
   739→        std::vector<uint64_t> a_added, a_removed;
   740→        for (auto c : new_set) if (!old_set.count(c)) a_added.push_back(c);
   741→        for (auto c : old_set) if (!new_set.count(c)) a_removed.push_back(c);
   742→        std::sort(a_added.begin(), a_added.end());
   743→        std::sort(a_removed.begin(), a_removed.end());
   744→
   745→        marker = CELL_CHANGES_ADMIN_MARKER;
   746→        na = a_added.size(); nr = a_removed.size();
   747→        wval(patch, &marker, 4); wval(patch, &na, 4); wval(patch, &nr, 4);
   748→        for (auto c : a_added) wval(patch, &c, 8);
   749→        for (auto c : a_removed) wval(patch, &c, 8);
   750→        std::cerr << "  Admin cell changes: +" << na << " -" << nr << std::endl;
   751→
   752→        // Derive ID remaps from stored merge sequences (same as patch tool does)
   753→        auto derive_remap = [&](PatchFileId id, size_t old_size, size_t stride) -> std::unordered_map<uint32_t,uint32_t> {
   754→            auto it = stored_merges.find(static_cast<uint32_t>(id));
   755→            if (it == stored_merges.end()) return {};
   756→            auto vec = derive_id_remap_from_merge(it->second, old_size / stride, stride);
   757→            std::unordered_map<uint32_t,uint32_t> m;
   758→            for (uint32_t i = 0; i < vec.size(); i++)
   759→                if (vec[i] != 0xFFFFFFFF) m[i] = vec[i];
   760→            return m;
   761→        };
   762→
   763→        auto w_rm_d = derive_remap(PatchFileId::STREET_WAYS,
   764→            read_file(old_dir + "/street_ways.bin").size(), way_stride);
   765→        auto a_rm_d = derive_remap(PatchFileId::ADDR_POINTS,
   766→            read_file(old_dir + "/addr_points.bin").size(), 16);
   767→        auto i_rm_d = derive_remap(PatchFileId::INTERP_WAYS,
   768→            read_file(old_dir + "/interp_ways.bin").size(), interp_stride);
   769→        auto ad_rm_d = derive_remap(PatchFileId::ADMIN_POLYGONS,
   770→            read_file(old_dir + "/admin_polygons.bin").size(), admin_stride);
   771→
   772→        // Secondary matching: recover additional ID mappings for modified records
   773→        // that didn't byte-match but represent the same entity (e.g. geometry edits).
   774→        // These are emitted into the patch so the patch tool can apply the same remaps.
   775→        std::unordered_map<uint32_t, uint32_t> soft_w, soft_a, soft_i, soft_ad;
   776→        {
   777→            size_t way_name_off = (way_stride == 12) ? 8 : 5;
   778→
   779→            // Street ways: match by name_id + node_count
   780→            auto old_w2 = read_file(old_dir + "/street_ways.bin");
   781→            remap_field(old_w2, way_stride, way_name_off, str_remap);
   782→            auto new_w2 = read_file(new_dir + "/street_ways.bin");
   783→            soft_w = secondary_match_from_merge(
   784→                stored_merges[static_cast<uint32_t>(PatchFileId::STREET_WAYS)],
   785→                old_w2, new_w2, way_stride,
   786→                [way_name_off](const char* rec) -> uint64_t {
   787→                    uint32_t name_id; memcpy(&name_id, rec + way_name_off, 4);
   788→                    uint8_t nc = static_cast<uint8_t>(rec[4]);
   789→                    return ((uint64_t)name_id << 8) | nc;
   790→                });
   791→            std::cerr << "  Secondary way matches: " << soft_w.size() << std::endl;
   792→
   793→            // Addr points: match by (street_id, housenumber_id)
   794→            auto old_a2 = read_file(old_dir + "/addr_points.bin");

    // These are small relative to data files and rebuilt by patch tool isn't reliable.
    // Include them as full replacement. zstd transport compression handles the rest.
    // Entry/cell files: NOT included.
    // The patch tool reconstructs them from the derived ID remaps
    // (extracted implicitly from the merge sequences above).
    // geo_cells.bin, admin_cells.bin, and all entry files are rebuilt.
    std::cerr << "  (entry/cell files: omitted, rebuilt by patch tool)" << std::endl;

    // Include cell_id changes for entry reconstruction
    {
        // Geo cell changes
        auto new_geo = read_file(new_dir + "/geo_cells.bin");
        auto old_geo = read_file(old_dir + "/geo_cells.bin");
        std::unordered_set<uint64_t> old_set, new_set;
        for (size_t i = 0; i < old_geo.size() / 20; i++) {
            uint64_t c; memcpy(&c, old_geo.data() + i * 20, 8); old_set.insert(c);
        }
        for (size_t i = 0; i < new_geo.size() / 20; i++) {
            uint64_t c; memcpy(&c, new_geo.data() + i * 20, 8); new_set.insert(c);
        }
        std::vector<uint64_t> g_added, g_removed;
        for (auto c : new_set) if (!old_set.count(c)) g_added.push_back(c);
        for (auto c : old_set) if (!new_set.count(c)) g_removed.push_back(c);
        std::sort(g_added.begin(), g_added.end());
        std::sort(g_removed.begin(), g_removed.end());

        uint32_t marker = CELL_CHANGES_GEO_MARKER;
        uint32_t na = g_added.size(), nr = g_removed.size();
        wval(patch, &marker, 4); wval(patch, &na, 4); wval(patch, &nr, 4);
        for (auto c : g_added) wval(patch, &c, 8);
        for (auto c : g_removed) wval(patch, &c, 8);
        std::cerr << "  Geo cell changes: +" << na << " -" << nr << " ("
                  << (na + nr) * 8 << " bytes)" << std::endl;

        // Admin cell changes
        auto new_ac = read_file(new_dir + "/admin_cells.bin");
        auto old_ac = read_file(old_dir + "/admin_cells.bin");
        old_set.clear(); new_set.clear();
        for (size_t i = 0; i < old_ac.size() / 12; i++) {
            uint64_t c; memcpy(&c, old_ac.data() + i * 12, 8); old_set.insert(c);
        }
        for (size_t i = 0; i < new_ac.size() / 12; i++) {
            uint64_t c; memcpy(&c, new_ac.data() + i * 12, 8); new_set.insert(c);
        }
        std::vector<uint64_t> a_added, a_removed;
        for (auto c : new_set) if (!old_set.count(c)) a_added.push_back(c);
        for (auto c : old_set) if (!new_set.count(c)) a_removed.push_back(c);
        std::sort(a_added.begin(), a_added.end());
        std::sort(a_removed.begin(), a_removed.end());

        marker = CELL_CHANGES_ADMIN_MARKER;
        na = a_added.size(); nr = a_removed.size();
        wval(patch, &marker, 4); wval(patch, &na, 4); wval(patch, &nr, 4);
        for (auto c : a_added) wval(patch, &c, 8);
        for (auto c : a_removed) wval(patch, &c, 8);
        std::cerr << "  Admin cell changes: +" << na << " -" << nr << std::endl;

        // Derive ID remaps from stored merge sequences (same as patch tool does)
        auto derive_remap = [&](PatchFileId id, size_t old_size, size_t stride) -> std::unordered_map<uint32_t,uint32_t> {
            auto it = stored_merges.find(static_cast<uint32_t>(id));
            if (it == stored_merges.end()) return {};
            auto vec = derive_id_remap_from_merge(it->second, old_size / stride, stride);
            std::unordered_map<uint32_t,uint32_t> m;
            for (uint32_t i = 0; i < vec.size(); i++)
                if (vec[i] != 0xFFFFFFFF) m[i] = vec[i];
            return m;
        };

        auto w_rm_d = derive_remap(PatchFileId::STREET_WAYS,
            read_file(old_dir + "/street_ways.bin").size(), way_stride);
        auto a_rm_d = derive_remap(PatchFileId::ADDR_POINTS,
            read_file(old_dir + "/addr_points.bin").size(), 16);
        auto i_rm_d = derive_remap(PatchFileId::INTERP_WAYS,
            read_file(old_dir + "/interp_ways.bin").size(), interp_stride);
        auto ad_rm_d = derive_remap(PatchFileId::ADMIN_POLYGONS,
            read_file(old_dir + "/admin_polygons.bin").size(), admin_stride);

        // Secondary matching: recover additional ID mappings for modified records
        // that didn't byte-match but represent the same entity (e.g. geometry edits).
        // These are emitted into the patch so the patch tool can apply the same remaps.
        std::unordered_map<uint32_t, uint32_t> soft_w, soft_a, soft_i, soft_ad;
        {
            size_t way_name_off = (way_stride == 12) ? 8 : 5;

            // Street ways: match by name_id + node_count
            auto old_w2 = read_file(old_dir + "/street_ways.bin");
            remap_field(old_w2, way_stride, way_name_off, str_remap);
            auto new_w2 = read_file(new_dir + "/street_ways.bin");
            soft_w = secondary_match_from_merge(
                stored_merges[static_cast<uint32_t>(PatchFileId::STREET_WAYS)],
                old_w2, new_w2, way_stride,
                [way_name_off](const char* rec) -> uint64_t {
                    uint32_t name_id; memcpy(&name_id, rec + way_name_off, 4);
                    uint8_t nc = static_cast<uint8_t>(rec[4]);
                    return ((uint64_t)name_id << 8) | nc;
                });
            std::cerr << "  Secondary way matches: " << soft_w.size() << std::endl;

            // Addr points: match by (street_id, housenumber_id)
            auto old_a2 = read_file(old_dir + "/addr_points.bin");


   794→            auto old_a2 = read_file(old_dir + "/addr_points.bin");
   795→            remap_addr_points(old_a2, str_remap);
   796→            auto new_a2 = read_file(new_dir + "/addr_points.bin");
   797→            soft_a = secondary_match_from_merge(
   798→                stored_merges[static_cast<uint32_t>(PatchFileId::ADDR_POINTS)],
   799→                old_a2, new_a2, 16,
   800→                [](const char* rec) -> uint64_t {
   801→                    uint32_t hn_id, st_id;
   802→                    memcpy(&hn_id, rec + 8, 4); memcpy(&st_id, rec + 12, 4);
   803→                    return ((uint64_t)st_id << 32) | hn_id;
   804→                });
   805→            std::cerr << "  Secondary addr matches: " << soft_a.size() << std::endl;
   806→
   807→            // Interp ways: match by (street_id, start_number)
   808→            size_t ist_off = (interp_stride >= 20) ? 8 : 5;
   809→            auto old_i2 = read_file(old_dir + "/interp_ways.bin");
   810→            remap_field(old_i2, interp_stride, ist_off, str_remap);
   811→            auto new_i2 = read_file(new_dir + "/interp_ways.bin");
   812→            soft_i = secondary_match_from_merge(
   813→                stored_merges[static_cast<uint32_t>(PatchFileId::INTERP_WAYS)],
   814→                old_i2, new_i2, interp_stride,
   815→                [ist_off](const char* rec) -> uint64_t {
   816→                    uint32_t street_id, start;
   817→                    memcpy(&street_id, rec + ist_off, 4);
   818→                    memcpy(&start, rec + ist_off + 4, 4);
   819→                    return ((uint64_t)street_id << 32) | start;
   820→                });
   821→            std::cerr << "  Secondary interp matches: " << soft_i.size() << std::endl;
   822→
   823→            // Admin polygons: match by (name_id, admin_level, country_code)
   824→            auto old_ap2 = read_file(old_dir + "/admin_polygons.bin");
   825→            remap_field(old_ap2, admin_stride, 8, str_remap);
   826→            auto new_ap2 = read_file(new_dir + "/admin_polygons.bin");
   827→            soft_ad = secondary_match_from_merge(
   828→                stored_merges[static_cast<uint32_t>(PatchFileId::ADMIN_POLYGONS)],
   829→                old_ap2, new_ap2, admin_stride,
   830→                [](const char* rec) -> uint64_t {
   831→                    uint32_t name_id; memcpy(&name_id, rec + 8, 4);
   832→                    uint8_t level = static_cast<uint8_t>(rec[12]);
   833→                    uint16_t cc; memcpy(&cc, rec + 20, 2);
   834→                    return ((uint64_t)name_id << 24) | ((uint64_t)level << 16) | cc;
   835→                });
   836→            std::cerr << "  Secondary admin matches: " << soft_ad.size() << std::endl;
   837→        }
   838→
   839→        // Emit secondary remap section into patch (before entry corrections)
   840→        {
   841→            uint32_t marker = SECONDARY_REMAP_MARKER;
   842→            wval(patch, &marker, 4);
   843→            // Count non-empty file remaps
   844→            auto emit_remap = [&](PatchFileId fid, const std::unordered_map<uint32_t,uint32_t>& rm) {
   845→                uint32_t file = static_cast<uint32_t>(fid);
   846→                uint32_t count = static_cast<uint32_t>(rm.size());
   847→                wval(patch, &file, 4);
   848→                wval(patch, &count, 4);
   849→                for (auto& [o, n] : rm) {
   850→                    wval(patch, &o, 4); wval(patch, &n, 4);
   851→                }
   852→            };
   853→            uint32_t n_files = 4;
   854→            wval(patch, &n_files, 4);
   855→            emit_remap(PatchFileId::STREET_WAYS, soft_w);
   856→            emit_remap(PatchFileId::ADDR_POINTS, soft_a);
   857→            emit_remap(PatchFileId::INTERP_WAYS, soft_i);
   858→            emit_remap(PatchFileId::ADMIN_POLYGONS, soft_ad);
   859→            size_t total_pairs = soft_w.size() + soft_a.size() + soft_i.size() + soft_ad.size();
   860→            std::cerr << "  Secondary remap section: " << total_pairs << " pairs ("
   861→                      << total_pairs * 8 << " bytes)" << std::endl;
   862→        }
   863→
   864→        // Merge secondary matches into derived remaps
   865→        for (auto& [o, n] : soft_w) w_rm_d[o] = n;
   866→        for (auto& [o, n] : soft_a) a_rm_d[o] = n;
   867→        for (auto& [o, n] : soft_i) i_rm_d[o] = n;
   868→        for (auto& [o, n] : soft_ad) ad_rm_d[o] = n;
   869→
   870→        // Rebuild entries using derived remaps (same as patch tool)
   871→        auto old_se2 = read_file(old_dir + "/street_entries.bin");
   872→        auto old_ae2 = read_file(old_dir + "/addr_entries.bin");
   873→        auto old_ie2 = read_file(old_dir + "/interp_entries.bin");

            auto old_a2 = read_file(old_dir + "/addr_points.bin");
            remap_addr_points(old_a2, str_remap);
            auto new_a2 = read_file(new_dir + "/addr_points.bin");
            soft_a = secondary_match_from_merge(
                stored_merges[static_cast<uint32_t>(PatchFileId::ADDR_POINTS)],
                old_a2, new_a2, 16,
                [](const char* rec) -> uint64_t {
                    uint32_t hn_id, st_id;
                    memcpy(&hn_id, rec + 8, 4); memcpy(&st_id, rec + 12, 4);
                    return ((uint64_t)st_id << 32) | hn_id;
                });
            std::cerr << "  Secondary addr matches: " << soft_a.size() << std::endl;

            // Interp ways: match by (street_id, start_number)
            size_t ist_off = (interp_stride >= 20) ? 8 : 5;
            auto old_i2 = read_file(old_dir + "/interp_ways.bin");
            remap_field(old_i2, interp_stride, ist_off, str_remap);
            auto new_i2 = read_file(new_dir + "/interp_ways.bin");
            soft_i = secondary_match_from_merge(
                stored_merges[static_cast<uint32_t>(PatchFileId::INTERP_WAYS)],
                old_i2, new_i2, interp_stride,
                [ist_off](const char* rec) -> uint64_t {
                    uint32_t street_id, start;
                    memcpy(&street_id, rec + ist_off, 4);
                    memcpy(&start, rec + ist_off + 4, 4);
                    return ((uint64_t)street_id << 32) | start;
                });
            std::cerr << "  Secondary interp matches: " << soft_i.size() << std::endl;

            // Admin polygons: match by (name_id, admin_level, country_code)
            auto old_ap2 = read_file(old_dir + "/admin_polygons.bin");
            remap_field(old_ap2, admin_stride, 8, str_remap);
            auto new_ap2 = read_file(new_dir + "/admin_polygons.bin");
            soft_ad = secondary_match_from_merge(
                stored_merges[static_cast<uint32_t>(PatchFileId::ADMIN_POLYGONS)],
                old_ap2, new_ap2, admin_stride,
                [](const char* rec) -> uint64_t {
                    uint32_t name_id; memcpy(&name_id, rec + 8, 4);
                    uint8_t level = static_cast<uint8_t>(rec[12]);
                    uint16_t cc; memcpy(&cc, rec + 20, 2);
                    return ((uint64_t)name_id << 24) | ((uint64_t)level << 16) | cc;
                });
            std::cerr << "  Secondary admin matches: " << soft_ad.size() << std::endl;
        }

        // Emit secondary remap section into patch (before entry corrections)
        {
            uint32_t marker = SECONDARY_REMAP_MARKER;
            wval(patch, &marker, 4);
            // Count non-empty file remaps
            auto emit_remap = [&](PatchFileId fid, const std::unordered_map<uint32_t,uint32_t>& rm) {
                uint32_t file = static_cast<uint32_t>(fid);
                uint32_t count = static_cast<uint32_t>(rm.size());
                wval(patch, &file, 4);
                wval(patch, &count, 4);
                for (auto& [o, n] : rm) {
                    wval(patch, &o, 4); wval(patch, &n, 4);
                }
            };
            uint32_t n_files = 4;
            wval(patch, &n_files, 4);
            emit_remap(PatchFileId::STREET_WAYS, soft_w);
            emit_remap(PatchFileId::ADDR_POINTS, soft_a);
            emit_remap(PatchFileId::INTERP_WAYS, soft_i);
            emit_remap(PatchFileId::ADMIN_POLYGONS, soft_ad);
            size_t total_pairs = soft_w.size() + soft_a.size() + soft_i.size() + soft_ad.size();
            std::cerr << "  Secondary remap section: " << total_pairs << " pairs ("
                      << total_pairs * 8 << " bytes)" << std::endl;
        }

        // Merge secondary matches into derived remaps
        for (auto& [o, n] : soft_w) w_rm_d[o] = n;
        for (auto& [o, n] : soft_a) a_rm_d[o] = n;
        for (auto& [o, n] : soft_i) i_rm_d[o] = n;
        for (auto& [o, n] : soft_ad) ad_rm_d[o] = n;

        // Rebuild entries using derived remaps (same as patch tool)
        auto old_se2 = read_file(old_dir + "/street_entries.bin");
        auto old_ae2 = read_file(old_dir + "/addr_entries.bin");
        auto old_ie2 = read_file(old_dir + "/interp_entries.bin");


Now I need to restructure this significantly. The plan:

1. Move secondary matching INTO each parallel merge group (avoid re-reading files)
2. The derive_remap calls currently re-read old files just for their size — store sizes from merge results instead
3. Parallelize the entry correction comparisons

Let me replace the entire block from line 695 to the end of the entry correction section. First let me see where it ends:


   870→        // Rebuild entries using derived remaps (same as patch tool)
   871→        auto old_se2 = read_file(old_dir + "/street_entries.bin");
   872→        auto old_ae2 = read_file(old_dir + "/addr_entries.bin");
   873→        auto old_ie2 = read_file(old_dir + "/interp_entries.bin");
   874→        auto derived = rebuild_geo_from_remap(old_geo, old_se2, old_ae2, old_ie2,
   875→                                               w_rm_d, a_rm_d, i_rm_d, g_added, g_removed);
   876→        auto old_admc = read_file(old_dir + "/admin_cells.bin");
   877→        auto old_adme = read_file(old_dir + "/admin_entries.bin");
   878→        auto admin_derived = rebuild_admin_from_remap(old_admc, old_adme, ad_rm_d, a_added, a_removed);
   879→
   880→        // Cell-level entry corrections: compare derived vs new entries cell by cell.
   881→        // Include only cells whose entry data differs.
   882→        // This is fully custom — no zstd, no temp files.
   883→        auto write_entry_correction = [&](PatchFileId fid, const std::string& fname,
   884→                                           const std::vector<char>& derived_entries,
   885→                                           const std::vector<char>& derived_cells, size_t cell_stride,
   886→                                           size_t offset_pos) {
   887→            auto new_entries = read_file(new_dir + "/" + fname);
   888→            auto new_cells_data = read_file(new_dir + "/" +
   889→                (fid == PatchFileId::ADMIN_ENTRIES ? "admin_cells.bin" : "geo_cells.bin"));
   890→
   891→            // Parse both derived and new entries by cell
   892→            auto parse_all = [](const std::vector<char>& cells, size_t stride, size_t off_pos,
   893→                                 const std::vector<char>& entries)
   894→                -> std::vector<std::pair<uint64_t, std::vector<uint32_t>>>
   895→            {
   896→                size_t n = cells.size() / stride;
   897→                std::vector<std::pair<uint64_t, std::vector<uint32_t>>> result;
   898→                result.reserve(n);
   899→                for (size_t i = 0; i < n; i++) {
   900→                    uint64_t cid; memcpy(&cid, cells.data() + i * stride, 8);
   901→                    uint32_t off; memcpy(&off, cells.data() + i * stride + off_pos, 4);
   902→                    std::vector<uint32_t> ids;
   903→                    if (off != 0xFFFFFFFF && off + 2 <= entries.size()) {
   904→                        uint16_t count; memcpy(&count, entries.data() + off, 2);
   905→                        if (off + 2 + count * 4 <= entries.size()) {
   906→                            ids.resize(count);
   907→                            memcpy(ids.data(), entries.data() + off + 2, count * 4);
   908→                        }
   909→                    }
   910→                    result.push_back({cid, std::move(ids)});
   911→                }
   912→                return result;
   913→            };
   914→
   915→            auto derived_parsed = parse_all(derived_cells, cell_stride, offset_pos, derived_entries);
   916→            auto new_parsed = parse_all(new_cells_data,
   917→                cell_stride == 12 ? 12 : 20,
   918→                cell_stride == 12 ? 8 : offset_pos,
   919→                new_entries);
   920→
   921→            // Build new cell map for lookup
   922→            std::unordered_map<uint64_t, const std::vector<uint32_t>*> new_map;
   923→            for (auto& [cid, ids] : new_parsed) new_map[cid] = &ids;
   924→
   925→            // Find differing cells
   926→            std::vector<char> corr_data;
   927→            uint32_t diff_count = 0;
   928→            for (auto& [cid, d_ids] : derived_parsed) {
   929→                auto it = new_map.find(cid);
   930→                const std::vector<uint32_t>* n_ids = nullptr;
   931→                if (it != new_map.end()) n_ids = it->second;
   932→
   933→                bool differs = false;
   934→                if (!n_ids) { differs = !d_ids.empty(); }
   935→                else if (d_ids.size() != n_ids->size()) { differs = true; }
   936→                else if (!d_ids.empty() && memcmp(d_ids.data(), n_ids->data(), d_ids.size() * 4) != 0) { differs = true; }
   937→
   938→                if (differs) {
   939→                    wval(corr_data, &cid, 8);
   940→                    uint16_t count = n_ids ? static_cast<uint16_t>(n_ids->size()) : 0;
   941→                    wval(corr_data, &count, 2);
   942→                    if (n_ids && !n_ids->empty())
   943→                        corr_data.insert(corr_data.end(), (const char*)n_ids->data(),
   944→                                        (const char*)n_ids->data() + n_ids->size() * 4);
   945→                    diff_count++;
   946→                }
   947→            }
   948→            // Also check for cells only in new (not in derived)
   949→            std::unordered_set<uint64_t> derived_set;
   950→            for (auto& [cid, _] : derived_parsed) derived_set.insert(cid);
   951→            for (auto& [cid, ids] : new_parsed) {
   952→                if (!derived_set.count(cid) && !ids.empty()) {
   953→                    wval(corr_data, &cid, 8);
   954→                    uint16_t count = static_cast<uint16_t>(ids.size());
   955→                    wval(corr_data, &count, 2);
   956→                    corr_data.insert(corr_data.end(), (const char*)ids.data(),
   957→                                    (const char*)ids.data() + ids.size() * 4);
   958→                    diff_count++;
   959→                }
   960→            }
   961→
   962→            uint32_t marker = ENTRY_CORRECTION_MARKER;
   963→            uint32_t file = static_cast<uint32_t>(fid);
   964→            wval(patch, &marker, 4);
   965→            wval(patch, &file, 4);
   966→            wval(patch, &diff_count, 4);
   967→            patch.insert(patch.end(), corr_data.begin(), corr_data.end());
   968→            std::cerr << "  " << fname << ": " << diff_count << " cell corrections ("
   969→                      << corr_data.size() << " bytes)" << std::endl;
   970→        };
   971→
   972→        // Geo entries: street, addr, interp
   973→        write_entry_correction(PatchFileId::STREET_ENTRIES, "street_entries.bin",
   974→            derived.street_entries_data, derived.geo_cells_data, 20, 8);
   975→        write_entry_correction(PatchFileId::ADDR_ENTRIES, "addr_entries.bin",
   976→            derived.addr_entries_data, derived.geo_cells_data, 20, 12);
   977→        write_entry_correction(PatchFileId::INTERP_ENTRIES, "interp_entries.bin",
   978→            derived.interp_entries_data, derived.geo_cells_data, 20, 16);
   979→        // Admin entries
   980→        write_entry_correction(PatchFileId::ADMIN_ENTRIES, "admin_entries.bin",
   981→            admin_derived.admin_entries_data, admin_derived.admin_cells_data, 12, 8);
   982→
   983→        // Cell flag corrections: identify cells whose has_street/has_addr/has_interp
   984→        // flags differ between old and new. Only ~3K cells typically.
   985→        // The patch tool uses these to correctly assign entry offsets during geo_cells rebuild.
   986→        {
   987→            auto new_geo_data = read_file(new_dir + "/geo_cells.bin");
   988→            std::unordered_map<uint64_t, uint8_t> old_cell_flags;
   989→            for (size_t i = 0; i < old_geo.size() / 20; i++) {
   990→                uint64_t cid; memcpy(&cid, old_geo.data() + i * 20, 8);
   991→                uint32_t s, a, ip;
   992→                memcpy(&s, old_geo.data()+i*20+8, 4); memcpy(&a, old_geo.data()+i*20+12, 4);
   993→                memcpy(&ip, old_geo.data()+i*20+16, 4);
   994→                uint8_t flags = (s != 0xFFFFFFFF ? 1 : 0) | (a != 0xFFFFFFFF ? 2 : 0) | (ip != 0xFFFFFFFF ? 4 : 0);
   995→                old_cell_flags[cid] = flags;
   996→            }
   997→            std::vector<std::pair<uint64_t, uint8_t>> flag_corrections;
   998→            for (size_t i = 0; i < new_geo_data.size() / 20; i++) {
   999→                uint64_t cid; memcpy(&cid, new_geo_data.data() + i * 20, 8);

        // Rebuild entries using derived remaps (same as patch tool)
        auto old_se2 = read_file(old_dir + "/street_entries.bin");
        auto old_ae2 = read_file(old_dir + "/addr_entries.bin");
        auto old_ie2 = read_file(old_dir + "/interp_entries.bin");
        auto derived = rebuild_geo_from_remap(old_geo, old_se2, old_ae2, old_ie2,
                                               w_rm_d, a_rm_d, i_rm_d, g_added, g_removed);
        auto old_admc = read_file(old_dir + "/admin_cells.bin");
        auto old_adme = read_file(old_dir + "/admin_entries.bin");
        auto admin_derived = rebuild_admin_from_remap(old_admc, old_adme, ad_rm_d, a_added, a_removed);

        // Cell-level entry corrections: compare derived vs new entries cell by cell.
        // Include only cells whose entry data differs.
        // This is fully custom — no zstd, no temp files.
        auto write_entry_correction = [&](PatchFileId fid, const std::string& fname,
                                           const std::vector<char>& derived_entries,
                                           const std::vector<char>& derived_cells, size_t cell_stride,
                                           size_t offset_pos) {
            auto new_entries = read_file(new_dir + "/" + fname);
            auto new_cells_data = read_file(new_dir + "/" +
                (fid == PatchFileId::ADMIN_ENTRIES ? "admin_cells.bin" : "geo_cells.bin"));

            // Parse both derived and new entries by cell
            auto parse_all = [](const std::vector<char>& cells, size_t stride, size_t off_pos,
                                 const std::vector<char>& entries)
                -> std::vector<std::pair<uint64_t, std::vector<uint32_t>>>
            {
                size_t n = cells.size() / stride;
                std::vector<std::pair<uint64_t, std::vector<uint32_t>>> result;
                result.reserve(n);
                for (size_t i = 0; i < n; i++) {
                    uint64_t cid; memcpy(&cid, cells.data() + i * stride, 8);
                    uint32_t off; memcpy(&off, cells.data() + i * stride + off_pos, 4);
                    std::vector<uint32_t> ids;
                    if (off != 0xFFFFFFFF && off + 2 <= entries.size()) {
                        uint16_t count; memcpy(&count, entries.data() + off, 2);
                        if (off + 2 + count * 4 <= entries.size()) {
                            ids.resize(count);
                            memcpy(ids.data(), entries.data() + off + 2, count * 4);
                        }
                    }
                    result.push_back({cid, std::move(ids)});
                }
                return result;
            };

            auto derived_parsed = parse_all(derived_cells, cell_stride, offset_pos, derived_entries);
            auto new_parsed = parse_all(new_cells_data,
                cell_stride == 12 ? 12 : 20,
                cell_stride == 12 ? 8 : offset_pos,
                new_entries);

            // Build new cell map for lookup
            std::unordered_map<uint64_t, const std::vector<uint32_t>*> new_map;
            for (auto& [cid, ids] : new_parsed) new_map[cid] = &ids;

            // Find differing cells
            std::vector<char> corr_data;
            uint32_t diff_count = 0;
            for (auto& [cid, d_ids] : derived_parsed) {
                auto it = new_map.find(cid);
                const std::vector<uint32_t>* n_ids = nullptr;
                if (it != new_map.end()) n_ids = it->second;

                bool differs = false;
                if (!n_ids) { differs = !d_ids.empty(); }
                else if (d_ids.size() != n_ids->size()) { differs = true; }
                else if (!d_ids.empty() && memcmp(d_ids.data(), n_ids->data(), d_ids.size() * 4) != 0) { differs = true; }

                if (differs) {
                    wval(corr_data, &cid, 8);
                    uint16_t count = n_ids ? static_cast<uint16_t>(n_ids->size()) : 0;
                    wval(corr_data, &count, 2);
                    if (n_ids && !n_ids->empty())
                        corr_data.insert(corr_data.end(), (const char*)n_ids->data(),
                                        (const char*)n_ids->data() + n_ids->size() * 4);
                    diff_count++;
                }
            }
            // Also check for cells only in new (not in derived)
            std::unordered_set<uint64_t> derived_set;
            for (auto& [cid, _] : derived_parsed) derived_set.insert(cid);
            for (auto& [cid, ids] : new_parsed) {
                if (!derived_set.count(cid) && !ids.empty()) {
                    wval(corr_data, &cid, 8);
                    uint16_t count = static_cast<uint16_t>(ids.size());
                    wval(corr_data, &count, 2);
                    corr_data.insert(corr_data.end(), (const char*)ids.data(),
                                    (const char*)ids.data() + ids.size() * 4);
                    diff_count++;
                }
            }

            uint32_t marker = ENTRY_CORRECTION_MARKER;
            uint32_t file = static_cast<uint32_t>(fid);
            wval(patch, &marker, 4);
            wval(patch, &file, 4);
            wval(patch, &diff_count, 4);
            patch.insert(patch.end(), corr_data.begin(), corr_data.end());
            std::cerr << "  " << fname << ": " << diff_count << " cell corrections ("
                      << corr_data.size() << " bytes)" << std::endl;
        };

        // Geo entries: street, addr, interp
        write_entry_correction(PatchFileId::STREET_ENTRIES, "street_entries.bin",
            derived.street_entries_data, derived.geo_cells_data, 20, 8);
        write_entry_correction(PatchFileId::ADDR_ENTRIES, "addr_entries.bin",
            derived.addr_entries_data, derived.geo_cells_data, 20, 12);
        write_entry_correction(PatchFileId::INTERP_ENTRIES, "interp_entries.bin",
            derived.interp_entries_data, derived.geo_cells_data, 20, 16);
        // Admin entries
        write_entry_correction(PatchFileId::ADMIN_ENTRIES, "admin_entries.bin",
            admin_derived.admin_entries_data, admin_derived.admin_cells_data, 12, 8);

        // Cell flag corrections: identify cells whose has_street/has_addr/has_interp
        // flags differ between old and new. Only ~3K cells typically.
        // The patch tool uses these to correctly assign entry offsets during geo_cells rebuild.
        {
            auto new_geo_data = read_file(new_dir + "/geo_cells.bin");
            std::unordered_map<uint64_t, uint8_t> old_cell_flags;
            for (size_t i = 0; i < old_geo.size() / 20; i++) {
                uint64_t cid; memcpy(&cid, old_geo.data() + i * 20, 8);
                uint32_t s, a, ip;
                memcpy(&s, old_geo.data()+i*20+8, 4); memcpy(&a, old_geo.data()+i*20+12, 4);
                memcpy(&ip, old_geo.data()+i*20+16, 4);
                uint8_t flags = (s != 0xFFFFFFFF ? 1 : 0) | (a != 0xFFFFFFFF ? 2 : 0) | (ip != 0xFFFFFFFF ? 4 : 0);
                old_cell_flags[cid] = flags;
            }
            std::vector<std::pair<uint64_t, uint8_t>> flag_corrections;
            for (size_t i = 0; i < new_geo_data.size() / 20; i++) {
                uint64_t cid; memcpy(&cid, new_geo_data.data() + i * 20, 8);


   999→                uint64_t cid; memcpy(&cid, new_geo_data.data() + i * 20, 8);
  1000→                uint32_t s, a, ip;
  1001→                memcpy(&s, new_geo_data.data()+i*20+8, 4); memcpy(&a, new_geo_data.data()+i*20+12, 4);
  1002→                memcpy(&ip, new_geo_data.data()+i*20+16, 4);
  1003→                uint8_t new_flags = (s != 0xFFFFFFFF ? 1 : 0) | (a != 0xFFFFFFFF ? 2 : 0) | (ip != 0xFFFFFFFF ? 4 : 0);
  1004→                auto it = old_cell_flags.find(cid);
  1005→                uint8_t old_f = (it != old_cell_flags.end()) ? it->second : 0;
  1006→                if (new_flags != old_f)
  1007→                    flag_corrections.push_back({cid, new_flags});
  1008→            }
  1009→            uint32_t fm = CELL_FLAGS_MARKER;
  1010→            uint32_t fc = static_cast<uint32_t>(flag_corrections.size());
  1011→            wval(patch, &fm, 4); wval(patch, &fc, 4);
  1012→            for (auto& [cid, flags] : flag_corrections) {
  1013→                wval(patch, &cid, 8); wval(patch, &flags, 1);
  1014→            }
  1015→            std::cerr << "  Cell flag corrections: " << fc << " cells ("
  1016→                      << fc * 9 << " bytes)" << std::endl;
  1017→        }
  1018→    }
  1019→
  1020→    // End marker
  1021→    uint32_t end_marker = 0xFFFFFFFF;
  1022→    wval(patch, &end_marker, 4);
  1023→
  1024→    std::cerr << "\nUncompressed patch: " << patch.size() << " bytes ("
  1025→              << patch.size() / 1024 / 1024 << " MiB)" << std::endl;
  1026→
  1027→    // Compress whole patch with zstd for transport
  1028→    {
  1029→        std::string raw_path = tmpdir + "/patch.raw";
  1030→        write_file(raw_path, patch);
  1031→        std::string cmd = "zstd -19 '" + raw_path + "' -o '" + patch_path + "' -f --quiet 2>/dev/null";
  1032→        system(cmd.c_str());
  1033→        auto compressed = read_file(patch_path);
  1034→        std::cerr << "Compressed patch: " << compressed.size() << " bytes ("
  1035→                  << compressed.size() / 1024 / 1024 << " MiB)" << std::endl;
  1036→        remove(raw_path.c_str());
  1037→    }
  1038→

                uint64_t cid; memcpy(&cid, new_geo_data.data() + i * 20, 8);
                uint32_t s, a, ip;
                memcpy(&s, new_geo_data.data()+i*20+8, 4); memcpy(&a, new_geo_data.data()+i*20+12, 4);
                memcpy(&ip, new_geo_data.data()+i*20+16, 4);
                uint8_t new_flags = (s != 0xFFFFFFFF ? 1 : 0) | (a != 0xFFFFFFFF ? 2 : 0) | (ip != 0xFFFFFFFF ? 4 : 0);
                auto it = old_cell_flags.find(cid);
                uint8_t old_f = (it != old_cell_flags.end()) ? it->second : 0;
                if (new_flags != old_f)
                    flag_corrections.push_back({cid, new_flags});
            }
            uint32_t fm = CELL_FLAGS_MARKER;
            uint32_t fc = static_cast<uint32_t>(flag_corrections.size());
            wval(patch, &fm, 4); wval(patch, &fc, 4);
            for (auto& [cid, flags] : flag_corrections) {
                wval(patch, &cid, 8); wval(patch, &flags, 1);
            }
            std::cerr << "  Cell flag corrections: " << fc << " cells ("
                      << fc * 9 << " bytes)" << std::endl;
        }
    }

    // End marker
    uint32_t end_marker = 0xFFFFFFFF;
    wval(patch, &end_marker, 4);

    std::cerr << "\nUncompressed patch: " << patch.size() << " bytes ("
              << patch.size() / 1024 / 1024 << " MiB)" << std::endl;

    // Compress whole patch with zstd for transport
    {
        std::string raw_path = tmpdir + "/patch.raw";
        write_file(raw_path, patch);
        std::string cmd = "zstd -19 '" + raw_path + "' -o '" + patch_path + "' -f --quiet 2>/dev/null";
        system(cmd.c_str());
        auto compressed = read_file(patch_path);
        std::cerr << "Compressed patch: " << compressed.size() << " bytes ("
                  << compressed.size() / 1024 / 1024 << " MiB)" << std::endl;
        remove(raw_path.c_str());
    }


Now I'll replace the entire post-merge section. The key changes:
1. Secondary matching moves into the parallel merge groups (extend `FileMergeResult` to include it)
2. Entry corrections run in parallel threads
3. No more redundant file reads

First, extend the merge groups to also compute secondary matches. I need to add a field to `FileMergeResult`:


Edit

struct FileMergeResult {
    PatchFileId id;
    std::string name;
    size_t stride;
    uint64_t old_size, new_size;
    MergeSequence seq;
    std::vector<std::pair<uint32_t,uint32_t>> fixups; // (record_idx, new_offset)
};

struct FileMergeResult {
    PatchFileId id;
    std::string name;
    size_t stride;
    uint64_t old_size, new_size;
    MergeSequence seq;
    std::vector<std::pair<uint32_t,uint32_t>> fixups; // (record_idx, new_offset)
    std::unordered_map<uint32_t,uint32_t> secondary_matches; // soft old→new ID map
};