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 @@ +