Files
noema/internal/events/events_test.go
T
Andy Dixon 85d769b245 feat: add immutable cognitive event store, live streams and event explorer
Phase 3. Events are the append-only record of everything a mind does
(ADR-0002). pkg/events defines the closed type vocabulary, categories,
evidence references and the observation payload with explicit trust
levels. The store validates and appends inside the caller's transaction,
publishes to a bounded fan-out bus only after commit, queries by seq
cursor with type/category/source/salience/confidence/time/search
filters, and walks provenance: cause, parents, children, cited evidence,
derived events and correlation groups. Durable per-consumer cursors never
move backwards.

Server-Sent Events streams (per mind and global) subscribe before
replaying history after Last-Event-ID so nothing is missed, apply the
same filter vocabulary live, and heartbeat. Operators can inject
observations tagged with operator trust for demos and tests.

UI: live thought stream with filters and paging, forensic event page
with causal chain and evidence navigation, dashboard important events.
Personality and status changes now leave operator_action events in the
mind's own history. CLI: event inspect/list/tail, observe.
2026-06-17 20:02:22 +01:00

115 lines
4.2 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)
}
}