converge_optimization/suggestors/
portfolio.rs1use async_trait::async_trait;
18use converge_pack::{AgentEffect, Context, ContextKey, ProposedFact, Suggestor};
19use serde::{Deserialize, Serialize};
20
21use crate::knapsack::{self, KnapsackProblem};
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PortfolioRequest {
29 pub id: String,
31 pub items: Vec<PortfolioItem>,
33 pub budget: i64,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct PortfolioItem {
40 pub label: String,
41 pub weight: i64,
43 pub value: i64,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct PortfolioSelection {
52 pub request_id: String,
53 pub selected: Vec<String>,
55 pub total_value: i64,
56 pub total_weight: i64,
57 pub utilization: f64,
59}
60
61const REQUEST_PREFIX: &str = "portfolio-request:";
64const SELECTION_PREFIX: &str = "portfolio-selection:";
65const ERROR_PREFIX: &str = "portfolio-request-error:";
66
67pub 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().as_str().starts_with(REQUEST_PREFIX)
88 && match serde_json::from_str::<PortfolioRequest>(f.content()) {
89 Ok(_) => !selection_exists(ctx, req_id(f.id().as_str())),
90 Err(_) => !error_exists(ctx, f.id().as_str()),
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().as_str().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().as_str())) {
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().as_str()) {
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
149fn 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
205fn 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)
214 .iter()
215 .any(|f| f.id().as_str() == id)
216}
217
218fn error_exists(ctx: &dyn Context, fact_id: &str) -> bool {
219 let id = format!("{}{}", ERROR_PREFIX, fact_id);
220 ctx.get(ContextKey::Diagnostic)
221 .iter()
222 .any(|f| f.id().as_str() == id)
223}
224
225#[cfg(test)]
228mod tests {
229 use super::*;
230 use converge_core::{ContextState, Engine};
231
232 fn req_json(id: &str, items: Vec<(&str, i64, i64)>, budget: i64) -> String {
233 serde_json::to_string(&PortfolioRequest {
234 id: id.to_string(),
235 items: items
236 .into_iter()
237 .map(|(label, weight, value)| PortfolioItem {
238 label: label.to_string(),
239 weight,
240 value,
241 })
242 .collect(),
243 budget,
244 })
245 .unwrap()
246 }
247
248 #[tokio::test]
249 async fn five_item_clrs_variant() {
250 let mut engine = Engine::new();
252 engine.register_suggestor(PortfolioSuggestor);
253
254 let mut ctx = ContextState::new();
255 ctx.add_input(
256 ContextKey::Seeds,
257 "portfolio-request:r1",
258 req_json(
259 "r1",
260 vec![
261 ("alpha", 2, 3),
262 ("beta", 3, 4),
263 ("gamma", 4, 5),
264 ("delta", 5, 8),
265 ("epsilon", 9, 10),
266 ],
267 20,
268 ),
269 )
270 .unwrap();
271
272 let result = engine.run(ctx).await.unwrap();
273 let facts = result.context.get(ContextKey::Strategies);
274 assert_eq!(facts.len(), 1);
275 let sel: PortfolioSelection = serde_json::from_str(facts[0].content()).unwrap();
276 assert_eq!(sel.total_value, 26, "optimal portfolio value = 26");
277 assert!(sel.total_weight <= 20);
278 }
279
280 #[tokio::test]
281 async fn result_is_idempotent() {
282 let mut engine = Engine::new();
283 engine.register_suggestor(PortfolioSuggestor);
284
285 let mut ctx = ContextState::new();
286 ctx.add_input(
287 ContextKey::Seeds,
288 "portfolio-request:r1",
289 req_json("r1", vec![("a", 2, 5), ("b", 3, 6), ("c", 4, 4)], 5),
290 )
291 .unwrap();
292
293 let first = engine.run(ctx).await.unwrap();
294 let mut engine2 = Engine::new();
295 engine2.register_suggestor(PortfolioSuggestor);
296 let second = engine2.run(first.context.clone()).await.unwrap();
297 assert_eq!(
298 second.context.get(ContextKey::Strategies).len(),
299 first.context.get(ContextKey::Strategies).len(),
300 );
301 }
302
303 #[tokio::test]
304 async fn malformed_request_emits_diagnostic() {
305 let mut engine = Engine::new();
306 engine.register_suggestor(PortfolioSuggestor);
307
308 let mut ctx = ContextState::new();
309 ctx.add_input(ContextKey::Seeds, "portfolio-request:bad", "not-json")
310 .unwrap();
311
312 let result = engine.run(ctx).await.unwrap();
313 assert_eq!(result.context.get(ContextKey::Diagnostic).len(), 1);
314 assert!(!result.context.has(ContextKey::Strategies));
315 }
316}