//! CRDT-aware signal state for per-node decay accumulation.
//!
//! `CrdtSignalState` enables deterministic reconciliation after network
//! partitions by tracking per-node contributions separately. On merge,
//! each node's contribution is preserved without double-counting.

use std::collections::HashMap;

use super::pn_counter::PNCounter;
use crate::{replication::shard::ShardId, signals::decay::forward_decay_step};

/// CRDT-aware signal state for a single (entity, `signal_type`) pair.
///
/// Extends decay scoring with per-node accounting that enables correct
/// merge after partitioned writes. Each node maintains its own running
/// decay accumulator; the global score is the sum of all per-node
/// accumulators (different nodes process disjoint WAL segments, so their
/// contributions are distinct and additive at read time).
///
/// # Why a per-node last-writer-wins register, not addition
///
/// Exponential decay: `S(t) = sum_i(w_i * exp(-lambda * (t - t_i)))`.
/// Within a single node, [`on_signal`](Self::on_signal) folds every event
/// for that node into one running accumulator that is *monotonic in event
/// time* -- the value at `last_update_ns = T` already encodes the full
/// contribution of that node up to `T`. Two snapshots of the *same* node are
/// therefore redundant views of one accumulator, and the snapshot with the
/// larger `last_update_ns` is the more complete one.
///
/// Merge keeps, per node, the accumulator with the larger `last_update_ns`
/// (a last-writer-wins register versioned by that timestamp; ties break on
/// the larger score for determinism). Summing per-node accumulators on merge
/// -- the previous behaviour -- double-counts a node whenever the same
/// segment is replayed, because each replay re-adds that node's already-folded
/// running total. The replication path can retry a segment, so addition is
/// unsafe; LWW-per-node is idempotent and yields the correct global sum across
/// distinct nodes at [`decay_score`](Self::decay_score) time.
///
/// # Properties (a true state-based CRDT)
///
/// - **Commutative:** `merge(A, B) == merge(B, A)`
/// - **Associative:** `merge(A, merge(B, C)) == merge(merge(A, B), C)`
/// - **Idempotent:** `merge(A, A) == A` and `merge(merge(A, B), B) == merge(A, B)`,
///   so replaying the same segment twice yields the same state as once -- merge
///   no longer depends on at-most-once delivery from the replication transport.
///
/// # Cross-map invariant (load-bearing)
///
/// `node_decay_scores` and `node_last_update_ns` form a single logical
/// last-writer-wins register split across two maps: a node's `(score, ts)` pair
/// is the unit of state, so the two maps MUST stay **key-paired** -- every node
/// present in one is present in the other. [`decay_score`](Self::decay_score)
/// reads the score from one map and its timestamp from the other by the same
/// key; a missing partner would silently apply the wrong decay factor (or, via
/// the `unwrap_or` fallback, decay against the query time as if no decay was
/// owed). Every mutator preserves the pairing: [`new`](Self::new) starts both
/// empty, [`from_node_contribution`](Self::from_node_contribution) inserts both,
/// [`on_signal`](Self::on_signal) seeds both via `entry`, and
/// [`merge`](Self::merge) inserts both together whenever LWW picks the other
/// node. Any future mutator that touches one map MUST touch the other.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CrdtSignalState {
    /// Per-node running decay score.
    ///
    /// Each node contributes its own partial decay sum.
    /// Global score = sum of all node contributions, decayed to query time.
    ///
    /// Key-paired with `node_last_update_ns`: see the type-level "Cross-map
    /// invariant" note. The two maps are one logical LWW register and MUST be
    /// mutated together.
    node_decay_scores: HashMap<ShardId, f64>,

    /// Timestamp of the last event per node (nanoseconds).
    ///
    /// Required to apply additional decay at query time:
    /// `decayed = score * exp(-lambda * (now_ns - last_ns))`.
    ///
    /// Key-paired with `node_decay_scores`: see the type-level "Cross-map
    /// invariant" note. A node present here MUST also be present there.
    node_last_update_ns: HashMap<ShardId, u64>,

    /// Per-node windowed event counters.
    ///
    /// Each node tracks its own event increments via [`PNCounter`].
    /// On merge, per-node buckets are merged (per-node max via [`PNCounter`] merge).
    ///
    /// Note on structural over-allocation: a [`PNCounter`] is itself a
    /// per-node-keyed map, yet here it only ever holds the single key equal to
    /// its own `ShardId`. This nests a full counter-map inside each bucket to
    /// store one entry, so the windowed-count state is a map-of-one-entry-maps.
    /// It is kept as-is deliberately: reusing the audited [`PNCounter`] merge
    /// (per-node max, already proven idempotent and commutative) is safer than a
    /// bespoke single-value register, and the over-allocation is bounded by the
    /// node count, not the event count. Flattening to `HashMap<ShardId, u64>`
    /// would require re-deriving and re-proving the merge law and is not worth
    /// the risk for the saved allocation.
    node_buckets: HashMap<ShardId, PNCounter>,

    /// Exponential decay rate (`lambda = ln(2) / half_life_s`).
    ///
    /// Identical across all nodes for this signal type.
    lambda: f64,
}

impl CrdtSignalState {
    /// Create an empty state with the given decay rate.
    #[must_use]
    pub fn new(lambda: f64) -> Self {
        Self {
            node_decay_scores: HashMap::new(),
            node_last_update_ns: HashMap::new(),
            node_buckets: HashMap::new(),
            lambda,
        }
    }

    /// Build a `CrdtSignalState` representing a single node's contribution.
    ///
    /// Used when snapshotting live ledger state: wraps the current node's
    /// running decay score *and* its windowed event count as a per-node CRDT
    /// contribution so it can participate in merge operations.
    ///
    /// # Score/timestamp contract (load-bearing)
    ///
    /// `score` MUST be the node's **stored decay accumulator as of
    /// `last_update_ns`** — i.e. the running total before any decay forward to a
    /// later query time. It is stored against `last_update_ns`, and
    /// [`decay_score`](Self::decay_score) applies the one and only decay from
    /// `last_update_ns` to the query time. Passing a score already decayed to some
    /// later `now_ns` while pairing it with the *earlier* `last_update_ns` would
    /// make `decay_score` re-apply the `[last_update_ns, now_ns]` interval a
    /// second time, systematically under-counting every reconciled contribution
    /// (a verified double-decay bug). The live caller therefore passes
    /// `HotSignalState::stored_score`, not `current_score`.
    ///
    /// `bucket_count` is the node's windowed event count at snapshot time. It is
    /// seeded into the per-node [`PNCounter`] so the snapshot round-trips: a
    /// state built from a contribution and then merged carries the same
    /// [`total_count`](Self::total_count) it had live. Omitting it (the previous
    /// behaviour) produced an asymmetric snapshot whose windowed counters
    /// silently dropped to zero — a state the live ledger never actually held.
    /// Pass `0` when the caller has no windowed count to preserve.
    #[must_use]
    pub fn from_node_contribution(
        node: ShardId,
        score: f64,
        last_update_ns: u64,
        bucket_count: u64,
        lambda: f64,
    ) -> Self {
        let mut state = Self::new(lambda);
        state.node_decay_scores.insert(node, score);
        state.node_last_update_ns.insert(node, last_update_ns);
        if bucket_count > 0 {
            state.increment_bucket(node, bucket_count);
        }
        state
    }

    /// Record a new signal event from `node` at `now_ns` with `weight`.
    ///
    /// Delegates the decay arithmetic to the canonical
    /// [`forward_decay_step`] kernel (`CODING_GUIDELINES.md` §3) so this tier
    /// cannot drift from the others: in-order events decay the running score
    /// forward then add the weight; out-of-order events fold in a pre-decayed
    /// weight without regressing the per-node timestamp.
    pub fn on_signal(&mut self, node: ShardId, weight: f64, now_ns: u64) {
        let last = *self.node_last_update_ns.entry(node).or_insert(now_ns);
        let score = self.node_decay_scores.entry(node).or_insert(0.0);
        let step = forward_decay_step(*score, last, now_ns, self.lambda, weight);
        *score = step.new_score;
        if step.advance_timestamp {
            self.node_last_update_ns.insert(node, now_ns);
        }
    }

    /// Global decay score: sum of all per-node contributions decayed to `now_ns`.
    ///
    /// Iterates over `node_decay_scores` and looks up the corresponding
    /// last-update timestamp by the same key. This key-aligned lookup is
    /// critical -- iterating two `HashMap`s via `.zip()` does NOT guarantee
    /// key alignment and would produce incorrect decay factors.
    #[must_use]
    pub fn decay_score(&self, now_ns: u64) -> f64 {
        self.node_decay_scores
            .iter()
            .map(|(&node, &score)| {
                // Look up the last update time for THIS node (key-aligned).
                let last_ns = self
                    .node_last_update_ns
                    .get(&node)
                    .copied()
                    .unwrap_or(now_ns);
                // Read-time decay is a zero-weight forward-decay step through the
                // canonical kernel: in-order (now_ns >= last_ns) decays the score
                // forward; a query "in the past" (now_ns < last_ns) adds a
                // pre-decayed weight of 0, i.e. leaves the score unchanged —
                // exactly the previous `saturating_sub` semantics, now sharing
                // the one decay implementation.
                forward_decay_step(score, last_ns, now_ns, self.lambda, 0.0).new_score
            })
            .sum()
    }

    /// Record an event in the windowed counter for `node`.
    pub fn increment_bucket(&mut self, node: ShardId, amount: u64) {
        self.node_buckets
            .entry(node)
            .or_default()
            .increment(node, amount);
    }

    /// Total windowed count: sum of all per-node increments.
    #[must_use]
    pub fn total_count(&self) -> u64 {
        self.node_buckets
            .values()
            .map(PNCounter::total_positive)
            .sum()
    }

    /// Merge another `CrdtSignalState` into this one.
    ///
    /// Per-node decay state is a last-writer-wins register versioned by
    /// `last_update_ns`: for each node, keep the `(score, last_update_ns)` pair
    /// with the larger timestamp (ties break on the larger score). Because a
    /// node's running accumulator is monotonic in event time, this never drops
    /// a real contribution and is idempotent -- replaying the same segment a
    /// second time leaves the state unchanged. Distinct nodes occupy distinct
    /// map entries, so their contributions still sum at
    /// [`decay_score`](Self::decay_score) time. Per-node bucket counters merge
    /// via [`PNCounter`] (per-node max), which is already idempotent.
    pub fn merge(&mut self, other: &Self) {
        // Last-writer-wins per node on the (last_update_ns, score) register.
        // The decision lives in the shared `lww_other_wins` helper so this path
        // cannot drift from `LWWRegister::merge`. The ordering key is the tuple
        // `(last_update_ns, score)`: the timestamp dominates, and the score is
        // the deterministic tiebreak appended after it, so an equal-timestamp
        // collision resolves the same way regardless of merge order.
        for (&node, &other_score) in &other.node_decay_scores {
            let other_ts = other.node_last_update_ns.get(&node).copied().unwrap_or(0);
            let self_key = self.node_decay_scores.get(&node).map(|&self_score| {
                let self_ts = self.node_last_update_ns.get(&node).copied().unwrap_or(0);
                (self_ts, self_score)
            });
            if super::lww_register::lww_other_wins(self_key, &(other_ts, other_score)) {
                self.node_decay_scores.insert(node, other_score);
                self.node_last_update_ns.insert(node, other_ts);
            }
        }
        // Merge per-node bucket counters.
        for (node, other_bucket) in &other.node_buckets {
            self.node_buckets
                .entry(*node)
                .or_default()
                .merge(other_bucket);
        }
    }

    /// The decay rate (lambda) for this signal type.
    #[must_use]
    pub const fn lambda(&self) -> f64 {
        self.lambda
    }

    /// Number of nodes that have contributed events.
    #[must_use]
    pub fn node_count(&self) -> usize {
        self.node_decay_scores.len()
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use proptest::prelude::*;

    use super::*;

    const LAMBDA: f64 = std::f64::consts::LN_2 / (7.0 * 24.0 * 3600.0); // 7-day half-life

    #[test]
    fn single_node_decay_score() {
        let mut state = CrdtSignalState::new(LAMBDA);
        let t0 = 1_000_000_000u64;
        state.on_signal(ShardId(0), 1.0, t0);
        let score = state.decay_score(t0);
        assert!((score - 1.0).abs() < 1e-10, "score at t0 should be ~1.0");
    }

    #[test]
    fn two_node_scores_add_on_merge() {
        let t = 1_000_000_000u64;
        let mut a = CrdtSignalState::new(LAMBDA);
        a.on_signal(ShardId(0), 1.0, t);

        let mut b = CrdtSignalState::new(LAMBDA);
        b.on_signal(ShardId(1), 1.0, t);

        let score_a = a.decay_score(t);
        let score_b = b.decay_score(t);

        a.merge(&b);
        let score_merged = a.decay_score(t);

        assert!(
            (score_merged - (score_a + score_b)).abs() < 1e-10,
            "merged score {score_merged} should equal sum {score_a} + {score_b}"
        );
    }

    #[test]
    fn merge_commutative() {
        let t = 1_000_000_000u64;
        let mut a = CrdtSignalState::new(LAMBDA);
        a.on_signal(ShardId(0), 2.0, t);
        let mut b = CrdtSignalState::new(LAMBDA);
        b.on_signal(ShardId(1), 3.0, t);

        let mut ab = a.clone();
        ab.merge(&b);
        let mut ba = b.clone();
        ba.merge(&a);

        let diff = (ab.decay_score(t) - ba.decay_score(t)).abs();
        assert!(diff < 1e-10, "merge should be commutative, diff={diff}");
    }

    #[test]
    fn merge_associative() {
        let t = 1_000_000_000u64;
        let mut a = CrdtSignalState::new(LAMBDA);
        a.on_signal(ShardId(0), 1.0, t);
        let mut b = CrdtSignalState::new(LAMBDA);
        b.on_signal(ShardId(1), 2.0, t);
        let mut c = CrdtSignalState::new(LAMBDA);
        c.on_signal(ShardId(2), 3.0, t);

        let mut a_bc = a.clone();
        let mut bc = b.clone();
        bc.merge(&c);
        a_bc.merge(&bc);

        let mut ab_c = a.clone();
        ab_c.merge(&b);
        ab_c.merge(&c);

        let diff = (a_bc.decay_score(t) - ab_c.decay_score(t)).abs();
        assert!(diff < 1e-10, "merge should be associative, diff={diff}");
    }

    /// WARNING (REPL): a snapshot built via `from_node_contribution` must
    /// preserve the windowed bucket count, not silently drop it to zero. The
    /// snapshot must round-trip: building from a node's live state and merging it
    /// back yields the same `total_count` the node held live.
    #[test]
    fn from_node_contribution_preserves_windowed_bucket() {
        let t = 1_000_000_000u64;

        // Live state: node 0 has a decay score and 17 windowed events.
        let mut live = CrdtSignalState::new(LAMBDA);
        live.on_signal(ShardId(0), 4.0, t);
        live.increment_bucket(ShardId(0), 17);
        assert_eq!(live.total_count(), 17);

        // Snapshot that node's contribution (score + last_update + bucket).
        let snapshot = CrdtSignalState::from_node_contribution(
            ShardId(0),
            live.decay_score(t),
            t,
            live.total_count(),
            LAMBDA,
        );
        // The snapshot alone carries the windowed count — not zero.
        assert_eq!(
            snapshot.total_count(),
            17,
            "snapshot must carry the windowed bucket count, not drop it"
        );

        // Round-trip: merging the snapshot into an empty peer reproduces the count.
        let mut peer = CrdtSignalState::new(LAMBDA);
        peer.merge(&snapshot);
        assert_eq!(peer.total_count(), 17, "windowed count must survive merge");
    }

    /// CRITICAL regression: a contribution built from the STORED accumulator at
    /// `last_update_ns` must decay exactly once when read at a later `now_ns`.
    /// The production caller pairs the stored score with the earlier
    /// `last_update_ns`; if `decay_score` (or the caller) decayed twice, the
    /// reconciled value would be `stored * exp(-2·lambda·dt)` instead of the
    /// correct single decay `stored * exp(-lambda·dt)`.
    #[test]
    fn from_node_contribution_decays_exactly_once() {
        let lambda = std::f64::consts::LN_2; // 1-second half-life
        let last_update_ns = 1_000_000_000u64;
        let now_ns = last_update_ns + 2_000_000_000; // 2 seconds later
        let stored = 8.0;

        // Build the snapshot the way the live ledger does: stored accumulator
        // paired with its own last_update_ns (NOT pre-decayed to now_ns).
        let state =
            CrdtSignalState::from_node_contribution(ShardId(0), stored, last_update_ns, 0, lambda);

        // Reading 2s later applies exactly one decay: 8.0 * exp(-ln2 * 2) = 2.0.
        let single = stored * (-lambda * 2.0).exp();
        let got = state.decay_score(now_ns);
        assert!(
            (got - single).abs() < 1e-9,
            "expected single decay {single}, got {got} (double-decay regression)"
        );
        assert!(
            (single - 2.0).abs() < 1e-9,
            "sanity: single decay should be 2.0"
        );
    }

    /// A zero bucket count seeds no `PNCounter` entry (no spurious node bucket).
    #[test]
    fn from_node_contribution_zero_bucket_is_empty() {
        let s = CrdtSignalState::from_node_contribution(ShardId(0), 1.0, 1_000, 0, LAMBDA);
        assert_eq!(s.total_count(), 0);
    }

    #[test]
    fn windowed_count_no_double_counting() {
        let mut a = CrdtSignalState::new(LAMBDA);
        a.increment_bucket(ShardId(0), 10);

        let mut b = CrdtSignalState::new(LAMBDA);
        b.increment_bucket(ShardId(1), 20);

        a.merge(&b);
        assert_eq!(a.total_count(), 30, "disjoint nodes: count should be sum");
    }

    #[test]
    fn decay_score_aligned_key_lookup() {
        // Verify that decay_score correctly aligns keys between
        // node_decay_scores and node_last_update_ns (was buggy in spec).
        let t0 = 1_000_000_000u64;
        let t1 = t0 + 1_000_000_000; // 1 second later
        let lambda = std::f64::consts::LN_2; // 1-second half-life

        let mut state = CrdtSignalState::new(lambda);
        // Node 0 had events at t0
        state.on_signal(ShardId(0), 1.0, t0);
        // Node 1 had events at t1
        state.on_signal(ShardId(1), 1.0, t1);

        // Query at t1: node 0 should decay, node 1 should not
        let score = state.decay_score(t1);
        // node0: 1.0 * exp(-ln2 * 1.0) = 0.5
        // node1: 1.0 * exp(-ln2 * 0.0) = 1.0
        // total: ~1.5
        assert!(
            (score - 1.5).abs() < 1e-6,
            "aligned key lookup failed, got {score}"
        );
    }

    #[test]
    fn empty_state_decay_is_zero() {
        let state = CrdtSignalState::new(LAMBDA);
        assert!(state.decay_score(1_000_000_000).abs() < 1e-12);
    }

    #[test]
    fn node_count_tracks_contributors() {
        let mut state = CrdtSignalState::new(LAMBDA);
        assert_eq!(state.node_count(), 0);
        state.on_signal(ShardId(0), 1.0, 1000);
        assert_eq!(state.node_count(), 1);
        state.on_signal(ShardId(1), 1.0, 1000);
        assert_eq!(state.node_count(), 2);
    }

    /// REPL-5: replaying the same segment twice must yield the same state as
    /// once. With the old additive merge this double-counted; LWW-per-node is
    /// idempotent: `merge(merge(a, b), b) == merge(a, b)`.
    #[test]
    fn merge_idempotent_on_replay() {
        let t = 1_000_000_000u64;
        let mut a = CrdtSignalState::new(LAMBDA);
        a.on_signal(ShardId(0), 2.0, t);
        a.increment_bucket(ShardId(0), 7);

        let mut b = CrdtSignalState::new(LAMBDA);
        b.on_signal(ShardId(1), 3.0, t);
        b.increment_bucket(ShardId(1), 11);
        // Shared node contribution: b also carries node 0 state (a retried segment).
        b.on_signal(ShardId(0), 0.0, t);

        // once = merge(a, b)
        let mut once = a.clone();
        once.merge(&b);

        // twice = merge(merge(a, b), b) -- the segment replayed.
        let mut twice = once.clone();
        twice.merge(&b);

        assert_eq!(
            twice, once,
            "replaying segment b must not change merged state (idempotent merge)"
        );
        let diff = (twice.decay_score(t) - once.decay_score(t)).abs();
        assert!(
            diff < 1e-10,
            "replay must not change decay score, diff={diff}"
        );
    }

    /// REPL-5: `merge(a, a) == a` -- the minimal idempotency law.
    #[test]
    fn merge_self_is_identity() {
        let t = 1_000_000_000u64;
        let mut a = CrdtSignalState::new(LAMBDA);
        a.on_signal(ShardId(0), 2.0, t);
        a.on_signal(ShardId(1), 5.0, t + 1);
        a.increment_bucket(ShardId(0), 4);

        let snapshot = a.clone();
        a.merge(&snapshot);
        assert_eq!(a, snapshot, "merge(a, a) must equal a");
    }

    /// REPL-5: full-state commutativity `merge(a, b) == merge(b, a)`, including
    /// the shared-node LWW path (not just disjoint nodes).
    #[test]
    fn merge_commutative_full_state() {
        let t = 1_000_000_000u64;
        // a and b both touch node 0 at different timestamps (LWW path), plus
        // disjoint nodes 1 and 2 (additive-at-read path).
        let mut a = CrdtSignalState::new(LAMBDA);
        a.on_signal(ShardId(0), 2.0, t);
        a.on_signal(ShardId(1), 4.0, t);
        a.increment_bucket(ShardId(0), 3);

        let mut b = CrdtSignalState::new(LAMBDA);
        b.on_signal(ShardId(0), 9.0, t + 1_000); // newer node-0 snapshot wins
        b.on_signal(ShardId(2), 6.0, t);
        b.increment_bucket(ShardId(0), 5);

        let mut ab = a.clone();
        ab.merge(&b);
        let mut ba = b.clone();
        ba.merge(&a);

        assert_eq!(ab, ba, "merge must be commutative on full state");
        // Node 0 must resolve to b's newer snapshot in both orders.
        assert!((ab.decay_score(t + 1_000) - ba.decay_score(t + 1_000)).abs() < 1e-10);
    }

    /// REPL-5: shared-node LWW keeps the newer snapshot rather than summing.
    #[test]
    fn merge_same_node_takes_newer_not_sum() {
        let t = 1_000_000_000u64;
        let mut a = CrdtSignalState::new(LAMBDA);
        a.on_signal(ShardId(0), 2.0, t);

        let mut b = CrdtSignalState::new(LAMBDA);
        b.on_signal(ShardId(0), 9.0, t + 1_000); // newer, more-complete accumulator

        a.merge(&b);
        // Newer snapshot wins; score is NOT 2.0 + 9.0.
        let score = a.decay_score(t + 1_000);
        assert!(
            (score - 9.0).abs() < 1e-6,
            "same-node merge must take the newer snapshot (9.0), not the sum (11.0); got {score}"
        );
        assert_eq!(
            a.node_count(),
            1,
            "same node must not create a second entry"
        );
    }

    // ── Property-based CRDT-law tests (TEST-3) ──────────────────────────────
    //
    // The single-path example tests above pin specific scenarios; these
    // establish that the CRDT laws hold for *arbitrary* generated states.
    //
    // Equality: `CrdtSignalState` derives `PartialEq`, which compares all four
    // fields. Merge never does float *arithmetic* on `node_decay_scores` -- it
    // only copies an existing `f64` verbatim (LWW pick) or inserts it -- so
    // bitwise `==` on the score field is the correct observable comparison
    // after merge. We therefore generate only *finite* scores to avoid the
    // `NaN != NaN` trap (NaN would break even reflexivity of merge, which is a
    // property of `f64`, not of the CRDT). `last_update_ns` and bucket counts
    // are integers, so they compare exactly. All generated states share one
    // fixed `lambda` because merge intentionally leaves `lambda` untouched
    // (it is "identical across all nodes for this signal type"); comparing
    // states with mismatched lambdas would be a vacuous test of merge, not of
    // the laws.

    /// One synthetic per-node contribution: a node id, its LWW-versioned
    /// `(score, last_update_ns)` register, and a windowed bucket increment.
    #[derive(Debug, Clone)]
    struct NodeOp {
        node: u16,
        score: f64,
        last_update_ns: u64,
        bucket: u64,
    }

    /// Strategy for a single node op.
    ///
    /// Node ids are drawn from a *small* space (`0..4`) so independently
    /// generated states overlap on shared nodes with high probability -- this
    /// exercises the shared-node last-writer-wins path, not just the trivial
    /// disjoint-node (additive-at-read) path. Scores are finite f64 across a
    /// wide magnitude band (including negatives and zero); timestamps and
    /// bucket counts span a wide integer range to stress LWW tie-breaking and
    /// timestamp ordering.
    fn node_op_strategy() -> impl Strategy<Value = NodeOp> {
        (
            0u16..4,
            // Finite scores only (no NaN/inf): a mix of small integers,
            // fractional, negative, and large values.
            prop_oneof![Just(0.0f64), -100.0f64..100.0, 1e6f64..1e9, -1e9f64..-1e6,],
            // Cluster timestamps so ties (equal last_update_ns across states)
            // actually occur, exercising the score tie-break branch.
            0u64..10,
            0u64..50,
        )
            .prop_map(|(node, score, last_update_ns, bucket)| NodeOp {
                node,
                score,
                last_update_ns,
                bucket,
            })
    }

    /// Build a `CrdtSignalState` from a list of per-node ops.
    ///
    /// Each op is folded in as a single-node contribution merged into the
    /// accumulator, so the resulting state is reachable via the production
    /// merge path and carries arbitrary (but valid) per-node LWW registers and
    /// bucket counters. Multiple ops on the same node resolve through the real
    /// LWW rule, producing the same class of states the replication path sees.
    fn build_state(ops: &[NodeOp], lambda: f64) -> CrdtSignalState {
        let mut state = CrdtSignalState::new(lambda);
        for op in ops {
            let shard = ShardId(op.node);
            // The bucket count now rides inside the contribution snapshot, so it
            // travels through the production merge path rather than a side-channel
            // increment. PNCounter merge is per-node max, so re-merging the same
            // node's bucket stays idempotent.
            let contribution = CrdtSignalState::from_node_contribution(
                shard,
                op.score,
                op.last_update_ns,
                op.bucket,
                lambda,
            );
            state.merge(&contribution);
        }
        state
    }

    /// Strategy producing an arbitrary `CrdtSignalState` over a fixed lambda.
    fn crdt_state_strategy() -> impl Strategy<Value = CrdtSignalState> {
        prop::collection::vec(node_op_strategy(), 0..8).prop_map(|ops| build_state(&ops, LAMBDA))
    }

    proptest! {
        // Modest case count keeps the suite fast (project rule: slow tests are
        // bugs). 256 is proptest's default and is ample to falsify a broken law.
        #![proptest_config(ProptestConfig::with_cases(256))]

        /// Commutativity: `merge(a, b) == merge(b, a)` for arbitrary states.
        #[test]
        fn prop_merge_commutative(a in crdt_state_strategy(), b in crdt_state_strategy()) {
            let mut ab = a.clone();
            ab.merge(&b);
            let mut ba = b.clone();
            ba.merge(&a);
            prop_assert_eq!(ab, ba, "merge must be commutative for all inputs");
        }

        /// Associativity: `merge(merge(a, b), c) == merge(a, merge(b, c))`.
        #[test]
        fn prop_merge_associative(
            a in crdt_state_strategy(),
            b in crdt_state_strategy(),
            c in crdt_state_strategy(),
        ) {
            // (a ∘ b) ∘ c
            let mut left = a.clone();
            left.merge(&b);
            left.merge(&c);
            // a ∘ (b ∘ c)  -- `a` is consumed here on its last use.
            let mut bc = b.clone();
            bc.merge(&c);
            let mut right = a;
            right.merge(&bc);
            prop_assert_eq!(left, right, "merge must be associative for all inputs");
        }

        /// Idempotency (replay): `merge(merge(a, b), b) == merge(a, b)`.
        /// Replaying the same segment a second time must not change state.
        #[test]
        fn prop_merge_idempotent_on_replay(
            a in crdt_state_strategy(),
            b in crdt_state_strategy(),
        ) {
            let mut once = a;
            once.merge(&b);
            let mut twice = once.clone();
            twice.merge(&b);
            prop_assert_eq!(twice, once, "replaying b must leave merge(a, b) unchanged");
        }

        /// Idempotency (self): `merge(a, a) == a`.
        #[test]
        fn prop_merge_self_is_identity(a in crdt_state_strategy()) {
            let mut merged = a.clone();
            merged.merge(&a);
            prop_assert_eq!(merged, a, "merge(a, a) must equal a");
        }
    }
}
