Skip to main content

converge_optimization/suggestors/
assignment.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Optimal assignment via the Hungarian algorithm (O(n³)).
5//!
6//! Reads an [`AssignmentRequest`] from context, solves the linear-sum
7//! assignment problem, and proposes an [`AssignmentPlan`] to
8//! [`ContextKey::Strategies`].
9//!
10//! # Formation role
11//!
12//! Seed a request once; every downstream suggestor that needs to know who
13//! does what reads the plan from `ContextKey::Strategies`. If cost estimates
14//! change (e.g. a capacity suggestor updates constraints), re-seed with a new
15//! request id — the suggestor reacts and the formation re-converges.
16
17use 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// ── Request ───────────────────────────────────────────────────────────────────
27
28/// Seed this under [`ContextKey::Seeds`] with id prefix `"assignment-request:"`.
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct AssignmentRequest {
32    /// Stable identifier for idempotency.
33    pub id: String,
34    /// Labels for the agents (rows). Length must equal `costs.len()`.
35    pub agents: Vec<String>,
36    /// Labels for the tasks (columns). Length must equal `costs[i].len()`.
37    pub tasks: Vec<String>,
38    /// Cost matrix: `costs[agent][task]`. Must be square (n×n).
39    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// ── Plan (output) ─────────────────────────────────────────────────────────────
48
49/// The optimal assignment produced by the suggestor.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct AssignmentPlan {
53    pub request_id: String,
54    /// `(agent_label, task_label)` pairs, one per matched agent.
55    pub assignments: Vec<(String, String)>,
56    pub total_cost: i64,
57    /// `assignments.len() / agents.len()` — 1.0 means fully matched.
58    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
66// ── Suggestor ─────────────────────────────────────────────────────────────────
67
68const REQUEST_PREFIX: &str = "assignment-request:";
69const PLAN_PREFIX: &str = "assignment-plan:";
70const ERROR_PREFIX: &str = "assignment-request-error:";
71
72/// Solves a linear-sum assignment problem using the Hungarian algorithm.
73///
74/// Registers as a zero-configuration unit — no injected state required.
75pub 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
162// ── Core logic ────────────────────────────────────────────────────────────────
163
164fn 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
214// ── Helpers ───────────────────────────────────────────────────────────────────
215
216fn 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// ── Tests ─────────────────────────────────────────────────────────────────────
235
236#[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        // Taha 3×3: optimal = 9
255        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}