Skip to content
Closed
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
59 changes: 59 additions & 0 deletions src/Core/Graph.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +180 to +183
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: This docstring references a contributor/name-based ADR label ("Otto-121"), but the repo’s standing rule is to avoid name attribution in code/docs. Please replace with a role-ref and/or an ADR number/file path (e.g., the relevant ADR in docs/DECISIONS/) so the reference stays stable across contributor turnover.

Copilot uses AI. Check for mistakes.
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)`.
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 doc comment says this is equivalent to union a (negate b), but there is no negate operation on Graph (and the implementation delegates to ZSet.sub). Consider rewording to the precise equivalence (ZSet.sub a b or ZSet.add a (ZSet.neg b)) to avoid misleading API consumers.

Suggested change
/// Equivalent to `union a (negate b)`.
/// Equivalent to `ZSet.sub a.Edges b.Edges` on the
/// underlying edge Z-sets.

Copilot uses AI. Check for mistakes.
///
/// 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 }
83 changes: 83 additions & 0 deletions tests/Tests.FSharp/Algebra/Graph.Tests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────

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

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

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

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

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

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

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

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