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
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+872
View File
@@ -3624,6 +3624,878 @@ el_val_t ttl_cache_age(el_val_t key) {
return (el_val_t)(now_ns - set_at);
}
/* ── Calendar + CalendarTime + Rhythm + LocalDate/Time/DateTime ──────────────
* Phase 1.5. Calendar is pluggable: EarthCalendar (IANA zones + Gregorian +
* DST), MarsCalendar (sols, MTC), CycleCalendar(period), NoCycleCalendar,
* RelativeCalendar(epoch). Phase 1 zone wrapping folds INTO EarthCalendar;
* UTC and IANA zones are themselves Earth-parochial and cannot live at the
* lowest type layer.
*
* A Rhythm is a small AST that asks the Calendar for cycle phase, weekday,
* etc. Most rhythm logic is calendar-agnostic at runtime: rhythm_cycle_phase
* means "midpoint of cycle" whether the cycle is 24h on Earth or 30h on a
* station or 300y on a long-cycle world. */
/* Magic headers — used by the runtime to recognize boxed temporal values
* arriving through el_val_t. Distinct constants so accidental misuse fails
* loudly rather than silently. */
#define EL_CAL_MAGIC 0xE1CA1EDDU
#define EL_CALTIME_MAGIC 0xE1CA1747U
#define EL_RHYTHM_MAGIC 0xE1287A11U
#define EL_LDATE_MAGIC 0xE1DA7E00U
#define EL_LDT_MAGIC 0xE1DA7E1DU
#define EL_ZONE_MAGIC 0xE12017E0U
typedef enum {
EL_CALENDAR_EARTH = 1,
EL_CALENDAR_MARS = 2,
EL_CALENDAR_CYCLE = 3,
EL_CALENDAR_NO_CYCLE = 4,
EL_CALENDAR_RELATIVE = 5
} el_calendar_kind_t;
typedef struct {
uint32_t magic;
char* id; /* IANA name or "+HH:MM" / "-HH:MM" */
int fixed; /* 1 for fixed offset, 0 for IANA */
int64_t offset_ns; /* fixed offset in nanos (only when fixed) */
} el_zone_t;
typedef struct {
uint32_t magic;
el_calendar_kind_t kind;
el_zone_t* zone; /* EarthCalendar; MarsCalendar uses MTC */
int64_t cycle_period_ns;/* CycleCalendar; computed for Earth (86400 s) and Mars (88775.244 s) */
int64_t epoch_ns; /* RelativeCalendar; Unix-epoch zero otherwise */
} el_calendar_t;
typedef struct {
uint32_t magic;
int64_t instant_ns;
el_calendar_t* cal;
} el_caltime_t;
/* Rhythm AST. */
typedef enum {
EL_RHYTHM_CYCLE_START = 1,
EL_RHYTHM_CYCLE_PHASE = 2,
EL_RHYTHM_DURATION = 3,
EL_RHYTHM_SESSION_START = 4,
EL_RHYTHM_EVENT = 5,
EL_RHYTHM_AND = 6,
EL_RHYTHM_OR = 7,
EL_RHYTHM_WEEKDAY = 8,
EL_RHYTHM_WEEKLY_AT = 9
} el_rhythm_kind_t;
typedef struct el_rhythm_s {
uint32_t magic;
el_rhythm_kind_t kind;
double phase; /* CYCLE_PHASE */
int64_t period_ns; /* DURATION */
int weekday; /* 1..7 Mon..Sun */
int hour;
int minute;
char* event_name; /* EVENT */
struct el_rhythm_s* a; /* AND/OR */
struct el_rhythm_s* b;
} el_rhythm_t;
typedef struct {
uint32_t magic;
int year;
int month;
int day;
} el_localdate_t;
typedef struct {
uint32_t magic;
el_localdate_t* date;
int64_t time_ns; /* nanos since midnight */
} el_localdt_t;
/* Magic-tag check helpers — peek the first 4 bytes of an el_val_t pointer
* and compare against the expected magic. Strings are NUL-terminated and
* never start with our magic byte sequence, so this is safe. */
static int el_is_magic(el_val_t v, uint32_t want) {
if (v == 0) return 0;
/* Defensive: only follow pointers in plausible address space.
* On 64-bit unix processes pointers are above 0x10000. */
if ((uint64_t)v < 0x10000ULL) return 0;
uint32_t got = *(volatile uint32_t*)(uintptr_t)v;
return got == want;
}
/* Sol length on Mars in nanoseconds: 88775.244 seconds. */
#define EL_MARS_SOL_NS ((int64_t)88775244000000LL)
/* Earth solar day in nanoseconds: 86400 seconds. */
#define EL_EARTH_DAY_NS ((int64_t)86400000000000LL)
/* ── Zone construction ──────────────────────────────────────────────────────
* Zones intern by id string so equality comparisons are pointer-compares. */
#define EL_ZONE_TABLE_CAP 64
static el_zone_t* _el_zone_table[EL_ZONE_TABLE_CAP];
static int _el_zone_count = 0;
static el_zone_t* _el_zone_intern(const char* id, int fixed, int64_t offset_ns) {
for (int i = 0; i < _el_zone_count; i++) {
el_zone_t* z = _el_zone_table[i];
if (z->fixed == fixed && z->offset_ns == offset_ns &&
strcmp(z->id ? z->id : "", id ? id : "") == 0) {
return z;
}
}
if (_el_zone_count >= EL_ZONE_TABLE_CAP) {
/* Out of slots: build a non-interned zone. Equality will fail across
* such zones but the program still runs. */
el_zone_t* z = (el_zone_t*)malloc(sizeof(el_zone_t));
z->magic = EL_ZONE_MAGIC;
z->id = el_strdup_persist(id ? id : "");
z->fixed = fixed;
z->offset_ns = offset_ns;
return z;
}
el_zone_t* z = (el_zone_t*)malloc(sizeof(el_zone_t));
z->magic = EL_ZONE_MAGIC;
z->id = el_strdup_persist(id ? id : "");
z->fixed = fixed;
z->offset_ns = offset_ns;
_el_zone_table[_el_zone_count++] = z;
return z;
}
el_val_t zone(el_val_t id) {
const char* s = EL_CSTR(id);
if (!s || !*s) return (el_val_t)(uintptr_t)_el_zone_intern("UTC", 0, 0);
/* Fixed-offset shortcut: "+HH:MM" or "-HH:MM". */
if ((s[0] == '+' || s[0] == '-') && strlen(s) >= 6 && s[3] == ':') {
int sign = (s[0] == '-') ? -1 : 1;
int hh = (s[1] - '0') * 10 + (s[2] - '0');
int mm = (s[4] - '0') * 10 + (s[5] - '0');
int64_t off = (int64_t)sign * ((int64_t)hh * 3600LL + (int64_t)mm * 60LL) * 1000000000LL;
return (el_val_t)(uintptr_t)_el_zone_intern(s, 1, off);
}
return (el_val_t)(uintptr_t)_el_zone_intern(s, 0, 0);
}
el_val_t zone_utc(void) {
return (el_val_t)(uintptr_t)_el_zone_intern("UTC", 1, 0);
}
el_val_t zone_local(void) {
/* Resolve the local zone via TZ env or system default. tzset() picks
* up TZ if set; otherwise the C library reads /etc/localtime. We store
* the zone id as "LOCAL" so subsequent equality holds; resolution is
* lazy at use time. */
return (el_val_t)(uintptr_t)_el_zone_intern("LOCAL", 0, 0);
}
el_val_t zone_offset(el_val_t hours, el_val_t minutes) {
int hh = (int)(int64_t)hours;
int mm = (int)(int64_t)minutes;
int sign = (hh < 0 || mm < 0) ? -1 : 1;
if (hh < 0) hh = -hh;
if (mm < 0) mm = -mm;
int64_t off = (int64_t)sign * ((int64_t)hh * 3600LL + (int64_t)mm * 60LL) * 1000000000LL;
char buf[16];
snprintf(buf, sizeof(buf), "%c%02d:%02d", sign < 0 ? '-' : '+', hh, mm);
return (el_val_t)(uintptr_t)_el_zone_intern(buf, 1, off);
}
/* ── Calendar interning ──────────────────────────────────────────────────── */
#define EL_CAL_TABLE_CAP 64
static el_calendar_t* _el_cal_table[EL_CAL_TABLE_CAP];
static int _el_cal_count = 0;
static el_calendar_t* _el_cal_intern(el_calendar_kind_t kind, el_zone_t* z,
int64_t period_ns, int64_t epoch_ns) {
for (int i = 0; i < _el_cal_count; i++) {
el_calendar_t* c = _el_cal_table[i];
if (c->kind == kind && c->zone == z &&
c->cycle_period_ns == period_ns && c->epoch_ns == epoch_ns) {
return c;
}
}
el_calendar_t* c = (el_calendar_t*)malloc(sizeof(el_calendar_t));
c->magic = EL_CAL_MAGIC;
c->kind = kind;
c->zone = z;
c->cycle_period_ns = period_ns;
c->epoch_ns = epoch_ns;
if (_el_cal_count < EL_CAL_TABLE_CAP) _el_cal_table[_el_cal_count++] = c;
return c;
}
el_val_t earth_calendar(el_val_t z_val) {
el_zone_t* z = NULL;
if (z_val != 0 && el_is_magic(z_val, EL_ZONE_MAGIC)) {
z = (el_zone_t*)(uintptr_t)z_val;
} else {
z = (el_zone_t*)(uintptr_t)zone_local();
}
return (el_val_t)(uintptr_t)_el_cal_intern(EL_CALENDAR_EARTH, z, EL_EARTH_DAY_NS, 0);
}
el_val_t earth_calendar_default(void) {
return earth_calendar(zone_local());
}
el_val_t mars_calendar(void) {
el_zone_t* z = (el_zone_t*)(uintptr_t)_el_zone_intern("MTC", 1, 0);
return (el_val_t)(uintptr_t)_el_cal_intern(EL_CALENDAR_MARS, z, EL_MARS_SOL_NS, 0);
}
el_val_t cycle_calendar(el_val_t period_dur) {
int64_t period = (int64_t)period_dur;
if (period <= 0) period = 1;
return (el_val_t)(uintptr_t)_el_cal_intern(EL_CALENDAR_CYCLE, NULL, period, 0);
}
el_val_t no_cycle_calendar(void) {
return (el_val_t)(uintptr_t)_el_cal_intern(EL_CALENDAR_NO_CYCLE, NULL, 0, 0);
}
el_val_t relative_calendar(el_val_t epoch_inst) {
int64_t ep = (int64_t)epoch_inst;
return (el_val_t)(uintptr_t)_el_cal_intern(EL_CALENDAR_RELATIVE, NULL, 0, ep);
}
/* ── CalendarTime ───────────────────────────────────────────────────────── */
static el_caltime_t* _el_caltime_alloc(int64_t inst, el_calendar_t* c) {
el_caltime_t* ct = (el_caltime_t*)malloc(sizeof(el_caltime_t));
ct->magic = EL_CALTIME_MAGIC;
ct->instant_ns = inst;
ct->cal = c;
return ct;
}
static el_calendar_t* _el_resolve_cal(el_val_t cal_val) {
if (cal_val == 0 || !el_is_magic(cal_val, EL_CAL_MAGIC)) {
return (el_calendar_t*)(uintptr_t)earth_calendar_default();
}
return (el_calendar_t*)(uintptr_t)cal_val;
}
el_val_t now_in(el_val_t cal_val) {
el_calendar_t* c = _el_resolve_cal(cal_val);
int64_t ns = (int64_t)el_now_instant();
return (el_val_t)(uintptr_t)_el_caltime_alloc(ns, c);
}
el_val_t in_calendar(el_val_t inst, el_val_t cal_val) {
el_calendar_t* c = _el_resolve_cal(cal_val);
return (el_val_t)(uintptr_t)_el_caltime_alloc((int64_t)inst, c);
}
el_val_t cal_to_instant(el_val_t ct_val) {
if (!el_is_magic(ct_val, EL_CALTIME_MAGIC)) return (el_val_t)0;
el_caltime_t* ct = (el_caltime_t*)(uintptr_t)ct_val;
return (el_val_t)ct->instant_ns;
}
el_val_t cal_in(el_val_t ct_val, el_val_t cal_val) {
if (!el_is_magic(ct_val, EL_CALTIME_MAGIC)) return (el_val_t)0;
el_caltime_t* ct = (el_caltime_t*)(uintptr_t)ct_val;
el_calendar_t* c = _el_resolve_cal(cal_val);
return (el_val_t)(uintptr_t)_el_caltime_alloc(ct->instant_ns, c);
}
el_val_t cal_cycle_phase(el_val_t ct_val) {
if (!el_is_magic(ct_val, EL_CALTIME_MAGIC)) return el_from_float(0.0);
el_caltime_t* ct = (el_caltime_t*)(uintptr_t)ct_val;
el_calendar_t* c = ct->cal;
if (c->kind == EL_CALENDAR_NO_CYCLE) {
return el_from_float(0.0/0.0); /* NaN sentinel */
}
int64_t period = c->cycle_period_ns;
if (period <= 0) return el_from_float(0.0);
int64_t base = ct->instant_ns - c->epoch_ns;
int64_t phase_ns = base % period;
if (phase_ns < 0) phase_ns += period;
double phase = (double)phase_ns / (double)period;
return el_from_float(phase);
}
/* ── Earth zone resolution: TZ-based offset lookup ──────────────────────────
* For an EarthCalendar(zone), we want to convert an instant_ns into local
* y/m/d/h/m/s, including DST. Approach: setenv("TZ", id), tzset(), use
* localtime_r, then restore. This is not thread-safe by design El's
* runtime is single-threaded for the request handler path. Cache the
* computed (instant -> tm) to avoid the syscall churn on repeat formats. */
static void _el_apply_zone(el_zone_t* z) {
if (!z) { unsetenv("TZ"); tzset(); return; }
if (z->fixed && strcmp(z->id, "UTC") == 0) {
setenv("TZ", "UTC0", 1);
tzset();
return;
}
if (z->fixed) {
/* Fixed offset: POSIX TZ uses inverted sign (sign convention of
* "hours WEST of UTC" rather than east). Build the spec accordingly. */
char buf[32];
int neg_secs = (int)(-z->offset_ns / 1000000000LL);
int sign = neg_secs < 0 ? -1 : 1;
int abs_secs = neg_secs < 0 ? -neg_secs : neg_secs;
int hh = abs_secs / 3600;
int mm = (abs_secs % 3600) / 60;
snprintf(buf, sizeof(buf), "FIX%c%d:%02d", sign < 0 ? '-' : '+', hh, mm);
setenv("TZ", buf, 1);
tzset();
return;
}
if (strcmp(z->id, "LOCAL") == 0) {
unsetenv("TZ");
tzset();
return;
}
setenv("TZ", z->id, 1);
tzset();
}
static int _el_decompose_earth(el_caltime_t* ct, struct tm* tm_out, int* abbr_len, char* abbr_buf, size_t abbr_cap) {
el_calendar_t* c = ct->cal;
el_zone_t* z = c->zone;
_el_apply_zone(z);
time_t s = (time_t)(ct->instant_ns / 1000000000LL);
struct tm tm;
localtime_r(&s, &tm);
*tm_out = tm;
if (abbr_buf && abbr_cap > 0) {
const char* z_str = tm.tm_zone ? tm.tm_zone : "";
size_t n = strlen(z_str);
if (n >= abbr_cap) n = abbr_cap - 1;
memcpy(abbr_buf, z_str, n);
abbr_buf[n] = '\0';
if (abbr_len) *abbr_len = (int)n;
}
return 0;
}
/* Format an Earth CalendarTime under a Java-DateTimeFormatter-ish pattern.
* We support a useful core: yyyy MM dd HH mm ss z EEE MMM d h a enough for
* the acceptance tests. Single quotes denote literal text. */
static const char* _el_weekday_short[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"};
static const char* _el_month_short[] = {"Jan","Feb","Mar","Apr","May","Jun",
"Jul","Aug","Sep","Oct","Nov","Dec"};
static char* _el_format_earth(el_caltime_t* ct, const char* pattern) {
struct tm tm;
char abbr[16] = {0};
int abbr_len = 0;
_el_decompose_earth(ct, &tm, &abbr_len, abbr, sizeof(abbr));
size_t cap = strlen(pattern) * 4 + 64;
char* out = (char*)malloc(cap);
size_t pos = 0;
size_t i = 0;
size_t plen = strlen(pattern);
while (i < plen) {
char ch = pattern[i];
/* Quoted literal */
if (ch == '\'') {
i++;
while (i < plen && pattern[i] != '\'') {
if (pos + 1 >= cap) { cap *= 2; out = realloc(out, cap); }
out[pos++] = pattern[i++];
}
if (i < plen) i++;
continue;
}
/* Count run of same letter */
size_t run = 1;
while (i + run < plen && pattern[i + run] == ch) run++;
char tmp[64];
tmp[0] = '\0';
if (ch == 'y') {
if (run >= 4) snprintf(tmp, sizeof(tmp), "%04d", tm.tm_year + 1900);
else snprintf(tmp, sizeof(tmp), "%02d", (tm.tm_year + 1900) % 100);
} else if (ch == 'M') {
if (run >= 3) snprintf(tmp, sizeof(tmp), "%s", _el_month_short[tm.tm_mon]);
else if (run == 2) snprintf(tmp, sizeof(tmp), "%02d", tm.tm_mon + 1);
else snprintf(tmp, sizeof(tmp), "%d", tm.tm_mon + 1);
} else if (ch == 'd') {
if (run >= 2) snprintf(tmp, sizeof(tmp), "%02d", tm.tm_mday);
else snprintf(tmp, sizeof(tmp), "%d", tm.tm_mday);
} else if (ch == 'H') {
if (run >= 2) snprintf(tmp, sizeof(tmp), "%02d", tm.tm_hour);
else snprintf(tmp, sizeof(tmp), "%d", tm.tm_hour);
} else if (ch == 'h') {
int h12 = tm.tm_hour % 12; if (h12 == 0) h12 = 12;
if (run >= 2) snprintf(tmp, sizeof(tmp), "%02d", h12);
else snprintf(tmp, sizeof(tmp), "%d", h12);
} else if (ch == 'm') {
if (run >= 2) snprintf(tmp, sizeof(tmp), "%02d", tm.tm_min);
else snprintf(tmp, sizeof(tmp), "%d", tm.tm_min);
} else if (ch == 's') {
if (run >= 2) snprintf(tmp, sizeof(tmp), "%02d", tm.tm_sec);
else snprintf(tmp, sizeof(tmp), "%d", tm.tm_sec);
} else if (ch == 'a') {
snprintf(tmp, sizeof(tmp), "%s", tm.tm_hour < 12 ? "AM" : "PM");
} else if (ch == 'E') {
snprintf(tmp, sizeof(tmp), "%s", _el_weekday_short[tm.tm_wday]);
} else if (ch == 'z') {
snprintf(tmp, sizeof(tmp), "%s", abbr);
} else {
for (size_t k = 0; k < run; k++) {
if (pos + 1 >= cap) { cap *= 2; out = realloc(out, cap); }
out[pos++] = ch;
}
i += run;
continue;
}
size_t tl = strlen(tmp);
if (pos + tl + 1 >= cap) { cap = (cap + tl) * 2; out = realloc(out, cap); }
memcpy(out + pos, tmp, tl);
pos += tl;
i += run;
}
out[pos] = '\0';
char* result = el_strdup(out);
free(out);
return result;
}
/* Format a Mars CalendarTime: %sol prints the integer sol number since
* mission epoch (Unix epoch fallback), %phase prints cycle_phase as a
* 0..1 decimal. Other %-specifiers fall through. */
static char* _el_format_mars(el_caltime_t* ct, const char* pattern) {
el_calendar_t* c = ct->cal;
int64_t period = c->cycle_period_ns > 0 ? c->cycle_period_ns : EL_MARS_SOL_NS;
int64_t base = ct->instant_ns - c->epoch_ns;
int64_t sol = base / period;
int64_t phase_ns = base % period;
if (phase_ns < 0) { phase_ns += period; sol -= 1; }
double phase = (double)phase_ns / (double)period;
size_t cap = strlen(pattern) * 4 + 64;
char* out = (char*)malloc(cap);
size_t pos = 0;
for (size_t i = 0; pattern[i]; i++) {
if (pattern[i] == '%' && pattern[i+1]) {
char tmp[64];
tmp[0] = '\0';
if (strncmp(pattern + i + 1, "sol", 3) == 0) {
snprintf(tmp, sizeof(tmp), "%lld", (long long)sol);
i += 3;
} else if (strncmp(pattern + i + 1, "phase", 5) == 0) {
snprintf(tmp, sizeof(tmp), "%.4f", phase);
i += 5;
} else if (pattern[i+1] == 'd') {
snprintf(tmp, sizeof(tmp), "%lld", (long long)sol);
i += 1;
} else {
tmp[0] = pattern[i+1]; tmp[1] = '\0';
i += 1;
}
size_t tl = strlen(tmp);
if (pos + tl + 1 >= cap) { cap = (cap + tl) * 2; out = realloc(out, cap); }
memcpy(out + pos, tmp, tl);
pos += tl;
} else {
if (pos + 1 >= cap) { cap *= 2; out = realloc(out, cap); }
out[pos++] = pattern[i];
}
}
out[pos] = '\0';
char* result = el_strdup(out);
free(out);
return result;
}
/* Format a CycleCalendar CalendarTime: %cycle and %phase. */
static char* _el_format_cycle(el_caltime_t* ct, const char* pattern) {
el_calendar_t* c = ct->cal;
int64_t period = c->cycle_period_ns > 0 ? c->cycle_period_ns : 1;
int64_t base = ct->instant_ns - c->epoch_ns;
int64_t cycle = base / period;
int64_t phase_ns = base % period;
if (phase_ns < 0) { phase_ns += period; cycle -= 1; }
double phase = (double)phase_ns / (double)period;
size_t cap = strlen(pattern) * 4 + 64;
char* out = (char*)malloc(cap);
size_t pos = 0;
for (size_t i = 0; pattern[i]; i++) {
if (pattern[i] == '%' && pattern[i+1]) {
char tmp[64];
tmp[0] = '\0';
if (strncmp(pattern + i + 1, "cycle", 5) == 0) {
snprintf(tmp, sizeof(tmp), "%lld", (long long)cycle);
i += 5;
} else if (strncmp(pattern + i + 1, "phase", 5) == 0) {
snprintf(tmp, sizeof(tmp), "%.4f", phase);
i += 5;
} else if (pattern[i+1] == 'd') {
snprintf(tmp, sizeof(tmp), "%lld", (long long)cycle);
i += 1;
} else if (pattern[i+1] == 'f') {
snprintf(tmp, sizeof(tmp), "%.2f", phase);
i += 1;
} else {
/* Pass through unknown specifier */
tmp[0] = '%'; tmp[1] = pattern[i+1]; tmp[2] = '\0';
i += 1;
}
size_t tl = strlen(tmp);
if (pos + tl + 1 >= cap) { cap = (cap + tl) * 2; out = realloc(out, cap); }
memcpy(out + pos, tmp, tl);
pos += tl;
} else {
if (pos + 1 >= cap) { cap *= 2; out = realloc(out, cap); }
out[pos++] = pattern[i];
}
}
out[pos] = '\0';
char* result = el_strdup(out);
free(out);
return result;
}
el_val_t cal_format(el_val_t ct_val, el_val_t pattern_val) {
if (!el_is_magic(ct_val, EL_CALTIME_MAGIC)) return el_wrap_str(el_strdup(""));
el_caltime_t* ct = (el_caltime_t*)(uintptr_t)ct_val;
const char* pat = EL_CSTR(pattern_val);
if (!pat) pat = "";
char* result = NULL;
switch (ct->cal->kind) {
case EL_CALENDAR_EARTH: result = _el_format_earth(ct, pat); break;
case EL_CALENDAR_MARS: result = _el_format_mars(ct, pat); break;
case EL_CALENDAR_CYCLE: result = _el_format_cycle(ct, pat); break;
case EL_CALENDAR_RELATIVE: result = _el_format_cycle(ct, pat); break;
case EL_CALENDAR_NO_CYCLE: {
char buf[64];
snprintf(buf, sizeof(buf), "instant:%lld", (long long)ct->instant_ns);
result = el_strdup(buf);
break;
}
default: result = el_strdup("");
}
return el_wrap_str(result);
}
/* ── LocalDate / LocalTime / LocalDateTime ──────────────────────────────── */
static int _el_days_in_month(int y, int m) {
static const int dim[12] = {31,28,31,30,31,30,31,31,30,31,30,31};
if (m == 2) {
int leap = ((y % 4 == 0) && (y % 100 != 0)) || (y % 400 == 0);
return 28 + (leap ? 1 : 0);
}
if (m < 1 || m > 12) return 30;
return dim[m - 1];
}
el_val_t local_date(el_val_t y, el_val_t m, el_val_t d) {
el_localdate_t* ld = (el_localdate_t*)malloc(sizeof(el_localdate_t));
ld->magic = EL_LDATE_MAGIC;
ld->year = (int)(int64_t)y;
ld->month = (int)(int64_t)m;
ld->day = (int)(int64_t)d;
return (el_val_t)(uintptr_t)ld;
}
el_val_t local_time(el_val_t h, el_val_t m, el_val_t s, el_val_t ns) {
int64_t hh = (int64_t)h;
int64_t mm = (int64_t)m;
int64_t ss = (int64_t)s;
int64_t nn = (int64_t)ns;
int64_t total = hh * 3600000000000LL + mm * 60000000000LL + ss * 1000000000LL + nn;
return (el_val_t)total;
}
el_val_t local_datetime(el_val_t date_val, el_val_t time_val) {
if (!el_is_magic(date_val, EL_LDATE_MAGIC)) return (el_val_t)0;
el_localdt_t* ldt = (el_localdt_t*)malloc(sizeof(el_localdt_t));
ldt->magic = EL_LDT_MAGIC;
ldt->date = (el_localdate_t*)(uintptr_t)date_val;
ldt->time_ns = (int64_t)time_val;
return (el_val_t)(uintptr_t)ldt;
}
el_val_t zoned(el_val_t date_val, el_val_t time_val, el_val_t cal_val) {
if (!el_is_magic(date_val, EL_LDATE_MAGIC)) return (el_val_t)0;
el_localdate_t* ld = (el_localdate_t*)(uintptr_t)date_val;
el_calendar_t* c = _el_resolve_cal(cal_val);
int64_t time_ns = (int64_t)time_val;
/* Convert (LocalDate, LocalTime, EarthCalendar) -> Instant.
* For non-Earth calendars we use day-anchored conversion: treat the
* LocalDate's (y,m,d) as a Gregorian projection, convert to seconds via
* mktime under the calendar's zone, then add nanos-since-midnight. */
if (c->kind == EL_CALENDAR_EARTH) {
_el_apply_zone(c->zone);
struct tm tm; memset(&tm, 0, sizeof(tm));
tm.tm_year = ld->year - 1900;
tm.tm_mon = ld->month - 1;
tm.tm_mday = ld->day;
tm.tm_hour = (int)(time_ns / 3600000000000LL);
tm.tm_min = (int)((time_ns / 60000000000LL) % 60);
tm.tm_sec = (int)((time_ns / 1000000000LL) % 60);
tm.tm_isdst = -1;
time_t t = mktime(&tm);
if (t == (time_t)-1) return (el_val_t)0;
int64_t ns = (int64_t)t * 1000000000LL + (time_ns % 1000000000LL);
return (el_val_t)(uintptr_t)_el_caltime_alloc(ns, c);
}
/* Non-Earth fallback: project as if Earth UTC then attach calendar. */
struct tm tm; memset(&tm, 0, sizeof(tm));
tm.tm_year = ld->year - 1900;
tm.tm_mon = ld->month - 1;
tm.tm_mday = ld->day;
tm.tm_hour = (int)(time_ns / 3600000000000LL);
tm.tm_min = (int)((time_ns / 60000000000LL) % 60);
tm.tm_sec = (int)((time_ns / 1000000000LL) % 60);
time_t t = timegm(&tm);
if (t == (time_t)-1) return (el_val_t)0;
int64_t ns = (int64_t)t * 1000000000LL + (time_ns % 1000000000LL);
return (el_val_t)(uintptr_t)_el_caltime_alloc(ns, c);
}
el_val_t local_date_year(el_val_t v) {
if (!el_is_magic(v, EL_LDATE_MAGIC)) return (el_val_t)0;
return (el_val_t)((el_localdate_t*)(uintptr_t)v)->year;
}
el_val_t local_date_month(el_val_t v) {
if (!el_is_magic(v, EL_LDATE_MAGIC)) return (el_val_t)0;
return (el_val_t)((el_localdate_t*)(uintptr_t)v)->month;
}
el_val_t local_date_day(el_val_t v) {
if (!el_is_magic(v, EL_LDATE_MAGIC)) return (el_val_t)0;
return (el_val_t)((el_localdate_t*)(uintptr_t)v)->day;
}
el_val_t local_time_hour(el_val_t v) {
int64_t t = (int64_t)v;
return (el_val_t)(t / 3600000000000LL);
}
el_val_t local_time_minute(el_val_t v) {
int64_t t = (int64_t)v;
return (el_val_t)((t / 60000000000LL) % 60);
}
el_val_t local_time_second(el_val_t v) {
int64_t t = (int64_t)v;
return (el_val_t)((t / 1000000000LL) % 60);
}
el_val_t local_time_nanos(el_val_t v) {
int64_t t = (int64_t)v;
return (el_val_t)(t % 1000000000LL);
}
el_val_t el_local_date_add_dur(el_val_t ld_val, el_val_t dur_val) {
if (!el_is_magic(ld_val, EL_LDATE_MAGIC)) return ld_val;
el_localdate_t* ld = (el_localdate_t*)(uintptr_t)ld_val;
int64_t dur_ns = (int64_t)dur_val;
int64_t days = dur_ns / EL_EARTH_DAY_NS;
int y = ld->year, m = ld->month, d = ld->day;
/* Walk days forward/backward in canonical Gregorian. */
while (days > 0) {
int dim = _el_days_in_month(y, m);
if (d + days <= dim) { d += (int)days; days = 0; break; }
days -= (dim - d + 1);
d = 1;
m++;
if (m > 12) { m = 1; y++; }
}
while (days < 0) {
if (d + days >= 1) { d += (int)days; days = 0; break; }
days += d;
m--;
if (m < 1) { m = 12; y--; }
d = _el_days_in_month(y, m);
}
return local_date((el_val_t)y, (el_val_t)m, (el_val_t)d);
}
el_val_t el_local_time_add_dur(el_val_t lt_val, el_val_t dur_val) {
int64_t t = (int64_t)lt_val + (int64_t)dur_val;
/* Wrap mod 24h on Earth-default. CycleCalendar wrapping requires the
* caller to use cal_in / cal_format for the right modulus. */
int64_t day = EL_EARTH_DAY_NS;
int64_t r = t % day;
if (r < 0) r += day;
return (el_val_t)r;
}
el_val_t el_local_date_lt(el_val_t a_val, el_val_t b_val) {
if (!el_is_magic(a_val, EL_LDATE_MAGIC) || !el_is_magic(b_val, EL_LDATE_MAGIC)) return (el_val_t)0;
el_localdate_t* a = (el_localdate_t*)(uintptr_t)a_val;
el_localdate_t* b = (el_localdate_t*)(uintptr_t)b_val;
if (a->year != b->year) return (el_val_t)(a->year < b->year ? 1 : 0);
if (a->month != b->month) return (el_val_t)(a->month < b->month ? 1 : 0);
return (el_val_t)(a->day < b->day ? 1 : 0);
}
el_val_t el_local_date_eq(el_val_t a_val, el_val_t b_val) {
if (!el_is_magic(a_val, EL_LDATE_MAGIC) || !el_is_magic(b_val, EL_LDATE_MAGIC)) return (el_val_t)0;
el_localdate_t* a = (el_localdate_t*)(uintptr_t)a_val;
el_localdate_t* b = (el_localdate_t*)(uintptr_t)b_val;
return (el_val_t)((a->year == b->year && a->month == b->month && a->day == b->day) ? 1 : 0);
}
/* ── Rhythm ──────────────────────────────────────────────────────────────── */
static el_rhythm_t* _el_rhythm_alloc(el_rhythm_kind_t k) {
el_rhythm_t* r = (el_rhythm_t*)calloc(1, sizeof(el_rhythm_t));
r->magic = EL_RHYTHM_MAGIC;
r->kind = k;
return r;
}
el_val_t rhythm_cycle_start(void) {
return (el_val_t)(uintptr_t)_el_rhythm_alloc(EL_RHYTHM_CYCLE_START);
}
el_val_t rhythm_cycle_phase(el_val_t phase_val) {
el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_CYCLE_PHASE);
r->phase = el_to_float(phase_val);
return (el_val_t)(uintptr_t)r;
}
el_val_t rhythm_duration(el_val_t d_val) {
el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_DURATION);
r->period_ns = (int64_t)d_val;
return (el_val_t)(uintptr_t)r;
}
el_val_t rhythm_session_start(void) {
return (el_val_t)(uintptr_t)_el_rhythm_alloc(EL_RHYTHM_SESSION_START);
}
el_val_t rhythm_event(el_val_t name_val) {
el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_EVENT);
const char* n = EL_CSTR(name_val);
r->event_name = el_strdup_persist(n ? n : "");
return (el_val_t)(uintptr_t)r;
}
el_val_t rhythm_and(el_val_t a_val, el_val_t b_val) {
el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_AND);
r->a = el_is_magic(a_val, EL_RHYTHM_MAGIC) ? (el_rhythm_t*)(uintptr_t)a_val : NULL;
r->b = el_is_magic(b_val, EL_RHYTHM_MAGIC) ? (el_rhythm_t*)(uintptr_t)b_val : NULL;
return (el_val_t)(uintptr_t)r;
}
el_val_t rhythm_or(el_val_t a_val, el_val_t b_val) {
el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_OR);
r->a = el_is_magic(a_val, EL_RHYTHM_MAGIC) ? (el_rhythm_t*)(uintptr_t)a_val : NULL;
r->b = el_is_magic(b_val, EL_RHYTHM_MAGIC) ? (el_rhythm_t*)(uintptr_t)b_val : NULL;
return (el_val_t)(uintptr_t)r;
}
el_val_t rhythm_weekday(el_val_t day) {
el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_WEEKDAY);
r->weekday = (int)(int64_t)day;
return (el_val_t)(uintptr_t)r;
}
el_val_t rhythm_weekly_at(el_val_t day, el_val_t hour, el_val_t minute) {
el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_WEEKLY_AT);
r->weekday = (int)(int64_t)day;
r->hour = (int)(int64_t)hour;
r->minute = (int)(int64_t)minute;
return (el_val_t)(uintptr_t)r;
}
/* Compute the next instant on or after `after` when rhythm `r` matches,
* under calendar `cal`. */
static int64_t _el_next_after(el_rhythm_t* r, int64_t after_ns, el_calendar_t* cal) {
if (!r) return after_ns;
int64_t period = cal->cycle_period_ns > 0 ? cal->cycle_period_ns : EL_EARTH_DAY_NS;
switch (r->kind) {
case EL_RHYTHM_CYCLE_START: {
int64_t base = after_ns - cal->epoch_ns;
int64_t cyc = (base / period) + 1;
return cal->epoch_ns + cyc * period;
}
case EL_RHYTHM_CYCLE_PHASE: {
int64_t base = after_ns - cal->epoch_ns;
int64_t cyc_ns = (int64_t)(r->phase * (double)period);
int64_t cur_cyc = base / period;
int64_t candidate = cal->epoch_ns + cur_cyc * period + cyc_ns;
if (candidate <= after_ns) candidate += period;
return candidate;
}
case EL_RHYTHM_DURATION: {
return after_ns + (r->period_ns > 0 ? r->period_ns : 1);
}
case EL_RHYTHM_WEEKDAY:
case EL_RHYTHM_WEEKLY_AT: {
if (cal->kind != EL_CALENDAR_EARTH) {
/* Non-Earth calendars: fall back to cycle math, treating
* weekday as a 7-cycle-per-period proxy. */
return after_ns + period;
}
_el_apply_zone(cal->zone);
time_t s = (time_t)(after_ns / 1000000000LL);
struct tm tm;
localtime_r(&s, &tm);
/* tm_wday: 0=Sun..6=Sat. We use 1=Mon..7=Sun. */
int target = r->weekday >= 1 && r->weekday <= 7 ? r->weekday : 1;
int target_wday = target == 7 ? 0 : target; /* 7→Sun=0, 1→Mon=1 */
int days_ahead = (target_wday - tm.tm_wday + 7) % 7;
int hour = (r->kind == EL_RHYTHM_WEEKLY_AT) ? r->hour : 0;
int minute = (r->kind == EL_RHYTHM_WEEKLY_AT) ? r->minute : 0;
struct tm cand = tm;
cand.tm_mday += days_ahead;
cand.tm_hour = hour;
cand.tm_min = minute;
cand.tm_sec = 0;
cand.tm_isdst = -1;
time_t cand_t = mktime(&cand);
int64_t cand_ns = (int64_t)cand_t * 1000000000LL;
if (cand_ns <= after_ns) {
cand.tm_mday += 7;
cand.tm_isdst = -1;
cand_t = mktime(&cand);
cand_ns = (int64_t)cand_t * 1000000000LL;
}
return cand_ns;
}
case EL_RHYTHM_AND: {
int64_t a = _el_next_after(r->a, after_ns, cal);
int64_t b = _el_next_after(r->b, after_ns, cal);
return a > b ? a : b;
}
case EL_RHYTHM_OR: {
int64_t a = _el_next_after(r->a, after_ns, cal);
int64_t b = _el_next_after(r->b, after_ns, cal);
return a < b ? a : b;
}
case EL_RHYTHM_SESSION_START:
case EL_RHYTHM_EVENT:
default:
return after_ns;
}
}
el_val_t rhythm_next_after(el_val_t r_val, el_val_t after_val, el_val_t cal_val) {
if (!el_is_magic(r_val, EL_RHYTHM_MAGIC)) return after_val;
el_rhythm_t* r = (el_rhythm_t*)(uintptr_t)r_val;
el_calendar_t* c = _el_resolve_cal(cal_val);
int64_t out = _el_next_after(r, (int64_t)after_val, c);
return (el_val_t)out;
}
el_val_t rhythm_matches(el_val_t r_val, el_val_t ct_val) {
if (!el_is_magic(r_val, EL_RHYTHM_MAGIC)) return (el_val_t)0;
if (!el_is_magic(ct_val, EL_CALTIME_MAGIC)) return (el_val_t)0;
el_rhythm_t* r = (el_rhythm_t*)(uintptr_t)r_val;
el_caltime_t* ct = (el_caltime_t*)(uintptr_t)ct_val;
int64_t period = ct->cal->cycle_period_ns > 0 ? ct->cal->cycle_period_ns : EL_EARTH_DAY_NS;
int64_t base = ct->instant_ns - ct->cal->epoch_ns;
int64_t phase_ns = base % period;
if (phase_ns < 0) phase_ns += period;
double phase = (double)phase_ns / (double)period;
switch (r->kind) {
case EL_RHYTHM_CYCLE_START: return (el_val_t)(phase_ns == 0 ? 1 : 0);
case EL_RHYTHM_CYCLE_PHASE: {
double diff = phase - r->phase;
if (diff < 0) diff = -diff;
return (el_val_t)(diff < 0.001 ? 1 : 0);
}
default: return (el_val_t)0;
}
}
/* ── UUID v4 ─────────────────────────────────────────────────────────────── */
static int _el_uuid_seeded = 0;
+83
View File
@@ -316,6 +316,89 @@ el_val_t ttl_cache_set(el_val_t key, el_val_t value);
el_val_t ttl_cache_get(el_val_t key, el_val_t max_age);
el_val_t ttl_cache_age(el_val_t key);
/* ── Calendar + CalendarTime + Rhythm + LocalDate/Time/DateTime ─────────────
* Phase 1.5 of the time system. Calendar is pluggable: EarthCalendar (IANA
* zones, Gregorian, DST) is the user-facing default; MarsCalendar,
* CycleCalendar(period), NoCycleCalendar, RelativeCalendar handle non-Earth
* domains.
*
* A Calendar interprets an Instant under a particular cycle convention and
* produces a CalendarTime. CalendarTime carries the underlying Instant and
* a back-pointer to its Calendar; arithmetic and formatting consult the
* Calendar to convert ns since epoch into year/month/day/hour/minute/second
* (or sol/phase, or cycle/phase, depending on kind).
*
* Storage convention: Calendar / CalendarTime / Rhythm / LocalDate /
* LocalDateTime are heap-allocated structs whose pointers are cast into
* el_val_t. A 24-bit magic header at offset 0 lets the runtime identify
* the kind safely. LocalTime is small enough to live in the int64 slot
* directly (nanos since midnight, signed). */
/* Zone — opaque IANA zone or fixed offset, used by EarthCalendar.
* `zone_id` is either an IANA name ("America/New_York", "UTC") or a fixed
* offset string ("+05:30", "-08:00"). The runtime resolves it via tzset()
* on first use of the owning EarthCalendar. */
el_val_t zone(el_val_t id);
el_val_t zone_utc(void);
el_val_t zone_local(void);
el_val_t zone_offset(el_val_t hours, el_val_t minutes);
/* Calendar constructors. Each returns an el_val_t pointer to a heap-
* allocated, magic-tagged Calendar struct. Calendars are interned by
* (kind, zone_id, period_ns, epoch_ns) so identical constructors return
* the same pointer — equality is reference equality. */
el_val_t earth_calendar(el_val_t z);
el_val_t earth_calendar_default(void);
el_val_t mars_calendar(void);
el_val_t cycle_calendar(el_val_t period_dur);
el_val_t no_cycle_calendar(void);
el_val_t relative_calendar(el_val_t epoch_inst);
/* CalendarTime constructors and methods. Returns a heap-allocated struct
* whose pointer fits in el_val_t. */
el_val_t now_in(el_val_t cal);
el_val_t in_calendar(el_val_t inst, el_val_t cal);
el_val_t cal_format(el_val_t ct, el_val_t pattern);
el_val_t cal_to_instant(el_val_t ct);
el_val_t cal_cycle_phase(el_val_t ct);
el_val_t cal_in(el_val_t ct, el_val_t cal);
/* LocalDate / LocalTime / LocalDateTime — calendar-agnostic value types.
* LocalTime carries nanoseconds since midnight as a signed int64 directly
* in the el_val_t slot (no allocation). LocalDate / LocalDateTime are
* heap-allocated structs with magic headers. */
el_val_t local_date(el_val_t y, el_val_t m, el_val_t d);
el_val_t local_time(el_val_t h, el_val_t m, el_val_t s, el_val_t ns);
el_val_t local_datetime(el_val_t date, el_val_t time);
el_val_t zoned(el_val_t date, el_val_t time, el_val_t cal);
el_val_t local_date_year(el_val_t ld);
el_val_t local_date_month(el_val_t ld);
el_val_t local_date_day(el_val_t ld);
el_val_t local_time_hour(el_val_t lt);
el_val_t local_time_minute(el_val_t lt);
el_val_t local_time_second(el_val_t lt);
el_val_t local_time_nanos(el_val_t lt);
el_val_t el_local_date_add_dur(el_val_t ld, el_val_t dur);
el_val_t el_local_time_add_dur(el_val_t lt, el_val_t dur);
el_val_t el_local_date_lt(el_val_t a, el_val_t b);
el_val_t el_local_date_eq(el_val_t a, el_val_t b);
/* Rhythm — pluggable recurrence AST. Returns a heap-allocated struct
* pointer in el_val_t; rhythms are immutable so callers may share them. */
el_val_t rhythm_cycle_start(void);
el_val_t rhythm_cycle_phase(el_val_t phase);
el_val_t rhythm_duration(el_val_t d);
el_val_t rhythm_session_start(void);
el_val_t rhythm_event(el_val_t name);
el_val_t rhythm_and(el_val_t a, el_val_t b);
el_val_t rhythm_or(el_val_t a, el_val_t b);
el_val_t rhythm_weekday(el_val_t day);
el_val_t rhythm_weekly_at(el_val_t day, el_val_t hour, el_val_t minute);
el_val_t rhythm_next_after(el_val_t r, el_val_t after, el_val_t cal);
el_val_t rhythm_matches(el_val_t r, el_val_t ct);
/* ── UUID ────────────────────────────────────────────────────────────────── */
el_val_t uuid_new(void);
+363
View File
@@ -202,6 +202,51 @@ fn cg_expr(expr: Map<String, Any>) -> String {
let right_is_inst: Bool = is_instant_expr(right)
let left_is_dur: Bool = is_duration_expr(left)
let right_is_dur: Bool = is_duration_expr(right)
// Phase 1.5 LocalDate / LocalTime / CalendarTime dispatch. These
// route through their typed runtime wrappers (el_local_date_add_dur,
// el_local_time_add_dur, el_local_date_lt, el_local_date_eq) and
// forbid mismatched ops at codegen time. Cross-calendar arithmetic
// (CalendarTime + CalendarTime, CalendarTime - CalendarTime under
// mismatched calendars) is structurally meaningless: a CalendarTime
// already projects an Instant under a Calendar, so subtraction
// between two of them only makes sense in instant-space (use
// cal_to_instant first).
let left_is_ld: Bool = is_localdate_expr(left)
let right_is_ld: Bool = is_localdate_expr(right)
let left_is_lt: Bool = is_localtime_expr(left)
let right_is_lt: Bool = is_localtime_expr(right)
let left_is_ct: Bool = is_caltime_expr(left)
let right_is_ct: Bool = is_caltime_expr(right)
if left_is_ld {
if op == "Plus" {
if right_is_dur {
return "el_local_date_add_dur(" + left_c + ", " + right_c + ")"
}
}
if op == "Lt" {
if right_is_ld { return "el_local_date_lt(" + left_c + ", " + right_c + ")" }
}
if op == "EqEq" {
if right_is_ld { return "el_local_date_eq(" + left_c + ", " + right_c + ")" }
}
}
if left_is_lt {
if op == "Plus" {
if right_is_dur {
return "el_local_time_add_dur(" + left_c + ", " + right_c + ")"
}
}
}
if left_is_ct {
if op == "Plus" {
if right_is_ct {
time_record_violation("caltime_plus_caltime", "CalendarTime + CalendarTime is not allowed (use cal_to_instant + Duration)")
return "0 /* TIME_TYPE_ERROR: CalendarTime + CalendarTime */"
}
}
}
let any_temporal: Bool = false
if left_is_inst { let any_temporal = true }
if right_is_inst { let any_temporal = true }
@@ -877,6 +922,27 @@ fn cg_stmt(stmt: Map<String, Any>, indent: String, declared: [String]) -> [Strin
if str_eq(ltype, "Duration") {
add_duration_name(name)
}
if str_eq(ltype, "Calendar") {
add_calendar_name(name)
}
if str_eq(ltype, "CalendarTime") {
add_caltime_name(name)
}
if str_eq(ltype, "Rhythm") {
add_rhythm_name(name)
}
if str_eq(ltype, "LocalDate") {
add_localdate_name(name)
}
if str_eq(ltype, "LocalTime") {
add_localtime_name(name)
}
if str_eq(ltype, "LocalDateTime") {
add_localdt_name(name)
}
if str_eq(ltype, "Zone") {
add_zone_name(name)
}
// Inference from RHS duration literals and known-typed calls
// propagate even when the let is unannotated.
if is_instant_expr(val) {
@@ -885,6 +951,27 @@ fn cg_stmt(stmt: Map<String, Any>, indent: String, declared: [String]) -> [Strin
if is_duration_expr(val) {
add_duration_name(name)
}
if is_calendar_expr(val) {
add_calendar_name(name)
}
if is_caltime_expr(val) {
add_caltime_name(name)
}
if is_rhythm_expr(val) {
add_rhythm_name(name)
}
if is_localdate_expr(val) {
add_localdate_name(name)
}
if is_localtime_expr(val) {
add_localtime_name(name)
}
if is_localdt_expr(val) {
add_localdt_name(name)
}
if is_zone_expr(val) {
add_zone_name(name)
}
let vk: String = val["expr"]
if str_eq(vk, "Int") {
add_int_name(name)
@@ -1232,6 +1319,191 @@ fn is_duration_call(call_expr: Map<String, Any>) -> Bool {
return false
}
// Phase 1.5 Calendar / CalendarTime / Rhythm / LocalDate / LocalTime /
// LocalDateTime / Zone are first-class boxed types. Each has its own name
// set in process state, populated from typed `let` bindings and parameter
// annotations. The BinOp dispatcher consults these to forbid mismatched
// arithmetic (e.g. CalendarTime + CalendarTime, LocalDate < CalendarTime).
fn is_calendar_name(name: String) -> Bool {
let csv: String = state_get("__calendar_names")
if str_eq(csv, "") { return false }
return str_contains(csv, "," + name + ",")
}
fn is_caltime_name(name: String) -> Bool {
let csv: String = state_get("__caltime_names")
if str_eq(csv, "") { return false }
return str_contains(csv, "," + name + ",")
}
fn is_rhythm_name(name: String) -> Bool {
let csv: String = state_get("__rhythm_names")
if str_eq(csv, "") { return false }
return str_contains(csv, "," + name + ",")
}
fn is_localdate_name(name: String) -> Bool {
let csv: String = state_get("__localdate_names")
if str_eq(csv, "") { return false }
return str_contains(csv, "," + name + ",")
}
fn is_localtime_name(name: String) -> Bool {
let csv: String = state_get("__localtime_names")
if str_eq(csv, "") { return false }
return str_contains(csv, "," + name + ",")
}
fn is_localdt_name(name: String) -> Bool {
let csv: String = state_get("__localdt_names")
if str_eq(csv, "") { return false }
return str_contains(csv, "," + name + ",")
}
fn is_zone_name(name: String) -> Bool {
let csv: String = state_get("__zone_names")
if str_eq(csv, "") { return false }
return str_contains(csv, "," + name + ",")
}
// Calendar-returning builtins. earth_calendar / mars_calendar / cycle_calendar
// / no_cycle_calendar / relative_calendar all box a calendar struct.
fn is_calendar_call(call_expr: Map<String, Any>) -> Bool {
let func = call_expr["func"]
let fk: String = func["expr"]
if !str_eq(fk, "Ident") { return false }
let name: String = func["name"]
if str_eq(name, "earth_calendar") { return true }
if str_eq(name, "earth_calendar_default") { return true }
if str_eq(name, "mars_calendar") { return true }
if str_eq(name, "cycle_calendar") { return true }
if str_eq(name, "no_cycle_calendar") { return true }
if str_eq(name, "relative_calendar") { return true }
return false
}
// CalendarTime-returning builtins.
fn is_caltime_call(call_expr: Map<String, Any>) -> Bool {
let func = call_expr["func"]
let fk: String = func["expr"]
if !str_eq(fk, "Ident") { return false }
let name: String = func["name"]
if str_eq(name, "now_in") { return true }
if str_eq(name, "in_calendar") { return true }
if str_eq(name, "cal_in") { return true }
if str_eq(name, "zoned") { return true }
return false
}
// Rhythm-returning builtins.
fn is_rhythm_call(call_expr: Map<String, Any>) -> Bool {
let func = call_expr["func"]
let fk: String = func["expr"]
if !str_eq(fk, "Ident") { return false }
let name: String = func["name"]
if str_eq(name, "rhythm_cycle_start") { return true }
if str_eq(name, "rhythm_cycle_phase") { return true }
if str_eq(name, "rhythm_duration") { return true }
if str_eq(name, "rhythm_session_start") { return true }
if str_eq(name, "rhythm_event") { return true }
if str_eq(name, "rhythm_and") { return true }
if str_eq(name, "rhythm_or") { return true }
if str_eq(name, "rhythm_weekday") { return true }
if str_eq(name, "rhythm_weekly_at") { return true }
return false
}
// LocalDate-returning builtins.
fn is_localdate_call(call_expr: Map<String, Any>) -> Bool {
let func = call_expr["func"]
let fk: String = func["expr"]
if !str_eq(fk, "Ident") { return false }
let name: String = func["name"]
if str_eq(name, "local_date") { return true }
if str_eq(name, "el_local_date_add_dur") { return true }
return false
}
fn is_localtime_call(call_expr: Map<String, Any>) -> Bool {
let func = call_expr["func"]
let fk: String = func["expr"]
if !str_eq(fk, "Ident") { return false }
let name: String = func["name"]
if str_eq(name, "local_time") { return true }
if str_eq(name, "el_local_time_add_dur") { return true }
return false
}
fn is_localdt_call(call_expr: Map<String, Any>) -> Bool {
let func = call_expr["func"]
let fk: String = func["expr"]
if !str_eq(fk, "Ident") { return false }
let name: String = func["name"]
if str_eq(name, "local_datetime") { return true }
return false
}
fn is_zone_call(call_expr: Map<String, Any>) -> Bool {
let func = call_expr["func"]
let fk: String = func["expr"]
if !str_eq(fk, "Ident") { return false }
let name: String = func["name"]
if str_eq(name, "zone") { return true }
if str_eq(name, "zone_utc") { return true }
if str_eq(name, "zone_local") { return true }
if str_eq(name, "zone_offset") { return true }
return false
}
fn is_calendar_expr(expr: Map<String, Any>) -> Bool {
let k: String = expr["expr"]
if str_eq(k, "Ident") { return is_calendar_name(expr["name"]) }
if str_eq(k, "Call") { return is_calendar_call(expr) }
return false
}
fn is_caltime_expr(expr: Map<String, Any>) -> Bool {
let k: String = expr["expr"]
if str_eq(k, "Ident") { return is_caltime_name(expr["name"]) }
if str_eq(k, "Call") { return is_caltime_call(expr) }
return false
}
fn is_rhythm_expr(expr: Map<String, Any>) -> Bool {
let k: String = expr["expr"]
if str_eq(k, "Ident") { return is_rhythm_name(expr["name"]) }
if str_eq(k, "Call") { return is_rhythm_call(expr) }
return false
}
fn is_localdate_expr(expr: Map<String, Any>) -> Bool {
let k: String = expr["expr"]
if str_eq(k, "Ident") { return is_localdate_name(expr["name"]) }
if str_eq(k, "Call") { return is_localdate_call(expr) }
return false
}
fn is_localtime_expr(expr: Map<String, Any>) -> Bool {
let k: String = expr["expr"]
if str_eq(k, "Ident") { return is_localtime_name(expr["name"]) }
if str_eq(k, "Call") { return is_localtime_call(expr) }
return false
}
fn is_localdt_expr(expr: Map<String, Any>) -> Bool {
let k: String = expr["expr"]
if str_eq(k, "Ident") { return is_localdt_name(expr["name"]) }
if str_eq(k, "Call") { return is_localdt_call(expr) }
return false
}
fn is_zone_expr(expr: Map<String, Any>) -> Bool {
let k: String = expr["expr"]
if str_eq(k, "Ident") { return is_zone_name(expr["name"]) }
if str_eq(k, "Call") { return is_zone_call(expr) }
return false
}
// Recursive type predicates for Instant / Duration. Mirror is_int_expr.
// is_instant_expr / is_duration_expr return true only when the expression
// is provably of that type at codegen time. Anything ambiguous returns
@@ -1810,10 +2082,80 @@ fn add_duration_name(name: String) -> Bool {
return true
}
fn add_calendar_name(name: String) -> Bool {
let csv: String = state_get("__calendar_names")
if str_eq(csv, "") { csv = "," }
let key: String = "," + name + ","
if str_contains(csv, key) { return true }
state_set("__calendar_names", csv + name + ",")
return true
}
fn add_caltime_name(name: String) -> Bool {
let csv: String = state_get("__caltime_names")
if str_eq(csv, "") { csv = "," }
let key: String = "," + name + ","
if str_contains(csv, key) { return true }
state_set("__caltime_names", csv + name + ",")
return true
}
fn add_rhythm_name(name: String) -> Bool {
let csv: String = state_get("__rhythm_names")
if str_eq(csv, "") { csv = "," }
let key: String = "," + name + ","
if str_contains(csv, key) { return true }
state_set("__rhythm_names", csv + name + ",")
return true
}
fn add_localdate_name(name: String) -> Bool {
let csv: String = state_get("__localdate_names")
if str_eq(csv, "") { csv = "," }
let key: String = "," + name + ","
if str_contains(csv, key) { return true }
state_set("__localdate_names", csv + name + ",")
return true
}
fn add_localtime_name(name: String) -> Bool {
let csv: String = state_get("__localtime_names")
if str_eq(csv, "") { csv = "," }
let key: String = "," + name + ","
if str_contains(csv, key) { return true }
state_set("__localtime_names", csv + name + ",")
return true
}
fn add_localdt_name(name: String) -> Bool {
let csv: String = state_get("__localdt_names")
if str_eq(csv, "") { csv = "," }
let key: String = "," + name + ","
if str_contains(csv, key) { return true }
state_set("__localdt_names", csv + name + ",")
return true
}
fn add_zone_name(name: String) -> Bool {
let csv: String = state_get("__zone_names")
if str_eq(csv, "") { csv = "," }
let key: String = "," + name + ","
if str_contains(csv, key) { return true }
state_set("__zone_names", csv + name + ",")
return true
}
fn build_int_names_for_params(params: [Map<String, Any>]) -> Bool {
state_set("__int_names", ",")
state_set("__instant_names", ",")
state_set("__duration_names", ",")
state_set("__calendar_names", ",")
state_set("__caltime_names", ",")
state_set("__rhythm_names", ",")
state_set("__localdate_names", ",")
state_set("__localtime_names", ",")
state_set("__localdt_names", ",")
state_set("__zone_names", ",")
let np: Int = native_list_len(params)
let pi = 0
while pi < np {
@@ -1829,6 +2171,27 @@ fn build_int_names_for_params(params: [Map<String, Any>]) -> Bool {
if str_eq(ptype, "Duration") {
add_duration_name(pname)
}
if str_eq(ptype, "Calendar") {
add_calendar_name(pname)
}
if str_eq(ptype, "CalendarTime") {
add_caltime_name(pname)
}
if str_eq(ptype, "Rhythm") {
add_rhythm_name(pname)
}
if str_eq(ptype, "LocalDate") {
add_localdate_name(pname)
}
if str_eq(ptype, "LocalTime") {
add_localtime_name(pname)
}
if str_eq(ptype, "LocalDateTime") {
add_localdt_name(pname)
}
if str_eq(ptype, "Zone") {
add_zone_name(pname)
}
let pi = pi + 1
}
return true
@@ -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()))
}
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# run.sh — build and execute the calendar/ acceptance corpus.
#
# Each examples/<case>.el is a self-contained El program with a fn main()
# that prints a single deterministic result line. The runner compiles each
# via the canonical native elc, links it against the shared C runtime, runs
# it, and asserts the output matches the expected value.
set -uo pipefail
cd "$(dirname "$0")"
EL_HOME="${EL_HOME:-$(cd ../.. && pwd)}"
ELC="${EL_HOME}/dist/platform/elc"
RUNTIME_DIR="${EL_HOME}/el-compiler/runtime"
if [ ! -x "${ELC}" ]; then
echo "elc not found at ${ELC}" >&2
exit 1
fi
PASS=0
FAIL=0
FAILED_NAMES=()
# run_runtime_case <name> <source> <expected> [<extra>]
run_runtime_case() {
local name="$1"
local src="$2"
local expected="$3"
local mode="${4:-exact}"
local out_c
local out_bin
out_c="$(mktemp -t cal_test.XXXXXX).c"
out_bin="$(mktemp -t cal_test.XXXXXX)"
if ! "${ELC}" "${src}" > "${out_c}" 2>/tmp/cal_test.elc.err; then
echo "FAIL ${name} — elc emit failed:"
cat /tmp/cal_test.elc.err | sed 's/^/ /'
FAIL=$((FAIL+1))
FAILED_NAMES+=("${name}")
rm -f "${out_c}" "${out_bin}"
return
fi
if ! cc -O2 -I "${RUNTIME_DIR}" "${out_c}" "${RUNTIME_DIR}/el_runtime.c" \
-lcurl -lpthread -o "${out_bin}" 2>/tmp/cal_test.cc.err; then
echo "FAIL ${name} — cc failed:"
cat /tmp/cal_test.cc.err | sed 's/^/ /'
FAIL=$((FAIL+1))
FAILED_NAMES+=("${name}")
rm -f "${out_c}" "${out_bin}"
return
fi
local got
got="$("${out_bin}" 2>&1 | tr -d '[:space:]')"
if [ "${mode}" = "either" ]; then
local ok=0
local IFS=','
for choice in ${expected}; do
if [ "${got}" = "${choice}" ]; then ok=1; break; fi
done
if [ "${ok}" = "1" ]; then
echo "PASS ${name} (got: ${got})"
PASS=$((PASS+1))
else
echo "FAIL ${name} expected one of {${expected}}, got: ${got}"
FAIL=$((FAIL+1))
FAILED_NAMES+=("${name}")
fi
else
if [ "${got}" = "${expected}" ]; then
echo "PASS ${name}"
PASS=$((PASS+1))
else
echo "FAIL ${name} expected: ${expected}, got: ${got}"
FAIL=$((FAIL+1))
FAILED_NAMES+=("${name}")
fi
fi
rm -f "${out_c}" "${out_bin}"
}
echo "==> Running calendar/ acceptance corpus"
echo
run_runtime_case "earth-zone" examples/earth-zone.el "EDT"
run_runtime_case "dst-spring-forward" examples/dst-spring-forward.el "03:30EDT"
run_runtime_case "mars-calendar" examples/mars-calendar.el "1"
run_runtime_case "cycle-30hr" examples/cycle-30hr.el "0.5"
run_runtime_case "cycle-300yr" examples/cycle-300yr.el "0.01"
run_runtime_case "no-cycle" examples/no-cycle.el "1"
run_runtime_case "cross-calendar-comparison" examples/cross-calendar-comparison.el "1"
run_runtime_case "local-date-arithmetic" examples/local-date-arithmetic.el "2026-6-4"
run_runtime_case "rhythm-grounding" examples/rhythm-grounding.el "1778504400"
run_runtime_case "rhythm-cycle-30hr" examples/rhythm-cycle-30hr.el "54000"
echo
echo "${PASS} passed, ${FAIL} failed"
if [ "${FAIL}" -gt 0 ]; then
echo "failed: ${FAILED_NAMES[*]}"
exit 1
fi
exit 0
+13
View File
@@ -0,0 +1,13 @@
// runner.el entry point for the calendar/ acceptance corpus.
//
// Each calendar/examples/*.el is its own El program with its own fn main().
// Compile, link, and run-output-diff is handled by tests/calendar/run.sh
// this file is the El-side stub kept for pattern parity.
//
// Run from the calendar/ directory:
// ./run.sh
fn main() -> Void {
println("calendar/ acceptance corpus is driven by run.sh — invoke that directly.")
println("Each examples/*.el is a self-contained program; runner.el is a parity stub.")
}