From a88d00b0c8610a4b64c500a8c698ad5ecc2ead7f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:51:28 +0000 Subject: [PATCH 01/11] Work in progress: the leap second table (fromSiSeconds proofs still open) isValidKnown checks second 60 against tzdata's leap-seconds.list, which Spec/Leap.lean copies line for line; siSeconds counts SI seconds since 1972, each leap second included, fromSiSeconds goes back, and taiMinusUtc gives TAI - UTC. The statements are in Spec.lean, and all are proved but the two about fromSiSeconds, which still carry sorry. Tests: 300,000 inputs with 0 failures, 34,402 of them checked against the table, which the test script reads itself, and 9,381 SI seconds turned back into timestamps. A one-off cross-check with ERFA, through pyerfa, finds 0 differences. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WWUFjVe6uzWVQGpyU113ow --- Main.lean | 27 ++++++- Proofs.lean | 1 + Proofs/Leap/FromSi.lean | 24 ++++++ Proofs/Leap/Main.lean | 64 ++++++++++++++++ Proofs/Leap/Order.lean | 98 +++++++++++++++++++++++++ Proofs/Leap/Si.lean | 115 +++++++++++++++++++++++++++++ Proofs/Leap/Table.lean | 157 ++++++++++++++++++++++++++++++++++++++++ README.md | 29 ++++++-- Rfc3339.lean | 1 + Rfc3339/Leap.lean | 71 ++++++++++++++++++ SPEC.md | 27 ++++++- Spec.lean | 71 ++++++++++++++++++ Spec/Leap.lean | 123 +++++++++++++++++++++++++++++++ bar.toml | 5 +- comparator.json | 9 ++- tests/CROSS-CHECK.md | 27 ++++--- tests/TEST-RESULTS.md | 19 +++-- tests/cross_check.py | 94 +++++++++++++++++++++++- tests/run_tests.py | 137 ++++++++++++++++++++++++++++++++++- 19 files changed, 1062 insertions(+), 37 deletions(-) create mode 100644 Proofs/Leap/FromSi.lean create mode 100644 Proofs/Leap/Main.lean create mode 100644 Proofs/Leap/Order.lean create mode 100644 Proofs/Leap/Si.lean create mode 100644 Proofs/Leap/Table.lean create mode 100644 Rfc3339/Leap.lean create mode 100644 Spec/Leap.lean diff --git a/Main.lean b/Main.lean index a376174..84d2814 100644 --- a/Main.lean +++ b/Main.lean @@ -22,6 +22,15 @@ seconds since the epoch, or NONE when it falls outside the years 0000 to 9999; N is decimal, with an optional leading `-` + known HEX prints OK, then tab-separated: SI seconds since + 1972-01-01T00:00:00Z and TAI - UTC (`-` before + 1972, when TAI - UTC was not a whole number of + seconds); NOLEAP when the timestamp is valid but + its second 60 is not a leap second of the table; + or REJECT + fromsi N prints the printed form of the UTC timestamp N SI + seconds after 1972-01-01T00:00:00Z, with second 60 + at a leap second, or NONE outside 1972 to 9999 A malformed request prints `error`: an unknown operation, hex digits that are not in pairs, bytes that are not UTF-8, or a number that is @@ -30,7 +39,9 @@ Every answer comes from the proved functions in `Rfc3339/`: `parse`, `Timestamp.print`, `Timestamp.unixSeconds`, - `Timestamp.compareInstant` and `fromUnixSeconds`. The hex decoding and the request handling + `Timestamp.compareInstant`, `fromUnixSeconds`, + `Timestamp.isValidKnown`, `Timestamp.siSeconds`, + `Timestamp.taiMinusUtc` and `fromSiSeconds`. The hex decoding and the request handling here are covered by the tests, not by a proof. -/ @@ -93,6 +104,20 @@ def request (line : String) : Option String := pure <| match fromUnixSeconds s with | some t => t.print | none => "NONE" + | ["known", h] => do + let s ← fromHex h + pure <| match parse s with + | some t => + if t.isValidKnown then + let tai := if 63072000 ≤ t.unixSeconds then toString t.taiMinusUtc else "-" + s!"OK\t{t.siSeconds}\t{tai}" + else "NOLEAP" + | none => "REJECT" + | ["fromsi", n] => do + let s ← decimal n + pure <| match fromSiSeconds s with + | some t => t.print + | none => "NONE" | _ => none /-- The line without its line ending: LF, or CR LF. -/ diff --git a/Proofs.lean b/Proofs.lean index fb97a93..80f7eb1 100644 --- a/Proofs.lean +++ b/Proofs.lean @@ -6,3 +6,4 @@ import Proofs.Main import Proofs.FromUnix.Main +import Proofs.Leap.Main diff --git a/Proofs/Leap/FromSi.lean b/Proofs/Leap/FromSi.lean new file mode 100644 index 0000000..13f3552 --- /dev/null +++ b/Proofs/Leap/FromSi.lean @@ -0,0 +1,24 @@ +/- + From SI seconds back to a timestamp. +-/ + +import Proofs.Leap.Si +import Proofs.Leap.Order +import Proofs.FromUnix.Main + +open Rfc3339 + +namespace Rfc3339.Proofs.Leap + +theorem fromSiSeconds_iff (n : Int) (t : Timestamp) : + fromSiSeconds n = some t ↔ + t.offset = .z ∧ t.fraction = [] ∧ Spec.ValidKnown t ∧ 1972 ≤ t.year ∧ + Spec.siSeconds t = n := by + sorry + +theorem fromSiSeconds_isSome (n : Int) : + (fromSiSeconds n).isSome ↔ + 0 ≤ n ∧ n ≤ Spec.siSeconds ⟨9999, 12, 31, 23, 59, 59, [], .z⟩ := by + sorry + +end Rfc3339.Proofs.Leap diff --git a/Proofs/Leap/Main.lean b/Proofs/Leap/Main.lean new file mode 100644 index 0000000..96ea0e7 --- /dev/null +++ b/Proofs/Leap/Main.lean @@ -0,0 +1,64 @@ +/- + The leap second statements of `Spec.lean`, under the same names, from + the lemmas in the other files of `Proofs/Leap/`. The test vectors are + checked by evaluation. +-/ + +import Proofs.Leap.Table +import Proofs.Leap.Si +import Proofs.Leap.Order +import Proofs.Leap.FromSi + +open Rfc3339 + +theorem Spec.isValidKnown_iff (t : Timestamp) : + t.isValidKnown = true ↔ Spec.ValidKnown t := + Rfc3339.Proofs.Leap.isValidKnown_iff t + +theorem Spec.siSeconds_eq (t : Timestamp) (h : Spec.Valid t) : + t.siSeconds = Spec.siSeconds t := + Rfc3339.Proofs.Leap.siSeconds_eq t h + +theorem Spec.siSeconds_order (a b : Timestamp) (ha : Spec.ValidKnown a) (hb : Spec.ValidKnown b) + (fa : a.fraction = []) (fb : b.fraction = []) : + compare a.siSeconds b.siSeconds = a.compareInstant b := + Rfc3339.Proofs.Leap.siSeconds_order a b ha hb fa fb + +theorem Spec.fromSiSeconds_iff (n : Int) (t : Timestamp) : + fromSiSeconds n = some t ↔ + t.offset = .z ∧ t.fraction = [] ∧ Spec.ValidKnown t ∧ 1972 ≤ t.year ∧ + Spec.siSeconds t = n := + Rfc3339.Proofs.Leap.fromSiSeconds_iff n t + +theorem Spec.fromSiSeconds_isSome (n : Int) : + (fromSiSeconds n).isSome ↔ + 0 ≤ n ∧ n ≤ Spec.siSeconds ⟨9999, 12, 31, 23, 59, 59, [], .z⟩ := + Rfc3339.Proofs.Leap.fromSiSeconds_isSome n + +theorem Spec.taiMinusUtc_eq (t : Timestamp) (h : Spec.Valid t) (h60 : t.second ≠ 60) + (h72 : Spec.unixSeconds ⟨1972, 1, 1, 0, 0, 0, [], .z⟩ ≤ Spec.unixSeconds t) : + t.taiMinusUtc = Spec.taiMinusUtcAt (Spec.ntpOfUnix (Spec.unixSeconds t)) := + Rfc3339.Proofs.Leap.taiMinusUtc_eq t h h60 h72 + +theorem Spec.leap_table_vectors : + Timestamp.isValidKnown ⟨1990, 12, 31, 23, 59, 60, [], .z⟩ = true ∧ + Timestamp.isValidKnown ⟨1990, 12, 31, 15, 59, 60, [], .minus 8 0⟩ = true ∧ + Timestamp.isValidKnown ⟨1972, 6, 30, 23, 59, 60, [], .z⟩ = true ∧ + Timestamp.isValidKnown ⟨2016, 12, 31, 23, 59, 60, [], .z⟩ = true ∧ + Timestamp.isValidKnown ⟨2017, 1, 1, 0, 59, 60, [], .plus 1 0⟩ = true ∧ + Timestamp.isValidKnown ⟨2015, 12, 31, 23, 59, 60, [], .z⟩ = false ∧ + Timestamp.isValidKnown ⟨1971, 12, 31, 23, 59, 60, [], .z⟩ = false ∧ + Timestamp.isValidKnown ⟨2026, 12, 31, 23, 59, 60, [], .z⟩ = false ∧ + Timestamp.siSeconds ⟨1972, 1, 1, 0, 0, 0, [], .z⟩ = 0 ∧ + Timestamp.siSeconds ⟨1972, 6, 30, 23, 59, 59, [], .z⟩ = 15724799 ∧ + Timestamp.siSeconds ⟨1972, 6, 30, 23, 59, 60, [], .z⟩ = 15724800 ∧ + Timestamp.siSeconds ⟨1972, 7, 1, 0, 0, 0, [], .z⟩ = 15724801 ∧ + Timestamp.siSeconds ⟨2017, 1, 1, 0, 0, 0, [], .z⟩ = 1420156827 ∧ + Timestamp.taiMinusUtc ⟨1972, 1, 1, 0, 0, 0, [], .z⟩ = 10 ∧ + Timestamp.taiMinusUtc ⟨2016, 12, 31, 23, 59, 59, [], .z⟩ = 36 ∧ + Timestamp.taiMinusUtc ⟨2017, 1, 1, 0, 0, 0, [], .z⟩ = 37 ∧ + fromSiSeconds 1420156826 = some ⟨2016, 12, 31, 23, 59, 60, [], .z⟩ ∧ + fromSiSeconds 1420156827 = some ⟨2017, 1, 1, 0, 0, 0, [], .z⟩ ∧ + fromUnixSeconds (Timestamp.unixSeconds ⟨2016, 12, 31, 23, 59, 60, [], .z⟩) = + some ⟨2017, 1, 1, 0, 0, 0, [], .z⟩ := by + and_intros <;> decide +kernel diff --git a/Proofs/Leap/Order.lean b/Proofs/Leap/Order.lean new file mode 100644 index 0000000..9d1d136 --- /dev/null +++ b/Proofs/Leap/Order.lean @@ -0,0 +1,98 @@ +/- + SI seconds order known-valid whole seconds as their instants. +-/ + +import Proofs.Leap.Table + +open Rfc3339 + +namespace Rfc3339.Proofs.Leap + +/-- Counting the entries below a threshold is monotone in the threshold. -/ +private theorem or_filter_mono {α : Type} (l : List α) (f : α → Int) (x y : Int) (h : x ≤ y) : + (l.filter fun a => decide (f a < x)).length ≤ + (l.filter fun a => decide (f a < y)).length := by + induction l with + | nil => simp + | cons a l ih => + simp only [List.filter_cons] + by_cases h1 : f a < x + · have h2 : f a < y := by omega + simp [h1, h2] <;> omega + · by_cases h2 : f a < y + · simp [h1, h2] <;> omega + · simp [h1, h2] <;> omega + +/-- And strictly so when an entry lies between the two thresholds. -/ +private theorem or_filter_strict {α : Type} (l : List α) (f : α → Int) (x y : Int) (h : x ≤ y) + (z : α) (hz : z ∈ l) (hz1 : x ≤ f z) (hz2 : f z < y) : + (l.filter fun a => decide (f a < x)).length + 1 ≤ + (l.filter fun a => decide (f a < y)).length := by + induction l with + | nil => simp at hz + | cons a l ih => + have hm := or_filter_mono l f x y h + simp only [List.filter_cons] + rcases List.mem_cons.mp hz with he | hz' + · rw [he] at hz1 hz2 + have h1 : ¬ f a < x := by omega + simp [h1, hz2] <;> omega + · have ih' := ih hz' + by_cases h1 : f a < x + · have h2 : f a < y := by omega + simp [h1, h2] <;> omega + · by_cases h2 : f a < y + · simp [h1, h2] <;> omega + · simp [h1, h2] <;> omega + +private theorem or_unix (t : Timestamp) : + t.unixSeconds = (t.utcMinute - 719528 * 1440) * 60 + t.second := by + unfold Timestamp.unixSeconds Timestamp.utcMinute + omega + +private theorem or_si (t : Timestamp) : + t.siSeconds = (t.utcMinute - 719528 * 1440) * 60 + t.second - 63072000 + t.leapsBefore := by + unfold Timestamp.siSeconds + rw [or_unix] + +private theorem or_lt (a b : Timestamp) (ha : Spec.ValidKnown a) (hb : Spec.ValidKnown b) + (h : a.utcMinute * 61 + a.second < b.utcMinute * 61 + b.second) : + a.siSeconds < b.siSeconds := by + obtain ⟨_, _, _, _, _, _, _, hsa, _⟩ := ha.1 + obtain ⟨_, _, _, _, _, _, _, hsb, _⟩ := hb.1 + have hm : a.leapsBefore ≤ b.leapsBefore := by + unfold Timestamp.leapsBefore + exact or_filter_mono leapMonths leapKey _ _ (by omega) + rw [or_si, or_si] + by_cases h60 : a.second = 60 + · obtain ⟨ym, hym, hk⟩ := leapKey_of_known a ha h60 + have hs : a.leapsBefore + 1 ≤ b.leapsBefore := by + unfold Timestamp.leapsBefore + exact or_filter_strict leapMonths leapKey _ _ (by omega) ym hym (by omega) (by omega) + omega + · omega + +private theorem or_eq (a b : Timestamp) (ha : Spec.ValidKnown a) (hb : Spec.ValidKnown b) + (h : a.utcMinute * 61 + a.second = b.utcMinute * 61 + b.second) : + a.siSeconds = b.siSeconds := by + obtain ⟨_, _, _, _, _, _, _, hsa, _⟩ := ha.1 + obtain ⟨_, _, _, _, _, _, _, hsb, _⟩ := hb.1 + have hL : a.leapsBefore = b.leapsBefore := by + unfold Timestamp.leapsBefore + rw [h] + rw [or_si, or_si, hL] + omega + +theorem siSeconds_order (a b : Timestamp) (ha : Spec.ValidKnown a) (hb : Spec.ValidKnown b) + (fa : a.fraction = []) (fb : b.fraction = []) : + compare a.siSeconds b.siSeconds = a.compareInstant b := by + unfold Timestamp.compareInstant + rw [fa, fb] + rcases Int.lt_trichotomy (a.utcMinute * 61 + a.second) (b.utcMinute * 61 + b.second) + with h | h | h + · rw [Int.compare_eq_lt.mpr h, Int.compare_eq_lt.mpr (or_lt a b ha hb h)] + · rw [Int.compare_eq_eq.mpr h, Int.compare_eq_eq.mpr (or_eq a b ha hb h)] + simp [compareFraction] + · rw [Int.compare_eq_gt.mpr h, Int.compare_eq_gt.mpr (or_lt b a hb ha h)] + +end Rfc3339.Proofs.Leap diff --git a/Proofs/Leap/Si.lean b/Proofs/Leap/Si.lean new file mode 100644 index 0000000..a298d9a --- /dev/null +++ b/Proofs/Leap/Si.lean @@ -0,0 +1,115 @@ +/- + SI seconds and TAI − UTC against the spec. +-/ + +import Proofs.Leap.Table + +open Rfc3339 + +namespace Rfc3339.Proofs.Leap + +/-- Filtering a mapped list counts what filtering by the composite + predicate counts. -/ +theorem si_length_filter_map {α β : Type} (l : List α) (f : α → β) (p : β → Bool) : + ((l.map f).filter p).length = (l.filter fun a => p (f a)).length := by + rw [List.filter_map, List.length_map] + rfl + +theorem leapsBefore_eq (t : Timestamp) (h : Spec.Valid t) : + t.leapsBefore = Spec.leapsBefore t := by + unfold Timestamp.leapsBefore Spec.leapsBefore + rw [Instant.utcMinute_eq t h] + have h1 := si_length_filter_map leapMonths leapKey + (fun k => decide (k < Spec.utcMinute t * 61 + (t.second : Int))) + have h2 := si_length_filter_map Spec.leapTable.tail (fun e => Spec.leapSecondKey e.1) + (fun k => decide (k < Spec.utcMinute t * 61 + (t.second : Int))) + rw [leapKey_table] at h1 + rw [← h1, h2] + rfl + +theorem si_unix1972 : Spec.unixSeconds ⟨1972, 1, 1, 0, 0, 0, [], .z⟩ = 63072000 := by + decide +kernel + +theorem siSeconds_eq (t : Timestamp) (h : Spec.Valid t) : t.siSeconds = Spec.siSeconds t := by + unfold Timestamp.siSeconds Spec.siSeconds + rw [Instant.unixSeconds_eq t h, si_unix1972, leapsBefore_eq t h] + +/-- The shape of the table after its first line: NTP times strictly + increasing from `n`, offsets one more each line from `c`. -/ +def si_shape : Nat → Nat → List (Nat × Nat) → Bool + | _, _, [] => true + | n, c, (n', c') :: rest => decide (n < n') && c' == c + 1 && si_shape n' c' rest + +theorem si_shape_lower (l : List (Nat × Nat)) : + ∀ n c, si_shape n c l = true → ∀ e ∈ l, n < e.1 := by + induction l with + | nil => intro n c _ e he; cases he + | cons hd rest ih => + intro n c hs e he + obtain ⟨n', c'⟩ := hd + simp only [si_shape, Bool.and_eq_true, decide_eq_true_eq, beq_iff_eq] at hs + obtain ⟨⟨hn, _⟩, hr⟩ := hs + rcases List.mem_cons.mp he with he | he + · subst he; exact hn + · have := ih n' c' hr e he + omega + +theorem si_lastLine (l : List (Nat × Nat)) : + ∀ (n c : Nat) (x : Int), si_shape n c l = true → (n : Int) ≤ x → + ((((n, c) :: l).filter fun e => decide ((e.1 : Int) ≤ x)).getLast?.map (·.2)).getD 0 = + c + (l.filter fun e => decide ((e.1 : Int) ≤ x)).length := by + induction l with + | nil => + intro n c x _ hx + simp [hx] + | cons hd rest ih => + intro n c x hs hx + obtain ⟨n', c'⟩ := hd + have hs' := hs + simp only [si_shape, Bool.and_eq_true, decide_eq_true_eq, beq_iff_eq] at hs' + obtain ⟨⟨hn, hc⟩, hr⟩ := hs' + by_cases hx' : (n' : Int) ≤ x + · have ih' := ih n' c' x hr hx' + simp only [List.filter_cons, hx, hx', decide_true, ite_true] at ih' ⊢ + rw [List.getLast?_cons_cons, ih', List.length_cons] + omega + · have hnil : (rest.filter fun e => decide ((e.1 : Int) ≤ x)) = [] := by + rw [List.filter_eq_nil_iff] + intro e he + have := si_shape_lower rest n' c' hr e he + simp only [decide_eq_true_eq] + omega + simp [hx, hx', hnil] + +theorem si_table_shape : si_shape 2272060800 10 Spec.leapTable.tail = true := by + decide +kernel + +theorem si_table_mod : ∀ e ∈ Spec.leapTable.tail, e.1 % 60 = 0 := by + decide +kernel + +theorem si_taiMinusUtcAt (x : Int) (hx : 2272060800 ≤ x) : + Spec.taiMinusUtcAt x = 10 + (Spec.leapTable.tail.filter fun e => decide ((e.1 : Int) ≤ x)).length := by + unfold Spec.taiMinusUtcAt + have hl : Spec.leapTable = (2272060800, 10) :: Spec.leapTable.tail := rfl + rw [hl] + exact si_lastLine Spec.leapTable.tail 2272060800 10 x si_table_shape (by omega) + +theorem taiMinusUtc_eq (t : Timestamp) (h : Spec.Valid t) (h60 : t.second ≠ 60) + (h72 : Spec.unixSeconds ⟨1972, 1, 1, 0, 0, 0, [], .z⟩ ≤ Spec.unixSeconds t) : + t.taiMinusUtc = Spec.taiMinusUtcAt (Spec.ntpOfUnix (Spec.unixSeconds t)) := by + have hs : t.second ≤ 60 := h.2.2.2.2.2.2.2.1 + rw [si_unix1972] at h72 + unfold Timestamp.taiMinusUtc + rw [leapsBefore_eq t h, si_taiMinusUtcAt _ (by unfold Spec.ntpOfUnix; omega)] + congr 1 + unfold Spec.leapsBefore + congr 1 + apply List.filter_congr + intro e he + have hm := si_table_mod e he + unfold Spec.leapSecondKey Spec.secondKey Spec.ntpOfUnix Spec.unixSeconds + rw [Instant.dayNumber_epoch] + simp only [decide_eq_decide] + omega + +end Rfc3339.Proofs.Leap diff --git a/Proofs/Leap/Table.lean b/Proofs/Leap/Table.lean new file mode 100644 index 0000000..22a091a --- /dev/null +++ b/Proofs/Leap/Table.lean @@ -0,0 +1,157 @@ +/- + The leap second table: the code's (year, month) list against the + spec's NTP times, and `isValidKnown` against `Spec.ValidKnown`. +-/ + +import Rfc3339 +import Spec.Leap +import Proofs.Valid +import Proofs.Instant +import Proofs.FromUnix.Day + +open Rfc3339 + +namespace Rfc3339.Proofs.Leap + +/-- The leap seconds of the code's list, in order, are those of the + table's lines after the first. -/ +theorem leapKey_table : + leapMonths.map leapKey = Spec.leapTable.tail.map (fun e => Spec.leapSecondKey e.1) := by + decide +kernel + +/-- The day number of the first day of the month after `ym`, as + `leapKey` computes it. -/ +def tb_next (ym : Nat × Nat) : Nat := + if ym.2 = 12 then dayNumber (ym.1 + 1) 1 1 else dayNumber ym.1 (ym.2 + 1) 1 + +theorem tb_leapKey_eq (ym : Nat × Nat) : + leapKey ym = (((tb_next ym * 1440 : Nat) : Int) - 1) * 61 + 60 := rfl + +/-- Each NTP time of the table after the first line is a whole number + of minutes after the Unix epoch. -/ +theorem tb_table_div : ∀ e ∈ Spec.leapTable.tail, ((e.1 : Int) - Spec.ntpOfUnix 0) % 60 = 0 := by + decide +kernel + +/-- Facts about each month of the code's list: its range, and that the + day before the next month starts falls in it. -/ +theorem tb_months : ∀ ym ∈ leapMonths, 1972 ≤ ym.1 ∧ ym.1 ≤ 2016 ∧ 1 ≤ ym.2 ∧ ym.2 ≤ 12 ∧ + 1 ≤ tb_next ym ∧ + (fromDayNumber (tb_next ym - 1)).1 = ym.1 ∧ (fromDayNumber (tb_next ym - 1)).2.1 = ym.2 := by + decide +kernel + +/-- The month after `(y, m)` starts `daysInMonth y m` days after it. -/ +theorem tb_next_eq (y m : Nat) (hm1 : 1 ≤ m) (hm2 : m ≤ 12) : + tb_next (y, m) = Spec.dayNumber y m 1 + Spec.daysInMonth y m := by + unfold tb_next + simp only + split + · rename_i h12 + subst h12 + rw [DayNumber.dayNumber_eq _ _ _ (by decide) (by decide), ← Valid.next_year y] + simp [Spec.daysInMonth] + · rw [DayNumber.dayNumber_eq _ _ _ (by omega) (by omega), Valid.next_month y m hm1] + +theorem tb_utcMonth_eq (t : Timestamp) : + t.utcMonth = ((fromDayNumber (t.utcMinute / 1440).toNat).1, + (fromDayNumber (t.utcMinute / 1440).toNat).2.1) := rfl + +/-- At second 60, a leap second of the code's list is one of the table's. -/ +theorem tb_key_iff_table (t : Timestamp) (h : Spec.Valid t) (h60 : t.second = 60) : + (∃ ym ∈ leapMonths, leapKey ym = t.utcMinute * 61 + t.second) ↔ Spec.TableLeapSecond t := by + have e1 : (∃ ym ∈ leapMonths, leapKey ym = t.utcMinute * 61 + t.second) ↔ + ∃ e ∈ Spec.leapTable.tail, Spec.leapSecondKey e.1 = t.utcMinute * 61 + t.second := by + have := congrArg (fun l => t.utcMinute * 61 + t.second ∈ l) leapKey_table + simp only [List.mem_map] at this + exact Iff.of_eq this + rw [e1, Instant.utcMinute_eq t h] + unfold Spec.TableLeapSecond + simp only [h60, true_and] + constructor + · rintro ⟨e, he, hk⟩ + refine ⟨e, he, ?_⟩ + have hd := tb_table_div e he + unfold Spec.leapSecondKey at hk + unfold Spec.unixSeconds + rw [Instant.dayNumber_epoch] at hk ⊢ + simp only [Spec.ntpOfUnix] at hk hd ⊢ + omega + · rintro ⟨e, he, hk⟩ + refine ⟨e, he, ?_⟩ + unfold Spec.leapSecondKey + unfold Spec.unixSeconds at hk + rw [Instant.dayNumber_epoch] at hk ⊢ + simp only [Spec.ntpOfUnix] at hk ⊢ + omega + +/-- At a valid second 60, the code's month test is the leap second key test. -/ +theorem tb_contains_iff (t : Timestamp) (h : Spec.Valid t) (h60 : t.second = 60) : + leapMonths.contains t.utcMonth = true ↔ + ∃ ym ∈ leapMonths, leapKey ym = t.utcMinute * 61 + t.second := by + obtain ⟨y, m, hm1, hm2, hD⟩ := h.2.2.2.2.2.2.2.2.1 h60 + rw [← Instant.utcMinute_eq t h] at hD + have hkey : ∀ ym, leapKey ym = t.utcMinute * 61 + t.second ↔ + tb_next ym = Spec.dayNumber y m 1 := by + intro ym + rw [tb_leapKey_eq, h60] + omega + simp only [hkey, List.contains_iff_mem] + rw [tb_utcMonth_eq] + constructor + · intro hmem + have hp := tb_months _ hmem + generalize hn : (t.utcMinute / 1440).toNat = n at hmem hp + have hy := FromUnix.yearOfDay_spec n + have hy1 : (fromDayNumber n).1 = yearOfDay n := rfl + obtain ⟨hp1, hp2, -, -, -, -, -⟩ := hp + simp only at hp1 hp2 + rw [hy1] at hp1 hp2 + have h10 : n < daysBeforeYear 10000 := by + have := FromUnix.dy_mono (show yearOfDay n + 1 ≤ 10000 by omega) + omega + have h72 : daysBeforeYear 1972 ≤ n := by + have := FromUnix.dy_mono hp1 + omega + have h72' : 0 < daysBeforeYear 1972 := by decide + have hnD : n + 1 = Spec.dayNumber y m 1 := by omega + obtain ⟨-, hm1', hm2', hd1', hd2', hdn⟩ := FromUnix.fromDayNumber_spec n h10 + refine ⟨_, hmem, ?_⟩ + rw [tb_next_eq _ _ hm1' hm2'] + have hday := Valid.dayNumber_day (fromDayNumber n).1 (fromDayNumber n).2.1 + (fromDayNumber n).2.2 + have hsp := Valid.month_start_spaced hm1' hm2' hm1 hm2 (show + Spec.dayNumber (fromDayNumber n).1 (fromDayNumber n).2.1 1 < Spec.dayNumber y m 1 by omega) + omega + · rintro ⟨ym, hmem, hk⟩ + obtain ⟨-, -, -, -, hn1, hy, hm⟩ := tb_months ym hmem + have hn : (t.utcMinute / 1440).toNat = tb_next ym - 1 := by omega + rw [hn, hy, hm] + exact hmem + +theorem isValidKnown_iff (t : Timestamp) : t.isValidKnown = true ↔ Spec.ValidKnown t := by + unfold Timestamp.isValidKnown Spec.ValidKnown + simp only [Bool.and_eq_true, Bool.or_eq_true, bne_iff_ne, ne_eq, Valid.isValid_iff] + constructor + · rintro ⟨hv, hl⟩ + refine ⟨hv, fun h60 => ?_⟩ + rcases hl with hl | hl + · exact absurd h60 hl + · exact (tb_key_iff_table t hv h60).1 ((tb_contains_iff t hv h60).1 hl) + · rintro ⟨hv, hl⟩ + refine ⟨hv, ?_⟩ + by_cases h60 : t.second = 60 + · exact Or.inr ((tb_contains_iff t hv h60).2 ((tb_key_iff_table t hv h60).2 (hl h60))) + · exact Or.inl h60 + +/-- A known-valid second 60 sits at one of the list's leap seconds. -/ +theorem leapKey_of_known (t : Timestamp) (h : Spec.ValidKnown t) (h60 : t.second = 60) : + ∃ ym ∈ leapMonths, leapKey ym = t.utcMinute * 61 + t.second := + (tb_key_iff_table t h.1 h60).2 (h.2 h60) + +/-- And a valid second 60 at one of the list's leap seconds is known + valid. -/ +theorem known_of_leapKey (t : Timestamp) (h : Spec.Valid t) (ym : Nat × Nat) + (hym : ym ∈ leapMonths) (hk : leapKey ym = t.utcMinute * 61 + t.second) : + Spec.ValidKnown t := + ⟨h, fun h60 => (tb_key_iff_table t h h60).1 ⟨ym, hym, hk⟩⟩ + +end Rfc3339.Proofs.Leap diff --git a/README.md b/README.md index ae4646f..ac93449 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # rfc: RFC 3339 timestamps, proved -This library reads, checks and writes RFC 3339 timestamps, such as `1985-04-12T23:20:50.52Z`, in Lean. Machine-checked proofs show that it accepts exactly the strings that the RFC's grammar and calendar rules allow, including the day ranges and leap years of the calendar. There is one relaxation: it accepts a leap second at the end of any month, because it keeps no table of the months that had one. The proofs also cover the round trip from text to value and back, the count of seconds since the Unix epoch, and the order of two timestamps by the instant they name. The library is tested against Python's date and time code; `tests/TEST-RESULTS.md` has the record. +This library reads, checks and writes RFC 3339 timestamps, such as `1985-04-12T23:20:50.52Z`, in Lean. Machine-checked proofs show that it accepts exactly the strings that the RFC's grammar and calendar rules allow, including the day ranges and leap years of the calendar. `isValid` has one relaxation: it accepts a leap second at the end of any month. `isValidKnown` is exact: it checks second 60 against the table of the 27 leap seconds there have been, as tzdata publishes it. The proofs also cover the round trip from text to value and back, the count of seconds since the Unix epoch, and the order of two timestamps by the instant they name. A count of SI seconds since 1972 gives each leap second its own number, so a round trip through it keeps a leap second, as one through Unix seconds cannot. TAI − UTC is also given, and proved to agree with the table. The library is tested against Python's date and time code; `tests/TEST-RESULTS.md` has the record. It is a project of [Proven Commons](https://github.com/Proven-Commons/mission), built to the verified bar published there. @@ -21,12 +21,16 @@ For each timestamp it prints the timestamp as written, with `T` and `Z` in upper - `Timestamp.unixSeconds` - `Timestamp.compareInstant` - `Rfc3339.fromUnixSeconds` +- `Timestamp.isValidKnown` +- `Timestamp.siSeconds` +- `Timestamp.taiMinusUtc` +- `Rfc3339.fromSiSeconds` `SPEC.md` says what each one does. ## What is proved -The statements are in `Spec.lean`. The definitions they use, written from the RFC, are in `Spec/Defs.lean`, and the types are in `Rfc3339/Types.lean`. The proofs are in `Proofs/`. +The statements are in `Spec.lean`. The definitions they use, written from the RFC, are in `Spec/Defs.lean`; those for the leap second table, with the table itself, are in `Spec/Leap.lean`; and the types are in `Rfc3339/Types.lean`. The proofs are in `Proofs/`. | Claim | Theorem | |-------|---------| @@ -44,13 +48,21 @@ The statements are in `Spec.lean`. The definitions they use, written from the RF | `fromUnixSeconds` gives a timestamp exactly for the seconds from 0000-01-01T00:00:00Z to 9999-12-31T23:59:59Z, and `none` outside them. | `Spec.fromUnixSeconds_isSome` | | `fromUnixSeconds` gives the epoch, the section 5.8 example, 2000-02-29, 2038-01-19T03:14:07Z for the last second of a signed 32-bit count, the second before the epoch, and both ends of the range, and gives `none` one second past each end. | `Spec.fromUnixSeconds_vectors` | | The calendar, leap second, and syntax cases: 29 February in 1900, 2000 and 2001; second 60 at and away from a month's end; lower case `t` and `z`; a space for `T`; a comma; an offset of `+24:00`; trailing text. | `Spec.calendar_vectors`, `Spec.leap_second_vectors`, `Spec.syntax_vectors` | +| `isValidKnown` decides section 5.7 with the table of leap seconds. It allows second 60 only at one of the table's leap seconds, at the same instant whatever the offset. The table is tzdata's `leap-seconds.list`, copied into the spec line for line. The code keeps its own list of months, and the proof shows the two agree. | `Spec.isValidKnown_iff` | +| `siSeconds` counts the SI seconds from 1972-01-01T00:00:00Z: Unix seconds, and one more for each leap second of the table before the timestamp. | `Spec.siSeconds_eq` | +| On whole seconds valid by the table, `siSeconds` orders timestamps exactly as `compareInstant` does. So no two instants share a number: a leap second, the second before it and the second after it get three numbers in a row. | `Spec.siSeconds_order` | +| `fromSiSeconds` turns SI seconds back into the one UTC timestamp from 1972 on, valid by the table, that has them, a leap second included. It gives a timestamp for every second from 1972-01-01T00:00:00Z to 9999-12-31T23:59:59Z and `none` outside. | `Spec.fromSiSeconds_iff`, `Spec.fromSiSeconds_isSome` | +| TAI − UTC at each second from 1972 on, other than a leap second, is the value in the table's second column, 10 to 37. The code never reads that column; it counts leap seconds. | `Spec.taiMinusUtc_eq` | +| Section 5.8's leap seconds, the table's first and last, one seen from another offset, and second 60 in months that had none. SI seconds and TAI − UTC around the first and the last leap second. And the round trip that Unix seconds lose and SI seconds keep. | `Spec.leap_table_vectors` | The command-line tool in `Main.lean` calls these functions directly. ## What is not proved - Reading requests and printing answers in `Main.lean`: the hex decoding and UTF-8 check in `serve` mode, the arguments, output and exit codes of `check` mode, and the decimal printing of numbers and reading of the numbers `fromunix` takes. The differential tests cover them, with malformed requests and CR LF line endings. -- Whether a leap second was announced. Second 60 is accepted at 23:59 UTC at the end of any month, without a table of leap seconds, where section 5.7 allows it only at the end of months in which a leap second occurs. Second 59 is always accepted, although section 5.7 makes 58 the last second of a month after a negative leap second, which has never happened. +- Whether a leap second was announced, in `isValid` and `parse`. They accept second 60 at 23:59 UTC at the end of any month, where section 5.7 allows it only at the end of months in which a leap second occurs; `isValidKnown` checks that against the table. Second 59 is always accepted, although section 5.7 makes 58 the last second of a month after a negative leap second, which has never happened. +- The table after its expiry date, 28 June 2027. `isValidKnown` refuses second 60 at any month end the table does not list, and `siSeconds` and `taiMinusUtc` count no leap second after 2016. If one is ever announced, the table in the spec and the code's list must change together, and the spec approved again. The table is what tzdata publishes from IERS Bulletin C; that it matches the IERS record is trusted, not proved. +- SI seconds and TAI − UTC before 1972. UTC then ran on seconds of varying length, with TAI − UTC not a whole number of seconds. Before 1972, `siSeconds` counts 86400 seconds a day, as Unix seconds do, and `taiMinusUtc` gives 10; no statement covers them there, and the tool's `known` request prints `-` for TAI − UTC. - Unix seconds at a leap second. They count second 60 as the first second of the next minute, as POSIX time does, so a leap second and the second after it give the same number. `compareInstant` does not use them, and puts the leap second first. `fromUnixSeconds` turns that number into the second after the leap second, so a round trip through Unix seconds loses a leap second, and `9999-12-31T23:59:60Z` gives `none`. - Forms outside RFC 3339 are not supported, by design: - a space in place of `T`, which section 5.6 lets applications choose; @@ -67,7 +79,7 @@ The command-line tool in `Main.lean` calls these functions directly. - The Lean compiler, with the C compiler and linker shipped with it, and the runtime, which turn the proved definitions into the program that runs. - Lean's core library, including the compiled code it puts in place of some definitions the proofs reason about (by `implemented_by` and `extern`), such as the arithmetic on natural numbers and integers and the conversion between strings and lists of characters. - The operating system and hardware it runs on. -- Python's `datetime` and `calendar` modules, as an oracle in the differential tests. The record names the Python version. +- Python's `datetime` and `calendar` modules, and for leap seconds the copy of tzdata's `leap-seconds.list` on the test machine, as oracles in the differential tests. The record names the Python version and the file. This project has no dependencies beyond Lean itself. @@ -82,6 +94,8 @@ This project has no dependencies beyond Lean itself. - Go accepts a comma before the fraction. - `rfc3339-validator` accepts a timestamp followed by a line break. +For leap seconds, it compares `siSeconds`, `taiMinusUtc` and `fromSiSeconds` with ERFA, the C version of the IAU's SOFA library, through pyerfa, which keeps its own leap second table. `tests/CROSS-CHECK.md` gives the counts. + ## Re-check it Needs Linux, [elan](https://github.com/leanprover/elan), bubblewrap (`apt install bubblewrap`), and Python 3.11 or later. @@ -94,14 +108,15 @@ python3 tests/run_tests.py `lake comparator --paranoid` builds the statements and the proofs in a sandbox. It checks that each theorem in `Proofs/` proves exactly the statement in `Spec.lean`, using only the axioms `propext`, `Classical.choice`, and `Quot.sound`, and replays it through every kernel shipped with Lean. -The test script compares the tool with Python's `datetime` and `calendar` modules, and with the RFC's rules copied into the script, on inputs from a recorded seed. It then rewrites `tests/TEST-RESULTS.md`. +The test script compares the tool with Python's `datetime` and `calendar` modules, with the RFC's rules copied into the script, and with tzdata's `leap-seconds.list`, which the script reads itself, on inputs from a recorded seed. It then rewrites `tests/TEST-RESULTS.md`. ## Layout - `Rfc3339/Types.lean`: the timestamp and offset types. It is part of the specification. -- `Rfc3339/Calendar.lean`, `Rfc3339/Format.lean`, `Rfc3339/Instant.lean`, `Rfc3339/FromUnix.lean`: the code under proof. +- `Rfc3339/Calendar.lean`, `Rfc3339/Format.lean`, `Rfc3339/Instant.lean`, `Rfc3339/FromUnix.lean`, `Rfc3339/Leap.lean`: the code under proof. - `Spec.lean`: the statements, with `sorry` for proofs. A reviewer approves this file. - `Spec/Defs.lean`: the definitions the statements use, written from the RFC, with checks against its tables and known day counts. +- `Spec/Leap.lean`: the leap second table, line for line from tzdata's `leap-seconds.list`, and the definitions the leap second statements use. - `Proofs.lean` and `Proofs/`: the proofs, under the same names. - `Main.lean`: the command-line tool. - `lakefile.toml`: the build. It names the package and its version, its libraries and executable, and the default targets, and sets nothing else. @@ -109,4 +124,4 @@ The test script compares the tool with Python's `datetime` and `calendar` module - `bar.toml`: the record for the bar check: status, authors, approvals, and where things are. - `SPEC.md`: the specification in plain words. - `tests/run_tests.py`: the differential tests. -- `tests/cross_check.py` and `tests/cross_check/main.go`: the one-off comparison with other parsers. +- `tests/cross_check.py` and `tests/cross_check/main.go`: the one-off comparison with other parsers, and with ERFA for leap seconds. diff --git a/Rfc3339.lean b/Rfc3339.lean index e4a567a..70cee98 100644 --- a/Rfc3339.lean +++ b/Rfc3339.lean @@ -8,3 +8,4 @@ import Rfc3339.Calendar import Rfc3339.Format import Rfc3339.Instant import Rfc3339.FromUnix +import Rfc3339.Leap diff --git a/Rfc3339/Leap.lean b/Rfc3339/Leap.lean new file mode 100644 index 0000000..641fba1 --- /dev/null +++ b/Rfc3339/Leap.lean @@ -0,0 +1,71 @@ +/- + Leap seconds: the table of the months that ended in one, validity by + that table as section 5.7 asks, SI seconds since 1972 that give each + leap second a number of its own, and TAI − UTC. +-/ + +import Rfc3339.FromUnix + +namespace Rfc3339 + +/-- The months, as (year, month), whose last second in UTC was a leap + second, 23:59:60: the 27 of IERS Bulletin C, as tzdata's + `leap-seconds.list` gives them. That file expires on 28 June 2027: + no leap second comes before then that is not in it. -/ +def leapMonths : List (Nat × Nat) := + [(1972, 6), (1972, 12), (1973, 12), (1974, 12), (1975, 12), (1976, 12), (1977, 12), + (1978, 12), (1979, 12), (1981, 6), (1982, 6), (1983, 6), (1985, 6), (1987, 12), + (1989, 12), (1990, 12), (1992, 6), (1993, 6), (1994, 6), (1995, 12), (1997, 6), + (1998, 12), (2005, 12), (2008, 12), (2012, 6), (2015, 6), (2016, 12)] + +/-- The year and month, in UTC, of the minute the timestamp falls in. -/ +def Timestamp.utcMonth (t : Timestamp) : Nat × Nat := + let (y, m, _) := fromDayNumber (t.utcMinute / 1440).toNat + (y, m) + +/-- Whether `t` is valid and, if its second is 60, one of the leap + seconds of the table: section 5.7 allows second 60 only "at the end + of months in which a leap second occurs". -/ +def Timestamp.isValidKnown (t : Timestamp) : Bool := + t.isValid && (t.second != 60 || leapMonths.contains t.utcMonth) + +/-- The place of the leap second at the end of month `ym` in the order + `utcMinute * 61 + second`: second 60 of the last minute before the + next month. -/ +def leapKey (ym : Nat × Nat) : Int := + let next := if ym.2 = 12 then dayNumber (ym.1 + 1) 1 1 else dayNumber ym.1 (ym.2 + 1) 1 + (((next * 1440 : Nat) : Int) - 1) * 61 + 60 + +/-- How many leap seconds of the table come before the second the + timestamp falls in. -/ +def Timestamp.leapsBefore (t : Timestamp) : Nat := + (leapMonths.filter fun ym => leapKey ym < t.utcMinute * 61 + t.second).length + +/-- SI seconds from 1972-01-01T00:00:00Z to the timestamp, leaving out + the fraction: Unix seconds, which give each day 86400, and one more + for each leap second of the table before it. 63072000 is the Unix + seconds of 1972-01-01T00:00:00Z. -/ +def Timestamp.siSeconds (t : Timestamp) : Int := + t.unixSeconds - 63072000 + t.leapsBefore + +/-- TAI − UTC at the timestamp, in seconds: 10 from the start of 1972, + and one more after each leap second. -/ +def Timestamp.taiMinusUtc (t : Timestamp) : Nat := + 10 + t.leapsBefore + +/-- The SI seconds of each leap second of the table. -/ +def leapSiSeconds : List Int := + (leapMonths.zipIdx).map fun (ym, i) => (leapKey ym + 1) / 61 * 60 - 719528 * 86400 - 63072000 + i + +/-- The UTC timestamp `n` SI seconds after 1972-01-01T00:00:00Z, with + second 60 at each leap second of the table, no fraction, and offset + `Z`; `none` below 0 or after 9999-12-31T23:59:59Z. -/ +def fromSiSeconds (n : Int) : Option Timestamp := + if n < 0 then none + else + let k := (leapSiSeconds.filter (· < n)).length + if leapSiSeconds.contains n then + (fromUnixSeconds (n + 63072000 - k - 1)).map fun t => { t with second := 60 } + else fromUnixSeconds (n + 63072000 - k) + +end Rfc3339 diff --git a/SPEC.md b/SPEC.md index 187bcff..9899b65 100644 --- a/SPEC.md +++ b/SPEC.md @@ -50,9 +50,20 @@ POSIX time has no number for a leap second. As in POSIX time, second 60 is count Every second from `0000-01-01T00:00:00Z` (-62167219200) to `9999-12-31T23:59:59Z` (253402300799), the years RFC 3339 allows, has one, and `fromUnixSeconds` returns `none` outside them. So for a valid timestamp `t` whose second is at most 59, `fromUnixSeconds t.unixSeconds` is `t` put into UTC with its fraction dropped, or `none` when that moves it outside those years. A leap second comes back as the second after it, a different instant, so `1990-12-31T23:59:60Z` gives `1991-01-01T00:00:00Z`, and `9999-12-31T23:59:60Z`, at any offset, gives `none`. +## Leap seconds, with the table + +Section 5.7 allows second 60 only "at the end of months in which a leap second occurs". `isValid` cannot know those months; `isValidKnown` uses a table of them. The table is tzdata's `leap-seconds.list`, which publishes IERS Bulletin C. `Spec/Leap.lean` copies it line for line: 28 NTP times, from 1 January 1972 to 1 January 2017, each with TAI − UTC from that time on, 10 to 37 seconds. Each line after the first follows a leap second, the second `23:59:60` UTC just before it, so there have been 27. The file expires on 28 June 2027: until then, no leap second comes that it does not list. + +- `isValidKnown` is `isValid`, with second 60 allowed only at one of the table's leap seconds. The instant is what counts, whatever the offset: `1990-12-31T15:59:60-08:00` is the leap second at the end of 1990, and `2017-01-01T00:59:60+01:00` the one at the end of 2016. `2015-12-31T23:59:60Z` is refused, since that leap second came at the end of June 2015. +- `siSeconds` gives the SI seconds from `1972-01-01T00:00:00Z`, ignoring the fraction: Unix seconds, and one more for each leap second of the table before the timestamp. So `2016-12-31T23:59:59Z`, `2016-12-31T23:59:60Z` and `2017-01-01T00:00:00Z` get three numbers in a row, where Unix seconds give the last two the same one. On whole seconds valid by the table, these numbers are in the order of the instants, as `compareInstant` gives it. +- `fromSiSeconds` goes back: from SI seconds to the UTC timestamp, a leap second as second 60. It gives one for every number from 0, `1972-01-01T00:00:00Z`, to that of `9999-12-31T23:59:59Z`, and `none` outside. +- `taiMinusUtc` gives TAI − UTC: 10, and one more for each leap second before the timestamp. At every second from 1972 on, a leap second aside, it is the table's second column. During a leap second it is still the value from before it. + +Before 1972, UTC ran on seconds of varying length, so these numbers mean nothing there, and no statement covers them. A leap second announced after the table's date would need a new table, in the spec and in the code, and a new approval. + ## Errors -`parse` returns `none` for any string it does not accept, and `fromUnixSeconds` for any number outside the years 0000 to 9999. No function can fail any other way. +`parse` returns `none` for any string it does not accept, `fromUnixSeconds` for any number outside the years 0000 to 9999, and `fromSiSeconds` for any number outside 1972 to 9999. No function can fail any other way. ## The command-line tool @@ -61,11 +72,11 @@ The command-line tool in `Main.lean` has two modes: - `rfc3339 check TIMESTAMP...` prints each timestamp's printed form and its Unix seconds, or `invalid`. It exits with 1 when any timestamp is invalid. - `rfc3339 serve` answers requests from the differential tests, one per line. Each string is sent as the hex digits of its UTF-8 bytes. The header comment of `Main.lean` gives the protocol. -Every answer comes from `parse`, `Timestamp.print`, `Timestamp.unixSeconds`, `Timestamp.compareInstant` and `fromUnixSeconds`. +Every answer comes from `parse`, `Timestamp.print`, `Timestamp.unixSeconds`, `Timestamp.compareInstant`, `fromUnixSeconds`, `Timestamp.isValidKnown`, `Timestamp.siSeconds`, `Timestamp.taiMinusUtc` and `fromSiSeconds`. The `known` request prints `-` for TAI − UTC before 1972. ## What must always hold -Each property is a theorem in `Spec.lean`, under the name given. The definitions the statements use are in `Spec/Defs.lean`. They are written from the RFC and checked against its tables and against known day counts. +Each property is a theorem in `Spec.lean`, under the name given. The definitions the statements use are in `Spec/Defs.lean` and, for the leap second table, `Spec/Leap.lean`. They are written from the RFC and checked against its tables and against known day counts. | Property | Theorem | |----------|---------| @@ -86,12 +97,19 @@ Each property is a theorem in `Spec.lean`, under the name given. The definitions | `fromUnixSeconds s` is `t` exactly when `t` is valid, in UTC, with no fraction and a second of at most 59, and names `s`. | `Spec.fromUnixSeconds_iff` | | `fromUnixSeconds` gives a timestamp exactly for the seconds from 0000-01-01T00:00:00Z to 9999-12-31T23:59:59Z. | `Spec.fromUnixSeconds_isSome` | | The epoch, the section 5.8 example, a leap day, the last second of a signed 32-bit count, the second before the epoch, and the ends of the range and one second past each. | `Spec.fromUnixSeconds_vectors` | +| `isValidKnown` decides `Spec.ValidKnown`: `Spec.Valid`, with second 60 only at a leap second of the table. | `Spec.isValidKnown_iff` | +| `siSeconds` is Unix seconds since 1972 and one more for each leap second of the table before the timestamp. | `Spec.siSeconds_eq` | +| On whole seconds valid by the table, `siSeconds` gives the order of the instants. | `Spec.siSeconds_order` | +| `fromSiSeconds n` is `t` exactly when `t` is in UTC, with no fraction, valid by the table, from 1972 on, and has SI seconds `n`; it gives one for every second from 1972 to 9999. | `Spec.fromSiSeconds_iff`, `Spec.fromSiSeconds_isSome` | +| TAI − UTC from 1972 on, at each second but a leap second, is the table's value. | `Spec.taiMinusUtc_eq` | +| Leap seconds of the table and months without one; SI seconds and TAI − UTC around the first and the last; the round trip Unix seconds lose. | `Spec.leap_table_vectors` | Together, these fix every output for valid input. - `parse` is fixed for every string. - `print`, `unixSeconds` and `compareInstant` are fixed for every valid timestamp. The parser gives only valid timestamps. -- `fromUnixSeconds` is fixed for every number, by `Spec.fromUnixSeconds_iff` and `Spec.fromUnixSeconds_isSome`. +- `fromUnixSeconds` is fixed for every number, by `Spec.fromUnixSeconds_iff` and `Spec.fromUnixSeconds_isSome`, and `fromSiSeconds` likewise by its two statements. +- `isValidKnown` is fixed for every timestamp, and `siSeconds` for every valid one. `taiMinusUtc` is fixed for every valid timestamp from 1972 on but a leap second. - A parser that accepted nothing would meet `Spec.parse_valid`, but it would fail `Spec.parse_iff` and `Spec.parse_print`. - A parser that accepted too much would fail `Spec.parse_iff`. @@ -101,3 +119,4 @@ The differential tests compare the tool with two oracles: - Python's `datetime` and `calendar` modules: month lengths, offset arithmetic, seconds since the epoch, `datetime.fromisoformat` on every accepted string it can hold, and the UTC timestamp for a number of seconds. `datetime` holds no year 0, so dates there are moved by 400 years, one cycle of the Gregorian calendar, for the arithmetic. - A copy of the rules above, written in Python from the RFC. +- For leap seconds, tzdata's `leap-seconds.list` on the test machine, read by the test script itself, with the SI seconds and TAI − UTC worked out from it in Python. diff --git a/Spec.lean b/Spec.lean index 974a98c..62189b7 100644 --- a/Spec.lean +++ b/Spec.lean @@ -14,6 +14,7 @@ import Rfc3339 import Spec.Defs +import Spec.Leap open Rfc3339 @@ -175,3 +176,73 @@ theorem Spec.fromUnixSeconds_vectors : Rfc3339.fromUnixSeconds 253402300799 = some ⟨9999, 12, 31, 23, 59, 59, [], .z⟩ ∧ Rfc3339.fromUnixSeconds (-62167219201) = none ∧ Rfc3339.fromUnixSeconds 253402300800 = none := sorry + +/-! ## Leap seconds, with the table -/ + +/-- `isValidKnown` decides section 5.7 with the table of leap seconds: + as `isValid`, but second 60 only at one of the table's leap seconds, + at the same instant whatever the offset. -/ +theorem Spec.isValidKnown_iff (t : Timestamp) : + t.isValidKnown = true ↔ Spec.ValidKnown t := sorry + +/-- `siSeconds` counts the SI seconds from 1972-01-01T00:00:00Z to the + timestamp, each leap second of the table included, leaving out the + fraction. -/ +theorem Spec.siSeconds_eq (t : Timestamp) (h : Spec.Valid t) : + t.siSeconds = Spec.siSeconds t := sorry + +/-- On whole seconds, valid by the table, `siSeconds` orders timestamps + exactly as the instants they name. So no two instants share a + number: a leap second, the second before it and the second after + it get three numbers in a row. -/ +theorem Spec.siSeconds_order (a b : Timestamp) (ha : Spec.ValidKnown a) (hb : Spec.ValidKnown b) + (fa : a.fraction = []) (fb : b.fraction = []) : + compare a.siSeconds b.siSeconds = a.compareInstant b := sorry + +/-- `fromSiSeconds` turns SI seconds since 1972-01-01T00:00:00Z back into + a timestamp: the one, at most one, in UTC, with no fraction, valid by + the table and from 1972 on, that has those SI seconds. A leap second + comes back as second 60. -/ +theorem Spec.fromSiSeconds_iff (n : Int) (t : Timestamp) : + fromSiSeconds n = some t ↔ + t.offset = .z ∧ t.fraction = [] ∧ Spec.ValidKnown t ∧ 1972 ≤ t.year ∧ + Spec.siSeconds t = n := sorry + +/-- `fromSiSeconds` gives a timestamp exactly for the seconds from + 1972-01-01T00:00:00Z to 9999-12-31T23:59:59Z. -/ +theorem Spec.fromSiSeconds_isSome (n : Int) : + (fromSiSeconds n).isSome ↔ + 0 ≤ n ∧ n ≤ Spec.siSeconds ⟨9999, 12, 31, 23, 59, 59, [], .z⟩ := sorry + +/-- TAI − UTC at each second from 1972 on, other than a leap second, is + the value the table's second column gives for it. The code never + reads that column: it counts leap seconds. -/ +theorem Spec.taiMinusUtc_eq (t : Timestamp) (h : Spec.Valid t) (h60 : t.second ≠ 60) + (h72 : Spec.unixSeconds ⟨1972, 1, 1, 0, 0, 0, [], .z⟩ ≤ Spec.unixSeconds t) : + t.taiMinusUtc = Spec.taiMinusUtcAt (Spec.ntpOfUnix (Spec.unixSeconds t)) := sorry + +/-- The leap seconds of section 5.8, the first and the last of the + table, one seen from another offset, and second 60 in months that + had none; SI seconds and TAI − UTC around the first and the last; + and the round trip that Unix seconds lose and SI seconds keep. -/ +theorem Spec.leap_table_vectors : + Timestamp.isValidKnown ⟨1990, 12, 31, 23, 59, 60, [], .z⟩ = true ∧ + Timestamp.isValidKnown ⟨1990, 12, 31, 15, 59, 60, [], .minus 8 0⟩ = true ∧ + Timestamp.isValidKnown ⟨1972, 6, 30, 23, 59, 60, [], .z⟩ = true ∧ + Timestamp.isValidKnown ⟨2016, 12, 31, 23, 59, 60, [], .z⟩ = true ∧ + Timestamp.isValidKnown ⟨2017, 1, 1, 0, 59, 60, [], .plus 1 0⟩ = true ∧ + Timestamp.isValidKnown ⟨2015, 12, 31, 23, 59, 60, [], .z⟩ = false ∧ + Timestamp.isValidKnown ⟨1971, 12, 31, 23, 59, 60, [], .z⟩ = false ∧ + Timestamp.isValidKnown ⟨2026, 12, 31, 23, 59, 60, [], .z⟩ = false ∧ + Timestamp.siSeconds ⟨1972, 1, 1, 0, 0, 0, [], .z⟩ = 0 ∧ + Timestamp.siSeconds ⟨1972, 6, 30, 23, 59, 59, [], .z⟩ = 15724799 ∧ + Timestamp.siSeconds ⟨1972, 6, 30, 23, 59, 60, [], .z⟩ = 15724800 ∧ + Timestamp.siSeconds ⟨1972, 7, 1, 0, 0, 0, [], .z⟩ = 15724801 ∧ + Timestamp.siSeconds ⟨2017, 1, 1, 0, 0, 0, [], .z⟩ = 1420156827 ∧ + Timestamp.taiMinusUtc ⟨1972, 1, 1, 0, 0, 0, [], .z⟩ = 10 ∧ + Timestamp.taiMinusUtc ⟨2016, 12, 31, 23, 59, 59, [], .z⟩ = 36 ∧ + Timestamp.taiMinusUtc ⟨2017, 1, 1, 0, 0, 0, [], .z⟩ = 37 ∧ + fromSiSeconds 1420156826 = some ⟨2016, 12, 31, 23, 59, 60, [], .z⟩ ∧ + fromSiSeconds 1420156827 = some ⟨2017, 1, 1, 0, 0, 0, [], .z⟩ ∧ + fromUnixSeconds (Timestamp.unixSeconds ⟨2016, 12, 31, 23, 59, 60, [], .z⟩) = + some ⟨2017, 1, 1, 0, 0, 0, [], .z⟩ := sorry diff --git a/Spec/Leap.lean b/Spec/Leap.lean new file mode 100644 index 0000000..97d49f4 --- /dev/null +++ b/Spec/Leap.lean @@ -0,0 +1,123 @@ +/- + The definitions the leap second statements use: the table of leap + seconds and TAI − UTC as tzdata's `leap-seconds.list` publishes it, + from IERS Bulletin C, and section 5.7 of RFC 3339 with that table. + Part of the approved specification, with `Spec.lean` and + `Spec/Defs.lean`, and written independently of the code in + `Rfc3339/`. +-/ + +import Spec.Defs + +open Rfc3339 + +/-! ## The table + +`leap-seconds.list`, as tzdata ships it (here from +`/usr/share/zoneinfo/leap-seconds.list`), gives "the times at which the +offset between TAI and UTC changed and the value of the offset after +that time". Each line is an NTP time, seconds since 1900-01-01T00:00:00Z +with 86400 to a day, then TAI − UTC from that time on: + +``` +2272060800 10 # 1 Jan 1972 +2287785600 11 # 1 Jul 1972 +2303683200 12 # 1 Jan 1973 +2335219200 13 # 1 Jan 1974 +2366755200 14 # 1 Jan 1975 +2398291200 15 # 1 Jan 1976 +2429913600 16 # 1 Jan 1977 +2461449600 17 # 1 Jan 1978 +2492985600 18 # 1 Jan 1979 +2524521600 19 # 1 Jan 1980 +2571782400 20 # 1 Jul 1981 +2603318400 21 # 1 Jul 1982 +2634854400 22 # 1 Jul 1983 +2698012800 23 # 1 Jul 1985 +2776982400 24 # 1 Jan 1988 +2840140800 25 # 1 Jan 1990 +2871676800 26 # 1 Jan 1991 +2918937600 27 # 1 Jul 1992 +2950473600 28 # 1 Jul 1993 +2982009600 29 # 1 Jul 1994 +3029443200 30 # 1 Jan 1996 +3076704000 31 # 1 Jul 1997 +3124137600 32 # 1 Jan 1999 +3345062400 33 # 1 Jan 2006 +3439756800 34 # 1 Jan 2009 +3550089600 35 # 1 Jul 2012 +3644697600 36 # 1 Jul 2015 +3692217600 37 # 1 Jan 2017 +``` + +"File expires on 28 June 2027": the table holds until then. The first +line is the start of UTC as it is now, with TAI − UTC 10; each later +line follows a leap second, the second 23:59:60 UTC just before it. +-/ + +/-- The table, line for line: NTP time, and TAI − UTC from then on. -/ +def Spec.leapTable : List (Nat × Nat) := + [(2272060800, 10), (2287785600, 11), (2303683200, 12), (2335219200, 13), + (2366755200, 14), (2398291200, 15), (2429913600, 16), (2461449600, 17), + (2492985600, 18), (2524521600, 19), (2571782400, 20), (2603318400, 21), + (2634854400, 22), (2698012800, 23), (2776982400, 24), (2840140800, 25), + (2871676800, 26), (2918937600, 27), (2950473600, 28), (2982009600, 29), + (3029443200, 30), (3076704000, 31), (3124137600, 32), (3345062400, 33), + (3439756800, 34), (3550089600, 35), (3644697600, 36), (3692217600, 37)] + +/-- NTP time from Unix seconds: NTP counts from 1900-01-01T00:00:00Z, + 70 years of 86400-second days, 17 of them leap years, before the + Unix epoch. -/ +def Spec.ntpOfUnix (u : Int) : Int := u + (70 * 365 + 17) * 86400 + +example : Spec.ntpOfUnix 0 = 2208988800 := by decide + +/-! ## Section 5.7, with the table + +"the leap second point is shifted by the zone offset (so it happens at +the same instant around the globe)": a timestamp with second 60 is a +leap second of the table when its Unix seconds, which count second 60 +as the start of the next minute, are those of a line after the first. +-/ + +/-- `t` names one of the table's leap seconds: its second is 60, and the + time just after it, in NTP time, is that of a line of the table + after the first. -/ +def Spec.TableLeapSecond (t : Timestamp) : Prop := + t.second = 60 ∧ ∃ e ∈ Spec.leapTable.tail, Spec.ntpOfUnix (Spec.unixSeconds t) = e.1 + +/-- Valid, by section 5.7 with the table: as `Spec.Valid`, and second 60 + only at a leap second of the table. -/ +def Spec.ValidKnown (t : Timestamp) : Prop := + Spec.Valid t ∧ (t.second = 60 → Spec.TableLeapSecond t) + +/-! ## SI seconds and TAI − UTC -/ + +/-- The place of a timestamp's whole second in UTC order: its UTC minute + and its second, with 61 places to a minute, as `Spec.instantKey` + with no fraction. -/ +def Spec.secondKey (t : Timestamp) : Int := Spec.utcMinute t * 61 + t.second + +/-- The same place for the leap second just before NTP time `ntp`: + second 60 of the minute before it. -/ +def Spec.leapSecondKey (ntp : Nat) : Int := + (((ntp : Int) - Spec.ntpOfUnix 0) / 60 + ((Spec.dayNumber 1970 1 1 * 1440 : Nat) : Int) - 1) * 61 + 60 + +/-- How many leap seconds of the table come before the second `t` falls + in. -/ +def Spec.leapsBefore (t : Timestamp) : Nat := + (Spec.leapTable.tail.filter fun e => Spec.leapSecondKey e.1 < Spec.secondKey t).length + +/-- SI seconds from 1972-01-01T00:00:00Z to `t`, without its fraction: + the Unix seconds between them, which count 86400 to every day, and + one more for each leap second of the table between them. -/ +def Spec.siSeconds (t : Timestamp) : Int := + Spec.unixSeconds t - Spec.unixSeconds ⟨1972, 1, 1, 0, 0, 0, [], .z⟩ + Spec.leapsBefore t + +/-- TAI − UTC at NTP time `ntp`: the value on the table's last line at + or before it. -/ +def Spec.taiMinusUtcAt (ntp : Int) : Nat := + ((Spec.leapTable.filter fun e => (e.1 : Int) ≤ ntp).getLast?.map (·.2)).getD 0 + +example : Spec.taiMinusUtcAt 2272060800 = 10 ∧ Spec.taiMinusUtcAt 3692217599 = 36 ∧ + Spec.taiMinusUtcAt 3692217600 = 37 := by decide diff --git a/bar.toml b/bar.toml index 505ea7d..7baee02 100644 --- a/bar.toml +++ b/bar.toml @@ -15,12 +15,13 @@ entry_points = ["Main.lean"] [spec] # Rfc3339/Types.lean holds the types every statement is about, so it is # part of the specification. -files = ["SPEC.md", "Spec.lean", "Spec/Defs.lean", "Rfc3339/Types.lean"] +files = ["SPEC.md", "Spec.lean", "Spec/Defs.lean", "Spec/Leap.lean", "Rfc3339/Types.lean"] # The code under proof: the functions the statements are about. code = [ "Rfc3339.parse", "Rfc3339.Timestamp.print", "Rfc3339.Timestamp.isValid", "Rfc3339.Timestamp.unixSeconds", "Rfc3339.Timestamp.compareInstant", - "Rfc3339.fromUnixSeconds", + "Rfc3339.fromUnixSeconds", "Rfc3339.Timestamp.isValidKnown", "Rfc3339.Timestamp.siSeconds", + "Rfc3339.Timestamp.taiMinusUtc", "Rfc3339.fromSiSeconds", ] # One entry per approval, added in a pull request the reviewer approves. diff --git a/comparator.json b/comparator.json index 60399c4..62d4328 100644 --- a/comparator.json +++ b/comparator.json @@ -18,7 +18,14 @@ "Spec.syntax_vectors", "Spec.fromUnixSeconds_iff", "Spec.fromUnixSeconds_isSome", - "Spec.fromUnixSeconds_vectors" + "Spec.fromUnixSeconds_vectors", + "Spec.isValidKnown_iff", + "Spec.siSeconds_eq", + "Spec.siSeconds_order", + "Spec.fromSiSeconds_iff", + "Spec.fromSiSeconds_isSome", + "Spec.taiMinusUtc_eq", + "Spec.leap_table_vectors" ], "permitted_axioms": [ "propext", diff --git a/tests/CROSS-CHECK.md b/tests/CROSS-CHECK.md index 4cbdf35..db3aa75 100644 --- a/tests/CROSS-CHECK.md +++ b/tests/CROSS-CHECK.md @@ -6,20 +6,27 @@ Written by `tests/cross_check.py`, once, by hand. It is not part of the verified - Go: `time.Parse(time.RFC3339Nano, s)`, go version go1.24.7 linux/amd64 - rfc3339-validator 0.1.4 (Python 3.13.17), called on the string as given. `jsonschema` calls it for its `date-time` check on the upper-cased string, so the lower case rows below do not apply to `jsonschema`. - Seed: 20261025 -- Strings: 256755 +- Strings: 182162 - Strings both this project and Go accept, read to a different instant: 0 Where each parser differs from this one, by the first category that fits the string: | Parser | Differs by | Category | Strings | Example | | --- | --- | --- | --- | --- | -| Go | rejects | lower case t or z | 28347 | `'1985-04-12t23:20:50.52z'`, `'3864-12-02t01:14:12.577732z'` | -| Go | rejects | second 60 | 4691 | `'1990-12-31T23:59:60Z'`, `'1990-12-31T15:59:60-08:00'` | -| Go | accepts | offset hour above 23 or minute above 59 | 3033 | `'2000-01-01T00:00:00+24:00'`, `'2000-01-01T00:00:00+23:60'` | -| Go | accepts | a field with one digit | 702 | `'5585-08-01T5:46:19.534-17:35'`, `'2098-08-07T1:15:55-19:39'` | -| Go | accepts | comma before the fraction | 5 | `'2000-01-01T00:00:00,5Z'`, `'3237-07-31T04:42:37,135148-08:01'` | -| rfc3339-validator | rejects | lower case t or z | 28347 | `'1985-04-12t23:20:50.52z'`, `'3864-12-02t01:14:12.577732z'` | -| rfc3339-validator | rejects | second 60 | 4691 | `'1990-12-31T23:59:60Z'`, `'1990-12-31T15:59:60-08:00'` | -| rfc3339-validator | rejects | year 0000 | 1583 | `'0000-01-01T00:00:00Z'`, `'0000-01-01T12:00:00Z'` | -| rfc3339-validator | accepts | line break at the end | 1330 | `'2513-04-29T17:09:04.19696594243724-20:08\n'`, `'8984-09-01T04:21:23+15:45\n'` | +| Go | rejects | lower case t or z | 18971 | `'1985-04-12t23:20:50.52z'`, `'2100-10-31t02:52:09+00:50'` | +| Go | rejects | second 60 | 3786 | `'1990-12-31T23:59:60Z'`, `'1990-12-31T15:59:60-08:00'` | +| Go | accepts | offset hour above 23 or minute above 59 | 1986 | `'2000-01-01T00:00:00+24:00'`, `'2000-01-01T00:00:00+23:60'` | +| Go | accepts | a field with one digit | 419 | `'0099-09-29T8:05:14.9-13:23'`, `'9972-03-01T8:16:47+02:02'` | +| Go | accepts | comma before the fraction | 3 | `'2000-01-01T00:00:00,5Z'`, `'1900-01-02T22:19:55,9-00:00'` | +| rfc3339-validator | rejects | lower case t or z | 18971 | `'1985-04-12t23:20:50.52z'`, `'2100-10-31t02:52:09+00:50'` | +| rfc3339-validator | rejects | second 60 | 3786 | `'1990-12-31T23:59:60Z'`, `'1990-12-31T15:59:60-08:00'` | +| rfc3339-validator | rejects | year 0000 | 1243 | `'0000-01-01T00:00:00Z'`, `'0000-01-01T12:00:00Z'` | +| rfc3339-validator | accepts | line break at the end | 869 | `'4244-06-01T08:05:55.054876690884+00:00\n'`, `'5263-10-30T07:22:33.52-07:24\n'` | + + +## Leap seconds + +- ERFA, through pyerfa 2.0.1.5, with its own leap second table +- Timestamps known valid by the table, from 1972 on, given to ERFA: 24191. Different SI seconds since 1972 or TAI - UTC: 0. Before 1972, where TAI - UTC was not a whole number of seconds, not compared: 3390. +- SI seconds turned back into timestamps, of which 27 leap seconds, and given to ERFA for their SI seconds: 8700. Different: 0. diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index bb0dd24..c2a5a65 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -2,17 +2,20 @@ Written by `tests/run_tests.py`. Rerun it to check these numbers. -- Oracle: Python 3.13.17 `datetime` and `calendar`, and the RFC 3339 rules copied into this script +- Oracle: Python 3.13.17 `datetime` and `calendar`, the RFC 3339 rules copied into this script, and for leap seconds tzdata's `/usr/share/zoneinfo/leap-seconds.list`, read by this script - Lean: Lean (version 4.35.0, x86_64-unknown-linux-gnu, commit eb6cafb7290e8bd5f2937522d8d804f58f02bafc, Release) - Seed: 20261025 - Inputs: 300000 -- Mutated: 79241 of the inputs are mutated timestamps -- By kind: compare 34149, error 27, fromunix 56056, grid 16998, mutated 79241, near miss 33926, valid 79603 -- Strings parsed: 209768, of which 96503 accepted (5227 with a leap second) and 113265 rejected -- Accepted strings also read by `datetime.fromisoformat`, with the same fields, offset and seconds: 89586 -- Pairs ordered: 34149 (11557 before, 9582 same instant, 11345 after, 1665 with a string rejected) -- Unix seconds turned back into timestamps: 56056, of which 1278 outside years 0000 to 9999 -- Malformed requests answered `error`: 27 of 27 +- Mutated: 67969 of the inputs are mutated timestamps +- By kind: compare 29344, error 35, fromsi 9381, fromunix 44688, grid 16998, known 34402, mutated 67969, near miss 29160, valid 68023 +- Strings parsed: 182150, of which 83488 accepted (4661 with a leap second) and 98662 rejected +- Accepted strings also read by `datetime.fromisoformat`, with the same fields, offset and seconds: 77297 +- Pairs ordered: 29344 (9831 before, 8253 same instant, 9780 after, 1480 with a string rejected) +- Unix seconds turned back into timestamps: 44688, of which 704 outside years 0000 to 9999 +- Leap second table: /usr/share/zoneinfo/leap-seconds.list, 28 lines, 27 leap seconds, expires 2027-06-28 +- Timestamps checked against the table (`known`): 34402, of which 303 leap seconds of the table, 6821 with a second 60 it does not list, and 0 rejected +- SI seconds turned back into timestamps (`fromsi`): 9381, of which 27 leap seconds and 681 outside 1972 to 9999 +- Malformed requests answered `error`: 35 of 35 - Requests ending in CR LF: every seventh, 42857 - Strings also given to `rfc3339 check` as arguments, in batches of 500: 20000, and once with no arguments - Last lines with no LF, sent alone, answered right: 2 of 2 diff --git a/tests/cross_check.py b/tests/cross_check.py index 6cfd605..ef0e9b5 100644 --- a/tests/cross_check.py +++ b/tests/cross_check.py @@ -16,8 +16,18 @@ `rfc3339_validator.validate_rfc3339`. Where a parser accepts what this one rejects, or the reverse, the string is put in the first category below that fits it. Where Go and this tool both accept a string, the -instants must match to the nanosecond. The counts go to -tests/CROSS-CHECK.md. +instants must match to the nanosecond. + +For the leap second table, the timestamps tests/run_tests.py sends as +`known` requests, and the SI seconds it sends as `fromsi`, go to ERFA, +the C version of the IAU's SOFA library, through pyerfa, which keeps its +own leap second table: `dat` gives TAI - UTC, and `dtf2d` with `utctai` +the TAI time, from which the SI seconds since 1972-01-01T00:00:00Z +follow. It needs pyerfa: + + pip install --target .lake/oracle-packages pyerfa==2.0.1.5 + +The counts go to tests/CROSS-CHECK.md. """ import argparse @@ -92,6 +102,85 @@ def go_version(): return out.stdout.strip() +def erfa_elapsed(erfa, y, mo, d, h, mi, sec, base): + """SI seconds from `base`, a TAI two-part Julian date, to the UTC time, + by ERFA.""" + u1, u2 = erfa.dtf2d("UTC", y, mo, d, h, mi, float(sec)) + t1, t2 = erfa.utctai(u1, u2) + return round(((t1 - base[0]) + (t2 - base[1])) * 86400) + + +def leap_section(args): + sys.path.insert(0, str(ROOT / ".lake" / "oracle-packages")) + try: + import erfa + except ImportError: + sys.exit("needs pyerfa: pip install --target .lake/oracle-packages pyerfa==2.0.1.5") + import warnings + from importlib.metadata import version + # ERFA warns of a "dubious year" after its table's last entry; its + # answers still assume no later leap second, as the table here does. + warnings.simplefilter("ignore") + items = list(run_tests.cases(args.seed, args.count)) + knowns = [bytes.fromhex(req.split(" ")[1]).decode("utf-8") for label, req, _, _ in items + if label == "known"] + sis = [int(req.split(" ")[1]) for label, req, _, _ in items if label == "fromsi"] + ours_k, problem = run_tests.run_tool("".join(f"known {run_tests.hexed(k)}\n" for k in knowns)) + if problem or len(ours_k) != len(knowns): + sys.exit(f"the tool failed: {problem}") + ours_s, problem = run_tests.run_tool("".join(f"fromsi {n}\n" for n in sis)) + if problem or len(ours_s) != len(sis): + sys.exit(f"the tool failed: {problem}") + b1, b2 = erfa.dtf2d("UTC", 1972, 1, 1, 0, 0, 0.0) + base = erfa.utctai(b1, b2) + k_checked, k_before, k_diff = 0, 0, [] + for s, ours in zip(knowns, ours_k): + if not ours.startswith("OK"): + continue + _, si, tai = ours.split("\t") + if tai == "-": + k_before += 1 + continue + f = run_tests.reference(s) + utc, _ = run_tests.utc_parts(f) + # utc_parts moves years `datetime` cannot hold by 400 years; move + # the UTC year back. + year = utc.year + f["year"] - run_tests.shifted(f["year"])[0] + theirs_tai = erfa.dat(year, utc.month, utc.day, + (utc.hour * 3600 + utc.minute * 60 + f["second"]) / 86400) + theirs_si = erfa_elapsed(erfa, year, utc.month, utc.day, utc.hour, utc.minute, + f["second"], base) + k_checked += 1 + if (int(si), int(tai)) != (theirs_si, round(theirs_tai)): + k_diff.append((s, ours, f"{theirs_si}\t{theirs_tai}")) + s_checked, s_leaps, s_diff = 0, 0, [] + for n, ours in zip(sis, ours_s): + if ours == "NONE": + continue + m = re.fullmatch(r"([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})Z", ours) + y, mo, d, h, mi, sec = map(int, m.groups()) + s_checked += 1 + s_leaps += sec == 60 + theirs = erfa_elapsed(erfa, y, mo, d, h, mi, sec, base) + if theirs != n: + s_diff.append((n, ours, theirs)) + lines = [ + "", + "## Leap seconds", + "", + f"- ERFA, through pyerfa {version('pyerfa')}, with its own leap second table", + f"- Timestamps known valid by the table, from 1972 on, given to ERFA: {k_checked}. " + f"Different SI seconds since 1972 or TAI - UTC: {len(k_diff)}. Before 1972, where " + f"TAI - UTC was not a whole number of seconds, not compared: {k_before}.", + f"- SI seconds turned back into timestamps, of which {s_leaps} leap seconds, and given " + f"to ERFA for their SI seconds: {s_checked}. Different: {len(s_diff)}.", + "", + ] + lines += [f"- `{s}`: this project `{o}`, ERFA `{t}`" for s, o, t in k_diff[:10]] + lines += [f"- `fromsi {n}`: this project `{o}`, ERFA's SI seconds `{t}`" for n, o, t in s_diff[:10]] + return lines + + def main(): ap = argparse.ArgumentParser() ap.add_argument("--seed", type=int, default=20261025) @@ -161,6 +250,7 @@ def main(): lines.append("") for s, theirs, ours in value_mismatch[:20]: lines.append(f"- Different instant: `{s!r}`: Go `{theirs}`, this project `{ours}`") + lines += leap_section(args) REPORT.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"{len(strings)} strings; report in {REPORT}") diff --git a/tests/run_tests.py b/tests/run_tests.py index b278528..a26e2a8 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -41,6 +41,15 @@ seconds in and around the range, and the seconds of valid timestamps. `datetime` arithmetic gives the expected UTC timestamp, with year 0 moved by 400 years as above; +- for the leap second table, a third oracle: tzdata's + `leap-seconds.list`, read here line by line, apart from the Lean code. + `known` requests must give the SI seconds and TAI − UTC it implies, + or NOLEAP for a second 60 it does not list. The inputs are 23:59:58, + 23:59:59, 23:59:60 and the next 00:00:00 at every month end from 1970 + to 2030, in UTC and spelled under seven offsets, and random valid + timestamps. `fromsi` requests take every leap second's SI seconds and + the two seconds either side, the ends of the range and one past each, + random numbers, and the SI seconds of random timestamps; - malformed requests, which the tool must answer with `error`. Every seventh request ends in CR LF. The first 20,000 parsed strings @@ -110,6 +119,87 @@ def from_unix(n): f"{d.hour:02d}:{d.minute:02d}:{d.second:02d}Z") +# The leap second table: tzdata's leap-seconds.list, read here, apart from +# the Lean code. Each line: an NTP time, and TAI - UTC from then on. +LEAP_FILE = Path("/usr/share/zoneinfo/leap-seconds.list") +NTP_UNIX = 2208988800 +EPOCH_1972 = 63072000 + + +def leap_table(): + rows, expires = [], None + for line in LEAP_FILE.read_text().splitlines(): + if line.startswith("#@"): + expires = int(line.split()[1]) + elif line.strip() and not line.startswith("#"): + ntp, off = line.split()[:2] + rows.append((int(ntp), int(off))) + return rows, expires + + +LEAP_TABLE, LEAP_EXPIRES = leap_table() +# The Unix seconds of the midnight after each leap second, which is also +# what Unix seconds give the leap second itself. +LEAP_MIDNIGHTS = [ntp - NTP_UNIX for ntp, _ in LEAP_TABLE[1:]] +# The SI seconds since 1972 of each leap second. +LEAP_SI = [m - EPOCH_1972 + i for i, m in enumerate(LEAP_MIDNIGHTS)] + + +def known(s): + """The expected answer to `known`: SI seconds since 1972 and TAI - UTC + by the table, NOLEAP for a second 60 the table does not list, or + REJECT.""" + f = reference(s) + if f is None: + return "REJECT" + u = f["unix"] + if f["second"] == 60: + if u not in LEAP_MIDNIGHTS: + return "NOLEAP" + before = LEAP_MIDNIGHTS.index(u) + tai = LEAP_TABLE[before][1] + else: + before = sum(1 for m in LEAP_MIDNIGHTS if m <= u) + tai = max((off for ntp, off in LEAP_TABLE if ntp <= u + NTP_UNIX), default=None) + return f"OK\t{u - EPOCH_1972 + before}\t{tai if u >= EPOCH_1972 else '-'}" + + +def from_si(n): + """The expected answer to `fromsi n`, by the table and `from_unix`.""" + if n < 0: + return "NONE" + k = sum(1 for x in LEAP_SI if x < n) + if n in LEAP_SI: + r = from_unix(n + EPOCH_1972 - k - 1) + return r if r == "NONE" else r[:17] + "60" + r[19:] + return from_unix(n + EPOCH_1972 - k) + + +def month_end_grid(): + """23:59:58 to the next 00:00:00 at every month end from 1970 to 2030, + in UTC and spelled under other offsets.""" + for year in range(1970, 2031): + for month in range(1, 13): + last = month_length(year, month) + base = datetime(year, month, last, 23, 59) + for off in (0, 60, -480, 330, 1439, -1439, 765): + local = base + timedelta(minutes=off) + text = "Z" if off == 0 else (f"+{off // 60:02d}:{off % 60:02d}" if off > 0 else + f"-{-off // 60:02d}:{-off % 60:02d}") + for sec in (58, 59, 60): + yield stamp(local.year, local.month, local.day, local.hour, local.minute, + sec, "", text) + nxt = local + timedelta(minutes=1) + yield stamp(nxt.year, nxt.month, nxt.day, nxt.hour, nxt.minute, 0, "", text) + + +def si_grid(): + for x in LEAP_SI: + yield from (x - 2, x - 1, x, x + 1, x + 2) + top = LAST - EPOCH_1972 + len(LEAP_SI) + yield from (-1, 0, 1, top - 1, top, top + 1, 10 ** 20, -10 ** 20) + + def unix_grid(): """The ends of the range and one second past each, numbers at the edges of 32-bit counts, and the first second of every month of years @@ -450,6 +540,14 @@ def same_instant_pair(rng): ("fromunix 1.5", "a fraction"), ("fromunix 0x10", "hex"), ("fromunix \u0663", "a digit that is not ASCII"), + ("known", "no argument"), + ("known 3", "an odd number of hex digits"), + ("known 30 30", "two arguments"), + ("known ff", "not UTF-8"), + ("fromsi", "no number"), + ("fromsi +1", "a plus sign"), + ("fromsi 1.5", "a fraction"), + ("fromsi 1 2", "two numbers"), ] # A last line with no LF must still be answered. @@ -475,6 +573,10 @@ def cases(seed, count): fixed.append(("compare", f"compare {hexed(a)} {hexed(b)}", order(a, b), False)) for n in unix_grid(): fixed.append(("fromunix", f"fromunix {n}", from_unix(n), False)) + for s in month_end_grid(): + fixed.append(("known", f"known {hexed(s)}", known(s), False)) + for n in si_grid(): + fixed.append(("fromsi", f"fromsi {n}", from_si(n), False)) for req, _ in MALFORMED: fixed.append(("error", req, "error", False)) made = 0 @@ -483,7 +585,24 @@ def cases(seed, count): made += 1 while made < count: r = rng.random() + if r < 0.06: + s = random_valid(rng) if rng.random() < 0.8 else leap_pair(rng)[0] + yield ("known", f"known {hexed(s)}", known(s), False) + made += 1 + continue if r < 0.1: + r = rng.random() + if r < 0.4: + n = rng.randint(-10, LAST - EPOCH_1972 + 40) + elif r < 0.7: + n = rng.choice(LEAP_SI) + rng.randint(-86400, 86400) + else: + want = known(random_valid(rng)) + n = int(want.split("\t")[1]) if want.startswith("OK") else rng.randint(0, 10 ** 9) + yield ("fromsi", f"fromsi {n}", from_si(n), False) + made += 1 + continue + if r < 0.16: r = rng.random() if r < 0.3: n = rng.randint(FIRST - 10 ** 7, LAST + 10 ** 7) @@ -621,8 +740,9 @@ def check_mode(items, limit=20000, batch=500): def oracle(): - return (f"Python {platform.python_version()} `datetime` and `calendar`, and the " - f"RFC 3339 rules copied into this script") + return (f"Python {platform.python_version()} `datetime` and `calendar`, the " + f"RFC 3339 rules copied into this script, and for leap seconds tzdata's " + f"`{LEAP_FILE}`, read by this script") def lean_version(): @@ -713,6 +833,11 @@ def main(): leap = sum(1 for w in parsed if w.startswith("OK") and w.split("\t")[8] == "60") orders = [want for label, _, want, _ in items if label == "compare"] from_unix_wants = [want for label, _, want, _ in items if label == "fromunix"] + knowns = [want for label, _, want, _ in items if label == "known"] + known_leaps = sum(1 for label, req, want, _ in items if label == "known" and + want.startswith("OK") and reference(bytes.fromhex(req.split(" ")[1]).decode())["second"] == 60) + from_si_wants = [want for label, _, want, _ in items if label == "fromsi"] + expires = datetime(1900, 1, 1) + timedelta(seconds=LEAP_EXPIRES) if LEAP_EXPIRES else None mutated = sum(1 for item in items if item[3]) malformed = [got for item, got in zip(items, out) if item[0] == "error"] @@ -735,6 +860,14 @@ def main(): f"instant, {orders.count('>')} after, {orders.count('REJECT')} with a string rejected)", f"- Unix seconds turned back into timestamps: {len(from_unix_wants)}, of which " f"{from_unix_wants.count('NONE')} outside years 0000 to 9999", + f"- Leap second table: {LEAP_FILE}, {len(LEAP_TABLE)} lines, {len(LEAP_MIDNIGHTS)} leap " + f"seconds, expires {expires:%Y-%m-%d}" if expires else f"- Leap second table: {LEAP_FILE}", + f"- Timestamps checked against the table (`known`): {len(knowns)}, of which " + f"{known_leaps} leap seconds of the table, {knowns.count('NOLEAP')} with a second 60 it " + f"does not list, and {knowns.count('REJECT')} rejected", + f"- SI seconds turned back into timestamps (`fromsi`): {len(from_si_wants)}, of which " + f"{sum(1 for w in from_si_wants if w[17:19] == '60')} leap seconds and " + f"{from_si_wants.count('NONE')} outside 1972 to 9999", f"- Malformed requests answered `error`: {malformed.count('error')} of " f"{by_label.get('error', 0)}", f"- Requests ending in CR LF: every seventh, {len(items) // 7}", From 1e85966b2e2909824d6158f7048ec4d239b2d7bb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:59:25 +0000 Subject: [PATCH 02/11] Prove fromSiSeconds: the leap second slice is now fully proved `fromSiSeconds` turns SI seconds since 1972 back into the one UTC timestamp, valid by the table and from 1972 on, that has them, and gives one for every second up to 9999-12-31T23:59:59Z. With it, all seven leap second statements are proved, and `lake comparator --paranoid` accepts all 24 theorems. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WWUFjVe6uzWVQGpyU113ow --- Proofs/Leap/FromSi.lean | 381 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 379 insertions(+), 2 deletions(-) diff --git a/Proofs/Leap/FromSi.lean b/Proofs/Leap/FromSi.lean index 13f3552..6d33141 100644 --- a/Proofs/Leap/FromSi.lean +++ b/Proofs/Leap/FromSi.lean @@ -10,15 +10,392 @@ open Rfc3339 namespace Rfc3339.Proofs.Leap +/-! ## Counting a prefix -/ + +/-- A predicate closed downward on `0, …, N - 1` holds exactly below + the number of indices it holds for. -/ +theorem fs_count_prefix (p : Nat → Bool) : ∀ N : Nat, + (∀ i j, i ≤ j → j < N → p j = true → p i = true) → + ((List.range N).filter p).length ≤ N ∧ + ∀ i, i < N → (p i = true ↔ i < ((List.range N).filter p).length) + | 0, _ => by simp + | N + 1, hm => by + have ih := fs_count_prefix p N (fun i j hij hj hp => hm i j hij (by omega) hp) + rw [List.range_succ, List.filter_append, List.length_append] + cases hpN : p N with + | true => + have hall : ∀ i, i < N + 1 → p i = true := fun i hi => hm i N (by omega) (by omega) hpN + have hk : ((List.range N).filter p).length = N := by + rcases Nat.eq_zero_or_pos N with h0 | hpos + · subst h0; simp + · have := (ih.2 (N - 1) (by omega)).mp (hall _ (by omega)); omega + simp only [List.filter_cons, hpN, List.filter_nil, ite_true, List.length_singleton, hk] + refine ⟨by omega, fun i hi => ?_⟩ + simp only [hall i hi, true_iff]; omega + | false => + simp only [List.filter_cons, hpN, List.filter_nil, Bool.false_eq_true, ite_false, + List.length_nil, Nat.add_zero] + refine ⟨by omega, fun i hi => ?_⟩ + rcases Nat.lt_or_ge i N with h | h + · exact ih.2 i h + · have : i = N := by omega + subst this + simp only [hpN, Bool.false_eq_true, false_iff] + omega + +/-- The number of indices below `N` that satisfy `p`, when `p` holds + exactly below `k`. -/ +theorem fs_count_lt (p : Nat → Bool) (N k : Nat) (hk : k ≤ N) + (h : ∀ i, i < N → (p i = true ↔ i < k)) : ((List.range N).filter p).length = k := by + have hc := fs_count_prefix p N (fun i j hij hj hp => (h i (by omega)).mpr + (by have := (h j hj).mp hp; omega)) + rcases Nat.lt_trichotomy ((List.range N).filter p).length k with c | c | c + · exfalso + have h1 := (h _ (by omega)).mpr c + have h2 := (hc.2 _ (by omega)).mp h1 + omega + · exact c + · exfalso + have h1 := (hc.2 k (by omega)).mpr c + have h2 := (h k (by omega)).mp h1 + omega + +/-! ## The list, by index -/ + +/-- The `i`-th month of the list. -/ +def fs_ym (i : Nat) : Nat × Nat := leapMonths.getD i (0, 0) + +/-- The Unix seconds of the instant just after the `i`-th leap second, + less those of 1972-01-01T00:00:00Z. -/ +def fs_V (i : Nat) : Int := (leapKey (fs_ym i) + 1) / 61 * 60 - 719528 * 86400 - 63072000 + +theorem fs_leapMonths_eq : leapMonths = (List.range 27).map fs_ym := by decide +kernel + +theorem fs_leapSiSeconds_eq : leapSiSeconds = (List.range 27).map (fun i => fs_V i + i) := by + decide +kernel + +theorem fs_V_mono_dec : ∀ j, j < 27 → ∀ i, i < j → fs_V i < fs_V j := by decide +kernel + +theorem fs_V_mono (i j : Nat) (hij : i ≤ j) (hj : j < 27) : fs_V i ≤ fs_V j := by + rcases Nat.lt_or_ge i j with h | h + · exact Int.le_of_lt (fs_V_mono_dec j hj i h) + · have : i = j := by omega + subst this; exact Int.le_refl _ + +theorem fs_key_mod : ∀ i, i < 27 → (leapKey (fs_ym i) + 1) % 87840 = 0 := by decide +kernel + +theorem fs_V_zero : fs_V 0 = 15724800 := by decide +kernel + +theorem fs_V_last : fs_V 26 = 1420156800 := by decide +kernel + +theorem fs_mem_leapSiSeconds (n : Int) : n ∈ leapSiSeconds ↔ ∃ i, i < 27 ∧ fs_V i + i = n := by + rw [fs_leapSiSeconds_eq, List.mem_map] + simp only [List.mem_range] + +/-- The count `fromSiSeconds` makes. -/ +theorem fs_k_eq (n : Int) : (leapSiSeconds.filter (· < n)).length = + ((List.range 27).filter (fun i => decide (fs_V i + i < n))).length := by + rw [fs_leapSiSeconds_eq, List.filter_map, List.length_map] + rfl + +theorem fs_k_char (n : Int) : + (leapSiSeconds.filter (· < n)).length ≤ 27 ∧ + ∀ i, i < 27 → (fs_V i + i < n ↔ i < (leapSiSeconds.filter (· < n)).length) := by + rw [fs_k_eq] + have hc := fs_count_prefix (fun i => decide (fs_V i + i < n)) 27 (by + intro i j hij hj hp + simp only [decide_eq_true_eq] at hp ⊢ + have := fs_V_mono i j hij hj + omega) + refine ⟨hc.1, fun i hi => ?_⟩ + have := hc.2 i hi + simp only [decide_eq_true_eq] at this + exact this + +/-- Off the leap seconds, the `i`-th leap second comes before `n - k` + Unix seconds after 1972 exactly when it is one of the `k` below `n`. -/ +theorem fs_V_le_iff (n : Int) (hn : n ∉ leapSiSeconds) (i : Nat) (hi : i < 27) : + fs_V i ≤ n - (leapSiSeconds.filter (· < n)).length ↔ + i < (leapSiSeconds.filter (· < n)).length := by + obtain ⟨hk, hc⟩ := fs_k_char n + have hne : ∀ j, j < 27 → fs_V j + j ≠ n := fun j hj e => + hn ((fs_mem_leapSiSeconds n).mpr ⟨j, hj, e⟩) + generalize (leapSiSeconds.filter (· < n)).length = k at hk hc ⊢ + constructor + · intro h + apply Nat.lt_of_not_le + intro hik + have hk27 : k < 27 := by omega + have h1 : ¬ fs_V k + k < n := fun c => by have := (hc k hk27).mp c; omega + have h2 := hne k hk27 + have h3 := fs_V_mono k i hik hi + omega + · intro h + have h1 := (hc (k - 1) (by omega)).mpr (by omega) + have h2 := fs_V_mono i (k - 1) (by omega) (by omega) + omega + +/-! ## Leap seconds before a second other than 60 -/ + +theorem fs_unix_utc (t : Timestamp) : + t.unixSeconds = (t.utcMinute - 719528 * 1440) * 60 + t.second := by + unfold Timestamp.unixSeconds Timestamp.utcMinute + omega + +theorem fs_lt_key_iff (t : Timestamp) (h59 : t.second ≤ 59) (i : Nat) (hi : i < 27) : + leapKey (fs_ym i) < t.utcMinute * 61 + t.second ↔ fs_V i + 63072000 ≤ t.unixSeconds := by + have := fs_key_mod i hi + rw [fs_unix_utc] + unfold fs_V + omega + +theorem fs_leapsBefore_of_le59 (t : Timestamp) (h59 : t.second ≤ 59) : + t.leapsBefore = + ((List.range 27).filter (fun i => decide (fs_V i + 63072000 ≤ t.unixSeconds))).length := by + unfold Timestamp.leapsBefore + rw [fs_leapMonths_eq, List.filter_map, List.length_map] + congr 1 + apply List.filter_congr + intro i hi + rw [List.mem_range] at hi + exact decide_eq_decide.mpr (fs_lt_key_iff t h59 i hi) + + +/-! ## The leap seconds themselves -/ + +/-- What `fromSiSeconds n` gives, checked: a UTC timestamp with no + fraction, known valid, from 1972 on, with SI seconds `n`. -/ +def fs_check (n : Int) : Bool := + match fromSiSeconds n with + | some t => decide (t.offset = .z) && decide (t.fraction = []) && t.isValidKnown && + decide (1972 ≤ t.year) && decide (t.siSeconds = n) + | none => false + +theorem fs_check_leap : ∀ n ∈ leapSiSeconds, fs_check n = true := by decide +kernel + +/-! ## Bounds -/ + +theorem fs_unix_1972 : Spec.unixSeconds ⟨1972, 1, 1, 0, 0, 0, [], .z⟩ = 63072000 := by + decide +kernel + +theorem fs_dby_1972 : daysBeforeYear 1972 = 720258 := by decide + +theorem fs_si_max : Spec.siSeconds ⟨9999, 12, 31, 23, 59, 59, [], .z⟩ = 253339228826 := by + decide +kernel + +/-- A valid UTC second other than 60, at or after 1972-01-01T00:00:00Z, + is in 1972 or later. -/ +theorem fs_year_of_unix (t : Timestamp) (hv : Spec.Valid t) (hz : t.offset = .z) + (h59 : t.second ≤ 59) (hu : 63072000 ≤ Spec.unixSeconds t) : 1972 ≤ t.year := by + apply Nat.le_of_not_lt + intro hy + obtain ⟨_, hm1, hm2, hd1, hd2, hh, hmi, _⟩ := hv + have e := FromUnix.fu_unixSeconds_z t hz + have b := FromUnix.dy_dayNumber_bounds t.year t.month t.day ⟨hm1, hm2⟩ ⟨hd1, hd2⟩ + have m := FromUnix.dy_mono (show t.year + 1 ≤ 1972 by omega) + rw [fs_dby_1972] at m + omega + +/-- A valid UTC timestamp in 1972 or later is at or after + 1972-01-01T00:00:00Z. -/ +theorem fs_unix_of_year (t : Timestamp) (hv : Spec.Valid t) (hz : t.offset = .z) + (hy : 1972 ≤ t.year) : 63072000 ≤ Spec.unixSeconds t := by + obtain ⟨_, hm1, hm2, hd1, hd2, _⟩ := hv + have e := FromUnix.fu_unixSeconds_z t hz + have b := FromUnix.dy_dayNumber_bounds t.year t.month t.day ⟨hm1, hm2⟩ ⟨hd1, hd2⟩ + have m := FromUnix.dy_mono hy + rw [fs_dby_1972] at m + omega + +theorem fs_si_nonneg (t : Timestamp) (hv : Spec.Valid t) (hz : t.offset = .z) + (hy : 1972 ≤ t.year) : 0 ≤ Spec.siSeconds t := by + unfold Spec.siSeconds + rw [fs_unix_1972] + have := fs_unix_of_year t hv hz hy + omega + +theorem fs_Tmax_known : Spec.ValidKnown ⟨9999, 12, 31, 23, 59, 59, [], .z⟩ := + (isValidKnown_iff _).mp (by decide +kernel) + +theorem fs_Tmax_utcMinute : + Timestamp.utcMinute ⟨9999, 12, 31, 23, 59, 59, [], .z⟩ = 5259491999 := by decide +kernel + +theorem fs_leapKey_lt_max : ∀ ym ∈ leapMonths, leapKey ym < 5259491999 * 61 + 59 := by + decide +kernel + +theorem fs_key_le_max (t : Timestamp) (hk : Spec.ValidKnown t) (hz : t.offset = .z) : + t.utcMinute * 61 + t.second ≤ 5259491999 * 61 + 59 := by + obtain ⟨hy, hm1, hm2, hd1, hd2, hh, hmi, hs, _⟩ := hk.1 + by_cases h60 : t.second = 60 + · obtain ⟨ym, hym, e⟩ := leapKey_of_known t hk h60 + have := fs_leapKey_lt_max ym hym + omega + · have hN := FromUnix.dayNumber_lt t.year t.month t.day hy ⟨hm1, hm2⟩ ⟨hd1, hd2⟩ + rw [FromUnix.fu_daysBeforeYear_10000, ← DayNumber.dayNumber_eq _ _ _ hm1 hm2] at hN + unfold Timestamp.utcMinute + rw [hz] + simp only [offsetMinutes] + omega + +/-- A known-valid UTC whole second is no later than + 9999-12-31T23:59:59Z, in SI seconds. -/ +theorem fs_si_le_max (t : Timestamp) (hk : Spec.ValidKnown t) (hz : t.offset = .z) + (hf : t.fraction = []) : + Spec.siSeconds t ≤ Spec.siSeconds ⟨9999, 12, 31, 23, 59, 59, [], .z⟩ := by + have ho := siSeconds_order t _ hk fs_Tmax_known hf rfl + rw [siSeconds_eq t hk.1, siSeconds_eq _ fs_Tmax_known.1] at ho + have hkey : t.utcMinute * 61 + (t.second : Int) ≤ + Timestamp.utcMinute ⟨9999, 12, 31, 23, 59, 59, [], .z⟩ * 61 + + ((Timestamp.second ⟨9999, 12, 31, 23, 59, 59, [], .z⟩ : Nat) : Int) := by + rw [fs_Tmax_utcMinute] + exact fs_key_le_max t hk hz + apply Int.not_lt.mp + intro c + rw [Int.compare_eq_gt.mpr c] at ho + unfold Timestamp.compareInstant at ho + rcases Int.lt_or_eq_of_le hkey with h | h + · rw [Int.compare_eq_lt.mpr h] at ho + cases ho + · rw [Int.compare_eq_eq.mpr h, hf] at ho + simp [compareFraction] at ho + +/-! ## Uniqueness -/ + +theorem fs_eq_of_key (t t' : Timestamp) (hv : Spec.Valid t) (hv' : Spec.Valid t') + (hz : t.offset = .z) (hz' : t'.offset = .z) (hf : t.fraction = []) (hf' : t'.fraction = []) + (h : t.utcMinute * 61 + t.second = t'.utcMinute * 61 + t'.second) : t = t' := by + obtain ⟨y, m, d, hh, mi, s, fr, off⟩ := t + obtain ⟨y', m', d', hh', mi', s', fr', off'⟩ := t' + simp only at hz hz' hf hf' + subst hz hz' hf hf' + obtain ⟨_, hm1, hm2, hd1, hd2, hh23, hmi, hs, _⟩ := hv + obtain ⟨_, hm1', hm2', hd1', hd2', hh23', hmi', hs', _⟩ := hv' + simp only at hm1 hm2 hd1 hd2 hh23 hmi hs hm1' hm2' hd1' hd2' hh23' hmi' hs' + unfold Timestamp.utcMinute at h + simp only [offsetMinutes] at h + rw [DayNumber.dayNumber_eq _ _ _ hm1 hm2, DayNumber.dayNumber_eq _ _ _ hm1' hm2'] at h + have hdn : Spec.dayNumber y m d = Spec.dayNumber y' m' d' := by omega + obtain ⟨rfl, rfl, rfl⟩ := FromUnix.dayNumber_inj y m d y' m' d' ⟨hm1, hm2⟩ ⟨hd1, hd2⟩ + ⟨hm1', hm2'⟩ ⟨hd1', hd2'⟩ hdn + have : hh = hh' ∧ mi = mi' ∧ s = s' := by omega + obtain ⟨rfl, rfl, rfl⟩ := this + rfl + +theorem fs_eq_of_si (t t' : Timestamp) (hk : Spec.ValidKnown t) (hk' : Spec.ValidKnown t') + (hz : t.offset = .z) (hz' : t'.offset = .z) (hf : t.fraction = []) (hf' : t'.fraction = []) + (h : Spec.siSeconds t = Spec.siSeconds t') : t = t' := by + have ho := siSeconds_order t t' hk hk' hf hf' + rw [siSeconds_eq t hk.1, siSeconds_eq t' hk'.1, h, Int.compare_eq_eq.mpr rfl] at ho + apply fs_eq_of_key t t' hk.1 hk'.1 hz hz' hf hf' + unfold Timestamp.compareInstant at ho + rw [hf, hf'] at ho + rcases Int.lt_trichotomy (t.utcMinute * 61 + t.second) (t'.utcMinute * 61 + t'.second) + with c | c | c + · rw [Int.compare_eq_lt.mpr c] at ho; cases ho + · exact c + · rw [Int.compare_eq_gt.mpr c] at ho; cases ho + +/-! ## The two directions -/ + +theorem fs_forward (n : Int) (t : Timestamp) (h : fromSiSeconds n = some t) : + t.offset = .z ∧ t.fraction = [] ∧ Spec.ValidKnown t ∧ 1972 ≤ t.year ∧ + Spec.siSeconds t = n := by + by_cases hl : n ∈ leapSiSeconds + · have hc := fs_check_leap n hl + unfold fs_check at hc + rw [h] at hc + simp only [Bool.and_eq_true, decide_eq_true_eq] at hc + obtain ⟨⟨⟨⟨hz, hf⟩, hk⟩, hy⟩, hs⟩ := hc + have hk' := (isValidKnown_iff t).mp hk + exact ⟨hz, hf, hk', hy, by rw [← siSeconds_eq t hk'.1]; exact hs⟩ + · have hcont : leapSiSeconds.contains n = false := by + cases e : leapSiSeconds.contains n + · rfl + · exact absurd (List.contains_iff_mem.mp e) hl + unfold fromSiSeconds at h + split at h + · contradiction + rename_i hn0 + simp only [hcont, Bool.false_eq_true, ite_false] at h + obtain ⟨hv, hz, hf, h59, hu⟩ := (Spec.fromUnixSeconds_iff _ _).mp h + have hU : t.unixSeconds = n + 63072000 - (leapSiSeconds.filter (· < n)).length := by + rw [Instant.unixSeconds_eq t hv]; exact hu + obtain ⟨hk27, hc⟩ := fs_k_char n + have hlb : t.leapsBefore = (leapSiSeconds.filter (· < n)).length := by + rw [fs_leapsBefore_of_le59 t h59] + apply fs_count_lt _ 27 _ hk27 + intro i hi + rw [decide_eq_true_iff, hU] + have := fs_V_le_iff n hl i hi + constructor + · intro a; exact this.mp (by omega) + · intro a; have := this.mpr a; omega + have hnk : ((leapSiSeconds.filter (· < n)).length : Int) ≤ n := by + have key : ∀ k : Nat, k ≤ 27 → (∀ i, i < 27 → (fs_V i + i < n ↔ i < k)) → + (k : Int) ≤ n := by + intro k hk hc + rcases Nat.eq_zero_or_pos k with e | e + · subst e; omega + · have h1 := (hc (k - 1) (by omega)).mpr (by omega) + have h2 := fs_V_mono 0 (k - 1) (Nat.zero_le _) (by omega) + rw [fs_V_zero] at h2 + omega + exact key _ hk27 hc + have hs : Spec.siSeconds t = n := by + rw [← siSeconds_eq t hv] + unfold Timestamp.siSeconds + rw [hU, hlb] + omega + refine ⟨hz, hf, ⟨hv, fun h60 => absurd h60 (by omega)⟩, + fs_year_of_unix t hv hz h59 (by omega), hs⟩ + +theorem fs_isSome_of_bounds (n : Int) (h0 : 0 ≤ n) (h1 : n ≤ 253339228826) : + (fromSiSeconds n).isSome := by + obtain ⟨hk27, hc⟩ := fs_k_char n + have h26 := hc 26 (by omega) + rw [fs_V_last] at h26 + have hb : n + 63072000 - (leapSiSeconds.filter (· < n)).length ≤ 253402300799 := by + by_cases c : (1420156800 : Int) + ((26 : Nat) : Int) < n + · have := h26.mp c; omega + · omega + unfold fromSiSeconds + split + · exact absurd ‹n < 0› (by omega) + dsimp only + split + · rw [Option.isSome_map] + apply (Spec.fromUnixSeconds_isSome _).mpr + rw [FromUnix.fu_unixSeconds_lo, FromUnix.fu_unixSeconds_hi] + omega + · apply (Spec.fromUnixSeconds_isSome _).mpr + rw [FromUnix.fu_unixSeconds_lo, FromUnix.fu_unixSeconds_hi] + omega + theorem fromSiSeconds_iff (n : Int) (t : Timestamp) : fromSiSeconds n = some t ↔ t.offset = .z ∧ t.fraction = [] ∧ Spec.ValidKnown t ∧ 1972 ≤ t.year ∧ Spec.siSeconds t = n := by - sorry + constructor + · exact fs_forward n t + · rintro ⟨hz, hf, hk, hy, hs⟩ + have h0 : 0 ≤ n := hs ▸ fs_si_nonneg t hk.1 hz hy + have h1 : n ≤ 253339228826 := by rw [← hs, ← fs_si_max]; exact fs_si_le_max t hk hz hf + obtain ⟨t', ht'⟩ := Option.isSome_iff_exists.mp (fs_isSome_of_bounds n h0 h1) + obtain ⟨hz', hf', hk', -, hs'⟩ := fs_forward n t' ht' + rw [ht'] + congr 1 + exact (fs_eq_of_si t t' hk hk' hz hz' hf hf' (by rw [hs, hs'])).symm theorem fromSiSeconds_isSome (n : Int) : (fromSiSeconds n).isSome ↔ 0 ≤ n ∧ n ≤ Spec.siSeconds ⟨9999, 12, 31, 23, 59, 59, [], .z⟩ := by - sorry + constructor + · intro h + obtain ⟨t, ht⟩ := Option.isSome_iff_exists.mp h + obtain ⟨hz, hf, hk, hy, hs⟩ := fs_forward n t ht + exact ⟨hs ▸ fs_si_nonneg t hk.1 hz hy, hs ▸ fs_si_le_max t hk hz hf⟩ + · rintro ⟨h0, h1⟩ + rw [fs_si_max] at h1 + exact fs_isSome_of_bounds n h0 h1 end Rfc3339.Proofs.Leap From 9d62a8da24b09f44c7b2f6dfbd2e51acbc92ee33 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:07:00 +0000 Subject: [PATCH 03/11] Work in progress: RFC 9557, the Internet Extended Date/Time Format Types, code, the spec from RFC 9557's grammar and rules, seven statements and a proof skeleton. The proofs, the command-line request, the tests and the page are still to come. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WWUFjVe6uzWVQGpyU113ow --- Proofs.lean | 1 + Proofs/Ixdtf/Main.lean | 85 ++++++++++++++++++ Proofs/Ixdtf/Parse.lean | 29 ++++++ Proofs/Ixdtf/Rules.lean | 21 +++++ Proofs/Ixdtf/Suffix.lean | 32 +++++++ Rfc3339.lean | 2 + Rfc3339/Ixdtf.lean | 155 ++++++++++++++++++++++++++++++++ Rfc3339/IxdtfTypes.lean | 43 +++++++++ Spec.lean | 89 ++++++++++++++++++ Spec/Ixdtf.lean | 190 +++++++++++++++++++++++++++++++++++++++ comparator.json | 9 +- 11 files changed, 655 insertions(+), 1 deletion(-) create mode 100644 Proofs/Ixdtf/Main.lean create mode 100644 Proofs/Ixdtf/Parse.lean create mode 100644 Proofs/Ixdtf/Rules.lean create mode 100644 Proofs/Ixdtf/Suffix.lean create mode 100644 Rfc3339/Ixdtf.lean create mode 100644 Rfc3339/IxdtfTypes.lean create mode 100644 Spec/Ixdtf.lean diff --git a/Proofs.lean b/Proofs.lean index 80f7eb1..1eedfed 100644 --- a/Proofs.lean +++ b/Proofs.lean @@ -7,3 +7,4 @@ import Proofs.Main import Proofs.FromUnix.Main import Proofs.Leap.Main +import Proofs.Ixdtf.Main diff --git a/Proofs/Ixdtf/Main.lean b/Proofs/Ixdtf/Main.lean new file mode 100644 index 0000000..016c6d5 --- /dev/null +++ b/Proofs/Ixdtf/Main.lean @@ -0,0 +1,85 @@ +/- + The IXDTF statements of `Spec.lean`, under the same names, from the + lemmas in the other files of `Proofs/Ixdtf/`. The test vectors are + checked by evaluation. +-/ + +import Proofs.Ixdtf.Parse +import Proofs.Ixdtf.Rules + +open Rfc3339 + +theorem Spec.parseExt_iff (s : String) (e : Ext) : + parseExt s = some e ↔ + Spec.ExtValid e ∧ ∃ dt, s.toList = dt ++ Spec.renderSuffix e ∧ + dt.map Spec.asciiUpper = Spec.render e.stamp := + Rfc3339.Proofs.Ixdtf.parseExt_iff s e + +theorem Spec.printExt_eq (e : Ext) (h : Spec.ExtValid e) : + e.print.toList = Spec.render e.stamp ++ Spec.renderSuffix e := + Rfc3339.Proofs.Ixdtf.printExt_eq e h + +theorem Spec.parseExt_print (e : Ext) (h : Spec.ExtValid e) : parseExt e.print = some e := + Rfc3339.Proofs.Ixdtf.parseExt_print e h + +theorem Spec.parseExt_plain (s : String) (h : '[' ∉ s.toList) : + parseExt s = (parse s).map fun t => ⟨t, none, []⟩ := + Rfc3339.Proofs.Ixdtf.parseExt_plain s h + +theorem Spec.lookup_iff (e : Ext) (k : List Char) (t : Tag) : + e.lookup k = some t ↔ ∃ l r, e.tags = l ++ t :: r ∧ t.key = k ∧ ∀ u ∈ l, u.key ≠ k := + Rfc3339.Proofs.Ixdtf.lookup_iff e k t + +theorem Spec.erroneous_iff (p : Processor) (e : Ext) : + e.erroneous p = true ↔ Spec.MustReject p e := + Rfc3339.Proofs.Ixdtf.erroneous_iff p e + +theorem Spec.ixdtf_vectors : + (parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]" = + some ⟨⟨1996, 12, 19, 16, 39, 57, [], .minus 8 0⟩, + some (false, .name ["America".toList, "Los_Angeles".toList]), + [⟨false, "u-ca".toList, ["hebrew".toList]⟩]⟩) ∧ + (parseExt "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]" = + some ⟨⟨1996, 12, 19, 16, 39, 57, [], .minus 8 0⟩, none, + [⟨false, "_foo".toList, ["bar".toList]⟩, ⟨false, "_baz".toList, ["bat".toList]⟩]⟩) ∧ + -- Elective: an inconsistent zone, an unknown key, repeated keys + ((parseExt "2022-07-08T00:14:07+01:00[Europe/Paris]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ + ((parseExt "2022-07-08T00:14:07+01:00[knort=blargel]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ + ((parseExt "2022-07-08T00:14:07Z[u-ca=chinese][u-ca=japanese]").map + (·.erroneous Spec.exampleProcessor) = some false) ∧ + ((parseExt "2022-07-08T00:14:07Z[u-ca=chinese][u-ca=japanese]").bind (·.lookup "u-ca".toList) = + some ⟨false, "u-ca".toList, ["chinese".toList]⟩) ∧ + -- Critical: the four of section 3.3 + ((parseExt "2022-07-08T00:14:07+01:00[!Europe/Paris]").map (·.erroneous Spec.exampleProcessor) = + some true) ∧ + ((parseExt "2022-07-08T00:14:07Z[!u-ca=chinese][u-ca=japanese]").map + (·.erroneous Spec.exampleProcessor) = some true) ∧ + ((parseExt "2022-07-08T00:14:07Z[u-ca=chinese][!u-ca=japanese]").map + (·.erroneous Spec.exampleProcessor) = some true) ∧ + ((parseExt "2022-07-08T00:14:07Z[!knort=blargel]").map (·.erroneous Spec.exampleProcessor) = + some true) ∧ + -- Figures 1 and 2 of section 3.4 + ((parseExt "2022-07-08T00:14:07+00:00[!Europe/London]").map (·.erroneous Spec.exampleProcessor) = + some true) ∧ + ((parseExt "2022-07-08T00:14:07+00:00[Europe/London]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ + ((parseExt "2022-07-08T00:14:07Z[!Europe/London]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ + ((parseExt "2022-07-08T00:14:07Z[Europe/London]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ + -- An experiment the application does not take part in + ((parseExt "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]").map + (·.erroneous Spec.exampleProcessor) = some true) ∧ + -- Refused by the grammar: an empty item, `.` and `..`, an upper case + -- key, an empty value, a time zone after a tag, `Z` as an offset + -- (it reads as a zone name), an offset out of range + parseExt "2022-07-08T00:14:07Z[]" = none ∧ + parseExt "2022-07-08T00:14:07Z[.]" = none ∧ + parseExt "2022-07-08T00:14:07Z[a/..]" = none ∧ + parseExt "2022-07-08T00:14:07Z[U-ca=x]" = none ∧ + parseExt "2022-07-08T00:14:07Z[u-ca=a--b]" = none ∧ + parseExt "2022-07-08T00:14:07Z[u-ca=x][Europe/Paris]" = none ∧ + parseExt "2022-07-08T00:14:07Z[+24:00]" = none := by + and_intros <;> decide +kernel diff --git a/Proofs/Ixdtf/Parse.lean b/Proofs/Ixdtf/Parse.lean new file mode 100644 index 0000000..b1ba247 --- /dev/null +++ b/Proofs/Ixdtf/Parse.lean @@ -0,0 +1,29 @@ +/- + `parseExt` and `Ext.print` against the grammar. +-/ + +import Proofs.Ixdtf.Suffix +import Proofs.Main + +open Rfc3339 + +namespace Rfc3339.Proofs.Ixdtf + +theorem parseExt_iff (s : String) (e : Ext) : + parseExt s = some e ↔ + Spec.ExtValid e ∧ ∃ dt, s.toList = dt ++ Spec.renderSuffix e ∧ + dt.map Spec.asciiUpper = Spec.render e.stamp := by + sorry + +theorem printExt_eq (e : Ext) (h : Spec.ExtValid e) : + e.print.toList = Spec.render e.stamp ++ Spec.renderSuffix e := by + sorry + +theorem parseExt_print (e : Ext) (h : Spec.ExtValid e) : parseExt e.print = some e := by + sorry + +theorem parseExt_plain (s : String) (h : '[' ∉ s.toList) : + parseExt s = (parse s).map fun t => ⟨t, none, []⟩ := by + sorry + +end Rfc3339.Proofs.Ixdtf diff --git a/Proofs/Ixdtf/Rules.lean b/Proofs/Ixdtf/Rules.lean new file mode 100644 index 0000000..1e1f915 --- /dev/null +++ b/Proofs/Ixdtf/Rules.lean @@ -0,0 +1,21 @@ +/- + `Ext.lookup` and `Ext.erroneous` against sections 3.2 to 3.4. +-/ + +import Rfc3339 +import Spec.Ixdtf +import Proofs.Valid + +open Rfc3339 + +namespace Rfc3339.Proofs.Ixdtf + +theorem lookup_iff (e : Ext) (k : List Char) (t : Tag) : + e.lookup k = some t ↔ ∃ l r, e.tags = l ++ t :: r ∧ t.key = k ∧ ∀ u ∈ l, u.key ≠ k := by + sorry + +theorem erroneous_iff (p : Processor) (e : Ext) : + e.erroneous p = true ↔ Spec.MustReject p e := by + sorry + +end Rfc3339.Proofs.Ixdtf diff --git a/Proofs/Ixdtf/Suffix.lean b/Proofs/Ixdtf/Suffix.lean new file mode 100644 index 0000000..1545036 --- /dev/null +++ b/Proofs/Ixdtf/Suffix.lean @@ -0,0 +1,32 @@ +/- + The suffix parser against the grammar of RFC 9557, section 4.1. +-/ + +import Rfc3339 +import Spec.Ixdtf + +open Rfc3339 + +namespace Rfc3339.Proofs.Ixdtf + +/-- `parseSuffix` reads exactly the suffixes of the grammar, as + `Spec.renderSuffix` writes them. -/ +theorem parseSuffix_iff (cs : List Char) (e : Ext) : + parseSuffix cs = some (e.zone, e.tags) ↔ + ((∀ c z, e.zone = some (c, z) → Spec.ZoneValid z) ∧ ∀ t ∈ e.tags, Spec.TagValid t) ∧ + cs = Spec.renderSuffix e := by + sorry + +/-- The code's rendering of the suffix is the spec's. -/ +theorem renderSuffix_eq (e : Ext) (h : ∀ c z, e.zone = some (c, z) → Spec.ZoneValid z) : + e.renderSuffix = Spec.renderSuffix e := by + sorry + +/-- A suffix of the grammar is empty or starts with `[`, and holds no + other `[` than at the start of each item; in particular a rendered + suffix starts with `[` when it is not empty. -/ +theorem renderSuffix_head (e : Ext) : + Spec.renderSuffix e = [] ∨ (Spec.renderSuffix e).head? = some '[' := by + sorry + +end Rfc3339.Proofs.Ixdtf diff --git a/Rfc3339.lean b/Rfc3339.lean index 70cee98..96ce4d0 100644 --- a/Rfc3339.lean +++ b/Rfc3339.lean @@ -9,3 +9,5 @@ import Rfc3339.Format import Rfc3339.Instant import Rfc3339.FromUnix import Rfc3339.Leap +import Rfc3339.IxdtfTypes +import Rfc3339.Ixdtf diff --git a/Rfc3339/Ixdtf.lean b/Rfc3339/Ixdtf.lean new file mode 100644 index 0000000..2a23618 --- /dev/null +++ b/Rfc3339/Ixdtf.lean @@ -0,0 +1,155 @@ +/- + RFC 9557, the Internet Extended Date/Time Format (IXDTF): reading and + writing the suffix, and the rules for elective and critical tags. +-/ + +import Rfc3339.IxdtfTypes +import Rfc3339.Format + +namespace Rfc3339 + +def isAlpha (c : Char) : Bool := ('A' ≤ c && c ≤ 'Z') || ('a' ≤ c && c ≤ 'z') + +def isDigitChar (c : Char) : Bool := '0' ≤ c && c ≤ '9' + +/-- `time-zone-part`: a letter, `.` or `_`, then letters, digits, `.`, + `_`, `-` and `+`; but not `.` or `..`. -/ +def zonePartOK : List Char → Bool + | [] => false + | c :: cs => + (isAlpha c || c == '.' || c == '_') && + cs.all (fun d => isAlpha d || isDigitChar d || d == '.' || d == '_' || d == '-' || d == '+') && + c :: cs != ['.'] && c :: cs != ['.', '.'] + +/-- `suffix-key`: a lower case letter or `_`, then lower case letters, + digits, `_` and `-`. -/ +def keyOK : List Char → Bool + | [] => false + | c :: cs => + (('a' ≤ c && c ≤ 'z') || c == '_') && + cs.all (fun d => ('a' ≤ d && d ≤ 'z') || d == '_' || isDigitChar d || d == '-') + +/-- `suffix-value`: one or more letters and digits. -/ +def valueOK (v : List Char) : Bool := !v.isEmpty && v.all (fun c => isAlpha c || isDigitChar c) + +/-- The body of a time zone item, after any `!`: a numeric offset, with + hours 00 to 23 and minutes 00 to 59, or a name. -/ +def parseZone (body : List Char) : Option Zone := + match body with + | '+' :: _ | '-' :: _ => + match parseOffset body with + | some o => if o.isValid then some (.offset o) else none + | none => none + | _ => + let parts := body.splitOn '/' + if parts.all zonePartOK then some (.name parts) else none + +/-- The body of a tag, after any `!`: the key, `=`, and the values, + separated by `-`. -/ +def parseTag (critical : Bool) (body : List Char) : Option Tag := + let key := body.takeWhile (· ≠ '=') + match body.drop key.length with + | '=' :: vs => + let values := vs.splitOn '-' + if keyOK key && values.all valueOK then some ⟨critical, key, values⟩ else none + | _ => none + +/-- The bracketed items of a suffix, each with its critical flag and its + body. Each item takes at least two characters, so `fuel` equal to + the length is enough. -/ +def suffixItems : Nat → List Char → Option (List (Bool × List Char)) + | _, [] => some [] + | 0, _ :: _ => none + | fuel + 1, c :: rest => + if c = '[' then + let body := rest.takeWhile (· ≠ ']') + match rest.drop body.length with + | ']' :: more => do + let tail ← suffixItems fuel more + match body with + | '!' :: b => pure ((true, b) :: tail) + | b => pure ((false, b) :: tail) + | _ => none + else none + +/-- Reads a suffix: a time zone first, if the first item has no `=`, + then tags. -/ +def parseSuffix (s : List Char) : Option (Option (Bool × Zone) × List Tag) := do + let items ← suffixItems s.length s + match items with + | [] => pure (none, []) + | (crit, body) :: rest => + let tags ← rest.mapM fun (c, b) => parseTag c b + if body.contains '=' then + let t ← parseTag crit body + pure (none, t :: tags) + else + let z ← parseZone body + pure (some (crit, z), tags) + +/-- Reads an IXDTF string: an RFC 3339 `date-time`, which `parse` reads, + then the suffix. The suffix starts at the first `[`. -/ +def parseExt (s : String) : Option Ext := do + let cs := s.toList + let dt := cs.takeWhile (· ≠ '[') + let stamp ← parse (String.ofList dt) + let (zone, tags) ← parseSuffix (cs.drop dt.length) + pure ⟨stamp, zone, tags⟩ + +/-- The critical flag. -/ +def renderFlag (critical : Bool) : List Char := if critical then ['!'] else [] + +def Zone.render : Zone → List Char + | .name parts => ['/'].intercalate parts + | .offset o => o.render + +def Tag.render (t : Tag) : List Char := + ['['] ++ renderFlag t.critical ++ t.key ++ ['='] ++ ['-'].intercalate t.values ++ [']'] + +/-- The suffix: the time zone, then the tags. -/ +def Ext.renderSuffix (e : Ext) : List Char := + (match e.zone with + | some (c, z) => ['['] ++ renderFlag c ++ z.render ++ [']'] + | none => []) ++ + e.tags.flatMap Tag.render + +/-- The IXDTF string, with `T` and `Z` in upper case. -/ +def Ext.print (e : Ext) : String := String.ofList (e.stamp.render ++ e.renderSuffix) + +/-- The first tag with the key: section 3.3 has an application that does + not handle a repeated elective key choose the first. -/ +def Ext.lookup (e : Ext) (key : List Char) : Option Tag := + e.tags.find? (·.key == key) + +/-- Whether the timestamp's offset asserts a local offset. Section 2 + makes `Z` mean the same as `-00:00`: the time in UTC is known, the + local offset is not. -/ +def assertsOffset : Offset → Bool + | .z => false + | .minus 0 0 => false + | _ => true + +/-- Whether the time zone agrees with the timestamp, for section 3.4: + the zone is known, and if the timestamp asserts a local offset, the + zone has that offset at that instant. -/ +def zoneAgrees (p : Processor) (t : Timestamp) : Zone → Bool + | .offset o => !assertsOffset t.offset || offsetMinutes o == offsetMinutes t.offset + | .name parts => + match p.zoneOffset parts t with + | some m => !assertsOffset t.offset || m == offsetMinutes t.offset + | none => false + +/-- Whether an application must treat the string as erroneous, by + sections 3.2 to 3.4: a critical time zone that is unknown or does not + agree with the offset; a critical tag it cannot process; a critical + tag whose key another tag gives other values; or an experimental + key, starting with `_`, that it does not take part in. -/ +def Ext.erroneous (p : Processor) (e : Ext) : Bool := + (match e.zone with + | some (true, z) => !zoneAgrees p e.stamp z + | _ => false) || + e.tags.any (fun t => t.critical && !p.processes t) || + e.tags.any (fun t => t.critical && e.tags.any fun u => u.key == t.key && u.values != t.values) || + e.tags.any (fun t => t.key.head? == some '_' && !p.processes t) + +end Rfc3339 diff --git a/Rfc3339/IxdtfTypes.lean b/Rfc3339/IxdtfTypes.lean new file mode 100644 index 0000000..c28c76c --- /dev/null +++ b/Rfc3339/IxdtfTypes.lean @@ -0,0 +1,43 @@ +/- + What an RFC 9557 (IXDTF) timestamp is: an RFC 3339 timestamp, then a + time zone and tags in brackets. Part of the approved specification, + with `Spec.lean` and `Spec/Ixdtf.lean`, since the IXDTF statements are + about these types. +-/ + +import Rfc3339.Types + +namespace Rfc3339 + +/-- The time zone in a suffix: a time zone name, kept as the parts + between its slashes, or a numeric offset (`time-numoffset`). -/ +inductive Zone where + | name (parts : List (List Char)) + | offset (o : Offset) + deriving DecidableEq, Repr + +/-- A suffix tag: whether it is marked critical with `!`, its key, and + its values, the parts between its hyphens. -/ +structure Tag where + critical : Bool + key : List Char + values : List (List Char) + deriving DecidableEq, Repr + +/-- An IXDTF timestamp: the RFC 3339 timestamp, the time zone if there + is one with whether it is critical, and the tags in order. -/ +structure Ext where + stamp : Timestamp + zone : Option (Bool × Zone) + tags : List Tag + deriving DecidableEq, Repr + +/-- What an application can act on, for the rules of sections 3.3 and + 3.4: the tags it can process, and for a time zone name, the offset in + minutes the zone has at a given instant, or `none` when it does not + know the zone. -/ +structure Processor where + processes : Tag → Bool + zoneOffset : List (List Char) → Timestamp → Option Int + +end Rfc3339 diff --git a/Spec.lean b/Spec.lean index 62189b7..201fbf1 100644 --- a/Spec.lean +++ b/Spec.lean @@ -15,6 +15,7 @@ import Rfc3339 import Spec.Defs import Spec.Leap +import Spec.Ixdtf open Rfc3339 @@ -246,3 +247,91 @@ theorem Spec.leap_table_vectors : fromSiSeconds 1420156827 = some ⟨2017, 1, 1, 0, 0, 0, [], .z⟩ ∧ fromUnixSeconds (Timestamp.unixSeconds ⟨2016, 12, 31, 23, 59, 60, [], .z⟩) = some ⟨2017, 1, 1, 0, 0, 0, [], .z⟩ := sorry + +/-! ## RFC 9557: the Internet Extended Date/Time Format -/ + +/-- `parseExt` returns `e` exactly when `e` is an IXDTF timestamp the + grammar of section 4.1 allows and the string is how it is written: + the RFC 3339 part with `T` and `Z` in either case, then the suffix + as written. So it accepts nothing outside the grammar, rejects + nothing inside it, and returns the time zone and tags as written. -/ +theorem Spec.parseExt_iff (s : String) (e : Ext) : + parseExt s = some e ↔ + Spec.ExtValid e ∧ ∃ dt, s.toList = dt ++ Spec.renderSuffix e ∧ + dt.map Spec.asciiUpper = Spec.render e.stamp := sorry + +/-- An IXDTF timestamp prints as the grammar writes it, with `T` and `Z` + in upper case. -/ +theorem Spec.printExt_eq (e : Ext) (h : Spec.ExtValid e) : + e.print.toList = Spec.render e.stamp ++ Spec.renderSuffix e := sorry + +/-- Round trip: printing then parsing gives the IXDTF timestamp back. -/ +theorem Spec.parseExt_print (e : Ext) (h : Spec.ExtValid e) : parseExt e.print = some e := sorry + +/-- IXDTF extends RFC 3339: a string with no suffix reads as `parse` + reads it, with no time zone and no tags. -/ +theorem Spec.parseExt_plain (s : String) (h : '[' ∉ s.toList) : + parseExt s = (parse s).map fun t => ⟨t, none, []⟩ := sorry + +/-- `lookup` gives the first tag with the key, which section 3.3 has an + application choose when an elective key is repeated. -/ +theorem Spec.lookup_iff (e : Ext) (k : List Char) (t : Tag) : + e.lookup k = some t ↔ ∃ l r, e.tags = l ++ t :: r ∧ t.key = k ∧ ∀ u ∈ l, u.key ≠ k := sorry + +/-- `erroneous` decides what sections 3.2 to 3.4 make an application + treat as erroneous. -/ +theorem Spec.erroneous_iff (p : Processor) (e : Ext) : + e.erroneous p = true ↔ Spec.MustReject p e := sorry + +/-- The examples of sections 3.3, 3.4 and 4.2, with the application of + `Spec.exampleProcessor`: each one is read, and is erroneous exactly + where the RFC says it must be treated so. And strings the grammar + refuses. -/ +theorem Spec.ixdtf_vectors : + (parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]" = + some ⟨⟨1996, 12, 19, 16, 39, 57, [], .minus 8 0⟩, + some (false, .name ["America".toList, "Los_Angeles".toList]), + [⟨false, "u-ca".toList, ["hebrew".toList]⟩]⟩) ∧ + (parseExt "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]" = + some ⟨⟨1996, 12, 19, 16, 39, 57, [], .minus 8 0⟩, none, + [⟨false, "_foo".toList, ["bar".toList]⟩, ⟨false, "_baz".toList, ["bat".toList]⟩]⟩) ∧ + -- Elective: an inconsistent zone, an unknown key, repeated keys + ((parseExt "2022-07-08T00:14:07+01:00[Europe/Paris]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ + ((parseExt "2022-07-08T00:14:07+01:00[knort=blargel]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ + ((parseExt "2022-07-08T00:14:07Z[u-ca=chinese][u-ca=japanese]").map + (·.erroneous Spec.exampleProcessor) = some false) ∧ + ((parseExt "2022-07-08T00:14:07Z[u-ca=chinese][u-ca=japanese]").bind (·.lookup "u-ca".toList) = + some ⟨false, "u-ca".toList, ["chinese".toList]⟩) ∧ + -- Critical: the four of section 3.3 + ((parseExt "2022-07-08T00:14:07+01:00[!Europe/Paris]").map (·.erroneous Spec.exampleProcessor) = + some true) ∧ + ((parseExt "2022-07-08T00:14:07Z[!u-ca=chinese][u-ca=japanese]").map + (·.erroneous Spec.exampleProcessor) = some true) ∧ + ((parseExt "2022-07-08T00:14:07Z[u-ca=chinese][!u-ca=japanese]").map + (·.erroneous Spec.exampleProcessor) = some true) ∧ + ((parseExt "2022-07-08T00:14:07Z[!knort=blargel]").map (·.erroneous Spec.exampleProcessor) = + some true) ∧ + -- Figures 1 and 2 of section 3.4 + ((parseExt "2022-07-08T00:14:07+00:00[!Europe/London]").map (·.erroneous Spec.exampleProcessor) = + some true) ∧ + ((parseExt "2022-07-08T00:14:07+00:00[Europe/London]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ + ((parseExt "2022-07-08T00:14:07Z[!Europe/London]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ + ((parseExt "2022-07-08T00:14:07Z[Europe/London]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ + -- An experiment the application does not take part in + ((parseExt "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]").map + (·.erroneous Spec.exampleProcessor) = some true) ∧ + -- Refused by the grammar: an empty item, `.` and `..`, an upper case + -- key, an empty value, a time zone after a tag, `Z` as an offset + -- (it reads as a zone name), an offset out of range + parseExt "2022-07-08T00:14:07Z[]" = none ∧ + parseExt "2022-07-08T00:14:07Z[.]" = none ∧ + parseExt "2022-07-08T00:14:07Z[a/..]" = none ∧ + parseExt "2022-07-08T00:14:07Z[U-ca=x]" = none ∧ + parseExt "2022-07-08T00:14:07Z[u-ca=a--b]" = none ∧ + parseExt "2022-07-08T00:14:07Z[u-ca=x][Europe/Paris]" = none ∧ + parseExt "2022-07-08T00:14:07Z[+24:00]" = none := sorry diff --git a/Spec/Ixdtf.lean b/Spec/Ixdtf.lean new file mode 100644 index 0000000..8b43732 --- /dev/null +++ b/Spec/Ixdtf.lean @@ -0,0 +1,190 @@ +/- + The definitions the IXDTF statements use, written from RFC 9557, the + Internet Extended Date/Time Format, independently of the code in + `Rfc3339/Ixdtf.lean`. Part of the approved specification, with + `Spec.lean`, `Spec/Defs.lean` and `Rfc3339/IxdtfTypes.lean`. +-/ + +import Spec.Defs +import Rfc3339.IxdtfTypes + +open Rfc3339 + +/-! ## Section 4.1, the grammar + +``` +time-zone-initial = ALPHA / "." / "_" +time-zone-char = time-zone-initial / DIGIT / "-" / "+" +time-zone-part = time-zone-initial *time-zone-char + ; but not "." or ".." +time-zone-name = time-zone-part *("/" time-zone-part) +time-zone = "[" critical-flag + time-zone-name / time-numoffset "]" + +key-initial = lcalpha / "_" +key-char = key-initial / DIGIT / "-" +suffix-key = key-initial *key-char + +suffix-value = 1*alphanum +suffix-values = suffix-value *("-" suffix-value) +suffix-tag = "[" critical-flag + suffix-key "=" suffix-values "]" + +suffix = [time-zone] *suffix-tag + +date-time-ext = date-time suffix + +critical-flag = [ "!" ] + +alphanum = ALPHA / DIGIT +lcalpha = %x61-7A +``` + +`date-time` and `time-numoffset` are RFC 3339's, as `Spec/Defs.lean` +gives them. In ABNF (RFC 5234, section 3.10) alternation binds more +loosely than concatenation, so the `time-zone` rule read literally is +`("[" critical-flag time-zone-name) / (time-numoffset "]")`. The intent, +which section 4.1's note ("a time-zone is syntactically similar to a +suffix-tag") and every example show, is +`"[" critical-flag (time-zone-name / time-numoffset) "]"`, and that is +what is written here. +-/ + +/-- `ALPHA`. -/ +def Spec.Alpha (c : Char) : Prop := ('A' ≤ c ∧ c ≤ 'Z') ∨ ('a' ≤ c ∧ c ≤ 'z') + +/-- `DIGIT`. -/ +def Spec.Digit (c : Char) : Prop := '0' ≤ c ∧ c ≤ '9' + +/-- `time-zone-part`, but not `.` or `..`. -/ +def Spec.ZonePart (p : List Char) : Prop := + ∃ c cs, p = c :: cs ∧ (Spec.Alpha c ∨ c = '.' ∨ c = '_') ∧ + (∀ d ∈ cs, Spec.Alpha d ∨ d = '.' ∨ d = '_' ∨ Spec.Digit d ∨ d = '-' ∨ d = '+') ∧ + p ≠ ['.'] ∧ p ≠ ['.', '.'] + +/-- What may stand in a `time-zone`: a `time-zone-name`, one or more + parts, or a `time-numoffset`, which is RFC 3339's numeric offset, not + `Z`. -/ +def Spec.ZoneValid : Zone → Prop + | .name parts => parts ≠ [] ∧ ∀ p ∈ parts, Spec.ZonePart p + | .offset o => o ≠ .z ∧ Spec.OffsetValid o + +/-- `suffix-key`. -/ +def Spec.SuffixKey (k : List Char) : Prop := + ∃ c cs, k = c :: cs ∧ (('a' ≤ c ∧ c ≤ 'z') ∨ c = '_') ∧ + ∀ d ∈ cs, ('a' ≤ d ∧ d ≤ 'z') ∨ d = '_' ∨ Spec.Digit d ∨ d = '-' + +/-- `suffix-value`. -/ +def Spec.SuffixValue (v : List Char) : Prop := v ≠ [] ∧ ∀ c ∈ v, Spec.Alpha c ∨ Spec.Digit c + +/-- A `suffix-tag`'s key and values. -/ +def Spec.TagValid (t : Tag) : Prop := + Spec.SuffixKey t.key ∧ t.values ≠ [] ∧ ∀ v ∈ t.values, Spec.SuffixValue v + +/-- An IXDTF timestamp the grammar allows: a valid RFC 3339 timestamp, a + time zone of the grammar if there is one, and tags of the grammar. -/ +def Spec.ExtValid (e : Ext) : Prop := + Spec.Valid e.stamp ∧ (∀ c z, e.zone = some (c, z) → Spec.ZoneValid z) ∧ + ∀ t ∈ e.tags, Spec.TagValid t + +/-- `critical-flag`. -/ +def Spec.renderFlag (c : Bool) : List Char := if c then ['!'] else [] + +/-- A `time-zone-name`, its parts joined by `/`, or a `time-numoffset`. -/ +def Spec.renderZone : Zone → List Char + | .name parts => (parts.intersperse ['/']).flatten + | .offset o => Spec.renderOffset o + +/-- `suffix-tag`. -/ +def Spec.renderTag (t : Tag) : List Char := + ['['] ++ Spec.renderFlag t.critical ++ t.key ++ ['='] ++ (t.values.intersperse ['-']).flatten ++ + [']'] + +/-- `suffix`: the `time-zone`, if any, then the tags. -/ +def Spec.renderSuffix (e : Ext) : List Char := + (match e.zone with + | some (c, z) => ['['] ++ Spec.renderFlag c ++ Spec.renderZone z ++ [']'] + | none => []) ++ + (e.tags.map Spec.renderTag).flatten + +example : Spec.renderSuffix ⟨⟨1996, 12, 19, 16, 39, 57, [], .minus 8 0⟩, + some (false, .name ["America".toList, "Los_Angeles".toList]), + [⟨false, "u-ca".toList, ["hebrew".toList]⟩]⟩ = + "[America/Los_Angeles][u-ca=hebrew]".toList := by decide + +/-! ## Sections 2, 3.2, 3.3 and 3.4: what an application must reject + +Section 2 updates RFC 3339: "If the time in UTC is known, but the offset +to local time is unknown, this can be represented with an offset of +`Z`", and section 3.4 adds that "-00:00 may be used instead of Z because +they have the same meaning". So `Z` and `-00:00` assert no local offset. + +Section 3.4: "In case of an inconsistency between time-offset and time +zone suffix, if the critical flag is used on the time zone suffix, an +application MUST act on the inconsistency." Figure 2's +`2022-07-08T00:14:07Z[!Europe/London]` is "not inconsistent because +[it does] not assert any particular local time nor local offset". +Section 4.1: recipients "may not be aware of such an IANA Time Zone name +and should treat such a situation as any other inconsistency." + +Section 3.3: "A suffix tag may also indicate that it is critical: The +recipient is advised that it MUST NOT act on the IXDTF string unless it +can process the suffix tag as specified." Its examples +`2022-07-08T00:14:07Z[!u-ca=chinese][u-ca=japanese]` and +`2022-07-08T00:14:07Z[u-ca=chinese][!u-ca=japanese]` "have an internal +inconsistency ... that is marked as critical, so a recipient MUST treat +these IXDTF strings as erroneous". And: "An application that encounters +duplicate use of a suffix key in elective suffixes and does not want to +perform additional processing on this inconsistency MUST choose the +first suffix that has that key". + +Section 3.2: "Key names that start with an underscore are intended for +experiments in controlled environments and cannot be registered; such +keys MUST NOT be used for interchange and MUST be rejected by +implementations not specifically configured to take part in such an +experiment." + +Below, what the application can act on is a `Processor`: which tags it +processes, which taking part in an experiment means for a key that +starts with `_`, and the offset a named time zone has at an instant. +-/ + +/-- The timestamp's offset asserts a local offset: it is not `Z` or + `-00:00`. -/ +def Spec.AssertsOffset (o : Offset) : Prop := o ≠ .z ∧ o ≠ .minus 0 0 + +/-- The time zone is consistent with the timestamp, for an application + that knows the zone offsets `p` gives: the zone is known and, where + the timestamp asserts a local offset, has that offset. -/ +def Spec.ZoneConsistent (p : Processor) (t : Timestamp) : Zone → Prop + | .offset o => Spec.AssertsOffset t.offset → Spec.offsetMinutes o = Spec.offsetMinutes t.offset + | .name parts => + ∃ m, p.zoneOffset parts t = some m ∧ + (Spec.AssertsOffset t.offset → m = Spec.offsetMinutes t.offset) + +/-- The application must treat the string as erroneous: by section 3.4, + a critical time zone that is not consistent with the timestamp; by + section 3.3, a critical tag it cannot process, or a critical tag + whose key a tag uses with other values; by section 3.2, a key for an + experiment it does not take part in. -/ +def Spec.MustReject (p : Processor) (e : Ext) : Prop := + (∃ z, e.zone = some (true, z) ∧ ¬ Spec.ZoneConsistent p e.stamp z) ∨ + (∃ t ∈ e.tags, t.critical = true ∧ p.processes t = false) ∨ + (∃ t ∈ e.tags, t.critical = true ∧ ∃ u ∈ e.tags, u.key = t.key ∧ u.values ≠ t.values) ∨ + (∃ t ∈ e.tags, t.key.head? = some '_' ∧ p.processes t = false) + +/-- An application for the examples of sections 3.3, 3.4 and 4.2: it + processes `u-ca` with the calendars `chinese`, `japanese` and + `hebrew`, takes part in no experiment, and knows three zones, with + the offsets they had at the examples' instants: Europe/Paris +02:00 + and Europe/London +01:00 in July 2022, America/Los_Angeles -08:00 in + December 1996. -/ +def Spec.exampleProcessor : Processor where + processes t := t.key = "u-ca".toList ∧ + (t.values = ["chinese".toList] ∨ t.values = ["japanese".toList] ∨ + t.values = ["hebrew".toList]) + zoneOffset parts _ := + if parts = ["Europe".toList, "Paris".toList] then some 120 + else if parts = ["Europe".toList, "London".toList] then some 60 + else if parts = ["America".toList, "Los_Angeles".toList] then some (-480) + else none diff --git a/comparator.json b/comparator.json index 62d4328..38f57ef 100644 --- a/comparator.json +++ b/comparator.json @@ -25,7 +25,14 @@ "Spec.fromSiSeconds_iff", "Spec.fromSiSeconds_isSome", "Spec.taiMinusUtc_eq", - "Spec.leap_table_vectors" + "Spec.leap_table_vectors", + "Spec.parseExt_iff", + "Spec.printExt_eq", + "Spec.parseExt_print", + "Spec.parseExt_plain", + "Spec.lookup_iff", + "Spec.erroneous_iff", + "Spec.ixdtf_vectors" ], "permitted_axioms": [ "propext", From ba79dc86d73351740586475c468d86a096047f8c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:12:09 +0000 Subject: [PATCH 04/11] IXDTF: command-line request, differential tests, Temporal cross-check; two of three proof files The ixdtf request prints the printed form, the erroneous verdict for the example application and the first u-ca tag. The tests write RFC 9557's grammar and rules again in Python: 300,000 inputs with 0 failures, 6,992 of them IXDTF strings. The suffix parser's proofs are still open. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WWUFjVe6uzWVQGpyU113ow --- Main.lean | 19 +++- Proofs/Ixdtf/Parse.lean | 99 ++++++++++++++++++-- Proofs/Ixdtf/Rules.lean | 72 ++++++++++++++- tests/CROSS-CHECK.md | 47 +++++++--- tests/TEST-RESULTS.md | 21 ++--- tests/cross_check.py | 82 +++++++++++++++++ tests/cross_check/ixdtf.js | 14 +++ tests/run_tests.py | 179 ++++++++++++++++++++++++++++++++++++- 8 files changed, 500 insertions(+), 33 deletions(-) create mode 100644 tests/cross_check/ixdtf.js diff --git a/Main.lean b/Main.lean index 84d2814..aaaad17 100644 --- a/Main.lean +++ b/Main.lean @@ -31,6 +31,12 @@ fromsi N prints the printed form of the UTC timestamp N SI seconds after 1972-01-01T00:00:00Z, with second 60 at a leap second, or NONE outside 1972 to 9999 + ixdtf HEX prints OK, then tab-separated: the printed form of + the RFC 9557 string, whether the example + application of `Spec.exampleProcessor` must treat + it as erroneous (`true` or `false`), and the values + of the first `u-ca` tag joined by `-`, or `-`; + or REJECT A malformed request prints `error`: an unknown operation, hex digits that are not in pairs, bytes that are not UTF-8, or a number that is @@ -41,11 +47,13 @@ `Timestamp.print`, `Timestamp.unixSeconds`, `Timestamp.compareInstant`, `fromUnixSeconds`, `Timestamp.isValidKnown`, `Timestamp.siSeconds`, - `Timestamp.taiMinusUtc` and `fromSiSeconds`. The hex decoding and the request handling + `Timestamp.taiMinusUtc`, `fromSiSeconds`, `parseExt`, `Ext.print`, + `Ext.erroneous` and `Ext.lookup`. The hex decoding and the request handling here are covered by the tests, not by a proof. -/ import Rfc3339 +import Spec.Ixdtf open Rfc3339 @@ -113,6 +121,15 @@ def request (line : String) : Option String := s!"OK\t{t.siSeconds}\t{tai}" else "NOLEAP" | none => "REJECT" + | ["ixdtf", h] => do + let s ← fromHex h + pure <| match parseExt s with + | some e => + let cal := match e.lookup "u-ca".toList with + | some t => String.ofList (['-'].intercalate t.values) + | none => "-" + s!"OK\t{e.print}\t{e.erroneous Spec.exampleProcessor}\t{cal}" + | none => "REJECT" | ["fromsi", n] => do let s ← decimal n pure <| match fromSiSeconds s with diff --git a/Proofs/Ixdtf/Parse.lean b/Proofs/Ixdtf/Parse.lean index b1ba247..85cb4c3 100644 --- a/Proofs/Ixdtf/Parse.lean +++ b/Proofs/Ixdtf/Parse.lean @@ -9,21 +9,110 @@ open Rfc3339 namespace Rfc3339.Proofs.Ixdtf +theorem px_digitChar_lt (n : Nat) : (Spec.digitChar n).toNat < 91 := by + unfold Spec.digitChar + rcases Nat.lt_or_ge n 10 with h | h + · have : ∀ i : Fin 10, ("0123456789".toList.getD i.val '?').toNat < 91 := by decide + exact this ⟨n, h⟩ + · obtain ⟨k, rfl⟩ : ∃ k, n = k + 10 := ⟨n - 10, by omega⟩ + simp [List.getD_eq_getElem?_getD] + +theorem px_digits_lt (k n : Nat) : ∀ c ∈ Spec.digits k n, c.toNat < 91 := by + intro c hc + simp only [Spec.digits, List.mem_map] at hc + obtain ⟨_, _, rfl⟩ := hc + exact px_digitChar_lt _ + +theorem px_renderOffset_lt (o : Offset) : ∀ c ∈ Spec.renderOffset o, c.toNat < 91 := by + cases o with + | z => decide + | plus h m | minus h m => + simp only [Spec.renderOffset, List.forall_mem_append, List.forall_mem_cons] + exact ⟨⟨⟨⟨by decide, by simp⟩, px_digits_lt _ _⟩, ⟨by decide, by simp⟩⟩, px_digits_lt _ _⟩ + +theorem px_render_lt (t : Timestamp) : ∀ c ∈ Spec.render t, c.toNat < 91 := by + simp only [Spec.render, List.forall_mem_append, List.forall_mem_cons] + and_intros + all_goals first + | exact px_digits_lt _ _ + | exact px_renderOffset_lt _ + | decide + | (intro _ h; cases h) + | skip + split + · simp + · simp only [List.forall_mem_append, List.forall_mem_cons, + List.forall_mem_map] + exact ⟨⟨by decide, by simp⟩, fun _ _ => px_digitChar_lt _⟩ + +/-- A list whose upper case is a rendered timestamp has no `[`. -/ +theorem px_no_bracket {dt : List Char} {t : Timestamp} + (h : dt.map Spec.asciiUpper = Spec.render t) : ∀ c ∈ dt, c ≠ '[' := by + intro c hc hb + subst hb + have hm : Spec.asciiUpper '[' ∈ Spec.render t := h ▸ List.mem_map_of_mem hc + have : Spec.asciiUpper '[' = '[' := by decide + rw [this] at hm + have := px_render_lt t _ hm + simp at this + +theorem px_takeWhile_split {dt r : List Char} (hd : ∀ c ∈ dt, c ≠ '[') + (hr : r = [] ∨ r.head? = some '[') : + (dt ++ r).takeWhile (fun x => decide (x ≠ '[')) = dt := by + rw [List.takeWhile_append_of_pos (by simpa using hd)] + rcases hr with rfl | hr + · simp + · match r, hr with + | _ :: _, hr => + simp only [List.head?_cons, Option.some.injEq] at hr + subst hr + simp + theorem parseExt_iff (s : String) (e : Ext) : parseExt s = some e ↔ Spec.ExtValid e ∧ ∃ dt, s.toList = dt ++ Spec.renderSuffix e ∧ dt.map Spec.asciiUpper = Spec.render e.stamp := by - sorry + constructor + · intro h + simp only [parseExt, bind, Option.bind_eq_some_iff] at h + obtain ⟨t, ht, ⟨zone, tags⟩, hs, he⟩ := h + simp only [pure, Option.some.injEq] at he + subst he + obtain ⟨hv, hu⟩ := (Spec.parse_iff _ t).mp ht + rw [String.toList_ofList] at hu + obtain ⟨⟨hz, htags⟩, hsuf⟩ := + (parseSuffix_iff _ ⟨t, zone, tags⟩).mp hs + refine ⟨⟨hv, hz, htags⟩, _, ?_, hu⟩ + obtain ⟨r, hr⟩ := List.takeWhile_prefix (fun x => decide (x ≠ '[')) (l := s.toList) + rw [← hsuf] + generalize s.toList.takeWhile (fun x => decide (x ≠ '[')) = tw at hr ⊢ + rw [← hr, List.drop_left] + · rintro ⟨⟨hv, hz, htags⟩, dt, hs, hu⟩ + have htw : s.toList.takeWhile (fun x => decide (x ≠ '[')) = dt := by + rw [hs]; exact px_takeWhile_split (px_no_bracket hu) (renderSuffix_head e) + have hp : parse (String.ofList dt) = some e.stamp := + (Spec.parse_iff _ _).mpr ⟨hv, by rw [String.toList_ofList]; exact hu⟩ + have hsuf : parseSuffix (Spec.renderSuffix e) = some (e.zone, e.tags) := + (parseSuffix_iff _ e).mpr ⟨⟨hz, htags⟩, rfl⟩ + simp only [parseExt, htw, hp] + rw [hs, List.drop_left, hsuf] + rfl theorem printExt_eq (e : Ext) (h : Spec.ExtValid e) : e.print.toList = Spec.render e.stamp ++ Spec.renderSuffix e := by - sorry + rw [Ext.print, String.toList_ofList, + Format.render_eq _ ((Spec.isValid_iff _).mpr h.1), renderSuffix_eq e h.2.1] -theorem parseExt_print (e : Ext) (h : Spec.ExtValid e) : parseExt e.print = some e := by - sorry +theorem parseExt_print (e : Ext) (h : Spec.ExtValid e) : parseExt e.print = some e := + (parseExt_iff _ e).mpr ⟨h, Spec.render e.stamp, printExt_eq e h, Main.upper_render _⟩ theorem parseExt_plain (s : String) (h : '[' ∉ s.toList) : parseExt s = (parse s).map fun t => ⟨t, none, []⟩ := by - sorry + have htw : s.toList.takeWhile (fun x => decide (x ≠ '[')) = s.toList := by + have := px_takeWhile_split (dt := s.toList) (r := []) + (fun c hc hb => h (hb ▸ hc)) (Or.inl rfl) + simpa using this + simp only [parseExt, htw, String.ofList_toList, List.drop_length] + cases parse s <;> rfl end Rfc3339.Proofs.Ixdtf diff --git a/Proofs/Ixdtf/Rules.lean b/Proofs/Ixdtf/Rules.lean index 1e1f915..11205f9 100644 --- a/Proofs/Ixdtf/Rules.lean +++ b/Proofs/Ixdtf/Rules.lean @@ -10,12 +10,80 @@ open Rfc3339 namespace Rfc3339.Proofs.Ixdtf +private theorem rx_find_iff (k : List Char) (t : Tag) : ∀ (ts : List Tag), + ts.find? (fun u => u.key == k) = some t ↔ ∃ l r, ts = l ++ t :: r ∧ t.key = k ∧ ∀ u ∈ l, u.key ≠ k + | [] => by simp + | u :: us => by + rw [List.find?_cons] + by_cases h : u.key = k + · simp only [h, beq_self_eq_true, Option.some.injEq] + constructor + · rintro rfl + exact ⟨[], us, by simp, h, by simp⟩ + · rintro ⟨l, r, hl, hk, hu⟩ + cases l with + | nil => simp at hl; exact hl.1 + | cons a l => + simp at hl + exact absurd (hl.1 ▸ h) (hu a (by simp)) + · have hb : (u.key == k) = false := by simpa using h + rw [hb] + simp only + rw [rx_find_iff k t us] + constructor + · rintro ⟨l, r, hl, hk, hu⟩ + refine ⟨u :: l, r, by simp [hl], hk, ?_⟩ + intro v hv + simp at hv + rcases hv with rfl | hv + · exact h + · exact hu v hv + · rintro ⟨l, r, hl, hk, hu⟩ + cases l with + | nil => + simp at hl + exact absurd (hl.1 ▸ hk) h + | cons a l => + simp at hl + exact ⟨l, r, hl.2, hk, fun v hv => hu v (by simp [hv])⟩ + theorem lookup_iff (e : Ext) (k : List Char) (t : Tag) : e.lookup k = some t ↔ ∃ l r, e.tags = l ++ t :: r ∧ t.key = k ∧ ∀ u ∈ l, u.key ≠ k := by - sorry + exact rx_find_iff k t e.tags + + +private theorem rx_assertsOffset_iff (o : Offset) : assertsOffset o = true ↔ Spec.AssertsOffset o := by + cases o with + | z => simp [assertsOffset, Spec.AssertsOffset] + | plus h m => simp [assertsOffset, Spec.AssertsOffset] + | minus h m => + by_cases hh : h = 0 <;> by_cases hm : m = 0 <;> subst_vars <;> + simp_all [assertsOffset, Spec.AssertsOffset] + +private theorem rx_zoneAgrees_iff (p : Processor) (t : Timestamp) (z : Zone) : + zoneAgrees p t z = true ↔ Spec.ZoneConsistent p t z := by + cases z with + | offset o => + simp only [zoneAgrees, Spec.ZoneConsistent, Bool.or_eq_true, Bool.not_eq_true', beq_iff_eq, + Rfc3339.Proofs.Valid.offsetMinutes_eq, ← rx_assertsOffset_iff] + cases assertsOffset t.offset <;> simp + | name parts => + simp only [zoneAgrees, Spec.ZoneConsistent] + cases hz : p.zoneOffset parts t with + | none => simp + | some m => + simp only [Bool.or_eq_true, Bool.not_eq_true', beq_iff_eq, Rfc3339.Proofs.Valid.offsetMinutes_eq, + ← rx_assertsOffset_iff, Option.some.injEq, exists_eq_left'] + cases assertsOffset t.offset <;> simp theorem erroneous_iff (p : Processor) (e : Ext) : e.erroneous p = true ↔ Spec.MustReject p e := by - sorry + simp only [Ext.erroneous, Spec.MustReject, Bool.or_eq_true, List.any_eq_true, + Bool.and_eq_true, Bool.not_eq_true', beq_iff_eq, bne_iff_ne, ne_eq, or_assoc] + apply or_congr _ Iff.rfl + rcases h : e.zone with _ | ⟨_ | _, z⟩ + · simp + · simp + · simp [← rx_zoneAgrees_iff] end Rfc3339.Proofs.Ixdtf diff --git a/tests/CROSS-CHECK.md b/tests/CROSS-CHECK.md index db3aa75..1f1e4b6 100644 --- a/tests/CROSS-CHECK.md +++ b/tests/CROSS-CHECK.md @@ -6,27 +6,48 @@ Written by `tests/cross_check.py`, once, by hand. It is not part of the verified - Go: `time.Parse(time.RFC3339Nano, s)`, go version go1.24.7 linux/amd64 - rfc3339-validator 0.1.4 (Python 3.13.17), called on the string as given. `jsonschema` calls it for its `date-time` check on the upper-cased string, so the lower case rows below do not apply to `jsonschema`. - Seed: 20261025 -- Strings: 182162 +- Strings: 182091 - Strings both this project and Go accept, read to a different instant: 0 Where each parser differs from this one, by the first category that fits the string: | Parser | Differs by | Category | Strings | Example | | --- | --- | --- | --- | --- | -| Go | rejects | lower case t or z | 18971 | `'1985-04-12t23:20:50.52z'`, `'2100-10-31t02:52:09+00:50'` | -| Go | rejects | second 60 | 3786 | `'1990-12-31T23:59:60Z'`, `'1990-12-31T15:59:60-08:00'` | -| Go | accepts | offset hour above 23 or minute above 59 | 1986 | `'2000-01-01T00:00:00+24:00'`, `'2000-01-01T00:00:00+23:60'` | -| Go | accepts | a field with one digit | 419 | `'0099-09-29T8:05:14.9-13:23'`, `'9972-03-01T8:16:47+02:02'` | -| Go | accepts | comma before the fraction | 3 | `'2000-01-01T00:00:00,5Z'`, `'1900-01-02T22:19:55,9-00:00'` | -| rfc3339-validator | rejects | lower case t or z | 18971 | `'1985-04-12t23:20:50.52z'`, `'2100-10-31t02:52:09+00:50'` | -| rfc3339-validator | rejects | second 60 | 3786 | `'1990-12-31T23:59:60Z'`, `'1990-12-31T15:59:60-08:00'` | -| rfc3339-validator | rejects | year 0000 | 1243 | `'0000-01-01T00:00:00Z'`, `'0000-01-01T12:00:00Z'` | -| rfc3339-validator | accepts | line break at the end | 869 | `'4244-06-01T08:05:55.054876690884+00:00\n'`, `'5263-10-30T07:22:33.52-07:24\n'` | +| Go | rejects | lower case t or z | 19027 | `'1985-04-12t23:20:50.52z'`, `'2100-10-31t02:52:09+00:50'` | +| Go | rejects | second 60 | 3680 | `'1990-12-31T23:59:60Z'`, `'1990-12-31T15:59:60-08:00'` | +| Go | accepts | offset hour above 23 or minute above 59 | 1970 | `'2000-01-01T00:00:00+24:00'`, `'2000-01-01T00:00:00+23:60'` | +| Go | accepts | a field with one digit | 435 | `'0099-09-29T8:05:14.9-13:23'`, `'6796-03-01T8:13:08+17:53'` | +| Go | accepts | comma before the fraction | 6 | `'2000-01-01T00:00:00,5Z'`, `'1900-01-02T22:19:55,9-00:00'` | +| rfc3339-validator | rejects | lower case t or z | 19027 | `'1985-04-12t23:20:50.52z'`, `'2100-10-31t02:52:09+00:50'` | +| rfc3339-validator | rejects | second 60 | 3680 | `'1990-12-31T23:59:60Z'`, `'1990-12-31T15:59:60-08:00'` | +| rfc3339-validator | rejects | year 0000 | 1224 | `'0000-01-01T00:00:00Z'`, `'0000-01-01T12:00:00Z'` | +| rfc3339-validator | accepts | line break at the end | 855 | `'4244-06-01T08:05:55.054876690884+00:00\n'`, `'5263-10-30T07:22:33.52-07:24\n'` | ## Leap seconds - ERFA, through pyerfa 2.0.1.5, with its own leap second table -- Timestamps known valid by the table, from 1972 on, given to ERFA: 24191. Different SI seconds since 1972 or TAI - UTC: 0. Before 1972, where TAI - UTC was not a whole number of seconds, not compared: 3390. -- SI seconds turned back into timestamps, of which 27 leap seconds, and given to ERFA for their SI seconds: 8700. Different: 0. - +- Timestamps known valid by the table, from 1972 on, given to ERFA: 19637. Different SI seconds since 1972 or TAI - UTC: 0. Before 1972, where TAI - UTC was not a whole number of seconds, not compared: 1965. +- SI seconds turned back into timestamps, of which 27 leap seconds, and given to ERFA for their SI seconds: 8666. Different: 0. + + +## RFC 9557 + +- Node v22.22.0, `Temporal.Instant.from`, with `--harmony-temporal`: V8's Temporal behind a flag, older than the current proposal +- Strings: 6992, of which this project reads 5193. The same answer, read or refused: 2737. + +Where Temporal differs from this project, by the first category that fits the string: + +| Temporal | Category | Strings | Example | +| --- | --- | --- | --- | +| refuses | a critical item, which Temporal refuses when it does not act on it | 2291 | `'2022-07-08T00:14:07+01:00[!Europe/Paris]'`, `'2022-07-08T00:14:07Z[!u-ca=chinese][u-ca=japanese]'` | +| refuses | an experimental key | 676 | `'1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]'`, `'1972-02-01T13:53:54-10:37[_=X1][u-ca=hebrew]'` | +| refuses | an unknown elective key | 645 | `'2022-07-08T00:14:07+01:00[knort=blargel]'`, `'4960-06-30T10:33:21-04:31[knort=gregory]'` | +| refuses | a fraction of more than nine digits, finer than Temporal's nanoseconds | 418 | `'5720-01-31T23:59:60.26153083810657+00:00[Etc/GMT+5][!_=a-b][_foo=japanese]'`, `'2490-07-01t16:59:07.026416563048384470366623+04:19[a-b_c9=chinese]'` | +| refuses | a calendar Temporal does not know | 119 | `'3221-10-31T23:59:60+00:00[a.b-c+d_e][u-ca=blargel]'`, `'2022-07-08T00:14:07+00:00[u-ca=0]'` | +| refuses | a repeated elective `u-ca` | 64 | `'2022-07-08T00:14:07Z[u-ca=chinese][u-ca=japanese]'`, `'2024-02-04T15:17:00+23:59[+01:00][x=a-b][u-ca=a-b][u-ca=blargel]'` | +| refuses | a one-part time zone name | 32 | `'2404-02-29T00:37:44.236447+18:26[a.b-c+d_e]'`, `'3793-03-02T15:50:39.27-20:39[a.b-c+d_e]'` | +| refuses | another time zone name | 7 | `'2022-07-08T00:14:07Z[Etc/G.T+5]'`, `'9167-04-30T21:08:20.7+11:30[Etc/GMT+Z]'` | +| accepts | a `[` inside an item | 1 | `'1019-06-30T05:31:37+00:34[+23:59][u-ca=blargel[]'` | +| accepts | a time zone offset with no colon, as ISO 8601's basic format writes it | 1 | `'6121-04-02t21:22:26.9+05:04[+0530]'` | +| accepts | another suffix the grammar refuses | 1 | `'5984-06-01T02:30:25.893247-11:17[u-ca=gregory]]'` | diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index c2a5a65..6b5d002 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -2,20 +2,21 @@ Written by `tests/run_tests.py`. Rerun it to check these numbers. -- Oracle: Python 3.13.17 `datetime` and `calendar`, the RFC 3339 rules copied into this script, and for leap seconds tzdata's `/usr/share/zoneinfo/leap-seconds.list`, read by this script +- Oracle: Python 3.13.17 `datetime` and `calendar`, the RFC 3339 rules copied into this script, for leap seconds tzdata's `/usr/share/zoneinfo/leap-seconds.list`, read by this script, and for RFC 9557 its grammar and rules written in this script - Lean: Lean (version 4.35.0, x86_64-unknown-linux-gnu, commit eb6cafb7290e8bd5f2937522d8d804f58f02bafc, Release) - Seed: 20261025 - Inputs: 300000 -- Mutated: 67969 of the inputs are mutated timestamps -- By kind: compare 29344, error 35, fromsi 9381, fromunix 44688, grid 16998, known 34402, mutated 67969, near miss 29160, valid 68023 -- Strings parsed: 182150, of which 83488 accepted (4661 with a leap second) and 98662 rejected -- Accepted strings also read by `datetime.fromisoformat`, with the same fields, offset and seconds: 77297 -- Pairs ordered: 29344 (9831 before, 8253 same instant, 9780 after, 1480 with a string rejected) -- Unix seconds turned back into timestamps: 44688, of which 704 outside years 0000 to 9999 +- Mutated: 67687 of the inputs are mutated timestamps +- By kind: compare 29458, error 39, fromsi 9356, fromunix 44579, grid 16998, ixdtf 6992, known 27497, mutated 67687, near miss 29289, valid 68105 +- Strings parsed: 182079, of which 83586 accepted (4568 with a leap second) and 98493 rejected +- Accepted strings also read by `datetime.fromisoformat`, with the same fields, offset and seconds: 77505 +- Pairs ordered: 29458 (9861 before, 8209 same instant, 9923 after, 1465 with a string rejected) +- Unix seconds turned back into timestamps: 44579, of which 657 outside years 0000 to 9999 - Leap second table: /usr/share/zoneinfo/leap-seconds.list, 28 lines, 27 leap seconds, expires 2027-06-28 -- Timestamps checked against the table (`known`): 34402, of which 303 leap seconds of the table, 6821 with a second 60 it does not list, and 0 rejected -- SI seconds turned back into timestamps (`fromsi`): 9381, of which 27 leap seconds and 681 outside 1972 to 9999 -- Malformed requests answered `error`: 35 of 35 +- Timestamps checked against the table (`known`): 27497, of which 236 leap seconds of the table, 5895 with a second 60 it does not list, and 0 rejected +- SI seconds turned back into timestamps (`fromsi`): 9356, of which 27 leap seconds and 690 outside 1972 to 9999 +- RFC 9557 strings (`ixdtf`): 6992, of which 5193 read, 3083 of them erroneous for the example application, and 1799 refused +- Malformed requests answered `error`: 39 of 39 - Requests ending in CR LF: every seventh, 42857 - Strings also given to `rfc3339 check` as arguments, in batches of 500: 20000, and once with no arguments - Last lines with no LF, sent alone, answered right: 2 of 2 diff --git a/tests/cross_check.py b/tests/cross_check.py index ef0e9b5..59a5ae2 100644 --- a/tests/cross_check.py +++ b/tests/cross_check.py @@ -27,6 +27,12 @@ pip install --target .lake/oracle-packages pyerfa==2.0.1.5 +For RFC 9557, the strings tests/run_tests.py sends as `ixdtf` requests go +to `Temporal.Instant.from` in Node, with `--harmony-temporal`. That is +V8's own Temporal behind a flag, an older one than the current proposal: +it refuses some strings the proposal now reads. Each difference is put in +the first category below that fits it. + The counts go to tests/CROSS-CHECK.md. """ @@ -181,6 +187,81 @@ def leap_section(args): return lines +def ix_category(s, ours_ok): + """Why Temporal reads `s` differently: the first reason that fits.""" + r = run_tests.ix_reference(s) + if ours_ok: + f, _, zone, tags = r + if len(f["fraction"]) > 9: + return "a fraction of more than nine digits, finer than Temporal's nanoseconds" + if (zone and zone[0]) or any(c for c, _, _ in tags): + return "a critical item, which Temporal refuses when it does not act on it" + if any(k.startswith("_") for _, k, _ in tags): + return "an experimental key" + if len([k for _, k, _ in tags if k == "u-ca"]) > 1: + return "a repeated elective `u-ca`" + if any(k != "u-ca" for _, k, _ in tags): + return "an unknown elective key" + if tags and tags[0][2] not in run_tests.IX_CALENDARS: + return "a calendar Temporal does not know" + if zone and zone[1][0] == "name" and "/" not in zone[1][1]: + return "a one-part time zone name" + if zone and zone[1][0] == "name": + return "another time zone name" + return "other" + i = s.find("[") + if run_tests.reference(s if i < 0 else s[:i]) is None: + return "an RFC 3339 part the RFC does not allow" + if re.search(r"\[!?[+-][0-9]{4}\]", s): + return "a time zone offset with no colon, as ISO 8601's basic format writes it" + if re.search(r"\[[^\]]*\[", s): + return "a `[` inside an item" + return "another suffix the grammar refuses" + + +def ixdtf_section(args): + import os + strings = [bytes.fromhex(req.split(" ")[1]).decode("utf-8") + for label, req, _, _ in run_tests.cases(args.seed, args.count) if label == "ixdtf"] + ours, problem = run_tests.run_tool("".join(f"ixdtf {run_tests.hexed(x)}\n" for x in strings)) + if problem or len(ours) != len(strings): + sys.exit(f"the tool failed: {problem}") + out = subprocess.run(["node", "--harmony-temporal", str(GO_DIR / "ixdtf.js")], + input="".join(run_tests.hexed(x) + "\n" for x in strings), + capture_output=True, text=True, check=True) + theirs = out.stdout.split("\n")[: len(strings)] + node = subprocess.run(["node", "--version"], capture_output=True, text=True).stdout.strip() + tallies, examples = {}, {} + for x, o, t in zip(strings, ours, theirs): + ok, tok = o.startswith("OK"), t == "OK" + if ok == tok: + continue + key = ("accepts" if tok else "refuses", ix_category(x, ok)) + tallies[key] = tallies.get(key, 0) + 1 + examples.setdefault(key, []) + if len(examples[key]) < 2: + examples[key].append(x) + same = sum(1 for o, t in zip(ours, theirs) if o.startswith("OK") == (t == "OK")) + lines = [ + "", + "## RFC 9557", + "", + f"- Node {node}, `Temporal.Instant.from`, with `--harmony-temporal`: V8's Temporal behind a " + "flag, older than the current proposal", + f"- Strings: {len(strings)}, of which this project reads " + f"{sum(1 for o in ours if o.startswith('OK'))}. The same answer, read or refused: {same}.", + "", + "Where Temporal differs from this project, by the first category that fits the string:", + "", + "| Temporal | Category | Strings | Example |", + "| --- | --- | --- | --- |", + ] + for (how, cat), n in sorted(tallies.items(), key=lambda kv: (-kv[1], kv[0])): + ex = ", ".join(f"`{e!r}`" for e in examples[(how, cat)]) + lines.append(f"| {how} | {cat} | {n} | {ex} |") + return lines + + def main(): ap = argparse.ArgumentParser() ap.add_argument("--seed", type=int, default=20261025) @@ -251,6 +332,7 @@ def main(): for s, theirs, ours in value_mismatch[:20]: lines.append(f"- Different instant: `{s!r}`: Go `{theirs}`, this project `{ours}`") lines += leap_section(args) + lines += ixdtf_section(args) REPORT.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"{len(strings)} strings; report in {REPORT}") diff --git a/tests/cross_check/ixdtf.js b/tests/cross_check/ixdtf.js new file mode 100644 index 0000000..0a5e176 --- /dev/null +++ b/tests/cross_check/ixdtf.js @@ -0,0 +1,14 @@ +// RFC 9557 in Node's Temporal, for tests/cross_check.py. Run with +// `node --harmony-temporal`. Reads one hex-encoded UTF-8 string per line +// and prints OK when Temporal.Instant.from reads it, or ERR. +const readline = require("readline"); +const out = []; +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const s = Buffer.from(line, "hex").toString("utf8"); + try { + Temporal.Instant.from(s); + out.push("OK"); + } catch (e) { + out.push("ERR"); + } +}).on("close", () => process.stdout.write(out.map((l) => l + "\n").join(""))); diff --git a/tests/run_tests.py b/tests/run_tests.py index a26e2a8..aebc857 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -50,6 +50,18 @@ timestamps. `fromsi` requests take every leap second's SI seconds and the two seconds either side, the ends of the range and one past each, random numbers, and the SI seconds of random timestamps; +- for RFC 9557, the Internet Extended Date/Time Format, a fourth + oracle: its grammar (section 4.1) and its rules for elective and + critical tags (sections 3.2 to 3.4), written again in this script. + `ixdtf` requests must give the printed form, whether the example + application must treat the string as erroneous, and the first `u-ca` + tag. The inputs are every example of sections 3.3, 3.4 and 4.2, + random timestamps with random suffixes (time zones by name and by + offset, tags with known, unknown and experimental keys, each item + elective or critical, keys repeated), and those suffixes mutated: a + character changed, dropped or added, a bracket or `!` moved, a key in + upper case, `.` or `..` as a zone part, an empty item or value, a + time zone after a tag; - malformed requests, which the tool must answer with `error`. Every seventh request ends in CR LF. The first 20,000 parsed strings @@ -200,6 +212,152 @@ def si_grid(): yield from (-1, 0, 1, top - 1, top, top + 1, 10 ** 20, -10 ** 20) +# RFC 9557: the grammar of section 4.1 and the rules of sections 3.2 to +# 3.4, written here apart from the Lean code. +IX_ITEMS = re.compile(r"(?:\[[^\[\]]*\])*") +IX_ZONE_PART = re.compile(r"[A-Za-z._][A-Za-z0-9._+\-]*") +IX_OFFSET = re.compile(r"[+-]([0-9]{2}):([0-9]{2})") +IX_TAG = re.compile(r"([a-z_][a-z0-9_\-]*)=([A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)") +# The example application of Spec.exampleProcessor. +IX_CALENDARS = (["chinese"], ["japanese"], ["hebrew"]) +IX_ZONES = {"Europe/Paris": 120, "Europe/London": 60, "America/Los_Angeles": -480} + + +def ix_zone(body): + """The offset in minutes of a numeric time zone, the name of a named + one, or None when the grammar refuses it.""" + m = IX_OFFSET.fullmatch(body) + if m: + h, mi = int(m.group(1)), int(m.group(2)) + if h > 23 or mi > 59: + return None + return ("offset", (h * 60 + mi) * (1 if body[0] == "+" else -1)) + parts = body.split("/") + if all(IX_ZONE_PART.fullmatch(q) and q not in (".", "..") for q in parts): + return ("name", body) + return None + + +def ix_reference(s): + """The fields of an IXDTF string, or None when the grammar refuses it.""" + i = s.find("[") + dt, sfx = (s, "") if i < 0 else (s[:i], s[i:]) + f = reference(dt) + if f is None or not IX_ITEMS.fullmatch(sfx): + return None + zone, tags = None, [] + for k, body in enumerate(re.findall(r"\[([^\[\]]*)\]", sfx)): + crit = body.startswith("!") + b = body[1:] if crit else body + if k == 0 and "=" not in b: + z = ix_zone(b) + if z is None: + return None + zone = (crit, z) + else: + m = IX_TAG.fullmatch(b) + if not m: + return None + tags.append((crit, m.group(1), m.group(2).split("-"))) + return f, sfx, zone, tags + + +def ixdtf(s): + """The expected answer to `ixdtf`.""" + r = ix_reference(s) + if r is None: + return "REJECT" + f, sfx, zone, tags = r + asserts = f["offset"] not in ("Z", "-00:00") + bad = False + if zone and zone[0]: + kind, z = zone[1] + m = z if kind == "offset" else IX_ZONES.get(z) + if m is None or (asserts and m != f["offset_minutes"]): + bad = True + for crit, key, values in tags: + known = key == "u-ca" and values in IX_CALENDARS + if crit and not known: + bad = True + if crit and any(k == key and v != values for _, k, v in tags): + bad = True + if key.startswith("_") and not known: + bad = True + cal = next(("-".join(v) for _, k, v in tags if k == "u-ca"), "-") + return f"OK\t{f['print']}{sfx}\t{'true' if bad else 'false'}\t{cal}" + + +IX_EXAMPLES = [ + "2022-07-08T00:14:07+01:00[Europe/Paris]", "2022-07-08T00:14:07Z[Europe/Paris]", + "2022-07-08T02:14:07+02:00[Europe/Paris]", "2022-07-08T00:14:07+01:00[knort=blargel]", + "2022-07-08T00:14:07+01:00[!Europe/Paris]", "2022-07-08T00:14:07Z[!u-ca=chinese][u-ca=japanese]", + "2022-07-08T00:14:07Z[u-ca=chinese][!u-ca=japanese]", "2022-07-08T00:14:07Z[!knort=blargel]", + "2022-07-08T00:14:07Z[u-ca=chinese][u-ca=japanese]", "2022-07-08T00:14:07Z[u-ca=chinese]", + "2022-07-08T00:14:07+00:00[!Europe/London]", "2022-07-08T00:14:07+00:00[Europe/London]", + "2022-07-08T00:14:07Z[!Europe/London]", "2022-07-08T00:14:07Z[Europe/London]", + "1996-12-19T16:39:57-08:00", "1996-12-19T16:39:57-08:00[America/Los_Angeles]", + "1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]", + "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]", +] +IX_NAMES = ["Europe/Paris", "Europe/London", "America/Los_Angeles", "Asia/Tokyo", "UTC", "Etc/GMT+5", + "America/Argentina/Buenos_Aires", "_x", ".a", "a.b-c+d_e", "Z", "z"] +IX_KEYS = ["u-ca", "u-ca", "knort", "_foo", "x", "a-b_c9", "_"] +IX_VALUES = [["chinese"], ["japanese"], ["hebrew"], ["gregory"], ["blargel"], ["a", "b"], ["X1"], ["0"]] + + +def ix_offset_text(rng): + m = rng.choice([0, 60, 120, -480, 330, -1439, 1439]) + return ("+" if m >= 0 else "-") + f"{abs(m) // 60:02d}:{abs(m) % 60:02d}" + + +def random_suffix(rng): + items = [] + if rng.random() < 0.6: + flag = "!" if rng.random() < 0.3 else "" + body = ix_offset_text(rng) if rng.random() < 0.2 else rng.choice(IX_NAMES) + items.append(f"[{flag}{body}]") + for _ in range(rng.choice([0, 1, 1, 2, 3])): + flag = "!" if rng.random() < 0.3 else "" + items.append(f"[{flag}{rng.choice(IX_KEYS)}={'-'.join(rng.choice(IX_VALUES))}]") + return "".join(items) + + +def mutate_suffix(rng, sfx): + """A suffix with one change that is likely to break the grammar.""" + op = rng.randrange(10) + c = list(sfx) + if op == 0 and c: + c[rng.randrange(len(c))] = rng.choice("[]!=-/.+_aZ0 ") + elif op == 1 and c: + del c[rng.randrange(len(c))] + elif op == 2: + c.insert(rng.randint(0, len(c)), rng.choice("[]!=-/.+_aZ0")) + elif op == 3: + return sfx + rng.choice(["[]", "[!]", "[=x]", "[x=]", "[.]", "[..]", "[a/..]", "[a//b]"]) + elif op == 4: + return sfx + "[U-ca=x]" + elif op == 5: + return sfx + "[u-ca=a--b]" + elif op == 6: + return sfx + "[" + rng.choice(IX_NAMES) + "]" + elif op == 7: + return sfx + rng.choice(["[+24:00]", "[+23:60]", "[Z]", "[+1:00]", "[!!u-ca=x]"]) + elif op == 8: + return sfx.replace("[", "", 1) + else: + return sfx + "[" + return "".join(c) + + +def random_ixdtf(rng): + base = random_valid(rng) if rng.random() < 0.7 else stamp( + 2022, 7, 8, 0, 14, 7, "", rng.choice(["Z", "-00:00", "+00:00", "+01:00", "+02:00", "-08:00"])) + sfx = random_suffix(rng) + if rng.random() < 0.3: + sfx = mutate_suffix(rng, sfx) + return base + sfx + + def unix_grid(): """The ends of the range and one second past each, numbers at the edges of 32-bit counts, and the first second of every month of years @@ -548,6 +706,10 @@ def same_instant_pair(rng): ("fromsi +1", "a plus sign"), ("fromsi 1.5", "a fraction"), ("fromsi 1 2", "two numbers"), + ("ixdtf", "no argument"), + ("ixdtf 3", "an odd number of hex digits"), + ("ixdtf 30 30", "two arguments"), + ("ixdtf ff", "not UTF-8"), ] # A last line with no LF must still be answered. @@ -577,6 +739,8 @@ def cases(seed, count): fixed.append(("known", f"known {hexed(s)}", known(s), False)) for n in si_grid(): fixed.append(("fromsi", f"fromsi {n}", from_si(n), False)) + for s in IX_EXAMPLES: + fixed.append(("ixdtf", f"ixdtf {hexed(s)}", ixdtf(s), False)) for req, _ in MALFORMED: fixed.append(("error", req, "error", False)) made = 0 @@ -585,6 +749,11 @@ def cases(seed, count): made += 1 while made < count: r = rng.random() + if r < 0.03: + s = random_ixdtf(rng) + yield ("ixdtf", f"ixdtf {hexed(s)}", ixdtf(s), False) + made += 1 + continue if r < 0.06: s = random_valid(rng) if rng.random() < 0.8 else leap_pair(rng)[0] yield ("known", f"known {hexed(s)}", known(s), False) @@ -741,8 +910,9 @@ def check_mode(items, limit=20000, batch=500): def oracle(): return (f"Python {platform.python_version()} `datetime` and `calendar`, the " - f"RFC 3339 rules copied into this script, and for leap seconds tzdata's " - f"`{LEAP_FILE}`, read by this script") + f"RFC 3339 rules copied into this script, for leap seconds tzdata's " + f"`{LEAP_FILE}`, read by this script, and for RFC 9557 its grammar and rules " + f"written in this script") def lean_version(): @@ -837,6 +1007,7 @@ def main(): known_leaps = sum(1 for label, req, want, _ in items if label == "known" and want.startswith("OK") and reference(bytes.fromhex(req.split(" ")[1]).decode())["second"] == 60) from_si_wants = [want for label, _, want, _ in items if label == "fromsi"] + ix_wants = [want for label, _, want, _ in items if label == "ixdtf"] expires = datetime(1900, 1, 1) + timedelta(seconds=LEAP_EXPIRES) if LEAP_EXPIRES else None mutated = sum(1 for item in items if item[3]) malformed = [got for item, got in zip(items, out) if item[0] == "error"] @@ -868,6 +1039,10 @@ def main(): f"- SI seconds turned back into timestamps (`fromsi`): {len(from_si_wants)}, of which " f"{sum(1 for w in from_si_wants if w[17:19] == '60')} leap seconds and " f"{from_si_wants.count('NONE')} outside 1972 to 9999", + f"- RFC 9557 strings (`ixdtf`): {len(ix_wants)}, of which " + f"{sum(1 for w in ix_wants if w.startswith('OK'))} read, " + f"{sum(1 for w in ix_wants if w.startswith('OK') and w.split(chr(9))[2] == 'true')} of them " + f"erroneous for the example application, and {ix_wants.count('REJECT')} refused", f"- Malformed requests answered `error`: {malformed.count('error')} of " f"{by_label.get('error', 0)}", f"- Requests ending in CR LF: every seventh, {len(items) // 7}", From 18487604d325af03daf04eef0e13b0337c6cb57c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:13:39 +0000 Subject: [PATCH 05/11] IXDTF tests: write out the edges of the grammar and the rules Planted bugs that the random inputs caught only a few times (a zone part of .., an offset out of range, -00:00, a critical tag repeated with the same values) now each have fixed cases, with 48 edge strings in all. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WWUFjVe6uzWVQGpyU113ow --- tests/TEST-RESULTS.md | 16 ++++++++-------- tests/run_tests.py | 27 +++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index 6b5d002..8873547 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -6,16 +6,16 @@ Written by `tests/run_tests.py`. Rerun it to check these numbers. - Lean: Lean (version 4.35.0, x86_64-unknown-linux-gnu, commit eb6cafb7290e8bd5f2937522d8d804f58f02bafc, Release) - Seed: 20261025 - Inputs: 300000 -- Mutated: 67687 of the inputs are mutated timestamps -- By kind: compare 29458, error 39, fromsi 9356, fromunix 44579, grid 16998, ixdtf 6992, known 27497, mutated 67687, near miss 29289, valid 68105 -- Strings parsed: 182079, of which 83586 accepted (4568 with a leap second) and 98493 rejected -- Accepted strings also read by `datetime.fromisoformat`, with the same fields, offset and seconds: 77505 -- Pairs ordered: 29458 (9861 before, 8209 same instant, 9923 after, 1465 with a string rejected) -- Unix seconds turned back into timestamps: 44579, of which 657 outside years 0000 to 9999 +- Mutated: 67675 of the inputs are mutated timestamps +- By kind: compare 29452, error 39, fromsi 9355, fromunix 44573, grid 16998, ixdtf 7038, known 27497, mutated 67675, near miss 29283, valid 68090 +- Strings parsed: 182046, of which 83570 accepted (4568 with a leap second) and 98476 rejected +- Accepted strings also read by `datetime.fromisoformat`, with the same fields, offset and seconds: 77491 +- Pairs ordered: 29452 (9859 before, 8207 same instant, 9921 after, 1465 with a string rejected) +- Unix seconds turned back into timestamps: 44573, of which 657 outside years 0000 to 9999 - Leap second table: /usr/share/zoneinfo/leap-seconds.list, 28 lines, 27 leap seconds, expires 2027-06-28 - Timestamps checked against the table (`known`): 27497, of which 236 leap seconds of the table, 5895 with a second 60 it does not list, and 0 rejected -- SI seconds turned back into timestamps (`fromsi`): 9356, of which 27 leap seconds and 690 outside 1972 to 9999 -- RFC 9557 strings (`ixdtf`): 6992, of which 5193 read, 3083 of them erroneous for the example application, and 1799 refused +- SI seconds turned back into timestamps (`fromsi`): 9355, of which 27 leap seconds and 690 outside 1972 to 9999 +- RFC 9557 strings (`ixdtf`): 7038, of which 5216 read, 3089 of them erroneous for the example application, and 1822 refused - Malformed requests answered `error`: 39 of 39 - Requests ending in CR LF: every seventh, 42857 - Strings also given to `rfc3339 check` as arguments, in batches of 500: 20000, and once with no arguments diff --git a/tests/run_tests.py b/tests/run_tests.py index aebc857..02684e0 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -55,7 +55,8 @@ critical tags (sections 3.2 to 3.4), written again in this script. `ixdtf` requests must give the printed form, whether the example application must treat the string as erroneous, and the first `u-ca` - tag. The inputs are every example of sections 3.3, 3.4 and 4.2, + tag. The inputs are every example of sections 3.3, 3.4 and 4.2, the + edges of the grammar and rules written out below, random timestamps with random suffixes (time zones by name and by offset, tags with known, unknown and experimental keys, each item elective or critical, keys repeated), and those suffixes mutated: a @@ -299,6 +300,28 @@ def ixdtf(s): "1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]", "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]", ] +# The edges of the grammar and the rules, each written once. +IX_EDGES = [ + "2022-07-08T00:14:07Z[..]", "2022-07-08T00:14:07Z[a/..]", "2022-07-08T00:14:07Z[../a]", + "2022-07-08T00:14:07Z[...]", "2022-07-08T00:14:07Z[.a]", "2022-07-08T00:14:07Z[a/./b]", + "2022-07-08T00:14:07Z[+23:59]", "2022-07-08T00:14:07Z[+24:00]", "2022-07-08T00:14:07Z[-00:60]", + "2022-07-08T00:14:07Z[!+01:00]", "2022-07-08T00:14:07+01:00[!+01:00]", + "2022-07-08T00:14:07+02:00[!+01:00]", "2022-07-08T00:14:07-00:00[!+01:00]", + "2022-07-08T00:14:07+00:00[!-00:00]", "2022-07-08T00:14:07-00:00[!Europe/Paris]", + "2022-07-08T00:14:07+02:00[!Europe/Paris]", "2022-07-08T00:14:07Z[!Nowhere/Fake]", + "2022-07-08T00:14:07Z[!u-ca=hebrew][!u-ca=hebrew]", "2022-07-08T00:14:07Z[!u-ca=hebrew][u-ca=hebrew]", + "2022-07-08T00:14:07Z[u-ca=hebrew][u-ca=chinese][!u-ca=hebrew]", "2022-07-08T00:14:07Z[!u-ca=a-b]", + "2022-07-08T00:14:07Z[_=x]", "2022-07-08T00:14:07Z[!_x=y]", "2022-07-08T00:14:07Z[x_=y]", + "2022-07-08T00:14:07Z[u-ca=]", "2022-07-08T00:14:07Z[=x]", "2022-07-08T00:14:07Z[u-ca==x]", + "2022-07-08T00:14:07Z[u-ca=x-]", "2022-07-08T00:14:07Z[u-ca=-x]", "2022-07-08T00:14:07Z[Ab=x]", + "2022-07-08T00:14:07Z[0a=x]", "2022-07-08T00:14:07Z[-a=x]", "2022-07-08T00:14:07Z[!!u-ca=x]", + "2022-07-08T00:14:07Z[!]", "2022-07-08T00:14:07Z[]", "2022-07-08T00:14:07Z[Europe/Paris][Europe/Paris]", + "2022-07-08T00:14:07Z[u-ca=hebrew][Europe/Paris]", "2022-07-08T00:14:07Z[Europe/Paris]x", + "2022-07-08T00:14:07Z [Europe/Paris]", "2022-07-08T00:14:07Z[Europe//Paris]", + "2022-07-08T00:14:07Z[/Europe]", "2022-07-08T00:14:07Z[Europe/]", "2022-07-08T00:14:07Z[Z]", + "2022-07-08T00:14:07Z[UTC][u-ca=hebrew]", "2022-07-08T00:14:07Z[u-ca=Hebrew]", + "1990-12-31T23:59:60Z[u-ca=hebrew]", "2022-07-08t00:14:07z[u-ca=hebrew]", +] IX_NAMES = ["Europe/Paris", "Europe/London", "America/Los_Angeles", "Asia/Tokyo", "UTC", "Etc/GMT+5", "America/Argentina/Buenos_Aires", "_x", ".a", "a.b-c+d_e", "Z", "z"] IX_KEYS = ["u-ca", "u-ca", "knort", "_foo", "x", "a-b_c9", "_"] @@ -739,7 +762,7 @@ def cases(seed, count): fixed.append(("known", f"known {hexed(s)}", known(s), False)) for n in si_grid(): fixed.append(("fromsi", f"fromsi {n}", from_si(n), False)) - for s in IX_EXAMPLES: + for s in IX_EXAMPLES + IX_EDGES: fixed.append(("ixdtf", f"ixdtf {hexed(s)}", ixdtf(s), False)) for req, _ in MALFORMED: fixed.append(("error", req, "error", False)) From 8a54be0d8601fa53dbfe5695a553c9d0dccc2b3b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:21:35 +0000 Subject: [PATCH 06/11] Fix what the pre-review found so far: TAI - UTC at a leap second, wording The spec and page lenses of an independent pre-review, each finding checked by a separate agent, found these. None is in the proofs. - TAI - UTC during a leap second was claimed but no statement covered it: `Spec.taiMinusUtc_leapsBefore` now fixes `taiMinusUtc` for every valid timestamp, and vectors give it at the first and last leap second. - Before 1972, `Spec.siSeconds_eq` does fix `siSeconds`, as Unix seconds less 63072000; the page said no statement covered it, and called the number SI seconds without limit. - The table's transcription is part of the spec, checked by the reviewer and the tests but no proof; it now says so under What is not proved, with the file's version, update stamp and hash line. The comment in Spec/Leap.lean now quotes what the file says. - `known` is now also sent near misses and mutated strings, so its REJECT answer is tested; a missing tzdata file fails with a message. - Vectors now close the SI round trip of both ends of the table. - Smaller wording: `isValid` keeps no table, not the library; ERFA is derived from SOFA, not the IAU's own; "three numbers in a row" rests on `siSeconds_eq`, not the order statement. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WWUFjVe6uzWVQGpyU113ow --- Main.lean | 3 ++- Proofs/Leap/Main.lean | 12 ++++++++++++ README.md | 20 +++++++++++--------- SPEC.md | 9 +++++---- Spec.lean | 25 ++++++++++++++++++++----- Spec/Leap.lean | 18 ++++++++++++------ comparator.json | 1 + tests/TEST-RESULTS.md | 18 +++++++++--------- tests/cross_check.py | 2 +- tests/run_tests.py | 27 +++++++++++++++++++++++---- 10 files changed, 96 insertions(+), 39 deletions(-) diff --git a/Main.lean b/Main.lean index 84d2814..1b56fcd 100644 --- a/Main.lean +++ b/Main.lean @@ -23,7 +23,8 @@ outside the years 0000 to 9999; N is decimal, with an optional leading `-` known HEX prints OK, then tab-separated: SI seconds since - 1972-01-01T00:00:00Z and TAI - UTC (`-` before + 1972-01-01T00:00:00Z (before 1972, Unix seconds + less 63072000) and TAI - UTC (`-` before 1972, when TAI - UTC was not a whole number of seconds); NOLEAP when the timestamp is valid but its second 60 is not a leap second of the table; diff --git a/Proofs/Leap/Main.lean b/Proofs/Leap/Main.lean index 96ea0e7..c9540f6 100644 --- a/Proofs/Leap/Main.lean +++ b/Proofs/Leap/Main.lean @@ -35,6 +35,11 @@ theorem Spec.fromSiSeconds_isSome (n : Int) : 0 ≤ n ∧ n ≤ Spec.siSeconds ⟨9999, 12, 31, 23, 59, 59, [], .z⟩ := Rfc3339.Proofs.Leap.fromSiSeconds_isSome n +theorem Spec.taiMinusUtc_leapsBefore (t : Timestamp) (h : Spec.Valid t) : + t.taiMinusUtc = 10 + Spec.leapsBefore t := by + unfold Timestamp.taiMinusUtc + rw [Rfc3339.Proofs.Leap.leapsBefore_eq t h] + theorem Spec.taiMinusUtc_eq (t : Timestamp) (h : Spec.Valid t) (h60 : t.second ≠ 60) (h72 : Spec.unixSeconds ⟨1972, 1, 1, 0, 0, 0, [], .z⟩ ≤ Spec.unixSeconds t) : t.taiMinusUtc = Spec.taiMinusUtcAt (Spec.ntpOfUnix (Spec.unixSeconds t)) := @@ -54,9 +59,16 @@ theorem Spec.leap_table_vectors : Timestamp.siSeconds ⟨1972, 6, 30, 23, 59, 60, [], .z⟩ = 15724800 ∧ Timestamp.siSeconds ⟨1972, 7, 1, 0, 0, 0, [], .z⟩ = 15724801 ∧ Timestamp.siSeconds ⟨2017, 1, 1, 0, 0, 0, [], .z⟩ = 1420156827 ∧ + Timestamp.siSeconds ⟨2016, 12, 31, 23, 59, 59, [], .z⟩ = 1420156825 ∧ + Timestamp.siSeconds ⟨2016, 12, 31, 23, 59, 60, [], .z⟩ = 1420156826 ∧ Timestamp.taiMinusUtc ⟨1972, 1, 1, 0, 0, 0, [], .z⟩ = 10 ∧ + Timestamp.taiMinusUtc ⟨1972, 6, 30, 23, 59, 59, [], .z⟩ = 10 ∧ + Timestamp.taiMinusUtc ⟨1972, 6, 30, 23, 59, 60, [], .z⟩ = 10 ∧ + Timestamp.taiMinusUtc ⟨1972, 7, 1, 0, 0, 0, [], .z⟩ = 11 ∧ + Timestamp.taiMinusUtc ⟨2016, 12, 31, 23, 59, 60, [], .z⟩ = 36 ∧ Timestamp.taiMinusUtc ⟨2016, 12, 31, 23, 59, 59, [], .z⟩ = 36 ∧ Timestamp.taiMinusUtc ⟨2017, 1, 1, 0, 0, 0, [], .z⟩ = 37 ∧ + fromSiSeconds 15724800 = some ⟨1972, 6, 30, 23, 59, 60, [], .z⟩ ∧ fromSiSeconds 1420156826 = some ⟨2016, 12, 31, 23, 59, 60, [], .z⟩ ∧ fromSiSeconds 1420156827 = some ⟨2017, 1, 1, 0, 0, 0, [], .z⟩ ∧ fromUnixSeconds (Timestamp.unixSeconds ⟨2016, 12, 31, 23, 59, 60, [], .z⟩) = diff --git a/README.md b/README.md index ac93449..484b59a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # rfc: RFC 3339 timestamps, proved -This library reads, checks and writes RFC 3339 timestamps, such as `1985-04-12T23:20:50.52Z`, in Lean. Machine-checked proofs show that it accepts exactly the strings that the RFC's grammar and calendar rules allow, including the day ranges and leap years of the calendar. `isValid` has one relaxation: it accepts a leap second at the end of any month. `isValidKnown` is exact: it checks second 60 against the table of the 27 leap seconds there have been, as tzdata publishes it. The proofs also cover the round trip from text to value and back, the count of seconds since the Unix epoch, and the order of two timestamps by the instant they name. A count of SI seconds since 1972 gives each leap second its own number, so a round trip through it keeps a leap second, as one through Unix seconds cannot. TAI − UTC is also given, and proved to agree with the table. The library is tested against Python's date and time code; `tests/TEST-RESULTS.md` has the record. +This library reads, checks and writes RFC 3339 timestamps, such as `1985-04-12T23:20:50.52Z`, in Lean. Machine-checked proofs show that it accepts exactly the strings that the RFC's grammar and calendar rules allow, including the day ranges and leap years of the calendar. `isValid` has one relaxation: it accepts a leap second at the end of any month. `isValidKnown` is exact: it checks second 60 against the table of the 27 leap seconds there have been, as tzdata publishes it. The proofs also cover the round trip from text to value and back, the count of seconds since the Unix epoch, and the order of two timestamps by the instant they name. A count of SI seconds since 1972 gives each leap second its own number, so a round trip through it keeps a leap second, as one through Unix seconds cannot. TAI − UTC is also given, as 10 plus the leap seconds before the timestamp, and proved to agree with the table's own TAI − UTC column at every second from 1972 on but a leap second. The library is tested against Python's date and time code; `tests/TEST-RESULTS.md` has the record. It is a project of [Proven Commons](https://github.com/Proven-Commons/mission), built to the verified bar published there. @@ -34,7 +34,7 @@ The statements are in `Spec.lean`. The definitions they use, written from the RF | Claim | Theorem | |-------|---------| -| `isValid` decides the rules of RFC 3339, sections 5.6 and 5.7. Every field is in its range. The day exists in its month, with the leap years of Appendix C. Second 60 falls only at 23:59 UTC on the last day of a month. Section 5.7 allows it only in months that had a leap second; with no table of them, any month is accepted. | `Spec.isValid_iff` | +| `isValid` decides the rules of RFC 3339, sections 5.6 and 5.7. Every field is in its range. The day exists in its month, with the leap years of Appendix C. Second 60 falls only at 23:59 UTC on the last day of a month. Section 5.7 allows it only in months that had a leap second; `isValid` uses no table of them, so any month is accepted. | `Spec.isValid_iff` | | A valid timestamp prints as the grammar of section 5.6 writes it, with `T` and `Z` in upper case. | `Spec.print_eq` | | `parse` returns a timestamp exactly when the timestamp is valid and the string is how it prints, with `T` and `Z` in either case. So `parse` accepts nothing outside the grammar and those rules, rejects nothing inside them, and returns the fields as written. | `Spec.parse_iff` | | What `parse` returns is valid; for example, 30 February never passes. | `Spec.parse_valid` | @@ -48,10 +48,11 @@ The statements are in `Spec.lean`. The definitions they use, written from the RF | `fromUnixSeconds` gives a timestamp exactly for the seconds from 0000-01-01T00:00:00Z to 9999-12-31T23:59:59Z, and `none` outside them. | `Spec.fromUnixSeconds_isSome` | | `fromUnixSeconds` gives the epoch, the section 5.8 example, 2000-02-29, 2038-01-19T03:14:07Z for the last second of a signed 32-bit count, the second before the epoch, and both ends of the range, and gives `none` one second past each end. | `Spec.fromUnixSeconds_vectors` | | The calendar, leap second, and syntax cases: 29 February in 1900, 2000 and 2001; second 60 at and away from a month's end; lower case `t` and `z`; a space for `T`; a comma; an offset of `+24:00`; trailing text. | `Spec.calendar_vectors`, `Spec.leap_second_vectors`, `Spec.syntax_vectors` | -| `isValidKnown` decides section 5.7 with the table of leap seconds. It allows second 60 only at one of the table's leap seconds, at the same instant whatever the offset. The table is tzdata's `leap-seconds.list`, copied into the spec line for line. The code keeps its own list of months, and the proof shows the two agree. | `Spec.isValidKnown_iff` | -| `siSeconds` counts the SI seconds from 1972-01-01T00:00:00Z: Unix seconds, and one more for each leap second of the table before the timestamp. | `Spec.siSeconds_eq` | -| On whole seconds valid by the table, `siSeconds` orders timestamps exactly as `compareInstant` does. So no two instants share a number: a leap second, the second before it and the second after it get three numbers in a row. | `Spec.siSeconds_order` | +| `isValidKnown` decides section 5.7 with the table of leap seconds. It allows second 60 only at one of the table's leap seconds, at the same instant whatever the offset. The table is the one in `Spec/Leap.lean`. The code keeps its own list of months, and the proof shows the two agree. | `Spec.isValidKnown_iff` | +| `siSeconds` is the Unix seconds since 1972-01-01T00:00:00Z, and one more for each leap second of the table before the timestamp. From 1972 on, those are the SI seconds; so a leap second, the second before it and the second after it get three numbers in a row. Before 1972 the number is Unix seconds, 86400 to a day. | `Spec.siSeconds_eq` | +| On whole seconds valid by the table, `siSeconds` orders timestamps exactly as `compareInstant` does. So no two instants share a number, a leap second and the second after it included. | `Spec.siSeconds_order` | | `fromSiSeconds` turns SI seconds back into the one UTC timestamp from 1972 on, valid by the table, that has them, a leap second included. It gives a timestamp for every second from 1972-01-01T00:00:00Z to 9999-12-31T23:59:59Z and `none` outside. | `Spec.fromSiSeconds_iff`, `Spec.fromSiSeconds_isSome` | +| `taiMinusUtc` is 10, and one more for each leap second of the table before the timestamp, so during a leap second it is still the value from before it. | `Spec.taiMinusUtc_leapsBefore` | | TAI − UTC at each second from 1972 on, other than a leap second, is the value in the table's second column, 10 to 37. The code never reads that column; it counts leap seconds. | `Spec.taiMinusUtc_eq` | | Section 5.8's leap seconds, the table's first and last, one seen from another offset, and second 60 in months that had none. SI seconds and TAI − UTC around the first and the last leap second. And the round trip that Unix seconds lose and SI seconds keep. | `Spec.leap_table_vectors` | @@ -59,10 +60,11 @@ The command-line tool in `Main.lean` calls these functions directly. ## What is not proved -- Reading requests and printing answers in `Main.lean`: the hex decoding and UTF-8 check in `serve` mode, the arguments, output and exit codes of `check` mode, and the decimal printing of numbers and reading of the numbers `fromunix` takes. The differential tests cover them, with malformed requests and CR LF line endings. +- Reading requests and printing answers in `Main.lean`: the hex decoding and UTF-8 check in `serve` mode, the arguments, output and exit codes of `check` mode, the decimal printing of numbers and reading of the numbers `fromunix` and `fromsi` take, and the layout of the `known` answer, with `-` for TAI − UTC before 1972. The differential tests cover them, with malformed requests and CR LF line endings. - Whether a leap second was announced, in `isValid` and `parse`. They accept second 60 at 23:59 UTC at the end of any month, where section 5.7 allows it only at the end of months in which a leap second occurs; `isValidKnown` checks that against the table. Second 59 is always accepted, although section 5.7 makes 58 the last second of a month after a negative leap second, which has never happened. - The table after its expiry date, 28 June 2027. `isValidKnown` refuses second 60 at any month end the table does not list, and `siSeconds` and `taiMinusUtc` count no leap second after 2016. If one is ever announced, the table in the spec and the code's list must change together, and the spec approved again. The table is what tzdata publishes from IERS Bulletin C; that it matches the IERS record is trusted, not proved. -- SI seconds and TAI − UTC before 1972. UTC then ran on seconds of varying length, with TAI − UTC not a whole number of seconds. Before 1972, `siSeconds` counts 86400 seconds a day, as Unix seconds do, and `taiMinusUtc` gives 10; no statement covers them there, and the tool's `known` request prints `-` for TAI − UTC. +- SI seconds and TAI − UTC before 1972. UTC then ran on seconds of varying length, with TAI − UTC not a whole number of seconds. Before 1972, `Spec.siSeconds_eq` makes `siSeconds` Unix seconds less 63072000, 86400 to a day, which are not SI seconds there, and `taiMinusUtc` gives 10, which means nothing there; the tool's `known` request prints `-` for TAI − UTC. +- The table itself. `Spec/Leap.lean` copies tzdata's `leap-seconds.list` (tzdata 2026e, updated through IERS Bulletin C, expiring 28 June 2027) line for line. That copy is part of the specification: the reviewer checks it against the file, and the differential tests check the code against the copy of the file on the test machine, but no proof does. - Unix seconds at a leap second. They count second 60 as the first second of the next minute, as POSIX time does, so a leap second and the second after it give the same number. `compareInstant` does not use them, and puts the leap second first. `fromUnixSeconds` turns that number into the second after the leap second, so a round trip through Unix seconds loses a leap second, and `9999-12-31T23:59:60Z` gives `none`. - Forms outside RFC 3339 are not supported, by design: - a space in place of `T`, which section 5.6 lets applications choose; @@ -94,11 +96,11 @@ This project has no dependencies beyond Lean itself. - Go accepts a comma before the fraction. - `rfc3339-validator` accepts a timestamp followed by a line break. -For leap seconds, it compares `siSeconds`, `taiMinusUtc` and `fromSiSeconds` with ERFA, the C version of the IAU's SOFA library, through pyerfa, which keeps its own leap second table. `tests/CROSS-CHECK.md` gives the counts. +For leap seconds, it compares `siSeconds`, `taiMinusUtc` and `fromSiSeconds` with ERFA, a BSD-licensed C library derived from the IAU's SOFA, through pyerfa, which keeps its own leap second table. `tests/CROSS-CHECK.md` gives the counts. ## Re-check it -Needs Linux, [elan](https://github.com/leanprover/elan), bubblewrap (`apt install bubblewrap`), and Python 3.11 or later. +Needs Linux, [elan](https://github.com/leanprover/elan), bubblewrap (`apt install bubblewrap`), Python 3.11 or later, and tzdata, for `/usr/share/zoneinfo/leap-seconds.list`. ``` lake build diff --git a/SPEC.md b/SPEC.md index 9899b65..08736c3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -18,7 +18,7 @@ time-offset = "Z" / ("+" / "-") time-hour ":" time-minute - Every field has exactly the number of digits the grammar gives. A digit is an ASCII digit, `0` to `9`. The parser rejects digits from other scripts, such as `٣` and `0`. - The year runs from 0000 to 9999 and the month from 01 to 12. The day runs from 01 to the length of its month, as in the table of section 5.7. February has 29 days in a leap year and 28 otherwise. A leap year is one divisible by 4, unless it is divisible by 100 and not by 400 (Appendix C). - The hour runs from 00 to 23 and the minute from 00 to 59. The second runs from 00 to 59. It may be 60 only for a leap second, which section 5.7 places at the end of a month in UTC. With the offset applied, the time must be 23:59 UTC on the last day of a month. Because the leap second is shifted by the offset, `1990-12-31T15:59:60-08:00` is the same leap second as `1990-12-31T23:59:60Z`. -- Section 5.7 allows second 60 only at the end of months in which a leap second occurs. This library keeps no table of them, so it allows second 60 at the end of every month, which is more than section 5.7 allows. It also allows second 59 everywhere, although section 5.7 makes 58 the last second of a month after a negative leap second, which has never happened. `Spec.Valid` states the rule as this library applies it. +- Section 5.7 allows second 60 only at the end of months in which a leap second occurs. `isValid` and `parse` use no table of them, so they allow second 60 at the end of every month, which is more than section 5.7 allows; `isValidKnown`, below, checks it against the table. It also allows second 59 everywhere, although section 5.7 makes 58 the last second of a month after a negative leap second, which has never happened. `Spec.Valid` states the rule as this library applies it. - The fraction is optional. When present it is a point and at least one digit, with no upper limit on the digits. The parser keeps every digit as written. - The offset is `Z`, `+hh:mm` or `-hh:mm`, with hours from 00 to 23 and minutes from 00 to 59. Section 4.3 gives `-00:00` its own meaning, an unknown local offset. It is therefore kept apart from `+00:00` and from `Z`, although all three name the same instant. - The note under the grammar lets `T` and `Z` be written in lower case. The parser accepts `t` and `z`, and printing writes upper case. @@ -55,11 +55,11 @@ Every second from `0000-01-01T00:00:00Z` (-62167219200) to `9999-12-31T23:59:59Z Section 5.7 allows second 60 only "at the end of months in which a leap second occurs". `isValid` cannot know those months; `isValidKnown` uses a table of them. The table is tzdata's `leap-seconds.list`, which publishes IERS Bulletin C. `Spec/Leap.lean` copies it line for line: 28 NTP times, from 1 January 1972 to 1 January 2017, each with TAI − UTC from that time on, 10 to 37 seconds. Each line after the first follows a leap second, the second `23:59:60` UTC just before it, so there have been 27. The file expires on 28 June 2027: until then, no leap second comes that it does not list. - `isValidKnown` is `isValid`, with second 60 allowed only at one of the table's leap seconds. The instant is what counts, whatever the offset: `1990-12-31T15:59:60-08:00` is the leap second at the end of 1990, and `2017-01-01T00:59:60+01:00` the one at the end of 2016. `2015-12-31T23:59:60Z` is refused, since that leap second came at the end of June 2015. -- `siSeconds` gives the SI seconds from `1972-01-01T00:00:00Z`, ignoring the fraction: Unix seconds, and one more for each leap second of the table before the timestamp. So `2016-12-31T23:59:59Z`, `2016-12-31T23:59:60Z` and `2017-01-01T00:00:00Z` get three numbers in a row, where Unix seconds give the last two the same one. On whole seconds valid by the table, these numbers are in the order of the instants, as `compareInstant` gives it. +- `siSeconds` gives the Unix seconds from `1972-01-01T00:00:00Z`, ignoring the fraction, and one more for each leap second of the table before the timestamp. From 1972 on, those are the SI seconds between them. So `2016-12-31T23:59:59Z`, `2016-12-31T23:59:60Z` and `2017-01-01T00:00:00Z` get three numbers in a row, where Unix seconds give the last two the same one. On whole seconds valid by the table, these numbers are in the order of the instants, as `compareInstant` gives it. - `fromSiSeconds` goes back: from SI seconds to the UTC timestamp, a leap second as second 60. It gives one for every number from 0, `1972-01-01T00:00:00Z`, to that of `9999-12-31T23:59:59Z`, and `none` outside. - `taiMinusUtc` gives TAI − UTC: 10, and one more for each leap second before the timestamp. At every second from 1972 on, a leap second aside, it is the table's second column. During a leap second it is still the value from before it. -Before 1972, UTC ran on seconds of varying length, so these numbers mean nothing there, and no statement covers them. A leap second announced after the table's date would need a new table, in the spec and in the code, and a new approval. +Before 1972, UTC ran on seconds of varying length, with TAI − UTC not a whole number of seconds. There `siSeconds` is Unix seconds less 63072000, 86400 to a day, as `Spec.siSeconds_eq` says, which are not SI seconds, and `taiMinusUtc` gives 10, which means nothing. A leap second announced after the table's date would need a new table, in the spec and in the code, and a new approval. ## Errors @@ -101,6 +101,7 @@ Each property is a theorem in `Spec.lean`, under the name given. The definitions | `siSeconds` is Unix seconds since 1972 and one more for each leap second of the table before the timestamp. | `Spec.siSeconds_eq` | | On whole seconds valid by the table, `siSeconds` gives the order of the instants. | `Spec.siSeconds_order` | | `fromSiSeconds n` is `t` exactly when `t` is in UTC, with no fraction, valid by the table, from 1972 on, and has SI seconds `n`; it gives one for every second from 1972 to 9999. | `Spec.fromSiSeconds_iff`, `Spec.fromSiSeconds_isSome` | +| `taiMinusUtc` is 10 and one more for each leap second of the table before the timestamp. | `Spec.taiMinusUtc_leapsBefore` | | TAI − UTC from 1972 on, at each second but a leap second, is the table's value. | `Spec.taiMinusUtc_eq` | | Leap seconds of the table and months without one; SI seconds and TAI − UTC around the first and the last; the round trip Unix seconds lose. | `Spec.leap_table_vectors` | @@ -109,7 +110,7 @@ Together, these fix every output for valid input. - `parse` is fixed for every string. - `print`, `unixSeconds` and `compareInstant` are fixed for every valid timestamp. The parser gives only valid timestamps. - `fromUnixSeconds` is fixed for every number, by `Spec.fromUnixSeconds_iff` and `Spec.fromUnixSeconds_isSome`, and `fromSiSeconds` likewise by its two statements. -- `isValidKnown` is fixed for every timestamp, and `siSeconds` for every valid one. `taiMinusUtc` is fixed for every valid timestamp from 1972 on but a leap second. +- `isValidKnown` is fixed for every timestamp, and `siSeconds` for every valid one. `taiMinusUtc` is fixed for every valid timestamp, by `Spec.taiMinusUtc_leapsBefore`, and agrees with the table's column from 1972 on but at a leap second. - A parser that accepted nothing would meet `Spec.parse_valid`, but it would fail `Spec.parse_iff` and `Spec.parse_print`. - A parser that accepted too much would fail `Spec.parse_iff`. diff --git a/Spec.lean b/Spec.lean index 62189b7..bb85987 100644 --- a/Spec.lean +++ b/Spec.lean @@ -185,16 +185,17 @@ theorem Spec.fromUnixSeconds_vectors : theorem Spec.isValidKnown_iff (t : Timestamp) : t.isValidKnown = true ↔ Spec.ValidKnown t := sorry -/-- `siSeconds` counts the SI seconds from 1972-01-01T00:00:00Z to the - timestamp, each leap second of the table included, leaving out the - fraction. -/ +/-- `siSeconds` is the Unix seconds from 1972-01-01T00:00:00Z to the + timestamp, leaving out the fraction, and one more for each leap + second of the table before it. From 1972 on, those are the SI + seconds between them; before 1972 there are no leap seconds, and the + number is Unix seconds, 86400 to a day, not SI seconds. -/ theorem Spec.siSeconds_eq (t : Timestamp) (h : Spec.Valid t) : t.siSeconds = Spec.siSeconds t := sorry /-- On whole seconds, valid by the table, `siSeconds` orders timestamps exactly as the instants they name. So no two instants share a - number: a leap second, the second before it and the second after - it get three numbers in a row. -/ + number, a leap second and the second after it included. -/ theorem Spec.siSeconds_order (a b : Timestamp) (ha : Spec.ValidKnown a) (hb : Spec.ValidKnown b) (fa : a.fraction = []) (fb : b.fraction = []) : compare a.siSeconds b.siSeconds = a.compareInstant b := sorry @@ -214,6 +215,13 @@ theorem Spec.fromSiSeconds_isSome (n : Int) : (fromSiSeconds n).isSome ↔ 0 ≤ n ∧ n ≤ Spec.siSeconds ⟨9999, 12, 31, 23, 59, 59, [], .z⟩ := sorry +/-- `taiMinusUtc` is 10, and one more for each leap second of the table + before the timestamp. So during a leap second it is still the value + from before it. (Before 1972, UTC was not a whole number of seconds + from TAI, and the 10 there means nothing.) -/ +theorem Spec.taiMinusUtc_leapsBefore (t : Timestamp) (h : Spec.Valid t) : + t.taiMinusUtc = 10 + Spec.leapsBefore t := sorry + /-- TAI − UTC at each second from 1972 on, other than a leap second, is the value the table's second column gives for it. The code never reads that column: it counts leap seconds. -/ @@ -239,9 +247,16 @@ theorem Spec.leap_table_vectors : Timestamp.siSeconds ⟨1972, 6, 30, 23, 59, 60, [], .z⟩ = 15724800 ∧ Timestamp.siSeconds ⟨1972, 7, 1, 0, 0, 0, [], .z⟩ = 15724801 ∧ Timestamp.siSeconds ⟨2017, 1, 1, 0, 0, 0, [], .z⟩ = 1420156827 ∧ + Timestamp.siSeconds ⟨2016, 12, 31, 23, 59, 59, [], .z⟩ = 1420156825 ∧ + Timestamp.siSeconds ⟨2016, 12, 31, 23, 59, 60, [], .z⟩ = 1420156826 ∧ Timestamp.taiMinusUtc ⟨1972, 1, 1, 0, 0, 0, [], .z⟩ = 10 ∧ + Timestamp.taiMinusUtc ⟨1972, 6, 30, 23, 59, 59, [], .z⟩ = 10 ∧ + Timestamp.taiMinusUtc ⟨1972, 6, 30, 23, 59, 60, [], .z⟩ = 10 ∧ + Timestamp.taiMinusUtc ⟨1972, 7, 1, 0, 0, 0, [], .z⟩ = 11 ∧ + Timestamp.taiMinusUtc ⟨2016, 12, 31, 23, 59, 60, [], .z⟩ = 36 ∧ Timestamp.taiMinusUtc ⟨2016, 12, 31, 23, 59, 59, [], .z⟩ = 36 ∧ Timestamp.taiMinusUtc ⟨2017, 1, 1, 0, 0, 0, [], .z⟩ = 37 ∧ + fromSiSeconds 15724800 = some ⟨1972, 6, 30, 23, 59, 60, [], .z⟩ ∧ fromSiSeconds 1420156826 = some ⟨2016, 12, 31, 23, 59, 60, [], .z⟩ ∧ fromSiSeconds 1420156827 = some ⟨2017, 1, 1, 0, 0, 0, [], .z⟩ ∧ fromUnixSeconds (Timestamp.unixSeconds ⟨2016, 12, 31, 23, 59, 60, [], .z⟩) = diff --git a/Spec/Leap.lean b/Spec/Leap.lean index 97d49f4..85698c8 100644 --- a/Spec/Leap.lean +++ b/Spec/Leap.lean @@ -13,11 +13,15 @@ open Rfc3339 /-! ## The table -`leap-seconds.list`, as tzdata ships it (here from -`/usr/share/zoneinfo/leap-seconds.list`), gives "the times at which the -offset between TAI and UTC changed and the value of the offset after -that time". Each line is an NTP time, seconds since 1900-01-01T00:00:00Z -with 86400 to a day, then TAI − UTC from that time on: +`leap-seconds.list`, as tzdata 2026e ships it in +`/usr/share/zoneinfo/leap-seconds.list`, is "Updated through IERS +Bulletin C", its last update at NTP time 3992312697 (its `#$` line), and +its own hash line reads `#h a9bad145 84c31c70 758402aa b37bfd54 +5923836a`. It defines its columns so: "NTP timestamp (X parameter) is +the number of seconds since 1900.0", and "DTAI: The difference DTAI= +TAI-UTC in units of seconds. It is the quantity to add to UTC to get the +time in TAI". NTP time counts 86400 seconds to every day. Each data line +is an NTP time and the DTAI from then on: ``` 2272060800 10 # 1 Jan 1972 @@ -52,7 +56,9 @@ with 86400 to a day, then TAI − UTC from that time on: "File expires on 28 June 2027": the table holds until then. The first line is the start of UTC as it is now, with TAI − UTC 10; each later -line follows a leap second, the second 23:59:60 UTC just before it. +line follows a leap second, the second 23:59:60 UTC just before it. The +file adds that "so far, all leap seconds have been positive (a second has +been added to UTC)". -/ /-- The table, line for line: NTP time, and TAI − UTC from then on. -/ diff --git a/comparator.json b/comparator.json index 62d4328..2924454 100644 --- a/comparator.json +++ b/comparator.json @@ -24,6 +24,7 @@ "Spec.siSeconds_order", "Spec.fromSiSeconds_iff", "Spec.fromSiSeconds_isSome", + "Spec.taiMinusUtc_leapsBefore", "Spec.taiMinusUtc_eq", "Spec.leap_table_vectors" ], diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index c2a5a65..ff13d4d 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -6,15 +6,15 @@ Written by `tests/run_tests.py`. Rerun it to check these numbers. - Lean: Lean (version 4.35.0, x86_64-unknown-linux-gnu, commit eb6cafb7290e8bd5f2937522d8d804f58f02bafc, Release) - Seed: 20261025 - Inputs: 300000 -- Mutated: 67969 of the inputs are mutated timestamps -- By kind: compare 29344, error 35, fromsi 9381, fromunix 44688, grid 16998, known 34402, mutated 67969, near miss 29160, valid 68023 -- Strings parsed: 182150, of which 83488 accepted (4661 with a leap second) and 98662 rejected -- Accepted strings also read by `datetime.fromisoformat`, with the same fields, offset and seconds: 77297 -- Pairs ordered: 29344 (9831 before, 8253 same instant, 9780 after, 1480 with a string rejected) -- Unix seconds turned back into timestamps: 44688, of which 704 outside years 0000 to 9999 -- Leap second table: /usr/share/zoneinfo/leap-seconds.list, 28 lines, 27 leap seconds, expires 2027-06-28 -- Timestamps checked against the table (`known`): 34402, of which 303 leap seconds of the table, 6821 with a second 60 it does not list, and 0 rejected -- SI seconds turned back into timestamps (`fromsi`): 9381, of which 27 leap seconds and 681 outside 1972 to 9999 +- Mutated: 67881 of the inputs are mutated timestamps +- By kind: compare 29415, error 35, fromsi 9361, fromunix 44544, grid 16998, known 34491, mutated 67881, near miss 29257, valid 68018 +- Strings parsed: 182154, of which 83524 accepted (4667 with a leap second) and 98630 rejected +- Accepted strings also read by `datetime.fromisoformat`, with the same fields, offset and seconds: 77335 +- Pairs ordered: 29415 (9875 before, 8323 same instant, 9754 after, 1463 with a string rejected) +- Unix seconds turned back into timestamps: 44544, of which 679 outside years 0000 to 9999 +- Leap second table: /usr/share/zoneinfo/leap-seconds.list, 28 lines, 27 leap seconds, expires 2027-06-28, last updated at NTP time 3992312697, hash line `a9bad145 84c31c70 758402aa b37bfd54 5923836a` +- Timestamps checked against the table (`known`): 34491, of which 267 leap seconds of the table, 6367 with a second 60 it does not list, and 2564 rejected +- SI seconds turned back into timestamps (`fromsi`): 9361, of which 27 leap seconds and 695 outside 1972 to 9999 - Malformed requests answered `error`: 35 of 35 - Requests ending in CR LF: every seventh, 42857 - Strings also given to `rfc3339 check` as arguments, in batches of 500: 20000, and once with no arguments diff --git a/tests/cross_check.py b/tests/cross_check.py index ef0e9b5..f2182b4 100644 --- a/tests/cross_check.py +++ b/tests/cross_check.py @@ -20,7 +20,7 @@ For the leap second table, the timestamps tests/run_tests.py sends as `known` requests, and the SI seconds it sends as `fromsi`, go to ERFA, -the C version of the IAU's SOFA library, through pyerfa, which keeps its +a BSD-licensed C library derived from the IAU's SOFA, through pyerfa, which keeps its own leap second table: `dat` gives TAI - UTC, and `dtf2d` with `utctai` the TAI time, from which the SI seconds since 1972-01-01T00:00:00Z follow. It needs pyerfa: diff --git a/tests/run_tests.py b/tests/run_tests.py index a26e2a8..f8687ca 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -47,7 +47,8 @@ or NOLEAP for a second 60 it does not list. The inputs are 23:59:58, 23:59:59, 23:59:60 and the next 00:00:00 at every month end from 1970 to 2030, in UTC and spelled under seven offsets, and random valid - timestamps. `fromsi` requests take every leap second's SI seconds and + timestamps, near misses and mutated strings, which `known` must refuse + wherever `parse` does. `fromsi` requests take every leap second's SI seconds and the two seconds either side, the ends of the range and one past each, random numbers, and the SI seconds of random timestamps; - malformed requests, which the tool must answer with `error`. @@ -127,9 +128,15 @@ def from_unix(n): def leap_table(): + if not LEAP_FILE.exists(): + sys.exit(f"needs tzdata's {LEAP_FILE}, the leap second table the tests check against") rows, expires = [], None for line in LEAP_FILE.read_text().splitlines(): - if line.startswith("#@"): + if line.startswith("#$"): + LEAP_FILE_INFO["updated"] = int(line.split()[1]) + elif line.startswith("#h"): + LEAP_FILE_INFO["hash"] = " ".join(line.split()[1:]) + elif line.startswith("#@"): expires = int(line.split()[1]) elif line.strip() and not line.startswith("#"): ntp, off = line.split()[:2] @@ -137,6 +144,7 @@ def leap_table(): return rows, expires +LEAP_FILE_INFO = {} LEAP_TABLE, LEAP_EXPIRES = leap_table() # The Unix seconds of the midnight after each leap second, which is also # what Unix seconds give the leap second itself. @@ -586,7 +594,16 @@ def cases(seed, count): while made < count: r = rng.random() if r < 0.06: - s = random_valid(rng) if rng.random() < 0.8 else leap_pair(rng)[0] + r = rng.random() + if r < 0.65: + s = random_valid(rng) + elif r < 0.8: + s = leap_pair(rng)[0] + elif r < 0.9: + s = near_miss(rng) + else: + base = random_valid(rng) + s = mutate(rng, base) yield ("known", f"known {hexed(s)}", known(s), False) made += 1 continue @@ -861,7 +878,9 @@ def main(): f"- Unix seconds turned back into timestamps: {len(from_unix_wants)}, of which " f"{from_unix_wants.count('NONE')} outside years 0000 to 9999", f"- Leap second table: {LEAP_FILE}, {len(LEAP_TABLE)} lines, {len(LEAP_MIDNIGHTS)} leap " - f"seconds, expires {expires:%Y-%m-%d}" if expires else f"- Leap second table: {LEAP_FILE}", + f"seconds, expires {expires:%Y-%m-%d}, last updated at NTP time " + f"{LEAP_FILE_INFO.get('updated')}, hash line `{LEAP_FILE_INFO.get('hash')}`" + if expires else f"- Leap second table: {LEAP_FILE}", f"- Timestamps checked against the table (`known`): {len(knowns)}, of which " f"{known_leaps} leap seconds of the table, {knowns.count('NOLEAP')} with a second 60 it " f"does not list, and {knowns.count('REJECT')} rejected", From 76bd131ee3a1a1d4534b1d3706d051aeb2643895 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:21:37 +0000 Subject: [PATCH 07/11] Prove the IXDTF suffix parser: all seven IXDTF statements are proved Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WWUFjVe6uzWVQGpyU113ow --- Proofs/Ixdtf/Suffix.lean | 583 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 580 insertions(+), 3 deletions(-) diff --git a/Proofs/Ixdtf/Suffix.lean b/Proofs/Ixdtf/Suffix.lean index 1545036..f3a5ad5 100644 --- a/Proofs/Ixdtf/Suffix.lean +++ b/Proofs/Ixdtf/Suffix.lean @@ -4,29 +4,606 @@ import Rfc3339 import Spec.Ixdtf +import Proofs.Format +import Proofs.Valid open Rfc3339 namespace Rfc3339.Proofs.Ixdtf +/-! ## Characters -/ + +theorem sx_alpha_ne {c : Char} (h : Spec.Alpha c) : + c ≠ ']' ∧ c ≠ '=' ∧ c ≠ '/' ∧ c ≠ '!' ∧ c ≠ '+' ∧ c ≠ '-' := by + unfold Spec.Alpha at h + refine ⟨?_, ?_, ?_, ?_, ?_, ?_⟩ <;> rintro rfl <;> revert h <;> decide + +theorem sx_digit_ne {c : Char} (h : Spec.Digit c) : + c ≠ ']' ∧ c ≠ '=' ∧ c ≠ '/' ∧ c ≠ '!' ∧ c ≠ '+' ∧ c ≠ '-' := by + unfold Spec.Digit at h + refine ⟨?_, ?_, ?_, ?_, ?_, ?_⟩ <;> rintro rfl <;> revert h <;> decide + +theorem sx_lower_ne {c : Char} (h : 'a' ≤ c ∧ c ≤ 'z') : + c ≠ ']' ∧ c ≠ '=' ∧ c ≠ '/' ∧ c ≠ '!' ∧ c ≠ '+' ∧ c ≠ '-' := by + refine ⟨?_, ?_, ?_, ?_, ?_, ?_⟩ <;> rintro rfl <;> revert h <;> decide + +theorem sx_zonePart_chars {p : List Char} (hp : Spec.ZonePart p) : + ∀ x ∈ p, x ≠ ']' ∧ x ≠ '=' ∧ x ≠ '/' := by + obtain ⟨c, cs, rfl, hc, hcs, -, -⟩ := hp + intro x hx + rcases List.mem_cons.mp hx with rfl | hx + · rcases hc with h | rfl | rfl + · have := sx_alpha_ne h; exact ⟨this.1, this.2.1, this.2.2.1⟩ + all_goals decide + · rcases hcs x hx with h | rfl | rfl | h | rfl | rfl + · have := sx_alpha_ne h; exact ⟨this.1, this.2.1, this.2.2.1⟩ + · decide + · decide + · have := sx_digit_ne h; exact ⟨this.1, this.2.1, this.2.2.1⟩ + all_goals decide + +theorem sx_zonePart_head {p : List Char} (hp : Spec.ZonePart p) : + ∃ c cs, p = c :: cs ∧ c ≠ '!' ∧ c ≠ '+' ∧ c ≠ '-' := by + obtain ⟨c, cs, rfl, hc, -, -, -⟩ := hp + refine ⟨c, cs, rfl, ?_⟩ + rcases hc with h | rfl | rfl + · have := sx_alpha_ne h; exact ⟨this.2.2.2.1, this.2.2.2.2.1, this.2.2.2.2.2⟩ + all_goals decide + +theorem sx_key_chars {k : List Char} (hk : Spec.SuffixKey k) : ∀ x ∈ k, x ≠ ']' ∧ x ≠ '=' := by + obtain ⟨c, cs, rfl, hc, hcs⟩ := hk + intro x hx + rcases List.mem_cons.mp hx with rfl | hx + · rcases hc with h | rfl + · have := sx_lower_ne h; exact ⟨this.1, this.2.1⟩ + · decide + · rcases hcs x hx with h | rfl | h | rfl + · have := sx_lower_ne h; exact ⟨this.1, this.2.1⟩ + · decide + · have := sx_digit_ne h; exact ⟨this.1, this.2.1⟩ + · decide + +theorem sx_key_head {k : List Char} (hk : Spec.SuffixKey k) : ∃ c cs, k = c :: cs ∧ c ≠ '!' := by + obtain ⟨c, cs, rfl, hc, -⟩ := hk + refine ⟨c, cs, rfl, ?_⟩ + rcases hc with h | rfl + · exact (sx_lower_ne h).2.2.2.1 + · decide + +theorem sx_value_chars {v : List Char} (hv : Spec.SuffixValue v) : + ∀ x ∈ v, x ≠ ']' ∧ x ≠ '=' ∧ x ≠ '-' := by + intro x hx + rcases hv.2 x hx with h | h + · have := sx_alpha_ne h; exact ⟨this.1, this.2.1, this.2.2.2.2.2⟩ + · have := sx_digit_ne h; exact ⟨this.1, this.2.1, this.2.2.2.2.2⟩ + +theorem sx_alpha_iff (c : Char) : isAlpha c = true ↔ Spec.Alpha c := by + simp [isAlpha, Spec.Alpha] +theorem sx_digit_iff (c : Char) : isDigitChar c = true ↔ Spec.Digit c := by + simp [isDigitChar, Spec.Digit] +theorem sx_keyOK_iff (k : List Char) : keyOK k = true ↔ Spec.SuffixKey k := by + cases k with + | nil => simp [keyOK, Spec.SuffixKey] + | cons c cs => + simp only [keyOK, Spec.SuffixKey, sx_digit_iff, Bool.and_eq_true, Bool.or_eq_true, List.all_eq_true, beq_iff_eq, decide_eq_true_eq, List.cons.injEq] + grind +theorem sx_valueOK_iff (v : List Char) : valueOK v = true ↔ Spec.SuffixValue v := by + simp only [valueOK, Spec.SuffixValue, sx_digit_iff, sx_alpha_iff, Bool.and_eq_true, Bool.or_eq_true, List.all_eq_true, Bool.not_eq_true', List.isEmpty_eq_false_iff, ne_eq] +theorem sx_drop_takeWhile (p : α → Bool) (xs : List α) : xs.drop (xs.takeWhile p).length = xs.dropWhile p := by + induction xs with + | nil => rfl + | cons x xs ih => + simp only [List.takeWhile_cons, List.dropWhile_cons] + cases p x <;> simp [ih] +theorem sx_takeWhile_append {p : α → Bool} (xs : List α) (y : α) (ys : List α) + (hx : ∀ x ∈ xs, p x = true) (hy : p y = false) : (xs ++ y :: ys).takeWhile p = xs := by + induction xs with + | nil => simp [hy] + | cons x xs ih => + simp only [List.cons_append, List.takeWhile_cons, hx x (by simp), ↓reduceIte] + rw [ih (fun z hz => hx z (by simp [hz]))] +theorem sx_digitChar_mem (n : Nat) : Spec.digitChar n ∈ "0123456789?".toList := by + unfold Spec.digitChar + rw [List.getD_eq_getElem?_getD] + cases h : "0123456789".toList[n]? with + | none => decide + | some x => + have := List.mem_of_getElem? h + simp only [Option.getD_some] + revert this; generalize x = y; revert y; decide +theorem sx_digits_mem {k n : Nat} {c : Char} (h : c ∈ Spec.digits k n) : c ∈ "0123456789?".toList := by + unfold Spec.digits at h + simp only [List.mem_map] at h + obtain ⟨i, _, rfl⟩ := h + exact sx_digitChar_mem _ +theorem sx_mem_join {α : Type} {s : List α} {ls : List (List α)} {x : α} + (h : x ∈ (ls.intersperse s).flatten) : x ∈ s ∨ ∃ l ∈ ls, x ∈ l := by + induction ls with + | nil => simp at h + | cons a tl ih => + cases tl with + | nil => exact Or.inr ⟨a, by simp, by simpa using h⟩ + | cons b rest => + rw [List.intersperse_cons_cons, List.flatten_cons, List.flatten_cons, List.mem_append, + List.mem_append] at h + rcases h with h | h | h + · exact Or.inr ⟨a, by simp, h⟩ + · exact Or.inl h + · rcases ih h with h' | ⟨l, hl, hx⟩ + · exact Or.inl h' + · exact Or.inr ⟨l, List.mem_cons_of_mem _ hl, hx⟩ + +theorem sx_join_cons {α : Type} (s p : List α) (rest : List (List α)) : + ∃ X, ((p :: rest).intersperse s).flatten = p ++ X := by + cases rest with + | nil => exact ⟨[], by simp⟩ + | cons b rest => exact ⟨_, by rw [List.intersperse_cons_cons, List.flatten_cons]⟩ + +theorem sx_intercalate_eq {α : Type} (s : List α) (ls : List (List α)) : + s.intercalate ls = (ls.intersperse s).flatten := rfl + +theorem sx_zonePartOK_iff (p : List Char) : zonePartOK p = true ↔ Spec.ZonePart p := by + cases p with + | nil => simp [zonePartOK, Spec.ZonePart] + | cons c cs => + simp only [zonePartOK, Spec.ZonePart, sx_alpha_iff, sx_digit_iff, Bool.and_eq_true, + Bool.or_eq_true, List.all_eq_true, beq_iff_eq, bne_iff_ne, ne_eq, List.cons.injEq] + grind + +/-- The body of a tag item, after the flag. -/ +def sx_tagBody (t : Tag) : List Char := t.key ++ ['='] ++ (t.values.intersperse ['-']).flatten + +/-- An item: brackets around the flag and the body. -/ +def sx_item (c : Bool) (b : List Char) : List Char := ['['] ++ Spec.renderFlag c ++ b ++ [']'] + +theorem sx_parseTag_sound {c : Bool} {b : List Char} {t : Tag} (h : parseTag c b = some t) : + Spec.TagValid t ∧ t.critical = c ∧ b = sx_tagBody t := by + unfold parseTag at h + have hb := (sx_drop_takeWhile (fun x => decide (x ≠ '=')) b).symm ▸ + (List.takeWhile_append_dropWhile (p := fun x => decide (x ≠ '=')) (l := b)) + dsimp only at h + split at h + · rename_i vs hvs + split at h + · rename_i hok + injection h with h + subst h + simp only [Bool.and_eq_true, List.all_eq_true, sx_keyOK_iff, sx_valueOK_iff] at hok + refine ⟨⟨hok.1, List.splitOn_ne_nil _ _, hok.2⟩, rfl, ?_⟩ + rw [hvs] at hb + simp only [sx_tagBody, ← sx_intercalate_eq, List.intercalate_splitOn] + rw [List.append_assoc, List.singleton_append] + exact hb.symm + · simp at h + · simp at h + +theorem sx_parseTag_render {t : Tag} (h : Spec.TagValid t) : + parseTag t.critical (sx_tagBody t) = some t := by + obtain ⟨hk, hne, hv⟩ := h + have hkey : (sx_tagBody t).takeWhile (fun x => decide (x ≠ '=')) = t.key := by + unfold sx_tagBody + rw [List.append_assoc, List.singleton_append] + exact sx_takeWhile_append _ _ _ (fun x hx => by simpa using (sx_key_chars hk x hx).2) + (by simp) + unfold parseTag + simp only [hkey] + unfold sx_tagBody + rw [List.append_assoc, List.drop_left, List.singleton_append] + simp only + rw [← sx_intercalate_eq, List.splitOn_intercalate _ (fun l hl => by + intro hm; exact (sx_value_chars (hv l hl) _ hm).2.2 rfl) hne] + have hkok : keyOK t.key = true := (sx_keyOK_iff _).mpr hk + have hvok : t.values.all valueOK = true := List.all_eq_true.mpr fun v hv' => (sx_valueOK_iff v).mpr (hv v hv') + simp [hkok, hvok] + +theorem sx_parseZone_sound {b : List Char} {z : Zone} (h : parseZone b = some z) : + Spec.ZoneValid z ∧ b = Spec.renderZone z := by + unfold parseZone at h + split at h + · rename_i x + split at h + · rename_i o ho + split at h + · rename_i hvo + injection h with h + subst h + have hb := Rfc3339.Proofs.Format.parseOffset_spec ho + refine ⟨⟨?_, (Rfc3339.Proofs.Valid.offset_isValid_iff o).mp hvo⟩, ?_⟩ + · rintro rfl; simp [Offset.render] at hb + · rw [hb]; exact Rfc3339.Proofs.Format.offset_render_eq o + · simp at h + · simp at h + · rename_i x + split at h + · rename_i o ho + split at h + · rename_i hvo + injection h with h + subst h + have hb := Rfc3339.Proofs.Format.parseOffset_spec ho + refine ⟨⟨?_, (Rfc3339.Proofs.Valid.offset_isValid_iff o).mp hvo⟩, ?_⟩ + · rintro rfl; simp [Offset.render] at hb + · rw [hb]; exact Rfc3339.Proofs.Format.offset_render_eq o + · simp at h + · simp at h + · dsimp only at h + split at h + · rename_i hall + injection h with h + subst h + refine ⟨⟨List.splitOn_ne_nil _ _, fun p hp => (sx_zonePartOK_iff p).mp (List.all_eq_true.mp hall p hp)⟩, ?_⟩ + show b = ((b.splitOn '/').intersperse ['/']).flatten + rw [← sx_intercalate_eq, List.intercalate_splitOn] + · simp at h + +theorem sx_parseZone_render {z : Zone} (h : Spec.ZoneValid z) : + parseZone (Spec.renderZone z) = some z := by + cases z with + | name parts => + obtain ⟨hne, hp⟩ := h + obtain ⟨p, rest, rfl⟩ := List.exists_cons_of_ne_nil hne + obtain ⟨c, cs, hpc, hc1, hc2, hc3⟩ := sx_zonePart_head (hp p (by simp)) + obtain ⟨X, hX⟩ := sx_join_cons ['/'] p rest + have hsplit : ((p :: rest).intersperse ['/']).flatten.splitOn '/' = p :: rest := by + rw [← sx_intercalate_eq] + exact List.splitOn_intercalate _ + (fun l hl hm => (sx_zonePart_chars (hp l hl) _ hm).2.2 rfl) (by simp) + have hall : (p :: rest).all zonePartOK = true := + List.all_eq_true.mpr fun l hl => (sx_zonePartOK_iff l).mpr (hp l hl) + show parseZone ((p :: rest).intersperse ['/']).flatten = _ + unfold parseZone + split + · rename_i x heq + rw [hX, hpc] at heq + simp at heq + exact absurd heq.1 hc2 + · rename_i x heq + rw [hX, hpc] at heq + simp at heq + exact absurd heq.1 hc3 + · simp only [hsplit, hall] + rfl + | offset o => + obtain ⟨hz, hv⟩ := h + have hvalid : o.isValid = true := (Rfc3339.Proofs.Valid.offset_isValid_iff o).mpr hv + have hr := Rfc3339.Proofs.Format.parseOffset_render o hvalid + show parseZone (Spec.renderOffset o) = _ + rw [← Rfc3339.Proofs.Format.offset_render_eq] + cases o with + | z => exact absurd rfl hz + | plus a m => + have hcons : (Offset.plus a m).render = '+' :: (pad2 a ++ ':' :: pad2 m) := rfl + rw [hcons] at hr ⊢ + unfold parseZone + simp only [hr, hvalid, ↓reduceIte] + | minus a m => + have hcons : (Offset.minus a m).render = '-' :: (pad2 a ++ ':' :: pad2 m) := rfl + rw [hcons] at hr ⊢ + unfold parseZone + simp only [hr, hvalid, ↓reduceIte] + + +theorem sx_digits_ne {k n : Nat} {c : Char} (h : c ∈ Spec.digits k n) : c ≠ ']' ∧ c ≠ '=' := by + have key : ∀ d ∈ "0123456789?".toList, d ≠ ']' ∧ d ≠ '=' := by decide + exact key c (sx_digits_mem h) + +theorem sx_tagBody_facts {t : Tag} (h : Spec.TagValid t) : + ']' ∉ sx_tagBody t ∧ (sx_tagBody t).head? ≠ some '!' ∧ '=' ∈ sx_tagBody t := by + obtain ⟨hk, hne, hv⟩ := h + refine ⟨?_, ?_, ?_⟩ + · unfold sx_tagBody + simp only [List.mem_append, not_or] + refine ⟨⟨fun hm => (sx_key_chars hk _ hm).1 rfl, by decide⟩, fun hm => ?_⟩ + rcases sx_mem_join hm with h | ⟨l, hl, hx⟩ + · simp at h + · exact (sx_value_chars (hv l hl) _ hx).1 rfl + · obtain ⟨c, cs, hkc, hc⟩ := sx_key_head hk + unfold sx_tagBody + rw [hkc] + simpa using hc + · unfold sx_tagBody + simp + +theorem sx_zoneBody_facts {z : Zone} (h : Spec.ZoneValid z) : + ']' ∉ Spec.renderZone z ∧ (Spec.renderZone z).head? ≠ some '!' ∧ '=' ∉ Spec.renderZone z := by + cases z with + | name parts => + obtain ⟨hne, hp⟩ := h + refine ⟨fun hm => ?_, ?_, fun hm => ?_⟩ + · rcases sx_mem_join hm with h | ⟨l, hl, hx⟩ + · simp at h + · exact (sx_zonePart_chars (hp l hl) _ hx).1 rfl + · obtain ⟨p, rest, rfl⟩ := List.exists_cons_of_ne_nil hne + obtain ⟨c, cs, hpc, hc1, -, -⟩ := sx_zonePart_head (hp p (by simp)) + obtain ⟨X, hX⟩ := sx_join_cons ['/'] p rest + show (((p :: rest).intersperse ['/']).flatten).head? ≠ _ + rw [hX, hpc] + simpa using hc1 + · rcases sx_mem_join hm with h | ⟨l, hl, hx⟩ + · simp at h + · exact (sx_zonePart_chars (hp l hl) _ hx).2.1 rfl + | offset o => + obtain ⟨hz, -⟩ := h + cases o with + | z => exact absurd rfl hz + | plus a m => + refine ⟨fun hm => ?_, by simp [Spec.renderZone, Spec.renderOffset], fun hm => ?_⟩ <;> + · simp only [Spec.renderZone, Spec.renderOffset, List.mem_append] at hm + rcases hm with ((h | h) | h) | h <;> + first + | exact absurd h (by decide) + | exact (sx_digits_ne h).1 rfl + | exact (sx_digits_ne h).2 rfl + | minus a m => + refine ⟨fun hm => ?_, by simp [Spec.renderZone, Spec.renderOffset], fun hm => ?_⟩ <;> + · simp only [Spec.renderZone, Spec.renderOffset, List.mem_append] at hm + rcases hm with ((h | h) | h) | h <;> + first + | exact absurd h (by decide) + | exact (sx_digits_ne h).1 rfl + | exact (sx_digits_ne h).2 rfl + +theorem sx_suffixItems_cons (f : Nat) (c : Bool) (b R : List Char) (hb : ']' ∉ b) + (hh : b.head? ≠ some '!') : + suffixItems (f + 1) (sx_item c b ++ R) = (suffixItems f R).map (fun tail => (c, b) :: tail) := by + have hB : ∀ x ∈ Spec.renderFlag c ++ b, decide (x ≠ ']') = true := by + intro x hx + simp only [decide_eq_true_eq] + rintro rfl + rcases List.mem_append.mp hx with h | h + · cases c <;> simp [Spec.renderFlag] at h + · exact hb h + have e1 : sx_item c b ++ R = '[' :: ((Spec.renderFlag c ++ b) ++ ']' :: R) := by simp [sx_item] + rw [e1] + simp only [suffixItems, ↓reduceIte] + rw [sx_takeWhile_append _ _ _ hB (by simp), List.drop_left] + simp only + cases c with + | true => + cases suffixItems f R <;> rfl + | false => + cases hs : suffixItems f R with + | none => rfl + | some tail => + simp only [Spec.renderFlag, Bool.false_eq_true, ↓reduceIte, List.nil_append, Option.map_some] + split + · simp at hh + · rfl + + +theorem sx_suffixItems_render (items : List (Bool × List Char)) (fuel : Nat) + (h : ∀ p ∈ items, ']' ∉ p.2 ∧ p.2.head? ≠ some '!') + (hf : (items.flatMap (fun p => sx_item p.1 p.2)).length ≤ fuel) : + suffixItems fuel (items.flatMap (fun p => sx_item p.1 p.2)) = some items := by + induction items generalizing fuel with + | nil => cases fuel <;> rfl + | cons p rest ih => + obtain ⟨c, b⟩ := p + simp only [List.flatMap_cons] at hf ⊢ + have hlen : (sx_item c b).length ≥ 2 := by simp [sx_item]; omega + simp only [List.length_append] at hf + obtain ⟨f, rfl⟩ : ∃ f, fuel = f + 1 := ⟨fuel - 1, by omega⟩ + have hp := h (c, b) (by simp) + rw [sx_suffixItems_cons f c b _ hp.1 hp.2, + ih f (fun q hq => h q (List.mem_cons_of_mem _ hq)) (by omega)] + rfl + +theorem sx_suffixItems_sound (fuel : Nat) (cs : List Char) (items : List (Bool × List Char)) + (h : suffixItems fuel cs = some items) : + cs = items.flatMap (fun p => sx_item p.1 p.2) := by + induction fuel generalizing cs items with + | zero => + cases cs with + | nil => + simp only [suffixItems, Option.some.injEq] at h + subst h; rfl + | cons c rest => simp [suffixItems] at h + | succ f ih => + cases cs with + | nil => + simp only [suffixItems, Option.some.injEq] at h + subst h; rfl + | cons c rest => + have hr := (sx_drop_takeWhile (fun x => decide (x ≠ ']')) rest).symm ▸ + (List.takeWhile_append_dropWhile (p := fun x => decide (x ≠ ']')) (l := rest)) + unfold suffixItems at h + split at h + · rename_i hc + subst hc + dsimp only at h + split at h + · rename_i more hmore + rw [hmore] at hr + obtain ⟨tail, ht, h2⟩ := Option.bind_eq_some_iff.mp h + clear h + have := ih more tail ht + subst this + split at h2 + · rename_i b hbody + simp only [pure, Option.some.injEq] at h2 + subst h2 + rw [hbody] at hr + subst hr + simp [sx_item, Spec.renderFlag] + · simp only [pure, Option.some.injEq] at h2 + subst h2 + obtain ⟨B, hB⟩ : ∃ B, List.takeWhile (fun x => decide (x ≠ ']')) rest = B := ⟨_, rfl⟩ + rw [hB] at hr ⊢ + subst hr + simp [sx_item, Spec.renderFlag] + · simp at h + · simp at h + + +theorem sx_mapM_sound (rest : List (Bool × List Char)) (tags : List Tag) + (h : rest.mapM (fun (c, b) => parseTag c b) = some tags) : + (∀ t ∈ tags, Spec.TagValid t) ∧ rest = tags.map (fun t => (t.critical, sx_tagBody t)) := by + induction rest generalizing tags with + | nil => + simp only [List.mapM_nil, pure, Option.some.injEq] at h + subst h + simp + | cons p rest ih => + obtain ⟨c, b⟩ := p + simp only [List.mapM_cons, bind, Option.bind_eq_some_iff, pure, Option.some.injEq] at h + obtain ⟨t, ht, ts, hts, rfl⟩ := h + obtain ⟨htv, rfl, rfl⟩ := sx_parseTag_sound ht + obtain ⟨hv, rfl⟩ := ih ts hts + refine ⟨?_, rfl⟩ + intro u hu + rcases List.mem_cons.mp hu with rfl | hu + · exact htv + · exact hv u hu + +theorem sx_mapM_render (tags : List Tag) (h : ∀ t ∈ tags, Spec.TagValid t) : + (tags.map (fun t => (t.critical, sx_tagBody t))).mapM (fun (c, b) => parseTag c b) = + some tags := by + induction tags with + | nil => rfl + | cons t ts ih => + simp only [List.map_cons, List.mapM_cons, sx_parseTag_render (h t (by simp)), + ih (fun u hu => h u (List.mem_cons_of_mem _ hu))] + rfl + +/-- The items of a suffix: the time zone, if any, then the tags. -/ +def sx_items (e : Ext) : List (Bool × List Char) := + (match e.zone with + | some (c, z) => [(c, Spec.renderZone z)] + | none => []) ++ e.tags.map (fun t => (t.critical, sx_tagBody t)) + +theorem sx_renderTag_eq (t : Tag) : Spec.renderTag t = sx_item t.critical (sx_tagBody t) := by + simp [Spec.renderTag, sx_item, sx_tagBody] + +theorem sx_renderSuffix_items (e : Ext) : + Spec.renderSuffix e = (sx_items e).flatMap (fun p => sx_item p.1 p.2) := by + obtain ⟨stamp, zone, tags⟩ := e + have htags : (tags.map Spec.renderTag).flatten = + (tags.map (fun t => (t.critical, sx_tagBody t))).flatMap (fun p => sx_item p.1 p.2) := by + induction tags with + | nil => rfl + | cons t ts ih => simp only [List.map_cons, List.flatten_cons, List.flatMap_cons, ih, sx_renderTag_eq] + rcases zone with _ | ⟨c, z⟩ + · simp only [Spec.renderSuffix, sx_items, List.nil_append, htags] + · simp only [Spec.renderSuffix, sx_items, htags, List.singleton_append, List.flatMap_cons, sx_item] + +theorem sx_items_facts (e : Ext) (hz : ∀ c z, e.zone = some (c, z) → Spec.ZoneValid z) + (ht : ∀ t ∈ e.tags, Spec.TagValid t) : + ∀ p ∈ sx_items e, ']' ∉ p.2 ∧ p.2.head? ≠ some '!' := by + obtain ⟨stamp, zone, tags⟩ := e + intro p hp + simp only [sx_items, List.mem_append, List.mem_map] at hp + rcases hp with hp | ⟨t, htm, rfl⟩ + · rcases zone with _ | ⟨c, z⟩ + · simp at hp + · simp only [List.mem_singleton] at hp + subst hp + have := sx_zoneBody_facts (hz c z rfl) + exact ⟨this.1, this.2.1⟩ + · have := sx_tagBody_facts (ht t htm) + exact ⟨this.1, this.2.1⟩ + /-- `parseSuffix` reads exactly the suffixes of the grammar, as `Spec.renderSuffix` writes them. -/ theorem parseSuffix_iff (cs : List Char) (e : Ext) : parseSuffix cs = some (e.zone, e.tags) ↔ ((∀ c z, e.zone = some (c, z) → Spec.ZoneValid z) ∧ ∀ t ∈ e.tags, Spec.TagValid t) ∧ cs = Spec.renderSuffix e := by - sorry + constructor + · intro h + unfold parseSuffix at h + obtain ⟨items, hitems, h2⟩ := Option.bind_eq_some_iff.mp h + clear h + have hcs := sx_suffixItems_sound _ _ _ hitems + clear hitems + subst hcs + rw [sx_renderSuffix_items] + obtain ⟨stamp, zone, tags⟩ := e + simp only at h2 ⊢ + match items, h2 with + | [], h => + simp only [pure, Option.some.injEq, Prod.mk.injEq] at h + obtain ⟨rfl, rfl⟩ := h + simp [sx_items] + | (crit, body) :: rest, h => + obtain ⟨tags', htags, h3⟩ := Option.bind_eq_some_iff.mp h + clear h + obtain ⟨hv', rfl⟩ := sx_mapM_sound rest tags' htags + split at h3 + · obtain ⟨t, ht, h⟩ := Option.bind_eq_some_iff.mp h3 + clear h3 + obtain ⟨htv, rfl, rfl⟩ := sx_parseTag_sound ht + simp only [pure, Option.some.injEq, Prod.mk.injEq] at h + obtain ⟨rfl, rfl⟩ := h + refine ⟨⟨by simp, ?_⟩, by simp [sx_items]⟩ + intro u hu + rcases List.mem_cons.mp hu with rfl | hu + · exact htv + · exact hv' u hu + · obtain ⟨z, hz, h⟩ := Option.bind_eq_some_iff.mp h3 + clear h3 + obtain ⟨hzv, rfl⟩ := sx_parseZone_sound hz + simp only [pure, Option.some.injEq, Prod.mk.injEq] at h + obtain ⟨rfl, rfl⟩ := h + refine ⟨⟨?_, hv'⟩, by simp [sx_items]⟩ + intro c z' hcz + simp only [Option.some.injEq, Prod.mk.injEq] at hcz + obtain ⟨rfl, rfl⟩ := hcz + exact hzv + · rintro ⟨⟨hz, ht⟩, rfl⟩ + have hf := sx_items_facts e hz ht + rw [sx_renderSuffix_items] + unfold parseSuffix + rw [sx_suffixItems_render _ _ hf (Nat.le_refl _)] + obtain ⟨stamp, zone, tags⟩ := e + simp only at hz ht ⊢ + rcases zone with _ | ⟨c, z⟩ + · cases tags with + | nil => rfl + | cons t ts => + have htv := ht t (by simp) + have hts : ∀ u ∈ ts, Spec.TagValid u := fun u hu => ht u (List.mem_cons_of_mem _ hu) + have hc : (sx_tagBody t).contains '=' = true := + List.contains_iff_mem.mpr (sx_tagBody_facts htv).2.2 + simp only [sx_items, List.nil_append, List.map_cons, Option.bind_eq_bind, Option.bind_some] + simp only [Option.bind_some, sx_mapM_render ts hts, hc, ↓reduceIte, sx_parseTag_render htv] + rfl + · have hzv := hz c z rfl + have hc : (Spec.renderZone z).contains '=' = false := by + cases h : (Spec.renderZone z).contains '=' with + | false => rfl + | true => exact absurd (List.contains_iff_mem.mp h) (sx_zoneBody_facts hzv).2.2 + simp only [sx_items, List.singleton_append] + simp only [bind, Option.bind_some, sx_mapM_render tags ht, hc, Bool.false_eq_true, ↓reduceIte, + sx_parseZone_render hzv] + rfl /-- The code's rendering of the suffix is the spec's. -/ theorem renderSuffix_eq (e : Ext) (h : ∀ c z, e.zone = some (c, z) → Spec.ZoneValid z) : e.renderSuffix = Spec.renderSuffix e := by - sorry + obtain ⟨stamp, zone, tags⟩ := e + have hz : ∀ z : Zone, z.render = Spec.renderZone z := by + intro z + cases z with + | name parts => rfl + | offset o => exact Rfc3339.Proofs.Format.offset_render_eq o + have ht : (Tag.render : Tag → List Char) = Spec.renderTag := funext fun t => rfl + simp only [Ext.renderSuffix, Spec.renderSuffix, List.flatMap_def, ht] + rcases zone with _ | ⟨c, z⟩ + · rfl + · simp only [hz] + rfl /-- A suffix of the grammar is empty or starts with `[`, and holds no other `[` than at the start of each item; in particular a rendered suffix starts with `[` when it is not empty. -/ theorem renderSuffix_head (e : Ext) : Spec.renderSuffix e = [] ∨ (Spec.renderSuffix e).head? = some '[' := by - sorry + obtain ⟨stamp, zone, tags⟩ := e + rcases zone with _ | ⟨c, z⟩ + · cases tags with + | nil => left; rfl + | cons t ts => right; simp [Spec.renderSuffix, Spec.renderTag] + · right + simp [Spec.renderSuffix] end Rfc3339.Proofs.Ixdtf From aacd7509d8fb0108be3c8aa8d5605971fdfc427c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:06:16 +0000 Subject: [PATCH 08/11] Test fromsi's number reading and known's REJECT answer with fixed cases fromsi gets the malformed numbers fromunix has, among them 1_000, which String.toInt? reads and decimal refuses. known gets fixed strings that must answer REJECT, not NOLEAP, and the hex cases parse has. These short lists now come first, so a run with a small count has them. Rerun of the cross-check recorded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WWUFjVe6uzWVQGpyU113ow --- tests/CROSS-CHECK.md | 20 ++++++++++---------- tests/TEST-RESULTS.md | 18 +++++++++--------- tests/run_tests.py | 28 +++++++++++++++++++++++++--- 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/tests/CROSS-CHECK.md b/tests/CROSS-CHECK.md index db3aa75..2eb1491 100644 --- a/tests/CROSS-CHECK.md +++ b/tests/CROSS-CHECK.md @@ -6,7 +6,7 @@ Written by `tests/cross_check.py`, once, by hand. It is not part of the verified - Go: `time.Parse(time.RFC3339Nano, s)`, go version go1.24.7 linux/amd64 - rfc3339-validator 0.1.4 (Python 3.13.17), called on the string as given. `jsonschema` calls it for its `date-time` check on the upper-cased string, so the lower case rows below do not apply to `jsonschema`. - Seed: 20261025 -- Strings: 182162 +- Strings: 182166 - Strings both this project and Go accept, read to a different instant: 0 Where each parser differs from this one, by the first category that fits the string: @@ -14,19 +14,19 @@ Where each parser differs from this one, by the first category that fits the str | Parser | Differs by | Category | Strings | Example | | --- | --- | --- | --- | --- | | Go | rejects | lower case t or z | 18971 | `'1985-04-12t23:20:50.52z'`, `'2100-10-31t02:52:09+00:50'` | -| Go | rejects | second 60 | 3786 | `'1990-12-31T23:59:60Z'`, `'1990-12-31T15:59:60-08:00'` | -| Go | accepts | offset hour above 23 or minute above 59 | 1986 | `'2000-01-01T00:00:00+24:00'`, `'2000-01-01T00:00:00+23:60'` | -| Go | accepts | a field with one digit | 419 | `'0099-09-29T8:05:14.9-13:23'`, `'9972-03-01T8:16:47+02:02'` | -| Go | accepts | comma before the fraction | 3 | `'2000-01-01T00:00:00,5Z'`, `'1900-01-02T22:19:55,9-00:00'` | +| Go | rejects | second 60 | 3784 | `'1990-12-31T23:59:60Z'`, `'1990-12-31T15:59:60-08:00'` | +| Go | accepts | offset hour above 23 or minute above 59 | 1978 | `'2000-01-01T00:00:00+24:00'`, `'2000-01-01T00:00:00+23:60'` | +| Go | accepts | a field with one digit | 448 | `'0099-09-29T8:05:14.9-13:23'`, `'0508-04-01T2:41:06.3-08:38'` | +| Go | accepts | comma before the fraction | 4 | `'2000-01-01T00:00:00,5Z'`, `'1900-01-02T22:19:55,9-00:00'` | | rfc3339-validator | rejects | lower case t or z | 18971 | `'1985-04-12t23:20:50.52z'`, `'2100-10-31t02:52:09+00:50'` | -| rfc3339-validator | rejects | second 60 | 3786 | `'1990-12-31T23:59:60Z'`, `'1990-12-31T15:59:60-08:00'` | -| rfc3339-validator | rejects | year 0000 | 1243 | `'0000-01-01T00:00:00Z'`, `'0000-01-01T12:00:00Z'` | -| rfc3339-validator | accepts | line break at the end | 869 | `'4244-06-01T08:05:55.054876690884+00:00\n'`, `'5263-10-30T07:22:33.52-07:24\n'` | +| rfc3339-validator | rejects | second 60 | 3784 | `'1990-12-31T23:59:60Z'`, `'1990-12-31T15:59:60-08:00'` | +| rfc3339-validator | rejects | year 0000 | 1248 | `'0000-01-01T00:00:00Z'`, `'0000-01-01T12:00:00Z'` | +| rfc3339-validator | accepts | line break at the end | 891 | `'4244-06-01T08:05:55.054876690884+00:00\n'`, `'8593-06-30T13:42:25+05:44\n'` | ## Leap seconds - ERFA, through pyerfa 2.0.1.5, with its own leap second table -- Timestamps known valid by the table, from 1972 on, given to ERFA: 24191. Different SI seconds since 1972 or TAI - UTC: 0. Before 1972, where TAI - UTC was not a whole number of seconds, not compared: 3390. -- SI seconds turned back into timestamps, of which 27 leap seconds, and given to ERFA for their SI seconds: 8700. Different: 0. +- Timestamps known valid by the table, from 1972 on, given to ERFA: 22611. Different SI seconds since 1972 or TAI - UTC: 0. Before 1972, where TAI - UTC was not a whole number of seconds, not compared: 2949. +- SI seconds turned back into timestamps, of which 27 leap seconds, and given to ERFA for their SI seconds: 8666. Different: 0. diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index ff13d4d..103614d 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -6,16 +6,16 @@ Written by `tests/run_tests.py`. Rerun it to check these numbers. - Lean: Lean (version 4.35.0, x86_64-unknown-linux-gnu, commit eb6cafb7290e8bd5f2937522d8d804f58f02bafc, Release) - Seed: 20261025 - Inputs: 300000 -- Mutated: 67881 of the inputs are mutated timestamps -- By kind: compare 29415, error 35, fromsi 9361, fromunix 44544, grid 16998, known 34491, mutated 67881, near miss 29257, valid 68018 -- Strings parsed: 182154, of which 83524 accepted (4667 with a leap second) and 98630 rejected -- Accepted strings also read by `datetime.fromisoformat`, with the same fields, offset and seconds: 77335 -- Pairs ordered: 29415 (9875 before, 8323 same instant, 9754 after, 1463 with a string rejected) -- Unix seconds turned back into timestamps: 44544, of which 679 outside years 0000 to 9999 +- Mutated: 67878 of the inputs are mutated timestamps +- By kind: compare 29413, error 44, fromsi 9360, fromunix 44543, grid 16998, known 34497, mutated 67878, near miss 29254, valid 68013 +- Strings parsed: 182143, of which 83519 accepted (4666 with a leap second) and 98624 rejected +- Accepted strings also read by `datetime.fromisoformat`, with the same fields, offset and seconds: 77331 +- Pairs ordered: 29413 (9874 before, 8322 same instant, 9754 after, 1463 with a string rejected) +- Unix seconds turned back into timestamps: 44543, of which 679 outside years 0000 to 9999 - Leap second table: /usr/share/zoneinfo/leap-seconds.list, 28 lines, 27 leap seconds, expires 2027-06-28, last updated at NTP time 3992312697, hash line `a9bad145 84c31c70 758402aa b37bfd54 5923836a` -- Timestamps checked against the table (`known`): 34491, of which 267 leap seconds of the table, 6367 with a second 60 it does not list, and 2564 rejected -- SI seconds turned back into timestamps (`fromsi`): 9361, of which 27 leap seconds and 695 outside 1972 to 9999 -- Malformed requests answered `error`: 35 of 35 +- Timestamps checked against the table (`known`): 34497, of which 267 leap seconds of the table, 6367 with a second 60 it does not list, and 2570 rejected +- SI seconds turned back into timestamps (`fromsi`): 9360, of which 27 leap seconds and 695 outside 1972 to 9999 +- Malformed requests answered `error`: 44 of 44 - Requests ending in CR LF: every seventh, 42857 - Strings also given to `rfc3339 check` as arguments, in batches of 500: 20000, and once with no arguments - Last lines with no LF, sent alone, answered right: 2 of 2 diff --git a/tests/run_tests.py b/tests/run_tests.py index f8687ca..85e8362 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -556,6 +556,26 @@ def same_instant_pair(rng): ("fromsi +1", "a plus sign"), ("fromsi 1.5", "a fraction"), ("fromsi 1 2", "two numbers"), + ("fromsi 1_000", "an underscore"), + ("fromsi -", "a sign alone"), + ("fromsi --1", "two signs"), + ("fromsi 0x10", "hex"), + ("fromsi \u0663", "a digit that is not ASCII"), + ("known 3G", "not a hex digit"), + ("known 3A", "hex digits are lower case"), + ("known c0af", "an overlong encoding, not UTF-8"), + ("known eda080", "a surrogate, not UTF-8"), +] + +# Strings `known` must answer REJECT, not NOLEAP: second 60 away from a +# UTC month end, second 61, an offset out of range, and no timestamp. +KNOWN_REJECT = [ + "1990-12-30T23:59:60Z", + "1990-12-31T23:59:61Z", + "1990-12-31T23:59:60+24:00", + "2015-02-30T00:00:00Z", + "", + "garbage", ] # A last line with no LF must still be answered. @@ -573,7 +593,11 @@ def hexed(s): def cases(seed, count): """(label, request, expected answer, mutated) for each input.""" rng = random.Random(seed) - fixed = [] + # The short lists first, so that a run with a small count has them. + fixed = [("error", req, "error", False) for req, _ in MALFORMED] + for s in KNOWN_REJECT: + assert known(s) == "REJECT", s + fixed.append(("known", f"known {hexed(s)}", "REJECT", False)) for s in itertools.chain(day_grid(), leap_grid(), edge_grid()): fixed.append(("grid", f"parse {hexed(s)}", answer(reference(s)), False)) edges = list(edge_grid()) @@ -585,8 +609,6 @@ def cases(seed, count): fixed.append(("known", f"known {hexed(s)}", known(s), False)) for n in si_grid(): fixed.append(("fromsi", f"fromsi {n}", from_si(n), False)) - for req, _ in MALFORMED: - fixed.append(("error", req, "error", False)) made = 0 for item in fixed[:count]: yield item From 7294c517e44788533dd9b96ad6db0629468f9a01 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:11:33 +0000 Subject: [PATCH 09/11] Check the IXDTF vectors through parseExt_iff, and write the RFC 9557 docs nanoda ran out of stack evaluating parseSuffix on the long examples. The vectors that read a string now get their Ext from parseExt_iff, with the grammar's checks made by the code's own functions, so no kernel runs the whole parser. All six kernels accept. README, SPEC.md and bar.toml now cover parseExt, Ext.print, Ext.lookup and Ext.erroneous, and list Spec/Ixdtf.lean and Rfc3339/IxdtfTypes.lean as part of the specification. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WWUFjVe6uzWVQGpyU113ow --- Proofs/Ixdtf/Main.lean | 90 +++++++++++++++++++++++++++++++++++++++++- README.md | 31 +++++++++++---- SPEC.md | 46 +++++++++++++++++++-- bar.toml | 12 ++++-- 4 files changed, 162 insertions(+), 17 deletions(-) diff --git a/Proofs/Ixdtf/Main.lean b/Proofs/Ixdtf/Main.lean index 016c6d5..495469c 100644 --- a/Proofs/Ixdtf/Main.lean +++ b/Proofs/Ixdtf/Main.lean @@ -34,6 +34,44 @@ theorem Spec.erroneous_iff (p : Processor) (e : Ext) : e.erroneous p = true ↔ Spec.MustReject p e := Rfc3339.Proofs.Ixdtf.erroneous_iff p e +/-- The grammar's checks on an IXDTF timestamp, made by the code's own + functions, which the lemmas of `Proofs/Ixdtf/Suffix.lean` tie to the + spec. Used to check the long test vectors without running the whole + parser in the kernel, which one of the external kernels cannot do in + its stack. -/ +def vx_validB (e : Ext) : Bool := + e.stamp.isValid && + (match e.zone with + | some (_, .name parts) => !parts.isEmpty && parts.all zonePartOK + | some (_, .offset o) => o != .z && o.isValid + | none => true) && + e.tags.all (fun t => keyOK t.key && !t.values.isEmpty && t.values.all valueOK) + +theorem vx_validB_sound (e : Ext) (h : vx_validB e = true) : Spec.ExtValid e := by + obtain ⟨stamp, zone, tags⟩ := e + simp only [vx_validB, Bool.and_eq_true, List.all_eq_true] at h + obtain ⟨⟨hs, hz⟩, ht⟩ := h + refine ⟨(Rfc3339.Proofs.Valid.isValid_iff stamp).mp hs, ?_, ?_⟩ + · intro c z hcz + subst hcz + cases z with + | name parts => + simp only [Bool.and_eq_true, Bool.not_eq_true', List.isEmpty_eq_false_iff, List.all_eq_true] at hz + exact ⟨hz.1, fun p hp => (Rfc3339.Proofs.Ixdtf.sx_zonePartOK_iff p).mp (hz.2 p hp)⟩ + | offset o => + simp only [Bool.and_eq_true, bne_iff_ne, ne_eq] at hz + exact ⟨hz.1, (Rfc3339.Proofs.Valid.offset_isValid_iff o).mp hz.2⟩ + · intro t htm + have h3 := ht t htm + simp only [Bool.not_eq_true', List.isEmpty_eq_false_iff] at h3 + exact ⟨(Rfc3339.Proofs.Ixdtf.sx_keyOK_iff _).mp h3.1.1, h3.1.2, + fun v hv => (Rfc3339.Proofs.Ixdtf.sx_valueOK_iff v).mp (h3.2 v hv)⟩ + +theorem vx_parseExt (s : String) (e : Ext) (dt : List Char) (hv : vx_validB e = true) + (hs : s.toList = dt ++ Spec.renderSuffix e) (hd : dt.map Spec.asciiUpper = Spec.render e.stamp) : + parseExt s = some e := + (Rfc3339.Proofs.Ixdtf.parseExt_iff s e).2 ⟨vx_validB_sound e hv, dt, hs, hd⟩ + theorem Spec.ixdtf_vectors : (parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]" = some ⟨⟨1996, 12, 19, 16, 39, 57, [], .minus 8 0⟩, @@ -82,4 +120,54 @@ theorem Spec.ixdtf_vectors : parseExt "2022-07-08T00:14:07Z[u-ca=a--b]" = none ∧ parseExt "2022-07-08T00:14:07Z[u-ca=x][Europe/Paris]" = none ∧ parseExt "2022-07-08T00:14:07Z[+24:00]" = none := by - and_intros <;> decide +kernel + and_intros + · exact vx_parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]" ⟨⟨1996, 12, 19, 16, 39, 57, [], (.minus 8 0)⟩, some (false, .name ["America".toList, "Los_Angeles".toList]), [⟨false, "u-ca".toList, ["hebrew".toList]⟩]⟩ + "1996-12-19T16:39:57-08:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel) + · exact vx_parseExt "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]" ⟨⟨1996, 12, 19, 16, 39, 57, [], (.minus 8 0)⟩, none, [⟨false, "_foo".toList, ["bar".toList]⟩, ⟨false, "_baz".toList, ["bat".toList]⟩]⟩ + "1996-12-19T16:39:57-08:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel) + · rw [vx_parseExt "2022-07-08T00:14:07+01:00[Europe/Paris]" ⟨⟨2022, 7, 8, 0, 14, 7, [], (.plus 1 0)⟩, some (false, .name ["Europe".toList, "Paris".toList]), []⟩ + "2022-07-08T00:14:07+01:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · rw [vx_parseExt "2022-07-08T00:14:07+01:00[knort=blargel]" ⟨⟨2022, 7, 8, 0, 14, 7, [], (.plus 1 0)⟩, none, [⟨false, "knort".toList, ["blargel".toList]⟩]⟩ + "2022-07-08T00:14:07+01:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · rw [vx_parseExt "2022-07-08T00:14:07Z[u-ca=chinese][u-ca=japanese]" ⟨⟨2022, 7, 8, 0, 14, 7, [], .z⟩, none, [⟨false, "u-ca".toList, ["chinese".toList]⟩, ⟨false, "u-ca".toList, ["japanese".toList]⟩]⟩ + "2022-07-08T00:14:07Z".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · rw [vx_parseExt "2022-07-08T00:14:07Z[u-ca=chinese][u-ca=japanese]" ⟨⟨2022, 7, 8, 0, 14, 7, [], .z⟩, none, [⟨false, "u-ca".toList, ["chinese".toList]⟩, ⟨false, "u-ca".toList, ["japanese".toList]⟩]⟩ + "2022-07-08T00:14:07Z".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · rw [vx_parseExt "2022-07-08T00:14:07+01:00[!Europe/Paris]" ⟨⟨2022, 7, 8, 0, 14, 7, [], (.plus 1 0)⟩, some (true, .name ["Europe".toList, "Paris".toList]), []⟩ + "2022-07-08T00:14:07+01:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · rw [vx_parseExt "2022-07-08T00:14:07Z[!u-ca=chinese][u-ca=japanese]" ⟨⟨2022, 7, 8, 0, 14, 7, [], .z⟩, none, [⟨true, "u-ca".toList, ["chinese".toList]⟩, ⟨false, "u-ca".toList, ["japanese".toList]⟩]⟩ + "2022-07-08T00:14:07Z".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · rw [vx_parseExt "2022-07-08T00:14:07Z[u-ca=chinese][!u-ca=japanese]" ⟨⟨2022, 7, 8, 0, 14, 7, [], .z⟩, none, [⟨false, "u-ca".toList, ["chinese".toList]⟩, ⟨true, "u-ca".toList, ["japanese".toList]⟩]⟩ + "2022-07-08T00:14:07Z".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · rw [vx_parseExt "2022-07-08T00:14:07Z[!knort=blargel]" ⟨⟨2022, 7, 8, 0, 14, 7, [], .z⟩, none, [⟨true, "knort".toList, ["blargel".toList]⟩]⟩ + "2022-07-08T00:14:07Z".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · rw [vx_parseExt "2022-07-08T00:14:07+00:00[!Europe/London]" ⟨⟨2022, 7, 8, 0, 14, 7, [], (.plus 0 0)⟩, some (true, .name ["Europe".toList, "London".toList]), []⟩ + "2022-07-08T00:14:07+00:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · rw [vx_parseExt "2022-07-08T00:14:07+00:00[Europe/London]" ⟨⟨2022, 7, 8, 0, 14, 7, [], (.plus 0 0)⟩, some (false, .name ["Europe".toList, "London".toList]), []⟩ + "2022-07-08T00:14:07+00:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · rw [vx_parseExt "2022-07-08T00:14:07Z[!Europe/London]" ⟨⟨2022, 7, 8, 0, 14, 7, [], .z⟩, some (true, .name ["Europe".toList, "London".toList]), []⟩ + "2022-07-08T00:14:07Z".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · rw [vx_parseExt "2022-07-08T00:14:07Z[Europe/London]" ⟨⟨2022, 7, 8, 0, 14, 7, [], .z⟩, some (false, .name ["Europe".toList, "London".toList]), []⟩ + "2022-07-08T00:14:07Z".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · rw [vx_parseExt "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]" ⟨⟨1996, 12, 19, 16, 39, 57, [], (.minus 8 0)⟩, none, [⟨false, "_foo".toList, ["bar".toList]⟩, ⟨false, "_baz".toList, ["bat".toList]⟩]⟩ + "1996-12-19T16:39:57-08:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · decide +kernel + · decide +kernel + · decide +kernel + · decide +kernel + · decide +kernel + · decide +kernel + · decide +kernel diff --git a/README.md b/README.md index 484b59a..e9954f5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # rfc: RFC 3339 timestamps, proved -This library reads, checks and writes RFC 3339 timestamps, such as `1985-04-12T23:20:50.52Z`, in Lean. Machine-checked proofs show that it accepts exactly the strings that the RFC's grammar and calendar rules allow, including the day ranges and leap years of the calendar. `isValid` has one relaxation: it accepts a leap second at the end of any month. `isValidKnown` is exact: it checks second 60 against the table of the 27 leap seconds there have been, as tzdata publishes it. The proofs also cover the round trip from text to value and back, the count of seconds since the Unix epoch, and the order of two timestamps by the instant they name. A count of SI seconds since 1972 gives each leap second its own number, so a round trip through it keeps a leap second, as one through Unix seconds cannot. TAI − UTC is also given, as 10 plus the leap seconds before the timestamp, and proved to agree with the table's own TAI − UTC column at every second from 1972 on but a leap second. The library is tested against Python's date and time code; `tests/TEST-RESULTS.md` has the record. +This library reads, checks and writes RFC 3339 timestamps, such as `1985-04-12T23:20:50.52Z`, in Lean. Machine-checked proofs show that it accepts exactly the strings that the RFC's grammar and calendar rules allow, including the day ranges and leap years of the calendar. `isValid` has one relaxation: it accepts a leap second at the end of any month. `isValidKnown` is exact: it checks second 60 against the table of the 27 leap seconds there have been, as tzdata publishes it. The proofs also cover the round trip from text to value and back, the count of seconds since the Unix epoch, and the order of two timestamps by the instant they name. A count of SI seconds since 1972 gives each leap second its own number, so a round trip through it keeps a leap second, as one through Unix seconds cannot. TAI − UTC is also given, as 10 plus the leap seconds before the timestamp, and proved to agree with the table's own TAI − UTC column at every second from 1972 on but a leap second. It also reads and writes the extended form of RFC 9557, which adds a time zone and tagged values after the timestamp, as in `1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]`. The proofs show that it accepts exactly that grammar, and that it decides which strings an application must treat as erroneous by sections 3.2 to 3.4, given what the application can process. The library is tested against Python's date and time code and a Python copy of the RFC 9557 grammar; `tests/TEST-RESULTS.md` has the record. It is a project of [Proven Commons](https://github.com/Proven-Commons/mission), built to the verified bar published there. @@ -25,12 +25,16 @@ For each timestamp it prints the timestamp as written, with `T` and `Z` in upper - `Timestamp.siSeconds` - `Timestamp.taiMinusUtc` - `Rfc3339.fromSiSeconds` +- `Rfc3339.parseExt` +- `Ext.print` +- `Ext.lookup` +- `Ext.erroneous` `SPEC.md` says what each one does. ## What is proved -The statements are in `Spec.lean`. The definitions they use, written from the RFC, are in `Spec/Defs.lean`; those for the leap second table, with the table itself, are in `Spec/Leap.lean`; and the types are in `Rfc3339/Types.lean`. The proofs are in `Proofs/`. +The statements are in `Spec.lean`. The definitions they use, written from the RFC, are in `Spec/Defs.lean`; those for the leap second table, with the table itself, are in `Spec/Leap.lean`; those for RFC 9557, with its grammar, are in `Spec/Ixdtf.lean`; and the types are in `Rfc3339/Types.lean` and `Rfc3339/IxdtfTypes.lean`. The proofs are in `Proofs/`. | Claim | Theorem | |-------|---------| @@ -55,21 +59,28 @@ The statements are in `Spec.lean`. The definitions they use, written from the RF | `taiMinusUtc` is 10, and one more for each leap second of the table before the timestamp, so during a leap second it is still the value from before it. | `Spec.taiMinusUtc_leapsBefore` | | TAI − UTC at each second from 1972 on, other than a leap second, is the value in the table's second column, 10 to 37. The code never reads that column; it counts leap seconds. | `Spec.taiMinusUtc_eq` | | Section 5.8's leap seconds, the table's first and last, one seen from another offset, and second 60 in months that had none. SI seconds and TAI − UTC around the first and the last leap second. And the round trip that Unix seconds lose and SI seconds keep. | `Spec.leap_table_vectors` | +| `parseExt` reads exactly the strings of RFC 9557's `date-time-ext` rule: an RFC 3339 timestamp, as `parse` reads it, then a time zone, a name or a numeric offset, if there is one, and the tags, each perhaps marked critical with `!`. It returns the timestamp, the zone and the tags as written. | `Spec.parseExt_iff` | +| A valid IXDTF timestamp prints as the grammar writes it, and printing then parsing gives it back. | `Spec.printExt_eq`, `Spec.parseExt_print` | +| A string with no suffix reads as `parse` reads it, with no time zone and no tags. | `Spec.parseExt_plain` | +| `lookup` gives the first tag with a key, which section 3.3 has an application choose when an elective key is repeated. | `Spec.lookup_iff` | +| `erroneous` decides what sections 3.2 to 3.4 make an application treat as erroneous, for an application described by the tags it processes and the zone offsets it knows: a critical time zone it does not know or that disagrees with the offset, a critical tag it cannot process or whose key is repeated with other values, and an experimental key, one starting with `_`, it does not take part in. | `Spec.erroneous_iff` | +| The examples of sections 3.3, 3.4 and 4.2, read and judged by an example application, and strings the grammar refuses: an empty item, `.` and `..` as zone parts, an upper case key, an empty value, a time zone after a tag, and an offset out of range. | `Spec.ixdtf_vectors` | The command-line tool in `Main.lean` calls these functions directly. ## What is not proved -- Reading requests and printing answers in `Main.lean`: the hex decoding and UTF-8 check in `serve` mode, the arguments, output and exit codes of `check` mode, the decimal printing of numbers and reading of the numbers `fromunix` and `fromsi` take, and the layout of the `known` answer, with `-` for TAI − UTC before 1972. The differential tests cover them, with malformed requests and CR LF line endings. +- Reading requests and printing answers in `Main.lean`: the hex decoding and UTF-8 check in `serve` mode, the arguments, output and exit codes of `check` mode, the decimal printing of numbers and reading of the numbers `fromunix` and `fromsi` take, the layout of the `known` answer, with `-` for TAI − UTC before 1972, and the layout of the `ixdtf` answer, which judges each string with the example application of `Spec.exampleProcessor`. The differential tests cover them, with malformed requests and CR LF line endings. - Whether a leap second was announced, in `isValid` and `parse`. They accept second 60 at 23:59 UTC at the end of any month, where section 5.7 allows it only at the end of months in which a leap second occurs; `isValidKnown` checks that against the table. Second 59 is always accepted, although section 5.7 makes 58 the last second of a month after a negative leap second, which has never happened. - The table after its expiry date, 28 June 2027. `isValidKnown` refuses second 60 at any month end the table does not list, and `siSeconds` and `taiMinusUtc` count no leap second after 2016. If one is ever announced, the table in the spec and the code's list must change together, and the spec approved again. The table is what tzdata publishes from IERS Bulletin C; that it matches the IERS record is trusted, not proved. - SI seconds and TAI − UTC before 1972. UTC then ran on seconds of varying length, with TAI − UTC not a whole number of seconds. Before 1972, `Spec.siSeconds_eq` makes `siSeconds` Unix seconds less 63072000, 86400 to a day, which are not SI seconds there, and `taiMinusUtc` gives 10, which means nothing there; the tool's `known` request prints `-` for TAI − UTC. - The table itself. `Spec/Leap.lean` copies tzdata's `leap-seconds.list` (tzdata 2026e, updated through IERS Bulletin C, expiring 28 June 2027) line for line. That copy is part of the specification: the reviewer checks it against the file, and the differential tests check the code against the copy of the file on the test machine, but no proof does. - Unix seconds at a leap second. They count second 60 as the first second of the next minute, as POSIX time does, so a leap second and the second after it give the same number. `compareInstant` does not use them, and puts the leap second first. `fromUnixSeconds` turns that number into the second after the leap second, so a round trip through Unix seconds loses a leap second, and `9999-12-31T23:59:60Z` gives `none`. -- Forms outside RFC 3339 are not supported, by design: +- Time zone rules. `parseExt` reads a time zone name by the grammar, but this library holds no time zone database: it does not know whether `Europe/Paris` exists or what its offset is. `erroneous` asks the application, through its `Processor`, for the offset a named zone has at an instant, and which tags it can process. What the application does with a tag, such as a calendar, is its own. +- RFC 9557's `time-zone` rule read literally. In ABNF, alternation binds more loosely than concatenation, so the rule as written reads `("[" critical-flag time-zone-name) / (time-numoffset "]")`. `Spec/Ixdtf.lean` follows the intent, which the RFC's note and every example show: a name or an offset between the brackets. +- Forms outside RFC 3339 and RFC 9557 are not supported, by design: - a space in place of `T`, which section 5.6 lets applications choose; - - other ISO 8601 forms; - - named time zones; + - other ISO 8601 forms, such as an offset with no colon in a time zone suffix; - years outside 0000 to 9999. - Speed. The code works on lists of characters, which suits proofs, not bulk data. @@ -96,6 +107,8 @@ This project has no dependencies beyond Lean itself. - Go accepts a comma before the fraction. - `rfc3339-validator` accepts a timestamp followed by a line break. +For RFC 9557, it compares `parseExt` with `Temporal.Instant.from` in Node's built-in Temporal, which is behind a flag and older than the current proposal. Temporal refuses what it does not act on: critical items, experimental and unknown keys, calendars it does not know, a repeated `u-ca`, and fractions finer than nanoseconds. Of the strings this project reads, it also refuses one-part time zone names and names that are not in its time zone database. It accepts three kinds of string that this project refuses as RFC 9557 does: a `[` inside an item, a zone offset with no colon, and a stray `]`. + For leap seconds, it compares `siSeconds`, `taiMinusUtc` and `fromSiSeconds` with ERFA, a BSD-licensed C library derived from the IAU's SOFA, through pyerfa, which keeps its own leap second table. `tests/CROSS-CHECK.md` gives the counts. ## Re-check it @@ -115,10 +128,12 @@ The test script compares the tool with Python's `datetime` and `calendar` module ## Layout - `Rfc3339/Types.lean`: the timestamp and offset types. It is part of the specification. -- `Rfc3339/Calendar.lean`, `Rfc3339/Format.lean`, `Rfc3339/Instant.lean`, `Rfc3339/FromUnix.lean`, `Rfc3339/Leap.lean`: the code under proof. +- `Rfc3339/IxdtfTypes.lean`: the RFC 9557 types, the time zone, the tags, and what an application can process. It is part of the specification. +- `Rfc3339/Calendar.lean`, `Rfc3339/Format.lean`, `Rfc3339/Instant.lean`, `Rfc3339/FromUnix.lean`, `Rfc3339/Leap.lean`, `Rfc3339/Ixdtf.lean`: the code under proof. - `Spec.lean`: the statements, with `sorry` for proofs. A reviewer approves this file. - `Spec/Defs.lean`: the definitions the statements use, written from the RFC, with checks against its tables and known day counts. - `Spec/Leap.lean`: the leap second table, line for line from tzdata's `leap-seconds.list`, and the definitions the leap second statements use. +- `Spec/Ixdtf.lean`: RFC 9557's grammar, the rules for what an application must reject, and the example application. - `Proofs.lean` and `Proofs/`: the proofs, under the same names. - `Main.lean`: the command-line tool. - `lakefile.toml`: the build. It names the package and its version, its libraries and executable, and the default targets, and sets nothing else. @@ -126,4 +141,4 @@ The test script compares the tool with Python's `datetime` and `calendar` module - `bar.toml`: the record for the bar check: status, authors, approvals, and where things are. - `SPEC.md`: the specification in plain words. - `tests/run_tests.py`: the differential tests. -- `tests/cross_check.py` and `tests/cross_check/main.go`: the one-off comparison with other parsers, and with ERFA for leap seconds. +- `tests/cross_check.py`, `tests/cross_check/main.go` and `tests/cross_check/ixdtf.js`: the one-off comparison with other parsers, with ERFA for leap seconds, and with Temporal for RFC 9557. diff --git a/SPEC.md b/SPEC.md index 08736c3..24f844e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Specification: rfc -This library reads and writes RFC 3339 timestamps, such as `1985-04-12T23:20:50.52Z`. It also gives the instant a timestamp names, as whole seconds since 1970-01-01T00:00:00Z, and puts two timestamps in order by instant. A reviewer who did not write this specification approves it. The approval also covers `Spec.lean`, `Spec/Defs.lean`, `Rfc3339/Types.lean`, and the other files `bar.toml` lists. +This library reads and writes RFC 3339 timestamps, such as `1985-04-12T23:20:50.52Z`. It also gives the instant a timestamp names, as whole seconds since 1970-01-01T00:00:00Z, and puts two timestamps in order by instant. It reads and writes the extended form of RFC 9557, with a time zone and tags after the timestamp. A reviewer who did not write this specification approves it. The approval also covers `Spec.lean`, `Spec/Defs.lean`, `Rfc3339/Types.lean`, `Spec/Leap.lean`, `Spec/Ixdtf.lean`, `Rfc3339/IxdtfTypes.lean`, and the other files `bar.toml` lists. ## The format @@ -61,9 +61,38 @@ Section 5.7 allows second 60 only "at the end of months in which a leap second o Before 1972, UTC ran on seconds of varying length, with TAI − UTC not a whole number of seconds. There `siSeconds` is Unix seconds less 63072000, 86400 to a day, as `Spec.siSeconds_eq` says, which are not SI seconds, and `taiMinusUtc` gives 10, which means nothing. A leap second announced after the table's date would need a new table, in the spec and in the code, and a new approval. +## RFC 9557: time zones and tags + +RFC 9557, the Internet Extended Date/Time Format (IXDTF), updates RFC 3339. After the timestamp may come a suffix: a time zone in brackets, then tags in brackets. Each may be marked critical with `!`. An example is `1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]`. `Spec/Ixdtf.lean` quotes the grammar of section 4.1. + +- The time zone is a name or a numeric offset: + - A name is one or more parts joined by `/`. Each part starts with a letter, `.` or `_`, and goes on with letters, digits, `.`, `_`, `-` and `+`. It may not be `.` or `..`. + - An offset is RFC 3339's `+hh:mm` or `-hh:mm`, in range. It may not be `Z`, which in brackets reads as a one-letter zone name. +- A tag is a key, `=`, and one or more values joined by `-`: + - The key starts with a lower case letter or `_`, and goes on with lower case letters, digits, `_` and `-`. + - A value is one or more ASCII letters or digits. +- The time zone, when there is one, comes before every tag. +- In ABNF, alternation binds more loosely than concatenation, so the `time-zone` rule as written reads `("[" critical-flag time-zone-name) / (time-numoffset "]")`. The spec follows the intent, which section 4.1's note and every example show: a name or an offset between the brackets. + +`parseExt` reads such a string. It returns an `Ext`: +- the timestamp, as `parse` reads it; +- the time zone, if there is one, with whether it is critical, its name as the parts between the slashes, or its offset; +- the tags in order, each with whether it is critical, its key, and its values as the parts between the hyphens. + +It reads exactly the strings of the grammar, and a string with no suffix exactly as `parse` reads it. `Ext.print` writes an `Ext` back, with `T` and `Z` in upper case. + +`Ext.lookup` gives the first tag with a key. Section 3.3 has an application that does not handle a repeated elective key choose the first. + +`Ext.erroneous` says whether an application must treat the string as erroneous. That depends on the application, so it takes a `Processor`: which tags the application can process, and the offset a named time zone has at an instant, or `none` for a zone it does not know. The string is erroneous when any of these holds: +- **Section 3.4:** the time zone is critical and does not agree with the timestamp. A named zone the application does not know never agrees, as section 4.1 asks. Otherwise the zone's offset at that instant must be the timestamp's offset. `Z` and `-00:00` assert no local offset, so a numeric offset or a zone the application knows agrees with them, as Figure 2 shows for `2022-07-08T00:14:07Z[!Europe/London]`. +- **Section 3.3:** a critical tag the application cannot process, or a critical tag whose key another tag uses with other values. +- **Section 3.2:** a key that starts with `_`, for an experiment, that the application does not process. + +Everything else is elective: an application may ignore a tag it does not know, or a time zone that disagrees with the offset when the zone is not critical. This library holds no time zone database, and does not judge what a tag such as a calendar means. Those are the application's, through its `Processor`. + ## Errors -`parse` returns `none` for any string it does not accept, `fromUnixSeconds` for any number outside the years 0000 to 9999, and `fromSiSeconds` for any number outside 1972 to 9999. No function can fail any other way. +`parse` and `parseExt` return `none` for any string they do not accept, `fromUnixSeconds` for any number outside the years 0000 to 9999, and `fromSiSeconds` for any number outside 1972 to 9999. No function can fail any other way. ## The command-line tool @@ -72,11 +101,11 @@ The command-line tool in `Main.lean` has two modes: - `rfc3339 check TIMESTAMP...` prints each timestamp's printed form and its Unix seconds, or `invalid`. It exits with 1 when any timestamp is invalid. - `rfc3339 serve` answers requests from the differential tests, one per line. Each string is sent as the hex digits of its UTF-8 bytes. The header comment of `Main.lean` gives the protocol. -Every answer comes from `parse`, `Timestamp.print`, `Timestamp.unixSeconds`, `Timestamp.compareInstant`, `fromUnixSeconds`, `Timestamp.isValidKnown`, `Timestamp.siSeconds`, `Timestamp.taiMinusUtc` and `fromSiSeconds`. The `known` request prints `-` for TAI − UTC before 1972. +Every answer comes from `parse`, `Timestamp.print`, `Timestamp.unixSeconds`, `Timestamp.compareInstant`, `fromUnixSeconds`, `Timestamp.isValidKnown`, `Timestamp.siSeconds`, `Timestamp.taiMinusUtc`, `fromSiSeconds`, `parseExt`, `Ext.print`, `Ext.lookup` and `Ext.erroneous`. The `known` request prints `-` for TAI − UTC before 1972. The `ixdtf` request judges each string with the example application of `Spec.exampleProcessor`, and gives the values of its first `u-ca` tag. ## What must always hold -Each property is a theorem in `Spec.lean`, under the name given. The definitions the statements use are in `Spec/Defs.lean` and, for the leap second table, `Spec/Leap.lean`. They are written from the RFC and checked against its tables and against known day counts. +Each property is a theorem in `Spec.lean`, under the name given. The definitions the statements use are in `Spec/Defs.lean`, for the leap second table in `Spec/Leap.lean`, and for RFC 9557 in `Spec/Ixdtf.lean`. They are written from the RFC and checked against its tables and against known day counts. | Property | Theorem | |----------|---------| @@ -104,12 +133,20 @@ Each property is a theorem in `Spec.lean`, under the name given. The definitions | `taiMinusUtc` is 10 and one more for each leap second of the table before the timestamp. | `Spec.taiMinusUtc_leapsBefore` | | TAI − UTC from 1972 on, at each second but a leap second, is the table's value. | `Spec.taiMinusUtc_eq` | | Leap seconds of the table and months without one; SI seconds and TAI − UTC around the first and the last; the round trip Unix seconds lose. | `Spec.leap_table_vectors` | +| `parseExt s` is `e` exactly when `e` is valid by the RFC 9557 grammar and `s` is a timestamp `parse` reads as `e`'s, then `e`'s suffix as the grammar writes it. | `Spec.parseExt_iff` | +| A valid `Ext` prints as the grammar writes it. | `Spec.printExt_eq` | +| Printing then parsing gives a valid `Ext` back. | `Spec.parseExt_print` | +| A string with no `[` reads as `parse` reads it, with no time zone and no tags. | `Spec.parseExt_plain` | +| `lookup` gives the first tag with the key. | `Spec.lookup_iff` | +| `erroneous` decides `Spec.MustReject`: the rules of sections 3.2 to 3.4, for any application. | `Spec.erroneous_iff` | +| The examples of sections 3.3, 3.4 and 4.2 are read, and are erroneous for the example application exactly where the RFC says; strings the grammar refuses are not read. | `Spec.ixdtf_vectors` | Together, these fix every output for valid input. - `parse` is fixed for every string. - `print`, `unixSeconds` and `compareInstant` are fixed for every valid timestamp. The parser gives only valid timestamps. - `fromUnixSeconds` is fixed for every number, by `Spec.fromUnixSeconds_iff` and `Spec.fromUnixSeconds_isSome`, and `fromSiSeconds` likewise by its two statements. +- `parseExt` is fixed for every string, `Ext.print` for every valid `Ext`, `Ext.lookup` for every `Ext` and key, and `Ext.erroneous` for every application and `Ext`. - `isValidKnown` is fixed for every timestamp, and `siSeconds` for every valid one. `taiMinusUtc` is fixed for every valid timestamp, by `Spec.taiMinusUtc_leapsBefore`, and agrees with the table's column from 1972 on but at a leap second. - A parser that accepted nothing would meet `Spec.parse_valid`, but it would fail `Spec.parse_iff` and `Spec.parse_print`. - A parser that accepted too much would fail `Spec.parse_iff`. @@ -121,3 +158,4 @@ The differential tests compare the tool with two oracles: - Python's `datetime` and `calendar` modules: month lengths, offset arithmetic, seconds since the epoch, `datetime.fromisoformat` on every accepted string it can hold, and the UTC timestamp for a number of seconds. `datetime` holds no year 0, so dates there are moved by 400 years, one cycle of the Gregorian calendar, for the arithmetic. - A copy of the rules above, written in Python from the RFC. - For leap seconds, tzdata's `leap-seconds.list` on the test machine, read by the test script itself, with the SI seconds and TAI − UTC worked out from it in Python. +- For RFC 9557, the grammar of section 4.1 and the rules of sections 3.2 to 3.4, written in Python from the RFC, with the same example application. diff --git a/bar.toml b/bar.toml index 7baee02..744cf5d 100644 --- a/bar.toml +++ b/bar.toml @@ -13,15 +13,19 @@ spec_authors = ["claude"] entry_points = ["Main.lean"] [spec] -# Rfc3339/Types.lean holds the types every statement is about, so it is -# part of the specification. -files = ["SPEC.md", "Spec.lean", "Spec/Defs.lean", "Spec/Leap.lean", "Rfc3339/Types.lean"] +# Rfc3339/Types.lean and Rfc3339/IxdtfTypes.lean hold the types the +# statements are about, so they are part of the specification. +files = [ + "SPEC.md", "Spec.lean", "Spec/Defs.lean", "Spec/Leap.lean", "Spec/Ixdtf.lean", + "Rfc3339/Types.lean", "Rfc3339/IxdtfTypes.lean", +] # The code under proof: the functions the statements are about. code = [ "Rfc3339.parse", "Rfc3339.Timestamp.print", "Rfc3339.Timestamp.isValid", "Rfc3339.Timestamp.unixSeconds", "Rfc3339.Timestamp.compareInstant", "Rfc3339.fromUnixSeconds", "Rfc3339.Timestamp.isValidKnown", "Rfc3339.Timestamp.siSeconds", - "Rfc3339.Timestamp.taiMinusUtc", "Rfc3339.fromSiSeconds", + "Rfc3339.Timestamp.taiMinusUtc", "Rfc3339.fromSiSeconds", "Rfc3339.parseExt", + "Rfc3339.Ext.print", "Rfc3339.Ext.lookup", "Rfc3339.Ext.erroneous", ] # One entry per approval, added in a pull request the reviewer approves. From c545a9d4a55d278f1353fd5778b9469044d51097 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:51:15 +0000 Subject: [PATCH 10/11] Fix what the IXDTF pre-review found: every RFC example as a vector, test gaps, wording Spec.ixdtf_vectors now has every example of RFC 9557, sections 1.2, 3.3, 3.4 and 4.2, critical zones that agree with the offset, a critical -00:00 zone that does not, and [Z] read as a zone name. The page says parseExt keeps parse's relaxation on second 60. The tests add -00:00 zones, a value list sharing a first part, and two critical tags with one key. The Temporal categories follow what that Temporal refuses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WWUFjVe6uzWVQGpyU113ow --- Proofs/Ixdtf/Main.lean | 98 ++++++++++++++++++++++++++++++++++++------ README.md | 8 ++-- SPEC.md | 6 +-- Spec.lean | 64 ++++++++++++++++++++------- tests/cross_check.py | 13 +++--- tests/run_tests.py | 32 ++++++++++---- 6 files changed, 172 insertions(+), 49 deletions(-) diff --git a/Proofs/Ixdtf/Main.lean b/Proofs/Ixdtf/Main.lean index 495469c..622cff6 100644 --- a/Proofs/Ixdtf/Main.lean +++ b/Proofs/Ixdtf/Main.lean @@ -72,24 +72,54 @@ theorem vx_parseExt (s : String) (e : Ext) (dt : List Char) (hv : vx_validB e = parseExt s = some e := (Rfc3339.Proofs.Ixdtf.parseExt_iff s e).2 ⟨vx_validB_sound e hv, dt, hs, hd⟩ +/-- Every example of RFC 9557, in sections 1.2, 3.3, 3.4 and 4.2, with + the application of `Spec.exampleProcessor`: each one is read, and is + erroneous exactly where the RFC says it must be treated so. Also + critical time zones that agree with the offset, a critical `-00:00` + zone that does not, and strings the grammar refuses. -/ theorem Spec.ixdtf_vectors : - (parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]" = + -- Section 4.2, Figures 4 to 7 + parseExt "1996-12-19T16:39:57-08:00" = + some ⟨⟨1996, 12, 19, 16, 39, 57, [], .minus 8 0⟩, none, []⟩ ∧ + parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles]" = + some ⟨⟨1996, 12, 19, 16, 39, 57, [], .minus 8 0⟩, + some (false, .name ["America".toList, "Los_Angeles".toList]), []⟩ ∧ + parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]" = some ⟨⟨1996, 12, 19, 16, 39, 57, [], .minus 8 0⟩, some (false, .name ["America".toList, "Los_Angeles".toList]), - [⟨false, "u-ca".toList, ["hebrew".toList]⟩]⟩) ∧ - (parseExt "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]" = + [⟨false, "u-ca".toList, ["hebrew".toList]⟩]⟩ ∧ + parseExt "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]" = some ⟨⟨1996, 12, 19, 16, 39, 57, [], .minus 8 0⟩, none, - [⟨false, "_foo".toList, ["bar".toList]⟩, ⟨false, "_baz".toList, ["bat".toList]⟩]⟩) ∧ - -- Elective: an inconsistent zone, an unknown key, repeated keys + [⟨false, "_foo".toList, ["bar".toList]⟩, ⟨false, "_baz".toList, ["bat".toList]⟩]⟩ ∧ + ((parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles]").map + (·.erroneous Spec.exampleProcessor) = some false) ∧ + ((parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]").map + (·.erroneous Spec.exampleProcessor) = some false) ∧ + -- An experiment the application does not take part in + ((parseExt "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]").map + (·.erroneous Spec.exampleProcessor) = some true) ∧ + -- Section 1.2: an offset time zone + parseExt "2022-07-08T00:14:07+08:45[+08:45]" = + some ⟨⟨2022, 7, 8, 0, 14, 7, [], .plus 8 45⟩, some (false, .offset (.plus 8 45)), []⟩ ∧ + ((parseExt "2022-07-08T00:14:07+08:45[!+08:45]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ + -- Section 3.3, elective: an inconsistent zone, the zones of the note, + -- an unknown key, repeated keys ((parseExt "2022-07-08T00:14:07+01:00[Europe/Paris]").map (·.erroneous Spec.exampleProcessor) = some false) ∧ + ((parseExt "2022-07-08T00:14:07Z[Europe/Paris]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ + ((parseExt "2022-07-08T02:14:07+02:00[Europe/Paris]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ ((parseExt "2022-07-08T00:14:07+01:00[knort=blargel]").map (·.erroneous Spec.exampleProcessor) = some false) ∧ ((parseExt "2022-07-08T00:14:07Z[u-ca=chinese][u-ca=japanese]").map (·.erroneous Spec.exampleProcessor) = some false) ∧ ((parseExt "2022-07-08T00:14:07Z[u-ca=chinese][u-ca=japanese]").bind (·.lookup "u-ca".toList) = some ⟨false, "u-ca".toList, ["chinese".toList]⟩) ∧ - -- Critical: the four of section 3.3 + ((parseExt "2022-07-08T00:14:07Z[u-ca=chinese]").bind (·.lookup "u-ca".toList) = + some ⟨false, "u-ca".toList, ["chinese".toList]⟩) ∧ + -- Section 3.3, critical: the four examples ((parseExt "2022-07-08T00:14:07+01:00[!Europe/Paris]").map (·.erroneous Spec.exampleProcessor) = some true) ∧ ((parseExt "2022-07-08T00:14:07Z[!u-ca=chinese][u-ca=japanese]").map @@ -98,7 +128,7 @@ theorem Spec.ixdtf_vectors : (·.erroneous Spec.exampleProcessor) = some true) ∧ ((parseExt "2022-07-08T00:14:07Z[!knort=blargel]").map (·.erroneous Spec.exampleProcessor) = some true) ∧ - -- Figures 1 and 2 of section 3.4 + -- Section 3.4, Figures 1 and 2 ((parseExt "2022-07-08T00:14:07+00:00[!Europe/London]").map (·.erroneous Spec.exampleProcessor) = some true) ∧ ((parseExt "2022-07-08T00:14:07+00:00[Europe/London]").map (·.erroneous Spec.exampleProcessor) = @@ -107,12 +137,18 @@ theorem Spec.ixdtf_vectors : some false) ∧ ((parseExt "2022-07-08T00:14:07Z[Europe/London]").map (·.erroneous Spec.exampleProcessor) = some false) ∧ - -- An experiment the application does not take part in - ((parseExt "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]").map - (·.erroneous Spec.exampleProcessor) = some true) ∧ + -- Critical zones that agree with the offset, and a critical `-00:00` + -- zone, offset 0, under an offset of +01:00 + ((parseExt "1996-12-19T16:39:57-08:00[!America/Los_Angeles]").map + (·.erroneous Spec.exampleProcessor) = some false) ∧ + ((parseExt "2022-07-08T02:14:07+02:00[!Europe/Paris]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ + ((parseExt "2022-07-08T01:14:07+01:00[!-00:00]").map (·.erroneous Spec.exampleProcessor) = + some true) ∧ + -- `Z` in brackets is a zone name, not an offset + ((parseExt "2022-07-08T00:14:07Z[Z]").map (·.zone) = some (some (false, .name ["Z".toList]))) ∧ -- Refused by the grammar: an empty item, `.` and `..`, an upper case - -- key, an empty value, a time zone after a tag, `Z` as an offset - -- (it reads as a zone name), an offset out of range + -- key, an empty value, a time zone after a tag, an offset out of range parseExt "2022-07-08T00:14:07Z[]" = none ∧ parseExt "2022-07-08T00:14:07Z[.]" = none ∧ parseExt "2022-07-08T00:14:07Z[a/..]" = none ∧ @@ -121,13 +157,37 @@ theorem Spec.ixdtf_vectors : parseExt "2022-07-08T00:14:07Z[u-ca=x][Europe/Paris]" = none ∧ parseExt "2022-07-08T00:14:07Z[+24:00]" = none := by and_intros + · exact vx_parseExt "1996-12-19T16:39:57-08:00" ⟨⟨1996, 12, 19, 16, 39, 57, [], (.minus 8 0)⟩, none, []⟩ + "1996-12-19T16:39:57-08:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel) + · exact vx_parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles]" ⟨⟨1996, 12, 19, 16, 39, 57, [], (.minus 8 0)⟩, some (false, .name ["America".toList, "Los_Angeles".toList]), []⟩ + "1996-12-19T16:39:57-08:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel) · exact vx_parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]" ⟨⟨1996, 12, 19, 16, 39, 57, [], (.minus 8 0)⟩, some (false, .name ["America".toList, "Los_Angeles".toList]), [⟨false, "u-ca".toList, ["hebrew".toList]⟩]⟩ "1996-12-19T16:39:57-08:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel) · exact vx_parseExt "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]" ⟨⟨1996, 12, 19, 16, 39, 57, [], (.minus 8 0)⟩, none, [⟨false, "_foo".toList, ["bar".toList]⟩, ⟨false, "_baz".toList, ["bat".toList]⟩]⟩ "1996-12-19T16:39:57-08:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel) + · rw [vx_parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles]" ⟨⟨1996, 12, 19, 16, 39, 57, [], (.minus 8 0)⟩, some (false, .name ["America".toList, "Los_Angeles".toList]), []⟩ + "1996-12-19T16:39:57-08:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · rw [vx_parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]" ⟨⟨1996, 12, 19, 16, 39, 57, [], (.minus 8 0)⟩, some (false, .name ["America".toList, "Los_Angeles".toList]), [⟨false, "u-ca".toList, ["hebrew".toList]⟩]⟩ + "1996-12-19T16:39:57-08:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · rw [vx_parseExt "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]" ⟨⟨1996, 12, 19, 16, 39, 57, [], (.minus 8 0)⟩, none, [⟨false, "_foo".toList, ["bar".toList]⟩, ⟨false, "_baz".toList, ["bat".toList]⟩]⟩ + "1996-12-19T16:39:57-08:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · exact vx_parseExt "2022-07-08T00:14:07+08:45[+08:45]" ⟨⟨2022, 7, 8, 0, 14, 7, [], (.plus 8 45)⟩, some (false, .offset (.plus 8 45)), []⟩ + "2022-07-08T00:14:07+08:45".toList (by decide +kernel) (by decide +kernel) (by decide +kernel) + · rw [vx_parseExt "2022-07-08T00:14:07+08:45[!+08:45]" ⟨⟨2022, 7, 8, 0, 14, 7, [], (.plus 8 45)⟩, some (true, .offset (.plus 8 45)), []⟩ + "2022-07-08T00:14:07+08:45".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel · rw [vx_parseExt "2022-07-08T00:14:07+01:00[Europe/Paris]" ⟨⟨2022, 7, 8, 0, 14, 7, [], (.plus 1 0)⟩, some (false, .name ["Europe".toList, "Paris".toList]), []⟩ "2022-07-08T00:14:07+01:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] decide +kernel + · rw [vx_parseExt "2022-07-08T00:14:07Z[Europe/Paris]" ⟨⟨2022, 7, 8, 0, 14, 7, [], .z⟩, some (false, .name ["Europe".toList, "Paris".toList]), []⟩ + "2022-07-08T00:14:07Z".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · rw [vx_parseExt "2022-07-08T02:14:07+02:00[Europe/Paris]" ⟨⟨2022, 7, 8, 2, 14, 7, [], (.plus 2 0)⟩, some (false, .name ["Europe".toList, "Paris".toList]), []⟩ + "2022-07-08T02:14:07+02:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel · rw [vx_parseExt "2022-07-08T00:14:07+01:00[knort=blargel]" ⟨⟨2022, 7, 8, 0, 14, 7, [], (.plus 1 0)⟩, none, [⟨false, "knort".toList, ["blargel".toList]⟩]⟩ "2022-07-08T00:14:07+01:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] decide +kernel @@ -137,6 +197,9 @@ theorem Spec.ixdtf_vectors : · rw [vx_parseExt "2022-07-08T00:14:07Z[u-ca=chinese][u-ca=japanese]" ⟨⟨2022, 7, 8, 0, 14, 7, [], .z⟩, none, [⟨false, "u-ca".toList, ["chinese".toList]⟩, ⟨false, "u-ca".toList, ["japanese".toList]⟩]⟩ "2022-07-08T00:14:07Z".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] decide +kernel + · rw [vx_parseExt "2022-07-08T00:14:07Z[u-ca=chinese]" ⟨⟨2022, 7, 8, 0, 14, 7, [], .z⟩, none, [⟨false, "u-ca".toList, ["chinese".toList]⟩]⟩ + "2022-07-08T00:14:07Z".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel · rw [vx_parseExt "2022-07-08T00:14:07+01:00[!Europe/Paris]" ⟨⟨2022, 7, 8, 0, 14, 7, [], (.plus 1 0)⟩, some (true, .name ["Europe".toList, "Paris".toList]), []⟩ "2022-07-08T00:14:07+01:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] decide +kernel @@ -161,9 +224,18 @@ theorem Spec.ixdtf_vectors : · rw [vx_parseExt "2022-07-08T00:14:07Z[Europe/London]" ⟨⟨2022, 7, 8, 0, 14, 7, [], .z⟩, some (false, .name ["Europe".toList, "London".toList]), []⟩ "2022-07-08T00:14:07Z".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] decide +kernel - · rw [vx_parseExt "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]" ⟨⟨1996, 12, 19, 16, 39, 57, [], (.minus 8 0)⟩, none, [⟨false, "_foo".toList, ["bar".toList]⟩, ⟨false, "_baz".toList, ["bat".toList]⟩]⟩ + · rw [vx_parseExt "1996-12-19T16:39:57-08:00[!America/Los_Angeles]" ⟨⟨1996, 12, 19, 16, 39, 57, [], (.minus 8 0)⟩, some (true, .name ["America".toList, "Los_Angeles".toList]), []⟩ "1996-12-19T16:39:57-08:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] decide +kernel + · rw [vx_parseExt "2022-07-08T02:14:07+02:00[!Europe/Paris]" ⟨⟨2022, 7, 8, 2, 14, 7, [], (.plus 2 0)⟩, some (true, .name ["Europe".toList, "Paris".toList]), []⟩ + "2022-07-08T02:14:07+02:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · rw [vx_parseExt "2022-07-08T01:14:07+01:00[!-00:00]" ⟨⟨2022, 7, 8, 1, 14, 7, [], (.plus 1 0)⟩, some (true, .offset (.minus 0 0)), []⟩ + "2022-07-08T01:14:07+01:00".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel + · rw [vx_parseExt "2022-07-08T00:14:07Z[Z]" ⟨⟨2022, 7, 8, 0, 14, 7, [], .z⟩, some (false, .name ["Z".toList]), []⟩ + "2022-07-08T00:14:07Z".toList (by decide +kernel) (by decide +kernel) (by decide +kernel)] + decide +kernel · decide +kernel · decide +kernel · decide +kernel diff --git a/README.md b/README.md index 7c2c975..a6b1ad7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # rfc: RFC 3339 timestamps, proved -This library reads, checks and writes RFC 3339 timestamps, such as `1985-04-12T23:20:50.52Z`, in Lean. Machine-checked proofs show that it accepts exactly the strings that the RFC's grammar and calendar rules allow, including the day ranges and leap years of the calendar. `isValid` has one relaxation: it accepts a leap second at the end of any month. `isValidKnown` is exact: it checks second 60 against the table of the 27 leap seconds there have been, as tzdata publishes it. The proofs also cover the round trip from text to value and back, the count of seconds since the Unix epoch, and the order of two timestamps by the instant they name. A count of SI seconds since 1972 gives each leap second its own number, so a round trip through it keeps a leap second, as one through Unix seconds cannot. TAI − UTC is also given, as 10 plus the leap seconds before the timestamp, and proved to agree with the table's own TAI − UTC column at every second from 1972 on but a leap second. It also reads and writes the extended form of RFC 9557, which adds a time zone and tagged values after the timestamp, as in `1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]`. The proofs show that it accepts exactly that grammar, and that it decides which strings an application must treat as erroneous by sections 3.2 to 3.4, given what the application can process. The library is tested against Python's date and time code and a Python copy of the RFC 9557 grammar; `tests/TEST-RESULTS.md` has the record. +This library reads, checks and writes RFC 3339 timestamps, such as `1985-04-12T23:20:50.52Z`, in Lean. Machine-checked proofs show that it accepts exactly the strings that the RFC's grammar and calendar rules allow, including the day ranges and leap years of the calendar. `isValid` has one relaxation: it accepts a leap second at the end of any month. `isValidKnown` is exact: it checks second 60 against the table of the 27 leap seconds there have been, as tzdata publishes it. The proofs also cover the round trip from text to value and back, the count of seconds since the Unix epoch, and the order of two timestamps by the instant they name. A count of SI seconds since 1972 gives each leap second its own number, so a round trip through it keeps a leap second, as one through Unix seconds cannot. TAI − UTC is also given, as 10 plus the leap seconds before the timestamp, and proved to agree with the table's own TAI − UTC column at every second from 1972 on but a leap second. It also reads and writes the extended form of RFC 9557, which adds a time zone and tagged values after the timestamp, as in `1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]`. The proofs show that it accepts exactly that grammar, with RFC 3339 timestamps as `parse` reads them, and that it decides which strings an application must treat as erroneous by sections 3.2 to 3.4, given what the application can process. The library is tested against Python's date and time code and a Python copy of the RFC 9557 grammar; `tests/TEST-RESULTS.md` has the record. It is a project of [Proven Commons](https://github.com/Proven-Commons/mission), built to the verified bar published there. @@ -59,19 +59,19 @@ The statements are in `Spec.lean`. The definitions they use, written from the RF | `taiMinusUtc` is 10, and one more for each leap second of the table before the timestamp, so during a leap second it is still the value from before it. | `Spec.taiMinusUtc_leapsBefore` | | TAI − UTC at each second from 1972 on, other than a leap second, is the value in the table's second column, 10 to 37. The code never reads that column; it counts leap seconds. | `Spec.taiMinusUtc_eq` | | Section 5.8's leap seconds, the table's first and last, one seen from another offset, and second 60 in months that had none. SI seconds and TAI − UTC around the first and the last leap second. And the round trip that Unix seconds lose and SI seconds keep. | `Spec.leap_table_vectors` | -| `parseExt` reads exactly the strings of RFC 9557's `date-time-ext` rule: an RFC 3339 timestamp, as `parse` reads it, then a time zone, a name or a numeric offset, if there is one, and the tags, each perhaps marked critical with `!`. It returns the timestamp, the zone and the tags as written. | `Spec.parseExt_iff` | +| `parseExt` reads exactly the strings of RFC 9557's `date-time-ext` rule, with one relaxation: an RFC 3339 timestamp, as `parse` reads it, so with second 60 at the end of any month, then a time zone, a name or a numeric offset, if there is one, and the tags, each perhaps marked critical with `!`. It returns the timestamp, the zone and the tags as written. | `Spec.parseExt_iff` | | A valid IXDTF timestamp prints as the grammar writes it, and printing then parsing gives it back. | `Spec.printExt_eq`, `Spec.parseExt_print` | | A string with no suffix reads as `parse` reads it, with no time zone and no tags. | `Spec.parseExt_plain` | | `lookup` gives the first tag with a key, which section 3.3 has an application choose when an elective key is repeated. | `Spec.lookup_iff` | | `erroneous` decides what sections 3.2 to 3.4 make an application treat as erroneous, for an application described by the tags it processes and the zone offsets it knows: a critical time zone it does not know or that disagrees with the offset, a critical tag it cannot process or whose key is repeated with other values, and an experimental key, one starting with `_`, it does not take part in. | `Spec.erroneous_iff` | -| The examples of sections 3.3, 3.4 and 4.2, read and judged by an example application, and strings the grammar refuses: an empty item, `.` and `..` as zone parts, an upper case key, an empty value, a time zone after a tag, and an offset out of range. | `Spec.ixdtf_vectors` | +| Every example of RFC 9557, in sections 1.2, 3.3, 3.4 and 4.2, read and judged by an example application, as the RFC judges them. Critical time zones that agree with the offset, a critical `-00:00` zone that does not, and `[Z]` read as a zone name. Strings the grammar refuses: an empty item, `.` and `..` as zone parts, an upper case key, an empty value, a time zone after a tag, and an offset out of range. | `Spec.ixdtf_vectors` | The command-line tool in `Main.lean` calls these functions directly. ## What is not proved - Reading requests and printing answers in `Main.lean`: the hex decoding and UTF-8 check in `serve` mode, the arguments, output and exit codes of `check` mode, the decimal printing of numbers and reading of the numbers `fromunix` and `fromsi` take, the layout of the `known` answer, with `-` for TAI − UTC before 1972, and the layout of the `ixdtf` answer, which judges each string with the example application of `Spec.exampleProcessor`. The differential tests cover them, with malformed requests and CR LF line endings. -- Whether a leap second was announced, in `isValid` and `parse`. They accept second 60 at 23:59 UTC at the end of any month, where section 5.7 allows it only at the end of months in which a leap second occurs; `isValidKnown` checks that against the table. Second 59 is always accepted, although section 5.7 makes 58 the last second of a month after a negative leap second, which has never happened. +- Whether a leap second was announced, in `isValid`, `parse` and `parseExt`. They accept second 60 at 23:59 UTC at the end of any month, where section 5.7 allows it only at the end of months in which a leap second occurs; `isValidKnown` checks that against the table. Second 59 is always accepted, although section 5.7 makes 58 the last second of a month after a negative leap second, which has never happened. - The table after its expiry date, 28 June 2027. `isValidKnown` refuses second 60 at any month end the table does not list, and `siSeconds` and `taiMinusUtc` count no leap second after 2016. If one is ever announced, the table in the spec and the code's list must change together, and the spec approved again. The table is what tzdata publishes from IERS Bulletin C; that it matches the IERS record is trusted, not proved. - SI seconds and TAI − UTC before 1972. UTC then ran on seconds of varying length, with TAI − UTC not a whole number of seconds. Before 1972, `Spec.siSeconds_eq` makes `siSeconds` Unix seconds less 63072000, 86400 to a day, which are not SI seconds there, and `taiMinusUtc` gives 10, which means nothing there; the tool's `known` request prints `-` for TAI − UTC. - The table itself. `Spec/Leap.lean` copies tzdata's `leap-seconds.list` (tzdata 2026e, updated through IERS Bulletin C, expiring 28 June 2027) line for line. That copy is part of the specification: the reviewer checks it against the file, and the differential tests check the code against the copy of the file on the test machine, but no proof does. diff --git a/SPEC.md b/SPEC.md index 24f844e..839b401 100644 --- a/SPEC.md +++ b/SPEC.md @@ -79,7 +79,7 @@ RFC 9557, the Internet Extended Date/Time Format (IXDTF), updates RFC 3339. Afte - the time zone, if there is one, with whether it is critical, its name as the parts between the slashes, or its offset; - the tags in order, each with whether it is critical, its key, and its values as the parts between the hyphens. -It reads exactly the strings of the grammar, and a string with no suffix exactly as `parse` reads it. `Ext.print` writes an `Ext` back, with `T` and `Z` in upper case. +It reads exactly the strings of the grammar, with the timestamp as `parse` reads it, second 60 at the end of any month included, and a string with no suffix exactly as `parse` reads it. `Ext.print` writes an `Ext` back, with `T` and `Z` in upper case. `Ext.lookup` gives the first tag with a key. Section 3.3 has an application that does not handle a repeated elective key choose the first. @@ -139,7 +139,7 @@ Each property is a theorem in `Spec.lean`, under the name given. The definitions | A string with no `[` reads as `parse` reads it, with no time zone and no tags. | `Spec.parseExt_plain` | | `lookup` gives the first tag with the key. | `Spec.lookup_iff` | | `erroneous` decides `Spec.MustReject`: the rules of sections 3.2 to 3.4, for any application. | `Spec.erroneous_iff` | -| The examples of sections 3.3, 3.4 and 4.2 are read, and are erroneous for the example application exactly where the RFC says; strings the grammar refuses are not read. | `Spec.ixdtf_vectors` | +| Every example of RFC 9557 is read, and is erroneous for the example application exactly where the RFC says; so are critical zones that agree with the offset and a critical `-00:00` zone that does not; strings the grammar refuses are not read. | `Spec.ixdtf_vectors` | Together, these fix every output for valid input. @@ -153,7 +153,7 @@ Together, these fix every output for valid input. ## Oracle -The differential tests compare the tool with two oracles: +The differential tests compare the tool with these oracles: - Python's `datetime` and `calendar` modules: month lengths, offset arithmetic, seconds since the epoch, `datetime.fromisoformat` on every accepted string it can hold, and the UTC timestamp for a number of seconds. `datetime` holds no year 0, so dates there are moved by 400 years, one cycle of the Gregorian calendar, for the arithmetic. - A copy of the rules above, written in Python from the RFC. diff --git a/Spec.lean b/Spec.lean index f754f2c..e33771a 100644 --- a/Spec.lean +++ b/Spec.lean @@ -298,28 +298,54 @@ theorem Spec.lookup_iff (e : Ext) (k : List Char) (t : Tag) : theorem Spec.erroneous_iff (p : Processor) (e : Ext) : e.erroneous p = true ↔ Spec.MustReject p e := sorry -/-- The examples of sections 3.3, 3.4 and 4.2, with the application of - `Spec.exampleProcessor`: each one is read, and is erroneous exactly - where the RFC says it must be treated so. And strings the grammar - refuses. -/ +/-- Every example of RFC 9557, in sections 1.2, 3.3, 3.4 and 4.2, with + the application of `Spec.exampleProcessor`: each one is read, and is + erroneous exactly where the RFC says it must be treated so. Also + critical time zones that agree with the offset, a critical `-00:00` + zone that does not, and strings the grammar refuses. -/ theorem Spec.ixdtf_vectors : - (parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]" = + -- Section 4.2, Figures 4 to 7 + parseExt "1996-12-19T16:39:57-08:00" = + some ⟨⟨1996, 12, 19, 16, 39, 57, [], .minus 8 0⟩, none, []⟩ ∧ + parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles]" = + some ⟨⟨1996, 12, 19, 16, 39, 57, [], .minus 8 0⟩, + some (false, .name ["America".toList, "Los_Angeles".toList]), []⟩ ∧ + parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]" = some ⟨⟨1996, 12, 19, 16, 39, 57, [], .minus 8 0⟩, some (false, .name ["America".toList, "Los_Angeles".toList]), - [⟨false, "u-ca".toList, ["hebrew".toList]⟩]⟩) ∧ - (parseExt "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]" = + [⟨false, "u-ca".toList, ["hebrew".toList]⟩]⟩ ∧ + parseExt "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]" = some ⟨⟨1996, 12, 19, 16, 39, 57, [], .minus 8 0⟩, none, - [⟨false, "_foo".toList, ["bar".toList]⟩, ⟨false, "_baz".toList, ["bat".toList]⟩]⟩) ∧ - -- Elective: an inconsistent zone, an unknown key, repeated keys + [⟨false, "_foo".toList, ["bar".toList]⟩, ⟨false, "_baz".toList, ["bat".toList]⟩]⟩ ∧ + ((parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles]").map + (·.erroneous Spec.exampleProcessor) = some false) ∧ + ((parseExt "1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]").map + (·.erroneous Spec.exampleProcessor) = some false) ∧ + -- An experiment the application does not take part in + ((parseExt "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]").map + (·.erroneous Spec.exampleProcessor) = some true) ∧ + -- Section 1.2: an offset time zone + parseExt "2022-07-08T00:14:07+08:45[+08:45]" = + some ⟨⟨2022, 7, 8, 0, 14, 7, [], .plus 8 45⟩, some (false, .offset (.plus 8 45)), []⟩ ∧ + ((parseExt "2022-07-08T00:14:07+08:45[!+08:45]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ + -- Section 3.3, elective: an inconsistent zone, the zones of the note, + -- an unknown key, repeated keys ((parseExt "2022-07-08T00:14:07+01:00[Europe/Paris]").map (·.erroneous Spec.exampleProcessor) = some false) ∧ + ((parseExt "2022-07-08T00:14:07Z[Europe/Paris]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ + ((parseExt "2022-07-08T02:14:07+02:00[Europe/Paris]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ ((parseExt "2022-07-08T00:14:07+01:00[knort=blargel]").map (·.erroneous Spec.exampleProcessor) = some false) ∧ ((parseExt "2022-07-08T00:14:07Z[u-ca=chinese][u-ca=japanese]").map (·.erroneous Spec.exampleProcessor) = some false) ∧ ((parseExt "2022-07-08T00:14:07Z[u-ca=chinese][u-ca=japanese]").bind (·.lookup "u-ca".toList) = some ⟨false, "u-ca".toList, ["chinese".toList]⟩) ∧ - -- Critical: the four of section 3.3 + ((parseExt "2022-07-08T00:14:07Z[u-ca=chinese]").bind (·.lookup "u-ca".toList) = + some ⟨false, "u-ca".toList, ["chinese".toList]⟩) ∧ + -- Section 3.3, critical: the four examples ((parseExt "2022-07-08T00:14:07+01:00[!Europe/Paris]").map (·.erroneous Spec.exampleProcessor) = some true) ∧ ((parseExt "2022-07-08T00:14:07Z[!u-ca=chinese][u-ca=japanese]").map @@ -328,7 +354,7 @@ theorem Spec.ixdtf_vectors : (·.erroneous Spec.exampleProcessor) = some true) ∧ ((parseExt "2022-07-08T00:14:07Z[!knort=blargel]").map (·.erroneous Spec.exampleProcessor) = some true) ∧ - -- Figures 1 and 2 of section 3.4 + -- Section 3.4, Figures 1 and 2 ((parseExt "2022-07-08T00:14:07+00:00[!Europe/London]").map (·.erroneous Spec.exampleProcessor) = some true) ∧ ((parseExt "2022-07-08T00:14:07+00:00[Europe/London]").map (·.erroneous Spec.exampleProcessor) = @@ -337,12 +363,18 @@ theorem Spec.ixdtf_vectors : some false) ∧ ((parseExt "2022-07-08T00:14:07Z[Europe/London]").map (·.erroneous Spec.exampleProcessor) = some false) ∧ - -- An experiment the application does not take part in - ((parseExt "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]").map - (·.erroneous Spec.exampleProcessor) = some true) ∧ + -- Critical zones that agree with the offset, and a critical `-00:00` + -- zone, offset 0, under an offset of +01:00 + ((parseExt "1996-12-19T16:39:57-08:00[!America/Los_Angeles]").map + (·.erroneous Spec.exampleProcessor) = some false) ∧ + ((parseExt "2022-07-08T02:14:07+02:00[!Europe/Paris]").map (·.erroneous Spec.exampleProcessor) = + some false) ∧ + ((parseExt "2022-07-08T01:14:07+01:00[!-00:00]").map (·.erroneous Spec.exampleProcessor) = + some true) ∧ + -- `Z` in brackets is a zone name, not an offset + ((parseExt "2022-07-08T00:14:07Z[Z]").map (·.zone) = some (some (false, .name ["Z".toList]))) ∧ -- Refused by the grammar: an empty item, `.` and `..`, an upper case - -- key, an empty value, a time zone after a tag, `Z` as an offset - -- (it reads as a zone name), an offset out of range + -- key, an empty value, a time zone after a tag, an offset out of range parseExt "2022-07-08T00:14:07Z[]" = none ∧ parseExt "2022-07-08T00:14:07Z[.]" = none ∧ parseExt "2022-07-08T00:14:07Z[a/..]" = none ∧ diff --git a/tests/cross_check.py b/tests/cross_check.py index f7e88ea..b74ebdf 100644 --- a/tests/cross_check.py +++ b/tests/cross_check.py @@ -195,18 +195,21 @@ def ix_category(s, ours_ok): if len(f["fraction"]) > 9: return "a fraction of more than nine digits, finer than Temporal's nanoseconds" if (zone and zone[0]) or any(c for c, _, _ in tags): - return "a critical item, which Temporal refuses when it does not act on it" + return "a critical flag, which this Temporal refuses on any item" if any(k.startswith("_") for _, k, _ in tags): return "an experimental key" if len([k for _, k, _ in tags if k == "u-ca"]) > 1: return "a repeated elective `u-ca`" if any(k != "u-ca" for _, k, _ in tags): return "an unknown elective key" - if tags and tags[0][2] not in run_tests.IX_CALENDARS: - return "a calendar Temporal does not know" - if zone and zone[1][0] == "name" and "/" not in zone[1][1]: - return "a one-part time zone name" + if tags and any(not 3 <= len(p) <= 8 for p in tags[0][2]): + return "a calendar with a part outside 3 to 8 characters, which the older proposal refuses" if zone and zone[1][0] == "name": + parts = zone[1][1].split("/") + etc_gmt = re.fullmatch(r"Etc/GMT[+-](?:[0-9]|1[0-4])", zone[1][1]) + if not etc_gmt and any(re.search(r"[0-9+]", q) or len(q) > 14 for q in parts): + return ("a time zone name with a digit or `+`, or a part over 14 characters, " + "which the older proposal refuses") return "another time zone name" return "other" i = s.find("[") diff --git a/tests/run_tests.py b/tests/run_tests.py index 9424962..219b5e9 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Differential tests: the Lean parser against Python's date code. -There are two oracles: +For RFC 3339 itself there are two oracles: - Python's `datetime` and `calendar` modules. `calendar.isleap` and `calendar.mdays` give the month lengths, `datetime` arithmetic applies @@ -56,14 +56,15 @@ critical tags (sections 3.2 to 3.4), written again in this script. `ixdtf` requests must give the printed form, whether the example application must treat the string as erroneous, and the first `u-ca` - tag. The inputs are every example of sections 3.3, 3.4 and 4.2, the + tag. The inputs are every example of sections 1.2, 3.3, 3.4 and 4.2, the edges of the grammar and rules written out below, random timestamps with random suffixes (time zones by name and by offset, tags with known, unknown and experimental keys, each item elective or critical, keys repeated), and those suffixes mutated: a - character changed, dropped or added, a bracket or `!` moved, a key in - upper case, `.` or `..` as a zone part, an empty item or value, a - time zone after a tag; + character changed, dropped or added, brackets, `!` and `=` among them, + the first bracket dropped or a last one left open, a key in upper + case, `.` or `..` as a zone part, an empty item or value, a time zone + after a tag; - malformed requests, which the tool must answer with `error`. Every seventh request ends in CR LF. The first 20,000 parsed strings @@ -306,7 +307,7 @@ def ixdtf(s): "2022-07-08T00:14:07Z[!Europe/London]", "2022-07-08T00:14:07Z[Europe/London]", "1996-12-19T16:39:57-08:00", "1996-12-19T16:39:57-08:00[America/Los_Angeles]", "1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]", - "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]", + "1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]", "2022-07-08T00:14:07+08:45[+08:45]", ] # The edges of the grammar and the rules, each written once. IX_EDGES = [ @@ -329,16 +330,31 @@ def ixdtf(s): "2022-07-08T00:14:07Z[/Europe]", "2022-07-08T00:14:07Z[Europe/]", "2022-07-08T00:14:07Z[Z]", "2022-07-08T00:14:07Z[UTC][u-ca=hebrew]", "2022-07-08T00:14:07Z[u-ca=Hebrew]", "1990-12-31T23:59:60Z[u-ca=hebrew]", "2022-07-08t00:14:07z[u-ca=hebrew]", + # A critical -00:00 zone is offset 0, whatever the timestamp's offset + "2022-07-08T01:14:07+01:00[!-00:00]", "2022-07-07T16:14:07-08:00[!-00:00]", + "2022-07-08T00:14:07Z[!-00:00]", + # Critical zones that agree with an asserted offset + "1996-12-19T16:39:57-08:00[!America/Los_Angeles]", "2022-07-08T00:14:07+08:45[!+08:45]", + "2022-07-08T00:14:07+01:00[!+08:45]", + # A critical tag whose key another tag uses with values that share + # its first part, and two critical tags with one key + "2022-07-08T00:14:07Z[!u-ca=chinese][u-ca=chinese-japanese]", + "2022-07-08T00:14:07Z[u-ca=chinese-japanese][!u-ca=chinese]", + "2022-07-08T00:14:07Z[!u-ca=chinese][!u-ca=japanese]", + "2022-07-08T00:14:07Z[!u-ca=chinese][!u-ca=chinese]", ] IX_NAMES = ["Europe/Paris", "Europe/London", "America/Los_Angeles", "Asia/Tokyo", "UTC", "Etc/GMT+5", "America/Argentina/Buenos_Aires", "_x", ".a", "a.b-c+d_e", "Z", "z"] IX_KEYS = ["u-ca", "u-ca", "knort", "_foo", "x", "a-b_c9", "_"] -IX_VALUES = [["chinese"], ["japanese"], ["hebrew"], ["gregory"], ["blargel"], ["a", "b"], ["X1"], ["0"]] +IX_VALUES = [["chinese"], ["japanese"], ["hebrew"], ["gregory"], ["blargel"], ["a", "b"], ["X1"], ["0"], + ["chinese", "japanese"]] def ix_offset_text(rng): + """An offset as RFC 3339 writes it; 0 as `+00:00` or `-00:00`.""" m = rng.choice([0, 60, 120, -480, 330, -1439, 1439]) - return ("+" if m >= 0 else "-") + f"{abs(m) // 60:02d}:{abs(m) % 60:02d}" + sign = "+" if m > 0 or (m == 0 and rng.random() < 0.5) else "-" + return sign + f"{abs(m) // 60:02d}:{abs(m) % 60:02d}" def random_suffix(rng): From 83d4ba8b5340e564db03382dba942d8eb5b3abe1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:51:34 +0000 Subject: [PATCH 11/11] Rerun the tests and the cross-check, and give Temporal's reasons as observed Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WWUFjVe6uzWVQGpyU113ow --- README.md | 2 +- tests/CROSS-CHECK.md | 38 ++++++++++++++++++-------------------- tests/TEST-RESULTS.md | 18 +++++++++--------- 3 files changed, 28 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index a6b1ad7..f461fb1 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ This project has no dependencies beyond Lean itself. - Go accepts a comma before the fraction. - `rfc3339-validator` accepts a timestamp followed by a line break. -For RFC 9557, it compares `parseExt` with `Temporal.Instant.from` in Node's built-in Temporal, which is behind a flag and older than the current proposal. Temporal refuses what it does not act on: critical items, experimental and unknown keys, calendars it does not know, a repeated `u-ca`, and fractions finer than nanoseconds. Of the strings this project reads, it also refuses time zone names that are not in its time zone database, such as `a.b-c+d_e` and `Etc/G.T+5`. It accepts three kinds of string that this project refuses as RFC 9557 does: a `[` inside an item, a zone offset with no colon, and a stray `]`. +For RFC 9557, it compares `parseExt` with `Temporal.Instant.from` in Node's built-in Temporal, which is behind a flag and older than the current proposal. This Temporal refuses every critical flag, even on an item it acts on. It also refuses experimental and unknown keys, a repeated `u-ca`, and fractions finer than nanoseconds. Its older grammar also refuses a calendar with a part outside 3 to 8 characters, and a time zone name with a digit or `+`, other than the `Etc/GMT` forms, or with a part over 14 characters. It reads calendars and zone names it does not know. It accepts a few strings this project refuses, as RFC 9557 does, such as a zone offset with no colon and a stray `]`. For leap seconds, it compares `siSeconds`, `taiMinusUtc` and `fromSiSeconds` with ERFA, a BSD-licensed C library derived from the IAU's SOFA, through pyerfa, which keeps its own leap second table. `tests/CROSS-CHECK.md` gives the counts. diff --git a/tests/CROSS-CHECK.md b/tests/CROSS-CHECK.md index 445ce86..dd0b090 100644 --- a/tests/CROSS-CHECK.md +++ b/tests/CROSS-CHECK.md @@ -6,48 +6,46 @@ Written by `tests/cross_check.py`, once, by hand. It is not part of the verified - Go: `time.Parse(time.RFC3339Nano, s)`, go version go1.24.7 linux/amd64 - rfc3339-validator 0.1.4 (Python 3.13.17), called on the string as given. `jsonschema` calls it for its `date-time` check on the upper-cased string, so the lower case rows below do not apply to `jsonschema`. - Seed: 20261025 -- Strings: 181892 +- Strings: 181849 - Strings both this project and Go accept, read to a different instant: 0 Where each parser differs from this one, by the first category that fits the string: | Parser | Differs by | Category | Strings | Example | | --- | --- | --- | --- | --- | -| Go | rejects | lower case t or z | 19069 | `'1985-04-12t23:20:50.52z'`, `'2100-10-31t02:52:09+00:50'` | -| Go | rejects | second 60 | 3679 | `'1990-12-31T23:59:60Z'`, `'1990-12-31T15:59:60-08:00'` | -| Go | accepts | offset hour above 23 or minute above 59 | 1947 | `'2000-01-01T00:00:00+24:00'`, `'2000-01-01T00:00:00+23:60'` | -| Go | accepts | a field with one digit | 444 | `'6796-03-01T8:13:08+17:53'`, `'2289-10-02T3:10:29.2-00:00'` | +| Go | rejects | lower case t or z | 19086 | `'1985-04-12t23:20:50.52z'`, `'2100-10-31t02:52:09+00:50'` | +| Go | rejects | second 60 | 3696 | `'1990-12-31T23:59:60Z'`, `'1990-12-31T15:59:60-08:00'` | +| Go | accepts | offset hour above 23 or minute above 59 | 1918 | `'2000-01-01T00:00:00+24:00'`, `'2000-01-01T00:00:00+23:60'` | +| Go | accepts | a field with one digit | 440 | `'6796-03-01T8:13:08+17:53'`, `'2289-10-02T3:10:29.2-00:00'` | | Go | accepts | comma before the fraction | 6 | `'2000-01-01T00:00:00,5Z'`, `'1900-01-02T22:19:55,9-00:00'` | -| rfc3339-validator | rejects | lower case t or z | 19069 | `'1985-04-12t23:20:50.52z'`, `'2100-10-31t02:52:09+00:50'` | -| rfc3339-validator | rejects | second 60 | 3679 | `'1990-12-31T23:59:60Z'`, `'1990-12-31T15:59:60-08:00'` | -| rfc3339-validator | rejects | year 0000 | 1222 | `'0000-01-01T00:00:00Z'`, `'0000-01-01T12:00:00Z'` | -| rfc3339-validator | accepts | line break at the end | 878 | `'4244-06-01T08:05:55.054876690884+00:00\n'`, `'5263-10-30T07:22:33.52-07:24\n'` | +| rfc3339-validator | rejects | lower case t or z | 19086 | `'1985-04-12t23:20:50.52z'`, `'2100-10-31t02:52:09+00:50'` | +| rfc3339-validator | rejects | second 60 | 3696 | `'1990-12-31T23:59:60Z'`, `'1990-12-31T15:59:60-08:00'` | +| rfc3339-validator | rejects | year 0000 | 1226 | `'0000-01-01T00:00:00Z'`, `'0000-01-01T12:00:00Z'` | +| rfc3339-validator | accepts | line break at the end | 885 | `'4244-06-01T08:05:55.054876690884+00:00\n'`, `'5263-10-30T07:22:33.52-07:24\n'` | ## Leap seconds - ERFA, through pyerfa 2.0.1.5, with its own leap second table -- Timestamps known valid by the table, from 1972 on, given to ERFA: 18819. Different SI seconds since 1972 or TAI - UTC: 0. Before 1972, where TAI - UTC was not a whole number of seconds, not compared: 1716. -- SI seconds turned back into timestamps, of which 27 leap seconds, and given to ERFA for their SI seconds: 8656. Different: 0. +- Timestamps known valid by the table, from 1972 on, given to ERFA: 18834. Different SI seconds since 1972 or TAI - UTC: 0. Before 1972, where TAI - UTC was not a whole number of seconds, not compared: 1717. +- SI seconds turned back into timestamps, of which 27 leap seconds, and given to ERFA for their SI seconds: 8695. Different: 0. ## RFC 9557 - Node v22.22.0, `Temporal.Instant.from`, with `--harmony-temporal`: V8's Temporal behind a flag, older than the current proposal -- Strings: 7157, of which this project reads 5299. The same answer, read or refused: 2822. +- Strings: 7143, of which this project reads 5302. The same answer, read or refused: 2833. Where Temporal differs from this project, by the first category that fits the string: | Temporal | Category | Strings | Example | | --- | --- | --- | --- | -| refuses | a critical item, which Temporal refuses when it does not act on it | 2343 | `'2022-07-08T00:14:07+01:00[!Europe/Paris]'`, `'2022-07-08T00:14:07Z[!u-ca=chinese][u-ca=japanese]'` | -| refuses | an experimental key | 682 | `'1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]'`, `'2022-07-08T00:14:07Z[_=x]'` | +| refuses | a critical flag, which this Temporal refuses on any item | 2338 | `'2022-07-08T00:14:07+01:00[!Europe/Paris]'`, `'2022-07-08T00:14:07Z[!u-ca=chinese][u-ca=japanese]'` | +| refuses | an experimental key | 681 | `'1996-12-19T16:39:57-08:00[_foo=bar][_baz=bat]'`, `'2022-07-08T00:14:07Z[_=x]'` | | refuses | an unknown elective key | 660 | `'2022-07-08T00:14:07+01:00[knort=blargel]'`, `'2022-07-08T00:14:07Z[x_=y]'` | -| refuses | a fraction of more than nine digits, finer than Temporal's nanoseconds | 418 | `'5720-01-31T23:59:60.26153083810657+00:00[Etc/GMT+5][!_=a-b][_foo=japanese]'`, `'3938-05-31T07:00:58.533659862016880639560673568684585171z[_x]'` | -| refuses | a calendar Temporal does not know | 124 | `'2022-07-08T00:14:07+00:00[u-ca=0]'`, `'2022-07-08T00:14:07+00:00[+01:00][u-ca=a-b]'` | -| refuses | a repeated elective `u-ca` | 68 | `'2022-07-08T00:14:07Z[u-ca=chinese][u-ca=japanese]'`, `'2024-02-04T15:17:00+23:59[+01:00][x=a-b][u-ca=a-b][u-ca=blargel]'` | -| refuses | a one-part time zone name | 31 | `'2404-02-29T00:37:44.236447+18:26[a.b-c+d_e]'`, `'3793-03-02T15:50:39.27-20:39[a.b-c+d_e]'` | -| refuses | another time zone name | 6 | `'2022-07-08T00:14:07Z[Etc/G.T+5]'`, `'9167-04-30T21:08:20.7+11:30[Etc/GMT+Z]'` | -| accepts | a `[` inside an item | 1 | `'1019-06-30T05:31:37+00:34[+23:59][u-ca=blargel[]'` | +| refuses | a fraction of more than nine digits, finer than Temporal's nanoseconds | 407 | `'5720-01-31T23:59:60.26153083810657+00:00[Etc/G_T+5][!_=chinese-japanese][_foo=0]'`, `'3938-05-31T07:00:58.533659862016880639560673568684585171z[_x]'` | +| refuses | a calendar with a part outside 3 to 8 characters, which the older proposal refuses | 111 | `'2022-07-08T00:14:07+00:00[u-ca=0]'`, `'2022-07-08T00:14:07+00:00[+01:00][u-ca=a-b]'` | +| refuses | a repeated elective `u-ca` | 70 | `'2022-07-08T00:14:07Z[u-ca=chinese][u-ca=japanese]'`, `'2024-02-04T15:17:00+23:59[+01:00][x=a-b][u-ca=a-b][u-ca=blargel]'` | +| refuses | a time zone name with a digit or `+`, or a part over 14 characters, which the older proposal refuses | 41 | `'2404-02-29T00:37:44.236447+18:26[a.b-c+d_e]'`, `'3793-03-02T15:50:39.27-20:39[a.b-c+d_e]'` | | accepts | a time zone offset with no colon, as ISO 8601's basic format writes it | 1 | `'6121-04-02t21:22:26.9+05:04[+0530]'` | | accepts | another suffix the grammar refuses | 1 | `'5984-06-01T02:30:25.893247-11:17[u-ca=gregory]]'` | diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index 9694fe8..4a7fa4c 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -6,16 +6,16 @@ Written by `tests/run_tests.py`. Rerun it to check these numbers. - Lean: Lean (version 4.35.0, x86_64-unknown-linux-gnu, commit eb6cafb7290e8bd5f2937522d8d804f58f02bafc, Release) - Seed: 20261025 - Inputs: 300000 -- Mutated: 67662 of the inputs are mutated timestamps -- By kind: compare 29524, error 48, fromsi 9358, fromunix 44565, grid 16998, ixdtf 7157, known 27468, mutated 67662, near miss 29208, valid 68012 -- Strings parsed: 181880, of which 83519 accepted (4589 with a leap second) and 98361 rejected -- Accepted strings also read by `datetime.fromisoformat`, with the same fields, offset and seconds: 77424 -- Pairs ordered: 29524 (9906 before, 8293 same instant, 9859 after, 1466 with a string rejected) -- Unix seconds turned back into timestamps: 44565, of which 648 outside years 0000 to 9999 +- Mutated: 67589 of the inputs are mutated timestamps +- By kind: compare 29508, error 48, fromsi 9393, fromunix 44578, grid 16998, ixdtf 7143, known 27493, mutated 67589, near miss 29155, valid 68095 +- Strings parsed: 181837, of which 83546 accepted (4617 with a leap second) and 98291 rejected +- Accepted strings also read by `datetime.fromisoformat`, with the same fields, offset and seconds: 77425 +- Pairs ordered: 29508 (9918 before, 8280 same instant, 9846 after, 1464 with a string rejected) +- Unix seconds turned back into timestamps: 44578, of which 671 outside years 0000 to 9999 - Leap second table: /usr/share/zoneinfo/leap-seconds.list, 28 lines, 27 leap seconds, expires 2027-06-28, last updated at NTP time 3992312697, hash line `a9bad145 84c31c70 758402aa b37bfd54 5923836a` -- Timestamps checked against the table (`known`): 27468, of which 224 leap seconds of the table, 5703 with a second 60 it does not list, and 1230 rejected -- SI seconds turned back into timestamps (`fromsi`): 9358, of which 27 leap seconds and 702 outside 1972 to 9999 -- RFC 9557 strings (`ixdtf`): 7157, of which 5299 read, 3126 of them erroneous for the example application, and 1858 refused +- Timestamps checked against the table (`known`): 27493, of which 224 leap seconds of the table, 5700 with a second 60 it does not list, and 1242 rejected +- SI seconds turned back into timestamps (`fromsi`): 9393, of which 27 leap seconds and 698 outside 1972 to 9999 +- RFC 9557 strings (`ixdtf`): 7143, of which 5302 read, 3122 of them erroneous for the example application, and 1841 refused - Malformed requests answered `error`: 48 of 48 - Requests ending in CR LF: every seventh, 42857 - Strings also given to `rfc3339 check` as arguments, in batches of 500: 20000, and once with no arguments