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