Skip to main content

scirs2_datasets/
formats.rs

1//! Support for various data formats (Parquet, Arrow, HDF5, CSV)
2//!
3//! This module provides integration with scirs2-io for reading and writing
4//! datasets in modern columnar formats like Parquet and Arrow, as well as
5//! scientific formats like HDF5 and plain CSV, with memory-efficient
6//! streaming support.
7//!
8//! ## Parquet conventions
9//!
10//! When writing a `Dataset` to Parquet, each feature column is stored as
11//! `feature_0`, `feature_1`, … (or by the names in `featurenames` when present).
12//! The optional target is stored in a column named `__target__`.
13//! On read, any column named `__target__` is loaded as the target vector;
14//! the remaining columns become the feature matrix.
15//!
16//! ## HDF5 conventions
17//!
18//! When writing, the feature matrix is stored under `<dataset_name>` and
19//! the optional target (if present) is stored under `<dataset_name>_target`.
20//! On read, the `<dataset_name>` dataset provides the feature matrix; a
21//! companion `<dataset_name>_target` dataset (if present) becomes the target.
22//!
23//! ## CSV conventions
24//!
25//! The first row is treated as a header containing column names.  A column
26//! named `__target__` (the same sentinel used by Parquet) is loaded as the
27//! target vector; all other columns form the feature matrix (all values must
28//! be parseable as `f64`).  Fields that contain a comma, newline, or
29//! double-quote are wrapped in double-quotes on write; internal double-quotes
30//! are escaped as `""`.  Lines beginning with `#` (after optional leading
31//! whitespace) and completely blank lines are skipped on read.
32
33#[cfg(feature = "formats")]
34use crate::error::{DatasetsError, Result};
35#[cfg(feature = "formats")]
36use crate::utils::Dataset;
37#[cfg(feature = "formats")]
38use scirs2_core::ndarray::{Array1, Array2};
39#[cfg(feature = "formats")]
40use std::path::Path;
41
42/// Format type enumeration
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum FormatType {
45    /// Apache Parquet columnar format
46    Parquet,
47    /// Apache Arrow in-memory format
48    Arrow,
49    /// HDF5 hierarchical format
50    Hdf5,
51    /// CSV format (for completeness)
52    Csv,
53}
54
55impl FormatType {
56    /// Detect format from file extension
57    pub fn from_extension(path: &str) -> Option<Self> {
58        let lower = path.to_lowercase();
59        if lower.ends_with(".parquet") || lower.ends_with(".pq") {
60            Some(FormatType::Parquet)
61        } else if lower.ends_with(".arrow") {
62            Some(FormatType::Arrow)
63        } else if lower.ends_with(".h5") || lower.ends_with(".hdf5") {
64            Some(FormatType::Hdf5)
65        } else if lower.ends_with(".csv") {
66            Some(FormatType::Csv)
67        } else {
68            None
69        }
70    }
71
72    /// Get file extension for this format
73    pub fn extension(&self) -> &'static str {
74        match self {
75            FormatType::Parquet => "parquet",
76            FormatType::Arrow => "arrow",
77            FormatType::Hdf5 => "h5",
78            FormatType::Csv => "csv",
79        }
80    }
81}
82
83/// Configuration for format conversion
84#[derive(Debug, Clone)]
85pub struct FormatConfig {
86    /// Chunk size for streaming operations
87    pub chunk_size: usize,
88    /// Compression codec
89    pub compression: Option<CompressionCodec>,
90    /// Whether to use memory mapping when possible
91    pub use_mmap: bool,
92    /// Buffer size for I/O operations
93    pub buffer_size: usize,
94}
95
96impl Default for FormatConfig {
97    fn default() -> Self {
98        Self {
99            chunk_size: 10_000,
100            compression: Some(CompressionCodec::Snappy),
101            use_mmap: true,
102            buffer_size: 8 * 1024 * 1024, // 8 MB
103        }
104    }
105}
106
107/// Compression codec options
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum CompressionCodec {
110    /// No compression
111    None,
112    /// Snappy compression
113    Snappy,
114    /// GZIP compression
115    Gzip,
116    /// LZ4 compression
117    Lz4,
118    /// ZSTD compression
119    Zstd,
120}
121
122impl CompressionCodec {
123    /// Get compression level (0-9 where applicable)
124    pub fn level(&self) -> Option<i32> {
125        match self {
126            CompressionCodec::None | CompressionCodec::Snappy | CompressionCodec::Lz4 => None,
127            CompressionCodec::Gzip => Some(6), // Default GZIP level
128            CompressionCodec::Zstd => Some(3), // Default ZSTD level
129        }
130    }
131}
132
133// ============================================================================
134// Parquet Support (when formats feature is enabled)
135// ============================================================================
136
137// Target column name stored inside Parquet files.
138#[cfg(feature = "formats")]
139const PARQUET_TARGET_COLUMN: &str = "__target__";
140
141/// Build column names for a Dataset: use featurenames when available, otherwise
142/// synthesise `feature_0`, `feature_1`, …
143#[cfg(feature = "formats")]
144fn feature_column_names(dataset: &Dataset) -> Vec<String> {
145    let n = dataset.n_features();
146    match &dataset.featurenames {
147        Some(names) if names.len() == n => names.clone(),
148        _ => (0..n).map(|i| format!("feature_{i}")).collect(),
149    }
150}
151
152/// Convert a `ParquetData` (from scirs2-io) back to a `Dataset`.
153///
154/// Columns whose name is `__target__` become the target vector; all others
155/// (in schema order) populate the feature matrix.
156#[cfg(feature = "formats")]
157fn parquet_data_to_dataset(pdata: &scirs2_io::parquet::ParquetData) -> Result<Dataset> {
158    // Use schema().column_names() to preserve the column order from the Arrow schema,
159    // rather than pdata.column_names() which is backed by a HashMap (unordered).
160    let all_columns = pdata.schema().column_names();
161    let n_rows = pdata.num_rows();
162
163    // Separate feature columns from the target column (preserving schema order).
164    let feat_names: Vec<String> = all_columns
165        .iter()
166        .filter(|n| n.as_str() != PARQUET_TARGET_COLUMN)
167        .cloned()
168        .collect();
169
170    if feat_names.is_empty() {
171        return Err(DatasetsError::InvalidFormat(
172            "Parquet file contains no feature columns (only '__target__' found)".to_string(),
173        ));
174    }
175
176    let n_features = feat_names.len();
177
178    // Build feature matrix row-by-row from each column's values.
179    let mut flat: Vec<f64> = Vec::with_capacity(n_rows * n_features);
180    for col_name in &feat_names {
181        let col = pdata.get_column_f64(col_name).map_err(|e| {
182            DatasetsError::InvalidFormat(format!(
183                "Failed to read feature column '{}': {}",
184                col_name, e
185            ))
186        })?;
187        flat.extend(col.iter());
188    }
189
190    // flat is column-major (feature0[row0..rowN], feature1[row0..rowN], …)
191    // Array2::from_shape_vec with shape (n_features, n_rows) then transpose gives (n_rows, n_features).
192    let column_major = Array2::from_shape_vec((n_features, n_rows), flat).map_err(|e| {
193        DatasetsError::InvalidFormat(format!("Failed to shape feature matrix: {e}"))
194    })?;
195    let data = column_major.t().to_owned();
196
197    // Load optional target column.
198    let target: Option<Array1<f64>> = if all_columns
199        .iter()
200        .any(|n| n.as_str() == PARQUET_TARGET_COLUMN)
201    {
202        let col = pdata.get_column_f64(PARQUET_TARGET_COLUMN).map_err(|e| {
203            DatasetsError::InvalidFormat(format!("Failed to read target column: {e}"))
204        })?;
205        Some(Array1::from_vec(col.to_vec()))
206    } else {
207        None
208    };
209
210    let mut ds = Dataset::new(data, target);
211    ds.featurenames = Some(feat_names);
212    Ok(ds)
213}
214
215/// Write a `Dataset` to a Parquet file, building a multi-column RecordBatch.
216///
217/// Each feature column is named from `featurenames` or synthesised as
218/// `feature_N`.  The optional target column is appended as `__target__`.
219#[cfg(feature = "formats")]
220fn write_dataset_parquet<P: AsRef<Path>>(dataset: &Dataset, path: P) -> Result<()> {
221    use arrow::array::Float64Array;
222    use arrow::datatypes::{DataType, Field, Schema};
223    use arrow::record_batch::RecordBatch;
224    use scirs2_io::parquet::{ParquetWriteOptions, ParquetWriter as IoParquetWriter};
225    use std::sync::Arc;
226
227    let col_names = feature_column_names(dataset);
228    let n_rows = dataset.n_samples();
229    let n_feats = dataset.n_features();
230
231    // Build schema fields.
232    let mut fields: Vec<Field> = col_names
233        .iter()
234        .map(|name| Field::new(name.as_str(), DataType::Float64, false))
235        .collect();
236    let has_target = dataset.target.is_some();
237    if has_target {
238        fields.push(Field::new(PARQUET_TARGET_COLUMN, DataType::Float64, false));
239    }
240    let schema = Arc::new(Schema::new(fields));
241
242    // Build Arrow column arrays.
243    let mut arrays: Vec<Arc<dyn arrow::array::Array>> = Vec::with_capacity(n_feats + 1);
244    for col_idx in 0..n_feats {
245        let col_data: Vec<f64> = (0..n_rows)
246            .map(|row| dataset.data[[row, col_idx]])
247            .collect();
248        arrays.push(Arc::new(Float64Array::from(col_data)));
249    }
250    if let Some(target) = &dataset.target {
251        let tgt_data: Vec<f64> = target.to_vec();
252        arrays.push(Arc::new(Float64Array::from(tgt_data)));
253    }
254
255    let batch = RecordBatch::try_new(Arc::clone(&schema), arrays)
256        .map_err(|e| DatasetsError::InvalidFormat(format!("Failed to build RecordBatch: {e}")))?;
257
258    // Write using scirs2-io's ParquetWriter directly.
259    let options = ParquetWriteOptions::default();
260    let mut writer = IoParquetWriter::from_path(path, schema, options)
261        .map_err(|e| DatasetsError::InvalidFormat(format!("Parquet writer creation error: {e}")))?;
262
263    writer
264        .write_batch(&batch)
265        .map_err(|e| DatasetsError::InvalidFormat(format!("Parquet write error: {e}")))?;
266
267    writer
268        .close()
269        .map_err(|e| DatasetsError::InvalidFormat(format!("Parquet close error: {e}")))
270}
271
272#[cfg(feature = "formats")]
273/// Parquet reader for datasets
274pub struct ParquetReader {
275    config: FormatConfig,
276}
277
278#[cfg(feature = "formats")]
279impl ParquetReader {
280    /// Create a new Parquet reader
281    pub fn new() -> Self {
282        Self {
283            config: FormatConfig::default(),
284        }
285    }
286
287    /// Create a Parquet reader with custom configuration
288    pub fn with_config(config: FormatConfig) -> Self {
289        Self { config }
290    }
291
292    /// Read a Parquet file into a Dataset.
293    ///
294    /// Columns named `__target__` are treated as the target vector; all other
295    /// columns (which must be `Float64`) form the feature matrix.
296    pub fn read<P: AsRef<Path>>(&self, path: P) -> Result<Dataset> {
297        let pdata = scirs2_io::parquet::read_parquet(path)
298            .map_err(|e| DatasetsError::InvalidFormat(format!("Parquet read error: {e}")))?;
299        let _ = &self.config; // config reserved for future chunked reads
300        parquet_data_to_dataset(&pdata)
301    }
302}
303
304#[cfg(feature = "formats")]
305impl Default for ParquetReader {
306    fn default() -> Self {
307        Self::new()
308    }
309}
310
311#[cfg(feature = "formats")]
312/// Parquet writer for datasets
313pub struct ParquetWriter {
314    config: FormatConfig,
315}
316
317#[cfg(feature = "formats")]
318impl ParquetWriter {
319    /// Create a new Parquet writer
320    pub fn new() -> Self {
321        Self {
322            config: FormatConfig::default(),
323        }
324    }
325
326    /// Create a Parquet writer with custom configuration
327    pub fn with_config(config: FormatConfig) -> Self {
328        Self { config }
329    }
330
331    /// Write a Dataset to a Parquet file.
332    ///
333    /// Feature columns are named from `featurenames` (or synthesised as
334    /// `feature_N`). The optional target column is stored as `__target__`.
335    pub fn write<P: AsRef<Path>>(&self, dataset: &Dataset, path: P) -> Result<()> {
336        let _ = &self.config; // config reserved for future compression options
337        write_dataset_parquet(dataset, path)
338    }
339}
340
341#[cfg(feature = "formats")]
342impl Default for ParquetWriter {
343    fn default() -> Self {
344        Self::new()
345    }
346}
347
348// ============================================================================
349// HDF5 Support
350// ============================================================================
351
352/// Convert from scirs2-io HDF5 error to DatasetsError.
353#[cfg(feature = "formats")]
354fn hdf5_err(msg: impl std::fmt::Display) -> DatasetsError {
355    DatasetsError::InvalidFormat(format!("HDF5 error: {msg}"))
356}
357
358/// Read a `Dataset` from an HDF5 group structure produced by `write_hdf5_dataset`.
359///
360/// The feature matrix is read from the dataset named `dataset_name`; a
361/// companion dataset `{dataset_name}_target` (if present) becomes the target.
362#[cfg(feature = "formats")]
363fn read_dataset_hdf5<P: AsRef<Path>>(path: P, dataset_name: &str) -> Result<Dataset> {
364    use scirs2_io::hdf5::read_hdf5;
365
366    let root = read_hdf5(path).map_err(hdf5_err)?;
367
368    // Retrieve the feature matrix dataset.
369    let ds = root.datasets.get(dataset_name).ok_or_else(|| {
370        DatasetsError::InvalidFormat(format!("Dataset '{}' not found in HDF5 file", dataset_name))
371    })?;
372
373    let shape = &ds.shape;
374    if shape.len() != 2 {
375        return Err(DatasetsError::InvalidFormat(format!(
376            "Expected 2-D dataset for '{}', got {}-D",
377            dataset_name,
378            shape.len()
379        )));
380    }
381    let n_rows = shape[0];
382    let n_cols = shape[1];
383
384    let float_data = ds.as_float_vec().ok_or_else(|| {
385        DatasetsError::InvalidFormat(format!(
386            "Dataset '{}' contains non-numeric data",
387            dataset_name
388        ))
389    })?;
390
391    let data = Array2::from_shape_vec((n_rows, n_cols), float_data).map_err(|e| {
392        DatasetsError::InvalidFormat(format!("Failed to shape feature matrix: {e}"))
393    })?;
394
395    // Optionally load the companion target dataset.
396    let target_name = format!("{}_target", dataset_name);
397    let target: Option<Array1<f64>> = if let Some(tds) = root.datasets.get(&target_name) {
398        let tvec = tds.as_float_vec().ok_or_else(|| {
399            DatasetsError::InvalidFormat(format!(
400                "Target dataset '{}' contains non-numeric data",
401                target_name
402            ))
403        })?;
404        Some(Array1::from_vec(tvec))
405    } else {
406        None
407    };
408
409    Ok(Dataset::new(data, target))
410}
411
412/// Write a `Dataset` to an HDF5 file.
413///
414/// The feature matrix is stored as a 2-D dataset named `dataset_name`; if
415/// the dataset has a target vector it is stored as `{dataset_name}_target`.
416#[cfg(feature = "formats")]
417fn write_dataset_hdf5<P: AsRef<Path>>(
418    dataset: &Dataset,
419    path: P,
420    dataset_name: &str,
421) -> Result<()> {
422    use scirs2_core::ndarray::IxDyn;
423    use scirs2_io::hdf5::write_hdf5;
424    use std::collections::HashMap;
425
426    let mut map: HashMap<String, scirs2_core::ndarray::ArrayD<f64>> = HashMap::new();
427
428    // Store feature matrix as a flat ArrayD with 2-D shape.
429    let n_rows = dataset.n_samples();
430    let n_cols = dataset.n_features();
431    let flat: Vec<f64> = dataset.data.iter().cloned().collect();
432    let arr_dyn = scirs2_core::ndarray::ArrayD::from_shape_vec(IxDyn(&[n_rows, n_cols]), flat)
433        .map_err(|e| {
434            DatasetsError::InvalidFormat(format!("Failed to convert data to ArrayD: {e}"))
435        })?;
436    map.insert(dataset_name.to_string(), arr_dyn);
437
438    // Store target vector if present.
439    if let Some(target) = &dataset.target {
440        let tvec: Vec<f64> = target.to_vec();
441        let tlen = tvec.len();
442        let tarr =
443            scirs2_core::ndarray::ArrayD::from_shape_vec(IxDyn(&[tlen]), tvec).map_err(|e| {
444                DatasetsError::InvalidFormat(format!("Failed to convert target to ArrayD: {e}"))
445            })?;
446        map.insert(format!("{}_target", dataset_name), tarr);
447    }
448
449    write_hdf5(path, map).map_err(hdf5_err)
450}
451
452#[cfg(feature = "formats")]
453/// HDF5 reader for datasets
454pub struct Hdf5Reader {
455    config: FormatConfig,
456}
457
458#[cfg(feature = "formats")]
459impl Hdf5Reader {
460    /// Create a new HDF5 reader
461    pub fn new() -> Self {
462        Self {
463            config: FormatConfig::default(),
464        }
465    }
466
467    /// Create an HDF5 reader with custom configuration
468    pub fn with_config(config: FormatConfig) -> Self {
469        Self { config }
470    }
471
472    /// Read an HDF5 file into a Dataset.
473    ///
474    /// `dataset_name` is the name of the HDF5 dataset containing the 2-D
475    /// feature matrix.  A companion dataset `{dataset_name}_target` (if
476    /// present) is loaded as the target vector.
477    pub fn read<P: AsRef<Path>>(&self, path: P, dataset_name: &str) -> Result<Dataset> {
478        let _ = &self.config;
479        read_dataset_hdf5(path, dataset_name)
480    }
481}
482
483#[cfg(feature = "formats")]
484impl Default for Hdf5Reader {
485    fn default() -> Self {
486        Self::new()
487    }
488}
489
490#[cfg(feature = "formats")]
491/// HDF5 writer for datasets
492pub struct Hdf5Writer {
493    config: FormatConfig,
494}
495
496#[cfg(feature = "formats")]
497impl Hdf5Writer {
498    /// Create a new HDF5 writer
499    pub fn new() -> Self {
500        Self {
501            config: FormatConfig::default(),
502        }
503    }
504
505    /// Create an HDF5 writer with custom configuration
506    pub fn with_config(config: FormatConfig) -> Self {
507        Self { config }
508    }
509
510    /// Write a Dataset to an HDF5 file.
511    ///
512    /// The feature matrix is stored under `dataset_name`; an optional target
513    /// is stored under `{dataset_name}_target`.
514    pub fn write<P: AsRef<Path>>(
515        &self,
516        dataset: &Dataset,
517        path: P,
518        dataset_name: &str,
519    ) -> Result<()> {
520        let _ = &self.config;
521        write_dataset_hdf5(dataset, path, dataset_name)
522    }
523}
524
525#[cfg(feature = "formats")]
526impl Default for Hdf5Writer {
527    fn default() -> Self {
528        Self::new()
529    }
530}
531
532// ============================================================================
533// CSV Support
534// ============================================================================
535
536/// Configuration for CSV reading/writing.
537#[derive(Debug, Clone)]
538pub struct CsvConfig {
539    /// Whether the first non-blank, non-comment row is a header.
540    /// Default: `true`.
541    pub has_header: bool,
542    /// Character delimiter between fields. Default: `','`.
543    pub delimiter: char,
544    /// Number of significant decimal digits to use when writing f64 values.
545    /// Default: `17` (enough for a lossless round-trip).
546    pub float_precision: usize,
547}
548
549impl Default for CsvConfig {
550    fn default() -> Self {
551        Self {
552            has_header: true,
553            delimiter: ',',
554            float_precision: 17,
555        }
556    }
557}
558
559// ──────────────────────────────────────────────────────────────────────────
560// Low-level CSV text helpers (not feature-gated; used only inside
561// `#[cfg(feature = "formats")]` functions, but declared unconditionally so
562// tests in the `#[cfg(test)]` block can reach them without the feature).
563// ──────────────────────────────────────────────────────────────────────────
564
565/// Commit a completed row into the output table.
566///
567/// Decides whether the row is the header or a data row, skips blank and
568/// comment rows, then appends the row to the appropriate collection.
569///
570/// Returns the updated `first_row` flag.
571fn commit_row(
572    current_field: &mut String,
573    current_row: &mut Vec<String>,
574    out_headers: &mut Vec<String>,
575    out_rows: &mut Vec<Vec<String>>,
576    has_header: bool,
577    first_row: bool,
578) -> bool {
579    // Finalise the pending field.
580    let last_field = current_field.clone();
581    current_field.clear();
582    current_row.push(last_field);
583
584    let is_comment = current_row
585        .first()
586        .map(|f| f.trim_start().starts_with('#'))
587        .unwrap_or(false);
588
589    let is_blank = current_row.iter().all(|f| f.is_empty());
590
591    let new_first_row = if !is_blank && !is_comment {
592        if first_row && has_header {
593            *out_headers = current_row.clone();
594            false
595        } else {
596            out_rows.push(current_row.clone());
597            false
598        }
599    } else {
600        first_row
601    };
602
603    current_row.clear();
604    new_first_row
605}
606
607/// Parse a CSV string into a table of `String` cells.
608///
609/// - Lines beginning with `#` (after optional leading whitespace) are skipped.
610/// - Completely blank lines are skipped.
611/// - Fields enclosed in `"..."` may contain the delimiter and literal `\n`;
612///   internal double-quotes are escaped as `""`.
613///
614/// Returns `(headers, rows)` where `headers` is populated only when
615/// `has_header` is `true` and at least one non-blank line exists.
616pub(crate) fn parse_csv_text(
617    text: &str,
618    has_header: bool,
619    delimiter: char,
620) -> (Vec<String>, Vec<Vec<String>>) {
621    // Single-pass character-level state machine so that multi-line quoted
622    // fields work correctly.
623    let mut out_headers: Vec<String> = Vec::new();
624    let mut out_rows: Vec<Vec<String>> = Vec::new();
625
626    let mut current_field = String::new();
627    let mut current_row: Vec<String> = Vec::new();
628    let mut in_quotes = false;
629    // `first_row` tracks whether the next content row is the first one
630    // (which becomes the header when `has_header` is true).
631    let mut first_row = true;
632
633    let chars: Vec<char> = text.chars().collect();
634    let len = chars.len();
635    let mut i = 0;
636
637    while i < len {
638        let ch = chars[i];
639
640        if in_quotes {
641            if ch == '"' {
642                if i + 1 < len && chars[i + 1] == '"' {
643                    // Escaped double-quote inside a quoted field.
644                    current_field.push('"');
645                    i += 2;
646                } else {
647                    // Closing quote.
648                    in_quotes = false;
649                    i += 1;
650                }
651            } else {
652                // All characters (including newlines) are literal inside quotes.
653                current_field.push(ch);
654                i += 1;
655            }
656        } else {
657            if ch == '"' {
658                in_quotes = true;
659                i += 1;
660            } else if ch == delimiter {
661                current_row.push(current_field.clone());
662                current_field.clear();
663                i += 1;
664            } else if ch == '\n' {
665                first_row = commit_row(
666                    &mut current_field,
667                    &mut current_row,
668                    &mut out_headers,
669                    &mut out_rows,
670                    has_header,
671                    first_row,
672                );
673                i += 1;
674            } else if ch == '\r' {
675                // Skip carriage returns (Windows CRLF).
676                i += 1;
677            } else {
678                current_field.push(ch);
679                i += 1;
680            }
681        }
682    }
683
684    // Handle text that does not end with a newline.
685    if !current_row.is_empty() || !current_field.is_empty() {
686        commit_row(
687            &mut current_field,
688            &mut current_row,
689            &mut out_headers,
690            &mut out_rows,
691            has_header,
692            first_row,
693        );
694    }
695
696    (out_headers, out_rows)
697}
698
699/// Encode a field for CSV output, wrapping in quotes when necessary.
700///
701/// A field must be quoted if it contains the delimiter, a double-quote, or a
702/// newline.  Internal double-quotes are doubled.
703pub(crate) fn csv_encode_field(field: &str, delimiter: char) -> String {
704    let needs_quoting = field.contains(delimiter)
705        || field.contains('"')
706        || field.contains('\n')
707        || field.contains('\r');
708
709    if needs_quoting {
710        let escaped = field.replace('"', "\"\"");
711        format!("\"{escaped}\"")
712    } else {
713        field.to_owned()
714    }
715}
716
717/// Render a table of string rows into a CSV string.
718///
719/// `headers` may be empty (no header row emitted in that case).
720/// Each row is terminated by `\n`.
721pub(crate) fn format_csv_rows(headers: &[String], rows: &[Vec<String>], delimiter: char) -> String {
722    let mut out = String::new();
723
724    if !headers.is_empty() {
725        let encoded: Vec<String> = headers
726            .iter()
727            .map(|h| csv_encode_field(h, delimiter))
728            .collect();
729        out.push_str(&encoded.join(&delimiter.to_string()));
730        out.push('\n');
731    }
732
733    for row in rows {
734        let encoded: Vec<String> = row.iter().map(|f| csv_encode_field(f, delimiter)).collect();
735        out.push_str(&encoded.join(&delimiter.to_string()));
736        out.push('\n');
737    }
738
739    out
740}
741
742// ──────────────────────────────────────────────────────────────────────────
743// Dataset ↔ CSV adapters
744// ──────────────────────────────────────────────────────────────────────────
745
746/// Read a CSV file into a [`Dataset`].
747///
748/// See the [module-level docs](self) for the column-naming conventions.
749#[cfg(feature = "formats")]
750fn read_dataset_csv<P: AsRef<Path>>(path: P, config: &CsvConfig) -> Result<Dataset> {
751    use std::fs;
752
753    let text = fs::read_to_string(path)
754        .map_err(|e| DatasetsError::InvalidFormat(format!("CSV read error: {e}")))?;
755
756    let (headers, rows) = parse_csv_text(&text, config.has_header, config.delimiter);
757
758    // Determine column names: use headers when available, otherwise synthesise.
759    // We need to know the number of columns; take it from the first data row
760    // if headers is empty.
761    let n_cols = if !headers.is_empty() {
762        headers.len()
763    } else if let Some(first) = rows.first() {
764        first.len()
765    } else {
766        // Completely empty file or only comments/blanks.
767        return Err(DatasetsError::InvalidFormat(
768            "CSV file is empty or contains only comments".to_string(),
769        ));
770    };
771
772    // Determine which columns are feature columns and which is the target.
773    let col_names: Vec<String> = if !headers.is_empty() {
774        headers.clone()
775    } else {
776        (0..n_cols).map(|i| format!("feature_{i}")).collect()
777    };
778
779    let target_col_idx: Option<usize> = col_names.iter().position(|n| n == PARQUET_TARGET_COLUMN);
780
781    let feat_indices: Vec<usize> = (0..n_cols).filter(|&i| Some(i) != target_col_idx).collect();
782
783    let feat_names: Vec<String> = feat_indices.iter().map(|&i| col_names[i].clone()).collect();
784
785    let n_features = feat_indices.len();
786    let n_rows = rows.len();
787
788    // Parse data — we support an empty table (header only), returning a
789    // zero-row Dataset.
790    let mut flat: Vec<f64> = Vec::with_capacity(n_rows * n_features);
791    let mut target_vals: Vec<f64> = Vec::with_capacity(n_rows);
792
793    for (row_idx, row) in rows.iter().enumerate() {
794        // Validate column count.
795        if row.len() != n_cols {
796            return Err(DatasetsError::InvalidFormat(format!(
797                "CSV row {} has {} fields, expected {}",
798                row_idx + 1,
799                row.len(),
800                n_cols
801            )));
802        }
803
804        // Parse feature columns.
805        for &col_idx in &feat_indices {
806            let s = row[col_idx].trim();
807            let v = s.parse::<f64>().map_err(|e| {
808                DatasetsError::InvalidFormat(format!(
809                    "CSV cell at row {}, col {} is not numeric ('{s}'): {e}",
810                    row_idx + 1,
811                    col_idx
812                ))
813            })?;
814            flat.push(v);
815        }
816
817        // Parse target column when present.
818        if let Some(t_idx) = target_col_idx {
819            let s = row[t_idx].trim();
820            let v = s.parse::<f64>().map_err(|e| {
821                DatasetsError::InvalidFormat(format!(
822                    "CSV target cell at row {} is not numeric ('{s}'): {e}",
823                    row_idx + 1,
824                ))
825            })?;
826            target_vals.push(v);
827        }
828    }
829
830    let data = if n_rows == 0 {
831        Array2::zeros((0, n_features))
832    } else {
833        Array2::from_shape_vec((n_rows, n_features), flat).map_err(|e| {
834            DatasetsError::InvalidFormat(format!("Failed to shape CSV feature matrix: {e}"))
835        })?
836    };
837
838    let target: Option<Array1<f64>> = if target_col_idx.is_some() && !target_vals.is_empty() {
839        Some(Array1::from_vec(target_vals))
840    } else {
841        None
842    };
843
844    let mut ds = Dataset::new(data, target);
845    ds.featurenames = Some(feat_names);
846    Ok(ds)
847}
848
849/// Write a [`Dataset`] to a CSV file.
850///
851/// Feature columns are named from `featurenames` (or synthesised as
852/// `feature_N`). The optional target column is stored as `__target__`.
853#[cfg(feature = "formats")]
854fn write_dataset_csv<P: AsRef<Path>>(dataset: &Dataset, path: P, config: &CsvConfig) -> Result<()> {
855    use std::fs;
856    use std::io::Write;
857
858    let col_names = feature_column_names(dataset);
859    let n_rows = dataset.n_samples();
860    let n_feats = dataset.n_features();
861    let has_target = dataset.target.is_some();
862    let prec = config.float_precision;
863    let delim = config.delimiter;
864
865    // Build header row.
866    let mut headers: Vec<String> = col_names;
867    if has_target {
868        headers.push(PARQUET_TARGET_COLUMN.to_string());
869    }
870
871    // Build data rows.
872    let mut data_rows: Vec<Vec<String>> = Vec::with_capacity(n_rows);
873    for row_idx in 0..n_rows {
874        let mut fields: Vec<String> = (0..n_feats)
875            .map(|col_idx| format!("{:.prec$e}", dataset.data[[row_idx, col_idx]], prec = prec))
876            .collect();
877        if let Some(target) = &dataset.target {
878            fields.push(format!("{:.prec$e}", target[row_idx], prec = prec));
879        }
880        data_rows.push(fields);
881    }
882
883    let csv_text = format_csv_rows(&headers, &data_rows, delim);
884
885    let mut file = fs::File::create(path)
886        .map_err(|e| DatasetsError::InvalidFormat(format!("CSV create error: {e}")))?;
887    file.write_all(csv_text.as_bytes())
888        .map_err(|e| DatasetsError::InvalidFormat(format!("CSV write error: {e}")))?;
889    file.flush()
890        .map_err(|e| DatasetsError::InvalidFormat(format!("CSV flush error: {e}")))?;
891
892    Ok(())
893}
894
895/// CSV reader for datasets.
896#[cfg(feature = "formats")]
897pub struct CsvReader {
898    config: CsvConfig,
899}
900
901#[cfg(feature = "formats")]
902impl CsvReader {
903    /// Create a new CSV reader with default configuration.
904    pub fn new() -> Self {
905        Self {
906            config: CsvConfig::default(),
907        }
908    }
909
910    /// Create a CSV reader with custom configuration.
911    pub fn with_config(config: CsvConfig) -> Self {
912        Self { config }
913    }
914
915    /// Read a CSV file into a [`Dataset`].
916    pub fn read<P: AsRef<Path>>(&self, path: P) -> Result<Dataset> {
917        read_dataset_csv(path, &self.config)
918    }
919}
920
921#[cfg(feature = "formats")]
922impl Default for CsvReader {
923    fn default() -> Self {
924        Self::new()
925    }
926}
927
928/// CSV writer for datasets.
929#[cfg(feature = "formats")]
930pub struct CsvWriter {
931    config: CsvConfig,
932}
933
934#[cfg(feature = "formats")]
935impl CsvWriter {
936    /// Create a new CSV writer with default configuration.
937    pub fn new() -> Self {
938        Self {
939            config: CsvConfig::default(),
940        }
941    }
942
943    /// Create a CSV writer with custom configuration.
944    pub fn with_config(config: CsvConfig) -> Self {
945        Self { config }
946    }
947
948    /// Write a [`Dataset`] to a CSV file.
949    pub fn write<P: AsRef<Path>>(&self, dataset: &Dataset, path: P) -> Result<()> {
950        write_dataset_csv(dataset, path, &self.config)
951    }
952}
953
954#[cfg(feature = "formats")]
955impl Default for CsvWriter {
956    fn default() -> Self {
957        Self::new()
958    }
959}
960
961// ============================================================================
962// Format Conversion
963// ============================================================================
964
965#[cfg(feature = "formats")]
966/// Convert between different data formats
967pub struct FormatConverter {
968    config: FormatConfig,
969}
970
971#[cfg(feature = "formats")]
972impl FormatConverter {
973    /// Create a new format converter
974    pub fn new() -> Self {
975        Self {
976            config: FormatConfig::default(),
977        }
978    }
979
980    /// Convert a dataset from one format to another
981    pub fn convert<P1: AsRef<Path>, P2: AsRef<Path>>(
982        &self,
983        input_path: P1,
984        input_format: FormatType,
985        output_path: P2,
986        output_format: FormatType,
987    ) -> Result<()> {
988        // Read in input format
989        let dataset = match input_format {
990            FormatType::Parquet => ParquetReader::new().read(input_path)?,
991            FormatType::Hdf5 => Hdf5Reader::new().read(input_path, "data")?,
992            FormatType::Csv => CsvReader::new().read(input_path)?,
993            FormatType::Arrow => {
994                return Err(DatasetsError::InvalidFormat(
995                    "Arrow format not yet supported".to_string(),
996                ))
997            }
998        };
999
1000        // Write in output format
1001        match output_format {
1002            FormatType::Parquet => ParquetWriter::new().write(&dataset, output_path)?,
1003            FormatType::Hdf5 => Hdf5Writer::new().write(&dataset, output_path, "data")?,
1004            FormatType::Csv => CsvWriter::new().write(&dataset, output_path)?,
1005            FormatType::Arrow => {
1006                return Err(DatasetsError::InvalidFormat(
1007                    "Arrow format not yet supported".to_string(),
1008                ))
1009            }
1010        }
1011
1012        Ok(())
1013    }
1014
1015    /// Auto-detect format and read
1016    pub fn read_auto<P: AsRef<Path>>(&self, path: P) -> Result<Dataset> {
1017        let path_str = path
1018            .as_ref()
1019            .to_str()
1020            .ok_or_else(|| DatasetsError::InvalidFormat("Invalid path".to_string()))?;
1021
1022        let format = FormatType::from_extension(path_str)
1023            .ok_or_else(|| DatasetsError::InvalidFormat("Could not detect format".to_string()))?;
1024
1025        match format {
1026            FormatType::Parquet => ParquetReader::new().read(path),
1027            FormatType::Hdf5 => Hdf5Reader::new().read(path, "data"),
1028            FormatType::Csv => CsvReader::new().read(path),
1029            FormatType::Arrow => Err(DatasetsError::InvalidFormat(format!(
1030                "Unsupported format: {:?}",
1031                format
1032            ))),
1033        }
1034    }
1035}
1036
1037#[cfg(feature = "formats")]
1038impl Default for FormatConverter {
1039    fn default() -> Self {
1040        Self::new()
1041    }
1042}
1043
1044// ============================================================================
1045// Convenience Functions
1046// ============================================================================
1047
1048/// Read a Parquet file
1049#[cfg(feature = "formats")]
1050pub fn read_parquet<P: AsRef<Path>>(path: P) -> Result<Dataset> {
1051    ParquetReader::new().read(path)
1052}
1053
1054/// Write a Parquet file
1055#[cfg(feature = "formats")]
1056pub fn write_parquet<P: AsRef<Path>>(dataset: &Dataset, path: P) -> Result<()> {
1057    ParquetWriter::new().write(dataset, path)
1058}
1059
1060/// Read an HDF5 file
1061#[cfg(feature = "formats")]
1062pub fn read_hdf5<P: AsRef<Path>>(path: P, dataset_name: &str) -> Result<Dataset> {
1063    Hdf5Reader::new().read(path, dataset_name)
1064}
1065
1066/// Write an HDF5 file
1067#[cfg(feature = "formats")]
1068pub fn write_hdf5<P: AsRef<Path>>(dataset: &Dataset, path: P, dataset_name: &str) -> Result<()> {
1069    Hdf5Writer::new().write(dataset, path, dataset_name)
1070}
1071
1072/// Auto-detect format and read
1073#[cfg(feature = "formats")]
1074pub fn read_auto<P: AsRef<Path>>(path: P) -> Result<Dataset> {
1075    FormatConverter::new().read_auto(path)
1076}
1077
1078/// Read a CSV file into a [`Dataset`] using the default [`CsvConfig`].
1079#[cfg(feature = "formats")]
1080pub fn read_csv<P: AsRef<Path>>(path: P) -> Result<Dataset> {
1081    CsvReader::new().read(path)
1082}
1083
1084/// Write a [`Dataset`] to a CSV file using the default [`CsvConfig`].
1085#[cfg(feature = "formats")]
1086pub fn write_csv<P: AsRef<Path>>(dataset: &Dataset, path: P) -> Result<()> {
1087    CsvWriter::new().write(dataset, path)
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092    use super::*;
1093
1094    #[test]
1095    fn test_format_detection() {
1096        assert_eq!(
1097            FormatType::from_extension("data.parquet"),
1098            Some(FormatType::Parquet)
1099        );
1100        assert_eq!(
1101            FormatType::from_extension("data.h5"),
1102            Some(FormatType::Hdf5)
1103        );
1104        assert_eq!(
1105            FormatType::from_extension("data.csv"),
1106            Some(FormatType::Csv)
1107        );
1108        assert_eq!(FormatType::from_extension("data.txt"), None);
1109    }
1110
1111    #[test]
1112    fn test_format_extension() {
1113        assert_eq!(FormatType::Parquet.extension(), "parquet");
1114        assert_eq!(FormatType::Hdf5.extension(), "h5");
1115        assert_eq!(FormatType::Csv.extension(), "csv");
1116    }
1117
1118    #[test]
1119    fn test_compression_codec() {
1120        assert_eq!(CompressionCodec::None.level(), None);
1121        assert_eq!(CompressionCodec::Snappy.level(), None);
1122        assert_eq!(CompressionCodec::Gzip.level(), Some(6));
1123        assert_eq!(CompressionCodec::Zstd.level(), Some(3));
1124    }
1125
1126    #[test]
1127    fn test_format_config() {
1128        let config = FormatConfig::default();
1129        assert_eq!(config.chunk_size, 10_000);
1130        assert_eq!(config.compression, Some(CompressionCodec::Snappy));
1131        assert!(config.use_mmap);
1132    }
1133
1134    // -----------------------------------------------------------------------
1135    // Parquet round-trip tests
1136    // -----------------------------------------------------------------------
1137
1138    /// Write a Dataset to Parquet and read it back; verify shape and values.
1139    #[cfg(feature = "formats")]
1140    #[test]
1141    fn test_parquet_roundtrip_no_target() {
1142        use scirs2_core::ndarray::Array2;
1143        let data = Array2::from_shape_vec(
1144            (4, 3),
1145            vec![
1146                1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
1147            ],
1148        )
1149        .expect("shape");
1150        let ds = Dataset::new(data.clone(), None);
1151
1152        let mut tmp = std::env::temp_dir();
1153        tmp.push("scirs2_test_parquet_roundtrip_no_target.parquet");
1154
1155        write_parquet(&ds, &tmp).expect("parquet write");
1156        let recovered = read_parquet(&tmp).expect("parquet read");
1157
1158        assert_eq!(recovered.n_samples(), 4, "n_samples mismatch");
1159        assert_eq!(recovered.n_features(), 3, "n_features mismatch");
1160        assert!(recovered.target.is_none(), "unexpected target");
1161
1162        // Verify values element-wise (within f64 precision).
1163        for row in 0..4 {
1164            for col in 0..3 {
1165                let expected = data[[row, col]];
1166                let actual = recovered.data[[row, col]];
1167                assert!(
1168                    (expected - actual).abs() < 1e-10,
1169                    "mismatch at [{row},{col}]: expected {expected}, got {actual}"
1170                );
1171            }
1172        }
1173
1174        let _ = std::fs::remove_file(&tmp);
1175    }
1176
1177    /// Write a Dataset with target to Parquet and read it back.
1178    #[cfg(feature = "formats")]
1179    #[test]
1180    fn test_parquet_roundtrip_with_target() {
1181        use scirs2_core::ndarray::{Array1, Array2};
1182        let data =
1183            Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).expect("shape");
1184        let target = Some(Array1::from_vec(vec![0.0, 1.0, 0.0]));
1185        let ds = Dataset::new(data.clone(), target.clone());
1186
1187        let mut tmp = std::env::temp_dir();
1188        tmp.push("scirs2_test_parquet_roundtrip_with_target.parquet");
1189
1190        write_parquet(&ds, &tmp).expect("parquet write");
1191        let recovered = read_parquet(&tmp).expect("parquet read");
1192
1193        assert_eq!(recovered.n_samples(), 3);
1194        assert_eq!(recovered.n_features(), 2);
1195        assert!(
1196            recovered.target.is_some(),
1197            "target missing after round-trip"
1198        );
1199
1200        let rtarget = recovered.target.as_ref().expect("target");
1201        assert_eq!(rtarget.len(), 3);
1202        for (i, (&expected, &actual)) in target
1203            .as_ref()
1204            .expect("t")
1205            .iter()
1206            .zip(rtarget.iter())
1207            .enumerate()
1208        {
1209            assert!(
1210                (expected - actual).abs() < 1e-10,
1211                "target mismatch at [{i}]: expected {expected}, got {actual}"
1212            );
1213        }
1214
1215        let _ = std::fs::remove_file(&tmp);
1216    }
1217
1218    /// Feature names survive the Parquet round-trip.
1219    #[cfg(feature = "formats")]
1220    #[test]
1221    fn test_parquet_roundtrip_feature_names() {
1222        use scirs2_core::ndarray::Array2;
1223        let data = Array2::from_shape_vec((2, 2), vec![10.0, 20.0, 30.0, 40.0]).expect("shape");
1224        let mut ds = Dataset::new(data, None);
1225        ds.featurenames = Some(vec!["alpha".to_string(), "beta".to_string()]);
1226
1227        let mut tmp = std::env::temp_dir();
1228        tmp.push("scirs2_test_parquet_feature_names.parquet");
1229
1230        write_parquet(&ds, &tmp).expect("parquet write");
1231        let recovered = read_parquet(&tmp).expect("parquet read");
1232
1233        let names = recovered.featurenames.as_ref().expect("featurenames");
1234        assert_eq!(names, &["alpha", "beta"]);
1235
1236        let _ = std::fs::remove_file(&tmp);
1237    }
1238
1239    // -----------------------------------------------------------------------
1240    // HDF5 round-trip tests
1241    // -----------------------------------------------------------------------
1242
1243    /// Write a Dataset to HDF5 and read it back.
1244    #[cfg(feature = "formats")]
1245    #[test]
1246    fn test_hdf5_roundtrip_no_target() {
1247        use scirs2_core::ndarray::Array2;
1248        let data = Array2::from_shape_vec(
1249            (3, 4),
1250            vec![
1251                1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
1252            ],
1253        )
1254        .expect("shape");
1255        let ds = Dataset::new(data.clone(), None);
1256
1257        let mut tmp = std::env::temp_dir();
1258        tmp.push("scirs2_test_hdf5_roundtrip_no_target.h5");
1259
1260        write_hdf5(&ds, &tmp, "mydata").expect("hdf5 write");
1261        let recovered = read_hdf5(&tmp, "mydata").expect("hdf5 read");
1262
1263        assert_eq!(recovered.n_samples(), 3, "n_samples mismatch");
1264        assert_eq!(recovered.n_features(), 4, "n_features mismatch");
1265        assert!(recovered.target.is_none());
1266
1267        for row in 0..3 {
1268            for col in 0..4 {
1269                let expected = data[[row, col]];
1270                let actual = recovered.data[[row, col]];
1271                assert!(
1272                    (expected - actual).abs() < 1e-10,
1273                    "mismatch [{row},{col}]: {expected} != {actual}"
1274                );
1275            }
1276        }
1277
1278        let _ = std::fs::remove_file(&tmp);
1279        // The no-hdf5-feature path also creates a sidecar JSON.
1280        let sidecar = format!("{}.json", tmp.to_string_lossy());
1281        let _ = std::fs::remove_file(&sidecar);
1282    }
1283
1284    // -----------------------------------------------------------------------
1285    // CSV round-trip and correctness tests
1286    // -----------------------------------------------------------------------
1287
1288    /// Low-level: parse_csv_text basic smoke test (no feature gate needed).
1289    #[test]
1290    fn test_csv_parse_roundtrip_strings() {
1291        // Headers + 3 data rows.
1292        let text = "a,b,c\n1,2,3\n4,5,6\n7,8,9\n";
1293        let (headers, rows) = parse_csv_text(text, true, ',');
1294        assert_eq!(headers, ["a", "b", "c"]);
1295        assert_eq!(rows.len(), 3);
1296        assert_eq!(rows[0], ["1", "2", "3"]);
1297        assert_eq!(rows[2], ["7", "8", "9"]);
1298    }
1299
1300    /// Low-level: quoted field containing a comma survives the round-trip.
1301    #[test]
1302    fn test_csv_quoted_field_with_comma() {
1303        // Build a row where one field contains a comma.
1304        let original_field = "hello, world";
1305        let headers = vec!["phrase".to_string(), "num".to_string()];
1306        let rows = vec![vec![original_field.to_string(), "42".to_string()]];
1307        let csv_text = format_csv_rows(&headers, &rows, ',');
1308
1309        // The generated text must quote the comma-containing field.
1310        assert!(csv_text.contains('"'), "expected quoting in: {csv_text:?}");
1311
1312        // Parse it back.
1313        let (h2, r2) = parse_csv_text(&csv_text, true, ',');
1314        assert_eq!(h2, ["phrase", "num"]);
1315        assert_eq!(r2.len(), 1);
1316        assert_eq!(r2[0][0], original_field, "quoted field not preserved");
1317        assert_eq!(r2[0][1], "42");
1318    }
1319
1320    /// Low-level: comment lines and blank lines are skipped.
1321    #[test]
1322    fn test_csv_skip_comments_and_blanks() {
1323        let text = "# file-level comment\n\nx,y\n# row comment\n1,2\n\n3,4\n";
1324        let (headers, rows) = parse_csv_text(text, true, ',');
1325        assert_eq!(headers, ["x", "y"]);
1326        assert_eq!(rows.len(), 2);
1327        assert_eq!(rows[0], ["1", "2"]);
1328        assert_eq!(rows[1], ["3", "4"]);
1329    }
1330
1331    /// Dataset round-trip: write 3-column 5-row CSV, read back, verify values.
1332    #[cfg(feature = "formats")]
1333    #[test]
1334    fn test_csv_dataset_roundtrip_3col_5row() {
1335        use scirs2_core::ndarray::Array2;
1336
1337        let vals: Vec<f64> = (0..15).map(|x| x as f64 * 1.1).collect();
1338        let data = Array2::from_shape_vec((5, 3), vals).expect("shape");
1339        let ds = Dataset::new(data.clone(), None);
1340
1341        let mut tmp = std::env::temp_dir();
1342        tmp.push("scirs2_test_csv_roundtrip_3col_5row.csv");
1343
1344        write_csv(&ds, &tmp).expect("csv write");
1345        let recovered = read_csv(&tmp).expect("csv read");
1346
1347        assert_eq!(recovered.n_samples(), 5, "n_samples mismatch");
1348        assert_eq!(recovered.n_features(), 3, "n_features mismatch");
1349        assert!(recovered.target.is_none());
1350
1351        for row in 0..5 {
1352            for col in 0..3 {
1353                let expected = data[[row, col]];
1354                let actual = recovered.data[[row, col]];
1355                assert!(
1356                    (expected - actual).abs() < 1e-10,
1357                    "mismatch at [{row},{col}]: expected {expected}, got {actual}"
1358                );
1359            }
1360        }
1361
1362        let _ = std::fs::remove_file(&tmp);
1363    }
1364
1365    /// Dataset round-trip: quoted field (target name is the sentinel).
1366    /// This test exercises write + read of the `__target__` column.
1367    #[cfg(feature = "formats")]
1368    #[test]
1369    fn test_csv_dataset_roundtrip_with_target() {
1370        use scirs2_core::ndarray::{Array1, Array2};
1371
1372        let data =
1373            Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).expect("shape");
1374        let target = Some(Array1::from_vec(vec![0.0, 1.0, 0.0]));
1375        let ds = Dataset::new(data.clone(), target.clone());
1376
1377        let mut tmp = std::env::temp_dir();
1378        tmp.push("scirs2_test_csv_roundtrip_with_target.csv");
1379
1380        write_csv(&ds, &tmp).expect("csv write");
1381        let recovered = read_csv(&tmp).expect("csv read");
1382
1383        assert_eq!(recovered.n_samples(), 3);
1384        assert_eq!(recovered.n_features(), 2);
1385        assert!(
1386            recovered.target.is_some(),
1387            "target missing after CSV round-trip"
1388        );
1389
1390        let rtarget = recovered.target.as_ref().expect("target");
1391        assert_eq!(rtarget.len(), 3);
1392        for (i, (&e, &a)) in target
1393            .as_ref()
1394            .expect("target")
1395            .iter()
1396            .zip(rtarget.iter())
1397            .enumerate()
1398        {
1399            assert!(
1400                (e - a).abs() < 1e-10,
1401                "target mismatch at [{i}]: expected {e}, got {a}"
1402            );
1403        }
1404
1405        let _ = std::fs::remove_file(&tmp);
1406    }
1407
1408    /// Float precision: values round-trip within 1e-10.
1409    #[cfg(feature = "formats")]
1410    #[test]
1411    fn test_csv_float_precision() {
1412        use scirs2_core::ndarray::Array2;
1413
1414        // Use values that cannot be represented exactly in decimal with few
1415        // digits, to exercise the full-precision write path.
1416        let vals = vec![
1417            std::f64::consts::PI,
1418            std::f64::consts::E,
1419            1.0 / 3.0,
1420            1.0 / 7.0,
1421            std::f64::consts::SQRT_2,
1422            0.1 + 0.2, // classic floating-point non-representable sum
1423        ];
1424        let data = Array2::from_shape_vec((2, 3), vals.clone()).expect("shape");
1425        let ds = Dataset::new(data, None);
1426
1427        let mut tmp = std::env::temp_dir();
1428        tmp.push("scirs2_test_csv_float_precision.csv");
1429
1430        write_csv(&ds, &tmp).expect("csv write");
1431        let recovered = read_csv(&tmp).expect("csv read");
1432
1433        assert_eq!(recovered.n_samples(), 2);
1434        assert_eq!(recovered.n_features(), 3);
1435
1436        for (idx, &original) in vals.iter().enumerate() {
1437            let row = idx / 3;
1438            let col = idx % 3;
1439            let got = recovered.data[[row, col]];
1440            assert!(
1441                (original - got).abs() < 1e-10,
1442                "float precision failure at [{row},{col}]: original={original}, got={got}"
1443            );
1444        }
1445
1446        let _ = std::fs::remove_file(&tmp);
1447    }
1448
1449    /// Empty / header-only CSV: must not panic; returns zero-row Dataset.
1450    #[cfg(feature = "formats")]
1451    #[test]
1452    fn test_csv_header_only_no_panic() {
1453        let mut tmp = std::env::temp_dir();
1454        tmp.push("scirs2_test_csv_header_only.csv");
1455
1456        // Write a header-only CSV manually.
1457        std::fs::write(&tmp, "feature_0,feature_1,feature_2\n").expect("write header-only csv");
1458
1459        let recovered = read_csv(&tmp).expect("csv read must not fail on header-only input");
1460        assert_eq!(recovered.n_samples(), 0, "expected zero rows");
1461        assert_eq!(recovered.n_features(), 3, "expected 3 feature columns");
1462        assert!(recovered.target.is_none());
1463
1464        let _ = std::fs::remove_file(&tmp);
1465    }
1466
1467    /// Write a Dataset with target to HDF5 and read it back.
1468    #[cfg(feature = "formats")]
1469    #[test]
1470    fn test_hdf5_roundtrip_with_target() {
1471        use scirs2_core::ndarray::{Array1, Array2};
1472        let data =
1473            Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).expect("shape");
1474        let target = Some(Array1::from_vec(vec![1.0, 0.0]));
1475        let ds = Dataset::new(data.clone(), target.clone());
1476
1477        let mut tmp = std::env::temp_dir();
1478        tmp.push("scirs2_test_hdf5_roundtrip_with_target.h5");
1479
1480        write_hdf5(&ds, &tmp, "experiment").expect("hdf5 write");
1481        let recovered = read_hdf5(&tmp, "experiment").expect("hdf5 read");
1482
1483        assert_eq!(recovered.n_samples(), 2);
1484        assert_eq!(recovered.n_features(), 3);
1485        assert!(recovered.target.is_some());
1486
1487        let rtarget = recovered.target.as_ref().expect("target");
1488        assert_eq!(rtarget.len(), 2);
1489        for (i, (&e, &a)) in target
1490            .as_ref()
1491            .expect("t")
1492            .iter()
1493            .zip(rtarget.iter())
1494            .enumerate()
1495        {
1496            assert!((e - a).abs() < 1e-10, "target mismatch [{i}]: {e} != {a}");
1497        }
1498
1499        let _ = std::fs::remove_file(&tmp);
1500        let sidecar = format!("{}.json", tmp.to_string_lossy());
1501        let _ = std::fs::remove_file(&sidecar);
1502    }
1503}