1use std::collections::HashSet;
10use std::sync::Mutex;
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use async_trait::async_trait;
14use serde::{Deserialize, Serialize};
15
16use clawft_core::embeddings::hnsw_store::{HnswStore, TieredSearch};
17
18use crate::health::HealthStatus;
19use crate::hnsw_eml::{HnswEmlConfig, HnswEmlManager};
20use crate::service::{ServiceType, SystemService};
21
22#[cfg(feature = "exochain")]
23use crate::chain::ChainManager;
24#[cfg(feature = "exochain")]
25use crate::governance::{EffectVector, GovernanceDecision, GovernanceEngine, GovernanceRequest};
26#[cfg(feature = "exochain")]
27use std::sync::Arc;
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct HnswServiceConfig {
34 pub ef_search: usize,
36 pub ef_construction: usize,
38 pub default_dimensions: usize,
40}
41
42impl Default for HnswServiceConfig {
43 fn default() -> Self {
44 Self {
45 ef_search: 100,
46 ef_construction: 200,
47 default_dimensions: 384,
48 }
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct MultiKeyConfig {
61 pub enabled: bool,
63 pub max_keys_per_entity: usize,
65 pub key_types: Vec<String>,
67}
68
69impl Default for MultiKeyConfig {
70 fn default() -> Self {
71 Self {
72 enabled: true,
73 max_keys_per_entity: 4,
74 key_types: vec![
75 "name".to_string(),
76 "context".to_string(),
77 "description".to_string(),
78 ],
79 }
80 }
81}
82
83#[derive(Debug, Clone)]
88pub struct MultiKey {
89 pub key_type: String,
91 pub embedding: Vec<f32>,
93}
94
95#[derive(Debug, Clone)]
99pub struct HnswSearchResult {
100 pub id: String,
102 pub score: f32,
104 pub metadata: serde_json::Value,
106}
107
108pub struct HnswService {
116 store: Mutex<HnswStore>,
117 config: HnswServiceConfig,
118 insert_count: AtomicU64,
119 search_count: AtomicU64,
120 epoch: AtomicU64,
122 eml: Mutex<HnswEmlManager>,
124 #[cfg(feature = "exochain")]
126 chain_manager: Option<Arc<ChainManager>>,
127 #[cfg(feature = "exochain")]
129 governance_engine: Option<Arc<GovernanceEngine>>,
130}
131
132impl HnswService {
133 pub fn new(config: HnswServiceConfig) -> Self {
135 let store = HnswStore::with_params(config.ef_search, config.ef_construction);
136 Self {
137 store: Mutex::new(store),
138 config,
139 insert_count: AtomicU64::new(0),
140 search_count: AtomicU64::new(0),
141 epoch: AtomicU64::new(0),
142 eml: Mutex::new(HnswEmlManager::with_defaults()),
143 #[cfg(feature = "exochain")]
144 chain_manager: None,
145 #[cfg(feature = "exochain")]
146 governance_engine: None,
147 }
148 }
149
150 pub fn from_store(config: HnswServiceConfig, store: HnswStore) -> Self {
155 Self {
156 store: Mutex::new(store),
157 config,
158 insert_count: AtomicU64::new(0),
159 search_count: AtomicU64::new(0),
160 epoch: AtomicU64::new(0),
161 eml: Mutex::new(HnswEmlManager::with_defaults()),
162 #[cfg(feature = "exochain")]
163 chain_manager: None,
164 #[cfg(feature = "exochain")]
165 governance_engine: None,
166 }
167 }
168
169 pub fn snapshot_entries(&self) -> Vec<clawft_core::embeddings::hnsw_store::HnswEntry> {
171 let store = self.store.lock().expect("HnswStore lock poisoned");
172 store.entries().to_vec()
173 }
174
175 pub fn ef_params(&self) -> (usize, usize) {
177 let store = self.store.lock().expect("HnswStore lock poisoned");
178 (store.ef_search(), store.ef_construction())
179 }
180
181 pub fn delete(&self, id: &str) -> bool {
183 let mut store = self.store.lock().expect("HnswStore lock poisoned");
184 let removed = store.delete(id);
185 if removed {
186 self.epoch.fetch_add(1, Ordering::SeqCst);
187 if let Ok(mut eml) = self.eml.lock() {
188 eml.clear_path_table();
189 }
190 }
191 removed
192 }
193
194 pub fn with_eml(config: HnswServiceConfig, eml_config: HnswEmlConfig) -> Self {
196 let store = HnswStore::with_params(config.ef_search, config.ef_construction);
197 Self {
198 store: Mutex::new(store),
199 config,
200 insert_count: AtomicU64::new(0),
201 search_count: AtomicU64::new(0),
202 epoch: AtomicU64::new(0),
203 eml: Mutex::new(HnswEmlManager::new(eml_config)),
204 #[cfg(feature = "exochain")]
205 chain_manager: None,
206 #[cfg(feature = "exochain")]
207 governance_engine: None,
208 }
209 }
210
211 #[cfg(feature = "exochain")]
213 pub fn set_chain_manager(&mut self, cm: Arc<ChainManager>) {
214 self.chain_manager = Some(cm);
215 }
216
217 #[cfg(feature = "exochain")]
219 pub fn set_governance_engine(&mut self, engine: Arc<GovernanceEngine>) {
220 self.governance_engine = Some(engine);
221 }
222
223 pub fn insert(&self, id: String, embedding: Vec<f32>, metadata: serde_json::Value) {
225 let mut store = self.store.lock().expect("HnswStore lock poisoned");
226 store.insert(id.clone(), embedding, metadata);
227 self.insert_count.fetch_add(1, Ordering::Relaxed);
228 self.epoch.fetch_add(1, Ordering::SeqCst);
229
230 if let Ok(mut eml) = self.eml.lock() {
232 eml.clear_path_table();
233 }
234
235 #[cfg(feature = "exochain")]
237 if let Some(ref cm) = self.chain_manager {
238 cm.append(
239 "hnsw_service",
240 crate::chain::EVENT_KIND_HNSW_INSERT,
241 Some(serde_json::json!({
242 "id": id,
243 })),
244 );
245 }
246 }
247
248 pub fn current_epoch(&self) -> u64 {
250 self.epoch.load(Ordering::SeqCst)
251 }
252
253 pub fn search(&self, query: &[f32], top_k: usize) -> Vec<HnswSearchResult> {
261 let mut store = self.store.lock().expect("HnswStore lock poisoned");
262 let mut eml = self.eml.lock().expect("EML lock poisoned");
263 self.search_count.fetch_add(1, Ordering::Relaxed);
264
265 let store_size = store.len();
266
267 let ef_pred = eml.predict_ef(query, store_size);
270 if ef_pred.is_learned && ef_pred.recommended_ef != store.ef_search() {
271 store.set_ef_search(ef_pred.recommended_ef);
272 }
273
274 let rebuild_pred = eml.predict_rebuild(store_size, store.inserts_since_rebuild(), 0);
278 if rebuild_pred.is_learned && rebuild_pred.should_rebuild {
279 store.force_rebuild();
280 let pairs = store.entry_id_embeddings();
282 eml.rebuild_path_table(&pairs);
283 }
284
285 if eml.path_predict_enabled() && !eml.has_path_table() && store_size >= 16 {
287 let pairs = store.entry_id_embeddings();
288 eml.rebuild_path_table(&pairs);
289 }
290
291 let t0 = std::time::Instant::now();
292 let (results, path_pred, used_guided) = if eml.has_path_table() {
293 let probes = eml.path_region_probes();
294 let table = eml
298 .path_table()
299 .expect("has_path_table true")
300 .clone();
301 let (raw, pred) = store.query_guided(query, top_k, &table, probes);
302 let mapped: Vec<HnswSearchResult> = raw
303 .into_iter()
304 .map(|r| HnswSearchResult {
305 id: r.id,
306 score: r.score,
307 metadata: r.metadata,
308 })
309 .collect();
310 let guided = pred.is_learned;
311 let path_pred = crate::hnsw_eml::PathPrediction {
312 region_id: pred.region_id,
313 entry_ids: pred.entry_ids,
314 region_score: pred.region_score,
315 candidate_ids: pred.candidate_ids,
316 is_learned: pred.is_learned,
317 eml_path_quality: None,
318 };
319 (mapped, path_pred, guided)
320 } else {
321 let mapped: Vec<HnswSearchResult> = store
322 .query(query, top_k)
323 .into_iter()
324 .map(|r| HnswSearchResult {
325 id: r.id,
326 score: r.score,
327 metadata: r.metadata,
328 })
329 .collect();
330 (
331 mapped,
332 crate::hnsw_eml::PathPrediction {
333 region_id: None,
334 entry_ids: Vec::new(),
335 region_score: 0.0,
336 candidate_ids: Vec::new(),
337 is_learned: false,
338 eml_path_quality: None,
339 },
340 false,
341 )
342 };
343 let elapsed_us = t0.elapsed().as_micros() as u64;
344
345 let top_score = results.first().map(|r| r.score).unwrap_or(0.0);
346 let ef_used = store.ef_search();
347 if used_guided {
348 let pool = path_pred.candidate_ids.len();
349 eml.record_guided_search(crate::hnsw_eml::GuidedSearch {
350 query,
351 result_count: results.len(),
352 top_score,
353 ef_used,
354 search_time_us: elapsed_us,
355 store_size,
356 prediction: &path_pred,
357 candidate_pool_size: pool,
358 });
359 } else {
360 eml.record_search(
361 query,
362 results.len(),
363 top_score,
364 ef_used,
365 elapsed_us,
366 store_size,
367 );
368 }
369
370 #[cfg(feature = "exochain")]
372 if let Some(ref cm) = self.chain_manager {
373 cm.append(
374 "hnsw_eml",
375 crate::chain::EVENT_KIND_HNSW_EML_OBSERVE,
376 Some(serde_json::json!({
377 "ef_used": ef_used,
378 "latency_us": elapsed_us,
379 "result_count": results.len(),
380 "top_score": top_score,
381 "store_size": store_size,
382 "is_learned": ef_pred.is_learned,
383 "path_guided": used_guided,
384 "path_region": path_pred.region_id,
385 "path_region_score": path_pred.region_score,
386 "path_entry_count": path_pred.entry_ids.len(),
387 })),
388 );
389 }
390
391 results
392 }
393
394 pub fn rebuild_path_table(&self) {
397 let store = self.store.lock().expect("HnswStore lock poisoned");
398 let mut eml = self.eml.lock().expect("EML lock poisoned");
399 let pairs = store.entry_id_embeddings();
400 eml.rebuild_path_table(&pairs);
401 }
402
403 pub fn path_table_ready(&self) -> bool {
405 self.eml
406 .lock()
407 .expect("EML lock poisoned")
408 .has_path_table()
409 }
410
411 pub fn brute_force_topk(&self, query: &[f32], top_k: usize) -> Vec<String> {
413 let store = self.store.lock().expect("HnswStore lock poisoned");
414 store.brute_force_topk(query, top_k)
415 }
416
417 pub fn measure_recall(&self, queries: &[Vec<f32>], top_k: usize) -> f64 {
419 let mut store = self.store.lock().expect("HnswStore lock poisoned");
420 let mut eml = self.eml.lock().expect("EML lock poisoned");
421
422 let mut hnsw_ids = Vec::with_capacity(queries.len());
423 let mut exact_ids = Vec::with_capacity(queries.len());
424 for q in queries {
425 let hnsw: Vec<String> = store.query(q, top_k).into_iter().map(|r| r.id).collect();
426 let exact = store.brute_force_topk(q, top_k);
427 hnsw_ids.push(hnsw);
428 exact_ids.push(exact);
429 }
430
431 let recall = eml.measure_recall(
432 &hnsw_ids,
433 &exact_ids,
434 store.len(),
435 store.inserts_since_rebuild(),
436 0,
437 );
438
439 #[cfg(feature = "exochain")]
440 if let Some(ref cm) = self.chain_manager {
441 cm.append(
442 "hnsw_eml",
443 crate::chain::EVENT_KIND_HNSW_EML_RECALL,
444 Some(serde_json::json!({
445 "avg_recall": recall,
446 "store_size": store.len(),
447 "inserts_since_rebuild": store.inserts_since_rebuild(),
448 "query_count": queries.len(),
449 })),
450 );
451 }
452
453 recall
454 }
455
456 pub fn build_tiered(&self, top_k: usize) -> TieredSearch {
462 let store = self.store.lock().expect("HnswStore lock poisoned");
463 let eml = self.eml.lock().expect("EML lock poisoned");
464
465 let entries: Vec<(String, Vec<f32>, serde_json::Value)> = (0..store.len())
466 .filter_map(|_| None) .collect();
468
469 let dims = self.config.default_dimensions;
473 if eml.is_enabled() {
474 let dim_order = eml.learned_dim_order(dims);
475 TieredSearch::build_learned(&entries, &dim_order, dims, top_k, self.config.ef_search)
476 } else {
477 TieredSearch::build_default(&entries, dims, top_k, self.config.ef_search)
478 }
479 }
480
481 pub fn eml_status(&self) -> crate::hnsw_eml::HnswEmlStatus {
483 self.eml.lock().expect("EML lock poisoned").status()
484 }
485
486 pub fn search_batch(&self, queries: &[&[f32]], top_k: usize) -> Vec<Vec<HnswSearchResult>> {
491 let mut store = self.store.lock().expect("HnswStore lock poisoned");
492 self.search_count
493 .fetch_add(queries.len() as u64, Ordering::Relaxed);
494 queries
495 .iter()
496 .map(|query| {
497 store
498 .query(query, top_k)
499 .into_iter()
500 .map(|r| HnswSearchResult {
501 id: r.id,
502 score: r.score,
503 metadata: r.metadata,
504 })
505 .collect()
506 })
507 .collect()
508 }
509
510 pub fn insert_multi_key(
519 &self,
520 primary_id: String,
521 keys: &[MultiKey],
522 metadata: serde_json::Value,
523 max_keys: usize,
524 ) {
525 let limit = keys.len().min(max_keys);
526 for key in &keys[..limit] {
527 let sub_id = format!("{}::{}", primary_id, key.key_type);
528 self.insert(sub_id, key.embedding.clone(), metadata.clone());
529 }
530 }
531
532 pub fn search_dedup(&self, query: &[f32], top_k: usize) -> Vec<HnswSearchResult> {
540 let raw = self.search(query, top_k * 3);
541
542 let mut seen = HashSet::new();
543 let mut results = Vec::with_capacity(top_k);
544
545 for r in raw {
546 let primary_id =
547 r.id.find("::")
548 .map(|pos| r.id[..pos].to_string())
549 .unwrap_or_else(|| r.id.clone());
550
551 if seen.insert(primary_id.clone()) {
552 results.push(HnswSearchResult {
553 id: primary_id,
554 score: r.score,
555 metadata: r.metadata,
556 });
557 }
558 if results.len() >= top_k {
559 break;
560 }
561 }
562
563 results
564 }
565
566 pub fn len(&self) -> usize {
568 let store = self.store.lock().expect("HnswStore lock poisoned");
569 store.len()
570 }
571
572 pub fn is_empty(&self) -> bool {
574 let store = self.store.lock().expect("HnswStore lock poisoned");
575 store.is_empty()
576 }
577
578 pub fn insert_count(&self) -> u64 {
580 self.insert_count.load(Ordering::Relaxed)
581 }
582
583 pub fn search_count(&self) -> u64 {
585 self.search_count.load(Ordering::Relaxed)
586 }
587
588 pub fn clear(&self) -> Result<(), String> {
594 #[cfg(feature = "exochain")]
596 if let Some(ref engine) = self.governance_engine {
597 let req = GovernanceRequest::new("system", "hnsw.clear").with_effect(EffectVector {
598 risk: 0.8,
599 ..Default::default()
600 });
601 let result = engine.evaluate(&req);
602 match &result.decision {
603 GovernanceDecision::Deny(reason) | GovernanceDecision::EscalateToHuman(reason) => {
604 return Err(format!("governance denied hnsw.clear: {reason}"));
605 }
606 _ => {}
607 }
608 }
609
610 let mut store = self.store.lock().expect("HnswStore lock poisoned");
611 #[cfg(feature = "exochain")]
612 let entries_destroyed = store.len();
613 *store = HnswStore::with_params(self.config.ef_search, self.config.ef_construction);
614 let epoch = self.epoch.fetch_add(1, Ordering::SeqCst);
615
616 #[cfg(feature = "exochain")]
618 if let Some(ref cm) = self.chain_manager {
619 cm.append(
620 "hnsw_service",
621 crate::chain::EVENT_KIND_HNSW_CLEAR,
622 Some(serde_json::json!({
623 "entries_destroyed": entries_destroyed,
624 "epoch": epoch + 1,
625 })),
626 );
627 }
628
629 Ok(())
630 }
631
632 pub fn config(&self) -> &HnswServiceConfig {
634 &self.config
635 }
636
637 pub fn save_to_file(&self, path: &std::path::Path) -> Result<(), std::io::Error> {
642 let store = self.store.lock().expect("HnswStore lock poisoned");
643 let result = store.save(path);
644
645 #[cfg(feature = "exochain")]
647 if result.is_ok()
648 && let Some(ref cm) = self.chain_manager
649 {
650 cm.append(
651 "hnsw_service",
652 crate::chain::EVENT_KIND_HNSW_SAVE,
653 Some(serde_json::json!({
654 "path": path.display().to_string(),
655 })),
656 );
657 }
658
659 result
660 }
661
662 pub fn load_from_file(path: &std::path::Path) -> Result<Self, std::io::Error> {
671 let store = HnswStore::load(path)?;
672 let (ef_search, ef_construction) = (store.ef_search(), store.ef_construction());
673 let config = HnswServiceConfig {
674 ef_search,
675 ef_construction,
676 default_dimensions: 384,
677 };
678 Ok(Self::from_store(config, store))
679 }
680
681 #[cfg(feature = "exochain")]
685 pub fn load_from_file_logged(
686 path: &std::path::Path,
687 cm: Arc<ChainManager>,
688 ) -> Result<Self, std::io::Error> {
689 let store = HnswStore::load(path)?;
690 let entry_count = store.len();
691 let (ef_search, ef_construction) = (store.ef_search(), store.ef_construction());
692 let config = HnswServiceConfig {
693 ef_search,
694 ef_construction,
695 default_dimensions: 384,
696 };
697
698 cm.append(
699 "hnsw_service",
700 crate::chain::EVENT_KIND_HNSW_LOAD,
701 Some(serde_json::json!({
702 "path": path.display().to_string(),
703 "entry_count": entry_count,
704 })),
705 );
706
707 let mut svc = Self::from_store(config, store);
708 svc.chain_manager = Some(cm);
709 Ok(svc)
710 }
711}
712
713impl std::fmt::Debug for HnswService {
714 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
715 f.debug_struct("HnswService")
716 .field("config", &self.config)
717 .field("insert_count", &self.insert_count.load(Ordering::Relaxed))
718 .field("search_count", &self.search_count.load(Ordering::Relaxed))
719 .field("epoch", &self.epoch.load(Ordering::Relaxed))
720 .finish()
721 }
722}
723
724#[async_trait]
727impl SystemService for HnswService {
728 fn name(&self) -> &str {
729 "ecc.hnsw"
730 }
731
732 fn service_type(&self) -> ServiceType {
733 ServiceType::Core
734 }
735
736 async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
737 Ok(())
738 }
739
740 async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
741 Ok(())
742 }
743
744 async fn health_check(&self) -> HealthStatus {
745 HealthStatus::Healthy
746 }
747}
748
749pub fn entity_search_keys(
760 label: &str,
761 source_file: Option<&str>,
762 metadata: &serde_json::Value,
763) -> Vec<(String, String)> {
764 let mut keys = vec![("name".to_string(), label.to_string())];
765
766 if let Some(src) = source_file {
768 keys.push(("context".to_string(), format!("{} in {}", label, src)));
769 }
770
771 if let Some(desc) = metadata.get("description").and_then(|v| v.as_str()) {
773 keys.push(("description".to_string(), desc.to_string()));
774 }
775
776 if let Some(rels) = metadata.get("relationships").and_then(|v| v.as_str()) {
778 keys.push(("relationship".to_string(), rels.to_string()));
779 }
780
781 keys
782}
783
784pub const RELATIONSHIP_HNSW_PREFIX: &str = "rel::";
790
791pub fn relationship_hnsw_id(stable_id: &str) -> String {
793 format!("{RELATIONSHIP_HNSW_PREFIX}{stable_id}")
794}
795
796pub fn relationship_search_keys(
804 source_label: &str,
805 relation_type: &str,
806 target_label: &str,
807 source_file: Option<&str>,
808) -> Vec<(String, String)> {
809 let base = format!("{source_label} {relation_type} {target_label}");
810 let mut keys = vec![
811 ("relation".to_string(), base.clone()),
812 (
813 "interaction".to_string(),
814 format!("how does {source_label} interact with {target_label}: {relation_type}"),
815 ),
816 ];
817 if let Some(sf) = source_file.filter(|s| !s.is_empty()) {
818 keys.push(("context".to_string(), format!("{base} in {sf}")));
819 }
820 keys
821}
822
823impl HnswService {
824 pub fn insert_relationship(
830 &self,
831 stable_id: &str,
832 embedding: Vec<f32>,
833 metadata: serde_json::Value,
834 ) {
835 self.insert(relationship_hnsw_id(stable_id), embedding, metadata);
836 }
837
838 pub fn search_relationships(&self, query: &[f32], top_k: usize) -> Vec<HnswSearchResult> {
843 if top_k == 0 {
844 return Vec::new();
845 }
846 let raw = self.search(query, top_k.saturating_mul(4).max(top_k));
847 let mut out = Vec::with_capacity(top_k);
848 for r in raw {
849 if r.id.starts_with(RELATIONSHIP_HNSW_PREFIX) {
850 out.push(r);
851 if out.len() >= top_k {
852 break;
853 }
854 }
855 }
856 out
857 }
858}
859
860#[cfg(test)]
863mod tests {
864 use super::*;
865
866 fn make_service() -> HnswService {
867 HnswService::new(HnswServiceConfig::default())
868 }
869
870 #[test]
871 fn new_service_empty() {
872 let svc = make_service();
873 assert!(svc.is_empty());
874 assert_eq!(svc.len(), 0);
875 }
876
877 #[test]
878 fn insert_and_len() {
879 let svc = make_service();
880 svc.insert("a".into(), vec![1.0, 0.0], serde_json::json!({}));
881 svc.insert("b".into(), vec![0.0, 1.0], serde_json::json!({}));
882 assert_eq!(svc.len(), 2);
883 assert!(!svc.is_empty());
884 }
885
886 #[test]
887 fn insert_upsert() {
888 let svc = make_service();
889 svc.insert("a".into(), vec![1.0, 0.0], serde_json::json!({"v": 1}));
890 svc.insert("a".into(), vec![0.0, 1.0], serde_json::json!({"v": 2}));
891 assert_eq!(svc.len(), 1);
892 }
893
894 #[test]
895 fn search_empty_returns_empty() {
896 let svc = make_service();
897 let results = svc.search(&[1.0, 0.0], 5);
898 assert!(results.is_empty());
899 }
900
901 #[test]
902 fn search_returns_results() {
903 let svc = make_service();
904 svc.insert("a".into(), vec![1.0, 0.0, 0.0], serde_json::json!({}));
905 svc.insert("b".into(), vec![0.0, 1.0, 0.0], serde_json::json!({}));
906 svc.insert("c".into(), vec![0.0, 0.0, 1.0], serde_json::json!({}));
907
908 let results = svc.search(&[1.0, 0.0, 0.0], 2);
909 assert_eq!(results.len(), 2);
910 assert_eq!(results[0].id, "a");
911 assert!((results[0].score - 1.0).abs() < 0.01);
912 }
913
914 #[test]
917 fn search_builds_path_table_and_guides() {
918 let svc = make_service();
919 for i in 0..24 {
920 let cluster = i % 3;
921 let mut emb = vec![0.02 * (i as f32); 4];
922 emb[cluster] = 1.0;
923 svc.insert(format!("n{i}"), emb, serde_json::json!({}));
924 }
925 assert!(!svc.path_table_ready());
926
927 let mut q = vec![0.0; 4];
928 q[0] = 1.0;
929 let results = svc.search(&q, 3);
930 assert!(!results.is_empty());
931 assert!(svc.path_table_ready());
932 assert!(
934 results[0].id.starts_with('n'),
935 "unexpected id {}",
936 results[0].id
937 );
938
939 svc.rebuild_path_table();
941 assert!(svc.path_table_ready());
942 let results2 = svc.search(&q, 3);
943 assert_eq!(results2[0].id, results[0].id);
944 }
945
946 #[test]
947 fn search_count_incremented() {
948 let svc = make_service();
949 assert_eq!(svc.search_count(), 0);
950 svc.search(&[1.0], 1);
951 svc.search(&[1.0], 1);
952 assert_eq!(svc.search_count(), 2);
953 }
954
955 #[test]
956 fn insert_count_incremented() {
957 let svc = make_service();
958 assert_eq!(svc.insert_count(), 0);
959 svc.insert("a".into(), vec![1.0], serde_json::json!({}));
960 svc.insert("b".into(), vec![0.0], serde_json::json!({}));
961 assert_eq!(svc.insert_count(), 2);
962 }
963
964 #[test]
965 fn clear_resets() {
966 let svc = make_service();
967 svc.insert("a".into(), vec![1.0], serde_json::json!({}));
968 svc.insert("b".into(), vec![0.0], serde_json::json!({}));
969 assert_eq!(svc.len(), 2);
970
971 svc.clear()
972 .expect("clear should succeed without governance engine");
973 assert!(svc.is_empty());
974 assert_eq!(svc.len(), 0);
975 assert_eq!(svc.insert_count(), 2);
977 }
978
979 #[test]
980 fn config_default() {
981 let cfg = HnswServiceConfig::default();
982 assert_eq!(cfg.ef_search, 100);
983 assert_eq!(cfg.ef_construction, 200);
984 assert_eq!(cfg.default_dimensions, 384);
985
986 let svc = HnswService::new(cfg);
987 let c = svc.config();
988 assert_eq!(c.ef_search, 100);
989 assert_eq!(c.ef_construction, 200);
990 assert_eq!(c.default_dimensions, 384);
991 }
992
993 #[test]
994 fn service_name_is_ecc_hnsw() {
995 let svc = make_service();
996 assert_eq!(svc.name(), "ecc.hnsw");
997 assert_eq!(svc.service_type(), ServiceType::Core);
998 }
999
1000 #[tokio::test]
1001 async fn service_lifecycle() {
1002 let svc = make_service();
1003 svc.start().await.unwrap();
1004 let health = svc.health_check().await;
1005 assert_eq!(health, HealthStatus::Healthy);
1006 svc.stop().await.unwrap();
1007 }
1008
1009 fn tmp_path(name: &str) -> std::path::PathBuf {
1012 std::env::temp_dir().join(format!(
1013 "hnsw_test_{name}_{}",
1014 std::time::SystemTime::now()
1015 .duration_since(std::time::UNIX_EPOCH)
1016 .unwrap()
1017 .as_nanos()
1018 ))
1019 }
1020
1021 #[test]
1022 fn persist_empty_index() {
1023 let svc = make_service();
1024 let path = tmp_path("empty");
1025 svc.save_to_file(&path).unwrap();
1026 let loaded = HnswService::load_from_file(&path).unwrap();
1027 assert!(loaded.is_empty());
1028 assert_eq!(loaded.len(), 0);
1029 let _ = std::fs::remove_file(&path);
1030 }
1031
1032 #[test]
1033 fn persist_index_with_vectors_search_matches() {
1034 let svc = make_service();
1035 svc.insert(
1036 "a".into(),
1037 vec![1.0, 0.0, 0.0],
1038 serde_json::json!({"tag": "first"}),
1039 );
1040 svc.insert(
1041 "b".into(),
1042 vec![0.0, 1.0, 0.0],
1043 serde_json::json!({"tag": "second"}),
1044 );
1045 svc.insert(
1046 "c".into(),
1047 vec![0.0, 0.0, 1.0],
1048 serde_json::json!({"tag": "third"}),
1049 );
1050
1051 let path = tmp_path("vectors");
1052 svc.save_to_file(&path).unwrap();
1053 let loaded = HnswService::load_from_file(&path).unwrap();
1054
1055 assert_eq!(loaded.len(), 3);
1056
1057 let results = loaded.search(&[1.0, 0.0, 0.0], 1);
1059 assert_eq!(results.len(), 1);
1060 assert_eq!(results[0].id, "a");
1061 assert!((results[0].score - 1.0).abs() < 0.01);
1062
1063 let _ = std::fs::remove_file(&path);
1064 }
1065
1066 #[test]
1069 fn multi_key_config_default() {
1070 let cfg = MultiKeyConfig::default();
1071 assert!(cfg.enabled);
1072 assert_eq!(cfg.max_keys_per_entity, 4);
1073 assert_eq!(cfg.key_types.len(), 3);
1074 }
1075
1076 #[test]
1077 fn insert_multi_key_creates_subentries() {
1078 let svc = make_service();
1079 let keys = vec![
1080 MultiKey {
1081 key_type: "name".into(),
1082 embedding: vec![1.0, 0.0, 0.0],
1083 },
1084 MultiKey {
1085 key_type: "context".into(),
1086 embedding: vec![0.7, 0.7, 0.0],
1087 },
1088 MultiKey {
1089 key_type: "description".into(),
1090 embedding: vec![0.0, 0.0, 1.0],
1091 },
1092 ];
1093
1094 svc.insert_multi_key(
1095 "entity_a".into(),
1096 &keys,
1097 serde_json::json!({"label": "FooBar"}),
1098 4,
1099 );
1100
1101 assert_eq!(svc.len(), 3);
1103 assert_eq!(svc.insert_count(), 3);
1104 }
1105
1106 #[test]
1107 fn insert_multi_key_respects_max_keys() {
1108 let svc = make_service();
1109 let keys = vec![
1110 MultiKey {
1111 key_type: "name".into(),
1112 embedding: vec![1.0, 0.0],
1113 },
1114 MultiKey {
1115 key_type: "context".into(),
1116 embedding: vec![0.0, 1.0],
1117 },
1118 MultiKey {
1119 key_type: "description".into(),
1120 embedding: vec![0.5, 0.5],
1121 },
1122 ];
1123
1124 svc.insert_multi_key("e1".into(), &keys, serde_json::json!({}), 2);
1125
1126 assert_eq!(svc.len(), 2);
1128 }
1129
1130 #[test]
1131 fn search_dedup_merges_multi_key_results() {
1132 let svc = make_service();
1133
1134 let keys_a = vec![
1136 MultiKey {
1137 key_type: "name".into(),
1138 embedding: vec![1.0, 0.0, 0.0],
1139 },
1140 MultiKey {
1141 key_type: "context".into(),
1142 embedding: vec![0.9, 0.1, 0.0],
1143 },
1144 ];
1145 svc.insert_multi_key(
1146 "entity_a".into(),
1147 &keys_a,
1148 serde_json::json!({"label": "A"}),
1149 4,
1150 );
1151
1152 svc.insert(
1154 "entity_b".into(),
1155 vec![0.0, 1.0, 0.0],
1156 serde_json::json!({"label": "B"}),
1157 );
1158
1159 let results = svc.search_dedup(&[1.0, 0.0, 0.0], 2);
1161
1162 let a_count = results.iter().filter(|r| r.id == "entity_a").count();
1164 assert_eq!(a_count, 1, "entity_a should be deduplicated to one result");
1165
1166 assert_eq!(results[0].id, "entity_a");
1168 }
1169
1170 #[test]
1171 fn search_dedup_preserves_non_multikey_ids() {
1172 let svc = make_service();
1173
1174 svc.insert("plain".into(), vec![1.0, 0.0], serde_json::json!({}));
1176
1177 let results = svc.search_dedup(&[1.0, 0.0], 1);
1178 assert_eq!(results.len(), 1);
1179 assert_eq!(results[0].id, "plain");
1181 }
1182
1183 #[test]
1184 fn search_dedup_respects_top_k() {
1185 let svc = make_service();
1186
1187 for i in 0..3 {
1189 let keys = vec![
1190 MultiKey {
1191 key_type: "name".into(),
1192 embedding: vec![1.0 - (i as f32 * 0.3), i as f32 * 0.3, 0.0],
1193 },
1194 MultiKey {
1195 key_type: "ctx".into(),
1196 embedding: vec![1.0 - (i as f32 * 0.2), i as f32 * 0.2, 0.1],
1197 },
1198 ];
1199 svc.insert_multi_key(format!("e{i}"), &keys, serde_json::json!({}), 4);
1200 }
1201
1202 let results = svc.search_dedup(&[1.0, 0.0, 0.0], 2);
1203 assert!(results.len() <= 2, "should return at most top_k results");
1204
1205 let ids: HashSet<_> = results.iter().map(|r| &r.id).collect();
1207 assert_eq!(ids.len(), results.len(), "all result IDs should be unique");
1208 }
1209
1210 #[test]
1213 fn entity_search_keys_name_only() {
1214 let keys = entity_search_keys("MyFunc", None, &serde_json::json!({}));
1215 assert_eq!(keys.len(), 1);
1216 assert_eq!(keys[0].0, "name");
1217 assert_eq!(keys[0].1, "MyFunc");
1218 }
1219
1220 #[test]
1221 fn entity_search_keys_with_source_file() {
1222 let keys = entity_search_keys("MyFunc", Some("src/lib.rs"), &serde_json::json!({}));
1223 assert_eq!(keys.len(), 2);
1224 assert_eq!(keys[1].0, "context");
1225 assert_eq!(keys[1].1, "MyFunc in src/lib.rs");
1226 }
1227
1228 #[test]
1229 fn entity_search_keys_with_description() {
1230 let meta = serde_json::json!({"description": "Handles user auth"});
1231 let keys = entity_search_keys("login", None, &meta);
1232 assert_eq!(keys.len(), 2);
1233 assert_eq!(keys[1].0, "description");
1234 assert_eq!(keys[1].1, "Handles user auth");
1235 }
1236
1237 #[test]
1238 fn entity_search_keys_all_fields() {
1239 let meta = serde_json::json!({
1240 "description": "Core auth handler",
1241 "relationships": "calls TokenService, used by Router"
1242 });
1243 let keys = entity_search_keys("login", Some("src/auth.rs"), &meta);
1244 assert_eq!(keys.len(), 4);
1245 assert_eq!(keys[0].0, "name");
1246 assert_eq!(keys[1].0, "context");
1247 assert_eq!(keys[2].0, "description");
1248 assert_eq!(keys[3].0, "relationship");
1249 }
1250
1251 #[test]
1254 fn relationship_hnsw_id_uses_prefix() {
1255 assert_eq!(relationship_hnsw_id("abc"), "rel::abc");
1256 assert!(relationship_hnsw_id("x").starts_with(RELATIONSHIP_HNSW_PREFIX));
1257 }
1258
1259 #[test]
1260 fn relationship_search_keys_interaction_phrasing() {
1261 let keys = relationship_search_keys("AuthService", "calls", "Database", Some("auth.rs"));
1262 assert!(keys.iter().any(|(k, _)| k == "relation"));
1263 assert!(keys.iter().any(|(k, _)| k == "interaction"));
1264 assert!(keys.iter().any(|(k, _)| k == "context"));
1265 let interaction = keys
1266 .iter()
1267 .find(|(k, _)| k == "interaction")
1268 .map(|(_, t)| t.as_str())
1269 .unwrap();
1270 assert!(interaction.contains("how does AuthService interact with Database"));
1271 }
1272
1273 #[test]
1274 fn insert_and_search_relationships() {
1275 let svc = make_service();
1276 svc.insert(
1278 "entity-auth".into(),
1279 vec![1.0, 0.0, 0.0],
1280 serde_json::json!({"kind": "entity"}),
1281 );
1282 svc.insert_relationship(
1284 "edge-auth-db",
1285 vec![0.0, 1.0, 0.0],
1286 serde_json::json!({
1287 "kind": "relationship",
1288 "source_label": "AuthService",
1289 "target_label": "Database",
1290 }),
1291 );
1292 assert_eq!(svc.len(), 2);
1293
1294 let hits = svc.search_relationships(&[0.0, 1.0, 0.0], 5);
1295 assert_eq!(hits.len(), 1);
1296 assert_eq!(hits[0].id, "rel::edge-auth-db");
1297 assert_eq!(hits[0].metadata["kind"], "relationship");
1298 }
1299}