soft3/tru/rs/focusing/focusing.rs

use std::collections::HashMap;

use crate::arithmetic::Fx;

use super::csr::{CsrBuilder, CsrMatrix};
use super::operators::{diffusion_step, heat_step, normalize_l1, springs_step};

// โ”€โ”€ Parameters โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

/// Tri-kernel parameters, fixed-point over the Goldilocks field.
pub struct FocusingParams {
    /// Diffusion teleport probability ฮฑ.
    pub alpha: Fx,
    /// Springs screening strength ฮผ.
    pub mu: Fx,
    /// Heat kernel time ฯ„.
    pub tau: Fx,
    /// Blend weight for diffusion (ฮป_d + ฮป_s + ฮป_h = 1).
    pub lambda_d: Fx,
    /// Blend weight for springs.
    pub lambda_s: Fx,
    /// Blend weight for heat.
    pub lambda_h: Fx,
    /// Convergence target ฮต: the iteration runs T(ฮต) = min t with ฮบ^t โ‰ค ฮต.
    pub epsilon: Fx,
    /// Hard cap on outer iterations (used when ฮบ is degenerate).
    pub iter_cap: usize,
}

impl Default for FocusingParams {
    fn default() -> Self {
        Self {
            alpha: Fx::from_ratio(15, 100),
            mu: Fx::ONE,
            tau: Fx::ONE,
            lambda_d: Fx::from_ratio(5, 10),
            lambda_s: Fx::from_ratio(3, 10),
            lambda_h: Fx::from_ratio(2, 10),
            epsilon: Fx::from_ratio(1, 1_000_000),
            iter_cap: 500,
        }
    }
}

// โ”€โ”€ Input link type โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

/// A single cyberlink contributing to the field.
#[derive(Clone)]
pub struct Link {
    /// Signing neuron ฮฝ โ€” the key karma is looked up under.
    pub neuron: [u8; 32],
    /// Source particle (32-byte hemera hash).
    pub from: [u8; 32],
    /// Target particle (32-byte hemera hash).
    pub to: [u8; 32],
    /// Stake amount (raw token units).
    pub amount: u128,
    /// Valence: +1 affirm, -1 challenge, 0 void/hold. Does not enter `A_eff`
    /// directly (focusing): its epistemic effect is mediated through `price`.
    pub valence: i8,
    /// Market believability `f(price(โ„“)) โˆˆ [0,1]`: the ICBS price mapped to an
    /// edge multiplier. `Fx::ONE` is market-neutral (fully believed); `ZERO`
    /// is fully doubted, structurally pruning the edge. Supplied by bbg.
    pub price: Fx,
}

impl Link {
    /// A stake-only link with a neutral market (`price = 1`) and its own neuron
    /// as author. Recovers the pre-karma/price behaviour exactly.
    pub fn stake(from: [u8; 32], to: [u8; 32], amount: u128) -> Self {
        Self {
            neuron: from,
            from,
            to,
            amount,
            valence: 1,
            price: Fx::ONE,
        }
    }
}

/// Per-neuron karma `ฮบ(ฮฝ)`: the non-transferable BTS trust multiplier read
/// from bbg each epoch. An unknown neuron scores the neutral baseline
/// `Fx::ONE` โ€” new identities are karma-light, never karma-negative.
#[derive(Default)]
pub struct Karma(HashMap<[u8; 32], Fx>);

impl Karma {
    /// No karma data โ€” every neuron scores the neutral baseline. Recovers the
    /// stake-only weighting.
    pub fn none() -> Self {
        Self(HashMap::new())
    }

    /// Karma from an explicit `(neuron, ฮบ)` table.
    pub fn from_pairs(pairs: impl IntoIterator<Item = ([u8; 32], Fx)>) -> Self {
        Self(pairs.into_iter().collect())
    }

    /// `ฮบ(ฮฝ)`, defaulting to the neutral baseline `Fx::ONE`.
    pub fn get(&self, neuron: &[u8; 32]) -> Fx {
        self.0.get(neuron).copied().unwrap_or(Fx::ONE)
    }
}

/// Per-neuron will: the broad-staking budget (locked span> ร— duration)
/// read from bbg each epoch. Unlike per-link conviction, will is auto-shared
/// across every link the neuron creates (attention). An unknown neuron has
/// zero will โ€” it contributes only its explicit conviction. Units match `amount`
/// (smallest token units), so it adds to the conviction stake before weighting.
#[derive(Default)]
pub struct Will(HashMap<[u8; 32], u128>);

impl Will {
    /// No will data โ€” every neuron's attention is its conviction alone. Recovers
    /// the conviction-only stake.
    pub fn none() -> Self {
        Self(HashMap::new())
    }

    /// Will from an explicit `(neuron, budget)` table.
    pub fn from_pairs(pairs: impl IntoIterator<Item = ([u8; 32], u128)>) -> Self {
        Self(pairs.into_iter().collect())
    }

    /// `will(ฮฝ)`, defaulting to zero.
    pub fn get(&self, neuron: &[u8; 32]) -> u128 {
        self.0.get(neuron).copied().unwrap_or(0)
    }
}

/// The per-epoch attention context read from bbg: the inputs to `A_eff`
/// beyond the raw links โ€” karma (the trust multiplier) and will (the
/// broad-staking budget). Bundled so new epoch inputs extend one type rather
/// than every `build` signature.
#[derive(Default)]
pub struct Context {
    pub karma: Karma,
    pub will: Will,
}

impl Context {
    /// Neutral context: no karma, no will. Recovers conviction-and-price-only
    /// weighting.
    pub fn none() -> Self {
        Self::default()
    }

    /// A context carrying karma but no will.
    pub fn with_karma(karma: Karma) -> Self {
        Self {
            karma,
            will: Will::none(),
        }
    }
}

/// The believability multiplier `f(price)`, clamped to `[0,1]`.
fn clamp01(x: Fx) -> Fx {
    if x < Fx::ZERO {
        Fx::ZERO
    } else if x > Fx::ONE {
        Fx::ONE
    } else {
        x
    }
}

// โ”€โ”€ FocusingGraph โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

/// Pre-built adjacency structures for one coupled tri-kernel computation.
///
/// Effective adjacency is the honesty-weighted sum (focusing, attention,
/// truth-scoring): `A_eff(p,q) = ฮฃ_{โ„“: pโ†’q} stake(โ„“)ยทฮบ(ฮฝ(โ„“))ยทf(price(โ„“))`,
/// where the per-link stake is a neuron's attention โ€” its explicit
/// conviction `amount` plus its share of broad will. `ฮบ(ฮฝ)` is the neuron's
/// karma, `f(price)` the ICBS believability. With `Context::none()` and
/// neutral `price = 1` this reduces to conviction-only stake-weighting.
pub struct FocusingGraph {
    n: usize,
    /// Particle hash at each node index.
    node_ids: Vec<[u8; 32]>,
    /// Column-stochastic transition: `transition[q][p] = A_eff(p,q)/out_strength(p)`.
    transition: CsrMatrix,
    /// True for nodes with no outgoing strength.
    dangling: Vec<bool>,
    /// Symmetric weights `A_sym(i,j) = A_eff(i,j) + A_eff(j,i)`.
    sym_weights: CsrMatrix,
    /// Weighted undirected degree `d(i) = ฮฃ_j A_sym(i,j)`.
    und_degree: Vec<Fx>,
    /// Stake-weighted teleport prior, normalized to sum 1.
    teleport: Vec<Fx>,
    /// Largest Laplacian eigenvalue โ€–Lโ€– (for the contraction ฮบ).
    lambda_max: Fx,
    /// Algebraic connectivity ฮปโ‚‚ (Fiedler value).
    lambda_2: Fx,
}

impl FocusingGraph {
    /// Build the honesty-weighted effective adjacency from cyberlinks and the
    /// epoch's attention context (karma + will). Self-loops, links with no
    /// effective stake (no conviction and no will), and links the market fully
    /// doubts (`f(price)ยทฮบ = 0`) are skipped โ€” a market-rejected link is
    /// structurally absent, not merely light.
    pub fn build(links: impl IntoIterator<Item = Link>, ctx: &Context) -> Self {
        // Self-loops carry no focus; drop them. Zero-stake links are kept here
        // and fall out below once their effective weight resolves to zero (a
        // pure-will link has amount 0 but nonzero attention).
        let kept: Vec<Link> = links.into_iter().filter(|l| l.from != l.to).collect();

        if kept.is_empty() {
            return Self::empty();
        }

        // Attention = conviction + will share. Will is auto-distributed equally
        // across every link a neuron authored (attention); count them first.
        let mut link_count: HashMap<[u8; 32], u128> = HashMap::new();
        for l in &kept {
            *link_count.entry(l.neuron).or_insert(0) += 1;
        }
        // Effective raw stake per link, in token units (integer floor share).
        let attention = |l: &Link| -> u128 {
            let share = ctx.will.get(&l.neuron) / link_count[&l.neuron];
            l.amount + share
        };

        // Stake weights are scale-invariant for ฯ†*; normalize by the largest so
        // they land in (0,1] (comparable to ฮผ=ฯ„=1) and never overflow the field.
        // Effective weight w = stakeยทฮบ(ฮฝ)ยทf(price): ฮบ(ฮฝ) โ‰ฅ 0 (default 1),
        // f(price) โˆˆ [0,1], so w โ‰ค stake โ‰ค 1 and overflow safety is preserved.
        let max_amount = kept.iter().map(&attention).max().unwrap_or(1).max(1);
        let raw: Vec<([u8; 32], [u8; 32], Fx)> = kept
            .iter()
            .map(|l| {
                let stake = Fx::ratio_u128(attention(l), max_amount);
                let w = stake * ctx.karma.get(&l.neuron) * clamp01(l.price);
                (l.from, l.to, w)
            })
            .filter(|&(_, _, w)| !w.is_zero())
            .collect();

        if raw.is_empty() {
            return Self::empty();
        }

        // Node indices, assigned by first appearance (deterministic).
        let mut node_ids: Vec<[u8; 32]> = Vec::new();
        let mut node_index: HashMap<[u8; 32], usize> = HashMap::new();
        for &(from, to, _) in &raw {
            for hash in [from, to] {
                if let std::collections::hash_map::Entry::Vacant(e) = node_index.entry(hash) {
                    e.insert(node_ids.len());
                    node_ids.push(hash);
                }
            }
        }
        let n = node_ids.len();

        // Directed A_eff and per-node stake mass (for the teleport prior).
        let mut dir_weight: HashMap<(usize, usize), Fx> = HashMap::new();
        let mut out_strength = vec![Fx::ZERO; n];
        let mut node_stake = vec![Fx::ZERO; n];
        for &(from, to, w) in &raw {
            let (fi, ti) = (node_index[&from], node_index[&to]);
            let e = dir_weight.entry((fi, ti)).or_insert(Fx::ZERO);
            *e = *e + w;
            out_strength[fi] = out_strength[fi] + w;
            node_stake[fi] = node_stake[fi] + w;
            node_stake[ti] = node_stake[ti] + w;
        }

        // Transition (col-stochastic) and symmetric weights + degree.
        let mut trans = CsrBuilder::new(n);
        let mut sym = CsrBuilder::new(n);
        let mut und_degree = vec![Fx::ZERO; n];
        for (&(fi, ti), &w) in &dir_weight {
            trans.add(ti, fi, w.div(out_strength[fi])); // T[to][from]
            sym.add(fi, ti, w);
            sym.add(ti, fi, w);
            und_degree[fi] = und_degree[fi] + w;
            und_degree[ti] = und_degree[ti] + w;
        }
        let dangling: Vec<bool> = (0..n).map(|i| out_strength[i].is_zero()).collect();

        let teleport = normalize_l1(&node_stake);
        let sym_weights = sym.build();

        // Spectrum for the contraction ฮบ (graph-only; params fold in at compute).
        let lambda_max = super::spectral::lambda_max(&sym_weights, &und_degree, n, 60);
        let lambda_2 = super::spectral::lambda_2(&sym_weights, &und_degree, n, lambda_max, 120);

        Self {
            n,
            node_ids,
            transition: trans.build(),
            dangling,
            sym_weights,
            und_degree,
            teleport,
            lambda_max,
            lambda_2,
        }
    }

    fn empty() -> Self {
        Self {
            n: 0,
            node_ids: vec![],
            transition: CsrBuilder::new(0).build(),
            dangling: vec![],
            sym_weights: CsrBuilder::new(0).build(),
            und_degree: vec![],
            teleport: vec![],
            lambda_max: Fx::ZERO,
            lambda_2: Fx::ZERO,
        }
    }

    pub fn n(&self) -> usize {
        self.n
    }

    pub fn node_id(&self, idx: usize) -> &[u8; 32] {
        &self.node_ids[idx]
    }

    pub fn node_ids(&self) -> &[[u8; 32]] {
        &self.node_ids
    }

    /// Largest Laplacian eigenvalue โ€–Lโ€–.
    pub fn lambda_max(&self) -> Fx {
        self.lambda_max
    }

    /// Algebraic connectivity ฮปโ‚‚ (Fiedler value).
    pub fn lambda_2(&self) -> Fx {
        self.lambda_2
    }

    /// The spectral embedding mir reads: each particle's coordinate in the
    /// space of the Laplacian's `k` lowest nontrivial eigenvectors (Fiedler
    /// first). `iters` is the subspace-iteration count. Structurally similar
    /// particles receive nearby coordinates.
    pub fn embedding(&self, k: usize, iters: usize) -> SpectralEmbedding {
        let (vectors, eigenvalues) = super::spectral::spectral_vectors(
            &self.sym_weights,
            &self.und_degree,
            self.n,
            self.lambda_max,
            k,
            iters,
        );
        let kk = vectors.len();
        // Transpose the k eigenvectors into per-particle coordinate rows.
        let coords: Vec<Vec<Fx>> = (0..self.n)
            .map(|i| (0..kk).map(|c| vectors[c][i]).collect())
            .collect();
        SpectralEmbedding {
            k: kk,
            coords,
            eigenvalues,
        }
    }
}

/// The spectral embedding focusing emits to mir every epoch: each
/// particle's position in the low-frequency Laplacian eigenspace.
pub struct SpectralEmbedding {
    /// Coordinates per particle (= number of eigenvectors extracted).
    pub k: usize,
    /// `coords[i]` is the k-vector for particle [`FocusingGraph::node_id`]`(i)`.
    pub coords: Vec<Vec<Fx>>,
    /// The k Laplacian eigenvalues, ascending (ฮปโ‚‚ first).
    pub eigenvalues: Vec<Fx>,
}

// โ”€โ”€ Output โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

/// Result of one tri-kernel computation.
pub struct FocusingResult {
    /// ฯ†* focus distribution (fixed-point), indexed as [`FocusingGraph::node_ids`].
    pub focus: Vec<Fx>,
    /// Syntropy J(ฯ†*) = D_KL(ฯ†* โ€– u), emitted alongside ฯ†* every epoch.
    pub syntropy: Fx,
    /// Diffusion component of the final step.
    pub diffusion: Vec<Fx>,
    /// Springs component of the final step.
    pub springs: Vec<Fx>,
    /// Heat component of the final step.
    pub heat: Vec<Fx>,
}

// โ”€โ”€ Composite: one coupled iteration to the fixed point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

/// The composite contraction coefficient ฮบ for this graph and params.
pub fn contraction(g: &FocusingGraph, p: &FocusingParams) -> Fx {
    super::spectral::kappa(p, g.lambda_max, g.lambda_2)
}

/// The step count T(ฮต) the coupled iteration runs: the smallest T with ฮบ^T โ‰ค ฮต
/// (tri-kernel ยง2.2), capped by `p.iter_cap`.
pub fn derived_steps(g: &FocusingGraph, p: &FocusingParams) -> usize {
    super::spectral::steps_for(contraction(g, p), p.epsilon, p.iter_cap)
}

/// Compute ฯ†* by iterating the coupled tri-kernel: each step applies D, S, and
/// H_ฯ„ to the same current ฯ†, blends `ฮป_dยทD + ฮป_sยทS + ฮป_hยทH`, normalizes onto
/// the simplex, and feeds ฯ† back โ€” for a fixed T(ฮต) steps derived from the
/// contraction ฮบ. Fixed-point throughout, so two runs are bit-identical.
pub fn compute_focusing(g: &FocusingGraph, p: &FocusingParams) -> FocusingResult {
    iterate(g, p, derived_steps(g, p))
}

/// The coupled iteration run for an explicit step count.
pub fn iterate(g: &FocusingGraph, p: &FocusingParams, steps: usize) -> FocusingResult {
    if g.n == 0 {
        return FocusingResult {
            focus: vec![],
            syntropy: Fx::ZERO,
            diffusion: vec![],
            springs: vec![],
            heat: vec![],
        };
    }
    let n = g.n;
    let uniform = Fx::from_ratio(1, n as i64);
    let x0 = vec![uniform; n];

    let mut phi = vec![uniform; n];
    let mut diffusion = vec![Fx::ZERO; n];
    let mut springs = vec![Fx::ZERO; n];
    let mut heat = vec![Fx::ZERO; n];

    for _ in 0..steps {
        diffusion = diffusion_step(&phi, &g.transition, &g.dangling, &g.teleport, p.alpha);
        springs = springs_step(&phi, &g.sym_weights, &g.und_degree, p.mu, &x0);
        heat = heat_step(&phi, &g.sym_weights, &g.und_degree, g.lambda_max, p.tau);

        let blend: Vec<Fx> = (0..n)
            .map(|i| p.lambda_d * diffusion[i] + p.lambda_s * springs[i] + p.lambda_h * heat[i])
            .collect();
        phi = normalize_l1(&blend);
    }

    let syntropy = super::measures::syntropy(&phi);
    FocusingResult {
        focus: phi,
        syntropy,
        diffusion,
        springs,
        heat,
    }
}

// โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

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

    fn hash(b: u8) -> [u8; 32] {
        let mut h = [0u8; 32];
        h[0] = b;
        h
    }

    fn link(from: u8, to: u8, amount: u128) -> Link {
        Link::stake(hash(from), hash(to), amount)
    }

    fn node_idx(g: &FocusingGraph, b: u8) -> usize {
        g.node_ids()
            .iter()
            .position(|h| h[0] == b && h[1..] == [0u8; 31][..])
            .unwrap()
    }

    #[test]
    fn focus_sums_to_one() {
        let links = vec![link(1, 2, 100), link(2, 3, 50), link(3, 1, 200)];
        let g = FocusingGraph::build(links, &Context::none());
        let r = compute_focusing(&g, &FocusingParams::default());
        let total: f64 = r.focus.iter().map(|x| x.to_f64()).sum();
        assert!((total - 1.0).abs() < 1e-6, "focus sums to {total}");
        assert!(r.focus.iter().all(|x| *x > Fx::ZERO), "all focus positive");
    }

    #[test]
    fn deterministic_bit_identical() {
        let mk = || {
            vec![
                link(1, 2, 100),
                link(2, 3, 50),
                link(3, 1, 200),
                link(4, 1, 300),
            ]
        };
        let a = compute_focusing(
            &FocusingGraph::build(mk(), &Context::none()),
            &FocusingParams::default(),
        );
        let b = compute_focusing(
            &FocusingGraph::build(mk(), &Context::none()),
            &FocusingParams::default(),
        );
        assert!(
            a.focus
                .iter()
                .zip(&b.focus)
                .all(|(x, y)| x.raw() == y.raw()),
            "ฯ†* not bit-identical across runs"
        );
    }

    #[test]
    fn contraction_below_one() {
        let links = vec![
            link(1, 2, 100),
            link(2, 3, 100),
            link(3, 1, 100),
            link(4, 1, 100),
        ];
        let g = FocusingGraph::build(links, &Context::none());
        let p = FocusingParams::default();
        let kappa = contraction(&g, &p);
        assert!(kappa < Fx::ONE, "ฮบ = {} must be < 1", kappa.to_f64());
        assert!(kappa > Fx::ZERO, "ฮบ = {} must be > 0", kappa.to_f64());
        // ฮป_max is real (positive) for a nonempty graph.
        assert!(g.lambda_max() > Fx::ZERO, "ฮป_max should be positive");
    }

    #[test]
    fn derived_steps_reach_the_fixed_point() {
        let links = vec![
            link(1, 2, 100),
            link(2, 3, 100),
            link(3, 1, 100),
            link(4, 1, 100),
        ];
        let g = FocusingGraph::build(links, &Context::none());
        let p = FocusingParams::default();
        let t = derived_steps(&g, &p);
        assert!(
            t > 0 && t < p.iter_cap,
            "derived T = {t} should be a real step count"
        );
        // Past T(ฮต) the iterate barely moves โ€” the fixed point is reached.
        let at_t = iterate(&g, &p, t);
        let past_t = iterate(&g, &p, t + 20);
        let drift: f64 = at_t
            .focus
            .iter()
            .zip(&past_t.focus)
            .map(|(a, b)| (a.to_f64() - b.to_f64()).abs())
            .sum();
        assert!(drift < 1e-4, "drift past T = {drift}, not converged");
    }

    #[test]
    fn heat_conserves_mass_and_smooths() {
        let links = vec![
            link(1, 2, 100),
            link(2, 3, 100),
            link(3, 1, 100),
            link(4, 1, 100),
        ];
        let g = FocusingGraph::build(links, &Context::none());
        let n = g.n();
        let i1 = node_idx(&g, 1);
        // A delta spike at node 1.
        let mut v = vec![Fx::ZERO; n];
        v[i1] = Fx::ONE;
        let h = heat_step(&v, &g.sym_weights, &g.und_degree, g.lambda_max, Fx::ONE);
        // exp(โˆ’ฯ„L) conserves mass (Lยท1 = 0), up to series truncation.
        let mass: f64 = h.iter().map(|x| x.to_f64()).sum();
        assert!(
            (mass - 1.0).abs() < 1e-2,
            "heat should conserve mass, got {mass}"
        );
        // and it diffuses the spike off the peak while staying ~positive.
        assert!(h[i1].to_f64() < 1.0, "heat should spread mass off node 1");
        assert!(
            h.iter().all(|x| x.to_f64() > -1e-3),
            "heat stays approximately positive"
        );
    }

    #[test]
    fn high_in_stake_ranks_higher() {
        let links = vec![
            link(1, 2, 100),
            link(2, 3, 100),
            link(3, 1, 100),
            link(4, 1, 1000),
        ];
        let g = FocusingGraph::build(links, &Context::none());
        let r = compute_focusing(&g, &FocusingParams::default());
        let (i1, i3) = (node_idx(&g, 1), node_idx(&g, 3));
        assert!(
            r.focus[i1] > r.focus[i3],
            "high-in-stake node 1 should outrank node 3"
        );
    }

    #[test]
    fn well_linked_node_ranks_higher() {
        let links = vec![
            link(1, 2, 100),
            link(2, 3, 100),
            link(3, 1, 100),
            link(4, 1, 100),
        ];
        let g = FocusingGraph::build(links, &Context::none());
        let r = compute_focusing(&g, &FocusingParams::default());
        let (i1, i2) = (node_idx(&g, 1), node_idx(&g, 2));
        assert!(
            r.focus[i1] > r.focus[i2],
            "well-linked node 1 should outrank node 2"
        );
    }

    #[test]
    fn large_stakes_are_scale_invariant() {
        // 10^15-scale stakes must not overflow and must give the SAME ฯ†* as the
        // proportionally-smaller graph (weights are scale-invariant).
        let big = 1_000_000_000_000_000u128;
        let large = vec![
            Link::stake(hash(1), hash(2), big),
            Link::stake(hash(2), hash(3), big / 2),
            Link::stake(hash(4), hash(1), big * 3),
        ];
        let small = vec![link(1, 2, 1000), link(2, 3, 500), link(4, 1, 3000)];
        let rl = compute_focusing(
            &FocusingGraph::build(large, &Context::none()),
            &FocusingParams::default(),
        );
        let rs = compute_focusing(
            &FocusingGraph::build(small, &Context::none()),
            &FocusingParams::default(),
        );
        let total: f64 = rl.focus.iter().map(|x| x.to_f64()).sum();
        assert!((total - 1.0).abs() < 1e-6, "large-stake ฯ†* sums to {total}");
        assert!(rl.focus.iter().all(|x| *x > Fx::ZERO));
        assert!(
            rl.focus
                .iter()
                .zip(&rs.focus)
                .all(|(a, b)| a.raw() == b.raw()),
            "ฯ†* not scale-invariant"
        );
    }

    #[test]
    fn empty_graph() {
        let g = FocusingGraph::build(vec![], &Context::none());
        assert_eq!(g.n(), 0);
        assert!(compute_focusing(&g, &FocusingParams::default())
            .focus
            .is_empty());
    }

    #[test]
    fn self_loops_excluded() {
        let g = FocusingGraph::build(vec![Link::stake(hash(1), hash(1), 100)], &Context::none());
        assert_eq!(g.n(), 0);
    }

    #[test]
    fn zero_amount_excluded() {
        let g = FocusingGraph::build(vec![link(1, 2, 0), link(2, 3, 50)], &Context::none());
        assert_eq!(g.n(), 2);
    }

    // โ”€โ”€ honesty weighting: karma and market price โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    // A voter V(=5) splits its focus between two otherwise-symmetric
    // candidates A(=1) and B(=2), each of which feeds a sink C(=3) that returns
    // to V. V has two out-edges, so re-weighting one genuinely redirects its
    // diffusion mass โ€” the regime where honesty weighting shows up in the rank.
    fn voter_graph(w_a: (/*neuron*/ [u8; 32], /*price*/ Fx), w_b: ([u8; 32], Fx)) -> Vec<Link> {
        vec![
            Link {
                neuron: w_a.0,
                from: hash(5),
                to: hash(1),
                amount: 100,
                valence: 1,
                price: w_a.1,
            },
            Link {
                neuron: w_b.0,
                from: hash(5),
                to: hash(2),
                amount: 100,
                valence: 1,
                price: w_b.1,
            },
            link(1, 3, 100), // A โ†’ C
            link(2, 3, 100), // B โ†’ C
            link(3, 5, 100), // C โ†’ V
        ]
    }

    #[test]
    fn karma_amplifies_the_link_it_weights() {
        let (sign_a, sign_b) = (hash(7), hash(8));
        let mk = || voter_graph((sign_a, Fx::ONE), (sign_b, Fx::ONE));

        // Neutral: A and B are symmetric, so they share focus exactly.
        let g0 = FocusingGraph::build(mk(), &Context::none());
        let r0 = compute_focusing(&g0, &FocusingParams::default());
        let gap = (r0.focus[node_idx(&g0, 1)].to_f64() - r0.focus[node_idx(&g0, 2)].to_f64()).abs();
        assert!(
            gap < 1e-6,
            "A and B must be symmetric under no karma (ฮ”={gap})"
        );

        // ฮบ=3 on A's signer sends more of V's focus to A: A outranks B.
        let g1 = FocusingGraph::build(
            mk(),
            &Context::with_karma(Karma::from_pairs([(sign_a, Fx::from_int(3))])),
        );
        let r1 = compute_focusing(&g1, &FocusingParams::default());
        assert!(
            r1.focus[node_idx(&g1, 1)] > r1.focus[node_idx(&g1, 2)],
            "ฮบ=3 on the A-link should make A outrank the symmetric B"
        );
    }

    #[test]
    fn market_price_scales_the_link_it_weights() {
        let (na, nb) = (hash(7), hash(8));
        // f(price)=0.5 on the B-link, full belief on A: A outranks B.
        let g = FocusingGraph::build(
            voter_graph((na, Fx::ONE), (nb, Fx::from_ratio(1, 2))),
            &Context::none(),
        );
        let r = compute_focusing(&g, &FocusingParams::default());
        assert!(
            r.focus[node_idx(&g, 1)] > r.focus[node_idx(&g, 2)],
            "a fully-believed link should outrank a half-believed one"
        );
    }

    #[test]
    fn market_doubt_prunes_the_edge() {
        // f(price) = 0 (fully doubted): the edge is structurally absent, so its
        // endpoints never enter the graph.
        let doubted = Link {
            neuron: hash(9),
            from: hash(7),
            to: hash(8),
            amount: 100,
            valence: 1,
            price: Fx::ZERO,
        };
        let g = FocusingGraph::build(vec![doubted, link(1, 2, 100)], &Context::none());
        assert_eq!(g.n(), 2, "a fully-doubted edge must create no nodes");
        assert!(
            !g.node_ids().iter().any(|h| h[0] == 7 || h[0] == 8),
            "endpoints of the doubted edge must be absent"
        );
    }

    // โ”€โ”€ attention: broad will vs per-link conviction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    #[test]
    fn will_adds_broad_attention_to_a_neurons_links() {
        // sign_a authors only the Vโ†’A spoke, so its whole will lands there:
        // effective stake 100 + will โ‰ซ B's 100, and A outranks the symmetric B.
        let (sign_a, sign_b) = (hash(7), hash(8));
        let mk = || voter_graph((sign_a, Fx::ONE), (sign_b, Fx::ONE));

        let g0 = FocusingGraph::build(mk(), &Context::none());
        let r0 = compute_focusing(&g0, &FocusingParams::default());
        let gap = (r0.focus[node_idx(&g0, 1)].to_f64() - r0.focus[node_idx(&g0, 2)].to_f64()).abs();
        assert!(gap < 1e-6, "A and B symmetric under no will (ฮ”={gap})");

        let ctx = Context {
            karma: Karma::none(),
            will: Will::from_pairs([(sign_a, 300)]),
        };
        let g1 = FocusingGraph::build(mk(), &ctx);
        let r1 = compute_focusing(&g1, &FocusingParams::default());
        assert!(
            r1.focus[node_idx(&g1, 1)] > r1.focus[node_idx(&g1, 2)],
            "broad will on A's author should make A outrank the symmetric B"
        );
    }

    #[test]
    fn will_is_shared_equally_across_a_neurons_links() {
        // One neuron authors both spokes Vโ†’A and Vโ†’B. Its will splits equally,
        // so A and B stay symmetric โ€” were it not shared, one would dominate.
        let s = hash(9);
        let spokes = || {
            vec![
                Link {
                    neuron: s,
                    from: hash(5),
                    to: hash(1),
                    amount: 100,
                    valence: 1,
                    price: Fx::ONE,
                },
                Link {
                    neuron: s,
                    from: hash(5),
                    to: hash(2),
                    amount: 100,
                    valence: 1,
                    price: Fx::ONE,
                },
                link(1, 3, 100),
                link(2, 3, 100),
                link(3, 5, 100),
            ]
        };
        let ctx = Context {
            karma: Karma::none(),
            will: Will::from_pairs([(s, 1000)]),
        };

        let g_will = FocusingGraph::build(spokes(), &ctx);
        let r_will = compute_focusing(&g_will, &FocusingParams::default());
        let (a, b) = (
            r_will.focus[node_idx(&g_will, 1)],
            r_will.focus[node_idx(&g_will, 2)],
        );
        assert!(
            (a.to_f64() - b.to_f64()).abs() < 1e-6,
            "equal will split must keep A,B symmetric"
        );

        // And the will genuinely acted: V's spokes now carry more of its focus,
        // so the sink C (node 3) draws more than in the will-free graph.
        let g0 = FocusingGraph::build(spokes(), &Context::none());
        let r0 = compute_focusing(&g0, &FocusingParams::default());
        let c_will = r_will.focus[node_idx(&g_will, 3)].to_f64();
        let c_none = r0.focus[node_idx(&g0, 3)].to_f64();
        assert!(
            (c_will - c_none).abs() > 1e-6,
            "will should change the distribution, not vanish"
        );
    }

    // โ”€โ”€ spectral embedding (positions for mir) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    fn sgn(x: Fx) -> i32 {
        if x > Fx::ZERO {
            1
        } else if x < Fx::ZERO {
            -1
        } else {
            0
        }
    }

    /// Undirected edge โ†’ a pair of unit-stake directed links.
    fn undirected(a: u8, b: u8) -> [Link; 2] {
        [
            Link::stake(hash(a), hash(b), 100),
            Link::stake(hash(b), hash(a), 100),
        ]
    }

    fn barbell() -> FocusingGraph {
        // Two triangles {1,2,3} and {4,5,6} joined by the single bridge 3โ€“4.
        let mut links = Vec::new();
        for (a, b) in [(1, 2), (2, 3), (1, 3), (4, 5), (5, 6), (4, 6), (3, 4)] {
            links.extend(undirected(a, b));
        }
        FocusingGraph::build(links, &Context::none())
    }

    #[test]
    fn fiedler_vector_separates_the_two_communities() {
        let g = barbell();
        let emb = g.embedding(1, 300);
        assert_eq!(emb.k, 1);
        let coord = |b: u8| emb.coords[node_idx(&g, b)][0];
        // All of cluster A share one sign; all of cluster B the opposite.
        let sa = sgn(coord(1));
        assert!(sa != 0, "Fiedler entry should not be zero");
        for b in [2, 3] {
            assert_eq!(sgn(coord(b)), sa, "node {b} must share cluster A's sign");
        }
        for b in [4, 5, 6] {
            assert_eq!(
                sgn(coord(b)),
                -sa,
                "node {b} must take cluster B's opposite sign"
            );
        }
    }

    #[test]
    fn embedding_is_centered_and_orthogonal() {
        let g = barbell();
        let emb = g.embedding(2, 300);
        assert_eq!(emb.k, 2);
        // Each eigenvector is centered (orthogonal to the constant vector).
        for c in 0..2 {
            let sum: f64 = (0..g.n()).map(|i| emb.coords[i][c].to_f64()).sum();
            assert!(sum.abs() < 1e-3, "eigenvector {c} not centered (ฮฃ={sum})");
        }
        // The two eigenvectors are mutually orthogonal.
        let d: f64 = (0..g.n())
            .map(|i| emb.coords[i][0].to_f64() * emb.coords[i][1].to_f64())
            .sum();
        assert!(d.abs() < 1e-2, "eigenvectors not orthogonal (โŸจvโ‚€,vโ‚โŸฉ={d})");
        // Eigenvalues ascending and nonnegative (ฮปโ‚‚ โ‰ค ฮปโ‚ƒ).
        assert!(emb.eigenvalues[0].to_f64() >= -1e-6);
        assert!(
            emb.eigenvalues[1] >= emb.eigenvalues[0],
            "eigenvalues must be ascending"
        );
    }

    #[test]
    fn embedding_k_clamps_to_n_minus_one() {
        let g = barbell(); // 6 nodes
        let emb = g.embedding(100, 50);
        assert_eq!(
            emb.k, 5,
            "cannot extract more than nโˆ’1 nontrivial eigenvectors"
        );
        assert!(emb.coords.iter().all(|row| row.len() == 5));
    }
}

Graph