Files
noema/internal/events/events_test.go
T
Andy Dixon 9eed6e2a18 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.
2026-08-22 19:47:22 +01:00

238 lines
10 KiB
Go

package events
import (
"context"
"errors"
"testing"
"time"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
func TestBusBoundedFanout(t *testing.T) {
b := NewBus(2)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
m1, m2 := ids.New(), ids.New()
all, _ := b.Subscribe(ctx, ids.Nil, 1)
only1, _ := b.Subscribe(ctx, m1, 8)
if _, err := b.Subscribe(ctx, ids.Nil, 1); err == nil {
t.Fatal("subscriber cap not enforced")
}
b.Publish(events.Event{MindID: m1, Type: events.Observation}, events.Event{MindID: m2, Type: events.Observation}, events.Event{MindID: m1, Type: events.Reflection})
if len(only1) != 2 {
t.Fatalf("mind filter wrong: %d", len(only1))
}
if len(all) != 1 || b.Dropped() != 2 {
t.Fatalf("expected 1 buffered and 2 dropped, got %d / %d", len(all), b.Dropped())
}
cancel()
time.Sleep(5 * time.Millisecond)
if b.Subscribers() != 0 {
t.Fatal("subscribers not removed on cancel")
}
}
func TestStoreAppendQueryImmutableAndCursors(t *testing.T) {
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
m, err := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "ev"})
if err != nil {
t.Fatal(err)
}
other, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "other"})
s := NewStore(pool, NewBus(10), nil)
sub, _ := s.Bus().Subscribe(ctx, m.ID, 16)
if _, err := s.Append(ctx, events.New{MindID: m.ID, Type: "bogus", Summary: "x"}); !errors.Is(err, ErrInvalid) {
t.Fatalf("unknown type accepted: %v", err)
}
obs, err := s.Append(ctx, events.New{MindID: m.ID, Type: events.Observation, Source: "test", Summary: "load spike observed", Salience: 0.9, Confidence: 1.2, Payload: map[string]any{"cpu": 97}})
if err != nil {
t.Fatal(err)
}
if obs[0].Confidence != 1 || obs[0].Seq == 0 || obs[0].Category != events.CatPerception {
t.Fatalf("normalisation or seq wrong: %+v", obs[0])
}
hyp, _ := s.Append(ctx, events.New{MindID: m.ID, Type: events.HypothesisCreated, Source: "reasoning", Summary: "disk contention hypothesis",
CausationID: obs[0].ID, ParentIDs: []ids.ID{obs[0].ID}, CorrelationID: obs[0].ID, Confidence: 0.4, Salience: 0.7,
Evidence: []events.EvidenceRef{{Kind: "event", ID: string(obs[0].ID)}}})
s.Append(ctx, events.New{MindID: other.ID, Type: events.Observation, Source: "test", Summary: "other mind sees something else"}) //nolint:errcheck
// Live delivery.
if len(sub) != 2 {
t.Fatalf("expected 2 live events for mind, got %d", len(sub))
}
// Mind scoping and filters.
got, _ := s.Query(ctx, Filter{MindID: m.ID})
if len(got) != 2 || got[0].Type != events.HypothesisCreated {
t.Fatalf("query wrong: %+v", got)
}
got, _ = s.Query(ctx, Filter{MindID: m.ID, Types: []events.Type{events.Observation}, MinSalience: 0.5})
if len(got) != 1 {
t.Fatalf("type filter wrong: %d", len(got))
}
got, _ = s.Query(ctx, Filter{MindID: m.ID, Search: "contention"})
if len(got) != 1 {
t.Fatalf("search wrong: %d", len(got))
}
got, _ = s.Query(ctx, Filter{MindID: m.ID, AfterSeq: obs[0].Seq, Ascending: true})
if len(got) != 1 || got[0].ID != hyp[0].ID {
t.Fatalf("cursor paging wrong: %+v", got)
}
// Causal navigation.
kids, _ := s.Children(ctx, obs[0].ID, 10)
if len(kids) != 1 || kids[0].ID != hyp[0].ID {
t.Fatalf("children wrong: %+v", kids)
}
derived, _ := s.DerivedFrom(ctx, "event", string(obs[0].ID), 10)
if len(derived) != 1 {
t.Fatalf("evidence lookup wrong: %d", len(derived))
}
// Immutability.
if _, err := pool.Exec(ctx, `UPDATE cognitive_events SET summary='rewritten' WHERE id=$1`, obs[0].ID); err == nil {
t.Fatal("events must be append-only")
}
if _, err := pool.Exec(ctx, `DELETE FROM cognitive_events`); err == nil {
t.Fatal("events must not be deletable")
}
// Cursors never move backwards.
s.SetCursor(ctx, pool, m.ID, "brain", hyp[0].Seq) //nolint:errcheck
s.SetCursor(ctx, pool, m.ID, "brain", obs[0].Seq) //nolint:errcheck
cur, _ := s.Cursor(ctx, pool, m.ID, "brain")
if cur != hyp[0].Seq {
t.Fatalf("cursor regressed: %d", cur)
}
st, _ := s.Stats(ctx, m.ID, time.Time{})
if st.Total != 2 || st.ByCategory["belief"] != 1 {
t.Fatalf("stats wrong: %+v", st)
}
}
// TestCursorsToleratePlaceholderGaps burns sequence values the way a rolled
// back insert does and checks that cursors, forward paging and SSE-style
// resumption (seq > last) see every real event and nothing else. A gap in
// seq must never be mistaken for a missing event (ADR-0002).
func TestCursorsToleratePlaceholderGaps(t *testing.T) {
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
m, err := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "gaps"})
if err != nil {
t.Fatal(err)
}
s := NewStore(pool, NewBus(10), nil)
first, err := s.Append(ctx, events.New{MindID: m.ID, Type: events.Observation, Source: "test", Summary: "one"})
if err != nil {
t.Fatal(err)
}
// A failed insert consumes a sequence value and rolls back; emulate several.
for i := 0; i < 3; i++ {
if _, err := pool.Exec(ctx, `SELECT nextval(pg_get_serial_sequence('cognitive_events','seq'))`); err != nil {
t.Fatal(err)
}
}
tx, _ := pool.Begin(ctx)
if _, err := s.AppendIn(ctx, tx, events.New{MindID: m.ID, Type: events.Observation, Source: "test", Summary: "rolled back"}); err != nil {
t.Fatal(err)
}
tx.Rollback(ctx) //nolint:errcheck
second, err := s.Append(ctx, events.New{MindID: m.ID, Type: events.Observation, Source: "test", Summary: "two"})
if err != nil {
t.Fatal(err)
}
if second[0].Seq <= first[0].Seq+1 {
t.Fatalf("test did not create a gap: %d then %d", first[0].Seq, second[0].Seq)
}
// Forward paging from the cursor sees exactly the real events after it.
after, err := s.Query(ctx, Filter{MindID: m.ID, AfterSeq: first[0].Seq, Ascending: true, Limit: 10})
if err != nil || len(after) != 1 || after[0].ID != second[0].ID {
t.Fatalf("paging across a gap: %v %+v", err, after)
}
// A cursor parked inside the gap still resumes at the next real event.
if err := s.SetCursor(ctx, pool, m.ID, "test", first[0].Seq+2); err != nil {
t.Fatal(err)
}
cur, _ := s.Cursor(ctx, pool, m.ID, "test")
resume, _ := s.Query(ctx, Filter{MindID: m.ID, AfterSeq: cur, Ascending: true, Limit: 10})
if len(resume) != 1 || resume[0].ID != second[0].ID {
t.Fatalf("resume from inside a gap: %+v", resume)
}
// Stats count real rows, not sequence span.
st, _ := s.Stats(ctx, m.ID, time.Time{})
if st.Total != 2 || st.LastSeq != second[0].Seq {
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))
}
}