Skip to main content

innate_core/kb/
recall.rs

1use super::*;
2
3/// Parameters for [`KnowledgeBase::recall`].
4///
5/// Borrowed, `Default`-able: construct with `RecallParams { query, budget, source, ..Default::default() }`.
6/// Empty-string defaults are normalized inside `recall`: `expand_deps` empty → `"false"`,
7/// `refine_mode` empty → `"off"`.
8#[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, // "false" | "direct" | "closure"
17    pub allow_trim: bool,     // if true, invoke Refiner::trim when block doesn't fit
18    pub refine_mode: &'a str, // "off" | "trim" | "adapt" — recorded in trace
19    /// Relevance gate: drop candidates whose fused score is below this value
20    /// **before** packing/trace, so the trace only records knowledge that was
21    /// actually surfaced. `None` disables the gate. Used by always-on hooks
22    /// (UserPromptSubmit / SessionStart) to stay high-frequency without noise.
23    pub min_score: Option<f64>,
24    /// Session trace mode: open an episodic log for later record-correlation but
25    /// write **no** per-chunk `retrieved`/`selected` usage events. Used by the
26    /// daemon, which recalls only to obtain a `trace_id` and discards the
27    /// knowledge without ever placing it in a model context. `selected` must
28    /// strictly mean "entered the model context", so a caller that does not
29    /// inject the result must set this. Defaults to `false` (full injection).
30    pub session_only: bool,
31    /// Part (d) — opt-in offline LLM rerank of the candidate shortlist. Off by
32    /// default so the hot hook path stays no-LLM; only set it in latency-tolerant
33    /// callers ("deep recall"). No-op unless a reranker was injected (LLM configured),
34    /// and non-fatal: a reranker error falls back to the fused order.
35    pub rerank: bool,
36}
37
38impl KnowledgeBase {
39    pub fn recall(&self, params: RecallParams<'_>) -> Result<RecallResult> {
40        // Distinguish the always-on hook recall channel from explicit recalls so the
41        // hook-silence metric (§5.5) can be read from operation_runs(op='hook_recall').
42        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        // Calibration path: derive the context_key from a Situation. A bare query degrades
81        // exactly to the legacy `content_hash(normalize_query(query))`, so recall stays
82        // zero-regression while sharing one key derivation with appraise (Spec §2.2).
83        let situation = Situation::from_query(query);
84        let context_key = situation.context_key(&self.situation_coarse_keys);
85
86        // Part (c) — query-embedding granularity. When enabled, anchor the vector
87        // query on the normalized situation signature in addition to the raw words.
88        // The lexical channel still matches the raw query (it indexes chunk text).
89        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        // `embed` op (§5.3 first batch): time the query embedding at the call site so
101        // embedding health (latency / arrearage) is aggregatable, without wrapping the
102        // provider. Nested inside the recall/hook_recall measure — both rows are written.
103        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        // ANN candidates (non-spark) — vector channels + lexical/BM25 (hybrid).
110        let mut candidates = self.ann_candidates(&q_content, &q_trigger, query)?;
111        self.apply_soft_dep_bonus(&mut candidates)?;
112        // ACT-R spreading activation (SAG-inspired associative recall). Off by
113        // default (w_spread = 0) — the call returns immediately, so the hot path
114        // is unchanged. When enabled, it may pull in NEW candidates reachable only
115        // via a shared entity (multi-hop), which then flow through normal scoring.
116        self.expand_by_spreading(&mut candidates, query)?;
117
118        // recall_snapshot schema 2 (design doc §5.2): per-channel candidate provenance.
119        // Counted from CandidateInfo.sim_* before scoring consumes the map — lets inspect
120        // attribute "lexical down vs vector down vs spread noise". Cheap (one pass).
121        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        // Score + anti-trigger penalty
129        let mut scored = self.score_candidates(candidates, query, &context_key, &now)?;
130
131        // Part (d) — opt-in offline rerank of the shortlist before packing. Non-fatal:
132        // a reranker error or empty result leaves the fused order untouched.
133        if rerank {
134            self.apply_rerank(query, &mut scored);
135        }
136
137        // Relevance gate — drop sub-threshold candidates before packing/trace so the
138        // trace records only what was actually surfaced (keeps selected→used stats clean).
139        if let Some(min) = min_score {
140            scored.retain(|(fused, _)| *fused >= min);
141        }
142
143        // First-fit pack with dep expansion
144        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        // Density refill
154        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        // Sparks
169        let sparks = if include_sparks {
170            self.recall_sparks(&q_content, &q_trigger)?
171        } else {
172            vec![]
173        };
174
175        if trace {
176            // recall_snapshot schema 2: scores + packing summary (aggregate only, no
177            // per-candidate detail — keeps the snapshot small per §5.2).
178            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        // Hybrid 检索 — lexical/BM25 channel. Recovers exact-term matches (error
257        // codes, flags, symbol names) that embedding similarity blurs away. Empty
258        // when the query has no usable tokens, so vector-only behaviour is preserved.
259        let lexical_res = self
260            .storage
261            .search_lexical(query, self.top_k_candidates * 2)?;
262
263        // Collect unique ids across all three channels and batch-fetch in one query.
264        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    /// ACT-R spreading activation over the associative entity index (SAG-inspired).
305    ///
306    /// Source activation flows from (a) entities of the **query** and (b) entities
307    /// of the top-`spread_seed_n` base-relevance candidates (the 2-hop / multi-hop
308    /// path). For each source entity it spreads `a_e / fan(e)` to every chunk that
309    /// carries it — the ACT-R associative strength `S_ji = S − ln(fan_j)` taken in
310    /// its `1/fan` form — so promiscuous entities contribute little and a
311    /// discriminative one (a specific error code / symbol) drives the link.
312    /// Entities whose fan exceeds `spread_fan_cap` are dropped outright.
313    ///
314    /// Sets `sim_spread` on existing candidates and inserts new ones reachable only
315    /// through a shared entity. Normalized to [0,1] for scale parity with the other
316    /// channels. No-op (immediate return) when `w_spread <= 0`.
317    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        // Source activation per entity: query entities at unit weight, plus the
327        // entities of the strongest base candidates weighted by their relevance.
328        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        // Fetch links for all source entities, then group → fan → cap → distribute.
362        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; // ACT-R fan effect: non-discriminative entity, drop it.
373            }
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        // Fetch any chunks not already candidates so spreading can introduce them.
392        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        // Collect non-spark candidate ids and batch-fetch their outgoing deps
428        // in a single query (was one get_deps per candidate).
429        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        // Gather distinct soft-dep targets and batch-fetch them in one query
441        // (was one get_chunk per soft edge).
442        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        // Batch-fetch context scores for all candidates in one query
491        // (was one context_score lookup per candidate).
492        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        // 方案 D 与 recall 解耦:recall 恒用中性 Laplace 先验,不读 intuition.* 旋钮。
503        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            // ACT-R base-level activation: recency × frequency from usage history.
520            // Zero for never-used chunks, so freshly-added knowledge is unaffected.
521            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            // Per-channel weighted contributions — these sum to the (pre-penalty)
529            // fused score and double as the explainability breakdown (N4).
530            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    /// Part (d) — reorder the scored shortlist by the injected reranker. Stable:
566    /// reranked ids move to the front in the reranker's order; anything the reranker
567    /// omits keeps its fused-relative position. Non-fatal — a reranker error or empty
568    /// result leaves `scored` untouched, so retrieval never depends on the LLM.
569    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            // Build block with dep expansion; fail-closed on dep issues.
608            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                // Attempt refiner trim — NullRefiner returns None (no-op).
634                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    /// Expand a seed chunk into a block according to `expand_deps`.
663    /// Returns `(block, Some(skip_reason))` if the block should be discarded (fail-closed).
664    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    /// Returns the chunk if the hard dep is usable, None if it should cause fail-closed.
701    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    /// BFS hard-dep expansion up to `max_depth`. Returns Some(reason) on fail-closed.
721    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            } // cycle guard
740            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        // Batch-fetch all candidate chunk IDs (mirrors the pattern in ann_candidates).
822        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        // `selected` must strictly mean "entered the model context". Record the
889        // per-chunk retrieved/selected/refined events only when the result is
890        // actually surfaced: skip them for empty results (nothing surfaced) and
891        // for session-only recalls (daemon discards the knowledge). The episodic
892        // log is still written in both cases — an empty result as a terminal
893        // `known_none`/`discarded` row (no-answer telemetry, never `open`), a
894        // session recall as an `open` row for later record-correlation.
895        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                    // For dep-skipped seeds, record their skip reason as refine_mode.
904                    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                    // Write 'refined' event for chunks that came through the trim path.
944                    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                // Write 'retrieved' events for sparks (for recurring-spark count tracking).
965                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            // The snapshot mirrors what was surfaced: empty for known_none and
983            // session-only recalls so no chunk is credited with a selection.
984            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            // Empty recall → terminal known_none/discarded (kept out of the `open`
995            // pool that feeds trace-completion stats). Otherwise `open` for the
996            // record() outcome transition (incl. session-only daemon traces).
997            // `usage_state='known_none'` is the no-answer signal; `task_state`
998            // stays 'recalled' (both bounded by schema CHECK constraints).
999            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
1029/// N4 — explainability: turn the per-channel weighted contributions into a
1030/// compact `match_reason` for the returned chunk. Names the channels that drive
1031/// the fused score (each contributing ≥15% of the positive total, plus always
1032/// the single largest), and includes the rounded contribution of every channel
1033/// for full transparency. Additive metadata; never affects ranking.
1034pub(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}