use std::collections::HashSet;
use super::varlen_named::{self, NameOrId};
use crate::engine::graph::csr::{CsrIndex, GraphOverlayDelta};
use crate::engine::graph::edge_store::Direction;
pub(super) const MAX_VARLEN_RESULTS: usize = 100_000;
pub(super) const MAX_VARLEN_FRONTIER: usize = 100_000;
#[derive(Debug, Clone, Copy)]
pub struct VarLenCaps {
pub max_results: usize,
pub max_frontier: usize,
}
impl VarLenCaps {
pub fn from_graph_tuning(tuning: &nodedb_types::config::tuning::GraphTuning) -> Self {
Self {
max_results: tuning.varlen_max_results,
max_frontier: tuning.varlen_max_frontier,
}
}
}
impl Default for VarLenCaps {
fn default() -> Self {
Self {
max_results: MAX_VARLEN_RESULTS,
max_frontier: MAX_VARLEN_FRONTIER,
}
}
}
#[derive(Debug, Clone)]
pub(super) struct VarLenCursor {
pub frontier: Vec<(String, String)>,
pub depth: usize,
}
pub(super) struct VarLenExpansion {
pub results: Vec<(u32, String)>,
pub named_results: Vec<(NameOrId, String)>,
pub cursor: Option<VarLenCursor>,
pub boundary: Vec<(String, String, usize)>,
}
pub(super) struct VarLenPattern<'a> {
pub label_filter: Option<&'a str>,
pub direction: Direction,
pub min_hops: usize,
pub max_hops: usize,
pub want_path: bool,
pub collection_filter: CollectionFilter,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(super) enum CollectionFilter {
#[default]
Unscoped,
Only(u32),
Empty,
}
pub(super) fn resolve_collection_filter(
collection: Option<&str>,
csr: &CsrIndex,
) -> CollectionFilter {
match collection {
None => CollectionFilter::Unscoped,
Some(c) => match csr.collection_id(c) {
Some(id) => CollectionFilter::Only(id),
None => CollectionFilter::Empty,
},
}
}
#[inline]
fn node_name_or_empty(csr: &CsrIndex, node: u32, want_path: bool) -> String {
if want_path {
csr.node_name_raw(node).to_string()
} else {
String::new()
}
}
pub(super) fn expand_variable_length(
csr: &CsrIndex,
source: u32,
pattern: &VarLenPattern<'_>,
caps: VarLenCaps,
overlay: Option<&GraphOverlayDelta>,
) -> VarLenExpansion {
let mut results: Vec<(u32, String)> = Vec::new();
if pattern.max_hops == 0 {
if pattern.min_hops == 0 {
results.push((source, node_name_or_empty(csr, source, pattern.want_path)));
}
return VarLenExpansion {
results,
named_results: Vec::new(),
cursor: None,
boundary: Vec::new(),
};
}
if let Some(ov) = overlay
&& !ov.is_empty()
{
return varlen_named::expand_named(csr, source, pattern, caps, ov);
}
let src_name = node_name_or_empty(csr, source, pattern.want_path);
let mut visited: HashSet<u32> = HashSet::new();
visited.insert(source);
if pattern.min_hops == 0 {
results.push((source, src_name.clone()));
}
let frontier: Vec<(u32, String)> = vec![(source, src_name)];
run_bfs(csr, results, visited, frontier, 1, pattern, caps)
}
pub(super) fn resume_variable_length(
csr: &CsrIndex,
cursor: &VarLenCursor,
pattern: &VarLenPattern<'_>,
caps: VarLenCaps,
overlay: Option<&GraphOverlayDelta>,
) -> VarLenExpansion {
if let Some(ov) = overlay
&& !ov.is_empty()
{
return varlen_named::resume_named(csr, cursor, pattern, caps, ov);
}
let mut visited: HashSet<u32> = HashSet::new();
let mut frontier: Vec<(u32, String)> = Vec::with_capacity(cursor.frontier.len());
for (name, path) in &cursor.frontier {
let Some(local_id) = csr.node_id_raw(name) else {
continue;
};
if !visited.insert(local_id) {
continue;
}
frontier.push((local_id, path.clone()));
}
run_bfs(
csr,
Vec::new(),
visited,
frontier,
cursor.depth,
pattern,
caps,
)
}
fn run_bfs(
csr: &CsrIndex,
mut results: Vec<(u32, String)>,
mut visited: HashSet<u32>,
mut frontier: Vec<(u32, String)>,
start_depth: usize,
pattern: &VarLenPattern<'_>,
caps: VarLenCaps,
) -> VarLenExpansion {
let mut cursor: Option<VarLenCursor> = None;
let mut boundary: Vec<(String, String, usize)> = Vec::new();
for depth in start_depth..=pattern.max_hops {
if frontier.is_empty() {
break;
}
let mut next_frontier: Vec<(u32, String)> = Vec::new();
for (node, path) in &frontier {
let neighbors = collect_neighbors(
csr,
*node,
pattern.label_filter,
pattern.direction,
pattern.collection_filter,
);
if neighbors.is_empty() {
boundary.push((csr.node_name_raw(*node).to_string(), path.clone(), depth));
continue;
}
for (_, dst) in neighbors {
if !visited.insert(dst) {
continue;
}
let new_path = if pattern.want_path {
let dst_name = csr.node_name_raw(dst).to_string();
format!("{path}->{dst_name}")
} else {
String::new()
};
if depth >= pattern.min_hops {
results.push((dst, new_path.clone()));
}
if depth < pattern.max_hops {
next_frontier.push((dst, new_path));
}
}
}
let cap_hit = results.len() >= caps.max_results || next_frontier.len() >= caps.max_frontier;
if cap_hit {
if depth < pattern.max_hops && !next_frontier.is_empty() {
let named_frontier: Vec<(String, String)> = next_frontier
.into_iter()
.map(|(local_id, path)| (csr.node_name_raw(local_id).to_string(), path))
.collect();
cursor = Some(VarLenCursor {
frontier: named_frontier,
depth: depth + 1,
});
}
break;
}
frontier = next_frontier;
}
VarLenExpansion {
results,
named_results: Vec::new(),
cursor,
boundary,
}
}
pub(super) fn collect_neighbors(
csr: &CsrIndex,
node: u32,
label_filter: Option<&str>,
direction: Direction,
collection_filter: CollectionFilter,
) -> Vec<(u32, u32)> {
let mut neighbors = Vec::new();
if collection_filter == CollectionFilter::Empty {
return neighbors;
}
let keep = |lid: u32| label_filter.is_none() || csr_label_matches(csr, lid, label_filter);
if matches!(direction, Direction::Out | Direction::Both) {
match collection_filter {
CollectionFilter::Only(cid) => {
for (lid, dst) in csr.iter_out_edges_raw_in(node, cid) {
if keep(lid) {
neighbors.push((lid, dst));
}
}
}
_ => {
for (lid, dst) in csr.iter_out_edges_raw(node) {
if keep(lid) {
neighbors.push((lid, dst));
}
}
}
}
}
if matches!(direction, Direction::In | Direction::Both) {
match collection_filter {
CollectionFilter::Only(cid) => {
for (lid, src) in csr.iter_in_edges_raw_in(node, cid) {
if keep(lid) {
neighbors.push((lid, src));
}
}
}
_ => {
for (lid, src) in csr.iter_in_edges_raw(node) {
if keep(lid) {
neighbors.push((lid, src));
}
}
}
}
}
neighbors
}
fn csr_label_matches(csr: &CsrIndex, label_id: u32, filter: Option<&str>) -> bool {
match filter {
None => true,
Some(f) => csr.label_name(label_id) == f,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::graph::csr::CsrIndex;
use crate::engine::graph::edge_store::Direction;
#[test]
fn variable_length_expansion_dedups_nodes_across_paths() {
let mut csr = CsrIndex::new();
let nodes = ["a", "b", "c", "d", "e", "f"];
for &src in &nodes {
for &dst in &nodes {
if src != dst {
csr.add_edge(src, "l", dst).unwrap();
}
}
}
let expansion = expand_variable_length(
&csr,
csr.node_id_raw("a").unwrap(),
&VarLenPattern {
label_filter: Some("l"),
direction: Direction::Out,
min_hops: 1,
max_hops: 8,
want_path: false,
collection_filter: CollectionFilter::Unscoped,
},
VarLenCaps::default(),
None,
);
let results = expansion.results;
let distinct_dsts: std::collections::HashSet<u32> =
results.iter().map(|(d, _)| *d).collect();
assert!(
distinct_dsts.len() <= nodes.len(),
"distinct dst count must be <= |V| ({}); got {}",
nodes.len(),
distinct_dsts.len()
);
assert!(
results.len() <= nodes.len() * 8,
"variable-length expansion must not allocate b^d paths; \
got {} results on a 6-node graph with max_hops=8 \
(expected ≤ {})",
results.len(),
nodes.len() * 8
);
}
#[test]
fn variable_length_expansion_includes_source_at_zero_hops() {
let mut csr = CsrIndex::new();
csr.add_edge("a", "l", "b").unwrap();
csr.add_edge("b", "l", "c").unwrap();
let expansion = expand_variable_length(
&csr,
csr.node_id_raw("a").unwrap(),
&VarLenPattern {
label_filter: Some("l"),
direction: Direction::Out,
min_hops: 0,
max_hops: 2,
want_path: false,
collection_filter: CollectionFilter::Unscoped,
},
VarLenCaps::default(),
None,
);
let results = expansion.results;
let dsts: std::collections::HashSet<u32> = results.iter().map(|(d, _)| *d).collect();
assert!(
dsts.contains(&csr.node_id_raw("a").unwrap()),
"*0..k must include the source node at depth 0; got dsts {dsts:?}"
);
}
#[test]
fn variable_length_expansion_exact_length_returns_only_that_depth() {
let mut csr = CsrIndex::new();
csr.add_edge("a", "l", "b").unwrap();
csr.add_edge("b", "l", "c").unwrap();
csr.add_edge("c", "l", "d").unwrap();
let expansion = expand_variable_length(
&csr,
csr.node_id_raw("a").unwrap(),
&VarLenPattern {
label_filter: Some("l"),
direction: Direction::Out,
min_hops: 2,
max_hops: 2,
want_path: false,
collection_filter: CollectionFilter::Unscoped,
},
VarLenCaps::default(),
None,
);
let results = expansion.results;
let dsts: std::collections::HashSet<u32> = results.iter().map(|(d, _)| *d).collect();
let c = csr.node_id_raw("c").unwrap();
let expected: std::collections::HashSet<u32> = [c].into_iter().collect();
assert_eq!(
dsts, expected,
"*2..2 must return exactly the depth-2 reachable set {{c}}; got {dsts:?}"
);
}
#[test]
fn variable_length_expansion_caps_frontier_per_hop() {
let mut csr = CsrIndex::new();
const LEAVES: usize = 5_000;
for i in 0..LEAVES {
csr.add_edge("root", "l", &format!("leaf_{i}")).unwrap();
}
let expansion = expand_variable_length(
&csr,
csr.node_id_raw("root").unwrap(),
&VarLenPattern {
label_filter: Some("l"),
direction: Direction::Out,
min_hops: 1,
max_hops: 5,
want_path: false,
collection_filter: CollectionFilter::Unscoped,
},
VarLenCaps::default(),
None,
);
let results = expansion.results;
assert!(
results.len() <= LEAVES,
"star with {LEAVES} leaves must return at most {LEAVES} results; \
got {}",
results.len()
);
}
fn make_chain(len: usize) -> CsrIndex {
let mut csr = CsrIndex::new();
for i in 0..len {
csr.add_edge(&format!("n{i}"), "l", &format!("n{}", i + 1))
.unwrap();
}
csr
}
fn dst_set(results: &[(u32, String)]) -> std::collections::HashSet<u32> {
results.iter().map(|(d, _)| *d).collect()
}
#[test]
fn varlen_resume_union_equals_uncapped_pass() {
let csr = make_chain(6);
let src = csr.node_id_raw("n0").unwrap();
let pat = VarLenPattern {
label_filter: Some("l"),
direction: Direction::Out,
min_hops: 1,
max_hops: 6,
want_path: false,
collection_filter: CollectionFilter::Unscoped,
};
let uncapped = expand_variable_length(&csr, src, &pat, VarLenCaps::default(), None);
assert!(uncapped.cursor.is_none(), "uncapped pass must not truncate");
let full = dst_set(&uncapped.results);
let caps = VarLenCaps {
max_results: 2,
max_frontier: usize::MAX,
};
let first = expand_variable_length(&csr, src, &pat, caps, None);
let cursor = first
.cursor
.clone()
.expect("low cap must produce a resume cursor");
assert!(cursor.depth >= 2, "resume depth advances past the cap");
let mut union: std::collections::HashSet<u32> = dst_set(&first.results);
let mut next = Some(cursor);
while let Some(c) = next {
let resumed = resume_variable_length(&csr, &c, &pat, caps, None);
union.extend(dst_set(&resumed.results));
next = resumed.cursor;
}
assert_eq!(
union, full,
"first-pass ∪ resumed must equal the uncapped destination set"
);
}
#[test]
fn varlen_no_truncation_path_unchanged() {
let csr = make_chain(3); let src = csr.node_id_raw("n0").unwrap();
let expansion = expand_variable_length(
&csr,
src,
&VarLenPattern {
label_filter: Some("l"),
direction: Direction::Out,
min_hops: 1,
max_hops: 3,
want_path: false,
collection_filter: CollectionFilter::Unscoped,
},
VarLenCaps::default(),
None,
);
assert!(
expansion.cursor.is_none(),
"well under the cap → no truncation cursor"
);
let dsts = dst_set(&expansion.results);
let expected: std::collections::HashSet<u32> = ["n1", "n2", "n3"]
.iter()
.map(|n| csr.node_id_raw(n).unwrap())
.collect();
assert_eq!(dsts, expected, "results identical to a normal pass");
}
#[test]
fn varlen_resume_honors_depth_bound() {
let csr = make_chain(3); let src = csr.node_id_raw("n0").unwrap();
let n3 = csr.node_id_raw("n3").unwrap();
let caps = VarLenCaps {
max_results: 1,
max_frontier: usize::MAX,
};
let pat = VarLenPattern {
label_filter: Some("l"),
direction: Direction::Out,
min_hops: 1,
max_hops: 2,
want_path: false,
collection_filter: CollectionFilter::Unscoped,
};
let first = expand_variable_length(&csr, src, &pat, caps, None);
let cursor = first.cursor.clone().expect("cap=1 must truncate");
let resumed = resume_variable_length(&csr, &cursor, &pat, caps, None);
let mut union = dst_set(&first.results);
union.extend(dst_set(&resumed.results));
assert!(
!union.contains(&n3),
"*1..2 must never emit the depth-3 node n3 across the resume boundary; \
got {union:?}"
);
let expected: std::collections::HashSet<u32> = ["n1", "n2"]
.iter()
.map(|n| csr.node_id_raw(n).unwrap())
.collect();
assert_eq!(
union, expected,
"*1..2 resume union must be exactly the depth-1..2 set {{n1,n2}}"
);
}
fn path_set(results: &[(u32, String)]) -> std::collections::HashSet<String> {
results.iter().map(|(_, p)| p.clone()).collect()
}
#[test]
fn varlen_resume_want_path_strings_are_full_paths() {
let csr = make_chain(6);
let src = csr.node_id_raw("n0").unwrap();
let pat = VarLenPattern {
label_filter: Some("l"),
direction: Direction::Out,
min_hops: 1,
max_hops: 6,
want_path: true,
collection_filter: CollectionFilter::Unscoped,
};
let uncapped = expand_variable_length(&csr, src, &pat, VarLenCaps::default(), None);
assert!(uncapped.cursor.is_none(), "uncapped pass must not truncate");
let full_paths = path_set(&uncapped.results);
assert!(
full_paths.contains("n0->n1->n2->n3->n4->n5->n6"),
"uncapped want_path pass must render the full chain path; got {full_paths:?}"
);
let caps = VarLenCaps {
max_results: 2,
max_frontier: usize::MAX,
};
let first = expand_variable_length(&csr, src, &pat, caps, None);
let cursor = first
.cursor
.clone()
.expect("low cap must produce a resume cursor");
let mut union: std::collections::HashSet<String> = path_set(&first.results);
let mut next = Some(cursor);
while let Some(c) = next {
let resumed = resume_variable_length(&csr, &c, &pat, caps, None);
union.extend(path_set(&resumed.results));
next = resumed.cursor;
}
assert_eq!(
union, full_paths,
"first-pass ∪ resumed path strings must equal the uncapped want_path set; \
a truncated suffix on the resumed tail would fail this"
);
}
#[test]
fn varlen_resume_skips_unowned_names_yields_empty() {
let csr = make_chain(6);
let pat = VarLenPattern {
label_filter: Some("l"),
direction: Direction::Out,
min_hops: 1,
max_hops: 6,
want_path: false,
collection_filter: CollectionFilter::Unscoped,
};
let cursor = VarLenCursor {
frontier: vec![
("foreign_a".to_string(), "src->foreign_a".to_string()),
("foreign_b".to_string(), "src->foreign_b".to_string()),
],
depth: 2,
};
let resumed = resume_variable_length(&csr, &cursor, &pat, VarLenCaps::default(), None);
assert!(
resumed.results.is_empty(),
"a core that owns none of the cursor's frontier names must yield no \
rows; got {:?}",
resumed.results
);
assert!(
resumed.cursor.is_none(),
"an empty resume frontier has nothing to truncate"
);
}
#[test]
fn varlen_resume_resolves_owned_names() {
let csr = make_chain(6);
let pat = VarLenPattern {
label_filter: Some("l"),
direction: Direction::Out,
min_hops: 1,
max_hops: 6,
want_path: false,
collection_filter: CollectionFilter::Unscoped,
};
let cursor = VarLenCursor {
frontier: vec![("n1".to_string(), "n0->n1".to_string())],
depth: 2,
};
let resumed = resume_variable_length(&csr, &cursor, &pat, VarLenCaps::default(), None);
let dsts = dst_set(&resumed.results);
let expected: std::collections::HashSet<u32> = ["n2", "n3", "n4", "n5", "n6"]
.iter()
.map(|n| csr.node_id_raw(n).unwrap())
.collect();
assert_eq!(
dsts, expected,
"resuming from owned name n1 must reach {{n2..n6}}; got {dsts:?}"
);
}
}