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}