From 1183beb5f5bd70ebd55719fe561bf774faf3152c Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Fri, 24 Apr 2026 03:27:56 -0400 Subject: [PATCH] =?UTF-8?q?core:=20Graph=20operator-algebra=20composition?= =?UTF-8?q?=20(map/filter/distinct/union/difference)=20=E2=80=94=209th=20g?= =?UTF-8?q?raduation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second Graph-substrate step per the ADR (PR #316). Demonstrates the ADR's 5th tightness property — "operator-algebra composable" — by delegating directly to the corresponding ZSet operators. No graph- specific implementation; each function is a 1-2 line projection through ZSet. Aaron Otto-121 claim validated: "first of its kind, no competitors" because the Graph operators ARE the ZSet operators, reused without reimplementation. Standard graph libraries reimplement map/filter for their own mutable types; Zeta's Graph gets them for free from the underlying algebraic substrate. Surface (5 new functions): - Graph.map : ('N -> 'M) -> Graph<'N> -> Graph<'M> Relabel via projection over node-tuple. Collisions sum via ZSet consolidation. - Graph.filter : ('N * 'N -> bool) -> Graph<'N> -> Graph<'N> Edge-predicate filter. Direct ZSet.filter delegation. - Graph.distinct : Graph<'N> -> Graph<'N> Collapse multi-edges to multiplicity 1; drop anti-edges (negative-weight entries). Set-semantics view of the graph. - Graph.union : Graph<'N> -> Graph<'N> -> Graph<'N> Sum edge weights across graphs. Useful for merging views. - Graph.difference : Graph<'N> -> Graph<'N> -> Graph<'N> Subtract b from a. Useful for counterfactuals ("what does graph minus suspected-cartel-edges look like?"). Retraction- native: produces anti-edges when b has entries a lacks. Retraction-native discipline carries through across operators: - union-with-b followed by difference-with-b restores original (cross-operator retraction-conservation test verifies this) - distinct drops anti-edges (negative-weight entries) to produce proper set semantics Tests (8 new, 25 total in GraphTests module, all passing): - map relabels nodes - map collisions sum via ZSet consolidation - filter with source-predicate keeps matching edges - distinct collapses multi-edge 7 to multiplicity 1 - distinct drops anti-edges - union sums weights across graphs - difference subtracts (produces anti-edges when b > a) - union+difference round-trip restores original Build: 0 Warning / 0 Error. SPOF (per Otto-106): pure functions; no external deps; no SPOF. Each operator is ~1-2 lines because the algebraic substrate already provides the semantics. Next graduation (queue): - Graph.largestEigenvalue (power iteration; cartel-detection proof-point) - Graph.modularityScore (Louvain or spectral clustering) - Toy cartel detector property test (50 validators + 5-node cartel; Amara Otto-122 validation bar; detection rate >=90% across 1000 FsCheck seeds) Composes with: - src/Core/Graph.fs (PR #317 skeleton — merged main) - src/Core/ZSet.fs operator API (substrate) Co-Authored-By: Claude Opus 4.7 --- src/Core/Graph.fs | 59 ++++++++++++++++ tests/Tests.FSharp/Algebra/Graph.Tests.fs | 83 +++++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/src/Core/Graph.fs b/src/Core/Graph.fs index 52a16f62..19b31580 100644 --- a/src/Core/Graph.fs +++ b/src/Core/Graph.fs @@ -170,3 +170,62 @@ module Graph = if s = n then acc <- acc + entry.Weight if t = n then acc <- acc + entry.Weight acc + + /// `map f g` — relabel nodes via `f`. The resulting graph + /// preserves edge multiplicity: `(s, t, w)` becomes + /// `(f s, f t, w)`. Collisions (two edges mapping to the + /// same `(f s, f t)` pair) sum their weights via the + /// underlying ZSet consolidation. + /// + /// Operator-algebra composition: wraps `ZSet.map` with a + /// projection over the node-tuple. The "tight-with-ZSet" + /// property (ADR Otto-121) is manifest here — we don't + /// reimplement map; we project through it. + let map (f: 'N -> 'M) (g: Graph<'N>) : Graph<'M> when 'M : comparison = + { Edges = ZSet.map (fun (s, t) -> (f s, f t)) g.Edges } + + /// `filter predicate g` — keep only edges where + /// `predicate (source, target)` is true. Weights are + /// preserved for kept edges. + /// + /// Operator-algebra composition: delegates to `ZSet.filter` + /// directly; no graph-specific logic. + let filter (predicate: 'N * 'N -> bool) (g: Graph<'N>) : Graph<'N> = + { Edges = ZSet.filter predicate g.Edges } + + /// `distinct g` — collapse each edge's multiplicity to + /// exactly `1` if it has positive total weight, `0` + /// otherwise (dropping anti-edges). + /// + /// Rationale: when downstream code wants "set semantics" + /// over edges (does edge `(s, t)` exist vs. does it have + /// multiplicity 7), `distinct` collapses the multi-graph + /// into a simple graph. Implemented as `ZSet.distinct` + /// wrapped over the edge ZSet. + /// + /// Note: `distinct` drops negative-weight anti-edges. A + /// graph with `(1,2)` at weight `-3` becomes empty after + /// distinct. This is the correct set-semantics answer — + /// an edge with negative multiplicity "doesn't exist" as + /// a positive set member. + let distinct (g: Graph<'N>) : Graph<'N> = + { Edges = ZSet.distinct g.Edges } + + /// `union a b` — add all edge weights between two graphs. + /// An edge present in both sums the weights; an edge in + /// only one is preserved at its own weight. + /// + /// Delegates to `ZSet.add`. The retraction-native semantics + /// carry through: a graph containing `(1,2, -5)` (an + /// anti-edge) unioned with `(1,2, 5)` cancels cleanly. + let union (a: Graph<'N>) (b: Graph<'N>) : Graph<'N> = + { Edges = ZSet.add a.Edges b.Edges } + + /// `difference a b` — subtract graph `b` from graph `a`. + /// Equivalent to `union a (negate b)`. + /// + /// Delegates to `ZSet.sub`. Useful for counterfactuals: + /// "what does `graph minus suspected-cartel-edges` look + /// like?" + let difference (a: Graph<'N>) (b: Graph<'N>) : Graph<'N> = + { Edges = ZSet.sub a.Edges b.Edges } diff --git a/tests/Tests.FSharp/Algebra/Graph.Tests.fs b/tests/Tests.FSharp/Algebra/Graph.Tests.fs index 8168a23c..fd5dfafc 100644 --- a/tests/Tests.FSharp/Algebra/Graph.Tests.fs +++ b/tests/Tests.FSharp/Algebra/Graph.Tests.fs @@ -163,3 +163,86 @@ let ``fromEdgeSeq drops zero-weight triples`` () = ] Graph.edgeCount g |> should equal 1 Graph.edgeWeight 2 3 g |> should equal 1L + + +// ─── map / filter / distinct / union / difference ───────── + +[] +let ``map relabels nodes via the projection`` () = + let g = Graph.fromEdgeSeq [ (1, 2, 3L); (2, 3, 1L) ] + let g' = Graph.map (fun n -> n * 10) g + Graph.edgeWeight 10 20 g' |> should equal 3L + Graph.edgeWeight 20 30 g' |> should equal 1L + +[] +let ``map collisions sum weights via underlying ZSet consolidation`` () = + // Two edges (1,2) and (3,4) both map to ("same", "same"). + // The underlying ZSet consolidates them. + let g = Graph.fromEdgeSeq [ (1, 2, 3L); (3, 4, 5L) ] + let g' = Graph.map (fun _ -> "collapsed") g + Graph.edgeWeight "collapsed" "collapsed" g' |> should equal 8L + Graph.edgeCount g' |> should equal 1 + +[] +let ``filter keeps only edges matching the predicate`` () = + let g = + Graph.fromEdgeSeq [ + (1, 2, 3L) + (2, 3, 1L) + (3, 1, 5L) + ] + let g' = Graph.filter (fun (s, _) -> s > 1) g + Graph.edgeCount g' |> should equal 2 + Graph.edgeWeight 2 3 g' |> should equal 1L + Graph.edgeWeight 3 1 g' |> should equal 5L + Graph.edgeWeight 1 2 g' |> should equal 0L + +[] +let ``distinct collapses multi-edges to multiplicity 1`` () = + let (g1, _) = Graph.addEdge 1 2 3L Graph.empty + let (g2, _) = Graph.addEdge 1 2 4L g1 + // Now (1,2) has weight 7 + Graph.edgeWeight 1 2 g2 |> should equal 7L + let g' = Graph.distinct g2 + Graph.edgeWeight 1 2 g' |> should equal 1L + +[] +let ``distinct drops anti-edges (negative-weight entries)`` () = + // An anti-edge has negative multiplicity; set semantics say + // it "doesn't exist" as a positive set member. + let (g, _) = Graph.removeEdge 1 2 3L Graph.empty + Graph.edgeWeight 1 2 g |> should equal -3L + let g' = Graph.distinct g + Graph.edgeWeight 1 2 g' |> should equal 0L + +[] +let ``union sums edge weights across graphs`` () = + let a = Graph.fromEdgeSeq [ (1, 2, 3L); (2, 3, 1L) ] + let b = Graph.fromEdgeSeq [ (1, 2, 5L); (3, 4, 2L) ] + let u = Graph.union a b + Graph.edgeWeight 1 2 u |> should equal 8L + Graph.edgeWeight 2 3 u |> should equal 1L + Graph.edgeWeight 3 4 u |> should equal 2L + +[] +let ``difference subtracts b from a (retraction-native)`` () = + let a = Graph.fromEdgeSeq [ (1, 2, 5L); (2, 3, 3L) ] + let b = Graph.fromEdgeSeq [ (1, 2, 2L); (3, 4, 1L) ] + let d = Graph.difference a b + Graph.edgeWeight 1 2 d |> should equal 3L // 5 - 2 + Graph.edgeWeight 2 3 d |> should equal 3L // preserved + Graph.edgeWeight 3 4 d |> should equal -1L // anti-edge + +[] +let ``union then difference recovers original (retraction conservation across operators)`` () = + // The same algebraic invariant from single-edge mutations, + // demonstrated across whole-graph operators. Union-with-b + // followed by difference-with-b restores the original + // (modulo consolidation metadata). + let a = Graph.fromEdgeSeq [ (1, 2, 5L); (2, 3, 3L) ] + let b = Graph.fromEdgeSeq [ (1, 2, 2L); (3, 4, 7L) ] + let combined = Graph.union a b + let restored = Graph.difference combined b + Graph.edgeWeight 1 2 restored |> should equal 5L + Graph.edgeWeight 2 3 restored |> should equal 3L + Graph.edgeWeight 3 4 restored |> should equal 0L