Skip to main content

crackle_runtime/
kiln.rs

1use crate::task::{CrackleTask, TaskMetadata, TaskOutput, Timestamp};
2use crate::patterns::{
3    ClusteringPattern, ConservationPattern, CorrelationPattern, CracklePattern, PhaseTransitionPattern,
4};
5use crate::profile::ThermalProfile;
6use crate::error::CrackleError;
7use crate::information::{entropy, jsd, kl_divergence, mutual_information, permutation_entropy};
8#[cfg(feature = "serde")]
9use serde::{Serialize, Deserialize};
10
11/// A completed task entry stored in the kiln.
12#[derive(Debug, Clone)]
13#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
14pub struct TaskEntry {
15    /// The task label.
16    pub label: String,
17    /// Metrics produced during firing.
18    pub metrics: Vec<(String, f64)>,
19    /// Metrics produced during cooling (may differ from firing).
20    pub cooled_metrics: Vec<(String, f64)>,
21    /// Task metadata.
22    pub metadata: TaskMetadata,
23}
24
25impl TaskEntry {
26    /// All metrics: cooled metrics override firing metrics of the same name.
27    pub fn all_metrics(&self) -> Vec<(String, f64)> {
28        let mut result = self.cooled_metrics.clone();
29        for (name, val) in &self.metrics {
30            if !result.iter().any(|(n, _)| n == name) {
31                result.push((name.clone(), *val));
32            }
33        }
34        result
35    }
36}
37
38/// The kiln — the runtime that fires tasks and cools them to detect patterns.
39///
40/// Like a pottery kiln, this runtime has two distinct phases:
41///
42/// 1. **Firing**: Tasks execute (`fire()`), producing outputs and metrics.
43///    This is the hot phase — the work gets done.
44///
45/// 2. **Cooling**: After all tasks have fired, the runtime examines the completed
46///    tasks for emergent patterns. The crackle glaze forms in the cooling, not the firing.
47///
48/// # Example
49///
50/// ```
51/// use crackle_runtime::{CrackleTask, Kiln, ThermalProfile, TaskOutput};
52///
53/// # fn main() -> crackle_runtime::Result<()> {
54/// struct MyTask { x: f64 }
55/// impl CrackleTask for MyTask {
56///     type Output = f64;
57///     fn fire(&self) -> TaskOutput<Self::Output> {
58///         TaskOutput::new(self.x, vec![("value".into(), self.x)])
59///     }
60/// }
61///
62/// let mut kiln = Kiln::new(ThermalProfile::default());
63/// kiln.fire_task(MyTask { x: 1.0 })?;
64/// kiln.fire_task(MyTask { x: 2.0 })?;
65/// kiln.fire_task(MyTask { x: 3.0 })?;
66///
67/// let patterns = kiln.cool();
68/// # Ok(())
69/// # }
70/// ```
71pub struct Kiln {
72    profile: ThermalProfile,
73    entries: Vec<TaskEntry>,
74    cooled: bool,
75}
76
77impl Kiln {
78    /// Create a new kiln with the given thermal profile.
79    pub fn new(profile: ThermalProfile) -> Self {
80        Kiln {
81            profile,
82            entries: Vec::new(),
83            cooled: false,
84        }
85    }
86
87    /// Create a kiln with default thermal profile.
88    pub fn default_profile() -> Self {
89        Kiln::new(ThermalProfile::default())
90    }
91
92    /// Fire a single task and return its output (without recording).
93    ///
94    /// Returns the task's output value.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`CrackleError::KilnCooled`] if called after `cool()`.
99    pub fn fire_task<T: CrackleTask>(&self, task: T) -> crate::Result<TaskOutput<T::Output>> {
100        if self.cooled {
101            return Err(CrackleError::KilnCooled);
102        }
103
104        Ok(task.fire())
105    }
106
107    /// Fire a task and record it in the kiln for later cooling.
108    ///
109    /// This stores the task's metrics internally so patterns can be detected
110    /// during the cooling phase.
111    ///
112    /// # Errors
113    ///
114    /// Returns [`CrackleError::KilnCooled`] if called after `cool()`.
115    pub fn fire_and_record<T: CrackleTask>(&mut self, task: T) -> crate::Result<TaskOutput<T::Output>> {
116        if self.cooled {
117            return Err(CrackleError::KilnCooled);
118        }
119
120        let label = task.label();
121        let fired_at = Timestamp::now();
122        let start = std::time::Instant::now();
123
124        let output = task.fire();
125
126        let fire_duration = start.elapsed();
127        let metadata = TaskMetadata {
128            fired_at,
129            cooled_at: None,
130            fire_duration,
131            label: label.clone(),
132        };
133
134        let entry = TaskEntry {
135            label,
136            metrics: output.metrics.clone(),
137            cooled_metrics: vec![],
138            metadata,
139        };
140
141        self.entries.push(entry);
142        Ok(output)
143    }
144
145    /// Fire multiple tasks in sequence and record them all.
146    ///
147    /// # Errors
148    ///
149    /// Returns the first error encountered. Already-fired tasks are still recorded.
150    pub fn fire_all<T: CrackleTask>(&mut self, tasks: Vec<T>) -> crate::Result<Vec<TaskOutput<T::Output>>> {
151        tasks
152            .into_iter()
153            .map(|task| self.fire_and_record(task))
154            .collect()
155    }
156
157    /// Add a pre-computed task entry directly (useful for testing).
158    pub fn add_entry(&mut self, label: impl Into<String>, metrics: Vec<(String, f64)>) {
159        let label = label.into();
160        let metadata = TaskMetadata::new(&label);
161        self.entries.push(TaskEntry {
162            label,
163            metrics,
164            cooled_metrics: vec![],
165            metadata,
166        });
167    }
168
169    /// The number of tasks currently in the kiln.
170    pub fn task_count(&self) -> usize {
171        self.entries.len()
172    }
173
174    /// Get all task entries.
175    pub fn entries(&self) -> &[TaskEntry] {
176        &self.entries
177    }
178
179    /// Cool the kiln: run pattern detection across all completed tasks.
180    ///
181    /// This is where the beauty emerges. Just as a pottery kiln's crackle glaze
182    /// forms during cooling, the patterns that crackle-runtime detects are only
183    /// visible after the heat of execution has passed.
184    ///
185    /// Returns all detected patterns.
186    pub fn cool(&mut self) -> Vec<CracklePattern> {
187        self.cooled = true;
188        let mut patterns = Vec::new();
189
190        if self.entries.len() < self.profile.rate.min_tasks_for_detection() {
191            return patterns;
192        }
193
194        let labels: Vec<String> = self.entries.iter().map(|e| e.label.clone()).collect();
195        let metrics: Vec<Vec<(String, f64)>> = self.entries.iter().map(|e| e.all_metrics()).collect();
196
197        // Run each task's cool() phase — set cooled timestamps
198        let cooled_ts = Timestamp::now();
199        for entry in &mut self.entries {
200            entry.metadata.cooled_at = Some(cooled_ts);
201        }
202
203        if self.profile.detect_clustering {
204            let p = ClusteringPattern::detect(
205                &labels,
206                &metrics,
207                self.profile.rate.cluster_threshold(),
208            );
209            patterns.extend(p);
210        }
211
212        if self.profile.detect_phase_transitions {
213            let p = PhaseTransitionPattern::detect(
214                &labels,
215                &metrics,
216                self.profile.rate.phase_transition_sensitivity(),
217            );
218            patterns.extend(p);
219        }
220
221        if self.profile.detect_conservation {
222            let p = ConservationPattern::detect(
223                &labels,
224                &metrics,
225                self.profile.rate.conservation_tolerance(),
226            );
227            patterns.extend(p);
228        }
229
230        if self.profile.detect_correlations {
231            let p = CorrelationPattern::detect(
232                &labels,
233                &metrics,
234                self.profile.rate.correlation_threshold(),
235            );
236            patterns.extend(p);
237        }
238
239        // Sort by confidence descending
240        patterns.sort_by(|a, b| b.confidence().partial_cmp(&a.confidence()).unwrap_or(std::cmp::Ordering::Equal));
241
242        patterns
243    }
244
245    /// Check if the kiln has been cooled.
246    pub fn is_cooled(&self) -> bool {
247        self.cooled
248    }
249
250    /// Get the thermal profile.
251    pub fn profile(&self) -> &ThermalProfile {
252        &self.profile
253    }
254
255    /// Reset the kiln for a new firing cycle.
256    pub fn reset(&mut self) {
257        self.entries.clear();
258        self.cooled = false;
259    }
260
261    /// Compute the full mutual information matrix for all metric pairs.
262    ///
263    /// Returns a symmetric matrix where entry (i,j) is the mutual information
264    /// between metric i and metric j. Captures non-linear dependencies that
265    /// Pearson correlation misses.
266    ///
267    /// # Arguments
268    ///
269    /// * `bins` - Number of bins for discretization (typically 10)
270    ///
271    /// # Panics
272    ///
273    /// Panics if there are no metric names (empty kiln).
274    pub fn mi_matrix(&self, bins: usize) -> Vec<Vec<f64>> {
275        let metric_names = self.collect_metric_names();
276        let n = metric_names.len();
277        if n == 0 {
278            return vec![];
279        }
280
281        // Extract values for each metric
282        let metric_values: Vec<Vec<f64>> = metric_names
283            .iter()
284            .map(|name| {
285                self.entries
286                    .iter()
287                    .filter_map(|e| {
288                        e.all_metrics()
289                            .iter()
290                            .find(|(n, _)| n == name)
291                            .map(|(_, v)| *v)
292                    })
293                    .collect()
294            })
295            .collect();
296
297        let mut matrix = vec![vec![0.0f64; n]; n];
298
299        for i in 0..n {
300            matrix[i][i] = entropy(&metric_values[i], bins);
301            for j in (i + 1)..n {
302                let mi = mutual_information(&metric_values[i], &metric_values[j], bins);
303                matrix[i][j] = mi;
304                matrix[j][i] = mi;
305            }
306        }
307
308        matrix
309    }
310
311    /// Compute KL divergence between first-half and second-half metric distributions.
312    ///
313    /// Principled replacement for the old "phase transition" heuristic.
314    /// Returns the KL divergence for each metric name.
315    pub fn distribution_shift(&self, bins: usize) -> Vec<(String, f64)> {
316        let metric_names = self.collect_metric_names();
317        let n = self.entries.len();
318        if n < 2 {
319            return vec![];
320        }
321
322        let mid = n / 2;
323        let mut results = Vec::new();
324
325        for name in &metric_names {
326            let first_half: Vec<f64> = self.entries[..mid]
327                .iter()
328                .filter_map(|e| {
329                    e.all_metrics()
330                        .iter()
331                        .find(|(n, _)| n == name)
332                        .map(|(_, v)| *v)
333                })
334                .collect();
335
336            let second_half: Vec<f64> = self.entries[mid..]
337                .iter()
338                .filter_map(|e| {
339                    e.all_metrics()
340                        .iter()
341                        .find(|(n, _)| n == name)
342                        .map(|(_, v)| *v)
343                })
344                .collect();
345
346            if !first_half.is_empty() && !second_half.is_empty() {
347                let kl = kl_divergence(&second_half, &first_half, bins);
348                results.push((name.clone(), kl));
349            }
350        }
351
352        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
353        results
354    }
355
356    /// Compute Jensen-Shannon divergence between first-half and second-half metric distributions.
357    ///
358    /// Symmetric version of KL divergence. Returns JSD for each metric name.
359    pub fn jsd_shift(&self, bins: usize) -> Vec<(String, f64)> {
360        let metric_names = self.collect_metric_names();
361        let n = self.entries.len();
362        if n < 2 {
363            return vec![];
364        }
365
366        let mid = n / 2;
367        let mut results = Vec::new();
368
369        for name in &metric_names {
370            let first_half: Vec<f64> = self.entries[..mid]
371                .iter()
372                .filter_map(|e| {
373                    e.all_metrics()
374                        .iter()
375                        .find(|(n, _)| n == name)
376                        .map(|(_, v)| *v)
377                })
378                .collect();
379
380            let second_half: Vec<f64> = self.entries[mid..]
381                .iter()
382                .filter_map(|e| {
383                    e.all_metrics()
384                        .iter()
385                        .find(|(n, _)| n == name)
386                        .map(|(_, v)| *v)
387                })
388                .collect();
389
390            if !first_half.is_empty() && !second_half.is_empty() {
391                let js = jsd(&first_half, &second_half, bins);
392                results.push((name.clone(), js));
393            }
394        }
395
396        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
397        results
398    }
399
400    /// Compute permutation entropy for each metric's time series.
401    ///
402    /// Captures temporal structure in metric values.
403    pub fn permutation_entropies(&self, order: usize) -> Vec<(String, f64)> {
404        let metric_names = self.collect_metric_names();
405
406        metric_names
407            .iter()
408            .map(|name| {
409                let values: Vec<f64> = self
410                    .entries
411                    .iter()
412                    .filter_map(|e| {
413                        e.all_metrics()
414                            .iter()
415                            .find(|(n, _)| n == name)
416                            .map(|(_, v)| *v)
417                    })
418                    .collect();
419                (name.clone(), permutation_entropy(&values, order))
420            })
421            .collect()
422    }
423
424    /// Collect all unique metric names across all entries.
425    fn collect_metric_names(&self) -> Vec<String> {
426        let mut names = std::collections::HashSet::new();
427        for entry in &self.entries {
428            for (name, _) in entry.all_metrics() {
429                names.insert(name.clone());
430            }
431        }
432        names.into_iter().collect()
433    }
434}