ruvector/crates/ruvector-postgres/tests/property_based_tests.rs
rUv 4b1fd0e286 fix(ci): Fix PostgreSQL Extension CI failures
- Remove invalid feature flags (hybrid-search, filtered-search) that don't exist
- Replace with valid all-features flag for comprehensive testing
- Add PostgreSQL apt repository for older versions on Ubuntu 24.04
- Apply cargo fmt formatting to all crates

This fixes CI failures caused by:
- Feature flags that were planned but not implemented
- PostgreSQL 14 packages not available on Ubuntu 24.04 default repos

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 23:43:01 +00:00

400 lines
13 KiB
Rust

//! Property-based tests using proptest
//!
//! These tests generate random inputs and verify mathematical properties
//! that should always hold true, helping catch edge cases and numerical issues.
use proptest::prelude::*;
use ruvector_postgres::distance::{
cosine_distance, euclidean_distance, inner_product_distance, manhattan_distance,
};
use ruvector_postgres::types::RuVector;
// ============================================================================
// Property: Distance Functions
// ============================================================================
proptest! {
/// L2 distance should always be non-negative
#[test]
fn prop_l2_distance_non_negative(
v1 in prop::collection::vec(-1000.0f32..1000.0f32, 1..100),
v2 in prop::collection::vec(-1000.0f32..1000.0f32, 1..100)
) {
if v1.len() == v2.len() {
let dist = euclidean_distance(&v1, &v2);
prop_assert!(dist >= 0.0, "L2 distance must be non-negative, got {}", dist);
prop_assert!(dist.is_finite(), "L2 distance must be finite");
}
}
/// L2 distance is symmetric: d(a,b) = d(b,a)
#[test]
fn prop_l2_distance_symmetric(
v1 in prop::collection::vec(-100.0f32..100.0f32, 1..50),
v2 in prop::collection::vec(-100.0f32..100.0f32, 1..50)
) {
if v1.len() == v2.len() {
let d1 = euclidean_distance(&v1, &v2);
let d2 = euclidean_distance(&v2, &v1);
prop_assert!((d1 - d2).abs() < 1e-5, "L2 distance must be symmetric");
}
}
/// L2 distance from vector to itself is zero
#[test]
fn prop_l2_distance_self_is_zero(
v in prop::collection::vec(-100.0f32..100.0f32, 1..50)
) {
let dist = euclidean_distance(&v, &v);
prop_assert!(dist.abs() < 1e-5, "Distance to self must be ~0, got {}", dist);
}
/// Triangle inequality: d(a,c) <= d(a,b) + d(b,c)
#[test]
fn prop_l2_triangle_inequality(
v1 in prop::collection::vec(-100.0f32..100.0f32, 1..30),
v2 in prop::collection::vec(-100.0f32..100.0f32, 1..30),
v3 in prop::collection::vec(-100.0f32..100.0f32, 1..30)
) {
if v1.len() == v2.len() && v2.len() == v3.len() {
let d_ac = euclidean_distance(&v1, &v3);
let d_ab = euclidean_distance(&v1, &v2);
let d_bc = euclidean_distance(&v2, &v3);
prop_assert!(
d_ac <= d_ab + d_bc + 1e-4, // Small epsilon for floating point
"Triangle inequality violated: {} > {} + {}", d_ac, d_ab, d_bc
);
}
}
/// Manhattan distance should always be non-negative
#[test]
fn prop_l1_distance_non_negative(
v1 in prop::collection::vec(-1000.0f32..1000.0f32, 1..100),
v2 in prop::collection::vec(-1000.0f32..1000.0f32, 1..100)
) {
if v1.len() == v2.len() {
let dist = manhattan_distance(&v1, &v2);
prop_assert!(dist >= 0.0, "L1 distance must be non-negative");
prop_assert!(dist.is_finite(), "L1 distance must be finite");
}
}
/// Manhattan distance is symmetric
#[test]
fn prop_l1_distance_symmetric(
v1 in prop::collection::vec(-100.0f32..100.0f32, 1..50),
v2 in prop::collection::vec(-100.0f32..100.0f32, 1..50)
) {
if v1.len() == v2.len() {
let d1 = manhattan_distance(&v1, &v2);
let d2 = manhattan_distance(&v2, &v1);
prop_assert!((d1 - d2).abs() < 1e-5);
}
}
/// Cosine distance should be in range [0, 2]
#[test]
fn prop_cosine_distance_range(
v1 in prop::collection::vec(-100.0f32..100.0f32, 1..50),
v2 in prop::collection::vec(-100.0f32..100.0f32, 1..50)
) {
if v1.len() == v2.len() && v1.iter().any(|&x| x != 0.0) && v2.iter().any(|&x| x != 0.0) {
let dist = cosine_distance(&v1, &v2);
if dist.is_finite() {
prop_assert!(dist >= -0.001, "Cosine distance should be >= 0, got {}", dist);
prop_assert!(dist <= 2.001, "Cosine distance should be <= 2, got {}", dist);
}
}
}
/// Cosine distance is symmetric
#[test]
fn prop_cosine_distance_symmetric(
v1 in prop::collection::vec(-100.0f32..100.0f32, 1..50),
v2 in prop::collection::vec(-100.0f32..100.0f32, 1..50)
) {
if v1.len() == v2.len() && v1.iter().any(|&x| x != 0.0) && v2.iter().any(|&x| x != 0.0) {
let d1 = cosine_distance(&v1, &v2);
let d2 = cosine_distance(&v2, &v1);
if d1.is_finite() && d2.is_finite() {
prop_assert!((d1 - d2).abs() < 1e-4);
}
}
}
}
// ============================================================================
// Property: Vector Operations
// ============================================================================
proptest! {
/// Normalization produces unit vectors
#[test]
fn prop_normalize_produces_unit_vector(
data in prop::collection::vec(-100.0f32..100.0f32, 1..50)
) {
// Skip zero vectors
if data.iter().any(|&x| x != 0.0) {
let v = RuVector::from_slice(&data);
let normalized = v.normalize();
let norm = normalized.norm();
prop_assert!(
(norm - 1.0).abs() < 1e-5,
"Normalized vector should have norm ~1.0, got {}",
norm
);
}
}
/// Adding zero vector doesn't change the vector
#[test]
fn prop_add_zero_identity(
data in prop::collection::vec(-100.0f32..100.0f32, 1..50)
) {
let v = RuVector::from_slice(&data);
let zero = RuVector::zeros(data.len());
let result = v.add(&zero);
for (a, b) in data.iter().zip(result.as_slice().iter()) {
prop_assert!((a - b).abs() < 1e-6);
}
}
/// Subtraction is inverse of addition: (a + b) - b = a
#[test]
fn prop_sub_inverse_of_add(
v1 in prop::collection::vec(-100.0f32..100.0f32, 1..50),
v2 in prop::collection::vec(-100.0f32..100.0f32, 1..50)
) {
if v1.len() == v2.len() {
let a = RuVector::from_slice(&v1);
let b = RuVector::from_slice(&v2);
let sum = a.add(&b);
let result = sum.sub(&b);
for (original, recovered) in v1.iter().zip(result.as_slice().iter()) {
prop_assert!((original - recovered).abs() < 1e-4);
}
}
}
/// Scalar multiplication by 1 is identity
#[test]
fn prop_mul_scalar_identity(
data in prop::collection::vec(-100.0f32..100.0f32, 1..50)
) {
let v = RuVector::from_slice(&data);
let result = v.mul_scalar(1.0);
for (a, b) in data.iter().zip(result.as_slice().iter()) {
prop_assert!((a - b).abs() < 1e-6);
}
}
/// Scalar multiplication by 0 produces zero vector
#[test]
fn prop_mul_scalar_zero(
data in prop::collection::vec(-100.0f32..100.0f32, 1..50)
) {
let v = RuVector::from_slice(&data);
let result = v.mul_scalar(0.0);
for &val in result.as_slice() {
prop_assert_eq!(val, 0.0);
}
}
/// Scalar multiplication is associative: (a * b) * c = a * (b * c)
#[test]
fn prop_mul_scalar_associative(
data in prop::collection::vec(-10.0f32..10.0f32, 1..30),
scalar1 in -10.0f32..10.0f32,
scalar2 in -10.0f32..10.0f32
) {
let v = RuVector::from_slice(&data);
let r1 = v.mul_scalar(scalar1).mul_scalar(scalar2);
let r2 = v.mul_scalar(scalar1 * scalar2);
for (a, b) in r1.as_slice().iter().zip(r2.as_slice().iter()) {
prop_assert!((a - b).abs() < 1e-4);
}
}
/// Dot product is commutative: a · b = b · a
#[test]
fn prop_dot_commutative(
v1 in prop::collection::vec(-100.0f32..100.0f32, 1..50),
v2 in prop::collection::vec(-100.0f32..100.0f32, 1..50)
) {
if v1.len() == v2.len() {
let a = RuVector::from_slice(&v1);
let b = RuVector::from_slice(&v2);
let dot1 = a.dot(&b);
let dot2 = b.dot(&a);
prop_assert!((dot1 - dot2).abs() < 1e-4);
}
}
/// Dot product with zero vector is zero
#[test]
fn prop_dot_with_zero(
data in prop::collection::vec(-100.0f32..100.0f32, 1..50)
) {
let v = RuVector::from_slice(&data);
let zero = RuVector::zeros(data.len());
let result = v.dot(&zero);
prop_assert!(result.abs() < 1e-6);
}
/// Norm squared equals dot product with self
#[test]
fn prop_norm_squared_equals_self_dot(
data in prop::collection::vec(-100.0f32..100.0f32, 1..50)
) {
let v = RuVector::from_slice(&data);
let norm_squared = v.norm() * v.norm();
let dot_self = v.dot(&v);
prop_assert!((norm_squared - dot_self).abs() < 1e-3);
}
}
// ============================================================================
// Property: Serialization (Varlena Round-trip)
// ============================================================================
proptest! {
/// Varlena serialization round-trip preserves data
#[test]
fn prop_varlena_roundtrip(
data in prop::collection::vec(-1000.0f32..1000.0f32, 0..100)
) {
unsafe {
let v1 = RuVector::from_slice(&data);
let varlena = v1.to_varlena();
let v2 = RuVector::from_varlena(varlena);
prop_assert_eq!(v1.dimensions(), v2.dimensions());
for (a, b) in v1.as_slice().iter().zip(v2.as_slice().iter()) {
prop_assert!((a - b).abs() < 1e-6);
}
pgrx::pg_sys::pfree(varlena as *mut std::ffi::c_void);
}
}
/// String parsing and display round-trip (for reasonable values)
#[test]
fn prop_string_roundtrip(
data in prop::collection::vec(-1000.0f32..1000.0f32, 1..20)
) {
let v1 = RuVector::from_slice(&data);
let s = v1.to_string();
if let Ok(v2) = s.parse::<RuVector>() {
prop_assert_eq!(v1.dimensions(), v2.dimensions());
for (a, b) in v1.as_slice().iter().zip(v2.as_slice().iter()) {
// Allow some floating point precision loss in string conversion
prop_assert!((a - b).abs() < 1e-4 || (a.abs() < 1e-6 && b.abs() < 1e-6));
}
}
}
}
// ============================================================================
// Property: Numerical Stability
// ============================================================================
proptest! {
/// Operations on very small values don't produce NaN/Inf
#[test]
fn prop_small_values_stable(
data in prop::collection::vec(-1e-6f32..1e-6f32, 1..50)
) {
let v = RuVector::from_slice(&data);
let norm = v.norm();
prop_assert!(norm.is_finite());
// Only normalize if not too close to zero
if data.iter().map(|x| x * x).sum::<f32>() > 1e-12 {
let normalized = v.normalize();
for &val in normalized.as_slice() {
prop_assert!(val.is_finite());
}
}
}
/// Operations on large values don't overflow
#[test]
fn prop_large_values_no_overflow(
data in prop::collection::vec(-1000.0f32..1000.0f32, 1..30)
) {
let v1 = RuVector::from_slice(&data);
let v2 = RuVector::from_slice(&data);
let sum = v1.add(&v2);
for &val in sum.as_slice() {
prop_assert!(val.is_finite());
}
let diff = v1.sub(&v2);
for &val in diff.as_slice() {
prop_assert!(val.is_finite());
}
}
/// Dot product doesn't overflow with reasonable inputs
#[test]
fn prop_dot_no_overflow(
v1 in prop::collection::vec(-100.0f32..100.0f32, 1..100),
v2 in prop::collection::vec(-100.0f32..100.0f32, 1..100)
) {
if v1.len() == v2.len() {
let a = RuVector::from_slice(&v1);
let b = RuVector::from_slice(&v2);
let dot = a.dot(&b);
prop_assert!(dot.is_finite());
}
}
}
// ============================================================================
// Property: Edge Cases
// ============================================================================
proptest! {
/// Single-element vectors work correctly
#[test]
fn prop_single_element_vector(
val in -1000.0f32..1000.0f32
) {
let v = RuVector::from_slice(&[val]);
prop_assert_eq!(v.dimensions(), 1);
prop_assert_eq!(v.as_slice()[0], val);
let norm = v.norm();
prop_assert!((norm - val.abs()).abs() < 1e-5);
}
/// Empty vectors handle operations gracefully
#[test]
fn prop_empty_vector_operations(_seed in 0u32..1000) {
let v = RuVector::from_slice(&[]);
prop_assert_eq!(v.dimensions(), 0);
prop_assert_eq!(v.norm(), 0.0);
let normalized = v.normalize();
prop_assert_eq!(normalized.dimensions(), 0);
}
}