This is an automated email from the ASF dual-hosted git repository.

rich7420 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/mahout.git


The following commit(s) were added to refs/heads/main by this push:
     new 8379a84ec [Feature][QDP] Pipeline file load respects 
PipelineConfig.dtype (#1407)
8379a84ec is described below

commit 8379a84eca341bf664cb8ff30e544b821da52b6c
Author: ChenChen Lai <[email protected]>
AuthorDate: Fri Jun 26 22:22:54 2026 +0800

    [Feature][QDP] Pipeline file load respects PipelineConfig.dtype (#1407)
    
    * [Feature][QDP] Pipeline file load respects PipelineConfig.dtype
    
    * [Feature][QDP] Pipeline file load respects PipelineConfig.dtype
    
    * address comment
---
 qdp/qdp-core/src/pipeline_runner.rs | 731 +++++++++++++++++++++++++++++++-----
 qdp/qdp-python/qumat_qdp/loader.py  |  32 ++
 qdp/qdp-python/src/engine.rs        |  20 +-
 qdp/qdp-python/src/loader.rs        |  17 +-
 4 files changed, 701 insertions(+), 99 deletions(-)

diff --git a/qdp/qdp-core/src/pipeline_runner.rs 
b/qdp/qdp-core/src/pipeline_runner.rs
index 71b95d5d1..e9374dbf2 100644
--- a/qdp/qdp-core/src/pipeline_runner.rs
+++ b/qdp/qdp-core/src/pipeline_runner.rs
@@ -26,7 +26,7 @@ use crate::dlpack::DLManagedTensor;
 use crate::error::{MahoutError, Result};
 use crate::gpu::memory::Precision;
 use crate::io;
-use crate::reader::{NullHandling, StreamingDataReader};
+use crate::reader::{FloatElem, NullHandling, StreamingDataReader};
 use crate::readers::ParquetStreamingReader;
 use crate::types::Encoding;
 
@@ -116,6 +116,45 @@ pub enum BatchData {
     F64(Vec<f64>),
 }
 
+impl BatchData {
+    #[allow(clippy::len_without_is_empty)]
+    pub fn len(&self) -> usize {
+        match self {
+            Self::F32(v) => v.len(),
+            Self::F64(v) => v.len(),
+        }
+    }
+}
+
+pub(crate) trait ToBatchData: FloatElem {
+    fn wrap(v: Vec<Self>) -> BatchData;
+    fn from_recycled(b: BatchData) -> Option<Vec<Self>>;
+}
+
+impl ToBatchData for f32 {
+    fn wrap(v: Vec<f32>) -> BatchData {
+        BatchData::F32(v)
+    }
+    fn from_recycled(b: BatchData) -> Option<Vec<f32>> {
+        match b {
+            BatchData::F32(v) => Some(v),
+            _ => None,
+        }
+    }
+}
+
+impl ToBatchData for f64 {
+    fn wrap(v: Vec<f64>) -> BatchData {
+        BatchData::F64(v)
+    }
+    fn from_recycled(b: BatchData) -> Option<Vec<f64>> {
+        match b {
+            BatchData::F64(v) => Some(v),
+            _ => None,
+        }
+    }
+}
+
 pub struct PrefetchedBatch {
     pub data: BatchData,
     pub batch_n: usize,
@@ -232,8 +271,8 @@ impl BatchProducer for SyntheticProducer {
     }
 }
 
-pub struct InMemoryProducer {
-    pub data: Vec<f64>,
+pub struct InMemoryProducer<T: FloatElem = f64> {
+    pub data: Vec<T>,
     pub cursor: usize,
     pub sample_size: usize,
     pub batch_size: usize,
@@ -242,7 +281,7 @@ pub struct InMemoryProducer {
     pub batch_limit: usize,
 }
 
-impl BatchProducer for InMemoryProducer {
+impl<T: FloatElem + ToBatchData> BatchProducer for InMemoryProducer<T> {
     fn produce(&mut self, recycled: Option<BatchData>) -> 
Result<Option<PrefetchedBatch>> {
         if self.batches_yielded >= self.batch_limit {
             return Ok(None);
@@ -259,13 +298,13 @@ impl BatchProducer for InMemoryProducer {
         self.batches_yielded += 1;
         let slice = &self.data[start..end];
 
-        let data = match recycled {
-            Some(BatchData::F64(mut buf)) => {
+        let data = match recycled.and_then(T::from_recycled) {
+            Some(mut buf) => {
                 buf.clear();
                 buf.extend_from_slice(slice);
-                BatchData::F64(buf)
+                T::wrap(buf)
             }
-            _ => BatchData::F64(slice.to_vec()),
+            None => T::wrap(slice.to_vec()),
         };
 
         Ok(Some(PrefetchedBatch {
@@ -277,11 +316,11 @@ impl BatchProducer for InMemoryProducer {
     }
 }
 
-pub struct StreamingProducer {
-    pub reader: ParquetStreamingReader,
-    pub buffer: Vec<f64>,
+pub struct StreamingProducer<T: FloatElem = f64> {
+    pub reader: ParquetStreamingReader<T>,
+    pub buffer: Vec<T>,
     pub buffer_cursor: usize,
-    pub read_chunk_scratch: Vec<f64>,
+    pub read_chunk_scratch: Vec<T>,
     pub sample_size: usize,
     pub batch_size: usize,
     pub num_qubits: usize,
@@ -289,7 +328,7 @@ pub struct StreamingProducer {
     pub batch_limit: usize,
 }
 
-impl BatchProducer for StreamingProducer {
+impl<T: FloatElem + ToBatchData> BatchProducer for StreamingProducer<T> {
     fn produce(&mut self, recycled: Option<BatchData>) -> 
Result<Option<PrefetchedBatch>> {
         if self.batches_yielded >= self.batch_limit {
             return Ok(None);
@@ -316,13 +355,13 @@ impl BatchProducer for StreamingProducer {
         self.buffer_cursor = end;
         self.batches_yielded += 1;
 
-        let data = match recycled {
-            Some(BatchData::F64(mut buf)) => {
+        let data = match recycled.and_then(T::from_recycled) {
+            Some(mut buf) => {
                 buf.clear();
                 buf.extend_from_slice(&self.buffer[start..end]);
-                BatchData::F64(buf)
+                T::wrap(buf)
             }
-            _ => BatchData::F64(self.buffer[start..end].to_vec()),
+            None => T::wrap(self.buffer[start..end].to_vec()),
         };
 
         if self.buffer_cursor >= self.buffer.len() / BUFFER_COMPACT_DENOM {
@@ -389,33 +428,106 @@ fn path_extension_lower(path: &Path) -> Option<String> {
         .map(|s| s.to_lowercase())
 }
 
-/// Dispatches by path extension to the appropriate io reader. Returns (data, 
num_samples, sample_size).
-/// Unsupported or missing extension returns Err with message listing 
supported formats.
+/// Largest integer exactly representable by an f32 (its mantissa is 24 bits): 
`2^24`.
+/// Basis indices above this would silently change value when narrowed to f32.
+const MAX_EXACT_F32_INT: f64 = (1u64 << 24) as f64;
+
+/// Reject an explicit f32 request for a basis file whose indices exceed f32's 
exact
+/// integer range. Basis values are integer state indices, not floats, so 
narrowing
+/// `16_777_217` to `16_777_216` would encode the wrong state. Rather than 
silently
+/// corrupt (or silently widen back to f64), surface the conflict to the 
caller.
+fn reject_basis_f32_out_of_range(data: &BatchData) -> Result<()> {
+    if let BatchData::F64(v) = data
+        && let Some(&bad) = v.iter().find(|&&x| x > MAX_EXACT_F32_INT)
+    {
+        return Err(MahoutError::InvalidInput(format!(
+            "basis index {bad:.0} exceeds f32's exact integer range 
({MAX_EXACT_F32_INT:.0}); \
+             narrowing to f32 would encode the wrong state. Use 
dtype='float64' for this basis file."
+        )));
+    }
+    Ok(())
+}
+
+/// f64→f32 narrowing cast: values outside f32 range silently become ±Inf.
+fn cast_f64_to_batch_data(
+    data: Vec<f64>,
+    n: usize,
+    s: usize,
+    dtype: Precision,
+    fmt: &str,
+) -> (BatchData, usize, usize) {
+    if matches!(dtype, Precision::Float32) {
+        log::warn!(
+            "{fmt} file loaded as f64, casting to f32: values outside f32 
range become ±Inf."
+        );
+        (
+            BatchData::F32(data.iter().map(|&x| x as f32).collect()),
+            n,
+            s,
+        )
+    } else {
+        (BatchData::F64(data), n, s)
+    }
+}
+
 fn read_file_by_extension(
     path: &Path,
     null_handling: NullHandling,
-) -> Result<(Vec<f64>, usize, usize)> {
+    dtype: Precision,
+    encoding: Encoding,
+) -> Result<(BatchData, usize, usize)> {
+    use crate::reader::DataReader;
+    // Basis values are integer state indices, not floats; f32's 24-bit 
mantissa
+    // corrupts indices above 2^24. Always read basis as f64, then reject an 
explicit
+    // f32 request whose indices would not survive the narrowing (see below).
+    let basis = matches!(encoding, Encoding::Basis);
+    let read_dtype = if basis { Precision::Float64 } else { dtype };
     let ext_lower = path_extension_lower(path);
     let ext = ext_lower.as_deref();
-    match ext {
+    let result = match ext {
         Some("parquet") => {
-            use crate::reader::DataReader;
-            let mut reader = crate::readers::ParquetReader::new(path, None, 
null_handling)?;
-            reader.read_batch()
+            if matches!(read_dtype, Precision::Float32) {
+                let mut reader =
+                    crate::readers::ParquetReader::<f32>::new(path, None, 
null_handling)?;
+                let (data, n, s) = reader.read_batch()?;
+                (BatchData::F32(data), n, s)
+            } else {
+                let mut reader =
+                    crate::readers::ParquetReader::<f64>::new(path, None, 
null_handling)?;
+                let (data, n, s) = reader.read_batch()?;
+                (BatchData::F64(data), n, s)
+            }
         }
         Some("arrow") | Some("feather") | Some("ipc") => {
-            use crate::reader::DataReader;
             let mut reader = crate::readers::ArrowIPCReader::new(path, 
null_handling)?;
-            reader.read_batch()
+            let (data, n, s) = reader.read_batch()?;
+            cast_f64_to_batch_data(data, n, s, read_dtype, "Arrow IPC")
         }
-        Some("npy") => io::read_numpy_batch(path),
-        Some("pt") | Some("pth") => io::read_torch_batch(path),
-        Some("pb") => io::read_tensorflow_batch(path),
-        _ => Err(MahoutError::InvalidInput(format!(
-            "Unsupported file extension {:?}. Supported: .parquet, .arrow, 
.feather, .ipc, .npy, .pt, .pth, .pb",
-            path.extension()
-        ))),
+        Some("npy") => {
+            let (data, n, s) = io::read_numpy_batch(path)?;
+            cast_f64_to_batch_data(data, n, s, read_dtype, "NumPy")
+        }
+        Some("pt") | Some("pth") => {
+            let (data, n, s) = io::read_torch_batch(path)?;
+            cast_f64_to_batch_data(data, n, s, read_dtype, "PyTorch")
+        }
+        Some("pb") => {
+            let (data, n, s) = io::read_tensorflow_batch(path)?;
+            cast_f64_to_batch_data(data, n, s, read_dtype, "TensorFlow")
+        }
+        _ => {
+            return Err(MahoutError::InvalidInput(format!(
+                "Unsupported file extension {:?}. Supported: .parquet, .arrow, 
.feather, .ipc, .npy, .pt, .pth, .pb",
+                path.extension()
+            )));
+        }
+    };
+    // basis is always read as f64 above; reject the f32 request only when the 
indices
+    // would actually have been corrupted by the narrowing the caller asked 
for.
+    if basis && matches!(dtype, Precision::Float32) {
+        reject_basis_f32_out_of_range(&result.0)?;
     }
+    Ok(result)
 }
 
 /// Stateful iterator that yields one batch DLPack at a time for Python `for` 
loop consumption.
@@ -450,6 +562,89 @@ impl Drop for PipelineIterator {
     }
 }
 
+/// Spawn an in-memory producer over already-loaded `data`. Mirrors
+/// [`build_streaming_producer`] so the f32/f64 dispatch in `new_from_file` is 
a single
+/// generic call instead of two byte-for-byte-identical match arms.
+fn build_inmemory_producer<T>(
+    data: Vec<T>,
+    sample_size: usize,
+    config: &PipelineConfig,
+    batch_limit: usize,
+) -> Result<ProducerHandles>
+where
+    T: FloatElem + ToBatchData,
+{
+    spawn_producer(
+        InMemoryProducer::<T> {
+            data,
+            cursor: 0,
+            sample_size,
+            batch_size: config.batch_size,
+            num_qubits: config.num_qubits as usize,
+            batches_yielded: 0,
+            batch_limit,
+        },
+        config.prefetch_depth,
+    )
+}
+
+fn build_streaming_producer<T>(
+    path: &Path,
+    config: &PipelineConfig,
+    batch_limit: usize,
+) -> Result<ProducerHandles>
+where
+    T: FloatElem + ToBatchData,
+{
+    let mut reader = ParquetStreamingReader::<T>::new(
+        path,
+        Some(DEFAULT_PARQUET_ROW_GROUP_SIZE),
+        config.null_handling,
+    )?;
+    let vector_len = vector_len(config.num_qubits, config.encoding);
+
+    const INITIAL_CHUNK_CAP: usize = 64 * 1024;
+    // Buffer must hold at least one complete sample; for amplitude encoding 
with 17+ qubits
+    // vector_len (2^n) exceeds INITIAL_CHUNK_CAP and read_chunk would return 
0 on a valid file.
+    let initial_cap = INITIAL_CHUNK_CAP.max(vector_len);
+    let mut buffer = vec![T::default(); initial_cap];
+    let written = reader.read_chunk(&mut buffer)?;
+    if written == 0 {
+        return Err(MahoutError::InvalidInput(
+            "Parquet file is empty or contains no data.".to_string(),
+        ));
+    }
+    let sample_size = reader.get_sample_size().ok_or_else(|| {
+        MahoutError::InvalidInput(
+            "Parquet streaming reader did not set sample_size after first 
chunk.".to_string(),
+        )
+    })?;
+    if sample_size != vector_len {
+        return Err(MahoutError::InvalidInput(format!(
+            "File feature length {} does not match vector_len {} for 
num_qubits={}, encoding={}",
+            sample_size,
+            vector_len,
+            config.num_qubits,
+            config.encoding.as_str()
+        )));
+    }
+
+    buffer.truncate(written);
+    let read_chunk_scratch = vec![T::default(); initial_cap];
+    let producer = StreamingProducer::<T> {
+        reader,
+        buffer,
+        buffer_cursor: 0,
+        read_chunk_scratch,
+        sample_size,
+        batch_size: config.batch_size,
+        num_qubits: config.num_qubits as usize,
+        batches_yielded: 0,
+        batch_limit,
+    };
+    spawn_producer(producer, config.prefetch_depth)
+}
+
 impl PipelineIterator {
     pub fn new_synthetic(engine: QdpEngine, mut config: PipelineConfig) -> 
Result<Self> {
         config.normalize();
@@ -479,10 +674,11 @@ impl PipelineIterator {
     ) -> Result<Self> {
         config.normalize();
         let path = path.as_ref();
-        let (data, num_samples, sample_size) = read_file_by_extension(path, 
config.null_handling)?;
+        let (batch_data, num_samples, sample_size) =
+            read_file_by_extension(path, config.null_handling, config.dtype, 
config.encoding)?;
         let vector_len = vector_len(config.num_qubits, config.encoding);
 
-        // Dimension validation at construction.
+        // Dimension validation before moving batch_data.
         if sample_size != vector_len {
             return Err(MahoutError::InvalidInput(format!(
                 "File feature length {} does not match vector_len {} for 
num_qubits={}, encoding={}",
@@ -492,26 +688,23 @@ impl PipelineIterator {
                 config.encoding.as_str()
             )));
         }
-        if data.len() != num_samples * sample_size {
+        if batch_data.len() != num_samples * sample_size {
             return Err(MahoutError::InvalidInput(format!(
                 "File data length {} is not num_samples ({}) * sample_size 
({})",
-                data.len(),
+                batch_data.len(),
                 num_samples,
                 sample_size
             )));
         }
 
-        let producer = InMemoryProducer {
-            data,
-            cursor: 0,
-            sample_size,
-            batch_size: config.batch_size,
-            num_qubits: config.num_qubits as usize,
-            batches_yielded: 0,
-            batch_limit,
+        let (rx, recycle_tx, _producer_handle) = match batch_data {
+            BatchData::F32(data) => {
+                build_inmemory_producer::<f32>(data, sample_size, &config, 
batch_limit)?
+            }
+            BatchData::F64(data) => {
+                build_inmemory_producer::<f64>(data, sample_size, &config, 
batch_limit)?
+            }
         };
-        let prefetch_depth = config.prefetch_depth;
-        let (rx, recycle_tx, _producer_handle) = spawn_producer(producer, 
prefetch_depth)?;
         Ok(Self {
             engine,
             config,
@@ -539,54 +732,29 @@ impl PipelineIterator {
             )));
         }
 
-        let mut reader = ParquetStreamingReader::new(
-            path,
-            Some(DEFAULT_PARQUET_ROW_GROUP_SIZE),
-            config.null_handling,
-        )?;
-        let vector_len = vector_len(config.num_qubits, config.encoding);
-
-        // Read first chunk to learn sample_size; reuse as initial buffer.
-        const INITIAL_CHUNK_CAP: usize = 64 * 1024;
-        let mut buffer = vec![0.0; INITIAL_CHUNK_CAP];
-        let written = reader.read_chunk(&mut buffer)?;
-        if written == 0 {
-            return Err(MahoutError::InvalidInput(
-                "Parquet file is empty or contains no data.".to_string(),
-            ));
-        }
-        let sample_size = reader.get_sample_size().ok_or_else(|| {
-            MahoutError::InvalidInput(
-                "Parquet streaming reader did not set sample_size after first 
chunk.".to_string(),
-            )
-        })?;
-
-        if sample_size != vector_len {
-            return Err(MahoutError::InvalidInput(format!(
-                "File feature length {} does not match vector_len {} for 
num_qubits={}, encoding={}",
-                sample_size,
-                vector_len,
-                config.num_qubits,
-                config.encoding.as_str()
-            )));
+        // Basis values are integer state indices; f32 narrowing corrupts 
indices above
+        // 2^24 (see read_file_by_extension). The streaming reader is chunked 
so we cannot
+        // pre-scan the whole file, so for basis we always stream as f64 
(lossless) rather
+        // than honoring an f32 request that could silently change states 
mid-stream.
+        //
+        // The non-streaming loader can pre-scan and so *rejects* an 
out-of-range f32 basis
+        // request; the chunked streaming path cannot, so it downgrades to f64 
instead. Warn
+        // so the caller is not left believing the stream ran in f32.
+        let f32_requested = matches!(config.dtype, Precision::Float32);
+        let is_basis = matches!(config.encoding, Encoding::Basis);
+        if f32_requested && is_basis {
+            log::warn!(
+                "float32 requested for streaming basis file; basis indices are 
integers and f32 \
+                 cannot represent indices above {MAX_EXACT_F32_INT:.0} 
exactly, so this stream is \
+                 read as f64. Use dtype='float64' to silence this warning."
+            );
         }
-
-        buffer.truncate(written);
-        let read_chunk_scratch = vec![0.0; INITIAL_CHUNK_CAP];
-
-        let producer = StreamingProducer {
-            reader,
-            buffer,
-            buffer_cursor: 0,
-            read_chunk_scratch,
-            sample_size,
-            batch_size: config.batch_size,
-            num_qubits: config.num_qubits as usize,
-            batches_yielded: 0,
-            batch_limit,
+        let use_f32 = f32_requested && !is_basis;
+        let (rx, recycle_tx, _producer_handle) = if use_f32 {
+            build_streaming_producer::<f32>(path, &config, batch_limit)?
+        } else {
+            build_streaming_producer::<f64>(path, &config, batch_limit)?
         };
-        let prefetch_depth = config.prefetch_depth;
-        let (rx, recycle_tx, _producer_handle) = spawn_producer(producer, 
prefetch_depth)?;
         Ok(Self {
             engine,
             config,
@@ -1365,4 +1533,381 @@ mod tests {
             "normalize() must set prefetch_depth > 0"
         );
     }
+
+    // 
-------------------------------------------------------------------------
+    // dtype file-load tests
+    //
+    // These tests verify that PipelineConfig.dtype is respected when loading
+    // from file sources.  They stop at the BatchData variant boundary — the
+    // encode kernel (encode_batch_f32_for_pipeline) is CUDA-gated and cannot
+    // be exercised in CPU-only CI.  BatchData::F32 is the observable proxy 
that
+    // confirms the f32 kernel would be called on a GPU host; this mirrors the
+    // existing convention in test_synthetic_producer_f32_*.
+    // 
-------------------------------------------------------------------------
+
+    mod dtype_file_tests {
+        use super::*;
+        use arrow::array::{ArrayRef, FixedSizeListBuilder, Float32Builder, 
RecordBatch};
+        use arrow::datatypes::{DataType, Field, Schema};
+        use parquet::arrow::ArrowWriter;
+        use std::fs;
+        use std::sync::Arc;
+
+        fn write_f32_parquet(path: &std::path::Path) {
+            // 8 samples, each 4 features — matches amplitude encoding with 2 
qubits (2^2=4)
+            let item_field = Arc::new(Field::new("item", DataType::Float32, 
true));
+            let list_field = Field::new("data", 
DataType::FixedSizeList(item_field, 4), true);
+            let schema = Arc::new(Schema::new(vec![list_field]));
+            let mut builder = FixedSizeListBuilder::new(Float32Builder::new(), 
4);
+            for _ in 0..8 {
+                builder.values().append_slice(&[0.25_f32, 0.5, 0.75, 1.0]);
+                builder.append(true);
+            }
+            let array = Arc::new(builder.finish()) as ArrayRef;
+            let batch = RecordBatch::try_new(schema.clone(), 
vec![array]).unwrap();
+            let file = fs::File::create(path).unwrap();
+            let mut writer = ArrowWriter::try_new(file, schema, None).unwrap();
+            writer.write(&batch).unwrap();
+            writer.close().unwrap();
+        }
+
+        static FILE_COUNTER: std::sync::atomic::AtomicUsize =
+            std::sync::atomic::AtomicUsize::new(0);
+
+        fn temp_parquet_path(tag: &str) -> std::path::PathBuf {
+            let n = FILE_COUNTER.fetch_add(1, 
std::sync::atomic::Ordering::Relaxed);
+            std::env::temp_dir().join(format!(
+                "mahout_pipeline_dtype_{tag}_{pid}_{n}.parquet",
+                pid = std::process::id(),
+            ))
+        }
+
+        #[test]
+        fn test_read_file_by_extension_f32_parquet_returns_f32_batch_data() {
+            let path = temp_parquet_path("f32");
+            write_f32_parquet(&path);
+            let result = read_file_by_extension(
+                &path,
+                NullHandling::FillZero,
+                Precision::Float32,
+                Encoding::Amplitude,
+            );
+            let _ = fs::remove_file(&path);
+            let (batch_data, num_samples, sample_size) = result.unwrap();
+            // Assert the actual values, not just the variant: a 
zeroed/garbled F32 batch
+            // would still match `BatchData::F32(_)` but is not what we loaded.
+            match batch_data {
+                BatchData::F32(buf) => {
+                    assert_eq!(buf.len(), 8 * 4);
+                    assert!((buf[0] - 0.25).abs() < 1e-6, "first value must 
round-trip");
+                    assert_eq!(&buf[..4], &[0.25_f32, 0.5, 0.75, 1.0]);
+                }
+                other => {
+                    panic!("dtype=Float32 + f32 Parquet must yield 
BatchData::F32, got {other:?}")
+                }
+            }
+            assert_eq!(num_samples, 8);
+            assert_eq!(sample_size, 4);
+        }
+
+        #[test]
+        fn test_read_file_by_extension_f64_parquet_returns_f64_batch_data() {
+            use arrow::array::Float64Builder;
+            use arrow::datatypes::DataType;
+            let item_field = Arc::new(Field::new("item", DataType::Float64, 
true));
+            let list_field = Field::new("data", 
DataType::FixedSizeList(item_field, 4), true);
+            let schema = Arc::new(Schema::new(vec![list_field]));
+            let mut builder = FixedSizeListBuilder::new(Float64Builder::new(), 
4);
+            for _ in 0..8 {
+                builder.values().append_slice(&[0.1_f64, 0.2, 0.3, 0.4]);
+                builder.append(true);
+            }
+            let array = Arc::new(builder.finish()) as ArrayRef;
+            let batch = RecordBatch::try_new(schema.clone(), 
vec![array]).unwrap();
+            let path = temp_parquet_path("f64");
+            let file = fs::File::create(&path).unwrap();
+            let mut writer = ArrowWriter::try_new(file, schema, None).unwrap();
+            writer.write(&batch).unwrap();
+            writer.close().unwrap();
+
+            let result = read_file_by_extension(
+                &path,
+                NullHandling::FillZero,
+                Precision::Float64,
+                Encoding::Amplitude,
+            );
+            let _ = fs::remove_file(&path);
+            let (batch_data, num_samples, sample_size) = result.unwrap();
+            match batch_data {
+                BatchData::F64(buf) => {
+                    assert_eq!(buf.len(), 8 * 4);
+                    assert!((buf[0] - 0.1).abs() < 1e-12, "first value must 
round-trip");
+                    assert_eq!(&buf[..4], &[0.1_f64, 0.2, 0.3, 0.4]);
+                }
+                other => {
+                    panic!("dtype=Float64 must yield BatchData::F64 
(regression), got {other:?}")
+                }
+            }
+            assert_eq!(num_samples, 8);
+            assert_eq!(sample_size, 4);
+        }
+
+        /// Basis encoding has sample_size 1 (one integer state index per 
sample).
+        fn write_basis_parquet(path: &std::path::Path, indices: &[f64]) {
+            use arrow::array::Float64Builder;
+            let item_field = Arc::new(Field::new("item", DataType::Float64, 
true));
+            let list_field = Field::new("data", 
DataType::FixedSizeList(item_field, 1), true);
+            let schema = Arc::new(Schema::new(vec![list_field]));
+            let mut builder = FixedSizeListBuilder::new(Float64Builder::new(), 
1);
+            for &idx in indices {
+                builder.values().append_value(idx);
+                builder.append(true);
+            }
+            let array = Arc::new(builder.finish()) as ArrayRef;
+            let batch = RecordBatch::try_new(schema.clone(), 
vec![array]).unwrap();
+            let file = fs::File::create(path).unwrap();
+            let mut writer = ArrowWriter::try_new(file, schema, None).unwrap();
+            writer.write(&batch).unwrap();
+            writer.close().unwrap();
+        }
+
+        #[test]
+        fn test_basis_f32_request_kept_as_f64_when_indices_fit() {
+            // Indices <= 2^24 are exactly representable in f32, so an f32 
request is
+            // lossless — but basis is always kept as f64 to route through the 
integer
+            // path. Values must round-trip exactly.
+            let path = temp_parquet_path("basis_small");
+            write_basis_parquet(&path, &[0.0, 1.0, 1024.0, 16_777_216.0]);
+            let result = read_file_by_extension(
+                &path,
+                NullHandling::FillZero,
+                Precision::Float32,
+                Encoding::Basis,
+            );
+            let _ = fs::remove_file(&path);
+            match result.unwrap().0 {
+                BatchData::F64(buf) => {
+                    assert_eq!(buf, vec![0.0, 1.0, 1024.0, 16_777_216.0]);
+                }
+                other => panic!("basis must be kept as F64 to preserve 
indices, got {other:?}"),
+            }
+        }
+
+        #[test]
+        fn test_basis_f32_request_rejected_when_index_exceeds_f32_range() {
+            // 16_777_217 = 2^24 + 1 cannot be represented exactly in f32 
(becomes
+            // 16_777_216), so an explicit f32 request must be rejected, not 
silently
+            // corrupted. This is the bug from PR #1407 review item #1.
+            let path = temp_parquet_path("basis_big_f32");
+            write_basis_parquet(&path, &[1.0, 16_777_217.0]);
+            let result = read_file_by_extension(
+                &path,
+                NullHandling::FillZero,
+                Precision::Float32,
+                Encoding::Basis,
+            );
+            let _ = fs::remove_file(&path);
+            let err = result.expect_err("f32 basis with index > 2^24 must 
error");
+            assert!(
+                matches!(err, MahoutError::InvalidInput(_)),
+                "expected InvalidInput, got {err:?}"
+            );
+        }
+
+        #[test]
+        fn test_basis_f64_large_index_loads_exactly() {
+            // The same large index under an f64 request loads fine and keeps 
its value.
+            let path = temp_parquet_path("basis_big_f64");
+            write_basis_parquet(&path, &[1.0, 16_777_217.0]);
+            let result = read_file_by_extension(
+                &path,
+                NullHandling::FillZero,
+                Precision::Float64,
+                Encoding::Basis,
+            );
+            let _ = fs::remove_file(&path);
+            match result.unwrap().0 {
+                BatchData::F64(buf) => assert_eq!(buf, vec![1.0, 
16_777_217.0]),
+                other => panic!("f64 basis must yield BatchData::F64, got 
{other:?}"),
+            }
+        }
+
+        #[test]
+        fn test_inmemory_producer_f32_produce_yields_f32_batch_data() {
+            let data = vec![0.25_f32, 0.5, 0.75, 1.0, 0.1, 0.2, 0.3, 0.4]; // 
2 samples × 4
+            let mut producer = InMemoryProducer::<f32> {
+                data,
+                cursor: 0,
+                sample_size: 4,
+                batch_size: 2,
+                num_qubits: 2,
+                batches_yielded: 0,
+                batch_limit: 10,
+            };
+            let batch = producer.produce(None).unwrap().unwrap();
+            assert!(
+                matches!(batch.data, BatchData::F32(_)),
+                "InMemoryProducer::<f32>.produce() must yield BatchData::F32 \
+                 so that next_batch() routes to encode_batch_f32_for_pipeline"
+            );
+            assert_eq!(batch.batch_n, 2);
+            assert_eq!(batch.sample_size, 4);
+        }
+
+        #[test]
+        fn test_streaming_producer_f32_produce_yields_f32_batch_data() {
+            let path = temp_parquet_path("streaming_f32");
+            write_f32_parquet(&path);
+
+            let result = (|| -> Result<BatchData> {
+                let mut reader = ParquetStreamingReader::<f32>::new(
+                    &path,
+                    Some(DEFAULT_PARQUET_ROW_GROUP_SIZE),
+                    NullHandling::FillZero,
+                )?;
+                const CAP: usize = 64 * 1024;
+                let mut buffer = vec![0.0_f32; CAP];
+                let written = reader.read_chunk(&mut buffer)?;
+                if written == 0 {
+                    return Err(MahoutError::InvalidInput("empty file".into()));
+                }
+                let sample_size = reader.get_sample_size().unwrap();
+                buffer.truncate(written);
+                let scratch = vec![0.0_f32; CAP];
+                let mut producer = StreamingProducer::<f32> {
+                    reader,
+                    buffer,
+                    buffer_cursor: 0,
+                    read_chunk_scratch: scratch,
+                    sample_size,
+                    batch_size: 4,
+                    num_qubits: 2,
+                    batches_yielded: 0,
+                    batch_limit: 10,
+                };
+                let batch = producer.produce(None)?.unwrap();
+                Ok(batch.data)
+            })();
+
+            let _ = fs::remove_file(&path);
+            assert!(
+                matches!(result.unwrap(), BatchData::F32(_)),
+                "StreamingProducer::<f32>.produce() must yield BatchData::F32"
+            );
+        }
+
+        /// Direct unit test of the shared f64→f32 narrowing helper used by 
every
+        /// non-Parquet format (Arrow IPC, NumPy, PyTorch, TensorFlow). Covers 
both the
+        /// variant switch and the documented ±Inf behavior for values outside 
f32 range.
+        #[test]
+        fn test_cast_f64_to_batch_data_narrows_and_overflows() {
+            // f32 request: in-range values cast exactly, out-of-range 
overflow to +Inf.
+            let (batch, n, s) = cast_f64_to_batch_data(
+                vec![0.25, 0.5, 1e40, -1e40],
+                1,
+                4,
+                Precision::Float32,
+                "test",
+            );
+            match batch {
+                BatchData::F32(buf) => {
+                    assert_eq!(buf[0], 0.25_f32);
+                    assert_eq!(buf[1], 0.5_f32);
+                    assert!(
+                        buf[2].is_infinite() && buf[2] > 0.0,
+                        "1e40 must overflow to +Inf"
+                    );
+                    assert!(
+                        buf[3].is_infinite() && buf[3] < 0.0,
+                        "-1e40 must overflow to -Inf"
+                    );
+                }
+                other => panic!("Float32 request must yield BatchData::F32, 
got {other:?}"),
+            }
+            assert_eq!((n, s), (1, 4));
+
+            // f64 request: passthrough, no cast.
+            let (batch, _, _) =
+                cast_f64_to_batch_data(vec![0.1, 0.2], 1, 2, 
Precision::Float64, "test");
+            assert_eq!(batch, BatchData::F64(vec![0.1, 0.2]));
+        }
+
+        /// End-to-end coverage of the Arrow IPC → f32 path through 
`read_file_by_extension`:
+        /// the reader produces f64, the cast helper narrows it to 
BatchData::F32.
+        #[test]
+        fn test_read_file_by_extension_arrow_f32_narrows_to_f32_batch_data() {
+            use arrow::array::Float64Builder;
+            use arrow::ipc::writer::FileWriter as ArrowIpcFileWriter;
+
+            let item_field = Arc::new(Field::new("item", DataType::Float64, 
true));
+            let list_field = Field::new("data", 
DataType::FixedSizeList(item_field, 4), true);
+            let schema = Arc::new(Schema::new(vec![list_field]));
+            let mut builder = FixedSizeListBuilder::new(Float64Builder::new(), 
4);
+            for _ in 0..3 {
+                builder.values().append_slice(&[0.25_f64, 0.5, 0.75, 1.0]);
+                builder.append(true);
+            }
+            let array = Arc::new(builder.finish()) as ArrayRef;
+            let batch = RecordBatch::try_new(schema.clone(), 
vec![array]).unwrap();
+
+            let n = FILE_COUNTER.fetch_add(1, 
std::sync::atomic::Ordering::Relaxed);
+            let path = std::env::temp_dir().join(format!(
+                "mahout_pipeline_dtype_arrow_{pid}_{n}.arrow",
+                pid = std::process::id(),
+            ));
+            {
+                let file = fs::File::create(&path).unwrap();
+                let mut writer = ArrowIpcFileWriter::try_new(file, 
&schema).unwrap();
+                writer.write(&batch).unwrap();
+                writer.finish().unwrap();
+            }
+
+            let result = read_file_by_extension(
+                &path,
+                NullHandling::FillZero,
+                Precision::Float32,
+                Encoding::Amplitude,
+            );
+            let _ = fs::remove_file(&path);
+            let (batch_data, num_samples, sample_size) = result.unwrap();
+            match batch_data {
+                BatchData::F32(buf) => {
+                    assert_eq!(buf.len(), 3 * 4);
+                    assert_eq!(&buf[..4], &[0.25_f32, 0.5, 0.75, 1.0]);
+                }
+                other => {
+                    panic!("Arrow IPC + dtype=Float32 must narrow to 
BatchData::F32, got {other:?}")
+                }
+            }
+            assert_eq!(num_samples, 3);
+            assert_eq!(sample_size, 4);
+        }
+
+        #[test]
+        fn test_build_streaming_producer_f32_channel_yields_f32_batch_data() {
+            // BatchData::F32 is the observable proxy for the f32 GPU kernel 
path; the kernel
+            // itself is CUDA-gated and cannot be called in CPU-only CI.
+            let path = temp_parquet_path("build_sp_f32");
+            write_f32_parquet(&path);
+            // 2 qubits + Amplitude → vector_len = 2^2 = 4, matching 
write_f32_parquet's 4 features.
+            let config = PipelineConfig {
+                num_qubits: 2,
+                encoding: Encoding::Amplitude,
+                batch_size: 4,
+                dtype: Precision::Float32,
+                prefetch_depth: 1,
+                ..PipelineConfig::default()
+            };
+            let result = (|| -> Result<BatchData> {
+                let (rx, _recycle_tx, _handle) =
+                    build_streaming_producer::<f32>(&path, &config, 1)?;
+                rx.recv().unwrap().map(|b| b.data)
+            })();
+            let _ = fs::remove_file(&path);
+            assert!(
+                matches!(result.unwrap(), BatchData::F32(_)),
+                "build_streaming_producer::<f32> must deliver BatchData::F32 
through the channel"
+            );
+        }
+    }
 }
diff --git a/qdp/qdp-python/qumat_qdp/loader.py 
b/qdp/qdp-python/qumat_qdp/loader.py
index cc01a405c..a70233650 100644
--- a/qdp/qdp-python/qumat_qdp/loader.py
+++ b/qdp/qdp-python/qumat_qdp/loader.py
@@ -44,6 +44,10 @@ if TYPE_CHECKING:
 # Seed must fit Rust u64: 0 <= seed <= 2^64 - 1.
 _U64_MAX = 2**64 - 1
 
+# Accepted dtype aliases for .dtype(); forwarded verbatim to the native loader,
+# which parses them case-insensitively (Dtype::from_str_ci).
+_VALID_DTYPES = frozenset({"float32", "f32", "float64", "f64"})
+
 # Canonical encoding names (must match Encoding enum in qdp-core/src/types.rs).
 _VALID_ENCODINGS: frozenset[str] = frozenset(
     {"amplitude", "angle", "basis", "iqp", "iqp-z", "phase"}
@@ -302,6 +306,7 @@ class QuantumDataLoader:
         self._synthetic_requested = False  # set True only by 
source_synthetic()
         self._file_requested = False
         self._null_handling: str | None = None
+        self._dtype: str | None = None  # None -> native default (float64, 
lossless)
         self._backend_name: str = _BACKEND_RUST
 
     def qubits(self, n: int) -> QuantumDataLoader:
@@ -410,6 +415,13 @@ class QuantumDataLoader:
         Remote ``s3://`` and ``gs://`` paths are accepted when the native 
remote
         I/O feature is enabled; remote query strings and fragments are 
rejected.
 
+        Element precision is controlled by :meth:`dtype`. By default file 
input is
+        loaded as ``float64`` (lossless). Selecting ``dtype("float32")`` 
narrows f64
+        file contents to f32 on load; values outside the f32 range become 
``±Inf``.
+        ``basis`` encoding is exempt: its values are integer state indices, so 
it is
+        always loaded as f64 and an explicit ``float32`` request is rejected 
when an
+        index exceeds f32's exact integer range (``2**24``).
+
         :param path: Local or supported remote input path.
         :param streaming: Whether to request native streaming file loading.
         :returns: ``self`` for fluent builder chaining.
@@ -435,6 +447,25 @@ class QuantumDataLoader:
         self._streaming_requested = streaming
         return self
 
+    def dtype(self, name: str) -> QuantumDataLoader:
+        """Set the element precision used when loading file sources.
+
+        Applies to the native :meth:`source_file` path. ``"float64"`` (the 
default)
+        loads file contents losslessly; ``"float32"`` narrows them to f32 on 
load.
+        See :meth:`source_file` for the cast caveats, including the ``basis``
+        exemption.
+
+        :param name: ``"float32"``/``"f32"`` or ``"float64"``/``"f64"`` 
(case-insensitive).
+        :returns: ``self`` for fluent builder chaining.
+        :raises ValueError: If ``name`` is not a recognized dtype.
+        """
+        if not isinstance(name, str) or name.strip().lower() not in 
_VALID_DTYPES:
+            raise ValueError(
+                f"dtype must be one of {sorted(_VALID_DTYPES)}, got {name!r}"
+            )
+        self._dtype = name.strip().lower()
+        return self
+
     def seed(self, s: int | None = None) -> QuantumDataLoader:
         """Set or clear the synthetic data seed.
 
@@ -580,6 +611,7 @@ class QuantumDataLoader:
                     encoding_method=self._encoding_method,
                     batch_limit=None,
                     null_handling=self._null_handling,
+                    dtype=self._dtype,
                 )
             )
         create_synthetic_loader = getattr(engine, "create_synthetic_loader", 
None)
diff --git a/qdp/qdp-python/src/engine.rs b/qdp/qdp-python/src/engine.rs
index 8297bf5e7..e3b9a9bd7 100644
--- a/qdp/qdp-python/src/engine.rs
+++ b/qdp/qdp-python/src/engine.rs
@@ -25,7 +25,9 @@ use pyo3::prelude::*;
 use qdp_core::{Dtype, Encoding, QdpEngine as CoreEngine};
 
 #[cfg(target_os = "linux")]
-use crate::loader::{PyQuantumLoader, config_from_args, parse_null_handling, 
path_from_py};
+use crate::loader::{
+    PyQuantumLoader, config_from_args, parse_dtype, parse_null_handling, 
path_from_py,
+};
 
 /// PyO3 wrapper for QdpEngine
 ///
@@ -535,6 +537,10 @@ impl QdpEngine {
         null_handling: Option<&str>,
     ) -> PyResult<PyQuantumLoader> {
         let nh = parse_null_handling(null_handling)?;
+        // Synthetic data is generated in-process for throughput benchmarking, 
so it
+        // defaults to f32 (PipelineConfig::normalize downgrades to f64 for 
encodings
+        // without an f32 batch path). This is deliberate and unrelated to the 
file
+        // loaders, which default to f64 to keep user-supplied data lossless.
         let config = config_from_args(
             &self.engine,
             batch_size,
@@ -554,7 +560,7 @@ impl QdpEngine {
     #[cfg(target_os = "linux")]
     /// Create a file-backed pipeline iterator (full read then batch; for 
QuantumDataLoader.source_file(path)).
     #[allow(clippy::too_many_arguments)]
-    #[pyo3(signature = (path, batch_size, num_qubits, encoding_method, 
batch_limit=None, null_handling=None))]
+    #[pyo3(signature = (path, batch_size, num_qubits, encoding_method, 
batch_limit=None, null_handling=None, dtype=None))]
     fn create_file_loader(
         &self,
         py: Python<'_>,
@@ -564,10 +570,12 @@ impl QdpEngine {
         encoding_method: &str,
         batch_limit: Option<usize>,
         null_handling: Option<&str>,
+        dtype: Option<&str>,
     ) -> PyResult<PyQuantumLoader> {
         let path_str = path_from_py(path)?;
         let batch_limit = batch_limit.unwrap_or(usize::MAX);
         let nh = parse_null_handling(null_handling)?;
+        let dt = parse_dtype(dtype)?;
         let config = config_from_args(
             &self.engine,
             batch_size,
@@ -576,7 +584,7 @@ impl QdpEngine {
             0,
             None,
             nh,
-            Dtype::Float32,
+            dt,
         )?;
         let engine = self.engine.clone();
         // Resolve remote URLs before detaching from GIL. The _resolved guard 
keeps the
@@ -603,7 +611,7 @@ impl QdpEngine {
     #[cfg(target_os = "linux")]
     /// Create a streaming Parquet pipeline iterator (for 
QuantumDataLoader.source_file(path, streaming=True)).
     #[allow(clippy::too_many_arguments)]
-    #[pyo3(signature = (path, batch_size, num_qubits, encoding_method, 
batch_limit=None, null_handling=None))]
+    #[pyo3(signature = (path, batch_size, num_qubits, encoding_method, 
batch_limit=None, null_handling=None, dtype=None))]
     fn create_streaming_file_loader(
         &self,
         py: Python<'_>,
@@ -613,10 +621,12 @@ impl QdpEngine {
         encoding_method: &str,
         batch_limit: Option<usize>,
         null_handling: Option<&str>,
+        dtype: Option<&str>,
     ) -> PyResult<PyQuantumLoader> {
         let path_str = path_from_py(path)?;
         let batch_limit = batch_limit.unwrap_or(usize::MAX);
         let nh = parse_null_handling(null_handling)?;
+        let dt = parse_dtype(dtype)?;
         let config = config_from_args(
             &self.engine,
             batch_size,
@@ -625,7 +635,7 @@ impl QdpEngine {
             0,
             None,
             nh,
-            Dtype::Float32,
+            dt,
         )?;
         let engine = self.engine.clone();
         // Resolve remote URLs before detaching from GIL. The _resolved guard 
keeps the
diff --git a/qdp/qdp-python/src/loader.rs b/qdp/qdp-python/src/loader.rs
index a43f94794..466d7ea64 100644
--- a/qdp/qdp-python/src/loader.rs
+++ b/qdp/qdp-python/src/loader.rs
@@ -83,6 +83,19 @@ mod loader_impl {
         }
     }
 
+    /// Parse an optional Python dtype string into the Rust enum. Defaults to 
f64 so
+    /// file loads are lossless unless the caller explicitly opts into f32 
narrowing.
+    pub fn parse_dtype(s: Option<&str>) -> PyResult<Dtype> {
+        match s {
+            None => Ok(Dtype::Float64),
+            Some(v) => Dtype::from_str_ci(v).map_err(|e| {
+                pyo3::exceptions::PyValueError::new_err(format!(
+                    "Invalid dtype '{v}': {e}. Expected 'float32'/'f32' or 
'float64'/'f64'."
+                ))
+            }),
+        }
+    }
+
     /// Build PipelineConfig from Python args. device_id is 0 (engine does not 
expose it); iterator uses engine clone with correct device.
     #[allow(clippy::too_many_arguments)]
     pub fn config_from_args(
@@ -121,4 +134,6 @@ mod loader_impl {
 }
 
 #[cfg(target_os = "linux")]
-pub use loader_impl::{PyQuantumLoader, config_from_args, parse_null_handling, 
path_from_py};
+pub use loader_impl::{
+    PyQuantumLoader, config_from_args, parse_dtype, parse_null_handling, 
path_from_py,
+};


Reply via email to