use fabula::prelude::*;
use fabula_memory::{MemGraph, MemValue};
#[test]
fn disable_pattern_skips_matching() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
let idx = engine.register(
PatternBuilder::new("test")
.stage("e", |s| {
s.edge("e", "type".into(), MemValue::Str("x".into()))
})
.build(),
);
g.add_str("ev1", "type", "x", 1);
g.set_time(1);
let events = engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("x".into()),
&Interval::open(1),
);
assert!(!events.is_empty(), "enabled pattern should match");
engine.set_pattern_enabled(idx, false);
g.add_str("ev2", "type", "x", 2);
g.set_time(2);
let events = engine.on_edge_added(
&g,
&"ev2".into(),
&"type".into(),
&MemValue::Str("x".into()),
&Interval::open(2),
);
assert!(events.is_empty(), "disabled pattern should not match");
}
#[test]
fn disable_kills_active_pms() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
let idx = engine.register(
PatternBuilder::new("two_stage")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("start".into()))
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("end".into()))
})
.build(),
);
g.add_str("ev1", "type", "start", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("start".into()),
&Interval::open(1),
);
assert_eq!(
engine
.partial_matches()
.iter()
.filter(|pm| pm.state == MatchState::Active)
.count(),
1,
"should have 1 active PM"
);
engine.set_pattern_enabled(idx, false);
assert_eq!(
engine
.partial_matches()
.iter()
.filter(|pm| pm.state == MatchState::Active)
.count(),
0,
"disabling should kill active PMs"
);
}
#[test]
fn reenable_allows_new_matches() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
let idx = engine.register(
PatternBuilder::new("test")
.stage("e", |s| {
s.edge("e", "type".into(), MemValue::Str("x".into()))
})
.build(),
);
engine.set_pattern_enabled(idx, false);
g.add_str("ev1", "type", "x", 1);
g.set_time(1);
let events = engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("x".into()),
&Interval::open(1),
);
assert!(events.is_empty(), "disabled → no match");
engine.set_pattern_enabled(idx, true);
g.add_str("ev2", "type", "x", 2);
g.set_time(2);
let events = engine.on_edge_added(
&g,
&"ev2".into(),
&"type".into(),
&MemValue::Str("x".into()),
&Interval::open(2),
);
assert!(!events.is_empty(), "re-enabled → should match");
}
#[test]
fn pattern_metrics_track_events() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
let idx = engine.register(
PatternBuilder::new("test")
.stage("e", |s| {
s.edge("e", "type".into(), MemValue::Str("x".into()))
})
.build(),
);
engine.tick();
g.add_str("ev1", "type", "x", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("x".into()),
&Interval::open(1),
);
let metrics = engine.pattern_metrics(idx).unwrap();
assert_eq!(metrics.completion_count, 1);
assert_eq!(metrics.last_advanced_tick, 1);
assert!(metrics.enabled);
}
#[test]
fn stale_patterns_detected() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("stale")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("start".into()))
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("end".into()))
})
.build(),
);
engine.tick();
g.add_str("ev1", "type", "start", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("start".into()),
&Interval::open(1),
);
for _ in 0..100 {
engine.tick();
}
let stale = engine.stale_patterns(50);
assert_eq!(
stale.len(),
1,
"pattern should be stale after 100 ticks without advancement"
);
assert_eq!(stale[0], 0);
}
#[test]
fn deregister_soft_deletes() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
let idx = engine.register(
PatternBuilder::new("ephemeral")
.stage("e", |s| {
s.edge("e", "type".into(), MemValue::Str("x".into()))
})
.build(),
);
engine.deregister(idx);
assert!(!engine.is_pattern_enabled(idx));
assert_eq!(engine.patterns().len(), 1);
g.add_str("ev1", "type", "x", 1);
g.set_time(1);
let events = engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("x".into()),
&Interval::open(1),
);
assert!(events.is_empty());
}
#[test]
fn evaluate_skips_disabled() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
let idx = engine.register(
PatternBuilder::new("batch_test")
.stage("e", |s| {
s.edge("e", "type".into(), MemValue::Str("x".into()))
})
.build(),
);
g.add_str("ev1", "type", "x", 1);
g.set_time(10);
assert_eq!(engine.evaluate(&g).len(), 1, "enabled → 1 match");
engine.set_pattern_enabled(idx, false);
assert_eq!(engine.evaluate(&g).len(), 0, "disabled → 0 matches");
engine.set_pattern_enabled(idx, true);
assert_eq!(engine.evaluate(&g).len(), 1, "re-enabled → 1 match");
}
#[test]
fn tick_delta_summarizes_events() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("quick")
.stage("e", |s| {
s.edge("e", "type".into(), MemValue::Str("x".into()))
})
.build(),
);
engine.register(
PatternBuilder::new("slow")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("start".into()))
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("end".into()))
})
.build(),
);
engine.tick();
g.add_str("ev1", "type", "x", 1);
g.add_str("ev2", "type", "start", 1);
g.set_time(1);
let mut all_events = Vec::new();
all_events.extend(engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("x".into()),
&Interval::open(1),
));
all_events.extend(engine.on_edge_added(
&g,
&"ev2".into(),
&"type".into(),
&MemValue::Str("start".into()),
&Interval::open(1),
));
let delta = engine.tick_delta(&all_events, 50);
assert!(delta.completed.contains(&"quick".to_string()));
assert!(delta.advanced.contains(&"slow".to_string()));
assert!(delta.stalled.is_empty());
for _ in 0..100 {
engine.tick();
}
let no_events: Vec<SiftEvent<String, MemValue>> = vec![];
let delta = engine.tick_delta(&no_events, 50);
assert!(delta.stalled.contains(&"slow".to_string()));
assert_eq!(delta.active_pm_count, 1);
}
#[test]
fn clone_engine_is_independent() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("two_stage")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("start".into()))
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("end".into()))
})
.build(),
);
engine.tick();
g.add_str("ev1", "type", "start", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("start".into()),
&Interval::open(1),
);
assert_eq!(
engine
.partial_matches()
.iter()
.filter(|pm| pm.state == MatchState::Active)
.count(),
1
);
let mut fork = engine.clone();
g.add_str("ev2", "type", "end", 5);
g.set_time(5);
let fork_events = fork.on_edge_added(
&g,
&"ev2".into(),
&"type".into(),
&MemValue::Str("end".into()),
&Interval::open(5),
);
let fork_completed = fork_events
.iter()
.filter(|e| matches!(e, SiftEvent::Completed { .. }))
.count();
assert_eq!(fork_completed, 1, "fork should complete");
assert_eq!(
engine
.partial_matches()
.iter()
.filter(|pm| pm.state == MatchState::Active)
.count(),
1,
"original should still have 1 active PM"
);
assert_eq!(
engine.pattern_metrics(0).unwrap().completion_count,
0,
"original has no completions"
);
assert_eq!(
fork.pattern_metrics(0).unwrap().completion_count,
1,
"fork has 1 completion"
);
}
#[test]
fn clone_preserves_disabled_state() {
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
let idx = engine.register(
PatternBuilder::new("test")
.stage("e", |s| {
s.edge("e", "type".into(), MemValue::Str("x".into()))
})
.build(),
);
engine.set_pattern_enabled(idx, false);
let fork = engine.clone();
assert!(
!fork.is_pattern_enabled(idx),
"fork should inherit disabled state"
);
}
#[test]
fn plant_payoff_tracks_setup_and_resolution() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
let plant_idx = engine.register(
PatternBuilder::new("promise")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("promise".into()))
})
.build(),
);
let payoff_idx = engine.register(
PatternBuilder::new("fulfill")
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("fulfill".into()))
})
.build(),
);
engine.register_plant_payoff(plant_idx, payoff_idx, None);
engine.tick();
g.add_str("ev1", "type", "promise", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("promise".into()),
&Interval::open(1),
);
let status = engine.plant_status(50);
assert_eq!(status.len(), 1);
assert_eq!(status[0].payoff_completions, 0, "no payoff yet");
assert!(!status[0].stale, "only 1 tick — not stale");
g.add_str("ev2", "type", "fulfill", 2);
g.set_time(2);
engine.on_edge_added(
&g,
&"ev2".into(),
&"type".into(),
&MemValue::Str("fulfill".into()),
&Interval::open(2),
);
let status = engine.plant_status(50);
assert_eq!(status[0].payoff_completions, 1, "payoff resolved");
}
#[test]
fn plant_payoff_stale_detection() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
let plant_idx = engine.register(
PatternBuilder::new("setup")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("setup".into()))
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("middle".into()))
})
.build(),
);
let payoff_idx = engine.register(
PatternBuilder::new("payoff")
.stage("e3", |s| {
s.edge("e3", "type".into(), MemValue::Str("payoff".into()))
})
.build(),
);
engine.register_plant_payoff(plant_idx, payoff_idx, None);
engine.tick();
g.add_str("ev1", "type", "setup", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("setup".into()),
&Interval::open(1),
);
for _ in 0..100 {
engine.tick();
}
let status = engine.plant_status(50);
assert_eq!(status.len(), 1);
assert!(status[0].stale, "plant should be stale after 100 ticks");
assert_eq!(status[0].active_plants, 1);
assert_eq!(status[0].payoff_completions, 0);
}
#[test]
fn end_tick_accumulates_and_clears() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("quick")
.stage("e", |s| {
s.edge("e", "type".into(), MemValue::Str("x".into()))
})
.build(),
);
engine.register(
PatternBuilder::new("slow")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("start".into()))
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("end".into()))
})
.build(),
);
g.add_str("ev1", "type", "x", 1);
g.add_str("ev2", "type", "start", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("x".into()),
&Interval::open(1),
);
engine.on_edge_added(
&g,
&"ev2".into(),
&"type".into(),
&MemValue::Str("start".into()),
&Interval::open(1),
);
let (delta, _) = engine.end_tick(50);
assert!(
delta.completed.contains(&"quick".to_string()),
"quick should complete"
);
assert!(
delta.advanced.contains(&"slow".to_string()),
"slow should advance"
);
assert_eq!(engine.current_tick(), 1);
let (delta2, _) = engine.end_tick(50);
assert!(delta2.completed.is_empty(), "no events this tick");
assert!(delta2.advanced.is_empty(), "no events this tick");
assert_eq!(engine.current_tick(), 2);
}
#[test]
fn end_tick_detects_stale_after_many_ticks() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("stuck")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("start".into()))
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("end".into()))
})
.build(),
);
g.add_str("ev1", "type", "start", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("start".into()),
&Interval::open(1),
);
let _ = engine.end_tick(50);
for _ in 0..100 {
let _ = engine.end_tick(50);
}
let (delta, _) = engine.end_tick(50);
assert!(delta.stalled.contains(&"stuck".to_string()));
}
#[test]
fn stats_reset() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("test")
.stage("e", |s| {
s.edge("e", "eventType".into(), MemValue::Str("enter".into()))
})
.build(),
);
g.add_str("ev1", "eventType", "enter", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"eventType".into(),
&MemValue::Str("enter".into()),
&Interval::open(1),
);
assert!(engine.stats().total_on_edge_added > 0);
engine.reset_stats();
assert_eq!(engine.stats().total_on_edge_added, 0);
assert_eq!(engine.stats().peak_active_pms, 0);
}
#[test]
fn pm_created_at_set_on_initiation() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("two_stage")
.stage("e1", |s| {
s.edge("e1", "eventType".into(), MemValue::Str("enter".into()))
})
.stage("e2", |s| {
s.edge("e2", "eventType".into(), MemValue::Str("leave".into()))
})
.build(),
);
g.add_str("ev1", "eventType", "enter", 42);
g.set_time(42);
engine.on_edge_added(
&g,
&"ev1".into(),
&"eventType".into(),
&MemValue::Str("enter".into()),
&Interval::open(42),
);
let pms = engine.active_matches_for("two_stage");
assert_eq!(pms.len(), 1);
assert_eq!(
pms[0].created_at, 42,
"created_at should be the initiating edge's timestamp"
);
}
#[test]
fn pm_created_at_inherited_on_advance() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("three_stage")
.stage("e1", |s| {
s.edge("e1", "eventType".into(), MemValue::Str("enter".into()))
})
.stage("e2", |s| {
s.edge("e2", "eventType".into(), MemValue::Str("greet".into()))
})
.stage("e3", |s| {
s.edge("e3", "eventType".into(), MemValue::Str("leave".into()))
})
.build(),
);
g.add_str("ev1", "eventType", "enter", 10);
g.set_time(10);
engine.on_edge_added(
&g,
&"ev1".into(),
&"eventType".into(),
&MemValue::Str("enter".into()),
&Interval::open(10),
);
g.add_str("ev2", "eventType", "greet", 50);
g.set_time(50);
engine.on_edge_added(
&g,
&"ev2".into(),
&"eventType".into(),
&MemValue::Str("greet".into()),
&Interval::open(50),
);
let active = engine.active_matches_for("three_stage");
assert_eq!(active.len(), 2);
let advanced = active.iter().find(|pm| pm.next_stage == 2).unwrap();
assert_eq!(
advanced.created_at, 10,
"advanced PM should inherit parent's created_at, not the advancing edge's timestamp"
);
}
#[test]
fn pm_expires_after_deadline_ticks() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("sla")
.deadline(5)
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("submit".into()))
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("review".into()))
})
.build(),
);
g.add_str("ev1", "type", "submit", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("submit".into()),
&Interval::open(1),
);
assert_eq!(
engine
.partial_matches()
.iter()
.filter(|pm| pm.state == MatchState::Active)
.count(),
1
);
for _ in 0..5 {
let (delta, _) = engine.end_tick(50);
assert!(
delta.expired.is_empty(),
"should not expire within deadline"
);
}
assert_eq!(
engine
.partial_matches()
.iter()
.filter(|pm| pm.state == MatchState::Active)
.count(),
1
);
let (delta, expired_events) = engine.end_tick(50);
assert!(
delta.expired.contains(&"sla".to_string()),
"should expire after deadline"
);
assert_eq!(
engine
.partial_matches()
.iter()
.filter(|pm| pm.state == MatchState::Active)
.count(),
0,
"expired PM should be removed"
);
assert_eq!(expired_events.len(), 1);
match &expired_events[0] {
SiftEvent::Expired {
pattern,
stage_reached,
ticks_elapsed,
metadata,
..
} => {
assert_eq!(pattern, "sla");
assert_eq!(*stage_reached, 1, "PM was at stage 1 (next_stage)");
assert_eq!(*ticks_elapsed, 6);
assert!(metadata.is_empty());
}
other => panic!("expected Expired event, got {:?}", other),
}
}
#[test]
fn no_expiry_without_deadline() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("no_deadline")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("start".into()))
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("end".into()))
})
.build(),
);
g.add_str("ev1", "type", "start", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("start".into()),
&Interval::open(1),
);
for _ in 0..200 {
let (delta, _) = engine.end_tick(50);
assert!(delta.expired.is_empty());
}
assert_eq!(
engine
.partial_matches()
.iter()
.filter(|pm| pm.state == MatchState::Active)
.count(),
1
);
}
#[test]
fn completed_before_deadline_no_expiry() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("fast")
.deadline(10)
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("done".into()))
})
.build(),
);
g.add_str("ev1", "type", "done", 1);
g.set_time(1);
let events = engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("done".into()),
&Interval::open(1),
);
assert!(events
.iter()
.any(|e| matches!(e, SiftEvent::Completed { .. })));
engine.drain_completed();
for _ in 0..20 {
let (delta, _) = engine.end_tick(50);
assert!(delta.expired.is_empty());
}
}
#[test]
fn negation_kills_before_deadline() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("negatable")
.deadline(100)
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("start".into()))
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("end".into()))
})
.unless_between("e1", "e2", |n| {
n.edge("mid", "type".into(), MemValue::Str("cancel".into()))
})
.build(),
);
g.add_str("ev1", "type", "start", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("start".into()),
&Interval::open(1),
);
let _ = engine.end_tick(50);
g.add_str("mid1", "type", "cancel", 2);
g.set_time(2);
let events = engine.on_edge_added(
&g,
&"mid1".into(),
&"type".into(),
&MemValue::Str("cancel".into()),
&Interval::open(2),
);
assert!(events
.iter()
.any(|e| matches!(e, SiftEvent::Negated { .. })));
for _ in 0..200 {
let (delta, _) = engine.end_tick(50);
assert!(
delta.expired.is_empty(),
"negated PM should not also expire"
);
}
}
#[test]
fn deadline_created_at_tick_inherited_on_advance() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("three_stage_deadline")
.deadline(8)
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("a".into()))
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("b".into()))
})
.stage("e3", |s| {
s.edge("e3", "type".into(), MemValue::Str("c".into()))
})
.build(),
);
g.add_str("ev1", "type", "a", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("a".into()),
&Interval::open(1),
);
for _ in 0..3 {
let _ = engine.end_tick(50);
}
g.add_str("ev2", "type", "b", 4);
g.set_time(4);
engine.on_edge_added(
&g,
&"ev2".into(),
&"type".into(),
&MemValue::Str("b".into()),
&Interval::open(4),
);
for _ in 0..5 {
let (delta, _) = engine.end_tick(50);
assert!(delta.expired.is_empty());
}
let (delta, _) = engine.end_tick(50);
assert!(
delta.expired.contains(&"three_stage_deadline".to_string()),
"should expire based on original creation tick, not advancement tick"
);
}
#[test]
fn batch_cross_stage_gt_var_matches() {
let mut g = MemGraph::new();
g.add_str("ev1", "type", "order", 1);
g.add_num("ev1", "price", 100.0, 1);
g.add_str("ev2", "type", "order", 2);
g.add_num("ev2", "price", 150.0, 2);
g.set_time(10);
let pattern = PatternBuilder::new("escalation")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("order".into()))
.edge_bind("e1", "price".into(), "base_price")
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("order".into()))
.edge_gt_var("e2", "price".into(), "base_price")
})
.build();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(pattern);
let matches = engine.evaluate(&g);
assert_eq!(matches.len(), 1, "150 > 100 should match");
}
#[test]
fn batch_cross_stage_gt_var_no_match() {
let mut g = MemGraph::new();
g.add_str("ev1", "type", "order", 1);
g.add_num("ev1", "price", 100.0, 1);
g.add_str("ev2", "type", "order", 2);
g.add_num("ev2", "price", 80.0, 2);
g.set_time(10);
let pattern = PatternBuilder::new("escalation")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("order".into()))
.edge_bind("e1", "price".into(), "base_price")
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("order".into()))
.edge_gt_var("e2", "price".into(), "base_price")
})
.build();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(pattern);
assert_eq!(engine.evaluate(&g).len(), 0, "80 > 100 should not match");
}
#[test]
fn incremental_cross_stage_gt_var() {
let mut g = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(
PatternBuilder::new("escalation")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("bid".into()))
.edge_bind("e1", "price".into(), "prev_price")
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("bid".into()))
.edge_gt_var("e2", "price".into(), "prev_price")
})
.build(),
);
g.add_str("ev1", "type", "bid", 1);
g.add_num("ev1", "price", 100.0, 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("bid".into()),
&Interval::open(1),
);
g.add_str("ev2", "type", "bid", 2);
g.add_num("ev2", "price", 150.0, 2);
g.set_time(2);
let events = engine.on_edge_added(
&g,
&"ev2".into(),
&"type".into(),
&MemValue::Str("bid".into()),
&Interval::open(2),
);
let completed = events
.iter()
.filter(|e| matches!(e, SiftEvent::Completed { .. }))
.count();
assert_eq!(completed, 1, "150 > 100 should complete incrementally");
}
#[test]
fn cross_stage_eq_var_matches() {
let mut g = MemGraph::new();
g.add_str("ev1", "type", "invoice", 1);
g.add_num("ev1", "amount", 500.0, 1);
g.add_str("ev2", "type", "payment", 2);
g.add_num("ev2", "amount", 500.0, 2);
g.set_time(10);
let pattern = PatternBuilder::new("exact_match")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("invoice".into()))
.edge_bind("e1", "amount".into(), "expected")
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("payment".into()))
.edge_eq_var("e2", "amount".into(), "expected")
})
.build();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(pattern);
assert_eq!(engine.evaluate(&g).len(), 1, "500 == 500 should match");
}
#[test]
fn cross_stage_var_node_type_mismatch_no_match() {
let mut g = MemGraph::new();
g.add_str("ev1", "type", "action", 1);
g.add_ref("ev1", "actor", "alice", 1);
g.add_str("ev2", "type", "action", 2);
g.add_num("ev2", "score", 50.0, 2);
g.set_time(10);
let pattern = PatternBuilder::new("bad_comparison")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("action".into()))
.edge_bind("e1", "actor".into(), "actor_ref")
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("action".into()))
.edge_gt_var("e2", "score".into(), "actor_ref")
})
.build();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(pattern);
assert_eq!(
engine.evaluate(&g).len(),
0,
"Node vs Value comparison should not match"
);
}
#[test]
fn cross_stage_gt_boundary_equality_no_match() {
let mut g = MemGraph::new();
g.add_str("ev1", "type", "order", 1);
g.add_num("ev1", "price", 100.0, 1);
g.add_str("ev2", "type", "order", 2);
g.add_num("ev2", "price", 100.0, 2);
g.set_time(10);
let pattern = PatternBuilder::new("escalation")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("order".into()))
.edge_bind("e1", "price".into(), "base")
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("order".into()))
.edge_gt_var("e2", "price".into(), "base")
})
.build();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(pattern);
assert_eq!(
engine.evaluate(&g).len(),
0,
"100 > 100 should not match (strict >)"
);
}
#[test]
fn cross_stage_gte_boundary_equality_matches() {
let mut g = MemGraph::new();
g.add_str("ev1", "type", "order", 1);
g.add_num("ev1", "price", 100.0, 1);
g.add_str("ev2", "type", "order", 2);
g.add_num("ev2", "price", 100.0, 2);
g.set_time(10);
let pattern = PatternBuilder::new("at_least")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("order".into()))
.edge_bind("e1", "price".into(), "base")
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("order".into()))
.edge_gte_var("e2", "price".into(), "base")
})
.build();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(pattern);
assert_eq!(engine.evaluate(&g).len(), 1, "100 >= 100 should match");
}
#[test]
fn cross_stage_lte_matches() {
let mut g = MemGraph::new();
g.add_str("ev1", "type", "check", 1);
g.add_num("ev1", "limit", 50.0, 1);
g.add_str("ev2", "type", "check", 2);
g.add_num("ev2", "val", 30.0, 2);
g.set_time(10);
let pattern = PatternBuilder::new("under_limit")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("check".into()))
.edge_bind("e1", "limit".into(), "cap")
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("check".into()))
.edge_lte_var("e2", "val".into(), "cap")
})
.build();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(pattern);
assert_eq!(engine.evaluate(&g).len(), 1, "30 <= 50 should match");
}
#[test]
fn cross_stage_var_range_check() {
let mut g = MemGraph::new();
g.add_str("ev1", "type", "bounds", 1);
g.add_num("ev1", "low", 10.0, 1);
g.add_num("ev1", "high", 90.0, 1);
g.add_str("ev2", "type", "reading", 2);
g.add_num("ev2", "val", 50.0, 2);
g.set_time(10);
let pattern = PatternBuilder::new("in_range")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("bounds".into()))
.edge_bind("e1", "low".into(), "lo")
.edge_bind("e1", "high".into(), "hi")
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("reading".into()))
.edge_gt_var("e2", "val".into(), "lo")
.edge_lt_var("e2", "val".into(), "hi")
})
.build();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(pattern);
assert_eq!(
engine.evaluate(&g).len(),
1,
"50 > 10 AND 50 < 90 should match"
);
}
#[test]
fn cross_stage_var_in_negation_body() {
let mut g = MemGraph::new();
g.add_str("ev1", "type", "set_limit", 1);
g.add_num("ev1", "limit", 50.0, 1);
g.add_str("ev2", "type", "violation", 2);
g.add_num("ev2", "amount", 80.0, 2); g.add_str("ev3", "type", "audit", 3);
g.set_time(10);
let pattern = PatternBuilder::new("clean_audit")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("set_limit".into()))
.edge_bind("e1", "limit".into(), "threshold")
})
.stage("e3", |s| {
s.edge("e3", "type".into(), MemValue::Str("audit".into()))
})
.unless_between("e1", "e3", |neg| {
neg.edge("mid", "type".into(), MemValue::Str("violation".into()))
.edge_constrained(
"mid",
"amount".into(),
ValueConstraint::GtVar("threshold".to_string()),
)
})
.build();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(pattern);
assert_eq!(
engine.evaluate(&g).len(),
0,
"negation should kill — 80 > 50"
);
}
#[test]
fn repeat_range_completes_at_min() {
let offense = PatternBuilder::new("offense")
.stage("e", |s| {
s.edge("e", "type".into(), MemValue::Str("offense".into()))
.edge_bind("e", "target".into(), "target")
})
.build();
let pattern = fabula::compose::repeat_range("strikes", &offense, 3, Some(5), &["target"]);
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(pattern);
let mut g = MemGraph::new();
g.add_str("ev1", "type", "offense", 1);
g.add_ref("ev1", "target", "alice", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("offense".into()),
&Interval::open(1),
);
g.add_str("ev2", "type", "offense", 2);
g.add_ref("ev2", "target", "alice", 2);
g.set_time(2);
let ev2 = engine.on_edge_added(
&g,
&"ev2".into(),
&"type".into(),
&MemValue::Str("offense".into()),
&Interval::open(2),
);
let completions: Vec<_> = ev2
.iter()
.filter(|e| matches!(e, SiftEvent::Completed { .. }))
.collect();
assert_eq!(
completions.len(),
0,
"should not complete at 2 occurrences (min=3)"
);
g.add_str("ev3", "type", "offense", 3);
g.add_ref("ev3", "target", "alice", 3);
g.set_time(3);
let ev3 = engine.on_edge_added(
&g,
&"ev3".into(),
&"type".into(),
&MemValue::Str("offense".into()),
&Interval::open(3),
);
let completions: Vec<_> = ev3
.iter()
.filter(|e| matches!(e, SiftEvent::Completed { .. }))
.collect();
assert_eq!(
completions.len(),
1,
"should complete at 3 occurrences (min=3)"
);
}
#[test]
fn repeat_range_continues_after_min() {
let offense = PatternBuilder::new("offense")
.stage("e", |s| {
s.edge("e", "type".into(), MemValue::Str("offense".into()))
.edge_bind("e", "target".into(), "target")
})
.build();
let pattern = fabula::compose::repeat_range("strikes", &offense, 2, Some(5), &["target"]);
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(pattern);
let mut g = MemGraph::new();
for i in 1..=2 {
g.add_str(&format!("ev{}", i), "type", "offense", i);
g.add_ref(&format!("ev{}", i), "target", "alice", i);
g.set_time(i);
engine.on_edge_added(
&g,
&format!("ev{}", i).into(),
&"type".into(),
&MemValue::Str("offense".into()),
&Interval::open(i),
);
}
g.add_str("ev3", "type", "offense", 3);
g.add_ref("ev3", "target", "alice", 3);
g.set_time(3);
let ev3 = engine.on_edge_added(
&g,
&"ev3".into(),
&"type".into(),
&MemValue::Str("offense".into()),
&Interval::open(3),
);
let completions: Vec<_> = ev3
.iter()
.filter(|e| matches!(e, SiftEvent::Completed { .. }))
.collect();
assert!(
completions.len() >= 1,
"should complete again at 3 total occurrences"
);
}
#[test]
fn repeat_range_stops_at_max() {
let offense = PatternBuilder::new("offense")
.stage("e", |s| {
s.edge("e", "type".into(), MemValue::Str("offense".into()))
.edge_bind("e", "target".into(), "target")
})
.build();
let pattern = fabula::compose::repeat_range("strikes", &offense, 2, Some(4), &["target"]);
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(pattern);
let mut g = MemGraph::new();
for i in 1..=5 {
g.add_str(&format!("ev{}", i), "type", "offense", i);
g.add_ref(&format!("ev{}", i), "target", "alice", i);
g.set_time(i);
engine.on_edge_added(
&g,
&format!("ev{}", i).into(),
&"type".into(),
&MemValue::Str("offense".into()),
&Interval::open(i),
);
}
let completed = engine.drain_completed();
assert!(!completed.is_empty(), "should have completions");
let active = engine.active_matches_for("strikes");
for pm in &active {
assert!(
pm.repetition_count <= 4,
"no PM should loop beyond max=4, got rep {}",
pm.repetition_count
);
}
}
#[test]
fn repeat_range_unbounded_keeps_matching() {
let offense = PatternBuilder::new("offense")
.stage("e", |s| {
s.edge("e", "type".into(), MemValue::Str("offense".into()))
.edge_bind("e", "target".into(), "target")
})
.build();
let pattern = fabula::compose::repeat_range("strikes", &offense, 2, None, &["target"]);
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(pattern);
let mut g = MemGraph::new();
for i in 1..=6 {
g.add_str(&format!("ev{}", i), "type", "offense", i);
g.add_ref(&format!("ev{}", i), "target", "alice", i);
g.set_time(i);
engine.on_edge_added(
&g,
&format!("ev{}", i).into(),
&"type".into(),
&MemValue::Str("offense".into()),
&Interval::open(i),
);
}
let completed = engine.drain_completed();
assert!(
completed.len() >= 5,
"unbounded should produce completions at each occurrence >= min (got {})",
completed.len()
);
}
#[test]
fn repeat_range_first_last_bindings() {
let offense = PatternBuilder::new("offense")
.stage("e", |s| {
s.edge("e", "type".into(), MemValue::Str("offense".into()))
.edge_bind("e", "actor".into(), "actor")
.edge_bind("e", "target".into(), "target")
})
.build();
let pattern = fabula::compose::repeat_range("strikes", &offense, 2, Some(4), &["target"]);
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(pattern);
let mut g = MemGraph::new();
g.add_str("ev1", "type", "offense", 1);
g.add_ref("ev1", "actor", "bob", 1);
g.add_ref("ev1", "target", "alice", 1);
g.set_time(1);
engine.on_edge_added(
&g,
&"ev1".into(),
&"type".into(),
&MemValue::Str("offense".into()),
&Interval::open(1),
);
g.add_str("ev2", "type", "offense", 2);
g.add_ref("ev2", "actor", "charlie", 2);
g.add_ref("ev2", "target", "alice", 2);
g.set_time(2);
engine.on_edge_added(
&g,
&"ev2".into(),
&"type".into(),
&MemValue::Str("offense".into()),
&Interval::open(2),
);
let completed = engine.drain_completed();
assert!(
!completed.is_empty(),
"should have completions at 2 total occurrences"
);
let first_match = &completed[0];
assert!(
first_match.bindings.contains_key("target"),
"shared var should be present"
);
assert!(
first_match.bindings.contains_key("first_actor"),
"first_ binding should exist"
);
assert!(
first_match.bindings.contains_key("last_actor"),
"last_ binding should exist"
);
assert_eq!(
first_match.bindings.get("first_actor"),
Some(&BoundValue::Node("bob".into())),
"first_actor should be bob"
);
assert_eq!(
first_match.bindings.get("last_actor"),
Some(&BoundValue::Node("charlie".into())),
"last_actor should be charlie"
);
}
#[test]
fn repeat_range_exact_is_backward_compatible() {
let offense = PatternBuilder::<String, MemValue>::new("offense")
.stage("e", |s| {
s.edge("e", "type".into(), MemValue::Str("offense".into()))
.edge_bind("e", "target".into(), "target")
})
.build();
let exact = fabula::compose::repeat("three", &offense, 3, &["target"]);
assert!(
exact.repeat_range.is_none(),
"exact repeat should not have repeat_range"
);
assert_eq!(exact.stages.len(), 3, "exact repeat should unroll 3 copies");
}