1use std::collections::{HashMap, HashSet};
16use rayon::prelude::*;
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
26#[derive(Debug, Clone)]
30pub struct Budget {
31 pub max_cycles: u32,
33 pub max_facts: u32,
35}
36
37impl Default for Budget {
38 fn default() -> Self {
39 Self {
40 max_cycles: 100,
41 max_facts: 10_000,
42 }
43 }
44}
45
46#[derive(Debug)]
48pub struct ConvergeResult {
49 pub context: Context,
51 pub cycles: u32,
53 pub converged: bool,
55}
56
57pub struct Engine {
61 agents: Vec<Box<dyn Agent>>,
63 index: HashMap<ContextKey, Vec<AgentId>>,
65 always_eligible: Vec<AgentId>,
67 next_id: u32,
69 budget: Budget,
71 invariants: InvariantRegistry,
73}
74
75impl Default for Engine {
76 fn default() -> Self {
77 Self::new()
78 }
79}
80
81impl Engine {
82 #[must_use]
84 pub fn new() -> Self {
85 Self {
86 agents: Vec::new(),
87 index: HashMap::new(),
88 always_eligible: Vec::new(),
89 next_id: 0,
90 budget: Budget::default(),
91 invariants: InvariantRegistry::new(),
92 }
93 }
94
95 #[must_use]
97 pub fn with_budget(budget: Budget) -> Self {
98 Self {
99 budget,
100 ..Self::new()
101 }
102 }
103
104 pub fn set_budget(&mut self, budget: Budget) {
106 self.budget = budget;
107 }
108
109 pub fn register_invariant(&mut self, invariant: impl Invariant + 'static) -> InvariantId {
116 let name = invariant.name().to_string();
117 let class = invariant.class();
118 let id = self.invariants.register(invariant);
119 debug!(invariant = %name, ?class, ?id, "Registered invariant");
120 id
121 }
122
123 pub fn register(&mut self, agent: impl Agent + 'static) -> AgentId {
128 let id = AgentId(self.next_id);
129 self.next_id += 1;
130
131 let name = agent.name().to_string();
132 let deps: Vec<ContextKey> = agent.dependencies().to_vec();
133
134 if deps.is_empty() {
136 self.always_eligible.push(id);
138 } else {
139 for &key in &deps {
140 self.index.entry(key).or_default().push(id);
141 }
142 }
143
144 self.agents.push(Box::new(agent));
145 debug!(agent = %name, ?id, ?deps, "Registered agent");
146 id
147 }
148
149 #[must_use]
151 pub fn agent_count(&self) -> usize {
152 self.agents.len()
153 }
154
155 pub fn run(&mut self, mut context: Context) -> Result<ConvergeResult, ConvergeError> {
178 let _span = info_span!("engine_run").entered();
179 let mut cycles: u32 = 0;
180
181 let mut dirty_keys: Vec<ContextKey> = context.all_keys();
184
185 loop {
186 cycles += 1;
187 let _cycle_span = info_span!("convergence_cycle", cycle = cycles).entered();
188 info!(cycle = cycles, "Starting convergence cycle");
189
190 if cycles > self.budget.max_cycles {
192 return Err(ConvergeError::BudgetExhausted {
193 kind: format!("max_cycles ({})", self.budget.max_cycles),
194 });
195 }
196
197 let eligible = {
199 let _span = info_span!("eligible_agents").entered();
200 let e = self.find_eligible(&context, &dirty_keys);
201 info!(count = e.len(), "Found eligible agents");
202 e
203 };
204
205 if eligible.is_empty() {
206 info!("No more eligible agents. Convergence reached.");
207 if let Err(e) = self.invariants.check_acceptance(&context) {
209 self.emit_diagnostic(&mut context, &e);
210 return Err(ConvergeError::InvariantViolation {
211 name: e.invariant_name,
212 class: e.class,
213 reason: e.violation.reason,
214 context: Box::new(context),
215 });
216 }
217
218 return Ok(ConvergeResult {
219 context,
220 cycles,
221 converged: true,
222 });
223 }
224
225 let effects = {
227 let _span = info_span!("execute_agents", count = eligible.len()).entered();
228 let eff = self.execute_agents(&context, &eligible);
229 info!(count = eff.len(), "Executed agents");
230 eff
231 };
232
233 dirty_keys = {
235 let _span = info_span!("merge_effects", count = effects.len()).entered();
236 let d = self.merge_effects(&mut context, effects)?;
237 info!(count = d.len(), "Merged effects");
238 d
239 };
240
241 if let Err(e) = self.invariants.check_structural(&context) {
244 self.emit_diagnostic(&mut context, &e);
245 return Err(ConvergeError::InvariantViolation {
246 name: e.invariant_name,
247 class: e.class,
248 reason: e.violation.reason,
249 context: Box::new(context),
250 });
251 }
252
253 if dirty_keys.is_empty() {
255 if let Err(e) = self.invariants.check_acceptance(&context) {
257 self.emit_diagnostic(&mut context, &e);
258 return Err(ConvergeError::InvariantViolation {
259 name: e.invariant_name,
260 class: e.class,
261 reason: e.violation.reason,
262 context: Box::new(context),
263 });
264 }
265
266 return Ok(ConvergeResult {
267 context,
268 cycles,
269 converged: true,
270 });
271 }
272
273 if let Err(e) = self.invariants.check_semantic(&context) {
276 self.emit_diagnostic(&mut context, &e);
277 return Err(ConvergeError::InvariantViolation {
278 name: e.invariant_name,
279 class: e.class,
280 reason: e.violation.reason,
281 context: Box::new(context),
282 });
283 }
284
285 let fact_count = self.count_facts(&context);
287 if fact_count > self.budget.max_facts {
288 return Err(ConvergeError::BudgetExhausted {
289 kind: format!("max_facts ({} > {})", fact_count, self.budget.max_facts),
290 });
291 }
292 }
293 }
294
295 fn find_eligible(&self, context: &Context, dirty_keys: &[ContextKey]) -> Vec<AgentId> {
297 let mut candidates: HashSet<AgentId> = HashSet::new();
298
299 let unique_dirty: HashSet<&ContextKey> = dirty_keys.iter().collect();
301
302 for key in unique_dirty {
304 if let Some(ids) = self.index.get(key) {
305 candidates.extend(ids);
306 }
307 }
308
309 candidates.extend(&self.always_eligible);
311
312 let mut eligible: Vec<AgentId> = candidates
314 .into_iter()
315 .filter(|&id| {
316 let agent = &self.agents[id.0 as usize];
317 agent.accepts(context)
318 })
319 .collect();
320
321 eligible.sort();
323 eligible
324 }
325
326 fn execute_agents(
332 &self,
333 context: &Context,
334 eligible: &[AgentId],
335 ) -> Vec<(AgentId, AgentEffect)> {
336 eligible
337 .par_iter()
338 .map(|&id| {
339 let agent = &self.agents[id.0 as usize];
340 let effect = agent.execute(context);
341 (id, effect)
342 })
343 .collect()
344 }
345
346 #[allow(clippy::unused_self)] fn merge_effects(
351 &self,
352 context: &mut Context,
353 mut effects: Vec<(AgentId, AgentEffect)>,
354 ) -> Result<Vec<ContextKey>, ConvergeError> {
355 effects.sort_by_key(|(id, _)| *id);
357
358 context.clear_dirty();
359
360 for (id, effect) in effects {
361 for fact in effect.facts {
363 if let Err(e) = context.add_fact(fact) {
364 return match e {
365 ConvergeError::Conflict { id, existing, new, .. } => Err(ConvergeError::Conflict {
366 id,
367 existing,
368 new,
369 context: Box::new(context.clone()),
370 }),
371 _ => Err(e),
372 };
373 }
374 }
375
376 for proposal in effect.proposals {
378 let _span = info_span!("validate_proposal", agent = %id, proposal = %proposal.id).entered();
379 match Fact::try_from(proposal) {
380 Ok(fact) => {
381 info!(agent = %id, fact = %fact.id, "Proposal promoted to fact");
382 if let Err(e) = context.add_fact(fact) {
383 return match e {
384 ConvergeError::Conflict { id, existing, new, .. } => Err(ConvergeError::Conflict {
385 id,
386 existing,
387 new,
388 context: Box::new(context.clone()),
389 }),
390 _ => Err(e),
391 };
392 }
393 }
394 Err(e) => {
395 info!(agent = %id, reason = %e, "Proposal rejected");
396 }
398 }
399 }
400 }
401
402 Ok(context.dirty_keys().to_vec())
403 }
404
405 #[allow(clippy::unused_self)] #[allow(clippy::cast_possible_truncation)] fn count_facts(&self, context: &Context) -> u32 {
409 ContextKey::iter().map(|key| context.get(key).len() as u32).sum()
410 }
411
412 fn emit_diagnostic(&self, context: &mut Context, err: &InvariantError) {
414 let _ = self; let fact = Fact {
416 key: ContextKey::Diagnostic,
417 id: format!("violation:{}:{}", err.invariant_name, context.version()),
418 content: format!(
419 "{:?} invariant '{}' violated: {}",
420 err.class, err.invariant_name, err.violation.reason
421 ),
422 };
423 let _ = context.add_fact(fact);
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use crate::context::Fact;
431 use tracing_test::traced_test;
432
433 #[test]
434 #[traced_test]
435 fn engine_emits_tracing_logs() {
436 let mut engine = Engine::new();
437 engine.register(SeedAgent);
438 let _ = engine.run(Context::new()).unwrap();
439
440 assert!(logs_contain("Starting convergence cycle"));
441 assert!(logs_contain("Found eligible agents"));
442 }
443
444 struct SeedAgent;
446
447 impl Agent for SeedAgent {
448 fn name(&self) -> &str {
449 "SeedAgent"
450 }
451
452 fn dependencies(&self) -> &[ContextKey] {
453 &[] }
455
456 fn accepts(&self, ctx: &Context) -> bool {
457 !ctx.has(ContextKey::Seeds)
458 }
459
460 fn execute(&self, _ctx: &Context) -> AgentEffect {
461 AgentEffect::with_fact(Fact {
462 key: ContextKey::Seeds,
463 id: "seed-1".into(),
464 content: "initial seed".into(),
465 })
466 }
467 }
468
469 struct ReactOnceAgent;
471
472 impl Agent for ReactOnceAgent {
473 fn name(&self) -> &str {
474 "ReactOnceAgent"
475 }
476
477 fn dependencies(&self) -> &[ContextKey] {
478 &[ContextKey::Seeds]
479 }
480
481 fn accepts(&self, ctx: &Context) -> bool {
482 ctx.has(ContextKey::Seeds) && !ctx.has(ContextKey::Hypotheses)
483 }
484
485 fn execute(&self, _ctx: &Context) -> AgentEffect {
486 AgentEffect::with_fact(Fact {
487 key: ContextKey::Hypotheses,
488 id: "hyp-1".into(),
489 content: "derived from seed".into(),
490 })
491 }
492 }
493
494 #[test]
495 fn engine_converges_with_single_agent() {
496 let mut engine = Engine::new();
497 engine.register(SeedAgent);
498
499 let result = engine.run(Context::new()).expect("should converge");
500
501 assert!(result.converged);
502 assert_eq!(result.cycles, 2); assert!(result.context.has(ContextKey::Seeds));
504 }
505
506 #[test]
507 fn engine_converges_with_chain() {
508 let mut engine = Engine::new();
509 engine.register(SeedAgent);
510 engine.register(ReactOnceAgent);
511
512 let result = engine.run(Context::new()).expect("should converge");
513
514 assert!(result.converged);
515 assert!(result.context.has(ContextKey::Seeds));
516 assert!(result.context.has(ContextKey::Hypotheses));
517 }
518
519 #[test]
520 fn engine_converges_deterministically() {
521 let run = || {
522 let mut engine = Engine::new();
523 engine.register(SeedAgent);
524 engine.register(ReactOnceAgent);
525 engine.run(Context::new()).expect("should converge")
526 };
527
528 let r1 = run();
529 let r2 = run();
530
531 assert_eq!(r1.cycles, r2.cycles);
532 assert_eq!(
533 r1.context.get(ContextKey::Seeds),
534 r2.context.get(ContextKey::Seeds)
535 );
536 assert_eq!(
537 r1.context.get(ContextKey::Hypotheses),
538 r2.context.get(ContextKey::Hypotheses)
539 );
540 }
541
542 #[test]
543 fn engine_respects_cycle_budget() {
544 use std::sync::atomic::{AtomicU32, Ordering};
545
546 struct InfiniteAgent {
548 counter: AtomicU32,
549 }
550
551 impl Agent for InfiniteAgent {
552 fn name(&self) -> &str {
553 "InfiniteAgent"
554 }
555
556 fn dependencies(&self) -> &[ContextKey] {
557 &[]
558 }
559
560 fn accepts(&self, _ctx: &Context) -> bool {
561 true }
563
564 fn execute(&self, _ctx: &Context) -> AgentEffect {
565 let n = self.counter.fetch_add(1, Ordering::SeqCst);
566 AgentEffect::with_fact(Fact {
567 key: ContextKey::Seeds,
568 id: format!("inf-{n}"),
569 content: "infinite".into(),
570 })
571 }
572 }
573
574 let mut engine = Engine::with_budget(Budget {
575 max_cycles: 5,
576 max_facts: 1000,
577 });
578 engine.register(InfiniteAgent {
579 counter: AtomicU32::new(0),
580 });
581
582 let result = engine.run(Context::new());
583
584 assert!(result.is_err());
585 let err = result.unwrap_err();
586 assert!(matches!(err, ConvergeError::BudgetExhausted { .. }));
587 }
588
589 #[test]
590 fn engine_respects_fact_budget() {
591 struct FloodAgent;
593
594 impl Agent for FloodAgent {
595 fn name(&self) -> &str {
596 "FloodAgent"
597 }
598
599 fn dependencies(&self) -> &[ContextKey] {
600 &[]
601 }
602
603 fn accepts(&self, _ctx: &Context) -> bool {
604 true
605 }
606
607 fn execute(&self, ctx: &Context) -> AgentEffect {
608 let n = ctx.get(ContextKey::Seeds).len();
609 AgentEffect::with_facts(
610 (0..10)
611 .map(|i| Fact {
612 key: ContextKey::Seeds,
613 id: format!("flood-{}-{i}", n),
614 content: "flood".into(),
615 })
616 .collect(),
617 )
618 }
619 }
620
621 let mut engine = Engine::with_budget(Budget {
622 max_cycles: 100,
623 max_facts: 25,
624 });
625 engine.register(FloodAgent);
626
627 let result = engine.run(Context::new());
628
629 assert!(result.is_err());
630 let err = result.unwrap_err();
631 assert!(matches!(err, ConvergeError::BudgetExhausted { .. }));
632 }
633
634 #[test]
635 fn dependency_index_filters_agents() {
636 struct StrategyAgent;
638
639 impl Agent for StrategyAgent {
640 fn name(&self) -> &str {
641 "StrategyAgent"
642 }
643
644 fn dependencies(&self) -> &[ContextKey] {
645 &[ContextKey::Strategies]
646 }
647
648 fn accepts(&self, _ctx: &Context) -> bool {
649 true
650 }
651
652 fn execute(&self, _ctx: &Context) -> AgentEffect {
653 AgentEffect::with_fact(Fact {
654 key: ContextKey::Constraints,
655 id: "constraint-1".into(),
656 content: "from strategy".into(),
657 })
658 }
659 }
660
661 let mut engine = Engine::new();
662 engine.register(SeedAgent); engine.register(StrategyAgent); let result = engine.run(Context::new()).expect("should converge");
666
667 assert!(result.context.has(ContextKey::Seeds));
670 assert!(!result.context.has(ContextKey::Constraints));
671 }
672
673 struct AlwaysAgent;
675
676 impl Agent for AlwaysAgent {
677 fn name(&self) -> &str {
678 "AlwaysAgent"
679 }
680
681 fn dependencies(&self) -> &[ContextKey] {
682 &[]
683 }
684
685 fn accepts(&self, _ctx: &Context) -> bool {
686 true
687 }
688
689 fn execute(&self, _ctx: &Context) -> AgentEffect {
690 AgentEffect::empty()
691 }
692 }
693
694 struct SeedWatcher;
696
697 impl Agent for SeedWatcher {
698 fn name(&self) -> &str {
699 "SeedWatcher"
700 }
701
702 fn dependencies(&self) -> &[ContextKey] {
703 &[ContextKey::Seeds]
704 }
705
706 fn accepts(&self, _ctx: &Context) -> bool {
707 true
708 }
709
710 fn execute(&self, _ctx: &Context) -> AgentEffect {
711 AgentEffect::empty()
712 }
713 }
714
715 #[test]
716 fn find_eligible_respects_dirty_keys() {
717 let mut engine = Engine::new();
718 let always_id = engine.register(AlwaysAgent);
719 let watcher_id = engine.register(SeedWatcher);
720 let ctx = Context::new();
721
722 let eligible = engine.find_eligible(&ctx, &[]);
723 assert_eq!(eligible, vec![always_id]);
724
725 let eligible = engine.find_eligible(&ctx, &[ContextKey::Seeds]);
726 assert_eq!(eligible, vec![always_id, watcher_id]);
727 }
728
729 struct MultiDepAgent;
731
732 impl Agent for MultiDepAgent {
733 fn name(&self) -> &str {
734 "MultiDepAgent"
735 }
736
737 fn dependencies(&self) -> &[ContextKey] {
738 &[ContextKey::Seeds, ContextKey::Hypotheses]
739 }
740
741 fn accepts(&self, _ctx: &Context) -> bool {
742 true
743 }
744
745 fn execute(&self, _ctx: &Context) -> AgentEffect {
746 AgentEffect::empty()
747 }
748 }
749
750 #[test]
751 fn find_eligible_deduplicates_agents() {
752 let mut engine = Engine::new();
753 let multi_id = engine.register(MultiDepAgent);
754 let ctx = Context::new();
755
756 let eligible = engine.find_eligible(&ctx, &[ContextKey::Seeds, ContextKey::Hypotheses]);
757 assert_eq!(eligible, vec![multi_id]);
758 }
759
760 struct NamedAgent {
762 name: &'static str,
763 fact_id: &'static str,
764 }
765
766 impl Agent for NamedAgent {
767 fn name(&self) -> &str {
768 self.name
769 }
770
771 fn dependencies(&self) -> &[ContextKey] {
772 &[]
773 }
774
775 fn accepts(&self, _ctx: &Context) -> bool {
776 true
777 }
778
779 fn execute(&self, _ctx: &Context) -> AgentEffect {
780 AgentEffect::with_fact(Fact {
781 key: ContextKey::Seeds,
782 id: self.fact_id.into(),
783 content: format!("emitted-by-{}", self.name),
784 })
785 }
786 }
787
788 #[test]
789 fn merge_effects_respect_agent_ordering() {
790 let mut engine = Engine::new();
791 let id_a = engine.register(NamedAgent {
792 name: "AgentA",
793 fact_id: "a",
794 });
795 let id_b = engine.register(NamedAgent {
796 name: "AgentB",
797 fact_id: "b",
798 });
799 let mut context = Context::new();
800
801 let effect_a = AgentEffect::with_fact(Fact {
802 key: ContextKey::Seeds,
803 id: "a".into(),
804 content: "first".into(),
805 });
806 let effect_b = AgentEffect::with_fact(Fact {
807 key: ContextKey::Seeds,
808 id: "b".into(),
809 content: "second".into(),
810 });
811
812 let dirty =
814 engine.merge_effects(&mut context, vec![(id_b, effect_b), (id_a, effect_a)]).expect("should not conflict");
815
816 let seeds = context.get(ContextKey::Seeds);
817 assert_eq!(seeds.len(), 2);
818 assert_eq!(seeds[0].id, "a");
819 assert_eq!(seeds[1].id, "b");
820 assert_eq!(dirty, vec![ContextKey::Seeds, ContextKey::Seeds]);
821 }
822
823 use crate::invariant::{Invariant, InvariantClass, InvariantResult, Violation};
828
829 struct ForbidContent {
831 forbidden: &'static str,
832 }
833
834 impl Invariant for ForbidContent {
835 fn name(&self) -> &str {
836 "forbid_content"
837 }
838
839 fn class(&self) -> InvariantClass {
840 InvariantClass::Structural
841 }
842
843 fn check(&self, ctx: &Context) -> InvariantResult {
844 for fact in ctx.get(ContextKey::Seeds) {
845 if fact.content.contains(self.forbidden) {
846 return InvariantResult::Violated(Violation::with_facts(
847 format!("content contains '{}'", self.forbidden),
848 vec![fact.id.clone()],
849 ));
850 }
851 }
852 InvariantResult::Ok
853 }
854 }
855
856 struct RequireBalance;
858
859 impl Invariant for RequireBalance {
860 fn name(&self) -> &str {
861 "require_balance"
862 }
863
864 fn class(&self) -> InvariantClass {
865 InvariantClass::Semantic
866 }
867
868 fn check(&self, ctx: &Context) -> InvariantResult {
869 let seeds = ctx.get(ContextKey::Seeds).len();
870 let hyps = ctx.get(ContextKey::Hypotheses).len();
871 if seeds > 0 && hyps == 0 {
873 return InvariantResult::Violated(Violation::new(
874 "seeds exist but no hypotheses derived yet",
875 ));
876 }
877 InvariantResult::Ok
878 }
879 }
880
881 struct RequireMultipleSeeds;
883
884 impl Invariant for RequireMultipleSeeds {
885 fn name(&self) -> &str {
886 "require_multiple_seeds"
887 }
888
889 fn class(&self) -> InvariantClass {
890 InvariantClass::Acceptance
891 }
892
893 fn check(&self, ctx: &Context) -> InvariantResult {
894 let seeds = ctx.get(ContextKey::Seeds).len();
895 if seeds < 2 {
896 return InvariantResult::Violated(Violation::new(format!(
897 "need at least 2 seeds, found {seeds}"
898 )));
899 }
900 InvariantResult::Ok
901 }
902 }
903
904 #[test]
905 fn structural_invariant_fails_immediately() {
906 let mut engine = Engine::new();
907 engine.register(SeedAgent);
908 engine.register_invariant(ForbidContent {
909 forbidden: "initial", });
911
912 let result = engine.run(Context::new());
913
914 assert!(result.is_err());
915 let err = result.unwrap_err();
916 match err {
917 ConvergeError::InvariantViolation { name, class, .. } => {
918 assert_eq!(name, "forbid_content");
919 assert_eq!(class, InvariantClass::Structural);
920 }
921 _ => panic!("expected InvariantViolation, got {err:?}"),
922 }
923 }
924
925 #[test]
926 fn semantic_invariant_blocks_convergence() {
927 let mut engine = Engine::new();
930 engine.register(SeedAgent);
931 engine.register_invariant(RequireBalance);
932
933 let result = engine.run(Context::new());
934
935 assert!(result.is_err());
936 let err = result.unwrap_err();
937 match err {
938 ConvergeError::InvariantViolation { name, class, .. } => {
939 assert_eq!(name, "require_balance");
940 assert_eq!(class, InvariantClass::Semantic);
941 }
942 _ => panic!("expected InvariantViolation, got {err:?}"),
943 }
944 }
945
946 #[test]
947 fn acceptance_invariant_rejects_result() {
948 let mut engine = Engine::new();
950 engine.register(SeedAgent);
951 engine.register(ReactOnceAgent); engine.register_invariant(RequireMultipleSeeds);
953
954 let result = engine.run(Context::new());
955
956 assert!(result.is_err());
957 let err = result.unwrap_err();
958 match err {
959 ConvergeError::InvariantViolation { name, class, .. } => {
960 assert_eq!(name, "require_multiple_seeds");
961 assert_eq!(class, InvariantClass::Acceptance);
962 }
963 _ => panic!("expected InvariantViolation, got {err:?}"),
964 }
965 }
966
967 #[test]
968 fn all_invariant_classes_pass_when_satisfied() {
969 struct TwoSeedAgent;
971
972 impl Agent for TwoSeedAgent {
973 fn name(&self) -> &str {
974 "TwoSeedAgent"
975 }
976
977 fn dependencies(&self) -> &[ContextKey] {
978 &[]
979 }
980
981 fn accepts(&self, ctx: &Context) -> bool {
982 !ctx.has(ContextKey::Seeds)
983 }
984
985 fn execute(&self, _ctx: &Context) -> AgentEffect {
986 AgentEffect::with_facts(vec![
987 Fact {
988 key: ContextKey::Seeds,
989 id: "seed-1".into(),
990 content: "good content".into(),
991 },
992 Fact {
993 key: ContextKey::Seeds,
994 id: "seed-2".into(),
995 content: "more good content".into(),
996 },
997 ])
998 }
999 }
1000
1001 struct DeriverAgent;
1003
1004 impl Agent for DeriverAgent {
1005 fn name(&self) -> &str {
1006 "DeriverAgent"
1007 }
1008
1009 fn dependencies(&self) -> &[ContextKey] {
1010 &[ContextKey::Seeds]
1011 }
1012
1013 fn accepts(&self, ctx: &Context) -> bool {
1014 ctx.has(ContextKey::Seeds) && !ctx.has(ContextKey::Hypotheses)
1015 }
1016
1017 fn execute(&self, _ctx: &Context) -> AgentEffect {
1018 AgentEffect::with_fact(Fact {
1019 key: ContextKey::Hypotheses,
1020 id: "hyp-1".into(),
1021 content: "derived".into(),
1022 })
1023 }
1024 }
1025
1026 struct AlwaysSatisfied;
1028
1029 impl Invariant for AlwaysSatisfied {
1030 fn name(&self) -> &str {
1031 "always_satisfied"
1032 }
1033
1034 fn class(&self) -> InvariantClass {
1035 InvariantClass::Semantic
1036 }
1037
1038 fn check(&self, _ctx: &Context) -> InvariantResult {
1039 InvariantResult::Ok
1040 }
1041 }
1042
1043 let mut engine = Engine::new();
1044 engine.register(TwoSeedAgent);
1045 engine.register(DeriverAgent);
1046
1047 engine.register_invariant(ForbidContent {
1049 forbidden: "forbidden", });
1051 engine.register_invariant(AlwaysSatisfied); engine.register_invariant(RequireMultipleSeeds);
1053
1054 let result = engine.run(Context::new());
1055
1056 assert!(result.is_ok());
1057 let result = result.unwrap();
1058 assert!(result.converged);
1059 assert_eq!(result.context.get(ContextKey::Seeds).len(), 2);
1060 assert!(result.context.has(ContextKey::Hypotheses));
1061 }
1062}