Skip to main content

edtf_core/
relation.rs

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