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, 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
34const 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#[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#[derive(Debug, Default, Clone)]
101pub struct CurateScope {
102 pub origin: Option<String>,
104 pub skill_name: Option<String>,
106 pub dry_run: bool,
108}
109
110pub trait Curator: Send + Sync {
113 fn run(&self, kb: &KnowledgeBase, scope: &CurateScope) -> Result<CurateReport>;
114}
115
116pub 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
125pub 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 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
387struct 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
407fn 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
607fn 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
619fn 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 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}