## 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
    }