Skip to main content

crackle_runtime/
patterns.rs

1use std::fmt;
2#[cfg(feature = "serde")]
3use serde::{Serialize, Deserialize};
4
5/// The kind of pattern detected during cooling.
6#[derive(Debug, Clone, PartialEq, Eq, Hash)]
7#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
8pub enum PatternKind {
9    /// Tasks that cluster together in metric space.
10    Clustering,
11    /// A shift in output distribution during cooling.
12    PhaseTransition,
13    /// A conservation law that holds across a group of tasks.
14    Conservation,
15    /// An unexpected correlation between seemingly unrelated tasks.
16    Correlation,
17}
18
19impl fmt::Display for PatternKind {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        match self {
22            PatternKind::Clustering => write!(f, "clustering"),
23            PatternKind::PhaseTransition => write!(f, "phase transition"),
24            PatternKind::Conservation => write!(f, "conservation law"),
25            PatternKind::Correlation => write!(f, "correlation"),
26        }
27    }
28}
29
30/// A pattern detected during the cooling phase.
31///
32/// Like a craze line in pottery glaze, each pattern is a record of something
33/// that wasn't designed — it emerged from the interaction of many tasks
34/// as the system cooled.
35#[derive(Debug, Clone)]
36#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
37pub struct CracklePattern {
38    kind: PatternKind,
39    description: String,
40    involved_tasks: Vec<String>,
41    confidence: f64,
42    metrics: Vec<(String, f64)>,
43}
44
45impl CracklePattern {
46    /// Create a new detected pattern.
47    pub fn new(
48        kind: PatternKind,
49        description: impl Into<String>,
50        involved_tasks: Vec<String>,
51        confidence: f64,
52    ) -> Self {
53        CracklePattern {
54            kind,
55            description: description.into(),
56            involved_tasks,
57            confidence: confidence.clamp(0.0, 1.0),
58            metrics: vec![],
59        }
60    }
61
62    /// The kind of pattern detected.
63    pub fn kind(&self) -> &PatternKind {
64        &self.kind
65    }
66
67    /// Human-readable description of the pattern.
68    pub fn description(&self) -> &str {
69        &self.description
70    }
71
72    /// Labels of tasks involved in this pattern.
73    pub fn involved_tasks(&self) -> &[String] {
74        &self.involved_tasks
75    }
76
77    /// Confidence score (0.0 to 1.0).
78    pub fn confidence(&self) -> f64 {
79        self.confidence
80    }
81
82    /// Additional metrics associated with this pattern.
83    pub fn metrics(&self) -> &[(String, f64)] {
84        &self.metrics
85    }
86
87    /// Add a metric to this pattern.
88    pub fn with_metric(mut self, name: impl Into<String>, value: f64) -> Self {
89        self.metrics.push((name.into(), value));
90        self
91    }
92
93    /// Create a pattern with additional metrics.
94    pub fn with_metrics(mut self, metrics: Vec<(String, f64)>) -> Self {
95        self.metrics = metrics;
96        self
97    }
98
99    /// Serialize this pattern to a JSON string.
100    ///
101    /// Requires the `serde` feature to be enabled.
102    #[cfg(feature = "serde")]
103    pub fn to_json(&self) -> String {
104        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
105    }
106}
107
108/// Detector for clustering patterns — tasks that complete near each other in metric space.
109#[derive(Debug, Clone)]
110#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
111pub struct ClusteringPattern;
112
113impl ClusteringPattern {
114    /// Detect clusters among task metrics.
115    ///
116    /// Returns groups of task labels that cluster together based on metric proximity.
117    pub fn detect(
118        task_labels: &[String],
119        task_metrics: &[Vec<(String, f64)>],
120        threshold: f64,
121    ) -> Vec<CracklePattern> {
122        if task_labels.len() < 2 {
123            return vec![];
124        }
125
126        let mut patterns = Vec::new();
127        let n = task_labels.len();
128        let mut visited = vec![false; n];
129
130        for i in 0..n {
131            if visited[i] {
132                continue;
133            }
134            let mut cluster = vec![i];
135            visited[i] = true;
136
137            for j in (i + 1)..n {
138                if visited[j] {
139                    continue;
140                }
141                if Self::metric_distance(&task_metrics[i], &task_metrics[j]) < threshold {
142                    cluster.push(j);
143                    visited[j] = true;
144                }
145            }
146
147            if cluster.len() > 1 {
148                let labels: Vec<String> = cluster.iter().map(|&idx| task_labels[idx].clone()).collect();
149                let avg_dist = Self::avg_cluster_distance(&cluster, task_metrics);
150                patterns.push(
151                    CracklePattern::new(
152                        PatternKind::Clustering,
153                        format!(
154                            "{} tasks clustered together in metric space (avg distance: {:.3})",
155                            labels.len(),
156                            avg_dist
157                        ),
158                        labels,
159                        1.0 - (avg_dist / threshold).min(1.0),
160                    )
161                    .with_metric("avg_distance", avg_dist)
162                    .with_metric("cluster_size", cluster.len() as f64),
163                );
164            }
165        }
166
167        patterns
168    }
169
170    /// Compute Euclidean-like distance between two metric sets.
171    pub fn metric_distance(a: &[(String, f64)], b: &[(String, f64)]) -> f64 {
172        let mut sum_sq = 0.0;
173        let mut matched = 0;
174
175        for (name_a, val_a) in a {
176            if let Some((_, val_b)) = b.iter().find(|(name_b, _)| name_b == name_a) {
177                sum_sq += (val_a - val_b).powi(2);
178                matched += 1;
179            }
180        }
181
182        if matched == 0 {
183            f64::MAX
184        } else {
185            sum_sq.sqrt()
186        }
187    }
188
189    fn avg_cluster_distance(indices: &[usize], metrics: &[Vec<(String, f64)>]) -> f64 {
190        if indices.len() < 2 {
191            return 0.0;
192        }
193        let mut total = 0.0;
194        let mut count = 0;
195        for i in 0..indices.len() {
196            for j in (i + 1)..indices.len() {
197                total += Self::metric_distance(&metrics[indices[i]], &metrics[indices[j]]);
198                count += 1;
199            }
200        }
201        total / count as f64
202    }
203}
204
205/// Detector for phase transitions — shifts in output distributions.
206#[derive(Debug, Clone)]
207#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
208pub struct PhaseTransitionPattern;
209
210impl PhaseTransitionPattern {
211    /// Detect phase transitions by looking for significant shifts in metric values.
212    ///
213    /// Works by comparing the first half of tasks to the second half.
214    pub fn detect(
215        task_labels: &[String],
216        task_metrics: &[Vec<(String, f64)>],
217        sensitivity: f64,
218    ) -> Vec<CracklePattern> {
219        let n = task_labels.len();
220        if n < 2 {
221            return vec![];
222        }
223
224        let mut patterns = Vec::new();
225        let all_metric_names = Self::collect_metric_names(task_metrics);
226
227        for metric_name in &all_metric_names {
228            let values: Vec<(usize, f64)> = task_metrics
229                .iter()
230                .enumerate()
231                .filter_map(|(i, m)| {
232                    m.iter()
233                        .find(|(n, _)| n == metric_name)
234                        .map(|(_, v)| (i, *v))
235                })
236                .collect();
237
238            if values.len() < 2 {
239                continue;
240            }
241
242            let mid = values.len() / 2;
243            let first_half_avg = values[..mid].iter().map(|(_, v)| v).sum::<f64>() / mid as f64;
244            let second_half_avg =
245                values[mid..].iter().map(|(_, v)| v).sum::<f64>() / (values.len() - mid) as f64;
246
247            let global_avg = values.iter().map(|(_, v)| v).sum::<f64>() / values.len() as f64;
248            if global_avg.abs() < f64::EPSILON {
249                continue;
250            }
251
252            let shift = (second_half_avg - first_half_avg).abs() / global_avg.abs();
253            if shift > sensitivity {
254                let involved: Vec<String> = values
255                    .iter()
256                    .map(|(idx, _)| task_labels[*idx].clone())
257                    .collect();
258                patterns.push(
259                    CracklePattern::new(
260                        PatternKind::PhaseTransition,
261                        format!(
262                            "metric '{}' shifted by {:.1}% between first and second half of tasks",
263                            metric_name,
264                            shift * 100.0
265                        ),
266                        involved,
267                        (shift / sensitivity).min(1.0),
268                    )
269                    .with_metric("metric_name_hash", metric_name.len() as f64)
270                    .with_metric("shift_magnitude", shift)
271                    .with_metric("first_half_avg", first_half_avg)
272                    .with_metric("second_half_avg", second_half_avg),
273                );
274            }
275        }
276
277        patterns
278    }
279
280    fn collect_metric_names(metrics: &[Vec<(String, f64)>]) -> Vec<String> {
281        let mut names = std::collections::HashSet::new();
282        for m in metrics {
283            for (name, _) in m {
284                names.insert(name.clone());
285            }
286        }
287        names.into_iter().collect()
288    }
289}
290
291/// Detector for conservation laws — metrics whose sum stays near-constant across tasks.
292#[derive(Debug, Clone)]
293#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
294pub struct ConservationPattern;
295
296impl ConservationPattern {
297    /// Detect conservation laws: metrics that sum to approximately the same value across task groups.
298    pub fn detect(
299        task_labels: &[String],
300        task_metrics: &[Vec<(String, f64)>],
301        tolerance: f64,
302    ) -> Vec<CracklePattern> {
303        let n = task_labels.len();
304        if n < 2 {
305            return vec![];
306        }
307
308        let mut patterns = Vec::new();
309        let all_metric_names = PhaseTransitionPattern::collect_metric_names(task_metrics);
310
311        for metric_name in &all_metric_names {
312            let values: Vec<(usize, f64)> = task_metrics
313                .iter()
314                .enumerate()
315                .filter_map(|(i, m)| {
316                    m.iter()
317                        .find(|(n, _)| n == metric_name)
318                        .map(|(_, v)| (i, *v))
319                })
320                .collect();
321
322            if values.len() < 2 {
323                continue;
324            }
325
326            let total: f64 = values.iter().map(|(_, v)| v).sum();
327            let avg = total / values.len() as f64;
328
329            // Check if values hover around a constant (low variance = conservation)
330            let variance =
331                values.iter().map(|(_, v)| (v - avg).powi(2)).sum::<f64>() / values.len() as f64;
332            let std_dev = variance.sqrt();
333
334            if avg.abs() > f64::EPSILON && std_dev / avg.abs() < tolerance {
335                let involved: Vec<String> = values
336                    .iter()
337                    .map(|(idx, _)| task_labels[*idx].clone())
338                    .collect();
339                patterns.push(
340                    CracklePattern::new(
341                        PatternKind::Conservation,
342                        format!(
343                            "metric '{}' is conserved across {} tasks (sum: {:.3}, std_dev: {:.3})",
344                            metric_name,
345                            involved.len(),
346                            total,
347                            std_dev
348                        ),
349                        involved,
350                        1.0 - (std_dev / avg.abs()).min(1.0),
351                    )
352                    .with_metric("total", total)
353                    .with_metric("std_dev", std_dev)
354                    .with_metric("coefficient_of_variation", std_dev / avg.abs()),
355                );
356            }
357        }
358
359        patterns
360    }
361}
362
363/// Detector for unexpected correlations between tasks.
364#[derive(Debug, Clone)]
365#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
366pub struct CorrelationPattern;
367
368impl CorrelationPattern {
369    /// Detect correlations between different metrics across tasks.
370    pub fn detect(
371        task_labels: &[String],
372        task_metrics: &[Vec<(String, f64)>],
373        threshold: f64,
374    ) -> Vec<CracklePattern> {
375        let n = task_labels.len();
376        if n < 3 {
377            return vec![];
378        }
379
380        let metric_names = PhaseTransitionPattern::collect_metric_names(task_metrics);
381        if metric_names.len() < 2 {
382            return vec![];
383        }
384
385        let mut patterns = Vec::new();
386
387        for i in 0..metric_names.len() {
388            for j in (i + 1)..metric_names.len() {
389                let name_a = &metric_names[i];
390                let name_b = &metric_names[j];
391
392                let pairs: Vec<(f64, f64)> = task_metrics
393                    .iter()
394                    .filter_map(|m| {
395                        let a = m.iter().find(|(n, _)| n == name_a).map(|(_, v)| *v);
396                        let b = m.iter().find(|(n, _)| n == name_b).map(|(_, v)| *v);
397                        match (a, b) {
398                            (Some(a), Some(b)) => Some((a, b)),
399                            _ => None,
400                        }
401                    })
402                    .collect();
403
404                if pairs.len() < 3 {
405                    continue;
406                }
407
408                let corr = Self::pearson_correlation(&pairs);
409                if corr.abs() >= threshold {
410                    let involved: Vec<String> = task_labels
411                        .iter()
412                        .take(pairs.len())
413                        .cloned()
414                        .collect();
415                    patterns.push(
416                        CracklePattern::new(
417                            PatternKind::Correlation,
418                            format!(
419                                "strong {} correlation between '{}' and '{}' (r = {:.3})",
420                                if corr > 0.0 { "positive" } else { "negative" },
421                                name_a,
422                                name_b,
423                                corr
424                            ),
425                            involved,
426                            corr.abs(),
427                        )
428                        .with_metric("correlation", corr)
429                        .with_metric("metric_a_len", name_a.len() as f64)
430                        .with_metric("metric_b_len", name_b.len() as f64),
431                    );
432                }
433            }
434        }
435
436        patterns
437    }
438
439    /// Compute Pearson correlation coefficient.
440    pub fn pearson_correlation(pairs: &[(f64, f64)]) -> f64 {
441        let n = pairs.len() as f64;
442        if n < 2.0 {
443            return 0.0;
444        }
445
446        let sum_x: f64 = pairs.iter().map(|(x, _)| x).sum();
447        let sum_y: f64 = pairs.iter().map(|(_, y)| y).sum();
448        let sum_xy: f64 = pairs.iter().map(|(x, y)| x * y).sum();
449        let sum_x2: f64 = pairs.iter().map(|(x, _)| x * x).sum();
450        let sum_y2: f64 = pairs.iter().map(|(_, y)| y * y).sum();
451
452        let numerator = n * sum_xy - sum_x * sum_y;
453        let denominator = ((n * sum_x2 - sum_x * sum_x) * (n * sum_y2 - sum_y * sum_y)).sqrt();
454
455        if denominator.abs() < f64::EPSILON {
456            0.0
457        } else {
458            numerator / denominator
459        }
460    }
461}