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 khive_brain_core::BalancedRecallState;
13
14use crate::ann::{new_shared, SharedAnn};
15use crate::config::RecallConfig;
16use crate::query_cache::QueryEmbeddingCache;
17
18/// Pack implementation providing `memory.remember` and `memory.recall` verbs.
19pub struct MemoryPack {
20    pub(crate) runtime: KhiveRuntime,
21    /// Active recall config.
22    pub(crate) config: Mutex<RecallConfig>,
23    /// Per-`(namespace, model)` warm ANN indexes.
24    pub(crate) ann: SharedAnn,
25    /// Bounded exact-match query embedding cache (model_name, query_text) → `Vec<f32>`.
26    pub(crate) query_cache: QueryEmbeddingCache,
27    /// In-memory Beta-posterior state for recall-domain feedback.
28    ///
29    /// Updated by `on_recall_hit`, `on_recall_miss`, and `on_explicit_feedback` in
30    /// `recall_feedback`. Posteriors flow into `RecallConfig` via `PackTunable`.
31    ///
32    /// Persistence is deferred — state is rebuilt from actions on restart.
33    pub(crate) recall_state: Mutex<BalancedRecallState>,
34    /// Explicit brain profile ID from config (ADR-035 §Brain profile configuration).
35    ///
36    /// Tier-1 of the 3-tier feedback resolution: when set, `memory.feedback` directs
37    /// feedback to this profile via `brain.feedback`. When absent, tier-2
38    /// (namespace-bound profile) and tier-3 (global prior) are tried in order.
39    pub(crate) brain_profile: Option<String>,
40}
41
42impl MemoryPack {
43    /// Return a clone of the current active `RecallConfig`.
44    ///
45    /// Handlers call this to pick up the latest tuned parameters.
46    pub(crate) fn active_config(&self) -> RecallConfig {
47        self.config.lock().unwrap().clone()
48    }
49
50    /// Create a new `MemoryPack` backed by the given runtime.
51    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
77// Illocutionary classification (Searle 1976):
78//   Commissive — commits caller to a persistent change
79//   Assertive  — retrieves/presents state of affairs
80static MEMORY_HANDLERS: [HandlerDef; 10] = [
81    // Commissive: commits a memory to the namespace
82    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            ParamDef {
131                name: "namespace",
132                param_type: "string",
133                required: false,
134                description: "Write namespace override. When absent: episodic memories land in the actor's namespace; semantic memories land in \"local\". When present, overrides both routing rules.",
135            },
136        ],
137    },
138    // Commissive: explicit feedback on a recalled memory — updates recall-domain posteriors
139    HandlerDef {
140        name: "memory.feedback",
141        description: "Emit explicit feedback on a recalled entity; updates recall-domain posteriors",
142        visibility: Visibility::Verb,
143        category: VerbCategory::Commissive,
144        params: &[
145            ParamDef {
146                name: "target_id",
147                param_type: "string",
148                required: true,
149                description: "UUID of the recalled entity or memory being rated.",
150            },
151            ParamDef {
152                name: "signal",
153                param_type: "string",
154                required: true,
155                description: "Feedback signal: \"useful\" | \"not_useful\" | \"wrong\" | \"explicit_positive\" | \"explicit_negative\" | \"implicit_positive\" | \"implicit_negative\" | \"correction\".",
156            },
157        ],
158    },
159    // Assertive: retrieves memory notes via decay-aware ranking
160    HandlerDef {
161        name: "memory.recall",
162        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.",
163        visibility: Visibility::Verb,
164        category: VerbCategory::Assertive,
165        params: &[
166            ParamDef {
167                name: "query",
168                param_type: "string",
169                required: true,
170                description: "Semantic recall query.",
171            },
172            ParamDef {
173                name: "limit",
174                param_type: "integer",
175                required: false,
176                description: "Maximum memories to return (default 10).",
177            },
178            ParamDef {
179                name: "top_k",
180                param_type: "integer",
181                required: false,
182                description: "Override result limit (max 100). Takes priority over limit.",
183            },
184            ParamDef {
185                name: "min_score",
186                param_type: "number",
187                required: false,
188                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.",
189            },
190            ParamDef {
191                name: "score_floor",
192                param_type: "number",
193                required: false,
194                description: "Alias for min_score. Filter out hits below this composite score. Scores are always in [0,1] regardless of fusion strategy.",
195            },
196            ParamDef {
197                name: "min_salience",
198                param_type: "number",
199                required: false,
200                description: "Minimum salience score filter.",
201            },
202            ParamDef {
203                name: "memory_type",
204                param_type: "string",
205                required: false,
206                description: "Filter to this memory_type.",
207            },
208            ParamDef {
209                name: "fusion_strategy",
210                param_type: "string",
211                required: false,
212                description: "Fusion strategy: \"rrf\" | \"weighted\" | \"union\" | \"vector_only\" | \"keyword_only\". Weighted values come from pack config.",
213            },
214            ParamDef {
215                name: "embedding_model",
216                param_type: "string",
217                required: false,
218                description: "Model name for vector recall (must be registered). Defaults to pack-configured model.",
219            },
220            ParamDef {
221                name: "include_breakdown",
222                param_type: "boolean",
223                required: false,
224                description: "Include per-component score breakdowns in results.",
225            },
226            ParamDef {
227                name: "entity_names",
228                param_type: "array",
229                required: false,
230                description: "Entity names to boost in scoring. Memories mentioning these entities receive a 1.3× score multiplier.",
231            },
232            ParamDef {
233                name: "full_content",
234                param_type: "boolean",
235                required: false,
236                description: "When false, content is truncated to 200 chars in results. Default true.",
237            },
238            ParamDef {
239                name: "tags",
240                param_type: "array",
241                required: false,
242                description: "Filter results to memories whose stored tags include at least one (any) or all (all) of these values. Matched against properties.tags.",
243            },
244            ParamDef {
245                name: "tag_mode",
246                param_type: "string",
247                required: false,
248                description: "Tag filter mode: \"any\" (OR, default) or \"all\" (AND). Only applies when tags is non-empty.",
249            },
250        ],
251    },
252    HandlerDef {
253        name: "memory.recall_embed",
254        description: "Return the embedding vector used by memory recall",
255        visibility: Visibility::Subhandler,
256        category: VerbCategory::Assertive,
257        params: &[ParamDef {
258            name: "include_embeddings",
259            param_type: "boolean",
260            required: false,
261            description: "When true, include full embedding vector arrays in the response. Default false — only model name and dimension metadata are returned.",
262        }],
263    },
264    HandlerDef {
265        name: "memory.recall_candidates",
266        description: "Return raw memory recall candidates by retrieval source",
267        visibility: Visibility::Subhandler,
268        category: VerbCategory::Assertive,
269        params: &[],
270    },
271    HandlerDef {
272        name: "memory.recall_fuse",
273        description: "Return fused memory recall candidates before final scoring",
274        visibility: Visibility::Subhandler,
275        category: VerbCategory::Assertive,
276        params: &[],
277    },
278    // Rerank stage between fuse and final scoring.
279    HandlerDef {
280        name: "memory.recall_rerank",
281        description: "Apply configured rerankers to fused candidates",
282        visibility: Visibility::Subhandler,
283        category: VerbCategory::Assertive,
284        params: &[],
285    },
286    HandlerDef {
287        name: "memory.recall_score",
288        description: "Score a memory recall candidate and return score breakdown",
289        visibility: Visibility::Subhandler,
290        category: VerbCategory::Assertive,
291        params: &[],
292    },
293    // Commissive: curation prune of low-salience or expired memories
294    HandlerDef {
295        name: "memory.prune",
296        description: "Soft-delete memories below a salience threshold and/or past expires_at. Curation-layer operation per ADR-014.",
297        visibility: Visibility::Verb,
298        category: VerbCategory::Commissive,
299        params: &[
300            ParamDef {
301                name: "min_salience",
302                param_type: "number",
303                required: false,
304                description: "Soft-delete memories with salience strictly below this value.",
305            },
306            ParamDef {
307                name: "before",
308                param_type: "integer",
309                required: false,
310                description: "Soft-delete memories expired at or before this Unix microsecond timestamp. Defaults to now. Pass 0 to skip expiry filter.",
311            },
312            ParamDef {
313                name: "namespace",
314                param_type: "string",
315                required: false,
316                description: "Namespace to prune. Defaults to \"local\".",
317            },
318            ParamDef {
319                name: "dry_run",
320                param_type: "boolean",
321                required: false,
322                description: "When true, count candidates without deleting. Default false.",
323            },
324        ],
325    },
326    // Commissive: reclaim disk space freed by soft-deleted rows
327    HandlerDef {
328        name: "memory.vacuum",
329        description: "Run SQLite VACUUM to reclaim space freed by soft-deleted rows.",
330        visibility: Visibility::Verb,
331        category: VerbCategory::Commissive,
332        params: &[],
333    },
334];
335
336// ── Inventory self-registration ───────────────────────────────────────────────
337
338struct MemoryPackFactory;
339
340impl khive_runtime::PackFactory for MemoryPackFactory {
341    fn name(&self) -> &'static str {
342        "memory"
343    }
344
345    fn requires(&self) -> &'static [&'static str] {
346        &["kg"]
347    }
348
349    fn create(&self, runtime: KhiveRuntime) -> Box<dyn khive_runtime::PackRuntime> {
350        Box::new(MemoryPack::new(runtime))
351    }
352}
353
354inventory::submit! { khive_runtime::PackRegistration(&MemoryPackFactory) }
355
356#[async_trait]
357impl PackRuntime for MemoryPack {
358    fn name(&self) -> &str {
359        <MemoryPack as Pack>::NAME
360    }
361
362    fn note_kinds(&self) -> &'static [&'static str] {
363        <MemoryPack as Pack>::NOTE_KINDS
364    }
365
366    fn entity_kinds(&self) -> &'static [&'static str] {
367        <MemoryPack as Pack>::ENTITY_KINDS
368    }
369
370    fn handlers(&self) -> &'static [HandlerDef] {
371        &MEMORY_HANDLERS
372    }
373
374    fn requires(&self) -> &'static [&'static str] {
375        <MemoryPack as Pack>::REQUIRES
376    }
377
378    async fn warm(&self) {
379        crate::ann::warm_existing_memory_indexes(&self.runtime, &self.ann).await;
380        fts_population_guard(&self.runtime).await;
381    }
382
383    async fn dispatch(
384        &self,
385        verb: &str,
386        params: Value,
387        registry: &VerbRegistry,
388        token: &NamespaceToken,
389    ) -> Result<Value, RuntimeError> {
390        match verb {
391            "memory.remember" => self.handle_remember(token, params).await,
392            "memory.feedback" => self.handle_feedback(token, params, registry).await,
393            "memory.recall" => self.handle_recall(token, params, registry).await,
394            "memory.recall_embed" => self.handle_recall_embed(params).await,
395            "memory.recall_candidates" => self.handle_recall_candidates(token, params).await,
396            "memory.recall_fuse" => self.handle_recall_fuse(token, params, registry).await,
397            "memory.recall_rerank" => self.handle_recall_rerank(params).await,
398            "memory.recall_score" => self.handle_recall_score(params).await,
399            "memory.prune" => self.handle_prune(token, params).await,
400            "memory.vacuum" => self.handle_vacuum(params).await,
401            _ => Err(RuntimeError::InvalidInput(format!(
402                "memory pack does not handle verb {verb:?}"
403            ))),
404        }
405    }
406}
407
408/// Check that the unified FTS tables are adequately populated relative to the
409/// base `notes` and `entities` tables. Called at daemon warm time (after
410/// `kkernel mcp` starts) to detect the V3→V4 migration footgun where the
411/// empty unified tables silently strand FTS recall at ~1% until a manual
412/// `kkernel reindex` is run.
413///
414/// Threshold: warns when `base_count > 100 AND fts_count < base_count / 2`.
415/// A legitimately fresh or empty database (base_count ≤ 100) never warns.
416/// Does NOT hard-fail — boot must succeed even on empty databases.
417async fn fts_population_guard(rt: &KhiveRuntime) {
418    use khive_storage::types::{SqlStatement, SqlValue};
419
420    let sql = rt.sql();
421
422    let Ok(mut reader) = sql.reader().await else {
423        tracing::warn!("fts_population_guard: could not open SQL reader — skipping check");
424        return;
425    };
426
427    for (base_table, fts_table) in [("notes", "fts_notes"), ("entities", "fts_entities")] {
428        let base_row = reader
429            .query_row(SqlStatement {
430                sql: format!("SELECT COUNT(*) AS cnt FROM {base_table} WHERE deleted_at IS NULL"),
431                params: vec![],
432                label: None,
433            })
434            .await;
435
436        let base_count: u64 = match base_row {
437            Ok(Some(r)) => match r.get("cnt") {
438                Some(SqlValue::Integer(n)) => *n as u64,
439                _ => 0,
440            },
441            _ => 0,
442        };
443
444        if base_count <= 100 {
445            continue;
446        }
447
448        let fts_row = reader
449            .query_row(SqlStatement {
450                sql: format!("SELECT COUNT(*) AS cnt FROM {fts_table}"),
451                params: vec![],
452                label: None,
453            })
454            .await;
455
456        let fts_count: u64 = match fts_row {
457            Ok(Some(r)) => match r.get("cnt") {
458                Some(SqlValue::Integer(n)) => *n as u64,
459                _ => 0,
460            },
461            _ => 0,
462        };
463
464        if fts_count < base_count / 2 {
465            tracing::warn!(
466                base_table,
467                fts_table,
468                base_count,
469                fts_count,
470                "FTS table is severely under-populated relative to base rows. \
471                 FTS recall will return near-nothing. This is typically caused by \
472                 a V3→V4 schema migration that did not run `kkernel reindex`. \
473                 Fix: run `kkernel reindex --no-knowledge` to repopulate {fts_table}."
474            );
475        }
476    }
477}
478
479// ── MAJ-1 regression test: second recall routes through warm ANN ──────────────
480
481#[cfg(test)]
482mod ann_route_tests {
483    use super::*;
484    use std::sync::Arc;
485
486    use async_trait::async_trait;
487    use khive_pack_kg::KgPack;
488    use khive_runtime::{EmbedderProvider, Namespace, RuntimeConfig, VerbRegistryBuilder};
489    use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService};
490
491    // Deterministic embedding service: distinct vector per unique text via FNV hash.
492    struct HashVecService {
493        dims: usize,
494    }
495
496    fn fnv_to_vec(text: &str, dims: usize) -> Vec<f32> {
497        let mut h: u64 = 0xcbf2_9ce4_8422_2325;
498        for b in text.bytes() {
499            h ^= b as u64;
500            h = h.wrapping_mul(0x0000_0001_0000_01b3);
501        }
502        let mut v = Vec::with_capacity(dims);
503        let mut s = h;
504        for _ in 0..dims {
505            s = s
506                .wrapping_mul(6_364_136_223_846_793_005)
507                .wrapping_add(1_442_695_040_888_963_407);
508            v.push(((s >> 33) as f32) / (0x7fff_ffff_u32 as f32) - 1.0);
509        }
510        v
511    }
512
513    #[async_trait]
514    impl EmbeddingService for HashVecService {
515        async fn embed(
516            &self,
517            texts: &[String],
518            _model: EmbeddingModel,
519        ) -> Result<Vec<Vec<f32>>, EmbedError> {
520            Ok(texts.iter().map(|t| fnv_to_vec(t, self.dims)).collect())
521        }
522
523        fn supports_model(&self, _model: EmbeddingModel) -> bool {
524            true
525        }
526
527        fn name(&self) -> &'static str {
528            "hash-vec"
529        }
530    }
531
532    struct HashVecProvider {
533        model_name: String,
534        dims: usize,
535    }
536
537    #[async_trait]
538    impl EmbedderProvider for HashVecProvider {
539        fn name(&self) -> &str {
540            &self.model_name
541        }
542
543        fn dimensions(&self) -> usize {
544            self.dims
545        }
546
547        async fn build(&self) -> Result<Arc<dyn EmbeddingService>, khive_runtime::RuntimeError> {
548            Ok(Arc::new(HashVecService { dims: self.dims }))
549        }
550    }
551
552    /// Regression: the second `memory.recall` call on a namespace with N embedded
553    /// notes must route through the warm Vamana ANN index, not the O(N) sqlite-vec
554    /// exact fallback.
555    ///
556    /// Proof of correctness: the first recall builds the ANN synchronously (via
557    /// `ensure_ann_for_model` awaited at `handlers.rs:690`). After the build the
558    /// `AnnState` warm-route counter is reset. The second recall hits
559    /// `search_loaded` with the index already loaded and increments the counter.
560    /// An assertion on the counter value is deterministic — it does not depend on
561    /// wall-clock timing or tracing output.
562    ///
563    /// Fail-on-revert proof: reverting the awaited `ensure_ann_for_model` call back
564    /// to fire-and-forget (`ensure_ann_background`) means the first recall does not
565    /// build the index synchronously. The second recall races against the background
566    /// task; in test execution without `tokio::time::sleep`, the task typically has
567    /// not completed, so `search_loaded` returns `Ok(None)` and the counter stays 0,
568    /// causing this assertion to fail.
569    #[tokio::test]
570    async fn recall_second_call_uses_warm_ann_route() {
571        let tmp = tempfile::Builder::new()
572            .prefix("khive-memory-ann-route-")
573            .tempdir_in(std::env::temp_dir())
574            .expect("temp /tmp db dir");
575        let db_path = tmp.path().join("khive-graph.db");
576
577        const MODEL: &str = "ann-route-test-model";
578        const DIMS: usize = 32;
579
580        let rt = KhiveRuntime::new(RuntimeConfig {
581            db_path: Some(db_path),
582            embedding_model: None,
583            additional_embedding_models: vec![],
584            ..RuntimeConfig::default()
585        })
586        .expect("runtime");
587        rt.register_embedder(HashVecProvider {
588            model_name: MODEL.to_owned(),
589            dims: DIMS,
590        });
591
592        let ns = Namespace::parse("local").expect("local namespace");
593        let token = rt.authorize(ns).expect("authorize local");
594
595        // Create notes with embedding_model: None so the runtime auto-detects
596        // the registered custom provider (resolve_embedding_model only handles
597        // lattice aliases; custom provider names must go through the auto-detect path).
598        for i in 0..32u32 {
599            rt.create_note_with_decay_for_embedding_model(
600                &token,
601                "memory",
602                None,
603                &format!("ann warm route note {i}"),
604                Some(0.7),
605                0.01,
606                None,
607                vec![],
608                None,
609            )
610            .await
611            .expect("create note");
612        }
613
614        let pack = MemoryPack::new(rt.clone());
615        let ann = pack.ann_for_test();
616
617        let mut builder = VerbRegistryBuilder::new();
618        builder.register(KgPack::new(rt.clone()));
619        builder.register(pack);
620        let registry = builder.build().expect("registry");
621
622        // First recall: triggers synchronous ANN build on cache miss.
623        // No explicit embedding_model — auto-detects ann-route-test-model.
624        registry
625            .dispatch(
626                "memory.recall",
627                serde_json::json!({
628                    "query": "ann warm route note 7",
629                    "limit": 10
630                }),
631            )
632            .await
633            .expect("first recall");
634
635        ann.reset_warm_route_count();
636
637        // Second recall: index is already loaded — must go through warm ANN.
638        registry
639            .dispatch(
640                "memory.recall",
641                serde_json::json!({
642                    "query": "ann warm route note 7",
643                    "limit": 10
644                }),
645            )
646            .await
647            .expect("second recall");
648
649        assert!(
650            ann.warm_route_count() > 0,
651            "second recall must route through warm ANN, not exact sqlite-vec fallback"
652        );
653    }
654}