use std::collections::BTreeMap;
use xlog_core::ScalarType;
use xlog_logic::ast::{Atom, BodyLiteral, Rule, Term};
use xlog_logic::hypergraph::{
evaluate_fixpoint_typed, evaluate_rule_typed, evaluate_scc_fixpoint_typed, AppearanceOrder,
Boundary, FixpointConfig, FixpointError, RefEvalError, RefRelation, RefRelationStore, RefValue,
SccFixpointError,
};
fn var(name: &str) -> Term {
Term::Variable(name.to_string())
}
fn atom(predicate: &str, terms: Vec<Term>) -> Atom {
Atom {
predicate: predicate.to_string(),
terms,
}
}
fn pos(predicate: &str, terms: Vec<Term>) -> BodyLiteral {
BodyLiteral::Positive(atom(predicate, terms))
}
fn rule_with(head: Atom, body: Vec<BodyLiteral>) -> Rule {
Rule { head, body }
}
fn u32_relation(rows: &[&[u32]]) -> RefRelation {
let arity = rows.first().map(|r| r.len()).unwrap_or(0);
RefRelation {
schema: vec![ScalarType::U32; arity],
rows: rows
.iter()
.map(|r| r.iter().map(|v| RefValue::U32(*v)).collect())
.collect(),
}
}
fn store_with_one(name: &str, rel: RefRelation) -> RefRelationStore {
let mut s: RefRelationStore = BTreeMap::new();
s.insert(name.to_string(), rel);
s
}
fn pairs_from_rel(rel: &RefRelation) -> Vec<(u32, u32)> {
let mut rows = rel.rows.clone();
rows.sort();
rows.iter()
.map(|r| match (&r[0], &r[1]) {
(RefValue::U32(a), RefValue::U32(b)) => (*a, *b),
other => panic!("unexpected RefValue shape: {other:?}"),
})
.collect()
}
#[test]
fn evaluate_rule_typed_evaluates_normally_for_supported_join_keys() {
let r = rule_with(
atom("reach", vec![var("X"), var("Z")]),
vec![
pos("edge", vec![var("X"), var("Y")]),
pos("edge", vec![var("Y"), var("Z")]),
],
);
let edges = u32_relation(&[&[1, 2], &[2, 3], &[3, 4]]);
let store = store_with_one("edge", edges);
let rows = evaluate_rule_typed(&r, &store, &AppearanceOrder).expect("typed gate passes");
let mut got: Vec<(u32, u32)> = rows
.iter()
.map(|r| match (&r[0], &r[1]) {
(RefValue::U32(a), RefValue::U32(b)) => (*a, *b),
other => panic!("unexpected: {other:?}"),
})
.collect();
got.sort();
assert_eq!(got, vec![(1, 3), (2, 4)]);
}
#[test]
fn evaluate_rule_typed_rejects_i32_join_key() {
let r = rule_with(
atom("reach", vec![var("X"), var("Z")]),
vec![
pos("edge", vec![var("X"), var("Y")]),
pos("edge", vec![var("Y"), var("Z")]),
],
);
let edge_i32 = RefRelation {
schema: vec![ScalarType::I32, ScalarType::I32],
rows: vec![
vec![RefValue::I32(1), RefValue::I32(2)],
vec![RefValue::I32(2), RefValue::I32(3)],
],
};
let store = store_with_one("edge", edge_i32);
let err = evaluate_rule_typed(&r, &store, &AppearanceOrder)
.expect_err("I32 join key must be rejected");
match err {
RefEvalError::Ineligible(bs) => {
assert!(
bs.contains(&Boundary::UnsupportedKeyType {
var: "Y".to_string(),
ty: ScalarType::I32,
}),
"expected UnsupportedKeyType for Y:I32, got {bs:?}"
);
}
other => panic!("expected Ineligible, got {other:?}"),
}
}
#[test]
fn evaluate_rule_typed_rejects_bool_join_key() {
let r = rule_with(
atom("reach", vec![var("X"), var("Z")]),
vec![
pos("edge", vec![var("X"), var("Y")]),
pos("edge", vec![var("Y"), var("Z")]),
],
);
let edge_bool = RefRelation {
schema: vec![ScalarType::Bool, ScalarType::Bool],
rows: vec![vec![RefValue::Bool(true), RefValue::Bool(false)]],
};
let store = store_with_one("edge", edge_bool);
let err = evaluate_rule_typed(&r, &store, &AppearanceOrder)
.expect_err("Bool join key must be rejected");
match err {
RefEvalError::Ineligible(bs) => {
assert!(
bs.contains(&Boundary::UnsupportedKeyType {
var: "Y".to_string(),
ty: ScalarType::Bool,
}),
"expected UnsupportedKeyType for Y:Bool, got {bs:?}"
);
}
other => panic!("expected Ineligible, got {other:?}"),
}
}
#[test]
fn evaluate_rule_typed_allows_unsupported_type_in_projection_only_column() {
let r = rule_with(
atom("tag", vec![var("X"), var("Z")]),
vec![
pos("p", vec![var("X"), var("Y")]),
pos("q", vec![var("X"), var("Z")]),
],
);
let p = RefRelation {
schema: vec![ScalarType::U32, ScalarType::I32],
rows: vec![
vec![RefValue::U32(1), RefValue::I32(-7)],
vec![RefValue::U32(2), RefValue::I32(9)],
],
};
let q = u32_relation(&[&[1, 100], &[2, 200]]);
let mut store: RefRelationStore = BTreeMap::new();
store.insert("p".into(), p);
store.insert("q".into(), q);
let rows = evaluate_rule_typed(&r, &store, &AppearanceOrder)
.expect("projection-only I32 must pass typed gate");
let mut got: Vec<(u32, u32)> = rows
.iter()
.map(|r| match (&r[0], &r[1]) {
(RefValue::U32(a), RefValue::U32(b)) => (*a, *b),
other => panic!("unexpected: {other:?}"),
})
.collect();
got.sort();
assert_eq!(got, vec![(1, 100), (2, 200)]);
}
#[test]
fn evaluate_rule_typed_conflicts_when_variable_has_incompatible_types() {
let r = rule_with(
atom("tag", vec![var("X")]),
vec![
pos("p", vec![var("X"), var("Y")]),
pos("q", vec![var("X"), var("Z")]),
],
);
let p = u32_relation(&[&[1, 2]]);
let q = RefRelation {
schema: vec![ScalarType::Symbol, ScalarType::U32],
rows: vec![vec![RefValue::Symbol("alpha".into()), RefValue::U32(7)]],
};
let mut store: RefRelationStore = BTreeMap::new();
store.insert("p".into(), p);
store.insert("q".into(), q);
let err =
evaluate_rule_typed(&r, &store, &AppearanceOrder).expect_err("type conflict must fail");
match err {
RefEvalError::ConflictingVariableType {
var,
first_predicate,
first_position,
first_type,
second_predicate,
second_position,
second_type,
} => {
assert_eq!(var, "X");
assert_eq!(first_predicate, "p");
assert_eq!(first_position, 0);
assert_eq!(first_type, ScalarType::U32);
assert_eq!(second_predicate, "q");
assert_eq!(second_position, 0);
assert_eq!(second_type, ScalarType::Symbol);
}
other => panic!("expected ConflictingVariableType, got {other:?}"),
}
}
#[test]
fn evaluate_rule_typed_recursive_only_join_key_passes_gate() {
let r = rule_with(
atom("even", vec![var("X"), var("Y")]),
vec![
pos("odd", vec![var("X"), var("Z")]),
pos("odd", vec![var("Z"), var("Y")]),
],
);
let store: RefRelationStore = BTreeMap::new();
let err = evaluate_rule_typed(&r, &store, &AppearanceOrder)
.expect_err("missing relation must surface");
match err {
RefEvalError::MissingRelation(name) => {
assert_eq!(name, "odd");
}
other => {
panic!("expected MissingRelation (gate must NOT reject unknown types), got {other:?}")
}
}
}
#[test]
fn evaluate_rule_typed_propagates_ground_fact_boundary() {
let r = rule_with(atom("fact", vec![Term::Integer(1)]), vec![]);
let store: RefRelationStore = BTreeMap::new();
let err = evaluate_rule_typed(&r, &store, &AppearanceOrder)
.expect_err("ground fact must be Ineligible");
match err {
RefEvalError::Ineligible(bs) => {
assert!(
bs.contains(&Boundary::GroundFact),
"expected GroundFact, got {bs:?}"
);
}
other => panic!("expected Ineligible(GroundFact), got {other:?}"),
}
}
#[test]
fn evaluate_fixpoint_typed_evaluates_normally_for_supported_keys() {
let r_seed = rule_with(
atom("reach", vec![var("X"), var("Y")]),
vec![
pos("edge", vec![var("X"), var("M")]),
pos("edge", vec![var("M"), var("Y")]),
],
);
let r_step = rule_with(
atom("reach", vec![var("X"), var("Z")]),
vec![
pos("edge", vec![var("X"), var("Y")]),
pos("reach", vec![var("Y"), var("Z")]),
],
);
let edges = u32_relation(&[&[1, 2], &[2, 3], &[3, 4]]);
let store = store_with_one("edge", edges);
let rules = vec![r_seed, r_step];
let result = evaluate_fixpoint_typed(
&rules,
&store,
"reach",
&AppearanceOrder,
&FixpointConfig::default(),
)
.expect("typed fixpoint converges");
let mut expected = vec![(1u32, 3u32), (2, 4), (1, 4)];
expected.sort();
assert_eq!(pairs_from_rel(&result), expected);
}
#[test]
fn evaluate_fixpoint_typed_rejects_unsupported_join_key() {
let r_seed = rule_with(
atom("reach", vec![var("X"), var("Y")]),
vec![
pos("edge", vec![var("X"), var("M")]),
pos("edge", vec![var("M"), var("Y")]),
],
);
let r_step = rule_with(
atom("reach", vec![var("X"), var("Z")]),
vec![
pos("edge", vec![var("X"), var("Y")]),
pos("reach", vec![var("Y"), var("Z")]),
],
);
let edge_i32 = RefRelation {
schema: vec![ScalarType::I32, ScalarType::I32],
rows: vec![vec![RefValue::I32(1), RefValue::I32(2)]],
};
let store = store_with_one("edge", edge_i32);
let rules = vec![r_seed, r_step];
let err = evaluate_fixpoint_typed(
&rules,
&store,
"reach",
&AppearanceOrder,
&FixpointConfig::default(),
)
.expect_err("I32 join key must be rejected by typed fixpoint");
match err {
FixpointError::RuleEval {
rule_index,
source: RefEvalError::Ineligible(bs),
} => {
assert!(
rule_index == 0 || rule_index == 1,
"expected rule_index in {{0,1}}, got {rule_index}"
);
assert!(
bs.iter().any(|b| matches!(
b,
Boundary::UnsupportedKeyType {
ty: ScalarType::I32,
..
}
)),
"expected UnsupportedKeyType I32, got {bs:?}"
);
}
other => panic!("expected RuleEval(Ineligible), got {other:?}"),
}
}
#[test]
fn evaluate_scc_fixpoint_typed_evaluates_normally_for_supported_keys() {
let even_step = rule_with(
atom("even_path", vec![var("X"), var("Z")]),
vec![
pos("edge", vec![var("X"), var("Y")]),
pos("odd_path", vec![var("Y"), var("Z")]),
],
);
let even_seed = rule_with(
atom("even_path", vec![var("X"), var("Z")]),
vec![
pos("edge", vec![var("X"), var("M")]),
pos("edge", vec![var("M"), var("Z")]),
],
);
let odd_step = rule_with(
atom("odd_path", vec![var("X"), var("Z")]),
vec![
pos("edge", vec![var("X"), var("Y")]),
pos("even_path", vec![var("Y"), var("Z")]),
],
);
let odd_seed = rule_with(
atom("odd_path", vec![var("X"), var("Z")]),
vec![
pos("edge", vec![var("X"), var("M")]),
pos("edge", vec![var("M"), var("Y")]),
pos("edge", vec![var("Y"), var("Z")]),
],
);
let edges = u32_relation(&[&[1, 2], &[2, 3], &[3, 4]]);
let store = store_with_one("edge", edges);
let mut rules: BTreeMap<String, Vec<Rule>> = BTreeMap::new();
rules.insert("even_path".into(), vec![even_seed, even_step]);
rules.insert("odd_path".into(), vec![odd_seed, odd_step]);
let result =
evaluate_scc_fixpoint_typed(&rules, &store, &AppearanceOrder, &FixpointConfig::default())
.expect("typed scc fixpoint converges");
assert!(result.contains_key("even_path"));
assert!(result.contains_key("odd_path"));
}
#[test]
fn evaluate_scc_fixpoint_typed_rejects_unsupported_join_key() {
let even_seed = rule_with(
atom("even_path", vec![var("X"), var("Z")]),
vec![
pos("edge", vec![var("X"), var("M")]),
pos("edge", vec![var("M"), var("Z")]),
],
);
let odd_seed = rule_with(
atom("odd_path", vec![var("X"), var("Z")]),
vec![
pos("edge", vec![var("X"), var("M")]),
pos("edge", vec![var("M"), var("Y")]),
pos("edge", vec![var("Y"), var("Z")]),
],
);
let edge_i32 = RefRelation {
schema: vec![ScalarType::I32, ScalarType::I32],
rows: vec![vec![RefValue::I32(1), RefValue::I32(2)]],
};
let store = store_with_one("edge", edge_i32);
let mut rules: BTreeMap<String, Vec<Rule>> = BTreeMap::new();
rules.insert("even_path".into(), vec![even_seed]);
rules.insert("odd_path".into(), vec![odd_seed]);
let err =
evaluate_scc_fixpoint_typed(&rules, &store, &AppearanceOrder, &FixpointConfig::default())
.expect_err("I32 join key must be rejected by typed scc");
match err {
SccFixpointError::RuleEval {
source: RefEvalError::Ineligible(bs),
..
} => {
assert!(
bs.iter().any(|b| matches!(
b,
Boundary::UnsupportedKeyType {
ty: ScalarType::I32,
..
}
)),
"expected UnsupportedKeyType I32, got {bs:?}"
);
}
other => panic!("expected RuleEval(Ineligible), got {other:?}"),
}
}
#[test]
fn evaluate_scc_fixpoint_typed_recursive_only_join_keys_pass_gate() {
let even_rule = rule_with(
atom("even", vec![var("X"), var("Y")]),
vec![
pos("odd", vec![var("X"), var("Z")]),
pos("odd", vec![var("Z"), var("Y")]),
],
);
let odd_rule = rule_with(
atom("odd", vec![var("X"), var("Y")]),
vec![
pos("edge", vec![var("X"), var("M")]),
pos("edge", vec![var("M"), var("Y")]),
],
);
let edges = u32_relation(&[&[1, 2], &[2, 3], &[3, 4], &[4, 5]]);
let store = store_with_one("edge", edges);
let mut rules: BTreeMap<String, Vec<Rule>> = BTreeMap::new();
rules.insert("even".into(), vec![even_rule]);
rules.insert("odd".into(), vec![odd_rule]);
let result =
evaluate_scc_fixpoint_typed(&rules, &store, &AppearanceOrder, &FixpointConfig::default())
.expect("recursive-only join keys must clear typed gate");
let odd_actual = pairs_from_rel(result.get("odd").expect("odd present"));
let mut odd_expected = vec![(1u32, 3u32), (2, 4), (3, 5)];
odd_expected.sort();
assert_eq!(odd_actual, odd_expected);
let even_actual = pairs_from_rel(result.get("even").expect("even present"));
assert_eq!(even_actual, vec![(1u32, 5u32)]);
}
#[test]
fn typed_fixpoint_structural_wrong_target_wins_over_typed_gate() {
let bad_rule = rule_with(
atom("not_reach", vec![var("X"), var("Z")]),
vec![
pos("edge", vec![var("X"), var("Y")]),
pos("edge", vec![var("Y"), var("Z")]),
],
);
let edge_bool = RefRelation {
schema: vec![ScalarType::Bool, ScalarType::Bool],
rows: vec![vec![RefValue::Bool(true), RefValue::Bool(false)]],
};
let store = store_with_one("edge", edge_bool);
let rules = vec![bad_rule];
let err = evaluate_fixpoint_typed(
&rules,
&store,
"reach",
&AppearanceOrder,
&FixpointConfig::default(),
)
.expect_err("wrong-target rule must surface structural error");
match err {
FixpointError::RuleNotForTarget {
rule_index,
observed,
expected,
} => {
assert_eq!(rule_index, 0);
assert_eq!(observed, "not_reach");
assert_eq!(expected, "reach");
}
other => {
panic!("expected structural RuleNotForTarget (typed gate must defer), got {other:?}")
}
}
}
#[test]
fn typed_scc_structural_misgrouped_rule_wins_over_typed_gate() {
let misgrouped = rule_with(
atom("sg", vec![var("X"), var("Z")]),
vec![
pos("edge", vec![var("X"), var("Y")]),
pos("edge", vec![var("Y"), var("Z")]),
],
);
let edge_bool = RefRelation {
schema: vec![ScalarType::Bool, ScalarType::Bool],
rows: vec![vec![RefValue::Bool(true), RefValue::Bool(false)]],
};
let store = store_with_one("edge", edge_bool);
let mut rules: BTreeMap<String, Vec<Rule>> = BTreeMap::new();
rules.insert("reach".into(), vec![misgrouped]);
let err =
evaluate_scc_fixpoint_typed(&rules, &store, &AppearanceOrder, &FixpointConfig::default())
.expect_err("misgrouped rule must surface structural error");
match err {
SccFixpointError::RuleHeadPredicateMismatch {
group_key,
rule_index,
observed,
} => {
assert_eq!(group_key, "reach");
assert_eq!(rule_index, 0);
assert_eq!(observed, "sg");
}
other => panic!(
"expected structural RuleHeadPredicateMismatch (typed gate must defer), got {other:?}"
),
}
}
#[test]
fn typed_gate_conflict_precedes_structural_boundary() {
let r = rule_with(
atom("tag", vec![var("X")]),
vec![
pos("p", vec![var("X"), var("Y")]),
pos("q", vec![var("X"), var("Z")]),
BodyLiteral::Negated(atom("forbidden", vec![var("X")])),
],
);
let p = u32_relation(&[&[1, 2]]);
let q = RefRelation {
schema: vec![ScalarType::Symbol, ScalarType::U32],
rows: vec![vec![RefValue::Symbol("a".into()), RefValue::U32(0)]],
};
let mut store: RefRelationStore = BTreeMap::new();
store.insert("p".into(), p);
store.insert("q".into(), q);
store.insert(
"forbidden".into(),
RefRelation {
schema: vec![ScalarType::U32],
rows: vec![],
},
);
let err = evaluate_rule_typed(&r, &store, &AppearanceOrder)
.expect_err("rule with both conflict and negation must fail");
match err {
RefEvalError::ConflictingVariableType { var, .. } => {
assert_eq!(var, "X");
}
other => panic!(
"expected ConflictingVariableType (within-gate precedence: \
conflict before boundary), got {other:?}"
),
}
}