Skip to main content

lemma/planning/
temporal.rs

1use crate::engine::{Context, TemporalBound};
2use crate::parsing::ast::{DateTimeValue, FactValue, LemmaSpec};
3use crate::parsing::source::Source;
4use crate::Error;
5use std::collections::BTreeSet;
6use std::sync::Arc;
7
8/// A temporal slice: an interval within a spec's active range where the
9/// entire transitive dependency tree resolves to the same set of versions.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct TemporalSlice {
12    /// Inclusive start. None = -∞.
13    pub from: Option<DateTimeValue>,
14    /// Exclusive end. None = +∞.
15    pub to: Option<DateTimeValue>,
16}
17
18/// Collect names of implicit (unpinned) spec references with their source locations.
19fn implicit_spec_refs(spec: &LemmaSpec) -> Vec<(String, Source)> {
20    spec.facts
21        .iter()
22        .filter_map(|fact| {
23            if let FactValue::SpecReference(spec_ref) = &fact.value {
24                if spec_ref.hash_pin.is_none() {
25                    return Some((spec_ref.name.clone(), fact.source_location.clone()));
26                }
27            }
28            None
29        })
30        .collect()
31}
32
33/// Collect just the names (for callers that don't need locations).
34fn implicit_spec_ref_names(spec: &LemmaSpec) -> Vec<String> {
35    implicit_spec_refs(spec)
36        .into_iter()
37        .map(|(n, _)| n)
38        .collect()
39}
40
41/// Compute temporal slices for a spec within its effective range.
42///
43/// A slice boundary occurs at every `effective_from` date of a dependency version
44/// that falls strictly within the spec's effective range. Transitive
45/// dependencies are followed recursively (fixed-point) to discover all
46/// boundaries.
47///
48/// Returns sorted, non-overlapping slices that partition the spec's
49/// effective range. For specs without implicit spec refs or without
50/// any version boundaries in range, returns a single slice covering the
51/// full effective range.
52pub fn compute_temporal_slices(spec_arc: &Arc<LemmaSpec>, context: &Context) -> Vec<TemporalSlice> {
53    let (eff_from, eff_to) = context.effective_range(spec_arc);
54    let range_start = TemporalBound::from_start(eff_from.as_ref());
55    let range_end = TemporalBound::from_end(eff_to.as_ref());
56
57    let direct_implicit_names = implicit_spec_ref_names(spec_arc);
58    if direct_implicit_names.is_empty() {
59        return vec![TemporalSlice {
60            from: eff_from,
61            to: eff_to,
62        }];
63    }
64
65    // Fixed-point: collect all boundary points from transitive implicit deps.
66    // We track which spec names we've already visited to avoid cycles.
67    let mut visited_names: BTreeSet<String> = BTreeSet::new();
68    let mut pending_names: Vec<String> = direct_implicit_names;
69    let mut all_boundaries: BTreeSet<DateTimeValue> = BTreeSet::new();
70
71    while let Some(dep_name) = pending_names.pop() {
72        if !visited_names.insert(dep_name.clone()) {
73            continue;
74        }
75
76        let dep_versions: Vec<Arc<LemmaSpec>> =
77            context.iter().filter(|d| d.name == dep_name).collect();
78        if dep_versions.is_empty() {
79            // Missing dep — validate_temporal_coverage already reported an error.
80            // Skip so graph building can still collect additional errors.
81            continue;
82        }
83
84        let boundaries = context.version_boundaries(&dep_name);
85        for boundary in boundaries {
86            let bound = TemporalBound::At(boundary.clone());
87            if bound > range_start && bound < range_end {
88                all_boundaries.insert(boundary);
89            }
90        }
91        for dep_spec in &dep_versions {
92            for transitive_name in implicit_spec_ref_names(dep_spec) {
93                if !visited_names.contains(&transitive_name) {
94                    pending_names.push(transitive_name);
95                }
96            }
97        }
98    }
99
100    if all_boundaries.is_empty() {
101        return vec![TemporalSlice {
102            from: eff_from,
103            to: eff_to,
104        }];
105    }
106
107    // Split the effective range at each boundary point.
108    let mut slices = Vec::new();
109    let mut cursor = eff_from.clone();
110
111    for boundary in &all_boundaries {
112        slices.push(TemporalSlice {
113            from: cursor,
114            to: Some(boundary.clone()),
115        });
116        cursor = Some(boundary.clone());
117    }
118
119    slices.push(TemporalSlice {
120        from: cursor,
121        to: eff_to,
122    });
123
124    slices
125}
126
127/// Validate temporal coverage for all specs in the context.
128///
129/// For each spec, checks that every implicit (unpinned) dependency has
130/// versions that fully cover the spec's effective range. Returns errors
131/// for any dependency that has gaps.
132///
133/// Allows interface evolution: coverage is checked here, and interface
134/// compatibility is validated per-slice during graph building.
135pub fn validate_temporal_coverage(context: &Context) -> Vec<Error> {
136    let mut errors = Vec::new();
137
138    for spec_arc in context.iter() {
139        let (eff_from, eff_to) = context.effective_range(&spec_arc);
140        let dep_refs = implicit_spec_refs(&spec_arc);
141
142        for (dep_name, ref_source) in &dep_refs {
143            let gaps = context.dep_coverage_gaps(dep_name, eff_from.as_ref(), eff_to.as_ref());
144
145            for (gap_start, gap_end) in &gaps {
146                let (message, suggestion) =
147                    format_coverage_gap(&spec_arc.name, dep_name, gap_start, gap_end, &eff_from);
148                errors.push(Error::validation_with_context(
149                    message,
150                    Some(ref_source.clone()),
151                    Some(suggestion),
152                    Some(Arc::clone(&spec_arc)),
153                    None,
154                ));
155            }
156        }
157    }
158
159    errors
160}
161
162fn format_coverage_gap(
163    spec_name: &str,
164    dep_name: &str,
165    gap_start: &Option<DateTimeValue>,
166    gap_end: &Option<DateTimeValue>,
167    spec_from: &Option<DateTimeValue>,
168) -> (String, String) {
169    let message = match (gap_start, gap_end) {
170        (None, Some(end)) => format!(
171            "'{}' depends on '{}', but no version of '{}' is active before {}",
172            spec_name, dep_name, dep_name, end
173        ),
174        (Some(start), None) => format!(
175            "'{}' depends on '{}', but no version of '{}' is active after {}",
176            spec_name, dep_name, dep_name, start
177        ),
178        (Some(start), Some(end)) => format!(
179            "'{}' depends on '{}', but no version of '{}' is active between {} and {}",
180            spec_name, dep_name, dep_name, start, end
181        ),
182        (None, None) => format!(
183            "'{}' depends on '{}', but no version of '{}' exists",
184            spec_name, dep_name, dep_name
185        ),
186    };
187
188    let suggestion = if gap_start.is_none() && gap_end.is_none() && dep_name.starts_with('@') {
189        format!(
190            "Run `lemma get` or `lemma get {}` to fetch this dependency.",
191            dep_name
192        )
193    } else if gap_start.is_none() && spec_from.is_none() {
194        format!(
195            "Add an effective_from date to '{}' so it starts when '{}' is available, \
196             or add an earlier version of '{}'.",
197            spec_name, dep_name, dep_name
198        )
199    } else if gap_end.is_none() {
200        format!(
201            "Add a newer version of '{}' that covers the remaining range.",
202            dep_name
203        )
204    } else {
205        format!(
206            "Add a version of '{}' that covers the gap, \
207             or adjust the effective_from date on '{}'.",
208            dep_name, spec_name
209        )
210    };
211
212    (message, suggestion)
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::parsing::ast::{FactValue, LemmaFact, LemmaSpec, Reference, SpecRef};
219    use crate::parsing::source::Source;
220    use crate::Span;
221
222    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
223        DateTimeValue {
224            year,
225            month,
226            day,
227            hour: 0,
228            minute: 0,
229            second: 0,
230            microsecond: 0,
231            timezone: None,
232        }
233    }
234
235    fn dummy_source() -> Source {
236        Source::new(
237            "test",
238            Span {
239                start: 0,
240                end: 0,
241                line: 0,
242                col: 0,
243            },
244        )
245    }
246
247    fn make_spec(name: &str) -> LemmaSpec {
248        LemmaSpec::new(name.to_string())
249    }
250
251    fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
252        let mut spec = make_spec(name);
253        spec.effective_from = effective_from;
254        spec
255    }
256
257    fn add_spec_ref_fact(spec: &mut LemmaSpec, fact_name: &str, dep_name: &str) {
258        spec.facts.push(LemmaFact {
259            reference: Reference::local(fact_name.to_string()),
260            value: FactValue::SpecReference(SpecRef {
261                name: dep_name.to_string(),
262                from_registry: false,
263                hash_pin: None,
264                effective: None,
265            }),
266            source_location: dummy_source(),
267        });
268    }
269
270    #[test]
271    fn no_deps_produces_single_slice() {
272        let mut ctx = Context::new();
273        let spec = Arc::new(make_spec_with_range("a", Some(date(2025, 1, 1))));
274        ctx.insert_spec(Arc::clone(&spec), false).unwrap();
275
276        let slices = compute_temporal_slices(&spec, &ctx);
277        assert_eq!(slices.len(), 1);
278        assert_eq!(slices[0].from, Some(date(2025, 1, 1)));
279        assert_eq!(slices[0].to, None);
280    }
281
282    #[test]
283    fn single_dep_no_boundary_in_range() {
284        let mut ctx = Context::new();
285        let mut main_spec = make_spec_with_range("main", Some(date(2025, 1, 1)));
286        add_spec_ref_fact(&mut main_spec, "dep", "config");
287        let main_arc = Arc::new(main_spec);
288        ctx.insert_spec(Arc::clone(&main_arc), false).unwrap();
289
290        let config = Arc::new(make_spec("config"));
291        ctx.insert_spec(config, false).unwrap();
292
293        let slices = compute_temporal_slices(&main_arc, &ctx);
294        assert_eq!(slices.len(), 1);
295    }
296
297    #[test]
298    fn single_dep_one_boundary_produces_two_slices() {
299        let mut ctx = Context::new();
300
301        let config_v1 = Arc::new(make_spec("config"));
302        ctx.insert_spec(config_v1, false).unwrap();
303        let config_v2 = Arc::new(make_spec_with_range("config", Some(date(2025, 2, 1))));
304        ctx.insert_spec(config_v2, false).unwrap();
305
306        // main: [Jan 1, +inf) depends on config
307        let mut main_spec = make_spec_with_range("main", Some(date(2025, 1, 1)));
308        add_spec_ref_fact(&mut main_spec, "cfg", "config");
309        let main_arc = Arc::new(main_spec);
310        ctx.insert_spec(Arc::clone(&main_arc), false).unwrap();
311
312        let slices = compute_temporal_slices(&main_arc, &ctx);
313        assert_eq!(slices.len(), 2);
314        assert_eq!(slices[0].from, Some(date(2025, 1, 1)));
315        assert_eq!(slices[0].to, Some(date(2025, 2, 1)));
316        assert_eq!(slices[1].from, Some(date(2025, 2, 1)));
317        assert_eq!(slices[1].to, None);
318    }
319
320    #[test]
321    fn boundary_outside_range_ignored() {
322        let mut ctx = Context::new();
323
324        let config_v1 = Arc::new(make_spec("config"));
325        ctx.insert_spec(config_v1, false).unwrap();
326        let config_v2 = Arc::new(make_spec_with_range("config", Some(date(2025, 6, 1))));
327        ctx.insert_spec(config_v2, false).unwrap();
328
329        // main v1: [Jan 1, Mar 1) — successor main v2 defines the end
330        let main_v1 = make_spec_with_range("main", Some(date(2025, 1, 1)));
331        let main_v2 = make_spec_with_range("main", Some(date(2025, 3, 1)));
332        let mut main_v1 = main_v1;
333        add_spec_ref_fact(&mut main_v1, "cfg", "config");
334        let main_arc = Arc::new(main_v1);
335        ctx.insert_spec(Arc::clone(&main_arc), false).unwrap();
336        ctx.insert_spec(Arc::new(main_v2), false).unwrap();
337
338        let slices = compute_temporal_slices(&main_arc, &ctx);
339        assert_eq!(slices.len(), 1);
340    }
341
342    #[test]
343    fn transitive_dep_boundary_included() {
344        let mut ctx = Context::new();
345
346        let mut config = make_spec("config");
347        add_spec_ref_fact(&mut config, "rates_ref", "rates");
348        ctx.insert_spec(Arc::new(config), false).unwrap();
349
350        let rates_v1 = Arc::new(make_spec("rates"));
351        ctx.insert_spec(rates_v1, false).unwrap();
352        let rates_v2 = Arc::new(make_spec_with_range("rates", Some(date(2025, 2, 1))));
353        ctx.insert_spec(rates_v2, false).unwrap();
354
355        // main: [Jan 1, +inf) depends on config
356        let mut main_spec = make_spec_with_range("main", Some(date(2025, 1, 1)));
357        add_spec_ref_fact(&mut main_spec, "cfg", "config");
358        let main_arc = Arc::new(main_spec);
359        ctx.insert_spec(Arc::clone(&main_arc), false).unwrap();
360
361        let slices = compute_temporal_slices(&main_arc, &ctx);
362        assert_eq!(slices.len(), 2);
363        assert_eq!(slices[0].to, Some(date(2025, 2, 1)));
364        assert_eq!(slices[1].from, Some(date(2025, 2, 1)));
365    }
366
367    #[test]
368    fn unbounded_spec_with_versioned_dep() {
369        let mut ctx = Context::new();
370
371        let dep_v1 = Arc::new(make_spec("dep"));
372        ctx.insert_spec(dep_v1, false).unwrap();
373        let dep_v2 = Arc::new(make_spec_with_range("dep", Some(date(2025, 6, 1))));
374        ctx.insert_spec(dep_v2, false).unwrap();
375
376        let mut main_spec = make_spec("main");
377        add_spec_ref_fact(&mut main_spec, "d", "dep");
378        let main_arc = Arc::new(main_spec);
379        ctx.insert_spec(Arc::clone(&main_arc), false).unwrap();
380
381        let slices = compute_temporal_slices(&main_arc, &ctx);
382        assert_eq!(slices.len(), 2);
383        assert_eq!(slices[0].from, None);
384        assert_eq!(slices[0].to, Some(date(2025, 6, 1)));
385        assert_eq!(slices[1].from, Some(date(2025, 6, 1)));
386        assert_eq!(slices[1].to, None);
387    }
388
389    #[test]
390    fn pinned_ref_does_not_create_boundary() {
391        let mut ctx = Context::new();
392
393        let dep_v1 = Arc::new(make_spec("dep"));
394        ctx.insert_spec(dep_v1, false).unwrap();
395        let dep_v2 = Arc::new(make_spec_with_range("dep", Some(date(2025, 6, 1))));
396        ctx.insert_spec(dep_v2, false).unwrap();
397
398        let mut main_spec = make_spec("main");
399        main_spec.facts.push(LemmaFact {
400            reference: Reference::local("d".to_string()),
401            value: FactValue::SpecReference(SpecRef {
402                name: "dep".to_string(),
403                from_registry: false,
404                hash_pin: Some("abcd1234".to_string()),
405                effective: None,
406            }),
407            source_location: dummy_source(),
408        });
409        let main_arc = Arc::new(main_spec);
410        ctx.insert_spec(Arc::clone(&main_arc), false).unwrap();
411
412        let slices = compute_temporal_slices(&main_arc, &ctx);
413        assert_eq!(slices.len(), 1);
414    }
415}