feat(events): causal timeline over recorded links only
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.
This commit is contained in:
@@ -25,6 +25,7 @@ func (s *Server) eventRoutes() {
|
||||
m.Handle("GET /api/v1/events/stream", require(auth.RoleObserver, s.eventsStream))
|
||||
m.Handle("GET /api/v1/events/{id}", require(auth.RoleObserver, s.eventGet))
|
||||
m.Handle("GET /api/v1/events/{id}/context", require(auth.RoleObserver, s.eventContext))
|
||||
m.Handle("GET /api/v1/events/{id}/chain", require(auth.RoleObserver, s.eventChain))
|
||||
m.Handle("GET /api/v1/events/types", require(auth.RoleObserver, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"types": events.AllTypes()})
|
||||
}))
|
||||
@@ -333,3 +334,20 @@ func (s *Server) eventsStream(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eventChain returns the bounded causal chain around an event: only the
|
||||
// links events actually record, in the requested direction and depth.
|
||||
func (s *Server) eventChain(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, r, ValidationError("bad id"))
|
||||
return
|
||||
}
|
||||
depth, _ := strconv.Atoi(r.URL.Query().Get("depth"))
|
||||
c, err := s.deps.Events.Chain(r.Context(), id, r.URL.Query().Get("direction"), depth)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, c)
|
||||
}
|
||||
|
||||
@@ -537,6 +537,64 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/events/{id}/chain": {
|
||||
"get": {
|
||||
"operationId": "eventChain",
|
||||
"summary": "bounded causal chain around an event (recorded links only)",
|
||||
"x-required-role": "observer",
|
||||
"tags": [
|
||||
"events"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "direction",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"back",
|
||||
"forward",
|
||||
"both"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "depth",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"maximum": 6
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Error"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/events/{id}/context": {
|
||||
"get": {
|
||||
"operationId": "eventContext",
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
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
|
||||
}
|
||||
@@ -167,3 +167,71 @@ func TestCursorsToleratePlaceholderGaps(t *testing.T) {
|
||||
t.Fatalf("stats: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChainFollowsOnlyRecordedLinks: the causal chain walks causation,
|
||||
// parent and cited-event links in both directions, reports cycle membership
|
||||
// without turning it into an edge, and stays within its bounds.
|
||||
func TestChainFollowsOnlyRecordedLinks(t *testing.T) {
|
||||
pool := storage.TestPool(t, migrations.FS)
|
||||
ctx := context.Background()
|
||||
m, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "chain"})
|
||||
s := NewStore(pool, NewBus(8), nil)
|
||||
cycle := ids.New()
|
||||
obs, _ := s.Append(ctx, events.New{MindID: m.ID, Type: events.Observation, Source: "test", Summary: "disk alert", CorrelationID: cycle})
|
||||
perc, _ := s.Append(ctx, events.New{MindID: m.ID, Type: events.Perception, Source: "brain.perceive", Summary: "perceived alert", CausationID: obs[0].ID, ParentIDs: []ids.ID{obs[0].ID}, CorrelationID: cycle})
|
||||
recall, _ := s.Append(ctx, events.New{MindID: m.ID, Type: events.MemoryRetrieved, Source: "brain.retrieve", Summary: "recalled last outage", CorrelationID: cycle}) // same cycle, no recorded link
|
||||
belief, _ := s.Append(ctx, events.New{MindID: m.ID, Type: events.BeliefCreated, Source: "beliefs", Summary: "belief: disk is failing", CausationID: perc[0].ID, CorrelationID: cycle,
|
||||
Evidence: []events.EvidenceRef{{Kind: "event", ID: string(obs[0].ID)}, {Kind: "memory", ID: "mem-1"}}})
|
||||
goal, _ := s.Append(ctx, events.New{MindID: m.ID, Type: events.GoalCreated, Source: "brain.curiosity", Summary: "investigate the disk", CausationID: belief[0].ID})
|
||||
|
||||
back, err := s.Chain(ctx, belief[0].ID, "back", 6)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids3 := map[ids.ID]bool{}
|
||||
for _, n := range back.Nodes {
|
||||
ids3[n.Event.ID] = true
|
||||
}
|
||||
if !ids3[obs[0].ID] || !ids3[perc[0].ID] || ids3[recall[0].ID] || ids3[goal[0].ID] {
|
||||
t.Fatalf("back chain nodes wrong: %+v", back.Nodes)
|
||||
}
|
||||
kinds := map[string]int{}
|
||||
for _, e := range back.Edges {
|
||||
kinds[e.Kind]++
|
||||
}
|
||||
if kinds[EdgeCaused] != 2 || kinds[EdgeCited] != 1 || kinds[EdgeParent] != 0 {
|
||||
// obs→perc is both causation and parent; causation wins for the same pair.
|
||||
t.Fatalf("edge kinds: %+v", back.Edges)
|
||||
}
|
||||
for _, n := range back.Nodes {
|
||||
if n.CycleID != cycle && n.Event.ID != goal[0].ID {
|
||||
t.Fatalf("cycle membership not reported: %+v", n)
|
||||
}
|
||||
if n.Event.ID == belief[0].ID && (len(n.Artefacts) != 1 || n.Artefacts[0].Kind != "memory") {
|
||||
t.Fatalf("artefacts: %+v", n.Artefacts)
|
||||
}
|
||||
}
|
||||
forward, _ := s.Chain(ctx, obs[0].ID, "forward", 6)
|
||||
fw := map[ids.ID]bool{}
|
||||
for _, n := range forward.Nodes {
|
||||
fw[n.Event.ID] = true
|
||||
}
|
||||
if !fw[perc[0].ID] || !fw[belief[0].ID] || !fw[goal[0].ID] || fw[recall[0].ID] {
|
||||
t.Fatalf("forward chain wrong: %+v", forward.Nodes)
|
||||
}
|
||||
// Depth bounds the walk: one hop back from the goal reaches the belief only.
|
||||
one, _ := s.Chain(ctx, goal[0].ID, "back", 1)
|
||||
if len(one.Nodes) != 2 {
|
||||
t.Fatalf("depth 1 should give origin plus one: %d", len(one.Nodes))
|
||||
}
|
||||
// A self-referencing evidence link cannot loop the walk.
|
||||
loop, _ := s.Append(ctx, events.New{MindID: m.ID, Type: events.Metacognition, Source: "brain", Summary: "loop", CausationID: goal[0].ID})
|
||||
// Append-only rows cannot be edited to point forward, so emulate a cycle through evidence back to an ancestor and re-walk.
|
||||
if _, err := s.Append(ctx, events.New{MindID: m.ID, Type: events.Metacognition, Source: "brain", Summary: "loop2", CausationID: loop[0].ID, Evidence: []events.EvidenceRef{{Kind: "event", ID: string(obs[0].ID)}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
both, err := s.Chain(ctx, obs[0].ID, "both", 6)
|
||||
if err != nil || len(both.Nodes) > MaxChainNodes || both.Truncated {
|
||||
t.Fatalf("cyclic references must terminate: %v %d", err, len(both.Nodes))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/andydixon/noema/internal/api"
|
||||
"github.com/andydixon/noema/internal/auth"
|
||||
evstore "github.com/andydixon/noema/internal/events"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
@@ -16,6 +17,7 @@ func (u *UI) eventRoutes(m *http.ServeMux) {
|
||||
m.Handle("GET /minds/{mind}/thoughts", u.require(auth.RoleObserver, u.thoughts))
|
||||
m.Handle("POST /minds/{mind}/observe", u.require(auth.RoleOperator, u.observe))
|
||||
m.Handle("GET /events/{id}", u.require(auth.RoleObserver, u.event))
|
||||
m.Handle("GET /events/{id}/timeline", u.require(auth.RoleObserver, u.timeline))
|
||||
}
|
||||
|
||||
var sinceWindows = map[string]string{"15m": "15 minutes", "1h": "1 hour", "24h": "24 hours", "168h": "7 days"}
|
||||
@@ -118,3 +120,50 @@ func prettyAny(v any) string {
|
||||
b, _ := json.MarshalIndent(v, "", " ")
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// timeline renders the causal chain around an event grouped by cognitive
|
||||
// cycle. Edges are the recorded links; cycle grouping is shown as such.
|
||||
func (u *UI) timeline(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
u.errorPage(w, r, 404, "No such event.")
|
||||
return
|
||||
}
|
||||
depth, _ := strconv.Atoi(r.URL.Query().Get("depth"))
|
||||
if depth <= 0 {
|
||||
depth = 4
|
||||
}
|
||||
direction := r.URL.Query().Get("direction")
|
||||
c, err := u.deps.Events.Chain(r.Context(), id, direction, depth)
|
||||
if err != nil {
|
||||
u.errorPage(w, r, 404, "No such event.")
|
||||
return
|
||||
}
|
||||
origin, _ := u.deps.Events.Get(r.Context(), id)
|
||||
m, _ := u.deps.Minds.Get(r.Context(), origin.MindID)
|
||||
// Group nodes by cycle in sequence order; edges into a lookup for rendering.
|
||||
type group struct {
|
||||
Cycle ids.ID
|
||||
Nodes []evstore.ChainNode
|
||||
}
|
||||
var groups []*group
|
||||
byCycle := map[ids.ID]*group{}
|
||||
for _, n := range c.Nodes {
|
||||
g := byCycle[n.CycleID]
|
||||
if g == nil {
|
||||
g = &group{Cycle: n.CycleID}
|
||||
byCycle[n.CycleID] = g
|
||||
groups = append(groups, g)
|
||||
}
|
||||
g.Nodes = append(g.Nodes, n)
|
||||
}
|
||||
inbound := map[ids.ID][]evstore.ChainEdge{}
|
||||
names := map[ids.ID]string{}
|
||||
for _, n := range c.Nodes {
|
||||
names[n.Event.ID] = n.Event.Summary
|
||||
}
|
||||
for _, e := range c.Edges {
|
||||
inbound[e.To] = append(inbound[e.To], e)
|
||||
}
|
||||
u.render(w, r, "timeline", "Timeline · "+truncateStr(origin.Summary, 60), map[string]any{"Mind": m, "Origin": origin, "Chain": c, "Groups": groups, "Inbound": inbound, "Names": names, "Depth": depth, "Direction": c.Direction})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user