1use 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}