mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-08-31 02:05:14 +00:00
fix(namespace-merge): preserve search on degenerate routes
This commit is contained in:
parent
5ca27d2d6f
commit
514164cc8f
6 changed files with 98 additions and 33 deletions
|
|
@ -140,7 +140,7 @@ fn run_variant(
|
|||
recall,
|
||||
avg_ns_searched: avg_ns,
|
||||
avg_dist_ops: avg_ops,
|
||||
memory_kb: (router.memory_bytes() + 1023) / 1024,
|
||||
memory_kb: router.memory_bytes().div_ceil(1024),
|
||||
pass,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,16 +47,16 @@ impl FlowGraph {
|
|||
|
||||
/// BFS: find shortest augmenting path from `s` to `t`.
|
||||
/// Returns (parent array, flow pushed). 0 if no path found.
|
||||
fn bfs(&self, s: usize, t: usize, parent: &mut Vec<usize>) -> i64 {
|
||||
fn bfs(&self, s: usize, t: usize, parent: &mut [usize]) -> i64 {
|
||||
let n = self.n;
|
||||
parent.iter_mut().for_each(|p| *p = usize::MAX);
|
||||
parent[s] = s;
|
||||
let mut queue = VecDeque::new();
|
||||
queue.push_back((s, i64::MAX));
|
||||
while let Some((u, flow)) = queue.pop_front() {
|
||||
for v in 0..n {
|
||||
if parent[v] == usize::MAX && self.cap_at(u, v) > 0 {
|
||||
parent[v] = u;
|
||||
for (v, parent_v) in parent.iter_mut().enumerate().take(n) {
|
||||
if *parent_v == usize::MAX && self.cap_at(u, v) > 0 {
|
||||
*parent_v = u;
|
||||
let new_flow = flow.min(self.cap_at(u, v));
|
||||
if v == t {
|
||||
return new_flow;
|
||||
|
|
@ -98,9 +98,9 @@ impl FlowGraph {
|
|||
visited[s] = true;
|
||||
queue.push_back(s);
|
||||
while let Some(u) = queue.pop_front() {
|
||||
for v in 0..n {
|
||||
if !visited[v] && self.cap_at(u, v) > 0 {
|
||||
visited[v] = true;
|
||||
for (v, is_visited) in visited.iter_mut().enumerate().take(n) {
|
||||
if !*is_visited && self.cap_at(u, v) > 0 {
|
||||
*is_visited = true;
|
||||
queue.push_back(v);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,11 @@
|
|||
//!
|
||||
//! 1. [`AllSearch`] – baseline: scan every namespace unconditionally.
|
||||
//! 2. [`CentroidFilter`] – heuristic: skip namespaces whose centroid cosine
|
||||
//! similarity to the query falls below a threshold.
|
||||
//! similarity to the query falls below a threshold.
|
||||
//! 3. [`MinCutRoute`] – principled: build a flow graph where source→namespace
|
||||
//! capacity = query relevance, namespace→sink capacity =
|
||||
//! query irrelevance, and inter-namespace edges = semantic
|
||||
//! similarity. Find the min S-T cut; search namespaces on
|
||||
//! the source side.
|
||||
//! capacity = query relevance, namespace→sink capacity = query irrelevance,
|
||||
//! and inter-namespace edges = semantic similarity. Find the min S-T cut;
|
||||
//! search namespaces on the source side.
|
||||
//!
|
||||
//! All three implement the [`NamespaceRouter`] trait so they can be swapped
|
||||
//! transparently by benchmark or production code.
|
||||
|
|
|
|||
|
|
@ -188,8 +188,27 @@ impl MinCutRoute {
|
|||
/// Capacities are normalised so the most relevant namespace always has
|
||||
/// S→ns capacity = `scale`, making the cut invariant to the absolute
|
||||
/// magnitude of cosine similarities (which depends on noise and dimension).
|
||||
/// If every namespace has the same affinity, there is no evidence for
|
||||
/// excluding any of them, so routing conservatively selects them all.
|
||||
fn route(&self, q_sim: &[f32]) -> Vec<bool> {
|
||||
let n = self.n_ns;
|
||||
debug_assert_eq!(q_sim.len(), n);
|
||||
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let q_min = q_sim.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
let q_max = q_sim.iter().copied().fold(f32::NEG_INFINITY, f32::max);
|
||||
let range = q_max - q_min;
|
||||
|
||||
// A min-cut cannot make an evidence-based distinction when all
|
||||
// affinities are equal (or invalid). AllSearch is deterministic and
|
||||
// recall-preserving, which is the conservative behavior for this case.
|
||||
if q_sim.iter().any(|value| !value.is_finite()) || range <= 1e-6 {
|
||||
return vec![true; n];
|
||||
}
|
||||
|
||||
// Nodes: 0..n = namespaces, n = source (S), n+1 = sink (T)
|
||||
let s = n;
|
||||
let t = n + 1;
|
||||
|
|
@ -197,12 +216,8 @@ impl MinCutRoute {
|
|||
|
||||
// Normalise q_sim into [0, 1] relative to its observed range so the
|
||||
// most-relevant namespace always receives full S→ns capacity.
|
||||
let q_min = q_sim.iter().cloned().fold(f32::INFINITY, f32::min);
|
||||
let q_max = q_sim.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||
let range = (q_max - q_min).max(1e-6);
|
||||
|
||||
for i in 0..n {
|
||||
let qs = ((q_sim[i] - q_min) / range).clamp(0.0, 1.0);
|
||||
for (i, query_sim) in q_sim.iter().copied().enumerate() {
|
||||
let qs = ((query_sim - q_min) / range).clamp(0.0, 1.0);
|
||||
let s_cap = (qs * self.scale as f32).round() as i64;
|
||||
let t_cap = ((1.0 - qs) * self.scale as f32).round() as i64;
|
||||
g.add_edge(s, i, s_cap);
|
||||
|
|
@ -211,7 +226,7 @@ impl MinCutRoute {
|
|||
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
let sim = self.inter_sim[i * n + j].max(0.0).min(1.0);
|
||||
let sim = self.inter_sim[i * n + j].clamp(0.0, 1.0);
|
||||
let cap = (sim * self.scale as f32).round() as i64;
|
||||
g.add_undirected(i, j, cap);
|
||||
}
|
||||
|
|
@ -220,7 +235,11 @@ impl MinCutRoute {
|
|||
g.max_flow(s, t);
|
||||
let side = g.source_side(s);
|
||||
// Only return namespace nodes (indices 0..n)
|
||||
side[..n].to_vec()
|
||||
let mut namespaces = side[..n].to_vec();
|
||||
if !namespaces.iter().any(|&selected| selected) {
|
||||
namespaces.fill(true);
|
||||
}
|
||||
namespaces
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use ruvector_namespace_merge::{
|
||||
dataset::{Dataset, DatasetConfig},
|
||||
dataset::{Dataset, DatasetConfig, Namespace},
|
||||
recall_at_k,
|
||||
router::{AllSearch, CentroidFilter, MinCutRoute, NamespaceRouter},
|
||||
};
|
||||
|
|
@ -16,13 +16,53 @@ fn make_dataset() -> Dataset {
|
|||
})
|
||||
}
|
||||
|
||||
fn make_dataset_64d() -> Dataset {
|
||||
Dataset::generate(&DatasetConfig {
|
||||
per_ns: 500,
|
||||
dims: 64,
|
||||
seed: SEED,
|
||||
noise: 0.30,
|
||||
})
|
||||
fn manual_dataset(vectors: &[&[f32]]) -> Dataset {
|
||||
let dims = vectors.first().map_or(0, |vector| vector.len());
|
||||
let namespaces = vectors
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(id, vector)| Namespace::new(id, format!("ns-{id}"), vector.to_vec(), 1, dims))
|
||||
.collect();
|
||||
|
||||
Dataset {
|
||||
namespaces,
|
||||
dims,
|
||||
per_ns: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mincut_single_namespace_remains_searchable() {
|
||||
let ds = manual_dataset(&[&[1.0, 0.0]]);
|
||||
let result = MinCutRoute::new(&ds).search(&ds, &[1.0, 0.0], 1);
|
||||
|
||||
assert_eq!(result.ns_searched, 1);
|
||||
assert_eq!(result.dist_ops, 1);
|
||||
assert_eq!(result.hits.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mincut_equal_similarities_searches_all_namespaces() {
|
||||
let ds = manual_dataset(&[&[1.0, 0.0], &[1.0, 0.0], &[1.0, 0.0]]);
|
||||
let result = MinCutRoute::new(&ds).search(&ds, &[0.0, 1.0], 3);
|
||||
|
||||
assert_eq!(result.ns_searched, 3);
|
||||
assert_eq!(result.dist_ops, 3);
|
||||
assert_eq!(result.hits.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mincut_empty_dataset_returns_empty_result() {
|
||||
let ds = Dataset {
|
||||
namespaces: Vec::new(),
|
||||
dims: 2,
|
||||
per_ns: 0,
|
||||
};
|
||||
let result = MinCutRoute::new(&ds).search(&ds, &[1.0, 0.0], 10);
|
||||
|
||||
assert!(result.hits.is_empty());
|
||||
assert_eq!(result.ns_searched, 0);
|
||||
assert_eq!(result.dist_ops, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -125,8 +165,8 @@ fn flow_unit_two_cluster_query() {
|
|||
let mut g = FlowGraph::new(5);
|
||||
|
||||
let q_sim = [0.60f32, 0.55f32, 0.02f32];
|
||||
for i in 0..n {
|
||||
let qs = q_sim[i].max(0.0).min(1.0);
|
||||
for (i, query_sim) in q_sim.iter().copied().enumerate().take(n) {
|
||||
let qs = query_sim.clamp(0.0, 1.0);
|
||||
g.add_edge(s, i, (qs * scale as f32).round() as i64);
|
||||
g.add_edge(i, t, ((1.0 - qs) * scale as f32).round() as i64);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
- **Status**: Accepted
|
||||
- **Date**: 2026-08-08
|
||||
- **Updated**: 2026-08-08
|
||||
- **Extends**: ADR-254 (turbovec), ADR-026 (tiered routing), ADR-297 (ACRP)
|
||||
- **Related crates**: `ruvector-namespace-merge`, `ruvector-agent-memory`, `ruvector-graph`, `ruvector-coherence-hnsw`, `rvf`
|
||||
|
||||
|
|
@ -50,6 +51,12 @@ namespaces for this query).
|
|||
Run Edmonds-Karp max-flow on this graph. The source-side of the min-cut
|
||||
(nodes reachable from S in the residual graph) are the namespaces to search.
|
||||
|
||||
**Correction (2026-08-08):** when all query affinities are equal (including a
|
||||
single-namespace dataset), relative normalisation contains no routing signal.
|
||||
`MinCutRoute` deterministically searches all namespaces in that case; an empty
|
||||
source-side cut also falls back to all namespaces to preserve recall. A dataset
|
||||
with no namespaces returns an empty result without constructing a flow graph.
|
||||
|
||||
### 2. Relative normalisation is non-negotiable
|
||||
|
||||
Raw cosine similarities depend on dimensionality and noise level. At dims=64
|
||||
|
|
@ -191,7 +198,7 @@ All acceptance criteria pass:
|
|||
- `MAX_DIST_OPS_CENTROID_FRAC=0.70` → actual 0.383 ✓
|
||||
- `MAX_DIST_OPS_MINCUT_FRAC=0.60` → actual 0.410 ✓
|
||||
|
||||
All 6 tests pass (`cargo test -p ruvector-namespace-merge`).
|
||||
All 9 tests pass (`cargo test -p ruvector-namespace-merge`).
|
||||
|
||||
## Failure Modes
|
||||
|
||||
|
|
@ -202,7 +209,7 @@ All 6 tests pass (`cargo test -p ruvector-namespace-merge`).
|
|||
| Stale centroids | Vectors inserted after `MinCutRoute::new()` | Rebuild router after bulk inserts; warn in docs |
|
||||
| Centroid collapse | Single-vector namespace | Centroid = that vector; routing still correct |
|
||||
| Flow overflow | N > 500 with SCALE=10000 | i64 capacity; N=500 gives max cap 10000×N²≈2.5×10⁹ < i64::MAX |
|
||||
| Identical q_sim values | Query equidistant from all centroids | range → 0; clamped by `max(range, 1e-6)`; all ns searched |
|
||||
| Identical q_sim values | Query equidistant from all centroids | Detect the degenerate range and deterministically search all namespaces |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue