1use 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
19const REQUEST_PREFIX: &str = "formation-request:";
22const PLAN_PREFIX: &str = "formation-plan:";
23const MALFORMED_PREFIX: &str = "formation-request-error:";
24
25pub 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
130fn assemble(req: &FormationRequest, catalog: &[ProfileSnapshot]) -> FormationPlan {
133 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 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
199fn 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
223impl Default for crate::graph::matching::Matching {
226 fn default() -> Self {
227 Self {
228 pairs: vec![],
229 size: 0,
230 }
231 }
232}
233
234#[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 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 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}