use std::path::PathBuf;
use super::*;
use crate::autograd::{self};
fn loss_source() -> String {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/setfit/loss.rs");
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
}
#[test]
fn pair_loss_never_reaches_the_f32_loss_utilities() {
let src = loss_source();
assert!(
!src.contains("nn::loss"),
"loss.rs reaches into the f32 nn::loss utilities (PF-001)"
);
assert!(
!src.contains("nn::self_supervised"),
"loss.rs reaches into the f32 nn::self_supervised utilities (PF-001)"
);
}
#[test]
fn pair_loss_annotates_its_own_contract_equation_not_raw_mse() {
let src = loss_source();
assert!(
src.contains("equation = \"pair_cosine_mse\""),
"the contract annotation must name the dedicated pair_cosine_mse equation"
);
assert!(
!src.contains("equation = \"mse_loss\""),
"annotating this wrapper as raw mse_loss misdescribes its inputs (two [B,H] \
embedding matrices plus binary labels, not pred/target vectors)"
);
}
#[test]
fn pair_loss_epsilon_agrees_with_the_encoder_normalize_path() {
assert_eq!(
PAIR_COSINE_EPS,
crate::setfit::encoder::L2_EPS,
"the pair objective's epsilon must equal the encoder's normalize epsilon"
);
}
fn hand_case() -> (Tensor, Tensor, Vec<f32>) {
let za = Tensor::new(&[3.0, 4.0, 1.0, 0.0, 1.0, 0.0], &[3, 2]);
let zb = Tensor::new(&[3.0, 4.0, 0.0, 1.0, 1.0, 1.0], &[3, 2]);
(za, zb, vec![1.0, 0.0, 0.0])
}
#[test]
fn pair_loss_equals_the_hand_computed_three_pair_value() {
let (za, zb, labels) = hand_case();
let loss = pair_cosine_mse(&za, &zb, &labels).expect("hand case must evaluate");
assert_eq!(loss.shape(), &[1], "the objective reduces to a [1] tensor");
let expected = 1.0f32 / 6.0;
assert!(
(loss.item() - expected).abs() < 1e-6,
"expected {expected}, got {}",
loss.item()
);
}
#[test]
fn pair_loss_equals_the_composition_of_the_two_primitives_bitwise() {
let (za, zb, labels) = hand_case();
let direct = pair_cosine_mse(&za, &zb, &labels).expect("pair loss");
let composed = mse_loss(
&cosine_similarity_rows(&za, &zb, PAIR_COSINE_EPS).expect("cosine"),
&labels,
)
.expect("mse");
assert_eq!(
direct.item().to_bits(),
composed.item().to_bits(),
"pair_cosine_mse must be exactly mse_loss(cosine_similarity_rows(..), labels)"
);
}
#[test]
fn pair_loss_is_zero_for_identical_embeddings_labelled_positive() {
let za = Tensor::new(&[0.3, -0.9, 0.4, 1.2, 0.1, -0.2], &[2, 3]);
let zb = za.clone();
let loss = pair_cosine_mse(&za, &zb, &[1.0, 1.0]).expect("identical pair");
assert!(
loss.item().abs() < 1e-6,
"cos == 1 against label 1 must give ~0 loss, got {}",
loss.item()
);
}
#[test]
fn pair_loss_is_one_for_orthogonal_embeddings_labelled_positive() {
let za = Tensor::new(&[1.0, 0.0, 0.0, 1.0], &[2, 2]);
let zb = Tensor::new(&[0.0, 1.0, 1.0, 0.0], &[2, 2]);
let loss = pair_cosine_mse(&za, &zb, &[1.0, 1.0]).expect("orthogonal pair");
assert!(
(loss.item() - 1.0).abs() < 1e-6,
"cos == 0 against label 1 must give ~1 loss, got {}",
loss.item()
);
}
#[test]
fn pair_loss_is_one_for_antiparallel_embeddings_labelled_negative() {
let za = Tensor::new(&[1.0, 2.0], &[1, 2]);
let zb = Tensor::new(&[-1.0, -2.0], &[1, 2]);
let loss = pair_cosine_mse(&za, &zb, &[0.0]).expect("antiparallel pair");
assert!(
(loss.item() - 1.0).abs() < 1e-6,
"cos == -1 against label 0 must give ~1 loss, got {}",
loss.item()
);
}
#[test]
fn pair_loss_requires_grad_when_either_input_does() {
autograd::clear_graph();
let (za, zb, labels) = hand_case();
let za = za.requires_grad();
let loss = pair_cosine_mse(&za, &zb, &labels).expect("loss");
assert!(
loss.requires_grad_enabled(),
"a graph-connected input must produce a graph-connected loss"
);
}
#[test]
fn pair_loss_does_not_require_grad_when_neither_input_does() {
autograd::clear_graph();
let (za, zb, labels) = hand_case();
let loss = pair_cosine_mse(&za, &zb, &labels).expect("loss");
assert!(!loss.requires_grad_enabled());
}
#[test]
fn pair_loss_backward_reaches_both_embedding_matrices() {
autograd::clear_graph();
let za = Tensor::new(&[0.7, -0.2, 0.4, 0.9, 1.1, 0.3], &[3, 2]).requires_grad();
let zb = Tensor::new(&[0.1, 0.8, -0.5, 0.2, 0.6, -0.4], &[3, 2]).requires_grad();
let loss = pair_cosine_mse(&za, &zb, &[1.0, 0.0, 1.0]).expect("loss");
assert!(loss.item().is_finite(), "loss is {}", loss.item());
loss.backward();
for (name, t) in [("za", &za), ("zb", &zb)] {
let g = autograd::get_grad(t.id())
.unwrap_or_else(|| panic!("`{name}` received NO gradient — the edge is severed"));
assert_eq!(g.numel(), t.numel(), "`{name}` gradient arity");
let l2: f64 = g.data().iter().map(|v| f64::from(*v) * f64::from(*v)).sum();
assert!(
l2.sqrt() > 1e-6,
"`{name}` gradient L2 is {:e}: the backward does not reach this branch",
l2.sqrt()
);
for (i, v) in g.data().iter().enumerate() {
assert!(v.is_finite(), "`{name}`[{i}] gradient is {v}");
}
}
}
#[test]
fn pair_loss_gradients_are_bitwise_those_of_the_explicit_composition() {
fn grads_via<F>(build: F) -> (Vec<f32>, Vec<f32>)
where
F: Fn(&Tensor, &Tensor, &[f32]) -> Tensor,
{
autograd::clear_graph();
let za = Tensor::new(&[0.7, -0.2, 0.4, 0.9, 1.1, 0.3], &[3, 2]).requires_grad();
let zb = Tensor::new(&[0.1, 0.8, -0.5, 0.2, 0.6, -0.4], &[3, 2]).requires_grad();
let labels = [1.0, 0.0, 1.0];
let loss = build(&za, &zb, &labels);
loss.backward();
(
autograd::get_grad(za.id())
.expect("za grad")
.data()
.to_vec(),
autograd::get_grad(zb.id())
.expect("zb grad")
.data()
.to_vec(),
)
}
let (da, db) = grads_via(|a, b, l| pair_cosine_mse(a, b, l).expect("pair loss"));
let (ca, cb) = grads_via(|a, b, l| {
mse_loss(
&cosine_similarity_rows(a, b, PAIR_COSINE_EPS).expect("cosine"),
l,
)
.expect("mse")
});
for (i, (x, y)) in da.iter().zip(ca.iter()).enumerate() {
assert_eq!(x.to_bits(), y.to_bits(), "za grad element {i}: {x} vs {y}");
}
for (i, (x, y)) in db.iter().zip(cb.iter()).enumerate() {
assert_eq!(x.to_bits(), y.to_bits(), "zb grad element {i}: {x} vs {y}");
}
}
fn batch_invalid_reason(e: &SetFitError) -> String {
match e {
SetFitError::BatchInvalid { reason } => reason.clone(),
other => panic!("expected SetFitError::BatchInvalid, got {other}"),
}
}
#[test]
fn pair_loss_rejects_a_label_count_that_disagrees_with_the_batch() {
let (za, zb, _) = hand_case();
let err = pair_cosine_mse(&za, &zb, &[1.0, 0.0]).expect_err("2 labels for 3 pairs");
let reason = batch_invalid_reason(&err);
assert!(reason.contains('2') && reason.contains('3'), "got {reason}");
}
#[test]
fn pair_loss_rejects_a_nan_label_naming_non_finiteness_not_membership() {
let (za, zb, _) = hand_case();
let err = pair_cosine_mse(&za, &zb, &[1.0, f32::NAN, 0.0]).expect_err("NaN label");
let reason = batch_invalid_reason(&err);
assert!(
reason.contains("non-finite"),
"the NaN rejection must name non-finiteness, got {reason}"
);
assert!(
reason.contains('1'),
"the position must be named, got {reason}"
);
}
#[test]
fn pair_loss_rejects_an_infinite_label_naming_non_finiteness() {
let (za, zb, _) = hand_case();
for (i, bad) in [f32::INFINITY, f32::NEG_INFINITY].into_iter().enumerate() {
let err = pair_cosine_mse(&za, &zb, &[bad, 0.0, 0.0]).expect_err("infinite label");
let reason = batch_invalid_reason(&err);
assert!(
reason.contains("non-finite"),
"case {i}: expected a non-finiteness diagnosis, got {reason}"
);
}
}
#[test]
fn pair_loss_rejects_a_finite_label_outside_the_binary_set() {
let (za, zb, _) = hand_case();
let err = pair_cosine_mse(&za, &zb, &[1.0, 0.5, 0.0]).expect_err("0.5 is not a pair label");
let reason = batch_invalid_reason(&err);
assert!(
!reason.contains("non-finite"),
"0.5 is finite; the diagnosis must not claim otherwise: {reason}"
);
assert!(
reason.contains("0.5"),
"the offending value must be named, got {reason}"
);
}
#[test]
fn pair_loss_distinguishes_the_two_label_rejections() {
let (za, zb, _) = hand_case();
let non_finite =
batch_invalid_reason(&pair_cosine_mse(&za, &zb, &[f32::NAN, 0.0, 0.0]).expect_err("NaN"));
let non_binary =
batch_invalid_reason(&pair_cosine_mse(&za, &zb, &[0.5, 0.0, 0.0]).expect_err("0.5"));
assert_ne!(
non_finite, non_binary,
"the two label failures must be distinguishable from the error alone"
);
}
#[test]
fn pair_loss_accepts_both_binary_label_values() {
let (za, zb, _) = hand_case();
for labels in [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [1.0, 0.0, 1.0]] {
pair_cosine_mse(&za, &zb, &labels).unwrap_or_else(|e| panic!("{labels:?} rejected: {e}"));
}
}
#[test]
fn pair_loss_rejects_a_shape_mismatch_between_the_two_branches() {
let za = Tensor::new(&[1.0, 2.0, 3.0, 4.0], &[2, 2]);
let zb = Tensor::new(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0], &[2, 3]);
let err = pair_cosine_mse(&za, &zb, &[1.0, 0.0]).expect_err("shapes differ");
match err {
SetFitError::Op(OpError::ShapeMismatch { expected, got }) => {
assert_eq!(expected, vec![2, 2]);
assert_eq!(got, vec![2, 3]);
}
other => panic!("expected a ShapeMismatch, got {other}"),
}
}
#[test]
fn pair_loss_rejects_a_shape_mismatch_before_validating_labels() {
let za = Tensor::new(&[1.0, 2.0, 3.0, 4.0], &[2, 2]);
let zb = Tensor::new(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0], &[2, 3]);
let err = pair_cosine_mse(&za, &zb, &[f32::NAN, 0.5]).expect_err("shape wins");
assert!(
matches!(err, SetFitError::Op(OpError::ShapeMismatch { .. })),
"expected the shape rejection to precede label validation, got {err}"
);
}
#[test]
fn pair_loss_rejects_a_non_rank_two_embedding_matrix() {
let za = Tensor::new(&[1.0, 2.0], &[2]);
let zb = Tensor::new(&[1.0, 2.0], &[2]);
let err = pair_cosine_mse(&za, &zb, &[1.0, 0.0]).expect_err("rank 1 is not [B,H]");
assert!(
matches!(err, SetFitError::Op(OpError::ShapeMismatch { .. })),
"got {err}"
);
}
#[test]
fn pair_loss_rejects_an_empty_label_slice_against_an_empty_batch() {
let (za, zb, _) = hand_case();
let err = pair_cosine_mse(&za, &zb, &[]).expect_err("no labels");
let reason = batch_invalid_reason(&err);
assert!(reason.contains('0') && reason.contains('3'), "got {reason}");
}