Skip to content

SEMANTICS — the conformance spec

Where R/dplyr, polars and duckdb disagree, this file records the decision. Every row below must be encoded as a test that links back here. Comparison against the dplyr oracle is checked modulo these documented divergences — never fuzzily.

Legend: R = follow dplyr · P = follow polars/duckdb · pinned = our own rule, backends forced to comply.

# Area dplyr polars/duckdb Decision
S1 Missing values typed NA, NaN distinct null vs NaN pinned: NA ↔ null bidirectionally; NaN preserved as NaN; document
S2 mean/sum/... with missing NA unless na.rm=TRUE ignore nulls P, with na_rm: bool = True kwarg for familiarity
S3 Sort: NA position & stability NAs last, stable sort varies per engine pinned: stable, NAs last; desc() keeps NAs last
S4 int / int promotes to double varies R (saner)
S5 Integer overflow promotes / warns wraps or errors pinned: Int64 default; overflow errors
S6 String ordering / collation locale-dependent (!) byte/UTF-8 pinned: C-locale codepoint order — known divergence from R; oracle harness normalizes
S7 Grouped result ordering sorted by group keys hash order R: sort by keys
S8 Empty groups / zero-row inputs specific dplyr behaviors varies R; port dplyr regression tests
S9 summarize ungrouping drops last group level n/a R, including the multi-key behavior
S10 Join key NA matching NA matches NA by default SQL: NULL ≠ NULL R default, na_matches="never" opt-out (mirrors dplyr arg)
S11 Join suffixes .x / .y _right etc. R: (".x", ".y")
S12 Boolean with NA (3-valued logic) NA propagates; filter drops NA same in SQL R/SQL (they agree); test it anyway
S13 n() / counts dtype integer u32/i64 pinned: Int64
S14 Division by zero Inf/NaN varies (duckdb errors on int) R: Inf/-Inf/NaN, cast first on duckdb
S15 case_when no match NA null agree; pin the result dtype unification rule
S16 Date/time zones rich, messy UTC-leaning pinned: tz-aware UTC default; naive allowed; conversions explicit
S17 Factors core R type none not supported; oracle harness converts factors → strings before compare
S18 Recycling length-1 values in mutate yes literals broadcast R for scalars only; no general recycling
S19 Float comparison in tests — — harness: sort-normalize where order unspecified + ULP tolerance

Process: when a differential test fails and the cause is a new semantic disagreement, the fix is (1) add a row here, (2) encode it in the harness normalization or backend compiler, (3) add a dedicated test naming the row.

Rows added during implementation (discovered by the oracle/fuzzer):

# Area dplyr polars/duckdb Decision
S20 mean/median of zero values (na_rm=TRUE, all missing) NaN null P: null; oracle harness compares NaN==null for floats
S21 Row order after joins, pivot_longer, distinct-dedup left-order preserved engine-dependent pinned: unspecified; pin with arrange(); oracle/agreement tests sort before compare
S22 Join output column positions left columns keep their original positions, suffixed in place varies R (verified by join goldens)
S23 % modulo floor-mod (R %%) polars floor-mod; SQL trunc-mod R: duckdb compiles to ((a % b) + b) % b
S24 is_in with missing left value NA %in% xs is FALSE null propagates P: null (filter drops it); divergence from R documented
S25 grouped slice_head column order original order polars moves keys first R: compiler restores schema order
S26 pivot_wider duplicate keys warns, builds list-columns polars keeps first pinned: UserWarning + keep first; a NULL in names_from becomes a column named 'null' (dplyr: 'NA')
S27 duckdb sources from different connections in one plan n/a undefined pinned: BackendError at collect; persist one side first
S28 arrange before order-sensitive aggregates (first/last) honored engines vary R: both backends honor pending sort order (ordered aggregates / framed windows on duckdb)
S29 cumulative aggregates over missing values NA poisons the rest (R cumsum) polars: null at the null row, running total continues; SQL: skips P (polars): null at the null position, total continues; divergence from R documented
S30 percent_rank (min_rank-1)/(non-missing-1) SQL percent_rank counts NULL rows R: built from min_rank and non-missing count on both backends
S31 separate() sep regex, default non-alnum n/a pinned: literal separator string, default "_"
S32 unite() missing values "NA" string (na.rm=FALSE) / dropped (TRUE) engines skip or null R: na_rm=False renders 'NA', na_rm=True drops (all-missing rows join to '')
S33 slice_sample algorithm R PRNG (sample()) engine-native samplers differ pinned: shared LCG-mix ordering — same seed selects the same rows on BOTH engines; differs from R's sampler by construction
S34 mixing in-memory dataframes with duckdb tables n/a n/a pinned: plans bridge automatically — duckdb scans the arrow data in place (zero-copy); two different duckdb connections still raise
S27 (rev. 1.7.0) tables on different duckdb connections in one plan n/a undefined pinned: the foreign side streams through arrow onto the primary connection (a copy, UserWarning emitted); to_view across connections still raises
S35 list/array/struct columns list-columns are first-class engines hold them; comparison semantics (nulls inside, ordering) differ pinned (1.9.0): nested columns are carried — select/filter by other columns/mutate copies/lag/lead/first/last/is_na/joins on other keys/persist/parquet·jsonl·arrow writes — and never compared: sorting, grouping, dedup, join keys, pivots, comparisons and aggregates other than first/last on them raise at build time. Dtypes are named canonically (List(Str), Struct(a: Int64), Array(Float64, 3); inner ints/floats widened) so both engines agree. CSV/TSV/Excel writes refuse them (polars refuses, duckdb stringifies)
S36 read(list of records) bind_rows() of rows polars samples the first 100 rows (drops late keys, truncates late floats in lists, stringifies int+str) R: union of keys in first-seen order, missing → NA; every row is scanned (built through arrow); bool+number → number and date+datetime → datetime (vctrs); any other mix raises naming the column and rows; an empty list is a 0x0 table (bind_rows(list())) (1.11.0)
S37 mean/sd/var of a logical TRUE/FALSE count as 1/0 (mean(correct) is the share of TRUE) polars casts; duckdb has no avg(BOOLEAN) R (1.9.0): both backends average the 0/1 values; median of a logical stays an error
S38 Python functions per row (vectorize) mutate(y = map_chr(x, f)), pmap() for several inputs polars map_elements, duckdb Python UDFs; neither is shared, both lose the type at build time pinned (1.10.0): the frame layer lifts each call into a RowMap step that runs in Python between engine steps, so both engines agree by construction. The column type comes from the return annotation (checked at build time). One call per distinct argument set, remembered per (function, version) for the session. Display pushes head() below row functions through row-local steps only. Failures raise after all rows ran (purrr aborts at the first); errors="null" keeps going. Valid in mutate()/filter() only
S39 unnest_longer / unnest_wider / unnest tidyr 1.3: an empty or NULL list drops its row (keep_empty = TRUE keeps one NA row); a NULL record gives NA fields; new columns take the unnested column's place; a name clash errors and names_sep disambiguates polars explode keeps an empty or null list as one null row; duckdb unnest drops both, in no guaranteed order R (1.12.0): both engines follow tidyr, input row order then element order. unnest() = unnest_longer() then, for records, unnest_wider() (tidyr's unnest() of a list of data frames). Lists only (unnest_wider of a plain list, and indices_to, are not ported). Struct fields are read back from the canonical dtype name, so a field name containing ,, : or parentheses is refused