Skip to main content

lemma/planning/
spec_set.rs

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