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