Skip to main content

hyperi_rustlib/scaling/
config.rs

1// Project:   hyperi-rustlib
2// File:      src/scaling/config.rs
3// Purpose:   Scaling pressure configuration types
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Configuration for the scaling pressure calculator.
10//!
11//! [`ScalingPressureConfig`] provides the base gate thresholds shared across
12//! all apps. Per-component weights and saturation points are defined in each
13//! app's own config struct and passed as [`ScalingComponent`] at construction.
14
15use serde::{Deserialize, Serialize};
16
17/// Base configuration for scaling pressure calculation.
18///
19/// Lives in the app's config cascade so thresholds are env-var overridable
20/// (e.g., `DFE_LOADER__SCALING__MEMORY_GATE_THRESHOLD=0.9`).
21///
22/// Component weights and saturation points are app-specific -- defined in
23/// each app's config and passed to [`super::ScalingPressure::new`] via
24/// [`ScalingComponent`].
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(default)]
27pub struct ScalingPressureConfig {
28    /// Enable scaling pressure calculation.
29    /// When disabled, `calculate()` always returns 0.0.
30    pub enabled: bool,
31
32    /// Memory usage ratio that triggers the memory gate (0.0-1.0).
33    ///
34    /// When `memory_used / memory_limit >= threshold`, scaling pressure
35    /// is forced to 100.0 to trigger immediate scale-up before OOM.
36    pub memory_gate_threshold: f64,
37}
38
39impl Default for ScalingPressureConfig {
40    fn default() -> Self {
41        Self {
42            enabled: true,
43            memory_gate_threshold: 0.8,
44        }
45    }
46}
47
48impl ScalingPressureConfig {
49    /// Load from the config cascade under the `scaling` key.
50    ///
51    /// Falls back to [`ScalingPressureConfig::default()`] if config is not
52    /// initialised or the key is absent.
53    #[must_use]
54    pub fn from_cascade() -> Self {
55        #[cfg(feature = "config")]
56        {
57            if let Some(cfg) = crate::config::try_get()
58                && let Ok(scaling) = cfg.unmarshal_key_registered::<Self>("scaling")
59            {
60                return scaling;
61            }
62        }
63        Self::default()
64    }
65}
66
67/// Named scaling component with weight and saturation point.
68///
69/// Apps define their components with service-specific signals:
70///
71/// ```rust
72/// use hyperi_rustlib::scaling::ScalingComponent;
73///
74/// let components = vec![
75///     ScalingComponent::new("kafka_lag", 0.35, 100_000.0),
76///     ScalingComponent::new("buffer_depth", 0.25, 10_000.0),
77///     ScalingComponent::new("insert_latency", 0.15, 5.0),
78///     ScalingComponent::new("memory", 0.15, 1.0),
79///     ScalingComponent::new("errors", 0.10, 100.0),
80/// ];
81/// ```
82#[derive(Debug, Clone)]
83pub struct ScalingComponent {
84    /// Component name (e.g., "kafka_lag", "buffer_depth").
85    pub name: String,
86    /// Relative weight (0.0-1.0). All weights should sum to ~1.0.
87    pub weight: f64,
88    /// Value at which this component contributes its full weight.
89    /// Score = `(value / saturation).min(1.0) * weight * 100.0`.
90    pub saturation: f64,
91}
92
93impl ScalingComponent {
94    /// Create a new scaling component.
95    #[must_use]
96    pub fn new(name: impl Into<String>, weight: f64, saturation: f64) -> Self {
97        Self {
98            name: name.into(),
99            weight,
100            saturation,
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn test_config_defaults() {
111        let config = ScalingPressureConfig::default();
112        assert!(config.enabled);
113        assert!((config.memory_gate_threshold - 0.8).abs() < f64::EPSILON);
114    }
115
116    #[test]
117    fn test_config_serde_roundtrip() {
118        let config = ScalingPressureConfig {
119            enabled: false,
120            memory_gate_threshold: 0.9,
121        };
122        let json = serde_json::to_string(&config).unwrap();
123        let parsed: ScalingPressureConfig = serde_json::from_str(&json).unwrap();
124        assert!(!parsed.enabled);
125        assert!((parsed.memory_gate_threshold - 0.9).abs() < f64::EPSILON);
126    }
127
128    #[test]
129    fn test_component_new() {
130        let c = ScalingComponent::new("kafka_lag", 0.35, 100_000.0);
131        assert_eq!(c.name, "kafka_lag");
132        assert!((c.weight - 0.35).abs() < f64::EPSILON);
133        assert!((c.saturation - 100_000.0).abs() < f64::EPSILON);
134    }
135}