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 RecallParams {
41 query,
42 budget,
43 trace,
44 include_sparks,
45 top,
46 source,
47 expand_deps,
48 allow_trim,
49 refine_mode,
50 min_score,
51 session_only,
52 rerank,
53 } = params;
54 let expand_deps = if expand_deps.is_empty() {
55 "false"
56 } else {
57 expand_deps
58 };
59 let refine_mode = if refine_mode.is_empty() {
60 "off"
61 } else {
62 refine_mode
63 };
64 validate_source(source)?;
65 let trace_id = gen_uuid();
66 let now = utc_now_iso();
67
68 let situation = Situation::from_query(query);
72 let context_key = situation.context_key(&self.situation_coarse_keys);
73
74 let embed_query = if self.embed_situation_signature {
78 let sig = situation.coarse_signature(&self.situation_coarse_keys);
79 if signature_has_signal(&sig) {
80 format!("{sig}\n{query}")
81 } else {
82 query.to_string()
83 }
84 } else {
85 query.to_string()
86 };
87
88 let (q_content, q_trigger) = self
89 .embedding
90 .embed_both(&embed_query)
91 .map_err(|e| InnateError::EmbeddingUnavailable(e.to_string()))?;
92
93 let mut candidates = self.ann_candidates(&q_content, &q_trigger, query)?;
95 self.apply_soft_dep_bonus(&mut candidates)?;
96 self.expand_by_spreading(&mut candidates, query)?;
101
102 let mut scored = self.score_candidates(candidates, query, &context_key, &now)?;
104
105 if rerank {
108 self.apply_rerank(query, &mut scored);
109 }
110
111 if let Some(min) = min_score {
114 scored.retain(|(fused, _)| *fused >= min);
115 }
116
117 let (selected, skipped, skipped_reasons) =
119 self.pack(&scored, budget, expand_deps, allow_trim, query)?;
120
121 let depth_skipped: Vec<String> = skipped_reasons
122 .iter()
123 .filter(|(_, r)| r.as_str() == "dep_depth_limit")
124 .map(|(id, _)| id.clone())
125 .collect();
126
127 let mut selected = selected;
129 if self.density_refill {
130 selected = self.density_refill(selected, &skipped, budget);
131 }
132
133 let limited = limit_knowledge(selected, top);
134 let visible = if refine_mode == "adapt" {
135 self.refiner
136 .refine(limited.clone(), Some(budget))
137 .unwrap_or(limited)
138 } else {
139 limited
140 };
141
142 let sparks = if include_sparks {
144 self.recall_sparks(&q_content, &q_trigger)?
145 } else {
146 vec![]
147 };
148
149 if trace {
150 self.write_recall_trace(
151 &trace_id,
152 query,
153 &context_key,
154 &scored,
155 &visible,
156 &sparks,
157 &depth_skipped,
158 &skipped_reasons,
159 refine_mode,
160 source,
161 &now,
162 session_only,
163 )?;
164 }
165
166 let empty = visible.is_empty() && sparks.is_empty();
167 Ok(RecallResult {
168 knowledge: visible,
169 sparks,
170 trace_id,
171 empty,
172 depth_skipped,
173 skipped_reasons,
174 })
175 }
176
177 pub(super) fn ann_candidates(
178 &self,
179 q_content: &[f32],
180 q_trigger: &[f32],
181 query: &str,
182 ) -> Result<HashMap<String, CandidateInfo>> {
183 let embed_version = self
184 .storage
185 .get_meta("embed_version")?
186 .and_then(|v| v.parse::<i64>().ok())
187 .unwrap_or(1);
188
189 let content_res = self
190 .storage
191 .search_vec_content(q_content, self.top_k_candidates * 2)?;
192 let trigger_res = self
193 .storage
194 .search_vec_trigger(q_trigger, self.top_k_candidates * 2)?;
195 let lexical_res = self
199 .storage
200 .search_lexical(query, self.top_k_candidates * 2)?;
201
202 let all_ids: Vec<&str> = {
204 let mut seen = HashSet::new();
205 content_res
206 .iter()
207 .chain(trigger_res.iter())
208 .chain(lexical_res.iter())
209 .map(|(id, _)| id.as_str())
210 .filter(|id| seen.insert(*id))
211 .collect()
212 };
213 let chunks = self.storage.get_chunks_by_ids(&all_ids)?;
214
215 let mut candidates: HashMap<String, CandidateInfo> = HashMap::new();
216 for (cid, sim) in &content_res {
217 if let Some(chunk) = chunks.get(cid) {
218 if chunk_is_valid_for_recall(chunk, embed_version) {
219 let e = candidates.entry(cid.clone()).or_insert_with(|| new_candidate(chunk));
220 e.sim_content = e.sim_content.max(*sim);
221 }
222 }
223 }
224 for (cid, sim) in &trigger_res {
225 if let Some(chunk) = chunks.get(cid) {
226 if chunk_is_valid_for_recall(chunk, embed_version) {
227 let e = candidates.entry(cid.clone()).or_insert_with(|| new_candidate(chunk));
228 e.sim_trigger = e.sim_trigger.max(*sim);
229 }
230 }
231 }
232 for (cid, sim) in &lexical_res {
233 if let Some(chunk) = chunks.get(cid) {
234 if chunk_is_valid_for_recall(chunk, embed_version) {
235 let e = candidates.entry(cid.clone()).or_insert_with(|| new_candidate(chunk));
236 e.sim_lexical = e.sim_lexical.max(*sim);
237 }
238 }
239 }
240 Ok(candidates)
241 }
242
243 pub(super) fn expand_by_spreading(
257 &self,
258 candidates: &mut HashMap<String, CandidateInfo>,
259 query: &str,
260 ) -> Result<()> {
261 if self.w_spread <= 0.0 {
262 return Ok(());
263 }
264
265 let mut source_act: HashMap<String, f64> = HashMap::new();
268 for e in crate::entities::extract_entities(query, None) {
269 *source_act.entry(e.entity).or_insert(0.0) += 1.0;
270 }
271
272 let mut seeds: Vec<(String, f32)> = candidates
273 .iter()
274 .map(|(id, info)| {
275 let base = info
276 .sim_content
277 .max(info.sim_trigger)
278 .max(info.sim_lexical);
279 (id.clone(), base)
280 })
281 .filter(|(_, base)| *base > 0.0)
282 .collect();
283 seeds.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
284 seeds.truncate(self.spread_seed_n);
285 if !seeds.is_empty() {
286 let seed_ids: Vec<&str> = seeds.iter().map(|(id, _)| id.as_str()).collect();
287 let seed_ents = self.storage.entities_for_chunks(&seed_ids)?;
288 for (id, weight) in &seeds {
289 if let Some(ents) = seed_ents.get(id) {
290 for ent in ents {
291 *source_act.entry(ent.clone()).or_insert(0.0) += *weight as f64;
292 }
293 }
294 }
295 }
296 if source_act.is_empty() {
297 return Ok(());
298 }
299
300 let ents: Vec<&str> = source_act.keys().map(String::as_str).collect();
302 let links = self.storage.entity_links(&ents)?;
303 let mut by_entity: HashMap<&str, Vec<&str>> = HashMap::new();
304 for (entity, chunk_id) in &links {
305 by_entity.entry(entity).or_default().push(chunk_id);
306 }
307 let mut spread_raw: HashMap<String, f64> = HashMap::new();
308 for (entity, chunk_ids) in &by_entity {
309 let fan = chunk_ids.len() as i64;
310 if fan == 0 || fan > self.spread_fan_cap {
311 continue; }
313 let a_e = source_act.get(*entity).copied().unwrap_or(0.0);
314 if a_e <= 0.0 {
315 continue;
316 }
317 let contribution = a_e / fan as f64;
318 for cid in chunk_ids {
319 *spread_raw.entry((*cid).to_string()).or_insert(0.0) += contribution;
320 }
321 }
322 if spread_raw.is_empty() {
323 return Ok(());
324 }
325 let max = spread_raw.values().cloned().fold(0.0_f64, f64::max);
326 if max <= 0.0 {
327 return Ok(());
328 }
329
330 let embed_version = self
332 .storage
333 .get_meta("embed_version")?
334 .and_then(|v| v.parse::<i64>().ok())
335 .unwrap_or(1);
336 let new_ids: Vec<String> = spread_raw
337 .keys()
338 .filter(|id| !candidates.contains_key(*id))
339 .cloned()
340 .collect();
341 let fetched = if new_ids.is_empty() {
342 HashMap::new()
343 } else {
344 let refs: Vec<&str> = new_ids.iter().map(String::as_str).collect();
345 self.storage.get_chunks_by_ids(&refs)?
346 };
347
348 for (cid, raw) in spread_raw {
349 let norm = (raw / max) as f32;
350 if let Some(info) = candidates.get_mut(&cid) {
351 info.sim_spread = info.sim_spread.max(norm);
352 } else if let Some(chunk) = fetched.get(&cid) {
353 if chunk_is_valid_for_recall(chunk, embed_version) {
354 let e = candidates.entry(cid).or_insert_with(|| new_candidate(chunk));
355 e.sim_spread = e.sim_spread.max(norm);
356 }
357 }
358 }
359 Ok(())
360 }
361
362 pub(super) fn apply_soft_dep_bonus(
363 &self,
364 candidates: &mut HashMap<String, CandidateInfo>,
365 ) -> Result<()> {
366 let src_ids: Vec<String> = candidates
369 .iter()
370 .filter(|(_, info)| info.chunk.get("origin").and_then(Value::as_str) != Some("spark"))
371 .map(|(cid, _)| cid.clone())
372 .collect();
373 if src_ids.is_empty() {
374 return Ok(());
375 }
376 let src_refs: Vec<&str> = src_ids.iter().map(String::as_str).collect();
377 let deps_map = self.storage.get_deps_batch(&src_refs)?;
378
379 let mut target_ids: Vec<String> = Vec::new();
382 let mut seen: HashSet<String> = HashSet::new();
383 for deps in deps_map.values() {
384 for (dst, kind, _) in deps {
385 if kind == "soft" && seen.insert(dst.clone()) {
386 target_ids.push(dst.clone());
387 }
388 }
389 }
390 if target_ids.is_empty() {
391 return Ok(());
392 }
393 let target_refs: Vec<&str> = target_ids.iter().map(String::as_str).collect();
394 let targets = self.storage.get_chunks_by_ids(&target_refs)?;
395
396 for src in &src_ids {
397 let Some(deps) = deps_map.get(src) else {
398 continue;
399 };
400 for (dst, kind, _) in deps {
401 if kind != "soft" {
402 continue;
403 }
404 let Some(target) = targets.get(dst) else {
405 continue;
406 };
407 if target.get("state").and_then(Value::as_str) == Some("archived") {
408 continue;
409 }
410 if target.get("origin").and_then(Value::as_str) == Some("spark") {
411 continue;
412 }
413 let e = candidates
414 .entry(dst.clone())
415 .or_insert_with(|| new_candidate(target));
416 e.sim_content = (e.sim_content + 0.05).min(1.0);
417 }
418 }
419 Ok(())
420 }
421
422 fn score_candidates(
423 &self,
424 candidates: HashMap<String, CandidateInfo>,
425 query: &str,
426 context_key: &str,
427 now: &str,
428 ) -> Result<Vec<(f64, Value)>> {
429 let cand_ids: Vec<String> = candidates
432 .values()
433 .filter_map(|info| {
434 info.chunk
435 .get("id")
436 .and_then(Value::as_str)
437 .map(str::to_string)
438 })
439 .collect();
440 let cand_refs: Vec<&str> = cand_ids.iter().map(String::as_str).collect();
441 let ctx_scores = self.storage.context_scores_batch(
443 &cand_refs,
444 context_key,
445 RECALL_PRIOR_M,
446 RECALL_BASE_RATE,
447 )?;
448
449 let mut scored: Vec<(f64, Value)> = Vec::with_capacity(candidates.len());
450 for info in candidates.into_values() {
451 let conf = info
452 .chunk
453 .get("confidence")
454 .and_then(Value::as_f64)
455 .unwrap_or(0.5);
456 let chunk_id = info.chunk.get("id").and_then(Value::as_str).unwrap_or("");
457 let context_score = ctx_scores.get(chunk_id).copied().unwrap_or(0.0);
458 let used_count = info
461 .chunk
462 .get("used_count")
463 .and_then(Value::as_i64)
464 .unwrap_or(0);
465 let last_used_at = info.chunk.get("last_used_at").and_then(Value::as_str);
466 let activation = actr_activation(used_count, last_used_at, now);
467 let mut fused = self.w_content * info.sim_content as f64
468 + self.w_trigger * info.sim_trigger as f64
469 + self.w_lexical * info.sim_lexical as f64
470 + self.w_spread * info.sim_spread as f64
471 + self.w_confidence * conf
472 + self.w_context * context_score
473 + self.w_activation * activation;
474 if info.chunk.get("state").and_then(Value::as_str) == Some("pending") {
475 fused *= PENDING_RECALL_PENALTY;
476 }
477 let anti = info
478 .chunk
479 .get("anti_trigger_desc")
480 .and_then(Value::as_str)
481 .unwrap_or("");
482 if !anti.is_empty() && anti_trigger_hit(query, anti) {
483 fused *= self.anti_trigger_penalty;
484 }
485 let mut chunk = info.chunk;
486 chunk["_context_score"] = json!(context_score);
487 chunk["_activation"] = json!(activation);
488 chunk["_sim_lexical"] = json!(info.sim_lexical);
489 chunk["_sim_spread"] = json!(info.sim_spread);
490 chunk["_fused_score"] = json!(fused);
491 scored.push((fused, chunk));
492 }
493 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
494 scored.truncate(self.top_k_candidates);
495 Ok(scored)
496 }
497
498 fn apply_rerank(&self, query: &str, scored: &mut [(f64, Value)]) {
503 let chunks: Vec<Value> = scored.iter().map(|(_, c)| c.clone()).collect();
504 let order = match self.reranker.rerank(query, &chunks) {
505 Ok(order) if !order.is_empty() => order,
506 _ => return,
507 };
508 let rank: HashMap<&str, usize> = order
509 .iter()
510 .enumerate()
511 .map(|(i, id)| (id.as_str(), i))
512 .collect();
513 scored.sort_by_key(|(_, c)| {
514 rank.get(c["id"].as_str().unwrap_or(""))
515 .copied()
516 .unwrap_or(usize::MAX)
517 });
518 }
519
520 fn pack(
521 &self,
522 scored: &[(f64, Value)],
523 budget: usize,
524 expand_deps: &str,
525 allow_trim: bool,
526 query: &str,
527 ) -> Result<PackResult> {
528 let mut selected: Vec<Value> = vec![];
529 let mut skipped: Vec<(Vec<Value>, f64, usize)> = vec![];
530 let mut skipped_reasons: HashMap<String, String> = HashMap::new();
531 let mut used_ids: HashSet<String> = HashSet::new();
532 let mut used_tokens: usize = 0;
533
534 for (fused, chunk) in scored {
535 let cid = chunk["id"].as_str().unwrap_or("").to_string();
536 if used_ids.contains(&cid) {
537 continue;
538 }
539
540 let (block, dep_skip_reason) = self.build_dep_block(chunk, expand_deps)?;
542 if let Some(reason) = dep_skip_reason {
543 skipped_reasons.insert(cid, reason);
544 continue;
545 }
546
547 let new_block: Vec<Value> = block
548 .iter()
549 .filter(|b| !used_ids.contains(b["id"].as_str().unwrap_or("")))
550 .cloned()
551 .collect();
552 let cost = block_cost(&new_block);
553
554 if used_tokens + cost <= budget {
555 for b in &block {
556 let bid = b["id"].as_str().unwrap_or("").to_string();
557 if !used_ids.contains(&bid) {
558 let mut b = b.clone();
559 b["_fused_score"] = json!(fused);
560 selected.push(b);
561 used_ids.insert(bid);
562 }
563 }
564 used_tokens += cost;
565 } else if allow_trim {
566 if let Some(trimmed) =
568 self.refiner
569 .trim(&block, query, budget.saturating_sub(used_tokens))
570 {
571 let trim_cost = block_cost(&trimmed);
572 if used_tokens + trim_cost <= budget {
573 for b in &trimmed {
574 let bid = b["id"].as_str().unwrap_or("").to_string();
575 if !used_ids.contains(&bid) {
576 let mut b = b.clone();
577 b["_fused_score"] = json!(fused);
578 b["_trimmed"] = json!(true);
579 selected.push(b);
580 used_ids.insert(bid);
581 }
582 }
583 used_tokens += trim_cost;
584 continue;
585 }
586 }
587 skipped.push((block, *fused, cost));
588 } else {
589 skipped.push((block, *fused, cost));
590 }
591 }
592 Ok((selected, skipped, skipped_reasons))
593 }
594
595 fn build_dep_block(
598 &self,
599 seed: &Value,
600 expand_deps: &str,
601 ) -> Result<(Vec<Value>, Option<String>)> {
602 if expand_deps == "false" || expand_deps.is_empty() {
603 return Ok((vec![seed.clone()], None));
604 }
605 let seed_id = seed["id"].as_str().unwrap_or("");
606 match expand_deps {
607 "direct" => {
608 let deps = self.storage.get_deps(seed_id)?;
609 let mut block = vec![seed.clone()];
610 for (dep_id, kind, _) in &deps {
611 if kind != "hard" {
612 continue;
613 }
614 match self.validate_hard_dep(dep_id)? {
615 Some(chunk) => block.push(chunk),
616 None => return Ok((vec![], Some("hard_dep_unavailable".to_string()))),
617 }
618 }
619 Ok((block, None))
620 }
621 "closure" => {
622 let mut block = vec![seed.clone()];
623 let mut visited: HashSet<String> = [seed_id.to_string()].into();
624 match self.expand_hard_closure(seed_id, &mut visited, &mut block, 0, 3)? {
625 Some(reason) => Ok((vec![], Some(reason))),
626 None => Ok((block, None)),
627 }
628 }
629 _ => Ok((vec![seed.clone()], None)),
630 }
631 }
632
633 fn validate_hard_dep(&self, dep_id: &str) -> Result<Option<Value>> {
635 match self.storage.get_chunk(dep_id)? {
636 None => Ok(None),
637 Some(chunk) => {
638 let state = chunk.get("state").and_then(Value::as_str).unwrap_or("");
639 let origin = chunk.get("origin").and_then(Value::as_str).unwrap_or("");
640 let embed_v = chunk
641 .get("embed_version")
642 .and_then(Value::as_i64)
643 .unwrap_or(0);
644 if state == "archived" || origin == "spark" || embed_v == 0 {
645 Ok(None)
646 } else {
647 Ok(Some(chunk))
648 }
649 }
650 }
651 }
652
653 fn expand_hard_closure(
655 &self,
656 id: &str,
657 visited: &mut HashSet<String>,
658 block: &mut Vec<Value>,
659 depth: usize,
660 max_depth: usize,
661 ) -> Result<Option<String>> {
662 if depth >= max_depth {
663 return Ok(Some("dep_depth_limit".to_string()));
664 }
665 let deps = self.storage.get_deps(id)?;
666 for (dep_id, kind, _) in &deps {
667 if kind != "hard" {
668 continue;
669 }
670 if visited.contains(dep_id) {
671 continue;
672 } visited.insert(dep_id.clone());
674 match self.validate_hard_dep(dep_id)? {
675 None => return Ok(Some("hard_dep_unavailable".to_string())),
676 Some(chunk) => {
677 block.push(chunk);
678 if let Some(reason) =
679 self.expand_hard_closure(dep_id, visited, block, depth + 1, max_depth)?
680 {
681 return Ok(Some(reason));
682 }
683 }
684 }
685 }
686 Ok(None)
687 }
688
689 fn density_refill(
690 &self,
691 mut selected: Vec<Value>,
692 skipped: &[(Vec<Value>, f64, usize)],
693 budget: usize,
694 ) -> Vec<Value> {
695 let used_tokens = block_cost(&selected);
696 if used_tokens >= budget {
697 return selected;
698 }
699
700 let selected_ids: HashSet<String> = selected
701 .iter()
702 .filter_map(|c| c["id"].as_str().map(str::to_string))
703 .collect();
704
705 let mut density_items: Vec<(f64, Vec<Value>, usize)> = skipped
706 .iter()
707 .filter_map(|(block, fscore, _)| {
708 let block: Vec<Value> = block
709 .iter()
710 .filter(|b| !selected_ids.contains(b["id"].as_str().unwrap_or("")))
711 .cloned()
712 .collect();
713 if block.is_empty() {
714 return None;
715 }
716 let cost = block_cost(&block);
717 let density = fscore / cost.max(1) as f64;
718 Some((density, block, cost))
719 })
720 .collect();
721 density_items.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
722
723 let mut used_tokens = block_cost(&selected);
724 let mut added_ids: HashSet<String> = selected_ids;
725 for (_, block, cost) in density_items {
726 if used_tokens + cost <= budget {
727 for b in block {
728 let bid = b["id"].as_str().unwrap_or("").to_string();
729 if !added_ids.contains(&bid) {
730 selected.push(b);
731 added_ids.insert(bid);
732 }
733 }
734 used_tokens += cost;
735 }
736 }
737 selected
738 }
739
740 fn recall_sparks(&self, q_content: &[f32], q_trigger: &[f32]) -> Result<Vec<Value>> {
741 let embed_version = self
742 .storage
743 .get_meta("embed_version")?
744 .and_then(|v| v.parse::<i64>().ok())
745 .unwrap_or(1);
746
747 let content_res = self
748 .storage
749 .search_vec_content(q_content, self.top_k_candidates)?;
750 let trigger_res = self
751 .storage
752 .search_vec_trigger(q_trigger, self.top_k_candidates)?;
753
754 let all_ids: Vec<&str> = {
756 let mut seen = HashSet::new();
757 content_res
758 .iter()
759 .chain(trigger_res.iter())
760 .map(|(id, _)| id.as_str())
761 .filter(|id| seen.insert(*id))
762 .collect()
763 };
764 let chunks = self.storage.get_chunks_by_ids(&all_ids)?;
765
766 let mut spark_scores: HashMap<String, (f32, Value)> = HashMap::new();
767 for (cid, sim) in content_res.iter().chain(trigger_res.iter()) {
768 if let Some(chunk) = chunks.get(cid) {
769 if chunk.get("origin").and_then(Value::as_str) != Some("spark") {
770 continue;
771 }
772 if chunk.get("state").and_then(Value::as_str) == Some("archived") {
773 continue;
774 }
775 let maturity = chunk.get("maturity").and_then(Value::as_str).unwrap_or("");
776 if maturity == "promoted" || maturity == "dropped" {
777 continue;
778 }
779 let ev = chunk
780 .get("embed_version")
781 .and_then(Value::as_i64)
782 .unwrap_or(1);
783 if ev < embed_version {
784 continue;
785 }
786 let entry = spark_scores
787 .entry(cid.clone())
788 .or_insert_with(|| (*sim, chunk.clone()));
789 if *sim > entry.0 {
790 *entry = (*sim, chunk.clone());
791 }
792 }
793 }
794 let mut sparks: Vec<(f32, Value)> = spark_scores.into_values().collect();
795 sparks.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
796 Ok(sparks
797 .into_iter()
798 .take(self.top_k_candidates)
799 .map(|(_, c)| c)
800 .collect())
801 }
802
803 #[allow(clippy::too_many_arguments)]
804 fn write_recall_trace(
805 &self,
806 trace_id: &str,
807 query: &str,
808 context_key: &str,
809 scored: &[(f64, Value)],
810 visible: &[Value],
811 sparks: &[Value],
812 depth_skipped: &[String],
813 skipped_reasons: &HashMap<String, String>,
814 refine_mode: &str,
815 source: &str,
816 now: &str,
817 session_only: bool,
818 ) -> Result<()> {
819 let lib_id = self.storage.lib_id()?;
820 let is_empty = visible.is_empty() && sparks.is_empty();
828 let record_selection = !is_empty && !session_only;
829 self.storage.begin_immediate()?;
830 let result = (|| -> Result<()> {
831 if record_selection {
832 for (rank, (_, chunk)) in scored.iter().enumerate() {
833 let cid = chunk["id"].as_str().unwrap_or("");
834 let sim = chunk.get("_fused_score").and_then(Value::as_f64);
835 let rm = skipped_reasons
837 .get(cid)
838 .map(|r| format!("skipped:{r}"))
839 .or_else(|| {
840 if refine_mode != "off" && !refine_mode.is_empty() {
841 Some(refine_mode.to_string())
842 } else {
843 None
844 }
845 });
846 self.storage.insert_usage_trace(
847 trace_id,
848 Some(cid),
849 "retrieved",
850 1.0,
851 sim,
852 rm.as_deref(),
853 None,
854 Some((rank + 1) as i64),
855 None,
856 source,
857 now,
858 )?;
859 }
860 for (rank, chunk) in visible.iter().enumerate() {
861 let cid = chunk["id"].as_str().unwrap_or("");
862 self.storage.insert_usage_trace(
863 trace_id,
864 Some(cid),
865 "selected",
866 1.0,
867 None,
868 None,
869 None,
870 Some((rank + 1) as i64),
871 None,
872 source,
873 now,
874 )?;
875 if chunk
877 .get("_trimmed")
878 .and_then(Value::as_bool)
879 .unwrap_or(false)
880 {
881 self.storage.insert_usage_trace(
882 trace_id,
883 Some(cid),
884 "refined",
885 1.0,
886 None,
887 Some("trim"),
888 None,
889 Some((rank + 1) as i64),
890 None,
891 source,
892 now,
893 )?;
894 }
895 }
896 for (rank, chunk) in sparks.iter().enumerate() {
898 let cid = chunk["id"].as_str().unwrap_or("");
899 self.storage.insert_usage_trace(
900 trace_id,
901 Some(cid),
902 "retrieved",
903 1.0,
904 None,
905 Some("spark"),
906 None,
907 Some((rank + 1) as i64),
908 None,
909 source,
910 now,
911 )?;
912 }
913 }
914 let snapshot = json!({
917 "retrieved": if record_selection { scored.iter().map(|(_, c)| c["id"].as_str().unwrap_or("")).collect::<Vec<_>>() } else { vec![] },
918 "selected": if record_selection { visible.iter().map(|c| c["id"].as_str().unwrap_or("")).collect::<Vec<_>>() } else { vec![] },
919 "sparks": if record_selection { sparks.iter().map(|c| c["id"].as_str().unwrap_or("")).collect::<Vec<_>>() } else { vec![] },
920 "depth_skipped": depth_skipped,
921 "skipped_reasons": skipped_reasons,
922 "session_only": session_only,
923 });
924 let (usage_state, distill_state) = if is_empty {
930 ("known_none", "discarded")
931 } else {
932 ("unknown", "open")
933 };
934 let log = EpisodicLogRow {
935 id: gen_uuid(),
936 trace_id: trace_id.to_string(),
937 lib_id,
938 ts: now.to_string(),
939 query: Some(query.to_string()),
940 recall_snapshot: Some(snapshot.to_string()),
941 event_source: source.to_string(),
942 agent: agent_source(),
943 task_state: "recalled".to_string(),
944 usage_state: usage_state.to_string(),
945 context_key: Some(context_key.to_string()),
946 distill_state: distill_state.to_string(),
947 ..Default::default()
948 };
949 self.storage.upsert_episodic_log(&log)?;
950 self.storage.commit()
951 })();
952 if result.is_err() {
953 let _ = self.storage.rollback();
954 }
955 result
956 }
957}