use xlog_logic::ast::{AggExpr, AggOp, Atom, BodyLiteral, CompOp, Comparison, IsExpr, Rule, Term};
use xlog_logic::hypergraph::{
analyze, explain, is_eligible, AppearanceOrder, Boundary, Eligibility, ExecutorContext,
HypergraphRule, VariableOrder,
};
fn var(name: &str) -> Term {
Term::Variable(name.to_string())
}
fn anon() -> Term {
Term::Anonymous
}
fn int(n: i64) -> Term {
Term::Integer(n)
}
fn atom(predicate: &str, terms: Vec<Term>) -> Atom {
Atom {
predicate: predicate.to_string(),
terms,
}
}
fn rule_with(head: Atom, body: Vec<BodyLiteral>) -> Rule {
Rule { head, body }
}
fn pos(predicate: &str, terms: Vec<Term>) -> BodyLiteral {
BodyLiteral::Positive(atom(predicate, terms))
}
fn neg(predicate: &str, terms: Vec<Term>) -> BodyLiteral {
BodyLiteral::Negated(atom(predicate, terms))
}
fn cmp(left: Term, op: CompOp, right: Term) -> BodyLiteral {
BodyLiteral::Comparison(Comparison { left, op, right })
}
#[test]
fn ir_dedups_repeated_variables_into_a_single_vertex() {
let r = rule_with(
atom("p", vec![var("X"), var("Y")]),
vec![
pos("e", vec![var("X"), var("Z")]),
pos("e", vec![var("Z"), var("Y")]),
pos("e", vec![var("X"), var("Y")]),
],
);
let hg = HypergraphRule::from_rule(&r);
assert_eq!(hg.vertex_count(), 3);
let names: Vec<&str> = hg.vertices.iter().map(|v| v.name.as_str()).collect();
assert_eq!(names, vec!["X", "Z", "Y"]);
assert_eq!(hg.hyperedges.len(), 3);
for edge in &hg.hyperedges {
assert!(edge.vertex_positions.iter().all(|p| p.is_some()));
}
}
#[test]
fn ir_treats_anonymous_wildcards_as_non_vertices() {
let r = rule_with(
atom("p", vec![var("X")]),
vec![
pos("e", vec![var("X"), anon()]),
pos("e", vec![anon(), var("X")]),
],
);
let hg = HypergraphRule::from_rule(&r);
assert_eq!(hg.vertex_count(), 1);
assert_eq!(hg.vertices[0].name, "X");
assert!(hg.hyperedges[0].vertex_positions[0].is_some());
assert!(hg.hyperedges[0].vertex_positions[1].is_none());
assert!(hg.hyperedges[1].vertex_positions[0].is_none());
assert!(hg.hyperedges[1].vertex_positions[1].is_some());
}
#[test]
fn ir_treats_constants_as_non_vertices() {
let r = rule_with(
atom("p", vec![var("X")]),
vec![pos("e", vec![var("X"), int(42)])],
);
let hg = HypergraphRule::from_rule(&r);
assert_eq!(hg.vertex_count(), 1);
assert!(hg.hyperedges[0].vertex_positions[0].is_some());
assert!(hg.hyperedges[0].vertex_positions[1].is_none());
}
#[test]
fn ir_records_comparisons_negation_and_isexpr_flags() {
use xlog_logic::ast::ArithExpr;
let r = rule_with(
atom("p", vec![var("X")]),
vec![
pos("e", vec![var("X"), var("Y")]),
cmp(var("Y"), CompOp::Lt, int(10)),
neg("f", vec![var("X")]),
BodyLiteral::IsExpr(IsExpr {
target: "Z".to_string(),
expr: ArithExpr::Add(
Box::new(ArithExpr::Variable("X".to_string())),
Box::new(ArithExpr::Integer(1)),
),
}),
],
);
let hg = HypergraphRule::from_rule(&r);
assert_eq!(hg.comparison_count, 1);
assert!(hg.has_negation);
assert!(hg.has_is_expr);
assert_eq!(hg.hyperedges.len(), 1);
}
#[test]
fn ir_marks_ground_facts() {
let r = rule_with(atom("edge", vec![int(1), int(2)]), vec![]);
let hg = HypergraphRule::from_rule(&r);
assert!(hg.is_fact);
assert_eq!(hg.hyperedge_count(), 0);
assert_eq!(hg.vertex_count(), 0);
}
#[test]
fn eligible_triangle_query_is_eligible() {
let r = rule_with(
atom("tri", vec![var("X"), var("Y"), var("Z")]),
vec![
pos("e", vec![var("X"), var("Y")]),
pos("e", vec![var("Y"), var("Z")]),
pos("e", vec![var("Z"), var("X")]),
],
);
let hg = HypergraphRule::from_rule(&r);
let v = analyze(&hg, ExecutorContext::HashFallback);
assert_eq!(v, Eligibility::Eligible);
assert!(is_eligible(&hg, ExecutorContext::HashFallback));
assert!(v.boundaries().is_empty());
}
#[test]
fn eligible_two_atom_rule_is_eligible_per_pr_doc() {
let r = rule_with(
atom("p", vec![var("X"), var("Z")]),
vec![
pos("e", vec![var("X"), var("Y")]),
pos("e", vec![var("Y"), var("Z")]),
],
);
let v = analyze(
&HypergraphRule::from_rule(&r),
ExecutorContext::HashFallback,
);
assert_eq!(v, Eligibility::Eligible);
}
#[test]
fn eligible_rule_with_filters_stays_eligible() {
let r = rule_with(
atom("p", vec![var("X"), var("Z")]),
vec![
pos("e", vec![var("X"), var("Y")]),
pos("e", vec![var("Y"), var("Z")]),
cmp(var("Y"), CompOp::Lt, int(10)),
],
);
let v = analyze(
&HypergraphRule::from_rule(&r),
ExecutorContext::HashFallback,
);
assert_eq!(v, Eligibility::Eligible);
}
#[test]
fn ground_fact_is_ineligible_with_groundfact_boundary() {
let r = rule_with(atom("edge", vec![int(1), int(2)]), vec![]);
let v = analyze(
&HypergraphRule::from_rule(&r),
ExecutorContext::HashFallback,
);
let bs = v.boundaries();
assert!(bs.contains(&Boundary::GroundFact));
assert!(!bs
.iter()
.any(|b| matches!(b, Boundary::InsufficientPositiveAtoms { .. })));
}
#[test]
fn head_aggregation_triggers_headaggregation_boundary() {
let head = Atom {
predicate: "p".to_string(),
terms: vec![Term::Aggregate(AggExpr {
op: AggOp::Count,
variable: "X".to_string(),
})],
};
let r = rule_with(
head,
vec![
pos("e", vec![var("X"), var("Y")]),
pos("e", vec![var("Y"), var("Z")]),
],
);
let v = analyze(
&HypergraphRule::from_rule(&r),
ExecutorContext::HashFallback,
);
assert!(v.boundaries().contains(&Boundary::HeadAggregation));
}
#[test]
fn body_negation_triggers_bodynegation_boundary() {
let r = rule_with(
atom("p", vec![var("X"), var("Y")]),
vec![
pos("e", vec![var("X"), var("Y")]),
neg("f", vec![var("X"), var("Y")]),
],
);
let v = analyze(
&HypergraphRule::from_rule(&r),
ExecutorContext::HashFallback,
);
assert!(v.boundaries().contains(&Boundary::BodyNegation));
}
#[test]
fn body_is_expr_triggers_bodyisexpr_boundary() {
use xlog_logic::ast::ArithExpr;
let r = rule_with(
atom("p", vec![var("X"), var("Z")]),
vec![
pos("e", vec![var("X"), var("Y")]),
BodyLiteral::IsExpr(IsExpr {
target: "Z".to_string(),
expr: ArithExpr::Add(
Box::new(ArithExpr::Variable("X".to_string())),
Box::new(ArithExpr::Integer(1)),
),
}),
pos("q", vec![var("Z")]),
],
);
let v = analyze(
&HypergraphRule::from_rule(&r),
ExecutorContext::HashFallback,
);
assert!(v.boundaries().contains(&Boundary::BodyIsExpr));
}
#[test]
fn single_atom_body_triggers_insufficientpositiveatoms_boundary() {
let r = rule_with(
atom("p", vec![var("X")]),
vec![pos("e", vec![var("X"), var("Y")])],
);
let v = analyze(
&HypergraphRule::from_rule(&r),
ExecutorContext::HashFallback,
);
assert!(v
.boundaries()
.contains(&Boundary::InsufficientPositiveAtoms { positive_count: 1 }));
}
#[test]
fn comparison_only_body_triggers_insufficientpositiveatoms_boundary() {
let r = rule_with(atom("q", vec![]), vec![cmp(int(1), CompOp::Lt, int(2))]);
let v = analyze(
&HypergraphRule::from_rule(&r),
ExecutorContext::HashFallback,
);
assert!(v
.boundaries()
.contains(&Boundary::InsufficientPositiveAtoms { positive_count: 0 }));
}
#[test]
fn five_join_keys_trigger_joinkeysexceedbinaryfallbacklimit_boundary() {
let r = rule_with(
atom("p", vec![var("A"), var("B"), var("C"), var("D"), var("E")]),
vec![
pos("r1", vec![var("A"), var("B")]),
pos("r2", vec![var("B"), var("C")]),
pos("r3", vec![var("C"), var("D")]),
pos("r4", vec![var("D"), var("E")]),
pos("r5", vec![var("E"), var("A")]),
],
);
let v = analyze(
&HypergraphRule::from_rule(&r),
ExecutorContext::HashFallback,
);
let bs = v.boundaries();
assert!(bs.iter().any(|b| matches!(
b,
Boundary::JoinKeysExceedBinaryFallbackLimit {
context: ExecutorContext::HashFallback,
count: 5,
limit: 4,
}
)));
}
#[test]
fn four_join_keys_stay_eligible() {
let r = rule_with(
atom("p", vec![var("A"), var("B"), var("C"), var("D")]),
vec![
pos("r1", vec![var("A"), var("B")]),
pos("r2", vec![var("B"), var("C")]),
pos("r3", vec![var("C"), var("D")]),
pos("r4", vec![var("D"), var("A")]),
],
);
let v = analyze(
&HypergraphRule::from_rule(&r),
ExecutorContext::HashFallback,
);
assert_eq!(v, Eligibility::Eligible);
}
#[test]
fn self_join_within_single_atom_counts_as_one_vertex_occurrence() {
let r = rule_with(
atom("p", vec![var("X"), var("Y")]),
vec![
pos("r", vec![var("X"), var("X")]),
pos("s", vec![var("X"), var("Y")]),
],
);
let hg = HypergraphRule::from_rule(&r);
let r_edge = &hg.hyperedges[0];
assert_eq!(
r_edge.vertices().len(),
1,
"self-join within an atom must not double-count the vertex"
);
let v = analyze(&hg, ExecutorContext::HashFallback);
assert_eq!(v, Eligibility::Eligible);
}
#[test]
fn projection_only_variables_do_not_count_as_join_keys() {
let r = rule_with(
atom("p", vec![var("A"), var("B"), var("C"), var("D"), var("E")]),
vec![
pos("r1", vec![var("A"), var("B"), var("C")]),
pos("r2", vec![var("C"), var("D"), var("E")]),
],
);
let hg = HypergraphRule::from_rule(&r);
assert_eq!(hg.vertex_count(), 5);
let v = analyze(&hg, ExecutorContext::HashFallback);
assert_eq!(v, Eligibility::Eligible);
}
#[test]
fn multiple_boundaries_are_reported_independently() {
use xlog_logic::ast::ArithExpr;
let r = rule_with(
atom("p", vec![]),
vec![
neg("e", vec![var("X"), var("Y"), var("Z"), var("W"), var("V")]),
BodyLiteral::IsExpr(IsExpr {
target: "W".to_string(),
expr: ArithExpr::Add(
Box::new(ArithExpr::Variable("X".to_string())),
Box::new(ArithExpr::Integer(1)),
),
}),
],
);
let v = analyze(
&HypergraphRule::from_rule(&r),
ExecutorContext::HashFallback,
);
let bs = v.boundaries();
assert!(bs.contains(&Boundary::BodyNegation));
assert!(bs.contains(&Boundary::BodyIsExpr));
assert!(bs
.iter()
.any(|b| matches!(b, Boundary::InsufficientPositiveAtoms { .. })));
}
#[test]
fn appearance_order_is_deterministic_and_complete() {
let r = rule_with(
atom("tri", vec![var("X"), var("Y"), var("Z")]),
vec![
pos("e", vec![var("X"), var("Y")]),
pos("e", vec![var("Y"), var("Z")]),
pos("e", vec![var("Z"), var("X")]),
],
);
let hg = HypergraphRule::from_rule(&r);
let vo = AppearanceOrder;
let order_a = vo.order(&hg);
let order_b = vo.order(&hg);
assert_eq!(order_a, order_b, "deterministic across calls");
assert_eq!(order_a.len(), hg.vertex_count(), "covers every vertex");
let names: Vec<&str> = order_a
.iter()
.map(|v| hg.vertex(*v).name.as_str())
.collect();
assert_eq!(names, vec!["X", "Y", "Z"]);
}
#[test]
fn appearance_order_name_is_stable() {
assert_eq!(AppearanceOrder.name(), "appearance");
}
#[test]
fn explain_eligible_triangle_snapshot() {
let r = rule_with(
atom("tri", vec![var("X"), var("Y"), var("Z")]),
vec![
pos("e", vec![var("X"), var("Y")]),
pos("e", vec![var("Y"), var("Z")]),
pos("e", vec![var("Z"), var("X")]),
],
);
let hg = HypergraphRule::from_rule(&r);
let v = analyze(&hg, ExecutorContext::HashFallback);
let s = explain(&hg, &v, &AppearanceOrder);
let expected = "\
rule head=tri
vertices: [X Y Z]
hyperedges:
e(?X, ?Y)
e(?Y, ?Z)
e(?Z, ?X)
filters: 0
eligibility: Eligible
variable-order(appearance): [X Y Z]
";
assert_eq!(s, expected);
}
#[test]
fn explain_ineligible_aggregation_snapshot() {
let head = Atom {
predicate: "p".to_string(),
terms: vec![Term::Aggregate(AggExpr {
op: AggOp::Count,
variable: "X".to_string(),
})],
};
let r = rule_with(
head,
vec![
pos("e", vec![var("X"), var("Y")]),
pos("f", vec![var("Y"), var("Z")]),
],
);
let hg = HypergraphRule::from_rule(&r);
let v = analyze(&hg, ExecutorContext::HashFallback);
let s = explain(&hg, &v, &AppearanceOrder);
let expected = "\
rule head=p
vertices: [X Y Z]
hyperedges:
e(?X, ?Y)
f(?Y, ?Z)
filters: 0
eligibility: Ineligible
HeadAggregation
variable-order(appearance): [X Y Z]
";
assert_eq!(s, expected);
}
#[test]
fn explain_ineligible_negation_snapshot() {
let r = rule_with(
atom("p", vec![var("X"), var("Y")]),
vec![
pos("e", vec![var("X"), var("Y")]),
neg("f", vec![var("X"), var("Y")]),
],
);
let hg = HypergraphRule::from_rule(&r);
let v = analyze(&hg, ExecutorContext::HashFallback);
let s = explain(&hg, &v, &AppearanceOrder);
let expected = "\
rule head=p
vertices: [X Y]
hyperedges:
e(?X, ?Y)
filters: 0
eligibility: Ineligible
BodyNegation
InsufficientPositiveAtoms(positive_count=1)
variable-order(appearance): [X Y]
";
assert_eq!(s, expected);
}
#[test]
fn explain_ineligible_keys_over_4_snapshot() {
let r = rule_with(
atom("p", vec![var("A"), var("B"), var("C"), var("D"), var("E")]),
vec![
pos("r1", vec![var("A"), var("B")]),
pos("r2", vec![var("B"), var("C")]),
pos("r3", vec![var("C"), var("D")]),
pos("r4", vec![var("D"), var("E")]),
pos("r5", vec![var("E"), var("A")]),
],
);
let hg = HypergraphRule::from_rule(&r);
let v = analyze(&hg, ExecutorContext::HashFallback);
let s = explain(&hg, &v, &AppearanceOrder);
let expected = "\
rule head=p
vertices: [A B C D E]
hyperedges:
r1(?A, ?B)
r2(?B, ?C)
r3(?C, ?D)
r4(?D, ?E)
r5(?E, ?A)
filters: 0
eligibility: Ineligible
JoinKeysExceedBinaryFallbackLimit(context=HashFallback, count=5, limit=4)
variable-order(appearance): [A B C D E]
";
assert_eq!(s, expected);
}
#[test]
fn explain_single_atom_snapshot() {
let r = rule_with(
atom("p", vec![var("X")]),
vec![pos("e", vec![var("X"), var("Y")])],
);
let hg = HypergraphRule::from_rule(&r);
let v = analyze(&hg, ExecutorContext::HashFallback);
let s = explain(&hg, &v, &AppearanceOrder);
let expected = "\
rule head=p
vertices: [X Y]
hyperedges:
e(?X, ?Y)
filters: 0
eligibility: Ineligible
InsufficientPositiveAtoms(positive_count=1)
variable-order(appearance): [X Y]
";
assert_eq!(s, expected);
}
#[test]
fn explain_multi_boundary_snapshot_locks_emission_order() {
use xlog_logic::ast::ArithExpr;
let r = rule_with(
atom("p", vec![]),
vec![
neg("e", vec![var("X"), var("Y"), var("Z"), var("W"), var("V")]),
BodyLiteral::IsExpr(IsExpr {
target: "W".to_string(),
expr: ArithExpr::Add(
Box::new(ArithExpr::Variable("X".to_string())),
Box::new(ArithExpr::Integer(1)),
),
}),
],
);
let hg = HypergraphRule::from_rule(&r);
let v = analyze(&hg, ExecutorContext::HashFallback);
let s = explain(&hg, &v, &AppearanceOrder);
let expected = "\
rule head=p
vertices: []
hyperedges: <none>
filters: 0
eligibility: Ineligible
BodyNegation
BodyIsExpr
InsufficientPositiveAtoms(positive_count=0)
variable-order(appearance): []
";
assert_eq!(s, expected);
}
#[test]
fn explain_ground_fact_snapshot() {
let r = rule_with(atom("edge", vec![int(1), int(2)]), vec![]);
let hg = HypergraphRule::from_rule(&r);
let v = analyze(&hg, ExecutorContext::HashFallback);
let s = explain(&hg, &v, &AppearanceOrder);
let expected = "\
rule head=edge
vertices: []
hyperedges: <none>
filters: 0
eligibility: Ineligible
GroundFact
variable-order(appearance): []
";
assert_eq!(s, expected);
}
#[test]
fn explain_body_is_expr_snapshot() {
use xlog_logic::ast::ArithExpr;
let r = rule_with(
atom("p", vec![var("X")]),
vec![
pos("e", vec![var("X"), var("Y")]),
BodyLiteral::IsExpr(IsExpr {
target: "Z".to_string(),
expr: ArithExpr::Add(
Box::new(ArithExpr::Variable("X".to_string())),
Box::new(ArithExpr::Integer(1)),
),
}),
],
);
let hg = HypergraphRule::from_rule(&r);
let v = analyze(&hg, ExecutorContext::HashFallback);
let s = explain(&hg, &v, &AppearanceOrder);
let expected = "\
rule head=p
vertices: [X Y]
hyperedges:
e(?X, ?Y)
filters: 0
eligibility: Ineligible
BodyIsExpr
InsufficientPositiveAtoms(positive_count=1)
variable-order(appearance): [X Y]
";
assert_eq!(s, expected);
}
#[test]
fn explain_with_filters_and_anonymous_wildcards_snapshot() {
let r = rule_with(
atom("p", vec![var("X"), var("Z")]),
vec![
pos("e", vec![var("X"), var("Y"), anon()]),
pos("e", vec![var("Y"), var("Z"), anon()]),
cmp(var("Y"), CompOp::Lt, int(10)),
],
);
let hg = HypergraphRule::from_rule(&r);
let v = analyze(&hg, ExecutorContext::HashFallback);
let s = explain(&hg, &v, &AppearanceOrder);
let expected = "\
rule head=p
vertices: [X Y Z]
hyperedges:
e(?X, ?Y, _)
e(?Y, ?Z, _)
filters: 1
eligibility: Eligible
variable-order(appearance): [X Y Z]
";
assert_eq!(s, expected);
}