1use 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
16const REQUEST_PREFIX: &str = "formation-request:";
19const PLAN_PREFIX: &str = "formation-plan:";
20const MALFORMED_PREFIX: &str = "formation-request-error:";
21
22pub 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
120fn assemble(req: &FormationRequest, catalog: &[ProfileSnapshot]) -> FormationPlan {
123 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 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
189fn 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
213impl Default for crate::graph::matching::Matching {
216 fn default() -> Self {
217 Self {
218 pairs: vec![],
219 size: 0,
220 }
221 }
222}
223
224#[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 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 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}