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
+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;