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;
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
15/// Wraps any Pack as a Converge Suggestor.
16///
17/// The adapter reads problem specifications from context (`input_key`),
18/// runs the solver, and proposes the solution as a fact to `output_key`.
19pub struct PackSuggestor<P: Pack> {
20    pack: P,
21    input_key: ContextKey,
22    output_key: ContextKey,
23}
24
25impl<P: Pack> PackSuggestor<P> {
26    /// Create a new `PackSuggestor` wrapping the given pack.
27    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    /// Pack double whose behaviour is configured per test.
104    struct ConfigurablePack {
105        name: &'static str,
106        outcome: PackOutcome,
107    }
108
109    #[derive(Clone)]
110    enum PackOutcome {
111        /// Returns a successful solve with this confidence.
112        Solved(f64),
113        /// Returns a Pack::solve error.
114        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    /// Minimal Context implementation for unit-testing the adapter.
161    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            // Mark the output key as present by inserting a placeholder fact.
197            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}