Skip to main content

peat_schema/validation/
product.rs

1//! Product validators (AI/ML Products)
2//!
3//! Validates Product messages and their content types for Peat Protocol.
4
5use super::{ValidationError, ValidationResult};
6use crate::product::v1::{
7    AlertProduct, AlertSeverity, AlertType, ChatProduct, ClassificationProduct, DetectionProduct,
8    EmbeddingProduct, ImageFormat, ImageProduct, Product, ProductType, SegmentationProduct,
9    SummaryProduct, SummaryType, TranscriptionProduct,
10};
11
12/// Validate a Product message
13///
14/// Validates:
15/// - product_id is present
16/// - product_type is specified (not unspecified)
17/// - source_platform is present
18/// - timestamp is present
19/// - confidence is in valid range (0.0 - 1.0)
20/// - content is present and valid for the product type
21pub fn validate_product(product: &Product) -> ValidationResult<()> {
22    // Check required fields
23    if product.product_id.is_empty() {
24        return Err(ValidationError::MissingField("product_id".to_string()));
25    }
26
27    // Product type must be specified
28    if product.product_type == ProductType::Unspecified as i32 {
29        return Err(ValidationError::InvalidValue(
30            "product_type must be specified".to_string(),
31        ));
32    }
33
34    if product.source_platform.is_empty() {
35        return Err(ValidationError::MissingField("source_platform".to_string()));
36    }
37
38    // Timestamp is required
39    if product.timestamp.is_none() {
40        return Err(ValidationError::MissingField("timestamp".to_string()));
41    }
42
43    // Confidence must be in valid range
44    if product.confidence < 0.0 || product.confidence > 1.0 {
45        return Err(ValidationError::InvalidConfidence(product.confidence));
46    }
47
48    // Validate model_source if present
49    if let Some(ref source) = product.model_source {
50        if source.model_id.is_empty() {
51            return Err(ValidationError::MissingField(
52                "model_source.model_id".to_string(),
53            ));
54        }
55    }
56
57    // Validate content based on type
58    use crate::product::v1::product::Content;
59    match &product.content {
60        Some(Content::Image(img)) => validate_image_product(img)?,
61        Some(Content::Classification(cls)) => validate_classification_product(cls)?,
62        Some(Content::Detection(det)) => validate_detection_product(det)?,
63        Some(Content::Summary(sum)) => validate_summary_product(sum)?,
64        Some(Content::Chat(chat)) => validate_chat_product(chat)?,
65        Some(Content::Alert(alert)) => validate_alert_product(alert)?,
66        Some(Content::Embedding(emb)) => validate_embedding_product(emb)?,
67        Some(Content::Segmentation(seg)) => validate_segmentation_product(seg)?,
68        Some(Content::Transcription(trans)) => validate_transcription_product(trans)?,
69        None => {
70            return Err(ValidationError::MissingField("content".to_string()));
71        }
72    }
73
74    Ok(())
75}
76
77/// Validate an ImageProduct (chipout, thumbnail, etc.)
78pub fn validate_image_product(image: &ImageProduct) -> ValidationResult<()> {
79    // Format must be specified
80    if image.format == ImageFormat::Unspecified as i32 {
81        return Err(ValidationError::InvalidValue(
82            "image format must be specified".to_string(),
83        ));
84    }
85
86    // Dimensions must be positive
87    if image.width == 0 {
88        return Err(ValidationError::InvalidValue(
89            "image width must be positive".to_string(),
90        ));
91    }
92
93    if image.height == 0 {
94        return Err(ValidationError::InvalidValue(
95            "image height must be positive".to_string(),
96        ));
97    }
98
99    // Must have image data (one of data, data_base64, url, or blob_hash)
100    use crate::product::v1::image_product::ImageData;
101    match &image.image_data {
102        Some(ImageData::Data(bytes)) => {
103            if bytes.is_empty() {
104                return Err(ValidationError::InvalidValue(
105                    "image data must not be empty".to_string(),
106                ));
107            }
108        }
109        Some(ImageData::DataBase64(b64)) => {
110            if b64.is_empty() {
111                return Err(ValidationError::InvalidValue(
112                    "image data_base64 must not be empty".to_string(),
113                ));
114            }
115        }
116        Some(ImageData::Url(url)) => {
117            if url.is_empty() {
118                return Err(ValidationError::InvalidValue(
119                    "image url must not be empty".to_string(),
120                ));
121            }
122            if !url.contains("://") {
123                return Err(ValidationError::InvalidValue(
124                    "image url must be a valid URL with scheme".to_string(),
125                ));
126            }
127        }
128        Some(ImageData::BlobHash(hash)) => {
129            if hash.is_empty() {
130                return Err(ValidationError::InvalidValue(
131                    "image blob_hash must not be empty".to_string(),
132                ));
133            }
134        }
135        None => {
136            return Err(ValidationError::MissingField("image_data".to_string()));
137        }
138    }
139
140    Ok(())
141}
142
143/// Validate a ClassificationProduct
144pub fn validate_classification_product(cls: &ClassificationProduct) -> ValidationResult<()> {
145    if cls.label.is_empty() {
146        return Err(ValidationError::MissingField("label".to_string()));
147    }
148
149    if cls.confidence < 0.0 || cls.confidence > 1.0 {
150        return Err(ValidationError::InvalidConfidence(cls.confidence));
151    }
152
153    // Validate top_k scores
154    for score in &cls.top_k {
155        if score.score < 0.0 || score.score > 1.0 {
156            return Err(ValidationError::InvalidConfidence(score.score));
157        }
158    }
159
160    Ok(())
161}
162
163/// Validate a DetectionProduct
164pub fn validate_detection_product(det: &DetectionProduct) -> ValidationResult<()> {
165    if det.label.is_empty() {
166        return Err(ValidationError::MissingField("label".to_string()));
167    }
168
169    if det.confidence < 0.0 || det.confidence > 1.0 {
170        return Err(ValidationError::InvalidConfidence(det.confidence));
171    }
172
173    // Bounding box should have 4 elements [x, y, width, height]
174    if det.bbox.len() != 4 {
175        return Err(ValidationError::InvalidValue(format!(
176            "bbox must have 4 elements, got {}",
177            det.bbox.len()
178        )));
179    }
180
181    // Frame size should have 2 elements [width, height]
182    if det.frame_size.len() != 2 {
183        return Err(ValidationError::InvalidValue(format!(
184            "frame_size must have 2 elements, got {}",
185            det.frame_size.len()
186        )));
187    }
188
189    Ok(())
190}
191
192/// Validate a SummaryProduct
193pub fn validate_summary_product(summary: &SummaryProduct) -> ValidationResult<()> {
194    if summary.text.is_empty() {
195        return Err(ValidationError::MissingField("text".to_string()));
196    }
197
198    // Summary type must be specified
199    if summary.summary_type == SummaryType::Unspecified as i32 {
200        return Err(ValidationError::InvalidValue(
201            "summary_type must be specified".to_string(),
202        ));
203    }
204
205    Ok(())
206}
207
208/// Validate a ChatProduct
209pub fn validate_chat_product(chat: &ChatProduct) -> ValidationResult<()> {
210    if chat.response.is_empty() {
211        return Err(ValidationError::MissingField("response".to_string()));
212    }
213
214    if chat.model_name.is_empty() {
215        return Err(ValidationError::MissingField("model_name".to_string()));
216    }
217
218    // Temperature should be non-negative
219    if chat.temperature < 0.0 {
220        return Err(ValidationError::InvalidValue(
221            "temperature must be non-negative".to_string(),
222        ));
223    }
224
225    // top_p should be in [0, 1]
226    if chat.top_p < 0.0 || chat.top_p > 1.0 {
227        return Err(ValidationError::InvalidValue(format!(
228            "top_p {} must be between 0.0 and 1.0",
229            chat.top_p
230        )));
231    }
232
233    Ok(())
234}
235
236/// Validate an AlertProduct
237pub fn validate_alert_product(alert: &AlertProduct) -> ValidationResult<()> {
238    // Alert type must be specified
239    if alert.alert_type == AlertType::Unspecified as i32 {
240        return Err(ValidationError::InvalidValue(
241            "alert_type must be specified".to_string(),
242        ));
243    }
244
245    // Severity must be specified
246    if alert.severity == AlertSeverity::Unspecified as i32 {
247        return Err(ValidationError::InvalidValue(
248            "severity must be specified".to_string(),
249        ));
250    }
251
252    if alert.message.is_empty() {
253        return Err(ValidationError::MissingField("message".to_string()));
254    }
255
256    Ok(())
257}
258
259/// Validate an EmbeddingProduct
260pub fn validate_embedding_product(emb: &EmbeddingProduct) -> ValidationResult<()> {
261    if emb.vector.is_empty() {
262        return Err(ValidationError::MissingField("vector".to_string()));
263    }
264
265    if emb.dimensions == 0 {
266        return Err(ValidationError::InvalidValue(
267            "dimensions must be positive".to_string(),
268        ));
269    }
270
271    // Vector length should match dimensions
272    if emb.vector.len() != emb.dimensions as usize {
273        return Err(ValidationError::ConstraintViolation(format!(
274            "vector length {} does not match dimensions {}",
275            emb.vector.len(),
276            emb.dimensions
277        )));
278    }
279
280    if emb.embedding_model.is_empty() {
281        return Err(ValidationError::MissingField("embedding_model".to_string()));
282    }
283
284    Ok(())
285}
286
287/// Validate a SegmentationProduct
288pub fn validate_segmentation_product(seg: &SegmentationProduct) -> ValidationResult<()> {
289    if seg.mask_data.is_empty() {
290        return Err(ValidationError::MissingField("mask_data".to_string()));
291    }
292
293    if seg.width == 0 {
294        return Err(ValidationError::InvalidValue(
295            "width must be positive".to_string(),
296        ));
297    }
298
299    if seg.height == 0 {
300        return Err(ValidationError::InvalidValue(
301            "height must be positive".to_string(),
302        ));
303    }
304
305    Ok(())
306}
307
308/// Validate a TranscriptionProduct
309pub fn validate_transcription_product(trans: &TranscriptionProduct) -> ValidationResult<()> {
310    if trans.text.is_empty() {
311        return Err(ValidationError::MissingField("text".to_string()));
312    }
313
314    if trans.language.is_empty() {
315        return Err(ValidationError::MissingField("language".to_string()));
316    }
317
318    if trans.confidence < 0.0 || trans.confidence > 1.0 {
319        return Err(ValidationError::InvalidConfidence(trans.confidence));
320    }
321
322    if trans.duration_seconds < 0.0 {
323        return Err(ValidationError::InvalidValue(
324            "duration_seconds must be non-negative".to_string(),
325        ));
326    }
327
328    Ok(())
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::common::v1::Timestamp;
335    use crate::product::v1::product::Content;
336
337    fn valid_detection_product() -> Product {
338        Product {
339            product_id: "det-001".to_string(),
340            product_type: ProductType::Detection as i32,
341            source_platform: "Alpha-3".to_string(),
342            timestamp: Some(Timestamp {
343                seconds: 1702000000,
344                nanos: 0,
345            }),
346            confidence: 0.92,
347            model_source: None,
348            track_id: String::new(),
349            position: None,
350            content: Some(Content::Detection(DetectionProduct {
351                label: "person".to_string(),
352                confidence: 0.92,
353                bbox: vec![100, 200, 50, 100],
354                frame_size: vec![1920, 1080],
355                frame_number: 0,
356                detection_index: 0,
357            })),
358            attributes_json: String::new(),
359        }
360    }
361
362    #[test]
363    fn test_valid_detection_product() {
364        let product = valid_detection_product();
365        assert!(validate_product(&product).is_ok());
366    }
367
368    #[test]
369    fn test_missing_product_id() {
370        let mut product = valid_detection_product();
371        product.product_id = String::new();
372        let err = validate_product(&product).unwrap_err();
373        assert!(matches!(err, ValidationError::MissingField(f) if f == "product_id"));
374    }
375
376    #[test]
377    fn test_unspecified_product_type() {
378        let mut product = valid_detection_product();
379        product.product_type = ProductType::Unspecified as i32;
380        let err = validate_product(&product).unwrap_err();
381        assert!(matches!(err, ValidationError::InvalidValue(_)));
382    }
383
384    #[test]
385    fn test_missing_source_platform() {
386        let mut product = valid_detection_product();
387        product.source_platform = String::new();
388        let err = validate_product(&product).unwrap_err();
389        assert!(matches!(err, ValidationError::MissingField(f) if f == "source_platform"));
390    }
391
392    #[test]
393    fn test_invalid_confidence() {
394        let mut product = valid_detection_product();
395        product.confidence = 1.5;
396        let err = validate_product(&product).unwrap_err();
397        assert!(matches!(err, ValidationError::InvalidConfidence(_)));
398    }
399
400    #[test]
401    fn test_missing_content() {
402        let mut product = valid_detection_product();
403        product.content = None;
404        let err = validate_product(&product).unwrap_err();
405        assert!(matches!(err, ValidationError::MissingField(f) if f == "content"));
406    }
407
408    #[test]
409    fn test_invalid_bbox_length() {
410        let mut product = valid_detection_product();
411        product.content = Some(Content::Detection(DetectionProduct {
412            label: "person".to_string(),
413            confidence: 0.92,
414            bbox: vec![100, 200], // Should have 4 elements
415            frame_size: vec![1920, 1080],
416            frame_number: 0,
417            detection_index: 0,
418        }));
419        let err = validate_product(&product).unwrap_err();
420        assert!(matches!(err, ValidationError::InvalidValue(_)));
421    }
422
423    #[test]
424    fn test_valid_classification_product() {
425        let product = Product {
426            product_id: "cls-001".to_string(),
427            product_type: ProductType::Classification as i32,
428            source_platform: "Alpha-3".to_string(),
429            timestamp: Some(Timestamp {
430                seconds: 1702000000,
431                nanos: 0,
432            }),
433            confidence: 0.95,
434            model_source: None,
435            track_id: String::new(),
436            position: None,
437            content: Some(Content::Classification(ClassificationProduct {
438                label: "vehicle".to_string(),
439                confidence: 0.95,
440                top_k: vec![],
441                taxonomy: "coco".to_string(),
442            })),
443            attributes_json: String::new(),
444        };
445        assert!(validate_product(&product).is_ok());
446    }
447
448    #[test]
449    fn test_valid_embedding_product() {
450        let product = Product {
451            product_id: "emb-001".to_string(),
452            product_type: ProductType::Embedding as i32,
453            source_platform: "Alpha-3".to_string(),
454            timestamp: Some(Timestamp {
455                seconds: 1702000000,
456                nanos: 0,
457            }),
458            confidence: 1.0,
459            model_source: None,
460            track_id: String::new(),
461            position: None,
462            content: Some(Content::Embedding(EmbeddingProduct {
463                vector: vec![0.1, 0.2, 0.3, 0.4],
464                dimensions: 4,
465                embedding_model: "test-model".to_string(),
466                source_hash: String::new(),
467                normalized: false,
468            })),
469            attributes_json: String::new(),
470        };
471        assert!(validate_product(&product).is_ok());
472    }
473
474    #[test]
475    fn test_embedding_dimension_mismatch() {
476        let product = Product {
477            product_id: "emb-001".to_string(),
478            product_type: ProductType::Embedding as i32,
479            source_platform: "Alpha-3".to_string(),
480            timestamp: Some(Timestamp {
481                seconds: 1702000000,
482                nanos: 0,
483            }),
484            confidence: 1.0,
485            model_source: None,
486            track_id: String::new(),
487            position: None,
488            content: Some(Content::Embedding(EmbeddingProduct {
489                vector: vec![0.1, 0.2, 0.3, 0.4],
490                dimensions: 8, // Mismatch with vector length
491                embedding_model: "test-model".to_string(),
492                source_hash: String::new(),
493                normalized: false,
494            })),
495            attributes_json: String::new(),
496        };
497        let err = validate_product(&product).unwrap_err();
498        assert!(matches!(err, ValidationError::ConstraintViolation(_)));
499    }
500}