1use std::sync::Mutex;
4
5use async_trait::async_trait;
6use serde_json::Value;
7
8use khive_runtime::pack::PackRuntime;
9use khive_runtime::{KhiveRuntime, NamespaceToken, RuntimeError, VerbRegistry};
10use khive_types::{HandlerDef, Pack, ParamDef, VerbCategory, Visibility};
11
12use crate::ann::{new_shared, SharedAnn};
13use crate::config::RecallConfig;
14use crate::query_cache::QueryEmbeddingCache;
15
16pub struct MemoryPack {
18 pub(crate) runtime: KhiveRuntime,
19 pub(crate) config: Mutex<RecallConfig>,
21 pub(crate) ann: SharedAnn,
23 pub(crate) query_cache: QueryEmbeddingCache,
25}
26
27impl MemoryPack {
28 pub(crate) fn active_config(&self) -> RecallConfig {
32 self.config.lock().unwrap().clone()
33 }
34
35 pub fn new(runtime: KhiveRuntime) -> Self {
37 Self {
38 runtime,
39 config: Mutex::new(RecallConfig::default()),
40 ann: new_shared(),
41 query_cache: QueryEmbeddingCache::with_default_capacity(),
42 }
43 }
44
45 #[cfg(test)]
46 pub(crate) fn ann_for_test(&self) -> SharedAnn {
47 self.ann.clone()
48 }
49}
50
51impl Pack for MemoryPack {
52 const NAME: &'static str = "memory";
53 const NOTE_KINDS: &'static [&'static str] = &["memory"];
54 const ENTITY_KINDS: &'static [&'static str] = &[];
55 const HANDLERS: &'static [HandlerDef] = &MEMORY_HANDLERS;
56 const REQUIRES: &'static [&'static str] = &["kg"];
57}
58
59static MEMORY_HANDLERS: [HandlerDef; 7] = [
63 HandlerDef {
65 name: "memory.remember",
66 description: "Create a memory note with salience and decay",
67 visibility: Visibility::Verb,
68 category: VerbCategory::Commissive,
69 params: &[
70 ParamDef {
71 name: "content",
72 param_type: "string",
73 required: true,
74 description: "Memory content to store.",
75 },
76 ParamDef {
77 name: "salience",
78 param_type: "number",
79 required: false,
80 description: "Salience weight 0.0–1.0 (default 0.5).",
81 },
82 ParamDef {
83 name: "decay_factor",
84 param_type: "number",
85 required: false,
86 description: "Decay rate >= 0 (default 0.01). Higher = faster decay. At 0.01 the effective half-life is ~69 days.",
87 },
88 ParamDef {
89 name: "memory_type",
90 param_type: "string",
91 required: false,
92 description: "Memory type tag: \"episodic\" | \"semantic\" (default \"episodic\"). Other values are rejected.",
93 },
94 ParamDef {
95 name: "source_id",
96 param_type: "string",
97 required: false,
98 description: "UUID or 8-char short ID of the entity or note this memory annotates.",
99 },
100 ParamDef {
101 name: "embedding_model",
102 param_type: "string",
103 required: false,
104 description: "Model name for vector embedding (must be registered). Defaults to pack-configured model.",
105 },
106 ParamDef {
107 name: "tags",
108 param_type: "array",
109 required: false,
110 description: "Tag values to filter by. Matched against properties.tags on stored memories.",
111 },
112 ],
113 },
114 HandlerDef {
116 name: "memory.recall",
117 description: "Recall memory notes with decay-aware hybrid ranking",
118 visibility: Visibility::Verb,
119 category: VerbCategory::Assertive,
120 params: &[
121 ParamDef {
122 name: "query",
123 param_type: "string",
124 required: true,
125 description: "Semantic recall query.",
126 },
127 ParamDef {
128 name: "limit",
129 param_type: "integer",
130 required: false,
131 description: "Maximum memories to return (default 10).",
132 },
133 ParamDef {
134 name: "top_k",
135 param_type: "integer",
136 required: false,
137 description: "Override result limit (max 100). Takes priority over limit.",
138 },
139 ParamDef {
140 name: "min_score",
141 param_type: "number",
142 required: false,
143 description: "Minimum composite score to include (default 0.0). Composite scores are always in [0,1]: relevance is normalized to [0,1] per strategy (RRF rank-1 → 1.0; Weighted scores are [0,1] natively), and all three weighted contributions sum to at most 1.0. Typical production floor: 0.3–0.7.",
144 },
145 ParamDef {
146 name: "score_floor",
147 param_type: "number",
148 required: false,
149 description: "Alias for min_score. Filter out hits below this composite score. Scores are always in [0,1] regardless of fusion strategy.",
150 },
151 ParamDef {
152 name: "min_salience",
153 param_type: "number",
154 required: false,
155 description: "Minimum salience score filter.",
156 },
157 ParamDef {
158 name: "memory_type",
159 param_type: "string",
160 required: false,
161 description: "Filter to this memory_type.",
162 },
163 ParamDef {
164 name: "fusion_strategy",
165 param_type: "string",
166 required: false,
167 description: "Fusion strategy: \"rrf\" | \"weighted\" | \"union\" | \"vector_only\" | \"keyword_only\". Weighted values come from pack config.",
168 },
169 ParamDef {
170 name: "embedding_model",
171 param_type: "string",
172 required: false,
173 description: "Model name for vector recall (must be registered). Defaults to pack-configured model.",
174 },
175 ParamDef {
176 name: "include_breakdown",
177 param_type: "boolean",
178 required: false,
179 description: "Include per-component score breakdowns in results.",
180 },
181 ParamDef {
182 name: "entity_names",
183 param_type: "array",
184 required: false,
185 description: "Entity names to boost in scoring. Memories mentioning these entities receive a 1.3× score multiplier.",
186 },
187 ParamDef {
188 name: "full_content",
189 param_type: "boolean",
190 required: false,
191 description: "When false, content is truncated to 200 chars in results. Default true.",
192 },
193 ParamDef {
194 name: "tags",
195 param_type: "array",
196 required: false,
197 description: "Filter results to memories whose stored tags include at least one (any) or all (all) of these values. Matched against properties.tags.",
198 },
199 ParamDef {
200 name: "tag_mode",
201 param_type: "string",
202 required: false,
203 description: "Tag filter mode: \"any\" (OR, default) or \"all\" (AND). Only applies when tags is non-empty.",
204 },
205 ],
206 },
207 HandlerDef {
208 name: "memory.recall_embed",
209 description: "Return the embedding vector used by memory recall",
210 visibility: Visibility::Subhandler,
211 category: VerbCategory::Assertive,
212 params: &[ParamDef {
213 name: "include_embeddings",
214 param_type: "boolean",
215 required: false,
216 description: "When true, include full embedding vector arrays in the response. Default false — only model name and dimension metadata are returned.",
217 }],
218 },
219 HandlerDef {
220 name: "memory.recall_candidates",
221 description: "Return raw memory recall candidates by retrieval source",
222 visibility: Visibility::Subhandler,
223 category: VerbCategory::Assertive,
224 params: &[],
225 },
226 HandlerDef {
227 name: "memory.recall_fuse",
228 description: "Return fused memory recall candidates before final scoring",
229 visibility: Visibility::Subhandler,
230 category: VerbCategory::Assertive,
231 params: &[],
232 },
233 HandlerDef {
235 name: "memory.recall_rerank",
236 description: "Apply configured rerankers to fused candidates",
237 visibility: Visibility::Subhandler,
238 category: VerbCategory::Assertive,
239 params: &[],
240 },
241 HandlerDef {
242 name: "memory.recall_score",
243 description: "Score a memory recall candidate and return score breakdown",
244 visibility: Visibility::Subhandler,
245 category: VerbCategory::Assertive,
246 params: &[],
247 },
248];
249
250struct MemoryPackFactory;
253
254impl khive_runtime::PackFactory for MemoryPackFactory {
255 fn name(&self) -> &'static str {
256 "memory"
257 }
258
259 fn requires(&self) -> &'static [&'static str] {
260 &["kg"]
261 }
262
263 fn create(&self, runtime: KhiveRuntime) -> Box<dyn khive_runtime::PackRuntime> {
264 Box::new(MemoryPack::new(runtime))
265 }
266}
267
268inventory::submit! { khive_runtime::PackRegistration(&MemoryPackFactory) }
269
270#[async_trait]
271impl PackRuntime for MemoryPack {
272 fn name(&self) -> &str {
273 <MemoryPack as Pack>::NAME
274 }
275
276 fn note_kinds(&self) -> &'static [&'static str] {
277 <MemoryPack as Pack>::NOTE_KINDS
278 }
279
280 fn entity_kinds(&self) -> &'static [&'static str] {
281 <MemoryPack as Pack>::ENTITY_KINDS
282 }
283
284 fn handlers(&self) -> &'static [HandlerDef] {
285 &MEMORY_HANDLERS
286 }
287
288 fn requires(&self) -> &'static [&'static str] {
289 <MemoryPack as Pack>::REQUIRES
290 }
291
292 async fn warm(&self) {
293 crate::ann::warm_existing_memory_indexes(&self.runtime, &self.ann).await;
294 }
295
296 async fn dispatch(
297 &self,
298 verb: &str,
299 params: Value,
300 registry: &VerbRegistry,
301 token: &NamespaceToken,
302 ) -> Result<Value, RuntimeError> {
303 match verb {
304 "memory.remember" => self.handle_remember(token, params).await,
305 "memory.recall" => self.handle_recall(token, params, registry).await,
306 "memory.recall_embed" => self.handle_recall_embed(params).await,
307 "memory.recall_candidates" => self.handle_recall_candidates(token, params).await,
308 "memory.recall_fuse" => self.handle_recall_fuse(token, params, registry).await,
309 "memory.recall_rerank" => self.handle_recall_rerank(params).await,
310 "memory.recall_score" => self.handle_recall_score(params).await,
311 _ => Err(RuntimeError::InvalidInput(format!(
312 "memory pack does not handle verb {verb:?}"
313 ))),
314 }
315 }
316}
317
318#[cfg(test)]
321mod ann_route_tests {
322 use super::*;
323 use std::sync::Arc;
324
325 use async_trait::async_trait;
326 use khive_pack_kg::KgPack;
327 use khive_runtime::{EmbedderProvider, Namespace, RuntimeConfig, VerbRegistryBuilder};
328 use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService};
329
330 struct HashVecService {
332 dims: usize,
333 }
334
335 fn fnv_to_vec(text: &str, dims: usize) -> Vec<f32> {
336 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
337 for b in text.bytes() {
338 h ^= b as u64;
339 h = h.wrapping_mul(0x0000_0001_0000_01b3);
340 }
341 let mut v = Vec::with_capacity(dims);
342 let mut s = h;
343 for _ in 0..dims {
344 s = s
345 .wrapping_mul(6_364_136_223_846_793_005)
346 .wrapping_add(1_442_695_040_888_963_407);
347 v.push(((s >> 33) as f32) / (0x7fff_ffff_u32 as f32) - 1.0);
348 }
349 v
350 }
351
352 #[async_trait]
353 impl EmbeddingService for HashVecService {
354 async fn embed(
355 &self,
356 texts: &[String],
357 _model: EmbeddingModel,
358 ) -> Result<Vec<Vec<f32>>, EmbedError> {
359 Ok(texts.iter().map(|t| fnv_to_vec(t, self.dims)).collect())
360 }
361
362 fn supports_model(&self, _model: EmbeddingModel) -> bool {
363 true
364 }
365
366 fn name(&self) -> &'static str {
367 "hash-vec"
368 }
369 }
370
371 struct HashVecProvider {
372 model_name: String,
373 dims: usize,
374 }
375
376 #[async_trait]
377 impl EmbedderProvider for HashVecProvider {
378 fn name(&self) -> &str {
379 &self.model_name
380 }
381
382 fn dimensions(&self) -> usize {
383 self.dims
384 }
385
386 async fn build(&self) -> Result<Arc<dyn EmbeddingService>, khive_runtime::RuntimeError> {
387 Ok(Arc::new(HashVecService { dims: self.dims }))
388 }
389 }
390
391 #[tokio::test]
409 async fn recall_second_call_uses_warm_ann_route() {
410 let tmp = tempfile::Builder::new()
411 .prefix("khive-memory-ann-route-")
412 .tempdir_in(std::env::temp_dir())
413 .expect("temp /tmp db dir");
414 let db_path = tmp.path().join("khive-graph.db");
415
416 const MODEL: &str = "ann-route-test-model";
417 const DIMS: usize = 32;
418
419 let rt = KhiveRuntime::new(RuntimeConfig {
420 db_path: Some(db_path),
421 embedding_model: None,
422 additional_embedding_models: vec![],
423 ..RuntimeConfig::default()
424 })
425 .expect("runtime");
426 rt.register_embedder(HashVecProvider {
427 model_name: MODEL.to_owned(),
428 dims: DIMS,
429 });
430
431 let ns = Namespace::parse("local").expect("local namespace");
432 let token = rt.authorize(ns).expect("authorize local");
433
434 for i in 0..32u32 {
438 rt.create_note_with_decay_for_embedding_model(
439 &token,
440 "memory",
441 None,
442 &format!("ann warm route note {i}"),
443 Some(0.7),
444 0.01,
445 None,
446 vec![],
447 None,
448 )
449 .await
450 .expect("create note");
451 }
452
453 let pack = MemoryPack::new(rt.clone());
454 let ann = pack.ann_for_test();
455
456 let mut builder = VerbRegistryBuilder::new();
457 builder.register(KgPack::new(rt.clone()));
458 builder.register(pack);
459 let registry = builder.build().expect("registry");
460
461 registry
464 .dispatch(
465 "memory.recall",
466 serde_json::json!({
467 "query": "ann warm route note 7",
468 "limit": 10
469 }),
470 )
471 .await
472 .expect("first recall");
473
474 ann.reset_warm_route_count();
475
476 registry
478 .dispatch(
479 "memory.recall",
480 serde_json::json!({
481 "query": "ann warm route note 7",
482 "limit": 10
483 }),
484 )
485 .await
486 .expect("second recall");
487
488 assert!(
489 ann.warm_route_count() > 0,
490 "second recall must route through warm ANN, not exact sqlite-vec fallback"
491 );
492 }
493}