Skip to main content

organism_runtime/
lib.rs

1//! # Organism Runtime
2//!
3//! The formation guru. Given an intent, assembles teams of heterogeneous
4//! agents and runs them in Converge Engine instances.
5//!
6//! There is ONE model: everything is a Suggestor. Adversarial review,
7//! simulation, planning, policy, optimization — all participate in the
8//! same convergence loop. No side-car pipelines.
9//!
10//! ```text
11//! Intent → Admit → Form (pick Suggestors) → Engine.run() → Evaluate → Learn
12//!                    ↑                                          ↓
13//!                    └──── reform if needed ────────────────────┘
14//! ```
15
16pub mod classifier;
17pub mod collaboration;
18pub mod compiler;
19pub mod execution;
20pub mod experience;
21pub mod factories;
22pub mod formation;
23pub mod guru;
24pub mod huddle;
25pub mod outcome;
26pub mod provenance;
27pub mod readiness;
28pub mod registry;
29pub mod stall;
30pub mod templates;
31pub mod tournament;
32pub mod vendor_selection;
33
34pub use classifier::{ProblemClassifierSuggestor, extract_classification};
35pub use collaboration::{
36    CollaborationParticipant, CollaborationRunner, CollaborationRunnerError, TransitionRecord,
37};
38pub use compiler::{
39    CandidateConsideration, CandidateDisposition, CatalogCompileFailure, CompiledFormationPlan,
40    CompiledSuggestorRole, DataContract, FormationCompileError, FormationCompileRequest,
41    FormationCompiler, FormationCompilerCatalogs, GovernanceClass, ProviderDescriptor,
42    ProviderDescriptorCatalog, RejectionReason, ReplayMode, RoleDecision, RoleProviderAssignment,
43    SelectionReason, SuggestorDescriptor, SuggestorDescriptorCatalog,
44};
45pub use execution::{
46    ExecutableSuggestorCatalog, FormationExecutionRecord, FormationInstantiationError,
47};
48pub use experience::{ExperienceEnvelopeSink, FormationExperienceObserver};
49pub use factories::register_default_factories;
50pub use formation::{Formation, FormationError, FormationResult, Seed};
51pub use guru::{CandidateScore, FormationGuru, GuruError, GuruSelection, SelectionTrace};
52pub use huddle::{
53    ConsensusEvaluator, DisagreementMap, DisagreementMapper, RoundConventions, RoundStarter,
54    RoundSynthesizer, SynthesisProducer, TerminalPredicate,
55};
56pub use organism_pack::{
57    CapabilityRequirement, DeclarativeBinding, IntentBinding, IntentResolver, PackRequirement,
58    ResolutionLevel, ResolutionTrace,
59};
60pub use outcome::{
61    BusinessQualitySignal, FormationOutcomeRecord, FormationOutcomeStatus, FormationRunScope,
62    OutcomeProviderAssignment, OutcomeRosterMember, QualityScoreBps, QualityScoreError,
63};
64pub use readiness::{
65    BudgetProbe, CredentialProbe, GapSeverity, PackProbe, ReadinessConfirmation, ReadinessGap,
66    ReadinessItem, ReadinessProbe, ReadinessReport, ResourceKind, check as check_readiness,
67};
68pub use registry::{RegisteredCapability, RegisteredPack, Registry, StructuralResolver};
69pub use stall::RoleStallSuggestor;
70pub use templates::{
71    CostHint, cost_hint_for, decision_formation, diligence_formation, evaluation_formation,
72    planning_formation, research_formation, standard_formation_catalog, template_id_for,
73};
74pub use tournament::{FormationScore, FormationTournament, TournamentError, TournamentResult};
75pub use vendor_selection::{
76    VendorSelectionFlow, VendorSelectionFlowSpec, vendor_selection_formation_catalog,
77    vendor_selection_lifecycle,
78};
79
80use organism_catalog::DiscoveryCatalog;
81
82use converge_kernel::admission::{
83    AdmissionActor, AdmissionContent, AdmissionError, AdmissionReceipt, AdmissionRequest,
84    AdmissionSource, admit_observation,
85};
86use converge_kernel::formation::{FormationCatalog, SuggestorCapability};
87use converge_kernel::{ContextKey, ContextState, ConvergeError};
88use organism_intent::admission::{self, Admission};
89use organism_pack::IntentPacket;
90use std::sync::Arc;
91
92/// Outcome of the full organism pipeline.
93#[derive(Debug)]
94pub struct OrganismResult {
95    /// The formation that produced the winning result.
96    pub winning_formation: String,
97    /// Converge result from the winning run.
98    pub converge_result: converge_kernel::ConvergeResult,
99}
100
101/// A single scored catalog-sourced candidate. Pairs the per-role
102/// decision trace (why this roster was chosen) with the tournament
103/// score (how it performed). Indexed-paired entries are how callers
104/// join "selection rationale" to "score outcome" without parsing
105/// labels.
106#[derive(Debug, Clone)]
107pub struct ScoredCatalogCandidate {
108    /// Stable index 0..k matching position in the originating
109    /// `compile_k_candidates` call. The label of the underlying
110    /// `Formation` was set to `format!("{template_id}#{index}")` at
111    /// instantiation so the tournament's `FormationScore.label` can be
112    /// joined back here unambiguously.
113    pub index: usize,
114    pub candidate: CompiledFormationPlan,
115    pub score: FormationScore,
116}
117
118/// Result of [`Runtime::compile_k_and_run_tournament`]. Pairs each
119/// candidate's selection rationale (decisions) with its tournament
120/// score so the audit trail can show *why* each roster was chosen
121/// alongside *how* it performed. Pair-by-index is the join key — the
122/// tournament's `FormationScore.label` is `{template_id}#{index}` for
123/// candidate at that index.
124///
125/// `winner_index` is the *original candidate index* (0..k) of the
126/// winner, not a position inside `scored_candidates`. Use
127/// [`Self::winner`] to dereference safely — if any non-winning
128/// candidate failed at runtime, the tournament drops it from
129/// `scored_candidates`, and looking up by position would index a
130/// different (or invalid) element.
131#[derive(Debug, Clone)]
132pub struct CatalogTournamentOutcome {
133    /// Original candidate index (0..k) of the tournament winner.
134    pub winner_index: usize,
135    /// Candidates that produced a scored result. Ordered by `index`
136    /// ascending; may be shorter than the originating `k` if any
137    /// candidate's formation failed at run time.
138    pub scored_candidates: Vec<ScoredCatalogCandidate>,
139    /// Calibrated priors ready to feed the next planning prior agent.
140    pub priors: Vec<organism_learning::PriorCalibration>,
141}
142
143impl CatalogTournamentOutcome {
144    /// Returns the winning [`ScoredCatalogCandidate`] by matching
145    /// `winner_index` against each entry's `index`, or `None` if the
146    /// winning candidate was dropped from `scored_candidates` (e.g.
147    /// because its formation failed at runtime). Safe against partial
148    /// tournament failures.
149    #[must_use]
150    pub fn winner(&self) -> Option<&ScoredCatalogCandidate> {
151        self.scored_candidates
152            .iter()
153            .find(|sc| sc.index == self.winner_index)
154    }
155}
156
157/// Why the pipeline rejected an intent or formation.
158#[derive(Debug, thiserror::Error)]
159pub enum PipelineError {
160    #[error("admission rejected: {0}")]
161    Rejected(String),
162    #[error("formation compile error: {0}")]
163    Compile(#[from] FormationCompileError),
164    /// Catalog-aware compile failure. Carries the partial per-role
165    /// decision trace so callers can explain why the requirement could
166    /// not be satisfied.
167    #[error("catalog compile error: {0}")]
168    CatalogCompile(#[from] CatalogCompileFailure),
169    #[error("formation instantiation error: {0}")]
170    Instantiate(#[from] FormationInstantiationError),
171    #[error("all formations failed: {0}")]
172    AllFormationsFailed(String),
173    #[error("formation error: {0}")]
174    Formation(#[from] FormationError),
175    /// Tournament error (e.g. no formations to score, all failed).
176    #[error("tournament error: {0}")]
177    Tournament(String),
178}
179
180/// Why an IntentPacket failed organism's structural gate or Converge's typed
181/// admission boundary.
182#[derive(Debug, thiserror::Error)]
183pub enum IntentAdmissionError {
184    /// Organism's structural admission gate rejected the IntentPacket.
185    #[error("admission rejected: {0}")]
186    Rejected(String),
187    /// Constructing the Converge admission request failed (empty actor / source / id / content).
188    #[error("admission request invalid: {0}")]
189    AdmissionRequest(#[from] AdmissionError),
190    /// Serializing the IntentPacket payload failed.
191    #[error("intent payload could not be serialized: {0}")]
192    Serialize(String),
193    /// `converge_kernel::admission::admit_observation` rejected the staged proposal.
194    #[error("converge admission failed: {0}")]
195    Converge(String),
196}
197
198impl From<ConvergeError> for IntentAdmissionError {
199    fn from(err: ConvergeError) -> Self {
200        Self::Converge(err.to_string())
201    }
202}
203
204/// The formation guru.
205///
206/// Organism's runtime does exactly three things:
207/// 1. Quick admission gate (is the intent even valid?)
208/// 2. Run formations in Converge (each is a team of heterogeneous Suggestors)
209/// 3. Pick the winner
210///
211/// Everything else — adversarial review, simulation, planning, policy checks —
212/// happens INSIDE the formation as Suggestors in the convergence loop.
213pub struct Runtime;
214
215impl Runtime {
216    pub fn new() -> Self {
217        Self
218    }
219
220    /// Run organism's structural admission gate on an [`IntentPacket`] and
221    /// stage it through Converge's typed admission boundary.
222    ///
223    /// This is the public Organism → Helms contract for getting work into the
224    /// runtime. Callers compile their input (e.g. with `axiom_truth::compile_intent`
225    /// for Truth-shaped sources) into an [`IntentPacket`] and pass it here.
226    ///
227    /// Flow:
228    /// 1. Organism's structural admission gate runs (cheap, deterministic).
229    /// 2. The intent is staged through
230    ///    [`converge_kernel::admission::admit_observation`] under
231    ///    [`ContextKey::Seeds`]. The kernel produces the [`AdmissionReceipt`];
232    ///    promotion to a governed fact happens later through the engine's
233    ///    normal gate.
234    ///
235    /// Returns the [`AdmissionReceipt`] — proof the intent has been staged.
236    /// The caller already holds the `IntentPacket` and can use it directly to
237    /// drive resolution and planning.
238    ///
239    /// # Errors
240    ///
241    /// Returns [`IntentAdmissionError`] if the intent fails the admission
242    /// gate, or fails Converge admission validation.
243    pub fn admit_intent(
244        &self,
245        intent: &IntentPacket,
246        actor: AdmissionActor,
247        source: AdmissionSource,
248        context: &mut ContextState,
249    ) -> Result<AdmissionReceipt, IntentAdmissionError> {
250        gate_admission(intent).map_err(|err| match err {
251            PipelineError::Rejected(msg) => IntentAdmissionError::Rejected(msg),
252            other => IntentAdmissionError::Rejected(other.to_string()),
253        })?;
254
255        let payload = serde_json::to_string(intent)
256            .map_err(|err| IntentAdmissionError::Serialize(err.to_string()))?;
257        let admission_body = AdmissionContent::new(payload)?;
258        let request = AdmissionRequest::new(
259            actor,
260            source,
261            ContextKey::Seeds,
262            format!("intent:{}", intent.id),
263            admission_body,
264        )?;
265        let receipt = admit_observation(context, request)?;
266        Ok(receipt)
267    }
268
269    /// Pick a formation template for `intent` from `catalog` given the host's
270    /// available `capabilities`. The guru classifies the intent, queries the
271    /// catalog by class-derived keywords, and post-filters by the host's
272    /// declared capability inventory. Returns the chosen primary plus up to
273    /// two alternates and a [`SelectionTrace`] explaining the choice.
274    ///
275    /// This is auto-mode's *front half* — selection without execution. To run
276    /// the chosen template, build a [`FormationCompileRequest`] keyed on
277    /// `selection.primary.id()` and call [`compile_and_run_formation`].
278    ///
279    /// # Errors
280    ///
281    /// Returns [`GuruError`] if no template in `catalog` satisfies the
282    /// classified problem under `capabilities`.
283    pub fn select_formation<'cat>(
284        &self,
285        intent: &IntentPacket,
286        catalog: &'cat FormationCatalog,
287        capabilities: &[SuggestorCapability],
288    ) -> Result<GuruSelection<'cat>, GuruError> {
289        FormationGuru::new(catalog).select(intent, capabilities)
290    }
291
292    /// Admit an intent and compile the formation plan Organism would run.
293    ///
294    /// This is the pure compiler boundary: descriptor catalogs produce an
295    /// auditable formation plan without creating live suggestor instances.
296    pub fn compile_formation(
297        &self,
298        intent: &IntentPacket,
299        request: &FormationCompileRequest,
300        catalogs: &FormationCompilerCatalogs,
301    ) -> Result<CompiledFormationPlan, PipelineError> {
302        gate_admission(intent)?;
303        Ok(FormationCompiler::new().compile(request, catalogs)?)
304    }
305
306    /// Admit, compile, and instantiate a runnable formation from registered
307    /// executable suggestor factories.
308    ///
309    /// This keeps the boundary honest: a plan can run only when every compiled
310    /// `suggestor_id` has a concrete factory in `executables`.
311    pub fn compile_and_instantiate_formation(
312        &self,
313        intent: &IntentPacket,
314        request: &FormationCompileRequest,
315        catalogs: &FormationCompilerCatalogs,
316        executables: &ExecutableSuggestorCatalog,
317        seeds: impl IntoIterator<Item = Seed>,
318    ) -> Result<(CompiledFormationPlan, Formation), PipelineError> {
319        let plan = self.compile_formation(intent, request, catalogs)?;
320        let formation = executables.instantiate(&plan, seeds)?;
321        Ok((plan, formation))
322    }
323
324    /// Admit, compile, instantiate, and run one formation candidate.
325    ///
326    /// This is the single-candidate execution path. Tournaments can build on
327    /// top of this by running multiple compile requests and comparing returned
328    /// `FormationExecutionRecord` values.
329    pub async fn compile_and_run_formation(
330        &self,
331        intent: &IntentPacket,
332        request: &FormationCompileRequest,
333        catalogs: &FormationCompilerCatalogs,
334        executables: &ExecutableSuggestorCatalog,
335        seeds: impl IntoIterator<Item = Seed>,
336        observer: Option<Arc<dyn converge_kernel::ExperienceEventObserver>>,
337    ) -> Result<FormationExecutionRecord, PipelineError> {
338        let (plan, formation) =
339            self.compile_and_instantiate_formation(intent, request, catalogs, executables, seeds)?;
340        let result = if let Some(observer) = observer {
341            formation.run_with_event_observer(observer).await?
342        } else {
343            formation.run().await?
344        };
345
346        Ok(FormationExecutionRecord::from_plan_and_result(plan, result))
347    }
348
349    // -- Catalog-aware compile path ----------------------------------------
350    //
351    // These methods source Suggestor candidates from a `DiscoveryCatalog`
352    // (organism-catalog) via deterministic structural filters, and return
353    // the structured per-role decision trace so callers can explain why
354    // each specialist is present or absent. `advisory_order` is an
355    // optional ranked list of descriptor IDs from an out-of-band advisor
356    // (e.g. an LLM-backed `CatalogLookup`); the compiler uses it strictly
357    // as a tie-breaker after deterministic scoring — never as authority.
358
359    /// Admit an intent and compile a formation plan from a
360    /// [`DiscoveryCatalog`]. Catalog-aware parallel to
361    /// [`Self::compile_formation`].
362    pub fn compile_formation_from_catalog(
363        &self,
364        intent: &IntentPacket,
365        request: &FormationCompileRequest,
366        formation_templates: &FormationCatalog,
367        catalog: &DiscoveryCatalog,
368        providers: &ProviderDescriptorCatalog,
369        advisory_order: Option<&[String]>,
370    ) -> Result<CompiledFormationPlan, PipelineError> {
371        gate_admission(intent)?;
372        Ok(FormationCompiler::new().compile_from_catalog(
373            request,
374            formation_templates,
375            catalog,
376            providers,
377            advisory_order,
378        )?)
379    }
380
381    /// Admit, compile from catalog, and instantiate a runnable formation.
382    /// Catalog-aware parallel to [`Self::compile_and_instantiate_formation`].
383    #[allow(clippy::too_many_arguments)]
384    pub fn compile_and_instantiate_from_catalog(
385        &self,
386        intent: &IntentPacket,
387        request: &FormationCompileRequest,
388        formation_templates: &FormationCatalog,
389        catalog: &DiscoveryCatalog,
390        providers: &ProviderDescriptorCatalog,
391        executables: &ExecutableSuggestorCatalog,
392        seeds: impl IntoIterator<Item = Seed>,
393        advisory_order: Option<&[String]>,
394    ) -> Result<(CompiledFormationPlan, Formation), PipelineError> {
395        let outcome = self.compile_formation_from_catalog(
396            intent,
397            request,
398            formation_templates,
399            catalog,
400            providers,
401            advisory_order,
402        )?;
403        let formation = executables.instantiate(&outcome, seeds)?;
404        Ok((outcome, formation))
405    }
406
407    /// Source `k` candidate rosters from the catalog, instantiate each,
408    /// and run a [`FormationTournament`] to pick the winner.
409    ///
410    /// Each candidate covers the same formation template requirements
411    /// but draws a different roster from the catalog (via swap-out
412    /// diversity — see [`FormationCompiler::compile_k_candidates`]).
413    /// The returned [`CatalogTournamentOutcome`] carries both the
414    /// tournament result (winner + scores + priors) and each candidate's
415    /// [`CompiledFormationPlan`] (with its `decisions` trace) so the
416    /// audit trail shows selection rationale AND score outcome
417    /// side-by-side.
418    ///
419    /// `seeds_fn` is called once per candidate to produce its seed
420    /// inventory — formations consume their seeds when run, so each
421    /// candidate needs its own fresh `Vec<Seed>`.
422    #[allow(clippy::too_many_arguments)]
423    pub async fn compile_k_and_run_tournament<F>(
424        &self,
425        intent: &IntentPacket,
426        request: &FormationCompileRequest,
427        formation_templates: &FormationCatalog,
428        catalog: &DiscoveryCatalog,
429        providers: &ProviderDescriptorCatalog,
430        executables: &ExecutableSuggestorCatalog,
431        seeds_fn: F,
432        k: usize,
433    ) -> Result<CatalogTournamentOutcome, PipelineError>
434    where
435        F: Fn(usize, &CompiledFormationPlan) -> Vec<Seed>,
436    {
437        gate_admission(intent)?;
438
439        let candidates = FormationCompiler::new().compile_k_candidates(
440            request,
441            formation_templates,
442            catalog,
443            providers,
444            k,
445        )?;
446
447        if candidates.is_empty() {
448            return Err(PipelineError::Tournament(
449                "compile_k_candidates returned no candidates".to_string(),
450            ));
451        }
452
453        let mut formations: Vec<Formation> = Vec::with_capacity(candidates.len());
454        for (index, candidate) in candidates.iter().enumerate() {
455            let seeds = seeds_fn(index, candidate);
456            // Unique label per candidate so the tournament's
457            // FormationScore.label is the join key back to the
458            // originating candidate. Format: "{template_id}#{index}".
459            let label = format!("{}#{index}", candidate.template_id);
460            let formation = executables.instantiate_with_label(candidate, seeds, label)?;
461            formations.push(formation);
462        }
463
464        let tournament = FormationTournament::new(intent.id, request.plan_id, formations);
465        let tournament_result = tournament
466            .run()
467            .await
468            .map_err(|err| PipelineError::Tournament(err.to_string()))?;
469
470        // Pair each FormationScore back to its candidate by parsing the
471        // index suffix from the label.
472        let mut scored_candidates: Vec<ScoredCatalogCandidate> =
473            Vec::with_capacity(candidates.len());
474        for score in &tournament_result.all_scores {
475            let index = candidate_index_from_label(&score.label).ok_or_else(|| {
476                PipelineError::Tournament(format!(
477                    "tournament returned an unjoinable score label: {label}",
478                    label = score.label
479                ))
480            })?;
481            let candidate = candidates.get(index).cloned().ok_or_else(|| {
482                PipelineError::Tournament(format!(
483                    "score index {index} out of range (k = {})",
484                    candidates.len()
485                ))
486            })?;
487            scored_candidates.push(ScoredCatalogCandidate {
488                index,
489                candidate,
490                score: score.clone(),
491            });
492        }
493        // Sort by index so the order matches the original
494        // compile_k_candidates output for predictable consumption.
495        scored_candidates.sort_by_key(|sc| sc.index);
496
497        let winner_index =
498            candidate_index_from_label(&tournament_result.winner.label).ok_or_else(|| {
499                PipelineError::Tournament(format!(
500                    "tournament winner has unjoinable label: {label}",
501                    label = tournament_result.winner.label
502                ))
503            })?;
504
505        Ok(CatalogTournamentOutcome {
506            winner_index,
507            scored_candidates,
508            priors: tournament_result.priors,
509        })
510    }
511
512    /// Drive an intent through the pipeline.
513    ///
514    /// The caller is responsible for assembling formations (teams of Suggestors).
515    /// That's the formation-guru logic — deciding which agents to include based
516    /// on the intent's characteristics, available capabilities, and learned priors.
517    ///
518    /// Each formation may include any mix of:
519    /// - LLM reasoning agents
520    /// - Optimization solvers
521    /// - Policy gates
522    /// - Analytics/ML agents
523    /// - Adversarial skeptics
524    /// - Domain-specific pack agents
525    ///
526    /// All participate through the same `Suggestor` trait. Same contract,
527    /// same governance, same convergence loop.
528    pub async fn handle(
529        &self,
530        intent: IntentPacket,
531        formations: Vec<Formation>,
532    ) -> Result<OrganismResult, PipelineError> {
533        // 1. Admission — the one imperative check that stays outside the loop.
534        //    Is the intent structurally valid? Not expired? Not empty?
535        gate_admission(&intent)?;
536
537        // 2. Run formations (concurrently in the future; sequential for now).
538        //    Each formation is a complete Converge Engine run with its own
539        //    team of Suggestors. Adversarial agents, simulators, planners —
540        //    they're all in there, converging together.
541        let mut results = Vec::new();
542        let mut errors = Vec::new();
543
544        for formation in formations {
545            match formation.run().await {
546                Ok(result) => results.push(result),
547                Err(e) => errors.push(e.to_string()),
548            }
549        }
550
551        if results.is_empty() {
552            return Err(PipelineError::AllFormationsFailed(errors.join("; ")));
553        }
554
555        // 3. Pick the winner.
556        //    Future: evaluate competing results via learned quality metrics,
557        //    convergence quality, cycle count, fact coverage.
558        let winner = results.into_iter().next().unwrap();
559
560        Ok(OrganismResult {
561            winning_formation: winner.label,
562            converge_result: winner.converge_result,
563        })
564    }
565}
566
567/// Extract the candidate index from a label of the form
568/// `{template_id}#{index}` as produced by
569/// [`Runtime::compile_k_and_run_tournament`]. Returns `None` if the
570/// label has no `#` or the suffix is not a valid `usize`.
571fn candidate_index_from_label(label: &str) -> Option<usize> {
572    label.rsplit_once('#')?.1.parse::<usize>().ok()
573}
574
575fn gate_admission(intent: &IntentPacket) -> Result<(), PipelineError> {
576    match admission::admit(intent) {
577        Admission::Admit => Ok(()),
578        Admission::Reject(err) => Err(PipelineError::Rejected(err.to_string())),
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use crate::provenance::ORGANISM_RUNTIME_PROVENANCE;
586    use chrono::{Duration, Utc};
587    use converge_kernel::formation::{
588        FormationTemplateQuery, ProfileSnapshot, SuggestorCapability, SuggestorRole,
589    };
590    use converge_kernel::{AgentEffect, Context, ContextKey, Suggestor};
591    use converge_pack::{Provenance, ProvenanceSource, TextPayload};
592    use converge_provider::{BackendRequirements, CostClass, LatencyClass};
593
594    fn id(n: u128) -> uuid::Uuid {
595        uuid::Uuid::from_u128(n)
596    }
597
598    fn valid_intent() -> IntentPacket {
599        IntentPacket::new("select the best AI vendor", Utc::now() + Duration::hours(1))
600    }
601
602    /// HIGH #1 regression. Verify [`CatalogTournamentOutcome::winner`]
603    /// looks up by `sc.index`, not by array position. When a
604    /// non-winning candidate fails at runtime, `FormationTournament`
605    /// drops it from `scored_candidates` — but `winner_index` stays
606    /// as the original candidate index. Indexing
607    /// `scored_candidates[winner_index]` would panic; finding by
608    /// `sc.index == winner_index` is safe.
609    #[test]
610    fn catalog_tournament_winner_lookup_safe_when_lower_index_dropped() {
611        // Construct an outcome that mimics: candidate 0 was dropped,
612        // candidate 1 won. scored_candidates has length 1 with
613        // index = 1; winner_index = 1.
614        let candidate = CompiledFormationPlan {
615            plan_id: id(0xCAFE),
616            correlation_id: id(0xBEEF),
617            tenant_id: None,
618            template_id: "winner-template".into(),
619            template_kind: converge_kernel::formation::FormationKind::Static,
620            roster: Vec::new(),
621            provider_assignments: Vec::new(),
622            trace: Vec::new(),
623            decisions: Vec::new(),
624        };
625        let score = FormationScore {
626            label: "winner-template#1".to_string(),
627            score: 0.9,
628            converged: true,
629            cycles: 1,
630            criteria_met: 0,
631            criteria_total: 0,
632        };
633        let outcome = CatalogTournamentOutcome {
634            winner_index: 1,
635            scored_candidates: vec![ScoredCatalogCandidate {
636                index: 1,
637                candidate,
638                score,
639            }],
640            priors: Vec::new(),
641        };
642
643        // Before the fix: scored_candidates[winner_index] would index
644        // position 1 in a length-1 Vec → out-of-bounds panic.
645        let winner = outcome.winner().expect("winner must be present");
646        assert_eq!(winner.index, 1);
647        assert!((winner.score.score - 0.9).abs() < f64::EPSILON);
648    }
649
650    fn profile(
651        name: &str,
652        role: SuggestorRole,
653        writes: Vec<ContextKey>,
654        capabilities: Vec<SuggestorCapability>,
655    ) -> ProfileSnapshot {
656        ProfileSnapshot {
657            name: name.to_string(),
658            role,
659            output_keys: writes,
660            cost_hint: CostClass::Low,
661            latency_hint: LatencyClass::Interactive,
662            capabilities,
663            confidence_min: 0.7,
664            confidence_max: 0.95,
665        }
666    }
667
668    fn request() -> FormationCompileRequest {
669        FormationCompileRequest::new(
670            id(1),
671            id(2),
672            FormationTemplateQuery::new()
673                .with_keyword("diligence-evaluate-decide")
674                .with_entity("VendorSelectionDecisionRecord"),
675        )
676        .with_tenant_id("tenant-a")
677        .with_domain_tag("vendor-selection")
678    }
679
680    fn catalogs() -> FormationCompilerCatalogs {
681        let policy_requirements = BackendRequirements::access_policy().with_replay();
682        FormationCompilerCatalogs::new(vendor_selection_formation_catalog())
683            .with_suggestor(
684                SuggestorDescriptor::new(
685                    "market-scan",
686                    profile(
687                        "market-scan",
688                        SuggestorRole::Signal,
689                        vec![ContextKey::Signals],
690                        vec![SuggestorCapability::KnowledgeRetrieval],
691                    ),
692                )
693                .with_domain_tag("vendor-selection"),
694            )
695            .with_suggestor(
696                SuggestorDescriptor::new(
697                    "weighted-evaluator",
698                    profile(
699                        "weighted-evaluator",
700                        SuggestorRole::Evaluation,
701                        vec![ContextKey::Evaluations],
702                        vec![SuggestorCapability::Analytics],
703                    ),
704                )
705                .with_domain_tag("vendor-selection"),
706            )
707            .with_suggestor(
708                SuggestorDescriptor::new(
709                    "policy-gate",
710                    profile(
711                        "policy-gate",
712                        SuggestorRole::Constraint,
713                        vec![ContextKey::Constraints],
714                        vec![SuggestorCapability::PolicyEnforcement],
715                    ),
716                )
717                .with_domain_tag("vendor-selection")
718                .with_backend_requirements(policy_requirements.clone()),
719            )
720            .with_suggestor(
721                SuggestorDescriptor::new(
722                    "decision-synthesis",
723                    profile(
724                        "decision-synthesis",
725                        SuggestorRole::Synthesis,
726                        vec![ContextKey::Proposals],
727                        vec![SuggestorCapability::LlmReasoning],
728                    ),
729                )
730                .with_domain_tag("vendor-selection"),
731            )
732            .with_provider(
733                ProviderDescriptor::new(
734                    "cedar-local",
735                    "Cedar local policy engine",
736                    policy_requirements,
737                )
738                .with_role_affinity(SuggestorRole::Constraint)
739                .with_domain_tag("vendor-selection"),
740            )
741    }
742
743    struct FixtureSuggestor {
744        name: &'static str,
745        dependencies: Vec<ContextKey>,
746        output: ContextKey,
747    }
748
749    impl FixtureSuggestor {
750        fn new(name: &'static str, dependencies: Vec<ContextKey>, output: ContextKey) -> Self {
751            Self {
752                name,
753                dependencies,
754                output,
755            }
756        }
757    }
758
759    #[async_trait::async_trait]
760    impl Suggestor for FixtureSuggestor {
761        fn name(&self) -> &str {
762            self.name
763        }
764
765        fn dependencies(&self) -> &[ContextKey] {
766            &self.dependencies
767        }
768
769        fn provenance(&self) -> Provenance {
770            ORGANISM_RUNTIME_PROVENANCE.provenance()
771        }
772
773        fn accepts(&self, ctx: &dyn Context) -> bool {
774            self.dependencies.iter().any(|key| ctx.has(*key)) && !ctx.has(self.output)
775        }
776
777        async fn execute(&self, _ctx: &dyn Context) -> AgentEffect {
778            AgentEffect::with_proposal(
779                crate::provenance::ORGANISM_RUNTIME_PROVENANCE.proposed_fact(
780                    self.output,
781                    format!("{}-output", self.name),
782                    TextPayload::new(format!("{} produced compiled-role output", self.name)),
783                ),
784            )
785        }
786    }
787
788    fn executable_catalog() -> ExecutableSuggestorCatalog {
789        let mut catalog = ExecutableSuggestorCatalog::new();
790        catalog
791            .register_factory("market-scan", || {
792                FixtureSuggestor::new("market-scan", vec![ContextKey::Seeds], ContextKey::Signals)
793            })
794            .expect("market-scan factory");
795        catalog
796            .register_factory("weighted-evaluator", || {
797                FixtureSuggestor::new(
798                    "weighted-evaluator",
799                    vec![ContextKey::Signals],
800                    ContextKey::Evaluations,
801                )
802            })
803            .expect("weighted-evaluator factory");
804        catalog
805            .register_factory("policy-gate", || {
806                FixtureSuggestor::new(
807                    "policy-gate",
808                    vec![ContextKey::Evaluations],
809                    ContextKey::Constraints,
810                )
811            })
812            .expect("policy-gate factory");
813        catalog
814            .register_factory("decision-synthesis", || {
815                FixtureSuggestor::new(
816                    "decision-synthesis",
817                    vec![ContextKey::Evaluations, ContextKey::Constraints],
818                    ContextKey::Proposals,
819                )
820            })
821            .expect("decision-synthesis factory");
822        catalog
823    }
824
825    #[test]
826    fn runtime_selects_decision_template_for_decision_intent() {
827        let catalog = standard_formation_catalog();
828        let caps = [
829            SuggestorCapability::LlmReasoning,
830            SuggestorCapability::PolicyEnforcement,
831            SuggestorCapability::Analytics,
832        ];
833        let intent = IntentPacket::new(
834            "decide which vendor to approve",
835            Utc::now() + Duration::hours(1),
836        );
837
838        let selection = Runtime::new()
839            .select_formation(&intent, &catalog, &caps)
840            .expect("decision intent matches the standard catalog");
841
842        assert_eq!(selection.primary.id(), "organism-decision");
843        assert_eq!(
844            selection.classification.class,
845            organism_intent::problem::ProblemClass::Decision
846        );
847    }
848
849    #[test]
850    fn runtime_compiles_after_admission() {
851        let plan = Runtime::new()
852            .compile_formation(&valid_intent(), &request(), &catalogs())
853            .expect("valid vendor-selection intent should compile");
854
855        assert_eq!(plan.template_id, "vendor-selection-decide");
856        assert_eq!(plan.correlation_id, id(2));
857        assert_eq!(plan.tenant_id.as_deref(), Some("tenant-a"));
858    }
859
860    #[test]
861    fn runtime_rejects_invalid_intent_before_compile() {
862        let invalid_intent = IntentPacket::new("   ", Utc::now() + Duration::hours(1));
863
864        let error = Runtime::new()
865            .compile_formation(&invalid_intent, &request(), &catalogs())
866            .expect_err("blank intent should fail admission");
867
868        assert!(matches!(error, PipelineError::Rejected(_)));
869    }
870
871    #[tokio::test]
872    async fn runtime_compiles_and_runs_executable_plan_with_outcome_record() {
873        let seed = Seed {
874            key: ContextKey::Seeds,
875            id: "vendor-selection-intent".into(),
876            content: "select the AI governance vendor".to_string(),
877            provenance: ORGANISM_RUNTIME_PROVENANCE.provenance(),
878        };
879
880        let record = Runtime::new()
881            .compile_and_run_formation(
882                &valid_intent(),
883                &request(),
884                &catalogs(),
885                &executable_catalog(),
886                vec![seed],
887                None,
888            )
889            .await
890            .expect("plan should compile and run");
891
892        assert_eq!(record.plan.template_id, "vendor-selection-decide");
893        assert_eq!(record.outcome.status, FormationOutcomeStatus::Converged);
894        assert_eq!(record.outcome.scope.correlation_id, id(2));
895
896        assert!(record.result.converge_result.converged);
897        assert!(
898            record
899                .result
900                .converge_result
901                .context
902                .has(ContextKey::Signals)
903        );
904        assert!(
905            record
906                .result
907                .converge_result
908                .context
909                .has(ContextKey::Evaluations)
910        );
911        assert!(
912            record
913                .result
914                .converge_result
915                .context
916                .has(ContextKey::Constraints)
917        );
918        assert!(
919            record
920                .result
921                .converge_result
922                .context
923                .has(ContextKey::Proposals)
924        );
925    }
926
927    #[test]
928    fn runtime_reports_missing_executable_factories() {
929        let seed = Seed {
930            key: ContextKey::Seeds,
931            id: "vendor-selection-intent".into(),
932            content: "select the AI governance vendor".to_string(),
933            provenance: ORGANISM_RUNTIME_PROVENANCE.provenance(),
934        };
935        let mut partial = ExecutableSuggestorCatalog::new();
936        partial
937            .register_factory("market-scan", || {
938                FixtureSuggestor::new("market-scan", vec![ContextKey::Seeds], ContextKey::Signals)
939            })
940            .expect("market-scan factory");
941
942        let Err(error) = Runtime::new().compile_and_instantiate_formation(
943            &valid_intent(),
944            &request(),
945            &catalogs(),
946            &partial,
947            vec![seed],
948        ) else {
949            panic!("missing executable factories should fail explicitly");
950        };
951
952        match error {
953            PipelineError::Instantiate(
954                FormationInstantiationError::MissingSuggestorFactories { suggestor_ids },
955            ) => {
956                assert!(suggestor_ids.contains(&"weighted-evaluator".to_string()));
957                assert!(suggestor_ids.contains(&"policy-gate".to_string()));
958                assert!(suggestor_ids.contains(&"decision-synthesis".to_string()));
959            }
960            other => panic!("unexpected error: {other:?}"),
961        }
962    }
963}