kglite 0.16.8

Pure-Rust embedded Cypher knowledge graph engine with in-memory, mmap, and disk storage, and agent-facing schema introspection
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
//! `ForkedGraph` — the writer-side copy-on-write overlay over a shared base.
//!
//! ## What this removes
//!
//! Holding any second `Arc<DirGraph>` — a lazy `ResultView`, a `freeze()`, a
//! `Session`, an open `Transaction` — made the next write deep-copy the entire
//! graph. Measured 2026-08-10 at 1M nodes: **36.3 ms** against a 3.0 µs
//! control, of which the backend row was **37.8 ms of a 41.6 ms**
//! `DirGraph::clone` on a plain graph. This module makes that row O(changes).
//!
//! ## The structural fact that forces this shape
//!
//! The copy-on-write has to be **writer-side**. A reader holds
//! `Arc<DirGraph>` and reads it as `&DirGraph` while the writer wants
//! `&mut DirGraph` to the same allocation — that is aliasing UB, and the only
//! escapes are a lock on the read path (over budget in the MATCH loop) or a
//! read guard that makes writes block on a held Python `ResultView` (a
//! deadlock hazard traded for a latency cliff). So the *reader's* graph is
//! left byte-for-byte untouched and the *writer* builds the delta.
//!
//! ## What the overlay covers, and what it deliberately does not
//!
//! | write | forked behaviour |
//! |---|---|
//! | node weight (`SET`, `REMOVE`, labels, title, id) | copied into `nodes` on first touch, O(1) |
//! | `add_node` (`CREATE`, `MERGE` insert) | appended to `nodes` at a predicted index, O(1) |
//! | column-store writes | the overlay owns its own map, O(types) `Arc` bumps at fork |
//! | **edge weight (`SET r.p`), `add_edge`, `remove_node`, `remove_edge`** | **materialise, then proceed** |
//!
//! The last row is a deliberate scope boundary, not an oversight, and its two
//! halves are out of scope for different reasons.
//!
//! `StableDiGraph` threads adjacency through per-node linked lists, so
//! `add_edge` / `remove_node` / `remove_edge` each rewrite *existing* nodes'
//! adjacency — which an overlay cannot express without reimplementing
//! `edges_directed`, `edges_directed_filtered`, `edges_connecting`,
//! `neighbors_*` and `edge_references` as base⊕overlay chains behind their GAT
//! iterator types.
//!
//! An **edge weight** would need that same rewrite for a different reason:
//! those iterators hand out `&EdgeData` borrowed out of the base, so a weight
//! parked in a delta would be served by `edge_weight`'s point lookup and missed
//! by every iterating read — `WHERE r.p = x` silently filtering on the
//! pre-write value, which is what it did until 2026-08-23
//! (`held_reader::an_edge_property_write_under_a_held_reader_reaches_traversal_reads`).
//! Node weights have no such split: `node_indices` is the only node-level
//! iterator and it yields indices, which every caller resolves through
//! `node_weight`.
//!
//! **Because no edit reaches base adjacency or a base edge weight, the overlay
//! needs no edge chaining at all** — traversal and edge iteration delegate
//! straight to the base, only `node_indices` gains a variant, and the read path
//! is unchanged apart from the node-weight probe.
//!
//! `materialise` is exactly today's cost — a base deep clone plus the overlay
//! replayed — so a topology write while a reader is held is no worse than
//! before this module existed, and everything else is O(changes).
//!
//! ## Slot identity, and why forking is *conditional*
//!
//! Rollback guarantees a node or edge comes back on the exact
//! `NodeIndex`/`EdgeIndex` it vacated (`dir_graph/rollback.rs`), and
//! `NodeIndex` is the key of every index structure on `DirGraph`. So the
//! indices the overlay hands out must be the indices the base will produce when
//! the overlay is folded back in. `StableGraph::add_node` reuses free-list
//! slots and offers no index-controlled insertion, so this holds only when the
//! base's node free list is **empty** — then appends are contiguous from
//! `node_bound()` and a sequential replay reproduces them exactly.
//!
//! [`SlotMirror`](super::slot_mirror) is what makes that checkable
//! ([`can_fork`]), and its `debug_assert` inside `note_node_added` is what
//! proves it: every `add_node` in [`ForkedGraph::apply_overlay`] — the
//! fold-back path — runs through the same seam and re-checks the prediction. A
//! base that cannot be forked cheaply falls back to the deep clone, which is
//! slower and never wrong — the same fail-safe direction
//! `rollback::journal_covers` takes.

use std::collections::HashMap;
use std::sync::Arc;

use petgraph::graph::{EdgeIndex, NodeIndex};
use petgraph::stable_graph::StableDiGraph;
use petgraph::visit::NodeIndexable;
use rustc_hash::FxHashMap;

use crate::datatypes::Value;
use crate::graph::core::iterators::GraphNodeIndices;
use crate::graph::schema::{EdgeData, InternedKey, NodeData};
use crate::graph::storage::column_store::ColumnStore;
use crate::graph::storage::undo::{ColumnarPreImages, ColumnarWrite, UndoJournal};
use crate::graph::storage::{GraphRead, GraphWrite, MemoryGraph};

/// A writer's copy-on-write view over a base another holder is still reading.
pub struct ForkedGraph {
    /// The reader's graph. **Never mutated while this exists** — that is the
    /// one unforgivable failure mode in this design, because a reader observing
    /// a writer's edit is silent and unrecoverable. The over-specified
    /// fingerprint in `dir_graph::rollback_tests::held_reader` is the
    /// golden-snapshot guard for it.
    base: Arc<MemoryGraph>,
    /// Node weights that diverge from the base: copies taken on first write,
    /// plus every node appended since the fork. Keyed by raw node index.
    nodes: FxHashMap<u32, NodeData>,
    /// How many nodes have been appended past `base.node_bound()`. Appends are
    /// contiguous by construction (see [`can_fork`]), which is what keeps
    /// `node_indices` globally ascending without a merge.
    appended: u32,
    /// The overlay's own column-store map, seeded with one `Arc` bump per type
    /// at fork time. Complete, so reads never chain into the base for it.
    column_stores: FxHashMap<InternedKey, Arc<ColumnStore>>,
    /// Statement-scoped inverse-op buffer. Lives *here*, so every undo entry
    /// reverses through this backend's `GraphWrite` and therefore lands in the
    /// overlay — never in the shared base.
    undo: Option<Box<UndoJournal>>,
    /// Continues the base's mirror. Predictions must stay in step across the
    /// fork or the fold-back would allocate different slots.
    slot_mirror: super::slot_mirror::SlotMirror,
}

/// Whether `base` can be shared behind an overlay rather than deep-copied.
///
/// The condition is exactly the slot-identity precondition from the module
/// doc: the free lists must be provably empty, so appended indices are
/// contiguous from the bounds and a sequential replay reproduces them. A graph
/// that has ever deleted a node or edge and not been vacuumed fails this and
/// keeps the deep clone.
pub(crate) fn can_fork(base: &MemoryGraph) -> bool {
    let node_bound = base.inner().node_bound();
    let edge_bound = petgraph::visit::EdgeIndexable::edge_bound(base.inner());
    base.slot_mirror.predict_next_node(node_bound) == Some(NodeIndex::new(node_bound))
        && base.slot_mirror.predict_next_edge(edge_bound) == Some(EdgeIndex::new(edge_bound))
}

impl ForkedGraph {
    /// Fork `base` — O(types), no node or edge is copied.
    pub(crate) fn new(base: Arc<MemoryGraph>) -> Self {
        let column_stores = base.column_stores.clone();
        let slot_mirror = base.slot_mirror.clone();
        Self {
            base,
            nodes: FxHashMap::default(),
            appended: 0,
            column_stores,
            undo: None,
            slot_mirror,
        }
    }

    /// The first index this overlay appended, i.e. the base's node bound.
    #[inline]
    fn append_floor(&self) -> usize {
        self.base.inner().node_bound()
    }

    /// How many node weights this overlay holds — the only nodes a clone of it
    /// duplicates, and what the `BACKEND_CLONE_NODES` oracle counts for a fork
    /// of a fork.
    #[cfg(test)]
    #[inline]
    pub(crate) fn overlay_node_count(&self) -> usize {
        self.nodes.len()
    }

    /// Replay the overlay into `target`, which must be a graph in the base's
    /// exact pre-fork state.
    ///
    /// **This is the fold-back path, and the slot-identity proof lives here.**
    /// Appended nodes go back through `GraphWrite::add_node`, whose
    /// `SlotMirror::note_node_added` debug-asserts that the index petgraph
    /// allocated is the index the mirror predicted — the same assertion that
    /// ran when the overlay handed the index out. The explicit `assert_eq!`
    /// below is the release-profile half of the same statement: getting a
    /// different index would silently mis-key every `DirGraph` index that
    /// recorded the overlay's number, which is a data-corruption bug rather
    /// than a crash, so it is worth an unconditional check on a path that runs
    /// once per compaction rather than once per write.
    fn apply_overlay(&mut self, target: &mut MemoryGraph) {
        let floor = target.inner().node_bound();
        // Appended nodes first and in index order, so the sequential
        // reallocation reproduces the overlay's contiguous run.
        for offset in 0..self.appended {
            let idx = floor as u32 + offset;
            let data = self
                .nodes
                .remove(&idx)
                .expect("an appended overlay index must carry its node weight");
            let actual = GraphWrite::add_node(target, data);
            assert_eq!(
                actual.index() as u32,
                idx,
                "fold-back allocated node {} where the overlay handed out {idx}; \
                 slot identity is broken and every index keyed on the overlay's \
                 number is now wrong (see storage/forked.rs)",
                actual.index()
            );
        }
        // Then the copy-on-write weights, which are pure overwrites.
        for (idx, data) in self.nodes.drain() {
            if let Some(slot) = target
                .inner_mut()
                .node_weight_mut(NodeIndex::new(idx as usize))
            {
                *slot = data;
            }
        }
        target.column_stores = std::mem::take(&mut self.column_stores);
        target.undo = self.undo.take();
        self.appended = 0;
    }

    /// Fold into the base when this writer is the only holder left, and return
    /// the collapsed backend. `Err(self)` when a reader is still outstanding.
    ///
    /// The reader dropping is exactly what makes `Arc::get_mut` succeed, so the
    /// common "hold a view, write, drop the view, write again" pattern
    /// self-heals on the next write with no timer and no bookkeeping.
    pub(crate) fn try_compact(mut self: Box<Self>) -> Result<MemoryGraph, Box<Self>> {
        if Arc::get_mut(&mut self.base).is_none() {
            return Err(self);
        }
        // Sole owner: take the base out and fold into it in place — no node or
        // edge is copied, which is what makes compaction O(changes), not O(V+E).
        let mut owned = Arc::try_unwrap(std::mem::replace(
            &mut self.base,
            Arc::new(MemoryGraph::new()),
        ))
        .unwrap_or_else(|_| unreachable!("get_mut proved unique ownership"));
        self.apply_overlay(&mut owned);
        Ok(owned)
    }

    /// Deep-copy the base and fold into the copy. Used when a write cannot be
    /// expressed in the overlay while a reader is still holding the base — the
    /// whole-graph copy the overlay exists to avoid, paid only on that write.
    pub(crate) fn materialise(&mut self) -> MemoryGraph {
        #[cfg(test)]
        super::backend::note_nodes_copied(self.base.inner().node_count());
        let mut owned = self.base.deep_clone();
        self.apply_overlay(&mut owned);
        owned
    }

    /// A standalone graph equal to what this overlay reads as, without
    /// disturbing it. For the `Serialize` arm in `backend.rs`, which needs one
    /// concrete `StableDiGraph`.
    pub(crate) fn to_memory_graph(&self) -> MemoryGraph {
        let mut clone = ForkedGraph {
            base: Arc::clone(&self.base),
            nodes: self.nodes.clone(),
            appended: self.appended,
            column_stores: self.column_stores.clone(),
            undo: None,
            slot_mirror: self.slot_mirror.clone(),
        };
        let mut owned = self.base.deep_clone();
        clone.apply_overlay(&mut owned);
        owned
    }

    #[inline]
    pub(crate) fn begin_undo(&mut self) {
        self.undo = Some(Box::new(UndoJournal::new()));
    }

    #[inline]
    pub(crate) fn take_undo(&mut self) -> Option<Box<UndoJournal>> {
        self.undo.take()
    }

    #[inline]
    pub(crate) fn undo_journal_mut(&mut self) -> Option<&mut UndoJournal> {
        self.undo.as_deref_mut()
    }

    /// The overlay's copy of a base node, taken on first write.
    ///
    /// This is the single point where a base node stops being shared, and the
    /// reason the base is never mutated: every `&mut NodeData` this backend
    /// hands out points into `self.nodes`.
    #[inline]
    fn cow_node(&mut self, idx: NodeIndex) -> Option<&mut NodeData> {
        let raw = idx.index() as u32;
        if !self.nodes.contains_key(&raw) {
            let base = self.base.inner().node_weight(idx)?.clone();
            self.nodes.insert(raw, base);
        }
        self.nodes.get_mut(&raw)
    }

    /// Clone `idx`'s current weight into the journal as its pre-statement
    /// state. Reads through [`GraphRead::node_weight`], i.e. overlay-then-base,
    /// so the pre-image is what *this writer* would have read — not what the
    /// base holds, which may already differ if an earlier statement wrote it.
    #[cold]
    fn capture_node_weight(&mut self, idx: NodeIndex) {
        let current = GraphRead::node_weight(self, idx).cloned();
        if let Some(journal) = self.undo.as_deref_mut() {
            journal.note_node_weight(idx, || current);
        }
    }

    /// Journal the pre-image a property write is about to overwrite — the
    /// overlay's counterpart of `impl_heap_pre_image_capture!`. Same two
    /// shapes, same reasoning (see that macro): a row-storage node journals a
    /// `NodeData` clone, a columnar one journals the prior value of each cell
    /// the write names.
    ///
    /// The store read is `self.column_stores`, the overlay's own map, which is
    /// what the write will mutate — so the pre-image is what *this writer*
    /// would read back, not what the shared base holds.
    #[inline]
    fn capture_property_pre_image(&mut self, idx: NodeIndex, write: ColumnarWrite<'_>) {
        if self.undo.is_none() {
            return;
        }
        let Some(nd) = GraphRead::node_weight(self, idx) else {
            return;
        };
        let columnar = nd
            .properties
            .columnar_row_id()
            .map(|row_id| (nd.node_type, row_id));
        match columnar {
            None => self.capture_node_weight(idx),
            Some((type_key, row_id)) => {
                let captured = self
                    .column_stores
                    .get(&type_key)
                    .map(|store| ColumnarPreImages::capture(store, row_id, write));
                if let (Some(captured), Some(journal)) = (captured, self.undo.as_deref_mut()) {
                    captured.record(journal, type_key, row_id);
                }
            }
        }
    }

    /// The row id of a columnar node, or `None` for row storage. Read before
    /// any copy-on-write so a columnar property write — which changes the
    /// store, never the node — does not needlessly copy a `NodeData`.
    #[inline]
    fn columnar_row_of(&self, idx: NodeIndex) -> Option<(InternedKey, u32)> {
        let nd = GraphRead::node_weight(self, idx)?;
        nd.properties
            .columnar_row_id()
            .map(|row_id| (nd.node_type, row_id))
    }

    #[inline]
    pub(crate) fn base_stable_digraph(&self) -> &StableDiGraph<NodeData, EdgeData> {
        self.base.inner()
    }
}

impl Clone for ForkedGraph {
    /// Forking a fork keeps the same base and copies only the delta.
    fn clone(&self) -> Self {
        Self {
            base: Arc::clone(&self.base),
            nodes: self.nodes.clone(),
            appended: self.appended,
            column_stores: self.column_stores.clone(),
            undo: None,
            slot_mirror: self.slot_mirror.clone(),
        }
    }
}

impl std::fmt::Debug for ForkedGraph {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "ForkedGraph {{ base: {} nodes / {} edges, overlay: {} node weights, \
             {} appended }}",
            self.base.inner().node_count(),
            self.base.inner().edge_count(),
            self.nodes.len(),
            self.appended
        )
    }
}

// Node weights are overlay-then-base; adjacency and edge weights are all the
// base's, because no write this backend accepts touches either (module doc).
impl GraphRead for ForkedGraph {
    type NodeIndicesIter<'a> = GraphNodeIndices<'a>;
    type EdgeIndicesIter<'a> = <MemoryGraph as GraphRead>::EdgeIndicesIter<'a>;
    type EdgesIter<'a> = <MemoryGraph as GraphRead>::EdgesIter<'a>;
    type EdgeReferencesIter<'a> = <MemoryGraph as GraphRead>::EdgeReferencesIter<'a>;
    type EdgesConnectingIter<'a> = <MemoryGraph as GraphRead>::EdgesConnectingIter<'a>;
    type NeighborsIter<'a> = <MemoryGraph as GraphRead>::NeighborsIter<'a>;

    #[inline]
    fn node_count(&self) -> usize {
        self.base.inner().node_count() + self.appended as usize
    }

    #[inline]
    fn edge_count(&self) -> usize {
        self.base.inner().edge_count()
    }

    #[inline]
    fn node_bound(&self) -> usize {
        self.append_floor() + self.appended as usize
    }

    /// The base's, unmodified — for the same reason `edge_count` is: no edit
    /// this overlay expresses creates or frees an edge slot (module doc).
    #[inline]
    fn edge_bound(&self) -> usize {
        GraphRead::edge_bound(&*self.base)
    }

    #[inline]
    fn is_memory(&self) -> bool {
        true
    }

    #[inline]
    fn node_weight(&self, idx: NodeIndex) -> Option<&NodeData> {
        match self.nodes.get(&(idx.index() as u32)) {
            Some(data) => Some(data),
            None => self.base.inner().node_weight(idx),
        }
    }

    #[inline]
    fn node_type_of(&self, idx: NodeIndex) -> Option<InternedKey> {
        self.node_weight(idx).map(|n| n.node_type)
    }

    #[inline]
    fn get_node_property(&self, idx: NodeIndex, key: InternedKey) -> Option<Value> {
        self.node_view(idx)?.get_value(key)
    }

    #[inline]
    fn get_node_id(&self, idx: NodeIndex) -> Option<Value> {
        Some(self.node_view(idx)?.id().into_owned())
    }

    #[inline]
    fn get_node_title(&self, idx: NodeIndex) -> Option<Value> {
        Some(self.node_view(idx)?.title().into_owned())
    }

    #[inline]
    fn str_prop_eq(&self, idx: NodeIndex, key: InternedKey, target: &str) -> Option<bool> {
        self.node_view(idx)?.str_prop_eq(key, target)
    }

    // Edge delegation from here down, structure and weights alike: no write
    // this backend accepts reaches base adjacency *or* a base `EdgeData`
    // (module doc), so the base's answer is the whole answer and the iterating
    // reads below agree with `edge_weight`'s point lookup by construction.

    #[inline]
    fn edges_directed_filtered(
        &self,
        idx: NodeIndex,
        dir: petgraph::Direction,
        conn_type_filter: Option<InternedKey>,
    ) -> Self::EdgesIter<'_> {
        GraphRead::edges_directed_filtered(&*self.base, idx, dir, conn_type_filter)
    }

    fn edge_endpoint_keys<'a>(
        &'a self,
    ) -> Box<dyn Iterator<Item = (NodeIndex, NodeIndex, InternedKey)> + 'a> {
        GraphRead::edge_endpoint_keys(&*self.base)
    }

    fn count_edges_grouped_by_peer(
        &self,
        conn_type: InternedKey,
        dir: petgraph::Direction,
        deadline: Option<std::time::Instant>,
    ) -> Result<HashMap<u32, i64>, String> {
        GraphRead::count_edges_grouped_by_peer(&*self.base, conn_type, dir, deadline)
    }

    fn count_edges_filtered(
        &self,
        node: NodeIndex,
        dir: petgraph::Direction,
        conn_type: Option<InternedKey>,
        other_node_type: Option<InternedKey>,
        deadline: Option<std::time::Instant>,
    ) -> Result<usize, String> {
        GraphRead::count_edges_filtered(
            &*self.base,
            node,
            dir,
            conn_type,
            other_node_type,
            deadline,
        )
    }

    #[inline]
    fn column_store(&self, type_key: InternedKey) -> Option<&Arc<ColumnStore>> {
        self.column_stores.get(&type_key)
    }

    fn column_stores_iter(
        &self,
    ) -> Box<dyn Iterator<Item = (InternedKey, &Arc<ColumnStore>)> + '_> {
        Box::new(self.column_stores.iter().map(|(k, v)| (*k, v)))
    }

    /// Base indices then appended ones. Appends are contiguous above every base
    /// index (see [`can_fork`]), so the chain is globally ascending and scan
    /// order — which `type_indices` bucket order and the rollback fidelity
    /// tests both pin — is unchanged.
    #[inline]
    fn node_indices(&self) -> Self::NodeIndicesIter<'_> {
        GraphNodeIndices::Forked {
            base: Box::new(self.base.inner().node_indices()),
            appended: self.append_floor()..self.node_bound(),
        }
    }

    #[inline]
    fn edge_indices(&self) -> Self::EdgeIndicesIter<'_> {
        GraphRead::edge_indices(&*self.base)
    }

    #[inline]
    fn edge_references(&self) -> Self::EdgeReferencesIter<'_> {
        GraphRead::edge_references(&*self.base)
    }

    fn edge_weights<'a>(&'a self) -> Box<dyn Iterator<Item = &'a EdgeData> + 'a> {
        GraphRead::edge_weights(&*self.base)
    }

    #[inline]
    fn edges_directed(&self, idx: NodeIndex, dir: petgraph::Direction) -> Self::EdgesIter<'_> {
        GraphRead::edges_directed(&*self.base, idx, dir)
    }

    #[inline]
    fn edges(&self, idx: NodeIndex) -> Self::EdgesIter<'_> {
        GraphRead::edges(&*self.base, idx)
    }

    #[inline]
    fn edges_connecting(&self, a: NodeIndex, b: NodeIndex) -> Self::EdgesConnectingIter<'_> {
        GraphRead::edges_connecting(&*self.base, a, b)
    }

    #[inline]
    fn edge_weight(&self, idx: EdgeIndex) -> Option<&EdgeData> {
        self.base.inner().edge_weight(idx)
    }

    #[inline]
    fn find_edge(&self, a: NodeIndex, b: NodeIndex) -> Option<EdgeIndex> {
        GraphRead::find_edge(&*self.base, a, b)
    }

    #[inline]
    fn edge_endpoints(&self, idx: EdgeIndex) -> Option<(NodeIndex, NodeIndex)> {
        GraphRead::edge_endpoints(&*self.base, idx)
    }

    #[inline]
    fn neighbors_directed(
        &self,
        idx: NodeIndex,
        dir: petgraph::Direction,
    ) -> Self::NeighborsIter<'_> {
        GraphRead::neighbors_directed(&*self.base, idx, dir)
    }

    #[inline]
    fn neighbors_undirected(&self, idx: NodeIndex) -> Self::NeighborsIter<'_> {
        GraphRead::neighbors_undirected(&*self.base, idx)
    }
}

// Every mutation lands in the overlay. The three that cannot be expressed here
// are intercepted one level up, in `GraphBackend` — the only place that can
// replace a `Forked` with a `Memory`.
impl GraphWrite for ForkedGraph {
    #[inline]
    fn node_weight_mut(&mut self, idx: NodeIndex) -> Option<&mut NodeData> {
        if self.undo.is_some() {
            self.capture_node_weight(idx);
        }
        self.cow_node(idx)
    }

    #[inline]
    fn node_weight_mut_silent(&mut self, idx: NodeIndex) -> Option<&mut NodeData> {
        self.cow_node(idx)
    }

    /// Unreachable for the same reason as the three below, though not for the
    /// same cause: `GraphBackend` materialises before dispatching an edge-weight
    /// write, because an overlay copy of one would be invisible to every
    /// iterating read (see the module doc).
    fn edge_weight_mut(&mut self, _idx: EdgeIndex) -> Option<&mut EdgeData> {
        unreachable!("forked backend must be materialised before edge_weight_mut")
    }

    #[inline]
    fn install_column_store(&mut self, type_key: InternedKey, store: Arc<ColumnStore>) {
        self.column_stores.insert(type_key, store);
    }

    #[inline]
    fn column_store_mut(&mut self, type_key: InternedKey) -> Option<&mut Arc<ColumnStore>> {
        self.column_stores.get_mut(&type_key)
    }

    #[inline]
    fn take_column_store(&mut self, type_key: InternedKey) -> Option<Arc<ColumnStore>> {
        self.column_stores.remove(&type_key)
    }

    #[inline]
    fn clear_column_stores(&mut self) {
        self.column_stores.clear();
    }

    fn set_node_property(&mut self, idx: NodeIndex, key: InternedKey, value: Value) {
        self.capture_property_pre_image(idx, ColumnarWrite::Cell(key));
        // Columnar: the value lives in the store, the node only holds a row id,
        // so nothing about the node diverges and no `NodeData` is copied.
        //
        // `Arc::make_mut` here sees the base's handle as well as the overlay's,
        // so the *first* write per type copies the store — which is exactly
        // right: the reader's base must keep the store it was forked with. It
        // is once per fork, not once per statement: the copy the overlay now
        // owns is uniquely held, so every later write mutates it in place. This
        // is the one place the master legitimately forks, and it is why the
        // write-site assertion in `columnar_write.rs` exempts a forked backend.
        if let Some((type_key, row_id)) = self.columnar_row_of(idx) {
            if let Some(store) = self.column_stores.get_mut(&type_key) {
                Arc::make_mut(store).set(row_id, key, &value, None);
            }
            return;
        }
        if let Some(nd) = self.cow_node(idx) {
            nd.properties.insert(key, value);
        }
    }

    fn set_node_property_if_absent(&mut self, idx: NodeIndex, key: InternedKey, value: Value) {
        if GraphRead::node_has_property(self, idx, key) {
            return;
        }
        GraphWrite::set_node_property(self, idx, key, value);
    }

    /// Titles follow properties: on a columnar node they live in the store's
    /// reserved column, so the overlay writes them there — copying the store
    /// once per type on the first write, exactly as `set_node_property` does,
    /// so the base a reader is holding keeps its own titles.
    fn set_node_title(&mut self, idx: NodeIndex, value: Value) {
        if let Some((type_key, row_id)) = self.columnar_row_of(idx) {
            if self.undo.is_some() {
                let prior = self
                    .column_stores
                    .get(&type_key)
                    .and_then(|store| store.get_title(row_id));
                if let Some(journal) = self.undo.as_deref_mut() {
                    journal.note_columnar_title(type_key, row_id, prior);
                }
            }
            if let Some(store) = self.column_stores.get_mut(&type_key) {
                if Arc::make_mut(store).set_title(row_id, &value) {
                    return;
                }
            }
        }
        if let Some(nd) = self.cow_node(idx) {
            nd.title = value;
        }
    }

    fn remove_node_property(&mut self, idx: NodeIndex, key: InternedKey) -> Option<Value> {
        let previous = GraphRead::get_node_property(self, idx, key);
        self.capture_property_pre_image(idx, ColumnarWrite::Cell(key));
        if let Some((type_key, row_id)) = self.columnar_row_of(idx) {
            if let Some(store) = self.column_stores.get_mut(&type_key) {
                Arc::make_mut(store).set(row_id, key, &Value::Null, None);
            }
            return previous;
        }
        self.cow_node(idx)?.properties.remove(key)
    }

    fn clear_node_property(&mut self, idx: NodeIndex, key: InternedKey) -> Option<Value> {
        GraphWrite::remove_node_property(self, idx, key)
    }

    /// Replace-**all**: every cell present on the row is nulled, then `pairs`
    /// are written — arm for arm what `impl_heap_column_writes!` does, so a
    /// held view cannot turn a caller's replace into an update.
    fn replace_node_properties(&mut self, idx: NodeIndex, pairs: Vec<(InternedKey, Value)>) {
        // Whole-row shape: the pre-image has to span the nulled cells as well
        // as the incoming keys, which is exactly `ReplaceRow`.
        let written: Vec<InternedKey> = pairs.iter().map(|(k, _)| *k).collect();
        self.capture_property_pre_image(idx, ColumnarWrite::ReplaceRow(&written));
        if let Some((type_key, row_id)) = self.columnar_row_of(idx) {
            let Some(store) = self.column_stores.get_mut(&type_key) else {
                return;
            };
            let store = Arc::make_mut(store);
            let existing: Vec<_> = store
                .row_properties(row_id)
                .into_iter()
                .map(|(k, _)| k)
                .collect();
            for key in existing {
                store.set(row_id, key, &Value::Null, None);
            }
            for (key, value) in pairs {
                store.set(row_id, key, &value, None);
            }
            return;
        }
        if let Some(nd) = self.cow_node(idx) {
            nd.properties.replace_all(pairs);
        }
    }

    #[inline]
    fn add_node(&mut self, data: NodeData) -> NodeIndex {
        let node_type = data.node_type;
        let bound_before = self.node_bound();
        let idx = NodeIndex::new(bound_before);
        self.nodes.insert(idx.index() as u32, data);
        self.appended += 1;
        // Keeps the mirror in step with the indices this overlay hands out, so
        // the fold-back's own `add_node` predicts the same run.
        self.slot_mirror.note_node_added(bound_before, idx);
        if let Some(journal) = self.undo.as_deref_mut() {
            journal.note_node_added(idx, node_type);
        }
        idx
    }

    /// Unreachable: `GraphBackend` materialises before dispatching any of the
    /// three adjacency-mutating writes here (see the module doc). The panic is
    /// the assertion that the interception is complete, not a stub — reaching
    /// it would mean a base adjacency edit was about to happen under a live
    /// reader.
    fn remove_node(&mut self, _idx: NodeIndex) -> Option<NodeData> {
        unreachable!("forked backend must be materialised before remove_node")
    }

    fn add_edge(&mut self, _a: NodeIndex, _b: NodeIndex, _data: EdgeData) -> EdgeIndex {
        unreachable!("forked backend must be materialised before add_edge")
    }

    fn remove_edge(&mut self, _idx: EdgeIndex) -> Option<EdgeData> {
        unreachable!("forked backend must be materialised before remove_edge")
    }
}