Skip to content

feat: add graph set operators#6394

Open
lloydrichards wants to merge 4 commits into
Effect-TS:mainfrom
lloydrichards:feat/graph-set-op
Open

feat: add graph set operators#6394
lloydrichards wants to merge 4 commits into
Effect-TS:mainfrom
lloydrichards:feat/graph-set-op

Conversation

@lloydrichards

Copy link
Copy Markdown
Contributor

Type

  • Refactor
  • Feature

Description

Have added 4 basic and 3 advanced set operators to the Graph module to be able to build and explore for interesting structures. These include:

  • Graph.compose - $(G1 ∪ G2 = { V1 ∪ V2, E1 ∪ E2 })$
    merges two graphs by node identity, combining nodes and edges from both. Overlapping data from that wins.
  • Graph.intersection - $(G1 ∩ G2 = { V1 ∩ V2, E1 ∩ E2 })$
    keeps only nodes and edges present in both graphs.
  • Graph.difference - $(G1 \ G2 = { V1, E1 \ E2 })$
    keeps all nodes from self, but removes edges that also exist in that.
  • Graph.symmetricDifference - $(G1 Δ G2 = { V1 ∪ V2, (E1 ∪ E2) \ (E1 ∩ E2) })$
    keeps edges that exist in exactly one graph, removing shared edges.
  • Graph.complement - $(G' = { V, (V × V) \ E })$
    keeps the same nodes, but adds every missing non-self edge.
  • Graph.neighborhood - $(Nᵣ(v) = G[{ u ∈ V | distance(v, u) ≤ r }])$
    builds an induced subgraph around a node within a radius.
  • Graph.sum - $(G1 + G2 = { V1 ⊔ V2, E1 ⊔ E2 })$
    creates a disjoint union of two graphs without merging equal nodes.

Most of the implementation was done similar to NetworkX when it came to making decisions around when self or that would win on conflict and the main decision on node equality is left to the implementor where they need to pass a nodeId callback to be able to identify each node.

One additional refactor (separate commit) was the additional logic to Graph.bfs and Graph.dfs to allow for a maxDepth. This was important to be able to reuse the BFS in the neighbordhood op and create a custom version like I did in my utils experiment.

Some fun and exciting things can be done with this functionality.

Screenshot 2026-06-23 at 22 26 49

For Reviewing

To make things easier to review I've broken up the three main parts so they can be reviewed separately and left off the type tests till last. Any feedback or requests I will initial add as commits on top of the current tree and then rebase together to clean up once approved

Related

Came up as a request in Slack with a bit of back and forth discussion with @fubhy. I then spend a couple of days building similar functions in my own repo to be sure of the api as well as test out their implementation with some real examples.

@github-project-automation github-project-automation Bot moved this to Discussion Ongoing in PR Backlog Jul 14, 2026
@changeset-bot

changeset-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a58b1b7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 27 packages
Name Type
effect Major
@effect/opentelemetry Major
@effect/platform-browser Major
@effect/platform-bun Major
@effect/platform-node-shared Major
@effect/platform-node Major
@effect/vitest Major
@effect/ai-anthropic Major
@effect/ai-openai-compat Major
@effect/ai-openai Major
@effect/ai-openrouter Major
@effect/atom-react Major
@effect/atom-solid Major
@effect/atom-vue Major
@effect/sql-clickhouse Major
@effect/sql-d1 Major
@effect/sql-libsql Major
@effect/sql-mssql Major
@effect/sql-mysql2 Major
@effect/sql-pg Major
@effect/sql-pglite Major
@effect/sql-sqlite-bun Major
@effect/sql-sqlite-do Major
@effect/sql-sqlite-node Major
@effect/sql-sqlite-react-native Major
@effect/sql-sqlite-wasm Major
@effect/openapi-generator Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@fubhy

fubhy commented Jul 14, 2026

Copy link
Copy Markdown
Member

I closed the split PRs again (I just wanted them to review the seperate chunks independently).

Comment on lines +613 to +630
const emptyLike = <N, E, T extends Kind>(
graph: Graph<N, E, T>,
mutate: (mutable: MutableGraph<N, E, T>) => void
): Graph<N, E, T> => {
const mutable = beginMutation(graph)

mutable.nodes.clear()
mutable.edges.clear()
mutable.adjacency.clear()
mutable.reverseAdjacency.clear()
mutable.nextNodeIndex = 0
mutable.nextEdgeIndex = 0
mutable.acyclic = Option.some(true)

mutate(mutable)

return endMutation(mutable)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is odd and adds unnecessary overhead and should not be needed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'd probably do something like this:

const make =
  <T extends Kind>(type: T) =>
  <N, E>(
    mutate?: (mutable: MutableGraph<N, E, T>) => void
  ): Graph<N, E, T> => {
    const graph: Mutable<Graph<N, E, T>> = Object.create(ProtoGraph)

    graph.type = type
    graph.nodes = new Map()
    graph.edges = new Map()
    graph.adjacency = new Map()
    graph.reverseAdjacency = new Map()
    graph.nextNodeIndex = 0
    graph.nextEdgeIndex = 0
    graph.acyclic = Option.some(true)
    graph.mutable = false

    if (mutate !== undefined) {
      const mutable = beginMutation(graph)
      mutate(mutable)
      return endMutation(mutable)
    }

    return graph
  }

export const directed: <N, E>(
  mutate?: (mutable: MutableDirectedGraph<N, E>) => void
) => DirectedGraph<N, E> = make("directed")

export const undirected: <N, E>(
  mutate?: (mutable: MutableUndirectedGraph<N, E>) => void
) => UndirectedGraph<N, E> = make("undirected")

And then instead of using emptyLike you'd call make(self.type)(mutate)

Comment on lines +585 to +610
/** @internal */
type NodeMaps<N> = {
readonly byId: Map<string, { readonly index: NodeIndex; readonly data: N }>
readonly byIndex: Map<NodeIndex, string>
}

/** @internal */
type EdgeIdentity<E> = { readonly sourceId: string; readonly targetId: string; readonly data: E }

/** @internal */
const buildNodeMaps = <N, E, T extends Kind>(graph: Graph<N, E, T>, nodeId: (node: N) => string): NodeMaps<N> => {
const byId = new Map<string, { readonly index: NodeIndex; readonly data: N }>()
const byIndex = new Map<NodeIndex, string>()

for (const [index, data] of graph.nodes) {
const id = nodeId(data)
byId.set(id, { index, data })
byIndex.set(index, id)
}

return { byId, byIndex }
}

/** @internal */
const edgeKey = (type: Kind, sourceId: string, targetId: string): string =>
type === "undirected" && sourceId > targetId ? `${targetId}\0${sourceId}` : `${sourceId}\0${targetId}`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Still not sure about the identity handling tbh.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'll have to think about this for a bit.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I still think there's something here with Equivalence & Hash

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 enhancement New feature or request

Projects

Status: Discussion Ongoing

Development

Successfully merging this pull request may close these issues.

3 participants