Skip to main content

organism_runtime/
formation.rs

1//! Formations — teams of heterogeneous agents assembled to solve a problem.
2//!
3//! A Formation is the unit of work that Organism hands to Converge.
4//! It contains a team of Suggestors (which may be LLMs, optimizers,
5//! policy gates, analytics, knowledge retrieval, schedulers, or any other
6//! agent type) plus the seed Context they
7//! operate on.
8
9use converge_kernel::{
10    AgentEffect, Budget, Context, ContextKey, ContextState, ConvergeResult, Engine,
11    ExperienceEventObserver, Suggestor,
12};
13use converge_pack::{ProposalId, Provenance};
14use std::sync::Arc;
15
16/// Wrapper that implements `Suggestor` for a boxed trait object.
17/// Needed because converge-pack does not provide a blanket impl.
18struct BoxedAgent(Box<dyn Suggestor>);
19
20#[async_trait::async_trait]
21impl Suggestor for BoxedAgent {
22    fn name(&self) -> &str {
23        self.0.name()
24    }
25
26    fn dependencies(&self) -> &[ContextKey] {
27        self.0.dependencies()
28    }
29
30    fn accepts(&self, ctx: &dyn Context) -> bool {
31        self.0.accepts(ctx)
32    }
33
34    fn provenance(&self) -> Provenance {
35        self.0.provenance()
36    }
37
38    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
39        self.0.execute(ctx).await
40    }
41}
42
43/// A team of agents assembled by Organism to run in a Converge Engine.
44///
45/// Formations are hypotheses: "this team, with these seeds, will converge
46/// on a good answer." Organism may run multiple formations concurrently
47/// and pick the winner.
48pub struct Formation {
49    /// Human-readable label for logging and learning.
50    pub label: String,
51    /// The agents in this team, ready to register on an Engine.
52    agents: Vec<Box<dyn Suggestor>>,
53    /// Initial external inputs to stage before running.
54    seeds: Vec<Seed>,
55    /// Execution budget for this formation's run.
56    pub budget: Budget,
57}
58
59/// A seed input to stage into the Context before the Engine runs.
60pub struct Seed {
61    pub key: ContextKey,
62    pub id: ProposalId,
63    pub content: String,
64    pub provenance: Provenance,
65}
66
67/// Result of running a Formation in a Converge Engine.
68pub struct FormationResult {
69    /// The label of the formation that produced this result.
70    pub label: String,
71    /// The governed Converge result.
72    pub converge_result: ConvergeResult,
73}
74
75impl Formation {
76    /// Create an empty formation with a human-readable label.
77    pub fn new(label: impl Into<String>) -> Self {
78        Self {
79            label: label.into(),
80            agents: Vec::new(),
81            seeds: Vec::new(),
82            budget: Budget::default(),
83        }
84    }
85
86    /// Add a heterogeneous agent to the team.
87    pub fn agent(mut self, suggestor: impl Suggestor + 'static) -> Self {
88        self.agents.push(Box::new(suggestor));
89        self
90    }
91
92    /// Add a boxed agent to the team.
93    pub fn agent_boxed(mut self, suggestor: Box<dyn Suggestor>) -> Self {
94        self.agents.push(suggestor);
95        self
96    }
97
98    /// Stage an initial input with explicit provenance.
99    pub fn seed(
100        mut self,
101        key: ContextKey,
102        id: impl Into<ProposalId>,
103        content: impl Into<String>,
104        provenance: impl Into<Provenance>,
105    ) -> Self {
106        self.seeds.push(Seed {
107            key,
108            id: id.into(),
109            content: content.into(),
110            provenance: provenance.into(),
111        });
112        self
113    }
114
115    /// Set the execution budget.
116    pub fn with_budget(mut self, budget: Budget) -> Self {
117        self.budget = budget;
118        self
119    }
120
121    /// Run this formation in a fresh Converge Engine.
122    ///
123    /// This is the honest execution boundary: Organism assembles the team,
124    /// Converge runs it. Agents propose, the engine promotes, and the
125    /// returned result is governed by Converge.
126    pub async fn run(self) -> Result<FormationResult, FormationError> {
127        self.run_observed(None).await
128    }
129
130    /// Run this formation with a run-scoped experience observer.
131    ///
132    /// Organism should use this with `FormationExperienceObserver` when it needs
133    /// tenant/correlation metadata on Converge experience envelopes.
134    pub async fn run_with_event_observer(
135        self,
136        observer: Arc<dyn ExperienceEventObserver>,
137    ) -> Result<FormationResult, FormationError> {
138        self.run_observed(Some(observer)).await
139    }
140
141    async fn run_observed(
142        self,
143        observer: Option<Arc<dyn ExperienceEventObserver>>,
144    ) -> Result<FormationResult, FormationError> {
145        let mut engine = Engine::with_budget(self.budget);
146        if let Some(observer) = observer {
147            engine.set_event_observer(observer);
148        }
149
150        // Register all agents
151        for agent in self.agents {
152            engine.register_suggestor(BoxedAgent(agent));
153        }
154
155        // Build seed context through the public input path.
156        let mut context = ContextState::new();
157        for seed in &self.seeds {
158            context
159                .add_input_with_provenance(
160                    seed.key,
161                    seed.id.clone(),
162                    &seed.content,
163                    seed.provenance.clone(),
164                )
165                .map_err(|e| FormationError::ConvergenceFailed(e.to_string()))?;
166        }
167
168        // Run convergence
169        let converge_result = engine
170            .run(context)
171            .await
172            .map_err(|e| FormationError::ConvergenceFailed(e.to_string()))?;
173
174        Ok(FormationResult {
175            label: self.label,
176            converge_result,
177        })
178    }
179}
180
181/// Builder helpers for standard organism agent teams.
182impl Formation {
183    /// Add the standard simulation swarm (all 5 dimensions) with default configs.
184    pub fn with_simulation_swarm(self) -> Self {
185        use organism_simulation::{
186            CausalSimulationAgent, CostSimulationAgent, OperationalSimulationAgent,
187            OutcomeSimulationAgent, PolicySimulationAgent,
188        };
189
190        self.agent(OutcomeSimulationAgent::default_config())
191            .agent(CostSimulationAgent::default_config())
192            .agent(PolicySimulationAgent::default_config())
193            .agent(CausalSimulationAgent::default_config())
194            .agent(OperationalSimulationAgent::default_config())
195    }
196
197    /// Add the standard adversarial team with default configs.
198    pub fn with_adversarial_team(self) -> Self {
199        use organism_adversarial::{
200            AssumptionBreakerAgent, ConstraintCheckerAgent, EconomicSkepticAgent,
201            OperationalSkepticAgent,
202        };
203
204        self.agent(AssumptionBreakerAgent::new())
205            .agent(ConstraintCheckerAgent::default_config())
206            .agent(EconomicSkepticAgent::default_config())
207            .agent(OperationalSkepticAgent::default_config())
208    }
209
210    /// Add the planning prior agent for learning feedback.
211    pub fn with_learning_priors(self) -> Self {
212        use organism_learning::PlanningPriorAgent;
213
214        self.agent(PlanningPriorAgent::new())
215    }
216
217    /// Full Stage 2 pipeline: priors → adversarial → simulation.
218    pub fn with_stress_test_pipeline(self) -> Self {
219        self.with_learning_priors()
220            .with_adversarial_team()
221            .with_simulation_swarm()
222    }
223
224    /// Add the platform consensus evaluator: tallies `Vote` facts under
225    /// [`ContextKey::Votes`] against `rule` and emits `ConsensusOutcome`
226    /// facts under [`ContextKey::ConsensusOutcomes`].
227    ///
228    /// Use this for any team that needs collective sign-off — research
229    /// huddles, vendor-selection panels, multi-agent reviews. Vote facts are
230    /// authored by domain pack agents; this evaluator stays domain-agnostic.
231    pub fn with_consensus_evaluator(
232        self,
233        rule: converge_pack::ConsensusRule,
234        total_voters: usize,
235    ) -> Self {
236        self.agent(crate::huddle::ConsensusEvaluator::new(rule, total_voters))
237    }
238
239    /// Add the platform round starter: emits `round:start:N` signals to drive
240    /// round-by-round deliberation. Round 1 fires immediately; later rounds
241    /// fire when a `round:continue:N` marker has landed under the configured
242    /// continue key. Stops at `max_rounds`.
243    pub fn with_round_starter(self, max_rounds: u8) -> Self {
244        self.agent(crate::huddle::RoundStarter::new(max_rounds))
245    }
246
247    /// Add the platform round synthesizer: once a started round has
248    /// `expected_note_count` notes under [`ContextKey::Hypotheses`], invokes
249    /// the supplied [`SynthesisProducer`] and emits a synthesis fact under
250    /// [`ContextKey::Strategies`]. Producer errors route to
251    /// [`ContextKey::Diagnostic`].
252    pub fn with_round_synthesizer<P>(self, expected_note_count: usize, producer: P) -> Self
253    where
254        P: crate::huddle::SynthesisProducer + 'static,
255    {
256        self.agent(crate::huddle::RoundSynthesizer::new(
257            expected_note_count,
258            producer,
259        ))
260    }
261
262    /// Add the platform disagreement mapper: aggregates `Disagreement` facts
263    /// into per-topic [`crate::huddle::DisagreementMap`] payloads, emitted
264    /// once per topic under [`ContextKey::Diagnostic`].
265    pub fn with_disagreement_mapper(self) -> Self {
266        self.agent(crate::huddle::DisagreementMapper::new())
267    }
268}
269
270#[derive(Debug, thiserror::Error)]
271pub enum FormationError {
272    #[error("convergence failed: {0}")]
273    ConvergenceFailed(String),
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::provenance::ORGANISM_RUNTIME_PROVENANCE;
280    use converge_pack::{Provenance, ProvenanceSource, TextPayload};
281    use proptest::prelude::*;
282
283    const SEED_DEPENDENCIES: &[ContextKey] = &[ContextKey::Seeds];
284
285    fn rt() -> tokio::runtime::Runtime {
286        tokio::runtime::Runtime::new().expect("runtime")
287    }
288
289    struct SeedObserver;
290
291    #[async_trait::async_trait]
292    impl Suggestor for SeedObserver {
293        fn name(&self) -> &'static str {
294            "seed-observer"
295        }
296
297        fn dependencies(&self) -> &[ContextKey] {
298            SEED_DEPENDENCIES
299        }
300
301        fn provenance(&self) -> Provenance {
302            ORGANISM_RUNTIME_PROVENANCE.provenance()
303        }
304
305        fn accepts(&self, ctx: &dyn Context) -> bool {
306            ctx.has(ContextKey::Seeds) && !ctx.has(ContextKey::Hypotheses)
307        }
308
309        async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
310            let seed = &ctx.get(ContextKey::Seeds)[0];
311            AgentEffect::builder()
312                .proposal(ORGANISM_RUNTIME_PROVENANCE.proposed_fact(
313                    ContextKey::Hypotheses,
314                    format!("observed-{}", seed.id()),
315                    TextPayload::new(format!("observed {}", seed.text().unwrap_or_default())),
316                ))
317                .build()
318        }
319    }
320
321    #[tokio::test]
322    async fn formation_promotes_valid_seed_before_agent_loop() {
323        let result = Formation::new("valid-seed")
324            .agent(SeedObserver)
325            .seed(
326                ContextKey::Seeds,
327                "seed-1",
328                "seed content",
329                "external-request",
330            )
331            .run()
332            .await
333            .expect("formation should converge");
334
335        assert!(result.converge_result.converged);
336        assert!(!result.converge_result.context.has_pending_proposals());
337
338        let seeds = result.converge_result.context.get(ContextKey::Seeds);
339        let hypotheses = result.converge_result.context.get(ContextKey::Hypotheses);
340
341        assert_eq!(seeds.len(), 1);
342        assert_eq!(seeds[0].id().as_str(), "seed-1");
343        assert_eq!(seeds[0].text(), Some("seed content"));
344        assert_eq!(hypotheses.len(), 1);
345        assert_eq!(hypotheses[0].id().as_str(), "observed-seed-1");
346        assert_eq!(hypotheses[0].text(), Some("observed seed content"));
347    }
348
349    #[tokio::test]
350    async fn formation_rejects_invalid_seed_before_agent_can_observe_it() {
351        let result = Formation::new("invalid-seed")
352            .agent(SeedObserver)
353            .seed(ContextKey::Seeds, "seed-1", "   \t\n  ", "external-request")
354            .run()
355            .await
356            .expect("formation should converge");
357
358        assert!(result.converge_result.converged);
359        assert!(!result.converge_result.context.has(ContextKey::Seeds));
360        assert!(!result.converge_result.context.has(ContextKey::Hypotheses));
361        assert!(!result.converge_result.context.has_pending_proposals());
362    }
363
364    #[test]
365    fn formation_rejects_conflicting_seed_ids_before_engine_run() {
366        let result = rt().block_on(
367            Formation::new("conflict")
368                .seed(ContextKey::Seeds, "seed-1", "version A", "user")
369                .seed(ContextKey::Seeds, "seed-1", "version B", "user")
370                .run(),
371        );
372
373        match result {
374            Err(FormationError::ConvergenceFailed(message)) => {
375                assert!(message.contains("conflict detected for fact 'seed-1'"));
376            }
377            Ok(_) => panic!("conflicting seeds must fail"),
378        }
379    }
380
381    proptest! {
382        #[test]
383        fn formation_roundtrips_valid_seed_inputs(
384            id in "[a-z0-9][a-z0-9-]{0,15}",
385            content in "[A-Za-z0-9][A-Za-z0-9 _-]{0,31}",
386            provenance in "[a-z][a-z0-9-]{2,15}",
387        ) {
388            let result = rt()
389                .block_on(
390                    Formation::new("prop-valid")
391                        .agent(SeedObserver)
392                        .seed(ContextKey::Seeds, id.clone(), content.clone(), provenance)
393                        .run(),
394                )
395                .expect("formation should converge");
396
397            let seeds = result.converge_result.context.get(ContextKey::Seeds);
398            let hypotheses = result.converge_result.context.get(ContextKey::Hypotheses);
399
400            prop_assert_eq!(seeds.len(), 1);
401            prop_assert_eq!(seeds[0].id().as_str(), id.as_str());
402            prop_assert_eq!(seeds[0].text(), Some(content.as_str()));
403            prop_assert_eq!(hypotheses.len(), 1);
404            let expected = format!("observed {content}");
405            prop_assert_eq!(hypotheses[0].text(), Some(expected.as_str()));
406            prop_assert!(!result.converge_result.context.has_pending_proposals());
407        }
408
409        #[test]
410        fn formation_never_promotes_whitespace_only_seed_content(
411            id in "[a-z0-9][a-z0-9-]{0,15}",
412            content in "[ \\t\\n]{1,12}",
413            provenance in "[a-z][a-z0-9-]{2,15}",
414        ) {
415            let result = rt()
416                .block_on(
417                    Formation::new("prop-invalid")
418                        .agent(SeedObserver)
419                        .seed(ContextKey::Seeds, id, content, provenance)
420                        .run(),
421                )
422                .expect("formation should converge");
423
424            prop_assert!(!result.converge_result.context.has(ContextKey::Seeds));
425            prop_assert!(!result.converge_result.context.has(ContextKey::Hypotheses));
426            prop_assert!(!result.converge_result.context.has_pending_proposals());
427        }
428    }
429}