Skip to content

temporal: close the non-DST Temporal TCK cluster (+30)#88

Merged
dylanbstorey merged 18 commits into
mainfrom
i0049-temporal
Jun 1, 2026
Merged

temporal: close the non-DST Temporal TCK cluster (+30)#88
dylanbstorey merged 18 commits into
mainfrom
i0049-temporal

Conversation

@dylanbstorey

Copy link
Copy Markdown
Contributor

Closes the entire non-DST Temporal TCK cluster — 3728 → 3758 (+30), zero regressions. Each fix verified with a rigorous full pass-set diff; unit 944/944; functional clean throughout. Part of initiative GQLITE-I-0049, task GQLITE-T-0341.

Fixes (all duration/date/time semantics)

  • Duration arithmetic: * n / / n component scaling (+3); fractional construction + date arithmetic with 1 month = 30.436875 days (+11); ISO fractional-month cascade (+1); alternate ISO form P<date>T<time> (+1).
  • duration.between: inMonths/inDays tz normalization (UTC instant) (+2).
  • Selection: date() quarter preserves month-of-quarter + day (+3); time() drops named-zone [Region] (+2).
  • Formatting: zero offset → Z (+2); offset zero-seconds drop +02:05:00+02:05 (+1); date(datetime) extracts the date (+2).
  • Comparison/accessors: temporal </> compares UTC instants (+1); .timezone accessor returns the zone name (+1).

Temporal8 went 27/27. Of the original ~42 Temporal failures, only the 12 DST scenarios remain.

DST is deliberately out of scope (documented in GQLITE-T-0341)

The remaining 12 (Temporal10 [8], Temporal3 [10], Temporal2 [6]) require an embedded IANA timezone database, not a rule-based approximation:

  • The TCK encodes historical data — e.g. 1818-07-21 Stockholm = +00:53:28 (Local Mean Time), 1984-10-11 Stockholm = +01:00 (pre-1996 EU DST ended the last Sunday of September).
  • A modern last-Sunday rule regressed −52 examples; the existing coarse approximation is load-bearing.
  • Temporal10 [8] also needs across-transition elapsed time (24 wall-clock hrs = 25 real hrs on the fall-back day).

Tracked as a separate large, data-heavy follow-up.

Dylan Bobby Storey added 18 commits May 30, 2026 10:27
`duration * n` and `duration / n` (Temporal8 [7]) returned 0 because
BINARY_OP_MUL/DIV emitted a bare ` * `/` / ` and SQLite coerced the duration
JSON text to 0.

New `_gql_dyn_mul` / `_gql_dyn_div` UDFs mirror the existing ADD/SUB
`_gql_dyn_*` dispatch: a Duration operand is scaled component-wise (months,
days, seconds, nanos) by the numeric factor, cascading each larger unit's
fractional remainder into the next smaller one using the duration-arithmetic
conversions 1 month = 30.436875 days and 1 day = 86400 s (no cross-component
normalization). The final nanosecond is snapped to the nearest integer within a
small tolerance (absorbs carry FP noise) then truncated toward zero, matching
Cypher (e.g. *0.5 of an odd nanosecond drops). Plain numerics keep native
int/float semantics inside the helper.

transform routes BINARY_OP_MUL/DIV through the helpers unless both operands are
numeric literals. Rigorous full pass-set diff: zero regressions, +3.
3728 -> 3731. Unit 944/944, functional clean.
…, T-0341)

Two coupled fixes in the duration machinery (udf_helpers.c):

1. Fractional duration construction (Temporal8 [6] frac examples, Temporal1
   [12], Temporal7 [6]): gql_duration_compose_func's fractional-month carry used
   30.0 days/month; Cypher uses the average Gregorian month 30.436875. Also
   removed the composer's day-overflow roll (seconds -> days) so durations keep
   sub-day time as hours (e.g. duration({hours:25}) = PT25H, fractional durations
   keep PT67H), matching emit_duration_json used by duration addition.

2. date + duration (Temporal8 [1] example 3): because the duration value is no
   longer pre-normalized, apply_duration_to_temporal now rolls the duration's
   WHOLE-day time component (time_ns / DAY_NS, trunc toward zero) into a pure-date
   input and drops the sub-day remainder. datetime/time inputs are unchanged
   (they already applied the full time component).

Rigorous full pass-set diff: zero regressions, +11. 3731 -> 3742. Unit 944/944,
functional clean.
`duration.inMonths`/`duration.inDays` compared the time-of-day on the local
clock face, so a tz-offset difference between the two operands (e.g. +0200 vs
+0100) spuriously dropped a whole month/day (Temporal10 [3] ex19 wanted P1Y but
gave P11M; [4] ex17 wanted P337D but gave P338D).

Both now compare time-of-day in UTC (time_ns_of_utc) when both operands carry a
tz offset, falling back to local for tz-naive pairings. inDays additionally
rewritten to compute whole days as the calendar day difference (days_from_civil,
unbounded) minus a partial trailing day when the later instant's time-of-day
falls short of the earlier one.

Rigorous full pass-set diff: zero regressions, +2. 3742 -> 3744. Unit 944/944,
functional clean. Remaining Temporal10 (DST [8], large durations [9]/[10])
deferred.
…+3 TCK, T-0341)

`date({date: other, quarter: N})` reset to the quarter's first month and day 1
(1984-07-01) instead of preserving the base's month-within-quarter and day
(expected 1984-08-11 for quarter 3 over 1984-11-11, which is Q4 month-2 day-11).

When only `quarter` is selected (no dayOfQuarter), gql_date_compose_func now
keeps month_in_quarter = ((src_month-1) %% 3) + 1 and the base/override day,
mapping to new_month = (q-1)*3 + month_in_quarter (day clamped to month length).
dayOfQuarter still selects an absolute day within the quarter.

Rigorous full pass-set diff: zero regressions, +3 (Temporal3 [1] quarter
examples). 3744 -> 3747. Unit 944/944, functional clean.
`time(<zoned datetime>)` and `time({timezone:'Europe/Stockholm'})` emitted
the full `+01:00[Europe/Stockholm]` tz, but a Cypher time/localtime value
never carries a named zone — only the numeric offset (Temporal3 [3] ex17/19
want '12:00+01:00'). datetime() must still keep the region.

The shared `_gql_time_compose` UDF (used by both time() and datetime()) gained
a 13th `drop_region` argument: time()/localtime() pass 1 (strip any [Region]
from a direct named tz or an inherited base tz), datetime() passes 0. Verified
datetime() still renders '...T12:00+01:00[Europe/Stockholm]'.

Rigorous full pass-set diff: zero regressions, +2. 3747 -> 3749. Unit 944/944,
functional clean.
Parsing a time/datetime with a zero UTC offset (e.g. '2140-00:00',
'2015-07-20T21:40-00:00') rendered '21:40-00:00' instead of '21:40Z'
(Temporal2 [3]/[5]). gql_normalize_time_func / gql_normalize_datetime_func now
emit 'Z' when the numeric offset is 00:00 and there is no named-zone bracket.

Also fixed gql_extract_tz_func: its time-only detection required length >= 8
(HH:MM:SS), so a 'Z' after HH:MM (e.g. '21:40Z', length 6) wasn't seen and the
time() transform appended a second 'Z' (=> '21:40ZZ'). Relaxed to length >= 5.

Rigorous full pass-set diff: zero regressions, +2. 3749 -> 3751. Unit 944/944,
functional clean.
`time({... timezone: '+02:05:00'})` rendered '+02:05:00'; Cypher drops a zero
seconds suffix from the offset ('+02:05') but keeps non-zero seconds
('+02:05:59', '-02:05:07') (Temporal1 [13]). New `_gql_fmt_offset` UDF
normalizes a numeric offset string; the direct time()/datetime() construction
path wraps its '+/-' timezone with it (named zones and 'Z' pass through).

Rigorous full pass-set diff: zero regressions, +1. 3751 -> 3752. Unit 944/944,
functional clean.
…K, T-0341)

`date(localdatetime(...))` and `date(datetime(...))` returned null because
gql_normalize_date_func rejected any string longer than 10 chars — a datetime
like '1984-11-11T12:31:14' (len 19) fell through every length branch to null
(Temporal3 [1] ex8/15). It now strips the time portion at the 'T' first, so the
date part parses normally.

Rigorous full pass-set diff: zero regressions, +2. 3752 -> 3754. Unit 944/944,
functional clean.
duration('P0.75M') gave P22DT12H — the ISO duration parser used 30.0 days/month
for the fractional-month carry; Cypher uses 30.436875 (avg Gregorian month), so
0.75M = 22D + 0.8276D = P22DT19H51M49.5S (Temporal2 [7] ex4).

Rigorous full pass-set diff: zero regressions, +1. 3754 -> 3755. Unit 944/944,
functional clean. (The alternate ISO form 'P2012-02-02T...' — Temporal2 [7] ex8
— remains; needs a separate date-time duration parse path.)
…-0341)

`time({hour:10,timezone:'+01:00'}) > time({hour:9,minute:35,timezone:'+00:00'})`
returned true (Temporal7 [3]) — _gql_order_cmp compared the two time strings
lexically ('10:00+01:00' > '09:35...') instead of by UTC instant (09:00 UTC <
09:35 UTC).

gql_order_cmp_func now detects temporal-looking text (time 'HH:MM…' or
date/datetime 'YYYY-MM-DD…') on both sides and compares via parse_temporal_ns
(UTC epoch nanoseconds); other text pairs keep the lexical strcmp. Rigorous full
pass-set diff: zero regressions (no string-comparison scenario affected), +1.
3755 -> 3756. Unit 944/944, functional clean.
`datetime.timezone` returned the numeric offset ('+01:00') instead of the
named zone ('Europe/Stockholm') when the value carries one (Temporal5 [6]).
gql_temporal_field_func now returns the bracketed zone name for `.timezone`
when present, falling back to the offset form; `.offset` is unchanged.

Rigorous full pass-set diff: zero regressions, +1. 3756 -> 3757. Unit 944/944,
functional clean.
…-0341)

duration('P2012-02-02T14:37:21.545') returned PT0S — the parser only handled
the unit-letter form (P2012Y2M2D...). Added detection of the alternate ISO
form P[YYYY]-[MM]-[DD]T[hh]:[mm]:[ss] (date-style dashes after 'P') and parse
its components directly (Temporal2 [7] ex8).

Rigorous full pass-set diff: zero regressions, +1. 3757 -> 3758. Unit 944/944,
functional clean.
@dylanbstorey dylanbstorey merged commit 5682285 into main Jun 1, 2026
32 of 34 checks passed
@dylanbstorey dylanbstorey deleted the i0049-temporal branch June 1, 2026 12:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant