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