fix(xbar): avoid narrow bucket truncation - #351
Merged
singaraiona merged 1 commit intoJul 28, 2026
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes an xbar vector fast-path edge case where an I64 bucket that doesn’t fit the narrow vector element type (e.g., I16/I32 storage) could be truncated, producing incorrect results. It adds a guard to route those cases through the generic mapping path so results widen correctly instead of silently truncating.
Changes:
- Add a bucket-range guard for the
xbarvectorized fast path to avoid narrowing/truncation forI16/I32/DATE/TIMEvectors. - Fall back to
atomic_map_binary(ray_xbar_fn, ...)when the bucket cannot be represented in the vector’s element storage type, producing widenedI64outputs. - Add regression tests covering wide-bucket behavior for
I32andI16vectors.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
src/ops/query.c |
Adds a bucket-fit check to prevent narrow-type truncation in the xbar vector fast path and safely fall back to generic mapping. |
test/rfl/arith/xbar.rfl |
Adds regression coverage ensuring wide I64 buckets widen results instead of truncating in vector fast path. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
singaraiona
added a commit
that referenced
this pull request
Jul 28, 2026
* v2.4.0 (#327) * feat(query): support live inserts into parted tables Add immutable live-tail growth with explicit partition keys, shared FILE-domain symbol handling, atomic symbol rebinding, adversarial coverage, documentation, and a runnable rollover example. * fix(core): restore total-core -c semantics * fix(parse) Fix nonstring if not defined * fix(store): surface FlushFileBuffers failure in journal SYNC mode (#335) In RAY_JOURNAL_SYNC mode ray_journal_write_bytes checks fsync's return on POSIX and fails the write with RAY_ERR_IO, but the Windows branch ignored FlushFileBuffers' return. A failed flush there was silently swallowed, so SYNC mode reported success while the data may not have reached disk — dropping the durability guarantee the mode exists to provide. Check FlushFileBuffers (0 = failure) and return RAY_ERR_IO, mirroring the POSIX path. Windows-only branch (not built on the Linux/macOS CI matrix), so it is verified by inspection against the adjacent fsync check; the failure path is not unit-testable, like the existing POSIX one. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(hnsw): reject build dims whose vector count overflows size_t (#333) ray_hnsw_build sized the copied vector block as n_nodes * dim * sizeof(float) with no overflow check. Dimensions whose product wraps size_t under-allocate the copy while the memcpy — and every later distance read (vectors + id*dim) — run past the buffer. Guard the product before any allocation, mirroring the per-layer neighbor guard in the loader, and reject overflowing dimensions. This hardens the public C API boundary; the in-tree (hnsw-build ...) path sizes vectors from an in-memory list and cannot reach the overflow, so it is defense-in-depth. Add a regression test driving an overflowing n_nodes/dim pair; with the guard removed it faults under ASan (stack-buffer-overflow at the copy). Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(store): read full link sidecar to avoid wrong-symbol truncation (#334) try_load_link_sidecar read the target table's sym name into a fixed 256-byte buffer (fread of 255 bytes). A name longer than 255 bytes was silently truncated, so ray_sym_intern interned a DIFFERENT symbol and the loaded column linked to the wrong table — silent data corruption on a save/load round-trip. The writer already emits the full, untruncated name. Read the whole sidecar into a buffer sized to the file (capped at 1 MiB to bound a corrupt/oversized file), and reject a short read (fread returning fewer bytes than the file size — an I/O error or a race-truncated sidecar) so a partial name can't be interned as a different symbol either. Add a regression test that links through a 300-byte target name and asserts the loaded link_target matches; it fails without the fix. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(hnsw): reject index files whose vector count overflows size_t (#332) hnsw_load_impl read n_nodes and dim straight from the file header and sized the vectors allocation as n_nodes * dim * sizeof(float) with no overflow check. A crafted header could make that product wrap size_t, so ray_sys_alloc under-allocated the buffer while the following fread still read the full (large) element count and wrote past the allocation — a heap-overflow write driven by an untrusted index file. Factor the check into ray_hnsw_vec_size_valid(n_nodes, dim) and reject the header before any allocation, mirroring the per-layer neighbor guard. Add a unit test that drives the helper directly (ordinary dims, non-positive dims, an overflowing pair, and the exact size_t boundary). It is tested at the helper rather than through ray_hnsw_load because an overflow-patched header is refused earlier — the huge node-level read fails first — so a full-load test could not distinguish the guard. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(docs): remediate F-0001 F-0005 F-0007 Escalate F-0002, F-0003, F-0004, and F-0006 into CF-0001 through CF-0004 after the required corpus census. * fix(docs): remediate CF-0001 * fix(docs): remediate CF-0002 * chore(audit): plan CF-0003 ratification * fix(docs): remediate CF-0003 * feat(docs): redesign website and documentation Rebuild the MkDocs and marketing surfaces around the Rayforce brand, add the live market demo and cloud preview, unify responsive navigation, and eliminate reload layout shifts. * fix(null): avoid f64 null casts to integers (#340) * fix(null): avoid f64 null casts to integers * fix(expr): guard f64 to i64 fallback casts * fix(null): clamp finite f64 narrow casts --------- Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(expr): avoid null truthiness casts in fallback binary ops (#339) * fix(expr): avoid null truthiness casts in fallback binary ops binary_range's fallback OP_AND/OP_OR kernels cast the widened `double` operand straight to `uint8_t`: uint8_t li = (uint8_t)LV_READ(i); `LV_READ` widens integer operands to `double` and yields NaN for float nulls, so this had two defects: - Wrong answers from 8-bit truncation: `(uint8_t)256.0 == 0`, so `256 and 1b` returned false. - Undefined behavior: casting NaN (NULL_F64) or a widened NULL_I64 (-9.2e18) to `uint8_t` is UB per C11 6.3.1.4. UBSan flagged the latter via expr_null/diff_i64_{and,or}_raw and expr_null/diff_f64_andor_chokes. Route AND/OR through two truthiness helpers that compare on the widened double and never cast it back to an integer: - truthy_intish(v, nullv) — false for 0 and for the operand's null sentinel. The fallback reads raw column memory, so a null arrives as the per-type sentinel widened to double (NULL_I16 / NULL_I32 / NULL_I64, with DATE/TIME stored as I32) rather than the NULL_I64 the VM kernel sees; `nullv` is derived per operand from the bound pointer type so I16/I32 nulls read as false, not just I64. - truthy_f64ish(v) — false for 0.0 and NaN (float null). Non-null truthiness is unchanged and null-input positions still agree with the VM kernel (documented "AND/OR with any null operand -> 0" and the fix_null_comparisons post-pass), keeping fallback ≡ fused. Add regression tests pinning fallback ≡ fused for nullable I64, I32 and I16 AND/OR operands (expr_null/diff_i{64,32,16}_{and,or}_raw). * fix(expr): preserve near-sentinel i64 truthiness --------- Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * ci: make Rayforce audit PR comments best-effort * ci: publish Rayforce audit comments from trusted workflow * ci: resolve fork PRs for audit commenter * perf: parallelize serial stages around group-by; unify binary-agg null semantics (#341) * wip(group): parallel binary aggregates (pearson/wavg/cov) via DA path Route binary co-moment aggregators through the dense-array (DA) group path instead of the hash scatter path. Adds sum_y/sumsq_y/sumxy co-moment slots to da_accum_t + per-row accumulation + per-worker merge; emit_agg_columns already finalises PEARSON/COV/WAVG/WSUM from the co-moments. Fixes poor multi-thread scaling of by-key binary aggregates (was ~2x, DA path scales ~9-12x like stddev). Verified vs numpy; diff comparator relaxed to 1e-9 combined abs+rel (1e-12 absolute tested bit-identical summation). Includes temporary RAY_GRPPROF phase instrumentation (to remove). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): merge binary-agg Sx as double for integer x-columns wavg/pearson accumulate Sx as double even when the x column is integer (e.g. wavg(bsize,bid), bsize=I32). The per-worker merge dispatched on the x-column type -> read the double bits as int64 -> garbage at >1 worker. Force float merge for binary aggs at all 3 sum-merge sites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): merge binary-agg co-moments in parallel da_merge_fn path The parallel slot-range merge (da_merge_fn, taken when n_slots>=1024) merged sumsq but not the binary-aggregate co-moment arrays (sum_y/sumsq_y/sumxy). Multi-key pearson/cov/wavg over >=1024 dense slots produced wrong results at >1 worker. Add the DA_NEED_PAIR merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(group): remove temporary RAY_NO_V2/RAY_GRPPROF instrumentation The binary-agg DA fix lands on the default path (v2 declines CHAR-keyed binary group-bys -> legacy DA), so the debug env overrides are no longer needed. 3635/3635 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(if): parallel elementwise OP_IF fill; route trivial-branch if to eager exec_if always took the 'selected' lazy-branch path, whose scaffolding (true-count, id-list build, per-branch gather, scatter) is serial over ALL rows — every if-projection ran at single-core speed regardless of -c (100M numeric if: 2.2s at any core count). 1. exec_if_eager: one shared fixed-width elementwise fill, dispatched across the worker pool for len >= 64K (SYM sides warm their runtime-id LUT serially first — sym.c frozen-table rule, mirrors window.c). STR keeps the serial append path. 2. exec_if_selected: bail to eager when both branches are trivial (column scan / scalar const) and eager fills the type combination correctly — the lazy path only pays off when a branch is an expression worth restricting to its passing rows. Mixed numeric/string shapes stay on the selected path (its per-value string conversion). 100M rows local c24: numeric if 1883->310ms, sym if 2012->306ms. dazzle c48 canonical Q22: 2658->1333ms end-to-end. make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(filter): parallel bitmap->index build in exec_filter and sel_compact exec_filter ran two sequential 0..nrows sweeps (pass-count and match_idx build) before its parallel gather; sel_compact rebuilt match_idx from the rowsel serially. Both now use the classic 3-phase compaction: parallel per-chunk/per-seg counts, tiny serial prefix, parallel fill at disjoint offsets. Lazy/morsel-backed predicates keep the sequential sweep. 100M rows local c24: 2-col where-select 143->21.7ms (1.4x -> 6.6x scaling); if+where 1040->324ms. make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf: parallel where builtin, gather_by_idx, and chunk-task dispatch - ray_where_fn: 3-phase chunk compaction on the pool (was fully serial). - gather_by_idx: fixed-width value gathers dispatched over disjoint output ranges (null-bit propagation stays serial - shared-word bit writes would race). - exec_filter/where chunk phases now use ray_pool_dispatch_n (one task per chunk); ray_pool_dispatch morselizes total_elems by 1024, so passing chunk counts gave only ~2 tasks for 100M rows. 100M rows local c24: where 88->25ms, at-gather 80->31ms, 2-col where-select 138->20ms (6.8x). make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(review): harden parallel paths per skeptic review Blockers (DA binary-agg y-column): - eligibility now requires a plain numeric/temporal y; nullable integer/temporal y stays on the HT path (da_accum_row's pair branch has no y-side sentinel machinery - nulls would accumulate as values) - an FP y with HAS_NULLS sets da_any_nullable so nn[] is allocated and the emitter divides by the non-null PAIR count, not the group count Majors: - all new parallel gates require pool->n_workers > 0 (a -c 1 pool exists with 0 workers; ring fill + atomics + rc_sync were pure overhead, and the OP_IF eager reroute lost to the selected path serially - the Q22/Q25 c1 regression) - chunked dispatch_n call sites cap chunks at 1024 = the pool's initial ring capacity, so the ring never grows (dispatch_n clamps and silently DROPS tasks if ring growth fails -> uninitialized prefix entries -> OOB writes) - sel_compact seg fill switched to dispatch_n over seg-chunks (ray_pool_dispatch over segs gave 1 task under 8.4M rows) - gather_by_idx parallel path guarded by ray_parallel_flag == 0 (leaf utility, 35+ call sites; nested dispatch would corrupt the single-producer task ring) Nits: stray time.h include, restored v2-gate comment, RAY_PARALLEL_THRESHOLD symbol in pivot.c. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): pair-skip y-side nulls in the legacy HT binary-agg path Unify grouped binary-aggregate (pearson/cov/scov/wsum/wavg) null handling with the scalar reducers, the v2 engine and the DA path: a null on either side of the (x,y) pair now voids the whole pair on the legacy HT route too. - ght_compute_layout: a nullable y-side sets GHT_AF2_Y_NULLABLE and routes the layout to the null-aware accumulators. - accum_from_entry_nullable: pair-skip before nn++/sums. - Entry packing canonicalizes integer nulls so the accumulator can see them: NaN in F64-packed slots (a (double)sentinel cast previously read as a huge finite value — this also fixes nullable-int x beside an FP y), NULL_I64 in int-by-int slots. - Both HT emitters (radix + serial) divided pearson/cov/scov moments by the group row count instead of the accumulated pair count — wrong results whenever a group carried any null; now divide by nn. - The all-null-group guards wrote v=0.0 after ray_vec_set_null, so the common store overwrote the null sentinel — emit NULL_F64 instead. - DA eligibility now also rejects a y shorter than the scan (OP_CONST vector literal would read out of bounds). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * test(agg): cross-path null coverage for grouped binary aggregates 46 assertions for wsum/wavg/pearson_corr/cov/scov over nullable inputs on all three grouped routes — v2 (plain-scan int key), DA (expression int key), legacy HT (expression key + nullable-int y; F64-packed and int-packed entry lanes) — against independently computed pair-skip truth, for all four x/y type combinations, plus an all-pairs-null group (wsum 0.0, typed nulls for the ratio/moment aggs) on every route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * chore(review): shared dispatch-safety gate; single filter threshold - ops/internal.h ray_par_dispatch_ok(): workers + RAY_PARALLEL_THRESHOLD + ray_parallel_flag reentrancy check in one place; applied at exec_filter, sel_compact, exec_if_eager and the where builtin (local copy there — builtins.c cannot include ops/internal.h). - exec_filter: gate and table fallback derive from one row count (fidx_rows); note that pass_count from the parallel count phase is consumed by exec_filter_vec for vector inputs. - group.c: drop the never-read da_ctx_t.agg_pair_mask plumbing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * chore(par): shared dispatch predicate, ring-cap constant, parallel-path test Follow-ups from the audit's non-blocking notes: - core/pool.h ray_pool_par_dispatch_ok(pool, n, min_elems): the single home for the dispatch-safety predicate (workers + element threshold + ray_parallel_flag reentrancy). The three hand-copies in ops/internal.h, lang/eval.c and ops/builtins.c are gone; all six gates call the shared one. - RAY_POOL_INIT_TASKS in core/pool.h replaces the hardcoded 1024 at the three dispatch_n chunk caps and in ray_pool_create, with a _Static_assert tying it to RAY_POOL_MAX_TASKS — lowering the initial ring capacity can no longer silently desync from the caps that rely on it. - test/rfl/query/parallel_paths_large.rfl: 200k-row coverage of every new pool-parallel branch (where, gather-by-index, exec_filter, sel_compact, OP_IF numeric and SYM fill incl. the serial LUT warm-up) against closed-form expected values. - RAY_F32 dropped from if_type_eager_ok's whitelist (if_fill_range has no F32 case; unreachable today, kept unreachable deliberately). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(aggr): preserve slice nullability in binary groups * fix(expr): avoid f64 null cast in fallback idiv integer output (#344) binary_range's OP_IDIV kernels for narrow integer output (I64/I32/I16/U8) computed `(intN_t)floor(lv/rv)` guarded only by `rv != 0.0`. That guard does not catch a NaN operand (`NaN != 0.0` is true), so a null float input yields `lv/rv == NaN`, `floor(NaN) == NaN`, and the subsequent cast to an integer type is undefined behavior — UBSan: "nan is outside the range of representable values of type 'long long'" at exec/expr_binary_f64_idiv_mod. Route the cast through the ray_cast_f64_to_{i64,i32,i16,u8}_null helpers, which map NaN to the canonical null sentinel (NULL_I64/I32/I16, 0 for the non-nullable U8) and saturate out-of-range finite results. The null post-pass (propagate_nulls_binary) already overwrites these positions, so final values are unchanged — this only removes the UB and yields the correct sentinel in-buffer. Mirrors the already-safe F64-output IDIV arm (ray_f64_fin) and the sibling casts fixed in "avoid f64 null casts to integers"; depends on those helpers. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(group): avoid f64 null read cast in dense aggs (#343) Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(ipc): preserve boxed data list args (#346) * fix(group): avoid f64 null cast in DA reads (#348) * ci: use portable march for fuzz jobs (#347) Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(pivot): preserve generic missing cells as null (#350) * fix(xbar): avoid narrow bucket truncation (#351) --------- Co-authored-by: Karim <k.nassar@lynxtrading.com> Co-authored-by: Evgen <ebelozerov@lynxtrading.com> Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Evgen Belozerov <yevhenbielozorov@gmail.com> Co-authored-by: Serhii Savchuk <ser.vasilich@hotmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
singaraiona
added a commit
that referenced
this pull request
Aug 4, 2026
* v2.4.0 (#327) * feat(query): support live inserts into parted tables Add immutable live-tail growth with explicit partition keys, shared FILE-domain symbol handling, atomic symbol rebinding, adversarial coverage, documentation, and a runnable rollover example. * fix(core): restore total-core -c semantics * fix(parse) Fix nonstring if not defined * fix(store): surface FlushFileBuffers failure in journal SYNC mode (#335) In RAY_JOURNAL_SYNC mode ray_journal_write_bytes checks fsync's return on POSIX and fails the write with RAY_ERR_IO, but the Windows branch ignored FlushFileBuffers' return. A failed flush there was silently swallowed, so SYNC mode reported success while the data may not have reached disk — dropping the durability guarantee the mode exists to provide. Check FlushFileBuffers (0 = failure) and return RAY_ERR_IO, mirroring the POSIX path. Windows-only branch (not built on the Linux/macOS CI matrix), so it is verified by inspection against the adjacent fsync check; the failure path is not unit-testable, like the existing POSIX one. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(hnsw): reject build dims whose vector count overflows size_t (#333) ray_hnsw_build sized the copied vector block as n_nodes * dim * sizeof(float) with no overflow check. Dimensions whose product wraps size_t under-allocate the copy while the memcpy — and every later distance read (vectors + id*dim) — run past the buffer. Guard the product before any allocation, mirroring the per-layer neighbor guard in the loader, and reject overflowing dimensions. This hardens the public C API boundary; the in-tree (hnsw-build ...) path sizes vectors from an in-memory list and cannot reach the overflow, so it is defense-in-depth. Add a regression test driving an overflowing n_nodes/dim pair; with the guard removed it faults under ASan (stack-buffer-overflow at the copy). Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(store): read full link sidecar to avoid wrong-symbol truncation (#334) try_load_link_sidecar read the target table's sym name into a fixed 256-byte buffer (fread of 255 bytes). A name longer than 255 bytes was silently truncated, so ray_sym_intern interned a DIFFERENT symbol and the loaded column linked to the wrong table — silent data corruption on a save/load round-trip. The writer already emits the full, untruncated name. Read the whole sidecar into a buffer sized to the file (capped at 1 MiB to bound a corrupt/oversized file), and reject a short read (fread returning fewer bytes than the file size — an I/O error or a race-truncated sidecar) so a partial name can't be interned as a different symbol either. Add a regression test that links through a 300-byte target name and asserts the loaded link_target matches; it fails without the fix. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(hnsw): reject index files whose vector count overflows size_t (#332) hnsw_load_impl read n_nodes and dim straight from the file header and sized the vectors allocation as n_nodes * dim * sizeof(float) with no overflow check. A crafted header could make that product wrap size_t, so ray_sys_alloc under-allocated the buffer while the following fread still read the full (large) element count and wrote past the allocation — a heap-overflow write driven by an untrusted index file. Factor the check into ray_hnsw_vec_size_valid(n_nodes, dim) and reject the header before any allocation, mirroring the per-layer neighbor guard. Add a unit test that drives the helper directly (ordinary dims, non-positive dims, an overflowing pair, and the exact size_t boundary). It is tested at the helper rather than through ray_hnsw_load because an overflow-patched header is refused earlier — the huge node-level read fails first — so a full-load test could not distinguish the guard. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(docs): remediate F-0001 F-0005 F-0007 Escalate F-0002, F-0003, F-0004, and F-0006 into CF-0001 through CF-0004 after the required corpus census. * fix(docs): remediate CF-0001 * fix(docs): remediate CF-0002 * chore(audit): plan CF-0003 ratification * fix(docs): remediate CF-0003 * feat(docs): redesign website and documentation Rebuild the MkDocs and marketing surfaces around the Rayforce brand, add the live market demo and cloud preview, unify responsive navigation, and eliminate reload layout shifts. * fix(null): avoid f64 null casts to integers (#340) * fix(null): avoid f64 null casts to integers * fix(expr): guard f64 to i64 fallback casts * fix(null): clamp finite f64 narrow casts --------- Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(expr): avoid null truthiness casts in fallback binary ops (#339) * fix(expr): avoid null truthiness casts in fallback binary ops binary_range's fallback OP_AND/OP_OR kernels cast the widened `double` operand straight to `uint8_t`: uint8_t li = (uint8_t)LV_READ(i); `LV_READ` widens integer operands to `double` and yields NaN for float nulls, so this had two defects: - Wrong answers from 8-bit truncation: `(uint8_t)256.0 == 0`, so `256 and 1b` returned false. - Undefined behavior: casting NaN (NULL_F64) or a widened NULL_I64 (-9.2e18) to `uint8_t` is UB per C11 6.3.1.4. UBSan flagged the latter via expr_null/diff_i64_{and,or}_raw and expr_null/diff_f64_andor_chokes. Route AND/OR through two truthiness helpers that compare on the widened double and never cast it back to an integer: - truthy_intish(v, nullv) — false for 0 and for the operand's null sentinel. The fallback reads raw column memory, so a null arrives as the per-type sentinel widened to double (NULL_I16 / NULL_I32 / NULL_I64, with DATE/TIME stored as I32) rather than the NULL_I64 the VM kernel sees; `nullv` is derived per operand from the bound pointer type so I16/I32 nulls read as false, not just I64. - truthy_f64ish(v) — false for 0.0 and NaN (float null). Non-null truthiness is unchanged and null-input positions still agree with the VM kernel (documented "AND/OR with any null operand -> 0" and the fix_null_comparisons post-pass), keeping fallback ≡ fused. Add regression tests pinning fallback ≡ fused for nullable I64, I32 and I16 AND/OR operands (expr_null/diff_i{64,32,16}_{and,or}_raw). * fix(expr): preserve near-sentinel i64 truthiness --------- Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * ci: make Rayforce audit PR comments best-effort * ci: publish Rayforce audit comments from trusted workflow * ci: resolve fork PRs for audit commenter * perf: parallelize serial stages around group-by; unify binary-agg null semantics (#341) * wip(group): parallel binary aggregates (pearson/wavg/cov) via DA path Route binary co-moment aggregators through the dense-array (DA) group path instead of the hash scatter path. Adds sum_y/sumsq_y/sumxy co-moment slots to da_accum_t + per-row accumulation + per-worker merge; emit_agg_columns already finalises PEARSON/COV/WAVG/WSUM from the co-moments. Fixes poor multi-thread scaling of by-key binary aggregates (was ~2x, DA path scales ~9-12x like stddev). Verified vs numpy; diff comparator relaxed to 1e-9 combined abs+rel (1e-12 absolute tested bit-identical summation). Includes temporary RAY_GRPPROF phase instrumentation (to remove). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): merge binary-agg Sx as double for integer x-columns wavg/pearson accumulate Sx as double even when the x column is integer (e.g. wavg(bsize,bid), bsize=I32). The per-worker merge dispatched on the x-column type -> read the double bits as int64 -> garbage at >1 worker. Force float merge for binary aggs at all 3 sum-merge sites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): merge binary-agg co-moments in parallel da_merge_fn path The parallel slot-range merge (da_merge_fn, taken when n_slots>=1024) merged sumsq but not the binary-aggregate co-moment arrays (sum_y/sumsq_y/sumxy). Multi-key pearson/cov/wavg over >=1024 dense slots produced wrong results at >1 worker. Add the DA_NEED_PAIR merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(group): remove temporary RAY_NO_V2/RAY_GRPPROF instrumentation The binary-agg DA fix lands on the default path (v2 declines CHAR-keyed binary group-bys -> legacy DA), so the debug env overrides are no longer needed. 3635/3635 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(if): parallel elementwise OP_IF fill; route trivial-branch if to eager exec_if always took the 'selected' lazy-branch path, whose scaffolding (true-count, id-list build, per-branch gather, scatter) is serial over ALL rows — every if-projection ran at single-core speed regardless of -c (100M numeric if: 2.2s at any core count). 1. exec_if_eager: one shared fixed-width elementwise fill, dispatched across the worker pool for len >= 64K (SYM sides warm their runtime-id LUT serially first — sym.c frozen-table rule, mirrors window.c). STR keeps the serial append path. 2. exec_if_selected: bail to eager when both branches are trivial (column scan / scalar const) and eager fills the type combination correctly — the lazy path only pays off when a branch is an expression worth restricting to its passing rows. Mixed numeric/string shapes stay on the selected path (its per-value string conversion). 100M rows local c24: numeric if 1883->310ms, sym if 2012->306ms. dazzle c48 canonical Q22: 2658->1333ms end-to-end. make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(filter): parallel bitmap->index build in exec_filter and sel_compact exec_filter ran two sequential 0..nrows sweeps (pass-count and match_idx build) before its parallel gather; sel_compact rebuilt match_idx from the rowsel serially. Both now use the classic 3-phase compaction: parallel per-chunk/per-seg counts, tiny serial prefix, parallel fill at disjoint offsets. Lazy/morsel-backed predicates keep the sequential sweep. 100M rows local c24: 2-col where-select 143->21.7ms (1.4x -> 6.6x scaling); if+where 1040->324ms. make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf: parallel where builtin, gather_by_idx, and chunk-task dispatch - ray_where_fn: 3-phase chunk compaction on the pool (was fully serial). - gather_by_idx: fixed-width value gathers dispatched over disjoint output ranges (null-bit propagation stays serial - shared-word bit writes would race). - exec_filter/where chunk phases now use ray_pool_dispatch_n (one task per chunk); ray_pool_dispatch morselizes total_elems by 1024, so passing chunk counts gave only ~2 tasks for 100M rows. 100M rows local c24: where 88->25ms, at-gather 80->31ms, 2-col where-select 138->20ms (6.8x). make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(review): harden parallel paths per skeptic review Blockers (DA binary-agg y-column): - eligibility now requires a plain numeric/temporal y; nullable integer/temporal y stays on the HT path (da_accum_row's pair branch has no y-side sentinel machinery - nulls would accumulate as values) - an FP y with HAS_NULLS sets da_any_nullable so nn[] is allocated and the emitter divides by the non-null PAIR count, not the group count Majors: - all new parallel gates require pool->n_workers > 0 (a -c 1 pool exists with 0 workers; ring fill + atomics + rc_sync were pure overhead, and the OP_IF eager reroute lost to the selected path serially - the Q22/Q25 c1 regression) - chunked dispatch_n call sites cap chunks at 1024 = the pool's initial ring capacity, so the ring never grows (dispatch_n clamps and silently DROPS tasks if ring growth fails -> uninitialized prefix entries -> OOB writes) - sel_compact seg fill switched to dispatch_n over seg-chunks (ray_pool_dispatch over segs gave 1 task under 8.4M rows) - gather_by_idx parallel path guarded by ray_parallel_flag == 0 (leaf utility, 35+ call sites; nested dispatch would corrupt the single-producer task ring) Nits: stray time.h include, restored v2-gate comment, RAY_PARALLEL_THRESHOLD symbol in pivot.c. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): pair-skip y-side nulls in the legacy HT binary-agg path Unify grouped binary-aggregate (pearson/cov/scov/wsum/wavg) null handling with the scalar reducers, the v2 engine and the DA path: a null on either side of the (x,y) pair now voids the whole pair on the legacy HT route too. - ght_compute_layout: a nullable y-side sets GHT_AF2_Y_NULLABLE and routes the layout to the null-aware accumulators. - accum_from_entry_nullable: pair-skip before nn++/sums. - Entry packing canonicalizes integer nulls so the accumulator can see them: NaN in F64-packed slots (a (double)sentinel cast previously read as a huge finite value — this also fixes nullable-int x beside an FP y), NULL_I64 in int-by-int slots. - Both HT emitters (radix + serial) divided pearson/cov/scov moments by the group row count instead of the accumulated pair count — wrong results whenever a group carried any null; now divide by nn. - The all-null-group guards wrote v=0.0 after ray_vec_set_null, so the common store overwrote the null sentinel — emit NULL_F64 instead. - DA eligibility now also rejects a y shorter than the scan (OP_CONST vector literal would read out of bounds). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * test(agg): cross-path null coverage for grouped binary aggregates 46 assertions for wsum/wavg/pearson_corr/cov/scov over nullable inputs on all three grouped routes — v2 (plain-scan int key), DA (expression int key), legacy HT (expression key + nullable-int y; F64-packed and int-packed entry lanes) — against independently computed pair-skip truth, for all four x/y type combinations, plus an all-pairs-null group (wsum 0.0, typed nulls for the ratio/moment aggs) on every route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * chore(review): shared dispatch-safety gate; single filter threshold - ops/internal.h ray_par_dispatch_ok(): workers + RAY_PARALLEL_THRESHOLD + ray_parallel_flag reentrancy check in one place; applied at exec_filter, sel_compact, exec_if_eager and the where builtin (local copy there — builtins.c cannot include ops/internal.h). - exec_filter: gate and table fallback derive from one row count (fidx_rows); note that pass_count from the parallel count phase is consumed by exec_filter_vec for vector inputs. - group.c: drop the never-read da_ctx_t.agg_pair_mask plumbing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * chore(par): shared dispatch predicate, ring-cap constant, parallel-path test Follow-ups from the audit's non-blocking notes: - core/pool.h ray_pool_par_dispatch_ok(pool, n, min_elems): the single home for the dispatch-safety predicate (workers + element threshold + ray_parallel_flag reentrancy). The three hand-copies in ops/internal.h, lang/eval.c and ops/builtins.c are gone; all six gates call the shared one. - RAY_POOL_INIT_TASKS in core/pool.h replaces the hardcoded 1024 at the three dispatch_n chunk caps and in ray_pool_create, with a _Static_assert tying it to RAY_POOL_MAX_TASKS — lowering the initial ring capacity can no longer silently desync from the caps that rely on it. - test/rfl/query/parallel_paths_large.rfl: 200k-row coverage of every new pool-parallel branch (where, gather-by-index, exec_filter, sel_compact, OP_IF numeric and SYM fill incl. the serial LUT warm-up) against closed-form expected values. - RAY_F32 dropped from if_type_eager_ok's whitelist (if_fill_range has no F32 case; unreachable today, kept unreachable deliberately). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(aggr): preserve slice nullability in binary groups * fix(expr): avoid f64 null cast in fallback idiv integer output (#344) binary_range's OP_IDIV kernels for narrow integer output (I64/I32/I16/U8) computed `(intN_t)floor(lv/rv)` guarded only by `rv != 0.0`. That guard does not catch a NaN operand (`NaN != 0.0` is true), so a null float input yields `lv/rv == NaN`, `floor(NaN) == NaN`, and the subsequent cast to an integer type is undefined behavior — UBSan: "nan is outside the range of representable values of type 'long long'" at exec/expr_binary_f64_idiv_mod. Route the cast through the ray_cast_f64_to_{i64,i32,i16,u8}_null helpers, which map NaN to the canonical null sentinel (NULL_I64/I32/I16, 0 for the non-nullable U8) and saturate out-of-range finite results. The null post-pass (propagate_nulls_binary) already overwrites these positions, so final values are unchanged — this only removes the UB and yields the correct sentinel in-buffer. Mirrors the already-safe F64-output IDIV arm (ray_f64_fin) and the sibling casts fixed in "avoid f64 null casts to integers"; depends on those helpers. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(group): avoid f64 null read cast in dense aggs (#343) Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(ipc): preserve boxed data list args (#346) * fix(group): avoid f64 null cast in DA reads (#348) * ci: use portable march for fuzz jobs (#347) Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(pivot): preserve generic missing cells as null (#350) * fix(xbar): avoid narrow bucket truncation (#351) * fix(arith): reject float temporal operands (#353) * fix(ops): make nested (LIST) columns usable through a parted view (fixes #355) (#356) * fix(query): preserve temporal arithmetic semantics (#354) * ci: skip audit comments for cancelled runs * fix(query): preserve if temporal branch types (#359) * fix(store): reject duplicate splayed column names (#360) * fix(store): reject duplicate splayed column names * test(ci): harden Ctrl-C PTY synchronization --------- Co-authored-by: Anton <singaraiona@gmail.com> * fix(builtins): reject malformed strings in TIMESTAMP cast (#361) * fix(builtins): reject malformed strings in TIMESTAMP cast (as 'TIMESTAMP str) accepted a range of malformed inputs and silently produced a valid-looking but wrong value — invisible data corruption at the call site. Examples that used to succeed: - "2024-01-02x01:02:03", "2024-01-02abc" -> midnight (time dropped) - "2024-01-02T25:02:03" -> rolled into the next day - "2024-01-02T12:34junk", "...03Zjunk" -> trailing garbage ignored - "2024-01-02T12:34:" -> dangling component ignored - "2024-01-02T12:34:03+99:99" -> out-of-range tz, wrong date Root cause: the parser used unanchored sscanf calls that matched a prefix and ignored the rest, and it never range-checked the components. Replace it with a bounded cursor over the grammar YYYY<sep>MM<sep>DD [ (T|' '|D) HH:MM[:SS][.frac] [Z|(+|-)HH[:]?MM] ] (<sep> is '-' or '.', consistent within the date). The cursor must reach the end of the string, every field is a fixed digit width, and the date, time, and timezone components are range-checked; anything else is a domain error. All previously accepted valid forms — bare date, space/T/D separators, fractional seconds (any length), and Z / +HH:MM / -HHMM / offset suffixes — continue to round-trip. Extends the TIMESTAMP-cast coverage in ops/builtins_branch_cov.rfl with the separator, out-of-range, trailing-garbage, partial-component, and out-of-range-timezone rejection cases. * fix(builtins): validate timestamp cast bounds --------- Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(store): support nested column inserts and persistence (#365) * fix(eval): enforce restricted mode in compiled lambdas (#366) * feat(core): add bounded poll step for embedders (#367) * fix(test): rely on public runtime declarations (#369) * feat: website add consumer --------- Co-authored-by: Karim <k.nassar@lynxtrading.com> Co-authored-by: Evgen <ebelozerov@lynxtrading.com> Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Evgen Belozerov <yevhenbielozorov@gmail.com> Co-authored-by: Serhii Savchuk <ser.vasilich@hotmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
singaraiona
added a commit
that referenced
this pull request
Aug 4, 2026
* v2.4.0 (#327) * feat(query): support live inserts into parted tables Add immutable live-tail growth with explicit partition keys, shared FILE-domain symbol handling, atomic symbol rebinding, adversarial coverage, documentation, and a runnable rollover example. * fix(core): restore total-core -c semantics * fix(parse) Fix nonstring if not defined * fix(store): surface FlushFileBuffers failure in journal SYNC mode (#335) In RAY_JOURNAL_SYNC mode ray_journal_write_bytes checks fsync's return on POSIX and fails the write with RAY_ERR_IO, but the Windows branch ignored FlushFileBuffers' return. A failed flush there was silently swallowed, so SYNC mode reported success while the data may not have reached disk — dropping the durability guarantee the mode exists to provide. Check FlushFileBuffers (0 = failure) and return RAY_ERR_IO, mirroring the POSIX path. Windows-only branch (not built on the Linux/macOS CI matrix), so it is verified by inspection against the adjacent fsync check; the failure path is not unit-testable, like the existing POSIX one. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(hnsw): reject build dims whose vector count overflows size_t (#333) ray_hnsw_build sized the copied vector block as n_nodes * dim * sizeof(float) with no overflow check. Dimensions whose product wraps size_t under-allocate the copy while the memcpy — and every later distance read (vectors + id*dim) — run past the buffer. Guard the product before any allocation, mirroring the per-layer neighbor guard in the loader, and reject overflowing dimensions. This hardens the public C API boundary; the in-tree (hnsw-build ...) path sizes vectors from an in-memory list and cannot reach the overflow, so it is defense-in-depth. Add a regression test driving an overflowing n_nodes/dim pair; with the guard removed it faults under ASan (stack-buffer-overflow at the copy). Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(store): read full link sidecar to avoid wrong-symbol truncation (#334) try_load_link_sidecar read the target table's sym name into a fixed 256-byte buffer (fread of 255 bytes). A name longer than 255 bytes was silently truncated, so ray_sym_intern interned a DIFFERENT symbol and the loaded column linked to the wrong table — silent data corruption on a save/load round-trip. The writer already emits the full, untruncated name. Read the whole sidecar into a buffer sized to the file (capped at 1 MiB to bound a corrupt/oversized file), and reject a short read (fread returning fewer bytes than the file size — an I/O error or a race-truncated sidecar) so a partial name can't be interned as a different symbol either. Add a regression test that links through a 300-byte target name and asserts the loaded link_target matches; it fails without the fix. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(hnsw): reject index files whose vector count overflows size_t (#332) hnsw_load_impl read n_nodes and dim straight from the file header and sized the vectors allocation as n_nodes * dim * sizeof(float) with no overflow check. A crafted header could make that product wrap size_t, so ray_sys_alloc under-allocated the buffer while the following fread still read the full (large) element count and wrote past the allocation — a heap-overflow write driven by an untrusted index file. Factor the check into ray_hnsw_vec_size_valid(n_nodes, dim) and reject the header before any allocation, mirroring the per-layer neighbor guard. Add a unit test that drives the helper directly (ordinary dims, non-positive dims, an overflowing pair, and the exact size_t boundary). It is tested at the helper rather than through ray_hnsw_load because an overflow-patched header is refused earlier — the huge node-level read fails first — so a full-load test could not distinguish the guard. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(docs): remediate F-0001 F-0005 F-0007 Escalate F-0002, F-0003, F-0004, and F-0006 into CF-0001 through CF-0004 after the required corpus census. * fix(docs): remediate CF-0001 * fix(docs): remediate CF-0002 * chore(audit): plan CF-0003 ratification * fix(docs): remediate CF-0003 * feat(docs): redesign website and documentation Rebuild the MkDocs and marketing surfaces around the Rayforce brand, add the live market demo and cloud preview, unify responsive navigation, and eliminate reload layout shifts. * fix(null): avoid f64 null casts to integers (#340) * fix(null): avoid f64 null casts to integers * fix(expr): guard f64 to i64 fallback casts * fix(null): clamp finite f64 narrow casts --------- Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(expr): avoid null truthiness casts in fallback binary ops (#339) * fix(expr): avoid null truthiness casts in fallback binary ops binary_range's fallback OP_AND/OP_OR kernels cast the widened `double` operand straight to `uint8_t`: uint8_t li = (uint8_t)LV_READ(i); `LV_READ` widens integer operands to `double` and yields NaN for float nulls, so this had two defects: - Wrong answers from 8-bit truncation: `(uint8_t)256.0 == 0`, so `256 and 1b` returned false. - Undefined behavior: casting NaN (NULL_F64) or a widened NULL_I64 (-9.2e18) to `uint8_t` is UB per C11 6.3.1.4. UBSan flagged the latter via expr_null/diff_i64_{and,or}_raw and expr_null/diff_f64_andor_chokes. Route AND/OR through two truthiness helpers that compare on the widened double and never cast it back to an integer: - truthy_intish(v, nullv) — false for 0 and for the operand's null sentinel. The fallback reads raw column memory, so a null arrives as the per-type sentinel widened to double (NULL_I16 / NULL_I32 / NULL_I64, with DATE/TIME stored as I32) rather than the NULL_I64 the VM kernel sees; `nullv` is derived per operand from the bound pointer type so I16/I32 nulls read as false, not just I64. - truthy_f64ish(v) — false for 0.0 and NaN (float null). Non-null truthiness is unchanged and null-input positions still agree with the VM kernel (documented "AND/OR with any null operand -> 0" and the fix_null_comparisons post-pass), keeping fallback ≡ fused. Add regression tests pinning fallback ≡ fused for nullable I64, I32 and I16 AND/OR operands (expr_null/diff_i{64,32,16}_{and,or}_raw). * fix(expr): preserve near-sentinel i64 truthiness --------- Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * ci: make Rayforce audit PR comments best-effort * ci: publish Rayforce audit comments from trusted workflow * ci: resolve fork PRs for audit commenter * perf: parallelize serial stages around group-by; unify binary-agg null semantics (#341) * wip(group): parallel binary aggregates (pearson/wavg/cov) via DA path Route binary co-moment aggregators through the dense-array (DA) group path instead of the hash scatter path. Adds sum_y/sumsq_y/sumxy co-moment slots to da_accum_t + per-row accumulation + per-worker merge; emit_agg_columns already finalises PEARSON/COV/WAVG/WSUM from the co-moments. Fixes poor multi-thread scaling of by-key binary aggregates (was ~2x, DA path scales ~9-12x like stddev). Verified vs numpy; diff comparator relaxed to 1e-9 combined abs+rel (1e-12 absolute tested bit-identical summation). Includes temporary RAY_GRPPROF phase instrumentation (to remove). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): merge binary-agg Sx as double for integer x-columns wavg/pearson accumulate Sx as double even when the x column is integer (e.g. wavg(bsize,bid), bsize=I32). The per-worker merge dispatched on the x-column type -> read the double bits as int64 -> garbage at >1 worker. Force float merge for binary aggs at all 3 sum-merge sites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): merge binary-agg co-moments in parallel da_merge_fn path The parallel slot-range merge (da_merge_fn, taken when n_slots>=1024) merged sumsq but not the binary-aggregate co-moment arrays (sum_y/sumsq_y/sumxy). Multi-key pearson/cov/wavg over >=1024 dense slots produced wrong results at >1 worker. Add the DA_NEED_PAIR merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(group): remove temporary RAY_NO_V2/RAY_GRPPROF instrumentation The binary-agg DA fix lands on the default path (v2 declines CHAR-keyed binary group-bys -> legacy DA), so the debug env overrides are no longer needed. 3635/3635 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(if): parallel elementwise OP_IF fill; route trivial-branch if to eager exec_if always took the 'selected' lazy-branch path, whose scaffolding (true-count, id-list build, per-branch gather, scatter) is serial over ALL rows — every if-projection ran at single-core speed regardless of -c (100M numeric if: 2.2s at any core count). 1. exec_if_eager: one shared fixed-width elementwise fill, dispatched across the worker pool for len >= 64K (SYM sides warm their runtime-id LUT serially first — sym.c frozen-table rule, mirrors window.c). STR keeps the serial append path. 2. exec_if_selected: bail to eager when both branches are trivial (column scan / scalar const) and eager fills the type combination correctly — the lazy path only pays off when a branch is an expression worth restricting to its passing rows. Mixed numeric/string shapes stay on the selected path (its per-value string conversion). 100M rows local c24: numeric if 1883->310ms, sym if 2012->306ms. dazzle c48 canonical Q22: 2658->1333ms end-to-end. make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(filter): parallel bitmap->index build in exec_filter and sel_compact exec_filter ran two sequential 0..nrows sweeps (pass-count and match_idx build) before its parallel gather; sel_compact rebuilt match_idx from the rowsel serially. Both now use the classic 3-phase compaction: parallel per-chunk/per-seg counts, tiny serial prefix, parallel fill at disjoint offsets. Lazy/morsel-backed predicates keep the sequential sweep. 100M rows local c24: 2-col where-select 143->21.7ms (1.4x -> 6.6x scaling); if+where 1040->324ms. make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf: parallel where builtin, gather_by_idx, and chunk-task dispatch - ray_where_fn: 3-phase chunk compaction on the pool (was fully serial). - gather_by_idx: fixed-width value gathers dispatched over disjoint output ranges (null-bit propagation stays serial - shared-word bit writes would race). - exec_filter/where chunk phases now use ray_pool_dispatch_n (one task per chunk); ray_pool_dispatch morselizes total_elems by 1024, so passing chunk counts gave only ~2 tasks for 100M rows. 100M rows local c24: where 88->25ms, at-gather 80->31ms, 2-col where-select 138->20ms (6.8x). make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(review): harden parallel paths per skeptic review Blockers (DA binary-agg y-column): - eligibility now requires a plain numeric/temporal y; nullable integer/temporal y stays on the HT path (da_accum_row's pair branch has no y-side sentinel machinery - nulls would accumulate as values) - an FP y with HAS_NULLS sets da_any_nullable so nn[] is allocated and the emitter divides by the non-null PAIR count, not the group count Majors: - all new parallel gates require pool->n_workers > 0 (a -c 1 pool exists with 0 workers; ring fill + atomics + rc_sync were pure overhead, and the OP_IF eager reroute lost to the selected path serially - the Q22/Q25 c1 regression) - chunked dispatch_n call sites cap chunks at 1024 = the pool's initial ring capacity, so the ring never grows (dispatch_n clamps and silently DROPS tasks if ring growth fails -> uninitialized prefix entries -> OOB writes) - sel_compact seg fill switched to dispatch_n over seg-chunks (ray_pool_dispatch over segs gave 1 task under 8.4M rows) - gather_by_idx parallel path guarded by ray_parallel_flag == 0 (leaf utility, 35+ call sites; nested dispatch would corrupt the single-producer task ring) Nits: stray time.h include, restored v2-gate comment, RAY_PARALLEL_THRESHOLD symbol in pivot.c. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): pair-skip y-side nulls in the legacy HT binary-agg path Unify grouped binary-aggregate (pearson/cov/scov/wsum/wavg) null handling with the scalar reducers, the v2 engine and the DA path: a null on either side of the (x,y) pair now voids the whole pair on the legacy HT route too. - ght_compute_layout: a nullable y-side sets GHT_AF2_Y_NULLABLE and routes the layout to the null-aware accumulators. - accum_from_entry_nullable: pair-skip before nn++/sums. - Entry packing canonicalizes integer nulls so the accumulator can see them: NaN in F64-packed slots (a (double)sentinel cast previously read as a huge finite value — this also fixes nullable-int x beside an FP y), NULL_I64 in int-by-int slots. - Both HT emitters (radix + serial) divided pearson/cov/scov moments by the group row count instead of the accumulated pair count — wrong results whenever a group carried any null; now divide by nn. - The all-null-group guards wrote v=0.0 after ray_vec_set_null, so the common store overwrote the null sentinel — emit NULL_F64 instead. - DA eligibility now also rejects a y shorter than the scan (OP_CONST vector literal would read out of bounds). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * test(agg): cross-path null coverage for grouped binary aggregates 46 assertions for wsum/wavg/pearson_corr/cov/scov over nullable inputs on all three grouped routes — v2 (plain-scan int key), DA (expression int key), legacy HT (expression key + nullable-int y; F64-packed and int-packed entry lanes) — against independently computed pair-skip truth, for all four x/y type combinations, plus an all-pairs-null group (wsum 0.0, typed nulls for the ratio/moment aggs) on every route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * chore(review): shared dispatch-safety gate; single filter threshold - ops/internal.h ray_par_dispatch_ok(): workers + RAY_PARALLEL_THRESHOLD + ray_parallel_flag reentrancy check in one place; applied at exec_filter, sel_compact, exec_if_eager and the where builtin (local copy there — builtins.c cannot include ops/internal.h). - exec_filter: gate and table fallback derive from one row count (fidx_rows); note that pass_count from the parallel count phase is consumed by exec_filter_vec for vector inputs. - group.c: drop the never-read da_ctx_t.agg_pair_mask plumbing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * chore(par): shared dispatch predicate, ring-cap constant, parallel-path test Follow-ups from the audit's non-blocking notes: - core/pool.h ray_pool_par_dispatch_ok(pool, n, min_elems): the single home for the dispatch-safety predicate (workers + element threshold + ray_parallel_flag reentrancy). The three hand-copies in ops/internal.h, lang/eval.c and ops/builtins.c are gone; all six gates call the shared one. - RAY_POOL_INIT_TASKS in core/pool.h replaces the hardcoded 1024 at the three dispatch_n chunk caps and in ray_pool_create, with a _Static_assert tying it to RAY_POOL_MAX_TASKS — lowering the initial ring capacity can no longer silently desync from the caps that rely on it. - test/rfl/query/parallel_paths_large.rfl: 200k-row coverage of every new pool-parallel branch (where, gather-by-index, exec_filter, sel_compact, OP_IF numeric and SYM fill incl. the serial LUT warm-up) against closed-form expected values. - RAY_F32 dropped from if_type_eager_ok's whitelist (if_fill_range has no F32 case; unreachable today, kept unreachable deliberately). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(aggr): preserve slice nullability in binary groups * fix(expr): avoid f64 null cast in fallback idiv integer output (#344) binary_range's OP_IDIV kernels for narrow integer output (I64/I32/I16/U8) computed `(intN_t)floor(lv/rv)` guarded only by `rv != 0.0`. That guard does not catch a NaN operand (`NaN != 0.0` is true), so a null float input yields `lv/rv == NaN`, `floor(NaN) == NaN`, and the subsequent cast to an integer type is undefined behavior — UBSan: "nan is outside the range of representable values of type 'long long'" at exec/expr_binary_f64_idiv_mod. Route the cast through the ray_cast_f64_to_{i64,i32,i16,u8}_null helpers, which map NaN to the canonical null sentinel (NULL_I64/I32/I16, 0 for the non-nullable U8) and saturate out-of-range finite results. The null post-pass (propagate_nulls_binary) already overwrites these positions, so final values are unchanged — this only removes the UB and yields the correct sentinel in-buffer. Mirrors the already-safe F64-output IDIV arm (ray_f64_fin) and the sibling casts fixed in "avoid f64 null casts to integers"; depends on those helpers. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(group): avoid f64 null read cast in dense aggs (#343) Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(ipc): preserve boxed data list args (#346) * fix(group): avoid f64 null cast in DA reads (#348) * ci: use portable march for fuzz jobs (#347) Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(pivot): preserve generic missing cells as null (#350) * fix(xbar): avoid narrow bucket truncation (#351) * fix(arith): reject float temporal operands (#353) * fix(ops): make nested (LIST) columns usable through a parted view (fixes #355) (#356) * fix(query): preserve temporal arithmetic semantics (#354) * ci: skip audit comments for cancelled runs * fix(query): preserve if temporal branch types (#359) * fix(store): reject duplicate splayed column names (#360) * fix(store): reject duplicate splayed column names * test(ci): harden Ctrl-C PTY synchronization --------- Co-authored-by: Anton <singaraiona@gmail.com> * fix(builtins): reject malformed strings in TIMESTAMP cast (#361) * fix(builtins): reject malformed strings in TIMESTAMP cast (as 'TIMESTAMP str) accepted a range of malformed inputs and silently produced a valid-looking but wrong value — invisible data corruption at the call site. Examples that used to succeed: - "2024-01-02x01:02:03", "2024-01-02abc" -> midnight (time dropped) - "2024-01-02T25:02:03" -> rolled into the next day - "2024-01-02T12:34junk", "...03Zjunk" -> trailing garbage ignored - "2024-01-02T12:34:" -> dangling component ignored - "2024-01-02T12:34:03+99:99" -> out-of-range tz, wrong date Root cause: the parser used unanchored sscanf calls that matched a prefix and ignored the rest, and it never range-checked the components. Replace it with a bounded cursor over the grammar YYYY<sep>MM<sep>DD [ (T|' '|D) HH:MM[:SS][.frac] [Z|(+|-)HH[:]?MM] ] (<sep> is '-' or '.', consistent within the date). The cursor must reach the end of the string, every field is a fixed digit width, and the date, time, and timezone components are range-checked; anything else is a domain error. All previously accepted valid forms — bare date, space/T/D separators, fractional seconds (any length), and Z / +HH:MM / -HHMM / offset suffixes — continue to round-trip. Extends the TIMESTAMP-cast coverage in ops/builtins_branch_cov.rfl with the separator, out-of-range, trailing-garbage, partial-component, and out-of-range-timezone rejection cases. * fix(builtins): validate timestamp cast bounds --------- Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(store): support nested column inserts and persistence (#365) * fix(eval): enforce restricted mode in compiled lambdas (#366) * feat(core): add bounded poll step for embedders (#367) * fix(test): rely on public runtime declarations (#369) * feat: website add consumer * feat(core): expose restricted poll mode (#372) * fix(aggr): preserve grouped nested first and last --------- Co-authored-by: Karim <k.nassar@lynxtrading.com> Co-authored-by: Evgen <ebelozerov@lynxtrading.com> Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Evgen Belozerov <yevhenbielozorov@gmail.com> Co-authored-by: Serhii Savchuk <ser.vasilich@hotmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
singaraiona
added a commit
that referenced
this pull request
Aug 5, 2026
* v2.4.0 (#327) * feat(query): support live inserts into parted tables Add immutable live-tail growth with explicit partition keys, shared FILE-domain symbol handling, atomic symbol rebinding, adversarial coverage, documentation, and a runnable rollover example. * fix(core): restore total-core -c semantics * fix(parse) Fix nonstring if not defined * fix(store): surface FlushFileBuffers failure in journal SYNC mode (#335) In RAY_JOURNAL_SYNC mode ray_journal_write_bytes checks fsync's return on POSIX and fails the write with RAY_ERR_IO, but the Windows branch ignored FlushFileBuffers' return. A failed flush there was silently swallowed, so SYNC mode reported success while the data may not have reached disk — dropping the durability guarantee the mode exists to provide. Check FlushFileBuffers (0 = failure) and return RAY_ERR_IO, mirroring the POSIX path. Windows-only branch (not built on the Linux/macOS CI matrix), so it is verified by inspection against the adjacent fsync check; the failure path is not unit-testable, like the existing POSIX one. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(hnsw): reject build dims whose vector count overflows size_t (#333) ray_hnsw_build sized the copied vector block as n_nodes * dim * sizeof(float) with no overflow check. Dimensions whose product wraps size_t under-allocate the copy while the memcpy — and every later distance read (vectors + id*dim) — run past the buffer. Guard the product before any allocation, mirroring the per-layer neighbor guard in the loader, and reject overflowing dimensions. This hardens the public C API boundary; the in-tree (hnsw-build ...) path sizes vectors from an in-memory list and cannot reach the overflow, so it is defense-in-depth. Add a regression test driving an overflowing n_nodes/dim pair; with the guard removed it faults under ASan (stack-buffer-overflow at the copy). Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(store): read full link sidecar to avoid wrong-symbol truncation (#334) try_load_link_sidecar read the target table's sym name into a fixed 256-byte buffer (fread of 255 bytes). A name longer than 255 bytes was silently truncated, so ray_sym_intern interned a DIFFERENT symbol and the loaded column linked to the wrong table — silent data corruption on a save/load round-trip. The writer already emits the full, untruncated name. Read the whole sidecar into a buffer sized to the file (capped at 1 MiB to bound a corrupt/oversized file), and reject a short read (fread returning fewer bytes than the file size — an I/O error or a race-truncated sidecar) so a partial name can't be interned as a different symbol either. Add a regression test that links through a 300-byte target name and asserts the loaded link_target matches; it fails without the fix. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(hnsw): reject index files whose vector count overflows size_t (#332) hnsw_load_impl read n_nodes and dim straight from the file header and sized the vectors allocation as n_nodes * dim * sizeof(float) with no overflow check. A crafted header could make that product wrap size_t, so ray_sys_alloc under-allocated the buffer while the following fread still read the full (large) element count and wrote past the allocation — a heap-overflow write driven by an untrusted index file. Factor the check into ray_hnsw_vec_size_valid(n_nodes, dim) and reject the header before any allocation, mirroring the per-layer neighbor guard. Add a unit test that drives the helper directly (ordinary dims, non-positive dims, an overflowing pair, and the exact size_t boundary). It is tested at the helper rather than through ray_hnsw_load because an overflow-patched header is refused earlier — the huge node-level read fails first — so a full-load test could not distinguish the guard. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(docs): remediate F-0001 F-0005 F-0007 Escalate F-0002, F-0003, F-0004, and F-0006 into CF-0001 through CF-0004 after the required corpus census. * fix(docs): remediate CF-0001 * fix(docs): remediate CF-0002 * chore(audit): plan CF-0003 ratification * fix(docs): remediate CF-0003 * feat(docs): redesign website and documentation Rebuild the MkDocs and marketing surfaces around the Rayforce brand, add the live market demo and cloud preview, unify responsive navigation, and eliminate reload layout shifts. * fix(null): avoid f64 null casts to integers (#340) * fix(null): avoid f64 null casts to integers * fix(expr): guard f64 to i64 fallback casts * fix(null): clamp finite f64 narrow casts --------- Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(expr): avoid null truthiness casts in fallback binary ops (#339) * fix(expr): avoid null truthiness casts in fallback binary ops binary_range's fallback OP_AND/OP_OR kernels cast the widened `double` operand straight to `uint8_t`: uint8_t li = (uint8_t)LV_READ(i); `LV_READ` widens integer operands to `double` and yields NaN for float nulls, so this had two defects: - Wrong answers from 8-bit truncation: `(uint8_t)256.0 == 0`, so `256 and 1b` returned false. - Undefined behavior: casting NaN (NULL_F64) or a widened NULL_I64 (-9.2e18) to `uint8_t` is UB per C11 6.3.1.4. UBSan flagged the latter via expr_null/diff_i64_{and,or}_raw and expr_null/diff_f64_andor_chokes. Route AND/OR through two truthiness helpers that compare on the widened double and never cast it back to an integer: - truthy_intish(v, nullv) — false for 0 and for the operand's null sentinel. The fallback reads raw column memory, so a null arrives as the per-type sentinel widened to double (NULL_I16 / NULL_I32 / NULL_I64, with DATE/TIME stored as I32) rather than the NULL_I64 the VM kernel sees; `nullv` is derived per operand from the bound pointer type so I16/I32 nulls read as false, not just I64. - truthy_f64ish(v) — false for 0.0 and NaN (float null). Non-null truthiness is unchanged and null-input positions still agree with the VM kernel (documented "AND/OR with any null operand -> 0" and the fix_null_comparisons post-pass), keeping fallback ≡ fused. Add regression tests pinning fallback ≡ fused for nullable I64, I32 and I16 AND/OR operands (expr_null/diff_i{64,32,16}_{and,or}_raw). * fix(expr): preserve near-sentinel i64 truthiness --------- Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * ci: make Rayforce audit PR comments best-effort * ci: publish Rayforce audit comments from trusted workflow * ci: resolve fork PRs for audit commenter * perf: parallelize serial stages around group-by; unify binary-agg null semantics (#341) * wip(group): parallel binary aggregates (pearson/wavg/cov) via DA path Route binary co-moment aggregators through the dense-array (DA) group path instead of the hash scatter path. Adds sum_y/sumsq_y/sumxy co-moment slots to da_accum_t + per-row accumulation + per-worker merge; emit_agg_columns already finalises PEARSON/COV/WAVG/WSUM from the co-moments. Fixes poor multi-thread scaling of by-key binary aggregates (was ~2x, DA path scales ~9-12x like stddev). Verified vs numpy; diff comparator relaxed to 1e-9 combined abs+rel (1e-12 absolute tested bit-identical summation). Includes temporary RAY_GRPPROF phase instrumentation (to remove). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): merge binary-agg Sx as double for integer x-columns wavg/pearson accumulate Sx as double even when the x column is integer (e.g. wavg(bsize,bid), bsize=I32). The per-worker merge dispatched on the x-column type -> read the double bits as int64 -> garbage at >1 worker. Force float merge for binary aggs at all 3 sum-merge sites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): merge binary-agg co-moments in parallel da_merge_fn path The parallel slot-range merge (da_merge_fn, taken when n_slots>=1024) merged sumsq but not the binary-aggregate co-moment arrays (sum_y/sumsq_y/sumxy). Multi-key pearson/cov/wavg over >=1024 dense slots produced wrong results at >1 worker. Add the DA_NEED_PAIR merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(group): remove temporary RAY_NO_V2/RAY_GRPPROF instrumentation The binary-agg DA fix lands on the default path (v2 declines CHAR-keyed binary group-bys -> legacy DA), so the debug env overrides are no longer needed. 3635/3635 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(if): parallel elementwise OP_IF fill; route trivial-branch if to eager exec_if always took the 'selected' lazy-branch path, whose scaffolding (true-count, id-list build, per-branch gather, scatter) is serial over ALL rows — every if-projection ran at single-core speed regardless of -c (100M numeric if: 2.2s at any core count). 1. exec_if_eager: one shared fixed-width elementwise fill, dispatched across the worker pool for len >= 64K (SYM sides warm their runtime-id LUT serially first — sym.c frozen-table rule, mirrors window.c). STR keeps the serial append path. 2. exec_if_selected: bail to eager when both branches are trivial (column scan / scalar const) and eager fills the type combination correctly — the lazy path only pays off when a branch is an expression worth restricting to its passing rows. Mixed numeric/string shapes stay on the selected path (its per-value string conversion). 100M rows local c24: numeric if 1883->310ms, sym if 2012->306ms. dazzle c48 canonical Q22: 2658->1333ms end-to-end. make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(filter): parallel bitmap->index build in exec_filter and sel_compact exec_filter ran two sequential 0..nrows sweeps (pass-count and match_idx build) before its parallel gather; sel_compact rebuilt match_idx from the rowsel serially. Both now use the classic 3-phase compaction: parallel per-chunk/per-seg counts, tiny serial prefix, parallel fill at disjoint offsets. Lazy/morsel-backed predicates keep the sequential sweep. 100M rows local c24: 2-col where-select 143->21.7ms (1.4x -> 6.6x scaling); if+where 1040->324ms. make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf: parallel where builtin, gather_by_idx, and chunk-task dispatch - ray_where_fn: 3-phase chunk compaction on the pool (was fully serial). - gather_by_idx: fixed-width value gathers dispatched over disjoint output ranges (null-bit propagation stays serial - shared-word bit writes would race). - exec_filter/where chunk phases now use ray_pool_dispatch_n (one task per chunk); ray_pool_dispatch morselizes total_elems by 1024, so passing chunk counts gave only ~2 tasks for 100M rows. 100M rows local c24: where 88->25ms, at-gather 80->31ms, 2-col where-select 138->20ms (6.8x). make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(review): harden parallel paths per skeptic review Blockers (DA binary-agg y-column): - eligibility now requires a plain numeric/temporal y; nullable integer/temporal y stays on the HT path (da_accum_row's pair branch has no y-side sentinel machinery - nulls would accumulate as values) - an FP y with HAS_NULLS sets da_any_nullable so nn[] is allocated and the emitter divides by the non-null PAIR count, not the group count Majors: - all new parallel gates require pool->n_workers > 0 (a -c 1 pool exists with 0 workers; ring fill + atomics + rc_sync were pure overhead, and the OP_IF eager reroute lost to the selected path serially - the Q22/Q25 c1 regression) - chunked dispatch_n call sites cap chunks at 1024 = the pool's initial ring capacity, so the ring never grows (dispatch_n clamps and silently DROPS tasks if ring growth fails -> uninitialized prefix entries -> OOB writes) - sel_compact seg fill switched to dispatch_n over seg-chunks (ray_pool_dispatch over segs gave 1 task under 8.4M rows) - gather_by_idx parallel path guarded by ray_parallel_flag == 0 (leaf utility, 35+ call sites; nested dispatch would corrupt the single-producer task ring) Nits: stray time.h include, restored v2-gate comment, RAY_PARALLEL_THRESHOLD symbol in pivot.c. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): pair-skip y-side nulls in the legacy HT binary-agg path Unify grouped binary-aggregate (pearson/cov/scov/wsum/wavg) null handling with the scalar reducers, the v2 engine and the DA path: a null on either side of the (x,y) pair now voids the whole pair on the legacy HT route too. - ght_compute_layout: a nullable y-side sets GHT_AF2_Y_NULLABLE and routes the layout to the null-aware accumulators. - accum_from_entry_nullable: pair-skip before nn++/sums. - Entry packing canonicalizes integer nulls so the accumulator can see them: NaN in F64-packed slots (a (double)sentinel cast previously read as a huge finite value — this also fixes nullable-int x beside an FP y), NULL_I64 in int-by-int slots. - Both HT emitters (radix + serial) divided pearson/cov/scov moments by the group row count instead of the accumulated pair count — wrong results whenever a group carried any null; now divide by nn. - The all-null-group guards wrote v=0.0 after ray_vec_set_null, so the common store overwrote the null sentinel — emit NULL_F64 instead. - DA eligibility now also rejects a y shorter than the scan (OP_CONST vector literal would read out of bounds). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * test(agg): cross-path null coverage for grouped binary aggregates 46 assertions for wsum/wavg/pearson_corr/cov/scov over nullable inputs on all three grouped routes — v2 (plain-scan int key), DA (expression int key), legacy HT (expression key + nullable-int y; F64-packed and int-packed entry lanes) — against independently computed pair-skip truth, for all four x/y type combinations, plus an all-pairs-null group (wsum 0.0, typed nulls for the ratio/moment aggs) on every route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * chore(review): shared dispatch-safety gate; single filter threshold - ops/internal.h ray_par_dispatch_ok(): workers + RAY_PARALLEL_THRESHOLD + ray_parallel_flag reentrancy check in one place; applied at exec_filter, sel_compact, exec_if_eager and the where builtin (local copy there — builtins.c cannot include ops/internal.h). - exec_filter: gate and table fallback derive from one row count (fidx_rows); note that pass_count from the parallel count phase is consumed by exec_filter_vec for vector inputs. - group.c: drop the never-read da_ctx_t.agg_pair_mask plumbing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * chore(par): shared dispatch predicate, ring-cap constant, parallel-path test Follow-ups from the audit's non-blocking notes: - core/pool.h ray_pool_par_dispatch_ok(pool, n, min_elems): the single home for the dispatch-safety predicate (workers + element threshold + ray_parallel_flag reentrancy). The three hand-copies in ops/internal.h, lang/eval.c and ops/builtins.c are gone; all six gates call the shared one. - RAY_POOL_INIT_TASKS in core/pool.h replaces the hardcoded 1024 at the three dispatch_n chunk caps and in ray_pool_create, with a _Static_assert tying it to RAY_POOL_MAX_TASKS — lowering the initial ring capacity can no longer silently desync from the caps that rely on it. - test/rfl/query/parallel_paths_large.rfl: 200k-row coverage of every new pool-parallel branch (where, gather-by-index, exec_filter, sel_compact, OP_IF numeric and SYM fill incl. the serial LUT warm-up) against closed-form expected values. - RAY_F32 dropped from if_type_eager_ok's whitelist (if_fill_range has no F32 case; unreachable today, kept unreachable deliberately). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(aggr): preserve slice nullability in binary groups * fix(expr): avoid f64 null cast in fallback idiv integer output (#344) binary_range's OP_IDIV kernels for narrow integer output (I64/I32/I16/U8) computed `(intN_t)floor(lv/rv)` guarded only by `rv != 0.0`. That guard does not catch a NaN operand (`NaN != 0.0` is true), so a null float input yields `lv/rv == NaN`, `floor(NaN) == NaN`, and the subsequent cast to an integer type is undefined behavior — UBSan: "nan is outside the range of representable values of type 'long long'" at exec/expr_binary_f64_idiv_mod. Route the cast through the ray_cast_f64_to_{i64,i32,i16,u8}_null helpers, which map NaN to the canonical null sentinel (NULL_I64/I32/I16, 0 for the non-nullable U8) and saturate out-of-range finite results. The null post-pass (propagate_nulls_binary) already overwrites these positions, so final values are unchanged — this only removes the UB and yields the correct sentinel in-buffer. Mirrors the already-safe F64-output IDIV arm (ray_f64_fin) and the sibling casts fixed in "avoid f64 null casts to integers"; depends on those helpers. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(group): avoid f64 null read cast in dense aggs (#343) Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(ipc): preserve boxed data list args (#346) * fix(group): avoid f64 null cast in DA reads (#348) * ci: use portable march for fuzz jobs (#347) Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(pivot): preserve generic missing cells as null (#350) * fix(xbar): avoid narrow bucket truncation (#351) * fix(arith): reject float temporal operands (#353) * fix(ops): make nested (LIST) columns usable through a parted view (fixes #355) (#356) * fix(query): preserve temporal arithmetic semantics (#354) * ci: skip audit comments for cancelled runs * fix(query): preserve if temporal branch types (#359) * fix(store): reject duplicate splayed column names (#360) * fix(store): reject duplicate splayed column names * test(ci): harden Ctrl-C PTY synchronization --------- Co-authored-by: Anton <singaraiona@gmail.com> * fix(builtins): reject malformed strings in TIMESTAMP cast (#361) * fix(builtins): reject malformed strings in TIMESTAMP cast (as 'TIMESTAMP str) accepted a range of malformed inputs and silently produced a valid-looking but wrong value — invisible data corruption at the call site. Examples that used to succeed: - "2024-01-02x01:02:03", "2024-01-02abc" -> midnight (time dropped) - "2024-01-02T25:02:03" -> rolled into the next day - "2024-01-02T12:34junk", "...03Zjunk" -> trailing garbage ignored - "2024-01-02T12:34:" -> dangling component ignored - "2024-01-02T12:34:03+99:99" -> out-of-range tz, wrong date Root cause: the parser used unanchored sscanf calls that matched a prefix and ignored the rest, and it never range-checked the components. Replace it with a bounded cursor over the grammar YYYY<sep>MM<sep>DD [ (T|' '|D) HH:MM[:SS][.frac] [Z|(+|-)HH[:]?MM] ] (<sep> is '-' or '.', consistent within the date). The cursor must reach the end of the string, every field is a fixed digit width, and the date, time, and timezone components are range-checked; anything else is a domain error. All previously accepted valid forms — bare date, space/T/D separators, fractional seconds (any length), and Z / +HH:MM / -HHMM / offset suffixes — continue to round-trip. Extends the TIMESTAMP-cast coverage in ops/builtins_branch_cov.rfl with the separator, out-of-range, trailing-garbage, partial-component, and out-of-range-timezone rejection cases. * fix(builtins): validate timestamp cast bounds --------- Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(store): support nested column inserts and persistence (#365) * fix(eval): enforce restricted mode in compiled lambdas (#366) * feat(core): add bounded poll step for embedders (#367) * fix(test): rely on public runtime declarations (#369) * feat: website add consumer * feat(core): expose restricted poll mode (#372) * fix(aggr): preserve grouped nested first and last * fix(join): preserve nested columns (#376) * fix(csv): round-trip signed and >=24h TIME values in .csv.read (#379) * fix(str): propagate null start/length in substr instead of overflowing (#378) * fix(builtins): clamp out-of-range float in scalar numeric casts (#380) --------- Co-authored-by: Karim <k.nassar@lynxtrading.com> Co-authored-by: Evgen <ebelozerov@lynxtrading.com> Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Evgen Belozerov <yevhenbielozorov@gmail.com> Co-authored-by: Serhii Savchuk <ser.vasilich@hotmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
singaraiona
added a commit
that referenced
this pull request
Aug 11, 2026
* v2.4.0 (#327) * feat(query): support live inserts into parted tables Add immutable live-tail growth with explicit partition keys, shared FILE-domain symbol handling, atomic symbol rebinding, adversarial coverage, documentation, and a runnable rollover example. * fix(core): restore total-core -c semantics * fix(parse) Fix nonstring if not defined * fix(store): surface FlushFileBuffers failure in journal SYNC mode (#335) In RAY_JOURNAL_SYNC mode ray_journal_write_bytes checks fsync's return on POSIX and fails the write with RAY_ERR_IO, but the Windows branch ignored FlushFileBuffers' return. A failed flush there was silently swallowed, so SYNC mode reported success while the data may not have reached disk — dropping the durability guarantee the mode exists to provide. Check FlushFileBuffers (0 = failure) and return RAY_ERR_IO, mirroring the POSIX path. Windows-only branch (not built on the Linux/macOS CI matrix), so it is verified by inspection against the adjacent fsync check; the failure path is not unit-testable, like the existing POSIX one. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(hnsw): reject build dims whose vector count overflows size_t (#333) ray_hnsw_build sized the copied vector block as n_nodes * dim * sizeof(float) with no overflow check. Dimensions whose product wraps size_t under-allocate the copy while the memcpy — and every later distance read (vectors + id*dim) — run past the buffer. Guard the product before any allocation, mirroring the per-layer neighbor guard in the loader, and reject overflowing dimensions. This hardens the public C API boundary; the in-tree (hnsw-build ...) path sizes vectors from an in-memory list and cannot reach the overflow, so it is defense-in-depth. Add a regression test driving an overflowing n_nodes/dim pair; with the guard removed it faults under ASan (stack-buffer-overflow at the copy). Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(store): read full link sidecar to avoid wrong-symbol truncation (#334) try_load_link_sidecar read the target table's sym name into a fixed 256-byte buffer (fread of 255 bytes). A name longer than 255 bytes was silently truncated, so ray_sym_intern interned a DIFFERENT symbol and the loaded column linked to the wrong table — silent data corruption on a save/load round-trip. The writer already emits the full, untruncated name. Read the whole sidecar into a buffer sized to the file (capped at 1 MiB to bound a corrupt/oversized file), and reject a short read (fread returning fewer bytes than the file size — an I/O error or a race-truncated sidecar) so a partial name can't be interned as a different symbol either. Add a regression test that links through a 300-byte target name and asserts the loaded link_target matches; it fails without the fix. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(hnsw): reject index files whose vector count overflows size_t (#332) hnsw_load_impl read n_nodes and dim straight from the file header and sized the vectors allocation as n_nodes * dim * sizeof(float) with no overflow check. A crafted header could make that product wrap size_t, so ray_sys_alloc under-allocated the buffer while the following fread still read the full (large) element count and wrote past the allocation — a heap-overflow write driven by an untrusted index file. Factor the check into ray_hnsw_vec_size_valid(n_nodes, dim) and reject the header before any allocation, mirroring the per-layer neighbor guard. Add a unit test that drives the helper directly (ordinary dims, non-positive dims, an overflowing pair, and the exact size_t boundary). It is tested at the helper rather than through ray_hnsw_load because an overflow-patched header is refused earlier — the huge node-level read fails first — so a full-load test could not distinguish the guard. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(docs): remediate F-0001 F-0005 F-0007 Escalate F-0002, F-0003, F-0004, and F-0006 into CF-0001 through CF-0004 after the required corpus census. * fix(docs): remediate CF-0001 * fix(docs): remediate CF-0002 * chore(audit): plan CF-0003 ratification * fix(docs): remediate CF-0003 * feat(docs): redesign website and documentation Rebuild the MkDocs and marketing surfaces around the Rayforce brand, add the live market demo and cloud preview, unify responsive navigation, and eliminate reload layout shifts. * fix(null): avoid f64 null casts to integers (#340) * fix(null): avoid f64 null casts to integers * fix(expr): guard f64 to i64 fallback casts * fix(null): clamp finite f64 narrow casts --------- Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(expr): avoid null truthiness casts in fallback binary ops (#339) * fix(expr): avoid null truthiness casts in fallback binary ops binary_range's fallback OP_AND/OP_OR kernels cast the widened `double` operand straight to `uint8_t`: uint8_t li = (uint8_t)LV_READ(i); `LV_READ` widens integer operands to `double` and yields NaN for float nulls, so this had two defects: - Wrong answers from 8-bit truncation: `(uint8_t)256.0 == 0`, so `256 and 1b` returned false. - Undefined behavior: casting NaN (NULL_F64) or a widened NULL_I64 (-9.2e18) to `uint8_t` is UB per C11 6.3.1.4. UBSan flagged the latter via expr_null/diff_i64_{and,or}_raw and expr_null/diff_f64_andor_chokes. Route AND/OR through two truthiness helpers that compare on the widened double and never cast it back to an integer: - truthy_intish(v, nullv) — false for 0 and for the operand's null sentinel. The fallback reads raw column memory, so a null arrives as the per-type sentinel widened to double (NULL_I16 / NULL_I32 / NULL_I64, with DATE/TIME stored as I32) rather than the NULL_I64 the VM kernel sees; `nullv` is derived per operand from the bound pointer type so I16/I32 nulls read as false, not just I64. - truthy_f64ish(v) — false for 0.0 and NaN (float null). Non-null truthiness is unchanged and null-input positions still agree with the VM kernel (documented "AND/OR with any null operand -> 0" and the fix_null_comparisons post-pass), keeping fallback ≡ fused. Add regression tests pinning fallback ≡ fused for nullable I64, I32 and I16 AND/OR operands (expr_null/diff_i{64,32,16}_{and,or}_raw). * fix(expr): preserve near-sentinel i64 truthiness --------- Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * ci: make Rayforce audit PR comments best-effort * ci: publish Rayforce audit comments from trusted workflow * ci: resolve fork PRs for audit commenter * perf: parallelize serial stages around group-by; unify binary-agg null semantics (#341) * wip(group): parallel binary aggregates (pearson/wavg/cov) via DA path Route binary co-moment aggregators through the dense-array (DA) group path instead of the hash scatter path. Adds sum_y/sumsq_y/sumxy co-moment slots to da_accum_t + per-row accumulation + per-worker merge; emit_agg_columns already finalises PEARSON/COV/WAVG/WSUM from the co-moments. Fixes poor multi-thread scaling of by-key binary aggregates (was ~2x, DA path scales ~9-12x like stddev). Verified vs numpy; diff comparator relaxed to 1e-9 combined abs+rel (1e-12 absolute tested bit-identical summation). Includes temporary RAY_GRPPROF phase instrumentation (to remove). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): merge binary-agg Sx as double for integer x-columns wavg/pearson accumulate Sx as double even when the x column is integer (e.g. wavg(bsize,bid), bsize=I32). The per-worker merge dispatched on the x-column type -> read the double bits as int64 -> garbage at >1 worker. Force float merge for binary aggs at all 3 sum-merge sites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): merge binary-agg co-moments in parallel da_merge_fn path The parallel slot-range merge (da_merge_fn, taken when n_slots>=1024) merged sumsq but not the binary-aggregate co-moment arrays (sum_y/sumsq_y/sumxy). Multi-key pearson/cov/wavg over >=1024 dense slots produced wrong results at >1 worker. Add the DA_NEED_PAIR merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(group): remove temporary RAY_NO_V2/RAY_GRPPROF instrumentation The binary-agg DA fix lands on the default path (v2 declines CHAR-keyed binary group-bys -> legacy DA), so the debug env overrides are no longer needed. 3635/3635 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(if): parallel elementwise OP_IF fill; route trivial-branch if to eager exec_if always took the 'selected' lazy-branch path, whose scaffolding (true-count, id-list build, per-branch gather, scatter) is serial over ALL rows — every if-projection ran at single-core speed regardless of -c (100M numeric if: 2.2s at any core count). 1. exec_if_eager: one shared fixed-width elementwise fill, dispatched across the worker pool for len >= 64K (SYM sides warm their runtime-id LUT serially first — sym.c frozen-table rule, mirrors window.c). STR keeps the serial append path. 2. exec_if_selected: bail to eager when both branches are trivial (column scan / scalar const) and eager fills the type combination correctly — the lazy path only pays off when a branch is an expression worth restricting to its passing rows. Mixed numeric/string shapes stay on the selected path (its per-value string conversion). 100M rows local c24: numeric if 1883->310ms, sym if 2012->306ms. dazzle c48 canonical Q22: 2658->1333ms end-to-end. make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(filter): parallel bitmap->index build in exec_filter and sel_compact exec_filter ran two sequential 0..nrows sweeps (pass-count and match_idx build) before its parallel gather; sel_compact rebuilt match_idx from the rowsel serially. Both now use the classic 3-phase compaction: parallel per-chunk/per-seg counts, tiny serial prefix, parallel fill at disjoint offsets. Lazy/morsel-backed predicates keep the sequential sweep. 100M rows local c24: 2-col where-select 143->21.7ms (1.4x -> 6.6x scaling); if+where 1040->324ms. make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf: parallel where builtin, gather_by_idx, and chunk-task dispatch - ray_where_fn: 3-phase chunk compaction on the pool (was fully serial). - gather_by_idx: fixed-width value gathers dispatched over disjoint output ranges (null-bit propagation stays serial - shared-word bit writes would race). - exec_filter/where chunk phases now use ray_pool_dispatch_n (one task per chunk); ray_pool_dispatch morselizes total_elems by 1024, so passing chunk counts gave only ~2 tasks for 100M rows. 100M rows local c24: where 88->25ms, at-gather 80->31ms, 2-col where-select 138->20ms (6.8x). make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(review): harden parallel paths per skeptic review Blockers (DA binary-agg y-column): - eligibility now requires a plain numeric/temporal y; nullable integer/temporal y stays on the HT path (da_accum_row's pair branch has no y-side sentinel machinery - nulls would accumulate as values) - an FP y with HAS_NULLS sets da_any_nullable so nn[] is allocated and the emitter divides by the non-null PAIR count, not the group count Majors: - all new parallel gates require pool->n_workers > 0 (a -c 1 pool exists with 0 workers; ring fill + atomics + rc_sync were pure overhead, and the OP_IF eager reroute lost to the selected path serially - the Q22/Q25 c1 regression) - chunked dispatch_n call sites cap chunks at 1024 = the pool's initial ring capacity, so the ring never grows (dispatch_n clamps and silently DROPS tasks if ring growth fails -> uninitialized prefix entries -> OOB writes) - sel_compact seg fill switched to dispatch_n over seg-chunks (ray_pool_dispatch over segs gave 1 task under 8.4M rows) - gather_by_idx parallel path guarded by ray_parallel_flag == 0 (leaf utility, 35+ call sites; nested dispatch would corrupt the single-producer task ring) Nits: stray time.h include, restored v2-gate comment, RAY_PARALLEL_THRESHOLD symbol in pivot.c. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): pair-skip y-side nulls in the legacy HT binary-agg path Unify grouped binary-aggregate (pearson/cov/scov/wsum/wavg) null handling with the scalar reducers, the v2 engine and the DA path: a null on either side of the (x,y) pair now voids the whole pair on the legacy HT route too. - ght_compute_layout: a nullable y-side sets GHT_AF2_Y_NULLABLE and routes the layout to the null-aware accumulators. - accum_from_entry_nullable: pair-skip before nn++/sums. - Entry packing canonicalizes integer nulls so the accumulator can see them: NaN in F64-packed slots (a (double)sentinel cast previously read as a huge finite value — this also fixes nullable-int x beside an FP y), NULL_I64 in int-by-int slots. - Both HT emitters (radix + serial) divided pearson/cov/scov moments by the group row count instead of the accumulated pair count — wrong results whenever a group carried any null; now divide by nn. - The all-null-group guards wrote v=0.0 after ray_vec_set_null, so the common store overwrote the null sentinel — emit NULL_F64 instead. - DA eligibility now also rejects a y shorter than the scan (OP_CONST vector literal would read out of bounds). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * test(agg): cross-path null coverage for grouped binary aggregates 46 assertions for wsum/wavg/pearson_corr/cov/scov over nullable inputs on all three grouped routes — v2 (plain-scan int key), DA (expression int key), legacy HT (expression key + nullable-int y; F64-packed and int-packed entry lanes) — against independently computed pair-skip truth, for all four x/y type combinations, plus an all-pairs-null group (wsum 0.0, typed nulls for the ratio/moment aggs) on every route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * chore(review): shared dispatch-safety gate; single filter threshold - ops/internal.h ray_par_dispatch_ok(): workers + RAY_PARALLEL_THRESHOLD + ray_parallel_flag reentrancy check in one place; applied at exec_filter, sel_compact, exec_if_eager and the where builtin (local copy there — builtins.c cannot include ops/internal.h). - exec_filter: gate and table fallback derive from one row count (fidx_rows); note that pass_count from the parallel count phase is consumed by exec_filter_vec for vector inputs. - group.c: drop the never-read da_ctx_t.agg_pair_mask plumbing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * chore(par): shared dispatch predicate, ring-cap constant, parallel-path test Follow-ups from the audit's non-blocking notes: - core/pool.h ray_pool_par_dispatch_ok(pool, n, min_elems): the single home for the dispatch-safety predicate (workers + element threshold + ray_parallel_flag reentrancy). The three hand-copies in ops/internal.h, lang/eval.c and ops/builtins.c are gone; all six gates call the shared one. - RAY_POOL_INIT_TASKS in core/pool.h replaces the hardcoded 1024 at the three dispatch_n chunk caps and in ray_pool_create, with a _Static_assert tying it to RAY_POOL_MAX_TASKS — lowering the initial ring capacity can no longer silently desync from the caps that rely on it. - test/rfl/query/parallel_paths_large.rfl: 200k-row coverage of every new pool-parallel branch (where, gather-by-index, exec_filter, sel_compact, OP_IF numeric and SYM fill incl. the serial LUT warm-up) against closed-form expected values. - RAY_F32 dropped from if_type_eager_ok's whitelist (if_fill_range has no F32 case; unreachable today, kept unreachable deliberately). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(aggr): preserve slice nullability in binary groups * fix(expr): avoid f64 null cast in fallback idiv integer output (#344) binary_range's OP_IDIV kernels for narrow integer output (I64/I32/I16/U8) computed `(intN_t)floor(lv/rv)` guarded only by `rv != 0.0`. That guard does not catch a NaN operand (`NaN != 0.0` is true), so a null float input yields `lv/rv == NaN`, `floor(NaN) == NaN`, and the subsequent cast to an integer type is undefined behavior — UBSan: "nan is outside the range of representable values of type 'long long'" at exec/expr_binary_f64_idiv_mod. Route the cast through the ray_cast_f64_to_{i64,i32,i16,u8}_null helpers, which map NaN to the canonical null sentinel (NULL_I64/I32/I16, 0 for the non-nullable U8) and saturate out-of-range finite results. The null post-pass (propagate_nulls_binary) already overwrites these positions, so final values are unchanged — this only removes the UB and yields the correct sentinel in-buffer. Mirrors the already-safe F64-output IDIV arm (ray_f64_fin) and the sibling casts fixed in "avoid f64 null casts to integers"; depends on those helpers. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(group): avoid f64 null read cast in dense aggs (#343) Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(ipc): preserve boxed data list args (#346) * fix(group): avoid f64 null cast in DA reads (#348) * ci: use portable march for fuzz jobs (#347) Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(pivot): preserve generic missing cells as null (#350) * fix(xbar): avoid narrow bucket truncation (#351) * fix(arith): reject float temporal operands (#353) * fix(ops): make nested (LIST) columns usable through a parted view (fixes #355) (#356) * fix(query): preserve temporal arithmetic semantics (#354) * ci: skip audit comments for cancelled runs * fix(query): preserve if temporal branch types (#359) * fix(store): reject duplicate splayed column names (#360) * fix(store): reject duplicate splayed column names * test(ci): harden Ctrl-C PTY synchronization --------- Co-authored-by: Anton <singaraiona@gmail.com> * fix(builtins): reject malformed strings in TIMESTAMP cast (#361) * fix(builtins): reject malformed strings in TIMESTAMP cast (as 'TIMESTAMP str) accepted a range of malformed inputs and silently produced a valid-looking but wrong value — invisible data corruption at the call site. Examples that used to succeed: - "2024-01-02x01:02:03", "2024-01-02abc" -> midnight (time dropped) - "2024-01-02T25:02:03" -> rolled into the next day - "2024-01-02T12:34junk", "...03Zjunk" -> trailing garbage ignored - "2024-01-02T12:34:" -> dangling component ignored - "2024-01-02T12:34:03+99:99" -> out-of-range tz, wrong date Root cause: the parser used unanchored sscanf calls that matched a prefix and ignored the rest, and it never range-checked the components. Replace it with a bounded cursor over the grammar YYYY<sep>MM<sep>DD [ (T|' '|D) HH:MM[:SS][.frac] [Z|(+|-)HH[:]?MM] ] (<sep> is '-' or '.', consistent within the date). The cursor must reach the end of the string, every field is a fixed digit width, and the date, time, and timezone components are range-checked; anything else is a domain error. All previously accepted valid forms — bare date, space/T/D separators, fractional seconds (any length), and Z / +HH:MM / -HHMM / offset suffixes — continue to round-trip. Extends the TIMESTAMP-cast coverage in ops/builtins_branch_cov.rfl with the separator, out-of-range, trailing-garbage, partial-component, and out-of-range-timezone rejection cases. * fix(builtins): validate timestamp cast bounds --------- Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(store): support nested column inserts and persistence (#365) * fix(eval): enforce restricted mode in compiled lambdas (#366) * feat(core): add bounded poll step for embedders (#367) * fix(test): rely on public runtime declarations (#369) * feat: website add consumer * feat(core): expose restricted poll mode (#372) * fix(aggr): preserve grouped nested first and last * fix(join): preserve nested columns (#376) * fix(csv): round-trip signed and >=24h TIME values in .csv.read (#379) * fix(str): propagate null start/length in substr instead of overflowing (#378) * fix(builtins): clamp out-of-range float in scalar numeric casts (#380) * fix(builtins): validate bounds in DATE string cast (#382) (as 'DATE str) parsed "YYYY.MM.DD" with an unanchored sscanf and never range-checked the month. A month greater than 13 walked the days-in-month table out of bounds — e.g. (as 'DATE "9999.99.99") reads md[99] on a 13-element array, which ASan reports as a heap/global out-of-bounds read (builtins.c:1567). The same path also silently accepted trailing garbage ("2024.01.02junk") and impossible days ("2024.02.31"). Replace it with a bounded cursor over YYYY.MM.DD that must consume the whole string, validates the month (1-12) BEFORE indexing the table, and validates the day against the actual number of days in that month (leap-aware). This mirrors the strict TIMESTAMP string cast. All valid dates — including the leap day 2024.02.29 — still round-trip; malformed input now returns a domain error instead of reading out of bounds or fabricating a date. Adds DATE-cast rejection/validation coverage to type/as.rfl. * perf(aggr): specialize reduction scans (#383) * fix(join): preserve nested columns Gather LIST columns with retained ownership across equi, anti, and asof joins. Represent unmatched boxed rows with the runtime null singleton and propagate allocation failures without dropping columns.\n\nFixes #375 * perf(aggr): specialize reduction scans * perf(collection): parallel radix distinct for fixed-width columns (#384) * perf(collection): parallel radix distinct for fixed-width columns distinct_vec_eager's hashset pass is single-threaded and dominates on large numeric/temporal columns. Reuse exec_count_distinct's radix layout (histogram -> scatter -> per-partition dedup) carrying row ids alongside values; first occurrences land in a shared byte array (a value lives in exactly one partition, so no atomics) and feed the existing sort + gather tail — result semantics unchanged. 13.9M-row column, 10 cores: 11.6K uniques 106->26ms, 125K uniques 164->45ms, 1.1M uniques 294->133ms, 5M uniques 719->502ms (the shared sequential value-sort now dominates that last case). * test(collection): cover the parallel radix distinct path The existing distinct.rfl only exercises small vectors; the radix kernel engages at >= 65536 rows. Add radix-scale assertions for every lane it handles (i64, i32, i16, f64, time, timestamp): value-sorted output, declared nulls collapsing to one sentinel, idempotence, the dedup invariant, agreement with the fused OP_COUNT_DISTINCT kernel, and the exact engage-threshold boundary. F64 NaN ordering among sorted values is comparator-defined, so those asserts check cardinality and null survival rather than full ordering. * fix(csv): harden import cancellation and schema handling (#387) * fix(csv): harden import cancellation and schemas * ci(tsan): use portable x86-64 baseline * fix(temporal): guard int64 overflow in DATE/TIMESTAMP decomposition (#386) * fix(temporal): guard int64 overflow in DATE/TIMESTAMP decomposition The DATE/TIMESTAMP → microseconds conversion shared by the temporal extract and truncate paths overflowed int64 on extreme inputs (UBSan): * DATE is int32 days, so an extreme value × µs-per-day overflowed — (dd (as 'DATE 2147483647)) tripped `raw * 86400000000`. * The ns→µs floor for TIMESTAMP negated the input, so a value within 999 of INT64_MIN overflowed `(-raw) + 999`. * The DAG date_trunc YEAR/MONTH arms re-multiply days_from_civil(...) — a day count floored down to the period start, up to a year beyond the input — by µs-per-day, so a DATE that cleared the µs bound still overflowed (d.year of (as 'DATE -106751991)). Both conversions appear in all four decomposition kernels: the standalone ray_temporal_extract / ray_temporal_truncate and the DAG exec_extract / exec_date_trunc morsel kernels. Do the TIMESTAMP ns→µs floor with truncate-then-adjust so it never negates (overflow-free, exact at INT64_MIN). A DATE so extreme its µs value is not representable decodes to a null instead of reading overflow garbage, consistent with how these kernels already treat a null input. The truncate DATE bound is the int64-NANOSECOND representable day range (not just the µs one) so the YEAR/MONTH re-multiply cannot overflow, and truncate additionally nulls a result whose bucketed µs would overflow the ns output. Adds temporal/extract_trunc_overflow.rfl covering the standalone kernels (yyyy/dd/mm/hh and (date …)) and the DAG kernels (dotted col.field), including the year/month re-multiply path, large-magnitude negative DATE, and minimum-edge TIMESTAMP. * fix(temporal): range-check truncated result, not pre-floor headroom rte_trunc_elem rejected a truncation whenever the input fell within one bucket of the low int64-ns edge, even when the floored result was still representable — so (date (as 'TIMESTAMP -9223286400000000000)) returned 0Np though its day boundary 1707.09.23 is a valid TIMESTAMP that the DAG path (select ts.date) returned, leaving the two public truncate paths disagreeing (PR #386 review). Floor with overflow-safe arithmetic (truncate toward zero, then guard the single toward-minus-infinity bucket subtraction) and range-check the actual bucketed result against the ns domain, mirroring exec_date_trunc. Adds the low-boundary case — standalone and DAG — as a regression. --------- Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(eval): materialize lazy values at compiled boundaries (#391) * fix(arith): wrap scalar integer add/sub/mul on overflow instead of UB (#388) ray_add_fn / ray_sub_fn / ray_mul_fn computed `as_i64(a) OP as_i64(b)` directly, so an i64 result that overflowed was signed-integer-overflow undefined behavior. UBSan reported it at src/ops/arith.c:141 (+), :223 (-), and :257 (*) for e.g. (+ 9223372036854775807 1), (- 0 -9223372036854775808), and (* 9223372036854775807 2). Compute the integer result with uint64 wraparound, matching the vector arithmetic kernel (src/ops/expr.c OP_ADD/SUB/MUL). The wrapped value — INT64_MIN for MAX+1, which is the i64 null sentinel — is exactly what the code already returned by relying on the overflow, so results are unchanged; only the undefined arithmetic is removed. Narrow-type (i16/i32) results are unaffected: their i64 intermediate never overflows and make_typed_int still narrows them. Adds test/rfl/arith/overflow_wrap.rfl. Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(builtins): reject malformed strings in TIME cast (#389) (as 'TIME str) parsed with an unanchored sscanf and never range-checked the minute/second fields, so it silently accepted malformed input and produced a wrong-but-valid value — the same data-corruption class the DATE (#382) and TIMESTAMP (#361) string casts were hardened against, but the TIME cast was left on the old path: - "12:34:56junk" -> 12:34:56.000 (trailing garbage ignored) - "25:99:99" -> 26:40:39.000 (minute/second out of range, normalized) Parse "[-]HH:MM[:SS][.fff]" with a bounded cursor that must consume the whole string, validating that minutes and seconds are 0-59. TIME is a signed duration — its ms-of-day may exceed a day and go negative (see .csv.read round-tripping) — so the hour field stays variable width and unbounded, and "HH:MM" without seconds plus a bare trailing "." are still accepted. The value is range-checked against the int32 millisecond domain. The string-vector cast routes through this same atom path, so it is fixed too. Extends the TIME-cast coverage in type/as.rfl with the duration, negative, and rejection cases. Co-authored-by: Anton Kundenko <singaraiona@gmail.com> --------- Co-authored-by: Karim <k.nassar@lynxtrading.com> Co-authored-by: Evgen <ebelozerov@lynxtrading.com> Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Evgen Belozerov <yevhenbielozorov@gmail.com> Co-authored-by: Serhii Savchuk <ser.vasilich@hotmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
singaraiona
added a commit
that referenced
this pull request
Aug 13, 2026
* v2.4.0 (#327) * feat(query): support live inserts into parted tables Add immutable live-tail growth with explicit partition keys, shared FILE-domain symbol handling, atomic symbol rebinding, adversarial coverage, documentation, and a runnable rollover example. * fix(core): restore total-core -c semantics * fix(parse) Fix nonstring if not defined * fix(store): surface FlushFileBuffers failure in journal SYNC mode (#335) In RAY_JOURNAL_SYNC mode ray_journal_write_bytes checks fsync's return on POSIX and fails the write with RAY_ERR_IO, but the Windows branch ignored FlushFileBuffers' return. A failed flush there was silently swallowed, so SYNC mode reported success while the data may not have reached disk — dropping the durability guarantee the mode exists to provide. Check FlushFileBuffers (0 = failure) and return RAY_ERR_IO, mirroring the POSIX path. Windows-only branch (not built on the Linux/macOS CI matrix), so it is verified by inspection against the adjacent fsync check; the failure path is not unit-testable, like the existing POSIX one. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(hnsw): reject build dims whose vector count overflows size_t (#333) ray_hnsw_build sized the copied vector block as n_nodes * dim * sizeof(float) with no overflow check. Dimensions whose product wraps size_t under-allocate the copy while the memcpy — and every later distance read (vectors + id*dim) — run past the buffer. Guard the product before any allocation, mirroring the per-layer neighbor guard in the loader, and reject overflowing dimensions. This hardens the public C API boundary; the in-tree (hnsw-build ...) path sizes vectors from an in-memory list and cannot reach the overflow, so it is defense-in-depth. Add a regression test driving an overflowing n_nodes/dim pair; with the guard removed it faults under ASan (stack-buffer-overflow at the copy). Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(store): read full link sidecar to avoid wrong-symbol truncation (#334) try_load_link_sidecar read the target table's sym name into a fixed 256-byte buffer (fread of 255 bytes). A name longer than 255 bytes was silently truncated, so ray_sym_intern interned a DIFFERENT symbol and the loaded column linked to the wrong table — silent data corruption on a save/load round-trip. The writer already emits the full, untruncated name. Read the whole sidecar into a buffer sized to the file (capped at 1 MiB to bound a corrupt/oversized file), and reject a short read (fread returning fewer bytes than the file size — an I/O error or a race-truncated sidecar) so a partial name can't be interned as a different symbol either. Add a regression test that links through a 300-byte target name and asserts the loaded link_target matches; it fails without the fix. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(hnsw): reject index files whose vector count overflows size_t (#332) hnsw_load_impl read n_nodes and dim straight from the file header and sized the vectors allocation as n_nodes * dim * sizeof(float) with no overflow check. A crafted header could make that product wrap size_t, so ray_sys_alloc under-allocated the buffer while the following fread still read the full (large) element count and wrote past the allocation — a heap-overflow write driven by an untrusted index file. Factor the check into ray_hnsw_vec_size_valid(n_nodes, dim) and reject the header before any allocation, mirroring the per-layer neighbor guard. Add a unit test that drives the helper directly (ordinary dims, non-positive dims, an overflowing pair, and the exact size_t boundary). It is tested at the helper rather than through ray_hnsw_load because an overflow-patched header is refused earlier — the huge node-level read fails first — so a full-load test could not distinguish the guard. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(docs): remediate F-0001 F-0005 F-0007 Escalate F-0002, F-0003, F-0004, and F-0006 into CF-0001 through CF-0004 after the required corpus census. * fix(docs): remediate CF-0001 * fix(docs): remediate CF-0002 * chore(audit): plan CF-0003 ratification * fix(docs): remediate CF-0003 * feat(docs): redesign website and documentation Rebuild the MkDocs and marketing surfaces around the Rayforce brand, add the live market demo and cloud preview, unify responsive navigation, and eliminate reload layout shifts. * fix(null): avoid f64 null casts to integers (#340) * fix(null): avoid f64 null casts to integers * fix(expr): guard f64 to i64 fallback casts * fix(null): clamp finite f64 narrow casts --------- Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(expr): avoid null truthiness casts in fallback binary ops (#339) * fix(expr): avoid null truthiness casts in fallback binary ops binary_range's fallback OP_AND/OP_OR kernels cast the widened `double` operand straight to `uint8_t`: uint8_t li = (uint8_t)LV_READ(i); `LV_READ` widens integer operands to `double` and yields NaN for float nulls, so this had two defects: - Wrong answers from 8-bit truncation: `(uint8_t)256.0 == 0`, so `256 and 1b` returned false. - Undefined behavior: casting NaN (NULL_F64) or a widened NULL_I64 (-9.2e18) to `uint8_t` is UB per C11 6.3.1.4. UBSan flagged the latter via expr_null/diff_i64_{and,or}_raw and expr_null/diff_f64_andor_chokes. Route AND/OR through two truthiness helpers that compare on the widened double and never cast it back to an integer: - truthy_intish(v, nullv) — false for 0 and for the operand's null sentinel. The fallback reads raw column memory, so a null arrives as the per-type sentinel widened to double (NULL_I16 / NULL_I32 / NULL_I64, with DATE/TIME stored as I32) rather than the NULL_I64 the VM kernel sees; `nullv` is derived per operand from the bound pointer type so I16/I32 nulls read as false, not just I64. - truthy_f64ish(v) — false for 0.0 and NaN (float null). Non-null truthiness is unchanged and null-input positions still agree with the VM kernel (documented "AND/OR with any null operand -> 0" and the fix_null_comparisons post-pass), keeping fallback ≡ fused. Add regression tests pinning fallback ≡ fused for nullable I64, I32 and I16 AND/OR operands (expr_null/diff_i{64,32,16}_{and,or}_raw). * fix(expr): preserve near-sentinel i64 truthiness --------- Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * ci: make Rayforce audit PR comments best-effort * ci: publish Rayforce audit comments from trusted workflow * ci: resolve fork PRs for audit commenter * perf: parallelize serial stages around group-by; unify binary-agg null semantics (#341) * wip(group): parallel binary aggregates (pearson/wavg/cov) via DA path Route binary co-moment aggregators through the dense-array (DA) group path instead of the hash scatter path. Adds sum_y/sumsq_y/sumxy co-moment slots to da_accum_t + per-row accumulation + per-worker merge; emit_agg_columns already finalises PEARSON/COV/WAVG/WSUM from the co-moments. Fixes poor multi-thread scaling of by-key binary aggregates (was ~2x, DA path scales ~9-12x like stddev). Verified vs numpy; diff comparator relaxed to 1e-9 combined abs+rel (1e-12 absolute tested bit-identical summation). Includes temporary RAY_GRPPROF phase instrumentation (to remove). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): merge binary-agg Sx as double for integer x-columns wavg/pearson accumulate Sx as double even when the x column is integer (e.g. wavg(bsize,bid), bsize=I32). The per-worker merge dispatched on the x-column type -> read the double bits as int64 -> garbage at >1 worker. Force float merge for binary aggs at all 3 sum-merge sites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): merge binary-agg co-moments in parallel da_merge_fn path The parallel slot-range merge (da_merge_fn, taken when n_slots>=1024) merged sumsq but not the binary-aggregate co-moment arrays (sum_y/sumsq_y/sumxy). Multi-key pearson/cov/wavg over >=1024 dense slots produced wrong results at >1 worker. Add the DA_NEED_PAIR merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(group): remove temporary RAY_NO_V2/RAY_GRPPROF instrumentation The binary-agg DA fix lands on the default path (v2 declines CHAR-keyed binary group-bys -> legacy DA), so the debug env overrides are no longer needed. 3635/3635 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(if): parallel elementwise OP_IF fill; route trivial-branch if to eager exec_if always took the 'selected' lazy-branch path, whose scaffolding (true-count, id-list build, per-branch gather, scatter) is serial over ALL rows — every if-projection ran at single-core speed regardless of -c (100M numeric if: 2.2s at any core count). 1. exec_if_eager: one shared fixed-width elementwise fill, dispatched across the worker pool for len >= 64K (SYM sides warm their runtime-id LUT serially first — sym.c frozen-table rule, mirrors window.c). STR keeps the serial append path. 2. exec_if_selected: bail to eager when both branches are trivial (column scan / scalar const) and eager fills the type combination correctly — the lazy path only pays off when a branch is an expression worth restricting to its passing rows. Mixed numeric/string shapes stay on the selected path (its per-value string conversion). 100M rows local c24: numeric if 1883->310ms, sym if 2012->306ms. dazzle c48 canonical Q22: 2658->1333ms end-to-end. make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(filter): parallel bitmap->index build in exec_filter and sel_compact exec_filter ran two sequential 0..nrows sweeps (pass-count and match_idx build) before its parallel gather; sel_compact rebuilt match_idx from the rowsel serially. Both now use the classic 3-phase compaction: parallel per-chunk/per-seg counts, tiny serial prefix, parallel fill at disjoint offsets. Lazy/morsel-backed predicates keep the sequential sweep. 100M rows local c24: 2-col where-select 143->21.7ms (1.4x -> 6.6x scaling); if+where 1040->324ms. make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf: parallel where builtin, gather_by_idx, and chunk-task dispatch - ray_where_fn: 3-phase chunk compaction on the pool (was fully serial). - gather_by_idx: fixed-width value gathers dispatched over disjoint output ranges (null-bit propagation stays serial - shared-word bit writes would race). - exec_filter/where chunk phases now use ray_pool_dispatch_n (one task per chunk); ray_pool_dispatch morselizes total_elems by 1024, so passing chunk counts gave only ~2 tasks for 100M rows. 100M rows local c24: where 88->25ms, at-gather 80->31ms, 2-col where-select 138->20ms (6.8x). make test 3635/3635. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(review): harden parallel paths per skeptic review Blockers (DA binary-agg y-column): - eligibility now requires a plain numeric/temporal y; nullable integer/temporal y stays on the HT path (da_accum_row's pair branch has no y-side sentinel machinery - nulls would accumulate as values) - an FP y with HAS_NULLS sets da_any_nullable so nn[] is allocated and the emitter divides by the non-null PAIR count, not the group count Majors: - all new parallel gates require pool->n_workers > 0 (a -c 1 pool exists with 0 workers; ring fill + atomics + rc_sync were pure overhead, and the OP_IF eager reroute lost to the selected path serially - the Q22/Q25 c1 regression) - chunked dispatch_n call sites cap chunks at 1024 = the pool's initial ring capacity, so the ring never grows (dispatch_n clamps and silently DROPS tasks if ring growth fails -> uninitialized prefix entries -> OOB writes) - sel_compact seg fill switched to dispatch_n over seg-chunks (ray_pool_dispatch over segs gave 1 task under 8.4M rows) - gather_by_idx parallel path guarded by ray_parallel_flag == 0 (leaf utility, 35+ call sites; nested dispatch would corrupt the single-producer task ring) Nits: stray time.h include, restored v2-gate comment, RAY_PARALLEL_THRESHOLD symbol in pivot.c. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(group): pair-skip y-side nulls in the legacy HT binary-agg path Unify grouped binary-aggregate (pearson/cov/scov/wsum/wavg) null handling with the scalar reducers, the v2 engine and the DA path: a null on either side of the (x,y) pair now voids the whole pair on the legacy HT route too. - ght_compute_layout: a nullable y-side sets GHT_AF2_Y_NULLABLE and routes the layout to the null-aware accumulators. - accum_from_entry_nullable: pair-skip before nn++/sums. - Entry packing canonicalizes integer nulls so the accumulator can see them: NaN in F64-packed slots (a (double)sentinel cast previously read as a huge finite value — this also fixes nullable-int x beside an FP y), NULL_I64 in int-by-int slots. - Both HT emitters (radix + serial) divided pearson/cov/scov moments by the group row count instead of the accumulated pair count — wrong results whenever a group carried any null; now divide by nn. - The all-null-group guards wrote v=0.0 after ray_vec_set_null, so the common store overwrote the null sentinel — emit NULL_F64 instead. - DA eligibility now also rejects a y shorter than the scan (OP_CONST vector literal would read out of bounds). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * test(agg): cross-path null coverage for grouped binary aggregates 46 assertions for wsum/wavg/pearson_corr/cov/scov over nullable inputs on all three grouped routes — v2 (plain-scan int key), DA (expression int key), legacy HT (expression key + nullable-int y; F64-packed and int-packed entry lanes) — against independently computed pair-skip truth, for all four x/y type combinations, plus an all-pairs-null group (wsum 0.0, typed nulls for the ratio/moment aggs) on every route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * chore(review): shared dispatch-safety gate; single filter threshold - ops/internal.h ray_par_dispatch_ok(): workers + RAY_PARALLEL_THRESHOLD + ray_parallel_flag reentrancy check in one place; applied at exec_filter, sel_compact, exec_if_eager and the where builtin (local copy there — builtins.c cannot include ops/internal.h). - exec_filter: gate and table fallback derive from one row count (fidx_rows); note that pass_count from the parallel count phase is consumed by exec_filter_vec for vector inputs. - group.c: drop the never-read da_ctx_t.agg_pair_mask plumbing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s * chore(par): shared dispatch predicate, ring-cap constant, parallel-path test Follow-ups from the audit's non-blocking notes: - core/pool.h ray_pool_par_dispatch_ok(pool, n, min_elems): the single home for the dispatch-safety predicate (workers + element threshold + ray_parallel_flag reentrancy). The three hand-copies in ops/internal.h, lang/eval.c and ops/builtins.c are gone; all six gates call the shared one. - RAY_POOL_INIT_TASKS in core/pool.h replaces the hardcoded 1024 at the three dispatch_n chunk caps and in ray_pool_create, with a _Static_assert tying it to RAY_POOL_MAX_TASKS — lowering the initial ring capacity can no longer silently desync from the caps that rely on it. - test/rfl/query/parallel_paths_large.rfl: 200k-row coverage of every new pool-parallel branch (where, gather-by-index, exec_filter, sel_compact, OP_IF numeric and SYM fill incl. the serial LUT warm-up) against closed-form expected values. - RAY_F32 dropped from if_type_eager_ok's whitelist (if_fill_range has no F32 case; unreachable today, kept unreachable deliberately). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(aggr): preserve slice nullability in binary groups * fix(expr): avoid f64 null cast in fallback idiv integer output (#344) binary_range's OP_IDIV kernels for narrow integer output (I64/I32/I16/U8) computed `(intN_t)floor(lv/rv)` guarded only by `rv != 0.0`. That guard does not catch a NaN operand (`NaN != 0.0` is true), so a null float input yields `lv/rv == NaN`, `floor(NaN) == NaN`, and the subsequent cast to an integer type is undefined behavior — UBSan: "nan is outside the range of representable values of type 'long long'" at exec/expr_binary_f64_idiv_mod. Route the cast through the ray_cast_f64_to_{i64,i32,i16,u8}_null helpers, which map NaN to the canonical null sentinel (NULL_I64/I32/I16, 0 for the non-nullable U8) and saturate out-of-range finite results. The null post-pass (propagate_nulls_binary) already overwrites these positions, so final values are unchanged — this only removes the UB and yields the correct sentinel in-buffer. Mirrors the already-safe F64-output IDIV arm (ray_f64_fin) and the sibling casts fixed in "avoid f64 null casts to integers"; depends on those helpers. Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> * fix(group): avoid f64 null read cast in dense aggs (#343) Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(ipc): preserve boxed data list args (#346) * fix(group): avoid f64 null cast in DA reads (#348) * ci: use portable march for fuzz jobs (#347) Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(pivot): preserve generic missing cells as null (#350) * fix(xbar): avoid narrow bucket truncation (#351) * fix(arith): reject float temporal operands (#353) * fix(ops): make nested (LIST) columns usable through a parted view (fixes #355) (#356) * fix(query): preserve temporal arithmetic semantics (#354) * ci: skip audit comments for cancelled runs * fix(query): preserve if temporal branch types (#359) * fix(store): reject duplicate splayed column names (#360) * fix(store): reject duplicate splayed column names * test(ci): harden Ctrl-C PTY synchronization --------- Co-authored-by: Anton <singaraiona@gmail.com> * fix(builtins): reject malformed strings in TIMESTAMP cast (#361) * fix(builtins): reject malformed strings in TIMESTAMP cast (as 'TIMESTAMP str) accepted a range of malformed inputs and silently produced a valid-looking but wrong value — invisible data corruption at the call site. Examples that used to succeed: - "2024-01-02x01:02:03", "2024-01-02abc" -> midnight (time dropped) - "2024-01-02T25:02:03" -> rolled into the next day - "2024-01-02T12:34junk", "...03Zjunk" -> trailing garbage ignored - "2024-01-02T12:34:" -> dangling component ignored - "2024-01-02T12:34:03+99:99" -> out-of-range tz, wrong date Root cause: the parser used unanchored sscanf calls that matched a prefix and ignored the rest, and it never range-checked the components. Replace it with a bounded cursor over the grammar YYYY<sep>MM<sep>DD [ (T|' '|D) HH:MM[:SS][.frac] [Z|(+|-)HH[:]?MM] ] (<sep> is '-' or '.', consistent within the date). The cursor must reach the end of the string, every field is a fixed digit width, and the date, time, and timezone components are range-checked; anything else is a domain error. All previously accepted valid forms — bare date, space/T/D separators, fractional seconds (any length), and Z / +HH:MM / -HHMM / offset suffixes — continue to round-trip. Extends the TIMESTAMP-cast coverage in ops/builtins_branch_cov.rfl with the separator, out-of-range, trailing-garbage, partial-component, and out-of-range-timezone rejection cases. * fix(builtins): validate timestamp cast bounds --------- Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(store): support nested column inserts and persistence (#365) * fix(eval): enforce restricted mode in compiled lambdas (#366) * feat(core): add bounded poll step for embedders (#367) * fix(test): rely on public runtime declarations (#369) * feat: website add consumer * feat(core): expose restricted poll mode (#372) * fix(aggr): preserve grouped nested first and last * fix(join): preserve nested columns (#376) * fix(csv): round-trip signed and >=24h TIME values in .csv.read (#379) * fix(str): propagate null start/length in substr instead of overflowing (#378) * fix(builtins): clamp out-of-range float in scalar numeric casts (#380) * fix(builtins): validate bounds in DATE string cast (#382) (as 'DATE str) parsed "YYYY.MM.DD" with an unanchored sscanf and never range-checked the month. A month greater than 13 walked the days-in-month table out of bounds — e.g. (as 'DATE "9999.99.99") reads md[99] on a 13-element array, which ASan reports as a heap/global out-of-bounds read (builtins.c:1567). The same path also silently accepted trailing garbage ("2024.01.02junk") and impossible days ("2024.02.31"). Replace it with a bounded cursor over YYYY.MM.DD that must consume the whole string, validates the month (1-12) BEFORE indexing the table, and validates the day against the actual number of days in that month (leap-aware). This mirrors the strict TIMESTAMP string cast. All valid dates — including the leap day 2024.02.29 — still round-trip; malformed input now returns a domain error instead of reading out of bounds or fabricating a date. Adds DATE-cast rejection/validation coverage to type/as.rfl. * perf(aggr): specialize reduction scans (#383) * fix(join): preserve nested columns Gather LIST columns with retained ownership across equi, anti, and asof joins. Represent unmatched boxed rows with the runtime null singleton and propagate allocation failures without dropping columns.\n\nFixes #375 * perf(aggr): specialize reduction scans * perf(collection): parallel radix distinct for fixed-width columns (#384) * perf(collection): parallel radix distinct for fixed-width columns distinct_vec_eager's hashset pass is single-threaded and dominates on large numeric/temporal columns. Reuse exec_count_distinct's radix layout (histogram -> scatter -> per-partition dedup) carrying row ids alongside values; first occurrences land in a shared byte array (a value lives in exactly one partition, so no atomics) and feed the existing sort + gather tail — result semantics unchanged. 13.9M-row column, 10 cores: 11.6K uniques 106->26ms, 125K uniques 164->45ms, 1.1M uniques 294->133ms, 5M uniques 719->502ms (the shared sequential value-sort now dominates that last case). * test(collection): cover the parallel radix distinct path The existing distinct.rfl only exercises small vectors; the radix kernel engages at >= 65536 rows. Add radix-scale assertions for every lane it handles (i64, i32, i16, f64, time, timestamp): value-sorted output, declared nulls collapsing to one sentinel, idempotence, the dedup invariant, agreement with the fused OP_COUNT_DISTINCT kernel, and the exact engage-threshold boundary. F64 NaN ordering among sorted values is comparator-defined, so those asserts check cardinality and null survival rather than full ordering. * fix(csv): harden import cancellation and schema handling (#387) * fix(csv): harden import cancellation and schemas * ci(tsan): use portable x86-64 baseline * fix(temporal): guard int64 overflow in DATE/TIMESTAMP decomposition (#386) * fix(temporal): guard int64 overflow in DATE/TIMESTAMP decomposition The DATE/TIMESTAMP → microseconds conversion shared by the temporal extract and truncate paths overflowed int64 on extreme inputs (UBSan): * DATE is int32 days, so an extreme value × µs-per-day overflowed — (dd (as 'DATE 2147483647)) tripped `raw * 86400000000`. * The ns→µs floor for TIMESTAMP negated the input, so a value within 999 of INT64_MIN overflowed `(-raw) + 999`. * The DAG date_trunc YEAR/MONTH arms re-multiply days_from_civil(...) — a day count floored down to the period start, up to a year beyond the input — by µs-per-day, so a DATE that cleared the µs bound still overflowed (d.year of (as 'DATE -106751991)). Both conversions appear in all four decomposition kernels: the standalone ray_temporal_extract / ray_temporal_truncate and the DAG exec_extract / exec_date_trunc morsel kernels. Do the TIMESTAMP ns→µs floor with truncate-then-adjust so it never negates (overflow-free, exact at INT64_MIN). A DATE so extreme its µs value is not representable decodes to a null instead of reading overflow garbage, consistent with how these kernels already treat a null input. The truncate DATE bound is the int64-NANOSECOND representable day range (not just the µs one) so the YEAR/MONTH re-multiply cannot overflow, and truncate additionally nulls a result whose bucketed µs would overflow the ns output. Adds temporal/extract_trunc_overflow.rfl covering the standalone kernels (yyyy/dd/mm/hh and (date …)) and the DAG kernels (dotted col.field), including the year/month re-multiply path, large-magnitude negative DATE, and minimum-edge TIMESTAMP. * fix(temporal): range-check truncated result, not pre-floor headroom rte_trunc_elem rejected a truncation whenever the input fell within one bucket of the low int64-ns edge, even when the floored result was still representable — so (date (as 'TIMESTAMP -9223286400000000000)) returned 0Np though its day boundary 1707.09.23 is a valid TIMESTAMP that the DAG path (select ts.date) returned, leaving the two public truncate paths disagreeing (PR #386 review). Floor with overflow-safe arithmetic (truncate toward zero, then guard the single toward-minus-infinity bucket subtraction) and range-check the actual bucketed result against the ns domain, mirroring exec_date_trunc. Adds the low-boundary case — standalone and DAG — as a regression. --------- Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(eval): materialize lazy values at compiled boundaries (#391) * fix(arith): wrap scalar integer add/sub/mul on overflow instead of UB (#388) ray_add_fn / ray_sub_fn / ray_mul_fn computed `as_i64(a) OP as_i64(b)` directly, so an i64 result that overflowed was signed-integer-overflow undefined behavior. UBSan reported it at src/ops/arith.c:141 (+), :223 (-), and :257 (*) for e.g. (+ 9223372036854775807 1), (- 0 -9223372036854775808), and (* 9223372036854775807 2). Compute the integer result with uint64 wraparound, matching the vector arithmetic kernel (src/ops/expr.c OP_ADD/SUB/MUL). The wrapped value — INT64_MIN for MAX+1, which is the i64 null sentinel — is exactly what the code already returned by relying on the overflow, so results are unchanged; only the undefined arithmetic is removed. Narrow-type (i16/i32) results are unaffected: their i64 intermediate never overflows and make_typed_int still narrows them. Adds test/rfl/arith/overflow_wrap.rfl. Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(builtins): reject malformed strings in TIME cast (#389) (as 'TIME str) parsed with an unanchored sscanf and never range-checked the minute/second fields, so it silently accepted malformed input and produced a wrong-but-valid value — the same data-corruption class the DATE (#382) and TIMESTAMP (#361) string casts were hardened against, but the TIME cast was left on the old path: - "12:34:56junk" -> 12:34:56.000 (trailing garbage ignored) - "25:99:99" -> 26:40:39.000 (minute/second out of range, normalized) Parse "[-]HH:MM[:SS][.fff]" with a bounded cursor that must consume the whole string, validating that minutes and seconds are 0-59. TIME is a signed duration — its ms-of-day may exceed a day and go negative (see .csv.read round-tripping) — so the hour field stays variable width and unbounded, and "HH:MM" without seconds plus a bare trailing "." are still accepted. The value is range-checked against the int32 millisecond domain. The string-vector cast routes through this same atom path, so it is fixed too. Extends the TIME-cast coverage in type/as.rfl with the duration, negative, and rejection cases. Co-authored-by: Anton Kundenko <singaraiona@gmail.com> * fix(builtins): validate hex digits and dash layout in GUID string cast (#393) The STR->GUID decoder skipped '-' wherever it appeared and decoded any character as a nibble ('a'-'z'/'A'-'Z'/'0'-'9' arithmetic), so malformed input silently produced a wrong-but-valid GUID: (as 'guid "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz") returned 33333333-3333-3333-3333-333333333333. Require the canonical 8-4-4-4-12 hyphenated form exactly: 36 chars, the dash at offsets 8/13/18/23 and every other position a valid hex digit, else a 'domain' error — matching the strict DATE/TIME/TIMESTAMP string cast series. The parse is position-driven (a dash is REQUIRED at 8/13/18/23, not just "reject a misplaced dash"): the 32 non-dash positions are then exactly 32 nibbles, so the byte index cannot run past bytes[16]. A hex digit where a dash belongs — e.g. a 36-char all-hex string — otherwise overflowed the 16-byte stack buffer (ASan stack-buffer-overflow); it is now a clean reject. The string-vector cast routes through this same atom path. Adds GUID-cast rejection coverage (non-hex, wrong length, misplaced and missing dashes, the all-hex overflow case) to type/as.rfl. * feat(core): address Data Vault workflow gaps (#395) * feat(core): address Data Vault workflow gaps * perf(query): hash string grouping keys * fix(query): distinguish lexical and query scopes * fix(query): classify only lexical shadowing * fix(query): isolate DAG lookup from query scopes * fix(join): guard string radix specialization * feat(io): add binary file read and write (#396) * fix(store): repair legacy string hash caches (#398) * fix(store): repair legacy string hash caches * fix(eval): keep structural hashing internal * fix(builtins): reject trailing garbage and overflow in numeric string casts (#399) The strtoll/strtol/strtod casts validated only that a digit was consumed (*end != sp) and never checked the end pointer or ERANGE. A malformed string therefore silently produced a value from its prefix: (as 'i64 "123abc") -> 123 (as 'i64 "1e19") -> 1 (as 'i64 "12.5") -> 12 (as 'f64 "1.5x") -> 1.5 and true overflow returned a silently wrong number: 24-digit positive strings yielded INT64_MAX, while the same magnitude negative collided with the LLONG_MIN null sentinel and came back as 0Nl (asymmetric). Now every STR->numeric cast requires the parse to consume the full string (*end == '\0', else 'domain') and checks ERANGE so out-of-range literals are a 'domain' error instead of a wrong/null value - matching the strict DATE/TIME/TIMESTAMP string casts this branch series already hardened. Narrow-int truncation itself still wraps (documented narrow-int rule); only unparseable or size-overflowing input errors. The string-vector cast routes through this same per-element parser, so it is covered too. Adds full-consumption/overflow rejection coverage (incl. a string-vector case) to type/as.rfl. Co-authored-by: Anton Kundenko <singaraiona@gmail.com> --------- Co-authored-by: Karim <k.nassar@lynxtrading.com> Co-authored-by: Evgen <ebelozerov@lynxtrading.com> Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com> Co-authored-by: Evgen Belozerov <yevhenbielozorov@gmail.com> Co-authored-by: Serhii Savchuk <ser.vasilich@hotmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
singaraiona
added a commit
that referenced
this pull request
Aug 17, 2026
* v2.4.0 (#327)
* feat(query): support live inserts into parted tables
Add immutable live-tail growth with explicit partition keys, shared FILE-domain symbol handling, atomic symbol rebinding, adversarial coverage, documentation, and a runnable rollover example.
* fix(core): restore total-core -c semantics
* fix(parse) Fix nonstring if not defined
* fix(store): surface FlushFileBuffers failure in journal SYNC mode (#335)
In RAY_JOURNAL_SYNC mode ray_journal_write_bytes checks fsync's return on
POSIX and fails the write with RAY_ERR_IO, but the Windows branch ignored
FlushFileBuffers' return. A failed flush there was silently swallowed, so
SYNC mode reported success while the data may not have reached disk —
dropping the durability guarantee the mode exists to provide.
Check FlushFileBuffers (0 = failure) and return RAY_ERR_IO, mirroring the
POSIX path. Windows-only branch (not built on the Linux/macOS CI matrix),
so it is verified by inspection against the adjacent fsync check; the
failure path is not unit-testable, like the existing POSIX one.
Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com>
* fix(hnsw): reject build dims whose vector count overflows size_t (#333)
ray_hnsw_build sized the copied vector block as n_nodes * dim *
sizeof(float) with no overflow check. Dimensions whose product wraps
size_t under-allocate the copy while the memcpy — and every later distance
read (vectors + id*dim) — run past the buffer. Guard the product before any
allocation, mirroring the per-layer neighbor guard in the loader, and
reject overflowing dimensions.
This hardens the public C API boundary; the in-tree (hnsw-build ...) path
sizes vectors from an in-memory list and cannot reach the overflow, so it
is defense-in-depth.
Add a regression test driving an overflowing n_nodes/dim pair; with the
guard removed it faults under ASan (stack-buffer-overflow at the copy).
Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com>
Co-authored-by: Anton Kundenko <singaraiona@gmail.com>
* fix(store): read full link sidecar to avoid wrong-symbol truncation (#334)
try_load_link_sidecar read the target table's sym name into a fixed
256-byte buffer (fread of 255 bytes). A name longer than 255 bytes was
silently truncated, so ray_sym_intern interned a DIFFERENT symbol and the
loaded column linked to the wrong table — silent data corruption on a
save/load round-trip. The writer already emits the full, untruncated name.
Read the whole sidecar into a buffer sized to the file (capped at 1 MiB to
bound a corrupt/oversized file), and reject a short read (fread returning
fewer bytes than the file size — an I/O error or a race-truncated sidecar)
so a partial name can't be interned as a different symbol either.
Add a regression test that links through a 300-byte target name and asserts
the loaded link_target matches; it fails without the fix.
Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com>
Co-authored-by: Anton Kundenko <singaraiona@gmail.com>
* fix(hnsw): reject index files whose vector count overflows size_t (#332)
hnsw_load_impl read n_nodes and dim straight from the file header and
sized the vectors allocation as n_nodes * dim * sizeof(float) with no
overflow check. A crafted header could make that product wrap size_t, so
ray_sys_alloc under-allocated the buffer while the following fread still
read the full (large) element count and wrote past the allocation — a
heap-overflow write driven by an untrusted index file.
Factor the check into ray_hnsw_vec_size_valid(n_nodes, dim) and reject the
header before any allocation, mirroring the per-layer neighbor guard.
Add a unit test that drives the helper directly (ordinary dims, non-positive
dims, an overflowing pair, and the exact size_t boundary). It is tested at
the helper rather than through ray_hnsw_load because an overflow-patched
header is refused earlier — the huge node-level read fails first — so a
full-load test could not distinguish the guard.
Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com>
* fix(docs): remediate F-0001 F-0005 F-0007
Escalate F-0002, F-0003, F-0004, and F-0006 into CF-0001 through CF-0004 after the required corpus census.
* fix(docs): remediate CF-0001
* fix(docs): remediate CF-0002
* chore(audit): plan CF-0003 ratification
* fix(docs): remediate CF-0003
* feat(docs): redesign website and documentation
Rebuild the MkDocs and marketing surfaces around the Rayforce brand, add the live market demo and cloud preview, unify responsive navigation, and eliminate reload layout shifts.
* fix(null): avoid f64 null casts to integers (#340)
* fix(null): avoid f64 null casts to integers
* fix(expr): guard f64 to i64 fallback casts
* fix(null): clamp finite f64 narrow casts
---------
Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com>
* fix(expr): avoid null truthiness casts in fallback binary ops (#339)
* fix(expr): avoid null truthiness casts in fallback binary ops
binary_range's fallback OP_AND/OP_OR kernels cast the widened `double`
operand straight to `uint8_t`:
uint8_t li = (uint8_t)LV_READ(i);
`LV_READ` widens integer operands to `double` and yields NaN for float
nulls, so this had two defects:
- Wrong answers from 8-bit truncation: `(uint8_t)256.0 == 0`, so
`256 and 1b` returned false.
- Undefined behavior: casting NaN (NULL_F64) or a widened NULL_I64
(-9.2e18) to `uint8_t` is UB per C11 6.3.1.4.
UBSan flagged the latter via expr_null/diff_i64_{and,or}_raw and
expr_null/diff_f64_andor_chokes.
Route AND/OR through two truthiness helpers that compare on the widened
double and never cast it back to an integer:
- truthy_intish(v, nullv) — false for 0 and for the operand's null
sentinel. The fallback reads raw column memory, so a null arrives as
the per-type sentinel widened to double (NULL_I16 / NULL_I32 /
NULL_I64, with DATE/TIME stored as I32) rather than the NULL_I64 the
VM kernel sees; `nullv` is derived per operand from the bound pointer
type so I16/I32 nulls read as false, not just I64.
- truthy_f64ish(v) — false for 0.0 and NaN (float null).
Non-null truthiness is unchanged and null-input positions still agree
with the VM kernel (documented "AND/OR with any null operand -> 0" and
the fix_null_comparisons post-pass), keeping fallback ≡ fused.
Add regression tests pinning fallback ≡ fused for nullable I64, I32 and
I16 AND/OR operands (expr_null/diff_i{64,32,16}_{and,or}_raw).
* fix(expr): preserve near-sentinel i64 truthiness
---------
Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com>
Co-authored-by: Anton Kundenko <singaraiona@gmail.com>
* ci: make Rayforce audit PR comments best-effort
* ci: publish Rayforce audit comments from trusted workflow
* ci: resolve fork PRs for audit commenter
* perf: parallelize serial stages around group-by; unify binary-agg null semantics (#341)
* wip(group): parallel binary aggregates (pearson/wavg/cov) via DA path
Route binary co-moment aggregators through the dense-array (DA) group path
instead of the hash scatter path. Adds sum_y/sumsq_y/sumxy co-moment slots
to da_accum_t + per-row accumulation + per-worker merge; emit_agg_columns
already finalises PEARSON/COV/WAVG/WSUM from the co-moments.
Fixes poor multi-thread scaling of by-key binary aggregates (was ~2x, DA
path scales ~9-12x like stddev). Verified vs numpy; diff comparator relaxed
to 1e-9 combined abs+rel (1e-12 absolute tested bit-identical summation).
Includes temporary RAY_GRPPROF phase instrumentation (to remove).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(group): merge binary-agg Sx as double for integer x-columns
wavg/pearson accumulate Sx as double even when the x column is integer
(e.g. wavg(bsize,bid), bsize=I32). The per-worker merge dispatched on the
x-column type -> read the double bits as int64 -> garbage at >1 worker.
Force float merge for binary aggs at all 3 sum-merge sites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(group): merge binary-agg co-moments in parallel da_merge_fn path
The parallel slot-range merge (da_merge_fn, taken when n_slots>=1024)
merged sumsq but not the binary-aggregate co-moment arrays
(sum_y/sumsq_y/sumxy). Multi-key pearson/cov/wavg over >=1024 dense
slots produced wrong results at >1 worker. Add the DA_NEED_PAIR merge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(group): remove temporary RAY_NO_V2/RAY_GRPPROF instrumentation
The binary-agg DA fix lands on the default path (v2 declines CHAR-keyed
binary group-bys -> legacy DA), so the debug env overrides are no longer
needed. 3635/3635 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(if): parallel elementwise OP_IF fill; route trivial-branch if to eager
exec_if always took the 'selected' lazy-branch path, whose scaffolding
(true-count, id-list build, per-branch gather, scatter) is serial over
ALL rows — every if-projection ran at single-core speed regardless of -c
(100M numeric if: 2.2s at any core count).
1. exec_if_eager: one shared fixed-width elementwise fill, dispatched
across the worker pool for len >= 64K (SYM sides warm their runtime-id
LUT serially first — sym.c frozen-table rule, mirrors window.c).
STR keeps the serial append path.
2. exec_if_selected: bail to eager when both branches are trivial (column
scan / scalar const) and eager fills the type combination correctly —
the lazy path only pays off when a branch is an expression worth
restricting to its passing rows. Mixed numeric/string shapes stay on
the selected path (its per-value string conversion).
100M rows local c24: numeric if 1883->310ms, sym if 2012->306ms.
dazzle c48 canonical Q22: 2658->1333ms end-to-end.
make test 3635/3635.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(filter): parallel bitmap->index build in exec_filter and sel_compact
exec_filter ran two sequential 0..nrows sweeps (pass-count and
match_idx build) before its parallel gather; sel_compact rebuilt
match_idx from the rowsel serially. Both now use the classic 3-phase
compaction: parallel per-chunk/per-seg counts, tiny serial prefix,
parallel fill at disjoint offsets. Lazy/morsel-backed predicates keep
the sequential sweep.
100M rows local c24: 2-col where-select 143->21.7ms (1.4x -> 6.6x
scaling); if+where 1040->324ms. make test 3635/3635.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf: parallel where builtin, gather_by_idx, and chunk-task dispatch
- ray_where_fn: 3-phase chunk compaction on the pool (was fully serial).
- gather_by_idx: fixed-width value gathers dispatched over disjoint
output ranges (null-bit propagation stays serial - shared-word bit
writes would race).
- exec_filter/where chunk phases now use ray_pool_dispatch_n (one task
per chunk); ray_pool_dispatch morselizes total_elems by 1024, so
passing chunk counts gave only ~2 tasks for 100M rows.
100M rows local c24: where 88->25ms, at-gather 80->31ms,
2-col where-select 138->20ms (6.8x). make test 3635/3635.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(review): harden parallel paths per skeptic review
Blockers (DA binary-agg y-column):
- eligibility now requires a plain numeric/temporal y; nullable
integer/temporal y stays on the HT path (da_accum_row's pair branch
has no y-side sentinel machinery - nulls would accumulate as values)
- an FP y with HAS_NULLS sets da_any_nullable so nn[] is allocated and
the emitter divides by the non-null PAIR count, not the group count
Majors:
- all new parallel gates require pool->n_workers > 0 (a -c 1 pool
exists with 0 workers; ring fill + atomics + rc_sync were pure
overhead, and the OP_IF eager reroute lost to the selected path
serially - the Q22/Q25 c1 regression)
- chunked dispatch_n call sites cap chunks at 1024 = the pool's
initial ring capacity, so the ring never grows (dispatch_n clamps
and silently DROPS tasks if ring growth fails -> uninitialized
prefix entries -> OOB writes)
- sel_compact seg fill switched to dispatch_n over seg-chunks
(ray_pool_dispatch over segs gave 1 task under 8.4M rows)
- gather_by_idx parallel path guarded by ray_parallel_flag == 0
(leaf utility, 35+ call sites; nested dispatch would corrupt the
single-producer task ring)
Nits: stray time.h include, restored v2-gate comment,
RAY_PARALLEL_THRESHOLD symbol in pivot.c.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(group): pair-skip y-side nulls in the legacy HT binary-agg path
Unify grouped binary-aggregate (pearson/cov/scov/wsum/wavg) null handling
with the scalar reducers, the v2 engine and the DA path: a null on either
side of the (x,y) pair now voids the whole pair on the legacy HT route too.
- ght_compute_layout: a nullable y-side sets GHT_AF2_Y_NULLABLE and routes
the layout to the null-aware accumulators.
- accum_from_entry_nullable: pair-skip before nn++/sums.
- Entry packing canonicalizes integer nulls so the accumulator can see
them: NaN in F64-packed slots (a (double)sentinel cast previously read
as a huge finite value — this also fixes nullable-int x beside an FP y),
NULL_I64 in int-by-int slots.
- Both HT emitters (radix + serial) divided pearson/cov/scov moments by
the group row count instead of the accumulated pair count — wrong
results whenever a group carried any null; now divide by nn.
- The all-null-group guards wrote v=0.0 after ray_vec_set_null, so the
common store overwrote the null sentinel — emit NULL_F64 instead.
- DA eligibility now also rejects a y shorter than the scan (OP_CONST
vector literal would read out of bounds).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s
* test(agg): cross-path null coverage for grouped binary aggregates
46 assertions for wsum/wavg/pearson_corr/cov/scov over nullable inputs on
all three grouped routes — v2 (plain-scan int key), DA (expression int
key), legacy HT (expression key + nullable-int y; F64-packed and
int-packed entry lanes) — against independently computed pair-skip truth,
for all four x/y type combinations, plus an all-pairs-null group
(wsum 0.0, typed nulls for the ratio/moment aggs) on every route.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s
* chore(review): shared dispatch-safety gate; single filter threshold
- ops/internal.h ray_par_dispatch_ok(): workers + RAY_PARALLEL_THRESHOLD +
ray_parallel_flag reentrancy check in one place; applied at exec_filter,
sel_compact, exec_if_eager and the where builtin (local copy there —
builtins.c cannot include ops/internal.h).
- exec_filter: gate and table fallback derive from one row count
(fidx_rows); note that pass_count from the parallel count phase is
consumed by exec_filter_vec for vector inputs.
- group.c: drop the never-read da_ctx_t.agg_pair_mask plumbing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s
* chore(par): shared dispatch predicate, ring-cap constant, parallel-path test
Follow-ups from the audit's non-blocking notes:
- core/pool.h ray_pool_par_dispatch_ok(pool, n, min_elems): the single
home for the dispatch-safety predicate (workers + element threshold +
ray_parallel_flag reentrancy). The three hand-copies in ops/internal.h,
lang/eval.c and ops/builtins.c are gone; all six gates call the shared
one.
- RAY_POOL_INIT_TASKS in core/pool.h replaces the hardcoded 1024 at the
three dispatch_n chunk caps and in ray_pool_create, with a
_Static_assert tying it to RAY_POOL_MAX_TASKS — lowering the initial
ring capacity can no longer silently desync from the caps that rely
on it.
- test/rfl/query/parallel_paths_large.rfl: 200k-row coverage of every
new pool-parallel branch (where, gather-by-index, exec_filter,
sel_compact, OP_IF numeric and SYM fill incl. the serial LUT warm-up)
against closed-form expected values.
- RAY_F32 dropped from if_type_eager_ok's whitelist (if_fill_range has
no F32 case; unreachable today, kept unreachable deliberately).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZwH1gpAeLzDX2bxc4jz6s
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Anton Kundenko <singaraiona@gmail.com>
* fix(aggr): preserve slice nullability in binary groups
* fix(expr): avoid f64 null cast in fallback idiv integer output (#344)
binary_range's OP_IDIV kernels for narrow integer output (I64/I32/I16/U8)
computed `(intN_t)floor(lv/rv)` guarded only by `rv != 0.0`. That guard
does not catch a NaN operand (`NaN != 0.0` is true), so a null float input
yields `lv/rv == NaN`, `floor(NaN) == NaN`, and the subsequent cast to an
integer type is undefined behavior — UBSan: "nan is outside the range of
representable values of type 'long long'" at exec/expr_binary_f64_idiv_mod.
Route the cast through the ray_cast_f64_to_{i64,i32,i16,u8}_null helpers,
which map NaN to the canonical null sentinel (NULL_I64/I32/I16, 0 for the
non-nullable U8) and saturate out-of-range finite results. The null
post-pass (propagate_nulls_binary) already overwrites these positions, so
final values are unchanged — this only removes the UB and yields the
correct sentinel in-buffer. Mirrors the already-safe F64-output IDIV arm
(ray_f64_fin) and the sibling casts fixed in "avoid f64 null casts to
integers"; depends on those helpers.
Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com>
* fix(group): avoid f64 null read cast in dense aggs (#343)
Co-authored-by: Evgen Byelozorov <belowzeroff@gmail.com>
Co-authored-by: Anton Kundenko <singaraiona@gmail.com>
* fix(ipc): preserve boxed data list args (#346)
* fix(group): avoid f64 null cast in DA reads (#348)
* ci: use portable march for fuzz jobs (#347)
Co-authored-by: Anton Kundenko <singaraiona@gmail.com>
* fix(pivot): preserve generic missing cells as null (#350)
* fix(xbar): avoid narrow bucket truncation (#351)
* fix(arith): reject float temporal operands (#353)
* fix(ops): make nested (LIST) columns usable through a parted view (fixes #355) (#356)
* fix(query): preserve temporal arithmetic semantics (#354)
* ci: skip audit comments for cancelled runs
* fix(query): preserve if temporal branch types (#359)
* fix(store): reject duplicate splayed column names (#360)
* fix(store): reject duplicate splayed column names
* test(ci): harden Ctrl-C PTY synchronization
---------
Co-authored-by: Anton <singaraiona@gmail.com>
* fix(builtins): reject malformed strings in TIMESTAMP cast (#361)
* fix(builtins): reject malformed strings in TIMESTAMP cast
(as 'TIMESTAMP str) accepted a range of malformed inputs and silently
produced a valid-looking but wrong value — invisible data corruption at
the call site. Examples that used to succeed:
- "2024-01-02x01:02:03", "2024-01-02abc" -> midnight (time dropped)
- "2024-01-02T25:02:03" -> rolled into the next day
- "2024-01-02T12:34junk", "...03Zjunk" -> trailing garbage ignored
- "2024-01-02T12:34:" -> dangling component ignored
- "2024-01-02T12:34:03+99:99" -> out-of-range tz, wrong date
Root cause: the parser used unanchored sscanf calls that matched a prefix
and ignored the rest, and it never range-checked the components.
Replace it with a bounded cursor over the grammar
YYYY<sep>MM<sep>DD [ (T|' '|D) HH:MM[:SS][.frac] [Z|(+|-)HH[:]?MM] ]
(<sep> is '-' or '.', consistent within the date). The cursor must reach
the end of the string, every field is a fixed digit width, and the date,
time, and timezone components are range-checked; anything else is a domain
error. All previously accepted valid forms — bare date, space/T/D
separators, fractional seconds (any length), and Z / +HH:MM / -HHMM /
offset suffixes — continue to round-trip.
Extends the TIMESTAMP-cast coverage in ops/builtins_branch_cov.rfl with
the separator, out-of-range, trailing-garbage, partial-component, and
out-of-range-timezone rejection cases.
* fix(builtins): validate timestamp cast bounds
---------
Co-authored-by: Anton Kundenko <singaraiona@gmail.com>
* fix(store): support nested column inserts and persistence (#365)
* fix(eval): enforce restricted mode in compiled lambdas (#366)
* feat(core): add bounded poll step for embedders (#367)
* fix(test): rely on public runtime declarations (#369)
* feat: website add consumer
* feat(core): expose restricted poll mode (#372)
* fix(aggr): preserve grouped nested first and last
* fix(join): preserve nested columns (#376)
* fix(csv): round-trip signed and >=24h TIME values in .csv.read (#379)
* fix(str): propagate null start/length in substr instead of overflowing (#378)
* fix(builtins): clamp out-of-range float in scalar numeric casts (#380)
* fix(builtins): validate bounds in DATE string cast (#382)
(as 'DATE str) parsed "YYYY.MM.DD" with an unanchored sscanf and never
range-checked the month. A month greater than 13 walked the days-in-month
table out of bounds — e.g. (as 'DATE "9999.99.99") reads md[99] on a
13-element array, which ASan reports as a heap/global out-of-bounds read
(builtins.c:1567). The same path also silently accepted trailing garbage
("2024.01.02junk") and impossible days ("2024.02.31").
Replace it with a bounded cursor over YYYY.MM.DD that must consume the
whole string, validates the month (1-12) BEFORE indexing the table, and
validates the day against the actual number of days in that month
(leap-aware). This mirrors the strict TIMESTAMP string cast. All valid
dates — including the leap day 2024.02.29 — still round-trip; malformed
input now returns a domain error instead of reading out of bounds or
fabricating a date.
Adds DATE-cast rejection/validation coverage to type/as.rfl.
* perf(aggr): specialize reduction scans (#383)
* fix(join): preserve nested columns
Gather LIST columns with retained ownership across equi, anti, and asof joins. Represent unmatched boxed rows with the runtime null singleton and propagate allocation failures without dropping columns.\n\nFixes #375
* perf(aggr): specialize reduction scans
* perf(collection): parallel radix distinct for fixed-width columns (#384)
* perf(collection): parallel radix distinct for fixed-width columns
distinct_vec_eager's hashset pass is single-threaded and dominates on
large numeric/temporal columns. Reuse exec_count_distinct's radix
layout (histogram -> scatter -> per-partition dedup) carrying row ids
alongside values; first occurrences land in a shared byte array (a
value lives in exactly one partition, so no atomics) and feed the
existing sort + gather tail — result semantics unchanged.
13.9M-row column, 10 cores: 11.6K uniques 106->26ms, 125K uniques
164->45ms, 1.1M uniques 294->133ms, 5M uniques 719->502ms (the shared
sequential value-sort now dominates that last case).
* test(collection): cover the parallel radix distinct path
The existing distinct.rfl only exercises small vectors; the radix
kernel engages at >= 65536 rows. Add radix-scale assertions for every
lane it handles (i64, i32, i16, f64, time, timestamp): value-sorted
output, declared nulls collapsing to one sentinel, idempotence, the
dedup invariant, agreement with the fused OP_COUNT_DISTINCT kernel,
and the exact engage-threshold boundary. F64 NaN ordering among sorted
values is comparator-defined, so those asserts check cardinality and
null survival rather than full ordering.
* fix(csv): harden import cancellation and schema handling (#387)
* fix(csv): harden import cancellation and schemas
* ci(tsan): use portable x86-64 baseline
* fix(temporal): guard int64 overflow in DATE/TIMESTAMP decomposition (#386)
* fix(temporal): guard int64 overflow in DATE/TIMESTAMP decomposition
The DATE/TIMESTAMP → microseconds conversion shared by the temporal
extract and truncate paths overflowed int64 on extreme inputs (UBSan):
* DATE is int32 days, so an extreme value × µs-per-day overflowed —
(dd (as 'DATE 2147483647)) tripped `raw * 86400000000`.
* The ns→µs floor for TIMESTAMP negated the input, so a value within
999 of INT64_MIN overflowed `(-raw) + 999`.
* The DAG date_trunc YEAR/MONTH arms re-multiply days_from_civil(...) —
a day count floored down to the period start, up to a year beyond the
input — by µs-per-day, so a DATE that cleared the µs bound still
overflowed (d.year of (as 'DATE -106751991)).
Both conversions appear in all four decomposition kernels: the standalone
ray_temporal_extract / ray_temporal_truncate and the DAG exec_extract /
exec_date_trunc morsel kernels.
Do the TIMESTAMP ns→µs floor with truncate-then-adjust so it never
negates (overflow-free, exact at INT64_MIN). A DATE so extreme its µs
value is not representable decodes to a null instead of reading overflow
garbage, consistent with how these kernels already treat a null input.
The truncate DATE bound is the int64-NANOSECOND representable day range
(not just the µs one) so the YEAR/MONTH re-multiply cannot overflow, and
truncate additionally nulls a result whose bucketed µs would overflow the
ns output.
Adds temporal/extract_trunc_overflow.rfl covering the standalone kernels
(yyyy/dd/mm/hh and (date …)) and the DAG kernels (dotted col.field),
including the year/month re-multiply path, large-magnitude negative DATE,
and minimum-edge TIMESTAMP.
* fix(temporal): range-check truncated result, not pre-floor headroom
rte_trunc_elem rejected a truncation whenever the input fell within one
bucket of the low int64-ns edge, even when the floored result was still
representable — so (date (as 'TIMESTAMP -9223286400000000000)) returned 0Np
though its day boundary 1707.09.23 is a valid TIMESTAMP that the DAG path
(select ts.date) returned, leaving the two public truncate paths disagreeing
(PR #386 review).
Floor with overflow-safe arithmetic (truncate toward zero, then guard the
single toward-minus-infinity bucket subtraction) and range-check the actual
bucketed result against the ns domain, mirroring exec_date_trunc. Adds the
low-boundary case — standalone and DAG — as a regression.
---------
Co-authored-by: Anton Kundenko <singaraiona@gmail.com>
* fix(eval): materialize lazy values at compiled boundaries (#391)
* fix(arith): wrap scalar integer add/sub/mul on overflow instead of UB (#388)
ray_add_fn / ray_sub_fn / ray_mul_fn computed `as_i64(a) OP as_i64(b)`
directly, so an i64 result that overflowed was signed-integer-overflow
undefined behavior. UBSan reported it at src/ops/arith.c:141 (+), :223
(-), and :257 (*) for e.g. (+ 9223372036854775807 1), (- 0
-9223372036854775808), and (* 9223372036854775807 2).
Compute the integer result with uint64 wraparound, matching the vector
arithmetic kernel (src/ops/expr.c OP_ADD/SUB/MUL). The wrapped value —
INT64_MIN for MAX+1, which is the i64 null sentinel — is exactly what the
code already returned by relying on the overflow, so results are
unchanged; only the undefined arithmetic is removed. Narrow-type (i16/i32)
results are unaffected: their i64 intermediate never overflows and
make_typed_int still narrows them.
Adds test/rfl/arith/overflow_wrap.rfl.
Co-authored-by: Anton Kundenko <singaraiona@gmail.com>
* fix(builtins): reject malformed strings in TIME cast (#389)
(as 'TIME str) parsed with an unanchored sscanf and never range-checked
the minute/second fields, so it silently accepted malformed input and
produced a wrong-but-valid value — the same data-corruption class the
DATE (#382) and TIMESTAMP (#361) string casts were hardened against, but
the TIME cast was left on the old path:
- "12:34:56junk" -> 12:34:56.000 (trailing garbage ignored)
- "25:99:99" -> 26:40:39.000 (minute/second out of range, normalized)
Parse "[-]HH:MM[:SS][.fff]" with a bounded cursor that must consume the
whole string, validating that minutes and seconds are 0-59. TIME is a
signed duration — its ms-of-day may exceed a day and go negative (see
.csv.read round-tripping) — so the hour field stays variable width and
unbounded, and "HH:MM" without seconds plus a bare trailing "." are still
accepted. The value is range-checked against the int32 millisecond domain.
The string-vector cast routes through this same atom path, so it is fixed
too.
Extends the TIME-cast coverage in type/as.rfl with the duration, negative,
and rejection cases.
Co-authored-by: Anton Kundenko <singaraiona@gmail.com>
* fix(builtins): validate hex digits and dash layout in GUID string cast (#393)
The STR->GUID decoder skipped '-' wherever it appeared and decoded any
character as a nibble ('a'-'z'/'A'-'Z'/'0'-'9' arithmetic), so malformed
input silently produced a wrong-but-valid GUID:
(as 'guid "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz") returned
33333333-3333-3333-3333-333333333333.
Require the canonical 8-4-4-4-12 hyphenated form exactly: 36 chars, the
dash at offsets 8/13/18/23 and every other position a valid hex digit,
else a 'domain' error — matching the strict DATE/TIME/TIMESTAMP string
cast series.
The parse is position-driven (a dash is REQUIRED at 8/13/18/23, not just
"reject a misplaced dash"): the 32 non-dash positions are then exactly 32
nibbles, so the byte index cannot run past bytes[16]. A hex digit where a
dash belongs — e.g. a 36-char all-hex string — otherwise overflowed the
16-byte stack buffer (ASan stack-buffer-overflow); it is now a clean
reject. The string-vector cast routes through this same atom path.
Adds GUID-cast rejection coverage (non-hex, wrong length, misplaced and
missing dashes, the all-hex overflow case) to type/as.rfl.
* feat(core): address Data Vault workflow gaps (#395)
* feat(core): address Data Vault workflow gaps
* perf(query): hash string grouping keys
* fix(query): distinguish lexical and query scopes
* fix(query): classify only lexical shadowing
* fix(query): isolate DAG lookup from query scopes
* fix(join): guard string radix specialization
* feat(io): add binary file read and write (#396)
* fix(store): repair legacy string hash caches (#398)
* fix(store): repair legacy string hash caches
* fix(eval): keep structural hashing internal
* fix(builtins): reject trailing garbage and overflow in numeric string casts (#399)
The strtoll/strtol/strtod casts validated only that a digit was consumed
(*end != sp) and never checked the end pointer or ERANGE. A malformed
string therefore silently produced a value from its prefix:
(as 'i64 "123abc") -> 123
(as 'i64 "1e19") -> 1
(as 'i64 "12.5") -> 12
(as 'f64 "1.5x") -> 1.5
and true overflow returned a silently wrong number: 24-digit positive
strings yielded INT64_MAX, while the same magnitude negative collided
with the LLONG_MIN null sentinel and came back as 0Nl (asymmetric).
Now every STR->numeric cast requires the parse to consume the full string
(*end == '\0', else 'domain') and checks ERANGE so out-of-range literals
are a 'domain' error instead of a wrong/null value - matching the strict
DATE/TIME/TIMESTAMP string casts this branch series already hardened.
Narrow-int truncation itself still wraps (documented narrow-int rule);
only unparseable or size-overflowing input errors. The string-vector
cast routes through this same per-element parser, so it is covered too.
Adds full-consumption/overflow rejection coverage (incl. a string-vector
case) to type/as.rfl.
Co-authored-by: Anton Kundenko <singaraiona@gmail.com>
* fix(query): reject STR equality keys in asof-join and window-join (#400)
* fix(query): reject STR equality keys in asof-join and window-join
Both kernels read equality-key cells through read_col_i64, which has no
RAY_STR case: a STR column falls into the byte-wide default. asof-join
(via asof_eq_lread) mismatches outright — a key that should match nulls
out; window-join / window-join1 read one byte per row when they sort and
probe the right side, so once there are more distinct keys than fit in a
byte the collisions cross-contaminate groups and the aggregates are
wrong. Neither surfaces an error, so callers get silently corrupt data.
The base inner/left/anti joins use a separate STR-aware kernel and stay
correct, so scope the fix to the two window/asof kernels: decline a STR
equality key on either side with nyi (parted columns unwrapped to their
base type first). One guard in ray_asof_join_core covers both asof call
paths; one in window_join_impl covers window-join and window-join1.
Adds test/rfl/join/str_key_nyi.rfl covering the nyi rejection, the SYM
keys that must keep working (order-independent), and the base joins that
must NOT be over-rejected on STR keys.
* fix(query): reject STR keys in legacy window joins
* fix(store): support LIST columns in parted fill (#402)
* fix(exec): propagate str_pool in OP_HEAD/OP_TAIL flat-column copies (#404)
The DAG head/tail executors copied ray_str_t descriptors verbatim but
never carried the source vector's str_pool, so every pool-backed string
(>12 bytes, non-inline) in a take:-limited select came back empty. The
SYM domain was adopted right next to the copy; STR was simply missed.
Applies to all four copy sites: table flat-column and bare-vector paths
of both OP_HEAD and OP_TAIL.
* fix(query): evaluate whole-column aggregate args once in ungrouped select (#405)
The scalar-aggregate eval fallback (eval_scalar_agg_outputs) fed the
aggregate's argument through eval_expr_per_row, so a whole-column verb
like distinct saw one scalar cell per row: STR/SYM inputs collapsed
(count (distinct s)) into the plain row count — silently wrong — and
integer inputs errored with "distinct: argument must be a list".
Route whole-column-verb arguments (distinct/asc/desc/reverse at the
head) through eval_expr_whole_column instead, mirroring the projection
fallback's routing, so the ungrouped form matches the by: path and the
whole-vector reducer.
* fix(group): sign-extend narrow keys in the DA accumulate loops
The direct-array accumulate dispatched key readers by ELEMENT SIZE only,
reading I16/I32/DATE/TIME keys as unsigned — while the min/max prescan
reads them through their signed column type. A negative key therefore
mapped to a slot far outside [0, range): an out-of-bounds write on the
dense accumulator arrays, and the group vanished from the result (caught
by csv_explicit_numeric_types.rfl once the DA path became reachable for
small inputs). Dispatch single-key and composite-gid loops on the key
type's signedness (only BOOL/U8 and SYM ids are unsigned) and route
mixed-signedness composites through the sign-aware generic reader.
* perf(group): size filtered group-by work from the surviving rows (ClickBench)
PR #326 made three group-by stages pay O(nrows) or O(key-domain) costs
regardless of WHERE selectivity or group count, regressing ClickBench
q07 4.9->43ms, q28 138->960ms, q38 8.5->53ms, q40 12->54ms, q21 (vs its
own DA path) on the 10M-row harness. Restore survivor-driven sizing:
- DA eligibility: reinstate the pre-#326 selective-rowsel gate (any key
count) — the DA prescan and accumulate visit every row when only a
bitmap selection exists — and bound dense slots by n_scan/8 (floor
262144, the old fixed cap) instead of n_scan, so a 2.7M-slot Referer
array no longer loses to the HT path on cache misses (q28).
- radix fan-out: derive the partition count from the selection's
survivor count, not raw nrows — 8x4096 per-(worker,part) HTs were
allocated and merged for ~10K surviving rows (q40).
- stable group ordering: for sparse results (groups <= rows/16) sort
(first_row, flat) pairs instead of building + scanning a dense
uint32[nrows] map — 40MB memset + 10M-row walk to order 110 groups
(q40). Byte-identical output order.
- sp_dyn dense emit: gate on dense-cap-vs-survivors (cap > 32x pass) —
a 1M-slot calloc+scan for 44 surviving rows (q21) — while keeping the
path for narrow keys (q07: 64K cap) and amortized survivors (q38).
Full 43-query A/B vs pre-fix HEAD: 14 queries faster (q07 0.09x,
q28 0.10x, q40 0.15x, q38 0.21x, q12 0.14x, q37 0.42x, ...), rest
within run noise. All 3681 tests pass.
* perf(agg): fix radix hash-bit overlap and rehash ladders in the v2 group pipelines
Three compounding costs in the high-cardinality group-by pipelines,
found chasing the remaining ClickBench gaps (q08/q17/q32):
- agg_engine radix: partition selection consumed the LOW log2(n_parts)
bits of the per-row FNV hash, and phase 2 indexed its partition-local
open-addressing table with the SAME low bits — within a partition
those bits are constant, so at 4096 partitions the table collapsed to
~2 usable slots and probing degraded to O(ng^2) chains (65% of q17 in
the probe loop). Phase 2 now shifts the partition bits out before
slot indexing, and both sides finalize the hash with fmix64 (same
rationale as agg_tuple_hash).
- agg_engine payload buffers grew 1->2->4->... per (worker,partition),
re-copying the scattered payload ~2x across tens of thousands of
buffers (23% of q17 in memmove). First allocation now jumps to the
uniform-hash expected row count.
- group.c v2 radix: per-(worker,partition) HTs and the phase-2 merge
tables initialized at cap 2 / block 1 and climbed the rehash ladder
to thousands of groups. Worker HTs start at the expected per-pair
row count (clamped [2,256]); merge tables size from the known
upper bound (sum of worker group counts); the fat-entry merge sizes
from its partition row count (capped 4096).
10M-row ClickBench: q17 582->249ms, q08 464->214ms, q13 185->104ms,
q22 87->73ms, q30/q31/q35/q42 -10..20%, rest unchanged; no query
slower. All 3681 tests pass.
* perf(group): prime fat-entry radix payload buffers to expected load
Same growth-ladder cost as the agg_engine payload buffers (68ace96c):
the fat-entry pipeline's per-(worker,partition) radix_buf grew
1->2->4->..., re-copying the whole scattered payload ~2x — 28% of
ClickBench q16 in memmove. First allocation now jumps to the
uniform-hash expected row count (both dispatch sites).
10M-row ClickBench: q16 161->122ms, q18 328->254ms; rest unchanged.
All 3681 tests pass.
* feat(store): decide hash-vs-zone indexes at csv->splayed conversion; fix load progress reset
Two loader issues:
- .csv.splayed persisted only chunk-zone (numeric) / dict (STR) indexes;
the hash-upgrade decision for high-entropy numeric columns existed only
in the in-memory .csv.read path, so a .db.splayed.get reload served
equality probes off a useless zone map (ClickBench q10: 71ms on-disk
vs 14ms in-memory). The chunk-zone entropy heuristic is now exported
(ray_csv_hash_upgrade_check) and the splayed index builder applies it
at conversion time, persisting a hash region instead of the zone for
random-shaped columns — the index decision is made once, on convert.
- CSV load progress jumped to 100% then reset to arbitrary fractions:
after the parse completes, the per-column chunk-zone/hash index builds
run pool dispatches whose totals are not n_rows, and the generic
progress pump displayed them. Suppress progress across the index
phase (same treatment the finalize dispatch already had) in both the
in-memory and splayed paths.
All 3681 tests pass.
* perf(group): route wide-domain SYM emit-filter groups to the parallel v2 engine
The single-SYM-key top-N-by-count shape (select ... by: symcol desc: c
take: N) ran through exec_group_sp_dyn_emit — a single-threaded dense
scatter whose array scales with the store's SHARED sym domain (splayed
stores keep one domain across all SYM columns, often 10M+ ids). That
serial pass was the Amdahl wall of the whole query family (ClickBench
q13: 88ms of a 110ms query in one thread's cache-miss loop; zero gain
from extra cores).
When the emit filter is active on a single SYM key whose domain exceeds
2^21 and v2 can handle the shape, run the parallel v2 engine with the
filter suppressed and trim its full result to the filter's keep set
(group_emit_filter_trim): quickselect the N-th value, keep ties — a
superset the DAG's downstream sort+take finalizes exactly as it would
an untrimmed result. Direction follows the v2_emit convention (unset
.desc on COUNT means largest-first).
Also adds a parallel per-worker dense-count fill inside sp_dyn for
bounded-key pure-count shapes (SYM small domains, narrow ints).
10M splayed store, 8 cores: q13 166->111ms with byte-identical results
across the q08/q13-16 family; all 3681 tests pass.
* perf(group): tighten emit-filter v2 rerouting gates
Two refinements from the 16-core A/B:
- Input-size gate on the v2 rerouting: raw-table inputs repeat keys in
consecutive rows, so sp_dyn's serial dense scatter mostly hits cache
and beats the radix pipeline (q33/q34: 56ms serial vs 148ms via v2).
Only the count-distinct second phase — a locality-free distinct-pairs
intermediate bounded by its distinct count — goes to v2 (<= 4M rows).
- The sp_dyn parallel dense-count fill excludes rowsel inputs: the
serial loop iterates selections segment-aware (whole SEL_NONE morsels
skipped), which beats a parallel per-row bitmap test on sparse
selections (q07: 2ms serial vs 6ms parallel).
Byte-identical results on q07/q13/q33; all 3681 tests pass.
* perf(agg): memset the phase-3 order map instead of a scalar -1 loop
* perf(agg): parallelize the phase-3 stable-order scatter and compaction
The order map (pairs[input_count]) was scattered into and stream-
compacted serially — an 80MB touch + random scatter + full linear scan
per 10M-row query, a flat-scaling wall on high-card groups (q17/q18).
Scatter now dispatches over partitions: each group's first_row is
globally unique, so writes are disjoint and race-free; the per-write
duplicate check moves to the aggregate ordered==ng check (a duplicate
overwrites one entry, the count comes up short, the same error fires).
Compaction runs as two chunk passes: parallel per-chunk non-empty
counts, a serial prefix over the chunk counts, and a parallel in-place
write at each chunk's prefix offset (forward-safe: a chunk's write
region never overlaps a later chunk's unread region, and within a chunk
dst <= src with ascending iteration). Inputs under 1M rows keep the
serial path.
Byte-identical results on q09/q13/q17/q18; all 3681 tests pass.
* perf(pool): default worker auto-sizing to physical cores, not SMT threads
The pool's kernels are memory-bound; hyperthread pairs sharing one
core's load/store machinery only contend. Measured on a 5950X
(16C/32T): the full ClickBench 43-query suite runs ~11% slower with 32
SMT threads than with the 16 physical cores (2175ms vs 1967ms sum) —
and auto-sizing previously picked ncpu-1 logical threads, i.e. the
worse configuration on every SMT machine, including benchmark runners
that use defaults.
ray_physical_core_count(): unique (package, core) pairs from sysfs on
Linux, hw.physicalcpu on macOS, logical-count fallback everywhere
topology is unreadable (and on Windows). Explicit -c N and
RAYFORCE_CORES are unaffected.
* perf(mem): reuse cache for direct (large) allocations
Analytical queries allocate the same few large scratch blocks every run
(the phase-3 order map alone is input_count*8 = 80MB on a 10M-row group)
and handed them straight back to the kernel — mmap + munmap + full page
re-zeroing on the next query's faults.
Keep a small global stash (16 slots, 512MB budget, RAY_DIRECT_CACHE_MB
overrides, 0 disables) of freed ANON direct blocks and serve size-matched
requests from it (first fit within need + max(need/4, 2MB)). Cached
blocks keep their committed-RAM and watermark accounting — their pages
stay resident — and the cache drains before any new commit would push
past the anon watermark. File-backed spill blocks are never cached.
Reused blocks get the same 32-byte header re-init as slab reuse; no
caller assumes zeroed data from ray_alloc (both calloc wrappers memset).
ClickBench q17 warm min-of-6, same config: 255ms -> 200ms (-22%).
All 3681 tests pass.
* fix(mem): public drain for the direct reuse cache + watermark test contract
The anon-watermark test asserts committed-RAM returns to baseline after
free and that the second over-watermark alloc spills — both broke under
the reuse cache (212fff3a): a freed block stays resident+counted when
stashed, and leftover cached blocks from earlier tests were drained by
the pressure path, freeing headroom that let the second alloc stay
anonymous. Expose ray_heap_direct_cache_drain() and have the test drain
before its baseline and before its final assertion.
NOTE: 212fff3a was pushed with this test failing — a rfl/system/part PASS 48.90 ms
rfl/system/querylog_ipc PASS 7.25 ms
rfl/system/ipc_open_timeout PASS 2.78 ms
rfl/system/db_parted_fill PASS 15.77 ms
rfl/system/system_branch_cov2 PASS 3.41 ms
rfl/system/sys_prof PASS 0.40 ms
rfl/system/splayed PASS 5.13 ms
rfl/system/serde PASS 0.67 ms
=== 3681 of 3681 passed (0 skipped, 0 failed) ===
pipeline swallowed the exit status. All 3681 tests pass now.
* refactor(mem): derive the direct-cache budget instead of an env knob
Drop RAY_DIRECT_CACHE_MB: the reuse cache's budget is now 1/16 of the
anon watermark (physical RAM by default, the -m budget when set), capped
at 512MB. The cache is invisible to correctness and self-drains under
pressure, so it needs no operator-facing switch; tests and explicit
trimming use ray_heap_direct_cache_drain().
* perf(mem): raise the direct-cache cap to 4GB for 100M-row scale
At 100M rows a single query's phase-3 order map is ~800MB; the 512MB
cap excluded exactly the blocks whose per-query mmap + kernel zeroing
cost the most, so the reuse cache's benefit vanished at the scale that
needs it. The watermark/16 fraction remains the binding limit on small
machines.
* perf(group): pipelined slot prefetch in the v2 direct-insert phase1
The narrow-key count-only group build (the q13-q18 high-cardinality
class) stalls on two dependent cache misses per row: the HT slot line,
then the group row. Stage a small ring (8) of pre-built entries and
prefetch each entry's slot line at stage time, so misses overlap across
iterations. Entry layout, hashing, probe and merge semantics are
identical to the generic loop — a scheduling change only; wide/inline
STR keys, nullable keys and value-carrying entries stay on the generic
loop.
10M splayed, 8 threads: q17 253->185ms, q15 150->139ms. Byte-identical
results (q13/q15/q16/q17); all 3681 tests pass.
* test(group): pin high-cardinality radix_v2 group shapes before partition-major rework
* fix(test): cast I32 key column to ensure radix_v2 path exercises I32 group keys
* perf(group): partition-major morsel batching in radix_v2 phase1
Stage 1024-row morsels (hash+partition, no HT access), counting-sort by
partition, probe each partition's run back-to-back with slot prefetch —
HT metadata and slot lines stay cache-hot within a run. Replaces the
per-row pipelined ring; same hashing/entry/probe/merge, iteration order
only. Eligibility unchanged (narrow null-free count-only entries).
* perf(group): sparse touched-partition histogram + u32 partition ids in morsel batching
* feat(group): fused grouped count-distinct kernel (unwired)
Single-pass partition-by-key dedupe+count for (count (distinct v)) by: k
— no pairs-table intermediate, no second group pipeline. C-level unit
tests; not yet reachable from queries.
* fix(cdfuse): guard dropped dispatch tasks; strengthen fused-cd tests
* perf(query): route single-key unfiltered count-distinct through the fused kernel
Wire ray_cd_fused (Task 3) into try_count_distinct_v2_rewrite
(src/ops/query.c:3661) — the live count-distinct route for
`(select {K: K c: (count (distinct X)) from: T by: K ...})`, confirmed
by gdb for the 10M ClickBench q08. Gated on a single by-key, no
where:, and ray_pool_par_dispatch_ok (the kernel dispatches
unconditionally and the pool is single-producer, so it must never run
from a worker thread; that helper also carries the CDF_MIN_ROWS floor).
The kernel's (k, u, _first) table is rebuilt as a 2-column result: the
key column is re-emitted at the source column's own type/width via
col_vec_new (+ sym domain adoption, so SYM keys still render as text)
and _first — an internal ordering artifact — is dropped. NULL from
the kernel is never an error: the two-pass rewrite below runs unchanged.
Byte-identical rendered output vs pre-change binary on the 10M store
for q08, q11, a SYM-key count-distinct, and the where:-filtered and
multi-key decline shapes. make test 3686/3686 at TEST_CORES=2 and 8.
* perf(cdfuse): pair-hash partitioning with additive per-key merge (skew-proof)
* perf(cdfuse): parallel key-bucketed merge phase
* fix(cdfuse): asymmetric pair-hash combine (k==v cancellation cliff)
* fix(cdfuse): memory admission gate, SYM + boundary coverage, self-contained dispatch guard
* perf(mem): drop the direct-cache absolute cap; watermark/16 is the bound
Both prior absolute caps (512MB, 4GB) excluded exactly the blocks whose
kernel re-zeroing cost the most at the next data scale: a 100M-row group
query cycles several GB-scale scratch blocks per execution (order map
~800MB, per-partition gather arrays ~1.6GB, result columns ~500MB), and
every cache miss is an mmap + munmap + page-zeroing round trip —
measured 14-17% of q17/q18 wall as kernel_init_pages. The watermark
fraction alone keeps small machines bounded (1GB on 16GB RAM).
* perf(query): push a positive take: into grouped DAGs and bound v2's emit
ClickBench q17 — `select {c: (count UserID)} by: {UserID SearchPhrase}
take: 10` — materialized all ~21M groups before the take was applied:
the take reached only the post-execution apply_sort_take, so the group
paid a full input-sized order map, a full key unpack and a full finalize
to produce 10 rows.
Two coordinated changes:
1. query.c pushes a take: into the grouped DAG as HEAD(GROUP) when it is
a POSITIVE integer atom, there is no asc:/desc:, no nearest:, no
deferred post-group WHERE, and no BOOL group key. Groups are emitted
in stable first-seen order, so "first N rows" is exactly "first N
groups". exec.c's HEAD(GROUP) fusion then forwards N to exec_group as
the group_limit HINT. Negative (tail) and range takes, sorted shapes
(which own the desc+take emit-filter machinery), the deferred-WHERE
shape (it drops rows AFTER the group) and BOOL keys (reordered to
first-occurrence AFTER execution) are all excluded.
2. The hint must not knock the query off the v2 engine: exec_group_run's
two `group_limit == 0` v2 gates now accept `group_limit >= 0` and
thread the limit through exec_group_v2 / _run down to the parallel
radix strategy. There, when the limit is smaller than the group
count, agg_radix_select_first_n replaces the input-sized order map
(alloc + 0xFF memset + scatter + compact — 80MB of traffic at 10M
rows) with an N-sized max-heap over the partitions' first_row values:
the N smallest first_rows ARE the first N first-seen groups. The
key-unpack and finalize loops then run over N rows instead of ng.
With no limit (or ng <= limit) the existing full path runs unchanged.
The selection helper is noinline: inlined, its code perturbed the hot
radix body enough to cost ~9% on unrelated grouped queries (q12/q33/q34).
10M ClickBench, -c 4 -t 1, min of 5: q17 196ms -> 96ms (2.05x). Output
byte-identical to the previous binary on q13/q15/q16/q17/q18 (q32/q21/
q31/q40 are tie-broken nondeterministically by BOTH binaries). Nothing
else regressed beyond code-alignment noise (q15's ~6% reproduces on
pristine HEAD with an unrelated dummy function added to agg_engine.c).
* fix(query): guard the grouped take: pushdown and trim LIST columns
Review follow-ups to the HEAD(GROUP) take: pushdown.
C1 — parted inputs. exec_group_parted reads a positive group_limit as
"stop after group_limit partitions", which holds only if every partition
yields a group; an EMPTY partition under-fills the answer. The pushdown
now skips any table carrying PARTED/MAPCOMMON columns (table_is_parted),
so the hint never reaches that path from a grouped take.
C2 — LIST-producing aggregates (top/bot). Two independent fixes:
(i) the pushdown skips a group whose aggregates emit LIST columns
(has_list_agg, set where the planner resolves OP_TOP_N/OP_BOT_N);
(ii) exec.c's OP_HEAD/OP_TAIL trims — table AND vector forms — gained a
RAY_LIST case that copies element pointers WITH a retain
(list_slice_retain). The raw byte copy could not build a LIST at
all (col_vec_new rejects it), so the column came back NULL and the
query died with "table add_col: column must be a vector". That was
reachable WITHOUT any group-by — `select {a b} from: L take: 2`
over a table with a LIST column failed before this commit — and
aliasing cells without a refcount would double-free (issue #355,
same reason exec_filter_head gathers LIST columns separately).
I1 — take: is evaluated once again. apply_sort_take takes an optional
pre-evaluated value; the grouped path passes the value it already
computed for the pushdown decision instead of letting apply_sort_take
re-evaluate a possibly side-effecting expression.
I2 — sg_shape_eligible no longer bails on a non-zero group_limit. The
slice-group kernel computes every group and the caller's HEAD trims, so
the limit is a hint there too; bailing dropped where+by+take shapes onto
the generic ladder for no reason.
Tests: highcard_group.rfl gains the LIST-agg take shapes, the plain
HEAD/TAIL-over-LIST-column repro, a take:-evaluated-once counter, and a
parted store with an empty partition under grouped takes.
* fix(query): pin the parted take guard, drop the take_pre allocation
Round-2 review follow-ups.
1. The parted regression test used 3 rows per partition, below
exec_group_parted's cardinality gate (est_groups*100 > rows_per_part),
so it stayed on the concat fallback and never consulted group_limit —
it passed even with the bug present. Regrown to 1000 rows across 5
partitions with partition 2 empty, it now reproduces: on f1aaa736
`by: part take: 2` answers [1] instead of [1 3] and take: 3 answers
[1 3] instead of [1 3 4]. Fails there, passes here.
2. apply_sort_take's pre-evaluated take: parameter is now a DECODED
take_pre_t {kind, a, b} stack struct instead of a retained ray_t*.
The owned pointer would have leaked on the ~24 early-return error and
OOM paths between the pushdown site and the final apply_sort_take
call; a stack value cannot. The take: expression is still evaluated
exactly once — apply_sort_take re-materializes the value locally.
3. .superpowers/ (working notes) was swept into the previous commit by
`git add -A`; untracked here, kept on disk, and ignored from now on.
* perf(group): packed narrow-key fast path in the fat-entry radix pipeline
When every group key is a plain fixed-width integer lane (no GUID/STR
indirection, no F64), the fat-entry pipeline now treats the key tuple as
one packed value. Four changes, all measured on the 10M on-disk splayed
ClickBench store:
- ght_lanes_copy/ght_lanes_equal: key regions are a whole number of
8-byte lanes, so <=8 lanes copy/compare through straight u64
loads/stores instead of libc calls that re-dispatch on a runtime
size. A dwarf call-graph profile put 14.6% of q18 in __memmove_avx,
all of it these 24-32 byte moves in radix_buf_push and
group_probe_entry.
- Null-mask elision: the mask words only keep a NULL key distinct from a
0/"" key, so they are elided when every key column is provably
null-free. ght_compute_layout takes the key vectors to decide;
pivot.c passes NULL and keeps them. The two row-side readers are
guarded (ght_null_words_at substitutes a shared zero word).
- OP_COUNT reserves no entry value slot: it is group size and its emit
reads the row count, never a staged value. The phase-1 agg packers
now index by the layout's agg_val_slot instead of a running counter,
so a valueless agg cannot shift a later agg's slot.
Together these take q18's fat entry from 56 to 40 bytes, q16's from
48 to 32.
- ght_hash_lanes: one wymum/wymix avalanche over the whole key region
instead of nk hashes plus nk-1 combines (q18: 3 multiplies, not 10).
Partition bits (hash>>16) and slot bits (hash&mask) stay independent.
All seven key builders, including hash_keys_inline used by
rehash/merge/lookup, converted together.
- radix_phase1_fn stages packed null-free tuples column-major over a
256-row morsel, hoisting read_col_i64's per-key jump-table dispatch
(~6% of q18 in mispredicted indirect jumps) out of the row loop.
Row order, entry bytes, hash and partition are unchanged.
Every other key shape (STR/GUID/F64/nullable/wide) keeps its existing
loop verbatim.
10M splayed store, min-of-7, back-to-back A/B vs 2dd36e9b:
q16 121.6 -> 87.5 ms (-28.1%), q18 209.9 -> 173.3 ms (-17.4%),
q15 159.7 -> 132.9 ms (-16.8%), q33/q34 -4%,
q13 -0.6%, q17 -0.5% (no regressions).
Rendered output of q13 q15 q16 q17 q18 q33 q34 at 10M is byte-identical
to base (md5 match).
Report: .superpowers/q18-packedkey-report.md
* fix(group): restore packed-key hash lockstep in the fused radix path
Review follow-ups to 5784c197.
1. radix_v2_phase1_fn's partition-major morsel path was DEAD in base: its
`null_words == 0` gate was unsatisfiable because null_words was floored
at 1. The null-mask elision made it reachable, and it still staged a
per-key ray_hash_i64 + ray_hash_combine hash while hash_keys_inline —
used by group_ht_rehash, group_ht_rebuild_slots and group_merge_row —
now returns ght_hash_lanes for the same layout. After a worker HT
rehashed, phase 1 probed with the stale hash, missed, and inserted a
DUPLICATE group row; phase 2's merge folded the duplicates so answers
stayed correct, which is exactly why a result diff could not catch it.
Now hashes through ght_hash_lanes for packed layouts (and skips the
dead per-key mixing). Measured with a temporary in-HT duplicate
detector on a 2.5M-row 2-key count-only query over 306 worker HTs:
4,539,688 mis-probed rows before, 0 after.
2. Packed eligibility is now a whitelist of the types read_col_i64
decodes deliberately (BOOL/U8/I16/I32/I64/DATE/TIME/TIMESTAMP/SYM)
instead of `!= RAY_F64`. read_col_i64's default arm reads one byte,
so an un-enumerated fixed-width type (RAY_F32) would have been
silently mis-loaded and hashed as the key identity.
3. pivot.c: guard the off_nn index with `s >= 0` the way group.c's emit
paths do (safe today only by an off_nn coincidence), and record why
pivot must keep passing key_vecs = NULL to ght_compute_layout — its
ingest reads the key region's null word raw and needs null_words >= 1.
4. ght_lanes_copy/ght_lanes_equal assert their whole-lane precondition
under DEBUG/RAY_HARDENED (what `make test` builds), free in release.
5. group_packed_key.rfl reworked: the 300K sizing never reached the code
under test — instrumentation showed the morsel stager was never
executed. Key cases are now 2.5M rows with the emit filter armed, and
each was verified with temporary probes to reach a specific builder:
morsel stager (nk=2 nv=0 contiguous, nk=2 contig=0 indexed gather,
nk=3 nv=2), the packed row-major builder (nullable, null_words=1), and
the fused v2 morsel with packed=1 (the regression case for #1) and
packed=0. Also restores the F64 ±0.0 case in a build-independent form
and records the finding: the group count for [0.0, -0.0, 1.0] differs
between release (-fno-signed-zeros folds -0.0, 2 groups) and debug
(3 groups, because group_keys_equal compares raw bits while
ray_hash_f64 normalises) — pre-existing, F64 is not packed, filed
separately. Fixes the false "past the 2-lane / 16-byte packing width"
comment: eligibility is per-key type, with no tuple-width cap.
10M splayed store, min-of-7, back-to-back A/B vs 2dd36e9b:
q16 122.1 -> 88.7 ms (-27.4%), q18 209.6 -> 177.5 ms (-15.3%),
q15 -14.0%, q33 -3.5%, q34 -1.6%, q13 +0.4%, q17 -0.8%.
Rendered output of q13 q15 q16 q17 q18 q33 q34 at 10M remains
byte-identical to base (md5 22e6fe0cd8f0eacc5c3f5b48c207b8e9).
Report: .superpowers/q18-packedkey-report.md
* test(group): pin F64 packed-key exclusion; honest 9-key test comment
* perf(group): prefetched partition-major probe for COUNT/SUM/AVG near-unique group-bys
ClickBench q32 (2 narrow keys, count+sum+avg, ~1 group per row) spent 32% of
its runtime on a single instruction: the ht->slots[slot] load in
radix_v2_phase1_fn. At 10M rows it builds 8 workers x 4096 partitions = 32768
worker hash tables (~134 MB of slot arrays); consecutive rows hit different
partitions, so nothing is cache-resident and nothing is prefetched.
The fix is ordering, not fewer instructions:
* radix_v2_phase1_fn's partition-major morsel path — which already stages a
1024-row morsel, counting-sorts it by partition and probes with an 8-entry
slot-line prefetch — was gated on need_flags == 0 (count-only). It now also
serves GHT_NEED_SUM, the only other need-flag the v2 pipeline can see
(exec_group_run admits only COUNT/SUM/AVG; group_merge_row supports exactly
{0, SUM}). Agg inputs are read at probe time from the staged source row, so
the morsel arrays and the entry bytes are unchanged. The ght_hash_lanes
lockstep branch in the staging loop is untouched.
Results are preserved even for f64 SUM: the counting sort is stable and a
group lives entirely inside one partition, so rows of a group are still
visited in source order; only the interleaving between partitions changes.
* The agg-packing block is factored into radix_v2_pack_aggs, shared by the
morsel-staged and row-major builders so they cannot drift.
* group_ht_t gains an optional one-shot grow_cap (slot-count target, 0 = plain
doubling). Near-unique worker tables climbed 256 -> 512 -> 1024, re-hashing
and re-inserting every live group on each rung (386 re-inserts against 305
real inserts on q32). radix_v2_phase1_fn seeds it from 2*v2_exp, the
existing per-(worker,partition) row budget at the 50% rehash load factor —
no new tunable. Applied at first GROWTH rather than first allocation: a
table that grows has proven it is near-unique, whereas pre-sizing every
table cost q15 (density ~0.25) 5%.
* Makefile: -falign-functions=64 in RELEASE_CFLAGS. Not an optimisation —
without it, adding these ~120 lines pushed exec_group_sp_dyn_emit (45% of
q33/q34, a path this change never executes) off a cache line and cost those
queries a reproducible 14%. That noise floor is larger than most real
optimisations. With the flag on both sides the artefact disappears.
10M splayed store, min-of-5, three interleaved CAND/BASE reps:
q32 644.6 -> 419.8 ms -34.9%
q31 102.3 -> 87.1 ms -14.9%
q15 140.2 -> 135.8 ms -3.2%
q13/q14/q16/q17/q18/q33/q34 all within 1%
Byte-identical on q13 q15 q16 q17 q18 q33 q34. Across all 43 queries only
q21/q31/q32/q39 differ, all inside all-tied `desc take 10` blocks; the
unmodified base binary produces different tie selections at different thread
counts for all four (q31 needs -t 5 to show it).
Test: group_packed_key.rfl gains a near-unique 2.5M-row count+sum+avg case
with desc/take; instrumentation confirms it executes both new paths
(need_flags=1 morsel stager; a 256->1024 grow_cap jump).
Full report: .superpowers/q32-unique-groups-report.md
* fix(group): bound the grow_cap jump and keep it off the row array
Review of 16bd46dd: grow_cap was applied in full on the strength of a single
rehash, on the premise that a growing table has "proven it is near-unique".
That premise is false — a rehash only proves the table just crossed ht_cap/2
groups, a lower bound on its cardinality. Since ht_init_cap is clamped to 256,
ANY table crossing 129 groups took the whole row-derived ceiling (4096 slots +
2048 rows at 10M), making worker-HT memory O(rows) instead of O(groups).
Measured on 10M-row 2-key in-memory shapes (the ClickBench single-key
group-bys route away from the v2 pipeline on a splayed store, so they never
showed it). Worker-HT bytes, summed over all 32768 tables:
count-only, 2.56M groups base 501 MB -> v1 640 MB (+28%) -> now 512 MB
count+sum+avg, 1.30M grps base 448 MB -> v1 896 MB (+100%) -> now 512 MB
count+sum+avg, near-uniq base 893 MB -> v1 896 MB (+0%) -> now 891 MB
Peak RSS (/usr/bin/time -f %M) on the middle shape at -c 2: 812 -> 976 -> 812.
Two bounds:
* group_ht_rehash skips ahead at most ONE extra doubling per rehash:
max(ht_cap*2, min(grow_cap, ht_cap*4)). A table that stalls right after a
jump now over-allocates its slot array by 2x and nothing else.
* group_ht_grow no longer consults grow_cap at all. The row array is where
nearly all the bytes are (row_stride 24-48 B vs a slot's 4 B) and growing it…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary:
Tests: