Skip to main content

clawft_kernel/
hnsw_service.rs

1//! HNSW vector search as a kernel `SystemService`.
2//!
3//! Wraps `clawft_core::embeddings::hnsw_store::HnswStore` behind a
4//! `Mutex` so that the service satisfies `Send + Sync` and can be
5//! registered in the `ServiceRegistry`.
6//!
7//! This module is compiled only when the `ecc` feature is enabled.
8
9use 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// ── Configuration ────────────────────────────────────────────────────────
30
31/// Configuration for the [`HnswService`].
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct HnswServiceConfig {
34    /// ef_search parameter forwarded to `HnswStore`.
35    pub ef_search: usize,
36    /// ef_construction parameter forwarded to `HnswStore`.
37    pub ef_construction: usize,
38    /// Default embedding dimensionality (informational; not enforced by the store).
39    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// ── Multi-key types ─────────────────────────────────────────────────────
53
54/// Configuration for multi-key HNSW indexing.
55///
56/// When enabled, entities can be indexed under multiple embedding vectors
57/// (e.g., by name, context, description) so that vague queries have
58/// better recall.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct MultiKeyConfig {
61    /// Whether multi-key indexing is enabled.
62    pub enabled: bool,
63    /// Maximum number of keys per entity (default: 4).
64    pub max_keys_per_entity: usize,
65    /// Recognised key types (default: `["name", "context", "description"]`).
66    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/// A single search key for multi-key insertion.
84///
85/// Each key carries a `key_type` label (e.g., `"name"`, `"context"`) and
86/// the pre-computed embedding vector for that textual representation.
87#[derive(Debug, Clone)]
88pub struct MultiKey {
89    /// The kind of key (`"name"`, `"context"`, `"relationship"`, `"docstring"`, etc.).
90    pub key_type: String,
91    /// The embedding vector for this key's textual representation.
92    pub embedding: Vec<f32>,
93}
94
95// ── Search result ────────────────────────────────────────────────────────
96
97/// A single search result returned by [`HnswService::search`].
98#[derive(Debug, Clone)]
99pub struct HnswSearchResult {
100    /// Entry identifier.
101    pub id: String,
102    /// Cosine similarity score (higher is better).
103    pub score: f32,
104    /// Arbitrary metadata stored alongside the embedding.
105    pub metadata: serde_json::Value,
106}
107
108// ── Service ──────────────────────────────────────────────────────────────
109
110/// Kernel service wrapping the HNSW vector store.
111///
112/// All mutable access to the inner `HnswStore` is serialized through a
113/// [`Mutex`]. Atomic counters track insert and search operations for
114/// observability without requiring the lock.
115pub struct HnswService {
116    store: Mutex<HnswStore>,
117    config: HnswServiceConfig,
118    insert_count: AtomicU64,
119    search_count: AtomicU64,
120    /// Monotonic epoch counter -- bumped on every mutation.
121    epoch: AtomicU64,
122    /// EML-based adaptive optimization (ef, rebuild, distance, path).
123    eml: Mutex<HnswEmlManager>,
124    /// Chain manager for exochain event logging.
125    #[cfg(feature = "exochain")]
126    chain_manager: Option<Arc<ChainManager>>,
127    /// Governance engine for gating destructive operations.
128    #[cfg(feature = "exochain")]
129    governance_engine: Option<Arc<GovernanceEngine>>,
130}
131
132impl HnswService {
133    /// Create a new service with the given configuration.
134    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    /// Wrap an existing [`HnswStore`] (e.g. after loading a snapshot).
151    ///
152    /// Counters and epoch start at zero; callers that restore backend-level
153    /// epoch (e.g. [`crate::vector_hnsw::HnswBackend`]) set it separately.
154    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    /// Snapshot all stored entries for composite backend persistence.
170    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    /// Current `(ef_search, ef_construction)` used by the inner store.
176    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    /// Hard-delete an entry by string id. Returns `true` if it was present.
182    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    /// Create a new service with custom EML configuration.
195    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    /// Set the chain manager for exochain event logging.
212    #[cfg(feature = "exochain")]
213    pub fn set_chain_manager(&mut self, cm: Arc<ChainManager>) {
214        self.chain_manager = Some(cm);
215    }
216
217    /// Set the governance engine for gating destructive operations.
218    #[cfg(feature = "exochain")]
219    pub fn set_governance_engine(&mut self, engine: Arc<GovernanceEngine>) {
220        self.governance_engine = Some(engine);
221    }
222
223    /// Insert an embedding with associated metadata (upsert semantics).
224    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        // Invalidate path table on mutation — rebuilt lazily on next search.
231        if let Ok(mut eml) = self.eml.lock() {
232            eml.clear_path_table();
233        }
234
235        // Chain logging: hnsw.insert
236        #[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    /// Return the current monotonic epoch.
249    pub fn current_epoch(&self) -> u64 {
250        self.epoch.load(Ordering::SeqCst)
251    }
252
253    /// Search for the `top_k` nearest embeddings to `query`.
254    ///
255    /// When EML is enabled and trained, the beam width (ef_search) is
256    /// adapted per-query and the rebuild model is consulted after each
257    /// search. When a region → entry-node path table is available
258    /// (WEFT-385 / HNSW-EML #4), search is guided through predicted
259    /// entry nodes. Training data is recorded for continuous learning.
260    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        // EML: adaptive ef_search — set before query so the next rebuild
268        // uses the learned value.
269        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        // EML: adaptive rebuild — trigger early when recall is predicted
275        // to have degraded, even if the mutation count hasn't hit the
276        // static threshold.
277        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            // Path table is tied to store layout — rebuild after graph rebuild.
281            let pairs = store.entry_id_embeddings();
282            eml.rebuild_path_table(&pairs);
283        }
284
285        // Lazy path-table build: first search after enough inserts.
286        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            // SAFETY: path_table borrowed only for the guided call; we re-fetch
295            // prediction metadata after releasing the table ref via clone of
296            // the needed fields. Clone table snapshot for query_guided.
297            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        // ExoChain: append multi-signal observation for training provenance.
371        #[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    /// Explicitly rebuild the region → entry-node path table from the
395    /// current store contents (WEFT-385).
396    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    /// Whether guided path prediction is active.
404    pub fn path_table_ready(&self) -> bool {
405        self.eml
406            .lock()
407            .expect("EML lock poisoned")
408            .has_path_table()
409    }
410
411    /// Brute-force ground-truth search for recall measurement.
412    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    /// Measure recall against brute-force and feed the EML rebuild model.
418    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    /// Build a tiered index from the current store contents.
457    ///
458    /// Uses the EML distance model's learned dimension ordering when
459    /// available, falling back to the first N dimensions otherwise.
460    /// The returned `TieredSearch` owns separate coarse/full indexes.
461    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) // placeholder — see below
467            .collect();
468
469        // Collect entries from the store. HnswStore doesn't expose an
470        // iterator, so we search for everything via brute-force at top-N.
471        // This is a build-time cost, not per-query.
472        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    /// Borrow the EML manager (for status/benchmarking).
482    pub fn eml_status(&self) -> crate::hnsw_eml::HnswEmlStatus {
483        self.eml.lock().expect("EML lock poisoned").status()
484    }
485
486    /// Batch search: acquire the lock once, perform all queries, release.
487    ///
488    /// This avoids per-query mutex acquisition overhead when processing
489    /// multiple embeddings in a single tick (e.g., DEMOCRITUS loop).
490    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    /// Insert an entity with multiple search keys.
511    ///
512    /// Each key generates a separate embedding entry in the store, all
513    /// sharing the same `primary_id` prefix. The stored ID has the form
514    /// `"{primary_id}::{key_type}"`. This allows the entity to be found
515    /// via different query formulations (name, context, description, etc.).
516    ///
517    /// Keys beyond `max_keys` are silently dropped.
518    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    /// Search with deduplication by primary entity ID.
533    ///
534    /// Because multi-key insertion stores several embeddings for one entity,
535    /// a single query may match multiple keys of the same entity. This
536    /// method searches for `top_k * 3` candidates, deduplicates by
537    /// stripping the `"::key_type"` suffix, and returns up to `top_k`
538    /// unique entities. The best (highest) score among duplicates is kept.
539    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    /// Return the number of entries currently in the store.
567    pub fn len(&self) -> usize {
568        let store = self.store.lock().expect("HnswStore lock poisoned");
569        store.len()
570    }
571
572    /// Return `true` if the store contains no entries.
573    pub fn is_empty(&self) -> bool {
574        let store = self.store.lock().expect("HnswStore lock poisoned");
575        store.is_empty()
576    }
577
578    /// Total number of insert operations since service creation.
579    pub fn insert_count(&self) -> u64 {
580        self.insert_count.load(Ordering::Relaxed)
581    }
582
583    /// Total number of search operations since service creation.
584    pub fn search_count(&self) -> u64 {
585        self.search_count.load(Ordering::Relaxed)
586    }
587
588    /// Replace the inner store with a fresh, empty instance.
589    ///
590    /// Useful for calibration cleanup. Counters are **not** reset.
591    ///
592    /// Returns `Err` if the governance engine denies the bulk destruction.
593    pub fn clear(&self) -> Result<(), String> {
594        // Governance gate: bulk destruction (hnsw clear).
595        #[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        // Chain logging: hnsw.clear
617        #[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    /// Borrow the service configuration.
633    pub fn config(&self) -> &HnswServiceConfig {
634        &self.config
635    }
636
637    /// Persist the HNSW store to a JSON file.
638    ///
639    /// Delegates to the underlying [`HnswStore::save`]. The HNSW index
640    /// graph is not serialized; it will be rebuilt on load.
641    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        // Chain logging: hnsw.save
646        #[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    /// Load an HNSW store from a JSON file and create a new service.
663    ///
664    /// Delegates to [`HnswStore::load`]. The HNSW index is rebuilt
665    /// from the saved entries. Counters start at zero.
666    ///
667    /// Note: chain logging for `hnsw.load` requires calling
668    /// [`set_chain_manager`] after construction; use
669    /// [`load_from_file_logged`] when a chain manager is available.
670    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    /// Load an HNSW store from a JSON file with chain logging.
682    ///
683    /// Same as [`load_from_file`] but emits an `hnsw.load` chain event.
684    #[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// ── SystemService impl ──────────────────────────────────────────────────
725
726#[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
749// ── Key-generation helpers ──────────────────────────────────────────────
750
751/// Generate search-key text pairs for a graphify [`Entity`]-like value.
752///
753/// Each returned tuple is `(key_type, text)`. The caller is responsible
754/// for embedding the `text` into a vector (e.g., via the embedding service)
755/// before passing it to [`HnswService::insert_multi_key`].
756///
757/// The function accepts the entity fields directly so it does not pull in
758/// a compile-time dependency on `clawft-graphify`.
759pub 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    // Add source-file context key.
767    if let Some(src) = source_file {
768        keys.push(("context".to_string(), format!("{} in {}", label, src)));
769    }
770
771    // Add description key from metadata, if present.
772    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    // Add relationship summary key from metadata, if present.
777    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
784// ── Relationship (edge) embedding helpers — WEFT-375 / LightRAG P5 ──────
785
786/// HNSW id prefix for relationship (edge) embeddings.
787///
788/// Full form: `rel::{stable_id}`. Matches `clawft_graphify::EDGE_HNSW_PREFIX`.
789pub const RELATIONSHIP_HNSW_PREFIX: &str = "rel::";
790
791/// Build the HNSW entry id for a relationship embedding.
792pub fn relationship_hnsw_id(stable_id: &str) -> String {
793    format!("{RELATIONSHIP_HNSW_PREFIX}{stable_id}")
794}
795
796/// Generate embed texts for a relationship edge (LightRAG-style unit).
797///
798/// Returns `(key_type, text)` pairs. The primary `"relation"` key is the
799/// canonical edge unit (`"{src} {rel} {tgt}"`). An `"interaction"` key uses
800/// natural-language phrasing for "how does X interact with Y?" queries.
801///
802/// Field types are plain strings so this module stays free of graphify deps.
803pub 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    /// Insert a relationship (edge) embedding under `rel::{stable_id}`.
825    ///
826    /// Metadata should include at least `kind: "relationship"` and the
827    /// edge endpoints so consumers can reconstruct the hit without a
828    /// separate graph lookup.
829    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    /// Search and keep only relationship-prefixed hits (`rel::`).
839    ///
840    /// Oversamples the underlying store so entity vectors do not starve
841    /// edge results when both share one HNSW index.
842    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// ── Tests ────────────────────────────────────────────────────────────────
861
862#[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    /// WEFT-385: path table builds lazily once store has ≥16 entries and
915    /// guided search still returns the nearest neighbor.
916    #[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        // Cluster-0 entries are n0, n3, n6, ...
933        assert!(
934            results[0].id.starts_with('n'),
935            "unexpected id {}",
936            results[0].id
937        );
938
939        // Explicit rebuild stays ready.
940        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        // Counters are preserved after clear.
976        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    // ── Persistence tests ────────────────────────────────────────────
1010
1011    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        // Search should return the same nearest neighbor.
1058        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    // ── Multi-key tests ─────────────────────────────────────────────
1067
1068    #[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        // Three sub-entries should exist.
1102        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        // Only 2 keys should have been inserted.
1127        assert_eq!(svc.len(), 2);
1128    }
1129
1130    #[test]
1131    fn search_dedup_merges_multi_key_results() {
1132        let svc = make_service();
1133
1134        // Insert entity_a with two keys.
1135        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        // Insert entity_b with one key.
1153        svc.insert(
1154            "entity_b".into(),
1155            vec![0.0, 1.0, 0.0],
1156            serde_json::json!({"label": "B"}),
1157        );
1158
1159        // Query near entity_a's embeddings.
1160        let results = svc.search_dedup(&[1.0, 0.0, 0.0], 2);
1161
1162        // entity_a should appear only once despite having two matching keys.
1163        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        // The top result should be entity_a (closest to the query).
1167        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        // Insert a regular (non-multi-key) entry.
1175        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        // ID should be preserved as-is (no "::" suffix to strip).
1180        assert_eq!(results[0].id, "plain");
1181    }
1182
1183    #[test]
1184    fn search_dedup_respects_top_k() {
1185        let svc = make_service();
1186
1187        // Insert 3 entities with 2 keys each.
1188        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        // All IDs should be unique.
1206        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    // ── entity_search_keys helper tests ─────────────────────────────
1211
1212    #[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    // ── WEFT-375 relationship (edge) embedding helpers ───────────────
1252
1253    #[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        // Entity vector (no rel:: prefix).
1277        svc.insert(
1278            "entity-auth".into(),
1279            vec![1.0, 0.0, 0.0],
1280            serde_json::json!({"kind": "entity"}),
1281        );
1282        // Edge vector.
1283        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}