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            source_text: "".into(),
246        }
247    }
248
249    fn make_spec(name: &str) -> LemmaSpec {
250        LemmaSpec::new(name.to_string())
251    }
252
253    fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
254        let mut spec = make_spec(name);
255        spec.effective_from = effective_from;
256        spec
257    }
258
259    fn add_spec_ref_fact(spec: &mut LemmaSpec, fact_name: &str, dep_name: &str) {
260        spec.facts.push(LemmaFact {
261            reference: Reference::local(fact_name.to_string()),
262            value: FactValue::SpecReference(SpecRef {
263                name: dep_name.to_string(),
264                is_registry: false,
265                hash_pin: None,
266                effective: None,
267            }),
268            source_location: dummy_source(),
269        });
270    }
271
272    #[test]
273    fn no_deps_produces_single_slice() {
274        let mut ctx = Context::new();
275        let spec = Arc::new(make_spec_with_range("a", Some(date(2025, 1, 1))));
276        ctx.insert_spec(Arc::clone(&spec)).unwrap();
277
278        let slices = compute_temporal_slices(&spec, &ctx);
279        assert_eq!(slices.len(), 1);
280        assert_eq!(slices[0].from, Some(date(2025, 1, 1)));
281        assert_eq!(slices[0].to, None);
282    }
283
284    #[test]
285    fn single_dep_no_boundary_in_range() {
286        let mut ctx = Context::new();
287        let mut main_spec = make_spec_with_range("main", Some(date(2025, 1, 1)));
288        add_spec_ref_fact(&mut main_spec, "dep", "config");
289        let main_arc = Arc::new(main_spec);
290        ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
291
292        let config = Arc::new(make_spec("config"));
293        ctx.insert_spec(config).unwrap();
294
295        let slices = compute_temporal_slices(&main_arc, &ctx);
296        assert_eq!(slices.len(), 1);
297    }
298
299    #[test]
300    fn single_dep_one_boundary_produces_two_slices() {
301        let mut ctx = Context::new();
302
303        let config_v1 = Arc::new(make_spec("config"));
304        ctx.insert_spec(config_v1).unwrap();
305        let config_v2 = Arc::new(make_spec_with_range("config", Some(date(2025, 2, 1))));
306        ctx.insert_spec(config_v2).unwrap();
307
308        // main: [Jan 1, +inf) depends on config
309        let mut main_spec = make_spec_with_range("main", Some(date(2025, 1, 1)));
310        add_spec_ref_fact(&mut main_spec, "cfg", "config");
311        let main_arc = Arc::new(main_spec);
312        ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
313
314        let slices = compute_temporal_slices(&main_arc, &ctx);
315        assert_eq!(slices.len(), 2);
316        assert_eq!(slices[0].from, Some(date(2025, 1, 1)));
317        assert_eq!(slices[0].to, Some(date(2025, 2, 1)));
318        assert_eq!(slices[1].from, Some(date(2025, 2, 1)));
319        assert_eq!(slices[1].to, None);
320    }
321
322    #[test]
323    fn boundary_outside_range_ignored() {
324        let mut ctx = Context::new();
325
326        let config_v1 = Arc::new(make_spec("config"));
327        ctx.insert_spec(config_v1).unwrap();
328        let config_v2 = Arc::new(make_spec_with_range("config", Some(date(2025, 6, 1))));
329        ctx.insert_spec(config_v2).unwrap();
330
331        // main v1: [Jan 1, Mar 1) — successor main v2 defines the end
332        let main_v1 = make_spec_with_range("main", Some(date(2025, 1, 1)));
333        let main_v2 = make_spec_with_range("main", Some(date(2025, 3, 1)));
334        let mut main_v1 = main_v1;
335        add_spec_ref_fact(&mut main_v1, "cfg", "config");
336        let main_arc = Arc::new(main_v1);
337        ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
338        ctx.insert_spec(Arc::new(main_v2)).unwrap();
339
340        let slices = compute_temporal_slices(&main_arc, &ctx);
341        assert_eq!(slices.len(), 1);
342    }
343
344    #[test]
345    fn transitive_dep_boundary_included() {
346        let mut ctx = Context::new();
347
348        let mut config = make_spec("config");
349        add_spec_ref_fact(&mut config, "rates_ref", "rates");
350        ctx.insert_spec(Arc::new(config)).unwrap();
351
352        let rates_v1 = Arc::new(make_spec("rates"));
353        ctx.insert_spec(rates_v1).unwrap();
354        let rates_v2 = Arc::new(make_spec_with_range("rates", Some(date(2025, 2, 1))));
355        ctx.insert_spec(rates_v2).unwrap();
356
357        // main: [Jan 1, +inf) depends on config
358        let mut main_spec = make_spec_with_range("main", Some(date(2025, 1, 1)));
359        add_spec_ref_fact(&mut main_spec, "cfg", "config");
360        let main_arc = Arc::new(main_spec);
361        ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
362
363        let slices = compute_temporal_slices(&main_arc, &ctx);
364        assert_eq!(slices.len(), 2);
365        assert_eq!(slices[0].to, Some(date(2025, 2, 1)));
366        assert_eq!(slices[1].from, Some(date(2025, 2, 1)));
367    }
368
369    #[test]
370    fn unbounded_spec_with_versioned_dep() {
371        let mut ctx = Context::new();
372
373        let dep_v1 = Arc::new(make_spec("dep"));
374        ctx.insert_spec(dep_v1).unwrap();
375        let dep_v2 = Arc::new(make_spec_with_range("dep", Some(date(2025, 6, 1))));
376        ctx.insert_spec(dep_v2).unwrap();
377
378        let mut main_spec = make_spec("main");
379        add_spec_ref_fact(&mut main_spec, "d", "dep");
380        let main_arc = Arc::new(main_spec);
381        ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
382
383        let slices = compute_temporal_slices(&main_arc, &ctx);
384        assert_eq!(slices.len(), 2);
385        assert_eq!(slices[0].from, None);
386        assert_eq!(slices[0].to, Some(date(2025, 6, 1)));
387        assert_eq!(slices[1].from, Some(date(2025, 6, 1)));
388        assert_eq!(slices[1].to, None);
389    }
390
391    #[test]
392    fn pinned_ref_does_not_create_boundary() {
393        let mut ctx = Context::new();
394
395        let dep_v1 = Arc::new(make_spec("dep"));
396        ctx.insert_spec(dep_v1).unwrap();
397        let dep_v2 = Arc::new(make_spec_with_range("dep", Some(date(2025, 6, 1))));
398        ctx.insert_spec(dep_v2).unwrap();
399
400        let mut main_spec = make_spec("main");
401        main_spec.facts.push(LemmaFact {
402            reference: Reference::local("d".to_string()),
403            value: FactValue::SpecReference(SpecRef {
404                name: "dep".to_string(),
405                is_registry: false,
406                hash_pin: Some("abcd1234".to_string()),
407                effective: None,
408            }),
409            source_location: dummy_source(),
410        });
411        let main_arc = Arc::new(main_spec);
412        ctx.insert_spec(Arc::clone(&main_arc)).unwrap();
413
414        let slices = compute_temporal_slices(&main_arc, &ctx);
415        assert_eq!(slices.len(), 1);
416    }
417}