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