converge_core/
engine.rs

1// Copyright 2024-2025 Aprio One AB, Sweden
2// Author: Kenneth Pernyer, kenneth@aprio.one
3// SPDX-License-Identifier: LicenseRef-Proprietary
4// All rights reserved. This source code is proprietary and confidential.
5// Unauthorized copying, modification, or distribution is strictly prohibited.
6
7//! Converge execution engine.
8//!
9//! The engine owns convergence:
10//! - Registers agents and builds dependency index
11//! - Runs the convergence loop
12//! - Merges effects serially
13//! - Detects fixed point
14
15use 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/// Budget limits for execution.
27///
28/// Guarantees termination even with misbehaving agents.
29#[derive(Debug, Clone)]
30pub struct Budget {
31    /// Maximum execution cycles before forced termination.
32    pub max_cycles: u32,
33    /// Maximum facts allowed in context.
34    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/// Result of a converged execution.
47#[derive(Debug)]
48pub struct ConvergeResult {
49    /// Final context state.
50    pub context: Context,
51    /// Number of cycles executed.
52    pub cycles: u32,
53    /// Whether convergence was reached (vs budget exhaustion).
54    pub converged: bool,
55}
56
57/// The Converge execution engine.
58///
59/// Owns agent registration, dependency indexing, and the convergence loop.
60pub struct Engine {
61    /// Registered agents in order of registration.
62    agents: Vec<Box<dyn Agent>>,
63    /// Dependency index: `ContextKey` → `AgentId`s interested in that key.
64    index: HashMap<ContextKey, Vec<AgentId>>,
65    /// Agents with no dependencies (run on every cycle).
66    always_eligible: Vec<AgentId>,
67    /// Next agent ID to assign.
68    next_id: u32,
69    /// Execution budget.
70    budget: Budget,
71    /// Runtime invariants (Gherkin compiled to predicates).
72    invariants: InvariantRegistry,
73}
74
75impl Default for Engine {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81impl Engine {
82    /// Creates a new engine with default budget.
83    #[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    /// Creates a new engine with custom budget.
96    #[must_use]
97    pub fn with_budget(budget: Budget) -> Self {
98        Self {
99            budget,
100            ..Self::new()
101        }
102    }
103
104    /// Sets the execution budget.
105    pub fn set_budget(&mut self, budget: Budget) {
106        self.budget = budget;
107    }
108
109    /// Registers an invariant (compiled Gherkin predicate).
110    ///
111    /// Invariants are checked at different points depending on their class:
112    /// - Structural: after every merge
113    /// - Semantic: at end of each cycle
114    /// - Acceptance: when convergence is claimed
115    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    /// Registers an agent and returns its ID.
124    ///
125    /// Agents are assigned monotonically increasing IDs.
126    /// The dependency index is updated incrementally.
127    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        // Update dependency index
135        if deps.is_empty() {
136            // No dependencies = always eligible for consideration
137            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    /// Returns the number of registered agents.
150    #[must_use]
151    pub fn agent_count(&self) -> usize {
152        self.agents.len()
153    }
154
155    /// Runs the convergence loop until fixed point or budget exhaustion.
156    ///
157    /// # Algorithm
158    ///
159    /// ```text
160    /// initialize context
161    /// mark all keys as dirty (first cycle)
162    ///
163    /// repeat:
164    ///   clear dirty flags
165    ///   find eligible agents (dirty deps + accepts)
166    ///   execute eligible agents (parallel read)
167    ///   merge effects (serial, deterministic order)
168    ///   track which keys changed
169    /// until no keys changed OR budget exhausted
170    /// ```
171    ///
172    /// # Errors
173    ///
174    /// Returns `ConvergeError::BudgetExhausted` if:
175    /// - `max_cycles` is exceeded
176    /// - `max_facts` is exceeded
177    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        // First cycle: we treat all existing keys in the context as "dirty"
182        // to ensure that dependency-indexed agents are triggered by initial data.
183        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            // Budget check: cycles
191            if cycles > self.budget.max_cycles {
192                return Err(ConvergeError::BudgetExhausted {
193                    kind: format!("max_cycles ({})", self.budget.max_cycles),
194                });
195            }
196
197            // Find eligible agents
198            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                // No agents want to run — check acceptance invariants before declaring convergence
208                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            // Execute eligible agents and collect effects
226            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            // Merge effects serially (deterministic order by AgentId)
234            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            // STRUCTURAL INVARIANTS: checked after every merge
242            // Violation = immediate failure, no recovery
243            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            // Convergence check: no keys changed
254            if dirty_keys.is_empty() {
255                // Check acceptance invariants before declaring convergence
256                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            // SEMANTIC INVARIANTS: checked at end of each cycle
274            // Violation = blocks convergence (could allow recovery in future)
275            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            // Budget check: facts
286            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    /// Finds agents eligible to run based on dirty keys and `accepts()`.
296    fn find_eligible(&self, context: &Context, dirty_keys: &[ContextKey]) -> Vec<AgentId> {
297        let mut candidates: HashSet<AgentId> = HashSet::new();
298
299        // Unique dirty keys to avoid redundant lookups
300        let unique_dirty: HashSet<&ContextKey> = dirty_keys.iter().collect();
301
302        // Agents whose dependencies intersect with dirty keys
303        for key in unique_dirty {
304            if let Some(ids) = self.index.get(key) {
305                candidates.extend(ids);
306            }
307        }
308
309        // Agents with no dependencies (always considered)
310        candidates.extend(&self.always_eligible);
311
312        // Filter by accepts()
313        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        // Sort for determinism
322        eligible.sort();
323        eligible
324    }
325
326    /// Executes agents in parallel and collects their effects.
327    ///
328    /// Uses Rayon for CPU-bound parallel execution. Agents receive
329    /// `&Context` (immutable), so they can safely read concurrently.
330    /// Effects are collected deterministically.
331    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    /// Merges effects into context in deterministic order.
347    ///
348    /// Returns the keys that changed (dirty keys for next cycle).
349    #[allow(clippy::unused_self)] // Will use self for invariant checking later
350    fn merge_effects(
351        &self,
352        context: &mut Context,
353        mut effects: Vec<(AgentId, AgentEffect)>,
354    ) -> Result<Vec<ContextKey>, ConvergeError> {
355        // Sort by AgentId for deterministic ordering (DECISIONS.md §1)
356        effects.sort_by_key(|(id, _)| *id);
357
358        context.clear_dirty();
359
360        for (id, effect) in effects {
361            // 1. Process explicit facts
362            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            // 2. Process proposals (Validation & Promotion)
377            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                        // Future: emit diagnostic fact or signal
397                    }
398                }
399            }
400        }
401
402        Ok(context.dirty_keys().to_vec())
403    }
404
405    /// Counts total facts in context.
406    #[allow(clippy::unused_self)] // Keeps API consistent
407    #[allow(clippy::cast_possible_truncation)] // Budget is u32, context won't exceed
408    fn count_facts(&self, context: &Context) -> u32 {
409        ContextKey::iter().map(|key| context.get(key).len() as u32).sum()
410    }
411
412    /// Emits a diagnostic fact to the context.
413    fn emit_diagnostic(&self, context: &mut Context, err: &InvariantError) {
414        let _ = self; // May use engine state in future (e.g., for diagnostic IDs)
415        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    /// Agent that emits a seed fact once.
445    struct SeedAgent;
446
447    impl Agent for SeedAgent {
448        fn name(&self) -> &str {
449            "SeedAgent"
450        }
451
452        fn dependencies(&self) -> &[ContextKey] {
453            &[] // No dependencies = runs first cycle
454        }
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    /// Agent that reacts to seeds once.
470    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); // Cycle 1: emit seed, Cycle 2: no eligible agents
503        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        /// Agent that always wants to run (would loop forever).
547        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 // Always wants to run
562            }
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        /// Agent that emits many facts.
592        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        /// Agent that only cares about Strategies.
637        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); // Emits to Seeds
663        engine.register(StrategyAgent); // Only watches Strategies
664
665        let result = engine.run(Context::new()).expect("should converge");
666
667        // SeedAgent runs, but StrategyAgent never runs because
668        // Seeds changed, not Strategies
669        assert!(result.context.has(ContextKey::Seeds));
670        assert!(!result.context.has(ContextKey::Constraints));
671    }
672
673    /// Agent used to probe dependency scheduling.
674    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    /// Agent that depends on Seeds regardless of their values.
695    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    /// Agent that depends on multiple keys, used to assert dedup.
730    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    /// Agent with static fact output used for merge ordering tests.
761    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        // Intentionally feed merge_effects in reverse order.
813        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    // ========================================================================
824    // INVARIANT VIOLATION TESTS
825    // ========================================================================
826
827    use crate::invariant::{Invariant, InvariantClass, InvariantResult, Violation};
828
829    /// Structural invariant that forbids facts with "forbidden" content.
830    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    /// Semantic invariant that requires balance between seeds and hypotheses.
857    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            // Semantic rule: can't have seeds without hypotheses for more than one cycle
872            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    /// Acceptance invariant that requires at least two seeds.
882    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", // SeedAgent emits "initial seed"
910        });
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        // This test uses an agent that emits a seed but no agent to emit hypotheses.
928        // The semantic invariant requires balance, so it should fail.
929        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        // SeedAgent emits only one seed, but acceptance requires 2
949        let mut engine = Engine::new();
950        engine.register(SeedAgent);
951        engine.register(ReactOnceAgent); // Add hypotheses to pass semantic
952        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        /// Agent that emits two seeds.
970        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        /// Agent that derives hypothesis from seeds.
1002        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        /// Semantic invariant that is always satisfied.
1027        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        // Register all three invariant classes
1048        engine.register_invariant(ForbidContent {
1049            forbidden: "forbidden", // Won't match
1050        });
1051        engine.register_invariant(AlwaysSatisfied); // Semantic that passes
1052        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}