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