Skip to main content

nodedb_array/tile/
layout.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Tile layout — mapping cell coordinates to tile boundaries.
4//!
5//! Tile coordinates are cell coordinates integer-divided by the
6//! schema's `tile_extents`. The space-filling-curve prefix is computed
7//! over the *tile* coordinates (not cell coordinates), so cells in the
8//! same tile share a [`TileId::hilbert_prefix`].
9
10use crate::coord::encode::encode_tile_prefix_with_order;
11use crate::coord::normalize::bits_per_dim;
12use crate::error::{ArrayError, ArrayResult};
13use crate::schema::ArraySchema;
14use crate::schema::dim_spec::DimType;
15use crate::types::TileId;
16use crate::types::coord::value::CoordValue;
17use crate::types::domain::DomainBound;
18
19/// Compute the per-dim tile index for one cell coordinate.
20pub fn tile_indices_for_cell(schema: &ArraySchema, coord: &[CoordValue]) -> ArrayResult<Vec<u64>> {
21    if coord.len() != schema.arity() {
22        return Err(ArrayError::CoordArityMismatch {
23            array: schema.name.clone(),
24            expected: schema.arity(),
25            got: coord.len(),
26        });
27    }
28    let mut out = Vec::with_capacity(schema.arity());
29    for (i, dim) in schema.dims.iter().enumerate() {
30        let extent = schema.tile_extents[i];
31        let cell = &coord[i];
32        let lo = &dim.domain.lo;
33        let off = match (dim.dtype, cell, lo) {
34            (DimType::Int64, CoordValue::Int64(v), DomainBound::Int64(lo))
35            | (DimType::TimestampMs, CoordValue::TimestampMs(v), DomainBound::TimestampMs(lo)) => {
36                if v < lo {
37                    return out_of_domain(schema, dim.name.as_str(), "below domain_lo");
38                }
39                let delta = (*v as i128) - (*lo as i128);
40                (delta as u128 / u128::from(extent)) as u64
41            }
42            (DimType::Float64, CoordValue::Float64(v), DomainBound::Float64(lo)) => {
43                if !v.is_finite() || v < lo {
44                    return out_of_domain(
45                        schema,
46                        dim.name.as_str(),
47                        "below domain_lo or non-finite",
48                    );
49                }
50                ((v - lo) as u64) / extent
51            }
52            (DimType::String, CoordValue::String(s), DomainBound::String(_)) => {
53                // Strings have no natural tile-extent semantics — bucket
54                // by hash modulo extent for stable grouping.
55                crate::coord::string_hash::hash_string_modulo(s, extent.max(1))
56            }
57            _ => {
58                return Err(ArrayError::CoordOutOfDomain {
59                    array: schema.name.clone(),
60                    dim: dim.name.clone(),
61                    detail: "coord variant does not match dim dtype".to_string(),
62                });
63            }
64        };
65        out.push(off);
66    }
67    Ok(out)
68}
69
70/// Compute the [`TileId`] for one cell coordinate at a given system
71/// time. Non-bitemporal callers pass `system_from_ms = 0`; bitemporal
72/// writes supply the leader-stamped HLC component.
73pub fn tile_id_for_cell(
74    schema: &ArraySchema,
75    coord: &[CoordValue],
76    system_from_ms: i64,
77) -> ArrayResult<TileId> {
78    let tile_indices = tile_indices_for_cell(schema, coord)?;
79    let bits = bits_per_dim(schema.arity());
80    let prefix = encode_tile_prefix_with_order(schema, &tile_indices, bits)?;
81    Ok(TileId::new(prefix, system_from_ms))
82}
83
84fn out_of_domain<T>(schema: &ArraySchema, dim: &str, detail: &str) -> ArrayResult<T> {
85    Err(ArrayError::CoordOutOfDomain {
86        array: schema.name.clone(),
87        dim: dim.to_string(),
88        detail: detail.to_string(),
89    })
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::schema::ArraySchemaBuilder;
96    use crate::schema::attr_spec::{AttrSpec, AttrType};
97    use crate::schema::dim_spec::DimSpec;
98    use crate::types::domain::Domain;
99
100    fn schema() -> ArraySchema {
101        ArraySchemaBuilder::new("g")
102            .dim(DimSpec::new(
103                "x",
104                DimType::Int64,
105                Domain::new(DomainBound::Int64(0), DomainBound::Int64(99)),
106            ))
107            .dim(DimSpec::new(
108                "y",
109                DimType::Int64,
110                Domain::new(DomainBound::Int64(0), DomainBound::Int64(99)),
111            ))
112            .attr(AttrSpec::new("v", AttrType::Int64, false))
113            .tile_extents(vec![10, 10])
114            .build()
115            .unwrap()
116    }
117
118    #[test]
119    fn tile_indices_floor_by_extent() {
120        let s = schema();
121        let t = tile_indices_for_cell(&s, &[CoordValue::Int64(7), CoordValue::Int64(15)]).unwrap();
122        assert_eq!(t, vec![0, 1]);
123    }
124
125    #[test]
126    fn cells_in_same_tile_share_prefix() {
127        let s = schema();
128        let t1 = tile_id_for_cell(&s, &[CoordValue::Int64(0), CoordValue::Int64(0)], 0).unwrap();
129        let t2 = tile_id_for_cell(&s, &[CoordValue::Int64(9), CoordValue::Int64(9)], 0).unwrap();
130        assert_eq!(t1.hilbert_prefix, t2.hilbert_prefix);
131    }
132
133    #[test]
134    fn cells_in_different_tiles_have_different_prefixes() {
135        let s = schema();
136        let t1 = tile_id_for_cell(&s, &[CoordValue::Int64(0), CoordValue::Int64(0)], 0).unwrap();
137        let t2 = tile_id_for_cell(&s, &[CoordValue::Int64(50), CoordValue::Int64(50)], 0).unwrap();
138        assert_ne!(t1.hilbert_prefix, t2.hilbert_prefix);
139    }
140
141    #[test]
142    fn system_from_ms_propagates() {
143        let s = schema();
144        let t = tile_id_for_cell(&s, &[CoordValue::Int64(0), CoordValue::Int64(0)], 1234).unwrap();
145        assert_eq!(t.system_from_ms, 1234);
146    }
147}