Skip to main content

converge_model/
formation.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Canonical formation semantics for downstream consumers and embedders.
5
6pub use converge_core::{FormationKind, ScoringWeights};
7
8use converge_pack::ContextKey;
9use converge_provider_api::{CostClass, LatencyClass};
10use serde::{Deserialize, Serialize};
11
12/// Describes the shape and operating envelope of a suggestor.
13pub 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/// The coarse role a suggestor plays inside a formation.
23#[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/// Capabilities formation assembly may use to filter or prefer suggestors.
36#[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/// Serializable snapshot of a suggestor's formation metadata.
49#[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/// Classification-facing metadata for a reusable formation template.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct FormationTemplateMetadata {
81    /// Stable catalog identifier.
82    pub id: String,
83    /// Human-readable explanation of the formation's purpose.
84    pub description: String,
85    /// Loose intent phrases that suggest this template is relevant.
86    pub keywords: Vec<String>,
87    /// Entity labels or structured fields the template expects to see.
88    pub entities: Vec<String>,
89    /// Roles the assembled formation must cover.
90    pub required_roles: Vec<SuggestorRole>,
91    /// Problem-level capabilities this template is designed to satisfy.
92    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/// Template for a static formation shape.
132#[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/// Template for a scored formation shape.
145#[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/// Template for a deliberated formation shape.
164#[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/// Template for an open-claw formation shape.
185#[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/// Reusable formation choice before concrete suggestor assembly happens.
202#[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    /// Compile this template into the current request surface.
258    ///
259    /// The template's `required_capabilities` remain catalog metadata for
260    /// problem classification and later guru/tournament logic. They are not
261    /// copied into [`FormationRequest::required_capabilities`] because the
262    /// current assembly suggestor interprets that field as a global
263    /// per-suggestor eligibility gate.
264    #[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/// Query surface a classifier can emit before choosing a formation template.
298#[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/// Registry of reusable formation templates.
338#[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/// Structured request for formation assembly.
444#[derive(Debug, Clone, Serialize, Deserialize)]
445pub struct FormationRequest {
446    /// Stable request identifier used for idempotency.
447    pub id: String,
448    /// Roles that must be covered. Duplicates mean multiple seats of the same role.
449    pub required_roles: Vec<SuggestorRole>,
450    /// Extra capability constraints all eligible suggestors must satisfy.
451    pub required_capabilities: Vec<SuggestorCapability>,
452}
453
454/// Structured result of formation assembly.
455#[derive(Debug, Clone, Serialize, Deserialize)]
456pub struct FormationPlan {
457    /// The request this plan answers.
458    pub request_id: String,
459    /// Matched role-to-suggestor assignments.
460    pub assignments: Vec<RoleAssignment>,
461    /// Roles that could not be filled from the catalog.
462    pub unmatched_roles: Vec<SuggestorRole>,
463    /// `assignments.len() / required_roles.len()` — 1.0 is full coverage.
464    pub coverage_ratio: f64,
465}
466
467/// A single role-to-suggestor assignment.
468#[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}