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