Skip to main content

edtf_core/
relation.rs

1//! Three-valued temporal relations between EDTF expressions.
2//!
3//! [`Edtf::relation`] answers "how does A relate to B in time?" honestly
4//! under uncertainty: each of the six coarsened Allen relations (*before /
5//! after / overlaps / contains / within / equal*) is reported as
6//! [`Modality::Impossible`], [`Modality::Possible`] (holds for some
7//! completion) or [`Modality::Definite`] (holds for every completion).
8//!
9//! Semantics (decision D23 in `docs/spec-notes.md`): an expression denotes
10//! some nonempty day-interval lying within its [`Edtf::bounds`] region —
11//! the "sometime during" reading. `1985` is a value falling somewhere
12//! within the calendar year, not necessarily spanning the whole of it.
13//! Consequences, documented rather than hidden:
14//!
15//! - Qualification (`?~%`) never moves bounds (ISO 8601-2 §8.4.2 NOTE), so
16//!   `1985?` relates exactly as `1985`.
17//! - Only *before*, *after* and *equal* can ever be Definite: any region
18//!   wider than one day admits single-day completions, so containment or
19//!   overlap can never be forced.
20//! - Interval endpoint linkage is coarsened away: `2004/2005` vs `2004-06`
21//!   reports possibly-before, although every true completion of the
22//!   interval straddles June 2004. Everything flows through the bounds
23//!   region.
24//! - Unknown bounds propagate as possible-everything, never Definite —
25//!   even where the other endpoint could in principle constrain them.
26//! - Bounds are day-granular (time of day refines within a day), so
27//!   same-day datetimes are definitely equal.
28
29use crate::bounds::{is_leap, last_day, Bound, BoundDate};
30use crate::types::Edtf;
31
32/// One of the six coarsened Allen relations between two time regions.
33///
34/// The six are exhaustive and mutually exclusive over concrete
35/// day-intervals: disjoint pairs are `Before`/`After`, coincident pairs are
36/// `Equal`, proper containment (including a shared endpoint) is
37/// `Contains`/`Within`, and partial overlap is `Overlaps`.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
40pub enum Relation {
41    /// A ends before B starts (no shared day).
42    Before,
43    /// A starts after B ends (no shared day).
44    After,
45    /// A and B share days but each also has days the other lacks on
46    /// opposite sides (partial overlap).
47    Overlaps,
48    /// B lies within A without being equal to it.
49    Contains,
50    /// A lies within B without being equal to it.
51    Within,
52    /// A and B cover exactly the same days.
53    Equal,
54}
55
56impl Relation {
57    /// All six relations, in canonical order.
58    pub const ALL: [Relation; 6] = [
59        Relation::Before,
60        Relation::After,
61        Relation::Overlaps,
62        Relation::Contains,
63        Relation::Within,
64        Relation::Equal,
65    ];
66
67    /// The relation that holds of (B, A) whenever `self` holds of (A, B).
68    pub fn converse(self) -> Relation {
69        match self {
70            Relation::Before => Relation::After,
71            Relation::After => Relation::Before,
72            Relation::Contains => Relation::Within,
73            Relation::Within => Relation::Contains,
74            Relation::Overlaps | Relation::Equal => self,
75        }
76    }
77
78    /// Lower-case name: `"before"`, `"after"`, `"overlaps"`, `"contains"`,
79    /// `"within"` or `"equal"`.
80    pub fn as_str(self) -> &'static str {
81        match self {
82            Relation::Before => "before",
83            Relation::After => "after",
84            Relation::Overlaps => "overlaps",
85            Relation::Contains => "contains",
86            Relation::Within => "within",
87            Relation::Equal => "equal",
88        }
89    }
90
91    fn idx(self) -> usize {
92        match self {
93            Relation::Before => 0,
94            Relation::After => 1,
95            Relation::Overlaps => 2,
96            Relation::Contains => 3,
97            Relation::Within => 4,
98            Relation::Equal => 5,
99        }
100    }
101}
102
103/// How firmly a relation holds across the completions of two expressions.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
106pub enum Modality {
107    /// Holds for no pair of completions.
108    Impossible,
109    /// Holds for some pair of completions, but not all.
110    Possible,
111    /// Holds for every pair of completions.
112    Definite,
113}
114
115impl Modality {
116    /// Lower-case name: `"impossible"`, `"possible"` or `"definite"`.
117    pub fn as_str(self) -> &'static str {
118        match self {
119            Modality::Impossible => "impossible",
120            Modality::Possible => "possible",
121            Modality::Definite => "definite",
122        }
123    }
124}
125
126/// The modality of every [`Relation`] between one pair of expressions, as
127/// computed by [`Edtf::relation`].
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
130pub struct Relations {
131    modalities: [Modality; 6],
132}
133
134impl Relations {
135    /// Every relation possible, none definite — the honest answer when a
136    /// bound is unknown.
137    const ALL_POSSIBLE: Relations = Relations {
138        modalities: [Modality::Possible; 6],
139    };
140
141    /// A possible-set becomes modalities: the sole possible relation (when
142    /// there is exactly one) holds for *every* completion pair, because the
143    /// six relations are exhaustive and mutually exclusive.
144    fn from_possible(possible: [bool; 6]) -> Relations {
145        let count = possible.iter().filter(|p| **p).count();
146        let mut modalities = [Modality::Impossible; 6];
147        for (m, p) in modalities.iter_mut().zip(possible) {
148            if p {
149                *m = if count == 1 {
150                    Modality::Definite
151                } else {
152                    Modality::Possible
153                };
154            }
155        }
156        Relations { modalities }
157    }
158
159    /// The modality of one relation.
160    pub fn modality(self, r: Relation) -> Modality {
161        self.modalities[r.idx()]
162    }
163
164    /// True if the relation holds for at least one completion pair
165    /// (i.e. its modality is `Possible` or `Definite`).
166    pub fn is_possible(self, r: Relation) -> bool {
167        self.modality(r) != Modality::Impossible
168    }
169
170    /// True if the relation holds for every completion pair.
171    pub fn is_definite(self, r: Relation) -> bool {
172        self.modality(r) == Modality::Definite
173    }
174
175    /// True if the relation holds for no completion pair.
176    pub fn is_impossible(self, r: Relation) -> bool {
177        self.modality(r) == Modality::Impossible
178    }
179
180    /// The one relation that definitely holds, if any. At most one relation
181    /// can be definite; only `Before`, `After` and `Equal` ever are.
182    pub fn definite(self) -> Option<Relation> {
183        Relation::ALL.into_iter().find(|r| self.is_definite(*r))
184    }
185
186    /// The relations that hold for at least one completion pair, in
187    /// canonical order. Never empty.
188    pub fn possible(self) -> impl Iterator<Item = Relation> {
189        Relation::ALL
190            .into_iter()
191            .filter(move |r| self.is_possible(*r))
192    }
193}
194
195impl core::fmt::Display for Relations {
196    /// Comma-separated non-impossible relations, e.g. `definitely before`
197    /// or `possibly before, possibly overlaps, possibly within`.
198    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
199        let mut first = true;
200        for r in self.possible() {
201            if !first {
202                write!(f, ", ")?;
203            }
204            first = false;
205            let adverb = match self.modality(r) {
206                Modality::Definite => "definitely",
207                _ => "possibly",
208            };
209            write!(f, "{adverb} {}", r.as_str())?;
210        }
211        Ok(())
212    }
213}
214
215/// A bound on the day axis with infinities ordered around concrete days.
216/// `Unknown` is handled before conversion, so no variant is needed for it.
217#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
218enum Ext {
219    NegInf,
220    Day(BoundDate),
221    PosInf,
222}
223
224fn ext(b: Bound) -> Option<Ext> {
225    match b {
226        Bound::NegativeInfinity => Some(Ext::NegInf),
227        Bound::Date(d) => Some(Ext::Day(d)),
228        Bound::PositiveInfinity => Some(Ext::PosInf),
229        Bound::Unknown => None,
230    }
231}
232
233/// The calendar day after `e`; year overflow saturates to `PosInf`, which
234/// only under-reports overlap feasibility at the edge of representable time.
235fn succ(e: Ext) -> Ext {
236    let Ext::Day(d) = e else { return e };
237    if d.day < last_day(d.month, is_leap(d.year)) {
238        Ext::Day(BoundDate {
239            day: d.day + 1,
240            ..d
241        })
242    } else if d.month < 12 {
243        Ext::Day(BoundDate {
244            year: d.year,
245            month: d.month + 1,
246            day: 1,
247        })
248    } else {
249        match d.year.checked_add(1) {
250            Some(year) => Ext::Day(BoundDate {
251                year,
252                month: 1,
253                day: 1,
254            }),
255            None => Ext::PosInf,
256        }
257    }
258}
259
260/// The calendar day before `e`; year underflow saturates to `NegInf`.
261fn pred(e: Ext) -> Ext {
262    let Ext::Day(d) = e else { return e };
263    if d.day > 1 {
264        Ext::Day(BoundDate {
265            day: d.day - 1,
266            ..d
267        })
268    } else if d.month > 1 {
269        let month = d.month - 1;
270        Ext::Day(BoundDate {
271            year: d.year,
272            month,
273            day: last_day(month, is_leap(d.year)),
274        })
275    } else {
276        match d.year.checked_sub(1) {
277            Some(year) => Ext::Day(BoundDate {
278                year,
279                month: 12,
280                day: 31,
281            }),
282            None => Ext::NegInf,
283        }
284    }
285}
286
287/// Can some completion of A start strictly before some completion of B and
288/// end inside it (`a1 < b1 <= a2 < b2`)? Setting `p` to the shared boundary
289/// day (A's end, B's start), feasibility is exactly
290/// `∃p: lo_a < p <= hi_a ∧ lo_b <= p < hi_b`.
291fn half_overlap(lo_a: Ext, hi_a: Ext, lo_b: Ext, hi_b: Ext) -> bool {
292    succ(lo_a).max(lo_b) <= hi_a.min(pred(hi_b))
293}
294
295impl Edtf {
296    /// The three-valued temporal relation between this expression and
297    /// `other`, computed over the two [`Edtf::bounds`] regions (see the
298    /// semantics note in this module's documentation).
299    ///
300    /// ```
301    /// use edtf_core::{Edtf, Modality, Relation};
302    ///
303    /// let a = Edtf::parse("1985~").unwrap();
304    /// let b = Edtf::parse("199X").unwrap();
305    /// assert_eq!(a.relation(&b).definite(), Some(Relation::Before));
306    ///
307    /// let c = Edtf::parse("198X").unwrap();
308    /// let d = Edtf::parse("1985").unwrap();
309    /// // 198X may fall before, on, after or around 1985 — nothing asserted.
310    /// assert_eq!(c.relation(&d).definite(), None);
311    /// assert!(c.relation(&d).is_possible(Relation::Before));
312    /// assert!(c.relation(&d).is_possible(Relation::After));
313    /// assert!(c.relation(&d).is_possible(Relation::Contains));
314    /// ```
315    pub fn relation(&self, other: &Edtf) -> Relations {
316        let a = self.bounds();
317        let b = other.bounds();
318        let (Some(a1), Some(a2), Some(b1), Some(b2)) = (
319            ext(a.earliest),
320            ext(a.latest),
321            ext(b.earliest),
322            ext(b.latest),
323        ) else {
324            return Relations::ALL_POSSIBLE;
325        };
326        let intersects = a1 <= b2 && b1 <= a2;
327        Relations::from_possible([
328            a1 < b2, // Before: some a ends before some b starts.
329            b1 < a2, // After, mirrored.
330            half_overlap(a1, a2, b1, b2) || half_overlap(b1, b2, a1, a2), // Overlaps.
331            intersects && a1 < a2, // Contains: a day of B inside a wider A.
332            intersects && b1 < b2, // Within, mirrored.
333            intersects, // Equal: a shared day serves both.
334        ])
335    }
336}