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