Skip to main content

genegraph_storage/traits/
lance.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3use std::sync::Arc;
4
5use arrow::array::{
6    ArrayRef, Float32Array, Float64Array, PrimitiveArray, UInt32Array, UInt64Array,
7};
8use arrow::datatypes::{ArrowPrimitiveType, DataType, Field, Schema};
9use arrow::record_batch::RecordBatch;
10use log::{debug, info};
11
12use crate::catalog::RESERVED_PROPERTIES;
13use crate::graph::{
14    GraphEdge, GraphReadOptions, GraphWriteOptions, NodeIdWidth, RESERVED_METADATA_KEYS,
15    StoredGraph, WeightType,
16};
17use crate::metadata::FileInfo;
18use crate::traits::backend::StorageBackend;
19use crate::{StorageError, StorageResult};
20
21/// Rejects user properties that would shadow computed registry facts
22/// (`RESERVED_PROPERTIES`, review of PR #96): the direct `save_*_with`
23/// write paths must enforce the same rule as `Catalog::register_table`, or
24/// a caller-provided `rows`/`nnz`/... would silently override the computed
25/// descriptor values.
26pub(crate) fn reject_reserved_user_properties(
27    properties: &BTreeMap<String, String>,
28) -> StorageResult<()> {
29    for key in properties.keys() {
30        if RESERVED_PROPERTIES.contains(&key.as_str()) {
31            return Err(StorageError::Invalid(format!(
32                "user property '{key}' is reserved (computed from the stored artifact)"
33            )));
34        }
35    }
36    Ok(())
37}
38
39/// Resolves a `file://` URI (as produced by `path_to_uri`) to a local path.
40fn uri_to_path(uri: &str) -> StorageResult<std::path::PathBuf> {
41    let url = url::Url::parse(uri)
42        .map_err(|e| StorageError::Invalid(format!("bad dataset URI `{uri}`: {e}")))?;
43    url.to_file_path().map_err(|_| {
44        StorageError::Invalid(format!("dataset URI is not a local file path: `{uri}`"))
45    })
46}
47
48/// Stamps collection metadata (RFC #81-P1) into the batch schema: the
49/// dataset-level `kind`, writer-computed pairs, and the user properties.
50/// Existing schema metadata is preserved; user properties may not shadow
51/// reserved keys and the `kind` value always wins.
52pub(crate) fn with_collection_metadata(
53    batch: &RecordBatch,
54    kind: &str,
55    fixed: &[(&'static str, String)],
56    user_properties: &BTreeMap<String, String>,
57) -> StorageResult<RecordBatch> {
58    for key in user_properties.keys() {
59        if RESERVED_METADATA_KEYS.contains(&key.as_str()) {
60            return Err(StorageError::Invalid(format!(
61                "user property '{key}' is reserved by the collection metadata"
62            )));
63        }
64    }
65    let mut metadata = batch.schema().metadata().clone();
66    for (k, v) in fixed {
67        metadata.insert(k.to_string(), v.clone());
68    }
69    for (k, v) in user_properties {
70        metadata.insert(k.clone(), v.clone());
71    }
72    metadata.insert("kind".to_string(), kind.to_string());
73
74    let schema = Arc::new(batch.schema().as_ref().clone().with_metadata(metadata));
75    RecordBatch::try_new(schema, batch.columns().to_vec())
76        .map_err(|e| StorageError::Lance(e.to_string()))
77}
78
79/// Validates a vector-space schema (RFC #81-P2): at least one
80/// `FixedSizeList<Float64|Float32>` column, no nullable top-level fields,
81/// and any additional (id/property) columns limited to the lancefmt scalar
82/// subset. Returns the dimension of the first vector column.
83pub(crate) fn validate_vector_space_schema(schema: &Schema) -> StorageResult<(i32, DataType)> {
84    let fields = schema.fields();
85    if fields.is_empty() {
86        return Err(StorageError::Invalid(
87            "vector-space schema has no columns".into(),
88        ));
89    }
90    let mut vector_dim: Option<(i32, DataType)> = None;
91    for f in fields {
92        if f.is_nullable() {
93            return Err(StorageError::Invalid(format!(
94                "vector-space schemas require non-nullable columns; '{}' is nullable",
95                f.name()
96            )));
97        }
98        match f.data_type() {
99            DataType::FixedSizeList(child, dim)
100                if matches!(child.data_type(), DataType::Float64 | DataType::Float32) =>
101            {
102                if vector_dim.is_none() {
103                    vector_dim = Some((*dim, child.data_type().clone()));
104                }
105            }
106            DataType::Float32
107            | DataType::Float64
108            | DataType::UInt8
109            | DataType::UInt32
110            | DataType::UInt64
111            | DataType::Int64 => {}
112            other => {
113                return Err(StorageError::Invalid(format!(
114                    "vector-space id/property column '{}' has unsupported type {other:?}; \
115                     expected FixedSizeList<Float64|Float32> or a scalar column",
116                    f.name()
117                )));
118            }
119        }
120    }
121    vector_dim.ok_or_else(|| {
122        StorageError::Invalid(
123            "vector-space schema requires at least one FixedSizeList<Float64|Float32> column"
124                .into(),
125        )
126    })
127}
128
129/// Validates a vector-space batch and stamps the dataset-level collection
130/// metadata: the shared write path of the registry-coupled
131/// `save_vectors_with` and the registry-free `save_vectors_to_path`
132/// (#106). Returns the stamped batch and the vector dimension.
133pub(crate) fn vector_space_collection_batch(
134    batch: &RecordBatch,
135    properties: &BTreeMap<String, String>,
136) -> StorageResult<(RecordBatch, i32)> {
137    reject_reserved_user_properties(properties)?;
138    if batch.num_rows() == 0 {
139        return Err(StorageError::Invalid(
140            "empty vector-space collections are not supported".into(),
141        ));
142    }
143    let (dim, _item_type) = validate_vector_space_schema(batch.schema().as_ref())?;
144    let stamped = with_collection_metadata(batch, "vector-space", &[], properties)?;
145    Ok((stamped, dim))
146}
147
148/// Validates an edge list and converts it into the canonical graph
149/// collection batch (the shared write path of `save_graph_with` and the
150/// registry-free `save_graph_to_path`, #106): non-empty, uniformly
151/// weighted-or-topology edges, node ids within the declared width
152/// (Overflow instead of truncation, #51), a consistent `num_nodes`, and
153/// weights exactly representable at the declared weight width (silent
154/// narrowing rejected). Returns the stamped batch and the effective node
155/// count.
156pub(crate) fn graph_record_batch(
157    edges: &[GraphEdge],
158    options: &GraphWriteOptions,
159) -> StorageResult<(RecordBatch, u64)> {
160    reject_reserved_user_properties(&options.properties)?;
161    if edges.is_empty() {
162        return Err(StorageError::Invalid(
163            "empty graph collections are not supported".into(),
164        ));
165    }
166    // A collection is fully weighted or topology-only; mixed edges are
167    // rejected instead of being coerced.
168    let weighted = edges[0].weight.is_some();
169    if edges.iter().any(|e| e.weight.is_some() != weighted) {
170        return Err(StorageError::Invalid(
171            "graph collection must be either fully weighted or topology-only; \
172             mixed edges are rejected"
173                .into(),
174        ));
175    }
176    // The schema declares the storage-type limit; ids above it surface
177    // Overflow instead of being silently truncated (consistent with #51).
178    let max_id = edges.iter().map(|e| e.src.max(e.dst)).max().unwrap_or(0);
179    let width = options.node_id_width;
180    if width == NodeIdWidth::U32 && max_id > u32::MAX as u64 {
181        return Err(StorageError::Overflow(format!(
182            "node id {max_id} exceeds u32::MAX; save with NodeIdWidth::U64"
183        )));
184    }
185    let num_nodes = match options.num_nodes {
186        Some(n) if n < max_id + 1 => {
187            return Err(StorageError::Invalid(format!(
188                "num_nodes {n} is smaller than the highest node id + 1 ({})",
189                max_id + 1
190            )));
191        }
192        Some(n) => n,
193        None => max_id + 1,
194    };
195    // Weight handling (#106):
196    // - Layering: the crate persists weights faithfully — no domain
197    //   assumptions, no normalization. Producer transforms
198    //   (x/(1+x), 1-exp(-x), ...) happen upstream; `weight_range` is
199    //   only an opt-in bounds assertion on already-computed values
200    //   (it catches a forgotten transform). NaN lies in no interval;
201    //   an inverted interval is a rejected configuration.
202    if let Some((lo, hi)) = options.weight_range
203        && !matches!(
204            lo.partial_cmp(&hi),
205            Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
206        )
207    {
208        return Err(StorageError::Invalid(format!(
209            "weight_range ({lo:?}, {hi:?}) is not a valid interval: expected low <= high"
210        )));
211    }
212    if weighted {
213        if let Some((lo, hi)) = options.weight_range {
214            for e in edges {
215                let w = e.weight.unwrap();
216                if !(lo..=hi).contains(&w) {
217                    return Err(StorageError::Invalid(format!(
218                        "graph weight {w} is outside the declared weight_range [{lo:?}, {hi:?}]"
219                    )));
220                }
221            }
222        }
223        // f32 narrowing guard — the #51 invariant, float edition: a
224        // declared f32 store must hold every weight exactly, because a
225        // silent f64 -> f32 -> f64 narrowing corrupts full-precision
226        // values (measured on a real laplacian: 100% of entries change,
227        // max abs diff 4.3e-7). The default f64 width never narrows.
228        if options.weight_type == WeightType::F32 {
229            for e in edges {
230                let w = e.weight.unwrap();
231                let narrowed = w as f32;
232                if w.is_finite() && !narrowed.is_finite() {
233                    return Err(StorageError::Overflow(format!(
234                        "weight {w} exceeds the f32 range; save with WeightType::F64"
235                    )));
236                }
237                let exact = f64::from(narrowed).to_bits() == w.to_bits()
238                    || (w.is_nan() && narrowed.is_nan());
239                if !exact {
240                    return Err(StorageError::Invalid(format!(
241                        "weight {w} is not exactly representable as f32 and would be \
242                         silently narrowed; save with WeightType::F64 or pre-narrow the \
243                         value with `as f32`"
244                    )));
245                }
246            }
247        }
248    }
249
250    let (src, dst): (ArrayRef, ArrayRef) = match width {
251        NodeIdWidth::U32 => (
252            Arc::new(UInt32Array::from_iter_values(
253                edges.iter().map(|e| e.src as u32),
254            )),
255            Arc::new(UInt32Array::from_iter_values(
256                edges.iter().map(|e| e.dst as u32),
257            )),
258        ),
259        NodeIdWidth::U64 => (
260            Arc::new(UInt64Array::from_iter_values(edges.iter().map(|e| e.src))),
261            Arc::new(UInt64Array::from_iter_values(edges.iter().map(|e| e.dst))),
262        ),
263    };
264    let mut fields = vec![
265        Field::new("src", width.data_type(), false),
266        Field::new("dst", width.data_type(), false),
267    ];
268    let mut columns: Vec<ArrayRef> = vec![src, dst];
269    if weighted {
270        fields.push(Field::new("weight", options.weight_type.data_type(), false));
271        let weights = edges.iter().map(|e| e.weight.unwrap());
272        columns.push(match options.weight_type {
273            WeightType::F32 => {
274                Arc::new(Float32Array::from_iter_values(weights.map(|w| w as f32))) as ArrayRef
275            }
276            WeightType::F64 => Arc::new(Float64Array::from_iter_values(weights)) as ArrayRef,
277        });
278    }
279    let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns)
280        .map_err(|e| StorageError::Lance(e.to_string()))?;
281    // Dataset-level kind + graph layout facts (RFC #81-P1/P3, #106).
282    let batch = with_collection_metadata(
283        &batch,
284        "graph",
285        &[
286            ("node_id_width", width.as_str().to_string()),
287            ("weighted", weighted.to_string()),
288            ("num_nodes", num_nodes.to_string()),
289            ("weight_type", options.weight_type.as_str().to_string()),
290        ],
291        &options.properties,
292    )?;
293    Ok((batch, num_nodes))
294}
295
296/// Parses a graph collection batch back into a [`StoredGraph`] (the
297/// counterpart of [`graph_record_batch`], shared by `load_graph` and the
298/// registry-free `load_graph_from_path`): shared src/dst width, a
299/// `Float32|Float64` weight column (#106), lossless f32 upcasting, and
300/// the node count from dataset metadata. In strict mode (#108), a dataset
301/// without a `num_nodes` stamp is rejected with a typed
302/// [`StorageError::Invalid`] instead of silently resizing to `max_id + 1`,
303/// and the `src`/`dst`/`weight` column names are validated so
304/// pre-collections triplet artifacts (`row`/`col`/`value`) are positively
305/// identified rather than positionally coerced.
306pub(crate) fn stored_graph_from_batch_with_options(
307    batch: RecordBatch,
308    options: &GraphReadOptions,
309) -> StorageResult<StoredGraph> {
310    let schema = batch.schema();
311    let n_cols = schema.fields().len();
312    if !(2..=3).contains(&n_cols) {
313        return Err(StorageError::Invalid(format!(
314            "graph edge-list schema expects src/dst (and optionally weight) columns, \
315             found {n_cols}"
316        )));
317    }
318    // Strict column-name validation (#108): the canonical graph collection
319    // names its columns src/dst/weight; a pre-collections triplet artifact
320    // (row/col/value) is positively identified and rejected rather than
321    // positionally coerced.
322    if options.strict {
323        let expected = ["src", "dst", "weight"];
324        for (i, want) in expected.iter().enumerate().take(n_cols) {
325            if schema.field(i).name() != *want {
326                return Err(StorageError::Invalid(format!(
327                    "strict graph read: column {i} is named '{}', expected '{want}' \
328                     (a pre-collections triplet artifact?)",
329                    schema.field(i).name()
330                )));
331            }
332        }
333    }
334    let width = match (schema.field(0).data_type(), schema.field(1).data_type()) {
335        (DataType::UInt32, DataType::UInt32) => NodeIdWidth::U32,
336        (DataType::UInt64, DataType::UInt64) => NodeIdWidth::U64,
337        (s, d) => {
338            return Err(StorageError::Invalid(format!(
339                "graph src/dst columns must share width UInt32|UInt64, found {s:?}/{d:?}"
340            )));
341        }
342    };
343    let weighted = n_cols == 3;
344    let weight_type = if weighted {
345        match schema.field(2).data_type() {
346            DataType::Float32 => WeightType::F32,
347            DataType::Float64 => WeightType::F64,
348            other => {
349                return Err(StorageError::Invalid(format!(
350                    "graph weight column must be Float32|Float64, found {other:?}"
351                )));
352            }
353        }
354    } else {
355        // Topology-only: report the declared width from the dataset
356        // metadata (the F64 default when unstamped).
357        schema
358            .metadata()
359            .get("weight_type")
360            .and_then(|v| WeightType::parse(v).ok())
361            .unwrap_or(WeightType::F64)
362    };
363
364    let ids = |col: usize, what: &'static str| -> StorageResult<Vec<u64>> {
365        let arr = batch.column(col);
366        if arr.null_count() != 0 {
367            return Err(StorageError::Invalid(format!(
368                "graph '{what}' column contains nulls"
369            )));
370        }
371        match width {
372            NodeIdWidth::U32 => {
373                let a = arr.as_any().downcast_ref::<UInt32Array>().ok_or_else(|| {
374                    StorageError::Invalid(format!("graph '{what}' column type mismatch"))
375                })?;
376                Ok((0..a.len()).map(|i| a.value(i) as u64).collect())
377            }
378            NodeIdWidth::U64 => {
379                let a = arr.as_any().downcast_ref::<UInt64Array>().ok_or_else(|| {
380                    StorageError::Invalid(format!("graph '{what}' column type mismatch"))
381                })?;
382                Ok((0..a.len()).map(|i| a.value(i)).collect())
383            }
384        }
385    };
386    let src = ids(0, "src")?;
387    let dst = ids(1, "dst")?;
388    let weights: Option<Vec<f64>> = if weighted {
389        let w = batch.column(2);
390        if w.null_count() != 0 {
391            return Err(StorageError::Invalid(
392                "graph 'weight' column contains nulls".into(),
393            ));
394        }
395        match weight_type {
396            WeightType::F32 => {
397                // lossless upcast at the API boundary
398                let a = w.as_any().downcast_ref::<Float32Array>().ok_or_else(|| {
399                    StorageError::Invalid("graph 'weight' column type mismatch".into())
400                })?;
401                Some((0..a.len()).map(|i| f64::from(a.value(i))).collect())
402            }
403            WeightType::F64 => {
404                let a = w.as_any().downcast_ref::<Float64Array>().ok_or_else(|| {
405                    StorageError::Invalid("graph 'weight' column type mismatch".into())
406                })?;
407                Some((0..a.len()).map(|i| a.value(i)).collect())
408            }
409        }
410    } else {
411        None
412    };
413
414    // Node count: dataset-level metadata if present (isolated vertices
415    // included), otherwise the highest id + 1.
416    let max_id = src
417        .iter()
418        .zip(dst.iter())
419        .map(|(s, d)| (*s).max(*d))
420        .max()
421        .unwrap_or(0);
422    let stamped_num_nodes = schema
423        .metadata()
424        .get("num_nodes")
425        .and_then(|v| v.parse::<u64>().ok());
426    // Strict mode (#108): an unstamped dataset is rejected instead of
427    // silently resizing to max_id + 1 — a graph whose trailing vertices are
428    // isolated (degree-0 laplacian rows store no entries) would otherwise
429    // load with a smaller node count than was written.
430    if options.strict && stamped_num_nodes.is_none() {
431        return Err(StorageError::Invalid(format!(
432            "strict graph read: dataset has no 'num_nodes' stamp; refusing to \
433             silently resize to max_id + 1 = {}",
434            max_id + 1
435        )));
436    }
437    let num_nodes = stamped_num_nodes.unwrap_or(max_id + 1);
438    let edges: Vec<GraphEdge> = match &weights {
439        Some(weights) => src
440            .iter()
441            .zip(dst.iter())
442            .zip(weights.iter())
443            .map(|((s, d), w)| GraphEdge::weighted(*s, *d, *w))
444            .collect(),
445        None => src
446            .iter()
447            .zip(dst.iter())
448            .map(|(s, d)| GraphEdge::unweighted(*s, *d))
449            .collect(),
450    };
451    if let Some(e) = edges
452        .iter()
453        .find(|e| e.src >= num_nodes || e.dst >= num_nodes)
454    {
455        return Err(StorageError::Invalid(format!(
456            "edge ({}, {}) out of bounds for {}-node graph",
457            e.src, e.dst, num_nodes
458        )));
459    }
460
461    Ok(StoredGraph {
462        edges,
463        node_id_width: width,
464        weight_type,
465        num_nodes,
466        weighted,
467    })
468}
469
470pub trait LanceStorage {
471    /// Async helper: write a RecordBatch to a Lance dataset.
472    ///
473    /// Runs the in-house v2.1 implementation (`lancefmt`) on the blocking
474    /// pool (#75 M5; the `official-lance` transition flag was removed after
475    /// one release cycle).
476    async fn write_lance_batch_async(&self, uri: String, batch: RecordBatch) -> StorageResult<()> {
477        info!("Writing Lance dataset (in-house v2.1) to {}", uri);
478        let path = uri_to_path(&uri)?;
479        tokio::task::spawn_blocking(move || crate::lancefmt::write_dataset(&batch, &path))
480            .await
481            .map_err(|e| StorageError::Io(format!("lancefmt writer task failed: {e}")))?
482    }
483
484    /// Async helper: read and concatenate all RecordBatches from a Lance dataset.
485    async fn read_lance_all_batches_async(&self, uri: String) -> StorageResult<RecordBatch> {
486        info!("Reading Lance dataset (in-house v2.1) from {}", uri);
487        let path = uri_to_path(&uri)?;
488        let combined = tokio::task::spawn_blocking(move || crate::lancefmt::scan_all(&path))
489            .await
490            .map_err(|e| StorageError::Io(format!("lancefmt reader task failed: {e}")))??;
491        debug!(
492            "Combined Lance batch for {:?} has {} rows",
493            uri,
494            combined.num_rows()
495        );
496        Ok(combined)
497    }
498
499    /// Writes a single-column primitive vector as a Lance dataset named
500    /// `<name>_<key>.lance` and registers it in the metadata files map under
501    /// `key` with filetype "vector" and shape (`<len>`, 1).
502    ///
503    /// Shared implementation for the scalar `save_*` methods (lambdas,
504    /// vectors, indices, norms, centroid maps, cluster assignments).
505    async fn save_primitive_column<T: ArrowPrimitiveType>(
506        &self,
507        key: &str,
508        field_name: &str,
509        values: Vec<T::Native>,
510        md_path: &Path,
511    ) -> StorageResult<()>
512    where
513        Self: StorageBackend,
514    {
515        self.validate_initialized(md_path)?;
516        let path = self.file_path(key);
517        let len = values.len();
518        info!("Saving {} values for {} (field {})", len, key, field_name);
519
520        // Dataset-level kind (RFC #81-P1): scalar artifacts are
521        // vector-space collections, mirroring the registry-level
522        // `CollectionKind::for_filetype("vector")` shim, so logical-kind
523        // readers (`load_scalars`) can enforce the kind (#106 review).
524        let schema = Schema::new(vec![Field::new(field_name, T::DATA_TYPE, false)]).with_metadata(
525            std::collections::HashMap::from([("kind".to_string(), "vector-space".to_string())]),
526        );
527        let batch = RecordBatch::try_new(
528            Arc::new(schema),
529            vec![Arc::new(PrimitiveArray::<T>::from_iter_values(values)) as ArrayRef],
530        )
531        .map_err(|e| StorageError::Lance(e.to_string()))?;
532
533        crate::commit::with_commit_actor(&self.metadata_path(), || async {
534            let mut metadata = self.load_metadata().await?;
535            metadata = metadata.add_file(
536                key,
537                FileInfo::new(
538                    crate::generations::artifact_file_name(self.get_name().as_str(), key),
539                    "vector",
540                    (len, 1),
541                    None,
542                    None,
543                )?,
544            );
545            self.save_metadata(&metadata).await
546        })
547        .await?;
548
549        let uri = Self::path_to_uri(&path)?;
550        self.write_lance_batch_async(uri, batch).await?;
551        info!("Vector {} saved successfully", key);
552        Ok(())
553    }
554}