Skip to main content

genegraph_storage/traits/
backend.rs

1use arrow::array::{Array as ArrowArray, FixedSizeListArray, Float64Array, UInt32Array};
2use arrow::datatypes::{DataType, Field, Schema};
3use arrow::record_batch::RecordBatch;
4use log::{debug, info, trace};
5use smartcore::linalg::basic::arrays::Array;
6use smartcore::linalg::basic::matrix::DenseMatrix;
7use sprs::{CsMat, TriMat};
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use crate::metadata::GeneMetadata;
13use crate::{StorageError, StorageResult};
14
15/// Async storage backend for Lance-based graph and embedding data.
16///
17/// This trait defines the minimal async API required to persist and reload
18/// all artifacts used by Javelin:
19///
20/// - Dense matrices (embeddings, eigenmaps, energy maps)
21/// - Sparse matrices in CSR form (e.g. Laplacians, adjacency)
22/// - Scalar vectors (eigenvalues, norms, generic f64 sequences)
23/// - Index-like vectors (usize mappings and cluster assignments)
24/// - Clustering metadata (centroid maps, subcentroids, lambdas)
25/// - Global metadata describing the dataset layout and dimensions
26///
27/// ## Initialization
28///
29/// Storage must be initialized before saving any data:
30///
31/// 1. Call `save_metadata()` once to write an initial `*_metadata.json`.
32/// 2. Subsequent `save_*` calls validate that metadata exists and is consistent.
33/// 3. `exists()` can be used to detect and reuse an existing initialized store.
34///
35/// Filenames are conventionally:
36///
37/// ```ignore
38/// <base dir>/<instance name or name id>_<key>.lance
39/// ```
40///
41/// ## Async usage
42///
43/// All I/O functions are async and intended to be called from a Tokio runtime.
44/// Implementations (e.g. `LanceStorage`) must not create their own runtimes or
45/// block on I/O internally.
46///
47/// ## High-level flow
48///
49/// - Dense data:
50///   - `save_dense("raw_input", &matrix, md_path)`
51///   - `load_dense("raw_input")`
52///
53/// - Sparse data:
54///   - `save_sparse("laplacian", &csr, md_path)`
55///   - `load_sparse("laplacian")`
56///
57/// - Scalars and indices:
58///   - `save_lambdas`, `load_lambdas`
59///   - `save_vector`, `load_vector`
60///   - `save_index`, `load_index`
61///   - `save_centroid_map`, `load_centroid_map`
62///   - `save_item_norms`, `load_item_norms`
63///   - `save_cluster_assignments`, `load_cluster_assignments`
64///
65/// - Clustering structure:
66///   - `save_subcentroids`, `load_subcentroids`
67///   - `save_subcentroid_lambdas`, `load_subcentroid_lambdas`
68///
69/// Implementations are free to choose the on-disk layout as long as they honor
70/// these logical keys and round-trip semantics.
71pub trait StorageBackend: Send + Sync {
72    /// Base directory of the instance
73    fn get_base(&self) -> String;
74    /// Name of the instance
75    fn get_name(&self) -> String;
76
77    ///
78    /// Returns `true` and the path to the metadata file if metadata file exists and is valid,
79    /// `false` otherwise.
80    /// This is used to avoid overwriting existing indexes.
81    fn exists(path: &str) -> (bool, Option<PathBuf>) {
82        let base_path = std::path::PathBuf::from(path);
83        if !base_path.exists() {
84            debug!("StorageBackend: path {:?} does not exist", base_path);
85            return (false, None);
86        }
87
88        // Check for any _metadata.json file in the directory
89        if let Ok(entries) = std::fs::read_dir(&base_path) {
90            for entry in entries.flatten() {
91                let path = entry.path();
92                if let Some(name) = path.file_name().and_then(|n| n.to_str())
93                    && name.ends_with("_metadata.json")
94                {
95                    debug!("StorageBackend::exists: found metadata file at {:?}", path);
96                    return (true, Some(path));
97                }
98            }
99        }
100        (false, None)
101    }
102
103    /// Returns the base directory path.
104    fn base_path(&self) -> PathBuf;
105    /// Returns the metadata path.
106    fn metadata_path(&self) -> PathBuf;
107    /// return the base path as file:// string
108    fn basepath_to_uri(&self) -> StorageResult<String>;
109
110    /// Load initial data using columnar format from a file path.
111    /// Implementations may use this as a helper for async `load_dense`.
112    async fn load_dense_from_file(&self, path: &Path) -> StorageResult<DenseMatrix<f64>>;
113
114    /// Compute the full Lance/parquet file path for a logical filetype.
115    fn file_path(&self, key: &str) -> PathBuf;
116
117    /// Converts a full file path to a `file://` URI for Lance.
118    ///
119    /// The path must be absolute; relative paths are rejected instead of
120    /// being silently joined against a guessed working directory.
121    /// Non-existing paths are allowed (saves write to fresh locations);
122    /// existing ones are canonicalised first.
123    fn path_to_uri(path: &Path) -> StorageResult<String> {
124        let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
125        if !resolved.is_absolute() {
126            return Err(StorageError::Invalid(format!(
127                "cannot convert relative path {:?} to a file:// URI; pass an absolute path",
128                path
129            )));
130        }
131        url::Url::from_file_path(&resolved)
132            .map(|u| u.to_string())
133            .map_err(|_| {
134                StorageError::Invalid(format!("cannot express {:?} as a file:// URI", resolved))
135            })
136    }
137
138    /// Validates that the storage directory is properly initialized with metadata.
139    ///
140    /// # Returns
141    ///
142    /// Returns `Ok(())` if metadata file exists, otherwise returns an error.
143    fn validate_initialized(&self, md_path: &Path) -> StorageResult<()> {
144        assert_eq!(self.metadata_path(), *md_path);
145        if !md_path.exists() {
146            return Err(StorageError::Invalid(format!(
147                "Storage not initialized: metadata file missing at {:?}. \
148                 Call save_metadata() or save_eigenmaps_all()/save_energymaps_all() first.",
149                md_path
150            )));
151        }
152        Ok(())
153    }
154
155    // =========
156    // ASYNC API
157    // =========
158
159    /// Converts a dense matrix to a RecordBatch in vector format (Lance-optimized).
160    /// Each row of the matrix becomes a single FixedSizeList entry.
161    ///
162    /// Arguments:
163    /// * matrix - Dense matrix to convert (N rows × F cols)
164    ///
165    /// Returns:
166    /// RecordBatch with schema: { vector: FixedSizeList<Float64>[F] }
167    fn to_dense_record_batch(
168        &self,
169        matrix: &DenseMatrix<f64>,
170    ) -> Result<RecordBatch, StorageError> {
171        let (rows, cols) = (matrix.shape().0, matrix.shape().1);
172
173        debug!(
174            "Converting dense matrix to RecordBatch (vector format): {}x{}",
175            rows, cols
176        );
177
178        if rows == 0 || cols == 0 {
179            return Err(StorageError::Invalid(
180                "Cannot convert empty matrix to RecordBatch".to_string(),
181            ));
182        }
183
184        // Flatten matrix row-by-row into a single Vec<f64>
185        let mut values: Vec<f64> = Vec::with_capacity(rows * cols);
186        for r in 0..rows {
187            for c in 0..cols {
188                values.push(*matrix.get((r, c)));
189            }
190        }
191
192        // Create FixedSizeList field: each entry is a vector of length cols
193        let value_field = Field::new("item", DataType::Float64, false);
194        let list_field = Field::new(
195            "vector",
196            DataType::FixedSizeList(Arc::new(value_field), cols as i32),
197            false,
198        );
199
200        let schema = Schema::new(vec![list_field]);
201
202        // Build the FixedSizeList array
203        let values_array = Float64Array::from(values);
204        let list_array = FixedSizeListArray::new(
205            Arc::new(Field::new("item", DataType::Float64, false)),
206            cols as i32,
207            Arc::new(values_array),
208            None, // No nulls
209        );
210
211        let batch = RecordBatch::try_new(Arc::new(schema), vec![Arc::new(list_array)])
212            .map_err(|e| StorageError::Lance(e.to_string()))?;
213
214        trace!(
215            "RecordBatch created with {} rows (vectors of length {})",
216            batch.num_rows(),
217            cols
218        );
219
220        Ok(batch)
221    }
222
223    /// Reconstructs a dense matrix from a RecordBatch in vector format.
224    ///
225    /// Arguments:
226    /// * batch - RecordBatch containing FixedSizeList<Float64> vectors
227    ///
228    /// Returns:
229    /// DenseMatrix in column-major format (smartcore convention)
230    #[allow(clippy::wrong_self_convention)]
231    fn from_dense_record_batch(
232        &self,
233        batch: &RecordBatch,
234    ) -> Result<DenseMatrix<f64>, StorageError> {
235        use std::mem;
236
237        debug!("Reconstructing dense matrix from RecordBatch (vector format)");
238        debug!("Batch has {} columns", batch.num_columns());
239
240        if batch.num_columns() != 1 {
241            return Err(StorageError::Invalid(format!(
242                "Expected Lance row-major format with 1 FixedSizeList<Float64> column, but found {} columns. \
243                  This parquet file appears to be in wide format (feature-per-column). \
244                  Convert it first using: \
245                  `python -c \"import pyarrow.parquet as pq; import pyarrow.compute as pc; \
246                  tbl = pq.read_table('input.parquet'); \
247                  import pyarrow as pa; \
248                  vectors = pa.array([row.as_py() for row in tbl.to_pylist()], type=pa.list_(pa.float64(), len(tbl.column_names))); \
249                  new_tbl = pa.table({{'vector': vectors}}); \
250                  pq.write_table(new_tbl, 'output.parquet')\"` \
251                  or use a Lance-native writer in your data pipeline.",
252                batch.num_columns()
253            )));
254        }
255
256        debug!("Extracting FixedSizeList column");
257        let column = batch.column(0);
258        let list_array = column
259            .as_any()
260            .downcast_ref::<FixedSizeListArray>()
261            .ok_or_else(|| {
262                StorageError::Invalid(format!(
263                    "Column 0 is not FixedSizeList (found type: {:?}). \
264                      Expected Lance row-major format with a single FixedSizeList<Float64> column.",
265                    column.data_type()
266                ))
267            })?;
268
269        let rows = list_array.len();
270        let cols = list_array.value_length() as usize;
271
272        debug!("Matrix dimensions: {}x{}", rows, cols);
273
274        // Guard against excessive allocations
275        let total = rows
276            .checked_mul(cols)
277            .ok_or_else(|| StorageError::Invalid("Matrix size overflow (rows*cols)".to_string()))?;
278        let bytes = total
279            .checked_mul(mem::size_of::<f64>())
280            .ok_or_else(|| StorageError::Invalid("Byte size overflow".to_string()))?;
281
282        const MAX_BYTES: usize = 4usize * 1024 * 1024 * 1024; // 4 GiB
283        if bytes > MAX_BYTES {
284            return Err(StorageError::Invalid(format!(
285                "Dense load would allocate {} bytes for {}x{} matrix; exceeds 4GiB cap. \
286                  Enable --reduce-dim or shard your input data.",
287                bytes, rows, cols
288            )));
289        }
290
291        // Extract Float64 values
292        let values_array = list_array
293            .values()
294            .as_any()
295            .downcast_ref::<Float64Array>()
296            .ok_or_else(|| {
297                StorageError::Invalid("FixedSizeList values are not Float64Array".to_string())
298            })?;
299
300        debug!("Converting row-major to column-major");
301        let mut data = vec![0.0f64; total];
302        for r in 0..rows {
303            for c in 0..cols {
304                let row_major_idx = r * cols + c;
305                let col_major_idx = c * rows + r;
306                data[col_major_idx] = values_array.value(row_major_idx);
307            }
308        }
309
310        debug!("Creating DenseMatrix");
311        DenseMatrix::new(rows, cols, data, true).map_err(|e| StorageError::Invalid(e.to_string()))
312    }
313
314    /// Converts a sparse CSR matrix to a RecordBatch in columnar format.
315    ///
316    /// Only non-zero entries are stored.
317    fn to_sparse_record_batch(&self, m: &CsMat<f64>) -> StorageResult<RecordBatch> {
318        debug!(
319            "Converting sparse matrix to RecordBatch: {} x {}, nnz={}",
320            m.rows(),
321            m.cols(),
322            m.nnz()
323        );
324
325        let mut row_idx = Vec::with_capacity(m.nnz());
326        let mut col_idx = Vec::with_capacity(m.nnz());
327        let mut vals = Vec::with_capacity(m.nnz());
328
329        for (v, (r, c)) in m.iter() {
330            row_idx.push(r as u32);
331            col_idx.push(c as u32);
332            vals.push(*v);
333        }
334
335        // Store actual dimensions in schema metadata
336        let mut schema_metadata = std::collections::HashMap::new();
337        schema_metadata.insert("rows".to_string(), m.rows().to_string());
338        schema_metadata.insert("cols".to_string(), m.cols().to_string());
339        schema_metadata.insert("nnz".to_string(), m.nnz().to_string());
340
341        let schema = Schema::new(vec![
342            Field::new("row", DataType::UInt32, false),
343            Field::new("col", DataType::UInt32, false),
344            Field::new("value", DataType::Float64, false),
345        ])
346        .with_metadata(schema_metadata);
347
348        let batch = RecordBatch::try_new(
349            Arc::new(schema),
350            vec![
351                Arc::new(UInt32Array::from(row_idx)) as _,
352                Arc::new(UInt32Array::from(col_idx)) as _,
353                Arc::new(Float64Array::from(vals)) as _,
354            ],
355        )
356        .map_err(|e| StorageError::Lance(e.to_string()))?;
357
358        trace!(
359            "Sparse RecordBatch created with {} entries",
360            batch.num_rows()
361        );
362        Ok(batch)
363    }
364
365    /// Reconstructs a sparse CSR matrix from a RecordBatch in columnar format.
366    ///
367    /// * `batch` - RecordBatch containing (`row`, `col`, `value`) triplets
368    /// * `expected_rows` / `expected_cols` - dimensions taken from metadata
369    #[allow(clippy::wrong_self_convention)]
370    fn from_sparse_record_batch(
371        &self,
372        batch: RecordBatch,
373        expected_rows: usize,
374        expected_cols: usize,
375    ) -> StorageResult<CsMat<f64>> {
376        debug!("Reconstructing sparse matrix from RecordBatch");
377
378        let row_arr = batch
379            .column(0)
380            .as_any()
381            .downcast_ref::<UInt32Array>()
382            .ok_or_else(|| StorageError::Invalid("row column type mismatch".into()))?;
383        let col_arr = batch
384            .column(1)
385            .as_any()
386            .downcast_ref::<UInt32Array>()
387            .ok_or_else(|| StorageError::Invalid("col column type mismatch".into()))?;
388        let val_arr = batch
389            .column(2)
390            .as_any()
391            .downcast_ref::<Float64Array>()
392            .ok_or_else(|| StorageError::Invalid("value column type mismatch".into()))?;
393
394        let n = row_arr.len();
395        if n == 0 {
396            debug!(
397                "Empty RecordBatch, returning {}x{} sparse matrix",
398                expected_rows, expected_cols
399            );
400            return Ok(CsMat::zero((expected_rows, expected_cols)));
401        }
402
403        // Try to read dimensions from schema metadata (for validation)
404        let schema = batch.schema();
405        let schema_metadata = schema.metadata();
406        if let (Some(rows_str), Some(cols_str)) =
407            (schema_metadata.get("rows"), schema_metadata.get("cols"))
408        {
409            let schema_rows = rows_str.parse::<usize>().ok();
410            let schema_cols = cols_str.parse::<usize>().ok();
411            if schema_rows != Some(expected_rows) || schema_cols != Some(expected_cols) {
412                panic!(
413                    "Schema metadata dimensions ({:?}x{:?}) don't match storage metadata ({}x{})",
414                    schema_rows, schema_cols, expected_rows, expected_cols
415                );
416            } else {
417                debug!(
418                    "Schema metadata matches storage metadata: {}x{}",
419                    expected_rows, expected_cols
420                );
421            }
422        }
423
424        let rows = expected_rows;
425        let cols = expected_cols;
426        debug!(
427            "Reconstructing {}x{} sparse matrix from {} entries",
428            rows, cols, n
429        );
430
431        let mut trimat = TriMat::new((rows, cols));
432        for i in 0..n {
433            let r = row_arr.value(i) as usize;
434            let c = col_arr.value(i) as usize;
435            let v = val_arr.value(i);
436
437            if r >= rows || c >= cols {
438                return Err(StorageError::Invalid(format!(
439                    "Index out of bounds: ({}, {}) in {}x{} matrix",
440                    r, c, rows, cols
441                )));
442            }
443            trimat.add_triplet(r, c, v);
444        }
445
446        let result = trimat.to_csr();
447        if result.rows() != rows || result.cols() != cols {
448            return Err(StorageError::Invalid(format!(
449                "Dimension mismatch after reconstruction: expected {}x{}, got {}x{}",
450                rows,
451                cols,
452                result.rows(),
453                result.cols()
454            )));
455        }
456
457        Ok(result)
458    }
459
460    /// Saves a dense matrix. Requires metadata to exist.
461    async fn save_dense(
462        &self,
463        key: &str,
464        matrix: &DenseMatrix<f64>,
465        md_path: &Path,
466    ) -> StorageResult<()>;
467
468    /// Loads a dense matrix from storage.
469    async fn load_dense(&self, key: &str) -> StorageResult<DenseMatrix<f64>>;
470
471    /// Saves a sparse matrix. Requires metadata to exist.
472    async fn save_sparse(
473        &self,
474        key: &str,
475        matrix: &CsMat<f64>,
476        md_path: &Path,
477    ) -> StorageResult<()>;
478
479    /// Loads a sparse matrix from storage.
480    async fn load_sparse(&self, key: &str) -> StorageResult<CsMat<f64>>;
481
482    /// Saves lambda eigenvalues. Requires metadata to exist.
483    async fn save_lambdas(&self, lambdas: &[f64], md_path: &Path) -> StorageResult<()>;
484
485    /// Loads lambda eigenvalues from storage.
486    async fn load_lambdas(&self) -> StorageResult<Vec<f64>>;
487
488    /// Initializes storage by saving metadata. Must be called first.
489    async fn save_metadata(&self, metadata: &GeneMetadata) -> StorageResult<PathBuf> {
490        let path = self.metadata_path();
491        info!("Saving metadata to {:?}", path);
492        fs::create_dir_all(self.base_path()).map_err(|e| StorageError::Io(e.to_string()))?;
493        let s = serde_json::to_string_pretty(metadata).map_err(StorageError::Serde)?;
494        fs::write(&path, s).map_err(|e| StorageError::Io(e.to_string()))?;
495        info!("Metadata saved successfully");
496        Ok(path)
497    }
498
499    /// Loads metadata from storage.
500    async fn load_metadata(&self) -> StorageResult<GeneMetadata> {
501        let filename = self.metadata_path();
502        info!("Loading metadata from {:?}", filename);
503        let s = fs::read_to_string(filename).map_err(|e| StorageError::Io(e.to_string()))?;
504        let md: GeneMetadata = serde_json::from_str(&s).map_err(StorageError::Serde)?;
505        info!("Metadata loaded successfully");
506        Ok(md)
507    }
508
509    /// Save vectors that are not lambdas but indices.
510    #[allow(dead_code)]
511    async fn save_index(&self, key: &str, vector: &[usize], md_path: &Path) -> StorageResult<()>;
512
513    /// save a generic f64 sequence
514    async fn save_vector(&self, key: &str, vector: &[f64], md_path: &Path) -> StorageResult<()>;
515
516    /// Save centroid_map (vector of usize mapping items to centroids)
517    async fn save_centroid_map(&self, map: &[usize], md_path: &Path) -> StorageResult<()>;
518
519    /// Load centroid_map
520    async fn load_centroid_map(&self) -> StorageResult<Vec<usize>>;
521    /// Save subcentroid_lambdas (tau values for subcentroids)
522    async fn save_subcentroid_lambdas(&self, lambdas: &[f64], md_path: &Path) -> StorageResult<()>;
523    /// Load subcentroid_lambdas
524    async fn load_subcentroid_lambdas(&self) -> StorageResult<Vec<f64>>;
525    /// Save subcentroids (dense matrix)
526    async fn save_subcentroids(
527        &self,
528        subcentroids: &DenseMatrix<f64>,
529        md_path: &Path,
530    ) -> StorageResult<()>;
531    /// Load subcentroids
532    async fn load_subcentroids(&self) -> StorageResult<Vec<Vec<f64>>>;
533
534    /// Save item norms (precomputed L2 norms for fast distance computation)
535    async fn save_item_norms(&self, item_norms: &[f64], md_path: &Path) -> StorageResult<()>;
536
537    /// Load item norms
538    async fn load_item_norms(&self) -> StorageResult<Vec<f64>>;
539
540    /// Save cluster assignments (Vec<Option<usize>>)
541    async fn save_cluster_assignments(
542        &self,
543        assignments: &[Option<usize>],
544        md_path: &Path,
545    ) -> StorageResult<()>;
546
547    /// Load cluster assignments
548    async fn load_cluster_assignments(&self) -> StorageResult<Vec<Option<usize>>>;
549
550    /// Load index or generic usize vector from storage.
551    #[allow(dead_code)]
552    async fn load_index(&self, key: &str) -> StorageResult<Vec<usize>>;
553
554    async fn load_vector(&self, key: &str) -> StorageResult<Vec<f64>>;
555
556    async fn save_dense_to_file(data: &DenseMatrix<f64>, path: &Path) -> StorageResult<()>;
557}