GRW

Graph rewriting · Rust

Graphs as
a first-class
expression.

Build a graph, mutate it transactionally, run patterns over it, consume the matches — one loop, inside a Rust process.

Three small DSLs overload Rust's operators so an edge is just ^, >>, << — and a variable-length path is just ... mgraph! and modify! are macro_rules!; search! is a procedural macro from grw_derive.

mgraph! — a triangle, in code and on the canvas
let g: MUndir0 = mgraph![
    N(0) ^ (N(1) ^ (N(2) ^ n(0)))
].unwrap();
0 1 2

A worked example, end to end

One graph, the whole loop.

A microservice fleet: services are nodes, calls are directed edges, latencies are edge values. A deploy sneaks in a circular dependency — patterns find it, modify! removes it, navigation proves the deploy still paid off. Every snippet below is extracted from tests/site_story.rs and runs in the test suite.

act 1 mgraph! — build the fleet
type ER = grw::edge::Dir<u32>;  // latency in ms

grw::mgraph![<&'static str, ER>;
    N(0).val("gateway") & E().val(2) >> N(1).val("auth"),
    n(0) & E().val(3) >> N(2).val("orders"),
    n(1) & E().val(9) >> N(3).val("db"),
    n(2) & E().val(4) >> N(4).val("billing"),
    n(2) & E().val(12) >> n(3),
    n(4) & E().val(1) >> n(3)
].unwrap()
2 3 9 4 12 1 gateway auth orders billing db
act 2 modify! — the deploy, one atomic transaction
// add a cache between orders and db — plus an
// accidental callback edge billing → orders
grw::modify!(g, [
    X(2) & E().val(1) >> N(0).val("cache"),
    n(0) & E().val(2) >> x(3),
    x(4) & E().val(5) >> x(2)
]).unwrap();
// applies as one unit — or not at all
1 2 5 gateway auth orders cache billing db

act 3 · search!

Ask the graph questions.

Each query is drawn in pattern space: amber nodes are what you bind, rose is what must not exist, dashes are variable-length paths.

// fan-out: services calling ≥ 2 others
search![&g, get(Mono) {
    N(0) >> N(1),
    n(0) >> N(2)
}]
0 1 2
// entry points: nobody calls them
search![&g, get(Mono) {
    !N(1) >> N(0)
}]
// → only the gateway
1 0 no caller may exist
// redundant shortcut: chain + direct edge
search![&g, get(Mono) {
    N(0) >> N(1),
    n(1) >> N(2),
    n(0) >> n(2)
}]  // orders → billing → db, plus orders → db
0 1 2
// circular dependencies
search![&g,
    get(Homo) { N(0), N(1) },
    get(Mono) { n(0) >> ..n(1).dfs().len(1..) }
]  // Homo lets 0 and 1 collapse onto one node
0=1
consume the Match — addressed by your own numbering
for m in &session {
    let caller = m[0];            // Index: absence = bug
    let callee = m.get(1).unwrap(); // Option: tolerant

    let tm = session.translate(&m); // ids → values
    let (_, name) = tm.node(LocalId(0)).unwrap();
}

// cycles: filter on the mapping itself,
// and read the concrete path behind the edge
session.iter()
    .filter(|m| m[0] == m[1] && !m.path(0).is_empty())
    .count()  // 1 — billing → orders closed a loop
pattern 0 1 graph billing orders m[0]        m[1]
act 4 modify! — remove the edge, prove it's gone
grw::modify!(g, [x(4) & !e() >> x(2)]).unwrap();

// re-run the same cycle pattern:
session.iter()
    .filter(|m| m[0] == m[1] && !m.path(0).is_empty())
    .count()  // 0 — fleet is acyclic again
billing orders x(4) & !e() >> x(2)
act 5 navigate — the deploy still paid off
let cfg = Config::new(())
    .navigate(Dijkstra::weighted(|ms: &u32| *ms as f64));

let (cost, path) = g
    .path_navigate(N(0), N(3), outgoing, cfg, &constraint)
    .next()
    .unwrap();

// before the deploy:  gw → orders → billing → db = 8 ms
// after:              gw → orders → cache → db  = 6 ms
3 1 2 gateway auth orders cache billing db cheapest chain: 6 ms

Variable-length paths

A path is just ..

Prefix a pattern node with .. and the edge becomes a variable-length path: traversal, length bounds, or cost-ordered navigation — all lazy, one path per next(). Full chapter in the book →

..n(1).dfs()                                              // deepest-first simple paths
..n(1).bfs().len(3..10)                                   // shortest-first, 3–9 edges
..n(1).navigate(Dijkstra::counted())                      // cheapest by hop count
..n(1).navigate(Dijkstra::weighted(|ev: &EV| ev.cost))    // cheapest by edge weight
..n(1).navigate(AStar::new(|ev| ev.cost, |n| h(n))).all()  // every simple path, cost-ordered
..n(1).dfs().guard(|p| !p.contains(N(2)))                 // prune paths mid-walk
..n(1).drive(|n| Some(Explore::One(0)))                   // steer expansion per depth

Measured, not claimed

Against VF3.

Head-to-head with vf3lib — the reference subgraph-isomorphism implementation — via its C++ API, using its bulk ARGLoader path. GRW runs each search four ways: enumerate yields every Match (what you pay when you consume bindings); count never materializes one — each sequential and parallel (8 cores / 16 threads). Every cell's five counts are verified equal before any timer is trusted. Cells are speedup vs VF3, time in parens.

nodesmorphismmatchesVF3seqseq countparpar count
10,458subiso1.95M377ms2.6× (146ms)7.2× (53ms)19× (20ms)44× (8.6ms)
10,458mono344k70ms3.5× (20ms)16× (4.4ms)16× (4.2ms)59× (1.2ms)
104,374subiso3.50M844ms3.0× (280ms)7.4× (115ms)19× (45ms)36× (23ms)
104,374mono3.50M805ms4.0× (199ms)18× (46ms)24× (34ms)50× (16ms)
253,122subiso8.53M2.29s3.2× (721ms)7.0× (326ms)22× (105ms)38× (60ms)
253,122mono8.53M2.22s4.5× (492ms)19× (115ms)26× (85ms)64× (35ms)
503,122subiso17.2M4.80s3.1× (1.54s)6.4× (755ms)22× (219ms)36× (133ms)
503,122mono17.3M4.59s4.4× (1.05s)19× (245ms)29× (159ms)66× (69ms)
1,013,122subiso34.7M10.08s2.8× (3.58s)5.9× (1.71s)18× (573ms)29× (351ms)
1,013,122mono34.7M9.66s4.5× (2.15s)17× (579ms)26× (368ms)62× (156ms)

iso is not tabulated: on this corpus it reduces to microsecond-scale pattern-vs-pattern checks (GRW 2–3× ahead) — too small to time against parallel variants. Counting a million-node graph's 34.7M mono matches takes 156 ms. Loading it: ~0.3 s for GRW (82 ms load + 203 ms CSR index) vs ~4 s for the vf3 build. Heaviest pattern per tier shown; pattern sizes capped on large graphs (subiso/mono ≤ 3 nodes from 29k up); one machine, single runs. All rows use the mutable store (MGraph); the versioned store reaches the same index at 3.7× build cost and searches at parity — measured below. Full protocol: grw_vf3_tests — clone, ./gen_data.sh, cargo build --release, ./bench_all.sh.

Mutable vs versioned.

GRW ships two stores behind one read trait: MGraph, built with mgraph!, mutates in place; VGraph, built with vgraph! — same DSL, versioned result — is persistent — every modify returns a new version, old versions stay live, structure is shared. The price is paid on writes and raw scans; through the search index the two are indistinguishable — and keeping a version costs 20 ns, flat, at any size. 1M nodes / 2M edges, median of 3:

measurementMGraphVGraphV / M
bulk build602ms10.49s17.4×
1000 small modifies1.7ms23.2ms13.5×
full edge scan1.9ms25.0ms13.2×
index build (CSR)208ms777ms3.7×
search (triangle, mono)1.41s1.45s1.03×
snapshot / keep a version29.8ms20ns~0

Same story at 10k and 100k nodes: search within 4% either way, version retention flat at 20 ns while a deep copy grows linearly (213 µs → 3.1 ms → 29.8 ms). Reproduce: cargo run --release --example graph_perf -- 1000000 3 42 in the grw repo.

The book

Each piece, in depth.

Every feature on this page has a full chapter — syntax tables, edge cases, and runnable examples.

Graph model

One type, three kinds of edge.

A Graph<NV, ER> is parameterized by its node-value type and its edge relation. The relation picks which operators are legal and how many edges can sit between any two nodes.

^

edge::Undir<EV>

One undirected edge between two nodes. Slot: UND.

>>  <<

edge::Dir<EV>

Up to two directed edges — one incoming, one outgoing. Slots: SRC, TGT.

^ >> <<

edge::Anydir<EV>

All three at once — undirected plus both directions. Slots: UND, SRC, TGT.

The theory underneath

Six morphisms, one lattice.

Every get and ban above names one of these. A morphism maps pattern nodes to target nodes while preserving edges; three independent axes decide how strict that mapping is, and they collapse into six named morphisms, ordered from exact to anything-goes. Chapter →

Iso SubIso EpiMono Mono Epi Homo
injective · one-to-one surjective · covers all induced · exact neighborhood
IsoExact match — same shape, same size, same edges.
SubIsoFind this exact shape inside the target.
EpiMonoBijection, but extra edges between matched nodes are OK.
MonoEach pattern node gets a unique target; extra edges OK.
EpiMust cover all target nodes; can collapse pattern nodes.
HomoAnything goes — just preserve edges.

Going up the lattice adds constraints; going down relaxes them. The meet() of two morphisms is the most relaxed one that still satisfies both. Clusters with different morphisms compose under one rule — two nodes may share a target iff they are not both injective — with the nuances drawn out in Mixing Clusters & Morphisms.