From f894b74eb93502b2cbb084721d82e282159f6cd3 Mon Sep 17 00:00:00 2001 From: Luiz Spies Date: Fri, 15 May 2026 23:36:02 +0200 Subject: [PATCH 1/2] feat(core,py): kind-filtered bfs and shortest_path traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing k_hop_filtered surface so callers can restrict breadth-first traversal and shortest-path queries to a subset of edge types — needed by downstream graph tools that overlay multiple relationship kinds (calls, imports, defines, tests) on the same node pair and want, for example, "callers of X" to follow only call edges. Core (sqlitegraph-core): - bfs::bfs_neighbors_filtered and bfs::shortest_path_filtered free functions, reusing multi_hop::typed_adjacency (now pub(crate)) so the typed-edge SQL path is shared with k_hop_filtered. Empty allowed_edge_types returns an empty result for bfs (matches k_hop_filtered) and None for shortest_path. - GraphBackend::bfs_filtered and GraphBackend::shortest_path_filtered trait methods plus &B blanket forwarders. - SqliteGraphBackend implements both via the new free functions. - V3Backend stubs both, matching the existing k_hop_filtered delegation; full V3 wiring tracked alongside that TODO. Python (sqlitegraph-py): - bfs(start, depth, edge_types=None, direction=None) — when edge_types is provided, dispatches to bfs_filtered with the given direction; otherwise behavior is unchanged. - shortest_path(start, end, edge_types=None) — optional kwarg dispatches to shortest_path_filtered. - k_hop(start, depth, direction=None, edge_types=None) — optional edge_types dispatches to the existing k_hop_filtered, closing the Python-side gap. Tests: - 8 Rust integration tests in bfs_tests.rs covering single-kind restriction, union over multiple kinds, empty-allowlist semantics, incoming direction, path selection through the allowed kind, excluded-kind paths returning None, and same-node singleton. - 11 Python pytest cases in test_filtered_traversal.py covering each new kwarg plus backwards-compatibility for the old kwargless calls. Co-Authored-By: Claude Opus 4.7 (1M context) --- sqlitegraph-core/src/backend.rs | 36 +++++ .../src/backend/native/v3/backend.rs | 27 ++++ sqlitegraph-core/src/backend/sqlite/impl_.rs | 25 +++- sqlitegraph-core/src/bfs.rs | 93 ++++++++++++- sqlitegraph-core/src/multi_hop.rs | 11 +- sqlitegraph-core/tests/bfs_tests.rs | 128 +++++++++++++++++- sqlitegraph-py/src/lib.rs | 99 ++++++++++++-- .../tests/test_filtered_traversal.py | 113 ++++++++++++++++ 8 files changed, 515 insertions(+), 17 deletions(-) create mode 100644 sqlitegraph-py/tests/test_filtered_traversal.py diff --git a/sqlitegraph-core/src/backend.rs b/sqlitegraph-core/src/backend.rs index fef1ed9a..c6394a50 100644 --- a/sqlitegraph-core/src/backend.rs +++ b/sqlitegraph-core/src/backend.rs @@ -250,6 +250,21 @@ pub trait GraphBackend { direction: BackendDirection, allowed_edge_types: &[&str], ) -> Result, SqliteGraphError>; + fn bfs_filtered( + &self, + snapshot_id: SnapshotId, + start: i64, + depth: u32, + direction: BackendDirection, + allowed_edge_types: &[&str], + ) -> Result, SqliteGraphError>; + fn shortest_path_filtered( + &self, + snapshot_id: SnapshotId, + start: i64, + end: i64, + allowed_edge_types: &[&str], + ) -> Result>, SqliteGraphError>; fn chain_query( &self, snapshot_id: SnapshotId, @@ -606,6 +621,27 @@ where (*self).k_hop_filtered(snapshot_id, start, depth, direction, allowed_edge_types) } + fn bfs_filtered( + &self, + snapshot_id: SnapshotId, + start: i64, + depth: u32, + direction: BackendDirection, + allowed_edge_types: &[&str], + ) -> Result, SqliteGraphError> { + (*self).bfs_filtered(snapshot_id, start, depth, direction, allowed_edge_types) + } + + fn shortest_path_filtered( + &self, + snapshot_id: SnapshotId, + start: i64, + end: i64, + allowed_edge_types: &[&str], + ) -> Result>, SqliteGraphError> { + (*self).shortest_path_filtered(snapshot_id, start, end, allowed_edge_types) + } + fn chain_query( &self, snapshot_id: SnapshotId, diff --git a/sqlitegraph-core/src/backend/native/v3/backend.rs b/sqlitegraph-core/src/backend/native/v3/backend.rs index 4e20966b..c265f5ac 100644 --- a/sqlitegraph-core/src/backend/native/v3/backend.rs +++ b/sqlitegraph-core/src/backend/native/v3/backend.rs @@ -1237,6 +1237,33 @@ impl GraphBackend for V3Backend { self.k_hop(_snapshot_id, _start, _depth, _direction) } + fn bfs_filtered( + &self, + snapshot_id: SnapshotId, + start: i64, + depth: u32, + _direction: BackendDirection, + _allowed_edge_types: &[&str], + ) -> Result, SqliteGraphError> { + // TODO: Implement edge type filtering for V3 backend. + // V3's edge_store exposes `neighbors_filtered`, but typed-edge traversal + // is not yet wired into the BFS path. Tracked alongside `k_hop_filtered`. + // For now, delegate to unfiltered bfs to match the existing stub pattern. + self.bfs(snapshot_id, start, depth) + } + + fn shortest_path_filtered( + &self, + snapshot_id: SnapshotId, + start: i64, + end: i64, + _allowed_edge_types: &[&str], + ) -> Result>, SqliteGraphError> { + // TODO: Implement edge type filtering for V3 backend. + // See note on `bfs_filtered`. + self.shortest_path(snapshot_id, start, end) + } + fn chain_query( &self, _snapshot_id: SnapshotId, diff --git a/sqlitegraph-core/src/backend/sqlite/impl_.rs b/sqlitegraph-core/src/backend/sqlite/impl_.rs index 40311d9b..9619a745 100644 --- a/sqlitegraph-core/src/backend/sqlite/impl_.rs +++ b/sqlitegraph-core/src/backend/sqlite/impl_.rs @@ -10,7 +10,7 @@ use crate::{ SqliteGraphError, backend::sqlite::types::{BackendDirection, EdgeSpec, NeighborQuery, NodeSpec}, backend::{PubSubEvent, SubscriptionFilter}, - bfs::{bfs_neighbors, shortest_path}, + bfs::{bfs_neighbors, bfs_neighbors_filtered, shortest_path, shortest_path_filtered}, graph::{GraphEdge, GraphEntity, SqliteGraph}, multi_hop, pattern::{self, PatternMatch, PatternQuery}, @@ -427,6 +427,29 @@ impl crate::backend::GraphBackend for SqliteGraphBackend { Ok(result) } + fn bfs_filtered( + &self, + snapshot_id: crate::snapshot::SnapshotId, + start: i64, + depth: u32, + direction: BackendDirection, + allowed_edge_types: &[&str], + ) -> Result, SqliteGraphError> { + validate_snapshot_for_sqlite(snapshot_id)?; + bfs_neighbors_filtered(&self.graph, start, depth, allowed_edge_types, direction) + } + + fn shortest_path_filtered( + &self, + snapshot_id: crate::snapshot::SnapshotId, + start: i64, + end: i64, + allowed_edge_types: &[&str], + ) -> Result>, SqliteGraphError> { + validate_snapshot_for_sqlite(snapshot_id)?; + shortest_path_filtered(&self.graph, start, end, allowed_edge_types) + } + fn chain_query( &self, snapshot_id: crate::snapshot::SnapshotId, diff --git a/sqlitegraph-core/src/bfs.rs b/sqlitegraph-core/src/bfs.rs index 1377490d..542feb9d 100644 --- a/sqlitegraph-core/src/bfs.rs +++ b/sqlitegraph-core/src/bfs.rs @@ -2,7 +2,12 @@ use std::collections::VecDeque; use ahash::{AHashMap, AHashSet}; -use crate::{errors::SqliteGraphError, graph::SqliteGraph}; +use crate::{ + backend::BackendDirection, + errors::SqliteGraphError, + graph::SqliteGraph, + multi_hop::{build_lookup, typed_adjacency}, +}; pub fn bfs_neighbors( graph: &SqliteGraph, @@ -29,6 +34,37 @@ pub fn bfs_neighbors( Ok(visited) } +pub fn bfs_neighbors_filtered( + graph: &SqliteGraph, + start: i64, + max_depth: u32, + allowed_edge_types: &[&str], + direction: BackendDirection, +) -> Result, SqliteGraphError> { + graph.get_entity(start)?; + if allowed_edge_types.is_empty() { + return Ok(Vec::new()); + } + let lookup = build_lookup(allowed_edge_types); + let mut visited = Vec::new(); + let mut seen = AHashSet::new(); + let mut queue = VecDeque::new(); + queue.push_back((start, 0)); + seen.insert(start); + while let Some((node, depth)) = queue.pop_front() { + visited.push(node); + if depth >= max_depth { + continue; + } + for next in typed_adjacency(graph, node, direction, Some(&lookup))? { + if seen.insert(next) { + queue.push_back((next, depth + 1)); + } + } + } + Ok(visited) +} + pub fn shortest_path( graph: &SqliteGraph, start: i64, @@ -78,3 +114,58 @@ pub fn shortest_path( path.reverse(); Ok(Some(path)) } + +pub fn shortest_path_filtered( + graph: &SqliteGraph, + start: i64, + end: i64, + allowed_edge_types: &[&str], +) -> Result>, SqliteGraphError> { + graph.get_entity(start)?; + graph.get_entity(end)?; + if start == end { + return Ok(Some(vec![start])); + } + if allowed_edge_types.is_empty() { + return Ok(None); + } + let lookup = build_lookup(allowed_edge_types); + let mut queue = VecDeque::new(); + let mut parents = AHashMap::new(); + let mut seen = AHashSet::new(); + queue.push_back(start); + seen.insert(start); + let mut found = false; + while let Some(node) = queue.pop_front() { + for next in typed_adjacency(graph, node, BackendDirection::Outgoing, Some(&lookup))? { + if seen.insert(next) { + parents.insert(next, node); + if next == end { + found = true; + break; + } + queue.push_back(next); + } + } + if found { + break; + } + } + if !found { + return Ok(None); + } + let mut path = vec![end]; + let mut current = end; + while let Some(&parent) = parents.get(¤t) { + path.push(parent); + if parent == start { + break; + } + current = parent; + } + if path.last().map(|last| *last != start).unwrap_or(true) { + return Ok(None); + } + path.reverse(); + Ok(Some(path)) +} diff --git a/sqlitegraph-core/src/multi_hop.rs b/sqlitegraph-core/src/multi_hop.rs index 6e6c33f7..a1f94170 100644 --- a/sqlitegraph-core/src/multi_hop.rs +++ b/sqlitegraph-core/src/multi_hop.rs @@ -139,6 +139,15 @@ fn adjacency_for( node: i64, direction: BackendDirection, allowed_types: Option<&AHashSet<&str>>, +) -> Result, SqliteGraphError> { + typed_adjacency(graph, node, direction, allowed_types) +} + +pub(crate) fn typed_adjacency( + graph: &SqliteGraph, + node: i64, + direction: BackendDirection, + allowed_types: Option<&AHashSet<&str>>, ) -> Result, SqliteGraphError> { match allowed_types { Some(set) => filter_neighbors(graph, node, direction, set), @@ -183,7 +192,7 @@ fn filter_neighbors( Ok(result) } -fn build_lookup<'a>(types: &'a [&'a str]) -> AHashSet<&'a str> { +pub(crate) fn build_lookup<'a>(types: &'a [&'a str]) -> AHashSet<&'a str> { let mut lookup = AHashSet::with_capacity(types.len()); for ty in types { lookup.insert(*ty); diff --git a/sqlitegraph-core/tests/bfs_tests.rs b/sqlitegraph-core/tests/bfs_tests.rs index 42f22846..593f56e9 100644 --- a/sqlitegraph-core/tests/bfs_tests.rs +++ b/sqlitegraph-core/tests/bfs_tests.rs @@ -1,7 +1,8 @@ use serde_json::json; use sqlitegraph::{ GraphEdge, GraphEntity, SqliteGraph, - bfs::{bfs_neighbors, shortest_path}, + backend::BackendDirection, + bfs::{bfs_neighbors, bfs_neighbors_filtered, shortest_path, shortest_path_filtered}, }; fn build_graph(edges: &[(i64, i64)]) -> SqliteGraph { @@ -32,6 +33,34 @@ fn build_graph(edges: &[(i64, i64)]) -> SqliteGraph { graph } +fn build_typed_graph(edges: &[(i64, i64, &str)]) -> SqliteGraph { + let graph = SqliteGraph::open_in_memory().expect("graph"); + for idx in 0..10 { + let name = format!("node_{idx}"); + graph + .insert_entity(&GraphEntity { + id: 0, + kind: "Node".to_string(), + name, + file_path: None, + data: json!({}), + }) + .unwrap(); + } + for &(from, to, edge_type) in edges { + graph + .insert_edge(&GraphEdge { + id: 0, + from_id: from, + to_id: to, + edge_type: edge_type.to_string(), + data: json!({}), + }) + .unwrap(); + } + graph +} + #[test] fn test_bfs_traversal_single_component() { let edges = vec![(1, 2), (2, 3), (3, 4), (4, 5)]; @@ -98,3 +127,100 @@ fn test_shortest_path_prefers_lexicographic_neighbors() { let path = shortest_path(&graph, 1, 4).expect("shortest"); assert_eq!(path, Some(vec![1, 2, 4])); } + +#[test] +fn test_bfs_neighbors_filtered_restricts_traversal_to_allowed_edge_types() { + let edges = vec![ + (1, 2, "CALL"), + (2, 3, "CALL"), + (1, 4, "IMPORTS"), + (4, 5, "IMPORTS"), + ]; + let graph = build_typed_graph(&edges); + let visited = + bfs_neighbors_filtered(&graph, 1, 10, &["CALL"], BackendDirection::Outgoing).expect("bfs"); + assert_eq!(visited, vec![1, 2, 3]); +} + +#[test] +fn test_bfs_neighbors_filtered_empty_allowed_types_returns_empty() { + let edges = vec![(1, 2, "CALL"), (2, 3, "CALL")]; + let graph = build_typed_graph(&edges); + let visited = + bfs_neighbors_filtered(&graph, 1, 10, &[], BackendDirection::Outgoing).expect("bfs"); + assert!(visited.is_empty()); +} + +#[test] +fn test_bfs_neighbors_filtered_multiple_kinds_unions_neighbors() { + let edges = vec![ + (1, 2, "CALL"), + (1, 3, "IMPORTS"), + (1, 4, "TESTS"), + (2, 5, "CALL"), + ]; + let graph = build_typed_graph(&edges); + let visited = bfs_neighbors_filtered( + &graph, + 1, + 10, + &["CALL", "IMPORTS"], + BackendDirection::Outgoing, + ) + .expect("bfs"); + assert!(visited.contains(&2)); + assert!(visited.contains(&3)); + assert!(visited.contains(&5)); + assert!(!visited.contains(&4)); +} + +#[test] +fn test_bfs_neighbors_filtered_incoming_direction() { + let edges = vec![(1, 5, "CALL"), (2, 5, "CALL"), (3, 5, "IMPORTS")]; + let graph = build_typed_graph(&edges); + let visited = + bfs_neighbors_filtered(&graph, 5, 10, &["CALL"], BackendDirection::Incoming).expect("bfs"); + assert!(visited.contains(&5)); + assert!(visited.contains(&1)); + assert!(visited.contains(&2)); + assert!(!visited.contains(&3)); +} + +#[test] +fn test_shortest_path_filtered_uses_only_allowed_kinds() { + let edges = vec![ + (1, 2, "CALL"), + (2, 3, "CALL"), + (1, 4, "IMPORTS"), + (4, 3, "IMPORTS"), + ]; + let graph = build_typed_graph(&edges); + let path = shortest_path_filtered(&graph, 1, 3, &["CALL"]).expect("shortest"); + assert_eq!(path, Some(vec![1, 2, 3])); + let path_imports = shortest_path_filtered(&graph, 1, 3, &["IMPORTS"]).expect("shortest"); + assert_eq!(path_imports, Some(vec![1, 4, 3])); +} + +#[test] +fn test_shortest_path_filtered_returns_none_when_kind_excludes_only_path() { + let edges = vec![(1, 2, "IMPORTS"), (2, 3, "IMPORTS")]; + let graph = build_typed_graph(&edges); + let path = shortest_path_filtered(&graph, 1, 3, &["CALL"]).expect("shortest"); + assert_eq!(path, None); +} + +#[test] +fn test_shortest_path_filtered_empty_allowed_types_returns_none() { + let edges = vec![(1, 2, "CALL"), (2, 3, "CALL")]; + let graph = build_typed_graph(&edges); + let path = shortest_path_filtered(&graph, 1, 3, &[]).expect("shortest"); + assert_eq!(path, None); +} + +#[test] +fn test_shortest_path_filtered_same_node_returns_singleton() { + let edges = vec![(1, 2, "CALL")]; + let graph = build_typed_graph(&edges); + let path = shortest_path_filtered(&graph, 1, 1, &["CALL"]).expect("shortest"); + assert_eq!(path, Some(vec![1])); +} diff --git a/sqlitegraph-py/src/lib.rs b/sqlitegraph-py/src/lib.rs index 0ec8047c..4c2ecef6 100644 --- a/sqlitegraph-py/src/lib.rs +++ b/sqlitegraph-py/src/lib.rs @@ -260,22 +260,73 @@ impl Graph { } /// BFS traversal from a node. - fn bfs(&self, start: i64, depth: u32) -> PyResult> { - self.backend - .bfs(sqlitegraph::SnapshotId::current(), start, depth) - .map_err(into_pyerr) + /// + /// Args: + /// start: Node ID to traverse from. + /// depth: Maximum hop distance. + /// edge_types: Optional list of edge types to traverse along. If + /// ``None``, every outgoing edge is followed. If provided as an + /// empty list, the result is empty. + /// direction: ``"outgoing"`` (default) or ``"incoming"``. Only meaningful + /// when ``edge_types`` is provided; the unfiltered path is always + /// outgoing. + #[pyo3(signature = (start, depth, edge_types=None, direction=None))] + fn bfs( + &self, + start: i64, + depth: u32, + edge_types: Option>, + direction: Option, + ) -> PyResult> { + let snapshot = sqlitegraph::SnapshotId::current(); + match edge_types { + None => self.backend.bfs(snapshot, start, depth).map_err(into_pyerr), + Some(types) => { + let dir = match direction.as_deref() { + Some("incoming") => sqlitegraph::BackendDirection::Incoming, + _ => sqlitegraph::BackendDirection::Outgoing, + }; + let refs: Vec<&str> = types.iter().map(String::as_str).collect(); + self.backend + .bfs_filtered(snapshot, start, depth, dir, &refs) + .map_err(into_pyerr) + } + } } /// K-hop neighbors. - #[pyo3(signature = (start, depth, direction=None))] - fn k_hop(&self, start: i64, depth: u32, direction: Option) -> PyResult> { + /// + /// Args: + /// start: Starting node ID. + /// depth: Number of hops. + /// direction: ``"outgoing"`` (default) or ``"incoming"``. + /// edge_types: Optional list of edge types to traverse along. When + /// provided as an empty list, the result is empty. + #[pyo3(signature = (start, depth, direction=None, edge_types=None))] + fn k_hop( + &self, + start: i64, + depth: u32, + direction: Option, + edge_types: Option>, + ) -> PyResult> { let dir = match direction.as_deref() { Some("incoming") => sqlitegraph::BackendDirection::Incoming, _ => sqlitegraph::BackendDirection::Outgoing, }; - self.backend - .k_hop(sqlitegraph::SnapshotId::current(), start, depth, dir) - .map_err(into_pyerr) + let snapshot = sqlitegraph::SnapshotId::current(); + match edge_types { + None => self + .backend + .k_hop(snapshot, start, depth, dir) + .map_err(into_pyerr), + Some(types) => { + let refs: Vec<&str> = types.iter().map(String::as_str).collect(); + self.backend + .k_hop_filtered(snapshot, start, depth, dir, &refs) + .map_err(into_pyerr) + } + } } /// Get node degree as ``(in_degree, out_degree)``. @@ -292,11 +343,33 @@ impl Graph { /// Shortest path between two nodes, as a list of node IDs. /// + /// Args: + /// start: Source node ID. + /// end: Destination node ID. + /// edge_types: Optional list of edge types the path may traverse. When + /// provided as an empty list, ``None`` is returned. + /// /// Returns ``None`` if no path exists. - fn shortest_path(&self, start: i64, end: i64) -> PyResult>> { - self.backend - .shortest_path(sqlitegraph::SnapshotId::current(), start, end) - .map_err(into_pyerr) + #[pyo3(signature = (start, end, edge_types=None))] + fn shortest_path( + &self, + start: i64, + end: i64, + edge_types: Option>, + ) -> PyResult>> { + let snapshot = sqlitegraph::SnapshotId::current(); + match edge_types { + None => self + .backend + .shortest_path(snapshot, start, end) + .map_err(into_pyerr), + Some(types) => { + let refs: Vec<&str> = types.iter().map(String::as_str).collect(); + self.backend + .shortest_path_filtered(snapshot, start, end, &refs) + .map_err(into_pyerr) + } + } } /// Fetch an edge by ID as a dict with keys: id, from_id, to_id, edge_type, data. diff --git a/sqlitegraph-py/tests/test_filtered_traversal.py b/sqlitegraph-py/tests/test_filtered_traversal.py new file mode 100644 index 00000000..479cb074 --- /dev/null +++ b/sqlitegraph-py/tests/test_filtered_traversal.py @@ -0,0 +1,113 @@ +"""Tests for kind-filtered graph traversal (bfs, shortest_path, k_hop).""" + +import sqlitegraph + + +def _g(): + return sqlitegraph.Graph.open_in_memory() + + +def _typed_chain(): + g = _g() + a = g.add_node(kind="N", name="a") + b = g.add_node(kind="N", name="b") + c = g.add_node(kind="N", name="c") + d = g.add_node(kind="N", name="d") + g.add_edge(a, b, "CALL") + g.add_edge(b, c, "CALL") + g.add_edge(a, d, "IMPORTS") + g.add_edge(d, c, "IMPORTS") + return g, a, b, c, d + + +def test_bfs_filter_allows_only_specified_edge_type(): + g, a, b, c, d = _typed_chain() + reached = g.bfs(a, depth=10, edge_types=["CALL"]) + assert a in reached + assert b in reached + assert c in reached + assert d not in reached + + +def test_bfs_filter_with_multiple_edge_types_unions_neighbors(): + g, a, b, c, d = _typed_chain() + reached = g.bfs(a, depth=10, edge_types=["CALL", "IMPORTS"]) + assert set(reached) >= {a, b, c, d} + + +def test_bfs_filter_empty_returns_only_start(): + g, a, _b, _c, _d = _typed_chain() + reached = g.bfs(a, depth=10, edge_types=[]) + assert reached == [] + + +def test_bfs_filter_incoming_direction(): + g = _g() + a = g.add_node(kind="N", name="a") + b = g.add_node(kind="N", name="b") + c = g.add_node(kind="N", name="c") + g.add_edge(a, c, "CALL") + g.add_edge(b, c, "IMPORTS") + reached = g.bfs(c, depth=10, edge_types=["CALL"], direction="incoming") + assert a in reached + assert b not in reached + + +def test_bfs_without_filter_still_works(): + """Backward compatibility: bfs() with no kwargs returns unfiltered result.""" + g, a, b, c, d = _typed_chain() + reached = g.bfs(a, depth=10) + assert set(reached) >= {a, b, c, d} + + +def test_shortest_path_filter_picks_path_through_allowed_kind(): + g, a, b, c, d = _typed_chain() + path = g.shortest_path(a, c, edge_types=["CALL"]) + assert path == [a, b, c] + assert d not in path + + path_imports = g.shortest_path(a, c, edge_types=["IMPORTS"]) + assert path_imports == [a, d, c] + assert b not in path_imports + + +def test_shortest_path_filter_returns_none_when_kind_excludes_all_paths(): + g, a, _b, c, _d = _typed_chain() + path = g.shortest_path(a, c, edge_types=["UNKNOWN_KIND"]) + assert path is None + + +def test_shortest_path_filter_empty_returns_none(): + g, a, _b, c, _d = _typed_chain() + path = g.shortest_path(a, c, edge_types=[]) + assert path is None + + +def test_shortest_path_without_filter_still_works(): + """Backward compatibility: shortest_path() with no kwargs returns unfiltered result.""" + g, a, _b, c, _d = _typed_chain() + path = g.shortest_path(a, c) + assert path is not None + assert path[0] == a and path[-1] == c + + +def test_k_hop_filter_excludes_neighbors_of_other_kinds(): + g = _g() + a = g.add_node(kind="N", name="a") + b = g.add_node(kind="N", name="b") + c = g.add_node(kind="N", name="c") + g.add_edge(a, b, "CALL") + g.add_edge(a, c, "IMPORTS") + hops = g.k_hop(a, depth=1, edge_types=["CALL"]) + assert b in hops + assert c not in hops + + +def test_k_hop_without_filter_still_works(): + """Backward compatibility: k_hop() with no edge_types returns unfiltered.""" + g = _g() + a = g.add_node(kind="N", name="a") + b = g.add_node(kind="N", name="b") + g.add_edge(a, b, "CALL") + hops = g.k_hop(a, depth=1) + assert b in hops From d08f2cbf2b9f52ea1a6cd03e14bfb3bd6ba8b86c Mon Sep 17 00:00:00 2001 From: Luiz Spies Date: Fri, 15 May 2026 23:36:43 +0200 Subject: [PATCH 2/2] release: bump versions and changelogs for 2.3.0 / 0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sqlitegraph-core: 2.2.5 → 2.3.0 (new public surface, bfs_filtered/shortest_path_filtered trait methods and free functions, SemVer minor) - sqlitegraph-py: 0.1.1 → 0.2.0 (new edge_types kwargs on bfs, shortest_path, and k_hop; SemVer minor for additive Python surface) Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 15 +++++++++++++++ sqlitegraph-core/Cargo.toml | 2 +- sqlitegraph-py/CHANGELOG.md | 25 +++++++++++++++++++++++++ sqlitegraph-py/Cargo.toml | 2 +- sqlitegraph-py/pyproject.toml | 2 +- 5 files changed, 43 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d17f453..4452f29d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # SQLiteGraph Changelog +## [2.3.0] - 2026-05-15 + +### Added +- **`bfs_filtered` and `shortest_path_filtered`** — Kind-aware variants of `bfs` and `shortest_path`. Both accept `allowed_edge_types: &[&str]` to restrict traversal to specific edge types, mirroring the existing `k_hop_filtered` API. Free functions in `bfs.rs` (`bfs_neighbors_filtered`, `shortest_path_filtered`) plus `GraphBackend` trait methods and `&B` blanket forwarders. + - `bfs_neighbors_filtered(graph, start, max_depth, allowed_edge_types, direction)` — Outgoing or incoming traversal, restricted to allowed edge types. Empty `allowed_edge_types` returns an empty result (parity with `k_hop_filtered`). + - `shortest_path_filtered(graph, start, end, allowed_edge_types)` — BFS shortest path restricted to allowed edge types. Empty `allowed_edge_types` returns `None`. +- **Internal helpers `typed_adjacency` and `build_lookup`** in `multi_hop.rs` are now `pub(crate)` so `bfs.rs` can reuse the typed-edge SQL access path that already backs `k_hop_filtered`. + +### Implementations +- **`SqliteGraphBackend`** — Full implementation of `bfs_filtered` and `shortest_path_filtered` via the new `bfs.rs` free functions. Backed by the existing typed-edge SQL access (`OUTGOING_TYPED_SQL` / `INCOMING_TYPED_SQL`). +- **`V3Backend`** — Stub implementation matching the existing `k_hop_filtered` pattern: delegates to the unfiltered methods until the V3 BFS path is wired to `edge_store.neighbors_filtered`. Tracked alongside the existing `k_hop_filtered` TODO. + +### Tests +- **`bfs_tests.rs`** — 8 new integration tests covering filtered traversal: restriction to a single kind, union over multiple kinds, empty-allowlist returns empty, incoming direction, path selection through the allowed kind, exclusion when the only path uses an excluded kind, empty allowlist returns `None`, and the same-node singleton case. + ## [2.2.5] - 2026-05-15 ### Fixed diff --git a/sqlitegraph-core/Cargo.toml b/sqlitegraph-core/Cargo.toml index c83c84e8..a52a10a2 100644 --- a/sqlitegraph-core/Cargo.toml +++ b/sqlitegraph-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlitegraph" -version = "2.2.5" +version = "2.3.0" edition = "2024" description = "Embedded graph database with full ACID transactions, HNSW vector search, dual backend support, and comprehensive graph algorithms library" license = "GPL-3.0" diff --git a/sqlitegraph-py/CHANGELOG.md b/sqlitegraph-py/CHANGELOG.md index 8091bb75..8d8a8a23 100644 --- a/sqlitegraph-py/CHANGELOG.md +++ b/sqlitegraph-py/CHANGELOG.md @@ -3,6 +3,31 @@ This file tracks releases of the `sqlitegraph` package on PyPI. The Rust crate of the same name has its own changelog at the repository root. +## [0.2.0] - 2026-05-15 + +### Added +- **`bfs(start, depth, edge_types=None, direction=None)`** — `bfs` now accepts + an optional `edge_types` list and `direction` (`"outgoing"` or + `"incoming"`). When `edge_types` is provided, traversal only follows edges + whose type is in the list, dispatching to the new + `GraphBackend::bfs_filtered`. With `edge_types=None`, behavior is unchanged + (outgoing traversal, all edge types). +- **`shortest_path(start, end, edge_types=None)`** — Optional `edge_types` + list restricts which edge types the path can traverse, dispatching to + `GraphBackend::shortest_path_filtered`. Empty list returns `None`. +- **`k_hop(start, depth, direction=None, edge_types=None)`** — The existing + `k_hop` now exposes `edge_types`, dispatching to + `GraphBackend::k_hop_filtered` when provided. Empty list returns an empty + result. +- **11 new pytest tests** in `tests/test_filtered_traversal.py` covering each + new kwarg plus backwards-compatibility checks for the old kwargless calls. + +### Notes +- Built against `sqlitegraph` (Rust) **v2.3.0**, which adds the underlying + `bfs_filtered` / `shortest_path_filtered` trait methods. +- All existing tests continue to pass without modification — the new kwargs + are strictly additive. + ## [0.1.1] - 2026-05-15 ### Fixed diff --git a/sqlitegraph-py/Cargo.toml b/sqlitegraph-py/Cargo.toml index cbf90815..7c86a199 100644 --- a/sqlitegraph-py/Cargo.toml +++ b/sqlitegraph-py/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlitegraph-py" -version = "0.1.1" +version = "0.2.0" edition = "2021" description = "Python bindings for sqlitegraph via PyO3" license = "GPL-3.0-only" diff --git a/sqlitegraph-py/pyproject.toml b/sqlitegraph-py/pyproject.toml index 6b1e12f3..15937808 100644 --- a/sqlitegraph-py/pyproject.toml +++ b/sqlitegraph-py/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "sqlitegraph" -version = "0.1.1" +version = "0.2.0" description = "Embedded graph database with HNSW vector search — Python bindings to the sqlitegraph Rust crate." license = { text = "GPL-3.0-only" } authors = [{ name = "Luiz Spies" }]