Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Core/Core.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
<Compile Include="RobustStats.fs" />
<Compile Include="TemporalCoordinationDetection.fs" />
<Compile Include="Veridicality.fs" />
<Compile Include="Graph.fs" />
<Compile Include="Window.fs" />
<Compile Include="Advanced.fs" />
<Compile Include="Fusion.fs" />
Expand Down
172 changes: 172 additions & 0 deletions src/Core/Graph.fs
Original file line number Diff line number Diff line change
@@ -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)
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The β€œDesign contract” comment labels the linked ADR as β€œOtto-123 ADR”, but the referenced file (docs/DECISIONS/2026-04-24-graph-substrate-zset-backed-retraction-native.md) doesn’t use that identifier. Consider removing the β€œOtto-123” label or aligning it with the ADR’s actual title/identifier to avoid confusing cross-references.

Suggested change
/// substrate-zset-backed-retraction-native.md` (Otto-123 ADR)
/// substrate-zset-backed-retraction-native.md`

Copilot uses AI. Check for mistakes.
/// 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
Comment on lines +13 to +32
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The file-level doc comment includes direct contributor name attribution (e.g., β€œAaron”, β€œAmara”, β€œOtto”), which violates the repo’s operational standing rule β€œNo name attribution in code, docs, or skills” (docs/AGENT-BEST-PRACTICES.md:284-292). Replace these with role references (e.g., β€œarchitect”, β€œresearch courier”, β€œhuman maintainer”) and move any detailed attribution to an allowed surface (memory/persona/** or docs/BACKLOG.md).

Suggested change
/// 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
/// substrate-zset-backed-retraction-native.md` (ADR record)
/// codifies the 5 tightness properties: ZSet-backed, first-class
/// event support, retractable, storage-format tight, operator-
/// algebra composable.
///
/// **Attribution.**
/// * Architect β€” design bar ("tight in all aspects")
/// * Research courier β€” formalization across the ferry sequence
/// and validation bar ("can it detect a dumb cartel in a toy
/// simulation?")
/// * Human maintainer β€” implementation under the small-
/// graduation 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 the

Copilot uses AI. Check for mistakes.
/// 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.
[<AutoOpen>]
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.
[<GeneralizableValue>]
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
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The example call form in the doc comment doesn’t match the actual parameter order. edgeWeight is defined as edgeWeight source target g, but the comment says edgeWeight g source target. Update the comment to match the signature so callers aren’t misled.

Suggested change
/// `edgeWeight g source target` β€” the signed multiplicity
/// `edgeWeight source target g` β€” the signed multiplicity

Copilot uses AI. Check for mistakes.
/// 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
Comment on lines +78 to +82
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The example call form in the doc comment doesn’t match the actual parameter order. addEdge is defined as addEdge source target weight g, but the comment says addEdge g source target weight. Update the comment to match the signature.

Copilot uses AI. Check for mistakes.
(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) ])
Comment on lines +78 to +92
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: addEdge/removeEdge only special-case weight = 0L. If a caller passes a negative weight, addEdge will effectively remove and still emit EdgeAdded(…, negativeWeight), and removeEdge will effectively add (double-negation). Either validate/normalize weights (e.g., require weight > 0L and reinterpret negative weights consistently) or adjust the API/event naming to explicitly accept signed deltas.

Copilot uses AI. Check for mistakes.

/// `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)
Comment on lines +94 to +101
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The example call form in the doc comment doesn’t match the actual parameter order. removeEdge is defined as removeEdge source target weight g, but the comment says removeEdge g source target weight. Update the comment to match the signature.

Copilot uses AI. Check for mistakes.
(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
165 changes: 165 additions & 0 deletions tests/Tests.FSharp/Algebra/Graph.Tests.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
module Zeta.Tests.Algebra.GraphTests

open FsUnit.Xunit
open global.Xunit
open Zeta.Core


// ─── empty + basic accessors ─────────

[<Fact>]
let ``empty graph has zero edges and zero nodes`` () =
let g : Graph<int> = Graph.empty
Graph.isEmpty g |> should equal true
Graph.edgeCount g |> should equal 0
Graph.nodeCount g |> should equal 0

[<Fact>]
let ``edgeWeight returns 0 for absent edge`` () =
let g : Graph<int> = Graph.empty
Graph.edgeWeight 1 2 g |> should equal 0L


// ─── addEdge ─────────

[<Fact>]
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) ]

[<Fact>]
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<int> list)

[<Fact>]
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 ─────────

[<Fact>]
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) ]

[<Fact>]
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

[<Fact>]
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

[<Fact>]
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 ─────────

[<Fact>]
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

[<Fact>]
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) ]

[<Fact>]
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) ]

[<Fact>]
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 ─────────

[<Fact>]
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

[<Fact>]
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 ─────────

[<Fact>]
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

[<Fact>]
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
1 change: 1 addition & 0 deletions tests/Tests.FSharp/Tests.FSharp.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<Compile Include="Algebra/RobustStats.Tests.fs" />
<Compile Include="Algebra/TemporalCoordinationDetection.Tests.fs" />
<Compile Include="Algebra/Veridicality.Tests.fs" />
<Compile Include="Algebra/Graph.Tests.fs" />

<!-- Circuit/ -->
<Compile Include="Circuit/Circuit.Tests.fs" />
Expand Down
Loading