Skip to main content

calybris_core/
builder.rs

1//! Builder ergonomics for [`crate::kernel::KernelInput`] and
2//! [`crate::kernel::PolicySnapshot`].
3//!
4//! Makes it hard to forget a required field — the compiler enforces it.
5//! Optional fields have safe defaults.
6
7use crate::kernel::*;
8
9/// Builder for [`KernelInput`] with safe defaults for optional fields.
10///
11/// ```
12/// use calybris_core::builder::InputBuilder;
13/// use calybris_core::kernel::ALL_PROVIDERS;
14///
15/// let input = InputBuilder::new(1, 1)
16///     .tokens(1000, 500)
17///     .business_value(100_000)
18///     .budget_limit(50_000_000)
19///     .risk(1000, 9000)
20///     .minimum_quality(5000)
21///     .build();
22///
23/// assert_eq!(input.request_sequence, 1);
24/// assert_eq!(input.allowed_provider_mask, ALL_PROVIDERS);
25/// ```
26pub struct InputBuilder {
27    input: KernelInput,
28}
29
30impl InputBuilder {
31    /// Start building an input. `sequence` and `model_id` are required.
32    #[must_use]
33    pub fn new(request_sequence: u64, requested_model_id: u32) -> Self {
34        Self {
35            input: KernelInput {
36                request_sequence,
37                requested_model_id,
38                input_tokens: 0,
39                output_tokens: 0,
40                business_value_microunits: 0,
41                budget_limit_microunits: u64::MAX,
42                risk_bps: 0,
43                confidence_bps: BASIS_POINTS as u16,
44                minimum_quality_bps: 0,
45                max_p95_latency_ms: 0,
46                required_capabilities: 0,
47                allowed_provider_mask: ALL_PROVIDERS,
48                required_region_mask: 0,
49            },
50        }
51    }
52
53    #[must_use]
54    pub fn tokens(mut self, input: u32, output: u32) -> Self {
55        self.input.input_tokens = input;
56        self.input.output_tokens = output;
57        self
58    }
59
60    #[must_use]
61    pub fn business_value(mut self, microunits: i64) -> Self {
62        self.input.business_value_microunits = microunits;
63        self
64    }
65
66    #[must_use]
67    pub fn budget_limit(mut self, microunits: u64) -> Self {
68        self.input.budget_limit_microunits = microunits;
69        self
70    }
71
72    /// Set risk and confidence in basis points.
73    #[must_use]
74    pub fn risk(mut self, risk_bps: u16, confidence_bps: u16) -> Self {
75        self.input.risk_bps = risk_bps;
76        self.input.confidence_bps = confidence_bps;
77        self
78    }
79
80    #[must_use]
81    pub fn minimum_quality(mut self, bps: u16) -> Self {
82        self.input.minimum_quality_bps = bps;
83        self
84    }
85
86    #[must_use]
87    pub fn max_latency(mut self, ms: u32) -> Self {
88        self.input.max_p95_latency_ms = ms;
89        self
90    }
91
92    #[must_use]
93    pub fn capabilities(mut self, mask: u64) -> Self {
94        self.input.required_capabilities = mask;
95        self
96    }
97
98    #[must_use]
99    pub fn providers(mut self, mask: u64) -> Self {
100        self.input.allowed_provider_mask = mask;
101        self
102    }
103
104    #[must_use]
105    pub fn regions(mut self, mask: u64) -> Self {
106        self.input.required_region_mask = mask;
107        self
108    }
109
110    /// Consume the builder and return a validated [`KernelInput`].
111    ///
112    /// # Panics
113    ///
114    /// Panics when [`KernelInput::validate`] fails. Prefer [`Self::try_build`]
115    /// at API boundaries that must surface validation errors to callers.
116    #[must_use]
117    pub fn build(self) -> KernelInput {
118        self.try_build()
119            .expect("KernelInput validation failed — use try_build() for fallible construction")
120    }
121
122    /// Consume the builder and return a validated [`KernelInput`], or an error.
123    pub fn try_build(self) -> Result<KernelInput, InputError> {
124        self.input.validate()?;
125        Ok(self.input)
126    }
127}
128
129/// Builder for [`KernelModel`] with safe defaults.
130///
131/// ```
132/// use calybris_core::builder::ModelBuilder;
133///
134/// let model = ModelBuilder::new(1, 0)
135///     .quality(9000)
136///     .latency(200)
137///     .cost(250, 1000)
138///     .build();
139///
140/// assert_eq!(model.model_id, 1);
141/// assert_eq!(model.enabled, 1);
142/// ```
143pub struct ModelBuilder {
144    model: KernelModel,
145}
146
147impl ModelBuilder {
148    /// Start building a model. `model_id` and `provider_id` are required.
149    #[must_use]
150    pub fn new(model_id: u32, provider_id: u16) -> Self {
151        Self {
152            model: KernelModel {
153                model_id,
154                provider_id,
155                quality_bps: 8_000,
156                risk_ceiling_bps: 9_500,
157                enabled: 1,
158                p95_latency_ms: 200,
159                capabilities: 0,
160                region_mask: ALL_REGIONS,
161                input_cost_microunits_per_million_tokens: 0,
162                output_cost_microunits_per_million_tokens: 0,
163            },
164        }
165    }
166
167    #[must_use]
168    pub fn quality(mut self, bps: u16) -> Self {
169        self.model.quality_bps = bps;
170        self
171    }
172
173    #[must_use]
174    pub fn risk_ceiling(mut self, bps: u16) -> Self {
175        self.model.risk_ceiling_bps = bps;
176        self
177    }
178
179    #[must_use]
180    pub fn enabled(mut self, yes: bool) -> Self {
181        self.model.enabled = u8::from(yes);
182        self
183    }
184
185    #[must_use]
186    pub fn latency(mut self, p95_ms: u32) -> Self {
187        self.model.p95_latency_ms = p95_ms;
188        self
189    }
190
191    #[must_use]
192    pub fn capabilities(mut self, mask: u64) -> Self {
193        self.model.capabilities = mask;
194        self
195    }
196
197    #[must_use]
198    pub fn regions(mut self, mask: u64) -> Self {
199        self.model.region_mask = mask;
200        self
201    }
202
203    /// Set input and output cost per million tokens (microunits).
204    #[must_use]
205    pub fn cost(mut self, input_per_m: u64, output_per_m: u64) -> Self {
206        self.model.input_cost_microunits_per_million_tokens = input_per_m;
207        self.model.output_cost_microunits_per_million_tokens = output_per_m;
208        self
209    }
210
211    /// Consume the builder and return a [`KernelModel`].
212    #[must_use]
213    pub fn build(self) -> KernelModel {
214        self.model
215    }
216}
217
218/// Errors from [`PolicyBuilder::build`].
219#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
220pub enum BuildError {
221    #[error("config error: {0}")]
222    Config(#[from] crate::config::ConfigError),
223    #[error("policy error: {0}")]
224    Policy(#[from] crate::kernel::PolicyError),
225    #[error("catalog too large: {len} models exceeds max_catalog_size {max}")]
226    CatalogTooLarge { len: usize, max: usize },
227}
228
229/// Build a [`PolicySnapshot`] from config + models with validation.
230///
231/// ```
232/// use calybris_core::builder::{PolicyBuilder, ModelBuilder};
233/// use calybris_core::config::EngineConfig;
234///
235/// let snapshot = PolicyBuilder::new(EngineConfig::new())
236///     .epochs(1, 1)
237///     .model(ModelBuilder::new(1, 0).quality(9000).cost(250, 1000).build())
238///     .model(ModelBuilder::new(2, 1).quality(7000).cost(25, 125).build())
239///     .build()
240///     .unwrap();
241///
242/// assert_eq!(snapshot.models().len(), 2);
243/// ```
244pub struct PolicyBuilder {
245    config: crate::config::EngineConfig,
246    policy_epoch: u64,
247    catalog_epoch: u64,
248    models: Vec<KernelModel>,
249}
250
251impl PolicyBuilder {
252    /// Start building a policy from an [`crate::config::EngineConfig`].
253    #[must_use]
254    pub fn new(config: crate::config::EngineConfig) -> Self {
255        Self {
256            config,
257            policy_epoch: 1,
258            catalog_epoch: 1,
259            models: Vec::new(),
260        }
261    }
262
263    /// Set policy and catalog epochs.
264    #[must_use]
265    pub fn epochs(mut self, policy: u64, catalog: u64) -> Self {
266        self.policy_epoch = policy;
267        self.catalog_epoch = catalog;
268        self
269    }
270
271    /// Add a model to the catalog.
272    #[must_use]
273    pub fn model(mut self, model: KernelModel) -> Self {
274        self.models.push(model);
275        self
276    }
277
278    /// Add multiple models.
279    #[must_use]
280    pub fn models(mut self, models: impl IntoIterator<Item = KernelModel>) -> Self {
281        self.models.extend(models);
282        self
283    }
284
285    /// Build and validate the snapshot.
286    ///
287    /// Validates config, enforces `max_catalog_size`, then delegates to
288    /// [`PolicySnapshot::try_new`] for policy-level validation.
289    pub fn build(self) -> Result<PolicySnapshot, BuildError> {
290        self.config.validate()?;
291        if self.models.len() > self.config.max_catalog_size {
292            return Err(BuildError::CatalogTooLarge {
293                len: self.models.len(),
294                max: self.config.max_catalog_size,
295            });
296        }
297        Ok(PolicySnapshot::try_new(
298            self.policy_epoch,
299            self.catalog_epoch,
300            self.config.hard_risk_limit_bps,
301            self.config.minimum_confidence_bps,
302            self.config.risk_penalty_multiplier_bps,
303            self.config.latency_penalty_microunits_per_ms,
304            self.models,
305        )?)
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn input_builder_defaults() {
315        let input = InputBuilder::new(1, 10).tokens(500, 200).build();
316        assert_eq!(input.request_sequence, 1);
317        assert_eq!(input.requested_model_id, 10);
318        assert_eq!(input.input_tokens, 500);
319        assert_eq!(input.allowed_provider_mask, ALL_PROVIDERS);
320        assert_eq!(input.required_region_mask, 0);
321        assert_eq!(input.budget_limit_microunits, u64::MAX);
322    }
323
324    #[test]
325    fn model_builder_defaults() {
326        let model = ModelBuilder::new(1, 0).cost(100, 400).build();
327        assert_eq!(model.model_id, 1);
328        assert_eq!(model.enabled, 1);
329        assert_eq!(model.quality_bps, 8_000);
330        assert_eq!(model.risk_ceiling_bps, 9_500);
331    }
332
333    #[test]
334    fn policy_builder_roundtrip() {
335        let config = crate::config::EngineConfig::new();
336        let snap = PolicyBuilder::new(config)
337            .epochs(7, 11)
338            .model(
339                ModelBuilder::new(1, 0)
340                    .quality(9000)
341                    .cost(250, 1000)
342                    .build(),
343            )
344            .model(ModelBuilder::new(2, 1).quality(7000).cost(25, 125).build())
345            .build()
346            .unwrap();
347        assert_eq!(snap.policy_epoch, 7);
348        assert_eq!(snap.models().len(), 2);
349    }
350
351    #[test]
352    fn builder_integrates_with_prescribe() {
353        let config = crate::config::EngineConfig::new();
354        let snap = PolicyBuilder::new(config)
355            .model(ModelBuilder::new(1, 0).quality(9000).cost(100, 400).build())
356            .build()
357            .unwrap();
358        let input = InputBuilder::new(1, 1)
359            .tokens(1000, 500)
360            .business_value(100_000)
361            .risk(1000, 9000)
362            .minimum_quality(5000)
363            .build();
364        let decision = snap.prescribe(input);
365        assert!(decision.is_executable());
366    }
367
368    #[test]
369    fn disabled_model_via_builder() {
370        let model = ModelBuilder::new(1, 0).enabled(false).build();
371        assert_eq!(model.enabled, 0);
372    }
373
374    #[test]
375    fn catalog_too_large_rejected() {
376        let config = crate::config::EngineConfig::new().max_catalog_size(1);
377        let result = PolicyBuilder::new(config)
378            .model(ModelBuilder::new(1, 0).cost(100, 400).build())
379            .model(ModelBuilder::new(2, 1).cost(10, 40).build())
380            .build();
381        assert!(matches!(result, Err(BuildError::CatalogTooLarge { .. })));
382    }
383
384    #[test]
385    fn invalid_config_rejected_at_build() {
386        let config = crate::config::EngineConfig::new().hard_risk_limit(10_001);
387        let result = PolicyBuilder::new(config)
388            .model(ModelBuilder::new(1, 0).cost(100, 400).build())
389            .build();
390        assert!(matches!(result, Err(BuildError::Config(_))));
391    }
392
393    use proptest::prelude::*;
394
395    proptest! {
396        #[test]
397        fn builder_prescribe_never_panics(
398            seq in any::<u64>(),
399            model_id in 1_u32..=2,
400            input_tokens in any::<u32>(),
401            output_tokens in any::<u32>(),
402            value in any::<i64>(),
403            risk in 0_u16..=MAX_BPS,
404            confidence in 0_u16..=MAX_BPS,
405        ) {
406            let config = crate::config::EngineConfig::new();
407            let snap = PolicyBuilder::new(config)
408                .model(ModelBuilder::new(1, 0).quality(9000).cost(100, 400).build())
409                .model(ModelBuilder::new(2, 1).quality(7000).cost(10, 40).build())
410                .build()
411                .unwrap();
412            let input = InputBuilder::new(seq, model_id)
413                .tokens(input_tokens, output_tokens)
414                .business_value(value)
415                .risk(risk, confidence)
416                .try_build()
417                .expect("valid bounded bps");
418            let _ = snap.prescribe(input);
419        }
420    }
421}