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