converge_optimization/suggestors/
portfolio.rs1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct PortfolioRequest {
33 pub id: String,
35 pub items: Vec<PortfolioItem>,
37 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct PortfolioItem {
50 pub label: String,
51 pub weight: i64,
53 pub value: i64,
55}
56
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct PortfolioSelection {
63 pub request_id: String,
64 pub selected: Vec<String>,
66 pub total_value: i64,
67 pub total_weight: i64,
68 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
77const REQUEST_PREFIX: &str = "portfolio-request:";
80const SELECTION_PREFIX: &str = "portfolio-selection:";
81const ERROR_PREFIX: &str = "portfolio-request-error:";
82
83pub 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
172fn 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
228fn 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#[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 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}