Skip to main content

organism_runtime/
execution.rs

1//! Execution bridge from compiled formation plans to runnable formations.
2//!
3//! The compiler produces an auditable plan. This module keeps the next boundary
4//! explicit: a compiled `suggestor_id` is executable only when the embedding app
5//! registered a concrete factory for it.
6
7use std::collections::BTreeMap;
8use std::fmt;
9use std::sync::Arc;
10
11use converge_kernel::{StopReason, Suggestor};
12
13use crate::compiler::CompiledFormationPlan;
14use crate::formation::{Formation, FormationResult, Seed};
15use crate::outcome::{FormationOutcomeRecord, FormationOutcomeStatus};
16
17type SuggestorFactory = Arc<dyn Fn() -> Box<dyn Suggestor> + Send + Sync>;
18
19/// Registry of concrete suggestor factories that Organism may instantiate.
20#[derive(Clone, Default)]
21pub struct ExecutableSuggestorCatalog {
22    factories: BTreeMap<String, SuggestorFactory>,
23}
24
25/// Result of compiling, instantiating, and running one formation candidate.
26pub struct FormationExecutionRecord {
27    pub plan: CompiledFormationPlan,
28    pub result: FormationResult,
29    pub outcome: FormationOutcomeRecord,
30}
31
32impl ExecutableSuggestorCatalog {
33    #[must_use]
34    pub fn new() -> Self {
35        Self::default()
36    }
37
38    /// Register a factory for a compiled `suggestor_id`.
39    pub fn register_factory<S, F>(
40        &mut self,
41        suggestor_id: impl Into<String>,
42        factory: F,
43    ) -> Result<(), FormationInstantiationError>
44    where
45        S: Suggestor + 'static,
46        F: Fn() -> S + Send + Sync + 'static,
47    {
48        self.register_boxed_factory(suggestor_id, move || Box::new(factory()))
49    }
50
51    /// Register a boxed factory for suggestors that already erase their type.
52    pub fn register_boxed_factory<F>(
53        &mut self,
54        suggestor_id: impl Into<String>,
55        factory: F,
56    ) -> Result<(), FormationInstantiationError>
57    where
58        F: Fn() -> Box<dyn Suggestor> + Send + Sync + 'static,
59    {
60        let suggestor_id = suggestor_id.into();
61        if self.factories.contains_key(&suggestor_id) {
62            return Err(FormationInstantiationError::DuplicateSuggestorFactory { suggestor_id });
63        }
64
65        self.factories.insert(suggestor_id, Arc::new(factory));
66        Ok(())
67    }
68
69    #[must_use]
70    pub fn contains(&self, suggestor_id: &str) -> bool {
71        self.factories.contains_key(suggestor_id)
72    }
73
74    #[must_use]
75    pub fn suggestor_ids(&self) -> Vec<&str> {
76        self.factories.keys().map(String::as_str).collect()
77    }
78
79    /// Instantiate a compiled plan into a runnable formation, labelled
80    /// with `plan.template_id`.
81    ///
82    /// Provider assignments remain part of the compiled plan and
83    /// outcome record. Concrete provider clients should be captured by
84    /// the registered factories.
85    pub fn instantiate(
86        &self,
87        plan: &CompiledFormationPlan,
88        seeds: impl IntoIterator<Item = Seed>,
89    ) -> Result<Formation, FormationInstantiationError> {
90        self.instantiate_with_label(plan, seeds, plan.template_id.clone())
91    }
92
93    /// Instantiate a compiled plan into a runnable formation with a
94    /// caller-supplied label. Use this when running multiple candidate
95    /// rosters compiled from the same template — the unique label is
96    /// the join key that lets a downstream tournament distinguish
97    /// scores per candidate.
98    pub fn instantiate_with_label(
99        &self,
100        plan: &CompiledFormationPlan,
101        seeds: impl IntoIterator<Item = Seed>,
102        label: impl Into<String>,
103    ) -> Result<Formation, FormationInstantiationError> {
104        let missing = plan
105            .roster
106            .iter()
107            .filter(|member| !self.factories.contains_key(member.suggestor_id.as_str()))
108            .map(|member| member.suggestor_id.to_string())
109            .collect::<Vec<_>>();
110
111        if !missing.is_empty() {
112            return Err(FormationInstantiationError::MissingSuggestorFactories {
113                suggestor_ids: missing,
114            });
115        }
116
117        let mut formation = Formation::new(label);
118        for seed in seeds {
119            formation = formation.seed(seed.key, seed.id, seed.content, seed.provenance);
120        }
121
122        for member in &plan.roster {
123            let factory = self
124                .factories
125                .get(member.suggestor_id.as_str())
126                .ok_or_else(|| FormationInstantiationError::MissingSuggestorFactories {
127                    suggestor_ids: vec![member.suggestor_id.to_string()],
128                })?;
129            formation = formation.agent_boxed(factory());
130        }
131
132        Ok(formation)
133    }
134}
135
136impl FormationExecutionRecord {
137    #[must_use]
138    pub fn from_plan_and_result(plan: CompiledFormationPlan, result: FormationResult) -> Self {
139        let status = outcome_status(
140            &result.converge_result.stop_reason,
141            result.converge_result.converged,
142        );
143        let outcome = FormationOutcomeRecord::from_compiled_plan(&plan, status)
144            .with_stop_reason(format!("{:?}", result.converge_result.stop_reason));
145
146        Self {
147            plan,
148            result,
149            outcome,
150        }
151    }
152}
153
154impl fmt::Debug for ExecutableSuggestorCatalog {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        f.debug_struct("ExecutableSuggestorCatalog")
157            .field("suggestor_ids", &self.suggestor_ids())
158            .finish()
159    }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
163pub enum FormationInstantiationError {
164    #[error("executable suggestor factory already registered for '{suggestor_id}'")]
165    DuplicateSuggestorFactory { suggestor_id: String },
166    #[error(
167        "compiled formation references suggestors without executable factories: {suggestor_ids:?}"
168    )]
169    MissingSuggestorFactories { suggestor_ids: Vec<String> },
170}
171
172fn outcome_status(stop_reason: &StopReason, converged: bool) -> FormationOutcomeStatus {
173    match stop_reason {
174        StopReason::Converged if converged => FormationOutcomeStatus::Converged,
175        StopReason::CriteriaMet { .. } => FormationOutcomeStatus::Converged,
176        StopReason::HumanInterventionRequired { .. } | StopReason::HitlGatePending { .. } => {
177            FormationOutcomeStatus::NeedsReview
178        }
179        StopReason::CycleBudgetExhausted { .. }
180        | StopReason::FactBudgetExhausted { .. }
181        | StopReason::TokenBudgetExhausted { .. }
182        | StopReason::TimeBudgetExhausted { .. } => FormationOutcomeStatus::BudgetExhausted,
183        StopReason::InvariantViolated { .. } | StopReason::PromotionRejected { .. } => {
184            FormationOutcomeStatus::CriteriaBlocked
185        }
186        StopReason::UserCancelled | StopReason::AgentRefused { .. } | StopReason::Error { .. } => {
187            FormationOutcomeStatus::Failed
188        }
189        _ => FormationOutcomeStatus::Failed,
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::provenance::ORGANISM_RUNTIME_PROVENANCE;
197    use converge_kernel::{AgentEffect, Context, ContextKey};
198    use converge_pack::{Provenance, ProvenanceSource, TextPayload};
199
200    const SEED_DEPENDENCIES: &[ContextKey] = &[ContextKey::Seeds];
201
202    struct TestSuggestor {
203        name: &'static str,
204    }
205
206    #[async_trait::async_trait]
207    impl Suggestor for TestSuggestor {
208        fn name(&self) -> &'static str {
209            self.name
210        }
211
212        fn dependencies(&self) -> &[ContextKey] {
213            SEED_DEPENDENCIES
214        }
215
216        fn provenance(&self) -> Provenance {
217            ORGANISM_RUNTIME_PROVENANCE.provenance()
218        }
219
220        fn accepts(&self, ctx: &dyn Context) -> bool {
221            ctx.has(ContextKey::Seeds) && !ctx.has(ContextKey::Hypotheses)
222        }
223
224        async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
225            let seed = &ctx.get(ContextKey::Seeds)[0];
226            AgentEffect::builder()
227                .proposal(
228                    crate::provenance::ORGANISM_RUNTIME_PROVENANCE.proposed_fact(
229                        ContextKey::Hypotheses,
230                        format!("{}-{}", self.name, seed.id()),
231                        TextPayload::new("instantiated suggestor ran"),
232                    ),
233                )
234                .build()
235        }
236    }
237
238    #[test]
239    fn rejects_duplicate_factories() {
240        let mut catalog = ExecutableSuggestorCatalog::new();
241        catalog
242            .register_factory("dup", || TestSuggestor { name: "dup-a" })
243            .expect("first registration should succeed");
244
245        let error = catalog
246            .register_factory("dup", || TestSuggestor { name: "dup-b" })
247            .expect_err("duplicate registration should fail");
248
249        assert_eq!(
250            error,
251            FormationInstantiationError::DuplicateSuggestorFactory {
252                suggestor_id: "dup".to_string()
253            }
254        );
255    }
256}