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/// Precise trust-boundary errors from [`PolicyBuilder::build_trusted`].
230///
231/// [`BuildError`] is retained as the compatibility surface. This type avoids
232/// translating reserved identifiers or noncanonical flags into unrelated
233/// legacy policy errors.
234#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
235pub enum TrustedBuildError {
236    #[error("config error: {0}")]
237    Config(#[from] crate::config::ConfigError),
238    #[error("catalog too large: {len} models exceeds max_catalog_size {max}")]
239    CatalogTooLarge { len: usize, max: usize },
240    #[error("trusted policy error: {0}")]
241    Trust(#[from] crate::kernel::TrustPolicyError),
242}
243
244/// Build a [`PolicySnapshot`] from config + models with validation.
245///
246/// ```
247/// use calybris_core::builder::{PolicyBuilder, ModelBuilder};
248/// use calybris_core::config::EngineConfig;
249///
250/// let snapshot = PolicyBuilder::new(EngineConfig::new())
251///     .epochs(1, 1)
252///     .model(ModelBuilder::new(1, 0).quality(9000).cost(250, 1000).build())
253///     .model(ModelBuilder::new(2, 1).quality(7000).cost(25, 125).build())
254///     .build()
255///     .unwrap();
256///
257/// assert_eq!(snapshot.models().len(), 2);
258/// ```
259pub struct PolicyBuilder {
260    config: crate::config::EngineConfig,
261    policy_epoch: u64,
262    catalog_epoch: u64,
263    models: Vec<KernelModel>,
264}
265
266impl PolicyBuilder {
267    /// Start building a policy from an [`crate::config::EngineConfig`].
268    #[must_use]
269    pub fn new(config: crate::config::EngineConfig) -> Self {
270        Self {
271            config,
272            policy_epoch: 1,
273            catalog_epoch: 1,
274            models: Vec::new(),
275        }
276    }
277
278    /// Set policy and catalog epochs.
279    #[must_use]
280    pub fn epochs(mut self, policy: u64, catalog: u64) -> Self {
281        self.policy_epoch = policy;
282        self.catalog_epoch = catalog;
283        self
284    }
285
286    /// Add a model to the catalog.
287    #[must_use]
288    pub fn model(mut self, model: KernelModel) -> Self {
289        self.models.push(model);
290        self
291    }
292
293    /// Add multiple models.
294    #[must_use]
295    pub fn models(mut self, models: impl IntoIterator<Item = KernelModel>) -> Self {
296        self.models.extend(models);
297        self
298    }
299
300    /// Build and validate the snapshot.
301    ///
302    /// Validates config, enforces `max_catalog_size`, then delegates to
303    /// [`PolicySnapshot::try_new_trusted`] for canonical trust-boundary validation.
304    pub fn build(self) -> Result<PolicySnapshot, BuildError> {
305        match self.build_trusted() {
306            Ok(snapshot) => Ok(snapshot),
307            Err(TrustedBuildError::Config(error)) => Err(BuildError::Config(error)),
308            Err(TrustedBuildError::CatalogTooLarge { len, max }) => {
309                Err(BuildError::CatalogTooLarge { len, max })
310            }
311            Err(TrustedBuildError::Trust(crate::kernel::TrustPolicyError::Policy(error))) => {
312                Err(BuildError::Policy(error))
313            }
314            Err(TrustedBuildError::Trust(crate::kernel::TrustPolicyError::CatalogTooLarge {
315                len,
316                max,
317            })) => Err(BuildError::CatalogTooLarge { len, max }),
318            Err(TrustedBuildError::Trust(crate::kernel::TrustPolicyError::ReservedModelId)) => Err(
319                BuildError::Policy(crate::kernel::PolicyError::DuplicateModelId { model_id: 0 }),
320            ),
321            Err(TrustedBuildError::Trust(
322                crate::kernel::TrustPolicyError::InvalidEnabledFlag { value, .. },
323            )) => Err(BuildError::Policy(
324                crate::kernel::PolicyError::OutOfRangeBps {
325                    field: "model.enabled",
326                    value: u16::from(value),
327                    max: 1,
328                },
329            )),
330        }
331    }
332
333    /// Build with precise trust-boundary error reporting.
334    pub fn build_trusted(self) -> Result<PolicySnapshot, TrustedBuildError> {
335        self.config.validate()?;
336        if self.models.len() > self.config.max_catalog_size {
337            return Err(TrustedBuildError::CatalogTooLarge {
338                len: self.models.len(),
339                max: self.config.max_catalog_size,
340            });
341        }
342        Ok(PolicySnapshot::try_new_trusted(
343            self.policy_epoch,
344            self.catalog_epoch,
345            self.config.hard_risk_limit_bps,
346            self.config.minimum_confidence_bps,
347            self.config.risk_penalty_multiplier_bps,
348            self.config.latency_penalty_microunits_per_ms,
349            self.models,
350        )?)
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn input_builder_defaults() {
360        let input = InputBuilder::new(1, 10).tokens(500, 200).build();
361        assert_eq!(input.request_sequence, 1);
362        assert_eq!(input.requested_model_id, 10);
363        assert_eq!(input.input_tokens, 500);
364        assert_eq!(input.allowed_provider_mask, ALL_PROVIDERS);
365        assert_eq!(input.required_region_mask, 0);
366        assert_eq!(input.budget_limit_microunits, u64::MAX);
367    }
368
369    #[test]
370    fn model_builder_defaults() {
371        let model = ModelBuilder::new(1, 0).cost(100, 400).build();
372        assert_eq!(model.model_id, 1);
373        assert_eq!(model.enabled, 1);
374        assert_eq!(model.quality_bps, 8_000);
375        assert_eq!(model.risk_ceiling_bps, 9_500);
376    }
377
378    #[test]
379    fn policy_builder_roundtrip() {
380        let config = crate::config::EngineConfig::new();
381        let snap = PolicyBuilder::new(config)
382            .epochs(7, 11)
383            .model(
384                ModelBuilder::new(1, 0)
385                    .quality(9000)
386                    .cost(250, 1000)
387                    .build(),
388            )
389            .model(ModelBuilder::new(2, 1).quality(7000).cost(25, 125).build())
390            .build()
391            .unwrap();
392        assert_eq!(snap.policy_epoch, 7);
393        assert_eq!(snap.models().len(), 2);
394    }
395
396    #[test]
397    fn policy_builder_rejects_reserved_model_id() {
398        let result = PolicyBuilder::new(crate::config::EngineConfig::new())
399            .model(ModelBuilder::new(0, 0).build())
400            .build();
401        assert!(matches!(
402            result,
403            Err(BuildError::Policy(
404                crate::kernel::PolicyError::DuplicateModelId { model_id: 0 }
405            ))
406        ));
407    }
408
409    #[test]
410    fn trusted_policy_builder_preserves_precise_trust_boundary_errors() {
411        let reserved = PolicyBuilder::new(crate::config::EngineConfig::new())
412            .model(ModelBuilder::new(0, 0).build())
413            .build_trusted();
414        assert!(matches!(
415            reserved,
416            Err(TrustedBuildError::Trust(
417                crate::kernel::TrustPolicyError::ReservedModelId
418            ))
419        ));
420
421        let mut model = ModelBuilder::new(1, 0).build();
422        model.enabled = 2;
423        let invalid_enabled = PolicyBuilder::new(crate::config::EngineConfig::new())
424            .model(model)
425            .build_trusted();
426        assert!(matches!(
427            invalid_enabled,
428            Err(TrustedBuildError::Trust(
429                crate::kernel::TrustPolicyError::InvalidEnabledFlag {
430                    model_id: 1,
431                    value: 2
432                }
433            ))
434        ));
435    }
436
437    #[test]
438    fn policy_builder_rejects_noncanonical_enabled_flag() {
439        let mut model = ModelBuilder::new(1, 0).build();
440        model.enabled = 2;
441        let result = PolicyBuilder::new(crate::config::EngineConfig::new())
442            .model(model)
443            .build();
444        assert!(matches!(
445            result,
446            Err(BuildError::Policy(
447                crate::kernel::PolicyError::OutOfRangeBps {
448                    field: "model.enabled",
449                    value: 2,
450                    max: 1
451                }
452            ))
453        ));
454    }
455
456    #[test]
457    fn builder_integrates_with_prescribe() {
458        let config = crate::config::EngineConfig::new();
459        let snap = PolicyBuilder::new(config)
460            .model(ModelBuilder::new(1, 0).quality(9000).cost(100, 400).build())
461            .build()
462            .unwrap();
463        let input = InputBuilder::new(1, 1)
464            .tokens(1000, 500)
465            .business_value(100_000)
466            .risk(1000, 9000)
467            .minimum_quality(5000)
468            .build();
469        let decision = snap.prescribe(input);
470        assert!(decision.is_executable());
471    }
472
473    #[test]
474    fn disabled_model_via_builder() {
475        let model = ModelBuilder::new(1, 0).enabled(false).build();
476        assert_eq!(model.enabled, 0);
477    }
478
479    #[test]
480    fn catalog_too_large_rejected() {
481        let config = crate::config::EngineConfig::new().max_catalog_size(1);
482        let result = PolicyBuilder::new(config)
483            .model(ModelBuilder::new(1, 0).cost(100, 400).build())
484            .model(ModelBuilder::new(2, 1).cost(10, 40).build())
485            .build();
486        assert!(matches!(result, Err(BuildError::CatalogTooLarge { .. })));
487    }
488
489    #[test]
490    fn invalid_config_rejected_at_build() {
491        let config = crate::config::EngineConfig::new().hard_risk_limit(10_001);
492        let result = PolicyBuilder::new(config)
493            .model(ModelBuilder::new(1, 0).cost(100, 400).build())
494            .build();
495        assert!(matches!(result, Err(BuildError::Config(_))));
496    }
497
498    use proptest::prelude::*;
499
500    proptest! {
501        #[test]
502        fn builder_prescribe_never_panics(
503            seq in any::<u64>(),
504            model_id in 1_u32..=2,
505            input_tokens in any::<u32>(),
506            output_tokens in any::<u32>(),
507            value in any::<i64>(),
508            risk in 0_u16..=MAX_BPS,
509            confidence in 0_u16..=MAX_BPS,
510        ) {
511            let config = crate::config::EngineConfig::new();
512            let snap = PolicyBuilder::new(config)
513                .model(ModelBuilder::new(1, 0).quality(9000).cost(100, 400).build())
514                .model(ModelBuilder::new(2, 1).quality(7000).cost(10, 40).build())
515                .build()
516                .unwrap();
517            let input = InputBuilder::new(seq, model_id)
518                .tokens(input_tokens, output_tokens)
519                .business_value(value)
520                .risk(risk, confidence)
521                .try_build()
522                .expect("valid bounded bps");
523            let _ = snap.prescribe(input);
524        }
525    }
526}