1use 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#[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 pub decisions: Vec<RoleDecision>,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct RoleDecision {
232 pub unmatched_roles_at_start: Vec<SuggestorRole>,
236 pub unmatched_capabilities_at_start: Vec<SuggestorCapability>,
238 pub considered: Vec<CandidateConsideration>,
241 pub chosen: Option<SuggestorDescriptorId>,
244 pub chosen_role: Option<SuggestorRole>,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct CandidateConsideration {
255 pub descriptor_id: SuggestorDescriptorId,
256 pub disposition: CandidateDisposition,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(tag = "kind", rename_all = "snake_case")]
262pub enum CandidateDisposition {
263 Selected { reason: SelectionReason },
265 Rejected { reason: RejectionReason },
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct SelectionReason {
274 pub coverage: usize,
277 pub domain_hits: usize,
279 pub advisory_hit: bool,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
287#[serde(tag = "kind", rename_all = "snake_case")]
288pub enum RejectionReason {
289 AlreadySelected,
291 NoCoverage,
293 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 #[error("draft references unknown descriptor '{descriptor_id}'")]
318 DraftDescriptorMissing {
319 descriptor_id: SuggestorDescriptorId,
320 },
321 #[error("draft references descriptor '{descriptor_id}' more than once")]
324 DuplicateDraftDescriptor {
325 descriptor_id: SuggestorDescriptorId,
326 },
327}
328
329#[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 #[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 #[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 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 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 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 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 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 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 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 candidate_added_no_exclusions {
917 break;
918 }
919 }
920 Err(failure) => {
921 if candidates.is_empty() {
922 return Err(failure);
923 }
924 break;
926 }
927 }
928 }
929
930 Ok(candidates)
931 }
932}
933
934fn 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#[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 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 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 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 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 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 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 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 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 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; }
1746
1747 #[test]
1748 fn catalog_compile_trace_reports_actual_chosen_role_when_later_role_wins() {
1749 let templates = loop_demo_template_catalog();
1759 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 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 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 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 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 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 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 #[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 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 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 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 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 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}