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}