Skip to main content

lens_core/benchmark/
pinned_loader.rs

1//! Pinned Dataset Loader Implementation
2//!
3//! Handles loading and validation of pinned golden datasets for consistent benchmarking.
4//! Supports the format described in CLAUDE.md with 390 golden queries in version
5//! "08653c1e-2025-09-01T21-51-35-302Z".
6
7use super::types::{
8    PinnedDataset, DatasetMetadata, DatasetVersion, ValidationResult, 
9    InconsistentQuery, LoadingError, BenchmarkConfig, GoldenQuery
10};
11use anyhow::{anyhow, Result};
12use serde_json;
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use tokio::fs;
17use tokio::sync::RwLock;
18use tracing::{info, warn, error, debug};
19use chrono::{DateTime, Utc};
20
21/// Pinned dataset loader with caching and validation capabilities
22pub struct PinnedDatasetLoader {
23    config: BenchmarkConfig,
24    dataset_cache: Arc<RwLock<HashMap<String, Arc<PinnedDataset>>>>,
25    available_versions: Arc<RwLock<Vec<DatasetVersion>>>,
26}
27
28impl PinnedDatasetLoader {
29    /// Create new loader with default configuration
30    pub async fn new() -> Result<Self> {
31        let config = BenchmarkConfig::default();
32        Self::with_config(config).await
33    }
34
35    /// Create loader with custom configuration
36    pub async fn with_config(config: BenchmarkConfig) -> Result<Self> {
37        info!("๐Ÿ”ง Initializing pinned dataset loader");
38        debug!("Dataset path: {}", config.dataset_path);
39
40        let loader = Self {
41            config,
42            dataset_cache: Arc::new(RwLock::new(HashMap::new())),
43            available_versions: Arc::new(RwLock::new(Vec::new())),
44        };
45
46        // Discover available datasets if enabled
47        if loader.config.auto_discover_datasets {
48            if let Err(e) = loader.discover_available_versions().await {
49                warn!("Failed to discover datasets: {}", e);
50                // Continue without discovery - we'll try direct loading
51            }
52        }
53
54        info!("โœ… Pinned dataset loader initialized");
55        Ok(loader)
56    }
57
58    /// Load the current pinned dataset (golden-pinned-current.json or default version)
59    pub async fn load_current_pinned_dataset(&self) -> Result<PinnedDataset> {
60        debug!("๐Ÿ” Loading current pinned dataset");
61
62        // Try to load current dataset symlink first
63        let current_path = PathBuf::from(&self.config.dataset_path)
64            .join("golden-pinned-current.json");
65
66        if current_path.exists() {
67            info!("๐Ÿ“„ Loading current dataset from: {}", current_path.display());
68            return self.load_dataset_from_file(&current_path).await;
69        }
70
71        // Fall back to default version
72        if let Some(ref default_version) = self.config.default_version {
73            info!("๐Ÿ“„ Loading default version: {}", default_version);
74            return self.load_pinned_dataset_version(default_version).await;
75        }
76
77        // Try to find any available pinned dataset
78        let versions = self.list_available_versions().await?;
79        if !versions.is_empty() {
80            let latest = &versions[0]; // Assume first is latest
81            info!("๐Ÿ“„ Loading latest available version: {}", latest.version);
82            return self.load_dataset_from_file(&latest.file_path).await;
83        }
84
85        // Last resort: create a mock dataset for development
86        warn!("โš ๏ธ No pinned datasets found, creating mock dataset for development");
87        Ok(self.create_mock_dataset())
88    }
89
90    /// Load specific pinned dataset version
91    pub async fn load_pinned_dataset_version(&self, version: &str) -> Result<PinnedDataset> {
92        debug!("๐Ÿ” Loading pinned dataset version: {}", version);
93
94        // Check cache first if caching is enabled
95        if self.config.enable_caching {
96            let cache = self.dataset_cache.read().await;
97            if let Some(cached) = cache.get(version) {
98                info!("๐Ÿ’พ Loaded dataset from cache: {}", version);
99                return Ok((**cached).clone());
100            }
101        }
102
103        // Construct file path for the version
104        let filename = format!("golden-pinned-{}.json", version);
105        let file_path = PathBuf::from(&self.config.dataset_path).join(&filename);
106
107        if !file_path.exists() {
108            return Err(anyhow!(LoadingError::VersionNotFound {
109                version: version.to_string()
110            }));
111        }
112
113        info!("๐Ÿ“„ Loading dataset from: {}", file_path.display());
114        let dataset = self.load_dataset_from_file(&file_path).await?;
115
116        // Cache the loaded dataset if caching is enabled
117        if self.config.enable_caching {
118            self.cache_dataset(version, &dataset).await;
119        }
120
121        Ok(dataset)
122    }
123
124    /// Load dataset from a specific file path
125    async fn load_dataset_from_file(&self, file_path: &Path) -> Result<PinnedDataset> {
126        let start_time = std::time::Instant::now();
127
128        let content = fs::read_to_string(file_path).await
129            .map_err(|e| anyhow!(LoadingError::IoError { source: e }))?;
130
131        // Try to parse as PinnedDataset format first
132        if let Ok(dataset) = serde_json::from_str::<PinnedDataset>(&content) {
133            let duration = start_time.elapsed();
134            info!("โœ… Loaded pinned dataset: {} queries in {:?}", 
135                  dataset.queries.len(), duration);
136            return Ok(dataset);
137        }
138
139        // Fall back to simple JSON array format (legacy compatibility)
140        match serde_json::from_str::<Vec<GoldenQuery>>(&content) {
141            Ok(queries) => {
142                info!("๐Ÿ“ Converting legacy dataset format to PinnedDataset");
143                let dataset = self.convert_legacy_to_pinned(queries, file_path)?;
144                
145                let duration = start_time.elapsed();
146                info!("โœ… Converted and loaded dataset: {} queries in {:?}", 
147                      dataset.queries.len(), duration);
148                Ok(dataset)
149            }
150            Err(e) => {
151                error!("โŒ Failed to parse dataset from {}: {}", file_path.display(), e);
152                Err(anyhow!(LoadingError::JsonParseError { source: e }))
153            }
154        }
155    }
156
157    /// Convert legacy query array format to full PinnedDataset
158    fn convert_legacy_to_pinned(&self, queries: Vec<GoldenQuery>, file_path: &Path) -> Result<PinnedDataset> {
159        let version = file_path.file_stem()
160            .and_then(|s| s.to_str())
161            .unwrap_or("unknown")
162            .replace("golden-pinned-", "");
163
164        let mut query_distribution = HashMap::new();
165        let mut languages = std::collections::HashSet::new();
166
167        for query in &queries {
168            *query_distribution.entry(query.query_type.clone()).or_insert(0) += 1;
169            if let Some(ref lang) = query.language {
170                languages.insert(lang.clone());
171            }
172        }
173
174        let metadata = DatasetMetadata {
175            version: version.clone(),
176            name: "storyviz-pinned".to_string(),
177            created_at: Utc::now(),
178            total_queries: queries.len(),
179            query_distribution,
180            languages: languages.into_iter().collect(),
181            git_sha: None,
182            additional_metadata: HashMap::new(),
183        };
184
185        // Create basic slices
186        let mut slices = HashMap::new();
187        let all_indices: Vec<usize> = (0..queries.len()).collect();
188        slices.insert("ALL".to_string(), all_indices.clone());
189        
190        // Create SMOKE_DEFAULT slice (first 40 queries as mentioned in CLAUDE.md)
191        let smoke_size = std::cmp::min(40, queries.len());
192        slices.insert("SMOKE_DEFAULT".to_string(), (0..smoke_size).collect());
193
194        Ok(PinnedDataset {
195            metadata,
196            queries,
197            slices,
198            corpus_info: None,
199        })
200    }
201
202    /// Validate dataset consistency against corpus
203    pub async fn validate_dataset_consistency(&self, dataset: &PinnedDataset) -> Result<ValidationResult> {
204        info!("๐Ÿ” Validating corpus consistency for {} queries", dataset.queries.len());
205        
206        let mut valid_queries = 0;
207        let mut inconsistent_queries = Vec::new();
208
209        // Check for indexed-content or similar corpus directory
210        let corpus_paths = [
211            "indexed-content",
212            "benchmark-corpus", 
213            "src",
214            "rust-core/src"
215        ];
216
217        let corpus_path = corpus_paths.iter()
218            .find(|path| std::path::Path::new(path).exists())
219            .map(|&s| s.to_string());
220
221        if corpus_path.is_none() {
222            warn!("โš ๏ธ No corpus directory found for validation");
223            return Ok(ValidationResult {
224                is_consistent: false,
225                valid_queries: 0,
226                total_queries: dataset.queries.len(),
227                inconsistent_queries,
228                validated_at: Utc::now(),
229            });
230        }
231
232        let corpus_path = corpus_path.unwrap();
233        debug!("๐Ÿ“ Using corpus path: {}", corpus_path);
234
235        // Validate each query
236        for query in &dataset.queries {
237            let mut query_valid = true;
238            let mut missing_files = Vec::new();
239
240            // Check if expected files exist in corpus
241            for expected_file in &query.expected_files {
242                // Handle different path formats
243                let file_paths_to_check = vec![
244                    PathBuf::from(&corpus_path).join(expected_file),
245                    PathBuf::from(expected_file),
246                    PathBuf::from(&corpus_path).join(
247                        expected_file.strip_prefix("indexed-content/").unwrap_or(expected_file)
248                    ),
249                ];
250
251                let file_exists = file_paths_to_check.iter().any(|path| path.exists());
252                
253                if !file_exists {
254                    query_valid = false;
255                    missing_files.push(expected_file.clone());
256                }
257            }
258
259            if query_valid {
260                valid_queries += 1;
261            } else {
262                inconsistent_queries.push(InconsistentQuery {
263                    query: query.clone(),
264                    failure_reason: "Expected files not found in corpus".to_string(),
265                    missing_files,
266                });
267            }
268        }
269
270        let is_consistent = valid_queries == dataset.queries.len();
271        let consistency_rate = valid_queries as f64 / dataset.queries.len() as f64 * 100.0;
272
273        if is_consistent {
274            info!("โœ… Perfect corpus consistency: {}/{} queries (100%)", 
275                  valid_queries, dataset.queries.len());
276        } else {
277            warn!("โš ๏ธ Partial corpus consistency: {}/{} queries ({:.1}%)", 
278                  valid_queries, dataset.queries.len(), consistency_rate);
279        }
280
281        Ok(ValidationResult {
282            is_consistent,
283            valid_queries,
284            total_queries: dataset.queries.len(),
285            inconsistent_queries,
286            validated_at: Utc::now(),
287        })
288    }
289
290    /// Discover available dataset versions in the dataset directory
291    async fn discover_available_versions(&self) -> Result<()> {
292        debug!("๐Ÿ” Discovering available dataset versions");
293        
294        let dataset_dir = Path::new(&self.config.dataset_path);
295        if !dataset_dir.exists() {
296            warn!("Dataset directory does not exist: {}", dataset_dir.display());
297            return Ok(());
298        }
299
300        let mut entries = fs::read_dir(dataset_dir).await?;
301        let mut versions = Vec::new();
302
303        while let Some(entry) = entries.next_entry().await? {
304            let path = entry.path();
305            let filename = path.file_name()
306                .and_then(|n| n.to_str())
307                .unwrap_or("");
308
309            // Look for pinned dataset files
310            if filename.starts_with("golden-pinned-") && filename.ends_with(".json") {
311                let version = filename
312                    .strip_prefix("golden-pinned-")
313                    .and_then(|s| s.strip_suffix(".json"))
314                    .unwrap_or("unknown")
315                    .to_string();
316
317                if version != "current" { // Skip the symlink
318                    let metadata = entry.metadata().await?;
319                    let created_at = metadata.created().ok()
320                        .and_then(|t| DateTime::from_timestamp(
321                            t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs() as i64, 0
322                        ))
323                        .unwrap_or_else(|| Utc::now());
324
325                    // Try to get query count by loading the dataset
326                    let query_count = match self.load_dataset_from_file(&path).await {
327                        Ok(dataset) => dataset.queries.len(),
328                        Err(_) => 0,
329                    };
330
331                    versions.push(DatasetVersion {
332                        version: version.clone(),
333                        created_at,
334                        query_count,
335                        file_path: path,
336                        is_current: version == self.config.default_version.as_deref().unwrap_or(""),
337                    });
338                }
339            }
340        }
341
342        // Sort by creation time (newest first)
343        versions.sort_by(|a, b| b.created_at.cmp(&a.created_at));
344
345        let count = versions.len();
346        *self.available_versions.write().await = versions;
347        
348        info!("๐Ÿ“Š Discovered {} dataset versions", count);
349        Ok(())
350    }
351
352    /// List available dataset versions
353    pub async fn list_available_versions(&self) -> Result<Vec<DatasetVersion>> {
354        Ok(self.available_versions.read().await.clone())
355    }
356
357    /// Cache a dataset in memory
358    async fn cache_dataset(&self, version: &str, dataset: &PinnedDataset) {
359        if !self.config.enable_caching {
360            return;
361        }
362
363        let mut cache = self.dataset_cache.write().await;
364        
365        // Enforce cache size limit
366        if cache.len() >= self.config.max_cache_size {
367            // Remove oldest entry (simple LRU approximation)
368            if let Some(oldest_key) = cache.keys().next().cloned() {
369                cache.remove(&oldest_key);
370            }
371        }
372
373        cache.insert(version.to_string(), Arc::new(dataset.clone()));
374        debug!("๐Ÿ’พ Cached dataset version: {}", version);
375    }
376
377    /// Create a mock dataset for development when no pinned datasets exist
378    fn create_mock_dataset(&self) -> PinnedDataset {
379        let mock_queries = vec![
380            GoldenQuery {
381                query: "function".to_string(),
382                expected_files: vec!["src/main.rs".to_string()],
383                query_type: super::types::QueryType::Identifier,
384                metadata: HashMap::new(),
385                language: Some("rust".to_string()),
386                confidence: Some(0.8),
387            },
388            GoldenQuery {
389                query: "struct SearchEngine".to_string(),
390                expected_files: vec!["src/search.rs".to_string()],
391                query_type: super::types::QueryType::Identifier,
392                metadata: HashMap::new(),
393                language: Some("rust".to_string()),
394                confidence: Some(0.9),
395            },
396        ];
397
398        let mut query_distribution = HashMap::new();
399        query_distribution.insert(super::types::QueryType::Identifier, mock_queries.len());
400
401        let metadata = DatasetMetadata {
402            version: "mock-dev".to_string(),
403            name: "mock-development-dataset".to_string(),
404            created_at: Utc::now(),
405            total_queries: mock_queries.len(),
406            query_distribution,
407            languages: vec!["rust".to_string()],
408            git_sha: None,
409            additional_metadata: HashMap::new(),
410        };
411
412        let mut slices = HashMap::new();
413        slices.insert("ALL".to_string(), vec![0, 1]);
414        slices.insert("SMOKE_DEFAULT".to_string(), vec![0, 1]);
415
416        PinnedDataset {
417            metadata,
418            queries: mock_queries,
419            slices,
420            corpus_info: None,
421        }
422    }
423
424    /// Get dataset slice by name
425    pub fn get_dataset_slice(&self, dataset: &PinnedDataset, slice_name: &str) -> Option<Vec<GoldenQuery>> {
426        if let Some(indices) = dataset.slices.get(slice_name) {
427            let slice_queries = indices.iter()
428                .filter_map(|&idx| dataset.queries.get(idx).cloned())
429                .collect();
430            Some(slice_queries)
431        } else {
432            None
433        }
434    }
435
436    /// Get SMOKE test dataset (subset for quick validation)
437    pub fn get_smoke_dataset(&self, dataset: &PinnedDataset) -> Vec<GoldenQuery> {
438        self.get_dataset_slice(dataset, "SMOKE_DEFAULT")
439            .unwrap_or_else(|| {
440                // Fallback: take first 40 queries
441                let smoke_size = std::cmp::min(40, dataset.queries.len());
442                dataset.queries.iter().take(smoke_size).cloned().collect()
443            })
444    }
445
446    /// Clear dataset cache
447    pub async fn clear_cache(&self) {
448        let mut cache = self.dataset_cache.write().await;
449        cache.clear();
450        info!("๐Ÿงน Cleared dataset cache");
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use tempfile::TempDir;
458    use tokio::fs::File;
459    use tokio::io::AsyncWriteExt;
460
461    async fn create_test_dataset_file(dir: &Path, filename: &str, queries: Vec<GoldenQuery>) -> Result<PathBuf> {
462        let file_path = dir.join(filename);
463        let mut file = File::create(&file_path).await?;
464        
465        // Extract version from filename (e.g., "golden-pinned-test-version.json" -> "test-version")
466        let version = filename
467            .strip_prefix("golden-pinned-")
468            .and_then(|s| s.strip_suffix(".json"))
469            .unwrap_or("test");
470        
471        let dataset = PinnedDataset {
472            metadata: DatasetMetadata {
473                version: version.to_string(),
474                name: "test-dataset".to_string(),
475                created_at: Utc::now(),
476                total_queries: queries.len(),
477                query_distribution: HashMap::new(),
478                languages: vec!["rust".to_string()],
479                git_sha: None,
480                additional_metadata: HashMap::new(),
481            },
482            queries,
483            slices: HashMap::new(),
484            corpus_info: None,
485        };
486
487        let json = serde_json::to_string_pretty(&dataset)?;
488        file.write_all(json.as_bytes()).await?;
489        
490        Ok(file_path)
491    }
492
493    #[tokio::test]
494    async fn test_pinned_loader_creation() {
495        let temp_dir = TempDir::new().unwrap();
496        let config = BenchmarkConfig {
497            dataset_path: temp_dir.path().to_string_lossy().to_string(),
498            auto_discover_datasets: false,
499            ..Default::default()
500        };
501
502        let loader = PinnedDatasetLoader::with_config(config).await.unwrap();
503        assert!(!loader.config.dataset_path.is_empty());
504    }
505
506    #[tokio::test]
507    async fn test_mock_dataset_creation() {
508        let temp_dir = TempDir::new().unwrap();
509        let config = BenchmarkConfig {
510            dataset_path: temp_dir.path().to_string_lossy().to_string(),
511            auto_discover_datasets: false,
512            default_version: None,  // No default version in temp dir, should fall back to mock
513            ..Default::default()
514        };
515
516        let loader = PinnedDatasetLoader::with_config(config).await.unwrap();
517        let dataset = loader.load_current_pinned_dataset().await.unwrap();
518        
519        assert!(!dataset.queries.is_empty());
520        assert_eq!(dataset.metadata.version, "mock-dev");
521    }
522
523    #[tokio::test]
524    async fn test_dataset_loading_and_caching() {
525        let temp_dir = TempDir::new().unwrap();
526        let config = BenchmarkConfig {
527            dataset_path: temp_dir.path().to_string_lossy().to_string(),
528            enable_caching: true,
529            auto_discover_datasets: false,
530            default_version: None,  // No default version in temp dir
531            ..Default::default()
532        };
533
534        // Create a test dataset file
535        let test_queries = vec![
536            GoldenQuery {
537                query: "test query".to_string(),
538                expected_files: vec!["test.rs".to_string()],
539                query_type: super::super::types::QueryType::Identifier,
540                metadata: HashMap::new(),
541                language: None,
542                confidence: None,
543            }
544        ];
545
546        let _file_path = create_test_dataset_file(
547            temp_dir.path(), 
548            "golden-pinned-test-version.json", 
549            test_queries
550        ).await.unwrap();
551
552        let loader = PinnedDatasetLoader::with_config(config).await.unwrap();
553        
554        // Load dataset twice - second should come from cache
555        let dataset1 = loader.load_pinned_dataset_version("test-version").await.unwrap();
556        let dataset2 = loader.load_pinned_dataset_version("test-version").await.unwrap();
557        
558        assert_eq!(dataset1.queries.len(), dataset2.queries.len());
559        assert_eq!(dataset1.metadata.version, "test-version");
560    }
561}