grc_20/model/
value.rs

1//! Value types for GRC-20 properties.
2//!
3//! Values are typed attribute instances on entities and relations.
4
5use std::borrow::Cow;
6
7use crate::model::Id;
8
9/// Data types for property values (spec Section 2.4).
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11#[repr(u8)]
12pub enum DataType {
13    Bool = 1,
14    Int64 = 2,
15    Float64 = 3,
16    Decimal = 4,
17    Text = 5,
18    Bytes = 6,
19    Date = 7,
20    Time = 8,
21    Datetime = 9,
22    Schedule = 10,
23    Point = 11,
24    Embedding = 12,
25}
26
27impl DataType {
28    /// Creates a DataType from its wire representation.
29    pub fn from_u8(v: u8) -> Option<DataType> {
30        match v {
31            1 => Some(DataType::Bool),
32            2 => Some(DataType::Int64),
33            3 => Some(DataType::Float64),
34            4 => Some(DataType::Decimal),
35            5 => Some(DataType::Text),
36            6 => Some(DataType::Bytes),
37            7 => Some(DataType::Date),
38            8 => Some(DataType::Time),
39            9 => Some(DataType::Datetime),
40            10 => Some(DataType::Schedule),
41            11 => Some(DataType::Point),
42            12 => Some(DataType::Embedding),
43            _ => None,
44        }
45    }
46}
47
48/// Embedding sub-types (spec Section 2.4).
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50#[repr(u8)]
51pub enum EmbeddingSubType {
52    /// 32-bit IEEE 754 float, little-endian (4 bytes per dim)
53    Float32 = 0,
54    /// Signed 8-bit integer (1 byte per dim)
55    Int8 = 1,
56    /// Bit-packed binary, LSB-first (1/8 byte per dim)
57    Binary = 2,
58}
59
60impl EmbeddingSubType {
61    /// Creates an EmbeddingSubType from its wire representation.
62    pub fn from_u8(v: u8) -> Option<EmbeddingSubType> {
63        match v {
64            0 => Some(EmbeddingSubType::Float32),
65            1 => Some(EmbeddingSubType::Int8),
66            2 => Some(EmbeddingSubType::Binary),
67            _ => None,
68        }
69    }
70
71    /// Returns the number of bytes needed for the given number of dimensions.
72    pub fn bytes_for_dims(self, dims: usize) -> usize {
73        match self {
74            EmbeddingSubType::Float32 => dims * 4,
75            EmbeddingSubType::Int8 => dims,
76            EmbeddingSubType::Binary => dims.div_ceil(8),
77        }
78    }
79}
80
81/// Decimal mantissa representation.
82///
83/// Most decimals fit in i64; larger values use big-endian two's complement bytes.
84#[derive(Debug, Clone, PartialEq, Eq, Hash)]
85pub enum DecimalMantissa<'a> {
86    /// Mantissa fits in signed 64-bit integer.
87    I64(i64),
88    /// Arbitrary precision: big-endian two's complement, minimal-length.
89    Big(Cow<'a, [u8]>),
90}
91
92impl DecimalMantissa<'_> {
93    /// Returns whether this mantissa has trailing zeros (not normalized).
94    pub fn has_trailing_zeros(&self) -> bool {
95        match self {
96            DecimalMantissa::I64(v) => *v != 0 && *v % 10 == 0,
97            DecimalMantissa::Big(bytes) => {
98                // For big mantissas, we'd need to convert to check
99                // This is a simplification - full check would convert to decimal
100                !bytes.is_empty() && bytes[bytes.len() - 1] == 0
101            }
102        }
103    }
104
105    /// Returns true if this is the zero mantissa.
106    pub fn is_zero(&self) -> bool {
107        match self {
108            DecimalMantissa::I64(v) => *v == 0,
109            DecimalMantissa::Big(bytes) => bytes.iter().all(|b| *b == 0),
110        }
111    }
112}
113
114/// A typed value that can be stored on an entity or relation.
115#[derive(Debug, Clone, PartialEq)]
116pub enum Value<'a> {
117    /// Boolean value.
118    Bool(bool),
119
120    /// 64-bit signed integer with optional unit.
121    Int64 {
122        value: i64,
123        /// Unit entity ID, or None for no unit.
124        unit: Option<Id>,
125    },
126
127    /// 64-bit IEEE 754 float (NaN not allowed) with optional unit.
128    Float64 {
129        value: f64,
130        /// Unit entity ID, or None for no unit.
131        unit: Option<Id>,
132    },
133
134    /// Arbitrary-precision decimal: value = mantissa * 10^exponent, with optional unit.
135    Decimal {
136        exponent: i32,
137        mantissa: DecimalMantissa<'a>,
138        /// Unit entity ID, or None for no unit.
139        unit: Option<Id>,
140    },
141
142    /// UTF-8 text with optional language.
143    Text {
144        value: Cow<'a, str>,
145        /// Language entity ID, or None for default language.
146        language: Option<Id>,
147    },
148
149    /// Opaque byte array.
150    Bytes(Cow<'a, [u8]>),
151
152    /// ISO 8601 date string (YYYY, YYYY-MM, or YYYY-MM-DD).
153    Date(Cow<'a, str>),
154
155    /// ISO 8601 time string with timezone (HH:MM:SS[.frac]TZ).
156    Time(Cow<'a, str>),
157
158    /// ISO 8601 datetime string (YYYY-MM-DDTHH:MM:SS[.frac][TZ]).
159    Datetime(Cow<'a, str>),
160
161    /// RFC 5545 iCalendar schedule string.
162    Schedule(Cow<'a, str>),
163
164    /// WGS84 geographic coordinate with optional altitude.
165    Point {
166        /// Longitude in degrees (-180 to +180).
167        lon: f64,
168        /// Latitude in degrees (-90 to +90).
169        lat: f64,
170        /// Altitude in meters above WGS84 ellipsoid (optional).
171        alt: Option<f64>,
172    },
173
174    /// Dense vector for semantic similarity search.
175    Embedding {
176        sub_type: EmbeddingSubType,
177        dims: usize,
178        /// Raw bytes in the format specified by sub_type.
179        data: Cow<'a, [u8]>,
180    },
181}
182
183impl Value<'_> {
184    /// Returns the data type of this value.
185    pub fn data_type(&self) -> DataType {
186        match self {
187            Value::Bool(_) => DataType::Bool,
188            Value::Int64 { .. } => DataType::Int64,
189            Value::Float64 { .. } => DataType::Float64,
190            Value::Decimal { .. } => DataType::Decimal,
191            Value::Text { .. } => DataType::Text,
192            Value::Bytes(_) => DataType::Bytes,
193            Value::Date(_) => DataType::Date,
194            Value::Time(_) => DataType::Time,
195            Value::Datetime(_) => DataType::Datetime,
196            Value::Schedule(_) => DataType::Schedule,
197            Value::Point { .. } => DataType::Point,
198            Value::Embedding { .. } => DataType::Embedding,
199        }
200    }
201
202    /// Validates this value according to spec rules.
203    ///
204    /// Returns an error description if invalid, None if valid.
205    pub fn validate(&self) -> Option<&'static str> {
206        match self {
207            Value::Float64 { value, .. } => {
208                if value.is_nan() {
209                    return Some("NaN is not allowed in Float64");
210                }
211            }
212            Value::Decimal { exponent, mantissa, .. } => {
213                // Zero must be {0, 0}
214                if mantissa.is_zero() && *exponent != 0 {
215                    return Some("zero DECIMAL must have exponent 0");
216                }
217                // Non-zero must not have trailing zeros
218                if !mantissa.is_zero() && mantissa.has_trailing_zeros() {
219                    return Some("DECIMAL mantissa has trailing zeros (not normalized)");
220                }
221            }
222            Value::Point { lon, lat, alt } => {
223                if *lon < -180.0 || *lon > 180.0 {
224                    return Some("longitude out of range [-180, +180]");
225                }
226                if *lat < -90.0 || *lat > 90.0 {
227                    return Some("latitude out of range [-90, +90]");
228                }
229                if lon.is_nan() || lat.is_nan() {
230                    return Some("NaN is not allowed in Point coordinates");
231                }
232                if let Some(a) = alt {
233                    if a.is_nan() {
234                        return Some("NaN is not allowed in Point altitude");
235                    }
236                }
237            }
238            Value::Embedding {
239                sub_type,
240                dims,
241                data,
242            } => {
243                let expected = sub_type.bytes_for_dims(*dims);
244                if data.len() != expected {
245                    return Some("embedding data length doesn't match dims");
246                }
247                // Check for NaN in float32 embeddings
248                if *sub_type == EmbeddingSubType::Float32 {
249                    for chunk in data.chunks_exact(4) {
250                        let f = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
251                        if f.is_nan() {
252                            return Some("NaN is not allowed in float32 embedding");
253                        }
254                    }
255                }
256            }
257            _ => {}
258        }
259        None
260    }
261}
262
263/// A property-value pair that can be attached to an object.
264#[derive(Debug, Clone, PartialEq)]
265pub struct PropertyValue<'a> {
266    /// The property ID this value is for.
267    pub property: Id,
268    /// The value.
269    pub value: Value<'a>,
270}
271
272/// A property definition in the schema.
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct Property {
275    /// The property's unique identifier.
276    pub id: Id,
277    /// The data type for values of this property.
278    pub data_type: DataType,
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_embedding_bytes_for_dims() {
287        assert_eq!(EmbeddingSubType::Float32.bytes_for_dims(10), 40);
288        assert_eq!(EmbeddingSubType::Int8.bytes_for_dims(10), 10);
289        assert_eq!(EmbeddingSubType::Binary.bytes_for_dims(10), 2);
290        assert_eq!(EmbeddingSubType::Binary.bytes_for_dims(8), 1);
291        assert_eq!(EmbeddingSubType::Binary.bytes_for_dims(9), 2);
292    }
293
294    #[test]
295    fn test_value_validation_nan() {
296        assert!(Value::Float64 { value: f64::NAN, unit: None }.validate().is_some());
297        assert!(Value::Float64 { value: f64::INFINITY, unit: None }.validate().is_none());
298        assert!(Value::Float64 { value: -f64::INFINITY, unit: None }.validate().is_none());
299        assert!(Value::Float64 { value: 42.0, unit: None }.validate().is_none());
300    }
301
302    #[test]
303    fn test_value_validation_point() {
304        assert!(Value::Point { lon: 0.0, lat: 91.0, alt: None }.validate().is_some());
305        assert!(Value::Point { lon: 0.0, lat: -91.0, alt: None }.validate().is_some());
306        assert!(Value::Point { lon: 181.0, lat: 0.0, alt: None }.validate().is_some());
307        assert!(Value::Point { lon: -181.0, lat: 0.0, alt: None }.validate().is_some());
308        assert!(Value::Point { lon: 180.0, lat: 90.0, alt: None }.validate().is_none());
309        assert!(Value::Point { lon: -180.0, lat: -90.0, alt: None }.validate().is_none());
310        // With altitude
311        assert!(Value::Point { lon: 0.0, lat: 0.0, alt: Some(1000.0) }.validate().is_none());
312        assert!(Value::Point { lon: 0.0, lat: 0.0, alt: Some(f64::NAN) }.validate().is_some());
313    }
314
315    #[test]
316    fn test_decimal_normalization() {
317        // Zero must have exponent 0
318        let zero_bad = Value::Decimal {
319            exponent: 1,
320            mantissa: DecimalMantissa::I64(0),
321            unit: None,
322        };
323        assert!(zero_bad.validate().is_some());
324
325        // Non-zero with trailing zeros is invalid
326        let trailing = Value::Decimal {
327            exponent: 0,
328            mantissa: DecimalMantissa::I64(1230),
329            unit: None,
330        };
331        assert!(trailing.validate().is_some());
332
333        // Valid decimal
334        let valid = Value::Decimal {
335            exponent: -2,
336            mantissa: DecimalMantissa::I64(1234),
337            unit: None,
338        };
339        assert!(valid.validate().is_none());
340    }
341}