1use async_trait::async_trait;
7use serde::{Deserialize, Serialize, de::DeserializeOwned};
8
9use crate::Suggestor;
10use crate::context::{Context, ContextKey};
11use crate::effect::AgentEffect;
12use crate::fact::Provenance;
13use crate::fact::{FactFamilyId, FactPayload, PayloadError, PayloadVersion, ProposedFact};
14use crate::gate::{GateError, GateResult, KernelTraceLink, ObjectiveSpec, ProblemSpec};
15use crate::pack::Pack;
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct PackInputPayload {
25 pack: String,
26 inputs: serde_json::Value,
27}
28
29impl PackInputPayload {
30 #[must_use]
32 pub fn new(pack: impl Into<String>, inputs: serde_json::Value) -> Self {
33 Self {
34 pack: pack.into(),
35 inputs,
36 }
37 }
38
39 #[must_use]
41 pub fn pack(&self) -> &str {
42 &self.pack
43 }
44
45 #[must_use]
47 pub fn inputs(&self) -> &serde_json::Value {
48 &self.inputs
49 }
50}
51
52impl FactPayload for PackInputPayload {
53 const FAMILY: &'static str = "converge.pack.input";
54 const VERSION: u16 = 1;
55
56 fn validate(&self) -> Result<(), PayloadError> {
57 if self.pack.trim().is_empty() {
58 return Err(PayloadError::Invalid {
59 family: FactFamilyId::from(Self::FAMILY),
60 version: PayloadVersion::new(Self::VERSION),
61 reason: "pack must not be empty".to_string(),
62 });
63 }
64 Ok(())
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct PackPlanPayload {
72 pub plan_id: String,
74 pub pack: String,
76 pub summary: String,
78 pub plan: serde_json::Value,
80 pub confidence: f64,
82 pub trace_link: KernelTraceLink,
84}
85
86impl PackPlanPayload {
87 #[must_use]
89 pub fn from_plan(plan: &crate::gate::ProposedPlan) -> Self {
90 Self {
91 plan_id: plan.plan_id.clone(),
92 pack: plan.pack.clone(),
93 summary: plan.summary.clone(),
94 plan: plan.plan.clone(),
95 confidence: plan.confidence(),
96 trace_link: plan.trace_link.clone(),
97 }
98 }
99
100 pub fn plan_as<T: DeserializeOwned>(&self) -> GateResult<T> {
102 serde_json::from_value(self.plan.clone())
103 .map_err(|err| GateError::invalid_input(format!("failed to parse plan: {err}")))
104 }
105}
106
107impl FactPayload for PackPlanPayload {
108 const FAMILY: &'static str = "converge.pack.plan";
109 const VERSION: u16 = 1;
110
111 fn validate(&self) -> Result<(), PayloadError> {
112 if self.pack.trim().is_empty() {
113 return Err(PayloadError::Invalid {
114 family: FactFamilyId::from(Self::FAMILY),
115 version: PayloadVersion::new(Self::VERSION),
116 reason: "pack must not be empty".to_string(),
117 });
118 }
119 if !self.confidence.is_finite() || !(0.0..=1.0).contains(&self.confidence) {
120 return Err(PayloadError::Invalid {
121 family: FactFamilyId::from(Self::FAMILY),
122 version: PayloadVersion::new(Self::VERSION),
123 reason: "confidence must be finite and in 0.0..=1.0".to_string(),
124 });
125 }
126 Ok(())
127 }
128}
129
130pub struct PackSuggestor<P: Pack> {
135 pack: P,
136 input_key: ContextKey,
137 output_key: ContextKey,
138}
139
140impl<P: Pack> PackSuggestor<P> {
141 pub fn new(pack: P, input_key: ContextKey, output_key: ContextKey) -> Self {
143 Self {
144 pack,
145 input_key,
146 output_key,
147 }
148 }
149}
150
151#[async_trait]
152impl<P: Pack> Suggestor for PackSuggestor<P> {
153 fn name(&self) -> &str {
154 self.pack.name()
155 }
156
157 fn provenance(&self) -> Provenance {
162 Provenance::new(self.pack.name())
163 }
164
165 fn dependencies(&self) -> &[ContextKey] {
166 std::slice::from_ref(&self.input_key)
167 }
168
169 fn accepts(&self, ctx: &dyn Context) -> bool {
170 ctx.has(self.input_key) && !ctx.has(self.output_key)
171 }
172
173 async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
174 let facts = ctx.get(self.input_key);
175 let Some(seed_fact) = facts.first() else {
176 return AgentEffect::empty();
177 };
178
179 let inputs = match seed_fact.payload::<PackInputPayload>() {
180 Some(payload) if payload.pack() == self.pack.name() => payload.inputs().clone(),
181 None => return AgentEffect::empty(),
182 Some(_) => return AgentEffect::empty(),
183 };
184
185 let spec = match ProblemSpec::builder(format!("{}-converge", self.pack.name()), "converge")
186 .objective(ObjectiveSpec::maximize("default"))
187 .inputs_raw(inputs)
188 .build()
189 {
190 Ok(s) => s,
191 Err(_) => return AgentEffect::empty(),
192 };
193
194 match self.pack.solve(&spec) {
195 Ok(result) => {
196 let confidence = result.plan.confidence();
197 let proposal = ProposedFact::new(
198 self.output_key,
199 format!("{}-solution", self.pack.name()),
200 PackPlanPayload::from_plan(&result.plan),
201 self.provenance(),
202 )
203 .with_confidence(confidence);
204 AgentEffect::with_proposal(proposal)
205 }
206 Err(_) => AgentEffect::empty(),
207 }
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use crate::fact::{
215 ContextFact, FactActor, FactActorKind, FactLocalTrace, FactPromotionRecord, FactTraceLink,
216 FactValidationSummary, TextPayload,
217 };
218 use crate::gate::{
219 GateError, GateResult, KernelTraceLink, PromotionGate, ProposedPlan, ReplayEnvelope,
220 SolverReport,
221 };
222 use crate::pack::{InvariantDef, InvariantResult, PackSolveResult};
223 use crate::types::{ContentHash, Timestamp};
224 use std::collections::HashMap;
225
226 struct ConfigurablePack {
228 name: &'static str,
229 outcome: PackOutcome,
230 }
231
232 #[derive(Clone)]
233 enum PackOutcome {
234 Solved(f64),
236 Errored,
238 }
239
240 impl Pack for ConfigurablePack {
241 fn name(&self) -> &'static str {
242 self.name
243 }
244 fn version(&self) -> &'static str {
245 "0.1.0"
246 }
247 fn validate_inputs(&self, _: &serde_json::Value) -> GateResult<()> {
248 Ok(())
249 }
250 fn invariants(&self) -> &[InvariantDef] {
251 &[]
252 }
253 fn solve(&self, spec: &ProblemSpec) -> GateResult<PackSolveResult> {
254 match self.outcome {
255 PackOutcome::Errored => Err(GateError::invalid_input("intentional test failure")),
256 PackOutcome::Solved(conf) => {
257 let plan = ProposedPlan::from_payload(
258 format!("plan-{}", spec.problem_id),
259 self.name,
260 "solved",
261 &serde_json::json!({"value": 42}),
262 conf,
263 KernelTraceLink::audit_only(format!("trace-{}", spec.problem_id)),
264 )
265 .expect("payload");
266 let report = SolverReport::optimal(
267 format!("{}-v1", self.name),
268 0.0,
269 ReplayEnvelope::minimal(spec.seed()),
270 );
271 Ok(PackSolveResult::new(plan, report))
272 }
273 }
274 }
275 fn check_invariants(&self, _: &ProposedPlan) -> GateResult<Vec<InvariantResult>> {
276 Ok(vec![])
277 }
278 fn evaluate_gate(&self, _: &ProposedPlan, _: &[InvariantResult]) -> PromotionGate {
279 PromotionGate::auto_promote("ok")
280 }
281 }
282
283 struct MockContext {
285 facts: HashMap<ContextKey, Vec<ContextFact>>,
286 }
287
288 impl MockContext {
289 fn empty() -> Self {
290 Self {
291 facts: HashMap::new(),
292 }
293 }
294 fn with_pack_input(pack: &str, value: serde_json::Value) -> Self {
295 let mut ctx = Self::empty();
296 let record = FactPromotionRecord::new_projection(
297 "projection-test",
298 ContentHash::zero(),
299 FactActor::new_projection("test", FactActorKind::System),
300 FactValidationSummary::default(),
301 Vec::new(),
302 FactTraceLink::Local(FactLocalTrace::new_projection("trace", "span", None, true)),
303 Timestamp::epoch(),
304 );
305 let fact = ContextFact::new_projection(
306 ContextKey::Seeds,
307 "seed-1",
308 PackInputPayload::new(pack, value),
309 record,
310 Timestamp::epoch(),
311 );
312 ctx.facts.insert(ContextKey::Seeds, vec![fact]);
313 ctx
314 }
315 fn with_text_seed(content: &str) -> Self {
316 let mut ctx = Self::empty();
317 let record = FactPromotionRecord::new_projection(
318 "projection-test",
319 ContentHash::zero(),
320 FactActor::new_projection("test", FactActorKind::System),
321 FactValidationSummary::default(),
322 Vec::new(),
323 FactTraceLink::Local(FactLocalTrace::new_projection("trace", "span", None, true)),
324 Timestamp::epoch(),
325 );
326 let fact = ContextFact::new_projection(
327 ContextKey::Seeds,
328 "seed-1",
329 TextPayload::new(content),
330 record,
331 Timestamp::epoch(),
332 );
333 ctx.facts.insert(ContextKey::Seeds, vec![fact]);
334 ctx
335 }
336 fn with_existing_output(self) -> Self {
337 let mut me = self;
339 me.facts.insert(
340 ContextKey::Strategies,
341 vec![ContextFact::new_projection(
342 ContextKey::Strategies,
343 "strat-1",
344 TextPayload::new("{}"),
345 FactPromotionRecord::new_projection(
346 "projection-test",
347 ContentHash::zero(),
348 FactActor::new_projection("test", FactActorKind::System),
349 FactValidationSummary::default(),
350 Vec::new(),
351 FactTraceLink::Local(FactLocalTrace::new_projection(
352 "trace", "span", None, true,
353 )),
354 Timestamp::epoch(),
355 ),
356 Timestamp::epoch(),
357 )],
358 );
359 me
360 }
361 }
362
363 impl Context for MockContext {
364 fn has(&self, key: ContextKey) -> bool {
365 self.facts.get(&key).is_some_and(|v| !v.is_empty())
366 }
367 fn get(&self, key: ContextKey) -> &[ContextFact] {
368 self.facts.get(&key).map_or(&[], Vec::as_slice)
369 }
370 }
371
372 fn solver(outcome: PackOutcome) -> PackSuggestor<ConfigurablePack> {
373 PackSuggestor::new(
374 ConfigurablePack {
375 name: "test-pack",
376 outcome,
377 },
378 ContextKey::Seeds,
379 ContextKey::Strategies,
380 )
381 }
382
383 #[test]
384 fn pack_suggestor_constructed() {
385 let s = solver(PackOutcome::Solved(0.9));
386 assert_eq!(s.name(), "test-pack");
387 assert_eq!(s.dependencies(), &[ContextKey::Seeds]);
388 }
389
390 #[test]
391 fn accepts_when_input_present_and_output_missing() {
392 let s = solver(PackOutcome::Solved(0.9));
393 let ctx = MockContext::with_pack_input("test-pack", serde_json::json!({"x": 1}));
394 assert!(s.accepts(&ctx));
395 }
396
397 #[test]
398 fn rejects_when_input_missing() {
399 let s = solver(PackOutcome::Solved(0.9));
400 let ctx = MockContext::empty();
401 assert!(!s.accepts(&ctx));
402 }
403
404 #[test]
405 fn rejects_when_output_already_present() {
406 let s = solver(PackOutcome::Solved(0.9));
407 let ctx = MockContext::with_pack_input("test-pack", serde_json::json!({"x": 1}))
408 .with_existing_output();
409 assert!(!s.accepts(&ctx));
410 }
411
412 #[tokio::test]
413 async fn execute_with_empty_context_returns_empty_effect() {
414 let s = solver(PackOutcome::Solved(0.9));
415 let ctx = MockContext::empty();
416 let effect = s.execute(&ctx).await;
417 assert_eq!(effect.proposals().len(), 0);
418 }
419
420 #[tokio::test]
421 async fn execute_with_invalid_json_seed_returns_empty_effect() {
422 let s = solver(PackOutcome::Solved(0.9));
423 let ctx = MockContext::with_text_seed("not a typed pack input");
424 let effect = s.execute(&ctx).await;
425 assert_eq!(effect.proposals().len(), 0);
426 }
427
428 #[tokio::test]
429 async fn execute_with_wrong_pack_input_returns_empty_effect() {
430 let s = solver(PackOutcome::Solved(0.9));
431 let ctx = MockContext::with_pack_input("other-pack", serde_json::json!({"x": 1}));
432 let effect = s.execute(&ctx).await;
433 assert_eq!(effect.proposals().len(), 0);
434 }
435
436 #[tokio::test]
437 async fn execute_with_pack_solve_error_returns_empty_effect() {
438 let s = solver(PackOutcome::Errored);
439 let ctx = MockContext::with_pack_input("test-pack", serde_json::json!({"x": 1}));
440 let effect = s.execute(&ctx).await;
441 assert_eq!(effect.proposals().len(), 0);
442 }
443
444 #[tokio::test]
445 async fn execute_with_successful_solve_emits_proposal_with_carried_confidence() {
446 let s = solver(PackOutcome::Solved(0.42));
447 let ctx = MockContext::with_pack_input("test-pack", serde_json::json!({"x": 1}));
448 let effect = s.execute(&ctx).await;
449 assert_eq!(effect.proposals().len(), 1);
450 let proposal = &effect.proposals()[0];
451 assert_eq!(proposal.key(), ContextKey::Strategies);
452 let payload = proposal
453 .require_payload::<PackPlanPayload>()
454 .expect("PackSuggestor should emit typed pack plan payload");
455 assert_eq!(payload.pack, "test-pack");
456 assert_eq!(payload.plan["value"], 42);
457 assert!(
458 (proposal.confidence() - 0.42).abs() < 1e-6,
459 "confidence must propagate from plan, got {}",
460 proposal.confidence()
461 );
462 }
463}