A bounded walk (six hops, two hundred events) over the links events
actually record: causation ids, parent ids and cited-event evidence,
backwards towards causes and forwards towards consequences, with the edge
kind on every arrow. Nodes carry their cycle for grouping, labelled as
correlation rather than causation, and the artefacts (beliefs, memories,
model calls) they rest on. Cyclic references terminate. Exposed at
GET /api/v1/events/{id}/chain and /events/{id}/timeline, linked from the
event page, and documented.
205 lines
5.6 KiB
Go
205 lines
5.6 KiB
Go
package events
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
|
|
"github.com/andydixon/noema/pkg/events"
|
|
"github.com/andydixon/noema/pkg/ids"
|
|
)
|
|
|
|
// Causal chains (review pass §7.2 item 11). A chain is a bounded walk over
|
|
// the provenance links events actually record: causation_id ("caused"),
|
|
// parent_ids ("parent") and evidence references to other events ("cited").
|
|
// Membership of the same cognitive cycle is reported on each node but is
|
|
// never an edge: it is correlation, and the interface must say so.
|
|
|
|
// Edge kinds.
|
|
const (
|
|
EdgeCaused = "caused" // to.causation_id = from
|
|
EdgeParent = "parent" // from ∈ to.parent_ids
|
|
EdgeCited = "cited" // to.evidence references from
|
|
)
|
|
|
|
// Chain limits. Depth counts hops from the origin; nodes caps the whole walk.
|
|
const (
|
|
MaxChainDepth = 6
|
|
MaxChainNodes = 200
|
|
)
|
|
|
|
// ChainNode is one event in a chain with its distance from the origin.
|
|
type ChainNode struct {
|
|
Event events.Event `json:"event"`
|
|
Depth int `json:"depth"` // negative = upstream (cause side), positive = downstream
|
|
CycleID ids.ID `json:"cycle_id"` // correlation id; grouping only, not causation
|
|
Artefacts []events.EvidenceRef `json:"artefacts"` // non-event references the node cites (beliefs, memories, calls)
|
|
}
|
|
|
|
// ChainEdge is one recorded relationship between two events in the chain.
|
|
type ChainEdge struct {
|
|
From ids.ID `json:"from"`
|
|
To ids.ID `json:"to"`
|
|
Kind string `json:"kind"`
|
|
}
|
|
|
|
// Chain is the result of a bounded walk.
|
|
type Chain struct {
|
|
Origin ids.ID `json:"origin"`
|
|
Direction string `json:"direction"`
|
|
Depth int `json:"depth"`
|
|
Nodes []ChainNode `json:"nodes"`
|
|
Edges []ChainEdge `json:"edges"`
|
|
Truncated bool `json:"truncated"` // the node cap stopped the walk
|
|
}
|
|
|
|
// Chain walks back (towards causes), forward (towards consequences) or both
|
|
// from an event, up to depth hops and MaxChainNodes nodes. Only recorded
|
|
// links are followed; nothing is inferred. Visited sets make cycles harmless.
|
|
func (s *Store) Chain(ctx context.Context, origin ids.ID, direction string, depth int) (Chain, error) {
|
|
if depth <= 0 || depth > MaxChainDepth {
|
|
depth = MaxChainDepth
|
|
}
|
|
if direction != "back" && direction != "forward" {
|
|
direction = "both"
|
|
}
|
|
start, err := s.Get(ctx, origin)
|
|
if err != nil {
|
|
return Chain{}, err
|
|
}
|
|
c := Chain{Origin: origin, Direction: direction, Depth: depth}
|
|
seen := map[ids.ID]bool{origin: true}
|
|
edgeSeen := map[[3]string]bool{}
|
|
nodes := map[ids.ID]ChainNode{origin: node(start, 0)}
|
|
addEdge := func(from, to ids.ID, kind string) {
|
|
k := [3]string{string(from), string(to), kind}
|
|
if edgeSeen[k] {
|
|
return
|
|
}
|
|
edgeSeen[k] = true
|
|
c.Edges = append(c.Edges, ChainEdge{From: from, To: to, Kind: kind})
|
|
}
|
|
type item struct {
|
|
ev events.Event
|
|
d int
|
|
}
|
|
// Upstream: what this event says it came from.
|
|
if direction != "forward" {
|
|
queue := []item{{start, 0}}
|
|
for len(queue) > 0 && len(nodes) < MaxChainNodes {
|
|
cur := queue[0]
|
|
queue = queue[1:]
|
|
if -cur.d >= depth {
|
|
continue
|
|
}
|
|
var upstream []ids.ID
|
|
kinds := map[ids.ID]string{}
|
|
if !cur.ev.CausationID.IsZero() {
|
|
upstream = append(upstream, cur.ev.CausationID)
|
|
kinds[cur.ev.CausationID] = EdgeCaused
|
|
}
|
|
for _, p := range cur.ev.ParentIDs {
|
|
if _, ok := kinds[p]; !ok {
|
|
upstream = append(upstream, p)
|
|
kinds[p] = EdgeParent
|
|
}
|
|
}
|
|
for _, ref := range cur.ev.Evidence {
|
|
if ref.Kind != "event" {
|
|
continue
|
|
}
|
|
if id, err := ids.Parse(ref.ID); err == nil {
|
|
if _, ok := kinds[id]; !ok {
|
|
upstream = append(upstream, id)
|
|
kinds[id] = EdgeCited
|
|
}
|
|
}
|
|
}
|
|
if len(upstream) == 0 {
|
|
continue
|
|
}
|
|
evs, err := s.GetMany(ctx, upstream)
|
|
if err != nil {
|
|
return c, err
|
|
}
|
|
for _, e := range evs {
|
|
addEdge(e.ID, cur.ev.ID, kinds[e.ID])
|
|
if seen[e.ID] {
|
|
continue
|
|
}
|
|
if len(nodes) >= MaxChainNodes {
|
|
c.Truncated = true
|
|
break
|
|
}
|
|
seen[e.ID] = true
|
|
nodes[e.ID] = node(e, cur.d-1)
|
|
queue = append(queue, item{e, cur.d - 1})
|
|
}
|
|
}
|
|
}
|
|
// Downstream: what later events say came from this one.
|
|
if direction != "back" {
|
|
queue := []item{{start, 0}}
|
|
for len(queue) > 0 && len(nodes) < MaxChainNodes {
|
|
cur := queue[0]
|
|
queue = queue[1:]
|
|
if cur.d >= depth {
|
|
continue
|
|
}
|
|
children, err := s.Children(ctx, cur.ev.ID, 50)
|
|
if err != nil {
|
|
return c, err
|
|
}
|
|
cited, err := s.DerivedFrom(ctx, "event", string(cur.ev.ID), 50)
|
|
if err != nil {
|
|
return c, err
|
|
}
|
|
for _, e := range children {
|
|
kind := EdgeParent
|
|
if e.CausationID == cur.ev.ID {
|
|
kind = EdgeCaused
|
|
}
|
|
addEdge(cur.ev.ID, e.ID, kind)
|
|
}
|
|
for _, e := range cited {
|
|
addEdge(cur.ev.ID, e.ID, EdgeCited)
|
|
}
|
|
for _, e := range append(children, cited...) {
|
|
if seen[e.ID] {
|
|
continue
|
|
}
|
|
if len(nodes) >= MaxChainNodes {
|
|
c.Truncated = true
|
|
break
|
|
}
|
|
seen[e.ID] = true
|
|
nodes[e.ID] = node(e, cur.d+1)
|
|
queue = append(queue, item{e, cur.d + 1})
|
|
}
|
|
}
|
|
}
|
|
for _, n := range nodes {
|
|
c.Nodes = append(c.Nodes, n)
|
|
}
|
|
sort.Slice(c.Nodes, func(i, j int) bool { return c.Nodes[i].Event.Seq < c.Nodes[j].Event.Seq })
|
|
sort.Slice(c.Edges, func(i, j int) bool {
|
|
if c.Edges[i].From != c.Edges[j].From {
|
|
return c.Edges[i].From < c.Edges[j].From
|
|
}
|
|
return c.Edges[i].To < c.Edges[j].To
|
|
})
|
|
if c.Edges == nil {
|
|
c.Edges = []ChainEdge{}
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func node(e events.Event, depth int) ChainNode {
|
|
n := ChainNode{Event: e, Depth: depth, CycleID: e.CorrelationID, Artefacts: []events.EvidenceRef{}}
|
|
for _, ref := range e.Evidence {
|
|
if ref.Kind != "event" {
|
|
n.Artefacts = append(n.Artefacts, ref)
|
|
}
|
|
}
|
|
return n
|
|
}
|