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