From 593039f20bb7d9eb3ceca106de273c3663598cc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 01:23:59 +0000 Subject: [PATCH 1/2] Add D-RST-3: a borrowed pattern-CSR view that petgraph traverses experiments/relstore-visit is an isolated crate (own workspace, rust 1.99) that prepares a relationship layout once (u32 offsets, u32 neighbour slots, u64 stable fact ids, u32 source lane, ordered by (src, fact id)) and hands out a Copy view, pinned to the version the buffers were prepared from. The view implements the visit traits petgraph's Bfs and Dfs need, unmodified. The edge id is the fact id; the physical row is reported separately and moves on rebuild. Out-of-domain endpoints, duplicate fact ids, stale versions and malformed BlasGraph parts are refused, never dropped. Tests compare BFS/DFS orders against a DiGraph oracle built from the facts, cover parallel edges, self-loops, isolated nodes and empty relations, and check borrowed-buffer identity and zero allocations for the view and the neighbour walk. The `quack` feature adds the shared-layout falsifier: a Quack-compiled one-hop reads the same dst buffer at the same version, and the store prepares once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VyWZYKycy6MoYDxHRVnV5K --- experiments/relstore-visit/.gitignore | 2 + experiments/relstore-visit/Cargo.toml | 27 + experiments/relstore-visit/README.md | 101 ++++ experiments/relstore-visit/clippy.toml | 2 + .../relstore-visit/examples/relstore_cost.rs | 248 +++++++++ experiments/relstore-visit/src/lib.rs | 504 +++++++++++++++++ experiments/relstore-visit/src/quack_hop.rs | 87 +++ experiments/relstore-visit/tests/visit.rs | 511 ++++++++++++++++++ 8 files changed, 1482 insertions(+) create mode 100644 experiments/relstore-visit/.gitignore create mode 100644 experiments/relstore-visit/Cargo.toml create mode 100644 experiments/relstore-visit/README.md create mode 100644 experiments/relstore-visit/clippy.toml create mode 100644 experiments/relstore-visit/examples/relstore_cost.rs create mode 100644 experiments/relstore-visit/src/lib.rs create mode 100644 experiments/relstore-visit/src/quack_hop.rs create mode 100644 experiments/relstore-visit/tests/visit.rs diff --git a/experiments/relstore-visit/.gitignore b/experiments/relstore-visit/.gitignore new file mode 100644 index 000000000..2c96eb1b6 --- /dev/null +++ b/experiments/relstore-visit/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/experiments/relstore-visit/Cargo.toml b/experiments/relstore-visit/Cargo.toml new file mode 100644 index 000000000..94fed0a89 --- /dev/null +++ b/experiments/relstore-visit/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "petgraph-relstore-visit" +version = "0.0.1" +edition = "2024" +rust-version = "1.99" +publish = false +description = "D-RST-3: a borrowed, version-pinned pattern-CSR view that petgraph's unmodified BFS/DFS traverse" + +# Isolated on purpose, like experiments/lance-fold-hop: building the petgraph +# workspace never pulls in lance-graph. The `quack` feature is the only way the +# substrate enters, and it is off by default. +[workspace] + +[features] +default = [] +# The F2b-shaped falsifier: a Quack compiled one-hop over the SAME prepared +# buffers. Pinned to lance-graph main at the D-RMAP-1 merge (fcd1f60e). +quack = ["dep:lance-graph-quack", "dep:lance-graph-mask-risc"] + +[dependencies] +petgraph = { path = "../../crates/petgraph", default-features = false, features = ["std"] } +fixedbitset = { version = "0.5.7", default-features = false } +lance-graph-quack = { git = "https://github.com/AdaWorldAPI/lance-graph", rev = "fcd1f60ea863da56a18032baf3989b9aae0e8c02", optional = true } +lance-graph-mask-risc = { git = "https://github.com/AdaWorldAPI/lance-graph", rev = "fcd1f60ea863da56a18032baf3989b9aae0e8c02", optional = true } + +[[example]] +name = "relstore_cost" diff --git a/experiments/relstore-visit/README.md b/experiments/relstore-visit/README.md new file mode 100644 index 000000000..48e796770 --- /dev/null +++ b/experiments/relstore-visit/README.md @@ -0,0 +1,101 @@ +# relstore-visit (D-RST-3) + +A borrowed, version-pinned pattern-CSR view that petgraph's own `Bfs` and +`Dfs` traverse unchanged. This is D-RST-3 of the lance-graph plan +`.claude/plans/relstore-quack-petgraph-traversal-v1.md` (#1622): the view only, +before the relationship store (D-RST-1/2) exists. + +It is isolated like `experiments/lance-fold-hop` (its own `[workspace]`), and +it does not use the quarantined `OutgoingHop`. The default build does not +depend on lance-graph. The `quack` feature adds `lance-graph-quack` and +`lance-graph-mask-risc` at lance-graph main `fcd1f60e`. + +```sh +cargo test --release # 9 tests +cargo test --release --features quack # + the shared-layout falsifier +cargo run --release --features quack --example relstore_cost +``` + +## What is here + +| item | role | +|---|---| +| `RelationStore::prepare` | Stands in for the store's layout preparation. Builds `u32` offsets, `u32` neighbour slots, `u64` fact ids and the `u32` source lane, ordered by `(src, fact id)`. Counted by `prepares()`. | +| `prepare_from_blasgraph_parts` | The same layout from BlasGraph `CsrStorage`'s index arrays (`nrows`, `ncols`, `row_ptrs`, `col_indices`), in its vocabulary. There is no values parameter, so the 2 KiB `BitVec`s cannot be passed in. | +| `PreparedRelation::view(version)` | A `PatternCsr<'_>`: three borrowed slices and a version. Refused (`Stale`) for any other version. | +| `PatternCsr` | `GraphBase`, `GraphRef`, `IntoNeighbors`, `Visitable` (`FixedBitSet`), `NodeIndexable`, `NodeCount`, `NodeCompactIndexable`, `Data`, `IntoEdgeReferences`, `IntoEdges`. `NodeId = u32`, `EdgeId = u64` = the fact id. | +| `FactEdge::row()` | The physical row, reported apart from the fact id. | +| `quack_hop::one_hop` (feature `quack`) | `src == s` lowered by Quack, executed by mask-risc over the prepared `src` lane; neighbours read from the prepared `dst` lane at the kept rows. | + +Refusals, never silent drops: `OutOfDomain { fact, src, dst, n_nodes }`, +`DuplicateFact`, `Stale { prepared, asked }`, `TooLarge`, `MalformedCsr`. + +## Tests (`tests/visit.rs`) + +The oracle is a `DiGraph` built from the facts, not from the layout. Each +source's edges are inserted in reverse `(src, fact id)` order, because +`DiGraph` yields neighbours in reverse insertion order; the view and the +oracle must then visit in exactly the same order. + +- BFS and DFS orders and out-edge sets equal the oracle from every start: + a hand fixture (parallel edges, a self-loop, an isolated node, a one-way + edge) and three random relations (checked to contain parallels and loops). +- Empty relations, including zero nodes. +- An out-of-domain endpoint is refused with its values (both endpoints), and + the last in-domain node is accepted. Refused prepares are not counted. +- A stale relation or revision is refused. +- Duplicate fact ids are refused. +- Reversed arrival order rebuilds the identical layout; after compaction + (every third fact kept, new revision) every fact keeps its endpoints while + most rows move. +- The view's buffer pointers equal the prepared buffers; every neighbour + slice lies inside the prepared `dst` buffer; building a view and walking + every neighbour and out-edge list allocate nothing (thread-local counting + allocator). +- BlasGraph index arrays give the same layout as `prepare`; malformed parts + are refused. +- `quack`: for every node, the Quack hop equals the view's neighbours, reads + the same `dst` buffer at the same version, and the store prepared once. + +## Measurements (`examples/relstore_cost.rs`) + +Release, one machine, medians of 7; ranges over three runs. Random relation, +`m` facts over `n` nodes, parallels and loops included. Every BFS is checked +to visit the same node set in all three arms. + +| stage | n 1k, m 10k | n 100k, m 1M | +|---|---|---| +| prepare (sort, offsets, 3 lanes) | 0.66–0.72 ms (6 allocs) | 308–396 ms (6 allocs) | +| view construction | 0.035 µs, 0 allocs | 0.035 µs, 0 allocs | +| neighbour walk, all nodes: view / Csr / DiGraph | 1.9 / 1.7 / 3.1 ns per edge | 2.0 / 1.8–1.9 / 5.5–7.2 ns per edge | +| BFS from 0: view / Csr / DiGraph | 29–47 / 32–48 / 48–64 µs | 6.8–12.9 / 7.2–14.8 / 42–51 ms | +| BFS allocations (view) | 10 | 17 | +| layout bytes | 16.4 B per edge | 16.4 B per edge | +| 64 one-hops: Quack / view | 134–137 µs / 0.3 µs | 14.0–18.2 ms / 0.3 µs | + +What they say: + +- The borrowed view costs the same as petgraph's `Csr` to walk and to + traverse, and allocates only petgraph's traversal state. The first run + showed the view 2.7× faster than `Csr` on BFS; repeat runs put them within + noise of each other, so that number was noise. +- Preparation is the cost: 300–400 ns per edge at 1M, about 50× the `Csr` + arm's build (the `Csr` arm is given pre-sorted, deduplicated pairs, so + this is not like for like). It is an indirect sort plus a second sort for + the duplicate-id check. This is measured, not optimized; per the plan, an + optimization waits for a workload that prepares often. +- The Quack hop is correct and reads the same buffers, but it scans the + whole `src` lane per query (O(E), ~3.5 ns per row) and lowers a program + per query (3 allocations). The view answers in O(degree). The lane is + sorted by `src`, so a range read would close the gap, but Quack cannot + assume that without a sealed ordering witness (D-DIAMOND `Cmp::Range` via + an ordered-lane witness). That is an admission question for D-RST-1/2. + +## Limits + +- BlasGraph is not a dependency (lance-graph core pulls lance, datafusion and + lancedb). Its vocabulary is reused by signature; the parts constructor is a + preparation (`usize` to `u32`), not a borrow. +- `RelationStore` and `RelationVersion` are stand-ins. The real version comes + from the store's seal (`LayoutSeal.built_from`), which does not exist yet. +- Out-edges only. `IntoNeighborsDirected` needs a second (reverse) layout. diff --git a/experiments/relstore-visit/clippy.toml b/experiments/relstore-visit/clippy.toml new file mode 100644 index 000000000..fed7d4476 --- /dev/null +++ b/experiments/relstore-visit/clippy.toml @@ -0,0 +1,2 @@ +# This experiment is rust-version 1.99 (see Cargo.toml), not the fork crates' 1.85. +msrv = "1.99" diff --git a/experiments/relstore-visit/examples/relstore_cost.rs b/experiments/relstore-visit/examples/relstore_cost.rs new file mode 100644 index 000000000..868a5613d --- /dev/null +++ b/experiments/relstore-visit/examples/relstore_cost.rs @@ -0,0 +1,248 @@ +//! Costs of the borrowed pattern-CSR view, each stage measured on its own. +//! +//! Run: `cargo run --release --example relstore_cost` (add `--features quack` +//! for the Quack one-hop arm). One machine, warm caches, medians of REPS runs. +//! Every traversal is checked against the petgraph `DiGraph` arm before it is +//! timed. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::hint::black_box; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use petgraph::csr::Csr; +use petgraph::graph::{DiGraph, NodeIndex}; +use petgraph::visit::{Bfs, IntoNeighbors}; +use petgraph_relstore_visit::{Fact, RelationStore, RelationVersion}; + +struct Counting; +static ALLOCS: AtomicUsize = AtomicUsize::new(0); + +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + ALLOCS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc(l) } + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + unsafe { System.dealloc(p, l) } + } +} + +#[global_allocator] +static GLOBAL: Counting = Counting; + +const REPS: usize = 7; +const V1: RelationVersion = RelationVersion { + relation: 1, + revision: 1, +}; + +fn median(mut v: Vec) -> Duration { + v.sort(); + v[v.len() / 2] +} + +/// Median time and the allocation count of the LAST run. +fn time(mut f: impl FnMut() -> T) -> (Duration, usize, T) { + let mut ts = Vec::with_capacity(REPS); + let mut last = None; + let mut allocs = 0; + for _ in 0..REPS { + let a = ALLOCS.load(Ordering::Relaxed); + let t = Instant::now(); + let v = black_box(f()); + ts.push(t.elapsed()); + allocs = ALLOCS.load(Ordering::Relaxed) - a; + last = Some(v); + } + (median(ts), allocs, last.unwrap()) +} + +fn us(d: Duration) -> f64 { + d.as_secs_f64() * 1e6 +} + +fn facts(n: u32, m: usize, seed: u64) -> Vec { + let mut s = seed; + let mut next = || { + s = s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + (s >> 33) as u32 + }; + (0..m) + .map(|i| Fact { + id: (i as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15), + src: next() % n, + dst: next() % n, + }) + .collect() +} + +fn section(n: u32, m: usize) { + let fs = facts(n, m, 42); + println!("\n## n {n}, m {m}"); + + let mut store = RelationStore::new(); + let (prep, prep_allocs, rel) = time(|| store.prepare(V1, n, &fs).unwrap()); + // Everything below reads this layout; none of it may prepare another. + let prepared = store.prepares(); + let (view_t, view_allocs, v) = time(|| rel.view(V1).unwrap()); + + // Arms built from the same facts. + let (graph_t, _, g) = time(|| { + let mut g = DiGraph::<(), u64>::with_capacity(n as usize, fs.len()); + for _ in 0..n { + g.add_node(()); + } + let mut sorted = fs.clone(); + sorted.sort_by_key(|f| (f.src, std::cmp::Reverse(f.id))); + for f in sorted { + g.add_edge( + NodeIndex::new(f.src as usize), + NodeIndex::new(f.dst as usize), + f.id, + ); + } + g + }); + let mut pairs: Vec<(u32, u32)> = fs.iter().map(|f| (f.src, f.dst)).collect(); + pairs.sort_unstable(); + pairs.dedup(); + let (csr_t, _, csr) = + time(|| Csr::<(), (), petgraph::Directed, u32>::from_sorted_edges(&pairs).unwrap()); + + // Correctness before timing: BFS orders agree with the DiGraph arm. + for s in [0, n / 2, n - 1] { + let mut a = Bfs::new(v, s); + let mut b = Bfs::new(&g, NodeIndex::new(s as usize)); + loop { + let (x, y) = (a.next(v), b.next(&g)); + assert_eq!(x, y.map(|y| y.index() as u32)); + if x.is_none() { + break; + } + } + } + + let walk = |f: &dyn Fn(u32) -> u64| (0..n).map(f).fold(0u64, |a, b| a.wrapping_add(b)); + let (walk_view, walk_view_allocs, s1) = + time(|| walk(&|u| v.neighbors(u).map(u64::from).sum::())); + let (walk_graph, _, s2) = time(|| { + walk(&|u| { + g.neighbors(NodeIndex::new(u as usize)) + .map(|x| x.index() as u64) + .sum::() + }) + }); + let (walk_csr, _, _) = time(|| { + walk(&|u| { + csr.neighbors_slice(u) + .iter() + .map(|&x| u64::from(x)) + .sum::() + }) + }); + assert_eq!(s1, s2); + + let bfs_all = |start: u32| { + let mut b = Bfs::new(v, start); + let mut k = 0u32; + while let Some(x) = b.next(v) { + k = k.wrapping_add(x); + } + k + }; + let (bfs_view, bfs_view_allocs, k_view) = time(|| bfs_all(0)); + let (bfs_graph, _, k_graph) = time(|| { + let mut b = Bfs::new(&g, NodeIndex::new(0)); + let mut k = 0u32; + while let Some(x) = b.next(&g) { + k = k.wrapping_add(x.index() as u32); + } + k + }); + let (bfs_csr, _, k_csr) = time(|| { + let mut b = Bfs::new(&csr, 0u32); + let mut k = 0u32; + while let Some(x) = b.next(&csr) { + k = k.wrapping_add(x); + } + k + }); + + // The three BFS arms visit the same node set (same wrapping sum). + assert_eq!((k_view, k_view), (k_graph, k_csr)); + let per_edge = |d: Duration| d.as_secs_f64() * 1e9 / m as f64; + println!("stage, us, allocs, ns/edge"); + println!( + "prepare (sort + offsets + 3 lanes), {:.1}, {prep_allocs}, {:.2}", + us(prep), + per_edge(prep) + ); + println!("view construction, {:.3}, {view_allocs}, -", us(view_t)); + println!( + "build DiGraph arm, {:.1}, -, {:.2}", + us(graph_t), + per_edge(graph_t) + ); + println!( + "build Csr arm ({} unique pairs of {m}), {:.1}, -, {:.2}", + pairs.len(), + us(csr_t), + per_edge(csr_t) + ); + println!( + "neighbour walk, all nodes: view {:.1} ({walk_view_allocs} allocs) | DiGraph {:.1} | Csr {:.1} [{:.2} / {:.2} / {:.2} ns/edge]", + us(walk_view), + us(walk_graph), + us(walk_csr), + per_edge(walk_view), + per_edge(walk_graph), + per_edge(walk_csr), + ); + println!( + "BFS from 0: view {:.1} ({bfs_view_allocs} allocs) | DiGraph {:.1} | Csr {:.1}", + us(bfs_view), + us(bfs_graph), + us(bfs_csr) + ); + println!( + "layout {} B for {m} edges ({:.1} B/edge)", + rel.bytes(), + rel.bytes() as f64 / m as f64 + ); + + #[cfg(feature = "quack")] + { + use petgraph_relstore_visit::quack_hop::one_hop; + let sources: Vec = (0..64).map(|i| (i * 7919) % n).collect(); + let mut out = Vec::new(); + let (quack, quack_allocs, _) = time(|| { + let mut k = 0usize; + for &s in &sources { + out.clear(); + one_hop(&rel, V1, s, &mut out).unwrap(); + k += out.len(); + } + k + }); + let (view_hop, _, _) = time(|| { + sources + .iter() + .map(|&s| v.neighbors(s).count()) + .sum::() + }); + println!( + "64 one-hop queries: Quack (lower + full src scan + dst read) {:.1} us ({quack_allocs} allocs) | view neighbours {:.2} us", + us(quack), + us(view_hop) + ); + } + assert_eq!(store.prepares(), prepared, "a consumer prepared a second layout"); +} + +fn main() { + println!("relstore-visit cost | release, medians of {REPS}"); + for (n, m) in [(1_000, 10_000), (100_000, 1_000_000)] { + section(n, m); + } +} diff --git a/experiments/relstore-visit/src/lib.rs b/experiments/relstore-visit/src/lib.rs new file mode 100644 index 000000000..f97c64a6e --- /dev/null +++ b/experiments/relstore-visit/src/lib.rs @@ -0,0 +1,504 @@ +//! D-RST-3: a borrowed, version-pinned pattern-CSR view over a prepared +//! relationship layout, traversed by petgraph's own `Bfs`/`Dfs` unchanged. +//! +//! Three pieces, each with one owner: +//! +//! - [`PreparedRelation`] owns the buffers: `u32` row offsets, `u32` +//! neighbour slots, `u64` stable fact ids and the `u32` source lane a +//! compiled scan reads. It stands in for a layout the relationship store +//! prepares; it is built once and counted ([`RelationStore::prepares`]). +//! - [`PatternCsr`] borrows those buffers and implements petgraph's visit +//! traits. It owns nothing, copies nothing, and refuses a version that is +//! not the one the buffers were prepared from. +//! - Nothing here evaluates a query. The view only answers "the neighbours +//! of `u`", which petgraph's existing algorithms compose. +//! +//! A pattern CSR carries structure only. BlasGraph's `CsrStorage` keeps a +//! 2 KiB `BitVec` per entry; this view has no value slot at all, and +//! [`RelationStore::prepare_from_blasgraph_parts`] takes the two index arrays +//! only, so a value buffer cannot be passed to it. +//! +//! The edge id is the stable fact id, never the physical row. A row offset is +//! reported separately by [`FactEdge::row`] and changes when the layout is +//! rebuilt; the fact id does not. + +use std::fmt; + +use fixedbitset::FixedBitSet; +use petgraph::visit::{ + Data, EdgeRef, GraphBase, GraphRef, IntoEdgeReferences, IntoEdges, IntoNeighbors, + NodeCompactIndexable, NodeCount, NodeIndexable, Visitable, +}; + +/// The version a buffer was prepared from: which relation, at which revision. +/// Stands in for the store's `RelationVersion` (D-RST plan §2). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RelationVersion { + pub relation: u32, + pub revision: u64, +} + +/// One relationship fact as the store holds it: a stable id and its endpoints. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Fact { + pub id: u64, + pub src: u32, + pub dst: u32, +} + +/// Why a layout or a view was refused. Every refusal names what it saw. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Refusal { + /// An endpoint outside `0..n_nodes`. Never dropped silently. + OutOfDomain { + fact: u64, + src: u32, + dst: u32, + n_nodes: u32, + }, + /// Two facts with the same id. + DuplicateFact { fact: u64 }, + /// The view was asked for a version the buffers were not prepared from. + Stale { + prepared: RelationVersion, + asked: RelationVersion, + }, + /// More edges or nodes than a `u32` slot can address. + TooLarge { what: &'static str, len: usize }, + /// BlasGraph parts that do not describe a square pattern CSR. + MalformedCsr { reason: &'static str }, +} + +impl fmt::Display for Refusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(self, f) + } +} + +impl std::error::Error for Refusal {} + +/// The prepared layout: CSR offsets and slots sorted by `(src, fact id)`, +/// with the source lane and the fact ids at the same row coordinates. +#[derive(Debug)] +pub struct PreparedRelation { + version: RelationVersion, + n_nodes: u32, + offsets: Vec, + src: Vec, + dst: Vec, + fact: Vec, +} + +impl PreparedRelation { + pub fn version(&self) -> RelationVersion { + self.version + } + + pub fn n_nodes(&self) -> u32 { + self.n_nodes + } + + pub fn n_edges(&self) -> usize { + self.dst.len() + } + + /// The buffer a compiled scan reads for the source column. + pub fn src_lane(&self) -> &[u32] { + &self.src + } + + /// The neighbour slots. The view and a compiled scan both read this. + pub fn dst_lane(&self) -> &[u32] { + &self.dst + } + + pub fn fact_lane(&self) -> &[u64] { + &self.fact + } + + pub fn offsets(&self) -> &[u32] { + &self.offsets + } + + /// Bytes the layout holds (the four buffers' lengths, not capacities). + pub fn bytes(&self) -> usize { + 4 * (self.offsets.len() + self.src.len() + self.dst.len()) + 8 * self.fact.len() + } + + /// A view pinned to `asked`. Refused when the buffers came from another + /// version, so a caller holding an old pin cannot read a new layout. + pub fn view(&self, asked: RelationVersion) -> Result, Refusal> { + if asked != self.version { + return Err(Refusal::Stale { + prepared: self.version, + asked, + }); + } + Ok(PatternCsr { + version: self.version, + offsets: &self.offsets, + dst: &self.dst, + fact: &self.fact, + }) + } +} + +/// Stands in for the relationship store's layout preparation. The counter is +/// how a test proves that a consumer did not rebuild the adjacency. +#[derive(Debug, Default)] +pub struct RelationStore { + prepares: usize, +} + +impl RelationStore { + pub fn new() -> Self { + Self::default() + } + + /// How many layouts this store has prepared. + pub fn prepares(&self) -> usize { + self.prepares + } + + /// Prepare the pattern CSR for `facts` over the node domain `0..n_nodes`. + /// + /// Rows are ordered by `(src, fact id)`, so the layout depends on the + /// facts and not on the order they arrived in: a compaction that reorders + /// the input rebuilds the same layout, and every fact keeps its id. + pub fn prepare( + &mut self, + version: RelationVersion, + n_nodes: u32, + facts: &[Fact], + ) -> Result { + if facts.len() >= u32::MAX as usize { + return Err(Refusal::TooLarge { + what: "edges", + len: facts.len(), + }); + } + for f in facts { + if f.src >= n_nodes || f.dst >= n_nodes { + return Err(Refusal::OutOfDomain { + fact: f.id, + src: f.src, + dst: f.dst, + n_nodes, + }); + } + } + let mut order: Vec = (0..facts.len() as u32).collect(); + order.sort_unstable_by_key(|&i| { + let f = facts[i as usize]; + (f.src, f.id) + }); + let mut ids: Vec = facts.iter().map(|f| f.id).collect(); + ids.sort_unstable(); + if let Some(w) = ids.windows(2).find(|w| w[0] == w[1]) { + return Err(Refusal::DuplicateFact { fact: w[0] }); + } + + let mut offsets = vec![0u32; n_nodes as usize + 1]; + for f in facts { + offsets[f.src as usize + 1] += 1; + } + for i in 0..n_nodes as usize { + offsets[i + 1] += offsets[i]; + } + let mut src = Vec::with_capacity(facts.len()); + let mut dst = Vec::with_capacity(facts.len()); + let mut fact = Vec::with_capacity(facts.len()); + for &i in &order { + let f = facts[i as usize]; + src.push(f.src); + dst.push(f.dst); + fact.push(f.id); + } + self.prepares += 1; + Ok(PreparedRelation { + version, + n_nodes, + offsets, + src, + dst, + fact, + }) + } + + /// Prepare from BlasGraph `CsrStorage`'s index arrays, in its own + /// vocabulary (`nrows`, `ncols`, `row_ptrs`, `col_indices`). + /// + /// The values array is not a parameter: a pattern CSR has no use for the + /// 2 KiB `BitVec`s, and taking them would invite copying them. BlasGraph + /// has no stable fact ids, so the store supplies them, one per entry. + /// The arrays are `usize` there and `u32` here, so this is a preparation + /// (counted), not a borrow. + pub fn prepare_from_blasgraph_parts( + &mut self, + version: RelationVersion, + nrows: usize, + ncols: usize, + row_ptrs: &[usize], + col_indices: &[usize], + fact_ids: &[u64], + ) -> Result { + if nrows != ncols { + return Err(Refusal::MalformedCsr { + reason: "a traversal relation needs one node domain (nrows == ncols)", + }); + } + if nrows >= u32::MAX as usize { + return Err(Refusal::TooLarge { + what: "nodes", + len: nrows, + }); + } + if row_ptrs.len() != nrows + 1 { + return Err(Refusal::MalformedCsr { + reason: "row_ptrs.len() != nrows + 1", + }); + } + if fact_ids.len() != col_indices.len() { + return Err(Refusal::MalformedCsr { + reason: "one fact id per entry", + }); + } + if row_ptrs[0] != 0 + || row_ptrs[nrows] != col_indices.len() + || row_ptrs.windows(2).any(|w| w[0] > w[1]) + { + return Err(Refusal::MalformedCsr { + reason: "row_ptrs must start at 0, be non-decreasing and end at nnz", + }); + } + let mut facts = Vec::with_capacity(col_indices.len()); + for r in 0..nrows { + for k in row_ptrs[r]..row_ptrs[r + 1] { + let c = col_indices[k]; + facts.push(Fact { + id: fact_ids[k], + src: r as u32, + dst: u32::try_from(c).unwrap_or(u32::MAX), + }); + } + } + self.prepare(version, nrows as u32, &facts) + } +} + +/// A borrowed pattern CSR, pinned to one relationship version. +/// +/// `Copy`, so it is its own `GraphRef`: `Bfs::new(view, start)` works on it +/// directly. Holds three slices and a version; constructing one allocates +/// nothing. +#[derive(Clone, Copy, Debug)] +pub struct PatternCsr<'a> { + version: RelationVersion, + offsets: &'a [u32], + dst: &'a [u32], + fact: &'a [u64], +} + +impl<'a> PatternCsr<'a> { + pub fn version(&self) -> RelationVersion { + self.version + } + + pub fn n_nodes(&self) -> u32 { + (self.offsets.len() - 1) as u32 + } + + pub fn n_edges(&self) -> usize { + self.dst.len() + } + + /// The physical rows of `u`'s out-edges. + pub fn rows(&self, u: u32) -> std::ops::Range { + self.offsets[u as usize] as usize..self.offsets[u as usize + 1] as usize + } + + /// The neighbour slots of `u`, as a sub-slice of the prepared buffer. + pub fn neighbour_slice(&self, u: u32) -> &'a [u32] { + &self.dst[self.rows(u)] + } + + /// Start pointers of the borrowed buffers, for identity checks. + pub fn buffer_ptrs(&self) -> (*const u32, *const u32, *const u64) { + (self.offsets.as_ptr(), self.dst.as_ptr(), self.fact.as_ptr()) + } +} + +/// One edge: its endpoints, its stable fact id, and the physical row it +/// occupies in this layout. Only the fact id survives a rebuild. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FactEdge<'a> { + source: u32, + target: u32, + fact: &'a u64, + row: u32, +} + +impl FactEdge<'_> { + /// The physical row. Changes when the layout is rebuilt. + pub fn row(&self) -> u32 { + self.row + } +} + +impl<'a> EdgeRef for FactEdge<'a> { + type NodeId = u32; + type EdgeId = u64; + type Weight = u64; + + fn source(&self) -> u32 { + self.source + } + + fn target(&self) -> u32 { + self.target + } + + /// The weight of a pattern edge is its fact id: there is no other value. + fn weight(&self) -> &u64 { + self.fact + } + + fn id(&self) -> u64 { + *self.fact + } +} + +impl GraphBase for PatternCsr<'_> { + type NodeId = u32; + type EdgeId = u64; +} + +/// The view is `Copy`, so it is its own graph reference. +impl GraphRef for PatternCsr<'_> {} + +impl<'a> IntoNeighbors for PatternCsr<'a> { + type Neighbors = std::iter::Copied>; + + fn neighbors(self, a: u32) -> Self::Neighbors { + self.neighbour_slice(a).iter().copied() + } +} + +impl Visitable for PatternCsr<'_> { + type Map = FixedBitSet; + + fn visit_map(&self) -> FixedBitSet { + FixedBitSet::with_capacity(self.n_nodes() as usize) + } + + fn reset_map(&self, map: &mut FixedBitSet) { + map.clear(); + map.grow(self.n_nodes() as usize); + } +} + +impl NodeIndexable for PatternCsr<'_> { + fn node_bound(&self) -> usize { + self.n_nodes() as usize + } + + fn to_index(&self, a: u32) -> usize { + a as usize + } + + fn from_index(&self, i: usize) -> u32 { + i as u32 + } +} + +impl NodeCount for PatternCsr<'_> { + fn node_count(&self) -> usize { + self.n_nodes() as usize + } +} + +impl NodeCompactIndexable for PatternCsr<'_> {} + +impl Data for PatternCsr<'_> { + type NodeWeight = (); + type EdgeWeight = u64; +} + +/// Every edge in row order. +#[derive(Clone, Debug)] +pub struct AllEdges<'a> { + view: PatternCsr<'a>, + source: u32, + row: usize, +} + +impl<'a> Iterator for AllEdges<'a> { + type Item = FactEdge<'a>; + + fn next(&mut self) -> Option> { + if self.row >= self.view.dst.len() { + return None; + } + while self.view.offsets[self.source as usize + 1] as usize <= self.row { + self.source += 1; + } + let row = self.row; + self.row += 1; + Some(FactEdge { + source: self.source, + target: self.view.dst[row], + fact: &self.view.fact[row], + row: row as u32, + }) + } +} + +/// The out-edges of one node. +#[derive(Clone, Debug)] +pub struct OutEdges<'a> { + view: PatternCsr<'a>, + source: u32, + rows: std::ops::Range, +} + +impl<'a> Iterator for OutEdges<'a> { + type Item = FactEdge<'a>; + + fn next(&mut self) -> Option> { + let row = self.rows.next()?; + Some(FactEdge { + source: self.source, + target: self.view.dst[row], + fact: &self.view.fact[row], + row: row as u32, + }) + } +} + +impl<'a> IntoEdgeReferences for PatternCsr<'a> { + type EdgeRef = FactEdge<'a>; + type EdgeReferences = AllEdges<'a>; + + fn edge_references(self) -> AllEdges<'a> { + AllEdges { + view: self, + source: 0, + row: 0, + } + } +} + +impl<'a> IntoEdges for PatternCsr<'a> { + type Edges = OutEdges<'a>; + + fn edges(self, a: u32) -> OutEdges<'a> { + OutEdges { + view: self, + source: a, + rows: self.rows(a), + } + } +} + +#[cfg(feature = "quack")] +pub mod quack_hop; diff --git a/experiments/relstore-visit/src/quack_hop.rs b/experiments/relstore-visit/src/quack_hop.rs new file mode 100644 index 000000000..d0763d0b6 --- /dev/null +++ b/experiments/relstore-visit/src/quack_hop.rs @@ -0,0 +1,87 @@ +//! The second consumer of the same prepared layout: a Quack-compiled one-hop. +//! +//! `src == s` lowers to one mask-risc program over the `src` lane; the kept +//! rows' neighbours are then read from the `dst` lane at those same rows. +//! Both lanes are borrowed from the [`PreparedRelation`] the petgraph view +//! borrows, so the adjacency is prepared once and read twice. The `dst` read +//! is at the kept rows' own coordinates, not at positions taken from another +//! lane's values, so it is not a gather. + +use lance_graph_mask_risc::{ + ExecError, Foreign, LaneRef, Out, Planes, Scratch, execute_into, words_for, +}; +use lance_graph_quack::{Agg, Cmp, Col, Filter, LowerError, Query, lower}; + +use crate::{PreparedRelation, Refusal, RelationVersion}; + +#[derive(Debug)] +pub enum HopError { + Refused(Refusal), + Lower(LowerError), + Exec(ExecError), +} + +/// What one compiled hop read, so a test can prove which buffers it touched. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HopReport { + pub version: RelationVersion, + pub src_ptr: *const u32, + pub dst_ptr: *const u32, + pub kept_rows: usize, +} + +/// The out-neighbours of `s`, in row order, appended to `out`. +pub fn one_hop( + rel: &PreparedRelation, + asked: RelationVersion, + s: u32, + out: &mut Vec, +) -> Result { + if asked != rel.version() { + return Err(HopError::Refused(Refusal::Stale { + prepared: rel.version(), + asked, + })); + } + let n = rel.n_edges(); + let program = lower(&Query { + filter: Filter::cmp(Col(0), Cmp::EqU32(s)), + agg: Agg::Rows, + }) + .map_err(HopError::Lower)?; + let lanes = [LaneRef::U32(rel.src_lane())]; + let planes = Planes { + n_rows: n, + masks: &[], + lanes: &lanes, + }; + let mut scratch = Scratch::for_program(&program, n).map_err(HopError::Exec)?; + let mut mask = vec![0u64; words_for(n)]; + execute_into( + &program, + &planes, + &Foreign::NONE, + &mut scratch, + Out::Mask(&mut mask), + ) + .map_err(HopError::Exec)?; + let dst = rel.dst_lane(); + let mut kept = 0; + for (w, &word) in mask.iter().enumerate() { + let mut bits = word; + while bits != 0 { + let row = w * 64 + bits.trailing_zeros() as usize; + bits &= bits - 1; + if row < n { + out.push(dst[row]); + kept += 1; + } + } + } + Ok(HopReport { + version: rel.version(), + src_ptr: rel.src_lane().as_ptr(), + dst_ptr: dst.as_ptr(), + kept_rows: kept, + }) +} diff --git a/experiments/relstore-visit/tests/visit.rs b/experiments/relstore-visit/tests/visit.rs new file mode 100644 index 000000000..818ebb8aa --- /dev/null +++ b/experiments/relstore-visit/tests/visit.rs @@ -0,0 +1,511 @@ +//! The borrowed pattern-CSR view against an independent petgraph oracle. +//! +//! The oracle is a `DiGraph` built straight from the facts, never from the +//! prepared layout. `DiGraph` yields a node's neighbours in reverse insertion +//! order, so each source's edges are inserted in reverse `(src, fact id)` +//! order: then petgraph's own `Bfs`/`Dfs` on the oracle and on the view must +//! visit nodes in exactly the same order. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; + +use petgraph::graph::{DiGraph, NodeIndex}; +use petgraph::visit::{Bfs, Dfs, EdgeRef, IntoEdgeReferences, IntoEdges, IntoNeighbors}; +use petgraph_relstore_visit::{Fact, PatternCsr, Refusal, RelationStore, RelationVersion}; + +// ---------------------------------------------------------------- allocations + +struct Counting; + +thread_local! { + static ALLOCS: Cell = const { Cell::new(0) }; +} + +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + ALLOCS.with(|c| c.set(c.get() + 1)); + unsafe { System.alloc(l) } + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + unsafe { System.dealloc(p, l) } + } + unsafe fn realloc(&self, p: *mut u8, l: Layout, n: usize) -> *mut u8 { + ALLOCS.with(|c| c.set(c.get() + 1)); + unsafe { System.realloc(p, l, n) } + } +} + +#[global_allocator] +static GLOBAL: Counting = Counting; + +fn allocs(f: impl FnOnce() -> T) -> (usize, T) { + let before = ALLOCS.with(Cell::get); + let v = f(); + (ALLOCS.with(Cell::get) - before, v) +} + +// ------------------------------------------------------------------ fixtures + +const V1: RelationVersion = RelationVersion { + relation: 7, + revision: 1, +}; + +/// Directed edges, two parallel edges, a self-loop, an isolated node (5) and +/// an edge whose reverse is absent (3 -> 4). +fn small() -> (u32, Vec) { + let f = |id, src, dst| Fact { id, src, dst }; + ( + 6, + vec![ + f(40, 0, 1), + f(10, 0, 1), // parallel to fact 40 + f(30, 0, 2), + f(20, 1, 3), + f(50, 2, 2), // self-loop + f(60, 3, 4), + f(70, 2, 0), + ], + ) +} + +/// `m` facts over `n` nodes from an LCG: parallels and self-loops included. +fn random(n: u32, m: usize, seed: u64) -> Vec { + let mut s = seed; + let mut next = || { + s = s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + (s >> 33) as u32 + }; + (0..m) + .map(|i| { + let src = next() % n; + // Skew: a third of the edges land in the first 16 nodes. + let dst = if next() % 3 == 0 { + next() % 16.min(n) + } else { + next() % n + }; + Fact { + id: 1_000_000 + (i as u64) * 7919 % 1_000_003, + src, + dst, + } + }) + .collect() +} + +fn oracle(n: u32, facts: &[Fact]) -> DiGraph<(), u64> { + let mut g = DiGraph::with_capacity(n as usize, facts.len()); + for _ in 0..n { + g.add_node(()); + } + let mut sorted = facts.to_vec(); + sorted.sort_by_key(|f| (f.src, std::cmp::Reverse(f.id))); + for f in sorted { + g.add_edge( + NodeIndex::new(f.src as usize), + NodeIndex::new(f.dst as usize), + f.id, + ); + } + g +} + +fn bfs_view(v: PatternCsr<'_>, s: u32) -> Vec { + let mut b = Bfs::new(v, s); + let mut out = Vec::new(); + while let Some(x) = b.next(v) { + out.push(x); + } + out +} + +fn dfs_view(v: PatternCsr<'_>, s: u32) -> Vec { + let mut d = Dfs::new(v, s); + let mut out = Vec::new(); + while let Some(x) = d.next(v) { + out.push(x); + } + out +} + +fn bfs_oracle(g: &DiGraph<(), u64>, s: u32) -> Vec { + let mut b = Bfs::new(g, NodeIndex::new(s as usize)); + let mut out = Vec::new(); + while let Some(x) = b.next(g) { + out.push(x.index() as u32); + } + out +} + +fn dfs_oracle(g: &DiGraph<(), u64>, s: u32) -> Vec { + let mut d = Dfs::new(g, NodeIndex::new(s as usize)); + let mut out = Vec::new(); + while let Some(x) = d.next(g) { + out.push(x.index() as u32); + } + out +} + +fn check_all_starts(n: u32, facts: &[Fact]) { + let mut store = RelationStore::new(); + let rel = store.prepare(V1, n, facts).unwrap(); + let v = rel.view(V1).unwrap(); + let g = oracle(n, facts); + for s in 0..n { + assert_eq!(bfs_view(v, s), bfs_oracle(&g, s), "bfs from {s}"); + assert_eq!(dfs_view(v, s), dfs_oracle(&g, s), "dfs from {s}"); + let mut got: Vec<(u32, u64)> = v.edges(s).map(|e| (e.target(), e.id())).collect(); + let mut want: Vec<(u32, u64)> = g + .edges(NodeIndex::new(s as usize)) + .map(|e| (e.target().index() as u32, *e.weight())) + .collect(); + got.sort_unstable(); + want.sort_unstable(); + assert_eq!(got, want, "out-edges of {s}"); + } + assert_eq!(store.prepares(), 1); +} + +// --------------------------------------------------------------------- tests + +#[test] +fn small_relation_traverses_like_the_digraph_oracle() { + let (n, facts) = small(); + check_all_starts(n, &facts); + + let mut store = RelationStore::new(); + let rel = store.prepare(V1, n, &facts).unwrap(); + let v = rel.view(V1).unwrap(); + // Parallel edges are both kept; a source's edges are in fact-id order + // (10 -> 1, 30 -> 2, 40 -> 1), not grouped by target. + assert_eq!(v.neighbour_slice(0), &[1, 2, 1]); + let ids: Vec = v.edges(0).map(|e| e.id()).collect(); + assert_eq!(ids, vec![10, 30, 40]); + // The self-loop is an edge, and BFS visits its node once. + assert!(v.neighbors(2).any(|x| x == 2)); + assert_eq!(bfs_view(v, 2), vec![2, 0, 1, 3, 4]); + // Direction holds: 4 reaches nothing, and the isolated node only itself. + assert_eq!(bfs_view(v, 4), vec![4]); + assert_eq!(bfs_view(v, 5), vec![5]); + assert_eq!(dfs_view(v, 5), vec![5]); + assert_eq!(v.edge_references().count(), facts.len()); +} + +#[test] +fn empty_relations_traverse_to_the_start_only() { + let mut store = RelationStore::new(); + let rel = store.prepare(V1, 4, &[]).unwrap(); + let v = rel.view(V1).unwrap(); + for s in 0..4 { + assert_eq!(bfs_view(v, s), vec![s]); + assert_eq!(dfs_view(v, s), vec![s]); + assert_eq!(v.neighbors(s).count(), 0); + } + assert_eq!(v.edge_references().count(), 0); + // No nodes at all is a valid, empty relation. + let rel0 = store.prepare(V1, 0, &[]).unwrap(); + let v0 = rel0.view(V1).unwrap(); + assert_eq!(v0.n_nodes(), 0); + assert_eq!(v0.edge_references().count(), 0); +} + +#[test] +fn random_relations_traverse_like_the_digraph_oracle() { + for (n, m, seed) in [(64, 300, 1), (500, 3000, 2), (1000, 800, 3)] { + let facts = random(n, m, seed); + // Not degenerate: parallels and self-loops both occur. + let selfs = facts.iter().filter(|f| f.src == f.dst).count(); + let mut pairs: Vec<(u32, u32)> = facts.iter().map(|f| (f.src, f.dst)).collect(); + pairs.sort_unstable(); + let parallels = pairs.windows(2).filter(|w| w[0] == w[1]).count(); + assert!( + selfs > 0 && parallels > 0, + "n {n}: selfs {selfs} parallels {parallels}" + ); + check_all_starts(n, &facts); + } +} + +#[test] +fn an_out_of_domain_endpoint_is_refused_never_dropped() { + let (n, mut facts) = small(); + facts.push(Fact { + id: 99, + src: 1, + dst: n, + }); + let mut store = RelationStore::new(); + assert_eq!( + store.prepare(V1, n, &facts).unwrap_err(), + Refusal::OutOfDomain { + fact: 99, + src: 1, + dst: n, + n_nodes: n + } + ); + facts.last_mut().unwrap().src = n + 3; + facts.last_mut().unwrap().dst = 0; + assert!(matches!( + store.prepare(V1, n, &facts), + Err(Refusal::OutOfDomain { src, .. }) if src == n + 3 + )); + // Silence twin: the last in-domain node is accepted. + facts.last_mut().unwrap().src = n - 1; + assert!(store.prepare(V1, n, &facts).is_ok()); + assert_eq!(store.prepares(), 1, "refused prepares are not counted"); +} + +#[test] +fn a_view_is_refused_for_any_other_version() { + let (n, facts) = small(); + let mut store = RelationStore::new(); + let rel = store.prepare(V1, n, &facts).unwrap(); + for asked in [ + RelationVersion { + relation: 7, + revision: 2, + }, + RelationVersion { + relation: 8, + revision: 1, + }, + ] { + assert_eq!( + rel.view(asked).unwrap_err(), + Refusal::Stale { + prepared: V1, + asked + } + ); + } + assert_eq!(rel.view(V1).unwrap().version(), V1); +} + +#[test] +fn duplicate_fact_ids_are_refused() { + let (n, mut facts) = small(); + facts.push(Fact { + id: 30, + src: 4, + dst: 5, + }); + assert_eq!( + RelationStore::new().prepare(V1, n, &facts).unwrap_err(), + Refusal::DuplicateFact { fact: 30 } + ); +} + +/// The edge id is the fact id. Rows are physical and move when the layout is +/// rebuilt; fact ids and their endpoints do not. +#[test] +fn fact_ids_survive_reordering_and_compaction_rows_do_not() { + let n = 200; + let facts = random(n, 1500, 9); + let mut store = RelationStore::new(); + let a = store.prepare(V1, n, &facts).unwrap(); + + // Same facts, reversed arrival order: the identical layout. + let mut rev = facts.clone(); + rev.reverse(); + let b = store.prepare(V1, n, &rev).unwrap(); + assert_eq!(a.offsets(), b.offsets()); + assert_eq!(a.dst_lane(), b.dst_lane()); + assert_eq!(a.fact_lane(), b.fact_lane()); + + // Compaction: drop every third fact and rebuild at a new revision. + let v2 = RelationVersion { + relation: 7, + revision: 2, + }; + let kept: Vec = facts.iter().copied().step_by(3).collect(); + let c = store.prepare(v2, n, &kept).unwrap(); + let endpoints = |rel: &petgraph_relstore_visit::PreparedRelation, ver| { + let v = rel.view(ver).unwrap(); + v.edge_references() + .map(|e| (e.id(), (e.source(), e.target(), e.row()))) + .collect::>() + }; + let before = endpoints(&a, V1); + let after = endpoints(&c, v2); + let mut moved = 0; + for f in &kept { + let (s0, t0, r0) = before[&f.id]; + let (s1, t1, r1) = after[&f.id]; + assert_eq!((s0, t0), (s1, t1), "fact {} changed endpoints", f.id); + assert_eq!((s1, t1), (f.src, f.dst)); + moved += usize::from(r0 != r1); + } + // Anti-vacuity: the compaction really moved rows. + assert!( + moved * 2 > kept.len(), + "only {moved} of {} rows moved", + kept.len() + ); + assert_eq!(store.prepares(), 3); +} + +/// The view borrows the prepared buffers: same start pointers, neighbour +/// slices inside the prepared `dst` buffer, and no allocation to build a view +/// or to walk every neighbour list. +#[test] +fn the_view_borrows_and_allocates_nothing() { + let n = 500; + let facts = random(n, 3000, 5); + let mut store = RelationStore::new(); + let rel = store.prepare(V1, n, &facts).unwrap(); + + let (a_view, v) = allocs(|| rel.view(V1).unwrap()); + assert_eq!(a_view, 0, "constructing a view allocated"); + let (off, dst, fact) = v.buffer_ptrs(); + assert_eq!(off, rel.offsets().as_ptr()); + assert_eq!(dst, rel.dst_lane().as_ptr()); + assert_eq!(fact, rel.fact_lane().as_ptr()); + + let range = rel.dst_lane().as_ptr_range(); + let mut nonempty = 0; + for u in 0..n { + let s = v.neighbour_slice(u); + if !s.is_empty() { + nonempty += 1; + assert!(range.contains(&s.as_ptr()), "neighbours of {u} not in dst"); + } + } + assert!(nonempty > n / 2); + + let (a_walk, sum) = allocs(|| { + let mut sum = 0u64; + for u in 0..n { + for x in v.neighbors(u) { + sum += u64::from(x); + } + for e in v.edges(u) { + sum ^= e.id(); + } + } + sum + }); + assert_eq!(a_walk, 0, "walking neighbour lists allocated"); + assert_ne!(sum, 0); + + // A traversal allocates only petgraph's own state (visit map + queue), + // and never anything per edge. + let (a_bfs, order) = allocs(|| bfs_view(v, 0)); + assert!(order.len() > 10); + assert!( + a_bfs < 40, + "bfs allocated {a_bfs} times for {} nodes", + order.len() + ); + assert_eq!(store.prepares(), 1); +} + +#[test] +fn blasgraph_index_arrays_prepare_the_same_layout() { + // BlasGraph vocabulary: usize row_ptrs / col_indices, no values. + let (n, facts) = small(); + let mut sorted = facts.clone(); + sorted.sort_by_key(|f| (f.src, f.id)); + let mut row_ptrs = vec![0usize; n as usize + 1]; + for f in &sorted { + row_ptrs[f.src as usize + 1] += 1; + } + for i in 0..n as usize { + row_ptrs[i + 1] += row_ptrs[i]; + } + let col: Vec = sorted.iter().map(|f| f.dst as usize).collect(); + let ids: Vec = sorted.iter().map(|f| f.id).collect(); + + let mut store = RelationStore::new(); + let a = store.prepare(V1, n, &facts).unwrap(); + let b = store + .prepare_from_blasgraph_parts(V1, n as usize, n as usize, &row_ptrs, &col, &ids) + .unwrap(); + assert_eq!(a.offsets(), b.offsets()); + assert_eq!(a.dst_lane(), b.dst_lane()); + assert_eq!(a.fact_lane(), b.fact_lane()); + + let nu = n as usize; + let mal = |r: Result<_, Refusal>| matches!(r, Err(Refusal::MalformedCsr { .. })); + assert!(mal(store.prepare_from_blasgraph_parts( + V1, + nu, + nu + 1, + &row_ptrs, + &col, + &ids + ))); + assert!(mal(store.prepare_from_blasgraph_parts( + V1, + nu, + nu, + &row_ptrs[1..], + &col, + &ids + ))); + assert!(mal(store.prepare_from_blasgraph_parts( + V1, + nu, + nu, + &row_ptrs, + &col, + &ids[1..] + ))); + let mut bad = row_ptrs.clone(); + bad.swap(1, 2); + if bad.windows(2).any(|w| w[0] > w[1]) { + assert!(mal( + store.prepare_from_blasgraph_parts(V1, nu, nu, &bad, &col, &ids) + )); + } + // A column past the domain is refused by the shared domain check. + let mut wide = col.clone(); + wide[0] = nu; + assert!(matches!( + store.prepare_from_blasgraph_parts(V1, nu, nu, &row_ptrs, &wide, &ids), + Err(Refusal::OutOfDomain { .. }) + )); + assert_eq!(store.prepares(), 2); +} + +/// The F2b-shaped falsifier: a petgraph iterator and a Quack-compiled hop +/// read the same prepared buffers at the same version, and neither rebuilds +/// the adjacency. +#[cfg(feature = "quack")] +#[test] +fn petgraph_and_quack_read_one_prepared_layout() { + use petgraph_relstore_visit::quack_hop::{HopError, one_hop}; + + let n = 300; + let facts = random(n, 2000, 11); + let mut store = RelationStore::new(); + let rel = store.prepare(V1, n, &facts).unwrap(); + let v = rel.view(V1).unwrap(); + + let (mut empty, mut multi) = (0, 0); + let mut got = Vec::new(); + for s in 0..n { + got.clear(); + let rep = one_hop(&rel, V1, s, &mut got).unwrap(); + let want: Vec = v.neighbors(s).collect(); + assert_eq!(got, want, "one hop from {s}"); + assert_eq!(rep.version, v.version()); + assert_eq!(rep.dst_ptr, v.buffer_ptrs().1, "different dst buffer"); + assert_eq!(rep.src_ptr, rel.src_lane().as_ptr()); + assert_eq!(rep.kept_rows, want.len()); + empty += usize::from(want.is_empty()); + multi += usize::from(want.len() >= 2); + } + assert!(empty > 0 && multi > 0, "empty {empty} multi {multi}"); + assert_eq!(store.prepares(), 1, "a consumer rebuilt the adjacency"); + + let v2 = RelationVersion { + relation: 7, + revision: 2, + }; + assert!(matches!( + one_hop(&rel, v2, 0, &mut got), + Err(HopError::Refused(Refusal::Stale { .. })) + )); +} From 3dfc532f5a2d67fffe7dc66d3b88e32409142a1c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 01:25:17 +0000 Subject: [PATCH 2/2] relstore-visit: record the disable runs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VyWZYKycy6MoYDxHRVnV5K --- experiments/relstore-visit/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/experiments/relstore-visit/README.md b/experiments/relstore-visit/README.md index 48e796770..ea5536c70 100644 --- a/experiments/relstore-visit/README.md +++ b/experiments/relstore-visit/README.md @@ -57,6 +57,18 @@ oracle must then visit in exactly the same order. - `quack`: for every node, the Quack hop equals the view's neighbours, reads the same `dst` buffer at the same version, and the store prepared once. +Disable runs (each guard broken alone, its test red, then restored): + +| guard broken | test that failed | +|---|---| +| destination domain check removed | `an_out_of_domain_endpoint_is_refused_never_dropped` | +| stale-version check removed | `a_view_is_refused_for_any_other_version` | +| duplicate-id check removed | `duplicate_fact_ids_are_refused` | +| rows ordered by `src` only (arrival order within a source) | `fact_ids_survive_reordering_and_compaction_rows_do_not` | +| view holds a copy of `dst` | `the_view_borrows_and_allocates_nothing` | +| edge id = physical row | `fact_ids_survive_reordering_and_compaction_rows_do_not` | +| Quack hop reads a copy of `dst` | `petgraph_and_quack_read_one_prepared_layout` | + ## Measurements (`examples/relstore_cost.rs`) Release, one machine, medians of 7; ranges over three runs. Random relation,