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        assert!(
79            !dep_versions.is_empty(),
80            "BUG: compute_temporal_slices found implicit dep '{}' with no versions in context — \
81             validate_temporal_coverage should have rejected this",
82            dep_name
83        );
84
85        let boundaries = context.version_boundaries(&dep_name);
86        for boundary in boundaries {
87            let bound = TemporalBound::At(boundary.clone());
88            if bound > range_start && bound < range_end {
89                all_boundaries.insert(boundary);
90            }
91        }
92        for dep_spec in &dep_versions {
93            for transitive_name in implicit_spec_ref_names(dep_spec) {
94                if !visited_names.contains(&transitive_name) {
95                    pending_names.push(transitive_name);
96                }
97            }
98        }
99    }
100
101    if all_boundaries.is_empty() {
102        return vec![TemporalSlice {
103            from: eff_from,
104            to: eff_to,
105        }];
106    }
107
108    // Split the effective range at each boundary point.
109    let mut slices = Vec::new();
110    let mut cursor = eff_from.clone();
111
112    for boundary in &all_boundaries {
113        slices.push(TemporalSlice {
114            from: cursor,
115            to: Some(boundary.clone()),
116        });
117        cursor = Some(boundary.clone());
118    }
119
120    slices.push(TemporalSlice {
121        from: cursor,
122        to: eff_to,
123    });
124
125    slices
126}
127
128/// Validate temporal coverage for all specs in the context.
129///
130/// For each spec, checks that every implicit (unpinned) dependency has
131/// versions that fully cover the spec's effective range. Returns errors
132/// for any dependency that has gaps.
133///
134/// This replaces the old `validate_later_specs_respect_original` which enforced
135/// that all versions of the same name had identical interfaces. The new
136/// approach allows interface evolution — coverage is checked here, and
137/// interface compatibility is validated per-slice during graph building.
138pub fn validate_temporal_coverage(context: &Context) -> Vec<Error> {
139    let mut errors = Vec::new();
140
141    for spec_arc in context.iter() {
142        let (eff_from, eff_to) = context.effective_range(&spec_arc);
143        let dep_refs = implicit_spec_refs(&spec_arc);
144
145        for (dep_name, ref_source) in &dep_refs {
146            let gaps = context.dep_coverage_gaps(dep_name, eff_from.as_ref(), eff_to.as_ref());
147
148            for (gap_start, gap_end) in &gaps {
149                let (message, suggestion) =
150                    format_coverage_gap(&spec_arc.name, dep_name, gap_start, gap_end, &eff_from);
151                errors.push(Error::validation(
152                    message,
153                    Some(ref_source.clone()),
154                    Some(suggestion),
155                ));
156            }
157        }
158    }
159
160    errors
161}
162
163fn format_coverage_gap(
164    spec_name: &str,
165    dep_name: &str,
166    gap_start: &Option<DateTimeValue>,
167    gap_end: &Option<DateTimeValue>,
168    spec_from: &Option<DateTimeValue>,
169) -> (String, String) {
170    let message = match (gap_start, gap_end) {
171        (None, Some(end)) => format!(
172            "'{}' depends on '{}', but no version of '{}' is active before {}",
173            spec_name, dep_name, dep_name, end
174        ),
175        (Some(start), None) => format!(
176            "'{}' depends on '{}', but no version of '{}' is active after {}",
177            spec_name, dep_name, dep_name, start
178        ),
179        (Some(start), Some(end)) => format!(
180            "'{}' depends on '{}', but no version of '{}' is active between {} and {}",
181            spec_name, dep_name, dep_name, start, end
182        ),
183        (None, None) => format!(
184            "'{}' depends on '{}', but no version of '{}' exists",
185            spec_name, dep_name, dep_name
186        ),
187    };
188
189    let suggestion = if gap_start.is_none() && gap_end.is_none() && dep_name.starts_with('@') {
190        format!(
191            "Run `lemma get` or `lemma get @{}` to fetch this dependency.",
192            dep_name
193        )
194    } else if gap_start.is_none() && spec_from.is_none() {
195        format!(
196            "Add an effective_from date to '{}' so it starts when '{}' is available, \
197             or add an earlier version of '{}'.",
198            spec_name, dep_name, dep_name
199        )
200    } else if gap_end.is_none() {
201        format!(
202            "Add a newer version of '{}' that covers the remaining range.",
203            dep_name
204        )
205    } else {
206        format!(
207            "Add a version of '{}' that covers the gap, \
208             or adjust the effective_from date on '{}'.",
209            dep_name, spec_name
210        )
211    };
212
213    (message, suggestion)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::parsing::ast::{FactValue, LemmaFact, LemmaSpec, Reference, SpecRef};
220    use crate::parsing::source::Source;
221    use crate::Span;
222
223    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
224        DateTimeValue {
225            year,
226            month,
227            day,
228            hour: 0,
229            minute: 0,
230            second: 0,
231            microsecond: 0,
232            timezone: None,
233        }
234    }
235
236    fn dummy_source() -> Source {
237        Source {
238            attribute: "test".to_string(),
239            span: Span {
240                start: 0,
241                end: 0,
242                line: 0,
243                col: 0,
244            },
245            spec_name: "test".to_string(),
246            source_text: "".into(),
247        }
248    }
249
250    fn make_spec(name: &str) -> LemmaSpec {
251        LemmaSpec::new(name.to_string())
252    }
253
254    fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
255        let mut spec = make_spec(name);
256        spec.effective_from = effective_from;
257        spec
258    }
259
260    fn add_spec_ref_fact(spec: &mut LemmaSpec, fact_name: &str, dep_name: &str) {
261        spec.facts.push(LemmaFact {
262            reference: Reference::local(fact_name.to_string()),
263            value: FactValue::SpecReference(SpecRef {
264                name: dep_name.to_string(),
265                is_registry: false,
266                hash_pin: None,
267                effective: None,
268            }),
269            source_location: dummy_source(),
270        });
271    }
272
273    #[test]
274    fn no_deps_produces_single_slice() {
275        let mut ctx = Context::new();
276        let spec = Arc::new(make_spec_with_range("a", Some(date(2025, 1, 1))));
277        ctx.insert_spec(Arc::clone(&spec)).unwrap();
278
279        let slices = compute_temporal_slices(&spec, &ctx);
280        assert_eq!(slices.len(), 1);
281        assert_eq!(slices[0].from, Some(date(2025, 1, 1)));
282        assert_eq!(slices[0].to, None);
283    }
284
285    #[test]
286    fn single_dep_no_boundary_in_range() {
287        let mut ctx = Context::new();
288        let mut main_spec = make_spec_with_range("main", Some(date(2025, 1, 1)));
289        add_spec_ref_fact(&mut main_spec, "dep", "config");
290        let main_arc = Arc::new(main_spec);
291        ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
292
293        let config = Arc::new(make_spec("config"));
294        ctx.insert_spec(config).unwrap();
295
296        let slices = compute_temporal_slices(&main_arc, &ctx);
297        assert_eq!(slices.len(), 1);
298    }
299
300    #[test]
301    fn single_dep_one_boundary_produces_two_slices() {
302        let mut ctx = Context::new();
303
304        let config_v1 = Arc::new(make_spec("config"));
305        ctx.insert_spec(config_v1).unwrap();
306        let config_v2 = Arc::new(make_spec_with_range("config", Some(date(2025, 2, 1))));
307        ctx.insert_spec(config_v2).unwrap();
308
309        // main: [Jan 1, +inf) depends on config
310        let mut main_spec = make_spec_with_range("main", Some(date(2025, 1, 1)));
311        add_spec_ref_fact(&mut main_spec, "cfg", "config");
312        let main_arc = Arc::new(main_spec);
313        ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
314
315        let slices = compute_temporal_slices(&main_arc, &ctx);
316        assert_eq!(slices.len(), 2);
317        assert_eq!(slices[0].from, Some(date(2025, 1, 1)));
318        assert_eq!(slices[0].to, Some(date(2025, 2, 1)));
319        assert_eq!(slices[1].from, Some(date(2025, 2, 1)));
320        assert_eq!(slices[1].to, None);
321    }
322
323    #[test]
324    fn boundary_outside_range_ignored() {
325        let mut ctx = Context::new();
326
327        let config_v1 = Arc::new(make_spec("config"));
328        ctx.insert_spec(config_v1).unwrap();
329        let config_v2 = Arc::new(make_spec_with_range("config", Some(date(2025, 6, 1))));
330        ctx.insert_spec(config_v2).unwrap();
331
332        // main v1: [Jan 1, Mar 1) — successor main v2 defines the end
333        let main_v1 = make_spec_with_range("main", Some(date(2025, 1, 1)));
334        let main_v2 = make_spec_with_range("main", Some(date(2025, 3, 1)));
335        let mut main_v1 = main_v1;
336        add_spec_ref_fact(&mut main_v1, "cfg", "config");
337        let main_arc = Arc::new(main_v1);
338        ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
339        ctx.insert_spec(Arc::new(main_v2)).unwrap();
340
341        let slices = compute_temporal_slices(&main_arc, &ctx);
342        assert_eq!(slices.len(), 1);
343    }
344
345    #[test]
346    fn transitive_dep_boundary_included() {
347        let mut ctx = Context::new();
348
349        let mut config = make_spec("config");
350        add_spec_ref_fact(&mut config, "rates_ref", "rates");
351        ctx.insert_spec(Arc::new(config)).unwrap();
352
353        let rates_v1 = Arc::new(make_spec("rates"));
354        ctx.insert_spec(rates_v1).unwrap();
355        let rates_v2 = Arc::new(make_spec_with_range("rates", Some(date(2025, 2, 1))));
356        ctx.insert_spec(rates_v2).unwrap();
357
358        // main: [Jan 1, +inf) depends on config
359        let mut main_spec = make_spec_with_range("main", Some(date(2025, 1, 1)));
360        add_spec_ref_fact(&mut main_spec, "cfg", "config");
361        let main_arc = Arc::new(main_spec);
362        ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
363
364        let slices = compute_temporal_slices(&main_arc, &ctx);
365        assert_eq!(slices.len(), 2);
366        assert_eq!(slices[0].to, Some(date(2025, 2, 1)));
367        assert_eq!(slices[1].from, Some(date(2025, 2, 1)));
368    }
369
370    #[test]
371    fn unbounded_spec_with_versioned_dep() {
372        let mut ctx = Context::new();
373
374        let dep_v1 = Arc::new(make_spec("dep"));
375        ctx.insert_spec(dep_v1).unwrap();
376        let dep_v2 = Arc::new(make_spec_with_range("dep", Some(date(2025, 6, 1))));
377        ctx.insert_spec(dep_v2).unwrap();
378
379        let mut main_spec = make_spec("main");
380        add_spec_ref_fact(&mut main_spec, "d", "dep");
381        let main_arc = Arc::new(main_spec);
382        ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
383
384        let slices = compute_temporal_slices(&main_arc, &ctx);
385        assert_eq!(slices.len(), 2);
386        assert_eq!(slices[0].from, None);
387        assert_eq!(slices[0].to, Some(date(2025, 6, 1)));
388        assert_eq!(slices[1].from, Some(date(2025, 6, 1)));
389        assert_eq!(slices[1].to, None);
390    }
391
392    #[test]
393    fn pinned_ref_does_not_create_boundary() {
394        let mut ctx = Context::new();
395
396        let dep_v1 = Arc::new(make_spec("dep"));
397        ctx.insert_spec(dep_v1).unwrap();
398        let dep_v2 = Arc::new(make_spec_with_range("dep", Some(date(2025, 6, 1))));
399        ctx.insert_spec(dep_v2).unwrap();
400
401        let mut main_spec = make_spec("main");
402        main_spec.facts.push(LemmaFact {
403            reference: Reference::local("d".to_string()),
404            value: FactValue::SpecReference(SpecRef {
405                name: "dep".to_string(),
406                is_registry: false,
407                hash_pin: Some("abcd1234".to_string()),
408                effective: None,
409            }),
410            source_location: dummy_source(),
411        });
412        let main_arc = Arc::new(main_spec);
413        ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
414
415        let slices = compute_temporal_slices(&main_arc, &ctx);
416        assert_eq!(slices.len(), 1);
417    }
418}