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