#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct MetricSample(i64);
impl MetricSample {
pub const SCALE: i64 = 1_000_000;
#[must_use]
pub const fn from_micro_units(value: i64) -> Self {
Self(value)
}
#[must_use]
pub const fn from_units(value: i64) -> Self {
Self(value.saturating_mul(Self::SCALE))
}
#[must_use]
pub const fn as_micro_units(self) -> i64 {
self.0
}
#[must_use]
pub const fn as_units(self) -> i64 {
self.0 / Self::SCALE
}
}
impl From<i64> for MetricSample {
fn from(value: i64) -> Self {
Self::from_units(value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum RuntimeMetricSeries {
ReadyQueueDepth,
WakeToRunLatencyMicros,
CancelStreakReward,
DrainRate,
Custom(u16),
}
impl RuntimeMetricSeries {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ReadyQueueDepth => "ready_queue_depth",
Self::WakeToRunLatencyMicros => "wake_to_run_latency_micros",
Self::CancelStreakReward => "cancel_streak_reward",
Self::DrainRate => "drain_rate",
Self::Custom(_) => "custom",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ChangePointDetectorKind {
PageHinkley,
Cusum,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ChangeDirection {
Increase,
Decrease,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChangePointDetection {
pub series: RuntimeMetricSeries,
pub detector: ChangePointDetectorKind,
pub direction: ChangeDirection,
pub sample_index: u64,
pub sample: MetricSample,
pub statistic: i64,
pub threshold: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChangePointSnapshot {
pub series: RuntimeMetricSeries,
pub detector: ChangePointDetectorKind,
pub sample_count: u64,
pub mean: MetricSample,
pub statistic: i64,
pub threshold: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PageHinkleyConfig {
pub tolerance: MetricSample,
pub threshold: i64,
pub reset_after_detection: bool,
}
impl PageHinkleyConfig {
#[must_use]
pub const fn conservative() -> Self {
Self {
tolerance: MetricSample::from_micro_units(50_000),
threshold: 3 * MetricSample::SCALE,
reset_after_detection: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageHinkleyDetector {
series: RuntimeMetricSeries,
config: PageHinkleyConfig,
sample_count: u64,
mean_micro_units: i64,
cumulative: i64,
min_cumulative: i64,
}
impl PageHinkleyDetector {
#[must_use]
pub const fn new(series: RuntimeMetricSeries, config: PageHinkleyConfig) -> Self {
Self {
series,
config,
sample_count: 0,
mean_micro_units: 0,
cumulative: 0,
min_cumulative: 0,
}
}
#[must_use]
pub const fn series(&self) -> RuntimeMetricSeries {
self.series
}
pub fn update(&mut self, sample: MetricSample) -> Option<ChangePointDetection> {
self.sample_count = self.sample_count.saturating_add(1);
let sample_micro_units = sample.as_micro_units();
self.mean_micro_units =
update_running_mean(self.mean_micro_units, sample_micro_units, self.sample_count);
let centered = sample_micro_units
.saturating_sub(self.mean_micro_units)
.saturating_sub(self.config.tolerance.as_micro_units());
self.cumulative = self.cumulative.saturating_add(centered);
self.min_cumulative = self.min_cumulative.min(self.cumulative);
let statistic = self.cumulative.saturating_sub(self.min_cumulative);
if statistic >= self.config.threshold {
let detection = ChangePointDetection {
series: self.series,
detector: ChangePointDetectorKind::PageHinkley,
direction: ChangeDirection::Increase,
sample_index: self.sample_count,
sample,
statistic,
threshold: self.config.threshold,
};
if self.config.reset_after_detection {
self.reset_at(sample);
}
Some(detection)
} else {
None
}
}
#[must_use]
pub const fn snapshot(&self) -> ChangePointSnapshot {
ChangePointSnapshot {
series: self.series,
detector: ChangePointDetectorKind::PageHinkley,
sample_count: self.sample_count,
mean: MetricSample::from_micro_units(self.mean_micro_units),
statistic: self.cumulative.saturating_sub(self.min_cumulative),
threshold: self.config.threshold,
}
}
fn reset_at(&mut self, sample: MetricSample) {
self.mean_micro_units = sample.as_micro_units();
self.cumulative = 0;
self.min_cumulative = 0;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CusumConfig {
pub baseline: MetricSample,
pub drift: MetricSample,
pub threshold: i64,
pub direction: ChangeDirection,
pub reset_after_detection: bool,
}
impl CusumConfig {
#[must_use]
pub const fn upward(baseline: MetricSample, threshold: i64) -> Self {
Self {
baseline,
drift: MetricSample::from_micro_units(50_000),
threshold,
direction: ChangeDirection::Increase,
reset_after_detection: true,
}
}
#[must_use]
pub const fn downward(baseline: MetricSample, threshold: i64) -> Self {
Self {
baseline,
drift: MetricSample::from_micro_units(50_000),
threshold,
direction: ChangeDirection::Decrease,
reset_after_detection: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CusumDetector {
series: RuntimeMetricSeries,
config: CusumConfig,
sample_count: u64,
statistic: i64,
}
impl CusumDetector {
#[must_use]
pub const fn new(series: RuntimeMetricSeries, config: CusumConfig) -> Self {
Self {
series,
config,
sample_count: 0,
statistic: 0,
}
}
#[must_use]
pub const fn series(&self) -> RuntimeMetricSeries {
self.series
}
pub fn update(&mut self, sample: MetricSample) -> Option<ChangePointDetection> {
self.sample_count = self.sample_count.saturating_add(1);
let residual = match self.config.direction {
ChangeDirection::Increase => sample
.as_micro_units()
.saturating_sub(self.config.baseline.as_micro_units())
.saturating_sub(self.config.drift.as_micro_units()),
ChangeDirection::Decrease => self
.config
.baseline
.as_micro_units()
.saturating_sub(sample.as_micro_units())
.saturating_sub(self.config.drift.as_micro_units()),
};
self.statistic = 0.max(self.statistic.saturating_add(residual));
if self.statistic >= self.config.threshold {
let detection = ChangePointDetection {
series: self.series,
detector: ChangePointDetectorKind::Cusum,
direction: self.config.direction,
sample_index: self.sample_count,
sample,
statistic: self.statistic,
threshold: self.config.threshold,
};
if self.config.reset_after_detection {
self.statistic = 0;
}
Some(detection)
} else {
None
}
}
#[must_use]
pub const fn snapshot(&self) -> ChangePointSnapshot {
ChangePointSnapshot {
series: self.series,
detector: ChangePointDetectorKind::Cusum,
sample_count: self.sample_count,
mean: self.config.baseline,
statistic: self.statistic,
threshold: self.config.threshold,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SeriesDetector {
PageHinkley(PageHinkleyDetector),
Cusum(CusumDetector),
}
impl SeriesDetector {
#[must_use]
pub const fn series(&self) -> RuntimeMetricSeries {
match self {
Self::PageHinkley(detector) => detector.series(),
Self::Cusum(detector) => detector.series(),
}
}
#[must_use]
pub const fn kind(&self) -> ChangePointDetectorKind {
match self {
Self::PageHinkley(_) => ChangePointDetectorKind::PageHinkley,
Self::Cusum(_) => ChangePointDetectorKind::Cusum,
}
}
pub fn update(&mut self, sample: MetricSample) -> Option<ChangePointDetection> {
match self {
Self::PageHinkley(detector) => detector.update(sample),
Self::Cusum(detector) => detector.update(sample),
}
}
#[must_use]
pub const fn snapshot(&self) -> ChangePointSnapshot {
match self {
Self::PageHinkley(detector) => detector.snapshot(),
Self::Cusum(detector) => detector.snapshot(),
}
}
}
impl From<PageHinkleyDetector> for SeriesDetector {
fn from(detector: PageHinkleyDetector) -> Self {
Self::PageHinkley(detector)
}
}
impl From<CusumDetector> for SeriesDetector {
fn from(detector: CusumDetector) -> Self {
Self::Cusum(detector)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangePointSeriesConfig {
PageHinkley {
series: RuntimeMetricSeries,
config: PageHinkleyConfig,
},
Cusum {
series: RuntimeMetricSeries,
config: CusumConfig,
},
}
impl ChangePointSeriesConfig {
#[must_use]
pub const fn page_hinkley(series: RuntimeMetricSeries, config: PageHinkleyConfig) -> Self {
Self::PageHinkley { series, config }
}
#[must_use]
pub const fn cusum(series: RuntimeMetricSeries, config: CusumConfig) -> Self {
Self::Cusum { series, config }
}
#[must_use]
pub const fn series(self) -> RuntimeMetricSeries {
match self {
Self::PageHinkley { series, .. } | Self::Cusum { series, .. } => series,
}
}
#[must_use]
pub const fn kind(self) -> ChangePointDetectorKind {
match self {
Self::PageHinkley { .. } => ChangePointDetectorKind::PageHinkley,
Self::Cusum { .. } => ChangePointDetectorKind::Cusum,
}
}
#[must_use]
pub const fn build_detector(self) -> SeriesDetector {
match self {
Self::PageHinkley { series, config } => {
SeriesDetector::PageHinkley(PageHinkleyDetector::new(series, config))
}
Self::Cusum { series, config } => {
SeriesDetector::Cusum(CusumDetector::new(series, config))
}
}
}
}
impl From<ChangePointSeriesConfig> for SeriesDetector {
fn from(config: ChangePointSeriesConfig) -> Self {
config.build_detector()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangePointMonitorConfig {
pub enabled: bool,
pub series: Vec<ChangePointSeriesConfig>,
}
impl ChangePointMonitorConfig {
#[must_use]
pub const fn disabled() -> Self {
Self {
enabled: false,
series: Vec::new(),
}
}
#[must_use]
pub fn conservative_scheduler_defaults() -> Self {
Self {
enabled: false,
series: vec![
ChangePointSeriesConfig::page_hinkley(
RuntimeMetricSeries::ReadyQueueDepth,
PageHinkleyConfig::conservative(),
),
ChangePointSeriesConfig::page_hinkley(
RuntimeMetricSeries::WakeToRunLatencyMicros,
PageHinkleyConfig::conservative(),
),
ChangePointSeriesConfig::page_hinkley(
RuntimeMetricSeries::CancelStreakReward,
PageHinkleyConfig::conservative(),
),
ChangePointSeriesConfig::page_hinkley(
RuntimeMetricSeries::DrainRate,
PageHinkleyConfig::conservative(),
),
],
}
}
#[must_use]
pub fn with_series(mut self, series: ChangePointSeriesConfig) -> Self {
self.series.push(series);
self
}
#[must_use]
pub fn enable(mut self) -> Self {
self.enabled = true;
self
}
#[must_use]
pub fn disable(mut self) -> Self {
self.enabled = false;
self
}
#[must_use]
pub fn len(&self) -> usize {
self.series.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.series.is_empty()
}
#[must_use]
pub fn build_monitor(&self) -> ChangePointMonitor {
let mut monitor = ChangePointMonitor::new();
for series in &self.series {
monitor.register(series.build_detector());
}
monitor.set_enabled(self.enabled);
monitor
}
}
impl Default for ChangePointMonitorConfig {
fn default() -> Self {
Self::disabled()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ChangePointMonitor {
enabled: bool,
detectors: Vec<SeriesDetector>,
}
impl ChangePointMonitor {
#[must_use]
pub const fn new() -> Self {
Self {
enabled: false,
detectors: Vec::new(),
}
}
#[must_use]
pub fn with_detector(mut self, detector: impl Into<SeriesDetector>) -> Self {
self.detectors.push(detector.into());
self
}
#[must_use]
pub fn enable(mut self) -> Self {
self.enabled = true;
self
}
pub fn register(&mut self, detector: impl Into<SeriesDetector>) {
self.detectors.push(detector.into());
}
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
#[must_use]
pub const fn is_enabled(&self) -> bool {
self.enabled
}
#[must_use]
pub fn len(&self) -> usize {
self.detectors.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.detectors.is_empty()
}
pub fn observe(
&mut self,
series: RuntimeMetricSeries,
sample: MetricSample,
) -> Option<ChangePointDetection> {
if !self.enabled {
return None;
}
let mut first: Option<ChangePointDetection> = None;
for detector in &mut self.detectors {
if detector.series() != series {
continue;
}
first = first.or(detector.update(sample));
}
first
}
#[must_use]
pub fn snapshots(&self) -> Vec<ChangePointSnapshot> {
self.detectors
.iter()
.map(SeriesDetector::snapshot)
.collect()
}
}
fn update_running_mean(current: i64, sample: i64, sample_count: u64) -> i64 {
if sample_count == 1 {
return sample;
}
let delta = i128::from(sample) - i128::from(current);
let next = i128::from(current) + delta / i128::from(sample_count);
next.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64
}
#[cfg(test)]
mod tests {
use super::*;
fn feed_page_hinkley(
detector: &mut PageHinkleyDetector,
samples: &[i64],
) -> Option<ChangePointDetection> {
samples
.iter()
.copied()
.find_map(|sample| detector.update(MetricSample::from_units(sample)))
}
fn feed_cusum(detector: &mut CusumDetector, samples: &[i64]) -> Option<ChangePointDetection> {
samples
.iter()
.copied()
.find_map(|sample| detector.update(MetricSample::from_units(sample)))
}
#[test]
fn metric_sample_uses_deterministic_micro_units() {
let sample = MetricSample::from_units(42);
assert_eq!(sample.as_micro_units(), 42 * MetricSample::SCALE);
assert_eq!(sample.as_units(), 42);
assert_eq!(MetricSample::from_micro_units(1_500_000).as_units(), 1);
}
#[test]
fn page_hinkley_detects_step_increase_after_stable_prefix() {
let mut detector = PageHinkleyDetector::new(
RuntimeMetricSeries::ReadyQueueDepth,
PageHinkleyConfig {
tolerance: MetricSample::from_micro_units(0),
threshold: 10 * MetricSample::SCALE,
reset_after_detection: false,
},
);
let detection = feed_page_hinkley(&mut detector, &[10, 10, 10, 10, 10, 18, 18, 18, 18, 18])
.expect("step increase should cross threshold");
assert_eq!(detection.series, RuntimeMetricSeries::ReadyQueueDepth);
assert_eq!(detection.detector, ChangePointDetectorKind::PageHinkley);
assert_eq!(detection.direction, ChangeDirection::Increase);
assert_eq!(detection.sample_index, 7);
assert!(detection.statistic >= detection.threshold);
}
#[test]
fn page_hinkley_stays_quiet_for_steady_series() {
let mut detector = PageHinkleyDetector::new(
RuntimeMetricSeries::WakeToRunLatencyMicros,
PageHinkleyConfig::conservative(),
);
let detection = feed_page_hinkley(&mut detector, &[40, 40, 41, 40, 41, 40, 41, 40]);
assert!(detection.is_none());
assert_eq!(detector.snapshot().sample_count, 8);
}
#[test]
fn cusum_detects_known_upward_shift() {
let mut detector = CusumDetector::new(
RuntimeMetricSeries::CancelStreakReward,
CusumConfig::upward(MetricSample::from_units(10), 6 * MetricSample::SCALE),
);
let detection = feed_cusum(&mut detector, &[10, 10, 11, 14, 14, 14])
.expect("upward shift should cross threshold");
assert_eq!(detection.detector, ChangePointDetectorKind::Cusum);
assert_eq!(detection.direction, ChangeDirection::Increase);
assert_eq!(detection.sample_index, 5);
assert!(detection.statistic >= detection.threshold);
assert_eq!(detector.snapshot().statistic, 0);
}
#[test]
fn cusum_detects_known_downward_shift() {
let mut detector = CusumDetector::new(
RuntimeMetricSeries::DrainRate,
CusumConfig::downward(MetricSample::from_units(20), 8 * MetricSample::SCALE),
);
let detection = feed_cusum(&mut detector, &[20, 19, 18, 15, 15, 15])
.expect("downward shift should cross threshold");
assert_eq!(detection.series, RuntimeMetricSeries::DrainRate);
assert_eq!(detection.direction, ChangeDirection::Decrease);
assert_eq!(detection.sample_index, 5);
}
#[test]
fn detector_snapshots_are_byte_identical_for_same_series() {
let samples = [3, 3, 4, 9, 9, 10, 10];
let mut left = PageHinkleyDetector::new(
RuntimeMetricSeries::Custom(7),
PageHinkleyConfig::conservative(),
);
let mut right = PageHinkleyDetector::new(
RuntimeMetricSeries::Custom(7),
PageHinkleyConfig::conservative(),
);
for sample in samples {
let left_detection = left.update(MetricSample::from_units(sample));
let right_detection = right.update(MetricSample::from_units(sample));
assert_eq!(left_detection, right_detection);
assert_eq!(left.snapshot(), right.snapshot());
}
}
#[test]
fn page_hinkley_detection_delay_within_documented_window() {
let cases = [
(10_i64, 5_usize, 18_i64, 10_i64, 4_u64),
(5, 6, 25, 8, 3),
(100, 8, 130, 20, 4),
];
for (prefix, prefix_len, post, threshold_units, max_delay) in cases {
let mut detector = PageHinkleyDetector::new(
RuntimeMetricSeries::ReadyQueueDepth,
PageHinkleyConfig {
tolerance: MetricSample::from_micro_units(0),
threshold: threshold_units * MetricSample::SCALE,
reset_after_detection: false,
},
);
for _ in 0..prefix_len {
assert!(detector.update(MetricSample::from_units(prefix)).is_none());
}
let prefix_count = u64::try_from(prefix_len).expect("prefix length fits in u64");
let mut detected_at = None;
for step in 1..=(max_delay + 2) {
if let Some(detection) = detector.update(MetricSample::from_units(post)) {
assert_eq!(detection.direction, ChangeDirection::Increase);
assert_eq!(detection.sample_index, prefix_count + step);
detected_at = Some(step);
break;
}
}
let delay = detected_at.expect("post-shift samples should cross threshold");
assert!(
delay <= max_delay,
"delay {delay} exceeded documented window {max_delay}"
);
}
}
#[test]
fn page_hinkley_detects_gradual_drift() {
let mut detector = PageHinkleyDetector::new(
RuntimeMetricSeries::WakeToRunLatencyMicros,
PageHinkleyConfig {
tolerance: MetricSample::from_micro_units(0),
threshold: 5 * MetricSample::SCALE,
reset_after_detection: false,
},
);
let mut detection = None;
for step in 0..40_i64 {
let value = MetricSample::from_micro_units(40 * MetricSample::SCALE + step * 500_000);
if let Some(found) = detector.update(value) {
detection = Some(found);
break;
}
}
let detection = detection.expect("a sustained upward drift must eventually be detected");
assert_eq!(detection.direction, ChangeDirection::Increase);
}
#[test]
fn steady_corpus_yields_no_false_positives() {
const JITTER: [i64; 8] = [
40_000, -90_000, 10_000, -50_000, 80_000, -20_000, -100_000, 30_000,
];
let series = [
RuntimeMetricSeries::ReadyQueueDepth,
RuntimeMetricSeries::WakeToRunLatencyMicros,
RuntimeMetricSeries::DrainRate,
];
for (seed, metric) in series.into_iter().enumerate() {
let base = 50_i64 * MetricSample::SCALE;
let mut detector = PageHinkleyDetector::new(metric, PageHinkleyConfig::conservative());
for index in 0..256_usize {
let jitter = JITTER[(index + seed) % JITTER.len()];
let value = MetricSample::from_micro_units(base + jitter);
assert!(
detector.update(value).is_none(),
"steady seed {seed} produced a false positive at index {index}"
);
}
}
}
#[test]
fn monitor_disabled_by_default_suppresses_detection() {
let detector = PageHinkleyDetector::new(
RuntimeMetricSeries::ReadyQueueDepth,
PageHinkleyConfig {
tolerance: MetricSample::from_micro_units(0),
threshold: 10 * MetricSample::SCALE,
reset_after_detection: false,
},
);
let mut monitor = ChangePointMonitor::new().with_detector(detector);
assert!(!monitor.is_enabled());
assert_eq!(monitor.len(), 1);
assert!(!monitor.is_empty());
for value in [10, 10, 10, 10, 10, 30, 30, 30, 30, 30] {
assert!(
monitor
.observe(
RuntimeMetricSeries::ReadyQueueDepth,
MetricSample::from_units(value)
)
.is_none()
);
}
monitor.set_enabled(true);
let mut fired = false;
for value in [10, 10, 10, 10, 10, 30, 30, 30, 30, 30] {
if monitor
.observe(
RuntimeMetricSeries::ReadyQueueDepth,
MetricSample::from_units(value),
)
.is_some()
{
fired = true;
break;
}
}
assert!(fired, "enabled monitor must detect the step");
}
#[test]
fn monitor_routes_per_series_and_replays_identically() {
fn build() -> ChangePointMonitor {
ChangePointMonitor::new()
.with_detector(PageHinkleyDetector::new(
RuntimeMetricSeries::ReadyQueueDepth,
PageHinkleyConfig {
tolerance: MetricSample::from_micro_units(0),
threshold: 10 * MetricSample::SCALE,
reset_after_detection: true,
},
))
.with_detector(CusumDetector::new(
RuntimeMetricSeries::DrainRate,
CusumConfig::downward(MetricSample::from_units(20), 8 * MetricSample::SCALE),
))
.enable()
}
let stream = [
(RuntimeMetricSeries::ReadyQueueDepth, 10),
(RuntimeMetricSeries::DrainRate, 20),
(RuntimeMetricSeries::ReadyQueueDepth, 10),
(RuntimeMetricSeries::DrainRate, 19),
(RuntimeMetricSeries::ReadyQueueDepth, 18),
(RuntimeMetricSeries::DrainRate, 15),
(RuntimeMetricSeries::ReadyQueueDepth, 18),
(RuntimeMetricSeries::DrainRate, 15),
(RuntimeMetricSeries::ReadyQueueDepth, 18),
(RuntimeMetricSeries::DrainRate, 15),
];
let run = |mut monitor: ChangePointMonitor| {
stream
.iter()
.filter_map(|&(series, value)| {
monitor.observe(series, MetricSample::from_units(value))
})
.collect::<Vec<_>>()
};
let first = run(build());
let second = run(build());
assert_eq!(
first, second,
"replay from a fresh monitor must be byte-identical"
);
assert!(
!first.is_empty(),
"the interleaved stream should trigger at least one detector"
);
for detection in &first {
assert!(
matches!(
detection.series,
RuntimeMetricSeries::ReadyQueueDepth | RuntimeMetricSeries::DrainRate
),
"unexpected routed series {:?}",
detection.series
);
if detection.series == RuntimeMetricSeries::ReadyQueueDepth {
assert_eq!(detection.detector, ChangePointDetectorKind::PageHinkley);
assert_eq!(detection.direction, ChangeDirection::Increase);
} else {
assert_eq!(detection.detector, ChangePointDetectorKind::Cusum);
assert_eq!(detection.direction, ChangeDirection::Decrease);
}
}
}
#[test]
fn monitor_config_default_is_disabled_and_empty() {
let config = ChangePointMonitorConfig::default();
assert!(!config.enabled);
assert!(config.is_empty());
let mut monitor = config.build_monitor();
assert!(!monitor.is_enabled());
assert!(monitor.is_empty());
assert!(
monitor
.observe(
RuntimeMetricSeries::ReadyQueueDepth,
MetricSample::from_units(100)
)
.is_none()
);
}
#[test]
fn conservative_scheduler_profile_is_installed_but_off() {
let config = ChangePointMonitorConfig::conservative_scheduler_defaults();
assert!(!config.enabled);
assert_eq!(config.len(), 4);
assert_eq!(
config
.series
.iter()
.map(|series| (series.series(), series.kind()))
.collect::<Vec<_>>(),
vec![
(
RuntimeMetricSeries::ReadyQueueDepth,
ChangePointDetectorKind::PageHinkley
),
(
RuntimeMetricSeries::WakeToRunLatencyMicros,
ChangePointDetectorKind::PageHinkley
),
(
RuntimeMetricSeries::CancelStreakReward,
ChangePointDetectorKind::PageHinkley
),
(
RuntimeMetricSeries::DrainRate,
ChangePointDetectorKind::PageHinkley
),
]
);
let mut monitor = config.build_monitor();
let before = monitor.snapshots();
for value in [10, 10, 10, 10, 10, 30, 30, 30, 30, 30] {
assert!(
monitor
.observe(
RuntimeMetricSeries::ReadyQueueDepth,
MetricSample::from_units(value)
)
.is_none()
);
}
assert_eq!(
monitor.snapshots(),
before,
"disabled config must not advance detector state"
);
}
#[test]
fn enabled_scheduler_profile_detects_without_custom_wiring() {
let mut monitor = ChangePointMonitorConfig::conservative_scheduler_defaults()
.enable()
.build_monitor();
assert!(monitor.is_enabled());
assert_eq!(monitor.len(), 4);
let detection = [10, 10, 10, 10, 10, 30, 30, 30, 30, 30]
.into_iter()
.find_map(|value| {
monitor.observe(
RuntimeMetricSeries::ReadyQueueDepth,
MetricSample::from_units(value),
)
});
assert!(
detection.is_some(),
"enabled conservative profile should detect a large step"
);
let Some(detection) = detection else {
return;
};
assert_eq!(detection.series, RuntimeMetricSeries::ReadyQueueDepth);
assert_eq!(detection.detector, ChangePointDetectorKind::PageHinkley);
assert_eq!(detection.direction, ChangeDirection::Increase);
}
#[test]
fn custom_config_can_install_cusum_profile() {
let config = ChangePointMonitorConfig::disabled()
.with_series(ChangePointSeriesConfig::cusum(
RuntimeMetricSeries::DrainRate,
CusumConfig::downward(MetricSample::from_units(20), 8 * MetricSample::SCALE),
))
.enable();
let mut monitor = config.build_monitor();
let detection = [20, 19, 18, 15, 15, 15].into_iter().find_map(|value| {
monitor.observe(
RuntimeMetricSeries::DrainRate,
MetricSample::from_units(value),
)
});
assert!(
detection.is_some(),
"custom CUSUM profile should detect a falling drain rate"
);
let Some(detection) = detection else {
return;
};
assert_eq!(detection.detector, ChangePointDetectorKind::Cusum);
assert_eq!(detection.direction, ChangeDirection::Decrease);
}
}