Skip to main content

converge_core/
formation.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(tag = "kind", rename_all = "snake_case")]
8pub enum Formation {
9    Static(StaticFormation),
10    Scored(ScoredFormation),
11    Deliberated(DeliberatedFormation),
12    OpenClaw(OpenClawFormation),
13}
14
15impl Formation {
16    pub fn kind(&self) -> FormationKind {
17        match self {
18            Self::Static(_) => FormationKind::Static,
19            Self::Scored(_) => FormationKind::Scored,
20            Self::Deliberated(_) => FormationKind::Deliberated,
21            Self::OpenClaw(_) => FormationKind::OpenClaw,
22        }
23    }
24
25    pub fn candidate_names(&self) -> &[String] {
26        match self {
27            Self::Static(f) => &f.suggestor_names,
28            Self::Scored(f) => &f.candidate_names,
29            Self::Deliberated(f) => &f.candidate_names,
30            Self::OpenClaw(f) => &f.candidate_names,
31        }
32    }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum FormationKind {
38    Static,
39    Scored,
40    Deliberated,
41    OpenClaw,
42}
43
44impl std::fmt::Display for FormationKind {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            Self::Static => write!(f, "static"),
48            Self::Scored => write!(f, "scored"),
49            Self::Deliberated => write!(f, "deliberated"),
50            Self::OpenClaw => write!(f, "open_claw"),
51        }
52    }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct StaticFormation {
57    pub suggestor_names: Vec<String>,
58}
59
60impl StaticFormation {
61    pub fn new(suggestor_names: impl IntoIterator<Item = impl Into<String>>) -> Self {
62        Self {
63            suggestor_names: suggestor_names.into_iter().map(Into::into).collect(),
64        }
65    }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ScoredFormation {
70    pub candidate_names: Vec<String>,
71    pub top_n: usize,
72    pub scoring_weights: ScoringWeights,
73}
74
75impl ScoredFormation {
76    pub fn new(candidate_names: impl IntoIterator<Item = impl Into<String>>, top_n: usize) -> Self {
77        Self {
78            candidate_names: candidate_names.into_iter().map(Into::into).collect(),
79            top_n,
80            scoring_weights: ScoringWeights::default(),
81        }
82    }
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ScoringWeights {
87    pub cost: f32,
88    pub latency: f32,
89    pub confidence: f32,
90    pub role_coverage: f32,
91}
92
93impl ScoringWeights {
94    pub fn uniform() -> Self {
95        Self {
96            cost: 0.25,
97            latency: 0.25,
98            confidence: 0.25,
99            role_coverage: 0.25,
100        }
101    }
102
103    pub fn sum(&self) -> f32 {
104        self.cost + self.latency + self.confidence + self.role_coverage
105    }
106}
107
108impl Default for ScoringWeights {
109    fn default() -> Self {
110        Self::uniform()
111    }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct DeliberatedFormation {
116    pub candidate_names: Vec<String>,
117    pub huddle_max_cycles: u32,
118    pub scoring_weights: ScoringWeights,
119    pub min_confidence_threshold: f32,
120}
121
122impl DeliberatedFormation {
123    pub fn new(
124        candidate_names: impl IntoIterator<Item = impl Into<String>>,
125        huddle_max_cycles: u32,
126    ) -> Self {
127        Self {
128            candidate_names: candidate_names.into_iter().map(Into::into).collect(),
129            huddle_max_cycles,
130            scoring_weights: ScoringWeights::default(),
131            min_confidence_threshold: 0.6,
132        }
133    }
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct OpenClawFormation {
138    pub candidate_names: Vec<String>,
139    pub max_extra_loops: u32,
140    pub formation_variants: Vec<Formation>,
141}
142
143impl OpenClawFormation {
144    pub fn new(
145        candidate_names: impl IntoIterator<Item = impl Into<String>>,
146        max_extra_loops: u32,
147    ) -> Self {
148        Self {
149            candidate_names: candidate_names.into_iter().map(Into::into).collect(),
150            max_extra_loops,
151            formation_variants: Vec::new(),
152        }
153    }
154
155    pub fn with_variants(mut self, variants: Vec<Formation>) -> Self {
156        self.formation_variants = variants;
157        self
158    }
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct FormationDecision {
163    pub selected_formation: Formation,
164    pub candidates_considered: Vec<String>,
165    pub rationale: String,
166    pub confidence: f32,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub correlation_id: Option<String>,
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub experience_key: Option<String>,
171}
172
173impl FormationDecision {
174    pub fn new(
175        selected_formation: Formation,
176        rationale: impl Into<String>,
177        confidence: f32,
178    ) -> Self {
179        let candidates = selected_formation.candidate_names().to_vec();
180        Self {
181            selected_formation,
182            candidates_considered: candidates,
183            rationale: rationale.into(),
184            confidence,
185            correlation_id: None,
186            experience_key: None,
187        }
188    }
189
190    pub fn with_correlation_id(mut self, correlation_id: impl Into<String>) -> Self {
191        self.correlation_id = Some(correlation_id.into());
192        self
193    }
194
195    pub fn with_experience_key(mut self, key: impl Into<String>) -> Self {
196        self.experience_key = Some(key.into());
197        self
198    }
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct FormationOutcome {
203    pub formation_kind: FormationKind,
204    pub suggestors_used: Vec<String>,
205    pub fixed_point_reached: bool,
206    pub cycles_used: u32,
207    pub extra_loops_used: u32,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub correlation_id: Option<String>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub quality_score: Option<f32>,
212    pub forced_fixed_point: bool,
213}
214
215impl FormationOutcome {
216    pub fn new(
217        formation_kind: FormationKind,
218        suggestors_used: Vec<String>,
219        fixed_point_reached: bool,
220        cycles_used: u32,
221    ) -> Self {
222        Self {
223            formation_kind,
224            suggestors_used,
225            fixed_point_reached,
226            cycles_used,
227            extra_loops_used: 0,
228            correlation_id: None,
229            quality_score: None,
230            forced_fixed_point: false,
231        }
232    }
233
234    pub fn with_correlation_id(mut self, correlation_id: impl Into<String>) -> Self {
235        self.correlation_id = Some(correlation_id.into());
236        self
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    // ── Formation::kind() and candidate_names() ──────────────────────────────
245
246    #[test]
247    fn static_formation_kind() {
248        let f = Formation::Static(StaticFormation::new(["a", "b"]));
249        assert_eq!(f.kind(), FormationKind::Static);
250        assert_eq!(f.candidate_names(), &["a", "b"]);
251    }
252
253    #[test]
254    fn scored_formation_kind() {
255        let f = Formation::Scored(ScoredFormation::new(["x", "y", "z"], 2));
256        assert_eq!(f.kind(), FormationKind::Scored);
257        assert_eq!(f.candidate_names().len(), 3);
258    }
259
260    #[test]
261    fn deliberated_formation_kind() {
262        let f = Formation::Deliberated(DeliberatedFormation::new(["p", "q"], 5));
263        assert_eq!(f.kind(), FormationKind::Deliberated);
264    }
265
266    #[test]
267    fn open_claw_formation_kind() {
268        let f = Formation::OpenClaw(OpenClawFormation::new(["a", "b", "c", "d"], 3));
269        assert_eq!(f.kind(), FormationKind::OpenClaw);
270        assert_eq!(f.candidate_names().len(), 4);
271    }
272
273    // ── FormationKind Display ─────────────────────────────────────────────────
274
275    #[test]
276    fn formation_kind_display() {
277        assert_eq!(FormationKind::Static.to_string(), "static");
278        assert_eq!(FormationKind::Scored.to_string(), "scored");
279        assert_eq!(FormationKind::Deliberated.to_string(), "deliberated");
280        assert_eq!(FormationKind::OpenClaw.to_string(), "open_claw");
281    }
282
283    // ── ScoringWeights ────────────────────────────────────────────────────────
284
285    #[test]
286    fn scoring_weights_uniform_sum_to_one() {
287        let w = ScoringWeights::uniform();
288        let total = w.sum();
289        assert!(
290            (total - 1.0).abs() < f32::EPSILON,
291            "expected sum 1.0, got {total}"
292        );
293    }
294
295    #[test]
296    fn scoring_weights_default_equals_uniform() {
297        let a = ScoringWeights::default();
298        let b = ScoringWeights::uniform();
299        assert_eq!(a.cost, b.cost);
300        assert_eq!(a.latency, b.latency);
301        assert_eq!(a.confidence, b.confidence);
302        assert_eq!(a.role_coverage, b.role_coverage);
303    }
304
305    // ── OpenClawFormation with_variants ──────────────────────────────────────
306
307    #[test]
308    fn open_claw_with_variants() {
309        let variant = Formation::Static(StaticFormation::new(["fallback"]));
310        let f = OpenClawFormation::new(["a", "b"], 2).with_variants(vec![variant]);
311        assert_eq!(f.formation_variants.len(), 1);
312        assert_eq!(f.max_extra_loops, 2);
313    }
314
315    // ── FormationDecision ─────────────────────────────────────────────────────
316
317    #[test]
318    fn formation_decision_captures_candidates() {
319        let formation = Formation::Static(StaticFormation::new(["alpha", "beta"]));
320        let decision = FormationDecision::new(formation, "best static fit", 0.9);
321        assert_eq!(decision.candidates_considered, vec!["alpha", "beta"]);
322        assert_eq!(decision.rationale, "best static fit");
323        assert!((decision.confidence - 0.9).abs() < f32::EPSILON);
324        assert!(decision.correlation_id.is_none());
325        assert!(decision.experience_key.is_none());
326    }
327
328    #[test]
329    fn formation_decision_with_linkage_keys() {
330        let formation = Formation::Deliberated(DeliberatedFormation::new(["a"], 3));
331        let decision = FormationDecision::new(formation, "deliberated", 0.75)
332            .with_correlation_id("corr-abc-123")
333            .with_experience_key("exp-abc-123");
334        assert_eq!(decision.correlation_id, Some("corr-abc-123".into()));
335        assert_eq!(decision.experience_key, Some("exp-abc-123".into()));
336    }
337
338    // ── FormationOutcome ──────────────────────────────────────────────────────
339
340    #[test]
341    fn formation_outcome_defaults() {
342        let outcome =
343            FormationOutcome::new(FormationKind::Scored, vec!["a".into(), "b".into()], true, 4);
344        assert_eq!(outcome.formation_kind, FormationKind::Scored);
345        assert!(outcome.fixed_point_reached);
346        assert_eq!(outcome.cycles_used, 4);
347        assert_eq!(outcome.extra_loops_used, 0);
348        assert!(!outcome.forced_fixed_point);
349        assert!(outcome.correlation_id.is_none());
350        assert!(outcome.quality_score.is_none());
351    }
352
353    // ── Serde roundtrips ──────────────────────────────────────────────────────
354
355    #[test]
356    fn formation_serde_roundtrip_static() {
357        let f = Formation::Static(StaticFormation::new(["a", "b"]));
358        let json = serde_json::to_string(&f).unwrap();
359        let back: Formation = serde_json::from_str(&json).unwrap();
360        assert_eq!(back.kind(), FormationKind::Static);
361        assert_eq!(back.candidate_names(), &["a", "b"]);
362    }
363
364    #[test]
365    fn formation_serde_roundtrip_open_claw() {
366        let inner = Formation::Static(StaticFormation::new(["fallback"]));
367        let f =
368            Formation::OpenClaw(OpenClawFormation::new(["x", "y"], 5).with_variants(vec![inner]));
369        let json = serde_json::to_string(&f).unwrap();
370        let back: Formation = serde_json::from_str(&json).unwrap();
371        assert_eq!(back.kind(), FormationKind::OpenClaw);
372        if let Formation::OpenClaw(oc) = back {
373            assert_eq!(oc.formation_variants.len(), 1);
374        } else {
375            panic!("expected OpenClaw");
376        }
377    }
378
379    #[test]
380    fn formation_kind_serde_roundtrip() {
381        for kind in [
382            FormationKind::Static,
383            FormationKind::Scored,
384            FormationKind::Deliberated,
385            FormationKind::OpenClaw,
386        ] {
387            let json = serde_json::to_string(&kind).unwrap();
388            let back: FormationKind = serde_json::from_str(&json).unwrap();
389            assert_eq!(back, kind);
390        }
391    }
392
393    #[test]
394    fn formation_decision_serde_roundtrip() {
395        let formation = Formation::Scored(ScoredFormation::new(["a", "b", "c"], 2));
396        let decision = FormationDecision::new(formation, "top-2 by score", 0.8)
397            .with_correlation_id("corr-99")
398            .with_experience_key("xp-99");
399        let json = serde_json::to_string(&decision).unwrap();
400        let back: FormationDecision = serde_json::from_str(&json).unwrap();
401        assert_eq!(back.rationale, "top-2 by score");
402        assert_eq!(back.correlation_id, Some("corr-99".into()));
403        assert_eq!(back.experience_key, Some("xp-99".into()));
404        assert_eq!(back.selected_formation.kind(), FormationKind::Scored);
405    }
406
407    #[test]
408    fn formation_outcome_serde_roundtrip() {
409        let mut outcome =
410            FormationOutcome::new(FormationKind::OpenClaw, vec!["a".into()], false, 10)
411                .with_correlation_id("corr-formation-1");
412        outcome.extra_loops_used = 3;
413        outcome.forced_fixed_point = true;
414        outcome.quality_score = Some(0.72);
415
416        let json = serde_json::to_string(&outcome).unwrap();
417        let back: FormationOutcome = serde_json::from_str(&json).unwrap();
418        assert_eq!(back.extra_loops_used, 3);
419        assert_eq!(back.correlation_id, Some("corr-formation-1".into()));
420        assert!(back.forced_fixed_point);
421        assert!((back.quality_score.unwrap() - 0.72).abs() < f32::EPSILON);
422    }
423}