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