1use super::*;
2
3#[derive(Debug, Clone, Default)]
9pub struct RecallParams<'a> {
10 pub query: &'a str,
11 pub budget: usize,
12 pub trace: bool,
13 pub include_sparks: bool,
14 pub top: Option<usize>,
15 pub source: &'a str,
16 pub expand_deps: &'a str, pub allow_trim: bool, pub refine_mode: &'a str, pub min_score: Option<f64>,
24 pub session_only: bool,
31 pub rerank: bool,
36}
37
38impl KnowledgeBase {
39 pub fn recall(&self, params: RecallParams<'_>) -> Result<RecallResult> {
40 let op = if params.source == "hook" {
43 "hook_recall"
44 } else {
45 "recall"
46 };
47 let src = params.source.to_string();
48 self.measure(op, Some(&src), None, || self.recall_inner(params))
49 }
50
51 fn recall_inner(&self, params: RecallParams<'_>) -> Result<RecallResult> {
52 let RecallParams {
53 query,
54 budget,
55 trace,
56 include_sparks,
57 top,
58 source,
59 expand_deps,
60 allow_trim,
61 refine_mode,
62 min_score,
63 session_only,
64 rerank,
65 } = params;
66 let expand_deps = if expand_deps.is_empty() {
67 "false"
68 } else {
69 expand_deps
70 };
71 let refine_mode = if refine_mode.is_empty() {
72 "off"
73 } else {
74 refine_mode
75 };
76 validate_source(source)?;
77 let trace_id = gen_uuid();
78 let now = utc_now_iso();
79
80 let situation = Situation::from_query(query);
84 let context_key = situation.context_key(&self.situation_coarse_keys);
85
86 let embed_query = if self.embed_situation_signature {
90 let sig = situation.coarse_signature(&self.situation_coarse_keys);
91 if signature_has_signal(&sig) {
92 format!("{sig}\n{query}")
93 } else {
94 query.to_string()
95 }
96 } else {
97 query.to_string()
98 };
99
100 let (q_content, q_trigger) = self.measure("embed", Some(source), None, || {
104 self.embedding
105 .embed_both(&embed_query)
106 .map_err(|e| InnateError::EmbeddingUnavailable(e.to_string()))
107 })?;
108
109 let mut candidates = self.ann_candidates(&q_content, &q_trigger, query)?;
111 self.apply_soft_dep_bonus(&mut candidates)?;
112 self.expand_by_spreading(&mut candidates, query)?;
117
118 let channels = json!({
122 "content": candidates.values().filter(|c| c.sim_content > 0.0).count(),
123 "trigger": candidates.values().filter(|c| c.sim_trigger > 0.0).count(),
124 "lexical": candidates.values().filter(|c| c.sim_lexical > 0.0).count(),
125 "spread": candidates.values().filter(|c| c.sim_spread > 0.0).count(),
126 });
127
128 let mut scored = self.score_candidates(candidates, query, &context_key, &now)?;
130
131 if rerank {
134 self.apply_rerank(query, &mut scored);
135 }
136
137 if let Some(min) = min_score {
140 scored.retain(|(fused, chunk)| {
141 chunk
142 .get("_gate_score")
143 .and_then(Value::as_f64)
144 .unwrap_or(*fused)
145 >= min
146 });
147 }
148
149 let (selected, skipped, skipped_reasons) =
151 self.pack(&scored, budget, expand_deps, allow_trim, query)?;
152
153 let depth_skipped: Vec<String> = skipped_reasons
154 .iter()
155 .filter(|(_, r)| r.as_str() == "dep_depth_limit")
156 .map(|(id, _)| id.clone())
157 .collect();
158
159 let mut selected = selected;
161 if self.density_refill {
162 selected = self.density_refill(selected, &skipped, budget);
163 }
164
165 let limited = limit_knowledge(selected, top);
166 let visible = if refine_mode == "adapt" {
167 self.refiner
168 .refine(limited.clone(), Some(budget))
169 .unwrap_or(limited)
170 } else {
171 limited
172 };
173
174 let sparks = if include_sparks {
176 self.recall_sparks(&q_content, &q_trigger)?
177 } else {
178 vec![]
179 };
180
181 if trace {
182 let max_score = scored
185 .iter()
186 .map(|(f, _)| *f)
187 .fold(f64::NEG_INFINITY, f64::max);
188 let sel_scores: Vec<f64> = visible
189 .iter()
190 .filter_map(|c| c.get("_fused_score").and_then(Value::as_f64))
191 .collect();
192 let r3 = |x: f64| (x * 1000.0).round() / 1000.0;
193 let selected_tokens: i64 = visible
194 .iter()
195 .filter_map(|c| c.get("token_count").and_then(Value::as_i64))
196 .sum();
197 let recall_meta = json!({
198 "budget": budget,
199 "top": top,
200 "expand_deps": expand_deps,
201 "rerank": rerank,
202 "channels": channels,
203 "scores": {
204 "max": if max_score.is_finite() { r3(max_score) } else { 0.0 },
205 "min_selected": if sel_scores.is_empty() { 0.0 }
206 else { r3(sel_scores.iter().cloned().fold(f64::INFINITY, f64::min)) },
207 "avg_selected": if sel_scores.is_empty() { 0.0 }
208 else { r3(sel_scores.iter().sum::<f64>() / sel_scores.len() as f64) },
209 },
210 "packing": {
211 "selected_tokens": selected_tokens,
212 "skipped_by_budget": skipped.len(),
213 "skipped_by_dep_depth": depth_skipped.len(),
214 },
215 });
216 self.write_recall_trace(
217 &trace_id,
218 query,
219 &context_key,
220 &scored,
221 &visible,
222 &sparks,
223 &depth_skipped,
224 &skipped_reasons,
225 refine_mode,
226 source,
227 &now,
228 session_only,
229 &recall_meta,
230 )?;
231 }
232
233 let empty = visible.is_empty() && sparks.is_empty();
234 Ok(RecallResult {
235 knowledge: visible,
236 sparks,
237 trace_id,
238 empty,
239 depth_skipped,
240 skipped_reasons,
241 })
242 }
243
244 pub(super) fn ann_candidates(
245 &self,
246 q_content: &[f32],
247 q_trigger: &[f32],
248 query: &str,
249 ) -> Result<HashMap<String, CandidateInfo>> {
250 let embed_version = self
251 .storage
252 .get_meta("embed_version")?
253 .and_then(|v| v.parse::<i64>().ok())
254 .unwrap_or(1);
255
256 let content_res = self
257 .storage
258 .search_vec_content(q_content, self.top_k_candidates * 2)?;
259 let trigger_res = self
260 .storage
261 .search_vec_trigger(q_trigger, self.top_k_candidates * 2)?;
262 let lexical_res = self
266 .storage
267 .search_lexical(query, self.top_k_candidates * 2)?;
268
269 let all_ids: Vec<&str> = {
271 let mut seen = HashSet::new();
272 content_res
273 .iter()
274 .chain(trigger_res.iter())
275 .chain(lexical_res.iter())
276 .map(|(id, _)| id.as_str())
277 .filter(|id| seen.insert(*id))
278 .collect()
279 };
280 let chunks = self.storage.get_chunks_by_ids(&all_ids)?;
281
282 let mut candidates: HashMap<String, CandidateInfo> = HashMap::new();
283 for (cid, sim) in &content_res {
284 if let Some(chunk) = chunks.get(cid) {
285 if chunk_is_valid_for_recall(chunk, embed_version) {
286 let e = candidates.entry(cid.clone()).or_insert_with(|| new_candidate(chunk));
287 e.sim_content = e.sim_content.max(*sim);
288 }
289 }
290 }
291 for (cid, sim) in &trigger_res {
292 if let Some(chunk) = chunks.get(cid) {
293 if chunk_is_valid_for_recall(chunk, embed_version) {
294 let e = candidates.entry(cid.clone()).or_insert_with(|| new_candidate(chunk));
295 e.sim_trigger = e.sim_trigger.max(*sim);
296 }
297 }
298 }
299 for (cid, sim) in &lexical_res {
300 if let Some(chunk) = chunks.get(cid) {
301 if chunk_is_valid_for_recall(chunk, embed_version) {
302 let e = candidates.entry(cid.clone()).or_insert_with(|| new_candidate(chunk));
303 e.sim_lexical = e.sim_lexical.max(*sim);
304 }
305 }
306 }
307 Ok(candidates)
308 }
309
310 pub(super) fn expand_by_spreading(
324 &self,
325 candidates: &mut HashMap<String, CandidateInfo>,
326 query: &str,
327 ) -> Result<()> {
328 if self.w_spread <= 0.0 {
329 return Ok(());
330 }
331
332 let mut source_act: HashMap<String, f64> = HashMap::new();
335 for e in crate::entities::extract_entities(query, None) {
336 *source_act.entry(e.entity).or_insert(0.0) += 1.0;
337 }
338
339 let mut seeds: Vec<(String, f32)> = candidates
340 .iter()
341 .map(|(id, info)| {
342 let base = info
343 .sim_content
344 .max(info.sim_trigger)
345 .max(info.sim_lexical);
346 (id.clone(), base)
347 })
348 .filter(|(_, base)| *base > 0.0)
349 .collect();
350 seeds.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
351 seeds.truncate(self.spread_seed_n);
352 if !seeds.is_empty() {
353 let seed_ids: Vec<&str> = seeds.iter().map(|(id, _)| id.as_str()).collect();
354 let seed_ents = self.storage.entities_for_chunks(&seed_ids)?;
355 for (id, weight) in &seeds {
356 if let Some(ents) = seed_ents.get(id) {
357 for ent in ents {
358 *source_act.entry(ent.clone()).or_insert(0.0) += *weight as f64;
359 }
360 }
361 }
362 }
363 if source_act.is_empty() {
364 return Ok(());
365 }
366
367 let ents: Vec<&str> = source_act.keys().map(String::as_str).collect();
369 let links = self.storage.entity_links(&ents)?;
370 let mut by_entity: HashMap<&str, Vec<&str>> = HashMap::new();
371 for (entity, chunk_id) in &links {
372 by_entity.entry(entity).or_default().push(chunk_id);
373 }
374 let mut spread_raw: HashMap<String, f64> = HashMap::new();
375 for (entity, chunk_ids) in &by_entity {
376 let fan = chunk_ids.len() as i64;
377 if fan == 0 || fan > self.spread_fan_cap {
378 continue; }
380 let a_e = source_act.get(*entity).copied().unwrap_or(0.0);
381 if a_e <= 0.0 {
382 continue;
383 }
384 let contribution = a_e / fan as f64;
385 for cid in chunk_ids {
386 *spread_raw.entry((*cid).to_string()).or_insert(0.0) += contribution;
387 }
388 }
389 if spread_raw.is_empty() {
390 return Ok(());
391 }
392 let max = spread_raw.values().cloned().fold(0.0_f64, f64::max);
393 if max <= 0.0 {
394 return Ok(());
395 }
396
397 let embed_version = self
399 .storage
400 .get_meta("embed_version")?
401 .and_then(|v| v.parse::<i64>().ok())
402 .unwrap_or(1);
403 let new_ids: Vec<String> = spread_raw
404 .keys()
405 .filter(|id| !candidates.contains_key(*id))
406 .cloned()
407 .collect();
408 let fetched = if new_ids.is_empty() {
409 HashMap::new()
410 } else {
411 let refs: Vec<&str> = new_ids.iter().map(String::as_str).collect();
412 self.storage.get_chunks_by_ids(&refs)?
413 };
414
415 for (cid, raw) in spread_raw {
416 let norm = (raw / max) as f32;
417 if let Some(info) = candidates.get_mut(&cid) {
418 info.sim_spread = info.sim_spread.max(norm);
419 } else if let Some(chunk) = fetched.get(&cid) {
420 if chunk_is_valid_for_recall(chunk, embed_version) {
421 let e = candidates.entry(cid).or_insert_with(|| new_candidate(chunk));
422 e.sim_spread = e.sim_spread.max(norm);
423 }
424 }
425 }
426 Ok(())
427 }
428
429 pub(super) fn apply_soft_dep_bonus(
430 &self,
431 candidates: &mut HashMap<String, CandidateInfo>,
432 ) -> Result<()> {
433 let src_ids: Vec<String> = candidates
436 .iter()
437 .filter(|(_, info)| info.chunk.get("origin").and_then(Value::as_str) != Some("spark"))
438 .map(|(cid, _)| cid.clone())
439 .collect();
440 if src_ids.is_empty() {
441 return Ok(());
442 }
443 let src_refs: Vec<&str> = src_ids.iter().map(String::as_str).collect();
444 let deps_map = self.storage.get_deps_batch(&src_refs)?;
445
446 let mut target_ids: Vec<String> = Vec::new();
449 let mut seen: HashSet<String> = HashSet::new();
450 for deps in deps_map.values() {
451 for (dst, kind, _) in deps {
452 if kind == "soft" && seen.insert(dst.clone()) {
453 target_ids.push(dst.clone());
454 }
455 }
456 }
457 if target_ids.is_empty() {
458 return Ok(());
459 }
460 let target_refs: Vec<&str> = target_ids.iter().map(String::as_str).collect();
461 let targets = self.storage.get_chunks_by_ids(&target_refs)?;
462
463 for src in &src_ids {
464 let Some(deps) = deps_map.get(src) else {
465 continue;
466 };
467 for (dst, kind, _) in deps {
468 if kind != "soft" {
469 continue;
470 }
471 let Some(target) = targets.get(dst) else {
472 continue;
473 };
474 if target.get("state").and_then(Value::as_str) == Some("archived") {
475 continue;
476 }
477 if target.get("origin").and_then(Value::as_str) == Some("spark") {
478 continue;
479 }
480 let e = candidates
481 .entry(dst.clone())
482 .or_insert_with(|| new_candidate(target));
483 e.sim_content = (e.sim_content + 0.05).min(1.0);
484 }
485 }
486 Ok(())
487 }
488
489 fn score_candidates(
490 &self,
491 candidates: HashMap<String, CandidateInfo>,
492 query: &str,
493 context_key: &str,
494 now: &str,
495 ) -> Result<Vec<(f64, Value)>> {
496 let cand_ids: Vec<String> = candidates
499 .values()
500 .filter_map(|info| {
501 info.chunk
502 .get("id")
503 .and_then(Value::as_str)
504 .map(str::to_string)
505 })
506 .collect();
507 let cand_refs: Vec<&str> = cand_ids.iter().map(String::as_str).collect();
508 let ctx_scores = self.storage.context_scores_batch(
510 &cand_refs,
511 context_key,
512 RECALL_PRIOR_M,
513 RECALL_BASE_RATE,
514 )?;
515
516 let mut scored: Vec<(f64, Value)> = Vec::with_capacity(candidates.len());
517 for info in candidates.into_values() {
518 let conf = info
519 .chunk
520 .get("confidence")
521 .and_then(Value::as_f64)
522 .unwrap_or(0.5);
523 let chunk_id = info.chunk.get("id").and_then(Value::as_str).unwrap_or("");
524 let context_score = ctx_scores.get(chunk_id).copied().unwrap_or(0.0);
525 let used_count = info
528 .chunk
529 .get("used_count")
530 .and_then(Value::as_i64)
531 .unwrap_or(0);
532 let last_used_at = info.chunk.get("last_used_at").and_then(Value::as_str);
533 let activation = actr_activation(used_count, last_used_at, now);
534 let contribs = [
537 ("content", self.w_content * info.sim_content as f64),
538 ("trigger", self.w_trigger * info.sim_trigger as f64),
539 ("lexical", self.w_lexical * info.sim_lexical as f64),
540 ("spread", self.w_spread * info.sim_spread as f64),
541 ("confidence", self.w_confidence * conf),
542 ("context", self.w_context * context_score),
543 ("activation", self.w_activation * activation),
544 ];
545 let mut fused: f64 = contribs.iter().map(|(_, c)| c).sum();
546 let mut gate_score = fused;
547 if info.chunk.get("state").and_then(Value::as_str) == Some("pending") {
548 fused *= PENDING_RECALL_PENALTY;
549 }
550 let anti = info
551 .chunk
552 .get("anti_trigger_desc")
553 .and_then(Value::as_str)
554 .unwrap_or("");
555 if !anti.is_empty() && anti_trigger_hit(query, anti) {
556 fused *= self.anti_trigger_penalty;
557 gate_score *= self.anti_trigger_penalty;
558 }
559 let mut chunk = info.chunk;
560 chunk["_context_score"] = json!(context_score);
561 chunk["_activation"] = json!(activation);
562 chunk["_sim_lexical"] = json!(info.sim_lexical);
563 chunk["_sim_spread"] = json!(info.sim_spread);
564 chunk["_gate_score"] = json!(gate_score);
565 chunk["_fused_score"] = json!(fused);
566 chunk["match_reason"] = match_reason(&contribs);
567 scored.push((fused, chunk));
568 }
569 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
570 scored.truncate(self.top_k_candidates);
571 Ok(scored)
572 }
573
574 fn apply_rerank(&self, query: &str, scored: &mut [(f64, Value)]) {
579 let chunks: Vec<Value> = scored.iter().map(|(_, c)| c.clone()).collect();
580 let order = match self.reranker.rerank(query, &chunks) {
581 Ok(order) if !order.is_empty() => order,
582 _ => return,
583 };
584 let rank: HashMap<&str, usize> = order
585 .iter()
586 .enumerate()
587 .map(|(i, id)| (id.as_str(), i))
588 .collect();
589 scored.sort_by_key(|(_, c)| {
590 rank.get(c["id"].as_str().unwrap_or(""))
591 .copied()
592 .unwrap_or(usize::MAX)
593 });
594 }
595
596 fn pack(
597 &self,
598 scored: &[(f64, Value)],
599 budget: usize,
600 expand_deps: &str,
601 allow_trim: bool,
602 query: &str,
603 ) -> Result<PackResult> {
604 let mut selected: Vec<Value> = vec![];
605 let mut skipped: Vec<(Vec<Value>, f64, usize)> = vec![];
606 let mut skipped_reasons: HashMap<String, String> = HashMap::new();
607 let mut used_ids: HashSet<String> = HashSet::new();
608 let mut used_tokens: usize = 0;
609
610 for (fused, chunk) in scored {
611 let cid = chunk["id"].as_str().unwrap_or("").to_string();
612 if used_ids.contains(&cid) {
613 continue;
614 }
615
616 let (block, dep_skip_reason) = self.build_dep_block(chunk, expand_deps)?;
618 if let Some(reason) = dep_skip_reason {
619 skipped_reasons.insert(cid, reason);
620 continue;
621 }
622
623 let new_block: Vec<Value> = block
624 .iter()
625 .filter(|b| !used_ids.contains(b["id"].as_str().unwrap_or("")))
626 .cloned()
627 .collect();
628 let cost = block_cost(&new_block);
629
630 if used_tokens + cost <= budget {
631 for b in &block {
632 let bid = b["id"].as_str().unwrap_or("").to_string();
633 if !used_ids.contains(&bid) {
634 let mut b = b.clone();
635 b["_fused_score"] = json!(fused);
636 selected.push(b);
637 used_ids.insert(bid);
638 }
639 }
640 used_tokens += cost;
641 } else if allow_trim {
642 if let Some(trimmed) =
644 self.refiner
645 .trim(&block, query, budget.saturating_sub(used_tokens))
646 {
647 let trim_cost = block_cost(&trimmed);
648 if used_tokens + trim_cost <= budget {
649 for b in &trimmed {
650 let bid = b["id"].as_str().unwrap_or("").to_string();
651 if !used_ids.contains(&bid) {
652 let mut b = b.clone();
653 b["_fused_score"] = json!(fused);
654 b["_trimmed"] = json!(true);
655 selected.push(b);
656 used_ids.insert(bid);
657 }
658 }
659 used_tokens += trim_cost;
660 continue;
661 }
662 }
663 skipped.push((block, *fused, cost));
664 } else {
665 skipped.push((block, *fused, cost));
666 }
667 }
668 Ok((selected, skipped, skipped_reasons))
669 }
670
671 fn build_dep_block(
674 &self,
675 seed: &Value,
676 expand_deps: &str,
677 ) -> Result<(Vec<Value>, Option<String>)> {
678 if expand_deps == "false" || expand_deps.is_empty() {
679 return Ok((vec![seed.clone()], None));
680 }
681 let seed_id = seed["id"].as_str().unwrap_or("");
682 match expand_deps {
683 "direct" => {
684 let deps = self.storage.get_deps(seed_id)?;
685 let mut block = vec![seed.clone()];
686 for (dep_id, kind, _) in &deps {
687 if kind != "hard" {
688 continue;
689 }
690 match self.validate_hard_dep(dep_id)? {
691 Some(chunk) => block.push(chunk),
692 None => return Ok((vec![], Some("hard_dep_unavailable".to_string()))),
693 }
694 }
695 Ok((block, None))
696 }
697 "closure" => {
698 let mut block = vec![seed.clone()];
699 let mut visited: HashSet<String> = [seed_id.to_string()].into();
700 match self.expand_hard_closure(seed_id, &mut visited, &mut block, 0, 3)? {
701 Some(reason) => Ok((vec![], Some(reason))),
702 None => Ok((block, None)),
703 }
704 }
705 _ => Ok((vec![seed.clone()], None)),
706 }
707 }
708
709 fn validate_hard_dep(&self, dep_id: &str) -> Result<Option<Value>> {
711 match self.storage.get_chunk(dep_id)? {
712 None => Ok(None),
713 Some(chunk) => {
714 let state = chunk.get("state").and_then(Value::as_str).unwrap_or("");
715 let origin = chunk.get("origin").and_then(Value::as_str).unwrap_or("");
716 let embed_v = chunk
717 .get("embed_version")
718 .and_then(Value::as_i64)
719 .unwrap_or(0);
720 if state == "archived" || origin == "spark" || embed_v == 0 {
721 Ok(None)
722 } else {
723 Ok(Some(chunk))
724 }
725 }
726 }
727 }
728
729 fn expand_hard_closure(
731 &self,
732 id: &str,
733 visited: &mut HashSet<String>,
734 block: &mut Vec<Value>,
735 depth: usize,
736 max_depth: usize,
737 ) -> Result<Option<String>> {
738 if depth >= max_depth {
739 return Ok(Some("dep_depth_limit".to_string()));
740 }
741 let deps = self.storage.get_deps(id)?;
742 for (dep_id, kind, _) in &deps {
743 if kind != "hard" {
744 continue;
745 }
746 if visited.contains(dep_id) {
747 continue;
748 } visited.insert(dep_id.clone());
750 match self.validate_hard_dep(dep_id)? {
751 None => return Ok(Some("hard_dep_unavailable".to_string())),
752 Some(chunk) => {
753 block.push(chunk);
754 if let Some(reason) =
755 self.expand_hard_closure(dep_id, visited, block, depth + 1, max_depth)?
756 {
757 return Ok(Some(reason));
758 }
759 }
760 }
761 }
762 Ok(None)
763 }
764
765 fn density_refill(
766 &self,
767 mut selected: Vec<Value>,
768 skipped: &[(Vec<Value>, f64, usize)],
769 budget: usize,
770 ) -> Vec<Value> {
771 let used_tokens = block_cost(&selected);
772 if used_tokens >= budget {
773 return selected;
774 }
775
776 let selected_ids: HashSet<String> = selected
777 .iter()
778 .filter_map(|c| c["id"].as_str().map(str::to_string))
779 .collect();
780
781 let mut density_items: Vec<(f64, Vec<Value>, usize)> = skipped
782 .iter()
783 .filter_map(|(block, fscore, _)| {
784 let block: Vec<Value> = block
785 .iter()
786 .filter(|b| !selected_ids.contains(b["id"].as_str().unwrap_or("")))
787 .cloned()
788 .collect();
789 if block.is_empty() {
790 return None;
791 }
792 let cost = block_cost(&block);
793 let density = fscore / cost.max(1) as f64;
794 Some((density, block, cost))
795 })
796 .collect();
797 density_items.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
798
799 let mut used_tokens = block_cost(&selected);
800 let mut added_ids: HashSet<String> = selected_ids;
801 for (_, block, cost) in density_items {
802 if used_tokens + cost <= budget {
803 for b in block {
804 let bid = b["id"].as_str().unwrap_or("").to_string();
805 if !added_ids.contains(&bid) {
806 selected.push(b);
807 added_ids.insert(bid);
808 }
809 }
810 used_tokens += cost;
811 }
812 }
813 selected
814 }
815
816 fn recall_sparks(&self, q_content: &[f32], q_trigger: &[f32]) -> Result<Vec<Value>> {
817 let embed_version = self
818 .storage
819 .get_meta("embed_version")?
820 .and_then(|v| v.parse::<i64>().ok())
821 .unwrap_or(1);
822
823 let content_res = self
824 .storage
825 .search_vec_content(q_content, self.top_k_candidates)?;
826 let trigger_res = self
827 .storage
828 .search_vec_trigger(q_trigger, self.top_k_candidates)?;
829
830 let all_ids: Vec<&str> = {
832 let mut seen = HashSet::new();
833 content_res
834 .iter()
835 .chain(trigger_res.iter())
836 .map(|(id, _)| id.as_str())
837 .filter(|id| seen.insert(*id))
838 .collect()
839 };
840 let chunks = self.storage.get_chunks_by_ids(&all_ids)?;
841
842 let mut spark_scores: HashMap<String, (f32, Value)> = HashMap::new();
843 for (cid, sim) in content_res.iter().chain(trigger_res.iter()) {
844 if let Some(chunk) = chunks.get(cid) {
845 if chunk.get("origin").and_then(Value::as_str) != Some("spark") {
846 continue;
847 }
848 if chunk.get("state").and_then(Value::as_str) == Some("archived") {
849 continue;
850 }
851 let maturity = chunk.get("maturity").and_then(Value::as_str).unwrap_or("");
852 if maturity == "promoted" || maturity == "dropped" {
853 continue;
854 }
855 let ev = chunk
856 .get("embed_version")
857 .and_then(Value::as_i64)
858 .unwrap_or(1);
859 if ev < embed_version {
860 continue;
861 }
862 let entry = spark_scores
863 .entry(cid.clone())
864 .or_insert_with(|| (*sim, chunk.clone()));
865 if *sim > entry.0 {
866 *entry = (*sim, chunk.clone());
867 }
868 }
869 }
870 let mut sparks: Vec<(f32, Value)> = spark_scores.into_values().collect();
871 sparks.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
872 Ok(sparks
873 .into_iter()
874 .take(self.top_k_candidates)
875 .map(|(_, c)| c)
876 .collect())
877 }
878
879 #[allow(clippy::too_many_arguments)]
880 fn write_recall_trace(
881 &self,
882 trace_id: &str,
883 query: &str,
884 context_key: &str,
885 scored: &[(f64, Value)],
886 visible: &[Value],
887 sparks: &[Value],
888 depth_skipped: &[String],
889 skipped_reasons: &HashMap<String, String>,
890 refine_mode: &str,
891 source: &str,
892 now: &str,
893 session_only: bool,
894 recall_meta: &Value,
895 ) -> Result<()> {
896 let lib_id = self.storage.lib_id()?;
897 let is_empty = visible.is_empty() && sparks.is_empty();
905 let record_selection = !is_empty && !session_only;
906 self.storage.begin_immediate()?;
907 let result = (|| -> Result<()> {
908 if record_selection {
909 for (rank, (_, chunk)) in scored.iter().enumerate() {
910 let cid = chunk["id"].as_str().unwrap_or("");
911 let sim = chunk.get("_fused_score").and_then(Value::as_f64);
912 let rm = skipped_reasons
914 .get(cid)
915 .map(|r| format!("skipped:{r}"))
916 .or_else(|| {
917 if refine_mode != "off" && !refine_mode.is_empty() {
918 Some(refine_mode.to_string())
919 } else {
920 None
921 }
922 });
923 self.storage.insert_usage_trace(
924 trace_id,
925 Some(cid),
926 "retrieved",
927 1.0,
928 sim,
929 rm.as_deref(),
930 None,
931 Some((rank + 1) as i64),
932 None,
933 source,
934 now,
935 )?;
936 }
937 for (rank, chunk) in visible.iter().enumerate() {
938 let cid = chunk["id"].as_str().unwrap_or("");
939 self.storage.insert_usage_trace(
940 trace_id,
941 Some(cid),
942 "selected",
943 1.0,
944 None,
945 None,
946 None,
947 Some((rank + 1) as i64),
948 None,
949 source,
950 now,
951 )?;
952 if chunk
954 .get("_trimmed")
955 .and_then(Value::as_bool)
956 .unwrap_or(false)
957 {
958 self.storage.insert_usage_trace(
959 trace_id,
960 Some(cid),
961 "refined",
962 1.0,
963 None,
964 Some("trim"),
965 None,
966 Some((rank + 1) as i64),
967 None,
968 source,
969 now,
970 )?;
971 }
972 }
973 for (rank, chunk) in sparks.iter().enumerate() {
975 let cid = chunk["id"].as_str().unwrap_or("");
976 self.storage.insert_usage_trace(
977 trace_id,
978 Some(cid),
979 "retrieved",
980 1.0,
981 None,
982 Some("spark"),
983 None,
984 Some((rank + 1) as i64),
985 None,
986 source,
987 now,
988 )?;
989 }
990 }
991 let snapshot = json!({
994 "schema": 2,
995 "retrieved": if record_selection { scored.iter().map(|(_, c)| c["id"].as_str().unwrap_or("")).collect::<Vec<_>>() } else { vec![] },
996 "selected": if record_selection { visible.iter().map(|c| c["id"].as_str().unwrap_or("")).collect::<Vec<_>>() } else { vec![] },
997 "sparks": if record_selection { sparks.iter().map(|c| c["id"].as_str().unwrap_or("")).collect::<Vec<_>>() } else { vec![] },
998 "depth_skipped": depth_skipped,
999 "skipped_reasons": skipped_reasons,
1000 "session_only": session_only,
1001 "recall": recall_meta,
1002 });
1003 let (usage_state, distill_state) = if is_empty {
1009 ("known_none", "discarded")
1010 } else {
1011 ("unknown", "open")
1012 };
1013 let log = EpisodicLogRow {
1014 id: gen_uuid(),
1015 trace_id: trace_id.to_string(),
1016 lib_id,
1017 ts: now.to_string(),
1018 query: Some(query.to_string()),
1019 recall_snapshot: Some(snapshot.to_string()),
1020 event_source: source.to_string(),
1021 agent: agent_source(),
1022 task_state: "recalled".to_string(),
1023 usage_state: usage_state.to_string(),
1024 context_key: Some(context_key.to_string()),
1025 distill_state: distill_state.to_string(),
1026 ..Default::default()
1027 };
1028 self.storage.upsert_episodic_log(&log)?;
1029 self.storage.commit()
1030 })();
1031 if result.is_err() {
1032 let _ = self.storage.rollback();
1033 }
1034 result
1035 }
1036}
1037
1038pub(super) fn match_reason(contribs: &[(&str, f64)]) -> Value {
1044 let total: f64 = contribs.iter().map(|(_, c)| c.max(0.0)).sum();
1045 let mut ranked: Vec<(&str, f64)> = contribs
1046 .iter()
1047 .filter(|(_, c)| *c > 1e-9)
1048 .map(|(n, c)| (*n, *c))
1049 .collect();
1050 ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1051 let threshold = total * 0.15;
1052 let primary: Vec<&str> = ranked
1053 .iter()
1054 .enumerate()
1055 .filter(|(i, (_, c))| *i == 0 || *c >= threshold)
1056 .map(|(_, (n, _))| *n)
1057 .collect();
1058 let r3 = |x: f64| (x * 1000.0).round() / 1000.0;
1059 let contributions: serde_json::Map<String, Value> = ranked
1060 .iter()
1061 .map(|(n, c)| ((*n).to_string(), json!(r3(*c))))
1062 .collect();
1063 json!({
1064 "summary": primary.join(", "),
1065 "primary": primary,
1066 "contributions": Value::Object(contributions),
1067 })
1068}