geolog-zeta-fork/tests/proptest_query.rs

947 lines
34 KiB
Rust
Raw Normal View History

2026-02-26 11:50:51 +01:00
//! Property tests for query operations.
//!
//! Verifies that execute_optimized produces the same results as execute (naive).
use geolog::core::Structure;
use geolog::id::{NumericId, Slid};
use geolog::query::{JoinCond, Predicate, QueryOp, execute, execute_optimized};
use proptest::prelude::*;
// ============================================================================
// QueryOp Generators
// ============================================================================
/// Generate arbitrary Slid values (within reasonable range)
fn arb_slid() -> impl Strategy<Value = Slid> {
(0..100usize).prop_map(Slid::from_usize)
}
/// Generate a simple structure with multiple sorts and elements
fn arb_query_structure(num_sorts: usize, max_per_sort: usize) -> impl Strategy<Value = Structure> {
prop::collection::vec(
prop::collection::vec(0..50u64, 0..=max_per_sort),
num_sorts,
)
.prop_map(|sort_elements| {
let mut structure = Structure::new(sort_elements.len());
for (sort_idx, elements) in sort_elements.iter().enumerate() {
for &elem in elements {
structure.carriers[sort_idx].insert(elem);
}
}
structure
})
}
/// Generate a scan operation
fn arb_scan(max_sort: usize) -> impl Strategy<Value = QueryOp> {
(0..max_sort).prop_map(|sort_idx| QueryOp::Scan { sort_idx })
}
/// Generate a constant tuple
fn arb_constant() -> impl Strategy<Value = QueryOp> {
prop::collection::vec(arb_slid(), 1..=3)
.prop_map(|tuple| QueryOp::Constant { tuple })
}
/// Generate empty
fn arb_empty() -> impl Strategy<Value = QueryOp> {
Just(QueryOp::Empty)
}
/// Generate a simple query (scan, constant, or empty)
fn arb_simple_query(max_sort: usize) -> impl Strategy<Value = QueryOp> {
prop_oneof![
arb_scan(max_sort),
arb_constant(),
arb_empty(),
]
}
/// Generate a join condition for given arity
fn arb_join_cond(left_arity: usize, right_arity: usize) -> impl Strategy<Value = JoinCond> {
if left_arity == 0 || right_arity == 0 {
Just(JoinCond::Cross).boxed()
} else {
prop_oneof![
Just(JoinCond::Cross),
(0..left_arity, 0..right_arity)
.prop_map(|(left_col, right_col)| JoinCond::Equi { left_col, right_col }),
]
.boxed()
}
}
/// Generate a join of two scans
fn arb_scan_join(max_sort: usize) -> impl Strategy<Value = QueryOp> {
(0..max_sort, 0..max_sort)
.prop_flat_map(move |(left_sort, right_sort)| {
arb_join_cond(1, 1).prop_map(move |cond| QueryOp::Join {
left: Box::new(QueryOp::Scan { sort_idx: left_sort }),
right: Box::new(QueryOp::Scan { sort_idx: right_sort }),
cond,
})
})
}
/// Generate a union of two simple queries
fn arb_union(max_sort: usize) -> impl Strategy<Value = QueryOp> {
(arb_simple_query(max_sort), arb_simple_query(max_sort))
.prop_map(|(left, right)| QueryOp::Union {
left: Box::new(left),
right: Box::new(right),
})
}
/// Generate a negate of a simple query
fn arb_negate(max_sort: usize) -> impl Strategy<Value = QueryOp> {
arb_simple_query(max_sort).prop_map(|input| QueryOp::Negate {
input: Box::new(input),
})
}
/// Generate a distinct of a simple query
fn arb_distinct(max_sort: usize) -> impl Strategy<Value = QueryOp> {
arb_simple_query(max_sort).prop_map(|input| QueryOp::Distinct {
input: Box::new(input),
})
}
/// Generate a simple predicate (no recursion, no function predicates)
/// Use this for tests with structures that don't have functions.
fn arb_simple_predicate_no_funcs() -> impl Strategy<Value = Predicate> {
prop_oneof![
Just(Predicate::True),
Just(Predicate::False),
(0..5usize, 0..5usize).prop_map(|(left, right)| Predicate::ColEqCol { left, right }),
(0..5usize, arb_slid()).prop_map(|(col, val)| Predicate::ColEqConst { col, val }),
]
}
/// Generate a simple predicate (no recursion) - includes function predicates
/// Use this for to_relalg compilation tests where functions don't need to evaluate.
fn arb_simple_predicate() -> impl Strategy<Value = Predicate> {
prop_oneof![
Just(Predicate::True),
Just(Predicate::False),
(0..5usize, 0..5usize).prop_map(|(left, right)| Predicate::ColEqCol { left, right }),
(0..5usize, arb_slid()).prop_map(|(col, val)| Predicate::ColEqConst { col, val }),
(0..3usize, 0..5usize, 0..5usize)
.prop_map(|(func_idx, arg_col, result_col)| Predicate::FuncEq { func_idx, arg_col, result_col }),
(0..3usize, 0..5usize, arb_slid())
.prop_map(|(func_idx, arg_col, expected)| Predicate::FuncEqConst { func_idx, arg_col, expected }),
]
}
/// Generate a predicate with possible And/Or nesting (no function predicates)
fn arb_predicate_no_funcs() -> impl Strategy<Value = Predicate> {
arb_simple_predicate_no_funcs().prop_recursive(2, 8, 2, |inner| {
prop_oneof![
inner.clone(),
(inner.clone(), inner.clone()).prop_map(|(l, r)| Predicate::And(Box::new(l), Box::new(r))),
(inner.clone(), inner).prop_map(|(l, r)| Predicate::Or(Box::new(l), Box::new(r))),
]
})
}
/// Generate a predicate with possible And/Or nesting (includes function predicates)
fn arb_predicate() -> impl Strategy<Value = Predicate> {
arb_simple_predicate().prop_recursive(2, 8, 2, |inner| {
prop_oneof![
inner.clone(),
(inner.clone(), inner.clone()).prop_map(|(l, r)| Predicate::And(Box::new(l), Box::new(r))),
(inner.clone(), inner).prop_map(|(l, r)| Predicate::Or(Box::new(l), Box::new(r))),
]
})
}
/// Generate a filter with arbitrary predicate (no function predicates)
/// Safe for testing against structures without functions.
fn arb_filter_safe(max_sort: usize) -> impl Strategy<Value = QueryOp> {
(arb_scan(max_sort), arb_predicate_no_funcs())
.prop_map(|(input, pred)| QueryOp::Filter {
input: Box::new(input),
pred,
})
}
/// Generate a filter with column equality predicate (simple version)
fn arb_filter_col_eq_const(max_sort: usize) -> impl Strategy<Value = QueryOp> {
(arb_scan(max_sort), arb_slid())
.prop_map(|(input, val)| QueryOp::Filter {
input: Box::new(input),
pred: Predicate::ColEqConst { col: 0, val },
})
}
/// Generate a query without DBSP operators (for comparing naive vs optimized)
/// Uses arb_filter_safe to avoid function predicates that require functions in the structure.
fn arb_query_no_dbsp(max_sort: usize) -> impl Strategy<Value = QueryOp> {
prop_oneof![
4 => arb_scan(max_sort),
2 => arb_constant(),
1 => arb_empty(),
3 => arb_scan_join(max_sort),
2 => arb_union(max_sort),
1 => arb_negate(max_sort),
1 => arb_distinct(max_sort),
2 => arb_filter_col_eq_const(max_sort),
3 => arb_filter_safe(max_sort),
]
}
// ============================================================================
// Property Tests
// ============================================================================
proptest! {
#![proptest_config(ProptestConfig::with_cases(500))]
/// execute_optimized should produce identical results to execute for any query
#[test]
fn optimized_matches_naive(
structure in arb_query_structure(4, 10),
query in arb_query_no_dbsp(4)
) {
let naive_result = execute(&query, &structure);
let optimized_result = execute_optimized(&query, &structure);
// Same number of unique tuples
prop_assert_eq!(
naive_result.len(),
optimized_result.len(),
"Length mismatch for query {:?}",
query
);
// Same multiplicities for each tuple
for (tuple, mult) in naive_result.iter() {
prop_assert_eq!(
optimized_result.tuples.get(tuple),
Some(mult),
"Multiplicity mismatch for tuple {:?}",
tuple
);
}
}
/// Equi-join should be symmetric in a sense: swapping left/right and columns
/// should produce equivalent results (after accounting for tuple order)
#[test]
fn equijoin_symmetric(
structure in arb_query_structure(2, 8),
left_sort in 0..2usize,
right_sort in 0..2usize,
) {
let join1 = QueryOp::Join {
left: Box::new(QueryOp::Scan { sort_idx: left_sort }),
right: Box::new(QueryOp::Scan { sort_idx: right_sort }),
cond: JoinCond::Equi { left_col: 0, right_col: 0 },
};
let join2 = QueryOp::Join {
left: Box::new(QueryOp::Scan { sort_idx: right_sort }),
right: Box::new(QueryOp::Scan { sort_idx: left_sort }),
cond: JoinCond::Equi { left_col: 0, right_col: 0 },
};
let result1 = execute_optimized(&join1, &structure);
let result2 = execute_optimized(&join2, &structure);
// Should have same number of tuples (with columns swapped)
prop_assert_eq!(result1.len(), result2.len());
}
/// Nested equijoins: (A ⋈ B) ⋈ C should work correctly
#[test]
fn nested_equijoin(
structure in arb_query_structure(3, 6),
) {
let join_ab = QueryOp::Join {
left: Box::new(QueryOp::Scan { sort_idx: 0 }),
right: Box::new(QueryOp::Scan { sort_idx: 1 }),
cond: JoinCond::Equi { left_col: 0, right_col: 0 },
};
let join_abc = QueryOp::Join {
left: Box::new(join_ab.clone()),
right: Box::new(QueryOp::Scan { sort_idx: 2 }),
cond: JoinCond::Equi { left_col: 0, right_col: 0 },
};
let naive_result = execute(&join_abc, &structure);
let optimized_result = execute_optimized(&join_abc, &structure);
prop_assert_eq!(naive_result.len(), optimized_result.len());
for (tuple, mult) in naive_result.iter() {
prop_assert_eq!(
optimized_result.tuples.get(tuple),
Some(mult),
"Mismatch in nested join"
);
}
}
/// Cross join should produce |A| * |B| results
#[test]
fn cross_join_cardinality(
structure in arb_query_structure(2, 5),
) {
let join = QueryOp::Join {
left: Box::new(QueryOp::Scan { sort_idx: 0 }),
right: Box::new(QueryOp::Scan { sort_idx: 1 }),
cond: JoinCond::Cross,
};
let result = execute_optimized(&join, &structure);
let expected_size = structure.carriers[0].len() as usize * structure.carriers[1].len() as usize;
prop_assert_eq!(result.len(), expected_size);
}
/// Union is commutative: A B = B A
#[test]
fn union_commutative(
structure in arb_query_structure(2, 5),
) {
let union1 = QueryOp::Union {
left: Box::new(QueryOp::Scan { sort_idx: 0 }),
right: Box::new(QueryOp::Scan { sort_idx: 1 }),
};
let union2 = QueryOp::Union {
left: Box::new(QueryOp::Scan { sort_idx: 1 }),
right: Box::new(QueryOp::Scan { sort_idx: 0 }),
};
let result1 = execute_optimized(&union1, &structure);
let result2 = execute_optimized(&union2, &structure);
prop_assert_eq!(result1.len(), result2.len());
for (tuple, mult) in result1.iter() {
prop_assert_eq!(
result2.tuples.get(tuple),
Some(mult),
"Union commutativity failed"
);
}
}
/// Distinct is idempotent: distinct(distinct(x)) = distinct(x)
#[test]
fn distinct_idempotent(
structure in arb_query_structure(1, 10),
) {
let scan = QueryOp::Scan { sort_idx: 0 };
let distinct1 = QueryOp::Distinct {
input: Box::new(scan.clone()),
};
let distinct2 = QueryOp::Distinct {
input: Box::new(QueryOp::Distinct {
input: Box::new(scan),
}),
};
let result1 = execute_optimized(&distinct1, &structure);
let result2 = execute_optimized(&distinct2, &structure);
prop_assert_eq!(result1.len(), result2.len());
for (tuple, mult) in result1.iter() {
prop_assert_eq!(
result2.tuples.get(tuple),
Some(mult),
"Distinct idempotency failed"
);
}
}
/// Negate twice is identity: negate(negate(x)) = x
#[test]
fn negate_involution(
structure in arb_query_structure(1, 10),
) {
let scan = QueryOp::Scan { sort_idx: 0 };
let double_negate = QueryOp::Negate {
input: Box::new(QueryOp::Negate {
input: Box::new(scan.clone()),
}),
};
let result_original = execute_optimized(&scan, &structure);
let result_double_neg = execute_optimized(&double_negate, &structure);
prop_assert_eq!(result_original.len(), result_double_neg.len());
for (tuple, mult) in result_original.iter() {
prop_assert_eq!(
result_double_neg.tuples.get(tuple),
Some(mult),
"Negate involution failed"
);
}
}
}
// ============================================================================
// RelAlgIR Compilation Property Tests
// ============================================================================
mod to_relalg_tests {
use geolog::core::ElaboratedTheory;
use geolog::query::{Predicate, QueryOp, to_relalg::compile_to_relalg};
use geolog::universe::Universe;
use geolog::repl::ReplState;
use proptest::prelude::*;
use std::rc::Rc;
/// Load the RelAlgIR theory for testing
fn load_relalg_theory() -> Rc<ElaboratedTheory> {
let meta_content = std::fs::read_to_string("theories/GeologMeta.geolog")
.expect("Failed to read GeologMeta.geolog");
let ir_content = std::fs::read_to_string("theories/RelAlgIR.geolog")
.expect("Failed to read RelAlgIR.geolog");
let mut state = ReplState::new();
state
.execute_geolog(&meta_content)
.expect("GeologMeta should load");
state
.execute_geolog(&ir_content)
.expect("RelAlgIR should load");
state
.theories
.get("RelAlgIR")
.expect("RelAlgIR should exist")
.clone()
}
/// Generate a simple QueryOp without Constant/Apply (which need target context)
fn arb_simple_query_op() -> impl Strategy<Value = QueryOp> {
prop_oneof![
// Scan
(0..10usize).prop_map(|sort_idx| QueryOp::Scan { sort_idx }),
// Empty
Just(QueryOp::Empty),
]
}
/// Generate a nested QueryOp (depth 2)
fn arb_nested_query_op() -> impl Strategy<Value = QueryOp> {
arb_simple_query_op().prop_flat_map(|base| {
prop_oneof![
// Filter with various predicates
Just(QueryOp::Filter {
input: Box::new(base.clone()),
pred: Predicate::True,
}),
Just(QueryOp::Filter {
input: Box::new(base.clone()),
pred: Predicate::False,
}),
Just(QueryOp::Filter {
input: Box::new(base.clone()),
pred: Predicate::ColEqCol { left: 0, right: 0 },
}),
// Negate
Just(QueryOp::Negate {
input: Box::new(base.clone()),
}),
// Distinct
Just(QueryOp::Distinct {
input: Box::new(base.clone()),
}),
// Project
prop::collection::vec(0..3usize, 1..=3).prop_map(move |columns| QueryOp::Project {
input: Box::new(base.clone()),
columns,
}),
]
})
}
proptest! {
/// Compiling simple QueryOps to RelAlgIR should not panic
#[test]
fn compile_simple_query_no_panic(plan in arb_simple_query_op()) {
let relalg_theory = load_relalg_theory();
let mut universe = Universe::new();
// Should not panic - may error for Constant/Apply but shouldn't crash
let _ = compile_to_relalg(&plan, &relalg_theory, &mut universe);
}
/// Compiling nested QueryOps to RelAlgIR should not panic
#[test]
fn compile_nested_query_no_panic(plan in arb_nested_query_op()) {
let relalg_theory = load_relalg_theory();
let mut universe = Universe::new();
// Should not panic
let _ = compile_to_relalg(&plan, &relalg_theory, &mut universe);
}
/// Compiled instances should have at least output wire
#[test]
fn compile_produces_valid_instance(plan in arb_simple_query_op()) {
let relalg_theory = load_relalg_theory();
let mut universe = Universe::new();
if let Ok(instance) = compile_to_relalg(&plan, &relalg_theory, &mut universe) {
// Instance should have elements
prop_assert!(!instance.structure.is_empty(), "Instance should have elements");
// Should have named elements including output wire
prop_assert!(!instance.names.is_empty(), "Instance should have named elements");
}
}
/// Compiling binary operations should work
#[test]
fn compile_binary_ops_no_panic(
left_sort in 0..5usize,
right_sort in 0..5usize,
) {
let relalg_theory = load_relalg_theory();
let mut universe = Universe::new();
// Join (cross)
let join_plan = QueryOp::Join {
left: Box::new(QueryOp::Scan { sort_idx: left_sort }),
right: Box::new(QueryOp::Scan { sort_idx: right_sort }),
cond: geolog::query::JoinCond::Cross,
};
let _ = compile_to_relalg(&join_plan, &relalg_theory, &mut universe);
// Join (equi)
let equi_plan = QueryOp::Join {
left: Box::new(QueryOp::Scan { sort_idx: left_sort }),
right: Box::new(QueryOp::Scan { sort_idx: right_sort }),
cond: geolog::query::JoinCond::Equi { left_col: 0, right_col: 0 },
};
let _ = compile_to_relalg(&equi_plan, &relalg_theory, &mut universe);
// Union
let union_plan = QueryOp::Union {
left: Box::new(QueryOp::Scan { sort_idx: left_sort }),
right: Box::new(QueryOp::Scan { sort_idx: right_sort }),
};
let _ = compile_to_relalg(&union_plan, &relalg_theory, &mut universe);
}
/// Compiling DBSP operators should work
#[test]
fn compile_dbsp_ops_no_panic(sort_idx in 0..5usize, state_id in 0..3usize) {
let relalg_theory = load_relalg_theory();
let mut universe = Universe::new();
let scan = QueryOp::Scan { sort_idx };
// Delay
let delay_plan = QueryOp::Delay {
input: Box::new(scan.clone()),
state_id,
};
let _ = compile_to_relalg(&delay_plan, &relalg_theory, &mut universe);
// Diff
let diff_plan = QueryOp::Diff {
input: Box::new(scan.clone()),
state_id,
};
let _ = compile_to_relalg(&diff_plan, &relalg_theory, &mut universe);
// Integrate
let integrate_plan = QueryOp::Integrate {
input: Box::new(scan),
state_id,
};
let _ = compile_to_relalg(&integrate_plan, &relalg_theory, &mut universe);
}
/// Compiling all predicate types should work
#[test]
fn compile_all_predicate_types_no_panic(pred in super::arb_predicate()) {
let relalg_theory = load_relalg_theory();
let mut universe = Universe::new();
let filter_plan = QueryOp::Filter {
input: Box::new(QueryOp::Scan { sort_idx: 0 }),
pred,
};
// Should compile without panic
let _ = compile_to_relalg(&filter_plan, &relalg_theory, &mut universe);
}
}
}
// ============================================================================
// Chase Algorithm Proptests
// ============================================================================
mod chase_proptest {
use super::*;
use geolog::core::{Context, DerivedSort, Formula, RelationStorage, Sequent, Signature, Structure, Term, Theory, VecRelation};
use geolog::cc::CongruenceClosure;
use geolog::query::chase::{chase_step, chase_fixpoint};
use geolog::universe::Universe;
/// Generate a simple theory with one sort and one unary relation
fn simple_relation_theory() -> Theory {
let mut sig = Signature::default();
sig.add_sort("V".to_string());
sig.add_relation("R".to_string(), DerivedSort::Base(0));
Theory {
name: "Simple".to_string(),
signature: sig,
axioms: vec![],
axiom_names: vec![],
}
}
proptest! {
#[test]
fn chase_step_no_panic_on_empty_axioms(
num_elements in 0..10usize,
) {
let mut universe = Universe::new();
let mut structure = {
let mut s = Structure::new(1);
for i in 0..num_elements {
s.carriers[0].insert(i as u64);
}
s.relations.push(VecRelation::new(1));
s
};
let theory = simple_relation_theory();
// Empty axioms should not change anything
let mut cc = CongruenceClosure::new();
let changed = chase_step(&[], &mut structure, &mut cc, &mut universe, &theory.signature).unwrap();
prop_assert!(!changed);
}
#[test]
fn chase_step_adds_to_relation(
num_elements in 1..10usize,
) {
let mut universe = Universe::new();
let mut structure = {
let mut s = Structure::new(1);
for i in 0..num_elements {
s.carriers[0].insert(i as u64);
}
s.relations.push(VecRelation::new(1)); // Unary relation
s
};
let theory = simple_relation_theory();
// Axiom: forall x : V. |- R(x)
let axiom = Sequent {
context: Context {
vars: vec![("x".to_string(), DerivedSort::Base(0))],
},
premise: Formula::True,
conclusion: Formula::Rel(0, Term::Var("x".to_string(), DerivedSort::Base(0))),
};
// First chase step should add elements
let mut cc = CongruenceClosure::new();
let changed = chase_step(std::slice::from_ref(&axiom), &mut structure, &mut cc, &mut universe, &theory.signature).unwrap();
if num_elements > 0 {
prop_assert!(changed);
prop_assert_eq!(structure.relations[0].len(), num_elements);
}
// Second chase step should not change anything
let changed2 = chase_step(&[axiom], &mut structure, &mut cc, &mut universe, &theory.signature).unwrap();
prop_assert!(!changed2);
}
#[test]
fn chase_fixpoint_converges(
num_elements in 1..8usize,
) {
let mut universe = Universe::new();
let mut structure = {
let mut s = Structure::new(1);
for i in 0..num_elements {
s.carriers[0].insert(i as u64);
}
s.relations.push(VecRelation::new(1)); // Unary relation
s
};
let theory = simple_relation_theory();
// Axiom: forall x : V. |- R(x)
let axiom = Sequent {
context: Context {
vars: vec![("x".to_string(), DerivedSort::Base(0))],
},
premise: Formula::True,
conclusion: Formula::Rel(0, Term::Var("x".to_string(), DerivedSort::Base(0))),
};
// Chase should converge in exactly 2 iterations:
// 1. Add all elements to relation
// 2. Verify no more changes
let iterations = chase_fixpoint(
&[axiom],
&mut structure,
&mut universe,
&theory.signature,
100,
).unwrap();
prop_assert_eq!(iterations, 2);
prop_assert_eq!(structure.relations[0].len(), num_elements);
}
/// Test reflexivity axiom: forall x. |- [lo: x, hi: x] leq
/// Should create diagonal tuples for all elements
#[test]
fn chase_reflexivity_creates_diagonal(
num_elements in 1..8usize,
) {
let mut universe = Universe::new();
let mut structure = {
let mut s = Structure::new(1);
for i in 0..num_elements {
s.carriers[0].insert(i as u64);
}
// Binary relation: leq : [lo: V, hi: V] -> Prop
s.relations.push(VecRelation::new(2));
s
};
let mut sig = Signature::default();
sig.add_sort("V".to_string());
sig.add_relation("leq".to_string(), DerivedSort::Product(vec![
("lo".to_string(), DerivedSort::Base(0)),
("hi".to_string(), DerivedSort::Base(0)),
]));
// Axiom: forall x : V. |- [lo: x, hi: x] leq
let axiom = Sequent {
context: Context {
vars: vec![("x".to_string(), DerivedSort::Base(0))],
},
premise: Formula::True,
conclusion: Formula::Rel(0, Term::Record(vec![
("lo".to_string(), Term::Var("x".to_string(), DerivedSort::Base(0))),
("hi".to_string(), Term::Var("x".to_string(), DerivedSort::Base(0))),
])),
};
let iterations = chase_fixpoint(
&[axiom],
&mut structure,
&mut universe,
&sig,
100,
).unwrap();
// Should have exactly num_elements diagonal tuples
prop_assert_eq!(structure.relations[0].len(), num_elements);
prop_assert!(iterations <= 3); // Should converge quickly
}
/// Test transitivity axiom: [lo: x, hi: y] leq, [lo: y, hi: z] leq |- [lo: x, hi: z] leq
/// Classic transitive closure - should derive all reachable pairs
#[test]
fn chase_transitivity_computes_closure(
chain_length in 2..5usize,
) {
let mut universe = Universe::new();
let mut structure = {
let mut s = Structure::new(1);
// Create a chain: 0 -> 1 -> 2 -> ... -> n-1
for i in 0..chain_length {
s.carriers[0].insert(i as u64);
}
s.relations.push(VecRelation::new(2));
s
};
let mut sig = Signature::default();
sig.add_sort("V".to_string());
sig.add_relation("leq".to_string(), DerivedSort::Product(vec![
("lo".to_string(), DerivedSort::Base(0)),
("hi".to_string(), DerivedSort::Base(0)),
]));
// Seed the chain edges: 0->1, 1->2, ..., (n-2)->(n-1)
use geolog::id::Slid;
for i in 0..(chain_length - 1) {
structure.relations[0].insert(vec![
Slid::from_usize(i),
Slid::from_usize(i + 1),
]);
}
// Transitivity axiom
let axiom = Sequent {
context: Context {
vars: vec![
("x".to_string(), DerivedSort::Base(0)),
("y".to_string(), DerivedSort::Base(0)),
("z".to_string(), DerivedSort::Base(0)),
],
},
premise: Formula::Conj(vec![
Formula::Rel(0, Term::Record(vec![
("lo".to_string(), Term::Var("x".to_string(), DerivedSort::Base(0))),
("hi".to_string(), Term::Var("y".to_string(), DerivedSort::Base(0))),
])),
Formula::Rel(0, Term::Record(vec![
("lo".to_string(), Term::Var("y".to_string(), DerivedSort::Base(0))),
("hi".to_string(), Term::Var("z".to_string(), DerivedSort::Base(0))),
])),
]),
conclusion: Formula::Rel(0, Term::Record(vec![
("lo".to_string(), Term::Var("x".to_string(), DerivedSort::Base(0))),
("hi".to_string(), Term::Var("z".to_string(), DerivedSort::Base(0))),
])),
};
let _iterations = chase_fixpoint(
&[axiom],
&mut structure,
&mut universe,
&sig,
100,
).unwrap();
// For a chain of length n, transitive closure has n*(n-1)/2 pairs
// (all pairs (i,j) where i < j)
let expected_tuples = chain_length * (chain_length - 1) / 2;
prop_assert_eq!(structure.relations[0].len(), expected_tuples);
}
/// Test existential conclusion creates fresh witnesses
/// ax/witness : forall x : V. |- exists y : V. [lo: x, hi: y] R
#[test]
fn chase_existential_creates_witnesses(
num_elements in 1..5usize,
) {
let mut universe = Universe::new();
let mut structure = {
let mut s = Structure::new(1);
for i in 0..num_elements {
s.carriers[0].insert(i as u64);
}
s.relations.push(VecRelation::new(2));
s
};
let mut sig = Signature::default();
sig.add_sort("V".to_string());
sig.add_relation("R".to_string(), DerivedSort::Product(vec![
("lo".to_string(), DerivedSort::Base(0)),
("hi".to_string(), DerivedSort::Base(0)),
]));
// Axiom: forall x : V. |- exists y : V. [lo: x, hi: y] R
let axiom = Sequent {
context: Context {
vars: vec![("x".to_string(), DerivedSort::Base(0))],
},
premise: Formula::True,
conclusion: Formula::Exists(
"y".to_string(),
DerivedSort::Base(0),
Box::new(Formula::Rel(0, Term::Record(vec![
("lo".to_string(), Term::Var("x".to_string(), DerivedSort::Base(0))),
("hi".to_string(), Term::Var("y".to_string(), DerivedSort::Base(0))),
]))),
),
};
let _iterations = chase_fixpoint(
&[axiom],
&mut structure,
&mut universe,
&sig,
100,
).unwrap();
// Each original element should have at least one witness
// So we should have at least num_elements tuples
prop_assert!(structure.relations[0].len() >= num_elements);
}
/// Test equality conclusion merges elements via CC
/// ax/collapse : forall x, y : V. [lo: x, hi: y] R |- x = y
#[test]
fn chase_equality_conclusion_reduces_carrier(
num_pairs in 1..4usize,
) {
let mut universe = Universe::new();
let num_elements = num_pairs * 2; // Each pair will merge
let mut structure = {
let mut s = Structure::new(1);
for i in 0..num_elements {
s.carriers[0].insert(i as u64);
}
s.relations.push(VecRelation::new(2));
s
};
let mut sig = Signature::default();
sig.add_sort("V".to_string());
sig.add_relation("R".to_string(), DerivedSort::Product(vec![
("lo".to_string(), DerivedSort::Base(0)),
("hi".to_string(), DerivedSort::Base(0)),
]));
// Seed pairs: (0,1), (2,3), (4,5), ...
// Each pair will be collapsed by the equality axiom
use geolog::id::Slid;
for i in 0..num_pairs {
structure.relations[0].insert(vec![
Slid::from_usize(i * 2),
Slid::from_usize(i * 2 + 1),
]);
}
// Axiom: forall x, y : V. [lo: x, hi: y] R |- x = y
let axiom = Sequent {
context: Context {
vars: vec![
("x".to_string(), DerivedSort::Base(0)),
("y".to_string(), DerivedSort::Base(0)),
],
},
premise: Formula::Rel(0, Term::Record(vec![
("lo".to_string(), Term::Var("x".to_string(), DerivedSort::Base(0))),
("hi".to_string(), Term::Var("y".to_string(), DerivedSort::Base(0))),
])),
conclusion: Formula::Eq(
Term::Var("x".to_string(), DerivedSort::Base(0)),
Term::Var("y".to_string(), DerivedSort::Base(0)),
),
};
let _iterations = chase_fixpoint(
&[axiom],
&mut structure,
&mut universe,
&sig,
100,
).unwrap();
// After canonicalization, carrier should have fewer elements
// Each pair merges into one, so we should have num_pairs elements
prop_assert_eq!(structure.carriers[0].len() as usize, num_pairs);
}
}
}