Deviations from spec

Over the course of developing Waratah, there are some design decisions that affect the degree of conformance to the W3C RDF and SPARQL standards.

Any deviations from the standards are documented here, with the reason and/or decision around why the deviation has occurred.

Original lexical value of numerics

For xsd:integer / decimal / float / double / boolean (when parse succeeds), Waratah stores the parsed value (i64, fixed-point decimal, f32/f64, bool). "01"^^xsd:integer and "1"^^xsd:integer both become IntegerLiteral(1). Decode always emits the canonical lexical ("1"). There is no side table of original lexicals for those variants — the string dictionary is only for IRIs, long strings, lang tags, and opaque typed literals that didn’t get a native encoding.

sameTerm in Waratah compares those value-encoded terms (ExpressionTerm / EncodedTerm PartialEq), not RDF Concepts term identity on the input lexical. So:

Pair Spec sameTerm Waratah

"1"^^integer vs "01"^^integer

false

true (same stored value)

"1"^^integer vs "1.0e0"^^double

false

false (different variants); = can still be true

So Waratah also cannot make true sameTerm false for different lexicals of the same native type. sameTerm-not-eq for those cases does not pass for them either; they treat it as an accepted SPARQL deviation in exchange for denser keys and cheaper compares.

This affects round-tripping of TTL too, so "01"^^xsd:integer parsed from a source TTL file, will be serialized back out as "1"^^xsd:integer or simply 1.

Computed results are canonical too. The same rule applies to values computed by SPARQL expressions, not just literals parsed from source data. Numeric results are produced as native values and materialized in canonical lexical form. coalesce01 (W3C sparql11/functions) pins this: integer division per spec yields xsd:decimal, and Waratah’s 4 / 2 computes Decimal(2.0) but materializes as "2"^^xsd:decimal where the expected result is "2.0"^^xsd:decimal (same for 0 vs "0.0"). By RDF term identity those are different literals, so strict term-compare harnesses flag the row even though the value is exactly right. Preserving computed-but-non-canonical lexicals would require carrying an output lexical alongside every native value (or returning only hash-referenced string literals from expressions), which defeats the purpose of value encoding; accepted as part of this same deviation.

xsd:date / xsd:dateTime timezone +00:00 normalizes to Z

Waratah parses date/dateTime literals into the DateTime/Time values of oxsdatatypes, which keep only the local time plus an explicit timezone-offset flag (UTC vs offset). The lexical forms +00:00 and Z both mean offset zero, so both round-trip out as Z. A stored "2006-08-23+00:00"^^xsd:date therefore reads back as "2006-08-23Z"^^xsd:date; non-zero offsets (e.g. +01:00) preserve their offset text.

Kept intentionally. By XML Schema the two lexical forms denote the same instant; date-3 (W3C sparql10/open-world) is the one suite test that pins the input lexical on output, and matching it would require carrying the original timezone spelling next to the parsed value for every date/dateTime literal. The instant and all comparison semantics (=, <, >, arithmetic) are preserved exactly, so the trade matches the numeric-lexical deviation above.

xsd:dateTimeStamp literals are stored and returned as xsd:dateTime

Waratah encodes valid xsd:dateTimeStamp literals identically to xsd:dateTime (encoded_term.rs treats both datatypes via the same DateTime parse into EncodedTerm::DateTimeLiteral), and decode re-types that variant as xsd:dateTime unconditionally. A stored "2024-01-15T10:30:00Z"^^xsd:dateTimeStamp therefore reads back (and serializes out) as "2024-01-15T10:30:00Z"^^xsd:dateTime. The lexical value and instant are preserved exactly — only the datatype IRI is collapsed.

Kept intentionally. xsd:dateTimeStamp is, by spec, a pure restriction of xsd:dateTime (XML Schema 1.1; F&O §19.1.3 derived-to-primitive casting): identical value space, with a mandatory timezone as the only difference. Every SPARQL operator that consumes a dateTime value behaves identically on a stamp, and Waratah’s cast layer (§17.5 XSD casts) already accepts both datatypes as DateTime-valued sources on the strength of that derived-type rule. Collapsing them keeps one fewer literal variant on the wire (compact 8-byte encoding instead of a hash-referenced typed literal) and avoids a second code path in every operator that pattern-matches DateTimeLiteral. The only observable difference is DATATYPE(?x) (and term-identity compares) over stamp literals, which report xsd:dateTime; no W3C suite test currently exercises that, and a future fix (variant flag or keeping stamps as BigTypedLiteral) can be added if DATATYPE() support surfaces a need.

Empty GRAPH ?g { } is not yet wired to the named-graph catalog

SPARQL binds ?g in GRAPH ?g { } to every named graph in the dataset, including empty graphs. Waratah now keeps an S-axis MVCC named_graphs_catalog column family (IRI graphs only; never the RDF default graph), but SPARQL execution is not wired to ListNamedGraphs yet. When the named window is not enumerated (FROM NAMED / named-graph-uri), an empty inner BGP cannot collect G from scan results, so GRAPH ?g { } returns no rows (it misses empty named graphs even if we scanned). With an enumerated FROM NAMED list we emit a VALUES table of those IRIs. Engine-default GRAPH ?g { BGP } populates ?g from inner scan G slots (block-list {DefaultGraph}), which only sees graphs that actually contain matching quads. A G-axis replica of the catalog is deferred.

Float term identity is bit identity (xsd:float / xsd:double)

This extends the numeric value-encoding entry above. For xsd:float / xsd:double, Waratah stores the parsed f32/f64 bit pattern (the wire round-trip preserves exact bits), and both halves of term identity agree on those bits: the manual Hash on EncodedTerm hashes the bit pattern, and — since 2026-10-27 (item 31) — the manual PartialEq compares it bit-wise, arm-for-arm with the hash (waratah_storage/src/encode/encoded_term.rs). Before that, the derived IEEE PartialEq broke the Eq/Hash contract in both directions: == said true for +0.0 vs -0.0 (the hash said distinct) and false for same-bits NaN (the hash said identical), so one NaN literal could double-intern in a query’s TermDict (a join then missed the term against itself) or count twice in COUNT(DISTINCT).

The two equality layers answer opposite questions, and both are now spec-shaped:

Pair (same datatype) Term identity (sameTerm, DISTINCT, hash-keyed structures) Value comparison (=, !=, <, arithmetic)

"NaN" vs "NaN" (same bits)

one term — sameTerm true, DISTINCT keeps one row

op:numeric-equal: false (NaN = NaN → false)

"+0.0" vs "-0.0"

two terms — different lexical forms parse to different bits, so sameTerm is false and DISTINCT keeps both

op:numeric-equal: true (+0.0 = -0.0 → true)

Where each comparison lives, precisely. = (the numeric payload arms of equals in waratah_compute/src/expr/ops.rs, and the residual decoded path’s terms_equal, which runs on lexical model terms) never consulted whole-term equality. Numeric ordering did: compare_numeric’s leading `left == right shortcut doubled as an ordered-equal test — safe only while == was IEEE — so the shortcut now excludes the float variants and NaN falls through to partial_cmp → None → type error: NaN ⇐ NaN stays a type error per op:numeric-less-than. Point equality through RDFterm-equal IS whole-term term identity, and flipped: POINT(0 …) vs POINT(-0 …) was = true, is now = false.

What changed on 2026-10-27 are the term-identity answers: sameTerm on same-bits NaN flipped false → true, whole-term equality on +0.0 vs -0.0 flipped true → false, and a NaN literal now counts once in COUNT(DISTINCT) instead of twice.

LiteralPoint coordinates (f64) follow the same rule: bit identity. Validated ingest never mints a NaN coordinate, but ±0.0 is valid WKT and the two zeros are distinct terms. NaN bit patterns beyond the parser’s canonical one are reachable only through arithmetic, never through a lexical: different-bits NaNs stay distinct terms and decode to the same canonical lexical, while two arithmetic results with the same bits are one term — which is the point of bit identity.

The timestamp family: term identity includes the timezone

For xsd:dateTime, time, date, and the gYear*/gMonth* family, term identity is is_identical_with: the timeline value, the timezone’s presence, and the offset value all compare (waratah_storage/src/encode/encoded_term.rs). Same-instant literals with different offsets are different terms: sameTerm("…T00:00:00Z", "…T01:00:00+01:00") is false — the pre-fix payload == was offset-agnostic when both sides carried an offset and wrongly merged them — while = compares instants and stays true. An unzoned literal never merges with an offset twin, including the -00:01 edge. One wire caveat drove the design: to_be_bytes writes "no timezone" as a [255, 255] sentinel byte-identical to the i16 -1 offset, so the in-memory Hash carries a timezone-presence discriminator alongside the bytes (hash values are per-query state and never persisted). Value ordering (<, ⇐) and = are unchanged: they compare timeline values per the XPath operators.