add Calendar + CalendarTime + Rhythm + LocalDate/Time as first-class

Phase 1.5 of time-system. Calendar is pluggable: EarthCalendar
(IANA zones, DST, Gregorian) is the default; MarsCalendar,
CycleCalendar(period), NoCycleCalendar handle non-Earth cases.

Rhythm abstracts recurrence from clock units - rhythm_cycle_phase(0.5)
means "midpoint of cycle" whether the cycle is 24 hours on Earth or
30 hours on a station or 300 years on a long-cycle world.

Phase 1 (Instant + Duration) unchanged. EarthCalendar(zone_local())
is the user-facing default; nobody who doesn't care about non-Earth
calendars sees the abstraction.

Self-host fixed point holds at 6339 lines.
Snapshot tagged at dist/platform/elc.20260502-1321-self-host.

Phase 2 (scheduling primitives every/after/at) lands next, now with
Calendar-aware grounding instead of Earth-time hardcoded.

Backlog: bl-297f66d8 (supersedes bl-b29b3e60)
This commit is contained in:
Will Anderson
2026-05-02 13:21:43 -05:00
parent e7c2fd02df
commit ed564b6dda
17 changed files with 1627 additions and 0 deletions
@@ -0,0 +1,22 @@
// cross-calendar-comparison.el same Instant under two different calendars
// produces identical cal_to_instant() outputs. Calendar choice does not change
// the underlying instant only the projection.
fn run_test() -> Int {
let i: Instant = unix_seconds(1782216000)
let z: Zone = zone("America/New_York")
let earth: Calendar = earth_calendar(z)
let mars: Calendar = mars_calendar()
let ct_earth: CalendarTime = in_calendar(i, earth)
let ct_mars: CalendarTime = in_calendar(i, mars)
let i_earth: Instant = cal_to_instant(ct_earth)
let i_mars: Instant = cal_to_instant(ct_mars)
if i_earth == i_mars {
return 1
}
return 0
}
fn main() -> Void {
println(int_to_str(run_test()))
}
+23
View File
@@ -0,0 +1,23 @@
// cycle-300yr.el CycleCalendar with a 100-year period proves the math
// holds at long periods. (300 years exceeds int64 nanos: 2^63 ns 292 yr;
// 100 yr is the largest round period that fits while leaving headroom for
// instants on either side.) One earth year apart yields phase_diff ~ 0.01.
fn run_test() -> String {
// 100 Julian years = 100 * 31557600 = 3155760000 seconds.
let period: Duration = duration_seconds(3155760000)
let cal: Calendar = cycle_calendar(period)
let base: Instant = unix_seconds(0)
let later: Instant = unix_seconds(31557600)
let ct1: CalendarTime = in_calendar(base, cal)
let ct2: CalendarTime = in_calendar(later, cal)
let p1: Float = cal_cycle_phase(ct1)
let p2: Float = cal_cycle_phase(ct2)
let diff: Float = p2 - p1
// 1 year / 100 years = 0.01
return format_float(diff, 2)
}
fn main() -> Void {
println(run_test())
}
+20
View File
@@ -0,0 +1,20 @@
// cycle-30hr.el CycleCalendar with a 30-hour period.
// Two CalendarTimes 15 hours apart should have cycle_phase differ by 0.5.
// We compare phases via float subtraction with format_float for determinism.
fn run_test() -> String {
let period: Duration = 30.hours
let cal: Calendar = cycle_calendar(period)
let base: Instant = unix_seconds(0)
let later: Instant = base + 15.hours
let ct1: CalendarTime = in_calendar(base, cal)
let ct2: CalendarTime = in_calendar(later, cal)
let p1: Float = cal_cycle_phase(ct1)
let p2: Float = cal_cycle_phase(ct2)
let diff: Float = p2 - p1
return format_float(diff, 1)
}
fn main() -> Void {
println(run_test())
}
@@ -0,0 +1,17 @@
// dst-spring-forward.el Earth calendar handles the DST transition.
// 2026 spring DST: March 8 at 02:00 EST clocks jump to 03:00 EDT.
// 2026-03-08 06:30 UTC = 01:30 EST (just before the jump).
// Add 1 hour 07:30 UTC = 03:30 EDT (the wall clock skipped 02:30 entirely).
fn run_test() -> String {
let z: Zone = zone("America/New_York")
let cal: Calendar = earth_calendar(z)
let i: Instant = unix_seconds(1772951400)
let later: Instant = i + 1.hour
let ct: CalendarTime = in_calendar(later, cal)
return cal_format(ct, "HH:mm z")
}
fn main() -> Void {
println(run_test())
}
+16
View File
@@ -0,0 +1,16 @@
// earth-zone.el EarthCalendar(zone) formats with the right zone abbreviation.
// We use a fixed Instant on July 4, 2026 (definitely EDT in NYC) so the
// abbreviation is deterministic across runs.
fn run_test() -> String {
let z: Zone = zone("America/New_York")
let cal: Calendar = earth_calendar(z)
// 2026-07-04 12:00:00 UTC = 2026-07-04 08:00:00 EDT
let i: Instant = unix_seconds(1782216000)
let ct: CalendarTime = in_calendar(i, cal)
return cal_format(ct, "z")
}
fn main() -> Void {
println(run_test())
}
@@ -0,0 +1,16 @@
// local-date-arithmetic.el LocalDate + Duration produces a LocalDate.
// 2026-05-28 + 7 days crosses the May/June boundary, yielding 2026-06-04.
fn run_test() -> String {
let d1: LocalDate = local_date(2026, 5, 28)
let week: Duration = 7.days
let d2: LocalDate = d1 + week
let y: Int = local_date_year(d2)
let m: Int = local_date_month(d2)
let day: Int = local_date_day(d2)
return int_to_str(y) + "-" + int_to_str(m) + "-" + int_to_str(day)
}
fn main() -> Void {
println(run_test())
}
+21
View File
@@ -0,0 +1,21 @@
// mars-calendar.el MarsCalendar uses sol period 88775.244 seconds.
// Two instants exactly one sol apart should differ by 1 in sol number.
fn run_test() -> Int {
let cal: Calendar = mars_calendar()
let base: Instant = unix_seconds(0)
// 88775.244 s * 1e9 nanos = 88775244000000 nanos
let one_sol_ns: Int = 88775244000000
let one_sol: Duration = el_duration_from_nanos(one_sol_ns)
let later: Instant = base + one_sol
let ct1: CalendarTime = in_calendar(base, cal)
let ct2: CalendarTime = in_calendar(later, cal)
let sol1: String = cal_format(ct1, "%sol")
let sol2: String = cal_format(ct2, "%sol")
let diff: Int = str_to_int(sol2) - str_to_int(sol1)
return diff
}
fn main() -> Void {
println(int_to_str(run_test()))
}
+18
View File
@@ -0,0 +1,18 @@
// no-cycle.el NoCycleCalendar's cycle_phase returns NaN sentinel.
// Detection via float_to_str NaN renders as "nan" under %g.
fn run_test() -> Int {
let cal: Calendar = no_cycle_calendar()
let i: Instant = unix_seconds(1000000000)
let ct: CalendarTime = in_calendar(i, cal)
let p: Float = cal_cycle_phase(ct)
let s: String = float_to_str(p)
if str_eq(s, "nan") {
return 1
}
return 0
}
fn main() -> Void {
println(int_to_str(run_test()))
}
@@ -0,0 +1,18 @@
// rhythm-cycle-30hr.el rhythm_cycle_phase(0.5) under CycleCalendar(30.hours)
// returns the next instant at the 15-hour mark of the cycle.
fn run_test() -> Int {
let period: Duration = 30.hours
let cal: Calendar = cycle_calendar(period)
let r: Rhythm = rhythm_cycle_phase(0.5)
let base: Instant = unix_seconds(0)
let next: Instant = rhythm_next_after(r, base, cal)
let elapsed_ns: Duration = next - base
let elapsed_secs: Int = duration_to_seconds(elapsed_ns)
// 15 hours = 54000 seconds.
return elapsed_secs
}
fn main() -> Void {
println(int_to_str(run_test()))
}
@@ -0,0 +1,18 @@
// rhythm-grounding.el Mondays at 9am, grounded against EarthCalendar(NYC),
// from a Wednesday timestamp returns the next Monday at 9am EDT.
// Wednesday 2026-05-06 00:00 UTC (1778025600) next Monday 9am EDT
// = 2026-05-11 09:00 EDT = 2026-05-11 13:00 UTC = 1778504400.
fn run_test() -> Int {
let z: Zone = zone("America/New_York")
let cal: Calendar = earth_calendar(z)
let r: Rhythm = rhythm_weekly_at(1, 9, 0)
let after: Instant = unix_seconds(1778025600)
let next: Instant = rhythm_next_after(r, after, cal)
let next_secs: Int = instant_to_unix_seconds(next)
return next_secs
}
fn main() -> Void {
println(int_to_str(run_test()))
}