Why bother

Causal Temporal Event Graphs (CTEGs) sit at an awkward intersection. They need the expressiveness of a typed graph — nodes with attributes, edges with relation types — but they also need the graph itself to be queryable under rewriting: new evidence arrives, an edge gets reclassified, a causal chain gets pruned or merged, and the consequences of that change have to propagate through the rest of the graph without the whole thing being recomputed from scratch by hand.

Most causal-graph tooling today (DoWhy, EconML-style identification, ad hoc Python over NetworkX) treats the graph as inert data and puts all the reasoning in imperative code that walks it. That works, but it means every new inference rule is a new function, and every interaction between rules has to be hand-coded.

Elpi offers a different substrate. It is a λProlog implementation built specifically to manipulate syntax trees with binders, extended with Constraint Handling Rules (CHR) for managing a live constraint store. Two things fall out of that combination almost for free:

  • Binders give you scoped context natively. An event's causal parents, its temporal frame, its enclosing episode — all of this is "context that must not leak" in exactly the way a bound variable's scope must not leak. Elpi's HOAS (Higher-Order Abstract Syntax) representation means you don't hand-roll de Bruijn indices to keep an event's causal ancestry from bleeding into an unrelated branch of the graph.

  • CHR gives you propagation for free. A causal edge is not just data — it's a standing constraint ("A LEADS_TO B, given the current evidence"). When new evidence arrives, you don't want to re-derive the whole graph; you want the constraint store to wake up exactly the rules that touch the changed edge. That's precisely what CHR is for: a goal is turned into a suspended syntactic constraint, resumed only when the variables it depends on are touched.

This article works through what a CTEG-as-Elpi encoding actually looks like: how to represent events and the four SST-style link types, how to express temporal ordering as a constraint rather than a precomputed fact, and how to write CHR rules that do the kind of propagation you'd otherwise hand-code — rule-based edge rewriting in the spirit of Pearl's do-calculus, but operating over event-level, Granger-style causal structure rather than structural causal models.

Mark Burgess's Semantic Spacetime (SST) γ(3,4) framework gives four link types that recur, largely independently, across reinforcement learning trajectories, LLM agent traces, and neuroscience connectivity work: NEAR, LEADS_TO, CONTAINS, EXPRESSES. That convergence is a good sign the taxonomy is picking out something real rather than something idiosyncratic to one domain, which makes it a reasonable seed vocabulary for a CTEG's edge types.

In Elpi, the natural move is to declare a type for events and a type for links, and let the four SST relations be constructors rather than string tags:

kind event   type.
kind time    type.
kind link    type.

% an event is identified by a name and carries a time
type mk-event string -> time -> event.

% instants and intervals — deliberately abstract, refine later
type instant int -> time.
type interval time -> time -> time.

% the four SST link types, each relating two events
type near      event -> event -> link.
type leads-to  event -> event -> link.
type contains  event -> event -> link.
type expresses event -> event -> link.

This is already doing useful work: leads-to and contains are structurally distinct constructors, not string-valued fields on a generic edge record, so a query that only wants causal edges can pattern-match on leads-to directly rather than filtering on a tag at runtime.

Events as binders, not just records

The more interesting move is representing an event's causal context as a scope rather than a foreign-key lookup. Suppose an event only makes sense relative to the episode that contains it — its causal parents are only meaningful within that episode's frame. Rather than threading an episode_id through every predicate, you can put the episode in binder position:

% an episode is a scope: a name and a function from "the episode's event scope"
% to a list of the events that live inside it
type episode string -> (event -> list event) -> episode.

pred episode-events i:episode, o:list event.
episode-events (episode _ F) Events :-
  pi e\ Events = F e.

This looks like overkill for something you could do with a foreign key — and for a flat list of events, it is. The payoff shows up once you start writing rules that need to guarantee an event's causal ancestry can't accidentally reference something outside its episode's scope. With HOAS, that guarantee is structural: a variable bound by pi e\ cannot escape the scope it was introduced in, so "this rule accidentally let a causal edge point outside its episode" becomes a type/scoping error instead of a bug you find at query time. That is the same property that makes Elpi good at writing type-checkers for languages with binders — CTEGs just happen to have "episode scope" playing the role that "lexical scope" plays in a type-checker.

Temporal constraints as CHR, not precomputed facts

The place CHR earns its keep is temporal ordering. You don't want leads-to A B to silently assume time(A) < time(B) — you want that ordering checked and maintained as a constraint, so that if evidence later revises when A happened, anything that depended on the ordering gets re-examined rather than left stale.

constraint leads-to time-before {
  % if we assert leads-to A B, and A's time is after B's time,
  % that's a genuine contradiction — fail loudly
  rule (leads-to A B) \ (time-before Tb Ta) <=>
    event-time A Ta, event-time B Tb, Ta @> Tb |
    print "temporal contradiction:", print A, print "cannot lead to", print B.

  % if two leads-to edges chain (A -> B -> C) and there's no direct A -> C,
  % propagate transitivity as a new (derived) constraint
  rule (leads-to A B) \ (leads-to B C) <=>
    not (leads-to A C) |
    leads-to A C.
}

The second rule is the one that matters operationally: it means transitive closure over leads-to is not something you compute in a batch pass over the whole graph every time it changes — it falls out of the constraint store resolving itself incrementally as new edges are asserted. That's the "propagate exactly what needs to propagate" property CHR is built for, and it's the part that's genuinely awkward to get right by hand in an imperative graph library.

A do-calculus-flavored rewrite rule

Pearl's do-calculus is defined over structural causal models, and CTEGs (event-level, Granger-style causality) aren't quite the same object — but the shape of the reasoning transfers: a rule that says "under condition X, you may rewrite this causal structure into an equivalent but simpler one." The action/observation exchange rule is a reasonable one to prototype, because its condition is a graph-separation property you can check locally:

% simplified action/observation exchange:
% if B and C are d-separated given the rest of the graph once we
% remove B's incoming causal edges, an observational edge B -> C
% can be rewritten as an interventional one, do(B) -> C
pred d-separated i:event, i:event, i:list event.

rule (leads-to B C) \ (observed B) <=>
  d-separated B C {other-parents-of C},
  (leads-to B C) => intervened-leads-to B C |
  print "rewrote observational edge to interventional:", print B, print C.

This is deliberately a sketch, not a finished identification algorithm — d-separated needs a real implementation over the graph's current edge set, and the rewrite needs to retract the old edge as well as assert the new one. But the point stands: the search for which rewrite applies, backtracking over alternative separations if the first one fails, is exactly what Prolog-family backtracking with a constraint store is for. A Python identification routine has to hand-code this search; here it's close to free.

Querying the graph

Once events and links are in the constraint store, querying is ordinary λProlog, and the binder structure keeps queries honest about scope:

pred causal-ancestors i:event, o:list event.
causal-ancestors E Ancestors :-
  findall a\ (leads-to a E) Direct,
  findall a\ (sigma d\ leads-to a d, member d Direct) Indirect,
  Ancestors = Direct ++ Indirect.

pred within-episode i:episode, i:event -> prop, o:list event.
within-episode Ep P Matching :-
  episode-events Ep Events,
  filter Events P Matching.

within-episode takes a predicate over events, so you can ask things like "which events in this episode have a leads-to edge into the anomaly event" without ever exposing an event from a different episode to the filter — the episode's binder already excludes them.

What this buys you, and what it doesn't

The honest assessment: this is a promising fit for the reasoning layer of a CTEG system — incremental constraint propagation, scoped causal context, backtracking search over candidate rewrites — not for the storage and retrieval layer. Elpi's WAM-derived engine wasn't built for traversing graphs at the scale a production system like LadybugDB handles; you would not want millions of events sitting directly in Elpi's constraint store. The more realistic architecture is a hybrid one: a columnar graph store holds the bulk data and handles the four-channel retrieval (HNSW, PPR, community detection, text-to-Cypher), and Elpi is invoked on the subgraph pulled back for a specific causal query — the working set is small enough that CHR's incremental propagation is a genuine advantage rather than an overhead.

That division of labor — bulk storage in a real graph database, causal reasoning in a binder-aware constraint logic language — hasn't been tried as far as I can tell. Whether it holds up past the sketch stage is an open question, but the representational fit between CTEGs' scoped causal structure and Elpi's core design goal (manipulating syntax trees with binders, under CHR) is close enough to be worth the experiment.