1use serde::{Deserialize, Serialize};
8
9use crate::economic::{EconomicMode, ModelTier};
10use crate::gating::HomeostaticState;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct GatingDecision {
15 pub rule_id: String,
17 pub economic_mode: Option<EconomicMode>,
19 pub max_tokens_next_turn: Option<u32>,
21 pub preferred_model: Option<ModelTier>,
23 pub restrict_expensive_tools: Option<bool>,
25 pub restrict_side_effects: Option<bool>,
27 pub max_tool_calls_per_tick: Option<u32>,
29 pub rationale: String,
31}
32
33impl GatingDecision {
34 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
49pub trait HomeostaticRule: Send + Sync {
51 fn rule_id(&self) -> &str;
53
54 fn evaluate(&self, state: &HomeostaticState) -> Option<GatingDecision>;
58}
59
60pub struct RuleSet {
62 rules: Vec<Box<dyn HomeostaticRule>>,
63}
64
65impl RuleSet {
66 pub fn new() -> Self {
68 Self { rules: Vec::new() }
69 }
70
71 pub fn add(&mut self, rule: Box<dyn HomeostaticRule>) {
73 self.rules.push(rule);
74 }
75
76 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 pub fn len(&self) -> usize {
86 self.rules.len()
87 }
88
89 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}