Skip to content

fix(xbar): avoid narrow bucket truncation - #351

Merged
singaraiona merged 1 commit into
RayforceDB:devfrom
belowzeroff:fix/xbar-narrow-bucket-overflow
Jul 28, 2026
Merged

fix(xbar): avoid narrow bucket truncation#351
singaraiona merged 1 commit into
RayforceDB:devfrom
belowzeroff:fix/xbar-narrow-bucket-overflow

Conversation

@belowzeroff

Copy link
Copy Markdown
Contributor

Summary:

  • guard xbar vector fast path against I64 buckets that do not fit narrow integer/temporal vector storage
  • fall back to generic atomic mapping so wide buckets produce widened I64 results instead of truncating to zero
  • add Rayfall regression coverage for I32 and I16 wide buckets

Tests:

  • make rayforce
  • make test TEST_CORES=2
  • git diff --check

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 xbar vectorized fast path to avoid narrowing/truncation for I16/I32/DATE/TIME vectors.
  • Fall back to atomic_map_binary(ray_xbar_fn, ...) when the bucket cannot be represented in the vector’s element storage type, producing widened I64 outputs.
  • Add regression tests covering wide-bucket behavior for I32 and I16 vectors.

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
singaraiona merged commit 57628fb into RayforceDB:dev Jul 28, 2026
9 checks passed
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…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants