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/// All spec versions sharing a (repository, name) identity, keyed by effective_from.
9///
10/// The owning [`LemmaRepository`] is held by `Arc` so the set carries repository identity as
11/// a real memory reference instead of relying on string parsing.
12#[derive(Debug, Clone)]
13pub struct LemmaSpecSet {
14    pub repository: Arc<LemmaRepository>,
15    pub name: String,
16    specs: BTreeMap<EffectiveDate, LemmaSpec>,
17}
18
19impl serde::Serialize for LemmaSpecSet {
20    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
21    where
22        S: serde::Serializer,
23    {
24        use serde::ser::SerializeStruct;
25        let mut state = serializer.serialize_struct("LemmaSpecSet", 3)?;
26        state.serialize_field("repository", &self.repository)?;
27        state.serialize_field("name", &self.name)?;
28        let specs: Vec<_> = self.iter_specs().collect();
29        state.serialize_field("specs", &specs)?;
30        state.end()
31    }
32}
33
34impl LemmaSpecSet {
35    #[must_use]
36    pub fn new(repository: Arc<LemmaRepository>, name: String) -> Self {
37        Self {
38            repository,
39            name,
40            specs: BTreeMap::new(),
41        }
42    }
43
44    #[must_use]
45    pub fn is_empty(&self) -> bool {
46        self.specs.is_empty()
47    }
48
49    /// Exact identity by `effective_from` key.
50    #[must_use]
51    pub fn get_exact(&self, effective_from: Option<&DateTimeValue>) -> Option<&LemmaSpec> {
52        let key = EffectiveDate::from_option(effective_from.cloned());
53        self.specs.get(&key)
54    }
55
56    /// Insert a spec. Returns `false` if the same `effective_from` already exists.
57    pub fn insert(&mut self, spec: LemmaSpec) -> bool {
58        assert_eq!(
59            spec.name, self.name,
60            "BUG: spec name mismatch in LemmaSpecSet::insert"
61        );
62        let key = spec.effective_from.clone();
63        if self.specs.contains_key(&key) {
64            return false;
65        }
66        self.specs.insert(key, spec);
67        true
68    }
69
70    /// Remove by `effective_from` key. Returns whether a row was removed.
71    pub fn remove(&mut self, effective_from: Option<&DateTimeValue>) -> bool {
72        let key = EffectiveDate::from_option(effective_from.cloned());
73        self.specs.remove(&key).is_some()
74    }
75
76    pub fn iter_specs(&self) -> impl Iterator<Item = &LemmaSpec> + '_ {
77        self.specs.values()
78    }
79
80    /// Every spec paired with its half-open `[effective_from, effective_to)` range.
81    ///
82    /// - `effective_from = None` on the first row means no earlier version exists.
83    /// - `effective_to = None` on the last row means no successor (this is the
84    ///   latest loaded version; its validity is unbounded forward).
85    /// - Otherwise `effective_to` equals the next row's `effective_from`
86    ///   (exclusive end of this row's validity).
87    ///
88    /// Iteration order matches [`Self::iter_specs`] (ascending by `effective_from`).
89    pub fn iter_with_ranges(
90        &self,
91    ) -> impl Iterator<Item = (&LemmaSpec, Option<DateTimeValue>, Option<DateTimeValue>)> + '_ {
92        self.iter_specs().map(move |spec| {
93            let (effective_from, effective_to) = self.effective_range(spec);
94            (spec, effective_from, effective_to)
95        })
96    }
97
98    /// Spec active at `effective`. Each spec covers `[effective_from, next.effective_from)`.
99    /// The last spec covers `[effective_from, +∞)`.
100    #[must_use]
101    pub fn spec_at(&self, effective: &EffectiveDate) -> Option<&LemmaSpec> {
102        self.specs
103            .range(..=effective.clone())
104            .next_back()
105            .map(|(_, spec)| spec)
106    }
107
108    /// Returns the effective range `[from, to)` for a spec in this set.
109    ///
110    /// - `from`: `spec.effective_from()` (None = -∞)
111    /// - `to`: next temporal version's `effective_from`, or None (+∞) if no successor.
112    pub fn effective_range(
113        &self,
114        spec: &LemmaSpec,
115    ) -> (Option<DateTimeValue>, Option<DateTimeValue>) {
116        let from = spec.effective_from().cloned();
117        let key = spec.effective_from.clone();
118        let exact = self.specs.get_key_value(&key).unwrap_or_else(|| {
119            unreachable!(
120                "BUG: effective_range called with spec '{}' not in spec set",
121                spec.name
122            )
123        });
124        let to = self
125            .specs
126            .range((
127                std::ops::Bound::Excluded(exact.0),
128                std::ops::Bound::Unbounded,
129            ))
130            .next()
131            .and_then(|(_, next)| next.effective_from().cloned());
132        (from, to)
133    }
134
135    /// Global effective dates filtered to the `[eff_from, eff_to)` validity range of `spec`.
136    #[must_use]
137    pub fn effective_dates(&self, spec: &LemmaSpec, context: &Context) -> Vec<EffectiveDate> {
138        let (from, to) = self.effective_range(spec);
139        let from_key = EffectiveDate::from_option(from);
140        let all_dates: BTreeSet<EffectiveDate> =
141            context.iter().map(|s| s.effective_from.clone()).collect();
142        match to {
143            Some(dt) => all_dates
144                .range(from_key..EffectiveDate::DateTimeValue(dt))
145                .cloned()
146                .collect(),
147            None => all_dates.range(from_key..).cloned().collect(),
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::parsing::ast::LemmaSpec;
156
157    fn main_repository() -> Arc<LemmaRepository> {
158        Arc::new(LemmaRepository::new(None))
159    }
160
161    use crate::literals::DateGranularity;
162
163    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
164        DateTimeValue {
165            year,
166            month,
167            day,
168            hour: 0,
169            minute: 0,
170            second: 0,
171            microsecond: 0,
172            timezone: None,
173            granularity: DateGranularity::Full,
174        }
175    }
176
177    fn make_spec(name: &str) -> LemmaSpec {
178        LemmaSpec::new(name.to_string())
179    }
180
181    fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
182        let mut spec = LemmaSpec::new(name.to_string());
183        spec.effective_from = EffectiveDate::from_option(effective_from);
184        spec
185    }
186
187    /// A `LemmaSpecSet` carries `(repository, name)` identity; inserting a spec
188    /// whose name differs corrupts that identity silently (`spec_at`/`get_exact`
189    /// would return a spec that does not belong to this set). This is an
190    /// invariant violation and must crash, not be accepted.
191    #[test]
192    #[should_panic(expected = "BUG")]
193    fn insert_panics_on_spec_name_mismatch() {
194        let mut ss = LemmaSpecSet::new(main_repository(), "a".to_string());
195        ss.insert(make_spec("b"));
196    }
197
198    #[test]
199    fn effective_range_unbounded_single_spec() {
200        let mut ss = LemmaSpecSet::new(main_repository(), "a".to_string());
201        let spec = make_spec("a");
202        assert!(ss.insert(spec));
203        let stored = ss.get_exact(None).expect("inserted");
204
205        let (from, to) = ss.effective_range(stored);
206        assert_eq!(from, None);
207        assert_eq!(to, None);
208    }
209
210    #[test]
211    fn effective_range_soft_end_from_next_spec() {
212        let mut ss = LemmaSpecSet::new(main_repository(), "a".to_string());
213        assert!(ss.insert(make_spec_with_range("a", Some(date(2025, 1, 1)))));
214        assert!(ss.insert(make_spec_with_range("a", Some(date(2025, 6, 1)))));
215
216        let v1 = ss.get_exact(Some(&date(2025, 1, 1))).expect("v1");
217        let (from, to) = ss.effective_range(v1);
218        assert_eq!(from, Some(date(2025, 1, 1)));
219        assert_eq!(to, Some(date(2025, 6, 1)));
220
221        let v2 = ss.get_exact(Some(&date(2025, 6, 1))).expect("v2");
222        let (from, to) = ss.effective_range(v2);
223        assert_eq!(from, Some(date(2025, 6, 1)));
224        assert_eq!(to, None);
225    }
226
227    /// `iter_with_ranges` yields each spec paired with its half-open
228    /// `[effective_from, effective_to)` range. Earlier rows end where the
229    /// next row begins; the latest row's `effective_to` is `None`.
230    #[test]
231    fn iter_with_ranges_yields_specs_paired_with_half_open_range() {
232        let mut ss = LemmaSpecSet::new(main_repository(), "a".to_string());
233        assert!(ss.insert(make_spec_with_range("a", Some(date(2025, 1, 1)))));
234        assert!(ss.insert(make_spec_with_range("a", Some(date(2025, 6, 1)))));
235
236        let entries: Vec<_> = ss.iter_with_ranges().collect();
237        assert_eq!(entries.len(), 2);
238
239        let (spec_0, from_0, to_0) = &entries[0];
240        assert_eq!(spec_0.effective_from(), Some(&date(2025, 1, 1)));
241        assert_eq!(from_0, &Some(date(2025, 1, 1)));
242        assert_eq!(
243            to_0,
244            &Some(date(2025, 6, 1)),
245            "earlier row ends at the next row's effective_from"
246        );
247
248        let (spec_1, from_1, to_1) = &entries[1];
249        assert_eq!(spec_1.effective_from(), Some(&date(2025, 6, 1)));
250        assert_eq!(from_1, &Some(date(2025, 6, 1)));
251        assert_eq!(
252            to_1, &None,
253            "latest row has no successor; effective_to is None"
254        );
255    }
256
257    #[test]
258    fn effective_range_unbounded_start_with_successor() {
259        let mut ss = LemmaSpecSet::new(main_repository(), "a".to_string());
260        assert!(ss.insert(make_spec("a")));
261        assert!(ss.insert(make_spec_with_range("a", Some(date(2025, 3, 1)))));
262
263        let v1 = ss.get_exact(None).expect("v1");
264        let (from, to) = ss.effective_range(v1);
265        assert_eq!(from, None);
266        assert_eq!(to, Some(date(2025, 3, 1)));
267    }
268}