1use async_trait::async_trait;
7
8use crate::Suggestor;
9use crate::context::{Context, ContextKey};
10use crate::effect::AgentEffect;
11use crate::fact::ProposedFact;
12use crate::gate::{ObjectiveSpec, ProblemSpec};
13use crate::pack::Pack;
14
15pub struct PackSuggestor<P: Pack> {
20 pack: P,
21 input_key: ContextKey,
22 output_key: ContextKey,
23}
24
25impl<P: Pack> PackSuggestor<P> {
26 pub fn new(pack: P, input_key: ContextKey, output_key: ContextKey) -> Self {
28 Self {
29 pack,
30 input_key,
31 output_key,
32 }
33 }
34}
35
36#[async_trait]
37impl<P: Pack> Suggestor for PackSuggestor<P> {
38 fn name(&self) -> &str {
39 self.pack.name()
40 }
41
42 fn dependencies(&self) -> &[ContextKey] {
43 std::slice::from_ref(&self.input_key)
44 }
45
46 fn accepts(&self, ctx: &dyn Context) -> bool {
47 ctx.has(self.input_key) && !ctx.has(self.output_key)
48 }
49
50 async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
51 let facts = ctx.get(self.input_key);
52 let Some(seed_fact) = facts.first() else {
53 return AgentEffect::empty();
54 };
55
56 let inputs: serde_json::Value = match serde_json::from_str(seed_fact.content()) {
57 Ok(v) => v,
58 Err(_) => return AgentEffect::empty(),
59 };
60
61 let spec = match ProblemSpec::builder(format!("{}-converge", self.pack.name()), "converge")
62 .objective(ObjectiveSpec::maximize("default"))
63 .inputs_raw(inputs)
64 .build()
65 {
66 Ok(s) => s,
67 Err(_) => return AgentEffect::empty(),
68 };
69
70 match self.pack.solve(&spec) {
71 Ok(result) => {
72 let content = serde_json::to_string(&result.plan).unwrap_or_default();
73 let confidence = result.plan.confidence();
74 let proposal = ProposedFact::new(
75 self.output_key,
76 format!("{}-solution", self.pack.name()),
77 content,
78 format!("solver:{}", self.pack.name()),
79 )
80 .with_confidence(confidence);
81 AgentEffect::with_proposal(proposal)
82 }
83 Err(_) => AgentEffect::empty(),
84 }
85 }
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91 use crate::fact::{
92 ContextFact, FactActor, FactActorKind, FactLocalTrace, FactPromotionRecord, FactTraceLink,
93 FactValidationSummary,
94 };
95 use crate::gate::{
96 GateError, GateResult, KernelTraceLink, PromotionGate, ProposedPlan, ReplayEnvelope,
97 SolverReport,
98 };
99 use crate::pack::{InvariantDef, InvariantResult, PackSolveResult};
100 use crate::types::{ContentHash, Timestamp};
101 use std::collections::HashMap;
102
103 struct ConfigurablePack {
105 name: &'static str,
106 outcome: PackOutcome,
107 }
108
109 #[derive(Clone)]
110 enum PackOutcome {
111 Solved(f64),
113 Errored,
115 }
116
117 impl Pack for ConfigurablePack {
118 fn name(&self) -> &'static str {
119 self.name
120 }
121 fn version(&self) -> &'static str {
122 "0.1.0"
123 }
124 fn validate_inputs(&self, _: &serde_json::Value) -> GateResult<()> {
125 Ok(())
126 }
127 fn invariants(&self) -> &[InvariantDef] {
128 &[]
129 }
130 fn solve(&self, spec: &ProblemSpec) -> GateResult<PackSolveResult> {
131 match self.outcome {
132 PackOutcome::Errored => Err(GateError::invalid_input("intentional test failure")),
133 PackOutcome::Solved(conf) => {
134 let plan = ProposedPlan::from_payload(
135 format!("plan-{}", spec.problem_id),
136 self.name,
137 "solved",
138 &serde_json::json!({"value": 42}),
139 conf,
140 KernelTraceLink::audit_only(format!("trace-{}", spec.problem_id)),
141 )
142 .expect("payload");
143 let report = SolverReport::optimal(
144 format!("{}-v1", self.name),
145 0.0,
146 ReplayEnvelope::minimal(spec.seed()),
147 );
148 Ok(PackSolveResult::new(plan, report))
149 }
150 }
151 }
152 fn check_invariants(&self, _: &ProposedPlan) -> GateResult<Vec<InvariantResult>> {
153 Ok(vec![])
154 }
155 fn evaluate_gate(&self, _: &ProposedPlan, _: &[InvariantResult]) -> PromotionGate {
156 PromotionGate::auto_promote("ok")
157 }
158 }
159
160 struct MockContext {
162 facts: HashMap<ContextKey, Vec<ContextFact>>,
163 }
164
165 impl MockContext {
166 fn empty() -> Self {
167 Self {
168 facts: HashMap::new(),
169 }
170 }
171 fn with_seed_content(content: &str) -> Self {
172 let mut ctx = Self::empty();
173 ctx.facts.insert(
174 ContextKey::Seeds,
175 vec![ContextFact::new_projection(
176 ContextKey::Seeds,
177 "seed-1",
178 content,
179 FactPromotionRecord::new_projection(
180 "projection-test",
181 ContentHash::zero(),
182 FactActor::new_projection("test", FactActorKind::System),
183 FactValidationSummary::default(),
184 Vec::new(),
185 FactTraceLink::Local(FactLocalTrace::new_projection(
186 "trace", "span", None, true,
187 )),
188 Timestamp::epoch(),
189 ),
190 Timestamp::epoch(),
191 )],
192 );
193 ctx
194 }
195 fn with_existing_output(self) -> Self {
196 let mut me = self;
198 me.facts.insert(
199 ContextKey::Strategies,
200 vec![ContextFact::new_projection(
201 ContextKey::Strategies,
202 "strat-1",
203 "{}",
204 FactPromotionRecord::new_projection(
205 "projection-test",
206 ContentHash::zero(),
207 FactActor::new_projection("test", FactActorKind::System),
208 FactValidationSummary::default(),
209 Vec::new(),
210 FactTraceLink::Local(FactLocalTrace::new_projection(
211 "trace", "span", None, true,
212 )),
213 Timestamp::epoch(),
214 ),
215 Timestamp::epoch(),
216 )],
217 );
218 me
219 }
220 }
221
222 impl Context for MockContext {
223 fn has(&self, key: ContextKey) -> bool {
224 self.facts.get(&key).is_some_and(|v| !v.is_empty())
225 }
226 fn get(&self, key: ContextKey) -> &[ContextFact] {
227 self.facts.get(&key).map_or(&[], Vec::as_slice)
228 }
229 }
230
231 fn solver(outcome: PackOutcome) -> PackSuggestor<ConfigurablePack> {
232 PackSuggestor::new(
233 ConfigurablePack {
234 name: "test-pack",
235 outcome,
236 },
237 ContextKey::Seeds,
238 ContextKey::Strategies,
239 )
240 }
241
242 #[test]
243 fn pack_suggestor_constructed() {
244 let s = solver(PackOutcome::Solved(0.9));
245 assert_eq!(s.name(), "test-pack");
246 assert_eq!(s.dependencies(), &[ContextKey::Seeds]);
247 }
248
249 #[test]
250 fn accepts_when_input_present_and_output_missing() {
251 let s = solver(PackOutcome::Solved(0.9));
252 let ctx = MockContext::with_seed_content("{\"x\":1}");
253 assert!(s.accepts(&ctx));
254 }
255
256 #[test]
257 fn rejects_when_input_missing() {
258 let s = solver(PackOutcome::Solved(0.9));
259 let ctx = MockContext::empty();
260 assert!(!s.accepts(&ctx));
261 }
262
263 #[test]
264 fn rejects_when_output_already_present() {
265 let s = solver(PackOutcome::Solved(0.9));
266 let ctx = MockContext::with_seed_content("{\"x\":1}").with_existing_output();
267 assert!(!s.accepts(&ctx));
268 }
269
270 #[tokio::test]
271 async fn execute_with_empty_context_returns_empty_effect() {
272 let s = solver(PackOutcome::Solved(0.9));
273 let ctx = MockContext::empty();
274 let effect = s.execute(&ctx).await;
275 assert_eq!(effect.proposals().len(), 0);
276 }
277
278 #[tokio::test]
279 async fn execute_with_invalid_json_seed_returns_empty_effect() {
280 let s = solver(PackOutcome::Solved(0.9));
281 let ctx = MockContext::with_seed_content("not valid json {{{{");
282 let effect = s.execute(&ctx).await;
283 assert_eq!(effect.proposals().len(), 0);
284 }
285
286 #[tokio::test]
287 async fn execute_with_pack_solve_error_returns_empty_effect() {
288 let s = solver(PackOutcome::Errored);
289 let ctx = MockContext::with_seed_content("{\"x\":1}");
290 let effect = s.execute(&ctx).await;
291 assert_eq!(effect.proposals().len(), 0);
292 }
293
294 #[tokio::test]
295 async fn execute_with_successful_solve_emits_proposal_with_carried_confidence() {
296 let s = solver(PackOutcome::Solved(0.42));
297 let ctx = MockContext::with_seed_content("{\"x\":1}");
298 let effect = s.execute(&ctx).await;
299 assert_eq!(effect.proposals().len(), 1);
300 let proposal = &effect.proposals()[0];
301 assert_eq!(proposal.key(), ContextKey::Strategies);
302 assert!(
303 (proposal.confidence() - 0.42).abs() < 1e-6,
304 "confidence must propagate from plan, got {}",
305 proposal.confidence()
306 );
307 }
308}