Skip to main content

lemma/planning/
spec_set.rs

1//! Source-level grouping: all specs sharing a name, keyed by effective_from.
2
3use crate::engine::Context;
4use crate::parsing::ast::{DateTimeValue, EffectiveDate, LemmaSpec};
5use std::collections::{BTreeMap, BTreeSet};
6use std::sync::Arc;
7
8// ─── Temporal bound for Option<DateTimeValue> comparisons ────────────
9
10/// Explicit representation of a temporal bound, eliminating the ambiguity
11/// of `Option<DateTimeValue>` where `None` means `-∞` for start bounds
12/// and `+∞` for end bounds.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub(crate) enum TemporalBound {
15    NegInf,
16    At(DateTimeValue),
17    PosInf,
18}
19
20impl PartialOrd for TemporalBound {
21    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
22        Some(self.cmp(other))
23    }
24}
25
26impl Ord for TemporalBound {
27    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
28        use std::cmp::Ordering;
29        match (self, other) {
30            (TemporalBound::NegInf, TemporalBound::NegInf) => Ordering::Equal,
31            (TemporalBound::NegInf, _) => Ordering::Less,
32            (_, TemporalBound::NegInf) => Ordering::Greater,
33            (TemporalBound::PosInf, TemporalBound::PosInf) => Ordering::Equal,
34            (TemporalBound::PosInf, _) => Ordering::Greater,
35            (_, TemporalBound::PosInf) => Ordering::Less,
36            (TemporalBound::At(a), TemporalBound::At(b)) => a.cmp(b),
37        }
38    }
39}
40
41impl TemporalBound {
42    /// Convert an `Option<&DateTimeValue>` used as a start bound (None = -∞).
43    pub(crate) fn from_start(opt: Option<&DateTimeValue>) -> Self {
44        match opt {
45            None => TemporalBound::NegInf,
46            Some(d) => TemporalBound::At(d.clone()),
47        }
48    }
49
50    /// Convert an `Option<&DateTimeValue>` used as an end bound (None = +∞).
51    pub(crate) fn from_end(opt: Option<&DateTimeValue>) -> Self {
52        match opt {
53            None => TemporalBound::PosInf,
54            Some(d) => TemporalBound::At(d.clone()),
55        }
56    }
57
58    /// Convert back to `Option<DateTimeValue>` for a start bound (NegInf → None).
59    pub(crate) fn to_start(&self) -> Option<DateTimeValue> {
60        match self {
61            TemporalBound::NegInf => None,
62            TemporalBound::At(d) => Some(d.clone()),
63            TemporalBound::PosInf => {
64                unreachable!("BUG: PosInf cannot represent a start bound")
65            }
66        }
67    }
68
69    /// Convert back to `Option<DateTimeValue>` for an end bound (PosInf → None).
70    pub(crate) fn to_end(&self) -> Option<DateTimeValue> {
71        match self {
72            TemporalBound::NegInf => {
73                unreachable!("BUG: NegInf cannot represent an end bound")
74            }
75            TemporalBound::At(d) => Some(d.clone()),
76            TemporalBound::PosInf => None,
77        }
78    }
79}
80
81/// All specs sharing a name, keyed by effective_from.
82#[derive(Debug, Clone)]
83pub struct LemmaSpecSet {
84    pub name: String,
85    specs: BTreeMap<EffectiveDate, Arc<LemmaSpec>>,
86}
87
88impl LemmaSpecSet {
89    #[must_use]
90    pub fn new(name: String) -> Self {
91        Self {
92            name,
93            specs: BTreeMap::new(),
94        }
95    }
96
97    #[must_use]
98    pub fn is_empty(&self) -> bool {
99        self.specs.is_empty()
100    }
101
102    #[must_use]
103    pub fn len(&self) -> usize {
104        self.specs.len()
105    }
106
107    #[must_use]
108    pub fn first(&self) -> Option<&Arc<LemmaSpec>> {
109        self.specs.values().next()
110    }
111
112    /// Exact identity by `effective_from` key.
113    #[must_use]
114    pub fn get_exact(&self, effective_from: Option<&DateTimeValue>) -> Option<&Arc<LemmaSpec>> {
115        let key = EffectiveDate::from_option(effective_from.cloned());
116        self.specs.get(&key)
117    }
118
119    /// Insert a spec. Returns `false` if the same `effective_from` already exists.
120    pub fn insert(&mut self, spec: Arc<LemmaSpec>) -> bool {
121        debug_assert_eq!(spec.name, self.name);
122        let key = spec.effective_from.clone();
123        if self.specs.contains_key(&key) {
124            return false;
125        }
126        self.specs.insert(key, spec);
127        true
128    }
129
130    /// Remove by `effective_from` key. Returns whether a row was removed.
131    pub fn remove(&mut self, effective_from: Option<&DateTimeValue>) -> bool {
132        let key = EffectiveDate::from_option(effective_from.cloned());
133        self.specs.remove(&key).is_some()
134    }
135
136    pub fn iter_specs(&self) -> impl Iterator<Item = Arc<LemmaSpec>> + '_ {
137        self.specs.values().cloned()
138    }
139
140    /// Every spec paired with its half-open `[effective_from, effective_to)` range.
141    ///
142    /// - `effective_from = None` on the first row means no earlier version exists.
143    /// - `effective_to = None` on the last row means no successor (this is the
144    ///   latest loaded version; its validity is unbounded forward).
145    /// - Otherwise `effective_to` equals the next row's `effective_from`
146    ///   (exclusive end of this row's validity).
147    ///
148    /// Iteration order matches [`Self::iter_specs`] (ascending by `effective_from`).
149    pub fn iter_with_ranges(
150        &self,
151    ) -> impl Iterator<Item = (Arc<LemmaSpec>, Option<DateTimeValue>, Option<DateTimeValue>)> + '_
152    {
153        self.iter_specs().map(move |spec| {
154            let (effective_from, effective_to) = self.effective_range(&spec);
155            (spec, effective_from, effective_to)
156        })
157    }
158
159    /// Borrowed iteration in key order (for planning loops without allocating a `Vec`).
160    pub fn specs_iter(&self) -> impl Iterator<Item = &Arc<LemmaSpec>> + '_ {
161        self.specs.values()
162    }
163
164    /// Spec active at `effective`. Each spec covers `[effective_from, next.effective_from)`.
165    /// The last spec covers `[effective_from, +∞)`.
166    #[must_use]
167    pub fn spec_at(&self, effective: &EffectiveDate) -> Option<Arc<LemmaSpec>> {
168        self.specs
169            .range(..=effective.clone())
170            .next_back()
171            .map(|(_, spec)| Arc::clone(spec))
172    }
173
174    /// Returns the effective range `[from, to)` for a spec in this set.
175    ///
176    /// - `from`: `spec.effective_from()` (None = -∞)
177    /// - `to`: next temporal version's `effective_from`, or None (+∞) if no successor.
178    pub fn effective_range(
179        &self,
180        spec: &Arc<LemmaSpec>,
181    ) -> (Option<DateTimeValue>, Option<DateTimeValue>) {
182        let from = spec.effective_from().cloned();
183        let key = spec.effective_from.clone();
184        let exact = self.specs.get_key_value(&key).unwrap_or_else(|| {
185            unreachable!(
186                "BUG: effective_range called with spec '{}' not in spec set",
187                spec.name
188            )
189        });
190        let to = self
191            .specs
192            .range((
193                std::ops::Bound::Excluded(exact.0),
194                std::ops::Bound::Unbounded,
195            ))
196            .next()
197            .and_then(|(_, next)| next.effective_from().cloned());
198        (from, to)
199    }
200
201    /// All `effective_from` dates, sorted ascending. Specs without `effective_from` excluded (-∞).
202    #[must_use]
203    pub fn temporal_boundaries(&self) -> Vec<DateTimeValue> {
204        self.specs
205            .values()
206            .filter_map(|s| s.effective_from().cloned())
207            .collect()
208    }
209
210    /// Global effective dates filtered to the `[eff_from, eff_to)` validity range of `spec`.
211    #[must_use]
212    pub fn effective_dates(&self, spec: &Arc<LemmaSpec>, context: &Context) -> Vec<EffectiveDate> {
213        let (from, to) = self.effective_range(spec);
214        let from_key = EffectiveDate::from_option(from);
215        let all_dates: BTreeSet<EffectiveDate> =
216            context.iter().map(|s| s.effective_from.clone()).collect();
217        match to {
218            Some(dt) => all_dates
219                .range(from_key..EffectiveDate::DateTimeValue(dt))
220                .cloned()
221                .collect(),
222            None => all_dates.range(from_key..).cloned().collect(),
223        }
224    }
225
226    /// Gaps where this spec set's specs do not cover `[required_from, required_to)`.
227    ///
228    /// Start: `None` = −∞, end: `None` = +∞. Empty result means full coverage.
229    /// When the set is empty, the entire required range is one gap.
230    #[must_use]
231    pub fn coverage_gaps(
232        &self,
233        required_from: Option<&DateTimeValue>,
234        required_to: Option<&DateTimeValue>,
235    ) -> Vec<(Option<DateTimeValue>, Option<DateTimeValue>)> {
236        let all_specs: Vec<&Arc<LemmaSpec>> = self.specs.values().collect();
237        if all_specs.is_empty() {
238            return vec![(required_from.cloned(), required_to.cloned())];
239        }
240
241        let req_start = TemporalBound::from_start(required_from);
242        let req_end = TemporalBound::from_end(required_to);
243
244        let intervals: Vec<(TemporalBound, TemporalBound)> = all_specs
245            .iter()
246            .enumerate()
247            .map(|(i, v)| {
248                let start = TemporalBound::from_start(v.effective_from());
249                let end = match all_specs.get(i + 1).and_then(|next| next.effective_from()) {
250                    Some(next_from) => TemporalBound::At(next_from.clone()),
251                    None => TemporalBound::PosInf,
252                };
253                (start, end)
254            })
255            .collect();
256
257        let mut gaps = Vec::new();
258        let mut cursor = req_start.clone();
259
260        for (v_start, v_end) in &intervals {
261            if cursor >= req_end {
262                break;
263            }
264
265            if *v_end <= cursor {
266                continue;
267            }
268
269            if *v_start > cursor {
270                let gap_end = std::cmp::min(v_start.clone(), req_end.clone());
271                if cursor < gap_end {
272                    gaps.push((cursor.to_start(), gap_end.to_end()));
273                }
274            }
275
276            if *v_end > cursor {
277                cursor = v_end.clone();
278            }
279        }
280
281        if cursor < req_end {
282            gaps.push((cursor.to_start(), req_end.to_end()));
283        }
284
285        gaps
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::parsing::ast::LemmaSpec;
293
294    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
295        DateTimeValue {
296            year,
297            month,
298            day,
299            hour: 0,
300            minute: 0,
301            second: 0,
302            microsecond: 0,
303            timezone: None,
304        }
305    }
306
307    fn make_spec(name: &str) -> LemmaSpec {
308        LemmaSpec::new(name.to_string())
309    }
310
311    fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
312        let mut spec = LemmaSpec::new(name.to_string());
313        spec.effective_from = EffectiveDate::from_option(effective_from);
314        spec
315    }
316
317    #[test]
318    fn effective_range_unbounded_single_spec() {
319        let mut ss = LemmaSpecSet::new("a".to_string());
320        let spec = Arc::new(make_spec("a"));
321        assert!(ss.insert(Arc::clone(&spec)));
322
323        let (from, to) = ss.effective_range(&spec);
324        assert_eq!(from, None);
325        assert_eq!(to, None);
326    }
327
328    #[test]
329    fn effective_range_soft_end_from_next_spec() {
330        let mut ss = LemmaSpecSet::new("a".to_string());
331        let v1 = Arc::new(make_spec_with_range("a", Some(date(2025, 1, 1))));
332        let v2 = Arc::new(make_spec_with_range("a", Some(date(2025, 6, 1))));
333        assert!(ss.insert(Arc::clone(&v1)));
334        assert!(ss.insert(Arc::clone(&v2)));
335
336        let (from, to) = ss.effective_range(&v1);
337        assert_eq!(from, Some(date(2025, 1, 1)));
338        assert_eq!(to, Some(date(2025, 6, 1)));
339
340        let (from, to) = ss.effective_range(&v2);
341        assert_eq!(from, Some(date(2025, 6, 1)));
342        assert_eq!(to, None);
343    }
344
345    /// `iter_with_ranges` yields each spec paired with its half-open
346    /// `[effective_from, effective_to)` range. Earlier rows end where the
347    /// next row begins; the latest row's `effective_to` is `None`.
348    #[test]
349    fn iter_with_ranges_yields_specs_paired_with_half_open_range() {
350        let mut ss = LemmaSpecSet::new("a".to_string());
351        let earlier = Arc::new(make_spec_with_range("a", Some(date(2025, 1, 1))));
352        let latest = Arc::new(make_spec_with_range("a", Some(date(2025, 6, 1))));
353        assert!(ss.insert(Arc::clone(&earlier)));
354        assert!(ss.insert(Arc::clone(&latest)));
355
356        let entries: Vec<_> = ss.iter_with_ranges().collect();
357        assert_eq!(entries.len(), 2);
358
359        let (spec_0, from_0, to_0) = &entries[0];
360        assert!(Arc::ptr_eq(spec_0, &earlier));
361        assert_eq!(from_0, &Some(date(2025, 1, 1)));
362        assert_eq!(
363            to_0,
364            &Some(date(2025, 6, 1)),
365            "earlier row ends at the next row's effective_from"
366        );
367
368        let (spec_1, from_1, to_1) = &entries[1];
369        assert!(Arc::ptr_eq(spec_1, &latest));
370        assert_eq!(from_1, &Some(date(2025, 6, 1)));
371        assert_eq!(
372            to_1, &None,
373            "latest row has no successor; effective_to is None"
374        );
375    }
376
377    #[test]
378    fn effective_range_unbounded_start_with_successor() {
379        let mut ss = LemmaSpecSet::new("a".to_string());
380        let v1 = Arc::new(make_spec("a"));
381        let v2 = Arc::new(make_spec_with_range("a", Some(date(2025, 3, 1))));
382        assert!(ss.insert(Arc::clone(&v1)));
383        assert!(ss.insert(Arc::clone(&v2)));
384
385        let (from, to) = ss.effective_range(&v1);
386        assert_eq!(from, None);
387        assert_eq!(to, Some(date(2025, 3, 1)));
388    }
389
390    #[test]
391    fn temporal_boundaries_single_spec() {
392        let mut ss = LemmaSpecSet::new("a".to_string());
393        assert!(ss.insert(Arc::new(make_spec("a"))));
394        assert!(ss.temporal_boundaries().is_empty());
395    }
396
397    #[test]
398    fn temporal_boundaries_multiple_specs() {
399        let mut ss = LemmaSpecSet::new("a".to_string());
400        assert!(ss.insert(Arc::new(make_spec("a"))));
401        assert!(ss.insert(Arc::new(make_spec_with_range("a", Some(date(2025, 3, 1))))));
402        assert!(ss.insert(Arc::new(make_spec_with_range("a", Some(date(2025, 6, 1))))));
403
404        assert_eq!(
405            ss.temporal_boundaries(),
406            vec![date(2025, 3, 1), date(2025, 6, 1)]
407        );
408    }
409
410    #[test]
411    fn coverage_empty_set_is_full_gap() {
412        let ss = LemmaSpecSet::new("missing".to_string());
413        let gaps = ss.coverage_gaps(Some(&date(2025, 1, 1)), Some(&date(2025, 6, 1)));
414        assert_eq!(gaps, vec![(Some(date(2025, 1, 1)), Some(date(2025, 6, 1)))]);
415    }
416
417    #[test]
418    fn coverage_single_unbounded_spec_covers_everything() {
419        let mut ss = LemmaSpecSet::new("dep".to_string());
420        assert!(ss.insert(Arc::new(make_spec("dep"))));
421
422        assert!(ss.coverage_gaps(None, None).is_empty());
423        assert!(ss
424            .coverage_gaps(Some(&date(2025, 1, 1)), Some(&date(2025, 12, 1)))
425            .is_empty());
426    }
427
428    #[test]
429    fn coverage_single_spec_with_from_leaves_leading_gap() {
430        let mut ss = LemmaSpecSet::new("dep".to_string());
431        assert!(ss.insert(Arc::new(make_spec_with_range(
432            "dep",
433            Some(date(2025, 3, 1))
434        ))));
435
436        assert_eq!(
437            ss.coverage_gaps(None, None),
438            vec![(None, Some(date(2025, 3, 1)))]
439        );
440    }
441
442    #[test]
443    fn coverage_continuous_specs_no_gaps() {
444        let mut ss = LemmaSpecSet::new("dep".to_string());
445        assert!(ss.insert(Arc::new(make_spec_with_range(
446            "dep",
447            Some(date(2025, 1, 1))
448        ))));
449        assert!(ss.insert(Arc::new(make_spec_with_range(
450            "dep",
451            Some(date(2025, 6, 1))
452        ))));
453
454        assert!(ss
455            .coverage_gaps(Some(&date(2025, 1, 1)), Some(&date(2025, 12, 1)))
456            .is_empty());
457    }
458
459    #[test]
460    fn coverage_dep_starts_after_required_start() {
461        let mut ss = LemmaSpecSet::new("dep".to_string());
462        assert!(ss.insert(Arc::new(make_spec_with_range(
463            "dep",
464            Some(date(2025, 6, 1))
465        ))));
466
467        assert_eq!(
468            ss.coverage_gaps(Some(&date(2025, 1, 1)), Some(&date(2025, 12, 1))),
469            vec![(Some(date(2025, 1, 1)), Some(date(2025, 6, 1)))]
470        );
471    }
472
473    #[test]
474    fn coverage_unbounded_required_range() {
475        let mut ss = LemmaSpecSet::new("dep".to_string());
476        assert!(ss.insert(Arc::new(make_spec_with_range(
477            "dep",
478            Some(date(2025, 6, 1))
479        ))));
480
481        assert_eq!(
482            ss.coverage_gaps(None, None),
483            vec![(None, Some(date(2025, 6, 1)))]
484        );
485    }
486}