use std::collections::BTreeMap;
use std::time::Duration;
use crate::{Error, Result};
use crate::model::decode::SchemaStore;
use crate::model::registry::SliceSet;
use crate::report::{CheckId, DoctorReport};
use crate::report::{CondState, Judgement, Transition, WatchdogSummary};
use sipper::{Straw, sipper};
#[derive(Debug, Clone, PartialEq)]
pub enum Condition {
RateAbove { selector: String, hz: f64 },
RateBelow { selector: String, hz: f64 },
SilentFor { selector: String, for_s: f64 },
InvalidPayload { selector: String },
QosMismatch { selector: String },
DoctorCheck { check: CheckId },
OriginDown { origin: String },
Dropped,
}
const VOCABULARY: &str = "rate-above <SEL> <HZ> | rate-below <SEL> <HZ> | \
silent-for <SEL> <SECS> | invalid-payload <SEL> | qos-mismatch <SEL> | \
doctor <CHECK-ID> | origin-down <ORIGIN> | dropped";
impl Condition {
pub fn parse(rule: &str) -> Result<Condition> {
let hz = |s: &str, kind: &str| -> Result<f64> {
let v: f64 = s
.parse()
.map_err(|_| Error::unaskable(format!("{kind} {s:?}"), "is not a number"))?;
if !v.is_finite() || v < 0.0 {
return Err(Error::unaskable(
kind.to_string(),
"the threshold must be a finite non-negative number",
));
}
Ok(v)
};
let tokens: Vec<&str> = rule.split_whitespace().collect();
Ok(match tokens.as_slice() {
["rate-above", sel, n] => Condition::RateAbove {
selector: sel.to_string(),
hz: hz(n, "rate-above")?,
},
["rate-below", sel, n] => Condition::RateBelow {
selector: sel.to_string(),
hz: hz(n, "rate-below")?,
},
["silent-for", sel, n] => {
let for_s = hz(n, "silent-for")?;
if for_s <= 0.0 {
return Err(Error::unaskable(
"silent-for",
"the span must be a positive number of seconds",
));
}
Condition::SilentFor {
selector: sel.to_string(),
for_s,
}
}
["invalid-payload", sel] => Condition::InvalidPayload {
selector: sel.to_string(),
},
["qos-mismatch", sel] => Condition::QosMismatch {
selector: sel.to_string(),
},
["doctor", check] => {
let Some(check) = CheckId::parse(check) else {
return Err(Error::unaskable(
format!("doctor {check:?}"),
format!(
"is not a check id — the stable vocabulary is: {}",
CheckId::ALL
.iter()
.map(|c| c.as_str())
.collect::<Vec<_>>()
.join(", ")
),
));
};
Condition::DoctorCheck { check }
}
["origin-down", origin] => Condition::OriginDown {
origin: origin.to_string(),
},
["dropped"] => Condition::Dropped,
_ => {
return Err(Error::unaskable(
format!("{rule:?}"),
format!(
"is not a rule — the vocabulary is closed (no \
expressions, no templating): {VOCABULARY}"
),
));
}
})
}
pub fn selector(&self) -> Option<&str> {
match self {
Condition::RateAbove { selector, .. }
| Condition::RateBelow { selector, .. }
| Condition::SilentFor { selector, .. }
| Condition::InvalidPayload { selector }
| Condition::QosMismatch { selector } => Some(selector),
_ => None,
}
}
pub fn judge(&self, ev: &TickEvidence<'_>) -> Eval {
match self {
Condition::DoctorCheck { check } => judge_doctor_check(*check, ev.doctor),
Condition::OriginDown { origin } => judge_origin_down(origin, ev.roster),
_ => self.judge_window_total(ev.window),
}
}
pub fn judge_window(&self, w: &CondWindow) -> Option<Eval> {
let synth = if w.synthetic > 0 {
format!("; {} synthetic-marked (RFC 09 §5.3)", w.synthetic)
} else {
String::new()
};
let rate = if w.window_s > 0.0 {
w.samples as f64 / w.window_s
} else {
0.0
};
Some(match self {
Condition::RateAbove { hz, .. } => {
let state = CondState::from(judge_excess(rate > *hz, w.dropped));
let evidence = match state {
CondState::Unobservable => format!(
"{rate:.2} Hz observed but {} sample(s) dropped — the true rate \
is at least that, not exactly that (O6){synth}",
w.dropped
),
_ => format!(
"{} sample(s) in {:.1}s = {rate:.2} Hz against the {hz:.2} Hz \
bound{synth}",
w.samples, w.window_s
),
};
Eval { state, evidence }
}
Condition::RateBelow { hz, .. } => {
let state = CondState::from(judge_shortfall(rate < *hz, w.dropped));
let evidence = match state {
CondState::Unobservable => format!(
"{rate:.2} Hz observed with {} sample(s) dropped — the drops \
could have carried the difference (O6){synth}",
w.dropped
),
_ => format!(
"{} sample(s) in {:.1}s = {rate:.2} Hz against the {hz:.2} Hz \
bound{synth}",
w.samples, w.window_s
),
};
Eval { state, evidence }
}
Condition::SilentFor { for_s, .. } => {
let ev = SilenceEvidence {
sample_within: w.last_sample_ago_s.map(|ago| ago < *for_s) == Some(true),
span_observed: w.observed_s >= *for_s,
drop_free: w.last_drop_ago_s.map(|ago| ago >= *for_s) != Some(false),
};
let SilenceEvidence { span_observed, .. } = ev;
let state = CondState::from(judge_silence(ev));
let evidence = match state {
CondState::Ok => format!(
"a sample rode {:.1}s ago, inside the {for_s:.1}s span{synth}",
w.last_sample_ago_s.unwrap_or(0.0)
),
CondState::Firing => {
format!("no sample for {for_s:.1}s, on a drop-free observer{synth}")
}
CondState::Unobservable if !span_observed => format!(
"watched only {:.1}s of a {for_s:.1}s silence claim — not asked \
is not answered (O4){synth}",
w.observed_s
),
CondState::Unobservable => format!(
"no sample seen, but the observer dropped inside the {for_s:.1}s \
span — silence is unprovable (O6){synth}"
),
};
Eval { state, evidence }
}
Condition::InvalidPayload { .. } => Eval {
state: if w.invalid > 0 {
CondState::Firing
} else {
CondState::Ok
},
evidence: format!(
"{} of {} checked sample(s) did not reach Valid ({} observed, \
{} dropped{synth})",
w.invalid, w.checked, w.samples, w.dropped
),
},
Condition::QosMismatch { .. } => Eval {
state: if w.qos_mismatched > 0 {
CondState::Firing
} else {
CondState::Ok
},
evidence: format!(
"{} of {} judged sample(s) did not ride their declared profile \
({} observed, {} with no declared profile to judge, \
{} dropped{synth})",
w.qos_mismatched,
w.qos_judged,
w.samples,
w.samples.saturating_sub(w.qos_judged),
w.dropped
),
},
Condition::Dropped => Eval {
state: if w.dropped > 0 {
CondState::Firing
} else {
CondState::Ok
},
evidence: format!(
"the observer dropped {} sample(s) in {:.1}s (O6){synth}",
w.dropped, w.window_s
),
},
Condition::DoctorCheck { .. } | Condition::OriginDown { .. } => return None,
})
}
fn judge_window_total(&self, w: &CondWindow) -> Eval {
debug_assert!(
!matches!(
self,
Condition::DoctorCheck { .. } | Condition::OriginDown { .. }
),
"judge() routes these two to their own evidence"
);
self.judge_window(w).unwrap_or_else(|| Eval {
state: CondState::Unobservable,
evidence: "this rule is not judged against a sample window".into(),
})
}
pub fn judge_roster(
&self,
roster: Result<&BTreeMap<String, Vec<String>>, &str>,
) -> Option<Eval> {
let Condition::OriginDown { origin } = self else {
return None;
};
Some(match roster {
Err(e) => Eval {
state: CondState::Unobservable,
evidence: format!("the roster could not be asked: {e}"),
},
Ok(r) => match r.get(origin) {
Some(producers) => Eval {
state: CondState::Ok,
evidence: format!(
"{origin} holds an alive token ({} producer(s))",
producers.len()
),
},
None => Eval {
state: CondState::Firing,
evidence: format!("{origin} holds no alive token (RFC 04 §5)"),
},
},
})
}
pub fn judge_doctor(&self, outcome: Result<&DoctorReport, &str>) -> Option<Eval> {
let Condition::DoctorCheck { check } = self else {
return None;
};
Some(match outcome {
Err(e) => Eval {
state: CondState::Unobservable,
evidence: format!("the doctor run failed: {e}"),
},
Ok(report) => {
let mut hits = report.findings.iter().filter(|f| f.check == *check);
match hits.next() {
Some(first) => Eval {
state: CondState::Firing,
evidence: format!(
"{} finding(s); first: {} — {}",
1 + hits.count(),
first.subject,
first.evidence
),
},
None => Eval {
state: CondState::Ok,
evidence: format!("no {check} findings"),
},
}
}
})
}
}
impl std::fmt::Display for Condition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Condition::RateAbove { selector, hz } => write!(f, "rate-above {selector} {hz}"),
Condition::RateBelow { selector, hz } => write!(f, "rate-below {selector} {hz}"),
Condition::SilentFor { selector, for_s } => {
write!(f, "silent-for {selector} {for_s}")
}
Condition::InvalidPayload { selector } => write!(f, "invalid-payload {selector}"),
Condition::QosMismatch { selector } => write!(f, "qos-mismatch {selector}"),
Condition::DoctorCheck { check } => write!(f, "doctor {check}"),
Condition::OriginDown { origin } => write!(f, "origin-down {origin}"),
Condition::Dropped => write!(f, "dropped"),
}
}
}
pub fn judge_shortfall(short: bool, dropped: u64) -> Judgement {
match (short, dropped) {
(false, _) => Judgement::NotEstablished {
reason: "enough was seen — a drop only hides more".into(),
},
(true, 0) => Judgement::Established,
(true, _) => Judgement::Unobservable {
reason: format!("{dropped} dropped sample(s) could have filled the shortfall (O6)"),
},
}
}
pub fn judge_excess(over: bool, dropped: u64) -> Judgement {
match (over, dropped) {
(true, _) => Judgement::Established,
(false, 0) => Judgement::NotEstablished {
reason: "no excess was counted, on a clean observation".into(),
},
(false, _) => Judgement::Unobservable {
reason: format!(
"{dropped} sample(s) dropped — \"did not exceed\" is a completeness \
claim (O6)"
),
},
}
}
#[derive(Debug, Clone, Copy)]
pub struct SilenceEvidence {
pub sample_within: bool,
pub span_observed: bool,
pub drop_free: bool,
}
pub fn judge_silence(ev: SilenceEvidence) -> Judgement {
let SilenceEvidence {
sample_within,
span_observed,
drop_free,
} = ev;
if sample_within {
Judgement::NotEstablished {
reason: "a sample rode inside the span".into(),
}
} else if span_observed && drop_free {
Judgement::Established
} else if !span_observed {
Judgement::Unobservable {
reason: "the observer has not watched the whole claimed span (O4)".into(),
}
} else {
Judgement::Unobservable {
reason: "the observer dropped inside the span — silence is unprovable (O6)".into(),
}
}
}
pub struct TickEvidence<'e> {
pub window: &'e CondWindow,
pub doctor: Option<Result<&'e DoctorReport, &'e str>>,
pub roster: Option<Result<&'e BTreeMap<String, Vec<String>>, &'e str>>,
}
pub fn judge_doctor_check(check: CheckId, outcome: Option<Result<&DoctorReport, &str>>) -> Eval {
let Some(outcome) = outcome else {
return Eval {
state: CondState::Unobservable,
evidence: "the doctor did not run this tick".into(),
};
};
Condition::DoctorCheck { check }
.judge_doctor(outcome)
.expect("a DoctorCheck is judged by the doctor")
}
pub fn judge_origin_down(
origin: &str,
roster: Option<Result<&BTreeMap<String, Vec<String>>, &str>>,
) -> Eval {
let Some(roster) = roster else {
return Eval {
state: CondState::Unobservable,
evidence: "the roster was not asked this tick".into(),
};
};
Condition::OriginDown {
origin: origin.to_string(),
}
.judge_roster(roster)
.expect("an OriginDown is judged by the roster")
}
#[derive(Debug, Clone, Copy, Default)]
pub struct CondWindow {
pub window_s: f64,
pub observed_s: f64,
pub samples: u64,
pub dropped: u64,
pub last_sample_ago_s: Option<f64>,
pub last_drop_ago_s: Option<f64>,
pub invalid: u64,
pub checked: u64,
pub qos_mismatched: u64,
pub qos_judged: u64,
pub synthetic: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Eval {
pub state: CondState,
pub evidence: String,
}
#[derive(Debug, Clone)]
pub struct RuleState {
rule: Condition,
state: Option<CondState>,
}
impl RuleState {
pub fn new(rule: Condition) -> RuleState {
RuleState { rule, state: None }
}
pub fn rule(&self) -> &Condition {
&self.rule
}
pub fn state(&self) -> Option<CondState> {
self.state
}
pub fn observe(&mut self, eval: Eval, at: impl Into<String>) -> Option<Transition> {
if self.state == Some(eval.state) {
return None;
}
let from = self.state;
self.state = Some(eval.state);
Some(Transition {
rule: self.rule.to_string(),
from,
to: eval.state,
at: at.into(),
evidence: eval.evidence,
})
}
}
#[derive(Debug, Clone)]
pub struct DoctorWatch {
checks: Vec<RuleState>,
}
impl DoctorWatch {
pub fn new() -> DoctorWatch {
DoctorWatch {
checks: CheckId::ALL
.iter()
.map(|id| RuleState::new(Condition::DoctorCheck { check: *id }))
.collect(),
}
}
pub fn observe(&mut self, outcome: Result<&DoctorReport, &str>, at: &str) -> Vec<Transition> {
self.checks
.iter_mut()
.filter_map(|state| {
let Condition::DoctorCheck { check } = *state.rule() else {
return None;
};
let eval = judge_doctor_check(check, Some(outcome));
state.observe(eval, at)
})
.collect()
}
}
impl Default for DoctorWatch {
fn default() -> Self {
DoctorWatch::new()
}
}
#[derive(Debug, Clone)]
pub struct WatchdogSpec {
pub rules: Vec<Condition>,
pub tick: Duration,
pub ticks: Option<u64>,
pub timeout: Duration,
}
const DECODE_BUDGET: u8 = 2;
pub fn watchdog<'a>(
fleet: &'a crate::Fleet<'a>,
slices: Option<&'a SliceSet>,
store: &'a SchemaStore,
spec: &'a WatchdogSpec,
) -> impl Straw<WatchdogSummary, Transition, Error> + 'a {
sipper(async move |mut sender: sipper::Sender<Transition>| {
use crate::{FleetEvent, StreamItem};
let (session, base) = (fleet.session(), fleet.base());
#[derive(Default, Clone, Copy)]
struct TickCounters {
samples: u64,
invalid: u64,
checked: u64,
qos_mismatched: u64,
qos_judged: u64,
synthetic: u64,
}
struct RuleRuntime {
rule: Condition,
keyexpr: Option<zenoh::key_expr::KeyExpr<'static>>,
counters: TickCounters,
last_sample: Option<tokio::time::Instant>,
state: RuleState,
}
let mut rules: Vec<RuleRuntime> = spec
.rules
.iter()
.map(|rule| {
Ok(RuleRuntime {
rule: rule.clone(),
keyexpr: rule
.selector()
.map(|sel| {
zenoh::key_expr::KeyExpr::try_from(sel.to_string())
.map_err(|e| Error::unaskable_from(format!("{sel:?}"), e))
})
.transpose()?,
counters: TickCounters::default(),
last_sample: None,
state: RuleState::new(rule.clone()),
})
})
.collect::<Result<_>>()?;
let mut watched: Vec<String> = Vec::new();
for rule in &spec.rules {
if let Some(sel) = rule.selector()
&& !watched.iter().any(|s| s == sel)
{
watched.push(sel.to_string());
}
}
let wants_doctor = spec
.rules
.iter()
.any(|r| matches!(r, Condition::DoctorCheck { .. }));
let wants_roster = spec
.rules
.iter()
.any(|r| matches!(r, Condition::OriginDown { .. }));
let wants_decode = spec
.rules
.iter()
.any(|r| matches!(r, Condition::InvalidPayload { .. }));
if wants_decode {
crate::model::decode::prewarm(fleet, store, slices).await;
}
let _sealed = store.seal();
let monitor = crate::Monitor::start(session, crate::MonitorSpec::default()).await?;
let mut events = monitor.events();
let monitor = monitor.watching(&watched).await?;
let started = tokio::time::Instant::now();
let mut last_drop: Option<tokio::time::Instant> = None;
let mut dropped_tick: u64 = 0;
let mut facts_cache = crate::model::facts::FactsCache::default();
let mut decode_budget: BTreeMap<String, u8> = BTreeMap::new();
let mut summary = WatchdogSummary {
ticks: 0,
transitions: 0,
facts_evicted: 0,
};
let mut last_eval = started;
let mut closed = false;
loop {
let deadline = last_eval + spec.tick;
let sweep = async {
let doctor = if wants_doctor {
Some(
crate::judge::doctor::run_doctor(
fleet,
slices,
&crate::judge::doctor::DoctorSpec {
deep: false,
sample: None,
timeout: spec.timeout,
listen: None,
},
)
.await
.map_err(|e| e.to_string()),
)
} else {
None
};
let roster = if wants_roster {
Some(
crate::bus::roster::roster(fleet, spec.timeout)
.await
.map_err(|e| e.to_string()),
)
} else {
None
};
if wants_decode {
crate::model::decode::prewarm(fleet, store, slices).await;
}
(doctor, roster)
};
let mut sweep = std::pin::pin!(sweep);
let mut swept = None;
let tick_over = tokio::time::sleep_until(deadline);
tokio::pin!(tick_over);
while !closed {
let item = tokio::select! {
item = events.recv() => item,
outcome = &mut sweep, if swept.is_none() => {
swept = Some(outcome);
continue;
}
() = &mut tick_over, if swept.is_some() => break,
};
match item {
Some(StreamItem::Event(FleetEvent::Sample(s))) => {
let Ok(key) = zenoh::key_expr::KeyExpr::try_from(s.key.as_str()) else {
continue;
};
let synthetic = s.attachment.as_ref().is_some_and(|a| {
crate::judge::common::is_synthetic_marker(&a.to_bytes())
});
let mut verdict: Option<crate::Verdict> = None;
for rt in rules.iter_mut() {
let Some(sel) = &rt.keyexpr else { continue };
if !sel.intersects(&key) {
continue;
}
rt.counters.samples += 1;
if synthetic {
rt.counters.synthetic += 1;
}
rt.last_sample = Some(tokio::time::Instant::now());
match &rt.rule {
Condition::InvalidPayload { .. } => {
if verdict.is_none() {
let budget =
decode_budget.entry(s.key.clone()).or_default();
if *budget < DECODE_BUDGET {
*budget += 1;
let d = crate::model::decode::decode_sample(
fleet,
store,
slices,
&s.key,
Some(&s.encoding),
&s.payload.to_bytes(),
)
.await;
verdict = Some(d.verdict);
}
}
if let Some(v) = &verdict {
rt.counters.checked += 1;
if !matches!(v, crate::Verdict::Valid) {
rt.counters.invalid += 1;
}
}
}
Condition::QosMismatch { .. } => {
facts_cache.ensure(base, &s.key, slices);
let facts =
facts_cache.get(&s.key).expect("just ensured this key");
if let crate::model::facts::Registration::Registered(sf) =
&facts.registration
&& let Some(profile) = sf.declared_qos()
{
rt.counters.qos_judged += 1;
if !s.qos_matches(profile) {
rt.counters.qos_mismatched += 1;
}
}
}
_ => {}
}
}
}
Some(StreamItem::Dropped(n)) => {
dropped_tick += n;
last_drop = Some(tokio::time::Instant::now());
}
Some(_) => {}
None => closed = true,
}
}
let (doctor_outcome, roster_outcome) = match swept {
Some(outcome) => outcome,
None => sweep.await,
};
let now = tokio::time::Instant::now();
let at = crate::tape::record::rfc3339_now();
for rt in rules.iter_mut() {
let window = CondWindow {
window_s: (now - last_eval).as_secs_f64(),
observed_s: (now - started).as_secs_f64(),
samples: rt.counters.samples,
dropped: dropped_tick,
last_sample_ago_s: rt.last_sample.map(|t| (now - t).as_secs_f64()),
last_drop_ago_s: last_drop.map(|t| (now - t).as_secs_f64()),
invalid: rt.counters.invalid,
checked: rt.counters.checked,
qos_mismatched: rt.counters.qos_mismatched,
qos_judged: rt.counters.qos_judged,
synthetic: rt.counters.synthetic,
};
let eval = rt.rule.judge(&TickEvidence {
window: &window,
doctor: doctor_outcome
.as_ref()
.map(|o| o.as_ref().map_err(String::as_str)),
roster: roster_outcome
.as_ref()
.map(|o| o.as_ref().map_err(String::as_str)),
});
if let Some(transition) = rt.state.observe(eval, &at) {
summary.transitions += 1;
sender.send(transition).await;
}
}
for rt in rules.iter_mut() {
rt.counters = TickCounters::default();
}
dropped_tick = 0;
decode_budget.clear();
summary.ticks += 1;
if closed || spec.ticks.is_some_and(|n| summary.ticks >= n) {
break;
}
last_eval = now;
}
monitor.shutdown().await?;
summary.facts_evicted = facts_cache.evicted();
Ok(summary)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::{DoctorFinding, DoctorSeverity};
fn report_with(checks: &[CheckId]) -> DoctorReport {
DoctorReport {
findings: checks
.iter()
.map(|c| DoctorFinding {
severity: DoctorSeverity::Error,
check: *c,
subject: "s".into(),
evidence: "e".into(),
citation: None,
})
.collect(),
synced: crate::report::Asked::NotAsked,
introspect_answered: 0,
live_producers: 0,
describe_served: 0,
describe_missing: 0,
routers: 0,
router_version: None,
deep: false,
observation: None,
}
}
#[test]
fn the_vocabulary_round_trips_and_is_closed() {
let rules = [
"rate-above v1/*/telemetry/** 5",
"rate-below v1/h-aaaaaaaaaaaa/state/p/health 0.5",
"silent-for v1/*/events/** 30",
"invalid-payload v1/*/state/**",
"qos-mismatch v1/*/telemetry/**",
"doctor slice-sync",
"origin-down h-aaaaaaaaaaaa",
"dropped",
];
for rule in rules {
let parsed = Condition::parse(rule).expect(rule);
assert_eq!(parsed.to_string(), rule, "canonical spelling round-trips");
}
let err = Condition::parse("if rate > 5 then page").unwrap_err();
assert!(err.to_string().contains("closed"), "{err}");
assert!(err.to_string().contains("rate-above"), "{err}");
let err = Condition::parse("doctor no-such-check").unwrap_err();
assert!(err.to_string().contains("slice-sync"), "{err}");
}
#[test]
fn a_drop_under_a_completeness_claim_is_unobservable_never_ok() {
let wire = CondState::from;
assert!(judge_excess(false, 1).is_unobservable());
assert_eq!(wire(judge_excess(false, 0)), CondState::Ok);
assert_eq!(judge_excess(true, 7), Judgement::Established);
assert!(judge_shortfall(true, 1).is_unobservable());
assert_eq!(judge_shortfall(true, 0), Judgement::Established);
assert_eq!(wire(judge_shortfall(false, 9)), CondState::Ok);
let silence = |sample_within, span_observed, drop_free| {
judge_silence(SilenceEvidence {
sample_within,
span_observed,
drop_free,
})
};
assert!(silence(false, true, false).is_unobservable());
assert!(silence(false, false, true).is_unobservable());
assert_eq!(silence(false, true, true), Judgement::Established);
assert_eq!(wire(silence(true, true, false)), CondState::Ok);
}
#[test]
fn cond_state_is_the_documented_projection_of_the_judgement_core() {
assert_eq!(CondState::from(Judgement::Established), CondState::Firing);
assert_eq!(
CondState::from(Judgement::NotEstablished {
reason: "clean".into()
}),
CondState::Ok
);
assert_eq!(
CondState::from(Judgement::NotAsked),
CondState::Unobservable
);
assert_eq!(
CondState::from(Judgement::Unobservable {
reason: "drops".into()
}),
CondState::Unobservable
);
}
#[test]
fn window_judgement_applies_the_drop_rules() {
let rule = Condition::parse("rate-above k/** 1").unwrap();
let base = CondWindow {
window_s: 10.0,
observed_s: 10.0,
..CondWindow::default()
};
let over = CondWindow {
samples: 20,
dropped: 5,
..base
};
assert_eq!(rule.judge_window(&over).unwrap().state, CondState::Firing);
let under_dropped = CondWindow {
samples: 2,
dropped: 5,
..base
};
assert_eq!(
rule.judge_window(&under_dropped).unwrap().state,
CondState::Unobservable
);
let rule = Condition::parse("silent-for k/** 30").unwrap();
let young = CondWindow {
window_s: 5.0,
observed_s: 5.0,
..CondWindow::default()
};
let eval = rule.judge_window(&young).unwrap();
assert_eq!(eval.state, CondState::Unobservable);
assert!(eval.evidence.contains("watched only"), "{}", eval.evidence);
let silent = CondWindow {
window_s: 5.0,
observed_s: 60.0,
..CondWindow::default()
};
assert_eq!(rule.judge_window(&silent).unwrap().state, CondState::Firing);
let recently_dropped = CondWindow {
last_drop_ago_s: Some(10.0),
..silent
};
assert_eq!(
rule.judge_window(&recently_dropped).unwrap().state,
CondState::Unobservable
);
let spoken = CondWindow {
samples: 1,
last_sample_ago_s: Some(3.0),
..silent
};
assert_eq!(rule.judge_window(&spoken).unwrap().state, CondState::Ok);
}
#[test]
fn synthetic_marked_samples_are_said_out_loud() {
let rule = Condition::parse("rate-above k/** 0.1").unwrap();
let w = CondWindow {
window_s: 10.0,
observed_s: 10.0,
samples: 20,
synthetic: 3,
..CondWindow::default()
};
let eval = rule.judge_window(&w).unwrap();
assert!(
eval.evidence.contains("3 synthetic-marked"),
"{}",
eval.evidence
);
}
#[test]
fn transitions_fire_once_per_genuine_change_and_never_per_tick() {
let eval = |state| Eval {
state,
evidence: "e".into(),
};
let mut rs = RuleState::new(Condition::Dropped);
let first = rs.observe(eval(CondState::Ok), "t0").expect("baseline");
assert_eq!(first.rule, "dropped", "the transition renders its rule");
assert_eq!(first.from, None, "the baseline comes from null (O4)");
assert_eq!(first.to, CondState::Ok);
assert!(rs.observe(eval(CondState::Ok), "t1").is_none());
assert!(rs.observe(eval(CondState::Ok), "t2").is_none());
let change = rs.observe(eval(CondState::Firing), "t3").expect("a change");
assert_eq!(change.from, Some(CondState::Ok));
assert_eq!(change.to, CondState::Firing);
assert!(rs.observe(eval(CondState::Firing), "t4").is_none());
}
#[test]
fn transition_json_shape_is_pinned() {
let t = Transition {
rule: "silent-for k/** 30".into(),
from: None,
to: CondState::Unobservable,
at: "2026-08-22T00:00:00Z".into(),
evidence: "watched only 5.0s of a 30.0s silence claim".into(),
};
assert_eq!(
serde_json::to_value(&t).unwrap(),
serde_json::json!({
"rule": "silent-for k/** 30",
"from": null,
"to": "unobservable",
"at": "2026-08-22T00:00:00Z",
"evidence": "watched only 5.0s of a 30.0s silence claim",
})
);
let t = Transition {
from: Some(CondState::Ok),
to: CondState::Firing,
..t
};
let json = serde_json::to_value(&t).unwrap();
assert_eq!(json["from"], "ok");
assert_eq!(json["to"], "firing");
}
#[test]
fn doctor_watch_reports_deltas_not_states() {
let mut watch = DoctorWatch::new();
let clean = report_with(&[]);
let baseline = watch.observe(Ok(&clean), "t0");
assert_eq!(baseline.len(), CheckId::ALL.len());
assert!(baseline.iter().all(|t| t.from.is_none()));
assert!(baseline.iter().all(|t| t.to == CondState::Ok));
assert!(
watch.observe(Ok(&clean), "t1").is_empty(),
"an unchanged run emits nothing"
);
let drifted = report_with(&[CheckId::SchemaDrift, CheckId::SchemaDrift]);
let changes = watch.observe(Ok(&drifted), "t2");
assert_eq!(changes.len(), 1, "only the changed check transitions");
assert_eq!(changes[0].rule, "doctor schema-drift");
assert_eq!(changes[0].to, CondState::Firing);
assert!(changes[0].evidence.contains("2 finding(s)"));
let failed = watch.observe(Err("session lost"), "t3");
assert_eq!(
failed.len(),
CheckId::ALL.len(),
"a failed run is unobservable for every check — never ok"
);
assert!(failed.iter().all(|t| t.to == CondState::Unobservable));
}
}