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 |
|---|---|---|
|
false |
true (same stored value) |
|
false |
false (different variants); |
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) |
|---|---|---|
|
one term — |
|
|
two terms — different lexical forms parse to different bits, so
|
|
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.