Skip to main content

lemma/planning/
temporal.rs

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