1use crate::kernel::{MAX_BPS, MAX_RISK_PENALTY_MULTIPLIER_BPS};
7
8#[derive(Clone, Debug)]
22pub struct EngineConfig {
23 pub latency_penalty_microunits_per_ms: u64,
25 pub hard_risk_limit_bps: u16,
27 pub minimum_confidence_bps: u16,
29 pub risk_penalty_multiplier_bps: u16,
31 pub default_exposure_cap_microcents: i64,
33 pub wal_sync_on_append: bool,
35 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#[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 #[must_use]
71 pub fn new() -> Self {
72 Self::default()
73 }
74
75 #[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 #[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 #[must_use]
91 pub fn minimum_confidence(mut self, bps: u16) -> Self {
92 self.minimum_confidence_bps = bps;
93 self
94 }
95
96 #[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 #[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 #[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 #[must_use]
119 pub fn max_catalog_size(mut self, size: usize) -> Self {
120 self.max_catalog_size = size;
121 self
122 }
123
124 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 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}