Adding an assert makes a test fail

Hello! I have a test that I'm running whose outcome appears to be affected by how I construct the input. Here's the short story:

  • I construct a linearly-spaced ndarray in two different ways: Construction A and Construction B
  • These result in the exact same values
  • I have a previously-existing test that works on Construction A, but not on Construction B
  • What's worse, simply asserting that the two constructions are equal in value causes the original test to start failing on Construction A.

Here's a main.rs to demonstrate:

Rust Code
use approx::assert_abs_diff_eq;
use ndarray::{Array, Array2, Ixs, s};

/// Two equivalent methods of doing `linspace`.
/// We show here that the two methods yield _identical_ results.
#[inline(never)]
fn equal_linspace() {
    let a = Array::linspace(0.5, 2., 128)
        .into_shape_with_order((8, 16))
        .unwrap();

    let step = (2. - 0.5) / 127.;
    let a_other = Array::from_iter((0..128).map(|i| 0.5 + step * (i as f32)))
        .into_shape_with_order((8, 16))
        .unwrap();

    // Equality according to both ndarray and Rust slices
    assert_eq!(a, a_other);
    assert_eq!(a.as_slice().unwrap(), a_other.as_slice().unwrap());
}

/// The original `product` test from `oper.rs`.
/// As expected, runs without issue.
#[inline(never)]
fn product() {
    let a = Array::linspace(0.5, 2., 128)
        .into_shape_with_order((8, 16))
        .unwrap();
    assert_abs_diff_eq!(a.fold(1., |acc, &x| acc * x), a.product(), epsilon = 1e-5);

    // test different strides
    let max = 8 as Ixs;
    for i in 1..max {
        for j in 1..max {
            let a1 = a.slice(s![..;i, ..;j]);
            let mut prod = 1.;
            for elt in a1.iter() {
                prod *= *elt;
            }
            assert_abs_diff_eq!(a1.fold(1., |acc, &x| acc * x), prod, epsilon = 1e-5);
            assert_abs_diff_eq!(prod, a1.product(), epsilon = 1e-5);
        }
    }
}

/// The product test, now with an additional, EQUIVALENT array `a_other`.
///
/// 1. We define `a` the same as before.
/// 2. We define `a_other` using a different method, but prior tests show us it's equivalent.
/// 3. We then run the test as before.
/// 4. Adding a simple `assert_eq` between the two arrays causes the test to fail..
#[inline(never)]
fn bad_product() {
    let a = Array::linspace(0.5, 2., 128)
        .into_shape_with_order((8, 16))
        .unwrap();

    let step = (2. - 0.5) / 127.;
    let a_other = Array::from_iter((0..128).map(|i| 0.5 + step * (i as f32)))
        .into_shape_with_order((8, 16))
        .unwrap();

    // UNCOMMENTING THIS LINE WILL CAUSE THE TEST TO FAIL.
    // THIS IS NOT THE LINE THAT CAUSES THE FAILURE.
    // IT WILL FAIL AT THE FIRST assert_abs_diff_eq IN THE LOOP ON i = 1, j = 2.
    assert_eq!(a, a_other);

    assert_abs_diff_eq!(a.fold(1., |acc, &x| acc * x), a.product(), epsilon = 1e-5);

    // test different strides
    let max = 8 as Ixs;
    for i in 1..max {
        for j in 1..max {
            let a1 = a.slice(s![..;i, ..;j]);

            let mut prod = 1.;
            for elt in a1.iter() {
                prod *= *elt;
            }
            eprintln!("{i}, {j}");
            assert_abs_diff_eq!(a1.fold(1., |acc, &x| acc * x), prod, epsilon = 1e-5);
            assert_abs_diff_eq!(prod, a1.product(), epsilon = 1e-5);
        }
    }
}

fn impl_test(a: &Array2<f32>) {
    assert_abs_diff_eq!(a.fold(1., |acc, &x| acc * x), a.product(), epsilon = 1e-5);

    // test different strides
    let max = 8 as Ixs;
    for i in 1..max {
        for j in 1..max {
            let a1 = a.slice(s![..;i, ..;j]);

            let mut prod = 1.;
            for elt in a1.iter() {
                prod *= *elt;
            }
            eprintln!("{i}, {j}");
            assert_abs_diff_eq!(a1.fold(1., |acc, &x| acc * x), prod, epsilon = 1e-5);
            assert_abs_diff_eq!(prod, a1.product(), epsilon = 1e-5);
        }
    }
}

fn main() {
    equal_linspace();
    product();
    bad_product();
}

I'm really stumped by what could be causing this. My best guess right now is some sort of difference in compiler optimizations? Any help would be greatly appreciated.

1 Like

if we assume it's very unlikely we hit a compiler bug, then this kind of difference is probably an indicator of UB, but I would rather lean towards the possibility that the difference is caused by non associative nature of floating point operations. but I need to dig deeper to find out.

I figured it out, it turns out the floating point type in a is inferred as f64 by default, but a_other is inferred as f32 due to step * (i as f32).

if you add an assert_eq!(), then a is forced to use f32.

if you explicitly annotate the type of a, then your original test fails consistently:

    let a = Array::linspace(0.5f32, 2., 128)
        .into_shape_with_order((8, 16))
        .unwrap();
4 Likes

Thank you so much! That makes a lot of sense. I didn't really think about how an assert statement could alter the type inference.

it's nothing special about the assertion really, the assert_eq!(x, y) macro expands to a function call like PartialEq::eq(&x, &y), which add a constraint for type inference, just like any function calls.

in rust, literal numeric expression can be suffixed or unsuffixed. for ergonomic reasons, when they are unsuffixed, the type is inferred from the context, and if there's no other constraint, sunsuffixed integer literals default to i32, unsuffixed floating point literals default to f64. I quote the reference for floating point literals:

  • If the token has a suffix, the suffix must be the name of one of the primitive floating-point types: f32 or f64, and the expression has that type.

  • If the token has no suffix, the expression’s type is determined by type inference:

    • If a floating-point type can be uniquely determined from the surrounding program context, the expression has that type.

    • If the program context under-constrains the type, it defaults to f64.

    • If the program context over-constrains the type, it is considered a static type error.

2 Likes

This is one of the benefits of denying/warning clippy::default_numeric_fallback. Clippy would have correctly informed you to explicitly add the f64 suffix which would have caused your code to not compile due to comparing an f64 with an f32 which in turn would have either caused you to change the f32 to an f64 causing your code to not panic or add f32s everywhere keeping the panic but hopefully hinting to you that f32 does not have enough precision.

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.