Matrix logo

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:

TypeByteMeaning
EdgeDerivedFrom0x01This memory was derived from another
EdgeSupersedes0x02This memory supersedes another
EdgeReferences0x03Explicit citation
EdgeContradicts0x04This memory contradicts another
EdgeCorroborates0x05This memory corroborates another
EdgeConsentsTo0x06Consent relationship
EdgeDispatchedTo0x07Dispatched to sub-agent
EdgeAttestedBy0x08Attested by agent
EdgeCitedIn0x09Cited in plan/intent
EdgeTombstones0x0ATombstones relationship
EdgePartOf0x0BComponent relationship
EdgeInstanceOf0x0CInstance-of relationship
EdgeCausedBy0x0DCausal relationship
EdgeObservedBy0x0EObserved 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

ModeWalks
DirOute/from/<src>/… (default)
DirIne/to/<dst>/…
DirBothBoth 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 changeWhere
Add an edge typememory/edge.go - new EdgeType const (append only, never reorder)
Add edge-type-specific payloadAddEdgeMeta.Data field, caller supplies canonical CBOR
Change BFS depth capquery/find.go - MaxHopsCap constant
Change traversal direction defaultsquery/graph.go - validateEdgeExpr default-fill