crackle-runtime 0.2.0

Detect emergent patterns — clustering, correlations, phase transitions, and conservation laws — across task outputs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
use crate::task::{CrackleTask, TaskMetadata, TaskOutput, Timestamp};
use crate::patterns::{
    ClusteringPattern, ConservationPattern, CorrelationPattern, CracklePattern, PhaseTransitionPattern,
};
use crate::profile::ThermalProfile;
use crate::error::CrackleError;
use crate::information::{entropy, jsd, kl_divergence, mutual_information, permutation_entropy};
#[cfg(feature = "serde")]
use serde::{Serialize, Deserialize};

/// A completed task entry stored in the kiln.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TaskEntry {
    /// The task label.
    pub label: String,
    /// Metrics produced during firing.
    pub metrics: Vec<(String, f64)>,
    /// Metrics produced during cooling (may differ from firing).
    pub cooled_metrics: Vec<(String, f64)>,
    /// Task metadata.
    pub metadata: TaskMetadata,
}

impl TaskEntry {
    /// All metrics: cooled metrics override firing metrics of the same name.
    pub fn all_metrics(&self) -> Vec<(String, f64)> {
        let mut result = self.cooled_metrics.clone();
        for (name, val) in &self.metrics {
            if !result.iter().any(|(n, _)| n == name) {
                result.push((name.clone(), *val));
            }
        }
        result
    }
}

/// The kiln — the runtime that fires tasks and cools them to detect patterns.
///
/// Like a pottery kiln, this runtime has two distinct phases:
///
/// 1. **Firing**: Tasks execute (`fire()`), producing outputs and metrics.
///    This is the hot phase — the work gets done.
///
/// 2. **Cooling**: After all tasks have fired, the runtime examines the completed
///    tasks for emergent patterns. The crackle glaze forms in the cooling, not the firing.
///
/// # Example
///
/// ```
/// use crackle_runtime::{CrackleTask, Kiln, ThermalProfile, TaskOutput};
///
/// # fn main() -> crackle_runtime::Result<()> {
/// struct MyTask { x: f64 }
/// impl CrackleTask for MyTask {
///     type Output = f64;
///     fn fire(&self) -> TaskOutput<Self::Output> {
///         TaskOutput::new(self.x, vec![("value".into(), self.x)])
///     }
/// }
///
/// let mut kiln = Kiln::new(ThermalProfile::default());
/// kiln.fire_task(MyTask { x: 1.0 })?;
/// kiln.fire_task(MyTask { x: 2.0 })?;
/// kiln.fire_task(MyTask { x: 3.0 })?;
///
/// let patterns = kiln.cool();
/// # Ok(())
/// # }
/// ```
pub struct Kiln {
    profile: ThermalProfile,
    entries: Vec<TaskEntry>,
    cooled: bool,
}

impl Kiln {
    /// Create a new kiln with the given thermal profile.
    pub fn new(profile: ThermalProfile) -> Self {
        Kiln {
            profile,
            entries: Vec::new(),
            cooled: false,
        }
    }

    /// Create a kiln with default thermal profile.
    pub fn default_profile() -> Self {
        Kiln::new(ThermalProfile::default())
    }

    /// Fire a single task and return its output (without recording).
    ///
    /// Returns the task's output value.
    ///
    /// # Errors
    ///
    /// Returns [`CrackleError::KilnCooled`] if called after `cool()`.
    pub fn fire_task<T: CrackleTask>(&self, task: T) -> crate::Result<TaskOutput<T::Output>> {
        if self.cooled {
            return Err(CrackleError::KilnCooled);
        }

        Ok(task.fire())
    }

    /// Fire a task and record it in the kiln for later cooling.
    ///
    /// This stores the task's metrics internally so patterns can be detected
    /// during the cooling phase.
    ///
    /// # Errors
    ///
    /// Returns [`CrackleError::KilnCooled`] if called after `cool()`.
    pub fn fire_and_record<T: CrackleTask>(&mut self, task: T) -> crate::Result<TaskOutput<T::Output>> {
        if self.cooled {
            return Err(CrackleError::KilnCooled);
        }

        let label = task.label();
        let fired_at = Timestamp::now();
        let start = std::time::Instant::now();

        let output = task.fire();

        let fire_duration = start.elapsed();
        let metadata = TaskMetadata {
            fired_at,
            cooled_at: None,
            fire_duration,
            label: label.clone(),
        };

        let entry = TaskEntry {
            label,
            metrics: output.metrics.clone(),
            cooled_metrics: vec![],
            metadata,
        };

        self.entries.push(entry);
        Ok(output)
    }

    /// Fire multiple tasks in sequence and record them all.
    ///
    /// # Errors
    ///
    /// Returns the first error encountered. Already-fired tasks are still recorded.
    pub fn fire_all<T: CrackleTask>(&mut self, tasks: Vec<T>) -> crate::Result<Vec<TaskOutput<T::Output>>> {
        tasks
            .into_iter()
            .map(|task| self.fire_and_record(task))
            .collect()
    }

    /// Add a pre-computed task entry directly (useful for testing).
    pub fn add_entry(&mut self, label: impl Into<String>, metrics: Vec<(String, f64)>) {
        let label = label.into();
        let metadata = TaskMetadata::new(&label);
        self.entries.push(TaskEntry {
            label,
            metrics,
            cooled_metrics: vec![],
            metadata,
        });
    }

    /// The number of tasks currently in the kiln.
    pub fn task_count(&self) -> usize {
        self.entries.len()
    }

    /// Get all task entries.
    pub fn entries(&self) -> &[TaskEntry] {
        &self.entries
    }

    /// Cool the kiln: run pattern detection across all completed tasks.
    ///
    /// This is where the beauty emerges. Just as a pottery kiln's crackle glaze
    /// forms during cooling, the patterns that crackle-runtime detects are only
    /// visible after the heat of execution has passed.
    ///
    /// Returns all detected patterns.
    pub fn cool(&mut self) -> Vec<CracklePattern> {
        self.cooled = true;
        let mut patterns = Vec::new();

        if self.entries.len() < self.profile.rate.min_tasks_for_detection() {
            return patterns;
        }

        let labels: Vec<String> = self.entries.iter().map(|e| e.label.clone()).collect();
        let metrics: Vec<Vec<(String, f64)>> = self.entries.iter().map(|e| e.all_metrics()).collect();

        // Run each task's cool() phase — set cooled timestamps
        let cooled_ts = Timestamp::now();
        for entry in &mut self.entries {
            entry.metadata.cooled_at = Some(cooled_ts);
        }

        if self.profile.detect_clustering {
            let p = ClusteringPattern::detect(
                &labels,
                &metrics,
                self.profile.rate.cluster_threshold(),
            );
            patterns.extend(p);
        }

        if self.profile.detect_phase_transitions {
            let p = PhaseTransitionPattern::detect(
                &labels,
                &metrics,
                self.profile.rate.phase_transition_sensitivity(),
            );
            patterns.extend(p);
        }

        if self.profile.detect_conservation {
            let p = ConservationPattern::detect(
                &labels,
                &metrics,
                self.profile.rate.conservation_tolerance(),
            );
            patterns.extend(p);
        }

        if self.profile.detect_correlations {
            let p = CorrelationPattern::detect(
                &labels,
                &metrics,
                self.profile.rate.correlation_threshold(),
            );
            patterns.extend(p);
        }

        // Sort by confidence descending
        patterns.sort_by(|a, b| b.confidence().partial_cmp(&a.confidence()).unwrap_or(std::cmp::Ordering::Equal));

        patterns
    }

    /// Check if the kiln has been cooled.
    pub fn is_cooled(&self) -> bool {
        self.cooled
    }

    /// Get the thermal profile.
    pub fn profile(&self) -> &ThermalProfile {
        &self.profile
    }

    /// Reset the kiln for a new firing cycle.
    pub fn reset(&mut self) {
        self.entries.clear();
        self.cooled = false;
    }

    /// Compute the full mutual information matrix for all metric pairs.
    ///
    /// Returns a symmetric matrix where entry (i,j) is the mutual information
    /// between metric i and metric j. Captures non-linear dependencies that
    /// Pearson correlation misses.
    ///
    /// # Arguments
    ///
    /// * `bins` - Number of bins for discretization (typically 10)
    ///
    /// # Panics
    ///
    /// Panics if there are no metric names (empty kiln).
    pub fn mi_matrix(&self, bins: usize) -> Vec<Vec<f64>> {
        let metric_names = self.collect_metric_names();
        let n = metric_names.len();
        if n == 0 {
            return vec![];
        }

        // Extract values for each metric
        let metric_values: Vec<Vec<f64>> = metric_names
            .iter()
            .map(|name| {
                self.entries
                    .iter()
                    .filter_map(|e| {
                        e.all_metrics()
                            .iter()
                            .find(|(n, _)| n == name)
                            .map(|(_, v)| *v)
                    })
                    .collect()
            })
            .collect();

        let mut matrix = vec![vec![0.0f64; n]; n];

        for i in 0..n {
            matrix[i][i] = entropy(&metric_values[i], bins);
            for j in (i + 1)..n {
                let mi = mutual_information(&metric_values[i], &metric_values[j], bins);
                matrix[i][j] = mi;
                matrix[j][i] = mi;
            }
        }

        matrix
    }

    /// Compute KL divergence between first-half and second-half metric distributions.
    ///
    /// Principled replacement for the old "phase transition" heuristic.
    /// Returns the KL divergence for each metric name.
    pub fn distribution_shift(&self, bins: usize) -> Vec<(String, f64)> {
        let metric_names = self.collect_metric_names();
        let n = self.entries.len();
        if n < 2 {
            return vec![];
        }

        let mid = n / 2;
        let mut results = Vec::new();

        for name in &metric_names {
            let first_half: Vec<f64> = self.entries[..mid]
                .iter()
                .filter_map(|e| {
                    e.all_metrics()
                        .iter()
                        .find(|(n, _)| n == name)
                        .map(|(_, v)| *v)
                })
                .collect();

            let second_half: Vec<f64> = self.entries[mid..]
                .iter()
                .filter_map(|e| {
                    e.all_metrics()
                        .iter()
                        .find(|(n, _)| n == name)
                        .map(|(_, v)| *v)
                })
                .collect();

            if !first_half.is_empty() && !second_half.is_empty() {
                let kl = kl_divergence(&second_half, &first_half, bins);
                results.push((name.clone(), kl));
            }
        }

        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
        results
    }

    /// Compute Jensen-Shannon divergence between first-half and second-half metric distributions.
    ///
    /// Symmetric version of KL divergence. Returns JSD for each metric name.
    pub fn jsd_shift(&self, bins: usize) -> Vec<(String, f64)> {
        let metric_names = self.collect_metric_names();
        let n = self.entries.len();
        if n < 2 {
            return vec![];
        }

        let mid = n / 2;
        let mut results = Vec::new();

        for name in &metric_names {
            let first_half: Vec<f64> = self.entries[..mid]
                .iter()
                .filter_map(|e| {
                    e.all_metrics()
                        .iter()
                        .find(|(n, _)| n == name)
                        .map(|(_, v)| *v)
                })
                .collect();

            let second_half: Vec<f64> = self.entries[mid..]
                .iter()
                .filter_map(|e| {
                    e.all_metrics()
                        .iter()
                        .find(|(n, _)| n == name)
                        .map(|(_, v)| *v)
                })
                .collect();

            if !first_half.is_empty() && !second_half.is_empty() {
                let js = jsd(&first_half, &second_half, bins);
                results.push((name.clone(), js));
            }
        }

        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
        results
    }

    /// Compute permutation entropy for each metric's time series.
    ///
    /// Captures temporal structure in metric values.
    pub fn permutation_entropies(&self, order: usize) -> Vec<(String, f64)> {
        let metric_names = self.collect_metric_names();

        metric_names
            .iter()
            .map(|name| {
                let values: Vec<f64> = self
                    .entries
                    .iter()
                    .filter_map(|e| {
                        e.all_metrics()
                            .iter()
                            .find(|(n, _)| n == name)
                            .map(|(_, v)| *v)
                    })
                    .collect();
                (name.clone(), permutation_entropy(&values, order))
            })
            .collect()
    }

    /// Collect all unique metric names across all entries.
    fn collect_metric_names(&self) -> Vec<String> {
        let mut names = std::collections::HashSet::new();
        for entry in &self.entries {
            for (name, _) in entry.all_metrics() {
                names.insert(name.clone());
            }
        }
        names.into_iter().collect()
    }
}