yah-tower 0.8.20

yah-tower — rule-driven monitor on scryer. Rule parser/compiler, ScryerFilter, and dispatch engine.
Documentation
//! Dry-run a `TowerRule` against historical events from scryer.
//!
//! `simulate` compiles the rule's predicate, runs each event through the
//! compiled filter, applies debounce and rate gating with a simulated clock,
//! and renders what each trigger WOULD produce — without firing it.
//!
//! The simulated clock advances by `SimulateOptions::event_spacing_ms` per
//! event. This makes debounce and rate window behaviour deterministic in the
//! absence of real event timestamps.
//!
//! Architecture: `.yah/docs/architecture/A052-yah-tower.md` §Open questions §Audit + replay

use std::collections::{HashSet, VecDeque};

use tower_rules::{Predicate, RatePredicate, RuleId, TowerRule, Trigger, YubabaActionKind};

use crate::dispatch::DispatchContext;
use crate::event::Event;
use crate::rules::compiler::{self, CompileError};
use crate::supervisor::scope_id;
use crate::triggers::{
    event_to_json, render_prompt, AgentDispatchSpec, NotificationPayload, YubabaCommand,
};

// ── Options ───────────────────────────────────────────────────────────────────

/// Options controlling the simulation.
#[derive(Debug, Clone)]
pub struct SimulateOptions {
    /// Simulated milliseconds between consecutive events.
    ///
    /// Used to evaluate debounce windows in the absence of real event
    /// timestamps. Default: 1000 (1 second per event). Set to 0 for
    /// "all events simultaneous" (any debounce > 0ms suppresses after the
    /// first fire).
    pub event_spacing_ms: u64,
    /// Maximum recent matching events to include in the dispatch context
    /// for `AgentDispatch` trigger rendering. Default: 5.
    pub context_window: usize,
}

impl Default for SimulateOptions {
    fn default() -> Self {
        Self { event_spacing_ms: 1_000, context_window: 5 }
    }
}

// ── Output types ──────────────────────────────────────────────────────────────

/// The rendered (but not fired) trigger for a matching event.
#[derive(Debug, Clone)]
pub enum RenderedTrigger {
    /// The `AgentDispatchSpec` that would be handed to `forge.run`.
    AgentDispatch(AgentDispatchSpec),
    /// The payload that would be delivered to all configured notification channels.
    Notification(NotificationPayload),
    /// The command that would be issued to the yubaba RPC.
    YubabaAction(YubabaCommand),
}

/// Whether a filter-matching event would fire or be suppressed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DebounceDecision {
    /// This event would cause the rule to fire.
    WouldFire,
    /// Suppressed by the rule's debounce window.
    WouldSuppress {
        /// Simulated milliseconds remaining in the debounce window at the
        /// moment this event arrives.
        debounce_remaining_ms: u64,
    },
    /// Suppressed because the rate predicate threshold is not yet met.
    BelowRateThreshold {
        /// Number of matches accumulated in the current window so far.
        seen: usize,
        /// Number required before the rule fires.
        required: u32,
    },
}

/// One match entry in the simulation report.
#[derive(Debug)]
pub struct SimulateMatch {
    /// The event that passed the rule's compiled filter.
    pub event: Event,
    /// What the dispatch engine would produce if the rule were live.
    pub rendered_trigger: RenderedTrigger,
    /// Whether this match would fire or be suppressed.
    pub decision: DebounceDecision,
}

/// Full simulation report returned by `simulate`.
#[derive(Debug)]
pub struct SimulateReport {
    pub rule_id: RuleId,
    pub rule_name: String,
    /// Total events fed into the simulator.
    pub total_events: usize,
    /// Events that passed the compiled filter (superset of `matches`).
    pub filter_matches: usize,
    /// Matches that would fire the trigger.
    pub would_fire: usize,
    /// Matches that would be suppressed (debounce or rate threshold).
    pub would_suppress: usize,
    /// Per-match detail, in input order.
    pub matches: Vec<SimulateMatch>,
}

/// Error returned by `simulate`.
#[derive(Debug, thiserror::Error)]
pub enum SimulateError {
    #[error("compile error: {0}")]
    Compile(#[from] CompileError),
}

// ── Simulated rate window ─────────────────────────────────────────────────────

struct SimRateWindow {
    window_ms: u64,
    min_count: u32,
    /// Simulated timestamps of recent matches within the window.
    timestamps: VecDeque<u64>,
}

impl SimRateWindow {
    fn new(rate: &RatePredicate) -> Self {
        Self { window_ms: rate.window_ms, min_count: rate.min_count, timestamps: VecDeque::new() }
    }

    /// Record a match at `sim_ms`. Returns `(count_in_window, threshold_met)`.
    fn record(&mut self, sim_ms: u64) -> (usize, bool) {
        self.timestamps.retain(|&t| sim_ms.saturating_sub(t) < self.window_ms);
        self.timestamps.push_back(sim_ms);
        let count = self.timestamps.len();
        (count, count >= self.min_count as usize)
    }
}

// ── simulate ──────────────────────────────────────────────────────────────────

/// Dry-run `rule` against `events` using `options`.
///
/// For each event that passes the compiled filter:
/// 1. Checks the rate predicate (if any) against the simulated clock.
/// 2. Checks the debounce window against the simulated clock.
/// 3. Renders the trigger as it would appear to the dispatch engine, without
///    invoking it.
///
/// The simulated clock advances by `options.event_spacing_ms` per event,
/// making debounce and rate window behaviour deterministic.
pub fn simulate(
    rule: &TowerRule,
    events: &[Event],
    options: &SimulateOptions,
) -> Result<SimulateReport, SimulateError> {
    let filter = compiler::compile(rule)?;

    let debounce_ms = rule.debounce_ms;
    let mut last_fire_sim_ms: Option<u64> = None;
    let mut rate_window = extract_rate_window(&rule.predicate);
    let mut context_ring: VecDeque<Event> = VecDeque::new();
    let mut seen: HashSet<(String, u64)> = HashSet::new();

    let mut matches = Vec::new();
    let mut filter_matches = 0usize;
    let mut sim_ms: u64 = 0;

    for event in events {
        sim_ms = sim_ms.saturating_add(options.event_spacing_ms);

        if !filter.matches(event) {
            continue;
        }
        filter_matches += 1;

        // Deduplicate exact events (same scope+seq). Shouldn't occur in real
        // scryer output but guard it so tests with duplicate events are clean.
        let eid = (scope_id(&event.scope), event.seq);
        if seen.contains(&eid) {
            continue;
        }

        // Rate check — threshold must be met before debounce is consulted.
        let rate_decision = if let Some(rw) = rate_window.as_mut() {
            let (seen_count, threshold_met) = rw.record(sim_ms);
            if !threshold_met {
                Some(DebounceDecision::BelowRateThreshold {
                    seen: seen_count,
                    required: rw.min_count,
                })
            } else {
                None
            }
        } else {
            None
        };

        // Debounce check — only consulted once the rate threshold is met.
        let debounce_decision = if rate_decision.is_none() {
            if let Some(d_ms) = debounce_ms {
                if let Some(last_ms) = last_fire_sim_ms {
                    let elapsed = sim_ms.saturating_sub(last_ms);
                    if elapsed < d_ms {
                        Some(DebounceDecision::WouldSuppress {
                            debounce_remaining_ms: d_ms - elapsed,
                        })
                    } else {
                        None
                    }
                } else {
                    None
                }
            } else {
                None
            }
        } else {
            None
        };

        let decision =
            rate_decision.or(debounce_decision).unwrap_or(DebounceDecision::WouldFire);

        if decision == DebounceDecision::WouldFire {
            last_fire_sim_ms = Some(sim_ms);
            seen.insert(eid);
        }

        // Build context for trigger rendering (older matching events, not this one).
        let context_events: Vec<Event> = context_ring.iter().cloned().collect();
        if context_ring.len() >= options.context_window {
            context_ring.pop_front();
        }
        context_ring.push_back(event.clone());

        let ctx = DispatchContext {
            rule_id: rule.id.clone(),
            rule: rule.clone(),
            matched_event: event.clone(),
            context_events,
            peer: None,
        };

        let rendered_trigger = render_trigger_dry(&ctx);
        matches.push(SimulateMatch { event: event.clone(), rendered_trigger, decision });
    }

    let would_fire =
        matches.iter().filter(|m| m.decision == DebounceDecision::WouldFire).count();
    let would_suppress = matches.len() - would_fire;

    Ok(SimulateReport {
        rule_id: rule.id.clone(),
        rule_name: rule.name.clone(),
        total_events: events.len(),
        filter_matches,
        would_fire,
        would_suppress,
        matches,
    })
}

// ── helpers ───────────────────────────────────────────────────────────────────

fn extract_rate_window(predicate: &Predicate) -> Option<SimRateWindow> {
    match predicate {
        Predicate::EventMatch { rate: Some(rate), .. } => Some(SimRateWindow::new(rate)),
        _ => None,
    }
}

/// Render the trigger from `ctx` without invoking any handler.
fn render_trigger_dry(ctx: &DispatchContext) -> RenderedTrigger {
    match &ctx.rule.trigger {
        Trigger::AgentDispatch { agent_class, prompt_template, placement } => {
            let prompt = render_prompt(prompt_template, ctx);
            RenderedTrigger::AgentDispatch(AgentDispatchSpec {
                agent_class: agent_class.clone(),
                prompt,
                placement: placement.clone(),
            })
        }
        Trigger::Notification { channels: _, severity } => {
            let event_json = event_to_json(&ctx.matched_event)
                .map(|v| v.to_string())
                .unwrap_or_else(|_| "{}".into());
            let matched_scope = scope_id(&ctx.matched_event.scope);
            RenderedTrigger::Notification(NotificationPayload {
                rule_name: ctx.rule.name.clone(),
                severity: *severity,
                event_json,
                matched_scope,
            })
        }
        Trigger::YubabaAction { kind, target } => {
            let cmd = match kind {
                YubabaActionKind::RestartWorkload => {
                    YubabaCommand::RestartWorkload { target: target.clone() }
                }
                YubabaActionKind::Drain => YubabaCommand::Drain { target: target.clone() },
                YubabaActionKind::Scale { replicas } => {
                    YubabaCommand::Scale { target: target.clone(), replicas: *replicas }
                }
            };
            RenderedTrigger::YubabaAction(cmd)
        }
    }
}