Skip to main content

converge_pack/
pack_suggestor.rs

1//! Suggestor adapter -- bridges Pack trait to Converge Suggestor.
2//!
3//! Every domain pack becomes a first-class Suggestor, participatable
4//! in the convergence loop via `PackSuggestor`.
5
6use 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/// Typed input payload for generic [`PackSuggestor`] execution.
18///
19/// Domain-specific Suggestors should prefer domain-specific payloads. This
20/// payload exists for generic `Pack` implementations whose schema is owned by
21/// the pack and validated through `Pack::validate_inputs`.
22#[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    /// Creates a typed generic pack input payload.
31    #[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    /// Returns the target pack name.
40    #[must_use]
41    pub fn pack(&self) -> &str {
42        &self.pack
43    }
44
45    /// Returns the pack-owned input value.
46    #[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/// Typed output payload emitted by generic [`PackSuggestor`] execution.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct PackPlanPayload {
72    /// Unique plan identifier.
73    pub plan_id: String,
74    /// Pack that generated this plan.
75    pub pack: String,
76    /// Human-readable summary.
77    pub summary: String,
78    /// Pack-owned typed plan payload.
79    pub plan: serde_json::Value,
80    /// Calibrated confidence score in `[0.0, 1.0]`.
81    pub confidence: f64,
82    /// Link to kernel trace for replay/audit.
83    pub trace_link: KernelTraceLink,
84}
85
86impl PackPlanPayload {
87    /// Creates a payload from a pack [`crate::gate::ProposedPlan`].
88    #[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    /// Deserialize the pack-owned plan payload to a typed struct.
101    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
130/// Wraps any Pack as a Converge Suggestor.
131///
132/// The adapter reads problem specifications from context (`input_key`),
133/// runs the solver, and proposes the solution as a fact to `output_key`.
134pub struct PackSuggestor<P: Pack> {
135    pack: P,
136    input_key: ContextKey,
137    output_key: ContextKey,
138}
139
140impl<P: Pack> PackSuggestor<P> {
141    /// Create a new `PackSuggestor` wrapping the given pack.
142    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    /// The wrapped pack's name doubles as its canonical provenance string.
158    /// External packs that need a separate provenance (e.g., a crate-level
159    /// `ProvenanceSource`) should wrap themselves in an outer Suggestor and
160    /// override `provenance()` there instead of relying on this default.
161    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    /// Pack double whose behaviour is configured per test.
227    struct ConfigurablePack {
228        name: &'static str,
229        outcome: PackOutcome,
230    }
231
232    #[derive(Clone)]
233    enum PackOutcome {
234        /// Returns a successful solve with this confidence.
235        Solved(f64),
236        /// Returns a Pack::solve error.
237        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    /// Minimal Context implementation for unit-testing the adapter.
284    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            // Mark the output key as present by inserting a placeholder fact.
338            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}