Skip to main content

calybris_core/
config.rs

1//! Runtime configuration for policy tuning and budget limits.
2//!
3//! [`crate::config::EngineConfig`] centralizes knobs that operators adjust without recompiling:
4//! latency penalty, exposure caps, WAL durability, and policy bounds.
5
6use crate::kernel::{MAX_BPS, MAX_RISK_PENALTY_MULTIPLIER_BPS};
7
8/// Runtime configuration for the decision engine.
9///
10/// All fields have safe defaults via [`Default`]. Use the builder methods
11/// to override only what you need:
12///
13/// ```
14/// use calybris_core::config::EngineConfig;
15///
16/// let config = EngineConfig::new()
17///     .latency_penalty(5)
18///     .default_exposure_cap(500_000_000)
19///     .wal_sync_on_append(true);
20/// ```
21#[derive(Clone, Debug)]
22pub struct EngineConfig {
23    /// Latency penalty in microunits per millisecond of p95 latency.
24    pub latency_penalty_microunits_per_ms: u64,
25    /// Hard risk limit in basis points (requests at or above this are rejected).
26    pub hard_risk_limit_bps: u16,
27    /// Minimum confidence in basis points (requests below this are rejected).
28    pub minimum_confidence_bps: u16,
29    /// Risk penalty multiplier in basis points.
30    pub risk_penalty_multiplier_bps: u16,
31    /// Default per-tenant exposure cap in microcents (0 = unlimited).
32    pub default_exposure_cap_microcents: i64,
33    /// Whether WAL should fsync after every append (durability vs throughput).
34    pub wal_sync_on_append: bool,
35    /// Maximum models in a single catalog (sanity bound).
36    pub max_catalog_size: usize,
37}
38
39impl Default for EngineConfig {
40    fn default() -> Self {
41        Self {
42            latency_penalty_microunits_per_ms: 2,
43            hard_risk_limit_bps: 9_600,
44            minimum_confidence_bps: 5_500,
45            risk_penalty_multiplier_bps: 3_500,
46            default_exposure_cap_microcents: 0,
47            wal_sync_on_append: false,
48            max_catalog_size: 1_024,
49        }
50    }
51}
52
53/// Validation errors for [`EngineConfig`].
54#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
55pub enum ConfigError {
56    #[error("{field} = {value} exceeds max {max}")]
57    OutOfRange {
58        field: &'static str,
59        value: u16,
60        max: u16,
61    },
62    #[error("max_catalog_size must be > 0")]
63    ZeroCatalogSize,
64    #[error("default_exposure_cap_microcents must be >= 0")]
65    NegativeExposureCap,
66}
67
68impl EngineConfig {
69    /// Create a config with safe defaults.
70    #[must_use]
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    /// Set latency penalty (microunits per ms).
76    #[must_use]
77    pub fn latency_penalty(mut self, microunits_per_ms: u64) -> Self {
78        self.latency_penalty_microunits_per_ms = microunits_per_ms;
79        self
80    }
81
82    /// Set hard risk limit (basis points, max 10_000).
83    #[must_use]
84    pub fn hard_risk_limit(mut self, bps: u16) -> Self {
85        self.hard_risk_limit_bps = bps;
86        self
87    }
88
89    /// Set minimum confidence (basis points, max 10_000).
90    #[must_use]
91    pub fn minimum_confidence(mut self, bps: u16) -> Self {
92        self.minimum_confidence_bps = bps;
93        self
94    }
95
96    /// Set risk penalty multiplier (basis points, max 50_000).
97    #[must_use]
98    pub fn risk_penalty_multiplier(mut self, bps: u16) -> Self {
99        self.risk_penalty_multiplier_bps = bps;
100        self
101    }
102
103    /// Set default per-tenant exposure cap (0 = unlimited).
104    #[must_use]
105    pub fn default_exposure_cap(mut self, microcents: i64) -> Self {
106        self.default_exposure_cap_microcents = microcents;
107        self
108    }
109
110    /// Enable fsync after every WAL append.
111    #[must_use]
112    pub fn wal_sync_on_append(mut self, sync: bool) -> Self {
113        self.wal_sync_on_append = sync;
114        self
115    }
116
117    /// Set maximum catalog size.
118    #[must_use]
119    pub fn max_catalog_size(mut self, size: usize) -> Self {
120        self.max_catalog_size = size;
121        self
122    }
123
124    /// Validate all fields.
125    pub fn validate(&self) -> Result<(), ConfigError> {
126        if self.hard_risk_limit_bps > MAX_BPS {
127            return Err(ConfigError::OutOfRange {
128                field: "hard_risk_limit_bps",
129                value: self.hard_risk_limit_bps,
130                max: MAX_BPS,
131            });
132        }
133        if self.minimum_confidence_bps > MAX_BPS {
134            return Err(ConfigError::OutOfRange {
135                field: "minimum_confidence_bps",
136                value: self.minimum_confidence_bps,
137                max: MAX_BPS,
138            });
139        }
140        if self.risk_penalty_multiplier_bps > MAX_RISK_PENALTY_MULTIPLIER_BPS {
141            return Err(ConfigError::OutOfRange {
142                field: "risk_penalty_multiplier_bps",
143                value: self.risk_penalty_multiplier_bps,
144                max: MAX_RISK_PENALTY_MULTIPLIER_BPS,
145            });
146        }
147        if self.max_catalog_size == 0 {
148            return Err(ConfigError::ZeroCatalogSize);
149        }
150        if self.default_exposure_cap_microcents < 0 {
151            return Err(ConfigError::NegativeExposureCap);
152        }
153        Ok(())
154    }
155
156    /// Initialize a tenant on a [`crate::budget::BudgetEngine`] with config-driven defaults.
157    ///
158    /// Applies `default_exposure_cap_microcents` if set (> 0).
159    pub fn ensure_tenant(
160        &self,
161        budget: &crate::budget::BudgetEngine,
162        tenant_id: &str,
163        initial_microcents: i64,
164    ) {
165        budget.ensure_tenant(tenant_id, initial_microcents);
166        if self.default_exposure_cap_microcents > 0 {
167            budget.set_max_reserved_microcents(tenant_id, self.default_exposure_cap_microcents);
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn default_config_validates() {
178        EngineConfig::new().validate().unwrap();
179    }
180
181    #[test]
182    fn builder_chain_works() {
183        let config = EngineConfig::new()
184            .latency_penalty(10)
185            .hard_risk_limit(9_000)
186            .minimum_confidence(6_000)
187            .risk_penalty_multiplier(5_000)
188            .default_exposure_cap(1_000_000)
189            .wal_sync_on_append(true)
190            .max_catalog_size(512);
191        config.validate().unwrap();
192        assert_eq!(config.latency_penalty_microunits_per_ms, 10);
193        assert_eq!(config.hard_risk_limit_bps, 9_000);
194        assert!(config.wal_sync_on_append);
195    }
196
197    #[test]
198    fn ensure_tenant_applies_exposure_cap() {
199        let config = EngineConfig::new().default_exposure_cap(500_000);
200        let budget = crate::budget::BudgetEngine::new();
201        config.ensure_tenant(&budget, "desk", 1_000_000);
202        assert_eq!(budget.remaining_microcents("desk"), Some(1_000_000));
203        let (_, id) = budget.try_reserve("desk", 500_001);
204        assert!(id.is_none());
205    }
206
207    #[test]
208    fn rejects_out_of_range_bps() {
209        let config = EngineConfig::new().hard_risk_limit(10_001);
210        assert!(matches!(
211            config.validate(),
212            Err(ConfigError::OutOfRange { .. })
213        ));
214    }
215
216    #[test]
217    fn rejects_zero_catalog() {
218        let config = EngineConfig::new().max_catalog_size(0);
219        assert!(matches!(
220            config.validate(),
221            Err(ConfigError::ZeroCatalogSize)
222        ));
223    }
224
225    #[test]
226    fn rejects_negative_exposure_cap() {
227        let config = EngineConfig::new().default_exposure_cap(-1);
228        assert!(matches!(
229            config.validate(),
230            Err(ConfigError::NegativeExposureCap)
231        ));
232    }
233
234    use proptest::prelude::*;
235
236    proptest! {
237        #[test]
238        fn arbitrary_valid_configs_always_validate(
239            latency in 0_u64..1_000_000,
240            risk in 0_u16..=10_000,
241            conf in 0_u16..=10_000,
242            penalty in 0_u16..=50_000,
243            cap in 0_i64..i64::MAX,
244            catalog in 1_usize..10_000,
245        ) {
246            let config = EngineConfig::new()
247                .latency_penalty(latency)
248                .hard_risk_limit(risk)
249                .minimum_confidence(conf)
250                .risk_penalty_multiplier(penalty)
251                .default_exposure_cap(cap)
252                .max_catalog_size(catalog);
253            prop_assert!(config.validate().is_ok());
254        }
255
256        #[test]
257        fn config_roundtrips_through_builder(
258            latency in any::<u64>(),
259            risk in any::<u16>(),
260        ) {
261            let config = EngineConfig::new()
262                .latency_penalty(latency)
263                .hard_risk_limit(risk);
264            prop_assert_eq!(config.latency_penalty_microunits_per_ms, latency);
265            prop_assert_eq!(config.hard_risk_limit_bps, risk);
266        }
267    }
268}