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::graph::{GraphEdge, GraphWriteOptions, StoredGraph};
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///
62/// Clustering artifacts (centroid maps, subcentroids, item norms, cluster
63/// assignments) live on the [`crate::traits::clustering::ClusteringArtifacts`]
64/// sub-trait (#117): the base backend stays domain-neutral, and general-purpose
65/// implementors do not stub ML-pipeline vocabulary.
66///
67/// Implementations are free to choose the on-disk layout as long as they honor
68/// these logical keys and round-trip semantics.
69///
70/// ## Extension policy (#117-2)
71///
72/// The core trait carries only domain-neutral, generic columnar surface.
73/// Consumer-specific vocabulary (clustering pipelines, ML pipelines) belongs
74/// on extension sub-traits, so adding surface there never breaks external
75/// `StorageBackend` implementors. Core-trait additions must provide a default
76/// implementation whenever one can be derived from existing methods.
77pub trait StorageBackend: Send + Sync {
78    /// Base directory of the instance
79    fn get_base(&self) -> String;
80    /// Name of the instance
81    fn get_name(&self) -> String;
82
83    ///
84    /// Returns `true` and the path to the metadata file if metadata file exists and is valid,
85    /// `false` otherwise.
86    /// This is used to avoid overwriting existing indexes.
87    fn exists(path: &str) -> (bool, Option<PathBuf>) {
88        let base_path = std::path::PathBuf::from(path);
89        if !base_path.exists() {
90            debug!("StorageBackend: path {:?} does not exist", base_path);
91            return (false, None);
92        }
93
94        // Check for any _metadata.json file in the directory
95        if let Ok(entries) = std::fs::read_dir(&base_path) {
96            for entry in entries.flatten() {
97                let path = entry.path();
98                if let Some(name) = path.file_name().and_then(|n| n.to_str())
99                    && name.ends_with("_metadata.json")
100                {
101                    debug!("StorageBackend::exists: found metadata file at {:?}", path);
102                    return (true, Some(path));
103                }
104            }
105        }
106        (false, None)
107    }
108
109    /// Returns the base directory path.
110    fn base_path(&self) -> PathBuf;
111    /// Returns the metadata path.
112    fn metadata_path(&self) -> PathBuf;
113    /// return the base path as file:// string
114    fn basepath_to_uri(&self) -> StorageResult<String>;
115
116    /// Load initial data using columnar format from a file path.
117    /// Implementations may use this as a helper for async `load_dense`.
118    ///
119    /// Supported parquet layouts: `vector: FixedSizeList<Float64>` (as
120    /// written by [`Self::save_dense_to_file`]) and the legacy wide layout
121    /// with `Float64` columns named `col_0..col_N`; anything else is rejected
122    /// with [`StorageError::Invalid`]. Supported lance layout: the vector
123    /// layout, read from the dataset directory at `path`.
124    async fn load_dense_from_file(&self, path: &Path) -> StorageResult<DenseMatrix<f64>>;
125
126    /// Compute the full Lance/parquet file path for a logical filetype.
127    fn file_path(&self, key: &str) -> PathBuf;
128
129    /// Converts a full file path to a `file://` URI for Lance.
130    ///
131    /// The path must be absolute; relative paths are rejected instead of
132    /// being silently joined against a guessed working directory.
133    /// Non-existing paths are allowed (saves write to fresh locations) and
134    /// fall back to the given absolute path; any other resolution failure
135    /// (permissions, symlink loops, ...) is surfaced as an error instead of
136    /// being silently replaced by the unresolved path.
137    fn path_to_uri(path: &Path) -> StorageResult<String> {
138        let resolved = match path.canonicalize() {
139            Ok(canonical) => canonical,
140            Err(e) if e.kind() == std::io::ErrorKind::NotFound => path.to_path_buf(),
141            Err(e) => {
142                return Err(StorageError::Io(format!(
143                    "Failed to resolve path `{}`: {}",
144                    path.display(),
145                    e
146                )));
147            }
148        };
149        if !resolved.is_absolute() {
150            return Err(StorageError::Invalid(format!(
151                "cannot convert relative path {:?} to a file:// URI; pass an absolute path",
152                path
153            )));
154        }
155        url::Url::from_file_path(&resolved)
156            .map(|u| u.to_string())
157            .map_err(|_| {
158                StorageError::Invalid(format!("cannot express {:?} as a file:// URI", resolved))
159            })
160    }
161
162    /// Validates that the storage directory is properly initialized with metadata.
163    ///
164    /// # Returns
165    ///
166    /// Returns `Ok(())` if metadata file exists, otherwise returns an error.
167    fn validate_initialized(&self, md_path: &Path) -> StorageResult<()> {
168        let expected = self.metadata_path();
169        if expected != *md_path {
170            return Err(StorageError::InvalidState(format!(
171                "metadata path mismatch: expected `{}`, found `{}`",
172                expected.display(),
173                md_path.display()
174            )));
175        }
176        if !md_path.exists() {
177            return Err(StorageError::Invalid(format!(
178                "Storage not initialized: metadata file missing at {:?}. \
179                 Call save_metadata() or save_eigenmaps_all()/save_energymaps_all() first.",
180                md_path
181            )));
182        }
183        Ok(())
184    }
185
186    // =========
187    // ASYNC API
188    // =========
189
190    /// Converts a dense matrix to a RecordBatch in vector format (Lance-optimized).
191    /// Each row of the matrix becomes a single FixedSizeList entry.
192    ///
193    /// Arguments:
194    /// * matrix - Dense matrix to convert (N rows × F cols)
195    ///
196    /// Returns:
197    /// RecordBatch with schema: { vector: FixedSizeList<Float64>[F] }
198    fn to_dense_record_batch(
199        &self,
200        matrix: &DenseMatrix<f64>,
201    ) -> Result<RecordBatch, StorageError> {
202        let (rows, cols) = (matrix.shape().0, matrix.shape().1);
203
204        debug!(
205            "Converting dense matrix to RecordBatch (vector format): {}x{}",
206            rows, cols
207        );
208
209        if rows == 0 || cols == 0 {
210            return Err(StorageError::Invalid(
211                "Cannot convert empty matrix to RecordBatch".to_string(),
212            ));
213        }
214
215        // Flatten matrix row-by-row into a single Vec<f64>
216        let mut values: Vec<f64> = Vec::with_capacity(rows * cols);
217        for r in 0..rows {
218            for c in 0..cols {
219                values.push(*matrix.get((r, c)));
220            }
221        }
222
223        // Create FixedSizeList field: each entry is a vector of length cols
224        let value_field = Field::new("item", DataType::Float64, false);
225        let list_field = Field::new(
226            "vector",
227            DataType::FixedSizeList(Arc::new(value_field), cols as i32),
228            false,
229        );
230
231        let schema = Schema::new(vec![list_field]);
232
233        // Build the FixedSizeList array
234        let values_array = Float64Array::from(values);
235        let list_array = FixedSizeListArray::new(
236            Arc::new(Field::new("item", DataType::Float64, false)),
237            cols as i32,
238            Arc::new(values_array),
239            None, // No nulls
240        );
241
242        let batch = RecordBatch::try_new(Arc::new(schema), vec![Arc::new(list_array)])
243            .map_err(|e| StorageError::Lance(e.to_string()))?;
244
245        trace!(
246            "RecordBatch created with {} rows (vectors of length {})",
247            batch.num_rows(),
248            cols
249        );
250
251        Ok(batch)
252    }
253
254    /// Reconstructs a dense matrix from a RecordBatch in vector format.
255    ///
256    /// Arguments:
257    /// * batch - RecordBatch containing FixedSizeList<Float64> vectors
258    ///
259    /// Returns:
260    /// DenseMatrix in column-major format (smartcore convention)
261    #[allow(clippy::wrong_self_convention)]
262    fn from_dense_record_batch(
263        &self,
264        batch: &RecordBatch,
265    ) -> Result<DenseMatrix<f64>, StorageError> {
266        use std::mem;
267
268        debug!("Reconstructing dense matrix from RecordBatch (vector format)");
269        debug!("Batch has {} columns", batch.num_columns());
270
271        if batch.num_columns() != 1 {
272            return Err(StorageError::Invalid(format!(
273                "Expected Lance row-major format with 1 FixedSizeList<Float64> column, but found {} columns. \
274                  This parquet file appears to be in wide format (feature-per-column). \
275                  Convert it first using: \
276                  `python -c \"import pyarrow.parquet as pq; import pyarrow.compute as pc; \
277                  tbl = pq.read_table('input.parquet'); \
278                  import pyarrow as pa; \
279                  vectors = pa.array([row.as_py() for row in tbl.to_pylist()], type=pa.list_(pa.float64(), len(tbl.column_names))); \
280                  new_tbl = pa.table({{'vector': vectors}}); \
281                  pq.write_table(new_tbl, 'output.parquet')\"` \
282                  or use a Lance-native writer in your data pipeline.",
283                batch.num_columns()
284            )));
285        }
286
287        debug!("Extracting FixedSizeList column");
288        let column = batch.column(0);
289        let list_array = column
290            .as_any()
291            .downcast_ref::<FixedSizeListArray>()
292            .ok_or_else(|| {
293                StorageError::Invalid(format!(
294                    "Column 0 is not FixedSizeList (found type: {:?}). \
295                      Expected Lance row-major format with a single FixedSizeList<Float64> column.",
296                    column.data_type()
297                ))
298            })?;
299
300        let rows = list_array.len();
301        let cols = list_array.value_length() as usize;
302
303        debug!("Matrix dimensions: {}x{}", rows, cols);
304
305        // Guard against excessive allocations
306        let total = rows
307            .checked_mul(cols)
308            .ok_or_else(|| StorageError::Invalid("Matrix size overflow (rows*cols)".to_string()))?;
309        let bytes = total
310            .checked_mul(mem::size_of::<f64>())
311            .ok_or_else(|| StorageError::Invalid("Byte size overflow".to_string()))?;
312
313        const MAX_BYTES: usize = 4usize * 1024 * 1024 * 1024; // 4 GiB
314        if bytes > MAX_BYTES {
315            return Err(StorageError::Invalid(format!(
316                "Dense load would allocate {} bytes for {}x{} matrix; exceeds 4GiB cap. \
317                  Enable --reduce-dim or shard your input data.",
318                bytes, rows, cols
319            )));
320        }
321
322        // Extract Float64 values
323        let values_array = list_array
324            .values()
325            .as_any()
326            .downcast_ref::<Float64Array>()
327            .ok_or_else(|| {
328                StorageError::Invalid("FixedSizeList values are not Float64Array".to_string())
329            })?;
330
331        debug!("Converting row-major to column-major");
332        let mut data = vec![0.0f64; total];
333        for r in 0..rows {
334            for c in 0..cols {
335                let row_major_idx = r * cols + c;
336                let col_major_idx = c * rows + r;
337                data[col_major_idx] = values_array.value(row_major_idx);
338            }
339        }
340
341        debug!("Creating DenseMatrix");
342        DenseMatrix::new(rows, cols, data, true).map_err(|e| StorageError::Invalid(e.to_string()))
343    }
344
345    /// Converts a sparse CSR matrix to a RecordBatch in columnar format.
346    ///
347    /// Only non-zero entries are stored.
348    fn to_sparse_record_batch(&self, m: &CsMat<f64>) -> StorageResult<RecordBatch> {
349        debug!(
350            "Converting sparse matrix to RecordBatch: {} x {}, nnz={}",
351            m.rows(),
352            m.cols(),
353            m.nnz()
354        );
355
356        let mut row_idx = Vec::with_capacity(m.nnz());
357        let mut col_idx = Vec::with_capacity(m.nnz());
358        let mut vals = Vec::with_capacity(m.nnz());
359
360        for (v, (r, c)) in m.iter() {
361            row_idx.push(r as u32);
362            col_idx.push(c as u32);
363            vals.push(*v);
364        }
365
366        // Store actual dimensions in schema metadata
367        let mut schema_metadata = std::collections::HashMap::new();
368        schema_metadata.insert("rows".to_string(), m.rows().to_string());
369        schema_metadata.insert("cols".to_string(), m.cols().to_string());
370        schema_metadata.insert("nnz".to_string(), m.nnz().to_string());
371
372        let schema = Schema::new(vec![
373            Field::new("row", DataType::UInt32, false),
374            Field::new("col", DataType::UInt32, false),
375            Field::new("value", DataType::Float64, false),
376        ])
377        .with_metadata(schema_metadata);
378
379        let batch = RecordBatch::try_new(
380            Arc::new(schema),
381            vec![
382                Arc::new(UInt32Array::from(row_idx)) as _,
383                Arc::new(UInt32Array::from(col_idx)) as _,
384                Arc::new(Float64Array::from(vals)) as _,
385            ],
386        )
387        .map_err(|e| StorageError::Lance(e.to_string()))?;
388
389        trace!(
390            "Sparse RecordBatch created with {} entries",
391            batch.num_rows()
392        );
393        Ok(batch)
394    }
395
396    /// Reconstructs a sparse CSR matrix from a RecordBatch in columnar format.
397    ///
398    /// * `batch` - RecordBatch containing (`row`, `col`, `value`) triplets
399    /// * `expected_rows` / `expected_cols` - dimensions taken from metadata
400    #[allow(clippy::wrong_self_convention)]
401    fn from_sparse_record_batch(
402        &self,
403        batch: RecordBatch,
404        expected_rows: usize,
405        expected_cols: usize,
406    ) -> StorageResult<CsMat<f64>> {
407        debug!("Reconstructing sparse matrix from RecordBatch");
408
409        let row_arr = batch
410            .column(0)
411            .as_any()
412            .downcast_ref::<UInt32Array>()
413            .ok_or_else(|| StorageError::Invalid("row column type mismatch".into()))?;
414        let col_arr = batch
415            .column(1)
416            .as_any()
417            .downcast_ref::<UInt32Array>()
418            .ok_or_else(|| StorageError::Invalid("col column type mismatch".into()))?;
419        let val_arr = batch
420            .column(2)
421            .as_any()
422            .downcast_ref::<Float64Array>()
423            .ok_or_else(|| StorageError::Invalid("value column type mismatch".into()))?;
424
425        let n = row_arr.len();
426        if n == 0 {
427            debug!(
428                "Empty RecordBatch, returning {}x{} sparse matrix",
429                expected_rows, expected_cols
430            );
431            return Ok(CsMat::zero((expected_rows, expected_cols)));
432        }
433
434        // Try to read dimensions from schema metadata (for validation)
435        let schema = batch.schema();
436        let schema_metadata = schema.metadata();
437        if let (Some(rows_str), Some(cols_str)) =
438            (schema_metadata.get("rows"), schema_metadata.get("cols"))
439        {
440            let schema_rows = rows_str.parse::<usize>().ok();
441            let schema_cols = cols_str.parse::<usize>().ok();
442            if schema_rows != Some(expected_rows) || schema_cols != Some(expected_cols) {
443                return Err(StorageError::DimensionMismatch {
444                    expected: format!("{}x{}", expected_rows, expected_cols),
445                    found: match (schema_rows, schema_cols) {
446                        (Some(r), Some(c)) => format!("{}x{}", r, c),
447                        _ => format!(
448                            "unparseable schema metadata {:?}x{:?}",
449                            schema_rows, schema_cols
450                        ),
451                    },
452                });
453            } else {
454                debug!(
455                    "Schema metadata matches storage metadata: {}x{}",
456                    expected_rows, expected_cols
457                );
458            }
459        }
460
461        let rows = expected_rows;
462        let cols = expected_cols;
463        debug!(
464            "Reconstructing {}x{} sparse matrix from {} entries",
465            rows, cols, n
466        );
467
468        let mut trimat = TriMat::new((rows, cols));
469        for i in 0..n {
470            let r = row_arr.value(i) as usize;
471            let c = col_arr.value(i) as usize;
472            let v = val_arr.value(i);
473
474            if r >= rows || c >= cols {
475                return Err(StorageError::Invalid(format!(
476                    "Index out of bounds: ({}, {}) in {}x{} matrix",
477                    r, c, rows, cols
478                )));
479            }
480            trimat.add_triplet(r, c, v);
481        }
482
483        let result = trimat.to_csr();
484        if result.rows() != rows || result.cols() != cols {
485            return Err(StorageError::Invalid(format!(
486                "Dimension mismatch after reconstruction: expected {}x{}, got {}x{}",
487                rows,
488                cols,
489                result.rows(),
490                result.cols()
491            )));
492        }
493
494        Ok(result)
495    }
496
497    /// Saves a dense matrix. Requires metadata to exist.
498    async fn save_dense(
499        &self,
500        key: &str,
501        matrix: &DenseMatrix<f64>,
502        md_path: &Path,
503    ) -> StorageResult<()>;
504
505    /// Loads a dense matrix from storage.
506    async fn load_dense(&self, key: &str) -> StorageResult<DenseMatrix<f64>>;
507
508    /// Saves a sparse matrix. Requires metadata to exist.
509    async fn save_sparse(
510        &self,
511        key: &str,
512        matrix: &CsMat<f64>,
513        md_path: &Path,
514    ) -> StorageResult<()>;
515
516    /// Loads a sparse matrix from storage.
517    async fn load_sparse(&self, key: &str) -> StorageResult<CsMat<f64>>;
518
519    /// Saves lambda eigenvalues. Requires metadata to exist.
520    async fn save_lambdas(&self, lambdas: &[f64], md_path: &Path) -> StorageResult<()>;
521
522    /// Loads lambda eigenvalues from storage.
523    async fn load_lambdas(&self) -> StorageResult<Vec<f64>>;
524
525    /// Initializes storage by saving metadata. Must be called first.
526    async fn save_metadata(&self, metadata: &GeneMetadata) -> StorageResult<PathBuf> {
527        let path = self.metadata_path();
528        info!("Saving metadata to {:?}", path);
529        let s = serde_json::to_string_pretty(metadata).map_err(StorageError::Serde)?;
530        // Atomic publish (#93): tmp + fsync + rename — the file is the
531        // single commit pointer and must never be observed half-written.
532        crate::generations::write_json_atomic(&path, &s)?;
533        info!("Metadata saved successfully");
534        Ok(path)
535    }
536
537    /// Loads metadata from storage.
538    async fn load_metadata(&self) -> StorageResult<GeneMetadata> {
539        let filename = self.metadata_path();
540        info!("Loading metadata from {:?}", filename);
541        let s = tokio::fs::read_to_string(filename)
542            .await
543            .map_err(|e| StorageError::Io(e.to_string()))?;
544        let md: GeneMetadata = serde_json::from_str(&s).map_err(StorageError::Serde)?;
545        info!("Metadata loaded successfully");
546        Ok(md)
547    }
548
549    /// Save vectors that are not lambdas but indices.
550    #[allow(dead_code)]
551    async fn save_index(&self, key: &str, vector: &[usize], md_path: &Path) -> StorageResult<()>;
552
553    /// save a generic f64 sequence
554    async fn save_vector(&self, key: &str, vector: &[f64], md_path: &Path) -> StorageResult<()>;
555
556    /// Load index or generic usize vector from storage.
557    #[allow(dead_code)]
558    async fn load_index(&self, key: &str) -> StorageResult<Vec<usize>>;
559
560    async fn load_vector(&self, key: &str) -> StorageResult<Vec<f64>>;
561
562    /// Writes a dense matrix to `path` (`.lance` dataset directory or
563    /// `.parquet` file in the `vector: FixedSizeList<Float64>` layout, the
564    /// counterpart of [`Self::load_dense_from_file`]). Parent directories are
565    /// created as needed.
566    async fn save_dense_to_file(data: &DenseMatrix<f64>, path: &Path) -> StorageResult<()>;
567
568    // =========
569    // NAMED COLLECTIONS (RFC #81)
570    // =========
571
572    /// Saves a schema-driven vector-space collection (RFC #81-P2) without
573    /// user properties; see [`Self::save_vectors_with`].
574    ///
575    /// `save_dense` remains as the fixed-key `f64` wrapper for compat.
576    async fn save_vectors(
577        &self,
578        name: &str,
579        batch: &RecordBatch,
580        md_path: &Path,
581    ) -> StorageResult<()> {
582        self.save_vectors_with(name, batch, &Default::default(), md_path)
583            .await
584    }
585
586    /// Saves a vector-space collection with user properties (e.g. the
587    /// `graph` linkage of RFC #81-P4: `properties.graph = <name>`).
588    async fn save_vectors_with(
589        &self,
590        name: &str,
591        batch: &RecordBatch,
592        properties: &std::collections::BTreeMap<String, String>,
593        md_path: &Path,
594    ) -> StorageResult<()>;
595
596    /// Loads a vector-space collection back as a `RecordBatch` with its
597    /// original schema (including collection metadata).
598    async fn load_vectors(&self, name: &str) -> StorageResult<RecordBatch>;
599
600    /// Saves an edge-list graph collection (RFC #81-P3) with `u32` node ids
601    /// (the default width) and `f64` weights (the default width, mirroring
602    /// the sparse-matrix `value` column). Ids above `u32::MAX` surface
603    /// [`StorageError::Overflow`] instead of being truncated. Weights are
604    /// persisted faithfully (normalization transforms belong to the
605    /// producer); the opt-in `GraphWriteOptions::weight_range` asserts
606    /// compliance with a declared closed interval, and weights that cannot
607    /// be stored exactly at an explicitly declared
608    /// [`crate::graph::WeightType::F32`] width are rejected instead of
609    /// being silently narrowed.
610    ///
611    /// The collection is either fully weighted (`weight: Float64|Float32`
612    /// column, schema-declared via `GraphWriteOptions::weight_type`) or
613    /// topology-only (`src`/`dst` only); mixed edges are rejected.
614    async fn save_graph(
615        &self,
616        name: &str,
617        edges: &[GraphEdge],
618        md_path: &Path,
619    ) -> StorageResult<()> {
620        self.save_graph_with(name, edges, &GraphWriteOptions::default(), md_path)
621            .await
622    }
623
624    /// Saves a graph collection with explicit options (node-id and weight
625    /// widths, node count, user properties for vector-space linkage,
626    /// RFC #81-P4).
627    async fn save_graph_with(
628        &self,
629        name: &str,
630        edges: &[GraphEdge],
631        options: &GraphWriteOptions,
632        md_path: &Path,
633    ) -> StorageResult<()>;
634
635    /// Loads a graph collection back as an edge list; convert to CSR at the
636    /// API boundary with [`StoredGraph::to_csr`].
637    async fn load_graph(&self, name: &str) -> StorageResult<StoredGraph>;
638
639    // =========
640    // REGISTRY-FREE COLLECTION I/O (#106)
641    // =========
642
643    /// Saves a vector-space collection dataset at an explicit `path`
644    /// without touching the metadata registry (#106): the batch is
645    /// validated, stamped with dataset-level collection metadata
646    /// (including user properties) and written; parent directories are
647    /// created as needed. Registry ownership stays with the caller — the
648    /// counterpart of [`Self::save_vectors_with`] for consumers that run
649    /// their own metadata registry (e.g. an ArrowSpaceMetadata commit
650    /// pointer at the instance metadata path). No `GeneMetadata` read or
651    /// write occurs.
652    async fn save_vectors_to_path(
653        &self,
654        path: &Path,
655        batch: &RecordBatch,
656        properties: &std::collections::BTreeMap<String, String>,
657    ) -> StorageResult<()>;
658
659    /// Loads a vector-space collection dataset from an explicit `path`
660    /// (registry-free): the path-based counterpart of
661    /// [`Self::load_vectors`].
662    async fn load_vectors_from_path(&self, path: &Path) -> StorageResult<RecordBatch>;
663
664    /// Saves a graph collection dataset at an explicit `path` without
665    /// touching the metadata registry (#106); see
666    /// [`Self::save_graph_with`] for the options and
667    /// [`Self::save_vectors_to_path`] for the registry-free contract.
668    async fn save_graph_to_path(
669        &self,
670        path: &Path,
671        edges: &[GraphEdge],
672        options: &GraphWriteOptions,
673    ) -> StorageResult<()>;
674
675    /// Loads a graph collection dataset from an explicit `path`
676    /// (registry-free): the path-based counterpart of [`Self::load_graph`].
677    async fn load_graph_from_path(&self, path: &Path) -> StorageResult<StoredGraph>;
678
679    /// Loads a scalar collection — a single `Float64|Float32` column, e.g.
680    /// lambdas or norms — as `Vec<f64>` (#106); `f32` values are upcast
681    /// losslessly. The stamped dataset kind must be `vector-space`
682    /// (missing or mismatched kinds are rejected: physical shape
683    /// determines decodability, the logical kind determines semantic
684    /// validity), making every `kind=vector-space` collection uniformly
685    /// loadable instead of requiring the legacy fixed-key readers.
686    async fn load_scalars(&self, name: &str) -> StorageResult<Vec<f64>>;
687
688    // =========
689    // #107: registry-free scalar collections
690    // =========
691
692    /// Saves a scalar collection — a single non-nullable `Float64` column
693    /// (e.g. lambdas or norms) — at an explicit `path` without touching the
694    /// metadata registry (#107): the dataset is stamped `kind=vector-space`
695    /// and parent directories are created as needed. Registry ownership
696    /// stays with the caller; no `GeneMetadata` read or write occurs. The
697    /// counterpart of [`Self::save_vectors_to_path`] for scalar-only
698    /// collections (which `save_vectors_to_path` cannot write, as its
699    /// schema validation requires a `FixedSizeList` column).
700    async fn save_scalars_to_path(&self, path: &Path, values: &[f64]) -> StorageResult<()>;
701
702    /// Loads a scalar collection from an explicit `path` (registry-free):
703    /// the path-based counterpart of [`Self::load_scalars`], with the same
704    /// kind gate. `load_scalars` delegates to this.
705    async fn load_scalars_from_path(&self, path: &Path) -> StorageResult<Vec<f64>>;
706
707    // =========
708    // #108: strict graph read mode
709    // =========
710
711    /// Loads a graph collection from an explicit `path` with read options
712    /// (#108): strict mode rejects datasets without a `num_nodes` stamp
713    /// (and, optionally, validates column names) instead of silently
714    /// resizing to `max_id + 1`. The tolerant default is the compat shim.
715    async fn load_graph_from_path_with_options(
716        &self,
717        path: &Path,
718        options: &crate::graph::GraphReadOptions,
719    ) -> StorageResult<StoredGraph>;
720
721    /// Strict variant of [`Self::load_graph_from_path`] (#108): rejects
722    /// datasets without a `num_nodes` stamp with a typed
723    /// [`StorageError::Invalid`] and validates the `src`/`dst`/`weight`
724    /// column names, so pre-collections triplet artifacts are positively
725    /// identified rather than positionally coerced.
726    ///
727    /// Provided default (#117-2): derivable from
728    /// [`Self::load_graph_from_path_with_options`], so implementors only
729    /// need the options-based method.
730    async fn load_graph_from_path_strict(&self, path: &Path) -> StorageResult<StoredGraph> {
731        self.load_graph_from_path_with_options(path, &crate::graph::GraphReadOptions::strict())
732            .await
733    }
734
735    // =========
736    // #109: metadata-only schema read
737    // =========
738
739    /// Reads the dataset schema (fields + KV metadata) of a collection at
740    /// an explicit `path` without decoding any column buffers (#109): the
741    /// backend surface of [`crate::lancefmt::read_schema`], so consumers
742    /// doing kind/stamp gating don't pay a full columnar scan.
743    async fn collection_schema_from_path(&self, path: &Path) -> StorageResult<Schema>;
744
745    /// Reads the dataset schema of a named collection (registry-free,
746    /// path-resolved): the name-based counterpart of
747    /// [`Self::collection_schema_from_path`].
748    async fn collection_schema(&self, name: &str) -> StorageResult<Schema> {
749        self.collection_schema_from_path(&self.file_path(name))
750            .await
751    }
752
753    // =========
754    // #115: dataset verification
755    // =========
756
757    /// Verifies the stamped dataset schema of a collection at `path`
758    /// against `expected` (#115): reads the schema footer via
759    /// [`crate::lancefmt::read_schema`] — no column buffers are decoded —
760    /// and cross-checks the facts (`kind`, `rows`/`cols`, `num_nodes`,
761    /// weight width). Mismatches surface as typed errors naming the fact.
762    ///
763    /// Provided (#117-2 policy): derivable from the lancefmt primitive, so
764    /// no implementor burden. Expectations derive from the registry via
765    /// [`crate::lancefmt::verify::DatasetFacts::from_file_info`] or
766    /// `from_descriptor`.
767    async fn verify_collection_from_path(
768        &self,
769        path: &Path,
770        expected: &crate::lancefmt::verify::DatasetFacts,
771    ) -> StorageResult<()> {
772        let path = path.to_path_buf();
773        let expected = expected.clone();
774        tokio::task::spawn_blocking(move || {
775            crate::lancefmt::verify_dataset_schema(&path, &expected)
776        })
777        .await
778        .map_err(|e| StorageError::Io(format!("verify task failed: {e}")))?
779    }
780
781    /// Name-based counterpart of [`Self::verify_collection_from_path`],
782    /// resolving through [`Self::file_path`].
783    async fn verify_collection(
784        &self,
785        name: &str,
786        expected: &crate::lancefmt::verify::DatasetFacts,
787    ) -> StorageResult<()> {
788        self.verify_collection_from_path(&self.file_path(name), expected)
789            .await
790    }
791}