Skip to main content

rill_ml/
descriptor.rs

1//! Deterministic feature-schema and model identity descriptors.
2//!
3//! Descriptors contain schema metadata, never feature values or training data.
4
5use std::collections::BTreeMap;
6use std::fmt;
7
8use sha2::{Digest, Sha256};
9
10use crate::RillError;
11#[cfg(feature = "serde")]
12use crate::ValidateState;
13
14/// Maximum number of features in one descriptor.
15pub const MAX_SCHEMA_FEATURES: usize = 4_096;
16/// Maximum UTF-8 byte length for a descriptor string.
17pub const MAX_DESCRIPTOR_STRING_BYTES: usize = 256;
18/// Maximum metadata entries on a single feature.
19pub const MAX_FEATURE_METADATA_ENTRIES: usize = 64;
20
21/// Optional numeric domain declared for a feature.
22#[derive(Debug, Clone, Copy, PartialEq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
25pub struct FeatureConstraint {
26    /// Inclusive lower bound.
27    pub min: Option<f64>,
28    /// Inclusive upper bound.
29    pub max: Option<f64>,
30}
31
32impl FeatureConstraint {
33    /// Validate finite bounds and their ordering.
34    pub fn validate(&self) -> Result<(), RillError> {
35        if self.min.is_some_and(|value| !value.is_finite())
36            || self.max.is_some_and(|value| !value.is_finite())
37        {
38            return Err(RillError::InvalidState(
39                "feature constraints must be finite".to_owned(),
40            ));
41        }
42        if let (Some(min), Some(max)) = (self.min, self.max)
43            && min > max
44        {
45            return Err(RillError::InvalidState(
46                "feature constraint minimum exceeds maximum".to_owned(),
47            ));
48        }
49        Ok(())
50    }
51}
52
53/// Semantic description of one ordered input feature.
54#[derive(Debug, Clone, PartialEq)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
56#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
57pub struct FeatureDescriptor {
58    /// Stable feature name.
59    pub name: String,
60    /// Optional unit such as `seconds` or `bytes`.
61    pub unit: Option<String>,
62    /// Optional caller-defined transform identifier.
63    pub transform: Option<String>,
64    /// Optional numeric domain.
65    pub constraint: Option<FeatureConstraint>,
66    /// Bounded semantic metadata. Ordered storage makes hashing canonical.
67    pub metadata: BTreeMap<String, String>,
68}
69
70impl FeatureDescriptor {
71    /// Create a descriptor with no optional metadata.
72    pub fn new(name: impl Into<String>) -> Result<Self, RillError> {
73        let descriptor = Self {
74            name: name.into(),
75            unit: None,
76            transform: None,
77            constraint: None,
78            metadata: BTreeMap::new(),
79        };
80        descriptor.validate()?;
81        Ok(descriptor)
82    }
83
84    /// Validate size, numeric, and metadata bounds.
85    pub fn validate(&self) -> Result<(), RillError> {
86        validate_string("feature name", &self.name, false)?;
87        if let Some(unit) = &self.unit {
88            validate_string("feature unit", unit, false)?;
89        }
90        if let Some(transform) = &self.transform {
91            validate_string("feature transform", transform, false)?;
92        }
93        if self.metadata.len() > MAX_FEATURE_METADATA_ENTRIES {
94            return Err(RillError::InvalidState(format!(
95                "feature metadata exceeds limit {}",
96                MAX_FEATURE_METADATA_ENTRIES
97            )));
98        }
99        for (key, value) in &self.metadata {
100            validate_string("feature metadata key", key, false)?;
101            validate_string("feature metadata value", value, true)?;
102        }
103        if let Some(constraint) = self.constraint {
104            constraint.validate()?;
105        }
106        Ok(())
107    }
108}
109
110/// Deterministic SHA-256 identity of a feature schema.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
112#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
113pub struct FeatureSchemaHash([u8; 32]);
114
115impl FeatureSchemaHash {
116    /// Raw 32-byte digest.
117    pub const fn as_bytes(&self) -> &[u8; 32] {
118        &self.0
119    }
120
121    /// Lowercase hexadecimal digest.
122    pub fn to_hex(self) -> String {
123        let mut output = String::with_capacity(64);
124        for byte in self.0 {
125            use std::fmt::Write as _;
126            let _ = write!(output, "{byte:02x}");
127        }
128        output
129    }
130}
131
132impl fmt::Display for FeatureSchemaHash {
133    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
134        formatter.write_str(&self.to_hex())
135    }
136}
137
138/// Ordered feature schema with an explicit caller-owned version.
139#[derive(Debug, Clone, PartialEq)]
140#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
141#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
142pub struct FeatureSchema {
143    /// Non-zero schema version.
144    pub version: u32,
145    /// Ordered feature descriptors. Order is part of the hash.
146    pub features: Vec<FeatureDescriptor>,
147}
148
149impl FeatureSchema {
150    /// Construct and validate an ordered schema.
151    pub fn new(version: u32, features: Vec<FeatureDescriptor>) -> Result<Self, RillError> {
152        let schema = Self { version, features };
153        schema.validate()?;
154        Ok(schema)
155    }
156
157    /// Validate version, capacity, descriptor, and unique-name invariants.
158    pub fn validate(&self) -> Result<(), RillError> {
159        if self.version == 0 {
160            return Err(RillError::InvalidState(
161                "feature schema version must be non-zero".to_owned(),
162            ));
163        }
164        if self.features.is_empty() || self.features.len() > MAX_SCHEMA_FEATURES {
165            return Err(RillError::InvalidState(format!(
166                "feature count must be in 1..={MAX_SCHEMA_FEATURES}"
167            )));
168        }
169        let mut names = std::collections::BTreeSet::new();
170        for feature in &self.features {
171            feature.validate()?;
172            if !names.insert(feature.name.as_str()) {
173                return Err(RillError::InvalidState(format!(
174                    "duplicate feature name `{}`",
175                    feature.name
176                )));
177            }
178        }
179        Ok(())
180    }
181
182    /// Compute a canonical SHA-256 digest without relying on JSON map order.
183    pub fn hash(&self) -> Result<FeatureSchemaHash, RillError> {
184        self.validate()?;
185        let mut hasher = Sha256::new();
186        hasher.update(b"rill-feature-schema-v1\0");
187        hasher.update(self.version.to_be_bytes());
188        write_len(&mut hasher, self.features.len());
189        for feature in &self.features {
190            write_string(&mut hasher, &feature.name);
191            write_optional_string(&mut hasher, feature.unit.as_deref());
192            write_optional_string(&mut hasher, feature.transform.as_deref());
193            match feature.constraint {
194                None => hasher.update([0]),
195                Some(constraint) => {
196                    hasher.update([1]);
197                    write_optional_f64(&mut hasher, constraint.min);
198                    write_optional_f64(&mut hasher, constraint.max);
199                }
200            }
201            write_len(&mut hasher, feature.metadata.len());
202            for (key, value) in &feature.metadata {
203                write_string(&mut hasher, key);
204                write_string(&mut hasher, value);
205            }
206        }
207        Ok(FeatureSchemaHash(hasher.finalize().into()))
208    }
209}
210
211/// Algorithm and state-format identity independent of product semantics.
212#[derive(Debug, Clone, PartialEq, Eq)]
213#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
214#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
215pub struct AlgorithmDescriptor {
216    /// Stable algorithm name.
217    pub name: String,
218    /// Algorithm implementation/version label.
219    pub algorithm_version: String,
220    /// State schema/version label.
221    pub state_version: String,
222}
223
224impl AlgorithmDescriptor {
225    /// Construct and validate algorithm identity.
226    pub fn new(
227        name: impl Into<String>,
228        algorithm_version: impl Into<String>,
229        state_version: impl Into<String>,
230    ) -> Result<Self, RillError> {
231        let descriptor = Self {
232            name: name.into(),
233            algorithm_version: algorithm_version.into(),
234            state_version: state_version.into(),
235        };
236        descriptor.validate()?;
237        Ok(descriptor)
238    }
239
240    /// Validate bounded non-empty strings.
241    pub fn validate(&self) -> Result<(), RillError> {
242        validate_string("algorithm name", &self.name, false)?;
243        validate_string("algorithm version", &self.algorithm_version, false)?;
244        validate_string("algorithm state version", &self.state_version, false)
245    }
246}
247
248/// Identity checked when activating persisted model state.
249#[derive(Debug, Clone, PartialEq, Eq)]
250#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
251#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
252pub struct ModelDescriptor {
253    /// Algorithm/state identity.
254    pub algorithm: AlgorithmDescriptor,
255    /// Exact ordered feature schema identity.
256    pub feature_schema_hash: FeatureSchemaHash,
257    /// Optional caller-generated configuration digest.
258    pub configuration_digest: Option<[u8; 32]>,
259}
260
261impl ModelDescriptor {
262    /// Construct and validate a descriptor.
263    pub fn new(
264        algorithm: AlgorithmDescriptor,
265        feature_schema_hash: FeatureSchemaHash,
266        configuration_digest: Option<[u8; 32]>,
267    ) -> Result<Self, RillError> {
268        let descriptor = Self {
269            algorithm,
270            feature_schema_hash,
271            configuration_digest,
272        };
273        descriptor.validate()?;
274        Ok(descriptor)
275    }
276
277    /// Validate nested identity state.
278    pub fn validate(&self) -> Result<(), RillError> {
279        self.algorithm.validate()
280    }
281
282    /// Reject state activated under a different feature schema.
283    pub fn ensure_schema(&self, schema: &FeatureSchema) -> Result<(), RillError> {
284        let actual = schema.hash()?;
285        if actual != self.feature_schema_hash {
286            return Err(RillError::InvalidState(format!(
287                "feature schema hash mismatch: expected {}, got {}",
288                self.feature_schema_hash, actual
289            )));
290        }
291        Ok(())
292    }
293}
294
295#[cfg(feature = "serde")]
296impl ValidateState for FeatureDescriptor {
297    fn validate_state(&self) -> Result<(), RillError> {
298        self.validate()
299    }
300}
301
302#[cfg(feature = "serde")]
303impl ValidateState for FeatureSchema {
304    fn validate_state(&self) -> Result<(), RillError> {
305        self.validate()
306    }
307}
308
309#[cfg(feature = "serde")]
310impl ValidateState for AlgorithmDescriptor {
311    fn validate_state(&self) -> Result<(), RillError> {
312        self.validate()
313    }
314}
315
316#[cfg(feature = "serde")]
317impl ValidateState for ModelDescriptor {
318    fn validate_state(&self) -> Result<(), RillError> {
319        self.validate()
320    }
321}
322
323fn validate_string(field: &str, value: &str, allow_empty: bool) -> Result<(), RillError> {
324    if (!allow_empty && value.is_empty()) || value.len() > MAX_DESCRIPTOR_STRING_BYTES {
325        return Err(RillError::InvalidState(format!(
326            "{field} must contain {}..={MAX_DESCRIPTOR_STRING_BYTES} bytes",
327            usize::from(!allow_empty)
328        )));
329    }
330    if value.chars().any(char::is_control) {
331        return Err(RillError::InvalidState(format!(
332            "{field} must not contain control characters"
333        )));
334    }
335    Ok(())
336}
337
338fn write_len(hasher: &mut Sha256, value: usize) {
339    hasher.update((value as u64).to_be_bytes());
340}
341
342fn write_string(hasher: &mut Sha256, value: &str) {
343    write_len(hasher, value.len());
344    hasher.update(value.as_bytes());
345}
346
347fn write_optional_string(hasher: &mut Sha256, value: Option<&str>) {
348    match value {
349        Some(value) => {
350            hasher.update([1]);
351            write_string(hasher, value);
352        }
353        None => hasher.update([0]),
354    }
355}
356
357fn write_optional_f64(hasher: &mut Sha256, value: Option<f64>) {
358    match value {
359        Some(value) => {
360            hasher.update([1]);
361            hasher.update(value.to_bits().to_be_bytes());
362        }
363        None => hasher.update([0]),
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use proptest::prelude::*;
371
372    fn feature(name: &str) -> FeatureDescriptor {
373        FeatureDescriptor::new(name).unwrap()
374    }
375
376    #[test]
377    fn feature_order_and_version_change_hash() {
378        let a = FeatureSchema::new(1, vec![feature("a"), feature("b")]).unwrap();
379        let b = FeatureSchema::new(1, vec![feature("b"), feature("a")]).unwrap();
380        let c = FeatureSchema::new(2, vec![feature("a"), feature("b")]).unwrap();
381        assert_ne!(a.hash().unwrap(), b.hash().unwrap());
382        assert_ne!(a.hash().unwrap(), c.hash().unwrap());
383    }
384
385    #[cfg(feature = "serde")]
386    #[test]
387    fn metadata_insertion_and_json_map_order_do_not_change_hash() {
388        let mut first = feature("latency");
389        first
390            .metadata
391            .insert("source".to_owned(), "host".to_owned());
392        first
393            .metadata
394            .insert("kind".to_owned(), "numeric".to_owned());
395        let schema = FeatureSchema::new(1, vec![first]).unwrap();
396
397        let expected = schema.hash().unwrap();
398        let json = r#"{"version":1,"features":[{"name":"latency","unit":null,"transform":null,"constraint":null,"metadata":{"kind":"numeric","source":"host"}}]}"#;
399        let decoded: FeatureSchema = serde_json::from_str(json).unwrap();
400        assert_eq!(decoded.hash().unwrap(), expected);
401    }
402
403    #[test]
404    fn descriptor_rejects_schema_mismatch() {
405        let schema = FeatureSchema::new(1, vec![feature("x")]).unwrap();
406        let descriptor = ModelDescriptor::new(
407            AlgorithmDescriptor::new("linucb", "1", "1").unwrap(),
408            schema.hash().unwrap(),
409            None,
410        )
411        .unwrap();
412        descriptor.ensure_schema(&schema).unwrap();
413        let other = FeatureSchema::new(1, vec![feature("y")]).unwrap();
414        assert!(descriptor.ensure_schema(&other).is_err());
415    }
416
417    #[cfg(feature = "serde")]
418    #[test]
419    fn serde_roundtrip_preserves_identity() {
420        let schema = FeatureSchema::new(1, vec![feature("x")]).unwrap();
421        let json = serde_json::to_string(&schema).unwrap();
422        let restored: FeatureSchema = serde_json::from_str(&json).unwrap();
423        restored.validate_state().unwrap();
424        assert_eq!(restored.hash().unwrap(), schema.hash().unwrap());
425    }
426
427    #[cfg(feature = "serde")]
428    #[test]
429    fn golden_schema_fixture_has_stable_hash() {
430        let schema: FeatureSchema = serde_json::from_str(include_str!(
431            "../tests/fixtures/descriptor/feature-schema-v1.json"
432        ))
433        .unwrap();
434        schema.validate_state().unwrap();
435        assert_eq!(
436            schema.hash().unwrap().to_hex(),
437            "7ac14564ad6e4c1581185d4e8f84cb42ff5e939a3f08df4213be34cdbc8e73e2"
438        );
439    }
440
441    proptest! {
442        #[test]
443        fn hash_is_deterministic_for_valid_names(names in prop::collection::vec("[a-z]{1,12}", 1..32)) {
444            let mut unique = names;
445            unique.sort();
446            unique.dedup();
447            prop_assume!(!unique.is_empty());
448            let features = unique.iter().map(|name| feature(name)).collect();
449            let schema = FeatureSchema::new(1, features).unwrap();
450            prop_assert_eq!(schema.hash().unwrap(), schema.hash().unwrap());
451        }
452    }
453}