feat(ensemble): add 7-gate conviction system with evaluator and 15 tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
566
ml/src/ensemble/conviction_gates.rs
Normal file
566
ml/src/ensemble/conviction_gates.rs
Normal file
@@ -0,0 +1,566 @@
|
||||
//! 7-Gate Conviction System
|
||||
//!
|
||||
//! Evaluates ensemble predictions through a series of quality gates before
|
||||
//! allowing trade execution. Gates are evaluated in order; any failure
|
||||
//! results in HOLD.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Trading session windows (Eastern Time)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TradingSession {
|
||||
PreMarket,
|
||||
Regular,
|
||||
AfterHours,
|
||||
}
|
||||
|
||||
/// Configuration for the 7-gate conviction system
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConvictionGateConfig {
|
||||
pub model_health_threshold: f64,
|
||||
pub allowed_sessions: Vec<TradingSession>,
|
||||
pub min_confidence: f64,
|
||||
pub max_disagreement: f64,
|
||||
pub min_quorum: f64,
|
||||
pub regime_tightening_factor: f64,
|
||||
pub high_vol_threshold: f64,
|
||||
pub conviction_scaling_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for ConvictionGateConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model_health_threshold: 0.70,
|
||||
allowed_sessions: vec![TradingSession::Regular],
|
||||
min_confidence: 0.60,
|
||||
max_disagreement: 0.40,
|
||||
min_quorum: 0.60,
|
||||
regime_tightening_factor: 0.80,
|
||||
high_vol_threshold: 0.03,
|
||||
conviction_scaling_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which gate rejected the decision (if any)
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum GateRejection {
|
||||
ModelHealth {
|
||||
healthy_ratio: f64,
|
||||
threshold: f64,
|
||||
},
|
||||
TimeOfDay {
|
||||
current_session: TradingSession,
|
||||
},
|
||||
Confidence {
|
||||
confidence: f64,
|
||||
threshold: f64,
|
||||
},
|
||||
Agreement {
|
||||
disagreement: f64,
|
||||
threshold: f64,
|
||||
},
|
||||
Quorum {
|
||||
quorum_ratio: f64,
|
||||
threshold: f64,
|
||||
},
|
||||
Regime {
|
||||
adjusted_confidence: f64,
|
||||
adjusted_threshold: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Result of passing all conviction gates
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GatePassResult {
|
||||
pub conviction_score: f64,
|
||||
pub gate_details: Vec<GateEvaluation>,
|
||||
}
|
||||
|
||||
/// Individual gate evaluation result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GateEvaluation {
|
||||
pub gate_name: String,
|
||||
pub value: f64,
|
||||
pub threshold: f64,
|
||||
pub passed: bool,
|
||||
}
|
||||
|
||||
/// Outcome of evaluating all gates
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConvictionGateOutcome {
|
||||
Passed(GatePassResult),
|
||||
Rejected(GateRejection),
|
||||
}
|
||||
|
||||
/// Input data for gate evaluation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GateInput {
|
||||
pub confidence: f64,
|
||||
pub disagreement_rate: f64,
|
||||
pub quorum_ratio: f64,
|
||||
pub healthy_models: usize,
|
||||
pub total_models: usize,
|
||||
pub current_session: TradingSession,
|
||||
pub regime_volatility: f64,
|
||||
}
|
||||
|
||||
/// Evaluates ensemble decisions through 7 conviction gates
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConvictionGateEvaluator {
|
||||
config: ConvictionGateConfig,
|
||||
}
|
||||
|
||||
impl ConvictionGateEvaluator {
|
||||
pub fn new(config: ConvictionGateConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &ConvictionGateConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
pub fn config_mut(&mut self) -> &mut ConvictionGateConfig {
|
||||
&mut self.config
|
||||
}
|
||||
|
||||
/// Evaluate all 7 gates in order. Returns on first rejection.
|
||||
pub fn evaluate(&self, input: &GateInput) -> ConvictionGateOutcome {
|
||||
let mut gate_details = Vec::with_capacity(7);
|
||||
|
||||
// Gate 1: Model Health - healthy_ratio >= threshold
|
||||
let healthy_ratio = if input.total_models == 0 {
|
||||
0.0
|
||||
} else {
|
||||
input.healthy_models as f64 / input.total_models as f64
|
||||
};
|
||||
|
||||
let gate1_passed = healthy_ratio >= self.config.model_health_threshold;
|
||||
gate_details.push(GateEvaluation {
|
||||
gate_name: "model_health".to_string(),
|
||||
value: healthy_ratio,
|
||||
threshold: self.config.model_health_threshold,
|
||||
passed: gate1_passed,
|
||||
});
|
||||
if !gate1_passed {
|
||||
return ConvictionGateOutcome::Rejected(GateRejection::ModelHealth {
|
||||
healthy_ratio,
|
||||
threshold: self.config.model_health_threshold,
|
||||
});
|
||||
}
|
||||
|
||||
// Gate 2: Time-of-Day - current_session in allowed_sessions
|
||||
let gate2_passed = self.config.allowed_sessions.contains(&input.current_session);
|
||||
gate_details.push(GateEvaluation {
|
||||
gate_name: "time_of_day".to_string(),
|
||||
value: if gate2_passed { 1.0 } else { 0.0 },
|
||||
threshold: 1.0,
|
||||
passed: gate2_passed,
|
||||
});
|
||||
if !gate2_passed {
|
||||
return ConvictionGateOutcome::Rejected(GateRejection::TimeOfDay {
|
||||
current_session: input.current_session,
|
||||
});
|
||||
}
|
||||
|
||||
// Gate 6 prep: If regime_volatility > high_vol_threshold, tighten thresholds
|
||||
let high_vol = input.regime_volatility > self.config.high_vol_threshold;
|
||||
let (adj_min_confidence, adj_max_disagreement, adj_min_quorum) = if high_vol {
|
||||
let factor = self.config.regime_tightening_factor;
|
||||
let adj_conf = (self.config.min_confidence / factor).min(0.95);
|
||||
let adj_disagree = (self.config.max_disagreement * factor).max(0.05);
|
||||
let adj_quorum = (self.config.min_quorum / factor).min(0.95);
|
||||
(adj_conf, adj_disagree, adj_quorum)
|
||||
} else {
|
||||
(
|
||||
self.config.min_confidence,
|
||||
self.config.max_disagreement,
|
||||
self.config.min_quorum,
|
||||
)
|
||||
};
|
||||
|
||||
// Gate 3: Confidence >= adj_min_confidence
|
||||
let gate3_passed = input.confidence >= adj_min_confidence;
|
||||
gate_details.push(GateEvaluation {
|
||||
gate_name: "confidence".to_string(),
|
||||
value: input.confidence,
|
||||
threshold: adj_min_confidence,
|
||||
passed: gate3_passed,
|
||||
});
|
||||
if !gate3_passed {
|
||||
return ConvictionGateOutcome::Rejected(GateRejection::Confidence {
|
||||
confidence: input.confidence,
|
||||
threshold: adj_min_confidence,
|
||||
});
|
||||
}
|
||||
|
||||
// Gate 4: Disagreement <= adj_max_disagreement
|
||||
let gate4_passed = input.disagreement_rate <= adj_max_disagreement;
|
||||
gate_details.push(GateEvaluation {
|
||||
gate_name: "agreement".to_string(),
|
||||
value: input.disagreement_rate,
|
||||
threshold: adj_max_disagreement,
|
||||
passed: gate4_passed,
|
||||
});
|
||||
if !gate4_passed {
|
||||
return ConvictionGateOutcome::Rejected(GateRejection::Agreement {
|
||||
disagreement: input.disagreement_rate,
|
||||
threshold: adj_max_disagreement,
|
||||
});
|
||||
}
|
||||
|
||||
// Gate 5: Quorum >= adj_min_quorum
|
||||
let gate5_passed = input.quorum_ratio >= adj_min_quorum;
|
||||
gate_details.push(GateEvaluation {
|
||||
gate_name: "quorum".to_string(),
|
||||
value: input.quorum_ratio,
|
||||
threshold: adj_min_quorum,
|
||||
passed: gate5_passed,
|
||||
});
|
||||
if !gate5_passed {
|
||||
return ConvictionGateOutcome::Rejected(GateRejection::Quorum {
|
||||
quorum_ratio: input.quorum_ratio,
|
||||
threshold: adj_min_quorum,
|
||||
});
|
||||
}
|
||||
|
||||
// Gate 6: Regime (already applied via threshold adjustments)
|
||||
// Record the regime gate evaluation
|
||||
let regime_value = if high_vol {
|
||||
input.confidence
|
||||
} else {
|
||||
input.confidence
|
||||
};
|
||||
let regime_threshold = adj_min_confidence;
|
||||
gate_details.push(GateEvaluation {
|
||||
gate_name: "regime".to_string(),
|
||||
value: regime_value,
|
||||
threshold: regime_threshold,
|
||||
passed: true, // Already enforced via adjusted thresholds in gates 3-5
|
||||
});
|
||||
|
||||
// Gate 7: Conviction Sizing
|
||||
let conviction_score = if self.config.conviction_scaling_enabled {
|
||||
input.confidence
|
||||
* (1.0 - input.disagreement_rate)
|
||||
* input.quorum_ratio
|
||||
* healthy_ratio
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
gate_details.push(GateEvaluation {
|
||||
gate_name: "conviction_sizing".to_string(),
|
||||
value: conviction_score,
|
||||
threshold: 0.0, // No minimum threshold for conviction sizing
|
||||
passed: true,
|
||||
});
|
||||
|
||||
ConvictionGateOutcome::Passed(GatePassResult {
|
||||
conviction_score,
|
||||
gate_details,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn default_passing_input() -> GateInput {
|
||||
GateInput {
|
||||
confidence: 0.80,
|
||||
disagreement_rate: 0.10,
|
||||
quorum_ratio: 0.80,
|
||||
healthy_models: 8,
|
||||
total_models: 10,
|
||||
current_session: TradingSession::Regular,
|
||||
regime_volatility: 0.01,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_config_values() {
|
||||
let config = ConvictionGateConfig::default();
|
||||
assert!((config.model_health_threshold - 0.70).abs() < f64::EPSILON);
|
||||
assert_eq!(config.allowed_sessions, vec![TradingSession::Regular]);
|
||||
assert!((config.min_confidence - 0.60).abs() < f64::EPSILON);
|
||||
assert!((config.max_disagreement - 0.40).abs() < f64::EPSILON);
|
||||
assert!((config.min_quorum - 0.60).abs() < f64::EPSILON);
|
||||
assert!((config.regime_tightening_factor - 0.80).abs() < f64::EPSILON);
|
||||
assert!((config.high_vol_threshold - 0.03).abs() < f64::EPSILON);
|
||||
assert!(config.conviction_scaling_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gate_rejection_variants() {
|
||||
let r1 = GateRejection::ModelHealth {
|
||||
healthy_ratio: 0.5,
|
||||
threshold: 0.7,
|
||||
};
|
||||
let r2 = GateRejection::ModelHealth {
|
||||
healthy_ratio: 0.5,
|
||||
threshold: 0.7,
|
||||
};
|
||||
assert_eq!(r1, r2);
|
||||
|
||||
let r3 = GateRejection::TimeOfDay {
|
||||
current_session: TradingSession::PreMarket,
|
||||
};
|
||||
assert_ne!(r1, r3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gate1_model_health_passes() {
|
||||
let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default());
|
||||
let input = GateInput {
|
||||
healthy_models: 8,
|
||||
total_models: 10,
|
||||
..default_passing_input()
|
||||
};
|
||||
let outcome = evaluator.evaluate(&input);
|
||||
assert!(matches!(outcome, ConvictionGateOutcome::Passed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gate1_model_health_rejects() {
|
||||
let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default());
|
||||
let input = GateInput {
|
||||
healthy_models: 5,
|
||||
total_models: 10,
|
||||
..default_passing_input()
|
||||
};
|
||||
let outcome = evaluator.evaluate(&input);
|
||||
if let ConvictionGateOutcome::Rejected(GateRejection::ModelHealth {
|
||||
healthy_ratio,
|
||||
threshold,
|
||||
}) = outcome
|
||||
{
|
||||
assert!((healthy_ratio - 0.5).abs() < f64::EPSILON);
|
||||
assert!((threshold - 0.70).abs() < f64::EPSILON);
|
||||
} else {
|
||||
panic!("Expected ModelHealth rejection, got {:?}", outcome);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gate2_time_of_day_rejects_premarket() {
|
||||
let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default());
|
||||
let input = GateInput {
|
||||
current_session: TradingSession::PreMarket,
|
||||
..default_passing_input()
|
||||
};
|
||||
let outcome = evaluator.evaluate(&input);
|
||||
if let ConvictionGateOutcome::Rejected(GateRejection::TimeOfDay { current_session }) =
|
||||
outcome
|
||||
{
|
||||
assert_eq!(current_session, TradingSession::PreMarket);
|
||||
} else {
|
||||
panic!("Expected TimeOfDay rejection, got {:?}", outcome);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gate3_confidence_rejects_low() {
|
||||
let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default());
|
||||
let input = GateInput {
|
||||
confidence: 0.45,
|
||||
..default_passing_input()
|
||||
};
|
||||
let outcome = evaluator.evaluate(&input);
|
||||
if let ConvictionGateOutcome::Rejected(GateRejection::Confidence {
|
||||
confidence,
|
||||
threshold,
|
||||
}) = outcome
|
||||
{
|
||||
assert!((confidence - 0.45).abs() < f64::EPSILON);
|
||||
assert!((threshold - 0.60).abs() < f64::EPSILON);
|
||||
} else {
|
||||
panic!("Expected Confidence rejection, got {:?}", outcome);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gate4_agreement_rejects_high_disagreement() {
|
||||
let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default());
|
||||
let input = GateInput {
|
||||
disagreement_rate: 0.55,
|
||||
..default_passing_input()
|
||||
};
|
||||
let outcome = evaluator.evaluate(&input);
|
||||
if let ConvictionGateOutcome::Rejected(GateRejection::Agreement {
|
||||
disagreement,
|
||||
threshold,
|
||||
}) = outcome
|
||||
{
|
||||
assert!((disagreement - 0.55).abs() < f64::EPSILON);
|
||||
assert!((threshold - 0.40).abs() < f64::EPSILON);
|
||||
} else {
|
||||
panic!("Expected Agreement rejection, got {:?}", outcome);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gate5_quorum_rejects_low() {
|
||||
let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default());
|
||||
let input = GateInput {
|
||||
quorum_ratio: 0.40,
|
||||
..default_passing_input()
|
||||
};
|
||||
let outcome = evaluator.evaluate(&input);
|
||||
if let ConvictionGateOutcome::Rejected(GateRejection::Quorum {
|
||||
quorum_ratio,
|
||||
threshold,
|
||||
}) = outcome
|
||||
{
|
||||
assert!((quorum_ratio - 0.40).abs() < f64::EPSILON);
|
||||
assert!((threshold - 0.60).abs() < f64::EPSILON);
|
||||
} else {
|
||||
panic!("Expected Quorum rejection, got {:?}", outcome);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conviction_score_calculation() {
|
||||
let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default());
|
||||
let input = GateInput {
|
||||
confidence: 0.80,
|
||||
disagreement_rate: 0.10,
|
||||
quorum_ratio: 0.80,
|
||||
healthy_models: 9,
|
||||
total_models: 10,
|
||||
current_session: TradingSession::Regular,
|
||||
regime_volatility: 0.01,
|
||||
};
|
||||
let outcome = evaluator.evaluate(&input);
|
||||
if let ConvictionGateOutcome::Passed(result) = outcome {
|
||||
// score = 0.80 * (1.0 - 0.10) * 0.80 * (9/10) = 0.80 * 0.90 * 0.80 * 0.90 = 0.5184
|
||||
let expected = 0.80 * 0.90 * 0.80 * 0.90;
|
||||
assert!(
|
||||
(result.conviction_score - expected).abs() < 1e-10,
|
||||
"Expected {}, got {}",
|
||||
expected,
|
||||
result.conviction_score
|
||||
);
|
||||
} else {
|
||||
panic!("Expected Passed outcome, got {:?}", outcome);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gate6_regime_tightens_in_high_vol() {
|
||||
let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default());
|
||||
// With default config: min_confidence=0.60, tightening=0.80
|
||||
// High vol: adj_min_confidence = 0.60 / 0.80 = 0.75
|
||||
// 0.70 confidence < 0.75 threshold => rejected
|
||||
let input = GateInput {
|
||||
confidence: 0.70,
|
||||
regime_volatility: 0.05,
|
||||
..default_passing_input()
|
||||
};
|
||||
let outcome = evaluator.evaluate(&input);
|
||||
if let ConvictionGateOutcome::Rejected(GateRejection::Confidence {
|
||||
confidence,
|
||||
threshold,
|
||||
}) = outcome
|
||||
{
|
||||
assert!((confidence - 0.70).abs() < f64::EPSILON);
|
||||
assert!((threshold - 0.75).abs() < f64::EPSILON);
|
||||
} else {
|
||||
panic!(
|
||||
"Expected Confidence rejection due to regime tightening, got {:?}",
|
||||
outcome
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gate6_regime_no_effect_in_low_vol() {
|
||||
let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default());
|
||||
// Low vol: thresholds unchanged, min_confidence=0.60
|
||||
// 0.65 confidence >= 0.60 => passes
|
||||
let input = GateInput {
|
||||
confidence: 0.65,
|
||||
regime_volatility: 0.01,
|
||||
..default_passing_input()
|
||||
};
|
||||
let outcome = evaluator.evaluate(&input);
|
||||
assert!(
|
||||
matches!(outcome, ConvictionGateOutcome::Passed(_)),
|
||||
"Expected Passed in low vol, got {:?}",
|
||||
outcome
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_models_rejects() {
|
||||
let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default());
|
||||
let input = GateInput {
|
||||
healthy_models: 0,
|
||||
total_models: 0,
|
||||
..default_passing_input()
|
||||
};
|
||||
let outcome = evaluator.evaluate(&input);
|
||||
if let ConvictionGateOutcome::Rejected(GateRejection::ModelHealth {
|
||||
healthy_ratio,
|
||||
threshold,
|
||||
}) = outcome
|
||||
{
|
||||
assert!((healthy_ratio - 0.0).abs() < f64::EPSILON);
|
||||
assert!((threshold - 0.70).abs() < f64::EPSILON);
|
||||
} else {
|
||||
panic!("Expected ModelHealth rejection for 0/0 models, got {:?}", outcome);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conviction_scaling_disabled() {
|
||||
let mut config = ConvictionGateConfig::default();
|
||||
config.conviction_scaling_enabled = false;
|
||||
let evaluator = ConvictionGateEvaluator::new(config);
|
||||
let input = default_passing_input();
|
||||
let outcome = evaluator.evaluate(&input);
|
||||
if let ConvictionGateOutcome::Passed(result) = outcome {
|
||||
assert!(
|
||||
(result.conviction_score - 1.0).abs() < f64::EPSILON,
|
||||
"Expected 1.0 when scaling disabled, got {}",
|
||||
result.conviction_score
|
||||
);
|
||||
} else {
|
||||
panic!("Expected Passed outcome, got {:?}", outcome);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gate_details_count() {
|
||||
let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default());
|
||||
let input = default_passing_input();
|
||||
let outcome = evaluator.evaluate(&input);
|
||||
if let ConvictionGateOutcome::Passed(result) = outcome {
|
||||
assert_eq!(
|
||||
result.gate_details.len(),
|
||||
7,
|
||||
"Expected 7 gate evaluations, got {}",
|
||||
result.gate_details.len()
|
||||
);
|
||||
} else {
|
||||
panic!("Expected Passed outcome, got {:?}", outcome);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_after_hours_allowed_when_configured() {
|
||||
let mut config = ConvictionGateConfig::default();
|
||||
config.allowed_sessions.push(TradingSession::AfterHours);
|
||||
let evaluator = ConvictionGateEvaluator::new(config);
|
||||
let input = GateInput {
|
||||
current_session: TradingSession::AfterHours,
|
||||
..default_passing_input()
|
||||
};
|
||||
let outcome = evaluator.evaluate(&input);
|
||||
assert!(
|
||||
matches!(outcome, ConvictionGateOutcome::Passed(_)),
|
||||
"Expected Passed for AfterHours when configured, got {:?}",
|
||||
outcome
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ pub mod inference_adapter;
|
||||
pub mod inference_ensemble;
|
||||
pub mod signal;
|
||||
pub mod adapters;
|
||||
pub mod conviction_gates;
|
||||
|
||||
// Re-export key types that are used across ensemble modules
|
||||
pub use ab_testing::{
|
||||
@@ -48,6 +49,10 @@ pub use metrics::{
|
||||
};
|
||||
pub use training_integration::EnsembleTrainingIntegration;
|
||||
pub use inference_adapter::{EnsemblePrediction, FeatureVector, ModelInferenceAdapter, PredictionMeta};
|
||||
pub use conviction_gates::{
|
||||
ConvictionGateConfig, ConvictionGateOutcome, GateEvaluation, GatePassResult, GateRejection,
|
||||
TradingSession,
|
||||
};
|
||||
|
||||
/// Errors that can occur in ensemble operations
|
||||
#[derive(Error, Debug)]
|
||||
|
||||
Reference in New Issue
Block a user