1use std::collections::BTreeMap;
6use std::fmt;
7
8use sha2::{Digest, Sha256};
9
10use crate::RillError;
11#[cfg(feature = "serde")]
12use crate::ValidateState;
13
14pub const MAX_SCHEMA_FEATURES: usize = 4_096;
16pub const MAX_DESCRIPTOR_STRING_BYTES: usize = 256;
18pub const MAX_FEATURE_METADATA_ENTRIES: usize = 64;
20
21#[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 pub min: Option<f64>,
28 pub max: Option<f64>,
30}
31
32impl FeatureConstraint {
33 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#[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 pub name: String,
60 pub unit: Option<String>,
62 pub transform: Option<String>,
64 pub constraint: Option<FeatureConstraint>,
66 pub metadata: BTreeMap<String, String>,
68}
69
70impl FeatureDescriptor {
71 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 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#[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 pub const fn as_bytes(&self) -> &[u8; 32] {
118 &self.0
119 }
120
121 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#[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 pub version: u32,
145 pub features: Vec<FeatureDescriptor>,
147}
148
149impl FeatureSchema {
150 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 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 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#[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 pub name: String,
218 pub algorithm_version: String,
220 pub state_version: String,
222}
223
224impl AlgorithmDescriptor {
225 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 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#[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 pub algorithm: AlgorithmDescriptor,
255 pub feature_schema_hash: FeatureSchemaHash,
257 pub configuration_digest: Option<[u8; 32]>,
259}
260
261impl ModelDescriptor {
262 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 pub fn validate(&self) -> Result<(), RillError> {
279 self.algorithm.validate()
280 }
281
282 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}