Skip to main content

organism_runtime/
compiler.rs

1//! Formation compiler — Organism-owned selection before Converge execution.
2//!
3//! The compiler turns a business intent classification into an executable
4//! formation plan. Converge still owns execution, promotion, gates, and audit.
5
6use converge_kernel::ContextKey;
7use converge_kernel::formation::{
8    FormationCatalog, FormationKind, FormationTemplateQuery, SuggestorCapability, SuggestorRole,
9};
10use converge_provider::{BackendRequirements, ComplianceLevel, DataSovereignty};
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14use organism_catalog::{CatalogSuggestorDescriptor, DiscoveryCatalog};
15pub use organism_catalog::{
16    DataContract, GovernanceClass, ProviderDescriptor, ProviderDescriptorCatalog, ProviderId,
17    ReplayMode, SuggestorDescriptor, SuggestorDescriptorCatalog, SuggestorDescriptorId,
18};
19
20/// Stable, human-readable identifier for a formation template (e.g.
21/// `"work-template"`, `"vendor-selection-decide"`). Wraps the
22/// `FormationTemplateMetadata::id` string from `converge_pack` so
23/// Organism code passes a typed handle around instead of bare strings,
24/// while remaining wire-compatible (serializes as the bare string).
25#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
26#[serde(transparent)]
27pub struct FormationTemplateId(String);
28
29impl FormationTemplateId {
30    #[must_use]
31    pub fn new(id: impl Into<String>) -> Self {
32        Self(id.into())
33    }
34    #[must_use]
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38    #[must_use]
39    pub fn into_inner(self) -> String {
40        self.0
41    }
42}
43
44impl std::fmt::Display for FormationTemplateId {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.write_str(&self.0)
47    }
48}
49
50impl AsRef<str> for FormationTemplateId {
51    fn as_ref(&self) -> &str {
52        &self.0
53    }
54}
55
56impl std::borrow::Borrow<str> for FormationTemplateId {
57    fn borrow(&self) -> &str {
58        &self.0
59    }
60}
61
62impl std::ops::Deref for FormationTemplateId {
63    type Target = str;
64    fn deref(&self) -> &str {
65        &self.0
66    }
67}
68
69impl From<&str> for FormationTemplateId {
70    fn from(s: &str) -> Self {
71        Self(s.to_string())
72    }
73}
74
75impl From<String> for FormationTemplateId {
76    fn from(s: String) -> Self {
77        Self(s)
78    }
79}
80
81impl From<&String> for FormationTemplateId {
82    fn from(s: &String) -> Self {
83        Self(s.clone())
84    }
85}
86
87impl From<FormationTemplateId> for String {
88    fn from(id: FormationTemplateId) -> Self {
89        id.0
90    }
91}
92
93impl PartialEq<str> for FormationTemplateId {
94    fn eq(&self, other: &str) -> bool {
95        self.0 == other
96    }
97}
98
99impl PartialEq<&str> for FormationTemplateId {
100    fn eq(&self, other: &&str) -> bool {
101        self.0.as_str() == *other
102    }
103}
104
105impl PartialEq<String> for FormationTemplateId {
106    fn eq(&self, other: &String) -> bool {
107        &self.0 == other
108    }
109}
110
111impl PartialEq<FormationTemplateId> for &str {
112    fn eq(&self, other: &FormationTemplateId) -> bool {
113        *self == other.0.as_str()
114    }
115}
116
117impl PartialEq<FormationTemplateId> for String {
118    fn eq(&self, other: &FormationTemplateId) -> bool {
119        self == &other.0
120    }
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct FormationCompilerCatalogs {
125    pub formation_templates: FormationCatalog,
126    pub suggestors: SuggestorDescriptorCatalog,
127    pub providers: ProviderDescriptorCatalog,
128}
129
130impl FormationCompilerCatalogs {
131    pub fn new(formation_templates: FormationCatalog) -> Self {
132        Self {
133            formation_templates,
134            suggestors: SuggestorDescriptorCatalog::new(),
135            providers: ProviderDescriptorCatalog::new(),
136        }
137    }
138
139    pub fn with_suggestor(mut self, descriptor: SuggestorDescriptor) -> Self {
140        self.suggestors.register(descriptor);
141        self
142    }
143
144    pub fn with_provider(mut self, descriptor: ProviderDescriptor) -> Self {
145        self.providers.register(descriptor);
146        self
147    }
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct FormationCompileRequest {
152    pub plan_id: Uuid,
153    pub correlation_id: Uuid,
154    pub tenant_id: Option<String>,
155    pub query: FormationTemplateQuery,
156    pub domain_tags: Vec<String>,
157}
158
159impl FormationCompileRequest {
160    pub fn new(plan_id: Uuid, correlation_id: Uuid, query: FormationTemplateQuery) -> Self {
161        Self {
162            plan_id,
163            correlation_id,
164            tenant_id: None,
165            query,
166            domain_tags: Vec::new(),
167        }
168    }
169
170    pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
171        self.tenant_id = Some(tenant_id.into());
172        self
173    }
174
175    pub fn with_domain_tag(mut self, tag: impl Into<String>) -> Self {
176        self.domain_tags.push(tag.into());
177        self
178    }
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct CompiledSuggestorRole {
183    pub suggestor_id: SuggestorDescriptorId,
184    pub role: SuggestorRole,
185    pub capabilities: Vec<SuggestorCapability>,
186    pub reads: Vec<ContextKey>,
187    pub writes: Vec<ContextKey>,
188    pub input_contracts: Vec<DataContract>,
189    pub output_contracts: Vec<DataContract>,
190    pub replay_mode: ReplayMode,
191    pub governance_class: GovernanceClass,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct RoleProviderAssignment {
196    pub suggestor_id: SuggestorDescriptorId,
197    pub role: SuggestorRole,
198    pub provider_id: ProviderId,
199    pub requirements: BackendRequirements,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct CompiledFormationPlan {
204    pub plan_id: Uuid,
205    pub correlation_id: Uuid,
206    pub tenant_id: Option<String>,
207    pub template_id: FormationTemplateId,
208    pub template_kind: FormationKind,
209    pub roster: Vec<CompiledSuggestorRole>,
210    pub provider_assignments: Vec<RoleProviderAssignment>,
211    pub trace: Vec<String>,
212    /// Per-role decision trace from the catalog-aware compile path
213    /// ([`FormationCompiler::compile_from_catalog`],
214    /// [`FormationCompiler::compile_draft_from_catalog`],
215    /// [`FormationCompiler::compile_k_candidates`]). Empty when this
216    /// plan was produced by the legacy non-catalog [`FormationCompiler::compile`].
217    pub decisions: Vec<RoleDecision>,
218}
219
220/// What was considered, chosen, or omitted when satisfying a single
221/// requirement step during catalog-aware compilation.
222///
223/// `unmatched_roles_at_start` and `unmatched_capabilities_at_start`
224/// capture the *full* outstanding requirement set at the start of the
225/// iteration, not just the first item. This matters because
226/// [`best_from_catalog`] picks globally — a candidate that covers a
227/// later role plus several capabilities can win over one that covers
228/// only the first remaining role. `chosen_role` records what was
229/// actually filled, so audit consumers don't have to infer it.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct RoleDecision {
232    /// All roles still unmatched when this iteration began. The compiler
233    /// picks a candidate that maximizes coverage globally — it may fill
234    /// any of these roles, not necessarily the first.
235    pub unmatched_roles_at_start: Vec<SuggestorRole>,
236    /// All capabilities still needed at the start of this iteration.
237    pub unmatched_capabilities_at_start: Vec<SuggestorCapability>,
238    /// Every candidate the catalog surfaced for this iteration, with
239    /// disposition. Includes accepted and rejected candidates.
240    pub considered: Vec<CandidateConsideration>,
241    /// The descriptor id selected, or None if the iteration ended in
242    /// `UncoveredRequirements`.
243    pub chosen: Option<SuggestorDescriptorId>,
244    /// The role of the chosen descriptor, if any. May or may not appear
245    /// in `unmatched_roles_at_start` — the greedy ranker can select a
246    /// descriptor whose role was already satisfied if it still covers
247    /// remaining capabilities.
248    pub chosen_role: Option<SuggestorRole>,
249}
250
251/// Disposition of a single candidate descriptor during a [`RoleDecision`].
252/// Discriminated so trace consumers can match instead of parsing prose.
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct CandidateConsideration {
255    pub descriptor_id: SuggestorDescriptorId,
256    pub disposition: CandidateDisposition,
257}
258
259/// Why a candidate was selected or rejected.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(tag = "kind", rename_all = "snake_case")]
262pub enum CandidateDisposition {
263    /// Candidate was selected to fill this role iteration.
264    Selected { reason: SelectionReason },
265    /// Candidate was considered and rejected.
266    Rejected { reason: RejectionReason },
267}
268
269/// Structured reason a candidate was selected. Carries the relevance
270/// signals so downstream consumers (UI, audit, tournament) can reason
271/// about the choice without parsing prose.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct SelectionReason {
274    /// Number of unmatched roles + unmatched capabilities this candidate
275    /// covers (the primary ranking key).
276    pub coverage: usize,
277    /// Number of domain-tag intersections with the compile request.
278    pub domain_hits: usize,
279    /// Whether the candidate appeared in an externally-supplied advisory
280    /// ranking (e.g. from an LLM lookup). Advisory is a tie-breaker, never
281    /// authority.
282    pub advisory_hit: bool,
283}
284
285/// Structured reason a candidate was rejected.
286#[derive(Debug, Clone, Serialize, Deserialize)]
287#[serde(tag = "kind", rename_all = "snake_case")]
288pub enum RejectionReason {
289    /// Already chosen in an earlier role iteration.
290    AlreadySelected,
291    /// Did not cover any remaining role or capability.
292    NoCoverage,
293    /// Outranked by another candidate with better coverage / domain affinity.
294    Outranked {
295        chosen_id: SuggestorDescriptorId,
296        own_coverage: usize,
297        own_domain_hits: usize,
298    },
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
302pub enum FormationCompileError {
303    #[error("no formation template matched the compile request")]
304    NoTemplate,
305    #[error("formation requirements were not covered")]
306    UncoveredRequirements {
307        unmatched_roles: Vec<SuggestorRole>,
308        unmatched_capabilities: Vec<SuggestorCapability>,
309    },
310    #[error("no provider matched backend requirements for suggestor '{suggestor_id}'")]
311    MissingProvider {
312        suggestor_id: SuggestorDescriptorId,
313        role: SuggestorRole,
314    },
315    /// A draft passed to [`FormationCompiler::compile_draft_from_catalog`]
316    /// referenced a descriptor id that does not exist in the catalog.
317    #[error("draft references unknown descriptor '{descriptor_id}'")]
318    DraftDescriptorMissing {
319        descriptor_id: SuggestorDescriptorId,
320    },
321    /// A draft passed to [`FormationCompiler::compile_draft_from_catalog`]
322    /// referenced the same descriptor id more than once.
323    #[error("draft references descriptor '{descriptor_id}' more than once")]
324    DuplicateDraftDescriptor {
325        descriptor_id: SuggestorDescriptorId,
326    },
327}
328
329/// Failure outcome from [`FormationCompiler::compile_from_catalog`].
330/// Carries the underlying [`FormationCompileError`] plus the partial
331/// per-role decision trace built up to the point of failure, so callers
332/// can explain *why* the requirement could not be satisfied.
333#[derive(Debug, Clone, thiserror::Error)]
334#[error("{error}")]
335pub struct CatalogCompileFailure {
336    #[source]
337    pub error: FormationCompileError,
338    pub decisions: Vec<RoleDecision>,
339}
340
341#[derive(Debug, Default)]
342pub struct FormationCompiler;
343
344impl FormationCompiler {
345    pub fn new() -> Self {
346        Self
347    }
348
349    pub fn compile(
350        &self,
351        request: &FormationCompileRequest,
352        catalogs: &FormationCompilerCatalogs,
353    ) -> Result<CompiledFormationPlan, FormationCompileError> {
354        let template = catalogs
355            .formation_templates
356            .top_match(&request.query)
357            .ok_or(FormationCompileError::NoTemplate)?;
358        let metadata = template.metadata();
359
360        let mut unmatched_roles = metadata.required_roles.clone();
361        let mut unmatched_capabilities = unique_capabilities(
362            metadata
363                .required_capabilities
364                .iter()
365                .chain(request.query.required_capabilities.iter())
366                .copied(),
367        );
368        let mut selected: Vec<&SuggestorDescriptor> = Vec::new();
369        let mut trace = vec![format!("selected template '{}'", metadata.id)];
370
371        while !unmatched_roles.is_empty() || !unmatched_capabilities.is_empty() {
372            let Some(next) = best_suggestor(
373                (&catalogs.suggestors).into_iter(),
374                &selected,
375                &unmatched_roles,
376                &unmatched_capabilities,
377                &request.domain_tags,
378            ) else {
379                return Err(FormationCompileError::UncoveredRequirements {
380                    unmatched_roles,
381                    unmatched_capabilities,
382                });
383            };
384
385            trace.push(format!(
386                "selected suggestor '{}' for role {:?}",
387                next.id, next.profile.role
388            ));
389            remove_role(&mut unmatched_roles, next.profile.role);
390            remove_capabilities(&mut unmatched_capabilities, &next.profile.capabilities);
391            selected.push(next);
392        }
393
394        let mut provider_assignments = Vec::new();
395        for descriptor in &selected {
396            let Some(requirements) = &descriptor.backend_requirements else {
397                continue;
398            };
399            let Some(provider) =
400                best_provider((&catalogs.providers).into_iter(), descriptor, requirements)
401            else {
402                return Err(FormationCompileError::MissingProvider {
403                    suggestor_id: descriptor.id.clone(),
404                    role: descriptor.profile.role,
405                });
406            };
407            trace.push(format!(
408                "assigned provider '{}' to suggestor '{}'",
409                provider.id, descriptor.id
410            ));
411            provider_assignments.push(RoleProviderAssignment {
412                suggestor_id: descriptor.id.clone(),
413                role: descriptor.profile.role,
414                provider_id: provider.id.clone(),
415                requirements: requirements.clone(),
416            });
417        }
418
419        let roster = selected
420            .into_iter()
421            .map(|descriptor| CompiledSuggestorRole {
422                suggestor_id: descriptor.id.clone(),
423                role: descriptor.profile.role,
424                capabilities: descriptor.profile.capabilities.clone(),
425                reads: descriptor.reads.clone(),
426                writes: descriptor.profile.output_keys.clone(),
427                input_contracts: descriptor.input_contracts.clone(),
428                output_contracts: descriptor.output_contracts.clone(),
429                replay_mode: descriptor.replay_mode,
430                governance_class: descriptor.governance_class,
431            })
432            .collect();
433
434        Ok(CompiledFormationPlan {
435            plan_id: request.plan_id,
436            correlation_id: request.correlation_id,
437            tenant_id: request.tenant_id.clone(),
438            template_id: metadata.id.clone().into(),
439            template_kind: template.kind(),
440            roster,
441            provider_assignments,
442            trace,
443            decisions: Vec::new(),
444        })
445    }
446
447    /// Catalog-aware compile. Sources Suggestor candidates from a
448    /// [`DiscoveryCatalog`] using structural filters
449    /// ([`DiscoveryCatalog::find_by_role`] /
450    /// [`DiscoveryCatalog::find_by_capability`]), then applies the same
451    /// deterministic coverage-and-affinity ranking as [`Self::compile`].
452    ///
453    /// The returned [`CompiledFormationPlan`] carries a structured
454    /// per-role decision trace under [`CompiledFormationPlan::decisions`],
455    /// so callers can see why each capability was satisfied or left
456    /// uncovered.
457    ///
458    /// `advisory_order` is an optional ranked list of descriptor IDs from
459    /// an out-of-band advisor (e.g. an LLM-backed [`CatalogLookup`]). The
460    /// compiler uses it strictly as a tie-breaker after deterministic
461    /// scoring — it cannot promote a candidate above one with better
462    /// coverage or domain affinity. LLM output is advisory, never
463    /// authority.
464    #[allow(clippy::too_many_lines)]
465    pub fn compile_from_catalog(
466        &self,
467        request: &FormationCompileRequest,
468        formation_templates: &FormationCatalog,
469        catalog: &DiscoveryCatalog,
470        providers: &ProviderDescriptorCatalog,
471        advisory_order: Option<&[String]>,
472    ) -> Result<CompiledFormationPlan, CatalogCompileFailure> {
473        let mut decisions: Vec<RoleDecision> = Vec::new();
474
475        let template = formation_templates
476            .top_match(&request.query)
477            .ok_or_else(|| CatalogCompileFailure {
478                error: FormationCompileError::NoTemplate,
479                decisions: decisions.clone(),
480            })?;
481        let metadata = template.metadata();
482
483        let mut unmatched_roles = metadata.required_roles.clone();
484        let mut unmatched_capabilities = unique_capabilities(
485            metadata
486                .required_capabilities
487                .iter()
488                .chain(request.query.required_capabilities.iter())
489                .copied(),
490        );
491        let mut selected: Vec<&CatalogSuggestorDescriptor> = Vec::new();
492        let mut trace = vec![format!("selected template '{}'", metadata.id)];
493
494        while !unmatched_roles.is_empty() || !unmatched_capabilities.is_empty() {
495            let unmatched_roles_at_start = unmatched_roles.clone();
496            let unmatched_capabilities_at_start = unmatched_capabilities.clone();
497
498            let (chosen, considered) = best_from_catalog(
499                catalog,
500                &selected,
501                &unmatched_roles,
502                &unmatched_capabilities,
503                &request.domain_tags,
504                advisory_order,
505            );
506
507            let Some(next) = chosen else {
508                decisions.push(RoleDecision {
509                    unmatched_roles_at_start,
510                    unmatched_capabilities_at_start,
511                    considered,
512                    chosen: None,
513                    chosen_role: None,
514                });
515                return Err(CatalogCompileFailure {
516                    error: FormationCompileError::UncoveredRequirements {
517                        unmatched_roles,
518                        unmatched_capabilities,
519                    },
520                    decisions,
521                });
522            };
523
524            trace.push(format!(
525                "selected suggestor '{}' for role {:?}",
526                next.descriptor.id, next.descriptor.profile.role
527            ));
528            let chosen_id = next.descriptor.id.clone();
529            let chosen_role = next.descriptor.profile.role;
530            decisions.push(RoleDecision {
531                unmatched_roles_at_start,
532                unmatched_capabilities_at_start,
533                considered,
534                chosen: Some(chosen_id),
535                chosen_role: Some(chosen_role),
536            });
537            remove_role(&mut unmatched_roles, next.descriptor.profile.role);
538            remove_capabilities(
539                &mut unmatched_capabilities,
540                &next.descriptor.profile.capabilities,
541            );
542            selected.push(next);
543        }
544
545        let mut provider_assignments = Vec::new();
546        for entry in &selected {
547            let descriptor = &entry.descriptor;
548            let Some(requirements) = &descriptor.backend_requirements else {
549                continue;
550            };
551            let Some(provider) = best_provider(providers.into_iter(), descriptor, requirements)
552            else {
553                return Err(CatalogCompileFailure {
554                    error: FormationCompileError::MissingProvider {
555                        suggestor_id: descriptor.id.clone(),
556                        role: descriptor.profile.role,
557                    },
558                    decisions,
559                });
560            };
561            trace.push(format!(
562                "assigned provider '{}' to suggestor '{}'",
563                provider.id, descriptor.id
564            ));
565            provider_assignments.push(RoleProviderAssignment {
566                suggestor_id: descriptor.id.clone(),
567                role: descriptor.profile.role,
568                provider_id: provider.id.clone(),
569                requirements: requirements.clone(),
570            });
571        }
572
573        let roster = selected
574            .into_iter()
575            .map(|entry| {
576                let descriptor = &entry.descriptor;
577                CompiledSuggestorRole {
578                    suggestor_id: descriptor.id.clone(),
579                    role: descriptor.profile.role,
580                    capabilities: descriptor.profile.capabilities.clone(),
581                    reads: descriptor.reads.clone(),
582                    writes: descriptor.profile.output_keys.clone(),
583                    input_contracts: descriptor.input_contracts.clone(),
584                    output_contracts: descriptor.output_contracts.clone(),
585                    replay_mode: descriptor.replay_mode,
586                    governance_class: descriptor.governance_class,
587                }
588            })
589            .collect();
590
591        Ok(CompiledFormationPlan {
592            plan_id: request.plan_id,
593            correlation_id: request.correlation_id,
594            tenant_id: request.tenant_id.clone(),
595            template_id: metadata.id.clone().into(),
596            template_kind: template.kind(),
597            roster,
598            provider_assignments,
599            trace,
600            decisions,
601        })
602    }
603
604    /// Exact-roster validator for a draft produced by an upstream
605    /// deliberation Formation (see `organism-dynamics`).
606    ///
607    /// Confirms every id in `descriptor_ids` is unique, resolves in
608    /// `catalog` (returns
609    /// [`FormationCompileError::DraftDescriptorMissing`] for the first
610    /// missing one), and that the supplied roster covers the template's
611    /// required roles + capabilities (returns
612    /// [`FormationCompileError::UncoveredRequirements`] otherwise).
613    /// Does **not** greedy-reselect — the returned plan's roster is
614    /// exactly `descriptor_ids` in the supplied order.
615    ///
616    /// This is intentionally distinct from
617    /// [`Self::compile_from_catalog`]: the catalog-aware path picks
618    /// the best roster from a pool; the draft validator honors an
619    /// upstream Formation's choice and only checks admissibility.
620    ///
621    /// The returned [`CompiledFormationPlan`] carries a single
622    /// [`RoleDecision`] per chosen descriptor under its `decisions`
623    /// field, with empty `considered` (no candidate ranking happened)
624    /// and `chosen_role` derived from the descriptor's profile.
625    #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
626    pub fn compile_draft_from_catalog(
627        &self,
628        request: &FormationCompileRequest,
629        formation_templates: &FormationCatalog,
630        catalog: &DiscoveryCatalog,
631        providers: &ProviderDescriptorCatalog,
632        descriptor_ids: &[SuggestorDescriptorId],
633    ) -> Result<CompiledFormationPlan, CatalogCompileFailure> {
634        let mut decisions: Vec<RoleDecision> = Vec::new();
635
636        let template = formation_templates
637            .top_match(&request.query)
638            .ok_or_else(|| CatalogCompileFailure {
639                error: FormationCompileError::NoTemplate,
640                decisions: decisions.clone(),
641            })?;
642        let metadata = template.metadata();
643
644        // Reject duplicates before resolving. A draft roster is an exact
645        // executable roster, not a weighted vote; repeated ids would
646        // duplicate the same Suggestor in the work Formation and can also
647        // game draft-time scoring.
648        let mut seen_descriptor_ids = std::collections::BTreeSet::new();
649        for id in descriptor_ids {
650            if !seen_descriptor_ids.insert(id.as_str()) {
651                return Err(CatalogCompileFailure {
652                    error: FormationCompileError::DuplicateDraftDescriptor {
653                        descriptor_id: id.clone(),
654                    },
655                    decisions,
656                });
657            }
658        }
659
660        // Resolve every descriptor id — first miss is the failure.
661        let mut selected: Vec<&CatalogSuggestorDescriptor> =
662            Vec::with_capacity(descriptor_ids.len());
663        for id in descriptor_ids {
664            let entry = catalog
665                .get(id.as_str())
666                .ok_or_else(|| CatalogCompileFailure {
667                    error: FormationCompileError::DraftDescriptorMissing {
668                        descriptor_id: id.clone(),
669                    },
670                    decisions: decisions.clone(),
671                })?;
672            selected.push(entry);
673        }
674
675        // Coverage check against the template requirements. The
676        // request may add extra required_capabilities on top of the
677        // template's; match the legacy compile and union them.
678        let mut unmatched_roles = metadata.required_roles.clone();
679        let mut unmatched_capabilities = unique_capabilities(
680            metadata
681                .required_capabilities
682                .iter()
683                .chain(request.query.required_capabilities.iter())
684                .copied(),
685        );
686        for entry in &selected {
687            remove_role(&mut unmatched_roles, entry.descriptor.profile.role);
688            remove_capabilities(
689                &mut unmatched_capabilities,
690                &entry.descriptor.profile.capabilities,
691            );
692        }
693        if !unmatched_roles.is_empty() || !unmatched_capabilities.is_empty() {
694            return Err(CatalogCompileFailure {
695                error: FormationCompileError::UncoveredRequirements {
696                    unmatched_roles,
697                    unmatched_capabilities,
698                },
699                decisions,
700            });
701        }
702
703        // Build a structured trace — one decision per chosen
704        // descriptor, in the order supplied by the draft. No
705        // `considered` candidates: the draft, not the compiler, made
706        // the selection.
707        let mut trace = vec![format!(
708            "validated draft against template '{}'",
709            metadata.id
710        )];
711        for entry in &selected {
712            trace.push(format!(
713                "draft chose suggestor '{}' for role {:?}",
714                entry.descriptor.id, entry.descriptor.profile.role
715            ));
716            decisions.push(RoleDecision {
717                unmatched_roles_at_start: Vec::new(),
718                unmatched_capabilities_at_start: Vec::new(),
719                considered: Vec::new(),
720                chosen: Some(entry.descriptor.id.clone()),
721                chosen_role: Some(entry.descriptor.profile.role),
722            });
723        }
724
725        // Provider assignment — same logic as compile_from_catalog.
726        let mut provider_assignments = Vec::new();
727        for entry in &selected {
728            let descriptor = &entry.descriptor;
729            let Some(requirements) = &descriptor.backend_requirements else {
730                continue;
731            };
732            let Some(provider) = best_provider(providers.into_iter(), descriptor, requirements)
733            else {
734                return Err(CatalogCompileFailure {
735                    error: FormationCompileError::MissingProvider {
736                        suggestor_id: descriptor.id.clone(),
737                        role: descriptor.profile.role,
738                    },
739                    decisions,
740                });
741            };
742            trace.push(format!(
743                "assigned provider '{}' to suggestor '{}'",
744                provider.id, descriptor.id
745            ));
746            provider_assignments.push(RoleProviderAssignment {
747                suggestor_id: descriptor.id.clone(),
748                role: descriptor.profile.role,
749                provider_id: provider.id.clone(),
750                requirements: requirements.clone(),
751            });
752        }
753
754        let roster = selected
755            .into_iter()
756            .map(|entry| {
757                let descriptor = &entry.descriptor;
758                CompiledSuggestorRole {
759                    suggestor_id: descriptor.id.clone(),
760                    role: descriptor.profile.role,
761                    capabilities: descriptor.profile.capabilities.clone(),
762                    reads: descriptor.reads.clone(),
763                    writes: descriptor.profile.output_keys.clone(),
764                    input_contracts: descriptor.input_contracts.clone(),
765                    output_contracts: descriptor.output_contracts.clone(),
766                    replay_mode: descriptor.replay_mode,
767                    governance_class: descriptor.governance_class,
768                }
769            })
770            .collect();
771
772        Ok(CompiledFormationPlan {
773            plan_id: request.plan_id,
774            correlation_id: request.correlation_id,
775            tenant_id: request.tenant_id.clone(),
776            template_id: metadata.id.clone().into(),
777            template_kind: template.kind(),
778            roster,
779            provider_assignments,
780            trace,
781            decisions,
782        })
783    }
784}
785
786fn best_suggestor<'a>(
787    candidates: impl Iterator<Item = &'a SuggestorDescriptor>,
788    selected: &[&SuggestorDescriptor],
789    unmatched_roles: &[SuggestorRole],
790    unmatched_capabilities: &[SuggestorCapability],
791    domain_tags: &[String],
792) -> Option<&'a SuggestorDescriptor> {
793    candidates
794        .filter(|candidate| !selected.iter().any(|chosen| chosen.id == candidate.id))
795        .map(|candidate| {
796            let coverage = suggestor_coverage(candidate, unmatched_roles, unmatched_capabilities);
797            let domain_hits = domain_overlap(&candidate.domain_tags, domain_tags);
798            (candidate, coverage, domain_hits)
799        })
800        .filter(|(_, coverage, _)| *coverage > 0)
801        .max_by(
802            |(left, left_coverage, left_domain), (right, right_coverage, right_domain)| {
803                left_coverage
804                    .cmp(right_coverage)
805                    .then_with(|| left_domain.cmp(right_domain))
806                    .then_with(|| right.profile.cost_hint.cmp(&left.profile.cost_hint))
807                    .then_with(|| right.profile.latency_hint.cmp(&left.profile.latency_hint))
808                    .then_with(|| right.id.cmp(&left.id))
809            },
810        )
811        .map(|(candidate, _, _)| candidate)
812}
813
814fn suggestor_coverage(
815    candidate: &SuggestorDescriptor,
816    unmatched_roles: &[SuggestorRole],
817    unmatched_capabilities: &[SuggestorCapability],
818) -> usize {
819    let role_score = usize::from(unmatched_roles.contains(&candidate.profile.role));
820    let capability_score = unmatched_capabilities
821        .iter()
822        .filter(|capability| candidate.profile.capabilities.contains(capability))
823        .count();
824    role_score + capability_score
825}
826
827impl FormationCompiler {
828    /// Source up to `k` distinct candidate rosters from the same
829    /// [`DiscoveryCatalog`] by iterative swap-out diversity.
830    ///
831    /// After each candidate is compiled, the function tests whether
832    /// excluding each chosen descriptor would still leave the catalog
833    /// capable of satisfying the template — by running a trial
834    /// [`Self::compile_from_catalog`] against the catalog minus the
835    /// descriptor and the current exclude set. A descriptor is added
836    /// to the exclude set for the next iteration only when the trial
837    /// compile succeeds, i.e. the remaining catalog can still produce
838    /// *some* valid roster without that descriptor (possibly via a
839    /// compositional alternative — multiple other descriptors covering
840    /// the slot collectively). This is stricter and more correct than
841    /// the previous heuristic "shares one capability with another
842    /// descriptor of the same role", which could mis-classify a
843    /// broad specialist as swappable even when it was the only
844    /// provider of a separate required capability.
845    ///
846    /// Stops early when the filtered catalog can no longer cover the
847    /// formation requirements — that's the graceful end of the pool,
848    /// not an error. Returns whatever candidates were produced.
849    ///
850    /// Returns the underlying [`CatalogCompileFailure`] **only** when
851    /// the very first iteration fails (i.e. the catalog can't satisfy
852    /// the template even unfiltered). All later failures are absorbed
853    /// as "pool exhausted" and the loop stops.
854    ///
855    /// **Cost.** Per produced candidate, this runs `1 + roster_size`
856    /// compiles: one to produce the candidate, and one per chosen
857    /// descriptor to test swappability. `compile_from_catalog` is
858    /// pure metadata work (no executable instantiation), so the
859    /// constant is small.
860    ///
861    /// `k = 0` is well-defined and returns `Ok(vec![])`. `k = 1` is
862    /// equivalent to a single [`Self::compile_from_catalog`] call.
863    pub fn compile_k_candidates(
864        &self,
865        request: &FormationCompileRequest,
866        formation_templates: &FormationCatalog,
867        catalog: &DiscoveryCatalog,
868        providers: &ProviderDescriptorCatalog,
869        k: usize,
870    ) -> Result<Vec<CompiledFormationPlan>, CatalogCompileFailure> {
871        let mut candidates: Vec<CompiledFormationPlan> = Vec::new();
872        let mut excluded: Vec<String> = Vec::new();
873
874        for _ in 0..k {
875            let filtered = filter_out_ids(catalog, &excluded);
876            match self.compile_from_catalog(
877                request,
878                formation_templates,
879                &filtered,
880                providers,
881                None,
882            ) {
883                Ok(plan) => {
884                    let excluded_before = excluded.len();
885                    for role in &plan.roster {
886                        // Trial-compile against the catalog minus the
887                        // current exclude set AND this descriptor. If
888                        // the trial succeeds, the descriptor is
889                        // genuinely swappable (a compositional
890                        // alternative exists) and is safe to exclude
891                        // for the next iteration. Scarce specialists
892                        // — and broad specialists whose contribution
893                        // is irreplaceable — stay available.
894                        let mut trial_exclude = excluded.clone();
895                        trial_exclude.push(role.suggestor_id.to_string());
896                        let trial_catalog = filter_out_ids(catalog, &trial_exclude);
897                        if self
898                            .compile_from_catalog(
899                                request,
900                                formation_templates,
901                                &trial_catalog,
902                                providers,
903                                None,
904                            )
905                            .is_ok()
906                        {
907                            excluded.push(role.suggestor_id.to_string());
908                        }
909                    }
910                    let candidate_added_no_exclusions = excluded.len() == excluded_before;
911                    candidates.push(plan);
912                    // If no descriptor in this roster was swappable,
913                    // the next iteration would compile against the
914                    // same filtered catalog and produce an identical
915                    // roster. Stop now to avoid emitting duplicates.
916                    if candidate_added_no_exclusions {
917                        break;
918                    }
919                }
920                Err(failure) => {
921                    if candidates.is_empty() {
922                        return Err(failure);
923                    }
924                    // Pool exhausted for later candidates — graceful stop.
925                    break;
926                }
927            }
928        }
929
930        Ok(candidates)
931    }
932}
933
934/// Build a new [`DiscoveryCatalog`] containing every entry of `source`
935/// whose id is not in `exclude_ids`. Used by k-best swap-out to source
936/// diverse candidate rosters from the same underlying catalog.
937fn filter_out_ids(source: &DiscoveryCatalog, exclude_ids: &[String]) -> DiscoveryCatalog {
938    let mut filtered = DiscoveryCatalog::new();
939    for entry in source {
940        if !exclude_ids.iter().any(|id| id == entry.id().as_str()) {
941            filtered.register(entry.clone());
942        }
943    }
944    filtered
945}
946
947/// Catalog-aware variant of [`best_suggestor`]. Uses the
948/// [`DiscoveryCatalog`]'s structural filters to source candidates and
949/// records every considered candidate (chosen, outranked, already
950/// selected, no coverage) so the caller can build a [`RoleDecision`].
951///
952/// `advisory_order` is an optional ranked list of descriptor IDs from an
953/// out-of-band advisor (e.g. an LLM-backed [`CatalogLookup`]). It is
954/// applied strictly as a tie-breaker after deterministic scoring —
955/// candidates earlier in the list are preferred when all other ranking
956/// keys are equal.
957///
958/// Returns `(chosen, considered)`. `chosen` is `None` when no catalog
959/// entry covers any remaining requirement.
960#[allow(clippy::too_many_lines)]
961fn best_from_catalog<'a>(
962    catalog: &'a DiscoveryCatalog,
963    selected: &[&'a CatalogSuggestorDescriptor],
964    unmatched_roles: &[SuggestorRole],
965    unmatched_capabilities: &[SuggestorCapability],
966    domain_tags: &[String],
967    advisory_order: Option<&[String]>,
968) -> (
969    Option<&'a CatalogSuggestorDescriptor>,
970    Vec<CandidateConsideration>,
971) {
972    // Source candidates via structural filters: union of "matches an
973    // unmatched role" with "matches at least one unmatched capability".
974    // Dedupe by descriptor id while preserving first occurrence order.
975    let mut candidate_ids: Vec<String> = Vec::new();
976    let mut candidate_refs: Vec<&CatalogSuggestorDescriptor> = Vec::new();
977    let mut push = |entry: &'a CatalogSuggestorDescriptor| {
978        if !candidate_ids.iter().any(|id| id == entry.id().as_str()) {
979            candidate_ids.push(entry.id().to_string());
980            candidate_refs.push(entry);
981        }
982    };
983    for role in unmatched_roles {
984        for entry in catalog.find_by_role(*role) {
985            push(entry);
986        }
987    }
988    for capability in unmatched_capabilities {
989        for entry in catalog.find_by_capability(*capability) {
990            push(entry);
991        }
992    }
993
994    let mut considered: Vec<CandidateConsideration> = Vec::new();
995    let mut ranked: Vec<(&CatalogSuggestorDescriptor, usize, usize, bool)> = Vec::new();
996
997    for entry in candidate_refs {
998        if selected.iter().any(|chosen| chosen.id() == entry.id()) {
999            considered.push(CandidateConsideration {
1000                descriptor_id: entry.id().clone(),
1001                disposition: CandidateDisposition::Rejected {
1002                    reason: RejectionReason::AlreadySelected,
1003                },
1004            });
1005            continue;
1006        }
1007        let coverage =
1008            suggestor_coverage(&entry.descriptor, unmatched_roles, unmatched_capabilities);
1009        if coverage == 0 {
1010            considered.push(CandidateConsideration {
1011                descriptor_id: entry.id().clone(),
1012                disposition: CandidateDisposition::Rejected {
1013                    reason: RejectionReason::NoCoverage,
1014                },
1015            });
1016            continue;
1017        }
1018        let domain_hits = domain_overlap(&entry.descriptor.domain_tags, domain_tags);
1019        let advisory_hit =
1020            advisory_order.is_some_and(|order| order.iter().any(|id| id == entry.id().as_str()));
1021        ranked.push((entry, coverage, domain_hits, advisory_hit));
1022    }
1023
1024    // Advisory rank: lower index = higher preference. usize::MAX for
1025    // entries not present in the advisory list (sorts last among ties).
1026    let advisory_rank = |id: &str| -> usize {
1027        advisory_order
1028            .and_then(|order| order.iter().position(|x| x == id))
1029            .unwrap_or(usize::MAX)
1030    };
1031
1032    let chosen = ranked
1033        .iter()
1034        .max_by(|(left, l_cov, l_dom, _), (right, r_cov, r_dom, _)| {
1035            l_cov
1036                .cmp(r_cov)
1037                .then_with(|| l_dom.cmp(r_dom))
1038                .then_with(|| {
1039                    right
1040                        .descriptor
1041                        .profile
1042                        .cost_hint
1043                        .cmp(&left.descriptor.profile.cost_hint)
1044                })
1045                .then_with(|| {
1046                    right
1047                        .descriptor
1048                        .profile
1049                        .latency_hint
1050                        .cmp(&left.descriptor.profile.latency_hint)
1051                })
1052                .then_with(|| {
1053                    advisory_rank(right.id().as_str()).cmp(&advisory_rank(left.id().as_str()))
1054                })
1055                .then_with(|| right.id().cmp(left.id()))
1056        })
1057        .map(|(entry, _, _, _)| *entry);
1058
1059    for (entry, coverage, domain_hits, advisory_hit) in &ranked {
1060        let is_chosen = chosen.is_some_and(|c| c.id() == entry.id());
1061        let disposition = if is_chosen {
1062            CandidateDisposition::Selected {
1063                reason: SelectionReason {
1064                    coverage: *coverage,
1065                    domain_hits: *domain_hits,
1066                    advisory_hit: *advisory_hit,
1067                },
1068            }
1069        } else {
1070            let chosen_id =
1071                chosen.map_or_else(|| SuggestorDescriptorId::new(""), |c| c.id().clone());
1072            CandidateDisposition::Rejected {
1073                reason: RejectionReason::Outranked {
1074                    chosen_id,
1075                    own_coverage: *coverage,
1076                    own_domain_hits: *domain_hits,
1077                },
1078            }
1079        };
1080        considered.push(CandidateConsideration {
1081            descriptor_id: entry.id().clone(),
1082            disposition,
1083        });
1084    }
1085
1086    (chosen, considered)
1087}
1088
1089fn best_provider<'a>(
1090    candidates: impl Iterator<Item = &'a ProviderDescriptor>,
1091    descriptor: &SuggestorDescriptor,
1092    requirements: &BackendRequirements,
1093) -> Option<&'a ProviderDescriptor> {
1094    candidates
1095        .filter(|candidate| provider_satisfies(candidate, requirements))
1096        .map(|candidate| {
1097            let role_hit = usize::from(candidate.role_affinity.contains(&descriptor.profile.role));
1098            let domain_hits = domain_overlap(&candidate.domain_tags, &descriptor.domain_tags);
1099            (candidate, role_hit, domain_hits)
1100        })
1101        .max_by(
1102            |(left, left_role, left_domain), (right, right_role, right_domain)| {
1103                left_role
1104                    .cmp(right_role)
1105                    .then_with(|| left_domain.cmp(right_domain))
1106                    .then_with(|| {
1107                        right
1108                            .requirements
1109                            .max_cost_class
1110                            .cmp(&left.requirements.max_cost_class)
1111                    })
1112                    .then_with(|| {
1113                        right
1114                            .requirements
1115                            .max_latency_ms
1116                            .cmp(&left.requirements.max_latency_ms)
1117                    })
1118                    .then_with(|| right.id.cmp(&left.id))
1119            },
1120        )
1121        .map(|(candidate, _, _)| candidate)
1122}
1123
1124fn provider_satisfies(provider: &ProviderDescriptor, requirements: &BackendRequirements) -> bool {
1125    provider.requirements.kind == requirements.kind
1126        && requirements.required_capabilities.iter().all(|capability| {
1127            provider
1128                .requirements
1129                .required_capabilities
1130                .contains(capability)
1131        })
1132        && provider.requirements.max_cost_class <= requirements.max_cost_class
1133        && latency_satisfies(
1134            provider.requirements.max_latency_ms,
1135            requirements.max_latency_ms,
1136        )
1137        && sovereignty_satisfies(
1138            provider.requirements.data_sovereignty,
1139            requirements.data_sovereignty,
1140        )
1141        && compliance_satisfies(provider.requirements.compliance, requirements.compliance)
1142        && (!requirements.requires_replay || provider.requirements.requires_replay)
1143        && (!requirements.requires_offline || provider.requirements.requires_offline)
1144}
1145
1146fn latency_satisfies(provider_ms: u32, required_ms: u32) -> bool {
1147    required_ms == 0 || provider_ms <= required_ms
1148}
1149
1150fn sovereignty_satisfies(provider: DataSovereignty, required: DataSovereignty) -> bool {
1151    match required {
1152        DataSovereignty::Any => true,
1153        _ => provider == required || provider == DataSovereignty::OnPremises,
1154    }
1155}
1156
1157fn compliance_satisfies(provider: ComplianceLevel, required: ComplianceLevel) -> bool {
1158    required == ComplianceLevel::None || provider == required
1159}
1160
1161fn domain_overlap(left: &[String], right: &[String]) -> usize {
1162    left.iter().filter(|tag| right.contains(tag)).count()
1163}
1164
1165fn unique_capabilities(
1166    capabilities: impl IntoIterator<Item = SuggestorCapability>,
1167) -> Vec<SuggestorCapability> {
1168    let mut unique = Vec::new();
1169    for capability in capabilities {
1170        if !unique.contains(&capability) {
1171            unique.push(capability);
1172        }
1173    }
1174    unique
1175}
1176
1177fn remove_role(roles: &mut Vec<SuggestorRole>, role: SuggestorRole) {
1178    if let Some(index) = roles.iter().position(|candidate| *candidate == role) {
1179        roles.remove(index);
1180    }
1181}
1182
1183fn remove_capabilities(
1184    capabilities: &mut Vec<SuggestorCapability>,
1185    covered: &[SuggestorCapability],
1186) {
1187    capabilities.retain(|capability| !covered.contains(capability));
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192    use super::*;
1193    use crate::vendor_selection::vendor_selection_formation_catalog;
1194    use converge_kernel::formation::ProfileSnapshot;
1195    use converge_provider::{BackendKind, Capability, CostClass, LatencyClass};
1196
1197    fn id(n: u128) -> Uuid {
1198        Uuid::from_u128(n)
1199    }
1200
1201    fn profile(
1202        name: &str,
1203        role: SuggestorRole,
1204        output_keys: Vec<ContextKey>,
1205        capabilities: Vec<SuggestorCapability>,
1206    ) -> ProfileSnapshot {
1207        ProfileSnapshot {
1208            name: name.to_string(),
1209            role,
1210            output_keys,
1211            cost_hint: CostClass::Low,
1212            latency_hint: LatencyClass::Interactive,
1213            capabilities,
1214            confidence_min: 0.7,
1215            confidence_max: 0.95,
1216        }
1217    }
1218
1219    fn market_scan_descriptor() -> SuggestorDescriptor {
1220        SuggestorDescriptor::new(
1221            "market-scan",
1222            profile(
1223                "market-scan",
1224                SuggestorRole::Signal,
1225                vec![ContextKey::Signals],
1226                vec![SuggestorCapability::KnowledgeRetrieval],
1227            ),
1228        )
1229        .with_read(ContextKey::Seeds)
1230        .with_domain_tag("vendor-selection")
1231        .with_output_contract(DataContract::new("MarketEvidence", "1.0"))
1232    }
1233
1234    fn weighted_evaluator_descriptor() -> SuggestorDescriptor {
1235        SuggestorDescriptor::new(
1236            "weighted-evaluator",
1237            profile(
1238                "weighted-evaluator",
1239                SuggestorRole::Evaluation,
1240                vec![ContextKey::Evaluations],
1241                vec![SuggestorCapability::Analytics],
1242            ),
1243        )
1244        .with_read(ContextKey::Signals)
1245        .with_domain_tag("vendor-selection")
1246        .with_input_contract(DataContract::new("NormalizedVendorResponse", "1.0"))
1247    }
1248
1249    fn policy_gate_descriptor(policy_requirements: BackendRequirements) -> SuggestorDescriptor {
1250        SuggestorDescriptor::new(
1251            "policy-gate",
1252            profile(
1253                "policy-gate",
1254                SuggestorRole::Constraint,
1255                vec![ContextKey::Constraints],
1256                vec![SuggestorCapability::PolicyEnforcement],
1257            ),
1258        )
1259        .with_read(ContextKey::Evaluations)
1260        .with_domain_tag("vendor-selection")
1261        .with_replay_mode(ReplayMode::Required)
1262        .with_governance_class(GovernanceClass::HumanApprovalRequired)
1263        .with_backend_requirements(policy_requirements)
1264    }
1265
1266    fn decision_synthesis_descriptor() -> SuggestorDescriptor {
1267        SuggestorDescriptor::new(
1268            "decision-synthesis",
1269            profile(
1270                "decision-synthesis",
1271                SuggestorRole::Synthesis,
1272                vec![ContextKey::Proposals],
1273                vec![SuggestorCapability::LlmReasoning],
1274            ),
1275        )
1276        .with_read(ContextKey::Evaluations)
1277        .with_read(ContextKey::Constraints)
1278        .with_domain_tag("vendor-selection")
1279        .with_output_contract(DataContract::new("VendorSelectionDecisionRecord", "1.0"))
1280    }
1281
1282    fn cedar_provider(policy_requirements: BackendRequirements) -> ProviderDescriptor {
1283        ProviderDescriptor::new(
1284            "cedar-local",
1285            "Cedar local policy engine",
1286            policy_requirements,
1287        )
1288        .with_role_affinity(SuggestorRole::Constraint)
1289        .with_domain_tag("vendor-selection")
1290    }
1291
1292    fn complete_vendor_selection_catalogs(
1293        policy_requirements: BackendRequirements,
1294    ) -> FormationCompilerCatalogs {
1295        FormationCompilerCatalogs::new(vendor_selection_formation_catalog())
1296            .with_suggestor(market_scan_descriptor())
1297            .with_suggestor(weighted_evaluator_descriptor())
1298            .with_suggestor(policy_gate_descriptor(policy_requirements.clone()))
1299            .with_suggestor(decision_synthesis_descriptor())
1300            .with_provider(cedar_provider(policy_requirements))
1301    }
1302
1303    #[test]
1304    fn compiles_complementary_vendor_selection_team() {
1305        let request = FormationCompileRequest::new(
1306            id(1),
1307            id(2),
1308            FormationTemplateQuery::new()
1309                .with_keyword("vendor")
1310                .with_keyword("diligence-evaluate-decide")
1311                .with_entity("VendorSelectionDecisionRecord"),
1312        )
1313        .with_tenant_id("tenant-a")
1314        .with_domain_tag("vendor-selection");
1315
1316        let policy_requirements = BackendRequirements::access_policy().with_replay();
1317        let catalogs = complete_vendor_selection_catalogs(policy_requirements);
1318
1319        let plan = FormationCompiler::new()
1320            .compile(&request, &catalogs)
1321            .expect("vendor selection should compile");
1322
1323        assert_eq!(plan.template_id, "vendor-selection-decide");
1324        assert_eq!(plan.correlation_id, id(2));
1325        assert_eq!(plan.tenant_id.as_deref(), Some("tenant-a"));
1326        assert_eq!(plan.roster.len(), 4);
1327        assert_eq!(plan.provider_assignments.len(), 1);
1328        assert_eq!(plan.provider_assignments[0].provider_id, "cedar-local");
1329        assert!(
1330            plan.roster
1331                .iter()
1332                .any(|role| role.suggestor_id == "market-scan")
1333        );
1334        assert!(
1335            plan.roster
1336                .iter()
1337                .any(|role| role.suggestor_id == "weighted-evaluator")
1338        );
1339        assert!(
1340            plan.roster
1341                .iter()
1342                .any(|role| role.suggestor_id == "policy-gate")
1343        );
1344        assert!(
1345            plan.roster
1346                .iter()
1347                .any(|role| role.suggestor_id == "decision-synthesis")
1348        );
1349    }
1350
1351    #[test]
1352    fn reports_uncovered_requirements_instead_of_over_filtering() {
1353        let request = FormationCompileRequest::new(
1354            id(3),
1355            id(4),
1356            FormationTemplateQuery::new()
1357                .with_keyword("vendor")
1358                .with_keyword("diligence-evaluate-decide"),
1359        );
1360        let catalogs = FormationCompilerCatalogs::new(vendor_selection_formation_catalog())
1361            .with_suggestor(SuggestorDescriptor::new(
1362                "analytics-only",
1363                profile(
1364                    "analytics-only",
1365                    SuggestorRole::Evaluation,
1366                    vec![ContextKey::Evaluations],
1367                    vec![SuggestorCapability::Analytics],
1368                ),
1369            ));
1370
1371        let error = FormationCompiler::new()
1372            .compile(&request, &catalogs)
1373            .expect_err("missing roles and capabilities should be explicit");
1374
1375        match error {
1376            FormationCompileError::UncoveredRequirements {
1377                unmatched_roles,
1378                unmatched_capabilities,
1379            } => {
1380                assert!(unmatched_roles.contains(&SuggestorRole::Signal));
1381                assert!(unmatched_roles.contains(&SuggestorRole::Constraint));
1382                assert!(unmatched_roles.contains(&SuggestorRole::Synthesis));
1383                assert!(unmatched_capabilities.contains(&SuggestorCapability::KnowledgeRetrieval));
1384                assert!(unmatched_capabilities.contains(&SuggestorCapability::PolicyEnforcement));
1385                assert!(unmatched_capabilities.contains(&SuggestorCapability::LlmReasoning));
1386            }
1387            other => panic!("unexpected compile error: {other:?}"),
1388        }
1389    }
1390
1391    #[test]
1392    fn requires_role_level_provider_match_when_backend_is_declared() {
1393        let request = FormationCompileRequest::new(
1394            id(5),
1395            id(6),
1396            FormationTemplateQuery::new()
1397                .with_keyword("vendor")
1398                .with_keyword("diligence-evaluate-decide"),
1399        );
1400        let policy_requirements = BackendRequirements::access_policy().with_replay();
1401        let catalogs = FormationCompilerCatalogs::new(vendor_selection_formation_catalog())
1402            .with_suggestor(market_scan_descriptor())
1403            .with_suggestor(weighted_evaluator_descriptor())
1404            .with_suggestor(policy_gate_descriptor(policy_requirements))
1405            .with_suggestor(decision_synthesis_descriptor())
1406            .with_provider(ProviderDescriptor::new(
1407                "generic-llm",
1408                "Generic LLM",
1409                BackendRequirements::reasoning_llm(),
1410            ));
1411
1412        let error = FormationCompiler::new()
1413            .compile(&request, &catalogs)
1414            .expect_err("policy role should not route to an LLM provider");
1415
1416        assert_eq!(
1417            error,
1418            FormationCompileError::MissingProvider {
1419                suggestor_id: "policy-gate".into(),
1420                role: SuggestorRole::Constraint,
1421            }
1422        );
1423    }
1424
1425    #[test]
1426    fn carries_rich_provider_requirements_per_role() {
1427        let requirements = BackendRequirements::new(BackendKind::Llm)
1428            .with_capability(Capability::TextGeneration)
1429            .with_capability(Capability::Reasoning)
1430            .with_data_sovereignty(DataSovereignty::EU)
1431            .with_compliance(ComplianceLevel::HighExplainability)
1432            .with_capability(Capability::StructuredOutput);
1433
1434        let descriptor = SuggestorDescriptor::new(
1435            "decision-synthesis",
1436            profile(
1437                "decision-synthesis",
1438                SuggestorRole::Synthesis,
1439                vec![ContextKey::Proposals],
1440                vec![SuggestorCapability::LlmReasoning],
1441            ),
1442        )
1443        .with_backend_requirements(requirements.clone());
1444
1445        assert_eq!(
1446            descriptor
1447                .backend_requirements
1448                .as_ref()
1449                .expect("requirements should be present")
1450                .data_sovereignty,
1451            DataSovereignty::EU
1452        );
1453        assert!(
1454            descriptor
1455                .backend_requirements
1456                .as_ref()
1457                .expect("requirements should be present")
1458                .required_capabilities
1459                .contains(&Capability::StructuredOutput)
1460        );
1461    }
1462
1463    // ------------------------------------------------------------------
1464    // Catalog-aware compile path — acceptance tests with a synthetic
1465    // 4-entry DiscoveryCatalog covering retrieve / score / optimize /
1466    // authorize loop contributions. These also serve as the acceptance
1467    // harness for the upcoming organism-catalog-mosaic seed crate.
1468    // ------------------------------------------------------------------
1469
1470    use converge_kernel::formation::{FormationTemplate, StaticFormationTemplate};
1471    use organism_catalog::{
1472        CatalogSuggestorDescriptor, DiscoveryCatalog, DiscoveryMetadata, LoopContribution,
1473    };
1474
1475    fn loop_demo_template_catalog() -> FormationCatalog {
1476        let metadata = converge_kernel::formation::FormationTemplateMetadata::new(
1477            "loop-demo",
1478            "Demonstrate Retrieve / Score / Optimize / Authorize loop coverage.",
1479            vec![
1480                SuggestorRole::Signal,
1481                SuggestorRole::Evaluation,
1482                SuggestorRole::Planning,
1483                SuggestorRole::Constraint,
1484            ],
1485        )
1486        .with_keyword("loop-demo")
1487        .with_required_capability(SuggestorCapability::KnowledgeRetrieval)
1488        .with_required_capability(SuggestorCapability::Analytics)
1489        .with_required_capability(SuggestorCapability::Optimization)
1490        .with_required_capability(SuggestorCapability::PolicyEnforcement);
1491        FormationCatalog::new().with_template(FormationTemplate::static_template(
1492            StaticFormationTemplate::new(metadata),
1493        ))
1494    }
1495
1496    fn loop_demo_query() -> FormationCompileRequest {
1497        FormationCompileRequest::new(
1498            id(100),
1499            id(200),
1500            FormationTemplateQuery::new().with_keyword("loop-demo"),
1501        )
1502    }
1503
1504    fn catalog_entry(
1505        id: &str,
1506        role: SuggestorRole,
1507        capability: SuggestorCapability,
1508        contribution: LoopContribution,
1509        summary: &str,
1510    ) -> CatalogSuggestorDescriptor {
1511        let descriptor =
1512            SuggestorDescriptor::new(id, profile(id, role, Vec::new(), vec![capability]));
1513        let discovery = DiscoveryMetadata::new(summary, "Synthetic test fixture.")
1514            .with_loop_contribution(contribution);
1515        CatalogSuggestorDescriptor::new(descriptor, discovery)
1516    }
1517
1518    fn loop_demo_catalog_full() -> DiscoveryCatalog {
1519        DiscoveryCatalog::new()
1520            .with_entry(catalog_entry(
1521                "retrieve-suggestor",
1522                SuggestorRole::Signal,
1523                SuggestorCapability::KnowledgeRetrieval,
1524                LoopContribution::Retrieve,
1525                "Pull external evidence into context.",
1526            ))
1527            .with_entry(catalog_entry(
1528                "score-suggestor",
1529                SuggestorRole::Evaluation,
1530                SuggestorCapability::Analytics,
1531                LoopContribution::Score,
1532                "Score candidates against weighted criteria.",
1533            ))
1534            .with_entry(catalog_entry(
1535                "optimize-suggestor",
1536                SuggestorRole::Planning,
1537                SuggestorCapability::Optimization,
1538                LoopContribution::Optimize,
1539                "Optimize selection under declared constraints.",
1540            ))
1541            .with_entry(catalog_entry(
1542                "authorize-suggestor",
1543                SuggestorRole::Constraint,
1544                SuggestorCapability::PolicyEnforcement,
1545                LoopContribution::Authorize,
1546                "Authorize the proposal via a policy gate.",
1547            ))
1548    }
1549
1550    #[test]
1551    fn catalog_compile_satisfies_four_contribution_formation() {
1552        let templates = loop_demo_template_catalog();
1553        let catalog = loop_demo_catalog_full();
1554        let providers = ProviderDescriptorCatalog::new();
1555        let request = loop_demo_query();
1556
1557        let outcome = FormationCompiler::new()
1558            .compile_from_catalog(&request, &templates, &catalog, &providers, None)
1559            .expect("4-entry catalog should satisfy the loop-demo template");
1560
1561        assert_eq!(outcome.template_id, "loop-demo");
1562        assert_eq!(outcome.roster.len(), 4);
1563        assert_eq!(outcome.decisions.len(), 4);
1564
1565        let chosen: Vec<&str> = outcome
1566            .decisions
1567            .iter()
1568            .filter_map(|d| d.chosen.as_deref())
1569            .collect();
1570        for expected in [
1571            "retrieve-suggestor",
1572            "score-suggestor",
1573            "optimize-suggestor",
1574            "authorize-suggestor",
1575        ] {
1576            assert!(
1577                chosen.contains(&expected),
1578                "expected {expected} in decisions, got {chosen:?}"
1579            );
1580        }
1581    }
1582
1583    #[test]
1584    fn catalog_compile_records_selected_disposition_with_structured_reason() {
1585        let templates = loop_demo_template_catalog();
1586        let catalog = loop_demo_catalog_full();
1587        let providers = ProviderDescriptorCatalog::new();
1588        let request = loop_demo_query();
1589
1590        let outcome = FormationCompiler::new()
1591            .compile_from_catalog(&request, &templates, &catalog, &providers, None)
1592            .expect("compile should succeed");
1593
1594        for decision in &outcome.decisions {
1595            let chosen_id = decision.chosen.as_deref().expect("each iteration chose");
1596            let chosen_consideration = decision
1597                .considered
1598                .iter()
1599                .find(|c| c.descriptor_id == chosen_id)
1600                .expect("chosen descriptor must appear in considered list");
1601            match &chosen_consideration.disposition {
1602                CandidateDisposition::Selected { reason } => {
1603                    assert!(reason.coverage >= 1, "chosen must cover at least one need");
1604                    // No advisor was passed, so no advisory_hit expected.
1605                    assert!(!reason.advisory_hit);
1606                }
1607                CandidateDisposition::Rejected { reason } => {
1608                    panic!("chosen descriptor must be Selected, got Rejected({reason:?})")
1609                }
1610            }
1611        }
1612    }
1613
1614    #[test]
1615    fn catalog_compile_fails_with_partial_trace_when_capability_missing() {
1616        let templates = loop_demo_template_catalog();
1617        // Drop optimize-suggestor; the remaining catalog cannot cover
1618        // Planning role + Optimization capability.
1619        let catalog = DiscoveryCatalog::new()
1620            .with_entry(catalog_entry(
1621                "retrieve-suggestor",
1622                SuggestorRole::Signal,
1623                SuggestorCapability::KnowledgeRetrieval,
1624                LoopContribution::Retrieve,
1625                "Pull external evidence into context.",
1626            ))
1627            .with_entry(catalog_entry(
1628                "score-suggestor",
1629                SuggestorRole::Evaluation,
1630                SuggestorCapability::Analytics,
1631                LoopContribution::Score,
1632                "Score candidates.",
1633            ))
1634            .with_entry(catalog_entry(
1635                "authorize-suggestor",
1636                SuggestorRole::Constraint,
1637                SuggestorCapability::PolicyEnforcement,
1638                LoopContribution::Authorize,
1639                "Authorize via policy gate.",
1640            ));
1641        let providers = ProviderDescriptorCatalog::new();
1642        let request = loop_demo_query();
1643
1644        let failure = FormationCompiler::new()
1645            .compile_from_catalog(&request, &templates, &catalog, &providers, None)
1646            .expect_err("missing optimize should fail to compile");
1647
1648        match &failure.error {
1649            FormationCompileError::UncoveredRequirements {
1650                unmatched_roles,
1651                unmatched_capabilities,
1652            } => {
1653                assert!(unmatched_roles.contains(&SuggestorRole::Planning));
1654                assert!(unmatched_capabilities.contains(&SuggestorCapability::Optimization));
1655            }
1656            other => panic!("expected UncoveredRequirements, got {other:?}"),
1657        }
1658
1659        // Decisions must contain a final iteration with chosen=None
1660        // explaining the absence.
1661        let final_decision = failure
1662            .decisions
1663            .last()
1664            .expect("partial trace must exist even on failure");
1665        assert!(final_decision.chosen.is_none());
1666        // The greedy ranker may have filled Planning's role-slot before
1667        // it ran out — assert via the unmatched snapshot, which is
1668        // authoritative for what was still open at the failing step.
1669        assert!(
1670            final_decision
1671                .unmatched_roles_at_start
1672                .contains(&SuggestorRole::Planning)
1673        );
1674    }
1675
1676    #[test]
1677    fn catalog_compile_is_deterministic_across_repeated_runs() {
1678        let templates = loop_demo_template_catalog();
1679        let catalog = loop_demo_catalog_full();
1680        let providers = ProviderDescriptorCatalog::new();
1681
1682        let a = FormationCompiler::new()
1683            .compile_from_catalog(&loop_demo_query(), &templates, &catalog, &providers, None)
1684            .expect("compile a");
1685        let b = FormationCompiler::new()
1686            .compile_from_catalog(&loop_demo_query(), &templates, &catalog, &providers, None)
1687            .expect("compile b");
1688
1689        let ids_a: Vec<_> = a.roster.iter().map(|r| r.suggestor_id.clone()).collect();
1690        let ids_b: Vec<_> = b.roster.iter().map(|r| r.suggestor_id.clone()).collect();
1691        assert_eq!(ids_a, ids_b);
1692    }
1693
1694    #[test]
1695    fn catalog_compile_records_outranked_disposition_for_competing_candidates() {
1696        // Two retrieve-capable candidates. The compiler picks one; the
1697        // other must appear in considered with an Outranked rejection.
1698        let templates = loop_demo_template_catalog();
1699        let catalog = loop_demo_catalog_full().with_entry(catalog_entry(
1700            "retrieve-suggestor-alt",
1701            SuggestorRole::Signal,
1702            SuggestorCapability::KnowledgeRetrieval,
1703            LoopContribution::Retrieve,
1704            "Alternative retrieve specialist.",
1705        ));
1706        let providers = ProviderDescriptorCatalog::new();
1707        let request = loop_demo_query();
1708
1709        let outcome = FormationCompiler::new()
1710            .compile_from_catalog(&request, &templates, &catalog, &providers, None)
1711            .expect("compile should succeed");
1712
1713        let retrieve_decision = outcome
1714            .decisions
1715            .iter()
1716            .find(|d| d.chosen_role == Some(SuggestorRole::Signal))
1717            .expect("Signal-role decision should exist");
1718        let retrieve_alt = retrieve_decision
1719            .considered
1720            .iter()
1721            .find(|c| {
1722                c.descriptor_id == "retrieve-suggestor-alt"
1723                    || c.descriptor_id == "retrieve-suggestor"
1724            })
1725            .expect("at least one retrieve candidate should be considered");
1726        // Exactly one of the two is Selected; the other must be Outranked.
1727        let chosen_id = retrieve_decision.chosen.as_deref().unwrap();
1728        let other_id = if chosen_id == "retrieve-suggestor" {
1729            "retrieve-suggestor-alt"
1730        } else {
1731            "retrieve-suggestor"
1732        };
1733        let other = retrieve_decision
1734            .considered
1735            .iter()
1736            .find(|c| c.descriptor_id == other_id)
1737            .expect("other retrieve candidate should appear");
1738        match &other.disposition {
1739            CandidateDisposition::Rejected {
1740                reason: RejectionReason::Outranked { chosen_id: cid, .. },
1741            } => assert_eq!(cid, chosen_id),
1742            other => panic!("expected Outranked, got {other:?}"),
1743        }
1744        let _ = retrieve_alt; // suppress unused warning
1745    }
1746
1747    #[test]
1748    fn catalog_compile_trace_reports_actual_chosen_role_when_later_role_wins() {
1749        // Scenario: the greedy ranker picks a candidate whose role is
1750        // NOT the first remaining role, because that candidate also
1751        // covers multiple capabilities. The trace must show:
1752        //   - unmatched_roles_at_start: the full snapshot at iteration start
1753        //   - chosen_role: the actual role filled (not the first remaining)
1754        //
1755        // This guards against the prior bug where `seeking_role` was
1756        // recorded as `unmatched_roles.first()`, which lied when the
1757        // chosen candidate actually filled a later role.
1758        let templates = loop_demo_template_catalog();
1759        // narrow-signal: 1 role + 1 cap = coverage 2
1760        // broad-evaluation: 1 role + 3 caps = coverage 4
1761        // → broad-evaluation wins iteration 1 even though Signal is first.
1762        let catalog = DiscoveryCatalog::new()
1763            .with_entry(CatalogSuggestorDescriptor::new(
1764                SuggestorDescriptor::new(
1765                    "narrow-signal",
1766                    profile(
1767                        "narrow-signal",
1768                        SuggestorRole::Signal,
1769                        Vec::new(),
1770                        vec![SuggestorCapability::KnowledgeRetrieval],
1771                    ),
1772                ),
1773                DiscoveryMetadata::new("Narrow signal.", "Test fixture."),
1774            ))
1775            .with_entry(CatalogSuggestorDescriptor::new(
1776                SuggestorDescriptor::new(
1777                    "broad-evaluation",
1778                    profile(
1779                        "broad-evaluation",
1780                        SuggestorRole::Evaluation,
1781                        Vec::new(),
1782                        vec![
1783                            SuggestorCapability::Analytics,
1784                            SuggestorCapability::Optimization,
1785                            SuggestorCapability::PolicyEnforcement,
1786                        ],
1787                    ),
1788                ),
1789                DiscoveryMetadata::new("Broad evaluation.", "Test fixture."),
1790            ))
1791            .with_entry(catalog_entry(
1792                "narrow-planning",
1793                SuggestorRole::Planning,
1794                SuggestorCapability::Optimization,
1795                LoopContribution::Optimize,
1796                "Narrow planning.",
1797            ))
1798            .with_entry(catalog_entry(
1799                "narrow-constraint",
1800                SuggestorRole::Constraint,
1801                SuggestorCapability::PolicyEnforcement,
1802                LoopContribution::Authorize,
1803                "Narrow constraint.",
1804            ));
1805        let providers = ProviderDescriptorCatalog::new();
1806        let request = loop_demo_query();
1807
1808        let outcome = FormationCompiler::new()
1809            .compile_from_catalog(&request, &templates, &catalog, &providers, None)
1810            .expect("compile should succeed");
1811
1812        let first = &outcome.decisions[0];
1813
1814        // Sanity: the trace snapshot at iteration 1 includes ALL four
1815        // unfilled roles in the original order.
1816        assert_eq!(
1817            first.unmatched_roles_at_start,
1818            vec![
1819                SuggestorRole::Signal,
1820                SuggestorRole::Evaluation,
1821                SuggestorRole::Planning,
1822                SuggestorRole::Constraint,
1823            ],
1824        );
1825
1826        // The fix: chosen_role must reflect what was filled — Evaluation,
1827        // NOT the first remaining (Signal). The previous shape would
1828        // have recorded seeking_role = Some(Signal), which lied about
1829        // what the compiler actually did.
1830        assert_eq!(first.chosen.as_deref(), Some("broad-evaluation"));
1831        assert_eq!(first.chosen_role, Some(SuggestorRole::Evaluation));
1832        assert_ne!(
1833            first.chosen_role,
1834            first.unmatched_roles_at_start.first().copied(),
1835            "chosen_role must reflect actual fill, not the first remaining role"
1836        );
1837    }
1838
1839    #[test]
1840    fn catalog_compile_advisory_order_breaks_ties_but_not_coverage() {
1841        // Two equally-scoring retrieve candidates. With no advisor, deterministic
1842        // id ordering picks the lexicographically-later id ("retrieve-suggestor-alt"
1843        // > "retrieve-suggestor" — but the comparator picks via
1844        // `right.id().cmp(left.id())` so "retrieve-suggestor" wins on the last
1845        // tie-breaker because it's lexicographically lesser → right_id is greater
1846        // → comparison favors left). Confirm advisory_order can flip the choice.
1847        let templates = loop_demo_template_catalog();
1848        let catalog = loop_demo_catalog_full().with_entry(catalog_entry(
1849            "retrieve-suggestor-alt",
1850            SuggestorRole::Signal,
1851            SuggestorCapability::KnowledgeRetrieval,
1852            LoopContribution::Retrieve,
1853            "Alternative retrieve specialist.",
1854        ));
1855        let providers = ProviderDescriptorCatalog::new();
1856        let request = loop_demo_query();
1857
1858        // Baseline: no advisor.
1859        let baseline = FormationCompiler::new()
1860            .compile_from_catalog(&request, &templates, &catalog, &providers, None)
1861            .expect("baseline compile");
1862        let baseline_signal_pick = baseline
1863            .decisions
1864            .iter()
1865            .find(|d| d.chosen_role == Some(SuggestorRole::Signal))
1866            .and_then(|d| d.chosen.clone())
1867            .unwrap();
1868
1869        // Advisory: prefer the other id.
1870        let other = if baseline_signal_pick == "retrieve-suggestor" {
1871            "retrieve-suggestor-alt"
1872        } else {
1873            "retrieve-suggestor"
1874        };
1875        let advisory = vec![other.to_string()];
1876        let advised = FormationCompiler::new()
1877            .compile_from_catalog(&request, &templates, &catalog, &providers, Some(&advisory))
1878            .expect("advised compile");
1879        let advised_signal_pick = advised
1880            .decisions
1881            .iter()
1882            .find(|d| d.chosen_role == Some(SuggestorRole::Signal))
1883            .and_then(|d| d.chosen.clone())
1884            .unwrap();
1885        assert_eq!(advised_signal_pick, other);
1886
1887        // Sanity: advisory cannot create coverage. If the advisor names a
1888        // descriptor that doesn't exist in the catalog, the chosen still
1889        // comes from real candidates.
1890        let bogus_advisory = vec!["does-not-exist".to_string()];
1891        let unaffected = FormationCompiler::new()
1892            .compile_from_catalog(
1893                &request,
1894                &templates,
1895                &catalog,
1896                &providers,
1897                Some(&bogus_advisory),
1898            )
1899            .expect("bogus advisor compile");
1900        assert_eq!(
1901            unaffected
1902                .decisions
1903                .iter()
1904                .find(|d| d.chosen_role == Some(SuggestorRole::Signal))
1905                .and_then(|d| d.chosen.clone())
1906                .unwrap(),
1907            baseline_signal_pick
1908        );
1909    }
1910
1911    // ------------------------------------------------------------------
1912    // compile_draft_from_catalog — exact-roster validator. These tests
1913    // prove the contract that the dynamics crate relies on: drafts are
1914    // honored verbatim, missing descriptors fail loudly, undercoverage
1915    // fails loudly, and no greedy reselection ever silently replaces a
1916    // draft's choice.
1917    // ------------------------------------------------------------------
1918
1919    #[test]
1920    fn compile_draft_rejects_unknown_descriptor() {
1921        let templates = loop_demo_template_catalog();
1922        let catalog = loop_demo_catalog_full();
1923        let providers = ProviderDescriptorCatalog::new();
1924        let request = loop_demo_query();
1925
1926        let descriptor_ids = vec![
1927            "retrieve-suggestor".into(),
1928            "does-not-exist".into(),
1929            "optimize-suggestor".into(),
1930            "authorize-suggestor".into(),
1931        ];
1932
1933        let failure = FormationCompiler::new()
1934            .compile_draft_from_catalog(&request, &templates, &catalog, &providers, &descriptor_ids)
1935            .expect_err("unknown descriptor must be rejected");
1936        assert!(matches!(
1937            failure.error,
1938            FormationCompileError::DraftDescriptorMissing { ref descriptor_id }
1939                if descriptor_id == "does-not-exist"
1940        ));
1941    }
1942
1943    #[test]
1944    fn compile_draft_rejects_duplicate_descriptor() {
1945        let templates = loop_demo_template_catalog();
1946        let catalog = loop_demo_catalog_full();
1947        let providers = ProviderDescriptorCatalog::new();
1948        let request = loop_demo_query();
1949
1950        let descriptor_ids = vec![
1951            "retrieve-suggestor".into(),
1952            "retrieve-suggestor".into(),
1953            "optimize-suggestor".into(),
1954            "authorize-suggestor".into(),
1955        ];
1956
1957        let failure = FormationCompiler::new()
1958            .compile_draft_from_catalog(&request, &templates, &catalog, &providers, &descriptor_ids)
1959            .expect_err("duplicate descriptor must be rejected");
1960        assert!(matches!(
1961            failure.error,
1962            FormationCompileError::DuplicateDraftDescriptor { ref descriptor_id }
1963                if descriptor_id == "retrieve-suggestor"
1964        ));
1965    }
1966
1967    #[test]
1968    fn compile_draft_rejects_undercovering_roster() {
1969        let templates = loop_demo_template_catalog();
1970        let catalog = loop_demo_catalog_full();
1971        let providers = ProviderDescriptorCatalog::new();
1972        let request = loop_demo_query();
1973
1974        // Drop optimize-suggestor → roster cannot cover Planning role +
1975        // Optimization capability. compile_draft_from_catalog must
1976        // refuse rather than silently swap in a different roster.
1977        let descriptor_ids = vec![
1978            "retrieve-suggestor".into(),
1979            "score-suggestor".into(),
1980            "authorize-suggestor".into(),
1981        ];
1982
1983        let failure = FormationCompiler::new()
1984            .compile_draft_from_catalog(&request, &templates, &catalog, &providers, &descriptor_ids)
1985            .expect_err("undercovering draft must be rejected");
1986        match failure.error {
1987            FormationCompileError::UncoveredRequirements {
1988                unmatched_roles,
1989                unmatched_capabilities,
1990            } => {
1991                assert!(unmatched_roles.contains(&SuggestorRole::Planning));
1992                assert!(unmatched_capabilities.contains(&SuggestorCapability::Optimization));
1993            }
1994            other => panic!("expected UncoveredRequirements, got {other:?}"),
1995        }
1996    }
1997
1998    #[test]
1999    fn compile_draft_preserves_exact_roster_no_greedy_reselect() {
2000        // Build a catalog with TWO valid descriptors for each role so
2001        // a greedy reselect would change the chosen ids. compile_from_catalog
2002        // would pick one set; compile_draft_from_catalog must honor a
2003        // DIFFERENT set supplied by the draft.
2004        let templates = loop_demo_template_catalog();
2005        let providers = ProviderDescriptorCatalog::new();
2006        let request = loop_demo_query();
2007
2008        let mut catalog = loop_demo_catalog_full();
2009        // Add alternates that share the same role+capability as the originals.
2010        catalog.register(catalog_entry(
2011            "retrieve-alt",
2012            SuggestorRole::Signal,
2013            SuggestorCapability::KnowledgeRetrieval,
2014            LoopContribution::Retrieve,
2015            "Alternative retrieve.",
2016        ));
2017        catalog.register(catalog_entry(
2018            "score-alt",
2019            SuggestorRole::Evaluation,
2020            SuggestorCapability::Analytics,
2021            LoopContribution::Score,
2022            "Alternative score.",
2023        ));
2024
2025        let compiler = FormationCompiler::new();
2026
2027        // What greedy compile picks (baseline).
2028        let greedy = compiler
2029            .compile_from_catalog(&request, &templates, &catalog, &providers, None)
2030            .expect("greedy compile");
2031        let greedy_ids: Vec<_> = greedy
2032            .roster
2033            .iter()
2034            .map(|r| r.suggestor_id.clone())
2035            .collect();
2036
2037        // Force a different valid roster via the draft validator.
2038        let draft_ids = vec![
2039            "retrieve-alt".into(),
2040            "score-alt".into(),
2041            "optimize-suggestor".into(),
2042            "authorize-suggestor".into(),
2043        ];
2044        assert_ne!(
2045            greedy_ids, draft_ids,
2046            "test fixture is wrong: greedy already matches the draft"
2047        );
2048
2049        let validated = compiler
2050            .compile_draft_from_catalog(&request, &templates, &catalog, &providers, &draft_ids)
2051            .expect("valid draft must compile");
2052
2053        let validated_ids: Vec<_> = validated
2054            .roster
2055            .iter()
2056            .map(|r| r.suggestor_id.clone())
2057            .collect();
2058        assert_eq!(
2059            validated_ids, draft_ids,
2060            "compile_draft_from_catalog must preserve the draft's exact roster — no greedy reselect"
2061        );
2062    }
2063}