1pub use converge_core::{FormationKind, ScoringWeights};
7
8use converge_pack::{ContextKey, FactPayload};
9use converge_provider::{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, PartialEq, Serialize, Deserialize)]
445#[serde(deny_unknown_fields)]
446pub struct FormationRequest {
447 pub id: String,
449 pub required_roles: Vec<SuggestorRole>,
451 pub required_capabilities: Vec<SuggestorCapability>,
453}
454
455#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
457#[serde(deny_unknown_fields)]
458pub struct FormationPlan {
459 pub request_id: String,
461 pub assignments: Vec<RoleAssignment>,
463 pub unmatched_roles: Vec<SuggestorRole>,
465 pub coverage_ratio: f64,
467}
468
469impl FactPayload for FormationRequest {
470 const FAMILY: &'static str = "converge.model.formation.request";
471 const VERSION: u16 = 1;
472}
473
474impl FactPayload for FormationPlan {
475 const FAMILY: &'static str = "converge.model.formation.plan";
476 const VERSION: u16 = 1;
477}
478
479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
481pub struct RoleAssignment {
482 pub role: SuggestorRole,
483 pub suggestor: String,
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 struct AnalysisSuggestor;
491
492 impl SuggestorProfile for AnalysisSuggestor {
493 fn role(&self) -> SuggestorRole {
494 SuggestorRole::Analysis
495 }
496
497 fn output_keys(&self) -> &[ContextKey] {
498 &[ContextKey::Hypotheses]
499 }
500
501 fn cost_hint(&self) -> CostClass {
502 CostClass::Medium
503 }
504
505 fn latency_hint(&self) -> LatencyClass {
506 LatencyClass::Interactive
507 }
508
509 fn capabilities(&self) -> &[SuggestorCapability] {
510 &[SuggestorCapability::LlmReasoning]
511 }
512
513 fn confidence_range(&self) -> (f32, f32) {
514 (0.5, 0.95)
515 }
516 }
517
518 #[test]
519 fn profile_snapshot_captures_all_fields() {
520 let suggestor = AnalysisSuggestor;
521 let snap = ProfileSnapshot::from_profile("analysis-1", &suggestor);
522
523 assert_eq!(snap.name, "analysis-1");
524 assert_eq!(snap.role, SuggestorRole::Analysis);
525 assert_eq!(snap.output_keys, vec![ContextKey::Hypotheses]);
526 assert_eq!(snap.confidence_min, 0.5);
527 assert_eq!(snap.confidence_max, 0.95);
528 assert_eq!(snap.capabilities, vec![SuggestorCapability::LlmReasoning]);
529 }
530
531 #[test]
532 fn profile_snapshot_serde_roundtrip() {
533 let suggestor = AnalysisSuggestor;
534 let snap = ProfileSnapshot::from_profile("analysis-1", &suggestor);
535 let json = serde_json::to_string(&snap).unwrap();
536 let back: ProfileSnapshot = serde_json::from_str(&json).unwrap();
537
538 assert_eq!(back.name, snap.name);
539 assert_eq!(back.role, snap.role);
540 assert_eq!(back.confidence_min, snap.confidence_min);
541 }
542
543 #[test]
544 fn template_compiles_to_current_request_surface() {
545 let template = FormationTemplate::deliberated(DeliberatedFormationTemplate::new(
546 FormationTemplateMetadata::new(
547 "market-entry",
548 "Go or no-go market launch formation",
549 [SuggestorRole::Analysis, SuggestorRole::Planning],
550 )
551 .with_keyword("launch")
552 .with_entity("market")
553 .with_required_capability(SuggestorCapability::LlmReasoning),
554 4,
555 ));
556
557 let request = template.to_request("req-1");
558
559 assert_eq!(template.id(), "market-entry");
560 assert_eq!(template.kind(), FormationKind::Deliberated);
561 assert_eq!(
562 request.required_roles,
563 vec![SuggestorRole::Analysis, SuggestorRole::Planning]
564 );
565 assert!(
566 request.required_capabilities.is_empty(),
567 "template capabilities stay in the catalog for selection"
568 );
569
570 match &template {
571 FormationTemplate::Deliberated(inner) => {
572 assert_eq!(inner.huddle_max_cycles, 4);
573 assert!((inner.min_confidence_threshold - 0.6).abs() < f32::EPSILON);
574 }
575 other => panic!("expected deliberated template, got {other:?}"),
576 }
577 }
578
579 #[test]
580 fn catalog_prefers_more_specific_matching_template() {
581 let broad = FormationTemplate::static_template(StaticFormationTemplate::new(
582 FormationTemplateMetadata::new(
583 "general-market",
584 "General market analysis formation",
585 [SuggestorRole::Analysis],
586 )
587 .with_keyword("market")
588 .with_entity("region"),
589 ));
590 let specific = FormationTemplate::deliberated(DeliberatedFormationTemplate::new(
591 FormationTemplateMetadata::new(
592 "market-entry",
593 "Launch decision formation",
594 [
595 SuggestorRole::Analysis,
596 SuggestorRole::Planning,
597 SuggestorRole::Constraint,
598 ],
599 )
600 .with_keyword("market")
601 .with_keyword("launch")
602 .with_entity("region")
603 .with_entity("competitors")
604 .with_required_capability(SuggestorCapability::LlmReasoning),
605 3,
606 ));
607 let catalog = FormationCatalog::new()
608 .with_template(broad)
609 .with_template(specific);
610 let query = FormationTemplateQuery::new()
611 .with_keyword("launch")
612 .with_entity("competitors")
613 .with_required_capability(SuggestorCapability::LlmReasoning);
614
615 let matches = catalog.matches(&query);
616
617 assert_eq!(matches.len(), 1);
618 assert_eq!(matches[0].id(), "market-entry");
619 assert_eq!(
620 catalog.top_match(&query).map(FormationTemplate::kind),
621 Some(FormationKind::Deliberated)
622 );
623 }
624
625 #[test]
626 fn catalog_register_replaces_existing_template_by_id() {
627 let mut catalog = FormationCatalog::new();
628 catalog.register(FormationTemplate::static_template(
629 StaticFormationTemplate::new(FormationTemplateMetadata::new(
630 "market-entry",
631 "First revision",
632 [SuggestorRole::Analysis],
633 )),
634 ));
635 catalog.register(FormationTemplate::scored(ScoredFormationTemplate::new(
636 FormationTemplateMetadata::new(
637 "market-entry",
638 "Second revision",
639 [SuggestorRole::Analysis, SuggestorRole::Planning],
640 ),
641 2,
642 )));
643
644 assert_eq!(catalog.len(), 1);
645 assert_eq!(
646 catalog.get("market-entry").map(FormationTemplate::kind),
647 Some(FormationKind::Scored)
648 );
649 }
650
651 #[test]
652 fn catalog_serde_roundtrip_preserves_templates() {
653 let catalog = FormationCatalog::new().with_template(FormationTemplate::open_claw(
654 OpenClawFormationTemplate::new(
655 FormationTemplateMetadata::new(
656 "stress-test",
657 "Open-claw escalation formation",
658 [
659 SuggestorRole::Analysis,
660 SuggestorRole::Evaluation,
661 SuggestorRole::Constraint,
662 ],
663 )
664 .with_keyword("stress")
665 .with_entity("scenario")
666 .with_required_capability(SuggestorCapability::ExperienceLearning),
667 2,
668 ),
669 ));
670
671 let json = serde_json::to_string(&catalog).unwrap();
672 let roundtrip: FormationCatalog = serde_json::from_str(&json).unwrap();
673
674 assert_eq!(roundtrip.len(), 1);
675 assert_eq!(
676 roundtrip.get("stress-test").map(FormationTemplate::kind),
677 Some(FormationKind::OpenClaw)
678 );
679 }
680
681 #[test]
682 fn formation_request_and_plan_roundtrip() {
683 let request = FormationRequest {
684 id: "req-1".to_string(),
685 required_roles: vec![SuggestorRole::Analysis, SuggestorRole::Planning],
686 required_capabilities: vec![SuggestorCapability::Analytics],
687 };
688 let plan = FormationPlan {
689 request_id: request.id.clone(),
690 assignments: vec![RoleAssignment {
691 role: SuggestorRole::Analysis,
692 suggestor: "analysis-1".to_string(),
693 }],
694 unmatched_roles: vec![SuggestorRole::Planning],
695 coverage_ratio: 0.5,
696 };
697
698 let request_back: FormationRequest =
699 serde_json::from_str(&serde_json::to_string(&request).unwrap()).unwrap();
700 let plan_back: FormationPlan =
701 serde_json::from_str(&serde_json::to_string(&plan).unwrap()).unwrap();
702
703 assert_eq!(request_back.required_roles, request.required_roles);
704 assert_eq!(plan_back.assignments, plan.assignments);
705 assert_eq!(plan_back.unmatched_roles, plan.unmatched_roles);
706 }
707}