use converge_kernel::CriterionResult;
use uuid::Uuid;
use crate::formation::{Formation, FormationError, FormationResult};
use organism_learning::PriorCalibration;
use organism_learning::adapter::calibrate_priors;
use organism_learning::{ErrorDimension, LearningEpisode, PredictionError};
#[derive(Debug, Clone)]
pub struct FormationScore {
pub label: String,
pub score: f64,
pub converged: bool,
pub cycles: u32,
pub criteria_met: usize,
pub criteria_total: usize,
}
impl FormationScore {
fn from_result(result: &FormationResult) -> Self {
let cr = &result.converge_result;
let criteria_total = cr.criteria_outcomes.len();
let criteria_met = cr
.criteria_outcomes
.iter()
.filter(|o| matches!(o.result, CriterionResult::Met { .. }))
.count();
let convergence_score = if cr.converged { 1.0 } else { 0.0 };
let efficiency_score = 1.0 - (f64::from(cr.cycles) / 50.0_f64).min(1.0);
let criteria_score = if criteria_total == 0 {
0.5 } else {
f64::from(u32::try_from(criteria_met).unwrap_or(u32::MAX))
/ f64::from(u32::try_from(criteria_total).unwrap_or(u32::MAX))
};
let score = convergence_score * 0.6 + efficiency_score * 0.2 + criteria_score * 0.2;
Self {
label: result.label.clone(),
score,
converged: cr.converged,
cycles: cr.cycles,
criteria_met,
criteria_total,
}
}
}
pub struct FormationTournament {
formations: Vec<Formation>,
intent_id: Uuid,
plan_id: Uuid,
}
pub struct TournamentResult {
pub winner: FormationScore,
pub all_scores: Vec<FormationScore>,
pub priors: Vec<PriorCalibration>,
}
#[derive(Debug, thiserror::Error)]
pub enum TournamentError {
#[error("no formations provided")]
NoFormations,
#[error("all formations failed: {0}")]
AllFailed(String),
#[error("formation error: {0}")]
Formation(#[from] FormationError),
}
impl FormationTournament {
pub fn new(intent_id: Uuid, plan_id: Uuid, formations: Vec<Formation>) -> Self {
Self {
formations,
intent_id,
plan_id,
}
}
pub async fn run(self) -> Result<TournamentResult, TournamentError> {
if self.formations.is_empty() {
return Err(TournamentError::NoFormations);
}
let mut results: Vec<FormationResult> = Vec::new();
let mut errors: Vec<String> = Vec::new();
for formation in self.formations {
match formation.run().await {
Ok(r) => results.push(r),
Err(e) => errors.push(e.to_string()),
}
}
if results.is_empty() {
return Err(TournamentError::AllFailed(errors.join("; ")));
}
let mut scores: Vec<FormationScore> =
results.iter().map(FormationScore::from_result).collect();
scores.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let winner = scores[0].clone();
let priors = calibrate_priors(
&episode_from_scores(&scores, self.intent_id, self.plan_id),
&[],
);
Ok(TournamentResult {
winner,
all_scores: scores,
priors,
})
}
}
fn episode_from_scores(
scores: &[FormationScore],
intent_id: Uuid,
plan_id: Uuid,
) -> LearningEpisode {
let winner = &scores[0];
let converged_count = scores.iter().filter(|s| s.converged).count();
let convergence_rate = f64::from(u32::try_from(converged_count).unwrap_or(u32::MAX))
/ f64::from(u32::try_from(scores.len()).unwrap_or(u32::MAX));
let criteria_coverage = if winner.criteria_total == 0 {
1.0
} else {
f64::from(u32::try_from(winner.criteria_met).unwrap_or(u32::MAX))
/ f64::from(u32::try_from(winner.criteria_total).unwrap_or(u32::MAX))
};
let cycle_efficiency = 1.0 - (f64::from(winner.cycles) / 50.0_f64).min(1.0);
LearningEpisode {
id: Uuid::new_v4(),
intent_id,
plan_id,
predicted_outcome: format!("winner: {}", winner.label),
actual_outcome: Some(format!(
"score={:.3} converged={} cycles={}",
winner.score, winner.converged, winner.cycles
)),
run_status: Some(if winner.converged {
"converged".into()
} else {
"did-not-converge".into()
}),
prediction_error: Some(PredictionError {
magnitude: 1.0 - winner.score,
dimensions: vec![
ErrorDimension {
name: "convergence_rate".into(),
predicted: 1.0,
actual: convergence_rate,
},
ErrorDimension {
name: "criteria_coverage".into(),
predicted: 1.0,
actual: criteria_coverage,
},
ErrorDimension {
name: "cycle_efficiency".into(),
predicted: 1.0,
actual: cycle_efficiency,
},
],
}),
adversarial_signals: vec![],
lessons: vec![],
}
}
#[cfg(test)]
mod tests {
use super::*;
use converge_kernel::{AgentEffect, Context, ContextKey, ProposedFact};
use converge_pack::Suggestor;
struct ConvergingAgent;
#[async_trait::async_trait]
impl Suggestor for ConvergingAgent {
fn name(&self) -> &'static str {
"converging"
}
fn dependencies(&self) -> &[ContextKey] {
&[ContextKey::Seeds]
}
fn accepts(&self, ctx: &dyn Context) -> bool {
ctx.has(ContextKey::Seeds) && !ctx.has(ContextKey::Hypotheses)
}
async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
let seeds = ctx.get(ContextKey::Seeds);
AgentEffect::with_proposal(ProposedFact::new(
ContextKey::Hypotheses,
format!("hyp-{}", seeds[0].id),
"converged hypothesis",
self.name(),
))
}
}
fn make_formation(label: &str) -> Formation {
Formation::new(label).agent(ConvergingAgent).seed(
ContextKey::Seeds,
"s1",
"test content",
"test",
)
}
fn id() -> Uuid {
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()
}
#[test]
fn score_converged_result_above_zero_point_six() {
let result = tokio::runtime::Runtime::new()
.unwrap()
.block_on(make_formation("f1").run())
.unwrap();
let score = FormationScore::from_result(&result);
assert!(score.converged);
assert!(score.score > 0.6, "score was {}", score.score);
}
#[tokio::test]
async fn tournament_picks_winner_from_two_formations() {
let t = FormationTournament::new(
id(),
id(),
vec![make_formation("team-a"), make_formation("team-b")],
);
let result = t.run().await.unwrap();
assert!(!result.winner.label.is_empty());
assert_eq!(result.all_scores.len(), 2);
for score in &result.all_scores {
assert!(result.winner.score >= score.score);
}
}
#[tokio::test]
async fn tournament_produces_priors() {
let t = FormationTournament::new(id(), id(), vec![make_formation("solo")]);
let result = t.run().await.unwrap();
assert!(!result.priors.is_empty());
assert!(
result
.priors
.iter()
.any(|p| p.assumption_type == "convergence_rate")
);
assert!(
result
.priors
.iter()
.any(|p| p.assumption_type == "criteria_coverage")
);
assert!(
result
.priors
.iter()
.any(|p| p.assumption_type == "cycle_efficiency")
);
}
#[tokio::test]
async fn tournament_error_on_no_formations() {
let t = FormationTournament::new(id(), id(), vec![]);
assert!(matches!(t.run().await, Err(TournamentError::NoFormations)));
}
#[tokio::test]
async fn tournament_scores_sorted_descending() {
let t = FormationTournament::new(
id(),
id(),
vec![
make_formation("a"),
make_formation("b"),
make_formation("c"),
],
);
let result = t.run().await.unwrap();
let scores: Vec<f64> = result.all_scores.iter().map(|s| s.score).collect();
for window in scores.windows(2) {
assert!(window[0] >= window[1]);
}
}
#[tokio::test]
async fn tournament_winner_is_first_in_sorted_list() {
let t =
FormationTournament::new(id(), id(), vec![make_formation("a"), make_formation("b")]);
let result = t.run().await.unwrap();
assert_eq!(result.winner.label, result.all_scores[0].label);
}
#[tokio::test]
async fn repeated_tournaments_tighten_priors() {
let run_once = |existing: Vec<PriorCalibration>| async move {
let t = FormationTournament::new(id(), id(), vec![make_formation("f")]);
let result = t.run().await.unwrap();
let episode = episode_from_scores(&result.all_scores, id(), id());
calibrate_priors(&episode, &existing)
};
let round1 = run_once(vec![]).await;
let round2 = run_once(round1.clone()).await;
assert_eq!(round1[0].evidence_count, 1);
assert_eq!(round2[0].evidence_count, 2);
}
#[tokio::test]
async fn priors_are_serializable_as_planning_prior_seeds() {
let t = FormationTournament::new(id(), id(), vec![make_formation("f")]);
let result = t.run().await.unwrap();
for prior in &result.priors {
let seed_content = serde_json::json!({
"type": "prior_calibration",
"calibration": prior,
});
let json = seed_content.to_string();
let back: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(
back["calibration"]["assumption_type"].as_str().unwrap(),
prior.assumption_type
);
}
}
}