bothan_lib/registry/
validate.rs

1//! Signal validation logic for the registry module.
2//!
3//! This module provides functions and error types for validating signals defined in the registry.
4//! Validation includes checks for cyclic dependencies, missing dependencies, and proper configuration
5//! of weighted median processors.
6//!
7//! Signal validation ensures that:
8//!
9//! - There are no cyclic dependencies among signals.
10//! - All signal dependencies exist and are properly defined in the registry.
11//! - Weighted median processors have correctly specified source weights.
12
13use std::collections::HashMap;
14
15use thiserror::Error;
16
17use crate::registry::Registry;
18use crate::registry::processor::Processor;
19use crate::registry::processor::weighted_median::WeightedMedianProcessor;
20use crate::registry::source::SourceQuery;
21
22/// Errors returned during the signal validation process.
23///
24/// This enum defines detailed errors for common signal validation issues such as:
25///
26/// - Dependency cycles.
27/// - Non-existent dependencies.
28/// - Improper configuration of weighted median processors.
29///
30/// These errors provide clear messages that can be displayed to users or logged.
31#[derive(Clone, Debug, Error, PartialEq)]
32pub enum ValidationError {
33    /// Indicates that a cyclic dependency has been detected among signals.
34    #[error("Signal {0} contains a cycle")]
35    CycleDetected(String),
36
37    /// Indicates a dependency on a signal that does not exist in the registry.
38    #[error("Signal {0} contains an invalid dependency")]
39    InvalidDependency(String),
40
41    /// Indicates incorrect weighted median processor configuration, specifying missing weights.
42    #[error("Signal {0} contains an invalid weighted median processor: {1}")]
43    InvalidWeightedMedianProcessor(String, String),
44}
45
46/// Tracks visitation state of signals during validation.
47///
48/// This enum is used internally to detect cycles by marking signals as either currently
49/// being validated (`InProgress`) or already validated (`Complete`).
50pub enum VisitState {
51    /// Currently validating this signal; helps detect cycles.
52    InProgress,
53
54    /// Signal validation has been completed without errors.
55    Complete,
56}
57
58pub(crate) fn validate_signal(
59    signal_id: &str,
60    visited: &mut HashMap<String, VisitState>,
61    registry: &Registry,
62) -> Result<(), ValidationError> {
63    match visited.get(signal_id) {
64        Some(VisitState::InProgress) => {
65            return Err(ValidationError::CycleDetected(signal_id.to_string()));
66        }
67        Some(VisitState::Complete) => {
68            return Ok(());
69        }
70        None => {
71            visited.insert(signal_id.to_string(), VisitState::InProgress);
72        }
73    }
74
75    let signal = registry
76        .get(signal_id)
77        .ok_or(ValidationError::InvalidDependency(signal_id.to_string()))?;
78
79    if let Processor::WeightedMedian(weighted_median) = &signal.processor {
80        validate_weighted_median_weights(weighted_median, signal_id, &signal.source_queries)?;
81    }
82
83    for source_query in signal.source_queries.iter() {
84        for route in source_query.routes.iter() {
85            if !registry.contains(&route.signal_id) {
86                return Err(ValidationError::InvalidDependency(signal_id.to_string()));
87            }
88            validate_signal(&route.signal_id, visited, registry)?;
89        }
90    }
91
92    visited.insert(signal_id.to_string(), VisitState::Complete);
93
94    Ok(())
95}
96
97fn validate_weighted_median_weights(
98    weighted_median: &WeightedMedianProcessor,
99    signal_id: &str,
100    source_queries: &[SourceQuery],
101) -> Result<(), ValidationError> {
102    source_queries.iter().try_for_each(|source_query| {
103        if !weighted_median
104            .source_weights
105            .contains_key(&source_query.source_id)
106        {
107            Err(ValidationError::InvalidWeightedMedianProcessor(
108                signal_id.to_string(),
109                source_query.source_id.to_string(),
110            ))
111        } else {
112            Ok(())
113        }
114    })
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::registry::tests::{complete_circular_dependency_mock_registry, valid_mock_registry};
121
122    #[test]
123    fn test_validate_signal() {
124        let registry = valid_mock_registry();
125
126        let res = validate_signal("CS:BTC-USD", &mut HashMap::new(), &registry);
127        assert!(res.is_ok());
128    }
129
130    #[test]
131    fn test_validate_signal_with_invalid_signal() {
132        let registry = valid_mock_registry();
133
134        let res = validate_signal("CS:DNE-USD", &mut HashMap::new(), &registry);
135        assert_eq!(
136            res,
137            Err(ValidationError::InvalidDependency("CS:DNE-USD".to_string()))
138        );
139    }
140
141    #[test]
142    fn test_validate_signal_with_circular_dependency() {
143        let registry = complete_circular_dependency_mock_registry();
144        let mut visited = HashMap::new();
145
146        let res = validate_signal("CS:USDT-USD", &mut visited, &registry);
147        assert_eq!(
148            res,
149            Err(ValidationError::CycleDetected("CS:USDT-USD".to_string()))
150        );
151    }
152}