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, FactPayload};
9use converge_provider::{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, PartialEq, Serialize, Deserialize)]
445#[serde(deny_unknown_fields)]
446pub struct FormationRequest {
447    /// Stable request identifier used for idempotency.
448    pub id: String,
449    /// Roles that must be covered. Duplicates mean multiple seats of the same role.
450    pub required_roles: Vec<SuggestorRole>,
451    /// Extra capability constraints all eligible suggestors must satisfy.
452    pub required_capabilities: Vec<SuggestorCapability>,
453}
454
455/// Structured result of formation assembly.
456#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
457#[serde(deny_unknown_fields)]
458pub struct FormationPlan {
459    /// The request this plan answers.
460    pub request_id: String,
461    /// Matched role-to-suggestor assignments.
462    pub assignments: Vec<RoleAssignment>,
463    /// Roles that could not be filled from the catalog.
464    pub unmatched_roles: Vec<SuggestorRole>,
465    /// `assignments.len() / required_roles.len()` — 1.0 is full coverage.
466    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/// A single role-to-suggestor assignment.
480#[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}