Skip to main content

converge_optimization/suggestors/
formation.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Formation assembly via bipartite matching.
5//!
6//! Reads a [`FormationRequest`] from context, matches required roles against a
7//! catalog of [`ProfileSnapshot`]s using Hopcroft-Karp, and proposes a
8//! [`FormationPlan`] to [`ContextKey::Strategies`].
9
10use async_trait::async_trait;
11use converge_model::formation::{FormationPlan, FormationRequest, ProfileSnapshot, RoleAssignment};
12use converge_pack::{AgentEffect, Context, ContextKey, ProposedFact, Suggestor};
13
14use crate::graph::matching::bipartite_matching;
15
16// ── Suggestor ─────────────────────────────────────────────────────────────────
17
18const REQUEST_PREFIX: &str = "formation-request:";
19const PLAN_PREFIX: &str = "formation-plan:";
20const MALFORMED_PREFIX: &str = "formation-request-error:";
21
22/// Assembles a formation by matching required roles to available suggestors.
23///
24/// # Construction
25///
26/// ```rust,ignore
27/// let mut catalog = Vec::new();
28///
29/// register_profiled(&mut engine, &mut catalog, analysis_suggestor);
30/// register_profiled(&mut engine, &mut catalog, planning_suggestor);
31///
32/// engine.register_suggestor(FormationAssemblySuggestor::new(catalog));
33/// ```
34pub struct FormationAssemblySuggestor {
35    catalog: Vec<ProfileSnapshot>,
36}
37
38impl FormationAssemblySuggestor {
39    pub fn new(catalog: Vec<ProfileSnapshot>) -> Self {
40        Self { catalog }
41    }
42}
43
44#[async_trait]
45impl Suggestor for FormationAssemblySuggestor {
46    fn name(&self) -> &str {
47        "FormationAssemblySuggestor"
48    }
49
50    fn dependencies(&self) -> &[ContextKey] {
51        &[ContextKey::Seeds]
52    }
53
54    fn accepts(&self, ctx: &dyn Context) -> bool {
55        ctx.get(ContextKey::Seeds).iter().any(|f| {
56            f.id.starts_with(REQUEST_PREFIX)
57                && match serde_json::from_str::<FormationRequest>(&f.content) {
58                    Ok(_) => !plan_exists(ctx, request_id(&f.id)),
59                    Err(_) => !malformed_diagnostic_exists(ctx, &f.id),
60                }
61        })
62    }
63
64    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
65        let mut proposals = Vec::new();
66
67        for fact in ctx
68            .get(ContextKey::Seeds)
69            .iter()
70            .filter(|f| f.id.starts_with(REQUEST_PREFIX))
71        {
72            match serde_json::from_str::<FormationRequest>(&fact.content) {
73                Ok(req) => {
74                    if plan_exists(ctx, request_id(&fact.id)) {
75                        continue;
76                    }
77
78                    let plan = assemble(&req, &self.catalog);
79                    proposals.push(
80                        ProposedFact::new(
81                            ContextKey::Strategies,
82                            format!("{}{}", PLAN_PREFIX, plan.request_id),
83                            serde_json::to_string(&plan).unwrap_or_default(),
84                            self.name(),
85                        )
86                        .with_confidence(plan.coverage_ratio),
87                    );
88                }
89                Err(error) => {
90                    if malformed_diagnostic_exists(ctx, &fact.id) {
91                        continue;
92                    }
93
94                    let diagnostic = serde_json::json!({
95                        "request_fact_id": fact.id,
96                        "message": "malformed formation request ignored",
97                        "error": error.to_string(),
98                    });
99                    proposals.push(
100                        ProposedFact::new(
101                            ContextKey::Diagnostic,
102                            malformed_diagnostic_id(&fact.id),
103                            diagnostic.to_string(),
104                            self.name(),
105                        )
106                        .with_confidence(1.0),
107                    );
108                }
109            }
110        }
111
112        if proposals.is_empty() {
113            AgentEffect::empty()
114        } else {
115            AgentEffect::with_proposals(proposals)
116        }
117    }
118}
119
120// ── Matching logic ────────────────────────────────────────────────────────────
121
122fn assemble(req: &FormationRequest, catalog: &[ProfileSnapshot]) -> FormationPlan {
123    // Filter catalog to suggestors that satisfy all required_capabilities (if any).
124    let eligible: Vec<&ProfileSnapshot> = if req.required_capabilities.is_empty() {
125        catalog.iter().collect()
126    } else {
127        catalog
128            .iter()
129            .filter(|s| {
130                req.required_capabilities
131                    .iter()
132                    .all(|cap| s.capabilities.contains(cap))
133            })
134            .collect()
135    };
136
137    // Left = required role slots (index = position in req.required_roles).
138    // Right = eligible catalog entries (index = position in `eligible`).
139    // Edge: eligible[j].role == req.required_roles[i].
140    let edges: Vec<(usize, usize)> = req
141        .required_roles
142        .iter()
143        .enumerate()
144        .flat_map(|(i, role)| {
145            eligible
146                .iter()
147                .enumerate()
148                .filter(move |(_, s)| s.role == *role)
149                .map(move |(j, _)| (i, j))
150        })
151        .collect();
152
153    let matching =
154        bipartite_matching(req.required_roles.len(), eligible.len(), &edges).unwrap_or_default();
155
156    let mut assigned = vec![false; req.required_roles.len()];
157    let mut assignments = Vec::with_capacity(matching.size);
158
159    for (role_idx, cand_idx) in &matching.pairs {
160        assignments.push(RoleAssignment {
161            role: req.required_roles[*role_idx],
162            suggestor: eligible[*cand_idx].name.clone(),
163        });
164        assigned[*role_idx] = true;
165    }
166
167    let unmatched_roles = req
168        .required_roles
169        .iter()
170        .enumerate()
171        .filter(|(i, _)| !assigned[*i])
172        .map(|(_, r)| *r)
173        .collect::<Vec<_>>();
174
175    let coverage_ratio = if req.required_roles.is_empty() {
176        1.0
177    } else {
178        matching.size as f64 / req.required_roles.len() as f64
179    };
180
181    FormationPlan {
182        request_id: req.id.clone(),
183        assignments,
184        unmatched_roles,
185        coverage_ratio,
186    }
187}
188
189// ── Helpers ───────────────────────────────────────────────────────────────────
190
191fn request_id(fact_id: &str) -> &str {
192    fact_id.trim_start_matches(REQUEST_PREFIX)
193}
194
195fn plan_exists(ctx: &dyn Context, request_id: &str) -> bool {
196    let plan_id = format!("{}{}", PLAN_PREFIX, request_id);
197    ctx.get(ContextKey::Strategies)
198        .iter()
199        .any(|f| f.id == plan_id)
200}
201
202fn malformed_diagnostic_id(fact_id: &str) -> String {
203    format!("{MALFORMED_PREFIX}{fact_id}")
204}
205
206fn malformed_diagnostic_exists(ctx: &dyn Context, fact_id: &str) -> bool {
207    let diagnostic_id = malformed_diagnostic_id(fact_id);
208    ctx.get(ContextKey::Diagnostic)
209        .iter()
210        .any(|fact| fact.id == diagnostic_id)
211}
212
213// ── Default for Matching (graceful degradation) ───────────────────────────────
214
215impl Default for crate::graph::matching::Matching {
216    fn default() -> Self {
217        Self {
218            pairs: vec![],
219            size: 0,
220        }
221    }
222}
223
224// ── Tests ─────────────────────────────────────────────────────────────────────
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use converge_core::{ContextState, Engine};
230    use converge_model::formation::{SuggestorCapability, SuggestorRole};
231    use converge_pack::ContextKey;
232    use converge_provider_api::{CostClass, LatencyClass};
233
234    fn snapshot(name: &str, role: SuggestorRole, caps: &[SuggestorCapability]) -> ProfileSnapshot {
235        ProfileSnapshot {
236            name: name.to_string(),
237            role,
238            output_keys: vec![ContextKey::Strategies],
239            cost_hint: CostClass::Medium,
240            latency_hint: LatencyClass::Interactive,
241            capabilities: caps.to_vec(),
242            confidence_min: 0.5,
243            confidence_max: 0.95,
244        }
245    }
246
247    fn request(
248        id: &str,
249        roles: &[SuggestorRole],
250        caps: &[SuggestorCapability],
251    ) -> FormationRequest {
252        FormationRequest {
253            id: id.to_string(),
254            required_roles: roles.to_vec(),
255            required_capabilities: caps.to_vec(),
256        }
257    }
258
259    #[test]
260    fn full_coverage_when_catalog_satisfies_all_roles() {
261        let catalog = vec![
262            snapshot("analyser", SuggestorRole::Analysis, &[]),
263            snapshot("planner", SuggestorRole::Planning, &[]),
264            snapshot("enforcer", SuggestorRole::Constraint, &[]),
265        ];
266        let req = request(
267            "r1",
268            &[
269                SuggestorRole::Analysis,
270                SuggestorRole::Planning,
271                SuggestorRole::Constraint,
272            ],
273            &[],
274        );
275
276        let plan = assemble(&req, &catalog);
277
278        assert_eq!(plan.assignments.len(), 3);
279        assert!(plan.unmatched_roles.is_empty());
280        assert!((plan.coverage_ratio - 1.0).abs() < f64::EPSILON);
281    }
282
283    #[test]
284    fn partial_coverage_when_catalog_missing_a_role() {
285        let catalog = vec![
286            snapshot("analyser", SuggestorRole::Analysis, &[]),
287            snapshot("planner", SuggestorRole::Planning, &[]),
288        ];
289        let req = request(
290            "r2",
291            &[
292                SuggestorRole::Analysis,
293                SuggestorRole::Planning,
294                SuggestorRole::Constraint,
295            ],
296            &[],
297        );
298
299        let plan = assemble(&req, &catalog);
300
301        assert_eq!(plan.assignments.len(), 2);
302        assert_eq!(plan.unmatched_roles, vec![SuggestorRole::Constraint]);
303        assert!((plan.coverage_ratio - 2.0 / 3.0).abs() < 1e-9);
304    }
305
306    #[test]
307    fn capability_filter_excludes_ineligible_suggestors() {
308        let catalog = vec![
309            snapshot(
310                "llm-analyser",
311                SuggestorRole::Analysis,
312                &[SuggestorCapability::LlmReasoning],
313            ),
314            snapshot("plain-analyser", SuggestorRole::Analysis, &[]),
315        ];
316        // Only LlmReasoning-capable suggestors should be considered.
317        let req = request(
318            "r3",
319            &[SuggestorRole::Analysis],
320            &[SuggestorCapability::LlmReasoning],
321        );
322
323        let plan = assemble(&req, &catalog);
324
325        assert_eq!(plan.assignments.len(), 1);
326        assert_eq!(plan.assignments[0].suggestor, "llm-analyser");
327    }
328
329    #[test]
330    fn no_double_booking_with_two_same_role_slots() {
331        let catalog = vec![
332            snapshot("a1", SuggestorRole::Analysis, &[]),
333            snapshot("a2", SuggestorRole::Analysis, &[]),
334        ];
335        let req = request(
336            "r4",
337            &[SuggestorRole::Analysis, SuggestorRole::Analysis],
338            &[],
339        );
340
341        let plan = assemble(&req, &catalog);
342
343        assert_eq!(plan.assignments.len(), 2);
344        // Each suggestor used at most once.
345        let names: Vec<_> = plan.assignments.iter().map(|a| &a.suggestor).collect();
346        let unique: std::collections::HashSet<_> = names.iter().collect();
347        assert_eq!(unique.len(), 2);
348    }
349
350    #[test]
351    fn empty_catalog_yields_zero_coverage() {
352        let req = request(
353            "r5",
354            &[SuggestorRole::Analysis, SuggestorRole::Planning],
355            &[],
356        );
357        let plan = assemble(&req, &[]);
358        assert_eq!(plan.assignments.len(), 0);
359        assert_eq!(plan.coverage_ratio, 0.0);
360    }
361
362    #[test]
363    fn empty_request_yields_full_coverage() {
364        let catalog = vec![snapshot("a", SuggestorRole::Analysis, &[])];
365        let req = request("r6", &[], &[]);
366        let plan = assemble(&req, &catalog);
367        assert_eq!(plan.assignments.len(), 0);
368        assert!((plan.coverage_ratio - 1.0).abs() < f64::EPSILON);
369    }
370
371    #[test]
372    fn repeated_matching_is_deterministic_for_equal_candidates() {
373        let catalog = vec![
374            snapshot("analysis-a", SuggestorRole::Analysis, &[]),
375            snapshot("analysis-b", SuggestorRole::Analysis, &[]),
376            snapshot("planning-a", SuggestorRole::Planning, &[]),
377        ];
378        let req = request(
379            "r7",
380            &[
381                SuggestorRole::Analysis,
382                SuggestorRole::Analysis,
383                SuggestorRole::Planning,
384            ],
385            &[],
386        );
387
388        let first = assemble(&req, &catalog);
389        let second = assemble(&req, &catalog);
390
391        assert_eq!(first.assignments, second.assignments);
392        assert_eq!(first.unmatched_roles, second.unmatched_roles);
393        assert_eq!(first.coverage_ratio, second.coverage_ratio);
394    }
395
396    #[tokio::test]
397    async fn malformed_request_emits_diagnostic_once() {
398        let mut engine = Engine::new();
399        engine.register_suggestor(FormationAssemblySuggestor::new(vec![snapshot(
400            "analysis-a",
401            SuggestorRole::Analysis,
402            &[],
403        )]));
404
405        let mut ctx = ContextState::new();
406        ctx.add_input(ContextKey::Seeds, "formation-request:broken", "{")
407            .expect("seed should stage");
408
409        let first = engine.run(ctx).await.expect("run should converge");
410        let diagnostics = first.context.get(ContextKey::Diagnostic);
411        assert_eq!(diagnostics.len(), 1);
412        assert_eq!(
413            diagnostics[0].id,
414            "formation-request-error:formation-request:broken"
415        );
416        assert!(!first.context.has(ContextKey::Strategies));
417
418        let mut rerun_engine = Engine::new();
419        rerun_engine.register_suggestor(FormationAssemblySuggestor::new(vec![snapshot(
420            "analysis-a",
421            SuggestorRole::Analysis,
422            &[],
423        )]));
424        let second = rerun_engine
425            .run(first.context.clone())
426            .await
427            .expect("rerun should converge");
428        assert_eq!(second.context.get(ContextKey::Diagnostic).len(), 1);
429    }
430}