Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/lance-fold-hop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
components: rustfmt, clippy
- name: Test direct Mask-RISC hop against petgraph BFS
run: cargo test --manifest-path experiments/lance-fold-hop/Cargo.toml --features lance --verbose
3 changes: 2 additions & 1 deletion experiments/lance-fold-hop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
name = "petgraph-lance-fold-hop"
version = "0.0.1"
edition = "2024"
rust-version = "1.99"
publish = false
description = "Fork-first differential one-hop execution on lance-graph Mask-RISC"

Expand All @@ -15,4 +16,4 @@ lance = ["dep:lance-graph-mask-risc"]

[dependencies]
petgraph = { path = "../../crates/petgraph", default-features = false, features = ["std"] }
lance-graph-mask-risc = { git = "https://github.com/AdaWorldAPI/lance-graph", rev = "b10e46a134c8429b32bafe1d8a463678f8844816", optional = true }
lance-graph-mask-risc = { git = "https://github.com/AdaWorldAPI/lance-graph", rev = "66636da46107edcc3280237db088172f652cacd5", optional = true }
19 changes: 14 additions & 5 deletions experiments/lance-fold-hop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ Run:
cargo test --manifest-path experiments/lance-fold-hop/Cargo.toml --features lance
```

The optional dependency is pinned to a specific `lance-graph` git revision.
The standalone probe directly passes borrowed SoA edge columns and resident
snapshot masks to `lance-graph-mask-risc`:
The optional dependency is pinned to the specific `lance-graph` commit that introduces
`lance-graph-mask-risc::OutgoingHop`. The petgraph-side lowering has now been
replaced with a small wrapper over that upstream kernel.
The upstream kernel directly passes borrowed SoA edge columns and resident
snapshot masks into `lance-graph-mask-risc`:

1. `Gather(source, frontier)` gated by edge presence;
2. `Gather(target, visible)` gated by the surviving edges;
Expand All @@ -21,7 +23,7 @@ snapshot masks to `lance-graph-mask-risc`:
The oracle is petgraph's real `Bfs` advancing one step over a separately
constructed graph **in tests only**. The runtime fold path does not create
a petgraph `Graph`, neighbor vectors, an adjacency list, or a graph-trait
adapter. Six differential/security tests cover multiple mask words, duplicate
adapter. Seven differential/security tests cover multi-seed frontiers, multiple mask words, duplicate
edges, invisible nodes, absent edges, distinct witness versions, unauthorized
seeds, invalid tails, and stale witnesses.

Expand All @@ -36,5 +38,12 @@ continuation primitive. Do not introduce a second interpreter or speculative
`lance-graph-core` yet. Once Gremlin and petgraph both consume the same
primitive, extract the smaller core if it actually reduces complexity.

This branch has **not** been compiled or benchmarked by the authoring agent;
Build with the fork's pinned **Rust/Cargo 1.99.0** toolchain; its optional
experiment declares `rust-version = "1.99"`. The upstream petgraph workspace
still advertises **MSRV 1.85**, since no upstream-compatible API needs a bump.
Do not run blanket `cargo upgrade`: the fork has no tracked root Cargo.lock,
and this experiment deliberately pins the lance-graph commit. Dependency
updates must be reviewed separately from a toolchain upgrade.

This change has **not** been compiled or benchmarked by the authoring agent;
CI and local Rust toolchains are required for validation.
191 changes: 73 additions & 118 deletions experiments/lance-fold-hop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,64 +12,16 @@

#[cfg(feature = "lance")]
pub mod lance {
use lance_graph_mask_risc::{
execute_into, words_for, Foreign, ForeignPlane, LaneRef, MaskOp, Operand,
Out, Planes, Program, Scratch, Terminal,
use lance_graph_mask_risc::{words_for, Scratch};
pub use lance_graph_mask_risc::{
EdgeColumns, GraphSnapshot as Snapshot, HopError, OutgoingHop,
};

/// Edges are not a second graph object: these are borrowed SoA columns.
/// Row IDs are local positions, not canonical thought GUIDs.
#[derive(Clone, Copy)]
pub struct EdgeColumns<'a> {
pub sources: &'a [u32],
pub targets: &'a [u32],
}

/// A selected witness version over one stable row order.
#[derive(Clone, Copy)]
pub struct Snapshot<'a> {
pub witness_version: u64,
pub node_rows: usize,
/// LSB-first, one bit per node. Scope authorization is already applied.
pub visible_nodes: &'a [u64],
/// LSB-first, one bit per edge, selected at this witness version.
pub live_edges: &'a [u64],
}

#[derive(Debug, PartialEq, Eq)]
pub enum HopError {
StaleWitness,
InvalidShape,
InvalidSeed,
InvisibleSeed,
Executor(String),
}

fn contains(mask: &[u64], row: usize) -> bool {
mask.get(row / 64)
.is_some_and(|word| (word & (1u64 << (row % 64))) != 0)
}

fn valid_tail(mask: &[u64], rows: usize) -> bool {
if mask.len() != words_for(rows) {
return false;
}
if rows == 0 || rows % 64 == 0 {
return true;
}
mask.last()
.is_some_and(|word| (word >> (rows % 64)) == 0)
}

/// Execute one scoped, witness-pinned outgoing adjacency fold.
///
/// The only query evaluator is Mask-RISC: an edge-presence-gated Gather
/// from the source frontier, a visibility-gated Gather through target IDs,
/// and ScatterOrU32 into the *demanded* one-hop result plane.
/// Convenience shape for the fork's existing single-seed BFS differential.
///
/// An implementation of repeat/iterative BFS must add an explicit
/// continuation contract upstream rather than quietly reusing the result
/// as an undocumented intermediate.
/// All graph execution and authorization live in lance-graph's reusable
/// OutgoingHop kernel. This fork constructs only the requested seed mask,
/// owns temporary memory, and translates the single-seed error vocabulary.
pub fn one_hop(
edges: EdgeColumns<'_>,
snapshot: Snapshot<'_>,
Expand All @@ -79,75 +31,29 @@ pub mod lance {
if snapshot.witness_version != expected_witness {
return Err(HopError::StaleWitness);
}
if snapshot.node_rows > u32::MAX as usize
|| edges.sources.len() != edges.targets.len()
|| !valid_tail(snapshot.visible_nodes, snapshot.node_rows)
|| !valid_tail(snapshot.live_edges, edges.sources.len())
|| edges.sources.iter().chain(edges.targets.iter()).any(
|&id| id as usize >= snapshot.node_rows
)
{
return Err(HopError::InvalidShape);
}
let kernel = OutgoingHop::new(edges.sources.len(), snapshot.node_rows)?;
if seed >= snapshot.node_rows {
return Err(HopError::InvalidSeed);
}
if !contains(snapshot.visible_nodes, seed) {
if !snapshot.visible_nodes.get(seed / 64).is_some_and(|word| {
(word & (1u64 << (seed % 64))) != 0
}) {
return Err(HopError::InvisibleSeed);
}

let mut frontier = vec![0u64; words_for(snapshot.node_rows)];
let mut frontier = vec![0; words_for(snapshot.node_rows)];
frontier[seed / 64] |= 1u64 << (seed % 64);
let mut targets = vec![0u64; words_for(snapshot.node_rows)];

if edges.sources.is_empty() {
return Ok(targets);
}

let planes_masks = [snapshot.live_edges];
let lanes = [
LaneRef::U32(edges.sources),
LaneRef::U32(edges.targets),
];
let planes = Planes {
n_rows: edges.sources.len(),
masks: &planes_masks,
lanes: &lanes,
};
let foreign_planes = [
ForeignPlane { words: &frontier, rows: snapshot.node_rows },
ForeignPlane { words: snapshot.visible_nodes, rows: snapshot.node_rows },
];
let foreign = Foreign { planes: &foreign_planes, lanes: &[] };
let program = Program::new(
vec![
MaskOp::Gather {
lane: 0,
foreign: 0,
under: Some(Operand::Plane(0)),
dst: 0,
},
MaskOp::Gather {
lane: 1,
foreign: 1,
under: Some(Operand::Scratch(0)),
dst: 1,
},
],
Terminal::ScatterOrU32 {
mask: Operand::Scratch(1),
lane: 1,
out_rows: snapshot.node_rows as u32,
},
);
let mut scratch = Scratch::for_program(&program, edges.sources.len())
.map_err(|error| HopError::Executor(format!("{error:?}")))?;
execute_into(
&program, &planes, &foreign, &mut scratch, Out::Mask(&mut targets),
)
.map_err(|error| HopError::Executor(format!("{error:?}")))?;

Ok(targets)
let mut out = vec![0; words_for(snapshot.node_rows)];
Comment on lines +43 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate snapshot shapes before allocating row masks

For malformed input whose claimed node_rows is large but whose visibility mask contains the selected seed word, these two allocations happen before kernel.run can validate the snapshot masks and edge lanes. A caller can therefore make one_hop reserve up to roughly 1 GiB for a u32-addressable graph and potentially abort on allocation, whereas the removed implementation returned InvalidShape before allocating. Validate the full upstream snapshot/edge shape before constructing frontier and out.

Useful? React with 👍 / 👎.

let mut scratch = Scratch::for_program(kernel.program(), edges.sources.len())
.map_err(HopError::Executor)?;
kernel.run(
edges,
snapshot,
&frontier,
expected_witness,
&mut scratch,
&mut out,
)?;
Ok(out)
}
}

Expand Down Expand Up @@ -297,4 +203,53 @@ mod tests {
snapshot, 0, 1,
), Err(HopError::InvalidShape));
}
#[test]
fn multi_seed_bfs_layer_uses_upstream_kernel_and_reusable_scratch() {
use lance_graph_mask_risc::{OutgoingHop, Scratch};

let edges = [(0, 1), (0, 65), (65, 129), (65, 70)];
let sources: Vec<u32> = edges.iter().map(|e| e.0 as u32).collect();
let targets: Vec<u32> = edges.iter().map(|e| e.1 as u32).collect();
let visible = mask(130, &(0..130).collect::<Vec<_>>());
let live = mask(edges.len(), &(0..edges.len()).collect::<Vec<_>>());
let frontier = mask(130, &[0, 65]);
let kernel = OutgoingHop::new(edges.len(), 130).unwrap();
let mut scratch = Scratch::for_program(kernel.program(), edges.len()).unwrap();
let mut out = vec![u64::MAX; words_for(130)];

let snapshot = Snapshot {
witness_version: 15, node_rows: 130,
visible_nodes: &visible, live_edges: &live,
};
kernel.run(
EdgeColumns { sources: &sources, targets: &targets },
snapshot, &frontier, 15, &mut scratch, &mut out,
).unwrap();
let want: BTreeSet<usize> = [0usize, 65usize].iter()
.flat_map(|&seed| {
petgraph_first_bfs_layer(130, &edges, &live, &visible, seed)
})
.collect();
assert_eq!(ids(&out, 130), want);

// Reuse the same plan and scratch on a second witness. The third
// edge disappeared; stable node ordinals have not changed.
let modified_live = mask(edges.len(), &[0, 1, 3]);
let second = Snapshot {
witness_version: 16, live_edges: &modified_live, ..snapshot
};
kernel.run(
EdgeColumns { sources: &sources, targets: &targets },
second, &frontier, 16, &mut scratch, &mut out,
).unwrap();
let want: BTreeSet<usize> = [0usize, 65usize].iter()
.flat_map(|&seed| {
petgraph_first_bfs_layer(
130, &edges, &modified_live, &visible, seed,
)
})
.collect();
assert_eq!(ids(&out, 130), want);
}

}
8 changes: 8 additions & 0 deletions rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[toolchain]
channel = "1.99.0"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the lint job on nightly

The Lints job in .github/workflows/ci.yml installs nightly but then invokes unqualified cargo through just fmt, just clippy, and cargo doc. The checked-in toolchain file takes precedence over rustup's default toolchain, while dtolnay/rust-toolchain only installs and sets nightly as the default, so this job will select or attempt to install stable 1.99 instead. In particular, stable rustfmt ignores this repository's nightly-only rustfmt.toml options, weakening the formatting check; remove the pin for this job or invoke the commands with +nightly. This precedence is documented in the rustup override documentation.

Useful? React with 👍 / 👎.

components = ["rustfmt", "clippy"]
profile = "minimal"

# This is the fork's tested development/CI toolchain, NOT the minimum
# supported Rust version of upstream petgraph. The latter remains 1.85
# in Cargo.toml until an actual public API needs a newer compiler.
Loading