Skip to main content

lemma/planning/
spec_set.rs

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