1use 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
21pub struct MemoryPack {
23 pub(crate) runtime: KhiveRuntime,
24 pub(crate) config: Mutex<RecallConfig>,
26 pub(crate) ann: SharedAnn,
28 pub(crate) query_cache: QueryEmbeddingCache,
30 pub(crate) recall_state: Mutex<BalancedRecallState>,
32 pub(crate) brain_profile: Option<String>,
34}
35
36impl MemoryPack {
37 pub(crate) fn active_config(&self) -> RecallConfig {
39 self.config.lock().unwrap().clone()
40 }
41
42 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 const SCHEMA_PLAN: Option<PackSchemaPlan> = Some(PackSchemaPlan {
71 pack: "memory",
72 statements: &MEMORY_SCHEMA_PLAN_STMTS,
73 });
74}
75
76static MEMORY_HANDLERS: [HandlerDef; 10] = [
80 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 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 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 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 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 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
347struct 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 fn registered_embedding_model_names(&self) -> Vec<String> {
403 self.runtime.registered_embedding_model_names()
404 }
405
406 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 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(¶ms)? {
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
493pub(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
512pub(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
543pub(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
557async 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#[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 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 #[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 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 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 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#[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 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 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, ®istry, &ann, "fr1 prune target content", 0.9).await;
867
868 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, ®istry, &ann, "fr1 update target content", 0.7).await;
897
898 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, ®istry, &ann, "fr1 delete target content", 0.7).await;
924
925 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 #[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 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 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 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}