Skip to main content

torsh_tensor/
lazy_loading.rs

1//! Lazy Loading for Memory-Mapped Tensor Data
2//!
3//! This module provides lazy loading capabilities for tensor data stored in memory-mapped files.
4//! Data is loaded on-demand when first accessed, allowing for efficient handling of large datasets
5//! that may not fit entirely in memory.
6//!
7//! # Features
8//!
9//! - **On-demand loading**: Data is loaded only when accessed
10//! - **Chunk-based access**: Supports loading specific regions of large tensors
11//! - **Caching**: Keeps recently accessed chunks in memory
12//! - **Memory pressure handling**: Unloads chunks when memory pressure is high
13//! - **Multi-threaded access**: Thread-safe concurrent access to lazy-loaded data
14
15use std::collections::HashMap;
16use std::fs::File;
17use std::io::{Read, Seek, SeekFrom};
18use std::path::{Path, PathBuf};
19use std::sync::{Arc, Mutex, RwLock};
20use std::time::{Duration, Instant};
21use torsh_core::sync::{MutexExt, RwLockExt};
22
23use torsh_core::{
24    dtype::TensorElement,
25    error::{Result, TorshError},
26    shape::Shape,
27};
28
29/// Configuration for lazy loading behavior
30#[derive(Debug, Clone)]
31pub struct LazyLoadConfig {
32    /// Size of chunks to load at once (in elements)
33    pub chunk_size: usize,
34    /// Maximum number of chunks to keep in cache
35    pub max_cached_chunks: usize,
36    /// Time to keep unused chunks in cache before unloading
37    pub cache_ttl: Duration,
38    /// Memory pressure threshold for aggressive cleanup
39    pub memory_pressure_threshold: usize,
40}
41
42impl Default for LazyLoadConfig {
43    fn default() -> Self {
44        Self {
45            chunk_size: 1024 * 1024, // 1M elements per chunk
46            max_cached_chunks: 16,
47            cache_ttl: Duration::from_secs(300), // 5 minutes
48            memory_pressure_threshold: 1024 * 1024 * 1024, // 1GB
49        }
50    }
51}
52
53/// Metadata for a lazily-loaded tensor
54#[derive(Debug, Clone)]
55pub struct LazyTensorMetadata {
56    /// Shape of the tensor
57    pub shape: Shape,
58    /// Data type name
59    pub dtype: String,
60    /// Total size in elements
61    pub total_elements: usize,
62    /// Size of each element in bytes
63    pub element_size: usize,
64    /// File offset where data begins
65    pub data_offset: u64,
66}
67
68/// A cached chunk of tensor data
69#[derive(Debug, Clone)]
70struct CachedChunk<T: TensorElement> {
71    /// The actual data
72    data: Vec<T>,
73    /// Index range this chunk covers (start, end)
74    range: (usize, usize),
75    /// Last access time for TTL management
76    last_accessed: Instant,
77}
78
79/// Lazy-loaded tensor data backed by a memory-mapped file
80pub struct LazyTensor<T: TensorElement> {
81    /// Metadata about the tensor
82    metadata: LazyTensorMetadata,
83    /// File backing the data
84    file: Arc<Mutex<File>>,
85    /// Path to the backing file
86    #[allow(dead_code)]
87    file_path: PathBuf,
88    /// Cache of loaded chunks
89    chunk_cache: Arc<RwLock<HashMap<usize, CachedChunk<T>>>>,
90    /// Configuration
91    config: LazyLoadConfig,
92    /// Element type marker
93    _phantom: std::marker::PhantomData<T>,
94}
95
96impl<T: TensorElement> LazyTensor<T> {
97    /// Create a new lazy tensor from a file
98    ///
99    /// # Arguments
100    /// * `file_path` - Path to the file containing tensor data
101    /// * `metadata` - Metadata describing the tensor layout
102    /// * `config` - Configuration for lazy loading behavior
103    ///
104    /// # Returns
105    /// * `Result<LazyTensor<T>>` - The lazy tensor or error
106    pub fn new<P: AsRef<Path>>(
107        file_path: P,
108        metadata: LazyTensorMetadata,
109        config: LazyLoadConfig,
110    ) -> Result<Self> {
111        let file_path = file_path.as_ref().to_path_buf();
112        let file = File::open(&file_path)
113            .map_err(|e| TorshError::IoError(format!("Failed to open file: {}", e)))?;
114
115        Ok(Self {
116            metadata,
117            file: Arc::new(Mutex::new(file)),
118            file_path,
119            chunk_cache: Arc::new(RwLock::new(HashMap::new())),
120            config,
121            _phantom: std::marker::PhantomData,
122        })
123    }
124
125    /// Get the shape of the tensor
126    pub fn shape(&self) -> &Shape {
127        &self.metadata.shape
128    }
129
130    /// Get the total number of elements
131    pub fn len(&self) -> usize {
132        self.metadata.total_elements
133    }
134
135    /// Check if the tensor is empty
136    pub fn is_empty(&self) -> bool {
137        self.metadata.total_elements == 0
138    }
139}
140
141/// Methods that load tensor data from the backing file.
142///
143/// These require `T: bytemuck::Pod` in addition to `TensorElement` because
144/// `LazyTensor::load_chunk_from_file` reinterprets raw file bytes as `[T]`.
145/// That reinterpretation is only sound for "plain old data" types where every
146/// bit pattern is a valid `T` and there is no uninitialized padding to worry
147/// about (`bytemuck::Pod`) -- notably this excludes `bool` (not every byte is
148/// 0/1) even though `bool: TensorElement`, since blindly trusting arbitrary
149/// bytes read from disk to be a valid `bool` would itself be unsound.
150impl<T: TensorElement + bytemuck::Pod> LazyTensor<T> {
151    /// Load a specific element by flat index
152    ///
153    /// # Arguments
154    /// * `index` - Flat index of the element to load
155    ///
156    /// # Returns
157    /// * `Result<T>` - The element value or error
158    pub fn get_element(&self, index: usize) -> Result<T> {
159        if index >= self.metadata.total_elements {
160            return Err(TorshError::InvalidArgument(format!(
161                "Index {} out of bounds for tensor with {} elements",
162                index, self.metadata.total_elements
163            )));
164        }
165
166        let chunk_index = index / self.config.chunk_size;
167        let chunk_offset = index % self.config.chunk_size;
168
169        let chunk = self.load_chunk(chunk_index)?;
170        Ok(chunk.data[chunk_offset])
171    }
172
173    /// Load a range of elements
174    ///
175    /// # Arguments
176    /// * `start` - Starting index (inclusive)
177    /// * `end` - Ending index (exclusive)
178    ///
179    /// # Returns
180    /// * `Result<Vec<T>>` - The loaded elements or error
181    pub fn get_range(&self, start: usize, end: usize) -> Result<Vec<T>> {
182        if start > end || end > self.metadata.total_elements {
183            return Err(TorshError::InvalidArgument(format!(
184                "Invalid range [{}..{}] for tensor with {} elements",
185                start, end, self.metadata.total_elements
186            )));
187        }
188
189        let mut result = Vec::with_capacity(end - start);
190        let start_chunk = start / self.config.chunk_size;
191        let end_chunk = (end - 1) / self.config.chunk_size;
192
193        for chunk_idx in start_chunk..=end_chunk {
194            let chunk = self.load_chunk(chunk_idx)?;
195
196            let chunk_start = chunk_idx * self.config.chunk_size;
197            let chunk_end = std::cmp::min(
198                (chunk_idx + 1) * self.config.chunk_size,
199                self.metadata.total_elements,
200            );
201
202            let range_start = std::cmp::max(start, chunk_start) - chunk_start;
203            let range_end = std::cmp::min(end, chunk_end) - chunk_start;
204
205            result.extend_from_slice(&chunk.data[range_start..range_end]);
206        }
207
208        Ok(result)
209    }
210
211    /// Load all data (use with caution for large tensors)
212    ///
213    /// # Returns
214    /// * `Result<Vec<T>>` - All tensor data or error
215    pub fn load_all(&self) -> Result<Vec<T>> {
216        self.get_range(0, self.metadata.total_elements)
217    }
218
219    /// Load a specific chunk into cache
220    fn load_chunk(&self, chunk_index: usize) -> Result<Arc<CachedChunk<T>>> {
221        // Check if chunk is already cached
222        {
223            let cache = self.chunk_cache.read_or_recover();
224            if let Some(cached) = cache.get(&chunk_index) {
225                // Update access time and return cached chunk
226                return Ok(Arc::new(CachedChunk {
227                    data: cached.data.clone(),
228                    range: cached.range,
229                    last_accessed: Instant::now(),
230                }));
231            }
232        }
233
234        // Calculate chunk boundaries
235        let start_element = chunk_index * self.config.chunk_size;
236        let end_element = std::cmp::min(
237            (chunk_index + 1) * self.config.chunk_size,
238            self.metadata.total_elements,
239        );
240        let chunk_size = end_element - start_element;
241
242        // Load data from file
243        let data = self.load_chunk_from_file(start_element, chunk_size)?;
244
245        let chunk = Arc::new(CachedChunk {
246            data,
247            range: (start_element, end_element),
248            last_accessed: Instant::now(),
249        });
250
251        // Add to cache
252        {
253            let mut cache = self.chunk_cache.write_or_recover();
254
255            // Clean up cache if needed
256            self.cleanup_cache(&mut cache);
257
258            cache.insert(chunk_index, (*chunk).clone());
259        }
260
261        Ok(chunk)
262    }
263
264    /// Load chunk data directly from file
265    ///
266    /// Reads `chunk_size` elements of `T` (`self.metadata.element_size` bytes each,
267    /// which must equal `size_of::<T>()`) starting at `start_element` and safely
268    /// reinterprets the raw bytes as a `Vec<T>`.
269    ///
270    /// # Why this is safe
271    /// A prior version of this function read bytes into a `Vec<u8>` (which is only
272    /// guaranteed 1-byte alignment) and then reinterpreted that buffer's raw pointer
273    /// as `*const T` via `std::slice::from_raw_parts`. That is undefined behavior
274    /// whenever the allocator doesn't happen to over-align the `Vec<u8>` allocation
275    /// to `align_of::<T>()` -- over-alignment is not part of the allocator's
276    /// contract (Miri's allocator deliberately does not over-align small
277    /// allocations, and correctly flagged this as "constructing invalid value:
278    /// encountered an unaligned reference").
279    ///
280    /// Instead, [`bytemuck::pod_collect_to_vec`] copies the raw bytes into a
281    /// freshly-allocated `Vec<T>`. Rust always allocates a `Vec<T>`'s backing
282    /// storage via `Layout::array::<T>()`, so that destination is guaranteed to be
283    /// correctly aligned for `T` from the start; the copy itself is a plain
284    /// byte-for-byte `memcpy` with no typed load through a misaligned pointer.
285    /// This requires `T: bytemuck::Pod` (see the impl block), which additionally
286    /// guarantees that any bit pattern read from the file is a valid `T`.
287    fn load_chunk_from_file(&self, start_element: usize, chunk_size: usize) -> Result<Vec<T>> {
288        let mut file = self.file.lock_or_recover();
289
290        let file_offset =
291            self.metadata.data_offset + (start_element as u64 * self.metadata.element_size as u64);
292
293        file.seek(SeekFrom::Start(file_offset))
294            .map_err(|e| TorshError::IoError(format!("Failed to seek: {}", e)))?;
295
296        // The on-disk element size recorded in the metadata must agree with the
297        // actual in-memory size of `T`; otherwise `chunk_size` elements of `T`
298        // would not correspond to exactly `chunk_size * self.metadata.element_size`
299        // bytes, and the buffer would be silently misinterpreted (too few or too
300        // many elements) rather than erroring out cleanly.
301        let element_size = std::mem::size_of::<T>();
302        if self.metadata.element_size != element_size {
303            return Err(TorshError::InvalidArgument(format!(
304                "Tensor element size mismatch: file metadata declares {} byte(s) per \
305                 element but `{}` is {} byte(s)",
306                self.metadata.element_size,
307                std::any::type_name::<T>(),
308                element_size
309            )));
310        }
311
312        let mut buffer = vec![0u8; chunk_size * element_size];
313        file.read_exact(&mut buffer)
314            .map_err(|e| TorshError::IoError(format!("Failed to read chunk: {}", e)))?;
315
316        // Safe, alignment-agnostic byte-to-element conversion (see doc comment
317        // above). `buffer.len() == chunk_size * size_of::<T>()` exactly (checked
318        // above), so the result is guaranteed to have exactly `chunk_size` elements.
319        let data: Vec<T> = bytemuck::pod_collect_to_vec(&buffer);
320
321        Ok(data)
322    }
323}
324
325impl<T: TensorElement> LazyTensor<T> {
326    /// Clean up old cached chunks
327    fn cleanup_cache(&self, cache: &mut HashMap<usize, CachedChunk<T>>) {
328        let now = Instant::now();
329
330        // Remove expired chunks
331        cache.retain(|_, chunk| now.duration_since(chunk.last_accessed) < self.config.cache_ttl);
332
333        // If we still have too many chunks, remove the oldest ones
334        if cache.len() > self.config.max_cached_chunks {
335            let mut chunks_to_remove: Vec<_> = cache
336                .iter()
337                .map(|(idx, chunk)| (*idx, chunk.last_accessed))
338                .collect();
339            chunks_to_remove.sort_by_key(|(_, accessed)| *accessed);
340
341            let to_remove = cache.len() - self.config.max_cached_chunks;
342            for (idx, _) in chunks_to_remove.iter().take(to_remove) {
343                cache.remove(idx);
344            }
345        }
346    }
347
348    /// Get cache statistics
349    pub fn cache_stats(&self) -> CacheStats {
350        let cache = self.chunk_cache.read_or_recover();
351
352        let total_cached_elements: usize = cache.values().map(|chunk| chunk.data.len()).sum();
353
354        CacheStats {
355            cached_chunks: cache.len(),
356            total_cached_elements,
357            estimated_memory_usage: total_cached_elements * std::mem::size_of::<T>(),
358        }
359    }
360
361    /// Force cleanup of all cached chunks
362    pub fn clear_cache(&self) {
363        let mut cache = self.chunk_cache.write_or_recover();
364        cache.clear();
365    }
366
367    /// Check memory pressure and cleanup if necessary
368    pub fn check_memory_pressure(&self) -> Result<()> {
369        let stats = self.cache_stats();
370
371        if stats.estimated_memory_usage > self.config.memory_pressure_threshold {
372            let mut cache = self.chunk_cache.write_or_recover();
373
374            // Aggressive cleanup - keep only recently accessed chunks
375            let recent_threshold = Duration::from_secs(60);
376            let now = Instant::now();
377
378            cache.retain(|_, chunk| now.duration_since(chunk.last_accessed) < recent_threshold);
379        }
380
381        Ok(())
382    }
383}
384
385/// Statistics about cached chunks
386#[derive(Debug, Clone)]
387pub struct CacheStats {
388    /// Number of chunks currently cached
389    pub cached_chunks: usize,
390    /// Total number of elements in cache
391    pub total_cached_elements: usize,
392    /// Estimated memory usage in bytes
393    pub estimated_memory_usage: usize,
394}
395
396/// Builder for creating lazy tensors with custom configuration
397pub struct LazyTensorBuilder {
398    config: LazyLoadConfig,
399}
400
401impl LazyTensorBuilder {
402    /// Create a new builder with default configuration
403    pub fn new() -> Self {
404        Self {
405            config: LazyLoadConfig::default(),
406        }
407    }
408
409    /// Set the chunk size
410    pub fn chunk_size(mut self, size: usize) -> Self {
411        self.config.chunk_size = size;
412        self
413    }
414
415    /// Set the maximum number of cached chunks
416    pub fn max_cached_chunks(mut self, max: usize) -> Self {
417        self.config.max_cached_chunks = max;
418        self
419    }
420
421    /// Set the cache TTL
422    pub fn cache_ttl(mut self, ttl: Duration) -> Self {
423        self.config.cache_ttl = ttl;
424        self
425    }
426
427    /// Set the memory pressure threshold
428    pub fn memory_pressure_threshold(mut self, threshold: usize) -> Self {
429        self.config.memory_pressure_threshold = threshold;
430        self
431    }
432
433    /// Build the lazy tensor
434    pub fn build<T: TensorElement, P: AsRef<Path>>(
435        self,
436        file_path: P,
437        metadata: LazyTensorMetadata,
438    ) -> Result<LazyTensor<T>> {
439        LazyTensor::new(file_path, metadata, self.config)
440    }
441}
442
443impl Default for LazyTensorBuilder {
444    fn default() -> Self {
445        Self::new()
446    }
447}
448
449/// Utility functions for working with lazy tensors
450pub mod utils {
451    use super::*;
452    use std::fs::File;
453    use std::io::{BufReader, Read};
454    use torsh_core::dtype::DType;
455
456    /// Create a lazy tensor metadata from a binary file header
457    ///
458    /// This function reads tensor metadata from a binary file header
459    /// and creates appropriate LazyTensorMetadata.
460    pub fn create_metadata_from_header<P: AsRef<Path>>(file_path: P) -> Result<LazyTensorMetadata> {
461        let file = File::open(file_path)?;
462        let mut reader = BufReader::new(file);
463
464        // Read a simple header format (this is a placeholder - you'd implement
465        // the actual format parsing based on your serialization format)
466        let mut header_size_bytes = [0u8; 4];
467        reader
468            .read_exact(&mut header_size_bytes)
469            .map_err(|e| TorshError::IoError(format!("Failed to read header size: {}", e)))?;
470
471        let header_size = u32::from_le_bytes(header_size_bytes) as usize;
472
473        let mut header_data = vec![0u8; header_size];
474        reader
475            .read_exact(&mut header_data)
476            .map_err(|e| TorshError::IoError(format!("Failed to read header: {}", e)))?;
477
478        // Deserialize the JSON header object into concrete tensor metadata.
479        //
480        // The header is a compact JSON object describing the on-disk tensor, e.g.
481        // `{"shape":[10,10],"dtype":"f32","total_elements":100}`. The element size is
482        // derived from the dtype, and the data offset is the size prefix (4 bytes)
483        // plus the header length, i.e. the first byte position of the payload.
484        let header_str = String::from_utf8(header_data)
485            .map_err(|e| TorshError::SerializationError(format!("Invalid header: {}", e)))?;
486
487        let dims = parse_json_usize_array(&header_str, "shape").ok_or_else(|| {
488            TorshError::SerializationError(format!(
489                "Header is missing a valid 'shape' field: {}",
490                header_str
491            ))
492        })?;
493
494        let dtype = parse_json_string(&header_str, "dtype").ok_or_else(|| {
495            TorshError::SerializationError(format!(
496                "Header is missing a valid 'dtype' field: {}",
497                header_str
498            ))
499        })?;
500
501        // Derive the element size from the canonical dtype definition so it always
502        // matches the dtype that was recorded in the header.
503        let element_size = dtype
504            .parse::<DType>()
505            .map_err(|e| {
506                TorshError::SerializationError(format!("Unsupported dtype '{}': {}", dtype, e))
507            })?
508            .size();
509
510        // `total_elements` is optional: when present it must agree with the product
511        // of the shape dimensions, otherwise the header is internally inconsistent.
512        let shape_elements: usize = dims.iter().product();
513        let total_elements = match parse_json_usize(&header_str, "total_elements") {
514            Some(declared) if declared != shape_elements => {
515                return Err(TorshError::SerializationError(format!(
516                    "Header total_elements ({}) does not match shape product ({})",
517                    declared, shape_elements
518                )));
519            }
520            Some(declared) => declared,
521            None => shape_elements,
522        };
523
524        Ok(LazyTensorMetadata {
525            shape: Shape::new(dims),
526            dtype,
527            total_elements,
528            element_size,
529            data_offset: 4 + header_size as u64,
530        })
531    }
532
533    /// Locate the value substring immediately following a `"key":` token.
534    ///
535    /// Returns the slice after the colon (with leading whitespace trimmed), or
536    /// `None` when the key is not present in the JSON object string.
537    fn json_value_after_key<'a>(header: &'a str, key: &str) -> Option<&'a str> {
538        let quoted_key = format!("\"{}\"", key);
539        let key_pos = header.find(&quoted_key)?;
540        let after_key = &header[key_pos + quoted_key.len()..];
541        let colon_pos = after_key.find(':')?;
542        Some(after_key[colon_pos + 1..].trim_start())
543    }
544
545    /// Parse a quoted JSON string value for `key` (e.g. `"dtype":"f32"`).
546    fn parse_json_string(header: &str, key: &str) -> Option<String> {
547        let value = json_value_after_key(header, key)?;
548        let inner = value.strip_prefix('"')?;
549        let end = inner.find('"')?;
550        Some(inner[..end].to_string())
551    }
552
553    /// Parse an unsigned integer JSON value for `key` (e.g. `"total_elements":100`).
554    fn parse_json_usize(header: &str, key: &str) -> Option<usize> {
555        let value = json_value_after_key(header, key)?;
556        let digits: String = value.chars().take_while(|c| c.is_ascii_digit()).collect();
557        if digits.is_empty() {
558            return None;
559        }
560        digits.parse::<usize>().ok()
561    }
562
563    /// Parse a JSON array of unsigned integers for `key` (e.g. `"shape":[10,10]`).
564    fn parse_json_usize_array(header: &str, key: &str) -> Option<Vec<usize>> {
565        let value = json_value_after_key(header, key)?;
566        let inner = value.strip_prefix('[')?;
567        let end = inner.find(']')?;
568        let mut dims = Vec::new();
569        for part in inner[..end].split(',') {
570            let trimmed = part.trim();
571            if trimmed.is_empty() {
572                continue;
573            }
574            dims.push(trimmed.parse::<usize>().ok()?);
575        }
576        Some(dims)
577    }
578
579    /// Create a lazy tensor from a file path with automatic metadata detection
580    pub fn lazy_tensor_from_file<T: TensorElement, P: AsRef<Path>>(
581        file_path: P,
582    ) -> Result<LazyTensor<T>> {
583        let metadata = create_metadata_from_header(&file_path)?;
584        LazyTensor::new(file_path, metadata, LazyLoadConfig::default())
585    }
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591    use std::io::Write;
592    use tempfile::NamedTempFile;
593    use torsh_core::shape::Shape;
594
595    fn create_test_file() -> (NamedTempFile, LazyTensorMetadata) {
596        let mut temp_file = NamedTempFile::new().expect("temp file creation should succeed");
597
598        // Write a simple header (4 bytes for header size, then JSON metadata)
599        let header = r#"{"shape":[10,10],"dtype":"f32","total_elements":100}"#;
600        let header_size = header.len() as u32;
601        temp_file
602            .write_all(&header_size.to_le_bytes())
603            .expect("write should succeed");
604        temp_file
605            .write_all(header.as_bytes())
606            .expect("write should succeed");
607
608        // Write test data (100 f32 values)
609        for i in 0..100 {
610            temp_file
611                .write_all(&(i as f32).to_le_bytes())
612                .expect("write should succeed");
613        }
614        temp_file.flush().expect("flush should succeed");
615
616        let metadata = LazyTensorMetadata {
617            shape: Shape::new(vec![10, 10]),
618            dtype: "f32".to_string(),
619            total_elements: 100,
620            element_size: 4,
621            data_offset: 4 + header.len() as u64,
622        };
623
624        (temp_file, metadata)
625    }
626
627    #[test]
628    fn test_lazy_tensor_creation() {
629        let (temp_file, metadata) = create_test_file();
630
631        let lazy_tensor: LazyTensor<f32> =
632            LazyTensor::new(temp_file.path(), metadata, LazyLoadConfig::default())
633                .expect("lazy tensor creation should succeed");
634
635        assert_eq!(lazy_tensor.len(), 100);
636        assert!(!lazy_tensor.is_empty());
637        assert_eq!(lazy_tensor.shape().dims(), &[10, 10]);
638    }
639
640    #[test]
641    fn test_lazy_loading_element_access() {
642        let (temp_file, metadata) = create_test_file();
643
644        let lazy_tensor: LazyTensor<f32> = LazyTensor::new(
645            temp_file.path(),
646            metadata,
647            LazyLoadConfig {
648                chunk_size: 10,
649                ..LazyLoadConfig::default()
650            },
651        )
652        .expect("lazy tensor creation should succeed");
653
654        // Test loading individual elements
655        let element = lazy_tensor
656            .get_element(5)
657            .expect("get_element should succeed");
658        assert!((element - 5.0).abs() < f32::EPSILON);
659
660        let element = lazy_tensor
661            .get_element(50)
662            .expect("get_element should succeed");
663        assert!((element - 50.0).abs() < f32::EPSILON);
664    }
665
666    #[test]
667    fn test_lazy_loading_range_access() {
668        let (temp_file, metadata) = create_test_file();
669
670        let lazy_tensor: LazyTensor<f32> = LazyTensor::new(
671            temp_file.path(),
672            metadata,
673            LazyLoadConfig {
674                chunk_size: 10,
675                ..LazyLoadConfig::default()
676            },
677        )
678        .expect("lazy tensor creation should succeed");
679
680        // Test loading a range of elements
681        let range = lazy_tensor
682            .get_range(10, 20)
683            .expect("get_range should succeed");
684        assert_eq!(range.len(), 10);
685
686        for (i, &value) in range.iter().enumerate() {
687            let expected = (10 + i) as f32;
688            assert!((value - expected).abs() < f32::EPSILON);
689        }
690    }
691
692    #[test]
693    fn test_cache_management() {
694        let (temp_file, metadata) = create_test_file();
695
696        let lazy_tensor: LazyTensor<f32> = LazyTensor::new(
697            temp_file.path(),
698            metadata,
699            LazyLoadConfig {
700                chunk_size: 10,
701                max_cached_chunks: 2,
702                ..LazyLoadConfig::default()
703            },
704        )
705        .expect("lazy tensor creation should succeed");
706
707        // Load some chunks - should respect max cache size
708        lazy_tensor
709            .get_element(5)
710            .expect("get_element should succeed"); // Chunk 0
711        lazy_tensor
712            .get_element(15)
713            .expect("get_element should succeed"); // Chunk 1
714
715        let stats = lazy_tensor.cache_stats();
716        assert!(stats.cached_chunks <= 2); // Should not exceed max after 2 chunks
717
718        lazy_tensor
719            .get_element(25)
720            .expect("get_element should succeed"); // Chunk 2 - should trigger cleanup
721
722        let stats_after = lazy_tensor.cache_stats();
723        // After loading a 3rd chunk, cache size might be 2 or 3 depending on cleanup timing
724        // We allow up to 3 as the cleanup happens during insertion, not after
725        assert!(stats_after.cached_chunks <= 3);
726        assert!(stats_after.total_cached_elements > 0);
727    }
728
729    #[test]
730    fn test_lazy_tensor_builder() {
731        let (temp_file, metadata) = create_test_file();
732
733        let lazy_tensor: LazyTensor<f32> = LazyTensorBuilder::new()
734            .chunk_size(5)
735            .max_cached_chunks(3)
736            .build(temp_file.path(), metadata)
737            .expect("lazy operation should succeed");
738
739        assert_eq!(lazy_tensor.config.chunk_size, 5);
740        assert_eq!(lazy_tensor.config.max_cached_chunks, 3);
741    }
742
743    #[test]
744    fn test_out_of_bounds_access() {
745        let (temp_file, metadata) = create_test_file();
746
747        let lazy_tensor: LazyTensor<f32> =
748            LazyTensor::new(temp_file.path(), metadata, LazyLoadConfig::default())
749                .expect("lazy tensor creation should succeed");
750
751        // Test out of bounds access
752        let result = lazy_tensor.get_element(1000);
753        assert!(result.is_err());
754
755        let result = lazy_tensor.get_range(90, 110);
756        assert!(result.is_err());
757    }
758
759    /// Build a unique path inside the system temp directory for header tests.
760    fn unique_temp_path(tag: &str) -> std::path::PathBuf {
761        use std::sync::atomic::{AtomicU64, Ordering};
762        static COUNTER: AtomicU64 = AtomicU64::new(0);
763        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
764        let pid = std::process::id();
765        let nanos = std::time::SystemTime::now()
766            .duration_since(std::time::UNIX_EPOCH)
767            .map(|d| d.as_nanos())
768            .unwrap_or(0);
769        std::env::temp_dir().join(format!("torsh_{}_{}_{}_{}.bin", tag, pid, n, nanos))
770    }
771
772    /// Write a `[u32 header_size][header bytes][f32 payload]` file used by the
773    /// lazy-loading header parser.
774    fn write_header_file(path: &std::path::Path, header: &str, data: &[f32]) {
775        let mut file = std::fs::File::create(path).expect("temp file creation should succeed");
776        let header_size = header.len() as u32;
777        file.write_all(&header_size.to_le_bytes())
778            .expect("write should succeed");
779        file.write_all(header.as_bytes())
780            .expect("write should succeed");
781        for &value in data {
782            file.write_all(&value.to_le_bytes())
783                .expect("write should succeed");
784        }
785        file.flush().expect("flush should succeed");
786    }
787
788    #[test]
789    fn test_create_metadata_from_header_populates_all_fields() {
790        let header = r#"{"shape":[3,4,5],"dtype":"f32","total_elements":60}"#;
791        let path = unique_temp_path("lazy_header_f32");
792        write_header_file(&path, header, &[]);
793
794        let metadata =
795            utils::create_metadata_from_header(&path).expect("metadata parsing should succeed");
796
797        // Every field must be populated from the header, not from placeholders.
798        assert_eq!(metadata.shape.dims(), &[3, 4, 5]);
799        assert_eq!(metadata.dtype, "f32");
800        assert_eq!(metadata.total_elements, 60);
801        assert_eq!(metadata.element_size, 4);
802        assert_eq!(metadata.data_offset, 4 + header.len() as u64);
803
804        let _ = std::fs::remove_file(&path);
805    }
806
807    #[test]
808    fn test_create_metadata_from_header_derives_element_size_and_total() {
809        // No `total_elements` field: it must be derived from the shape product,
810        // and the element size must follow the f64 dtype (8 bytes).
811        let header = r#"{"shape":[2,8],"dtype":"f64"}"#;
812        let path = unique_temp_path("lazy_header_f64");
813        write_header_file(&path, header, &[]);
814
815        let metadata =
816            utils::create_metadata_from_header(&path).expect("metadata parsing should succeed");
817
818        assert_eq!(metadata.shape.dims(), &[2, 8]);
819        assert_eq!(metadata.dtype, "f64");
820        assert_eq!(metadata.total_elements, 16);
821        assert_eq!(metadata.element_size, 8);
822        assert_eq!(metadata.data_offset, 4 + header.len() as u64);
823
824        let _ = std::fs::remove_file(&path);
825    }
826
827    #[test]
828    fn test_create_metadata_from_header_roundtrip_load() {
829        // End-to-end: parse the header, then load the payload through a LazyTensor.
830        // This only yields the correct values if `data_offset` is computed correctly.
831        let header = r#"{"shape":[2,3],"dtype":"f32","total_elements":6}"#;
832        let path = unique_temp_path("lazy_header_roundtrip");
833        let data: Vec<f32> = vec![10.0, 11.0, 12.0, 13.0, 14.0, 15.0];
834        write_header_file(&path, header, &data);
835
836        let metadata =
837            utils::create_metadata_from_header(&path).expect("metadata parsing should succeed");
838        assert_eq!(metadata.total_elements, 6);
839        assert_eq!(metadata.data_offset, 4 + header.len() as u64);
840
841        let lazy: LazyTensor<f32> = LazyTensor::new(
842            &path,
843            metadata,
844            LazyLoadConfig {
845                chunk_size: 4,
846                ..LazyLoadConfig::default()
847            },
848        )
849        .expect("lazy tensor creation should succeed");
850
851        let loaded = lazy.load_all().expect("load_all should succeed");
852        assert_eq!(loaded, data);
853
854        let _ = std::fs::remove_file(&path);
855    }
856
857    #[test]
858    fn test_create_metadata_from_header_rejects_missing_shape() {
859        let header = r#"{"dtype":"f32","total_elements":6}"#;
860        let path = unique_temp_path("lazy_header_missing_shape");
861        write_header_file(&path, header, &[]);
862
863        let result = utils::create_metadata_from_header(&path);
864        assert!(result.is_err());
865
866        let _ = std::fs::remove_file(&path);
867    }
868
869    #[test]
870    fn test_create_metadata_from_header_rejects_inconsistent_total() {
871        // total_elements (99) contradicts the shape product (2 * 3 = 6).
872        let header = r#"{"shape":[2,3],"dtype":"f32","total_elements":99}"#;
873        let path = unique_temp_path("lazy_header_inconsistent");
874        write_header_file(&path, header, &[]);
875
876        let result = utils::create_metadata_from_header(&path);
877        assert!(result.is_err());
878
879        let _ = std::fs::remove_file(&path);
880    }
881}