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