1pub use converge_core::{FormationKind, ScoringWeights};
7
8use converge_pack::ContextKey;
9use converge_provider_api::{CostClass, LatencyClass};
10use serde::{Deserialize, Serialize};
11
12pub trait SuggestorProfile {
14 fn role(&self) -> SuggestorRole;
15 fn output_keys(&self) -> &[ContextKey];
16 fn cost_hint(&self) -> CostClass;
17 fn latency_hint(&self) -> LatencyClass;
18 fn capabilities(&self) -> &[SuggestorCapability];
19 fn confidence_range(&self) -> (f32, f32);
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum SuggestorRole {
26 Analysis,
27 Planning,
28 Evaluation,
29 Constraint,
30 Signal,
31 Synthesis,
32 Meta,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum SuggestorCapability {
39 LlmReasoning,
40 KnowledgeRetrieval,
41 Analytics,
42 Optimization,
43 PolicyEnforcement,
44 HumanInTheLoop,
45 ExperienceLearning,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ProfileSnapshot {
51 pub name: String,
52 pub role: SuggestorRole,
53 pub output_keys: Vec<ContextKey>,
54 pub cost_hint: CostClass,
55 pub latency_hint: LatencyClass,
56 pub capabilities: Vec<SuggestorCapability>,
57 pub confidence_min: f32,
58 pub confidence_max: f32,
59}
60
61impl ProfileSnapshot {
62 #[must_use]
63 pub fn from_profile(name: impl Into<String>, profile: &dyn SuggestorProfile) -> Self {
64 let (min, max) = profile.confidence_range();
65 Self {
66 name: name.into(),
67 role: profile.role(),
68 output_keys: profile.output_keys().to_vec(),
69 cost_hint: profile.cost_hint(),
70 latency_hint: profile.latency_hint(),
71 capabilities: profile.capabilities().to_vec(),
72 confidence_min: min,
73 confidence_max: max,
74 }
75 }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct FormationTemplateMetadata {
81 pub id: String,
83 pub description: String,
85 pub keywords: Vec<String>,
87 pub entities: Vec<String>,
89 pub required_roles: Vec<SuggestorRole>,
91 pub required_capabilities: Vec<SuggestorCapability>,
93}
94
95impl FormationTemplateMetadata {
96 #[must_use]
97 pub fn new(
98 id: impl Into<String>,
99 description: impl Into<String>,
100 required_roles: impl IntoIterator<Item = SuggestorRole>,
101 ) -> Self {
102 Self {
103 id: id.into(),
104 description: description.into(),
105 keywords: Vec::new(),
106 entities: Vec::new(),
107 required_roles: required_roles.into_iter().collect(),
108 required_capabilities: Vec::new(),
109 }
110 }
111
112 #[must_use]
113 pub fn with_keyword(mut self, keyword: impl Into<String>) -> Self {
114 self.keywords.push(keyword.into());
115 self
116 }
117
118 #[must_use]
119 pub fn with_entity(mut self, entity: impl Into<String>) -> Self {
120 self.entities.push(entity.into());
121 self
122 }
123
124 #[must_use]
125 pub fn with_required_capability(mut self, capability: SuggestorCapability) -> Self {
126 self.required_capabilities.push(capability);
127 self
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct StaticFormationTemplate {
134 pub metadata: FormationTemplateMetadata,
135}
136
137impl StaticFormationTemplate {
138 #[must_use]
139 pub fn new(metadata: FormationTemplateMetadata) -> Self {
140 Self { metadata }
141 }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct ScoredFormationTemplate {
147 pub metadata: FormationTemplateMetadata,
148 pub top_n: usize,
149 pub scoring_weights: ScoringWeights,
150}
151
152impl ScoredFormationTemplate {
153 #[must_use]
154 pub fn new(metadata: FormationTemplateMetadata, top_n: usize) -> Self {
155 Self {
156 metadata,
157 top_n,
158 scoring_weights: ScoringWeights::default(),
159 }
160 }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct DeliberatedFormationTemplate {
166 pub metadata: FormationTemplateMetadata,
167 pub huddle_max_cycles: u32,
168 pub scoring_weights: ScoringWeights,
169 pub min_confidence_threshold: f32,
170}
171
172impl DeliberatedFormationTemplate {
173 #[must_use]
174 pub fn new(metadata: FormationTemplateMetadata, huddle_max_cycles: u32) -> Self {
175 Self {
176 metadata,
177 huddle_max_cycles,
178 scoring_weights: ScoringWeights::default(),
179 min_confidence_threshold: 0.6,
180 }
181 }
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct OpenClawFormationTemplate {
187 pub metadata: FormationTemplateMetadata,
188 pub max_extra_loops: u32,
189}
190
191impl OpenClawFormationTemplate {
192 #[must_use]
193 pub fn new(metadata: FormationTemplateMetadata, max_extra_loops: u32) -> Self {
194 Self {
195 metadata,
196 max_extra_loops,
197 }
198 }
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203#[serde(tag = "kind", rename_all = "snake_case")]
204pub enum FormationTemplate {
205 Static(StaticFormationTemplate),
206 Scored(ScoredFormationTemplate),
207 Deliberated(DeliberatedFormationTemplate),
208 OpenClaw(OpenClawFormationTemplate),
209}
210
211impl FormationTemplate {
212 #[must_use]
213 pub fn static_template(template: StaticFormationTemplate) -> Self {
214 Self::Static(template)
215 }
216
217 #[must_use]
218 pub fn scored(template: ScoredFormationTemplate) -> Self {
219 Self::Scored(template)
220 }
221
222 #[must_use]
223 pub fn deliberated(template: DeliberatedFormationTemplate) -> Self {
224 Self::Deliberated(template)
225 }
226
227 #[must_use]
228 pub fn open_claw(template: OpenClawFormationTemplate) -> Self {
229 Self::OpenClaw(template)
230 }
231
232 #[must_use]
233 pub fn metadata(&self) -> &FormationTemplateMetadata {
234 match self {
235 Self::Static(template) => &template.metadata,
236 Self::Scored(template) => &template.metadata,
237 Self::Deliberated(template) => &template.metadata,
238 Self::OpenClaw(template) => &template.metadata,
239 }
240 }
241
242 #[must_use]
243 pub fn id(&self) -> &str {
244 &self.metadata().id
245 }
246
247 #[must_use]
248 pub fn kind(&self) -> FormationKind {
249 match self {
250 Self::Static(_) => FormationKind::Static,
251 Self::Scored(_) => FormationKind::Scored,
252 Self::Deliberated(_) => FormationKind::Deliberated,
253 Self::OpenClaw(_) => FormationKind::OpenClaw,
254 }
255 }
256
257 #[must_use]
265 pub fn to_request(&self, request_id: impl Into<String>) -> FormationRequest {
266 FormationRequest {
267 id: request_id.into(),
268 required_roles: self.metadata().required_roles.clone(),
269 required_capabilities: Vec::new(),
270 }
271 }
272
273 fn match_score(&self, query: &FormationTemplateQuery) -> Option<usize> {
274 let metadata = self.metadata();
275
276 if !query
277 .required_capabilities
278 .iter()
279 .all(|capability| metadata.required_capabilities.contains(capability))
280 {
281 return None;
282 }
283
284 if query.is_empty() {
285 return Some(0);
286 }
287
288 let keyword_hits = string_matches(&metadata.keywords, &query.keywords);
289 let entity_hits = string_matches(&metadata.entities, &query.entities);
290 let capability_hits = query.required_capabilities.len() * 2;
291 let score = keyword_hits + entity_hits + capability_hits;
292
293 (score > 0).then_some(score)
294 }
295}
296
297#[derive(Debug, Clone, Default, Serialize, Deserialize)]
299pub struct FormationTemplateQuery {
300 pub keywords: Vec<String>,
301 pub entities: Vec<String>,
302 pub required_capabilities: Vec<SuggestorCapability>,
303}
304
305impl FormationTemplateQuery {
306 #[must_use]
307 pub fn new() -> Self {
308 Self::default()
309 }
310
311 #[must_use]
312 pub fn with_keyword(mut self, keyword: impl Into<String>) -> Self {
313 self.keywords.push(keyword.into());
314 self
315 }
316
317 #[must_use]
318 pub fn with_entity(mut self, entity: impl Into<String>) -> Self {
319 self.entities.push(entity.into());
320 self
321 }
322
323 #[must_use]
324 pub fn with_required_capability(mut self, capability: SuggestorCapability) -> Self {
325 self.required_capabilities.push(capability);
326 self
327 }
328
329 #[must_use]
330 pub fn is_empty(&self) -> bool {
331 self.keywords.is_empty()
332 && self.entities.is_empty()
333 && self.required_capabilities.is_empty()
334 }
335}
336
337#[derive(Debug, Clone, Default, Serialize, Deserialize)]
339pub struct FormationCatalog {
340 templates: Vec<FormationTemplate>,
341}
342
343impl FormationCatalog {
344 #[must_use]
345 pub fn new() -> Self {
346 Self::default()
347 }
348
349 #[must_use]
350 pub fn with_template(mut self, template: FormationTemplate) -> Self {
351 self.register(template);
352 self
353 }
354
355 pub fn register(&mut self, template: FormationTemplate) {
356 let template_id = template.id().to_string();
357
358 if let Some(existing) = self
359 .templates
360 .iter_mut()
361 .find(|existing| existing.id() == template_id)
362 {
363 *existing = template;
364 } else {
365 self.templates.push(template);
366 }
367 }
368
369 #[must_use]
370 pub fn len(&self) -> usize {
371 self.templates.len()
372 }
373
374 #[must_use]
375 pub fn is_empty(&self) -> bool {
376 self.templates.is_empty()
377 }
378
379 #[must_use]
380 pub fn get(&self, template_id: &str) -> Option<&FormationTemplate> {
381 self.templates
382 .iter()
383 .find(|template| template.id() == template_id)
384 }
385
386 pub fn iter(&self) -> std::slice::Iter<'_, FormationTemplate> {
387 self.templates.iter()
388 }
389
390 #[must_use]
391 pub fn matches(&self, query: &FormationTemplateQuery) -> Vec<&FormationTemplate> {
392 let mut matches = self
393 .templates
394 .iter()
395 .enumerate()
396 .filter_map(|(index, template)| {
397 template
398 .match_score(query)
399 .map(|score| (score, index, template))
400 })
401 .collect::<Vec<_>>();
402
403 matches.sort_by(
404 |(left_score, left_index, _), (right_score, right_index, _)| {
405 right_score
406 .cmp(left_score)
407 .then_with(|| left_index.cmp(right_index))
408 },
409 );
410
411 matches
412 .into_iter()
413 .map(|(_, _, template)| template)
414 .collect()
415 }
416
417 #[must_use]
418 pub fn top_match(&self, query: &FormationTemplateQuery) -> Option<&FormationTemplate> {
419 self.matches(query).into_iter().next()
420 }
421}
422
423impl<'a> IntoIterator for &'a FormationCatalog {
424 type Item = &'a FormationTemplate;
425 type IntoIter = std::slice::Iter<'a, FormationTemplate>;
426
427 fn into_iter(self) -> Self::IntoIter {
428 self.iter()
429 }
430}
431
432fn string_matches(catalog_values: &[String], query_values: &[String]) -> usize {
433 query_values
434 .iter()
435 .filter(|query| {
436 catalog_values
437 .iter()
438 .any(|candidate| candidate.eq_ignore_ascii_case(query))
439 })
440 .count()
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize)]
445pub struct FormationRequest {
446 pub id: String,
448 pub required_roles: Vec<SuggestorRole>,
450 pub required_capabilities: Vec<SuggestorCapability>,
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
456pub struct FormationPlan {
457 pub request_id: String,
459 pub assignments: Vec<RoleAssignment>,
461 pub unmatched_roles: Vec<SuggestorRole>,
463 pub coverage_ratio: f64,
465}
466
467#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
469pub struct RoleAssignment {
470 pub role: SuggestorRole,
471 pub suggestor: String,
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 struct AnalysisSuggestor;
479
480 impl SuggestorProfile for AnalysisSuggestor {
481 fn role(&self) -> SuggestorRole {
482 SuggestorRole::Analysis
483 }
484
485 fn output_keys(&self) -> &[ContextKey] {
486 &[ContextKey::Hypotheses]
487 }
488
489 fn cost_hint(&self) -> CostClass {
490 CostClass::Medium
491 }
492
493 fn latency_hint(&self) -> LatencyClass {
494 LatencyClass::Interactive
495 }
496
497 fn capabilities(&self) -> &[SuggestorCapability] {
498 &[SuggestorCapability::LlmReasoning]
499 }
500
501 fn confidence_range(&self) -> (f32, f32) {
502 (0.5, 0.95)
503 }
504 }
505
506 #[test]
507 fn profile_snapshot_captures_all_fields() {
508 let suggestor = AnalysisSuggestor;
509 let snap = ProfileSnapshot::from_profile("analysis-1", &suggestor);
510
511 assert_eq!(snap.name, "analysis-1");
512 assert_eq!(snap.role, SuggestorRole::Analysis);
513 assert_eq!(snap.output_keys, vec![ContextKey::Hypotheses]);
514 assert_eq!(snap.confidence_min, 0.5);
515 assert_eq!(snap.confidence_max, 0.95);
516 assert_eq!(snap.capabilities, vec![SuggestorCapability::LlmReasoning]);
517 }
518
519 #[test]
520 fn profile_snapshot_serde_roundtrip() {
521 let suggestor = AnalysisSuggestor;
522 let snap = ProfileSnapshot::from_profile("analysis-1", &suggestor);
523 let json = serde_json::to_string(&snap).unwrap();
524 let back: ProfileSnapshot = serde_json::from_str(&json).unwrap();
525
526 assert_eq!(back.name, snap.name);
527 assert_eq!(back.role, snap.role);
528 assert_eq!(back.confidence_min, snap.confidence_min);
529 }
530
531 #[test]
532 fn template_compiles_to_current_request_surface() {
533 let template = FormationTemplate::deliberated(DeliberatedFormationTemplate::new(
534 FormationTemplateMetadata::new(
535 "market-entry",
536 "Go or no-go market launch formation",
537 [SuggestorRole::Analysis, SuggestorRole::Planning],
538 )
539 .with_keyword("launch")
540 .with_entity("market")
541 .with_required_capability(SuggestorCapability::LlmReasoning),
542 4,
543 ));
544
545 let request = template.to_request("req-1");
546
547 assert_eq!(template.id(), "market-entry");
548 assert_eq!(template.kind(), FormationKind::Deliberated);
549 assert_eq!(
550 request.required_roles,
551 vec![SuggestorRole::Analysis, SuggestorRole::Planning]
552 );
553 assert!(
554 request.required_capabilities.is_empty(),
555 "template capabilities stay in the catalog for selection"
556 );
557
558 match &template {
559 FormationTemplate::Deliberated(inner) => {
560 assert_eq!(inner.huddle_max_cycles, 4);
561 assert!((inner.min_confidence_threshold - 0.6).abs() < f32::EPSILON);
562 }
563 other => panic!("expected deliberated template, got {other:?}"),
564 }
565 }
566
567 #[test]
568 fn catalog_prefers_more_specific_matching_template() {
569 let broad = FormationTemplate::static_template(StaticFormationTemplate::new(
570 FormationTemplateMetadata::new(
571 "general-market",
572 "General market analysis formation",
573 [SuggestorRole::Analysis],
574 )
575 .with_keyword("market")
576 .with_entity("region"),
577 ));
578 let specific = FormationTemplate::deliberated(DeliberatedFormationTemplate::new(
579 FormationTemplateMetadata::new(
580 "market-entry",
581 "Launch decision formation",
582 [
583 SuggestorRole::Analysis,
584 SuggestorRole::Planning,
585 SuggestorRole::Constraint,
586 ],
587 )
588 .with_keyword("market")
589 .with_keyword("launch")
590 .with_entity("region")
591 .with_entity("competitors")
592 .with_required_capability(SuggestorCapability::LlmReasoning),
593 3,
594 ));
595 let catalog = FormationCatalog::new()
596 .with_template(broad)
597 .with_template(specific);
598 let query = FormationTemplateQuery::new()
599 .with_keyword("launch")
600 .with_entity("competitors")
601 .with_required_capability(SuggestorCapability::LlmReasoning);
602
603 let matches = catalog.matches(&query);
604
605 assert_eq!(matches.len(), 1);
606 assert_eq!(matches[0].id(), "market-entry");
607 assert_eq!(
608 catalog.top_match(&query).map(FormationTemplate::kind),
609 Some(FormationKind::Deliberated)
610 );
611 }
612
613 #[test]
614 fn catalog_register_replaces_existing_template_by_id() {
615 let mut catalog = FormationCatalog::new();
616 catalog.register(FormationTemplate::static_template(
617 StaticFormationTemplate::new(FormationTemplateMetadata::new(
618 "market-entry",
619 "First revision",
620 [SuggestorRole::Analysis],
621 )),
622 ));
623 catalog.register(FormationTemplate::scored(ScoredFormationTemplate::new(
624 FormationTemplateMetadata::new(
625 "market-entry",
626 "Second revision",
627 [SuggestorRole::Analysis, SuggestorRole::Planning],
628 ),
629 2,
630 )));
631
632 assert_eq!(catalog.len(), 1);
633 assert_eq!(
634 catalog.get("market-entry").map(FormationTemplate::kind),
635 Some(FormationKind::Scored)
636 );
637 }
638
639 #[test]
640 fn catalog_serde_roundtrip_preserves_templates() {
641 let catalog = FormationCatalog::new().with_template(FormationTemplate::open_claw(
642 OpenClawFormationTemplate::new(
643 FormationTemplateMetadata::new(
644 "stress-test",
645 "Open-claw escalation formation",
646 [
647 SuggestorRole::Analysis,
648 SuggestorRole::Evaluation,
649 SuggestorRole::Constraint,
650 ],
651 )
652 .with_keyword("stress")
653 .with_entity("scenario")
654 .with_required_capability(SuggestorCapability::ExperienceLearning),
655 2,
656 ),
657 ));
658
659 let json = serde_json::to_string(&catalog).unwrap();
660 let roundtrip: FormationCatalog = serde_json::from_str(&json).unwrap();
661
662 assert_eq!(roundtrip.len(), 1);
663 assert_eq!(
664 roundtrip.get("stress-test").map(FormationTemplate::kind),
665 Some(FormationKind::OpenClaw)
666 );
667 }
668
669 #[test]
670 fn formation_request_and_plan_roundtrip() {
671 let request = FormationRequest {
672 id: "req-1".to_string(),
673 required_roles: vec![SuggestorRole::Analysis, SuggestorRole::Planning],
674 required_capabilities: vec![SuggestorCapability::Analytics],
675 };
676 let plan = FormationPlan {
677 request_id: request.id.clone(),
678 assignments: vec![RoleAssignment {
679 role: SuggestorRole::Analysis,
680 suggestor: "analysis-1".to_string(),
681 }],
682 unmatched_roles: vec![SuggestorRole::Planning],
683 coverage_ratio: 0.5,
684 };
685
686 let request_back: FormationRequest =
687 serde_json::from_str(&serde_json::to_string(&request).unwrap()).unwrap();
688 let plan_back: FormationPlan =
689 serde_json::from_str(&serde_json::to_string(&plan).unwrap()).unwrap();
690
691 assert_eq!(request_back.required_roles, request.required_roles);
692 assert_eq!(plan_back.assignments, plan.assignments);
693 assert_eq!(plan_back.unmatched_roles, plan.unmatched_roles);
694 }
695}