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;
pub struct PackSuggestor<P: Pack> {
pack: P,
input_key: ContextKey,
output_key: ContextKey,
}
impl<P: Pack> PackSuggestor<P> {
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;
struct ConfigurablePack {
name: &'static str,
outcome: PackOutcome,
}
#[derive(Clone)]
enum PackOutcome {
Solved(f64),
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")
}
}
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 {
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()
);
}
}