use fabula::prelude::*;
use fabula_memory::{MemGraph, MemValue};
#[test]
fn incremental_negation_kills_only_matching_variable_bindings() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("enter_then_harm")
.stage("e1", |s| {
s.edge("e1", "eventType".into(), MemValue::Str("enter".into()))
.edge_bind("e1", "actor".into(), "guest")
})
.stage("e2", |s| {
s.edge("e2", "eventType".into(), MemValue::Str("harm".into()))
.edge_bind("e2", "actor".into(), "host")
.edge_bind("e2", "target".into(), "guest")
})
.unless_between("e1", "e2", |neg| {
neg.edge("mid", "eventType".into(), MemValue::Str("leave".into()))
.edge_bind("mid", "actor".into(), "guest")
})
.build(),
);
g.add_str("ev1", "eventType", "enter", 1);
g.add_ref("ev1", "actor", "alice", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"eventType".into(),
&MemValue::Str("enter".into()),
&Interval::open(1),
);
g.add_str("ev2", "eventType", "enter", 2);
g.add_ref("ev2", "actor", "bob", 2);
g.set_time(2);
engine.on_edge_added(
&g,
&"ev2".into(),
&"eventType".into(),
&MemValue::Str("enter".into()),
&Interval::open(2),
);
assert_eq!(engine.active_matches_for("enter_then_harm").len(), 2);
g.add_str("ev_leave", "eventType", "leave", 3);
g.add_ref("ev_leave", "actor", "alice", 3);
g.set_time(3);
let events = engine.on_edge_added(
&g,
&"ev_leave".into(),
&"eventType".into(),
&MemValue::Str("leave".into()),
&Interval::open(3),
);
let negated_count = events
.iter()
.filter(|e| matches!(e, SiftEvent::Negated { .. }))
.count();
assert_eq!(
negated_count, 1,
"only alice's partial match should be negated"
);
assert_eq!(
engine.active_matches_for("enter_then_harm").len(),
1,
"bob's partial match should survive"
);
}
#[test]
fn out_of_order_insertion_incremental_misses_match() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("enter_then_harm")
.stage("e1", |s| {
s.edge("e1", "eventType".into(), MemValue::Str("enter".into()))
.edge_bind("e1", "actor".into(), "person")
})
.stage("e2", |s| {
s.edge("e2", "eventType".into(), MemValue::Str("harm".into()))
.edge_bind("e2", "actor".into(), "person")
})
.build(),
);
g.add_str("ev2", "eventType", "harm", 5);
g.add_ref("ev2", "actor", "alice", 5);
g.set_time(5);
engine.on_edge_added(
&g,
&"ev2".into(),
&"eventType".into(),
&MemValue::Str("harm".into()),
&Interval::open(5),
);
g.add_str("ev1", "eventType", "enter", 1);
g.add_ref("ev1", "actor", "alice", 1);
g.set_time(5);
engine.on_edge_added(
&g,
&"ev1".into(),
&"eventType".into(),
&MemValue::Str("enter".into()),
&Interval::open(1),
);
let incremental_completed = engine
.partial_matches()
.iter()
.filter(|pm| pm.state == MatchState::Complete)
.count();
assert_eq!(
incremental_completed, 0,
"incremental misses match when edges arrive out of chronological order"
);
let batch_matches = engine.evaluate(&g);
assert_eq!(
batch_matches.len(),
1,
"batch correctly finds the match regardless of insertion order"
);
}
#[test]
fn batch_and_incremental_agree_on_simple_case() {
let pattern = PatternBuilder::new("hospitality_violation")
.stage("e1", |s| {
s.edge("e1", "eventType".into(), MemValue::Str("enter".into()))
.edge_bind("e1", "actor".into(), "guest")
})
.stage("e2", |s| {
s.edge(
"e2",
"eventType".into(),
MemValue::Str("show_hospitality".into()),
)
.edge_bind("e2", "actor".into(), "host")
.edge_bind("e2", "target".into(), "guest")
})
.stage("e3", |s| {
s.edge("e3", "eventType".into(), MemValue::Str("harm".into()))
.edge_bind("e3", "actor".into(), "host")
.edge_bind("e3", "target".into(), "guest")
})
.build();
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(pattern.clone());
g.add_str("ev1", "eventType", "enter", 1);
g.add_ref("ev1", "actor", "alice", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"eventType".into(),
&MemValue::Str("enter".into()),
&Interval::open(1),
);
g.add_str("ev2", "eventType", "show_hospitality", 2);
g.add_ref("ev2", "actor", "bob", 2);
g.add_ref("ev2", "target", "alice", 2);
g.set_time(2);
engine.on_edge_added(
&g,
&"ev2".into(),
&"eventType".into(),
&MemValue::Str("show_hospitality".into()),
&Interval::open(2),
);
g.add_str("ev3", "eventType", "harm", 3);
g.add_ref("ev3", "actor", "bob", 3);
g.add_ref("ev3", "target", "alice", 3);
g.set_time(3);
engine.on_edge_added(
&g,
&"ev3".into(),
&"eventType".into(),
&MemValue::Str("harm".into()),
&Interval::open(3),
);
let incremental_completed = engine
.partial_matches()
.iter()
.filter(|pm| pm.state == MatchState::Complete)
.count();
let mut batch_engine: SiftEngineFor<MemGraph> = SiftEngine::new();
batch_engine.register(pattern);
let batch_matches = batch_engine.evaluate(&g);
assert_eq!(
incremental_completed,
batch_matches.len(),
"batch and incremental should agree when edges arrive in order"
);
assert_eq!(batch_matches.len(), 1);
}
#[test]
fn incremental_temporal_ordering_enforced() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("a_then_b")
.stage("e1", |s| {
s.edge("e1", "eventType".into(), MemValue::Str("a".into()))
})
.stage("e2", |s| {
s.edge("e2", "eventType".into(), MemValue::Str("b".into()))
})
.build(),
);
g.add_str("ev1", "eventType", "a", 10);
g.set_time(10);
engine.on_edge_added(
&g,
&"ev1".into(),
&"eventType".into(),
&MemValue::Str("a".into()),
&Interval::open(10),
);
g.add_str("ev2", "eventType", "b", 5);
g.set_time(10);
let events = engine.on_edge_added(
&g,
&"ev2".into(),
&"eventType".into(),
&MemValue::Str("b".into()),
&Interval::open(5),
);
let completed = events
.iter()
.any(|e| matches!(e, SiftEvent::Completed { .. }));
assert!(
!completed,
"incremental should reject temporally inverted match"
);
let batch_matches = engine.evaluate(&g);
assert_eq!(
batch_matches.len(),
0,
"batch also rejects temporally inverted match"
);
}
#[test]
fn interval_zero_length() {
let iv = Interval::new(5, 5);
assert!(!iv.covers(&5), "[5,5) should not cover 5 (empty interval)");
assert!(!iv.covers(&4));
}
#[test]
fn interval_open_ended_relation_always_none() {
let a = Interval::open(1);
let b = Interval::new(3, 5);
assert_eq!(a.relation(&b), None);
let c = Interval::open(1);
let d = Interval::open(3);
assert_eq!(c.relation(&d), None);
}
#[test]
fn interval_intersects_edge_cases() {
let a = Interval::<i64>::open(100);
let b = Interval::<i64>::open(200);
assert!(
a.intersects(&b),
"two open-ended intervals always intersect"
);
let z = Interval::new(5, 5);
let c = Interval::new(3, 7);
assert!(
z.intersects(&c),
"zero-length [5,5) 'intersects' [3,7) due to half-open comparison (quirk)"
);
let d = Interval::new(1, 5);
let e = Interval::new(5, 10);
assert!(!d.intersects(&e), "[1,5) and [5,10) don't intersect");
}
#[test]
fn open_ended_interval_fails_non_before_temporal_constraints() {
let a = Interval::open(1);
let b = Interval::new(3, 7);
assert_eq!(
a.relation(&b),
None,
"open-ended interval returns None for relation()"
);
}
#[test]
fn unless_global_no_stages_still_resolves() {
let pattern = PatternBuilder::<String, String>::new("empty_global_neg")
.unless_global(|neg| neg.edge("x", "type".into(), "bad".into()))
.build();
assert!(
!pattern.negations[0].is_global,
"is_global should be cleared at build time"
);
}
#[test]
fn why_not_nonexistent_pattern() {
let g = MemGraph::new();
let engine: SiftEngineFor<MemGraph> = SiftEngine::new();
assert!(
engine.why_not(&g, "nonexistent").is_none(),
"why_not for unregistered pattern should return None"
);
}
#[test]
fn why_not_matched_pattern_shows_all_matched() {
let mut g = MemGraph::new();
g.add_str("ev1", "eventType", "harm", 1);
g.add_ref("ev1", "actor", "bob", 1);
g.set_time(10);
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("find_harm")
.stage("e", |s| {
s.edge("e", "eventType".into(), MemValue::Str("harm".into()))
.edge_bind("e", "actor".into(), "attacker")
})
.build(),
);
let analysis = engine.why_not(&g, "find_harm").unwrap();
assert_eq!(analysis.stages.len(), 1);
}
#[test]
fn why_not_stops_at_first_unmatched_stage() {
let g = MemGraph::new(); let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("three_stages")
.stage("e1", |s| {
s.edge("e1", "eventType".into(), MemValue::Str("a".into()))
})
.stage("e2", |s| {
s.edge("e2", "eventType".into(), MemValue::Str("b".into()))
})
.stage("e3", |s| {
s.edge("e3", "eventType".into(), MemValue::Str("c".into()))
})
.build(),
);
let analysis = engine.why_not(&g, "three_stages").unwrap();
assert_eq!(
analysis.stages.len(),
1,
"why_not should stop at first unmatched stage, not report all three"
);
matches!(analysis.stages[0].status, StageStatus::Unmatched);
}
#[test]
fn drain_completed_on_empty_engine() {
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
let drained = engine.drain_completed();
assert!(drained.is_empty());
}
#[test]
fn drain_completed_preserves_active_matches() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("single_stage")
.stage("e", |s| {
s.edge("e", "eventType".into(), MemValue::Str("harm".into()))
})
.build(),
);
engine.register(
PatternBuilder::new("two_stage")
.stage("e1", |s| {
s.edge("e1", "eventType".into(), MemValue::Str("harm".into()))
})
.stage("e2", |s| {
s.edge("e2", "eventType".into(), MemValue::Str("heal".into()))
})
.build(),
);
g.add_str("ev1", "eventType", "harm", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"eventType".into(),
&MemValue::Str("harm".into()),
&Interval::open(1),
);
let drained = engine.drain_completed();
assert_eq!(drained.len(), 1);
assert_eq!(drained[0].pattern, "single_stage");
assert_eq!(engine.active_matches_for("two_stage").len(), 1);
}