Skip to main content

klieo_spec/
loops.rs

1//! `QualityLoop` — drive a [`Critic`] + [`Refiner`] pair to
2//! convergence under a bounded iteration budget.
3//!
4//! Algorithm:
5//!
6//! ```text
7//! iter = 0
8//! while iter < max_iterations:
9//!     critique = critic.evaluate(t, iter)
10//!     if critique.pass:
11//!         return (t, QualityMetadata { passed: true, .. })
12//!     t = refiner.refine(t, critique, iter)
13//!     iter += 1
14//! return (t, QualityMetadata { passed: false, .. })
15//! ```
16//!
17//! The loop **never** loses the candidate — the final `T` is returned
18//! whether or not the critic passed, paired with metadata describing
19//! the run. Callers decide what to do with a non-passing result.
20
21use crate::critique::Critic;
22use crate::quality::{Critique, QualityMetadata};
23use crate::refine::Refiner;
24use crate::SpecError;
25
26/// Default maximum iterations when the caller does not override.
27pub const DEFAULT_MAX_ITERATIONS: u8 = 5;
28
29/// Drive a critic + refiner pair to convergence.
30pub struct QualityLoop<T, C, R>
31where
32    T: Send + Sync,
33    C: Critic<T>,
34    R: Refiner<T>,
35{
36    critic: C,
37    refiner: R,
38    max_iterations: u8,
39    _phantom: std::marker::PhantomData<fn() -> T>,
40}
41
42impl<T, C, R> QualityLoop<T, C, R>
43where
44    T: Send + Sync,
45    C: Critic<T>,
46    R: Refiner<T>,
47{
48    /// Build a loop with the default iteration budget
49    /// ([`DEFAULT_MAX_ITERATIONS`]).
50    pub fn new(critic: C, refiner: R) -> Self {
51        Self {
52            critic,
53            refiner,
54            max_iterations: DEFAULT_MAX_ITERATIONS,
55            _phantom: std::marker::PhantomData,
56        }
57    }
58
59    /// Override the maximum iteration count. Setting `0` is rejected
60    /// at run-time with [`SpecError::Config`].
61    pub fn with_max_iterations(mut self, n: u8) -> Self {
62        self.max_iterations = n;
63        self
64    }
65
66    /// Run the loop. Returns the final `T` and run metadata.
67    ///
68    /// **Important:** the returned `T` may not satisfy the critic —
69    /// inspect [`QualityMetadata::passed`] before relying on it.
70    pub async fn run(&self, mut t: T) -> Result<(T, QualityMetadata), SpecError> {
71        if self.max_iterations == 0 {
72            return Err(SpecError::Config("max_iterations must be > 0".into()));
73        }
74
75        #[allow(unused_assignments)]
76        let mut last_critique: Option<Critique> = None;
77        let mut iter: u8 = 0;
78        loop {
79            let critique = self.critic.evaluate(&t, iter).await?.with_iteration(iter);
80
81            if critique.pass {
82                tracing::debug!(target: "klieo_spec.loop", iter, "critique passed");
83                return Ok((
84                    t,
85                    QualityMetadata {
86                        iterations_used: iter,
87                        max_iterations: self.max_iterations,
88                        passed: true,
89                        final_critique: Some(critique),
90                    },
91                ));
92            }
93
94            tracing::debug!(
95                target: "klieo_spec.loop",
96                iter,
97                feedback = %critique.feedback,
98                "critique failed; refining"
99            );
100            last_critique = Some(critique.clone());
101            iter = iter.saturating_add(1);
102            if iter >= self.max_iterations {
103                tracing::warn!(
104                    target: "klieo_spec.loop",
105                    max = self.max_iterations,
106                    "loop exhausted without passing"
107                );
108                return Ok((
109                    t,
110                    QualityMetadata {
111                        iterations_used: iter,
112                        max_iterations: self.max_iterations,
113                        passed: false,
114                        final_critique: last_critique,
115                    },
116                ));
117            }
118            t = self.refiner.refine(t, &critique, iter - 1).await?;
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::Critique;
127    use async_trait::async_trait;
128
129    struct AlwaysPass;
130    #[async_trait]
131    impl Critic<String> for AlwaysPass {
132        async fn evaluate(&self, _t: &String, _i: u8) -> Result<Critique, SpecError> {
133            Ok(Critique::pass("ok"))
134        }
135    }
136    struct PadRefiner;
137    #[async_trait]
138    impl Refiner<String> for PadRefiner {
139        async fn refine(&self, t: String, _c: &Critique, _i: u8) -> Result<String, SpecError> {
140            Ok(format!("{t}-x"))
141        }
142    }
143
144    struct AlwaysFail;
145    #[async_trait]
146    impl Critic<String> for AlwaysFail {
147        async fn evaluate(&self, _t: &String, _i: u8) -> Result<Critique, SpecError> {
148            Ok(Critique::fail("bad"))
149        }
150    }
151
152    #[tokio::test]
153    async fn passes_first_iteration() {
154        let l = QualityLoop::new(AlwaysPass, PadRefiner);
155        let (out, meta) = l.run("hello".into()).await.unwrap();
156        assert_eq!(out, "hello");
157        assert!(meta.passed);
158        assert_eq!(meta.iterations_used, 0);
159    }
160
161    #[tokio::test]
162    async fn exhausts_iterations_when_critic_never_passes() {
163        let l = QualityLoop::new(AlwaysFail, PadRefiner).with_max_iterations(3);
164        let (out, meta) = l.run("a".into()).await.unwrap();
165        assert!(!meta.passed);
166        assert_eq!(meta.iterations_used, 3);
167        assert_eq!(meta.max_iterations, 3);
168        // Refiner ran twice (iters 0 and 1); iter 2 critique fails and
169        // the loop exits without an extra refine call.
170        assert_eq!(out, "a-x-x");
171    }
172
173    #[tokio::test]
174    async fn zero_max_iterations_rejected() {
175        let l = QualityLoop::new(AlwaysPass, PadRefiner).with_max_iterations(0);
176        let err = l.run("x".into()).await.unwrap_err();
177        assert!(matches!(err, SpecError::Config(_)));
178    }
179}