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 PROMOTE_USED_SUCCESS_MIN: i64 = 3;
86const PROMOTE_CONFIDENCE_MIN: f64 = 0.60;
87const DECAY_FLOOR: f64 = 0.20;
88const EVOLVE_THRESHOLD: i64 = 5;
89const DISTILL_BATCH_SIZE: usize = 20;
90const PENDING_RECALL_PENALTY: f64 = 0.60;
91
92const APPRAISE_TIER_WEAK: f64 = 0.30;
95const APPRAISE_TIER_STRONG: f64 = 0.65;
96const APPRAISE_MIN_STRENGTH: f64 = 0.40;
97const APPRAISE_TOP: usize = 8;
98const APPRAISE_TRIGGER_HIT_MIN: f64 = 0.50;
99const APPRAISE_CANDIDATE_IN_EMBED: bool = true;
100const APPRAISE_SIGNATURE_FLOOR: f64 = 0.0;
106const APPRAISE_MIN_EVIDENCE: i64 = 0;
107const APPRAISE_CONFLICT_CEILING: f64 = 1.0;
108const INTUITION_PRIOR_M: f64 = 2.0;
112const INTUITION_BASE_RATE: f64 = 0.5;
113const RECALL_PRIOR_M: f64 = 2.0;
116const RECALL_BASE_RATE: f64 = 0.5;
117const CALIBRATION_BINS: i64 = 10;
119const SITUATION_COARSE_KEYS: &str = "stage,error_class,file_type";
120const EMBED_SITUATION_SIGNATURE: bool = false;
126const GOVERNANCE_ARCHIVE_THRESHOLD: i64 = 3;
127const NEGATIVE_FEEDBACK_ARCHIVE_THRESHOLD: i64 = 5;
128const GOVERNANCE_EVOLVE_THRESHOLD: i64 = 3;
129const FAILURE_MIN_USES: i64 = 5;
130const FAILURE_MAX_SUCCESS_RATE: f64 = 0.20;
131const FAILURE_CONFIDENCE_MAX: f64 = 0.35;
132const LOG_COMPACT_DAYS: i64 = 30;
133
134#[derive(Debug, Default, Clone)]
139pub struct RecallResult {
140 pub knowledge: Vec<Value>,
141 pub sparks: Vec<Value>,
142 pub trace_id: String,
143 pub empty: bool,
144 pub depth_skipped: Vec<String>,
145 pub skipped_reasons: HashMap<String, String>,
146}
147
148#[derive(Debug, Default)]
149pub struct CurateReport {
150 pub archived: Vec<String>,
151 pub deduped: Vec<String>,
152 pub decayed: Vec<String>,
153 pub cycles: Vec<Vec<String>>,
154 pub orphans: Vec<String>,
155 pub recovered: Vec<String>,
156 pub warnings: Vec<String>,
157 pub stats: HashMap<String, Value>,
158}
159
160#[derive(Debug, Default)]
161struct DistillBatchReport {
162 distilled: usize,
163 failed: usize,
164}
165
166#[derive(Debug, Default, Clone)]
168pub struct CurateScope {
169 pub origin: Option<String>,
171 pub skill_name: Option<String>,
173 pub dry_run: bool,
175}
176
177pub trait Curator: Send + Sync {
180 fn run(&self, kb: &KnowledgeBase, scope: &CurateScope) -> Result<CurateReport>;
181}
182
183pub struct BuiltinCurator;
185
186impl Curator for BuiltinCurator {
187 fn run(&self, kb: &KnowledgeBase, scope: &CurateScope) -> Result<CurateReport> {
188 kb.builtin_curate_impl(scope)
189 }
190}
191
192pub struct KnowledgeBase {
197 pub storage: Storage,
198 embedding: Arc<dyn EmbeddingProvider>,
199 refiner: Arc<dyn Refiner>,
200 distiller: Arc<dyn Distiller>,
201 curator: Arc<dyn Curator>,
202 sanitizer: Arc<dyn Sanitizer>,
203 reranker: Arc<dyn Reranker>,
206
207 w_content: f64,
209 w_trigger: f64,
210 w_confidence: f64,
211 w_context: f64,
212 w_activation: f64,
213 w_lexical: f64,
214 w_spread: f64,
215 spread_fan_cap: i64,
216 spread_seed_n: usize,
217 top_k_candidates: usize,
218 anti_trigger_penalty: f64,
219 density_refill: bool,
220
221 low_conf_threshold: f64,
222 low_conf_idle_days: i64,
223 repeat_select_min: i64,
224 repeat_select_conf_max: f64,
225 never_used_age_days: i64,
226 open_ttl_days: i64,
227 screening_timeout_minutes: i64,
228 promote_used_success_min: i64,
229 promote_confidence_min: f64,
230 decay_floor: f64,
231 evolve_threshold: i64,
232 distill_batch_size: usize,
233 evolve_schedule_interval_hours: i64,
234 governance_archive_threshold: i64,
235 negative_feedback_archive_threshold: i64,
236 governance_evolve_threshold: i64,
237 governance_proposal_max_age_days: i64,
238 failure_min_uses: i64,
239 failure_max_success_rate: f64,
240 failure_confidence_max: f64,
241 log_compact_days: i64,
242
243 appraise_tier_weak: f64,
245 appraise_tier_strong: f64,
246 appraise_min_strength: f64,
247 appraise_top: usize,
248 appraise_trigger_hit_min: f64,
249 appraise_candidate_in_embed: bool,
250 appraise_signature_floor: f64,
251 appraise_min_evidence: i64,
252 appraise_conflict_ceiling: f64,
253 intuition_prior_m: f64,
254 intuition_base_rate: f64,
255 calibration_bins: i64,
256 situation_coarse_keys: String,
257 embed_situation_signature: bool,
258}
259
260impl KnowledgeBase {
261 pub fn open(db_path: impl AsRef<Path>) -> Result<Self> {
262 Self::open_with(db_path, None, None, None, None, None)
263 }
264
265 pub(crate) fn store_vec_content(&self, chunk_id: &str, cvec: &[f32]) -> Result<()> {
271 let want = self.embedding.content_dim();
272 if cvec.len() != want {
273 return Err(InnateError::InvalidState(format!(
274 "content embedding dim {} != configured {want} (chunk {chunk_id})",
275 cvec.len()
276 )));
277 }
278 self.storage
279 .insert_vec_content(chunk_id, &pack_embedding(cvec))
280 }
281
282 pub(crate) fn store_vec_trigger(&self, chunk_id: &str, tvec: &[f32]) -> Result<()> {
285 let want = self.embedding.trigger_dim();
286 if tvec.len() != want {
287 return Err(InnateError::InvalidState(format!(
288 "trigger embedding dim {} != configured {want} (chunk {chunk_id})",
289 tvec.len()
290 )));
291 }
292 self.storage
293 .insert_vec_trigger(chunk_id, &pack_embedding(tvec))
294 }
295
296 pub fn open_with(
297 db_path: impl AsRef<Path>,
298 embedding: Option<Arc<dyn EmbeddingProvider>>,
299 refiner: Option<Arc<dyn Refiner>>,
300 distiller: Option<Arc<dyn Distiller>>,
301 curator: Option<Arc<dyn Curator>>,
302 sanitizer: Option<Arc<dyn Sanitizer>>,
303 ) -> Result<Self> {
304 let embedding = embedding.unwrap_or_else(|| Arc::new(DummyEmbeddingProvider::default()));
305 let refiner = refiner.unwrap_or_else(|| Arc::new(NullRefiner));
306 let distiller = distiller.unwrap_or_else(|| Arc::new(HeuristicDistiller));
307 let curator = curator.unwrap_or_else(|| Arc::new(BuiltinCurator));
308 let sanitizer = sanitizer.unwrap_or_else(|| Arc::new(DefaultSanitizer));
309 let reranker: Arc<dyn Reranker> = Arc::new(NoopReranker);
310
311 let storage = Storage::open(db_path, embedding.content_dim(), embedding.trigger_dim())?;
312
313 let mut kb = Self {
314 storage,
315 embedding,
316 refiner,
317 distiller,
318 curator,
319 sanitizer,
320 reranker,
321 w_lexical: W_LEXICAL,
322 w_spread: W_SPREAD,
323 spread_fan_cap: SPREAD_FAN_CAP,
324 spread_seed_n: SPREAD_SEED_N,
325 embed_situation_signature: EMBED_SITUATION_SIGNATURE,
326 w_content: W_CONTENT,
327 w_trigger: W_TRIGGER,
328 w_confidence: W_CONFIDENCE,
329 w_context: W_CONTEXT,
330 w_activation: W_ACTIVATION,
331 top_k_candidates: TOP_K_CANDIDATES,
332 anti_trigger_penalty: ANTI_TRIGGER_PENALTY,
333 density_refill: DENSITY_REFILL,
334 low_conf_threshold: LOW_CONF_THRESHOLD,
335 low_conf_idle_days: LOW_CONF_IDLE_DAYS,
336 repeat_select_min: REPEAT_SELECT_MIN,
337 repeat_select_conf_max: REPEAT_SELECT_CONF_MAX,
338 never_used_age_days: NEVER_USED_AGE_DAYS,
339 open_ttl_days: OPEN_TTL_DAYS,
340 screening_timeout_minutes: SCREENING_TIMEOUT_MINUTES,
341 promote_used_success_min: PROMOTE_USED_SUCCESS_MIN,
342 promote_confidence_min: PROMOTE_CONFIDENCE_MIN,
343 decay_floor: DECAY_FLOOR,
344 evolve_threshold: EVOLVE_THRESHOLD,
345 distill_batch_size: DISTILL_BATCH_SIZE,
346 evolve_schedule_interval_hours: 6,
347 governance_archive_threshold: GOVERNANCE_ARCHIVE_THRESHOLD,
348 negative_feedback_archive_threshold: NEGATIVE_FEEDBACK_ARCHIVE_THRESHOLD,
349 governance_evolve_threshold: GOVERNANCE_EVOLVE_THRESHOLD,
350 governance_proposal_max_age_days: 30,
351 failure_min_uses: FAILURE_MIN_USES,
352 failure_max_success_rate: FAILURE_MAX_SUCCESS_RATE,
353 failure_confidence_max: FAILURE_CONFIDENCE_MAX,
354 log_compact_days: LOG_COMPACT_DAYS,
355 appraise_tier_weak: APPRAISE_TIER_WEAK,
356 appraise_tier_strong: APPRAISE_TIER_STRONG,
357 appraise_min_strength: APPRAISE_MIN_STRENGTH,
358 appraise_top: APPRAISE_TOP,
359 appraise_trigger_hit_min: APPRAISE_TRIGGER_HIT_MIN,
360 appraise_candidate_in_embed: APPRAISE_CANDIDATE_IN_EMBED,
361 appraise_signature_floor: APPRAISE_SIGNATURE_FLOOR,
362 appraise_min_evidence: APPRAISE_MIN_EVIDENCE,
363 appraise_conflict_ceiling: APPRAISE_CONFLICT_CEILING,
364 intuition_prior_m: INTUITION_PRIOR_M,
365 intuition_base_rate: INTUITION_BASE_RATE,
366 calibration_bins: CALIBRATION_BINS,
367 situation_coarse_keys: SITUATION_COARSE_KEYS.to_string(),
368 };
369 kb.init_meta()?;
370 kb.load_params()?;
371 Ok(kb)
372 }
373
374 pub fn with_reranker(mut self, reranker: Arc<dyn Reranker>) -> Self {
378 self.reranker = reranker;
379 self
380 }
381
382 fn init_meta(&self) -> Result<()> {
383 let lib_id = gen_uuid();
384 let content_dim = self.embedding.content_dim().to_string();
385 let trigger_dim = self.embedding.trigger_dim().to_string();
386 let embed_model = self.embedding.model_name();
387
388 for (key, expected) in [
389 ("content_dim", self.embedding.content_dim()),
390 ("trigger_dim", self.embedding.trigger_dim()),
391 ] {
392 if let Some(stored) = self.storage.get_meta(key)? {
393 let actual = stored.parse::<usize>().map_err(|_| {
394 InnateError::Other(format!("invalid {key} metadata value: {stored}"))
395 })?;
396 if actual != expected {
397 return Err(InnateError::Other(format!(
398 "{key} mismatch: database uses {actual}, embedding provider uses {expected}"
399 )));
400 }
401 }
402 }
403
404 let defaults: &[(&str, &str)] = &[
405 ("lib_id", &lib_id),
406 ("lib_role", "personal"),
407 ("schema_version", "4.14"),
408 ("content_dim", &content_dim),
409 ("trigger_dim", &trigger_dim),
410 ("embed_model", embed_model),
411 ("embed_version", "1"),
412 ("vector_revision", "0"),
413 ("last_agg_ts", "1970-01-01T00:00:00.000Z"),
414 ("recall.w_content", "0.55"),
415 ("recall.w_trigger", "0.25"),
416 ("recall.w_confidence", "0.10"),
417 ("recall.w_context", "0.15"),
418 ("recall.w_activation", "0.08"),
419 ("recall.w_lexical", "0.25"),
420 ("recall.w_spread", "0.0"),
421 ("recall.spread_fan_cap", "50"),
422 ("recall.spread_seed_n", "5"),
423 ("recall.embed_situation_signature", "false"),
424 ("recall.top_k_candidates", "20"),
425 ("recall.anti_trigger_penalty", "0.6"),
426 ("recall.density_refill", "true"),
427 ("curate.low_conf_threshold", "0.25"),
428 ("curate.low_conf_idle_days", "60"),
429 ("curate.repeat_select_min", "10"),
430 ("curate.repeat_select_conf_max", "0.5"),
431 ("curate.never_used_age_days", "30"),
432 ("curate.open_ttl_days", "14"),
433 ("curate.screening_timeout_minutes", "30"),
434 ("curate.promote_used_success_min", "3"),
435 ("curate.promote_confidence_min", "0.60"),
436 ("curate.decay_floor", "0.20"),
437 ("evolve.threshold_new_count", "5"),
438 ("evolve.distill_batch_size", "20"),
439 ("evolve.schedule_interval_hours", "6"),
440 ("curate.soft_mature_threshold", "5"),
441 ("evolve.distill_token_window_hours", "24"),
442 ("curate.governance_archive_threshold", "3"),
443 ("curate.negative_feedback_archive_threshold", "5"),
444 ("evolve.governance_pending_threshold", "3"),
445 ("curate.governance_proposal_max_age_days", "30"),
446 ("curate.failure_min_uses", "5"),
447 ("curate.failure_max_success_rate", "0.20"),
448 ("curate.failure_confidence_max", "0.35"),
449 ("curate.log_compact_days", "30"),
450 ("appraise.tier_weak", "0.30"),
451 ("appraise.tier_strong", "0.65"),
452 ("appraise.min_strength", "0.40"),
453 ("appraise.top", "8"),
454 ("appraise.trigger_hit_min", "0.50"),
455 ("appraise.candidate_in_embed", "true"),
456 ("appraise.signature_floor", "0.0"),
457 ("appraise.min_evidence", "0"),
458 ("appraise.conflict_ceiling", "1.0"),
459 ("intuition.prior_m", "2.0"),
460 ("intuition.base_rate", "0.5"),
461 ("intuition.calibration_bins", "10"),
462 ("situation.coarse_keys", "stage,error_class,file_type"),
463 ];
464 self.storage.begin_immediate()?;
465 let result = (|| -> Result<()> {
466 for (k, v) in defaults {
467 if self.storage.get_meta(k)?.is_none() {
468 self.storage.set_meta(k, v)?;
469 }
470 }
471 self.storage.commit()
472 })();
473 if result.is_err() {
474 let _ = self.storage.rollback();
475 }
476 result
477 }
478
479 fn load_params(&mut self) -> Result<()> {
480 let f = |k: &str, d: f64| -> f64 {
481 self.storage
482 .get_meta(k)
483 .ok()
484 .flatten()
485 .and_then(|v| v.parse().ok())
486 .unwrap_or(d)
487 };
488 let i = |k: &str, d: i64| -> i64 {
489 self.storage
490 .get_meta(k)
491 .ok()
492 .flatten()
493 .and_then(|v| v.parse().ok())
494 .unwrap_or(d)
495 };
496 let b = |k: &str, d: bool| -> bool {
497 self.storage
498 .get_meta(k)
499 .ok()
500 .flatten()
501 .map(|v| v.to_lowercase() == "true")
502 .unwrap_or(d)
503 };
504 self.w_content = f("recall.w_content", W_CONTENT);
505 self.w_trigger = f("recall.w_trigger", W_TRIGGER);
506 self.w_confidence = f("recall.w_confidence", W_CONFIDENCE);
507 self.w_context = f("recall.w_context", W_CONTEXT);
508 self.w_lexical = f("recall.w_lexical", W_LEXICAL);
509 self.w_spread = f("recall.w_spread", W_SPREAD);
510 self.spread_fan_cap = i("recall.spread_fan_cap", SPREAD_FAN_CAP).max(1);
511 self.spread_seed_n = i("recall.spread_seed_n", SPREAD_SEED_N as i64).max(0) as usize;
512 self.embed_situation_signature =
513 b("recall.embed_situation_signature", EMBED_SITUATION_SIGNATURE);
514 self.w_activation = f("recall.w_activation", W_ACTIVATION);
515 self.top_k_candidates =
516 i("recall.top_k_candidates", TOP_K_CANDIDATES as i64).max(1) as usize;
517 self.anti_trigger_penalty = f("recall.anti_trigger_penalty", ANTI_TRIGGER_PENALTY);
518 self.density_refill = b("recall.density_refill", DENSITY_REFILL);
519 self.low_conf_threshold = f("curate.low_conf_threshold", LOW_CONF_THRESHOLD);
520 self.low_conf_idle_days = i("curate.low_conf_idle_days", LOW_CONF_IDLE_DAYS);
521 self.repeat_select_min = i("curate.repeat_select_min", REPEAT_SELECT_MIN);
522 self.repeat_select_conf_max = f("curate.repeat_select_conf_max", REPEAT_SELECT_CONF_MAX);
523 self.never_used_age_days = i("curate.never_used_age_days", NEVER_USED_AGE_DAYS);
524 self.open_ttl_days = i("curate.open_ttl_days", OPEN_TTL_DAYS);
525 self.screening_timeout_minutes = i(
526 "curate.screening_timeout_minutes",
527 SCREENING_TIMEOUT_MINUTES,
528 );
529 self.promote_used_success_min =
530 i("curate.promote_used_success_min", PROMOTE_USED_SUCCESS_MIN);
531 self.promote_confidence_min = f("curate.promote_confidence_min", PROMOTE_CONFIDENCE_MIN);
532 self.decay_floor = f("curate.decay_floor", DECAY_FLOOR).clamp(0.0, 0.4);
533 self.evolve_threshold = i("evolve.threshold_new_count", EVOLVE_THRESHOLD);
534 self.distill_batch_size =
535 i("evolve.distill_batch_size", DISTILL_BATCH_SIZE as i64) as usize;
536 self.evolve_schedule_interval_hours = i("evolve.schedule_interval_hours", 6).max(1);
537 self.governance_archive_threshold = i(
538 "curate.governance_archive_threshold",
539 GOVERNANCE_ARCHIVE_THRESHOLD,
540 )
541 .max(1);
542 self.negative_feedback_archive_threshold = i(
543 "curate.negative_feedback_archive_threshold",
544 NEGATIVE_FEEDBACK_ARCHIVE_THRESHOLD,
545 )
546 .max(1);
547 self.governance_evolve_threshold = i(
548 "evolve.governance_pending_threshold",
549 GOVERNANCE_EVOLVE_THRESHOLD,
550 )
551 .max(1);
552 self.governance_proposal_max_age_days =
553 i("curate.governance_proposal_max_age_days", 30).max(1);
554 self.failure_min_uses = i("curate.failure_min_uses", FAILURE_MIN_USES).max(1);
555 self.failure_max_success_rate =
556 f("curate.failure_max_success_rate", FAILURE_MAX_SUCCESS_RATE).clamp(0.0, 1.0);
557 self.failure_confidence_max =
558 f("curate.failure_confidence_max", FAILURE_CONFIDENCE_MAX).clamp(0.0, 1.0);
559 self.log_compact_days = i("curate.log_compact_days", LOG_COMPACT_DAYS).max(1);
560 let s = |k: &str, d: &str| -> String {
561 self.storage
562 .get_meta(k)
563 .ok()
564 .flatten()
565 .filter(|v| !v.trim().is_empty())
566 .unwrap_or_else(|| d.to_string())
567 };
568 self.appraise_tier_weak = f("appraise.tier_weak", APPRAISE_TIER_WEAK).clamp(0.0, 1.0);
569 self.appraise_tier_strong = f("appraise.tier_strong", APPRAISE_TIER_STRONG).clamp(0.0, 1.0);
570 self.appraise_min_strength =
571 f("appraise.min_strength", APPRAISE_MIN_STRENGTH).clamp(0.0, 1.0);
572 self.appraise_top = i("appraise.top", APPRAISE_TOP as i64).max(1) as usize;
573 self.appraise_trigger_hit_min =
574 f("appraise.trigger_hit_min", APPRAISE_TRIGGER_HIT_MIN).clamp(0.0, 1.0);
575 self.appraise_candidate_in_embed =
576 b("appraise.candidate_in_embed", APPRAISE_CANDIDATE_IN_EMBED);
577 self.appraise_signature_floor =
578 f("appraise.signature_floor", APPRAISE_SIGNATURE_FLOOR).clamp(0.0, 1.0);
579 self.appraise_min_evidence = i("appraise.min_evidence", APPRAISE_MIN_EVIDENCE).max(0);
580 self.appraise_conflict_ceiling =
581 f("appraise.conflict_ceiling", APPRAISE_CONFLICT_CEILING).clamp(0.0, 1.0);
582 self.intuition_prior_m = f("intuition.prior_m", INTUITION_PRIOR_M).max(0.0);
583 self.intuition_base_rate = f("intuition.base_rate", INTUITION_BASE_RATE).clamp(0.0, 1.0);
584 self.calibration_bins = i("intuition.calibration_bins", CALIBRATION_BINS).clamp(2, 100);
585 self.situation_coarse_keys = s("situation.coarse_keys", SITUATION_COARSE_KEYS);
586 Ok(())
587 }
588}
589
590struct CandidateInfo {
595 chunk: Value,
596 sim_content: f32,
597 sim_trigger: f32,
598 sim_lexical: f32,
601 sim_spread: f32,
606}
607
608fn signature_has_signal(sig: &str) -> bool {
612 sig.split('|').any(|p| {
613 p.split_once('=')
614 .map(|(_, v)| !v.is_empty() && v != "none" && v != "unknown")
615 .unwrap_or(false)
616 })
617}
618
619fn new_candidate(chunk: &Value) -> CandidateInfo {
622 CandidateInfo {
623 chunk: chunk.clone(),
624 sim_content: 0.0,
625 sim_trigger: 0.0,
626 sim_lexical: 0.0,
627 sim_spread: 0.0,
628 }
629}
630
631fn chunk_is_valid_for_recall(chunk: &Value, embed_version: i64) -> bool {
632 chunk.get("state").and_then(Value::as_str) != Some("archived")
633 && chunk.get("origin").and_then(Value::as_str) != Some("spark")
634 && chunk
635 .get("embed_version")
636 .and_then(Value::as_i64)
637 .unwrap_or(1)
638 >= embed_version
639}
640
641fn normalize_query(query: &str) -> String {
650 const STOP_WORDS: &[&str] = &[
651 "a", "an", "and", "for", "in", "of", "on", "the", "to", "with",
652 ];
653 let cleaned: String = query
654 .to_lowercase()
655 .chars()
656 .map(|ch| {
657 if ch.is_alphanumeric() || ch.is_whitespace() {
658 ch
659 } else {
660 ' '
661 }
662 })
663 .collect();
664 let mut tokens: Vec<&str> = cleaned
665 .split_whitespace()
666 .filter(|token| !STOP_WORDS.contains(token))
667 .collect();
668 tokens.sort_unstable();
669 tokens.dedup();
670 tokens.join(" ")
671}
672
673fn estimate_distill_prompt_tokens(log: &Value, related_logs: &[Value]) -> i64 {
674 let primary: i64 = [
675 "query",
676 "recall_snapshot",
677 "output",
678 "output_summary",
679 "nomination",
680 ]
681 .iter()
682 .filter_map(|key| log.get(*key).and_then(Value::as_str))
683 .map(|text| estimate_tokens(text) as i64)
684 .sum();
685 let log_id = log.get("id").and_then(Value::as_str).unwrap_or("");
686 let context_key = log.get("context_key").and_then(Value::as_str);
687 let related: i64 = related_logs
688 .iter()
689 .filter(|other| other.get("id").and_then(Value::as_str).unwrap_or("") != log_id)
690 .filter(|other| {
691 context_key.is_some() && other.get("context_key").and_then(Value::as_str) == context_key
692 })
693 .take(4)
694 .flat_map(|other| {
695 ["query", "output_summary", "outcome"]
696 .into_iter()
697 .filter_map(|key| other.get(key).and_then(Value::as_str))
698 })
699 .map(|text| estimate_tokens(text) as i64)
700 .sum();
701 primary + related
702}
703
704fn estimate_distilled_chunk_tokens(chunk: &DistilledChunk) -> i64 {
705 estimate_tokens(&chunk.content) as i64
706 + chunk
707 .trigger_desc
708 .as_deref()
709 .map(estimate_tokens)
710 .unwrap_or(0) as i64
711 + chunk
712 .anti_trigger_desc
713 .as_deref()
714 .map(estimate_tokens)
715 .unwrap_or(0) as i64
716}
717
718fn anti_trigger_hit(query: &str, anti: &str) -> bool {
719 let q_lower = query.to_lowercase();
720 anti.to_lowercase().split(',').any(|part| {
721 let p = part.trim();
722 !p.is_empty() && q_lower.contains(p)
723 })
724}
725
726fn block_cost(block: &[Value]) -> usize {
727 block
728 .iter()
729 .map(|b| {
730 b.get("token_count")
731 .and_then(Value::as_u64)
732 .map(|t| t as usize)
733 .unwrap_or_else(|| {
734 estimate_tokens(b.get("content").and_then(Value::as_str).unwrap_or("")).max(100)
735 })
736 })
737 .sum()
738}
739
740fn limit_knowledge(knowledge: Vec<Value>, top: Option<usize>) -> Vec<Value> {
741 match top {
742 None => knowledge,
743 Some(0) => vec![],
744 Some(n) => knowledge.into_iter().take(n).collect(),
745 }
746}
747
748fn usage_state(used: Option<&[String]>) -> &'static str {
749 match used {
750 None => "unknown",
751 Some([]) => "known_none",
752 Some(_) => "known_some",
753 }
754}
755
756fn ratio(numerator: i64, denominator: i64) -> f64 {
757 if denominator <= 0 {
758 0.0
759 } else {
760 ((numerator as f64 / denominator as f64) * 1000.0).round() / 1000.0
761 }
762}
763
764fn validate_source(source: &str) -> Result<()> {
765 if !matches!(
766 source,
767 "mcp" | "sdk" | "cli" | "hook" | "daemon" | "augmented"
768 ) {
769 return Err(InnateError::InvalidState(format!(
770 "invalid event source: {source}"
771 )));
772 }
773 Ok(())
774}
775
776fn count_query(storage: &Storage, sql: &str) -> Result<i64> {
777 Ok(storage
778 .query_chunks(sql)?
779 .first()
780 .and_then(|r| r.as_object())
781 .and_then(|m| m.values().next())
782 .and_then(Value::as_i64)
783 .unwrap_or(0))
784}
785
786fn count_query_params<P: rusqlite::Params>(storage: &Storage, sql: &str, p: P) -> Result<i64> {
787 Ok(storage
788 .query_chunks_params(sql, p)?
789 .first()
790 .and_then(|r| r.as_object())
791 .and_then(|m| m.values().next())
792 .and_then(Value::as_i64)
793 .unwrap_or(0))
794}
795
796fn days_ago(now_iso: &str, days: i64) -> String {
797 use chrono::{DateTime, Duration, Utc};
798 if let Ok(t) = now_iso.parse::<DateTime<Utc>>() {
799 let cutoff = t - Duration::days(days);
800 return cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
801 }
802 now_iso.to_string()
803}
804
805fn minutes_ago(now_iso: &str, minutes: i64) -> String {
806 use chrono::{DateTime, Duration, Utc};
807 if let Ok(t) = now_iso.parse::<DateTime<Utc>>() {
808 let cutoff = t - Duration::minutes(minutes);
809 return cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
810 }
811 now_iso.to_string()
812}
813
814fn hours_ago(now_iso: &str, hours: i64) -> String {
815 use chrono::{DateTime, Duration, Utc};
816 if let Ok(t) = now_iso.parse::<DateTime<Utc>>() {
817 let cutoff = t - Duration::hours(hours);
818 return cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
819 }
820 now_iso.to_string()
821}
822
823fn minutes_after(now_iso: &str, minutes: i64) -> String {
824 use chrono::{DateTime, Duration, Utc};
825 if let Ok(t) = now_iso.parse::<DateTime<Utc>>() {
826 let cutoff = t + Duration::minutes(minutes);
827 return cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
828 }
829 now_iso.to_string()
830}
831
832fn hours_after(now_iso: &str, hours: i64) -> String {
833 use chrono::{DateTime, Duration, Utc};
834 if let Ok(t) = now_iso.parse::<DateTime<Utc>>() {
835 let cutoff = t + Duration::hours(hours);
836 return cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
837 }
838 now_iso.to_string()
839}
840
841fn iso_days_diff(now_iso: &str, past_iso: &str) -> i64 {
843 use chrono::{DateTime, Utc};
844 let parse = |s: &str| s.parse::<DateTime<Utc>>().ok();
845 if let (Some(a), Some(b)) = (parse(now_iso), parse(past_iso)) {
846 let diff = a - b;
847 diff.num_days().max(0)
848 } else {
849 0
850 }
851}
852
853fn iso_fractional_days(now_iso: &str, past_iso: &str) -> f64 {
856 use chrono::{DateTime, Utc};
857 let parse = |s: &str| s.parse::<DateTime<Utc>>().ok();
858 if let (Some(a), Some(b)) = (parse(now_iso), parse(past_iso)) {
859 ((a - b).num_seconds().max(0)) as f64 / 86_400.0
860 } else {
861 0.0
862 }
863}
864
865const ACTR_DECAY: f64 = 0.5;
867
868pub(super) fn actr_activation(used_count: i64, last_used_at: Option<&str>, now_iso: &str) -> f64 {
881 if used_count <= 0 {
882 return 0.0;
883 }
884 let Some(last) = last_used_at else {
885 return 0.0;
886 };
887 let recency_days = iso_fractional_days(now_iso, last);
888 let b = (1.0 + used_count as f64).ln() - ACTR_DECAY * (1.0 + recency_days).ln();
889 1.0 / (1.0 + (-b).exp())
890}
891
892fn detect_cycles(deps: &[Value]) -> Vec<Vec<String>> {
894 use std::collections::HashMap;
895 let mut adj: HashMap<String, Vec<String>> = HashMap::new();
896 for d in deps {
897 let src = d
898 .get("src")
899 .and_then(Value::as_str)
900 .unwrap_or("")
901 .to_string();
902 let dst = d
903 .get("dst")
904 .and_then(Value::as_str)
905 .unwrap_or("")
906 .to_string();
907 if !src.is_empty() && !dst.is_empty() {
908 adj.entry(src).or_default().push(dst);
909 }
910 }
911 let nodes: Vec<String> = adj.keys().cloned().collect();
912 let mut visited: HashSet<String> = HashSet::new();
913 let mut on_stack: HashSet<String> = HashSet::new();
914 let mut cycles: Vec<Vec<String>> = vec![];
915
916 fn dfs(
917 node: &str,
918 adj: &HashMap<String, Vec<String>>,
919 visited: &mut HashSet<String>,
920 on_stack: &mut HashSet<String>,
921 path: &mut Vec<String>,
922 cycles: &mut Vec<Vec<String>>,
923 ) {
924 if on_stack.contains(node) {
925 let start = path.iter().position(|n| n == node).unwrap_or(0);
927 cycles.push(path[start..].to_vec());
928 return;
929 }
930 if visited.contains(node) {
931 return;
932 }
933 visited.insert(node.to_string());
934 on_stack.insert(node.to_string());
935 path.push(node.to_string());
936 if let Some(children) = adj.get(node) {
937 for child in children {
938 dfs(child, adj, visited, on_stack, path, cycles);
939 }
940 }
941 path.pop();
942 on_stack.remove(node);
943 }
944
945 for node in nodes {
946 let mut path = vec![];
947 dfs(
948 &node,
949 &adj,
950 &mut visited,
951 &mut on_stack,
952 &mut path,
953 &mut cycles,
954 );
955 }
956 cycles
957}