/* * time.cpp — Antheos Protocol v9 TIMESTAMP (§6.2.2) + DURATION (§6.2.3) * * ANTHEOS-TIMESTAMP-V1-PARSER-IMPL, ANTHEOS-DURATION-V1-PARSER-IMPL. * * Both types share one discipline, which is why they share a file: PARSE * LOOSE, STORE CANONICAL, EMIT CANONICAL. The point of it is that two * conformant implementations produce byte-identical words for the same value — * so the emitters have no options, and everywhere the spec could have admitted * two answers it picks one and says so. * * The sharpest instance: sub-microsecond digits are TRUNCATED, never rounded, * for both types. §6.2.2 gives the reason outright — rounding admits multiple * valid implementations and truncation has one. That is the whole design in a * sentence, and it is why nothing here reaches for a rounding mode. * * SEMANTICS ARE OUT OF SCOPE, per §6.2.3: arithmetic and ordering over these * values, and whether Y/M/D/W are calendar-relative or fixed-second, belong to * the consuming reasoner's own specification. This file defines the byte * sequence and its canonical form and stops there. * * Copyright (c) 2025-2026 Are Bjørby * SPDX-License-Identifier: MIT */ #include "antheos.hpp" #include #include namespace antheos::iso { namespace { bool is_digit(char c) { return c >= '0' && c <= '9'; } /* Read exactly `n` digits into `out`. Anything else — a short field, a sign, a * letter — is a reject, which is what keeps `2026-2-8` and `+02026-02-08` out * without a separate check for each. */ bool take_digits(std::string_view s, size_t& pos, int n, int& out) { if (pos + static_cast(n) > s.size()) return false; int v = 0; for (int i = 0; i < n; i++) { const char c = s[pos + static_cast(i)]; if (!is_digit(c)) return false; v = v * 10 + (c - '0'); } pos += static_cast(n); out = v; return true; } bool take_char(std::string_view s, size_t& pos, char c) { if (pos >= s.size() || s[pos] != c) return false; pos++; return true; } bool is_leap(int y) { return (y % 4 == 0 && y % 100 != 0) || y % 400 == 0; } int days_in_month(int y, int m) { static const int d[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (m < 1 || m > 12) return 0; if (m == 2 && is_leap(y)) return 29; return d[m - 1]; } /* Days since 1970-01-01 (Howard Hinnant's civil algorithm). Needed only to * apply a ±HH:MM offset, which can cross a day, month or year boundary — the * one place this file does date arithmetic, and it does it to REMOVE the * offset rather than to interpret anything. */ long long days_from_civil(int y, int m, int d) { y -= m <= 2; const long long era = (y >= 0 ? y : y - 399) / 400; const unsigned yoe = static_cast(y - era * 400); const unsigned doy = static_cast((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1); const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; return era * 146097 + static_cast(doe) - 719468; } void civil_from_days(long long z, int& y, int& m, int& d) { z += 719468; const long long era = (z >= 0 ? z : z - 146096) / 146097; const unsigned doe = static_cast(z - era * 146097); const unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; const long long yy = static_cast(yoe) + era * 400; const unsigned doy = doe - (365 * yoe + yoe / 4 - yoe / 100); const unsigned mp = (5 * doy + 2) / 153; d = static_cast(doy - (153 * mp + 2) / 5 + 1); m = static_cast(mp + (mp < 10 ? 3 : -9)); y = static_cast(yy + (m <= 2)); } void set_err(std::string* e, std::string msg) { if (e) *e = std::move(msg); } } // namespace /* ── TIMESTAMP (§6.2.2) ── */ std::optional parse_timestamp(std::string_view body, std::string* error) { if (body.empty()) { set_err(error, "a TIMESTAMP body may not be empty — it must carry an " "instant as YYYY-MM-DDTHH:MM:SS[.ffffff][Z|±HH:MM]"); return std::nullopt; } /* An extended year carries a sign where a digit belongs. Checked first so * the message names the real cause rather than "expected a digit". */ if (body[0] == '+' || body[0] == '-') { set_err(error, "extended years (±YYYYYY-MM-DD…) are not in V1 — the year " "is exactly four digits, 0000-9999"); return std::nullopt; } size_t pos = 0; Timestamp ts{}; int y = 0, mo = 0, d = 0, h = 0, mi = 0, se = 0; if (!take_digits(body, pos, 4, y) || !take_char(body, pos, '-') || !take_digits(body, pos, 2, mo) || !take_char(body, pos, '-') || !take_digits(body, pos, 2, d) || !take_char(body, pos, 'T') || !take_digits(body, pos, 2, h) || !take_char(body, pos, ':') || !take_digits(body, pos, 2, mi) || !take_char(body, pos, ':') || !take_digits(body, pos, 2, se)) { set_err(error, "expected YYYY-MM-DDTHH:MM:SS[.ffffff][Z|±HH:MM]"); return std::nullopt; } if (se == 60) { set_err(error, "leap seconds (23:59:60) are not in V1 — a TIMESTAMP " "names an instant on a 60-second minute"); return std::nullopt; } if (mo < 1 || mo > 12 || d < 1 || d > days_in_month(y, mo) || h > 23 || mi > 59 || se > 59) { set_err(error, "field out of range for the calendar"); return std::nullopt; } /* Fractional seconds: pad short, TRUNCATE long. Never round — §6.2.2 says * so and gives the reason: rounding admits multiple valid implementations * and truncation has exactly one. */ long long micros = 0; if (pos < body.size() && body[pos] == '.') { pos++; size_t start = pos; while (pos < body.size() && is_digit(body[pos])) pos++; if (pos == start) { set_err(error, "a decimal point must be followed by fractional-second digits"); return std::nullopt; } for (size_t i = 0; i < 6; i++) { micros *= 10; if (start + i < pos) micros += body[start + i] - '0'; } /* digits past the sixth are dropped, not consulted */ } /* Zone: absent (UTC), `Z`, or ±HH:MM converted to UTC. */ long long offset_minutes = 0; if (pos < body.size()) { const char z = body[pos]; if (z == 'Z') { pos++; } else if (z == '+' || z == '-') { pos++; int oh = 0, om = 0; if (!take_digits(body, pos, 2, oh) || !take_char(body, pos, ':') || !take_digits(body, pos, 2, om)) { set_err(error, "an offset must be written ±HH:MM"); return std::nullopt; } if (oh > 23 || om > 59) { set_err(error, "offset out of range"); return std::nullopt; } offset_minutes = static_cast(oh) * 60 + om; if (z == '+') offset_minutes = -offset_minutes; /* toward UTC */ } else { set_err(error, "named time zones are not in V1 — use `Z` or a ±HH:MM " "offset, which the parser converts to UTC"); return std::nullopt; } } if (pos != body.size()) { set_err(error, "unexpected characters after the instant"); return std::nullopt; } if (offset_minutes != 0) { long long total = days_from_civil(y, mo, d) * 1440LL + static_cast(h) * 60 + mi + offset_minutes; long long day = total / 1440; long long rem = total % 1440; if (rem < 0) { rem += 1440; day -= 1; } civil_from_days(day, y, mo, d); h = static_cast(rem / 60); mi = static_cast(rem % 60); if (y < 0 || y > 9999) { set_err(error, "the offset moves the instant outside the V1 year range 0000-9999"); return std::nullopt; } } ts.year = y; ts.month = mo; ts.day = d; ts.hour = h; ts.minute = mi; ts.second = se; ts.micros = static_cast(micros); return ts; } std::string emit_timestamp(const Timestamp& ts) { /* 27 bytes exactly, per §6.2.2. */ char buf[32]; std::snprintf(buf, sizeof buf, "%04d-%02d-%02dT%02d:%02d:%02d.%06uZ", ts.year, ts.month, ts.day, ts.hour, ts.minute, ts.second, static_cast(ts.micros)); return std::string(buf); } /* ── DURATION (§6.2.3) ── */ std::optional parse_duration(std::string_view body, std::string* error) { size_t pos = 0; Duration du{}; if (pos < body.size() && body[pos] == '-') { du.negative = true; pos++; } if (!take_char(body, pos, 'P')) { set_err(error, "a DURATION body must begin with `P` (optionally preceded " "by `-`) — bare components like `3Y6M` are not in V1"); return std::nullopt; } if (pos == body.size()) { set_err(error, "`P` alone declares no duration — name at least one component"); return std::nullopt; } bool any_component = false; bool saw_calendar = false; /* Calendar section, up to the `T`. */ while (pos < body.size() && body[pos] != 'T') { size_t start = pos; while (pos < body.size() && is_digit(body[pos])) pos++; if (pos == start) { set_err(error, "expected a number before a duration component letter"); return std::nullopt; } long long n = std::strtoll(std::string(body.substr(start, pos - start)).c_str(), nullptr, 10); if (pos >= body.size()) { set_err(error, "a number must be followed by its component letter"); return std::nullopt; } const char unit = body[pos++]; switch (unit) { case 'Y': du.years = n; saw_calendar = true; break; case 'M': du.months = n; saw_calendar = true; break; case 'D': du.days = n; saw_calendar = true; break; case 'W': /* §6.2.3: the weeks form is MUTUALLY EXCLUSIVE with calendar * components, and the canonical form PRESERVES it rather than * converting to days — the shape is part of what was said. */ if (du.weeks_form) { set_err(error, "`W` may appear once"); return std::nullopt; } du.weeks_form = true; du.weeks = n; break; case 'H': case 'S': set_err(error, "time components (H, M, S) must follow the `T` separator"); return std::nullopt; default: set_err(error, std::string("`") + unit + "` is not a duration component"); return std::nullopt; } any_component = true; if (du.weeks_form && saw_calendar) { set_err(error, "the weeks form (`PnW`) may not be mixed with Y/M/D " "components — they are mutually exclusive"); return std::nullopt; } } /* Time section. */ if (pos < body.size() && body[pos] == 'T') { pos++; if (du.weeks_form) { set_err(error, "the weeks form (`PnW`) may not carry time components"); return std::nullopt; } if (pos == body.size()) { set_err(error, "`T` must be followed by at least one time component"); return std::nullopt; } while (pos < body.size()) { size_t start = pos; while (pos < body.size() && is_digit(body[pos])) pos++; if (pos == start) { set_err(error, "expected a number before a time component letter"); return std::nullopt; } long long n = std::strtoll(std::string(body.substr(start, pos - start)).c_str(), nullptr, 10); /* Only the seconds component may carry a fraction. */ long long micros = 0; bool had_fraction = false; if (pos < body.size() && body[pos] == '.') { pos++; size_t fs = pos; while (pos < body.size() && is_digit(body[pos])) pos++; if (pos == fs) { set_err(error, "a decimal point must be followed by digits"); return std::nullopt; } had_fraction = true; for (size_t i = 0; i < 6; i++) { micros *= 10; if (fs + i < pos) micros += body[fs + i] - '0'; } /* sub-μs digits truncated, never rounded — §6.2.2's rule, and * §6.2.3 inherits it */ } if (pos >= body.size()) { set_err(error, "a number must be followed by its component letter"); return std::nullopt; } const char unit = body[pos++]; if (had_fraction && unit != 'S') { set_err(error, "only the seconds component may carry a fraction"); return std::nullopt; } switch (unit) { case 'H': du.hours = n; break; case 'M': du.minutes = n; break; case 'S': du.seconds = n; du.micros = static_cast(micros); break; default: set_err(error, std::string("`") + unit + "` is not a time component"); return std::nullopt; } any_component = true; } } if (!any_component) { set_err(error, "`P` alone declares no duration — name at least one component"); return std::nullopt; } return du; } std::string emit_duration(const Duration& du) { /* §6.2.3 canonical: minimal-component form. Zero components are omitted, * and any all-zero duration is `PT0S` — one spelling for nothing. */ const bool zero = du.weeks_form ? du.weeks == 0 : (du.years == 0 && du.months == 0 && du.days == 0 && du.hours == 0 && du.minutes == 0 && du.seconds == 0 && du.micros == 0); std::string out; if (du.negative && !zero) out += '-'; out += 'P'; if (zero) return out + "T0S"; char buf[32]; if (du.weeks_form) { std::snprintf(buf, sizeof buf, "%lldW", du.weeks); return out + buf; } if (du.years) { std::snprintf(buf, sizeof buf, "%lldY", du.years); out += buf; } if (du.months) { std::snprintf(buf, sizeof buf, "%lldM", du.months); out += buf; } if (du.days) { std::snprintf(buf, sizeof buf, "%lldD", du.days); out += buf; } const bool has_time = du.hours || du.minutes || du.seconds || du.micros; if (has_time) { out += 'T'; if (du.hours) { std::snprintf(buf, sizeof buf, "%lldH", du.hours); out += buf; } if (du.minutes) { std::snprintf(buf, sizeof buf, "%lldM", du.minutes); out += buf; } if (du.seconds || du.micros) { /* Fractional digits appear only if there IS a fraction, and then * all six of them — §6.2.3: omitted entirely for integer seconds, * zero-padded to 6 when any precision is present. */ if (du.micros) std::snprintf(buf, sizeof buf, "%lld.%06uS", du.seconds, static_cast(du.micros)); else std::snprintf(buf, sizeof buf, "%lldS", du.seconds); out += buf; } } return out; } } // namespace antheos::iso