Skip to main content

converge_optimization/suggestors/
portfolio.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Portfolio selection via 0-1 Knapsack DP.
5//!
6//! Reads a [`PortfolioRequest`] from context — a set of candidate items each
7//! with a weight (cost/effort) and value (benefit/ROI) — and proposes a
8//! [`PortfolioSelection`] that maximises value within the given budget.
9//!
10//! # Formation role
11//!
12//! Downstream suggestors that plan execution or allocate resources read the
13//! selected item labels from `ContextKey::Strategies`. A budget suggestor that
14//! revises the available capacity re-seeds the request; the formation re-runs
15//! and converges on the new optimum.
16
17use async_trait::async_trait;
18use converge_pack::{AgentEffect, Context, ContextKey, ProposedFact, Suggestor};
19use serde::{Deserialize, Serialize};
20
21use crate::knapsack::{self, KnapsackProblem};
22
23// ── Request ───────────────────────────────────────────────────────────────────
24
25/// A portfolio optimisation request. Seed under [`ContextKey::Seeds`] with id
26/// prefix `"portfolio-request:"`.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PortfolioRequest {
29    /// Stable identifier for idempotency.
30    pub id: String,
31    /// Candidate initiatives, investments, or features to select from.
32    pub items: Vec<PortfolioItem>,
33    /// Total budget (capacity). Same unit as `PortfolioItem::weight`.
34    pub budget: i64,
35}
36
37/// A single candidate item in the portfolio.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct PortfolioItem {
40    pub label: String,
41    /// Resource consumption (cost, effort, capital, story points, …).
42    pub weight: i64,
43    /// Expected benefit (ROI, value, impact score, …).
44    pub value: i64,
45}
46
47// ── Selection (output) ────────────────────────────────────────────────────────
48
49/// The optimal portfolio selection.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct PortfolioSelection {
52    pub request_id: String,
53    /// Labels of the selected items.
54    pub selected: Vec<String>,
55    pub total_value: i64,
56    pub total_weight: i64,
57    /// `total_weight / budget` — how much of the budget is consumed.
58    pub utilization: f64,
59}
60
61// ── Suggestor ─────────────────────────────────────────────────────────────────
62
63const REQUEST_PREFIX: &str = "portfolio-request:";
64const SELECTION_PREFIX: &str = "portfolio-selection:";
65const ERROR_PREFIX: &str = "portfolio-request-error:";
66
67/// Selects an optimal portfolio of items under a budget constraint using 0-1
68/// Knapsack dynamic programming.
69pub struct PortfolioSuggestor;
70
71#[async_trait]
72impl Suggestor for PortfolioSuggestor {
73    fn name(&self) -> &str {
74        "PortfolioSuggestor"
75    }
76
77    fn dependencies(&self) -> &[ContextKey] {
78        &[ContextKey::Seeds]
79    }
80
81    fn complexity_hint(&self) -> Option<&'static str> {
82        Some("O(n × W) 0-1 Knapsack DP — n = items, W = budget; avoid W > 10⁶")
83    }
84
85    fn accepts(&self, ctx: &dyn Context) -> bool {
86        ctx.get(ContextKey::Seeds).iter().any(|f| {
87            f.id.starts_with(REQUEST_PREFIX)
88                && match serde_json::from_str::<PortfolioRequest>(&f.content) {
89                    Ok(_) => !selection_exists(ctx, req_id(&f.id)),
90                    Err(_) => !error_exists(ctx, &f.id),
91                }
92        })
93    }
94
95    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
96        let mut proposals = Vec::new();
97
98        for fact in ctx
99            .get(ContextKey::Seeds)
100            .iter()
101            .filter(|f| f.id.starts_with(REQUEST_PREFIX))
102        {
103            match serde_json::from_str::<PortfolioRequest>(&fact.content) {
104                Ok(req) => {
105                    if selection_exists(ctx, req_id(&fact.id)) {
106                        continue;
107                    }
108                    let selection = solve(&req);
109                    proposals.push(
110                        ProposedFact::new(
111                            ContextKey::Strategies,
112                            format!("{}{}", SELECTION_PREFIX, selection.request_id),
113                            serde_json::to_string(&selection).unwrap_or_default(),
114                            self.name(),
115                        )
116                        .with_confidence(selection.utilization.min(1.0)),
117                    );
118                }
119                Err(e) => {
120                    if error_exists(ctx, &fact.id) {
121                        continue;
122                    }
123                    let diag = serde_json::json!({
124                        "request_fact_id": fact.id,
125                        "message": "malformed portfolio request",
126                        "error": e.to_string(),
127                    });
128                    proposals.push(
129                        ProposedFact::new(
130                            ContextKey::Diagnostic,
131                            format!("{}{}", ERROR_PREFIX, fact.id),
132                            diag.to_string(),
133                            self.name(),
134                        )
135                        .with_confidence(1.0),
136                    );
137                }
138            }
139        }
140
141        if proposals.is_empty() {
142            AgentEffect::empty()
143        } else {
144            AgentEffect::with_proposals(proposals)
145        }
146    }
147}
148
149// ── Core logic ────────────────────────────────────────────────────────────────
150
151fn solve(req: &PortfolioRequest) -> PortfolioSelection {
152    if req.items.is_empty() {
153        return PortfolioSelection {
154            request_id: req.id.clone(),
155            selected: vec![],
156            total_value: 0,
157            total_weight: 0,
158            utilization: 0.0,
159        };
160    }
161
162    let weights: Vec<i64> = req.items.iter().map(|i| i.weight).collect();
163    let values: Vec<i64> = req.items.iter().map(|i| i.value).collect();
164
165    let Ok(problem) = KnapsackProblem::new(weights, values, req.budget) else {
166        return PortfolioSelection {
167            request_id: req.id.clone(),
168            selected: vec![],
169            total_value: 0,
170            total_weight: 0,
171            utilization: 0.0,
172        };
173    };
174
175    match knapsack::solve(&problem) {
176        Ok(sol) => {
177            let selected = sol
178                .selected
179                .iter()
180                .filter_map(|&idx| req.items.get(idx).map(|i| i.label.clone()))
181                .collect();
182            let utilization = if req.budget > 0 {
183                sol.total_weight as f64 / req.budget as f64
184            } else {
185                0.0
186            };
187            PortfolioSelection {
188                request_id: req.id.clone(),
189                selected,
190                total_value: sol.total_value,
191                total_weight: sol.total_weight,
192                utilization,
193            }
194        }
195        Err(_) => PortfolioSelection {
196            request_id: req.id.clone(),
197            selected: vec![],
198            total_value: 0,
199            total_weight: 0,
200            utilization: 0.0,
201        },
202    }
203}
204
205// ── Helpers ───────────────────────────────────────────────────────────────────
206
207fn req_id(fact_id: &str) -> &str {
208    fact_id.trim_start_matches(REQUEST_PREFIX)
209}
210
211fn selection_exists(ctx: &dyn Context, request_id: &str) -> bool {
212    let id = format!("{}{}", SELECTION_PREFIX, request_id);
213    ctx.get(ContextKey::Strategies).iter().any(|f| f.id == id)
214}
215
216fn error_exists(ctx: &dyn Context, fact_id: &str) -> bool {
217    let id = format!("{}{}", ERROR_PREFIX, fact_id);
218    ctx.get(ContextKey::Diagnostic).iter().any(|f| f.id == id)
219}
220
221// ── Tests ─────────────────────────────────────────────────────────────────────
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use converge_core::{ContextState, Engine};
227
228    fn req_json(id: &str, items: Vec<(&str, i64, i64)>, budget: i64) -> String {
229        serde_json::to_string(&PortfolioRequest {
230            id: id.to_string(),
231            items: items
232                .into_iter()
233                .map(|(label, weight, value)| PortfolioItem {
234                    label: label.to_string(),
235                    weight,
236                    value,
237                })
238                .collect(),
239            budget,
240        })
241        .unwrap()
242    }
243
244    #[tokio::test]
245    async fn five_item_clrs_variant() {
246        // Weights [2,3,4,5,9], values [3,4,5,8,10], capacity 20 → optimal 26
247        let mut engine = Engine::new();
248        engine.register_suggestor(PortfolioSuggestor);
249
250        let mut ctx = ContextState::new();
251        ctx.add_input(
252            ContextKey::Seeds,
253            "portfolio-request:r1",
254            req_json(
255                "r1",
256                vec![
257                    ("alpha", 2, 3),
258                    ("beta", 3, 4),
259                    ("gamma", 4, 5),
260                    ("delta", 5, 8),
261                    ("epsilon", 9, 10),
262                ],
263                20,
264            ),
265        )
266        .unwrap();
267
268        let result = engine.run(ctx).await.unwrap();
269        let facts = result.context.get(ContextKey::Strategies);
270        assert_eq!(facts.len(), 1);
271        let sel: PortfolioSelection = serde_json::from_str(&facts[0].content).unwrap();
272        assert_eq!(sel.total_value, 26, "optimal portfolio value = 26");
273        assert!(sel.total_weight <= 20);
274    }
275
276    #[tokio::test]
277    async fn result_is_idempotent() {
278        let mut engine = Engine::new();
279        engine.register_suggestor(PortfolioSuggestor);
280
281        let mut ctx = ContextState::new();
282        ctx.add_input(
283            ContextKey::Seeds,
284            "portfolio-request:r1",
285            req_json("r1", vec![("a", 2, 5), ("b", 3, 6), ("c", 4, 4)], 5),
286        )
287        .unwrap();
288
289        let first = engine.run(ctx).await.unwrap();
290        let mut engine2 = Engine::new();
291        engine2.register_suggestor(PortfolioSuggestor);
292        let second = engine2.run(first.context.clone()).await.unwrap();
293        assert_eq!(
294            second.context.get(ContextKey::Strategies).len(),
295            first.context.get(ContextKey::Strategies).len(),
296        );
297    }
298
299    #[tokio::test]
300    async fn malformed_request_emits_diagnostic() {
301        let mut engine = Engine::new();
302        engine.register_suggestor(PortfolioSuggestor);
303
304        let mut ctx = ContextState::new();
305        ctx.add_input(ContextKey::Seeds, "portfolio-request:bad", "not-json")
306            .unwrap();
307
308        let result = engine.run(ctx).await.unwrap();
309        assert_eq!(result.context.get(ContextKey::Diagnostic).len(), 1);
310        assert!(!result.context.has(ContextKey::Strategies));
311    }
312}