Skip to main content

khive_pack_memory/
pack.rs

1//! `MemoryPack` struct, trait impls, verb handler table, and inventory registration.
2
3use 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
16/// Pack implementation providing `memory.remember` and `memory.recall` verbs.
17pub struct MemoryPack {
18    pub(crate) runtime: KhiveRuntime,
19    /// Active recall config.
20    pub(crate) config: Mutex<RecallConfig>,
21    /// Per-`(namespace, model)` warm ANN indexes.
22    pub(crate) ann: SharedAnn,
23    /// Bounded exact-match query embedding cache (model_name, query_text) → Vec<f32>.
24    pub(crate) query_cache: QueryEmbeddingCache,
25}
26
27impl MemoryPack {
28    /// Return a clone of the current active `RecallConfig`.
29    ///
30    /// Handlers call this to pick up the latest tuned parameters.
31    pub(crate) fn active_config(&self) -> RecallConfig {
32        self.config.lock().unwrap().clone()
33    }
34
35    /// Create a new `MemoryPack` backed by the given runtime.
36    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
59// Illocutionary classification (Searle 1976):
60//   Commissive — commits caller to a persistent change
61//   Assertive  — retrieves/presents state of affairs
62static MEMORY_HANDLERS: [HandlerDef; 7] = [
63    // Commissive: commits a memory to the namespace
64    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    // Assertive: retrieves memory notes via decay-aware ranking
115    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    // Rerank stage between fuse and final scoring.
234    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
250// ── Inventory self-registration ───────────────────────────────────────────────
251
252struct 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// ── MAJ-1 regression test: second recall routes through warm ANN ──────────────
319
320#[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    // Deterministic embedding service: distinct vector per unique text via FNV hash.
331    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    /// Regression: the second `memory.recall` call on a namespace with N embedded
392    /// notes must route through the warm Vamana ANN index, not the O(N) sqlite-vec
393    /// exact fallback.
394    ///
395    /// Proof of correctness: the first recall builds the ANN synchronously (via
396    /// `ensure_ann_for_model` awaited at `handlers.rs:690`). After the build the
397    /// `AnnState` warm-route counter is reset. The second recall hits
398    /// `search_loaded` with the index already loaded and increments the counter.
399    /// An assertion on the counter value is deterministic — it does not depend on
400    /// wall-clock timing or tracing output.
401    ///
402    /// Fail-on-revert proof: reverting the awaited `ensure_ann_for_model` call back
403    /// to fire-and-forget (`ensure_ann_background`) means the first recall does not
404    /// build the index synchronously. The second recall races against the background
405    /// task; in test execution without `tokio::time::sleep`, the task typically has
406    /// not completed, so `search_loaded` returns `Ok(None)` and the counter stays 0,
407    /// causing this assertion to fail.
408    #[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        // Create notes with embedding_model: None so the runtime auto-detects
435        // the registered custom provider (resolve_embedding_model only handles
436        // lattice aliases; custom provider names must go through the auto-detect path).
437        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        // First recall: triggers synchronous ANN build on cache miss.
462        // No explicit embedding_model — auto-detects ann-route-test-model.
463        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        // Second recall: index is already loaded — must go through warm ANN.
477        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}