//! Reusable C¹/C² smoothing primitives for correlation transitions.
//!
//! Equation-oriented Newton solvers require the residual functions — and
//! therefore every correlation they depend on — to be at least **C¹**
//! (continuous value *and* continuous first derivative). A naive `if/else`
//! switch between two correlations (e.g. laminar `64/Re` below `Re = 2300`
//! and a turbulent friction factor above it) introduces a slope discontinuity
//! that makes the analytic Jacobian jump. Newton then oscillates or diverges,
//! and finite-difference Jacobians produce spurious large entries near the
//! switch. This is the failure mode behind real-world regressions where an
//! `if/else` transition was activated on the wrong branch.
//!
//! This module centralises the smoothing primitives so that **every** component
//! uses the same, tested, analytically-differentiable building blocks instead
//! of ad-hoc `tanh`/`spliceFunction` code scattered across the codebase.
//!
//! # Primitives
//!
//! - [`smoothstep`] / [`smoothstep_derivative`] — C¹ Hermite cubic ramp on `[0, 1]`.
//! - [`smootherstep`] / [`smootherstep_derivative`] / [`smootherstep_second_derivative`]
//!   — C² quintic ramp on `[0, 1]`.
//! - [`cubic_blend`] / [`quintic_blend`] — blend two values across a transition window.
//! - [`smooth_max`] / [`smooth_min`] — C^∞ soft maximum / minimum.
//! - [`smooth_abs`] — C^∞ approximation of `|x|`.
//! - [`smooth_clamp`] — C¹ clamp into `[lo, hi]`.
//!
//! Analytic first derivatives are exported for every Newton-facing primitive
//! ([`smooth_max_derivative`], [`smooth_min_derivative`], [`smooth_abs_derivative`],
//! [`smooth_clamp_derivative`]) so component Jacobians stay exact.
//!
//! # ε / width selection (physical scale of the blend)
//!
//! | Primitive | Parameter | Meaning | Rule of thumb |
//! |---|---|---|---|
//! | [`smooth_abs`] | `eps` | value at `x = 0`; blend half-width | ~1% of characteristic `\|x\|`, or smaller if the kink is far from the operating region |
//! | [`smooth_max`] / [`smooth_min`] | `k` | overshoot at equality = `k / 2` | `k` much smaller than `\|a - b\|` in the region you care about; never so large it shifts equilibrium |
//! | [`smooth_clamp`] | `width` | transition length at each bound | `width <= (hi - lo) / 2`; keep the band physically small (e.g. opening `1e-3`..`1e-2` on `[0, 1]`) |
//!
//! Heat-exchanger mass-flow activity (`flow_activity`, `m_eps`) lives in
//! `entropyk-components` as a specialized reference pattern — see
//! `docs/components/phantom-gradient-regularization.md`.
//!
//! # Degenerate parameters (never panic)
//!
//! - `k <= 0` → hard [`f64::max`] / [`f64::min`]
//! - `eps <= 0` → hard `|x|` / `sign(x)`
//! - `width <= 0` or inverted `[lo, hi]` → hard [`f64::clamp`] (bounds swapped if needed)
//!
//! # Example: smoothing a friction-factor transition
//!
//! ```rust
//! use entropyk_core::smoothing::cubic_blend;
//!
//! // Blend laminar -> turbulent friction factor over the Re window [2300, 4000].
//! let f_laminar = 64.0 / 2300.0;
//! let f_turbulent = 0.04;
//! let re = 3000.0;
//! let f = cubic_blend(f_laminar, f_turbulent, re, 2300.0, 4000.0);
//! assert!(f > f_turbulent.min(f_laminar) && f < f_laminar.max(f_turbulent));
//! ```

/// Normalises `x` to `t = (x - edge0) / (edge1 - edge0)` clamped to `[0, 1]`.
///
/// Returns the clamped parameter together with `1 / (edge1 - edge0)` so callers
/// can chain a chain-rule factor for derivatives. When `edge1 == edge0` (a
/// degenerate, zero-width window) the inverse-width factor is `0.0` and the
/// parameter is a hard step at the shared edge.
#[inline]
fn normalize(x: f64, edge0: f64, edge1: f64) -> (f64, f64) {
    let width = edge1 - edge0;
    if width == 0.0 {
        let t = if x < edge0 { 0.0 } else { 1.0 };
        return (t, 0.0);
    }
    let inv_width = 1.0 / width;
    let val = (x - edge0) * inv_width;
    if val.is_nan() {
        return (f64::NAN, inv_width);
    }
    let t = val.clamp(0.0, 1.0);
    (t, inv_width)
}

/// C¹ Hermite cubic smoothstep on the window `[edge0, edge1]`.
///
/// Returns `0.0` for `x <= edge0`, `1.0` for `x >= edge1`, and the cubic
/// `3t² - 2t³` (with `t = (x - edge0) / (edge1 - edge0)`) in between. Both the
/// value and the first derivative are continuous everywhere; the first
/// derivative is exactly `0` at both edges, so blending two constant regions
/// stays C¹.
///
/// `edge0` must be strictly less than `edge1`; a zero-width window degenerates
/// to a hard step at the shared edge.
///
/// # Example
///
/// ```rust
/// use entropyk_core::smoothing::smoothstep;
///
/// assert_eq!(smoothstep(0.0, 1.0, -0.5), 0.0);
/// assert_eq!(smoothstep(0.0, 1.0, 1.5), 1.0);
/// assert!((smoothstep(0.0, 1.0, 0.5) - 0.5).abs() < 1e-12);
/// ```
#[inline]
pub fn smoothstep(edge0: f64, edge1: f64, x: f64) -> f64 {
    let (t, _) = normalize(x, edge0, edge1);
    t * t * (3.0 - 2.0 * t)
}

/// Analytic derivative of [`smoothstep`] with respect to `x`.
///
/// Equals `6t(1 - t) / (edge1 - edge0)` inside the window and `0.0` outside.
///
/// # Example
///
/// ```rust
/// use entropyk_core::smoothing::smoothstep_derivative;
///
/// // Derivative is zero at both edges (C¹ join with the flat regions).
/// assert_eq!(smoothstep_derivative(0.0, 1.0, 0.0), 0.0);
/// assert_eq!(smoothstep_derivative(0.0, 1.0, 1.0), 0.0);
/// // ... and positive in the interior.
/// assert!(smoothstep_derivative(0.0, 1.0, 0.5) > 0.0);
/// ```
#[inline]
pub fn smoothstep_derivative(edge0: f64, edge1: f64, x: f64) -> f64 {
    let (t, inv_width) = normalize(x, edge0, edge1);
    6.0 * t * (1.0 - t) * inv_width
}

/// C² quintic smootherstep on the window `[edge0, edge1]`.
///
/// Returns `0.0` for `x <= edge0`, `1.0` for `x >= edge1`, and the quintic
/// `6t⁵ - 15t⁴ + 10t³` in between. Value, first **and** second derivatives are
/// continuous everywhere; both derivatives vanish at the edges. Prefer this
/// over [`smoothstep`] when the consuming correlation is itself differentiated
/// again (e.g. needs a C¹ Jacobian of a quantity that already contains a blend).
///
/// # Example
///
/// ```rust
/// use entropyk_core::smoothing::smootherstep;
///
/// assert_eq!(smootherstep(0.0, 1.0, -0.5), 0.0);
/// assert_eq!(smootherstep(0.0, 1.0, 1.5), 1.0);
/// assert!((smootherstep(0.0, 1.0, 0.5) - 0.5).abs() < 1e-12);
/// ```
#[inline]
pub fn smootherstep(edge0: f64, edge1: f64, x: f64) -> f64 {
    let (t, _) = normalize(x, edge0, edge1);
    t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
}

/// Analytic first derivative of [`smootherstep`] with respect to `x`.
///
/// Equals `30t²(t - 1)² / (edge1 - edge0)` inside the window and `0.0` outside.
///
/// # Example
///
/// ```rust
/// use entropyk_core::smoothing::smootherstep_derivative;
///
/// assert_eq!(smootherstep_derivative(0.0, 1.0, 0.0), 0.0);
/// assert_eq!(smootherstep_derivative(0.0, 1.0, 1.0), 0.0);
/// assert!(smootherstep_derivative(0.0, 1.0, 0.5) > 0.0);
/// ```
#[inline]
pub fn smootherstep_derivative(edge0: f64, edge1: f64, x: f64) -> f64 {
    let (t, inv_width) = normalize(x, edge0, edge1);
    30.0 * t * t * (t - 1.0) * (t - 1.0) * inv_width
}

/// Analytic second derivative of [`smootherstep`] with respect to `x`.
///
/// Equals `60t(2t - 1)(t - 1) / (edge1 - edge0)²` inside the window and `0.0`
/// outside. It vanishes at both edges, which is what makes [`smootherstep`] C².
///
/// # Example
///
/// ```rust
/// use entropyk_core::smoothing::smootherstep_second_derivative;
///
/// assert_eq!(smootherstep_second_derivative(0.0, 1.0, 0.0), 0.0);
/// assert_eq!(smootherstep_second_derivative(0.0, 1.0, 1.0), 0.0);
/// ```
#[inline]
pub fn smootherstep_second_derivative(edge0: f64, edge1: f64, x: f64) -> f64 {
    let (t, inv_width) = normalize(x, edge0, edge1);
    60.0 * t * (2.0 * t - 1.0) * (t - 1.0) * inv_width * inv_width
}

/// Blends two values `a` (active for `x <= edge0`) and `b` (active for
/// `x >= edge1`) with a C¹ [`smoothstep`] weight.
///
/// Equivalent to `a + (b - a) * smoothstep(edge0, edge1, x)`. Use this to
/// transition between two correlations (e.g. laminar and turbulent friction
/// factor) without a slope discontinuity.
///
/// # Example
///
/// ```rust
/// use entropyk_core::smoothing::cubic_blend;
///
/// assert_eq!(cubic_blend(10.0, 20.0, -1.0, 0.0, 1.0), 10.0); // below window -> a
/// assert_eq!(cubic_blend(10.0, 20.0, 2.0, 0.0, 1.0), 20.0);  // above window -> b
/// assert!((cubic_blend(10.0, 20.0, 0.5, 0.0, 1.0) - 15.0).abs() < 1e-12);
/// ```
#[inline]
pub fn cubic_blend(a: f64, b: f64, x: f64, edge0: f64, edge1: f64) -> f64 {
    a + (b - a) * smoothstep(edge0, edge1, x)
}

/// Derivative of [`cubic_blend`] with respect to `x`.
///
/// Equals `(b - a) * smoothstep_derivative(edge0, edge1, x)`. The endpoint
/// values `a` and `b` are treated as constants; if they themselves depend on
/// `x`, add their contributions via the chain rule at the call site.
///
/// # Example
///
/// ```rust
/// use entropyk_core::smoothing::cubic_blend_derivative;
///
/// // Flat (zero slope) at both edges.
/// assert_eq!(cubic_blend_derivative(10.0, 20.0, 0.0, 0.0, 1.0), 0.0);
/// assert_eq!(cubic_blend_derivative(10.0, 20.0, 1.0, 0.0, 1.0), 0.0);
/// ```
#[inline]
pub fn cubic_blend_derivative(a: f64, b: f64, x: f64, edge0: f64, edge1: f64) -> f64 {
    (b - a) * smoothstep_derivative(edge0, edge1, x)
}

/// Blends two values `a` and `b` with a C² [`smootherstep`] weight.
///
/// Equivalent to `a + (b - a) * smootherstep(edge0, edge1, x)`. Prefer over
/// [`cubic_blend`] when the blended quantity is differentiated again downstream.
///
/// # Example
///
/// ```rust
/// use entropyk_core::smoothing::quintic_blend;
///
/// assert_eq!(quintic_blend(10.0, 20.0, -1.0, 0.0, 1.0), 10.0);
/// assert_eq!(quintic_blend(10.0, 20.0, 2.0, 0.0, 1.0), 20.0);
/// assert!((quintic_blend(10.0, 20.0, 0.5, 0.0, 1.0) - 15.0).abs() < 1e-12);
/// ```
#[inline]
pub fn quintic_blend(a: f64, b: f64, x: f64, edge0: f64, edge1: f64) -> f64 {
    a + (b - a) * smootherstep(edge0, edge1, x)
}

/// Derivative of [`quintic_blend`] with respect to `x`.
///
/// Equals `(b - a) * smootherstep_derivative(edge0, edge1, x)`, treating `a`
/// and `b` as constants.
///
/// # Example
///
/// ```rust
/// use entropyk_core::smoothing::quintic_blend_derivative;
///
/// assert_eq!(quintic_blend_derivative(10.0, 20.0, 0.0, 0.0, 1.0), 0.0);
/// assert_eq!(quintic_blend_derivative(10.0, 20.0, 1.0, 0.0, 1.0), 0.0);
/// ```
#[inline]
pub fn quintic_blend_derivative(a: f64, b: f64, x: f64, edge0: f64, edge1: f64) -> f64 {
    (b - a) * smootherstep_derivative(edge0, edge1, x)
}

/// C^∞ smooth maximum of `a` and `b` with sharpness controlled by `k > 0`.
///
/// Uses the quadratic (square-root) soft-max
/// `0.5 * (a + b + sqrt((a - b)² + k²))`. The result is always `>= max(a, b)`
/// and converges to `max(a, b)` as `k -> 0`; the overshoot at `a == b` is
/// exactly `k / 2`. Unlike a `LogSumExp` soft-max it never overflows for large
/// arguments. A small `k` (relative to the scale of `a - b`) gives a sharp but
/// still infinitely differentiable maximum.
///
/// When `k <= 0`, returns the hard `a.max(b)` (no panic).
///
/// # Example
///
/// ```rust
/// use entropyk_core::smoothing::smooth_max;
///
/// // Recovers the hard max away from the kink.
/// assert!((smooth_max(5.0, 1.0, 1e-3) - 5.0).abs() < 1e-2);
/// // Always an upper bound on the true max.
/// assert!(smooth_max(2.0, 2.0, 0.1) >= 2.0);
/// assert_eq!(smooth_max(5.0, 1.0, 0.0), 5.0);
/// ```
#[inline]
pub fn smooth_max(a: f64, b: f64, k: f64) -> f64 {
    if k <= 0.0 {
        return a.max(b);
    }
    0.5 * (a + b + ((a - b) * (a - b) + k * k).sqrt())
}

/// Analytic derivative of [`smooth_max`] with respect to `a` (`b`, `k` fixed).
///
/// Equals `0.5 * (1 + (a - b) / sqrt((a - b)² + k²))` for `k > 0`. When
/// `k <= 0`, returns the hard-max subgradient pick: `1` if `a > b`, `0` if
/// `a < b`, and `0.5` if `a == b`.
///
/// # Example
///
/// ```rust
/// use entropyk_core::smoothing::smooth_max_derivative;
///
/// assert!((smooth_max_derivative(5.0, 1.0, 1e-3) - 1.0).abs() < 1e-2);
/// assert!((smooth_max_derivative(1.0, 5.0, 1e-3)).abs() < 1e-2);
/// ```
#[inline]
pub fn smooth_max_derivative(a: f64, b: f64, k: f64) -> f64 {
    if a.is_nan() || b.is_nan() || k.is_nan() {
        return f64::NAN;
    }
    if k <= 0.0 {
        return if a > b {
            1.0
        } else if a < b {
            0.0
        } else {
            0.5
        };
    }
    let denom = ((a - b) * (a - b) + k * k).sqrt();
    if denom == 0.0 {
        return 0.5;
    }
    0.5 * (1.0 + (a - b) / denom)
}

/// C^∞ smooth minimum of `a` and `b` with sharpness controlled by `k > 0`.
///
/// Uses `0.5 * (a + b - sqrt((a - b)² + k²))`. Always `<= min(a, b)`, converging
/// to `min(a, b)` as `k -> 0`.
///
/// When `k <= 0`, returns the hard `a.min(b)` (no panic).
///
/// # Example
///
/// ```rust
/// use entropyk_core::smoothing::smooth_min;
///
/// assert!((smooth_min(5.0, 1.0, 1e-3) - 1.0).abs() < 1e-2);
/// assert!(smooth_min(2.0, 2.0, 0.1) <= 2.0);
/// assert_eq!(smooth_min(5.0, 1.0, -1.0), 1.0);
/// ```
#[inline]
pub fn smooth_min(a: f64, b: f64, k: f64) -> f64 {
    if k <= 0.0 {
        return a.min(b);
    }
    0.5 * (a + b - ((a - b) * (a - b) + k * k).sqrt())
}

/// Analytic derivative of [`smooth_min`] with respect to `a` (`b`, `k` fixed).
///
/// Equals `0.5 * (1 - (a - b) / sqrt((a - b)² + k²))` for `k > 0`. When
/// `k <= 0`, returns the hard-min subgradient pick: `1` if `a < b`, `0` if
/// `a > b`, and `0.5` if `a == b`.
///
/// # Example
///
/// ```rust
/// use entropyk_core::smoothing::smooth_min_derivative;
///
/// assert!((smooth_min_derivative(1.0, 5.0, 1e-3) - 1.0).abs() < 1e-2);
/// assert!((smooth_min_derivative(5.0, 1.0, 1e-3)).abs() < 1e-2);
/// ```
#[inline]
pub fn smooth_min_derivative(a: f64, b: f64, k: f64) -> f64 {
    if a.is_nan() || b.is_nan() || k.is_nan() {
        return f64::NAN;
    }
    if k <= 0.0 {
        return if a < b {
            1.0
        } else if a > b {
            0.0
        } else {
            0.5
        };
    }
    let denom = ((a - b) * (a - b) + k * k).sqrt();
    if denom == 0.0 {
        return 0.5;
    }
    0.5 * (1.0 - (a - b) / denom)
}

/// C^∞ approximation of the absolute value `|x|`.
///
/// Returns `sqrt(x² + eps²)`. Always `>= |x|`, converging to `|x|` as
/// `eps -> 0`; the value at `x = 0` is exactly `eps`. The derivative
/// `x / sqrt(x² + eps²)` is smooth through the origin, replacing the
/// non-differentiable corner of `|x|` that would otherwise break Newton.
///
/// When `eps <= 0`, returns the hard `|x|` (no panic).
///
/// # Example
///
/// ```rust
/// use entropyk_core::smoothing::smooth_abs;
///
/// assert!((smooth_abs(0.0, 1e-3) - 1e-3).abs() < 1e-12);
/// assert!((smooth_abs(5.0, 1e-3) - 5.0).abs() < 1e-3);
/// assert!(smooth_abs(-3.0, 0.1) > 3.0);
/// assert_eq!(smooth_abs(-3.0, 0.0), 3.0);
/// ```
#[inline]
pub fn smooth_abs(x: f64, eps: f64) -> f64 {
    if eps <= 0.0 {
        return x.abs();
    }
    (x * x + eps * eps).sqrt()
}

/// Analytic derivative of [`smooth_abs`] with respect to `x`.
///
/// Equals `x / sqrt(x² + eps²)`, a smooth approximation of `sign(x)` that
/// passes continuously through `0` at the origin. When `eps <= 0`, returns
/// hard `sign(x)` with `0.0` at the origin.
///
/// # Example
///
/// ```rust
/// use entropyk_core::smoothing::smooth_abs_derivative;
///
/// assert_eq!(smooth_abs_derivative(0.0, 1e-3), 0.0);
/// assert!(smooth_abs_derivative(10.0, 1e-3) > 0.99);
/// assert!(smooth_abs_derivative(-10.0, 1e-3) < -0.99);
/// ```
#[inline]
pub fn smooth_abs_derivative(x: f64, eps: f64) -> f64 {
    if x.is_nan() || eps.is_nan() {
        return f64::NAN;
    }
    if eps <= 0.0 {
        return if x > 0.0 {
            1.0
        } else if x < 0.0 {
            -1.0
        } else {
            0.0
        };
    }
    let denom = (x * x + eps * eps).sqrt();
    if denom == 0.0 {
        return 0.0;
    }
    if denom.is_infinite() {
        return x.signum();
    }
    x / denom
}

/// C¹ clamp of `x` into `[lo, hi]` with a smooth transition of width `width`
/// at each bound.
///
/// Outside the transition zones the result equals `x` (for `lo + width <= x <=
/// hi - width`) or saturates to `lo` / `hi`. Within `width` of a bound it eases
/// smoothly via [`smoothstep`], so the first derivative is continuous (unlike
/// `f64::clamp`, whose derivative jumps between `0` and `1`). The output is
/// always within `[lo, hi]`. For a flat identity region to exist, pass
/// `width <= (hi - lo) / 2`; larger widths still produce a bounded, C¹ result.
///
/// Degenerate cases (never panic):
/// - `hi < lo` → bounds are swapped
/// - `width <= 0` → hard `x.clamp(lo, hi)`
///
/// # Example
///
/// ```rust
/// use entropyk_core::smoothing::smooth_clamp;
///
/// // Interior values pass through unchanged.
/// assert!((smooth_clamp(0.0, -1.0, 1.0, 0.1) - 0.0).abs() < 1e-9);
/// // Values well outside saturate to the bounds.
/// assert!((smooth_clamp(5.0, -1.0, 1.0, 0.1) - 1.0).abs() < 1e-9);
/// assert!((smooth_clamp(-5.0, -1.0, 1.0, 0.1) + 1.0).abs() < 1e-9);
/// assert_eq!(smooth_clamp(5.0, -1.0, 1.0, 0.0), 1.0);
/// ```
#[inline]
pub fn smooth_clamp(x: f64, lo: f64, hi: f64, width: f64) -> f64 {
    if x.is_nan() || lo.is_nan() || hi.is_nan() || width.is_nan() {
        return f64::NAN;
    }
    let (lo, hi) = if hi < lo { (hi, lo) } else { (lo, hi) };
    if width <= 0.0 {
        return x.clamp(lo, hi);
    }
    let max_width = (hi - lo) / 2.0;
    let width = width.min(max_width);
    // Lower ramp: equals `lo` for x <= lo, eases to identity `x` by x = lo + width.
    // Stays within [lo, x] and is C¹ at both joins (zero slope at lo, unit slope
    // at lo + width).
    let lower = lo + (x - lo) * smoothstep(lo, lo + width, x);
    // Upper ramp: equals `hi` for v >= hi, eases to identity `v` by v = hi - width.
    hi + (lower - hi) * smoothstep(hi, hi - width, lower)
}

/// Analytic derivative of [`smooth_clamp`] with respect to `x`.
///
/// Chain-rules the lower then upper [`smoothstep`] ramps. Outside the
/// transition bands the slope is `0` (saturated) or `1` (identity interior).
/// Degenerate parameters match [`smooth_clamp`]: hard-clamp derivative is `1`
/// on the open interior and `0` elsewhere (including the kink points).
///
/// # Example
///
/// ```rust
/// use entropyk_core::smoothing::smooth_clamp_derivative;
///
/// // Flat exterior, unit slope deep interior.
/// assert!((smooth_clamp_derivative(5.0, -1.0, 1.0, 0.1)).abs() < 1e-9);
/// assert!((smooth_clamp_derivative(0.0, -1.0, 1.0, 0.1) - 1.0).abs() < 1e-9);
/// ```
#[inline]
pub fn smooth_clamp_derivative(x: f64, lo: f64, hi: f64, width: f64) -> f64 {
    if x.is_nan() || lo.is_nan() || hi.is_nan() || width.is_nan() {
        return f64::NAN;
    }
    let (lo, hi) = if hi < lo { (hi, lo) } else { (lo, hi) };
    if width <= 0.0 {
        return if x > lo && x < hi { 1.0 } else { 0.0 };
    }
    let max_width = (hi - lo) / 2.0;
    let width = width.min(max_width);
    let s_lo = smoothstep(lo, lo + width, x);
    let d_lower = s_lo + (x - lo) * smoothstep_derivative(lo, lo + width, x);
    let lower = lo + (x - lo) * s_lo;
    let s_hi = smoothstep(hi, hi - width, lower);
    let ds_hi = smoothstep_derivative(hi, hi - width, lower);
    d_lower * (s_hi + (lower - hi) * ds_hi)
}

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

    /// Central finite-difference derivative helper.
    fn fd(f: impl Fn(f64) -> f64, x: f64, h: f64) -> f64 {
        (f(x + h) - f(x - h)) / (2.0 * h)
    }

    #[test]
    fn test_smoothstep_endpoints_and_midpoint() {
        assert_eq!(smoothstep(0.0, 1.0, -10.0), 0.0);
        assert_eq!(smoothstep(0.0, 1.0, 10.0), 1.0);
        assert_eq!(smoothstep(0.0, 1.0, 0.0), 0.0);
        assert_eq!(smoothstep(0.0, 1.0, 1.0), 1.0);
        assert!((smoothstep(0.0, 1.0, 0.5) - 0.5).abs() < 1e-12);
        // Symmetry: f(t) + f(1 - t) == 1
        for t in [0.1, 0.25, 0.4] {
            assert!((smoothstep(0.0, 1.0, t) + smoothstep(0.0, 1.0, 1.0 - t) - 1.0).abs() < 1e-12);
        }
    }

    #[test]
    fn test_smoothstep_monotonic() {
        let mut prev = -1.0;
        for i in 0..=100 {
            let x = i as f64 / 100.0;
            let v = smoothstep(0.0, 1.0, x);
            assert!(v >= prev - 1e-12, "smoothstep not monotonic at {}", x);
            prev = v;
        }
    }

    #[test]
    fn test_smoothstep_c1_at_edges() {
        // The defining property: zero slope at both edges => C¹ join with flats.
        assert_eq!(smoothstep_derivative(2.0, 5.0, 2.0), 0.0);
        assert_eq!(smoothstep_derivative(2.0, 5.0, 5.0), 0.0);
        assert_eq!(smoothstep_derivative(2.0, 5.0, 1.0), 0.0);
        assert_eq!(smoothstep_derivative(2.0, 5.0, 6.0), 0.0);
        assert!(smoothstep_derivative(2.0, 5.0, 3.5) > 0.0);
    }

    #[test]
    fn test_smoothstep_derivative_matches_fd() {
        let (e0, e1) = (2.0, 5.0);
        for x in [2.3, 3.0, 3.5, 4.0, 4.7] {
            let analytic = smoothstep_derivative(e0, e1, x);
            let numeric = fd(|x| smoothstep(e0, e1, x), x, 1e-6);
            assert!(
                (analytic - numeric).abs() < 1e-5,
                "smoothstep deriv mismatch at {}: {} vs {}",
                x,
                analytic,
                numeric
            );
        }
    }

    #[test]
    fn test_smootherstep_endpoints_and_midpoint() {
        assert_eq!(smootherstep(0.0, 1.0, -10.0), 0.0);
        assert_eq!(smootherstep(0.0, 1.0, 10.0), 1.0);
        assert!((smootherstep(0.0, 1.0, 0.5) - 0.5).abs() < 1e-12);
    }

    #[test]
    fn test_smootherstep_first_derivative_matches_fd() {
        let (e0, e1) = (-1.0, 3.0);
        for x in [-0.5, 0.0, 1.0, 2.0, 2.5] {
            let analytic = smootherstep_derivative(e0, e1, x);
            let numeric = fd(|x| smootherstep(e0, e1, x), x, 1e-6);
            assert!(
                (analytic - numeric).abs() < 1e-5,
                "smootherstep deriv mismatch at {}: {} vs {}",
                x,
                analytic,
                numeric
            );
        }
    }

    #[test]
    fn test_smootherstep_second_derivative_matches_fd_and_zero_at_edges() {
        let (e0, e1) = (-1.0, 3.0);
        // C²: both first and second derivatives vanish at the edges.
        assert_eq!(smootherstep_derivative(e0, e1, e0), 0.0);
        assert_eq!(smootherstep_derivative(e0, e1, e1), 0.0);
        assert_eq!(smootherstep_second_derivative(e0, e1, e0), 0.0);
        assert_eq!(smootherstep_second_derivative(e0, e1, e1), 0.0);
        // Interior: second derivative matches finite difference of first derivative.
        for x in [-0.5, 0.0, 1.0, 2.0, 2.5] {
            let analytic = smootherstep_second_derivative(e0, e1, x);
            let numeric = fd(|x| smootherstep_derivative(e0, e1, x), x, 1e-5);
            assert!(
                (analytic - numeric).abs() < 1e-3,
                "smootherstep 2nd deriv mismatch at {}: {} vs {}",
                x,
                analytic,
                numeric
            );
        }
    }

    #[test]
    fn test_cubic_blend_bounds_and_derivative() {
        assert_eq!(cubic_blend(10.0, 20.0, -1.0, 0.0, 1.0), 10.0);
        assert_eq!(cubic_blend(10.0, 20.0, 2.0, 0.0, 1.0), 20.0);
        assert!((cubic_blend(10.0, 20.0, 0.5, 0.0, 1.0) - 15.0).abs() < 1e-12);
        // Derivative vs finite difference.
        for x in [0.2, 0.5, 0.8] {
            let analytic = cubic_blend_derivative(10.0, 20.0, x, 0.0, 1.0);
            let numeric = fd(|x| cubic_blend(10.0, 20.0, x, 0.0, 1.0), x, 1e-6);
            assert!((analytic - numeric).abs() < 1e-4);
        }
    }

    #[test]
    fn test_quintic_blend_bounds_and_derivative() {
        assert_eq!(quintic_blend(10.0, 20.0, -1.0, 0.0, 1.0), 10.0);
        assert_eq!(quintic_blend(10.0, 20.0, 2.0, 0.0, 1.0), 20.0);
        for x in [0.2, 0.5, 0.8] {
            let analytic = quintic_blend_derivative(10.0, 20.0, x, 0.0, 1.0);
            let numeric = fd(|x| quintic_blend(10.0, 20.0, x, 0.0, 1.0), x, 1e-6);
            assert!((analytic - numeric).abs() < 1e-4);
        }
    }

    #[test]
    fn test_reynolds_transition_is_c1() {
        // Realistic use case: blend laminar 64/Re into a turbulent f over
        // [2300, 4000]. The blended function must be continuous in value AND
        // slope across the whole range (no PR#563-style jump).
        let f_turb = 0.04;
        let blend = |re: f64| cubic_blend(64.0 / re.max(1.0), f_turb, re, 2300.0, 4000.0);
        let mut prev = blend(2200.0);
        let mut prev_slope = fd(blend, 2200.0, 1.0);
        for re_i in (2300..=4000).step_by(50) {
            let re = re_i as f64;
            let v = blend(re);
            let slope = fd(blend, re, 1.0);
            // Value continuity (no big jumps between samples).
            assert!((v - prev).abs() < 0.05, "value jump near Re={}", re);
            // Slope continuity (no big slope jumps between samples).
            assert!(
                (slope - prev_slope).abs() < 5e-3,
                "slope jump near Re={}: {} vs {}",
                re,
                slope,
                prev_slope
            );
            prev = v;
            prev_slope = slope;
        }
    }

    #[test]
    fn test_smooth_max_min_properties() {
        // Upper / lower bound property.
        for (a, b) in [(5.0, 1.0), (-2.0, 3.0), (0.0, 0.0)] {
            assert!(smooth_max(a, b, 0.1) >= a.max(b) - 1e-12);
            assert!(smooth_min(a, b, 0.1) <= a.min(b) + 1e-12);
        }
        // Converges to hard max/min as k -> 0.
        assert!((smooth_max(5.0, 1.0, 1e-4) - 5.0).abs() < 1e-3);
        assert!((smooth_min(5.0, 1.0, 1e-4) - 1.0).abs() < 1e-3);
        // Symmetry.
        assert!((smooth_max(2.0, 7.0, 0.5) - smooth_max(7.0, 2.0, 0.5)).abs() < 1e-12);
        // Overshoot at a == b is exactly k/2.
        assert!((smooth_max(3.0, 3.0, 0.2) - (3.0 + 0.1)).abs() < 1e-12);
    }

    #[test]
    fn test_smooth_max_derivative_matches_fd() {
        let (b, k): (f64, f64) = (1.0, 0.3);
        for a in [-1.0, 0.5, 1.0, 1.5, 3.0] {
            let analytic = smooth_max_derivative(a, b, k);
            let numeric = fd(|a| smooth_max(a, b, k), a, 1e-6);
            assert!((analytic - numeric).abs() < 1e-5, "at a={}", a);
        }
    }

    #[test]
    fn test_smooth_min_derivative_matches_fd() {
        let (b, k): (f64, f64) = (1.0, 0.3);
        for a in [-1.0, 0.5, 1.0, 1.5, 3.0] {
            let analytic = smooth_min_derivative(a, b, k);
            let numeric = fd(|a| smooth_min(a, b, k), a, 1e-6);
            assert!((analytic - numeric).abs() < 1e-5, "at a={}", a);
        }
    }

    #[test]
    fn test_smooth_clamp_derivative_matches_fd() {
        let (lo, hi, width) = (-1.0, 1.0, 0.1);
        // Exterior, both ramps, deep interior.
        for x in [-5.0, -1.05, -0.95, -0.5, 0.0, 0.5, 0.95, 1.05, 5.0] {
            let analytic = smooth_clamp_derivative(x, lo, hi, width);
            let numeric = fd(|x| smooth_clamp(x, lo, hi, width), x, 1e-6);
            assert!(
                (analytic - numeric).abs() < 1e-5,
                "smooth_clamp deriv mismatch at {}: {} vs {}",
                x,
                analytic,
                numeric
            );
        }
    }

    #[test]
    fn test_smooth_clamp_c1_at_junctions() {
        // Slope continuity across the four joins: lo, lo+width, hi-width, hi.
        let (lo, hi, width) = (-1.0, 1.0, 0.2);
        let junctions = [lo, lo + width, hi - width, hi];
        let h = 1e-5;
        for j in junctions {
            let slope_left = fd(|x| smooth_clamp(x, lo, hi, width), j - h, h);
            let slope_right = fd(|x| smooth_clamp(x, lo, hi, width), j + h, h);
            let analytic = smooth_clamp_derivative(j, lo, hi, width);
            assert!(
                (slope_left - slope_right).abs() < 5e-3,
                "slope jump at {}: left={} right={}",
                j,
                slope_left,
                slope_right
            );
            assert!(
                (analytic - 0.5 * (slope_left + slope_right)).abs() < 5e-3,
                "analytic slope mismatch at {}: analytic={} fd_avg={}",
                j,
                analytic,
                0.5 * (slope_left + slope_right)
            );
        }
    }

    #[test]
    fn test_degenerate_parameters_do_not_panic() {
        assert_eq!(smooth_max(3.0, 1.0, 0.0), 3.0);
        assert_eq!(smooth_max(3.0, 1.0, -0.5), 3.0);
        assert_eq!(smooth_min(3.0, 1.0, -1.0), 1.0);
        assert_eq!(smooth_abs(-2.0, 0.0), 2.0);
        assert_eq!(smooth_abs_derivative(-2.0, 0.0), -1.0);
        assert_eq!(smooth_clamp(5.0, -1.0, 1.0, 0.0), 1.0);
        assert_eq!(smooth_clamp_derivative(0.0, -1.0, 1.0, 0.0), 1.0);
        assert_eq!(smooth_clamp_derivative(5.0, -1.0, 1.0, 0.0), 0.0);
        // Inverted bounds swap.
        assert!((smooth_clamp(0.0, 1.0, -1.0, 0.1) - 0.0).abs() < 1e-9);
        assert_eq!(smooth_max_derivative(3.0, 1.0, 0.0), 1.0);
        assert_eq!(smooth_min_derivative(3.0, 1.0, 0.0), 0.0);
    }

    #[test]
    fn test_smooth_abs_properties_and_derivative() {
        assert!((smooth_abs(0.0, 1e-3) - 1e-3).abs() < 1e-12);
        assert!((smooth_abs(5.0, 1e-3) - 5.0).abs() < 1e-3);
        // Always an upper bound on |x|.
        for x in [-4.0, -0.1, 0.0, 0.1, 4.0] {
            assert!(smooth_abs(x, 0.2) >= x.abs() - 1e-12);
        }
        // Derivative passes smoothly through 0.
        assert_eq!(smooth_abs_derivative(0.0, 1e-3), 0.0);
        for x in [-2.0, -0.5, 0.5, 2.0] {
            let numeric = fd(|x| smooth_abs(x, 0.1), x, 1e-6);
            assert!(
                (smooth_abs_derivative(x, 0.1) - numeric).abs() < 1e-5,
                "at x={}",
                x
            );
        }
    }

    #[test]
    fn test_smooth_clamp_saturates_and_passes_interior() {
        // Interior pass-through.
        assert!((smooth_clamp(0.0, -1.0, 1.0, 0.1) - 0.0).abs() < 1e-9);
        assert!((smooth_clamp(0.5, -1.0, 1.0, 0.1) - 0.5).abs() < 1e-9);
        // Saturation outside.
        assert!((smooth_clamp(5.0, -1.0, 1.0, 0.1) - 1.0).abs() < 1e-9);
        assert!((smooth_clamp(-5.0, -1.0, 1.0, 0.1) + 1.0).abs() < 1e-9);
        // Output stays within [lo, hi].
        for i in -200..=200 {
            let x = i as f64 / 50.0;
            let y = smooth_clamp(x, -1.0, 1.0, 0.1);
            assert!(
                (-1.0 - 1e-9..=1.0 + 1e-9).contains(&y),
                "out of bounds at x={}: {}",
                x,
                y
            );
        }
    }

    #[test]
    fn test_zero_width_window_degenerates_to_step() {
        // Degenerate window must not panic and behaves as a hard step.
        assert_eq!(smoothstep(3.0, 3.0, 2.9), 0.0);
        assert_eq!(smoothstep(3.0, 3.0, 3.1), 1.0);
        assert_eq!(smoothstep_derivative(3.0, 3.0, 3.1), 0.0);
        assert_eq!(smootherstep(3.0, 3.0, 3.1), 1.0);
    }

    #[test]
    fn test_code_review_patches() {
        // 1. NaN propagation (no panic)
        assert!(smooth_max_derivative(f64::NAN, 1.0, 0.1).is_nan());
        assert!(smooth_min_derivative(1.0, f64::NAN, 0.1).is_nan());
        assert!(smooth_abs_derivative(f64::NAN, 0.1).is_nan());
        assert!(smooth_clamp(f64::NAN, 0.0, 1.0, 0.1).is_nan());
        assert!(smooth_clamp_derivative(f64::NAN, 0.0, 1.0, 0.1).is_nan());
        assert!(smooth_clamp(0.5, f64::NAN, 1.0, 0.1).is_nan());

        // 2. NaN clamp degenerate
        assert!(smooth_clamp(f64::NAN, 0.0, 1.0, -0.1).is_nan());

        // 3. Subnormal / zero division
        assert_eq!(smooth_max_derivative(1.0, 1.0, 0.0), 0.5);
        assert_eq!(smooth_min_derivative(1.0, 1.0, 0.0), 0.5);
        assert_eq!(smooth_abs_derivative(0.0, 0.0), 0.0);

        // 4. Overlapping bounds width capping
        // Standard [0.0, 1.0] with too large width = 2.0 -> caps to 0.5
        let val_neg_inf = smooth_clamp(-1e10, 0.0, 1.0, 2.0);
        assert!((val_neg_inf - 0.0).abs() < 1e-9);
        let val_pos_inf = smooth_clamp(1e10, 0.0, 1.0, 2.0);
        assert!((val_pos_inf - 1.0).abs() < 1e-9);
    }
}
