From 21e74ac40a6a5f25b769cfc688319a85013d19cf Mon Sep 17 00:00:00 2001 From: It Apilium Date: Wed, 11 Mar 2026 19:30:41 +0100 Subject: [PATCH 1/2] feat: HNSW vector index for Ineru + vector search endpoints (v0.4.2) - Add HNSW approximate nearest-neighbor search (hnsw.rs) with proper topology-preserving serialization, dynamic entry point, natural level distribution, and neighbor pruning on deletion - Wire HNSW index into LTM for semantic_search acceleration - Add Cortex REST endpoints: POST /memory/search, GET /memory/index/stats, POST /memory/index/rebuild - Bump all crates to 0.4.2 --- Cargo.lock | 20 +- crates/ai_hash/Cargo.toml | 2 +- crates/aingle/Cargo.toml | 2 +- crates/aingle_ai/Cargo.toml | 2 +- crates/aingle_contracts/Cargo.toml | 2 +- crates/aingle_cortex/Cargo.toml | 2 +- crates/aingle_cortex/src/rest/memory.rs | 101 +++ crates/aingle_graph/Cargo.toml | 2 +- crates/aingle_logic/Cargo.toml | 2 +- crates/aingle_minimal/Cargo.toml | 2 +- crates/aingle_viz/Cargo.toml | 2 +- crates/aingle_zk/Cargo.toml | 2 +- crates/aingle_zome_types/Cargo.toml | 2 +- crates/ineru/Cargo.toml | 2 +- crates/ineru/src/hnsw.rs | 942 ++++++++++++++++++++++++ crates/ineru/src/lib.rs | 1 + crates/ineru/src/ltm.rs | 73 ++ crates/kaneru/Cargo.toml | 2 +- 18 files changed, 1140 insertions(+), 23 deletions(-) create mode 100644 crates/ineru/src/hnsw.rs diff --git a/Cargo.lock b/Cargo.lock index 0a0b6e5..212bf9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,7 +86,7 @@ dependencies = [ [[package]] name = "aingle_ai" -version = "0.4.1" +version = "0.4.2" dependencies = [ "blake2", "candle-core 0.9.2", @@ -108,7 +108,7 @@ dependencies = [ [[package]] name = "aingle_contracts" -version = "0.4.1" +version = "0.4.2" dependencies = [ "blake3", "dashmap 6.1.0", @@ -127,7 +127,7 @@ dependencies = [ [[package]] name = "aingle_cortex" -version = "0.4.1" +version = "0.4.2" dependencies = [ "aingle_graph", "aingle_logic", @@ -173,7 +173,7 @@ dependencies = [ [[package]] name = "aingle_graph" -version = "0.4.1" +version = "0.4.2" dependencies = [ "bincode", "blake3", @@ -194,7 +194,7 @@ dependencies = [ [[package]] name = "aingle_logic" -version = "0.4.1" +version = "0.4.2" dependencies = [ "aingle_graph", "chrono", @@ -210,7 +210,7 @@ dependencies = [ [[package]] name = "aingle_minimal" -version = "0.4.1" +version = "0.4.2" dependencies = [ "async-io", "async-tungstenite", @@ -252,7 +252,7 @@ dependencies = [ [[package]] name = "aingle_viz" -version = "0.4.1" +version = "0.4.2" dependencies = [ "aingle_graph", "aingle_minimal", @@ -274,7 +274,7 @@ dependencies = [ [[package]] name = "aingle_zk" -version = "0.4.1" +version = "0.4.2" dependencies = [ "blake3", "bulletproofs", @@ -3977,7 +3977,7 @@ dependencies = [ [[package]] name = "ineru" -version = "0.4.1" +version = "0.4.2" dependencies = [ "bincode", "blake3", @@ -4236,7 +4236,7 @@ dependencies = [ [[package]] name = "kaneru" -version = "0.4.1" +version = "0.4.2" dependencies = [ "chrono", "criterion", diff --git a/crates/ai_hash/Cargo.toml b/crates/ai_hash/Cargo.toml index 62d9091..9c16772 100644 --- a/crates/ai_hash/Cargo.toml +++ b/crates/ai_hash/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ai_hash" -version = "0.4.1" +version = "0.4.2" authors = ["Apilium Technologies "] keywords = [ "aingle", "ai", "hash", "blake", "blake2b" ] categories = [ "cryptography" ] diff --git a/crates/aingle/Cargo.toml b/crates/aingle/Cargo.toml index 6b15d28..fc82e0d 100644 --- a/crates/aingle/Cargo.toml +++ b/crates/aingle/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aingle" -version = "0.4.1" +version = "0.4.2" description = "AIngle, a framework for distributed applications" license = "Apache-2.0 OR LicenseRef-Commercial" homepage = "https://apilium.com" diff --git a/crates/aingle_ai/Cargo.toml b/crates/aingle_ai/Cargo.toml index feeae23..48095ae 100644 --- a/crates/aingle_ai/Cargo.toml +++ b/crates/aingle_ai/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aingle_ai" -version = "0.4.1" +version = "0.4.2" description = "AI integration layer for AIngle - Ineru, Nested Learning, Kaneru" license = "Apache-2.0 OR LicenseRef-Commercial" repository = "https://github.com/ApiliumCode/aingle" diff --git a/crates/aingle_contracts/Cargo.toml b/crates/aingle_contracts/Cargo.toml index 015e475..3c9f735 100644 --- a/crates/aingle_contracts/Cargo.toml +++ b/crates/aingle_contracts/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aingle_contracts" -version = "0.4.1" +version = "0.4.2" description = "Smart Contracts DSL and WASM Runtime for AIngle" license = "Apache-2.0 OR LicenseRef-Commercial" repository = "https://github.com/ApiliumCode/aingle" diff --git a/crates/aingle_cortex/Cargo.toml b/crates/aingle_cortex/Cargo.toml index fa2f8a2..03693b3 100644 --- a/crates/aingle_cortex/Cargo.toml +++ b/crates/aingle_cortex/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aingle_cortex" -version = "0.4.1" +version = "0.4.2" description = "Córtex API - REST/GraphQL/SPARQL interface for AIngle semantic graphs" license = "Apache-2.0 OR LicenseRef-Commercial" repository = "https://github.com/ApiliumCode/aingle" diff --git a/crates/aingle_cortex/src/rest/memory.rs b/crates/aingle_cortex/src/rest/memory.rs index dcc7413..cb5999b 100644 --- a/crates/aingle_cortex/src/rest/memory.rs +++ b/crates/aingle_cortex/src/rest/memory.rs @@ -328,6 +328,103 @@ pub async fn restore_checkpoint( Ok(StatusCode::OK) } +// ============================================================================ +// Vector Search (Motomeru) +// ============================================================================ + +#[derive(Debug, Deserialize)] +pub struct VectorSearchRequest { + pub embedding: Vec, + pub k: usize, + #[serde(default = "default_min_similarity")] + pub min_similarity: f32, + pub entry_type: Option, + pub tags: Option>, +} + +fn default_min_similarity() -> f32 { + 0.0 +} + +#[derive(Debug, Serialize)] +pub struct VectorIndexStatsDto { + pub point_count: usize, + pub deleted_count: usize, + pub dimensions: usize, + pub memory_bytes: usize, +} + +/// Vector search over memory entries using HNSW index. +pub async fn vector_search( + State(state): State, + Json(req): Json, +) -> Result>> { + let memory = state.memory.read().await; + let results = memory.ltm.vector_search_memories(&req.embedding, req.k, req.min_similarity); + + let mut dtos: Vec = results + .into_iter() + .map(|(entry, similarity)| MemoryResultDto { + id: entry.id.to_hex(), + entry_type: entry.entry_type.clone(), + data: entry.data.clone(), + tags: entry.tags.iter().map(|t| t.0.clone()).collect(), + importance: entry.metadata.importance, + relevance: similarity, + source: "LongTerm".to_string(), + created_at: entry.metadata.created_at.0.to_string(), + last_accessed: entry.metadata.last_accessed.0.to_string(), + access_count: entry.metadata.access_count, + }) + .collect(); + + // Apply optional filters + if let Some(ref entry_type) = req.entry_type { + dtos.retain(|d| &d.entry_type == entry_type); + } + if let Some(ref tags) = req.tags { + if !tags.is_empty() { + dtos.retain(|d| tags.iter().any(|t| d.tags.contains(t))); + } + } + + Ok(Json(dtos)) +} + +/// Get HNSW vector index statistics. +pub async fn vector_index_stats( + State(state): State, +) -> Result> { + let memory = state.memory.read().await; + let stats = memory.ltm.hnsw_index() + .map(|idx| idx.stats()) + .unwrap_or(ineru::hnsw::HnswStats { + point_count: 0, + deleted_count: 0, + dimensions: 0, + memory_bytes: 0, + }); + + Ok(Json(VectorIndexStatsDto { + point_count: stats.point_count, + deleted_count: stats.deleted_count, + dimensions: stats.dimensions, + memory_bytes: stats.memory_bytes, + })) +} + +/// Force rebuild of the HNSW vector index. +pub async fn rebuild_vector_index( + State(state): State, +) -> Result { + let mut memory = state.memory.write().await; + if let Some(hnsw) = memory.ltm.hnsw_index_mut() { + hnsw.rebuild(); + tracing::info!("HNSW index rebuilt, {} active points", hnsw.len()); + } + Ok(StatusCode::OK) +} + // ============================================================================ // Helpers // ============================================================================ @@ -376,4 +473,8 @@ pub fn memory_router() -> axum::Router { .route("/api/v1/memory/checkpoint", post(checkpoint)) .route("/api/v1/memory/checkpoints", get(list_checkpoints)) .route("/api/v1/memory/restore/{id}", post(restore_checkpoint)) + // Motomeru: HNSW vector search endpoints + .route("/api/v1/memory/search", post(vector_search)) + .route("/api/v1/memory/index/stats", get(vector_index_stats)) + .route("/api/v1/memory/index/rebuild", post(rebuild_vector_index)) } diff --git a/crates/aingle_graph/Cargo.toml b/crates/aingle_graph/Cargo.toml index 80988e6..b4b6cdd 100644 --- a/crates/aingle_graph/Cargo.toml +++ b/crates/aingle_graph/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aingle_graph" -version = "0.4.1" +version = "0.4.2" description = "Native GraphDB for AIngle - Semantic triple store with SPO indexes" license = "Apache-2.0 OR LicenseRef-Commercial" repository = "https://github.com/ApiliumCode/aingle" diff --git a/crates/aingle_logic/Cargo.toml b/crates/aingle_logic/Cargo.toml index 2833965..d125b16 100644 --- a/crates/aingle_logic/Cargo.toml +++ b/crates/aingle_logic/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aingle_logic" -version = "0.4.1" +version = "0.4.2" description = "Proof-of-Logic validation engine for AIngle semantic graphs" license = "Apache-2.0 OR LicenseRef-Commercial" repository = "https://github.com/ApiliumCode/aingle" diff --git a/crates/aingle_minimal/Cargo.toml b/crates/aingle_minimal/Cargo.toml index a186645..4fd5e60 100644 --- a/crates/aingle_minimal/Cargo.toml +++ b/crates/aingle_minimal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aingle_minimal" -version = "0.4.1" +version = "0.4.2" description = "Ultra-light AIngle node for IoT devices (<1MB RAM)" license = "Apache-2.0 OR LicenseRef-Commercial" repository = "https://github.com/ApiliumCode/aingle" diff --git a/crates/aingle_viz/Cargo.toml b/crates/aingle_viz/Cargo.toml index fd5b506..d99c3e3 100644 --- a/crates/aingle_viz/Cargo.toml +++ b/crates/aingle_viz/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aingle_viz" -version = "0.4.1" +version = "0.4.2" description = "DAG Visualization for AIngle - Web-based graph explorer" license = "Apache-2.0 OR LicenseRef-Commercial" repository = "https://github.com/ApiliumCode/aingle" diff --git a/crates/aingle_zk/Cargo.toml b/crates/aingle_zk/Cargo.toml index 12e1d9a..5b7e352 100644 --- a/crates/aingle_zk/Cargo.toml +++ b/crates/aingle_zk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aingle_zk" -version = "0.4.1" +version = "0.4.2" description = "Zero-Knowledge Proofs for AIngle - privacy-preserving cryptographic primitives" license = "Apache-2.0 OR LicenseRef-Commercial" repository = "https://github.com/ApiliumCode/aingle" diff --git a/crates/aingle_zome_types/Cargo.toml b/crates/aingle_zome_types/Cargo.toml index 8b2ed7a..5896532 100644 --- a/crates/aingle_zome_types/Cargo.toml +++ b/crates/aingle_zome_types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aingle_zome_types" -version = "0.4.1" +version = "0.4.2" description = "AIngle zome types" license = "Apache-2.0 OR LicenseRef-Commercial" homepage = "https://apilium.com" diff --git a/crates/ineru/Cargo.toml b/crates/ineru/Cargo.toml index 21c82c1..e28d6f7 100644 --- a/crates/ineru/Cargo.toml +++ b/crates/ineru/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ineru" -version = "0.4.1" +version = "0.4.2" description = "Ineru: Neural-inspired memory system for AIngle AI agents" license = "Apache-2.0 OR LicenseRef-Commercial" repository = "https://github.com/ApiliumCode/aingle" diff --git a/crates/ineru/src/hnsw.rs b/crates/ineru/src/hnsw.rs new file mode 100644 index 0000000..e88bd55 --- /dev/null +++ b/crates/ineru/src/hnsw.rs @@ -0,0 +1,942 @@ +// Copyright 2019-2026 Apilium Technologies OÜ. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR Commercial + +//! HNSW (Hierarchical Navigable Small World) Vector Index +//! +//! Feature-gated HNSW index for sub-millisecond approximate nearest-neighbor +//! search on embedding vectors. Uses cosine distance as the similarity metric. + +use crate::error::{Error, Result}; +use crate::types::MemoryId; +use serde::{Deserialize, Serialize}; +use std::collections::{BinaryHeap, HashMap, HashSet}; +use std::cmp::Ordering; + +// ============================================================================ +// Config +// ============================================================================ + +/// Configuration for the HNSW index. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HnswConfig { + /// Number of bi-directional links per element (default: 16). + pub m: usize, + /// Size of the dynamic candidate list during construction (default: 100). + pub ef_construction: usize, + /// Size of the dynamic candidate list during search (default: 50). + pub ef_search: usize, +} + +impl Default for HnswConfig { + fn default() -> Self { + Self { + m: 16, + ef_construction: 100, + ef_search: 50, + } + } +} + +// ============================================================================ +// Stats +// ============================================================================ + +/// Statistics about the HNSW index. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HnswStats { + pub point_count: usize, + pub deleted_count: usize, + pub dimensions: usize, + pub max_layer: usize, + pub memory_bytes: usize, +} + +// ============================================================================ +// Internal types +// ============================================================================ + +#[derive(Clone)] +struct HnswPoint { + id: MemoryId, + embedding: Vec, + neighbors: Vec>, // neighbors per layer + deleted: bool, +} + +/// A scored candidate for the priority queue. +#[derive(Clone)] +struct Candidate { + index: usize, + distance: f32, +} + +impl PartialEq for Candidate { + fn eq(&self, other: &Self) -> bool { + self.distance == other.distance + } +} + +impl Eq for Candidate {} + +/// Min-heap by default (smallest distance first). +impl Ord for Candidate { + fn cmp(&self, other: &Self) -> Ordering { + // Reverse for min-heap behavior (BinaryHeap is max-heap) + other.distance.partial_cmp(&self.distance).unwrap_or(Ordering::Equal) + } +} + +impl PartialOrd for Candidate { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Max-heap candidate (furthest first). +#[derive(Clone)] +struct MaxCandidate { + index: usize, + distance: f32, +} + +impl PartialEq for MaxCandidate { + fn eq(&self, other: &Self) -> bool { + self.distance == other.distance + } +} + +impl Eq for MaxCandidate {} + +impl Ord for MaxCandidate { + fn cmp(&self, other: &Self) -> Ordering { + self.distance.partial_cmp(&other.distance).unwrap_or(Ordering::Equal) + } +} + +impl PartialOrd for MaxCandidate { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +// ============================================================================ +// HNSW Index +// ============================================================================ + +/// HNSW index for approximate nearest-neighbor search. +pub struct HnswIndex { + points: Vec, + id_to_index: HashMap, + config: HnswConfig, + max_layer: usize, + entry_point: Option, + dirty: bool, + deleted_count: usize, + dimensions: usize, +} + +impl HnswIndex { + /// Create a new empty HNSW index. + pub fn new(config: HnswConfig) -> Self { + Self { + points: Vec::new(), + id_to_index: HashMap::new(), + config, + max_layer: 0, + entry_point: None, + dirty: false, + deleted_count: 0, + dimensions: 0, + } + } + + /// Insert a point into the index. + pub fn insert(&mut self, id: MemoryId, embedding: Vec) { + if embedding.is_empty() { + return; + } + + // If first point, set dimensions + if self.points.is_empty() { + self.dimensions = embedding.len(); + } + + // Remove existing point with same ID + if self.id_to_index.contains_key(&id) { + self.remove(&id); + } + + let index = self.points.len(); + let level = self.random_level(); + + let mut neighbors = Vec::with_capacity(level + 1); + for _ in 0..=level { + neighbors.push(Vec::new()); + } + + let point = HnswPoint { + id: id.clone(), + embedding, + neighbors, + deleted: false, + }; + + self.points.push(point); + self.id_to_index.insert(id, index); + + // Connect to neighbors and update entry point (M2 fix) + if let Some(ep) = self.entry_point { + if level > self.max_layer { + // New point has a higher layer — it becomes the new entry point + self.entry_point = Some(index); + self.max_layer = level; + } + self.connect_new_point(index, ep); + } else { + self.entry_point = Some(index); + self.max_layer = level; + } + + self.dirty = true; + } + + /// Search for k nearest neighbors to the query vector. + /// Returns (MemoryId, similarity_score) pairs sorted by similarity (highest first). + pub fn search(&self, query: &[f32], k: usize) -> Vec<(MemoryId, f32)> { + if self.points.is_empty() || query.is_empty() { + return Vec::new(); + } + + let ep = match self.entry_point { + Some(ep) if !self.points[ep].deleted => ep, + _ => return Vec::new(), + }; + + // Greedy search from top layer to layer 1 + let mut current = ep; + for layer in (1..=self.max_layer).rev() { + current = self.greedy_search(current, query, layer); + } + + // Search at layer 0 with ef_search candidates + let candidates = self.search_layer(current, query, self.config.ef_search, 0); + + // Take top-k results, convert distance to similarity + candidates + .into_iter() + .filter(|c| !self.points[c.index].deleted) + .take(k) + .map(|c| { + let similarity = 1.0 - c.distance; // cosine distance → similarity + (self.points[c.index].id.clone(), similarity) + }) + .collect() + } + + /// Mark a point as deleted (lazy deletion) and prune it from neighbor lists (M4 fix). + pub fn remove(&mut self, id: &MemoryId) { + if let Some(&point_index) = self.id_to_index.get(id) { + if !self.points[point_index].deleted { + self.points[point_index].deleted = true; + self.deleted_count += 1; + self.dirty = true; + + // M4: Prune this point from ALL neighbor lists of every other + // point. A simple scan over the deleted point's own neighbors + // is insufficient because neighbor links can be asymmetric + // after pruning. + for i in 0..self.points.len() { + if i == point_index { + continue; + } + for layer in &mut self.points[i].neighbors { + layer.retain(|&n| n != point_index); + } + } + } + } + } + + /// Rebuild the index, removing deleted points. + pub fn rebuild(&mut self) { + let active_points: Vec<(MemoryId, Vec)> = self.points + .iter() + .filter(|p| !p.deleted) + .map(|p| (p.id.clone(), p.embedding.clone())) + .collect(); + + // Reset + self.points.clear(); + self.id_to_index.clear(); + self.entry_point = None; + self.max_layer = 0; + self.deleted_count = 0; + + // Re-insert all active points + for (id, embedding) in active_points { + self.insert(id, embedding); + } + + self.dirty = true; + } + + /// Serialize the index to bytes (M1 fix — preserves full topology). + pub fn serialize(&self) -> Result> { + let points: Vec = self.points + .iter() + .map(|p| HnswPointSnapshot { + id: p.id.clone(), + embedding: p.embedding.clone(), + neighbors: p.neighbors.clone(), + deleted: p.deleted, + }) + .collect(); + + let snapshot = HnswSnapshotV2 { + version: 2, + max_layer: self.max_layer, + entry_point: self.entry_point, + ef_construction: self.config.ef_construction, + ef_search: self.config.ef_search, + m: self.config.m, + points, + }; + + serde_json::to_vec(&snapshot) + .map_err(|e| Error::internal(format!("HNSW serialize: {e}"))) + } + + /// Deserialize an index from bytes (M1 fix — backward-compatible). + pub fn deserialize(data: &[u8]) -> Result { + // Try v2 format first (topology-preserving) + if let Ok(snapshot) = serde_json::from_slice::(data) { + if snapshot.version >= 2 { + let config = HnswConfig { + m: snapshot.m, + ef_construction: snapshot.ef_construction, + ef_search: snapshot.ef_search, + }; + let mut index = Self::new(config); + index.max_layer = snapshot.max_layer; + index.entry_point = snapshot.entry_point; + + let dimensions = snapshot.points.first() + .map(|p| p.embedding.len()) + .unwrap_or(0); + index.dimensions = dimensions; + + for pt in &snapshot.points { + let hnsw_point = HnswPoint { + id: pt.id.clone(), + embedding: pt.embedding.clone(), + neighbors: pt.neighbors.clone(), + deleted: pt.deleted, + }; + let idx = index.points.len(); + index.id_to_index.insert(pt.id.clone(), idx); + if pt.deleted { + index.deleted_count += 1; + } + index.points.push(hnsw_point); + } + + return Ok(index); + } + } + + // Fallback: legacy format (just embeddings, no topology) + let snapshot: HnswSnapshotLegacy = serde_json::from_slice(data) + .map_err(|e| Error::internal(format!("HNSW deserialize: {e}")))?; + + let mut index = Self::new(snapshot.config); + for (id, embedding) in snapshot.points { + index.insert(id, embedding); + } + + Ok(index) + } + + /// Get index statistics (M5 fix — accurate memory accounting). + pub fn stats(&self) -> HnswStats { + let mut memory_bytes = std::mem::size_of::(); + + // Points vector: outer Vec overhead + per-point data + memory_bytes += 24; // outer Vec overhead + for p in &self.points { + // MemoryId (32 bytes) + Vec overhead (24) + actual floats + memory_bytes += 32 + 24 + p.embedding.len() * 4; + // neighbors: Vec> overhead (24) + per-layer + memory_bytes += 24; + for layer in &p.neighbors { + memory_bytes += 24 + layer.len() * std::mem::size_of::(); + } + // deleted flag + memory_bytes += 1; + } + + // id_to_index: HashMap overhead per entry (key + value + bucket overhead) + memory_bytes += self.id_to_index.len() * (32 + std::mem::size_of::() + 32); + + HnswStats { + point_count: self.points.len() - self.deleted_count, + deleted_count: self.deleted_count, + dimensions: self.dimensions, + max_layer: self.max_layer, + memory_bytes, + } + } + + /// Check if the index has pending changes. + pub fn is_dirty(&self) -> bool { + self.dirty + } + + /// Mark the index as clean (after persisting). + pub fn mark_clean(&mut self) { + self.dirty = false; + } + + /// Number of active (non-deleted) points. + pub fn len(&self) -> usize { + self.points.len() - self.deleted_count + } + + /// Whether the index is empty. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + // ======================================================================== + // Private methods + // ======================================================================== + + fn random_level(&self) -> usize { + // M3 fix: geometric distribution without artificial cap. + // Levels grow naturally; max_layer tracks the current maximum but does + // not limit new levels. + let ml = 1.0 / (self.config.m as f64).ln(); + let r: f64 = rand_f64(); + (-r.ln() * ml).floor() as usize + } + + fn cosine_distance(a: &[f32], b: &[f32]) -> f32 { + if a.len() != b.len() || a.is_empty() { + return 1.0; + } + + let mut dot = 0.0f32; + let mut norm_a = 0.0f32; + let mut norm_b = 0.0f32; + + for i in 0..a.len() { + dot += a[i] * b[i]; + norm_a += a[i] * a[i]; + norm_b += b[i] * b[i]; + } + + let denom = norm_a.sqrt() * norm_b.sqrt(); + if denom < f32::EPSILON { + return 1.0; + } + + 1.0 - (dot / denom) + } + + fn greedy_search(&self, start: usize, query: &[f32], layer: usize) -> usize { + let mut current = start; + let mut best_dist = Self::cosine_distance(&self.points[current].embedding, query); + + loop { + let mut changed = false; + let neighbors = if layer < self.points[current].neighbors.len() { + &self.points[current].neighbors[layer] + } else { + break; + }; + + for &neighbor_idx in neighbors { + if neighbor_idx >= self.points.len() || self.points[neighbor_idx].deleted { + continue; + } + let dist = Self::cosine_distance(&self.points[neighbor_idx].embedding, query); + if dist < best_dist { + best_dist = dist; + current = neighbor_idx; + changed = true; + } + } + + if !changed { + break; + } + } + + current + } + + fn search_layer(&self, start: usize, query: &[f32], ef: usize, _layer: usize) -> Vec { + let mut visited = HashSet::new(); + let start_dist = Self::cosine_distance(&self.points[start].embedding, query); + + let mut candidates = BinaryHeap::new(); // min-heap + let mut result = BinaryHeap::::new(); // max-heap + + candidates.push(Candidate { index: start, distance: start_dist }); + result.push(MaxCandidate { index: start, distance: start_dist }); + visited.insert(start); + + while let Some(current) = candidates.pop() { + // Stop if current candidate is worse than worst result + if let Some(worst) = result.peek() { + if current.distance > worst.distance && result.len() >= ef { + break; + } + } + + // Explore neighbors at layer 0 + let neighbors = if !self.points[current.index].neighbors.is_empty() { + &self.points[current.index].neighbors[0] + } else { + continue; + }; + + for &neighbor_idx in neighbors { + if neighbor_idx >= self.points.len() || visited.contains(&neighbor_idx) { + continue; + } + visited.insert(neighbor_idx); + + if self.points[neighbor_idx].deleted { + continue; + } + + let dist = Self::cosine_distance(&self.points[neighbor_idx].embedding, query); + + let should_add = result.len() < ef || { + if let Some(worst) = result.peek() { + dist < worst.distance + } else { + true + } + }; + + if should_add { + candidates.push(Candidate { index: neighbor_idx, distance: dist }); + result.push(MaxCandidate { index: neighbor_idx, distance: dist }); + + if result.len() > ef { + result.pop(); // Remove worst + } + } + } + } + + // Convert max-heap to sorted vec (best first) + let mut results: Vec = result + .into_iter() + .map(|mc| Candidate { index: mc.index, distance: mc.distance }) + .collect(); + results.sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap_or(Ordering::Equal)); + results + } + + fn connect_new_point(&mut self, new_idx: usize, entry_point: usize) { + let query = self.points[new_idx].embedding.clone(); + let new_level = self.points[new_idx].neighbors.len().saturating_sub(1); + let m = self.config.m; + + // Greedy descent from top to new_level+1 + let mut current = entry_point; + for layer in (new_level + 1..=self.max_layer).rev() { + current = self.greedy_search(current, &query, layer); + } + + // For each layer from new_level down to 0, find and connect neighbors + for layer in (0..=new_level.min(self.max_layer)).rev() { + let candidates = self.search_layer(current, &query, self.config.ef_construction, layer); + let max_neighbors = if layer == 0 { m * 2 } else { m }; + + let selected: Vec = candidates + .iter() + .filter(|c| c.index != new_idx && !self.points[c.index].deleted) + .take(max_neighbors) + .map(|c| c.index) + .collect(); + + // Set neighbors for new point at this layer + if layer < self.points[new_idx].neighbors.len() { + self.points[new_idx].neighbors[layer] = selected.clone(); + } + + // Add reverse links + for &neighbor_idx in &selected { + if layer < self.points[neighbor_idx].neighbors.len() { + let already_linked = self.points[neighbor_idx].neighbors[layer].contains(&new_idx); + if !already_linked { + self.points[neighbor_idx].neighbors[layer].push(new_idx); + // Prune if too many neighbors + if self.points[neighbor_idx].neighbors[layer].len() > max_neighbors { + // Compute distances for sorting, then sort & truncate + let emb = self.points[neighbor_idx].embedding.clone(); + let mut scored: Vec<(usize, f32)> = self.points[neighbor_idx] + .neighbors[layer] + .iter() + .map(|&n| (n, Self::cosine_distance(&self.points[n].embedding, &emb))) + .collect(); + scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal)); + scored.truncate(max_neighbors); + self.points[neighbor_idx].neighbors[layer] = + scored.into_iter().map(|(idx, _)| idx).collect(); + } + } + } + } + + if let Some(c) = candidates.first() { + current = c.index; + } + } + } +} + +// ============================================================================ +// Snapshot (v2 — topology-preserving) +// ============================================================================ + +#[derive(Serialize, Deserialize)] +struct HnswPointSnapshot { + id: MemoryId, + embedding: Vec, + neighbors: Vec>, + deleted: bool, +} + +#[derive(Serialize, Deserialize)] +struct HnswSnapshotV2 { + version: u8, + max_layer: usize, + entry_point: Option, + ef_construction: usize, + ef_search: usize, + m: usize, + points: Vec, +} + +// ============================================================================ +// Snapshot (legacy — backward compatibility) +// ============================================================================ + +#[derive(Serialize, Deserialize)] +struct HnswSnapshotLegacy { + points: Vec<(MemoryId, Vec)>, + config: HnswConfig, +} + +// ============================================================================ +// Utility +// ============================================================================ + +/// Simple pseudo-random f64 in [0, 1) using thread-local state. +fn rand_f64() -> f64 { + use std::cell::Cell; + thread_local! { + static SEED: Cell = Cell::new(0x12345678_9abcdef0); + } + SEED.with(|s| { + let mut x = s.get(); + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + s.set(x); + (x as f64) / (u64::MAX as f64) + }) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn make_id(n: u8) -> MemoryId { + MemoryId::from_bytes([n; 32]) + } + + fn make_embedding(values: &[f32]) -> Vec { + values.to_vec() + } + + #[test] + fn test_new_index() { + let index = HnswIndex::new(HnswConfig::default()); + assert!(index.is_empty()); + assert_eq!(index.len(), 0); + } + + #[test] + fn test_insert_and_search() { + let mut index = HnswIndex::new(HnswConfig::default()); + + index.insert(make_id(1), make_embedding(&[1.0, 0.0, 0.0])); + index.insert(make_id(2), make_embedding(&[0.0, 1.0, 0.0])); + index.insert(make_id(3), make_embedding(&[1.0, 1.0, 0.0])); + + assert_eq!(index.len(), 3); + + let results = index.search(&[1.0, 0.0, 0.0], 2); + assert!(!results.is_empty()); + // First result should be most similar to [1, 0, 0] + assert_eq!(results[0].0, make_id(1)); + assert!(results[0].1 > 0.9); // Very high similarity + } + + #[test] + fn test_cosine_distance() { + let a = &[1.0, 0.0, 0.0]; + let b = &[1.0, 0.0, 0.0]; + let dist = HnswIndex::cosine_distance(a, b); + assert!(dist.abs() < 0.01); // Same vector = 0 distance + + let c = &[0.0, 1.0, 0.0]; + let dist2 = HnswIndex::cosine_distance(a, c); + assert!((dist2 - 1.0).abs() < 0.01); // Orthogonal = 1.0 distance + } + + #[test] + fn test_remove() { + let mut index = HnswIndex::new(HnswConfig::default()); + + index.insert(make_id(1), make_embedding(&[1.0, 0.0])); + index.insert(make_id(2), make_embedding(&[0.0, 1.0])); + assert_eq!(index.len(), 2); + + index.remove(&make_id(1)); + assert_eq!(index.len(), 1); + + let results = index.search(&[1.0, 0.0], 5); + // Should not return deleted point + for (id, _) in &results { + assert_ne!(id, &make_id(1)); + } + } + + #[test] + fn test_rebuild() { + let mut index = HnswIndex::new(HnswConfig::default()); + + for i in 0..10u8 { + let v = vec![i as f32, (10 - i) as f32]; + index.insert(make_id(i), v); + } + + // Delete some + index.remove(&make_id(3)); + index.remove(&make_id(7)); + assert_eq!(index.len(), 8); + + index.rebuild(); + assert_eq!(index.len(), 8); + assert_eq!(index.deleted_count, 0); + } + + #[test] + fn test_serialize_deserialize() { + let mut index = HnswIndex::new(HnswConfig::default()); + + index.insert(make_id(1), make_embedding(&[1.0, 0.0, 0.0])); + index.insert(make_id(2), make_embedding(&[0.0, 1.0, 0.0])); + + let data = index.serialize().unwrap(); + let restored = HnswIndex::deserialize(&data).unwrap(); + + assert_eq!(restored.len(), 2); + + let results = restored.search(&[1.0, 0.0, 0.0], 1); + assert_eq!(results[0].0, make_id(1)); + } + + #[test] + fn test_stats() { + let mut index = HnswIndex::new(HnswConfig::default()); + + index.insert(make_id(1), make_embedding(&[1.0, 0.0, 0.0])); + index.insert(make_id(2), make_embedding(&[0.0, 1.0, 0.0])); + + let stats = index.stats(); + assert_eq!(stats.point_count, 2); + assert_eq!(stats.dimensions, 3); + assert!(stats.memory_bytes > 0); + } + + #[test] + fn test_empty_search() { + let index = HnswIndex::new(HnswConfig::default()); + let results = index.search(&[1.0, 0.0], 5); + assert!(results.is_empty()); + } + + #[test] + fn test_empty_embedding() { + let mut index = HnswIndex::new(HnswConfig::default()); + index.insert(make_id(1), vec![]); // Should be ignored + assert_eq!(index.len(), 0); + } + + // ==================================================================== + // New tests for Hito 3 fixes + // ==================================================================== + + #[test] + fn test_serialize_roundtrip_preserves_topology() { + let mut index = HnswIndex::new(HnswConfig::default()); + + // Insert 20 points with 8-dim embeddings + for i in 0..20u8 { + let mut emb = vec![0.0f32; 8]; + emb[i as usize % 8] = 1.0; + emb[(i as usize + 1) % 8] = 0.5; + index.insert(make_id(i), emb); + } + + let query = vec![1.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + let before = index.search(&query, 5); + assert!(!before.is_empty(), "should have results before serialize"); + + let data = index.serialize().unwrap(); + let restored = HnswIndex::deserialize(&data).unwrap(); + + assert_eq!(restored.len(), index.len()); + assert_eq!(restored.max_layer, index.max_layer); + assert_eq!(restored.entry_point, index.entry_point); + + let after = restored.search(&query, 5); + assert_eq!( + before.iter().map(|(id, _)| id.clone()).collect::>(), + after.iter().map(|(id, _)| id.clone()).collect::>(), + "search results must be identical after round-trip" + ); + + // Similarity scores should match within floating-point tolerance + for (b, a) in before.iter().zip(after.iter()) { + assert!( + (b.1 - a.1).abs() < 1e-6, + "scores diverged: {} vs {}", + b.1, + a.1 + ); + } + } + + #[test] + fn test_legacy_format_backward_compat() { + // Build a legacy snapshot manually + let legacy = HnswSnapshotLegacy { + points: vec![ + (make_id(1), vec![1.0, 0.0, 0.0]), + (make_id(2), vec![0.0, 1.0, 0.0]), + ], + config: HnswConfig::default(), + }; + + let data = serde_json::to_vec(&legacy).unwrap(); + let restored = HnswIndex::deserialize(&data).unwrap(); + assert_eq!(restored.len(), 2); + + let results = restored.search(&[1.0, 0.0, 0.0], 1); + assert_eq!(results[0].0, make_id(1)); + } + + #[test] + fn test_entry_point_updates_on_higher_level() { + // Use a config with small m to increase chance of higher layers + let config = HnswConfig { m: 2, ef_construction: 10, ef_search: 10 }; + let mut index = HnswIndex::new(config); + + // Insert many points; at least one should get a level > 0 + for i in 0..50u8 { + let emb = vec![i as f32, (50 - i) as f32]; + index.insert(make_id(i), emb); + } + + // With m=2, max_layer should have grown beyond 0 + assert!( + index.max_layer >= 1, + "expected max_layer >= 1 after 50 inserts with m=2, got {}", + index.max_layer + ); + + // Verify entry point is at the max layer + if let Some(ep) = index.entry_point { + let ep_level = index.points[ep].neighbors.len().saturating_sub(1); + assert_eq!( + ep_level, index.max_layer, + "entry point level ({}) should equal max_layer ({})", + ep_level, index.max_layer + ); + } else { + panic!("entry_point should be Some after inserts"); + } + } + + #[test] + fn test_deletion_prunes_neighbor_lists() { + let config = HnswConfig { m: 4, ef_construction: 20, ef_search: 10 }; + let mut index = HnswIndex::new(config); + + // Insert several closely-related points so they appear in each other's + // neighbor lists + for i in 0..10u8 { + let emb = vec![i as f32, (10 - i) as f32, 1.0]; + index.insert(make_id(i), emb); + } + + // Pick a point to delete (not the entry point to keep things simple) + let victim_id = make_id(5); + let &victim_idx = index.id_to_index.get(&victim_id).unwrap(); + + index.remove(&victim_id); + + // Verify victim_idx does not appear in any neighbor list of any point + for (idx, point) in index.points.iter().enumerate() { + if idx == victim_idx { + continue; // skip the deleted point itself + } + for (layer, neighbors) in point.neighbors.iter().enumerate() { + assert!( + !neighbors.contains(&victim_idx), + "point {} layer {} still references deleted point {}", + idx, + layer, + victim_idx + ); + } + } + } + + #[test] + fn test_memory_stats_lower_bound() { + let mut index = HnswIndex::new(HnswConfig::default()); + + let dim = 128; + for i in 0..100u8 { + let mut emb = vec![0.0f32; dim]; + emb[i as usize % dim] = 1.0; + index.insert(make_id(i), emb); + } + + let stats = index.stats(); + assert_eq!(stats.point_count, 100); + assert_eq!(stats.dimensions, dim); + + // Conservative lower bound: 100 points * (32-byte MemoryId + 128*4 floats) + let lower_bound = 100 * (32 + dim * 4); + assert!( + stats.memory_bytes >= lower_bound, + "memory_bytes ({}) should be >= conservative lower bound ({})", + stats.memory_bytes, + lower_bound + ); + } +} diff --git a/crates/ineru/src/lib.rs b/crates/ineru/src/lib.rs index f13f1ec..7689026 100644 --- a/crates/ineru/src/lib.rs +++ b/crates/ineru/src/lib.rs @@ -66,6 +66,7 @@ pub mod config; pub mod consolidation; pub mod error; +pub mod hnsw; pub mod ltm; pub mod stm; pub mod types; diff --git a/crates/ineru/src/ltm.rs b/crates/ineru/src/ltm.rs index cca65dd..158f75e 100644 --- a/crates/ineru/src/ltm.rs +++ b/crates/ineru/src/ltm.rs @@ -8,6 +8,7 @@ use crate::config::LtmConfig; use crate::error::{Error, Result}; +use crate::hnsw::{HnswConfig, HnswIndex}; use crate::types::{ Embedding, Entity, EntityId, Link, MemoryEntry, MemoryId, MemoryQuery, MemoryResult, MemorySource, Relation, SemanticTag, @@ -35,11 +36,19 @@ pub struct LongTermMemory { config: LtmConfig, /// A running estimate of the total memory used by the LTM. memory_usage: usize, + /// Optional HNSW index for fast vector search on memory embeddings. + hnsw_index: Option, } impl LongTermMemory { /// Creates a new, empty `LongTermMemory` with the given configuration. pub fn new(config: LtmConfig) -> Self { + let hnsw_index = if config.enable_embeddings { + Some(HnswIndex::new(HnswConfig::default())) + } else { + None + }; + Self { memories: HashMap::new(), entities: HashMap::new(), @@ -49,6 +58,7 @@ impl LongTermMemory { type_index: HashMap::new(), config, memory_usage: 0, + hnsw_index, } } @@ -88,6 +98,13 @@ impl LongTermMemory { .or_default() .insert(id.clone()); + // Insert into HNSW index if entry has an embedding + if let Some(ref emb) = entry.embedding { + if let Some(hnsw) = self.hnsw_index.as_mut() { + hnsw.insert(id.clone(), emb.0.clone()); + } + } + // Store self.memory_usage += entry.size_bytes(); self.memories.insert(id.clone(), entry); @@ -105,6 +122,11 @@ impl LongTermMemory { if let Some(entry) = self.memories.remove(id) { self.memory_usage = self.memory_usage.saturating_sub(entry.size_bytes()); + // Remove from HNSW index + if let Some(hnsw) = self.hnsw_index.as_mut() { + hnsw.remove(id); + } + // Update indices for tag in &entry.tags { if let Some(set) = self.tag_index.get_mut(tag) { @@ -334,6 +356,54 @@ impl LongTermMemory { scored } + /// Performs a vector search over memory entries using the HNSW index. + /// + /// This is much faster than brute-force `semantic_search` for large datasets. + /// Falls back to brute-force if HNSW index is not available. + pub fn vector_search_memories( + &self, + query: &[f32], + k: usize, + min_similarity: f32, + ) -> Vec<(&MemoryEntry, f32)> { + if let Some(ref hnsw) = self.hnsw_index { + let results = hnsw.search(query, k); + results + .into_iter() + .filter(|(_, sim)| *sim >= min_similarity) + .filter_map(|(id, sim)| { + self.memories.get(&id).map(|entry| (entry, sim)) + }) + .collect() + } else { + // Fallback to brute-force over memory entries with embeddings + let query_emb = Embedding::new(query.to_vec()); + let mut scored: Vec<_> = self.memories.values() + .filter_map(|entry| { + entry.embedding.as_ref().map(|emb| { + let sim = query_emb.cosine_similarity(emb); + (entry, sim) + }) + }) + .filter(|(_, sim)| *sim >= min_similarity) + .collect(); + + scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + scored.truncate(k); + scored + } + } + + /// Get a reference to the HNSW index, if available. + pub fn hnsw_index(&self) -> Option<&HnswIndex> { + self.hnsw_index.as_ref() + } + + /// Get a mutable reference to the HNSW index, if available. + pub fn hnsw_index_mut(&mut self) -> Option<&mut HnswIndex> { + self.hnsw_index.as_mut() + } + // ============ Statistics ============ /// Returns the number of memory entries stored in the LTM. @@ -370,6 +440,9 @@ impl LongTermMemory { self.tag_index.clear(); self.type_index.clear(); self.memory_usage = 0; + if let Some(hnsw) = self.hnsw_index.as_mut() { + hnsw.rebuild(); // clears all points + } Ok(()) } diff --git a/crates/kaneru/Cargo.toml b/crates/kaneru/Cargo.toml index 98c67e0..146cef3 100644 --- a/crates/kaneru/Cargo.toml +++ b/crates/kaneru/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kaneru" -version = "0.4.1" +version = "0.4.2" description = "Kaneru: Unified Multi-Agent Execution System for AIngle AI agents" license = "Apache-2.0 OR LicenseRef-Commercial" repository = "https://github.com/ApiliumCode/aingle" From 2e5165aed2f2b618cb981e78f75df67a997ebc72 Mon Sep 17 00:00:00 2001 From: It Apilium Date: Wed, 11 Mar 2026 20:40:04 +0100 Subject: [PATCH 2/2] fix: add missing max_layer field in HnswStats fallback initializer --- crates/aingle_cortex/src/rest/memory.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/aingle_cortex/src/rest/memory.rs b/crates/aingle_cortex/src/rest/memory.rs index cb5999b..848d171 100644 --- a/crates/aingle_cortex/src/rest/memory.rs +++ b/crates/aingle_cortex/src/rest/memory.rs @@ -402,6 +402,7 @@ pub async fn vector_index_stats( point_count: 0, deleted_count: 0, dimensions: 0, + max_layer: 0, memory_bytes: 0, });