//! Canonical forward-decay kernel — the single source of truth for running
//! exponentially-decayed signal scores.
//!
//! # Why this module exists
//!
//! `CODING_GUIDELINES.md` §3 ("Signal System") is unambiguous: *decay is a
//! type, not a formula you call.* Before this module the forward-decay
//! arithmetic was re-implemented independently in `HotSignalState`,
//! `SessionHotState`, `InteractionLedger`, `CohortSignalLedger`, and
//! `CrdtSignalState`. The copies drifted: at least one (the session tier)
//! added late-arriving weight at full value instead of pre-decaying it,
//! silently over-crediting stale activity and violating the spec. Five copies
//! of subtle floating-point/ordering logic is five chances to get it wrong.
//!
//! [`forward_decay_step`] is the one place the two-branch decay math lives.
//! Every tier delegates to it for the *arithmetic* and keeps its own atomics /
//! CAS loops / WAL hooks. The kernel is a pure function over plain `f64`/`u64`
//! values: it allocates nothing, locks nothing, and has no side effects, so it
//! composes cleanly inside any caller's lock-free retry loop.
//!
//! # The math (verbatim from `CODING_GUIDELINES.md` §3)
//!
//! The forward-decay identity is *exact*, not an approximation. For a running
//! score `S` last updated at `t_prev`:
//!
//! - **In-order write** (`t_event >= t_prev`):
//!   `S(t_event) = S(t_prev) * exp(-lambda * dt) + weight`, where
//!   `dt = t_event - t_prev`. The timestamp advances to `t_event`.
//! - **Out-of-order write** (`t_event < t_prev`):
//!   `S += weight * exp(-lambda * age)`, where `age = t_prev - t_event`. The
//!   weight is *pre-decayed* by the event's age and folded into the existing
//!   score. The timestamp does **not** regress — it already reflects a more
//!   recent time.
//!
//! Because the accumulator is linear in event weights and the decay factor is
//! `exp(-lambda * Δt)`, processing events out of order yields the same final
//! score (to floating-point tolerance) as processing them in order. The
//! out-of-order branch is exactly what makes that hold.

/// Nanoseconds per second, as `f64`.
///
/// The canonical divisor for converting a nanosecond timestamp delta into the
/// seconds that `lambda` (which carries units of `1/second`) expects. Defined
/// once here to retire the literal `1e9` / `1_000_000_000.0` that the review
/// found scattered across 18 call sites — a single typo in any copy would
/// silently mis-scale a decay rate by orders of magnitude.
pub const NANOS_PER_SEC: f64 = 1_000_000_000.0;

/// The result of applying one forward-decay step to a single running score.
///
/// Pure data: the caller is responsible for committing `new_score` (typically
/// via a CAS loop on an `AtomicU64` holding `f64::to_bits`) and, when
/// `advance_timestamp` is `true`, for advancing its `last_update` timestamp to
/// the event time. Keeping the side effects in the caller lets every tier reuse
/// this arithmetic without giving up its own concurrency strategy.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DecayStep {
    /// The updated running score after folding in the event's weight.
    pub new_score: f64,
    /// `true` for an in-order event whose timestamp should become the new
    /// `last_update`; `false` for an out-of-order event, whose timestamp must
    /// **not** regress the accumulator's `last_update`.
    pub advance_timestamp: bool,
}

/// Computes one forward-decay update for a single running score.
///
/// This is the canonical kernel mandated by `CODING_GUIDELINES.md` §3. It
/// handles both temporal orderings in one place so no tier can drift:
///
/// - **In-order** (`event_time_ns >= last_update_ns`): decays the existing
///   score forward by `dt = event_time_ns - last_update_ns` seconds, then adds
///   `weight`:
///   `new_score = old_score * exp(-lambda * dt) + weight`,
///   computed with [`f64::mul_add`] for one fused rounding. Returns
///   `advance_timestamp = true`.
/// - **Out-of-order** (`event_time_ns < last_update_ns`): pre-decays the weight
///   by the event's age `age = last_update_ns - event_time_ns` seconds, then
///   folds it into the existing score:
///   `new_score = old_score + weight * exp(-lambda * age)`.
///   Returns `advance_timestamp = false` — the timestamp must not regress.
///
/// # Parameters
///
/// - `old_score`: the current running (stored) score.
/// - `last_update_ns`: nanosecond timestamp of the most recent in-order event.
/// - `event_time_ns`: nanosecond timestamp of the event being recorded.
/// - `lambda`: decay rate in units of `1/second` (`ln(2) / half_life`).
/// - `weight`: the event's weight.
///
/// # Allocation & blocking
///
/// None. Pure arithmetic over scalars; safe to call inside a tight CAS loop.
#[inline]
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn forward_decay_step(
    old_score: f64,
    last_update_ns: u64,
    event_time_ns: u64,
    lambda: f64,
    weight: f64,
) -> DecayStep {
    if event_time_ns >= last_update_ns {
        // In-order: decay existing score forward by dt, then add full weight.
        let dt_secs = (event_time_ns - last_update_ns) as f64 / NANOS_PER_SEC;
        let decay_factor = (-lambda * dt_secs).exp();
        DecayStep {
            new_score: old_score.mul_add(decay_factor, weight),
            advance_timestamp: true,
        }
    } else {
        // Out-of-order: pre-decay the weight by the event's age and fold it in.
        // Do NOT advance the timestamp — last_update already reflects a more
        // recent time (CODING_GUIDELINES §3).
        let age_secs = (last_update_ns - event_time_ns) as f64 / NANOS_PER_SEC;
        let effective_weight = weight * (-lambda * age_secs).exp();
        DecayStep {
            new_score: old_score + effective_weight,
            advance_timestamp: false,
        }
    }
}

#[cfg(test)]
#[allow(clippy::float_cmp, clippy::cast_precision_loss, clippy::cast_sign_loss)]
mod tests {
    use super::*;

    /// `lambda` for a given half-life in seconds.
    fn lambda_for_half_life(secs: f64) -> f64 {
        std::f64::consts::LN_2 / secs
    }

    #[test]
    fn in_order_first_event_yields_weight() {
        // With old_score = 0 and dt = 0, new_score == weight exactly.
        let step = forward_decay_step(0.0, 0, 0, lambda_for_half_life(3600.0), 1.0);
        assert!(step.advance_timestamp);
        assert!((step.new_score - 1.0).abs() < 1e-15);
    }

    #[test]
    fn in_order_advances_timestamp() {
        let step = forward_decay_step(2.0, 100, 200, lambda_for_half_life(3600.0), 1.0);
        assert!(
            step.advance_timestamp,
            "in-order events advance the timestamp"
        );
    }

    #[test]
    fn out_of_order_does_not_advance_timestamp() {
        let step = forward_decay_step(2.0, 200, 100, lambda_for_half_life(3600.0), 1.0);
        assert!(
            !step.advance_timestamp,
            "out-of-order events must not regress the timestamp"
        );
    }

    #[test]
    fn half_life_decays_score_to_half_within_one_percent() {
        // A single unit-weight event, then a second in-order event exactly one
        // half-life later: the first contributes ~0.5 by then.
        let half_life = 3600.0;
        let lambda = lambda_for_half_life(half_life);

        let first = forward_decay_step(0.0, 0, 0, lambda, 1.0);
        let one_hl_ns = (half_life * NANOS_PER_SEC) as u64;
        let second = forward_decay_step(first.new_score, 0, one_hl_ns, lambda, 0.0);

        // The original weight should have decayed to ~0.5.
        let relative_error = (second.new_score - 0.5).abs() / 0.5;
        assert!(
            relative_error < 0.01,
            "decayed score {} not within 1% of 0.5",
            second.new_score
        );
    }

    #[test]
    fn out_of_order_contributes_strictly_less_than_in_order() {
        // An event arriving "late" by some age must contribute less than the
        // same event arriving exactly on time (in-order with dt = 0).
        let lambda = lambda_for_half_life(3600.0);
        let last_update_ns = 10_000_000_000; // t = 10s
        let weight = 3.0;

        // In-order at t = last_update: contributes the full weight.
        let in_order = forward_decay_step(0.0, last_update_ns, last_update_ns, lambda, weight);
        assert!((in_order.new_score - weight).abs() < 1e-12);

        // Out-of-order at t = 5s (age = 5s): contributes a pre-decayed weight.
        let out_of_order = forward_decay_step(0.0, last_update_ns, 5_000_000_000, lambda, weight);

        assert!(
            out_of_order.new_score < in_order.new_score,
            "out-of-order {} should contribute strictly less than in-order {}",
            out_of_order.new_score,
            in_order.new_score
        );
        // And it must still be positive (age is finite).
        assert!(out_of_order.new_score > 0.0);
    }

    #[test]
    fn out_of_order_matches_analytical_predecay() {
        let lambda = lambda_for_half_life(3600.0);
        let last_update_ns = 10_000_000_000;
        let event_ns = 5_000_000_000; // 5s in the past
        let weight = 2.0;

        let step = forward_decay_step(0.0, last_update_ns, event_ns, lambda, weight);
        let expected = weight * (-lambda * 5.0).exp();
        assert!(
            (step.new_score - expected).abs() < 1e-12,
            "got {}, expected {expected}",
            step.new_score
        );
    }

    use proptest::prelude::*;

    proptest! {
        // The running score never goes negative for non-negative inputs.
        #[test]
        fn new_score_non_negative(
            old_score in 0.0f64..1e12,
            last_update_ns in 0u64..2_000_000_000,
            event_time_ns in 0u64..2_000_000_000,
            lambda in 1e-7f64..1e-3,
            weight in 0.0f64..1e6,
        ) {
            let step = forward_decay_step(old_score, last_update_ns, event_time_ns, lambda, weight);
            prop_assert!(step.new_score >= 0.0, "new_score was {}", step.new_score);
        }

        // Decaying an existing score forward with zero new weight is monotone
        // non-increasing: time only ever shrinks the running score.
        #[test]
        fn zero_weight_in_order_is_monotone_non_increasing(
            old_score in 0.0f64..1e12,
            dt_ns in 0u64..2_000_000_000,
            lambda in 1e-7f64..1e-3,
        ) {
            let step = forward_decay_step(old_score, 0, dt_ns, lambda, 0.0);
            prop_assert!(step.new_score <= old_score + 1e-9);
            prop_assert!(step.new_score >= 0.0);
            prop_assert!(step.advance_timestamp);
        }

        // Out-of-order pre-decayed weight is always <= the raw weight (it is
        // multiplied by exp(-lambda * age) <= 1), and the timestamp never
        // advances.
        #[test]
        fn out_of_order_weight_bounded_by_raw_weight(
            last_update_ns in 1u64..2_000_000_000,
            event_time_ns in 0u64..1_999_999_999,
            lambda in 1e-7f64..1e-3,
            weight in 0.0f64..1e6,
        ) {
            prop_assume!(event_time_ns < last_update_ns);
            let step = forward_decay_step(0.0, last_update_ns, event_time_ns, lambda, weight);
            prop_assert!(step.new_score <= weight + 1e-9);
            prop_assert!(!step.advance_timestamp);
        }

        // Branch selection is decided purely by temporal ordering.
        #[test]
        fn branch_selection_matches_ordering(
            last_update_ns in 0u64..2_000_000_000,
            event_time_ns in 0u64..2_000_000_000,
            lambda in 1e-7f64..1e-3,
            weight in 0.0f64..1e3,
        ) {
            let step = forward_decay_step(1.0, last_update_ns, event_time_ns, lambda, weight);
            prop_assert_eq!(step.advance_timestamp, event_time_ns >= last_update_ns);
        }
    }
}
