Matrix logo

Edges & Graph

cortex.AddEdge, cortex.RemoveEdge, and cortex.GetEdge manage typed directed edges between memories. cortex.IterEdgesOut / cortex.IterEdgesIn scan adjacency lists. query.Run with...

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. AddEdge on a tombstoned edge revives it. RemoveEdge on a missing or already-tombstoned edge is a no-op.


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 — for example, a contradicts edge could name the conflicting fields. Empty for all current Phase 6 usages.


AddEdge

err := c.AddEdge(
    srcID,
    memory.EdgeTypeRelatedTo,
    dstID,
    cortex.AddEdgeMeta{
        CreatedBy: "paxeer-assistant",
        Weight:    0.9,
    },
)

Self-edges (src == dst) are rejected with ErrSelfEdge. Zero IDs are rejected.

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.


RemoveEdge

err := c.RemoveEdge(srcID, memory.EdgeTypeRelatedTo, 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.EdgeTypeRelatedTo},
    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: &startID,
    Follow: &query.EdgeExpr{
        Types:    []memory.EdgeType{memory.EdgeTypeRelatedTo, memory.EdgeTypeDerivedFrom},
        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.HopDistances[i] is the hop count of Result.Memories[i] from From.


Edge types

EdgeType is a 1-byte closed enum stored in e/from and e/to keys. The full set lives in memory/edge.go. Representative types:

TypeMeaning
EdgeTypeRelatedToGeneric semantic relationship
EdgeTypeDerivedFromThis memory was derived from another
EdgeTypeContradictsThis memory contradicts another
EdgeTypeSupersedesThis memory supersedes another
EdgeTypePartOfComponent relationship
EdgeTypeReferencesExplicit citation

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.goMaxHopsCap constant
Change traversal direction defaultsquery/graph.govalidateEdgeExpr default-fill