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