converge_optimization/suggestors/
assignment.rs1use async_trait::async_trait;
18use converge_pack::ProvenanceSource;
19use converge_pack::{
20 AgentEffect, Context, ContextKey, DiagnosticPayload, FactPayload, ProposedFact, Suggestor,
21};
22use serde::{Deserialize, Serialize};
23
24use crate::assignment::{AssignmentProblem, hungarian};
25
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct AssignmentRequest {
32 pub id: String,
34 pub agents: Vec<String>,
36 pub tasks: Vec<String>,
38 pub costs: Vec<Vec<i64>>,
40}
41
42impl FactPayload for AssignmentRequest {
43 const FAMILY: &'static str = "converge.optimization.assignment.request";
44 const VERSION: u16 = 1;
45}
46
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct AssignmentPlan {
53 pub request_id: String,
54 pub assignments: Vec<(String, String)>,
56 pub total_cost: i64,
57 pub utilization: f64,
59}
60
61impl FactPayload for AssignmentPlan {
62 const FAMILY: &'static str = "converge.optimization.assignment.plan";
63 const VERSION: u16 = 1;
64}
65
66const REQUEST_PREFIX: &str = "assignment-request:";
69const PLAN_PREFIX: &str = "assignment-plan:";
70const ERROR_PREFIX: &str = "assignment-request-error:";
71
72pub struct AssignmentSuggestor;
76
77#[async_trait]
78impl Suggestor for AssignmentSuggestor {
79 fn name(&self) -> &str {
80 "AssignmentSuggestor"
81 }
82
83 fn dependencies(&self) -> &[ContextKey] {
84 &[ContextKey::Seeds]
85 }
86
87 fn complexity_hint(&self) -> Option<&'static str> {
88 Some("O(n³) Hungarian algorithm — n = agents = tasks; practical for n ≤ 500")
89 }
90
91 fn accepts(&self, ctx: &dyn Context) -> bool {
92 ctx.get(ContextKey::Seeds).iter().any(|f| {
93 f.id().as_str().starts_with(REQUEST_PREFIX)
94 && match f.payload::<AssignmentRequest>() {
95 Some(_) => !plan_exists(ctx, req_id(f.id().as_str())),
96 None => !error_exists(ctx, f.id().as_str()),
97 }
98 })
99 }
100
101 async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
102 let mut proposals = Vec::new();
103
104 for fact in ctx
105 .get(ContextKey::Seeds)
106 .iter()
107 .filter(|f| f.id().as_str().starts_with(REQUEST_PREFIX))
108 {
109 match fact.payload::<AssignmentRequest>() {
110 Some(req) => {
111 if plan_exists(ctx, req_id(fact.id().as_str())) {
112 continue;
113 }
114 let plan = solve(req);
115 proposals.push(
116 ProposedFact::new(
117 ContextKey::Strategies,
118 format!("{}{}", PLAN_PREFIX, plan.request_id),
119 plan.clone(),
120 self.name().to_string(),
121 )
122 .with_confidence(plan.utilization),
123 );
124 }
125 None => {
126 if error_exists(ctx, fact.id().as_str()) {
127 continue;
128 }
129 proposals.push(
130 ProposedFact::new(
131 ContextKey::Diagnostic,
132 format!("{}{}", ERROR_PREFIX, fact.id()),
133 DiagnosticPayload::new(
134 self.name(),
135 format!(
136 "malformed assignment request '{}': expected {} v{} payload",
137 fact.id(),
138 AssignmentRequest::FAMILY,
139 AssignmentRequest::VERSION
140 ),
141 ),
142 self.name().to_string(),
143 )
144 .with_confidence(1.0),
145 );
146 }
147 }
148 }
149
150 if proposals.is_empty() {
151 AgentEffect::empty()
152 } else {
153 AgentEffect::with_proposals(proposals)
154 }
155 }
156
157 fn provenance(&self) -> &'static str {
158 super::CONVERGE_OPTIMIZATION_PROVENANCE.as_str()
159 }
160}
161
162fn solve(req: &AssignmentRequest) -> AssignmentPlan {
165 if req.agents.is_empty() {
166 return AssignmentPlan {
167 request_id: req.id.clone(),
168 assignments: vec![],
169 total_cost: 0,
170 utilization: 1.0,
171 };
172 }
173
174 let problem = AssignmentProblem::from_costs(req.costs.clone());
175 if problem.validate().is_err() {
176 return AssignmentPlan {
177 request_id: req.id.clone(),
178 assignments: vec![],
179 total_cost: 0,
180 utilization: 0.0,
181 };
182 }
183
184 match hungarian::solve(&problem) {
185 Ok(sol) => {
186 let assignments = sol
187 .assignments
188 .iter()
189 .enumerate()
190 .map(|(agent_idx, &task_idx)| {
191 (
192 req.agents.get(agent_idx).cloned().unwrap_or_default(),
193 req.tasks.get(task_idx).cloned().unwrap_or_default(),
194 )
195 })
196 .collect::<Vec<_>>();
197 let n = assignments.len();
198 AssignmentPlan {
199 request_id: req.id.clone(),
200 assignments,
201 total_cost: sol.total_cost,
202 utilization: n as f64 / req.agents.len() as f64,
203 }
204 }
205 Err(_) => AssignmentPlan {
206 request_id: req.id.clone(),
207 assignments: vec![],
208 total_cost: 0,
209 utilization: 0.0,
210 },
211 }
212}
213
214fn req_id(fact_id: &str) -> &str {
217 fact_id.trim_start_matches(REQUEST_PREFIX)
218}
219
220fn plan_exists(ctx: &dyn Context, request_id: &str) -> bool {
221 let id = format!("{}{}", PLAN_PREFIX, request_id);
222 ctx.get(ContextKey::Strategies)
223 .iter()
224 .any(|f| f.id().as_str() == id)
225}
226
227fn error_exists(ctx: &dyn Context, fact_id: &str) -> bool {
228 let id = format!("{}{}", ERROR_PREFIX, fact_id);
229 ctx.get(ContextKey::Diagnostic)
230 .iter()
231 .any(|f| f.id().as_str() == id)
232}
233
234#[cfg(test)]
237mod tests {
238 use super::*;
239 use converge_core::{ContextState, Engine};
240 use converge_pack::TextPayload;
241
242 fn req(id: &str, costs: Vec<Vec<i64>>) -> AssignmentRequest {
243 let n = costs.len();
244 AssignmentRequest {
245 id: id.to_string(),
246 agents: (0..n).map(|i| format!("agent-{i}")).collect(),
247 tasks: (0..n).map(|i| format!("task-{i}")).collect(),
248 costs,
249 }
250 }
251
252 #[tokio::test]
253 async fn textbook_3x3_finds_optimal_cost() {
254 let mut engine = Engine::new();
256 engine.register_suggestor(AssignmentSuggestor);
257
258 let mut ctx = ContextState::new();
259 ctx.add_proposal(ProposedFact::new(
260 ContextKey::Seeds,
261 "assignment-request:r1",
262 req("r1", vec![vec![9, 2, 7], vec![6, 4, 3], vec![5, 8, 1]]),
263 "test",
264 ))
265 .unwrap();
266
267 let result = engine.run(ctx).await.unwrap();
268 let plans = result.context.get(ContextKey::Strategies);
269 assert_eq!(plans.len(), 1);
270 let plan = plans[0].require_payload::<AssignmentPlan>().unwrap();
271 assert_eq!(plan.total_cost, 9, "optimal cost = 9");
272 assert_eq!(plan.assignments.len(), 3);
273 assert!((plan.utilization - 1.0).abs() < f64::EPSILON);
274 }
275
276 #[tokio::test]
277 async fn result_is_idempotent() {
278 let mut engine = Engine::new();
279 engine.register_suggestor(AssignmentSuggestor);
280
281 let mut ctx = ContextState::new();
282 ctx.add_proposal(ProposedFact::new(
283 ContextKey::Seeds,
284 "assignment-request:r1",
285 req("r1", vec![vec![9, 2, 7], vec![6, 4, 3], vec![5, 8, 1]]),
286 "test",
287 ))
288 .unwrap();
289
290 let first = engine.run(ctx).await.unwrap();
291 let mut engine2 = Engine::new();
292 engine2.register_suggestor(AssignmentSuggestor);
293 let second = engine2.run(first.context.clone()).await.unwrap();
294 assert_eq!(
295 second.context.get(ContextKey::Strategies).len(),
296 first.context.get(ContextKey::Strategies).len(),
297 );
298 }
299
300 #[tokio::test]
301 async fn malformed_request_emits_diagnostic() {
302 let mut engine = Engine::new();
303 engine.register_suggestor(AssignmentSuggestor);
304
305 let mut ctx = ContextState::new();
306 ctx.add_proposal(ProposedFact::new(
307 ContextKey::Seeds,
308 "assignment-request:bad",
309 TextPayload::new("not an assignment request"),
310 "test",
311 ))
312 .unwrap();
313
314 let result = engine.run(ctx).await.unwrap();
315 assert_eq!(result.context.get(ContextKey::Diagnostic).len(), 1);
316 assert!(!result.context.has(ContextKey::Strategies));
317 }
318}