diff --git a/src/Core/Core.fsproj b/src/Core/Core.fsproj
index 3fc6e80f..99c76640 100644
--- a/src/Core/Core.fsproj
+++ b/src/Core/Core.fsproj
@@ -42,6 +42,7 @@
+
diff --git a/src/Core/Graph.fs b/src/Core/Graph.fs
new file mode 100644
index 00000000..52a16f62
--- /dev/null
+++ b/src/Core/Graph.fs
@@ -0,0 +1,172 @@
+namespace Zeta.Core
+
+
+/// **Graph — ZSet-backed retraction-native graph substrate.**
+///
+/// A `Graph<'N>` is a structural wrapper around `ZSet<'N * 'N>`:
+/// every edge is an entry in the underlying ZSet with a signed
+/// `Weight`. Add-edge is a ZSet add; remove-edge is a ZSet sub.
+/// Net-zero entries compact by the existing ZSet consolidation
+/// pass (Spine-backed when persisted).
+///
+/// **Design contract:** `docs/DECISIONS/2026-04-24-graph-
+/// substrate-zset-backed-retraction-native.md` (Otto-123 ADR)
+/// codifies the 5 tightness properties: ZSet-backed, first-class
+/// event support, retractable, storage-format tight, operator-
+/// algebra composable.
+///
+/// **Attribution.**
+/// * Aaron — design bar ("tight in all aspects") Otto-121
+/// * Amara — formalization (11th + 12th + 13th + 14th ferries
+/// + validation-bar Otto-122 "can it detect a dumb cartel in
+/// a toy simulation?")
+/// * Otto — implementation (8th graduation under Otto-105
+/// cadence; first module that completes a cross-ferry arc
+/// from concept to running substrate)
+///
+/// **Scope of this first graduation.** Core type + minimal
+/// mutation operators + node/edge accessors + retraction-
+/// conservation property test. Detection primitives
+/// (`largestEigenvalue`, `modularityScore`) and toy cartel
+/// detector ship in follow-up PRs composing on this
+/// foundation. Splitting across multiple PRs per Otto-105
+/// small-graduation cadence; the ADR's single-PR preference
+/// defers to cadence discipline.
+///
+/// **Graph event semantics.** Directed edges by default. Multi-
+/// edges supported by ZSet weight (weight = count). Self-loops
+/// allowed (source = target is a legal edge). Nodes derived
+/// from edge-endpoint set — MVP; standalone-node tracking
+/// deferred to future graduation if needed.
+[]
+module Graph =
+
+ /// A directed-edge event emitted when a graph mutates.
+ /// Subscribers consume this via Zeta's existing Circuit /
+ /// Stream machinery — no graph-specific event plumbing.
+ type GraphEvent<'N> =
+ | EdgeAdded of source:'N * target:'N * weight:int64
+ | EdgeRemoved of source:'N * target:'N * weight:int64
+
+ /// `Graph<'N>` — a directed graph with signed-weight edges.
+ /// Internal representation is `ZSet<'N * 'N>` so that every
+ /// existing ZSet operator composes automatically.
+ type Graph<'N when 'N : comparison> =
+ internal
+ { Edges: ZSet<'N * 'N> }
+
+ /// Empty graph — no edges, no nodes.
+ []
+ let empty<'N when 'N : comparison> : Graph<'N> =
+ { Edges = ZSet.empty<'N * 'N> }
+
+ /// `isEmpty g` — true when the graph has no edges with
+ /// non-zero weight.
+ let isEmpty (g: Graph<'N>) : bool = ZSet.isEmpty g.Edges
+
+ /// `edgeCount g` — number of distinct edges with non-zero
+ /// weight (multi-edges count as one; weight is not the
+ /// count here — `edgeWeight` exposes the multiplicity).
+ let edgeCount (g: Graph<'N>) : int = ZSet.count g.Edges
+
+ /// `edgeWeight g source target` — the signed multiplicity
+ /// of the edge `source → target`. Returns 0 when the edge
+ /// is absent or has been fully retracted.
+ let edgeWeight (source: 'N) (target: 'N) (g: Graph<'N>) : int64 =
+ ZSet.lookup (source, target) g.Edges
+
+ /// `addEdge g source target weight` — add `weight` to the
+ /// multiplicity of the edge `source → target`. Returns the
+ /// updated graph AND the emitted event. Weight of zero is
+ /// a no-op and emits no event.
+ let addEdge
+ (source: 'N)
+ (target: 'N)
+ (weight: int64)
+ (g: Graph<'N>)
+ : Graph<'N> * GraphEvent<'N> list =
+ if weight = 0L then (g, [])
+ else
+ let delta = ZSet.singleton (source, target) weight
+ let merged = ZSet.add g.Edges delta
+ ({ Edges = merged }, [ EdgeAdded(source, target, weight) ])
+
+ /// `removeEdge g source target weight` — subtract `weight`
+ /// from the multiplicity of the edge. NON-DESTRUCTIVE:
+ /// emits a negative-weight ZSet delta; if the result
+ /// net-zeros, ZSet consolidation drops the entry but the
+ /// Spine trace preserves the history. Weight of zero is
+ /// a no-op.
+ let removeEdge
+ (source: 'N)
+ (target: 'N)
+ (weight: int64)
+ (g: Graph<'N>)
+ : Graph<'N> * GraphEvent<'N> list =
+ if weight = 0L then (g, [])
+ else
+ let delta = ZSet.singleton (source, target) (-weight)
+ let merged = ZSet.add g.Edges delta
+ ({ Edges = merged }, [ EdgeRemoved(source, target, weight) ])
+
+ /// `fromEdgeSeq edges` — build a graph from an unordered
+ /// sequence of `(source, target, weight)` triples.
+ /// Duplicates sum via the underlying ZSet; zero-weight
+ /// triples are dropped.
+ let fromEdgeSeq (edges: ('N * 'N * int64) seq) : Graph<'N> =
+ let pairs = edges |> Seq.map (fun (s, t, w) -> (s, t), w)
+ { Edges = ZSet.ofSeq pairs }
+
+ /// `nodes g` — the set of nodes that appear as an endpoint
+ /// of any edge with non-zero weight. Derived from the edge
+ /// set; standalone-node tracking is a future graduation
+ /// candidate.
+ let nodes (g: Graph<'N>) : Set<'N> =
+ let mutable acc = Set.empty
+ let span = g.Edges.AsSpan()
+ for i in 0 .. span.Length - 1 do
+ let entry = span.[i]
+ let (s, t) = entry.Key
+ acc <- acc.Add s |> Set.add t
+ acc
+
+ /// `nodeCount g` — `|nodes g|`.
+ let nodeCount (g: Graph<'N>) : int = (nodes g).Count
+
+ /// `outNeighbors source g` — for each `(source, t, w)` in
+ /// the graph with non-zero `w`, return `(t, w)`. Does not
+ /// traverse; direct lookup against the edge set.
+ let outNeighbors (source: 'N) (g: Graph<'N>) : ('N * int64) list =
+ let span = g.Edges.AsSpan()
+ let mutable acc = []
+ for i in 0 .. span.Length - 1 do
+ let entry = span.[i]
+ let (s, t) = entry.Key
+ if s = source && entry.Weight <> 0L then
+ acc <- (t, entry.Weight) :: acc
+ List.rev acc
+
+ /// `inNeighbors target g` — dual of `outNeighbors`, edges
+ /// where `_ → target`.
+ let inNeighbors (target: 'N) (g: Graph<'N>) : ('N * int64) list =
+ let span = g.Edges.AsSpan()
+ let mutable acc = []
+ for i in 0 .. span.Length - 1 do
+ let entry = span.[i]
+ let (s, t) = entry.Key
+ if t = target && entry.Weight <> 0L then
+ acc <- (s, entry.Weight) :: acc
+ List.rev acc
+
+ /// `degree n g` — total in-degree + out-degree of `n`
+ /// (counting each edge-weight once). Self-loops count
+ /// twice (once as in-edge, once as out-edge).
+ let degree (n: 'N) (g: Graph<'N>) : int64 =
+ let span = g.Edges.AsSpan()
+ let mutable acc = 0L
+ for i in 0 .. span.Length - 1 do
+ let entry = span.[i]
+ let (s, t) = entry.Key
+ if s = n then acc <- acc + entry.Weight
+ if t = n then acc <- acc + entry.Weight
+ acc
diff --git a/tests/Tests.FSharp/Algebra/Graph.Tests.fs b/tests/Tests.FSharp/Algebra/Graph.Tests.fs
new file mode 100644
index 00000000..8168a23c
--- /dev/null
+++ b/tests/Tests.FSharp/Algebra/Graph.Tests.fs
@@ -0,0 +1,165 @@
+module Zeta.Tests.Algebra.GraphTests
+
+open FsUnit.Xunit
+open global.Xunit
+open Zeta.Core
+
+
+// ─── empty + basic accessors ─────────
+
+[]
+let ``empty graph has zero edges and zero nodes`` () =
+ let g : Graph = Graph.empty
+ Graph.isEmpty g |> should equal true
+ Graph.edgeCount g |> should equal 0
+ Graph.nodeCount g |> should equal 0
+
+[]
+let ``edgeWeight returns 0 for absent edge`` () =
+ let g : Graph = Graph.empty
+ Graph.edgeWeight 1 2 g |> should equal 0L
+
+
+// ─── addEdge ─────────
+
+[]
+let ``addEdge sets edge weight and emits EdgeAdded event`` () =
+ let (g, events) = Graph.addEdge 1 2 5L Graph.empty
+ Graph.edgeWeight 1 2 g |> should equal 5L
+ events |> should equal [ EdgeAdded(1, 2, 5L) ]
+
+[]
+let ``addEdge with zero weight is a no-op`` () =
+ let (g, events) = Graph.addEdge 1 2 0L Graph.empty
+ Graph.isEmpty g |> should equal true
+ events |> should equal ([]: GraphEvent list)
+
+[]
+let ``addEdge accumulates multi-edge weight`` () =
+ // Multi-edges are supported via ZSet signed-weight:
+ // two adds on the same edge sum to multiplicity 7.
+ let (g1, _) = Graph.addEdge 1 2 3L Graph.empty
+ let (g2, _) = Graph.addEdge 1 2 4L g1
+ Graph.edgeWeight 1 2 g2 |> should equal 7L
+ Graph.edgeCount g2 |> should equal 1
+
+
+// ─── removeEdge / retraction-native ─────────
+
+[]
+let ``removeEdge subtracts weight and emits EdgeRemoved event`` () =
+ let (g1, _) = Graph.addEdge 1 2 5L Graph.empty
+ let (g2, events) = Graph.removeEdge 1 2 5L g1
+ Graph.edgeWeight 1 2 g2 |> should equal 0L
+ Graph.isEmpty g2 |> should equal true
+ events |> should equal [ EdgeRemoved(1, 2, 5L) ]
+
+[]
+let ``removeEdge partial retraction leaves remainder`` () =
+ // Retraction-native: remove 3 from an edge of weight 5
+ // leaves weight 2, not "edge deleted".
+ let (g1, _) = Graph.addEdge 1 2 5L Graph.empty
+ let (g2, _) = Graph.removeEdge 1 2 3L g1
+ Graph.edgeWeight 1 2 g2 |> should equal 2L
+ Graph.edgeCount g2 |> should equal 1
+
+[]
+let ``retraction-conservation: addEdge then removeEdge restores empty`` () =
+ // The load-bearing property from the ADR: apply(delta)
+ // followed by apply(-delta) restores prior state modulo
+ // compaction metadata.
+ let (g1, _) = Graph.addEdge 1 2 7L Graph.empty
+ let (g2, _) = Graph.removeEdge 1 2 7L g1
+ Graph.isEmpty g2 |> should equal true
+
+[]
+let ``removeEdge on absent edge produces net-negative weight`` () =
+ // Remove-before-add is legal — ZSet signed-weight means
+ // the result is an anti-edge (negative multiplicity).
+ // Adding it later will cancel. This is what makes
+ // retraction-native counterfactuals O(|delta|).
+ let (g, _) = Graph.removeEdge 1 2 3L Graph.empty
+ Graph.edgeWeight 1 2 g |> should equal -3L
+
+
+// ─── nodes + neighbors ─────────
+
+[]
+let ``nodes derives from edge endpoints`` () =
+ let g =
+ Graph.fromEdgeSeq [
+ (1, 2, 1L)
+ (2, 3, 1L)
+ (3, 1, 1L)
+ ]
+ Graph.nodes g |> should equal (Set.ofList [1; 2; 3])
+ Graph.nodeCount g |> should equal 3
+
+[]
+let ``outNeighbors lists target nodes and weights`` () =
+ let g =
+ Graph.fromEdgeSeq [
+ (1, 2, 3L)
+ (1, 3, 5L)
+ (2, 3, 1L)
+ ]
+ let ns = Graph.outNeighbors 1 g
+ ns |> should equal [ (2, 3L); (3, 5L) ]
+
+[]
+let ``inNeighbors is dual of outNeighbors`` () =
+ let g =
+ Graph.fromEdgeSeq [
+ (1, 3, 5L)
+ (2, 3, 1L)
+ ]
+ let ns = Graph.inNeighbors 3 g
+ ns |> List.sortBy fst |> should equal [ (1, 5L); (2, 1L) ]
+
+[]
+let ``degree sums in+out edge weights`` () =
+ let g =
+ Graph.fromEdgeSeq [
+ (1, 2, 3L) // out-edge for 1
+ (2, 1, 4L) // in-edge for 1
+ (1, 3, 5L) // out-edge for 1
+ ]
+ Graph.degree 1 g |> should equal (3L + 4L + 5L)
+
+
+// ─── self-loop support ─────────
+
+[]
+let ``self-loop is a legal edge (source = target)`` () =
+ let (g, _) = Graph.addEdge 1 1 5L Graph.empty
+ Graph.edgeCount g |> should equal 1
+ Graph.edgeWeight 1 1 g |> should equal 5L
+
+[]
+let ``self-loop counts twice in degree (once in, once out)`` () =
+ let (g, _) = Graph.addEdge 1 1 3L Graph.empty
+ Graph.degree 1 g |> should equal 6L // 3 in + 3 out
+
+
+// ─── fromEdgeSeq ─────────
+
+[]
+let ``fromEdgeSeq sums duplicate edges`` () =
+ let g =
+ Graph.fromEdgeSeq [
+ (1, 2, 3L)
+ (1, 2, 4L)
+ ]
+ Graph.edgeWeight 1 2 g |> should equal 7L
+ Graph.edgeCount g |> should equal 1
+
+[]
+let ``fromEdgeSeq drops zero-weight triples`` () =
+ let g =
+ Graph.fromEdgeSeq [
+ (1, 2, 0L)
+ (2, 3, 1L)
+ (3, 4, 0L)
+ ]
+ Graph.edgeCount g |> should equal 1
+ Graph.edgeWeight 2 3 g |> should equal 1L
diff --git a/tests/Tests.FSharp/Tests.FSharp.fsproj b/tests/Tests.FSharp/Tests.FSharp.fsproj
index 9c6d4286..5799674c 100644
--- a/tests/Tests.FSharp/Tests.FSharp.fsproj
+++ b/tests/Tests.FSharp/Tests.FSharp.fsproj
@@ -20,6 +20,7 @@
+