klieo-spec 3.3.0

Generic decompose / critique / refine traits and a quality loop runner.
Documentation
//! `QualityLoop` — drive a [`Critic`] + [`Refiner`] pair to
//! convergence under a bounded iteration budget.
//!
//! Algorithm:
//!
//! ```text
//! iter = 0
//! while iter < max_iterations:
//!     critique = critic.evaluate(t, iter)
//!     if critique.pass:
//!         return (t, QualityMetadata { passed: true, .. })
//!     t = refiner.refine(t, critique, iter)
//!     iter += 1
//! return (t, QualityMetadata { passed: false, .. })
//! ```
//!
//! The loop **never** loses the candidate — the final `T` is returned
//! whether or not the critic passed, paired with metadata describing
//! the run. Callers decide what to do with a non-passing result.

use crate::critique::Critic;
use crate::quality::{Critique, QualityMetadata};
use crate::refine::Refiner;
use crate::SpecError;

/// Default maximum iterations when the caller does not override.
pub const DEFAULT_MAX_ITERATIONS: u8 = 5;

/// Drive a critic + refiner pair to convergence.
pub struct QualityLoop<T, C, R>
where
    T: Send + Sync,
    C: Critic<T>,
    R: Refiner<T>,
{
    critic: C,
    refiner: R,
    max_iterations: u8,
    _phantom: std::marker::PhantomData<fn() -> T>,
}

impl<T, C, R> QualityLoop<T, C, R>
where
    T: Send + Sync,
    C: Critic<T>,
    R: Refiner<T>,
{
    /// Build a loop with the default iteration budget
    /// ([`DEFAULT_MAX_ITERATIONS`]).
    pub fn new(critic: C, refiner: R) -> Self {
        Self {
            critic,
            refiner,
            max_iterations: DEFAULT_MAX_ITERATIONS,
            _phantom: std::marker::PhantomData,
        }
    }

    /// Override the maximum iteration count. Setting `0` is rejected
    /// at run-time with [`SpecError::Config`].
    pub fn with_max_iterations(mut self, n: u8) -> Self {
        self.max_iterations = n;
        self
    }

    /// Run the loop. Returns the final `T` and run metadata.
    ///
    /// **Important:** the returned `T` may not satisfy the critic —
    /// inspect [`QualityMetadata::passed`] before relying on it.
    pub async fn run(&self, mut t: T) -> Result<(T, QualityMetadata), SpecError> {
        if self.max_iterations == 0 {
            return Err(SpecError::Config("max_iterations must be > 0".into()));
        }

        #[allow(unused_assignments)]
        let mut last_critique: Option<Critique> = None;
        let mut iter: u8 = 0;
        loop {
            let critique = self.critic.evaluate(&t, iter).await?.with_iteration(iter);

            if critique.pass {
                tracing::debug!(target: "klieo_spec.loop", iter, "critique passed");
                return Ok((
                    t,
                    QualityMetadata {
                        iterations_used: iter,
                        max_iterations: self.max_iterations,
                        passed: true,
                        final_critique: Some(critique),
                    },
                ));
            }

            tracing::debug!(
                target: "klieo_spec.loop",
                iter,
                feedback = %critique.feedback,
                "critique failed; refining"
            );
            last_critique = Some(critique.clone());
            iter = iter.saturating_add(1);
            if iter >= self.max_iterations {
                tracing::warn!(
                    target: "klieo_spec.loop",
                    max = self.max_iterations,
                    "loop exhausted without passing"
                );
                return Ok((
                    t,
                    QualityMetadata {
                        iterations_used: iter,
                        max_iterations: self.max_iterations,
                        passed: false,
                        final_critique: last_critique,
                    },
                ));
            }
            t = self.refiner.refine(t, &critique, iter - 1).await?;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Critique;
    use async_trait::async_trait;

    struct AlwaysPass;
    #[async_trait]
    impl Critic<String> for AlwaysPass {
        async fn evaluate(&self, _t: &String, _i: u8) -> Result<Critique, SpecError> {
            Ok(Critique::pass("ok"))
        }
    }
    struct PadRefiner;
    #[async_trait]
    impl Refiner<String> for PadRefiner {
        async fn refine(&self, t: String, _c: &Critique, _i: u8) -> Result<String, SpecError> {
            Ok(format!("{t}-x"))
        }
    }

    struct AlwaysFail;
    #[async_trait]
    impl Critic<String> for AlwaysFail {
        async fn evaluate(&self, _t: &String, _i: u8) -> Result<Critique, SpecError> {
            Ok(Critique::fail("bad"))
        }
    }

    #[tokio::test]
    async fn passes_first_iteration() {
        let l = QualityLoop::new(AlwaysPass, PadRefiner);
        let (out, meta) = l.run("hello".into()).await.unwrap();
        assert_eq!(out, "hello");
        assert!(meta.passed);
        assert_eq!(meta.iterations_used, 0);
    }

    #[tokio::test]
    async fn exhausts_iterations_when_critic_never_passes() {
        let l = QualityLoop::new(AlwaysFail, PadRefiner).with_max_iterations(3);
        let (out, meta) = l.run("a".into()).await.unwrap();
        assert!(!meta.passed);
        assert_eq!(meta.iterations_used, 3);
        assert_eq!(meta.max_iterations, 3);
        // Refiner ran twice (iters 0 and 1); iter 2 critique fails and
        // the loop exits without an extra refine call.
        assert_eq!(out, "a-x-x");
    }

    #[tokio::test]
    async fn zero_max_iterations_rejected() {
        let l = QualityLoop::new(AlwaysPass, PadRefiner).with_max_iterations(0);
        let err = l.run("x".into()).await.unwrap_err();
        assert!(matches!(err, SpecError::Config(_)));
    }
}