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 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
26/// Callback trait for streaming fact emissions during convergence.
27///
28/// Implement this trait to receive real-time notifications as the engine
29/// executes. Useful for:
30/// - Streaming output to CLI/UI
31/// - Progress monitoring
32/// - Real-time fact logging
33///
34/// # Thread Safety
35///
36/// Callbacks must be `Send + Sync` as they may be called from the engine's
37/// execution context. Keep implementations lightweight to avoid blocking
38/// the convergence loop.
39pub trait StreamingCallback: Send + Sync {
40    /// Called at the start of each convergence cycle.
41    fn on_cycle_start(&self, cycle: u32);
42
43    /// Called when a fact is added to the context during merge.
44    fn on_fact(&self, cycle: u32, fact: &Fact);
45
46    /// Called at the end of each convergence cycle.
47    fn on_cycle_end(&self, cycle: u32, facts_added: usize);
48}
49
50/// Budget limits for execution.
51///
52/// Guarantees termination even with misbehaving agents.
53#[derive(Debug, Clone)]
54pub struct Budget {
55    /// Maximum execution cycles before forced termination.
56    pub max_cycles: u32,
57    /// Maximum facts allowed in context.
58    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/// Result of a converged execution.
71#[derive(Debug)]
72pub struct ConvergeResult {
73    /// Final context state.
74    pub context: Context,
75    /// Number of cycles executed.
76    pub cycles: u32,
77    /// Whether convergence was reached (vs budget exhaustion).
78    pub converged: bool,
79}
80
81/// The Converge execution engine.
82///
83/// Owns agent registration, dependency indexing, and the convergence loop.
84pub struct Engine {
85    /// Registered agents in order of registration.
86    agents: Vec<Box<dyn Agent>>,
87    /// Dependency index: `ContextKey` → `AgentId`s interested in that key.
88    index: HashMap<ContextKey, Vec<AgentId>>,
89    /// Agents with no dependencies (run on every cycle).
90    always_eligible: Vec<AgentId>,
91    /// Next agent ID to assign.
92    next_id: u32,
93    /// Execution budget.
94    budget: Budget,
95    /// Runtime invariants (Gherkin compiled to predicates).
96    invariants: InvariantRegistry,
97    /// Optional streaming callback for real-time fact emission.
98    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    /// Creates a new engine with default budget.
109    #[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    /// Creates a new engine with custom budget.
123    #[must_use]
124    pub fn with_budget(budget: Budget) -> Self {
125        Self {
126            budget,
127            ..Self::new()
128        }
129    }
130
131    /// Sets the execution budget.
132    pub fn set_budget(&mut self, budget: Budget) {
133        self.budget = budget;
134    }
135
136    /// Sets a streaming callback for real-time fact emission.
137    ///
138    /// When set, the callback will be invoked:
139    /// - At the start of each convergence cycle
140    /// - When each fact is added to the context
141    /// - At the end of each convergence cycle
142    ///
143    /// # Example
144    ///
145    /// ```ignore
146    /// use std::sync::Arc;
147    /// use converge_core::{Engine, StreamingCallback, Fact};
148    ///
149    /// struct MyCallback;
150    /// impl StreamingCallback for MyCallback {
151    ///     fn on_cycle_start(&self, cycle: u32) {
152    ///         println!("[cycle:{}] started", cycle);
153    ///     }
154    ///     fn on_fact(&self, cycle: u32, fact: &Fact) {
155    ///         println!("[cycle:{}] fact:{} | {}", cycle, fact.id, fact.content);
156    ///     }
157    ///     fn on_cycle_end(&self, cycle: u32, facts_added: usize) {
158    ///         println!("[cycle:{}] ended with {} facts", cycle, facts_added);
159    ///     }
160    /// }
161    ///
162    /// let mut engine = Engine::new();
163    /// engine.set_streaming(Arc::new(MyCallback));
164    /// ```
165    pub fn set_streaming(&mut self, callback: Arc<dyn StreamingCallback>) {
166        self.streaming_callback = Some(callback);
167    }
168
169    /// Clears the streaming callback.
170    pub fn clear_streaming(&mut self) {
171        self.streaming_callback = None;
172    }
173
174    /// Registers an invariant (compiled Gherkin predicate).
175    ///
176    /// Invariants are checked at different points depending on their class:
177    /// - Structural: after every merge
178    /// - Semantic: at end of each cycle
179    /// - Acceptance: when convergence is claimed
180    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    /// Registers an agent and returns its ID.
189    ///
190    /// Agents are assigned monotonically increasing IDs.
191    /// The dependency index is updated incrementally.
192    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        // Update dependency index
200        if deps.is_empty() {
201            // No dependencies = always eligible for consideration
202            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    /// Returns the number of registered agents.
215    #[must_use]
216    pub fn agent_count(&self) -> usize {
217        self.agents.len()
218    }
219
220    /// Runs the convergence loop until fixed point or budget exhaustion.
221    ///
222    /// # Algorithm
223    ///
224    /// ```text
225    /// initialize context
226    /// mark all keys as dirty (first cycle)
227    ///
228    /// repeat:
229    ///   clear dirty flags
230    ///   find eligible agents (dirty deps + accepts)
231    ///   execute eligible agents (parallel read)
232    ///   merge effects (serial, deterministic order)
233    ///   track which keys changed
234    /// until no keys changed OR budget exhausted
235    /// ```
236    ///
237    /// # Errors
238    ///
239    /// Returns `ConvergeError::BudgetExhausted` if:
240    /// - `max_cycles` is exceeded
241    /// - `max_facts` is exceeded
242    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        // First cycle: we treat all existing keys in the context as "dirty"
247        // to ensure that dependency-indexed agents are triggered by initial data.
248        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            // Emit cycle start callback
256            if let Some(ref cb) = self.streaming_callback {
257                cb.on_cycle_start(cycles);
258            }
259
260            // Budget check: cycles
261            if cycles > self.budget.max_cycles {
262                return Err(ConvergeError::BudgetExhausted {
263                    kind: format!("max_cycles ({})", self.budget.max_cycles),
264                });
265            }
266
267            // Find eligible agents
268            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                // Emit cycle end callback (0 facts added)
278                if let Some(ref cb) = self.streaming_callback {
279                    cb.on_cycle_end(cycles, 0);
280                }
281                // No agents want to run — check acceptance invariants before declaring convergence
282                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            // Execute eligible agents and collect effects
300            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            // Merge effects serially (deterministic order by AgentId)
309            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            // Emit cycle end callback
318            if let Some(ref cb) = self.streaming_callback {
319                cb.on_cycle_end(cycles, facts_added);
320            }
321
322            // STRUCTURAL INVARIANTS: checked after every merge
323            // Violation = immediate failure, no recovery
324            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            // Convergence check: no keys changed
335            if dirty_keys.is_empty() {
336                // Check acceptance invariants before declaring convergence
337                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            // SEMANTIC INVARIANTS: checked at end of each cycle
355            // Violation = blocks convergence (could allow recovery in future)
356            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            // Budget check: facts
367            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    /// Finds agents eligible to run based on dirty keys and `accepts()`.
377    fn find_eligible(&self, context: &Context, dirty_keys: &[ContextKey]) -> Vec<AgentId> {
378        let mut candidates: HashSet<AgentId> = HashSet::new();
379
380        // Unique dirty keys to avoid redundant lookups
381        let unique_dirty: HashSet<&ContextKey> = dirty_keys.iter().collect();
382
383        // Agents whose dependencies intersect with dirty keys
384        for key in unique_dirty {
385            if let Some(ids) = self.index.get(key) {
386                candidates.extend(ids);
387            }
388        }
389
390        // Agents with no dependencies (always considered)
391        candidates.extend(&self.always_eligible);
392
393        // Filter by accepts()
394        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        // Sort for determinism
403        eligible.sort();
404        eligible
405    }
406
407    /// Executes agents sequentially and collects their effects.
408    ///
409    /// # Deprecation Notice
410    ///
411    /// This method currently uses sequential execution. In converge-core v2.0.0,
412    /// parallel execution was removed to eliminate the rayon dependency.
413    /// Use `converge-runtime` with an `Executor` implementation for parallel execution.
414    #[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    /// Merges effects into context in deterministic order.
434    ///
435    /// Returns a tuple of (dirty keys for next cycle, count of facts added).
436    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        // Sort by AgentId for deterministic ordering (DECISIONS.md §1)
443        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            // 1. Process explicit facts
450            for fact in effect.facts {
451                // Emit streaming callback before adding (so we have the fact data)
452                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            // 2. Process proposals (Validation & Promotion)
472            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                        // Emit streaming callback for promoted proposal
479                        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                        // Future: emit diagnostic fact or signal
500                    }
501                }
502            }
503        }
504
505        Ok((context.dirty_keys().to_vec(), facts_added))
506    }
507
508    /// Counts total facts in context.
509    #[allow(clippy::unused_self)] // Keeps API consistent
510    #[allow(clippy::cast_possible_truncation)] // Budget is u32, context won't exceed
511    fn count_facts(&self, context: &Context) -> u32 {
512        ContextKey::iter()
513            .map(|key| context.get(key).len() as u32)
514            .sum()
515    }
516
517    /// Emits a diagnostic fact to the context.
518    fn emit_diagnostic(&self, context: &mut Context, err: &InvariantError) {
519        let _ = self; // May use engine state in future (e.g., for diagnostic IDs)
520        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    /// Agent that emits a seed fact once.
550    struct SeedAgent;
551
552    impl Agent for SeedAgent {
553        fn name(&self) -> &'static str {
554            "SeedAgent"
555        }
556
557        fn dependencies(&self) -> &[ContextKey] {
558            &[] // No dependencies = runs first cycle
559        }
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    /// Agent that reacts to seeds once.
575    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); // Cycle 1: emit seed, Cycle 2: no eligible agents
608        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        /// Agent that always wants to run (would loop forever).
652        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 // Always wants to run
667            }
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        /// Agent that emits many facts.
697        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        /// Agent that only cares about Strategies.
742        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); // Emits to Seeds
768        engine.register(StrategyAgent); // Only watches Strategies
769
770        let result = engine.run(Context::new()).expect("should converge");
771
772        // SeedAgent runs, but StrategyAgent never runs because
773        // Seeds changed, not Strategies
774        assert!(result.context.has(ContextKey::Seeds));
775        assert!(!result.context.has(ContextKey::Constraints));
776    }
777
778    /// Agent used to probe dependency scheduling.
779    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    /// Agent that depends on Seeds regardless of their values.
800    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    /// Agent that depends on multiple keys, used to assert dedup.
835    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    /// Agent with static fact output used for merge ordering tests.
866    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        // Intentionally feed merge_effects in reverse order.
918        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    // ========================================================================
931    // INVARIANT VIOLATION TESTS
932    // ========================================================================
933
934    use crate::invariant::{Invariant, InvariantClass, InvariantResult, Violation};
935
936    /// Structural invariant that forbids facts with "forbidden" content.
937    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    /// Semantic invariant that requires balance between seeds and hypotheses.
964    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            // Semantic rule: can't have seeds without hypotheses for more than one cycle
979            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    /// Acceptance invariant that requires at least two seeds.
989    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", // SeedAgent emits "initial seed"
1017        });
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        // This test uses an agent that emits a seed but no agent to emit hypotheses.
1035        // The semantic invariant requires balance, so it should fail.
1036        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        // SeedAgent emits only one seed, but acceptance requires 2
1056        let mut engine = Engine::new();
1057        engine.register(SeedAgent);
1058        engine.register(ReactOnceAgent); // Add hypotheses to pass semantic
1059        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        /// Agent that emits two seeds.
1077        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        /// Agent that derives hypothesis from seeds.
1109        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        /// Semantic invariant that is always satisfied.
1134        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        // Register all three invariant classes
1155        engine.register_invariant(ForbidContent {
1156            forbidden: "forbidden", // Won't match
1157        });
1158        engine.register_invariant(AlwaysSatisfied); // Semantic that passes
1159        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}