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