Skip to main content

converge_core/
agents.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Example suggestors for testing and demonstration.
5//!
6// Suggestor trait returns &str, but we return literals. This is fine.
7#![allow(clippy::unnecessary_literal_bound)]
8//!
9//! These suggestors prove the core convergence properties:
10//! - `SeedSuggestor`: Emits initial facts, stops when done
11//! - `ReactOnceSuggestor`: Reacts to changes, stops after one contribution
12//!
13//! # Example
14//!
15//! ```
16//! use converge_core::{Engine, Context, ContextKey};
17//! use converge_core::suggestors::{SeedSuggestor, ReactOnceSuggestor};
18//!
19//! let mut engine = Engine::new();
20//! engine.register_suggestor(SeedSuggestor::new("seed-1", "initial value"));
21//! engine.register_suggestor(ReactOnceSuggestor::new("hyp-1", "derived insight"));
22//!
23//! let result = engine.run(Context::new()).expect("converges");
24//! assert!(result.converged);
25//! assert!(result.context.has(ContextKey::Seeds));
26//! assert!(result.context.has(ContextKey::Hypotheses));
27//! ```
28
29use crate::agent::Suggestor;
30use crate::context::{ContextKey, ProposedFact};
31use crate::effect::AgentEffect;
32
33/// A suggestor that emits an initial seed proposal once.
34///
35/// Demonstrates:
36/// - Suggestor with no dependencies (runs first)
37/// - Self-terminating behavior (checks if already contributed)
38/// - Monotonic context evolution
39pub struct SeedSuggestor {
40    fact_id: String,
41    content: String,
42}
43
44impl SeedSuggestor {
45    /// Creates a new seed suggestor.
46    #[must_use]
47    pub fn new(fact_id: impl Into<String>, content: impl Into<String>) -> Self {
48        Self {
49            fact_id: fact_id.into(),
50            content: content.into(),
51        }
52    }
53}
54
55impl Suggestor for SeedSuggestor {
56    fn name(&self) -> &str {
57        "SeedSuggestor"
58    }
59
60    fn dependencies(&self) -> &[ContextKey] {
61        &[] // No dependencies = eligible on first cycle
62    }
63
64    fn accepts(&self, ctx: &dyn crate::ContextView) -> bool {
65        // Only run if we haven't contributed yet
66        !ctx.get(ContextKey::Seeds)
67            .iter()
68            .any(|f| f.id == self.fact_id)
69    }
70
71    fn execute(&self, _ctx: &dyn crate::ContextView) -> AgentEffect {
72        AgentEffect::with_proposal(ProposedFact::new(
73            ContextKey::Seeds,
74            self.fact_id.clone(),
75            self.content.clone(),
76            self.name(),
77        ))
78    }
79}
80
81/// A suggestor that reacts to seeds by emitting a hypothesis once.
82///
83/// Demonstrates:
84/// - Dependency-driven activation (only runs when Seeds change)
85/// - Data-driven behavior (reads context to decide)
86/// - Self-terminating (checks if already contributed)
87pub struct ReactOnceSuggestor {
88    fact_id: String,
89    content: String,
90}
91
92impl ReactOnceSuggestor {
93    /// Creates a new reactive suggestor.
94    #[must_use]
95    pub fn new(fact_id: impl Into<String>, content: impl Into<String>) -> Self {
96        Self {
97            fact_id: fact_id.into(),
98            content: content.into(),
99        }
100    }
101}
102
103impl Suggestor for ReactOnceSuggestor {
104    fn name(&self) -> &str {
105        "ReactOnceSuggestor"
106    }
107
108    fn dependencies(&self) -> &[ContextKey] {
109        &[ContextKey::Seeds] // Only wake when Seeds change
110    }
111
112    fn accepts(&self, ctx: &dyn crate::ContextView) -> bool {
113        // Run if: seeds exist AND we haven't contributed
114        ctx.has(ContextKey::Seeds)
115            && !ctx
116                .get(ContextKey::Hypotheses)
117                .iter()
118                .any(|f| f.id == self.fact_id)
119    }
120
121    fn execute(&self, _ctx: &dyn crate::ContextView) -> AgentEffect {
122        AgentEffect::with_proposal(ProposedFact::new(
123            ContextKey::Hypotheses,
124            self.fact_id.clone(),
125            self.content.clone(),
126            self.name(),
127        ))
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::context::Context;
135    use crate::engine::Engine;
136
137    #[test]
138    fn seed_agent_emits_once() {
139        let mut engine = Engine::new();
140        engine.register_suggestor(SeedSuggestor::new("s1", "value"));
141
142        let result = engine.run(Context::new()).expect("converges");
143
144        assert!(result.converged);
145        assert_eq!(result.context.get(ContextKey::Seeds).len(), 1);
146    }
147
148    #[test]
149    fn react_once_agent_chains_from_seed() {
150        let mut engine = Engine::new();
151        engine.register_suggestor(SeedSuggestor::new("s1", "seed"));
152        engine.register_suggestor(ReactOnceSuggestor::new("h1", "hypothesis"));
153
154        let result = engine.run(Context::new()).expect("converges");
155
156        assert!(result.converged);
157        assert!(result.context.has(ContextKey::Seeds));
158        assert!(result.context.has(ContextKey::Hypotheses));
159    }
160
161    #[test]
162    fn multiple_seeds_all_converge() {
163        let mut engine = Engine::new();
164        engine.register_suggestor(SeedSuggestor::new("s1", "first"));
165        engine.register_suggestor(SeedSuggestor::new("s2", "second"));
166        engine.register_suggestor(SeedSuggestor::new("s3", "third"));
167
168        let result = engine.run(Context::new()).expect("converges");
169
170        assert!(result.converged);
171        assert_eq!(result.context.get(ContextKey::Seeds).len(), 3);
172    }
173
174    #[test]
175    fn chain_of_three_converges() {
176        /// Third suggestor in the chain.
177        struct StrategyAgent;
178
179        impl Suggestor for StrategyAgent {
180            fn name(&self) -> &str {
181                "StrategyAgent"
182            }
183
184            fn dependencies(&self) -> &[ContextKey] {
185                &[ContextKey::Hypotheses]
186            }
187
188            fn accepts(&self, ctx: &dyn crate::ContextView) -> bool {
189                ctx.has(ContextKey::Hypotheses) && !ctx.has(ContextKey::Strategies)
190            }
191
192            fn execute(&self, _ctx: &dyn crate::ContextView) -> AgentEffect {
193                AgentEffect::with_proposal(ProposedFact::new(
194                    ContextKey::Strategies,
195                    "strat-1",
196                    "derived strategy",
197                    self.name(),
198                ))
199            }
200        }
201
202        let mut engine = Engine::new();
203        engine.register_suggestor(SeedSuggestor::new("s1", "seed"));
204        engine.register_suggestor(ReactOnceSuggestor::new("h1", "hypothesis"));
205        engine.register_suggestor(StrategyAgent);
206
207        let result = engine.run(Context::new()).expect("converges");
208
209        assert!(result.converged);
210        assert!(result.context.has(ContextKey::Seeds));
211        assert!(result.context.has(ContextKey::Hypotheses));
212        assert!(result.context.has(ContextKey::Strategies));
213    }
214}