Skip to main content

tower/
simulate.rs

1//! Dry-run a `TowerRule` against historical events from scryer.
2//!
3//! `simulate` compiles the rule's predicate, runs each event through the
4//! compiled filter, applies debounce and rate gating with a simulated clock,
5//! and renders what each trigger WOULD produce — without firing it.
6//!
7//! The simulated clock advances by `SimulateOptions::event_spacing_ms` per
8//! event. This makes debounce and rate window behaviour deterministic in the
9//! absence of real event timestamps.
10//!
11//! Architecture: `.yah/docs/architecture/A052-yah-tower.md` §Open questions §Audit + replay
12
13use 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// ── Options ───────────────────────────────────────────────────────────────────
26
27/// Options controlling the simulation.
28#[derive(Debug, Clone)]
29pub struct SimulateOptions {
30    /// Simulated milliseconds between consecutive events.
31    ///
32    /// Used to evaluate debounce windows in the absence of real event
33    /// timestamps. Default: 1000 (1 second per event). Set to 0 for
34    /// "all events simultaneous" (any debounce > 0ms suppresses after the
35    /// first fire).
36    pub event_spacing_ms: u64,
37    /// Maximum recent matching events to include in the dispatch context
38    /// for `AgentDispatch` trigger rendering. Default: 5.
39    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// ── Output types ──────────────────────────────────────────────────────────────
49
50/// The rendered (but not fired) trigger for a matching event.
51#[derive(Debug, Clone)]
52pub enum RenderedTrigger {
53    /// The `AgentDispatchSpec` that would be handed to `forge.run`.
54    AgentDispatch(AgentDispatchSpec),
55    /// The payload that would be delivered to all configured notification channels.
56    Notification(NotificationPayload),
57    /// The command that would be issued to the yubaba RPC.
58    YubabaAction(YubabaCommand),
59}
60
61/// Whether a filter-matching event would fire or be suppressed.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum DebounceDecision {
64    /// This event would cause the rule to fire.
65    WouldFire,
66    /// Suppressed by the rule's debounce window.
67    WouldSuppress {
68        /// Simulated milliseconds remaining in the debounce window at the
69        /// moment this event arrives.
70        debounce_remaining_ms: u64,
71    },
72    /// Suppressed because the rate predicate threshold is not yet met.
73    BelowRateThreshold {
74        /// Number of matches accumulated in the current window so far.
75        seen: usize,
76        /// Number required before the rule fires.
77        required: u32,
78    },
79}
80
81/// One match entry in the simulation report.
82#[derive(Debug)]
83pub struct SimulateMatch {
84    /// The event that passed the rule's compiled filter.
85    pub event: Event,
86    /// What the dispatch engine would produce if the rule were live.
87    pub rendered_trigger: RenderedTrigger,
88    /// Whether this match would fire or be suppressed.
89    pub decision: DebounceDecision,
90}
91
92/// Full simulation report returned by `simulate`.
93#[derive(Debug)]
94pub struct SimulateReport {
95    pub rule_id: RuleId,
96    pub rule_name: String,
97    /// Total events fed into the simulator.
98    pub total_events: usize,
99    /// Events that passed the compiled filter (superset of `matches`).
100    pub filter_matches: usize,
101    /// Matches that would fire the trigger.
102    pub would_fire: usize,
103    /// Matches that would be suppressed (debounce or rate threshold).
104    pub would_suppress: usize,
105    /// Per-match detail, in input order.
106    pub matches: Vec<SimulateMatch>,
107}
108
109/// Error returned by `simulate`.
110#[derive(Debug, thiserror::Error)]
111pub enum SimulateError {
112    #[error("compile error: {0}")]
113    Compile(#[from] CompileError),
114}
115
116// ── Simulated rate window ─────────────────────────────────────────────────────
117
118struct SimRateWindow {
119    window_ms: u64,
120    min_count: u32,
121    /// Simulated timestamps of recent matches within the window.
122    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    /// Record a match at `sim_ms`. Returns `(count_in_window, threshold_met)`.
131    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
139// ── simulate ──────────────────────────────────────────────────────────────────
140
141/// Dry-run `rule` against `events` using `options`.
142///
143/// For each event that passes the compiled filter:
144/// 1. Checks the rate predicate (if any) against the simulated clock.
145/// 2. Checks the debounce window against the simulated clock.
146/// 3. Renders the trigger as it would appear to the dispatch engine, without
147///    invoking it.
148///
149/// The simulated clock advances by `options.event_spacing_ms` per event,
150/// making debounce and rate window behaviour deterministic.
151pub 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        // Deduplicate exact events (same scope+seq). Shouldn't occur in real
177        // scryer output but guard it so tests with duplicate events are clean.
178        let eid = (scope_id(&event.scope), event.seq);
179        if seen.contains(&eid) {
180            continue;
181        }
182
183        // Rate check — threshold must be met before debounce is consulted.
184        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        // Debounce check — only consulted once the rate threshold is met.
199        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        // Build context for trigger rendering (older matching events, not this one).
229        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
262// ── helpers ───────────────────────────────────────────────────────────────────
263
264fn 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
271/// Render the trigger from `ctx` without invoking any handler.
272fn 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}