477 lines
15 KiB
Rust
477 lines
15 KiB
Rust
|
|
//! Property tests for tensor operations
|
|||
|
|
//!
|
|||
|
|
//! Tests algebraic properties of tensor operations using proptest.
|
|||
|
|
|
|||
|
|
mod generators;
|
|||
|
|
|
|||
|
|
use generators::{TensorParams, arb_sparse_tensor, arb_tensor_pair_same_dims, arb_sparse_tensor_with_dims};
|
|||
|
|
use geolog::tensor::{SparseTensor, TensorExpr, conjunction, exists, conjunction_all, disjunction_all};
|
|||
|
|
use proptest::prelude::*;
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// SparseTensor Basic Properties
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
proptest! {
|
|||
|
|
#![proptest_config(ProptestConfig::with_cases(1024))]
|
|||
|
|
|
|||
|
|
/// Empty tensor has no tuples
|
|||
|
|
#[test]
|
|||
|
|
fn empty_tensor_is_empty(dims in proptest::collection::vec(1usize..10, 0..4)) {
|
|||
|
|
let tensor = SparseTensor::empty(dims.clone());
|
|||
|
|
prop_assert!(tensor.is_empty());
|
|||
|
|
prop_assert_eq!(tensor.len(), 0);
|
|||
|
|
prop_assert_eq!(tensor.dims, dims);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Scalar true contains the empty tuple
|
|||
|
|
#[test]
|
|||
|
|
fn scalar_true_contains_empty(_seed in any::<u64>()) {
|
|||
|
|
let tensor = SparseTensor::scalar(true);
|
|||
|
|
prop_assert!(tensor.contains(&[]));
|
|||
|
|
prop_assert_eq!(tensor.len(), 1);
|
|||
|
|
prop_assert!(tensor.dims.is_empty());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Scalar false is empty
|
|||
|
|
#[test]
|
|||
|
|
fn scalar_false_is_empty(_seed in any::<u64>()) {
|
|||
|
|
let tensor = SparseTensor::scalar(false);
|
|||
|
|
prop_assert!(!tensor.contains(&[]));
|
|||
|
|
prop_assert!(tensor.is_empty());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Insert/remove roundtrip
|
|||
|
|
#[test]
|
|||
|
|
fn insert_remove_roundtrip(
|
|||
|
|
dims in proptest::collection::vec(1usize..5, 1..3),
|
|||
|
|
tuple_idx in any::<prop::sample::Index>(),
|
|||
|
|
) {
|
|||
|
|
let mut tensor = SparseTensor::empty(dims.clone());
|
|||
|
|
|
|||
|
|
// Generate a valid tuple
|
|||
|
|
let tuple: Vec<usize> = dims.iter()
|
|||
|
|
.map(|&d| tuple_idx.index(d.max(1)))
|
|||
|
|
.collect();
|
|||
|
|
|
|||
|
|
prop_assert!(!tensor.contains(&tuple));
|
|||
|
|
tensor.insert(tuple.clone());
|
|||
|
|
prop_assert!(tensor.contains(&tuple));
|
|||
|
|
tensor.remove(&tuple);
|
|||
|
|
prop_assert!(!tensor.contains(&tuple));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Generated tensor has valid tuples (within dimension bounds)
|
|||
|
|
#[test]
|
|||
|
|
fn generated_tensor_valid_tuples(
|
|||
|
|
tensor in arb_sparse_tensor(TensorParams::default())
|
|||
|
|
) {
|
|||
|
|
for tuple in tensor.iter() {
|
|||
|
|
prop_assert_eq!(tuple.len(), tensor.dims.len());
|
|||
|
|
for (i, &val) in tuple.iter().enumerate() {
|
|||
|
|
prop_assert!(val < tensor.dims[i], "tuple value {} >= dim {}", val, tensor.dims[i]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// TensorExpr Product Properties
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
proptest! {
|
|||
|
|
#![proptest_config(ProptestConfig::with_cases(512))]
|
|||
|
|
|
|||
|
|
/// Product of empty tensors is empty
|
|||
|
|
#[test]
|
|||
|
|
fn product_with_empty_is_empty(
|
|||
|
|
tensor in arb_sparse_tensor(TensorParams { max_dims: 2, max_dim_size: 5, max_tuples: 10 })
|
|||
|
|
) {
|
|||
|
|
let empty = SparseTensor::empty(vec![3]);
|
|||
|
|
let expr = TensorExpr::Product(vec![
|
|||
|
|
TensorExpr::leaf(tensor),
|
|||
|
|
TensorExpr::leaf(empty),
|
|||
|
|
]);
|
|||
|
|
let result = expr.materialize();
|
|||
|
|
prop_assert!(result.is_empty());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Product with scalar true is identity (dims extended but tuples preserved)
|
|||
|
|
#[test]
|
|||
|
|
fn product_with_scalar_true(
|
|||
|
|
tensor in arb_sparse_tensor(TensorParams { max_dims: 2, max_dim_size: 5, max_tuples: 10 })
|
|||
|
|
) {
|
|||
|
|
let scalar_true = SparseTensor::scalar(true);
|
|||
|
|
let orig_len = tensor.len();
|
|||
|
|
let orig_dims = tensor.dims.clone();
|
|||
|
|
|
|||
|
|
let expr = TensorExpr::Product(vec![
|
|||
|
|
TensorExpr::leaf(tensor),
|
|||
|
|
TensorExpr::leaf(scalar_true),
|
|||
|
|
]);
|
|||
|
|
let result = expr.materialize();
|
|||
|
|
|
|||
|
|
prop_assert_eq!(result.len(), orig_len);
|
|||
|
|
prop_assert_eq!(result.dims, orig_dims);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Empty product is scalar true
|
|||
|
|
#[test]
|
|||
|
|
fn empty_product_is_scalar_true(_seed in any::<u64>()) {
|
|||
|
|
let expr = TensorExpr::Product(vec![]);
|
|||
|
|
let result = expr.materialize();
|
|||
|
|
prop_assert!(result.contains(&[]));
|
|||
|
|
prop_assert_eq!(result.len(), 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Product dimensions are concatenation
|
|||
|
|
#[test]
|
|||
|
|
fn product_dims_concatenate(
|
|||
|
|
t1 in arb_sparse_tensor(TensorParams { max_dims: 2, max_dim_size: 4, max_tuples: 5 }),
|
|||
|
|
t2 in arb_sparse_tensor(TensorParams { max_dims: 2, max_dim_size: 4, max_tuples: 5 }),
|
|||
|
|
) {
|
|||
|
|
let expected_dims: Vec<usize> = t1.dims.iter().chain(t2.dims.iter()).copied().collect();
|
|||
|
|
|
|||
|
|
let expr = TensorExpr::Product(vec![
|
|||
|
|
TensorExpr::leaf(t1),
|
|||
|
|
TensorExpr::leaf(t2),
|
|||
|
|
]);
|
|||
|
|
let result = expr.materialize();
|
|||
|
|
|
|||
|
|
prop_assert_eq!(result.dims, expected_dims);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// Sum (Disjunction) Properties
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
proptest! {
|
|||
|
|
#![proptest_config(ProptestConfig::with_cases(512))]
|
|||
|
|
|
|||
|
|
/// Sum is commutative
|
|||
|
|
#[test]
|
|||
|
|
fn sum_commutative(
|
|||
|
|
(t1, t2) in arb_tensor_pair_same_dims(TensorParams { max_dims: 2, max_dim_size: 5, max_tuples: 10 })
|
|||
|
|
) {
|
|||
|
|
let sum1 = TensorExpr::Sum(vec![
|
|||
|
|
TensorExpr::leaf(t1.clone()),
|
|||
|
|
TensorExpr::leaf(t2.clone()),
|
|||
|
|
]).materialize();
|
|||
|
|
|
|||
|
|
let sum2 = TensorExpr::Sum(vec![
|
|||
|
|
TensorExpr::leaf(t2),
|
|||
|
|
TensorExpr::leaf(t1),
|
|||
|
|
]).materialize();
|
|||
|
|
|
|||
|
|
prop_assert_eq!(sum1, sum2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Sum is idempotent (T ∨ T = T)
|
|||
|
|
#[test]
|
|||
|
|
fn sum_idempotent(
|
|||
|
|
tensor in arb_sparse_tensor(TensorParams { max_dims: 2, max_dim_size: 5, max_tuples: 10 })
|
|||
|
|
) {
|
|||
|
|
let sum = TensorExpr::Sum(vec![
|
|||
|
|
TensorExpr::leaf(tensor.clone()),
|
|||
|
|
TensorExpr::leaf(tensor.clone()),
|
|||
|
|
]).materialize();
|
|||
|
|
|
|||
|
|
prop_assert_eq!(sum, tensor);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Sum with empty is identity
|
|||
|
|
#[test]
|
|||
|
|
fn sum_with_empty_is_identity(
|
|||
|
|
tensor in arb_sparse_tensor(TensorParams { max_dims: 2, max_dim_size: 5, max_tuples: 10 })
|
|||
|
|
) {
|
|||
|
|
let empty = SparseTensor::empty(tensor.dims.clone());
|
|||
|
|
let sum = TensorExpr::Sum(vec![
|
|||
|
|
TensorExpr::leaf(tensor.clone()),
|
|||
|
|
TensorExpr::leaf(empty),
|
|||
|
|
]).materialize();
|
|||
|
|
|
|||
|
|
prop_assert_eq!(sum, tensor);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Empty sum is scalar false
|
|||
|
|
#[test]
|
|||
|
|
fn empty_sum_is_scalar_false(_seed in any::<u64>()) {
|
|||
|
|
let sum = TensorExpr::Sum(vec![]).materialize();
|
|||
|
|
prop_assert!(sum.is_empty());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Sum extent is union of extents
|
|||
|
|
#[test]
|
|||
|
|
fn sum_is_union(
|
|||
|
|
(t1, t2) in arb_tensor_pair_same_dims(TensorParams { max_dims: 2, max_dim_size: 5, max_tuples: 10 })
|
|||
|
|
) {
|
|||
|
|
let sum = TensorExpr::Sum(vec![
|
|||
|
|
TensorExpr::leaf(t1.clone()),
|
|||
|
|
TensorExpr::leaf(t2.clone()),
|
|||
|
|
]).materialize();
|
|||
|
|
|
|||
|
|
// Every tuple in t1 should be in sum
|
|||
|
|
for tuple in t1.iter() {
|
|||
|
|
prop_assert!(sum.contains(tuple));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Every tuple in t2 should be in sum
|
|||
|
|
for tuple in t2.iter() {
|
|||
|
|
prop_assert!(sum.contains(tuple));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Every tuple in sum should be in t1 or t2
|
|||
|
|
for tuple in sum.iter() {
|
|||
|
|
prop_assert!(t1.contains(tuple) || t2.contains(tuple));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// Conjunction Properties
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
proptest! {
|
|||
|
|
#![proptest_config(ProptestConfig::with_cases(256))]
|
|||
|
|
|
|||
|
|
/// Conjunction with scalar true is identity (modulo variable naming)
|
|||
|
|
#[test]
|
|||
|
|
fn conjunction_with_true(
|
|||
|
|
tensor in arb_sparse_tensor_with_dims(vec![3, 3], 5)
|
|||
|
|
) {
|
|||
|
|
let vars = vec!["x".to_string(), "y".to_string()];
|
|||
|
|
let scalar_true = SparseTensor::scalar(true);
|
|||
|
|
|
|||
|
|
let (expr, result_vars) = conjunction(
|
|||
|
|
TensorExpr::leaf(tensor.clone()),
|
|||
|
|
&vars,
|
|||
|
|
TensorExpr::leaf(scalar_true),
|
|||
|
|
&[],
|
|||
|
|
);
|
|||
|
|
let result = expr.materialize();
|
|||
|
|
|
|||
|
|
prop_assert_eq!(result_vars, vars);
|
|||
|
|
prop_assert_eq!(result, tensor);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Conjunction with scalar false is empty
|
|||
|
|
#[test]
|
|||
|
|
fn conjunction_with_false(
|
|||
|
|
tensor in arb_sparse_tensor_with_dims(vec![3, 3], 5)
|
|||
|
|
) {
|
|||
|
|
let vars = vec!["x".to_string(), "y".to_string()];
|
|||
|
|
let scalar_false = SparseTensor::scalar(false);
|
|||
|
|
|
|||
|
|
let (expr, _result_vars) = conjunction(
|
|||
|
|
TensorExpr::leaf(tensor),
|
|||
|
|
&vars,
|
|||
|
|
TensorExpr::leaf(scalar_false),
|
|||
|
|
&[],
|
|||
|
|
);
|
|||
|
|
let result = expr.materialize();
|
|||
|
|
|
|||
|
|
prop_assert!(result.is_empty());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Conjunction is commutative (on shared variables)
|
|||
|
|
#[test]
|
|||
|
|
fn conjunction_commutative(
|
|||
|
|
t1 in arb_sparse_tensor_with_dims(vec![3, 4], 5),
|
|||
|
|
t2 in arb_sparse_tensor_with_dims(vec![4, 5], 5),
|
|||
|
|
) {
|
|||
|
|
let vars1 = vec!["x".to_string(), "y".to_string()];
|
|||
|
|
let vars2 = vec!["y".to_string(), "z".to_string()];
|
|||
|
|
|
|||
|
|
let (expr1, _vars_result1) = conjunction(
|
|||
|
|
TensorExpr::leaf(t1.clone()),
|
|||
|
|
&vars1,
|
|||
|
|
TensorExpr::leaf(t2.clone()),
|
|||
|
|
&vars2,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
let (expr2, _vars_result2) = conjunction(
|
|||
|
|
TensorExpr::leaf(t2),
|
|||
|
|
&vars2,
|
|||
|
|
TensorExpr::leaf(t1),
|
|||
|
|
&vars1,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
let result1 = expr1.materialize();
|
|||
|
|
let result2 = expr2.materialize();
|
|||
|
|
|
|||
|
|
// Same number of tuples (though variable order may differ)
|
|||
|
|
prop_assert_eq!(result1.len(), result2.len());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// Exists (Contraction) Properties
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
proptest! {
|
|||
|
|
#![proptest_config(ProptestConfig::with_cases(256))]
|
|||
|
|
|
|||
|
|
/// Exists on non-existent variable is identity
|
|||
|
|
#[test]
|
|||
|
|
fn exists_nonexistent_var(
|
|||
|
|
tensor in arb_sparse_tensor_with_dims(vec![3, 3], 5)
|
|||
|
|
) {
|
|||
|
|
let vars = vec!["x".to_string(), "y".to_string()];
|
|||
|
|
let (expr, result_vars) = exists(TensorExpr::leaf(tensor.clone()), &vars, "z");
|
|||
|
|
let result = expr.materialize();
|
|||
|
|
|
|||
|
|
prop_assert_eq!(result_vars, vars);
|
|||
|
|
prop_assert_eq!(result, tensor);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Exists reduces arity by 1
|
|||
|
|
#[test]
|
|||
|
|
fn exists_reduces_arity(
|
|||
|
|
tensor in arb_sparse_tensor_with_dims(vec![3, 4], 8)
|
|||
|
|
) {
|
|||
|
|
let vars = vec!["x".to_string(), "y".to_string()];
|
|||
|
|
let (expr, result_vars) = exists(TensorExpr::leaf(tensor), &vars, "y");
|
|||
|
|
let result = expr.materialize();
|
|||
|
|
|
|||
|
|
prop_assert_eq!(result_vars, vec!["x"]);
|
|||
|
|
prop_assert_eq!(result.arity(), 1);
|
|||
|
|
prop_assert_eq!(result.dims, vec![3]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Exists on scalar is identity
|
|||
|
|
#[test]
|
|||
|
|
fn exists_on_scalar(value in any::<bool>()) {
|
|||
|
|
let tensor = SparseTensor::scalar(value);
|
|||
|
|
let (expr, result_vars) = exists(TensorExpr::leaf(tensor.clone()), &[], "x");
|
|||
|
|
let result = expr.materialize();
|
|||
|
|
|
|||
|
|
prop_assert!(result_vars.is_empty());
|
|||
|
|
prop_assert_eq!(result, tensor);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Double exists is same as single exists (idempotent on same var)
|
|||
|
|
#[test]
|
|||
|
|
fn exists_idempotent(
|
|||
|
|
tensor in arb_sparse_tensor_with_dims(vec![3, 4], 8)
|
|||
|
|
) {
|
|||
|
|
let vars = vec!["x".to_string(), "y".to_string()];
|
|||
|
|
|
|||
|
|
let (expr1, vars1) = exists(TensorExpr::leaf(tensor.clone()), &vars, "y");
|
|||
|
|
let (expr2, vars2) = exists(expr1, &vars1, "y");
|
|||
|
|
|
|||
|
|
let result = expr2.materialize();
|
|||
|
|
|
|||
|
|
prop_assert_eq!(vars2, vec!["x"]);
|
|||
|
|
prop_assert_eq!(result.arity(), 1);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// Fusion Tests (Contract(Product(...)))
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
proptest! {
|
|||
|
|
#![proptest_config(ProptestConfig::with_cases(128))]
|
|||
|
|
|
|||
|
|
/// Fused join produces same result as naive evaluation
|
|||
|
|
#[test]
|
|||
|
|
fn fused_join_correctness(
|
|||
|
|
t1 in arb_sparse_tensor_with_dims(vec![5, 5], 10),
|
|||
|
|
t2 in arb_sparse_tensor_with_dims(vec![5, 5], 10),
|
|||
|
|
) {
|
|||
|
|
let vars1 = vec!["x".to_string(), "y".to_string()];
|
|||
|
|
let vars2 = vec!["y".to_string(), "z".to_string()];
|
|||
|
|
|
|||
|
|
// This creates Contract(Product(...)) which gets fused
|
|||
|
|
let (conj_expr, conj_vars) = conjunction(
|
|||
|
|
TensorExpr::leaf(t1.clone()),
|
|||
|
|
&vars1,
|
|||
|
|
TensorExpr::leaf(t2.clone()),
|
|||
|
|
&vars2,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
let (result_expr, _result_vars) = exists(conj_expr, &conj_vars, "y");
|
|||
|
|
let result = result_expr.materialize();
|
|||
|
|
|
|||
|
|
// Verify result is correct by checking each tuple
|
|||
|
|
for tuple in result.iter() {
|
|||
|
|
let x = tuple[0];
|
|||
|
|
let z = tuple[1];
|
|||
|
|
|
|||
|
|
// Should exist some y such that t1(x,y) and t2(y,z)
|
|||
|
|
let mut found = false;
|
|||
|
|
for y in 0..5 {
|
|||
|
|
if t1.contains(&[x, y]) && t2.contains(&[y, z]) {
|
|||
|
|
found = true;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
prop_assert!(found, "tuple {:?} in result but no witness y", tuple);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// And every valid (x,z) should be in result
|
|||
|
|
for x in 0..5 {
|
|||
|
|
for z in 0..5 {
|
|||
|
|
let mut should_be_in_result = false;
|
|||
|
|
for y in 0..5 {
|
|||
|
|
if t1.contains(&[x, y]) && t2.contains(&[y, z]) {
|
|||
|
|
should_be_in_result = true;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
prop_assert_eq!(
|
|||
|
|
result.contains(&[x, z]),
|
|||
|
|
should_be_in_result,
|
|||
|
|
"({}, {}) expected {} but got {}",
|
|||
|
|
x, z, should_be_in_result, result.contains(&[x, z])
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// Disjunction Helper Tests
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
proptest! {
|
|||
|
|
#![proptest_config(ProptestConfig::with_cases(256))]
|
|||
|
|
|
|||
|
|
/// disjunction_all with empty is scalar false
|
|||
|
|
#[test]
|
|||
|
|
fn disjunction_all_empty(_seed in any::<u64>()) {
|
|||
|
|
let (expr, vars) = disjunction_all(vec![]);
|
|||
|
|
let result = expr.materialize();
|
|||
|
|
|
|||
|
|
prop_assert!(vars.is_empty());
|
|||
|
|
prop_assert!(result.is_empty());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// disjunction_all with single element is identity
|
|||
|
|
#[test]
|
|||
|
|
fn disjunction_all_single(
|
|||
|
|
tensor in arb_sparse_tensor_with_dims(vec![3, 3], 5)
|
|||
|
|
) {
|
|||
|
|
let vars = vec!["x".to_string(), "y".to_string()];
|
|||
|
|
let (expr, result_vars) = disjunction_all(vec![
|
|||
|
|
(TensorExpr::leaf(tensor.clone()), vars.clone())
|
|||
|
|
]);
|
|||
|
|
let result = expr.materialize();
|
|||
|
|
|
|||
|
|
prop_assert_eq!(result_vars, vars);
|
|||
|
|
prop_assert_eq!(result, tensor);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// conjunction_all with empty is scalar true
|
|||
|
|
#[test]
|
|||
|
|
fn conjunction_all_empty(_seed in any::<u64>()) {
|
|||
|
|
let (expr, vars) = conjunction_all(vec![]);
|
|||
|
|
let result = expr.materialize();
|
|||
|
|
|
|||
|
|
prop_assert!(vars.is_empty());
|
|||
|
|
prop_assert!(result.contains(&[]));
|
|||
|
|
prop_assert_eq!(result.len(), 1);
|
|||
|
|
}
|
|||
|
|
}
|