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:
Andy Dixon
2026-08-22 19:47:22 +01:00
parent 844de93c8b
commit 9eed6e2a18
12 changed files with 491 additions and 1 deletions
+1
View File
@@ -10,6 +10,7 @@ All notable changes to Noema are recorded here. Format follows Keep a Changelog;
- Security suite: SQL-injection effect test, static allow-list scan for assembled SQL, deceived-mind authority test, garbage model output test, self-preservation vocabulary test; threat model rewritten so every verification cites a test that asserts the claim. - Security suite: SQL-injection effect test, static allow-list scan for assembled SQL, deceived-mind authority test, garbage model output test, self-preservation vocabulary test; threat model rewritten so every verification cites a test that asserts the claim.
### Added ### Added
- Causal timeline: `GET /api/v1/events/{id}/chain` and `/events/{id}/timeline` walk the links events actually record (causation, parents, cited evidence) backwards and forwards within bounded depth and size, label edge kinds, group by cycle as correlation only, and infer nothing.
- Cognitive forks per model: minds may override model routing per role (`llm_roles`, fail-closed); experiments may declare variants that fork one snapshot under different configuration, personality or routing, receive identical stimuli, and are compared deterministically (first divergent stimulus, belief and attention overlap, score differences, memory, goal, cost and capability-request ranges). - Cognitive forks per model: minds may override model routing per role (`llm_roles`, fail-closed); experiments may declare variants that fork one snapshot under different configuration, personality or routing, receive identical stimuli, and are compared deterministically (first divergent stimulus, belief and attention overlap, score differences, memory, goal, cost and capability-request ranges).
- Runtime identity: snapshots, replays and experiments record build (commit, modified tree, Go version, platform), schema version, a secret-free configuration digest, personality version, policy digest, model routing, prompt versions, deterministic subsystem versions and timezone. Replays compare identities and label themselves exact-candidate, different-build or incompatible (the last is never run), and analyse the first divergence by component. Migration 0022. - Runtime identity: snapshots, replays and experiments record build (commit, modified tree, Go version, platform), schema version, a secret-free configuration digest, personality version, policy digest, model routing, prompt versions, deterministic subsystem versions and timezone. Replays compare identities and label themselves exact-candidate, different-build or incompatible (the last is never run), and analyse the first divergence by component. Migration 0022.
- Contextual source reliability: a per-(source, domain) record updated only by prediction outcomes and operator corrections, adjusting evidence credibility in that domain once three outcomes exist; never global. Shown on belief pages. Migration 0021. - Contextual source reliability: a per-(source, domain) record updated only by prediction outcomes and operator corrections, adjusting evidence credibility in that domain once three outcomes exist; never global. Shown on belief pages. Migration 0021.
+4
View File
@@ -1,5 +1,9 @@
# Background cognition: metacognition, reflection, consolidation, narrative, notifications # Background cognition: metacognition, reflection, consolidation, narrative, notifications
## Causal timeline
Every event records the links it was written with: `causation_id` (the event that directly led to it), `parent_ids` (the events it was assembled from) and evidence references (the events, memories, beliefs and model calls it rests on). The causal timeline (`/events/{id}/timeline`, `GET /api/v1/events/{id}/chain`) walks those links backwards towards causes and forwards towards consequences, up to six hops and two hundred events, and draws each edge with its kind: `caused`, `parent` or `cited`. Events are grouped by the cognitive cycle they belong to, and that grouping is labelled as correlation, not causation. Nothing on the page is inferred; where the system does not know why something happened, no arrow is drawn. Questions the walk answers directly: why a belief exists (walk back to the observations and assertions it cites), what changed it (the `belief_updated` events downstream of an outcome), which memories were retrieved and which thoughts won attention in the cycle, which model call contributed (an artefact on the node), and which later decisions depended on it (walk forward).
These subsystems run beside the cognitive cycle. Each is deterministic in what it concludes; a language model may only word things, and only within a configured budget. These subsystems run beside the cognitive cycle. Each is deterministic in what it concludes; a language model may only word things, and only within a configured budget.
## Metacognition (per cycle) ## Metacognition (per cycle)
+58
View File
@@ -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": { "/api/v1/events/{id}/context": {
"get": { "get": {
"operationId": "eventContext", "operationId": "eventContext",
+18
View File
@@ -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/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}", 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}/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) { 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()}) 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)
}
+58
View File
@@ -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": { "/api/v1/events/{id}/context": {
"get": { "get": {
"operationId": "eventContext", "operationId": "eventContext",
+204
View File
@@ -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
}
+68
View File
@@ -167,3 +167,71 @@ func TestCursorsToleratePlaceholderGaps(t *testing.T) {
t.Fatalf("stats: %+v", st) 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))
}
}
+49
View File
@@ -8,6 +8,7 @@ import (
"github.com/andydixon/noema/internal/api" "github.com/andydixon/noema/internal/api"
"github.com/andydixon/noema/internal/auth" "github.com/andydixon/noema/internal/auth"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/pkg/events" "github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids" "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("GET /minds/{mind}/thoughts", u.require(auth.RoleObserver, u.thoughts))
m.Handle("POST /minds/{mind}/observe", u.require(auth.RoleOperator, u.observe)) 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}", 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"} 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, "", " ") b, _ := json.MarshalIndent(v, "", " ")
return string(b) 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})
}
+8
View File
@@ -229,6 +229,14 @@ func TestFirstMilestone(t *testing.T) {
if len(detail.Assertions) != 1 || detail.Assertions[0].AttributedTo != "admin" || detail.Assertions[0].SourceEvent != ids.ID(detail.Evidence[0].RootIDs[0]) { if len(detail.Assertions) != 1 || detail.Assertions[0].AttributedTo != "admin" || detail.Assertions[0].SourceEvent != ids.ID(detail.Evidence[0].RootIDs[0]) {
t.Fatalf("assertion: %+v", detail.Assertions) t.Fatalf("assertion: %+v", detail.Assertions)
} }
// The causal chain walks recorded links only: the message leads to the belief.
var chain struct {
Nodes []struct{ Depth int }
Edges []struct{ Kind string }
}
if code := h.do("GET", "/api/v1/events/"+detail.Evidence[0].RootIDs[0]+"/chain?direction=forward&depth=4", nil, &chain); code != 200 || len(chain.Nodes) < 2 || len(chain.Edges) == 0 {
t.Fatalf("chain: %d %+v", code, chain)
}
var evCtx api.EventContext var evCtx api.EventContext
h.do("GET", "/api/v1/events/"+detail.Evidence[0].RootIDs[0]+"/context", nil, &evCtx) h.do("GET", "/api/v1/events/"+detail.Evidence[0].RootIDs[0]+"/context", nil, &evCtx)
if evCtx.Event.Type != events.Observation || len(evCtx.Children) == 0 { if evCtx.Event.Type != events.Observation || len(evCtx.Children) == 0 {
+3
View File
@@ -231,3 +231,6 @@ canvas.graph { width: 100%; height: 560px; display: block; border-radius: 8px; b
/* Study: replay & experiments */ /* Study: replay & experiments */
.replay-step { padding: .6rem 0; border-top: 1px solid var(--border); } .replay-step:first-child { border-top: 0; } .replay-step ul { margin: .2rem 0 0 1rem; padding: 0; } .strong { font-weight: 600; } .replay-step { padding: .6rem 0; border-top: 1px solid var(--border); } .replay-step:first-child { border-top: 0; } .replay-step ul { margin: .2rem 0 0 1rem; padding: 0; } .strong { font-weight: 600; }
.break { word-break: break-all; } .break { word-break: break-all; }
/* Causal timeline */
.tl-cycle { border-left: 2px solid var(--border); margin: .6rem 0 .6rem .4rem; padding: .2rem 0 .2rem .9rem; } .tl-node { padding: .35rem 0 .35rem .6rem; border-left: 2px solid transparent; } .tl-node.current { border-left-color: var(--accent); background: var(--surface-2); border-radius: 4px; } .tl-when { font-family: var(--mono); }
+1 -1
View File
@@ -8,7 +8,7 @@
<section class="grid cols-3-1"> <section class="grid cols-3-1">
<div class="stack"> <div class="stack">
<div class="card"> <div class="card">
<h2>Causal chain</h2> <h2>Causal chain <a class="small" href="/events/{{$e.ID}}/timeline">full timeline →</a></h2>
<div class="chain"> <div class="chain">
{{if $c.Cause}}<div class="chain-node"><span class="muted small">caused by</span><br><span class="etype cat-{{$c.Cause.Category}}">{{$c.Cause.Type}}</span> <a href="/events/{{$c.Cause.ID}}">{{$c.Cause.Summary}}</a></div><div class="chain-arrow">↓</div>{{end}} {{if $c.Cause}}<div class="chain-node"><span class="muted small">caused by</span><br><span class="etype cat-{{$c.Cause.Category}}">{{$c.Cause.Type}}</span> <a href="/events/{{$c.Cause.ID}}">{{$c.Cause.Summary}}</a></div><div class="chain-arrow">↓</div>{{end}}
<div class="chain-node current"><span class="etype cat-{{$e.Category}}">{{$e.Type}}</span> {{$e.Summary}}</div> <div class="chain-node current"><span class="etype cat-{{$e.Category}}">{{$e.Type}}</span> {{$e.Summary}}</div>
+19
View File
@@ -0,0 +1,19 @@
{{define "content"}}
{{$o := .Data.Origin}}{{$c := .Data.Chain}}
<div class="pagehead">
<p class="muted small"><a href="/minds/{{.Data.Mind.Slug}}">{{.Data.Mind.DisplayName}}</a> › <a href="/events/{{$o.ID}}">event #{{$o.Seq}}</a> › timeline</p>
<h1>Causal timeline</h1>
<p class="muted">Around <span class="etype cat-{{$o.Category}}">{{$o.Type}}</span> {{$o.Summary}}. Arrows are the links events recorded when they were written: <span class="badge small">caused</span> (causation), <span class="badge small">parent</span> (parent ids) and <span class="badge small">cited</span> (evidence references). Grouping by cycle is correlation, shown for orientation and <strong>not</strong> a causal claim. Nothing here is inferred.</p>
<form method="get" class="row"><label>Direction <select name="direction"><option value="both" {{if eq .Data.Direction "both"}}selected{{end}}>both</option><option value="back" {{if eq .Data.Direction "back"}}selected{{end}}>back (causes)</option><option value="forward" {{if eq .Data.Direction "forward"}}selected{{end}}>forward (consequences)</option></select></label> <label>Depth <input type="number" name="depth" value="{{.Data.Depth}}" min="1" max="6" size="2"></label> <button class="btn small">Redraw</button>{{if $c.Truncated}} <span class="badge warn">truncated at {{len $c.Nodes}} events</span>{{end}}</form>
</div>
<div class="card">
{{range .Data.Groups}}<div class="tl-cycle"><div class="muted small mono">cycle {{if .Cycle}}{{trunc (printf "%s" .Cycle) 8}}{{else}}—{{end}} <span class="muted">(same-cycle grouping; correlation, not causation)</span></div>
{{range .Nodes}}<div class="tl-node {{if eq .Event.ID $o.ID}}current{{end}}">
<div class="tl-when muted small">#{{.Event.Seq}} · {{.Event.OccurredAt.Local.Format "15:04:05.000"}} · {{if lt .Depth 0}}{{.Depth}} (upstream){{else if gt .Depth 0}}+{{.Depth}} (downstream){{else}}origin{{end}}</div>
<div><span class="etype cat-{{.Event.Category}}">{{.Event.Type}}</span> <a href="/events/{{.Event.ID}}">{{.Event.Summary}}</a> <span class="muted small">· {{.Event.Source}}</span></div>
{{with index $.Data.Inbound .Event.ID}}<div class="small muted">{{range .}}← <span class="badge small">{{.Kind}}</span> <a href="/events/{{.From}}">{{trunc (index $.Data.Names .From) 70}}</a> {{end}}</div>{{end}}
{{if .Artefacts}}<div class="small muted">rests on: {{range .Artefacts}}{{if eq .Kind "belief"}}<a href="/minds/{{$.Data.Mind.Slug}}/beliefs/{{.ID}}">belief</a>{{else if eq .Kind "memory"}}<a href="/minds/{{$.Data.Mind.Slug}}/memory/{{.ID}}">memory</a>{{else if eq .Kind "llm_call"}}<a href="/llm/calls/{{.ID}}">model call</a>{{else}}{{.Kind}}{{end}}{{with .Note}} ({{.}}){{end}} · {{end}}</div>{{end}}
</div>{{end}}
</div>{{end}}
</div>
{{end}}