1type 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
47const 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;
60const W_LEXICAL: f64 = 0.25;
63const W_SPREAD: f64 = 0.0;
68const SPREAD_FAN_CAP: i64 = 50;
72const 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
93const 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;
101const APPRAISE_SIGNATURE_FLOOR: f64 = 0.0;
107const APPRAISE_MIN_EVIDENCE: i64 = 0;
108const APPRAISE_CONFLICT_CEILING: f64 = 1.0;
109const INTUITION_PRIOR_M: f64 = 2.0;
113const INTUITION_BASE_RATE: f64 = 0.5;
114const RECALL_PRIOR_M: f64 = 2.0;
117const RECALL_BASE_RATE: f64 = 0.5;
118const CALIBRATION_BINS: i64 = 10;
120const SITUATION_COARSE_KEYS: &str = "stage,error_class,file_type";
121const 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#[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#[derive(Debug, Default, Clone)]
170pub struct CurateScope {
171 pub origin: Option<String>,
173 pub skill_name: Option<String>,
175 pub dry_run: bool,
177}
178
179pub trait Curator: Send + Sync {
182 fn run(&self, kb: &KnowledgeBase, scope: &CurateScope) -> Result<CurateReport>;
183}
184
185pub 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
194pub 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 reranker: Arc<dyn Reranker>,
208
209 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 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 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 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 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
596struct CandidateInfo {
601 chunk: Value,
602 sim_content: f32,
603 sim_trigger: f32,
604 sim_lexical: f32,
607 sim_spread: f32,
612}
613
614fn 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
625fn 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
647fn 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 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 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
930fn 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
942fn 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
954const ACTR_DECAY: f64 = 0.5;
956
957pub(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
981fn 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 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}