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