Skip to main content

khive_pack_memory/
pack.rs

1//! `MemoryPack` struct, trait impls, verb handler table, and inventory registration.
2//! See `crates/khive-pack-memory/docs/api/pack-integration.md`.
3
4use std::sync::Mutex;
5
6use async_trait::async_trait;
7use serde_json::Value;
8
9use khive_runtime::pack::PackRuntime;
10use khive_runtime::{
11    KhiveRuntime, NamespaceToken, PackSchemaPlan, RuntimeError, SchemaPlan, VerbRegistry,
12};
13use khive_types::{HandlerDef, Pack, ParamDef, VerbCategory, Visibility};
14
15use khive_brain_core::BalancedRecallState;
16
17use crate::ann::{new_shared, SharedAnn, MEMORY_SCHEMA_PLAN_STMTS};
18use crate::config::RecallConfig;
19use crate::query_cache::QueryEmbeddingCache;
20
21/// Pack implementation providing `memory.remember` and `memory.recall` verbs.
22pub struct MemoryPack {
23    pub(crate) runtime: KhiveRuntime,
24    /// Active recall config.
25    pub(crate) config: Mutex<RecallConfig>,
26    /// Per-`(namespace, model)` warm ANN indexes.
27    pub(crate) ann: SharedAnn,
28    /// Bounded exact-match query embedding cache (model_name, query_text) → `Vec<f32>`.
29    pub(crate) query_cache: QueryEmbeddingCache,
30    /// In-memory recall posteriors; persistence is deferred and actions rebuild state.
31    pub(crate) recall_state: Mutex<BalancedRecallState>,
32    /// Optional tier-one brain profile used before binding and global-prior fallbacks.
33    pub(crate) brain_profile: Option<String>,
34}
35
36impl MemoryPack {
37    /// Clone the current tuned recall configuration for one request.
38    pub(crate) fn active_config(&self) -> RecallConfig {
39        self.config.lock().unwrap().clone()
40    }
41
42    /// Create a memory pack with default recall policy, ANN state, and query cache.
43    ///
44    /// See `crates/khive-pack-memory/docs/api/pack-integration.md`.
45    pub fn new(runtime: KhiveRuntime) -> Self {
46        let brain_profile = runtime.config().brain_profile.clone();
47        Self {
48            runtime,
49            config: Mutex::new(RecallConfig::default()),
50            ann: new_shared(),
51            query_cache: QueryEmbeddingCache::with_default_capacity(),
52            recall_state: Mutex::new(BalancedRecallState::new(10_000)),
53            brain_profile,
54        }
55    }
56
57    #[cfg(test)]
58    pub(crate) fn ann_for_test(&self) -> SharedAnn {
59        self.ann.clone()
60    }
61}
62
63impl Pack for MemoryPack {
64    const NAME: &'static str = "memory";
65    const NOTE_KINDS: &'static [&'static str] = &["memory"];
66    const ENTITY_KINDS: &'static [&'static str] = &[];
67    const HANDLERS: &'static [HandlerDef] = &MEMORY_HANDLERS;
68    const REQUIRES: &'static [&'static str] = &["kg"];
69    /// Pack-owned durable ANN epoch schema, applied during registry boot.
70    const SCHEMA_PLAN: Option<PackSchemaPlan> = Some(PackSchemaPlan {
71        pack: "memory",
72        statements: &MEMORY_SCHEMA_PLAN_STMTS,
73    });
74}
75
76// Illocutionary classification (Searle 1976):
77//   Commissive — commits caller to a persistent change
78//   Assertive  — retrieves/presents state of affairs
79static MEMORY_HANDLERS: [HandlerDef; 10] = [
80    // Commissive: commits a memory to the namespace
81    HandlerDef {
82        name: "memory.remember",
83        description: "Create a memory note with salience and decay",
84        visibility: Visibility::Verb,
85        category: VerbCategory::Commissive,
86        params: &[
87            ParamDef {
88                name: "content",
89                param_type: "string",
90                required: true,
91                description: "Memory content to store.",
92            },
93            ParamDef {
94                name: "salience",
95                param_type: "number",
96                required: false,
97                description: "Salience weight 0.0–1.0. Default is type-differentiated: episodic=0.3, semantic=0.5.",
98            },
99            ParamDef {
100                name: "decay_factor",
101                param_type: "number",
102                required: false,
103                description: "Decay rate >= 0. Default is type-differentiated: episodic=0.02 (~35d half-life), semantic=0.005 (~139d half-life). Higher = faster decay.",
104            },
105            ParamDef {
106                name: "memory_type",
107                param_type: "string",
108                required: false,
109                description: "Memory type tag: \"episodic\" | \"semantic\" (default \"episodic\"). Other values are rejected.",
110            },
111            ParamDef {
112                name: "source_id",
113                param_type: "string",
114                required: false,
115                description: "UUID or 8-char short ID of the entity or note this memory annotates.",
116            },
117            ParamDef {
118                name: "embedding_model",
119                param_type: "string",
120                required: false,
121                description: "Model name for vector embedding (must be registered). Defaults to pack-configured model.",
122            },
123            ParamDef {
124                name: "tags",
125                param_type: "array",
126                required: false,
127                description: "Tag values to filter by. Matched against properties.tags on stored memories.",
128            },
129            ParamDef {
130                name: "namespace",
131                param_type: "string",
132                required: false,
133                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.",
134            },
135        ],
136    },
137    // Commissive: explicit feedback on a recalled memory — updates recall-domain posteriors
138    HandlerDef {
139        name: "memory.feedback",
140        description: "Emit explicit feedback on a recalled entity; updates recall-domain posteriors",
141        visibility: Visibility::Verb,
142        category: VerbCategory::Commissive,
143        params: &[
144            ParamDef {
145                name: "target_id",
146                param_type: "string",
147                required: true,
148                description: "UUID of the recalled entity or memory being rated.",
149            },
150            ParamDef {
151                name: "signal",
152                param_type: "string",
153                required: true,
154                description: "Feedback signal: \"useful\" | \"not_useful\" | \"wrong\" | \"explicit_positive\" | \"explicit_negative\" | \"implicit_positive\" | \"implicit_negative\" | \"correction\".",
155            },
156        ],
157    },
158    // Assertive: retrieves memory notes via decay-aware ranking
159    HandlerDef {
160        name: "memory.recall",
161        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.",
162        visibility: Visibility::Verb,
163        category: VerbCategory::Assertive,
164        params: &[
165            ParamDef {
166                name: "query",
167                param_type: "string",
168                required: true,
169                description: "Semantic recall query.",
170            },
171            ParamDef {
172                name: "limit",
173                param_type: "integer",
174                required: false,
175                description: "Maximum memories to return (default 10).",
176            },
177            ParamDef {
178                name: "top_k",
179                param_type: "integer",
180                required: false,
181                description: "Override result limit (max 100). Takes priority over limit.",
182            },
183            ParamDef {
184                name: "min_score",
185                param_type: "number",
186                required: false,
187                description: "Minimum rank_score to include (default 0.0). This filters `rank_score`, not `score`: `score` (absolute/raw relevance in each result) stays in [0,1] regardless of fusion strategy, but `rank_score` (the composite used for ranking and this filter) is the weighted relevance/salience/temporal composite — nominally [0,1] — further adjusted by ADR-104 posterior terms whenever a brain profile serves the request: a weight-reprojection component, and a per-entity term bounded to clamp(1 + 0.3 * (entity_posterior_mean - 0.5), 0.85, 1.15). So a served, positively-reinforced memory's rank_score can exceed 1.0 by up to 15%. Typical production floor: 0.3–0.7.",
188            },
189            ParamDef {
190                name: "score_floor",
191                param_type: "number",
192                required: false,
193                description: "Alias for min_score. Filters by `rank_score`, not `score` — see min_score for the [0,1]-plus-up-to-15%-under-ADR-104 range of rank_score when a profile serves the request. `score` (absolute/raw relevance) stays in [0,1] regardless of fusion strategy or served profile.",
194            },
195            ParamDef {
196                name: "min_salience",
197                param_type: "number",
198                required: false,
199                description: "Minimum salience score filter.",
200            },
201            ParamDef {
202                name: "memory_type",
203                param_type: "string",
204                required: false,
205                description: "Filter to this memory_type.",
206            },
207            ParamDef {
208                name: "fusion_strategy",
209                param_type: "string",
210                required: false,
211                description: "Fusion strategy: \"rrf\" | \"weighted\" | \"union\" | \"vector_only\" | \"keyword_only\". Weighted values come from pack config.",
212            },
213            ParamDef {
214                name: "embedding_model",
215                param_type: "string",
216                required: false,
217                description: "Model name for vector recall (must be registered). Defaults to pack-configured model.",
218            },
219            ParamDef {
220                name: "include_breakdown",
221                param_type: "boolean",
222                required: false,
223                description: "Include per-component score breakdowns in results.",
224            },
225            ParamDef {
226                name: "entity_names",
227                param_type: "array",
228                required: false,
229                description: "Entity names to boost in scoring. Memories mentioning these entities receive a 1.3× score multiplier.",
230            },
231            ParamDef {
232                name: "profile_id",
233                param_type: "string",
234                required: false,
235                description: "Serving-profile override (ADR-104 §4): short-circuits binding resolution so the named profile's state serves this request; stamped and ledgered like a resolved profile. Unknown ids error.",
236            },
237            ParamDef {
238                name: "full_content",
239                param_type: "boolean",
240                required: false,
241                description: "When false, content is truncated to 200 chars in results. Default true.",
242            },
243            ParamDef {
244                name: "tags",
245                param_type: "array",
246                required: false,
247                description: "Filter results to memories whose stored tags include at least one (any) or all (all) of these values. Matched against properties.tags.",
248            },
249            ParamDef {
250                name: "tag_mode",
251                param_type: "string",
252                required: false,
253                description: "Tag filter mode: \"any\" (OR, default) or \"all\" (AND). Only applies when tags is non-empty.",
254            },
255            ParamDef {
256                name: "namespace",
257                param_type: "string",
258                required: false,
259                description: "Exact-match read-namespace override (ADR-007 Rev 6 escape hatch). When absent, reads the caller's default visible namespace set (unchanged default behavior). When present, scopes the candidate fetch to exactly this namespace; invalid values are rejected.",
260            },
261        ],
262    },
263    HandlerDef {
264        name: "memory.recall_embed",
265        description: "Return the embedding vector used by memory recall",
266        visibility: Visibility::Subhandler,
267        category: VerbCategory::Assertive,
268        params: &[ParamDef {
269            name: "include_embeddings",
270            param_type: "boolean",
271            required: false,
272            description: "When true, include full embedding vector arrays in the response. Default false — only model name and dimension metadata are returned.",
273        }],
274    },
275    HandlerDef {
276        name: "memory.recall_candidates",
277        description: "Return raw memory recall candidates by retrieval source",
278        visibility: Visibility::Subhandler,
279        category: VerbCategory::Assertive,
280        params: &[],
281    },
282    HandlerDef {
283        name: "memory.recall_fuse",
284        description: "Return fused memory recall candidates before final scoring",
285        visibility: Visibility::Subhandler,
286        category: VerbCategory::Assertive,
287        params: &[],
288    },
289    // Rerank stage between fuse and final scoring.
290    HandlerDef {
291        name: "memory.recall_rerank",
292        description: "Apply configured rerankers to fused candidates",
293        visibility: Visibility::Subhandler,
294        category: VerbCategory::Assertive,
295        params: &[],
296    },
297    HandlerDef {
298        name: "memory.recall_score",
299        description: "Score a memory recall candidate and return score breakdown",
300        visibility: Visibility::Subhandler,
301        category: VerbCategory::Assertive,
302        params: &[],
303    },
304    // Commissive: curation prune of low-salience or expired memories
305    HandlerDef {
306        name: "memory.prune",
307        description: "Soft-delete memories below a salience threshold and/or past expires_at. Curation-layer operation per ADR-014.",
308        visibility: Visibility::Verb,
309        category: VerbCategory::Commissive,
310        params: &[
311            ParamDef {
312                name: "min_salience",
313                param_type: "number",
314                required: false,
315                description: "Soft-delete memories with salience strictly below this value.",
316            },
317            ParamDef {
318                name: "before",
319                param_type: "integer",
320                required: false,
321                description: "Soft-delete memories expired at or before this Unix microsecond timestamp. Defaults to now. Pass 0 to skip expiry filter.",
322            },
323            ParamDef {
324                name: "namespace",
325                param_type: "string",
326                required: false,
327                description: "Namespace to prune. Defaults to \"local\".",
328            },
329            ParamDef {
330                name: "dry_run",
331                param_type: "boolean",
332                required: false,
333                description: "When true, count candidates without deleting. Default false.",
334            },
335        ],
336    },
337    // Commissive: reclaim disk space freed by soft-deleted rows
338    HandlerDef {
339        name: "memory.vacuum",
340        description: "Run SQLite VACUUM to reclaim space freed by soft-deleted rows.",
341        visibility: Visibility::Verb,
342        category: VerbCategory::Commissive,
343        params: &[],
344    },
345];
346
347// ── Inventory self-registration ───────────────────────────────────────────────
348
349struct MemoryPackFactory;
350
351impl khive_runtime::PackFactory for MemoryPackFactory {
352    fn name(&self) -> &'static str {
353        "memory"
354    }
355
356    fn requires(&self) -> &'static [&'static str] {
357        &["kg"]
358    }
359
360    fn create(&self, runtime: KhiveRuntime) -> Box<dyn khive_runtime::PackRuntime> {
361        Box::new(MemoryPack::new(runtime))
362    }
363}
364
365inventory::submit! { khive_runtime::PackRegistration(&MemoryPackFactory) }
366
367#[async_trait]
368impl PackRuntime for MemoryPack {
369    fn name(&self) -> &str {
370        <MemoryPack as Pack>::NAME
371    }
372
373    fn note_kinds(&self) -> &'static [&'static str] {
374        <MemoryPack as Pack>::NOTE_KINDS
375    }
376
377    fn entity_kinds(&self) -> &'static [&'static str] {
378        <MemoryPack as Pack>::ENTITY_KINDS
379    }
380
381    fn handlers(&self) -> &'static [HandlerDef] {
382        &MEMORY_HANDLERS
383    }
384
385    fn requires(&self) -> &'static [&'static str] {
386        <MemoryPack as Pack>::REQUIRES
387    }
388
389    fn schema_plan(&self) -> SchemaPlan {
390        SchemaPlan {
391            pack: "memory",
392            statements: &MEMORY_SCHEMA_PLAN_STMTS,
393        }
394    }
395
396    async fn warm(&self) {
397        crate::ann::warm_existing_memory_indexes(&self.runtime, &self.ann).await;
398        fts_population_guard(&self.runtime).await;
399    }
400
401    /// Report registered models for remember's dispatch resource accounting.
402    fn registered_embedding_model_names(&self) -> Vec<String> {
403        self.runtime.registered_embedding_model_names()
404    }
405
406    /// Install memory-note generation bumps on this pack's own runtime.
407    ///
408    /// Generic KG mutation paths preserve the stale graph and schedule replacement. See
409    /// `crates/khive-pack-memory/docs/api/pack-integration.md`.
410    fn register_note_mutation_hook(&self, _runtime: &KhiveRuntime) {
411        let runtime = self.runtime.clone();
412        let ann = self.ann.clone();
413        let hook: khive_runtime::NoteMutationHookFn = std::sync::Arc::new(move |kind, _id| {
414            let runtime = runtime.clone();
415            let ann = ann.clone();
416            Box::pin(async move {
417                if kind != "memory" {
418                    return;
419                }
420                let Ok(token) = runtime.authorize(khive_runtime::Namespace::local()) else {
421                    return;
422                };
423                for model in runtime.registered_embedding_model_names() {
424                    let key = crate::ann::AnnKey::new("local", model.as_str());
425                    crate::ann::bump_generation(&ann, &key).await;
426                    crate::ann::ensure_ann_background(&runtime, &token, &ann, &model).await;
427                }
428            })
429        });
430        self.runtime.install_note_mutation_hook(hook);
431    }
432
433    async fn dispatch(
434        &self,
435        verb: &str,
436        params: Value,
437        registry: &VerbRegistry,
438        token: &NamespaceToken,
439    ) -> Result<Value, RuntimeError> {
440        match verb {
441            "memory.remember" => self.handle_remember(token, params).await,
442            "memory.feedback" => self.handle_feedback(token, params, registry).await,
443            "memory.recall" => {
444                self.handle_recall_with_deadline(token, params, registry)
445                    .await
446            }
447            "memory.recall_embed" => self.handle_recall_embed(params).await,
448            "memory.recall_candidates" => self.handle_recall_candidates(token, params).await,
449            "memory.recall_fuse" => self.handle_recall_fuse(token, params, registry).await,
450            "memory.recall_rerank" => self.handle_recall_rerank(params).await,
451            "memory.recall_score" => self.handle_recall_score(params).await,
452            "memory.prune" => self.handle_prune(token, params).await,
453            "memory.vacuum" => self.handle_vacuum(params).await,
454            _ => Err(RuntimeError::InvalidInput(format!(
455                "memory pack does not handle verb {verb:?}"
456            ))),
457        }
458    }
459}
460
461impl MemoryPack {
462    /// Run the complete recall pipeline under its validated end-to-end deadline.
463    ///
464    /// Timeout returns `DeadlineExceeded` without claiming to cancel runtime-owned storage
465    /// work. See `crates/khive-pack-memory/docs/api/recall-pipeline.md`.
466    async fn handle_recall_with_deadline(
467        &self,
468        token: &NamespaceToken,
469        params: Value,
470        registry: &VerbRegistry,
471    ) -> Result<Value, RuntimeError> {
472        let budget_ms = match parse_recall_deadline_override(&params)? {
473            Some(ms) => ms,
474            None => recall_deadline_ms(),
475        };
476        let start = std::time::Instant::now();
477        match tokio::time::timeout(
478            std::time::Duration::from_millis(budget_ms),
479            self.handle_recall(token, params, registry),
480        )
481        .await
482        {
483            Ok(result) => result,
484            Err(_) => Err(RuntimeError::DeadlineExceeded {
485                operation: "memory.recall".to_string(),
486                budget_ms,
487                elapsed_ms: start.elapsed().as_millis() as u64,
488            }),
489        }
490    }
491}
492
493/// Parse an optional positive per-request deadline; null or absence means fallback.
494pub(crate) fn parse_recall_deadline_override(params: &Value) -> Result<Option<u64>, RuntimeError> {
495    let Some(raw) = params
496        .get("config")
497        .and_then(|c| c.get("recall_deadline_ms"))
498    else {
499        return Ok(None);
500    };
501    if raw.is_null() {
502        return Ok(None);
503    }
504    match raw.as_u64() {
505        Some(ms) if ms > 0 => Ok(Some(ms)),
506        _ => Err(RuntimeError::InvalidInput(format!(
507            "config.recall_deadline_ms must be a positive integer milliseconds value, got {raw}"
508        ))),
509    }
510}
511
512/// Parse the operator deadline, falling back to 30 seconds for absent or invalid input.
513///
514/// Operator mistakes warn instead of breaking every recall; request mistakes are errors.
515pub(crate) fn parse_recall_deadline_env(raw: Option<&str>) -> u64 {
516    const DEFAULT_RECALL_DEADLINE_MS: u64 = 30_000;
517    let Some(raw) = raw else {
518        return DEFAULT_RECALL_DEADLINE_MS;
519    };
520    match raw.parse::<u64>() {
521        Ok(ms) if ms > 0 => ms,
522        Ok(_) => {
523            tracing::warn!(
524                raw = %raw,
525                default_ms = DEFAULT_RECALL_DEADLINE_MS,
526                "KHIVE_MEMORY_RECALL_DEADLINE_MS=0 is not a valid recall deadline; \
527                 falling back to the default (#889)"
528            );
529            DEFAULT_RECALL_DEADLINE_MS
530        }
531        Err(_) => {
532            tracing::warn!(
533                raw = %raw,
534                default_ms = DEFAULT_RECALL_DEADLINE_MS,
535                "KHIVE_MEMORY_RECALL_DEADLINE_MS is not a valid positive integer; \
536                 falling back to the default (#889)"
537            );
538            DEFAULT_RECALL_DEADLINE_MS
539        }
540    }
541}
542
543/// Return the cached end-to-end recall deadline, defaulting to 30 seconds.
544pub(crate) fn recall_deadline_ms() -> u64 {
545    static DEADLINE_MS: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
546    *DEADLINE_MS.get_or_init(|| {
547        let raw = std::env::var("KHIVE_MEMORY_RECALL_DEADLINE_MS").ok();
548        let ms = parse_recall_deadline_env(raw.as_deref());
549        khive_runtime::config_ledger::record_config_locked(
550            "KHIVE_MEMORY_RECALL_DEADLINE_MS",
551            ms.to_string(),
552        );
553        ms
554    })
555}
556
557/// Warn when a nontrivial base table has less than half its rows represented in FTS.
558/// Never fail boot for an empty, fresh, or partially migrated database.
559async fn fts_population_guard(rt: &KhiveRuntime) {
560    use khive_storage::types::{SqlStatement, SqlValue};
561
562    let sql = rt.sql();
563
564    let Ok(mut reader) = sql.reader().await else {
565        tracing::warn!("fts_population_guard: could not open SQL reader — skipping check");
566        return;
567    };
568
569    for (base_table, fts_table) in [("notes", "fts_notes"), ("entities", "fts_entities")] {
570        let base_row = reader
571            .query_row(SqlStatement {
572                sql: format!("SELECT COUNT(*) AS cnt FROM {base_table} WHERE deleted_at IS NULL"),
573                params: vec![],
574                label: None,
575            })
576            .await;
577
578        let base_count: u64 = match base_row {
579            Ok(Some(r)) => match r.get("cnt") {
580                Some(SqlValue::Integer(n)) => *n as u64,
581                _ => 0,
582            },
583            _ => 0,
584        };
585
586        if base_count <= 100 {
587            continue;
588        }
589
590        let fts_row = reader
591            .query_row(SqlStatement {
592                sql: format!("SELECT COUNT(*) AS cnt FROM {fts_table}"),
593                params: vec![],
594                label: None,
595            })
596            .await;
597
598        let fts_count: u64 = match fts_row {
599            Ok(Some(r)) => match r.get("cnt") {
600                Some(SqlValue::Integer(n)) => *n as u64,
601                _ => 0,
602            },
603            _ => 0,
604        };
605
606        if fts_count < base_count / 2 {
607            tracing::warn!(
608                base_table,
609                fts_table,
610                base_count,
611                fts_count,
612                "FTS table is severely under-populated relative to base rows. \
613                 FTS recall will return near-nothing. This is typically caused by \
614                 a V3→V4 schema migration that did not run `kkernel reindex`. \
615                 Fix: run `kkernel reindex --no-knowledge` to repopulate {fts_table}."
616            );
617        }
618    }
619}
620
621// ── MAJ-1 regression test: second recall routes through warm ANN ──────────────
622
623#[cfg(test)]
624mod ann_route_tests {
625    use super::*;
626    use std::sync::Arc;
627
628    use async_trait::async_trait;
629    use khive_pack_kg::KgPack;
630    use khive_runtime::{EmbedderProvider, Namespace, RuntimeConfig, VerbRegistryBuilder};
631    use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService};
632    use serial_test::serial;
633
634    // Deterministic embedding service: distinct vector per unique text via FNV hash.
635    struct HashVecService {
636        dims: usize,
637    }
638
639    fn fnv_to_vec(text: &str, dims: usize) -> Vec<f32> {
640        let mut h: u64 = 0xcbf2_9ce4_8422_2325;
641        for b in text.bytes() {
642            h ^= b as u64;
643            h = h.wrapping_mul(0x0000_0001_0000_01b3);
644        }
645        let mut v = Vec::with_capacity(dims);
646        let mut s = h;
647        for _ in 0..dims {
648            s = s
649                .wrapping_mul(6_364_136_223_846_793_005)
650                .wrapping_add(1_442_695_040_888_963_407);
651            v.push(((s >> 33) as f32) / (0x7fff_ffff_u32 as f32) - 1.0);
652        }
653        v
654    }
655
656    #[async_trait]
657    impl EmbeddingService for HashVecService {
658        async fn embed(
659            &self,
660            texts: &[String],
661            _model: EmbeddingModel,
662        ) -> Result<Vec<Vec<f32>>, EmbedError> {
663            Ok(texts.iter().map(|t| fnv_to_vec(t, self.dims)).collect())
664        }
665
666        fn supports_model(&self, _model: EmbeddingModel) -> bool {
667            true
668        }
669
670        fn name(&self) -> &'static str {
671            "hash-vec"
672        }
673    }
674
675    struct HashVecProvider {
676        model_name: String,
677        dims: usize,
678    }
679
680    #[async_trait]
681    impl EmbedderProvider for HashVecProvider {
682        fn name(&self) -> &str {
683            &self.model_name
684        }
685
686        fn dimensions(&self) -> usize {
687            self.dims
688        }
689
690        async fn build(&self) -> Result<Arc<dyn EmbeddingService>, khive_runtime::RuntimeError> {
691            Ok(Arc::new(HashVecService { dims: self.dims }))
692        }
693    }
694
695    /// The second recall uses the installed ANN index, measured by its route counter.
696    /// See `crates/khive-pack-memory/docs/api/ann-lifecycle.md`.
697    #[tokio::test]
698    #[serial(background_tasks)]
699    async fn recall_second_call_uses_warm_ann_route() {
700        let tmp = tempfile::Builder::new()
701            .prefix("khive-memory-ann-route-")
702            .tempdir_in(std::env::temp_dir())
703            .expect("temp /tmp db dir");
704        let db_path = tmp.path().join("khive-graph.db");
705
706        const MODEL: &str = "ann-route-test-model";
707        const DIMS: usize = 32;
708
709        let rt = KhiveRuntime::new(RuntimeConfig {
710            db_path: Some(db_path),
711            embedding_model: None,
712            additional_embedding_models: vec![],
713            ..RuntimeConfig::default()
714        })
715        .expect("runtime");
716        rt.register_embedder(HashVecProvider {
717            model_name: MODEL.to_owned(),
718            dims: DIMS,
719        });
720
721        let ns = Namespace::parse("local").expect("local namespace");
722        let token = rt.authorize(ns).expect("authorize local");
723
724        // Create notes with embedding_model: None so the runtime auto-detects
725        // the registered custom provider (resolve_embedding_model only handles
726        // lattice aliases; custom provider names must go through the auto-detect path).
727        for i in 0..32u32 {
728            rt.create_note_with_decay_for_embedding_model(
729                &token,
730                "memory",
731                None,
732                &format!("ann warm route note {i}"),
733                Some(0.7),
734                0.01,
735                None,
736                vec![],
737                None,
738            )
739            .await
740            .expect("create note");
741        }
742
743        let pack = MemoryPack::new(rt.clone());
744        let ann = pack.ann_for_test();
745
746        let mut builder = VerbRegistryBuilder::new();
747        builder.register(KgPack::new(rt.clone()));
748        builder.register(pack);
749        let registry = builder.build().expect("registry");
750
751        // First recall: triggers synchronous ANN build on cache miss.
752        // No explicit embedding_model — auto-detects ann-route-test-model.
753        registry
754            .dispatch(
755                "memory.recall",
756                serde_json::json!({
757                    "query": "ann warm route note 7",
758                    "limit": 10
759                }),
760            )
761            .await
762            .expect("first recall");
763
764        ann.reset_warm_route_count();
765
766        // Second recall: index is already loaded — must go through warm ANN.
767        registry
768            .dispatch(
769                "memory.recall",
770                serde_json::json!({
771                    "query": "ann warm route note 7",
772                    "limit": 10
773                }),
774            )
775            .await
776            .expect("second recall");
777
778        assert!(
779            ann.warm_route_count() > 0,
780            "second recall must route through warm ANN, not exact sqlite-vec fallback"
781        );
782    }
783}
784
785/// Mutation-hook tests assert ANN generation staleness directly after corpus changes.
786/// See `crates/khive-pack-memory/docs/recall-reliability.md`.
787#[cfg(test)]
788mod note_mutation_hook_tests {
789    use super::*;
790    use crate::ann;
791    use khive_pack_kg::KgPack;
792    use khive_runtime::VerbRegistryBuilder;
793    use serde_json::json;
794    use serial_test::serial;
795    use uuid::Uuid;
796
797    const FR1_MODEL: &str = "fr1-mutation-hook-model";
798
799    /// Builds a registry with the production note-mutation hook and returns its ANN state.
800    fn build_note_hook_registry(rt: &KhiveRuntime) -> (khive_runtime::VerbRegistry, SharedAnn) {
801        let mut builder = VerbRegistryBuilder::new();
802        builder.register(KgPack::new(rt.clone()));
803        let memory_pack = MemoryPack::new(rt.clone());
804        let ann = memory_pack.ann_for_test();
805        builder.register(memory_pack);
806        let registry = builder.build().expect("registry builds");
807        registry.call_register_note_mutation_hooks(rt);
808        (registry, ann)
809    }
810
811    fn mutation_hook_ann_key() -> ann::AnnKey {
812        ann::AnnKey::new("local", FR1_MODEL)
813    }
814
815    /// Seeds one note, warms ANN through recall, and verifies the pre-mutation state.
816    async fn seed_and_warm_ann(
817        rt: &KhiveRuntime,
818        registry: &khive_runtime::VerbRegistry,
819        ann: &SharedAnn,
820        content: &str,
821        salience: f64,
822    ) -> Uuid {
823        rt.register_embedder(Fr1FixedVecProvider {
824            model_name: FR1_MODEL.to_string(),
825            vector: [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
826        });
827        let id = registry
828            .dispatch(
829                "memory.remember",
830                json!({"content": content, "salience": salience}),
831            )
832            .await
833            .expect("seed remember")["id"]
834            .as_str()
835            .expect("id")
836            .parse::<Uuid>()
837            .expect("valid uuid");
838
839        registry
840            .dispatch(
841                "memory.recall",
842                json!({
843                    "query": content,
844                    "namespace": "local",
845                    "fusion_strategy": "vector_only",
846                    "embedding_model": FR1_MODEL,
847                }),
848            )
849            .await
850            .expect("warm recall");
851
852        assert!(
853            ann::is_current(ann, &mutation_hook_ann_key()).await,
854            "sanity: warm-up recall must leave the ANN cache current before \
855             the mutation under test"
856        );
857        id
858    }
859
860    #[tokio::test]
861    #[serial(background_tasks)]
862    async fn prune_invalidates_warm_ann_without_subsequent_remember() {
863        let rt = KhiveRuntime::memory().expect("in-memory runtime");
864        let (registry, ann) = build_note_hook_registry(&rt);
865
866        seed_and_warm_ann(&rt, &registry, &ann, "fr1 prune target content", 0.9).await;
867
868        // `min_salience: 1.0` is strictly above the seeded note's 0.9
869        // salience, so it is the one candidate. No `memory.remember` call
870        // follows.
871        let pruned = registry
872            .dispatch(
873                "memory.prune",
874                json!({ "min_salience": 1.0, "namespace": "local" }),
875            )
876            .await
877            .expect("prune");
878        assert_eq!(
879            pruned["pruned"], 1,
880            "the seeded note must be the one candidate pruned: {pruned:?}"
881        );
882
883        assert!(
884            !ann::is_current(&ann, &mutation_hook_ann_key()).await,
885            "memory.prune deleting a candidate must invalidate the warm ANN \
886             generation for affected models (#750)"
887        );
888    }
889
890    #[tokio::test]
891    #[serial(background_tasks)]
892    async fn kg_update_reindex_invalidates_warm_ann_without_subsequent_remember() {
893        let rt = KhiveRuntime::memory().expect("in-memory runtime");
894        let (registry, ann) = build_note_hook_registry(&rt);
895
896        let id = seed_and_warm_ann(&rt, &registry, &ann, "fr1 update target content", 0.7).await;
897
898        // KG's generic `update` verb — NOT `memory.remember` — changes the
899        // note's content. Same call shape `khive-pack-kg/src/handlers/
900        // update.rs` dispatches through `KhiveRuntime::update_note`; no
901        // `kind` param needed, the UUID resolves the substrate.
902        registry
903            .dispatch(
904                "update",
905                json!({ "id": id.to_string(), "content": "entirely different rewritten content" }),
906            )
907            .await
908            .expect("kg update on memory-kind note");
909
910        assert!(
911            !ann::is_current(&ann, &mutation_hook_ann_key()).await,
912            "a KG `update` that changes a memory-kind note's content must \
913             invalidate the warm ANN generation (#750)"
914        );
915    }
916
917    #[tokio::test]
918    #[serial(background_tasks)]
919    async fn kg_delete_invalidates_warm_ann_without_subsequent_remember() {
920        let rt = KhiveRuntime::memory().expect("in-memory runtime");
921        let (registry, ann) = build_note_hook_registry(&rt);
922
923        let id = seed_and_warm_ann(&rt, &registry, &ann, "fr1 delete target content", 0.7).await;
924
925        // KG's generic `delete` verb (soft delete by default) — NOT any
926        // memory-pack verb.
927        let deleted = registry
928            .dispatch("delete", json!({ "id": id.to_string() }))
929            .await
930            .expect("kg delete on memory-kind note");
931        assert_eq!(
932            deleted["deleted"].as_bool(),
933            Some(true),
934            "delete must report success: {deleted:?}"
935        );
936
937        assert!(
938            !ann::is_current(&ann, &mutation_hook_ann_key()).await,
939            "a KG `delete` on a memory-kind note must invalidate the warm \
940             ANN generation (#750)"
941        );
942    }
943
944    /// A real merge invalidates an index warmed only after both notes were seeded.
945    /// See `crates/khive-pack-memory/docs/recall-reliability.md`.
946    #[tokio::test]
947    #[serial(background_tasks)]
948    async fn kg_merge_invalidates_warm_ann_without_subsequent_remember() {
949        let rt = KhiveRuntime::memory().expect("in-memory runtime");
950        let (registry, ann) = build_note_hook_registry(&rt);
951
952        rt.register_embedder(Fr1FixedVecProvider {
953            model_name: FR1_MODEL.to_string(),
954            vector: [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
955        });
956
957        let into_id = registry
958            .dispatch(
959                "memory.remember",
960                json!({"content": "fr2 merge into content", "salience": 0.7}),
961            )
962            .await
963            .expect("seed into-note")["id"]
964            .as_str()
965            .expect("id")
966            .parse::<Uuid>()
967            .expect("valid uuid");
968        let from_id = registry
969            .dispatch(
970                "memory.remember",
971                json!({"content": "fr2 merge from content", "salience": 0.7}),
972            )
973            .await
974            .expect("seed from-note")["id"]
975            .as_str()
976            .expect("id")
977            .parse::<Uuid>()
978            .expect("valid uuid");
979
980        // ONE warm-up recall, after BOTH notes exist.
981        registry
982            .dispatch(
983                "memory.recall",
984                json!({
985                    "query": "fr2 merge into content",
986                    "namespace": "local",
987                    "fusion_strategy": "vector_only",
988                    "embedding_model": FR1_MODEL,
989                }),
990            )
991            .await
992            .expect("warm recall");
993        // Trust freshness only after all single-flight rebuilds release the warm guard.
994        ann::wait_until_warm_idle(&ann, &mutation_hook_ann_key()).await;
995        assert!(
996            ann::is_current(&ann, &mutation_hook_ann_key()).await,
997            "sanity: warm-up recall must leave the ANN cache current before \
998             the merge under test"
999        );
1000
1001        registry
1002            .dispatch(
1003                "merge",
1004                json!({
1005                    "into_id": into_id.to_string(),
1006                    "from_id": from_id.to_string(),
1007                    "kind": "memory",
1008                }),
1009            )
1010            .await
1011            .expect("kg merge on memory-kind notes");
1012
1013        assert!(
1014            !ann::is_current(&ann, &mutation_hook_ann_key()).await,
1015            "a KG `merge` of two memory-kind notes must invalidate the warm \
1016             ANN generation (#750)"
1017        );
1018    }
1019
1020    struct Fr1FixedVecProvider {
1021        model_name: String,
1022        vector: [f32; 8],
1023    }
1024
1025    #[async_trait::async_trait]
1026    impl khive_runtime::EmbedderProvider for Fr1FixedVecProvider {
1027        fn name(&self) -> &str {
1028            &self.model_name
1029        }
1030
1031        fn dimensions(&self) -> usize {
1032            8
1033        }
1034
1035        async fn build(
1036            &self,
1037        ) -> Result<std::sync::Arc<dyn lattice_embed::EmbeddingService>, RuntimeError> {
1038            Ok(std::sync::Arc::new(Fr1FixedVecService {
1039                vector: self.vector,
1040            }))
1041        }
1042    }
1043
1044    struct Fr1FixedVecService {
1045        vector: [f32; 8],
1046    }
1047
1048    #[async_trait::async_trait]
1049    impl lattice_embed::EmbeddingService for Fr1FixedVecService {
1050        async fn embed(
1051            &self,
1052            texts: &[String],
1053            _model: lattice_embed::EmbeddingModel,
1054        ) -> Result<Vec<Vec<f32>>, lattice_embed::EmbedError> {
1055            // Every text maps to the SAME fixed vector — deterministic
1056            // cosine=1.0 between any query and any seeded content under this
1057            // provider, so ANN warming/hit behavior is fully controlled by
1058            // this test module, not by real embedding semantics.
1059            Ok(texts.iter().map(|_| self.vector.to_vec()).collect())
1060        }
1061
1062        fn supports_model(&self, _model: lattice_embed::EmbeddingModel) -> bool {
1063            true
1064        }
1065
1066        fn name(&self) -> &'static str {
1067            "fr1-fixed-vec"
1068        }
1069    }
1070}