Skip to main content

ipfrs_semantic/
query_planner.rs

1//! # NearestNeighborQueryPlanner
2//!
3//! Plans and optimizes k-NN queries over sharded HNSW indexes, choosing between
4//! local-only, remote-fanout, and hybrid execution strategies.
5
6/// Execution strategy for a k-NN query plan.
7#[derive(Debug, Clone, PartialEq)]
8pub enum ExecutionStrategy {
9    /// Query served entirely from the local shard.
10    LocalOnly,
11    /// Fan out to the listed remote peers.
12    RemoteFanout { peer_ids: Vec<String> },
13    /// Query both local shard and remote peers.
14    Hybrid { local: bool, peer_ids: Vec<String> },
15    /// Result is available in the similarity cache.
16    Cached { cache_key: u64 },
17}
18
19/// Metadata describing a single shard.
20#[derive(Debug, Clone)]
21pub struct ShardInfo {
22    /// Unique shard identifier.
23    pub shard_id: String,
24    /// Peer that owns this shard. Use `"local"` for the local peer.
25    pub peer_id: String,
26    /// Number of vectors stored in this shard.
27    pub vector_count: u64,
28    /// Embedding dimensionality.
29    pub dimension: usize,
30    /// Expected round-trip latency in milliseconds.
31    pub estimated_latency_ms: f64,
32    /// Whether this shard resides on the local node.
33    pub is_local: bool,
34}
35
36/// A fully-resolved query execution plan.
37#[derive(Debug, Clone)]
38pub struct QueryPlan {
39    /// FNV-1a hash of the query vector bytes (used as a stable query identifier).
40    pub query_id: u64,
41    /// Number of nearest neighbours requested.
42    pub k: usize,
43    /// Chosen execution strategy.
44    pub strategy: ExecutionStrategy,
45    /// Shards that will be queried under this plan.
46    pub shards: Vec<ShardInfo>,
47    /// Maximum latency across all selected shards (0.0 if no shards).
48    pub estimated_latency_ms: f64,
49    /// Expected number of candidate results before final merge.
50    pub estimated_results: usize,
51}
52
53impl QueryPlan {
54    /// Returns `true` when the plan executes entirely on the local node.
55    pub fn is_local_only(&self) -> bool {
56        matches!(self.strategy, ExecutionStrategy::LocalOnly)
57    }
58}
59
60/// Configuration for the [`NearestNeighborQueryPlanner`].
61#[derive(Debug, Clone)]
62pub struct PlannerConfig {
63    /// Maximum number of shards / peers to fan out to. Default: `8`.
64    pub max_fanout: usize,
65    /// Shards whose `estimated_latency_ms` exceeds this value are excluded.
66    /// Default: `100.0`.
67    pub latency_budget_ms: f64,
68    /// Shards with fewer vectors than this threshold are excluded. Default: `100`.
69    pub min_vectors_per_shard: u64,
70    /// When `true`, local shards are sorted to the front of the candidate list.
71    /// Default: `true`.
72    pub prefer_local: bool,
73}
74
75impl Default for PlannerConfig {
76    fn default() -> Self {
77        Self {
78            max_fanout: 8,
79            latency_budget_ms: 100.0,
80            min_vectors_per_shard: 100,
81            prefer_local: true,
82        }
83    }
84}
85
86// ---------------------------------------------------------------------------
87// Internal helpers
88// ---------------------------------------------------------------------------
89
90/// Compute an FNV-1a 64-bit hash over `f32` values by hashing their
91/// little-endian byte representation.
92fn fnv1a_hash_f32_slice(values: &[f32]) -> u64 {
93    const OFFSET_BASIS: u64 = 2_166_136_261_u64;
94    const PRIME: u64 = 16_777_619_u64;
95
96    let mut hash = OFFSET_BASIS;
97    for &v in values {
98        for byte in v.to_le_bytes() {
99            hash ^= u64::from(byte);
100            hash = hash.wrapping_mul(PRIME);
101        }
102    }
103    hash
104}
105
106// ---------------------------------------------------------------------------
107// NearestNeighborQueryPlanner
108// ---------------------------------------------------------------------------
109
110/// Plans k-NN queries over a heterogeneous set of HNSW shards.
111pub struct NearestNeighborQueryPlanner {
112    /// Planner configuration.
113    pub config: PlannerConfig,
114}
115
116impl NearestNeighborQueryPlanner {
117    /// Create a new planner with the given configuration.
118    pub fn new(config: PlannerConfig) -> Self {
119        Self { config }
120    }
121
122    /// Produce an optimised [`QueryPlan`] for the given query vector and `k`.
123    ///
124    /// # Algorithm
125    ///
126    /// 1. Compute a stable `query_id` via FNV-1a over the query bytes.
127    /// 2. Filter shards by latency budget and minimum vector count.
128    /// 3. Optionally sort local shards to the front.
129    /// 4. Limit to `max_fanout` shards.
130    /// 5. Choose the execution strategy based on the mix of local/remote shards.
131    pub fn plan(&self, query_vec: &[f32], k: usize, shards: &[ShardInfo]) -> QueryPlan {
132        let query_id = fnv1a_hash_f32_slice(query_vec);
133
134        // --- Step 1: filter ---
135        let mut candidates: Vec<ShardInfo> = shards
136            .iter()
137            .filter(|s| {
138                s.estimated_latency_ms <= self.config.latency_budget_ms
139                    && s.vector_count >= self.config.min_vectors_per_shard
140            })
141            .cloned()
142            .collect();
143
144        // --- Step 2: sort ---
145        if self.config.prefer_local {
146            // local shards first, then ascending latency
147            candidates.sort_by(|a, b| match (a.is_local, b.is_local) {
148                (true, false) => std::cmp::Ordering::Less,
149                (false, true) => std::cmp::Ordering::Greater,
150                _ => a
151                    .estimated_latency_ms
152                    .partial_cmp(&b.estimated_latency_ms)
153                    .unwrap_or(std::cmp::Ordering::Equal),
154            });
155        } else {
156            candidates.sort_by(|a, b| {
157                a.estimated_latency_ms
158                    .partial_cmp(&b.estimated_latency_ms)
159                    .unwrap_or(std::cmp::Ordering::Equal)
160            });
161        }
162
163        // --- Step 3: limit ---
164        candidates.truncate(self.config.max_fanout);
165
166        // --- Step 4: strategy ---
167        let has_local = candidates.iter().any(|s| s.is_local);
168        let remote_peer_ids: Vec<String> = candidates
169            .iter()
170            .filter(|s| !s.is_local)
171            .map(|s| s.peer_id.clone())
172            .collect();
173
174        let strategy = if candidates.is_empty() || (has_local && remote_peer_ids.is_empty()) {
175            ExecutionStrategy::LocalOnly
176        } else if !has_local {
177            ExecutionStrategy::RemoteFanout {
178                peer_ids: remote_peer_ids,
179            }
180        } else {
181            ExecutionStrategy::Hybrid {
182                local: true,
183                peer_ids: remote_peer_ids,
184            }
185        };
186
187        // --- Step 5: derived metrics ---
188        let estimated_latency_ms = candidates
189            .iter()
190            .map(|s| s.estimated_latency_ms)
191            .fold(0.0_f64, f64::max);
192
193        let total_vectors: u64 = candidates.iter().map(|s| s.vector_count).sum();
194        let upper = (k * candidates.len().max(1)) as u64;
195        let raw = upper.min(total_vectors);
196        let estimated_results = (raw as usize).max(k.min(total_vectors as usize));
197
198        QueryPlan {
199            query_id,
200            k,
201            strategy,
202            shards: candidates,
203            estimated_latency_ms,
204            estimated_results,
205        }
206    }
207
208    /// Return a human-readable description of the plan.
209    pub fn explain(&self, plan: &QueryPlan) -> String {
210        let strategy_desc = match &plan.strategy {
211            ExecutionStrategy::LocalOnly => "LocalOnly".to_string(),
212            ExecutionStrategy::RemoteFanout { peer_ids } => {
213                format!("RemoteFanout(peers={})", peer_ids.join(", "))
214            }
215            ExecutionStrategy::Hybrid { local, peer_ids } => {
216                format!("Hybrid(local={}, peers={})", local, peer_ids.join(", "))
217            }
218            ExecutionStrategy::Cached { cache_key } => {
219                format!("Cached(key={cache_key:#x})")
220            }
221        };
222
223        format!(
224            "QueryPlan {{ id={:#x}, k={}, strategy={}, shards={}, \
225             est_latency={:.2}ms, est_results={} }}",
226            plan.query_id,
227            plan.k,
228            strategy_desc,
229            plan.shards.len(),
230            plan.estimated_latency_ms,
231            plan.estimated_results,
232        )
233    }
234
235    /// Produce a revised plan after `failed_peer` could not be reached.
236    ///
237    /// All shards owned by `failed_peer` are removed from the original plan's
238    /// shard list, and the strategy is recomputed from the survivors.
239    pub fn replan_on_failure(&self, plan: &QueryPlan, failed_peer: &str) -> QueryPlan {
240        let surviving: Vec<ShardInfo> = plan
241            .shards
242            .iter()
243            .filter(|s| s.peer_id != failed_peer)
244            .cloned()
245            .collect();
246
247        let has_local = surviving.iter().any(|s| s.is_local);
248        let remote_peer_ids: Vec<String> = surviving
249            .iter()
250            .filter(|s| !s.is_local)
251            .map(|s| s.peer_id.clone())
252            .collect();
253
254        let strategy = if surviving.is_empty() || (has_local && remote_peer_ids.is_empty()) {
255            ExecutionStrategy::LocalOnly
256        } else if !has_local {
257            ExecutionStrategy::RemoteFanout {
258                peer_ids: remote_peer_ids,
259            }
260        } else {
261            ExecutionStrategy::Hybrid {
262                local: true,
263                peer_ids: remote_peer_ids,
264            }
265        };
266
267        let estimated_latency_ms = surviving
268            .iter()
269            .map(|s| s.estimated_latency_ms)
270            .fold(0.0_f64, f64::max);
271
272        let total_vectors: u64 = surviving.iter().map(|s| s.vector_count).sum();
273        let upper = (plan.k * surviving.len().max(1)) as u64;
274        let raw = upper.min(total_vectors);
275        let estimated_results = (raw as usize).max(plan.k.min(total_vectors as usize));
276
277        QueryPlan {
278            query_id: plan.query_id,
279            k: plan.k,
280            strategy,
281            shards: surviving,
282            estimated_latency_ms,
283            estimated_results,
284        }
285    }
286}
287
288// ---------------------------------------------------------------------------
289// Tests
290// ---------------------------------------------------------------------------
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    fn make_local_shard(id: &str, vectors: u64, latency: f64) -> ShardInfo {
297        ShardInfo {
298            shard_id: id.to_string(),
299            peer_id: "local".to_string(),
300            vector_count: vectors,
301            dimension: 128,
302            estimated_latency_ms: latency,
303            is_local: true,
304        }
305    }
306
307    fn make_remote_shard(id: &str, peer: &str, vectors: u64, latency: f64) -> ShardInfo {
308        ShardInfo {
309            shard_id: id.to_string(),
310            peer_id: peer.to_string(),
311            vector_count: vectors,
312            dimension: 128,
313            estimated_latency_ms: latency,
314            is_local: false,
315        }
316    }
317
318    fn default_planner() -> NearestNeighborQueryPlanner {
319        NearestNeighborQueryPlanner::new(PlannerConfig::default())
320    }
321
322    fn query_vec() -> Vec<f32> {
323        vec![0.1, 0.2, 0.3, 0.4]
324    }
325
326    // 1. Empty shards → LocalOnly with no shards
327    #[test]
328    fn test_plan_empty_shards_local_only() {
329        let planner = default_planner();
330        let plan = planner.plan(&query_vec(), 5, &[]);
331        assert!(matches!(plan.strategy, ExecutionStrategy::LocalOnly));
332        assert!(plan.shards.is_empty());
333    }
334
335    // 2. Single local shard → LocalOnly
336    #[test]
337    fn test_plan_single_local_shard() {
338        let planner = default_planner();
339        let shards = vec![make_local_shard("s0", 500, 5.0)];
340        let plan = planner.plan(&query_vec(), 5, &shards);
341        assert!(matches!(plan.strategy, ExecutionStrategy::LocalOnly));
342        assert_eq!(plan.shards.len(), 1);
343    }
344
345    // 3. Single remote shard → RemoteFanout
346    #[test]
347    fn test_plan_single_remote_shard() {
348        let planner = default_planner();
349        let shards = vec![make_remote_shard("s1", "peer-A", 500, 20.0)];
350        let plan = planner.plan(&query_vec(), 5, &shards);
351        assert!(
352            matches!(&plan.strategy, ExecutionStrategy::RemoteFanout { peer_ids } if peer_ids == &["peer-A"])
353        );
354    }
355
356    // 4. Mixed shards → Hybrid
357    #[test]
358    fn test_plan_mixed_shards_hybrid() {
359        let planner = default_planner();
360        let shards = vec![
361            make_local_shard("s0", 500, 5.0),
362            make_remote_shard("s1", "peer-B", 500, 30.0),
363        ];
364        let plan = planner.plan(&query_vec(), 5, &shards);
365        assert!(matches!(
366            &plan.strategy,
367            ExecutionStrategy::Hybrid { local: true, .. }
368        ));
369    }
370
371    // 5. Latency budget excludes slow shards
372    #[test]
373    fn test_plan_respects_latency_budget() {
374        let config = PlannerConfig {
375            latency_budget_ms: 50.0,
376            ..PlannerConfig::default()
377        };
378        let planner = NearestNeighborQueryPlanner::new(config);
379        let shards = vec![
380            make_local_shard("s0", 500, 40.0),
381            make_remote_shard("s1", "peer-C", 500, 80.0), // too slow
382        ];
383        let plan = planner.plan(&query_vec(), 5, &shards);
384        assert_eq!(plan.shards.len(), 1);
385        assert!(plan.shards[0].is_local);
386    }
387
388    // 6. min_vectors_per_shard excludes sparse shards
389    #[test]
390    fn test_plan_respects_min_vectors() {
391        let config = PlannerConfig {
392            min_vectors_per_shard: 200,
393            ..PlannerConfig::default()
394        };
395        let planner = NearestNeighborQueryPlanner::new(config);
396        let shards = vec![
397            make_local_shard("s0", 500, 5.0),
398            make_remote_shard("s1", "peer-D", 50, 10.0), // too sparse
399        ];
400        let plan = planner.plan(&query_vec(), 5, &shards);
401        assert_eq!(plan.shards.len(), 1);
402        assert!(plan.shards[0].is_local);
403    }
404
405    // 7. max_fanout limits selected shards
406    #[test]
407    fn test_plan_respects_max_fanout() {
408        let config = PlannerConfig {
409            max_fanout: 2,
410            ..PlannerConfig::default()
411        };
412        let planner = NearestNeighborQueryPlanner::new(config);
413        let shards: Vec<ShardInfo> = (0..5)
414            .map(|i| {
415                make_remote_shard(&format!("s{i}"), &format!("peer-{i}"), 500, 10.0 + i as f64)
416            })
417            .collect();
418        let plan = planner.plan(&query_vec(), 5, &shards);
419        assert_eq!(plan.shards.len(), 2);
420    }
421
422    // 8. prefer_local puts local shard first
423    #[test]
424    fn test_plan_prefer_local_first() {
425        let planner = default_planner();
426        let shards = vec![
427            make_remote_shard("s1", "peer-E", 500, 5.0), // lower latency but remote
428            make_local_shard("s0", 500, 20.0),
429        ];
430        let plan = planner.plan(&query_vec(), 5, &shards);
431        assert!(plan.shards[0].is_local, "local shard should be first");
432    }
433
434    // 9. query_id is deterministic for same vector
435    #[test]
436    fn test_query_id_deterministic() {
437        let planner = default_planner();
438        let v = vec![1.0_f32, 2.0, 3.0];
439        let p1 = planner.plan(&v, 5, &[]);
440        let p2 = planner.plan(&v, 5, &[]);
441        assert_eq!(p1.query_id, p2.query_id);
442    }
443
444    // 10. query_id differs for different vectors
445    #[test]
446    fn test_query_id_differs_for_different_vectors() {
447        let planner = default_planner();
448        let p1 = planner.plan(&[1.0_f32, 0.0], 5, &[]);
449        let p2 = planner.plan(&[0.0_f32, 1.0], 5, &[]);
450        assert_ne!(p1.query_id, p2.query_id);
451    }
452
453    // 11. estimated_latency_ms is the max across selected shards
454    #[test]
455    fn test_estimated_latency_is_max() {
456        let planner = default_planner();
457        let shards = vec![
458            make_local_shard("s0", 500, 10.0),
459            make_remote_shard("s1", "peer-F", 500, 45.0),
460            make_remote_shard("s2", "peer-G", 500, 30.0),
461        ];
462        let plan = planner.plan(&query_vec(), 5, &shards);
463        assert!((plan.estimated_latency_ms - 45.0).abs() < 1e-9);
464    }
465
466    // 12. is_local_only() true/false
467    #[test]
468    fn test_is_local_only_flag() {
469        let planner = default_planner();
470
471        let local_shards = vec![make_local_shard("s0", 500, 5.0)];
472        let local_plan = planner.plan(&query_vec(), 5, &local_shards);
473        assert!(local_plan.is_local_only());
474
475        let remote_shards = vec![make_remote_shard("s1", "peer-H", 500, 10.0)];
476        let remote_plan = planner.plan(&query_vec(), 5, &remote_shards);
477        assert!(!remote_plan.is_local_only());
478    }
479
480    // 13. explain() returns a non-empty string
481    #[test]
482    fn test_explain_non_empty() {
483        let planner = default_planner();
484        let shards = vec![make_local_shard("s0", 500, 5.0)];
485        let plan = planner.plan(&query_vec(), 5, &shards);
486        let explanation = planner.explain(&plan);
487        assert!(!explanation.is_empty());
488        assert!(explanation.contains("QueryPlan"));
489    }
490
491    // 14. replan_on_failure removes the failed peer's shards
492    #[test]
493    fn test_replan_removes_failed_peer() {
494        let planner = default_planner();
495        let shards = vec![
496            make_local_shard("s0", 500, 5.0),
497            make_remote_shard("s1", "peer-X", 500, 20.0),
498            make_remote_shard("s2", "peer-Y", 500, 25.0),
499        ];
500        let plan = planner.plan(&query_vec(), 5, &shards);
501        let new_plan = planner.replan_on_failure(&plan, "peer-X");
502        assert!(new_plan.shards.iter().all(|s| s.peer_id != "peer-X"));
503        assert_eq!(new_plan.shards.len(), 2);
504    }
505
506    // 15. replan_on_failure updates strategy (remote-only → LocalOnly after removing remote)
507    #[test]
508    fn test_replan_updates_strategy() {
509        let planner = default_planner();
510        let shards = vec![
511            make_local_shard("s0", 500, 5.0),
512            make_remote_shard("s1", "peer-Z", 500, 20.0),
513        ];
514        let plan = planner.plan(&query_vec(), 5, &shards);
515        // Initially Hybrid; after removing peer-Z only local remains → LocalOnly
516        let new_plan = planner.replan_on_failure(&plan, "peer-Z");
517        assert!(matches!(new_plan.strategy, ExecutionStrategy::LocalOnly));
518    }
519
520    // 16. estimated_results clamped to k minimum (when total_vectors < k)
521    #[test]
522    fn test_estimated_results_clamped_to_k_minimum() {
523        let planner = default_planner();
524        let shards = vec![make_local_shard("s0", 200, 5.0)];
525        // k=10, total_vectors=200 → raw = min(10*1, 200) = 10 ≥ k already
526        // Use k=300 to force clamping: min(300*1, 200)=200, max(200, min(300,200))=200
527        let plan = planner.plan(&query_vec(), 300, &shards);
528        assert!(plan.estimated_results >= plan.k.min(200));
529    }
530
531    // Bonus: All-filtered scenario returns LocalOnly with empty shards
532    #[test]
533    fn test_all_filtered_returns_local_only_empty() {
534        let config = PlannerConfig {
535            latency_budget_ms: 1.0, // everything is too slow
536            ..PlannerConfig::default()
537        };
538        let planner = NearestNeighborQueryPlanner::new(config);
539        let shards = vec![
540            make_local_shard("s0", 500, 50.0),
541            make_remote_shard("s1", "peer-Q", 500, 80.0),
542        ];
543        let plan = planner.plan(&query_vec(), 5, &shards);
544        assert!(matches!(plan.strategy, ExecutionStrategy::LocalOnly));
545        assert!(plan.shards.is_empty());
546    }
547}