Skip to main content

Simulation Monitoring

Agent-based models produce emergent behaviors -- resource hoarding, cascade failures, oscillating populations. These behaviors are hard to predict but easy to describe as temporal patterns. Sifting detects them as they happen.

Time~15 minutes
PrerequisitesWhat is Sifting?

1. Resource Hoarding

An agent acquires resources repeatedly without ever sharing. The pattern triggers when the same agent acquires two different resources with no sharing event between them.

Loading playground...

Result: agent_alpha matches -- acquired food then water (and water then shelter) with no sharing between acquisitions. agent_beta acquired food, shared at t=4, then acquired water -- the sharing event between acquisitions kills the match via unless between.

What to notice

The variable ?agent joins both acquisitions and the negation clause to the same agent. A sharing event by a different agent does not suppress the match. The pattern fires for each consecutive pair of hoarding acquisitions, so a persistent hoarder generates multiple matches.


2. Cascade Failure

One agent's failure triggers a dependent agent's failure. The pattern chains two failures through a shared dependency variable and uses negation to exclude cases where the first agent recovered in time.

Loading playground...

Result: 1 match -- power_plant failed at t=1, factory depends on power_plant, factory failed at t=3, and power_plant never recovered. water_plant also failed, and farm depends on it, but water_plant recovered at t=3 before farm could fail at t=5 -- the unless between negation kills that cascade.

What to notice

The dependency relationship is an edge in the graph, not hardcoded in the pattern. The variable ?agent_a threads from the first failure through the dependency edge to the negation clause. Any dependency topology the simulation produces -- chains, trees, cycles -- is surfaced by the same pattern.


3. Population Oscillation

A population rises then falls in the same region. This two-stage pattern detects boom-bust cycles by joining on a shared region variable.

Loading playground...

Result: region_north matches -- population increased at t=1 (and again at t=3), then decreased at t=5. region_south has two increases but no decrease, so no match. The pattern fires once per increase-decrease pair in the same region, so region_north produces two matches (t=1 to t=5, and t=3 to t=5).

What to notice

No negation needed. The pattern is purely structural: rise then fall in the same region. To detect sustained oscillation (rise-fall-rise-fall), compose two instances with sequence or add more stages. To require a minimum gap between rise and fall, add temporal e1 before e2 gap 3..100.


Real-Time Monitoring with Incremental Mode

The playgrounds above use batch evaluation. In a running simulation, use incremental mode instead: feed edges as they happen and react immediately.

let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
let mut graph = MemGraph::new();

engine.register(
PatternBuilder::new("resource_hoarding")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("acquire".into()))
.edge_bind("e1", "agent".into(), "agent")
.edge_bind("e1", "resource".into(), "r1")
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("acquire".into()))
.edge_bind("e2", "agent".into(), "agent")
.edge_bind("e2", "resource".into(), "r2")
})
.unless_between("e1", "e2", |neg| {
neg.edge("mid", "type".into(), MemValue::Str("share".into()))
.edge_bind("mid", "agent".into(), "agent")
})
.build(),
);

// Simulate a few ticks of an agent-based model.
struct SimEvent {
id: String,
kind: String,
agent: String,
resource: String,
}

let ticks: Vec<(i64, Vec<SimEvent>)> = vec![
(
1,
vec![SimEvent {
id: "ev1".into(),
kind: "acquire".into(),
agent: "alpha".into(),
resource: "food".into(),
}],
),
(
2,
vec![SimEvent {
id: "ev2".into(),
kind: "acquire".into(),
agent: "alpha".into(),
resource: "water".into(),
}],
),
];

let mut completed = Vec::new();
for (tick, sim_events) in &ticks {
for event in sim_events {
graph.add_str(&event.id, "type", &event.kind, *tick);
graph.add_ref(&event.id, "agent", &event.agent, *tick);
graph.add_ref(&event.id, "resource", &event.resource, *tick);
graph.set_time(*tick);

let interval = Interval::open(*tick);

// Notify the engine about the type edge (trigger clause).
let mut sift_events = engine.on_edge_added(
&graph,
&event.id,
&"type".to_string(),
&MemValue::Str(event.kind.clone()),
&interval,
);
// Also notify about the agent edge.
sift_events.extend(engine.on_edge_added(
&graph,
&event.id,
&"agent".to_string(),
&MemValue::Node(event.agent.clone()),
&interval,
));

for se in &sift_events {
if let SiftEvent::Completed {
pattern, bindings, ..
} = se
{
println!("[tick {}] detected: {} {:?}", tick, pattern, bindings);
completed.push(pattern.clone());
}
}
}

let (_delta, _expired) = engine.end_tick(50);
}

Call on_edge_added() for each edge produced by the simulation. Call end_tick() once per round to finalize the tick, expire stale partial matches, and produce a TickDelta for narrative scoring. React to SiftEvent::Completed to trigger simulation-level responses -- spawn rescue agents, adjust resource allocation, log anomalies.

Mapping your data

Agent-based model events map to fabula edges as follows:

Real-world fieldFabula edge
eventID or step+agentIDsource node
action typelabel value
agent, target entity, resourcetarget nodes
simulation stepinterval start

Each simulation step produces edges for agent actions. The agent and resource fields become target nodes, so patterns can join across events by the same agent or involving the same resource.


How fabula compares

  • vs custom observer code: Hard-coded callbacks that check specific conditions each tick. No gap analysis (you cannot ask "how close did we get to a cascade?"), no composition for building complex detection from reusable fragments, no variable-scoped negation.
  • vs Flink CEP: Complex event processing over flat event streams. No graph topology -- Flink patterns match sequences of events, not events connected by shared entities in a graph. No Allen algebra for temporal relations, no incremental partial match tracking with negation windows.

Where to go next