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 mut fused = self.w_content * info.sim_content as f64
529 + self.w_trigger * info.sim_trigger as f64
530 + self.w_lexical * info.sim_lexical as f64
531 + self.w_spread * info.sim_spread as f64
532 + self.w_confidence * conf
533 + self.w_context * context_score
534 + self.w_activation * activation;
535 if info.chunk.get("state").and_then(Value::as_str) == Some("pending") {
536 fused *= PENDING_RECALL_PENALTY;
537 }
538 let anti = info
539 .chunk
540 .get("anti_trigger_desc")
541 .and_then(Value::as_str)
542 .unwrap_or("");
543 if !anti.is_empty() && anti_trigger_hit(query, anti) {
544 fused *= self.anti_trigger_penalty;
545 }
546 let mut chunk = info.chunk;
547 chunk["_context_score"] = json!(context_score);
548 chunk["_activation"] = json!(activation);
549 chunk["_sim_lexical"] = json!(info.sim_lexical);
550 chunk["_sim_spread"] = json!(info.sim_spread);
551 chunk["_fused_score"] = json!(fused);
552 scored.push((fused, chunk));
553 }
554 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
555 scored.truncate(self.top_k_candidates);
556 Ok(scored)
557 }
558
559 fn apply_rerank(&self, query: &str, scored: &mut [(f64, Value)]) {
564 let chunks: Vec<Value> = scored.iter().map(|(_, c)| c.clone()).collect();
565 let order = match self.reranker.rerank(query, &chunks) {
566 Ok(order) if !order.is_empty() => order,
567 _ => return,
568 };
569 let rank: HashMap<&str, usize> = order
570 .iter()
571 .enumerate()
572 .map(|(i, id)| (id.as_str(), i))
573 .collect();
574 scored.sort_by_key(|(_, c)| {
575 rank.get(c["id"].as_str().unwrap_or(""))
576 .copied()
577 .unwrap_or(usize::MAX)
578 });
579 }
580
581 fn pack(
582 &self,
583 scored: &[(f64, Value)],
584 budget: usize,
585 expand_deps: &str,
586 allow_trim: bool,
587 query: &str,
588 ) -> Result<PackResult> {
589 let mut selected: Vec<Value> = vec![];
590 let mut skipped: Vec<(Vec<Value>, f64, usize)> = vec![];
591 let mut skipped_reasons: HashMap<String, String> = HashMap::new();
592 let mut used_ids: HashSet<String> = HashSet::new();
593 let mut used_tokens: usize = 0;
594
595 for (fused, chunk) in scored {
596 let cid = chunk["id"].as_str().unwrap_or("").to_string();
597 if used_ids.contains(&cid) {
598 continue;
599 }
600
601 let (block, dep_skip_reason) = self.build_dep_block(chunk, expand_deps)?;
603 if let Some(reason) = dep_skip_reason {
604 skipped_reasons.insert(cid, reason);
605 continue;
606 }
607
608 let new_block: Vec<Value> = block
609 .iter()
610 .filter(|b| !used_ids.contains(b["id"].as_str().unwrap_or("")))
611 .cloned()
612 .collect();
613 let cost = block_cost(&new_block);
614
615 if used_tokens + cost <= budget {
616 for b in &block {
617 let bid = b["id"].as_str().unwrap_or("").to_string();
618 if !used_ids.contains(&bid) {
619 let mut b = b.clone();
620 b["_fused_score"] = json!(fused);
621 selected.push(b);
622 used_ids.insert(bid);
623 }
624 }
625 used_tokens += cost;
626 } else if allow_trim {
627 if let Some(trimmed) =
629 self.refiner
630 .trim(&block, query, budget.saturating_sub(used_tokens))
631 {
632 let trim_cost = block_cost(&trimmed);
633 if used_tokens + trim_cost <= budget {
634 for b in &trimmed {
635 let bid = b["id"].as_str().unwrap_or("").to_string();
636 if !used_ids.contains(&bid) {
637 let mut b = b.clone();
638 b["_fused_score"] = json!(fused);
639 b["_trimmed"] = json!(true);
640 selected.push(b);
641 used_ids.insert(bid);
642 }
643 }
644 used_tokens += trim_cost;
645 continue;
646 }
647 }
648 skipped.push((block, *fused, cost));
649 } else {
650 skipped.push((block, *fused, cost));
651 }
652 }
653 Ok((selected, skipped, skipped_reasons))
654 }
655
656 fn build_dep_block(
659 &self,
660 seed: &Value,
661 expand_deps: &str,
662 ) -> Result<(Vec<Value>, Option<String>)> {
663 if expand_deps == "false" || expand_deps.is_empty() {
664 return Ok((vec![seed.clone()], None));
665 }
666 let seed_id = seed["id"].as_str().unwrap_or("");
667 match expand_deps {
668 "direct" => {
669 let deps = self.storage.get_deps(seed_id)?;
670 let mut block = vec![seed.clone()];
671 for (dep_id, kind, _) in &deps {
672 if kind != "hard" {
673 continue;
674 }
675 match self.validate_hard_dep(dep_id)? {
676 Some(chunk) => block.push(chunk),
677 None => return Ok((vec![], Some("hard_dep_unavailable".to_string()))),
678 }
679 }
680 Ok((block, None))
681 }
682 "closure" => {
683 let mut block = vec![seed.clone()];
684 let mut visited: HashSet<String> = [seed_id.to_string()].into();
685 match self.expand_hard_closure(seed_id, &mut visited, &mut block, 0, 3)? {
686 Some(reason) => Ok((vec![], Some(reason))),
687 None => Ok((block, None)),
688 }
689 }
690 _ => Ok((vec![seed.clone()], None)),
691 }
692 }
693
694 fn validate_hard_dep(&self, dep_id: &str) -> Result<Option<Value>> {
696 match self.storage.get_chunk(dep_id)? {
697 None => Ok(None),
698 Some(chunk) => {
699 let state = chunk.get("state").and_then(Value::as_str).unwrap_or("");
700 let origin = chunk.get("origin").and_then(Value::as_str).unwrap_or("");
701 let embed_v = chunk
702 .get("embed_version")
703 .and_then(Value::as_i64)
704 .unwrap_or(0);
705 if state == "archived" || origin == "spark" || embed_v == 0 {
706 Ok(None)
707 } else {
708 Ok(Some(chunk))
709 }
710 }
711 }
712 }
713
714 fn expand_hard_closure(
716 &self,
717 id: &str,
718 visited: &mut HashSet<String>,
719 block: &mut Vec<Value>,
720 depth: usize,
721 max_depth: usize,
722 ) -> Result<Option<String>> {
723 if depth >= max_depth {
724 return Ok(Some("dep_depth_limit".to_string()));
725 }
726 let deps = self.storage.get_deps(id)?;
727 for (dep_id, kind, _) in &deps {
728 if kind != "hard" {
729 continue;
730 }
731 if visited.contains(dep_id) {
732 continue;
733 } visited.insert(dep_id.clone());
735 match self.validate_hard_dep(dep_id)? {
736 None => return Ok(Some("hard_dep_unavailable".to_string())),
737 Some(chunk) => {
738 block.push(chunk);
739 if let Some(reason) =
740 self.expand_hard_closure(dep_id, visited, block, depth + 1, max_depth)?
741 {
742 return Ok(Some(reason));
743 }
744 }
745 }
746 }
747 Ok(None)
748 }
749
750 fn density_refill(
751 &self,
752 mut selected: Vec<Value>,
753 skipped: &[(Vec<Value>, f64, usize)],
754 budget: usize,
755 ) -> Vec<Value> {
756 let used_tokens = block_cost(&selected);
757 if used_tokens >= budget {
758 return selected;
759 }
760
761 let selected_ids: HashSet<String> = selected
762 .iter()
763 .filter_map(|c| c["id"].as_str().map(str::to_string))
764 .collect();
765
766 let mut density_items: Vec<(f64, Vec<Value>, usize)> = skipped
767 .iter()
768 .filter_map(|(block, fscore, _)| {
769 let block: Vec<Value> = block
770 .iter()
771 .filter(|b| !selected_ids.contains(b["id"].as_str().unwrap_or("")))
772 .cloned()
773 .collect();
774 if block.is_empty() {
775 return None;
776 }
777 let cost = block_cost(&block);
778 let density = fscore / cost.max(1) as f64;
779 Some((density, block, cost))
780 })
781 .collect();
782 density_items.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
783
784 let mut used_tokens = block_cost(&selected);
785 let mut added_ids: HashSet<String> = selected_ids;
786 for (_, block, cost) in density_items {
787 if used_tokens + cost <= budget {
788 for b in block {
789 let bid = b["id"].as_str().unwrap_or("").to_string();
790 if !added_ids.contains(&bid) {
791 selected.push(b);
792 added_ids.insert(bid);
793 }
794 }
795 used_tokens += cost;
796 }
797 }
798 selected
799 }
800
801 fn recall_sparks(&self, q_content: &[f32], q_trigger: &[f32]) -> Result<Vec<Value>> {
802 let embed_version = self
803 .storage
804 .get_meta("embed_version")?
805 .and_then(|v| v.parse::<i64>().ok())
806 .unwrap_or(1);
807
808 let content_res = self
809 .storage
810 .search_vec_content(q_content, self.top_k_candidates)?;
811 let trigger_res = self
812 .storage
813 .search_vec_trigger(q_trigger, self.top_k_candidates)?;
814
815 let all_ids: Vec<&str> = {
817 let mut seen = HashSet::new();
818 content_res
819 .iter()
820 .chain(trigger_res.iter())
821 .map(|(id, _)| id.as_str())
822 .filter(|id| seen.insert(*id))
823 .collect()
824 };
825 let chunks = self.storage.get_chunks_by_ids(&all_ids)?;
826
827 let mut spark_scores: HashMap<String, (f32, Value)> = HashMap::new();
828 for (cid, sim) in content_res.iter().chain(trigger_res.iter()) {
829 if let Some(chunk) = chunks.get(cid) {
830 if chunk.get("origin").and_then(Value::as_str) != Some("spark") {
831 continue;
832 }
833 if chunk.get("state").and_then(Value::as_str) == Some("archived") {
834 continue;
835 }
836 let maturity = chunk.get("maturity").and_then(Value::as_str).unwrap_or("");
837 if maturity == "promoted" || maturity == "dropped" {
838 continue;
839 }
840 let ev = chunk
841 .get("embed_version")
842 .and_then(Value::as_i64)
843 .unwrap_or(1);
844 if ev < embed_version {
845 continue;
846 }
847 let entry = spark_scores
848 .entry(cid.clone())
849 .or_insert_with(|| (*sim, chunk.clone()));
850 if *sim > entry.0 {
851 *entry = (*sim, chunk.clone());
852 }
853 }
854 }
855 let mut sparks: Vec<(f32, Value)> = spark_scores.into_values().collect();
856 sparks.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
857 Ok(sparks
858 .into_iter()
859 .take(self.top_k_candidates)
860 .map(|(_, c)| c)
861 .collect())
862 }
863
864 #[allow(clippy::too_many_arguments)]
865 fn write_recall_trace(
866 &self,
867 trace_id: &str,
868 query: &str,
869 context_key: &str,
870 scored: &[(f64, Value)],
871 visible: &[Value],
872 sparks: &[Value],
873 depth_skipped: &[String],
874 skipped_reasons: &HashMap<String, String>,
875 refine_mode: &str,
876 source: &str,
877 now: &str,
878 session_only: bool,
879 recall_meta: &Value,
880 ) -> Result<()> {
881 let lib_id = self.storage.lib_id()?;
882 let is_empty = visible.is_empty() && sparks.is_empty();
890 let record_selection = !is_empty && !session_only;
891 self.storage.begin_immediate()?;
892 let result = (|| -> Result<()> {
893 if record_selection {
894 for (rank, (_, chunk)) in scored.iter().enumerate() {
895 let cid = chunk["id"].as_str().unwrap_or("");
896 let sim = chunk.get("_fused_score").and_then(Value::as_f64);
897 let rm = skipped_reasons
899 .get(cid)
900 .map(|r| format!("skipped:{r}"))
901 .or_else(|| {
902 if refine_mode != "off" && !refine_mode.is_empty() {
903 Some(refine_mode.to_string())
904 } else {
905 None
906 }
907 });
908 self.storage.insert_usage_trace(
909 trace_id,
910 Some(cid),
911 "retrieved",
912 1.0,
913 sim,
914 rm.as_deref(),
915 None,
916 Some((rank + 1) as i64),
917 None,
918 source,
919 now,
920 )?;
921 }
922 for (rank, chunk) in visible.iter().enumerate() {
923 let cid = chunk["id"].as_str().unwrap_or("");
924 self.storage.insert_usage_trace(
925 trace_id,
926 Some(cid),
927 "selected",
928 1.0,
929 None,
930 None,
931 None,
932 Some((rank + 1) as i64),
933 None,
934 source,
935 now,
936 )?;
937 if chunk
939 .get("_trimmed")
940 .and_then(Value::as_bool)
941 .unwrap_or(false)
942 {
943 self.storage.insert_usage_trace(
944 trace_id,
945 Some(cid),
946 "refined",
947 1.0,
948 None,
949 Some("trim"),
950 None,
951 Some((rank + 1) as i64),
952 None,
953 source,
954 now,
955 )?;
956 }
957 }
958 for (rank, chunk) in sparks.iter().enumerate() {
960 let cid = chunk["id"].as_str().unwrap_or("");
961 self.storage.insert_usage_trace(
962 trace_id,
963 Some(cid),
964 "retrieved",
965 1.0,
966 None,
967 Some("spark"),
968 None,
969 Some((rank + 1) as i64),
970 None,
971 source,
972 now,
973 )?;
974 }
975 }
976 let snapshot = json!({
979 "schema": 2,
980 "retrieved": if record_selection { scored.iter().map(|(_, c)| c["id"].as_str().unwrap_or("")).collect::<Vec<_>>() } else { vec![] },
981 "selected": if record_selection { visible.iter().map(|c| c["id"].as_str().unwrap_or("")).collect::<Vec<_>>() } else { vec![] },
982 "sparks": if record_selection { sparks.iter().map(|c| c["id"].as_str().unwrap_or("")).collect::<Vec<_>>() } else { vec![] },
983 "depth_skipped": depth_skipped,
984 "skipped_reasons": skipped_reasons,
985 "session_only": session_only,
986 "recall": recall_meta,
987 });
988 let (usage_state, distill_state) = if is_empty {
994 ("known_none", "discarded")
995 } else {
996 ("unknown", "open")
997 };
998 let log = EpisodicLogRow {
999 id: gen_uuid(),
1000 trace_id: trace_id.to_string(),
1001 lib_id,
1002 ts: now.to_string(),
1003 query: Some(query.to_string()),
1004 recall_snapshot: Some(snapshot.to_string()),
1005 event_source: source.to_string(),
1006 agent: agent_source(),
1007 task_state: "recalled".to_string(),
1008 usage_state: usage_state.to_string(),
1009 context_key: Some(context_key.to_string()),
1010 distill_state: distill_state.to_string(),
1011 ..Default::default()
1012 };
1013 self.storage.upsert_episodic_log(&log)?;
1014 self.storage.commit()
1015 })();
1016 if result.is_err() {
1017 let _ = self.storage.rollback();
1018 }
1019 result
1020 }
1021}