1use std::collections::{HashMap, HashSet};
16use std::sync::Arc;
17use strum::IntoEnumIterator;
18use tracing::{debug, info, info_span};
19
20use crate::agent::{Agent, AgentId};
21use crate::context::{Context, ContextKey, Fact};
22use crate::effect::AgentEffect;
23use crate::error::ConvergeError;
24use crate::invariant::{Invariant, InvariantError, InvariantId, InvariantRegistry};
25
26pub trait StreamingCallback: Send + Sync {
40 fn on_cycle_start(&self, cycle: u32);
42
43 fn on_fact(&self, cycle: u32, fact: &Fact);
45
46 fn on_cycle_end(&self, cycle: u32, facts_added: usize);
48}
49
50#[derive(Debug, Clone)]
54pub struct Budget {
55 pub max_cycles: u32,
57 pub max_facts: u32,
59}
60
61impl Default for Budget {
62 fn default() -> Self {
63 Self {
64 max_cycles: 100,
65 max_facts: 10_000,
66 }
67 }
68}
69
70#[derive(Debug)]
72pub struct ConvergeResult {
73 pub context: Context,
75 pub cycles: u32,
77 pub converged: bool,
79}
80
81pub struct Engine {
85 agents: Vec<Box<dyn Agent>>,
87 index: HashMap<ContextKey, Vec<AgentId>>,
89 always_eligible: Vec<AgentId>,
91 next_id: u32,
93 budget: Budget,
95 invariants: InvariantRegistry,
97 streaming_callback: Option<Arc<dyn StreamingCallback>>,
99}
100
101impl Default for Engine {
102 fn default() -> Self {
103 Self::new()
104 }
105}
106
107impl Engine {
108 #[must_use]
110 pub fn new() -> Self {
111 Self {
112 agents: Vec::new(),
113 index: HashMap::new(),
114 always_eligible: Vec::new(),
115 next_id: 0,
116 budget: Budget::default(),
117 invariants: InvariantRegistry::new(),
118 streaming_callback: None,
119 }
120 }
121
122 #[must_use]
124 pub fn with_budget(budget: Budget) -> Self {
125 Self {
126 budget,
127 ..Self::new()
128 }
129 }
130
131 pub fn set_budget(&mut self, budget: Budget) {
133 self.budget = budget;
134 }
135
136 pub fn set_streaming(&mut self, callback: Arc<dyn StreamingCallback>) {
166 self.streaming_callback = Some(callback);
167 }
168
169 pub fn clear_streaming(&mut self) {
171 self.streaming_callback = None;
172 }
173
174 pub fn register_invariant(&mut self, invariant: impl Invariant + 'static) -> InvariantId {
181 let name = invariant.name().to_string();
182 let class = invariant.class();
183 let id = self.invariants.register(invariant);
184 debug!(invariant = %name, ?class, ?id, "Registered invariant");
185 id
186 }
187
188 pub fn register(&mut self, agent: impl Agent + 'static) -> AgentId {
193 let id = AgentId(self.next_id);
194 self.next_id += 1;
195
196 let name = agent.name().to_string();
197 let deps: Vec<ContextKey> = agent.dependencies().to_vec();
198
199 if deps.is_empty() {
201 self.always_eligible.push(id);
203 } else {
204 for &key in &deps {
205 self.index.entry(key).or_default().push(id);
206 }
207 }
208
209 self.agents.push(Box::new(agent));
210 debug!(agent = %name, ?id, ?deps, "Registered agent");
211 id
212 }
213
214 #[must_use]
216 pub fn agent_count(&self) -> usize {
217 self.agents.len()
218 }
219
220 pub fn run(&mut self, mut context: Context) -> Result<ConvergeResult, ConvergeError> {
243 let _span = info_span!("engine_run").entered();
244 let mut cycles: u32 = 0;
245
246 let mut dirty_keys: Vec<ContextKey> = context.all_keys();
249
250 loop {
251 cycles += 1;
252 let _cycle_span = info_span!("convergence_cycle", cycle = cycles).entered();
253 info!(cycle = cycles, "Starting convergence cycle");
254
255 if let Some(ref cb) = self.streaming_callback {
257 cb.on_cycle_start(cycles);
258 }
259
260 if cycles > self.budget.max_cycles {
262 return Err(ConvergeError::BudgetExhausted {
263 kind: format!("max_cycles ({})", self.budget.max_cycles),
264 });
265 }
266
267 let eligible = {
269 let _span = info_span!("eligible_agents").entered();
270 let e = self.find_eligible(&context, &dirty_keys);
271 info!(count = e.len(), "Found eligible agents");
272 e
273 };
274
275 if eligible.is_empty() {
276 info!("No more eligible agents. Convergence reached.");
277 if let Some(ref cb) = self.streaming_callback {
279 cb.on_cycle_end(cycles, 0);
280 }
281 if let Err(e) = self.invariants.check_acceptance(&context) {
283 self.emit_diagnostic(&mut context, &e);
284 return Err(ConvergeError::InvariantViolation {
285 name: e.invariant_name,
286 class: e.class,
287 reason: e.violation.reason,
288 context: Box::new(context),
289 });
290 }
291
292 return Ok(ConvergeResult {
293 context,
294 cycles,
295 converged: true,
296 });
297 }
298
299 let effects = {
301 let _span = info_span!("execute_agents", count = eligible.len()).entered();
302 #[allow(deprecated)]
303 let eff = self.execute_agents(&context, &eligible);
304 info!(count = eff.len(), "Executed agents");
305 eff
306 };
307
308 let (new_dirty_keys, facts_added) = {
310 let _span = info_span!("merge_effects", count = effects.len()).entered();
311 let (d, count) = self.merge_effects(&mut context, effects, cycles)?;
312 info!(count = d.len(), "Merged effects");
313 (d, count)
314 };
315 dirty_keys = new_dirty_keys;
316
317 if let Some(ref cb) = self.streaming_callback {
319 cb.on_cycle_end(cycles, facts_added);
320 }
321
322 if let Err(e) = self.invariants.check_structural(&context) {
325 self.emit_diagnostic(&mut context, &e);
326 return Err(ConvergeError::InvariantViolation {
327 name: e.invariant_name,
328 class: e.class,
329 reason: e.violation.reason,
330 context: Box::new(context),
331 });
332 }
333
334 if dirty_keys.is_empty() {
336 if let Err(e) = self.invariants.check_acceptance(&context) {
338 self.emit_diagnostic(&mut context, &e);
339 return Err(ConvergeError::InvariantViolation {
340 name: e.invariant_name,
341 class: e.class,
342 reason: e.violation.reason,
343 context: Box::new(context),
344 });
345 }
346
347 return Ok(ConvergeResult {
348 context,
349 cycles,
350 converged: true,
351 });
352 }
353
354 if let Err(e) = self.invariants.check_semantic(&context) {
357 self.emit_diagnostic(&mut context, &e);
358 return Err(ConvergeError::InvariantViolation {
359 name: e.invariant_name,
360 class: e.class,
361 reason: e.violation.reason,
362 context: Box::new(context),
363 });
364 }
365
366 let fact_count = self.count_facts(&context);
368 if fact_count > self.budget.max_facts {
369 return Err(ConvergeError::BudgetExhausted {
370 kind: format!("max_facts ({} > {})", fact_count, self.budget.max_facts),
371 });
372 }
373 }
374 }
375
376 fn find_eligible(&self, context: &Context, dirty_keys: &[ContextKey]) -> Vec<AgentId> {
378 let mut candidates: HashSet<AgentId> = HashSet::new();
379
380 let unique_dirty: HashSet<&ContextKey> = dirty_keys.iter().collect();
382
383 for key in unique_dirty {
385 if let Some(ids) = self.index.get(key) {
386 candidates.extend(ids);
387 }
388 }
389
390 candidates.extend(&self.always_eligible);
392
393 let mut eligible: Vec<AgentId> = candidates
395 .into_iter()
396 .filter(|&id| {
397 let agent = &self.agents[id.0 as usize];
398 agent.accepts(context)
399 })
400 .collect();
401
402 eligible.sort();
404 eligible
405 }
406
407 #[deprecated(
415 since = "2.0.0",
416 note = "Use converge-runtime with Executor trait for parallel execution"
417 )]
418 fn execute_agents(
419 &self,
420 context: &Context,
421 eligible: &[AgentId],
422 ) -> Vec<(AgentId, AgentEffect)> {
423 eligible
424 .iter()
425 .map(|&id| {
426 let agent = &self.agents[id.0 as usize];
427 let effect = agent.execute(context);
428 (id, effect)
429 })
430 .collect()
431 }
432
433 fn merge_effects(
437 &self,
438 context: &mut Context,
439 mut effects: Vec<(AgentId, AgentEffect)>,
440 cycle: u32,
441 ) -> Result<(Vec<ContextKey>, usize), ConvergeError> {
442 effects.sort_by_key(|(id, _)| *id);
444
445 context.clear_dirty();
446 let mut facts_added = 0usize;
447
448 for (id, effect) in effects {
449 for fact in effect.facts {
451 if let Some(ref cb) = self.streaming_callback {
453 cb.on_fact(cycle, &fact);
454 }
455 if let Err(e) = context.add_fact(fact) {
456 return match e {
457 ConvergeError::Conflict {
458 id, existing, new, ..
459 } => Err(ConvergeError::Conflict {
460 id,
461 existing,
462 new,
463 context: Box::new(context.clone()),
464 }),
465 _ => Err(e),
466 };
467 }
468 facts_added += 1;
469 }
470
471 for proposal in effect.proposals {
473 let _span =
474 info_span!("validate_proposal", agent = %id, proposal = %proposal.id).entered();
475 match Fact::try_from(proposal) {
476 Ok(fact) => {
477 info!(agent = %id, fact = %fact.id, "Proposal promoted to fact");
478 if let Some(ref cb) = self.streaming_callback {
480 cb.on_fact(cycle, &fact);
481 }
482 if let Err(e) = context.add_fact(fact) {
483 return match e {
484 ConvergeError::Conflict {
485 id, existing, new, ..
486 } => Err(ConvergeError::Conflict {
487 id,
488 existing,
489 new,
490 context: Box::new(context.clone()),
491 }),
492 _ => Err(e),
493 };
494 }
495 facts_added += 1;
496 }
497 Err(e) => {
498 info!(agent = %id, reason = %e, "Proposal rejected");
499 }
501 }
502 }
503 }
504
505 Ok((context.dirty_keys().to_vec(), facts_added))
506 }
507
508 #[allow(clippy::unused_self)] #[allow(clippy::cast_possible_truncation)] fn count_facts(&self, context: &Context) -> u32 {
512 ContextKey::iter()
513 .map(|key| context.get(key).len() as u32)
514 .sum()
515 }
516
517 fn emit_diagnostic(&self, context: &mut Context, err: &InvariantError) {
519 let _ = self; let fact = Fact {
521 key: ContextKey::Diagnostic,
522 id: format!("violation:{}:{}", err.invariant_name, context.version()),
523 content: format!(
524 "{:?} invariant '{}' violated: {}",
525 err.class, err.invariant_name, err.violation.reason
526 ),
527 };
528 let _ = context.add_fact(fact);
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use crate::context::Fact;
536 use tracing_test::traced_test;
537
538 #[test]
539 #[traced_test]
540 fn engine_emits_tracing_logs() {
541 let mut engine = Engine::new();
542 engine.register(SeedAgent);
543 let _ = engine.run(Context::new()).unwrap();
544
545 assert!(logs_contain("Starting convergence cycle"));
546 assert!(logs_contain("Found eligible agents"));
547 }
548
549 struct SeedAgent;
551
552 impl Agent for SeedAgent {
553 fn name(&self) -> &'static str {
554 "SeedAgent"
555 }
556
557 fn dependencies(&self) -> &[ContextKey] {
558 &[] }
560
561 fn accepts(&self, ctx: &Context) -> bool {
562 !ctx.has(ContextKey::Seeds)
563 }
564
565 fn execute(&self, _ctx: &Context) -> AgentEffect {
566 AgentEffect::with_fact(Fact {
567 key: ContextKey::Seeds,
568 id: "seed-1".into(),
569 content: "initial seed".into(),
570 })
571 }
572 }
573
574 struct ReactOnceAgent;
576
577 impl Agent for ReactOnceAgent {
578 fn name(&self) -> &'static str {
579 "ReactOnceAgent"
580 }
581
582 fn dependencies(&self) -> &[ContextKey] {
583 &[ContextKey::Seeds]
584 }
585
586 fn accepts(&self, ctx: &Context) -> bool {
587 ctx.has(ContextKey::Seeds) && !ctx.has(ContextKey::Hypotheses)
588 }
589
590 fn execute(&self, _ctx: &Context) -> AgentEffect {
591 AgentEffect::with_fact(Fact {
592 key: ContextKey::Hypotheses,
593 id: "hyp-1".into(),
594 content: "derived from seed".into(),
595 })
596 }
597 }
598
599 #[test]
600 fn engine_converges_with_single_agent() {
601 let mut engine = Engine::new();
602 engine.register(SeedAgent);
603
604 let result = engine.run(Context::new()).expect("should converge");
605
606 assert!(result.converged);
607 assert_eq!(result.cycles, 2); assert!(result.context.has(ContextKey::Seeds));
609 }
610
611 #[test]
612 fn engine_converges_with_chain() {
613 let mut engine = Engine::new();
614 engine.register(SeedAgent);
615 engine.register(ReactOnceAgent);
616
617 let result = engine.run(Context::new()).expect("should converge");
618
619 assert!(result.converged);
620 assert!(result.context.has(ContextKey::Seeds));
621 assert!(result.context.has(ContextKey::Hypotheses));
622 }
623
624 #[test]
625 fn engine_converges_deterministically() {
626 let run = || {
627 let mut engine = Engine::new();
628 engine.register(SeedAgent);
629 engine.register(ReactOnceAgent);
630 engine.run(Context::new()).expect("should converge")
631 };
632
633 let r1 = run();
634 let r2 = run();
635
636 assert_eq!(r1.cycles, r2.cycles);
637 assert_eq!(
638 r1.context.get(ContextKey::Seeds),
639 r2.context.get(ContextKey::Seeds)
640 );
641 assert_eq!(
642 r1.context.get(ContextKey::Hypotheses),
643 r2.context.get(ContextKey::Hypotheses)
644 );
645 }
646
647 #[test]
648 fn engine_respects_cycle_budget() {
649 use std::sync::atomic::{AtomicU32, Ordering};
650
651 struct InfiniteAgent {
653 counter: AtomicU32,
654 }
655
656 impl Agent for InfiniteAgent {
657 fn name(&self) -> &'static str {
658 "InfiniteAgent"
659 }
660
661 fn dependencies(&self) -> &[ContextKey] {
662 &[]
663 }
664
665 fn accepts(&self, _ctx: &Context) -> bool {
666 true }
668
669 fn execute(&self, _ctx: &Context) -> AgentEffect {
670 let n = self.counter.fetch_add(1, Ordering::SeqCst);
671 AgentEffect::with_fact(Fact {
672 key: ContextKey::Seeds,
673 id: format!("inf-{n}"),
674 content: "infinite".into(),
675 })
676 }
677 }
678
679 let mut engine = Engine::with_budget(Budget {
680 max_cycles: 5,
681 max_facts: 1000,
682 });
683 engine.register(InfiniteAgent {
684 counter: AtomicU32::new(0),
685 });
686
687 let result = engine.run(Context::new());
688
689 assert!(result.is_err());
690 let err = result.unwrap_err();
691 assert!(matches!(err, ConvergeError::BudgetExhausted { .. }));
692 }
693
694 #[test]
695 fn engine_respects_fact_budget() {
696 struct FloodAgent;
698
699 impl Agent for FloodAgent {
700 fn name(&self) -> &'static str {
701 "FloodAgent"
702 }
703
704 fn dependencies(&self) -> &[ContextKey] {
705 &[]
706 }
707
708 fn accepts(&self, _ctx: &Context) -> bool {
709 true
710 }
711
712 fn execute(&self, ctx: &Context) -> AgentEffect {
713 let n = ctx.get(ContextKey::Seeds).len();
714 AgentEffect::with_facts(
715 (0..10)
716 .map(|i| Fact {
717 key: ContextKey::Seeds,
718 id: format!("flood-{n}-{i}"),
719 content: "flood".into(),
720 })
721 .collect(),
722 )
723 }
724 }
725
726 let mut engine = Engine::with_budget(Budget {
727 max_cycles: 100,
728 max_facts: 25,
729 });
730 engine.register(FloodAgent);
731
732 let result = engine.run(Context::new());
733
734 assert!(result.is_err());
735 let err = result.unwrap_err();
736 assert!(matches!(err, ConvergeError::BudgetExhausted { .. }));
737 }
738
739 #[test]
740 fn dependency_index_filters_agents() {
741 struct StrategyAgent;
743
744 impl Agent for StrategyAgent {
745 fn name(&self) -> &'static str {
746 "StrategyAgent"
747 }
748
749 fn dependencies(&self) -> &[ContextKey] {
750 &[ContextKey::Strategies]
751 }
752
753 fn accepts(&self, _ctx: &Context) -> bool {
754 true
755 }
756
757 fn execute(&self, _ctx: &Context) -> AgentEffect {
758 AgentEffect::with_fact(Fact {
759 key: ContextKey::Constraints,
760 id: "constraint-1".into(),
761 content: "from strategy".into(),
762 })
763 }
764 }
765
766 let mut engine = Engine::new();
767 engine.register(SeedAgent); engine.register(StrategyAgent); let result = engine.run(Context::new()).expect("should converge");
771
772 assert!(result.context.has(ContextKey::Seeds));
775 assert!(!result.context.has(ContextKey::Constraints));
776 }
777
778 struct AlwaysAgent;
780
781 impl Agent for AlwaysAgent {
782 fn name(&self) -> &'static str {
783 "AlwaysAgent"
784 }
785
786 fn dependencies(&self) -> &[ContextKey] {
787 &[]
788 }
789
790 fn accepts(&self, _ctx: &Context) -> bool {
791 true
792 }
793
794 fn execute(&self, _ctx: &Context) -> AgentEffect {
795 AgentEffect::empty()
796 }
797 }
798
799 struct SeedWatcher;
801
802 impl Agent for SeedWatcher {
803 fn name(&self) -> &'static str {
804 "SeedWatcher"
805 }
806
807 fn dependencies(&self) -> &[ContextKey] {
808 &[ContextKey::Seeds]
809 }
810
811 fn accepts(&self, _ctx: &Context) -> bool {
812 true
813 }
814
815 fn execute(&self, _ctx: &Context) -> AgentEffect {
816 AgentEffect::empty()
817 }
818 }
819
820 #[test]
821 fn find_eligible_respects_dirty_keys() {
822 let mut engine = Engine::new();
823 let always_id = engine.register(AlwaysAgent);
824 let watcher_id = engine.register(SeedWatcher);
825 let ctx = Context::new();
826
827 let eligible = engine.find_eligible(&ctx, &[]);
828 assert_eq!(eligible, vec![always_id]);
829
830 let eligible = engine.find_eligible(&ctx, &[ContextKey::Seeds]);
831 assert_eq!(eligible, vec![always_id, watcher_id]);
832 }
833
834 struct MultiDepAgent;
836
837 impl Agent for MultiDepAgent {
838 fn name(&self) -> &'static str {
839 "MultiDepAgent"
840 }
841
842 fn dependencies(&self) -> &[ContextKey] {
843 &[ContextKey::Seeds, ContextKey::Hypotheses]
844 }
845
846 fn accepts(&self, _ctx: &Context) -> bool {
847 true
848 }
849
850 fn execute(&self, _ctx: &Context) -> AgentEffect {
851 AgentEffect::empty()
852 }
853 }
854
855 #[test]
856 fn find_eligible_deduplicates_agents() {
857 let mut engine = Engine::new();
858 let multi_id = engine.register(MultiDepAgent);
859 let ctx = Context::new();
860
861 let eligible = engine.find_eligible(&ctx, &[ContextKey::Seeds, ContextKey::Hypotheses]);
862 assert_eq!(eligible, vec![multi_id]);
863 }
864
865 struct NamedAgent {
867 name: &'static str,
868 fact_id: &'static str,
869 }
870
871 impl Agent for NamedAgent {
872 fn name(&self) -> &str {
873 self.name
874 }
875
876 fn dependencies(&self) -> &[ContextKey] {
877 &[]
878 }
879
880 fn accepts(&self, _ctx: &Context) -> bool {
881 true
882 }
883
884 fn execute(&self, _ctx: &Context) -> AgentEffect {
885 AgentEffect::with_fact(Fact {
886 key: ContextKey::Seeds,
887 id: self.fact_id.into(),
888 content: format!("emitted-by-{}", self.name),
889 })
890 }
891 }
892
893 #[test]
894 fn merge_effects_respect_agent_ordering() {
895 let mut engine = Engine::new();
896 let id_a = engine.register(NamedAgent {
897 name: "AgentA",
898 fact_id: "a",
899 });
900 let id_b = engine.register(NamedAgent {
901 name: "AgentB",
902 fact_id: "b",
903 });
904 let mut context = Context::new();
905
906 let effect_a = AgentEffect::with_fact(Fact {
907 key: ContextKey::Seeds,
908 id: "a".into(),
909 content: "first".into(),
910 });
911 let effect_b = AgentEffect::with_fact(Fact {
912 key: ContextKey::Seeds,
913 id: "b".into(),
914 content: "second".into(),
915 });
916
917 let (dirty, facts_added) = engine
919 .merge_effects(&mut context, vec![(id_b, effect_b), (id_a, effect_a)], 1)
920 .expect("should not conflict");
921
922 let seeds = context.get(ContextKey::Seeds);
923 assert_eq!(seeds.len(), 2);
924 assert_eq!(seeds[0].id, "a");
925 assert_eq!(seeds[1].id, "b");
926 assert_eq!(dirty, vec![ContextKey::Seeds, ContextKey::Seeds]);
927 assert_eq!(facts_added, 2);
928 }
929
930 use crate::invariant::{Invariant, InvariantClass, InvariantResult, Violation};
935
936 struct ForbidContent {
938 forbidden: &'static str,
939 }
940
941 impl Invariant for ForbidContent {
942 fn name(&self) -> &'static str {
943 "forbid_content"
944 }
945
946 fn class(&self) -> InvariantClass {
947 InvariantClass::Structural
948 }
949
950 fn check(&self, ctx: &Context) -> InvariantResult {
951 for fact in ctx.get(ContextKey::Seeds) {
952 if fact.content.contains(self.forbidden) {
953 return InvariantResult::Violated(Violation::with_facts(
954 format!("content contains '{}'", self.forbidden),
955 vec![fact.id.clone()],
956 ));
957 }
958 }
959 InvariantResult::Ok
960 }
961 }
962
963 struct RequireBalance;
965
966 impl Invariant for RequireBalance {
967 fn name(&self) -> &'static str {
968 "require_balance"
969 }
970
971 fn class(&self) -> InvariantClass {
972 InvariantClass::Semantic
973 }
974
975 fn check(&self, ctx: &Context) -> InvariantResult {
976 let seeds = ctx.get(ContextKey::Seeds).len();
977 let hyps = ctx.get(ContextKey::Hypotheses).len();
978 if seeds > 0 && hyps == 0 {
980 return InvariantResult::Violated(Violation::new(
981 "seeds exist but no hypotheses derived yet",
982 ));
983 }
984 InvariantResult::Ok
985 }
986 }
987
988 struct RequireMultipleSeeds;
990
991 impl Invariant for RequireMultipleSeeds {
992 fn name(&self) -> &'static str {
993 "require_multiple_seeds"
994 }
995
996 fn class(&self) -> InvariantClass {
997 InvariantClass::Acceptance
998 }
999
1000 fn check(&self, ctx: &Context) -> InvariantResult {
1001 let seeds = ctx.get(ContextKey::Seeds).len();
1002 if seeds < 2 {
1003 return InvariantResult::Violated(Violation::new(format!(
1004 "need at least 2 seeds, found {seeds}"
1005 )));
1006 }
1007 InvariantResult::Ok
1008 }
1009 }
1010
1011 #[test]
1012 fn structural_invariant_fails_immediately() {
1013 let mut engine = Engine::new();
1014 engine.register(SeedAgent);
1015 engine.register_invariant(ForbidContent {
1016 forbidden: "initial", });
1018
1019 let result = engine.run(Context::new());
1020
1021 assert!(result.is_err());
1022 let err = result.unwrap_err();
1023 match err {
1024 ConvergeError::InvariantViolation { name, class, .. } => {
1025 assert_eq!(name, "forbid_content");
1026 assert_eq!(class, InvariantClass::Structural);
1027 }
1028 _ => panic!("expected InvariantViolation, got {err:?}"),
1029 }
1030 }
1031
1032 #[test]
1033 fn semantic_invariant_blocks_convergence() {
1034 let mut engine = Engine::new();
1037 engine.register(SeedAgent);
1038 engine.register_invariant(RequireBalance);
1039
1040 let result = engine.run(Context::new());
1041
1042 assert!(result.is_err());
1043 let err = result.unwrap_err();
1044 match err {
1045 ConvergeError::InvariantViolation { name, class, .. } => {
1046 assert_eq!(name, "require_balance");
1047 assert_eq!(class, InvariantClass::Semantic);
1048 }
1049 _ => panic!("expected InvariantViolation, got {err:?}"),
1050 }
1051 }
1052
1053 #[test]
1054 fn acceptance_invariant_rejects_result() {
1055 let mut engine = Engine::new();
1057 engine.register(SeedAgent);
1058 engine.register(ReactOnceAgent); engine.register_invariant(RequireMultipleSeeds);
1060
1061 let result = engine.run(Context::new());
1062
1063 assert!(result.is_err());
1064 let err = result.unwrap_err();
1065 match err {
1066 ConvergeError::InvariantViolation { name, class, .. } => {
1067 assert_eq!(name, "require_multiple_seeds");
1068 assert_eq!(class, InvariantClass::Acceptance);
1069 }
1070 _ => panic!("expected InvariantViolation, got {err:?}"),
1071 }
1072 }
1073
1074 #[test]
1075 fn all_invariant_classes_pass_when_satisfied() {
1076 struct TwoSeedAgent;
1078
1079 impl Agent for TwoSeedAgent {
1080 fn name(&self) -> &'static str {
1081 "TwoSeedAgent"
1082 }
1083
1084 fn dependencies(&self) -> &[ContextKey] {
1085 &[]
1086 }
1087
1088 fn accepts(&self, ctx: &Context) -> bool {
1089 !ctx.has(ContextKey::Seeds)
1090 }
1091
1092 fn execute(&self, _ctx: &Context) -> AgentEffect {
1093 AgentEffect::with_facts(vec![
1094 Fact {
1095 key: ContextKey::Seeds,
1096 id: "seed-1".into(),
1097 content: "good content".into(),
1098 },
1099 Fact {
1100 key: ContextKey::Seeds,
1101 id: "seed-2".into(),
1102 content: "more good content".into(),
1103 },
1104 ])
1105 }
1106 }
1107
1108 struct DeriverAgent;
1110
1111 impl Agent for DeriverAgent {
1112 fn name(&self) -> &'static str {
1113 "DeriverAgent"
1114 }
1115
1116 fn dependencies(&self) -> &[ContextKey] {
1117 &[ContextKey::Seeds]
1118 }
1119
1120 fn accepts(&self, ctx: &Context) -> bool {
1121 ctx.has(ContextKey::Seeds) && !ctx.has(ContextKey::Hypotheses)
1122 }
1123
1124 fn execute(&self, _ctx: &Context) -> AgentEffect {
1125 AgentEffect::with_fact(Fact {
1126 key: ContextKey::Hypotheses,
1127 id: "hyp-1".into(),
1128 content: "derived".into(),
1129 })
1130 }
1131 }
1132
1133 struct AlwaysSatisfied;
1135
1136 impl Invariant for AlwaysSatisfied {
1137 fn name(&self) -> &'static str {
1138 "always_satisfied"
1139 }
1140
1141 fn class(&self) -> InvariantClass {
1142 InvariantClass::Semantic
1143 }
1144
1145 fn check(&self, _ctx: &Context) -> InvariantResult {
1146 InvariantResult::Ok
1147 }
1148 }
1149
1150 let mut engine = Engine::new();
1151 engine.register(TwoSeedAgent);
1152 engine.register(DeriverAgent);
1153
1154 engine.register_invariant(ForbidContent {
1156 forbidden: "forbidden", });
1158 engine.register_invariant(AlwaysSatisfied); engine.register_invariant(RequireMultipleSeeds);
1160
1161 let result = engine.run(Context::new());
1162
1163 assert!(result.is_ok());
1164 let result = result.unwrap();
1165 assert!(result.converged);
1166 assert_eq!(result.context.get(ContextKey::Seeds).len(), 2);
1167 assert!(result.context.has(ContextKey::Hypotheses));
1168 }
1169}