Skip to main content

innate_core/kb/
mod.rs

1//! KnowledgeBase — all 8 Public APIs.
2
3/// Return type for pack(): (selected_chunks, skipped_groups, skip_reasons)
4type PackResult = (
5    Vec<Value>,
6    Vec<(Vec<Value>, f64, usize)>,
7    std::collections::HashMap<String, String>,
8);
9
10use std::collections::{HashMap, HashSet};
11use std::path::Path;
12use std::sync::Arc;
13
14use serde_json::{json, Value};
15
16use crate::embedding::{DummyEmbeddingProvider, EmbeddingProvider};
17use crate::errors::{InnateError, Result};
18use crate::refine::{
19    DefaultSanitizer, DistilledChunk, Distiller, HeuristicDistiller, NoopReranker, NullRefiner,
20    Refiner, Reranker, Sanitizer,
21};
22use crate::storage::{ChunkRow, EpisodicLogRow, Storage};
23use crate::utils::{
24    agent_source, content_hash, estimate_tokens, gen_uuid, pack_embedding, utc_now_iso,
25    SanitizeAction,
26};
27
28mod appraise;
29mod curate;
30mod evolve;
31mod inspection;
32mod lifecycle;
33mod recall;
34mod record;
35mod repair;
36mod situation;
37
38pub use appraise::{
39    AbstainReason, AppraiseParams, Contributor, FlaggedPoint, Tier, Valence, Verdict,
40    APPRAISE_ADVISORY,
41};
42pub use recall::RecallParams;
43pub use record::RecordParams;
44pub use repair::TraceRepairReport;
45pub use situation::Situation;
46
47// ---------------------------------------------------------------------------
48// Tuning defaults
49// ---------------------------------------------------------------------------
50
51// Fused recall score weights. These intentionally sum to 1.05 (not 1.0): the
52// score is a relative ranking signal, not a calibrated probability, so the extra
53// 0.05 of headroom on content similarity is deliberate and the result is never
54// re-normalised. Keep this in mind before "fixing" the sum.
55const W_CONTENT: f64 = 0.55;
56const W_TRIGGER: f64 = 0.25;
57const W_CONFIDENCE: f64 = 0.10;
58const W_CONTEXT: f64 = 0.15;
59const W_ACTIVATION: f64 = 0.08;
60// Hybrid 检索:lexical/BM25 channel weight. Modest by default so exact-term
61// matches lift the right chunk without overpowering semantic similarity.
62const W_LEXICAL: f64 = 0.25;
63// ACT-R spreading-activation channel (SAG-inspired associative recall). Weight of
64// the spread score in the fused sum. Defaults to 0.0 — OFF — so the no-LLM hot
65// path is byte-for-byte unchanged until a multi-hop eval set justifies turning it
66// on. When 0, recall skips the entity-expansion work entirely (zero added cost).
67const W_SPREAD: f64 = 0.0;
68// Entities linking more chunks than this are treated as non-discriminative and
69// dropped from the spread (ACT-R fan effect taken to its limit). Keeps promiscuous
70// tokens (`--release`, `rust`) from flooding the candidate set.
71const SPREAD_FAN_CAP: i64 = 50;
72// Number of top base-relevance candidates whose entities seed the 2-hop spread.
73const SPREAD_SEED_N: usize = 5;
74const TOP_K_CANDIDATES: usize = 20;
75const ANTI_TRIGGER_PENALTY: f64 = 0.6;
76const DENSITY_REFILL: bool = true;
77
78const LOW_CONF_THRESHOLD: f64 = 0.25;
79const LOW_CONF_IDLE_DAYS: i64 = 60;
80const REPEAT_SELECT_MIN: i64 = 10;
81const REPEAT_SELECT_CONF_MAX: f64 = 0.5;
82const NEVER_USED_AGE_DAYS: i64 = 30;
83const OPEN_TTL_DAYS: i64 = 14;
84const SCREENING_TIMEOUT_MINUTES: i64 = 30;
85const METRICS_RETAIN_DAYS: i64 = 30;
86const PROMOTE_USED_SUCCESS_MIN: i64 = 2;
87const PROMOTE_CONFIDENCE_MIN: f64 = 0.60;
88const DECAY_FLOOR: f64 = 0.20;
89const EVOLVE_THRESHOLD: i64 = 5;
90const DISTILL_BATCH_SIZE: usize = 20;
91const PENDING_RECALL_PENALTY: f64 = 0.60;
92
93// Intuition / appraise critic defaults (Spec §8). The appraise path reuses the
94// same fused score as recall; these only govern how that score is tiered/flagged.
95const APPRAISE_TIER_WEAK: f64 = 0.30;
96const APPRAISE_TIER_STRONG: f64 = 0.65;
97const APPRAISE_MIN_STRENGTH: f64 = 0.40;
98const APPRAISE_TOP: usize = 8;
99const APPRAISE_TRIGGER_HIT_MIN: f64 = 0.50;
100const APPRAISE_CANDIDATE_IN_EMBED: bool = true;
101// 弃权门(方案 A/F/G)。默认值保持现行行为(门2/门3/门4 关闭),由 meta 调参激活。
102//   门2 signature_floor=0.0   → 关闭(任何一致度都放行)
103//   门3 min_evidence=0        → 关闭(不要求观测历史)
104//   门4 conflict_ceiling=1.0  → 关闭(离散度上界恒不触发)
105// 门1 弱共振无需阈值:prune 后候选为空即弃权(WeakResonance),天然作动。
106const APPRAISE_SIGNATURE_FLOOR: f64 = 0.0;
107const APPRAISE_MIN_EVIDENCE: i64 = 0;
108const APPRAISE_CONFLICT_CEILING: f64 = 1.0;
109// 方案 D 基率锚定先验:prior = Beta(m·g0, m·(1-g0))。默认 m=2, g0=0.5 与旧 Laplace
110// (wins+1)/(evidence+2) 完全等价 → 零行为变化,调大 m / 设真实基率即激活。
111// **仅作用于 appraise(直觉/校准)路径**:实施文档明确范围不含 recall。
112const INTUITION_PRIOR_M: f64 = 2.0;
113const INTUITION_BASE_RATE: f64 = 0.5;
114// recall(图书管理员)路径恒用中性 Laplace 先验(m=2, g0=0.5),与历史
115// (wins+1)/(evidence+2) 逐位等价,绝不受 intuition.* 校准旋钮影响。方案 D 与 recall 解耦。
116const RECALL_PRIOR_M: f64 = 2.0;
117const RECALL_BASE_RATE: f64 = 0.5;
118// 方案 E 校准映射桶数。
119const CALIBRATION_BINS: i64 = 10;
120const SITUATION_COARSE_KEYS: &str = "stage,error_class,file_type";
121// Part (c) — query-embedding granularity. When true, recall folds the normalized
122// situation signature (stage/error_class/file_type) into the embedded query text so
123// the embedding anchors on the situation, not just raw words. Default OFF: opt-in
124// and reversible (chunks are embedded from content/trigger, so enabling it shifts
125// only the query side — measure with `innate recall-eval` before turning on).
126const EMBED_SITUATION_SIGNATURE: bool = false;
127const GOVERNANCE_ARCHIVE_THRESHOLD: i64 = 3;
128const NEGATIVE_FEEDBACK_ARCHIVE_THRESHOLD: i64 = 5;
129const GOVERNANCE_EVOLVE_THRESHOLD: i64 = 3;
130const FAILURE_MIN_USES: i64 = 5;
131const FAILURE_MAX_SUCCESS_RATE: f64 = 0.20;
132const FAILURE_CONFIDENCE_MAX: f64 = 0.35;
133const LOG_COMPACT_DAYS: i64 = 30;
134
135// ---------------------------------------------------------------------------
136// Public result types
137// ---------------------------------------------------------------------------
138
139#[derive(Debug, Default, Clone)]
140pub struct RecallResult {
141    pub knowledge: Vec<Value>,
142    pub sparks: Vec<Value>,
143    pub trace_id: String,
144    pub empty: bool,
145    pub depth_skipped: Vec<String>,
146    pub skipped_reasons: HashMap<String, String>,
147}
148
149#[derive(Debug, Default)]
150pub struct CurateReport {
151    pub archived: Vec<String>,
152    pub promoted: Vec<String>,
153    pub deduped: Vec<String>,
154    pub decayed: Vec<String>,
155    pub cycles: Vec<Vec<String>>,
156    pub orphans: Vec<String>,
157    pub recovered: Vec<String>,
158    pub warnings: Vec<String>,
159    pub stats: HashMap<String, Value>,
160}
161
162#[derive(Debug, Default)]
163struct DistillBatchReport {
164    distilled: usize,
165    failed: usize,
166}
167
168/// Scope for a single Curate run — allows limiting governance to a subset of chunks.
169#[derive(Debug, Default, Clone)]
170pub struct CurateScope {
171    /// If set, only process chunks with this origin (e.g. "distilled").
172    pub origin: Option<String>,
173    /// If set, only process chunks belonging to this skill.
174    pub skill_name: Option<String>,
175    /// When true, compute the report but do not write any changes.
176    pub dry_run: bool,
177}
178
179/// Replaceable governance interface (§二·六). Inject via `KnowledgeBase::open_with`.
180/// Default implementation: `BuiltinCurator`.
181pub trait Curator: Send + Sync {
182    fn run(&self, kb: &KnowledgeBase, scope: &CurateScope) -> Result<CurateReport>;
183}
184
185/// Built-in curator — implements the full §四 governance pipeline.
186pub struct BuiltinCurator;
187
188impl Curator for BuiltinCurator {
189    fn run(&self, kb: &KnowledgeBase, scope: &CurateScope) -> Result<CurateReport> {
190        kb.builtin_curate_impl(scope)
191    }
192}
193
194// ---------------------------------------------------------------------------
195// KnowledgeBase
196// ---------------------------------------------------------------------------
197
198pub struct KnowledgeBase {
199    pub storage: Storage,
200    embedding: Arc<dyn EmbeddingProvider>,
201    refiner: Arc<dyn Refiner>,
202    distiller: Arc<dyn Distiller>,
203    curator: Arc<dyn Curator>,
204    sanitizer: Arc<dyn Sanitizer>,
205    /// Opt-in offline reranker (part d). Defaults to `NoopReranker` (fused order
206    /// preserved); set via `with_reranker` when an LLM is configured.
207    reranker: Arc<dyn Reranker>,
208
209    // Tuning params (loaded from meta at init)
210    w_content: f64,
211    w_trigger: f64,
212    w_confidence: f64,
213    w_context: f64,
214    w_activation: f64,
215    w_lexical: f64,
216    w_spread: f64,
217    spread_fan_cap: i64,
218    spread_seed_n: usize,
219    top_k_candidates: usize,
220    anti_trigger_penalty: f64,
221    density_refill: bool,
222
223    low_conf_threshold: f64,
224    low_conf_idle_days: i64,
225    repeat_select_min: i64,
226    repeat_select_conf_max: f64,
227    never_used_age_days: i64,
228    open_ttl_days: i64,
229    screening_timeout_minutes: i64,
230    metrics_retain_days: i64,
231    promote_used_success_min: i64,
232    promote_confidence_min: f64,
233    decay_floor: f64,
234    evolve_threshold: i64,
235    distill_batch_size: usize,
236    evolve_schedule_interval_hours: i64,
237    governance_archive_threshold: i64,
238    negative_feedback_archive_threshold: i64,
239    governance_evolve_threshold: i64,
240    governance_proposal_max_age_days: i64,
241    failure_min_uses: i64,
242    failure_max_success_rate: f64,
243    failure_confidence_max: f64,
244    log_compact_days: i64,
245
246    // Intuition / appraise critic params
247    appraise_tier_weak: f64,
248    appraise_tier_strong: f64,
249    appraise_min_strength: f64,
250    appraise_top: usize,
251    appraise_trigger_hit_min: f64,
252    appraise_candidate_in_embed: bool,
253    appraise_signature_floor: f64,
254    appraise_min_evidence: i64,
255    appraise_conflict_ceiling: f64,
256    intuition_prior_m: f64,
257    intuition_base_rate: f64,
258    calibration_bins: i64,
259    situation_coarse_keys: String,
260    embed_situation_signature: bool,
261}
262
263impl KnowledgeBase {
264    pub fn open(db_path: impl AsRef<Path>) -> Result<Self> {
265        Self::open_with(db_path, None, None, None, None, None)
266    }
267
268    /// Persist a content embedding, rejecting any vector whose dimension differs
269    /// from the configured provider. A mismatched vector is silently skipped at
270    /// search time (cosine search only scores equal-dimension vectors), so an
271    /// unchecked write becomes invisible recall loss with no error. Fail closed
272    /// at the write boundary instead. All vector writers route through here.
273    pub(crate) fn store_vec_content(&self, chunk_id: &str, cvec: &[f32]) -> Result<()> {
274        let want = self.embedding.content_dim();
275        if cvec.len() != want {
276            return Err(InnateError::InvalidState(format!(
277                "content embedding dim {} != configured {want} (chunk {chunk_id})",
278                cvec.len()
279            )));
280        }
281        self.storage
282            .insert_vec_content(chunk_id, &pack_embedding(cvec))
283    }
284
285    /// Trigger-vector counterpart of [`store_vec_content`]; same fail-closed
286    /// dimension guard against `trigger_dim()`.
287    pub(crate) fn store_vec_trigger(&self, chunk_id: &str, tvec: &[f32]) -> Result<()> {
288        let want = self.embedding.trigger_dim();
289        if tvec.len() != want {
290            return Err(InnateError::InvalidState(format!(
291                "trigger embedding dim {} != configured {want} (chunk {chunk_id})",
292                tvec.len()
293            )));
294        }
295        self.storage
296            .insert_vec_trigger(chunk_id, &pack_embedding(tvec))
297    }
298
299    pub fn open_with(
300        db_path: impl AsRef<Path>,
301        embedding: Option<Arc<dyn EmbeddingProvider>>,
302        refiner: Option<Arc<dyn Refiner>>,
303        distiller: Option<Arc<dyn Distiller>>,
304        curator: Option<Arc<dyn Curator>>,
305        sanitizer: Option<Arc<dyn Sanitizer>>,
306    ) -> Result<Self> {
307        let embedding = embedding.unwrap_or_else(|| Arc::new(DummyEmbeddingProvider::default()));
308        let refiner = refiner.unwrap_or_else(|| Arc::new(NullRefiner));
309        let distiller = distiller.unwrap_or_else(|| Arc::new(HeuristicDistiller));
310        let curator = curator.unwrap_or_else(|| Arc::new(BuiltinCurator));
311        let sanitizer = sanitizer.unwrap_or_else(|| Arc::new(DefaultSanitizer));
312        let reranker: Arc<dyn Reranker> = Arc::new(NoopReranker);
313
314        let storage = Storage::open(db_path, embedding.content_dim(), embedding.trigger_dim())?;
315
316        let mut kb = Self {
317            storage,
318            embedding,
319            refiner,
320            distiller,
321            curator,
322            sanitizer,
323            reranker,
324            w_lexical: W_LEXICAL,
325            w_spread: W_SPREAD,
326            spread_fan_cap: SPREAD_FAN_CAP,
327            spread_seed_n: SPREAD_SEED_N,
328            embed_situation_signature: EMBED_SITUATION_SIGNATURE,
329            w_content: W_CONTENT,
330            w_trigger: W_TRIGGER,
331            w_confidence: W_CONFIDENCE,
332            w_context: W_CONTEXT,
333            w_activation: W_ACTIVATION,
334            top_k_candidates: TOP_K_CANDIDATES,
335            anti_trigger_penalty: ANTI_TRIGGER_PENALTY,
336            density_refill: DENSITY_REFILL,
337            low_conf_threshold: LOW_CONF_THRESHOLD,
338            low_conf_idle_days: LOW_CONF_IDLE_DAYS,
339            repeat_select_min: REPEAT_SELECT_MIN,
340            repeat_select_conf_max: REPEAT_SELECT_CONF_MAX,
341            never_used_age_days: NEVER_USED_AGE_DAYS,
342            open_ttl_days: OPEN_TTL_DAYS,
343            metrics_retain_days: METRICS_RETAIN_DAYS,
344            screening_timeout_minutes: SCREENING_TIMEOUT_MINUTES,
345            promote_used_success_min: PROMOTE_USED_SUCCESS_MIN,
346            promote_confidence_min: PROMOTE_CONFIDENCE_MIN,
347            decay_floor: DECAY_FLOOR,
348            evolve_threshold: EVOLVE_THRESHOLD,
349            distill_batch_size: DISTILL_BATCH_SIZE,
350            evolve_schedule_interval_hours: 6,
351            governance_archive_threshold: GOVERNANCE_ARCHIVE_THRESHOLD,
352            negative_feedback_archive_threshold: NEGATIVE_FEEDBACK_ARCHIVE_THRESHOLD,
353            governance_evolve_threshold: GOVERNANCE_EVOLVE_THRESHOLD,
354            governance_proposal_max_age_days: 30,
355            failure_min_uses: FAILURE_MIN_USES,
356            failure_max_success_rate: FAILURE_MAX_SUCCESS_RATE,
357            failure_confidence_max: FAILURE_CONFIDENCE_MAX,
358            log_compact_days: LOG_COMPACT_DAYS,
359            appraise_tier_weak: APPRAISE_TIER_WEAK,
360            appraise_tier_strong: APPRAISE_TIER_STRONG,
361            appraise_min_strength: APPRAISE_MIN_STRENGTH,
362            appraise_top: APPRAISE_TOP,
363            appraise_trigger_hit_min: APPRAISE_TRIGGER_HIT_MIN,
364            appraise_candidate_in_embed: APPRAISE_CANDIDATE_IN_EMBED,
365            appraise_signature_floor: APPRAISE_SIGNATURE_FLOOR,
366            appraise_min_evidence: APPRAISE_MIN_EVIDENCE,
367            appraise_conflict_ceiling: APPRAISE_CONFLICT_CEILING,
368            intuition_prior_m: INTUITION_PRIOR_M,
369            intuition_base_rate: INTUITION_BASE_RATE,
370            calibration_bins: CALIBRATION_BINS,
371            situation_coarse_keys: SITUATION_COARSE_KEYS.to_string(),
372        };
373        kb.init_meta()?;
374        kb.load_params()?;
375        Ok(kb)
376    }
377
378    /// Install an opt-in offline reranker (part d). Used by `open_kb` when an LLM is
379    /// configured; recall only invokes it when a caller passes `rerank=true`, so the
380    /// default hook path stays no-LLM regardless.
381    pub fn with_reranker(mut self, reranker: Arc<dyn Reranker>) -> Self {
382        self.reranker = reranker;
383        self
384    }
385
386    fn init_meta(&self) -> Result<()> {
387        let lib_id = gen_uuid();
388        let content_dim = self.embedding.content_dim().to_string();
389        let trigger_dim = self.embedding.trigger_dim().to_string();
390        let embed_model = self.embedding.model_name();
391
392        for (key, expected) in [
393            ("content_dim", self.embedding.content_dim()),
394            ("trigger_dim", self.embedding.trigger_dim()),
395        ] {
396            if let Some(stored) = self.storage.get_meta(key)? {
397                let actual = stored.parse::<usize>().map_err(|_| {
398                    InnateError::Other(format!("invalid {key} metadata value: {stored}"))
399                })?;
400                if actual != expected {
401                    return Err(InnateError::Other(format!(
402                        "{key} mismatch: database uses {actual}, embedding provider uses {expected}"
403                    )));
404                }
405            }
406        }
407
408        let defaults: &[(&str, &str)] = &[
409            ("lib_id", &lib_id),
410            ("lib_role", "personal"),
411            ("schema_version", "4.14"),
412            ("content_dim", &content_dim),
413            ("trigger_dim", &trigger_dim),
414            ("embed_model", embed_model),
415            ("embed_version", "1"),
416            ("vector_revision", "0"),
417            ("last_agg_ts", "1970-01-01T00:00:00.000Z"),
418            ("recall.w_content", "0.55"),
419            ("recall.w_trigger", "0.25"),
420            ("recall.w_confidence", "0.10"),
421            ("recall.w_context", "0.15"),
422            ("recall.w_activation", "0.08"),
423            ("recall.w_lexical", "0.25"),
424            ("recall.w_spread", "0.0"),
425            ("recall.spread_fan_cap", "50"),
426            ("recall.spread_seed_n", "5"),
427            ("recall.embed_situation_signature", "false"),
428            ("recall.top_k_candidates", "20"),
429            ("recall.anti_trigger_penalty", "0.6"),
430            ("recall.density_refill", "true"),
431            ("curate.low_conf_threshold", "0.25"),
432            ("curate.low_conf_idle_days", "60"),
433            ("curate.repeat_select_min", "10"),
434            ("curate.repeat_select_conf_max", "0.5"),
435            ("curate.never_used_age_days", "30"),
436            ("metrics.retain_days", "30"),
437            ("curate.open_ttl_days", "14"),
438            ("curate.screening_timeout_minutes", "30"),
439            ("curate.promote_used_success_min", "2"),
440            ("curate.promote_confidence_min", "0.60"),
441            ("curate.decay_floor", "0.20"),
442            ("evolve.threshold_new_count", "5"),
443            ("evolve.distill_batch_size", "20"),
444            ("evolve.schedule_interval_hours", "6"),
445            ("curate.soft_mature_threshold", "5"),
446            ("evolve.distill_token_window_hours", "24"),
447            ("curate.governance_archive_threshold", "3"),
448            ("curate.negative_feedback_archive_threshold", "5"),
449            ("evolve.governance_pending_threshold", "3"),
450            ("curate.governance_proposal_max_age_days", "30"),
451            ("curate.failure_min_uses", "5"),
452            ("curate.failure_max_success_rate", "0.20"),
453            ("curate.failure_confidence_max", "0.35"),
454            ("curate.log_compact_days", "30"),
455            ("appraise.tier_weak", "0.30"),
456            ("appraise.tier_strong", "0.65"),
457            ("appraise.min_strength", "0.40"),
458            ("appraise.top", "8"),
459            ("appraise.trigger_hit_min", "0.50"),
460            ("appraise.candidate_in_embed", "true"),
461            ("appraise.signature_floor", "0.0"),
462            ("appraise.min_evidence", "0"),
463            ("appraise.conflict_ceiling", "1.0"),
464            ("intuition.prior_m", "2.0"),
465            ("intuition.base_rate", "0.5"),
466            ("intuition.calibration_bins", "10"),
467            ("situation.coarse_keys", "stage,error_class,file_type"),
468        ];
469        self.storage.begin_immediate()?;
470        let result = (|| -> Result<()> {
471            for (k, v) in defaults {
472                if self.storage.get_meta(k)?.is_none() {
473                    self.storage.set_meta(k, v)?;
474                }
475            }
476            self.storage.commit()
477        })();
478        if result.is_err() {
479            let _ = self.storage.rollback();
480        }
481        result
482    }
483
484    fn load_params(&mut self) -> Result<()> {
485        let f = |k: &str, d: f64| -> f64 {
486            self.storage
487                .get_meta(k)
488                .ok()
489                .flatten()
490                .and_then(|v| v.parse().ok())
491                .unwrap_or(d)
492        };
493        let i = |k: &str, d: i64| -> i64 {
494            self.storage
495                .get_meta(k)
496                .ok()
497                .flatten()
498                .and_then(|v| v.parse().ok())
499                .unwrap_or(d)
500        };
501        let b = |k: &str, d: bool| -> bool {
502            self.storage
503                .get_meta(k)
504                .ok()
505                .flatten()
506                .map(|v| v.to_lowercase() == "true")
507                .unwrap_or(d)
508        };
509        self.w_content = f("recall.w_content", W_CONTENT);
510        self.w_trigger = f("recall.w_trigger", W_TRIGGER);
511        self.w_confidence = f("recall.w_confidence", W_CONFIDENCE);
512        self.w_context = f("recall.w_context", W_CONTEXT);
513        self.w_lexical = f("recall.w_lexical", W_LEXICAL);
514        self.w_spread = f("recall.w_spread", W_SPREAD);
515        self.spread_fan_cap = i("recall.spread_fan_cap", SPREAD_FAN_CAP).max(1);
516        self.spread_seed_n = i("recall.spread_seed_n", SPREAD_SEED_N as i64).max(0) as usize;
517        self.embed_situation_signature =
518            b("recall.embed_situation_signature", EMBED_SITUATION_SIGNATURE);
519        self.w_activation = f("recall.w_activation", W_ACTIVATION);
520        self.top_k_candidates =
521            i("recall.top_k_candidates", TOP_K_CANDIDATES as i64).max(1) as usize;
522        self.anti_trigger_penalty = f("recall.anti_trigger_penalty", ANTI_TRIGGER_PENALTY);
523        self.density_refill = b("recall.density_refill", DENSITY_REFILL);
524        self.low_conf_threshold = f("curate.low_conf_threshold", LOW_CONF_THRESHOLD);
525        self.low_conf_idle_days = i("curate.low_conf_idle_days", LOW_CONF_IDLE_DAYS);
526        self.repeat_select_min = i("curate.repeat_select_min", REPEAT_SELECT_MIN);
527        self.metrics_retain_days = i("metrics.retain_days", METRICS_RETAIN_DAYS);
528        self.repeat_select_conf_max = f("curate.repeat_select_conf_max", REPEAT_SELECT_CONF_MAX);
529        self.never_used_age_days = i("curate.never_used_age_days", NEVER_USED_AGE_DAYS);
530        self.open_ttl_days = i("curate.open_ttl_days", OPEN_TTL_DAYS);
531        self.screening_timeout_minutes = i(
532            "curate.screening_timeout_minutes",
533            SCREENING_TIMEOUT_MINUTES,
534        );
535        self.promote_used_success_min =
536            i("curate.promote_used_success_min", PROMOTE_USED_SUCCESS_MIN);
537        self.promote_confidence_min = f("curate.promote_confidence_min", PROMOTE_CONFIDENCE_MIN);
538        self.decay_floor = f("curate.decay_floor", DECAY_FLOOR).clamp(0.0, 0.4);
539        self.evolve_threshold = i("evolve.threshold_new_count", EVOLVE_THRESHOLD);
540        self.distill_batch_size =
541            i("evolve.distill_batch_size", DISTILL_BATCH_SIZE as i64) as usize;
542        self.evolve_schedule_interval_hours = i("evolve.schedule_interval_hours", 6).max(1);
543        self.governance_archive_threshold = i(
544            "curate.governance_archive_threshold",
545            GOVERNANCE_ARCHIVE_THRESHOLD,
546        )
547        .max(1);
548        self.negative_feedback_archive_threshold = i(
549            "curate.negative_feedback_archive_threshold",
550            NEGATIVE_FEEDBACK_ARCHIVE_THRESHOLD,
551        )
552        .max(1);
553        self.governance_evolve_threshold = i(
554            "evolve.governance_pending_threshold",
555            GOVERNANCE_EVOLVE_THRESHOLD,
556        )
557        .max(1);
558        self.governance_proposal_max_age_days =
559            i("curate.governance_proposal_max_age_days", 30).max(1);
560        self.failure_min_uses = i("curate.failure_min_uses", FAILURE_MIN_USES).max(1);
561        self.failure_max_success_rate =
562            f("curate.failure_max_success_rate", FAILURE_MAX_SUCCESS_RATE).clamp(0.0, 1.0);
563        self.failure_confidence_max =
564            f("curate.failure_confidence_max", FAILURE_CONFIDENCE_MAX).clamp(0.0, 1.0);
565        self.log_compact_days = i("curate.log_compact_days", LOG_COMPACT_DAYS).max(1);
566        let s = |k: &str, d: &str| -> String {
567            self.storage
568                .get_meta(k)
569                .ok()
570                .flatten()
571                .filter(|v| !v.trim().is_empty())
572                .unwrap_or_else(|| d.to_string())
573        };
574        self.appraise_tier_weak = f("appraise.tier_weak", APPRAISE_TIER_WEAK).clamp(0.0, 1.0);
575        self.appraise_tier_strong = f("appraise.tier_strong", APPRAISE_TIER_STRONG).clamp(0.0, 1.0);
576        self.appraise_min_strength =
577            f("appraise.min_strength", APPRAISE_MIN_STRENGTH).clamp(0.0, 1.0);
578        self.appraise_top = i("appraise.top", APPRAISE_TOP as i64).max(1) as usize;
579        self.appraise_trigger_hit_min =
580            f("appraise.trigger_hit_min", APPRAISE_TRIGGER_HIT_MIN).clamp(0.0, 1.0);
581        self.appraise_candidate_in_embed =
582            b("appraise.candidate_in_embed", APPRAISE_CANDIDATE_IN_EMBED);
583        self.appraise_signature_floor =
584            f("appraise.signature_floor", APPRAISE_SIGNATURE_FLOOR).clamp(0.0, 1.0);
585        self.appraise_min_evidence = i("appraise.min_evidence", APPRAISE_MIN_EVIDENCE).max(0);
586        self.appraise_conflict_ceiling =
587            f("appraise.conflict_ceiling", APPRAISE_CONFLICT_CEILING).clamp(0.0, 1.0);
588        self.intuition_prior_m = f("intuition.prior_m", INTUITION_PRIOR_M).max(0.0);
589        self.intuition_base_rate = f("intuition.base_rate", INTUITION_BASE_RATE).clamp(0.0, 1.0);
590        self.calibration_bins = i("intuition.calibration_bins", CALIBRATION_BINS).clamp(2, 100);
591        self.situation_coarse_keys = s("situation.coarse_keys", SITUATION_COARSE_KEYS);
592        Ok(())
593    }
594}
595
596// ---------------------------------------------------------------------------
597// Helpers
598// ---------------------------------------------------------------------------
599
600struct CandidateInfo {
601    chunk: Value,
602    sim_content: f32,
603    sim_trigger: f32,
604    /// Lexical/BM25 channel score ∈ [0,1] (hybrid 检索). Zero when the chunk was
605    /// found only by vector search; positive when an exact-term match recovered it.
606    sim_lexical: f32,
607    /// ACT-R spreading-activation score ∈ [0,1]. Positive when the chunk was
608    /// reached via a shared entity (with the query or a high-relevance seed),
609    /// even if no similarity/lexical channel surfaced it. Zero on the default
610    /// (w_spread = 0) path.
611    sim_spread: f32,
612}
613
614/// True when a coarse signature carries at least one real value (not empty /
615/// `none` / `unknown`) — used to decide whether folding it into the embed query
616/// adds signal or just noise.
617fn signature_has_signal(sig: &str) -> bool {
618    sig.split('|').any(|p| {
619        p.split_once('=')
620            .map(|(_, v)| !v.is_empty() && v != "none" && v != "unknown")
621            .unwrap_or(false)
622    })
623}
624
625/// Fresh candidate from a chunk with all channel sims zeroed (callers set the
626/// channel(s) that surfaced it). Centralised so adding a channel touches one place.
627fn new_candidate(chunk: &Value) -> CandidateInfo {
628    CandidateInfo {
629        chunk: chunk.clone(),
630        sim_content: 0.0,
631        sim_trigger: 0.0,
632        sim_lexical: 0.0,
633        sim_spread: 0.0,
634    }
635}
636
637fn chunk_is_valid_for_recall(chunk: &Value, embed_version: i64) -> bool {
638    chunk.get("state").and_then(Value::as_str) != Some("archived")
639        && chunk.get("origin").and_then(Value::as_str) != Some("spark")
640        && chunk
641            .get("embed_version")
642            .and_then(Value::as_i64)
643            .unwrap_or(1)
644            >= embed_version
645}
646
647/// Normalize a query string before hashing into a context_key.
648///
649/// Goals: collapse whitespace variations and case differences so that
650/// semantically equivalent queries (same words, different capitalisation or
651/// spacing) accumulate statistics in the same context_stat bucket.
652///
653/// Deliberately conservative: no stemming, no stop-word removal. The canonical
654/// query guidance in SKILL.md handles vocabulary consistency at the agent level.
655fn normalize_query(query: &str) -> String {
656    const STOP_WORDS: &[&str] = &[
657        "a", "an", "and", "for", "in", "of", "on", "the", "to", "with",
658    ];
659    let cleaned: String = query
660        .to_lowercase()
661        .chars()
662        .map(|ch| {
663            if ch.is_alphanumeric() || ch.is_whitespace() {
664                ch
665            } else {
666                ' '
667            }
668        })
669        .collect();
670    let mut tokens: Vec<&str> = cleaned
671        .split_whitespace()
672        .filter(|token| !STOP_WORDS.contains(token))
673        .collect();
674    tokens.sort_unstable();
675    tokens.dedup();
676    tokens.join(" ")
677}
678
679fn estimate_distill_prompt_tokens(log: &Value, related_logs: &[Value]) -> i64 {
680    let primary: i64 = [
681        "query",
682        "recall_snapshot",
683        "output",
684        "output_summary",
685        "nomination",
686    ]
687    .iter()
688    .filter_map(|key| log.get(*key).and_then(Value::as_str))
689    .map(|text| estimate_tokens(text) as i64)
690    .sum();
691    let log_id = log.get("id").and_then(Value::as_str).unwrap_or("");
692    let context_key = log.get("context_key").and_then(Value::as_str);
693    let related: i64 = related_logs
694        .iter()
695        .filter(|other| other.get("id").and_then(Value::as_str).unwrap_or("") != log_id)
696        .filter(|other| {
697            context_key.is_some() && other.get("context_key").and_then(Value::as_str) == context_key
698        })
699        .take(4)
700        .flat_map(|other| {
701            ["query", "output_summary", "outcome"]
702                .into_iter()
703                .filter_map(|key| other.get(key).and_then(Value::as_str))
704        })
705        .map(|text| estimate_tokens(text) as i64)
706        .sum();
707    primary + related
708}
709
710fn estimate_distilled_chunk_tokens(chunk: &DistilledChunk) -> i64 {
711    estimate_tokens(&chunk.content) as i64
712        + chunk
713            .trigger_desc
714            .as_deref()
715            .map(estimate_tokens)
716            .unwrap_or(0) as i64
717        + chunk
718            .anti_trigger_desc
719            .as_deref()
720            .map(estimate_tokens)
721            .unwrap_or(0) as i64
722}
723
724fn anti_trigger_hit(query: &str, anti: &str) -> bool {
725    let q_lower = query.to_lowercase();
726    anti.to_lowercase().split(',').any(|part| {
727        let p = part.trim();
728        !p.is_empty() && q_lower.contains(p)
729    })
730}
731
732fn block_cost(block: &[Value]) -> usize {
733    block
734        .iter()
735        .map(|b| {
736            b.get("token_count")
737                .and_then(Value::as_u64)
738                .map(|t| t as usize)
739                .unwrap_or_else(|| {
740                    estimate_tokens(b.get("content").and_then(Value::as_str).unwrap_or("")).max(100)
741                })
742        })
743        .sum()
744}
745
746fn limit_knowledge(knowledge: Vec<Value>, top: Option<usize>) -> Vec<Value> {
747    match top {
748        None => knowledge,
749        Some(0) => vec![],
750        Some(n) => knowledge.into_iter().take(n).collect(),
751    }
752}
753
754fn usage_state(used: Option<&[String]>) -> &'static str {
755    match used {
756        None => "unknown",
757        Some([]) => "known_none",
758        Some(_) => "known_some",
759    }
760}
761
762fn ratio(numerator: i64, denominator: i64) -> f64 {
763    if denominator <= 0 {
764        0.0
765    } else {
766        ((numerator as f64 / denominator as f64) * 1000.0).round() / 1000.0
767    }
768}
769
770fn validate_source(source: &str) -> Result<()> {
771    if !matches!(
772        source,
773        "mcp" | "sdk" | "cli" | "hook" | "daemon" | "augmented"
774    ) {
775        return Err(InnateError::InvalidState(format!(
776            "invalid event source: {source}"
777        )));
778    }
779    Ok(())
780}
781
782fn count_query(storage: &Storage, sql: &str) -> Result<i64> {
783    Ok(storage
784        .query_chunks(sql)?
785        .first()
786        .and_then(|r| r.as_object())
787        .and_then(|m| m.values().next())
788        .and_then(Value::as_i64)
789        .unwrap_or(0))
790}
791
792fn count_query_params<P: rusqlite::Params>(storage: &Storage, sql: &str, p: P) -> Result<i64> {
793    Ok(storage
794        .query_chunks_params(sql, p)?
795        .first()
796        .and_then(|r| r.as_object())
797        .and_then(|m| m.values().next())
798        .and_then(Value::as_i64)
799        .unwrap_or(0))
800}
801
802fn days_ago(now_iso: &str, days: i64) -> String {
803    use chrono::{DateTime, Duration, Utc};
804    if let Ok(t) = now_iso.parse::<DateTime<Utc>>() {
805        let cutoff = t - Duration::days(days);
806        return cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
807    }
808    now_iso.to_string()
809}
810
811fn hours_ago(now_iso: &str, hours: i64) -> String {
812    use chrono::{DateTime, Duration, Utc};
813    if let Ok(t) = now_iso.parse::<DateTime<Utc>>() {
814        let cutoff = t - Duration::hours(hours);
815        return cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
816    }
817    now_iso.to_string()
818}
819
820impl KnowledgeBase {
821    /// Time a fallible operation, persist a one-row `operation_runs` summary (P3a,
822    /// design doc §5.3), and return its result unchanged. Instrumentation is
823    /// **non-fatal**: a failed metrics insert never affects the wrapped operation.
824    /// Raw LLM detail still lives in `llm_trace.log`; this holds only the aggregatable
825    /// summary (status / duration / error_kind). `error_kind` comes from the closed
826    /// vocabulary in `storage::metrics::classify_error` so the top-list groups cleanly.
827    pub(crate) fn measure<T>(
828        &self,
829        op: &str,
830        source: Option<&str>,
831        trace_id: Option<&str>,
832        f: impl FnOnce() -> Result<T>,
833    ) -> Result<T> {
834        let started = std::time::Instant::now();
835        let started_at = utc_now_iso();
836        let result = f();
837        let duration_ms = started.elapsed().as_millis() as i64;
838        let (status, error_kind) = match &result {
839            Ok(_) => ("ok", None),
840            Err(e) => (
841                "error",
842                Some(crate::storage::metrics::classify_error(e).to_string()),
843            ),
844        };
845        let run = crate::storage::metrics::OperationRun {
846            id: gen_uuid(),
847            trace_id: trace_id.map(|s| s.to_string()),
848            op: op.to_string(),
849            source: source.map(|s| s.to_string()),
850            agent: crate::utils::agent_source(),
851            status: status.to_string(),
852            error_kind,
853            started_at,
854            duration_ms,
855            counts_json: None,
856            params_json: None,
857        };
858        let _ = self.storage.insert_operation_run(&run);
859        result
860    }
861
862    /// Embed content+trigger as one timed `embed` operation_run (full-path coverage:
863    /// add / spark / promote / distill / rebuild_embeddings). Returns the two Results
864    /// **unchanged** so each caller keeps its own embedding-failure fallback. The op row
865    /// is best-effort/non-fatal; when called inside an open transaction it simply joins
866    /// it (same connection, no lock conflict). `error_kind` uses the closed vocabulary.
867    pub(crate) fn embed_pair(
868        &self,
869        content: &str,
870        trigger: &str,
871        source: &str,
872    ) -> (Result<Vec<f32>>, Result<Vec<f32>>) {
873        let started = std::time::Instant::now();
874        let started_at = utc_now_iso();
875        let c = self.embedding.embed_content(content);
876        let t = self.embedding.embed_trigger(trigger);
877        let duration_ms = started.elapsed().as_millis() as i64;
878        let (status, error_kind) = match c.as_ref().err().or(t.as_ref().err()) {
879            None => ("ok", None),
880            Some(e) => (
881                "error",
882                Some(crate::storage::metrics::classify_error(e).to_string()),
883            ),
884        };
885        let run = crate::storage::metrics::OperationRun {
886            id: gen_uuid(),
887            trace_id: None,
888            op: "embed".to_string(),
889            source: Some(source.to_string()),
890            agent: crate::utils::agent_source(),
891            status: status.to_string(),
892            error_kind,
893            started_at,
894            duration_ms,
895            counts_json: None,
896            params_json: None,
897        };
898        let _ = self.storage.insert_operation_run(&run);
899        (c, t)
900    }
901}
902
903fn minutes_ago(now_iso: &str, minutes: i64) -> String {
904    use chrono::{DateTime, Duration, Utc};
905    if let Ok(t) = now_iso.parse::<DateTime<Utc>>() {
906        let cutoff = t - Duration::minutes(minutes);
907        return cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
908    }
909    now_iso.to_string()
910}
911
912fn minutes_after(now_iso: &str, minutes: i64) -> String {
913    use chrono::{DateTime, Duration, Utc};
914    if let Ok(t) = now_iso.parse::<DateTime<Utc>>() {
915        let cutoff = t + Duration::minutes(minutes);
916        return cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
917    }
918    now_iso.to_string()
919}
920
921fn hours_after(now_iso: &str, hours: i64) -> String {
922    use chrono::{DateTime, Duration, Utc};
923    if let Ok(t) = now_iso.parse::<DateTime<Utc>>() {
924        let cutoff = t + Duration::hours(hours);
925        return cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
926    }
927    now_iso.to_string()
928}
929
930/// Return the number of whole days between two ISO timestamps (now - past; clamped ≥ 0).
931fn iso_days_diff(now_iso: &str, past_iso: &str) -> i64 {
932    use chrono::{DateTime, Utc};
933    let parse = |s: &str| s.parse::<DateTime<Utc>>().ok();
934    if let (Some(a), Some(b)) = (parse(now_iso), parse(past_iso)) {
935        let diff = a - b;
936        diff.num_days().max(0)
937    } else {
938        0
939    }
940}
941
942/// Fractional days between two ISO timestamps (≥ 0). Finer than `iso_days_diff`
943/// so the activation recency term keeps sub-day resolution.
944fn iso_fractional_days(now_iso: &str, past_iso: &str) -> f64 {
945    use chrono::{DateTime, Utc};
946    let parse = |s: &str| s.parse::<DateTime<Utc>>().ok();
947    if let (Some(a), Some(b)) = (parse(now_iso), parse(past_iso)) {
948        ((a - b).num_seconds().max(0)) as f64 / 86_400.0
949    } else {
950        0.0
951    }
952}
953
954/// ACT-R decay exponent for the base-level activation recency term.
955const ACTR_DECAY: f64 = 0.5;
956
957/// ACT-R-inspired base-level activation, bounded to `(0, 1)`.
958///
959/// Fuses **frequency** (how often a chunk has been used) and **recency** (time
960/// since last use) into one re-ranking signal, following the standard ACT-R
961/// approximation `B = ln(n) − d·ln(t)` (Petrov 2006), here using
962/// `B = ln(1 + used_count) − d·ln(1 + recency_days)` and squashed with a
963/// logistic so it stays on the same `[0, 1]` scale as the other fused-score
964/// terms (content/trigger sim, confidence, context).
965///
966/// Returns `0.0` for never-used chunks (no usage history → no boost), which
967/// keeps recall **zero-regression** for freshly-added knowledge: a chunk with
968/// `used_count == 0` contributes nothing to the fused score.
969pub(super) fn actr_activation(used_count: i64, last_used_at: Option<&str>, now_iso: &str) -> f64 {
970    if used_count <= 0 {
971        return 0.0;
972    }
973    let Some(last) = last_used_at else {
974        return 0.0;
975    };
976    let recency_days = iso_fractional_days(now_iso, last);
977    let b = (1.0 + used_count as f64).ln() - ACTR_DECAY * (1.0 + recency_days).ln();
978    1.0 / (1.0 + (-b).exp())
979}
980
981/// DFS-based cycle detection on the hard-dep graph. Returns list of cycles (each is a Vec of ids).
982fn detect_cycles(deps: &[Value]) -> Vec<Vec<String>> {
983    use std::collections::HashMap;
984    let mut adj: HashMap<String, Vec<String>> = HashMap::new();
985    for d in deps {
986        let src = d
987            .get("src")
988            .and_then(Value::as_str)
989            .unwrap_or("")
990            .to_string();
991        let dst = d
992            .get("dst")
993            .and_then(Value::as_str)
994            .unwrap_or("")
995            .to_string();
996        if !src.is_empty() && !dst.is_empty() {
997            adj.entry(src).or_default().push(dst);
998        }
999    }
1000    let nodes: Vec<String> = adj.keys().cloned().collect();
1001    let mut visited: HashSet<String> = HashSet::new();
1002    let mut on_stack: HashSet<String> = HashSet::new();
1003    let mut cycles: Vec<Vec<String>> = vec![];
1004
1005    fn dfs(
1006        node: &str,
1007        adj: &HashMap<String, Vec<String>>,
1008        visited: &mut HashSet<String>,
1009        on_stack: &mut HashSet<String>,
1010        path: &mut Vec<String>,
1011        cycles: &mut Vec<Vec<String>>,
1012    ) {
1013        if on_stack.contains(node) {
1014            // Found cycle — extract loop segment.
1015            let start = path.iter().position(|n| n == node).unwrap_or(0);
1016            cycles.push(path[start..].to_vec());
1017            return;
1018        }
1019        if visited.contains(node) {
1020            return;
1021        }
1022        visited.insert(node.to_string());
1023        on_stack.insert(node.to_string());
1024        path.push(node.to_string());
1025        if let Some(children) = adj.get(node) {
1026            for child in children {
1027                dfs(child, adj, visited, on_stack, path, cycles);
1028            }
1029        }
1030        path.pop();
1031        on_stack.remove(node);
1032    }
1033
1034    for node in nodes {
1035        let mut path = vec![];
1036        dfs(
1037            &node,
1038            &adj,
1039            &mut visited,
1040            &mut on_stack,
1041            &mut path,
1042            &mut cycles,
1043        );
1044    }
1045    cycles
1046}