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, NullRefiner, Refiner,
20    Sanitizer,
21};
22use crate::storage::{ChunkRow, EpisodicLogRow, Storage};
23use crate::utils::{
24    content_hash, estimate_tokens, gen_uuid, pack_embedding, utc_now_iso, SanitizeAction,
25};
26
27mod curate;
28mod evolve;
29mod inspection;
30mod lifecycle;
31mod recall;
32mod record;
33
34pub use recall::RecallParams;
35pub use record::RecordParams;
36
37// ---------------------------------------------------------------------------
38// Tuning defaults
39// ---------------------------------------------------------------------------
40
41// Fused recall score weights. These intentionally sum to 1.05 (not 1.0): the
42// score is a relative ranking signal, not a calibrated probability, so the extra
43// 0.05 of headroom on content similarity is deliberate and the result is never
44// re-normalised. Keep this in mind before "fixing" the sum.
45const W_CONTENT: f64 = 0.55;
46const W_TRIGGER: f64 = 0.25;
47const W_CONFIDENCE: f64 = 0.10;
48const W_CONTEXT: f64 = 0.15;
49const TOP_K_CANDIDATES: usize = 20;
50const ANTI_TRIGGER_PENALTY: f64 = 0.6;
51const DENSITY_REFILL: bool = true;
52
53const LOW_CONF_THRESHOLD: f64 = 0.25;
54const LOW_CONF_IDLE_DAYS: i64 = 60;
55const REPEAT_SELECT_MIN: i64 = 10;
56const REPEAT_SELECT_CONF_MAX: f64 = 0.5;
57const NEVER_USED_AGE_DAYS: i64 = 30;
58const OPEN_TTL_DAYS: i64 = 14;
59const SCREENING_TIMEOUT_MINUTES: i64 = 30;
60const PROMOTE_USED_SUCCESS_MIN: i64 = 3;
61const PROMOTE_CONFIDENCE_MIN: f64 = 0.60;
62const DECAY_FLOOR: f64 = 0.20;
63const EVOLVE_THRESHOLD: i64 = 5;
64const DISTILL_BATCH_SIZE: usize = 20;
65const PENDING_RECALL_PENALTY: f64 = 0.60;
66const GOVERNANCE_ARCHIVE_THRESHOLD: i64 = 3;
67const NEGATIVE_FEEDBACK_ARCHIVE_THRESHOLD: i64 = 5;
68const GOVERNANCE_EVOLVE_THRESHOLD: i64 = 3;
69const FAILURE_MIN_USES: i64 = 5;
70const FAILURE_MAX_SUCCESS_RATE: f64 = 0.20;
71const FAILURE_CONFIDENCE_MAX: f64 = 0.35;
72const LOG_COMPACT_DAYS: i64 = 30;
73
74// ---------------------------------------------------------------------------
75// Public result types
76// ---------------------------------------------------------------------------
77
78#[derive(Debug, Default, Clone)]
79pub struct RecallResult {
80    pub knowledge: Vec<Value>,
81    pub sparks: Vec<Value>,
82    pub trace_id: String,
83    pub empty: bool,
84    pub depth_skipped: Vec<String>,
85    pub skipped_reasons: HashMap<String, String>,
86}
87
88#[derive(Debug, Default)]
89pub struct CurateReport {
90    pub archived: Vec<String>,
91    pub deduped: Vec<String>,
92    pub decayed: Vec<String>,
93    pub cycles: Vec<Vec<String>>,
94    pub orphans: Vec<String>,
95    pub recovered: Vec<String>,
96    pub warnings: Vec<String>,
97    pub stats: HashMap<String, Value>,
98}
99
100#[derive(Debug, Default)]
101struct DistillBatchReport {
102    distilled: usize,
103    failed: usize,
104}
105
106/// Scope for a single Curate run — allows limiting governance to a subset of chunks.
107#[derive(Debug, Default, Clone)]
108pub struct CurateScope {
109    /// If set, only process chunks with this origin (e.g. "distilled").
110    pub origin: Option<String>,
111    /// If set, only process chunks belonging to this skill.
112    pub skill_name: Option<String>,
113    /// When true, compute the report but do not write any changes.
114    pub dry_run: bool,
115}
116
117/// Replaceable governance interface (§二·六). Inject via `KnowledgeBase::open_with`.
118/// Default implementation: `BuiltinCurator`.
119pub trait Curator: Send + Sync {
120    fn run(&self, kb: &KnowledgeBase, scope: &CurateScope) -> Result<CurateReport>;
121}
122
123/// Built-in curator — implements the full §四 governance pipeline.
124pub struct BuiltinCurator;
125
126impl Curator for BuiltinCurator {
127    fn run(&self, kb: &KnowledgeBase, scope: &CurateScope) -> Result<CurateReport> {
128        kb.builtin_curate_impl(scope)
129    }
130}
131
132// ---------------------------------------------------------------------------
133// KnowledgeBase
134// ---------------------------------------------------------------------------
135
136pub struct KnowledgeBase {
137    pub storage: Storage,
138    embedding: Arc<dyn EmbeddingProvider>,
139    refiner: Arc<dyn Refiner>,
140    distiller: Arc<dyn Distiller>,
141    curator: Arc<dyn Curator>,
142    sanitizer: Arc<dyn Sanitizer>,
143
144    // Tuning params (loaded from meta at init)
145    w_content: f64,
146    w_trigger: f64,
147    w_confidence: f64,
148    w_context: f64,
149    top_k_candidates: usize,
150    anti_trigger_penalty: f64,
151    density_refill: bool,
152
153    low_conf_threshold: f64,
154    low_conf_idle_days: i64,
155    repeat_select_min: i64,
156    repeat_select_conf_max: f64,
157    never_used_age_days: i64,
158    open_ttl_days: i64,
159    screening_timeout_minutes: i64,
160    promote_used_success_min: i64,
161    promote_confidence_min: f64,
162    decay_floor: f64,
163    evolve_threshold: i64,
164    distill_batch_size: usize,
165    evolve_schedule_interval_hours: i64,
166    governance_archive_threshold: i64,
167    negative_feedback_archive_threshold: i64,
168    governance_evolve_threshold: i64,
169    governance_proposal_max_age_days: i64,
170    failure_min_uses: i64,
171    failure_max_success_rate: f64,
172    failure_confidence_max: f64,
173    log_compact_days: i64,
174}
175
176impl KnowledgeBase {
177    pub fn open(db_path: impl AsRef<Path>) -> Result<Self> {
178        Self::open_with(db_path, None, None, None, None, None)
179    }
180
181    /// Persist a content embedding, rejecting any vector whose dimension differs
182    /// from the configured provider. A mismatched vector is silently skipped at
183    /// search time (cosine search only scores equal-dimension vectors), so an
184    /// unchecked write becomes invisible recall loss with no error. Fail closed
185    /// at the write boundary instead. All vector writers route through here.
186    pub(crate) fn store_vec_content(&self, chunk_id: &str, cvec: &[f32]) -> Result<()> {
187        let want = self.embedding.content_dim();
188        if cvec.len() != want {
189            return Err(InnateError::InvalidState(format!(
190                "content embedding dim {} != configured {want} (chunk {chunk_id})",
191                cvec.len()
192            )));
193        }
194        self.storage
195            .insert_vec_content(chunk_id, &pack_embedding(cvec))
196    }
197
198    /// Trigger-vector counterpart of [`store_vec_content`]; same fail-closed
199    /// dimension guard against `trigger_dim()`.
200    pub(crate) fn store_vec_trigger(&self, chunk_id: &str, tvec: &[f32]) -> Result<()> {
201        let want = self.embedding.trigger_dim();
202        if tvec.len() != want {
203            return Err(InnateError::InvalidState(format!(
204                "trigger embedding dim {} != configured {want} (chunk {chunk_id})",
205                tvec.len()
206            )));
207        }
208        self.storage
209            .insert_vec_trigger(chunk_id, &pack_embedding(tvec))
210    }
211
212    pub fn open_with(
213        db_path: impl AsRef<Path>,
214        embedding: Option<Arc<dyn EmbeddingProvider>>,
215        refiner: Option<Arc<dyn Refiner>>,
216        distiller: Option<Arc<dyn Distiller>>,
217        curator: Option<Arc<dyn Curator>>,
218        sanitizer: Option<Arc<dyn Sanitizer>>,
219    ) -> Result<Self> {
220        let embedding = embedding.unwrap_or_else(|| Arc::new(DummyEmbeddingProvider::default()));
221        let refiner = refiner.unwrap_or_else(|| Arc::new(NullRefiner));
222        let distiller = distiller.unwrap_or_else(|| Arc::new(HeuristicDistiller));
223        let curator = curator.unwrap_or_else(|| Arc::new(BuiltinCurator));
224        let sanitizer = sanitizer.unwrap_or_else(|| Arc::new(DefaultSanitizer));
225
226        let storage = Storage::open(db_path, embedding.content_dim(), embedding.trigger_dim())?;
227
228        let mut kb = Self {
229            storage,
230            embedding,
231            refiner,
232            distiller,
233            curator,
234            sanitizer,
235            w_content: W_CONTENT,
236            w_trigger: W_TRIGGER,
237            w_confidence: W_CONFIDENCE,
238            w_context: W_CONTEXT,
239            top_k_candidates: TOP_K_CANDIDATES,
240            anti_trigger_penalty: ANTI_TRIGGER_PENALTY,
241            density_refill: DENSITY_REFILL,
242            low_conf_threshold: LOW_CONF_THRESHOLD,
243            low_conf_idle_days: LOW_CONF_IDLE_DAYS,
244            repeat_select_min: REPEAT_SELECT_MIN,
245            repeat_select_conf_max: REPEAT_SELECT_CONF_MAX,
246            never_used_age_days: NEVER_USED_AGE_DAYS,
247            open_ttl_days: OPEN_TTL_DAYS,
248            screening_timeout_minutes: SCREENING_TIMEOUT_MINUTES,
249            promote_used_success_min: PROMOTE_USED_SUCCESS_MIN,
250            promote_confidence_min: PROMOTE_CONFIDENCE_MIN,
251            decay_floor: DECAY_FLOOR,
252            evolve_threshold: EVOLVE_THRESHOLD,
253            distill_batch_size: DISTILL_BATCH_SIZE,
254            evolve_schedule_interval_hours: 6,
255            governance_archive_threshold: GOVERNANCE_ARCHIVE_THRESHOLD,
256            negative_feedback_archive_threshold: NEGATIVE_FEEDBACK_ARCHIVE_THRESHOLD,
257            governance_evolve_threshold: GOVERNANCE_EVOLVE_THRESHOLD,
258            governance_proposal_max_age_days: 30,
259            failure_min_uses: FAILURE_MIN_USES,
260            failure_max_success_rate: FAILURE_MAX_SUCCESS_RATE,
261            failure_confidence_max: FAILURE_CONFIDENCE_MAX,
262            log_compact_days: LOG_COMPACT_DAYS,
263        };
264        kb.init_meta()?;
265        kb.load_params()?;
266        Ok(kb)
267    }
268
269    fn init_meta(&self) -> Result<()> {
270        let lib_id = gen_uuid();
271        let content_dim = self.embedding.content_dim().to_string();
272        let trigger_dim = self.embedding.trigger_dim().to_string();
273        let embed_model = self.embedding.model_name();
274
275        for (key, expected) in [
276            ("content_dim", self.embedding.content_dim()),
277            ("trigger_dim", self.embedding.trigger_dim()),
278        ] {
279            if let Some(stored) = self.storage.get_meta(key)? {
280                let actual = stored.parse::<usize>().map_err(|_| {
281                    InnateError::Other(format!("invalid {key} metadata value: {stored}"))
282                })?;
283                if actual != expected {
284                    return Err(InnateError::Other(format!(
285                        "{key} mismatch: database uses {actual}, embedding provider uses {expected}"
286                    )));
287                }
288            }
289        }
290
291        let defaults: &[(&str, &str)] = &[
292            ("lib_id", &lib_id),
293            ("lib_role", "personal"),
294            ("schema_version", "4.14"),
295            ("content_dim", &content_dim),
296            ("trigger_dim", &trigger_dim),
297            ("embed_model", embed_model),
298            ("embed_version", "1"),
299            ("vector_revision", "0"),
300            ("last_agg_ts", "1970-01-01T00:00:00.000Z"),
301            ("recall.w_content", "0.55"),
302            ("recall.w_trigger", "0.25"),
303            ("recall.w_confidence", "0.10"),
304            ("recall.w_context", "0.15"),
305            ("recall.top_k_candidates", "20"),
306            ("recall.anti_trigger_penalty", "0.6"),
307            ("recall.density_refill", "true"),
308            ("curate.low_conf_threshold", "0.25"),
309            ("curate.low_conf_idle_days", "60"),
310            ("curate.repeat_select_min", "10"),
311            ("curate.repeat_select_conf_max", "0.5"),
312            ("curate.never_used_age_days", "30"),
313            ("curate.open_ttl_days", "14"),
314            ("curate.screening_timeout_minutes", "30"),
315            ("curate.promote_used_success_min", "3"),
316            ("curate.promote_confidence_min", "0.60"),
317            ("curate.decay_floor", "0.20"),
318            ("evolve.threshold_new_count", "5"),
319            ("evolve.distill_batch_size", "20"),
320            ("evolve.schedule_interval_hours", "6"),
321            ("curate.soft_mature_threshold", "5"),
322            ("evolve.distill_token_window_hours", "24"),
323            ("curate.governance_archive_threshold", "3"),
324            ("curate.negative_feedback_archive_threshold", "5"),
325            ("evolve.governance_pending_threshold", "3"),
326            ("curate.governance_proposal_max_age_days", "30"),
327            ("curate.failure_min_uses", "5"),
328            ("curate.failure_max_success_rate", "0.20"),
329            ("curate.failure_confidence_max", "0.35"),
330            ("curate.log_compact_days", "30"),
331        ];
332        self.storage.begin_immediate()?;
333        let result = (|| -> Result<()> {
334            for (k, v) in defaults {
335                if self.storage.get_meta(k)?.is_none() {
336                    self.storage.set_meta(k, v)?;
337                }
338            }
339            self.storage.commit()
340        })();
341        if result.is_err() {
342            let _ = self.storage.rollback();
343        }
344        result
345    }
346
347    fn load_params(&mut self) -> Result<()> {
348        let f = |k: &str, d: f64| -> f64 {
349            self.storage
350                .get_meta(k)
351                .ok()
352                .flatten()
353                .and_then(|v| v.parse().ok())
354                .unwrap_or(d)
355        };
356        let i = |k: &str, d: i64| -> i64 {
357            self.storage
358                .get_meta(k)
359                .ok()
360                .flatten()
361                .and_then(|v| v.parse().ok())
362                .unwrap_or(d)
363        };
364        let b = |k: &str, d: bool| -> bool {
365            self.storage
366                .get_meta(k)
367                .ok()
368                .flatten()
369                .map(|v| v.to_lowercase() == "true")
370                .unwrap_or(d)
371        };
372        self.w_content = f("recall.w_content", W_CONTENT);
373        self.w_trigger = f("recall.w_trigger", W_TRIGGER);
374        self.w_confidence = f("recall.w_confidence", W_CONFIDENCE);
375        self.w_context = f("recall.w_context", W_CONTEXT);
376        self.top_k_candidates =
377            i("recall.top_k_candidates", TOP_K_CANDIDATES as i64).max(1) as usize;
378        self.anti_trigger_penalty = f("recall.anti_trigger_penalty", ANTI_TRIGGER_PENALTY);
379        self.density_refill = b("recall.density_refill", DENSITY_REFILL);
380        self.low_conf_threshold = f("curate.low_conf_threshold", LOW_CONF_THRESHOLD);
381        self.low_conf_idle_days = i("curate.low_conf_idle_days", LOW_CONF_IDLE_DAYS);
382        self.repeat_select_min = i("curate.repeat_select_min", REPEAT_SELECT_MIN);
383        self.repeat_select_conf_max = f("curate.repeat_select_conf_max", REPEAT_SELECT_CONF_MAX);
384        self.never_used_age_days = i("curate.never_used_age_days", NEVER_USED_AGE_DAYS);
385        self.open_ttl_days = i("curate.open_ttl_days", OPEN_TTL_DAYS);
386        self.screening_timeout_minutes = i(
387            "curate.screening_timeout_minutes",
388            SCREENING_TIMEOUT_MINUTES,
389        );
390        self.promote_used_success_min =
391            i("curate.promote_used_success_min", PROMOTE_USED_SUCCESS_MIN);
392        self.promote_confidence_min = f("curate.promote_confidence_min", PROMOTE_CONFIDENCE_MIN);
393        self.decay_floor = f("curate.decay_floor", DECAY_FLOOR).clamp(0.0, 0.4);
394        self.evolve_threshold = i("evolve.threshold_new_count", EVOLVE_THRESHOLD);
395        self.distill_batch_size =
396            i("evolve.distill_batch_size", DISTILL_BATCH_SIZE as i64) as usize;
397        self.evolve_schedule_interval_hours = i("evolve.schedule_interval_hours", 6).max(1);
398        self.governance_archive_threshold = i(
399            "curate.governance_archive_threshold",
400            GOVERNANCE_ARCHIVE_THRESHOLD,
401        )
402        .max(1);
403        self.negative_feedback_archive_threshold = i(
404            "curate.negative_feedback_archive_threshold",
405            NEGATIVE_FEEDBACK_ARCHIVE_THRESHOLD,
406        )
407        .max(1);
408        self.governance_evolve_threshold = i(
409            "evolve.governance_pending_threshold",
410            GOVERNANCE_EVOLVE_THRESHOLD,
411        )
412        .max(1);
413        self.governance_proposal_max_age_days =
414            i("curate.governance_proposal_max_age_days", 30).max(1);
415        self.failure_min_uses = i("curate.failure_min_uses", FAILURE_MIN_USES).max(1);
416        self.failure_max_success_rate =
417            f("curate.failure_max_success_rate", FAILURE_MAX_SUCCESS_RATE).clamp(0.0, 1.0);
418        self.failure_confidence_max =
419            f("curate.failure_confidence_max", FAILURE_CONFIDENCE_MAX).clamp(0.0, 1.0);
420        self.log_compact_days = i("curate.log_compact_days", LOG_COMPACT_DAYS).max(1);
421        Ok(())
422    }
423}
424
425// ---------------------------------------------------------------------------
426// Helpers
427// ---------------------------------------------------------------------------
428
429struct CandidateInfo {
430    chunk: Value,
431    sim_content: f32,
432    sim_trigger: f32,
433}
434
435fn chunk_is_valid_for_recall(chunk: &Value, embed_version: i64) -> bool {
436    chunk.get("state").and_then(Value::as_str) != Some("archived")
437        && chunk.get("origin").and_then(Value::as_str) != Some("spark")
438        && chunk
439            .get("embed_version")
440            .and_then(Value::as_i64)
441            .unwrap_or(1)
442            >= embed_version
443}
444
445/// Normalize a query string before hashing into a context_key.
446///
447/// Goals: collapse whitespace variations and case differences so that
448/// semantically equivalent queries (same words, different capitalisation or
449/// spacing) accumulate statistics in the same context_stat bucket.
450///
451/// Deliberately conservative: no stemming, no stop-word removal. The canonical
452/// query guidance in SKILL.md handles vocabulary consistency at the agent level.
453fn normalize_query(query: &str) -> String {
454    const STOP_WORDS: &[&str] = &[
455        "a", "an", "and", "for", "in", "of", "on", "the", "to", "with",
456    ];
457    let cleaned: String = query
458        .to_lowercase()
459        .chars()
460        .map(|ch| {
461            if ch.is_alphanumeric() || ch.is_whitespace() {
462                ch
463            } else {
464                ' '
465            }
466        })
467        .collect();
468    let mut tokens: Vec<&str> = cleaned
469        .split_whitespace()
470        .filter(|token| !STOP_WORDS.contains(token))
471        .collect();
472    tokens.sort_unstable();
473    tokens.dedup();
474    tokens.join(" ")
475}
476
477fn estimate_distill_prompt_tokens(log: &Value, related_logs: &[Value]) -> i64 {
478    let primary: i64 = [
479        "query",
480        "recall_snapshot",
481        "output",
482        "output_summary",
483        "nomination",
484    ]
485    .iter()
486    .filter_map(|key| log.get(*key).and_then(Value::as_str))
487    .map(|text| estimate_tokens(text) as i64)
488    .sum();
489    let log_id = log.get("id").and_then(Value::as_str).unwrap_or("");
490    let context_key = log.get("context_key").and_then(Value::as_str);
491    let related: i64 = related_logs
492        .iter()
493        .filter(|other| other.get("id").and_then(Value::as_str).unwrap_or("") != log_id)
494        .filter(|other| {
495            context_key.is_some() && other.get("context_key").and_then(Value::as_str) == context_key
496        })
497        .take(4)
498        .flat_map(|other| {
499            ["query", "output_summary", "outcome"]
500                .into_iter()
501                .filter_map(|key| other.get(key).and_then(Value::as_str))
502        })
503        .map(|text| estimate_tokens(text) as i64)
504        .sum();
505    primary + related
506}
507
508fn estimate_distilled_chunk_tokens(chunk: &DistilledChunk) -> i64 {
509    estimate_tokens(&chunk.content) as i64
510        + chunk
511            .trigger_desc
512            .as_deref()
513            .map(estimate_tokens)
514            .unwrap_or(0) as i64
515        + chunk
516            .anti_trigger_desc
517            .as_deref()
518            .map(estimate_tokens)
519            .unwrap_or(0) as i64
520}
521
522fn anti_trigger_hit(query: &str, anti: &str) -> bool {
523    let q_lower = query.to_lowercase();
524    anti.to_lowercase().split(',').any(|part| {
525        let p = part.trim();
526        !p.is_empty() && q_lower.contains(p)
527    })
528}
529
530fn block_cost(block: &[Value]) -> usize {
531    block
532        .iter()
533        .map(|b| {
534            b.get("token_count")
535                .and_then(Value::as_u64)
536                .map(|t| t as usize)
537                .unwrap_or_else(|| {
538                    estimate_tokens(b.get("content").and_then(Value::as_str).unwrap_or("")).max(100)
539                })
540        })
541        .sum()
542}
543
544fn limit_knowledge(knowledge: Vec<Value>, top: Option<usize>) -> Vec<Value> {
545    match top {
546        None => knowledge,
547        Some(0) => vec![],
548        Some(n) => knowledge.into_iter().take(n).collect(),
549    }
550}
551
552fn usage_state(used: Option<&[String]>) -> &'static str {
553    match used {
554        None => "unknown",
555        Some([]) => "known_none",
556        Some(_) => "known_some",
557    }
558}
559
560fn ratio(numerator: i64, denominator: i64) -> f64 {
561    if denominator <= 0 {
562        0.0
563    } else {
564        ((numerator as f64 / denominator as f64) * 1000.0).round() / 1000.0
565    }
566}
567
568fn validate_source(source: &str) -> Result<()> {
569    if !matches!(
570        source,
571        "mcp" | "sdk" | "cli" | "hook" | "daemon" | "augmented"
572    ) {
573        return Err(InnateError::InvalidState(format!(
574            "invalid event source: {source}"
575        )));
576    }
577    Ok(())
578}
579
580fn count_query(storage: &Storage, sql: &str) -> Result<i64> {
581    Ok(storage
582        .query_chunks(sql)?
583        .first()
584        .and_then(|r| r.as_object())
585        .and_then(|m| m.values().next())
586        .and_then(Value::as_i64)
587        .unwrap_or(0))
588}
589
590fn count_query_params<P: rusqlite::Params>(storage: &Storage, sql: &str, p: P) -> Result<i64> {
591    Ok(storage
592        .query_chunks_params(sql, p)?
593        .first()
594        .and_then(|r| r.as_object())
595        .and_then(|m| m.values().next())
596        .and_then(Value::as_i64)
597        .unwrap_or(0))
598}
599
600fn days_ago(now_iso: &str, days: i64) -> String {
601    use chrono::{DateTime, Duration, Utc};
602    if let Ok(t) = now_iso.parse::<DateTime<Utc>>() {
603        let cutoff = t - Duration::days(days);
604        return cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
605    }
606    now_iso.to_string()
607}
608
609fn minutes_ago(now_iso: &str, minutes: i64) -> String {
610    use chrono::{DateTime, Duration, Utc};
611    if let Ok(t) = now_iso.parse::<DateTime<Utc>>() {
612        let cutoff = t - Duration::minutes(minutes);
613        return cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
614    }
615    now_iso.to_string()
616}
617
618fn hours_ago(now_iso: &str, hours: i64) -> String {
619    use chrono::{DateTime, Duration, Utc};
620    if let Ok(t) = now_iso.parse::<DateTime<Utc>>() {
621        let cutoff = t - Duration::hours(hours);
622        return cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
623    }
624    now_iso.to_string()
625}
626
627fn minutes_after(now_iso: &str, minutes: i64) -> String {
628    use chrono::{DateTime, Duration, Utc};
629    if let Ok(t) = now_iso.parse::<DateTime<Utc>>() {
630        let cutoff = t + Duration::minutes(minutes);
631        return cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
632    }
633    now_iso.to_string()
634}
635
636fn hours_after(now_iso: &str, hours: i64) -> String {
637    use chrono::{DateTime, Duration, Utc};
638    if let Ok(t) = now_iso.parse::<DateTime<Utc>>() {
639        let cutoff = t + Duration::hours(hours);
640        return cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
641    }
642    now_iso.to_string()
643}
644
645/// Return the number of whole days between two ISO timestamps (now - past; clamped ≥ 0).
646fn iso_days_diff(now_iso: &str, past_iso: &str) -> i64 {
647    use chrono::{DateTime, Utc};
648    let parse = |s: &str| s.parse::<DateTime<Utc>>().ok();
649    if let (Some(a), Some(b)) = (parse(now_iso), parse(past_iso)) {
650        let diff = a - b;
651        diff.num_days().max(0)
652    } else {
653        0
654    }
655}
656
657/// DFS-based cycle detection on the hard-dep graph. Returns list of cycles (each is a Vec of ids).
658fn detect_cycles(deps: &[Value]) -> Vec<Vec<String>> {
659    use std::collections::HashMap;
660    let mut adj: HashMap<String, Vec<String>> = HashMap::new();
661    for d in deps {
662        let src = d
663            .get("src")
664            .and_then(Value::as_str)
665            .unwrap_or("")
666            .to_string();
667        let dst = d
668            .get("dst")
669            .and_then(Value::as_str)
670            .unwrap_or("")
671            .to_string();
672        if !src.is_empty() && !dst.is_empty() {
673            adj.entry(src).or_default().push(dst);
674        }
675    }
676    let nodes: Vec<String> = adj.keys().cloned().collect();
677    let mut visited: HashSet<String> = HashSet::new();
678    let mut on_stack: HashSet<String> = HashSet::new();
679    let mut cycles: Vec<Vec<String>> = vec![];
680
681    fn dfs(
682        node: &str,
683        adj: &HashMap<String, Vec<String>>,
684        visited: &mut HashSet<String>,
685        on_stack: &mut HashSet<String>,
686        path: &mut Vec<String>,
687        cycles: &mut Vec<Vec<String>>,
688    ) {
689        if on_stack.contains(node) {
690            // Found cycle — extract loop segment.
691            let start = path.iter().position(|n| n == node).unwrap_or(0);
692            cycles.push(path[start..].to_vec());
693            return;
694        }
695        if visited.contains(node) {
696            return;
697        }
698        visited.insert(node.to_string());
699        on_stack.insert(node.to_string());
700        path.push(node.to_string());
701        if let Some(children) = adj.get(node) {
702            for child in children {
703                dfs(child, adj, visited, on_stack, path, cycles);
704            }
705        }
706        path.pop();
707        on_stack.remove(node);
708    }
709
710    for node in nodes {
711        let mut path = vec![];
712        dfs(
713            &node,
714            &adj,
715            &mut visited,
716            &mut on_stack,
717            &mut path,
718            &mut cycles,
719        );
720    }
721    cycles
722}