1 unstable release
Uses new Rust 2024
| 0.1.0 | Apr 2, 2026 |
|---|
#1247 in Algorithms
505KB
13K
SLoC
GRW
Graph construction, mutation, and morphism matching in Rust.
GRW is an embedded graph rewriting system that runs inside a Rust process. The user API is modelled as small domain-specific languages built with macro_rules! — no procedural macros — by overloading Rust operators to express graph edge semantics. All DSL fragments are plain Rust structs — they can be constructed, composed, and manipulated programmatically before being passed to the macros or the underlying from_fragment() / modify() / compile() functions directly.
graph!— graph literal (likevec!)modify!— transactional graph mutation (add/remove/change nodes and edges atomically)search!— graph pattern matching iterator with morphism control
Graph model
A graph Graph<NV, ER> is parameterized by:
NV— node value type (use()for no attributes)ER— edge relation type, one of:
| Edge type | Operators | Max edges between 2 nodes | Slots |
|---|---|---|---|
edge::Undir<EV> |
^ |
1 undirected | UND |
edge::Dir<EV> |
>> << |
2 directed (incoming + outgoing) | SRC TGT |
edge::Anydir<EV> |
^ >> << |
3 (undirected + incoming + outgoing) | UND SRC TGT |
Type aliases for common configurations:
type Undir0 = Graph<(), edge::Undir<()>>; // no attributes
type UndirN<NV> = Graph<NV, edge::Undir<()>>; // node values only
type UndirE<EV> = Graph<(), edge::Undir<EV>>; // edge values only
type Undir<N, E> = Graph<NV, edge::Undir<EV>>; // both
type Dir0 = Graph<(), edge::Dir<()>>;
type Anydir0 = Graph<(), edge::Anydir<()>>;
// ... same pattern for Dir, Anydir
graph! — construction
DSL primitives
| Symbol | Meaning |
|---|---|
N(id) |
New node with explicit local id |
N_() |
New node with auto-assigned id |
n(id) |
Reference to previously defined node |
E() |
Edge constructor (for attaching values) |
.val(v) |
Attach a value to a node or edge |
^ |
Undirected edge |
>> |
Directed edge (source → target) |
<< |
Directed edge (target ← source) |
& |
Attach explicit edge before direction operator |
, |
Separate independent fragments |
Undirected graphs
| |
| |
|
// with node values
let g: Graph<&str, edge::Undir<()>> = graph![
N(0).val("alice") ^ (N(1).val("bob") ^ N(2).val("carol"))
].unwrap();
// with edge values — use & E().val(...) before the direction operator
let g: Graph<(), edge::Undir<f64>> = graph![
N(0) & E().val(1.5) ^ (N(1) & E().val(2.0) ^ N(2))
].unwrap();
// both node and edge values
let g: Graph<&str, edge::Undir<u32>> = graph![
N(0).val("a") & E().val(10) ^ N(1).val("b")
].unwrap();
// anonymous nodes — ids assigned automatically
let g: Graph<(), edge::Undir<()>> = graph![N_() ^ N_() ^ N_()].unwrap();
Directed graphs
| |
| |
|
// incoming edges with <<
let g: Graph<(), edge::Dir<()>> = graph![
N(0) << N(1), // edge from 1 to 0
].unwrap();
// directed with edge values
let g: Graph<(), edge::Dir<i32>> = graph![
N(0) & E().val(10) >> (N(1) & E().val(20) >> N(2))
].unwrap();
Anydirected graphs (mixed undirected + directed)
| |
|
Turbofish syntax
When the type can't be inferred, use the turbofish form:
let g = graph![<(), grw::graph::edge::Undir<()>>; N(0) ^ N(1)].unwrap();
modify! — mutation
modify! applies transactional changes to an existing graph — adding nodes, removing nodes, adding/removing edges, and swapping values — all atomically. It returns a Modification with the ids of everything that changed.
DSL primitives
| Symbol | Meaning |
|---|---|
N(id) |
New node (local id for back-references within this modification) |
N_() |
New node (auto-assigned local id) |
n(id) |
Reference to new node defined earlier in same modify! |
X(id) |
Existing graph node (by graph node id) |
x(id) |
Reference to existing graph node (no value change) |
!X(id) |
Remove existing node (and all its edges) |
E() |
New edge constructor |
e() |
Existing edge reference (for value swap) |
!e() |
Remove existing edge |
.val(v) |
Set value on node or edge |
Adding nodes and edges
| |
| |
| |
| |
| |
|
Removing nodes and edges
| |
|
Updating values
| |
|
Modification result
The returned Modification contains:
new_node_ids— mapping from local ids to real graph idsadded_edges— edges createdremoved_nodes/removed_edges— what was deletedswapped_node_vals/swapped_edge_vals— old values that were replaced
search! — pattern matching
search! compiles a pattern into a query and iterates over all morphism-valid mappings from pattern nodes to target graph nodes. Patterns are organized into clusters — get (required) and ban (forbidden substructures).
|
Pattern (what you're looking for) |
Target (the graph you're searching in) |
DSL primitives
| Symbol | Meaning |
|---|---|
get(morphism) { ... } |
Required pattern cluster — must be found |
ban(morphism) { ... } |
Forbidden pattern cluster — matches are rejected |
N(id) |
Pattern node |
n(id) |
Reference to pattern node |
^ >> << |
Edge operators (same as graph!) |
!N(id) |
Negated node — the edge must NOT exist |
N(id).val(v) |
Node value — exact match |
N(id).test(|v| ...) |
Node value predicate |
E().val(v) |
Edge value — exact match |
X(id) |
Context node — pinned to a specific graph node |
Basic usage
When search! receives a graph reference, it creates a Session that you iterate directly with a for loop:
use grw::*; // Graph, graph!, search!, Morphism, etc.
use grw::graph::edge;
// target graph: triangle 0—1—2—0
let g: graph::Undir0 = graph![
N(0) ^ (N(1) ^ (N(2) ^ n(0)))
].unwrap();
// search! with a graph reference → Session → for loop
let session = search![&g,
get(Mono) {
N(0) ^ N(1)
}
].unwrap();
for m in &session {
let a = m.get(0).unwrap(); // graph node matched by pattern node 0
let b = m.get(1).unwrap(); // graph node matched by pattern node 1
println!("{:?} — {:?}", a, b);
}
The Match struct
Each iteration yields a Match — a mapping from pattern node local ids to graph node ids.
let session = search![&g, get(Mono) { N(0) ^ N(1) }].unwrap();
for m in &session {
// get a specific pattern node's graph mapping
let node_id: id::N = m.get(0).unwrap();
// iterate all (pattern_local_id, graph_node_id) pairs
for &(lid, nid) in m.iter() {
println!("pattern {} → graph {:?}", lid.0, nid);
}
// just the graph node ids
let graph_nodes: Vec<id::N> = m.values().collect();
}
For richer access — node values, adjacencies — use translate():
for m in &session {
let tm = session.translate(&m);
// node with its value
if let Some((nid, val)) = tm.node(0) {
println!("pattern 0 → graph {:?} val={:?}", nid, val);
}
// all matched nodes with values
for (lid, nid, val) in tm.nodes() {
println!("{} → {:?} = {:?}", lid.0, nid, val);
}
}
Ban clusters (forbidden substructures)
// find edges whose endpoints do NOT share a common neighbor
let session = search![&g,
get(Mono) {
N(0) ^ N(1)
},
ban(Mono) {
n(0) ^ N(2),
n(1) ^ n(2),
}
].unwrap();
Sequential vs parallel iteration
The Session returned by search![&g, ...] supports two iteration modes:
// sequential — lazy iterator, one match at a time (backtracking)
for m in session.iter() { /* ... */ }
// parallel — uses rayon, returns all matches at once
let all_matches: Vec<Match> = session.par_iter().collect();
session.iter() (or &session in a for loop) is Seq — single-threaded lazy backtracking. session.par_iter() is Par — partitions the search space across threads via rayon.
Index tiers (optional optimization)
When using search![&g, ...], the graph is indexed automatically with RevCsr. For manual control, you can index explicitly and use the lower-level Seq::search / Par::search API:
| Tier | What it builds | When to use |
|---|---|---|
Rev |
Reverse adjacency map | Minimal memory, small graphs |
RevCsr |
CSR-compressed reverse adjacency | Default choice, good balance |
RevCsrVal |
CSR + cached edge values | Valued graphs with edge predicates |
use grw::search::{Search, Seq, Par, RevCsr};
let Search::Resolved(r) = search![<(), edge::Undir<()>>;
get(Mono) { N(0) ^ N(1) }
].unwrap() else { panic!() };
let indexed = g.index(RevCsr);
// low-level sequential
let matches: Vec<_> = Seq::search(&r.query, &indexed).collect();
// low-level parallel
let matches: Vec<_> = Par::search(&r.query, &indexed);
Morphisms
A morphism is a mapping from pattern nodes to target nodes that preserves edges. Three independent axes control how strict the mapping is:
| Axis | Meaning |
|---|---|
| Injective | One-to-one — no two pattern nodes map to the same target node |
| Surjective | Covers everything — every target node is hit by at least one pattern node |
| Induced | Exact neighborhood — no extra edges allowed between matched nodes |
The six morphisms
| Morphism | Injective | Surjective | Induced | Plain English |
|---|---|---|---|---|
| Iso | yes | yes | yes | Exact match — same shape, same size, same edges |
| SubIso | yes | no | yes | Find this exact shape inside the target |
| EpiMono | yes | yes | no | Bijection, but extra edges between matched nodes OK |
| Mono | yes | no | no | Each pattern node gets a unique target node, extra edges OK |
| Epi | no | yes | no | Must cover all target nodes, can collapse pattern nodes |
| Homo | no | no | no | Anything goes — just preserve edges |
Lattice
These form a partial order. Going up adds constraints, going down relaxes them. The meet() of two morphisms is the most relaxed morphism that satisfies both.
Iso
/ \
SubIso EpiMono
\ / \ /
Mono Epi
\ /
Homo
- Up = more constrained, down = more relaxed
SubIso= Mono + induced,EpiMono= Mono + surjectiveIso= SubIso + surjective = EpiMono + induced
When to use what
- Iso — "Are these two graphs identical?" Graph comparison, canonical forms, symmetry detection.
- SubIso — "Does this shape appear inside that graph?" The workhorse of pattern matching. Find motifs, substructures, embedded patterns. The matched region must look exactly like the pattern — no extra connections.
- Mono — "Can I embed this pattern without node conflicts?" Like SubIso but relaxed: extra edges between matched nodes are allowed. Often easier to compute.
- Epi — "Does the pattern cover the entire target?" Every target node must be matched. Coverage analysis, tiling.
- EpiMono — "Is this a relabeling with possible extra edges?" Bijective but non-induced. Arises naturally when combining Mono and Epi constraints.
- Homo — "Can this pattern be projected onto that graph?" Most relaxed. Graph coloring is a homomorphism to a complete graph.
Visual examples
Pattern: path A — B — C. Target: triangle 0 — 1 — 2 — 0.
|
Pattern |
Target |
Mapping A→0, B→1, C→2 — the required edges (A—B, B—C) are present, but there's an extra edge 0—2 not in the pattern:
|
Mono: accept extra edge OK — not induced |
SubIso: reject extra edge violates induced constraint |
| Morphism | Verdict | Why |
|---|---|---|
| Iso | reject | extra edge 0—2 not in pattern (induced) |
| SubIso | reject | same — extra edge violates induced |
| EpiMono | accept | bijective, edges preserved, extra OK |
| Mono | accept | injective, edges preserved, extra OK |
| Epi | accept | surjective, edges preserved |
| Homo | accept | edges preserved, no other constraints |
Collapsing A→0, B→1, C→1 — C maps to the same target node as B:
|
Homo: accept collapse allowed |
Mono: reject — B and C map to same node (not injective) |
Complexity
| Morphism | Complexity | Why |
|---|---|---|
| Iso | GI-complete | Own complexity class, believed sub-exponential |
| SubIso | NP-complete | Generalizes clique, Hamiltonian path |
| EpiMono | GI-complete | Bijection check + edge preservation |
| Mono | NP-complete | Generalizes clique |
| Epi | NP-complete | Surjectivity + edge preservation |
| Homo | NP-complete | Generalizes graph coloring |
In practice, real-world graphs have structure (bounded degree, sparsity, value predicates) that makes these tractable with backtracking and pruning. Small patterns on large graphs are fast.
Acknowledgement
Claude (Anthropic) has been involved in the implementation of this project since February 2026, contributing to code generation, cross-validation testing, and performance tuning.
The theoretical foundations (morphism cluster mixing, ban/get pattern semantics) are original human work rooted in 2014 PhD studies. The Rust DSL operator design and API architecture are new work built on top of that theory.
License
MIT
Dependencies
~5.5MB
~104K SLoC