Skip to main content

solmath/
arithmetic.rs

1use crate::constants::*;
2use crate::double_word::DoubleWord;
3use crate::error::SolMathError;
4use crate::overflow::{checked_mul_div_i, checked_mul_div_u, checked_mul_div_rem_u};
5
6/// Unsigned fixed-point multiply: `(a * b) / SCALE`.
7///
8/// Both `a` and `b` are `u128` values at SCALE (1e12).
9/// Returns the product at SCALE. Error: 1 ULP max (truncation toward zero).
10///
11/// Returns `Err(Overflow)` if the result exceeds `u128::MAX`.
12///
13/// # Example
14/// ```
15/// use solmath::{fp_mul, SCALE};
16/// let result = fp_mul(2 * SCALE, 3 * SCALE).unwrap();
17/// assert_eq!(result, 6 * SCALE);
18/// # Ok::<(), solmath::SolMathError>(())
19/// ```
20#[inline]
21pub fn fp_mul(a: u128, b: u128) -> Result<u128, SolMathError> {
22    match a.checked_mul(b) {
23        Some(p) => Ok(p / SCALE),
24        None => checked_mul_div_u(a, b, SCALE).ok_or(SolMathError::Overflow),
25    }
26}
27
28/// Signed fixed-point multiply: `(a * b) / SCALE`.
29///
30/// Both `a` and `b` are `i128` values at SCALE (1e12).
31/// Returns the product at SCALE. Exact (truncation toward zero).
32///
33/// Returns `Err(Overflow)` if the result exceeds `i128` range.
34///
35/// # Example
36/// ```
37/// use solmath::{fp_mul_i, SCALE_I};
38/// let result = fp_mul_i(-2 * SCALE_I, 3 * SCALE_I).unwrap();
39/// assert_eq!(result, -6 * SCALE_I);
40/// ```
41#[inline]
42pub fn fp_mul_i(a: i128, b: i128) -> Result<i128, SolMathError> {
43    match a.checked_mul(b) {
44        Some(p) => Ok(p / SCALE_I),
45        None => checked_mul_div_i(a, b, SCALE_I),
46    }
47}
48
49/// Internal unchecked fixed-point multiply. Inputs MUST be bounded
50/// such that |a * b| < i128::MAX. No overflow check — caller's
51/// responsibility. Not exposed in public API.
52#[allow(dead_code)]
53#[inline]
54pub(crate) fn fp_mul_i_fast(a: i128, b: i128) -> i128 {
55    a * b / SCALE_I
56}
57
58/// Signed fixed-point multiply with rounding: round((a × b) / SCALE).
59/// Error: ≤ 0.5 ULP. Returns `Err(Overflow)` if the result exceeds `i128` range.
60#[inline]
61pub fn fp_mul_i_round(a: i128, b: i128) -> Result<i128, SolMathError> {
62    match a.checked_mul(b) {
63        Some(p) => {
64            if p >= 0 {
65                match p.checked_add(SCALE_I / 2) {
66                    Some(v) => Ok(v / SCALE_I),
67                    None => Ok(p / SCALE_I + 1),
68                }
69            } else {
70                match p.checked_sub(SCALE_I / 2) {
71                    Some(v) => Ok(v / SCALE_I),
72                    None => Ok(p / SCALE_I - 1),
73                }
74            }
75        }
76        None => {
77            // Overflow path: use U256 intermediate with correct rounding.
78            let neg = (a < 0) != (b < 0);
79            let (q, rem) = checked_mul_div_rem_u(a.unsigned_abs(), b.unsigned_abs(), SCALE)
80                .ok_or(SolMathError::Overflow)?;
81            let rounded = if rem >= SCALE / 2 {
82                q.checked_add(1).ok_or(SolMathError::Overflow)?
83            } else {
84                q
85            };
86            if neg {
87                if rounded == (1u128 << 127) { Ok(i128::MIN) }
88                else if rounded < (1u128 << 127) { Ok(-(rounded as i128)) }
89                else { Err(SolMathError::Overflow) }
90            } else if rounded <= i128::MAX as u128 { Ok(rounded as i128) }
91            else { Err(SolMathError::Overflow) }
92        }
93    }
94}
95
96/// Unsigned fixed-point division: `(a * SCALE) / b`.
97///
98/// Both `a` and `b` are `u128` values at SCALE (1e12).
99/// Returns the quotient at SCALE. Error: 1 ULP max (truncation toward zero).
100///
101/// Returns `Err(DivisionByZero)` if `b == 0`, or `Err(Overflow)` if the result exceeds `u128::MAX`.
102///
103/// # Example
104/// ```
105/// use solmath::{fp_div, SCALE};
106/// let result = fp_div(10 * SCALE, 2 * SCALE).unwrap();
107/// assert_eq!(result, 5 * SCALE);
108/// # Ok::<(), solmath::SolMathError>(())
109/// ```
110#[inline]
111pub fn fp_div(a: u128, b: u128) -> Result<u128, SolMathError> {
112    if b == 0 {
113        return Err(SolMathError::DivisionByZero);
114    }
115    fp_div_rem_experimental_u(a, b)
116        .map(|(q, _)| q)
117        .ok_or(SolMathError::Overflow)
118}
119
120/// Signed fixed-point division: (a × SCALE) / b.
121/// Error: < 1 ULP (truncation). Returns Err on division by zero or overflow.
122#[inline]
123pub fn fp_div_i(a: i128, b: i128) -> Result<i128, SolMathError> {
124    if b == 0 {
125        return Err(SolMathError::DivisionByZero);
126    }
127    if a == 0 {
128        return Ok(0);
129    }
130
131    let neg = (a < 0) ^ (b < 0);
132    let mag = fp_div_rem_experimental_u(a.unsigned_abs(), b.unsigned_abs());
133
134    match mag {
135        Some((q, _)) => {
136            if neg {
137                if q == (1u128 << 127) {
138                    Ok(i128::MIN)
139                } else if q < (1u128 << 127) {
140                    Ok(-(q as i128))
141                } else {
142                    Err(SolMathError::Overflow)
143                }
144            } else if q <= i128::MAX as u128 {
145                Ok(q as i128)
146            } else {
147                Err(SolMathError::Overflow)
148            }
149        }
150        None => Err(SolMathError::Overflow),
151    }
152}
153
154/// Unsigned fixed-point floor division: (a × SCALE) / b, truncated.
155/// Exact: 0 ULP (unsigned truncation is floor). Returns Err on division by zero or overflow.
156#[inline]
157pub fn fp_div_floor(a: u128, b: u128) -> Result<u128, SolMathError> {
158    fp_div(a, b) // unsigned truncation is already floor
159}
160
161/// Unsigned fixed-point ceil division: (a × SCALE) / b, rounded up.
162/// Exact: 0 ULP (exact ceil). Returns Err on division by zero or overflow.
163#[inline]
164pub fn fp_div_ceil(a: u128, b: u128) -> Result<u128, SolMathError> {
165    if b == 0 {
166        return Err(SolMathError::DivisionByZero);
167    }
168    match fp_div_rem_experimental_u(a, b) {
169        Some((q, rem)) => {
170            if rem > 0 { Ok(q.checked_add(1).ok_or(SolMathError::Overflow)?) } else { Ok(q) }
171        }
172        None => Err(SolMathError::Overflow),
173    }
174}
175
176/// Rounding signed division: (a × SCALE) / b, rounded to nearest.
177/// Same as fp_div_i but rounds instead of truncating. Returns `Err(Overflow)` on overflow.
178/// Internal — called by mills_ratio_cf8.
179#[allow(dead_code)]
180#[inline]
181pub(crate) fn fp_div_i_round(a: i128, b: i128) -> Result<i128, SolMathError> {
182    if b == 0 { return Err(SolMathError::DivisionByZero); }
183    if a == 0 { return Ok(0); }
184
185    let neg = (a < 0) ^ (b < 0);
186    let (q, r) = fp_div_rem_experimental_u(a.unsigned_abs(), b.unsigned_abs())
187        .ok_or(SolMathError::Overflow)?;
188    let round_up = r >= b.unsigned_abs() / 2;
189    let q = if round_up { q.checked_add(1).ok_or(SolMathError::Overflow)? } else { q };
190    if neg {
191        if q == (1u128 << 127) { Ok(i128::MIN) }
192        else if q < (1u128 << 127) { Ok(-(q as i128)) }
193        else { Err(SolMathError::Overflow) }
194    } else if q <= i128::MAX as u128 { Ok(q as i128) }
195    else { Err(SolMathError::Overflow) }
196}
197
198/// Fractional tail of fixed-point division. Internal — called by fp_div_rem_experimental_u.
199#[inline]
200pub(crate) fn fp_div_fractional_tail_u(a: u128, b: u128) -> Option<(u128, u128)> {
201    debug_assert!(b != 0);
202    if a == 0 {
203        return Some((0, 0));
204    }
205    if a <= FP_DIV_THIN_MAX {
206        // a ≤ FP_DIV_THIN_MAX = u128::MAX / SCALE; a * SCALE ≤ u128::MAX, no overflow
207        let scaled = a * SCALE;
208        return Some((scaled / b, scaled % b));
209    }
210    checked_mul_div_rem_u(a, SCALE, b)
211}
212
213/// Three-path fixed-point division with remainder. Internal — core of fp_div.
214#[inline]
215pub(crate) fn fp_div_rem_experimental_u(a: u128, b: u128) -> Option<(u128, u128)> {
216    if b == 0 {
217        return None;
218    }
219    if a == 0 {
220        return Some((0, 0));
221    }
222
223    // Cheapest path when the classic scaled intermediate still fits.
224    if a <= FP_DIV_THIN_MAX {
225        // a ≤ FP_DIV_THIN_MAX = u128::MAX / SCALE; a * SCALE ≤ u128::MAX, no overflow
226        let scaled = a * SCALE;
227        return Some((scaled / b, scaled % b));
228    }
229
230    // Quotient/remainder decomposition:
231    // a = q*b + r  =>  (a*SCALE)/b = q*SCALE + (r*SCALE)/b
232    // This avoids forming the huge scaled product on many overflow-prone inputs.
233    let q = a / b;
234    if q == 0 {
235        return checked_mul_div_rem_u(a, SCALE, b);
236    }
237    if q > FP_DIV_THIN_MAX {
238        return None;
239    }
240
241    // q = a / b, so q * b ≤ a; remainder r = a - q*b ≥ 0 and < b; no underflow
242    let r = a - q * b;
243    // q ≤ FP_DIV_THIN_MAX = u128::MAX / SCALE (checked above); q * SCALE ≤ u128::MAX, no overflow
244    let base = q * SCALE;
245    if r == 0 {
246        return Some((base, 0));
247    }
248
249    let (frac, rem) = fp_div_fractional_tail_u(r, b)?;
250    Some((base.checked_add(frac)?, rem))
251}
252
253/// Integer square root via Newton's method. Internal — used by fp_sqrt.
254#[allow(dead_code)]
255#[inline]
256pub(crate) fn isqrt_u128(n: u128) -> u128 {
257    if n == 0 {
258        return 0;
259    }
260    let mut x = 1u128 << ((128 - n.leading_zeros() + 1) / 2);
261    loop {
262        let x1 = (x + n / x) / 2;
263        if x1 >= x {
264            return x;
265        }
266        x = x1;
267    }
268}
269
270/// Newton's method sqrt on pre-scaled input. Internal — used by fp_sqrt fast path.
271pub(crate) fn sqrt_scaled_newton(scaled: u128) -> u128 {
272    let bit_len = 128 - scaled.leading_zeros();
273    let mut guess: u128 = 1u128 << ((bit_len + 1) / 2);
274    for _ in 0..8 {
275        if guess == 0 {
276            break;
277        }
278        let new_guess = (guess + scaled / guess) / 2;
279        if new_guess == guess || new_guess + 1 == guess {
280            guess = guess.min(new_guess);
281            break;
282        }
283        guess = new_guess;
284    }
285    debug_assert!(
286        guess.checked_mul(guess).map_or(false, |sq| sq <= scaled)
287            && (guess + 1).checked_mul(guess + 1).map_or(true, |sq| sq > scaled),
288        "sqrt_scaled_newton: post-check failed for scaled={}, guess={}",
289        scaled, guess
290    );
291    guess
292}
293
294/// Widening 128×128 → 256-bit multiply. Internal — used by fp_sqrt overflow path.
295pub(crate) fn wide_mul_u128(a: u128, b: u128) -> (u128, u128) {
296    let mask = u128::from(u64::MAX);
297    let a0 = a & mask;
298    let a1 = a >> 64;
299    let b0 = b & mask;
300    let b1 = b >> 64;
301
302    let p0 = a0 * b0;
303    let p1 = a0 * b1;
304    let p2 = a1 * b0;
305    let p3 = a1 * b1;
306
307    let carry0 = p0 >> 64;
308    let mid = (p1 & mask) + (p2 & mask) + carry0;
309    let lo = (p0 & mask) | ((mid & mask) << 64);
310    let hi = p3 + (p1 >> 64) + (p2 >> 64) + (mid >> 64);
311    (hi, lo)
312}
313
314/// Compare two 256-bit (hi, lo) pairs. Internal — used by fp_sqrt bisection.
315pub(crate) fn cmp_wide(lhs: (u128, u128), rhs: (u128, u128)) -> core::cmp::Ordering {
316    if lhs.0 != rhs.0 {
317        lhs.0.cmp(&rhs.0)
318    } else {
319        lhs.1.cmp(&rhs.1)
320    }
321}
322
323/// Check if candidate² vs x×SCALE via wide multiply. Internal — used by fp_sqrt.
324pub(crate) fn cmp_sqrt_candidate(candidate: u128, x: u128) -> core::cmp::Ordering {
325    cmp_wide(wide_mul_u128(candidate, candidate), wide_mul_u128(x, SCALE))
326}
327
328/// Fixed-point square root: `sqrt(x)` at SCALE.
329///
330/// `x` is a `u128` value at SCALE (1e12). Returns `sqrt(x)` at SCALE.
331/// Error: 1 ULP max. Returns `Ok(0)` if `x == 0`.
332///
333/// # Example
334/// ```
335/// use solmath::{fp_sqrt, SCALE};
336/// // sqrt(4.0) = 2.0
337/// let result = fp_sqrt(4 * SCALE).unwrap();
338/// assert_eq!(result, 2 * SCALE);
339/// # Ok::<(), solmath::SolMathError>(())
340/// ```
341pub fn fp_sqrt(x: u128) -> Result<u128, SolMathError> {
342    if x == 0 {
343        return Ok(0);
344    }
345
346    if let Some(scaled) = x.checked_mul(SCALE) {
347        return Ok(sqrt_scaled_newton(scaled));
348    }
349
350    let mut reduced = x;
351    let mut scale_back = 1u128;
352    while reduced > u128::MAX / SCALE {
353        // sqrt(4a) = 2*sqrt(a); round while reducing to preserve monotonicity.
354        reduced = (reduced + 2) / 4;
355        scale_back = scale_back.checked_mul(2).ok_or(SolMathError::Overflow)?;
356    }
357
358    let approx = sqrt_scaled_newton(reduced * SCALE)
359        .checked_mul(scale_back).ok_or(SolMathError::Overflow)?;
360
361    let mut low = approx.checked_sub(scale_back).ok_or(SolMathError::Overflow)?;
362    while cmp_sqrt_candidate(low, x).is_gt() {
363        if low == 0 {
364            break;
365        }
366        low = low.checked_sub(scale_back).ok_or(SolMathError::Overflow)?;
367    }
368
369    let mut high = approx.checked_add(scale_back).ok_or(SolMathError::Overflow)?;
370    while !cmp_sqrt_candidate(high, x).is_gt() {
371        low = high;
372        high = high.checked_add(scale_back).ok_or(SolMathError::Overflow)?;
373    }
374
375    while low + 1 < high {
376        let mid = low + (high - low) / 2;
377        if cmp_sqrt_candidate(mid, x).is_gt() {
378            high = mid;
379        } else {
380            low = mid;
381        }
382    }
383
384    Ok(low)
385}
386
387/// Unsigned fixed-point multiply with rounding: round((a × b) / SCALE).
388/// Error: ≤ 0.5 ULP. Returns `Err(Overflow)` on overflow.
389#[inline]
390pub fn fp_mul_round(a: u128, b: u128) -> Result<u128, SolMathError> {
391    match checked_mul_div_rem_u(a, b, SCALE) {
392        Some((q, r)) => {
393            if r >= SCALE / 2 { q.checked_add(1).ok_or(SolMathError::Overflow) } else { Ok(q) }
394        }
395        None => Err(SolMathError::Overflow),
396    }
397}
398
399/// Signed fixed-point multiply returning a DoubleWord: exact result split
400/// into standard-precision quotient and sub-ULP remainder.
401///
402/// dw.hi == fp_mul_i_round(a, b) for all inputs where a*b fits in i128.
403/// dw.lo is the exact remainder: |dw.lo| < SCALE_I.
404/// true_product(a, b) / SCALE = dw.hi + dw.lo / SCALE.
405///
406/// For inputs where a*b overflows i128, this function still rounds correctly
407/// (unlike fp_mul_i_round which truncates on its overflow path).
408#[inline]
409pub fn fp_mul_i_round_dw(a: i128, b: i128) -> Result<DoubleWord, SolMathError> {
410    if a == 0 || b == 0 {
411        return Ok(DoubleWord::new_raw(0, 0));
412    }
413
414    let neg = (a < 0) != (b < 0);
415    let aa = a.unsigned_abs();
416    let bb = b.unsigned_abs();
417
418    let (q_trunc, r_trunc) = checked_mul_div_rem_u(aa, bb, SCALE)
419        .ok_or(SolMathError::Overflow)?;
420    // q_trunc = floor(|a*b| / SCALE), r_trunc in [0, SCALE)
421
422    let half = SCALE / 2;
423    let (q, lo) = if r_trunc >= half {
424        (q_trunc + 1, r_trunc as i128 - SCALE_I)  // lo in (-SCALE/2, 0]
425    } else {
426        (q_trunc, r_trunc as i128)                  // lo in [0, SCALE/2)
427    };
428
429    if neg {
430        if q == (1u128 << 127) {
431            if lo != 0 {
432                return Err(SolMathError::Overflow);
433            }
434            Ok(DoubleWord::new_raw(i128::MIN, 0))
435        } else if q < (1u128 << 127) {
436            Ok(DoubleWord::new_raw(-(q as i128), -lo))
437        } else {
438            Err(SolMathError::Overflow)
439        }
440    } else if q <= i128::MAX as u128 {
441        Ok(DoubleWord::new_raw(q as i128, lo))
442    } else {
443        Err(SolMathError::Overflow)
444    }
445}
446
447/// Unsigned fixed-point division with rounding: round((a × SCALE) / b).
448/// Error: ≤ 0.5 ULP. Returns Err on division by zero or overflow.
449#[inline]
450pub fn fp_div_round(a: u128, b: u128) -> Result<u128, SolMathError> {
451    if b == 0 {
452        return Err(SolMathError::DivisionByZero);
453    }
454    let (q, r) = fp_div_rem_experimental_u(a, b).ok_or(SolMathError::Overflow)?;
455    Ok(if r >= b / 2 {
456        q.checked_add(1).ok_or(SolMathError::Overflow)?
457    } else {
458        q
459    })
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use crate::constants::{SCALE, SCALE_I};
466    use crate::error::SolMathError;
467
468    // ===== fp_mul_round tests =====
469
470    #[test]
471    fn test_fp_mul_round_exact() {
472        assert_eq!(fp_mul_round(2 * SCALE, 3 * SCALE).unwrap(), 6 * SCALE);
473        assert_eq!(fp_mul_round(SCALE, SCALE).unwrap(), SCALE);
474        assert_eq!(fp_mul_round(0, SCALE).unwrap(), 0);
475        assert_eq!(fp_mul_round(SCALE, 0).unwrap(), 0);
476    }
477
478    #[test]
479    fn test_fp_mul_round_vs_truncating() {
480        let cases: &[(u128, u128)] = &[
481            (SCALE / 3, SCALE), (SCALE, SCALE / 3),
482            (SCALE / 7, SCALE / 11), (2 * SCALE, SCALE / 3),
483            (SCALE + 1, SCALE + 1), (SCALE * 1000, SCALE / 997),
484        ];
485        for &(a, b) in cases {
486            let trunc = fp_mul(a, b).unwrap();
487            let round = fp_mul_round(a, b).unwrap();
488            assert!(round == trunc || round == trunc + 1,
489                "a={}, b={}: trunc={}, round={}", a, b, trunc, round);
490        }
491    }
492
493    #[test]
494    fn test_fp_mul_round_large_no_panic() {
495        let large = u128::MAX / SCALE;
496        let _ = fp_mul_round(large, SCALE);
497        let _ = fp_mul_round(large, 2);
498        let _ = fp_mul_round(SCALE, large);
499    }
500
501    #[test]
502    fn test_fp_mul_round_overflow_is_error() {
503        assert!(matches!(fp_mul_round(u128::MAX, u128::MAX), Err(SolMathError::Overflow)));
504    }
505
506    // ===== fp_mul_i_round_dw tests =====
507
508    #[test]
509    fn test_dw_mul_quotient_matches_fp_mul_i_round() {
510        let values: &[i128] = &[
511            0, 1, -1, 2, -2,
512            SCALE_I / 2, -SCALE_I / 2,
513            SCALE_I, -SCALE_I,
514            SCALE_I + 1, -(SCALE_I + 1),
515            SCALE_I - 1, -(SCALE_I - 1),
516            SCALE_I * 2, -(SCALE_I * 2),
517            SCALE_I * 50, -(SCALE_I * 50),
518            SCALE_I / 3, -(SCALE_I / 3),
519            SCALE_I / 7, -(SCALE_I / 7),
520            999_999_999_999, -999_999_999_999,
521            500_000_000_001, -500_000_000_001,
522        ];
523        for &a in values {
524            for &b in values {
525                if a.checked_mul(b).is_some() {
526                    let expected = fp_mul_i_round(a, b).unwrap();
527                    let dw = fp_mul_i_round_dw(a, b).unwrap();
528                    assert_eq!(dw.hi(), expected,
529                        "Quotient mismatch: a={}, b={}, expected={}, got={}", a, b, expected, dw.hi());
530                }
531            }
532        }
533    }
534
535    #[test]
536    fn test_dw_mul_remainder_bounded() {
537        let values: &[i128] = &[
538            0, 1, -1, SCALE_I, -SCALE_I, SCALE_I / 3, -SCALE_I / 7,
539            SCALE_I * 50, -SCALE_I * 50, 999_999_999_999,
540        ];
541        for &a in values {
542            for &b in values {
543                let dw = fp_mul_i_round_dw(a, b).unwrap();
544                assert!(dw.lo().abs() < SCALE_I,
545                    "Remainder out of bounds: a={}, b={}, lo={}", a, b, dw.lo());
546            }
547        }
548    }
549
550    #[test]
551    fn test_dw_mul_remainder_sign_convention() {
552        let dw = fp_mul_i_round_dw(SCALE_I / 3, SCALE_I).unwrap();
553        assert!(dw.lo() >= 0, "Expected non-negative lo for 1/3: lo={}", dw.lo());
554
555        let dw2 = fp_mul_i_round_dw(2 * SCALE_I / 3, SCALE_I).unwrap();
556        assert!(dw2.lo().abs() < SCALE_I);
557    }
558
559    #[test]
560    fn test_dw_mul_zero() {
561        let dw = fp_mul_i_round_dw(0, SCALE_I).unwrap();
562        assert_eq!(dw.hi(), 0);
563        assert_eq!(dw.lo(), 0);
564        let dw2 = fp_mul_i_round_dw(SCALE_I, 0).unwrap();
565        assert_eq!(dw2.hi(), 0);
566        assert_eq!(dw2.lo(), 0);
567    }
568
569    #[test]
570    fn test_dw_mul_symmetry() {
571        let pos = fp_mul_i_round_dw(SCALE_I / 3, SCALE_I / 7).unwrap();
572        let neg = fp_mul_i_round_dw(-SCALE_I / 3, SCALE_I / 7).unwrap();
573        assert_eq!(pos.hi(), -neg.hi(), "hi not symmetric");
574        assert_eq!(pos.lo(), -neg.lo(), "lo not symmetric");
575    }
576
577    #[test]
578    fn test_dw_mul_allows_exact_i128_min() {
579        let dw = fp_mul_i_round_dw(i128::MIN, SCALE_I).unwrap();
580        assert_eq!(dw.hi(), i128::MIN);
581        assert_eq!(dw.lo(), 0);
582    }
583
584    #[test]
585    fn test_dw_mul_to_i128_matches_fp_mul_i_round() {
586        let values: &[i128] = &[
587            SCALE_I / 3, SCALE_I / 7, SCALE_I * 2, -SCALE_I / 3, -SCALE_I * 5,
588        ];
589        for &a in values {
590            for &b in values {
591                if a.checked_mul(b).is_some() {
592                    let direct = fp_mul_i_round(a, b).unwrap();
593                    let via_dw = fp_mul_i_round_dw(a, b).unwrap().to_i128();
594                    assert_eq!(direct, via_dw,
595                        "Roundtrip mismatch: a={}, b={}", a, b);
596                }
597            }
598        }
599    }
600
601    // ===== fp_div_round tests =====
602
603    #[test]
604    fn test_fp_div_round_exact() {
605        assert_eq!(fp_div_round(10 * SCALE, 5 * SCALE).unwrap(), 2 * SCALE);
606        assert_eq!(fp_div_round(SCALE, SCALE).unwrap(), SCALE);
607        assert_eq!(fp_div_round(0, SCALE).unwrap(), 0);
608    }
609
610    #[test]
611    fn test_fp_div_round_rounds_up() {
612        let t = fp_div(2 * SCALE, 3 * SCALE).unwrap();
613        let r = fp_div_round(2 * SCALE, 3 * SCALE).unwrap();
614        assert_eq!(r, t + 1, "2/3 should round up");
615    }
616
617    #[test]
618    fn test_fp_div_round_no_round() {
619        let t = fp_div(SCALE, 3 * SCALE).unwrap();
620        let r = fp_div_round(SCALE, 3 * SCALE).unwrap();
621        assert_eq!(r, t, "1/3 should not round up");
622    }
623
624    #[test]
625    fn test_fp_div_round_agrees_with_signed() {
626        let cases: &[(u128, u128)] = &[
627            (SCALE, 3 * SCALE), (2 * SCALE, 3 * SCALE),
628            (7 * SCALE, 11 * SCALE), (SCALE, 7 * SCALE),
629            (100 * SCALE, 97 * SCALE),
630        ];
631        for &(a, b) in cases {
632            let unsigned_r = fp_div_round(a, b).unwrap();
633            let signed_r = fp_div_i_round(a as i128, b as i128).unwrap();
634            assert_eq!(unsigned_r as i128, signed_r,
635                "Disagree for a={}, b={}", a, b);
636        }
637    }
638
639    #[test]
640    fn test_fp_div_round_within_one_of_truncating() {
641        let values: &[u128] = &[1, 2, 3, 7, 10, 13, 100, 997, SCALE, SCALE + 1, SCALE * 1000];
642        for &a in values {
643            for &b in values {
644                let t = fp_div(a, b).unwrap();
645                let r = fp_div_round(a, b).unwrap();
646                assert!(r == t || r == t + 1,
647                    "a={}, b={}: trunc={}, round={}", a, b, t, r);
648            }
649        }
650    }
651
652    #[test]
653    fn test_fp_div_round_zero_divisor() {
654        assert!(matches!(fp_div_round(SCALE, 0), Err(SolMathError::DivisionByZero)));
655    }
656
657    #[test]
658    fn test_fp_div_round_large_no_panic() {
659        let large = u128::MAX / (SCALE * 2);
660        assert!(fp_div_round(large, SCALE).is_ok());
661        assert!(fp_div_round(SCALE, large).is_ok());
662    }
663
664}