Coverage Report

Created: 2026-01-25 15:05

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/noah/src/trueno/src/tuner/data_collector.rs
Line
Count
Source
1
//! Training Data Collection
2
//!
3
//! Implements TunerDataCollector for collecting and persisting training samples.
4
5
use serde::{Deserialize, Serialize};
6
7
use crate::brick::BrickProfiler;
8
9
use super::brick_tuner::BrickTuner;
10
use super::error::TunerError;
11
use super::features::{FeatureExtractor, RunConfig, TunerFeatures};
12
use super::helpers::{chrono_lite_now, crc32_hash};
13
use super::types::{BottleneckClass, KernelType};
14
15
// ============================================================================
16
// TrainingSample
17
// ============================================================================
18
19
/// Training sample for the tuner
20
#[derive(Debug, Clone, Serialize, Deserialize)]
21
pub struct TrainingSample {
22
    /// Features
23
    pub features: TunerFeatures,
24
    /// Measured throughput (label)
25
    pub throughput_tps: f32,
26
    /// Best kernel (label)
27
    pub best_kernel: KernelType,
28
    /// Bottleneck class (label)
29
    pub bottleneck: BottleneckClass,
30
    /// Timestamp
31
    pub timestamp: String,
32
    /// Hardware ID
33
    pub hardware_id: String,
34
}
35
36
// ============================================================================
37
// UserFeedback
38
// ============================================================================
39
40
/// User feedback on a recommendation
41
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
42
pub enum UserFeedback {
43
    /// User accepted the recommendation
44
    Accepted,
45
    /// User rejected the recommendation
46
    Rejected,
47
    /// User provided alternative (overrode recommendation)
48
    Alternative,
49
    /// No feedback (default)
50
    #[default]
51
    None,
52
}
53
54
// ============================================================================
55
// ConceptDriftStatus
56
// ============================================================================
57
58
/// Concept drift detection result
59
#[derive(Debug, Clone)]
60
pub struct ConceptDriftStatus {
61
    /// Whether drift has been detected
62
    pub drift_detected: bool,
63
    /// Estimated model staleness (0.0 = fresh, 1.0 = very stale)
64
    pub staleness_score: f32,
65
    /// Number of samples since last training
66
    pub samples_since_training: usize,
67
    /// Recommendation: should retrain?
68
    pub recommend_retrain: bool,
69
    /// Explanation of drift status
70
    pub explanation: String,
71
}
72
73
// ============================================================================
74
// TrainingStats
75
// ============================================================================
76
77
/// Training statistics summary
78
#[derive(Debug, Clone)]
79
pub struct TrainingStats {
80
    /// Total samples collected
81
    pub total_samples: usize,
82
    /// Samples since last training
83
    pub samples_since_training: usize,
84
    /// Accepted recommendations count
85
    pub accepted_count: usize,
86
    /// Rejected recommendations count
87
    pub rejected_count: usize,
88
    /// Alternative provided count
89
    pub alternative_count: usize,
90
    /// Staleness score (0.0 = fresh, 1.0 = stale)
91
    pub staleness_score: f32,
92
    /// Whether concept drift was detected
93
    pub drift_detected: bool,
94
    /// Whether online learning is enabled
95
    pub online_learning_enabled: bool,
96
}
97
98
// ============================================================================
99
// TunerDataCollector
100
// ============================================================================
101
102
/// Training data collector with online learning support (T-TUNER-005, GitHub #82)
103
#[derive(Debug, Default)]
104
pub struct TunerDataCollector {
105
    /// Collected samples
106
    pub(crate) samples: Vec<TrainingSample>,
107
    /// Feature extractor
108
    pub(crate) extractor: FeatureExtractor,
109
    /// Auto-retrain threshold
110
    pub(crate) retrain_threshold: usize,
111
    /// Number of samples at last training
112
    pub(crate) samples_at_last_train: usize,
113
    /// User feedback history (sample index -> feedback)
114
    pub(crate) feedback: Vec<UserFeedback>,
115
    /// Online learning enabled (privacy: opt-in only)
116
    pub(crate) online_learning_enabled: bool,
117
    /// Moving average of prediction errors (for concept drift)
118
    pub(crate) error_window: Vec<f32>,
119
    /// Error window size for drift detection
120
    error_window_size: usize,
121
}
122
123
impl TunerDataCollector {
124
    /// Default error window size for concept drift detection
125
    const DEFAULT_ERROR_WINDOW_SIZE: usize = 50;
126
127
    /// Error threshold for drift detection (mean absolute error)
128
    const DRIFT_ERROR_THRESHOLD: f32 = 0.15;
129
130
    /// Staleness threshold (samples since training) for recommending retrain
131
    const STALENESS_THRESHOLD: usize = 100;
132
133
    /// Minimum samples required before training triggers
134
    pub const MIN_SAMPLES_FOR_TRAINING: usize = 1000;
135
136
    /// Create a new collector
137
0
    pub fn new() -> Self {
138
0
        Self {
139
0
            samples: Vec::new(),
140
0
            extractor: FeatureExtractor::new(),
141
0
            retrain_threshold: 100,
142
0
            samples_at_last_train: 0,
143
0
            feedback: Vec::new(),
144
0
            online_learning_enabled: false, // Privacy: opt-in
145
0
            error_window: Vec::new(),
146
0
            error_window_size: Self::DEFAULT_ERROR_WINDOW_SIZE,
147
0
        }
148
0
    }
149
150
    /// Create a collector with online learning enabled
151
0
    pub fn with_online_learning() -> Self {
152
0
        let mut collector = Self::new();
153
0
        collector.online_learning_enabled = true;
154
0
        collector
155
0
    }
156
157
    /// Enable online learning (privacy: explicit opt-in)
158
0
    pub fn enable_online_learning(&mut self) {
159
0
        self.online_learning_enabled = true;
160
0
    }
161
162
    /// Disable online learning
163
0
    pub fn disable_online_learning(&mut self) {
164
0
        self.online_learning_enabled = false;
165
0
    }
166
167
    /// Check if online learning is enabled
168
0
    pub fn is_online_learning_enabled(&self) -> bool {
169
0
        self.online_learning_enabled
170
0
    }
171
172
    /// Record a profiling run as training data
173
0
    pub fn record(
174
0
        &mut self,
175
0
        profiler: &BrickProfiler,
176
0
        config: &RunConfig,
177
0
        kernel: KernelType,
178
0
    ) -> Option<()> {
179
0
        let throughput_tps = profiler.tokens_per_sec()?;
180
0
        let features = self.extractor.extract(profiler, config);
181
0
        let bottleneck = features.bottleneck_class.unwrap_or(BottleneckClass::Unknown);
182
183
0
        let sample = TrainingSample {
184
0
            features,
185
0
            throughput_tps,
186
0
            best_kernel: kernel,
187
0
            bottleneck,
188
0
            timestamp: chrono_lite_now(),
189
0
            hardware_id: "unknown".to_string(),
190
0
        };
191
192
0
        self.samples.push(sample);
193
0
        Some(())
194
0
    }
195
196
    /// Get all samples
197
0
    pub fn samples(&self) -> &[TrainingSample] {
198
0
        &self.samples
199
0
    }
200
201
    /// Get sample count
202
0
    pub fn len(&self) -> usize {
203
0
        self.samples.len()
204
0
    }
205
206
    /// Check if empty
207
0
    pub fn is_empty(&self) -> bool {
208
0
        self.samples.is_empty()
209
0
    }
210
211
    /// Export to JSON
212
0
    pub fn to_json(&self) -> Result<String, TunerError> {
213
0
        serde_json::to_string_pretty(&self.samples)
214
0
            .map_err(|e| TunerError::Serialization(e.to_string()))
215
0
    }
216
217
    /// Prepare training data for model
218
0
    pub fn prepare_training_data(&self) -> Vec<(TunerFeatures, f32)> {
219
0
        self.samples
220
0
            .iter()
221
0
            .map(|s| (s.features.clone(), s.throughput_tps))
222
0
            .collect()
223
0
    }
224
225
    // ========================================================================
226
    // T-TUNER-003: Persistent Training Data (GitHub #80)
227
    // ========================================================================
228
229
    /// Training data cache path
230
    #[cfg(feature = "hardware-detect")]
231
    pub fn cache_path() -> std::path::PathBuf {
232
        let hw_id = Self::hardware_id();
233
        dirs::cache_dir()
234
            .unwrap_or_else(|| std::path::PathBuf::from(".cache"))
235
            .join("trueno")
236
            .join(format!("training_data_{}.apr", hw_id))
237
    }
238
239
    /// Generate hardware fingerprint for hardware-specific models
240
    #[cfg(feature = "hardware-detect")]
241
    pub fn hardware_id() -> String {
242
        use crate::hardware::HardwareCapability;
243
        let hw = HardwareCapability::detect();
244
245
        // Create a stable fingerprint from hardware characteristics
246
        let fingerprint = format!(
247
            "{}-{:?}-{}-{}",
248
            hw.cpu.cores,
249
            hw.cpu.simd,
250
            hw.gpu
251
                .as_ref()
252
                .map(|g| g.model.as_str())
253
                .unwrap_or("none"),
254
            hw.gpu.as_ref().map(|g| g.vram_gb as u32).unwrap_or(0),
255
        );
256
257
        // Hash to short hex string
258
        let hash = crc32_hash(fingerprint.as_bytes());
259
        format!("{:08x}", hash)
260
    }
261
262
    /// Load from cache or create empty
263
    #[cfg(feature = "hardware-detect")]
264
    pub fn load_or_create() -> Self {
265
        let path = Self::cache_path();
266
        if path.exists() {
267
            if let Ok(collector) = Self::load_apr(&path) {
268
                return collector;
269
            }
270
        }
271
        Self::new()
272
    }
273
274
    /// Save training data to APR format
275
0
    pub fn save_apr<P: AsRef<std::path::Path>>(&self, path: P) -> Result<(), TunerError> {
276
        use std::io::Write;
277
278
        // Ensure parent directory exists
279
0
        if let Some(parent) = path.as_ref().parent() {
280
0
            std::fs::create_dir_all(parent)
281
0
                .map_err(|e: std::io::Error| TunerError::Io(e.to_string()))?;
282
0
        }
283
284
        // Serialize samples to JSON
285
0
        let json = serde_json::to_string(&self.samples)
286
0
            .map_err(|e| TunerError::Serialization(e.to_string()))?;
287
0
        let json_bytes = json.as_bytes();
288
289
        // Create APR format: MAGIC + LEN + JSON + CRC32
290
0
        let mut file = std::fs::File::create(path.as_ref())
291
0
            .map_err(|e: std::io::Error| TunerError::Io(e.to_string()))?;
292
293
        // Write magic bytes: "APR2" (version 2 for training data)
294
0
        file.write_all(b"APR2")
295
0
            .map_err(|e: std::io::Error| TunerError::Io(e.to_string()))?;
296
297
        // Write length as u32 little-endian
298
0
        let len = json_bytes.len() as u32;
299
0
        file.write_all(&len.to_le_bytes())
300
0
            .map_err(|e: std::io::Error| TunerError::Io(e.to_string()))?;
301
302
        // Write JSON
303
0
        file.write_all(json_bytes)
304
0
            .map_err(|e: std::io::Error| TunerError::Io(e.to_string()))?;
305
306
        // Write CRC32 checksum
307
0
        let checksum = crc32_hash(json_bytes);
308
0
        file.write_all(&checksum.to_le_bytes())
309
0
            .map_err(|e: std::io::Error| TunerError::Io(e.to_string()))?;
310
311
0
        Ok(())
312
0
    }
313
314
    /// Load training data from APR format
315
0
    pub fn load_apr<P: AsRef<std::path::Path>>(path: P) -> Result<Self, TunerError> {
316
        use std::io::Read;
317
318
0
        let mut file = std::fs::File::open(path.as_ref())
319
0
            .map_err(|e: std::io::Error| TunerError::Io(e.to_string()))?;
320
321
        // Read and verify magic
322
0
        let mut magic = [0u8; 4];
323
0
        file.read_exact(&mut magic)
324
0
            .map_err(|e: std::io::Error| TunerError::Io(e.to_string()))?;
325
0
        if &magic != b"APR2" {
326
0
            return Err(TunerError::InvalidFormat(format!(
327
0
                "Expected APR2 magic, got {:?}",
328
0
                magic
329
0
            )));
330
0
        }
331
332
        // Read length
333
0
        let mut len_bytes = [0u8; 4];
334
0
        file.read_exact(&mut len_bytes)
335
0
            .map_err(|e: std::io::Error| TunerError::Io(e.to_string()))?;
336
0
        let len = u32::from_le_bytes(len_bytes) as usize;
337
338
        // Read JSON
339
0
        let mut json_bytes = vec![0u8; len];
340
0
        file.read_exact(&mut json_bytes)
341
0
            .map_err(|e: std::io::Error| TunerError::Io(e.to_string()))?;
342
343
        // Read and verify CRC32
344
0
        let mut crc_bytes = [0u8; 4];
345
0
        file.read_exact(&mut crc_bytes)
346
0
            .map_err(|e: std::io::Error| TunerError::Io(e.to_string()))?;
347
0
        let stored_crc = u32::from_le_bytes(crc_bytes);
348
0
        let computed_crc = crc32_hash(&json_bytes);
349
350
0
        if stored_crc != computed_crc {
351
0
            return Err(TunerError::InvalidFormat(format!(
352
0
                "CRC mismatch: stored={:08x}, computed={:08x}",
353
0
                stored_crc, computed_crc
354
0
            )));
355
0
        }
356
357
        // Deserialize samples
358
0
        let samples: Vec<TrainingSample> = serde_json::from_slice(&json_bytes)
359
0
            .map_err(|e| TunerError::Serialization(e.to_string()))?;
360
361
0
        Ok(Self {
362
0
            samples,
363
0
            extractor: FeatureExtractor::new(),
364
0
            retrain_threshold: 100,
365
0
            samples_at_last_train: 0,
366
0
            feedback: Vec::new(),
367
0
            online_learning_enabled: false,
368
0
            error_window: Vec::new(),
369
0
            error_window_size: Self::DEFAULT_ERROR_WINDOW_SIZE,
370
0
        })
371
0
    }
372
373
    /// Append a sample to the cached training data
374
    #[cfg(feature = "hardware-detect")]
375
    pub fn record_and_persist(
376
        &mut self,
377
        profiler: &BrickProfiler,
378
        config: &RunConfig,
379
        kernel: KernelType,
380
    ) -> Result<(), TunerError> {
381
        // Record the sample
382
        self.record(profiler, config, kernel);
383
384
        // Append to cache file
385
        let path = Self::cache_path();
386
        self.save_apr(&path)?;
387
388
        Ok(())
389
    }
390
391
    /// Check if we have enough samples to train
392
0
    pub fn ready_to_train(&self) -> bool {
393
0
        self.samples.len() >= Self::MIN_SAMPLES_FOR_TRAINING
394
0
    }
395
396
    /// Train a BrickTuner from collected data if we have enough samples
397
0
    pub fn train_if_ready(&self) -> Option<BrickTuner> {
398
0
        if !self.ready_to_train() {
399
0
            return None;
400
0
        }
401
402
0
        let training_data = self.prepare_training_data();
403
0
        let mut tuner = BrickTuner::new();
404
405
0
        match tuner.train(&training_data) {
406
0
            Ok(()) => Some(tuner),
407
0
            Err(_) => None,
408
        }
409
0
    }
410
411
    /// Get training progress as (current, required)
412
0
    pub fn training_progress(&self) -> (usize, usize) {
413
0
        (self.samples.len(), Self::MIN_SAMPLES_FOR_TRAINING)
414
0
    }
415
416
    /// Merge samples from another collector
417
0
    pub fn merge(&mut self, other: &TunerDataCollector) {
418
0
        self.samples.extend(other.samples.iter().cloned());
419
0
    }
420
421
    /// Import samples from JSON
422
0
    pub fn from_json(json: &str) -> Result<Self, TunerError> {
423
0
        let samples: Vec<TrainingSample> =
424
0
            serde_json::from_str(json).map_err(|e| TunerError::Serialization(e.to_string()))?;
425
426
0
        Ok(Self {
427
0
            samples,
428
0
            extractor: FeatureExtractor::new(),
429
0
            retrain_threshold: 100,
430
0
            samples_at_last_train: 0,
431
0
            feedback: Vec::new(),
432
0
            online_learning_enabled: false,
433
0
            error_window: Vec::new(),
434
0
            error_window_size: Self::DEFAULT_ERROR_WINDOW_SIZE,
435
0
        })
436
0
    }
437
438
    /// Import samples from the Five-Whys archive (85 labeled iterations)
439
    /// Bootstrap initial training data from historical analysis
440
0
    pub fn bootstrap_from_five_whys() -> Self {
441
        // Five-Whys archive has 85 labeled iterations from SHOWCASE-BRICK-001
442
        // Each iteration has: features, throughput, kernel selection, bottleneck
443
444
        // TODO: Load actual Five-Whys data from archive
445
        // For now, return empty collector - data will be collected from real runs
446
0
        Self::new()
447
0
    }
448
449
    // ========================================================================
450
    // T-TUNER-005: Online Learning (GitHub #82)
451
    // ========================================================================
452
453
    /// Record user feedback on a recommendation
454
0
    pub fn record_feedback(&mut self, sample_index: usize, feedback: UserFeedback) {
455
        // Extend feedback vector if needed
456
0
        while self.feedback.len() <= sample_index {
457
0
            self.feedback.push(UserFeedback::None);
458
0
        }
459
0
        self.feedback[sample_index] = feedback;
460
0
    }
461
462
    /// Get feedback for a sample
463
0
    pub fn get_feedback(&self, sample_index: usize) -> UserFeedback {
464
0
        self.feedback
465
0
            .get(sample_index)
466
0
            .copied()
467
0
            .unwrap_or(UserFeedback::None)
468
0
    }
469
470
    /// Record prediction error for concept drift detection
471
0
    pub fn record_prediction_error(&mut self, predicted: f32, actual: f32) {
472
0
        if !self.online_learning_enabled {
473
0
            return;
474
0
        }
475
476
        // Compute relative error (0.0 = perfect, 1.0 = 100% error)
477
0
        let error = if actual > 0.0 {
478
0
            ((predicted - actual) / actual).abs().min(1.0)
479
        } else {
480
0
            1.0
481
        };
482
483
        // Add to sliding window
484
0
        self.error_window.push(error);
485
486
        // Trim window to max size
487
0
        if self.error_window.len() > self.error_window_size {
488
0
            self.error_window.remove(0);
489
0
        }
490
0
    }
491
492
    /// Detect concept drift based on prediction error trends
493
0
    pub fn detect_concept_drift(&self) -> ConceptDriftStatus {
494
0
        let samples_since_training = self
495
0
            .samples
496
0
            .len()
497
0
            .saturating_sub(self.samples_at_last_train);
498
499
        // Not enough data for drift detection
500
0
        if self.error_window.len() < 10 {
501
0
            return ConceptDriftStatus {
502
0
                drift_detected: false,
503
0
                staleness_score: 0.0,
504
0
                samples_since_training,
505
0
                recommend_retrain: false,
506
0
                explanation: "Insufficient data for drift detection".to_string(),
507
0
            };
508
0
        }
509
510
        // Compute mean error
511
0
        let mean_error: f32 =
512
0
            self.error_window.iter().sum::<f32>() / self.error_window.len() as f32;
513
514
        // Compute staleness score (0.0 = fresh, 1.0 = stale)
515
0
        let staleness_score =
516
0
            (samples_since_training as f32 / Self::STALENESS_THRESHOLD as f32).min(1.0);
517
518
        // Detect drift
519
0
        let drift_detected = mean_error > Self::DRIFT_ERROR_THRESHOLD;
520
521
        // Recommend retrain if drift detected OR stale
522
0
        let recommend_retrain = drift_detected || staleness_score > 0.8;
523
524
0
        let explanation = if drift_detected {
525
0
            format!(
526
0
                "Concept drift detected: mean error {:.1}% exceeds threshold {:.1}%",
527
0
                mean_error * 100.0,
528
                Self::DRIFT_ERROR_THRESHOLD * 100.0
529
            )
530
0
        } else if staleness_score > 0.8 {
531
0
            format!(
532
0
                "Model stale: {} samples since last training (threshold: {})",
533
                samples_since_training,
534
                Self::STALENESS_THRESHOLD
535
            )
536
        } else {
537
0
            format!(
538
0
                "Model fresh: mean error {:.1}%, {} samples since training",
539
0
                mean_error * 100.0,
540
                samples_since_training
541
            )
542
        };
543
544
0
        ConceptDriftStatus {
545
0
            drift_detected,
546
0
            staleness_score,
547
0
            samples_since_training,
548
0
            recommend_retrain,
549
0
            explanation,
550
0
        }
551
0
    }
552
553
    /// Check if auto-retrain should trigger
554
0
    pub fn should_retrain(&self) -> bool {
555
0
        if !self.online_learning_enabled {
556
0
            return false;
557
0
        }
558
559
0
        let samples_since = self
560
0
            .samples
561
0
            .len()
562
0
            .saturating_sub(self.samples_at_last_train);
563
564
        // Retrain if we have enough new samples
565
0
        if samples_since >= self.retrain_threshold {
566
0
            return true;
567
0
        }
568
569
        // Or if concept drift is detected
570
0
        let drift = self.detect_concept_drift();
571
0
        drift.recommend_retrain && samples_since >= 10
572
0
    }
573
574
    /// Mark that training occurred (resets drift counters)
575
0
    pub fn mark_trained(&mut self) {
576
0
        self.samples_at_last_train = self.samples.len();
577
0
        self.error_window.clear();
578
0
    }
579
580
    /// Get training statistics
581
0
    pub fn training_stats(&self) -> TrainingStats {
582
0
        let drift = self.detect_concept_drift();
583
584
        // Count feedback types
585
0
        let accepted_count = self
586
0
            .feedback
587
0
            .iter()
588
0
            .filter(|f| **f == UserFeedback::Accepted)
589
0
            .count();
590
0
        let rejected_count = self
591
0
            .feedback
592
0
            .iter()
593
0
            .filter(|f| **f == UserFeedback::Rejected)
594
0
            .count();
595
0
        let alternative_count = self
596
0
            .feedback
597
0
            .iter()
598
0
            .filter(|f| **f == UserFeedback::Alternative)
599
0
            .count();
600
601
0
        TrainingStats {
602
0
            total_samples: self.samples.len(),
603
0
            samples_since_training: drift.samples_since_training,
604
0
            accepted_count,
605
0
            rejected_count,
606
0
            alternative_count,
607
0
            staleness_score: drift.staleness_score,
608
0
            drift_detected: drift.drift_detected,
609
0
            online_learning_enabled: self.online_learning_enabled,
610
0
        }
611
0
    }
612
613
    /// Auto-retrain and update BrickTuner if conditions are met
614
0
    pub fn auto_retrain(&mut self, tuner: &mut BrickTuner) -> bool {
615
0
        if !self.should_retrain() {
616
0
            return false;
617
0
        }
618
619
        // Weight samples by feedback
620
0
        let training_data = self.prepare_weighted_training_data();
621
622
0
        if training_data.len() < 10 {
623
0
            return false;
624
0
        }
625
626
        // Train and update
627
0
        match tuner.train(&training_data) {
628
            Ok(()) => {
629
0
                self.mark_trained();
630
0
                true
631
            }
632
0
            Err(_) => false,
633
        }
634
0
    }
635
636
    /// Prepare training data with feedback weighting
637
0
    fn prepare_weighted_training_data(&self) -> Vec<(TunerFeatures, f32)> {
638
0
        self.samples
639
0
            .iter()
640
0
            .enumerate()
641
0
            .filter_map(|(i, s)| {
642
0
                let feedback = self.get_feedback(i);
643
644
                // Skip rejected samples (they had bad throughput measurements)
645
0
                if feedback == UserFeedback::Rejected {
646
0
                    return None;
647
0
                }
648
649
                // Weight accepted samples higher (duplicate them)
650
0
                let weight = match feedback {
651
0
                    UserFeedback::Accepted => 2,
652
0
                    UserFeedback::Alternative => 1, // Still use, but normal weight
653
0
                    _ => 1,
654
                };
655
656
0
                Some((0..weight).map(|_| (s.features.clone(), s.throughput_tps)))
657
0
            })
658
0
            .flatten()
659
0
            .collect()
660
0
    }
661
}