Files
rustytorch/crates/training/rtx-transformers/src/ssl/jepa_data.rs
T
osobhandClaude Sonnet 5 4aaa36a57a style: cargo fmt --workspace (whitespace/wrapping only, no semantic change)
Whole-workspace rustfmt pass picked up while iterating on Mamba GPU
backward work. Verified formatting-only via diff sampling; no logic
changed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-10 07:09:36 -07:00

2223 lines
76 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! JEPA Data Pipeline
//!
//! Complete data pipeline for I-JEPA / V-JEPA training:
//! - ImageRecord: decoded image storage
//! - MultiScaleRandomCrop: bilinear-resampled random crop augmentation
//! - RandomHorizontalFlip: stochastic horizontal flip
//! - JepaAugmentationPipeline: chained augmentation with ImageNet normalization
//! - JepaDataConfig: pipeline configuration
//! - InMemoryShard: in-memory image shard (CPU-testable without filesystem)
//! - JepaBatch: one ready-to-train batch with masks
//! - JepaDataPipeline: streaming batched iterator over shards
//! - DatasetStats: aggregate statistics
//! - WebDatasetShard: filesystem shard descriptor (stub)
// ============================================================================
// ImageRecord
// ============================================================================
/// A single decoded image stored in host memory.
#[derive(Debug, Clone)]
pub struct ImageRecord {
/// Pixel data in HWC layout, values in [0, 1].
pub pixels: Vec<f32>,
pub width: usize,
pub height: usize,
pub channels: usize,
/// Optional class label.
pub label: Option<usize>,
/// Dataset key / file name.
pub key: String,
}
impl ImageRecord {
/// Pixel value at (row, col, channel). Returns 0.0 if out of range.
#[inline]
fn get(&self, row: usize, col: usize, c: usize) -> f32 {
if row < self.height && col < self.width && c < self.channels {
self.pixels[row * self.width * self.channels + col * self.channels + c]
} else {
0.0
}
}
}
// ============================================================================
// LCG PRNG helper
// ============================================================================
#[inline(always)]
fn lcg_step(state: &mut u64) -> u64 {
*state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
*state
}
/// Sample a f32 in [0, 1) from the LCG state.
#[inline(always)]
fn lcg_f32(state: &mut u64) -> f32 {
(lcg_step(state) >> 11) as f32 / (1u64 << 53) as f32
}
/// Sample a usize in [0, n).
#[inline(always)]
fn lcg_usize(state: &mut u64, n: usize) -> usize {
if n == 0 {
return 0;
}
(lcg_step(state) % n as u64) as usize
}
// ============================================================================
// MultiScaleRandomCrop
// ============================================================================
/// Random crop followed by bilinear resize to a fixed target size.
///
/// Mimics the torchvision `RandomResizedCrop` transform used in I-JEPA:
/// - Sample crop area as `scale * total_area` of the source image.
/// - Sample aspect ratio from `ratio_range`.
/// - Bilinear-resample to `(target_size, target_size, channels)`.
#[derive(Debug, Clone)]
pub struct MultiScaleRandomCrop {
pub target_size: usize,
pub scale_range: (f32, f32),
pub ratio_range: (f32, f32),
}
impl MultiScaleRandomCrop {
pub fn new(target_size: usize, scale_range: (f32, f32), ratio_range: (f32, f32)) -> Self {
Self {
target_size,
scale_range,
ratio_range,
}
}
/// Crop and bilinear-resize a single `ImageRecord`.
///
/// `seed` seeds the LCG PRNG so results are deterministic per sample.
/// Returns a flat `[target_size, target_size, channels]` buffer.
pub fn crop_and_resize(&self, image: &ImageRecord, seed: u64) -> Vec<f32> {
let mut lcg = seed.wrapping_mul(2654435761).wrapping_add(1);
let src_h = image.height;
let src_w = image.width;
let c = image.channels;
let ts = self.target_size;
// Clamp scale range to (0, 1]
let scale_lo = self.scale_range.0.max(1e-6_f32).min(1.0);
let scale_hi = self.scale_range.1.max(scale_lo).min(1.0);
let ratio_lo = self.ratio_range.0.max(1e-4_f32);
let ratio_hi = self.ratio_range.1.max(ratio_lo);
// Try up to 10 attempts to find a valid crop; fall back to centre crop.
let mut crop_top = 0usize;
let mut crop_left = 0usize;
let mut crop_h = src_h;
let mut crop_w = src_w;
for _ in 0..10 {
let scale = scale_lo + lcg_f32(&mut lcg) * (scale_hi - scale_lo);
let ratio = ratio_lo + lcg_f32(&mut lcg) * (ratio_hi - ratio_lo);
let area = (src_h * src_w) as f32 * scale;
let h_f = (area / ratio).sqrt();
let w_f = (area * ratio).sqrt();
let h = h_f.round() as usize;
let w = w_f.round() as usize;
if h == 0 || w == 0 || h > src_h || w > src_w {
continue;
}
let top = lcg_usize(&mut lcg, src_h - h + 1);
let left = lcg_usize(&mut lcg, src_w - w + 1);
crop_top = top;
crop_left = left;
crop_h = h;
crop_w = w;
break;
}
// Bilinear resample crop → target_size × target_size
let mut out = vec![0.0f32; ts * ts * c];
for oy in 0..ts {
for ox in 0..ts {
// Map output pixel to source pixel in the crop region
// Using half-pixel convention for better edge behaviour
let src_y_f = (oy as f32 + 0.5) / ts as f32 * crop_h as f32 - 0.5 + crop_top as f32;
let src_x_f =
(ox as f32 + 0.5) / ts as f32 * crop_w as f32 - 0.5 + crop_left as f32;
let y0 = src_y_f.floor() as isize;
let x0 = src_x_f.floor() as isize;
let y1 = y0 + 1;
let x1 = x0 + 1;
let dy = src_y_f - y0 as f32;
let dx = src_x_f - x0 as f32;
// Clamp coordinates
let y0c = y0.clamp(0, src_h as isize - 1) as usize;
let y1c = y1.clamp(0, src_h as isize - 1) as usize;
let x0c = x0.clamp(0, src_w as isize - 1) as usize;
let x1c = x1.clamp(0, src_w as isize - 1) as usize;
let base = (oy * ts + ox) * c;
for ch in 0..c {
let p00 = image.get(y0c, x0c, ch);
let p01 = image.get(y0c, x1c, ch);
let p10 = image.get(y1c, x0c, ch);
let p11 = image.get(y1c, x1c, ch);
out[base + ch] = p00 * (1.0 - dy) * (1.0 - dx)
+ p01 * (1.0 - dy) * dx
+ p10 * dy * (1.0 - dx)
+ p11 * dy * dx;
}
}
}
out
}
}
// ============================================================================
// RandomHorizontalFlip
// ============================================================================
/// Randomly flip an image left-right with probability `prob`.
#[derive(Debug, Clone)]
pub struct RandomHorizontalFlip {
pub prob: f32,
}
impl RandomHorizontalFlip {
pub fn new(prob: f32) -> Self {
Self { prob }
}
/// Apply (or not) horizontal flip.
///
/// `pixels` must be in HWC layout with `w * channels` stride per row.
pub fn apply(
&self,
pixels: &[f32],
w: usize,
h: usize,
channels: usize,
seed: u64,
) -> Vec<f32> {
let mut lcg = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let r = lcg_f32(&mut lcg);
if r >= self.prob {
return pixels.to_vec();
}
let mut out = pixels.to_vec();
for row in 0..h {
for col in 0..w / 2 {
let mirror = w - 1 - col;
for c in 0..channels {
let a = row * w * channels + col * channels + c;
let b = row * w * channels + mirror * channels + c;
out.swap(a, b);
}
}
}
out
}
}
// ============================================================================
// JepaAugmentationPipeline
// ============================================================================
/// Chained augmentation: MultiScaleRandomCrop → RandomHorizontalFlip → normalize.
#[derive(Debug, Clone)]
pub struct JepaAugmentationPipeline {
pub crop: MultiScaleRandomCrop,
pub flip: RandomHorizontalFlip,
pub mean: [f32; 3],
pub std: [f32; 3],
}
impl JepaAugmentationPipeline {
/// Build from a `JepaDataConfig`.
pub fn from_config(config: &JepaDataConfig) -> Self {
let mean = if config.imagenet_normalize {
[0.485, 0.456, 0.406]
} else {
[0.0, 0.0, 0.0]
};
let std = if config.imagenet_normalize {
[0.229, 0.224, 0.225]
} else {
[1.0, 1.0, 1.0]
};
Self {
crop: MultiScaleRandomCrop::new(
config.image_size,
config.scale_range,
config.ratio_range,
),
flip: RandomHorizontalFlip::new(if config.use_horizontal_flip { 0.5 } else { 0.0 }),
mean,
std,
}
}
/// Per-channel ImageNet normalization in-place.
pub fn normalize(pixels: &mut Vec<f32>, mean: [f32; 3], std: [f32; 3]) {
let c = mean.len(); // 3
for chunk in pixels.chunks_mut(c) {
for (i, v) in chunk.iter_mut().enumerate() {
let ch = i % c;
let s = std[ch].max(1e-7_f32);
*v = (*v - mean[ch]) / s;
}
}
}
/// Run the full augmentation pipeline on one `ImageRecord`.
///
/// Returns a flat `[H_out * W_out * C]` buffer (C=channels of the source image).
pub fn process(&self, image: &ImageRecord, seed: u64) -> Vec<f32> {
let ts = self.crop.target_size;
let c = image.channels;
// 1. Multi-scale random crop → [ts, ts, c]
let mut cropped = self.crop.crop_and_resize(image, seed);
// 2. Horizontal flip (use a derived seed so crop and flip are independent)
let flip_seed = seed
.wrapping_mul(2654435761)
.wrapping_add(1442695040888963407);
let flipped = self.flip.apply(&cropped, ts, ts, c, flip_seed);
cropped = flipped;
// 3. Normalize per-channel
Self::normalize(&mut cropped, self.mean, self.std);
cropped
}
}
// ============================================================================
// JepaDataConfig
// ============================================================================
/// Top-level configuration for the JEPA data pipeline.
#[derive(Debug, Clone)]
pub struct JepaDataConfig {
/// Target image size after crop (e.g. 224).
pub image_size: usize,
/// Patch size used for masking (e.g. 16 → 14×14 grid on 224px images).
pub patch_size: usize,
/// Number of images per batch.
pub batch_size: usize,
/// Concurrency hint (not used in CPU reference implementation).
pub num_workers: usize,
/// Paths to WebDataset shards (.tar files).
pub shard_paths: Vec<String>,
/// Scale range for `MultiScaleRandomCrop` (area fraction).
pub scale_range: (f32, f32),
/// Aspect ratio range for `MultiScaleRandomCrop`.
pub ratio_range: (f32, f32),
/// Whether to apply random horizontal flip.
pub use_horizontal_flip: bool,
/// Whether to apply ImageNet mean/std normalization.
pub imagenet_normalize: bool,
}
impl Default for JepaDataConfig {
fn default() -> Self {
Self {
image_size: 224,
patch_size: 16,
batch_size: 1024,
num_workers: 8,
shard_paths: Vec::new(),
scale_range: (0.2, 1.0),
ratio_range: (0.75, 1.33),
use_horizontal_flip: true,
imagenet_normalize: true,
}
}
}
impl JepaDataConfig {
/// Grid side length in patches.
pub fn grid_size(&self) -> usize {
self.image_size / self.patch_size
}
/// Total patch count per image.
pub fn num_patches(&self) -> usize {
let g = self.grid_size();
g * g
}
}
// ============================================================================
// InMemoryShard
// ============================================================================
/// A shard of images held entirely in host memory.
///
/// Used for unit testing without a real filesystem.
#[derive(Debug, Clone)]
pub struct InMemoryShard {
pub records: Vec<ImageRecord>,
pub shard_id: usize,
}
impl InMemoryShard {
/// Create `n` synthetic images of size `image_size × image_size × 3`
/// with LCG-generated random pixels in `[0, 1]` and sequential labels.
pub fn synthetic(n: usize, image_size: usize, shard_id: usize) -> Self {
let mut records = Vec::with_capacity(n);
let mut lcg: u64 = (shard_id as u64)
.wrapping_mul(2654435761)
.wrapping_add(1442695040888963407);
for i in 0..n {
let pixel_count = image_size * image_size * 3;
let pixels: Vec<f32> = (0..pixel_count).map(|_| lcg_f32(&mut lcg)).collect();
records.push(ImageRecord {
pixels,
width: image_size,
height: image_size,
channels: 3,
label: Some(i % 1000),
key: format!("shard{shard_id}/img{i:06}"),
});
}
Self { records, shard_id }
}
/// Number of records in this shard.
pub fn len(&self) -> usize {
self.records.len()
}
/// True if the shard is empty.
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
}
// ============================================================================
// JepaBatch
// ============================================================================
/// One training batch ready for JEPA forward + loss.
#[derive(Debug, Clone)]
pub struct JepaBatch {
/// Augmented image pixels for each sample, flattened to `[H*W*C]`.
pub images: Vec<Vec<f32>>,
/// Context patch indices per sample `[batch_size][n_context]`.
pub context_patch_indices: Vec<Vec<usize>>,
/// Target block patch indices per sample `[batch_size][num_blocks][n_patches]`.
pub target_patch_groups: Vec<Vec<Vec<usize>>>,
/// Union of all target patch indices per sample `[batch_size][n_total_target]`.
pub all_target_indices: Vec<Vec<usize>>,
/// Class labels (if available) per sample.
pub labels: Vec<Option<usize>>,
/// Monotonically increasing batch ID within the epoch.
pub batch_id: usize,
}
impl JepaBatch {
/// Actual number of samples in this batch (may be less than config.batch_size at epoch end).
pub fn batch_size(&self) -> usize {
self.images.len()
}
}
// ============================================================================
// JepaDataPipeline
// ============================================================================
/// Streaming data pipeline: iterates over shards, applies augmentation, and
/// assembles `JepaBatch` instances with JEPA block masks.
pub struct JepaDataPipeline {
pub config: JepaDataConfig,
augmentation: JepaAugmentationPipeline,
mask_strategy: super::jepa::BlockMaskStrategy,
shards: Vec<InMemoryShard>,
/// Order in which shards are visited this epoch (shuffled each epoch).
shard_order: Vec<usize>,
/// Index into `shard_order` for the currently active shard.
current_shard: usize,
/// Index within the current shard's records.
current_idx: usize,
/// Current epoch (starts at 0, increments on `reset_epoch`).
epoch: usize,
/// Monotonic batch counter (resets on `reset_epoch`).
batch_counter: usize,
/// LCG seed for shard shuffling.
shuffle_lcg: u64,
}
impl JepaDataPipeline {
/// Build a new pipeline.
pub fn new(config: JepaDataConfig, shards: Vec<InMemoryShard>) -> Self {
let augmentation = JepaAugmentationPipeline::from_config(&config);
let mask_strategy = super::jepa::BlockMaskStrategy::default_ijepa();
let n = shards.len();
let shard_order: Vec<usize> = (0..n).collect();
Self {
config,
augmentation,
mask_strategy,
shards,
shard_order,
current_shard: 0,
current_idx: 0,
epoch: 0,
batch_counter: 0,
shuffle_lcg: 0xdeadbeef_cafebabe_u64,
}
}
/// Total images across all shards.
fn total_images(&self) -> usize {
self.shards.iter().map(|s| s.len()).sum()
}
/// Number of full (and potentially partial) batches per epoch.
pub fn num_batches_per_epoch(&self) -> usize {
let total = self.total_images();
if total == 0 || self.config.batch_size == 0 {
return 0;
}
(total + self.config.batch_size - 1) / self.config.batch_size
}
/// Current epoch number.
pub fn epoch(&self) -> usize {
self.epoch
}
/// Advance the shard pointer, skipping empty shards.
/// Returns false if all shards for this epoch have been consumed.
fn advance_shard(&mut self) -> bool {
self.current_shard += 1;
self.current_idx = 0;
self.current_shard < self.shard_order.len()
}
/// Reference to the currently active shard, or None if epoch is done.
fn active_shard(&self) -> Option<&InMemoryShard> {
let si = self.shard_order.get(self.current_shard)?;
self.shards.get(*si)
}
/// Pull the next `ImageRecord` from the stream, advancing shards as needed.
fn next_record(&mut self) -> Option<&ImageRecord> {
loop {
let shard = match self.active_shard() {
Some(s) => s,
None => return None,
};
if self.current_idx < shard.len() {
// This looks like a borrow issue but we borrow immutably here.
// We will return a raw pointer trick via direct indexing below.
break;
} else {
if !self.advance_shard() {
return None;
}
}
}
// Re-resolve to get the record (needed because we can't hold shard ref through advance_shard).
let si = *self.shard_order.get(self.current_shard)?;
let shard = self.shards.get(si)?;
let rec = shard.records.get(self.current_idx)?;
self.current_idx += 1;
Some(rec)
}
/// Yield the next batch, or `None` when the epoch is exhausted.
pub fn next_batch(&mut self) -> Option<JepaBatch> {
let batch_size = self.config.batch_size;
let grid = self.config.grid_size();
let batch_id = self.batch_counter;
// Collect up to `batch_size` records.
// We need to collect references first, then process.
// Because next_record borrows &mut self, we collect indices instead.
let mut sample_indices: Vec<(usize, usize)> = Vec::with_capacity(batch_size); // (shard_order_idx, record_idx)
for _ in 0..batch_size {
loop {
let shard = match self.active_shard() {
Some(s) => s,
None => break,
};
if self.current_idx < shard.len() {
let si = self.shard_order[self.current_shard];
sample_indices.push((si, self.current_idx));
self.current_idx += 1;
break;
} else {
if !self.advance_shard() {
break;
}
}
}
// Check if we are done.
if self.active_shard().is_none()
&& sample_indices.len() < batch_size
&& !sample_indices.is_empty()
{
// Last partial batch — collect what we have.
break;
}
if sample_indices.len() == batch_size {
break;
}
}
if sample_indices.is_empty() {
return None;
}
let actual_bs = sample_indices.len();
let mut images = Vec::with_capacity(actual_bs);
let mut context_patch_indices = Vec::with_capacity(actual_bs);
let mut target_patch_groups = Vec::with_capacity(actual_bs);
let mut all_target_indices_out = Vec::with_capacity(actual_bs);
let mut labels = Vec::with_capacity(actual_bs);
for (sample_i, (shard_idx, record_idx)) in sample_indices.iter().enumerate() {
let record = &self.shards[*shard_idx].records[*record_idx];
// Build a per-sample seed from batch_id, sample_i, and shard/record position.
let seed: u64 = (batch_id as u64)
.wrapping_mul(1000003)
.wrapping_add(*shard_idx as u64)
.wrapping_mul(999983)
.wrapping_add(*record_idx as u64)
.wrapping_add(sample_i as u64);
// Augment image.
let aug = self.augmentation.process(record, seed);
images.push(aug);
labels.push(record.label);
// Generate JEPA block masks.
let mask = self.mask_strategy.generate(grid, grid, seed);
context_patch_indices.push(mask.context_indices);
target_patch_groups.push(mask.target_blocks);
all_target_indices_out.push(mask.all_target_indices);
}
self.batch_counter += 1;
Some(JepaBatch {
images,
context_patch_indices,
target_patch_groups,
all_target_indices: all_target_indices_out,
labels,
batch_id,
})
}
/// Reset for the next epoch: shuffle shard order and rewind position counters.
pub fn reset_epoch(&mut self) {
self.epoch += 1;
self.batch_counter = 0;
self.current_shard = 0;
self.current_idx = 0;
// Fisher-Yates shuffle of shard order using internal LCG.
let n = self.shard_order.len();
for i in 0..n {
self.shard_order[i] = i;
}
for i in 0..n {
let j = i + lcg_usize(&mut self.shuffle_lcg, n - i);
self.shard_order.swap(i, j);
}
}
}
// ============================================================================
// DatasetStats
// ============================================================================
/// Aggregate statistics about the dataset and masking behaviour.
#[derive(Debug, Clone)]
pub struct DatasetStats {
pub total_images: usize,
pub num_shards: usize,
pub images_per_shard: Vec<usize>,
/// Mean context patch count across the first sampled batch.
pub avg_context_patches: f64,
/// Mean target patch count across the first sampled batch.
pub avg_target_patches: f64,
/// Fraction of total patches that are target patches (mask_efficiency).
pub mask_efficiency: f64,
}
impl DatasetStats {
/// Compute statistics by sampling one batch from a separate mask strategy instance.
pub fn compute(pipeline: &JepaDataPipeline) -> Self {
let total_images: usize = pipeline.shards.iter().map(|s| s.len()).sum();
let num_shards = pipeline.shards.len();
let images_per_shard: Vec<usize> = pipeline.shards.iter().map(|s| s.len()).collect();
let total_patches = pipeline.config.num_patches();
let grid = pipeline.config.grid_size();
// Sample a small number of masks to estimate average patch counts.
let sample_size = 32.min(total_images.max(1));
let mut mask_strategy = super::jepa::BlockMaskStrategy::default_ijepa();
let masks = mask_strategy.generate_batch(sample_size, grid, grid);
let avg_context = if masks.is_empty() {
0.0
} else {
masks
.iter()
.map(|m| m.context_indices.len() as f64)
.sum::<f64>()
/ masks.len() as f64
};
let avg_target = if masks.is_empty() {
0.0
} else {
masks
.iter()
.map(|m| m.all_target_indices.len() as f64)
.sum::<f64>()
/ masks.len() as f64
};
let mask_efficiency = if total_patches == 0 {
0.0
} else {
avg_target / total_patches as f64
};
Self {
total_images,
num_shards,
images_per_shard,
avg_context_patches: avg_context,
avg_target_patches: avg_target,
mask_efficiency,
}
}
}
// ============================================================================
// WebDatasetShard
// ============================================================================
/// Filesystem shard descriptor for WebDataset-format `.tar` archives.
///
/// Use [`WebDatasetShard::load`] to read the actual archive from disk
/// (via [`read_webdataset_shard`]); [`WebDatasetShard::to_in_memory`]
/// generates synthetic data and exists for tests that need a shard
/// without touching the filesystem.
#[derive(Debug, Clone)]
pub struct WebDatasetShard {
pub path: String,
pub num_records: usize,
pub shard_id: usize,
pub compressed: bool,
}
impl WebDatasetShard {
/// Create a descriptor pointing at `path`.
///
/// `num_records` is used both for progress estimation and for synthetic
/// data generation in `to_in_memory`.
pub fn new(path: &str, num_records: usize, shard_id: usize) -> Self {
Self {
path: path.to_owned(),
num_records,
shard_id,
compressed: path.ends_with(".tar.gz") || path.ends_with(".tgz"),
}
}
/// Read the shard's `.tar` (or gzip-compressed `.tar.gz`/`.tgz`)
/// archive from disk and decode its records into an [`InMemoryShard`].
pub fn load(&self) -> Result<InMemoryShard, String> {
let (raw_records, _stats) = read_webdataset_shard(std::path::Path::new(&self.path))?;
let records: Vec<ImageRecord> = raw_records
.into_iter()
.map(webdataset_record_to_image)
.collect();
Ok(InMemoryShard {
records,
shard_id: self.shard_id,
})
}
/// Generate a synthetic `InMemoryShard` with `num_records` records of
/// size `image_size × image_size × 3`. Used for testing without a
/// real filesystem — use [`WebDatasetShard::load`] for real data.
pub fn to_in_memory(&self, image_size: usize) -> InMemoryShard {
InMemoryShard::synthetic(self.num_records, image_size, self.shard_id)
}
}
// ============================================================================
// WebDatasetRecord
// ============================================================================
/// A record decoded from a WebDataset shard (one image + optional label).
#[derive(Debug, Clone)]
pub struct WebDatasetRecord {
/// Numeric prefix, e.g. "000042".
pub key: String,
/// Raw JPEG/PNG bytes (not decoded to pixels).
pub image_bytes: Vec<u8>,
/// Parsed from .cls file.
pub label: Option<usize>,
/// "jpg" or "png".
pub extension: String,
}
// ============================================================================
// ShardLoadStats
// ============================================================================
/// Statistics for a loaded shard.
#[derive(Debug, Clone)]
pub struct ShardLoadStats {
pub shard_id: usize,
pub records_loaded: usize,
pub bytes_read: u64,
pub load_duration_ms: u64,
}
// ============================================================================
// ShuffleBuffer
// ============================================================================
/// Fixed-size ring buffer for dataset shuffling.
/// Holds up to `capacity` records; reservoir sampling on insert.
pub struct ShuffleBuffer {
capacity: usize,
buffer: Vec<ImageRecord>,
rng_state: u64,
}
impl ShuffleBuffer {
/// Create a new shuffle buffer seeded from `capacity`.
pub fn new(capacity: usize) -> Self {
let rng_state = (capacity as u64).wrapping_mul(6364136223846793005);
Self {
capacity,
buffer: Vec::new(),
rng_state,
}
}
/// Insert a record. If the buffer is not full, push it; otherwise replace
/// a random slot using the internal LCG.
pub fn push(&mut self, record: ImageRecord) {
if self.buffer.len() < self.capacity {
self.buffer.push(record);
} else if self.capacity > 0 {
let slot = lcg_usize(&mut self.rng_state, self.capacity);
self.buffer[slot] = record;
}
}
/// Remove and return the first `n` items (or all items if fewer than `n` remain).
pub fn drain_batch(&mut self, n: usize) -> Vec<ImageRecord> {
let take = n.min(self.buffer.len());
self.buffer.drain(..take).collect()
}
/// Number of records currently in the buffer.
pub fn len(&self) -> usize {
self.buffer.len()
}
/// True when `len() >= capacity`.
pub fn is_full(&self) -> bool {
self.len() >= self.capacity
}
}
// ============================================================================
// Tar parsing helpers (stdlib only)
// ============================================================================
/// Parse a null-terminated octal ASCII string from a fixed-width tar field.
fn parse_octal(field: &[u8]) -> u64 {
// Trim leading/trailing null bytes and spaces
let s = field
.iter()
.take_while(|&&b| b != 0 && b != b' ')
.copied()
.collect::<Vec<u8>>();
let s = std::str::from_utf8(&s).unwrap_or("0");
u64::from_str_radix(s.trim(), 8).unwrap_or(0)
}
/// Parse all WebDataset records from an in-memory tar byte slice.
///
/// A tar archive is a sequence of 512-byte blocks:
/// - Header block: filename (bytes 0..100), size (bytes 124..136, octal),
/// typeflag (byte 156, '0' or '\0' = regular file, '5' = directory).
/// - Data blocks: ceil(size / 512) × 512 bytes follow the header.
/// - End-of-archive: two consecutive all-zero blocks.
pub fn parse_tar_bytes(data: &[u8]) -> Vec<WebDatasetRecord> {
// Accumulate raw file entries keyed by stem (numeric prefix).
// Values: (image_bytes, image_ext, label_bytes)
let mut image_map: std::collections::HashMap<String, (Vec<u8>, String)> =
std::collections::HashMap::new();
let mut label_map: std::collections::HashMap<String, Vec<u8>> =
std::collections::HashMap::new();
let mut offset = 0usize;
loop {
// Need at least one header block.
if offset + 512 > data.len() {
break;
}
let header = &data[offset..offset + 512];
offset += 512;
// End-of-archive: two consecutive zero blocks. A zero block starts here.
if header.iter().all(|&b| b == 0) {
break;
}
// Parse filename (bytes 0..100, null-terminated).
let fname_raw = &header[0..100];
let fname_len = fname_raw.iter().position(|&b| b == 0).unwrap_or(100);
let filename = match std::str::from_utf8(&fname_raw[..fname_len]) {
Ok(s) => s.trim_matches('/').to_owned(),
Err(_) => {
// Skip unreadable header.
continue;
}
};
// Parse typeflag (byte 156). '5' = directory, skip data blocks but
// don't advance data pointer here — size may still be non-zero.
let typeflag = header[156];
let is_directory = typeflag == b'5';
// Parse size (bytes 124..136, octal ASCII).
let size = parse_octal(&header[124..136]) as usize;
// Skip aligned data blocks for directories and zero-size files.
let data_blocks = (size + 511) / 512;
let data_bytes = data_blocks * 512;
if is_directory || size == 0 {
offset += data_bytes;
continue;
}
// Read `size` bytes of content.
if offset + data_bytes > data.len() {
// Truncated archive — stop.
break;
}
let content = data[offset..offset + size].to_vec();
offset += data_bytes;
// Derive extension and key from the filename.
// Filenames may include a directory prefix; take the basename.
let basename = filename.rsplit('/').next().unwrap_or(&filename);
let (stem, ext) = if let Some(dot) = basename.rfind('.') {
(&basename[..dot], &basename[dot + 1..])
} else {
(basename, "")
};
match ext {
"jpg" | "jpeg" | "png" => {
let image_ext = if ext == "jpeg" { "jpg" } else { ext };
image_map.insert(stem.to_owned(), (content, image_ext.to_owned()));
}
"cls" => {
label_map.insert(stem.to_owned(), content);
}
_ => {
// Unknown extension — ignore.
}
}
}
// Merge image + label by key.
let mut keys: Vec<String> = image_map.keys().cloned().collect();
keys.sort();
keys.into_iter()
.filter_map(|key| {
let (image_bytes, extension) = image_map.remove(&key)?;
let label = label_map.get(&key).and_then(|bytes| {
let s = std::str::from_utf8(bytes).ok()?.trim().to_owned();
s.parse::<usize>().ok()
});
Some(WebDatasetRecord {
key,
image_bytes,
label,
extension,
})
})
.collect()
}
/// Read all records from a WebDataset tar shard at `path`.
///
/// Returns records in shard order plus load statistics.
/// Gzip-compressed shards (`.tar.gz`/`.tgz`, detected by the 1F 8B magic
/// bytes rather than extension) are decompressed transparently.
pub fn read_webdataset_shard(
path: &std::path::Path,
) -> Result<(Vec<WebDatasetRecord>, ShardLoadStats), String> {
use std::io::Read;
use std::time::Instant;
let t0 = Instant::now();
let mut file =
std::fs::File::open(path).map_err(|e| format!("cannot open {}: {e}", path.display()))?;
let mut data = Vec::new();
file.read_to_end(&mut data)
.map_err(|e| format!("read error for {}: {e}", path.display()))?;
let bytes_read = data.len() as u64;
// Gzip magic: 0x1F 0x8B. Decompress before tar parsing.
if data.len() >= 2 && data[0] == 0x1f && data[1] == 0x8b {
let mut decoder = flate2::read::GzDecoder::new(&data[..]);
let mut decompressed = Vec::new();
decoder
.read_to_end(&mut decompressed)
.map_err(|e| format!("gzip decompression failed for {}: {e}", path.display()))?;
data = decompressed;
}
let records = parse_tar_bytes(&data);
let records_loaded = records.len();
let load_duration_ms = t0.elapsed().as_millis() as u64;
// shard_id is unknown at this level; callers should override if needed.
let stats = ShardLoadStats {
shard_id: 0,
records_loaded,
bytes_read,
load_duration_ms,
};
Ok((records, stats))
}
/// Decode a `WebDatasetRecord` into an `ImageRecord`.
///
/// Pixel values are placeholder 0.5f32 until rtx-vision image decoder is
/// wired. The function detects JPEG (`\xFF\xD8`) and PNG (`\x89PNG`) magic
/// bytes to validate the format; other byte sequences also receive the
/// placeholder.
fn webdataset_record_to_image(rec: WebDatasetRecord) -> ImageRecord {
const TARGET_W: usize = 224;
const TARGET_H: usize = 224;
const C: usize = 3;
#[cfg(feature = "image-decode")]
{
use image::io::Reader as ImageReader;
use std::io::Cursor;
if let Ok(reader) = ImageReader::new(Cursor::new(&rec.image_bytes)).with_guessed_format() {
if let Ok(img) = reader.decode() {
// Resize to 224×224 and convert to RGB
let rgb = img
.resize_exact(
TARGET_W as u32,
TARGET_H as u32,
image::imageops::FilterType::Triangle,
)
.into_rgb8();
let pixels: Vec<f32> = rgb
.into_raw()
.into_iter()
.map(|p| p as f32 / 255.0)
.collect();
return ImageRecord {
pixels,
width: TARGET_W,
height: TARGET_H,
channels: C,
label: rec.label,
key: rec.key,
};
}
}
}
// Fallback: placeholder (without image-decode feature, or on decode failure)
let _ = &rec.image_bytes; // suppress unused warning
ImageRecord {
pixels: vec![0.5f32; TARGET_W * TARGET_H * C],
width: TARGET_W,
height: TARGET_H,
channels: C,
label: rec.label,
key: rec.key,
}
}
impl JepaDataPipeline {
/// Load a pipeline from filesystem shards.
///
/// `shard_paths` is a list of `.tar` files (uncompressed WebDataset format).
/// Each file is read, parsed, and converted to an `InMemoryShard`.
///
/// Pixel values are placeholder 0.5f32 until rtx-vision image decoder is
/// wired.
pub fn from_filesystem(
config: JepaDataConfig,
shard_paths: Vec<std::path::PathBuf>,
) -> Result<Self, String> {
// Validate all paths exist before loading anything.
for p in &shard_paths {
if !p.exists() {
return Err(format!("shard path does not exist: {}", p.display()));
}
}
let mut shards: Vec<InMemoryShard> = Vec::with_capacity(shard_paths.len());
for (shard_id, path) in shard_paths.iter().enumerate() {
let (raw_records, _stats) = read_webdataset_shard(path)?;
let records: Vec<ImageRecord> = raw_records
.into_iter()
.map(webdataset_record_to_image)
.collect();
shards.push(InMemoryShard { records, shard_id });
}
Ok(Self::new(config, shards))
}
}
// ============================================================================
// HTTP / WebDataset URL loading
// ============================================================================
/// Descriptor for a remote WebDataset shard at a URL.
#[derive(Debug, Clone)]
pub struct UrlShardDescriptor {
/// Full HTTP/HTTPS URL to the .tar shard file
pub url: String,
/// Expected number of records (0 = unknown)
pub expected_records: usize,
/// Shard index in the dataset
pub shard_id: usize,
}
impl UrlShardDescriptor {
pub fn new(url: impl Into<String>, expected_records: usize, shard_id: usize) -> Self {
Self {
url: url.into(),
expected_records,
shard_id,
}
}
/// Returns true if this descriptor points to an HTTPS URL (vs plain HTTP)
pub fn is_https(&self) -> bool {
self.url.starts_with("https://")
}
}
/// Like `download_webdataset_shard` but with a configurable timeout.
/// Used internally and for testing.
pub(crate) fn download_webdataset_shard_with_timeout(
url: &str,
shard_id: usize,
timeout_secs: u64,
) -> Result<(Vec<WebDatasetRecord>, ShardLoadStats), String> {
let start = std::time::Instant::now();
// Validate URL scheme
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(format!(
"unsupported URL scheme (expected http:// or https://): {url}"
));
}
// Download using a one-shot Tokio runtime
let bytes = {
let rt = tokio::runtime::Runtime::new()
.map_err(|e| format!("failed to create Tokio runtime: {e}"))?;
rt.block_on(async {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_secs))
.build()
.map_err(|e| format!("reqwest client build failed: {e}"))?;
let response = client
.get(url)
.send()
.await
.map_err(|e| format!("HTTP GET failed for {url}: {e}"))?;
if !response.status().is_success() {
return Err(format!("HTTP {} for {url}", response.status()));
}
response
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| format!("failed to read response body: {e}"))
})?
};
let load_duration_ms = start.elapsed().as_millis() as u64;
let bytes_read = bytes.len() as u64;
let records = parse_tar_bytes(&bytes);
let records_loaded = records.len();
let stats = ShardLoadStats {
shard_id,
records_loaded,
bytes_read,
load_duration_ms,
};
Ok((records, stats))
}
/// Download a WebDataset `.tar` shard from an HTTP/HTTPS URL and parse it.
///
/// Returns `(records, stats)` on success, `Err(message)` on failure.
/// Requires `reqwest` (already a workspace dep).
///
/// The download is synchronous (blocks via `tokio::runtime::Handle`). For
/// async callers, use the async variant `download_shard_async` instead.
pub fn download_webdataset_shard(
url: &str,
shard_id: usize,
) -> Result<(Vec<WebDatasetRecord>, ShardLoadStats), String> {
download_webdataset_shard_with_timeout(url, shard_id, 300)
}
impl JepaDataPipeline {
/// Load a pipeline from HTTP/HTTPS WebDataset shard URLs.
///
/// Each URL must point to an uncompressed `.tar` file in WebDataset format.
/// Downloads are sequential (one shard at a time).
///
/// Returns `Err` if any URL fails to download or parse.
pub fn from_urls(config: JepaDataConfig, urls: Vec<String>) -> Result<Self, String> {
// Validate all URLs before downloading
for url in &urls {
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(format!(
"unsupported URL scheme in '{}' (expected http:// or https://)",
url
));
}
}
let mut shards: Vec<InMemoryShard> = Vec::with_capacity(urls.len());
for (shard_id, url) in urls.iter().enumerate() {
let (raw_records, _stats) = download_webdataset_shard(url, shard_id)?;
let records: Vec<ImageRecord> = raw_records
.into_iter()
.map(webdataset_record_to_image)
.collect();
shards.push(InMemoryShard { records, shard_id });
}
Ok(Self::new(config, shards))
}
/// Validate a list of URLs without downloading.
///
/// Returns `Ok(())` if all URLs have valid schemes, `Err` with the first invalid URL.
pub fn validate_urls(urls: &[String]) -> Result<(), String> {
for url in urls {
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(format!(
"invalid URL '{}': must start with http:// or https://",
url
));
}
}
Ok(())
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
// ------------------------------------------------------------------
// Helper: build a small synthetic ImageRecord
// ------------------------------------------------------------------
fn make_record(w: usize, h: usize, label: Option<usize>) -> ImageRecord {
let pixels: Vec<f32> = (0..h * w * 3).map(|i| (i % 256) as f32 / 255.0).collect();
ImageRecord {
pixels,
width: w,
height: h,
channels: 3,
label,
key: "test".into(),
}
}
fn make_pipeline(
n_shards: usize,
imgs_per_shard: usize,
batch_size: usize,
) -> JepaDataPipeline {
let shards: Vec<InMemoryShard> = (0..n_shards)
.map(|id| InMemoryShard::synthetic(imgs_per_shard, 32, id))
.collect();
let config = JepaDataConfig {
image_size: 32,
patch_size: 8,
batch_size,
num_workers: 1,
shard_paths: Vec::new(),
scale_range: (0.5, 1.0),
ratio_range: (0.75, 1.33),
use_horizontal_flip: true,
imagenet_normalize: true,
};
JepaDataPipeline::new(config, shards)
}
// ── MultiScaleRandomCrop ──────────────────────────────────────────────────
#[test]
fn test_crop_output_size() {
let crop = MultiScaleRandomCrop::new(64, (0.2, 1.0), (0.75, 1.33));
let img = make_record(128, 128, None);
let out = crop.crop_and_resize(&img, 42);
assert_eq!(out.len(), 64 * 64 * 3, "output must be [target, target, 3]");
}
#[test]
fn test_crop_output_in_range() {
// Input pixels are in [0,1], bilinear interp keeps them in [0,1]
let crop = MultiScaleRandomCrop::new(32, (0.2, 1.0), (0.75, 1.33));
let img = make_record(64, 64, None);
let out = crop.crop_and_resize(&img, 7);
for &v in &out {
assert!(v >= -1e-6 && v <= 1.0 + 1e-6, "pixel {v} out of [0,1]");
}
}
#[test]
fn test_crop_different_seeds_differ() {
let crop = MultiScaleRandomCrop::new(32, (0.2, 0.8), (0.75, 1.33));
let img = make_record(64, 64, None);
let a = crop.crop_and_resize(&img, 1);
let b = crop.crop_and_resize(&img, 999);
assert_ne!(a, b, "different seeds should produce different crops");
}
#[test]
fn test_crop_same_seed_reproducible() {
let crop = MultiScaleRandomCrop::new(32, (0.2, 1.0), (0.75, 1.33));
let img = make_record(64, 64, None);
let a = crop.crop_and_resize(&img, 42);
let b = crop.crop_and_resize(&img, 42);
assert_eq!(a, b, "same seed must be deterministic");
}
#[test]
fn test_crop_small_image() {
// Should not panic on a 1×1 image
let crop = MultiScaleRandomCrop::new(8, (0.5, 1.0), (1.0, 1.0));
let img = make_record(1, 1, None);
let out = crop.crop_and_resize(&img, 0);
assert_eq!(out.len(), 8 * 8 * 3);
}
// ── RandomHorizontalFlip ──────────────────────────────────────────────────
#[test]
fn test_flip_is_own_inverse() {
let flip = RandomHorizontalFlip::new(1.0); // always flip
let pixels: Vec<f32> = (0..4 * 4 * 3).map(|i| i as f32 / 48.0).collect();
let flipped = flip.apply(&pixels, 4, 4, 3, 0);
let restored = flip.apply(&flipped, 4, 4, 3, 0);
for (a, b) in pixels.iter().zip(restored.iter()) {
assert!((a - b).abs() < 1e-6, "double flip should be identity");
}
}
#[test]
fn test_flip_prob_zero_never_flips() {
let flip = RandomHorizontalFlip::new(0.0);
let pixels: Vec<f32> = (0..6 * 6 * 3).map(|i| i as f32 / 100.0).collect();
for seed in 0..50u64 {
let out = flip.apply(&pixels, 6, 6, 3, seed);
assert_eq!(out, pixels, "prob=0 must never flip");
}
}
#[test]
fn test_flip_prob_one_always_flips() {
let flip = RandomHorizontalFlip::new(1.0);
// Use an asymmetric image so flipping is detectable.
let mut pixels = vec![0.0f32; 4 * 4 * 3];
pixels[0] = 1.0; // top-left pixel, channel 0
for seed in 0..10u64 {
let out = flip.apply(&pixels, 4, 4, 3, seed);
// After flip, top-left is now what was top-right (which is 0.0)
assert!((out[0] - 0.0).abs() < 1e-6, "prob=1 must always flip");
// The original top-left value (1.0) is now at the rightmost position of row 0
assert!((out[(4 - 1) * 3] - 1.0).abs() < 1e-6);
}
}
#[test]
fn test_flip_output_same_size() {
let flip = RandomHorizontalFlip::new(0.5);
let pixels: Vec<f32> = vec![0.5; 8 * 8 * 3];
let out = flip.apply(&pixels, 8, 8, 3, 12345);
assert_eq!(out.len(), pixels.len());
}
// ── JepaAugmentationPipeline ──────────────────────────────────────────────
#[test]
fn test_normalize_changes_values() {
let mut pixels = vec![0.5f32; 6];
let mean = [0.485, 0.456, 0.406];
let std = [0.229, 0.224, 0.225];
JepaAugmentationPipeline::normalize(&mut pixels, mean, std);
// 0.5 normalized should differ from 0.5
assert!(
(pixels[0] - 0.5).abs() > 1e-4,
"normalization must change values"
);
}
#[test]
fn test_augmentation_pipeline_output_shape() {
let config = JepaDataConfig {
image_size: 32,
patch_size: 8,
imagenet_normalize: true,
use_horizontal_flip: true,
scale_range: (0.5, 1.0),
ratio_range: (0.75, 1.33),
..Default::default()
};
let pipeline = JepaAugmentationPipeline::from_config(&config);
let img = make_record(64, 64, None);
let out = pipeline.process(&img, 0);
assert_eq!(out.len(), 32 * 32 * 3);
}
#[test]
fn test_augmentation_consistent_shape_across_samples() {
let config = JepaDataConfig {
image_size: 32,
patch_size: 8,
imagenet_normalize: true,
use_horizontal_flip: true,
scale_range: (0.5, 1.0),
ratio_range: (0.75, 1.33),
..Default::default()
};
let pipeline = JepaAugmentationPipeline::from_config(&config);
for seed in 0..20u64 {
let img = make_record(64, 64, None);
let out = pipeline.process(&img, seed);
assert_eq!(out.len(), 32 * 32 * 3, "seed {seed}: wrong output size");
}
}
// ── InMemoryShard ─────────────────────────────────────────────────────────
#[test]
fn test_in_memory_shard_record_count() {
let shard = InMemoryShard::synthetic(50, 32, 0);
assert_eq!(shard.len(), 50);
assert!(!shard.is_empty());
}
#[test]
fn test_in_memory_shard_zero() {
let shard = InMemoryShard::synthetic(0, 32, 0);
assert!(shard.is_empty());
}
#[test]
fn test_in_memory_shard_pixel_range() {
let shard = InMemoryShard::synthetic(5, 16, 1);
for rec in &shard.records {
for &px in &rec.pixels {
assert!(px >= 0.0 && px <= 1.0, "pixel {px} out of [0,1]");
}
}
}
#[test]
fn test_in_memory_shard_sequential_labels() {
let shard = InMemoryShard::synthetic(10, 16, 0);
for (i, rec) in shard.records.iter().enumerate() {
assert_eq!(rec.label, Some(i % 1000));
}
}
// ── JepaDataPipeline ─────────────────────────────────────────────────────
#[test]
fn test_pipeline_num_batches_per_epoch() {
// 2 shards × 10 images = 20 total; batch_size=8 → ceil(20/8)=3
let pipeline = make_pipeline(2, 10, 8);
assert_eq!(pipeline.num_batches_per_epoch(), 3);
}
#[test]
fn test_pipeline_next_batch_exhausts_epoch() {
let mut pipeline = make_pipeline(1, 5, 2);
// 5 images, batch_size=2 → 3 batches (2+2+1)
let mut count = 0;
while pipeline.next_batch().is_some() {
count += 1;
}
assert_eq!(count, 3);
// After exhaustion, next_batch returns None again.
assert!(pipeline.next_batch().is_none());
}
#[test]
fn test_pipeline_batch_sizes_correct() {
let mut pipeline = make_pipeline(1, 7, 3);
// batch 0: 3, batch 1: 3, batch 2: 1
let b0 = pipeline.next_batch().expect("batch 0");
assert_eq!(b0.batch_size(), 3);
let b1 = pipeline.next_batch().expect("batch 1");
assert_eq!(b1.batch_size(), 3);
let b2 = pipeline.next_batch().expect("batch 2 (partial)");
assert_eq!(b2.batch_size(), 1);
assert!(pipeline.next_batch().is_none());
}
#[test]
fn test_pipeline_reset_epoch_allows_reiteration() {
let mut pipeline = make_pipeline(1, 4, 4);
assert!(pipeline.next_batch().is_some());
assert!(pipeline.next_batch().is_none());
pipeline.reset_epoch();
assert_eq!(pipeline.epoch(), 1);
assert!(pipeline.next_batch().is_some());
}
#[test]
fn test_pipeline_epoch_counter() {
let mut pipeline = make_pipeline(1, 2, 2);
assert_eq!(pipeline.epoch(), 0);
pipeline.reset_epoch();
assert_eq!(pipeline.epoch(), 1);
pipeline.reset_epoch();
assert_eq!(pipeline.epoch(), 2);
}
#[test]
fn test_pipeline_multi_shard() {
// 3 shards × 4 images = 12 total; batch_size=5 → 3 batches
let mut pipeline = make_pipeline(3, 4, 5);
let mut total = 0;
while let Some(b) = pipeline.next_batch() {
total += b.batch_size();
}
assert_eq!(total, 12);
}
#[test]
fn test_pipeline_batch_size_gt_shard_size() {
// batch_size larger than any single shard — should still work
let mut pipeline = make_pipeline(2, 3, 100);
// 6 total images → 1 batch of 6
let batch = pipeline.next_batch().expect("should yield one batch");
assert_eq!(batch.batch_size(), 6);
assert!(pipeline.next_batch().is_none());
}
#[test]
fn test_pipeline_single_shard() {
let mut pipeline = make_pipeline(1, 8, 4);
let b0 = pipeline.next_batch().unwrap();
let b1 = pipeline.next_batch().unwrap();
assert_eq!(b0.batch_size() + b1.batch_size(), 8);
assert!(pipeline.next_batch().is_none());
}
// ── JepaBatch mask indices ────────────────────────────────────────────────
#[test]
fn test_batch_context_target_non_overlapping() {
let mut pipeline = make_pipeline(1, 8, 4);
let batch = pipeline.next_batch().unwrap();
for (ctx, tgt) in batch
.context_patch_indices
.iter()
.zip(batch.all_target_indices.iter())
{
for &ci in ctx {
assert!(!tgt.contains(&ci), "context index {ci} in target set");
}
}
}
#[test]
fn test_batch_target_indices_in_range() {
let config = JepaDataConfig {
image_size: 32,
patch_size: 8,
batch_size: 4,
..JepaDataConfig::default()
};
let total_patches = config.num_patches(); // (32/8)^2 = 16
let shards = vec![InMemoryShard::synthetic(8, 32, 0)];
let mut pipeline = JepaDataPipeline::new(config, shards);
let batch = pipeline.next_batch().unwrap();
for tgt in &batch.all_target_indices {
for &idx in tgt {
assert!(idx < total_patches, "target index {idx} >= {total_patches}");
}
}
}
#[test]
fn test_batch_context_sorted_deduped() {
let mut pipeline = make_pipeline(1, 8, 4);
let batch = pipeline.next_batch().unwrap();
for ctx in &batch.context_patch_indices {
for w in ctx.windows(2) {
assert!(
w[0] < w[1],
"context indices must be sorted and deduplicated"
);
}
}
}
#[test]
fn test_batch_labels_match_shard() {
let mut pipeline = make_pipeline(1, 6, 6);
let batch = pipeline.next_batch().unwrap();
// All labels must be Some (synthetic shards always set labels)
for label in &batch.labels {
assert!(label.is_some());
}
}
#[test]
fn test_batch_id_increments() {
let mut pipeline = make_pipeline(1, 9, 3);
let b0 = pipeline.next_batch().unwrap();
let b1 = pipeline.next_batch().unwrap();
let b2 = pipeline.next_batch().unwrap();
assert_eq!(b0.batch_id, 0);
assert_eq!(b1.batch_id, 1);
assert_eq!(b2.batch_id, 2);
}
// ── DatasetStats ──────────────────────────────────────────────────────────
#[test]
fn test_dataset_stats_totals() {
let pipeline = make_pipeline(3, 10, 8);
let stats = DatasetStats::compute(&pipeline);
assert_eq!(stats.total_images, 30);
assert_eq!(stats.num_shards, 3);
assert_eq!(stats.images_per_shard, vec![10, 10, 10]);
}
#[test]
fn test_dataset_stats_mask_efficiency_positive() {
let pipeline = make_pipeline(1, 8, 4);
let stats = DatasetStats::compute(&pipeline);
assert!(stats.mask_efficiency > 0.0, "mask_efficiency should be > 0");
assert!(
stats.mask_efficiency <= 1.0,
"mask_efficiency should be <= 1"
);
}
#[test]
fn test_dataset_stats_avg_patches_positive() {
let pipeline = make_pipeline(1, 8, 4);
let stats = DatasetStats::compute(&pipeline);
assert!(stats.avg_context_patches > 0.0);
assert!(stats.avg_target_patches > 0.0);
}
// ── WebDatasetShard ───────────────────────────────────────────────────────
#[test]
fn test_web_dataset_shard_to_in_memory() {
let shard = WebDatasetShard::new("/data/imagenet/shard-0000.tar", 100, 0);
let mem = shard.to_in_memory(32);
assert_eq!(mem.len(), 100);
assert_eq!(mem.shard_id, 0);
}
#[test]
fn test_web_dataset_shard_compressed_flag() {
let gz = WebDatasetShard::new("data/shard.tar.gz", 10, 0);
assert!(gz.compressed);
let plain = WebDatasetShard::new("data/shard.tar", 10, 0);
assert!(!plain.compressed);
}
#[test]
fn test_web_dataset_shard_pixel_count() {
let shard = WebDatasetShard::new("test.tar", 5, 1);
let mem = shard.to_in_memory(16);
for rec in &mem.records {
assert_eq!(rec.pixels.len(), 16 * 16 * 3);
}
}
// ── Tar parsing helpers ───────────────────────────────────────────────────
/// Build a tar header block for a regular file with the given filename and size.
fn make_tar_header(filename: &str, size: u64) -> [u8; 512] {
let mut block = [0u8; 512];
// filename: bytes 0..100
let fname_bytes = filename.as_bytes();
block[..fname_bytes.len().min(99)]
.copy_from_slice(&fname_bytes[..fname_bytes.len().min(99)]);
// size: bytes 124..136, octal ASCII with trailing null
let octal = format!("{:011o}\0", size);
block[124..136].copy_from_slice(octal.as_bytes());
// typeflag: byte 156 = '0' for regular file
block[156] = b'0';
// checksum: bytes 148..156, sum of all header bytes with checksum field as spaces
for i in 148..156 {
block[i] = b' ';
}
let checksum: u32 = block.iter().map(|&b| b as u32).sum();
let chk_str = format!("{:06o}\0 ", checksum);
block[148..156].copy_from_slice(chk_str.as_bytes());
block
}
/// Build a tar header block for a directory entry.
fn make_tar_dir_header(dirname: &str) -> [u8; 512] {
let mut block = [0u8; 512];
let fname_bytes = dirname.as_bytes();
block[..fname_bytes.len().min(99)]
.copy_from_slice(&fname_bytes[..fname_bytes.len().min(99)]);
// size = 0
let octal = format!("{:011o}\0", 0u64);
block[124..136].copy_from_slice(octal.as_bytes());
// typeflag '5' = directory
block[156] = b'5';
for i in 148..156 {
block[i] = b' ';
}
let checksum: u32 = block.iter().map(|&b| b as u32).sum();
let chk_str = format!("{:06o}\0 ", checksum);
block[148..156].copy_from_slice(chk_str.as_bytes());
block
}
/// Build a minimal valid tar archive from (key, image_bytes, label) tuples.
///
/// Each entry emits a `{key}.jpg` file and, if label is Some, a `{key}.cls` file.
fn make_test_tar(records: &[(&str, &[u8], Option<usize>)]) -> Vec<u8> {
let mut out: Vec<u8> = Vec::new();
for (key, image_bytes, label) in records {
// Image file
let img_name = format!("{}.jpg", key);
let img_size = image_bytes.len() as u64;
out.extend_from_slice(&make_tar_header(&img_name, img_size));
out.extend_from_slice(image_bytes);
// Pad to next 512-byte boundary
let pad = (512 - (image_bytes.len() % 512)) % 512;
out.extend(std::iter::repeat(0u8).take(pad));
// Class label file (optional)
if let Some(lbl) = label {
let cls_content = format!("{}\n", lbl);
let cls_bytes = cls_content.as_bytes();
let cls_name = format!("{}.cls", key);
out.extend_from_slice(&make_tar_header(&cls_name, cls_bytes.len() as u64));
out.extend_from_slice(cls_bytes);
let cls_pad = (512 - (cls_bytes.len() % 512)) % 512;
out.extend(std::iter::repeat(0u8).take(cls_pad));
}
}
// End-of-archive: two zero blocks
out.extend(std::iter::repeat(0u8).take(1024));
out
}
// ── Tar parsing tests ─────────────────────────────────────────────────────
#[test]
fn test_webdataset_shard_load_reads_real_tar() {
// Write a real tar to a temp file and load it through WebDatasetShard.
let fake_jpg = b"\xFF\xD8\xFF\xE0fake jpeg content";
let tar = make_test_tar(&[("000000", fake_jpg, Some(7)), ("000001", fake_jpg, None)]);
let dir = std::env::temp_dir();
let path = dir.join(format!("jepa_shard_load_test_{}.tar", std::process::id()));
std::fs::write(&path, &tar).expect("write temp tar");
let shard = WebDatasetShard::new(path.to_str().unwrap(), 2, 3);
let mem = shard.load().expect("load real tar");
std::fs::remove_file(&path).ok();
assert_eq!(mem.shard_id, 3);
assert_eq!(mem.records.len(), 2);
assert_eq!(mem.records[0].label, Some(7));
assert_eq!(mem.records[1].label, None);
}
#[test]
fn test_webdataset_shard_load_reads_gzip_tar() {
use std::io::Write;
let fake_jpg = b"\xFF\xD8\xFF\xE0fake jpeg content";
let tar = make_test_tar(&[("000000", fake_jpg, Some(11))]);
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(&tar).expect("gzip write");
let gz = encoder.finish().expect("gzip finish");
let dir = std::env::temp_dir();
let path = dir.join(format!("jepa_shard_gz_test_{}.tar.gz", std::process::id()));
std::fs::write(&path, &gz).expect("write temp tar.gz");
let shard = WebDatasetShard::new(path.to_str().unwrap(), 1, 5);
let mem = shard.load().expect("load gzip tar");
std::fs::remove_file(&path).ok();
assert!(shard.compressed);
assert_eq!(mem.shard_id, 5);
assert_eq!(mem.records.len(), 1);
assert_eq!(mem.records[0].label, Some(11));
}
#[test]
fn test_parse_empty_tar() {
// Two zero blocks = empty archive
let data = vec![0u8; 1024];
let records = parse_tar_bytes(&data);
assert_eq!(records.len(), 0, "empty tar must yield 0 records");
}
#[test]
fn test_parse_single_record() {
let fake_jpg = b"\xFF\xD8\xFF\xE0fake jpeg content";
let tar = make_test_tar(&[("000000", fake_jpg, Some(42))]);
let records = parse_tar_bytes(&tar);
assert_eq!(records.len(), 1);
assert_eq!(records[0].key, "000000");
assert_eq!(records[0].label, Some(42));
assert_eq!(records[0].extension, "jpg");
assert_eq!(records[0].image_bytes, fake_jpg);
}
#[test]
fn test_parse_multiple_records() {
let fake_jpg = b"\xFF\xD8content";
let entries: Vec<(&str, &[u8], Option<usize>)> = vec![
("000000", fake_jpg, Some(0)),
("000001", fake_jpg, Some(1)),
("000002", fake_jpg, Some(2)),
("000003", fake_jpg, Some(3)),
("000004", fake_jpg, Some(4)),
];
let tar = make_test_tar(&entries);
let records = parse_tar_bytes(&tar);
assert_eq!(records.len(), 5, "expected 5 records");
for (i, rec) in records.iter().enumerate() {
assert_eq!(rec.label, Some(i));
}
}
#[test]
fn test_parse_record_without_cls() {
let fake_jpg = b"\xFF\xD8no label";
let tar = make_test_tar(&[("000000", fake_jpg, None)]);
let records = parse_tar_bytes(&tar);
assert_eq!(records.len(), 1);
assert_eq!(
records[0].label, None,
"image with no .cls should have label=None"
);
}
#[test]
fn test_parse_directory_entries_skipped() {
let mut out: Vec<u8> = Vec::new();
// Directory entry
out.extend_from_slice(&make_tar_dir_header("somedir/"));
// A regular image record after the directory
let fake_jpg = b"\xFF\xD8data";
let img_name = "000000.jpg";
out.extend_from_slice(&make_tar_header(img_name, fake_jpg.len() as u64));
out.extend_from_slice(fake_jpg);
let pad = (512 - (fake_jpg.len() % 512)) % 512;
out.extend(std::iter::repeat(0u8).take(pad));
// End-of-archive
out.extend(std::iter::repeat(0u8).take(1024));
let records = parse_tar_bytes(&out);
assert_eq!(records.len(), 1, "directory entries must be skipped");
assert_eq!(records[0].key, "000000");
}
#[test]
fn test_parse_large_file() {
// 50 000 bytes > 512*97 = 49664, spans many blocks
let big_image: Vec<u8> = (0..50_000u32).map(|i| (i % 251) as u8).collect();
let tar = make_test_tar(&[("000000", &big_image, Some(7))]);
let records = parse_tar_bytes(&tar);
assert_eq!(records.len(), 1);
assert_eq!(records[0].image_bytes.len(), 50_000);
assert_eq!(records[0].image_bytes, big_image);
}
// ── ShuffleBuffer tests ───────────────────────────────────────────────────
fn make_image_record(key: &str) -> ImageRecord {
ImageRecord {
pixels: vec![0.5f32; 8 * 8 * 3],
width: 8,
height: 8,
channels: 3,
label: None,
key: key.to_owned(),
}
}
#[test]
fn test_shuffle_buffer_fill() {
let mut buf = ShuffleBuffer::new(10);
for i in 0..10 {
buf.push(make_image_record(&format!("img{i}")));
}
assert_eq!(buf.len(), 10);
assert!(buf.is_full());
}
#[test]
fn test_shuffle_buffer_push_overflow() {
let mut buf = ShuffleBuffer::new(10);
for i in 0..15 {
buf.push(make_image_record(&format!("img{i}")));
}
assert_eq!(buf.len(), 10, "buffer must not grow beyond capacity");
}
#[test]
fn test_shuffle_buffer_drain() {
let mut buf = ShuffleBuffer::new(10);
for i in 0..10 {
buf.push(make_image_record(&format!("img{i}")));
}
let batch = buf.drain_batch(5);
assert_eq!(batch.len(), 5);
assert_eq!(buf.len(), 5);
}
#[test]
fn test_shuffle_buffer_empty_drain() {
let mut buf = ShuffleBuffer::new(10);
for i in 0..3 {
buf.push(make_image_record(&format!("img{i}")));
}
// Drain more than available
let batch = buf.drain_batch(100);
assert_eq!(
batch.len(),
3,
"drain should return at most what's in the buffer"
);
assert_eq!(buf.len(), 0);
// Drain zero
let empty = buf.drain_batch(0);
assert_eq!(empty.len(), 0);
}
// ── ShardLoadStats tests ──────────────────────────────────────────────────
#[test]
fn test_shard_load_stats_bytes() {
use std::io::Write;
// Write a real tar to a temp file and read it back.
let fake_jpg = b"\xFF\xD8fake";
let entries: Vec<(&str, &[u8], Option<usize>)> = vec![
("000000", fake_jpg, Some(0)),
("000001", fake_jpg, Some(1)),
("000002", fake_jpg, Some(2)),
];
let tar_bytes = make_test_tar(&entries);
let mut tmp = std::env::temp_dir();
tmp.push("jepa_test_shard_stats.tar");
{
let mut f = std::fs::File::create(&tmp).expect("create temp file");
f.write_all(&tar_bytes).expect("write tar");
}
let (records, stats) = read_webdataset_shard(&tmp).expect("read shard");
std::fs::remove_file(&tmp).ok();
assert_eq!(records.len(), 3);
assert!(stats.bytes_read > 0, "bytes_read must be > 0");
assert_eq!(stats.records_loaded, 3);
}
// ── from_filesystem tests ─────────────────────────────────────────────────
#[test]
fn test_from_filesystem_missing_path() {
let config = JepaDataConfig::default();
let missing = std::path::PathBuf::from("/nonexistent/path/shard-0000.tar");
let result = JepaDataPipeline::from_filesystem(config, vec![missing.clone()]);
assert!(result.is_err(), "missing path must return Err");
// Extract the error message without calling unwrap_err (JepaDataPipeline has no Debug).
let msg = match result {
Err(e) => e,
Ok(_) => panic!("expected Err"),
};
assert!(
msg.contains("nonexistent") || msg.contains("shard-0000.tar"),
"error message should mention the path: {msg}"
);
}
// ── webdataset_record_to_image ────────────────────────────────────────────
#[test]
fn test_webdataset_decode_placeholder_shape() {
let rec = WebDatasetRecord {
key: "test".to_string(),
image_bytes: vec![0u8; 100], // not a valid image
label: Some(1),
extension: "jpg".to_string(),
};
let img = webdataset_record_to_image(rec);
assert_eq!(img.pixels.len(), 224 * 224 * 3);
assert_eq!(img.width, 224);
assert_eq!(img.height, 224);
assert_eq!(img.channels, 3);
}
#[test]
fn test_webdataset_decode_placeholder_value() {
let rec = WebDatasetRecord {
key: "k".to_string(),
image_bytes: vec![],
label: None,
extension: "jpg".to_string(),
};
let img = webdataset_record_to_image(rec);
for &p in &img.pixels {
assert!(
(p - 0.5f32).abs() < 1e-6 || (0.0..=1.0).contains(&p),
"pixel {} must be in [0, 1]",
p
);
}
}
#[test]
fn test_webdataset_decode_label_preserved() {
let rec = WebDatasetRecord {
key: "sample_0000".to_string(),
image_bytes: vec![0xFF, 0xD8, 0xFF], // JPEG magic start
label: Some(42),
extension: "jpg".to_string(),
};
let img = webdataset_record_to_image(rec);
assert_eq!(img.label, Some(42));
}
#[test]
fn test_webdataset_decode_key_preserved() {
let rec = WebDatasetRecord {
key: "my_key_123".to_string(),
image_bytes: vec![],
label: None,
extension: "png".to_string(),
};
let img = webdataset_record_to_image(rec);
assert_eq!(img.key, "my_key_123");
}
// ── HTTP WebDataset ──────────────────────────────────────────────────────
// test: validate_urls accepts valid http URLs
#[test]
fn test_validate_urls_http_ok() {
let urls = vec![
"http://example.com/shard-0.tar".to_string(),
"https://storage.example.com/data/shard-1.tar".to_string(),
];
assert!(JepaDataPipeline::validate_urls(&urls).is_ok());
}
// test: validate_urls rejects non-http schemes
#[test]
fn test_validate_urls_bad_scheme() {
let urls = vec!["s3://bucket/shard.tar".to_string()];
assert!(JepaDataPipeline::validate_urls(&urls).is_err());
}
// test: validate_urls empty list is Ok
#[test]
fn test_validate_urls_empty() {
assert!(JepaDataPipeline::validate_urls(&[]).is_ok());
}
// test: from_urls with bad scheme returns Err immediately
#[test]
fn test_from_urls_bad_scheme_err() {
let config = JepaDataConfig::default();
let result =
JepaDataPipeline::from_urls(config, vec!["ftp://example.com/shard.tar".to_string()]);
assert!(result.is_err());
let msg = match result {
Err(e) => e,
Ok(_) => panic!("expected Err"),
};
assert!(msg.contains("unsupported URL scheme"), "error was: {msg}");
}
// test: download_webdataset_shard with invalid URL scheme returns Err
#[test]
fn test_download_bad_scheme() {
let result = download_webdataset_shard("file:///tmp/test.tar", 0);
assert!(result.is_err());
}
// test: download_webdataset_shard to a non-existent host returns Err
// 192.0.2.0/24 is reserved "documentation" space — guaranteed unreachable.
// Uses a 5-second timeout so the test completes quickly.
#[test]
fn test_download_unreachable_host() {
let result =
download_webdataset_shard_with_timeout("http://192.0.2.1:9999/shard.tar", 0, 5);
assert!(result.is_err(), "unreachable host should return Err");
}
// test: UrlShardDescriptor creation
#[test]
fn test_url_shard_descriptor_new() {
let desc = UrlShardDescriptor::new("https://example.com/shard-0.tar", 1000, 0);
assert_eq!(desc.expected_records, 1000);
assert_eq!(desc.shard_id, 0);
assert!(desc.is_https());
}
// test: UrlShardDescriptor::is_https with http URL
#[test]
fn test_url_shard_descriptor_is_http() {
let desc = UrlShardDescriptor::new("http://example.com/shard.tar", 0, 1);
assert!(!desc.is_https());
}
// test: UrlShardDescriptor clone
#[test]
fn test_url_shard_descriptor_clone() {
let desc = UrlShardDescriptor::new("https://a.b/c.tar", 42, 3);
let desc2 = desc.clone();
assert_eq!(desc2.url, desc.url);
assert_eq!(desc2.shard_id, 3);
}
// test: from_urls with empty list succeeds with empty pipeline
#[test]
fn test_from_urls_empty_list() {
let config = JepaDataConfig::default();
// Empty URL list → empty pipeline (no shards loaded)
let result = JepaDataPipeline::from_urls(config, vec![]);
assert!(result.is_ok());
}
}
#[cfg(all(test, feature = "image-decode"))]
mod tests_image_decode {
use super::*;
#[test]
fn test_webdataset_decode_real_image() {
// Create a minimal 1×1 PNG using the image crate itself
use image::{ImageBuffer, Rgb};
let img_buf: ImageBuffer<Rgb<u8>, Vec<u8>> =
ImageBuffer::from_pixel(1, 1, Rgb([128u8, 64u8, 32u8]));
let mut bytes = Vec::new();
img_buf
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageOutputFormat::Png,
)
.unwrap();
let rec = WebDatasetRecord {
key: "synthetic".to_string(),
image_bytes: bytes,
label: Some(0),
extension: "png".to_string(),
};
let decoded = webdataset_record_to_image(rec);
assert_eq!(decoded.pixels.len(), 224 * 224 * 3);
assert_eq!(decoded.width, 224);
assert_eq!(decoded.height, 224);
for &p in &decoded.pixels {
assert!((0.0..=1.0).contains(&p), "pixel {} out of range", p);
}
}
}