gedcomkit 0.1.8__tar.gz → 0.1.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/CHANGELOG.md +26 -0
  2. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/Cargo.lock +1 -1
  3. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/Cargo.toml +1 -1
  4. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/PKG-INFO +1 -1
  5. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/bindings/gedcomkit-py/Cargo.lock +2 -2
  6. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/bindings/gedcomkit-py/Cargo.toml +1 -1
  7. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/src/validate.rs +319 -49
  8. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/LICENSE +0 -0
  9. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/README.md +0 -0
  10. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/bindings/gedcomkit-py/LICENSE +0 -0
  11. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/bindings/gedcomkit-py/README.md +0 -0
  12. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/bindings/gedcomkit-py/gedcomkit.pyi +0 -0
  13. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/bindings/gedcomkit-py/src/lib.rs +0 -0
  14. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom-spec/LICENSE +0 -0
  15. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom-spec/README.md +0 -0
  16. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom-spec/cardinalities.tsv +0 -0
  17. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom-spec/enumerations.tsv +0 -0
  18. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom-spec/enumerationsets.tsv +0 -0
  19. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom-spec/grammar.abnf +0 -0
  20. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom-spec/grammar.gedstruct +0 -0
  21. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom-spec/payloads.tsv +0 -0
  22. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom-spec/substructures.tsv +0 -0
  23. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/age-invalid.ged +0 -0
  24. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/age-valid.ged +0 -0
  25. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/atsign.ged +0 -0
  26. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/char_ascii_1.ged +0 -0
  27. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/char_ascii_2.ged +0 -0
  28. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/char_utf16be-1.ged +0 -0
  29. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/char_utf16be-2.ged +0 -0
  30. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/char_utf16le-1.ged +0 -0
  31. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/char_utf16le-2.ged +0 -0
  32. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/char_utf8-1.ged +0 -0
  33. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/char_utf8-2.ged +0 -0
  34. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/char_utf8-3.ged +0 -0
  35. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/date-all.ged +0 -0
  36. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/date-dual-invalid.ged +0 -0
  37. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/date-dual-valid.ged +0 -0
  38. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/enum-ext.ged +0 -0
  39. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/filename-1.ged +0 -0
  40. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/lang-all.ged +0 -0
  41. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/notes-1.ged +0 -0
  42. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/obje-1.ged +0 -0
  43. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/obsolete-1.ged +0 -0
  44. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/pedi-1.ged +0 -0
  45. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/rela_1.ged +0 -0
  46. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/sour-1.ged +0 -0
  47. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/tiny-1.ged +0 -0
  48. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/5/xref-case.ged +0 -0
  49. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/age-invalid.ged +0 -0
  50. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/age-valid.ged +0 -0
  51. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/atsign.ged +0 -0
  52. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/char_ascii_1.ged +0 -0
  53. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/char_ascii_2.ged +0 -0
  54. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/char_utf16be-1.ged +0 -0
  55. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/char_utf16be-2.ged +0 -0
  56. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/char_utf16le-1.ged +0 -0
  57. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/char_utf16le-2.ged +0 -0
  58. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/char_utf8-1.ged +0 -0
  59. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/char_utf8-2.ged +0 -0
  60. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/char_utf8-3.ged +0 -0
  61. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/date-all.ged +0 -0
  62. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/date-dual-invalid.ged +0 -0
  63. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/enum-ext.ged +0 -0
  64. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/filename-1.ged +0 -0
  65. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/lang-all.ged +0 -0
  66. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/notes-1.ged +0 -0
  67. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/obje-1.ged +0 -0
  68. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/obsolete-1.ged +0 -0
  69. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/pedi-1.ged +0 -0
  70. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/rela_1.ged +0 -0
  71. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/sour-1.ged +0 -0
  72. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/tiny-1.ged +0 -0
  73. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/7/xref-case.ged +0 -0
  74. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/LICENSE +0 -0
  75. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/gedcom7code/UPSTREAM-README.md +0 -0
  76. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/zip-interop/README.md +0 -0
  77. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/zip-interop/python-zip64.gdz +0 -0
  78. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/fixtures/vendored/zip-interop/python-zipfile.gdz +0 -0
  79. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/pyproject.toml +0 -0
  80. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/src/build.rs +0 -0
  81. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/src/convert.rs +0 -0
  82. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/src/dates.rs +0 -0
  83. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/src/decode.rs +0 -0
  84. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/src/family.rs +0 -0
  85. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/src/gedzip.rs +0 -0
  86. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/src/lib.rs +0 -0
  87. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/src/names.rs +0 -0
  88. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/src/schema.rs +0 -0
  89. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/src/synthetic.rs +0 -0
  90. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/src/tags.rs +0 -0
  91. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/src/view.rs +0 -0
  92. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/tests/endurance.rs +0 -0
  93. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/tests/external_charset.rs +0 -0
  94. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/tests/properties.rs +0 -0
  95. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/tests/requirement_corpus.rs +0 -0
  96. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/tests/spec_conformance.rs +0 -0
  97. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/tests/vendored_corpus.rs +0 -0
  98. {gedcomkit-0.1.8 → gedcomkit-0.1.9}/tests/version_conversion.rs +0 -0
@@ -5,6 +5,32 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
  the project uses semantic versioning within the usual pre-1.0 compatibility
6
6
  rules.
7
7
 
8
+ ## [0.1.9] - 2026-09-27
9
+
10
+ ### Fixed
11
+
12
+ - `validate::inspect` drew years from dates the reader could not read: a
13
+ year merely mentioned in an unreadable payload (`WFT Est 1804-1911`) was
14
+ taken as the event's year, contrary to the module's own rule, and made
15
+ contradictions out of nothing. Such dates are now left out of every check.
16
+ - A date that bounds a year rather than naming one (`BEF 1811`, `AFT 1850`,
17
+ `BET 1800 AND 1810`) was compared as if it named its one mentioned year.
18
+ Checks now compare the years a date admits, so a finding is reported only
19
+ when no year both dates allow could be true, and the message says the
20
+ bounds it was drawn from (`born 1807 and died before 1805`; a parent age
21
+ of `at most 10 years old`).
22
+ - A birth dated after a death was reported twice, as `died-before-born` and
23
+ as `event-after-death`; it is now reported once.
24
+ - A Hebrew or French Republican year was compared as if it were Gregorian;
25
+ such dates are now left out of the checks. A range written backwards
26
+ (`BET 1910 AND 1900`) admits the same years as one written forwards.
27
+ - `child-born-after-mother-died` was skipped whenever the mother's own birth
28
+ year was unknown, although it needs only her death and the child's birth;
29
+ it now runs whenever those two are known, and alongside a
30
+ `parent-too-young` finding rather than instead of one. A child born before
31
+ its parent is worded as such (`5 years before the parent was born`), and a
32
+ lifespan between two exact years is stated exactly.
33
+
8
34
  ## [0.1.8] - 2026-09-27
9
35
 
10
36
  ### Added
@@ -35,7 +35,7 @@ dependencies = [
35
35
 
36
36
  [[package]]
37
37
  name = "gedcomkit"
38
- version = "0.1.8"
38
+ version = "0.1.9"
39
39
  dependencies = [
40
40
  "flate2",
41
41
  "serde",
@@ -3,7 +3,7 @@
3
3
  # was checked on crates.io, npm, and PyPI before the initial release.
4
4
  [package]
5
5
  name = "gedcomkit"
6
- version = "0.1.8"
6
+ version = "0.1.9"
7
7
  description = "A byte-preserving GEDCOM document model: decoding, parsing, readings, version conversion, plausibility checks, and the GEDZIP container, for GEDCOM 5.5 through 7.x."
8
8
  edition = "2024"
9
9
  # The true minimum, not the workspace's toolchain: a parser library is a leaf
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gedcomkit
3
- Version: 0.1.8
3
+ Version: 0.1.9
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: Programming Language :: Python :: Implementation :: CPython
@@ -35,7 +35,7 @@ dependencies = [
35
35
 
36
36
  [[package]]
37
37
  name = "gedcomkit"
38
- version = "0.1.8"
38
+ version = "0.1.9"
39
39
  dependencies = [
40
40
  "flate2",
41
41
  "serde",
@@ -43,7 +43,7 @@ dependencies = [
43
43
 
44
44
  [[package]]
45
45
  name = "gedcomkit-py"
46
- version = "0.1.8"
46
+ version = "0.1.9"
47
47
  dependencies = [
48
48
  "gedcomkit",
49
49
  "pyo3",
@@ -2,7 +2,7 @@
2
2
  # abi3 keeps it to one wheel per platform across Python versions.
3
3
  [package]
4
4
  name = "gedcomkit-py"
5
- version = "0.1.8"
5
+ version = "0.1.9"
6
6
  description = "Python bindings for gedcomkit: an archival-grade GEDCOM library — byte-preserving round-trip, encoding detection, GEDCOM 5.5 through 7.x, GEDZIP."
7
7
  edition = "2024"
8
8
  rust-version = "1.88"
@@ -20,12 +20,18 @@
20
20
  //! **A date nobody can read is not a contradiction.** Half the dates in a real
21
21
  //! file are approximate, and a check that treats "about 1850" as a claim about
22
22
  //! a particular day produces a page of findings nobody will read twice. Only
23
- //! years are compared, and only where a year was actually recovered.
24
-
25
- use crate::dates::GedcomDate;
23
+ //! years are compared, and only where the date reader recovered a reading: a
24
+ //! year merely *mentioned* in an unreadable payload (`WFT Est 1804-1911`) says
25
+ //! nothing about when the event happened. A date that bounds a year rather
26
+ //! than naming one — `BEF 1811`, `AFT 1850`, `BET 1804 AND 1911` — is
27
+ //! compared by its bounds, so it contradicts another only when no year it
28
+ //! admits could be true.
29
+
30
+ use crate::dates::{Calendar, GedcomDate};
26
31
  use crate::view::{FamilyView, IndividualView, record_title};
27
32
  use crate::{Document, Node};
28
33
  use std::collections::HashMap;
34
+ use std::fmt;
29
35
 
30
36
  /// How much a finding is claiming.
31
37
  #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
@@ -81,12 +87,81 @@ const OLDEST_FATHER: i32 = 80;
81
87
  /// A parent younger than this at a birth is almost always a mis-keyed year.
82
88
  const YOUNGEST_PARENT: i32 = 12;
83
89
 
90
+ /// The years a date admits: its earliest and latest, either open when the
91
+ /// date bounds only one side (`BEF 1811` has no earliest).
92
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
93
+ struct Years {
94
+ earliest: Option<i32>,
95
+ latest: Option<i32>,
96
+ }
97
+
98
+ impl Years {
99
+ /// The years a `DATE` payload admits, or `None` when the reader could not
100
+ /// read it — a year that merely appears in unreadable text is not used.
101
+ ///
102
+ /// Only Gregorian and Julian years are comparable with each other; a
103
+ /// Hebrew or French Republican year counts from another epoch, so such a
104
+ /// date is left out rather than compared as if it were Gregorian. A range
105
+ /// written backwards (`BET 1910 AND 1900`) admits the same years as one
106
+ /// written forwards.
107
+ fn of(payload: &str) -> Option<Self> {
108
+ let date = GedcomDate::parse(payload);
109
+ if !date.is_parsed() || !matches!(date.calendar, Calendar::Gregorian | Calendar::Julian) {
110
+ return None;
111
+ }
112
+ let mut years = Self {
113
+ earliest: date.earliest.map(|point| point.year),
114
+ latest: date.latest.map(|point| point.year),
115
+ };
116
+ if let (Some(start), Some(end)) = (years.earliest, years.latest)
117
+ && start > end
118
+ {
119
+ years = Self {
120
+ earliest: Some(end),
121
+ latest: Some(start),
122
+ };
123
+ }
124
+ (years.earliest.is_some() || years.latest.is_some()).then_some(years)
125
+ }
126
+
127
+ /// Whether every year this admits comes before every year `other` admits.
128
+ const fn wholly_before(self, other: Self) -> bool {
129
+ matches!((self.latest, other.earliest), (Some(end), Some(start)) if end < start)
130
+ }
131
+
132
+ /// The smallest and largest number of years from `self` to `later` that
133
+ /// the two dates allow, each `None` when a bound is open.
134
+ fn gap_to(self, later: Self) -> (Option<i32>, Option<i32>) {
135
+ let least = later
136
+ .earliest
137
+ .zip(self.latest)
138
+ .map(|(end, start)| end - start);
139
+ let most = later
140
+ .latest
141
+ .zip(self.earliest)
142
+ .map(|(end, start)| end - start);
143
+ (least, most)
144
+ }
145
+ }
146
+
147
+ impl fmt::Display for Years {
148
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149
+ match (self.earliest, self.latest) {
150
+ (Some(start), Some(end)) if start == end => write!(formatter, "{start}"),
151
+ (Some(start), Some(end)) => write!(formatter, "between {start} and {end}"),
152
+ (None, Some(end)) => write!(formatter, "before {end}"),
153
+ (Some(start), None) => write!(formatter, "after {start}"),
154
+ (None, None) => formatter.write_str("undated"),
155
+ }
156
+ }
157
+ }
158
+
84
159
  /// What one person's record says about when they lived.
85
160
  #[derive(Clone, Copy, Default)]
86
161
  struct Life {
87
- born: Option<i32>,
88
- died: Option<i32>,
89
- buried: Option<i32>,
162
+ born: Option<Years>,
163
+ died: Option<Years>,
164
+ buried: Option<Years>,
90
165
  }
91
166
 
92
167
  /// Every finding in the document, in record order.
@@ -119,12 +194,12 @@ pub fn inspect(document: &Document) -> Vec<Finding> {
119
194
  found
120
195
  }
121
196
 
122
- fn year_of(view: &IndividualView, tag: &str) -> Option<i32> {
197
+ fn year_of(view: &IndividualView, tag: &str) -> Option<Years> {
123
198
  view.events
124
199
  .iter()
125
200
  .find(|event| event.tag == tag)
126
201
  .and_then(|event| event.date.as_deref())
127
- .and_then(|payload| GedcomDate::parse(payload).year_hint())
202
+ .and_then(Years::of)
128
203
  }
129
204
 
130
205
  fn life_of(view: &IndividualView) -> Life {
@@ -162,7 +237,7 @@ fn inspect_person(
162
237
  .unwrap_or_default();
163
238
 
164
239
  if let (Some(born), Some(died)) = (life.born, life.died) {
165
- if died < born {
240
+ if died.wholly_before(born) {
166
241
  add(
167
242
  found,
168
243
  record,
@@ -170,22 +245,28 @@ fn inspect_person(
170
245
  Severity::Contradiction,
171
246
  format!("born {born} and died {died}"),
172
247
  );
173
- } else if died - born > LONGEST_LIFE {
248
+ } else if let (Some(shortest), _) = born.gap_to(died)
249
+ && shortest > LONGEST_LIFE
250
+ {
174
251
  add(
175
252
  found,
176
253
  record,
177
254
  "improbable-lifespan",
178
255
  Severity::Question,
179
256
  format!(
180
- "born {born} and died {died}, which is {} years",
181
- died - born
257
+ "born {born} and died {died}, which is {}{shortest} years",
258
+ if born.gap_to(died).1 == Some(shortest) {
259
+ ""
260
+ } else {
261
+ "at least "
262
+ }
182
263
  ),
183
264
  );
184
265
  }
185
266
  }
186
267
 
187
268
  if let (Some(died), Some(buried)) = (life.died, life.buried)
188
- && buried < died
269
+ && buried.wholly_before(died)
189
270
  {
190
271
  add(
191
272
  found,
@@ -197,23 +278,20 @@ fn inspect_person(
197
278
  }
198
279
 
199
280
  // An event after the funeral is either a wrong year or a second person.
200
- // Probate and a will are the two that legitimately come after a death.
281
+ // Probate and a will are the two that legitimately come after a death;
282
+ // a birth after it is already `died-before-born`, reported once.
201
283
  if let Some(died) = life.died {
202
284
  for event in &view.events {
203
285
  if matches!(
204
286
  event.tag.as_str(),
205
- "DEAT" | "BURI" | "CREM" | "PROB" | "WILL"
287
+ "BIRT" | "DEAT" | "BURI" | "CREM" | "PROB" | "WILL"
206
288
  ) {
207
289
  continue;
208
290
  }
209
- let Some(year) = event
210
- .date
211
- .as_deref()
212
- .and_then(|payload| GedcomDate::parse(payload).year_hint())
213
- else {
291
+ let Some(year) = event.date.as_deref().and_then(Years::of) else {
214
292
  continue;
215
293
  };
216
- if year > died {
294
+ if died.wholly_before(year) {
217
295
  add(
218
296
  found,
219
297
  record,
@@ -244,7 +322,7 @@ fn inspect_family(record: &Node, lives: &HashMap<String, Life>, found: &mut Vec<
244
322
  let married = record
245
323
  .first("MARR")
246
324
  .and_then(|event| event.value_of("DATE"))
247
- .and_then(|payload| GedcomDate::parse(&payload).year_hint());
325
+ .and_then(|payload| Years::of(&payload));
248
326
 
249
327
  for parent in family.partners() {
250
328
  let Some(parent_life) = lives.get(&parent.to_ascii_uppercase()).copied() else {
@@ -293,30 +371,55 @@ fn inspect_family(record: &Node, lives: &HashMap<String, Life>, found: &mut Vec<
293
371
  }
294
372
  }
295
373
 
296
- fn inspect_marriage(record: &Node, married: Option<i32>, parent: Life, found: &mut Vec<Finding>) {
374
+ fn inspect_marriage(record: &Node, married: Option<Years>, parent: Life, found: &mut Vec<Finding>) {
297
375
  let (Some(married), Some(born)) = (married, parent.born) else {
298
376
  return;
299
377
  };
300
- let age = married - born;
301
- if age < 0 {
378
+ if married.wholly_before(born) {
302
379
  add(
303
380
  found,
304
381
  record,
305
382
  "married-before-born",
306
383
  Severity::Contradiction,
307
- format!("a marriage in {married} for somebody born {born}"),
384
+ format!("a marriage {} for somebody born {born}", in_year(married)),
308
385
  );
309
- } else if age < YOUNGEST_MARRIAGE {
386
+ } else if let (_, Some(oldest)) = born.gap_to(married)
387
+ && oldest < YOUNGEST_MARRIAGE
388
+ {
310
389
  add(
311
390
  found,
312
391
  record,
313
392
  "married-as-a-child",
314
393
  Severity::Question,
315
- format!("married in {married} at {age}, born {born}"),
394
+ format!(
395
+ "married {} {}, born {born}",
396
+ in_year(married),
397
+ age(born, married)
398
+ ),
316
399
  );
317
400
  }
318
401
  }
319
402
 
403
+ /// `in 1900`, or the bounded form (`before 1811`) as it stands.
404
+ fn in_year(years: Years) -> String {
405
+ match (years.earliest, years.latest) {
406
+ (Some(start), Some(end)) if start == end => format!("in {start}"),
407
+ _ => years.to_string(),
408
+ }
409
+ }
410
+
411
+ /// The age between two dates as a phrase: `at 9` when both are single years,
412
+ /// the range the dates allow otherwise (`at 9 to 12`, `at most 9`).
413
+ fn age(from: Years, to: Years) -> String {
414
+ match from.gap_to(to) {
415
+ (Some(least), Some(most)) if least == most => format!("at {least}"),
416
+ (Some(least), Some(most)) => format!("at {least} to {most}"),
417
+ (Some(least), None) => format!("at least {least}"),
418
+ (None, Some(most)) => format!("at most {most}"),
419
+ (None, None) => "at an unknown age".to_owned(),
420
+ }
421
+ }
422
+
320
423
  fn inspect_parenthood(
321
424
  record: &Node,
322
425
  parent: Life,
@@ -324,46 +427,64 @@ fn inspect_parenthood(
324
427
  mother: bool,
325
428
  found: &mut Vec<Finding>,
326
429
  ) {
327
- let (Some(parent_born), Some(child_born)) = (parent.born, child.born) else {
430
+ let Some(child_born) = child.born else {
328
431
  return;
329
432
  };
330
- let age = child_born - parent_born;
331
- if age < YOUNGEST_PARENT {
332
- let how = if age < 0 { "before" } else { "at" };
433
+
434
+ // A father may die before a birth; a mother may not. This needs only her
435
+ // death and the child's birth, not her own birth year.
436
+ if let (true, Some(parent_died)) = (mother, parent.died)
437
+ && parent_died.wholly_before(child_born)
438
+ {
333
439
  add(
334
440
  found,
335
441
  record,
336
- "parent-too-young",
442
+ "child-born-after-mother-died",
337
443
  Severity::Contradiction,
338
- format!(
339
- "a parent born {parent_born} with a child born {child_born}, {how} {} years old",
340
- age.abs()
341
- ),
444
+ format!("a mother who died {parent_died} with a child born {child_born}"),
342
445
  );
343
- return;
344
446
  }
345
447
 
346
- let limit = if mother { OLDEST_MOTHER } else { OLDEST_FATHER };
347
- if age > limit {
448
+ let Some(parent_born) = parent.born else {
449
+ return;
450
+ };
451
+ let (youngest, oldest) = parent_born.gap_to(child_born);
452
+ // Too young only if even the oldest age the dates allow is too young.
453
+ if let Some(oldest) = oldest
454
+ && oldest < YOUNGEST_PARENT
455
+ {
456
+ // The sign decides the wording: a negative oldest age means the child
457
+ // was born before the parent whatever the dates' ranges.
458
+ let said = if oldest < 0 {
459
+ format!("{} years before the parent was born", oldest.abs())
460
+ } else if youngest == Some(oldest) {
461
+ format!("at {oldest} years old")
462
+ } else {
463
+ format!("at most {oldest} years old")
464
+ };
348
465
  add(
349
466
  found,
350
467
  record,
351
- "parent-too-old",
352
- Severity::Question,
353
- format!("a parent born {parent_born} with a child born {child_born}, at {age}"),
468
+ "parent-too-young",
469
+ Severity::Contradiction,
470
+ format!("a parent born {parent_born} with a child born {child_born}, {said}"),
354
471
  );
472
+ return;
355
473
  }
356
474
 
357
- // A father may die before a birth; a mother may not.
358
- if let (true, Some(parent_died)) = (mother, parent.died)
359
- && child_born > parent_died
475
+ let limit = if mother { OLDEST_MOTHER } else { OLDEST_FATHER };
476
+ if let Some(youngest) = youngest
477
+ && youngest > limit
360
478
  {
361
479
  add(
362
480
  found,
363
481
  record,
364
- "child-born-after-mother-died",
365
- Severity::Contradiction,
366
- format!("a mother who died {parent_died} with a child born {child_born}"),
482
+ "parent-too-old",
483
+ Severity::Question,
484
+ format!(
485
+ "a parent born {parent_born} with a child born {child_born}, {}",
486
+ age(parent_born, child_born)
487
+ ),
367
488
  );
368
489
  }
369
490
  }
@@ -549,6 +670,155 @@ mod tests {
549
670
  );
550
671
  }
551
672
 
673
+ #[test]
674
+ fn a_year_mentioned_in_an_unreadable_date_is_not_a_death_year() {
675
+ // `WFT Est 1804-1911` mentions 1804; it does not say the person died
676
+ // then, so a birth in 1807 contradicts nothing.
677
+ let found = findings(
678
+ "0 HEAD\n0 @I1@ INDI\n1 BIRT\n2 DATE 1807\n1 DEAT\n2 DATE WFT Est 1804-1911\n0 TRLR\n",
679
+ );
680
+ assert!(
681
+ !checks(&found).contains(&"died-before-born")
682
+ && !checks(&found).contains(&"event-after-death"),
683
+ "{found:?}"
684
+ );
685
+ }
686
+
687
+ #[test]
688
+ fn a_bounded_date_contradicts_only_when_no_year_it_allows_could_be_true() {
689
+ // Died before 1811 and born 1807: both can be true.
690
+ let found =
691
+ findings("0 HEAD\n0 @I1@ INDI\n1 BIRT\n2 DATE 1807\n1 DEAT\n2 DATE BEF 1811\n0 TRLR\n");
692
+ assert!(!checks(&found).contains(&"died-before-born"), "{found:?}");
693
+ // Died before 1805 and born 1807: they cannot.
694
+ let found =
695
+ findings("0 HEAD\n0 @I1@ INDI\n1 BIRT\n2 DATE 1807\n1 DEAT\n2 DATE BEF 1805\n0 TRLR\n");
696
+ let died = found
697
+ .iter()
698
+ .find(|finding| finding.check == "died-before-born")
699
+ .expect("a contradiction");
700
+ assert_eq!(died.message, "born 1807 and died before 1805");
701
+ // A range is compared by both of its ends.
702
+ let found = findings(
703
+ "0 HEAD\n0 @I1@ INDI\n1 BIRT\n2 DATE BET 1800 AND 1810\n1 DEAT\n2 DATE 1805\n0 TRLR\n",
704
+ );
705
+ assert!(!checks(&found).contains(&"died-before-born"), "{found:?}");
706
+ }
707
+
708
+ #[test]
709
+ fn a_bounded_marriage_says_its_age_once() {
710
+ let found = findings(
711
+ "0 HEAD\n\
712
+ 0 @I1@ INDI\n\
713
+ 1 BIRT\n\
714
+ 2 DATE 1802\n\
715
+ 0 @F1@ FAM\n\
716
+ 1 HUSB @I1@\n\
717
+ 1 MARR\n\
718
+ 2 DATE BEF 1811\n\
719
+ 0 TRLR\n",
720
+ );
721
+ let child = found
722
+ .iter()
723
+ .find(|finding| finding.check == "married-as-a-child")
724
+ .expect("married as a child");
725
+ assert_eq!(child.message, "married before 1811 at most 9, born 1802");
726
+ }
727
+
728
+ #[test]
729
+ fn another_calendar_and_a_backwards_range_are_not_misread() {
730
+ // 5620 is a Hebrew year (1859-60), not a birth after a death in 1900.
731
+ let found = findings(
732
+ "0 HEAD\n\
733
+ 0 @I1@ INDI\n\
734
+ 1 BIRT\n\
735
+ 2 DATE @#DHEBREW@ 5620\n\
736
+ 1 DEAT\n\
737
+ 2 DATE 1900\n\
738
+ 0 TRLR\n",
739
+ );
740
+ assert!(!checks(&found).contains(&"died-before-born"), "{found:?}");
741
+ let found = findings(
742
+ "0 HEAD\n\
743
+ 0 @I1@ INDI\n\
744
+ 1 BIRT\n\
745
+ 2 DATE BET 1910 AND 1900\n\
746
+ 1 DEAT\n\
747
+ 2 DATE 1905\n\
748
+ 0 TRLR\n",
749
+ );
750
+ assert!(!checks(&found).contains(&"died-before-born"), "{found:?}");
751
+ }
752
+
753
+ #[test]
754
+ fn a_child_born_before_its_parent_says_so_plainly() {
755
+ let found = findings(
756
+ "0 HEAD\n\
757
+ 0 @I1@ INDI\n\
758
+ 1 BIRT\n\
759
+ 2 DATE BET 1850 AND 1855\n\
760
+ 0 @I2@ INDI\n\
761
+ 1 BIRT\n\
762
+ 2 DATE BET 1840 AND 1845\n\
763
+ 0 @F1@ FAM\n\
764
+ 1 HUSB @I1@\n\
765
+ 1 CHIL @I2@\n\
766
+ 0 TRLR\n",
767
+ );
768
+ let young = found
769
+ .iter()
770
+ .find(|finding| finding.check == "parent-too-young")
771
+ .expect("born before the parent");
772
+ assert!(
773
+ young
774
+ .message
775
+ .ends_with("5 years before the parent was born"),
776
+ "{}",
777
+ young.message
778
+ );
779
+ }
780
+
781
+ #[test]
782
+ fn a_mother_dead_before_the_birth_needs_no_birth_year_of_her_own() {
783
+ let found = findings(
784
+ "0 HEAD\n\
785
+ 0 @I1@ INDI\n\
786
+ 1 DEAT\n\
787
+ 2 DATE 1850\n\
788
+ 0 @I2@ INDI\n\
789
+ 1 BIRT\n\
790
+ 2 DATE 1860\n\
791
+ 0 @F1@ FAM\n\
792
+ 1 WIFE @I1@\n\
793
+ 1 CHIL @I2@\n\
794
+ 0 TRLR\n",
795
+ );
796
+ assert!(
797
+ checks(&found).contains(&"child-born-after-mother-died"),
798
+ "{found:?}"
799
+ );
800
+ }
801
+
802
+ #[test]
803
+ fn a_parent_with_a_bounded_birth_is_too_young_only_when_every_age_is() {
804
+ let family = |parent: &str, child: &str| {
805
+ findings(&format!(
806
+ "0 HEAD\n0 @I1@ INDI\n1 BIRT\n2 DATE {parent}\n0 @I2@ INDI\n1 BIRT\n2 DATE {child}\n\
807
+ 0 @F1@ FAM\n1 HUSB @I1@\n1 CHIL @I2@\n0 TRLR\n"
808
+ ))
809
+ };
810
+ assert!(!checks(&family("AFT 1850", "1870")).contains(&"parent-too-young"));
811
+ let found = family("BET 1860 AND 1865", "1870");
812
+ let young = found
813
+ .iter()
814
+ .find(|finding| finding.check == "parent-too-young")
815
+ .expect("too young at every allowed age");
816
+ assert_eq!(
817
+ young.message,
818
+ "a parent born between 1860 and 1865 with a child born 1870, at most 10 years old"
819
+ );
820
+ }
821
+
552
822
  #[test]
553
823
  fn a_clean_record_produces_nothing_but_what_is_actually_missing() {
554
824
  let found = findings(
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes