use std::collections::BTreeSet;
use std::time::Duration;
use crate::Result;
use crate::judge::common::FINDING_CAP;
use crate::judge::condition;
use crate::model::decode::SchemaStore;
use crate::model::examples::Examples;
use crate::model::registry::SliceSet;
use crate::report::{ExpectReport, ExpectVerdict};
use crate::{FleetEvent, Monitor, MonitorSpec, StreamItem, Verdict};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QosCheck {
Declared,
Profile(zenkey::qos::QosProfile),
}
#[derive(Debug, Clone)]
pub struct ExpectSpec {
pub selector: String,
pub within: Duration,
pub count: Option<u64>,
pub rate_min: Option<f64>,
pub rate_max: Option<f64>,
pub valid_payload: bool,
pub qos: Option<QosCheck>,
pub absent: bool,
}
impl Default for ExpectSpec {
fn default() -> Self {
ExpectSpec {
selector: String::new(),
within: Duration::from_secs(30),
count: None,
rate_min: None,
rate_max: None,
valid_payload: false,
qos: None,
absent: false,
}
}
}
pub async fn run_expect(
fleet: &crate::Fleet<'_>,
slices: Option<&SliceSet>,
store: &SchemaStore,
spec: &ExpectSpec,
) -> Result<ExpectReport> {
let _sealed = if spec.valid_payload {
crate::model::decode::prewarm(fleet, store, slices).await;
Some(store.seal())
} else {
None
};
let monitor = Monitor::start(fleet.session(), MonitorSpec::default()).await?;
let mut events = monitor.events();
let monitor = monitor.watching([spec.selector.as_str()]).await?;
let opened = tokio::time::Instant::now();
let deadline = opened + spec.within;
let need = if spec.absent {
0
} else {
spec.count.unwrap_or(1)
};
let no_rate_bounds = spec.rate_min.is_none() && spec.rate_max.is_none();
let mut samples: u64 = 0;
let mut keys: BTreeSet<String> = BTreeSet::new();
let mut dropped: u64 = 0;
let mut violations: Examples<String> = Examples::new(FINDING_CAP);
let mut ended_early = false;
let window_over = tokio::time::sleep_until(deadline);
tokio::pin!(window_over);
loop {
let item = tokio::select! {
item = events.recv() => item,
() = &mut window_over => break,
};
match item {
Some(StreamItem::Event(FleetEvent::Sample(s))) => {
samples += 1;
keys.insert(s.key.clone());
if spec.absent {
violations.push(format!("{}: a sample where none may be", s.key));
continue;
}
if spec.valid_payload {
let d = crate::model::decode::decode_sample(
fleet,
store,
slices,
&s.key,
Some(&s.encoding),
&s.payload.to_bytes(),
)
.await;
match d.verdict {
Verdict::Valid => {}
Verdict::Invalid(errors) => {
violations.push(format!("{}: invalid — {}", s.key, errors.join("; ")))
}
Verdict::NotValidated(reason) => {
violations.push(format!("{}: validity unknowable — {reason}", s.key))
}
}
}
if let Some(check) = spec.qos {
let against = match check {
QosCheck::Profile(p) => Some(p),
QosCheck::Declared => {
match crate::model::facts::describe_key(fleet.base(), &s.key, slices)
.facts
.registration
{
crate::model::facts::Registration::Registered(f) => {
f.declared_qos()
}
_ => None,
}
}
};
match against {
Some(p) if s.qos_matches(p) => {}
Some(p) => violations.push(format!(
"{}: did not ride {} on the wire",
s.key,
p.name()
)),
None => violations.push(format!("{}: no declared profile to ride", s.key)),
}
}
if no_rate_bounds && violations.total() == 0 && samples >= need && need > 0 {
ended_early = true;
break;
}
}
Some(StreamItem::Dropped(n)) => dropped += n,
Some(_) => continue,
None => break,
}
}
monitor.shutdown().await?;
let window = if ended_early {
opened.elapsed()
} else {
spec.within
};
let window_s = window.as_secs_f64();
let rate_hz = (spec.rate_min.is_some() || spec.rate_max.is_some())
.then(|| samples as f64 / spec.within.as_secs_f64());
let mut unmet: Vec<String> = Vec::new();
let mut positive = false;
let violations_total = violations.total() as u64;
if violations_total > 0 {
positive = true;
unmet.push(if spec.absent {
format!("{violations_total} sample(s) observed where none may be")
} else {
format!("{violations_total} sample(s) violated a per-sample requirement")
});
}
let count_short = !spec.absent && samples < need;
if count_short {
unmet.push(format!("{samples} sample(s) observed, {need} required"));
}
let mut rate_short = false;
if let (Some(min), Some(r)) = (spec.rate_min, rate_hz)
&& r < min
{
rate_short = true;
unmet.push(format!("rate {r:.2} Hz below the {min:.2} Hz floor"));
}
if let (Some(max), Some(r)) = (spec.rate_max, rate_hz)
&& r > max
{
positive = true;
unmet.push(format!("rate {r:.2} Hz above the {max:.2} Hz ceiling"));
}
let verdict = if unmet.is_empty() {
let met_states = [
spec.absent.then(|| {
condition::judge_silence(condition::SilenceEvidence {
sample_within: false,
span_observed: true,
drop_free: dropped == 0,
})
}),
spec.rate_max
.map(|_| condition::judge_excess(false, dropped)),
];
if met_states
.into_iter()
.flatten()
.any(|j| j.is_unobservable())
{
unmet.push(format!(
"{dropped} sample(s) dropped while the claim needs completeness (O6)"
));
ExpectVerdict::Impaired
} else {
ExpectVerdict::Met
}
} else if positive {
ExpectVerdict::NotMet
} else {
let shortfall = condition::judge_shortfall(count_short || rate_short, dropped);
if shortfall.is_unobservable() {
unmet.push(format!(
"{dropped} sample(s) dropped — the shortfall may not be real (O6)"
));
}
ExpectVerdict::from(shortfall)
};
Ok(ExpectReport {
selector: spec.selector.clone(),
window_s,
ended_early,
samples,
keys_seen: keys.len(),
dropped,
rate_hz,
violations: violations.into_vec(),
violations_total,
unmet,
verdict,
})
}