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::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use arrow::array::{Array as ArrowArray, ArrayRef, Float32Array, Float64Array, UInt32Array};
12use arrow::datatypes::{DataType, Field, Float64Type, Int64Type, Schema, UInt32Type};
13use arrow::record_batch::RecordBatch;
14use log::{debug, info};
15use smartcore::linalg::basic::arrays::Array;
16use smartcore::linalg::basic::matrix::DenseMatrix;
17use sprs::CsMat;
18
19use crate::graph::{GraphEdge, GraphReadOptions, GraphWriteOptions, StoredGraph};
20use crate::metadata::FileInfo;
21use crate::metadata::GeneMetadata;
22use crate::traits::backend::StorageBackend;
23use crate::traits::lance::{
24    LanceStorage, graph_record_batch, stored_graph_from_batch_with_options,
25    validate_vector_space_schema, vector_space_collection_batch,
26};
27use crate::{StorageError, StorageResult};
28
29/// Checked `usize -> u32` conversion.
30///
31/// Values above `u32::MAX` must surface an error instead of being silently
32/// truncated into wrong stored data (issue #51).
33fn checked_u32_values(values: &[usize], what: &str) -> StorageResult<Vec<u32>> {
34    values
35        .iter()
36        .map(|&v| {
37            u32::try_from(v).map_err(|_| {
38                StorageError::Overflow(format!(
39                    "{} value {} exceeds u32::MAX and would be silently truncated",
40                    what, v
41                ))
42            })
43        })
44        .collect()
45}
46
47/// Creates the parent directory of an explicit collection path (mirrors
48/// `save_dense_to_file`): registry-free writes may target any location.
49async fn ensure_parent_dir(path: &Path) -> StorageResult<()> {
50    let parent = path
51        .parent()
52        .filter(|p| !p.as_os_str().is_empty())
53        .ok_or_else(|| StorageError::Invalid(format!("path has no parent directory: {path:?}")))?;
54    tokio::fs::create_dir_all(parent)
55        .await
56        .map_err(|e| StorageError::Io(format!("create dir {parent:?}: {e}")))
57}
58
59/// Lance-based storage backend for ArrowSpace graph embeddings.
60///
61/// Stores dense and sparse matrices as Lance datasets using a columnar format
62/// (`row`, `col`, `value` for sparse; `col_*` for dense) schema for efficient
63/// random and columnar access.
64///
65/// Metadata must be seeded before any `save_*` call so the storage directory
66/// is initialized; the example below is executed as a doc-test on every
67/// `cargo test` run so it cannot go stale.
68///
69/// # Examples
70///
71/// ```
72/// use genegraph_storage::lance_storage_graph::LanceStorageGraph;
73/// use genegraph_storage::metadata::GeneMetadata;
74/// use genegraph_storage::traits::backend::StorageBackend;
75/// use genegraph_storage::traits::metadata::Metadata;
76/// use smartcore::linalg::basic::arrays::{Array, Array2};
77/// use smartcore::linalg::basic::matrix::DenseMatrix;
78///
79/// let base = std::env::temp_dir().join(format!("genegraph_doc_{}", std::process::id()));
80/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
81/// let storage = LanceStorageGraph::new(
82///     base.to_string_lossy().to_string(),
83///     "doc_example".to_string(),
84/// )
85/// .expect("valid instance name");
86///
87/// // some 2D data
88/// let dense: Vec<Vec<f64>> = vec![vec![0.1, 0.4], vec![0.5, 0.2], vec![0.03, 0.8]];
89/// let (nitems, nfeatures) = (dense.len(), dense[0].len());
90/// let data = DenseMatrix::<f64>::from_iterator(
91///     dense.iter().flatten().copied(),
92///     nitems,
93///     nfeatures,
94///     0,
95/// );
96///
97/// // seed metadata FIRST to initialize the storage directory
98/// let md = GeneMetadata::seed_metadata("doc_example", nitems, nfeatures, &storage)
99///     .await
100///     .unwrap();
101/// let md_path = storage.save_metadata(&md).await.unwrap();
102///
103/// // your data is saved in an efficient Lance format
104/// storage.save_dense("my_dataset", &data, &md_path).await.unwrap();
105///
106/// // Loading back
107/// let loaded = storage.load_dense("my_dataset").await.unwrap();
108/// assert_eq!(loaded.shape(), (nitems, nfeatures));
109/// # });
110/// # std::fs::remove_dir_all(&base).ok();
111/// ```
112#[derive(Debug, Clone)]
113pub struct LanceStorageGraph {
114    pub(crate) base: String,
115    pub(crate) name: String,
116}
117
118impl LanceStorageGraph {
119    /// Creates a new Lance storage backend.
120    ///
121    /// This is used for on-the-fly creation. For proper setup use `Genefold<...>::seed`.
122    ///
123    /// # Arguments
124    ///
125    /// * `base` - Base directory path for all storage files
126    /// * `name` - Name prefix for this storage instance
127    ///
128    /// The name must not carry the reserved generation suffix `__g<n>`
129    /// (#117-3): only [`Self::scoped_generation`] mints generation-qualified
130    /// names, otherwise generation discovery and sweep would treat the
131    /// instance as a generation of another logical dataset.
132    pub fn new(base: String, name: String) -> StorageResult<Self> {
133        crate::generations::validate_logical_name(&name)?;
134        Ok(Self::from_parts(base, name))
135    }
136
137    /// Unvalidated constructor for vetted names: stored `name_id`s read
138    /// back through [`Self::spawn`], generation handles from
139    /// [`Self::scoped_generation`], and internal temporary handles.
140    fn from_parts(base: String, name: String) -> Self {
141        info!("Creating LanceStorage at base={}, name={}", base, name);
142        Self { base, name }
143    }
144
145    /// Spawn a LanceStorage from an existing seeded directory (with metadata.json)
146    pub async fn spawn(base_path: String) -> Result<(Self, GeneMetadata), StorageError> {
147        // Reuse the generic `exists` helper from the StorageBackend trait
148        let (exists, md_path) = Self::exists(&base_path);
149
150        // Replace assert! with proper error handling
151        if !exists || md_path.is_none() {
152            return Err(StorageError::Invalid(format!(
153                "Metadata does not exist in base path: {}",
154                base_path
155            )));
156        }
157
158        // Load metadata from the discovered metadata.json
159        let metadata = GeneMetadata::read(md_path.unwrap()).await?;
160
161        // Construct the LanceStorage using the metadata-provided nameid
162        let storage = Self::from_parts(base_path.clone(), metadata.name_id.clone());
163        Ok((storage, metadata))
164    }
165
166    /// A handle over generation `gen` of this logical dataset: artifact
167    /// paths resolve as `{logical}__g{gen}_{key}.lance` and the metadata
168    /// path as `{logical}__g{gen}_metadata.json` — the per-generation
169    /// commit pointer (#93, RFC #81-P5). The logical identity stays
170    /// recoverable via [`crate::generations::logical_name`].
171    pub fn scoped_generation(&self, generation: u64) -> Self {
172        let logical = crate::generations::logical_name(&self.name);
173        Self::from_parts(
174            self.base.clone(),
175            crate::generations::generation_name(logical, generation),
176        )
177    }
178}
179
180impl LanceStorage for LanceStorageGraph {}
181
182// =========
183// Dense file I/O helpers (#117-4): one function per format branch so each
184// can be read and tested in isolation. The dispatchers above stay thin.
185// =========
186
187/// Parent directory of `path`, UTF-8 validated.
188fn parent_dir_of(path: &Path) -> StorageResult<String> {
189    path.parent()
190        .ok_or_else(|| StorageError::Invalid(format!("Path has no parent: {:?}", path)))?
191        .to_str()
192        .map(str::to_string)
193        .ok_or_else(|| StorageError::Invalid(format!("Non-UTF8 parent path for {:?}", path)))
194}
195
196/// Lance branch of `load_dense_from_file`: read the dataset directory
197/// through a temporary backend rooted at the file's parent.
198async fn load_dense_lance(path: &Path) -> StorageResult<DenseMatrix<f64>> {
199    use crate::traits::backend::StorageBackend;
200    use crate::traits::lance::LanceStorage;
201
202    let parent = parent_dir_of(path)?;
203    let tmp_storage = LanceStorageGraph::from_parts(parent, String::from("tmp_storage"));
204
205    let uri = LanceStorageGraph::path_to_uri(path)?;
206    let batch = tmp_storage.read_lance_all_batches_async(uri).await?;
207    let matrix = tmp_storage.from_dense_record_batch(&batch)?;
208    info!(
209        "Loaded dense matrix from Lance: {} x {}",
210        matrix.shape().0,
211        matrix.shape().1
212    );
213    Ok(matrix)
214}
215
216/// Parquet branch of `load_dense_from_file`, reader half.
217///
218/// Parquet readers require a synchronous `Read` impl; run the blocking
219/// open/read/concat on the dedicated blocking pool so the async executor
220/// thread is not stalled (issue #52).
221async fn read_parquet_batches(path: &Path) -> StorageResult<RecordBatch> {
222    use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
223
224    let owned_path = path.to_path_buf();
225    let combined = tokio::task::spawn_blocking(move || -> StorageResult<RecordBatch> {
226        let file = std::fs::File::open(&owned_path)
227            .map_err(|e| StorageError::Io(format!("Failed to open parquet file: {}", e)))?;
228
229        let builder = ParquetRecordBatchReaderBuilder::try_new(file).map_err(|e| {
230            StorageError::Parquet(format!("Failed to create parquet reader: {}", e))
231        })?;
232        let reader = builder
233            .build()
234            .map_err(|e| StorageError::Parquet(format!("Failed to build parquet reader: {}", e)))?;
235
236        let batches: Vec<RecordBatch> = reader
237            .collect::<Result<Vec<_>, _>>()
238            .map_err(|e| StorageError::Parquet(format!("Failed to read parquet batch: {}", e)))?;
239
240        if batches.is_empty() {
241            return Err(StorageError::Invalid(format!(
242                "Empty parquet dataset at {:?}",
243                owned_path
244            )));
245        }
246
247        let schema = batches[0].schema();
248        arrow::compute::concat_batches(&schema, &batches).map_err(|e| {
249            StorageError::Parquet(format!("Failed to concatenate parquet batches: {}", e))
250        })
251    })
252    .await
253    .map_err(|e| StorageError::Io(format!("Parquet reader task failed: {}", e)))??;
254    Ok(combined)
255}
256
257/// Parquet branch of `load_dense_from_file`, layout half.
258///
259/// Supported layouts: `vector: FixedSizeList<Float64>` (decodes like the
260/// Lance path) and the legacy wide layout with `Float64` columns named
261/// `col_0..col_N`; anything else is rejected with `StorageError::Invalid`.
262fn dense_matrix_from_parquet(
263    path: &Path,
264    combined: &RecordBatch,
265) -> StorageResult<DenseMatrix<f64>> {
266    use crate::traits::backend::StorageBackend;
267
268    let schema = combined.schema();
269    let fields = schema.fields();
270    let is_vector = fields.len() == 1
271        && matches!(
272            fields[0].data_type(),
273            DataType::FixedSizeList(inner, _)
274                if matches!(inner.data_type(), DataType::Float64)
275        );
276
277    let is_wide_col = !is_vector
278        && !fields.is_empty()
279        && fields
280            .iter()
281            .all(|f| matches!(f.data_type(), DataType::Float64))
282        && fields.iter().any(|f| f.name().starts_with("col_"));
283
284    let matrix = if is_vector {
285        // New format already: vector column (FixedSizeList<Float64>)
286        // Reuse the same decoding as Lance.
287        let parent = parent_dir_of(path)?;
288        let tmp_storage = LanceStorageGraph::from_parts(parent, String::from("tmp_storage"));
289        tmp_storage.from_dense_record_batch(combined)?
290    } else if is_wide_col {
291        // Old wide columnar: columns like col_0, col_1, ... as Float64
292        let n_rows = combined.num_rows();
293        let n_cols = combined.num_columns();
294        if n_rows == 0 || n_cols == 0 {
295            return Err(StorageError::Invalid(format!(
296                "Cannot load empty wide-column parquet at {:?}",
297                path
298            )));
299        }
300
301        let mut data = Vec::with_capacity(n_rows * n_cols);
302        for col_idx in 0..n_cols {
303            let col = combined.column(col_idx);
304            let arr = col.as_any().downcast_ref::<Float64Array>().ok_or_else(|| {
305                StorageError::Invalid(format!(
306                    "Wide-column parquet expects Float64, got {:?} in column {}",
307                    col.data_type(),
308                    col_idx
309                ))
310            })?;
311            // Build column-major storage: all rows for col 0, then col 1, ...
312            for row_idx in 0..n_rows {
313                data.push(arr.value(row_idx));
314            }
315        }
316
317        DenseMatrix::new(n_rows, n_cols, data, true)
318            .map_err(|e| StorageError::Invalid(e.to_string()))?
319    } else {
320        return Err(StorageError::Invalid(format!(
321            "Unsupported Parquet schema at {:?}: expected FixedSizeList<Float64> \
322             or wide Float64 columns named col_*",
323            path
324        )));
325    };
326
327    info!(
328        "Loaded dense matrix from Parquet: {} x {}",
329        matrix.shape().0,
330        matrix.shape().1
331    );
332    Ok(matrix)
333}
334
335/// Builds the vector-layout RecordBatch for `data` and verifies the row
336/// count survived the conversion.
337fn checked_dense_batch(
338    storage: &LanceStorageGraph,
339    data: &DenseMatrix<f64>,
340) -> StorageResult<RecordBatch> {
341    use crate::traits::backend::StorageBackend;
342
343    let batch = storage.to_dense_record_batch(data)?;
344    let (n_rows, _) = data.shape();
345    if batch.num_rows() != n_rows {
346        return Err(StorageError::Invalid(format!(
347            "RecordBatch has {} rows but matrix has {} rows",
348            batch.num_rows(),
349            n_rows
350        )));
351    }
352    Ok(batch)
353}
354
355/// Lance branch of `save_dense_to_file`.
356async fn save_dense_lance(
357    parent_dir: String,
358    data: &DenseMatrix<f64>,
359    path: &Path,
360) -> StorageResult<()> {
361    use crate::traits::backend::StorageBackend;
362    use crate::traits::lance::LanceStorage;
363
364    let tmp_storage = LanceStorageGraph::from_parts(parent_dir, String::from("tmp_storage"));
365    let batch = checked_dense_batch(&tmp_storage, data)?;
366    debug!(
367        "Created RecordBatch with {} rows for Lance",
368        batch.num_rows()
369    );
370
371    let uri = LanceStorageGraph::path_to_uri(path)?;
372    tmp_storage.write_lance_batch_async(uri, batch).await?;
373    let (n_rows, n_cols) = data.shape();
374    info!("Saved dense matrix to Lance: {} x {}", n_rows, n_cols);
375    Ok(())
376}
377
378/// Parquet branch of `save_dense_to_file`.
379///
380/// The parquet writer is synchronous: run it on the blocking pool so the
381/// async executor thread is not stalled (see #52 for the matching read
382/// path).
383async fn save_dense_parquet(
384    parent_dir: String,
385    data: &DenseMatrix<f64>,
386    path: &Path,
387) -> StorageResult<()> {
388    use parquet::arrow::ArrowWriter;
389    use parquet::file::properties::WriterProperties;
390    use std::fs::File;
391
392    let tmp_storage = LanceStorageGraph::from_parts(parent_dir, String::from("tmp_storage"));
393    let batch = checked_dense_batch(&tmp_storage, data)?;
394    debug!(
395        "Created RecordBatch with {} rows for Parquet",
396        batch.num_rows()
397    );
398
399    let owned_path = path.to_path_buf();
400    tokio::task::spawn_blocking(move || -> StorageResult<()> {
401        let file = File::create(&owned_path)
402            .map_err(|e| StorageError::Io(format!("Failed to create parquet file: {}", e)))?;
403
404        let props = WriterProperties::builder()
405            .set_compression(parquet::basic::Compression::SNAPPY)
406            .build();
407
408        let mut writer = ArrowWriter::try_new(file, batch.schema(), Some(props)).map_err(|e| {
409            StorageError::Parquet(format!("Failed to create parquet writer: {}", e))
410        })?;
411
412        writer
413            .write(&batch)
414            .map_err(|e| StorageError::Parquet(format!("Failed to write batch: {}", e)))?;
415
416        writer
417            .close()
418            .map_err(|e| StorageError::Parquet(format!("Failed to close writer: {}", e)))?;
419
420        Ok(())
421    })
422    .await
423    .map_err(|e| StorageError::Io(format!("parquet writer task failed: {}", e)))??;
424
425    let (n_rows, n_cols) = data.shape();
426    info!("Saved dense matrix to Parquet: {} x {}", n_rows, n_cols);
427    Ok(())
428}
429
430impl StorageBackend for LanceStorageGraph {
431    fn get_base(&self) -> String {
432        self.base.clone()
433    }
434
435    fn get_name(&self) -> String {
436        self.name.clone()
437    }
438
439    fn base_path(&self) -> PathBuf {
440        PathBuf::from(&self.base)
441    }
442
443    fn metadata_path(&self) -> PathBuf {
444        crate::generations::metadata_file_path(&self.base_path(), &self.name)
445    }
446
447    /// Converts the base path for the store to a `file://` URI for Lance.
448    fn basepath_to_uri(&self) -> StorageResult<String> {
449        Self::path_to_uri(PathBuf::from(self.base.clone()).as_path())
450    }
451
452    /// Save dense matrix using Lance-optimized vector format.
453    ///
454    /// Each row of the matrix becomes a FixedSizeList entry for efficient vector operations.
455    /// This format is optimized for vector search and enables Lance's full-zip encoding.
456    ///
457    /// # Arguments
458    /// * `filename` - any name
459    /// * `matrix` - Dense matrix to save (N rows × F cols)
460    /// * `md_path` - Metadata file path for validation
461    async fn save_dense(
462        &self,
463        key: &str,
464        matrix: &DenseMatrix<f64>,
465        md_path: &Path,
466    ) -> StorageResult<()> {
467        self.validate_initialized(md_path)?;
468        let path = self.file_path(key);
469        let (n_rows, n_cols) = matrix.shape();
470
471        info!(
472            "Saving dense {} matrix: {} x {} at {:?}",
473            key, n_rows, n_cols, path
474        );
475
476        // Convert to Lance-optimized RecordBatch (FixedSizeList format)
477        let batch = self.to_dense_record_batch(matrix)?;
478
479        // Verify batch has correct number of rows
480        if batch.num_rows() != n_rows {
481            return Err(StorageError::Invalid(format!(
482                "RecordBatch has {} rows but matrix has {} rows",
483                batch.num_rows(),
484                n_rows
485            )));
486        }
487
488        {
489            // Write to Lance
490            let uri = Self::path_to_uri(&path)?;
491            self.write_lance_batch_async(uri, batch).await?;
492            // Commit the registry entry through the instance's commit actor:
493            // concurrent save_* calls serialize their read-modify-write
494            // cycles instead of losing updates (duva actor model).
495            crate::commit::with_commit_actor(&self.metadata_path(), || async {
496                let mut md = self.load_metadata().await?;
497                md = md.add_file(
498                    key,
499                    FileInfo::new(
500                        crate::generations::artifact_file_name(self.get_name().as_str(), key),
501                        "dense",
502                        matrix.shape(),
503                        None,
504                        None,
505                    )?,
506                );
507                self.save_metadata(&md).await
508            })
509            .await?;
510            info!("Dense {} matrix saved successfully", key);
511        }
512        Ok(())
513    }
514
515    /// Load dense matrix from Lance-optimized vector format.
516    ///
517    /// Reads FixedSizeList vectors and reconstructs a column-major DenseMatrix.
518    ///
519    /// # Arguments
520    /// * `filename` - any name previously assigned
521    ///
522    /// # Returns
523    /// Column-major DenseMatrix matching smartcore conventions
524    async fn load_dense(&self, key: &str) -> StorageResult<DenseMatrix<f64>> {
525        let path = self.file_path(key);
526        info!("Loading dense {} matrix from {:?}", key, path);
527
528        // Read all batches from Lance (may span multiple batches for large datasets)
529        let uri = Self::path_to_uri(&path)?;
530        let batch = self.read_lance_all_batches_async(uri).await?;
531
532        // Convert from FixedSizeList format to DenseMatrix
533        let matrix = self.from_dense_record_batch(&batch)?;
534
535        let (n_rows, n_cols) = matrix.shape();
536        info!("Loaded dense {} matrix: {} x {}", key, n_rows, n_cols);
537
538        Ok(matrix)
539    }
540
541    /// Load initial data using columnar format from a file path.
542    ///
543    /// Async test helper that avoids any internal blocking runtimes.
544    /// Format dispatch only (#117-4): each branch lives in its own helper.
545    async fn load_dense_from_file(&self, path: &Path) -> StorageResult<DenseMatrix<f64>> {
546        info!("Loading dense matrix from file (async): {:?}", path);
547
548        if !path.exists() {
549            return Err(StorageError::Invalid(format!(
550                "Dense file does not exist: {:?}",
551                path
552            )));
553        }
554
555        let extension = path
556            .extension()
557            .and_then(|e| e.to_str())
558            .ok_or_else(|| StorageError::Invalid(format!("Invalid file path: {:?}", path)))?;
559
560        match extension {
561            "lance" => load_dense_lance(path).await,
562            "parquet" => {
563                let combined = read_parquet_batches(path).await?;
564                dense_matrix_from_parquet(path, &combined)
565            }
566            _ => Err(StorageError::Invalid(format!(
567                "Unsupported file format: {}. Only .lance and .parquet are supported",
568                extension
569            ))),
570        }
571    }
572
573    fn file_path(&self, key: &str) -> PathBuf {
574        crate::generations::artifact_file_path(&self.base_path(), &self.name, key)
575    }
576
577    // =========
578    // ASYNC API (matches StorageBackend)
579    // =========
580
581    async fn save_sparse(
582        &self,
583        key: &str,
584        matrix: &CsMat<f64>,
585        md_path: &Path,
586    ) -> StorageResult<()> {
587        self.validate_initialized(md_path)?;
588        // #102, fail early and typed: an nnz=0 sparse matrix (a fully
589        // disconnected adjacency — a legitimate data state, e.g. from a
590        // too-small eps) cannot be persisted as a sparse artifact. Reject
591        // here, before the metadata commit cycle and before any artifact
592        // write, so no partial directory is left behind.
593        if matrix.nnz() == 0 {
594            return Err(StorageError::Invalid(format!(
595                "sparse '{key}' is fully disconnected: adjacency nnz=0 ({}x{}) — \
596                 raise --eps/--k or gate the input before persisting",
597                matrix.rows(),
598                matrix.cols()
599            )));
600        }
601        let path = self.file_path(key);
602        info!(
603            "Saving sparse {} matrix: {} x {}, nnz={} at {:?}",
604            key,
605            matrix.rows(),
606            matrix.cols(),
607            matrix.nnz(),
608            path
609        );
610
611        let filetype = FileInfo::which_filetype(key)?;
612        {
613            crate::commit::with_commit_actor(&self.metadata_path(), || async {
614                let mut metadata = self.load_metadata().await?;
615                metadata = metadata.add_file(
616                    key,
617                    FileInfo::new(
618                        crate::generations::artifact_file_name(self.get_name().as_str(), key),
619                        filetype.as_str(),
620                        (matrix.rows(), matrix.cols()),
621                        Some(matrix.nnz()),
622                        None,
623                    )?,
624                );
625                self.save_metadata(&metadata).await
626            })
627            .await?;
628
629            let batch = self.to_sparse_record_batch(matrix)?;
630            let uri = Self::path_to_uri(&path)?;
631            self.write_lance_batch_async(uri, batch).await?;
632        }
633        info!("Sparse matrix {} saved successfully", filetype);
634        Ok(())
635    }
636
637    async fn load_sparse(&self, key: &str) -> StorageResult<CsMat<f64>> {
638        info!("Loading sparse {} matrix", key);
639
640        let metadata = self.load_metadata().await?;
641        let filetype = FileInfo::which_filetype(key)?;
642        let file_info = metadata
643            .files
644            .get(key)
645            .ok_or_else(|| StorageError::Invalid(format!("{key} not found in metadata")))?;
646
647        let expected_rows = file_info.rows;
648        let expected_cols = file_info.cols;
649        debug!(
650            "Expected dimensions from storage metadata: {} x {}",
651            expected_rows, expected_cols
652        );
653
654        let path = self.file_path(key);
655        let uri = Self::path_to_uri(&path)?;
656        let batch = self.read_lance_all_batches_async(uri).await?;
657        let matrix = self.from_sparse_record_batch(batch, expected_rows, expected_cols)?;
658        info!(
659            "Sparse {} matrix loaded: {} x {}, nnz={}",
660            filetype,
661            matrix.rows(),
662            matrix.cols(),
663            matrix.nnz()
664        );
665        Ok(matrix)
666    }
667
668    async fn save_lambdas(&self, lambdas: &[f64], md_path: &Path) -> StorageResult<()> {
669        info!("Saving {} lambda values", lambdas.len());
670        self.save_primitive_column::<Float64Type>("lambdas", "lambda", lambdas.to_vec(), md_path)
671            .await
672    }
673
674    async fn load_lambdas(&self) -> StorageResult<Vec<f64>> {
675        let path = self.file_path("lambdas");
676        info!("Loading lambda values from {:?}", path);
677
678        let uri = Self::path_to_uri(&path)?;
679        let batch = self.read_lance_all_batches_async(uri).await?;
680        let arr = batch
681            .column(0)
682            .as_any()
683            .downcast_ref::<Float64Array>()
684            .ok_or_else(|| StorageError::Invalid("lambda column type mismatch".into()))?;
685
686        let lambdas: Vec<f64> = (0..arr.len()).map(|i| arr.value(i)).collect();
687        info!("Loaded {} lambda values", lambdas.len());
688        Ok(lambdas)
689    }
690
691    async fn save_vector(&self, key: &str, vector: &[f64], md_path: &Path) -> StorageResult<()> {
692        info!("Saving {} values for vector {}", vector.len(), key);
693        self.save_primitive_column::<Float64Type>(key, "element", vector.to_vec(), md_path)
694            .await
695    }
696
697    async fn save_index(&self, key: &str, vector: &[usize], md_path: &Path) -> StorageResult<()> {
698        info!("Saving {} values for index {}", vector.len(), key);
699        // Checked cast: usize values above u32::MAX must not be silently
700        // truncated (issue #51).
701        let values = checked_u32_values(vector, "index")?;
702        self.save_primitive_column::<UInt32Type>(key, "id", values, md_path)
703            .await
704    }
705
706    async fn load_vector(&self, filename: &str) -> StorageResult<Vec<f64>> {
707        let path = self.file_path(filename);
708        info!("Loading vector {} from {:?}", filename, 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::<Float64Array>()
716            .ok_or_else(|| StorageError::Invalid("column type mismatch".into()))?;
717
718        let vector: Vec<f64> = (0..arr.len()).map(|i| arr.value(i)).collect();
719        info!("Loaded {} vector values for {}", vector.len(), filename);
720        Ok(vector)
721    }
722
723    async fn load_index(&self, filename: &str) -> StorageResult<Vec<usize>> {
724        let path = self.file_path(filename);
725        info!("Loading vector {} from {:?}", filename, path);
726
727        let uri = Self::path_to_uri(&path)?;
728        let batch = self.read_lance_all_batches_async(uri).await?;
729        let arr = batch
730            .column(0)
731            .as_any()
732            .downcast_ref::<UInt32Array>()
733            .ok_or_else(|| StorageError::Invalid("column type mismatch".into()))?;
734
735        let vector: Vec<usize> = (0..arr.len()).map(|i| arr.value(i) as usize).collect();
736        info!("Loaded {} vector values for {}", vector.len(), filename);
737        Ok(vector)
738    }
739
740    /// Save dense matrix to file in columnar format (col_0, col_1, ..., col_N)
741    ///
742    /// Async test helper that avoids any internal blocking runtimes.
743    async fn save_dense_to_file(data: &DenseMatrix<f64>, path: &Path) -> StorageResult<()> {
744        info!("Saving dense matrix to file (async): {:?}", path);
745
746        // Create missing parent directories instead of failing late in the
747        // format-specific writers.
748        let parent = path
749            .parent()
750            .filter(|p| !p.as_os_str().is_empty())
751            .ok_or_else(|| {
752                StorageError::Invalid(format!("path has no parent directory: {:?}", path))
753            })?;
754        tokio::fs::create_dir_all(parent)
755            .await
756            .map_err(|e| StorageError::Io(format!("create dir {:?}: {}", parent, e)))?;
757        let parent_str = parent
758            .to_str()
759            .ok_or_else(|| StorageError::Invalid(format!("non-UTF8 parent path for {:?}", path)))?
760            .to_string();
761
762        let extension = path
763            .extension()
764            .and_then(|e| e.to_str())
765            .ok_or_else(|| StorageError::Invalid(format!("Invalid file path: {:?}", path)))?;
766
767        let (n_rows, n_cols) = data.shape();
768        info!("Saving matrix: {} rows x {} cols", n_rows, n_cols);
769
770        match extension {
771            "lance" => save_dense_lance(parent_str, data, path).await,
772            "parquet" => save_dense_parquet(parent_str, data, path).await,
773            _ => Err(StorageError::Invalid(format!(
774                "Unsupported file format: {}. Only .lance and .parquet are supported",
775                extension
776            ))),
777        }
778    }
779
780    // =========
781    // NAMED COLLECTIONS (RFC #81)
782    // =========
783
784    async fn save_vectors_with(
785        &self,
786        name: &str,
787        batch: &RecordBatch,
788        properties: &BTreeMap<String, String>,
789        md_path: &Path,
790    ) -> StorageResult<()> {
791        self.validate_initialized(md_path)?;
792        let (batch, dim) = vector_space_collection_batch(batch, properties)?;
793        let rows = batch.num_rows();
794
795        let path = self.file_path(name);
796        info!(
797            "Saving vector-space collection '{}' ({} rows, vector dim {}) at {:?}",
798            name, rows, dim, path
799        );
800        let uri = Self::path_to_uri(&path)?;
801        // Artifact first, registry publish second: the registry update
802        // (through the commit actor) is the single commit point, so a
803        // live entry only exists once the artifact is durable at its
804        // final location (#106 review). A failed artifact write leaves
805        // no registry entry; a failed publish leaves the artifact as
806        // unreferenced residue that a later sweep reclaims — never a
807        // live entry pointing at a missing artifact. Crash recovery for
808        // the residue case is a sweep/gc, not a compensating rewrite.
809        self.write_lance_batch_async(uri, batch).await?;
810        crate::commit::with_commit_actor(&self.metadata_path(), || async {
811            let mut md = self.load_metadata().await?;
812            let mut info = FileInfo::new(
813                crate::generations::artifact_file_name(self.get_name().as_str(), name),
814                "vectors",
815                (rows, dim as usize),
816                None,
817                None,
818            )?;
819            info.properties = properties.clone();
820            md = md.add_file(name, info);
821            self.save_metadata(&md).await
822        })
823        .await?;
824        info!("Vector-space collection '{}' saved successfully", name);
825        Ok(())
826    }
827
828    async fn load_vectors(&self, name: &str) -> StorageResult<RecordBatch> {
829        info!("Loading vector-space collection '{}'", name);
830        self.load_vectors_from_path(&self.file_path(name)).await
831    }
832
833    async fn save_graph_with(
834        &self,
835        name: &str,
836        edges: &[GraphEdge],
837        options: &GraphWriteOptions,
838        md_path: &Path,
839    ) -> StorageResult<()> {
840        self.validate_initialized(md_path)?;
841        let (batch, num_nodes) = graph_record_batch(edges, options)?;
842        let num_nodes_usize = usize::try_from(num_nodes)
843            .map_err(|_| StorageError::Overflow(format!("node count {num_nodes} exceeds usize")))?;
844        let weighted = edges[0].weight.is_some();
845        let width = options.node_id_width;
846
847        let path = self.file_path(name);
848        info!(
849            "Saving graph collection '{}' ({} edges, {} nodes, width {:?}, weight width {:?}, weighted {}) at {:?}",
850            name,
851            edges.len(),
852            num_nodes,
853            width,
854            options.weight_type,
855            weighted,
856            path
857        );
858        let uri = Self::path_to_uri(&path)?;
859        // Artifact first, registry publish second: the registry update
860        // (through the commit actor) is the single commit point, so a
861        // live entry only exists once the artifact is durable at its
862        // final location (#106 review). A failed artifact write leaves
863        // no registry entry; a failed publish leaves the artifact as
864        // unreferenced residue that a later sweep reclaims — never a
865        // live entry pointing at a missing artifact.
866        self.write_lance_batch_async(uri, batch).await?;
867        crate::commit::with_commit_actor(&self.metadata_path(), || async {
868            let mut md = self.load_metadata().await?;
869            let mut info = FileInfo::new(
870                crate::generations::artifact_file_name(self.get_name().as_str(), name),
871                "graph",
872                (num_nodes_usize, num_nodes_usize),
873                Some(edges.len()),
874                None,
875            )?;
876            info.properties = options.properties.clone();
877            info.properties
878                .insert("node_id_width".to_string(), width.as_str().to_string());
879            info.properties
880                .insert("weighted".to_string(), weighted.to_string());
881            info.properties
882                .insert("num_nodes".to_string(), num_nodes.to_string());
883            info.properties.insert(
884                "weight_type".to_string(),
885                options.weight_type.as_str().to_string(),
886            );
887            md = md.add_file(name, info);
888            self.save_metadata(&md).await
889        })
890        .await?;
891        info!("Graph collection '{}' saved successfully", name);
892        Ok(())
893    }
894
895    async fn load_graph(&self, name: &str) -> StorageResult<StoredGraph> {
896        info!("Loading graph collection '{}'", name);
897        self.load_graph_from_path(&self.file_path(name)).await
898    }
899
900    // =========
901    // REGISTRY-FREE COLLECTION I/O (#106)
902    // =========
903
904    async fn save_vectors_to_path(
905        &self,
906        path: &Path,
907        batch: &RecordBatch,
908        properties: &BTreeMap<String, String>,
909    ) -> StorageResult<()> {
910        let (batch, dim) = vector_space_collection_batch(batch, properties)?;
911        ensure_parent_dir(path).await?;
912        info!(
913            "Saving vector-space collection ({} rows, vector dim {}) at {:?} (registry-free)",
914            batch.num_rows(),
915            dim,
916            path
917        );
918        let uri = Self::path_to_uri(path)?;
919        self.write_lance_batch_async(uri, batch).await?;
920        info!("Vector-space collection saved successfully (registry-free)");
921        Ok(())
922    }
923
924    async fn load_vectors_from_path(&self, path: &Path) -> StorageResult<RecordBatch> {
925        info!(
926            "Loading vector-space collection from {:?} (registry-free)",
927            path
928        );
929        let uri = Self::path_to_uri(path)?;
930        let batch = self.read_lance_all_batches_async(uri).await?;
931        validate_vector_space_schema(batch.schema().as_ref())?;
932        info!(
933            "Loaded vector-space collection: {} rows (registry-free)",
934            batch.num_rows()
935        );
936        Ok(batch)
937    }
938
939    async fn save_graph_to_path(
940        &self,
941        path: &Path,
942        edges: &[GraphEdge],
943        options: &GraphWriteOptions,
944    ) -> StorageResult<()> {
945        let (batch, num_nodes) = graph_record_batch(edges, options)?;
946        ensure_parent_dir(path).await?;
947        info!(
948            "Saving graph collection ({} edges, {} nodes, width {:?}, weight width {:?}, weighted {}) at {:?} (registry-free)",
949            edges.len(),
950            num_nodes,
951            options.node_id_width,
952            options.weight_type,
953            edges.first().map(|e| e.weight.is_some()).unwrap_or(false),
954            path
955        );
956        let uri = Self::path_to_uri(path)?;
957        self.write_lance_batch_async(uri, batch).await?;
958        info!("Graph collection saved successfully (registry-free)");
959        Ok(())
960    }
961
962    async fn load_graph_from_path(&self, path: &Path) -> StorageResult<StoredGraph> {
963        self.load_graph_from_path_with_options(path, &GraphReadOptions::default())
964            .await
965    }
966
967    async fn load_graph_from_path_with_options(
968        &self,
969        path: &Path,
970        options: &GraphReadOptions,
971    ) -> StorageResult<StoredGraph> {
972        info!(
973            "Loading graph collection from {:?} (registry-free, strict={})",
974            path, options.strict
975        );
976        let uri = Self::path_to_uri(path)?;
977        let batch = self.read_lance_all_batches_async(uri).await?;
978        let graph = stored_graph_from_batch_with_options(batch, options)?;
979        info!(
980            "Loaded graph collection: {} edges, {} nodes, width {:?}, weight width {:?} (registry-free)",
981            graph.edges.len(),
982            graph.num_nodes,
983            graph.node_id_width,
984            graph.weight_type
985        );
986        Ok(graph)
987    }
988
989    async fn load_scalars(&self, name: &str) -> StorageResult<Vec<f64>> {
990        self.load_scalars_from_path(&self.file_path(name)).await
991    }
992
993    async fn save_scalars_to_path(&self, path: &Path, values: &[f64]) -> StorageResult<()> {
994        if values.is_empty() {
995            return Err(StorageError::Invalid(
996                "empty scalar collections are not supported".into(),
997            ));
998        }
999        ensure_parent_dir(path).await?;
1000        info!(
1001            "Saving scalar collection ({} values) at {:?} (registry-free)",
1002            values.len(),
1003            path
1004        );
1005        // Dataset-level kind (RFC #81-P1): scalar artifacts are
1006        // vector-space collections, mirroring the registry-level
1007        // `CollectionKind::for_filetype("vector")` shim, so logical-kind
1008        // readers (`load_scalars`) can enforce the kind (#106 review). The
1009        // column name is the neutral default `lambda`.
1010        let schema =
1011            Schema::new(vec![Field::new("lambda", DataType::Float64, false)]).with_metadata(
1012                std::collections::HashMap::from([("kind".to_string(), "vector-space".to_string())]),
1013            );
1014        let batch = RecordBatch::try_new(
1015            Arc::new(schema),
1016            vec![Arc::new(Float64Array::from(values.to_vec())) as ArrayRef],
1017        )
1018        .map_err(|e| StorageError::Lance(e.to_string()))?;
1019        let uri = Self::path_to_uri(path)?;
1020        self.write_lance_batch_async(uri, batch).await?;
1021        info!("Scalar collection saved successfully (registry-free)");
1022        Ok(())
1023    }
1024
1025    async fn load_scalars_from_path(&self, path: &Path) -> StorageResult<Vec<f64>> {
1026        info!("Loading scalar collection from {:?} (registry-free)", path);
1027        let uri = Self::path_to_uri(path)?;
1028        let batch = self.read_lance_all_batches_async(uri).await?;
1029
1030        let schema = batch.schema();
1031        // Logical kind gate (#106 review): physical shape determines
1032        // decodability, but the stamped dataset kind determines whether
1033        // the collection is semantically a scalar vector-space
1034        // collection. Missing or mismatched kinds are rejected.
1035        match schema.metadata().get("kind").map(String::as_str) {
1036            Some("vector-space") => {}
1037            other => {
1038                return Err(StorageError::Invalid(format!(
1039                    "scalar collection at {path:?} has dataset kind {other:?}, \
1040                     expected 'vector-space'"
1041                )));
1042            }
1043        }
1044        if schema.fields().len() != 1 {
1045            return Err(StorageError::Invalid(format!(
1046                "scalar collection expects exactly one column, found {}",
1047                schema.fields().len()
1048            )));
1049        }
1050        let field = schema.field(0);
1051        if field.is_nullable() {
1052            return Err(StorageError::Invalid(format!(
1053                "scalar collection column '{}' is nullable",
1054                field.name()
1055            )));
1056        }
1057        let column = batch.column(0);
1058        if column.null_count() != 0 {
1059            return Err(StorageError::Invalid(
1060                "scalar collection column contains nulls".into(),
1061            ));
1062        }
1063        let values: Vec<f64> = match field.data_type() {
1064            DataType::Float64 => {
1065                let a = column
1066                    .as_any()
1067                    .downcast_ref::<Float64Array>()
1068                    .ok_or_else(|| StorageError::Invalid("scalar column type mismatch".into()))?;
1069                (0..a.len()).map(|i| a.value(i)).collect()
1070            }
1071            DataType::Float32 => {
1072                // lossless upcast at the API boundary
1073                let a = column
1074                    .as_any()
1075                    .downcast_ref::<Float32Array>()
1076                    .ok_or_else(|| StorageError::Invalid("scalar column type mismatch".into()))?;
1077                (0..a.len()).map(|i| f64::from(a.value(i))).collect()
1078            }
1079            other => {
1080                return Err(StorageError::Invalid(format!(
1081                    "scalar collection column must be Float64|Float32, found {other:?}"
1082                )));
1083            }
1084        };
1085        info!("Loaded {} scalar values (registry-free)", values.len());
1086        Ok(values)
1087    }
1088
1089    async fn collection_schema_from_path(&self, path: &Path) -> StorageResult<Schema> {
1090        info!("Reading collection schema from {:?} (registry-free)", path);
1091        let path = path.to_path_buf();
1092        tokio::task::spawn_blocking(move || crate::lancefmt::read_schema(&path))
1093            .await
1094            .map_err(|e| StorageError::Io(format!("lancefmt read_schema task failed: {e}")))?
1095    }
1096}
1097
1098impl crate::traits::clustering::ClusteringArtifacts for LanceStorageGraph {
1099    /// Save centroid_map (item-to-centroid assignments)
1100    async fn save_centroid_map(&self, map: &[usize], md_path: &Path) -> StorageResult<()> {
1101        info!("Saving {} centroid map entries", map.len());
1102        // Checked cast: usize values above u32::MAX must not be silently
1103        // truncated (issue #51).
1104        let values = checked_u32_values(map, "centroid map")?;
1105        self.save_primitive_column::<UInt32Type>("centroid_map", "centroid_id", values, md_path)
1106            .await
1107    }
1108
1109    /// Load centroid_map
1110    async fn load_centroid_map(&self) -> StorageResult<Vec<usize>> {
1111        let path = self.file_path("centroid_map");
1112        info!("Loading centroid map from {:?}", path);
1113
1114        let uri = Self::path_to_uri(&path)?;
1115        let batch = self.read_lance_all_batches_async(uri).await?;
1116        let arr = batch
1117            .column(0)
1118            .as_any()
1119            .downcast_ref::<UInt32Array>()
1120            .ok_or_else(|| StorageError::Invalid("centroid_id column type mismatch".into()))?;
1121
1122        let map: Vec<usize> = (0..arr.len()).map(|i| arr.value(i) as usize).collect();
1123        info!("Loaded {} centroid map entries", map.len());
1124        Ok(map)
1125    }
1126
1127    /// Save subcentroid_lambdas (tau values for subcentroids)
1128    async fn save_subcentroid_lambdas(&self, lambdas: &[f64], md_path: &Path) -> StorageResult<()> {
1129        info!("Saving {} subcentroid lambda values", lambdas.len());
1130        self.save_primitive_column::<Float64Type>(
1131            "subcentroid_lambdas",
1132            "subcentroid_lambda",
1133            lambdas.to_vec(),
1134            md_path,
1135        )
1136        .await
1137    }
1138
1139    /// Load subcentroid_lambdas
1140    async fn load_subcentroid_lambdas(&self) -> StorageResult<Vec<f64>> {
1141        let path = self.file_path("subcentroid_lambdas");
1142        info!("Loading subcentroid lambda values from {:?}", path);
1143
1144        let uri = Self::path_to_uri(&path)?;
1145        let batch = self.read_lance_all_batches_async(uri).await?;
1146        let arr = batch
1147            .column(0)
1148            .as_any()
1149            .downcast_ref::<Float64Array>()
1150            .ok_or_else(|| {
1151                StorageError::Invalid("subcentroid_lambda column type mismatch".into())
1152            })?;
1153
1154        let lambdas: Vec<f64> = (0..arr.len()).map(|i| arr.value(i)).collect();
1155        info!("Loaded {} subcentroid lambda values", lambdas.len());
1156        Ok(lambdas)
1157    }
1158
1159    /// Save subcentroids (dense matrix)
1160    async fn save_subcentroids(
1161        &self,
1162        subcentroids: &DenseMatrix<f64>,
1163        md_path: &Path,
1164    ) -> StorageResult<()> {
1165        self.validate_initialized(md_path)?;
1166        let key = "sub_centroids";
1167        let path = self.file_path(key);
1168        let (n_rows, n_cols) = subcentroids.shape();
1169        info!(
1170            "Saving subcentroids matrix {} x {} at {:?}",
1171            n_rows, n_cols, path
1172        );
1173
1174        let batch = self.to_dense_record_batch(subcentroids)?;
1175        {
1176            crate::commit::with_commit_actor(&self.metadata_path(), || async {
1177                let mut metadata = self.load_metadata().await?;
1178                metadata = metadata.add_file(
1179                    key,
1180                    FileInfo::new(
1181                        crate::generations::artifact_file_name(self.get_name().as_str(), key),
1182                        "vector",
1183                        subcentroids.shape(),
1184                        None,
1185                        None,
1186                    )?,
1187                );
1188                self.save_metadata(&metadata).await
1189            })
1190            .await?;
1191
1192            let uri = Self::path_to_uri(&path)?;
1193            self.write_lance_batch_async(uri, batch).await?;
1194        }
1195        debug!("Subcentroids matrix saved successfully");
1196        Ok(())
1197    }
1198
1199    /// Load subcentroids as Vec<Vec<f64>>
1200    async fn load_subcentroids(&self) -> StorageResult<Vec<Vec<f64>>> {
1201        let path = self.file_path("sub_centroids");
1202        info!("Loading sub_centroids from {:?}", path);
1203
1204        let uri = Self::path_to_uri(&path)?;
1205        let batch = self.read_lance_all_batches_async(uri).await?;
1206        let matrix = self.from_dense_record_batch(&batch)?;
1207
1208        // Convert DenseMatrix to Vec<Vec<f64>>
1209        let (n_rows, n_cols) = matrix.shape();
1210        let mut result = Vec::with_capacity(n_rows);
1211
1212        for row_idx in 0..n_rows {
1213            let row: Vec<f64> = (0..n_cols)
1214                .map(|col_idx| *matrix.get((row_idx, col_idx)))
1215                .collect();
1216            result.push(row);
1217        }
1218
1219        info!(
1220            "Loaded sub_centroids: {} x {} as Vec<Vec<f64>>",
1221            n_rows, n_cols
1222        );
1223        Ok(result)
1224    }
1225
1226    /// Save item norms vector
1227    async fn save_item_norms(&self, item_norms: &[f64], md_path: &Path) -> StorageResult<()> {
1228        info!("Saving {} item norm values", item_norms.len());
1229        self.save_primitive_column::<Float64Type>(
1230            "item_norms",
1231            "norm",
1232            item_norms.to_vec(),
1233            md_path,
1234        )
1235        .await
1236    }
1237
1238    /// Load item norms vector
1239    async fn load_item_norms(&self) -> StorageResult<Vec<f64>> {
1240        let path = self.file_path("item_norms");
1241        info!("Loading item norms from {:?}", path);
1242
1243        let uri = Self::path_to_uri(&path)?;
1244        let batch = self.read_lance_all_batches_async(uri).await?;
1245        let arr = batch
1246            .column(0)
1247            .as_any()
1248            .downcast_ref::<Float64Array>()
1249            .ok_or_else(|| StorageError::Invalid("norm column type mismatch".into()))?;
1250
1251        let norms: Vec<f64> = (0..arr.len()).map(|i| arr.value(i)).collect();
1252        info!("Loaded {} item norm values", norms.len());
1253        Ok(norms)
1254    }
1255
1256    async fn save_cluster_assignments(
1257        &self,
1258        assignments: &[Option<usize>],
1259        md_path: &Path,
1260    ) -> StorageResult<()> {
1261        info!("Saving {} cluster assignments", assignments.len());
1262
1263        // Convert Option<usize> to i64 (-1 for None)
1264        let values: Vec<i64> = assignments
1265            .iter()
1266            .map(|opt| opt.map(|v| v as i64).unwrap_or(-1))
1267            .collect();
1268
1269        self.save_primitive_column::<Int64Type>(
1270            "cluster_assignments",
1271            "cluster_id",
1272            values,
1273            md_path,
1274        )
1275        .await
1276    }
1277
1278    async fn load_cluster_assignments(&self) -> StorageResult<Vec<Option<usize>>> {
1279        use arrow::array::Int64Array;
1280        let path = self.file_path("cluster_assignments");
1281        info!("Loading cluster assignments from {:?}", path);
1282
1283        let uri = Self::path_to_uri(&path)?;
1284        let batch = self.read_lance_all_batches_async(uri).await?;
1285        let arr = batch
1286            .column(0)
1287            .as_any()
1288            .downcast_ref::<Int64Array>()
1289            .ok_or_else(|| StorageError::Invalid("cluster_id column type mismatch".into()))?;
1290
1291        let assignments: Vec<Option<usize>> = (0..arr.len())
1292            .map(|i| {
1293                let v = arr.value(i);
1294                if v < 0 { None } else { Some(v as usize) }
1295            })
1296            .collect();
1297        info!("Loaded {} cluster assignments", assignments.len());
1298        Ok(assignments)
1299    }
1300}