use super::{embedding_gather, OpError};
use crate::autograd::{self, Tensor};
const FD_EPS: f32 = 1e-3;
const TOL: f32 = 2e-2;
fn coeff(n: usize) -> Vec<f32> {
(0..n).map(|i| 0.37 + 0.13 * (i as f32)).collect()
}
fn scalar_loss(output: &Tensor, c: &[f32]) -> Tensor {
let ct = Tensor::new(c, output.shape());
output.mul(&ct).sum()
}
fn perturbed_loss<F>(
w_data: &[f32],
w_shape: &[usize],
flat_idx: usize,
delta: f32,
fwd: &F,
c: &[f32],
) -> f32
where
F: Fn(&Tensor) -> Tensor,
{
autograd::no_grad(|| {
let mut wd = w_data.to_vec();
wd[flat_idx] += delta;
let w = Tensor::new(&wd, w_shape);
let y = fwd(&w);
scalar_loss(&y, c).item()
})
}
fn assert_close(analytic: f32, numeric: f32, what: &str) {
let denom = analytic.abs().max(numeric.abs()).max(1.0);
let rel = (analytic - numeric).abs() / denom;
assert!(
rel < TOL,
"{what}: analytic grad {analytic} != finite-diff {numeric} (rel err {rel})"
);
}
fn gradcheck_weight<F>(name: &str, w_data: &[f32], w_shape: &[usize], fwd: F)
where
F: Fn(&Tensor) -> Tensor,
{
autograd::clear_graph();
let w = Tensor::new(w_data, w_shape).requires_grad();
let wid = w.id();
let y = fwd(&w);
let c = coeff(y.numel());
let loss = scalar_loss(&y, &c);
loss.backward();
let grad = autograd::get_grad(wid)
.unwrap_or_else(|| panic!("{name}: weight received NO gradient — autograd graph severed"));
assert_eq!(grad.shape(), w_shape, "{name}: grad shape mismatch");
assert!(
grad.data().iter().all(|v| v.is_finite()),
"{name}: non-finite grad"
);
assert!(
grad.data().iter().any(|&v| v.abs() > 1e-9),
"{name}: all-zero grad"
);
for i in 0..w_data.len() {
let num = (perturbed_loss(w_data, w_shape, i, FD_EPS, &fwd, &c)
- perturbed_loss(w_data, w_shape, i, -FD_EPS, &fwd, &c))
/ (2.0 * FD_EPS);
assert_close(grad.data()[i], num, &format!("{name} dL/dW[{i}]"));
}
}
#[test]
fn embedding_gather_forward_matches_hand_computed_rows() {
let w: Vec<f32> = (0..12).map(|i| 0.5 + (i as f32)).collect();
let weight = Tensor::new(&w, &[4, 3]);
let ids = [0u32, 2, 1, 0, 3, 2];
let out = embedding_gather(&weight, &ids, 2, 3).expect("gather must succeed");
assert_eq!(out.shape(), &[2, 3, 3], "row-major [B,S,H] expected");
for (i, &id) in ids.iter().enumerate() {
for h in 0..3 {
assert_eq!(
out.data()[i * 3 + h],
w[(id as usize) * 3 + h],
"out[{i}][{h}] must be weight row {id}"
);
}
}
}
#[test]
fn embedding_gather_forward_is_not_grad_connected_without_requires_grad() {
autograd::clear_graph();
let weight = Tensor::new(&[1.0, 2.0, 3.0, 4.0], &[2, 2]);
let out = embedding_gather(&weight, &[0, 1], 1, 2).expect("gather must succeed");
assert!(
!out.requires_grad_enabled(),
"gather from a frozen table must not require grad"
);
}
#[test]
fn embedding_gather_backward_matches_central_finite_differences() {
let w: Vec<f32> = (0..12)
.map(|i| 0.21 + 0.17 * (i as f32) - 0.013 * ((i * i) as f32))
.collect();
let ids = [0u32, 2, 1, 0, 3, 2];
gradcheck_weight("embedding_gather", &w, &[4, 3], move |t| {
embedding_gather(t, &ids, 2, 3).expect("gather must succeed")
});
}
#[test]
fn embedding_gather_backward_accumulates_repeated_ids() {
let w = [1.0f32, 2.0, 3.0, 4.0];
autograd::clear_graph();
let w1 = Tensor::new(&w, &[2, 2]).requires_grad();
let id1 = w1.id();
embedding_gather(&w1, &[0], 1, 1)
.expect("gather must succeed")
.sum()
.backward();
let g1 = autograd::get_grad(id1).expect("single-occurrence gradient must exist");
autograd::clear_graph();
let w2 = Tensor::new(&w, &[2, 2]).requires_grad();
let id2 = w2.id();
embedding_gather(&w2, &[0, 0], 1, 2)
.expect("gather must succeed")
.sum()
.backward();
let g2 = autograd::get_grad(id2).expect("repeated-occurrence gradient must exist");
for i in 0..4 {
assert!(
(g2.data()[i] - 2.0 * g1.data()[i]).abs() < 1e-6,
"repeated id must accumulate: g2[{i}]={} != 2 * g1[{i}]={}",
g2.data()[i],
g1.data()[i]
);
}
assert_eq!(
g2.data()[2],
0.0,
"unreferenced row must receive no gradient"
);
assert_eq!(
g2.data()[3],
0.0,
"unreferenced row must receive no gradient"
);
}
#[test]
fn embedding_gather_rejects_out_of_vocabulary_id() {
let weight = Tensor::new(&[1.0, 2.0, 3.0, 4.0], &[2, 2]);
let err = embedding_gather(&weight, &[0, 7], 1, 2)
.expect_err("an id at or beyond vocab_size must be rejected, never zero-filled");
assert_eq!(
err,
OpError::OutOfVocabulary {
id: 7,
vocab_size: 2,
position: 1,
},
"the error must name the offending id AND its flat position"
);
}
#[test]
fn embedding_gather_rejects_id_exactly_at_vocab_size() {
let weight = Tensor::new(&[1.0, 2.0, 3.0, 4.0], &[2, 2]);
let err = embedding_gather(&weight, &[2], 1, 1).expect_err("id == vocab_size is out of range");
assert!(matches!(err, OpError::OutOfVocabulary { id: 2, .. }));
}
#[test]
fn embedding_gather_rejects_zero_dimension() {
let weight = Tensor::new(&[1.0, 2.0, 3.0, 4.0], &[2, 2]);
assert_eq!(
embedding_gather(&weight, &[], 0, 2).expect_err("batch 0 must be rejected"),
OpError::ZeroDimension { which: "batch" }
);
assert_eq!(
embedding_gather(&weight, &[], 2, 0).expect_err("seq 0 must be rejected"),
OpError::ZeroDimension { which: "seq" }
);
let zero_hidden = Tensor::new(&[], &[3, 0]);
assert_eq!(
embedding_gather(&zero_hidden, &[0], 1, 1).expect_err("hidden 0 must be rejected"),
OpError::ZeroDimension { which: "hidden" }
);
let zero_vocab = Tensor::new(&[], &[0, 3]);
assert_eq!(
embedding_gather(&zero_vocab, &[0], 1, 1).expect_err("vocab_size 0 must be rejected"),
OpError::ZeroDimension {
which: "vocab_size"
}
);
}
#[test]
fn embedding_gather_rejects_shape_overflow() {
let weight = Tensor::new(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0], &[2, 3]);
let err = embedding_gather(&weight, &[0], usize::MAX, 2)
.expect_err("batch * seq overflow must be rejected before allocating");
assert_eq!(
err,
OpError::ShapeOverflow {
dims: vec![usize::MAX, 2, 3],
}
);
let err = embedding_gather(&weight, &[0], usize::MAX / 2, 1)
.expect_err("positions * hidden overflow must be rejected before allocating");
assert_eq!(
err,
OpError::ShapeOverflow {
dims: vec![usize::MAX / 2, 1, 3],
}
);
}
#[test]
fn embedding_gather_rejects_non_finite_weight() {
let nan = Tensor::new(&[1.0, f32::NAN, 3.0, 4.0], &[2, 2]);
assert_eq!(
embedding_gather(&nan, &[0], 1, 1).expect_err("NaN weight must be rejected"),
OpError::NonFiniteInput { position: 1 }
);
let inf = Tensor::new(&[1.0, 2.0, f32::INFINITY, 4.0], &[2, 2]);
assert_eq!(
embedding_gather(&inf, &[0], 1, 1).expect_err("Inf weight must be rejected"),
OpError::NonFiniteInput { position: 2 }
);
}
#[test]
fn embedding_gather_rejects_rank_and_length_mismatch() {
let rank3 = Tensor::new(&[1.0, 2.0, 3.0, 4.0], &[1, 2, 2]);
assert_eq!(
embedding_gather(&rank3, &[0], 1, 1).expect_err("weight must be 2-D"),
OpError::ShapeMismatch {
expected: vec![0, 0],
got: vec![1, 2, 2],
}
);
let weight = Tensor::new(&[1.0, 2.0, 3.0, 4.0], &[2, 2]);
assert_eq!(
embedding_gather(&weight, &[0, 1, 0], 2, 3).expect_err("ids.len() must equal batch * seq"),
OpError::ShapeMismatch {
expected: vec![2, 3],
got: vec![3],
}
);
}
#[test]
fn embedding_gather_error_display_names_the_condition() {
let weight = Tensor::new(&[1.0, 2.0, 3.0, 4.0], &[2, 2]);
let err = embedding_gather(&weight, &[9], 1, 1).expect_err("OOV expected");
let rendered = err.to_string();
assert!(
rendered.contains("OutOfVocabulary") && rendered.contains('9'),
"Display must name the condition and the offending id, got {rendered}"
);
}