Skip to main content

genegraph_storage/
lance_storage_graph.rs

1//! Lance storage backend for graph embeddings.
2//!
3//! Async-first implementation that matches the async `StorageBackend` trait:
4//! - All I/O is async, no internal `block_on` or runtime creation.
5//! - Callers (CLI, tests, services) are responsible for providing a Tokio runtime.
6
7use std::path::{Path, PathBuf};
8
9use arrow::array::{Array as ArrowArray, Float64Array, UInt32Array};
10use arrow::datatypes::{DataType, Float64Type, Int64Type, UInt32Type};
11use arrow::record_batch::RecordBatch;
12use log::{debug, info};
13use smartcore::linalg::basic::arrays::Array;
14use smartcore::linalg::basic::matrix::DenseMatrix;
15use sprs::CsMat;
16
17use crate::metadata::FileInfo;
18use crate::metadata::GeneMetadata;
19use crate::traits::backend::StorageBackend;
20use crate::traits::lance::LanceStorage;
21use crate::traits::metadata::Metadata;
22use crate::{StorageError, StorageResult};
23
24/// Checked `usize -> u32` conversion.
25///
26/// Values above `u32::MAX` must surface an error instead of being silently
27/// truncated into wrong stored data (issue #51).
28fn checked_u32_values(values: &[usize], what: &str) -> StorageResult<Vec<u32>> {
29    values
30        .iter()
31        .map(|&v| {
32            u32::try_from(v).map_err(|_| {
33                StorageError::Overflow(format!(
34                    "{} value {} exceeds u32::MAX and would be silently truncated",
35                    what, v
36                ))
37            })
38        })
39        .collect()
40}
41
42/// Lance-based storage backend for ArrowSpace graph embeddings.
43///
44/// Stores dense and sparse matrices as Lance datasets using a columnar format
45/// (`row`, `col`, `value` for sparse; `col_*` for dense) schema for efficient
46/// random and columnar access.
47///
48/// Metadata must be seeded before any `save_*` call so the storage directory
49/// is initialized; the example below is executed as a doc-test on every
50/// `cargo test` run so it cannot go stale.
51///
52/// # Examples
53///
54/// ```
55/// use genegraph_storage::lance_storage_graph::LanceStorageGraph;
56/// use genegraph_storage::metadata::GeneMetadata;
57/// use genegraph_storage::traits::backend::StorageBackend;
58/// use genegraph_storage::traits::metadata::Metadata;
59/// use smartcore::linalg::basic::arrays::{Array, Array2};
60/// use smartcore::linalg::basic::matrix::DenseMatrix;
61///
62/// let base = std::env::temp_dir().join(format!("genegraph_doc_{}", std::process::id()));
63/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
64/// let storage = LanceStorageGraph::new(
65///     base.to_string_lossy().to_string(),
66///     "doc_example".to_string(),
67/// );
68///
69/// // some 2D data
70/// let dense: Vec<Vec<f64>> = vec![vec![0.1, 0.4], vec![0.5, 0.2], vec![0.03, 0.8]];
71/// let (nitems, nfeatures) = (dense.len(), dense[0].len());
72/// let data = DenseMatrix::<f64>::from_iterator(
73///     dense.iter().flatten().copied(),
74///     nitems,
75///     nfeatures,
76///     0,
77/// );
78///
79/// // seed metadata FIRST to initialize the storage directory
80/// let md = GeneMetadata::seed_metadata("doc_example", nitems, nfeatures, &storage)
81///     .await
82///     .unwrap();
83/// let md_path = storage.save_metadata(&md).await.unwrap();
84///
85/// // your data is saved in an efficient Lance format
86/// storage.save_dense("my_dataset", &data, &md_path).await.unwrap();
87///
88/// // Loading back
89/// let loaded = storage.load_dense("my_dataset").await.unwrap();
90/// assert_eq!(loaded.shape(), (nitems, nfeatures));
91/// # });
92/// # std::fs::remove_dir_all(&base).ok();
93/// ```
94#[derive(Debug, Clone)]
95pub struct LanceStorageGraph {
96    pub(crate) base: String,
97    pub(crate) name: String,
98}
99
100impl LanceStorageGraph {
101    /// Creates a new Lance storage backend.
102    ///
103    /// This is used for on-the-fly creation. For proper setup use `Genefold<...>::seed`.
104    ///
105    /// # Arguments
106    ///
107    /// * `base` - Base directory path for all storage files
108    /// * `name` - Name prefix for this storage instance
109    pub fn new(base: String, name: String) -> Self {
110        info!("Creating LanceStorage at base={}, name={}", base, name);
111        Self { base, name }
112    }
113
114    /// Spawn a LanceStorage from an existing seeded directory (with metadata.json)
115    pub async fn spawn(base_path: String) -> Result<(Self, GeneMetadata), StorageError> {
116        // Reuse the generic `exists` helper from the StorageBackend trait
117        let (exists, md_path) = Self::exists(&base_path);
118
119        // Replace assert! with proper error handling
120        if !exists || md_path.is_none() {
121            return Err(StorageError::Invalid(format!(
122                "Metadata does not exist in base path: {}",
123                base_path
124            )));
125        }
126
127        // Load metadata from the discovered metadata.json
128        let metadata = GeneMetadata::read(md_path.unwrap()).await?;
129
130        // Construct the LanceStorage using the metadata-provided nameid
131        let storage = Self::new(base_path.clone(), metadata.name_id.clone());
132        Ok((storage, metadata))
133    }
134}
135
136impl LanceStorage for LanceStorageGraph {}
137
138impl StorageBackend for LanceStorageGraph {
139    fn get_base(&self) -> String {
140        self.base.clone()
141    }
142
143    fn get_name(&self) -> String {
144        self.name.clone()
145    }
146
147    fn base_path(&self) -> PathBuf {
148        PathBuf::from(&self.base)
149    }
150
151    fn metadata_path(&self) -> PathBuf {
152        self.base_path()
153            .join(format!("{}_metadata.json", self.name))
154    }
155
156    /// Converts the base path for the store to a `file://` URI for Lance.
157    fn basepath_to_uri(&self) -> StorageResult<String> {
158        Self::path_to_uri(PathBuf::from(self.base.clone()).as_path())
159    }
160
161    /// Save dense matrix using Lance-optimized vector format.
162    ///
163    /// Each row of the matrix becomes a FixedSizeList entry for efficient vector operations.
164    /// This format is optimized for vector search and enables Lance's full-zip encoding.
165    ///
166    /// # Arguments
167    /// * `filename` - any name
168    /// * `matrix` - Dense matrix to save (N rows × F cols)
169    /// * `md_path` - Metadata file path for validation
170    async fn save_dense(
171        &self,
172        key: &str,
173        matrix: &DenseMatrix<f64>,
174        md_path: &Path,
175    ) -> StorageResult<()> {
176        self.validate_initialized(md_path)?;
177        let path = self.file_path(key);
178        let (n_rows, n_cols) = matrix.shape();
179
180        info!(
181            "Saving dense {} matrix: {} x {} at {:?}",
182            key, n_rows, n_cols, path
183        );
184
185        // Convert to Lance-optimized RecordBatch (FixedSizeList format)
186        let batch = self.to_dense_record_batch(matrix)?;
187
188        // Verify batch has correct number of rows
189        if batch.num_rows() != n_rows {
190            return Err(StorageError::Invalid(format!(
191                "RecordBatch has {} rows but matrix has {} rows",
192                batch.num_rows(),
193                n_rows
194            )));
195        }
196
197        {
198            // Write to Lance
199            let uri = Self::path_to_uri(&path)?;
200            self.write_lance_batch_async(uri, batch).await?;
201            let mut md = self.load_metadata().await?;
202            md = md.add_file(
203                key,
204                FileInfo::new(
205                    format!("{}_{}.lance", self.get_name(), key),
206                    "dense",
207                    matrix.shape(),
208                    None,
209                    None,
210                )?,
211            );
212            self.save_metadata(&md).await?;
213            info!("Dense {} matrix saved successfully", key);
214        }
215        Ok(())
216    }
217
218    /// Load dense matrix from Lance-optimized vector format.
219    ///
220    /// Reads FixedSizeList vectors and reconstructs a column-major DenseMatrix.
221    ///
222    /// # Arguments
223    /// * `filename` - any name previously assigned
224    ///
225    /// # Returns
226    /// Column-major DenseMatrix matching smartcore conventions
227    async fn load_dense(&self, key: &str) -> StorageResult<DenseMatrix<f64>> {
228        let path = self.file_path(key);
229        info!("Loading dense {} matrix from {:?}", key, path);
230
231        // Read all batches from Lance (may span multiple batches for large datasets)
232        let uri = Self::path_to_uri(&path)?;
233        let batch = self.read_lance_all_batches_async(uri).await?;
234
235        // Convert from FixedSizeList format to DenseMatrix
236        let matrix = self.from_dense_record_batch(&batch)?;
237
238        let (n_rows, n_cols) = matrix.shape();
239        info!("Loaded dense {} matrix: {} x {}", key, n_rows, n_cols);
240
241        Ok(matrix)
242    }
243
244    /// Load initial data using columnar format from a file path.
245    ///
246    /// Async test helper that avoids any internal blocking runtimes.
247    async fn load_dense_from_file(&self, path: &Path) -> StorageResult<DenseMatrix<f64>> {
248        info!("Loading dense matrix from file (async): {:?}", path);
249
250        if !path.exists() {
251            return Err(StorageError::Invalid(format!(
252                "Dense file does not exist: {:?}",
253                path
254            )));
255        }
256
257        let extension = path
258            .extension()
259            .and_then(|e| e.to_str())
260            .ok_or_else(|| StorageError::Invalid(format!("Invalid file path: {:?}", path)))?;
261
262        match extension {
263            "lance" => {
264                // Use a temporary LanceStorage rooted at the file's parent dir,
265                // same pattern as save_dense_to_file_async.
266                let parent = path
267                    .parent()
268                    .ok_or_else(|| {
269                        StorageError::Invalid(format!("Path has no parent: {:?}", path))
270                    })?
271                    .to_str()
272                    .ok_or_else(|| {
273                        StorageError::Invalid(format!("Non-UTF8 parent path for {:?}", path))
274                    })?
275                    .to_string();
276
277                let tmp_storage = Self::new(parent, String::from("tmp_storage"));
278
279                // Reuse the async Lance reader logic.
280                let uri = Self::path_to_uri(path)?;
281                let batch = tmp_storage.read_lance_all_batches_async(uri).await?;
282                let matrix = tmp_storage.from_dense_record_batch(&batch)?;
283                info!(
284                    "Loaded dense matrix from Lance: {} x {}",
285                    matrix.shape().0,
286                    matrix.shape().1
287                );
288                Ok(matrix)
289            }
290            "parquet" => {
291                use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
292
293                // Parquet readers require a synchronous `Read` impl; run the
294                // blocking open/read/concat on the dedicated blocking pool so
295                // the async executor thread is not stalled (issue #52).
296                let owned_path = path.to_path_buf();
297                let combined =
298                    tokio::task::spawn_blocking(move || -> StorageResult<RecordBatch> {
299                        let file = std::fs::File::open(&owned_path).map_err(|e| {
300                            StorageError::Io(format!("Failed to open parquet file: {}", e))
301                        })?;
302
303                        let builder =
304                            ParquetRecordBatchReaderBuilder::try_new(file).map_err(|e| {
305                                StorageError::Parquet(format!(
306                                    "Failed to create parquet reader: {}",
307                                    e
308                                ))
309                            })?;
310                        let reader = builder.build().map_err(|e| {
311                            StorageError::Parquet(format!("Failed to build parquet reader: {}", e))
312                        })?;
313
314                        let batches: Vec<RecordBatch> =
315                            reader.collect::<Result<Vec<_>, _>>().map_err(|e| {
316                                StorageError::Parquet(format!(
317                                    "Failed to read parquet batch: {}",
318                                    e
319                                ))
320                            })?;
321
322                        if batches.is_empty() {
323                            return Err(StorageError::Invalid(format!(
324                                "Empty parquet dataset at {:?}",
325                                owned_path
326                            )));
327                        }
328
329                        let schema = batches[0].schema();
330                        arrow::compute::concat_batches(&schema, &batches).map_err(|e| {
331                            StorageError::Parquet(format!(
332                                "Failed to concatenate parquet batches: {}",
333                                e
334                            ))
335                        })
336                    })
337                    .await
338                    .map_err(|e| {
339                        StorageError::Io(format!("Parquet reader task failed: {}", e))
340                    })??;
341
342                // 2. Detect layout: vector (FixedSizeList) vs old wide columnar (col_* Float64)
343                let schema = combined.schema();
344                let fields = schema.fields();
345                let is_vector = fields.len() == 1
346                    && matches!(
347                        fields[0].data_type(),
348                        DataType::FixedSizeList(inner, _)
349                            if matches!(inner.data_type(), DataType::Float64)
350                    );
351
352                let is_wide_col = !is_vector
353                    && !fields.is_empty()
354                    && fields
355                        .iter()
356                        .all(|f| matches!(f.data_type(), DataType::Float64))
357                    && fields.iter().any(|f| f.name().starts_with("col_"));
358
359                // 3. Build DenseMatrix from the RecordBatch
360                let matrix = if is_vector {
361                    // New format already: vector column (FixedSizeList<Float64>)
362                    // Reuse the same decoding as Lance.
363                    let parent = path
364                        .parent()
365                        .ok_or_else(|| {
366                            StorageError::Invalid(format!("Path has no parent: {:?}", path))
367                        })?
368                        .to_str()
369                        .ok_or_else(|| {
370                            StorageError::Invalid(format!("Non-UTF8 parent path for {:?}", path))
371                        })?
372                        .to_string();
373
374                    let tmp_storage = Self::new(parent, String::from("tmp_storage"));
375                    tmp_storage.from_dense_record_batch(&combined)?
376                } else if is_wide_col {
377                    // Old wide columnar: columns like col_0, col_1, ... as Float64
378                    let n_rows = combined.num_rows();
379                    let n_cols = combined.num_columns();
380                    if n_rows == 0 || n_cols == 0 {
381                        return Err(StorageError::Invalid(format!(
382                            "Cannot load empty wide-column parquet at {:?}",
383                            path
384                        )));
385                    }
386
387                    let mut data = Vec::with_capacity(n_rows * n_cols);
388                    for col_idx in 0..n_cols {
389                        let col = combined.column(col_idx);
390                        let arr = col.as_any().downcast_ref::<Float64Array>().ok_or_else(|| {
391                            StorageError::Invalid(format!(
392                                "Wide-column parquet expects Float64, got {:?} in column {}",
393                                col.data_type(),
394                                col_idx
395                            ))
396                        })?;
397                        // Build column-major storage: all rows for col 0, then col 1, ...
398                        for row_idx in 0..n_rows {
399                            data.push(arr.value(row_idx));
400                        }
401                    }
402
403                    DenseMatrix::new(n_rows, n_cols, data, true)
404                        .map_err(|e| StorageError::Invalid(e.to_string()))?
405                } else {
406                    return Err(StorageError::Invalid(format!(
407                        "Unsupported Parquet schema at {:?}: expected FixedSizeList<Float64> \
408                         or wide Float64 columns named col_*",
409                        path
410                    )));
411                };
412
413                info!(
414                    "Loaded dense matrix from Parquet: {} x {}",
415                    matrix.shape().0,
416                    matrix.shape().1
417                );
418
419                Ok(matrix)
420            }
421            _ => Err(StorageError::Invalid(format!(
422                "Unsupported file format: {}. Only .lance and .parquet are supported",
423                extension
424            ))),
425        }
426    }
427
428    fn file_path(&self, key: &str) -> PathBuf {
429        self.base_path()
430            .join(format!("{}_{}.lance", self.name, key))
431    }
432
433    // =========
434    // ASYNC API (matches StorageBackend)
435    // =========
436
437    async fn save_sparse(
438        &self,
439        key: &str,
440        matrix: &CsMat<f64>,
441        md_path: &Path,
442    ) -> StorageResult<()> {
443        self.validate_initialized(md_path)?;
444        let path = self.file_path(key);
445        info!(
446            "Saving sparse {} matrix: {} x {}, nnz={} at {:?}",
447            key,
448            matrix.rows(),
449            matrix.cols(),
450            matrix.nnz(),
451            path
452        );
453
454        let filetype = FileInfo::which_filetype(key)?;
455        {
456            let mut metadata = self.load_metadata().await?;
457            metadata = metadata.add_file(
458                key,
459                FileInfo::new(
460                    format!("{}_{}.lance", self.get_name(), key),
461                    filetype.as_str(),
462                    (matrix.rows(), matrix.cols()),
463                    Some(matrix.nnz()),
464                    None,
465                )?,
466            );
467            self.save_metadata(&metadata).await?;
468
469            let batch = self.to_sparse_record_batch(matrix)?;
470            let uri = Self::path_to_uri(&path)?;
471            self.write_lance_batch_async(uri, batch).await?;
472        }
473        info!("Sparse matrix {} saved successfully", filetype);
474        Ok(())
475    }
476
477    async fn load_sparse(&self, key: &str) -> StorageResult<CsMat<f64>> {
478        info!("Loading sparse {} matrix", key);
479
480        let metadata = self.load_metadata().await?;
481        let filetype = FileInfo::which_filetype(key)?;
482        let file_info = metadata
483            .files
484            .get(key)
485            .ok_or_else(|| StorageError::Invalid(format!("{key} not found in metadata")))?;
486
487        let expected_rows = file_info.rows;
488        let expected_cols = file_info.cols;
489        debug!(
490            "Expected dimensions from storage metadata: {} x {}",
491            expected_rows, expected_cols
492        );
493
494        let path = self.file_path(key);
495        let uri = Self::path_to_uri(&path)?;
496        let batch = self.read_lance_all_batches_async(uri).await?;
497        let matrix = self.from_sparse_record_batch(batch, expected_rows, expected_cols)?;
498        info!(
499            "Sparse {} matrix loaded: {} x {}, nnz={}",
500            filetype,
501            matrix.rows(),
502            matrix.cols(),
503            matrix.nnz()
504        );
505        Ok(matrix)
506    }
507
508    async fn save_lambdas(&self, lambdas: &[f64], md_path: &Path) -> StorageResult<()> {
509        info!("Saving {} lambda values", lambdas.len());
510        self.save_primitive_column::<Float64Type>("lambdas", "lambda", lambdas.to_vec(), md_path)
511            .await
512    }
513
514    async fn load_lambdas(&self) -> StorageResult<Vec<f64>> {
515        let path = self.file_path("lambdas");
516        info!("Loading lambda values from {:?}", path);
517
518        let uri = Self::path_to_uri(&path)?;
519        let batch = self.read_lance_all_batches_async(uri).await?;
520        let arr = batch
521            .column(0)
522            .as_any()
523            .downcast_ref::<Float64Array>()
524            .ok_or_else(|| StorageError::Invalid("lambda column type mismatch".into()))?;
525
526        let lambdas: Vec<f64> = (0..arr.len()).map(|i| arr.value(i)).collect();
527        info!("Loaded {} lambda values", lambdas.len());
528        Ok(lambdas)
529    }
530
531    async fn save_vector(&self, key: &str, vector: &[f64], md_path: &Path) -> StorageResult<()> {
532        info!("Saving {} values for vector {}", vector.len(), key);
533        self.save_primitive_column::<Float64Type>(key, "element", vector.to_vec(), md_path)
534            .await
535    }
536
537    async fn save_index(&self, key: &str, vector: &[usize], md_path: &Path) -> StorageResult<()> {
538        info!("Saving {} values for index {}", vector.len(), key);
539        // Checked cast: usize values above u32::MAX must not be silently
540        // truncated (issue #51).
541        let values = checked_u32_values(vector, "index")?;
542        self.save_primitive_column::<UInt32Type>(key, "id", values, md_path)
543            .await
544    }
545
546    async fn load_vector(&self, filename: &str) -> StorageResult<Vec<f64>> {
547        let path = self.file_path(filename);
548        info!("Loading vector {} from {:?}", filename, path);
549
550        let uri = Self::path_to_uri(&path)?;
551        let batch = self.read_lance_all_batches_async(uri).await?;
552        let arr = batch
553            .column(0)
554            .as_any()
555            .downcast_ref::<Float64Array>()
556            .ok_or_else(|| StorageError::Invalid("column type mismatch".into()))?;
557
558        let vector: Vec<f64> = (0..arr.len()).map(|i| arr.value(i)).collect();
559        info!("Loaded {} vector values for {}", vector.len(), filename);
560        Ok(vector)
561    }
562
563    async fn load_index(&self, filename: &str) -> StorageResult<Vec<usize>> {
564        let path = self.file_path(filename);
565        info!("Loading vector {} from {:?}", filename, path);
566
567        let uri = Self::path_to_uri(&path)?;
568        let batch = self.read_lance_all_batches_async(uri).await?;
569        let arr = batch
570            .column(0)
571            .as_any()
572            .downcast_ref::<UInt32Array>()
573            .ok_or_else(|| StorageError::Invalid("column type mismatch".into()))?;
574
575        let vector: Vec<usize> = (0..arr.len()).map(|i| arr.value(i) as usize).collect();
576        info!("Loaded {} vector values for {}", vector.len(), filename);
577        Ok(vector)
578    }
579
580    /// Save dense matrix to file in columnar format (col_0, col_1, ..., col_N)
581    ///
582    /// Async test helper that avoids any internal blocking runtimes.
583    async fn save_dense_to_file(data: &DenseMatrix<f64>, path: &Path) -> StorageResult<()> {
584        info!("Saving dense matrix to file (async): {:?}", path);
585
586        // Create missing parent directories instead of failing late in the
587        // format-specific writers.
588        let parent = path
589            .parent()
590            .filter(|p| !p.as_os_str().is_empty())
591            .ok_or_else(|| {
592                StorageError::Invalid(format!("path has no parent directory: {:?}", path))
593            })?;
594        tokio::fs::create_dir_all(parent)
595            .await
596            .map_err(|e| StorageError::Io(format!("create dir {:?}: {}", parent, e)))?;
597        let parent_str = parent
598            .to_str()
599            .ok_or_else(|| StorageError::Invalid(format!("non-UTF8 parent path for {:?}", path)))?
600            .to_string();
601
602        // Temporary storage, only used to build the record batch / write lance.
603        let tmp_storage = Self::new(parent_str, String::from("tmp_storage"));
604
605        let extension = path
606            .extension()
607            .and_then(|e| e.to_str())
608            .ok_or_else(|| StorageError::Invalid(format!("Invalid file path: {:?}", path)))?;
609
610        let (n_rows, n_cols) = data.shape();
611        info!("Saving matrix: {} rows x {} cols", n_rows, n_cols);
612
613        match extension {
614            "lance" => {
615                let batch = tmp_storage.to_dense_record_batch(data)?;
616                debug!(
617                    "Created RecordBatch with {} rows for Lance",
618                    batch.num_rows()
619                );
620
621                // Verify all rows are in the batch
622                if batch.num_rows() != n_rows {
623                    return Err(StorageError::Invalid(format!(
624                        "RecordBatch has {} rows but matrix has {} rows",
625                        batch.num_rows(),
626                        n_rows
627                    )));
628                }
629
630                let uri = Self::path_to_uri(path)?;
631                tmp_storage.write_lance_batch_async(uri, batch).await?;
632                info!("Saved dense matrix to Lance: {} x {}", n_rows, n_cols);
633                Ok(())
634            }
635            "parquet" => {
636                use parquet::arrow::ArrowWriter;
637                use parquet::file::properties::WriterProperties;
638                use std::fs::File;
639
640                let batch = tmp_storage.to_dense_record_batch(data)?;
641                debug!(
642                    "Created RecordBatch with {} rows for Parquet",
643                    batch.num_rows()
644                );
645
646                if batch.num_rows() != n_rows {
647                    return Err(StorageError::Invalid(format!(
648                        "RecordBatch has {} rows but matrix has {} rows",
649                        batch.num_rows(),
650                        n_rows
651                    )));
652                }
653
654                // The parquet writer is synchronous: run it on the blocking
655                // pool so the async executor thread is not stalled (see #52
656                // for the matching read path).
657                let owned_path = path.to_path_buf();
658                tokio::task::spawn_blocking(move || -> StorageResult<()> {
659                    let file = File::create(&owned_path).map_err(|e| {
660                        StorageError::Io(format!("Failed to create parquet file: {}", e))
661                    })?;
662
663                    let props = WriterProperties::builder()
664                        .set_compression(parquet::basic::Compression::SNAPPY)
665                        .build();
666
667                    let mut writer = ArrowWriter::try_new(file, batch.schema(), Some(props))
668                        .map_err(|e| {
669                            StorageError::Parquet(format!("Failed to create parquet writer: {}", e))
670                        })?;
671
672                    writer.write(&batch).map_err(|e| {
673                        StorageError::Parquet(format!("Failed to write batch: {}", e))
674                    })?;
675
676                    writer.close().map_err(|e| {
677                        StorageError::Parquet(format!("Failed to close writer: {}", e))
678                    })?;
679
680                    Ok(())
681                })
682                .await
683                .map_err(|e| StorageError::Io(format!("parquet writer task failed: {}", e)))??;
684
685                info!("Saved dense matrix to Parquet: {} x {}", n_rows, n_cols);
686                Ok(())
687            }
688            _ => Err(StorageError::Invalid(format!(
689                "Unsupported file format: {}. Only .lance and .parquet are supported",
690                extension
691            ))),
692        }
693    }
694
695    /// Save centroid_map (item-to-centroid assignments)
696    async fn save_centroid_map(&self, map: &[usize], md_path: &Path) -> StorageResult<()> {
697        info!("Saving {} centroid map entries", map.len());
698        // Checked cast: usize values above u32::MAX must not be silently
699        // truncated (issue #51).
700        let values = checked_u32_values(map, "centroid map")?;
701        self.save_primitive_column::<UInt32Type>("centroid_map", "centroid_id", values, md_path)
702            .await
703    }
704
705    /// Load centroid_map
706    async fn load_centroid_map(&self) -> StorageResult<Vec<usize>> {
707        let path = self.file_path("centroid_map");
708        info!("Loading centroid map from {:?}", path);
709
710        let uri = Self::path_to_uri(&path)?;
711        let batch = self.read_lance_all_batches_async(uri).await?;
712        let arr = batch
713            .column(0)
714            .as_any()
715            .downcast_ref::<UInt32Array>()
716            .ok_or_else(|| StorageError::Invalid("centroid_id column type mismatch".into()))?;
717
718        let map: Vec<usize> = (0..arr.len()).map(|i| arr.value(i) as usize).collect();
719        info!("Loaded {} centroid map entries", map.len());
720        Ok(map)
721    }
722
723    /// Save subcentroid_lambdas (tau values for subcentroids)
724    async fn save_subcentroid_lambdas(&self, lambdas: &[f64], md_path: &Path) -> StorageResult<()> {
725        info!("Saving {} subcentroid lambda values", lambdas.len());
726        self.save_primitive_column::<Float64Type>(
727            "subcentroid_lambdas",
728            "subcentroid_lambda",
729            lambdas.to_vec(),
730            md_path,
731        )
732        .await
733    }
734
735    /// Load subcentroid_lambdas
736    async fn load_subcentroid_lambdas(&self) -> StorageResult<Vec<f64>> {
737        let path = self.file_path("subcentroid_lambdas");
738        info!("Loading subcentroid lambda values from {:?}", path);
739
740        let uri = Self::path_to_uri(&path)?;
741        let batch = self.read_lance_all_batches_async(uri).await?;
742        let arr = batch
743            .column(0)
744            .as_any()
745            .downcast_ref::<Float64Array>()
746            .ok_or_else(|| {
747                StorageError::Invalid("subcentroid_lambda column type mismatch".into())
748            })?;
749
750        let lambdas: Vec<f64> = (0..arr.len()).map(|i| arr.value(i)).collect();
751        info!("Loaded {} subcentroid lambda values", lambdas.len());
752        Ok(lambdas)
753    }
754
755    /// Save subcentroids (dense matrix)
756    async fn save_subcentroids(
757        &self,
758        subcentroids: &DenseMatrix<f64>,
759        md_path: &Path,
760    ) -> StorageResult<()> {
761        self.validate_initialized(md_path)?;
762        let key = "sub_centroids";
763        let path = self.file_path(key);
764        let (n_rows, n_cols) = subcentroids.shape();
765        info!(
766            "Saving subcentroids matrix {} x {} at {:?}",
767            n_rows, n_cols, path
768        );
769
770        let batch = self.to_dense_record_batch(subcentroids)?;
771        {
772            let mut metadata = self.load_metadata().await?;
773            metadata = metadata.add_file(
774                key,
775                FileInfo::new(
776                    format!("{}_{}.lance", self.get_name(), key),
777                    "vector",
778                    subcentroids.shape(),
779                    None,
780                    None,
781                )?,
782            );
783            self.save_metadata(&metadata).await?;
784
785            let uri = Self::path_to_uri(&path)?;
786            self.write_lance_batch_async(uri, batch).await?;
787        }
788        debug!("Subcentroids matrix saved successfully");
789        Ok(())
790    }
791
792    /// Load subcentroids as Vec<Vec<f64>>
793    async fn load_subcentroids(&self) -> StorageResult<Vec<Vec<f64>>> {
794        let path = self.file_path("sub_centroids");
795        info!("Loading sub_centroids from {:?}", path);
796
797        let uri = Self::path_to_uri(&path)?;
798        let batch = self.read_lance_all_batches_async(uri).await?;
799        let matrix = self.from_dense_record_batch(&batch)?;
800
801        // Convert DenseMatrix to Vec<Vec<f64>>
802        let (n_rows, n_cols) = matrix.shape();
803        let mut result = Vec::with_capacity(n_rows);
804
805        for row_idx in 0..n_rows {
806            let row: Vec<f64> = (0..n_cols)
807                .map(|col_idx| *matrix.get((row_idx, col_idx)))
808                .collect();
809            result.push(row);
810        }
811
812        info!(
813            "Loaded sub_centroids: {} x {} as Vec<Vec<f64>>",
814            n_rows, n_cols
815        );
816        Ok(result)
817    }
818
819    /// Save item norms vector
820    async fn save_item_norms(&self, item_norms: &[f64], md_path: &Path) -> StorageResult<()> {
821        info!("Saving {} item norm values", item_norms.len());
822        self.save_primitive_column::<Float64Type>(
823            "item_norms",
824            "norm",
825            item_norms.to_vec(),
826            md_path,
827        )
828        .await
829    }
830
831    /// Load item norms vector
832    async fn load_item_norms(&self) -> StorageResult<Vec<f64>> {
833        let path = self.file_path("item_norms");
834        info!("Loading item norms from {:?}", path);
835
836        let uri = Self::path_to_uri(&path)?;
837        let batch = self.read_lance_all_batches_async(uri).await?;
838        let arr = batch
839            .column(0)
840            .as_any()
841            .downcast_ref::<Float64Array>()
842            .ok_or_else(|| StorageError::Invalid("norm column type mismatch".into()))?;
843
844        let norms: Vec<f64> = (0..arr.len()).map(|i| arr.value(i)).collect();
845        info!("Loaded {} item norm values", norms.len());
846        Ok(norms)
847    }
848
849    async fn save_cluster_assignments(
850        &self,
851        assignments: &[Option<usize>],
852        md_path: &Path,
853    ) -> StorageResult<()> {
854        info!("Saving {} cluster assignments", assignments.len());
855
856        // Convert Option<usize> to i64 (-1 for None)
857        let values: Vec<i64> = assignments
858            .iter()
859            .map(|opt| opt.map(|v| v as i64).unwrap_or(-1))
860            .collect();
861
862        self.save_primitive_column::<Int64Type>(
863            "cluster_assignments",
864            "cluster_id",
865            values,
866            md_path,
867        )
868        .await
869    }
870
871    async fn load_cluster_assignments(&self) -> StorageResult<Vec<Option<usize>>> {
872        use arrow::array::Int64Array;
873        let path = self.file_path("cluster_assignments");
874        info!("Loading cluster assignments from {:?}", path);
875
876        let uri = Self::path_to_uri(&path)?;
877        let batch = self.read_lance_all_batches_async(uri).await?;
878        let arr = batch
879            .column(0)
880            .as_any()
881            .downcast_ref::<Int64Array>()
882            .ok_or_else(|| StorageError::Invalid("cluster_id column type mismatch".into()))?;
883
884        let assignments: Vec<Option<usize>> = (0..arr.len())
885            .map(|i| {
886                let v = arr.value(i);
887                if v < 0 { None } else { Some(v as usize) }
888            })
889            .collect();
890        info!("Loaded {} cluster assignments", assignments.len());
891        Ok(assignments)
892    }
893}