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