Skip to main content

iicp_client/
availability.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Time-based availability windows — operator capacity shaping by time-of-day.
3//!
4//! Port of iicp-adapter `scheduling/availability.py` (parity Block D, #340). Lets an
5//! operator dedicate different fractions of `max_concurrent` at different times.
6//!
7//!   - start/end: "HH:MM" in local time.
8//!   - share: fraction of max_concurrent (0.0 = closed, 1.0 = full).
9//!   - Outside all windows: 0.5 (available but not primary).
10//!   - No windows: always 1.0.
11//!
12//! The directory learns live load via heartbeats and scores accordingly (ADR-001).
13
14use chrono::{Local, Timelike};
15
16/// One availability window. `share` is a fraction in [0.0, 1.0].
17#[derive(Debug, Clone, PartialEq)]
18pub struct Window {
19    pub start: String, // "HH:MM"
20    pub end: String,   // "HH:MM"
21    pub share: f64,
22}
23
24/// Evaluates time-based availability windows (local time). No windows → always 1.0.
25#[derive(Debug, Clone, Default)]
26pub struct AvailabilityEvaluator {
27    windows: Vec<Window>,
28}
29
30impl AvailabilityEvaluator {
31    pub fn new(windows: Vec<Window>) -> Self {
32        Self { windows }
33    }
34
35    fn now_hhmm() -> String {
36        let now = Local::now();
37        format!("{:02}:{:02}", now.hour(), now.minute())
38    }
39
40    /// Capacity share [0,1] for the current local time-of-day.
41    pub fn current_share(&self) -> f64 {
42        self.share_at(&Self::now_hhmm())
43    }
44
45    /// Pure core: share for an explicit "HH:MM" (used by tests + `current_share`).
46    pub fn share_at(&self, current: &str) -> f64 {
47        if self.windows.is_empty() {
48            return 1.0;
49        }
50        for w in &self.windows {
51            if w.start <= w.end {
52                if w.start.as_str() <= current && current <= w.end.as_str() {
53                    return w.share;
54                }
55            } else if current >= w.start.as_str() || current <= w.end.as_str() {
56                // Midnight-spanning window (e.g. 22:00–06:00).
57                return w.share;
58            }
59        }
60        0.5 // outside all windows
61    }
62
63    /// Scale `base_max` by the current share (floor 1 when share > 0). A base of 0
64    /// (operator explicitly disabled) stays 0.
65    pub fn effective_max_concurrent(&self, base_max: usize) -> usize {
66        self.effective_at(base_max, &Self::now_hhmm())
67    }
68
69    /// Pure core of [`effective_max_concurrent`] for an explicit "HH:MM".
70    pub fn effective_at(&self, base_max: usize, current: &str) -> usize {
71        if base_max == 0 {
72            return 0;
73        }
74        let share = self.share_at(current);
75        if share <= 0.0 {
76            return 0;
77        }
78        std::cmp::max(1, (base_max as f64 * share) as usize)
79    }
80
81    pub fn is_within_window(&self) -> bool {
82        self.windows.is_empty() || self.current_share() > 0.0
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    fn win(start: &str, end: &str, share: f64) -> Window {
91        Window {
92            start: start.into(),
93            end: end.into(),
94            share,
95        }
96    }
97
98    #[test]
99    fn no_windows_full() {
100        let ev = AvailabilityEvaluator::default();
101        assert_eq!(ev.share_at("03:00"), 1.0);
102        assert_eq!(ev.effective_at(4, "14:00"), 4);
103    }
104
105    #[test]
106    fn inside_normal_window() {
107        let ev = AvailabilityEvaluator::new(vec![win("08:00", "22:00", 0.5)]);
108        assert_eq!(ev.share_at("12:00"), 0.5);
109        assert_eq!(ev.effective_at(4, "12:00"), 2);
110    }
111
112    #[test]
113    fn outside_window_half() {
114        let ev = AvailabilityEvaluator::new(vec![win("08:00", "22:00", 1.0)]);
115        assert_eq!(ev.share_at("02:00"), 0.5);
116    }
117
118    #[test]
119    fn floors_at_one_when_share_positive() {
120        let ev = AvailabilityEvaluator::new(vec![win("08:00", "22:00", 0.1)]);
121        assert_eq!(ev.effective_at(4, "10:00"), 1);
122    }
123
124    #[test]
125    fn base_zero_stays_zero() {
126        let ev = AvailabilityEvaluator::default();
127        assert_eq!(ev.effective_at(0, "10:00"), 0);
128    }
129
130    #[test]
131    fn midnight_spanning_window() {
132        let ev = AvailabilityEvaluator::new(vec![win("22:00", "06:00", 1.0)]);
133        assert_eq!(ev.share_at("23:30"), 1.0);
134        assert_eq!(ev.share_at("02:00"), 1.0);
135        assert_eq!(ev.share_at("12:00"), 0.5);
136    }
137
138    #[test]
139    fn closed_window_zero_capacity() {
140        let ev = AvailabilityEvaluator::new(vec![win("00:00", "23:59", 0.0)]);
141        assert_eq!(ev.effective_at(4, "10:00"), 0);
142    }
143}