converge_optimization/suggestors/
portfolio.rs1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct PortfolioRequest {
34 pub id: String,
36 pub items: Vec<PortfolioItem>,
38 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct PortfolioItem {
51 pub label: String,
52 pub weight: i64,
54 pub value: i64,
56}
57
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct PortfolioSelection {
64 pub request_id: String,
65 pub selected: Vec<String>,
67 pub total_value: i64,
68 pub total_weight: i64,
69 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
78const REQUEST_PREFIX: &str = "portfolio-request:";
81const SELECTION_PREFIX: &str = "portfolio-selection:";
82const ERROR_PREFIX: &str = "portfolio-request-error:";
83
84pub 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
173fn 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
229fn 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#[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 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}