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