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