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::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// ── Request ───────────────────────────────────────────────────────────────────
28
29/// Seed this under [`ContextKey::Seeds`] with id prefix `"assignment-request:"`.
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct AssignmentRequest {
33    /// Stable identifier for idempotency.
34    pub id: String,
35    /// Labels for the agents (rows). Length must equal `costs.len()`.
36    pub agents: Vec<String>,
37    /// Labels for the tasks (columns). Length must equal `costs[i].len()`.
38    pub tasks: Vec<String>,
39    /// Cost matrix: `costs[agent][task]`. Must be square (n×n).
40    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// ── Plan (output) ─────────────────────────────────────────────────────────────
49
50/// The optimal assignment produced by the suggestor.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct AssignmentPlan {
54    pub request_id: String,
55    /// `(agent_label, task_label)` pairs, one per matched agent.
56    pub assignments: Vec<(String, String)>,
57    pub total_cost: i64,
58    /// `assignments.len() / agents.len()` — 1.0 means fully matched.
59    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
67// ── Suggestor ─────────────────────────────────────────────────────────────────
68
69const REQUEST_PREFIX: &str = "assignment-request:";
70const PLAN_PREFIX: &str = "assignment-plan:";
71const ERROR_PREFIX: &str = "assignment-request-error:";
72
73/// Solves a linear-sum assignment problem using the Hungarian algorithm.
74///
75/// Registers as a zero-configuration unit — no injected state required.
76pub 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
163// ── Core logic ────────────────────────────────────────────────────────────────
164
165fn 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
215// ── Helpers ───────────────────────────────────────────────────────────────────
216
217fn 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// ── Tests ─────────────────────────────────────────────────────────────────────
236
237#[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        // Taha 3×3: optimal = 9
256        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}