1use converge_kernel::{
10 AgentEffect, Budget, Context, ContextKey, ContextState, ConvergeResult, Engine,
11 ExperienceEventObserver, Suggestor,
12};
13use converge_pack::{ProposalId, Provenance};
14use std::sync::Arc;
15
16struct 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
43pub struct Formation {
49 pub label: String,
51 agents: Vec<Box<dyn Suggestor>>,
53 seeds: Vec<Seed>,
55 pub budget: Budget,
57}
58
59pub struct Seed {
61 pub key: ContextKey,
62 pub id: ProposalId,
63 pub content: String,
64 pub provenance: Provenance,
65}
66
67pub struct FormationResult {
69 pub label: String,
71 pub converge_result: ConvergeResult,
73}
74
75impl Formation {
76 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 pub fn agent(mut self, suggestor: impl Suggestor + 'static) -> Self {
88 self.agents.push(Box::new(suggestor));
89 self
90 }
91
92 pub fn agent_boxed(mut self, suggestor: Box<dyn Suggestor>) -> Self {
94 self.agents.push(suggestor);
95 self
96 }
97
98 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 pub fn with_budget(mut self, budget: Budget) -> Self {
117 self.budget = budget;
118 self
119 }
120
121 pub async fn run(self) -> Result<FormationResult, FormationError> {
127 self.run_observed(None).await
128 }
129
130 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 for agent in self.agents {
152 engine.register_suggestor(BoxedAgent(agent));
153 }
154
155 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 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
181impl Formation {
183 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 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 pub fn with_learning_priors(self) -> Self {
212 use organism_learning::PlanningPriorAgent;
213
214 self.agent(PlanningPriorAgent::new())
215 }
216
217 pub fn with_stress_test_pipeline(self) -> Self {
219 self.with_learning_priors()
220 .with_adversarial_team()
221 .with_simulation_swarm()
222 }
223
224 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 pub fn with_round_starter(self, max_rounds: u8) -> Self {
244 self.agent(crate::huddle::RoundStarter::new(max_rounds))
245 }
246
247 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 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}