• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jzombie / rust-triplets / 22365976263

24 Feb 2026 07:09PM UTC coverage: 93.285% (-0.01%) from 93.296%
22365976263

Pull #8

github

web-flow
Merge adc37b0d6 into 4807bc8c3
Pull Request #8: Improve HF integration

274 of 337 new or added lines in 2 files covered. (81.31%)

2 existing lines in 1 file now uncovered.

14976 of 16054 relevant lines covered (93.29%)

2464.94 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.44
/src/source/backends/huggingface_source.rs
1
use hf_hub::Repo;
2
use hf_hub::RepoType;
3
use hf_hub::api::sync::ApiBuilder;
4
use parquet::file::reader::{FileReader, SerializedFileReader};
5
use parquet::record::reader::RowIter;
6
use rayon::prelude::*;
7
use serde::{Deserialize, Serialize};
8
use serde_json::{Value, json};
9
use std::cmp::Ordering;
10
use std::collections::hash_map::DefaultHasher;
11
use std::collections::{BTreeMap, HashMap, VecDeque};
12
use std::fs;
13
use std::fs::File;
14
use std::hash::{Hash, Hasher};
15
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
16
use std::path::Path;
17
use std::path::PathBuf;
18
use std::sync::{Arc, Mutex};
19
use std::thread;
20
use std::time::Duration;
21
use std::time::Instant;
22
use tracing::{info, warn};
23
use walkdir::WalkDir;
24

25
use crate::SamplerError;
26
use crate::config::{NegativeStrategy, SamplerConfig, Selector, TripletRecipe};
27
use crate::data::{DataRecord, QualityScore, SectionRole};
28
use crate::utils::make_section;
29
use chrono::{DateTime, Utc};
30

31
use crate::source::{DataSource, SourceCursor, SourceSnapshot};
32

33
const REMOTE_URL_PREFIX: &str = "url::";
34
/// Extra row-index headroom above currently materialized rows exposed via `len_hint`.
35
///
36
/// This is not a file count. It lets sampling look slightly past the local row
37
/// frontier so lazy remote expansion can continue without jumping to the full
38
/// global row domain at once.
39
/// Multiplies the sampler ingestion base (`SamplerConfig.ingestion_max_records`)
40
/// to compute `len_hint` expansion headroom rows.
41
const REMOTE_EXPANSION_HEADROOM_MULTIPLIER: usize = 4;
42
/// Number of initial remote shards to materialize when bootstrapping an empty
43
/// local snapshot before regular lazy expansion.
44
const REMOTE_BOOTSTRAP_SHARDS: usize = 4;
45
/// Multiplies the source `refresh` limit passed by `IngestionManager`
46
/// (`step.unwrap_or(max_records)`) to set this source's internal row-read
47
/// batch target for each refresh pass.
48
const HUGGINGFACE_REFRESH_BATCH_MULTIPLIER: usize = 32;
49
const SHARD_SEQUENCE_STATE_VERSION: u32 = 1;
50
const SHARD_SEQUENCE_STATE_FILE: &str = "_sequence_state.json";
51

52
#[derive(Clone, Debug)]
53
struct RowTextField {
54
    name: String,
55
    text: String,
56
}
57

58
#[derive(Clone, Debug)]
59
struct RowView {
60
    row_id: Option<String>,
61
    timestamp: Option<DateTime<Utc>>,
62
    text_fields: Vec<RowTextField>,
63
}
64

65
/// Parsed Hugging Face source-list entry with explicit field mappings.
66
#[derive(Clone, Debug, PartialEq, Eq)]
67
pub struct HfSourceEntry {
68
    /// Full hf:// URI for dataset/config/split.
69
    pub uri: String,
70
    /// Optional anchor column name.
71
    pub anchor_column: Option<String>,
72
    /// Optional positive column name.
73
    pub positive_column: Option<String>,
74
    /// Optional context columns (ordered).
75
    pub context_columns: Vec<String>,
76
    /// Optional text columns (ordered) for text-columns mode.
77
    pub text_columns: Vec<String>,
78
}
79

80
/// Parsed Hugging Face source list with explicit mappings and caps.
81
#[derive(Debug, Clone)]
82
pub struct HfListRoots {
83
    /// The source list file path used for loading.
84
    pub source_list: String,
85
    /// Parsed sources with explicit field mappings.
86
    pub sources: Vec<HfSourceEntry>,
87
    /// Optional maximum rows per source.
88
    pub max_rows_per_source: Option<usize>,
89
}
90

91
/// Split a comma-delimited field list into trimmed column names.
92
pub fn parse_csv_fields(value: &str) -> Vec<String> {
5✔
93
    value
5✔
94
        .split(',')
5✔
95
        .map(str::trim)
5✔
96
        .filter(|entry| !entry.is_empty())
10✔
97
        .map(ToString::to_string)
5✔
98
        .collect()
5✔
99
}
5✔
100

101
/// Parse a single source-list line of the form:
102
/// `hf://org/dataset/config/split anchor=... positive=... context=a,b text=x,y`.
103
pub fn parse_hf_source_line(line: &str) -> Result<HfSourceEntry, String> {
7✔
104
    let mut parts = line.split_whitespace();
7✔
105
    let Some(uri) = parts.next() else {
7✔
106
        return Err("empty source line".to_string());
1✔
107
    };
108
    if !uri.starts_with("hf://") {
6✔
109
        return Err(format!("unsupported source URI (expected hf://...): {uri}"));
1✔
110
    }
5✔
111

112
    let mut entry = HfSourceEntry {
5✔
113
        uri: uri.to_string(),
5✔
114
        anchor_column: None,
5✔
115
        positive_column: None,
5✔
116
        context_columns: Vec::new(),
5✔
117
        text_columns: Vec::new(),
5✔
118
    };
5✔
119

120
    for token in parts {
10✔
121
        let Some((raw_key, raw_value)) = token.split_once('=') else {
10✔
122
            return Err(format!(
1✔
123
                "invalid mapping token '{token}' (expected key=value)"
1✔
124
            ));
1✔
125
        };
126
        let key = raw_key.trim().to_ascii_lowercase();
9✔
127
        let value = raw_value.trim();
9✔
128
        match key.as_str() {
9✔
129
            "anchor" => {
9✔
130
                entry.anchor_column = (!value.is_empty()).then(|| value.to_string());
2✔
131
            }
132
            "positive" => {
7✔
133
                entry.positive_column = (!value.is_empty()).then(|| value.to_string());
2✔
134
            }
135
            "context" => {
5✔
136
                entry.context_columns = parse_csv_fields(value);
2✔
137
            }
2✔
138
            "text" | "text_columns" => {
3✔
139
                entry.text_columns = parse_csv_fields(value);
2✔
140
            }
2✔
141
            _ => {
142
                return Err(format!("unsupported mapping key '{raw_key}'"));
1✔
143
            }
144
        }
145
    }
146

147
    let has_explicit_mapping = entry.anchor_column.is_some()
3✔
148
        || entry.positive_column.is_some()
1✔
149
        || !entry.context_columns.is_empty()
1✔
150
        || !entry.text_columns.is_empty();
1✔
151
    if !has_explicit_mapping {
3✔
152
        return Err(format!(
1✔
153
            "source '{}' has no field mapping; expected at least one of anchor=, positive=, context=, text=",
1✔
154
            entry.uri
1✔
155
        ));
1✔
156
    }
2✔
157

158
    Ok(entry)
2✔
159
}
7✔
160

161
/// Parse an hf:// URI into dataset/config/split components.
162
pub fn parse_hf_uri(uri: &str) -> Result<(String, String, String), String> {
4✔
163
    let trimmed = uri.trim();
4✔
164
    let Some(rest) = trimmed.strip_prefix("hf://") else {
4✔
165
        return Err(format!(
1✔
166
            "unsupported source URI (expected hf://...): {trimmed}"
1✔
167
        ));
1✔
168
    };
169

170
    let parts = rest
3✔
171
        .split('/')
3✔
172
        .filter(|part| !part.trim().is_empty())
4✔
173
        .collect::<Vec<_>>();
3✔
174

175
    if parts.len() < 2 {
3✔
176
        return Err(format!("invalid hf URI (need hf://org/dataset): {trimmed}"));
2✔
177
    }
1✔
178

179
    let dataset = format!("{}/{}", parts[0], parts[1]);
1✔
180
    let config = parts.get(2).copied().unwrap_or("default").to_string();
1✔
181
    let split = parts.get(3).copied().unwrap_or("train").to_string();
1✔
182

183
    Ok((dataset, config, split))
1✔
184
}
4✔
185

186
/// Load a Hugging Face source list file containing explicit field mappings.
187
pub fn load_hf_sources_from_list(path: &str) -> Result<Vec<HfSourceEntry>, String> {
2✔
188
    let body = fs::read_to_string(path).map_err(|err| format!("{err}"))?;
2✔
189
    let mut out = Vec::new();
2✔
190
    for (line_no, raw) in body.lines().enumerate() {
5✔
191
        let line = raw.trim();
5✔
192
        if line.is_empty() || line.starts_with('#') {
5✔
193
            continue;
4✔
194
        }
1✔
195
        let parsed = parse_hf_source_line(line).map_err(|err| {
1✔
NEW
196
            format!(
×
197
                "invalid source-list entry at {}:{} -> {}",
198
                path,
NEW
199
                line_no + 1,
×
200
                err
201
            )
NEW
202
        })?;
×
203
        out.push(parsed);
1✔
204
    }
205
    Ok(out)
2✔
206
}
2✔
207

208
/// Resolve parsed Hugging Face source list entries and caps into a structured root.
209
pub fn resolve_hf_list_roots(
1✔
210
    source_list: String,
1✔
211
    max_rows_per_source: Option<usize>,
1✔
212
) -> Result<HfListRoots, String> {
1✔
213
    let sources = load_hf_sources_from_list(&source_list)?;
1✔
214
    if sources.is_empty() {
1✔
215
        return Err(format!("no hf:// entries found in {}", source_list));
1✔
NEW
216
    }
×
NEW
217
    Ok(HfListRoots {
×
NEW
218
        source_list,
×
NEW
219
        sources,
×
NEW
220
        max_rows_per_source,
×
NEW
221
    })
×
222
}
1✔
223

224
/// Build Hugging Face row sources from a parsed source list.
225
pub fn build_hf_sources(roots: &HfListRoots) -> Vec<Box<dyn DataSource + 'static>> {
1✔
226
    roots
1✔
227
        .sources
1✔
228
        .iter()
1✔
229
        .enumerate()
1✔
230
        .filter_map(|(idx, source)| {
1✔
231
            let (dataset, config, split) = match parse_hf_uri(&source.uri) {
1✔
NEW
232
                Ok(parsed) => parsed,
×
233
                Err(err) => {
1✔
234
                    eprintln!("Skipping invalid source URI '{}': {}", source.uri, err);
1✔
235
                    return None;
1✔
236
                }
237
            };
238

NEW
239
            let source_id = format!("hf_list_{idx}");
×
NEW
240
            let snapshot_dir = PathBuf::from(".hf-snapshots")
×
NEW
241
                .join("source-list")
×
NEW
242
                .join(dataset.replace('/', "__"))
×
NEW
243
                .join(&config)
×
NEW
244
                .join(&split)
×
NEW
245
                .join(format!("replica_{idx}"));
×
246

NEW
247
            let mut hf = HuggingFaceRowsConfig::new(
×
NEW
248
                source_id,
×
NEW
249
                dataset,
×
NEW
250
                config,
×
NEW
251
                split,
×
NEW
252
                snapshot_dir,
×
253
            );
NEW
254
            hf.anchor_column = source.anchor_column.clone();
×
NEW
255
            hf.positive_column = source.positive_column.clone();
×
NEW
256
            hf.context_columns = source.context_columns.clone();
×
NEW
257
            hf.text_columns = source.text_columns.clone();
×
NEW
258
            println!(
×
259
                "source {idx}: hf://{}/{}/{} -> anchor={:?}, positive={:?}, context={:?}, text_columns={:?}",
260
                hf.dataset,
261
                hf.config,
262
                hf.split,
263
                hf.anchor_column,
264
                hf.positive_column,
265
                hf.context_columns,
266
                hf.text_columns
267
            );
NEW
268
            hf.max_rows = roots.max_rows_per_source;
×
269

NEW
270
            match HuggingFaceRowSource::new(hf) {
×
NEW
271
                Ok(source) => Some(Box::new(source) as Box<dyn DataSource + 'static>),
×
NEW
272
                Err(err) => {
×
NEW
273
                    eprintln!(
×
274
                        "Skipping Hugging Face source initialization for '{}': {}",
275
                        source.uri, err
276
                    );
NEW
277
                    None
×
278
                }
279
            }
280
        })
1✔
281
        .collect()
1✔
282
}
1✔
283

284
/// Configuration for a bulk Hugging Face row source backed by local snapshot files.
285
#[derive(Clone, Debug)]
286
pub struct HuggingFaceRowsConfig {
287
    /// Stable sampler source id used in record ids and metrics.
288
    pub source_id: String,
289
    /// Hugging Face dataset id, e.g. `HuggingFaceFW/fineweb`.
290
    pub dataset: String,
291
    /// Dataset config name, e.g. `default`.
292
    pub config: String,
293
    /// Split name, e.g. `train`.
294
    pub split: String,
295
    /// Local path to a snapshot directory for this split.
296
    pub snapshot_dir: PathBuf,
297
    /// File extensions accepted as shard files.
298
    ///
299
    /// Non-parquet files are read as line-delimited entries. Each line may be:
300
    /// - a JSON object row (for example JSONL/NDJSON), or
301
    /// - plain text, which is wrapped as `{ "text": "..." }`.
302
    pub shard_extensions: Vec<String>,
303
    /// Number of rows between seek checkpoints while indexing a shard.
304
    pub checkpoint_stride: usize,
305
    /// Maximum number of rows cached in-memory.
306
    pub cache_capacity: usize,
307
    /// Maximum number of decoded parquet row groups cached in-memory.
308
    pub parquet_row_group_cache_capacity: usize,
309
    /// Multiplier applied to current refresh `limit` when building a read batch target.
310
    ///
311
    /// Effective target is `limit * refresh_batch_multiplier`.
312
    pub refresh_batch_multiplier: usize,
313
    /// Multiplier applied to ingestion-sized base records for `len_hint` headroom.
314
    ///
315
    /// Effective headroom is `cache_capacity * remote_expansion_headroom_multiplier`.
316
    pub remote_expansion_headroom_multiplier: usize,
317
    /// Optional maximum row cap exposed by the source.
318
    pub max_rows: Option<usize>,
319
    /// Hard cap for local manifest-shard cache bytes.
320
    ///
321
    /// When exceeded, oldest cached manifest shards are evicted.
322
    pub local_disk_cap_bytes: Option<u64>,
323
    /// Minimum number of manifest shards to keep resident during eviction.
324
    pub min_resident_shards: usize,
325
    /// Optional row id column name. Falls back to synthetic id when missing.
326
    pub id_column: Option<String>,
327
    /// Text columns to extract. Empty means auto-detect textual scalar columns.
328
    pub text_columns: Vec<String>,
329
    /// Optional column used for anchor text.
330
    ///
331
    /// When set (or when `positive_column`/`context_columns` are set), role-based
332
    /// extraction is used instead of `text_columns`/auto-detect mode.
333
    pub anchor_column: Option<String>,
334
    /// Optional column used for positive text.
335
    ///
336
    /// Positive text is emitted as a `SectionRole::Context` section.
337
    pub positive_column: Option<String>,
338
    /// Optional ordered context columns.
339
    ///
340
    /// Used only in role-based extraction mode.
341
    pub context_columns: Vec<String>,
342
}
343

344
impl HuggingFaceRowsConfig {
345
    /// Create a config with required dataset identity values and local snapshot path.
346
    pub fn new(
135✔
347
        source_id: impl Into<String>,
135✔
348
        dataset: impl Into<String>,
135✔
349
        config: impl Into<String>,
135✔
350
        split: impl Into<String>,
135✔
351
        snapshot_dir: impl Into<PathBuf>,
135✔
352
    ) -> Self {
135✔
353
        Self {
135✔
354
            source_id: source_id.into(),
135✔
355
            dataset: dataset.into(),
135✔
356
            config: config.into(),
135✔
357
            split: split.into(),
135✔
358
            snapshot_dir: snapshot_dir.into(),
135✔
359
            shard_extensions: vec![
135✔
360
                "parquet".to_string(),
135✔
361
                "jsonl".to_string(),
135✔
362
                "ndjson".to_string(),
135✔
363
            ],
135✔
364
            checkpoint_stride: 4096,
135✔
365
            cache_capacity: SamplerConfig::default().ingestion_max_records,
135✔
366
            parquet_row_group_cache_capacity: 8,
135✔
367
            refresh_batch_multiplier: HUGGINGFACE_REFRESH_BATCH_MULTIPLIER,
135✔
368
            remote_expansion_headroom_multiplier: REMOTE_EXPANSION_HEADROOM_MULTIPLIER,
135✔
369
            max_rows: None,
135✔
370
            local_disk_cap_bytes: Some(32 * 1024 * 1024 * 1024),
135✔
371
            min_resident_shards: REMOTE_BOOTSTRAP_SHARDS,
135✔
372
            id_column: Some("id".to_string()),
135✔
373
            text_columns: Vec::new(),
135✔
374
            anchor_column: None,
135✔
375
            positive_column: None,
135✔
376
            context_columns: Vec::new(),
135✔
377
        }
135✔
378
    }
135✔
379
}
380

381
#[derive(Default)]
382
struct ParquetCache {
383
    readers: HashMap<PathBuf, Arc<SerializedFileReader<File>>>,
384
}
385

386
impl ParquetCache {
387
    /// Return a cached parquet reader for `path`, opening and caching it when missing.
388
    fn reader_for(
7✔
389
        &mut self,
7✔
390
        source_id: &str,
7✔
391
        path: &Path,
7✔
392
    ) -> Result<Arc<SerializedFileReader<File>>, SamplerError> {
7✔
393
        if let Some(reader) = self.readers.get(path) {
7✔
394
            return Ok(reader.clone());
×
395
        }
7✔
396

397
        let file = File::open(path).map_err(|err| SamplerError::SourceUnavailable {
7✔
398
            source_id: source_id.to_string(),
2✔
399
            reason: format!("failed opening parquet shard {}: {err}", path.display()),
2✔
400
        })?;
2✔
401
        let reader =
4✔
402
            SerializedFileReader::new(file).map_err(|err| SamplerError::SourceUnavailable {
5✔
403
                source_id: source_id.to_string(),
1✔
404
                reason: format!("failed reading parquet shard {}: {err}", path.display()),
1✔
405
            })?;
1✔
406
        let reader = Arc::new(reader);
4✔
407
        self.readers.insert(path.to_path_buf(), reader.clone());
4✔
408
        Ok(reader)
4✔
409
    }
7✔
410
}
411

412
#[derive(Clone, Debug)]
413
struct ShardIndex {
414
    path: PathBuf,
415
    global_start: usize,
416
    row_count: usize,
417
    is_parquet: bool,
418
    parquet_row_groups: Vec<(usize, usize)>,
419
    checkpoints: Vec<u64>,
420
}
421

422
#[derive(Default)]
423
struct RowCache {
424
    rows: HashMap<usize, RowView>,
425
    order: VecDeque<usize>,
426
}
427

428
impl RowCache {
429
    /// Return a cloned cached row by absolute index.
430
    fn get(&self, idx: usize) -> Option<RowView> {
67✔
431
        self.rows.get(&idx).cloned()
67✔
432
    }
67✔
433

434
    /// Insert or refresh a cached row and evict oldest entries over `capacity`.
435
    fn insert(&mut self, idx: usize, row: RowView, capacity: usize) {
54✔
436
        if capacity == 0 {
54✔
437
            return;
1✔
438
        }
53✔
439
        if !self.rows.contains_key(&idx) {
53✔
440
            self.order.push_back(idx);
53✔
441
        }
53✔
442
        self.rows.insert(idx, row);
53✔
443
        while self.rows.len() > capacity {
54✔
444
            if let Some(old) = self.order.pop_front() {
1✔
445
                self.rows.remove(&old);
1✔
446
            } else {
1✔
447
                break;
×
448
            }
449
        }
450
    }
54✔
451
}
452

453
/// Bulk-oriented Hugging Face source backed by local shard files.
454
pub struct HuggingFaceRowSource {
455
    config: HuggingFaceRowsConfig,
456
    sampler_config: Mutex<Option<SamplerConfig>>,
457
    state: Mutex<SourceState>,
458
    cache: Mutex<RowCache>,
459
    parquet_cache: Mutex<ParquetCache>,
460
}
461

462
#[derive(Debug)]
463
struct SourceState {
464
    materialized_rows: usize,
465
    total_rows: Option<usize>,
466
    shards: Vec<ShardIndex>,
467
    remote_candidates: Option<Vec<String>>,
468
    remote_candidate_sizes: HashMap<String, u64>,
469
    next_remote_idx: usize,
470
}
471

472
type ParquetGroupKey = (PathBuf, usize);
473
type ParquetGroupRequest = (usize, usize, ShardIndex);
474

475
#[derive(Clone, Debug, Serialize, Deserialize)]
476
struct PersistedShardSequence {
477
    version: u32,
478
    source_id: String,
479
    dataset: String,
480
    config: String,
481
    split: String,
482
    sampler_seed: u64,
483
    candidates: Vec<String>,
484
    candidate_sizes: HashMap<String, u64>,
485
    next_remote_idx: usize,
486
}
487

488
impl HuggingFaceRowSource {
489
    /// Build a new source by indexing local shard files.
490
    pub fn new(config: HuggingFaceRowsConfig) -> Result<Self, SamplerError> {
13✔
491
        let start_new = Instant::now();
13✔
492
        if config.checkpoint_stride == 0 {
13✔
493
            return Err(SamplerError::Configuration(
1✔
494
                "huggingface source checkpoint_stride must be > 0".to_string(),
1✔
495
            ));
1✔
496
        }
12✔
497

498
        fs::create_dir_all(&config.snapshot_dir).map_err(|err| {
12✔
499
            SamplerError::SourceUnavailable {
×
500
                source_id: config.source_id.clone(),
×
501
                reason: format!(
×
502
                    "failed creating snapshot_dir {}: {err}",
×
503
                    config.snapshot_dir.display()
×
504
                ),
×
505
            }
×
506
        })?;
×
507

508
        info!(
12✔
509
            "[triplets:hf] indexing local shards in {}",
510
            config.snapshot_dir.display()
×
511
        );
512
        let (shards, discovered) = Self::build_shard_index(&config).unwrap_or_default();
12✔
513
        if discovered == 0 {
12✔
514
            info!(
×
515
                "[triplets:hf] no local shards found in {} — lazy remote download enabled",
516
                config.snapshot_dir.display()
×
517
            );
518
        }
12✔
519

520
        let materialized_rows = config
12✔
521
            .max_rows
12✔
522
            .map(|cap| cap.min(discovered))
12✔
523
            .unwrap_or(discovered);
12✔
524
        let total_rows = match Self::fetch_global_row_count(&config) {
12✔
525
            Ok(value) => value,
×
526
            Err(err) => {
12✔
527
                warn!(
12✔
528
                    "[triplets:hf] global row count request failed; continuing with discovered rows only: {}",
529
                    err
530
                );
531
                None
12✔
532
            }
533
        };
534

535
        if let Some(global_total) = total_rows {
12✔
536
            info!(
×
537
                "[triplets:hf] global split row count reported: {} (known_local_rows={})",
538
                global_total, materialized_rows
539
            );
540
        }
12✔
541

542
        info!(
12✔
543
            "[triplets:hf] source ready in {:.2}s (rows={}, shards={})",
544
            start_new.elapsed().as_secs_f64(),
×
545
            materialized_rows,
546
            shards.len()
×
547
        );
548

549
        Ok(Self {
12✔
550
            config,
12✔
551
            sampler_config: Mutex::new(None),
12✔
552
            state: Mutex::new(SourceState {
12✔
553
                materialized_rows,
12✔
554
                total_rows,
12✔
555
                shards,
12✔
556
                remote_candidates: None,
12✔
557
                remote_candidate_sizes: HashMap::new(),
12✔
558
                next_remote_idx: 0,
12✔
559
            }),
12✔
560
            cache: Mutex::new(RowCache::default()),
12✔
561
            parquet_cache: Mutex::new(ParquetCache::default()),
12✔
562
        })
12✔
563
    }
13✔
564

565
    fn set_active_sampler_config(&self, config: &SamplerConfig) {
98✔
566
        if let Ok(mut slot) = self.sampler_config.lock() {
98✔
567
            *slot = Some(config.clone());
98✔
568
        }
98✔
569
    }
98✔
570

571
    #[cfg(test)]
572
    fn active_or_default_sampler_config(&self) -> SamplerConfig {
12✔
573
        self.sampler_config
12✔
574
            .lock()
12✔
575
            .ok()
12✔
576
            .and_then(|slot| slot.clone())
12✔
577
            .unwrap_or_default()
12✔
578
    }
12✔
579

580
    #[cfg(test)]
581
    fn configure_sampler(&self, config: &SamplerConfig) {
6✔
582
        self.set_active_sampler_config(config);
6✔
583
    }
6✔
584

585
    #[cfg(test)]
586
    fn refresh(
9✔
587
        &self,
9✔
588
        cursor: Option<&SourceCursor>,
9✔
589
        limit: Option<usize>,
9✔
590
    ) -> Result<SourceSnapshot, SamplerError> {
9✔
591
        let config = self.active_or_default_sampler_config();
9✔
592
        <Self as DataSource>::refresh(self, &config, cursor, limit)
9✔
593
    }
9✔
594

595
    #[cfg(test)]
596
    fn reported_record_count(&self) -> Result<u128, SamplerError> {
3✔
597
        let config = self.active_or_default_sampler_config();
3✔
598
        <Self as DataSource>::reported_record_count(self, &config)
3✔
599
    }
3✔
600

601
    /// Compute the effective internal row read target from refresh `limit`.
602
    fn effective_refresh_batch_target(&self, limit: usize) -> usize {
22✔
603
        let multiplier = self.config.refresh_batch_multiplier.max(1);
22✔
604
        limit.saturating_mul(multiplier)
22✔
605
    }
22✔
606

607
    /// Compute dynamic `len_hint` headroom rows based on sampler and source config.
608
    fn effective_expansion_headroom_rows(&self) -> usize {
8✔
609
        let multiplier = self.config.remote_expansion_headroom_multiplier.max(1);
8✔
610
        let base = self
8✔
611
            .sampler_config
8✔
612
            .lock()
8✔
613
            .ok()
8✔
614
            .and_then(|config| config.as_ref().map(|value| value.ingestion_max_records))
8✔
615
            .unwrap_or(self.config.cache_capacity)
8✔
616
            .max(1);
8✔
617
        base.saturating_mul(multiplier)
8✔
618
    }
8✔
619

620
    fn configured_sampler_seed(&self) -> Result<u64, SamplerError> {
38✔
621
        self.sampler_config
38✔
622
            .lock()
38✔
623
            .map_err(|_| SamplerError::SourceUnavailable {
38✔
624
                source_id: self.config.source_id.clone(),
×
625
                reason: "huggingface sampler-config lock poisoned".to_string(),
×
626
            })?
×
627
            .as_ref()
38✔
628
            .map(|config| config.seed)
38✔
629
            .ok_or_else(|| SamplerError::SourceInconsistent {
38✔
630
                source_id: self.config.source_id.clone(),
2✔
631
                details: "huggingface source sampler configuration not provided".to_string(),
2✔
632
            })
2✔
633
    }
38✔
634

635
    fn paging_seed(&self, total: usize) -> Result<u64, SamplerError> {
21✔
636
        let sampler_seed = self.configured_sampler_seed()?;
21✔
637
        Ok(crate::source::IndexablePager::seed_for_sampler(
20✔
638
            &self.config.source_id,
20✔
639
            total,
20✔
640
            sampler_seed,
20✔
641
        ))
20✔
642
    }
21✔
643

644
    fn normalized_shard_extensions(config: &HuggingFaceRowsConfig) -> Vec<String> {
14✔
645
        config
14✔
646
            .shard_extensions
14✔
647
            .iter()
14✔
648
            .map(|value| value.trim().trim_start_matches('.').to_ascii_lowercase())
39✔
649
            .collect::<Vec<_>>()
14✔
650
    }
14✔
651

652
    fn collect_candidates_from_siblings(
9✔
653
        config: &HuggingFaceRowsConfig,
9✔
654
        siblings: &[String],
9✔
655
        accepted: &[String],
9✔
656
        respect_split: bool,
9✔
657
    ) -> (Vec<String>, bool) {
9✔
658
        let mut saw_parquet = false;
9✔
659
        let mut candidates = Vec::new();
9✔
660
        for remote_path in siblings {
16✔
661
            if respect_split && !config.split.is_empty() {
16✔
662
                let split_tag = format!("{}/", config.split);
12✔
663
                let split_token = format!("-{}-", config.split);
12✔
664
                let split_prefix = format!("{}-", config.split);
12✔
665
                if !remote_path.contains(&split_tag)
12✔
666
                    && !remote_path.contains(&split_token)
7✔
667
                    && !Path::new(remote_path)
7✔
668
                        .file_name()
7✔
669
                        .and_then(|name| name.to_str())
7✔
670
                        .is_some_and(|name| name.starts_with(&split_prefix))
7✔
671
                {
672
                    continue;
4✔
673
                }
8✔
674
            }
4✔
675

676
            let ext = Path::new(remote_path)
12✔
677
                .extension()
12✔
678
                .and_then(|v| v.to_str())
12✔
679
                .map(|v| v.to_ascii_lowercase());
12✔
680
            if ext.as_deref() == Some("parquet") {
12✔
681
                saw_parquet = true;
3✔
682
            }
9✔
683
            if ext
12✔
684
                .as_deref()
12✔
685
                .is_some_and(|ext| accepted.iter().any(|allowed| allowed == ext))
30✔
686
            {
687
                let target = Self::candidate_target_path(config, remote_path);
7✔
688
                if target.exists() {
7✔
689
                    continue;
1✔
690
                }
6✔
691
                candidates.push(remote_path.clone());
6✔
692
            }
5✔
693
        }
694
        (candidates, saw_parquet)
9✔
695
    }
9✔
696

697
    fn resolve_remote_candidates_from_siblings(
4✔
698
        config: &HuggingFaceRowsConfig,
4✔
699
        siblings: &[String],
4✔
700
        accepted: &[String],
4✔
701
    ) -> Result<(Vec<String>, HashMap<String, u64>), SamplerError> {
4✔
702
        let (mut candidates, mut saw_parquet) =
4✔
703
            Self::collect_candidates_from_siblings(config, siblings, accepted, true);
4✔
704
        if candidates.is_empty() && !config.split.is_empty() {
4✔
705
            let (fallback_candidates, fallback_saw_parquet) =
3✔
706
                Self::collect_candidates_from_siblings(config, siblings, accepted, false);
3✔
707
            if !fallback_candidates.is_empty() {
3✔
708
                warn!(
1✔
709
                    "[triplets:hf] split filter '{}' matched no remote files; falling back to extension-only remote candidate scan",
710
                    config.split
711
                );
712
                candidates = fallback_candidates;
1✔
713
                saw_parquet = fallback_saw_parquet;
1✔
714
            }
2✔
715
        }
1✔
716

717
        candidates.sort();
4✔
718
        info!(
4✔
719
            "[triplets:hf] remote candidates matching {:?}: {}",
720
            config.shard_extensions,
721
            candidates.len()
4✔
722
        );
723
        if candidates.is_empty() {
4✔
724
            if saw_parquet {
2✔
725
                return Err(SamplerError::SourceUnavailable {
1✔
726
                    source_id: config.source_id.clone(),
1✔
727
                    reason: format!(
1✔
728
                        "dataset '{}' appears to be parquet-only, but shard_extensions does not include parquet ({:?}).",
1✔
729
                        config.dataset, config.shard_extensions
1✔
730
                    ),
1✔
731
                });
1✔
732
            }
1✔
733
            warn!(
1✔
734
                "[triplets:hf] no remote candidates found for dataset='{}' split='{}' extensions={:?}; source will be treated as exhausted",
735
                config.dataset, config.split, config.shard_extensions
736
            );
737
            return Ok((Vec::new(), HashMap::new()));
1✔
738
        }
2✔
739

740
        Ok((candidates, HashMap::new()))
2✔
741
    }
4✔
742

743
    fn candidates_from_parquet_manifest_json(
7✔
744
        config: &HuggingFaceRowsConfig,
7✔
745
        json: &Value,
7✔
746
    ) -> Result<(Vec<String>, HashMap<String, u64>), SamplerError> {
7✔
747
        let accepted = Self::normalized_shard_extensions(config);
7✔
748

749
        let mut candidates = Vec::new();
7✔
750
        let mut candidate_sizes = HashMap::new();
7✔
751
        if let Some(entries) = json.get("parquet_files").and_then(Value::as_array) {
7✔
752
            for entry in entries {
10✔
753
                let Some(url) = entry.get("url").and_then(Value::as_str) else {
10✔
754
                    continue;
1✔
755
                };
756

757
                let ext = Path::new(url)
9✔
758
                    .extension()
9✔
759
                    .and_then(|value| value.to_str())
9✔
760
                    .map(|value| value.to_ascii_lowercase());
9✔
761
                if !ext
9✔
762
                    .as_deref()
9✔
763
                    .is_some_and(|value| accepted.iter().any(|allowed| allowed == value))
15✔
764
                {
765
                    continue;
1✔
766
                }
8✔
767

768
                let candidate = format!("{REMOTE_URL_PREFIX}{url}");
8✔
769
                let expected_size = entry.get("size").and_then(Value::as_u64);
8✔
770
                let target = Self::candidate_target_path(config, &candidate);
8✔
771
                if target.exists() {
8✔
772
                    if Self::target_matches_expected_size(&target, expected_size) {
3✔
773
                        continue;
1✔
774
                    }
2✔
775
                    warn!(
2✔
776
                        "[triplets:hf] incomplete cached shard detected (will redownload): {}",
777
                        target.display()
2✔
778
                    );
779
                    if let Err(err) = fs::remove_file(&target)
2✔
780
                        && err.kind() != std::io::ErrorKind::NotFound
1✔
781
                    {
782
                        return Err(SamplerError::SourceUnavailable {
1✔
783
                            source_id: config.source_id.clone(),
1✔
784
                            reason: format!(
1✔
785
                                "failed removing incomplete shard {}: {err}",
1✔
786
                                target.display()
1✔
787
                            ),
1✔
788
                        });
1✔
789
                    }
1✔
790
                }
5✔
791
                if let Some(size) = expected_size {
6✔
792
                    candidate_sizes.insert(candidate.clone(), size);
6✔
793
                }
6✔
794
                candidates.push(candidate);
6✔
795
            }
796
        }
1✔
797

798
        candidates.sort();
6✔
799
        Ok((candidates, candidate_sizes))
6✔
800
    }
7✔
801

802
    /// Resolve and filter remote shard candidates from manifest or repository listing.
803
    fn list_remote_candidates(
1✔
804
        config: &HuggingFaceRowsConfig,
1✔
805
    ) -> Result<(Vec<String>, HashMap<String, u64>), SamplerError> {
1✔
806
        if let Ok((candidates, candidate_sizes)) =
1✔
807
            Self::list_remote_candidates_from_parquet_manifest(config)
1✔
808
            && !candidates.is_empty()
1✔
809
        {
810
            info!(
1✔
811
                "[triplets:hf] remote parquet manifest candidates matching {:?}: {}",
812
                config.shard_extensions,
813
                candidates.len()
1✔
814
            );
815
            return Ok((candidates, candidate_sizes));
1✔
816
        }
×
817

818
        let api = ApiBuilder::new()
×
819
            .with_progress(true)
×
820
            .with_retries(5)
×
821
            .with_token(None)
×
822
            .build()
×
823
            .map_err(|err| SamplerError::SourceUnavailable {
×
824
                source_id: config.source_id.clone(),
×
825
                reason: format!("failed building hf-hub client: {err}"),
×
826
            })?;
×
827

828
        let repo = Repo::new(config.dataset.clone(), RepoType::Dataset);
×
829
        let repo_api = api.repo(repo);
×
830
        info!(
×
831
            "[triplets:hf] reading remote file list for dataset {}",
832
            config.dataset
833
        );
834
        let info = repo_api
×
835
            .info()
×
836
            .map_err(|err| SamplerError::SourceUnavailable {
×
837
                source_id: config.source_id.clone(),
×
838
                reason: format!("failed reading hf-hub repository info: {err}"),
×
839
            })?;
×
840

841
        let accepted = Self::normalized_shard_extensions(config);
×
842

843
        let siblings = info
×
844
            .siblings
×
845
            .into_iter()
×
846
            .map(|entry| entry.rfilename)
×
847
            .collect::<Vec<_>>();
×
848

849
        Self::resolve_remote_candidates_from_siblings(config, &siblings, &accepted)
×
850
    }
1✔
851

852
    /// Return the persistence file path for shard sequence state.
853
    fn shard_sequence_state_path(config: &HuggingFaceRowsConfig) -> PathBuf {
22✔
854
        config
22✔
855
            .snapshot_dir
22✔
856
            .join("_parquet_manifest")
22✔
857
            .join(SHARD_SEQUENCE_STATE_FILE)
22✔
858
    }
22✔
859

860
    /// Load persisted shard candidate sequence when metadata and sampler seed match.
861
    #[cfg(test)]
862
    fn load_persisted_shard_sequence(
7✔
863
        config: &HuggingFaceRowsConfig,
7✔
864
        current_sampler_seed: u64,
7✔
865
    ) -> Result<Option<PersistedShardSequence>, SamplerError> {
7✔
866
        let path = Self::shard_sequence_state_path(config);
7✔
867
        if !path.exists() {
7✔
868
            return Ok(None);
1✔
869
        }
6✔
870

871
        let raw = fs::read_to_string(&path).map_err(|err| SamplerError::SourceUnavailable {
6✔
872
            source_id: config.source_id.clone(),
×
873
            reason: format!(
×
874
                "failed reading shard-sequence state {}: {err}",
875
                path.display()
×
876
            ),
877
        })?;
×
878

879
        let mut persisted: PersistedShardSequence =
5✔
880
            serde_json::from_str(&raw).map_err(|err| SamplerError::SourceUnavailable {
6✔
881
                source_id: config.source_id.clone(),
1✔
882
                reason: format!(
1✔
883
                    "failed parsing shard-sequence state {}: {err}",
884
                    path.display()
1✔
885
                ),
886
            })?;
1✔
887

888
        if persisted.version != SHARD_SEQUENCE_STATE_VERSION
5✔
889
            || persisted.source_id != config.source_id
5✔
890
            || persisted.dataset != config.dataset
4✔
891
            || persisted.config != config.config
4✔
892
            || persisted.split != config.split
4✔
893
            || persisted.sampler_seed != current_sampler_seed
4✔
894
        {
895
            warn!(
2✔
896
                "[triplets:hf] shard-sequence state mismatch for {}; rebuilding candidate order",
897
                path.display()
2✔
898
            );
899
            return Ok(None);
2✔
900
        }
3✔
901

902
        if persisted.next_remote_idx > persisted.candidates.len() {
3✔
903
            persisted.next_remote_idx = persisted.candidates.len();
2✔
904
        }
2✔
905

906
        Ok(Some(persisted))
3✔
907
    }
7✔
908

909
    /// Persist current shard candidate sequence and position atomically.
910
    fn persist_shard_sequence_locked(&self, state: &SourceState) -> Result<(), SamplerError> {
9✔
911
        let Some(candidates) = state.remote_candidates.as_ref() else {
9✔
912
            return Ok(());
1✔
913
        };
914

915
        let path = Self::shard_sequence_state_path(&self.config);
8✔
916
        if let Some(parent) = path.parent() {
8✔
917
            fs::create_dir_all(parent).map_err(|err| SamplerError::SourceUnavailable {
8✔
918
                source_id: self.config.source_id.clone(),
×
919
                reason: format!(
×
920
                    "failed creating shard-sequence state dir {}: {err}",
921
                    parent.display()
×
922
                ),
923
            })?;
×
924
        }
×
925

926
        let persisted = PersistedShardSequence {
8✔
927
            version: SHARD_SEQUENCE_STATE_VERSION,
928
            source_id: self.config.source_id.clone(),
8✔
929
            dataset: self.config.dataset.clone(),
8✔
930
            config: self.config.config.clone(),
8✔
931
            split: self.config.split.clone(),
8✔
932
            sampler_seed: self.configured_sampler_seed()?,
8✔
933
            candidates: candidates.clone(),
8✔
934
            candidate_sizes: state.remote_candidate_sizes.clone(),
8✔
935
            next_remote_idx: state.next_remote_idx.min(candidates.len()),
8✔
936
        };
937

938
        let raw = serde_json::to_vec_pretty(&persisted).map_err(|err| {
8✔
939
            SamplerError::SourceUnavailable {
×
940
                source_id: self.config.source_id.clone(),
×
941
                reason: format!(
×
942
                    "failed encoding shard-sequence state {}: {err}",
×
943
                    path.display()
×
944
                ),
×
945
            }
×
946
        })?;
×
947

948
        let tmp_path = path.with_extension("tmp");
8✔
949
        fs::write(&tmp_path, raw).map_err(|err| SamplerError::SourceUnavailable {
8✔
950
            source_id: self.config.source_id.clone(),
×
951
            reason: format!(
×
952
                "failed writing shard-sequence state temp {}: {err}",
953
                tmp_path.display()
×
954
            ),
955
        })?;
×
956
        fs::rename(&tmp_path, &path).map_err(|err| SamplerError::SourceUnavailable {
8✔
957
            source_id: self.config.source_id.clone(),
×
958
            reason: format!(
×
959
                "failed replacing shard-sequence state {}: {err}",
960
                path.display()
×
961
            ),
962
        })?;
×
963

964
        Ok(())
8✔
965
    }
9✔
966

967
    /// Rotate candidate ordering deterministically using source identity.
968
    fn rotate_candidates_deterministically(
4✔
969
        config: &HuggingFaceRowsConfig,
4✔
970
        candidates: &mut [String],
4✔
971
    ) {
4✔
972
        if candidates.len() <= 1 {
4✔
973
            return;
1✔
974
        }
3✔
975
        let mut hasher = DefaultHasher::new();
3✔
976
        config.source_id.hash(&mut hasher);
3✔
977
        config.dataset.hash(&mut hasher);
3✔
978
        config.config.hash(&mut hasher);
3✔
979
        config.split.hash(&mut hasher);
3✔
980
        let offset = (hasher.finish() as usize) % candidates.len();
3✔
981
        candidates.rotate_left(offset);
3✔
982
    }
4✔
983

984
    /// Build deterministic seed used to permute remote shard candidate order.
985
    fn shard_candidate_seed(
17✔
986
        config: &HuggingFaceRowsConfig,
17✔
987
        total_candidates: usize,
17✔
988
        sampler_seed: u64,
17✔
989
    ) -> u64 {
17✔
990
        let mut hasher = DefaultHasher::new();
17✔
991
        "hf_shard_candidate_sequence_v1".hash(&mut hasher);
17✔
992
        sampler_seed.hash(&mut hasher);
17✔
993
        config.source_id.hash(&mut hasher);
17✔
994
        config.dataset.hash(&mut hasher);
17✔
995
        config.config.hash(&mut hasher);
17✔
996
        config.split.hash(&mut hasher);
17✔
997
        total_candidates.hash(&mut hasher);
17✔
998
        hasher.finish()
17✔
999
    }
17✔
1000

1001
    fn parquet_manifest_endpoint() -> String {
4✔
1002
        #[cfg(test)]
1003
        if let Ok(value) = std::env::var("TRIPLETS_HF_PARQUET_ENDPOINT")
4✔
1004
            && !value.trim().is_empty()
4✔
1005
        {
1006
            return value;
3✔
1007
        }
1✔
1008
        "https://datasets-server.huggingface.co/parquet".to_string()
1✔
1009
    }
4✔
1010

1011
    fn size_endpoint() -> String {
16✔
1012
        #[cfg(test)]
1013
        if let Ok(value) = std::env::var("TRIPLETS_HF_SIZE_ENDPOINT")
4✔
1014
            && !value.trim().is_empty()
4✔
1015
        {
1016
            return value;
3✔
1017
        }
1✔
1018
        "https://datasets-server.huggingface.co/size".to_string()
13✔
1019
    }
16✔
1020

1021
    /// Query datasets-server parquet manifest and derive shard candidates.
1022
    fn list_remote_candidates_from_parquet_manifest(
3✔
1023
        config: &HuggingFaceRowsConfig,
3✔
1024
    ) -> Result<(Vec<String>, HashMap<String, u64>), SamplerError> {
3✔
1025
        let endpoint = Self::parquet_manifest_endpoint();
3✔
1026
        info!(
3✔
1027
            "[triplets:hf] reading datasets-server parquet manifest for dataset {}",
1028
            config.dataset
1029
        );
1030
        let response = ureq::get(&endpoint)
3✔
1031
            .query("dataset", &config.dataset)
3✔
1032
            .query("config", &config.config)
3✔
1033
            .query("split", &config.split)
3✔
1034
            .call()
3✔
1035
            .map_err(|err| SamplerError::SourceUnavailable {
3✔
1036
                source_id: config.source_id.clone(),
1✔
1037
                reason: format!("failed querying datasets-server parquet endpoint: {err}"),
1✔
1038
            })?;
1✔
1039

1040
        let body = response.into_body().read_to_string().map_err(|err| {
2✔
1041
            SamplerError::SourceUnavailable {
×
1042
                source_id: config.source_id.clone(),
×
1043
                reason: format!("failed reading datasets-server parquet response body: {err}"),
×
1044
            }
×
1045
        })?;
×
1046

1047
        Self::parse_parquet_manifest_response(config, &body)
2✔
1048
    }
3✔
1049

1050
    fn parse_parquet_manifest_response(
4✔
1051
        config: &HuggingFaceRowsConfig,
4✔
1052
        body: &str,
4✔
1053
    ) -> Result<(Vec<String>, HashMap<String, u64>), SamplerError> {
4✔
1054
        let json: Value =
3✔
1055
            serde_json::from_str(body).map_err(|err| SamplerError::SourceUnavailable {
4✔
1056
                source_id: config.source_id.clone(),
1✔
1057
                reason: format!("failed parsing datasets-server parquet response: {err}"),
1✔
1058
            })?;
1✔
1059

1060
        Self::candidates_from_parquet_manifest_json(config, &json)
3✔
1061
    }
4✔
1062

1063
    /// Map a candidate identifier to the local snapshot target path.
1064
    fn candidate_target_path(config: &HuggingFaceRowsConfig, candidate: &str) -> PathBuf {
37✔
1065
        if let Some(url) = candidate.strip_prefix(REMOTE_URL_PREFIX) {
37✔
1066
            let suffix = url
28✔
1067
                .split("/resolve/")
28✔
1068
                .nth(1)
28✔
1069
                .map(|value| value.trim_start_matches('/'))
28✔
1070
                .filter(|value| !value.is_empty())
28✔
1071
                .unwrap_or("parquet/unknown.parquet");
28✔
1072
            return config.snapshot_dir.join("_parquet_manifest").join(suffix);
28✔
1073
        }
9✔
1074
        config.snapshot_dir.join(candidate)
9✔
1075
    }
37✔
1076

1077
    /// Validate target file size against expected bytes when available.
1078
    fn target_matches_expected_size(path: &Path, expected_bytes: Option<u64>) -> bool {
9✔
1079
        if !path.exists() {
9✔
1080
            return false;
1✔
1081
        }
8✔
1082
        if let Some(expected) = expected_bytes
8✔
1083
            && expected > 0
7✔
1084
        {
1085
            return fs::metadata(path)
7✔
1086
                .map(|meta| meta.len() == expected)
7✔
1087
                .unwrap_or(false);
7✔
1088
        }
1✔
1089
        true
1✔
1090
    }
9✔
1091

1092
    /// Return root directory used for manifest-cached remote shards.
1093
    fn manifest_cache_root(&self) -> PathBuf {
25✔
1094
        self.config.snapshot_dir.join("_parquet_manifest")
25✔
1095
    }
25✔
1096

1097
    /// Return on-disk size for a shard path, or 0 if metadata lookup fails.
1098
    fn shard_size_bytes(path: &Path) -> u64 {
25✔
1099
        fs::metadata(path).map(|meta| meta.len()).unwrap_or(0)
25✔
1100
    }
25✔
1101

1102
    /// Recompute shard `global_start` offsets and total materialized row count.
1103
    fn recompute_shard_offsets(state: &mut SourceState) {
4✔
1104
        let mut running = 0usize;
4✔
1105
        for shard in &mut state.shards {
5✔
1106
            shard.global_start = running;
5✔
1107
            running = running.saturating_add(shard.row_count);
5✔
1108
        }
5✔
1109
        state.materialized_rows = running;
4✔
1110
    }
4✔
1111

1112
    /// Enforce local disk cap by evicting oldest manifest shards when possible.
1113
    fn enforce_disk_cap_locked(
12✔
1114
        &self,
12✔
1115
        state: &mut SourceState,
12✔
1116
        protected_path: &Path,
12✔
1117
    ) -> Result<bool, SamplerError> {
12✔
1118
        let Some(cap_bytes) = self.config.local_disk_cap_bytes else {
12✔
1119
            return Ok(false);
1✔
1120
        };
1121

1122
        let manifest_root = self.manifest_cache_root();
11✔
1123
        let mut usage_bytes = state
11✔
1124
            .shards
11✔
1125
            .iter()
11✔
1126
            .filter(|shard| shard.path.starts_with(&manifest_root))
14✔
1127
            .map(|shard| Self::shard_size_bytes(&shard.path))
14✔
1128
            .sum::<u64>();
11✔
1129

1130
        if usage_bytes <= cap_bytes {
11✔
1131
            return Ok(false);
6✔
1132
        }
5✔
1133

1134
        let mut evicted_any = false;
5✔
1135
        loop {
1136
            if usage_bytes <= cap_bytes {
8✔
1137
                break;
3✔
1138
            }
5✔
1139

1140
            let resident_manifest_count = state
5✔
1141
                .shards
5✔
1142
                .iter()
5✔
1143
                .filter(|shard| shard.path.starts_with(&manifest_root))
8✔
1144
                .count();
5✔
1145
            if resident_manifest_count <= self.config.min_resident_shards {
5✔
1146
                break;
2✔
1147
            }
3✔
1148

1149
            let evict_pos = state.shards.iter().position(|shard| {
3✔
1150
                shard.path.starts_with(&manifest_root) && shard.path != protected_path
3✔
1151
            });
3✔
1152
            let Some(pos) = evict_pos else {
3✔
1153
                break;
×
1154
            };
1155

1156
            let shard = state.shards.remove(pos);
3✔
1157
            let shard_size = Self::shard_size_bytes(&shard.path);
3✔
1158
            if let Err(err) = fs::remove_file(&shard.path)
3✔
1159
                && err.kind() != std::io::ErrorKind::NotFound
×
1160
            {
1161
                return Err(SamplerError::SourceUnavailable {
×
1162
                    source_id: self.config.source_id.clone(),
×
1163
                    reason: format!(
×
1164
                        "failed evicting shard {} under disk cap: {err}",
×
1165
                        shard.path.display()
×
1166
                    ),
×
1167
                });
×
1168
            }
3✔
1169

1170
            usage_bytes = usage_bytes.saturating_sub(shard_size);
3✔
1171
            evicted_any = true;
3✔
1172
            warn!(
3✔
1173
                "[triplets:hf] evicted shard for disk cap: {} (usage={:.2} GiB cap={:.2} GiB)",
1174
                shard.path.display(),
3✔
1175
                usage_bytes as f64 / (1024.0 * 1024.0 * 1024.0),
3✔
1176
                cap_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
3✔
1177
            );
1178
        }
1179

1180
        if usage_bytes > cap_bytes {
5✔
1181
            if protected_path.exists() {
2✔
1182
                let _ = fs::remove_file(protected_path);
2✔
1183
            }
2✔
1184
            return Err(SamplerError::SourceUnavailable {
2✔
1185
                source_id: self.config.source_id.clone(),
2✔
1186
                reason: format!(
2✔
1187
                    "local disk cap exceeded and cannot evict further (usage={} bytes cap={} bytes)",
2✔
1188
                    usage_bytes, cap_bytes
2✔
1189
                ),
2✔
1190
            });
2✔
1191
        }
3✔
1192

1193
        if evicted_any {
3✔
1194
            Self::recompute_shard_offsets(state);
3✔
1195
        }
3✔
1196
        Ok(evicted_any)
3✔
1197
    }
12✔
1198

1199
    /// Return total on-disk bytes used by manifest-backed shards.
1200
    fn manifest_usage_bytes_locked(&self, state: &SourceState) -> u64 {
7✔
1201
        let manifest_root = self.manifest_cache_root();
7✔
1202
        state
7✔
1203
            .shards
7✔
1204
            .iter()
7✔
1205
            .filter(|shard| shard.path.starts_with(&manifest_root))
8✔
1206
            .map(|shard| Self::shard_size_bytes(&shard.path))
7✔
1207
            .sum::<u64>()
7✔
1208
    }
7✔
1209

1210
    /// Fetch exact split row count metadata from datasets-server size endpoint.
1211
    fn fetch_global_row_count(
15✔
1212
        config: &HuggingFaceRowsConfig,
15✔
1213
    ) -> Result<Option<usize>, SamplerError> {
15✔
1214
        let endpoint = Self::size_endpoint();
15✔
1215
        info!(
15✔
1216
            "[triplets:hf] requesting global row count dataset='{}' config='{}' split='{}'",
1217
            config.dataset, config.config, config.split
1218
        );
1219

1220
        let response = ureq::get(&endpoint)
15✔
1221
            .query("dataset", &config.dataset)
15✔
1222
            .query("config", &config.config)
15✔
1223
            .query("split", &config.split)
15✔
1224
            .call()
15✔
1225
            .map_err(|err| SamplerError::SourceUnavailable {
15✔
1226
                source_id: config.source_id.clone(),
13✔
1227
                reason: format!("failed querying datasets-server size endpoint: {err}"),
13✔
1228
            })?;
13✔
1229

1230
        let body = response.into_body().read_to_string().map_err(|err| {
2✔
1231
            SamplerError::SourceUnavailable {
×
1232
                source_id: config.source_id.clone(),
×
1233
                reason: format!("failed reading datasets-server size response body: {err}"),
×
1234
            }
×
1235
        })?;
×
1236

1237
        Self::parse_global_row_count_response(config, &body)
2✔
1238
    }
15✔
1239

1240
    fn parse_global_row_count_response(
6✔
1241
        config: &HuggingFaceRowsConfig,
6✔
1242
        body: &str,
6✔
1243
    ) -> Result<Option<usize>, SamplerError> {
6✔
1244
        let json: Value =
5✔
1245
            serde_json::from_str(body).map_err(|err| SamplerError::SourceUnavailable {
6✔
1246
                source_id: config.source_id.clone(),
1✔
1247
                reason: format!("failed parsing datasets-server size response: {err}"),
1✔
1248
            })?;
1✔
1249

1250
        let mut count =
5✔
1251
            Self::extract_split_row_count_from_size_response(&json, &config.config, &config.split);
5✔
1252
        if let (Some(max_rows), Some(rows)) = (config.max_rows, count) {
5✔
1253
            count = Some(rows.min(max_rows));
1✔
1254
        }
4✔
1255
        Ok(count)
5✔
1256
    }
6✔
1257

1258
    /// Extract split row count from datasets-server size payload variants.
1259
    fn extract_split_row_count_from_size_response(
13✔
1260
        json: &Value,
13✔
1261
        config_name: &str,
13✔
1262
        split_name: &str,
13✔
1263
    ) -> Option<usize> {
13✔
1264
        let to_usize = |value: &Value| value.as_u64().and_then(|raw| usize::try_from(raw).ok());
13✔
1265

1266
        let size = json.get("size")?;
13✔
1267

1268
        if let Some(splits) = size.get("splits").and_then(Value::as_array) {
13✔
1269
            for entry in splits {
6✔
1270
                let entry_config = entry
6✔
1271
                    .get("config")
6✔
1272
                    .or_else(|| entry.get("config_name"))
6✔
1273
                    .and_then(Value::as_str)
6✔
1274
                    .unwrap_or_default();
6✔
1275
                let entry_split = entry
6✔
1276
                    .get("split")
6✔
1277
                    .or_else(|| entry.get("name"))
6✔
1278
                    .and_then(Value::as_str)
6✔
1279
                    .unwrap_or_default();
6✔
1280
                if entry_config == config_name
6✔
1281
                    && entry_split == split_name
5✔
1282
                    && let Some(rows) = entry.get("num_rows").and_then(to_usize)
3✔
1283
                {
1284
                    return Some(rows);
3✔
1285
                }
3✔
1286
            }
1287
        }
8✔
1288

1289
        if let Some(configs) = size.get("configs").and_then(Value::as_array) {
10✔
1290
            for config_entry in configs {
6✔
1291
                let entry_config = config_entry
6✔
1292
                    .get("config")
6✔
1293
                    .or_else(|| config_entry.get("config_name"))
6✔
1294
                    .and_then(Value::as_str)
6✔
1295
                    .unwrap_or_default();
6✔
1296
                if entry_config != config_name {
6✔
1297
                    continue;
1✔
1298
                }
5✔
1299

1300
                if let Some(splits) = config_entry.get("splits").and_then(Value::as_array) {
5✔
1301
                    for split_entry in splits {
4✔
1302
                        let entry_split = split_entry
4✔
1303
                            .get("split")
4✔
1304
                            .or_else(|| split_entry.get("name"))
4✔
1305
                            .and_then(Value::as_str)
4✔
1306
                            .unwrap_or_default();
4✔
1307
                        if entry_split == split_name
4✔
1308
                            && let Some(rows) = split_entry.get("num_rows").and_then(to_usize)
2✔
1309
                        {
1310
                            return Some(rows);
2✔
1311
                        }
2✔
1312
                    }
1313
                }
1✔
1314

1315
                if split_name.is_empty()
3✔
1316
                    && let Some(rows) = config_entry.get("num_rows").and_then(to_usize)
3✔
1317
                {
1318
                    return Some(rows);
3✔
1319
                }
×
1320
            }
1321
        }
4✔
1322

1323
        if split_name.is_empty() {
5✔
1324
            return size
2✔
1325
                .get("dataset")
2✔
1326
                .and_then(|dataset| dataset.get("num_rows"))
2✔
1327
                .and_then(to_usize);
2✔
1328
        }
3✔
1329

1330
        None
3✔
1331
    }
13✔
1332

1333
    /// Download a shard (URL or hf-hub path) and materialize it under snapshot dir.
1334
    fn download_and_materialize_shard(
13✔
1335
        config: &HuggingFaceRowsConfig,
13✔
1336
        remote_path: &str,
13✔
1337
        expected_bytes: Option<u64>,
13✔
1338
    ) -> Result<PathBuf, SamplerError> {
13✔
1339
        if let Some(remote_url) = remote_path.strip_prefix(REMOTE_URL_PREFIX) {
13✔
1340
            let target = Self::candidate_target_path(config, remote_path);
12✔
1341
            if target.exists() {
12✔
1342
                if Self::target_matches_expected_size(&target, expected_bytes) {
2✔
1343
                    return Ok(target);
1✔
1344
                }
1✔
1345
                warn!(
1✔
1346
                    "[triplets:hf] replacing incomplete shard before retry: {}",
1347
                    target.display()
1✔
1348
                );
1349
                fs::remove_file(&target).map_err(|err| SamplerError::SourceUnavailable {
1✔
1350
                    source_id: config.source_id.clone(),
×
1351
                    reason: format!(
×
1352
                        "failed removing incomplete shard {}: {err}",
1353
                        target.display()
×
1354
                    ),
1355
                })?;
×
1356
            }
10✔
1357

1358
            if let Some(parent) = target.parent() {
11✔
1359
                fs::create_dir_all(parent).map_err(|err| SamplerError::SourceUnavailable {
11✔
1360
                    source_id: config.source_id.clone(),
×
1361
                    reason: format!(
×
1362
                        "failed creating snapshot subdir {}: {err}",
1363
                        parent.display()
×
1364
                    ),
1365
                })?;
×
1366
            }
×
1367

1368
            let temp_target = target.with_extension("part");
11✔
1369
            if temp_target.exists() {
11✔
1370
                let _ = fs::remove_file(&temp_target);
1✔
1371
            }
10✔
1372

1373
            let response =
11✔
1374
                ureq::get(remote_url)
11✔
1375
                    .call()
11✔
1376
                    .map_err(|err| SamplerError::SourceUnavailable {
11✔
1377
                        source_id: config.source_id.clone(),
×
1378
                        reason: format!("failed downloading shard URL '{}': {err}", remote_url),
×
1379
                    })?;
×
1380
            let mut reader = response.into_body().into_reader();
11✔
1381
            let mut file =
11✔
1382
                File::create(&temp_target).map_err(|err| SamplerError::SourceUnavailable {
11✔
1383
                    source_id: config.source_id.clone(),
×
1384
                    reason: format!(
×
1385
                        "failed creating target shard {}: {err}",
1386
                        temp_target.display()
×
1387
                    ),
1388
                })?;
×
1389
            info!(
11✔
1390
                "[triplets:hf] downloading shard payload -> {}",
1391
                target.display()
11✔
1392
            );
1393
            let started = Instant::now();
11✔
1394
            let mut total_bytes = 0u64;
11✔
1395
            let mut buffer = vec![0u8; 8 * 1024 * 1024];
11✔
1396
            let mut last_report = Instant::now();
11✔
1397
            loop {
1398
                let read =
21✔
1399
                    reader
21✔
1400
                        .read(&mut buffer)
21✔
1401
                        .map_err(|err| SamplerError::SourceUnavailable {
21✔
1402
                            source_id: config.source_id.clone(),
×
1403
                            reason: format!("failed reading shard stream '{}': {err}", remote_url),
×
1404
                        })?;
×
1405
                if read == 0 {
21✔
1406
                    break;
11✔
1407
                }
10✔
1408
                file.write_all(&buffer[..read])
10✔
1409
                    .map_err(|err| SamplerError::SourceUnavailable {
10✔
1410
                        source_id: config.source_id.clone(),
×
1411
                        reason: format!(
×
1412
                            "failed writing target shard {}: {err}",
1413
                            temp_target.display()
×
1414
                        ),
1415
                    })?;
×
1416
                total_bytes = total_bytes.saturating_add(read as u64);
10✔
1417
                if last_report.elapsed() >= Duration::from_secs(2) {
10✔
1418
                    let elapsed = started.elapsed().as_secs_f64();
×
1419
                    if let Some(expected) = expected_bytes
×
1420
                        && expected > 0
×
1421
                    {
1422
                        let pct =
×
1423
                            ((total_bytes as f64 / expected as f64) * 100.0).clamp(0.0, 100.0);
×
1424
                        let rate = if elapsed > 0.0 {
×
1425
                            total_bytes as f64 / elapsed
×
1426
                        } else {
1427
                            0.0
×
1428
                        };
1429
                        let eta_secs = if rate > 0.0 && total_bytes < expected {
×
1430
                            (expected.saturating_sub(total_bytes) as f64) / rate
×
1431
                        } else {
1432
                            0.0
×
1433
                        };
1434
                        info!(
×
1435
                            "[triplets:hf] download progress {}: {:.1}/{:.1} MiB ({:.1}%, {:.1}s elapsed, ETA {:.1}s)",
1436
                            target.display(),
×
1437
                            total_bytes as f64 / (1024.0 * 1024.0),
×
1438
                            expected as f64 / (1024.0 * 1024.0),
×
1439
                            pct,
1440
                            elapsed,
1441
                            eta_secs.max(0.0)
×
1442
                        );
1443
                    } else {
1444
                        info!(
×
1445
                            "[triplets:hf] download progress {}: {:.1} MiB ({:.1}s)",
1446
                            target.display(),
×
1447
                            total_bytes as f64 / (1024.0 * 1024.0),
×
1448
                            elapsed
1449
                        );
1450
                    }
1451
                    last_report = Instant::now();
×
1452
                }
10✔
1453
            }
1454
            let elapsed = started.elapsed().as_secs_f64();
11✔
1455
            if let Some(expected) = expected_bytes
11✔
1456
                && expected > 0
3✔
1457
            {
1458
                let pct = ((total_bytes as f64 / expected as f64) * 100.0).clamp(0.0, 100.0);
3✔
1459
                info!(
3✔
1460
                    "[triplets:hf] download complete {}: {:.1}/{:.1} MiB ({:.1}%) in {:.1}s",
1461
                    target.display(),
3✔
1462
                    total_bytes as f64 / (1024.0 * 1024.0),
3✔
1463
                    expected as f64 / (1024.0 * 1024.0),
3✔
1464
                    pct,
1465
                    elapsed
1466
                );
1467
            } else {
1468
                info!(
8✔
1469
                    "[triplets:hf] download complete {}: {:.1} MiB in {:.1}s",
1470
                    target.display(),
8✔
1471
                    total_bytes as f64 / (1024.0 * 1024.0),
8✔
1472
                    elapsed
1473
                );
1474
            }
1475

1476
            fs::rename(&temp_target, &target).map_err(|err| SamplerError::SourceUnavailable {
11✔
1477
                source_id: config.source_id.clone(),
×
1478
                reason: format!(
×
1479
                    "failed moving downloaded shard {} -> {}: {err}",
1480
                    temp_target.display(),
×
1481
                    target.display()
×
1482
                ),
1483
            })?;
×
1484
            return Ok(target);
11✔
1485
        }
1✔
1486

1487
        let api = ApiBuilder::new()
1✔
1488
            .with_progress(true)
1✔
1489
            .with_retries(5)
1✔
1490
            .with_token(None)
1✔
1491
            .build()
1✔
1492
            .map_err(|err| SamplerError::SourceUnavailable {
1✔
1493
                source_id: config.source_id.clone(),
×
1494
                reason: format!("failed building hf-hub client: {err}"),
×
1495
            })?;
×
1496

1497
        let repo = Repo::new(config.dataset.clone(), RepoType::Dataset);
1✔
1498
        let repo_api = api.repo(repo);
1✔
1499

1500
        let mut local_cached =
×
1501
            repo_api
1✔
1502
                .get(remote_path)
1✔
1503
                .map_err(|err| SamplerError::SourceUnavailable {
1✔
1504
                    source_id: config.source_id.clone(),
1✔
1505
                    reason: format!("failed downloading '{}' from hf-hub: {err}", remote_path),
1✔
1506
                })?;
1✔
1507
        if !local_cached.exists() {
×
1508
            for _ in 0..5 {
×
1509
                local_cached = repo_api.download(remote_path).map_err(|err| {
×
1510
                    SamplerError::SourceUnavailable {
×
1511
                        source_id: config.source_id.clone(),
×
1512
                        reason: format!(
×
1513
                            "hf-hub returned missing cache path for '{}', and forced download failed: {err}",
×
1514
                            remote_path
×
1515
                        ),
×
1516
                    }
×
1517
                })?;
×
1518
                if local_cached.exists() {
×
1519
                    break;
×
1520
                }
×
1521
                thread::sleep(Duration::from_millis(400));
×
1522
            }
1523
        }
×
1524
        if !local_cached.exists() {
×
1525
            return Err(SamplerError::SourceUnavailable {
×
1526
                source_id: config.source_id.clone(),
×
1527
                reason: format!(
×
1528
                    "hf-hub returned non-existent cache file for '{}' at {}",
×
1529
                    remote_path,
×
1530
                    local_cached.display()
×
1531
                ),
×
1532
            });
×
1533
        }
×
1534

1535
        let target = Self::candidate_target_path(config, remote_path);
×
1536
        Self::materialize_local_file(config, &local_cached, &target)?;
×
1537
        Ok(target)
×
1538
    }
13✔
1539

1540
    /// Build shard metadata for a single local file.
1541
    fn index_single_shard(
42✔
1542
        config: &HuggingFaceRowsConfig,
42✔
1543
        path: &Path,
42✔
1544
        global_start: usize,
42✔
1545
    ) -> Result<Option<ShardIndex>, SamplerError> {
42✔
1546
        let is_parquet = path
42✔
1547
            .extension()
42✔
1548
            .and_then(|v| v.to_str())
42✔
1549
            .is_some_and(|ext| ext.eq_ignore_ascii_case("parquet"));
42✔
1550

1551
        let (rows, parquet_row_groups, checkpoints) = if is_parquet {
42✔
1552
            let (rows, parquet_row_groups) = Self::parquet_row_group_map(config, path)?;
5✔
1553
            (rows, parquet_row_groups, Vec::new())
5✔
1554
        } else {
1555
            let file = File::open(path).map_err(|err| SamplerError::SourceUnavailable {
37✔
1556
                source_id: config.source_id.clone(),
1✔
1557
                reason: format!("failed opening shard {}: {err}", path.display()),
1✔
1558
            })?;
1✔
1559
            let mut reader = BufReader::new(file);
36✔
1560
            let mut checkpoints = Vec::new();
36✔
1561
            let mut line = String::new();
36✔
1562
            let mut offset = 0u64;
36✔
1563
            let mut rows = 0usize;
36✔
1564

1565
            loop {
1566
                if rows.is_multiple_of(config.checkpoint_stride) {
10,102✔
1567
                    checkpoints.push(offset);
102✔
1568
                }
10,000✔
1569
                line.clear();
10,102✔
1570
                let bytes =
10,102✔
1571
                    reader
10,102✔
1572
                        .read_line(&mut line)
10,102✔
1573
                        .map_err(|err| SamplerError::SourceUnavailable {
10,102✔
1574
                            source_id: config.source_id.clone(),
×
1575
                            reason: format!("failed reading shard {}: {err}", path.display()),
×
1576
                        })?;
×
1577
                if bytes == 0 {
10,102✔
1578
                    break;
36✔
1579
                }
10,066✔
1580
                rows += 1;
10,066✔
1581
                offset = offset.saturating_add(bytes as u64);
10,066✔
1582
            }
1583

1584
            (rows, Vec::new(), checkpoints)
36✔
1585
        };
1586

1587
        if rows == 0 {
41✔
1588
            return Ok(None);
3✔
1589
        }
38✔
1590

1591
        Ok(Some(ShardIndex {
38✔
1592
            path: path.to_path_buf(),
38✔
1593
            global_start,
38✔
1594
            row_count: rows,
38✔
1595
            is_parquet,
38✔
1596
            parquet_row_groups,
38✔
1597
            checkpoints,
38✔
1598
        }))
38✔
1599
    }
42✔
1600

1601
    /// Build parquet row-group map for random-access row reads.
1602
    fn parquet_row_group_map(
7✔
1603
        config: &HuggingFaceRowsConfig,
7✔
1604
        path: &Path,
7✔
1605
    ) -> Result<(usize, Vec<(usize, usize)>), SamplerError> {
7✔
1606
        let file = File::open(path).map_err(|err| SamplerError::SourceUnavailable {
7✔
1607
            source_id: config.source_id.clone(),
×
1608
            reason: format!("failed opening parquet shard {}: {err}", path.display()),
×
1609
        })?;
×
1610
        let reader =
7✔
1611
            SerializedFileReader::new(file).map_err(|err| SamplerError::SourceUnavailable {
7✔
1612
                source_id: config.source_id.clone(),
×
1613
                reason: format!("failed reading parquet metadata {}: {err}", path.display()),
×
1614
            })?;
×
1615

1616
        let mut row_groups = Vec::new();
7✔
1617
        let mut running = 0usize;
7✔
1618
        for meta in reader.metadata().row_groups() {
7✔
1619
            let group_rows =
7✔
1620
                usize::try_from(meta.num_rows()).map_err(|_| SamplerError::SourceUnavailable {
7✔
1621
                    source_id: config.source_id.clone(),
×
1622
                    reason: format!("parquet row group size overflow in {}", path.display()),
×
1623
                })?;
×
1624
            if group_rows == 0 {
7✔
1625
                continue;
1✔
1626
            }
6✔
1627
            row_groups.push((running, group_rows));
6✔
1628
            running = running.saturating_add(group_rows);
6✔
1629
        }
1630
        if running > 0 {
7✔
1631
            return Ok((running, row_groups));
6✔
1632
        }
1✔
1633

1634
        let total_rows =
1✔
1635
            usize::try_from(reader.metadata().file_metadata().num_rows()).map_err(|_| {
1✔
1636
                SamplerError::SourceUnavailable {
×
1637
                    source_id: config.source_id.clone(),
×
1638
                    reason: format!("parquet row count overflow in {}", path.display()),
×
1639
                }
×
1640
            })?;
×
1641
        if total_rows == 0 {
1✔
1642
            return Ok((0, Vec::new()));
1✔
1643
        }
×
1644
        Ok((total_rows, vec![(0, total_rows)]))
×
1645
    }
7✔
1646

1647
    /// Ensure row index is available, expanding remote shard set lazily if needed.
1648
    fn ensure_row_available(&self, idx: usize) -> Result<bool, SamplerError> {
73✔
1649
        loop {
1650
            {
1651
                let state = self
76✔
1652
                    .state
76✔
1653
                    .lock()
76✔
1654
                    .map_err(|_| SamplerError::SourceUnavailable {
76✔
1655
                        source_id: self.config.source_id.clone(),
×
1656
                        reason: "huggingface source state lock poisoned".to_string(),
×
1657
                    })?;
×
1658

1659
                if idx < state.materialized_rows {
76✔
1660
                    return Ok(true);
67✔
1661
                }
9✔
1662

1663
                if self.config.max_rows.is_some_and(|max_rows| idx >= max_rows) {
9✔
1664
                    return Ok(false);
2✔
1665
                }
7✔
1666

1667
                if let Some(candidates) = &state.remote_candidates
7✔
1668
                    && state.next_remote_idx >= candidates.len()
7✔
1669
                {
1670
                    return Ok(false);
4✔
1671
                }
3✔
1672
            }
1673

1674
            let need_candidates = {
3✔
1675
                let state = self
3✔
1676
                    .state
3✔
1677
                    .lock()
3✔
1678
                    .map_err(|_| SamplerError::SourceUnavailable {
3✔
1679
                        source_id: self.config.source_id.clone(),
×
1680
                        reason: "huggingface source state lock poisoned".to_string(),
×
1681
                    })?;
×
1682
                state.remote_candidates.is_none()
3✔
1683
            };
1684

1685
            if need_candidates {
3✔
1686
                let mut state = self
×
1687
                    .state
×
1688
                    .lock()
×
1689
                    .map_err(|_| SamplerError::SourceUnavailable {
×
1690
                        source_id: self.config.source_id.clone(),
×
1691
                        reason: "huggingface source state lock poisoned".to_string(),
×
1692
                    })?;
×
1693
                if state.remote_candidates.is_none() {
×
1694
                    let (mut candidates, candidate_sizes) =
×
1695
                        Self::list_remote_candidates(&self.config)?;
×
1696
                    Self::rotate_candidates_deterministically(&self.config, &mut candidates);
×
1697
                    state.remote_candidates = Some(candidates);
×
1698
                    state.remote_candidate_sizes = candidate_sizes;
×
1699
                    state.next_remote_idx = 0;
×
1700

1701
                    self.persist_shard_sequence_locked(&state)?;
×
1702

1703
                    let candidate_count = state
×
1704
                        .remote_candidates
×
1705
                        .as_ref()
×
1706
                        .map(|values| values.len())
×
1707
                        .unwrap_or(0);
×
1708
                    let bootstrap_needed = state.materialized_rows == 0
×
1709
                        && candidate_count > 0
×
1710
                        && state.next_remote_idx == 0;
×
1711
                    let known_rows = state.materialized_rows;
×
1712
                    let shard_count = state.shards.len();
×
1713
                    info!(
×
1714
                        "[triplets:hf] state: candidates={} known_rows={} active_shards={} disk_cap={} min_resident_shards={}",
1715
                        candidate_count,
1716
                        known_rows,
1717
                        shard_count,
1718
                        self.config
×
1719
                            .local_disk_cap_bytes
×
1720
                            .map(|bytes| format!(
×
1721
                                "{:.2} GiB",
1722
                                bytes as f64 / (1024.0 * 1024.0 * 1024.0)
×
1723
                            ))
1724
                            .unwrap_or_else(|| "disabled".to_string()),
×
1725
                        self.config.min_resident_shards,
1726
                    );
1727
                    drop(state);
×
1728

1729
                    if bootstrap_needed {
×
1730
                        let bootstrap_target = REMOTE_BOOTSTRAP_SHARDS.min(candidate_count);
×
1731
                        info!(
×
1732
                            "[triplets:hf] bootstrapping remote shard diversity: target={} shard(s)",
1733
                            bootstrap_target
1734
                        );
1735
                        for step in 0..bootstrap_target {
×
1736
                            info!(
×
1737
                                "[triplets:hf] bootstrap progress: {}/{}",
1738
                                step + 1,
×
1739
                                bootstrap_target
1740
                            );
1741
                            if !self.download_next_remote_shard()? {
×
1742
                                break;
×
1743
                            }
×
1744
                        }
1745
                        info!("[triplets:hf] bootstrap complete");
×
1746
                    }
×
1747
                } else {
×
1748
                    drop(state);
×
1749
                }
×
1750
                continue;
×
1751
            }
3✔
1752
            if !self.download_next_remote_shard()? {
3✔
1753
                return Ok(false);
×
1754
            }
3✔
1755
        }
1756
    }
73✔
1757

1758
    /// Download and register the next remote shard candidate.
1759
    fn download_next_remote_shard(&self) -> Result<bool, SamplerError> {
8✔
1760
        let (remote_ordinal, remote_total, remote_path, expected_bytes) = {
8✔
1761
            let mut state = self
8✔
1762
                .state
8✔
1763
                .lock()
8✔
1764
                .map_err(|_| SamplerError::SourceUnavailable {
8✔
1765
                    source_id: self.config.source_id.clone(),
×
1766
                    reason: "huggingface source state lock poisoned".to_string(),
×
1767
                })?;
×
1768
            let Some(candidates) = &state.remote_candidates else {
8✔
1769
                return Ok(false);
×
1770
            };
1771
            if state.next_remote_idx >= candidates.len() {
8✔
1772
                return Ok(false);
×
1773
            }
8✔
1774
            let sequence_pos = state.next_remote_idx;
8✔
1775
            let remote_ordinal = sequence_pos + 1;
8✔
1776
            let remote_total = candidates.len();
8✔
1777
            let sampler_seed = self.configured_sampler_seed()?;
8✔
1778
            let seed = Self::shard_candidate_seed(&self.config, remote_total, sampler_seed);
8✔
1779
            let mut permutation =
8✔
1780
                crate::source::IndexPermutation::new(remote_total, seed, sequence_pos as u64);
8✔
1781
            let candidate_idx = permutation.next();
8✔
1782
            let remote_path = candidates[candidate_idx].clone();
8✔
1783
            let expected_bytes = state.remote_candidate_sizes.get(&remote_path).copied();
8✔
1784
            state.next_remote_idx += 1;
8✔
1785
            (remote_ordinal, remote_total, remote_path, expected_bytes)
8✔
1786
        };
1787

1788
        info!(
8✔
1789
            "[triplets:hf] lazy downloading shard {}/{}: {}",
1790
            remote_ordinal,
1791
            remote_total,
1792
            remote_path.as_str()
8✔
1793
        );
1794
        let local_path =
8✔
1795
            Self::download_and_materialize_shard(&self.config, &remote_path, expected_bytes)?;
8✔
1796

1797
        let global_start = {
8✔
1798
            let state = self
8✔
1799
                .state
8✔
1800
                .lock()
8✔
1801
                .map_err(|_| SamplerError::SourceUnavailable {
8✔
1802
                    source_id: self.config.source_id.clone(),
×
1803
                    reason: "huggingface source state lock poisoned".to_string(),
×
1804
                })?;
×
1805
            state.materialized_rows
8✔
1806
        };
1807

1808
        let Some(shard) = Self::index_single_shard(&self.config, &local_path, global_start)? else {
8✔
1809
            warn!(
1✔
1810
                "[triplets:hf] downloaded shard had zero rows and was skipped: {}",
1811
                local_path.display()
1✔
1812
            );
1813
            return Ok(true);
1✔
1814
        };
1815

1816
        let mut state = self
7✔
1817
            .state
7✔
1818
            .lock()
7✔
1819
            .map_err(|_| SamplerError::SourceUnavailable {
7✔
1820
                source_id: self.config.source_id.clone(),
×
1821
                reason: "huggingface source state lock poisoned".to_string(),
×
1822
            })?;
×
1823

1824
        if self
7✔
1825
            .config
7✔
1826
            .max_rows
7✔
1827
            .is_some_and(|max_rows| state.materialized_rows >= max_rows)
7✔
1828
        {
1829
            return Ok(true);
1✔
1830
        }
6✔
1831

1832
        let mut rows_to_add = shard.row_count;
6✔
1833
        if let Some(max_rows) = self.config.max_rows {
6✔
1834
            rows_to_add = rows_to_add.min(max_rows.saturating_sub(state.materialized_rows));
1✔
1835
        }
5✔
1836
        if rows_to_add == 0 {
6✔
1837
            return Ok(true);
×
1838
        }
6✔
1839

1840
        let mut shard = shard;
6✔
1841
        shard.global_start = state.materialized_rows;
6✔
1842
        shard.row_count = rows_to_add;
6✔
1843
        if shard.is_parquet {
6✔
1844
            shard
×
1845
                .parquet_row_groups
×
1846
                .retain(|(start, _)| *start < rows_to_add);
×
1847
            if let Some((start, count)) = shard.parquet_row_groups.last_mut() {
×
1848
                let allowed = rows_to_add.saturating_sub(*start);
×
1849
                *count = (*count).min(allowed);
×
1850
            }
×
1851
        }
6✔
1852
        state.materialized_rows += rows_to_add;
6✔
1853
        state.shards.push(shard);
6✔
1854

1855
        let evicted_any = self.enforce_disk_cap_locked(&mut state, &local_path)?;
6✔
1856
        self.persist_shard_sequence_locked(&state)?;
6✔
1857
        let materialized_rows = state.materialized_rows;
6✔
1858
        let shard_count = state.shards.len();
6✔
1859
        let remaining_candidates = state
6✔
1860
            .remote_candidates
6✔
1861
            .as_ref()
6✔
1862
            .map(|candidates| candidates.len().saturating_sub(state.next_remote_idx))
6✔
1863
            .unwrap_or(0);
6✔
1864
        let usage_bytes = self.manifest_usage_bytes_locked(&state);
6✔
1865
        let usage_gib = usage_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
6✔
1866
        let cap_str = self
6✔
1867
            .config
6✔
1868
            .local_disk_cap_bytes
6✔
1869
            .map(|bytes| format!("{:.2} GiB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)))
6✔
1870
            .unwrap_or_else(|| "disabled".to_string());
6✔
1871
        drop(state);
6✔
1872

1873
        if evicted_any {
6✔
1874
            if let Ok(mut cache) = self.cache.lock() {
1✔
1875
                cache.rows.clear();
1✔
1876
                cache.order.clear();
1✔
1877
            }
1✔
1878
            if let Ok(mut parquet_cache) = self.parquet_cache.lock() {
1✔
1879
                parquet_cache.readers.clear();
1✔
1880
            }
1✔
1881
        }
5✔
1882

1883
        info!(
6✔
1884
            "[triplets:hf] state: rows={} shards={} remaining_candidates={} disk_usage={:.2} GiB cap={}",
1885
            materialized_rows, shard_count, remaining_candidates, usage_gib, cap_str,
1886
        );
1887

1888
        Ok(true)
6✔
1889
    }
8✔
1890

1891
    /// Copy cached/downloaded source file into snapshot tree.
1892
    fn materialize_local_file(
4✔
1893
        config: &HuggingFaceRowsConfig,
4✔
1894
        source_path: &Path,
4✔
1895
        target_path: &Path,
4✔
1896
    ) -> Result<(), SamplerError> {
4✔
1897
        let resolved_source =
4✔
1898
            fs::canonicalize(source_path).unwrap_or_else(|_| source_path.to_path_buf());
4✔
1899

1900
        if let Some(parent) = target_path.parent() {
4✔
1901
            fs::create_dir_all(parent).map_err(|err| SamplerError::SourceUnavailable {
4✔
1902
                source_id: config.source_id.clone(),
×
1903
                reason: format!(
×
1904
                    "failed creating snapshot subdir {}: {err}",
1905
                    parent.display()
×
1906
                ),
1907
            })?;
×
1908
        }
×
1909

1910
        if target_path.exists() {
4✔
1911
            let src_meta =
2✔
1912
                fs::metadata(&resolved_source).map_err(|err| SamplerError::SourceUnavailable {
2✔
1913
                    source_id: config.source_id.clone(),
×
1914
                    reason: format!(
×
1915
                        "failed reading source metadata {}: {err}",
1916
                        resolved_source.display()
×
1917
                    ),
1918
                })?;
×
1919
            let dst_meta =
2✔
1920
                fs::metadata(target_path).map_err(|err| SamplerError::SourceUnavailable {
2✔
1921
                    source_id: config.source_id.clone(),
×
1922
                    reason: format!(
×
1923
                        "failed reading target metadata {}: {err}",
1924
                        target_path.display()
×
1925
                    ),
1926
                })?;
×
1927
            if src_meta.len() == dst_meta.len() {
2✔
1928
                return Ok(());
1✔
1929
            }
1✔
1930
            fs::remove_file(target_path).map_err(|err| SamplerError::SourceUnavailable {
1✔
1931
                source_id: config.source_id.clone(),
×
1932
                reason: format!(
×
1933
                    "failed replacing target file {}: {err}",
1934
                    target_path.display()
×
1935
                ),
1936
            })?;
×
1937
        }
2✔
1938

1939
        fs::copy(&resolved_source, target_path).map_err(|err| SamplerError::SourceUnavailable {
3✔
1940
            source_id: config.source_id.clone(),
1✔
1941
            reason: format!(
1✔
1942
                "failed copying synced file {} -> {}: {err}",
1943
                resolved_source.display(),
1✔
1944
                target_path.display()
1✔
1945
            ),
1946
        })?;
1✔
1947
        Ok(())
2✔
1948
    }
4✔
1949

1950
    /// Build deterministic local shard index for accepted extensions.
1951
    fn build_shard_index(
20✔
1952
        config: &HuggingFaceRowsConfig,
20✔
1953
    ) -> Result<(Vec<ShardIndex>, usize), SamplerError> {
20✔
1954
        let start_index = Instant::now();
20✔
1955
        let mut shard_paths = Vec::new();
20✔
1956
        let manifest_root = config.snapshot_dir.join("_parquet_manifest");
20✔
1957
        let accepted = config
20✔
1958
            .shard_extensions
20✔
1959
            .iter()
20✔
1960
            .map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
32✔
1961
            .collect::<Vec<_>>();
20✔
1962

1963
        let mut saw_parquet = false;
20✔
1964
        for entry in WalkDir::new(&config.snapshot_dir)
46✔
1965
            .follow_links(true)
20✔
1966
            .into_iter()
20✔
1967
            .filter_map(Result::ok)
20✔
1968
        {
1969
            if !entry.file_type().is_file() {
46✔
1970
                continue;
23✔
1971
            }
23✔
1972
            if entry.path().starts_with(&manifest_root) {
23✔
1973
                continue;
1✔
1974
            }
22✔
1975
            let Some(ext) = entry.path().extension().and_then(|v| v.to_str()) else {
22✔
1976
                continue;
×
1977
            };
1978
            if ext.eq_ignore_ascii_case("parquet") {
22✔
1979
                saw_parquet = true;
4✔
1980
            }
18✔
1981
            if accepted
22✔
1982
                .iter()
22✔
1983
                .any(|allowed| allowed == &ext.to_ascii_lowercase())
34✔
1984
            {
19✔
1985
                shard_paths.push(entry.path().to_path_buf());
19✔
1986
            }
19✔
1987
        }
1988

1989
        shard_paths.sort();
20✔
1990
        if shard_paths.is_empty() {
20✔
1991
            if saw_parquet && !accepted.iter().any(|value| value == "parquet") {
3✔
1992
                return Err(SamplerError::SourceUnavailable {
1✔
1993
                    source_id: config.source_id.clone(),
1✔
1994
                    reason: format!(
1✔
1995
                        "found parquet files under {}, but shard_extensions does not include parquet.",
1✔
1996
                        config.snapshot_dir.display()
1✔
1997
                    ),
1✔
1998
                });
1✔
1999
            }
2✔
2000
            return Err(SamplerError::SourceUnavailable {
2✔
2001
                source_id: config.source_id.clone(),
2✔
2002
                reason: format!(
2✔
2003
                    "no shard files found under {} with extensions {:?}",
2✔
2004
                    config.snapshot_dir.display(),
2✔
2005
                    config.shard_extensions
2✔
2006
                ),
2✔
2007
            });
2✔
2008
        }
17✔
2009

2010
        let mut indexed_shards = shard_paths
17✔
2011
            .into_par_iter()
17✔
2012
            .enumerate()
17✔
2013
            .map(|(ordinal, path)| {
19✔
2014
                info!(
19✔
2015
                    "[triplets:hf] indexing shard {}: {}",
2016
                    ordinal + 1,
7✔
2017
                    path.display()
7✔
2018
                );
2019
                let shard = Self::index_single_shard(config, &path, 0)?;
19✔
2020
                Ok::<_, SamplerError>((ordinal, shard))
19✔
2021
            })
19✔
2022
            .collect::<Result<Vec<_>, _>>()?;
17✔
2023

2024
        indexed_shards.sort_by_key(|(ordinal, _)| *ordinal);
17✔
2025

2026
        let mut shards = Vec::new();
17✔
2027
        let mut running_total = 0usize;
17✔
2028
        for (_, maybe_shard) in indexed_shards {
19✔
2029
            let Some(mut shard) = maybe_shard else {
19✔
2030
                continue;
1✔
2031
            };
2032

2033
            if let Some(max_rows) = config.max_rows {
18✔
2034
                if running_total >= max_rows {
14✔
2035
                    break;
×
2036
                }
14✔
2037
                let allowed = max_rows.saturating_sub(running_total);
14✔
2038
                if shard.row_count > allowed {
14✔
2039
                    shard.row_count = allowed;
3✔
2040
                    if shard.is_parquet {
3✔
2041
                        shard
1✔
2042
                            .parquet_row_groups
1✔
2043
                            .retain(|(start, _)| *start < shard.row_count);
1✔
2044
                        if let Some((start, count)) = shard.parquet_row_groups.last_mut() {
1✔
2045
                            let group_allowed = shard.row_count.saturating_sub(*start);
1✔
2046
                            *count = (*count).min(group_allowed);
1✔
2047
                        }
1✔
2048
                    }
2✔
2049
                }
11✔
2050
            }
4✔
2051

2052
            if shard.row_count == 0 {
18✔
2053
                continue;
×
2054
            }
18✔
2055

2056
            shard.global_start = running_total;
18✔
2057
            running_total = running_total.saturating_add(shard.row_count);
18✔
2058
            shards.push(shard);
18✔
2059
        }
2060

2061
        info!(
17✔
2062
            "[triplets:hf] indexing complete in {:.2}s (rows={}, shards={})",
2063
            start_index.elapsed().as_secs_f64(),
5✔
2064
            running_total,
2065
            shards.len()
5✔
2066
        );
2067

2068
        Ok((shards, running_total))
17✔
2069
    }
20✔
2070

2071
    /// Locate containing shard and local offset for a global row index.
2072
    fn locate_shard(shards: &[ShardIndex], idx: usize) -> Option<(&ShardIndex, usize)> {
60✔
2073
        let pos = shards
60✔
2074
            .binary_search_by(|shard| {
60✔
2075
                if idx < shard.global_start {
60✔
2076
                    Ordering::Greater
1✔
2077
                } else if idx >= shard.global_start + shard.row_count {
59✔
2078
                    Ordering::Less
1✔
2079
                } else {
2080
                    Ordering::Equal
58✔
2081
                }
2082
            })
60✔
2083
            .ok()?;
60✔
2084
        let shard = shards.get(pos)?;
58✔
2085
        Some((shard, idx - shard.global_start))
58✔
2086
    }
60✔
2087

2088
    /// Read one JSONL/NDJSON line at a local row offset using checkpoints.
2089
    fn read_line_at(&self, shard: &ShardIndex, local_idx: usize) -> Result<String, SamplerError> {
53✔
2090
        let checkpoint_idx = local_idx / self.config.checkpoint_stride;
53✔
2091
        let checkpoint_line = checkpoint_idx * self.config.checkpoint_stride;
53✔
2092
        let seek_offset = *shard.checkpoints.get(checkpoint_idx).ok_or_else(|| {
53✔
2093
            SamplerError::SourceUnavailable {
2✔
2094
                source_id: self.config.source_id.clone(),
2✔
2095
                reason: format!(
2✔
2096
                    "missing checkpoint for shard {} line {}",
2✔
2097
                    shard.path.display(),
2✔
2098
                    local_idx
2✔
2099
                ),
2✔
2100
            }
2✔
2101
        })?;
2✔
2102

2103
        let mut file = File::open(&shard.path).map_err(|err| SamplerError::SourceUnavailable {
51✔
2104
            source_id: self.config.source_id.clone(),
×
2105
            reason: format!("failed opening shard {}: {err}", shard.path.display()),
×
2106
        })?;
×
2107
        file.seek(SeekFrom::Start(seek_offset))
51✔
2108
            .map_err(|err| SamplerError::SourceUnavailable {
51✔
2109
                source_id: self.config.source_id.clone(),
×
2110
                reason: format!("failed seeking shard {}: {err}", shard.path.display()),
×
2111
            })?;
×
2112

2113
        let mut reader = BufReader::new(file);
51✔
2114
        let mut line = String::new();
51✔
2115
        for _ in checkpoint_line..local_idx {
51✔
2116
            line.clear();
186✔
2117
            let bytes =
186✔
2118
                reader
186✔
2119
                    .read_line(&mut line)
186✔
2120
                    .map_err(|err| SamplerError::SourceUnavailable {
186✔
2121
                        source_id: self.config.source_id.clone(),
×
2122
                        reason: format!("failed scanning shard {}: {err}", shard.path.display()),
×
2123
                    })?;
×
2124
            if bytes == 0 {
186✔
2125
                return Err(SamplerError::SourceUnavailable {
×
2126
                    source_id: self.config.source_id.clone(),
×
2127
                    reason: format!(
×
2128
                        "unexpected EOF while scanning shard {} at row {}",
×
2129
                        shard.path.display(),
×
2130
                        local_idx
×
2131
                    ),
×
2132
                });
×
2133
            }
186✔
2134
        }
2135

2136
        line.clear();
51✔
2137
        let bytes = reader
51✔
2138
            .read_line(&mut line)
51✔
2139
            .map_err(|err| SamplerError::SourceUnavailable {
51✔
2140
                source_id: self.config.source_id.clone(),
×
2141
                reason: format!("failed reading shard {}: {err}", shard.path.display()),
×
2142
            })?;
×
2143
        if bytes == 0 {
51✔
2144
            return Err(SamplerError::SourceUnavailable {
1✔
2145
                source_id: self.config.source_id.clone(),
1✔
2146
                reason: format!(
1✔
2147
                    "unexpected EOF while reading shard {} row {}",
1✔
2148
                    shard.path.display(),
1✔
2149
                    local_idx
1✔
2150
                ),
1✔
2151
            });
1✔
2152
        }
50✔
2153
        Ok(line)
50✔
2154
    }
53✔
2155

2156
    /// Locate parquet row-group and in-group row offset for a local row index.
2157
    fn locate_parquet_group(
10✔
2158
        &self,
10✔
2159
        shard: &ShardIndex,
10✔
2160
        local_idx: usize,
10✔
2161
    ) -> Result<(usize, usize), SamplerError> {
10✔
2162
        let group_pos = shard
10✔
2163
            .parquet_row_groups
10✔
2164
            .binary_search_by(|(start, count)| {
14✔
2165
                if local_idx < *start {
14✔
2166
                    Ordering::Greater
1✔
2167
                } else if local_idx >= start.saturating_add(*count) {
13✔
2168
                    Ordering::Less
3✔
2169
                } else {
2170
                    Ordering::Equal
10✔
2171
                }
2172
            })
14✔
2173
            .map_err(|_| SamplerError::SourceUnavailable {
10✔
2174
                source_id: self.config.source_id.clone(),
1✔
2175
                reason: format!(
1✔
2176
                    "parquet row {} could not be mapped to a row group in {}",
2177
                    local_idx,
2178
                    shard.path.display()
1✔
2179
                ),
2180
            })?;
1✔
2181
        let (group_start, _) = shard.parquet_row_groups[group_pos];
9✔
2182
        Ok((group_pos, local_idx.saturating_sub(group_start)))
9✔
2183
    }
10✔
2184

2185
    /// Convert a serde JSON value into non-empty text when possible.
2186
    fn value_to_text(value: &Value) -> Option<String> {
133✔
2187
        match value {
133✔
2188
            Value::Null => None,
1✔
2189
            Value::String(s) => {
124✔
2190
                if s.trim().is_empty() {
124✔
2191
                    None
2✔
2192
                } else {
2193
                    Some(s.clone())
122✔
2194
                }
2195
            }
2196
            Value::Bool(b) => Some(b.to_string()),
2✔
2197
            Value::Number(n) => Some(n.to_string()),
5✔
2198
            Value::Array(_) | Value::Object(_) => Some(value.to_string()),
1✔
2199
        }
2200
    }
133✔
2201

2202
    /// Parse a raw row payload into normalized `RowView` fields.
2203
    fn parse_row(
61✔
2204
        &self,
61✔
2205
        absolute_idx: usize,
61✔
2206
        row_value: &Value,
61✔
2207
    ) -> Result<Option<RowView>, SamplerError> {
61✔
2208
        let row_payload = row_value.get("row").unwrap_or(row_value);
61✔
2209
        let row_obj = row_payload
61✔
2210
            .as_object()
61✔
2211
            .ok_or_else(|| SamplerError::SourceUnavailable {
61✔
2212
                source_id: self.config.source_id.clone(),
1✔
2213
                reason: "snapshot row entry missing JSON object payload".to_string(),
1✔
2214
            })?;
1✔
2215

2216
        let row_id = self
60✔
2217
            .config
60✔
2218
            .id_column
60✔
2219
            .as_ref()
60✔
2220
            .and_then(|col| row_obj.get(col))
60✔
2221
            .and_then(Self::value_to_text)
60✔
2222
            .unwrap_or_else(|| {
60✔
2223
                format!(
12✔
2224
                    "{}:{}:{}",
2225
                    self.config.dataset, self.config.split, absolute_idx
2226
                )
2227
            });
12✔
2228

2229
        let mut text_fields = Vec::new();
60✔
2230
        let use_role_columns = self.config.anchor_column.is_some()
60✔
2231
            || self.config.positive_column.is_some()
50✔
2232
            || !self.config.context_columns.is_empty();
50✔
2233

2234
        if use_role_columns {
60✔
2235
            if let Some(name) = &self.config.anchor_column {
10✔
2236
                let Some(value) = row_obj.get(name) else {
10✔
NEW
2237
                    return Ok(None);
×
2238
                };
2239
                let Some(text) = Self::value_to_text(value) else {
10✔
2240
                    return Ok(None);
1✔
2241
                };
2242
                text_fields.push(RowTextField {
9✔
2243
                    name: name.clone(),
9✔
2244
                    text,
9✔
2245
                });
9✔
2246
            }
×
2247

2248
            if let Some(name) = &self.config.positive_column {
9✔
2249
                let Some(value) = row_obj.get(name) else {
8✔
2250
                    return Ok(None);
1✔
2251
                };
2252
                let Some(text) = Self::value_to_text(value) else {
7✔
NEW
2253
                    return Ok(None);
×
2254
                };
2255
                text_fields.push(RowTextField {
7✔
2256
                    name: name.clone(),
7✔
2257
                    text,
7✔
2258
                });
7✔
2259
            }
1✔
2260

2261
            for name in &self.config.context_columns {
11✔
2262
                let Some(value) = row_obj.get(name) else {
11✔
2263
                    return Ok(None);
4✔
2264
                };
2265
                let Some(text) = Self::value_to_text(value) else {
7✔
NEW
2266
                    return Ok(None);
×
2267
                };
2268
                text_fields.push(RowTextField {
7✔
2269
                    name: name.clone(),
7✔
2270
                    text,
7✔
2271
                });
7✔
2272
            }
2273
        } else if self.config.text_columns.is_empty() {
50✔
2274
            for (name, value) in row_obj {
73✔
2275
                if self.config.id_column.as_ref().is_some_and(|id| id == name) {
73✔
2276
                    continue;
34✔
2277
                }
39✔
2278
                if let Some(text) = Self::value_to_text(value) {
39✔
2279
                    text_fields.push(RowTextField {
39✔
2280
                        name: name.clone(),
39✔
2281
                        text,
39✔
2282
                    });
39✔
2283
                }
39✔
2284
            }
2285
        } else {
2286
            for name in &self.config.text_columns {
17✔
2287
                let Some(value) = row_obj.get(name) else {
17✔
2288
                    return Ok(None);
1✔
2289
                };
2290
                let Some(text) = Self::value_to_text(value) else {
16✔
NEW
2291
                    return Ok(None);
×
2292
                };
2293
                text_fields.push(RowTextField {
16✔
2294
                    name: name.clone(),
16✔
2295
                    text,
16✔
2296
                });
16✔
2297
            }
2298
        }
2299

2300
        if text_fields.is_empty() {
53✔
NEW
2301
            return Ok(None);
×
2302
        }
53✔
2303

2304
        Ok(Some(RowView {
53✔
2305
            row_id: Some(row_id),
53✔
2306
            timestamp: None,
53✔
2307
            text_fields,
53✔
2308
        }))
53✔
2309
    }
61✔
2310

2311
    /// Decode one line from a non-parquet shard into an object-like row payload.
2312
    fn parse_non_parquet_line(
49✔
2313
        &self,
49✔
2314
        shard: &ShardIndex,
49✔
2315
        local_idx: usize,
49✔
2316
        line: &str,
49✔
2317
    ) -> Result<Value, SamplerError> {
49✔
2318
        let trimmed = line.trim();
49✔
2319
        if trimmed.is_empty() {
49✔
NEW
2320
            return Err(SamplerError::SourceInconsistent {
×
NEW
2321
                source_id: self.config.source_id.clone(),
×
NEW
2322
                details: format!(
×
NEW
2323
                    "empty row in shard {} at local index {}",
×
NEW
2324
                    shard.path.display(),
×
NEW
2325
                    local_idx
×
NEW
2326
                ),
×
NEW
2327
            });
×
2328
        }
49✔
2329

2330
        let is_strict_json_lines = shard
49✔
2331
            .path
49✔
2332
            .extension()
49✔
2333
            .and_then(|ext| ext.to_str())
49✔
2334
            .is_some_and(|ext| {
49✔
2335
                ext.eq_ignore_ascii_case("jsonl") || ext.eq_ignore_ascii_case("ndjson")
49✔
2336
            });
49✔
2337

2338
        match serde_json::from_str::<Value>(trimmed) {
49✔
2339
            Ok(value) => {
44✔
2340
                let payload = value.get("row").unwrap_or(&value);
44✔
2341
                if payload.is_object() {
44✔
2342
                    Ok(value)
44✔
NEW
2343
                } else if let Some(text) = Self::value_to_text(payload) {
×
NEW
2344
                    Ok(json!({ "text": text }))
×
2345
                } else {
NEW
2346
                    Err(SamplerError::SourceInconsistent {
×
NEW
2347
                        source_id: self.config.source_id.clone(),
×
NEW
2348
                        details: format!(
×
NEW
2349
                            "non-object JSON row in shard {} at local index {} could not be converted to text",
×
NEW
2350
                            shard.path.display(),
×
NEW
2351
                            local_idx
×
NEW
2352
                        ),
×
NEW
2353
                    })
×
2354
                }
2355
            }
2356
            Err(err) => {
5✔
2357
                if is_strict_json_lines {
5✔
2358
                    Err(SamplerError::SourceInconsistent {
3✔
2359
                        source_id: self.config.source_id.clone(),
3✔
2360
                        details: format!(
3✔
2361
                            "failed decoding JSON row from shard {} at local index {}: {err}",
3✔
2362
                            shard.path.display(),
3✔
2363
                            local_idx
3✔
2364
                        ),
3✔
2365
                    })
3✔
2366
                } else {
2367
                    Ok(json!({ "text": trimmed }))
2✔
2368
                }
2369
            }
2370
        }
2371
    }
49✔
2372

2373
    /// Convert a `RowView` into a sampler `DataRecord`.
2374
    fn row_to_record(
58✔
2375
        &self,
58✔
2376
        row: &RowView,
58✔
2377
        row_index: u64,
58✔
2378
    ) -> Result<Option<DataRecord>, SamplerError> {
58✔
2379
        if row.text_fields.is_empty() {
58✔
2380
            return Ok(None);
1✔
2381
        }
57✔
2382

2383
        let record_id = row
57✔
2384
            .row_id
57✔
2385
            .as_ref()
57✔
2386
            .cloned()
57✔
2387
            .unwrap_or_else(|| format!("row_{row_index}"));
57✔
2388
        let id = format!("{}::{}", self.config.source_id, record_id);
57✔
2389

2390
        let mut sections = Vec::new();
57✔
2391
        let anchor = &row.text_fields[0];
57✔
2392
        sections.push(make_section(
57✔
2393
            SectionRole::Anchor,
57✔
2394
            Some(anchor.name.as_str()),
57✔
2395
            anchor.text.as_str(),
57✔
2396
        ));
2397

2398
        let positive = row.text_fields.get(1).unwrap_or(anchor);
57✔
2399
        sections.push(make_section(
57✔
2400
            SectionRole::Context,
57✔
2401
            Some(positive.name.as_str()),
57✔
2402
            positive.text.as_str(),
57✔
2403
        ));
2404

2405
        for field in row.text_fields.iter().skip(2) {
57✔
2406
            sections.push(make_section(
6✔
2407
                SectionRole::Context,
6✔
2408
                Some(field.name.as_str()),
6✔
2409
                field.text.as_str(),
6✔
2410
            ));
6✔
2411
        }
6✔
2412

2413
        let timestamp = row.timestamp.unwrap_or(DateTime::<Utc>::UNIX_EPOCH);
57✔
2414
        Ok(Some(DataRecord {
57✔
2415
            id,
57✔
2416
            source: self.config.source_id.clone(),
57✔
2417
            created_at: timestamp,
57✔
2418
            updated_at: timestamp,
57✔
2419
            quality: QualityScore::default(),
57✔
2420
            taxonomy: vec![
57✔
2421
                format!("dataset={}", self.config.dataset),
57✔
2422
                format!("config={}", self.config.config),
57✔
2423
                format!("split={}", self.config.split),
57✔
2424
            ],
57✔
2425
            sections,
57✔
2426
            meta_prefix: None,
57✔
2427
        }))
57✔
2428
    }
58✔
2429

2430
    /// Materialize records for requested indices into output buffer.
2431
    fn read_row_batch(
29✔
2432
        &self,
29✔
2433
        indices: &[usize],
29✔
2434
        out: &mut Vec<DataRecord>,
29✔
2435
        limit: Option<usize>,
29✔
2436
    ) -> Result<(), SamplerError> {
29✔
2437
        let mut sorted = indices.to_vec();
29✔
2438
        sorted.sort_unstable();
29✔
2439

2440
        let mut fetched = HashMap::with_capacity(sorted.len());
29✔
2441
        let mut pending = Vec::new();
29✔
2442
        for idx in &sorted {
65✔
2443
            if !self.ensure_row_available(*idx)? {
65✔
2444
                fetched.insert(*idx, None);
2✔
2445
                continue;
2✔
2446
            }
63✔
2447

2448
            if let Some(row) = self
63✔
2449
                .cache
63✔
2450
                .lock()
63✔
2451
                .map_err(|_| SamplerError::SourceUnavailable {
63✔
2452
                    source_id: self.config.source_id.clone(),
×
2453
                    reason: "huggingface row cache lock poisoned".to_string(),
×
2454
                })?
×
2455
                .get(*idx)
63✔
2456
            {
2457
                let record = self.row_to_record(&row, *idx as u64)?;
5✔
2458
                fetched.insert(*idx, record);
5✔
2459
                continue;
5✔
2460
            }
58✔
2461

2462
            pending.push(*idx);
58✔
2463
        }
2464

2465
        if !pending.is_empty() {
29✔
2466
            let resolutions =
24✔
2467
                {
2468
                    let state = self
25✔
2469
                        .state
25✔
2470
                        .lock()
25✔
2471
                        .map_err(|_| SamplerError::SourceUnavailable {
25✔
2472
                            source_id: self.config.source_id.clone(),
×
2473
                            reason: "huggingface source state lock poisoned".to_string(),
×
2474
                        })?;
×
2475
                    let mut resolved = Vec::with_capacity(pending.len());
25✔
2476
                    for idx in &pending {
58✔
2477
                        let (shard, local_idx) = Self::locate_shard(&state.shards, *idx)
58✔
2478
                            .ok_or_else(|| SamplerError::SourceUnavailable {
58✔
2479
                                source_id: self.config.source_id.clone(),
1✔
2480
                                reason: format!("row index out of range: {idx}"),
1✔
2481
                            })?;
1✔
2482
                        resolved.push((*idx, shard.clone(), local_idx));
57✔
2483
                    }
2484
                    resolved
24✔
2485
                };
2486

2487
            let mut parquet_groups: HashMap<ParquetGroupKey, Vec<ParquetGroupRequest>> =
24✔
2488
                HashMap::new();
24✔
2489
            for (idx, shard, local_idx) in resolutions {
57✔
2490
                if shard.is_parquet {
57✔
2491
                    let (group_pos, local_in_group) =
8✔
2492
                        self.locate_parquet_group(&shard, local_idx)?;
8✔
2493
                    parquet_groups
8✔
2494
                        .entry((shard.path.clone(), group_pos))
8✔
2495
                        .or_default()
8✔
2496
                        .push((idx, local_in_group, shard));
8✔
2497
                    continue;
8✔
2498
                }
49✔
2499

2500
                let line = self.read_line_at(&shard, local_idx)?;
49✔
2501
                let row_value = self.parse_non_parquet_line(&shard, local_idx, &line)?;
49✔
2502
                let row = self.parse_row(idx, &row_value)?;
46✔
2503
                if let Some(row) = row {
46✔
2504
                    let record = self.row_to_record(&row, idx as u64)?;
43✔
2505
                    self.cache
43✔
2506
                        .lock()
43✔
2507
                        .map_err(|_| SamplerError::SourceUnavailable {
43✔
NEW
2508
                            source_id: self.config.source_id.clone(),
×
NEW
2509
                            reason: "huggingface row cache lock poisoned".to_string(),
×
NEW
2510
                        })?
×
2511
                        .insert(idx, row, self.config.cache_capacity);
43✔
2512
                    fetched.insert(idx, record);
43✔
2513
                } else {
3✔
2514
                    fetched.insert(idx, None);
3✔
2515
                }
3✔
2516
            }
2517

2518
            for ((shard_path, group_pos), mut requested) in parquet_groups {
21✔
2519
                requested.sort_by_key(|(_, local_in_group, _)| *local_in_group);
5✔
2520
                let shard = requested
5✔
2521
                    .first()
5✔
2522
                    .map(|(_, _, shard)| shard.clone())
5✔
2523
                    .ok_or_else(|| SamplerError::SourceUnavailable {
5✔
2524
                        source_id: self.config.source_id.clone(),
×
2525
                        reason: format!(
×
2526
                            "missing parquet request metadata for shard {} row_group {}",
2527
                            shard_path.display(),
×
2528
                            group_pos
2529
                        ),
2530
                    })?;
×
2531

2532
                let mut targets: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
5✔
2533
                for (idx, local_in_group, _) in requested {
8✔
2534
                    targets.entry(local_in_group).or_default().push(idx);
8✔
2535
                }
8✔
2536
                let max_target = targets.keys().next_back().copied().unwrap_or(0);
5✔
2537

2538
                let reader = self
5✔
2539
                    .parquet_cache
5✔
2540
                    .lock()
5✔
2541
                    .map_err(|_| SamplerError::SourceUnavailable {
5✔
2542
                        source_id: self.config.source_id.clone(),
×
2543
                        reason: "huggingface parquet cache lock poisoned".to_string(),
×
2544
                    })?
×
2545
                    .reader_for(&self.config.source_id, &shard.path)?;
5✔
2546

2547
                let row_group = reader.get_row_group(group_pos).map_err(|err| {
4✔
2548
                    SamplerError::SourceUnavailable {
×
2549
                        source_id: self.config.source_id.clone(),
×
2550
                        reason: format!(
×
2551
                            "failed opening parquet row group {} for {}: {err}",
×
2552
                            group_pos,
×
2553
                            shard.path.display()
×
2554
                        ),
×
2555
                    }
×
2556
                })?;
×
2557
                let iter = RowIter::from_row_group(None, row_group.as_ref()).map_err(|err| {
4✔
2558
                    SamplerError::SourceUnavailable {
×
2559
                        source_id: self.config.source_id.clone(),
×
2560
                        reason: format!(
×
2561
                            "failed iterating parquet row group {} for {}: {err}",
×
2562
                            group_pos,
×
2563
                            shard.path.display()
×
2564
                        ),
×
2565
                    }
×
2566
                })?;
×
2567

2568
                for (position, row_result) in iter.enumerate() {
7✔
2569
                    if position > max_target {
7✔
2570
                        break;
×
2571
                    }
7✔
2572
                    let Some(indices_for_position) = targets.remove(&position) else {
7✔
2573
                        continue;
1✔
2574
                    };
2575
                    let row_value = row_result.map_err(|err| SamplerError::SourceUnavailable {
6✔
2576
                        source_id: self.config.source_id.clone(),
×
2577
                        reason: format!(
×
2578
                            "failed reading parquet row {} in shard {} row_group {}: {err}",
2579
                            position,
2580
                            shard.path.display(),
×
2581
                            group_pos
2582
                        ),
2583
                    })?;
×
2584
                    let row_value = row_value.to_json_value();
6✔
2585

2586
                    for idx in indices_for_position {
6✔
2587
                        let row = self.parse_row(idx, &row_value)?;
6✔
2588
                        if let Some(row) = row {
6✔
2589
                            let record = self.row_to_record(&row, idx as u64)?;
5✔
2590
                            self.cache
5✔
2591
                                .lock()
5✔
2592
                                .map_err(|_| SamplerError::SourceUnavailable {
5✔
NEW
2593
                                    source_id: self.config.source_id.clone(),
×
NEW
2594
                                    reason: "huggingface row cache lock poisoned".to_string(),
×
NEW
2595
                                })?
×
2596
                                .insert(idx, row, self.config.cache_capacity);
5✔
2597
                            fetched.insert(idx, record);
5✔
2598
                        } else {
1✔
2599
                            fetched.insert(idx, None);
1✔
2600
                        }
1✔
2601
                    }
2602

2603
                    if targets.is_empty() {
6✔
2604
                        break;
3✔
2605
                    }
3✔
2606
                }
2607

2608
                if !targets.is_empty() {
4✔
2609
                    let missing = targets
1✔
2610
                        .into_keys()
1✔
2611
                        .map(|value| value.to_string())
1✔
2612
                        .collect::<Vec<_>>()
1✔
2613
                        .join(",");
1✔
2614
                    return Err(SamplerError::SourceUnavailable {
1✔
2615
                        source_id: self.config.source_id.clone(),
1✔
2616
                        reason: format!(
1✔
2617
                            "parquet rows missing in shard {} row_group {} at local offsets [{}]",
1✔
2618
                            shard.path.display(),
1✔
2619
                            group_pos,
1✔
2620
                            missing
1✔
2621
                        ),
1✔
2622
                    });
1✔
2623
                }
3✔
2624
            }
2625
        }
4✔
2626

2627
        for idx in indices {
59✔
2628
            if limit.is_some_and(|max| out.len() >= max) {
59✔
2629
                break;
1✔
2630
            }
58✔
2631
            if let Some(record) = fetched.remove(idx).flatten() {
58✔
2632
                out.push(record);
52✔
2633
            }
52✔
2634
        }
2635
        Ok(())
23✔
2636
    }
29✔
2637

2638
    /// Return the current index-domain upper bound for refresh paging.
2639
    fn len_hint(&self) -> Option<usize> {
31✔
2640
        let state = self.state.lock().ok()?;
31✔
2641
        let known = state.materialized_rows;
31✔
2642
        if known > 0 {
31✔
2643
            let mut upper = known;
26✔
2644
            if state
26✔
2645
                .total_rows
26✔
2646
                .is_some_and(|total_rows| total_rows > known)
26✔
2647
            {
2648
                let headroom = self.effective_expansion_headroom_rows();
4✔
2649
                upper = known.saturating_add(headroom);
4✔
2650
                if let Some(total_rows) = state.total_rows {
4✔
2651
                    upper = upper.min(total_rows);
4✔
2652
                }
4✔
2653
            }
22✔
2654
            if let Some(max_rows) = self.config.max_rows {
26✔
2655
                upper = upper.min(max_rows);
16✔
2656
            }
16✔
2657
            return Some(upper.max(known));
26✔
2658
        }
5✔
2659

2660
        if state.total_rows.is_some_and(|total_rows| total_rows == 0) {
5✔
2661
            return Some(0);
2✔
2662
        }
3✔
2663

2664
        if state
3✔
2665
            .remote_candidates
3✔
2666
            .as_ref()
3✔
2667
            .is_some_and(|candidates| candidates.is_empty())
3✔
2668
        {
2669
            return Some(0);
×
2670
        }
3✔
2671

2672
        if self.config.max_rows.is_some_and(|max_rows| max_rows == 0) {
3✔
2673
            return Some(0);
1✔
2674
        }
2✔
2675

2676
        Some(1)
2✔
2677
    }
31✔
2678
}
2679

2680
impl DataSource for HuggingFaceRowSource {
2681
    /// Return stable source id.
2682
    fn id(&self) -> &str {
×
2683
        &self.config.source_id
×
2684
    }
×
2685

2686
    /// Refresh source records for the requested cursor and row limit.
2687
    fn refresh(
21✔
2688
        &self,
21✔
2689
        config: &SamplerConfig,
21✔
2690
        cursor: Option<&SourceCursor>,
21✔
2691
        limit: Option<usize>,
21✔
2692
    ) -> Result<SourceSnapshot, SamplerError> {
21✔
2693
        self.set_active_sampler_config(config);
21✔
2694
        let total = self
21✔
2695
            .len_hint()
21✔
2696
            .ok_or_else(|| SamplerError::SourceInconsistent {
21✔
2697
                source_id: self.config.source_id.clone(),
×
2698
                details: "huggingface source did not provide len_hint".to_string(),
×
2699
            })?;
×
2700

2701
        if total == 0 {
21✔
2702
            return Ok(SourceSnapshot {
1✔
2703
                records: Vec::new(),
1✔
2704
                cursor: SourceCursor {
1✔
2705
                    last_seen: Utc::now(),
1✔
2706
                    revision: 0,
1✔
2707
                },
1✔
2708
            });
1✔
2709
        }
20✔
2710

2711
        let max = limit.unwrap_or(total);
20✔
2712
        let mut start = cursor.map(|state| state.revision as usize).unwrap_or(0);
20✔
2713
        if start >= total {
20✔
2714
            start = 0;
2✔
2715
        }
18✔
2716

2717
        let source_id = self.config.source_id.clone();
20✔
2718
        let seed = self.paging_seed(total)?;
20✔
2719
        let mut permutation = crate::source::IndexPermutation::new(total, seed, start as u64);
20✔
2720

2721
        let mut records = Vec::new();
20✔
2722
        let read_batch_target = self.effective_refresh_batch_target(max);
20✔
2723
        let mut pending_indices = Vec::with_capacity(read_batch_target);
20✔
2724
        let should_report = total >= 10_000 || max >= 1_024;
20✔
2725
        let report_every = Duration::from_millis(750);
20✔
2726
        let refresh_start = Instant::now();
20✔
2727
        let mut last_report = refresh_start;
20✔
2728
        let mut attempts = 0usize;
20✔
2729

2730
        if should_report {
20✔
2731
            info!(
1✔
2732
                "[triplets:source] refresh start source='{}' total={} target={}",
2733
                source_id, total, max
2734
            );
2735
        }
19✔
2736

2737
        while attempts < total && records.len() < max {
39✔
2738
            pending_indices.clear();
20✔
2739
            let remaining_attempts = total.saturating_sub(attempts);
20✔
2740
            let to_collect = read_batch_target.min(remaining_attempts);
20✔
2741
            for _ in 0..to_collect {
20✔
2742
                if records.len() + pending_indices.len() >= max {
54✔
2743
                    break;
2✔
2744
                }
52✔
2745
                pending_indices.push(permutation.next());
52✔
2746
                attempts += 1;
52✔
2747
            }
2748

2749
            if pending_indices.is_empty() {
20✔
2750
                break;
×
2751
            }
20✔
2752

2753
            if should_report {
20✔
2754
                info!(
1✔
2755
                    "[triplets:source] refresh batch source='{}' batch_size={} attempted={} fetched={} elapsed={:.1}s",
2756
                    source_id,
2757
                    pending_indices.len(),
1✔
2758
                    attempts,
2759
                    records.len(),
1✔
2760
                    refresh_start.elapsed().as_secs_f64()
1✔
2761
                );
2762
            }
19✔
2763

2764
            self.read_row_batch(&pending_indices, &mut records, Some(max))?;
20✔
2765

2766
            if should_report && last_report.elapsed() >= report_every {
19✔
2767
                info!(
×
2768
                    "[triplets:source] refresh progress source='{}' attempted={}/{} fetched={}/{} elapsed={:.1}s",
2769
                    source_id,
2770
                    attempts,
2771
                    total,
2772
                    records.len(),
×
2773
                    max,
2774
                    refresh_start.elapsed().as_secs_f64()
×
2775
                );
2776
                last_report = Instant::now();
×
2777
            }
19✔
2778
        }
2779

2780
        if should_report {
19✔
2781
            info!(
1✔
2782
                "[triplets:source] refresh done source='{}' attempted={} fetched={} elapsed={:.2}s",
2783
                source_id,
2784
                attempts,
2785
                records.len(),
1✔
2786
                refresh_start.elapsed().as_secs_f64()
1✔
2787
            );
2788
        }
18✔
2789

2790
        let next_start = permutation.cursor();
19✔
2791
        let last_seen = records
19✔
2792
            .iter()
19✔
2793
            .map(|record| record.updated_at)
19✔
2794
            .max()
19✔
2795
            .unwrap_or_else(Utc::now);
19✔
2796

2797
        Ok(SourceSnapshot {
19✔
2798
            records,
19✔
2799
            cursor: SourceCursor {
19✔
2800
                last_seen,
19✔
2801
                revision: next_start as u64,
19✔
2802
            },
19✔
2803
        })
19✔
2804
    }
21✔
2805

2806
    /// Return exact reported record count from current len hint.
2807
    fn reported_record_count(&self, config: &SamplerConfig) -> Result<u128, SamplerError> {
5✔
2808
        self.set_active_sampler_config(config);
5✔
2809
        self.len_hint()
5✔
2810
            .map(|count| count as u128)
5✔
2811
            .ok_or_else(|| SamplerError::SourceInconsistent {
5✔
2812
                source_id: self.config.source_id.clone(),
×
2813
                details: "huggingface source did not provide len_hint".to_string(),
×
2814
            })
×
2815
    }
5✔
2816

2817
    /// Return default triplet recipe used by Hugging Face row sources.
2818
    fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1✔
2819
        vec![TripletRecipe {
1✔
2820
            name: "huggingface_anchor_context".into(),
1✔
2821
            anchor: Selector::Role(SectionRole::Anchor),
1✔
2822
            positive_selector: Selector::Role(SectionRole::Context),
1✔
2823
            negative_selector: Selector::Role(SectionRole::Context),
1✔
2824
            negative_strategy: NegativeStrategy::WrongArticle,
1✔
2825
            weight: 1.0,
1✔
2826
            instruction: None,
1✔
2827
        }]
1✔
2828
    }
1✔
2829
}
2830

2831
#[cfg(test)]
2832
mod tests {
2833
    use super::*;
2834
    use parquet::data_type::{ByteArray, ByteArrayType};
2835
    use parquet::file::properties::WriterProperties;
2836
    use parquet::file::writer::SerializedFileWriter;
2837
    use parquet::schema::parser::parse_message_type;
2838
    use serde_json::json;
2839
    use std::env;
2840
    use std::io::{Read, Write};
2841
    use std::net::TcpListener;
2842
    use std::sync::OnceLock;
2843
    use std::thread;
2844
    use tempfile::tempdir;
2845

2846
    fn test_config(snapshot_dir: PathBuf) -> HuggingFaceRowsConfig {
123✔
2847
        let mut config =
123✔
2848
            HuggingFaceRowsConfig::new("hf_test", "org/dataset", "default", "train", snapshot_dir);
123✔
2849
        config.cache_capacity = 10;
123✔
2850
        config.remote_expansion_headroom_multiplier = 3;
123✔
2851
        config
123✔
2852
    }
123✔
2853

2854
    fn test_source(config: HuggingFaceRowsConfig) -> HuggingFaceRowSource {
66✔
2855
        let source = HuggingFaceRowSource {
66✔
2856
            config,
66✔
2857
            sampler_config: Mutex::new(None),
66✔
2858
            state: Mutex::new(SourceState {
66✔
2859
                materialized_rows: 0,
66✔
2860
                total_rows: None,
66✔
2861
                shards: Vec::new(),
66✔
2862
                remote_candidates: None,
66✔
2863
                remote_candidate_sizes: HashMap::new(),
66✔
2864
                next_remote_idx: 0,
66✔
2865
            }),
66✔
2866
            cache: Mutex::new(RowCache::default()),
66✔
2867
            parquet_cache: Mutex::new(ParquetCache::default()),
66✔
2868
        };
66✔
2869
        source.set_active_sampler_config(&SamplerConfig {
66✔
2870
            seed: 1,
66✔
2871
            ingestion_max_records: source.config.cache_capacity,
66✔
2872
            ..SamplerConfig::default()
66✔
2873
        });
66✔
2874
        source
66✔
2875
    }
66✔
2876

2877
    fn spawn_one_shot_http(payload: Vec<u8>) -> (String, thread::JoinHandle<()>) {
15✔
2878
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15✔
2879
        let addr = listener.local_addr().unwrap();
15✔
2880
        let handle = thread::spawn(move || {
15✔
2881
            let (mut stream, _) = listener.accept().unwrap();
15✔
2882
            let mut request_buf = [0u8; 1024];
15✔
2883
            let _ = stream.read(&mut request_buf);
15✔
2884
            let headers = format!(
15✔
2885
                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2886
                payload.len()
15✔
2887
            );
2888
            stream.write_all(headers.as_bytes()).unwrap();
15✔
2889
            stream.write_all(&payload).unwrap();
15✔
2890
            let _ = stream.flush();
15✔
2891
        });
15✔
2892
        (format!("http://{addr}"), handle)
15✔
2893
    }
15✔
2894

2895
    fn with_env_var<R>(key: &str, value: &str, run: impl FnOnce() -> R) -> R {
8✔
2896
        static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2897
        let guard = ENV_LOCK
8✔
2898
            .get_or_init(|| Mutex::new(()))
8✔
2899
            .lock()
8✔
2900
            .expect("env lock poisoned");
8✔
2901
        let previous = env::var(key).ok();
8✔
2902
        unsafe { env::set_var(key, value) };
8✔
2903
        let result = run();
8✔
2904
        if let Some(old) = previous {
8✔
2905
            unsafe { env::set_var(key, old) };
×
2906
        } else {
8✔
2907
            unsafe { env::remove_var(key) };
8✔
2908
        }
8✔
2909
        drop(guard);
8✔
2910
        result
8✔
2911
    }
8✔
2912

2913
    fn write_parquet_fixture(path: &Path, rows: &[(&str, &str)]) {
5✔
2914
        let schema = Arc::new(
5✔
2915
            parse_message_type(
5✔
2916
                "message test_schema {
5✔
2917
                    REQUIRED BINARY id (UTF8);
5✔
2918
                    REQUIRED BINARY text (UTF8);
5✔
2919
                }",
5✔
2920
            )
2921
            .unwrap(),
5✔
2922
        );
2923
        let props = Arc::new(WriterProperties::builder().build());
5✔
2924
        let file = File::create(path).unwrap();
5✔
2925
        let mut writer = SerializedFileWriter::new(file, schema, props).unwrap();
5✔
2926
        let mut row_group = writer.next_row_group().unwrap();
5✔
2927

2928
        if let Some(mut col_writer) = row_group.next_column().unwrap() {
5✔
2929
            let values = rows
5✔
2930
                .iter()
5✔
2931
                .map(|(id, _)| ByteArray::from(*id))
9✔
2932
                .collect::<Vec<_>>();
5✔
2933
            col_writer
5✔
2934
                .typed::<ByteArrayType>()
5✔
2935
                .write_batch(&values, None, None)
5✔
2936
                .unwrap();
5✔
2937
            col_writer.close().unwrap();
5✔
2938
        }
×
2939

2940
        if let Some(mut col_writer) = row_group.next_column().unwrap() {
5✔
2941
            let values = rows
5✔
2942
                .iter()
5✔
2943
                .map(|(_, text)| ByteArray::from(*text))
9✔
2944
                .collect::<Vec<_>>();
5✔
2945
            col_writer
5✔
2946
                .typed::<ByteArrayType>()
5✔
2947
                .write_batch(&values, None, None)
5✔
2948
                .unwrap();
5✔
2949
            col_writer.close().unwrap();
5✔
2950
        }
×
2951

2952
        assert!(row_group.next_column().unwrap().is_none());
5✔
2953
        row_group.close().unwrap();
5✔
2954
        writer.close().unwrap();
5✔
2955
    }
5✔
2956

2957
    #[test]
2958
    fn row_cache_insert_and_evicts_oldest_entry() {
1✔
2959
        let mut cache = RowCache::default();
1✔
2960
        let row_a = RowView {
1✔
2961
            row_id: Some("a".to_string()),
1✔
2962
            timestamp: None,
1✔
2963
            text_fields: vec![RowTextField {
1✔
2964
                name: "text".to_string(),
1✔
2965
                text: "alpha".to_string(),
1✔
2966
            }],
1✔
2967
        };
1✔
2968
        let row_b = RowView {
1✔
2969
            row_id: Some("b".to_string()),
1✔
2970
            timestamp: None,
1✔
2971
            text_fields: vec![RowTextField {
1✔
2972
                name: "text".to_string(),
1✔
2973
                text: "beta".to_string(),
1✔
2974
            }],
1✔
2975
        };
1✔
2976

2977
        cache.insert(0, row_a.clone(), 1);
1✔
2978
        assert!(cache.get(0).is_some());
1✔
2979

2980
        cache.insert(1, row_b, 1);
1✔
2981
        assert!(cache.get(0).is_none());
1✔
2982
        assert_eq!(cache.get(1).unwrap().row_id.as_deref(), Some("b"));
1✔
2983

2984
        let mut zero_cache = RowCache::default();
1✔
2985
        zero_cache.insert(7, row_a, 0);
1✔
2986
        assert!(zero_cache.get(7).is_none());
1✔
2987
    }
1✔
2988

2989
    #[test]
2990
    fn parquet_cache_reader_for_reports_open_and_parse_errors() {
1✔
2991
        let dir = tempdir().unwrap();
1✔
2992
        let parquet_path = dir.path().join("missing.parquet");
1✔
2993
        let mut cache = ParquetCache::default();
1✔
2994
        let missing = cache.reader_for("hf_test", &parquet_path);
1✔
2995
        assert!(missing.is_err());
1✔
2996

2997
        let invalid_parquet = dir.path().join("invalid.parquet");
1✔
2998
        fs::write(&invalid_parquet, b"not parquet").unwrap();
1✔
2999
        let invalid = cache.reader_for("hf_test", &invalid_parquet);
1✔
3000
        assert!(invalid.is_err());
1✔
3001
    }
1✔
3002

3003
    #[test]
3004
    fn effective_targets_respect_minimum_multiplier_and_sampler_override() {
1✔
3005
        let dir = tempdir().unwrap();
1✔
3006
        let mut config = test_config(dir.path().to_path_buf());
1✔
3007
        config.refresh_batch_multiplier = 0;
1✔
3008
        config.remote_expansion_headroom_multiplier = 0;
1✔
3009
        config.cache_capacity = 9;
1✔
3010
        let source = test_source(config.clone());
1✔
3011

3012
        assert_eq!(source.effective_refresh_batch_target(5), 5);
1✔
3013
        assert_eq!(source.effective_expansion_headroom_rows(), 9);
1✔
3014

3015
        let sampler = SamplerConfig {
1✔
3016
            ingestion_max_records: 4,
1✔
3017
            ..SamplerConfig::default()
1✔
3018
        };
1✔
3019
        *source.sampler_config.lock().unwrap() = Some(sampler);
1✔
3020
        assert_eq!(source.effective_expansion_headroom_rows(), 4);
1✔
3021
    }
1✔
3022

3023
    #[test]
3024
    fn collect_candidates_from_siblings_filters_split_and_tracks_parquet() {
1✔
3025
        let dir = tempdir().unwrap();
1✔
3026
        let config = test_config(dir.path().to_path_buf());
1✔
3027
        let accepted = HuggingFaceRowSource::normalized_shard_extensions(&config);
1✔
3028
        let siblings = vec![
1✔
3029
            "train/a.ndjson".to_string(),
1✔
3030
            "dev/b.ndjson".to_string(),
1✔
3031
            "train-c.parquet".to_string(),
1✔
3032
            "train-z.txt".to_string(),
1✔
3033
        ];
3034

3035
        let (candidates, saw_parquet) = HuggingFaceRowSource::collect_candidates_from_siblings(
1✔
3036
            &config, &siblings, &accepted, true,
1✔
3037
        );
1✔
3038

3039
        assert!(saw_parquet);
1✔
3040
        assert_eq!(
1✔
3041
            candidates,
3042
            vec!["train/a.ndjson".to_string(), "train-c.parquet".to_string()]
1✔
3043
        );
3044
    }
1✔
3045

3046
    #[test]
3047
    fn collect_candidates_from_siblings_skips_existing_targets() {
1✔
3048
        let dir = tempdir().unwrap();
1✔
3049
        let config = test_config(dir.path().to_path_buf());
1✔
3050
        let accepted = HuggingFaceRowSource::normalized_shard_extensions(&config);
1✔
3051
        let existing = "train/already.ndjson".to_string();
1✔
3052
        let existing_target = HuggingFaceRowSource::candidate_target_path(&config, &existing);
1✔
3053
        fs::create_dir_all(existing_target.parent().unwrap()).unwrap();
1✔
3054
        fs::write(&existing_target, b"x\n").unwrap();
1✔
3055

3056
        let siblings = vec![existing, "train/new.ndjson".to_string()];
1✔
3057
        let (candidates, _) = HuggingFaceRowSource::collect_candidates_from_siblings(
1✔
3058
            &config, &siblings, &accepted, true,
1✔
3059
        );
1✔
3060
        assert_eq!(candidates, vec!["train/new.ndjson".to_string()]);
1✔
3061
    }
1✔
3062

3063
    #[test]
3064
    fn candidates_from_parquet_manifest_json_filters_and_records_sizes() {
1✔
3065
        let dir = tempdir().unwrap();
1✔
3066
        let config = test_config(dir.path().to_path_buf());
1✔
3067
        let payload = json!({
1✔
3068
            "parquet_files": [
1✔
3069
                {"url": "https://host/x/train/000.parquet", "size": 11},
1✔
3070
                {"url": "https://host/x/train/001.ndjson", "size": 13},
1✔
3071
                {"url": "https://host/x/train/002.txt", "size": 5},
1✔
3072
                {"foo": "missing-url"}
1✔
3073
            ]
3074
        });
3075

3076
        let (candidates, sizes) =
1✔
3077
            HuggingFaceRowSource::candidates_from_parquet_manifest_json(&config, &payload).unwrap();
1✔
3078
        assert_eq!(candidates.len(), 2);
1✔
3079
        assert!(
1✔
3080
            candidates
1✔
3081
                .iter()
1✔
3082
                .any(|c| c.ends_with("https://host/x/train/000.parquet"))
1✔
3083
        );
3084
        assert!(
1✔
3085
            candidates
1✔
3086
                .iter()
1✔
3087
                .any(|c| c.ends_with("https://host/x/train/001.ndjson"))
2✔
3088
        );
3089
        assert_eq!(sizes.len(), 2);
1✔
3090
    }
1✔
3091

3092
    #[test]
3093
    fn candidates_from_parquet_manifest_skips_complete_cached_and_replaces_incomplete() {
1✔
3094
        let dir = tempdir().unwrap();
1✔
3095
        let config = test_config(dir.path().to_path_buf());
1✔
3096

3097
        let complete_url = "https://host/datasets/org/ds/resolve/main/train/000.parquet";
1✔
3098
        let complete_candidate = format!("{REMOTE_URL_PREFIX}{complete_url}");
1✔
3099
        let complete_target =
1✔
3100
            HuggingFaceRowSource::candidate_target_path(&config, &complete_candidate);
1✔
3101
        fs::create_dir_all(complete_target.parent().unwrap()).unwrap();
1✔
3102
        fs::write(&complete_target, vec![1u8; 7]).unwrap();
1✔
3103

3104
        let stale_url = "https://host/datasets/org/ds/resolve/main/train/001.parquet";
1✔
3105
        let stale_candidate = format!("{REMOTE_URL_PREFIX}{stale_url}");
1✔
3106
        let stale_target = HuggingFaceRowSource::candidate_target_path(&config, &stale_candidate);
1✔
3107
        fs::create_dir_all(stale_target.parent().unwrap()).unwrap();
1✔
3108
        fs::write(&stale_target, vec![2u8; 3]).unwrap();
1✔
3109

3110
        let payload = json!({
1✔
3111
            "parquet_files": [
1✔
3112
                {"url": complete_url, "size": 7},
1✔
3113
                {"url": stale_url, "size": 9}
1✔
3114
            ]
3115
        });
3116

3117
        let (candidates, sizes) =
1✔
3118
            HuggingFaceRowSource::candidates_from_parquet_manifest_json(&config, &payload).unwrap();
1✔
3119
        assert_eq!(candidates.len(), 1);
1✔
3120
        assert!(candidates[0].ends_with(stale_url));
1✔
3121
        assert!(!stale_target.exists());
1✔
3122
        assert_eq!(sizes[&candidates[0]], 9);
1✔
3123
        assert!(complete_target.exists());
1✔
3124
    }
1✔
3125

3126
    #[test]
3127
    fn candidates_from_parquet_manifest_errors_when_removing_incomplete_target_fails() {
1✔
3128
        let dir = tempdir().unwrap();
1✔
3129
        let config = test_config(dir.path().to_path_buf());
1✔
3130
        let url = "https://host/datasets/org/ds/resolve/main/train/blocked.parquet";
1✔
3131
        let candidate = format!("{REMOTE_URL_PREFIX}{url}");
1✔
3132
        let target = HuggingFaceRowSource::candidate_target_path(&config, &candidate);
1✔
3133
        fs::create_dir_all(&target).unwrap();
1✔
3134

3135
        let payload = json!({
1✔
3136
            "parquet_files": [
1✔
3137
                {"url": url, "size": 1}
1✔
3138
            ]
3139
        });
3140

3141
        let err = HuggingFaceRowSource::candidates_from_parquet_manifest_json(&config, &payload);
1✔
3142
        assert!(err.is_err());
1✔
3143
    }
1✔
3144

3145
    #[test]
3146
    fn normalized_shard_extensions_trims_dots_and_lowercases() {
1✔
3147
        let dir = tempdir().unwrap();
1✔
3148
        let mut config = test_config(dir.path().to_path_buf());
1✔
3149
        config.shard_extensions = vec![".PARQUET".into(), " ndjson ".into()];
1✔
3150
        let normalized = HuggingFaceRowSource::normalized_shard_extensions(&config);
1✔
3151
        assert_eq!(
1✔
3152
            normalized,
3153
            vec!["parquet".to_string(), "ndjson".to_string()]
1✔
3154
        );
3155
    }
1✔
3156

3157
    #[test]
3158
    fn manifest_usage_bytes_locked_counts_only_manifest_shards() {
1✔
3159
        let dir = tempdir().unwrap();
1✔
3160
        let config = test_config(dir.path().to_path_buf());
1✔
3161
        let source = test_source(config);
1✔
3162
        let manifest_root = source.manifest_cache_root();
1✔
3163
        fs::create_dir_all(&manifest_root).unwrap();
1✔
3164

3165
        let manifest_file = manifest_root.join("a.parquet");
1✔
3166
        fs::write(&manifest_file, vec![1u8; 7]).unwrap();
1✔
3167
        let local_file = source.config.snapshot_dir.join("local.ndjson");
1✔
3168
        fs::write(&local_file, vec![2u8; 9]).unwrap();
1✔
3169

3170
        let state = SourceState {
1✔
3171
            materialized_rows: 2,
1✔
3172
            total_rows: None,
1✔
3173
            shards: vec![
1✔
3174
                ShardIndex {
1✔
3175
                    path: manifest_file,
1✔
3176
                    global_start: 0,
1✔
3177
                    row_count: 1,
1✔
3178
                    is_parquet: true,
1✔
3179
                    parquet_row_groups: vec![(0, 1)],
1✔
3180
                    checkpoints: Vec::new(),
1✔
3181
                },
1✔
3182
                ShardIndex {
1✔
3183
                    path: local_file,
1✔
3184
                    global_start: 1,
1✔
3185
                    row_count: 1,
1✔
3186
                    is_parquet: false,
1✔
3187
                    parquet_row_groups: Vec::new(),
1✔
3188
                    checkpoints: vec![0],
1✔
3189
                },
1✔
3190
            ],
1✔
3191
            remote_candidates: None,
1✔
3192
            remote_candidate_sizes: HashMap::new(),
1✔
3193
            next_remote_idx: 0,
1✔
3194
        };
1✔
3195

3196
        assert_eq!(source.manifest_usage_bytes_locked(&state), 7);
1✔
3197
    }
1✔
3198

3199
    #[test]
3200
    fn build_shard_index_errors_when_parquet_present_but_not_accepted() {
1✔
3201
        let dir = tempdir().unwrap();
1✔
3202
        fs::write(dir.path().join("rows.parquet"), b"fake").unwrap();
1✔
3203
        let mut config = test_config(dir.path().to_path_buf());
1✔
3204
        config.shard_extensions = vec!["ndjson".to_string()];
1✔
3205

3206
        let result = HuggingFaceRowSource::build_shard_index(&config);
1✔
3207
        assert!(result.is_err());
1✔
3208
    }
1✔
3209

3210
    #[test]
3211
    fn locate_parquet_group_maps_offsets_and_reports_missing() {
1✔
3212
        let dir = tempdir().unwrap();
1✔
3213
        let config = test_config(dir.path().to_path_buf());
1✔
3214
        let source = test_source(config);
1✔
3215
        let shard = ShardIndex {
1✔
3216
            path: dir.path().join("rows.parquet"),
1✔
3217
            global_start: 0,
1✔
3218
            row_count: 6,
1✔
3219
            is_parquet: true,
1✔
3220
            parquet_row_groups: vec![(0, 2), (2, 2), (4, 2)],
1✔
3221
            checkpoints: Vec::new(),
1✔
3222
        };
1✔
3223

3224
        let mapped = source.locate_parquet_group(&shard, 3).unwrap();
1✔
3225
        assert_eq!(mapped, (1, 1));
1✔
3226
        let missing = source.locate_parquet_group(&shard, 99);
1✔
3227
        assert!(missing.is_err());
1✔
3228
    }
1✔
3229

3230
    #[test]
3231
    fn parse_row_role_columns_mode_builds_expected_fields() {
1✔
3232
        let dir = tempdir().unwrap();
1✔
3233
        let mut config = test_config(dir.path().to_path_buf());
1✔
3234
        config.anchor_column = Some("anchor".into());
1✔
3235
        config.positive_column = Some("positive".into());
1✔
3236
        config.context_columns = vec!["ctx1".into(), "ctx2".into()];
1✔
3237
        let source = test_source(config);
1✔
3238

3239
        let row = source
1✔
3240
            .parse_row(
1✔
3241
                2,
3242
                &json!({"id":"r","anchor":"a","positive":"p","ctx1":"c1","ctx2":2}),
1✔
3243
            )
3244
            .unwrap()
1✔
3245
            .unwrap();
1✔
3246
        assert_eq!(row.text_fields.len(), 4);
1✔
3247
        assert_eq!(row.text_fields[0].name, "anchor");
1✔
3248
        assert_eq!(row.text_fields[1].name, "positive");
1✔
3249
    }
1✔
3250

3251
    #[test]
3252
    fn parse_row_role_columns_mode_skips_missing_or_empty_values() {
1✔
3253
        let dir = tempdir().unwrap();
1✔
3254
        let mut config = test_config(dir.path().to_path_buf());
1✔
3255
        config.anchor_column = Some("anchor".into());
1✔
3256
        config.context_columns = vec!["ctx".into()];
1✔
3257
        let source = test_source(config);
1✔
3258

3259
        let missing = source.parse_row(0, &json!({"anchor":"a"}));
1✔
3260
        assert!(missing.unwrap().is_none());
1✔
3261

3262
        let empty_anchor = source.parse_row(1, &json!({"anchor":"   ", "ctx":"ok"}));
1✔
3263
        assert!(empty_anchor.unwrap().is_none());
1✔
3264
    }
1✔
3265

3266
    #[test]
3267
    fn row_to_record_uses_anchor_for_positive_when_single_field() {
1✔
3268
        let dir = tempdir().unwrap();
1✔
3269
        let config = test_config(dir.path().to_path_buf());
1✔
3270
        let source = test_source(config);
1✔
3271
        let row = RowView {
1✔
3272
            row_id: Some("r1".into()),
1✔
3273
            timestamp: None,
1✔
3274
            text_fields: vec![RowTextField {
1✔
3275
                name: "text".into(),
1✔
3276
                text: "alpha".into(),
1✔
3277
            }],
1✔
3278
        };
1✔
3279

3280
        let record = source.row_to_record(&row, 0).unwrap().unwrap();
1✔
3281
        assert_eq!(record.sections.len(), 2);
1✔
3282
        assert_eq!(record.sections[0].text, record.sections[1].text);
1✔
3283
    }
1✔
3284

3285
    #[test]
3286
    fn read_line_at_errors_on_unexpected_eof_while_scanning() {
1✔
3287
        let dir = tempdir().unwrap();
1✔
3288
        let path = dir.path().join("rows.jsonl");
1✔
3289
        fs::write(&path, b"{\"text\":\"a\"}\n").unwrap();
1✔
3290
        let mut config = test_config(dir.path().to_path_buf());
1✔
3291
        config.checkpoint_stride = 1;
1✔
3292
        let source = test_source(config.clone());
1✔
3293
        let mut shard = HuggingFaceRowSource::index_single_shard(&config, &path, 0)
1✔
3294
            .unwrap()
1✔
3295
            .unwrap();
1✔
3296
        shard.checkpoints = vec![0];
1✔
3297

3298
        let err = source.read_line_at(&shard, 3);
1✔
3299
        assert!(err.is_err());
1✔
3300
    }
1✔
3301

3302
    #[test]
3303
    fn target_matches_expected_size_is_false_for_missing_path() {
1✔
3304
        let dir = tempdir().unwrap();
1✔
3305
        let missing = dir.path().join("missing.bin");
1✔
3306
        assert!(!HuggingFaceRowSource::target_matches_expected_size(
1✔
3307
            &missing,
1✔
3308
            Some(1)
1✔
3309
        ));
1✔
3310
    }
1✔
3311

3312
    #[test]
3313
    fn candidate_target_path_uses_fallback_suffix_without_resolve_segment() {
1✔
3314
        let dir = tempdir().unwrap();
1✔
3315
        let config = test_config(dir.path().to_path_buf());
1✔
3316
        let candidate = "url::https://example.com/raw/file.parquet";
1✔
3317
        let target = HuggingFaceRowSource::candidate_target_path(&config, candidate);
1✔
3318
        assert!(target.ends_with("_parquet_manifest/parquet/unknown.parquet"));
1✔
3319
    }
1✔
3320

3321
    #[test]
3322
    fn persist_shard_sequence_is_noop_without_remote_candidates() {
1✔
3323
        let dir = tempdir().unwrap();
1✔
3324
        let config = test_config(dir.path().to_path_buf());
1✔
3325
        let source = test_source(config.clone());
1✔
3326
        let state = SourceState {
1✔
3327
            materialized_rows: 0,
1✔
3328
            total_rows: None,
1✔
3329
            shards: Vec::new(),
1✔
3330
            remote_candidates: None,
1✔
3331
            remote_candidate_sizes: HashMap::new(),
1✔
3332
            next_remote_idx: 0,
1✔
3333
        };
1✔
3334

3335
        source.persist_shard_sequence_locked(&state).unwrap();
1✔
3336
        assert!(!HuggingFaceRowSource::shard_sequence_state_path(&config).exists());
1✔
3337
    }
1✔
3338

3339
    #[test]
3340
    fn load_persisted_shard_sequence_returns_none_for_identity_mismatch() {
1✔
3341
        let dir = tempdir().unwrap();
1✔
3342
        let config = test_config(dir.path().to_path_buf());
1✔
3343
        let state_path = HuggingFaceRowSource::shard_sequence_state_path(&config);
1✔
3344
        fs::create_dir_all(state_path.parent().unwrap()).unwrap();
1✔
3345
        fs::write(
1✔
3346
            &state_path,
1✔
3347
            serde_json::to_vec_pretty(&json!({
1✔
3348
                "version": 1,
1✔
3349
                "source_id": "different",
1✔
3350
                "dataset": config.dataset,
1✔
3351
                "config": config.config,
1✔
3352
                "split": config.split,
1✔
3353
                "sampler_seed": 1,
1✔
3354
                "candidates": ["train/0.ndjson"],
1✔
3355
                "candidate_sizes": {},
1✔
3356
                "next_remote_idx": 0
1✔
3357
            }))
1✔
3358
            .unwrap(),
1✔
3359
        )
3360
        .unwrap();
1✔
3361

3362
        let loaded = HuggingFaceRowSource::load_persisted_shard_sequence(&config, 1).unwrap();
1✔
3363
        assert!(loaded.is_none());
1✔
3364
    }
1✔
3365

3366
    #[test]
3367
    fn parse_row_falls_back_to_synthetic_id_when_missing_id_column() {
1✔
3368
        let dir = tempdir().unwrap();
1✔
3369
        let mut config = test_config(dir.path().to_path_buf());
1✔
3370
        config.id_column = Some("id".into());
1✔
3371
        let source = test_source(config);
1✔
3372

3373
        let row = source
1✔
3374
            .parse_row(42, &json!({"text": "hello"}))
1✔
3375
            .unwrap()
1✔
3376
            .unwrap();
1✔
3377
        assert_eq!(row.row_id, Some("org/dataset:train:42".to_string()));
1✔
3378
    }
1✔
3379

3380
    #[test]
3381
    fn row_to_record_falls_back_to_row_index_when_row_id_missing() {
1✔
3382
        let dir = tempdir().unwrap();
1✔
3383
        let config = test_config(dir.path().to_path_buf());
1✔
3384
        let source = test_source(config);
1✔
3385
        let row = RowView {
1✔
3386
            row_id: None,
1✔
3387
            timestamp: None,
1✔
3388
            text_fields: vec![RowTextField {
1✔
3389
                name: "text".into(),
1✔
3390
                text: "body".into(),
1✔
3391
            }],
1✔
3392
        };
1✔
3393

3394
        let record = source.row_to_record(&row, 7).unwrap().unwrap();
1✔
3395
        assert!(record.id.ends_with("::row_7"));
1✔
3396
    }
1✔
3397

3398
    #[test]
3399
    fn locate_shard_returns_none_for_out_of_range_index() {
1✔
3400
        let shards = vec![ShardIndex {
1✔
3401
            path: PathBuf::from("a.ndjson"),
1✔
3402
            global_start: 0,
1✔
3403
            row_count: 2,
1✔
3404
            is_parquet: false,
1✔
3405
            parquet_row_groups: Vec::new(),
1✔
3406
            checkpoints: vec![0],
1✔
3407
        }];
1✔
3408

3409
        assert!(HuggingFaceRowSource::locate_shard(&shards, 5).is_none());
1✔
3410
    }
1✔
3411

3412
    #[test]
3413
    fn read_row_batch_errors_when_row_not_mappable_to_shard() {
1✔
3414
        let dir = tempdir().unwrap();
1✔
3415
        let config = test_config(dir.path().to_path_buf());
1✔
3416
        let source = test_source(config);
1✔
3417
        {
1✔
3418
            let mut state = source.state.lock().unwrap();
1✔
3419
            state.materialized_rows = 1;
1✔
3420
            state.total_rows = Some(1);
1✔
3421
            state.shards.clear();
1✔
3422
        }
1✔
3423

3424
        let mut out = Vec::new();
1✔
3425
        let err = source.read_row_batch(&[0], &mut out, Some(1));
1✔
3426
        assert!(err.is_err());
1✔
3427
    }
1✔
3428

3429
    #[test]
3430
    fn read_row_batch_errors_on_invalid_json_row() {
1✔
3431
        let dir = tempdir().unwrap();
1✔
3432
        let path = dir.path().join("broken.ndjson");
1✔
3433
        fs::write(&path, b"not-json\n").unwrap();
1✔
3434
        let config = test_config(dir.path().to_path_buf());
1✔
3435
        let source = test_source(config);
1✔
3436

3437
        {
1✔
3438
            let mut state = source.state.lock().unwrap();
1✔
3439
            state.materialized_rows = 1;
1✔
3440
            state.total_rows = Some(1);
1✔
3441
            state.shards = vec![ShardIndex {
1✔
3442
                path,
1✔
3443
                global_start: 0,
1✔
3444
                row_count: 1,
1✔
3445
                is_parquet: false,
1✔
3446
                parquet_row_groups: Vec::new(),
1✔
3447
                checkpoints: vec![0],
1✔
3448
            }];
1✔
3449
        }
1✔
3450

3451
        let mut out = Vec::new();
1✔
3452
        let err = source.read_row_batch(&[0], &mut out, Some(1)).unwrap_err();
1✔
3453
        assert!(matches!(
1✔
3454
            err,
1✔
3455
            SamplerError::SourceInconsistent { ref details, .. } if details.contains("failed decoding JSON row")
1✔
3456
        ));
3457
    }
1✔
3458

3459
    #[test]
3460
    fn read_row_batch_errors_when_parquet_local_offsets_are_missing() {
1✔
3461
        let dir = tempdir().unwrap();
1✔
3462
        let path = dir.path().join("rows.parquet");
1✔
3463
        write_parquet_fixture(&path, &[("id-1", "text-1")]);
1✔
3464
        let config = test_config(dir.path().to_path_buf());
1✔
3465
        let source = test_source(config);
1✔
3466

3467
        {
1✔
3468
            let mut state = source.state.lock().unwrap();
1✔
3469
            state.materialized_rows = 3;
1✔
3470
            state.total_rows = Some(3);
1✔
3471
            state.shards = vec![ShardIndex {
1✔
3472
                path,
1✔
3473
                global_start: 0,
1✔
3474
                row_count: 3,
1✔
3475
                is_parquet: true,
1✔
3476
                parquet_row_groups: vec![(0, 3)],
1✔
3477
                checkpoints: Vec::new(),
1✔
3478
            }];
1✔
3479
        }
1✔
3480

3481
        let mut out = Vec::new();
1✔
3482
        let err = source.read_row_batch(&[2], &mut out, Some(1)).unwrap_err();
1✔
3483
        assert!(matches!(
1✔
3484
            err,
1✔
3485
            SamplerError::SourceUnavailable { ref reason, .. } if reason.contains("parquet rows missing")
1✔
3486
        ));
3487
    }
1✔
3488

3489
    #[test]
3490
    fn len_hint_applies_max_rows_cap() {
1✔
3491
        let dir = tempdir().unwrap();
1✔
3492
        let mut config = test_config(dir.path().to_path_buf());
1✔
3493
        config.max_rows = Some(3);
1✔
3494
        let source = test_source(config);
1✔
3495
        {
1✔
3496
            let mut state = source.state.lock().unwrap();
1✔
3497
            state.materialized_rows = 2;
1✔
3498
            state.total_rows = Some(100);
1✔
3499
        }
1✔
3500
        assert_eq!(source.len_hint(), Some(3));
1✔
3501
    }
1✔
3502

3503
    #[test]
3504
    fn enforce_disk_cap_returns_false_when_disabled_or_under_limit() {
1✔
3505
        let dir = tempdir().unwrap();
1✔
3506
        let mut config = test_config(dir.path().to_path_buf());
1✔
3507
        config.local_disk_cap_bytes = None;
1✔
3508
        let source = test_source(config);
1✔
3509
        let mut state = SourceState {
1✔
3510
            materialized_rows: 0,
1✔
3511
            total_rows: None,
1✔
3512
            shards: Vec::new(),
1✔
3513
            remote_candidates: None,
1✔
3514
            remote_candidate_sizes: HashMap::new(),
1✔
3515
            next_remote_idx: 0,
1✔
3516
        };
1✔
3517
        let protected = dir.path().join("p");
1✔
3518
        assert!(
1✔
3519
            !source
1✔
3520
                .enforce_disk_cap_locked(&mut state, &protected)
1✔
3521
                .unwrap()
1✔
3522
        );
3523

3524
        let mut config2 = test_config(dir.path().to_path_buf());
1✔
3525
        config2.local_disk_cap_bytes = Some(10_000);
1✔
3526
        let source2 = test_source(config2);
1✔
3527
        let manifest_root = source2.manifest_cache_root();
1✔
3528
        fs::create_dir_all(&manifest_root).unwrap();
1✔
3529
        let shard_path = manifest_root.join("small.parquet");
1✔
3530
        fs::write(&shard_path, vec![1u8; 32]).unwrap();
1✔
3531
        let mut state2 = SourceState {
1✔
3532
            materialized_rows: 1,
1✔
3533
            total_rows: None,
1✔
3534
            shards: vec![ShardIndex {
1✔
3535
                path: shard_path,
1✔
3536
                global_start: 0,
1✔
3537
                row_count: 1,
1✔
3538
                is_parquet: true,
1✔
3539
                parquet_row_groups: vec![(0, 1)],
1✔
3540
                checkpoints: Vec::new(),
1✔
3541
            }],
1✔
3542
            remote_candidates: None,
1✔
3543
            remote_candidate_sizes: HashMap::new(),
1✔
3544
            next_remote_idx: 0,
1✔
3545
        };
1✔
3546
        assert!(
1✔
3547
            !source2
1✔
3548
                .enforce_disk_cap_locked(&mut state2, &protected)
1✔
3549
                .unwrap()
1✔
3550
        );
3551
    }
1✔
3552

3553
    #[test]
3554
    fn enforce_disk_cap_evicts_manifest_shards_and_recomputes_offsets() {
1✔
3555
        let dir = tempdir().unwrap();
1✔
3556
        let mut config = test_config(dir.path().to_path_buf());
1✔
3557
        config.local_disk_cap_bytes = Some(20);
1✔
3558
        config.min_resident_shards = 0;
1✔
3559
        let source = test_source(config);
1✔
3560
        let manifest_root = source.manifest_cache_root();
1✔
3561
        fs::create_dir_all(&manifest_root).unwrap();
1✔
3562

3563
        let first = manifest_root.join("first.parquet");
1✔
3564
        let second = manifest_root.join("second.parquet");
1✔
3565
        fs::write(&first, vec![1u8; 16]).unwrap();
1✔
3566
        fs::write(&second, vec![2u8; 16]).unwrap();
1✔
3567

3568
        let mut state = SourceState {
1✔
3569
            materialized_rows: 2,
1✔
3570
            total_rows: None,
1✔
3571
            shards: vec![
1✔
3572
                ShardIndex {
1✔
3573
                    path: first.clone(),
1✔
3574
                    global_start: 0,
1✔
3575
                    row_count: 1,
1✔
3576
                    is_parquet: true,
1✔
3577
                    parquet_row_groups: vec![(0, 1)],
1✔
3578
                    checkpoints: Vec::new(),
1✔
3579
                },
1✔
3580
                ShardIndex {
1✔
3581
                    path: second.clone(),
1✔
3582
                    global_start: 1,
1✔
3583
                    row_count: 1,
1✔
3584
                    is_parquet: true,
1✔
3585
                    parquet_row_groups: vec![(0, 1)],
1✔
3586
                    checkpoints: Vec::new(),
1✔
3587
                },
1✔
3588
            ],
1✔
3589
            remote_candidates: None,
1✔
3590
            remote_candidate_sizes: HashMap::new(),
1✔
3591
            next_remote_idx: 0,
1✔
3592
        };
1✔
3593

3594
        let evicted = source.enforce_disk_cap_locked(&mut state, &second).unwrap();
1✔
3595
        assert!(evicted);
1✔
3596
        assert!(!first.exists());
1✔
3597
        assert!(second.exists());
1✔
3598
        assert_eq!(state.shards.len(), 1);
1✔
3599
        assert_eq!(state.shards[0].global_start, 0);
1✔
3600
        assert_eq!(state.materialized_rows, 1);
1✔
3601
    }
1✔
3602

3603
    #[test]
3604
    fn enforce_disk_cap_errors_when_usage_still_exceeds_cap() {
1✔
3605
        let dir = tempdir().unwrap();
1✔
3606
        let mut config = test_config(dir.path().to_path_buf());
1✔
3607
        config.local_disk_cap_bytes = Some(1);
1✔
3608
        config.min_resident_shards = 1;
1✔
3609
        let source = test_source(config);
1✔
3610
        let manifest_root = source.manifest_cache_root();
1✔
3611
        fs::create_dir_all(&manifest_root).unwrap();
1✔
3612

3613
        let protected = manifest_root.join("protected.parquet");
1✔
3614
        fs::write(&protected, vec![3u8; 16]).unwrap();
1✔
3615

3616
        let mut state = SourceState {
1✔
3617
            materialized_rows: 1,
1✔
3618
            total_rows: None,
1✔
3619
            shards: vec![ShardIndex {
1✔
3620
                path: protected.clone(),
1✔
3621
                global_start: 0,
1✔
3622
                row_count: 1,
1✔
3623
                is_parquet: true,
1✔
3624
                parquet_row_groups: vec![(0, 1)],
1✔
3625
                checkpoints: Vec::new(),
1✔
3626
            }],
1✔
3627
            remote_candidates: None,
1✔
3628
            remote_candidate_sizes: HashMap::new(),
1✔
3629
            next_remote_idx: 0,
1✔
3630
        };
1✔
3631

3632
        let err = source
1✔
3633
            .enforce_disk_cap_locked(&mut state, &protected)
1✔
3634
            .unwrap_err();
1✔
3635
        assert!(matches!(
1✔
3636
            err,
1✔
3637
            SamplerError::SourceUnavailable { ref reason, .. } if reason.contains("cannot evict further")
1✔
3638
        ));
3639
        assert!(!protected.exists());
1✔
3640
    }
1✔
3641

3642
    #[test]
3643
    fn configured_sampler_seed_and_paging_seed_require_sampler_config() {
1✔
3644
        let dir = tempdir().unwrap();
1✔
3645
        let config = test_config(dir.path().to_path_buf());
1✔
3646
        let source = HuggingFaceRowSource {
1✔
3647
            config,
1✔
3648
            sampler_config: Mutex::new(None),
1✔
3649
            state: Mutex::new(SourceState {
1✔
3650
                materialized_rows: 0,
1✔
3651
                total_rows: None,
1✔
3652
                shards: Vec::new(),
1✔
3653
                remote_candidates: None,
1✔
3654
                remote_candidate_sizes: HashMap::new(),
1✔
3655
                next_remote_idx: 0,
1✔
3656
            }),
1✔
3657
            cache: Mutex::new(RowCache::default()),
1✔
3658
            parquet_cache: Mutex::new(ParquetCache::default()),
1✔
3659
        };
1✔
3660

3661
        assert!(source.configured_sampler_seed().is_err());
1✔
3662
        assert!(source.paging_seed(5).is_err());
1✔
3663
    }
1✔
3664

3665
    #[test]
3666
    fn shard_candidate_seed_and_rotation_are_deterministic() {
1✔
3667
        let dir = tempdir().unwrap();
1✔
3668
        let mut config = test_config(dir.path().to_path_buf());
1✔
3669
        config.source_id = "hf_rotator".to_string();
1✔
3670

3671
        let seed_a = HuggingFaceRowSource::shard_candidate_seed(&config, 12, 1);
1✔
3672
        let seed_b = HuggingFaceRowSource::shard_candidate_seed(&config, 12, 2);
1✔
3673
        assert_ne!(seed_a, seed_b);
1✔
3674

3675
        let baseline = vec!["c".to_string(), "a".to_string(), "b".to_string()];
1✔
3676
        let mut left = baseline.clone();
1✔
3677
        let mut right = baseline;
1✔
3678
        HuggingFaceRowSource::rotate_candidates_deterministically(&config, &mut left);
1✔
3679
        HuggingFaceRowSource::rotate_candidates_deterministically(&config, &mut right);
1✔
3680
        assert_eq!(left, right);
1✔
3681

3682
        let mut sorted = left.clone();
1✔
3683
        sorted.sort();
1✔
3684
        assert_eq!(
1✔
3685
            sorted,
3686
            vec!["a".to_string(), "b".to_string(), "c".to_string()]
1✔
3687
        );
3688
    }
1✔
3689

3690
    #[test]
3691
    fn extract_split_row_count_handles_configs_and_dataset_fallbacks() {
1✔
3692
        let by_config_splits = json!({
1✔
3693
            "size": {
1✔
3694
                "configs": [
1✔
3695
                    {
3696
                        "config_name": "default",
1✔
3697
                        "splits": [
1✔
3698
                            {"name": "train", "num_rows": 21},
1✔
3699
                            {"name": "validation", "num_rows": 4}
1✔
3700
                        ]
3701
                    }
3702
                ]
3703
            }
3704
        });
3705
        let rows = HuggingFaceRowSource::extract_split_row_count_from_size_response(
1✔
3706
            &by_config_splits,
1✔
3707
            "default",
1✔
3708
            "train",
1✔
3709
        );
3710
        assert_eq!(rows, Some(21));
1✔
3711

3712
        let dataset_only = json!({
1✔
3713
            "size": {
1✔
3714
                "dataset": {"num_rows": 99}
1✔
3715
            }
3716
        });
3717
        let rows = HuggingFaceRowSource::extract_split_row_count_from_size_response(
1✔
3718
            &dataset_only,
1✔
3719
            "default",
1✔
3720
            "",
1✔
3721
        );
3722
        assert_eq!(rows, Some(99));
1✔
3723
    }
1✔
3724

3725
    #[test]
3726
    fn parse_global_row_count_response_uses_config_total_when_split_empty() {
1✔
3727
        let dir = tempdir().unwrap();
1✔
3728
        let config = test_config(dir.path().to_path_buf());
1✔
3729
        let body = serde_json::to_string(&json!({
1✔
3730
            "size": {
1✔
3731
                "configs": [
1✔
3732
                    {"config": "default", "num_rows": 17}
1✔
3733
                ]
1✔
3734
            }
1✔
3735
        }))
1✔
3736
        .unwrap();
1✔
3737

3738
        let parsed = HuggingFaceRowSource::parse_global_row_count_response(
1✔
3739
            &HuggingFaceRowsConfig {
1✔
3740
                split: "".to_string(),
1✔
3741
                ..config
1✔
3742
            },
1✔
3743
            &body,
1✔
3744
        )
3745
        .unwrap();
1✔
3746
        assert_eq!(parsed, Some(17));
1✔
3747
    }
1✔
3748

3749
    #[test]
3750
    fn ensure_row_available_returns_from_fast_paths() {
1✔
3751
        let dir = tempdir().unwrap();
1✔
3752
        let config = test_config(dir.path().to_path_buf());
1✔
3753
        let source = test_source(config);
1✔
3754

3755
        {
1✔
3756
            let mut state = source.state.lock().unwrap();
1✔
3757
            state.materialized_rows = 3;
1✔
3758
            state.remote_candidates = Some(vec!["x".to_string()]);
1✔
3759
            state.next_remote_idx = 0;
1✔
3760
        }
1✔
3761
        assert!(source.ensure_row_available(1).unwrap());
1✔
3762

3763
        let mut cfg_max = test_config(dir.path().to_path_buf());
1✔
3764
        cfg_max.max_rows = Some(2);
1✔
3765
        let source_max = test_source(cfg_max);
1✔
3766
        {
1✔
3767
            let mut state = source_max.state.lock().unwrap();
1✔
3768
            state.materialized_rows = 0;
1✔
3769
            state.remote_candidates = Some(vec!["x".to_string()]);
1✔
3770
            state.next_remote_idx = 0;
1✔
3771
        }
1✔
3772
        assert!(!source_max.ensure_row_available(2).unwrap());
1✔
3773

3774
        let source_done = test_source(test_config(dir.path().to_path_buf()));
1✔
3775
        {
1✔
3776
            let mut state = source_done.state.lock().unwrap();
1✔
3777
            state.materialized_rows = 0;
1✔
3778
            state.remote_candidates = Some(vec!["a".to_string()]);
1✔
3779
            state.next_remote_idx = 1;
1✔
3780
        }
1✔
3781
        assert!(!source_done.ensure_row_available(0).unwrap());
1✔
3782
    }
1✔
3783

3784
    #[test]
3785
    fn build_shard_index_errors_when_no_accepted_files_exist() {
1✔
3786
        let dir = tempdir().unwrap();
1✔
3787
        fs::write(dir.path().join("notes.txt"), b"plain").unwrap();
1✔
3788
        let config = test_config(dir.path().to_path_buf());
1✔
3789

3790
        let err = HuggingFaceRowSource::build_shard_index(&config).unwrap_err();
1✔
3791
        assert!(matches!(
1✔
3792
            err,
1✔
3793
            SamplerError::SourceUnavailable { ref reason, .. } if reason.contains("no shard files found")
1✔
3794
        ));
3795
    }
1✔
3796

3797
    #[test]
3798
    fn build_shard_index_applies_max_rows_to_parquet_shard() {
1✔
3799
        let dir = tempdir().unwrap();
1✔
3800
        let parquet_path = dir.path().join("rows.parquet");
1✔
3801
        write_parquet_fixture(
1✔
3802
            &parquet_path,
1✔
3803
            &[("id-1", "text-1"), ("id-2", "text-2"), ("id-3", "text-3")],
1✔
3804
        );
3805
        let mut config = test_config(dir.path().to_path_buf());
1✔
3806
        config.max_rows = Some(2);
1✔
3807

3808
        let (shards, discovered) = HuggingFaceRowSource::build_shard_index(&config).unwrap();
1✔
3809
        assert_eq!(discovered, 2);
1✔
3810
        assert_eq!(shards.len(), 1);
1✔
3811
        assert!(shards[0].is_parquet);
1✔
3812
        assert_eq!(shards[0].row_count, 2);
1✔
3813
    }
1✔
3814

3815
    #[test]
3816
    fn materialize_local_file_errors_for_missing_source() {
1✔
3817
        let dir = tempdir().unwrap();
1✔
3818
        let config = test_config(dir.path().to_path_buf());
1✔
3819
        let missing = dir.path().join("missing.ndjson");
1✔
3820
        let target = dir.path().join("target.ndjson");
1✔
3821

3822
        let err =
1✔
3823
            HuggingFaceRowSource::materialize_local_file(&config, &missing, &target).unwrap_err();
1✔
3824
        assert!(matches!(
1✔
3825
            err,
1✔
3826
            SamplerError::SourceUnavailable { ref reason, .. } if reason.contains("failed copying synced file")
1✔
3827
        ));
3828
    }
1✔
3829

3830
    #[test]
3831
    fn download_and_materialize_shard_hf_hub_branch_returns_error_for_invalid_repo() {
1✔
3832
        let dir = tempdir().unwrap();
1✔
3833
        let mut config = test_config(dir.path().to_path_buf());
1✔
3834
        config.dataset = "invalid///dataset".to_string();
1✔
3835

3836
        let err = HuggingFaceRowSource::download_and_materialize_shard(
1✔
3837
            &config,
1✔
3838
            "train/part-000.parquet",
1✔
3839
            None,
1✔
3840
        )
3841
        .unwrap_err();
1✔
3842
        assert!(matches!(err, SamplerError::SourceUnavailable { .. }));
1✔
3843
    }
1✔
3844

3845
    #[test]
3846
    fn index_single_shard_errors_for_missing_file() {
1✔
3847
        let dir = tempdir().unwrap();
1✔
3848
        let config = test_config(dir.path().to_path_buf());
1✔
3849
        let missing = dir.path().join("missing.ndjson");
1✔
3850

3851
        let err = HuggingFaceRowSource::index_single_shard(&config, &missing, 0).unwrap_err();
1✔
3852
        assert!(matches!(err, SamplerError::SourceUnavailable { .. }));
1✔
3853
    }
1✔
3854

3855
    #[test]
3856
    fn index_single_shard_jsonl_records_checkpoints_by_stride() {
1✔
3857
        let dir = tempdir().unwrap();
1✔
3858
        let path = dir.path().join("rows.ndjson");
1✔
3859
        fs::write(
1✔
3860
            &path,
1✔
3861
            b"{\"text\":\"a\"}\n{\"text\":\"b\"}\n{\"text\":\"c\"}\n",
3862
        )
3863
        .unwrap();
1✔
3864
        let mut config = test_config(dir.path().to_path_buf());
1✔
3865
        config.checkpoint_stride = 2;
1✔
3866

3867
        let shard = HuggingFaceRowSource::index_single_shard(&config, &path, 5)
1✔
3868
            .unwrap()
1✔
3869
            .unwrap();
1✔
3870
        assert_eq!(shard.global_start, 5);
1✔
3871
        assert_eq!(shard.row_count, 3);
1✔
3872
        assert!(!shard.is_parquet);
1✔
3873
        assert!(shard.checkpoints.len() >= 2);
1✔
3874
        assert_eq!(shard.checkpoints[0], 0);
1✔
3875
    }
1✔
3876

3877
    #[test]
3878
    fn parquet_row_group_map_handles_empty_parquet_file() {
1✔
3879
        let dir = tempdir().unwrap();
1✔
3880
        let path = dir.path().join("empty.parquet");
1✔
3881
        write_parquet_fixture(&path, &[]);
1✔
3882
        let config = test_config(dir.path().to_path_buf());
1✔
3883

3884
        let (rows, groups) = HuggingFaceRowSource::parquet_row_group_map(&config, &path).unwrap();
1✔
3885
        assert_eq!(rows, 0);
1✔
3886
        assert!(groups.is_empty());
1✔
3887
    }
1✔
3888

3889
    #[test]
3890
    fn download_next_remote_shard_clears_row_cache_when_eviction_occurs() {
1✔
3891
        let dir = tempdir().unwrap();
1✔
3892
        let mut config = test_config(dir.path().to_path_buf());
1✔
3893
        config.local_disk_cap_bytes = Some(20);
1✔
3894
        config.min_resident_shards = 0;
1✔
3895
        let source = test_source(config.clone());
1✔
3896

3897
        let manifest_root = source.manifest_cache_root();
1✔
3898
        fs::create_dir_all(&manifest_root).unwrap();
1✔
3899
        let old_path = manifest_root.join("old.parquet");
1✔
3900
        fs::write(&old_path, vec![1u8; 20]).unwrap();
1✔
3901

3902
        let payload = b"{\"text\":\"new\"}\n".to_vec();
1✔
3903
        let (base_url, server) = spawn_one_shot_http(payload);
1✔
3904
        let candidate =
1✔
3905
            format!("url::{base_url}/datasets/org/ds/resolve/main/train/new-shard.ndjson");
1✔
3906

3907
        {
1✔
3908
            let mut state = source.state.lock().unwrap();
1✔
3909
            state.materialized_rows = 1;
1✔
3910
            state.shards = vec![ShardIndex {
1✔
3911
                path: old_path.clone(),
1✔
3912
                global_start: 0,
1✔
3913
                row_count: 1,
1✔
3914
                is_parquet: true,
1✔
3915
                parquet_row_groups: vec![(0, 1)],
1✔
3916
                checkpoints: Vec::new(),
1✔
3917
            }];
1✔
3918
            state.remote_candidates = Some(vec![candidate]);
1✔
3919
            state.next_remote_idx = 0;
1✔
3920
        }
1✔
3921
        {
1✔
3922
            let mut cache = source.cache.lock().unwrap();
1✔
3923
            cache.insert(
1✔
3924
                0,
1✔
3925
                RowView {
1✔
3926
                    row_id: Some("cached".to_string()),
1✔
3927
                    timestamp: None,
1✔
3928
                    text_fields: vec![RowTextField {
1✔
3929
                        name: "text".to_string(),
1✔
3930
                        text: "cached".to_string(),
1✔
3931
                    }],
1✔
3932
                },
1✔
3933
                8,
1✔
3934
            );
1✔
3935
        }
1✔
3936

3937
        assert!(source.download_next_remote_shard().unwrap());
1✔
3938
        server.join().unwrap();
1✔
3939

3940
        assert!(!old_path.exists());
1✔
3941
        let cache = source.cache.lock().unwrap();
1✔
3942
        assert!(cache.rows.is_empty());
1✔
3943
        assert!(cache.order.is_empty());
1✔
3944
    }
1✔
3945

3946
    #[test]
3947
    fn load_persisted_shard_sequence_clamps_next_remote_index() {
1✔
3948
        let dir = tempdir().unwrap();
1✔
3949
        let config = test_config(dir.path().to_path_buf());
1✔
3950
        let state_path = HuggingFaceRowSource::shard_sequence_state_path(&config);
1✔
3951
        fs::create_dir_all(state_path.parent().unwrap()).unwrap();
1✔
3952
        fs::write(
1✔
3953
            &state_path,
1✔
3954
            serde_json::to_vec_pretty(&json!({
1✔
3955
                "version": SHARD_SEQUENCE_STATE_VERSION,
1✔
3956
                "source_id": config.source_id,
1✔
3957
                "dataset": config.dataset,
1✔
3958
                "config": config.config,
1✔
3959
                "split": config.split,
1✔
3960
                "sampler_seed": 7,
1✔
3961
                "candidates": ["train/0.ndjson", "train/1.ndjson"],
1✔
3962
                "candidate_sizes": {},
1✔
3963
                "next_remote_idx": 99
1✔
3964
            }))
1✔
3965
            .unwrap(),
1✔
3966
        )
3967
        .unwrap();
1✔
3968

3969
        let loaded = HuggingFaceRowSource::load_persisted_shard_sequence(&config, 7)
1✔
3970
            .unwrap()
1✔
3971
            .unwrap();
1✔
3972
        assert_eq!(loaded.candidates.len(), 2);
1✔
3973
        assert_eq!(loaded.next_remote_idx, 2);
1✔
3974
    }
1✔
3975

3976
    #[test]
3977
    fn default_triplet_recipes_returns_expected_shape() {
1✔
3978
        let dir = tempdir().unwrap();
1✔
3979
        let config = test_config(dir.path().to_path_buf());
1✔
3980
        let source = test_source(config);
1✔
3981
        let recipes = source.default_triplet_recipes();
1✔
3982
        assert_eq!(recipes.len(), 1);
1✔
3983
        assert_eq!(recipes[0].name, "huggingface_anchor_context");
1✔
3984
    }
1✔
3985

3986
    #[test]
3987
    fn download_and_materialize_shard_url_short_circuits_when_cached_complete() {
1✔
3988
        let dir = tempdir().unwrap();
1✔
3989
        let config = test_config(dir.path().to_path_buf());
1✔
3990
        let candidate = "url::https://host/datasets/org/ds/resolve/main/train/ok.ndjson";
1✔
3991
        let target = HuggingFaceRowSource::candidate_target_path(&config, candidate);
1✔
3992
        fs::create_dir_all(target.parent().unwrap()).unwrap();
1✔
3993
        fs::write(&target, b"ok").unwrap();
1✔
3994

3995
        let resolved =
1✔
3996
            HuggingFaceRowSource::download_and_materialize_shard(&config, candidate, Some(2))
1✔
3997
                .unwrap();
1✔
3998
        assert_eq!(resolved, target);
1✔
3999
    }
1✔
4000

4001
    #[test]
4002
    fn download_and_materialize_shard_url_replaces_stale_part_file() {
1✔
4003
        let dir = tempdir().unwrap();
1✔
4004
        let config = test_config(dir.path().to_path_buf());
1✔
4005
        let payload = b"{\"text\":\"a\"}\n".to_vec();
1✔
4006
        let (base_url, server) = spawn_one_shot_http(payload.clone());
1✔
4007
        let candidate = format!("url::{base_url}/datasets/org/ds/resolve/main/train/part-x.ndjson");
1✔
4008
        let target = HuggingFaceRowSource::candidate_target_path(&config, &candidate);
1✔
4009
        let temp_target = target.with_extension("part");
1✔
4010
        fs::create_dir_all(temp_target.parent().unwrap()).unwrap();
1✔
4011
        fs::write(&temp_target, b"stale").unwrap();
1✔
4012

4013
        let out = HuggingFaceRowSource::download_and_materialize_shard(&config, &candidate, None)
1✔
4014
            .unwrap();
1✔
4015
        server.join().unwrap();
1✔
4016

4017
        assert_eq!(out, target);
1✔
4018
        assert_eq!(fs::read(&target).unwrap(), payload);
1✔
4019
    }
1✔
4020

4021
    #[test]
4022
    fn download_next_remote_shard_skips_when_max_rows_already_reached() {
1✔
4023
        let dir = tempdir().unwrap();
1✔
4024
        let mut config = test_config(dir.path().to_path_buf());
1✔
4025
        config.max_rows = Some(0);
1✔
4026
        let source = test_source(config);
1✔
4027
        let payload = b"{\"text\":\"x\"}\n".to_vec();
1✔
4028
        let (base_url, server) = spawn_one_shot_http(payload);
1✔
4029
        let candidate =
1✔
4030
            format!("url::{base_url}/datasets/org/ds/resolve/main/train/part-200.ndjson");
1✔
4031

4032
        {
1✔
4033
            let mut state = source.state.lock().unwrap();
1✔
4034
            state.remote_candidates = Some(vec![candidate]);
1✔
4035
            state.next_remote_idx = 0;
1✔
4036
            state.materialized_rows = 0;
1✔
4037
        }
1✔
4038

4039
        assert!(source.download_next_remote_shard().unwrap());
1✔
4040
        server.join().unwrap();
1✔
4041
        let state = source.state.lock().unwrap();
1✔
4042
        assert_eq!(state.materialized_rows, 0);
1✔
4043
        assert!(state.shards.is_empty());
1✔
4044
    }
1✔
4045

4046
    #[test]
4047
    fn download_next_remote_shard_skips_zero_row_download() {
1✔
4048
        let dir = tempdir().unwrap();
1✔
4049
        let config = test_config(dir.path().to_path_buf());
1✔
4050
        let source = test_source(config);
1✔
4051
        let payload = Vec::<u8>::new();
1✔
4052
        let (base_url, server) = spawn_one_shot_http(payload);
1✔
4053
        let candidate =
1✔
4054
            format!("url::{base_url}/datasets/org/ds/resolve/main/train/part-empty.ndjson");
1✔
4055

4056
        {
1✔
4057
            let mut state = source.state.lock().unwrap();
1✔
4058
            state.remote_candidates = Some(vec![candidate]);
1✔
4059
            state.next_remote_idx = 0;
1✔
4060
        }
1✔
4061

4062
        assert!(source.download_next_remote_shard().unwrap());
1✔
4063
        server.join().unwrap();
1✔
4064
        let state = source.state.lock().unwrap();
1✔
4065
        assert_eq!(state.materialized_rows, 0);
1✔
4066
        assert!(state.shards.is_empty());
1✔
4067
    }
1✔
4068

4069
    #[test]
4070
    fn read_row_batch_errors_when_parquet_reader_cannot_open_file() {
1✔
4071
        let dir = tempdir().unwrap();
1✔
4072
        let config = test_config(dir.path().to_path_buf());
1✔
4073
        let source = test_source(config);
1✔
4074
        {
1✔
4075
            let mut state = source.state.lock().unwrap();
1✔
4076
            state.materialized_rows = 1;
1✔
4077
            state.total_rows = Some(1);
1✔
4078
            state.shards = vec![ShardIndex {
1✔
4079
                path: dir.path().join("missing.parquet"),
1✔
4080
                global_start: 0,
1✔
4081
                row_count: 1,
1✔
4082
                is_parquet: true,
1✔
4083
                parquet_row_groups: vec![(0, 1)],
1✔
4084
                checkpoints: Vec::new(),
1✔
4085
            }];
1✔
4086
        }
1✔
4087

4088
        let mut out = Vec::new();
1✔
4089
        let err = source.read_row_batch(&[0], &mut out, Some(1));
1✔
4090
        assert!(err.is_err());
1✔
4091
    }
1✔
4092

4093
    #[test]
4094
    fn refresh_exercises_large_total_progress_branch() {
1✔
4095
        let dir = tempdir().unwrap();
1✔
4096
        let path = dir.path().join("rows.jsonl");
1✔
4097
        let line = b"{\"id\":\"r\",\"text\":\"v\"}\n";
1✔
4098
        let mut bytes = Vec::with_capacity(line.len() * 10_000);
1✔
4099
        for _ in 0..10_000 {
10,000✔
4100
            bytes.extend_from_slice(line);
10,000✔
4101
        }
10,000✔
4102
        fs::write(&path, bytes).unwrap();
1✔
4103

4104
        let mut config = test_config(dir.path().to_path_buf());
1✔
4105
        config.checkpoint_stride = 256;
1✔
4106
        config.refresh_batch_multiplier = 1;
1✔
4107
        let source = test_source(config.clone());
1✔
4108
        let shard = HuggingFaceRowSource::index_single_shard(&config, &path, 0)
1✔
4109
            .unwrap()
1✔
4110
            .unwrap();
1✔
4111

4112
        {
1✔
4113
            let mut state = source.state.lock().unwrap();
1✔
4114
            state.materialized_rows = 10_000;
1✔
4115
            state.total_rows = Some(10_000);
1✔
4116
            state.shards = vec![shard];
1✔
4117
        }
1✔
4118

4119
        let snapshot = source.refresh(None, Some(1)).unwrap();
1✔
4120
        assert_eq!(snapshot.records.len(), 1);
1✔
4121
    }
1✔
4122

4123
    #[test]
4124
    fn shard_size_bytes_returns_zero_for_missing_path() {
1✔
4125
        let dir = tempdir().unwrap();
1✔
4126
        let missing = dir.path().join("missing.file");
1✔
4127
        assert_eq!(HuggingFaceRowSource::shard_size_bytes(&missing), 0);
1✔
4128
    }
1✔
4129

4130
    #[test]
4131
    fn load_persisted_shard_sequence_errors_on_invalid_json() {
1✔
4132
        let dir = tempdir().unwrap();
1✔
4133
        let config = test_config(dir.path().to_path_buf());
1✔
4134
        let path = HuggingFaceRowSource::shard_sequence_state_path(&config);
1✔
4135
        fs::create_dir_all(path.parent().unwrap()).unwrap();
1✔
4136
        fs::write(&path, b"{not-valid-json").unwrap();
1✔
4137

4138
        let loaded = HuggingFaceRowSource::load_persisted_shard_sequence(&config, 1);
1✔
4139
        assert!(loaded.is_err());
1✔
4140
    }
1✔
4141

4142
    #[test]
4143
    fn rotate_candidates_deterministically_is_noop_for_singleton() {
1✔
4144
        let dir = tempdir().unwrap();
1✔
4145
        let config = test_config(dir.path().to_path_buf());
1✔
4146
        let mut candidates = vec!["one".to_string()];
1✔
4147
        HuggingFaceRowSource::rotate_candidates_deterministically(&config, &mut candidates);
1✔
4148
        assert_eq!(candidates, vec!["one".to_string()]);
1✔
4149
    }
1✔
4150

4151
    #[test]
4152
    fn extract_split_row_count_returns_none_when_missing_entries() {
1✔
4153
        let payload = json!({"size": {"configs": [{"config": "other", "splits": []}]}});
1✔
4154
        let rows = HuggingFaceRowSource::extract_split_row_count_from_size_response(
1✔
4155
            &payload, "default", "train",
1✔
4156
        );
4157
        assert!(rows.is_none());
1✔
4158
    }
1✔
4159

4160
    #[test]
4161
    fn candidates_from_parquet_manifest_json_returns_empty_without_entries() {
1✔
4162
        let dir = tempdir().unwrap();
1✔
4163
        let config = test_config(dir.path().to_path_buf());
1✔
4164
        let payload = json!({"other": []});
1✔
4165
        let (candidates, sizes) =
1✔
4166
            HuggingFaceRowSource::candidates_from_parquet_manifest_json(&config, &payload).unwrap();
1✔
4167
        assert!(candidates.is_empty());
1✔
4168
        assert!(sizes.is_empty());
1✔
4169
    }
1✔
4170

4171
    #[test]
4172
    fn read_line_at_errors_on_unexpected_eof_while_reading_target_row() {
1✔
4173
        let dir = tempdir().unwrap();
1✔
4174
        let path = dir.path().join("rows.jsonl");
1✔
4175
        fs::write(&path, b"{\"text\":\"a\"}\n").unwrap();
1✔
4176
        let mut config = test_config(dir.path().to_path_buf());
1✔
4177
        config.checkpoint_stride = 1;
1✔
4178
        let source = test_source(config.clone());
1✔
4179
        let mut shard = HuggingFaceRowSource::index_single_shard(&config, &path, 0)
1✔
4180
            .unwrap()
1✔
4181
            .unwrap();
1✔
4182
        let end = fs::metadata(&path).unwrap().len();
1✔
4183
        shard.checkpoints = vec![0, end];
1✔
4184

4185
        let err = source.read_line_at(&shard, 1);
1✔
4186
        assert!(err.is_err());
1✔
4187
    }
1✔
4188

4189
    #[test]
4190
    fn load_persisted_shard_sequence_returns_none_when_state_missing() {
1✔
4191
        let dir = tempdir().unwrap();
1✔
4192
        let config = test_config(dir.path().to_path_buf());
1✔
4193
        let loaded = HuggingFaceRowSource::load_persisted_shard_sequence(&config, 1).unwrap();
1✔
4194
        assert!(loaded.is_none());
1✔
4195
    }
1✔
4196

4197
    #[test]
4198
    fn persist_shard_sequence_clamps_next_index_on_write() {
1✔
4199
        let dir = tempdir().unwrap();
1✔
4200
        let config = test_config(dir.path().to_path_buf());
1✔
4201
        let source = test_source(config.clone());
1✔
4202
        let state = SourceState {
1✔
4203
            materialized_rows: 0,
1✔
4204
            total_rows: None,
1✔
4205
            shards: Vec::new(),
1✔
4206
            remote_candidates: Some(vec!["a".into(), "b".into()]),
1✔
4207
            remote_candidate_sizes: HashMap::new(),
1✔
4208
            next_remote_idx: 99,
1✔
4209
        };
1✔
4210

4211
        source.persist_shard_sequence_locked(&state).unwrap();
1✔
4212
        let raw =
1✔
4213
            fs::read_to_string(HuggingFaceRowSource::shard_sequence_state_path(&config)).unwrap();
1✔
4214
        let parsed: Value = serde_json::from_str(&raw).unwrap();
1✔
4215
        assert_eq!(
1✔
4216
            parsed.get("next_remote_idx").and_then(Value::as_u64),
1✔
4217
            Some(2)
4218
        );
4219
    }
1✔
4220

4221
    #[test]
4222
    fn materialize_local_file_replaces_target_when_size_differs() {
1✔
4223
        let dir = tempdir().unwrap();
1✔
4224
        let config = test_config(dir.path().to_path_buf());
1✔
4225
        let src = dir.path().join("src.ndjson");
1✔
4226
        let dst = dir.path().join("dst.ndjson");
1✔
4227
        fs::write(&src, b"newer\n").unwrap();
1✔
4228
        fs::write(&dst, b"old\n").unwrap();
1✔
4229

4230
        HuggingFaceRowSource::materialize_local_file(&config, &src, &dst).unwrap();
1✔
4231
        assert_eq!(fs::read(&dst).unwrap(), b"newer\n");
1✔
4232
    }
1✔
4233

4234
    #[test]
4235
    fn row_to_record_preserves_explicit_timestamp() {
1✔
4236
        let dir = tempdir().unwrap();
1✔
4237
        let config = test_config(dir.path().to_path_buf());
1✔
4238
        let source = test_source(config);
1✔
4239
        let ts = Utc::now();
1✔
4240
        let row = RowView {
1✔
4241
            row_id: Some("r1".into()),
1✔
4242
            timestamp: Some(ts),
1✔
4243
            text_fields: vec![RowTextField {
1✔
4244
                name: "text".into(),
1✔
4245
                text: "alpha".into(),
1✔
4246
            }],
1✔
4247
        };
1✔
4248

4249
        let record = source.row_to_record(&row, 0).unwrap().unwrap();
1✔
4250
        assert_eq!(record.created_at, ts);
1✔
4251
        assert_eq!(record.updated_at, ts);
1✔
4252
    }
1✔
4253

4254
    #[test]
4255
    fn parse_row_text_columns_accept_numeric_values() {
1✔
4256
        let dir = tempdir().unwrap();
1✔
4257
        let mut config = test_config(dir.path().to_path_buf());
1✔
4258
        config.text_columns = vec!["score".into()];
1✔
4259
        let source = test_source(config);
1✔
4260

4261
        let row = source
1✔
4262
            .parse_row(0, &json!({"score": 123}))
1✔
4263
            .unwrap()
1✔
4264
            .unwrap();
1✔
4265
        assert_eq!(row.text_fields.len(), 1);
1✔
4266
        assert_eq!(row.text_fields[0].text, "123");
1✔
4267
    }
1✔
4268

4269
    #[test]
4270
    fn len_hint_returns_zero_when_max_rows_is_zero() {
1✔
4271
        let dir = tempdir().unwrap();
1✔
4272
        let mut config = test_config(dir.path().to_path_buf());
1✔
4273
        config.max_rows = Some(0);
1✔
4274
        let source = test_source(config);
1✔
4275
        assert_eq!(source.len_hint(), Some(0));
1✔
4276
    }
1✔
4277

4278
    #[test]
4279
    fn refresh_limit_none_reads_up_to_total() {
1✔
4280
        let dir = tempdir().unwrap();
1✔
4281
        let path = dir.path().join("rows.jsonl");
1✔
4282
        fs::write(
1✔
4283
            &path,
1✔
4284
            b"{\"id\":\"r1\",\"text\":\"a\"}\n{\"id\":\"r2\",\"text\":\"b\"}\n",
4285
        )
4286
        .unwrap();
1✔
4287
        let mut config = test_config(dir.path().to_path_buf());
1✔
4288
        config.checkpoint_stride = 1;
1✔
4289
        config.refresh_batch_multiplier = 1;
1✔
4290
        let source = test_source(config.clone());
1✔
4291
        let shard = HuggingFaceRowSource::index_single_shard(&config, &path, 0)
1✔
4292
            .unwrap()
1✔
4293
            .unwrap();
1✔
4294
        {
1✔
4295
            let mut state = source.state.lock().unwrap();
1✔
4296
            state.materialized_rows = 2;
1✔
4297
            state.total_rows = Some(2);
1✔
4298
            state.shards = vec![shard];
1✔
4299
        }
1✔
4300

4301
        let snapshot = source.refresh(None, None).unwrap();
1✔
4302
        assert_eq!(snapshot.records.len(), 2);
1✔
4303
    }
1✔
4304

4305
    #[test]
4306
    fn read_row_batch_skips_unavailable_indices_without_error() {
1✔
4307
        let dir = tempdir().unwrap();
1✔
4308
        let config = test_config(dir.path().to_path_buf());
1✔
4309
        let source = test_source(config);
1✔
4310
        {
1✔
4311
            let mut state = source.state.lock().unwrap();
1✔
4312
            state.materialized_rows = 0;
1✔
4313
            state.total_rows = Some(0);
1✔
4314
            state.remote_candidates = Some(Vec::new());
1✔
4315
        }
1✔
4316

4317
        let mut out = Vec::new();
1✔
4318
        source.read_row_batch(&[0, 1], &mut out, Some(2)).unwrap();
1✔
4319
        assert!(out.is_empty());
1✔
4320
    }
1✔
4321

4322
    #[test]
4323
    fn candidate_target_path_maps_remote_urls_under_manifest_root() {
1✔
4324
        let dir = tempdir().unwrap();
1✔
4325
        let config = test_config(dir.path().to_path_buf());
1✔
4326
        let candidate =
1✔
4327
            "url::https://huggingface.co/datasets/org/ds/resolve/main/train/part-000.parquet";
1✔
4328
        let target = HuggingFaceRowSource::candidate_target_path(&config, candidate);
1✔
4329
        assert!(target.ends_with("_parquet_manifest/main/train/part-000.parquet"));
1✔
4330
    }
1✔
4331

4332
    #[test]
4333
    fn candidate_target_path_keeps_local_candidates_relative() {
1✔
4334
        let dir = tempdir().unwrap();
1✔
4335
        let config = test_config(dir.path().to_path_buf());
1✔
4336
        let candidate = "train/part-001.ndjson";
1✔
4337
        let target = HuggingFaceRowSource::candidate_target_path(&config, candidate);
1✔
4338
        assert_eq!(target, config.snapshot_dir.join(candidate));
1✔
4339
    }
1✔
4340

4341
    #[test]
4342
    fn target_matches_expected_size_validates_when_expected_is_provided() {
1✔
4343
        let dir = tempdir().unwrap();
1✔
4344
        let path = dir.path().join("payload.bin");
1✔
4345
        fs::write(&path, vec![0u8; 5]).unwrap();
1✔
4346

4347
        assert!(HuggingFaceRowSource::target_matches_expected_size(
1✔
4348
            &path,
1✔
4349
            Some(5)
1✔
4350
        ));
4351
        assert!(!HuggingFaceRowSource::target_matches_expected_size(
1✔
4352
            &path,
1✔
4353
            Some(4)
1✔
4354
        ));
1✔
4355
        assert!(HuggingFaceRowSource::target_matches_expected_size(
1✔
4356
            &path, None
1✔
4357
        ));
4358
    }
1✔
4359

4360
    #[test]
4361
    fn parquet_row_group_map_and_index_single_shard_cover_success_path() {
1✔
4362
        let dir = tempdir().unwrap();
1✔
4363
        let path = dir.path().join("rows.parquet");
1✔
4364
        write_parquet_fixture(&path, &[("r1", "alpha"), ("r2", "beta"), ("r3", "gamma")]);
1✔
4365
        let config = test_config(dir.path().to_path_buf());
1✔
4366

4367
        let (total_rows, groups) =
1✔
4368
            HuggingFaceRowSource::parquet_row_group_map(&config, &path).unwrap();
1✔
4369
        assert_eq!(total_rows, 3);
1✔
4370
        assert!(!groups.is_empty());
1✔
4371

4372
        let shard = HuggingFaceRowSource::index_single_shard(&config, &path, 0)
1✔
4373
            .unwrap()
1✔
4374
            .unwrap();
1✔
4375
        assert!(shard.is_parquet);
1✔
4376
        assert_eq!(shard.row_count, 3);
1✔
4377
        assert!(shard.checkpoints.is_empty());
1✔
4378
    }
1✔
4379

4380
    #[test]
4381
    fn read_row_batch_reads_parquet_rows_and_uses_cache_on_repeat() {
1✔
4382
        let dir = tempdir().unwrap();
1✔
4383
        let path = dir.path().join("rows.parquet");
1✔
4384
        write_parquet_fixture(&path, &[("r10", "ten"), ("r11", "eleven")]);
1✔
4385

4386
        let config = test_config(dir.path().to_path_buf());
1✔
4387
        let source = test_source(config.clone());
1✔
4388
        let shard = HuggingFaceRowSource::index_single_shard(&config, &path, 0)
1✔
4389
            .unwrap()
1✔
4390
            .unwrap();
1✔
4391
        {
1✔
4392
            let mut state = source.state.lock().unwrap();
1✔
4393
            state.materialized_rows = 2;
1✔
4394
            state.total_rows = Some(2);
1✔
4395
            state.shards = vec![shard];
1✔
4396
        }
1✔
4397

4398
        let mut first = Vec::new();
1✔
4399
        source.read_row_batch(&[0, 1], &mut first, None).unwrap();
1✔
4400
        assert_eq!(first.len(), 2);
1✔
4401
        assert!(first.iter().any(|record| record.id.ends_with("::r10")));
1✔
4402

4403
        let mut second = Vec::new();
1✔
4404
        source.read_row_batch(&[0, 1], &mut second, None).unwrap();
1✔
4405
        assert_eq!(second.len(), 2);
1✔
4406
    }
1✔
4407

4408
    #[test]
4409
    fn ensure_row_available_bootstraps_from_in_memory_candidates() {
1✔
4410
        let dir = tempdir().unwrap();
1✔
4411
        let config = test_config(dir.path().to_path_buf());
1✔
4412
        let source = test_source(config.clone());
1✔
4413

4414
        let payload =
1✔
4415
            b"{\"id\":\"r1\",\"text\":\"alpha\"}\n{\"id\":\"r2\",\"text\":\"beta\"}\n".to_vec();
1✔
4416
        let (base_url, server) = spawn_one_shot_http(payload);
1✔
4417
        let candidate =
1✔
4418
            format!("url::{base_url}/datasets/org/ds/resolve/main/train/persisted.ndjson");
1✔
4419

4420
        {
1✔
4421
            let mut state = source.state.lock().unwrap();
1✔
4422
            state.remote_candidates = Some(vec![candidate]);
1✔
4423
            state.next_remote_idx = 0;
1✔
4424
        }
1✔
4425

4426
        assert!(source.ensure_row_available(0).unwrap());
1✔
4427
        server.join().unwrap();
1✔
4428

4429
        let state = source.state.lock().unwrap();
1✔
4430
        assert_eq!(state.materialized_rows, 2);
1✔
4431
        assert_eq!(state.next_remote_idx, 1);
1✔
4432
        assert_eq!(state.shards.len(), 1);
1✔
4433
    }
1✔
4434

4435
    #[test]
4436
    fn configure_sampler_updates_len_hint_headroom_via_trait_methods() {
1✔
4437
        let dir = tempdir().unwrap();
1✔
4438
        let mut config = test_config(dir.path().to_path_buf());
1✔
4439
        config.cache_capacity = 10;
1✔
4440
        config.remote_expansion_headroom_multiplier = 3;
1✔
4441
        let source = test_source(config);
1✔
4442
        {
1✔
4443
            let mut state = source.state.lock().unwrap();
1✔
4444
            state.materialized_rows = 5;
1✔
4445
            state.total_rows = Some(100);
1✔
4446
        }
1✔
4447

4448
        assert_eq!(source.reported_record_count().unwrap(), 35);
1✔
4449

4450
        let sampler = SamplerConfig {
1✔
4451
            ingestion_max_records: 2,
1✔
4452
            ..SamplerConfig::default()
1✔
4453
        };
1✔
4454
        source.configure_sampler(&sampler);
1✔
4455

4456
        assert_eq!(source.reported_record_count().unwrap(), 11);
1✔
4457
    }
1✔
4458

4459
    #[test]
4460
    fn refresh_ignores_persisted_remote_sequence_state() {
1✔
4461
        let dir = tempdir().unwrap();
1✔
4462
        let config = test_config(dir.path().to_path_buf());
1✔
4463
        let source = test_source(config.clone());
1✔
4464

4465
        let payload = b"{\"id\":\"rr\",\"text\":\"hello\"}\n".to_vec();
1✔
4466
        let (base_url, server) = spawn_one_shot_http(payload);
1✔
4467
        let candidate =
1✔
4468
            format!("url::{base_url}/datasets/org/ds/resolve/main/train/refresh.ndjson");
1✔
4469

4470
        let state_path = HuggingFaceRowSource::shard_sequence_state_path(&config);
1✔
4471
        fs::create_dir_all(state_path.parent().unwrap()).unwrap();
1✔
4472
        fs::write(
1✔
4473
            &state_path,
1✔
4474
            serde_json::to_vec_pretty(&json!({
1✔
4475
                "version": 1,
1✔
4476
                "source_id": config.source_id,
1✔
4477
                "dataset": config.dataset,
1✔
4478
                "config": config.config,
1✔
4479
                "split": config.split,
1✔
4480
                "sampler_seed": 1,
1✔
4481
                "candidates": [candidate],
1✔
4482
                "candidate_sizes": {},
1✔
4483
                "next_remote_idx": 1
1✔
4484
            }))
1✔
4485
            .unwrap(),
1✔
4486
        )
4487
        .unwrap();
1✔
4488

4489
        {
1✔
4490
            let mut state = source.state.lock().unwrap();
1✔
4491
            state.remote_candidates = Some(vec![format!(
1✔
4492
                "url::{base_url}/datasets/org/ds/resolve/main/train/refresh.ndjson"
1✔
4493
            )]);
1✔
4494
            state.next_remote_idx = 0;
1✔
4495
        }
1✔
4496

4497
        let snapshot = source.refresh(None, Some(1)).unwrap();
1✔
4498
        server.join().unwrap();
1✔
4499

4500
        assert_eq!(snapshot.records.len(), 1);
1✔
4501
        assert!(snapshot.records[0].id.contains("hf_test::rr"));
1✔
4502
    }
1✔
4503

4504
    #[test]
4505
    fn download_next_remote_shard_trims_rows_to_max_rows_limit() {
1✔
4506
        let dir = tempdir().unwrap();
1✔
4507
        let mut config = test_config(dir.path().to_path_buf());
1✔
4508
        config.max_rows = Some(1);
1✔
4509
        let source = test_source(config);
1✔
4510
        let payload = b"{\"text\":\"a\"}\n{\"text\":\"b\"}\n".to_vec();
1✔
4511
        let (base_url, server) = spawn_one_shot_http(payload);
1✔
4512
        let candidate = format!("url::{base_url}/datasets/org/ds/resolve/main/train/trim.ndjson");
1✔
4513

4514
        {
1✔
4515
            let mut state = source.state.lock().unwrap();
1✔
4516
            state.remote_candidates = Some(vec![candidate]);
1✔
4517
            state.next_remote_idx = 0;
1✔
4518
            state.materialized_rows = 0;
1✔
4519
        }
1✔
4520

4521
        assert!(source.download_next_remote_shard().unwrap());
1✔
4522
        server.join().unwrap();
1✔
4523

4524
        let state = source.state.lock().unwrap();
1✔
4525
        assert_eq!(state.materialized_rows, 1);
1✔
4526
        assert_eq!(state.shards.len(), 1);
1✔
4527
        assert_eq!(state.shards[0].row_count, 1);
1✔
4528
    }
1✔
4529

4530
    #[test]
4531
    fn build_shard_index_skips_empty_files_and_keeps_non_empty() {
1✔
4532
        let dir = tempdir().unwrap();
1✔
4533
        fs::write(dir.path().join("a.ndjson"), b"").unwrap();
1✔
4534
        fs::write(dir.path().join("b.ndjson"), b"{\"text\":\"x\"}\n").unwrap();
1✔
4535
        let config = test_config(dir.path().to_path_buf());
1✔
4536

4537
        let (shards, discovered) = HuggingFaceRowSource::build_shard_index(&config).unwrap();
1✔
4538
        assert_eq!(discovered, 1);
1✔
4539
        assert_eq!(shards.len(), 1);
1✔
4540
        assert_eq!(shards[0].row_count, 1);
1✔
4541
    }
1✔
4542

4543
    #[test]
4544
    fn resolve_remote_candidates_from_siblings_falls_back_when_split_filter_misses() {
1✔
4545
        let dir = tempdir().unwrap();
1✔
4546
        let mut config = test_config(dir.path().to_path_buf());
1✔
4547
        config.split = "train".to_string();
1✔
4548
        let accepted = HuggingFaceRowSource::normalized_shard_extensions(&config);
1✔
4549
        let siblings = vec![
1✔
4550
            "validation/file-a.ndjson".to_string(),
1✔
4551
            "test/file-b.ndjson".to_string(),
1✔
4552
        ];
4553

4554
        let (candidates, sizes) = HuggingFaceRowSource::resolve_remote_candidates_from_siblings(
1✔
4555
            &config, &siblings, &accepted,
1✔
4556
        )
1✔
4557
        .unwrap();
1✔
4558

4559
        assert!(sizes.is_empty());
1✔
4560
        assert_eq!(candidates.len(), 2);
1✔
4561
    }
1✔
4562

4563
    #[test]
4564
    fn resolve_remote_candidates_from_siblings_errors_for_parquet_only_when_not_accepted() {
1✔
4565
        let dir = tempdir().unwrap();
1✔
4566
        let mut config = test_config(dir.path().to_path_buf());
1✔
4567
        config.shard_extensions = vec!["ndjson".to_string()];
1✔
4568
        let accepted = HuggingFaceRowSource::normalized_shard_extensions(&config);
1✔
4569
        let siblings = vec!["train/only.parquet".to_string()];
1✔
4570

4571
        let result = HuggingFaceRowSource::resolve_remote_candidates_from_siblings(
1✔
4572
            &config, &siblings, &accepted,
1✔
4573
        );
4574
        assert!(result.is_err());
1✔
4575
    }
1✔
4576

4577
    #[test]
4578
    fn resolve_remote_candidates_from_siblings_returns_empty_when_no_matches_and_no_parquet() {
1✔
4579
        let dir = tempdir().unwrap();
1✔
4580
        let config = test_config(dir.path().to_path_buf());
1✔
4581
        let accepted = HuggingFaceRowSource::normalized_shard_extensions(&config);
1✔
4582
        let siblings = vec!["train/notes.txt".to_string()];
1✔
4583

4584
        let (candidates, sizes) = HuggingFaceRowSource::resolve_remote_candidates_from_siblings(
1✔
4585
            &config, &siblings, &accepted,
1✔
4586
        )
1✔
4587
        .unwrap();
1✔
4588
        assert!(candidates.is_empty());
1✔
4589
        assert!(sizes.is_empty());
1✔
4590
    }
1✔
4591

4592
    #[test]
4593
    fn parse_global_row_count_response_applies_max_rows() {
1✔
4594
        let dir = tempdir().unwrap();
1✔
4595
        let mut config = test_config(dir.path().to_path_buf());
1✔
4596
        config.max_rows = Some(3);
1✔
4597
        let body = serde_json::to_string(&json!({
1✔
4598
            "size": {
1✔
4599
                "splits": [
1✔
4600
                    {"config": "default", "split": "train", "num_rows": 10}
1✔
4601
                ]
1✔
4602
            }
1✔
4603
        }))
1✔
4604
        .unwrap();
1✔
4605

4606
        let rows = HuggingFaceRowSource::parse_global_row_count_response(&config, &body)
1✔
4607
            .unwrap()
1✔
4608
            .unwrap();
1✔
4609
        assert_eq!(rows, 3);
1✔
4610
    }
1✔
4611

4612
    #[test]
4613
    fn parse_global_row_count_response_errors_on_invalid_json() {
1✔
4614
        let dir = tempdir().unwrap();
1✔
4615
        let config = test_config(dir.path().to_path_buf());
1✔
4616
        let parsed = HuggingFaceRowSource::parse_global_row_count_response(&config, "{bad-json");
1✔
4617
        assert!(parsed.is_err());
1✔
4618
    }
1✔
4619

4620
    #[test]
4621
    fn parse_parquet_manifest_response_errors_on_invalid_json() {
1✔
4622
        let dir = tempdir().unwrap();
1✔
4623
        let config = test_config(dir.path().to_path_buf());
1✔
4624
        let parsed = HuggingFaceRowSource::parse_parquet_manifest_response(&config, "{bad-json");
1✔
4625
        assert!(parsed.is_err());
1✔
4626
    }
1✔
4627

4628
    #[test]
4629
    fn parse_parquet_manifest_response_returns_candidates() {
1✔
4630
        let dir = tempdir().unwrap();
1✔
4631
        let config = test_config(dir.path().to_path_buf());
1✔
4632
        let body = serde_json::to_string(&json!({
1✔
4633
            "parquet_files": [
1✔
4634
                {"url": "https://host/datasets/x/resolve/main/train/0.parquet", "size": 5}
1✔
4635
            ]
1✔
4636
        }))
1✔
4637
        .unwrap();
1✔
4638

4639
        let (candidates, sizes) =
1✔
4640
            HuggingFaceRowSource::parse_parquet_manifest_response(&config, &body).unwrap();
1✔
4641
        assert_eq!(candidates.len(), 1);
1✔
4642
        assert_eq!(sizes.len(), 1);
1✔
4643
    }
1✔
4644

4645
    #[test]
4646
    fn list_remote_candidates_from_parquet_manifest_uses_test_endpoint_override() {
1✔
4647
        let dir = tempdir().unwrap();
1✔
4648
        let config = test_config(dir.path().to_path_buf());
1✔
4649
        let body = serde_json::to_vec(&json!({
1✔
4650
            "parquet_files": [
1✔
4651
                {"url": "https://host/datasets/x/resolve/main/train/0.parquet", "size": 5}
1✔
4652
            ]
1✔
4653
        }))
1✔
4654
        .unwrap();
1✔
4655
        let (base_url, server) = spawn_one_shot_http(body);
1✔
4656

4657
        let (candidates, sizes) = with_env_var("TRIPLETS_HF_PARQUET_ENDPOINT", &base_url, || {
1✔
4658
            HuggingFaceRowSource::list_remote_candidates_from_parquet_manifest(&config)
1✔
4659
        })
1✔
4660
        .unwrap();
1✔
4661
        server.join().unwrap();
1✔
4662

4663
        assert_eq!(candidates.len(), 1);
1✔
4664
        assert_eq!(sizes.len(), 1);
1✔
4665
    }
1✔
4666

4667
    #[test]
4668
    fn fetch_global_row_count_uses_test_endpoint_override() {
1✔
4669
        let dir = tempdir().unwrap();
1✔
4670
        let config = test_config(dir.path().to_path_buf());
1✔
4671
        let body = serde_json::to_vec(&json!({
1✔
4672
            "size": {
1✔
4673
                "splits": [
1✔
4674
                    {"config": "default", "split": "train", "num_rows": 12}
1✔
4675
                ]
1✔
4676
            }
1✔
4677
        }))
1✔
4678
        .unwrap();
1✔
4679
        let (base_url, server) = spawn_one_shot_http(body);
1✔
4680

4681
        let rows = with_env_var("TRIPLETS_HF_SIZE_ENDPOINT", &base_url, || {
1✔
4682
            HuggingFaceRowSource::fetch_global_row_count(&config)
1✔
4683
        })
1✔
4684
        .unwrap();
1✔
4685
        server.join().unwrap();
1✔
4686
        assert_eq!(rows, Some(12));
1✔
4687
    }
1✔
4688

4689
    #[test]
4690
    fn endpoint_helpers_fallback_for_empty_env_values() {
1✔
4691
        let parquet = with_env_var("TRIPLETS_HF_PARQUET_ENDPOINT", "   ", || {
1✔
4692
            HuggingFaceRowSource::parquet_manifest_endpoint()
1✔
4693
        });
1✔
4694
        assert_eq!(parquet, "https://datasets-server.huggingface.co/parquet");
1✔
4695

4696
        let size = with_env_var("TRIPLETS_HF_SIZE_ENDPOINT", "", || {
1✔
4697
            HuggingFaceRowSource::size_endpoint()
1✔
4698
        });
1✔
4699
        assert_eq!(size, "https://datasets-server.huggingface.co/size");
1✔
4700
    }
1✔
4701

4702
    #[test]
4703
    fn resolve_remote_candidates_respects_split_prefix_in_filename() {
1✔
4704
        let dir = tempdir().unwrap();
1✔
4705
        let mut config = test_config(dir.path().to_path_buf());
1✔
4706
        config.split = "train".to_string();
1✔
4707
        let accepted = HuggingFaceRowSource::normalized_shard_extensions(&config);
1✔
4708
        let siblings = vec![
1✔
4709
            "train-part-000.ndjson".to_string(),
1✔
4710
            "validation-part-000.ndjson".to_string(),
1✔
4711
        ];
4712

4713
        let (candidates, _) = HuggingFaceRowSource::resolve_remote_candidates_from_siblings(
1✔
4714
            &config, &siblings, &accepted,
1✔
4715
        )
1✔
4716
        .unwrap();
1✔
4717

4718
        assert_eq!(candidates, vec!["train-part-000.ndjson".to_string()]);
1✔
4719
    }
1✔
4720

4721
    #[test]
4722
    fn fetch_global_row_count_returns_none_when_split_not_present() {
1✔
4723
        let dir = tempdir().unwrap();
1✔
4724
        let config = test_config(dir.path().to_path_buf());
1✔
4725
        let body = serde_json::to_vec(&json!({
1✔
4726
            "size": {
1✔
4727
                "splits": [
1✔
4728
                    {"config": "default", "split": "validation", "num_rows": 12}
1✔
4729
                ]
1✔
4730
            }
1✔
4731
        }))
1✔
4732
        .unwrap();
1✔
4733
        let (base_url, server) = spawn_one_shot_http(body);
1✔
4734

4735
        let rows = with_env_var("TRIPLETS_HF_SIZE_ENDPOINT", &base_url, || {
1✔
4736
            HuggingFaceRowSource::fetch_global_row_count(&config)
1✔
4737
        })
1✔
4738
        .unwrap();
1✔
4739
        server.join().unwrap();
1✔
4740
        assert_eq!(rows, None);
1✔
4741
    }
1✔
4742

4743
    #[test]
4744
    fn list_remote_candidates_returns_manifest_candidates_before_repo_fallback() {
1✔
4745
        let dir = tempdir().unwrap();
1✔
4746
        let config = test_config(dir.path().to_path_buf());
1✔
4747
        let body = serde_json::to_vec(&json!({
1✔
4748
            "parquet_files": [
1✔
4749
                {"url": "https://host/datasets/x/resolve/main/train/1.ndjson", "size": 9}
1✔
4750
            ]
1✔
4751
        }))
1✔
4752
        .unwrap();
1✔
4753
        let (base_url, server) = spawn_one_shot_http(body);
1✔
4754

4755
        let (candidates, sizes) = with_env_var("TRIPLETS_HF_PARQUET_ENDPOINT", &base_url, || {
1✔
4756
            HuggingFaceRowSource::list_remote_candidates(&config)
1✔
4757
        })
1✔
4758
        .unwrap();
1✔
4759
        server.join().unwrap();
1✔
4760

4761
        assert_eq!(candidates.len(), 1);
1✔
4762
        assert_eq!(sizes.len(), 1);
1✔
4763
        assert!(candidates[0].ends_with("/1.ndjson"));
1✔
4764
    }
1✔
4765

4766
    #[test]
4767
    fn list_remote_candidates_from_parquet_manifest_errors_when_endpoint_unreachable() {
1✔
4768
        let dir = tempdir().unwrap();
1✔
4769
        let config = test_config(dir.path().to_path_buf());
1✔
4770

4771
        let result = with_env_var("TRIPLETS_HF_PARQUET_ENDPOINT", "http://127.0.0.1:1", || {
1✔
4772
            HuggingFaceRowSource::list_remote_candidates_from_parquet_manifest(&config)
1✔
4773
        });
1✔
4774
        assert!(result.is_err());
1✔
4775
    }
1✔
4776

4777
    #[test]
4778
    fn fetch_global_row_count_errors_when_endpoint_unreachable() {
1✔
4779
        let dir = tempdir().unwrap();
1✔
4780
        let config = test_config(dir.path().to_path_buf());
1✔
4781

4782
        let result = with_env_var("TRIPLETS_HF_SIZE_ENDPOINT", "http://127.0.0.1:1", || {
1✔
4783
            HuggingFaceRowSource::fetch_global_row_count(&config)
1✔
4784
        });
1✔
4785
        assert!(result.is_err());
1✔
4786
    }
1✔
4787

4788
    #[test]
4789
    fn download_and_materialize_shard_downloads_url_candidate() {
1✔
4790
        let dir = tempdir().unwrap();
1✔
4791
        let config = test_config(dir.path().to_path_buf());
1✔
4792
        let payload = b"{\"text\":\"a\"}\n{\"text\":\"b\"}\n".to_vec();
1✔
4793
        let (base_url, server) = spawn_one_shot_http(payload.clone());
1✔
4794
        let candidate =
1✔
4795
            format!("url::{base_url}/datasets/org/ds/resolve/main/train/part-000.ndjson");
1✔
4796

4797
        let target =
1✔
4798
            HuggingFaceRowSource::download_and_materialize_shard(&config, &candidate, None)
1✔
4799
                .unwrap();
1✔
4800

4801
        server.join().unwrap();
1✔
4802
        assert!(target.exists());
1✔
4803
        assert_eq!(fs::read(&target).unwrap(), payload);
1✔
4804
    }
1✔
4805

4806
    #[test]
4807
    fn download_and_materialize_shard_replaces_incomplete_existing_target() {
1✔
4808
        let dir = tempdir().unwrap();
1✔
4809
        let config = test_config(dir.path().to_path_buf());
1✔
4810
        let payload = b"{\"text\":\"a\"}\n".to_vec();
1✔
4811
        let (base_url, server) = spawn_one_shot_http(payload.clone());
1✔
4812
        let candidate =
1✔
4813
            format!("url::{base_url}/datasets/org/ds/resolve/main/train/part-009.ndjson");
1✔
4814

4815
        let target = HuggingFaceRowSource::candidate_target_path(&config, &candidate);
1✔
4816
        fs::create_dir_all(target.parent().unwrap()).unwrap();
1✔
4817
        fs::write(&target, b"bad").unwrap();
1✔
4818

4819
        let refreshed = HuggingFaceRowSource::download_and_materialize_shard(
1✔
4820
            &config,
1✔
4821
            &candidate,
1✔
4822
            Some(payload.len() as u64),
1✔
4823
        )
4824
        .unwrap();
1✔
4825

4826
        server.join().unwrap();
1✔
4827
        assert_eq!(refreshed, target);
1✔
4828
        assert_eq!(fs::read(&target).unwrap(), payload);
1✔
4829
    }
1✔
4830

4831
    #[test]
4832
    fn download_next_remote_shard_materializes_and_indexes_rows() {
1✔
4833
        let dir = tempdir().unwrap();
1✔
4834
        let config = test_config(dir.path().to_path_buf());
1✔
4835
        let source = test_source(config);
1✔
4836
        let payload = b"{\"text\":\"a\"}\n{\"text\":\"b\"}\n".to_vec();
1✔
4837
        let (base_url, server) = spawn_one_shot_http(payload);
1✔
4838
        let candidate =
1✔
4839
            format!("url::{base_url}/datasets/org/ds/resolve/main/train/part-001.ndjson");
1✔
4840

4841
        {
1✔
4842
            let mut state = source.state.lock().unwrap();
1✔
4843
            state.remote_candidates = Some(vec![candidate.clone()]);
1✔
4844
            state.remote_candidate_sizes.insert(candidate, 24);
1✔
4845
            state.next_remote_idx = 0;
1✔
4846
        }
1✔
4847

4848
        assert!(source.download_next_remote_shard().unwrap());
1✔
4849
        server.join().unwrap();
1✔
4850

4851
        let state = source.state.lock().unwrap();
1✔
4852
        assert_eq!(state.materialized_rows, 2);
1✔
4853
        assert_eq!(state.shards.len(), 1);
1✔
4854
        assert_eq!(state.next_remote_idx, 1);
1✔
4855
    }
1✔
4856

4857
    #[test]
4858
    fn ensure_row_available_triggers_lazy_download_for_remote_candidates() {
1✔
4859
        let dir = tempdir().unwrap();
1✔
4860
        let config = test_config(dir.path().to_path_buf());
1✔
4861
        let source = test_source(config);
1✔
4862
        let payload = b"{\"text\":\"x\"}\n{\"text\":\"y\"}\n".to_vec();
1✔
4863
        let (base_url, server) = spawn_one_shot_http(payload);
1✔
4864
        let candidate =
1✔
4865
            format!("url::{base_url}/datasets/org/ds/resolve/main/train/part-002.ndjson");
1✔
4866

4867
        {
1✔
4868
            let mut state = source.state.lock().unwrap();
1✔
4869
            state.materialized_rows = 0;
1✔
4870
            state.remote_candidates = Some(vec![candidate.clone()]);
1✔
4871
            state.remote_candidate_sizes.insert(candidate, 24);
1✔
4872
            state.next_remote_idx = 0;
1✔
4873
        }
1✔
4874

4875
        assert!(source.ensure_row_available(0).unwrap());
1✔
4876
        server.join().unwrap();
1✔
4877

4878
        let state = source.state.lock().unwrap();
1✔
4879
        assert!(state.materialized_rows >= 1);
1✔
4880
        assert_eq!(state.next_remote_idx, 1);
1✔
4881
    }
1✔
4882

4883
    #[test]
4884
    fn extract_split_row_count_reads_split_entries() {
1✔
4885
        let payload = json!({
1✔
4886
            "size": {
1✔
4887
                "splits": [
1✔
4888
                    {"config": "default", "split": "train", "num_rows": 123u64},
1✔
4889
                    {"config": "default", "split": "validation", "num_rows": 45u64}
1✔
4890
                ]
4891
            }
4892
        });
4893

4894
        let count = HuggingFaceRowSource::extract_split_row_count_from_size_response(
1✔
4895
            &payload,
1✔
4896
            "default",
1✔
4897
            "validation",
1✔
4898
        );
4899
        assert_eq!(count, Some(45));
1✔
4900
    }
1✔
4901

4902
    #[test]
4903
    fn extract_split_row_count_reads_config_fallback_and_dataset_total() {
1✔
4904
        let payload = json!({
1✔
4905
            "size": {
1✔
4906
                "configs": [
1✔
4907
                    {
4908
                        "config": "default",
1✔
4909
                        "splits": [{"name": "test", "num_rows": 77u64}],
1✔
4910
                        "num_rows": 200u64
1✔
4911
                    }
4912
                ],
4913
                "dataset": {"num_rows": 999u64}
1✔
4914
            }
4915
        });
4916

4917
        let split_count = HuggingFaceRowSource::extract_split_row_count_from_size_response(
1✔
4918
            &payload, "default", "test",
1✔
4919
        );
4920
        assert_eq!(split_count, Some(77));
1✔
4921

4922
        let empty_split_count = HuggingFaceRowSource::extract_split_row_count_from_size_response(
1✔
4923
            &payload, "default", "",
1✔
4924
        );
4925
        assert_eq!(empty_split_count, Some(200));
1✔
4926
    }
1✔
4927

4928
    #[test]
4929
    fn shard_candidate_seed_is_seeded_and_source_scoped() {
1✔
4930
        let dir = tempdir().unwrap();
1✔
4931
        let mut a = test_config(dir.path().join("a"));
1✔
4932
        let mut b = test_config(dir.path().join("b"));
1✔
4933
        a.source_id = "source_a".to_string();
1✔
4934
        b.source_id = "source_b".to_string();
1✔
4935

4936
        let with_seed_a = HuggingFaceRowSource::shard_candidate_seed(&a, 100, 42);
1✔
4937
        let with_seed_a_again = HuggingFaceRowSource::shard_candidate_seed(&a, 100, 42);
1✔
4938
        assert_eq!(with_seed_a, with_seed_a_again);
1✔
4939

4940
        let with_seed_b = HuggingFaceRowSource::shard_candidate_seed(&b, 100, 42);
1✔
4941
        assert_ne!(with_seed_a, with_seed_b);
1✔
4942

4943
        let different_seed_a = HuggingFaceRowSource::shard_candidate_seed(&a, 100, 7);
1✔
4944
        assert_ne!(with_seed_a, different_seed_a);
1✔
4945
    }
1✔
4946

4947
    #[test]
4948
    fn remote_shard_permutation_is_deterministic_by_sampler_seed() {
1✔
4949
        let dir = tempdir().unwrap();
1✔
4950
        let config = test_config(dir.path().to_path_buf());
1✔
4951
        let total = 8usize;
1✔
4952

4953
        let seed_a = HuggingFaceRowSource::shard_candidate_seed(&config, total, 7);
1✔
4954
        let seed_b = HuggingFaceRowSource::shard_candidate_seed(&config, total, 7);
1✔
4955
        let seed_c = HuggingFaceRowSource::shard_candidate_seed(&config, total, 123);
1✔
4956

4957
        let mut perm_a = crate::source::IndexPermutation::new(total, seed_a, 0);
1✔
4958
        let mut perm_b = crate::source::IndexPermutation::new(total, seed_b, 0);
1✔
4959
        let mut perm_c = crate::source::IndexPermutation::new(total, seed_c, 0);
1✔
4960

4961
        let take = 6usize;
1✔
4962
        let order_a: Vec<usize> = (0..take).map(|_| perm_a.next()).collect();
6✔
4963
        let order_b: Vec<usize> = (0..take).map(|_| perm_b.next()).collect();
6✔
4964
        let order_c: Vec<usize> = (0..take).map(|_| perm_c.next()).collect();
6✔
4965

4966
        assert_eq!(order_a, order_b);
1✔
4967
        assert_ne!(order_a, order_c);
1✔
4968
    }
1✔
4969

4970
    #[test]
4971
    fn build_shard_index_ignores_manifest_cache_artifacts() {
1✔
4972
        let dir = tempdir().unwrap();
1✔
4973
        let mut config = test_config(dir.path().to_path_buf());
1✔
4974
        config.shard_extensions = vec!["ndjson".to_string()];
1✔
4975

4976
        let local = dir.path().join("local.ndjson");
1✔
4977
        fs::write(&local, b"{\"id\":\"l1\",\"text\":\"x\"}\n").unwrap();
1✔
4978

4979
        let manifest_cached = dir
1✔
4980
            .path()
1✔
4981
            .join("_parquet_manifest")
1✔
4982
            .join("main/train/cached.ndjson");
1✔
4983
        fs::create_dir_all(manifest_cached.parent().unwrap()).unwrap();
1✔
4984
        fs::write(&manifest_cached, b"{\"id\":\"r1\",\"text\":\"y\"}\n").unwrap();
1✔
4985

4986
        let (shards, discovered) = HuggingFaceRowSource::build_shard_index(&config).unwrap();
1✔
4987
        assert_eq!(discovered, 1);
1✔
4988
        assert_eq!(shards.len(), 1);
1✔
4989
        assert_eq!(shards[0].path, local);
1✔
4990
    }
1✔
4991

4992
    #[test]
4993
    fn expansion_headroom_uses_sampler_ingestion_max_records_when_configured() {
1✔
4994
        let dir = tempdir().unwrap();
1✔
4995
        let config = test_config(dir.path().to_path_buf());
1✔
4996
        let source = test_source(config);
1✔
4997

4998
        assert_eq!(source.effective_expansion_headroom_rows(), 30);
1✔
4999

5000
        let sampler = SamplerConfig {
1✔
5001
            ingestion_max_records: 7,
1✔
5002
            ..SamplerConfig::default()
1✔
5003
        };
1✔
5004
        source.configure_sampler(&sampler);
1✔
5005
        assert_eq!(source.effective_expansion_headroom_rows(), 21);
1✔
5006
    }
1✔
5007

5008
    #[test]
5009
    fn persisted_shard_sequence_roundtrip_respects_sampler_seed() {
1✔
5010
        let dir = tempdir().unwrap();
1✔
5011
        let config = test_config(dir.path().to_path_buf());
1✔
5012
        let source = test_source(config.clone());
1✔
5013

5014
        {
1✔
5015
            let sampler = SamplerConfig {
1✔
5016
                seed: 4242,
1✔
5017
                ..SamplerConfig::default()
1✔
5018
            };
1✔
5019
            source.configure_sampler(&sampler);
1✔
5020
        }
1✔
5021

5022
        let mut state = SourceState {
1✔
5023
            materialized_rows: 0,
1✔
5024
            total_rows: None,
1✔
5025
            shards: Vec::new(),
1✔
5026
            remote_candidates: Some(vec![
1✔
5027
                "url::https://x/resolve/main/train/000.parquet".to_string(),
1✔
5028
                "url::https://x/resolve/main/train/001.parquet".to_string(),
1✔
5029
            ]),
1✔
5030
            remote_candidate_sizes: HashMap::new(),
1✔
5031
            next_remote_idx: 1,
1✔
5032
        };
1✔
5033
        state.remote_candidate_sizes.insert(
1✔
5034
            "url::https://x/resolve/main/train/000.parquet".to_string(),
1✔
5035
            10,
5036
        );
5037

5038
        source.persist_shard_sequence_locked(&state).unwrap();
1✔
5039

5040
        let restored = HuggingFaceRowSource::load_persisted_shard_sequence(&config, 4242).unwrap();
1✔
5041
        assert!(restored.is_some());
1✔
5042
        let restored = restored.unwrap();
1✔
5043
        assert_eq!(restored.next_remote_idx, 1);
1✔
5044
        assert_eq!(restored.candidates.len(), 2);
1✔
5045

5046
        let rejected = HuggingFaceRowSource::load_persisted_shard_sequence(&config, 9999).unwrap();
1✔
5047
        assert!(rejected.is_none());
1✔
5048
    }
1✔
5049

5050
    #[test]
5051
    fn value_to_text_handles_scalar_and_structured_values() {
1✔
5052
        assert_eq!(HuggingFaceRowSource::value_to_text(&json!(null)), None);
1✔
5053
        assert_eq!(HuggingFaceRowSource::value_to_text(&json!("   ")), None);
1✔
5054
        assert_eq!(
1✔
5055
            HuggingFaceRowSource::value_to_text(&json!("hello")),
1✔
5056
            Some("hello".into())
1✔
5057
        );
5058
        assert_eq!(
1✔
5059
            HuggingFaceRowSource::value_to_text(&json!(true)),
1✔
5060
            Some("true".into())
1✔
5061
        );
5062
        assert_eq!(
1✔
5063
            HuggingFaceRowSource::value_to_text(&json!(3.5)),
1✔
5064
            Some("3.5".into())
1✔
5065
        );
5066
        assert_eq!(
1✔
5067
            HuggingFaceRowSource::value_to_text(&json!([1, 2])),
1✔
5068
            Some("[1,2]".into())
1✔
5069
        );
5070
    }
1✔
5071

5072
    #[test]
5073
    fn parse_row_auto_detects_text_fields_and_skips_id() {
1✔
5074
        let dir = tempdir().unwrap();
1✔
5075
        let mut config = test_config(dir.path().to_path_buf());
1✔
5076
        config.id_column = Some("id".into());
1✔
5077
        let source = test_source(config);
1✔
5078

5079
        let row = source
1✔
5080
            .parse_row(
1✔
5081
                5,
5082
                &json!({
1✔
5083
                    "id": "row-5",
1✔
5084
                    "title": "Anchor text",
1✔
5085
                    "body": "Context text",
1✔
5086
                    "flag": true
1✔
5087
                }),
1✔
5088
            )
5089
            .unwrap()
1✔
5090
            .unwrap();
1✔
5091

5092
        assert_eq!(row.row_id.as_deref(), Some("row-5"));
1✔
5093
        assert!(row.text_fields.iter().any(|f| f.name == "title"));
3✔
5094
        assert!(row.text_fields.iter().any(|f| f.name == "body"));
1✔
5095
        assert!(row.text_fields.iter().all(|f| f.name != "id"));
3✔
5096
    }
1✔
5097

5098
    #[test]
5099
    fn parse_row_with_required_columns_skips_when_missing() {
1✔
5100
        let dir = tempdir().unwrap();
1✔
5101
        let mut config = test_config(dir.path().to_path_buf());
1✔
5102
        config.anchor_column = Some("anchor".into());
1✔
5103
        config.positive_column = Some("positive".into());
1✔
5104
        config.context_columns = vec!["context".into()];
1✔
5105
        let source = test_source(config);
1✔
5106

5107
        let parsed = source.parse_row(0, &json!({"anchor": "x", "context": "z"}));
1✔
5108
        assert!(parsed.unwrap().is_none());
1✔
5109
    }
1✔
5110

5111
    #[test]
5112
    fn parse_row_errors_when_payload_is_not_object() {
1✔
5113
        let dir = tempdir().unwrap();
1✔
5114
        let config = test_config(dir.path().to_path_buf());
1✔
5115
        let source = test_source(config);
1✔
5116

5117
        let err = source.parse_row(0, &json!("not-an-object"));
1✔
5118
        assert!(err.is_err());
1✔
5119
    }
1✔
5120

5121
    #[test]
5122
    fn row_to_record_builds_expected_sections() {
1✔
5123
        let dir = tempdir().unwrap();
1✔
5124
        let config = test_config(dir.path().to_path_buf());
1✔
5125
        let source = test_source(config);
1✔
5126
        let row = RowView {
1✔
5127
            row_id: Some("abc".into()),
1✔
5128
            timestamp: Some(Utc::now()),
1✔
5129
            text_fields: vec![
1✔
5130
                RowTextField {
1✔
5131
                    name: "title".into(),
1✔
5132
                    text: "anchor".into(),
1✔
5133
                },
1✔
5134
                RowTextField {
1✔
5135
                    name: "pos".into(),
1✔
5136
                    text: "positive".into(),
1✔
5137
                },
1✔
5138
                RowTextField {
1✔
5139
                    name: "ctx".into(),
1✔
5140
                    text: "extra".into(),
1✔
5141
                },
1✔
5142
            ],
1✔
5143
        };
1✔
5144

5145
        let record = source.row_to_record(&row, 1).unwrap().unwrap();
1✔
5146
        assert_eq!(record.sections.len(), 3);
1✔
5147
        assert_eq!(record.sections[0].role, SectionRole::Anchor);
1✔
5148
        assert_eq!(record.sections[1].role, SectionRole::Context);
1✔
5149
        assert_eq!(record.id, "hf_test::abc");
1✔
5150
    }
1✔
5151

5152
    #[test]
5153
    fn effective_refresh_batch_target_uses_multiplier_floor_of_one() {
1✔
5154
        let dir = tempdir().unwrap();
1✔
5155
        let mut config = test_config(dir.path().to_path_buf());
1✔
5156
        config.refresh_batch_multiplier = 0;
1✔
5157
        let source = test_source(config);
1✔
5158
        assert_eq!(source.effective_refresh_batch_target(7), 7);
1✔
5159
    }
1✔
5160

5161
    #[test]
5162
    fn locate_shard_and_recompute_offsets_work() {
1✔
5163
        let mut shards = vec![
1✔
5164
            ShardIndex {
1✔
5165
                path: PathBuf::from("a"),
1✔
5166
                global_start: 10,
1✔
5167
                row_count: 3,
1✔
5168
                is_parquet: false,
1✔
5169
                parquet_row_groups: Vec::new(),
1✔
5170
                checkpoints: vec![0],
1✔
5171
            },
1✔
5172
            ShardIndex {
1✔
5173
                path: PathBuf::from("b"),
1✔
5174
                global_start: 20,
1✔
5175
                row_count: 2,
1✔
5176
                is_parquet: false,
1✔
5177
                parquet_row_groups: Vec::new(),
1✔
5178
                checkpoints: vec![0],
1✔
5179
            },
1✔
5180
        ];
5181
        let hit = HuggingFaceRowSource::locate_shard(&shards, 11).unwrap();
1✔
5182
        assert_eq!(hit.1, 1);
1✔
5183

5184
        let mut state = SourceState {
1✔
5185
            materialized_rows: 0,
1✔
5186
            total_rows: None,
1✔
5187
            shards: std::mem::take(&mut shards),
1✔
5188
            remote_candidates: None,
1✔
5189
            remote_candidate_sizes: HashMap::new(),
1✔
5190
            next_remote_idx: 0,
1✔
5191
        };
1✔
5192
        HuggingFaceRowSource::recompute_shard_offsets(&mut state);
1✔
5193
        assert_eq!(state.shards[0].global_start, 0);
1✔
5194
        assert_eq!(state.shards[1].global_start, 3);
1✔
5195
        assert_eq!(state.materialized_rows, 5);
1✔
5196
    }
1✔
5197

5198
    #[test]
5199
    fn len_hint_covers_known_and_empty_paths() {
1✔
5200
        let dir = tempdir().unwrap();
1✔
5201
        let mut config = test_config(dir.path().to_path_buf());
1✔
5202
        config.max_rows = Some(9);
1✔
5203
        let source = test_source(config);
1✔
5204

5205
        {
1✔
5206
            let mut state = source.state.lock().unwrap();
1✔
5207
            state.materialized_rows = 5;
1✔
5208
            state.total_rows = Some(100);
1✔
5209
        }
1✔
5210
        assert_eq!(source.len_hint(), Some(9));
1✔
5211

5212
        {
1✔
5213
            let mut state = source.state.lock().unwrap();
1✔
5214
            state.materialized_rows = 0;
1✔
5215
            state.total_rows = Some(0);
1✔
5216
        }
1✔
5217
        assert_eq!(source.len_hint(), Some(0));
1✔
5218
    }
1✔
5219

5220
    #[test]
5221
    fn len_hint_defaults_to_one_when_unknown_and_not_exhausted() {
1✔
5222
        let dir = tempdir().unwrap();
1✔
5223
        let config = test_config(dir.path().to_path_buf());
1✔
5224
        let source = test_source(config);
1✔
5225
        assert_eq!(source.len_hint(), Some(1));
1✔
5226
    }
1✔
5227

5228
    #[test]
5229
    fn read_line_at_reads_expected_row_with_checkpoints() {
1✔
5230
        let dir = tempdir().unwrap();
1✔
5231
        let path = dir.path().join("rows.jsonl");
1✔
5232
        let mut file = File::create(&path).unwrap();
1✔
5233
        file.write_all(b"{\"text\":\"a\"}\n").unwrap();
1✔
5234
        file.write_all(b"{\"text\":\"b\"}\n").unwrap();
1✔
5235
        file.write_all(b"{\"text\":\"c\"}\n").unwrap();
1✔
5236

5237
        let mut config = test_config(dir.path().to_path_buf());
1✔
5238
        config.checkpoint_stride = 1;
1✔
5239
        let source = test_source(config.clone());
1✔
5240
        let shard = HuggingFaceRowSource::index_single_shard(&config, &path, 0)
1✔
5241
            .unwrap()
1✔
5242
            .unwrap();
1✔
5243

5244
        let line = source.read_line_at(&shard, 2).unwrap();
1✔
5245
        assert!(line.contains("\"c\""));
1✔
5246
    }
1✔
5247

5248
    #[test]
5249
    fn read_line_at_errors_when_checkpoint_is_missing() {
1✔
5250
        let dir = tempdir().unwrap();
1✔
5251
        let path = dir.path().join("rows.jsonl");
1✔
5252
        fs::write(&path, b"{\"text\":\"a\"}\n").unwrap();
1✔
5253

5254
        let mut config = test_config(dir.path().to_path_buf());
1✔
5255
        config.checkpoint_stride = 1;
1✔
5256
        let source = test_source(config.clone());
1✔
5257
        let mut shard = HuggingFaceRowSource::index_single_shard(&config, &path, 0)
1✔
5258
            .unwrap()
1✔
5259
            .unwrap();
1✔
5260
        shard.checkpoints.clear();
1✔
5261

5262
        let err = source.read_line_at(&shard, 0);
1✔
5263
        assert!(err.is_err());
1✔
5264
    }
1✔
5265

5266
    #[test]
5267
    fn load_persisted_shard_sequence_clamps_next_index_to_candidate_len() {
1✔
5268
        let dir = tempdir().unwrap();
1✔
5269
        let config = test_config(dir.path().to_path_buf());
1✔
5270
        let state_path = HuggingFaceRowSource::shard_sequence_state_path(&config);
1✔
5271
        fs::create_dir_all(state_path.parent().unwrap()).unwrap();
1✔
5272
        fs::write(
1✔
5273
            &state_path,
1✔
5274
            serde_json::to_vec_pretty(&json!({
1✔
5275
                "version": 1,
1✔
5276
                "source_id": config.source_id,
1✔
5277
                "dataset": config.dataset,
1✔
5278
                "config": config.config,
1✔
5279
                "split": config.split,
1✔
5280
                "sampler_seed": 1,
1✔
5281
                "candidates": ["url::http://x/resolve/main/train/000.ndjson"],
1✔
5282
                "candidate_sizes": {},
1✔
5283
                "next_remote_idx": 99
1✔
5284
            }))
1✔
5285
            .unwrap(),
1✔
5286
        )
5287
        .unwrap();
1✔
5288

5289
        let loaded = HuggingFaceRowSource::load_persisted_shard_sequence(&config, 1)
1✔
5290
            .unwrap()
1✔
5291
            .unwrap();
1✔
5292
        assert_eq!(loaded.next_remote_idx, 1);
1✔
5293
    }
1✔
5294

5295
    #[test]
5296
    fn materialize_local_file_copies_and_is_idempotent_when_size_matches() {
1✔
5297
        let dir = tempdir().unwrap();
1✔
5298
        let config = test_config(dir.path().to_path_buf());
1✔
5299
        let src = dir.path().join("src.ndjson");
1✔
5300
        let dst = dir.path().join("nested/dst.ndjson");
1✔
5301

5302
        fs::write(&src, b"line\n").unwrap();
1✔
5303
        HuggingFaceRowSource::materialize_local_file(&config, &src, &dst).unwrap();
1✔
5304
        let first = fs::read(&dst).unwrap();
1✔
5305
        HuggingFaceRowSource::materialize_local_file(&config, &src, &dst).unwrap();
1✔
5306
        let second = fs::read(&dst).unwrap();
1✔
5307
        assert_eq!(first, second);
1✔
5308
    }
1✔
5309

5310
    #[test]
5311
    fn enforce_disk_cap_evicts_old_manifest_shards() {
1✔
5312
        let dir = tempdir().unwrap();
1✔
5313
        let mut config = test_config(dir.path().to_path_buf());
1✔
5314
        config.local_disk_cap_bytes = Some(10);
1✔
5315
        config.min_resident_shards = 0;
1✔
5316
        let source = test_source(config);
1✔
5317

5318
        let manifest_root = source.manifest_cache_root();
1✔
5319
        fs::create_dir_all(&manifest_root).unwrap();
1✔
5320
        let evict_path = manifest_root.join("a.parquet");
1✔
5321
        let keep_path = manifest_root.join("b.parquet");
1✔
5322
        fs::write(&evict_path, vec![1u8; 8]).unwrap();
1✔
5323
        fs::write(&keep_path, vec![2u8; 8]).unwrap();
1✔
5324

5325
        let mut state = SourceState {
1✔
5326
            materialized_rows: 16,
1✔
5327
            total_rows: None,
1✔
5328
            shards: vec![
1✔
5329
                ShardIndex {
1✔
5330
                    path: evict_path.clone(),
1✔
5331
                    global_start: 0,
1✔
5332
                    row_count: 8,
1✔
5333
                    is_parquet: true,
1✔
5334
                    parquet_row_groups: vec![(0, 8)],
1✔
5335
                    checkpoints: Vec::new(),
1✔
5336
                },
1✔
5337
                ShardIndex {
1✔
5338
                    path: keep_path.clone(),
1✔
5339
                    global_start: 8,
1✔
5340
                    row_count: 8,
1✔
5341
                    is_parquet: true,
1✔
5342
                    parquet_row_groups: vec![(0, 8)],
1✔
5343
                    checkpoints: Vec::new(),
1✔
5344
                },
1✔
5345
            ],
1✔
5346
            remote_candidates: None,
1✔
5347
            remote_candidate_sizes: HashMap::new(),
1✔
5348
            next_remote_idx: 0,
1✔
5349
        };
1✔
5350

5351
        let evicted = source
1✔
5352
            .enforce_disk_cap_locked(&mut state, &keep_path)
1✔
5353
            .unwrap();
1✔
5354
        assert!(evicted);
1✔
5355
        assert!(!evict_path.exists());
1✔
5356
        assert!(keep_path.exists());
1✔
5357
        assert_eq!(state.shards.len(), 1);
1✔
5358
    }
1✔
5359

5360
    #[test]
5361
    fn enforce_disk_cap_errors_when_min_resident_prevents_eviction() {
1✔
5362
        let dir = tempdir().unwrap();
1✔
5363
        let mut config = test_config(dir.path().to_path_buf());
1✔
5364
        config.local_disk_cap_bytes = Some(4);
1✔
5365
        config.min_resident_shards = 1;
1✔
5366
        let source = test_source(config);
1✔
5367

5368
        let manifest_root = source.manifest_cache_root();
1✔
5369
        fs::create_dir_all(&manifest_root).unwrap();
1✔
5370
        let protected = manifest_root.join("only.parquet");
1✔
5371
        fs::write(&protected, vec![1u8; 8]).unwrap();
1✔
5372

5373
        let mut state = SourceState {
1✔
5374
            materialized_rows: 8,
1✔
5375
            total_rows: None,
1✔
5376
            shards: vec![ShardIndex {
1✔
5377
                path: protected.clone(),
1✔
5378
                global_start: 0,
1✔
5379
                row_count: 8,
1✔
5380
                is_parquet: true,
1✔
5381
                parquet_row_groups: vec![(0, 8)],
1✔
5382
                checkpoints: Vec::new(),
1✔
5383
            }],
1✔
5384
            remote_candidates: None,
1✔
5385
            remote_candidate_sizes: HashMap::new(),
1✔
5386
            next_remote_idx: 0,
1✔
5387
        };
1✔
5388

5389
        let err = source.enforce_disk_cap_locked(&mut state, &protected);
1✔
5390
        assert!(err.is_err());
1✔
5391
        assert!(!protected.exists());
1✔
5392
    }
1✔
5393

5394
    #[test]
5395
    fn build_shard_index_discovers_local_jsonl_shards() {
1✔
5396
        let dir = tempdir().unwrap();
1✔
5397
        let root = dir.path().to_path_buf();
1✔
5398
        fs::write(root.join("a.jsonl"), b"{\"text\":\"a\"}\n").unwrap();
1✔
5399
        fs::write(root.join("b.ndjson"), b"{\"text\":\"b\"}\n").unwrap();
1✔
5400

5401
        let config = test_config(root.clone());
1✔
5402
        let (shards, discovered) = HuggingFaceRowSource::build_shard_index(&config).unwrap();
1✔
5403
        assert_eq!(discovered, 2);
1✔
5404
        assert_eq!(shards.len(), 2);
1✔
5405
    }
1✔
5406

5407
    #[test]
5408
    fn index_single_shard_returns_none_for_empty_file() {
1✔
5409
        let dir = tempdir().unwrap();
1✔
5410
        let config = test_config(dir.path().to_path_buf());
1✔
5411
        let path = dir.path().join("empty.jsonl");
1✔
5412
        fs::write(&path, b"").unwrap();
1✔
5413
        let shard = HuggingFaceRowSource::index_single_shard(&config, &path, 0).unwrap();
1✔
5414
        assert!(shard.is_none());
1✔
5415
    }
1✔
5416

5417
    #[test]
5418
    fn refresh_reads_local_rows_and_advances_cursor() {
1✔
5419
        let dir = tempdir().unwrap();
1✔
5420
        let path = dir.path().join("rows.jsonl");
1✔
5421
        fs::write(
1✔
5422
            &path,
1✔
5423
            b"{\"id\":\"r1\",\"text\":\"alpha\"}\n{\"id\":\"r2\",\"text\":\"beta\"}\n{\"id\":\"r3\",\"text\":\"gamma\"}\n",
5424
        )
5425
        .unwrap();
1✔
5426

5427
        let mut config = test_config(dir.path().to_path_buf());
1✔
5428
        config.checkpoint_stride = 1;
1✔
5429
        config.refresh_batch_multiplier = 1;
1✔
5430
        let source = test_source(config.clone());
1✔
5431
        let shard = HuggingFaceRowSource::index_single_shard(&config, &path, 0)
1✔
5432
            .unwrap()
1✔
5433
            .unwrap();
1✔
5434
        {
1✔
5435
            let mut state = source.state.lock().unwrap();
1✔
5436
            state.materialized_rows = shard.row_count;
1✔
5437
            state.total_rows = Some(shard.row_count);
1✔
5438
            state.shards = vec![shard];
1✔
5439
        }
1✔
5440

5441
        let snapshot = source.refresh(None, Some(2)).unwrap();
1✔
5442
        assert_eq!(snapshot.records.len(), 2);
1✔
5443
        assert!(snapshot.cursor.revision > 0);
1✔
5444
    }
1✔
5445

5446
    #[test]
5447
    fn reported_record_count_uses_len_hint_for_local_state() {
1✔
5448
        let dir = tempdir().unwrap();
1✔
5449
        let config = test_config(dir.path().to_path_buf());
1✔
5450
        let source = test_source(config);
1✔
5451
        {
1✔
5452
            let mut state = source.state.lock().unwrap();
1✔
5453
            state.materialized_rows = 4;
1✔
5454
            state.total_rows = Some(4);
1✔
5455
        }
1✔
5456
        assert_eq!(source.reported_record_count().unwrap(), 4);
1✔
5457
    }
1✔
5458

5459
    #[test]
5460
    fn rotate_candidates_deterministically_preserves_membership() {
1✔
5461
        let dir = tempdir().unwrap();
1✔
5462
        let config = test_config(dir.path().to_path_buf());
1✔
5463
        let original = vec!["a".to_string(), "b".to_string(), "c".to_string()];
1✔
5464
        let mut rotated = original.clone();
1✔
5465
        HuggingFaceRowSource::rotate_candidates_deterministically(&config, &mut rotated);
1✔
5466
        let mut sorted_original = original;
1✔
5467
        let mut sorted_rotated = rotated;
1✔
5468
        sorted_original.sort();
1✔
5469
        sorted_rotated.sort();
1✔
5470
        assert_eq!(sorted_rotated, sorted_original);
1✔
5471
    }
1✔
5472

5473
    #[test]
5474
    fn parse_row_supports_row_wrapped_payload_and_text_columns() {
1✔
5475
        let dir = tempdir().unwrap();
1✔
5476
        let mut config = test_config(dir.path().to_path_buf());
1✔
5477
        config.text_columns = vec!["headline".into(), "body".into()];
1✔
5478
        config.id_column = Some("rid".into());
1✔
5479
        let source = test_source(config);
1✔
5480

5481
        let parsed = source
1✔
5482
            .parse_row(
1✔
5483
                0,
5484
                &json!({"row": {"rid": "r-1", "headline": "h", "body": "b"}}),
1✔
5485
            )
5486
            .unwrap()
1✔
5487
            .unwrap();
1✔
5488

5489
        assert_eq!(parsed.row_id.as_deref(), Some("r-1"));
1✔
5490
        assert_eq!(parsed.text_fields.len(), 2);
1✔
5491
        assert_eq!(parsed.text_fields[0].name, "headline");
1✔
5492
    }
1✔
5493

5494
    #[test]
5495
    fn row_to_record_returns_none_for_empty_fields() {
1✔
5496
        let dir = tempdir().unwrap();
1✔
5497
        let config = test_config(dir.path().to_path_buf());
1✔
5498
        let source = test_source(config);
1✔
5499
        let row = RowView {
1✔
5500
            row_id: Some("x".into()),
1✔
5501
            timestamp: None,
1✔
5502
            text_fields: Vec::new(),
1✔
5503
        };
1✔
5504
        assert!(source.row_to_record(&row, 0).unwrap().is_none());
1✔
5505
    }
1✔
5506

5507
    #[test]
5508
    fn ensure_row_available_handles_materialized_max_and_exhausted_candidates() {
1✔
5509
        let dir = tempdir().unwrap();
1✔
5510
        let mut config = test_config(dir.path().to_path_buf());
1✔
5511
        config.max_rows = Some(2);
1✔
5512
        let source = test_source(config);
1✔
5513
        {
1✔
5514
            let mut state = source.state.lock().unwrap();
1✔
5515
            state.materialized_rows = 1;
1✔
5516
            state.remote_candidates = Some(vec![]);
1✔
5517
            state.next_remote_idx = 0;
1✔
5518
        }
1✔
5519

5520
        assert!(source.ensure_row_available(0).unwrap());
1✔
5521
        assert!(!source.ensure_row_available(3).unwrap());
1✔
5522
        assert!(!source.ensure_row_available(1).unwrap());
1✔
5523
    }
1✔
5524

5525
    #[test]
5526
    fn read_row_batch_uses_cached_rows_and_respects_limit() {
1✔
5527
        let dir = tempdir().unwrap();
1✔
5528
        let config = test_config(dir.path().to_path_buf());
1✔
5529
        let source = test_source(config.clone());
1✔
5530

5531
        {
1✔
5532
            let mut state = source.state.lock().unwrap();
1✔
5533
            state.materialized_rows = 2;
1✔
5534
            state.total_rows = Some(2);
1✔
5535
        }
1✔
5536

5537
        let row0 = RowView {
1✔
5538
            row_id: Some("r0".into()),
1✔
5539
            timestamp: Some(Utc::now()),
1✔
5540
            text_fields: vec![RowTextField {
1✔
5541
                name: "text".into(),
1✔
5542
                text: "alpha".into(),
1✔
5543
            }],
1✔
5544
        };
1✔
5545
        let row1 = RowView {
1✔
5546
            row_id: Some("r1".into()),
1✔
5547
            timestamp: Some(Utc::now()),
1✔
5548
            text_fields: vec![RowTextField {
1✔
5549
                name: "text".into(),
1✔
5550
                text: "beta".into(),
1✔
5551
            }],
1✔
5552
        };
1✔
5553
        {
1✔
5554
            let mut cache = source.cache.lock().unwrap();
1✔
5555
            cache.insert(0, row0, config.cache_capacity);
1✔
5556
            cache.insert(1, row1, config.cache_capacity);
1✔
5557
        }
1✔
5558

5559
        let mut out = Vec::new();
1✔
5560
        source.read_row_batch(&[0, 1], &mut out, Some(1)).unwrap();
1✔
5561
        assert_eq!(out.len(), 1);
1✔
5562
    }
1✔
5563

5564
    #[test]
5565
    fn read_row_batch_errors_on_invalid_json_line() {
1✔
5566
        let dir = tempdir().unwrap();
1✔
5567
        let path = dir.path().join("broken.jsonl");
1✔
5568
        fs::write(&path, b"not-json\n").unwrap();
1✔
5569

5570
        let mut config = test_config(dir.path().to_path_buf());
1✔
5571
        config.checkpoint_stride = 1;
1✔
5572
        let source = test_source(config.clone());
1✔
5573
        let shard = HuggingFaceRowSource::index_single_shard(&config, &path, 0)
1✔
5574
            .unwrap()
1✔
5575
            .unwrap();
1✔
5576
        {
1✔
5577
            let mut state = source.state.lock().unwrap();
1✔
5578
            state.materialized_rows = 1;
1✔
5579
            state.total_rows = Some(1);
1✔
5580
            state.shards = vec![shard];
1✔
5581
        }
1✔
5582

5583
        let mut out = Vec::new();
1✔
5584
        let result = source.read_row_batch(&[0], &mut out, Some(1));
1✔
5585
        assert!(result.is_err());
1✔
5586
    }
1✔
5587

5588
    #[test]
5589
    fn build_shard_index_errors_when_no_matching_extensions() {
1✔
5590
        let dir = tempdir().unwrap();
1✔
5591
        fs::write(dir.path().join("data.txt"), b"x\n").unwrap();
1✔
5592
        let config = test_config(dir.path().to_path_buf());
1✔
5593
        let result = HuggingFaceRowSource::build_shard_index(&config);
1✔
5594
        assert!(result.is_err());
1✔
5595
    }
1✔
5596

5597
    #[test]
5598
    fn build_shard_index_honors_max_rows() {
1✔
5599
        let dir = tempdir().unwrap();
1✔
5600
        fs::write(
1✔
5601
            dir.path().join("rows.jsonl"),
1✔
5602
            b"{\"text\":\"1\"}\n{\"text\":\"2\"}\n{\"text\":\"3\"}\n",
5603
        )
5604
        .unwrap();
1✔
5605
        let mut config = test_config(dir.path().to_path_buf());
1✔
5606
        config.max_rows = Some(2);
1✔
5607

5608
        let (_, discovered) = HuggingFaceRowSource::build_shard_index(&config).unwrap();
1✔
5609
        assert_eq!(discovered, 2);
1✔
5610
    }
1✔
5611

5612
    #[test]
5613
    fn refresh_handles_empty_total_and_cursor_wrap() {
1✔
5614
        let dir = tempdir().unwrap();
1✔
5615
        let config = test_config(dir.path().to_path_buf());
1✔
5616
        let source = test_source(config.clone());
1✔
5617

5618
        {
1✔
5619
            let mut state = source.state.lock().unwrap();
1✔
5620
            state.materialized_rows = 0;
1✔
5621
            state.total_rows = Some(0);
1✔
5622
        }
1✔
5623
        let empty = source.refresh(None, Some(5)).unwrap();
1✔
5624
        assert!(empty.records.is_empty());
1✔
5625
        assert_eq!(empty.cursor.revision, 0);
1✔
5626

5627
        let path = dir.path().join("rows.jsonl");
1✔
5628
        fs::write(
1✔
5629
            &path,
1✔
5630
            b"{\"id\":\"a\",\"text\":\"A\"}\n{\"id\":\"b\",\"text\":\"B\"}\n",
5631
        )
5632
        .unwrap();
1✔
5633
        let mut cfg2 = config;
1✔
5634
        cfg2.checkpoint_stride = 1;
1✔
5635
        let source2 = test_source(cfg2.clone());
1✔
5636
        let shard = HuggingFaceRowSource::index_single_shard(&cfg2, &path, 0)
1✔
5637
            .unwrap()
1✔
5638
            .unwrap();
1✔
5639
        {
1✔
5640
            let mut state = source2.state.lock().unwrap();
1✔
5641
            state.materialized_rows = 2;
1✔
5642
            state.total_rows = Some(2);
1✔
5643
            state.shards = vec![shard];
1✔
5644
        }
1✔
5645
        let cursor = SourceCursor {
1✔
5646
            last_seen: Utc::now(),
1✔
5647
            revision: 99,
1✔
5648
        };
1✔
5649
        let snapshot = source2.refresh(Some(&cursor), Some(1)).unwrap();
1✔
5650
        assert_eq!(snapshot.records.len(), 1);
1✔
5651
    }
1✔
5652

5653
    #[test]
5654
    fn new_rejects_zero_checkpoint_stride() {
1✔
5655
        let dir = tempdir().unwrap();
1✔
5656
        let mut config = test_config(dir.path().to_path_buf());
1✔
5657
        config.checkpoint_stride = 0;
1✔
5658
        let result = HuggingFaceRowSource::new(config);
1✔
5659
        assert!(result.is_err());
1✔
5660
    }
1✔
5661

5662
    #[test]
5663
    fn parse_global_row_count_response_returns_none_when_split_missing() {
1✔
5664
        let dir = tempdir().unwrap();
1✔
5665
        let config = test_config(dir.path().to_path_buf());
1✔
5666
        let body = r#"{
1✔
5667
            "size": {
1✔
5668
                "splits": [
1✔
5669
                    {"config":"main","split":"test","num_rows":7}
1✔
5670
                ]
1✔
5671
            }
1✔
5672
        }"#;
1✔
5673

5674
        let parsed = HuggingFaceRowSource::parse_global_row_count_response(&config, body).unwrap();
1✔
5675
        assert_eq!(parsed, None);
1✔
5676
    }
1✔
5677

5678
    #[test]
5679
    fn extract_split_row_count_uses_config_num_rows_when_split_empty() {
1✔
5680
        let payload = serde_json::json!({
1✔
5681
            "size": {
1✔
5682
                "configs": [
1✔
5683
                    {
5684
                        "config": "main",
1✔
5685
                        "num_rows": 123,
1✔
5686
                        "splits": [
1✔
5687
                            {"split": "train", "num_rows": 999}
1✔
5688
                        ]
5689
                    }
5690
                ]
5691
            }
5692
        });
5693

5694
        let rows =
1✔
5695
            HuggingFaceRowSource::extract_split_row_count_from_size_response(&payload, "main", "");
1✔
5696
        assert_eq!(rows, Some(123));
1✔
5697
    }
1✔
5698

5699
    #[test]
5700
    fn extract_split_row_count_uses_dataset_num_rows_when_split_empty() {
1✔
5701
        let payload = serde_json::json!({
1✔
5702
            "size": {
1✔
5703
                "dataset": {
1✔
5704
                    "num_rows": 77
1✔
5705
                }
5706
            }
5707
        });
5708

5709
        let rows =
1✔
5710
            HuggingFaceRowSource::extract_split_row_count_from_size_response(&payload, "main", "");
1✔
5711
        assert_eq!(rows, Some(77));
1✔
5712
    }
1✔
5713

5714
    #[test]
5715
    fn refresh_order_uses_sampler_seed_for_local_rows() {
1✔
5716
        let dir = tempdir().unwrap();
1✔
5717
        let path = dir.path().join("rows.jsonl");
1✔
5718
        let mut payload = String::new();
1✔
5719
        for idx in 0..12 {
12✔
5720
            payload.push_str(&format!("{{\"id\":\"r{idx}\",\"text\":\"v{idx}\"}}\n"));
12✔
5721
        }
12✔
5722
        fs::write(&path, payload).unwrap();
1✔
5723

5724
        let mut config = test_config(dir.path().to_path_buf());
1✔
5725
        config.checkpoint_stride = 1;
1✔
5726
        config.refresh_batch_multiplier = 1;
1✔
5727

5728
        let source_a = test_source(config.clone());
1✔
5729
        let source_b = test_source(config.clone());
1✔
5730
        let source_c = test_source(config.clone());
1✔
5731
        let shard = HuggingFaceRowSource::index_single_shard(&config, &path, 0)
1✔
5732
            .unwrap()
1✔
5733
            .unwrap();
1✔
5734

5735
        for source in [&source_a, &source_b, &source_c] {
3✔
5736
            let mut state = source.state.lock().unwrap();
3✔
5737
            state.materialized_rows = 12;
3✔
5738
            state.total_rows = Some(12);
3✔
5739
            state.shards = vec![shard.clone()];
3✔
5740
        }
3✔
5741

5742
        let seed_1 = SamplerConfig {
1✔
5743
            seed: 7,
1✔
5744
            ..SamplerConfig::default()
1✔
5745
        };
1✔
5746
        let seed_2 = SamplerConfig {
1✔
5747
            seed: 7,
1✔
5748
            ..SamplerConfig::default()
1✔
5749
        };
1✔
5750
        let seed_3 = SamplerConfig {
1✔
5751
            seed: 123,
1✔
5752
            ..SamplerConfig::default()
1✔
5753
        };
1✔
5754

5755
        source_a.configure_sampler(&seed_1);
1✔
5756
        source_b.configure_sampler(&seed_2);
1✔
5757
        source_c.configure_sampler(&seed_3);
1✔
5758

5759
        let ids_a: Vec<String> = source_a
1✔
5760
            .refresh(None, Some(8))
1✔
5761
            .unwrap()
1✔
5762
            .records
1✔
5763
            .into_iter()
1✔
5764
            .map(|record| record.id)
1✔
5765
            .collect();
1✔
5766
        let ids_b: Vec<String> = source_b
1✔
5767
            .refresh(None, Some(8))
1✔
5768
            .unwrap()
1✔
5769
            .records
1✔
5770
            .into_iter()
1✔
5771
            .map(|record| record.id)
1✔
5772
            .collect();
1✔
5773
        let ids_c: Vec<String> = source_c
1✔
5774
            .refresh(None, Some(8))
1✔
5775
            .unwrap()
1✔
5776
            .records
1✔
5777
            .into_iter()
1✔
5778
            .map(|record| record.id)
1✔
5779
            .collect();
1✔
5780

5781
        assert_eq!(ids_a, ids_b);
1✔
5782
        assert_ne!(ids_a, ids_c);
1✔
5783
    }
1✔
5784
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc