Edges & Graph
cortex.AddEdge, cortex.RemoveEdge, and cortex.GetEdge manage typed directed edges between memories. BFS graph traversal powers Find From / Follow.
cortex.AddEdge, cortex.RemoveEdge, and cortex.GetEdge manage typed directed edges between memories. cortex.IterEdgesOut / cortex.IterEdgesIn scan adjacency lists. query.Run with From + Follow performs bounded BFS graph traversal.
Source files: cortex/edges.go, cortex/memory/edge.go, cortex/query/graph.go.
Design decisions
Bidirectional atomic writes. Every AddEdge writes both the forward key (e/from/<src>/<t>/<dst>) and the reverse key (e/to/<dst>/<t>/<src>) in a single Pebble batch, alongside a KindAddEdge journal entry. You cannot have a forward edge without a reverse edge.
Soft-delete only. RemoveEdge rewrites both edge records with Tombstoned=true rather than deleting them. The audit trail lives in the e/ records and in the KindRemoveEdge journal entry. Traversal and iteration skip tombstoned edges by default; callers that need audit history set IncludeTombstoned=true.
Idempotent semantics. AddEdge on a live existing edge is a no-op (no second journal entry). AddEdge on a tombstoned edge revives it (rewrites Tombstoned=false). RemoveEdge on a missing or already-tombstoned edge is a no-op.
Self-edges rejected. AddEdge where src == dst returns ErrSelfEdge.
Edge types
14 byte-tagged edge types. The full set lives in memory/edge.go:
| Type | Byte | Meaning |
|---|---|---|
EdgeDerivedFrom | 0x01 | This memory was derived from another |
EdgeSupersedes | 0x02 | This memory supersedes another |
EdgeReferences | 0x03 | Explicit citation |
EdgeContradicts | 0x04 | This memory contradicts another |
EdgeCorroborates | 0x05 | This memory corroborates another |
EdgeConsentsTo | 0x06 | Consent relationship |
EdgeDispatchedTo | 0x07 | Dispatched to sub-agent |
EdgeAttestedBy | 0x08 | Attested by agent |
EdgeCitedIn | 0x09 | Cited in plan/intent |
EdgeTombstones | 0x0A | Tombstones relationship |
EdgePartOf | 0x0B | Component relationship |
EdgeInstanceOf | 0x0C | Instance-of relationship |
EdgeCausedBy | 0x0D | Causal relationship |
EdgeObservedBy | 0x0E | Observed by agent |
EdgeRecord
type EdgeRecord struct {
Type EdgeType
Src ID
Dst ID
CreatedAt time.Time
CreatedBy string
Weight float32
Data []byte // opaque CBOR for edge-type-specific payloads
Tombstoned bool
TombstonedAt *time.Time
TombstonedReason string
TombstonedBy string
}
Data is reserved for edge-type-specific payloads. Empty for all current Phase 6 usages.
AddEdge
err := c.AddEdge(
srcID,
memory.EdgeReferences,
dstID,
cortex.AddEdgeMeta{
CreatedBy: "paxeer-assistant",
Weight: 0.9,
},
)
Batch contents
e/from/<src:16>/<t:1>/<dst:16> <- canonical CBOR EdgeRecord
e/to/<dst:16>/<t:1>/<src:16> <- identical bytes (bidirectional mirror)
j/<seq> <- KindAddEdge journal entry
idx/smt/edges/… <- edges SMT update (forward direction only)
The SMT stages only the forward direction to avoid double-anchoring the same fact. Both directional keys carry the same canonical bytes; a hop in either direction reads consistent metadata in one Get.
RemoveEdge
err := c.RemoveEdge(srcID, memory.EdgeReferences, dstID, "superseded", "auditor")
Rewrites both e/from/<src>/<t>/<dst> and e/to/<dst>/<t>/<src> with Tombstoned=true, TombstonedReason, and TombstonedBy set. Appends a KindRemoveEdge journal entry and stages the edges SMT update.
GetEdge
rec, err := c.GetEdge(srcID, edgeType, dstID)
// err == memory.ErrNotFound if edge was never created
// rec.Tombstoned == true if removed
Returns tombstoned edges, callers inspect rec.Tombstoned. Use this for audit reads.
Iteration
// Outgoing edges from src
err = c.IterEdgesOut(srcID, cortex.IterEdgesOptions{
Types: []memory.EdgeType{memory.EdgeReferences},
IncludeTombstoned: false,
}, func(rec *memory.EdgeRecord) error {
// process rec
return nil
})
// Incoming edges into dst
err = c.IterEdgesIn(dstID, cortex.IterEdgesOptions{}, func(rec *memory.EdgeRecord) error { ... })
When exactly one Types filter is provided, the scan uses the tighter per-type prefix (e/from/<src>/<t>). Otherwise it scans the full anchor prefix and post-filters by type byte. Stop iteration by returning any non-nil error; the iterator treats errStopIter as clean stop.
Graph traversal via Find
query.Find with From and Follow performs bounded BFS:
result, err := c.Find(query.Query{
Type: []memory.Type{memory.TypeFact, memory.TypeEvent},
From: &startURI,
Follow: &query.EdgeExpr{
Types: []memory.EdgeType{memory.EdgeReferences, memory.EdgeDerivedFrom},
MaxHops: 3,
Direction: query.DirOut,
},
Limit: 20,
Form: query.FormMedium,
})
BFS visits neighbors in byte-ascending (edge_type, dst) order, same as Pebble's natural iteration order, so results are reproducible across runs for the same store state.
MaxHopsCap = 6 is the hard upper bound regardless of Follow.MaxHops.
Result.Hops[id] reports the BFS hop count from From for each surviving memory.
Direction modes
| Mode | Walks |
|---|---|
DirOut | e/from/<src>/… (default) |
DirIn | e/to/<dst>/… |
DirBoth | Both directions, dedupe by neighbour ID before expanding |
Follow may be nil with From set; default is "1 hop out, any edge type, live only". Follow without From returns ErrUnsupported.
Snapshot participation
AddEdge and RemoveEdge both call c.snap.StageEdgeUpdate(wb, src, edgeTypeByte, dst, enc) inside the same atomic batch. This advances the edges namespace SMT root, which feeds OverallRoot. The reverse e/to record is byte-identical to the forward record, only the forward direction is staged into the SMT to avoid double-anchoring the same fact.
Modifying edges
| What to change | Where |
|---|---|
| Add an edge type | memory/edge.go - new EdgeType const (append only, never reorder) |
| Add edge-type-specific payload | AddEdgeMeta.Data field, caller supplies canonical CBOR |
| Change BFS depth cap | query/find.go - MaxHopsCap constant |
| Change traversal direction defaults | query/graph.go - validateEdgeExpr default-fill |
