Skip to main content

autonomic_core/
rules.rs

1//! Homeostatic rule trait and rule set.
2//!
3//! Rules are pure functions: given `HomeostaticState`, they produce
4//! an optional `GatingDecision`. The controller evaluates all rules
5//! and merges their decisions into a final `AutonomicGatingProfile`.
6
7use serde::{Deserialize, Serialize};
8
9use crate::economic::{EconomicMode, ModelTier};
10use crate::gating::HomeostaticState;
11
12/// A decision produced by a homeostatic rule.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct GatingDecision {
15    /// Which rule produced this decision.
16    pub rule_id: String,
17    /// Whether to override the economic mode.
18    pub economic_mode: Option<EconomicMode>,
19    /// Whether to cap tokens for the next turn.
20    pub max_tokens_next_turn: Option<u32>,
21    /// Whether to suggest a model tier.
22    pub preferred_model: Option<ModelTier>,
23    /// Whether to restrict expensive tools.
24    pub restrict_expensive_tools: Option<bool>,
25    /// Whether to restrict side effects.
26    pub restrict_side_effects: Option<bool>,
27    /// Override for max tool calls per tick.
28    pub max_tool_calls_per_tick: Option<u32>,
29    /// Human-readable rationale.
30    pub rationale: String,
31}
32
33impl GatingDecision {
34    /// Create a no-op decision (used when a rule doesn't fire).
35    pub fn noop(rule_id: impl Into<String>) -> Self {
36        Self {
37            rule_id: rule_id.into(),
38            economic_mode: None,
39            max_tokens_next_turn: None,
40            preferred_model: None,
41            restrict_expensive_tools: None,
42            restrict_side_effects: None,
43            max_tool_calls_per_tick: None,
44            rationale: String::new(),
45        }
46    }
47}
48
49/// A homeostatic rule that evaluates state and optionally produces a gating decision.
50pub trait HomeostaticRule: Send + Sync {
51    /// Unique identifier for this rule.
52    fn rule_id(&self) -> &str;
53
54    /// Evaluate the rule against the current homeostatic state.
55    ///
56    /// Returns `Some(decision)` if the rule fires, `None` if it doesn't apply.
57    fn evaluate(&self, state: &HomeostaticState) -> Option<GatingDecision>;
58}
59
60/// An ordered collection of homeostatic rules.
61pub struct RuleSet {
62    rules: Vec<Box<dyn HomeostaticRule>>,
63}
64
65impl RuleSet {
66    /// Create an empty rule set.
67    pub fn new() -> Self {
68        Self { rules: Vec::new() }
69    }
70
71    /// Add a rule to the set.
72    pub fn add(&mut self, rule: Box<dyn HomeostaticRule>) {
73        self.rules.push(rule);
74    }
75
76    /// Evaluate all rules and collect decisions.
77    pub fn evaluate_all(&self, state: &HomeostaticState) -> Vec<GatingDecision> {
78        self.rules
79            .iter()
80            .filter_map(|rule| rule.evaluate(state))
81            .collect()
82    }
83
84    /// Number of rules in the set.
85    pub fn len(&self) -> usize {
86        self.rules.len()
87    }
88
89    /// Whether the set is empty.
90    pub fn is_empty(&self) -> bool {
91        self.rules.is_empty()
92    }
93}
94
95impl Default for RuleSet {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::gating::HomeostaticState;
105
106    struct AlwaysFireRule;
107
108    impl HomeostaticRule for AlwaysFireRule {
109        fn rule_id(&self) -> &str {
110            "always_fire"
111        }
112
113        fn evaluate(&self, _state: &HomeostaticState) -> Option<GatingDecision> {
114            Some(GatingDecision {
115                rule_id: self.rule_id().into(),
116                economic_mode: Some(EconomicMode::Conserving),
117                rationale: "always fires".into(),
118                ..GatingDecision::noop(self.rule_id())
119            })
120        }
121    }
122
123    struct NeverFireRule;
124
125    impl HomeostaticRule for NeverFireRule {
126        fn rule_id(&self) -> &str {
127            "never_fire"
128        }
129
130        fn evaluate(&self, _state: &HomeostaticState) -> Option<GatingDecision> {
131            None
132        }
133    }
134
135    #[test]
136    fn rule_set_evaluates_all() {
137        let mut set = RuleSet::new();
138        set.add(Box::new(AlwaysFireRule));
139        set.add(Box::new(NeverFireRule));
140        set.add(Box::new(AlwaysFireRule));
141
142        let state = HomeostaticState::default();
143        let decisions = set.evaluate_all(&state);
144        assert_eq!(decisions.len(), 2);
145    }
146
147    #[test]
148    fn rule_set_empty() {
149        let set = RuleSet::new();
150        assert!(set.is_empty());
151        assert_eq!(set.len(), 0);
152    }
153
154    #[test]
155    fn gating_decision_noop() {
156        let decision = GatingDecision::noop("test");
157        assert_eq!(decision.rule_id, "test");
158        assert!(decision.economic_mode.is_none());
159        assert!(decision.max_tokens_next_turn.is_none());
160    }
161}