diff --git a/crates/model/tests/attn.rs b/crates/model/tests/attn.rs index 0adbb44dc..59ebe4c7f 100644 --- a/crates/model/tests/attn.rs +++ b/crates/model/tests/attn.rs @@ -237,14 +237,6 @@ fn hw_attn_exact_input_stages() { v, ) }; - let maxdiff = |a: &Tensor2, b: &Tensor2| { - a.data - .iter() - .zip(&b.data) - .map(|(x, y)| (x - y).abs()) - .fold(0.0f32, f32::max) - }; - for blk in [0usize, 1usize] { let g = gguf::Gguf::open(model_path::model_path()).unwrap(); let p = attn::MlaParams::read(&g, blk).unwrap(); @@ -262,9 +254,11 @@ fn hw_attn_exact_input_stages() { attn::q_nope2_absorbed(derived.wk_b_all_heads(blk).unwrap(), &q_exact, &p).unwrap(); let want = load2(&format!("q_nope2-{blk}"), 0); assert_eq!([out.ne0, out.ne1], [want.ne0, want.ne1], "stage A shape"); - assert!( - maxdiff(&out, &want) <= 1e-5, - "stage A: absorption arithmetic" + asserts::assert_close( + &out.data, + &want.data, + 1e-5, + "stage A: absorption arithmetic", ); // B: the attention proper — two legs. @@ -366,9 +360,11 @@ fn hw_attn_exact_input_stages() { let out = model::ops::matmul_q(&g, wo, &kqv_2d).unwrap(); let want = load2(&format!("kqv_out-{blk}"), 0); assert_eq!([out.ne0, out.ne1], [want.ne0, want.ne1], "stage C shape"); - assert!( - maxdiff(&out, &want) <= 1e-4, - "stage C: wv_b + output projection" + asserts::assert_close( + &out.data, + &want.data, + 1e-4, + "stage C: wv_b + output projection", ); } } diff --git a/crates/model/tests/common/asserts.rs b/crates/model/tests/common/asserts.rs index 518cfb742..4d2d55c77 100644 --- a/crates/model/tests/common/asserts.rs +++ b/crates/model/tests/common/asserts.rs @@ -13,6 +13,15 @@ pub fn assert_close(got: &[f32], want: &[f32], tol: f32, what: &str) { let mut worst = 0.0f32; let mut at = 0usize; for (i, (&g, &w)) in got.iter().zip(want).enumerate() { + // Reject non-finite operands before comparing errors. + assert!( + g.is_finite(), + "{what}: non-finite got at index {i} (reference {w}); gate is {tol:e}" + ); + assert!( + w.is_finite(), + "{what}: non-finite reference at index {i} (got {g}); gate is {tol:e}" + ); let d = (g - w).abs(); if d > worst { worst = d; diff --git a/crates/model/tests/derived.rs b/crates/model/tests/derived.rs index 61412ed29..5ef62239b 100644 --- a/crates/model/tests/derived.rs +++ b/crates/model/tests/derived.rs @@ -24,6 +24,8 @@ //! `hw_` prefix: needs the box and the model file. Like `tests/kv.rs`, this //! compares our paths against each other — `tests/attn.rs` and //! `tests/forward.rs` are what tie them to ik. +#[path = "common/asserts.rs"] +mod asserts; #[path = "common/manifest.rs"] mod manifest; #[path = "common/model_path.rs"] @@ -119,15 +121,11 @@ fn hw_derived_wblocks_bit_identical() { let from_reference = q_nope2_absorbed(&reference_flat, &q, &p).unwrap(); let from_derived = q_nope2_absorbed(d.wk_b_all_heads(blk).unwrap(), &q, &p).unwrap(); assert_eq!(from_derived.data.len(), from_reference.data.len(), "shape"); - let worst = from_derived - .data - .iter() - .zip(&from_reference.data) - .map(|(&a, &b)| (a - b).abs()) - .fold(0.0f32, f32::max); - assert_eq!( - worst, 0.0, - "q_nope2-{blk}: cached-wblocks vs in-place-wblocks max|diff| {worst:e}" + asserts::assert_close( + &from_derived.data, + &from_reference.data, + 0.0, + &format!("q_nope2-{blk}: cached-wblocks vs in-place-wblocks"), ); eprintln!("q_nope2-{blk:<4} cached == in-place max|diff| = 0 exact"); } diff --git a/crates/model/tests/forward.rs b/crates/model/tests/forward.rs index 8ca41fdb2..12eac92b0 100644 --- a/crates/model/tests/forward.rs +++ b/crates/model/tests/forward.rs @@ -28,6 +28,10 @@ fn max_abs_diff(got: &[f32], want: &[f32]) -> (f32, usize) { let mut worst = 0.0f32; let mut at = 0usize; for (i, (&g, &w)) in got.iter().zip(want).enumerate() { + assert!( + g.is_finite() && w.is_finite(), + "non-finite comparison operand at index {i}: got {g}, reference {w}" + ); let d = (g - w).abs(); if d > worst { worst = d; diff --git a/crates/model/tests/head.rs b/crates/model/tests/head.rs index 21ae18732..0c35fa3d1 100644 --- a/crates/model/tests/head.rs +++ b/crates/model/tests/head.rs @@ -63,6 +63,10 @@ fn assert_close_rel(got: &[f32], want: &[f32], what: &str) { let mut at = 0usize; let mut pair = (0.0f32, 0.0f32); for (i, (&g, &w)) in got.iter().zip(want).enumerate() { + assert!( + g.is_finite() && w.is_finite(), + "non-finite comparison operand at index {i}: got {g}, reference {w}" + ); let d = (g - w).abs(); worst_abs = worst_abs.max(d); max_abs_ref = max_abs_ref.max(w.abs()); diff --git a/crates/model/tests/kv.rs b/crates/model/tests/kv.rs index 3ca6fb3f5..f904a952c 100644 --- a/crates/model/tests/kv.rs +++ b/crates/model/tests/kv.rs @@ -34,7 +34,14 @@ fn max_abs_diff(got: &[f32], want: &[f32]) -> f32 { ); got.iter() .zip(want) - .map(|(&g, &w)| (g - w).abs()) + .enumerate() + .map(|(i, (&g, &w))| { + assert!( + g.is_finite() && w.is_finite(), + "non-finite comparison operand at index {i}: got {g}, reference {w}" + ); + (g - w).abs() + }) .fold(0.0f32, f32::max) } @@ -43,8 +50,8 @@ fn max_abs_diff(got: &[f32], want: &[f32]) -> f32 { /// only its batch, so any difference here means the shim is not the identity it says /// it is. The same run is `Derived`'s end-to-end claim (`tests/derived.rs`, layer 3): /// `forward` builds its own `Derived`, `step` is handed one, and block `b`'s absorption -/// must get block `b`'s blocks either way. Bits, not differences: `NaN - NaN` folds to -/// a clean zero through `f32::max`. +/// must get block `b`'s blocks either way. Bits, not differences; a non-finite +/// side in either vector is refused before the fold. #[test] #[ignore = "hw: needs the box and the model file"] fn hw_kv_one_shot_equals_uncached() { diff --git a/crates/model/tests/ops.rs b/crates/model/tests/ops.rs index 9c52042e2..a098c85bf 100644 --- a/crates/model/tests/ops.rs +++ b/crates/model/tests/ops.rs @@ -51,6 +51,23 @@ fn hw_rms_norm_matches_ggml() { asserts::assert_close(&got.data, &want, 0.0, "rms_norm -> attn_norm-0"); } +// The shared numeric gate rejects a non-finite output without fixtures. +#[test] +fn the_nan_output_cell_cannot_pass_the_gate() { + let got = std::panic::catch_unwind(|| { + asserts::assert_close(&[f32::NAN], &[1.0], 0.0, "rms_norm mutant -> attn_norm-0") + }) + .is_err(); + let reference = std::panic::catch_unwind(|| { + asserts::assert_close(&[1.0], &[f32::NAN], 0.0, "rms_norm mutant -> attn_norm-0") + }) + .is_err(); + assert!( + got && reference, + "a NaN must fail the gate in each operand position" + ); +} + #[test] #[ignore = "hw: needs the box, the model file and $BLOOMERY_DATA/ref"] fn hw_matmul_q_matches_ggml() { diff --git a/crates/model/tests/profile.rs b/crates/model/tests/profile.rs index a78360729..6516d7725 100644 --- a/crates/model/tests/profile.rs +++ b/crates/model/tests/profile.rs @@ -24,6 +24,8 @@ //! hold anything specific — the oracle is used only as the shared prompt, the same //! one every other gate runs, so the bit-identity claim is about the prompt that //! matters. +#[path = "common/asserts.rs"] +mod asserts; #[path = "common/manifest.rs"] mod manifest; #[path = "common/model_path.rs"] @@ -117,17 +119,7 @@ fn hw_profile_gate() { off.len(), on.len() ); - let worst = off - .iter() - .zip(&on) - .map(|(&a, &b)| (a - b).abs()) - .fold(0.0f32, f32::max); - assert_eq!( - worst, - 0.0, - "profiling changed the logits: max|diff| {worst:e} over {} values", - off.len() - ); + asserts::assert_close(&on, &off, 0.0, "profiling changed the logits"); eprintln!("logits, off vs profiled level 2 max|diff| = 0 exact"); // Assertions 2 and 3 need the in-process accumulators. The children were separate