1use anyhow::Result;
24use rusqlite::Connection;
25use serde::{Deserialize, Serialize};
26use std::path::PathBuf;
27use std::sync::Arc;
28use std::sync::atomic::{AtomicBool, Ordering};
29use std::time::{Duration, Instant};
30
31use crate::db;
32use crate::llm::OllamaClient;
33use crate::models::{Memory, Tier};
34
35pub const DEFAULT_INTERVAL_SECS: u64 = 3600;
37
38pub const DEFAULT_MAX_OPS_PER_CYCLE: usize = 100;
40
41pub const MIN_CONTENT_LEN: usize = 50;
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct CuratorConfig {
48 pub interval_secs: u64,
51 pub max_ops_per_cycle: usize,
53 pub dry_run: bool,
55 pub include_namespaces: Vec<String>,
57 pub exclude_namespaces: Vec<String>,
59}
60
61impl Default for CuratorConfig {
62 fn default() -> Self {
63 Self {
64 interval_secs: DEFAULT_INTERVAL_SECS,
65 max_ops_per_cycle: DEFAULT_MAX_OPS_PER_CYCLE,
66 dry_run: false,
67 include_namespaces: Vec::new(),
68 exclude_namespaces: Vec::new(),
69 }
70 }
71}
72
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77pub struct CuratorReport {
78 pub started_at: String,
79 pub completed_at: String,
80 pub cycle_duration_ms: u128,
81 pub memories_scanned: usize,
82 pub memories_eligible: usize,
83 pub auto_tagged: usize,
84 pub contradictions_found: usize,
85 pub operations_attempted: usize,
86 pub operations_skipped_cap: usize,
87 #[serde(default)]
91 pub autonomy: crate::autonomy::AutonomyPassReport,
92 pub errors: Vec<String>,
93 pub dry_run: bool,
94}
95
96impl CuratorReport {
97 fn new(dry_run: bool) -> Self {
98 let now = chrono::Utc::now().to_rfc3339();
99 Self {
100 started_at: now.clone(),
101 completed_at: now,
102 dry_run,
103 ..Self::default()
104 }
105 }
106}
107
108pub fn run_once(
112 conn: &Connection,
113 llm: Option<&OllamaClient>,
114 cfg: &CuratorConfig,
115) -> Result<CuratorReport> {
116 let mut report = CuratorReport::new(cfg.dry_run);
117 let started = Instant::now();
118
119 let CandidateBatch {
120 memories: candidates,
121 truncated,
122 } = collect_candidates(conn, cfg)?;
123 report.memories_scanned = candidates.len();
124 record_truncation(&mut report, truncated, cfg);
125
126 let eligible: Vec<&Memory> = candidates
127 .iter()
128 .filter(|m| needs_curation(m, cfg))
129 .collect();
130 report.memories_eligible = eligible.len();
131
132 let Some(llm_client) = llm else {
133 report.errors.push("no LLM client configured".to_string());
134 report.completed_at = chrono::Utc::now().to_rfc3339();
135 report.cycle_duration_ms = started.elapsed().as_millis();
136 return Ok(report);
137 };
138
139 for mem in eligible {
140 if report.operations_attempted >= cfg.max_ops_per_cycle {
141 report.operations_skipped_cap += 1;
142 continue;
143 }
144 report.operations_attempted += 1;
145
146 match llm_client.auto_tag(&mem.title, &mem.content) {
147 Ok(tags) if !tags.is_empty() => {
148 let tag_list: Vec<String> = tags.into_iter().take(8).collect::<Vec<String>>();
149 if !cfg.dry_run
150 && let Err(e) = persist_auto_tags(conn, mem, &tag_list)
151 {
152 report
153 .errors
154 .push(format!("auto_tag persist failed for {}: {e}", mem.id));
155 continue;
156 }
157 report.auto_tagged += 1;
158 }
159 Ok(_) => {}
160 Err(e) => {
161 report
162 .errors
163 .push(format!("auto_tag failed for {}: {e}", mem.id));
164 }
165 }
166
167 if let Ok(Some(sibling)) = adjacent_memory(conn, mem) {
172 match llm_client.detect_contradiction(&mem.content, &sibling.content) {
173 Ok(true) => {
174 if !cfg.dry_run
175 && let Err(e) = persist_contradiction(conn, mem, &sibling.id)
176 {
177 report
178 .errors
179 .push(format!("contradiction persist failed for {}: {e}", mem.id));
180 continue;
181 }
182 report.contradictions_found += 1;
183 }
184 Ok(false) => {}
185 Err(e) => {
186 report.errors.push(format!(
187 "detect_contradiction failed ({} vs {}): {e}",
188 mem.id, sibling.id
189 ));
190 }
191 }
192 }
193 }
194
195 let autonomy_candidates: Vec<crate::models::Memory> = candidates
199 .iter()
200 .filter(|m| needs_curation(m, cfg))
201 .cloned()
202 .collect();
203 let pass_report =
204 crate::autonomy::run_autonomy_passes(conn, llm_client, &autonomy_candidates, cfg.dry_run);
205 report.errors.extend(pass_report.errors.clone());
206 report.autonomy = pass_report;
207
208 report.completed_at = chrono::Utc::now().to_rfc3339();
209 report.cycle_duration_ms = started.elapsed().as_millis();
210
211 if !cfg.dry_run
216 && let Err(e) = crate::autonomy::persist_self_report(
217 conn,
218 report.cycle_duration_ms,
219 &report.autonomy,
220 report.auto_tagged,
221 report.contradictions_found,
222 report.errors.len(),
223 )
224 {
225 tracing::warn!("self-report persist failed: {e}");
226 }
227
228 crate::metrics::curator_cycle_completed(
229 report.operations_attempted,
230 report.auto_tagged,
231 report.contradictions_found,
232 report.errors.len(),
233 );
234
235 Ok(report)
236}
237
238#[allow(clippy::needless_pass_by_value)]
244#[allow(dead_code)] pub fn run_daemon(
246 db_path: PathBuf,
247 llm: Option<Arc<OllamaClient>>,
248 cfg: CuratorConfig,
249 shutdown: Arc<AtomicBool>,
250) {
251 let interval = cfg.interval_secs.clamp(60, 86400);
252 tracing::info!(
253 "curator daemon started (interval={}s, max_ops={}, dry_run={})",
254 interval,
255 cfg.max_ops_per_cycle,
256 cfg.dry_run
257 );
258
259 while !shutdown.load(Ordering::Relaxed) {
260 match Connection::open(&db_path) {
261 Ok(conn) => {
262 let llm_ref = llm.as_deref();
263 match run_once(&conn, llm_ref, &cfg) {
264 Ok(report) => tracing::info!(
265 "curator cycle: scanned={} eligible={} tagged={} contradictions={} errors={} ({}ms, dry_run={})",
266 report.memories_scanned,
267 report.memories_eligible,
268 report.auto_tagged,
269 report.contradictions_found,
270 report.errors.len(),
271 report.cycle_duration_ms,
272 report.dry_run
273 ),
274 Err(e) => tracing::error!("curator cycle errored: {e}"),
275 }
276 }
277 Err(e) => tracing::error!("curator could not open db {}: {e}", db_path.display()),
278 }
279
280 let deadline = Instant::now() + Duration::from_secs(interval);
281 while Instant::now() < deadline {
282 if shutdown.load(Ordering::Relaxed) {
283 break;
284 }
285 std::thread::sleep(Duration::from_millis(500));
286 }
287 }
288
289 tracing::info!("curator daemon shutdown");
290}
291
292pub(crate) struct CandidateBatch {
296 pub memories: Vec<Memory>,
297 pub truncated: bool,
300}
301
302fn record_truncation(report: &mut CuratorReport, truncated: bool, cfg: &CuratorConfig) {
306 if truncated {
307 report.errors.push(format!(
308 "collect_candidates truncated at cap={} per tier; consider raising max_ops_per_cycle or paginating across cycles",
309 cfg.max_ops_per_cycle.saturating_mul(4)
310 ));
311 }
312}
313
314fn collect_candidates(conn: &Connection, cfg: &CuratorConfig) -> Result<CandidateBatch> {
315 let cap = cfg.max_ops_per_cycle.saturating_mul(4);
318 let mut out = Vec::new();
319 let mut truncated = false;
320 for tier in [Tier::Mid, Tier::Long] {
321 let batch = db::list(
322 conn,
323 None,
324 Some(&tier),
325 cap,
326 0,
327 None,
328 None,
329 None,
330 None,
331 None,
332 )?;
333 if batch.len() >= cap {
334 truncated = true;
340 }
341 out.extend(batch);
342 }
343 Ok(CandidateBatch {
344 memories: out,
345 truncated,
346 })
347}
348
349fn needs_curation(mem: &Memory, cfg: &CuratorConfig) -> bool {
350 if mem.namespace.starts_with('_') {
351 return false;
352 }
353 if !cfg.include_namespaces.is_empty() && !cfg.include_namespaces.contains(&mem.namespace) {
354 return false;
355 }
356 if cfg.exclude_namespaces.contains(&mem.namespace) {
357 return false;
358 }
359 if mem.content.len() < MIN_CONTENT_LEN {
360 return false;
361 }
362 let has_auto_tags = mem
367 .metadata
368 .get("auto_tags")
369 .is_some_and(|v| v.as_array().is_some_and(|a| !a.is_empty()));
370 !has_auto_tags
371}
372
373fn persist_auto_tags(conn: &Connection, mem: &Memory, tags: &[String]) -> Result<()> {
374 let mut updated = mem.metadata.clone();
375 if let Some(obj) = updated.as_object_mut() {
376 obj.insert("auto_tags".to_string(), serde_json::json!(tags));
377 obj.insert(
378 "curated_at".to_string(),
379 serde_json::json!(chrono::Utc::now().to_rfc3339()),
380 );
381 }
382 db::update(
383 conn,
384 &mem.id,
385 None,
386 None,
387 None,
388 None,
389 None,
390 None,
391 None,
392 None,
393 Some(&updated),
394 )?;
395 Ok(())
396}
397
398fn persist_contradiction(conn: &Connection, mem: &Memory, against_id: &str) -> Result<()> {
399 let mut updated = mem.metadata.clone();
400 if let Some(obj) = updated.as_object_mut() {
401 let existing = obj
402 .get("confirmed_contradictions")
403 .and_then(|v| v.as_array())
404 .cloned()
405 .unwrap_or_default();
406 let mut ids: Vec<String> = existing
407 .into_iter()
408 .filter_map(|v| v.as_str().map(String::from))
409 .collect();
410 if !ids.iter().any(|id| id == against_id) {
411 ids.push(against_id.to_string());
412 }
413 obj.insert(
414 "confirmed_contradictions".to_string(),
415 serde_json::json!(ids),
416 );
417 }
418 db::update(
419 conn,
420 &mem.id,
421 None,
422 None,
423 None,
424 None,
425 None,
426 None,
427 None,
428 None,
429 Some(&updated),
430 )?;
431 Ok(())
432}
433
434fn adjacent_memory(conn: &Connection, mem: &Memory) -> Result<Option<Memory>> {
435 let batch = db::list(
436 conn,
437 Some(&mem.namespace),
438 None,
439 8,
440 0,
441 None,
442 None,
443 None,
444 None,
445 None,
446 )?;
447 Ok(batch
448 .into_iter()
449 .find(|m| m.id != mem.id && m.content.len() >= MIN_CONTENT_LEN))
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn default_config_has_sane_values() {
458 let cfg = CuratorConfig::default();
459 assert_eq!(cfg.interval_secs, DEFAULT_INTERVAL_SECS);
460 assert_eq!(cfg.max_ops_per_cycle, DEFAULT_MAX_OPS_PER_CYCLE);
461 assert!(!cfg.dry_run);
462 assert!(cfg.include_namespaces.is_empty());
463 assert!(cfg.exclude_namespaces.is_empty());
464 }
465
466 #[test]
467 fn needs_curation_skips_internal_namespaces() {
468 let mem = Memory {
469 id: "m1".to_string(),
470 tier: Tier::Mid,
471 namespace: "_messages/alice".to_string(),
472 title: "t".to_string(),
473 content: "a".repeat(100),
474 tags: vec![],
475 priority: 5,
476 confidence: 1.0,
477 source: "test".to_string(),
478 access_count: 0,
479 created_at: "2026-01-01T00:00:00Z".to_string(),
480 updated_at: "2026-01-01T00:00:00Z".to_string(),
481 last_accessed_at: None,
482 expires_at: None,
483 metadata: serde_json::json!({}),
484 };
485 assert!(!needs_curation(&mem, &CuratorConfig::default()));
486 }
487
488 #[test]
489 fn needs_curation_skips_short_content() {
490 let mem = Memory {
491 id: "m1".to_string(),
492 tier: Tier::Mid,
493 namespace: "app".to_string(),
494 title: "t".to_string(),
495 content: "short".to_string(),
496 tags: vec![],
497 priority: 5,
498 confidence: 1.0,
499 source: "test".to_string(),
500 access_count: 0,
501 created_at: "2026-01-01T00:00:00Z".to_string(),
502 updated_at: "2026-01-01T00:00:00Z".to_string(),
503 last_accessed_at: None,
504 expires_at: None,
505 metadata: serde_json::json!({}),
506 };
507 assert!(!needs_curation(&mem, &CuratorConfig::default()));
508 }
509
510 #[test]
511 fn needs_curation_skips_already_tagged() {
512 let mem = Memory {
513 id: "m1".to_string(),
514 tier: Tier::Long,
515 namespace: "app".to_string(),
516 title: "t".to_string(),
517 content: "a".repeat(100),
518 tags: vec![],
519 priority: 5,
520 confidence: 1.0,
521 source: "test".to_string(),
522 access_count: 0,
523 created_at: "2026-01-01T00:00:00Z".to_string(),
524 updated_at: "2026-01-01T00:00:00Z".to_string(),
525 last_accessed_at: None,
526 expires_at: None,
527 metadata: serde_json::json!({"auto_tags":["x","y"]}),
528 };
529 assert!(!needs_curation(&mem, &CuratorConfig::default()));
530 }
531
532 #[test]
533 fn needs_curation_respects_include_list() {
534 let mem = Memory {
535 id: "m1".to_string(),
536 tier: Tier::Long,
537 namespace: "app".to_string(),
538 title: "t".to_string(),
539 content: "a".repeat(100),
540 tags: vec![],
541 priority: 5,
542 confidence: 1.0,
543 source: "test".to_string(),
544 access_count: 0,
545 created_at: "2026-01-01T00:00:00Z".to_string(),
546 updated_at: "2026-01-01T00:00:00Z".to_string(),
547 last_accessed_at: None,
548 expires_at: None,
549 metadata: serde_json::json!({}),
550 };
551 let mut cfg = CuratorConfig {
552 include_namespaces: vec!["other".to_string()],
553 ..CuratorConfig::default()
554 };
555 assert!(!needs_curation(&mem, &cfg));
556 cfg.include_namespaces = vec!["app".to_string()];
557 assert!(needs_curation(&mem, &cfg));
558 }
559
560 #[test]
561 fn needs_curation_respects_exclude_list() {
562 let mem = Memory {
563 id: "m1".to_string(),
564 tier: Tier::Long,
565 namespace: "noisy".to_string(),
566 title: "t".to_string(),
567 content: "a".repeat(100),
568 tags: vec![],
569 priority: 5,
570 confidence: 1.0,
571 source: "test".to_string(),
572 access_count: 0,
573 created_at: "2026-01-01T00:00:00Z".to_string(),
574 updated_at: "2026-01-01T00:00:00Z".to_string(),
575 last_accessed_at: None,
576 expires_at: None,
577 metadata: serde_json::json!({}),
578 };
579 let cfg = CuratorConfig {
580 exclude_namespaces: vec!["noisy".to_string()],
581 ..CuratorConfig::default()
582 };
583 assert!(!needs_curation(&mem, &cfg));
584 }
585
586 #[test]
587 fn run_once_without_llm_emits_error_but_succeeds() {
588 let tmp = tempfile::NamedTempFile::new().unwrap();
589 let conn = db::open(tmp.path()).unwrap();
590 let cfg = CuratorConfig::default();
591 let report = run_once(&conn, None, &cfg).unwrap();
592 assert_eq!(report.memories_scanned, 0);
593 assert_eq!(report.memories_eligible, 0);
594 assert_eq!(report.operations_attempted, 0);
595 assert!(report.errors.iter().any(|e| e.contains("no LLM")));
596 }
597
598 #[test]
599 fn report_serialises_to_json() {
600 let report = CuratorReport::new(true);
601 let json = serde_json::to_string(&report).unwrap();
602 assert!(json.contains("dry_run"));
603 assert!(json.contains("memories_scanned"));
604 }
605
606 fn make_test_memory(ns: &str, title: &str, content: &str) -> Memory {
610 let now = chrono::Utc::now().to_rfc3339();
611 Memory {
612 id: uuid::Uuid::new_v4().to_string(),
613 tier: Tier::Long,
614 namespace: ns.to_string(),
615 title: title.to_string(),
616 content: content.to_string(),
617 tags: vec![],
618 priority: 5,
619 confidence: 1.0,
620 source: "api".to_string(),
621 access_count: 0,
622 created_at: now.clone(),
623 updated_at: now,
624 last_accessed_at: None,
625 expires_at: None,
626 metadata: serde_json::json!({}),
627 }
628 }
629
630 #[test]
631 fn persist_auto_tags_writes_metadata() {
632 let tmp = tempfile::NamedTempFile::new().unwrap();
635 let conn = db::open(tmp.path()).unwrap();
636 let mem = make_test_memory("curate-test", "anchor", &"a".repeat(120));
637 db::insert(&conn, &mem).unwrap();
638
639 persist_auto_tags(&conn, &mem, &["alpha".to_string(), "beta".to_string()]).unwrap();
640
641 let updated = db::get(&conn, &mem.id).unwrap().unwrap();
642 let tags = updated
643 .metadata
644 .get("auto_tags")
645 .unwrap()
646 .as_array()
647 .unwrap();
648 assert_eq!(tags.len(), 2);
649 assert_eq!(tags[0].as_str().unwrap(), "alpha");
650 assert!(
651 updated
652 .metadata
653 .get("curated_at")
654 .and_then(|v| v.as_str())
655 .is_some_and(|s| !s.is_empty())
656 );
657 }
658
659 #[test]
660 fn persist_auto_tags_with_empty_tag_list_still_writes_marker() {
661 let tmp = tempfile::NamedTempFile::new().unwrap();
664 let conn = db::open(tmp.path()).unwrap();
665 let mem = make_test_memory("curate-test", "anchor", &"a".repeat(120));
666 db::insert(&conn, &mem).unwrap();
667
668 persist_auto_tags(&conn, &mem, &[]).unwrap();
669
670 let updated = db::get(&conn, &mem.id).unwrap().unwrap();
671 let tags = updated
672 .metadata
673 .get("auto_tags")
674 .unwrap()
675 .as_array()
676 .unwrap();
677 assert!(tags.is_empty());
678 }
679
680 #[test]
681 fn persist_contradiction_appends_unique_ids() {
682 let tmp = tempfile::NamedTempFile::new().unwrap();
685 let conn = db::open(tmp.path()).unwrap();
686 let mem = make_test_memory("curate-test", "anchor", &"a".repeat(120));
687 db::insert(&conn, &mem).unwrap();
688
689 persist_contradiction(&conn, &mem, "id-1").unwrap();
690 let mid = db::get(&conn, &mem.id).unwrap().unwrap();
692 persist_contradiction(&conn, &mid, "id-2").unwrap();
693 let mid2 = db::get(&conn, &mem.id).unwrap().unwrap();
695 persist_contradiction(&conn, &mid2, "id-1").unwrap();
696
697 let updated = db::get(&conn, &mem.id).unwrap().unwrap();
698 let ids = updated
699 .metadata
700 .get("confirmed_contradictions")
701 .unwrap()
702 .as_array()
703 .unwrap();
704 assert_eq!(ids.len(), 2);
705 let strs: Vec<String> = ids
706 .iter()
707 .filter_map(|v| v.as_str().map(String::from))
708 .collect();
709 assert!(strs.contains(&"id-1".to_string()));
710 assert!(strs.contains(&"id-2".to_string()));
711 }
712
713 #[test]
714 fn adjacent_memory_returns_none_when_only_self_exists() {
715 let tmp = tempfile::NamedTempFile::new().unwrap();
717 let conn = db::open(tmp.path()).unwrap();
718 let mem = make_test_memory("solo-ns", "only", &"a".repeat(120));
719 db::insert(&conn, &mem).unwrap();
720
721 let got = adjacent_memory(&conn, &mem).unwrap();
722 assert!(got.is_none());
723 }
724
725 #[test]
726 fn adjacent_memory_returns_some_when_sibling_present() {
727 let tmp = tempfile::NamedTempFile::new().unwrap();
730 let conn = db::open(tmp.path()).unwrap();
731 let m1 = make_test_memory("dual-ns", "first", &"a".repeat(120));
732 let m2 = make_test_memory("dual-ns", "second", &"b".repeat(120));
733 db::insert(&conn, &m1).unwrap();
734 db::insert(&conn, &m2).unwrap();
735
736 let got = adjacent_memory(&conn, &m1).unwrap().unwrap();
737 assert_ne!(got.id, m1.id);
738 assert!(got.content.len() >= MIN_CONTENT_LEN);
739 }
740
741 #[test]
742 fn adjacent_memory_skips_short_sibling() {
743 let tmp = tempfile::NamedTempFile::new().unwrap();
745 let conn = db::open(tmp.path()).unwrap();
746 let m1 = make_test_memory("ns-short", "anchor", &"a".repeat(120));
747 let mut m2 = make_test_memory("ns-short", "tiny-sibling", "x");
748 m2.content = "short".to_string(); db::insert(&conn, &m1).unwrap();
750 db::insert(&conn, &m2).unwrap();
751
752 let got = adjacent_memory(&conn, &m1).unwrap();
753 assert!(got.is_none());
754 }
755
756 #[test]
757 fn record_truncation_appends_when_truncated() {
758 let mut report = CuratorReport::new(false);
759 let cfg = CuratorConfig::default();
760 record_truncation(&mut report, true, &cfg);
761 assert_eq!(report.errors.len(), 1);
762 assert!(report.errors[0].contains("collect_candidates truncated"));
763 }
764
765 #[test]
766 fn record_truncation_noop_when_not_truncated() {
767 let mut report = CuratorReport::new(false);
768 let cfg = CuratorConfig::default();
769 record_truncation(&mut report, false, &cfg);
770 assert!(report.errors.is_empty());
771 }
772
773 #[test]
774 fn collect_candidates_returns_eligible_memories() {
775 let tmp = tempfile::NamedTempFile::new().unwrap();
778 let conn = db::open(tmp.path()).unwrap();
779 for i in 0..3 {
780 let mem = make_test_memory("cand-ns", &format!("row-{i}"), &"a".repeat(120));
781 db::insert(&conn, &mem).unwrap();
782 }
783 let cfg = CuratorConfig::default();
784 let batch = collect_candidates(&conn, &cfg).unwrap();
785 assert!(!batch.memories.is_empty());
786 assert!(!batch.truncated);
788 }
789
790 #[test]
791 fn run_once_with_dry_run_does_not_persist() {
792 let tmp = tempfile::NamedTempFile::new().unwrap();
795 let conn = db::open(tmp.path()).unwrap();
796 let mem = make_test_memory("dry-ns", "anchor", &"a".repeat(120));
797 db::insert(&conn, &mem).unwrap();
798
799 let cfg = CuratorConfig {
800 dry_run: true,
801 ..CuratorConfig::default()
802 };
803 let report = run_once(&conn, None, &cfg).unwrap();
804 assert!(report.dry_run);
805 let after = db::get(&conn, &mem.id).unwrap().unwrap();
807 assert!(after.metadata.get("auto_tags").is_none());
808 }
809
810 #[test]
811 fn run_daemon_executes_multiple_cycles_and_respects_shutdown() {
812 use std::sync::Mutex;
813 use std::thread;
814 use std::time::Duration;
815
816 let tmp = tempfile::NamedTempFile::new().unwrap();
817 let db_path = tmp.path().to_path_buf();
818 let conn = db::open(&db_path).unwrap();
819
820 let now = chrono::Utc::now().to_rfc3339();
822 for i in 0..5 {
823 let mem = Memory {
824 id: format!("test-mem-{i}"),
825 tier: crate::models::Tier::Mid,
826 namespace: "test".to_string(),
827 title: format!("Memory {i}"),
828 content: "x".repeat(100), tags: vec![],
830 priority: 5,
831 confidence: 1.0,
832 source: "test".to_string(),
833 access_count: 0,
834 created_at: now.clone(),
835 updated_at: now.clone(),
836 last_accessed_at: None,
837 expires_at: None,
838 metadata: serde_json::json!({}),
839 };
840 db::insert(&conn, &mem).unwrap();
841 }
842 drop(conn);
843
844 let cycle_count = std::sync::Arc::new(Mutex::new(0));
846 let cycle_count_for_test = cycle_count.clone();
847
848 let cfg = CuratorConfig {
850 interval_secs: 1,
851 max_ops_per_cycle: 50,
852 dry_run: true, include_namespaces: vec![],
854 exclude_namespaces: vec![],
855 };
856
857 let shutdown = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
859 let shutdown_for_daemon = shutdown.clone();
860
861 let daemon_thread = thread::spawn(move || {
863 *cycle_count_for_test.lock().unwrap() = 1;
865 run_daemon(db_path, None, cfg, shutdown_for_daemon);
866 *cycle_count_for_test.lock().unwrap() = 2;
868 });
869
870 thread::sleep(Duration::from_millis(2500));
872
873 shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
875
876 let join_result = daemon_thread.join();
878 assert!(
879 join_result.is_ok(),
880 "daemon thread panicked or failed to join"
881 );
882
883 let final_count = *cycle_count.lock().unwrap();
885 assert_eq!(
886 final_count, 2,
887 "daemon should have entered and exited cleanly"
888 );
889 }
890
891 use std::io::{BufRead, BufReader, Read, Write};
900 use std::net::TcpListener;
901 use std::sync::Arc as StdArc;
902 use std::sync::atomic::{AtomicBool as StdAtomicBool, AtomicUsize, Ordering as StdOrdering};
903 use std::thread::JoinHandle;
904
905 #[derive(Clone)]
907 struct FakeOllamaCfg {
908 tag_response: String,
910 contradiction_answer: String,
912 summary_response: String,
914 chat_returns_error: bool,
916 }
917
918 impl Default for FakeOllamaCfg {
919 fn default() -> Self {
920 Self {
921 tag_response: "alpha\nbeta\ngamma".to_string(),
922 contradiction_answer: "no".to_string(),
923 summary_response: "consolidated summary".to_string(),
924 chat_returns_error: false,
925 }
926 }
927 }
928
929 struct FakeOllama {
931 url: String,
932 shutdown: StdArc<StdAtomicBool>,
933 handle: Option<JoinHandle<()>>,
934 chat_calls: StdArc<AtomicUsize>,
935 }
936
937 impl FakeOllama {
938 fn start(cfg: FakeOllamaCfg) -> Self {
939 let listener = TcpListener::bind("127.0.0.1:0").expect("bind 127.0.0.1");
940 let addr = listener.local_addr().unwrap();
941 listener.set_nonblocking(true).unwrap();
943 let shutdown = StdArc::new(StdAtomicBool::new(false));
944 let chat_calls = StdArc::new(AtomicUsize::new(0));
945 let shutdown_for_thread = shutdown.clone();
946 let chat_calls_for_thread = chat_calls.clone();
947 let cfg_for_thread = cfg;
948
949 let handle = std::thread::spawn(move || {
950 while !shutdown_for_thread.load(StdOrdering::Relaxed) {
951 match listener.accept() {
952 Ok((mut stream, _peer)) => {
953 stream.set_nonblocking(false).ok();
954 stream
955 .set_read_timeout(Some(std::time::Duration::from_secs(2)))
956 .ok();
957 let cfg = cfg_for_thread.clone();
958 let chat_calls = chat_calls_for_thread.clone();
959 std::thread::spawn(move || {
960 handle_one(&mut stream, &cfg, &chat_calls);
961 });
962 }
963 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
964 std::thread::sleep(std::time::Duration::from_millis(20));
965 }
966 Err(_) => break,
967 }
968 }
969 });
970
971 Self {
972 url: format!("http://127.0.0.1:{}", addr.port()),
973 shutdown,
974 handle: Some(handle),
975 chat_calls,
976 }
977 }
978 }
979
980 impl Drop for FakeOllama {
981 fn drop(&mut self) {
982 self.shutdown.store(true, StdOrdering::Relaxed);
983 if let Some(h) = self.handle.take() {
984 let _ = h.join();
985 }
986 }
987 }
988
989 fn handle_one(stream: &mut std::net::TcpStream, cfg: &FakeOllamaCfg, chat_calls: &AtomicUsize) {
993 let mut reader = BufReader::new(stream.try_clone().expect("clone tcp"));
994 let mut request_line = String::new();
996 if reader.read_line(&mut request_line).is_err() {
997 return;
998 }
999 let parts: Vec<&str> = request_line.split_whitespace().collect();
1000 if parts.len() < 2 {
1001 return;
1002 }
1003 let method = parts[0];
1004 let path = parts[1];
1005
1006 let mut content_length: usize = 0;
1008 loop {
1009 let mut header = String::new();
1010 if reader.read_line(&mut header).is_err() {
1011 return;
1012 }
1013 if header == "\r\n" || header.is_empty() {
1014 break;
1015 }
1016 let lower = header.to_ascii_lowercase();
1017 if let Some(rest) = lower.strip_prefix("content-length:") {
1018 content_length = rest.trim().parse().unwrap_or(0);
1019 }
1020 }
1021
1022 let mut body = vec![0u8; content_length];
1024 if content_length > 0 {
1025 let _ = reader.read_exact(&mut body);
1026 }
1027 let body_str = String::from_utf8_lossy(&body).to_string();
1028
1029 let (status, body): (&str, String) = if method == "GET" && path == "/api/tags" {
1030 (
1032 "200 OK",
1033 serde_json::json!({"models": [{"name": "fake-model:latest"}]}).to_string(),
1034 )
1035 } else if method == "POST" && path == "/api/chat" {
1036 chat_calls.fetch_add(1, StdOrdering::Relaxed);
1037 if cfg.chat_returns_error {
1038 (
1039 "500 Internal Server Error",
1040 "{\"error\":\"forced fault\"}".to_string(),
1041 )
1042 } else {
1043 let response = if body_str.contains("contradict") {
1045 cfg.contradiction_answer.clone()
1046 } else if body_str.contains("Summarize") || body_str.contains("summari") {
1047 cfg.summary_response.clone()
1048 } else if body_str.contains("tags") {
1049 cfg.tag_response.clone()
1050 } else {
1051 "ok".to_string()
1052 };
1053 (
1054 "200 OK",
1055 serde_json::json!({"message": {"content": response}}).to_string(),
1056 )
1057 }
1058 } else {
1059 ("404 Not Found", "{}".to_string())
1060 };
1061
1062 let resp = format!(
1063 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1064 body.len()
1065 );
1066 let _ = stream.write_all(resp.as_bytes());
1067 let _ = stream.flush();
1068 let _ = stream.shutdown(std::net::Shutdown::Write);
1069 }
1070
1071 fn ollama_for(server: &FakeOllama) -> crate::llm::OllamaClient {
1073 crate::llm::OllamaClient::new_with_url(&server.url, "fake-model")
1074 .expect("client must reach fake server")
1075 }
1076
1077 fn make_eligible_memory(ns: &str, title: &str) -> Memory {
1078 let now = chrono::Utc::now().to_rfc3339();
1079 Memory {
1080 id: uuid::Uuid::new_v4().to_string(),
1081 tier: Tier::Long,
1082 namespace: ns.to_string(),
1083 title: title.to_string(),
1084 content: "a".repeat(120),
1085 tags: vec![],
1086 priority: 5,
1087 confidence: 1.0,
1088 source: "api".to_string(),
1089 access_count: 0,
1090 created_at: now.clone(),
1091 updated_at: now,
1092 last_accessed_at: None,
1093 expires_at: None,
1094 metadata: serde_json::json!({}),
1095 }
1096 }
1097
1098 #[test]
1102 fn run_once_with_llm_tags_eligible_memories() {
1103 let server = FakeOllama::start(FakeOllamaCfg::default());
1104 let llm = ollama_for(&server);
1105
1106 let tmp = tempfile::NamedTempFile::new().unwrap();
1107 let conn = db::open(tmp.path()).unwrap();
1108 let mem = make_eligible_memory("autotag-ns", "anchor");
1109 db::insert(&conn, &mem).unwrap();
1110
1111 let cfg = CuratorConfig {
1112 include_namespaces: vec!["autotag-ns".to_string()],
1115 ..CuratorConfig::default()
1116 };
1117 let report = run_once(&conn, Some(&llm), &cfg).unwrap();
1118
1119 assert!(report.memories_eligible >= 1);
1120 assert!(report.auto_tagged >= 1, "report: {report:?}");
1121 let updated = db::get(&conn, &mem.id).unwrap().unwrap();
1122 let tags = updated
1123 .metadata
1124 .get("auto_tags")
1125 .and_then(|v| v.as_array())
1126 .expect("auto_tags persisted");
1127 assert!(!tags.is_empty());
1128 }
1129
1130 #[test]
1134 fn run_once_with_llm_dry_run_skips_writes() {
1135 let server = FakeOllama::start(FakeOllamaCfg::default());
1136 let llm = ollama_for(&server);
1137
1138 let tmp = tempfile::NamedTempFile::new().unwrap();
1139 let conn = db::open(tmp.path()).unwrap();
1140 let mem = make_eligible_memory("dry-llm-ns", "anchor");
1141 db::insert(&conn, &mem).unwrap();
1142
1143 let cfg = CuratorConfig {
1144 dry_run: true,
1145 include_namespaces: vec!["dry-llm-ns".to_string()],
1146 ..CuratorConfig::default()
1147 };
1148 let report = run_once(&conn, Some(&llm), &cfg).unwrap();
1149 assert!(report.dry_run);
1150
1151 let after = db::get(&conn, &mem.id).unwrap().unwrap();
1153 assert!(after.metadata.get("auto_tags").is_none());
1154 let reports = db::list(
1155 &conn,
1156 Some("_curator/reports"),
1157 None,
1158 10,
1159 0,
1160 None,
1161 None,
1162 None,
1163 None,
1164 None,
1165 )
1166 .unwrap();
1167 assert!(reports.is_empty(), "dry-run must not persist self-report");
1168 }
1169
1170 #[test]
1174 fn run_once_max_ops_cap_respected() {
1175 let server = FakeOllama::start(FakeOllamaCfg::default());
1176 let llm = ollama_for(&server);
1177
1178 let tmp = tempfile::NamedTempFile::new().unwrap();
1179 let conn = db::open(tmp.path()).unwrap();
1180 for i in 0..3 {
1181 let m = make_eligible_memory("capns", &format!("anchor-{i}"));
1182 db::insert(&conn, &m).unwrap();
1183 }
1184 let cfg = CuratorConfig {
1185 max_ops_per_cycle: 1,
1186 include_namespaces: vec!["capns".to_string()],
1187 ..CuratorConfig::default()
1188 };
1189 let report = run_once(&conn, Some(&llm), &cfg).unwrap();
1190 assert_eq!(report.operations_attempted, 1);
1191 assert!(report.operations_skipped_cap >= 2, "report: {report:?}");
1192 }
1193
1194 #[test]
1198 fn run_once_include_namespaces_filter() {
1199 let server = FakeOllama::start(FakeOllamaCfg::default());
1200 let llm = ollama_for(&server);
1201
1202 let tmp = tempfile::NamedTempFile::new().unwrap();
1203 let conn = db::open(tmp.path()).unwrap();
1204 let inside = make_eligible_memory("included", "in");
1205 let outside = make_eligible_memory("not-included", "out");
1206 db::insert(&conn, &inside).unwrap();
1207 db::insert(&conn, &outside).unwrap();
1208
1209 let cfg = CuratorConfig {
1210 include_namespaces: vec!["included".to_string()],
1211 ..CuratorConfig::default()
1212 };
1213 let report = run_once(&conn, Some(&llm), &cfg).unwrap();
1214 assert!(report.memories_scanned >= 2);
1216 assert_eq!(report.memories_eligible, 1);
1217 let after_outside = db::get(&conn, &outside.id).unwrap().unwrap();
1219 assert!(after_outside.metadata.get("auto_tags").is_none());
1220 }
1221
1222 #[test]
1224 fn run_once_exclude_namespaces_filter() {
1225 let server = FakeOllama::start(FakeOllamaCfg::default());
1226 let llm = ollama_for(&server);
1227
1228 let tmp = tempfile::NamedTempFile::new().unwrap();
1229 let conn = db::open(tmp.path()).unwrap();
1230 let kept = make_eligible_memory("kept", "k");
1231 let dropped = make_eligible_memory("dropped", "d");
1232 db::insert(&conn, &kept).unwrap();
1233 db::insert(&conn, &dropped).unwrap();
1234
1235 let cfg = CuratorConfig {
1236 exclude_namespaces: vec!["dropped".to_string()],
1237 ..CuratorConfig::default()
1238 };
1239 let report = run_once(&conn, Some(&llm), &cfg).unwrap();
1240 assert!(report.memories_scanned >= 2);
1241 assert_eq!(report.memories_eligible, 1);
1243 let after_dropped = db::get(&conn, &dropped.id).unwrap().unwrap();
1244 assert!(after_dropped.metadata.get("auto_tags").is_none());
1245 }
1246
1247 #[test]
1251 fn run_once_handles_zero_candidates() {
1252 let server = FakeOllama::start(FakeOllamaCfg::default());
1253 let llm = ollama_for(&server);
1254
1255 let tmp = tempfile::NamedTempFile::new().unwrap();
1256 let conn = db::open(tmp.path()).unwrap();
1257 let cfg = CuratorConfig::default();
1258
1259 let report = run_once(&conn, Some(&llm), &cfg).unwrap();
1260 assert_eq!(report.memories_scanned, 0);
1261 assert_eq!(report.memories_eligible, 0);
1262 assert_eq!(report.operations_attempted, 0);
1263 assert_eq!(report.auto_tagged, 0);
1264 assert_eq!(report.contradictions_found, 0);
1265 }
1266
1267 #[test]
1271 fn run_once_records_contradictions_when_llm_affirms() {
1272 let cfg_server = FakeOllamaCfg {
1273 contradiction_answer: "yes".to_string(),
1274 ..FakeOllamaCfg::default()
1275 };
1276 let server = FakeOllama::start(cfg_server);
1277 let llm = ollama_for(&server);
1278
1279 let tmp = tempfile::NamedTempFile::new().unwrap();
1280 let conn = db::open(tmp.path()).unwrap();
1281 let m1 = make_eligible_memory("dual", "first");
1282 let m2 = make_eligible_memory("dual", "second");
1283 db::insert(&conn, &m1).unwrap();
1284 db::insert(&conn, &m2).unwrap();
1285
1286 let cfg = CuratorConfig {
1287 include_namespaces: vec!["dual".to_string()],
1288 ..CuratorConfig::default()
1289 };
1290 let report = run_once(&conn, Some(&llm), &cfg).unwrap();
1291 assert!(report.contradictions_found >= 1, "report: {report:?}");
1292 }
1293
1294 #[test]
1298 fn run_once_records_errors_when_llm_fails() {
1299 let cfg_server = FakeOllamaCfg {
1300 chat_returns_error: true,
1301 ..FakeOllamaCfg::default()
1302 };
1303 let server = FakeOllama::start(cfg_server);
1304 let llm = ollama_for(&server);
1305
1306 let tmp = tempfile::NamedTempFile::new().unwrap();
1307 let conn = db::open(tmp.path()).unwrap();
1308 let mem = make_eligible_memory("fail-ns", "anchor");
1309 db::insert(&conn, &mem).unwrap();
1310
1311 let cfg = CuratorConfig {
1312 include_namespaces: vec!["fail-ns".to_string()],
1313 ..CuratorConfig::default()
1314 };
1315 let report = run_once(&conn, Some(&llm), &cfg).unwrap();
1316 assert!(!report.completed_at.is_empty());
1318 assert!(
1320 report
1321 .errors
1322 .iter()
1323 .any(|e| e.contains("auto_tag failed") || e.contains("detect_contradiction failed")),
1324 "expected an LLM-error entry in report.errors: {:?}",
1325 report.errors
1326 );
1327 let after = db::get(&conn, &mem.id).unwrap().unwrap();
1329 assert!(after.metadata.get("auto_tags").is_none());
1330 }
1331
1332 #[test]
1336 fn run_once_writes_self_report_when_not_dry_run() {
1337 let server = FakeOllama::start(FakeOllamaCfg::default());
1338 let llm = ollama_for(&server);
1339
1340 let tmp = tempfile::NamedTempFile::new().unwrap();
1341 let conn = db::open(tmp.path()).unwrap();
1342 let mem = make_eligible_memory("report-ns", "anchor");
1343 db::insert(&conn, &mem).unwrap();
1344
1345 let cfg = CuratorConfig {
1346 include_namespaces: vec!["report-ns".to_string()],
1347 ..CuratorConfig::default()
1348 };
1349 let _ = run_once(&conn, Some(&llm), &cfg).unwrap();
1350
1351 let reports = db::list(
1352 &conn,
1353 Some("_curator/reports"),
1354 None,
1355 10,
1356 0,
1357 None,
1358 None,
1359 None,
1360 None,
1361 None,
1362 )
1363 .unwrap();
1364 assert_eq!(reports.len(), 1);
1365 assert!(reports[0].content.contains("memories_consolidated"));
1366 }
1367
1368 #[test]
1373 fn run_once_idempotent_on_already_tagged_rows() {
1374 let server = FakeOllama::start(FakeOllamaCfg::default());
1375 let llm = ollama_for(&server);
1376
1377 let tmp = tempfile::NamedTempFile::new().unwrap();
1378 let conn = db::open(tmp.path()).unwrap();
1379 let mem = make_eligible_memory("idem-ns", "anchor");
1380 db::insert(&conn, &mem).unwrap();
1381
1382 let cfg = CuratorConfig {
1383 include_namespaces: vec!["idem-ns".to_string()],
1384 ..CuratorConfig::default()
1385 };
1386 let r1 = run_once(&conn, Some(&llm), &cfg).unwrap();
1387 assert_eq!(r1.memories_eligible, 1);
1388 let r2 = run_once(&conn, Some(&llm), &cfg).unwrap();
1389 assert!(r2.memories_scanned >= 1);
1390 assert_eq!(r2.memories_eligible, 0);
1391 assert_eq!(r2.operations_attempted, 0);
1392 }
1393
1394 #[test]
1400 fn run_once_iterates_through_multiple_rows() {
1401 let server = FakeOllama::start(FakeOllamaCfg::default());
1402 let llm = ollama_for(&server);
1403
1404 let tmp = tempfile::NamedTempFile::new().unwrap();
1405 let conn = db::open(tmp.path()).unwrap();
1406 for i in 0..3 {
1407 let m = make_eligible_memory("multi-ns", &format!("anchor-{i}"));
1408 db::insert(&conn, &m).unwrap();
1409 }
1410 let cfg = CuratorConfig {
1411 include_namespaces: vec!["multi-ns".to_string()],
1412 ..CuratorConfig::default()
1413 };
1414 let report = run_once(&conn, Some(&llm), &cfg).unwrap();
1415 assert_eq!(report.operations_attempted, 3);
1416 assert_eq!(report.auto_tagged, 3);
1417 assert!(server.chat_calls.load(StdOrdering::Relaxed) >= 3);
1419 }
1420
1421 #[test]
1427 fn run_once_smart_tier_consults_llm_for_clusters() {
1428 let server = FakeOllama::start(FakeOllamaCfg::default());
1429 let llm = ollama_for(&server);
1430
1431 let tmp = tempfile::NamedTempFile::new().unwrap();
1432 let conn = db::open(tmp.path()).unwrap();
1433 let now = chrono::Utc::now().to_rfc3339();
1435 let m_a = Memory {
1436 id: "smart-a".to_string(),
1437 tier: Tier::Long,
1438 namespace: "smart".to_string(),
1439 title: "deploy plan".to_string(),
1440 content: "kubernetes rolling canary deploy strategy kubernetes deploy".to_string(),
1441 tags: vec![],
1442 priority: 5,
1443 confidence: 1.0,
1444 source: "api".to_string(),
1445 access_count: 0,
1446 created_at: now.clone(),
1447 updated_at: now.clone(),
1448 last_accessed_at: None,
1449 expires_at: None,
1450 metadata: serde_json::json!({}),
1451 };
1452 let m_b = Memory {
1453 id: "smart-b".to_string(),
1454 content: "kubernetes rolling canary deploy strategy kubernetes deploy".to_string(),
1455 title: "deploy overview".to_string(),
1456 ..m_a.clone()
1457 };
1458 db::insert(&conn, &m_a).unwrap();
1459 db::insert(&conn, &m_b).unwrap();
1460
1461 let cfg = CuratorConfig {
1462 include_namespaces: vec!["smart".to_string()],
1463 ..CuratorConfig::default()
1464 };
1465 let report = run_once(&conn, Some(&llm), &cfg).unwrap();
1466 assert!(server.chat_calls.load(StdOrdering::Relaxed) >= 3);
1468 assert!(report.autonomy.clusters_formed >= 1, "report: {report:?}");
1470 }
1471}
1472
1473#[test]
1474fn apply_rollback_handles_storage_error() {
1475 let tmp = tempfile::NamedTempFile::new().unwrap();
1478 let conn = db::open(tmp.path()).unwrap();
1479
1480 let mem = Memory {
1481 id: "m1".to_string(),
1482 tier: Tier::Mid,
1483 namespace: "test".to_string(),
1484 title: "Test".to_string(),
1485 content: "a".repeat(100),
1486 tags: vec![],
1487 priority: 5,
1488 confidence: 1.0,
1489 source: "test".to_string(),
1490 access_count: 0,
1491 created_at: "2026-01-01T00:00:00Z".to_string(),
1492 updated_at: "2026-01-01T00:00:00Z".to_string(),
1493 last_accessed_at: None,
1494 expires_at: None,
1495 metadata: serde_json::json!({}),
1496 };
1497
1498 db::insert(&conn, &mem).unwrap();
1500
1501 let tags = vec!["test-tag".to_string()];
1506 match persist_auto_tags(&conn, &mem, &tags) {
1507 Ok(_) => {
1508 let batch = db::list(&conn, None, None, 10, 0, None, None, None, None, None).unwrap();
1510 let updated = batch.iter().find(|m| m.id == mem.id).unwrap();
1511 assert!(updated.metadata.get("auto_tags").is_some());
1512 }
1513 Err(e) => {
1514 assert!(!e.to_string().is_empty());
1516 }
1517 }
1518}
1519
1520#[test]
1521fn consolidate_pair_skips_when_namespaces_disagree() {
1522 let tmp = tempfile::NamedTempFile::new().unwrap();
1526 let conn = db::open(tmp.path()).unwrap();
1527
1528 let now = chrono::Utc::now().to_rfc3339();
1529 let mem1 = Memory {
1530 id: "m1".to_string(),
1531 tier: Tier::Mid,
1532 namespace: "ns1".to_string(),
1533 title: "Title 1".to_string(),
1534 content: "a".repeat(100),
1535 tags: vec![],
1536 priority: 5,
1537 confidence: 1.0,
1538 source: "test".to_string(),
1539 access_count: 0,
1540 created_at: now.clone(),
1541 updated_at: now.clone(),
1542 last_accessed_at: None,
1543 expires_at: None,
1544 metadata: serde_json::json!({}),
1545 };
1546
1547 let mem2 = Memory {
1548 id: "m2".to_string(),
1549 tier: Tier::Mid,
1550 namespace: "ns2".to_string(),
1551 title: "Title 2".to_string(),
1552 content: "b".repeat(100),
1553 tags: vec![],
1554 priority: 5,
1555 confidence: 1.0,
1556 source: "test".to_string(),
1557 access_count: 0,
1558 created_at: now.clone(),
1559 updated_at: now.clone(),
1560 last_accessed_at: None,
1561 expires_at: None,
1562 metadata: serde_json::json!({}),
1563 };
1564
1565 db::insert(&conn, &mem1).unwrap();
1566 db::insert(&conn, &mem2).unwrap();
1567
1568 let adj = adjacent_memory(&conn, &mem1).unwrap();
1570 assert!(adj.is_none());
1572}
1573
1574#[test]
1575fn priority_feedback_caps_at_priority_10() {
1576 let cfg = CuratorConfig {
1580 interval_secs: 3600,
1581 max_ops_per_cycle: 100,
1582 dry_run: false,
1583 include_namespaces: vec![],
1584 exclude_namespaces: vec![],
1585 };
1586 let cap = cfg.max_ops_per_cycle.saturating_mul(4);
1588 assert_eq!(cap, 400);
1589 assert!(cap <= usize::MAX / 10);
1590}
1591
1592#[test]
1593fn priority_feedback_floors_at_priority_1() {
1594 let cfg = CuratorConfig::default();
1596 assert!(cfg.max_ops_per_cycle > 0);
1597 let floored = 0_usize.saturating_add(1);
1600 assert_eq!(floored, 1);
1601}
1602
1603#[test]
1604fn cycle_aborts_on_database_error() {
1605 let tmp = tempfile::NamedTempFile::new().unwrap();
1608 let conn = db::open(tmp.path()).unwrap();
1609 let cfg = CuratorConfig::default();
1610
1611 let result = run_once(&conn, None, &cfg);
1613 assert!(result.is_ok());
1614 let report = result.unwrap();
1615 assert!(report.errors.iter().any(|e| e.contains("no LLM")));
1617}