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    /// Calendar date (6 bytes: int32 days + int16 offset_min).
153    Date {
154        /// Signed days since Unix epoch (1970-01-01).
155        days: i32,
156        /// Signed UTC offset in minutes (e.g., +330 for +05:30).
157        offset_min: i16,
158    },
159
160    /// Time of day (8 bytes: int48 time_us + int16 offset_min).
161    Time {
162        /// Microseconds since midnight (0 to 86,399,999,999).
163        time_us: i64,
164        /// Signed UTC offset in minutes (e.g., +330 for +05:30).
165        offset_min: i16,
166    },
167
168    /// Combined date and time (10 bytes: int64 epoch_us + int16 offset_min).
169    Datetime {
170        /// Microseconds since Unix epoch (1970-01-01T00:00:00Z).
171        epoch_us: i64,
172        /// Signed UTC offset in minutes (e.g., +330 for +05:30).
173        offset_min: i16,
174    },
175
176    /// RFC 5545 iCalendar schedule string.
177    Schedule(Cow<'a, str>),
178
179    /// WGS84 geographic coordinate with optional altitude.
180    Point {
181        /// Longitude in degrees (-180 to +180).
182        lon: f64,
183        /// Latitude in degrees (-90 to +90).
184        lat: f64,
185        /// Altitude in meters above WGS84 ellipsoid (optional).
186        alt: Option<f64>,
187    },
188
189    /// Dense vector for semantic similarity search.
190    Embedding {
191        sub_type: EmbeddingSubType,
192        dims: usize,
193        /// Raw bytes in the format specified by sub_type.
194        data: Cow<'a, [u8]>,
195    },
196}
197
198impl Value<'_> {
199    /// Returns the data type of this value.
200    pub fn data_type(&self) -> DataType {
201        match self {
202            Value::Bool(_) => DataType::Bool,
203            Value::Int64 { .. } => DataType::Int64,
204            Value::Float64 { .. } => DataType::Float64,
205            Value::Decimal { .. } => DataType::Decimal,
206            Value::Text { .. } => DataType::Text,
207            Value::Bytes(_) => DataType::Bytes,
208            Value::Date { .. } => DataType::Date,
209            Value::Time { .. } => DataType::Time,
210            Value::Datetime { .. } => DataType::Datetime,
211            Value::Schedule(_) => DataType::Schedule,
212            Value::Point { .. } => DataType::Point,
213            Value::Embedding { .. } => DataType::Embedding,
214        }
215    }
216
217    /// Validates this value according to spec rules.
218    ///
219    /// Returns an error description if invalid, None if valid.
220    pub fn validate(&self) -> Option<&'static str> {
221        match self {
222            Value::Float64 { value, .. } => {
223                if value.is_nan() {
224                    return Some("NaN is not allowed in Float64");
225                }
226            }
227            Value::Decimal { exponent, mantissa, .. } => {
228                // Zero must be {0, 0}
229                if mantissa.is_zero() && *exponent != 0 {
230                    return Some("zero DECIMAL must have exponent 0");
231                }
232                // Non-zero must not have trailing zeros
233                if !mantissa.is_zero() && mantissa.has_trailing_zeros() {
234                    return Some("DECIMAL mantissa has trailing zeros (not normalized)");
235                }
236            }
237            Value::Point { lon, lat, alt } => {
238                if *lon < -180.0 || *lon > 180.0 {
239                    return Some("longitude out of range [-180, +180]");
240                }
241                if *lat < -90.0 || *lat > 90.0 {
242                    return Some("latitude out of range [-90, +90]");
243                }
244                if lon.is_nan() || lat.is_nan() {
245                    return Some("NaN is not allowed in Point coordinates");
246                }
247                if let Some(a) = alt {
248                    if a.is_nan() {
249                        return Some("NaN is not allowed in Point altitude");
250                    }
251                }
252            }
253            Value::Date { offset_min, .. } => {
254                if *offset_min < -1440 || *offset_min > 1440 {
255                    return Some("DATE offset_min outside range [-1440, +1440]");
256                }
257            }
258            Value::Time { time_us, offset_min } => {
259                if *time_us < 0 || *time_us > 86_399_999_999 {
260                    return Some("TIME time_us outside range [0, 86399999999]");
261                }
262                if *offset_min < -1440 || *offset_min > 1440 {
263                    return Some("TIME offset_min outside range [-1440, +1440]");
264                }
265            }
266            Value::Datetime { offset_min, .. } => {
267                if *offset_min < -1440 || *offset_min > 1440 {
268                    return Some("DATETIME offset_min outside range [-1440, +1440]");
269                }
270            }
271            Value::Embedding {
272                sub_type,
273                dims,
274                data,
275            } => {
276                let expected = sub_type.bytes_for_dims(*dims);
277                if data.len() != expected {
278                    return Some("embedding data length doesn't match dims");
279                }
280                // Check for NaN in float32 embeddings
281                if *sub_type == EmbeddingSubType::Float32 {
282                    for chunk in data.chunks_exact(4) {
283                        let f = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
284                        if f.is_nan() {
285                            return Some("NaN is not allowed in float32 embedding");
286                        }
287                    }
288                }
289            }
290            _ => {}
291        }
292        None
293    }
294}
295
296/// A property-value pair that can be attached to an object.
297#[derive(Debug, Clone, PartialEq)]
298pub struct PropertyValue<'a> {
299    /// The property ID this value is for.
300    pub property: Id,
301    /// The value.
302    pub value: Value<'a>,
303}
304
305/// A property definition in the schema.
306#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct Property {
308    /// The property's unique identifier.
309    pub id: Id,
310    /// The data type for values of this property.
311    pub data_type: DataType,
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn test_embedding_bytes_for_dims() {
320        assert_eq!(EmbeddingSubType::Float32.bytes_for_dims(10), 40);
321        assert_eq!(EmbeddingSubType::Int8.bytes_for_dims(10), 10);
322        assert_eq!(EmbeddingSubType::Binary.bytes_for_dims(10), 2);
323        assert_eq!(EmbeddingSubType::Binary.bytes_for_dims(8), 1);
324        assert_eq!(EmbeddingSubType::Binary.bytes_for_dims(9), 2);
325    }
326
327    #[test]
328    fn test_value_validation_nan() {
329        assert!(Value::Float64 { value: f64::NAN, unit: None }.validate().is_some());
330        assert!(Value::Float64 { value: f64::INFINITY, unit: None }.validate().is_none());
331        assert!(Value::Float64 { value: -f64::INFINITY, unit: None }.validate().is_none());
332        assert!(Value::Float64 { value: 42.0, unit: None }.validate().is_none());
333    }
334
335    #[test]
336    fn test_value_validation_point() {
337        assert!(Value::Point { lon: 0.0, lat: 91.0, alt: None }.validate().is_some());
338        assert!(Value::Point { lon: 0.0, lat: -91.0, alt: None }.validate().is_some());
339        assert!(Value::Point { lon: 181.0, lat: 0.0, alt: None }.validate().is_some());
340        assert!(Value::Point { lon: -181.0, lat: 0.0, alt: None }.validate().is_some());
341        assert!(Value::Point { lon: 180.0, lat: 90.0, alt: None }.validate().is_none());
342        assert!(Value::Point { lon: -180.0, lat: -90.0, alt: None }.validate().is_none());
343        // With altitude
344        assert!(Value::Point { lon: 0.0, lat: 0.0, alt: Some(1000.0) }.validate().is_none());
345        assert!(Value::Point { lon: 0.0, lat: 0.0, alt: Some(f64::NAN) }.validate().is_some());
346    }
347
348    #[test]
349    fn test_decimal_normalization() {
350        // Zero must have exponent 0
351        let zero_bad = Value::Decimal {
352            exponent: 1,
353            mantissa: DecimalMantissa::I64(0),
354            unit: None,
355        };
356        assert!(zero_bad.validate().is_some());
357
358        // Non-zero with trailing zeros is invalid
359        let trailing = Value::Decimal {
360            exponent: 0,
361            mantissa: DecimalMantissa::I64(1230),
362            unit: None,
363        };
364        assert!(trailing.validate().is_some());
365
366        // Valid decimal
367        let valid = Value::Decimal {
368            exponent: -2,
369            mantissa: DecimalMantissa::I64(1234),
370            unit: None,
371        };
372        assert!(valid.validate().is_none());
373    }
374}