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.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
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).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#[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 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}