1use serde::{Deserialize, Serialize};
5
6pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}