1use std::collections::{HashSet, VecDeque};
14
15use tower_rules::{Predicate, RatePredicate, RuleId, TowerRule, Trigger, YubabaActionKind};
16
17use crate::dispatch::DispatchContext;
18use crate::event::Event;
19use crate::rules::compiler::{self, CompileError};
20use crate::supervisor::scope_id;
21use crate::triggers::{
22 event_to_json, render_prompt, AgentDispatchSpec, NotificationPayload, YubabaCommand,
23};
24
25#[derive(Debug, Clone)]
29pub struct SimulateOptions {
30 pub event_spacing_ms: u64,
37 pub context_window: usize,
40}
41
42impl Default for SimulateOptions {
43 fn default() -> Self {
44 Self { event_spacing_ms: 1_000, context_window: 5 }
45 }
46}
47
48#[derive(Debug, Clone)]
52pub enum RenderedTrigger {
53 AgentDispatch(AgentDispatchSpec),
55 Notification(NotificationPayload),
57 YubabaAction(YubabaCommand),
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum DebounceDecision {
64 WouldFire,
66 WouldSuppress {
68 debounce_remaining_ms: u64,
71 },
72 BelowRateThreshold {
74 seen: usize,
76 required: u32,
78 },
79}
80
81#[derive(Debug)]
83pub struct SimulateMatch {
84 pub event: Event,
86 pub rendered_trigger: RenderedTrigger,
88 pub decision: DebounceDecision,
90}
91
92#[derive(Debug)]
94pub struct SimulateReport {
95 pub rule_id: RuleId,
96 pub rule_name: String,
97 pub total_events: usize,
99 pub filter_matches: usize,
101 pub would_fire: usize,
103 pub would_suppress: usize,
105 pub matches: Vec<SimulateMatch>,
107}
108
109#[derive(Debug, thiserror::Error)]
111pub enum SimulateError {
112 #[error("compile error: {0}")]
113 Compile(#[from] CompileError),
114}
115
116struct SimRateWindow {
119 window_ms: u64,
120 min_count: u32,
121 timestamps: VecDeque<u64>,
123}
124
125impl SimRateWindow {
126 fn new(rate: &RatePredicate) -> Self {
127 Self { window_ms: rate.window_ms, min_count: rate.min_count, timestamps: VecDeque::new() }
128 }
129
130 fn record(&mut self, sim_ms: u64) -> (usize, bool) {
132 self.timestamps.retain(|&t| sim_ms.saturating_sub(t) < self.window_ms);
133 self.timestamps.push_back(sim_ms);
134 let count = self.timestamps.len();
135 (count, count >= self.min_count as usize)
136 }
137}
138
139pub fn simulate(
152 rule: &TowerRule,
153 events: &[Event],
154 options: &SimulateOptions,
155) -> Result<SimulateReport, SimulateError> {
156 let filter = compiler::compile(rule)?;
157
158 let debounce_ms = rule.debounce_ms;
159 let mut last_fire_sim_ms: Option<u64> = None;
160 let mut rate_window = extract_rate_window(&rule.predicate);
161 let mut context_ring: VecDeque<Event> = VecDeque::new();
162 let mut seen: HashSet<(String, u64)> = HashSet::new();
163
164 let mut matches = Vec::new();
165 let mut filter_matches = 0usize;
166 let mut sim_ms: u64 = 0;
167
168 for event in events {
169 sim_ms = sim_ms.saturating_add(options.event_spacing_ms);
170
171 if !filter.matches(event) {
172 continue;
173 }
174 filter_matches += 1;
175
176 let eid = (scope_id(&event.scope), event.seq);
179 if seen.contains(&eid) {
180 continue;
181 }
182
183 let rate_decision = if let Some(rw) = rate_window.as_mut() {
185 let (seen_count, threshold_met) = rw.record(sim_ms);
186 if !threshold_met {
187 Some(DebounceDecision::BelowRateThreshold {
188 seen: seen_count,
189 required: rw.min_count,
190 })
191 } else {
192 None
193 }
194 } else {
195 None
196 };
197
198 let debounce_decision = if rate_decision.is_none() {
200 if let Some(d_ms) = debounce_ms {
201 if let Some(last_ms) = last_fire_sim_ms {
202 let elapsed = sim_ms.saturating_sub(last_ms);
203 if elapsed < d_ms {
204 Some(DebounceDecision::WouldSuppress {
205 debounce_remaining_ms: d_ms - elapsed,
206 })
207 } else {
208 None
209 }
210 } else {
211 None
212 }
213 } else {
214 None
215 }
216 } else {
217 None
218 };
219
220 let decision =
221 rate_decision.or(debounce_decision).unwrap_or(DebounceDecision::WouldFire);
222
223 if decision == DebounceDecision::WouldFire {
224 last_fire_sim_ms = Some(sim_ms);
225 seen.insert(eid);
226 }
227
228 let context_events: Vec<Event> = context_ring.iter().cloned().collect();
230 if context_ring.len() >= options.context_window {
231 context_ring.pop_front();
232 }
233 context_ring.push_back(event.clone());
234
235 let ctx = DispatchContext {
236 rule_id: rule.id.clone(),
237 rule: rule.clone(),
238 matched_event: event.clone(),
239 context_events,
240 peer: None,
241 };
242
243 let rendered_trigger = render_trigger_dry(&ctx);
244 matches.push(SimulateMatch { event: event.clone(), rendered_trigger, decision });
245 }
246
247 let would_fire =
248 matches.iter().filter(|m| m.decision == DebounceDecision::WouldFire).count();
249 let would_suppress = matches.len() - would_fire;
250
251 Ok(SimulateReport {
252 rule_id: rule.id.clone(),
253 rule_name: rule.name.clone(),
254 total_events: events.len(),
255 filter_matches,
256 would_fire,
257 would_suppress,
258 matches,
259 })
260}
261
262fn extract_rate_window(predicate: &Predicate) -> Option<SimRateWindow> {
265 match predicate {
266 Predicate::EventMatch { rate: Some(rate), .. } => Some(SimRateWindow::new(rate)),
267 _ => None,
268 }
269}
270
271fn render_trigger_dry(ctx: &DispatchContext) -> RenderedTrigger {
273 match &ctx.rule.trigger {
274 Trigger::AgentDispatch { agent_class, prompt_template, placement } => {
275 let prompt = render_prompt(prompt_template, ctx);
276 RenderedTrigger::AgentDispatch(AgentDispatchSpec {
277 agent_class: agent_class.clone(),
278 prompt,
279 placement: placement.clone(),
280 })
281 }
282 Trigger::Notification { channels: _, severity } => {
283 let event_json = event_to_json(&ctx.matched_event)
284 .map(|v| v.to_string())
285 .unwrap_or_else(|_| "{}".into());
286 let matched_scope = scope_id(&ctx.matched_event.scope);
287 RenderedTrigger::Notification(NotificationPayload {
288 rule_name: ctx.rule.name.clone(),
289 severity: *severity,
290 event_json,
291 matched_scope,
292 })
293 }
294 Trigger::YubabaAction { kind, target } => {
295 let cmd = match kind {
296 YubabaActionKind::RestartWorkload => {
297 YubabaCommand::RestartWorkload { target: target.clone() }
298 }
299 YubabaActionKind::Drain => YubabaCommand::Drain { target: target.clone() },
300 YubabaActionKind::Scale { replicas } => {
301 YubabaCommand::Scale { target: target.clone(), replicas: *replicas }
302 }
303 };
304 RenderedTrigger::YubabaAction(cmd)
305 }
306 }
307}