//! source2rosetta — the derivation/verification engine: catalogue resolution, corpus-model
//! distillation, gamedata folding, live validation, backfill, and change classification. The
//! `source2rosetta` binary is a thin clap front-end over this module (see `main.rs`); keeping the
//! engine in the library lets CI and tests link and call it directly instead of shelling out.

use crate::elf::CodeImage;
use crate::locate::{find_file, load_lib};
use crate::par::{default_threads, parallel_map};
use crate::profile::GameProfile;
use crate::sig::Pattern;
use crate::taxonomy::{
    FullName, class_of, clean_offset_class, guess_tier, is_dead_weight_class, is_dead_weight_name,
    is_serializer_plumbing, parse_ret, protobuf_message_classes,
};
use crate::{abi, emit, fingerprint, model, render, rtti, xref};
use anyhow::{Context, Result, bail, ensure};
use serde::Deserialize;
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};

// ══════════════════════════════════════════════════════════════════════════════════════════════
// TABLE OF CONTENTS  (grep `§N ·` to jump)
//   §1 · Catalogue model & shared vocabulary     §5 · Monolith assembly
//   §2 · Catalogue & image loading               §6 · Corpus-model distillation
//   §3 · Fingerprint alignment                   §7 · Gamedata derivation (catalogue → sigs/offsets)
//   §4 · Gamedata fold & experimental band       §8 · Backfill (cross-build timelines)
// This module is the OFFLINE engine — nothing here attaches to a running server. Companion modules:
// `taxonomy` (name/dead-weight predicates), `schema` (offline reader + live type walk), `produce`
// (CI orchestration + the whole LIVE verification subsystem — oracle / validate-verify / fuzzer / launch).
// ══════════════════════════════════════════════════════════════════════════════════════════════

// ══════════════════════════════════════════════════════════════════════════════════════════════
// §1 · CATALOGUE MODEL & SHARED VOCABULARY
// ══════════════════════════════════════════════════════════════════════════════════════════════

/// class name -> its vtable slot fingerprints (index = slot; `None` where a slot didn't decode).
/// The recurring shape behind every cross-build vtable alignment.
type VtableFps = HashMap<String, Vec<Option<Vec<u32>>>>;

/// Candidate target addresses for one function, each tagged with how many distinct era-sigs voted
/// for it (most-voted first).
type VoteList = Vec<(u64, usize)>;

/// Where `gamedata` pulls its cross-build reference signals from — exactly one of the two.
#[derive(Clone, Copy)]
pub enum CorpusSource<'a> {
    /// The raw corpus of build binaries (each fingerprinted on the fly).
    Binaries(&'a Path),
    /// A distilled corpus model (already parsed) — then only the target binary is read. The caller owns the
    /// model so it can be parsed ONCE and reused (`produce` derives through this borrow, then hands the same
    /// model by value to the sidecar fold — the 2.9 GB Dota model is parsed a single time, not twice).
    Model(&'a CorpusModel),
}

#[derive(Deserialize)]
struct Func {
    name: String,
    #[serde(default)]
    library: Vec<String>,
    #[serde(default)]
    variants: Vec<Variant>,
}

/// A catalogue variant's locator kind. `Other` (via serde) absorbs any future/unknown kind, so an
/// unexpected value is skipped rather than a hard parse error.
#[derive(Deserialize, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
enum VariantKind {
    Offset,
    Signature,
    StringAnchor,
    VtableOffset,
    #[serde(other)]
    Other,
}

/// The platform a variant applies to. A locator is used for the linux derive when it is `Linux` or the
/// platform-agnostic `Any`.
#[derive(Deserialize, PartialEq, Eq, Clone, Copy, Default)]
#[serde(rename_all = "lowercase")]
enum Platform {
    Linux,
    Any,
    Windows,
    #[serde(other)]
    #[default]
    Other,
}

impl Platform {
    fn is_linux(self) -> bool {
        matches!(self, Platform::Linux | Platform::Any)
    }
}

#[derive(Deserialize)]
struct Variant {
    kind: VariantKind,
    #[serde(default)]
    platform: Platform,
    value: String,
    #[serde(default)]
    src: String, // era tag (v1.0.N) for gamedata-sourced variants
}

/// Which classes get vtable-slot hops in a distilled corpus model. clap validates the choice at parse
/// time (and lists it in --help), so `corpus-model` can match it exhaustively — no silent fallback.
#[derive(
    Clone,
    Copy,
    PartialEq,
    Eq,
    Debug,
    Default,
    clap::ValueEnum,
    serde::Serialize,
    serde::Deserialize,
)]
#[serde(rename_all = "kebab-case")]
pub enum ClassScope {
    /// Every real game class (the default) — enough for any modding offset to derive model-only.
    #[default]
    Clean,
    /// Also template / protobuf / NetworkVar junk.
    All,
    /// Only the classes the catalogue names.
    Catalogue,
}

// ══════════════════════════════════════════════════════════════════════════════════════════════
// §2 · CATALOGUE & IMAGE LOADING
// ══════════════════════════════════════════════════════════════════════════════════════════════

pub(crate) fn lib_filename(prof: &GameProfile, lib: &str) -> String {
    match lib {
        "server" => prof.server_lib.into(),
        "engine2" => prof.engine_lib.into(),
        other => format!("lib{other}.so"),
    }
}

fn lib_name_from_file(fname: &str) -> String {
    fname
        .strip_prefix("lib")
        .unwrap_or(fname)
        .strip_suffix(".so")
        .unwrap_or(fname)
        .to_string()
}

fn candidate_libs(prof: &GameProfile, f: &Func) -> Vec<String> {
    let named: Vec<&String> = f.library.iter().filter(|l| *l != "unknown").collect();
    if named.is_empty() {
        vec![prof.server_lib.into(), prof.engine_lib.into()]
    } else {
        named.iter().map(|l| lib_filename(prof, l)).collect()
    }
}

fn linux_sigs(f: &Func) -> Vec<&Variant> {
    f.variants
        .iter()
        .filter(|v| v.kind == VariantKind::Signature && v.platform.is_linux())
        .collect()
}

/// String-anchor locators for `f`: distinctive strings the function references. Stable across builds
/// even when byte sigs drift, so `gamedata` re-resolves them per build via the xref index.
fn string_anchors(f: &Func) -> Vec<&str> {
    f.variants
        .iter()
        .filter(|v| v.kind == VariantKind::StringAnchor && v.platform.is_linux())
        .map(|v| v.value.as_str())
        .collect()
}

pub(crate) fn label_of(dir: &Path) -> String {
    dir.file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_else(|| dir.display().to_string())
}

/// Load every library the catalogue references (usually just libserver/libengine2) from a build.
fn preload_images(prof: &GameProfile, funcs: &[Func], dir: &Path) -> HashMap<String, CodeImage> {
    let mut wanted: BTreeSet<String> = BTreeSet::new();
    for f in funcs {
        if !linux_sigs(f).is_empty() {
            wanted.extend(candidate_libs(prof, f));
        }
    }
    let mut images = HashMap::new();
    for fname in &wanted {
        if let Some(p) = find_file(dir, fname, 8)
            && let Ok(img) = CodeImage::load(&p)
        {
            images.insert(fname.clone(), img);
        }
    }
    images
}

/// Every distinct address a UNIQUE-matching, de-duplicated era-sig of `f` resolves to in `img`, each mapped
/// to how many distinct era-sigs voted for it. The shared scan behind `locate_addr` / `locate_candidates` /
/// `resolve_unique` — they differ only in how they aggregate this map (unique-key / vote-ranked / cross-image
/// union). A sig with a non-unique match (0 or >1 hits) contributes nothing; the seen-set dedups equal sigs.
fn scan_sig_hits(f: &Func, img: &CodeImage) -> BTreeMap<u64, usize> {
    let mut votes: BTreeMap<u64, usize> = BTreeMap::new();
    let mut seen_sig = HashSet::new();
    for v in linux_sigs(f) {
        if !seen_sig.insert(v.value.as_str()) {
            continue;
        }
        if let Ok(pat) = Pattern::parse(&v.value)
            && let [addr] = img.find(&pat).as_slice()
        {
            *votes.entry(*addr).or_default() += 1;
        }
    }
    votes
}

/// The unique address of `f` in the preloaded images, if exactly one variant resolves cleanly.
fn locate_addr<'a>(
    prof: &GameProfile,
    f: &Func,
    images: &'a HashMap<String, CodeImage>,
) -> Option<(&'a CodeImage, u64, String)> {
    let (img, fname) = candidate_libs(prof, f)
        .into_iter()
        .find_map(|fname| images.get(&fname).map(|img| (img, fname)))?;
    let hits = scan_sig_hits(f, img);
    (hits.len() == 1).then(|| {
        (
            img,
            *hits.keys().next().unwrap(),
            lib_name_from_file(&fname),
        )
    })
}

/// Every distinct target address a unique-matching era-sig lands on, ranked by how many distinct
/// era-sigs voted for it (most first). Unlike `locate_addr` this keeps ALL candidates so a caller
/// can disambiguate a stale sig's coincidental hit from the real function by fingerprint.
fn locate_candidates<'a>(
    prof: &GameProfile,
    f: &Func,
    images: &'a HashMap<String, CodeImage>,
) -> Option<(&'a CodeImage, String, VoteList)> {
    let (img, fname) = candidate_libs(prof, f)
        .into_iter()
        .find_map(|fname| images.get(&fname).map(|img| (img, fname)))?;
    let hits = scan_sig_hits(f, img);
    if hits.is_empty() {
        return None;
    }
    let mut cand: Vec<(u64, usize)> = hits.into_iter().collect();
    cand.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); // most-voted first, addr as tiebreak
    Some((img, lib_name_from_file(&fname), cand))
}

fn load_catalogue(path: &Path) -> Result<Vec<Func>> {
    let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
    serde_json::from_str(&text).context("parse catalogue json")
}

/// The two locator kinds a human may contribute. DELIBERATELY not `VariantKind`: that one carries a
/// `#[serde(other)]` catch-all, which is right for the machine-generated catalogue (forward-compatible with
/// kinds this build doesn't know) and WRONG for a hand-authored file — `"vtable_offset"` with an underscore,
/// or the real-but-underivable `"offset"`, would parse cleanly, be counted as merged, and then match nothing
/// downstream, producing no output and no diagnostic. A closed enum turns that typo into a parse error
/// naming the file and entry.
#[derive(Deserialize, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
enum ContributionKind {
    Signature,
    VtableOffset,
}

impl From<ContributionKind> for VariantKind {
    fn from(k: ContributionKind) -> Self {
        match k {
            ContributionKind::Signature => VariantKind::Signature,
            ContributionKind::VtableOffset => VariantKind::VtableOffset,
        }
    }
}

/// One human-contributed function locator, from `mappings/contributions/<game_key>/*.json`: a name + a
/// DATED signature or vtable-offset locator. Merged into the catalogue at DERIVE time — the unified
/// contribution/PR path. The `date` is the observation's build; the derive chains/verifies it exactly like
/// any dated catalogue variant.
#[derive(Deserialize)]
struct Contribution {
    name: String,
    kind: ContributionKind,
    value: String,
    date: String, // observation build date, YYYY-MM-DD (drives offset chaining / backfill)
}

/// `YYYY-MM-DD`, the shape the dated-variant machinery requires. Validated at INTAKE so a malformed date is
/// reported against its file and entry, rather than silently failing the `src.len() >= 10 && src[4] == b'-'`
/// test deep inside `vtable_offset_timelines`, where it just looks like a locator that didn't resolve.
fn is_iso_date(s: &str) -> bool {
    let b = s.as_bytes();
    b.len() == 10
        && b[4] == b'-'
        && b[7] == b'-'
        && b.iter()
            .enumerate()
            .all(|(i, c)| matches!(i, 4 | 7) || c.is_ascii_digit())
}

/// Human contributions for the active game as catalogue `Func`s (one dated variant each), read from
/// `<catalogue-dir>/contributions/<game_key>/*.json` in sorted order. Empty when the dir is absent.
/// Merged by the DERIVE only — never into the historical corpus model.
fn load_contributions(prof: &GameProfile, catalogue: &Path) -> Vec<Func> {
    let Some(dir) = catalogue
        .parent()
        .map(|p| p.join("contributions").join(prof.game_key))
        .filter(|d| d.is_dir())
    else {
        return Vec::new();
    };
    let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
        .into_iter()
        .flatten()
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.extension().is_some_and(|x| x == "json"))
        .collect();
    files.sort(); // deterministic merge order
    let mut funcs = Vec::new();
    for f in &files {
        let text = match std::fs::read_to_string(f) {
            Ok(t) => t,
            Err(e) => {
                eprintln!("  contribution {}: {e} — skipped", f.display());
                continue;
            }
        };
        // Parse entry-by-entry via `Vec<Value>` so ONE malformed entry costs only itself, not the whole
        // file — CONTRIBUTING.md promises "a malformed entry is skipped with a warning".
        let rows: Vec<Value> = match serde_json::from_str(&text) {
            Ok(r) => r,
            Err(e) => {
                eprintln!(
                    "  contribution {}: not a JSON array ({e}) — file skipped",
                    f.display()
                );
                continue;
            }
        };
        for (i, row) in rows.into_iter().enumerate() {
            let c: Contribution = match serde_json::from_value(row) {
                Ok(c) => c,
                Err(e) => {
                    eprintln!("  contribution {}[{i}]: {e} — entry skipped", f.display());
                    continue;
                }
            };
            if !is_iso_date(&c.date) {
                eprintln!(
                    "  contribution {}[{i}] ({}): date {:?} is not YYYY-MM-DD — entry skipped",
                    f.display(),
                    c.name,
                    c.date
                );
                continue;
            }
            let variant = Variant {
                kind: c.kind.into(),
                platform: Platform::Linux,
                value: c.value,
                src: c.date,
            };
            // Merge onto an existing contributed Func rather than appending a duplicate. A second `Func`
            // of the same name would never let the contribution corroborate the catalogue's own era-sigs
            // (`scan_sig_hits` votes across the variants of ONE `Func`), and would let one name be counted
            // twice by the emit tallies.
            match funcs.iter_mut().find(|g: &&mut Func| g.name == c.name) {
                Some(g) => g.variants.push(variant),
                None => funcs.push(Func {
                    name: c.name,
                    library: Vec::new(),
                    variants: vec![variant],
                }),
            }
        }
    }
    if !funcs.is_empty() {
        eprintln!(
            "  merged {} contribution(s) from {}",
            funcs.len(),
            dir.display()
        );
    }
    funcs
}

/// Immediate subdirectories of `corpus` that contain a libserver.so (i.e. builds), sorted.
pub(crate) fn find_builds(prof: &GameProfile, corpus: &Path) -> Result<Vec<PathBuf>> {
    let mut builds: Vec<PathBuf> = std::fs::read_dir(corpus)
        .with_context(|| format!("read {}", corpus.display()))?
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.is_dir() && find_file(p, prof.server_lib, 8).is_some())
        .collect();
    builds.sort();
    ensure!(
        !builds.is_empty(),
        "no build subdirs containing libserver.so under {}",
        corpus.display()
    );
    Ok(builds)
}

/// Load a build's server/engine libraries once (reused across many class lookups).
fn load_build_images(prof: &GameProfile, dir: &Path) -> Vec<CodeImage> {
    load_build_images_counted(prof, dir).0
}

/// As [`load_build_images`], plus the libs that were absent or unparseable.
///
/// A build is admitted to the corpus on file EXISTENCE alone, so a corrupt-but-present library still
/// counts as a build; it then contributes empty fingerprints, which the distill folds in as slot-count 0
/// with an EMPTY hop for every class — and an empty hop is an unconditional chain break, severing every
/// anchor older than that build. That is worth a line of output rather than a silent `filter_map`.
fn load_build_images_counted(prof: &GameProfile, dir: &Path) -> (Vec<CodeImage>, Vec<String>) {
    let mut imgs = Vec::new();
    let mut failed = Vec::new();
    for f in prof.libs {
        // Absent is normal — a profile's lib list is a superset across builds — so only an
        // unparseable file counts as a failure.
        if let Some(p) = find_file(dir, f, 8) {
            match CodeImage::load(&p) {
                Ok(i) => imgs.push(i),
                Err(_) => failed.push((*f).to_string()),
            }
        }
    }
    (imgs, failed)
}

// ══════════════════════════════════════════════════════════════════════════════════════════════
// §3 · FINGERPRINT ALIGNMENT
// ══════════════════════════════════════════════════════════════════════════════════════════════

fn l1(a: &[u32], b: &[u32]) -> u64 {
    a.iter().zip(b).map(|(&x, &y)| x.abs_diff(y) as u64).sum()
}

/// L1 distance, but stops as soon as it passes `cap` (then returns `cap + 1`). `slot_score` only needs
/// to know which of `{0, 1..=4, >4}` the distance lands in, and a Needleman–Wunsch fill scores mostly
/// unrelated slot pairs whose distance blows past the cap in the first few of 63 dims — so the early
/// exit turns the reduction from 63 adds into 1–3 on the hot path. Exact for the buckets `slot_score` uses.
fn l1_capped(a: &[u32], b: &[u32], cap: u64) -> u64 {
    let mut sum = 0u64;
    for (&x, &y) in a.iter().zip(b) {
        sum += x.abs_diff(y) as u64;
        if sum > cap {
            return cap + 1;
        }
    }
    sum
}

/// Score two slot fingerprints for alignment: identical → strong match, near → weak, else
/// mismatch. Undecodable slots never match.
fn slot_score(a: &Option<Vec<u32>>, b: &Option<Vec<u32>>) -> i32 {
    match (a, b) {
        (Some(x), Some(y)) => match l1_capped(x, y, 4) {
            0 => 3,
            1..=4 => 1,
            _ => -1,
        },
        _ => -1,
    }
}

/// Order-preserving (Needleman-Wunsch) alignment of two slot-fingerprint sequences, scored by
/// fingerprint similarity. Returns from_idx -> Some(to_idx) on diagonal (matched-position) steps,
/// None where the from-slot was deleted. Order preservation disambiguates the many near-identical
/// trivial getters a greedy nearest-neighbour cannot.
fn nw_align(from: &[Option<Vec<u32>>], to: &[Option<Vec<u32>>]) -> Vec<Option<usize>> {
    let (n, m) = (from.len(), to.len());
    // Identity fast-path: for equal sequences the all-diagonal path is the unique optimum (a match, incl.
    // None-None, scores > a delete+insert gap pair at every prefix), so the traceback walks the full diagonal
    // and returns `[Some(0)..Some(n-1)]`. This is BYTE-IDENTICAL to the full DP result — and on a code-distinct
    // corpus the vast majority of a class's adjacent-build vtables are unchanged, so this skips the O(n²) fill.
    if from == to {
        return (0..n).map(Some).collect();
    }
    const GAP: i32 = -1;
    // Flat (n+1)×(m+1) DP matrix in ONE allocation rather than n+1 heap Vecs — this is a per-call hot path
    // (millions of calls per distill).
    let w = m + 1;
    let mut dp = vec![0i32; (n + 1) * w];
    for i in 1..=n {
        dp[i * w] = GAP * i as i32;
    }
    for j in 1..=m {
        dp[j] = GAP * j as i32;
    }
    for i in 1..=n {
        for j in 1..=m {
            let s = slot_score(&from[i - 1], &to[j - 1]);
            dp[i * w + j] = (dp[(i - 1) * w + (j - 1)] + s)
                .max(dp[(i - 1) * w + j] + GAP)
                .max(dp[i * w + (j - 1)] + GAP);
        }
    }
    let mut map = vec![None; n];
    let (mut i, mut j) = (n, m);
    while i > 0 && j > 0 {
        let s = slot_score(&from[i - 1], &to[j - 1]);
        if dp[i * w + j] == dp[(i - 1) * w + (j - 1)] + s {
            map[i - 1] = Some(j - 1); // diagonal: positions correspond (match or substitution)
            i -= 1;
            j -= 1;
        } else if dp[i * w + j] == dp[(i - 1) * w + j] + GAP {
            i -= 1;
        } else {
            j -= 1;
        }
    }
    map
}

// ══════════════════════════════════════════════════════════════════════════════════════════════
// §4 · GAMEDATA FOLD & EXPERIMENTAL BAND  (dead-weight/naming predicates live in `taxonomy`)
// ══════════════════════════════════════════════════════════════════════════════════════════════

/// One verified promotable (T3) name from the name-extrapolation harvest.
#[derive(Deserialize)]
struct PromoName {
    addr: String,
    name: String,
    #[serde(default)]
    confidence: String,
    #[serde(default)]
    rationale: String,
    #[serde(default)]
    corroboration: String,
    #[serde(default)]
    self_named: bool,
    #[serde(default)]
    promote: bool,
}

/// One anonymous-function context bundle from the `--candidates` input (its membership = the dead-weight filter).
#[derive(Deserialize)]
struct CandCtx {
    addr: String,
    #[serde(default)]
    class: Option<String>,
    #[serde(default)]
    slot: Option<i64>,
    #[serde(default)]
    abi: Option<String>,
}

#[derive(Deserialize, Default)]
struct CandDoc {
    candidates: Vec<CandCtx>,
}

/// A function's ABI return class — typed so the by-value guard can't be silently broken by a `"byval"` string
/// typo. A struct returned BY VALUE hides an output-buffer pointer in RDI (RVO), so a naive `this`-call would
/// make the callee WRITE its return into the object = memory corruption; this flag keeps such functions out of
/// the blind-call path. Parsed from the candidate's `ret=` abi token; `as_str` round-trips to that token, so
/// the provenance `ret_class` string is byte-unchanged. Distinct from `abi::RetClass` (the DECODED 5-way
/// return class fed to the ABI-drift consensus): this one is parsed from the naming-harvest provenance token,
/// carries arbitrary type strings the fixed enum can't hold, and exists ONLY for the by-value safety flag.
enum ByValClass {
    ByValue,
    Other(String),
}

impl ByValClass {
    fn from_abi(abi: Option<&str>) -> ByValClass {
        match abi.and_then(parse_ret) {
            Some("byval") => ByValClass::ByValue,
            Some(s) => ByValClass::Other(s.to_string()),
            None => ByValClass::Other("?".to_string()),
        }
    }
    fn is_by_value(&self) -> bool {
        matches!(self, ByValClass::ByValue)
    }
    fn as_str(&self) -> &str {
        match self {
            ByValClass::ByValue => "byval",
            ByValClass::Other(s) => s,
        }
    }
}

/// Does this promoted entry route to a vtable OFFSET (a clean-class method at a known slot whose AI-guessed
/// class matches its RTTI class) rather than a byte signature? Decided identically at the sig prefilter and
/// the emit loop.
fn is_offset_locator(prof: &GameProfile, pr: &PromoName, c: &CandCtx) -> bool {
    let ai_class = pr.name.rsplit_once("::").map(|(cl, _)| cl);
    c.slot.is_some()
        && c.class
            .as_deref()
            .is_some_and(|cls| clean_offset_class(prof, cls))
        && ai_class == c.class.as_deref()
}

/// Parse a `0x`-prefixed hex address string (the harvest/candidate `addr` field) to a `u64`. `None` on a
/// missing prefix or non-hex digits.
fn parse_hex_addr(s: &str) -> Option<u64> {
    u64::from_str_radix(s.strip_prefix("0x")?, 16).ok()
}

/// A ground-truth vtable-OFFSET entry for a lib OTHER than the primary — `name` is `Class::Method` (the
/// class is read from the name, exactly like the primary fold's offset entries), `slot` is its vtable
/// index. Keyed by lib in the `--extra-offsets` file. The source is symbol transfer (macOS ground truth),
/// so these bypass the candidate/dead-weight gate — they are verified names, not guesses.
#[derive(Deserialize)]
struct ExtraOffset {
    name: String,
    class: String,
    slot: i64,
    /// Provenance overrides — absent = the macOS ground-truth shape (`corroborated`/`macos-groundtruth`),
    /// so a plain `{name,class,slot}` stays macOS. AI-promotable virtual methods set their own tier
    /// (`self-named`/`dict-exact`) + source so the monolith records where each name came from.
    #[serde(default)]
    tier: Option<String>,
    #[serde(default)]
    source: Option<String>,
    #[serde(default)]
    confidence: Option<String>,
    #[serde(default)]
    self_named: bool,
}

/// A multilib NON-virtual name to fold as a byte SIGNATURE — `addr` is its offset in lib `elib` (the
/// key). `make_sig` runs on that lib's own image. Used for AI-promotable names that aren't clean vtable
/// methods, keyed by lib in the `--extra-sigs` file.
#[derive(Deserialize)]
struct ExtraSig {
    name: String,
    addr: String,
    #[serde(default)]
    tier: Option<String>,
    #[serde(default)]
    source: Option<String>,
    #[serde(default)]
    confidence: Option<String>,
    #[serde(default)]
    self_named: bool,
}

/// Lift an extra-fold's data-string tier to the typed enum, erroring loudly on an unrecognised id.
fn tier_from_id(t: &str) -> Result<model::Tier> {
    model::Tier::from_id(t).with_context(|| format!("unknown provenance tier {t:?}"))
}

/// Fold the multilib ground-truth vtable-OFFSET entries (macOS symbol transfer) for the non-primary server
/// libs as high_confidence offsets: the class is read from the name (so the modder resolves the right lib's
/// vtable) and the candidate/dead-weight gate is bypassed — these are verified ground truth, not guesses. A
/// name already owned by the promoted fold (`t3`) or the guaranteed core is left untouched. Returns the count.
fn fold_extra_offsets(
    path: &Path,
    core_gd: &GdMap,
    t3: &mut BTreeMap<String, model::Entry>,
    prov: &mut BTreeMap<String, model::Provenance>,
) -> Result<u32> {
    let extra: BTreeMap<String, Vec<ExtraOffset>> =
        serde_json::from_str(&std::fs::read_to_string(path)?)
            .context("parse extra-offsets json")?;
    let mut n = 0u32;
    for (elib, entries) in &extra {
        for e in entries {
            if t3.contains_key(&e.name) || core_gd.contains_key(&e.name) {
                continue; // a higher-precedence source already owns this name
            }
            t3.insert(e.name.clone(), model::Entry::offset(e.slot));
            let source = e.source.as_deref().unwrap_or("macos-groundtruth");
            prov.insert(
                e.name.clone(),
                model::Provenance {
                    addr: Some(String::new()),
                    confidence: Some(e.confidence.as_deref().unwrap_or("high").to_string()),
                    self_named: Some(e.self_named),
                    by_value: Some(false),
                    ret_class: Some("ret=?".to_string()),
                    source: Some(source.to_string()),
                    rtti_class: Some(e.class.clone()),
                    rationale: Some(format!("multilib vtable offset ({elib}, {source})")),
                    ..model::Provenance::with_tier(tier_from_id(
                        e.tier.as_deref().unwrap_or("corroborated"),
                    )?)
                },
            );
            n += 1;
        }
    }
    eprintln!(
        "  +{n} multilib ground-truth offset entries folded from {}",
        path.display()
    );
    Ok(n)
}

/// Fold the multilib NON-virtual names as byte SIGNATURES via `make_sig` on each name's OWN lib image (loaded
/// on demand, cached) — the AI-promotable names that aren't clean vtable methods. Same precedence/gate rule as
/// [`fold_extra_offsets`]; a name whose sig can't be made (bad addr / no unique pattern) is skipped. Count.
fn fold_extra_sigs(
    build: &Path,
    path: &Path,
    sig_cap: usize,
    core_gd: &GdMap,
    t3: &mut BTreeMap<String, model::Entry>,
    prov: &mut BTreeMap<String, model::Provenance>,
) -> Result<u32> {
    let extra: BTreeMap<String, Vec<ExtraSig>> =
        serde_json::from_str(&std::fs::read_to_string(path)?).context("parse extra-sigs json")?;
    let mut imgs: HashMap<String, CodeImage> = HashMap::new();
    let mut n = 0u32;
    // Losses here are the norm, not the exception (~30% of this input typically doesn't fold), and a
    // success-only tally cannot distinguish "folded what it could" from "skipped an entire library".
    let (mut skipped_libs, mut bad_addr, mut unmakeable, mut overlaps) =
        (Vec::new(), 0u32, 0u32, 0u32);
    for (elib, entries) in &extra {
        if !imgs.contains_key(elib) {
            match load_lib(build, elib) {
                Ok(i) => {
                    imgs.insert(elib.clone(), i);
                }
                Err(_) => {
                    // lib not present in this build — its whole name list is skipped
                    skipped_libs.push(format!("{elib} ({})", entries.len()));
                    continue;
                }
            }
        }
        let eimg = &imgs[elib];
        let elib_name = lib_name_from_file(elib);
        for e in entries {
            if t3.contains_key(&e.name) || core_gd.contains_key(&e.name) {
                overlaps += 1;
                continue;
            }
            let Some(addr) = parse_hex_addr(&e.addr) else {
                bad_addr += 1;
                continue;
            };
            let Some(sig) = emit::make_sig(eimg, addr, sig_cap) else {
                unmakeable += 1;
                continue;
            };
            t3.insert(
                e.name.clone(),
                model::Entry::signature(elib_name.clone(), sig),
            );
            prov.insert(
                e.name.clone(),
                model::Provenance {
                    addr: Some(e.addr.clone()),
                    confidence: Some(e.confidence.as_deref().unwrap_or("medium").to_string()),
                    self_named: Some(e.self_named),
                    by_value: Some(false),
                    ret_class: Some("ret=?".to_string()),
                    source: Some(
                        e.source
                            .as_deref()
                            .unwrap_or("source2rosetta-nameext")
                            .to_string(),
                    ),
                    rationale: Some(format!("multilib name sig ({elib})")),
                    ..model::Provenance::with_tier(tier_from_id(
                        e.tier.as_deref().unwrap_or("contextual"),
                    )?)
                },
            );
            n += 1;
        }
    }
    eprintln!("  +{n} multilib name sigs folded from {}", path.display());
    if !skipped_libs.is_empty() || bad_addr > 0 || unmakeable > 0 || overlaps > 0 {
        eprintln!(
            "    skipped: {overlaps} already-known, {unmakeable} unmakeable-sig, {bad_addr} bad-addr{}",
            if skipped_libs.is_empty() {
                String::new()
            } else {
                format!(", libs absent from this build: {}", skipped_libs.join(", "))
            }
        );
    }
    Ok(n)
}

/// Assemble the monolith in memory and render its CS# gamedata (the string the live validate stage checks).
/// Writes NOTHING — `produce` holds the `Monolith` (to annotate it live) plus this render, and writes the
/// monolith exactly once at the end (after live validation, if a game is present).
fn build_monolith(
    prof: &GameProfile,
    source_build: &str,
    version: &str,
    core_json: &str,
    flagged: &[model::Flagged],
    experimental: Option<&[ExpGuess]>,
    unverified: &BTreeSet<String>,
    t3: &BTreeMap<String, model::Entry>,
    prov: &BTreeMap<String, model::Provenance>,
) -> Result<(model::Monolith, String)> {
    let mono = assemble_monolith(
        prof,
        source_build,
        version,
        core_json,
        flagged,
        experimental,
        unverified,
        t3,
        prov,
    )?;
    let cssharp = render::render_monolith_cssharp(&mono, model::TierSelect::HighConfidence);
    eprintln!(
        "  monolith: {} core + {} high-conf + {} experimental + {} unresolved",
        mono.meta.counts.core,
        mono.meta.counts.high_confidence,
        mono.meta.counts.experimental,
        mono.meta.counts.unresolved
    );
    Ok((mono, cssharp))
}

/// The fold's inputs, grouped so a new fold input is added in ONE place — not threaded through a long
/// positional call where two same-typed `Option<&Path>` are a silent transposition hazard. Built by the
/// `produce` orchestration; the `--extra-*`/`--full-names` members are the multilib/experimental opt-ins.
pub(crate) struct FoldArgs<'a> {
    pub build: &'a Path,
    pub lib: &'a str,
    /// Optional bring-up refinements (which extrapolated names may promote, and their prefiltered
    /// context). `None` = empty; a game derives its full catalogue without either.
    pub promotable: Option<&'a Path>,
    pub candidates: Option<&'a Path>,
    /// The derive's in-memory rendered core gamedata + its flag list (`None` when empty) — handed straight
    /// from `gamedata()`, no disk round-trip.
    pub core: &'a str,
    pub flagged: &'a [model::Flagged],
    /// Core names whose fingerprint check rejected every candidate (see [`Derived::unverified`]) — marked
    /// `catalogue-unverified` in the monolith rather than passed off as fingerprint-verified.
    pub unverified: &'a BTreeSet<String>,
    pub sig_cap: usize,
    pub version: &'a str,
    pub full_names: Option<&'a Path>,
    pub extra_offsets: Option<&'a Path>,
    pub extra_sigs: Option<&'a Path>,
    pub source_build: &'a str,
}

/// Fold the verified name-extrapolation harvest + curated entries under the guaranteed core into ONE
/// shipped gamedata — the whole deterministic promote stage in a single per-game command. Each promotable
/// gets the correct-by-construction locator (a vtable OFFSET when its RTTI class is clean and the AI's
/// class matches ground-truth, else a fresh `make_sig`); the prefiltered `--candidates` membership drops
/// dead weight; the guaranteed `--core` wins name collisions. `--extra-offsets` folds multilib ground-truth
/// vtable methods (name + slot) from the other server libs directly as high_confidence offsets. Emits the
/// combined file + a provenance sidecar (tier / confidence / return-class / by-value flag).
pub(crate) fn build_gamedata_cmd(
    prof: &GameProfile,
    a: FoldArgs,
) -> Result<(model::Monolith, String)> {
    let FoldArgs {
        build,
        lib,
        promotable,
        candidates,
        core,
        flagged,
        unverified,
        sig_cap,
        version,
        full_names,
        extra_offsets,
        extra_sigs,
        source_build,
    } = a;
    let img = load_lib(build, lib)?;
    let default_lib = lib_name_from_file(lib);

    // Both are OPTIONAL bring-up inputs: they refine which extrapolated names get promoted, and a game
    // with neither still derives its whole catalogue. Absent = empty, so standing up a new game needs only
    // a catalogue rather than two files with no generator in the repo.
    let promo: Vec<PromoName> = match promotable {
        Some(p) => {
            serde_json::from_str(&std::fs::read_to_string(p)?).context("parse promotable json")?
        }
        None => Vec::new(),
    };
    let cdoc: CandDoc = match candidates {
        Some(p) => {
            serde_json::from_str(&std::fs::read_to_string(p)?).context("parse candidates json")?
        }
        None => CandDoc::default(),
    };
    let cand: HashMap<String, CandCtx> = cdoc
        .candidates
        .into_iter()
        .map(|c| (c.addr.clone(), c))
        .collect();

    // Protobuf message types are dead weight even when the class name (e.g. `AccountActivity`,
    // `CGCToGCMsgMasterAck`) matches no foreign/CMsg prefix — only the serializer METHOD names reveal them.
    // Cluster over the promoted set (the names this fold would ship).
    let protobuf_classes = protobuf_message_classes(
        prof,
        promo.iter().filter(|p| p.promote).map(|p| p.name.as_str()),
    );

    let mut t3: BTreeMap<String, model::Entry> = BTreeMap::new();
    let mut prov: BTreeMap<String, model::Provenance> = BTreeMap::new();
    let (mut n_off, mut n_sig, mut n_byval) = (0u32, 0u32, 0u32);
    let (mut dead, mut unmakeable) = (0u32, 0u32);
    let mut unmakeable_list = Vec::new();

    // The fold's make_sig dominates the cost and is a pure read on `img`, so do it ONCE in
    // parallel for every promoted addr that routes to a SIGNATURE (not a vtable offset); the loop
    // below just looks the result up. make_sig is a pure fn of (img, addr, cap), so the fold's
    // output is byte-identical to the serial version — this changes WHERE the work runs, not what
    // it produces. (Inherited methods, whose declaring class != the candidate's RTTI class, fall to
    // make_sig rather than an offset, so this path carries real load.)
    let sig_addrs: Vec<u64> = promo
        .iter()
        .filter(|pr| {
            pr.promote
                && !is_dead_weight_name(prof, &pr.name)
                && !is_serializer_plumbing(prof, &pr.name, &protobuf_classes)
        })
        .filter_map(|pr| {
            let c = cand.get(&pr.addr)?;
            if is_offset_locator(prof, pr, c) {
                return None;
            }
            parse_hex_addr(&pr.addr)
        })
        .collect::<HashSet<u64>>()
        .into_iter()
        .collect();
    let sig_by_addr: HashMap<u64, String> = parallel_map(&sig_addrs, default_threads(None), |&a| {
        (a, emit::make_sig(&img, a, sig_cap))
    })
    .into_iter()
    .filter_map(|(a, s)| s.map(|s| (a, s)))
    .collect();

    for pr in &promo {
        if !pr.promote || t3.contains_key(&pr.name) {
            continue;
        }
        // Dead-weight gates: (1) the resolved NAME (foreign/protobuf functions the class-based dump
        // prefilter can't see); (2) a lone HARD serializer method or a protobuf message class (>=3
        // serializer methods); (3) membership in the prefiltered candidate dump.
        if is_dead_weight_name(prof, &pr.name)
            || is_serializer_plumbing(prof, &pr.name, &protobuf_classes)
        {
            dead += 1;
            continue;
        }
        let Some(c) = cand.get(&pr.addr) else {
            dead += 1;
            continue;
        };
        let ret = ByValClass::from_abi(c.abi.as_deref());
        if is_offset_locator(prof, pr, c) {
            t3.insert(pr.name.clone(), model::Entry::offset(c.slot.unwrap()));
            n_off += 1;
        } else {
            let Some(addr) = parse_hex_addr(&pr.addr) else {
                unmakeable += 1;
                unmakeable_list.push(format!("{} (bad addr {:?})", pr.name, pr.addr));
                continue;
            };
            match sig_by_addr.get(&addr).cloned() {
                Some(sig) => {
                    t3.insert(
                        pr.name.clone(),
                        model::Entry::signature(default_lib.to_string(), sig),
                    );
                    n_sig += 1;
                }
                None => {
                    unmakeable += 1;
                    unmakeable_list.push(format!("{} @ {addr:#x}", pr.name));
                    continue;
                }
            }
        }
        let tier = if pr.self_named {
            model::Tier::SelfNamed
        } else if pr.corroboration == "exact" {
            model::Tier::DictExact
        } else {
            model::Tier::Contextual
        };
        if ret.is_by_value() {
            n_byval += 1;
        }
        // Build the provenance TYPED so the compiler enforces the schema (model::Provenance serializes its
        // fields in declaration order).
        prov.insert(
            pr.name.clone(),
            model::Provenance {
                addr: Some(pr.addr.clone()),
                confidence: Some(pr.confidence.clone()),
                self_named: Some(pr.self_named),
                rationale: Some(pr.rationale.clone()),
                ret_class: Some(format!("ret={}", ret.as_str())),
                by_value: Some(ret.is_by_value()),
                source: Some("source2rosetta-nameext".to_string()),
                rtti_class: c.class.clone(),
                ..model::Provenance::with_tier(tier)
            },
        );
    }

    // fold under the guaranteed core (core wins any name collision) — parsed from the derive's in-memory
    // rendered core, no disk round-trip.
    let core_gd: GdMap = read_gamedata_str(core)?;
    let overlaps: Vec<String> = t3
        .keys()
        .filter(|k| core_gd.contains_key(*k))
        .cloned()
        .collect();
    for k in &overlaps {
        t3.remove(k);
        prov.remove(k);
    }

    // Multilib ground-truth folds for the non-primary libs (macOS symbol-transfer offsets + AI-name sigs),
    // each under the same core-wins precedence — see fold_extra_offsets / fold_extra_sigs.
    if let Some(ep) = extra_offsets {
        fold_extra_offsets(ep, &core_gd, &mut t3, &mut prov)?;
    }
    if let Some(sp) = extra_sigs {
        fold_extra_sigs(build, sp, sig_cap, &core_gd, &mut t3, &mut prov)?;
    }

    let tier_count = |t: model::Tier| prov.values().filter(|p| p.tier == t).count();
    let (n_self, n_dict) = (
        tier_count(model::Tier::SelfNamed),
        tier_count(model::Tier::DictExact),
    );

    eprintln!(
        "build-gamedata {version}: {} core + {} extrapolated = {} functions ({n_off} offsets, {n_sig} sigs; \
         {n_self} self-named, {n_dict} dict-exact, {n_byval} by-value-flagged)",
        core_gd.len(),
        t3.len(),
        core_gd.len() + t3.len()
    );
    eprintln!(
        "  {dead} promotable dropped (dead-weight, absent from prefiltered candidates); {unmakeable} unmakeable-sig; {} core-name overlaps dropped",
        overlaps.len()
    );
    for u in unmakeable_list.iter().take(20) {
        eprintln!("  UNMAKEABLE {u}");
    }

    // Least-filtered inclusion tier: the experimental band, in memory, emitted iff `--full-names` is given.
    // Emitted here (the fold step) because this is where names + locators become gamedata entries — the SAME
    // derivation, just with no promote/dead-weight gate.
    let experimental = full_names
        .map(|fnames| emit_experimental_band(prof, &img, build, fnames, &default_lib, sig_cap))
        .transpose()?;

    // Assemble the monolith (high_confidence from the fold's own `t3`/`prov`; core/unresolved/experimental
    // from the derive's in-memory strings) + its CS# render, returned to `produce` — nothing written here.
    build_monolith(
        prof,
        source_build,
        version,
        core,
        flagged,
        experimental.as_deref(),
        unverified,
        &t3,
        &prov,
    )
}

/// One experimental-band guess, built by [`emit_experimental_band`] and consumed by [`assemble_monolith`]
/// into the monolith's experimental tier. Passed as a typed `Vec` in memory — NOT serialized and re-parsed,
/// so `class` is a typed field a reader can see and `locator` is `None` (not a null sentinel) when the guess
/// is unlocatable. `promoted` rows are skipped on read (they live in high_confidence).
struct ExpGuess {
    name: String,
    tier: model::Tier,
    confidence: String,
    corroboration: String,
    self_named: bool,
    promoted: bool,
    collision: bool,
    dead_weight: bool,
    addr: String,
    /// The vtable class an OFFSET guess lives on (experimental-only eyeball aid); `None` for a sig guess.
    class: Option<String>,
    /// The resolved locator, or `None` when the address is neither a vtable slot nor make_sig-able.
    locator: Option<model::Entry>,
}

/// Emit the EXPERIMENTAL band — the least-filtered inclusion tier. Every name guess in the full-slice
/// universe is kept (no promote/dead-weight gate: each row is explicitly a *guess*), each attached to a
/// locator derived FRESH from this binary — a vtable OFFSET where the address is a virtual slot, else a
/// `make_sig` SIGNATURE — graded by [`guess_tier`], with same-name collisions flagged and cluster-sorted
/// so competing guesses sit side by side. One self-describing file under a blunt UNVERIFIED banner.
/// Locators are derived in parallel (the non-virtual `make_sig` majority dominates the cost).
fn emit_experimental_band(
    prof: &GameProfile,
    img: &CodeImage,
    build: &Path,
    names_path: &Path,
    default_lib: &str,
    sig_cap: usize,
) -> Result<Vec<ExpGuess>> {
    // The name universe is either a flat [FullName] array (single-lib → the default lib) or a per-lib
    // { "libX.so": [FullName] } map (multilib). Either shape resolves each guess against its OWN lib image.
    let raw: Value = serde_json::from_str(&std::fs::read_to_string(names_path)?)
        .context("parse full-names json")?;
    let per_lib: BTreeMap<String, Vec<FullName>> = match raw {
        Value::Array(_) => BTreeMap::from([(
            default_lib.to_string(),
            serde_json::from_value(raw).context("parse full-names json")?,
        )]),
        Value::Object(_) => serde_json::from_value(raw).context("parse per-lib full-names json")?,
        _ => {
            anyhow::bail!("full-names json must be a [..] array or a {{ \"lib.so\": [..] }} object")
        }
    };

    // collision + protobuf clustering span the WHOLE universe (across libs): a name guessed at more than
    // one address — even in different libs — is flagged, and a protobuf class is seen by its full surface.
    let all_rows: Vec<&FullName> = per_lib.values().flatten().collect();
    let mut name_count: HashMap<&str, usize> = HashMap::new();
    for r in &all_rows {
        *name_count.entry(r.name.as_str()).or_default() += 1;
    }
    let pb_classes = protobuf_message_classes(prof, all_rows.iter().map(|r| r.name.as_str()));

    // resolve each lib's guesses against that lib's OWN image — the default lib reuses the already-loaded
    // `img`; the others load on demand (a guess for a lib absent from this build is skipped). make_sig (the
    // non-virtual majority) dominates the cost, so each lib's batch runs in parallel.
    let mut extra_imgs: HashMap<String, CodeImage> = HashMap::new();
    let mut entries: Vec<(u8, bool, String, String, ExpGuess)> = Vec::new();
    let mut skipped_libs: Vec<String> = Vec::new();
    for (lib_file, rows) in &per_lib {
        let libname = lib_name_from_file(lib_file);
        let limg: &CodeImage = if libname == default_lib {
            img
        } else {
            if !extra_imgs.contains_key(lib_file) {
                let Ok(loaded) = load_lib(build, lib_file) else {
                    // a whole library's guesses drop out — report it rather than shrinking the band silently
                    skipped_libs.push(format!("{lib_file} ({})", rows.len()));
                    continue;
                };
                extra_imgs.insert(lib_file.clone(), loaded);
            }
            &extra_imgs[lib_file]
        };

        // addr -> (class, slot): the first primary vtable holding the address == a directly callable offset.
        let mut vt_of: HashMap<u64, (String, usize)> = HashMap::new();
        for cv in rtti::enumerate_vtables(limg, prof.max_vtable_slots)
            .into_iter()
            .filter(|c| c.offset_to_top == 0)
        {
            for (i, &a) in cv.slots.iter().enumerate() {
                vt_of.entry(a).or_insert_with(|| (cv.name.clone(), i));
            }
        }

        let idxs: Vec<usize> = (0..rows.len()).collect();
        let lib_entries: Vec<(u8, bool, String, String, ExpGuess)> =
            parallel_map(&idxs, default_threads(None), |&i| {
                let r = &rows[i];
                let (rank, tier) = guess_tier(r);
                let collision = name_count.get(r.name.as_str()).copied().unwrap_or(0) > 1;
                // protobuf / foreign-namespace / serializer plumbing: kept (nothing hidden) but FLAGGED, so
                // a reader can grep it out and the confidence tiers aren't polluted by generic-method matches
                // (e.g. a protobuf `::Clear` corroborating as "exact" on the dictionary word). Also sunk to
                // the bottom of its tier via the sort key below, so real game functions surface first.
                let dead = is_dead_weight_name(prof, &r.name)
                    || is_serializer_plumbing(prof, &r.name, &pb_classes);
                let addr = parse_hex_addr(&r.addr);
                let (class, locator): (Option<String>, Option<model::Entry>) =
                    match addr.and_then(|a| vt_of.get(&a).map(|v| (a, v))) {
                        // a vtable slot: the canonical offset locator + the experimental-only `class` tag
                        // (the vtable it lives on) so a reader can eyeball an offset guess.
                        Some((_, (cls, slot))) => (
                            Some(cls.clone()),
                            Some(model::Entry {
                                offset: Some(*slot as i64),
                                ..Default::default()
                            }),
                        ),
                        None => match addr.and_then(|a| emit::make_sig(limg, a, sig_cap)) {
                            Some(sig) => (
                                None,
                                Some(model::Entry {
                                    signature: Some(model::Sig {
                                        library: libname.clone(),
                                        linux: sig,
                                    }),
                                    ..Default::default()
                                }),
                            ),
                            None => (None, None),
                        },
                    };
                let entry = ExpGuess {
                    name: r.name.clone(),
                    tier,
                    confidence: r.confidence.clone(),
                    corroboration: r.corroboration.clone(),
                    self_named: r.self_named,
                    promoted: r.promote,
                    collision,
                    dead_weight: dead,
                    addr: r.addr.clone(),
                    class,
                    locator,
                };
                (rank, dead, r.name.clone(), r.addr.clone(), entry)
            });
        entries.extend(lib_entries);
    }

    // cluster-sort: tier first (the confidence grouping), then dead-weight LAST within a tier (real game
    // functions surface above the plumbing), then name (collisions become adjacent), then addr.
    entries.sort_by(|a, b| (a.0, a.1, &a.2, &a.3).cmp(&(b.0, b.1, &b.2, &b.3)));

    let (mut collisions, mut unlocatable, mut dead_weight) = (0usize, 0usize, 0usize);
    let guesses: Vec<ExpGuess> = entries.into_iter().map(|(_, _, _, _, e)| e).collect();
    for e in &guesses {
        collisions += usize::from(e.collision);
        dead_weight += usize::from(e.dead_weight);
        unlocatable += usize::from(e.locator.is_none());
    }
    let total = guesses.len();
    eprintln!(
        "experimental band: {total} guesses ({collisions} name-collisions, {dead_weight} dead-weight-flagged, \
         {unlocatable} unlocatable)"
    );
    if !skipped_libs.is_empty() {
        eprintln!(
            "  libs absent from this build (their guesses dropped): {}",
            skipped_libs.join(", ")
        );
    }
    Ok(guesses)
}

// ══════════════════════════════════════════════════════════════════════════════════════════════
// §5 · MONOLITH ASSEMBLY
// ══════════════════════════════════════════════════════════════════════════════════════════════

pub(crate) type GdMap = serde_json::Map<String, serde_json::Value>;

// A gamedata entry's locator (a byte SIGNATURE or a vtable OFFSET) is `model::Entry`: construct one with
// `Entry::signature`/`Entry::offset`, render it with `render::locator_value`, parse it back with
// `render::entry_from_value` — one type, one write/read pair in `core`, no separate enum or ad-hoc `.get()`.

/// Parse a gamedata json that MAY carry `//` line-comment banners, from an in-memory string — the
/// disk-free path the fold takes for the derive's rendered core and the live validate takes for the
/// monolith's CS# render, both avoiding a disk round-trip.
pub(crate) fn read_gamedata_str(raw: &str) -> Result<GdMap> {
    let stripped = raw
        .lines()
        .filter(|l| !l.trim_start().starts_with("//"))
        .collect::<Vec<_>>()
        .join("\n");
    serde_json::from_str(&stripped).context("parse gamedata json")
}

/// Assemble the monolith (`gamedata-<game>.json`) from the in-memory derive/fold results:
/// the rendered `core_json` → core tier; the fold's `t3`/`prov` → high_confidence; the `experimental` guesses
/// → the non-promoted experimental tail; the `flagged` list → unresolved. Pure assembly, no re-derivation and
/// no intermediate files. `validated` stays `None` (the live stage annotates it later).
/// Experimental is name-keyed: a collision (one name guessed at several addresses, already best-first in the
/// input order) collapses to its best representative with `collision:true` retained.
fn assemble_monolith(
    prof: &GameProfile,
    source_build: &str,
    version: &str,
    core_json: &str,
    flagged: &[model::Flagged],
    experimental: Option<&[ExpGuess]>,
    unverified: &BTreeSet<String>,
    t3: &BTreeMap<String, model::Entry>,
    prov: &BTreeMap<String, model::Provenance>,
) -> Result<model::Monolith> {
    use model::{Counts, MonoEntry, MonoMeta, Monolith, Provenance, Tier, Unresolved};

    // core tier: every guaranteed entry in the derive's rendered core, minimal provenance.
    // An entry whose fingerprint check rejected every candidate (emitted only because ONE era-sig resolved
    // uniquely) is marked `catalogue-unverified` — it is in `core` on locator strength alone, and nothing
    // downstream, live validation included, re-checks that it is the right function.
    let mut core: BTreeMap<String, MonoEntry> = BTreeMap::new();
    {
        // ABI prototype-drift detail per name, from the flag list — surfaced onto the core entry (the sig
        // still ships; `assemble` would otherwise drop the abi-drift flag since its name is already in core).
        let abi_drift: BTreeMap<&str, &str> = flagged
            .iter()
            .filter(|f| f.reason == model::FlagReason::AbiDrift)
            .map(|f| (f.name.as_str(), f.detail.as_str()))
            .collect();
        let doc: BTreeMap<String, Value> =
            serde_json::from_str(core_json).context("parse core gamedata")?;
        for (name, v) in &doc {
            let source = if unverified.contains(name) {
                "catalogue-unverified"
            } else {
                "catalogue"
            };
            core.insert(
                name.clone(),
                MonoEntry {
                    locator: render::entry_from_value(v),
                    class: None,
                    provenance: Provenance {
                        source: Some(source.into()),
                        abi_drift: abi_drift.get(name.as_str()).map(|d| d.to_string()),
                        ..Provenance::with_tier(Tier::Core)
                    },
                    validated: None,
                },
            );
        }
    }

    // high_confidence tier: the promoted names — provenance + locator taken from the IN-MEMORY fold (`t3` =
    // name -> typed `Entry`, `prov` = name -> typed provenance), so no disk round-trip is needed to build the
    // monolith and no untyped re-parse stands between fold and shipped model.
    let mut high: BTreeMap<String, MonoEntry> = BTreeMap::new();
    for (name, provenance) in prov {
        let locator = t3.get(name).cloned().unwrap_or_default();
        high.insert(
            name.clone(),
            MonoEntry {
                locator,
                class: None,
                provenance: provenance.clone(),
                validated: None,
            },
        );
    }

    // experimental tier: the graded guess tail, EXCLUDING promoted ones (they are high_confidence) and any
    // name already owned by core/high_confidence. First occurrence wins per name (best-first order). Read
    // straight off the typed `Vec<ExpGuess>` — no serialize-and-re-parse, so a renamed field is a compile
    // error here rather than a silent `None`.
    let mut exp_tier: BTreeMap<String, MonoEntry> = BTreeMap::new();
    if let Some(guesses) = experimental {
        for g in guesses {
            if g.promoted {
                continue; // promoted → it lives in high_confidence
            }
            if core.contains_key(&g.name) || high.contains_key(&g.name) {
                continue; // a higher tier already owns this name
            }
            let Some(locator) = &g.locator else {
                continue; // unlocatable guess — not shippable
            };
            // The experimental tier's provenance (the rest of the fields stay None).
            let provenance = Provenance {
                addr: Some(g.addr.clone()),
                confidence: Some(g.confidence.clone()),
                self_named: Some(g.self_named),
                corroboration: Some(g.corroboration.clone()),
                collision: Some(g.collision),
                dead_weight: Some(g.dead_weight),
                ..Provenance::with_tier(g.tier)
            };
            exp_tier.entry(g.name.clone()).or_insert(MonoEntry {
                locator: locator.clone(),
                class: g.class.clone(),
                provenance,
                validated: None,
            });
        }
    }

    // unresolved tier: catalogue functions the derivation couldn't produce.
    let mut unresolved: BTreeMap<String, Unresolved> = BTreeMap::new();
    for f in flagged {
        // A flagged function can still have been resolved by another path (e.g. a sig-drifted entry
        // recovered as a vtable offset lands in `core`). `unresolved` means truly no locator in any
        // tier, so skip anything a resolved tier already owns — this keeps the four tiers disjoint.
        if core.contains_key(&f.name)
            || high.contains_key(&f.name)
            || exp_tier.contains_key(&f.name)
        {
            continue;
        }
        // One name can be flagged by more than one stage (a catalogue entry carrying both a sig and a
        // vtable-offset variant fails each independently). The tier is name-keyed so only one reason
        // fits: keep the FIRST, since the list runs in derivation order and the earlier stage's reason
        // is the more specific one.
        unresolved
            .entry(f.name.clone())
            .or_insert_with(|| Unresolved {
                reason: f.reason.as_str().to_string(),
                detail: f.detail.clone(),
            });
    }

    let counts = Counts {
        core: core.len(),
        high_confidence: high.len(),
        experimental: exp_tier.len(),
        unresolved: unresolved.len(),
    };
    Ok(Monolith {
        meta: MonoMeta {
            game_key: prof.game_key.to_string(),
            game: prof.display_name.to_string(),
            source_build: source_build.to_string(),
            version: version.to_string(),
            counts,
        },
        core,
        high_confidence: high,
        experimental: exp_tier,
        unresolved,
    })
}

/// Fold the live-validation verdict INTO the monolith (Stage F): an entry that survived `validate-live` (is
/// present in `validated_path`) becomes `validated: Some(true)`, one that was dropped confident-bad becomes
/// `Some(false)`. Only the shipped tiers (`core` + `high_confidence`) are annotated — `experimental` was
/// never sent to the server, so it stays `None`. This keeps the monolith self-contained (no standalone
/// validation sidecar).
pub(crate) fn annotate_validation(
    mono: &mut model::Monolith,
    verdicts: &BTreeMap<String, Option<bool>>,
) {
    // `verdicts` carries the HONEST per-entry outcome from the live oracle: Some(true) = live-confirmed,
    // None = kept but the oracle could NOT check it (library not mapped this run, non-vtable class, or an
    // out-of-bounds-but-kept offset that was demoted to Some(false)) — so the artifact must not claim
    // validation; an entry ABSENT from `verdicts` was dropped by the oracle -> Some(false). The monolith must
    // never assert a live validation it did not receive ("never lies").
    let annotate = |m: &mut BTreeMap<String, model::MonoEntry>| {
        for (name, e) in m.iter_mut() {
            e.validated = verdicts.get(name).copied().unwrap_or(Some(false));
        }
    };
    annotate(&mut mono.core);
    annotate(&mut mono.high_confidence);
    let all = || mono.core.values().chain(mono.high_confidence.values());
    let validated = all().filter(|e| e.validated == Some(true)).count();
    let unverified = all().filter(|e| e.validated.is_none()).count();
    let failed = all().filter(|e| e.validated == Some(false)).count();
    eprintln!(
        "  monolith validation annotated: {validated} live-validated, {unverified} kept-unverified, \
         {failed} failed (core + high_confidence)"
    );
}

// ══════════════════════════════════════════════════════════════════════════════════════════════
// §6 · CORPUS-MODEL DISTILLATION
// ══════════════════════════════════════════════════════════════════════════════════════════════

/// A vtable-offset function's class + its dated offset history (from gamedata across eras).
struct VtFunc {
    name: String,
    class: String,
    timeline: Vec<(String, i64)>, // (date, offset), sorted ascending by date
}

fn parse_offset(v: &str) -> Option<i64> {
    v.rsplit('=').next().unwrap_or(v).trim().parse().ok() // handles "102" and "linux=102"
}

/// Build each catalogue function's dated vtable-offset timeline: sorted `(YYYY-MM-DD, offset)` pairs,
/// skipping functions with no dated linux/any vtable-offset variant. The shared input to both the model
/// distiller's slot chaining and gamedata's offset derivation — each offset variant's `src` carries its
/// own date (baked into the catalogue), so there is no external tag→date map.
fn vtable_offset_timelines(cat: &[Func]) -> Vec<VtFunc> {
    let mut vfuncs: Vec<VtFunc> = Vec::new();
    for f in cat {
        let mut tl: Vec<(String, i64)> = Vec::new();
        for v in &f.variants {
            if v.kind != VariantKind::VtableOffset || !v.platform.is_linux() {
                continue;
            }
            // `src` is the offset's `YYYY-MM-DD` commit/build date.
            let d = (v.src.len() >= 10 && v.src.as_bytes().get(4) == Some(&b'-'))
                .then(|| v.src.clone());
            if let (Some(d), Some(o)) = (d, parse_offset(&v.value)) {
                tl.push((d, o));
            }
        }
        if tl.is_empty() {
            continue;
        }
        tl.sort();
        vfuncs.push(VtFunc {
            name: f.name.clone(),
            class: class_of(&f.name).to_string(),
            timeline: tl,
        });
    }
    vfuncs
}

/// Fingerprint every slot address of a vtable: `slot index -> its structural fingerprint` (`None` where
/// a slot doesn't decode). The per-class vtable signature the cross-build slot alignment operates on.
fn slot_fingerprints(img: &CodeImage, slots: &[u64]) -> Vec<Option<Vec<u32>>> {
    slots
        .iter()
        .map(|&a| fingerprint::extract(img, a).map(|f| f.to_vec()))
        .collect()
}

/// As [`slot_fingerprints`], but MEMOIZES `fingerprint::extract` per function address — a single inherited
/// method (a `CBaseEntity` slot) appears at the same address in hundreds of subclasses' vtables, so the
/// distill re-fingerprints it once per owning class without this. `extract` is pure/deterministic, so the
/// memo returns byte-identical vectors. CAVEAT: the memo MUST be scoped to ONE image — the offline
/// server/engine2 `CodeImage`s share low vaddr ranges, so a cross-image bare-vaddr memo would be a
/// correctness/output bug. `extract_build_vtables` creates a fresh memo per image for exactly this reason.
fn slot_fingerprints_memo(
    img: &CodeImage,
    slots: &[u64],
    memo: &mut HashMap<u64, Option<Vec<u32>>>,
) -> Vec<Option<Vec<u32>>> {
    slots
        .iter()
        .map(|&a| {
            memo.entry(a)
                .or_insert_with(|| fingerprint::extract(img, a).map(|f| f.to_vec()))
                .clone()
        })
        .collect()
}

pub(crate) fn build_date(label: &str) -> String {
    label.chars().take(10).collect() // "2024-08-21_231850" -> "2024-08-21"
}

/// The offset in effect at `date` = the latest era observation with date <= `date`.
fn offset_at(timeline: &[(String, i64)], date: &str) -> Option<i64> {
    timeline
        .iter()
        .rfind(|(d, _)| d.as_str() <= date)
        .map(|(_, o)| *o)
}

/// One build's identity in the corpus model: its directory label and derived date.
#[derive(serde::Serialize, serde::Deserialize)]
struct BuildMeta {
    label: String,
    date: String,
}

/// A function's distilled ABI shape — the argument footprint + return class, serializable for the model.
/// The consensus of the corpus's newest builds; a target whose derived shape differs is a prototype
/// drift the byte-signature can't see, flaggable at DERIVE time without the raw corpus.
#[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
struct AbiSig {
    int: u8,
    float: u8,
    ret: abi::RetClass, // typed; serializes to the describe() token ("ret=int", …)
}

impl From<abi::AbiShape> for AbiSig {
    fn from(s: abi::AbiShape) -> Self {
        AbiSig {
            int: s.int_args,
            float: s.float_args,
            ret: s.ret_class,
        }
    }
}

impl AbiSig {
    /// True when this shape DEFINITELY differs from `other`: the argument footprint moved, or both
    /// return classes are definite (not `Unknown`) and differ. An `Unknown` return is "no signal" and
    /// never raises a drift flag on its own.
    fn differs(&self, other: &AbiSig) -> bool {
        self.int != other.int
            || self.float != other.float
            || (self.ret != other.ret
                && self.ret != abi::RetClass::Unknown
                && other.ret != abi::RetClass::Unknown)
    }
    fn brief(&self) -> String {
        format!(
            "int={} float={} {}",
            self.int,
            self.float,
            self.ret.describe()
        )
    }
}

/// One reference build's per-sig-fn observations: `(name, fingerprint, optional ABI shape)`.
type RefObs = Vec<(String, Vec<u32>, Option<AbiSig>)>;

/// The model's `consensus_abi` field from its raw `abi_obs` windows — the modal ABI shape per sig fn.
/// SHARED by the full distill and the incremental fold (like `resolved_slot_of`/`extract_build_vtables`):
/// `consensus_abi` is a `CorpusModel` field and the fold contracts byte-equality with a re-distill, so an
/// inline copy edited in one path but not the other would break that silently.
fn consensus_from_windows(
    abi_obs: &BTreeMap<String, Vec<(u32, AbiSig)>>,
) -> BTreeMap<String, AbiSig> {
    abi_obs
        .iter()
        .filter_map(|(n, obs)| {
            mode_abi(obs.iter().map(|(_, s)| s.clone()).collect()).map(|s| (n.clone(), s))
        })
        .collect()
}

/// The modal shape across a function's per-build observations — robust to an occasional decode miss.
/// Ties break to the larger shape by `Ord`, so the consensus is deterministic.
fn mode_abi(mut obs: Vec<AbiSig>) -> Option<AbiSig> {
    obs.sort();
    let mut best: Option<(AbiSig, usize)> = None;
    let mut i = 0;
    while i < obs.len() {
        let run = 1 + obs[i + 1..].iter().take_while(|x| **x == obs[i]).count();
        if best.as_ref().is_none_or(|(_, c)| run >= *c) {
            best = Some((obs[i].clone(), run));
        }
        i += run;
    }
    best.map(|(s, _)| s)
}

/// A vtable slot index stored in 4 bytes instead of `Option<usize>`'s 16. Slot indices max in the low
/// hundreds, so a `u32` with `u32::MAX` reserved for "no match" is lossless — and it quarters the RAM of
/// the model's `hops`/`resolved_slot`, which are its dominant cost on a long-history, many-class corpus
/// (Dota's clean model is ~10x CS2's in both class-slots and builds; the `Option<usize>` hops alone would
/// be tens of GB). Serializes to the SAME JSON as `Option<usize>` — a bare number, or `null` for none —
/// so the model file format and every previously-produced model are unchanged; the win is purely
/// build-time RAM and on-disk size.
#[derive(Clone, Copy, PartialEq, Eq)]
struct Slot(u32);

impl Slot {
    const NONE: Slot = Slot(u32::MAX);
    fn from_opt(o: Option<usize>) -> Slot {
        o.map_or(Slot::NONE, |v| Slot(v as u32))
    }
    fn get(self) -> Option<usize> {
        (self.0 != u32::MAX).then_some(self.0 as usize)
    }
}

impl serde::Serialize for Slot {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        match self.get() {
            Some(v) => s.serialize_u64(v as u64),
            None => s.serialize_none(),
        }
    }
}
impl<'de> serde::Deserialize<'de> for Slot {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Slot, D::Error> {
        Ok(Slot::from_opt(
            Option::<u32>::deserialize(d)?.map(|v| v as usize),
        ))
    }
}

/// One consecutive-build vtable alignment (old slot index -> new slot). The ~93 % of alignments that are pure
/// IDENTITY (slot `i` still maps to slot `i`, no drops) — plus the empty alignment — are carried as just their
/// LENGTH, both on disk (a single integer, not the full `[0,1,2,…,n-1]` array) AND IN MEMORY (the `Identity`
/// variant, not a materialized `Vec<Slot>`). `hops` is the model's dominant field at scale (per-class ×
/// per-build-PAIR, so it grows with corpus length), and it is overwhelmingly identity, so this collapses the
/// on-disk size, the JSON parse cost, AND the resident RAM + per-load allocations (a model load does not
/// materialize millions of small identity `Vec`s) — with no derivation change. A shifted alignment keeps the
/// explicit slot list (`Explicit`, `null` for a dropped slot). `deserialize` accepts BOTH forms, so a full
/// identity array also loads and normalizes to `Identity`.
#[derive(Clone, PartialEq, Eq)]
enum Hop {
    /// identity/empty alignment of this length: slot `i` maps to `i`. The compressible common case.
    Identity(u32),
    /// a shifted alignment: the explicit old-slot -> new-slot list (`Slot::NONE` for a dropped slot).
    Explicit(Vec<Slot>),
}

impl Default for Hop {
    fn default() -> Hop {
        Hop::Identity(0) // the empty alignment
    }
}

impl Hop {
    /// Classify a raw alignment into the compact form: a pure identity/empty run (every present slot maps to
    /// its own index) collapses to `Identity(len)`, anything shifted stays `Explicit`.
    fn from_slots(v: Vec<Slot>) -> Hop {
        if v.iter().enumerate().all(|(i, s)| s.get() == Some(i)) {
            Hop::Identity(v.len() as u32)
        } else {
            Hop::Explicit(v)
        }
    }
    /// The alignment as one `Option<usize>` per old slot — the form the derive/backfill readers consume
    /// (identity materializes `(0..len)` on demand rather than storing it).
    fn to_options(&self) -> Vec<Option<usize>> {
        match self {
            Hop::Identity(n) => (0..*n as usize).map(Some).collect(),
            Hop::Explicit(v) => v.iter().map(|s| s.get()).collect(),
        }
    }
}

impl serde::Serialize for Hop {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        match self {
            Hop::Identity(n) => s.serialize_u64(*n as u64), // identity (incl. empty) -> its length
            Hop::Explicit(v) => v.serialize(s),             // shifted -> the explicit slot array
        }
    }
}

impl<'de> serde::Deserialize<'de> for Hop {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Hop, D::Error> {
        use serde::de::{self, SeqAccess, Visitor};
        struct HopVisitor;
        impl<'de> Visitor<'de> for HopVisitor {
            type Value = Hop;
            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
                f.write_str("an integer (identity-alignment length) or an array of slot indices")
            }
            // integer form: an identity alignment of this length -> slot i maps to i.
            fn visit_u64<E: de::Error>(self, v: u64) -> Result<Hop, E> {
                Ok(Hop::Identity(v as u32))
            }
            fn visit_i64<E: de::Error>(self, v: i64) -> Result<Hop, E> {
                self.visit_u64(v as u64)
            }
            // array form: normalize through `from_slots` so a pre-compression identity array collapses to
            // `Identity` (identical to the integer form), a shift stays `Explicit`.
            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Hop, A::Error> {
                let mut v = Vec::new();
                while let Some(s) = seq.next_element::<Slot>()? {
                    v.push(s);
                }
                Ok(Hop::from_slots(v))
            }
        }
        d.deserialize_any(HopVisitor)
    }
}

/// Distilled cross-build corpus signals — everything `gamedata` reads from the full build corpus,
/// WITHOUT the Valve binaries. Fingerprints/offsets/slot-indices are derived facts (zero
/// Valve bytes) → shippable. Produced by `corpus-model`; consumed by `gamedata --corpus-model`
/// alongside only the target build's binary. Incremental: model N + build N+1 → model N+1.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct CorpusModel {
    /// The class scope this model was distilled with. The incremental fold recomputes the tracked-class set
    /// with the CLI's `--class-scope`, so a fold whose scope differs from the distill's would silently
    /// produce a model that is NOT the re-distill it claims to be — the fold `ensure!`s they match. Defaults
    /// to `Clean` (what every production model uses) when the field is absent.
    #[serde(default)]
    class_scope: ClassScope,
    builds: Vec<BuildMeta>, // chronological
    /// class -> per consecutive build-pair: the vtable slot alignment (old slot -> new slot). Each alignment
    /// is a [`Hop`], which serializes the identity-alignment common case as a single length integer.
    hops: BTreeMap<String, Vec<Hop>>,
    /// class -> per build: vtable slot count (offset in-bounds checks).
    slot_counts: BTreeMap<String, Vec<usize>>,
    /// sig function -> per build: the vtable slot its signature resolved to (virtual-anchor timeline).
    resolved_slot: BTreeMap<String, Vec<Slot>>,
    /// sig function -> `(build index, raw fingerprint)` across the newest `CORPUS_REF_K` BUILDS, newest
    /// first — the sig-hit verification set.
    ///
    /// The build index is what makes the window well-defined. Without it the incremental fold could only
    /// keep "the newest K OBSERVATIONS", which for a function that fails to resolve in some builds retains
    /// fingerprints from arbitrarily far back — a superset of what a re-distill produces, drifting further
    /// with every fold, and one the fold==re-distill equivalence claim could never actually hold against.
    /// With it, the fold evicts by build AGE and reproduces the re-distill's window exactly.
    ref_fps: BTreeMap<String, Vec<(u32, Vec<u32>)>>,
    /// latest build's per-class vtable slot fingerprints — to align the NEXT build without its binary.
    /// (BTreeMap like the other fields, so the serialized model is deterministic; the transient per-build
    /// `VtableFps` stays a HashMap — this is the one boundary where they convert.)
    latest_vtable_fps: BTreeMap<String, Vec<Option<Vec<u32>>>>,
    /// sig function -> its consensus ABI shape across the newest builds. Lets `gamedata --corpus-model`
    /// flag a target whose derived shape differs from history at DERIVE time — the prototype-drift signal
    /// `abi-diff` gives, but forward and corpus-free.
    consensus_abi: BTreeMap<String, AbiSig>,
    /// sig function -> `(build index, raw ABI shape)` over the newest builds, mirroring [`Self::ref_fps`].
    /// The window `consensus_abi` is the mode of — stored so an incremental fold can re-window it and
    /// recompute the consensus byte-exactly rather than approximate from the collapsed mode.
    abi_obs: BTreeMap<String, Vec<(u32, AbiSig)>>,
}

/// Number of newest builds whose raw sig fingerprints go into the model's verification set.
const CORPUS_REF_K: usize = 8;

/// The set of classes a model tracks hops for — THE rule that has to agree between the full distill and the
/// incremental fold, since `fold_model_cmd` claims to produce a model equal to a re-distill over the same
/// builds and CI depends on that. Shared for the same reason `extract_build_vtables` and `ref_obs_of_build`
/// are: a divergence here would be silent, and the fold is the standing production path.
///
/// `clean` (default) = every REAL game class in the latest build — enough for any modding/PR offset to
/// derive model-only, without the dead weight (template stamps / protobuf / NetworkVar chainers whose hops
/// nobody derives an offset from, and whose ML "patterns" live in the fingerprint matrix, not here). `all`
/// keeps those too; `catalogue` keeps only what the catalogue names. The catalogue's own classes are ALWAYS
/// unioned in (the derivation needs them). CI compresses the model on release, so the uncompressed on-disk
/// size is not the shipped size.
fn tracked_classes(
    prof: &GameProfile,
    class_scope: ClassScope,
    latest_imgs: &[CodeImage],
    vfuncs: &[VtFunc],
    sig_funcs: &[&Func],
) -> Vec<String> {
    let class_ok = |name: &str| match class_scope {
        ClassScope::All => true,
        ClassScope::Catalogue => false,
        ClassScope::Clean => {
            !is_dead_weight_class(prof, name)
                && !name.contains('<')
                && !name.contains("NetworkVar_")
        }
    };
    latest_imgs
        .iter()
        .flat_map(|img| rtti::enumerate_vtables(img, prof.max_vtable_slots))
        .filter(|c| c.offset_to_top == 0)
        .map(|c| c.name)
        .filter(|n| class_ok(n))
        .chain(vfuncs.iter().map(|f| f.class.clone()))
        .chain(sig_funcs.iter().map(|f| class_of(&f.name).to_string()))
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect()
}

pub fn corpus_model_cmd(
    prof: &GameProfile,
    catalogue: &Path,
    corpus: &Path,
    class_scope: ClassScope,
    out: &Path,
) -> Result<()> {
    let cat = load_catalogue(catalogue)?;

    // dated vtable-offset timelines -> the classes we chain (same as gamedata).
    let vfuncs = vtable_offset_timelines(&cat);
    let sig_funcs: Vec<&Func> = cat.iter().filter(|f| !linux_sigs(f).is_empty()).collect();

    let mut builds: Vec<(String, PathBuf)> = find_builds(prof, corpus)?
        .into_iter()
        .map(|p| (build_date(&label_of(&p)), p))
        .collect();
    builds.sort();
    ensure!(!builds.is_empty(), "no builds under {}", corpus.display());

    let latest_imgs = load_build_images(prof, &builds.last().unwrap().1);
    let classes = tracked_classes(prof, class_scope, &latest_imgs, &vfuncs, &sig_funcs);
    eprintln!(
        "distilling {} builds x {} classes (scope={class_scope:?}; reads the corpus once) ...",
        builds.len(),
        classes.len()
    );

    // The one expensive pass over the binaries: each class's vtable slot fingerprints+addresses and each sig
    // function's resolved address, per build. Builds are processed ONE AT A TIME in chronological order — the
    // parallelism lives INSIDE each build's extract (the `resolve_unique` sweep is ~98% of the cost and fans
    // out across all cores; see `extract_build_vtables`), so a single build saturates the machine while only
    // that build's fps (plus the previous build's, for the next hop) are ever resident. That keeps peak RAM at
    // ~1.5 builds — which is what makes a deep game (Dota's fat fps)
    // fit. The fat per-slot fingerprints are the memory hog; the distilled signals below consume them only
    // pairwise (nw_align of consecutive builds) plus the final build's set, so we carry only the previous
    // build's fps across the loop and drop the rest. Byte-identical to a whole-corpus distill (same
    // consecutive pairs, same order).
    let want: HashSet<&str> = classes.iter().map(String::as_str).collect();
    let nb = builds.len();
    let mut hop_acc: HashMap<String, Vec<Vec<Slot>>> = classes
        .iter()
        .map(|c| (c.clone(), Vec::with_capacity(nb.saturating_sub(1))))
        .collect();
    let mut count_acc: HashMap<String, Vec<usize>> = classes
        .iter()
        .map(|c| (c.clone(), Vec::with_capacity(nb)))
        .collect();
    let mut rslot_acc: HashMap<String, Vec<Slot>> = sig_funcs
        .iter()
        .map(|f| (f.name.clone(), Vec::with_capacity(nb)))
        .collect();
    let mut prev_fps: Option<VtableFps> = None;
    let nthreads = default_threads(None);
    for (_, dir) in &builds {
        let bv = extract_build_vtables(prof, dir, &want, &sig_funcs, nthreads);
        // A build whose libs are present-but-unparseable poisons every chain that crosses it (empty
        // fingerprints ⇒ empty hops ⇒ an unconditional chain break), and `find_builds` admits builds on
        // file existence alone — so say so instead of folding the damage in silently.
        if bv.empty {
            bail!(
                "build {} loaded ZERO libraries — it would contribute empty hops and sever every \
                 anchor chain crossing it; remove or repair it, then re-distill",
                bv.date
            );
        }
        if !bv.unreadable.is_empty() {
            eprintln!(
                "  WARNING build {}: {} unparseable ({}) — its fingerprints are partial",
                bv.date,
                bv.unreadable.len(),
                bv.unreadable.join(", ")
            );
        }
        // Per-class facts — the slot count and the vtable-slot hop against the previous build — computed in
        // PARALLEL over the class set. `parallel_map` returns results in input (class) order, so pushing them
        // into the by-name accumulators below is byte-identical to a serial per-class loop —
        // the build loop itself stays serial (chronology is the hop's `from`, and each key still gets one push
        // per build). count is folded into the same pass (trivial, but it spares a second walk of the classes).
        let per_class: Vec<(usize, Option<Vec<Slot>>)> = match &prev_fps {
            Some(prev) => parallel_map(&classes, nthreads, |c| {
                let count = bv.fps.get(c).map_or(0, Vec::len);
                // hop empty where either side lacks the class — identical to the windows(2) pairing of a
                // whole-corpus distill.
                let hv: Vec<Slot> = match (prev.get(c), bv.fps.get(c)) {
                    (Some(a), Some(b)) => nw_align(a, b).into_iter().map(Slot::from_opt).collect(),
                    _ => Vec::new(),
                };
                (count, Some(hv))
            }),
            // first build: a count for every class, but no hop yet (nothing to align against).
            None => parallel_map(&classes, nthreads, |c| {
                (bv.fps.get(c).map_or(0, Vec::len), None)
            }),
        };
        for (c, (count, hop)) in classes.iter().zip(per_class) {
            count_acc.get_mut(c).unwrap().push(count);
            if let Some(hv) = hop {
                hop_acc.get_mut(c).unwrap().push(hv);
            }
        }
        // the vtable slot each sig fn resolved to this build (position of its resolved addr in the vtable),
        // likewise parallel over the sig set and reassembled in input order.
        let rslots: Vec<Slot> = parallel_map(&sig_funcs, nthreads, |f| {
            Slot::from_opt(resolved_slot_of(&bv, f))
        });
        for (f, s) in sig_funcs.iter().zip(rslots) {
            rslot_acc.get_mut(&f.name).unwrap().push(s);
        }
        prev_fps = Some(bv.fps); // carry only this build's fps: next hop's `from`, and at the end `latest`
    }

    // distill -> the small, shippable signals (assembled in classes / sig_funcs key order)
    let hops: BTreeMap<String, Vec<Hop>> = classes
        .iter()
        .map(|c| {
            (
                c.clone(),
                hop_acc
                    .remove(c)
                    .unwrap_or_default()
                    .into_iter()
                    .map(Hop::from_slots)
                    .collect(),
            )
        })
        .collect();
    let slot_counts: BTreeMap<String, Vec<usize>> = classes
        .iter()
        .map(|c| (c.clone(), count_acc.remove(c).unwrap_or_default()))
        .collect();
    let resolved_slot: BTreeMap<String, Vec<Slot>> = sig_funcs
        .iter()
        .map(|f| {
            (
                f.name.clone(),
                rslot_acc.remove(&f.name).unwrap_or_default(),
            )
        })
        .collect();
    // convert the transient (HashMap) newest-build fps into the model's deterministic BTreeMap field.
    let latest_vtable_fps: BTreeMap<String, Vec<Option<Vec<u32>>>> =
        prev_fps.unwrap_or_default().into_iter().collect();

    // reference fingerprints of each sig fn in the newest CORPUS_REF_K builds (verification set, raw)
    // newest-first, carrying each build's ABSOLUTE index so the window is defined by build age (see
    // `CorpusModel::ref_fps`) rather than by how many observations happened to succeed.
    let ref_dirs: Vec<(u32, PathBuf)> = builds
        .iter()
        .enumerate()
        .rev()
        .take(CORPUS_REF_K)
        .map(|(i, (_, p))| (i as u32, p.clone()))
        .collect();
    // SERIAL over the (few) ref builds so `ref_obs_of_build`'s inner sig fan-out (nthreads-wide) drives the
    // cores directly — an outer parallel_map here would nest and oversubscribe. Order is preserved either way
    // (results keyed by build index + name downstream), so this is byte-identical.
    let per_ref: Vec<(u32, RefObs)> = ref_dirs
        .iter()
        .map(|(i, dir)| (*i, ref_obs_of_build(prof, dir, &cat, &sig_funcs, nthreads)))
        .collect();
    let mut ref_fps: BTreeMap<String, Vec<(u32, Vec<u32>)>> = BTreeMap::new();
    let mut abi_obs: BTreeMap<String, Vec<(u32, AbiSig)>> = BTreeMap::new();
    for (idx, v) in per_ref {
        for (n, fp, sh) in v {
            if let Some(s) = sh {
                abi_obs.entry(n.clone()).or_default().push((idx, s));
            }
            ref_fps.entry(n).or_default().push((idx, fp));
        }
    }
    // consensus ABI shape per sig fn = the modal shape over the newest builds (derive-time drift check).
    // Computed by reference so the raw `abi_obs` window survives into the model (the incremental fold needs it).
    let consensus_abi = consensus_from_windows(&abi_obs);

    let model = CorpusModel {
        class_scope,
        builds: builds
            .iter()
            .map(|(d, p)| BuildMeta {
                label: label_of(p),
                date: d.clone(),
            })
            .collect(),
        hops,
        slot_counts,
        resolved_slot,
        ref_fps,
        latest_vtable_fps,
        consensus_abi,
        abi_obs,
    };
    let text = serde_json::to_string(&model)?;
    std::fs::write(out, &text).with_context(|| format!("write {}", out.display()))?;
    eprintln!(
        "corpus model: {} builds, {} classes, {} sig fns, {:.1} MB -> {}",
        model.builds.len(),
        classes.len(),
        sig_funcs.len(),
        text.len() as f64 / 1e6,
        out.display()
    );
    Ok(())
}

/// One build's tracked-class vtable fingerprints/addresses plus each sig fn's uniquely-resolved address —
/// the per-build extraction shared by the full distill (`corpus-model`) and the incremental fold. One RTTI
/// sweep per image (imgs are [server, engine2, …] — the first image to carry a class wins), keeping only the
/// `want` classes' primary vtables: O(sweep + classes), not O(classes × sweep).
fn extract_build_vtables(
    prof: &GameProfile,
    dir: &Path,
    want: &HashSet<&str>,
    sig_funcs: &[&Func],
    nthreads: usize,
) -> BuildVtables {
    let (imgs, unreadable) = load_build_images_counted(prof, dir);
    let (mut fps, mut addrs) = (HashMap::new(), HashMap::new());
    let mut truncated: Vec<String> = Vec::new();
    for img in &imgs {
        // Fresh memo per IMAGE (never shared across images — see slot_fingerprints_memo's caveat): inherited
        // slots repeat across this image's subclass vtables, so this collapses the fingerprint pass.
        let mut fp_memo: HashMap<u64, Option<Vec<u32>>> = HashMap::new();
        for cv in rtti::enumerate_vtables(img, prof.max_vtable_slots)
            .into_iter()
            .filter(|c| c.offset_to_top == 0)
        {
            if want.contains(cv.name.as_str()) && !fps.contains_key(&cv.name) {
                // Exactly at the cap means the read STOPPED there, not that the vtable ended there — the
                // tail (and any offset in it) is missing from the model with nothing else to show for it.
                if cv.slots.len() == prof.max_vtable_slots {
                    truncated.push(cv.name.clone());
                }
                fps.insert(
                    cv.name.clone(),
                    slot_fingerprints_memo(img, &cv.slots, &mut fp_memo),
                );
                addrs.insert(cv.name.clone(), cv.slots);
            }
        }
    }
    if !truncated.is_empty() {
        truncated.sort();
        eprintln!(
            "  WARNING {}: {} class(es) hit the {}-slot vtable cap (truncated — raise \
             GameProfile::max_vtable_slots for {}): {}{}",
            label_of(dir),
            truncated.len(),
            prof.max_vtable_slots,
            prof.token,
            truncated
                .iter()
                .take(5)
                .cloned()
                .collect::<Vec<_>>()
                .join(", "),
            if truncated.len() > 5 { ", …" } else { "" }
        );
    }
    // `resolve_unique` dominates the whole extract — a `scan_sig_hits` sweep of every sig fn across all the
    // build's images (the RTTI+fingerprint pass above is a tiny fraction of it). It holds no per-build
    // fingerprints, so parallelising it over the sig set is RSS-NEUTRAL, and the result is keyed by name so it
    // is byte-identical to a serial resolve. This is the distill's real hot loop (see the
    // build-serial driver in `corpus_model_cmd`, which lets this saturate all cores at ~1.5 builds resident).
    let resolved: HashMap<String, u64> = parallel_map(sig_funcs, nthreads, |f| {
        resolve_unique(f, &imgs).map(|a| (f.name.clone(), a))
    })
    .into_iter()
    .flatten()
    .collect();
    BuildVtables {
        date: build_date(&label_of(dir)),
        fps,
        addrs,
        resolved,
        unreadable,
        empty: imgs.is_empty(),
    }
}

/// Unique address of `f` scanning a build's loaded images directly (server+engine2 as a set).
fn resolve_unique(f: &Func, imgs: &[CodeImage]) -> Option<u64> {
    // Union the per-image unique-match addresses; resolve iff exactly one distinct address wins across all
    // images (vote counts are irrelevant here — only the distinct-address set matters).
    let mut keys: BTreeSet<u64> = BTreeSet::new();
    for img in imgs {
        keys.extend(scan_sig_hits(f, img).into_keys());
    }
    (keys.len() == 1).then(|| *keys.iter().next().unwrap())
}

/// The vtable slot a sig fn's resolved address lands in for one build — the fourth per-build "fact" the
/// model records (beside slot_counts / hops / ref windows). SHARED by the full distill and the incremental
/// fold for the same reason its three siblings (`extract_build_vtables`/`ref_obs_of_build`/`tracked_classes`)
/// are: the fold claims byte-equality with a re-distill, and an inline copy edited in one path but not the
/// other would break that silently.
fn resolved_slot_of(bv: &BuildVtables, f: &Func) -> Option<usize> {
    let class = class_of(&f.name);
    bv.resolved.get(&f.name).and_then(|addr| {
        bv.addrs
            .get(class)
            .and_then(|a| a.iter().position(|x| x == addr))
    })
}

/// One reference build's per-sig-fn `(name, fingerprint, optional ABI shape)` observations — the ref-window
/// extraction shared by the full distill and the incremental fold. Uses `locate_addr` (the same path the
/// live-derive verification takes), not the chunk loop's `resolve_unique`. The per-sig work
/// (locate_addr + fingerprint + abi_shape, all thread-safe reads over `rimg`) is parallelised `nthreads`-wide:
/// in the FOLD this is the sole per-sig pass (a serial 1-wide stretch otherwise), and in the distill the
/// caller drives the ref builds SERIALLY so this inner fan-out uses the cores rather than nesting under an
/// outer parallel_map. `parallel_map` preserves input order, so the output equals the old serial `filter_map`.
fn ref_obs_of_build(
    prof: &GameProfile,
    dir: &Path,
    cat: &[Func],
    sig_funcs: &[&Func],
    nthreads: usize,
) -> RefObs {
    let rimg = preload_images(prof, cat, dir);
    parallel_map(sig_funcs, nthreads, |f| {
        let (img, addr, _) = locate_addr(prof, f, &rimg)?;
        let fp = fingerprint::extract(img, addr)?.to_vec();
        Some((
            f.name.clone(),
            fp,
            abi::abi_shape(img, addr).map(Into::into),
        ))
    })
    .into_iter()
    .flatten()
    .collect()
}

/// Parse a distilled corpus model from disk. Shared by the standalone `fold-model` command and `produce`
/// (which parses it ONCE, lets the derive borrow it, then hands it by value to the sidecar fold).
pub fn load_model(path: &Path) -> Result<CorpusModel> {
    let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
    serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))
}

/// Fold ONE new build into an existing corpus model: `model N + build N+1 → model N+1`, reading only the
/// loaded model + the one new binary (no corpus). The model is DESIGNED for this — `latest_vtable_fps` holds
/// the previous build's per-class fingerprints, exactly the seed for the new hop.
///
/// EQUIVALENCE — the result equals a full re-distill over the same N+1 builds ONLY when the fold is run with
/// the same `class_scope` (asserted below) AND the same catalogue the model was distilled from, AND no class
/// re-appears across the model's latest-build boundary (present in old builds, absent from the model's latest,
/// present again now). In those cases a class/sig-fn the model has never seen is back-filled with ABSENT
/// history (a re-distill would instead resolve its real slots/counts/hops in the old binaries) — reduced
/// coverage for that one name until the next full re-distill, never a wrong offset. The catalogue is not
/// fingerprint-checked (production always folds with the distill's catalogue), but a newcomer is WARNED below.
pub fn fold_model_cmd(
    prof: &GameProfile,
    mut model: CorpusModel,
    catalogue: &Path,
    build: &Path,
    class_scope: ClassScope,
    out: &Path,
) -> Result<()> {
    let cat = load_catalogue(catalogue)?;
    let vfuncs = vtable_offset_timelines(&cat);
    let sig_funcs: Vec<&Func> = cat.iter().filter(|f| !linux_sigs(f).is_empty()).collect();

    ensure!(
        model.class_scope == class_scope,
        "the input model was distilled with class-scope {:?}, but the fold was called with {:?} — they must \
         match or the folded model would diverge from a re-distill; re-run with --class-scope {:?}",
        model.class_scope,
        class_scope,
        model.class_scope
    );

    // The new build becomes the latest, so its RTTI (scope-filtered) ∪ catalogue classes IS the new tracked
    // set — exactly as a full distill takes `classes` from ITS latest build (hence the SHARED
    // `tracked_classes`). So a class dropped from the new build's RTTI (and not catalogue-required) falls
    // out; a class new to it is back-filled below.
    let latest_imgs = load_build_images(prof, build);
    let classes = tracked_classes(prof, class_scope, &latest_imgs, &vfuncs, &sig_funcs);

    let want: HashSet<&str> = classes.iter().map(String::as_str).collect();
    let bv = extract_build_vtables(prof, build, &want, &sig_funcs, default_threads(None));
    // Same guard the full distill enforces: a build that loaded zero libraries contributes empty
    // fingerprints → empty hops → an unconditional chain break severing every anchor older than it. The
    // fold is the standing production path (the sidecar in every `produce`) and its output SEEDS the next
    // build's derive, so folding damage in silently is worse here than in the distill, not better.
    ensure!(
        !bv.empty,
        "build {} loaded ZERO libraries — folding it would sever every anchor chain crossing it; \
         repair or skip this build",
        bv.date
    );
    if !bv.unreadable.is_empty() {
        eprintln!(
            "  WARNING build {}: {} unparseable ({}) — its fingerprints are partial",
            bv.date,
            bv.unreadable.len(),
            bv.unreadable.join(", ")
        );
    }
    let old_nb = model.builds.len();

    // Per class: append the new build's slot count and ONE hop. A continuing class extends its history; a
    // class new to this build is back-filled with absent history (0 count / empty hop for every prior build),
    // matching what a re-distill produces for a class first seen at build N.
    let mut hops: BTreeMap<String, Vec<Hop>> = BTreeMap::new();
    let mut slot_counts: BTreeMap<String, Vec<usize>> = BTreeMap::new();
    let mut backfilled = 0usize; // classes/sig-fns the model has never seen (see the EQUIVALENCE note above)
    for c in &classes {
        let new_count = bv.fps.get(c).map_or(0, Vec::len);
        // MOVE the class's prior counts/hops out of the owned model instead of cloning them (the model is
        // dropped at end of fn and `hops` is its 2.9 GB-dominant field): `remove` returns the exact Vec a
        // clone would have produced, and nothing reads `model.slot_counts`/`model.hops` after this loop, so
        // the folded model is byte-identical. Halves the fold's transient peak RSS.
        match model.slot_counts.remove(c) {
            Some(mut counts) => {
                counts.push(new_count);
                slot_counts.insert(c.clone(), counts);
                // the hop into the new build = align the model's stored latest fps against the new build's
                // (empty where either side lacks the class — identical to the distill's windows(2) pairing).
                let hop: Vec<Slot> = match (model.latest_vtable_fps.get(c), bv.fps.get(c)) {
                    (Some(a), Some(b)) => nw_align(a, b).into_iter().map(Slot::from_opt).collect(),
                    _ => Vec::new(),
                };
                let mut h = model.hops.remove(c).unwrap_or_default();
                h.push(Hop::from_slots(hop));
                hops.insert(c.clone(), h);
            }
            None => {
                backfilled += 1;
                let mut counts = vec![0usize; old_nb];
                counts.push(new_count);
                slot_counts.insert(c.clone(), counts);
                hops.insert(c.clone(), vec![Hop::default(); old_nb]); // old_nb == new_nb − 1 hops, all empty
            }
        }
    }

    // Per sig fn: append the vtable slot its resolved address lands in this build (None if unresolved).
    // `resolved_slot` is a per-build parallel array read by build INDEX (`build_per_build_vtables`), so a
    // name absent from the model — a class/sig-fn the CATALOGUE gained since the distill (NOT a contribution:
    // contributions are a derive-time overlay and never enter the model) — must be back-filled with absent
    // history first, exactly as the class loop above. Without it the lone new observation lands at index 0
    // and is attributed to the OLDEST build in the corpus. (Back-fill ≠ a re-distill's real old-build history
    // for such a newcomer — see the EQUIVALENCE note on `fold_model_cmd`; counted + warned below.)
    let mut resolved_slot: BTreeMap<String, Vec<Slot>> = BTreeMap::new();
    for f in &sig_funcs {
        // move-out (see the class loop): the removed Vec == the cloned one; nothing reads the field after.
        let mut v = model.resolved_slot.remove(&f.name).unwrap_or_else(|| {
            backfilled += 1;
            vec![Slot::NONE; old_nb]
        });
        v.push(Slot::from_opt(resolved_slot_of(&bv, f)));
        resolved_slot.insert(f.name.clone(), v);
    }

    // Ref/ABI windows: prepend the new build's observations (newest-first), then evict everything outside
    // the newest CORPUS_REF_K BUILDS. Evicting by build age — not by list length — is what makes this equal
    // a re-distill: a fn that fails to resolve in some builds would otherwise retain fingerprints from
    // arbitrarily far back, and the divergence would compound over every future fold.
    let new_idx = old_nb as u32; // index of the build being appended = the new latest
    let cutoff = new_idx.saturating_sub(CORPUS_REF_K as u32 - 1); // keep indices >= cutoff
    let mut ref_fps = std::mem::take(&mut model.ref_fps);
    let mut abi_obs = std::mem::take(&mut model.abi_obs);
    for (n, fp, sh) in ref_obs_of_build(prof, build, &cat, &sig_funcs, default_threads(None)) {
        ref_fps
            .entry(n.clone())
            .or_default()
            .insert(0, (new_idx, fp));
        if let Some(s) = sh {
            abi_obs.entry(n).or_default().insert(0, (new_idx, s));
        }
    }
    for v in ref_fps.values_mut() {
        v.retain(|(i, _)| *i >= cutoff);
    }
    for v in abi_obs.values_mut() {
        v.retain(|(i, _)| *i >= cutoff);
    }
    ref_fps.retain(|_, v| !v.is_empty());
    abi_obs.retain(|_, v| !v.is_empty());
    let consensus_abi = consensus_from_windows(&abi_obs);

    let latest_vtable_fps: BTreeMap<String, Vec<Option<Vec<u32>>>> = bv.fps.into_iter().collect();
    let mut builds = model.builds;
    builds.push(BuildMeta {
        label: label_of(build),
        date: build_date(&label_of(build)),
    });

    let folded = CorpusModel {
        class_scope,
        builds,
        hops,
        slot_counts,
        resolved_slot,
        ref_fps,
        latest_vtable_fps,
        consensus_abi,
        abi_obs,
    };
    // The per-build arrays are read by build INDEX everywhere downstream, so a length desync silently
    // re-dates an observation instead of failing. Check the shape the readers assume — O(classes) against
    // a fold that just walked a whole binary, and this is the production path, so not a debug-only assert.
    let nb = folded.builds.len();
    for (c, v) in &folded.slot_counts {
        ensure!(
            v.len() == nb,
            "slot_counts[{c}] has {} entries, expected {nb}",
            v.len()
        );
    }
    for (c, v) in &folded.hops {
        ensure!(
            v.len() == nb - 1,
            "hops[{c}] has {} entries, expected {}",
            v.len(),
            nb - 1
        );
    }
    for (n, v) in &folded.resolved_slot {
        ensure!(
            v.len() == nb,
            "resolved_slot[{n}] has {} entries, expected {nb}",
            v.len()
        );
    }

    let out_text = serde_json::to_string(&folded)?;
    std::fs::write(out, &out_text).with_context(|| format!("write {}", out.display()))?;
    eprintln!(
        "folded {} -> model N+1: {} builds, {} classes, {} sig fns, {:.1} MB -> {}",
        label_of(build),
        folded.builds.len(),
        classes.len(),
        sig_funcs.len(),
        out_text.len() as f64 / 1e6,
        out.display()
    );
    if backfilled > 0 {
        eprintln!(
            "  NOTE {backfilled} class(es)/sig-fn(s) were new to the model and back-filled with EMPTY \
             pre-fold history — a re-distill would record their real old-build history instead (catalogue \
             grew since the distill). Harmless (reduced coverage for those names only); re-distill for exact."
        );
    }
    Ok(())
}

// ══════════════════════════════════════════════════════════════════════════════════════════════
// §7 · GAMEDATA DERIVATION  (catalogue → signatures / vtable offsets)
// ══════════════════════════════════════════════════════════════════════════════════════════════

/// A per-catalogue-function signature-resolution verdict from [`resolve_signatures`].
enum SigResolve {
    Skip,
    Flag(String),
    Emit(String, String, String), // confident: (name, library, signature)
    EmitFallback(String, String, String), // heuristic-flagged but uniquely resolves — live-validate
}
/// `(dbg line, resolution, optional (name, abi-drift detail))` per catalogue function.
type SigItem = (Option<String>, SigResolve, Option<(String, String)>);

/// Resolve every catalogue signature in the target, across threads: locate the era-sig's candidates, verify
/// the hit against the reference fingerprints (or a self-consensus when history is thin), regenerate a fresh
/// unique sig, and run the derive-time ABI-drift check. Read-only over `images`/`ref_fps`; the caller applies
/// the returned verdicts in catalogue order, so `gd`/`sig_ok`/`sig_flag` stay byte-identical to a serial run.
fn resolve_signatures(
    prof: &GameProfile,
    cat: &[Func],
    images: &HashMap<String, CodeImage>,
    ref_fps: &HashMap<String, Vec<Vec<u32>>>,
    ref_majority: usize,
    cmodel: Option<&CorpusModel>,
    dbg: bool,
) -> Vec<SigItem> {
    const VERIFY_L1_MAX: u64 = 12;
    parallel_map(cat, default_threads(None), |f| {
        if linux_sigs(f).is_empty() {
            return (None, SigResolve::Skip, None);
        }
        let Some((img, lib, cands)) = locate_candidates(prof, f, images) else {
            return (None, SigResolve::Flag(f.name.clone()), None);
        };
        let mut dbg_line = None;
        let chosen = match ref_fps.get(&f.name) {
            // stable recent history: pick the target candidate nearest the real function's fingerprint,
            // accept only within recompile distance (rejects a stale sig that drifted onto a decoy).
            Some(rs) if rs.len() >= ref_majority => {
                let best = cands
                    .iter()
                    .filter_map(|&(addr, _)| {
                        let tfp = fingerprint::extract(img, addr)?.to_vec();
                        let d = rs.iter().map(|r| l1(&tfp, r)).min().unwrap_or(u64::MAX);
                        Some((addr, d))
                    })
                    .min_by_key(|&(_, d)| d);
                if dbg && let Some((a, d)) = best {
                    dbg_line = Some(format!(
                        "  [dbg] {:<46} @{a:#x} L1={d} ({} refs)",
                        f.name,
                        rs.len()
                    ));
                }
                best.filter(|&(_, d)| d <= VERIFY_L1_MAX).map(|(a, _)| a)
            }
            // no stable reference history: trust only a self-corroborating consensus of >=2 distinct era-sigs
            // agreeing in the target itself; a lone hit is otherwise a likely coincidental decoy -> flag.
            _ => {
                if dbg {
                    let top = cands.first().map(|&(a, v)| (a, v)).unwrap_or((0, 0));
                    dbg_line = Some(format!(
                        "  [dbg] {:<46} @{:#x} votes={} refs={} -> {}",
                        f.name,
                        top.0,
                        top.1,
                        ref_fps.get(&f.name).map_or(0, Vec::len),
                        if top.1 >= 2 { "consensus" } else { "FLAG" }
                    ));
                }
                cands.first().filter(|&&(_, v)| v >= 2).map(|&(a, _)| a)
            }
        };
        // Fallback: the verification heuristic rejected every candidate, but if exactly one era-sig hit
        // uniquely resolves in the target it is a legitimate — if unverified — location; emit it and lean on
        // validate-live as the real gate rather than dropping a real function that merely drifted.
        let was_flagged = chosen.is_none();
        let chosen = chosen.or_else(|| (cands.len() == 1).then(|| cands[0].0));
        // Derive-time prototype-drift check (model path only): unknown-return-guarded, a kept signal for
        // review, not a drop — the sig still emits.
        let abi_drift = chosen.and_then(|addr| {
            let cons = cmodel?.consensus_abi.get(&f.name)?;
            let sh: AbiSig = abi::abi_shape(img, addr)?.into();
            sh.differs(cons).then(|| {
                (
                    f.name.clone(),
                    format!("target [{}] vs history [{}]", sh.brief(), cons.brief()),
                )
            })
        });
        let r = match chosen
            .and_then(|addr| emit::make_sig(img, addr, CORE_SIG_CAP).map(|s| (lib, s)))
        {
            Some((lib, sig)) if was_flagged => SigResolve::EmitFallback(f.name.clone(), lib, sig),
            Some((lib, sig)) => SigResolve::Emit(f.name.clone(), lib, sig),
            None => SigResolve::Flag(f.name.clone()),
        };
        (dbg_line, r, abi_drift)
    })
}

/// Byte budget for signatures generated by the DERIVE (the `core` tier). Distinct from `--sig-cap`, which
/// bounds the FOLD's generated sigs: the two run over different inputs, and the derive's value is the one
/// the shipped guaranteed tier was calibrated against.
const CORE_SIG_CAP: usize = 400;

/// How many of the newest reference builds the sig-hit verification samples when it reads BINARIES; also the
/// history FLOOR (`/2 + 1` = 3) below which the model path won't trust a fingerprint match. See
/// [`build_ref_fingerprints`] for why the model path reuses this constant rather than `CORPUS_REF_K`.
const REF_BUILDS: usize = 5;

/// Reference fingerprints for the sig-hit verification, plus the clean-resolve majority threshold: straight
/// from the corpus MODEL (which ships them), or by resolving + fingerprinting each sig fn in the reference
/// binaries. (A function resolving cleanly in the majority of sampled builds == a stable recent history;
/// below that a lone target hit is a suspected decoy.)
fn build_ref_fingerprints(
    prof: &GameProfile,
    cmodel: Option<&CorpusModel>,
    cat: &[Func],
    ref_dirs: &[PathBuf],
) -> (HashMap<String, Vec<Vec<u32>>>, usize) {
    if let Some(m) = cmodel {
        // the model ships ref_fps as a (deterministic) BTreeMap; the derive-side verification set is a plain
        // HashMap (order-independent — it's only `.get()`/`.len()`), so copy it across.
        let ref_fps = m
            .ref_fps
            .iter()
            .map(|(k, v)| (k.clone(), v.iter().map(|(_, fp)| fp.clone()).collect()))
            .collect();
        // This threshold is a HISTORY FLOOR, not a per-window majority — deliberately `REF_BUILDS/2+1` (=3),
        // NOT `CORPUS_REF_K/2+1` (=5), even though the model's window is CORPUS_REF_K long. `resolve_signatures`
        // routes on `window.len() >= threshold`: at/above it a function takes the STRICT arm, which accepts the
        // target only within recompile distance of its NEAREST historical fingerprint (`rs.iter().map(l1).min()`
        // — one close ref suffices; it is not a vote). So "resolved cleanly in >=3 of the newest builds" is
        // ample basis for that min-distance check. A higher floor would demote functions with only 3-4 builds
        // of genuine history to the lenient >=2-era-sig arm, where single-candidate ones fall through to the
        // UNVERIFIED fallback — so a higher floor yields MORE unverified entries, not fewer.
        return (ref_fps, REF_BUILDS / 2 + 1);
    }
    let per_ref: Vec<Vec<(String, Vec<u32>)>> =
        parallel_map(ref_dirs, default_threads(None), |dir| {
            let rimg = preload_images(prof, cat, dir);
            let mut v = Vec::new();
            for f in cat {
                if linux_sigs(f).is_empty() {
                    continue;
                }
                if let Some((img, addr, _)) = locate_addr(prof, f, &rimg)
                    && let Some(fp) = fingerprint::extract(img, addr)
                {
                    v.push((f.name.clone(), fp.to_vec()));
                }
            }
            v
        });
    let mut rf: HashMap<String, Vec<Vec<u32>>> = HashMap::new();
    for v in per_ref {
        for (n, fp) in v {
            rf.entry(n).or_default().push(fp);
        }
    }
    let maj = ref_dirs.len() / 2 + 1;
    (rf, maj)
}

/// Apply the (thread-resolved) per-function sig verdicts to `gd` IN CATALOGUE ORDER, so `gd` (name-keyed),
/// the emitted-count and the flag/fallback lists are byte-identical to a serial resolve. Emits each captured
/// DBG line and reports the fallback set. Returns `(emitted, flagged, fallback, abi_drift)` — `flagged`/
/// `emitted` are further mutated by the recovery passes in the caller.
fn apply_sig_items(
    sig_items: Vec<SigItem>,
    gd: &mut model::Gamedata,
) -> (usize, Vec<String>, Vec<String>, Vec<model::Flagged>) {
    let mut sig_ok = 0;
    let mut sig_flag: Vec<String> = Vec::new();
    let mut sig_fallback: Vec<String> = Vec::new();
    let mut abi_drift: Vec<model::Flagged> = Vec::new();
    for (dbg_line, r, drift) in sig_items {
        if let Some(l) = dbg_line {
            eprintln!("{l}");
        }
        if let Some((name, detail)) = drift {
            abi_drift.push(model::Flagged::new(
                name,
                model::FlagReason::AbiDrift,
                detail,
            ));
        }
        match r {
            SigResolve::Skip => {}
            SigResolve::Flag(name) => sig_flag.push(name),
            SigResolve::Emit(name, lib, sig) => {
                gd.set_signature(name, lib, sig);
                sig_ok += 1;
            }
            SigResolve::EmitFallback(name, lib, sig) => {
                gd.set_signature(name.clone(), lib, sig);
                sig_ok += 1;
                sig_fallback.push(name);
            }
        }
    }
    if !sig_fallback.is_empty() {
        eprintln!(
            "  {} signatures emitted via unique-resolve fallback (fingerprint-UNVERIFIED — marked \
             `catalogue-unverified` in the monolith): {}",
            sig_fallback.len(),
            sig_fallback.join(", ")
        );
    }
    (sig_ok, sig_flag, sig_fallback, abi_drift)
}

/// One offline derive, in memory — no files are written; the fold consumes this directly, disk-free.
pub(crate) struct Derived {
    /// The cssharp-rendered guaranteed gamedata (the `core` tier).
    pub core: String,
    /// Why each catalogue entry failed to produce a shipped locator. Typed, not a JSON string: this crosses
    /// no disk boundary (the fold consumes it in the same call chain), and a typed value makes a renamed
    /// field a compile error rather than a silently absorbed default.
    pub flagged: Vec<model::Flagged>,
    /// Names emitted through the unique-resolve fallback: the reference-fingerprint check REJECTED every
    /// candidate, but exactly one era-sig resolved uniquely, so the locator is sound while the identity is
    /// UNVERIFIED. They land in `core` like any other catalogue entry, so the monolith must mark them —
    /// live validation cannot re-verify them (it checks that a sig resolves to executable code, and the
    /// pattern was generated from that very address, so a decoy passes trivially).
    pub unverified: BTreeSet<String>,
}

/// Derive a target build's gamedata OFFLINE and return it in memory.
pub(crate) fn gamedata(
    prof: &GameProfile,
    catalogue: &Path,
    source: CorpusSource,
    target: &Path,
) -> Result<Derived> {
    let emitter = render::by_id("cssharp").expect("cssharp is a built-in format");
    let mut cat = load_catalogue(catalogue)?;
    // Merge contributions BY NAME into the catalogue, not `extend` — a contribution for a name the catalogue
    // already carries must add its dated variant to that Func's `variants` (so `scan_sig_hits`, which votes
    // across one Func's variants, can let it corroborate the existing era-sigs), not append a second Func of
    // the same name that the resolver would treat as an unrelated entry and double-count in the tallies.
    for c in load_contributions(prof, catalogue) {
        match cat.iter_mut().find(|f| f.name == c.name) {
            Some(f) => f.variants.extend(c.variants),
            None => cat.push(c),
        }
    }
    let mut gd = model::Gamedata {
        game_key: prof.game_key.to_string(),
        ..Default::default()
    };

    // dated vtable-offset timelines (consumed by the offset section below).
    let vfuncs = vtable_offset_timelines(&cat);

    // Corpus MODEL (distilled) OR the raw corpus binaries: with a model, only the target binary is
    // read; the model supplies the reference fingerprints + the cross-build vtable alignment. The model
    // is pre-parsed by the caller (borrowed here, read-only) so `produce` parses it a single time.
    let cmodel: Option<&CorpusModel> = match source {
        CorpusSource::Model(m) => Some(m),
        CorpusSource::Binaries(_) => None,
    };
    // The corpus model is a FORWARD-derivation artifact: it ships cross-build vtable hops plus the
    // fingerprints of its LATEST build only, so the closing alignment hop (model's latest -> target)
    // is reconstructable exactly when the target is newer than every build the model distilled. A
    // target inside the model's range would both lack the fingerprints for its own predecessor hop
    // and let post-target builds pollute the offset chains (the slot would drift the wrong way) — so
    // refuse it up front and point at --corpus rather than emit a quietly-wrong offset. Compare full
    // "YYYY-MM-DD_HHMMSS" labels (they sort chronologically) so a same-day-but-later build still counts.
    if let Some(m) = cmodel
        && let Some(latest) = m.builds.last()
    {
        let tlabel = label_of(target);
        ensure!(
            tlabel.as_str() > latest.label.as_str(),
            "target build {tlabel} is not newer than the corpus model's latest build {} — \
                 --corpus-model derives forward-only. Re-distill a model excluding builds >= the \
                 target, or derive this target from --corpus <binaries>.",
            latest.label
        );
    }
    // reference builds strictly before the target's date (binary path only). Empty with a model —
    // which also auto-skips context recovery below (it has no reference binary to build an xref over).
    let ref_builds: Vec<(String, PathBuf)> = match source {
        CorpusSource::Model(_) => Vec::new(),
        CorpusSource::Binaries(corpus) => {
            let tdate = build_date(&label_of(target));
            let mut rb: Vec<(String, PathBuf)> = find_builds(prof, corpus)?
                .into_iter()
                .map(|p| (build_date(&label_of(&p)), p))
                .filter(|(d, _)| d.as_str() < tdate.as_str())
                .collect();
            rb.sort();
            rb
        }
    };

    // ---- signatures: resolve a known sig in the target, VERIFY the hit is really the function
    //      (fingerprint vs the newest reference builds — a stale era-sig can uniquely match the
    //      WRONG function), then regenerate a fresh unique sig. ----
    let images = preload_images(prof, &cat, target);
    // The target must be a build DIRECTORY that `find_file` can search (a bare `.so` path is NOT searched —
    // read_dir on a file yields nothing). Without this, a mistyped/file target loads zero images and the
    // derive silently emits an all-flagged, near-empty release that even clears the live gate. Fail loudly
    // instead — unless the catalogue is offset-only (no sigs to locate), where empty images is legitimate.
    ensure!(
        images.contains_key(prof.server_lib) || cat.iter().all(|f| linux_sigs(f).is_empty()),
        "target {} loaded no {} — pass the build DIRECTORY (a bare .so path is not searched)",
        label_of(target),
        prof.server_lib
    );

    // Sample the newest reference builds (there a sig still lands on the real function) to reject a target
    // hit that is structurally a different function; see build_ref_fingerprints.
    let ref_dirs: Vec<PathBuf> = ref_builds
        .iter()
        .rev()
        .take(REF_BUILDS)
        .map(|(_, p)| p.clone())
        .collect();
    let (ref_fps, ref_majority) = build_ref_fingerprints(prof, cmodel, &cat, &ref_dirs);
    let dbg = std::env::var("SOURCE2ROSETTA_DBG").is_ok();

    // Each catalogue function resolves independently — locate its candidates, verify the hit against the
    // reference fingerprints, and emit a fresh sig — all read-only over images/ref_fps, each doing many
    // whole-binary memchr scans. Resolve across threads (capturing any SOURCE2ROSETTA_DBG line), then apply in
    // catalogue order so gd (name-keyed), the counts and the flag list are byte-identical to a serial run.
    let sig_items = resolve_signatures(prof, &cat, &images, &ref_fps, ref_majority, cmodel, dbg);
    let (mut sig_ok, mut sig_flag, unverified, abi_drift) = apply_sig_items(sig_items, &mut gd);

    // ---- one full-corpus vtable pass feeds BOTH offset chaining and vtable-anchoring recovery.
    //      Both need each class's vtable fingerprinted across every build; doing it once (over the
    //      union of the classes they need) halves the work versus two separate passes. ----
    // still-flagged functions whose class has a target vtable = virtual-anchoring candidates
    let virt_funcs: Vec<(&Func, String)> = cat
        .iter()
        .filter(|f| !linux_sigs(f).is_empty() && sig_flag.iter().any(|n| n == &f.name))
        .map(|f| (f, class_of(&f.name).to_string()))
        .filter(|(_, c)| {
            images
                .values()
                .any(|im| rtti::find_vtable(im, c, prof.max_vtable_slots).is_some())
        })
        .collect();

    let classes: Vec<String> = vfuncs
        .iter()
        .map(|f| f.class.clone())
        .chain(virt_funcs.iter().map(|(_, c)| c.clone()))
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect();

    // per-build vtable data (fingerprints + addresses + resolved anchors): from the MODEL (only the
    // target binary is read; past builds are synthesised from the distilled signals) or by
    // fingerprinting every reference build's binaries.
    let (per_build, target_idx) = build_per_build_vtables(
        prof,
        cmodel,
        ref_builds,
        target,
        &images,
        &classes,
        &virt_funcs,
    );
    // vtable-slot alignment across consecutive builds: the model ships past hops; the final hop
    // (latest model build -> target) is computed from the target's real vtable fingerprints.
    let hops = build_hops(cmodel, &per_build, target_idx, &classes);

    // vtable-anchoring recovery for changed virtual functions (shares the pass above)
    let vrec = recover_virtual_sigs(
        prof,
        &virt_funcs,
        &per_build,
        &hops,
        target_idx,
        &images,
        &mut gd,
    );
    if !vrec.is_empty() {
        sig_ok += vrec.len();
        sig_flag.retain(|n| !vrec.contains(n));
        eprintln!(
            "  {} drifted signatures RECOVERED via vtable anchoring: {}",
            vrec.len(),
            vrec.join(", ")
        );
    }

    // ---- string-anchor recovery: functions carrying a distinctive search-string (a sig-list anchor)
    //      are relocated in the target by that string and a fresh sig emitted — re-resolved per build,
    //      so the anchor survives byte-sig drift. Recovers anchors with no byte sig AND still-flagged
    //      ones that also carry an anchor. ----
    let arec = recover_by_string_anchor(prof, &cat, &images, &mut gd);
    if !arec.is_empty() {
        sig_ok += arec.len();
        sig_flag.retain(|n| !arec.contains(n));
        eprintln!(
            "  {} functions RECOVERED by string anchor: {}",
            arec.len(),
            arec.join(", ")
        );
    }

    // ---- offsets: chain from dated corpus reference builds to the target, then vote ----
    let (off_ok, off_flag) = derive_offsets(&vfuncs, &hops, &per_build, target_idx, &mut gd);

    let core = emitter.render(&gd);
    eprintln!(
        "gamedata for {}: {sig_ok} signatures + {off_ok} offsets emitted",
        label_of(target)
    );
    if !sig_flag.is_empty() {
        eprintln!(
            "  {} signatures FLAGGED (drifted): {}",
            sig_flag.len(),
            sig_flag.join(", ")
        );
    }
    if !off_flag.is_empty() {
        let rendered: Vec<String> = off_flag
            .iter()
            .map(|f| format!("{} ({})", f.name, f.detail))
            .collect();
        eprintln!(
            "  {} offsets FLAGGED (review): {}",
            off_flag.len(),
            rendered.join(", ")
        );
    }
    if !abi_drift.is_empty() {
        // A prototype-drift WARNING (sig still emitted): the target's ABI shape differs from the model's
        // consensus — the loader-hook seam the byte-sig can't see, caught forward without the corpus.
        eprintln!(
            "  {} signatures with ABI DRIFT vs model consensus (kept; review prototype): {}",
            abi_drift.len(),
            abi_drift
                .iter()
                .map(|f| format!("{} ({})", f.name, f.detail))
                .collect::<Vec<_>>()
                .join(", ")
        );
    }
    // The flag list: sig-drifted + offset-review + abi-drift, for the monolith's unresolved tier.
    let mut flagged: Vec<model::Flagged> = sig_flag
        .into_iter()
        .map(|n| {
            model::Flagged::new(
                n,
                model::FlagReason::SigDrifted,
                "no unique/recovered signature in target",
            )
        })
        .collect();
    flagged.extend(off_flag);
    flagged.extend(abi_drift);
    Ok(Derived {
        core,
        flagged,
        unverified: unverified.into_iter().collect(),
    })
}

/// One build's vtable data, shared by offset chaining and vtable-anchoring recovery.
#[derive(Default)]
struct BuildVtables {
    date: String,
    fps: VtableFps,                   // class -> slot fingerprints (for chaining)
    addrs: HashMap<String, Vec<u64>>, // class -> slot addresses (to read the located slot)
    resolved: HashMap<String, u64>,   // anchoring candidate name -> resolved address
    /// Libraries present in the build but unparseable. Empty on a healthy build; non-empty means this
    /// build's fingerprints are partial, which silently breaks anchor chains through it.
    unreadable: Vec<String>,
    /// True when no library loaded at all — the build contributes nothing but still occupies an index.
    empty: bool,
}

/// Recover functions by a distinctive string they reference (a `string-anchor` locator from a curated sig
/// list). Unlike byte sigs, the anchor string is stable across builds, so this re-resolves
/// per build: for each catalogue function with a string-anchor not already located by a byte sig, take
/// the *unique* function referencing that string in the target and emit a fresh sig there. A string
/// referenced by ≥2 functions is not distinctive enough → skipped (stays flagged). Server-only (all
/// curated anchors are server-lib). Returns the recovered names.
fn recover_by_string_anchor(
    prof: &GameProfile,
    cat: &[Func],
    target_images: &HashMap<String, CodeImage>,
    gd: &mut model::Gamedata,
) -> Vec<String> {
    let anchored: Vec<&Func> = cat
        .iter()
        .filter(|f| !string_anchors(f).is_empty())
        .collect();
    let Some(img) = target_images.get(prof.server_lib) else {
        return Vec::new();
    };
    if anchored.is_empty() {
        return Vec::new();
    }
    let idx = xref::XrefIndex::build(img);
    let mut recovered = Vec::new();
    for f in anchored {
        if gd.entries.contains_key(&f.name) {
            continue; // already located by a byte sig — the anchor is just a backup
        }
        for s in string_anchors(f) {
            if let [addr] = xref::funcs_using_string(img, &idx, s).as_slice()
                && let Some(sig) = emit::make_sig(img, *addr, CORE_SIG_CAP)
            {
                gd.set_signature(f.name.clone(), "server", sig);
                recovered.push(f.name.clone());
                break;
            }
        }
    }
    recovered
}

/// Chain each anchor's slot forward through the vtable-alignment hops `hv` to the target build, then take
/// a RECENCY-WEIGHTED vote on the resulting target slot: each anchor's vote is weighted by its build index,
/// so a recent anchor — which chains through fewer hops and reflects the current build's slot convention —
/// outweighs a distant one whose value may predate a layout shift or come from a differently-indexed tool.
/// An offset consistent across history is unaffected, since every anchor chains to the same slot.
/// Returns the winning (slot, confidence%), or None if no anchor chained through.
fn chain_and_vote(
    anchors: &[(usize, usize)],
    hv: &[Vec<Option<usize>>],
    target_idx: usize,
) -> Option<(usize, u64)> {
    let mut votes: HashMap<usize, u64> = HashMap::new();
    for &(a, off0) in anchors {
        let mut pos = Some(off0);
        for t in a..target_idx {
            pos = match hv.get(t) {
                Some(m) if !m.is_empty() => pos.and_then(|p| m.get(p).copied().flatten()),
                _ => None,
            };
            if pos.is_none() {
                break;
            }
        }
        if let Some(p) = pos {
            *votes.entry(p).or_default() += a as u64 + 1;
        }
    }
    let total: u64 = votes.values().sum();
    let (&slot, &agree) = votes
        .iter()
        .max_by_key(|e| (*e.1, std::cmp::Reverse(*e.0)))?;
    Some((slot, 100 * agree / total.max(1)))
}

/// Time-spread vtable anchors: walk reference builds `[0, target_idx)`, keeping at most one anchor per
/// 15-build window, where `slot_of(build)` yields the function's slot in that build. The two vtable paths
/// (offset chaining + drifted-sig recovery) differ only in HOW they read a build's slot, so this captures the
/// shared spacing loop; the closure supplies the per-build read.
fn spread_anchors(
    per_build: &[BuildVtables],
    target_idx: usize,
    mut slot_of: impl FnMut(&BuildVtables) -> Option<usize>,
) -> Vec<(usize, usize)> {
    let mut anchors: Vec<(usize, usize)> = Vec::new();
    for (i, bv) in per_build.iter().enumerate().take(target_idx) {
        if anchors.last().is_some_and(|&(li, _)| i - li < 15) {
            continue;
        }
        if let Some(off) = slot_of(bv) {
            anchors.push((i, off));
        }
    }
    anchors
}

/// Derive each catalogued vtable offset for the target build: chain its dated reference offsets forward
/// through the per-build slot-alignment hops, then take a recency-weighted vote. Only a high-confidence
/// chain is emitted; a weak one is flagged, and one with no usable reference at all is recorded as
/// `unresolved` rather than dropped.
fn derive_offsets(
    vfuncs: &[VtFunc],
    hops: &HashMap<&str, Vec<Vec<Option<usize>>>>,
    per_build: &[BuildVtables],
    target_idx: usize,
    gd: &mut model::Gamedata,
) -> (u32, Vec<model::Flagged>) {
    let mut off_ok = 0;
    let mut off_flag: Vec<model::Flagged> = Vec::new();
    for f in vfuncs {
        let hv = &hops[f.class.as_str()];
        let anchors = spread_anchors(per_build, target_idx, |bv| {
            let o0 = offset_at(&f.timeline, &bv.date)? as usize;
            (o0 < bv.fps.get(&f.class)?.len()).then_some(o0)
        });
        if anchors.is_empty() {
            // Nothing to chain from: the offset exceeds the vtable (a struct FIELD offset like
            // m_iHitGroup), the class has no locatable vtable, or — the common case for a fresh
            // contribution — the value is dated at or after the newest reference build. We deliberately
            // do NOT carry the last known value forward: CS2 inherits Source-1 class NAMES (CAK47,
            // CBaseButton) whose LAYOUTS differ, so an ecosystem date-src offset for a coincident name is
            // a different game's value and would ship a wrong offset. Drop it, but VISIBLY — record it so
            // `unresolved` is the complete catalogue picture the consumer contract claims, and a
            // contributor gets a reason instead of nothing.
            off_flag.push(model::Flagged::new(
                f.name.clone(),
                model::FlagReason::Unresolved,
                "no chainable anchor (offset exceeds the vtable, class has no vtable, or the value \
                 post-dates every reference build)",
            ));
            continue;
        }
        // Recency-weighted vote (see chain_and_vote): only a high-confidence chain reaches the shipped
        // gamedata. A low-confidence chain over a cross-game catalogue is usually a coincident class NAME
        // (CS2 shares CAK47/CBaseEntity with GoldSrc) whose offset chained by luck — flag, never emit.
        match chain_and_vote(&anchors, hv, target_idx) {
            Some((pred, conf)) if conf >= 80 => {
                gd.set_offset(f.name.clone(), pred as i64);
                off_ok += 1;
            }
            Some((pred, conf)) => {
                off_flag.push(model::Flagged::new(
                    f.name.clone(),
                    model::FlagReason::OffsetLowConf,
                    format!("offset {pred}, conf {conf}% — not emitted"),
                ));
            }
            None => off_flag.push(model::Flagged::new(
                f.name.clone(),
                model::FlagReason::Unresolved,
                "no vote",
            )),
        }
    }
    (off_ok, off_flag)
}

/// For each class, its vtable slot-fingerprints + slot addresses from the FIRST library (in the given
/// precedence order) whose primary vtable holds it — the deterministic, correctness-critical "first lib
/// holding the class wins" rule. Extracted so the model and binary paths of
/// `build_per_build_vtables` implement that precedence exactly once.
fn target_vtables<'a>(
    prof: &GameProfile,
    imgs: impl IntoIterator<Item = &'a CodeImage>,
    classes: &[String],
) -> (
    HashMap<String, Vec<Option<Vec<u32>>>>,
    HashMap<String, Vec<u64>>,
) {
    let imgs: Vec<&CodeImage> = imgs.into_iter().collect();
    let (mut fps, mut addrs) = (HashMap::new(), HashMap::new());
    for c in classes {
        for img in &imgs {
            if let Some(vt) = rtti::find_vtable(img, c, prof.max_vtable_slots) {
                fps.insert(c.clone(), slot_fingerprints(img, &vt.slots));
                addrs.insert(c.clone(), vt.slots);
                break;
            }
        }
    }
    (fps, addrs)
}

/// Per-build vtable data (fingerprints + slot addresses + resolved anchors) plus the target's index —
/// the input to both offset chaining and vtable-anchoring recovery. From the MODEL (only the target
/// binary is read; past builds are synthesised from the distilled slot counts + anchor slots so the
/// downstream is byte-identical) or by fingerprinting every reference build's binaries.
fn build_per_build_vtables(
    prof: &GameProfile,
    cmodel: Option<&CorpusModel>,
    ref_builds: Vec<(String, PathBuf)>,
    target: &Path,
    images: &HashMap<String, CodeImage>,
    classes: &[String],
    virt_funcs: &[(&Func, String)],
) -> (Vec<BuildVtables>, usize) {
    if let Some(m) = cmodel {
        // the one build we DO read — the target's real vtable slots + fingerprints. Iterate the
        // libraries in the profile's `libs` precedence order (the same order the binary path's
        // `load_build_images` uses), not `images.values()` (HashMap order): a class whose vtable appears
        // in more than one lib must resolve to the same image here as it does everywhere else, or offsets
        // go nondet. Precedence is authoritative — the first lib holding the class wins.
        let (tf, ta) = target_vtables(
            prof,
            prof.libs.iter().copied().filter_map(|f| images.get(f)),
            classes,
        );
        // The target's own (real, newest) date — offset_at(timeline, target_date) then returns the latest
        // timeline entry (the target is newer than every corpus build by the forward-only guard); the
        // target's position is tracked explicitly by `target_idx` below.
        let target_bv = BuildVtables {
            date: build_date(&label_of(target)),
            fps: tf,
            addrs: ta,
            resolved: HashMap::new(),
            // synthetic: reconstructed from the model, not loaded from images, so there is nothing to report
            ..Default::default()
        };
        // past builds: encode the model's distilled slot counts + anchor slots as a synthetic
        // BuildVtables so the downstream is byte-identical — fps length = slot_count (bounds checks),
        // addrs = slot indices, resolved = the anchor slot (so `position(resolved in addrs)` recovers it).
        let mut pb: Vec<BuildVtables> = m
            .builds
            .iter()
            .enumerate()
            .map(|(i, bm)| {
                let (mut fps, mut addrs) = (HashMap::new(), HashMap::new());
                for c in classes {
                    let n = m
                        .slot_counts
                        .get(c)
                        .and_then(|v| v.get(i))
                        .copied()
                        .unwrap_or(0);
                    if n > 0 {
                        fps.insert(c.clone(), vec![None; n]);
                        addrs.insert(c.clone(), (0..n as u64).collect());
                    }
                }
                let mut resolved = HashMap::new();
                for (f, _) in virt_funcs {
                    if let Some(slot) = m
                        .resolved_slot
                        .get(&f.name)
                        .and_then(|v| v.get(i))
                        .and_then(|s| s.get())
                    {
                        resolved.insert(f.name.clone(), slot as u64);
                    }
                }
                BuildVtables {
                    date: bm.date.clone(),
                    fps,
                    addrs,
                    resolved,
                    ..Default::default() // synthetic (model-derived): no images were loaded
                }
            })
            .collect();
        pb.push(target_bv);
        let ti = pb.len() - 1;
        (pb, ti)
    } else {
        let mut builds = ref_builds;
        // the target's real (newest) date — see the model branch above; `ti` is its explicit position marker.
        builds.push((build_date(&label_of(target)), target.to_path_buf()));
        let ti = builds.len() - 1;
        let pb = parallel_map(&builds, default_threads(None), |(date, dir)| {
            let imgs = load_build_images(prof, dir);
            let (fps, addrs) = target_vtables(prof, &imgs, classes);
            let mut resolved = HashMap::new();
            for (f, _) in virt_funcs {
                if let Some(a) = resolve_unique(f, &imgs) {
                    resolved.insert(f.name.clone(), a);
                }
            }
            BuildVtables {
                date: date.clone(),
                fps,
                addrs,
                resolved,
                ..Default::default() // built from already-loaded images; load reporting happens there
            }
        });
        (pb, ti)
    }
}

/// Vtable-slot alignment hops per class: the model ships the past consecutive-build hops and the final
/// hop (latest model build -> target) is computed from the target's real vtable fingerprints; the binary
/// path aligns every consecutive pair directly. Keys borrow `classes`.
fn build_hops<'a>(
    cmodel: Option<&CorpusModel>,
    per_build: &[BuildVtables],
    target_idx: usize,
    classes: &'a [String],
) -> HashMap<&'a str, Vec<Vec<Option<usize>>>> {
    classes
        .iter()
        .map(|c| {
            let hv = if let Some(m) = cmodel {
                let mut h: Vec<Vec<Option<usize>>> = m
                    .hops
                    .get(c)
                    .map(|v| v.iter().map(|inner| inner.to_options()).collect())
                    .unwrap_or_default();
                let fh = match (m.latest_vtable_fps.get(c), per_build[target_idx].fps.get(c)) {
                    (Some(a), Some(b)) => nw_align(a, b),
                    _ => Vec::new(),
                };
                h.push(fh);
                h
            } else {
                per_build
                    .windows(2)
                    .map(|w| match (w[0].fps.get(c), w[1].fps.get(c)) {
                        (Some(a), Some(b)) => nw_align(a, b),
                        _ => Vec::new(),
                    })
                    .collect()
            };
            (c.as_str(), hv)
        })
        .collect()
}

/// Recover drifted VIRTUAL functions by vtable slot — content-INDEPENDENT, so it works even when the
/// function changed so much no fingerprint matches. For each candidate: find builds where its signature
/// still resolved *to a slot of its class vtable* (that slot index is its offset, and requiring a real slot
/// auto-rejects decoy matches), chain the offset forward to the target through the order-preserving vtable
/// alignments, vote on the target slot, then read `vtable[offset]` in the target and emit a sig there.
/// Consumes the shared vtable pass. Returns the recovered names.
fn recover_virtual_sigs(
    prof: &GameProfile,
    virt_funcs: &[(&Func, String)],
    per_build: &[BuildVtables],
    hops: &HashMap<&str, Vec<Vec<Option<usize>>>>,
    target_idx: usize,
    target_images: &HashMap<String, CodeImage>,
    gd: &mut model::Gamedata,
) -> Vec<String> {
    let mut recovered = Vec::new();
    for (f, class) in virt_funcs {
        let Some(hv) = hops.get(class.as_str()) else {
            continue;
        };
        // anchors: time-spread builds where the sig resolved to a slot of this class's vtable
        let anchors = spread_anchors(per_build, target_idx, |bv| {
            let &addr = bv.resolved.get(&f.name)?;
            bv.addrs
                .get(class.as_str())?
                .iter()
                .position(|&a| a == addr)
        });
        if anchors.is_empty() {
            continue; // never resolved to a slot -> can't anchor
        }
        // chain each anchor to the target and recency-weight-vote the target slot
        let Some((off, conf)) = chain_and_vote(&anchors, hv, target_idx) else {
            continue;
        };
        if conf < 80 {
            continue; // low agreement -> leave flagged
        }
        // read the located slot in the target and emit a sig there. All CS2 .so link at vaddr 0, so
        // is_code(addr) is ambiguous across libraries — resolve the class's OWN vtable image
        // (find_vtable is class-specific) so make_sig reads the right bytes, deterministically, and
        // the emitted library is correct rather than a hardcoded "server".
        if let Some(&addr) = per_build[target_idx]
            .addrs
            .get(class.as_str())
            .and_then(|s| s.get(off))
        {
            let lib = prof.libs.iter().copied().find(|&l| {
                target_images
                    .get(l)
                    .and_then(|im| rtti::find_vtable(im, class, prof.max_vtable_slots))
                    .is_some_and(|vt| vt.slots.get(off) == Some(&addr))
            });
            if let Some(lib) = lib
                && let Some(sig) = emit::make_sig(&target_images[lib], addr, CORE_SIG_CAP)
            {
                gd.set_signature(f.name.clone(), lib_name_from_file(lib), sig);
                recovered.push(f.name.clone());
            }
        }
    }
    recovered
}

// ══════════════════════════════════════════════════════════════════════════════════════════════
// §8 · BACKFILL  (cross-build name/offset timelines)
// ══════════════════════════════════════════════════════════════════════════════════════════════

#[derive(Deserialize)]
struct BackfillItem {
    name: String,
    #[serde(default)]
    tier: String,
    #[serde(default)]
    anchor: Option<String>, // a distinctive string it references (drives the binary string-anchor half)
    #[serde(default)]
    class: Option<String>, // drives the model-hops half (with slot)
    #[serde(default)]
    slot: Option<i64>,
}

/// Chain an offset's vtable slot BACKWARD through a class's per-build-pair alignment hops, from its known
/// latest-build slot to every earlier build — the model-only history for a vtable method (no binaries).
/// `hops[i]` aligns build i -> i+1 (indexed by OLD slot); inverting each hop walks the slot back in time.
fn hops_timeline(
    hops: &[Vec<Option<usize>>],
    latest_slot: usize,
    latest_count: usize,
) -> Vec<Option<usize>> {
    let n = hops.len() + 1;
    let mut tl = vec![None; n];
    // Guard the tip like `vtable_slot_timeline`: a slot beyond the class's latest-build vtable is NOT located
    // (seed None), so an out-of-range slot doesn't count as "located at the newest build" and spuriously
    // inflate this method's coverage in the cov() tiebreak against the bounds-checked binary method.
    tl[n - 1] = (latest_slot < latest_count).then_some(latest_slot);
    for i in (0..hops.len()).rev() {
        if let Some(target) = tl[i + 1] {
            tl[i] = hops[i].iter().position(|&m| m == Some(target));
        }
    }
    tl
}

/// The class's per-build vtable-slot fingerprints (None where the class is absent in that build). Chained
/// to walk one latest-build slot back through time.
type VtSeq = Vec<Option<Vec<Option<Vec<u32>>>>>;

/// Track a class's vtable slot BACKWARD across the raw builds by chaining the order-preserving vtable
/// alignments (the `chain` logic, for one latest-build slot). Reliable where model `hops` aren't
/// available — adjacent-build alignment is near-perfect — and it covers ANY class, not just model-tracked
/// ones. This is the binary path for offset back-fill (the model-hops path is its binaries-free CI twin).
fn vtable_slot_timeline(seq: &VtSeq, tip_slot: usize) -> Vec<Option<usize>> {
    let n = seq.len();
    let mut tl = vec![None; n];
    if n == 0 {
        return tl;
    }
    tl[n - 1] = seq[n - 1]
        .as_ref()
        .filter(|f| tip_slot < f.len())
        .map(|_| tip_slot);
    for i in (0..n - 1).rev() {
        if let (Some(target), Some(from), Some(to)) =
            (tl[i + 1], seq[i].as_ref(), seq[i + 1].as_ref())
        {
            let map = nw_align(from, to); // from(build i) -> to(build i+1)
            tl[i] = map.iter().position(|&m| m == Some(target));
        }
    }
    tl
}

/// One raw build's back-fill signals: the unique anchor address per name + each needed class's vtable
/// slot fingerprints (for the vtable-chain method).
struct BuildBackfill {
    anchor: Vec<Option<u64>>,
    vt: HashMap<String, Vec<Option<Vec<u32>>>>,
}

/// Which timeline won for a back-filled name — a closed domain that also encodes how its values read: `Anchor`
/// timelines are ADDRESSES (formatted `{:#x}`), `Vtable`/`Hops` are vtable SLOTS (plain).
#[derive(Clone, Copy)]
enum BackfillMethod {
    Anchor,
    Vtable,
    Hops,
}

impl BackfillMethod {
    /// The report label.
    fn as_str(self) -> &'static str {
        match self {
            BackfillMethod::Anchor => "anchor",
            BackfillMethod::Vtable => "vtable",
            BackfillMethod::Hops => "hops",
        }
    }
    /// Timeline values are addresses (`{:#x}`), not vtable slots.
    fn is_address(self) -> bool {
        matches!(self, BackfillMethod::Anchor)
    }
}

/// Back-fill cross-build history for extrapolated (T3) names — the historical-enrichment + community-PR
/// ingestion tool. An OFFSET name rides the model's `hops` (no binaries — the vtable-method PR path); a
/// SIG / self-named name rides the string-anchor locator over the raw builds. Reports per-name history
/// depth + consistency-since-first-appearance: the measure of how many T3 names graduate to first-class
/// (deep + consistent history == high confidence, regardless of T3 origin). The compact per-name history
/// it emits is the persistent timeline that survives deleting the binaries.
pub fn backfill_cmd(
    prof: &GameProfile,
    corpus: Option<&Path>,
    corpus_model: Option<&Path>,
    lib: &str,
    names: &Path,
    threads: Option<usize>,
    out: Option<&Path>,
) -> Result<()> {
    // A backfill needs a build source to resolve anchors against; with neither it would run to completion
    // reporting every name as "not located" and exit 0 — a vacuous success. Fail loudly, as `produce` does.
    ensure!(
        corpus.is_some() || corpus_model.is_some(),
        "backfill needs --corpus <binaries> or --corpus-model <model.json> — with neither there is nothing \
         to resolve anchors against"
    );
    let items: Vec<BackfillItem> = serde_json::from_str(&std::fs::read_to_string(names)?)
        .context("parse backfill names json")?;
    let model: Option<CorpusModel> = match corpus_model {
        Some(p) => {
            Some(serde_json::from_str(&std::fs::read_to_string(p)?).context("parse corpus model")?)
        }
        None => None,
    };
    let model_dates: Vec<String> = model
        .as_ref()
        .map(|m| m.builds.iter().map(|b| b.date.clone()).collect())
        .unwrap_or_default();

    // The binary half: per raw build, locate each name by its anchor string AND fingerprint each needed
    // class's vtable (for the vtable-chain method — the reliable, all-classes offset back-fill).
    let builds = match corpus {
        Some(c) => find_builds(prof, c)?,
        None => Vec::new(),
    };
    let anchor_dates: Vec<String> = builds.iter().map(|d| build_date(&label_of(d))).collect();
    let vt_needed: BTreeSet<String> = items
        .iter()
        .filter(|it| it.slot.is_some_and(|s| s >= 0))
        .filter_map(|it| it.class.clone())
        .collect();
    let per_build: Vec<BuildBackfill> = if builds.is_empty() {
        Vec::new()
    } else {
        parallel_map(&builds, default_threads(threads), |dir| {
            let empty = BuildBackfill {
                anchor: vec![None; items.len()],
                vt: HashMap::new(),
            };
            let Some(img) = find_file(dir, lib, 8).and_then(|p| CodeImage::load(&p).ok()) else {
                return empty;
            };
            let idx = xref::XrefIndex::build(&img);
            let anchor = items
                .iter()
                .map(|it| {
                    it.anchor.as_deref().and_then(|a| {
                        match xref::funcs_using_string(&img, &idx, a).as_slice() {
                            [addr] => Some(*addr),
                            _ => None, // absent or ambiguous in this build
                        }
                    })
                })
                .collect();
            // one RTTI sweep, keep the needed primary vtables' slot fingerprints
            let mut vt = HashMap::new();
            if !vt_needed.is_empty() {
                for cv in rtti::enumerate_vtables(&img, prof.max_vtable_slots)
                    .into_iter()
                    .filter(|c| c.offset_to_top == 0 && vt_needed.contains(&c.name))
                {
                    let fps = slot_fingerprints(&img, &cv.slots);
                    vt.insert(cv.name, fps);
                }
            }
            BuildBackfill { anchor, vt }
        })
    };

    let mut report = Vec::new();
    let mut buckets = [0usize; 4]; // deep-first-class, recent-consistent, flaky, not-located
    let cov = |tl: &[Option<u64>]| tl.iter().filter(|x| x.is_some()).count();
    for (i, it) in items.iter().enumerate() {
        // Compute every applicable timeline, then KEEP the one with the most cross-build coverage — so a
        // self-named offset that anchors perfectly isn't regressed onto a weaker vtable chain, and a
        // dict-exact offset with no self-string is rescued by the vtable chain.
        let mut best: (BackfillMethod, &[String], Vec<Option<u64>>) = (
            BackfillMethod::Anchor,
            &anchor_dates,
            (0..per_build.len())
                .map(|b| per_build[b].anchor[i])
                .collect(),
        );
        // vtable-chain over binaries (offset, any class the RTTI sweep captured)
        if let (Some(cls), Some(slot)) = (it.class.as_deref(), it.slot.filter(|&s| s >= 0)) {
            if per_build.iter().any(|b| b.vt.contains_key(cls)) {
                let seq: VtSeq = per_build.iter().map(|b| b.vt.get(cls).cloned()).collect();
                let tl: Vec<Option<u64>> = vtable_slot_timeline(&seq, slot as usize)
                    .into_iter()
                    .map(|o| o.map(|s| s as u64))
                    .collect();
                if cov(&tl) > cov(&best.2) {
                    best = (BackfillMethod::Vtable, &anchor_dates, tl);
                }
            }
            // model hops (binaries-free) — the CI twin; used if it beats the binary methods
            if let Some(h) = model
                .as_ref()
                .and_then(|m| m.hops.get(cls))
                .filter(|h| !h.is_empty())
            {
                let h: Vec<Vec<Option<usize>>> = h.iter().map(|inner| inner.to_options()).collect();
                let latest_count = model
                    .as_ref()
                    .and_then(|m| m.slot_counts.get(cls))
                    .and_then(|v| v.last())
                    .copied()
                    .unwrap_or(0);
                let tl: Vec<Option<u64>> = hops_timeline(&h, slot as usize, latest_count)
                    .into_iter()
                    .map(|o| o.map(|s| s as u64))
                    .collect();
                if cov(&tl) > cov(&best.2) {
                    best = (BackfillMethod::Hops, &model_dates, tl);
                }
            }
        }
        let (method, dates, timeline) = best;
        let n = dates.len();
        let located: Vec<usize> = (0..n).filter(|&b| timeline[b].is_some()).collect();
        let count = located.len();
        let first = located.first().copied();
        let span = first.map_or(0, |f| n - f);
        let consistency = if span > 0 {
            count as f64 / span as f64
        } else {
            0.0
        };
        let bucket = if count == 0 {
            3
        } else if count * 2 >= n && consistency >= 0.9 {
            0 // deep, consistent history == first-class regardless of T3 origin
        } else if consistency >= 0.9 {
            1 // recent but consistent since it appeared
        } else {
            2 // flaky — the anchor drifts / isn't distinctive
        };
        buckets[bucket] += 1;
        // compact history: only located builds -> value (vtable slot for hops, address for anchor).
        let history: serde_json::Map<String, Value> = located
            .iter()
            .filter_map(|&b| {
                timeline[b].map(|v| {
                    // hops/vtable timelines are vtable SLOTS; anchor is an ADDRESS.
                    let val = if method.is_address() {
                        json!(format!("{v:#x}"))
                    } else {
                        json!(v)
                    };
                    (dates[b].clone(), val)
                })
            })
            .collect();
        report.push(json!({
            "name": it.name, "tier": it.tier, "method": method.as_str(),
            "builds_located": count, "of_builds": n,
            "first_build": first.map(|f| dates[f].clone()),
            "consistency": (consistency * 100.0).round() / 100.0,
            "history": history,
        }));
    }

    eprintln!(
        "backfill: {} names over {} corpus builds / {} model builds",
        items.len(),
        anchor_dates.len(),
        model_dates.len()
    );
    eprintln!(
        "  graduates: {} DEEP first-class (>=50% coverage, >=0.9 consistent), {} recent-but-consistent, {} flaky, {} not located",
        buckets[0], buckets[1], buckets[2], buckets[3]
    );
    let doc = json!({
        "names": items.len(), "corpus_builds": anchor_dates.len(),
        "model_builds": model_dates.len(), "report": report,
    });
    match out {
        Some(p) => {
            std::fs::write(p, serde_json::to_string(&doc)?)
                .with_context(|| format!("write {}", p.display()))?;
            eprintln!("  -> {}", p.display());
        }
        None => println!("{}", serde_json::to_string_pretty(&doc)?),
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    // ---- contribution intake gate (the fork-facing surface) ----
    #[test]
    fn is_iso_date_accepts_only_yyyy_mm_dd() {
        assert!(is_iso_date("2026-07-23"));
        assert!(is_iso_date("2015-01-01"));
        // wrong shape / separators / length / non-digit all rejected — these would otherwise slip past
        // intake and only fail deep in the timeline builder's `src[4]==b'-'` check.
        assert!(!is_iso_date("2026-7-3")); // unpadded
        assert!(!is_iso_date("07/23/2026")); // wrong separator
        assert!(!is_iso_date("2026-07-23T00:00")); // trailing time
        assert!(!is_iso_date("2026_07_23")); // underscores
        assert!(!is_iso_date("not-a-date")); // non-digit in digit slots
        assert!(!is_iso_date("")); // empty
    }

    // ---- hops identity-compression: an identity alignment is `Identity(len)` IN MEMORY and one integer on
    // the wire; a shift is `Explicit` and a full array. Both forms round-trip, and a pre-compression model
    // (identity stored as a full array) must load to the identical `Identity` Hop. ----
    #[test]
    fn hop_serde_compresses_identity_and_round_trips() {
        let s = |o: Option<usize>| Slot::from_opt(o);
        // identity/empty alignment -> the Identity variant -> a single integer on the wire.
        let ident = Hop::from_slots(vec![s(Some(0)), s(Some(1)), s(Some(2))]);
        assert!(matches!(ident, Hop::Identity(3)));
        assert_eq!(serde_json::to_string(&ident).unwrap(), "3");
        assert!(matches!(Hop::from_slots(vec![]), Hop::Identity(0)));
        assert_eq!(serde_json::to_string(&Hop::default()).unwrap(), "0");
        // any non-identity alignment (a drop, or a reorder) -> Explicit -> the explicit array, nulls for drops.
        let dropped = Hop::from_slots(vec![s(Some(0)), s(None), s(Some(2))]);
        assert!(matches!(dropped, Hop::Explicit(_)));
        assert_eq!(serde_json::to_string(&dropped).unwrap(), "[0,null,2]");
        // every form round-trips (Hop derives PartialEq; Slot has no Debug so assert! not assert_eq!).
        for slots in [
            vec![s(Some(0)), s(Some(1)), s(Some(2))], // identity
            vec![],                                   // empty
            vec![s(Some(0)), s(None), s(Some(2))],    // dropped slot
            vec![s(Some(1)), s(Some(0))],             // a real reorder = non-identity
        ] {
            let h = Hop::from_slots(slots);
            let j = serde_json::to_string(&h).unwrap();
            let back: Hop = serde_json::from_str(&j).unwrap();
            assert!(back == h, "round-trip {j}");
        }
        // backward-compat: an identity alignment written as a full array normalizes to the identical
        // Identity(3) the compact integer produces.
        let from_old_array: Hop = serde_json::from_str("[0,1,2]").unwrap();
        let from_new_int: Hop = serde_json::from_str("3").unwrap();
        assert!(from_old_array == from_new_int);
        assert!(matches!(from_old_array, Hop::Identity(3)));
    }

    // ---- the CS# text the live oracle re-parses round-trips back to the same locators ----
    // render_monolith_cssharp writes the string; run_live_oracle strips comments and reads it via
    // read_gamedata_str. A drift between writer and reader would silently change what gets validated, so
    // pin that the core (sig) + high_confidence (offset) locators survive the round trip byte-for-byte.
    #[test]
    fn cssharp_render_round_trips_through_read_gamedata_str() {
        use model::{Counts, Entry, MonoEntry, MonoMeta, Monolith, Provenance, Tier};
        let mono = Monolith {
            meta: MonoMeta {
                game_key: "csgo".into(),
                game: "Counter-Strike 2".into(),
                source_build: "test".into(),
                version: "t-1-0".into(),
                counts: Counts {
                    core: 1,
                    high_confidence: 1,
                    experimental: 0,
                    unresolved: 0,
                },
            },
            core: BTreeMap::from([(
                "CBaseEntity::TakeDamage".to_string(),
                MonoEntry {
                    locator: Entry::signature("server", "48 8B 05 ? ? ? ?"),
                    class: None,
                    provenance: Provenance {
                        source: Some("catalogue".into()),
                        ..Provenance::with_tier(Tier::Core)
                    },
                    validated: None,
                },
            )]),
            high_confidence: BTreeMap::from([(
                "CCSPlayerPawn::IsBot".to_string(),
                MonoEntry {
                    locator: Entry::offset(42),
                    class: None,
                    provenance: Provenance::with_tier(Tier::SelfNamed),
                    validated: None,
                },
            )]),
            experimental: BTreeMap::new(),
            unresolved: BTreeMap::new(),
        };
        let text = render::render_monolith_cssharp(&mono, model::TierSelect::HighConfidence);
        let gd = read_gamedata_str(&text).expect("rendered cssharp must parse back");
        let sig = render::entry_from_value(&gd["CBaseEntity::TakeDamage"]);
        assert_eq!(
            sig.signature.map(|s| s.linux).as_deref(),
            Some("48 8B 05 ? ? ? ?")
        );
        let off = render::entry_from_value(&gd["CCSPlayerPawn::IsBot"]);
        assert_eq!(off.offset, Some(42));
    }

    // ---- annotate_validation must NOT claim validation it didn't perform ("never lies"). A live-confirmed
    // entry -> Some(true); an entry the oracle KEPT-BUT-COULD-NOT-CHECK (verdict None) -> None (unverified,
    // NOT true); an entry the oracle dropped (absent from the verdict map) -> Some(false). ----
    #[test]
    fn annotate_validation_does_not_overclaim() {
        use model::{Counts, Entry, MonoEntry, MonoMeta, Monolith, Provenance, Tier};
        let entry = |loc: Entry, tier| MonoEntry {
            locator: loc,
            class: None,
            provenance: Provenance::with_tier(tier),
            validated: None,
        };
        let mut mono = Monolith {
            meta: MonoMeta {
                game_key: "csgo".into(),
                game: "Counter-Strike 2".into(),
                source_build: "test".into(),
                version: "t-1-0".into(),
                counts: Counts {
                    core: 2,
                    high_confidence: 1,
                    experimental: 0,
                    unresolved: 0,
                },
            },
            core: BTreeMap::from([
                (
                    "Live::Fn".to_string(),
                    entry(Entry::signature("server", "48 8B"), Tier::Core),
                ),
                (
                    "Unchecked::Fn".to_string(),
                    entry(Entry::signature("vscript", "55 48"), Tier::Core),
                ),
            ]),
            high_confidence: BTreeMap::from([(
                "Dropped::Fn".to_string(),
                entry(Entry::offset(42), Tier::SelfNamed),
            )]),
            experimental: BTreeMap::new(),
            unresolved: BTreeMap::new(),
        };
        // Live::Fn was live-confirmed; Unchecked::Fn was kept but its lib wasn't mapped (verdict None);
        // Dropped::Fn is ABSENT from the map (the oracle dropped it).
        let verdicts = BTreeMap::from([
            ("Live::Fn".to_string(), Some(true)),
            ("Unchecked::Fn".to_string(), None),
        ]);
        annotate_validation(&mut mono, &verdicts);
        assert_eq!(mono.core["Live::Fn"].validated, Some(true)); // confirmed -> true
        assert_eq!(mono.core["Unchecked::Fn"].validated, None); // kept-unverified -> NOT true, never claimed
        assert_eq!(mono.high_confidence["Dropped::Fn"].validated, Some(false)); // dropped -> false
    }
}
