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    use crate::literals::DateGranularity;
319
320    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
321        DateTimeValue {
322            year,
323            month,
324            day,
325            hour: 0,
326            minute: 0,
327            second: 0,
328            microsecond: 0,
329            timezone: None,
330            granularity: DateGranularity::Full,
331        }
332    }
333
334    fn make_spec(name: &str) -> LemmaSpec {
335        LemmaSpec::new(name.to_string())
336    }
337
338    fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
339        let mut spec = LemmaSpec::new(name.to_string());
340        spec.effective_from = EffectiveDate::from_option(effective_from);
341        spec
342    }
343
344    #[test]
345    fn effective_range_unbounded_single_spec() {
346        let mut ss = LemmaSpecSet::new(main_repository(), "a".to_string());
347        let spec = Arc::new(make_spec("a"));
348        assert!(ss.insert(Arc::clone(&spec)));
349
350        let (from, to) = ss.effective_range(&spec);
351        assert_eq!(from, None);
352        assert_eq!(to, None);
353    }
354
355    #[test]
356    fn effective_range_soft_end_from_next_spec() {
357        let mut ss = LemmaSpecSet::new(main_repository(), "a".to_string());
358        let v1 = Arc::new(make_spec_with_range("a", Some(date(2025, 1, 1))));
359        let v2 = Arc::new(make_spec_with_range("a", Some(date(2025, 6, 1))));
360        assert!(ss.insert(Arc::clone(&v1)));
361        assert!(ss.insert(Arc::clone(&v2)));
362
363        let (from, to) = ss.effective_range(&v1);
364        assert_eq!(from, Some(date(2025, 1, 1)));
365        assert_eq!(to, Some(date(2025, 6, 1)));
366
367        let (from, to) = ss.effective_range(&v2);
368        assert_eq!(from, Some(date(2025, 6, 1)));
369        assert_eq!(to, None);
370    }
371
372    /// `iter_with_ranges` yields each spec paired with its half-open
373    /// `[effective_from, effective_to)` range. Earlier rows end where the
374    /// next row begins; the latest row's `effective_to` is `None`.
375    #[test]
376    fn iter_with_ranges_yields_specs_paired_with_half_open_range() {
377        let mut ss = LemmaSpecSet::new(main_repository(), "a".to_string());
378        let earlier = Arc::new(make_spec_with_range("a", Some(date(2025, 1, 1))));
379        let latest = Arc::new(make_spec_with_range("a", Some(date(2025, 6, 1))));
380        assert!(ss.insert(Arc::clone(&earlier)));
381        assert!(ss.insert(Arc::clone(&latest)));
382
383        let entries: Vec<_> = ss.iter_with_ranges().collect();
384        assert_eq!(entries.len(), 2);
385
386        let (spec_0, from_0, to_0) = &entries[0];
387        assert!(Arc::ptr_eq(spec_0, &earlier));
388        assert_eq!(from_0, &Some(date(2025, 1, 1)));
389        assert_eq!(
390            to_0,
391            &Some(date(2025, 6, 1)),
392            "earlier row ends at the next row's effective_from"
393        );
394
395        let (spec_1, from_1, to_1) = &entries[1];
396        assert!(Arc::ptr_eq(spec_1, &latest));
397        assert_eq!(from_1, &Some(date(2025, 6, 1)));
398        assert_eq!(
399            to_1, &None,
400            "latest row has no successor; effective_to is None"
401        );
402    }
403
404    #[test]
405    fn effective_range_unbounded_start_with_successor() {
406        let mut ss = LemmaSpecSet::new(main_repository(), "a".to_string());
407        let v1 = Arc::new(make_spec("a"));
408        let v2 = Arc::new(make_spec_with_range("a", Some(date(2025, 3, 1))));
409        assert!(ss.insert(Arc::clone(&v1)));
410        assert!(ss.insert(Arc::clone(&v2)));
411
412        let (from, to) = ss.effective_range(&v1);
413        assert_eq!(from, None);
414        assert_eq!(to, Some(date(2025, 3, 1)));
415    }
416
417    #[test]
418    fn temporal_boundaries_single_spec() {
419        let mut ss = LemmaSpecSet::new(main_repository(), "a".to_string());
420        assert!(ss.insert(Arc::new(make_spec("a"))));
421        assert!(ss.temporal_boundaries().is_empty());
422    }
423
424    #[test]
425    fn temporal_boundaries_multiple_specs() {
426        let mut ss = LemmaSpecSet::new(main_repository(), "a".to_string());
427        assert!(ss.insert(Arc::new(make_spec("a"))));
428        assert!(ss.insert(Arc::new(make_spec_with_range("a", Some(date(2025, 3, 1))))));
429        assert!(ss.insert(Arc::new(make_spec_with_range("a", Some(date(2025, 6, 1))))));
430
431        assert_eq!(
432            ss.temporal_boundaries(),
433            vec![date(2025, 3, 1), date(2025, 6, 1)]
434        );
435    }
436
437    #[test]
438    fn coverage_empty_set_is_full_gap() {
439        let ss = LemmaSpecSet::new(main_repository(), "missing".to_string());
440        let gaps = ss.coverage_gaps(Some(&date(2025, 1, 1)), Some(&date(2025, 6, 1)));
441        assert_eq!(gaps, vec![(Some(date(2025, 1, 1)), Some(date(2025, 6, 1)))]);
442    }
443
444    #[test]
445    fn coverage_single_unbounded_spec_covers_everything() {
446        let mut ss = LemmaSpecSet::new(main_repository(), "dep".to_string());
447        assert!(ss.insert(Arc::new(make_spec("dep"))));
448
449        assert!(ss.coverage_gaps(None, None).is_empty());
450        assert!(ss
451            .coverage_gaps(Some(&date(2025, 1, 1)), Some(&date(2025, 12, 1)))
452            .is_empty());
453    }
454
455    #[test]
456    fn coverage_single_spec_with_from_leaves_leading_gap() {
457        let mut ss = LemmaSpecSet::new(main_repository(), "dep".to_string());
458        assert!(ss.insert(Arc::new(make_spec_with_range(
459            "dep",
460            Some(date(2025, 3, 1))
461        ))));
462
463        assert_eq!(
464            ss.coverage_gaps(None, None),
465            vec![(None, Some(date(2025, 3, 1)))]
466        );
467    }
468
469    #[test]
470    fn coverage_continuous_specs_no_gaps() {
471        let mut ss = LemmaSpecSet::new(main_repository(), "dep".to_string());
472        assert!(ss.insert(Arc::new(make_spec_with_range(
473            "dep",
474            Some(date(2025, 1, 1))
475        ))));
476        assert!(ss.insert(Arc::new(make_spec_with_range(
477            "dep",
478            Some(date(2025, 6, 1))
479        ))));
480
481        assert!(ss
482            .coverage_gaps(Some(&date(2025, 1, 1)), Some(&date(2025, 12, 1)))
483            .is_empty());
484    }
485
486    #[test]
487    fn coverage_dep_starts_after_required_start() {
488        let mut ss = LemmaSpecSet::new(main_repository(), "dep".to_string());
489        assert!(ss.insert(Arc::new(make_spec_with_range(
490            "dep",
491            Some(date(2025, 6, 1))
492        ))));
493
494        assert_eq!(
495            ss.coverage_gaps(Some(&date(2025, 1, 1)), Some(&date(2025, 12, 1))),
496            vec![(Some(date(2025, 1, 1)), Some(date(2025, 6, 1)))]
497        );
498    }
499
500    #[test]
501    fn coverage_unbounded_required_range() {
502        let mut ss = LemmaSpecSet::new(main_repository(), "dep".to_string());
503        assert!(ss.insert(Arc::new(make_spec_with_range(
504            "dep",
505            Some(date(2025, 6, 1))
506        ))));
507
508        assert_eq!(
509            ss.coverage_gaps(None, None),
510            vec![(None, Some(date(2025, 6, 1)))]
511        );
512    }
513}