converge-pack 3.8.1

The strict authoring contract for Converge packs, suggestors, and invariants
Documentation
//! Suggestor adapter -- bridges Pack trait to Converge Suggestor.
//!
//! Every domain pack becomes a first-class Suggestor, participatable
//! in the convergence loop via `PackSuggestor`.

use async_trait::async_trait;

use crate::Suggestor;
use crate::context::{Context, ContextKey};
use crate::effect::AgentEffect;
use crate::fact::ProposedFact;
use crate::gate::{ObjectiveSpec, ProblemSpec};
use crate::pack::Pack;

/// Wraps any Pack as a Converge Suggestor.
///
/// The adapter reads problem specifications from context (`input_key`),
/// runs the solver, and proposes the solution as a fact to `output_key`.
pub struct PackSuggestor<P: Pack> {
    pack: P,
    input_key: ContextKey,
    output_key: ContextKey,
}

impl<P: Pack> PackSuggestor<P> {
    /// Create a new `PackSuggestor` wrapping the given pack.
    pub fn new(pack: P, input_key: ContextKey, output_key: ContextKey) -> Self {
        Self {
            pack,
            input_key,
            output_key,
        }
    }
}

#[async_trait]
impl<P: Pack> Suggestor for PackSuggestor<P> {
    fn name(&self) -> &str {
        self.pack.name()
    }

    fn dependencies(&self) -> &[ContextKey] {
        std::slice::from_ref(&self.input_key)
    }

    fn accepts(&self, ctx: &dyn Context) -> bool {
        ctx.has(self.input_key) && !ctx.has(self.output_key)
    }

    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
        let facts = ctx.get(self.input_key);
        let Some(seed_fact) = facts.first() else {
            return AgentEffect::empty();
        };

        let inputs: serde_json::Value = match serde_json::from_str(seed_fact.content()) {
            Ok(v) => v,
            Err(_) => return AgentEffect::empty(),
        };

        let spec = match ProblemSpec::builder(format!("{}-converge", self.pack.name()), "converge")
            .objective(ObjectiveSpec::maximize("default"))
            .inputs_raw(inputs)
            .build()
        {
            Ok(s) => s,
            Err(_) => return AgentEffect::empty(),
        };

        match self.pack.solve(&spec) {
            Ok(result) => {
                let content = serde_json::to_string(&result.plan).unwrap_or_default();
                let confidence = result.plan.confidence();
                let proposal = ProposedFact::new(
                    self.output_key,
                    format!("{}-solution", self.pack.name()),
                    content,
                    format!("solver:{}", self.pack.name()),
                )
                .with_confidence(confidence);
                AgentEffect::with_proposal(proposal)
            }
            Err(_) => AgentEffect::empty(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::fact::{
        ContextFact, FactActor, FactActorKind, FactLocalTrace, FactPromotionRecord, FactTraceLink,
        FactValidationSummary,
    };
    use crate::gate::{
        GateError, GateResult, KernelTraceLink, PromotionGate, ProposedPlan, ReplayEnvelope,
        SolverReport,
    };
    use crate::pack::{InvariantDef, InvariantResult, PackSolveResult};
    use crate::types::{ContentHash, Timestamp};
    use std::collections::HashMap;

    /// Pack double whose behaviour is configured per test.
    struct ConfigurablePack {
        name: &'static str,
        outcome: PackOutcome,
    }

    #[derive(Clone)]
    enum PackOutcome {
        /// Returns a successful solve with this confidence.
        Solved(f64),
        /// Returns a Pack::solve error.
        Errored,
    }

    impl Pack for ConfigurablePack {
        fn name(&self) -> &'static str {
            self.name
        }
        fn version(&self) -> &'static str {
            "0.1.0"
        }
        fn validate_inputs(&self, _: &serde_json::Value) -> GateResult<()> {
            Ok(())
        }
        fn invariants(&self) -> &[InvariantDef] {
            &[]
        }
        fn solve(&self, spec: &ProblemSpec) -> GateResult<PackSolveResult> {
            match self.outcome {
                PackOutcome::Errored => Err(GateError::invalid_input("intentional test failure")),
                PackOutcome::Solved(conf) => {
                    let plan = ProposedPlan::from_payload(
                        format!("plan-{}", spec.problem_id),
                        self.name,
                        "solved",
                        &serde_json::json!({"value": 42}),
                        conf,
                        KernelTraceLink::audit_only(format!("trace-{}", spec.problem_id)),
                    )
                    .expect("payload");
                    let report = SolverReport::optimal(
                        format!("{}-v1", self.name),
                        0.0,
                        ReplayEnvelope::minimal(spec.seed()),
                    );
                    Ok(PackSolveResult::new(plan, report))
                }
            }
        }
        fn check_invariants(&self, _: &ProposedPlan) -> GateResult<Vec<InvariantResult>> {
            Ok(vec![])
        }
        fn evaluate_gate(&self, _: &ProposedPlan, _: &[InvariantResult]) -> PromotionGate {
            PromotionGate::auto_promote("ok")
        }
    }

    /// Minimal Context implementation for unit-testing the adapter.
    struct MockContext {
        facts: HashMap<ContextKey, Vec<ContextFact>>,
    }

    impl MockContext {
        fn empty() -> Self {
            Self {
                facts: HashMap::new(),
            }
        }
        fn with_seed_content(content: &str) -> Self {
            let mut ctx = Self::empty();
            ctx.facts.insert(
                ContextKey::Seeds,
                vec![ContextFact::new_projection(
                    ContextKey::Seeds,
                    "seed-1",
                    content,
                    FactPromotionRecord::new_projection(
                        "projection-test",
                        ContentHash::zero(),
                        FactActor::new_projection("test", FactActorKind::System),
                        FactValidationSummary::default(),
                        Vec::new(),
                        FactTraceLink::Local(FactLocalTrace::new_projection(
                            "trace", "span", None, true,
                        )),
                        Timestamp::epoch(),
                    ),
                    Timestamp::epoch(),
                )],
            );
            ctx
        }
        fn with_existing_output(self) -> Self {
            // Mark the output key as present by inserting a placeholder fact.
            let mut me = self;
            me.facts.insert(
                ContextKey::Strategies,
                vec![ContextFact::new_projection(
                    ContextKey::Strategies,
                    "strat-1",
                    "{}",
                    FactPromotionRecord::new_projection(
                        "projection-test",
                        ContentHash::zero(),
                        FactActor::new_projection("test", FactActorKind::System),
                        FactValidationSummary::default(),
                        Vec::new(),
                        FactTraceLink::Local(FactLocalTrace::new_projection(
                            "trace", "span", None, true,
                        )),
                        Timestamp::epoch(),
                    ),
                    Timestamp::epoch(),
                )],
            );
            me
        }
    }

    impl Context for MockContext {
        fn has(&self, key: ContextKey) -> bool {
            self.facts.get(&key).is_some_and(|v| !v.is_empty())
        }
        fn get(&self, key: ContextKey) -> &[ContextFact] {
            self.facts.get(&key).map_or(&[], Vec::as_slice)
        }
    }

    fn solver(outcome: PackOutcome) -> PackSuggestor<ConfigurablePack> {
        PackSuggestor::new(
            ConfigurablePack {
                name: "test-pack",
                outcome,
            },
            ContextKey::Seeds,
            ContextKey::Strategies,
        )
    }

    #[test]
    fn pack_suggestor_constructed() {
        let s = solver(PackOutcome::Solved(0.9));
        assert_eq!(s.name(), "test-pack");
        assert_eq!(s.dependencies(), &[ContextKey::Seeds]);
    }

    #[test]
    fn accepts_when_input_present_and_output_missing() {
        let s = solver(PackOutcome::Solved(0.9));
        let ctx = MockContext::with_seed_content("{\"x\":1}");
        assert!(s.accepts(&ctx));
    }

    #[test]
    fn rejects_when_input_missing() {
        let s = solver(PackOutcome::Solved(0.9));
        let ctx = MockContext::empty();
        assert!(!s.accepts(&ctx));
    }

    #[test]
    fn rejects_when_output_already_present() {
        let s = solver(PackOutcome::Solved(0.9));
        let ctx = MockContext::with_seed_content("{\"x\":1}").with_existing_output();
        assert!(!s.accepts(&ctx));
    }

    #[tokio::test]
    async fn execute_with_empty_context_returns_empty_effect() {
        let s = solver(PackOutcome::Solved(0.9));
        let ctx = MockContext::empty();
        let effect = s.execute(&ctx).await;
        assert_eq!(effect.proposals().len(), 0);
    }

    #[tokio::test]
    async fn execute_with_invalid_json_seed_returns_empty_effect() {
        let s = solver(PackOutcome::Solved(0.9));
        let ctx = MockContext::with_seed_content("not valid json {{{{");
        let effect = s.execute(&ctx).await;
        assert_eq!(effect.proposals().len(), 0);
    }

    #[tokio::test]
    async fn execute_with_pack_solve_error_returns_empty_effect() {
        let s = solver(PackOutcome::Errored);
        let ctx = MockContext::with_seed_content("{\"x\":1}");
        let effect = s.execute(&ctx).await;
        assert_eq!(effect.proposals().len(), 0);
    }

    #[tokio::test]
    async fn execute_with_successful_solve_emits_proposal_with_carried_confidence() {
        let s = solver(PackOutcome::Solved(0.42));
        let ctx = MockContext::with_seed_content("{\"x\":1}");
        let effect = s.execute(&ctx).await;
        assert_eq!(effect.proposals().len(), 1);
        let proposal = &effect.proposals()[0];
        assert_eq!(proposal.key(), ContextKey::Strategies);
        assert!(
            (proposal.confidence() - 0.42).abs() < 1e-6,
            "confidence must propagate from plan, got {}",
            proposal.confidence()
        );
    }
}