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, chunk)| {
141                chunk
142                    .get("_gate_score")
143                    .and_then(Value::as_f64)
144                    .unwrap_or(*fused)
145                    >= min
146            });
147        }
148
149        // First-fit pack with dep expansion
150        let (selected, skipped, skipped_reasons) =
151            self.pack(&scored, budget, expand_deps, allow_trim, query)?;
152
153        let depth_skipped: Vec<String> = skipped_reasons
154            .iter()
155            .filter(|(_, r)| r.as_str() == "dep_depth_limit")
156            .map(|(id, _)| id.clone())
157            .collect();
158
159        // Density refill
160        let mut selected = selected;
161        if self.density_refill {
162            selected = self.density_refill(selected, &skipped, budget);
163        }
164
165        let limited = limit_knowledge(selected, top);
166        let visible = if refine_mode == "adapt" {
167            self.refiner
168                .refine(limited.clone(), Some(budget))
169                .unwrap_or(limited)
170        } else {
171            limited
172        };
173
174        // Sparks
175        let sparks = if include_sparks {
176            self.recall_sparks(&q_content, &q_trigger)?
177        } else {
178            vec![]
179        };
180
181        if trace {
182            // recall_snapshot schema 2: scores + packing summary (aggregate only, no
183            // per-candidate detail — keeps the snapshot small per §5.2).
184            let max_score = scored
185                .iter()
186                .map(|(f, _)| *f)
187                .fold(f64::NEG_INFINITY, f64::max);
188            let sel_scores: Vec<f64> = visible
189                .iter()
190                .filter_map(|c| c.get("_fused_score").and_then(Value::as_f64))
191                .collect();
192            let r3 = |x: f64| (x * 1000.0).round() / 1000.0;
193            let selected_tokens: i64 = visible
194                .iter()
195                .filter_map(|c| c.get("token_count").and_then(Value::as_i64))
196                .sum();
197            let recall_meta = json!({
198                "budget": budget,
199                "top": top,
200                "expand_deps": expand_deps,
201                "rerank": rerank,
202                "channels": channels,
203                "scores": {
204                    "max": if max_score.is_finite() { r3(max_score) } else { 0.0 },
205                    "min_selected": if sel_scores.is_empty() { 0.0 }
206                        else { r3(sel_scores.iter().cloned().fold(f64::INFINITY, f64::min)) },
207                    "avg_selected": if sel_scores.is_empty() { 0.0 }
208                        else { r3(sel_scores.iter().sum::<f64>() / sel_scores.len() as f64) },
209                },
210                "packing": {
211                    "selected_tokens": selected_tokens,
212                    "skipped_by_budget": skipped.len(),
213                    "skipped_by_dep_depth": depth_skipped.len(),
214                },
215            });
216            self.write_recall_trace(
217                &trace_id,
218                query,
219                &context_key,
220                &scored,
221                &visible,
222                &sparks,
223                &depth_skipped,
224                &skipped_reasons,
225                refine_mode,
226                source,
227                &now,
228                session_only,
229                &recall_meta,
230            )?;
231        }
232
233        let empty = visible.is_empty() && sparks.is_empty();
234        Ok(RecallResult {
235            knowledge: visible,
236            sparks,
237            trace_id,
238            empty,
239            depth_skipped,
240            skipped_reasons,
241        })
242    }
243
244    pub(super) fn ann_candidates(
245        &self,
246        q_content: &[f32],
247        q_trigger: &[f32],
248        query: &str,
249    ) -> Result<HashMap<String, CandidateInfo>> {
250        let embed_version = self
251            .storage
252            .get_meta("embed_version")?
253            .and_then(|v| v.parse::<i64>().ok())
254            .unwrap_or(1);
255
256        let content_res = self
257            .storage
258            .search_vec_content(q_content, self.top_k_candidates * 2)?;
259        let trigger_res = self
260            .storage
261            .search_vec_trigger(q_trigger, self.top_k_candidates * 2)?;
262        // Hybrid 检索 — lexical/BM25 channel. Recovers exact-term matches (error
263        // codes, flags, symbol names) that embedding similarity blurs away. Empty
264        // when the query has no usable tokens, so vector-only behaviour is preserved.
265        let lexical_res = self
266            .storage
267            .search_lexical(query, self.top_k_candidates * 2)?;
268
269        // Collect unique ids across all three channels and batch-fetch in one query.
270        let all_ids: Vec<&str> = {
271            let mut seen = HashSet::new();
272            content_res
273                .iter()
274                .chain(trigger_res.iter())
275                .chain(lexical_res.iter())
276                .map(|(id, _)| id.as_str())
277                .filter(|id| seen.insert(*id))
278                .collect()
279        };
280        let chunks = self.storage.get_chunks_by_ids(&all_ids)?;
281
282        let mut candidates: HashMap<String, CandidateInfo> = HashMap::new();
283        for (cid, sim) in &content_res {
284            if let Some(chunk) = chunks.get(cid) {
285                if chunk_is_valid_for_recall(chunk, embed_version) {
286                    let e = candidates.entry(cid.clone()).or_insert_with(|| new_candidate(chunk));
287                    e.sim_content = e.sim_content.max(*sim);
288                }
289            }
290        }
291        for (cid, sim) in &trigger_res {
292            if let Some(chunk) = chunks.get(cid) {
293                if chunk_is_valid_for_recall(chunk, embed_version) {
294                    let e = candidates.entry(cid.clone()).or_insert_with(|| new_candidate(chunk));
295                    e.sim_trigger = e.sim_trigger.max(*sim);
296                }
297            }
298        }
299        for (cid, sim) in &lexical_res {
300            if let Some(chunk) = chunks.get(cid) {
301                if chunk_is_valid_for_recall(chunk, embed_version) {
302                    let e = candidates.entry(cid.clone()).or_insert_with(|| new_candidate(chunk));
303                    e.sim_lexical = e.sim_lexical.max(*sim);
304                }
305            }
306        }
307        Ok(candidates)
308    }
309
310    /// ACT-R spreading activation over the associative entity index (SAG-inspired).
311    ///
312    /// Source activation flows from (a) entities of the **query** and (b) entities
313    /// of the top-`spread_seed_n` base-relevance candidates (the 2-hop / multi-hop
314    /// path). For each source entity it spreads `a_e / fan(e)` to every chunk that
315    /// carries it — the ACT-R associative strength `S_ji = S − ln(fan_j)` taken in
316    /// its `1/fan` form — so promiscuous entities contribute little and a
317    /// discriminative one (a specific error code / symbol) drives the link.
318    /// Entities whose fan exceeds `spread_fan_cap` are dropped outright.
319    ///
320    /// Sets `sim_spread` on existing candidates and inserts new ones reachable only
321    /// through a shared entity. Normalized to [0,1] for scale parity with the other
322    /// channels. No-op (immediate return) when `w_spread <= 0`.
323    pub(super) fn expand_by_spreading(
324        &self,
325        candidates: &mut HashMap<String, CandidateInfo>,
326        query: &str,
327    ) -> Result<()> {
328        if self.w_spread <= 0.0 {
329            return Ok(());
330        }
331
332        // Source activation per entity: query entities at unit weight, plus the
333        // entities of the strongest base candidates weighted by their relevance.
334        let mut source_act: HashMap<String, f64> = HashMap::new();
335        for e in crate::entities::extract_entities(query, None) {
336            *source_act.entry(e.entity).or_insert(0.0) += 1.0;
337        }
338
339        let mut seeds: Vec<(String, f32)> = candidates
340            .iter()
341            .map(|(id, info)| {
342                let base = info
343                    .sim_content
344                    .max(info.sim_trigger)
345                    .max(info.sim_lexical);
346                (id.clone(), base)
347            })
348            .filter(|(_, base)| *base > 0.0)
349            .collect();
350        seeds.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
351        seeds.truncate(self.spread_seed_n);
352        if !seeds.is_empty() {
353            let seed_ids: Vec<&str> = seeds.iter().map(|(id, _)| id.as_str()).collect();
354            let seed_ents = self.storage.entities_for_chunks(&seed_ids)?;
355            for (id, weight) in &seeds {
356                if let Some(ents) = seed_ents.get(id) {
357                    for ent in ents {
358                        *source_act.entry(ent.clone()).or_insert(0.0) += *weight as f64;
359                    }
360                }
361            }
362        }
363        if source_act.is_empty() {
364            return Ok(());
365        }
366
367        // Fetch links for all source entities, then group → fan → cap → distribute.
368        let ents: Vec<&str> = source_act.keys().map(String::as_str).collect();
369        let links = self.storage.entity_links(&ents)?;
370        let mut by_entity: HashMap<&str, Vec<&str>> = HashMap::new();
371        for (entity, chunk_id) in &links {
372            by_entity.entry(entity).or_default().push(chunk_id);
373        }
374        let mut spread_raw: HashMap<String, f64> = HashMap::new();
375        for (entity, chunk_ids) in &by_entity {
376            let fan = chunk_ids.len() as i64;
377            if fan == 0 || fan > self.spread_fan_cap {
378                continue; // ACT-R fan effect: non-discriminative entity, drop it.
379            }
380            let a_e = source_act.get(*entity).copied().unwrap_or(0.0);
381            if a_e <= 0.0 {
382                continue;
383            }
384            let contribution = a_e / fan as f64;
385            for cid in chunk_ids {
386                *spread_raw.entry((*cid).to_string()).or_insert(0.0) += contribution;
387            }
388        }
389        if spread_raw.is_empty() {
390            return Ok(());
391        }
392        let max = spread_raw.values().cloned().fold(0.0_f64, f64::max);
393        if max <= 0.0 {
394            return Ok(());
395        }
396
397        // Fetch any chunks not already candidates so spreading can introduce them.
398        let embed_version = self
399            .storage
400            .get_meta("embed_version")?
401            .and_then(|v| v.parse::<i64>().ok())
402            .unwrap_or(1);
403        let new_ids: Vec<String> = spread_raw
404            .keys()
405            .filter(|id| !candidates.contains_key(*id))
406            .cloned()
407            .collect();
408        let fetched = if new_ids.is_empty() {
409            HashMap::new()
410        } else {
411            let refs: Vec<&str> = new_ids.iter().map(String::as_str).collect();
412            self.storage.get_chunks_by_ids(&refs)?
413        };
414
415        for (cid, raw) in spread_raw {
416            let norm = (raw / max) as f32;
417            if let Some(info) = candidates.get_mut(&cid) {
418                info.sim_spread = info.sim_spread.max(norm);
419            } else if let Some(chunk) = fetched.get(&cid) {
420                if chunk_is_valid_for_recall(chunk, embed_version) {
421                    let e = candidates.entry(cid).or_insert_with(|| new_candidate(chunk));
422                    e.sim_spread = e.sim_spread.max(norm);
423                }
424            }
425        }
426        Ok(())
427    }
428
429    pub(super) fn apply_soft_dep_bonus(
430        &self,
431        candidates: &mut HashMap<String, CandidateInfo>,
432    ) -> Result<()> {
433        // Collect non-spark candidate ids and batch-fetch their outgoing deps
434        // in a single query (was one get_deps per candidate).
435        let src_ids: Vec<String> = candidates
436            .iter()
437            .filter(|(_, info)| info.chunk.get("origin").and_then(Value::as_str) != Some("spark"))
438            .map(|(cid, _)| cid.clone())
439            .collect();
440        if src_ids.is_empty() {
441            return Ok(());
442        }
443        let src_refs: Vec<&str> = src_ids.iter().map(String::as_str).collect();
444        let deps_map = self.storage.get_deps_batch(&src_refs)?;
445
446        // Gather distinct soft-dep targets and batch-fetch them in one query
447        // (was one get_chunk per soft edge).
448        let mut target_ids: Vec<String> = Vec::new();
449        let mut seen: HashSet<String> = HashSet::new();
450        for deps in deps_map.values() {
451            for (dst, kind, _) in deps {
452                if kind == "soft" && seen.insert(dst.clone()) {
453                    target_ids.push(dst.clone());
454                }
455            }
456        }
457        if target_ids.is_empty() {
458            return Ok(());
459        }
460        let target_refs: Vec<&str> = target_ids.iter().map(String::as_str).collect();
461        let targets = self.storage.get_chunks_by_ids(&target_refs)?;
462
463        for src in &src_ids {
464            let Some(deps) = deps_map.get(src) else {
465                continue;
466            };
467            for (dst, kind, _) in deps {
468                if kind != "soft" {
469                    continue;
470                }
471                let Some(target) = targets.get(dst) else {
472                    continue;
473                };
474                if target.get("state").and_then(Value::as_str) == Some("archived") {
475                    continue;
476                }
477                if target.get("origin").and_then(Value::as_str) == Some("spark") {
478                    continue;
479                }
480                let e = candidates
481                    .entry(dst.clone())
482                    .or_insert_with(|| new_candidate(target));
483                e.sim_content = (e.sim_content + 0.05).min(1.0);
484            }
485        }
486        Ok(())
487    }
488
489    fn score_candidates(
490        &self,
491        candidates: HashMap<String, CandidateInfo>,
492        query: &str,
493        context_key: &str,
494        now: &str,
495    ) -> Result<Vec<(f64, Value)>> {
496        // Batch-fetch context scores for all candidates in one query
497        // (was one context_score lookup per candidate).
498        let cand_ids: Vec<String> = candidates
499            .values()
500            .filter_map(|info| {
501                info.chunk
502                    .get("id")
503                    .and_then(Value::as_str)
504                    .map(str::to_string)
505            })
506            .collect();
507        let cand_refs: Vec<&str> = cand_ids.iter().map(String::as_str).collect();
508        // 方案 D 与 recall 解耦:recall 恒用中性 Laplace 先验,不读 intuition.* 旋钮。
509        let ctx_scores = self.storage.context_scores_batch(
510            &cand_refs,
511            context_key,
512            RECALL_PRIOR_M,
513            RECALL_BASE_RATE,
514        )?;
515
516        let mut scored: Vec<(f64, Value)> = Vec::with_capacity(candidates.len());
517        for info in candidates.into_values() {
518            let conf = info
519                .chunk
520                .get("confidence")
521                .and_then(Value::as_f64)
522                .unwrap_or(0.5);
523            let chunk_id = info.chunk.get("id").and_then(Value::as_str).unwrap_or("");
524            let context_score = ctx_scores.get(chunk_id).copied().unwrap_or(0.0);
525            // ACT-R base-level activation: recency × frequency from usage history.
526            // Zero for never-used chunks, so freshly-added knowledge is unaffected.
527            let used_count = info
528                .chunk
529                .get("used_count")
530                .and_then(Value::as_i64)
531                .unwrap_or(0);
532            let last_used_at = info.chunk.get("last_used_at").and_then(Value::as_str);
533            let activation = actr_activation(used_count, last_used_at, now);
534            // Per-channel weighted contributions — these sum to the (pre-penalty)
535            // fused score and double as the explainability breakdown (N4).
536            let contribs = [
537                ("content", self.w_content * info.sim_content as f64),
538                ("trigger", self.w_trigger * info.sim_trigger as f64),
539                ("lexical", self.w_lexical * info.sim_lexical as f64),
540                ("spread", self.w_spread * info.sim_spread as f64),
541                ("confidence", self.w_confidence * conf),
542                ("context", self.w_context * context_score),
543                ("activation", self.w_activation * activation),
544            ];
545            let mut fused: f64 = contribs.iter().map(|(_, c)| c).sum();
546            let mut gate_score = fused;
547            if info.chunk.get("state").and_then(Value::as_str) == Some("pending") {
548                fused *= PENDING_RECALL_PENALTY;
549            }
550            let anti = info
551                .chunk
552                .get("anti_trigger_desc")
553                .and_then(Value::as_str)
554                .unwrap_or("");
555            if !anti.is_empty() && anti_trigger_hit(query, anti) {
556                fused *= self.anti_trigger_penalty;
557                gate_score *= self.anti_trigger_penalty;
558            }
559            let mut chunk = info.chunk;
560            chunk["_context_score"] = json!(context_score);
561            chunk["_activation"] = json!(activation);
562            chunk["_sim_lexical"] = json!(info.sim_lexical);
563            chunk["_sim_spread"] = json!(info.sim_spread);
564            chunk["_gate_score"] = json!(gate_score);
565            chunk["_fused_score"] = json!(fused);
566            chunk["match_reason"] = match_reason(&contribs);
567            scored.push((fused, chunk));
568        }
569        scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
570        scored.truncate(self.top_k_candidates);
571        Ok(scored)
572    }
573
574    /// Part (d) — reorder the scored shortlist by the injected reranker. Stable:
575    /// reranked ids move to the front in the reranker's order; anything the reranker
576    /// omits keeps its fused-relative position. Non-fatal — a reranker error or empty
577    /// result leaves `scored` untouched, so retrieval never depends on the LLM.
578    fn apply_rerank(&self, query: &str, scored: &mut [(f64, Value)]) {
579        let chunks: Vec<Value> = scored.iter().map(|(_, c)| c.clone()).collect();
580        let order = match self.reranker.rerank(query, &chunks) {
581            Ok(order) if !order.is_empty() => order,
582            _ => return,
583        };
584        let rank: HashMap<&str, usize> = order
585            .iter()
586            .enumerate()
587            .map(|(i, id)| (id.as_str(), i))
588            .collect();
589        scored.sort_by_key(|(_, c)| {
590            rank.get(c["id"].as_str().unwrap_or(""))
591                .copied()
592                .unwrap_or(usize::MAX)
593        });
594    }
595
596    fn pack(
597        &self,
598        scored: &[(f64, Value)],
599        budget: usize,
600        expand_deps: &str,
601        allow_trim: bool,
602        query: &str,
603    ) -> Result<PackResult> {
604        let mut selected: Vec<Value> = vec![];
605        let mut skipped: Vec<(Vec<Value>, f64, usize)> = vec![];
606        let mut skipped_reasons: HashMap<String, String> = HashMap::new();
607        let mut used_ids: HashSet<String> = HashSet::new();
608        let mut used_tokens: usize = 0;
609
610        for (fused, chunk) in scored {
611            let cid = chunk["id"].as_str().unwrap_or("").to_string();
612            if used_ids.contains(&cid) {
613                continue;
614            }
615
616            // Build block with dep expansion; fail-closed on dep issues.
617            let (block, dep_skip_reason) = self.build_dep_block(chunk, expand_deps)?;
618            if let Some(reason) = dep_skip_reason {
619                skipped_reasons.insert(cid, reason);
620                continue;
621            }
622
623            let new_block: Vec<Value> = block
624                .iter()
625                .filter(|b| !used_ids.contains(b["id"].as_str().unwrap_or("")))
626                .cloned()
627                .collect();
628            let cost = block_cost(&new_block);
629
630            if used_tokens + cost <= budget {
631                for b in &block {
632                    let bid = b["id"].as_str().unwrap_or("").to_string();
633                    if !used_ids.contains(&bid) {
634                        let mut b = b.clone();
635                        b["_fused_score"] = json!(fused);
636                        selected.push(b);
637                        used_ids.insert(bid);
638                    }
639                }
640                used_tokens += cost;
641            } else if allow_trim {
642                // Attempt refiner trim — NullRefiner returns None (no-op).
643                if let Some(trimmed) =
644                    self.refiner
645                        .trim(&block, query, budget.saturating_sub(used_tokens))
646                {
647                    let trim_cost = block_cost(&trimmed);
648                    if used_tokens + trim_cost <= budget {
649                        for b in &trimmed {
650                            let bid = b["id"].as_str().unwrap_or("").to_string();
651                            if !used_ids.contains(&bid) {
652                                let mut b = b.clone();
653                                b["_fused_score"] = json!(fused);
654                                b["_trimmed"] = json!(true);
655                                selected.push(b);
656                                used_ids.insert(bid);
657                            }
658                        }
659                        used_tokens += trim_cost;
660                        continue;
661                    }
662                }
663                skipped.push((block, *fused, cost));
664            } else {
665                skipped.push((block, *fused, cost));
666            }
667        }
668        Ok((selected, skipped, skipped_reasons))
669    }
670
671    /// Expand a seed chunk into a block according to `expand_deps`.
672    /// Returns `(block, Some(skip_reason))` if the block should be discarded (fail-closed).
673    fn build_dep_block(
674        &self,
675        seed: &Value,
676        expand_deps: &str,
677    ) -> Result<(Vec<Value>, Option<String>)> {
678        if expand_deps == "false" || expand_deps.is_empty() {
679            return Ok((vec![seed.clone()], None));
680        }
681        let seed_id = seed["id"].as_str().unwrap_or("");
682        match expand_deps {
683            "direct" => {
684                let deps = self.storage.get_deps(seed_id)?;
685                let mut block = vec![seed.clone()];
686                for (dep_id, kind, _) in &deps {
687                    if kind != "hard" {
688                        continue;
689                    }
690                    match self.validate_hard_dep(dep_id)? {
691                        Some(chunk) => block.push(chunk),
692                        None => return Ok((vec![], Some("hard_dep_unavailable".to_string()))),
693                    }
694                }
695                Ok((block, None))
696            }
697            "closure" => {
698                let mut block = vec![seed.clone()];
699                let mut visited: HashSet<String> = [seed_id.to_string()].into();
700                match self.expand_hard_closure(seed_id, &mut visited, &mut block, 0, 3)? {
701                    Some(reason) => Ok((vec![], Some(reason))),
702                    None => Ok((block, None)),
703                }
704            }
705            _ => Ok((vec![seed.clone()], None)),
706        }
707    }
708
709    /// Returns the chunk if the hard dep is usable, None if it should cause fail-closed.
710    fn validate_hard_dep(&self, dep_id: &str) -> Result<Option<Value>> {
711        match self.storage.get_chunk(dep_id)? {
712            None => Ok(None),
713            Some(chunk) => {
714                let state = chunk.get("state").and_then(Value::as_str).unwrap_or("");
715                let origin = chunk.get("origin").and_then(Value::as_str).unwrap_or("");
716                let embed_v = chunk
717                    .get("embed_version")
718                    .and_then(Value::as_i64)
719                    .unwrap_or(0);
720                if state == "archived" || origin == "spark" || embed_v == 0 {
721                    Ok(None)
722                } else {
723                    Ok(Some(chunk))
724                }
725            }
726        }
727    }
728
729    /// BFS hard-dep expansion up to `max_depth`. Returns Some(reason) on fail-closed.
730    fn expand_hard_closure(
731        &self,
732        id: &str,
733        visited: &mut HashSet<String>,
734        block: &mut Vec<Value>,
735        depth: usize,
736        max_depth: usize,
737    ) -> Result<Option<String>> {
738        if depth >= max_depth {
739            return Ok(Some("dep_depth_limit".to_string()));
740        }
741        let deps = self.storage.get_deps(id)?;
742        for (dep_id, kind, _) in &deps {
743            if kind != "hard" {
744                continue;
745            }
746            if visited.contains(dep_id) {
747                continue;
748            } // cycle guard
749            visited.insert(dep_id.clone());
750            match self.validate_hard_dep(dep_id)? {
751                None => return Ok(Some("hard_dep_unavailable".to_string())),
752                Some(chunk) => {
753                    block.push(chunk);
754                    if let Some(reason) =
755                        self.expand_hard_closure(dep_id, visited, block, depth + 1, max_depth)?
756                    {
757                        return Ok(Some(reason));
758                    }
759                }
760            }
761        }
762        Ok(None)
763    }
764
765    fn density_refill(
766        &self,
767        mut selected: Vec<Value>,
768        skipped: &[(Vec<Value>, f64, usize)],
769        budget: usize,
770    ) -> Vec<Value> {
771        let used_tokens = block_cost(&selected);
772        if used_tokens >= budget {
773            return selected;
774        }
775
776        let selected_ids: HashSet<String> = selected
777            .iter()
778            .filter_map(|c| c["id"].as_str().map(str::to_string))
779            .collect();
780
781        let mut density_items: Vec<(f64, Vec<Value>, usize)> = skipped
782            .iter()
783            .filter_map(|(block, fscore, _)| {
784                let block: Vec<Value> = block
785                    .iter()
786                    .filter(|b| !selected_ids.contains(b["id"].as_str().unwrap_or("")))
787                    .cloned()
788                    .collect();
789                if block.is_empty() {
790                    return None;
791                }
792                let cost = block_cost(&block);
793                let density = fscore / cost.max(1) as f64;
794                Some((density, block, cost))
795            })
796            .collect();
797        density_items.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
798
799        let mut used_tokens = block_cost(&selected);
800        let mut added_ids: HashSet<String> = selected_ids;
801        for (_, block, cost) in density_items {
802            if used_tokens + cost <= budget {
803                for b in block {
804                    let bid = b["id"].as_str().unwrap_or("").to_string();
805                    if !added_ids.contains(&bid) {
806                        selected.push(b);
807                        added_ids.insert(bid);
808                    }
809                }
810                used_tokens += cost;
811            }
812        }
813        selected
814    }
815
816    fn recall_sparks(&self, q_content: &[f32], q_trigger: &[f32]) -> Result<Vec<Value>> {
817        let embed_version = self
818            .storage
819            .get_meta("embed_version")?
820            .and_then(|v| v.parse::<i64>().ok())
821            .unwrap_or(1);
822
823        let content_res = self
824            .storage
825            .search_vec_content(q_content, self.top_k_candidates)?;
826        let trigger_res = self
827            .storage
828            .search_vec_trigger(q_trigger, self.top_k_candidates)?;
829
830        // Batch-fetch all candidate chunk IDs (mirrors the pattern in ann_candidates).
831        let all_ids: Vec<&str> = {
832            let mut seen = HashSet::new();
833            content_res
834                .iter()
835                .chain(trigger_res.iter())
836                .map(|(id, _)| id.as_str())
837                .filter(|id| seen.insert(*id))
838                .collect()
839        };
840        let chunks = self.storage.get_chunks_by_ids(&all_ids)?;
841
842        let mut spark_scores: HashMap<String, (f32, Value)> = HashMap::new();
843        for (cid, sim) in content_res.iter().chain(trigger_res.iter()) {
844            if let Some(chunk) = chunks.get(cid) {
845                if chunk.get("origin").and_then(Value::as_str) != Some("spark") {
846                    continue;
847                }
848                if chunk.get("state").and_then(Value::as_str) == Some("archived") {
849                    continue;
850                }
851                let maturity = chunk.get("maturity").and_then(Value::as_str).unwrap_or("");
852                if maturity == "promoted" || maturity == "dropped" {
853                    continue;
854                }
855                let ev = chunk
856                    .get("embed_version")
857                    .and_then(Value::as_i64)
858                    .unwrap_or(1);
859                if ev < embed_version {
860                    continue;
861                }
862                let entry = spark_scores
863                    .entry(cid.clone())
864                    .or_insert_with(|| (*sim, chunk.clone()));
865                if *sim > entry.0 {
866                    *entry = (*sim, chunk.clone());
867                }
868            }
869        }
870        let mut sparks: Vec<(f32, Value)> = spark_scores.into_values().collect();
871        sparks.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
872        Ok(sparks
873            .into_iter()
874            .take(self.top_k_candidates)
875            .map(|(_, c)| c)
876            .collect())
877    }
878
879    #[allow(clippy::too_many_arguments)]
880    fn write_recall_trace(
881        &self,
882        trace_id: &str,
883        query: &str,
884        context_key: &str,
885        scored: &[(f64, Value)],
886        visible: &[Value],
887        sparks: &[Value],
888        depth_skipped: &[String],
889        skipped_reasons: &HashMap<String, String>,
890        refine_mode: &str,
891        source: &str,
892        now: &str,
893        session_only: bool,
894        recall_meta: &Value,
895    ) -> Result<()> {
896        let lib_id = self.storage.lib_id()?;
897        // `selected` must strictly mean "entered the model context". Record the
898        // per-chunk retrieved/selected/refined events only when the result is
899        // actually surfaced: skip them for empty results (nothing surfaced) and
900        // for session-only recalls (daemon discards the knowledge). The episodic
901        // log is still written in both cases — an empty result as a terminal
902        // `known_none`/`discarded` row (no-answer telemetry, never `open`), a
903        // session recall as an `open` row for later record-correlation.
904        let is_empty = visible.is_empty() && sparks.is_empty();
905        let record_selection = !is_empty && !session_only;
906        self.storage.begin_immediate()?;
907        let result = (|| -> Result<()> {
908            if record_selection {
909                for (rank, (_, chunk)) in scored.iter().enumerate() {
910                    let cid = chunk["id"].as_str().unwrap_or("");
911                    let sim = chunk.get("_fused_score").and_then(Value::as_f64);
912                    // For dep-skipped seeds, record their skip reason as refine_mode.
913                    let rm = skipped_reasons
914                        .get(cid)
915                        .map(|r| format!("skipped:{r}"))
916                        .or_else(|| {
917                            if refine_mode != "off" && !refine_mode.is_empty() {
918                                Some(refine_mode.to_string())
919                            } else {
920                                None
921                            }
922                        });
923                    self.storage.insert_usage_trace(
924                        trace_id,
925                        Some(cid),
926                        "retrieved",
927                        1.0,
928                        sim,
929                        rm.as_deref(),
930                        None,
931                        Some((rank + 1) as i64),
932                        None,
933                        source,
934                        now,
935                    )?;
936                }
937                for (rank, chunk) in visible.iter().enumerate() {
938                    let cid = chunk["id"].as_str().unwrap_or("");
939                    self.storage.insert_usage_trace(
940                        trace_id,
941                        Some(cid),
942                        "selected",
943                        1.0,
944                        None,
945                        None,
946                        None,
947                        Some((rank + 1) as i64),
948                        None,
949                        source,
950                        now,
951                    )?;
952                    // Write 'refined' event for chunks that came through the trim path.
953                    if chunk
954                        .get("_trimmed")
955                        .and_then(Value::as_bool)
956                        .unwrap_or(false)
957                    {
958                        self.storage.insert_usage_trace(
959                            trace_id,
960                            Some(cid),
961                            "refined",
962                            1.0,
963                            None,
964                            Some("trim"),
965                            None,
966                            Some((rank + 1) as i64),
967                            None,
968                            source,
969                            now,
970                        )?;
971                    }
972                }
973                // Write 'retrieved' events for sparks (for recurring-spark count tracking).
974                for (rank, chunk) in sparks.iter().enumerate() {
975                    let cid = chunk["id"].as_str().unwrap_or("");
976                    self.storage.insert_usage_trace(
977                        trace_id,
978                        Some(cid),
979                        "retrieved",
980                        1.0,
981                        None,
982                        Some("spark"),
983                        None,
984                        Some((rank + 1) as i64),
985                        None,
986                        source,
987                        now,
988                    )?;
989                }
990            }
991            // The snapshot mirrors what was surfaced: empty for known_none and
992            // session-only recalls so no chunk is credited with a selection.
993            let snapshot = json!({
994                "schema": 2,
995                "retrieved": if record_selection { scored.iter().map(|(_, c)| c["id"].as_str().unwrap_or("")).collect::<Vec<_>>() } else { vec![] },
996                "selected": if record_selection { visible.iter().map(|c| c["id"].as_str().unwrap_or("")).collect::<Vec<_>>() } else { vec![] },
997                "sparks": if record_selection { sparks.iter().map(|c| c["id"].as_str().unwrap_or("")).collect::<Vec<_>>() } else { vec![] },
998                "depth_skipped": depth_skipped,
999                "skipped_reasons": skipped_reasons,
1000                "session_only": session_only,
1001                "recall": recall_meta,
1002            });
1003            // Empty recall → terminal known_none/discarded (kept out of the `open`
1004            // pool that feeds trace-completion stats). Otherwise `open` for the
1005            // record() outcome transition (incl. session-only daemon traces).
1006            // `usage_state='known_none'` is the no-answer signal; `task_state`
1007            // stays 'recalled' (both bounded by schema CHECK constraints).
1008            let (usage_state, distill_state) = if is_empty {
1009                ("known_none", "discarded")
1010            } else {
1011                ("unknown", "open")
1012            };
1013            let log = EpisodicLogRow {
1014                id: gen_uuid(),
1015                trace_id: trace_id.to_string(),
1016                lib_id,
1017                ts: now.to_string(),
1018                query: Some(query.to_string()),
1019                recall_snapshot: Some(snapshot.to_string()),
1020                event_source: source.to_string(),
1021                agent: agent_source(),
1022                task_state: "recalled".to_string(),
1023                usage_state: usage_state.to_string(),
1024                context_key: Some(context_key.to_string()),
1025                distill_state: distill_state.to_string(),
1026                ..Default::default()
1027            };
1028            self.storage.upsert_episodic_log(&log)?;
1029            self.storage.commit()
1030        })();
1031        if result.is_err() {
1032            let _ = self.storage.rollback();
1033        }
1034        result
1035    }
1036}
1037
1038/// N4 — explainability: turn the per-channel weighted contributions into a
1039/// compact `match_reason` for the returned chunk. Names the channels that drive
1040/// the fused score (each contributing ≥15% of the positive total, plus always
1041/// the single largest), and includes the rounded contribution of every channel
1042/// for full transparency. Additive metadata; never affects ranking.
1043pub(super) fn match_reason(contribs: &[(&str, f64)]) -> Value {
1044    let total: f64 = contribs.iter().map(|(_, c)| c.max(0.0)).sum();
1045    let mut ranked: Vec<(&str, f64)> = contribs
1046        .iter()
1047        .filter(|(_, c)| *c > 1e-9)
1048        .map(|(n, c)| (*n, *c))
1049        .collect();
1050    ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1051    let threshold = total * 0.15;
1052    let primary: Vec<&str> = ranked
1053        .iter()
1054        .enumerate()
1055        .filter(|(i, (_, c))| *i == 0 || *c >= threshold)
1056        .map(|(_, (n, _))| *n)
1057        .collect();
1058    let r3 = |x: f64| (x * 1000.0).round() / 1000.0;
1059    let contributions: serde_json::Map<String, Value> = ranked
1060        .iter()
1061        .map(|(n, c)| ((*n).to_string(), json!(r3(*c))))
1062        .collect();
1063    json!({
1064        "summary": primary.join(", "),
1065        "primary": primary,
1066        "contributions": Value::Object(contributions),
1067    })
1068}