Skip to main content

nd_arrow_array/
tensor.rs

1//! Conversion between nd columns and the Arrow canonical tensor types
2//! `arrow.fixed_shape_tensor` and `arrow.variable_shape_tensor`.
3//!
4//! The canonical types keep the dimension names in the field metadata, so all
5//! rows of a column must share their names and rank. They have no attributes,
6//! and their elements cannot be null: fill nulls before a conversion.
7//!
8//! The canonical `dim_names` and `shape` describe the physical row-major
9//! layout, which is the nd layout. A `permutation` is only a view hint, so a
10//! conversion from a tensor ignores it.
11//!
12//! This module writes and reads the metadata JSON itself, by the Arrow
13//! specification. It reads both `permutation` and `permutations` keys.
14
15use std::collections::HashMap;
16use std::sync::Arc;
17
18use arrow::array::{
19    Array, FixedSizeListArray, Int32Array, ListArray, MutableArrayData, StructArray, UInt64Array,
20    make_array,
21};
22use arrow::compute::cast;
23use arrow::datatypes::{DataType, Field, Fields};
24use arrow_schema::extension::{EXTENSION_TYPE_METADATA_KEY, EXTENSION_TYPE_NAME_KEY};
25use serde::{Deserialize, Serialize};
26
27use crate::NdArrowArray;
28use crate::column::{NdColumn, NdColumnBuilder};
29use crate::dimensions::Dimensions;
30use crate::error::{Result, nd_err};
31use crate::extension::is_nd_field;
32
33/// Extension name of the Arrow canonical fixed shape tensor.
34pub const FIXED_SHAPE_TENSOR: &str = "arrow.fixed_shape_tensor";
35/// Extension name of the Arrow canonical variable shape tensor.
36pub const VARIABLE_SHAPE_TENSOR: &str = "arrow.variable_shape_tensor";
37
38#[derive(Debug, Default, Serialize, Deserialize)]
39struct TensorMetadata {
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    shape: Option<Vec<usize>>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    dim_names: Option<Vec<String>>,
44    #[serde(
45        default,
46        alias = "permutations",
47        skip_serializing_if = "Option::is_none"
48    )]
49    permutation: Option<Vec<usize>>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    uniform_shape: Option<Vec<Option<i32>>>,
52}
53
54fn tensor_field(
55    name: &str,
56    data_type: DataType,
57    extension: &str,
58    metadata: &TensorMetadata,
59) -> Field {
60    let json = serde_json::to_string(metadata).expect("metadata serializes");
61    Field::new(name, data_type, true).with_metadata(HashMap::from([
62        (EXTENSION_TYPE_NAME_KEY.to_string(), extension.to_string()),
63        (EXTENSION_TYPE_METADATA_KEY.to_string(), json),
64    ]))
65}
66
67fn read_metadata(field: &Field) -> Result<TensorMetadata> {
68    match field.metadata().get(EXTENSION_TYPE_METADATA_KEY) {
69        None => Ok(TensorMetadata::default()),
70        Some(json) if json.trim().is_empty() => Ok(TensorMetadata::default()),
71        Some(json) => serde_json::from_str(json)
72            .map_err(|e| nd_err!(InvalidEncoding, "invalid tensor metadata: {e}")),
73    }
74}
75
76/// The dimensions that all non-null rows share in name (and in size, if `sizes`).
77fn shared_dims(column: &NdColumn, sizes: bool) -> Result<Dimensions> {
78    let mut shared: Option<Dimensions> = None;
79    for row in (0..column.len()).filter(|&r| !column.is_null(r)) {
80        let dims = column.dimensions_at(row);
81        match &shared {
82            None => shared = Some(dims),
83            Some(first) if first.names() != dims.names() || (sizes && *first != dims) => {
84                return Err(nd_err!(
85                    NotSupported,
86                    "tensor types need one set of dimensions, got {first} and {dims}"
87                ));
88            }
89            Some(_) => {}
90        }
91    }
92    Ok(shared.unwrap_or_default())
93}
94
95fn check_no_null_elements(column: &NdColumn) -> Result<()> {
96    if column.values_list().values().null_count() > 0 {
97        return Err(nd_err!(
98            NotSupported,
99            "tensor elements cannot be null; fill the nulls first"
100        ));
101    }
102    Ok(())
103}
104
105fn element_field(value_type: &DataType) -> Arc<Field> {
106    Arc::new(Field::new_list_field(value_type.clone(), false))
107}
108
109fn names_of(dims: &Dimensions) -> Option<Vec<String>> {
110    (dims.rank() > 0).then(|| dims.names().iter().map(|n| n.to_string()).collect())
111}
112
113/// Convert to an `arrow.variable_shape_tensor` field named `name` and its
114/// storage. The values are not copied.
115pub fn to_variable_shape_tensor(column: &NdColumn, name: &str) -> Result<(Field, StructArray)> {
116    check_no_null_elements(column)?;
117    let dims = shared_dims(column, false)?;
118    let rank = dims.rank();
119
120    let mut shapes: Vec<i32> = Vec::with_capacity(column.len() * rank);
121    let mut uniform: Vec<Option<i32>> = Vec::new();
122    for row in 0..column.len() {
123        let row_dims = (!column.is_null(row)).then(|| column.dimensions_at(row));
124        for axis in 0..rank {
125            let size = match &row_dims {
126                Some(d) => i32::try_from(d.as_slice()[axis].size())
127                    .map_err(|_| nd_err!(NotSupported, "dimension size exceeds Int32"))?,
128                None => 0,
129            };
130            shapes.push(size);
131            if row_dims.is_some() {
132                match uniform.get_mut(axis) {
133                    None => uniform.push(Some(size)),
134                    Some(slot) if *slot != Some(size) => *slot = None,
135                    Some(_) => {}
136                }
137            }
138        }
139    }
140
141    let values = column.values_list();
142    let data = ListArray::try_new(
143        element_field(column.value_type()),
144        values.offsets().clone(),
145        values.values().clone(),
146        None,
147    )?;
148    let shape = FixedSizeListArray::try_new(
149        Arc::new(Field::new_list_field(DataType::Int32, false)),
150        rank as i32,
151        Arc::new(Int32Array::from(shapes)),
152        None,
153    )?;
154    let fields = Fields::from(vec![
155        Field::new("data", data.data_type().clone(), false),
156        Field::new("shape", shape.data_type().clone(), false),
157    ]);
158    let storage = StructArray::try_new(
159        fields,
160        vec![Arc::new(data), Arc::new(shape)],
161        column.storage().nulls().cloned(),
162    )?;
163    let metadata = TensorMetadata {
164        dim_names: names_of(&dims),
165        uniform_shape: (rank > 0 && uniform.len() == rank).then_some(uniform),
166        ..Default::default()
167    };
168    let field = tensor_field(
169        name,
170        storage.data_type().clone(),
171        VARIABLE_SHAPE_TENSOR,
172        &metadata,
173    );
174    Ok((field, storage))
175}
176
177/// Convert to an `arrow.fixed_shape_tensor` field named `name` and its
178/// storage. All non-null rows need the same dimensions.
179pub fn to_fixed_shape_tensor(column: &NdColumn, name: &str) -> Result<(Field, FixedSizeListArray)> {
180    check_no_null_elements(column)?;
181    let dims = shared_dims(column, true)?;
182    let size = dims.num_elements();
183
184    let values = column.values_list();
185    let data = values.values().to_data();
186    let offsets = values.value_offsets();
187    let mut out = MutableArrayData::new(vec![&data], true, column.len() * size);
188    for row in 0..column.len() {
189        if column.is_null(row) {
190            out.extend_nulls(size);
191        } else {
192            out.extend(0, offsets[row] as usize, offsets[row + 1] as usize);
193        }
194    }
195    let array = FixedSizeListArray::try_new(
196        element_field(column.value_type()),
197        size as i32,
198        make_array(out.freeze()),
199        column.storage().nulls().cloned(),
200    )?;
201    let metadata = TensorMetadata {
202        shape: Some(dims.shape()),
203        dim_names: names_of(&dims),
204        ..Default::default()
205    };
206    let field = tensor_field(
207        name,
208        array.data_type().clone(),
209        FIXED_SHAPE_TENSOR,
210        &metadata,
211    );
212    Ok((field, array))
213}
214
215fn dimension_names(names: Option<Vec<String>>, rank: usize) -> Result<Vec<String>> {
216    match names {
217        Some(names) if names.len() == rank => Ok(names),
218        Some(names) => Err(nd_err!(
219            InvalidEncoding,
220            "tensor has {} dimension names for rank {rank}",
221            names.len()
222        )),
223        None => Ok((0..rank).map(|i| format!("dim_{i}")).collect()),
224    }
225}
226
227fn from_variable_shape_tensor(storage: &StructArray, metadata: TensorMetadata) -> Result<NdColumn> {
228    let (Some(data), Some(shape)) = (
229        storage.column(0).as_any().downcast_ref::<ListArray>(),
230        storage
231            .column(1)
232            .as_any()
233            .downcast_ref::<FixedSizeListArray>(),
234    ) else {
235        return Err(nd_err!(
236            InvalidEncoding,
237            "variable shape tensor storage must be Struct{{data: List, shape: FixedSizeList}}"
238        ));
239    };
240    let rank = shape.value_length() as usize;
241    let names = dimension_names(metadata.dim_names, rank)?;
242    let sizes = cast(shape.values().as_ref(), &DataType::UInt64)?;
243    let sizes = sizes
244        .as_any()
245        .downcast_ref::<UInt64Array>()
246        .expect("cast to UInt64");
247
248    let value_type = match data.data_type() {
249        DataType::List(item) => item.data_type().clone(),
250        _ => unreachable!(),
251    };
252    let mut builder = NdColumnBuilder::with_value_type(value_type);
253    for row in 0..storage.len() {
254        if storage.is_null(row) {
255            builder.append_null();
256            continue;
257        }
258        let base = shape.value_offset(row) as usize;
259        let dims = Dimensions::try_new(
260            names
261                .iter()
262                .enumerate()
263                .map(|(axis, name)| (name.as_str(), sizes.value(base + axis) as usize)),
264        )?;
265        builder.append(&NdArrowArray::try_new(data.value(row), dims)?)?;
266    }
267    builder.finish()
268}
269
270fn from_fixed_shape_tensor(
271    array: &FixedSizeListArray,
272    metadata: TensorMetadata,
273) -> Result<NdColumn> {
274    let shape = metadata
275        .shape
276        .ok_or_else(|| nd_err!(InvalidEncoding, "fixed shape tensor metadata has no shape"))?;
277    let names = dimension_names(metadata.dim_names, shape.len())?;
278    let dims = Dimensions::try_new(names.iter().map(String::as_str).zip(shape))?;
279    if dims.num_elements() != array.value_length() as usize {
280        return Err(nd_err!(
281            InvalidEncoding,
282            "fixed shape tensor shape {dims} does not match list size {}",
283            array.value_length()
284        ));
285    }
286    let value_type = array.value_type();
287    let mut builder = NdColumnBuilder::with_value_type(value_type);
288    for row in 0..array.len() {
289        if array.is_null(row) {
290            builder.append_null();
291        } else {
292            builder.append(&NdArrowArray::try_new(array.value(row), dims.clone())?)?;
293        }
294    }
295    builder.finish()
296}
297
298/// Read a column that is an nd column or a canonical tensor column, by the
299/// extension name on `field`.
300pub fn column_from_field(field: &Field, array: &dyn Array) -> Result<NdColumn> {
301    if is_nd_field(field) {
302        return NdColumn::try_from_array(array);
303    }
304    match field.extension_type_name() {
305        Some(VARIABLE_SHAPE_TENSOR) => {
306            let storage = array
307                .as_any()
308                .downcast_ref::<StructArray>()
309                .ok_or_else(|| {
310                    nd_err!(
311                        InvalidEncoding,
312                        "variable shape tensor storage must be a Struct"
313                    )
314                })?;
315            from_variable_shape_tensor(storage, read_metadata(field)?)
316        }
317        Some(FIXED_SHAPE_TENSOR) => {
318            let storage = array
319                .as_any()
320                .downcast_ref::<FixedSizeListArray>()
321                .ok_or_else(|| {
322                    nd_err!(
323                        InvalidEncoding,
324                        "fixed shape tensor storage must be a FixedSizeList"
325                    )
326                })?;
327            from_fixed_shape_tensor(storage, read_metadata(field)?)
328        }
329        other => Err(nd_err!(
330            InvalidEncoding,
331            "field '{}' has extension type {other:?}, not an nd or tensor type",
332            field.name()
333        )),
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use arrow::array::{AsArray, Float32Array};
340    use arrow::datatypes::Float32Type;
341
342    use super::*;
343
344    fn nd(values: Vec<f32>, dims: &[(&str, usize)]) -> NdArrowArray {
345        NdArrowArray::try_new(
346            Arc::new(Float32Array::from(values)),
347            Dimensions::try_new(dims.iter().copied()).unwrap(),
348        )
349        .unwrap()
350    }
351
352    fn column(arrays: &[Option<NdArrowArray>]) -> NdColumn {
353        let mut builder = NdColumnBuilder::with_value_type(DataType::Float32);
354        for a in arrays {
355            builder.append_option(a.as_ref()).unwrap();
356        }
357        builder.finish().unwrap()
358    }
359
360    #[test]
361    fn variable_shape_round_trip() {
362        let a = nd(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], &[("y", 2), ("x", 3)]);
363        let b = nd(vec![7.0, 8.0], &[("y", 1), ("x", 2)]);
364        let col = column(&[Some(a.clone()), None, Some(b.clone())]);
365        let (field, storage) = to_variable_shape_tensor(&col, "t").unwrap();
366        assert_eq!(
367            field.metadata()[EXTENSION_TYPE_METADATA_KEY],
368            r#"{"dim_names":["y","x"],"uniform_shape":[null,null]}"#
369        );
370        let back = column_from_field(&field, &storage).unwrap();
371        assert_eq!(back.value(0), a);
372        assert!(back.is_null(1));
373        assert_eq!(back.value(2), b);
374    }
375
376    #[test]
377    fn fixed_shape_round_trip() {
378        let a = nd(vec![1.0, 2.0, 3.0, 4.0], &[("y", 2), ("x", 2)]);
379        let col = column(&[Some(a.clone()), None, Some(a.clone())]);
380        let (field, array) = to_fixed_shape_tensor(&col, "t").unwrap();
381        assert_eq!(array.value_length(), 4);
382        let back = column_from_field(&field, &array).unwrap();
383        assert_eq!(back.value(2), a);
384        assert!(back.is_null(1));
385        assert_eq!(
386            back.value(0)
387                .values()
388                .as_primitive::<Float32Type>()
389                .values(),
390            &[1.0, 2.0, 3.0, 4.0]
391        );
392    }
393
394    #[test]
395    fn reads_metadata_from_other_writers() {
396        let array = FixedSizeListArray::try_new(
397            element_field(&DataType::Float32),
398            2,
399            Arc::new(Float32Array::from(vec![1.0, 2.0])),
400            None,
401        )
402        .unwrap();
403        let field =
404            Field::new("t", array.data_type().clone(), true).with_metadata(HashMap::from([
405                (
406                    EXTENSION_TYPE_NAME_KEY.to_string(),
407                    FIXED_SHAPE_TENSOR.to_string(),
408                ),
409                (
410                    EXTENSION_TYPE_METADATA_KEY.to_string(),
411                    r#"{"shape":[2],"permutation":[0]}"#.to_string(),
412                ),
413            ]));
414        let col = column_from_field(&field, &array).unwrap();
415        assert_eq!(col.value(0).dims().names(), vec!["dim_0"]);
416    }
417
418    #[test]
419    fn mismatched_rows_and_null_elements_rejected() {
420        let a = nd(vec![1.0, 2.0], &[("x", 2)]);
421        let b = nd(vec![1.0, 2.0], &[("y", 2)]);
422        assert!(to_variable_shape_tensor(&column(&[Some(a.clone()), Some(b)]), "t").is_err());
423        let c = nd(vec![1.0, 2.0, 3.0], &[("x", 3)]);
424        assert!(to_fixed_shape_tensor(&column(&[Some(a), Some(c)]), "t").is_err());
425        let with_null = NdArrowArray::try_new(
426            Arc::new(Float32Array::from(vec![Some(1.0), None])),
427            Dimensions::try_new([("x", 2)]).unwrap(),
428        )
429        .unwrap();
430        assert!(to_variable_shape_tensor(&column(&[Some(with_null)]), "t").is_err());
431    }
432}